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);
}
}
@@ -0,0 +1,30 @@
namespace LiveRecorder.Domain.Entities;
public class AppSetting
{
private AppSetting()
{
}
public AppSetting(string key, string value, DateTimeOffset updatedAt)
{
Id = Guid.NewGuid();
Key = key;
Value = value;
UpdatedAt = updatedAt;
}
public Guid Id { get; private set; }
public string Key { get; private set; } = string.Empty;
public string Value { get; private set; } = string.Empty;
public DateTimeOffset UpdatedAt { get; private set; }
public void Update(string value, DateTimeOffset updatedAt)
{
Value = value;
UpdatedAt = updatedAt;
}
}
@@ -0,0 +1,103 @@
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Domain.Entities;
public class LiveRoom
{
private LiveRoom()
{
}
public LiveRoom(
LivePlatformType platform,
string sourceUrl,
string roomId,
string normalizedUrl,
DateTimeOffset createdAt)
{
Id = Guid.NewGuid();
Platform = platform;
SourceUrl = sourceUrl;
RoomId = roomId;
NormalizedUrl = normalizedUrl;
IsEnabled = true;
AvailabilityStatus = LiveRoomAvailabilityStatus.Unknown;
CreatedAt = createdAt;
UpdatedAt = createdAt;
}
public Guid Id { get; private set; }
public LivePlatformType Platform { get; private set; }
public string SourceUrl { get; private set; } = string.Empty;
public string RoomId { get; private set; } = string.Empty;
public string NormalizedUrl { get; private set; } = string.Empty;
public string? Title { get; private set; }
public string? AnchorName { get; private set; }
public string? CoverUrl { get; private set; }
public bool IsEnabled { get; private set; }
public bool HasSentLiveNotificationForCurrentSession { get; private set; }
public LiveRoomAvailabilityStatus AvailabilityStatus { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
public DateTimeOffset UpdatedAt { get; private set; }
public DateTimeOffset? LastCheckedAt { get; private set; }
public ICollection<RecordTask> RecordTasks { get; private set; } = new List<RecordTask>();
public void UpdateSource(string sourceUrl, string normalizedUrl, DateTimeOffset updatedAt)
{
SourceUrl = sourceUrl;
NormalizedUrl = normalizedUrl;
UpdatedAt = updatedAt;
}
public void UpdateRoomId(string roomId, DateTimeOffset updatedAt)
{
RoomId = roomId;
UpdatedAt = updatedAt;
}
public void UpdateMetadata(string? title, string? anchorName, string? coverUrl, DateTimeOffset updatedAt)
{
Title = title;
AnchorName = anchorName;
CoverUrl = coverUrl;
UpdatedAt = updatedAt;
}
public void UpdateAvailability(LiveRoomAvailabilityStatus availabilityStatus, DateTimeOffset checkedAt)
{
AvailabilityStatus = availabilityStatus;
if (availabilityStatus != LiveRoomAvailabilityStatus.Live)
{
HasSentLiveNotificationForCurrentSession = false;
}
LastCheckedAt = checkedAt;
UpdatedAt = checkedAt;
}
public void SetEnabled(bool isEnabled, DateTimeOffset updatedAt)
{
IsEnabled = isEnabled;
UpdatedAt = updatedAt;
}
public void MarkLiveNotificationSent(DateTimeOffset updatedAt)
{
HasSentLiveNotificationForCurrentSession = true;
UpdatedAt = updatedAt;
}
}
@@ -0,0 +1,73 @@
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Domain.Entities;
public class RecordResult
{
private RecordResult()
{
}
public RecordResult(
Guid recordTaskId,
string filePath,
long? fileSizeBytes,
double? durationSeconds,
string? danmakuFilePath,
int danmakuMessageCount,
RecordTaskStatus finalStatus,
string? errorMessage,
DateTimeOffset createdAt)
{
Id = Guid.NewGuid();
RecordTaskId = recordTaskId;
FilePath = filePath;
FileSizeBytes = fileSizeBytes;
DurationSeconds = durationSeconds;
DanmakuFilePath = danmakuFilePath;
DanmakuMessageCount = Math.Max(0, danmakuMessageCount);
FinalStatus = finalStatus;
ErrorMessage = errorMessage;
CreatedAt = createdAt;
}
public Guid Id { get; private set; }
public Guid RecordTaskId { get; private set; }
public RecordTask? RecordTask { get; private set; }
public string FilePath { get; private set; } = string.Empty;
public long? FileSizeBytes { get; private set; }
public double? DurationSeconds { get; private set; }
public string? DanmakuFilePath { get; private set; }
public int DanmakuMessageCount { get; private set; }
public RecordTaskStatus FinalStatus { get; private set; }
public string? ErrorMessage { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
public void Update(
string filePath,
long? fileSizeBytes,
double? durationSeconds,
string? danmakuFilePath,
int danmakuMessageCount,
RecordTaskStatus finalStatus,
string? errorMessage)
{
FilePath = filePath;
FileSizeBytes = fileSizeBytes;
DurationSeconds = durationSeconds;
DanmakuFilePath = danmakuFilePath;
DanmakuMessageCount = Math.Max(0, danmakuMessageCount);
FinalStatus = finalStatus;
ErrorMessage = errorMessage;
}
}
@@ -0,0 +1,140 @@
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Domain.Entities;
public class RecordSession
{
private RecordSession()
{
}
public RecordSession(
Guid liveRoomId,
string preferredQuality,
RecordOutputFormat outputFormat,
RecordSaveMode saveMode,
DateTimeOffset createdAt)
{
Id = Guid.NewGuid();
LiveRoomId = liveRoomId;
PreferredQuality = preferredQuality;
OutputFormat = outputFormat;
SaveMode = saveMode;
Status = RecordSessionStatus.Pending;
ActiveSegmentIndex = 0;
SegmentCount = 0;
CreatedAt = createdAt;
UpdatedAt = createdAt;
}
public Guid Id { get; private set; }
public Guid LiveRoomId { get; private set; }
public LiveRoom? LiveRoom { get; private set; }
public RecordSessionStatus Status { get; private set; }
public string PreferredQuality { get; private set; } = "origin";
public RecordOutputFormat OutputFormat { get; private set; } = RecordOutputFormat.Mp4;
public RecordSaveMode SaveMode { get; private set; } = RecordSaveMode.SingleFile;
public string? StreamUrl { get; private set; }
public string? OutputPathPattern { get; private set; }
public int ActiveSegmentIndex { get; private set; }
public int SegmentCount { get; private set; }
public int? RecorderProcessId { get; private set; }
public string? ErrorMessage { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
public DateTimeOffset UpdatedAt { get; private set; }
public DateTimeOffset? StartedAt { get; private set; }
public DateTimeOffset? EndedAt { get; private set; }
public ICollection<RecordTask> RecordTasks { get; private set; } = new List<RecordTask>();
public void MarkStarting(string streamUrl, string outputPathPattern, DateTimeOffset startedAt)
{
StreamUrl = streamUrl;
OutputPathPattern = outputPathPattern;
StartedAt = startedAt;
Status = RecordSessionStatus.Starting;
ErrorMessage = null;
UpdatedAt = startedAt;
}
public void MarkRunning(DateTimeOffset updatedAt)
{
Status = RecordSessionStatus.Running;
StartedAt ??= updatedAt;
ErrorMessage = null;
UpdatedAt = updatedAt;
}
public void MarkStopping(DateTimeOffset updatedAt)
{
Status = RecordSessionStatus.Stopping;
UpdatedAt = updatedAt;
}
public void MarkCompleted(DateTimeOffset endedAt)
{
Status = RecordSessionStatus.Completed;
EndedAt = endedAt;
RecorderProcessId = null;
ErrorMessage = null;
UpdatedAt = endedAt;
}
public void MarkStopped(DateTimeOffset endedAt, string? errorMessage = null)
{
Status = RecordSessionStatus.Stopped;
EndedAt = endedAt;
RecorderProcessId = null;
ErrorMessage = errorMessage;
UpdatedAt = endedAt;
}
public void MarkFailed(string errorMessage, DateTimeOffset endedAt)
{
Status = RecordSessionStatus.Failed;
ErrorMessage = errorMessage;
EndedAt = endedAt;
RecorderProcessId = null;
UpdatedAt = endedAt;
}
public void AttachProcess(int processId, DateTimeOffset updatedAt)
{
RecorderProcessId = processId;
UpdatedAt = updatedAt;
}
public void ActivateSegment(int segmentIndex, DateTimeOffset updatedAt)
{
ActiveSegmentIndex = Math.Max(1, segmentIndex);
SegmentCount = Math.Max(SegmentCount, ActiveSegmentIndex);
UpdatedAt = updatedAt;
}
public void SyncSegmentCount(int segmentCount, DateTimeOffset updatedAt)
{
SegmentCount = Math.Max(0, segmentCount);
if (ActiveSegmentIndex > SegmentCount)
{
ActiveSegmentIndex = SegmentCount;
}
UpdatedAt = updatedAt;
}
}
@@ -0,0 +1,140 @@
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Domain.Entities;
public class RecordTask
{
private RecordTask()
{
}
public RecordTask(
Guid liveRoomId,
Guid recordSessionId,
int segmentIndex,
string preferredQuality,
RecordOutputFormat outputFormat,
DateTimeOffset createdAt)
{
Id = Guid.NewGuid();
LiveRoomId = liveRoomId;
RecordSessionId = recordSessionId;
SegmentIndex = Math.Max(1, segmentIndex);
PreferredQuality = preferredQuality;
OutputFormat = outputFormat;
Status = RecordTaskStatus.Pending;
CreatedAt = createdAt;
UpdatedAt = createdAt;
}
public Guid Id { get; private set; }
public Guid LiveRoomId { get; private set; }
public LiveRoom? LiveRoom { get; private set; }
public Guid RecordSessionId { get; private set; }
public RecordSession? RecordSession { get; private set; }
public int SegmentIndex { get; private set; }
public RecordTaskStatus Status { get; private set; }
public string PreferredQuality { get; private set; } = "origin";
public RecordOutputFormat OutputFormat { get; private set; } = RecordOutputFormat.Mp4;
public string? StreamUrl { get; private set; }
public string? OutputFilePath { get; private set; }
public int? RecorderProcessId { get; private set; }
public string? ErrorMessage { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
public DateTimeOffset UpdatedAt { get; private set; }
public DateTimeOffset? StartedAt { get; private set; }
public DateTimeOffset? EndedAt { get; private set; }
public double? DurationSeconds { get; private set; }
public RecordResult? Result { get; private set; }
public void AssignToSession(Guid recordSessionId, int segmentIndex, DateTimeOffset updatedAt)
{
RecordSessionId = recordSessionId;
SegmentIndex = Math.Max(1, segmentIndex);
UpdatedAt = updatedAt;
}
public void MarkStarting(string streamUrl, string outputFilePath, DateTimeOffset startedAt)
{
StreamUrl = streamUrl;
OutputFilePath = outputFilePath;
StartedAt = startedAt;
Status = RecordTaskStatus.Starting;
ErrorMessage = null;
UpdatedAt = startedAt;
}
public void AttachProcess(int processId, DateTimeOffset updatedAt)
{
RecorderProcessId = processId;
UpdatedAt = updatedAt;
}
public void DetachProcess(DateTimeOffset updatedAt)
{
RecorderProcessId = null;
UpdatedAt = updatedAt;
}
public void MarkRunning(DateTimeOffset updatedAt)
{
Status = RecordTaskStatus.Running;
StartedAt ??= updatedAt;
ErrorMessage = null;
UpdatedAt = updatedAt;
}
public void MarkStopping(DateTimeOffset updatedAt)
{
Status = RecordTaskStatus.Stopping;
UpdatedAt = updatedAt;
}
public void MarkCompleted(DateTimeOffset endedAt, double? durationSeconds)
{
Status = RecordTaskStatus.Completed;
EndedAt = endedAt;
DurationSeconds = durationSeconds;
ErrorMessage = null;
RecorderProcessId = null;
UpdatedAt = endedAt;
}
public void MarkStopped(DateTimeOffset endedAt, double? durationSeconds, string? errorMessage = null)
{
Status = RecordTaskStatus.Stopped;
EndedAt = endedAt;
DurationSeconds = durationSeconds;
ErrorMessage = errorMessage;
RecorderProcessId = null;
UpdatedAt = endedAt;
}
public void MarkFailed(string errorMessage, DateTimeOffset endedAt)
{
Status = RecordTaskStatus.Failed;
ErrorMessage = errorMessage;
EndedAt = endedAt;
DurationSeconds = StartedAt.HasValue ? Math.Max(0, (endedAt - StartedAt.Value).TotalSeconds) : null;
RecorderProcessId = null;
UpdatedAt = endedAt;
}
}
@@ -0,0 +1,49 @@
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Domain.Entities;
public class SystemLogEntry
{
private SystemLogEntry()
{
}
public SystemLogEntry(
SystemLogLevel level,
string category,
string message,
string? detail,
Guid? liveRoomId,
Guid? recordSessionId,
Guid? recordTaskId,
DateTimeOffset createdAt)
{
Id = Guid.NewGuid();
Level = level;
Category = category;
Message = message;
Detail = detail;
LiveRoomId = liveRoomId;
RecordSessionId = recordSessionId;
RecordTaskId = recordTaskId;
CreatedAt = createdAt;
}
public Guid Id { get; private set; }
public SystemLogLevel Level { get; private set; }
public string Category { get; private set; } = string.Empty;
public string Message { get; private set; } = string.Empty;
public string? Detail { get; private set; }
public Guid? LiveRoomId { get; private set; }
public Guid? RecordSessionId { get; private set; }
public Guid? RecordTaskId { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
}
@@ -0,0 +1,35 @@
namespace LiveRecorder.Domain.Entities;
public class UserAccount
{
private UserAccount()
{
}
public UserAccount(string username, string displayName, string passwordHash, DateTimeOffset createdAt)
{
Id = Guid.NewGuid();
Username = username;
DisplayName = displayName;
PasswordHash = passwordHash;
IsActive = true;
CreatedAt = createdAt;
}
public Guid Id { get; private set; }
public string Username { get; private set; } = string.Empty;
public string DisplayName { get; private set; } = string.Empty;
public string PasswordHash { get; private set; } = string.Empty;
public bool IsActive { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
public void UpdatePassword(string passwordHash)
{
PasswordHash = passwordHash;
}
}
@@ -0,0 +1,38 @@
namespace LiveRecorder.Domain.Entities;
public class UserSession
{
private UserSession()
{
}
public UserSession(Guid userAccountId, string token, DateTimeOffset expiresAt, DateTimeOffset createdAt)
{
Id = Guid.NewGuid();
UserAccountId = userAccountId;
Token = token;
ExpiresAt = expiresAt;
CreatedAt = createdAt;
}
public Guid Id { get; private set; }
public Guid UserAccountId { get; private set; }
public UserAccount? UserAccount { get; private set; }
public string Token { get; private set; } = string.Empty;
public DateTimeOffset ExpiresAt { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
public DateTimeOffset? RevokedAt { get; private set; }
public bool IsValid(DateTimeOffset now) => RevokedAt is null && ExpiresAt > now;
public void Revoke(DateTimeOffset revokedAt)
{
RevokedAt = revokedAt;
}
}
@@ -0,0 +1,11 @@
namespace LiveRecorder.Domain.Enums;
public enum LivePlatformType
{
Unknown = 0,
Douyin = 1,
Bilibili = 2,
Huya = 3,
Douyu = 4,
Kuaishou = 5
}
@@ -0,0 +1,8 @@
namespace LiveRecorder.Domain.Enums;
public enum LiveRoomAvailabilityStatus
{
Unknown = 0,
Offline = 1,
Live = 2
}
@@ -0,0 +1,7 @@
namespace LiveRecorder.Domain.Enums;
public enum RecordOutputFormat
{
Mp4 = 0,
Ts = 1
}
@@ -0,0 +1,7 @@
namespace LiveRecorder.Domain.Enums;
public enum RecordSaveMode
{
SingleFile = 0,
Segmented = 1
}
@@ -0,0 +1,12 @@
namespace LiveRecorder.Domain.Enums;
public enum RecordSessionStatus
{
Pending = 0,
Starting = 1,
Running = 2,
Stopping = 3,
Completed = 4,
Failed = 5,
Stopped = 6
}
@@ -0,0 +1,12 @@
namespace LiveRecorder.Domain.Enums;
public enum RecordTaskStatus
{
Pending = 0,
Starting = 1,
Running = 2,
Stopping = 3,
Completed = 4,
Failed = 5,
Stopped = 6
}
@@ -0,0 +1,8 @@
namespace LiveRecorder.Domain.Enums;
public enum RecordingTemplateType
{
StreamCopy = 0,
BalancedMp4 = 1,
ArchiveTs = 2
}
@@ -0,0 +1,9 @@
namespace LiveRecorder.Domain.Enums;
public enum SystemLogLevel
{
Trace = 0,
Info = 1,
Warning = 2,
Error = 3
}
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LiveRecorder.Domain\LiveRecorder.Domain.csproj" />
<ProjectReference Include="..\LiveRecorder.Application\LiveRecorder.Application.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.14" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.14" />
</ItemGroup>
<ItemGroup>
<Content Include="Platforms\Douyin\Signing\*.js">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -0,0 +1,312 @@
using LiveRecorder.Application.Common;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace LiveRecorder.Infrastructure.Persistence;
public sealed class DatabaseInitializer
{
private readonly LiveRecorderDbContext _dbContext;
public DatabaseInitializer(LiveRecorderDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task InitializeAsync(CancellationToken cancellationToken = default)
{
await _dbContext.Database.EnsureCreatedAsync(cancellationToken);
await EnsureSchemaAsync(cancellationToken);
await BackfillRecordSessionsAsync(cancellationToken);
if (!await _dbContext.UserAccounts.AnyAsync(cancellationToken))
{
var now = DateTimeOffset.UtcNow;
var admin = new UserAccount("admin", "Administrator", PasswordHasher.Hash("Admin@123"), now);
await _dbContext.UserAccounts.AddAsync(admin, cancellationToken);
}
var defaults = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["ffmpeg.path"] = "ffmpeg",
["recording.output_root"] = "records",
["recording.output_directory_template"] = "{platform}/{yyyy}/{MM}/{dd}/{anchor}",
["recording.output_file_name_template"] = "{HHmmss}_{anchor}_{title}_{roomId}{segmentSuffix}",
["recording.default_quality"] = "origin",
["recording.default_output_format"] = "Mp4",
["recording.save_mode"] = "SingleFile",
["recording.template"] = "StreamCopy",
["recording.segment_duration_minutes"] = "30",
["recording.enable_auto_reconnect"] = "True",
["recording.reconnect_delay_max_seconds"] = "5",
["recording.read_write_timeout_milliseconds"] = "15000000",
["recording.enable_danmaku_recording"] = "True",
["recording.danmaku_include_non_chat_events"] = "True",
["recording.danmaku_min_poll_interval_milliseconds"] = "1000",
["recording.danmaku_retry_delay_max_seconds"] = "15",
["scheduler.enable_background_polling"] = "True",
["scheduler.auto_start_recording_on_live"] = "True",
["scheduler.polling_interval_seconds"] = "60",
["notification.email.enabled"] = "False",
["notification.email.smtp_host"] = string.Empty,
["notification.email.smtp_port"] = "587",
["notification.email.use_ssl"] = "True",
["notification.email.username"] = string.Empty,
["notification.email.password"] = string.Empty,
["notification.email.from_address"] = string.Empty,
["notification.email.from_display_name"] = "Live Recorder",
["notification.email.to_addresses"] = string.Empty,
["notification.email.notify_live_started"] = "True",
["notification.email.notify_exception"] = "True",
["notification.email.live_started.subject_template"] = "[{{appName}}] Live started: {{anchor}} {{title}} ({{roomId}})",
["notification.email.live_started.body_template_html"] = """
<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>
""",
["notification.email.exception.subject_template"] = "[{{appName}}] Exception: {{source}}",
["notification.email.exception.body_template_html"] = """
<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>
""",
["douyin.user_agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
["douyin.referer"] = "https://live.douyin.com/",
["douyin.cookie"] = string.Empty
};
var existingKeys = await _dbContext.AppSettings
.Select(static item => item.Key)
.ToListAsync(cancellationToken);
foreach (var pair in defaults.Where(pair => !existingKeys.Contains(pair.Key, StringComparer.OrdinalIgnoreCase)))
{
await _dbContext.AppSettings.AddAsync(new AppSetting(pair.Key, pair.Value, DateTimeOffset.UtcNow), cancellationToken);
}
await _dbContext.SaveChangesAsync(cancellationToken);
}
private async Task EnsureSchemaAsync(CancellationToken cancellationToken)
{
try
{
await _dbContext.Database.ExecuteSqlRawAsync(
"ALTER TABLE LiveRooms ADD COLUMN IsEnabled INTEGER NOT NULL DEFAULT 1;",
cancellationToken);
}
catch (SqliteException ex) when (ex.SqliteErrorCode == 1 &&
ex.Message.Contains("duplicate column name", StringComparison.OrdinalIgnoreCase))
{
}
try
{
await _dbContext.Database.ExecuteSqlRawAsync(
"ALTER TABLE LiveRooms ADD COLUMN HasSentLiveNotificationForCurrentSession INTEGER NOT NULL DEFAULT 0;",
cancellationToken);
}
catch (SqliteException ex) when (ex.SqliteErrorCode == 1 &&
ex.Message.Contains("duplicate column name", StringComparison.OrdinalIgnoreCase))
{
}
await _dbContext.Database.ExecuteSqlRawAsync(
"""
CREATE TABLE IF NOT EXISTS RecordSessions (
Id TEXT NOT NULL CONSTRAINT PK_RecordSessions PRIMARY KEY,
LiveRoomId TEXT NOT NULL,
Status INTEGER NOT NULL,
PreferredQuality TEXT NOT NULL,
OutputFormat INTEGER NOT NULL,
SaveMode INTEGER NOT NULL,
StreamUrl TEXT NULL,
OutputPathPattern TEXT NULL,
ActiveSegmentIndex INTEGER NOT NULL,
SegmentCount INTEGER NOT NULL,
RecorderProcessId INTEGER NULL,
ErrorMessage TEXT NULL,
CreatedAt TEXT NOT NULL,
UpdatedAt TEXT NOT NULL,
StartedAt TEXT NULL,
EndedAt TEXT NULL
);
""",
cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE RecordTasks ADD COLUMN RecordSessionId TEXT NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE RecordTasks ADD COLUMN SegmentIndex INTEGER NOT NULL DEFAULT 1;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN DanmakuFilePath TEXT NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN DanmakuMessageCount INTEGER NOT NULL DEFAULT 0;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE SystemLogEntries ADD COLUMN RecordSessionId TEXT NULL;", cancellationToken);
await _dbContext.Database.ExecuteSqlRawAsync(
"CREATE INDEX IF NOT EXISTS IX_RecordTasks_RecordSessionId_SegmentIndex ON RecordTasks (RecordSessionId, SegmentIndex);",
cancellationToken);
await _dbContext.Database.ExecuteSqlRawAsync(
"CREATE INDEX IF NOT EXISTS IX_RecordSessions_LiveRoomId_CreatedAt ON RecordSessions (LiveRoomId, CreatedAt);",
cancellationToken);
await _dbContext.Database.ExecuteSqlRawAsync(
"CREATE INDEX IF NOT EXISTS IX_SystemLogEntries_RecordSessionId ON SystemLogEntries (RecordSessionId);",
cancellationToken);
}
private async Task ExecuteAddColumnAsync(string sql, CancellationToken cancellationToken)
{
try
{
await _dbContext.Database.ExecuteSqlRawAsync(sql, cancellationToken);
}
catch (SqliteException ex) when (ex.SqliteErrorCode == 1 &&
ex.Message.Contains("duplicate column name", StringComparison.OrdinalIgnoreCase))
{
}
}
private async Task BackfillRecordSessionsAsync(CancellationToken cancellationToken)
{
await using var connection = (SqliteConnection)_dbContext.Database.GetDbConnection();
if (connection.State != System.Data.ConnectionState.Open)
{
await connection.OpenAsync(cancellationToken);
}
var selectCommand = connection.CreateCommand();
selectCommand.CommandText =
"""
SELECT Id, LiveRoomId, Status, PreferredQuality, OutputFormat, StreamUrl, OutputFilePath, ErrorMessage, CreatedAt, UpdatedAt, StartedAt, EndedAt
FROM RecordTasks
WHERE RecordSessionId IS NULL OR RecordSessionId = '';
""";
var orphanTasks = new List<LegacyTaskRow>();
await using (var reader = await selectCommand.ExecuteReaderAsync(cancellationToken))
{
while (await reader.ReadAsync(cancellationToken))
{
orphanTasks.Add(new LegacyTaskRow(
reader.GetString(0),
reader.GetString(1),
reader.GetInt32(2),
reader.IsDBNull(3) ? "origin" : reader.GetString(3),
reader.GetInt32(4),
reader.IsDBNull(5) ? null : reader.GetString(5),
reader.IsDBNull(6) ? null : reader.GetString(6),
reader.IsDBNull(7) ? null : reader.GetString(7),
reader.GetString(8),
reader.GetString(9),
reader.IsDBNull(10) ? null : reader.GetString(10),
reader.IsDBNull(11) ? null : reader.GetString(11)));
}
}
foreach (var orphanTask in orphanTasks)
{
var sessionId = Guid.NewGuid().ToString();
var saveMode = !string.IsNullOrWhiteSpace(orphanTask.OutputFilePath) && orphanTask.OutputFilePath.Contains('%')
? (int)RecordSaveMode.Segmented
: (int)RecordSaveMode.SingleFile;
var segmentCount = 1;
var activeSegmentIndex = orphanTask.Status is (int)RecordTaskStatus.Starting or (int)RecordTaskStatus.Running or (int)RecordTaskStatus.Stopping
? 1
: 0;
var insertSessionCommand = connection.CreateCommand();
insertSessionCommand.CommandText =
"""
INSERT INTO RecordSessions (
Id, LiveRoomId, Status, PreferredQuality, OutputFormat, SaveMode, StreamUrl, OutputPathPattern,
ActiveSegmentIndex, SegmentCount, RecorderProcessId, ErrorMessage, CreatedAt, UpdatedAt, StartedAt, EndedAt
) VALUES (
$id, $liveRoomId, $status, $preferredQuality, $outputFormat, $saveMode, $streamUrl, $outputPathPattern,
$activeSegmentIndex, $segmentCount, NULL, $errorMessage, $createdAt, $updatedAt, $startedAt, $endedAt
);
""";
insertSessionCommand.Parameters.AddWithValue("$id", sessionId);
insertSessionCommand.Parameters.AddWithValue("$liveRoomId", orphanTask.LiveRoomId);
insertSessionCommand.Parameters.AddWithValue("$status", MapLegacyTaskStatusToSessionStatus(orphanTask.Status));
insertSessionCommand.Parameters.AddWithValue("$preferredQuality", orphanTask.PreferredQuality);
insertSessionCommand.Parameters.AddWithValue("$outputFormat", orphanTask.OutputFormat);
insertSessionCommand.Parameters.AddWithValue("$saveMode", saveMode);
insertSessionCommand.Parameters.AddWithValue("$streamUrl", (object?)orphanTask.StreamUrl ?? DBNull.Value);
insertSessionCommand.Parameters.AddWithValue("$outputPathPattern", (object?)orphanTask.OutputFilePath ?? DBNull.Value);
insertSessionCommand.Parameters.AddWithValue("$activeSegmentIndex", activeSegmentIndex);
insertSessionCommand.Parameters.AddWithValue("$segmentCount", segmentCount);
insertSessionCommand.Parameters.AddWithValue("$errorMessage", (object?)orphanTask.ErrorMessage ?? DBNull.Value);
insertSessionCommand.Parameters.AddWithValue("$createdAt", orphanTask.CreatedAt);
insertSessionCommand.Parameters.AddWithValue("$updatedAt", orphanTask.UpdatedAt);
insertSessionCommand.Parameters.AddWithValue("$startedAt", (object?)orphanTask.StartedAt ?? DBNull.Value);
insertSessionCommand.Parameters.AddWithValue("$endedAt", (object?)orphanTask.EndedAt ?? DBNull.Value);
await insertSessionCommand.ExecuteNonQueryAsync(cancellationToken);
var updateTaskCommand = connection.CreateCommand();
updateTaskCommand.CommandText =
"""
UPDATE RecordTasks
SET RecordSessionId = $recordSessionId, SegmentIndex = COALESCE(SegmentIndex, 1)
WHERE Id = $taskId;
""";
updateTaskCommand.Parameters.AddWithValue("$recordSessionId", sessionId);
updateTaskCommand.Parameters.AddWithValue("$taskId", orphanTask.Id);
await updateTaskCommand.ExecuteNonQueryAsync(cancellationToken);
var updateLogsCommand = connection.CreateCommand();
updateLogsCommand.CommandText =
"""
UPDATE SystemLogEntries
SET RecordSessionId = $recordSessionId
WHERE RecordTaskId = $taskId AND (RecordSessionId IS NULL OR RecordSessionId = '');
""";
updateLogsCommand.Parameters.AddWithValue("$recordSessionId", sessionId);
updateLogsCommand.Parameters.AddWithValue("$taskId", orphanTask.Id);
await updateLogsCommand.ExecuteNonQueryAsync(cancellationToken);
}
}
private static int MapLegacyTaskStatusToSessionStatus(int taskStatus) =>
taskStatus switch
{
(int)RecordTaskStatus.Pending => (int)RecordSessionStatus.Pending,
(int)RecordTaskStatus.Starting => (int)RecordSessionStatus.Starting,
(int)RecordTaskStatus.Running => (int)RecordSessionStatus.Running,
(int)RecordTaskStatus.Stopping => (int)RecordSessionStatus.Stopping,
(int)RecordTaskStatus.Completed => (int)RecordSessionStatus.Completed,
(int)RecordTaskStatus.Failed => (int)RecordSessionStatus.Failed,
(int)RecordTaskStatus.Stopped => (int)RecordSessionStatus.Stopped,
_ => (int)RecordSessionStatus.Stopped
};
private sealed record LegacyTaskRow(
string Id,
string LiveRoomId,
int Status,
string PreferredQuality,
int OutputFormat,
string? StreamUrl,
string? OutputFilePath,
string? ErrorMessage,
string CreatedAt,
string UpdatedAt,
string? StartedAt,
string? EndedAt);
}
@@ -0,0 +1,145 @@
using LiveRecorder.Application.Abstractions.Persistence;
using LiveRecorder.Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace LiveRecorder.Infrastructure.Persistence;
public sealed class LiveRecorderDbContext : DbContext, IUnitOfWork
{
public LiveRecorderDbContext(DbContextOptions<LiveRecorderDbContext> options)
: base(options)
{
}
public DbSet<LiveRoom> LiveRooms => Set<LiveRoom>();
public DbSet<RecordSession> RecordSessions => Set<RecordSession>();
public DbSet<RecordTask> RecordTasks => Set<RecordTask>();
public DbSet<RecordResult> RecordResults => Set<RecordResult>();
public DbSet<SystemLogEntry> SystemLogEntries => Set<SystemLogEntry>();
public DbSet<AppSetting> AppSettings => Set<AppSetting>();
public DbSet<UserAccount> UserAccounts => Set<UserAccount>();
public DbSet<UserSession> UserSessions => Set<UserSession>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<LiveRoom>(builder =>
{
builder.ToTable("LiveRooms");
builder.HasKey(static x => x.Id);
builder.Property(static x => x.Platform).HasConversion<int>();
builder.Property(static x => x.AvailabilityStatus).HasConversion<int>();
builder.HasIndex(static x => new { x.Platform, x.RoomId }).IsUnique();
builder.Property(static x => x.SourceUrl).HasMaxLength(512);
builder.Property(static x => x.NormalizedUrl).HasMaxLength(512);
builder.Property(static x => x.RoomId).HasMaxLength(128);
builder.Property(static x => x.Title).HasMaxLength(256);
builder.Property(static x => x.AnchorName).HasMaxLength(128);
builder.Property(static x => x.CoverUrl).HasMaxLength(512);
builder.Property(static x => x.IsEnabled).HasDefaultValue(true);
builder.Property(static x => x.HasSentLiveNotificationForCurrentSession).HasDefaultValue(false);
});
modelBuilder.Entity<RecordTask>(builder =>
{
builder.ToTable("RecordTasks");
builder.HasKey(static x => x.Id);
builder.Property(static x => x.Status).HasConversion<int>();
builder.Property(static x => x.OutputFormat).HasConversion<int>();
builder.Property(static x => x.PreferredQuality).HasMaxLength(64);
builder.Property(static x => x.StreamUrl).HasMaxLength(2048);
builder.Property(static x => x.OutputFilePath).HasMaxLength(2048);
builder.Property(static x => x.ErrorMessage).HasMaxLength(2048);
builder.HasOne(static x => x.LiveRoom)
.WithMany(static x => x.RecordTasks)
.HasForeignKey(static x => x.LiveRoomId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(static x => x.RecordSession)
.WithMany(static x => x.RecordTasks)
.HasForeignKey(static x => x.RecordSessionId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(static x => new { x.RecordSessionId, x.SegmentIndex });
});
modelBuilder.Entity<RecordSession>(builder =>
{
builder.ToTable("RecordSessions");
builder.HasKey(static x => x.Id);
builder.Property(static x => x.Status).HasConversion<int>();
builder.Property(static x => x.OutputFormat).HasConversion<int>();
builder.Property(static x => x.SaveMode).HasConversion<int>();
builder.Property(static x => x.PreferredQuality).HasMaxLength(64);
builder.Property(static x => x.StreamUrl).HasMaxLength(2048);
builder.Property(static x => x.OutputPathPattern).HasMaxLength(2048);
builder.Property(static x => x.ErrorMessage).HasMaxLength(2048);
builder.HasOne(static x => x.LiveRoom)
.WithMany()
.HasForeignKey(static x => x.LiveRoomId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<RecordResult>(builder =>
{
builder.ToTable("RecordResults");
builder.HasKey(static x => x.Id);
builder.Property(static x => x.FinalStatus).HasConversion<int>();
builder.Property(static x => x.FilePath).HasMaxLength(2048);
builder.Property(static x => x.DanmakuFilePath).HasMaxLength(2048);
builder.Property(static x => x.ErrorMessage).HasMaxLength(2048);
builder.HasIndex(static x => x.RecordTaskId).IsUnique();
builder.HasOne(static x => x.RecordTask)
.WithOne(static x => x.Result)
.HasForeignKey<RecordResult>(static x => x.RecordTaskId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<SystemLogEntry>(builder =>
{
builder.ToTable("SystemLogEntries");
builder.HasKey(static x => x.Id);
builder.Property(static x => x.Level).HasConversion<int>();
builder.Property(static x => x.Category).HasMaxLength(128);
builder.Property(static x => x.Message).HasMaxLength(512);
builder.Property(static x => x.Detail).HasMaxLength(4000);
builder.HasIndex(static x => x.CreatedAt);
builder.HasIndex(static x => x.RecordSessionId);
});
modelBuilder.Entity<AppSetting>(builder =>
{
builder.ToTable("AppSettings");
builder.HasKey(static x => x.Id);
builder.Property(static x => x.Key).HasMaxLength(128);
builder.Property(static x => x.Value).HasMaxLength(4000);
builder.HasIndex(static x => x.Key).IsUnique();
});
modelBuilder.Entity<UserAccount>(builder =>
{
builder.ToTable("UserAccounts");
builder.HasKey(static x => x.Id);
builder.Property(static x => x.Username).HasMaxLength(64);
builder.Property(static x => x.DisplayName).HasMaxLength(64);
builder.Property(static x => x.PasswordHash).HasMaxLength(512);
builder.HasIndex(static x => x.Username).IsUnique();
});
modelBuilder.Entity<UserSession>(builder =>
{
builder.ToTable("UserSessions");
builder.HasKey(static x => x.Id);
builder.Property(static x => x.Token).HasMaxLength(128);
builder.HasIndex(static x => x.Token).IsUnique();
builder.HasOne(static x => x.UserAccount)
.WithMany()
.HasForeignKey(static x => x.UserAccountId)
.OnDelete(DeleteBehavior.Cascade);
});
}
}
@@ -0,0 +1,325 @@
using LiveRecorder.Application.Abstractions.Persistence;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
using Microsoft.EntityFrameworkCore;
namespace LiveRecorder.Infrastructure.Persistence.Repositories;
public sealed class AppSettingRepository : IAppSettingRepository
{
private readonly LiveRecorderDbContext _dbContext;
public AppSettingRepository(LiveRecorderDbContext dbContext)
{
_dbContext = dbContext;
}
public Task<AppSetting?> GetByKeyAsync(string key, CancellationToken cancellationToken = default) =>
_dbContext.AppSettings.FirstOrDefaultAsync(item => item.Key == key, cancellationToken);
public async Task<IReadOnlyList<AppSetting>> ListAsync(CancellationToken cancellationToken = default) =>
await _dbContext.AppSettings.OrderBy(static item => item.Key).ToListAsync(cancellationToken);
public Task AddAsync(AppSetting setting, CancellationToken cancellationToken = default) =>
_dbContext.AppSettings.AddAsync(setting, cancellationToken).AsTask();
public void Update(AppSetting setting) => _dbContext.AppSettings.Update(setting);
}
public sealed class LiveRoomRepository : ILiveRoomRepository
{
private readonly LiveRecorderDbContext _dbContext;
public LiveRoomRepository(LiveRecorderDbContext dbContext)
{
_dbContext = dbContext;
}
public Task<LiveRoom?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default) =>
_dbContext.LiveRooms.FirstOrDefaultAsync(item => item.Id == id, cancellationToken);
public Task<LiveRoom?> GetByPlatformRoomIdAsync(
LivePlatformType platformType,
string roomId,
CancellationToken cancellationToken = default) =>
_dbContext.LiveRooms.FirstOrDefaultAsync(
item => item.Platform == platformType && item.RoomId == roomId,
cancellationToken);
public async Task<IReadOnlyList<LiveRoom>> ListAsync(CancellationToken cancellationToken = default) =>
(await _dbContext.LiveRooms.AsNoTracking().ToListAsync(cancellationToken))
.OrderByDescending(static item => item.UpdatedAt)
.ToList();
public Task AddAsync(LiveRoom liveRoom, CancellationToken cancellationToken = default) =>
_dbContext.LiveRooms.AddAsync(liveRoom, cancellationToken).AsTask();
public void Remove(LiveRoom liveRoom) => _dbContext.LiveRooms.Remove(liveRoom);
}
public sealed class RecordTaskRepository : IRecordTaskRepository
{
private readonly LiveRecorderDbContext _dbContext;
public RecordTaskRepository(LiveRecorderDbContext dbContext)
{
_dbContext = dbContext;
}
public Task<RecordTask?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default) =>
_dbContext.RecordTasks
.Include(item => item.LiveRoom)
.Include(item => item.Result)
.FirstOrDefaultAsync(item => item.Id == id, cancellationToken);
public async Task<IReadOnlyList<RecordTask>> GetByIdsAsync(IReadOnlyCollection<Guid> ids, CancellationToken cancellationToken = default)
{
if (ids.Count == 0)
{
return [];
}
return await _dbContext.RecordTasks
.Include(item => item.LiveRoom)
.Include(item => item.Result)
.Where(item => ids.Contains(item.Id))
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<RecordTask>> ListAsync(Guid? liveRoomId = null, CancellationToken cancellationToken = default)
{
IQueryable<RecordTask> query = _dbContext.RecordTasks
.Include(item => item.LiveRoom)
.Include(item => item.RecordSession)
.Include(item => item.Result)
.AsNoTracking();
if (liveRoomId.HasValue)
{
query = query.Where(item => item.LiveRoomId == liveRoomId.Value);
}
var items = await query.ToListAsync(cancellationToken);
return items
.OrderByDescending(static item => item.CreatedAt)
.ToList();
}
public async Task<IReadOnlyList<RecordTask>> ListBySessionIdAsync(Guid recordSessionId, CancellationToken cancellationToken = default)
{
var items = await _dbContext.RecordTasks
.Include(item => item.LiveRoom)
.Include(item => item.RecordSession)
.Include(item => item.Result)
.AsNoTracking()
.Where(item => item.RecordSessionId == recordSessionId)
.ToListAsync(cancellationToken);
return items
.OrderBy(static item => item.SegmentIndex)
.ThenBy(static item => item.CreatedAt)
.ToList();
}
public Task<RecordTask?> GetRunningByLiveRoomIdAsync(Guid liveRoomId, CancellationToken cancellationToken = default) =>
_dbContext.RecordTasks
.Include(item => item.LiveRoom)
.Include(item => item.RecordSession)
.FirstOrDefaultAsync(
item => item.LiveRoomId == liveRoomId &&
(item.Status == RecordTaskStatus.Starting || item.Status == RecordTaskStatus.Running),
cancellationToken);
public Task AddAsync(RecordTask recordTask, CancellationToken cancellationToken = default) =>
_dbContext.RecordTasks.AddAsync(recordTask, cancellationToken).AsTask();
public void Remove(RecordTask recordTask) => _dbContext.RecordTasks.Remove(recordTask);
public void RemoveRange(IEnumerable<RecordTask> recordTasks) => _dbContext.RecordTasks.RemoveRange(recordTasks);
}
public sealed class RecordSessionRepository : IRecordSessionRepository
{
private readonly LiveRecorderDbContext _dbContext;
public RecordSessionRepository(LiveRecorderDbContext dbContext)
{
_dbContext = dbContext;
}
public Task<RecordSession?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default) =>
_dbContext.RecordSessions
.Include(item => item.LiveRoom)
.Include(item => item.RecordTasks.OrderBy(task => task.SegmentIndex))
.ThenInclude(item => item.Result)
.FirstOrDefaultAsync(item => item.Id == id, cancellationToken);
public async Task<IReadOnlyList<RecordSession>> ListAsync(Guid? liveRoomId = null, CancellationToken cancellationToken = default)
{
IQueryable<RecordSession> query = _dbContext.RecordSessions
.Include(item => item.LiveRoom)
.Include(item => item.RecordTasks.OrderBy(task => task.SegmentIndex))
.ThenInclude(item => item.Result)
.AsNoTracking();
if (liveRoomId.HasValue)
{
query = query.Where(item => item.LiveRoomId == liveRoomId.Value);
}
var items = await query.ToListAsync(cancellationToken);
return items
.OrderByDescending(static item => item.CreatedAt)
.ToList();
}
public Task<RecordSession?> GetActiveByLiveRoomIdAsync(Guid liveRoomId, CancellationToken cancellationToken = default) =>
_dbContext.RecordSessions
.Include(item => item.LiveRoom)
.Include(item => item.RecordTasks)
.ThenInclude(item => item.Result)
.FirstOrDefaultAsync(
item => item.LiveRoomId == liveRoomId &&
(item.Status == RecordSessionStatus.Starting ||
item.Status == RecordSessionStatus.Running ||
item.Status == RecordSessionStatus.Stopping),
cancellationToken);
public Task AddAsync(RecordSession recordSession, CancellationToken cancellationToken = default) =>
_dbContext.RecordSessions.AddAsync(recordSession, cancellationToken).AsTask();
public void Remove(RecordSession recordSession) => _dbContext.RecordSessions.Remove(recordSession);
}
public sealed class RecordResultRepository : IRecordResultRepository
{
private readonly LiveRecorderDbContext _dbContext;
public RecordResultRepository(LiveRecorderDbContext dbContext)
{
_dbContext = dbContext;
}
public Task<RecordResult?> GetByTaskIdAsync(Guid recordTaskId, CancellationToken cancellationToken = default) =>
_dbContext.RecordResults.FirstOrDefaultAsync(item => item.RecordTaskId == recordTaskId, cancellationToken);
public Task AddAsync(RecordResult recordResult, CancellationToken cancellationToken = default) =>
_dbContext.RecordResults.AddAsync(recordResult, cancellationToken).AsTask();
public void Update(RecordResult recordResult) => _dbContext.RecordResults.Update(recordResult);
public void Remove(RecordResult recordResult) => _dbContext.RecordResults.Remove(recordResult);
public void RemoveRange(IEnumerable<RecordResult> recordResults) => _dbContext.RecordResults.RemoveRange(recordResults);
}
public sealed class SystemLogRepository : ISystemLogRepository
{
private readonly LiveRecorderDbContext _dbContext;
public SystemLogRepository(LiveRecorderDbContext dbContext)
{
_dbContext = dbContext;
}
public Task AddAsync(SystemLogEntry entry, CancellationToken cancellationToken = default) =>
_dbContext.SystemLogEntries.AddAsync(entry, cancellationToken).AsTask();
public async Task<IReadOnlyList<SystemLogEntry>> ListByRecordTaskIdsAsync(
IReadOnlyCollection<Guid> recordTaskIds,
CancellationToken cancellationToken = default)
{
if (recordTaskIds.Count == 0)
{
return [];
}
return await _dbContext.SystemLogEntries
.Where(item => item.RecordTaskId.HasValue && recordTaskIds.Contains(item.RecordTaskId.Value))
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<SystemLogEntry>> ListByRecordSessionIdsAsync(
IReadOnlyCollection<Guid> recordSessionIds,
CancellationToken cancellationToken = default)
{
if (recordSessionIds.Count == 0)
{
return [];
}
return await _dbContext.SystemLogEntries
.Where(item => item.RecordSessionId.HasValue && recordSessionIds.Contains(item.RecordSessionId.Value))
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<SystemLogEntry>> ListAsync(
Guid? liveRoomId = null,
Guid? recordSessionId = null,
Guid? recordTaskId = null,
int take = 200,
CancellationToken cancellationToken = default)
{
IQueryable<SystemLogEntry> query = _dbContext.SystemLogEntries.AsNoTracking();
if (liveRoomId.HasValue)
{
query = query.Where(item => item.LiveRoomId == liveRoomId.Value);
}
if (recordTaskId.HasValue)
{
query = query.Where(item => item.RecordTaskId == recordTaskId.Value);
}
if (recordSessionId.HasValue)
{
query = query.Where(item => item.RecordSessionId == recordSessionId.Value);
}
var items = await query.ToListAsync(cancellationToken);
return items
.OrderByDescending(static item => item.CreatedAt)
.Take(Math.Clamp(take, 1, 500))
.ToList();
}
public void RemoveRange(IEnumerable<SystemLogEntry> entries) => _dbContext.SystemLogEntries.RemoveRange(entries);
}
public sealed class UserAccountRepository : IUserAccountRepository
{
private readonly LiveRecorderDbContext _dbContext;
public UserAccountRepository(LiveRecorderDbContext dbContext)
{
_dbContext = dbContext;
}
public Task<UserAccount?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default) =>
_dbContext.UserAccounts.FirstOrDefaultAsync(item => item.Id == id, cancellationToken);
public Task<UserAccount?> GetByUsernameAsync(string username, CancellationToken cancellationToken = default) =>
_dbContext.UserAccounts.FirstOrDefaultAsync(item => item.Username == username, cancellationToken);
public Task AddAsync(UserAccount userAccount, CancellationToken cancellationToken = default) =>
_dbContext.UserAccounts.AddAsync(userAccount, cancellationToken).AsTask();
}
public sealed class UserSessionRepository : IUserSessionRepository
{
private readonly LiveRecorderDbContext _dbContext;
public UserSessionRepository(LiveRecorderDbContext dbContext)
{
_dbContext = dbContext;
}
public Task<UserSession?> GetByTokenAsync(string token, CancellationToken cancellationToken = default) =>
_dbContext.UserSessions.FirstOrDefaultAsync(item => item.Token == token, cancellationToken);
public Task AddAsync(UserSession session, CancellationToken cancellationToken = default) =>
_dbContext.UserSessions.AddAsync(session, cancellationToken).AsTask();
public void Update(UserSession session) => _dbContext.UserSessions.Update(session);
}
@@ -0,0 +1,25 @@
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Infrastructure.Platforms.Bilibili;
public sealed class BilibiliLivePlatformAdapter : ILivePlatformAdapter
{
public LivePlatformType PlatformType => LivePlatformType.Bilibili;
public bool CanHandle(string input) =>
!string.IsNullOrWhiteSpace(input) &&
input.Contains("bilibili.com", StringComparison.OrdinalIgnoreCase);
public Task<ParsedLiveRoom> ParseRoomAsync(string input, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("Bilibili 适配器尚未实现。");
public Task<LiveStatusSnapshot> GetLiveStatusAsync(string roomId, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("Bilibili 适配器尚未实现。");
public Task<StreamUrlResult> GetStreamUrlAsync(
string roomId,
string? preferredQuality = null,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException("Bilibili 适配器尚未实现。");
}
@@ -0,0 +1,147 @@
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Domain.Enums;
using Microsoft.Extensions.Logging;
namespace LiveRecorder.Infrastructure.Platforms.Douyin.Danmaku;
public sealed class DouyinDanmakuAdapter : ILiveDanmakuAdapter
{
private readonly DouyinHttpClient _douyinHttpClient;
private readonly ILoggerFactory _loggerFactory;
public DouyinDanmakuAdapter(
DouyinHttpClient douyinHttpClient,
ILoggerFactory loggerFactory)
{
_douyinHttpClient = douyinHttpClient;
_loggerFactory = loggerFactory;
}
public LivePlatformType PlatformType => LivePlatformType.Douyin;
public bool CanHandle(LivePlatformType platformType) => platformType == LivePlatformType.Douyin;
public Task<ILiveDanmakuConnection> ConnectAsync(DanmakuConnectionContext context, CancellationToken cancellationToken = default) =>
Task.FromResult<ILiveDanmakuConnection>(new DouyinDanmakuConnection(_douyinHttpClient, _loggerFactory.CreateLogger<DouyinDanmakuConnection>(), context));
}
internal sealed class DouyinDanmakuConnection : ILiveDanmakuConnection
{
private readonly DouyinHttpClient _douyinHttpClient;
private readonly ILogger<DouyinDanmakuConnection> _logger;
private readonly DanmakuConnectionContext _context;
public DouyinDanmakuConnection(
DouyinHttpClient douyinHttpClient,
ILogger<DouyinDanmakuConnection> logger,
DanmakuConnectionContext context)
{
_douyinHttpClient = douyinHttpClient;
_logger = logger;
_context = context;
}
public async Task StartAsync(Func<DanmakuEvent, Task> onEvent, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(onEvent);
var bootstrap = await _douyinHttpClient.GetDanmakuBootstrapAsync(_context.RoomId, cancellationToken);
var danmakuRoomId = bootstrap.DanmakuRoomId;
var cursor = bootstrap.Cursor ?? string.Empty;
var internalExt = bootstrap.InternalExt ?? string.Empty;
var userUniqueId = bootstrap.UserUniqueId ?? throw new InvalidOperationException("Douyin danmaku bootstrap did not provide a user unique id.");
var backoff = TimeSpan.FromSeconds(1);
var minPollInterval = TimeSpan.FromMilliseconds(Math.Max(100, _context.MinPollIntervalMilliseconds));
var maxBackoff = TimeSpan.FromSeconds(Math.Max(1, _context.RetryDelayMaxSeconds));
var consecutiveEmptyPolls = 0;
_logger.LogInformation(
"Douyin danmaku bootstrap resolved web room {WebRoomId} to im room {DanmakuRoomId}. CursorPresent={HasCursor}; InternalExtPresent={HasInternalExt}; UserUniqueId={UserUniqueId}",
_context.RoomId,
danmakuRoomId,
!string.IsNullOrWhiteSpace(cursor),
!string.IsNullOrWhiteSpace(internalExt),
userUniqueId);
while (!cancellationToken.IsCancellationRequested)
{
try
{
var bytes = await _douyinHttpClient.GetLiveImAsync(
danmakuRoomId,
userUniqueId,
cursor,
internalExt,
cancellationToken);
var envelope = DouyinDanmakuProtocol.Parse(bytes);
cursor = envelope.Cursor ?? cursor;
internalExt = envelope.InternalExt ?? internalExt;
foreach (var danmakuEvent in envelope.Events)
{
await onEvent(danmakuEvent);
}
if (envelope.Events.Count == 0)
{
consecutiveEmptyPolls++;
if (consecutiveEmptyPolls == 1 || consecutiveEmptyPolls % 30 == 0)
{
_logger.LogWarning(
"Douyin danmaku polling returned no events for web room {WebRoomId}. ImRoomId={DanmakuRoomId}; EmptyPolls={EmptyPolls}; CursorPresent={HasCursor}; InternalExtPresent={HasInternalExt}",
_context.RoomId,
danmakuRoomId,
consecutiveEmptyPolls,
!string.IsNullOrWhiteSpace(cursor),
!string.IsNullOrWhiteSpace(internalExt));
}
if (consecutiveEmptyPolls % 30 == 0)
{
var refreshedBootstrap = await _douyinHttpClient.GetDanmakuBootstrapAsync(_context.RoomId, cancellationToken);
if (!string.Equals(refreshedBootstrap.DanmakuRoomId, danmakuRoomId, StringComparison.Ordinal))
{
_logger.LogInformation(
"Douyin danmaku bootstrap refreshed im room id from {OldDanmakuRoomId} to {NewDanmakuRoomId} for web room {WebRoomId}.",
danmakuRoomId,
refreshedBootstrap.DanmakuRoomId,
_context.RoomId);
danmakuRoomId = refreshedBootstrap.DanmakuRoomId;
}
if (!string.IsNullOrWhiteSpace(refreshedBootstrap.Cursor))
{
cursor = refreshedBootstrap.Cursor;
}
if (!string.IsNullOrWhiteSpace(refreshedBootstrap.InternalExt))
{
internalExt = refreshedBootstrap.InternalExt;
}
}
}
else
{
consecutiveEmptyPolls = 0;
}
backoff = TimeSpan.FromSeconds(1);
var pollDelay = envelope.PollInterval < minPollInterval ? minPollInterval : envelope.PollInterval;
await Task.Delay(pollDelay, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Douyin danmaku polling failed for room {RoomId}. Retrying.", _context.RoomId);
await Task.Delay(backoff, cancellationToken);
backoff = TimeSpan.FromSeconds(Math.Min(maxBackoff.TotalSeconds, backoff.TotalSeconds * 2));
}
}
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
@@ -0,0 +1,480 @@
using System.IO.Compression;
using System.Text;
using LiveRecorder.Application.Abstractions.Platforms;
namespace LiveRecorder.Infrastructure.Platforms.Douyin.Danmaku;
internal static class DouyinDanmakuProtocol
{
public static DouyinDanmakuEnvelope Parse(byte[] payload)
{
if (payload.Length == 0)
{
return new DouyinDanmakuEnvelope([], null, null, TimeSpan.FromSeconds(1));
}
var responseBytes = payload.AsSpan();
var pushFrame = TryParsePushFrame(responseBytes);
if (pushFrame.Payload.Length > 0)
{
responseBytes = pushFrame.Payload;
}
if (LooksLikeGzip(responseBytes))
{
responseBytes = Decompress(responseBytes);
}
var response = ParseResponse(responseBytes);
return response;
}
private static DouyinPushFrame ParsePushFrame(ReadOnlySpan<byte> data)
{
var payload = ReadOnlySpan<byte>.Empty;
var payloadEncoding = string.Empty;
var payloadType = string.Empty;
var index = 0;
while (index < data.Length)
{
var tag = ReadVarint(data, ref index);
var fieldNumber = (int)(tag >> 3);
var wireType = (int)(tag & 0x07);
switch (fieldNumber)
{
case 5 when wireType == 2:
var headerBytes = ReadLengthDelimited(data, ref index);
var (key, value) = ParseHeader(headerBytes);
if (string.Equals(key, "compress_type", StringComparison.OrdinalIgnoreCase))
{
payloadEncoding = value;
}
break;
case 7 when wireType == 2:
payloadType = ReadString(data, ref index);
break;
case 8 when wireType == 2:
payload = ReadLengthDelimited(data, ref index).ToArray();
break;
default:
SkipField(data, ref index, wireType);
break;
}
}
if (payload.Length > 0 &&
(LooksLikeGzip(payload) ||
payloadEncoding.Contains("gzip", StringComparison.OrdinalIgnoreCase) ||
payloadType.Contains("response", StringComparison.OrdinalIgnoreCase)))
{
payload = Decompress(payload);
}
return new DouyinPushFrame(payload.ToArray());
}
private static DouyinPushFrame TryParsePushFrame(ReadOnlySpan<byte> data)
{
try
{
return ParsePushFrame(data);
}
catch
{
return new DouyinPushFrame(Array.Empty<byte>());
}
}
private static DouyinDanmakuEnvelope ParseResponse(ReadOnlySpan<byte> data)
{
var events = new List<DanmakuEvent>();
string? cursor = null;
string? internalExt = null;
TimeSpan pollInterval = TimeSpan.FromSeconds(1);
var index = 0;
while (index < data.Length)
{
var tag = ReadVarint(data, ref index);
var fieldNumber = (int)(tag >> 3);
var wireType = (int)(tag & 0x07);
switch (fieldNumber)
{
case 1 when wireType == 2:
var messageBytes = ReadLengthDelimited(data, ref index);
var danmakuEvent = ParseEnvelopeMessage(messageBytes);
if (danmakuEvent is not null)
{
events.Add(danmakuEvent);
}
break;
case 2 when wireType == 2:
cursor = Encoding.UTF8.GetString(ReadLengthDelimited(data, ref index));
break;
case 5 when wireType == 2:
internalExt = Encoding.UTF8.GetString(ReadLengthDelimited(data, ref index));
break;
case 3 when wireType == 0:
case 8 when wireType == 0:
pollInterval = TimeSpan.FromMilliseconds(Math.Max(500, (long)ReadVarint(data, ref index)));
break;
default:
SkipField(data, ref index, wireType);
break;
}
}
return new DouyinDanmakuEnvelope(events, cursor, internalExt, pollInterval);
}
private static DanmakuEvent? ParseEnvelopeMessage(ReadOnlySpan<byte> data)
{
string? method = null;
ReadOnlySpan<byte> payload = [];
var index = 0;
while (index < data.Length)
{
var tag = ReadVarint(data, ref index);
var fieldNumber = (int)(tag >> 3);
var wireType = (int)(tag & 0x07);
switch (fieldNumber)
{
case 1 when wireType == 2:
method = Encoding.UTF8.GetString(ReadLengthDelimited(data, ref index));
break;
case 2 when wireType == 2:
payload = ReadLengthDelimited(data, ref index).ToArray();
break;
default:
SkipField(data, ref index, wireType);
break;
}
}
if (string.IsNullOrWhiteSpace(method) || payload.Length == 0)
{
return null;
}
var occurredAt = DateTimeOffset.UtcNow;
return method switch
{
"WebcastChatMessage" => ParseChat(payload, occurredAt),
"WebcastGiftMessage" => ParseGift(payload, occurredAt),
"WebcastLikeMessage" => ParseLike(payload, occurredAt),
"WebcastMemberMessage" => ParseMember(payload, occurredAt),
"WebcastSocialMessage" => ParseSocial(payload, occurredAt),
_ => new DanmakuEvent("other", null, null, method, occurredAt, Convert.ToBase64String(payload.ToArray()))
};
}
private static DanmakuEvent ParseChat(ReadOnlySpan<byte> data, DateTimeOffset occurredAt)
{
string? user = null;
string? userId = null;
string? content = null;
var index = 0;
while (index < data.Length)
{
var tag = ReadVarint(data, ref index);
var fieldNumber = (int)(tag >> 3);
var wireType = (int)(tag & 0x07);
switch (fieldNumber)
{
case 2 when wireType == 2:
(userId, user) = ParseUser(ReadLengthDelimited(data, ref index));
break;
case 3 when wireType == 2:
content = Encoding.UTF8.GetString(ReadLengthDelimited(data, ref index));
break;
default:
SkipField(data, ref index, wireType);
break;
}
}
return new DanmakuEvent("chat", user, userId, content, occurredAt, Convert.ToBase64String(data.ToArray()));
}
private static DanmakuEvent ParseGift(ReadOnlySpan<byte> data, DateTimeOffset occurredAt)
{
string? user = null;
string? userId = null;
long giftId = 0;
long repeatCount = 0;
var index = 0;
while (index < data.Length)
{
var tag = ReadVarint(data, ref index);
var fieldNumber = (int)(tag >> 3);
var wireType = (int)(tag & 0x07);
switch (fieldNumber)
{
case 2 when wireType == 0:
giftId = (long)ReadVarint(data, ref index);
break;
case 5 when wireType == 0:
repeatCount = (long)ReadVarint(data, ref index);
break;
case 7 when wireType == 2:
(userId, user) = ParseUser(ReadLengthDelimited(data, ref index));
break;
default:
SkipField(data, ref index, wireType);
break;
}
}
var extra = new Dictionary<string, string>
{
["giftId"] = giftId.ToString(),
["repeatCount"] = repeatCount.ToString()
};
return new DanmakuEvent("gift", user, userId, $"gift:{giftId} x{Math.Max(1, repeatCount)}", occurredAt, Convert.ToBase64String(data.ToArray()), extra);
}
private static DanmakuEvent ParseLike(ReadOnlySpan<byte> data, DateTimeOffset occurredAt)
{
string? user = null;
string? userId = null;
long count = 0;
var index = 0;
while (index < data.Length)
{
var tag = ReadVarint(data, ref index);
var fieldNumber = (int)(tag >> 3);
var wireType = (int)(tag & 0x07);
switch (fieldNumber)
{
case 2 when wireType == 0:
count = (long)ReadVarint(data, ref index);
break;
case 5 when wireType == 2:
(userId, user) = ParseUser(ReadLengthDelimited(data, ref index));
break;
default:
SkipField(data, ref index, wireType);
break;
}
}
return new DanmakuEvent(
"like",
user,
userId,
$"like x{Math.Max(1, count)}",
occurredAt,
Convert.ToBase64String(data.ToArray()),
new Dictionary<string, string> { ["count"] = count.ToString() });
}
private static DanmakuEvent ParseMember(ReadOnlySpan<byte> data, DateTimeOffset occurredAt)
{
string? user = null;
string? userId = null;
var index = 0;
while (index < data.Length)
{
var tag = ReadVarint(data, ref index);
var fieldNumber = (int)(tag >> 3);
var wireType = (int)(tag & 0x07);
switch (fieldNumber)
{
case 2 when wireType == 2:
(userId, user) = ParseUser(ReadLengthDelimited(data, ref index));
break;
default:
SkipField(data, ref index, wireType);
break;
}
}
return new DanmakuEvent("member", user, userId, "member joined", occurredAt, Convert.ToBase64String(data.ToArray()));
}
private static DanmakuEvent ParseSocial(ReadOnlySpan<byte> data, DateTimeOffset occurredAt)
{
string? user = null;
string? userId = null;
long action = 0;
var index = 0;
while (index < data.Length)
{
var tag = ReadVarint(data, ref index);
var fieldNumber = (int)(tag >> 3);
var wireType = (int)(tag & 0x07);
switch (fieldNumber)
{
case 2 when wireType == 2:
(userId, user) = ParseUser(ReadLengthDelimited(data, ref index));
break;
case 4 when wireType == 0:
action = (long)ReadVarint(data, ref index);
break;
default:
SkipField(data, ref index, wireType);
break;
}
}
return new DanmakuEvent(
"enter",
user,
userId,
$"action:{action}",
occurredAt,
Convert.ToBase64String(data.ToArray()),
new Dictionary<string, string> { ["action"] = action.ToString() });
}
private static (string? UserId, string? UserName) ParseUser(ReadOnlySpan<byte> data)
{
string? userId = null;
string? userName = null;
var index = 0;
while (index < data.Length)
{
var tag = ReadVarint(data, ref index);
var fieldNumber = (int)(tag >> 3);
var wireType = (int)(tag & 0x07);
switch (fieldNumber)
{
case 1 when wireType == 0:
userId = ReadVarint(data, ref index).ToString();
break;
case 3 when wireType == 2:
userName = Encoding.UTF8.GetString(ReadLengthDelimited(data, ref index));
break;
default:
SkipField(data, ref index, wireType);
break;
}
}
return (userId, userName);
}
private static (string Key, string Value) ParseHeader(ReadOnlySpan<byte> data)
{
string key = string.Empty;
string value = string.Empty;
var index = 0;
while (index < data.Length)
{
var tag = ReadVarint(data, ref index);
var fieldNumber = (int)(tag >> 3);
var wireType = (int)(tag & 0x07);
switch (fieldNumber)
{
case 1 when wireType == 2:
key = Encoding.UTF8.GetString(ReadLengthDelimited(data, ref index));
break;
case 2 when wireType == 2:
value = Encoding.UTF8.GetString(ReadLengthDelimited(data, ref index));
break;
default:
SkipField(data, ref index, wireType);
break;
}
}
return (key, value);
}
private static ulong ReadVarint(ReadOnlySpan<byte> data, ref int index)
{
ulong result = 0;
var shift = 0;
while (index < data.Length)
{
var value = data[index++];
result |= (ulong)(value & 0x7F) << shift;
if ((value & 0x80) == 0)
{
return result;
}
shift += 7;
if (shift > 63)
{
throw new InvalidDataException("Invalid protobuf varint.");
}
}
throw new EndOfStreamException();
}
private static ReadOnlySpan<byte> ReadLengthDelimited(ReadOnlySpan<byte> data, ref int index)
{
var length = checked((int)ReadVarint(data, ref index));
if (length < 0 || index + length > data.Length)
{
throw new InvalidDataException("Invalid protobuf length-delimited field.");
}
var result = data.Slice(index, length);
index += length;
return result;
}
private static string ReadString(ReadOnlySpan<byte> data, ref int index) =>
Encoding.UTF8.GetString(ReadLengthDelimited(data, ref index));
private static void SkipField(ReadOnlySpan<byte> data, ref int index, int wireType)
{
switch (wireType)
{
case 0:
ReadVarint(data, ref index);
return;
case 1:
index += 8;
return;
case 2:
_ = ReadLengthDelimited(data, ref index);
return;
case 5:
index += 4;
return;
default:
throw new InvalidDataException($"Unsupported protobuf wire type: {wireType}");
}
}
private static bool LooksLikeGzip(ReadOnlySpan<byte> data) =>
data.Length >= 2 && data[0] == 0x1F && data[1] == 0x8B;
private static byte[] Decompress(ReadOnlySpan<byte> data)
{
using var input = new MemoryStream(data.ToArray());
using var gzip = new GZipStream(input, CompressionMode.Decompress);
using var output = new MemoryStream();
gzip.CopyTo(output);
return output.ToArray();
}
internal sealed record DouyinDanmakuEnvelope(
IReadOnlyList<DanmakuEvent> Events,
string? Cursor,
string? InternalExt,
TimeSpan PollInterval);
private readonly record struct DouyinPushFrame(byte[] Payload);
}
@@ -0,0 +1,681 @@
using System.Net;
using System.Text.Json;
using System.Text.RegularExpressions;
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Models.Settings;
using LiveRecorder.Infrastructure.Platforms.Douyin.Signing;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging;
namespace LiveRecorder.Infrastructure.Platforms.Douyin;
public sealed class DouyinHttpClient
{
private const int MaxAttempts = 3;
private const int MsTokenLength = 184;
private static readonly Regex PaceStateRegex = new(
"<script\\snonce=\"\\S+?\"\\s>self\\.__pace_f\\.push\\(\\[1,\"[a-z]?:\\[\\\\\"\\$\\\\\",\\\\\"\\$L\\d+\\\\\",null,(?<state>[\\s\\S]+?state[\\s\\S]+?)\\]\\\\n\"\\]\\)</script>",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex EscapedQuoteRegex = new(
"\\\\{1,7}\"",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex HtmlRoomIdRegex = new(
"\"roomId\":\"(?<id>\\d{8,})\"",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex HtmlRoomIdNewRegex = new(
"\"roomStore\":{[\\s\\S]*?\"roomInfo\":{[\\s\\S]*?\"roomId\":\"(?<id>\\d{8,})\"",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex HtmlTitleRegex = new(
"\"roomStore\":{[\\s\\S]*?\"roomInfo\":{[\\s\\S]*?\"room\":{[\\s\\S]*?\"title\":\"(?<title>[\\s\\S]*?)\"",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex HtmlAnchorRegex = new(
"\"roomStore\":{[\\s\\S]*?\"roomInfo\":{[\\s\\S]*?\"anchor\":{[\\s\\S]*?\"nickname\":\"(?<nickname>[\\s\\S]*?)\"",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private readonly IHttpClientFactory _httpClientFactory;
private readonly ISystemSettingsService _systemSettingsService;
private readonly DouyinXBogusSigner _xBogusSigner;
private readonly ILogger<DouyinHttpClient> _logger;
public DouyinHttpClient(
IHttpClientFactory httpClientFactory,
ISystemSettingsService systemSettingsService,
DouyinXBogusSigner xBogusSigner,
ILogger<DouyinHttpClient> logger)
{
_httpClientFactory = httpClientFactory;
_systemSettingsService = systemSettingsService;
_xBogusSigner = xBogusSigner;
_logger = logger;
}
public async Task<(Uri? FinalUri, string Body)> ResolvePageAsync(string input, CancellationToken cancellationToken = default)
{
var settings = await _systemSettingsService.GetAsync(cancellationToken);
var cookieHeader = await BuildCookieHeaderAsync(settings, cancellationToken);
using var response = await SendWithRetryAsync(
() =>
{
var request = new HttpRequestMessage(HttpMethod.Get, input);
ApplyDefaultHeaders(request, settings, refererRoomId: null);
if (!string.IsNullOrWhiteSpace(cookieHeader))
{
request.Headers.TryAddWithoutValidation("Cookie", cookieHeader);
}
return request;
},
HttpCompletionOption.ResponseContentRead,
cancellationToken);
var body = await response.Content.ReadAsStringAsync(cancellationToken);
return (response.RequestMessage?.RequestUri, body);
}
public async Task<JsonDocument> GetRoomEnterAsync(string roomId, CancellationToken cancellationToken = default)
{
var settings = await _systemSettingsService.GetAsync(cancellationToken);
var cookieHeader = await BuildCookieHeaderAsync(settings, cancellationToken);
using var response = await SendWithRetryAsync(
() =>
{
var request = new HttpRequestMessage(HttpMethod.Get, BuildRoomEnterUri(roomId));
ApplyDefaultHeaders(request, settings, roomId);
if (!string.IsNullOrWhiteSpace(cookieHeader))
{
request.Headers.TryAddWithoutValidation("Cookie", cookieHeader);
}
return request;
},
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
return await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
}
public async Task<StreamInputHeaders> GetStreamInputHeadersAsync(string roomId, CancellationToken cancellationToken = default)
{
var settings = await _systemSettingsService.GetAsync(cancellationToken);
var cookieHeader = await BuildCookieHeaderAsync(settings, cancellationToken);
var additionalHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["Origin"] = "https://live.douyin.com"
};
return new StreamInputHeaders(
settings.DouyinUserAgent,
string.IsNullOrWhiteSpace(roomId) ? settings.DouyinReferer : $"https://live.douyin.com/{roomId}",
string.IsNullOrWhiteSpace(cookieHeader) ? null : cookieHeader,
additionalHeaders);
}
public async Task<byte[]> GetLiveImAsync(
string roomId,
string userUniqueId,
string? cursor,
string? internalExt,
CancellationToken cancellationToken = default)
{
var settings = await _systemSettingsService.GetAsync(cancellationToken);
var cookieHeader = await BuildCookieHeaderAsync(settings, cancellationToken);
var requestUri = await BuildSignedLiveImUriAsync(
roomId,
userUniqueId,
cursor,
internalExt,
settings.DouyinUserAgent,
cancellationToken);
using var response = await SendWithRetryAsync(
() =>
{
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
ApplyLiveImHeaders(request, settings, roomId);
request.Headers.Accept.ParseAdd("application/protobuf, application/octet-stream, */*");
if (!string.IsNullOrWhiteSpace(cookieHeader))
{
request.Headers.TryAddWithoutValidation("Cookie", cookieHeader);
}
return request;
},
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
var body = await response.Content.ReadAsByteArrayAsync(cancellationToken);
if (body.Length == 0)
{
throw new InvalidOperationException($"Douyin live im returned an empty body for room {roomId}.");
}
return body;
}
public async Task<DouyinDanmakuBootstrap> GetDanmakuBootstrapAsync(
string webRoomId,
CancellationToken cancellationToken = default)
{
var (_, pageHtml) = await ResolvePageAsync($"https://live.douyin.com/{webRoomId}", cancellationToken);
using var document = await GetRoomEnterAsync(webRoomId, cancellationToken);
var root = document.RootElement;
var room = GetRoomNode(root);
var danmakuRoomId =
TryExtractDanmakuRoomIdFromHtml(pageHtml) ??
ReadNumericLikeString(room, "id_str") ??
ReadNumericLikeString(room, "id") ??
ReadNumericLikeString(room, "room_id") ??
ReadNumericLikeString(room, "roomId") ??
FindNumericLikeValue(room, "id_str", "room_id", "roomId", "id") ??
webRoomId;
var (anchor, title) = TryExtractAnchorAndTitleFromHtml(pageHtml);
return new DouyinDanmakuBootstrap(
webRoomId,
danmakuRoomId,
null,
null,
CreateAnonymousUserUniqueId(),
anchor,
title);
}
private async Task<string> BuildCookieHeaderAsync(SystemSettingsDto settings, CancellationToken cancellationToken)
{
var configuredCookie = settings.DouyinCookie?.Trim() ?? string.Empty;
if (!string.IsNullOrWhiteSpace(configuredCookie) &&
configuredCookie.Contains("ttwid=", StringComparison.OrdinalIgnoreCase))
{
return configuredCookie;
}
var ttwidCookie = await FetchTtwidCookieAsync(settings, cancellationToken);
if (string.IsNullOrWhiteSpace(ttwidCookie))
{
return configuredCookie;
}
if (string.IsNullOrWhiteSpace(configuredCookie))
{
return ttwidCookie;
}
return $"{configuredCookie.TrimEnd(';')}; {ttwidCookie}";
}
private async Task<string?> FetchTtwidCookieAsync(SystemSettingsDto settings, CancellationToken cancellationToken)
{
try
{
using var response = await SendWithRetryAsync(
() =>
{
var request = new HttpRequestMessage(HttpMethod.Get, settings.DouyinReferer);
ApplyDefaultHeaders(request, settings, refererRoomId: null);
if (!string.IsNullOrWhiteSpace(settings.DouyinCookie))
{
request.Headers.TryAddWithoutValidation("Cookie", settings.DouyinCookie.Trim());
}
return request;
},
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
if (!response.Headers.TryGetValues("Set-Cookie", out var setCookieHeaders))
{
return null;
}
foreach (var cookieLine in setCookieHeaders)
{
var part = cookieLine
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.FirstOrDefault(item => item.StartsWith("ttwid=", StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(part))
{
return part;
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Fetch ttwid cookie failed.");
}
return null;
}
private async Task<HttpResponseMessage> SendWithRetryAsync(
Func<HttpRequestMessage> requestFactory,
HttpCompletionOption completionOption,
CancellationToken cancellationToken)
{
var client = _httpClientFactory.CreateClient("douyin");
for (var attempt = 1; attempt <= MaxAttempts; attempt++)
{
using var request = requestFactory();
try
{
var response = await client.SendAsync(request, completionOption, cancellationToken);
if (response.IsSuccessStatusCode)
{
return response;
}
if (attempt < MaxAttempts && IsTransientStatusCode(response.StatusCode))
{
response.Dispose();
await DelayForRetryAsync(attempt, cancellationToken);
continue;
}
response.EnsureSuccessStatusCode();
return response;
}
catch (Exception ex) when (attempt < MaxAttempts && IsTransientTransportException(ex, cancellationToken))
{
_logger.LogWarning(ex, "Douyin request transient failure on attempt {Attempt}. Retrying.", attempt);
await DelayForRetryAsync(attempt, cancellationToken);
}
}
using var lastRequest = requestFactory();
return await client.SendAsync(lastRequest, completionOption, cancellationToken);
}
private static void ApplyDefaultHeaders(HttpRequestMessage request, SystemSettingsDto settings, string? refererRoomId)
{
request.Version = HttpVersion.Version11;
request.VersionPolicy = HttpVersionPolicy.RequestVersionOrLower;
request.Headers.Accept.ParseAdd("application/json, text/plain, */*");
request.Headers.ConnectionClose = true;
request.Headers.UserAgent.ParseAdd(settings.DouyinUserAgent);
request.Headers.TryAddWithoutValidation("Origin", "https://live.douyin.com");
request.Headers.Referrer = new Uri(
string.IsNullOrWhiteSpace(refererRoomId)
? settings.DouyinReferer
: $"https://live.douyin.com/{refererRoomId}");
}
private static void ApplyLiveImHeaders(HttpRequestMessage request, SystemSettingsDto settings, string roomId)
{
ApplyDefaultHeaders(request, settings, roomId);
request.Headers.TryAddWithoutValidation("Host", "live.douyin.com");
}
private static string BuildRoomEnterUri(string roomId)
{
var parameters = new Dictionary<string, string?>
{
["aid"] = "6383",
["app_name"] = "douyin_web",
["device_platform"] = "web",
["language"] = "zh-CN",
["browser_language"] = "zh-CN",
["browser_platform"] = "Win32",
["browser_name"] = "Mozilla",
["browser_version"] = "5.0",
["enter_from"] = "web_live",
["cookie_enabled"] = "true",
["screen_width"] = "1920",
["screen_height"] = "1080",
["is_need_double_stream"] = "true",
["web_rid"] = roomId
};
return QueryHelpers.AddQueryString("https://live.douyin.com/webcast/room/web/enter/", parameters);
}
private async Task<string> BuildSignedLiveImUriAsync(
string roomId,
string userUniqueId,
string? cursor,
string? internalExt,
string userAgent,
CancellationToken cancellationToken)
{
var parameters = BuildLiveImParameters(roomId, userUniqueId, cursor, internalExt, userAgent);
var unencodedQuery = string.Join("&", parameters.Select(p => $"{p.Key}={p.Value}"));
var signedValue = await _xBogusSigner.SignAsync(unencodedQuery, userAgent, cancellationToken);
parameters["a_bogus"] = signedValue;
return QueryHelpers.AddQueryString("https://live.douyin.com/webcast/im/fetch/", parameters);
}
private static Dictionary<string, string?> BuildLiveImParameters(
string roomId,
string userUniqueId,
string? cursor,
string? internalExt,
string userAgent)
{
return new Dictionary<string, string?>
{
["aid"] = "6383",
["app_name"] = "douyin_web",
["browser_language"] = "zh-CN",
["browser_name"] = "Mozilla",
["browser_online"] = "true",
["browser_platform"] = "Win32",
["browser_version"] = string.IsNullOrWhiteSpace(userAgent) ? "5.0" : userAgent,
["compress"] = "gzip",
["cookie_enabled"] = "true",
["cursor"] = cursor ?? string.Empty,
["device_id"] = string.Empty,
["device_platform"] = "web",
["did_rule"] = "3",
["endpoint"] = "live_pc",
["fetch_rule"] = "1",
["host"] = "https://live.douyin.com",
["identity"] = "audience",
["insert_task_id"] = string.Empty,
["internal_ext"] = internalExt ?? string.Empty,
["last_rtt"] = "0",
["live_id"] = "1",
["live_pc"] = roomId,
["live_reason"] = string.Empty,
["msToken"] = CreateMsToken(),
["need_persist_msg_count"] = "15",
["room_id"] = roomId,
["room_id_str"] = roomId,
["resp_content_type"] = "protobuf",
["screen_height"] = "1080",
["screen_width"] = "1920",
["support_wrds"] = "1",
["tz_name"] = "Asia/Shanghai",
["user_unique_id"] = userUniqueId,
["version_code"] = "180800",
["webcast_sdk_version"] = "1.0.15"
};
}
private static bool IsTransientStatusCode(HttpStatusCode statusCode) =>
statusCode == HttpStatusCode.RequestTimeout ||
statusCode == (HttpStatusCode)429 ||
(int)statusCode >= 500;
private static bool IsTransientTransportException(Exception exception, CancellationToken cancellationToken)
{
if (exception is OperationCanceledException && cancellationToken.IsCancellationRequested)
{
return false;
}
if (exception is HttpRequestException or IOException or TimeoutException)
{
return true;
}
return exception.InnerException is not null &&
IsTransientTransportException(exception.InnerException, cancellationToken);
}
private static Task DelayForRetryAsync(int attempt, CancellationToken cancellationToken) =>
Task.Delay(TimeSpan.FromMilliseconds(250 * attempt), cancellationToken);
private static JsonElement GetRoomNode(JsonElement root)
{
if (!TryGetProperty(root, "data", out var data))
{
throw new InvalidOperationException("Douyin response does not contain a data node.");
}
if (data.ValueKind == JsonValueKind.Array && data.GetArrayLength() > 0)
{
return data[0];
}
if (data.ValueKind == JsonValueKind.Object)
{
if (TryGetProperty(data, "data", out var innerData))
{
if (innerData.ValueKind == JsonValueKind.Array && innerData.GetArrayLength() > 0)
{
return innerData[0];
}
if (innerData.ValueKind == JsonValueKind.Object)
{
return innerData;
}
}
if (TryGetProperty(data, "room", out var room))
{
return room;
}
return data;
}
throw new InvalidOperationException("Douyin response data node format is not supported.");
}
private static bool TryGetProperty(JsonElement element, string name, out JsonElement value)
{
if (element.ValueKind == JsonValueKind.Object)
{
if (element.TryGetProperty(name, out value))
{
return true;
}
foreach (var property in element.EnumerateObject())
{
if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase))
{
value = property.Value;
return true;
}
}
}
value = default;
return false;
}
private static string? ReadNumericLikeString(JsonElement element, string propertyName)
{
if (!TryGetProperty(element, propertyName, out var value))
{
return null;
}
return NormalizeNumericLikeString(ReadScalarString(value));
}
private static string? FindNumericLikeValue(JsonElement element, params string[] propertyNames)
{
foreach (var propertyName in propertyNames)
{
var value = FindScalarStringByPropertyName(element, propertyName);
var normalized = NormalizeNumericLikeString(value);
if (!string.IsNullOrWhiteSpace(normalized))
{
return normalized;
}
}
return null;
}
private static string? FindScalarString(JsonElement element, params string[] propertyNames)
{
foreach (var propertyName in propertyNames)
{
var value = FindScalarStringByPropertyName(element, propertyName);
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}
}
return null;
}
private static string? FindScalarStringByPropertyName(JsonElement element, string propertyName)
{
if (element.ValueKind == JsonValueKind.Object)
{
foreach (var property in element.EnumerateObject())
{
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
{
var scalar = ReadScalarString(property.Value);
if (!string.IsNullOrWhiteSpace(scalar))
{
return scalar;
}
}
var nested = FindScalarStringByPropertyName(property.Value, propertyName);
if (!string.IsNullOrWhiteSpace(nested))
{
return nested;
}
}
}
else if (element.ValueKind == JsonValueKind.Array)
{
foreach (var item in element.EnumerateArray())
{
var nested = FindScalarStringByPropertyName(item, propertyName);
if (!string.IsNullOrWhiteSpace(nested))
{
return nested;
}
}
}
return null;
}
private static string? ReadScalarString(JsonElement value)
{
return value.ValueKind switch
{
JsonValueKind.String => value.GetString(),
JsonValueKind.Number => value.GetRawText(),
JsonValueKind.True => bool.TrueString.ToLowerInvariant(),
JsonValueKind.False => bool.FalseString.ToLowerInvariant(),
_ => null
};
}
private static string? NormalizeOptional(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string? NormalizeNumericLikeString(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var trimmed = value.Trim();
return trimmed.All(char.IsDigit) ? trimmed : null;
}
private static string CreateMsToken()
{
const string characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_=";
Span<char> buffer = stackalloc char[MsTokenLength];
for (var index = 0; index < buffer.Length; index++)
{
buffer[index] = characters[Random.Shared.Next(0, characters.Length)];
}
return new string(buffer);
}
private static string CreateAnonymousUserUniqueId()
{
Span<char> buffer = stackalloc char[19];
buffer[0] = '7';
for (var index = 1; index < buffer.Length; index++)
{
buffer[index] = (char)('0' + Random.Shared.Next(0, 10));
}
return new string(buffer);
}
private static string? TryExtractDanmakuRoomIdFromHtml(string html)
{
if (string.IsNullOrWhiteSpace(html))
{
return null;
}
var stateMatch = PaceStateRegex.Match(html);
if (!stateMatch.Success)
{
return null;
}
var statePayload = stateMatch.Groups["state"].Value;
if (string.IsNullOrWhiteSpace(statePayload))
{
return null;
}
var normalized = Regex.Unescape(statePayload);
var roomIdMatch = HtmlRoomIdNewRegex.Match(normalized);
if (!roomIdMatch.Success)
{
roomIdMatch = HtmlRoomIdRegex.Match(normalized);
}
return roomIdMatch.Success ? roomIdMatch.Groups["id"].Value : null;
}
private static (string? Anchor, string? Title) TryExtractAnchorAndTitleFromHtml(string? html)
{
if (string.IsNullOrWhiteSpace(html))
{
return (null, null);
}
var stateMatch = PaceStateRegex.Match(html);
if (!stateMatch.Success)
{
return (null, null);
}
var statePayload = stateMatch.Groups["state"].Value;
if (string.IsNullOrWhiteSpace(statePayload))
{
return (null, null);
}
var normalized = Regex.Unescape(statePayload);
var anchorMatch = HtmlAnchorRegex.Match(normalized);
var titleMatch = HtmlTitleRegex.Match(normalized);
return (
anchorMatch.Success ? Regex.Unescape(anchorMatch.Groups["nickname"].Value) : null,
titleMatch.Success ? Regex.Unescape(titleMatch.Groups["title"].Value) : null
);
}
}
public sealed record DouyinDanmakuBootstrap(
string WebRoomId,
string DanmakuRoomId,
string? Cursor,
string? InternalExt,
string? UserUniqueId,
string? Anchor,
string? Title);
@@ -0,0 +1,424 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Infrastructure.Platforms.Douyin;
public sealed class DouyinLivePlatformAdapter : ILivePlatformAdapter
{
private static readonly Regex RoomIdRegex = new(
@"(?<id>\d{8,})",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex HtmlTitleRegex = new(
"\"roomStore\":{[\\s\\S]*?\"roomInfo\":{[\\s\\S]*?\"room\":{[\\s\\S]*?\"title\":\"(?<title>[\\s\\S]*?)\"",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex HtmlAnchorRegex = new(
"\"roomStore\":{[\\s\\S]*?\"roomInfo\":{[\\s\\S]*?\"anchor\":{[\\s\\S]*?\"nickname\":\"(?<nickname>[\\s\\S]*?)\"",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private readonly DouyinHttpClient _douyinHttpClient;
public DouyinLivePlatformAdapter(DouyinHttpClient douyinHttpClient)
{
_douyinHttpClient = douyinHttpClient;
}
public LivePlatformType PlatformType => LivePlatformType.Douyin;
public bool CanHandle(string input)
{
if (string.IsNullOrWhiteSpace(input))
{
return false;
}
return input.Contains("douyin.com", StringComparison.OrdinalIgnoreCase) ||
input.Contains("iesdouyin.com", StringComparison.OrdinalIgnoreCase);
}
public async Task<ParsedLiveRoom> ParseRoomAsync(string input, CancellationToken cancellationToken = default)
{
var trimmedInput = input.Trim();
if (RoomIdRegex.FullMatch(trimmedInput))
{
return new ParsedLiveRoom(PlatformType, trimmedInput, trimmedInput, $"https://live.douyin.com/{trimmedInput}");
}
var (finalUri, body) = await _douyinHttpClient.ResolvePageAsync(trimmedInput, cancellationToken);
var roomId = ExtractRoomId(finalUri?.AbsoluteUri) ?? ExtractRoomId(body);
if (string.IsNullOrWhiteSpace(roomId))
{
throw new InvalidOperationException("Unable to extract the Douyin live room id from the input.");
}
var normalizedUrl = finalUri?.AbsoluteUri ?? $"https://live.douyin.com/{roomId}";
return new ParsedLiveRoom(PlatformType, roomId, trimmedInput, normalizedUrl);
}
private static (string? Anchor, string? Title) TryExtractAnchorAndTitleFromHtml(JsonElement root)
{
if (root.ValueKind != JsonValueKind.Object || !TryGetProperty(root, "data", out var data))
{
return (null, null);
}
if (data.ValueKind == JsonValueKind.Array && data.GetArrayLength() > 0)
{
data = data[0];
}
var json = data.ValueKind == JsonValueKind.String ? data.GetString() : data.GetRawText();
if (string.IsNullOrWhiteSpace(json))
{
return (null, null);
}
var normalized = Regex.Replace(json, "\\\\{1,7}\"", "\"");
var anchorMatch = HtmlAnchorRegex.Match(normalized);
var titleMatch = HtmlTitleRegex.Match(normalized);
return (
anchorMatch.Success ? anchorMatch.Groups["nickname"].Value : null,
titleMatch.Success ? titleMatch.Groups["title"].Value : null
);
}
public async Task<LiveStatusSnapshot> GetLiveStatusAsync(string roomId, CancellationToken cancellationToken = default)
{
using var document = await _douyinHttpClient.GetRoomEnterAsync(roomId, cancellationToken);
var room = GetRoomNode(document.RootElement);
var statusCode = GetInt(room, "status") ?? GetInt(room, "live_status");
var (anchorMatch, titleMatch) = TryExtractAnchorAndTitleFromHtml(document.RootElement);
var title = titleMatch ?? GetString(room, "title");
var anchorName = anchorMatch ?? GetNestedString(room, "owner", "nickname")
?? GetNestedString(room, "anchor", "nickname")
?? GetNestedString(room, "user", "nickname");
var coverUrl = GetCoverUrl(room);
var isLive = statusCode == 2 || GetInt(room, "live_status") is 1 or 2;
return new LiveStatusSnapshot(isLive, title, anchorName, coverUrl, statusCode, statusCode?.ToString());
}
public async Task<StreamUrlResult> GetStreamUrlAsync(
string roomId,
string? preferredQuality = null,
CancellationToken cancellationToken = default)
{
using var document = await _douyinHttpClient.GetRoomEnterAsync(roomId, cancellationToken);
var room = GetRoomNode(document.RootElement);
var streamRoot = TryGetProperty(room, "stream_url", out var streamUrl) ? streamUrl : room;
var options = ParseStreamDataOptions(streamRoot);
if (options.Count == 0)
{
options = ParseLegacyStreamOptions(streamRoot);
}
if (options.Count == 0)
{
throw new InvalidOperationException("Douyin did not return any playable stream URL.");
}
var ordered = options
.DistinctBy(static item => $"{item.QualityKey}|{item.Protocol}|{item.Url}")
.OrderByDescending(static item => item.Rank)
.ThenBy(static item => item.Protocol.Equals("flv", StringComparison.OrdinalIgnoreCase) ? 0 : 1)
.ToList();
var selected = SelectOption(ordered, preferredQuality);
var inputHeaders = await _douyinHttpClient.GetStreamInputHeadersAsync(roomId, cancellationToken);
return new StreamUrlResult(
selected.QualityKey,
selected.Protocol,
selected.Url,
inputHeaders,
ordered);
}
private static string? ExtractRoomId(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
var liveUrlMatch = Regex.Match(text, @"live\.douyin\.com/(?<id>\d{8,})", RegexOptions.IgnoreCase);
if (liveUrlMatch.Success)
{
return liveUrlMatch.Groups["id"].Value;
}
var roomIdFieldMatch = Regex.Match(text, @"room(?:_id|Id)\\?[""=: ]+\\?[""]?(?<id>\d{8,})", RegexOptions.IgnoreCase);
if (roomIdFieldMatch.Success)
{
return roomIdFieldMatch.Groups["id"].Value;
}
var genericMatch = RoomIdRegex.Match(text);
return genericMatch.Success ? genericMatch.Groups["id"].Value : null;
}
private static JsonElement GetRoomNode(JsonElement root)
{
if (!TryGetProperty(root, "data", out var data))
{
throw new InvalidOperationException("Douyin response does not contain a data node.");
}
if (data.ValueKind == JsonValueKind.Array && data.GetArrayLength() > 0)
{
return data[0];
}
if (data.ValueKind == JsonValueKind.Object)
{
if (TryGetProperty(data, "data", out var innerData))
{
if (innerData.ValueKind == JsonValueKind.Array && innerData.GetArrayLength() > 0)
{
return innerData[0];
}
if (innerData.ValueKind == JsonValueKind.Object)
{
return innerData;
}
}
if (TryGetProperty(data, "room", out var room))
{
return room;
}
return data;
}
throw new InvalidOperationException("Douyin response data node format is not supported.");
}
private static List<StreamQualityOption> ParseStreamDataOptions(JsonElement streamRoot)
{
if (!TryGetNested(streamRoot, out var streamDataElement, "live_core_sdk_data", "pull_data", "stream_data"))
{
return [];
}
var rawJson = streamDataElement.ValueKind == JsonValueKind.String
? streamDataElement.GetString()
: streamDataElement.GetRawText();
if (string.IsNullOrWhiteSpace(rawJson))
{
return [];
}
using var document = JsonDocument.Parse(rawJson);
if (!TryGetProperty(document.RootElement, "data", out var data) || data.ValueKind != JsonValueKind.Object)
{
return [];
}
var result = new List<StreamQualityOption>();
foreach (var qualityNode in data.EnumerateObject())
{
var qualityKey = qualityNode.Name;
var rank = GetQualityRank(qualityKey);
var flvUrl = GetNestedString(qualityNode.Value, "main", "flv");
var hlsUrl = GetNestedString(qualityNode.Value, "main", "hls");
if (!string.IsNullOrWhiteSpace(flvUrl))
{
result.Add(new StreamQualityOption(qualityKey, qualityKey, flvUrl, "flv", rank));
}
if (!string.IsNullOrWhiteSpace(hlsUrl))
{
result.Add(new StreamQualityOption(qualityKey, qualityKey, hlsUrl, "hls", rank));
}
}
return result;
}
private static List<StreamQualityOption> ParseLegacyStreamOptions(JsonElement streamRoot)
{
var result = new List<StreamQualityOption>();
if (TryGetProperty(streamRoot, "flv_pull_url", out var flvMap) && flvMap.ValueKind == JsonValueKind.Object)
{
foreach (var item in flvMap.EnumerateObject())
{
var url = item.Value.GetString();
if (!string.IsNullOrWhiteSpace(url))
{
result.Add(new StreamQualityOption(item.Name, item.Name, url, "flv", GetQualityRank(item.Name)));
}
}
}
if (TryGetProperty(streamRoot, "hls_pull_url_map", out var hlsMap) && hlsMap.ValueKind == JsonValueKind.Object)
{
foreach (var item in hlsMap.EnumerateObject())
{
var url = item.Value.GetString();
if (!string.IsNullOrWhiteSpace(url))
{
result.Add(new StreamQualityOption(item.Name, item.Name, url, "hls", GetQualityRank(item.Name)));
}
}
}
return result;
}
private static StreamQualityOption SelectOption(IReadOnlyList<StreamQualityOption> options, string? preferredQuality)
{
if (!string.IsNullOrWhiteSpace(preferredQuality))
{
var normalized = preferredQuality.Trim();
var matched = options
.Where(item =>
item.QualityKey.Equals(normalized, StringComparison.OrdinalIgnoreCase) ||
item.QualityName.Equals(normalized, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(static item => item.Rank)
.ThenBy(static item => item.Protocol.Equals("flv", StringComparison.OrdinalIgnoreCase) ? 0 : 1)
.FirstOrDefault();
if (matched is not null)
{
return matched;
}
}
return options[0];
}
private static int GetQualityRank(string quality)
{
if (quality.Equals("origin", StringComparison.OrdinalIgnoreCase))
{
return 100;
}
if (quality.Contains("full", StringComparison.OrdinalIgnoreCase) ||
quality.Contains("uhd", StringComparison.OrdinalIgnoreCase))
{
return 90;
}
if (quality.Contains("hd", StringComparison.OrdinalIgnoreCase))
{
return 80;
}
if (quality.Contains("sd", StringComparison.OrdinalIgnoreCase))
{
return 70;
}
return 60;
}
private static bool TryGetProperty(JsonElement element, string name, out JsonElement value)
{
if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out value))
{
return true;
}
value = default;
return false;
}
private static bool TryGetNested(JsonElement element, out JsonElement value, params string[] path)
{
value = element;
foreach (var segment in path)
{
if (!TryGetProperty(value, segment, out value))
{
return false;
}
}
return true;
}
private static int? GetInt(JsonElement element, string name)
{
if (!TryGetProperty(element, name, out var value))
{
return null;
}
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var intValue))
{
return intValue;
}
if (value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), out intValue))
{
return intValue;
}
return null;
}
private static string? GetString(JsonElement element, string name)
{
if (!TryGetProperty(element, name, out var value))
{
return null;
}
return value.ValueKind == JsonValueKind.String ? value.GetString() : null;
}
private static string? GetNestedString(JsonElement element, params string[] path)
{
if (!TryGetNested(element, out var value, path))
{
return null;
}
return value.ValueKind == JsonValueKind.String ? value.GetString() : null;
}
private static string? GetCoverUrl(JsonElement room)
{
if (!TryGetProperty(room, "cover", out var cover))
{
return null;
}
if (cover.ValueKind == JsonValueKind.String)
{
return cover.GetString();
}
if (cover.ValueKind == JsonValueKind.Object &&
TryGetProperty(cover, "url_list", out var urlList) &&
urlList.ValueKind == JsonValueKind.Array &&
urlList.GetArrayLength() > 0 &&
urlList[0].ValueKind == JsonValueKind.String)
{
return urlList[0].GetString();
}
return null;
}
}
file static class RegexExtensions
{
public static bool FullMatch(this Regex regex, string input)
{
var match = regex.Match(input);
return match.Success && match.Index == 0 && match.Length == input.Length;
}
}
@@ -0,0 +1,65 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
namespace LiveRecorder.Infrastructure.Platforms.Douyin.Signing;
public sealed class DouyinXBogusSigner
{
private readonly ILogger<DouyinXBogusSigner> _logger;
private readonly string _signerScriptPath;
public DouyinXBogusSigner(ILogger<DouyinXBogusSigner> logger)
{
_logger = logger;
_signerScriptPath = Path.Combine(AppContext.BaseDirectory, "Platforms", "Douyin", "Signing", "sign-xbogus.js");
}
public async Task<string> SignAsync(string requestUrl, string userAgent, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(requestUrl))
{
throw new ArgumentException("Request URL cannot be empty.", nameof(requestUrl));
}
if (!File.Exists(_signerScriptPath))
{
throw new FileNotFoundException("Douyin X-Bogus signer script is missing.", _signerScriptPath);
}
var startInfo = new ProcessStartInfo
{
FileName = "node",
RedirectStandardError = true,
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
};
startInfo.ArgumentList.Add(_signerScriptPath);
startInfo.ArgumentList.Add(requestUrl);
startInfo.ArgumentList.Add(userAgent ?? string.Empty);
using var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true };
process.Start();
var standardOutputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
var standardErrorTask = process.StandardError.ReadToEndAsync(cancellationToken);
await process.WaitForExitAsync(cancellationToken);
var standardOutput = (await standardOutputTask).Trim();
var standardError = (await standardErrorTask).Trim();
if (process.ExitCode != 0 || string.IsNullOrWhiteSpace(standardOutput))
{
_logger.LogWarning(
"Douyin X-Bogus signing failed. ExitCode={ExitCode}; Error={Error}",
process.ExitCode,
standardError);
throw new InvalidOperationException(
string.IsNullOrWhiteSpace(standardError)
? $"Douyin X-Bogus signer exited with code {process.ExitCode}."
: $"Douyin X-Bogus signer failed: {standardError}");
}
return standardOutput;
}
}
@@ -0,0 +1,551 @@
/**
* 生成 a_bougs 参数值
* @param {string} query 请求的 query 参数
* @param {string} ua 浏览器 User-Agent
* @returns
*/
module.exports = function (query, ua) {
function enc_sum(n_str) {
function ir(t) {
return (
(ir =
'function' == typeof Symbol && 'symbol' == typeof Symbol.iterator
? function (t) {
return typeof t;
}
: function (t) {
return t && 'function' == typeof Symbol && t.constructor === Symbol && t !== Symbol.prototype
? 'symbol'
: typeof t;
}),
ir(t)
);
}
function ur(t, r) {
for (let e = 0; e < r.length; e++) {
const n = r[e];
((n.enumerable = n.enumerable || !1),
(n.configurable = !0),
'value' in n && (n.writable = !0),
Object.defineProperty(t, sr(n.key), n));
}
}
function sr(t) {
const r = (function (t, r) {
if ('object' != ir(t) || !t) return t;
const e = t[Symbol.toPrimitive];
if (void 0 !== e) {
const n = e.call(t, r || 'default');
if ('object' != ir(n)) return n;
throw new TypeError('@@toPrimitive must return a primitive value.');
}
return ('string' === r ? String : Number)(t);
})(t, 'string');
return 'symbol' == ir(r) ? r : r + '';
}
const gr = (function () {
function t() {
if (
((function (t, r) {
if (!(t instanceof r)) throw new TypeError('Cannot call a class as a function');
})(this, t),
!(this instanceof t))
)
return new t();
((this.reg = new Array(8)), (this.chunk = []), (this.size = 0), this.reset());
}
return (
(function (t, r, e) {
(r && ur(t.prototype, r),
e && ur(t, e),
Object.defineProperty(t, 'prototype', {
writable: !1
}));
})(t, [
{
key: 'reset',
value: function () {
((this.reg[0] = 1937774191),
(this.reg[1] = 1226093241),
(this.reg[2] = 388252375),
(this.reg[3] = 3666478592),
(this.reg[4] = 2842636476),
(this.reg[5] = 372324522),
(this.reg[6] = 3817729613),
(this.reg[7] = 2969243214),
(this.chunk = []),
(this.size = 0));
}
},
{
key: 'write',
value: function (t) {
const r =
'string' == typeof t
? (function (t) {
const r = encodeURIComponent(t).replace(/%([0-9A-F]{2})/g, function (t, r) {
return String.fromCharCode('0x' + r);
}),
e = new Array(r.length);
return (
Array.prototype.forEach.call(r, function (t, r) {
e[r] = t.charCodeAt(0);
}),
e
);
})(t)
: t;
this.size += r.length;
let e = 64 - this.chunk.length;
if (r.length < e) this.chunk = this.chunk.concat(r);
else
for (this.chunk = this.chunk.concat(r.slice(0, e)); this.chunk.length >= 64; )
(this._compress(this.chunk),
e < r.length ? (this.chunk = r.slice(e, Math.min(e + 64, r.length))) : (this.chunk = []),
(e += 64));
}
},
{
key: 'sum',
value: function (t, r) {
(t && (this.reset(), this.write(t)), this._fill());
let e = 0;
for (; e < this.chunk.length; e += 64) this._compress(this.chunk.slice(e, e + 64));
let n,
o,
i,
u = null;
if ('hex' == r) {
u = '';
for (e = 0; e < 8; e++)
u +=
((n = this.reg[e].toString(16)),
(o = 8),
(i = '0'),
n.length >= o ? n : i.repeat(o - n.length) + n);
} else
for (u = new Array(32), e = 0; e < 8; e++) {
let s = this.reg[e];
((u[4 * e + 3] = (255 & s) >>> 0),
(s >>>= 8),
(u[4 * e + 2] = (255 & s) >>> 0),
(s >>>= 8),
(u[4 * e + 1] = (255 & s) >>> 0),
(s >>>= 8),
(u[4 * e] = (255 & s) >>> 0));
}
return (this.reset(), u);
}
},
{
key: '_compress',
value: function (t) {
if (t < 64) console.error('compress error: not enough data');
else {
let r = (function (t) {
const r = new Array(132);
for (let e = 0; e < 16; e++) {
r[e] = t[4 * e] << 24;
r[e] |= t[4 * e + 1] << 16;
r[e] |= t[4 * e + 2] << 8;
r[e] |= t[4 * e + 3];
r[e] >>>= 0;
}
for (let n = 16; n < 68; n++) {
let o = r[n - 16] ^ r[n - 9] ^ dr(r[n - 3], 15);
o = o ^ dr(o, 15) ^ dr(o, 23);
r[n] = (o ^ dr(r[n - 13], 7) ^ r[n - 6]) >>> 0;
}
for (let n = 0; n < 64; n++) r[n + 68] = (r[n] ^ r[n + 4]) >>> 0;
return r;
})(t);
let e = this.reg.slice(0);
for (let n = 0; n < 64; n++) {
let o = dr(e[0], 12) + e[4] + dr(yr(n), n),
i = ((o = dr((o = (4294967295 & o) >>> 0), 7)) ^ dr(e[0], 12)) >>> 0,
u = br(n, e[0], e[1], e[2]);
u = (4294967295 & (u = u + e[3] + i + r[n + 68])) >>> 0;
let s = mr(n, e[4], e[5], e[6]);
s = (4294967295 & (s = s + e[7] + o + r[n])) >>> 0;
e[3] = e[2];
e[2] = dr(e[1], 9);
e[1] = e[0];
e[0] = u;
e[7] = e[6];
e[6] = dr(e[5], 19);
e[5] = e[4];
e[4] = (s ^ dr(s, 9) ^ dr(s, 17)) >>> 0;
}
for (let c = 0; c < 8; c++) this.reg[c] = (this.reg[c] ^ e[c]) >>> 0;
}
}
},
{
key: '_fill',
value: function () {
let t = 8 * this.size,
r = this.chunk.push(128) % 64;
for (64 - r < 8 && (r -= 64); r < 56; r++) this.chunk.push(0);
for (let e = 0; e < 4; e++) {
let n = Math.floor(t / 4294967296);
this.chunk.push((n >>> (8 * (3 - e))) & 255);
}
for (let e = 0; e < 4; e++) this.chunk.push((t >>> (8 * (3 - e))) & 255);
}
}
]),
t
);
})();
function dr(t, r) {
return ((t << (r %= 32)) | (t >>> (32 - r))) >>> 0;
}
function yr(t) {
return 0 <= t && t < 16
? 2043430169
: 16 <= t && t < 64
? 2055708042
: void console.error('invalid j for constant Tj');
}
function br(t, r, e, n) {
return 0 <= t && t < 16
? (r ^ e ^ n) >>> 0
: 16 <= t && t < 64
? ((r & e) | (r & n) | (e & n)) >>> 0
: (console.error('invalid j for bool function FF'), 0);
}
function mr(t, r, e, n) {
return 0 <= t && t < 16
? (r ^ e ^ n) >>> 0
: 16 <= t && t < 64
? ((r & e) | (~r & n)) >>> 0
: (console.error('invalid j for bool function GG'), 0);
}
const enc_ = new gr();
return enc_.sum(n_str);
}
function generate_lm_g_EP(uat = ua) {
function get_sz256f() {
let r = [],
k = 0,
y = [0, 1, 0];
for (let i = 255; i >= 0; i--) {
r.push(i);
}
for (let i = 0; i < r.length; i++) {
let a = r[i];
k = (k * a + k + y[i % 3]) % 256;
let b = r[k];
((r[i] = b), (r[k] = a));
}
return r;
}
const sz256f = [
233, 5, 1, 249, 162, 140, 57, 143, 19, 203, 254, 236, 99, 248, 93, 213, 79, 149, 216, 50, 145, 123, 240, 92, 23,
113, 130, 53, 235, 220, 201, 136, 223, 155, 190, 242, 243, 42, 52, 214, 151, 232, 97, 187, 163, 222, 30, 78, 47,
71, 49, 170, 247, 196, 25, 156, 183, 182, 217, 180, 147, 124, 208, 69, 215, 200, 161, 154, 91, 60, 133, 224, 119,
164, 221, 45, 98, 40, 186, 120, 51, 167, 38, 90, 194, 212, 129, 56, 87, 195, 144, 44, 75, 84, 81, 13, 197, 245,
36, 250, 115, 100, 105, 252, 206, 103, 112, 202, 114, 138, 192, 21, 116, 173, 181, 29, 82, 125, 141, 16, 211, 131,
225, 118, 31, 101, 77, 146, 135, 150, 62, 66, 67, 176, 0, 41, 46, 59, 107, 178, 43, 26, 189, 128, 8, 207, 166,
110, 3, 229, 85, 54, 63, 11, 32, 4, 234, 142, 72, 58, 33, 231, 12, 230, 102, 86, 70, 159, 226, 65, 237, 34, 244,
76, 132, 122, 111, 95, 179, 152, 175, 18, 177, 6, 126, 193, 219, 74, 134, 2, 61, 251, 191, 168, 209, 241, 137,
165, 88, 238, 160, 174, 153, 157, 199, 48, 22, 64, 246, 7, 139, 55, 27, 188, 148, 204, 127, 171, 89, 37, 172, 205,
121, 20, 28, 17, 169, 15, 227, 117, 80, 218, 198, 10, 106, 9, 39, 210, 104, 83, 109, 24, 108, 228, 184, 96, 185,
158, 14, 255, 239, 68, 94, 35, 73, 253
];
let k = 0,
s = '';
for (let i = 0; i < uat.length; i++) {
let t = (i + 1) % 256;
let a = sz256f[t];
k = (k + a) % 256;
let c = sz256f[k];
sz256f[t] = c;
sz256f[k] = a;
s += String.fromCharCode(uat.charCodeAt(i) ^ sz256f[(a + c) % 256]);
}
return s;
}
function get_str_chr_list(one_str) {
const r = [];
for (let i = 0; i < one_str.length; i++) {
r.push(one_str.charCodeAt(i));
}
return r;
}
function generate_szenc_head8p1() {
let z = Math.random() * 65535;
let a = z & 255;
let b = (z >> 8) & 255,
d = [];
d.push((a & 170) | 1);
d.push((a & 85) | 0);
d.push((b & 170) | 0);
d.push((b & 85) | 0);
return d;
}
function generate_szenc_head8p2() {
let a = ((Math.random() * 240) >> 0) + 1;
let b = ((Math.random() * 255) >> 0) & 77,
c = [1, 4, 5, 7],
d = [];
for (let i = 0; i < c.length; i++) {
b = b | (1 << c[i]);
}
d.push((a & 170) | 1);
d.push((a & 85) | 0);
d.push((b & 170) | 0);
d.push((b & 85) | 0);
return d;
}
function get_szenc_tail(sz96) {
const zKeys = [145, 110, 66, 189, 44, 211];
const a = [];
for (let i = 0; i < 94; i += 3) {
let b = sz96[i];
let c = sz96[i + 1];
let d = sz96[i + 2];
let e = (Math.random() * 1000) & 255;
a.push((e & zKeys[0]) | (b & zKeys[1]));
a.push((e & zKeys[2]) | (c & zKeys[3]));
a.push((e & zKeys[4]) | (d & zKeys[5]));
a.push((b & zKeys[0]) | (c & zKeys[2]) | (d & zKeys[4]));
}
return a;
}
function generate_lm_g_ab_head4() {
let s = '';
const a = (Math.random() * 65535) & 255,
b = (Math.random() * 40) >> 0;
s += String.fromCharCode((a & 170) | 1);
s += String.fromCharCode((a & 85) | 2);
s += String.fromCharCode((b & 170) | 80);
s += String.fromCharCode((b & 85) | 2);
return s;
}
function get_list_str(one_list) {
let s = '';
for (let i = 0; i < one_list.length; i++) {
s += String.fromCharCode(one_list[i]);
}
return s;
}
function get_lm_g_ab(lm_g_lm_n) {
function getSZ256() {
const raw = [];
let z = 0;
for (let i = 255; i >= 0; i--) {
raw.push(i);
}
for (let i = 0; i < raw.length; i++) {
z += 211;
let a = z % 256;
let b = raw[i];
let c = raw[a];
raw[a] = b;
raw[i] = c;
z = raw[i + 1] * a + a;
}
return raw;
}
const fixedSZ256 = [
194, 249, 255, 165, 114, 67, 251, 187, 174, 231, 164, 237, 124, 235, 68, 83, 206, 79, 142, 167, 30, 77, 0, 93,
118, 29, 32, 161, 2, 171, 243, 179, 42, 170, 223, 119, 98, 222, 219, 57, 245, 135, 197, 13, 186, 202, 88, 184,
214, 12, 76, 185, 116, 74, 54, 53, 104, 208, 158, 163, 82, 173, 253, 240, 172, 63, 191, 207, 25, 15, 201, 203,
215, 236, 183, 233, 145, 127, 72, 6, 16, 10, 228, 35, 232, 159, 66, 168, 108, 71, 217, 75, 33, 155, 112, 128, 36,
24, 138, 50, 211, 23, 107, 14, 247, 137, 175, 242, 234, 157, 199, 49, 139, 85, 81, 17, 180, 86, 120, 78, 51, 205,
169, 148, 181, 3, 94, 106, 252, 220, 150, 47, 151, 84, 212, 18, 149, 182, 100, 123, 121, 156, 154, 152, 126, 204,
60, 133, 132, 248, 7, 91, 58, 59, 20, 97, 113, 117, 131, 46, 250, 224, 21, 73, 146, 31, 193, 69, 140, 125, 9, 39,
89, 5, 65, 141, 218, 80, 1, 70, 64, 166, 87, 189, 55, 147, 22, 26, 143, 61, 144, 99, 92, 44, 129, 130, 227, 103,
90, 192, 198, 244, 136, 101, 246, 153, 56, 38, 4, 178, 221, 162, 134, 37, 111, 28, 216, 96, 102, 210, 254, 196,
195, 230, 241, 62, 11, 122, 52, 40, 41, 229, 226, 225, 48, 45, 160, 105, 8, 115, 34, 43, 209, 95, 239, 190, 188,
109, 27, 19, 176, 213, 200, 238, 177, 110
];
// const fixedSZ256 = getSZ256();
let z = 0;
let st = '';
for (let i = 0; i < lm_g_lm_n.length; i++) {
let a = (i + 1) % 256;
let c = fixedSZ256[a];
z = (z + c) % 256;
let e = fixedSZ256[z];
fixedSZ256[a] = e;
fixedSZ256[z] = c;
let g = (e + c) % 256;
let h = lm_g_lm_n.charCodeAt(i);
let j = fixedSZ256[g];
let k = h ^ j;
let l = String.fromCharCode(k);
st += l;
}
return st;
}
function get_raw_ab(lm_get_ab_n, key_str = info_dic.s4) {
let s = '',
bw = 0;
for (let i = 0; i < lm_get_ab_n.length; i += 3) {
let cl = 16;
let tcz = 0;
let sof = 16515072;
for (let j = i; j < i + 3; j++) {
if (j < lm_get_ab_n.length) {
let tlcy = lm_get_ab_n.charCodeAt(j) & 255;
tcz = tcz | (tlcy << cl);
cl -= 8;
} else {
bw += 1;
}
}
for (let h = 18; h >= 6 * bw; h -= 6) {
let tsz = tcz & sof;
s += key_str[tsz >> h];
sof = sof / 64;
}
s += '='.repeat(bw);
}
return s;
}
function get_random_number(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
const info_dic = {
s0: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=',
s1: 'Dkdpgh4ZKsQB80/Mfvw36XI1R25+WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=',
s2: 'Dkdpgh4ZKsQB80/Mfvw36XI1R25-WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=',
s3: 'ckdp1h4ZKsUB80/Mfvw36XIgR25+WQAlEi7NLboqYTOPuzmFjJnryx9HVGDaStCe',
s4: 'Dkdpgh2ZmsQB80/MfvV36XI1R45-WUAlEixNLwoqYTOPuzKFjJnry79HbGcaStCe'
};
const t1 = Date.now();
const s = [];
const t2 = Date.now() - 1 + get_random_number(1, 3);
const EP = get_raw_ab(generate_lm_g_EP(ua), info_dic.s3);
const eEP = enc_sum(EP);
s.push('env_fx_list', 'dpf_ua_dic', 1, 0, 8, 'dpf', '', 'ua', 6241, 6383, '1.0.1.19-fix.01', 'ink', 3, '0X21_dic'); // 固定即可
const t3 = Date.now() + get_random_number(4, 15);
const eedp = enc_sum(enc_sum(query + 'dhzx'));
s.push(t3, 'reg_dic', 1, 0, eedp, 'eedh', EP, eEP, t2, [3, 82], 41, [1, 0, 1, 0, 1]);
const t4 = Date.now() + get_random_number(100, 1000);
const s1 = ((t4 - 1721836800000) / 1000 / 60 / 60 / 24 / 14) >> 0,
szenc_o95_tail41 = [
49, 52, 52, 49, 124, 56, 51, 56, 124, 49, 52, 52, 49, 124, 57, 49, 51, 124, 49, 52, 52, 49, 124, 57, 49, 51, 124,
49, 52, 52, 49, 124, 57, 54, 49, 124, 87, 105, 110, 51, 50
];
s.push(
s1,
6,
(t3 - t1 + 3) & 255,
t3 & 255,
(t3 >> 8) & 255,
(t3 >> 16) & 255,
(t3 >> 24) & 255,
(t3 / 256 / 256 / 256 / 256) & 255
);
const s2 = (t3 / 256 / 256 / 256 / 256 / 256) & 255;
s.push(
s2,
(s2 % 256) & 255,
(s2 / 256) & 255,
[211, 2, 5, 1, 129],
129,
0,
211,
2,
5,
1,
0,
0,
0,
0,
eedp[9],
eedp[18],
3,
eedp[3],
82,
177,
4,
44,
eEP[11],
eEP[21],
5,
eEP[5],
t2 & 255,
(t2 >> 8) & 255,
(t2 >> 16) & 255,
(t2 >> 24) & 255,
(t2 / 256 / 256 / 256 / 256) & 255,
(t2 / 256 / 256 / 256 / 256 / 256) & 255,
3,
97,
24,
0,
0,
239,
24,
0,
0,
'screec_dic',
'screen_str',
szenc_o95_tail41,
41,
41,
0
);
const s3 = ((t3 + 3) & 255) + ',',
s4 = get_str_chr_list(s3);
s.push(s3, s4, s4.length, s4.length & 255, (s4.length >> 8) & 255);
const szenc_head8_p1 = generate_szenc_head8p1(),
szenc_head8_p2 = generate_szenc_head8p2(),
szenc_head8 = szenc_head8_p1.concat(szenc_head8_p2),
s5 = [],
s6 = [
24, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 51, 52, 53, 55,
56, 57, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 79, 80, 84, 85
];
for (let i = 0; i < s6.length; i++) {
s5.push(s[s6[i]]);
}
s.push(szenc_head8);
const s7 = szenc_head8.concat(s5);
let s8 = s7[0];
for (let i = 1; i < s7.length; i++) {
s8 = s8 ^ s7[i];
}
s.push(s8);
const enc_s_i = [
34, 44, 56, 61, 73, 29, 70, 45, 35, 49, 38, 66, 51, 68, 28, 48, 64, 47, 30, 71, 26, 55, 31, 69, 59, 40, 62, 63,
27, 72, 41, 74, 57, 52, 42, 39, 33, 67, 53, 43, 65, 46, 36, 24, 60, 32, 79, 80, 84, 85
],
szenc_o95_head50 = [];
for (let i = 0; i < enc_s_i.length; i++) {
szenc_o95_head50.push(s[enc_s_i[i]]);
}
let szenc_o95 = [];
szenc_o95 = szenc_o95.concat(szenc_o95_head50, szenc_o95_tail41, s4, [s8]);
const szenc_tail = get_szenc_tail(szenc_o95),
szenc = szenc_head8.concat(szenc_tail),
lm_get_ab_head4 = generate_lm_g_ab_head4();
const lm_get_lm = get_list_str(szenc);
const lm_get_ab_tail = get_lm_g_ab(lm_get_lm);
const lm_get_ab = lm_get_ab_head4 + lm_get_ab_tail;
const ab = get_raw_ab(lm_get_ab);
return ab;
};
@@ -0,0 +1,7 @@
// index.js
const sign = require('./abogus');
module.exports = function (query, userAgent) {
return sign(query, userAgent);
};
@@ -0,0 +1,8 @@
const path = require("path");
const sign = require(path.join(__dirname, "index.js"));
const query = process.argv[2] || "";
const userAgent = process.argv[3] || "";
process.stdout.write(sign(query, userAgent));
File diff suppressed because one or more lines are too long
@@ -0,0 +1,25 @@
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Infrastructure.Platforms.Huya;
public sealed class HuyaLivePlatformAdapter : ILivePlatformAdapter
{
public LivePlatformType PlatformType => LivePlatformType.Huya;
public bool CanHandle(string input) =>
!string.IsNullOrWhiteSpace(input) &&
input.Contains("huya.com", StringComparison.OrdinalIgnoreCase);
public Task<ParsedLiveRoom> ParseRoomAsync(string input, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("Huya 适配器尚未实现。");
public Task<LiveStatusSnapshot> GetLiveStatusAsync(string roomId, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("Huya 适配器尚未实现。");
public Task<StreamUrlResult> GetStreamUrlAsync(
string roomId,
string? preferredQuality = null,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException("Huya 适配器尚未实现。");
}
@@ -0,0 +1,265 @@
using System.Net;
using System.Net.Mail;
using System.Text;
using System.Text.RegularExpressions;
using LiveRecorder.Application.Abstractions.Notifications;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Models.Settings;
using LiveRecorder.Domain.Entities;
using Microsoft.Extensions.Logging;
namespace LiveRecorder.Infrastructure.Services;
public sealed class EmailNotificationService : IEmailNotificationService
{
private const string AppName = "LiveRecorder";
private static readonly Regex TemplateTokenRegex = new("""\{\{\s*(?<name>[a-zA-Z0-9_]+)\s*\}\}""", RegexOptions.Compiled);
private readonly ISystemSettingsService _systemSettingsService;
private readonly ILogger<EmailNotificationService> _logger;
public EmailNotificationService(
ISystemSettingsService systemSettingsService,
ILogger<EmailNotificationService> logger)
{
_systemSettingsService = systemSettingsService;
_logger = logger;
}
public async Task SendLiveStartedAsync(LiveRoom liveRoom, CancellationToken cancellationToken = default)
{
var settings = await _systemSettingsService.GetAsync(cancellationToken);
if (!settings.EnableEmailNotification || !settings.NotifyOnLiveStarted)
{
return;
}
var tokens = CreateTokenMap(new Dictionary<string, string?>
{
["platform"] = liveRoom.Platform.ToString(),
["roomId"] = liveRoom.RoomId,
["title"] = liveRoom.Title,
["anchor"] = liveRoom.AnchorName,
["sourceUrl"] = liveRoom.SourceUrl,
["detectedAtUtc"] = DateTimeOffset.UtcNow.ToString("O")
});
var subject = RenderSubject(settings.EmailLiveStartedSubjectTemplate, tokens);
var body = RenderHtml(settings.EmailLiveStartedBodyTemplateHtml, tokens);
await SendAsync(settings, subject, body, cancellationToken);
}
public async Task SendExceptionAsync(
string source,
string summary,
string? detail = null,
LiveRoom? liveRoom = null,
RecordTask? recordTask = null,
CancellationToken cancellationToken = default)
{
var settings = await _systemSettingsService.GetAsync(cancellationToken);
if (!settings.EnableEmailNotification || !settings.NotifyOnException)
{
return;
}
var tokens = CreateTokenMap(new Dictionary<string, string?>
{
["source"] = source,
["summary"] = summary,
["detail"] = detail,
["liveRoomId"] = liveRoom?.Id.ToString(),
["roomId"] = liveRoom?.RoomId,
["recordTaskId"] = recordTask?.Id.ToString(),
["taskStatus"] = recordTask?.Status.ToString(),
["occurredAtUtc"] = DateTimeOffset.UtcNow.ToString("O")
});
var subject = RenderSubject(settings.EmailExceptionSubjectTemplate, tokens);
var body = RenderHtml(settings.EmailExceptionBodyTemplateHtml, tokens);
await SendAsync(settings, subject, body, cancellationToken);
}
public async Task SendTestAsync(SendTestEmailRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var settings = new SystemSettingsDto
{
EnableEmailNotification = true,
NotifyOnLiveStarted = true,
NotifyOnException = true,
EmailSmtpHost = request.EmailSmtpHost.Trim(),
EmailSmtpPort = request.EmailSmtpPort,
EmailUseSsl = request.EmailUseSsl,
EmailUsername = request.EmailUsername.Trim(),
EmailPassword = request.EmailPassword,
EmailFromAddress = request.EmailFromAddress.Trim(),
EmailFromDisplayName = request.EmailFromDisplayName.Trim(),
EmailToAddresses = request.EmailToAddresses.Trim(),
EmailLiveStartedSubjectTemplate = request.EmailLiveStartedSubjectTemplate,
EmailLiveStartedBodyTemplateHtml = request.EmailLiveStartedBodyTemplateHtml,
EmailExceptionSubjectTemplate = request.EmailExceptionSubjectTemplate,
EmailExceptionBodyTemplateHtml = request.EmailExceptionBodyTemplateHtml
};
var sampleLiveTokens = CreateTokenMap(new Dictionary<string, string?>
{
["platform"] = "Douyin",
["roomId"] = "123456789",
["title"] = "Sample Live Title",
["anchor"] = "Sample Anchor",
["sourceUrl"] = "https://live.douyin.com/123456789",
["detectedAtUtc"] = DateTimeOffset.UtcNow.ToString("O")
});
var sampleExceptionTokens = CreateTokenMap(new Dictionary<string, string?>
{
["source"] = "Scheduler",
["summary"] = "Background polling failed for a live room.",
["detail"] = "Sample stack trace or diagnostic detail goes here.",
["liveRoomId"] = Guid.NewGuid().ToString(),
["roomId"] = "123456789",
["recordTaskId"] = Guid.NewGuid().ToString(),
["taskStatus"] = "Running",
["occurredAtUtc"] = DateTimeOffset.UtcNow.ToString("O")
});
var body = $$"""
<!DOCTYPE html>
<html lang="en">
<body style="margin: 0; padding: 24px; background: #f5f7fb; font-family: 'Segoe UI', 'PingFang SC', sans-serif; color: #1f2937;">
<div style="max-width: 880px; margin: 0 auto;">
<h1 style="margin: 0 0 20px; color: #2f4c66;">LiveRecorder SMTP template test</h1>
<div style="margin-bottom: 18px; padding: 18px 20px; border-radius: 12px; background: #ffffff; border: 1px solid #d9e1ea;">
<h2 style="margin: 0 0 14px; color: #3e5f7c;"></h2>
<div style="margin-bottom: 10px; font-size: 13px; color: #6b7280;"><strong>Subject:</strong> {{WebUtility.HtmlEncode(RenderSubject(settings.EmailLiveStartedSubjectTemplate, sampleLiveTokens))}}</div>
{{RenderHtml(settings.EmailLiveStartedBodyTemplateHtml, sampleLiveTokens)}}
</div>
<div style="padding: 18px 20px; border-radius: 12px; background: #ffffff; border: 1px solid #d9e1ea;">
<h2 style="margin: 0 0 14px; color: #8b5e3c;"></h2>
<div style="margin-bottom: 10px; font-size: 13px; color: #6b7280;"><strong>Subject:</strong> {{WebUtility.HtmlEncode(RenderSubject(settings.EmailExceptionSubjectTemplate, sampleExceptionTokens))}}</div>
{{RenderHtml(settings.EmailExceptionBodyTemplateHtml, sampleExceptionTokens)}}
</div>
</div>
</body>
</html>
""";
await SendAsync(settings, "[LiveRecorder] SMTP template test", body, cancellationToken, swallowErrors: false);
}
private async Task SendAsync(
SystemSettingsDto settings,
string subject,
string body,
CancellationToken cancellationToken,
bool swallowErrors = true)
{
var recipients = ParseRecipients(settings.EmailToAddresses);
if (string.IsNullOrWhiteSpace(settings.EmailSmtpHost) ||
string.IsNullOrWhiteSpace(settings.EmailFromAddress) ||
recipients.Count == 0)
{
if (swallowErrors)
{
return;
}
throw new InvalidOperationException("SMTP host, sender address or recipient list is missing.");
}
try
{
using var message = new MailMessage
{
From = new MailAddress(settings.EmailFromAddress, settings.EmailFromDisplayName),
Subject = subject,
Body = body,
BodyEncoding = Encoding.UTF8,
SubjectEncoding = Encoding.UTF8,
IsBodyHtml = true
};
foreach (var recipient in recipients)
{
message.To.Add(recipient);
}
using var client = new SmtpClient(settings.EmailSmtpHost, settings.EmailSmtpPort)
{
EnableSsl = settings.EmailUseSsl,
DeliveryMethod = SmtpDeliveryMethod.Network
};
if (!string.IsNullOrWhiteSpace(settings.EmailUsername))
{
client.Credentials = new NetworkCredential(settings.EmailUsername, settings.EmailPassword);
}
using var registration = cancellationToken.Register(client.SendAsyncCancel);
await client.SendMailAsync(message);
}
catch (Exception ex)
{
if (!swallowErrors)
{
throw new InvalidOperationException($"SMTP test email send failed: {ex.Message}", ex);
}
_logger.LogWarning(ex, "Email notification send failed");
}
}
private static List<string> ParseRecipients(string rawAddresses) =>
rawAddresses
.Split([',', ';', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(static item => !string.IsNullOrWhiteSpace(item))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
private static Dictionary<string, string> CreateTokenMap(IReadOnlyDictionary<string, string?> rawTokens)
{
var tokens = rawTokens.ToDictionary(
static pair => pair.Key,
static pair => pair.Value?.Trim() ?? string.Empty,
StringComparer.OrdinalIgnoreCase);
tokens["appName"] = AppName;
return tokens;
}
private static string RenderSubject(string template, IReadOnlyDictionary<string, string> tokens)
{
var rendered = RenderTemplate(template, tokens, htmlEncodeValues: false);
rendered = rendered.Replace("\r", " ").Replace("\n", " ").Trim();
return string.IsNullOrWhiteSpace(rendered) ? $"[{AppName}] Notification" : rendered;
}
private static string RenderHtml(string template, IReadOnlyDictionary<string, string> tokens)
{
var rendered = RenderTemplate(template, tokens, htmlEncodeValues: true).Trim();
return string.IsNullOrWhiteSpace(rendered) ? "<div></div>" : rendered;
}
private static string RenderTemplate(string template, IReadOnlyDictionary<string, string> tokens, bool htmlEncodeValues)
{
if (string.IsNullOrWhiteSpace(template))
{
return string.Empty;
}
return TemplateTokenRegex.Replace(
template,
match =>
{
var tokenName = match.Groups["name"].Value;
if (!tokens.TryGetValue(tokenName, out var value))
{
return string.Empty;
}
return htmlEncodeValues ? WebUtility.HtmlEncode(value) : value;
});
}
}
@@ -0,0 +1,767 @@
using System.Diagnostics;
using LiveRecorder.Application.Abstractions.Logging;
using LiveRecorder.Application.Abstractions.Notifications;
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Services;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
using LiveRecorder.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace LiveRecorder.Infrastructure.Services;
public sealed partial class FfmpegService
{
private async Task HandleProcessOutputAsync(SessionProcessRuntime runtime, string? line, bool isError)
{
if (string.IsNullOrWhiteSpace(line))
{
return;
}
_logger.LogDebug("ffmpeg[{SessionId}] {Line}", runtime.RecordSessionId, line);
if (TryParseSegmentOpenPath(line, out var openedPath))
{
await HandleSegmentOpenedAsync(runtime, openedPath);
return;
}
if (!runtime.HasOpenedFirstSegment && IsOptionCompatibilityFailureLine(line))
{
runtime.MarkStartupFailure(StartupFailureKind.InputOptionCompatibility, line);
}
else if (!runtime.HasOpenedFirstSegment && IsRetryableStartupFailureLine(line))
{
runtime.MarkStartupFailure(StartupFailureKind.StreamHandshake, line);
}
if (line.Contains("error", StringComparison.OrdinalIgnoreCase) ||
line.Contains("fail", StringComparison.OrdinalIgnoreCase) ||
line.Contains("timed out", StringComparison.OrdinalIgnoreCase))
{
await PersistFfmpegLineAsync(runtime, line, isError ? SystemLogLevel.Error : SystemLogLevel.Warning);
}
}
private async Task PersistFfmpegLineAsync(SessionProcessRuntime runtime, string line, SystemLogLevel level)
{
try
{
using var scope = _serviceScopeFactory.CreateScope();
var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>();
await logService.WriteAsync(
level,
"FFmpeg",
"ffmpeg reported a warning or error line.",
line,
runtime.LiveRoomId,
runtime.RecordSessionId,
runtime.CurrentTaskId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Persist ffmpeg output failed for session {RecordSessionId}", runtime.RecordSessionId);
}
}
private async Task HandleSegmentOpenedAsync(SessionProcessRuntime runtime, string openedPath)
{
await runtime.Gate.WaitAsync();
try
{
using var scope = _serviceScopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
var session = await dbContext.RecordSessions
.Include(item => item.RecordTasks)
.ThenInclude(item => item.Result)
.FirstOrDefaultAsync(item => item.Id == runtime.RecordSessionId);
if (session is null)
{
return;
}
var now = DateTimeOffset.UtcNow;
var currentTask = session.RecordTasks.FirstOrDefault(item => item.Id == runtime.CurrentTaskId);
if (currentTask is null)
{
return;
}
if (runtime.SaveMode == RecordSaveMode.SingleFile)
{
runtime.HasOpenedFirstSegment = true;
currentTask.AttachProcess(runtime.ProcessId, now);
currentTask.MarkRunning(now);
session.AttachProcess(runtime.ProcessId, now);
session.MarkRunning(now);
session.ActivateSegment(1, now);
if (!runtime.HasInitializedDanmaku)
{
await EnsureDanmakuSegmentAsync(runtime, currentTask, now);
runtime.HasInitializedDanmaku = true;
}
await dbContext.SaveChangesAsync();
return;
}
var segmentIndex = string.Equals(openedPath, runtime.CurrentOutputFilePath, StringComparison.OrdinalIgnoreCase)
? Math.Max(1, runtime.CurrentSegmentIndex)
: Math.Max(1, runtime.CurrentSegmentIndex + 1);
if (!runtime.HasInitializedDanmaku)
{
await EnsureDanmakuSegmentAsync(runtime, currentTask, now);
runtime.HasInitializedDanmaku = true;
}
if (segmentIndex == runtime.CurrentSegmentIndex)
{
runtime.HasOpenedFirstSegment = true;
currentTask.MarkStarting(runtime.StreamUrl, openedPath, currentTask.StartedAt ?? now);
currentTask.AttachProcess(runtime.ProcessId, now);
currentTask.MarkRunning(now);
session.AttachProcess(runtime.ProcessId, now);
session.MarkRunning(now);
session.ActivateSegment(segmentIndex, now);
runtime.CurrentOutputFilePath = openedPath;
await dbContext.SaveChangesAsync();
return;
}
var previousTask = currentTask;
var previousTaskId = previousTask.Id;
var previousOutputPath = runtime.CurrentOutputFilePath;
var newTask = new RecordTask(
session.LiveRoomId,
session.Id,
segmentIndex,
session.PreferredQuality,
session.OutputFormat,
now);
newTask.MarkStarting(runtime.StreamUrl, openedPath, now);
newTask.AttachProcess(runtime.ProcessId, now);
newTask.MarkRunning(now);
await dbContext.RecordTasks.AddAsync(newTask);
previousTask.MarkCompleted(
now,
previousTask.StartedAt.HasValue ? Math.Max(0, (now - previousTask.StartedAt.Value).TotalSeconds) : null);
previousTask.DetachProcess(now);
session.AttachProcess(runtime.ProcessId, now);
session.MarkRunning(now);
session.ActivateSegment(segmentIndex, now);
await dbContext.SaveChangesAsync();
if (runtime.DanmakuRecorder is not null)
{
await runtime.DanmakuRecorder.StartSegmentAsync(newTask.Id, segmentIndex, openedPath, now);
}
var previousDanmakuSummary = runtime.DanmakuRecorder?.TakeSummary(previousTaskId);
UpsertRecordResult(
previousTask,
dbContext,
previousOutputPath,
CalculateFileSize(previousOutputPath),
previousTask.DurationSeconds,
previousDanmakuSummary?.FilePath,
previousDanmakuSummary?.MessageCount ?? 0,
now);
await dbContext.SaveChangesAsync();
runtime.CurrentTaskId = newTask.Id;
runtime.CurrentSegmentIndex = segmentIndex;
runtime.CurrentOutputFilePath = openedPath;
runtime.HasOpenedFirstSegment = true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Handle segment open failed for session {RecordSessionId}", runtime.RecordSessionId);
}
finally
{
runtime.Gate.Release();
}
}
private async Task HandleProcessExitedAsync(SessionProcessRuntime runtime, Process process)
{
_processes.TryRemove(runtime.RecordSessionId, out _);
try
{
runtime.DanmakuCancellation.Cancel();
if (runtime.DanmakuPumpTask is not null)
{
try
{
await runtime.DanmakuPumpTask;
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Danmaku pump ended with error for session {RecordSessionId}", runtime.RecordSessionId);
}
}
SessionDanmakuXmlRecorder.DanmakuSegmentSummary? activeDanmakuSummary = null;
if (runtime.DanmakuRecorder is not null)
{
activeDanmakuSummary = await runtime.DanmakuRecorder.CompleteActiveSegmentAsync();
await runtime.DanmakuRecorder.DisposeAsync();
}
if (runtime.DanmakuConnection is not null)
{
await runtime.DanmakuConnection.DisposeAsync();
}
if (await TryRecoverStartupFailureAsync(runtime))
{
return;
}
await FinalizeExitedSessionAsync(runtime, process, activeDanmakuSummary);
}
catch (Exception ex)
{
_logger.LogError(ex, "Handle ffmpeg exit failed for session {RecordSessionId}", runtime.RecordSessionId);
}
finally
{
runtime.ExitCompletion.TrySetResult(true);
runtime.Dispose();
process.Dispose();
}
}
private async Task<bool> TryRecoverStartupFailureAsync(SessionProcessRuntime runtime)
{
if (runtime.HasOpenedFirstSegment ||
runtime.StartupFailureKind == StartupFailureKind.None ||
string.IsNullOrWhiteSpace(runtime.LastStartupFailureLine))
{
return false;
}
try
{
using var scope = _serviceScopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>();
var session = await dbContext.RecordSessions
.Include(item => item.LiveRoom)
.Include(item => item.RecordTasks)
.ThenInclude(item => item.Result)
.FirstOrDefaultAsync(item => item.Id == runtime.RecordSessionId);
if (session?.LiveRoom is null || !IsActiveSessionStatus(session.Status))
{
return false;
}
var currentTask = session.RecordTasks
.OrderBy(item => item.SegmentIndex)
.ThenBy(item => item.CreatedAt)
.FirstOrDefault(item => item.Id == runtime.CurrentTaskId);
if (currentTask is null || !IsActiveTaskStatus(currentTask.Status))
{
return false;
}
var observedAt = DateTimeOffset.UtcNow;
if (runtime.StartupFailureKind == StartupFailureKind.InputOptionCompatibility)
{
if (runtime.HasRetriedWithCompatibilityProfile)
{
return false;
}
await logService.WriteAsync(
SystemLogLevel.Warning,
"FFmpeg",
"Initial ffmpeg input profile was rejected. Retrying once with minimal compatibility options.",
runtime.LastStartupFailureLine,
session.LiveRoomId,
session.Id,
currentTask.Id);
var retryStream = new StreamUrlResult(
runtime.SelectedQuality,
runtime.SelectedProtocol,
runtime.StreamUrl,
runtime.InputHeaders,
Array.Empty<StreamQualityOption>());
session.MarkStarting(retryStream.SelectedUrl, session.OutputPathPattern ?? runtime.OutputPathPattern, observedAt);
session.ActivateSegment(Math.Max(1, runtime.CurrentSegmentIndex), observedAt);
currentTask.MarkStarting(retryStream.SelectedUrl, currentTask.OutputFilePath ?? runtime.CurrentOutputFilePath, observedAt);
await dbContext.SaveChangesAsync();
await StartInternalAsync(
session,
currentTask,
retryStream,
FfmpegInputOptionProfile.Minimal,
hasRetriedWithCompatibilityProfile: true,
hasRetriedWithRefreshedStream: runtime.HasRetriedWithRefreshedStream,
runtime.RetryAttemptCount + 1);
var restartedAt = DateTimeOffset.UtcNow;
session.MarkRunning(restartedAt);
currentTask.MarkRunning(restartedAt);
await dbContext.SaveChangesAsync();
await logService.WriteAsync(
SystemLogLevel.Info,
"FFmpeg",
"ffmpeg startup retry succeeded with minimal compatibility options.",
liveRoomId: session.LiveRoomId,
recordSessionId: session.Id,
recordTaskId: currentTask.Id);
return true;
}
if (runtime.StartupFailureKind != StartupFailureKind.StreamHandshake || runtime.HasRetriedWithRefreshedStream)
{
return false;
}
var adapterFactory = scope.ServiceProvider.GetRequiredService<ILivePlatformAdapterFactory>();
var liveRoomStatusService = scope.ServiceProvider.GetRequiredService<LiveRoomStatusService>();
var adapter = adapterFactory.GetByPlatform(session.LiveRoom.Platform);
var liveStatus = await adapter.GetLiveStatusAsync(session.LiveRoom.RoomId);
await liveRoomStatusService.ApplySnapshotAsync(session.LiveRoom, liveStatus, observedAt);
await dbContext.SaveChangesAsync();
if (!liveStatus.IsLive)
{
return false;
}
await logService.WriteAsync(
SystemLogLevel.Warning,
"FFmpeg",
"Initial ffmpeg input handshake failed. Refreshing stream URL and retrying once.",
runtime.LastStartupFailureLine,
session.LiveRoomId,
session.Id,
currentTask.Id);
var refreshedStream = await adapter.GetStreamUrlAsync(session.LiveRoom.RoomId, session.PreferredQuality);
session.MarkStarting(refreshedStream.SelectedUrl, session.OutputPathPattern ?? runtime.OutputPathPattern, observedAt);
session.ActivateSegment(Math.Max(1, runtime.CurrentSegmentIndex), observedAt);
currentTask.MarkStarting(refreshedStream.SelectedUrl, currentTask.OutputFilePath ?? runtime.CurrentOutputFilePath, observedAt);
await dbContext.SaveChangesAsync();
await StartInternalAsync(
session,
currentTask,
refreshedStream,
runtime.InputOptionProfile,
hasRetriedWithCompatibilityProfile: runtime.HasRetriedWithCompatibilityProfile,
hasRetriedWithRefreshedStream: true,
runtime.RetryAttemptCount + 1);
var refreshedRetryStartedAt = DateTimeOffset.UtcNow;
session.MarkRunning(refreshedRetryStartedAt);
currentTask.MarkRunning(refreshedRetryStartedAt);
await dbContext.SaveChangesAsync();
await logService.WriteAsync(
SystemLogLevel.Info,
"FFmpeg",
"ffmpeg startup retry succeeded with a refreshed stream URL.",
liveRoomId: session.LiveRoomId,
recordSessionId: session.Id,
recordTaskId: currentTask.Id);
return true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Recover ffmpeg startup failure failed for session {RecordSessionId}", runtime.RecordSessionId);
return false;
}
}
private async Task FinalizeExitedSessionAsync(
SessionProcessRuntime runtime,
Process process,
SessionDanmakuXmlRecorder.DanmakuSegmentSummary? activeDanmakuSummary)
{
using var scope = _serviceScopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>();
var settingsService = scope.ServiceProvider.GetRequiredService<ISystemSettingsService>();
var emailNotificationService = scope.ServiceProvider.GetRequiredService<IEmailNotificationService>();
var settings = await settingsService.GetAsync();
var session = await dbContext.RecordSessions
.Include(item => item.LiveRoom)
.Include(item => item.RecordTasks)
.ThenInclude(item => item.Result)
.FirstOrDefaultAsync(item => item.Id == runtime.RecordSessionId);
if (session is null)
{
return;
}
var currentTask = session.RecordTasks.FirstOrDefault(item => item.Id == runtime.CurrentTaskId)
?? session.RecordTasks.OrderByDescending(static item => item.SegmentIndex).FirstOrDefault();
if (currentTask is null)
{
return;
}
var endedAt = DateTimeOffset.UtcNow;
string? finalizationError = null;
var effectiveOutputPath = runtime.CurrentOutputFilePath;
if (session.SaveMode == RecordSaveMode.SingleFile &&
session.OutputFormat == RecordOutputFormat.Mp4 &&
!string.IsNullOrWhiteSpace(session.OutputPathPattern))
{
var finalOutputPath = Path.IsPathRooted(session.OutputPathPattern)
? session.OutputPathPattern
: Path.GetFullPath(session.OutputPathPattern, AppContext.BaseDirectory);
var finalizationResult = await TryFinalizeMp4Async(settings.FfmpegPath, runtime.RecorderOutputPath, finalOutputPath);
effectiveOutputPath = finalizationResult.OutputPath;
finalizationError = finalizationResult.ErrorMessage;
}
var fileSize = CalculateFileSize(effectiveOutputPath);
var durationSeconds = currentTask.StartedAt.HasValue
? (double?)Math.Max(0, (endedAt - currentTask.StartedAt.Value).TotalSeconds)
: null;
var danmakuPath = activeDanmakuSummary?.FilePath ?? currentTask.Result?.DanmakuFilePath ?? GuessDanmakuPath(currentTask.OutputFilePath);
var danmakuMessageCount = activeDanmakuSummary?.MessageCount ?? CountDanmakuMessages(danmakuPath);
if (!string.IsNullOrWhiteSpace(finalizationError))
{
currentTask.MarkFailed(finalizationError, endedAt);
session.MarkFailed(finalizationError, endedAt);
}
else if (runtime.StopRequested)
{
currentTask.MarkStopped(endedAt, durationSeconds);
session.MarkStopped(endedAt);
}
else if (process.ExitCode == 0 && (runtime.CompletionRequested || !runtime.StopRequested))
{
currentTask.MarkCompleted(endedAt, durationSeconds);
session.MarkCompleted(endedAt);
}
else
{
var errorMessage = $"ffmpeg exit code: {process.ExitCode}";
currentTask.MarkFailed(errorMessage, endedAt);
session.MarkFailed(errorMessage, endedAt);
}
currentTask.DetachProcess(endedAt);
session.SyncSegmentCount(session.RecordTasks.Count, endedAt);
UpsertRecordResult(currentTask, dbContext, effectiveOutputPath, fileSize, durationSeconds, danmakuPath, danmakuMessageCount, endedAt);
await dbContext.SaveChangesAsync();
await logService.WriteAsync(
session.Status == RecordSessionStatus.Completed ? SystemLogLevel.Info :
session.Status == RecordSessionStatus.Stopped ? SystemLogLevel.Warning :
SystemLogLevel.Error,
"FFmpeg",
$"Recording session exited with status={session.Status}.",
$"exitCode={process.ExitCode}; output={effectiveOutputPath}; recorderOutput={runtime.RecorderOutputPath}",
session.LiveRoomId,
session.Id,
currentTask.Id);
if (session.Status == RecordSessionStatus.Failed && session.LiveRoom is not null)
{
await emailNotificationService.SendExceptionAsync(
"FFmpeg",
"Recording session exited abnormally.",
$"exitCode={process.ExitCode}; output={effectiveOutputPath}",
session.LiveRoom,
currentTask);
}
}
private async Task PersistProcessBindingAsync(Guid recordSessionId, Guid recordTaskId, int processId, CancellationToken cancellationToken)
{
using var scope = _serviceScopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
var session = await dbContext.RecordSessions.FirstOrDefaultAsync(item => item.Id == recordSessionId, cancellationToken);
var task = await dbContext.RecordTasks.FirstOrDefaultAsync(item => item.Id == recordTaskId, cancellationToken);
var now = DateTimeOffset.UtcNow;
session?.AttachProcess(processId, now);
task?.AttachProcess(processId, now);
if (session is not null || task is not null)
{
await dbContext.SaveChangesAsync(cancellationToken);
}
}
private async Task StartDanmakuAsync(SessionProcessRuntime runtime, RecordTask initialTask, CancellationToken cancellationToken)
{
try
{
LiveRoom? liveRoom = null;
var includeNonChatEvents = true;
using (var readScope = _serviceScopeFactory.CreateScope())
{
var dbContext = readScope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
var adapterFactory = readScope.ServiceProvider.GetRequiredService<ILiveDanmakuAdapterFactory>();
var settingsService = readScope.ServiceProvider.GetRequiredService<ISystemSettingsService>();
var settings = await settingsService.GetAsync(cancellationToken);
if (!settings.EnableDanmakuRecording)
{
return;
}
liveRoom = await dbContext.LiveRooms
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == runtime.LiveRoomId, cancellationToken);
if (liveRoom is null)
{
return;
}
includeNonChatEvents = settings.DanmakuIncludeNonChatEvents;
var adapter = adapterFactory.GetByPlatform(liveRoom.Platform);
runtime.DanmakuConnection = await adapter.ConnectAsync(
new DanmakuConnectionContext(
liveRoom.Id,
runtime.RecordSessionId,
liveRoom.Platform,
liveRoom.RoomId,
liveRoom.AnchorName,
liveRoom.Title,
liveRoom.SourceUrl,
settings.DanmakuMinPollIntervalMilliseconds,
settings.DanmakuRetryDelayMaxSeconds),
cancellationToken);
}
runtime.DanmakuRecorder = new SessionDanmakuXmlRecorder(
liveRoom.Platform,
liveRoom.Id,
runtime.RecordSessionId,
liveRoom.RoomId);
await runtime.DanmakuRecorder.StartSegmentAsync(
initialTask.Id,
initialTask.SegmentIndex,
initialTask.OutputFilePath ?? runtime.OutputPathPattern,
initialTask.StartedAt ?? DateTimeOffset.UtcNow);
runtime.HasInitializedDanmaku = true;
runtime.DanmakuPumpTask = Task.Run(
() => runtime.DanmakuConnection.StartAsync(
danmakuEvent =>
{
if (runtime.DanmakuRecorder is null)
{
return Task.CompletedTask;
}
if (!includeNonChatEvents &&
!string.Equals(danmakuEvent.Type, "chat", StringComparison.OrdinalIgnoreCase))
{
return Task.CompletedTask;
}
return runtime.DanmakuRecorder.AppendAsync(danmakuEvent);
},
runtime.DanmakuCancellation.Token),
runtime.DanmakuCancellation.Token);
using var logScope = _serviceScopeFactory.CreateScope();
var logService = logScope.ServiceProvider.GetRequiredService<ISystemLogService>();
await logService.WriteAsync(
SystemLogLevel.Info,
"Danmaku",
"Danmaku capture started for the recording session.",
liveRoomId: liveRoom.Id,
recordSessionId: runtime.RecordSessionId,
recordTaskId: initialTask.Id,
cancellationToken: cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Start danmaku capture failed for session {RecordSessionId}", runtime.RecordSessionId);
try
{
using var scope = _serviceScopeFactory.CreateScope();
var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>();
await logService.WriteAsync(
SystemLogLevel.Warning,
"Danmaku",
"Danmaku capture startup failed. Recording will continue without live comments until retry succeeds.",
ex.ToString(),
runtime.LiveRoomId,
runtime.RecordSessionId,
runtime.CurrentTaskId,
cancellationToken);
}
catch (Exception logEx)
{
_logger.LogWarning(logEx, "Persist danmaku startup failure log failed for session {RecordSessionId}", runtime.RecordSessionId);
}
}
}
private static bool IsRetryableStartupFailureLine(string line) =>
line.Contains("Error reading HTTP response", StringComparison.OrdinalIgnoreCase) ||
line.Contains("unexpected EOF", StringComparison.OrdinalIgnoreCase) ||
line.Contains("Connection reset", StringComparison.OrdinalIgnoreCase) ||
line.Contains("Connection refused", StringComparison.OrdinalIgnoreCase) ||
line.Contains("I/O error", StringComparison.OrdinalIgnoreCase) ||
line.Contains("Invalid data found when processing input", StringComparison.OrdinalIgnoreCase) ||
line.Contains("Server returned 4", StringComparison.OrdinalIgnoreCase) ||
line.Contains("Server returned 5", StringComparison.OrdinalIgnoreCase) ||
line.Contains("HTTP error 4", StringComparison.OrdinalIgnoreCase) ||
line.Contains("HTTP error 5", StringComparison.OrdinalIgnoreCase);
private static bool IsOptionCompatibilityFailureLine(string line) =>
line.Contains("Option not found", StringComparison.OrdinalIgnoreCase) ||
line.Contains("Unrecognized option", StringComparison.OrdinalIgnoreCase);
private async Task EnsureDanmakuSegmentAsync(SessionProcessRuntime runtime, RecordTask recordTask, DateTimeOffset startedAt)
{
if (runtime.DanmakuRecorder is null)
{
return;
}
await runtime.DanmakuRecorder.StartSegmentAsync(
recordTask.Id,
recordTask.SegmentIndex,
recordTask.OutputFilePath ?? runtime.CurrentOutputFilePath,
startedAt);
}
private sealed class SessionProcessRuntime : IDisposable
{
public SessionProcessRuntime(
Guid recordSessionId,
Guid liveRoomId,
string streamUrl,
string outputPathPattern,
string recorderOutputPath,
RecordOutputFormat outputFormat,
RecordSaveMode saveMode,
Guid currentTaskId,
int currentSegmentIndex,
string currentOutputFilePath,
string selectedQuality,
string selectedProtocol,
StreamInputHeaders? inputHeaders,
FfmpegInputOptionProfile inputOptionProfile,
bool hasRetriedWithCompatibilityProfile,
bool hasRetriedWithRefreshedStream,
int retryAttemptCount)
{
RecordSessionId = recordSessionId;
LiveRoomId = liveRoomId;
StreamUrl = streamUrl;
OutputPathPattern = outputPathPattern;
RecorderOutputPath = recorderOutputPath;
OutputFormat = outputFormat;
SaveMode = saveMode;
CurrentTaskId = currentTaskId;
CurrentSegmentIndex = currentSegmentIndex;
CurrentOutputFilePath = currentOutputFilePath;
SelectedQuality = selectedQuality;
SelectedProtocol = selectedProtocol;
InputHeaders = inputHeaders;
InputOptionProfile = inputOptionProfile;
HasRetriedWithCompatibilityProfile = hasRetriedWithCompatibilityProfile;
HasRetriedWithRefreshedStream = hasRetriedWithRefreshedStream;
RetryAttemptCount = Math.Max(0, retryAttemptCount);
}
public Guid RecordSessionId { get; }
public Guid LiveRoomId { get; }
public string StreamUrl { get; }
public string OutputPathPattern { get; }
public string RecorderOutputPath { get; }
public RecordOutputFormat OutputFormat { get; }
public RecordSaveMode SaveMode { get; }
public Guid CurrentTaskId { get; set; }
public int CurrentSegmentIndex { get; set; }
public string CurrentOutputFilePath { get; set; }
public string SelectedQuality { get; }
public string SelectedProtocol { get; }
public StreamInputHeaders? InputHeaders { get; }
public FfmpegInputOptionProfile InputOptionProfile { get; }
public bool HasRetriedWithCompatibilityProfile { get; }
public bool HasRetriedWithRefreshedStream { get; }
public Process? Process { get; private set; }
public int ProcessId => Process?.Id ?? 0;
public bool CompletionRequested { get; private set; }
public bool StopRequested { get; private set; }
public bool HasInitializedDanmaku { get; set; }
public bool HasOpenedFirstSegment { get; set; }
public int RetryAttemptCount { get; }
public StartupFailureKind StartupFailureKind { get; private set; }
public string? LastStartupFailureLine { get; private set; }
public SemaphoreSlim Gate { get; } = new(1, 1);
public CancellationTokenSource DanmakuCancellation { get; } = new();
public TaskCompletionSource<bool> ExitCompletion { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public SessionDanmakuXmlRecorder? DanmakuRecorder { get; set; }
public ILiveDanmakuConnection? DanmakuConnection { get; set; }
public Task? DanmakuPumpTask { get; set; }
public void AttachProcess(Process process) => Process = process;
public void MarkStopRequested(bool markAsCompletedOnExit)
{
if (markAsCompletedOnExit)
{
CompletionRequested = true;
StopRequested = false;
}
else
{
CompletionRequested = false;
StopRequested = true;
}
}
public void MarkStartupFailure(StartupFailureKind kind, string line)
{
if (StartupFailureKind == StartupFailureKind.InputOptionCompatibility &&
kind != StartupFailureKind.InputOptionCompatibility)
{
return;
}
StartupFailureKind = kind;
LastStartupFailureLine = line;
}
public void Dispose()
{
DanmakuCancellation.Dispose();
Gate.Dispose();
}
}
}
@@ -0,0 +1,399 @@
using System.Diagnostics;
using System.Text;
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
using LiveRecorder.Infrastructure.Persistence;
namespace LiveRecorder.Infrastructure.Services;
public sealed partial class FfmpegService
{
private static async Task<(string OutputPath, string? ErrorMessage)> TryFinalizeMp4Async(
string ffmpegPath,
string sourcePath,
string targetPath)
{
if (!File.Exists(sourcePath))
{
return (File.Exists(targetPath) ? targetPath : sourcePath, "The intermediate recording file was not found for MP4 finalization.");
}
var tempPath = Path.Combine(
Path.GetDirectoryName(targetPath)!,
$"{Path.GetFileNameWithoutExtension(targetPath)}.remux{Path.GetExtension(targetPath)}");
if (File.Exists(tempPath))
{
File.Delete(tempPath);
}
var remuxProcess = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = ffmpegPath,
Arguments = $"-hide_banner -y -i {Quote(sourcePath)} -c copy -movflags +faststart {Quote(tempPath)}",
UseShellExecute = false,
RedirectStandardError = true,
RedirectStandardOutput = true,
CreateNoWindow = true
}
};
remuxProcess.Start();
await remuxProcess.WaitForExitAsync();
if (remuxProcess.ExitCode == 0 && File.Exists(tempPath))
{
if (File.Exists(targetPath))
{
File.Replace(tempPath, targetPath, null, ignoreMetadataErrors: true);
}
else
{
File.Move(tempPath, targetPath);
}
if (!string.Equals(sourcePath, targetPath, StringComparison.OrdinalIgnoreCase) && File.Exists(sourcePath))
{
File.Delete(sourcePath);
}
return (targetPath, null);
}
if (File.Exists(tempPath))
{
File.Delete(tempPath);
}
var fallbackPath = File.Exists(targetPath) ? targetPath : sourcePath;
return (fallbackPath, "The MP4 file could not be finalized into a seekable output.");
}
private static IReadOnlyList<string> BuildArgumentList(
string streamUrl,
string outputFilePath,
RecordOutputFormat outputFormat,
RecordSaveMode saveMode,
RecordingTemplateType recordingTemplate,
bool enableReconnect,
int reconnectDelayMaxSeconds,
int readWriteTimeoutMilliseconds,
int segmentDurationMinutes,
StreamInputHeaders? inputHeaders,
FfmpegInputOptionProfile inputOptionProfile)
{
var arguments = new List<string> { "-hide_banner", "-y" };
var useIntermediateTransportStream = ShouldUseIntermediateTransportStream(outputFilePath, outputFormat, saveMode);
if (IsHttpInput(streamUrl))
{
if (!string.IsNullOrWhiteSpace(inputHeaders?.UserAgent))
{
arguments.AddRange(["-user_agent", inputHeaders.UserAgent]);
}
if (!string.IsNullOrWhiteSpace(inputHeaders?.Referer))
{
arguments.AddRange(["-referer", inputHeaders.Referer]);
}
var customHeaders = BuildCustomHeaderArgument(inputHeaders);
if (!string.IsNullOrWhiteSpace(customHeaders))
{
arguments.AddRange(["-headers", customHeaders]);
}
}
if (enableReconnect && inputOptionProfile == FfmpegInputOptionProfile.Baseline)
{
arguments.AddRange(
[
"-reconnect", "1",
"-reconnect_streamed", "1",
"-reconnect_at_eof", "1",
"-reconnect_delay_max", reconnectDelayMaxSeconds.ToString()
]);
}
arguments.AddRange(["-rw_timeout", readWriteTimeoutMilliseconds.ToString(), "-fflags", "+discardcorrupt+genpts", "-i", streamUrl]);
arguments.AddRange(BuildCodecArguments(recordingTemplate));
if (saveMode == RecordSaveMode.Segmented)
{
arguments.AddRange(
[
"-f", "segment",
"-segment_start_number", "1",
"-segment_time", Math.Max(60, segmentDurationMinutes * 60).ToString(),
"-reset_timestamps", "1",
"-strftime", "0",
"-segment_format", outputFormat == RecordOutputFormat.Ts ? "mpegts" : "mp4"
]);
if (outputFormat == RecordOutputFormat.Mp4)
{
arguments.AddRange(["-segment_format_options", $"movflags={BuildSegmentedMp4MovFlags()}"]);
}
}
else if (useIntermediateTransportStream)
{
arguments.AddRange(["-f", "mpegts"]);
}
else if (outputFormat == RecordOutputFormat.Mp4)
{
arguments.AddRange(["-movflags", BuildSingleFileMp4MovFlags()]);
}
arguments.Add(outputFilePath);
return arguments;
}
private static IReadOnlyList<string> BuildCodecArguments(RecordingTemplateType recordingTemplate) =>
recordingTemplate switch
{
RecordingTemplateType.BalancedMp4 =>
[
"-c:v", "libx264",
"-preset", "veryfast",
"-crf", "23",
"-c:a", "aac",
"-b:a", "128k"
],
RecordingTemplateType.ArchiveTs =>
[
"-map", "0",
"-c", "copy"
],
_ =>
[
"-c", "copy"
]
};
private static long? CalculateFileSize(string? outputPath)
{
if (string.IsNullOrWhiteSpace(outputPath))
{
return null;
}
if (File.Exists(outputPath))
{
return new FileInfo(outputPath).Length;
}
if (Directory.Exists(outputPath))
{
return new DirectoryInfo(outputPath)
.EnumerateFiles("*", SearchOption.TopDirectoryOnly)
.Sum(static file => file.Length);
}
return null;
}
private static bool HasUsableOutput(string? outputPath, long? fileSize)
{
if (string.IsNullOrWhiteSpace(outputPath))
{
return false;
}
if (File.Exists(outputPath))
{
return fileSize.GetValueOrDefault() > 0;
}
if (Directory.Exists(outputPath))
{
return Directory.EnumerateFiles(outputPath, "*", SearchOption.TopDirectoryOnly).Any();
}
return false;
}
private static void UpsertRecordResult(
RecordTask recordTask,
LiveRecorderDbContext dbContext,
string? effectiveOutputPath,
long? fileSize,
double? durationSeconds,
string? danmakuFilePath,
int danmakuMessageCount,
DateTimeOffset endedAt)
{
if (string.IsNullOrWhiteSpace(effectiveOutputPath))
{
effectiveOutputPath = recordTask.OutputFilePath ?? string.Empty;
}
if (recordTask.Result is null)
{
dbContext.RecordResults.Add(new RecordResult(
recordTask.Id,
effectiveOutputPath,
fileSize,
durationSeconds,
danmakuFilePath,
danmakuMessageCount,
recordTask.Status,
recordTask.ErrorMessage,
endedAt));
return;
}
recordTask.Result.Update(
effectiveOutputPath,
fileSize,
durationSeconds,
danmakuFilePath,
danmakuMessageCount,
recordTask.Status,
recordTask.ErrorMessage);
}
private static string BuildSingleFileMp4MovFlags() =>
"+faststart+frag_keyframe+empty_moov+default_base_moof";
private static string BuildSegmentedMp4MovFlags() =>
"+faststart+frag_keyframe+empty_moov+default_base_moof";
private static string GetRecorderOutputPath(
string finalOutputPath,
RecordOutputFormat outputFormat,
RecordSaveMode saveMode)
{
if (saveMode == RecordSaveMode.SingleFile && outputFormat == RecordOutputFormat.Mp4)
{
return Path.Combine(
Path.GetDirectoryName(finalOutputPath)!,
$"{Path.GetFileNameWithoutExtension(finalOutputPath)}.recording.ts");
}
return finalOutputPath;
}
private static bool ShouldUseIntermediateTransportStream(
string outputPath,
RecordOutputFormat outputFormat,
RecordSaveMode saveMode) =>
saveMode == RecordSaveMode.SingleFile &&
outputFormat == RecordOutputFormat.Mp4 &&
outputPath.EndsWith(".ts", StringComparison.OrdinalIgnoreCase);
private static bool IsHttpInput(string streamUrl) =>
streamUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
streamUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase);
private static string? BuildCustomHeaderArgument(StreamInputHeaders? inputHeaders)
{
if (inputHeaders is null)
{
return null;
}
var builder = new StringBuilder();
if (!string.IsNullOrWhiteSpace(inputHeaders.Cookie))
{
builder.Append("Cookie: ");
builder.Append(inputHeaders.Cookie.Trim());
builder.Append("\r\n");
}
if (inputHeaders.AdditionalHeaders is not null)
{
foreach (var pair in inputHeaders.AdditionalHeaders.Where(static pair => !string.IsNullOrWhiteSpace(pair.Key)))
{
builder.Append(pair.Key.Trim());
builder.Append(": ");
builder.Append(pair.Value?.Trim() ?? string.Empty);
builder.Append("\r\n");
}
}
return builder.Length == 0 ? null : builder.ToString();
}
private static bool TryParseSegmentOpenPath(string line, out string openedPath)
{
var match = SegmentOpeningRegex.Match(line);
if (match.Success)
{
openedPath = match.Groups[1].Value;
return true;
}
openedPath = string.Empty;
return false;
}
private static int? ExtractSegmentIndex(string openedPath)
{
var fileName = Path.GetFileNameWithoutExtension(openedPath);
var lastUnderscore = fileName.LastIndexOf('_');
if (lastUnderscore < 0 || lastUnderscore == fileName.Length - 1)
{
return null;
}
var suffix = fileName[(lastUnderscore + 1)..];
return int.TryParse(suffix, out var value) ? value : null;
}
private static string? GuessDanmakuPath(string? outputFilePath)
{
if (string.IsNullOrWhiteSpace(outputFilePath))
{
return null;
}
var fullPath = Path.IsPathRooted(outputFilePath)
? outputFilePath
: Path.GetFullPath(outputFilePath, AppContext.BaseDirectory);
return SessionDanmakuXmlRecorder.GetDanmakuFilePath(fullPath);
}
private static int CountDanmakuMessages(string? danmakuPath)
{
if (string.IsNullOrWhiteSpace(danmakuPath) || !File.Exists(danmakuPath))
{
return 0;
}
var count = 0;
foreach (var line in File.ReadLines(danmakuPath))
{
if (line.Contains("<d ", StringComparison.OrdinalIgnoreCase) ||
line.Contains("<event ", StringComparison.OrdinalIgnoreCase))
{
count++;
}
}
return count;
}
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 Quote(string value) => $"\"{value.Replace("\"", "\\\"", StringComparison.Ordinal)}\"";
private enum FfmpegInputOptionProfile
{
Baseline = 0,
Minimal = 1
}
private enum StartupFailureKind
{
None = 0,
InputOptionCompatibility = 1,
StreamHandshake = 2
}
}
@@ -0,0 +1,380 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Text.RegularExpressions;
using LiveRecorder.Application.Abstractions.Logging;
using LiveRecorder.Application.Abstractions.Notifications;
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Application.Abstractions.Recording;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
using LiveRecorder.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace LiveRecorder.Infrastructure.Services;
public sealed partial class FfmpegService : IFfmpegService
{
private static readonly Regex SegmentOpeningRegex = new(
"""Opening '([^']+)' for writing""",
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
private readonly ConcurrentDictionary<Guid, SessionProcessRuntime> _processes = new();
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly ILogger<FfmpegService> _logger;
public FfmpegService(
IServiceScopeFactory serviceScopeFactory,
ILogger<FfmpegService> logger)
{
_serviceScopeFactory = serviceScopeFactory;
_logger = logger;
}
public bool IsRunning(Guid recordSessionId) => _processes.ContainsKey(recordSessionId);
public Task CompleteAsync(Guid recordSessionId, CancellationToken cancellationToken = default) =>
RequestStopAsync(recordSessionId, markAsCompletedOnExit: true, cancellationToken);
public Task StartAsync(
RecordSession recordSession,
RecordTask initialTask,
StreamUrlResult streamUrlResult,
CancellationToken cancellationToken = default) =>
StartInternalAsync(
recordSession,
initialTask,
streamUrlResult,
inputOptionProfile: FfmpegInputOptionProfile.Baseline,
hasRetriedWithCompatibilityProfile: false,
hasRetriedWithRefreshedStream: false,
retryAttemptCount: 0,
cancellationToken);
private async Task StartInternalAsync(
RecordSession recordSession,
RecordTask initialTask,
StreamUrlResult streamUrlResult,
FfmpegInputOptionProfile inputOptionProfile,
bool hasRetriedWithCompatibilityProfile,
bool hasRetriedWithRefreshedStream,
int retryAttemptCount,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(recordSession);
ArgumentNullException.ThrowIfNull(initialTask);
ArgumentNullException.ThrowIfNull(streamUrlResult);
if (string.IsNullOrWhiteSpace(streamUrlResult.SelectedUrl) || string.IsNullOrWhiteSpace(recordSession.OutputPathPattern))
{
throw new InvalidOperationException("Recording session is missing stream URL or output path.");
}
using var settingsScope = _serviceScopeFactory.CreateScope();
var settingsService = settingsScope.ServiceProvider.GetRequiredService<ISystemSettingsService>();
var settings = await settingsService.GetAsync(cancellationToken);
var outputPathPattern = Path.IsPathRooted(recordSession.OutputPathPattern)
? recordSession.OutputPathPattern
: Path.GetFullPath(recordSession.OutputPathPattern, AppContext.BaseDirectory);
var recorderOutputPath = GetRecorderOutputPath(outputPathPattern, recordSession.OutputFormat, recordSession.SaveMode);
Directory.CreateDirectory(Path.GetDirectoryName(recorderOutputPath)!);
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = settings.FfmpegPath,
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
},
EnableRaisingEvents = true
};
var runtime = new SessionProcessRuntime(
recordSession.Id,
recordSession.LiveRoomId,
streamUrlResult.SelectedUrl,
outputPathPattern,
recorderOutputPath,
recordSession.OutputFormat,
recordSession.SaveMode,
initialTask.Id,
Math.Max(1, initialTask.SegmentIndex),
initialTask.OutputFilePath ?? outputPathPattern,
streamUrlResult.SelectedQuality,
streamUrlResult.SelectedProtocol,
streamUrlResult.InputHeaders,
inputOptionProfile,
hasRetriedWithCompatibilityProfile,
hasRetriedWithRefreshedStream,
retryAttemptCount);
foreach (var argument in BuildArgumentList(
streamUrlResult.SelectedUrl,
recorderOutputPath,
recordSession.OutputFormat,
recordSession.SaveMode,
settings.RecordingTemplate,
settings.EnableAutoReconnect,
settings.ReconnectDelayMaxSeconds,
settings.ReadWriteTimeoutMilliseconds,
settings.SegmentDurationMinutes,
streamUrlResult.InputHeaders,
inputOptionProfile))
{
process.StartInfo.ArgumentList.Add(argument);
}
process.OutputDataReceived += (_, args) => _ = HandleProcessOutputAsync(runtime, args.Data, isError: false);
process.ErrorDataReceived += (_, args) => _ = HandleProcessOutputAsync(runtime, args.Data, isError: true);
process.Exited += (_, _) => _ = HandleProcessExitedAsync(runtime, process);
if (!process.Start())
{
throw new InvalidOperationException("ffmpeg failed to start.");
}
runtime.AttachProcess(process);
if (!_processes.TryAdd(recordSession.Id, runtime))
{
process.Kill(true);
process.Dispose();
throw new InvalidOperationException("A running ffmpeg process already exists for the recording session.");
}
await PersistProcessBindingAsync(recordSession.Id, initialTask.Id, process.Id, cancellationToken);
await PersistStartupProfileAsync(runtime, cancellationToken);
await StartDanmakuAsync(runtime, initialTask, cancellationToken);
process.BeginOutputReadLine();
process.BeginErrorReadLine();
}
public Task StopAsync(Guid recordSessionId, CancellationToken cancellationToken = default) =>
RequestStopAsync(recordSessionId, markAsCompletedOnExit: false, cancellationToken);
public async Task<bool> StopAndWaitAsync(
Guid recordSessionId,
bool markAsCompletedOnExit,
TimeSpan timeout,
CancellationToken cancellationToken = default)
{
if (!_processes.TryGetValue(recordSessionId, out var runtime))
{
return true;
}
await RequestStopAsync(recordSessionId, markAsCompletedOnExit, cancellationToken);
return await WaitForExitAsync(runtime, timeout, cancellationToken);
}
public async Task<bool> KillAndWaitAsync(
Guid recordSessionId,
TimeSpan timeout,
CancellationToken cancellationToken = default)
{
if (!_processes.TryGetValue(recordSessionId, out var runtime))
{
return true;
}
var process = runtime.Process;
if (process is not null)
{
try
{
if (!process.HasExited)
{
process.Kill(true);
}
}
catch (InvalidOperationException)
{
}
}
return await WaitForExitAsync(runtime, timeout, cancellationToken);
}
public async Task<bool> TryReconcileInactiveSessionAsync(Guid recordSessionId, CancellationToken cancellationToken = default)
{
if (IsRunning(recordSessionId))
{
return false;
}
using var scope = _serviceScopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
var settingsService = scope.ServiceProvider.GetRequiredService<ISystemSettingsService>();
var recordSession = await dbContext.RecordSessions
.Include(item => item.LiveRoom)
.Include(item => item.RecordTasks)
.ThenInclude(item => item.Result)
.FirstOrDefaultAsync(item => item.Id == recordSessionId, cancellationToken);
if (recordSession is null || !IsActiveSessionStatus(recordSession.Status))
{
return false;
}
var settings = await settingsService.GetAsync(cancellationToken);
var endedAt = DateTimeOffset.UtcNow;
var tasks = recordSession.RecordTasks
.OrderBy(static item => item.SegmentIndex)
.ThenBy(static item => item.CreatedAt)
.ToList();
var anyUsableOutput = false;
string? finalizationError = null;
foreach (var task in tasks.Where(item => IsActiveTaskStatus(item.Status)))
{
var effectiveOutputPath = task.OutputFilePath ?? string.Empty;
if (recordSession.SaveMode == RecordSaveMode.SingleFile &&
recordSession.OutputFormat == RecordOutputFormat.Mp4 &&
!string.IsNullOrWhiteSpace(recordSession.OutputPathPattern))
{
var finalOutputPath = Path.IsPathRooted(recordSession.OutputPathPattern)
? recordSession.OutputPathPattern
: Path.GetFullPath(recordSession.OutputPathPattern, AppContext.BaseDirectory);
var recorderOutputPath = GetRecorderOutputPath(finalOutputPath, recordSession.OutputFormat, recordSession.SaveMode);
if (File.Exists(recorderOutputPath))
{
var finalizationResult = await TryFinalizeMp4Async(settings.FfmpegPath, recorderOutputPath, finalOutputPath);
effectiveOutputPath = finalizationResult.OutputPath;
finalizationError ??= finalizationResult.ErrorMessage;
}
}
var fileSize = CalculateFileSize(effectiveOutputPath);
var danmakuPath = task.Result?.DanmakuFilePath ?? GuessDanmakuPath(task.OutputFilePath);
var danmakuCount = CountDanmakuMessages(danmakuPath);
var durationSeconds = task.StartedAt.HasValue
? (double?)Math.Max(0, (endedAt - task.StartedAt.Value).TotalSeconds)
: null;
if (!string.IsNullOrWhiteSpace(finalizationError))
{
task.MarkFailed(finalizationError, endedAt);
}
else if (HasUsableOutput(effectiveOutputPath, fileSize))
{
task.MarkCompleted(endedAt, durationSeconds);
anyUsableOutput = true;
}
else
{
task.MarkStopped(endedAt, durationSeconds, "Recording process was no longer running when the session was reconciled.");
}
UpsertRecordResult(task, dbContext, effectiveOutputPath, fileSize, durationSeconds, danmakuPath, danmakuCount, endedAt);
}
recordSession.SyncSegmentCount(tasks.Count, endedAt);
if (!string.IsNullOrWhiteSpace(finalizationError))
{
recordSession.MarkFailed(finalizationError, endedAt);
}
else if (anyUsableOutput)
{
recordSession.MarkCompleted(endedAt);
}
else
{
recordSession.MarkStopped(endedAt, "Recording process was no longer running when the session was reconciled.");
}
await dbContext.SaveChangesAsync(cancellationToken);
return true;
}
private async Task PersistStartupProfileAsync(SessionProcessRuntime runtime, CancellationToken cancellationToken)
{
using var scope = _serviceScopeFactory.CreateScope();
var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>();
await logService.WriteAsync(
SystemLogLevel.Info,
"FFmpeg",
$"ffmpeg input profile={runtime.InputOptionProfile}.",
detail: $"stream={runtime.SelectedProtocol}:{runtime.SelectedQuality}; compatibilityRetry={runtime.HasRetriedWithCompatibilityProfile}; refreshRetry={runtime.HasRetriedWithRefreshedStream}",
liveRoomId: runtime.LiveRoomId,
recordSessionId: runtime.RecordSessionId,
recordTaskId: runtime.CurrentTaskId,
cancellationToken: cancellationToken);
}
private static async Task<bool> WaitForExitAsync(
SessionProcessRuntime runtime,
TimeSpan timeout,
CancellationToken cancellationToken)
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var delayTask = Task.Delay(timeout, timeoutCts.Token);
var completed = await Task.WhenAny(runtime.ExitCompletion.Task, delayTask);
if (completed == runtime.ExitCompletion.Task)
{
timeoutCts.Cancel();
await runtime.ExitCompletion.Task;
return true;
}
return false;
}
private async Task RequestStopAsync(Guid recordSessionId, bool markAsCompletedOnExit, CancellationToken cancellationToken)
{
if (!_processes.TryGetValue(recordSessionId, out var runtime))
{
return;
}
runtime.MarkStopRequested(markAsCompletedOnExit);
var process = runtime.Process;
if (process is null)
{
return;
}
try
{
if (process.HasExited)
{
return;
}
await process.StandardInput.WriteLineAsync("q");
await process.StandardInput.FlushAsync();
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(12));
try
{
await process.WaitForExitAsync(timeoutCts.Token);
}
catch (OperationCanceledException)
{
if (!process.HasExited)
{
process.Kill(true);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Stop ffmpeg process failed for session {RecordSessionId}", recordSessionId);
if (!process.HasExited)
{
process.Kill(true);
}
}
}
}
@@ -0,0 +1,243 @@
using LiveRecorder.Application.Abstractions.Logging;
using LiveRecorder.Application.Abstractions.Notifications;
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Application.Abstractions.Recording;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Models.RecordTasks;
using LiveRecorder.Application.Services;
using LiveRecorder.Domain.Enums;
using LiveRecorder.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace LiveRecorder.Infrastructure.Services;
public sealed class LiveRoomPollingBackgroundService : BackgroundService
{
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly ILogger<LiveRoomPollingBackgroundService> _logger;
public LiveRoomPollingBackgroundService(
IServiceScopeFactory serviceScopeFactory,
ILogger<LiveRoomPollingBackgroundService> logger)
{
_serviceScopeFactory = serviceScopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var delaySeconds = 60;
try
{
using var scope = _serviceScopeFactory.CreateScope();
var settingsService = scope.ServiceProvider.GetRequiredService<ISystemSettingsService>();
var settings = await settingsService.GetAsync(stoppingToken);
var emailNotificationService = scope.ServiceProvider.GetRequiredService<IEmailNotificationService>();
delaySeconds = settings.PollingIntervalSeconds;
if (!settings.EnableBackgroundPolling)
{
await DelayAsync(delaySeconds, stoppingToken);
continue;
}
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
var adapterFactory = scope.ServiceProvider.GetRequiredService<ILivePlatformAdapterFactory>();
var ffmpegService = scope.ServiceProvider.GetRequiredService<IFfmpegService>();
var recordService = scope.ServiceProvider.GetRequiredService<RecordService>();
var liveRoomStatusService = scope.ServiceProvider.GetRequiredService<LiveRoomStatusService>();
var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>();
var liveRooms = (await dbContext.LiveRooms.ToListAsync(stoppingToken))
.Where(static item => item.IsEnabled)
.OrderBy(static item => item.UpdatedAt)
.ToList();
foreach (var liveRoom in liveRooms)
{
if (stoppingToken.IsCancellationRequested)
{
break;
}
try
{
var adapter = adapterFactory.GetByPlatform(liveRoom.Platform);
var liveStatus = await adapter.GetLiveStatusAsync(liveRoom.RoomId, stoppingToken);
var now = DateTimeOffset.UtcNow;
await liveRoomStatusService.ApplySnapshotAsync(liveRoom, liveStatus, now, stoppingToken);
await dbContext.SaveChangesAsync(stoppingToken);
if (!liveStatus.IsLive)
{
await CompleteActiveSessionsForOfflineRoomAsync(
dbContext,
ffmpegService,
logService,
liveRoom.Id,
stoppingToken);
continue;
}
if (!settings.AutoStartRecordingOnLive)
{
continue;
}
var hasRunningSession = await dbContext.RecordSessions.AnyAsync(
item => item.LiveRoomId == liveRoom.Id &&
(item.Status == RecordSessionStatus.Starting ||
item.Status == RecordSessionStatus.Running ||
item.Status == RecordSessionStatus.Stopping),
stoppingToken);
if (hasRunningSession)
{
continue;
}
_logger.LogInformation("Auto-start recording for live room {RoomId}", liveRoom.RoomId);
await logService.WriteAsync(
SystemLogLevel.Info,
"Scheduler",
"Live detected by background poller. Auto-starting recording task.",
liveRoomId: liveRoom.Id,
cancellationToken: stoppingToken);
await recordService.StartAsync(
new StartRecordTaskRequest
{
LiveRoomId = liveRoom.Id,
PreferredQuality = settings.DefaultQuality,
OutputFormat = settings.DefaultOutputFormat
},
stoppingToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Polling room {RoomId} failed", liveRoom.RoomId);
var isTransient = IsTransientPollingException(ex, stoppingToken);
await logService.WriteAsync(
isTransient ? SystemLogLevel.Warning : SystemLogLevel.Error,
"Scheduler",
isTransient
? "Transient background polling failure. The room will be retried on the next cycle."
: "Background polling failed for a live room.",
ex.ToString(),
liveRoomId: liveRoom.Id,
cancellationToken: stoppingToken);
if (!isTransient)
{
await emailNotificationService.SendExceptionAsync(
"Scheduler",
"Background polling failed for a live room.",
ex.ToString(),
liveRoom,
cancellationToken: stoppingToken);
}
}
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Background live room polling failed");
try
{
using var notificationScope = _serviceScopeFactory.CreateScope();
var emailNotificationService = notificationScope.ServiceProvider.GetRequiredService<IEmailNotificationService>();
await emailNotificationService.SendExceptionAsync(
"Scheduler",
"Background live room polling failed.",
ex.ToString(),
cancellationToken: stoppingToken);
}
catch (Exception notificationEx)
{
_logger.LogWarning(notificationEx, "Scheduler failure notification send failed");
}
}
await DelayAsync(delaySeconds, stoppingToken);
}
}
private static Task DelayAsync(int delaySeconds, CancellationToken cancellationToken) =>
Task.Delay(TimeSpan.FromSeconds(Math.Clamp(delaySeconds, 10, 3600)), cancellationToken);
private static async Task CompleteActiveSessionsForOfflineRoomAsync(
LiveRecorderDbContext dbContext,
IFfmpegService ffmpegService,
ISystemLogService logService,
Guid liveRoomId,
CancellationToken cancellationToken)
{
var activeSessions = await dbContext.RecordSessions
.Where(item => item.LiveRoomId == liveRoomId &&
(item.Status == RecordSessionStatus.Starting ||
item.Status == RecordSessionStatus.Running ||
item.Status == RecordSessionStatus.Stopping))
.ToListAsync(cancellationToken);
activeSessions = activeSessions
.OrderBy(item => item.CreatedAt)
.ToList();
foreach (var activeSession in activeSessions)
{
if (ffmpegService.IsRunning(activeSession.Id))
{
await ffmpegService.CompleteAsync(activeSession.Id, cancellationToken);
await logService.WriteAsync(
SystemLogLevel.Info,
"Scheduler",
"Live room is offline. Completing the active recording session.",
liveRoomId: liveRoomId,
recordSessionId: activeSession.Id,
cancellationToken: cancellationToken);
continue;
}
if (await ffmpegService.TryReconcileInactiveSessionAsync(activeSession.Id, cancellationToken))
{
await logService.WriteAsync(
SystemLogLevel.Warning,
"Scheduler",
"Recovered a stale active recording session after the room was detected offline.",
liveRoomId: liveRoomId,
recordSessionId: activeSession.Id,
cancellationToken: cancellationToken);
}
}
}
private static bool IsTransientPollingException(Exception exception, CancellationToken cancellationToken)
{
if (exception is OperationCanceledException && cancellationToken.IsCancellationRequested)
{
return false;
}
if (exception is HttpRequestException or IOException or TimeoutException)
{
return true;
}
return exception.InnerException is not null &&
IsTransientPollingException(exception.InnerException, cancellationToken);
}
}
@@ -0,0 +1,102 @@
using System.Security.Cryptography;
using LiveRecorder.Application.Abstractions.Persistence;
using LiveRecorder.Application.Abstractions.Recording;
using LiveRecorder.Domain.Enums;
using Microsoft.Extensions.Caching.Memory;
namespace LiveRecorder.Infrastructure.Services;
public sealed class RecordMediaService : IRecordMediaService
{
private static readonly TimeSpan PreviewTicketLifetime = TimeSpan.FromMinutes(5);
private readonly IMemoryCache _memoryCache;
private readonly IRecordTaskRepository _recordTaskRepository;
private readonly IRecordResultRepository _recordResultRepository;
public RecordMediaService(
IMemoryCache memoryCache,
IRecordTaskRepository recordTaskRepository,
IRecordResultRepository recordResultRepository)
{
_memoryCache = memoryCache;
_recordTaskRepository = recordTaskRepository;
_recordResultRepository = recordResultRepository;
}
public async Task<RecordPreviewTicketGrant> CreatePreviewTicketAsync(Guid recordTaskId, CancellationToken cancellationToken = default)
{
var recordTask = await _recordTaskRepository.GetByIdAsync(recordTaskId, cancellationToken)
?? throw new KeyNotFoundException("Recording task was not found.");
if (recordTask.Status != RecordTaskStatus.Completed)
{
throw new InvalidOperationException("Preview is only available for completed tasks.");
}
if (recordTask.OutputFormat != RecordOutputFormat.Mp4)
{
throw new NotSupportedException("Only MP4 recordings are supported for preview.");
}
var recordResult = await _recordResultRepository.GetByTaskIdAsync(recordTaskId, cancellationToken);
var filePath = recordResult?.FilePath ?? recordTask.OutputFilePath;
if (string.IsNullOrWhiteSpace(filePath))
{
throw new InvalidOperationException("Preview file path is missing.");
}
var absoluteFilePath = Path.IsPathRooted(filePath)
? filePath
: Path.GetFullPath(filePath, AppContext.BaseDirectory);
if (Directory.Exists(absoluteFilePath))
{
throw new NotSupportedException("Segmented recordings are not supported for preview.");
}
if (!absoluteFilePath.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase))
{
throw new NotSupportedException("Only MP4 recordings are supported for preview.");
}
if (!File.Exists(absoluteFilePath))
{
throw new InvalidOperationException("Preview file does not exist on disk.");
}
var ticket = Convert.ToHexString(RandomNumberGenerator.GetBytes(24));
var expiresAt = DateTimeOffset.UtcNow.Add(PreviewTicketLifetime);
_memoryCache.Set(
GetCacheKey(ticket),
new PreviewTicketPayload(recordTaskId, absoluteFilePath, "video/mp4"),
expiresAt);
return new RecordPreviewTicketGrant(ticket, expiresAt);
}
public Task<RecordMediaFile?> ResolvePreviewAsync(string ticket, CancellationToken cancellationToken = default)
{
if (!_memoryCache.TryGetValue(GetCacheKey(ticket), out PreviewTicketPayload? payload) || payload is null)
{
return Task.FromResult<RecordMediaFile?>(null);
}
if (!File.Exists(payload.FilePath))
{
_memoryCache.Remove(GetCacheKey(ticket));
return Task.FromResult<RecordMediaFile?>(null);
}
return Task.FromResult<RecordMediaFile?>(
new RecordMediaFile(payload.RecordTaskId, payload.FilePath, payload.ContentType));
}
private static string GetCacheKey(string ticket) => $"record-preview:{ticket}";
private sealed record PreviewTicketPayload(
Guid RecordTaskId,
string FilePath,
string ContentType);
}
@@ -0,0 +1,236 @@
using System.Collections.Concurrent;
using System.Security;
using System.Text;
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Infrastructure.Services;
internal sealed class SessionDanmakuXmlRecorder : IAsyncDisposable
{
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly LivePlatformType _platformType;
private readonly Guid _liveRoomId;
private readonly Guid _recordSessionId;
private readonly string _roomId;
private DanmakuSegmentWriter? _currentWriter;
private readonly ConcurrentDictionary<Guid, DanmakuSegmentSummary> _completedSummaries = new();
public SessionDanmakuXmlRecorder(
LivePlatformType platformType,
Guid liveRoomId,
Guid recordSessionId,
string roomId)
{
_platformType = platformType;
_liveRoomId = liveRoomId;
_recordSessionId = recordSessionId;
_roomId = roomId;
}
public async Task StartSegmentAsync(Guid recordTaskId, int segmentIndex, string videoFilePath, DateTimeOffset startedAt)
{
await _gate.WaitAsync();
try
{
if (_currentWriter is not null)
{
var summary = await _currentWriter.CloseAsync();
_completedSummaries[_currentWriter.RecordTaskId] = summary;
}
_currentWriter = await DanmakuSegmentWriter.CreateAsync(
_platformType,
_liveRoomId,
_recordSessionId,
recordTaskId,
_roomId,
Math.Max(1, segmentIndex),
GetDanmakuFilePath(videoFilePath),
startedAt);
}
finally
{
_gate.Release();
}
}
public async Task AppendAsync(DanmakuEvent danmakuEvent)
{
await _gate.WaitAsync();
try
{
if (_currentWriter is null)
{
return;
}
await _currentWriter.AppendAsync(danmakuEvent);
}
finally
{
_gate.Release();
}
}
public async Task<DanmakuSegmentSummary?> CompleteActiveSegmentAsync()
{
await _gate.WaitAsync();
try
{
if (_currentWriter is null)
{
return null;
}
var summary = await _currentWriter.CloseAsync();
_completedSummaries[_currentWriter.RecordTaskId] = summary;
_currentWriter = null;
return summary;
}
finally
{
_gate.Release();
}
}
public DanmakuSegmentSummary? TakeSummary(Guid recordTaskId)
{
if (_completedSummaries.TryRemove(recordTaskId, out var summary))
{
return summary;
}
if (_currentWriter is not null && _currentWriter.RecordTaskId == recordTaskId)
{
return _currentWriter.ToSummary();
}
return null;
}
public async ValueTask DisposeAsync()
{
await CompleteActiveSegmentAsync();
_gate.Dispose();
}
public static string GetDanmakuFilePath(string videoFilePath) =>
Path.ChangeExtension(videoFilePath, ".xml");
internal sealed record DanmakuSegmentSummary(Guid RecordTaskId, string FilePath, int MessageCount);
private sealed class DanmakuSegmentWriter
{
private readonly StreamWriter _writer;
private readonly DateTimeOffset _startedAt;
private bool _closed;
private DanmakuSegmentWriter(
Guid recordTaskId,
int segmentIndex,
string filePath,
DateTimeOffset startedAt,
StreamWriter writer)
{
RecordTaskId = recordTaskId;
SegmentIndex = segmentIndex;
FilePath = filePath;
_startedAt = startedAt;
_writer = writer;
}
public Guid RecordTaskId { get; }
public int SegmentIndex { get; }
public string FilePath { get; }
public int MessageCount { get; private set; }
public static async Task<DanmakuSegmentWriter> CreateAsync(
LivePlatformType platformType,
Guid liveRoomId,
Guid recordSessionId,
Guid recordTaskId,
string roomId,
int segmentIndex,
string filePath,
DateTimeOffset startedAt)
{
Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
var writer = new StreamWriter(filePath, false, new UTF8Encoding(false));
await writer.WriteLineAsync("""<?xml version="1.0" encoding="UTF-8"?>""");
await writer.WriteLineAsync(
$"""<i platform="{Escape(platformType.ToString())}" roomId="{Escape(roomId)}" liveRoomId="{liveRoomId}" recordSessionId="{recordSessionId}" recordTaskId="{recordTaskId}" segmentIndex="{segmentIndex}" startedAt="{startedAt:O}">""");
await writer.FlushAsync();
return new DanmakuSegmentWriter(recordTaskId, segmentIndex, filePath, startedAt, writer);
}
public async Task AppendAsync(DanmakuEvent danmakuEvent)
{
if (_closed)
{
return;
}
var offsetSeconds = Math.Max(0, (danmakuEvent.OccurredAt - _startedAt).TotalSeconds);
var timestamp = danmakuEvent.OccurredAt.ToUnixTimeMilliseconds();
if (string.Equals(danmakuEvent.Type, "chat", StringComparison.OrdinalIgnoreCase))
{
var content = Escape(danmakuEvent.Content ?? string.Empty);
var user = Escape(danmakuEvent.User ?? string.Empty);
var userId = Escape(danmakuEvent.UserId ?? "0");
var raw = Escape(CompactRawPayload(danmakuEvent.RawPayload));
await _writer.WriteLineAsync(
$""" <d p="{offsetSeconds:F1},1,25,16777215,{timestamp},0,{userId},0" user="{user}" type="chat" raw="{raw}">{content}</d>""");
}
else
{
var extraAttributes = string.Empty;
if (danmakuEvent.Extra is not null)
{
extraAttributes = string.Join(
string.Empty,
danmakuEvent.Extra.Select(pair => $" {Escape(pair.Key)}=\"{Escape(pair.Value)}\""));
}
await _writer.WriteLineAsync(
$""" <event type="{Escape(danmakuEvent.Type)}" ts="{timestamp}" offset="{offsetSeconds:F1}" user="{Escape(danmakuEvent.User ?? string.Empty)}" userId="{Escape(danmakuEvent.UserId ?? string.Empty)}" content="{Escape(danmakuEvent.Content ?? string.Empty)}" raw="{Escape(CompactRawPayload(danmakuEvent.RawPayload))}"{extraAttributes} />""");
}
MessageCount++;
await _writer.FlushAsync();
}
public async Task<DanmakuSegmentSummary> CloseAsync()
{
if (!_closed)
{
await _writer.WriteLineAsync("</i>");
await _writer.FlushAsync();
await _writer.DisposeAsync();
_closed = true;
}
return ToSummary();
}
public DanmakuSegmentSummary ToSummary() => new(RecordTaskId, FilePath, MessageCount);
private static string Escape(string input) => SecurityElement.Escape(input) ?? string.Empty;
private static string CompactRawPayload(string rawPayload)
{
if (string.IsNullOrWhiteSpace(rawPayload))
{
return string.Empty;
}
var compact = rawPayload.Trim();
return compact.Length <= 512 ? compact : compact[..512];
}
}
}
@@ -0,0 +1,36 @@
using LiveRecorder.Application.Abstractions.Auth;
using LiveRecorder.Application.Models.Auth;
using Microsoft.AspNetCore.Mvc;
namespace LiveRecorder.WebApi.Controllers;
[ApiController]
[Route("api/auth")]
public sealed class AuthController : ControllerBase
{
private readonly IAuthService _authService;
public AuthController(IAuthService authService)
{
_authService = authService;
}
[HttpPost("login")]
public async Task<ActionResult<LoginResponse>> Login([FromBody] LoginRequest request, CancellationToken cancellationToken)
{
var result = await _authService.LoginAsync(request, cancellationToken);
return Ok(result);
}
[HttpPost("logout")]
public async Task<IActionResult> Logout(CancellationToken cancellationToken)
{
var authorization = Request.Headers.Authorization.ToString();
var token = authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
? authorization["Bearer ".Length..].Trim()
: string.Empty;
await _authService.LogoutAsync(token, cancellationToken);
return NoContent();
}
}
@@ -0,0 +1,50 @@
using LiveRecorder.Application.Models.LiveRooms;
using LiveRecorder.Application.Services;
using Microsoft.AspNetCore.Mvc;
namespace LiveRecorder.WebApi.Controllers;
[ApiController]
[Route("api/live-rooms")]
public sealed class LiveRoomsController : ControllerBase
{
private readonly LiveRoomService _liveRoomService;
public LiveRoomsController(LiveRoomService liveRoomService)
{
_liveRoomService = liveRoomService;
}
[HttpGet]
public async Task<ActionResult<IReadOnlyList<LiveRoomDto>>> List(CancellationToken cancellationToken) =>
Ok(await _liveRoomService.ListAsync(cancellationToken));
[HttpGet("{id:guid}")]
public async Task<ActionResult<LiveRoomDto>> Get(Guid id, CancellationToken cancellationToken)
{
var result = await _liveRoomService.GetAsync(id, cancellationToken);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
public async Task<ActionResult<LiveRoomDto>> Create([FromBody] CreateLiveRoomRequest request, CancellationToken cancellationToken) =>
Ok(await _liveRoomService.CreateAsync(request, cancellationToken));
[HttpPost("{id:guid}/refresh")]
public async Task<ActionResult<LiveRoomDto>> Refresh(Guid id, CancellationToken cancellationToken) =>
Ok(await _liveRoomService.RefreshStatusAsync(id, cancellationToken));
[HttpPut("{id:guid}/enabled")]
public async Task<ActionResult<LiveRoomDto>> SetEnabled(
Guid id,
[FromBody] SetLiveRoomEnabledRequest request,
CancellationToken cancellationToken) =>
Ok(await _liveRoomService.SetEnabledAsync(id, request.IsEnabled, cancellationToken));
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken cancellationToken)
{
await _liveRoomService.DeleteAsync(id, cancellationToken);
return NoContent();
}
}
@@ -0,0 +1,26 @@
using LiveRecorder.Application.Abstractions.Logging;
using LiveRecorder.Application.Models.Logs;
using Microsoft.AspNetCore.Mvc;
namespace LiveRecorder.WebApi.Controllers;
[ApiController]
[Route("api/logs")]
public sealed class LogsController : ControllerBase
{
private readonly ISystemLogService _systemLogService;
public LogsController(ISystemLogService systemLogService)
{
_systemLogService = systemLogService;
}
[HttpGet]
public async Task<ActionResult<IReadOnlyList<SystemLogDto>>> List(
[FromQuery] Guid? liveRoomId,
[FromQuery] Guid? recordSessionId,
[FromQuery] Guid? recordTaskId,
[FromQuery] int take = 200,
CancellationToken cancellationToken = default) =>
Ok(await _systemLogService.ListAsync(liveRoomId, recordSessionId, recordTaskId, take, cancellationToken));
}
@@ -0,0 +1,32 @@
using LiveRecorder.Application.Abstractions.Recording;
using Microsoft.AspNetCore.Mvc;
namespace LiveRecorder.WebApi.Controllers;
[ApiController]
[Route("media/record-tasks")]
public sealed class MediaController : ControllerBase
{
private readonly IRecordMediaService _recordMediaService;
public MediaController(IRecordMediaService recordMediaService)
{
_recordMediaService = recordMediaService;
}
[HttpGet("{ticket}")]
public async Task<IActionResult> GetRecordTaskMedia(string ticket, CancellationToken cancellationToken)
{
var mediaFile = await _recordMediaService.ResolvePreviewAsync(ticket, cancellationToken);
if (mediaFile is null)
{
return NotFound();
}
var stream = System.IO.File.OpenRead(mediaFile.FilePath);
return new FileStreamResult(stream, mediaFile.ContentType)
{
EnableRangeProcessing = true
};
}
}
@@ -0,0 +1,38 @@
using LiveRecorder.Application.Models.RecordTasks;
using LiveRecorder.Application.Services;
using Microsoft.AspNetCore.Mvc;
namespace LiveRecorder.WebApi.Controllers;
[ApiController]
[Route("api/record-sessions")]
public sealed class RecordSessionsController : ControllerBase
{
private readonly RecordSessionService _recordSessionService;
public RecordSessionsController(RecordSessionService recordSessionService)
{
_recordSessionService = recordSessionService;
}
[HttpGet]
public async Task<ActionResult<IReadOnlyList<RecordSessionDto>>> List([FromQuery] Guid? liveRoomId, CancellationToken cancellationToken) =>
Ok(await _recordSessionService.ListAsync(liveRoomId, cancellationToken));
[HttpGet("{id:guid}")]
public async Task<ActionResult<RecordSessionDetailDto>> Get(Guid id, CancellationToken cancellationToken)
{
var result = await _recordSessionService.GetDetailAsync(id, cancellationToken);
return result is null ? NotFound() : Ok(result);
}
[HttpPost("{id:guid}/stop")]
public async Task<ActionResult<RecordSessionDto>> Stop(Guid id, CancellationToken cancellationToken) =>
Ok(await _recordSessionService.StopAsync(id, cancellationToken));
[HttpPost("delete")]
public async Task<ActionResult<DeleteCompletedRecordTasksResultDto>> Delete(
[FromBody] DeleteRecordSessionsRequest request,
CancellationToken cancellationToken) =>
Ok(await _recordSessionService.DeleteAsync(request, cancellationToken));
}
@@ -0,0 +1,64 @@
using LiveRecorder.Application.Models.RecordTasks;
using LiveRecorder.Application.Services;
using Microsoft.AspNetCore.Mvc;
namespace LiveRecorder.WebApi.Controllers;
[ApiController]
[Route("api/record-tasks")]
public sealed class RecordTasksController : ControllerBase
{
private readonly RecordService _recordService;
private readonly LinkGenerator _linkGenerator;
public RecordTasksController(RecordService recordService, LinkGenerator linkGenerator)
{
_recordService = recordService;
_linkGenerator = linkGenerator;
}
[HttpGet]
public async Task<ActionResult<IReadOnlyList<RecordTaskDto>>> List([FromQuery] Guid? liveRoomId, CancellationToken cancellationToken) =>
Ok(await _recordService.ListAsync(liveRoomId, cancellationToken));
[HttpGet("{id:guid}")]
public async Task<ActionResult<RecordTaskDetailDto>> Get(Guid id, CancellationToken cancellationToken)
{
var result = await _recordService.GetDetailAsync(id, cancellationToken);
return result is null ? NotFound() : Ok(result);
}
[HttpPost("start")]
public async Task<ActionResult<RecordTaskDto>> Start([FromBody] StartRecordTaskRequest request, CancellationToken cancellationToken) =>
Ok(await _recordService.StartAsync(request, cancellationToken));
[HttpPost("{id:guid}/stop")]
public async Task<ActionResult<RecordTaskDto>> Stop(Guid id, CancellationToken cancellationToken) =>
Ok(await _recordService.StopAsync(id, cancellationToken));
[HttpPost("delete")]
public async Task<ActionResult<DeleteCompletedRecordTasksResultDto>> Delete(
[FromBody] DeleteCompletedRecordTasksRequest request,
CancellationToken cancellationToken) =>
Ok(await _recordService.DeleteTasksAsync(request, cancellationToken));
[HttpPost("delete-completed")]
public async Task<ActionResult<DeleteCompletedRecordTasksResultDto>> DeleteCompleted(
[FromBody] DeleteCompletedRecordTasksRequest request,
CancellationToken cancellationToken) =>
Ok(await _recordService.DeleteTasksAsync(request, cancellationToken));
[HttpPost("{id:guid}/preview-ticket")]
public async Task<ActionResult<RecordPreviewTicketDto>> CreatePreviewTicket(Guid id, CancellationToken cancellationToken)
{
var baseUrl = _linkGenerator.GetUriByAction(
HttpContext,
action: nameof(MediaController.GetRecordTaskMedia),
controller: "Media",
values: new { ticket = "placeholder" })
?? $"{Request.Scheme}://{Request.Host}/media/record-tasks/placeholder";
var mediaBaseUrl = baseUrl[..baseUrl.LastIndexOf('/')];
return Ok(await _recordService.CreatePreviewTicketAsync(id, mediaBaseUrl, cancellationToken));
}
}
@@ -0,0 +1,41 @@
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Abstractions.Notifications;
using LiveRecorder.Application.Models.Settings;
using Microsoft.AspNetCore.Mvc;
namespace LiveRecorder.WebApi.Controllers;
[ApiController]
[Route("api/settings")]
public sealed class SettingsController : ControllerBase
{
private readonly ISystemSettingsService _systemSettingsService;
private readonly IEmailNotificationService _emailNotificationService;
public SettingsController(
ISystemSettingsService systemSettingsService,
IEmailNotificationService emailNotificationService)
{
_systemSettingsService = systemSettingsService;
_emailNotificationService = emailNotificationService;
}
[HttpGet]
public async Task<ActionResult<SystemSettingsDto>> Get(CancellationToken cancellationToken) =>
Ok(await _systemSettingsService.GetAsync(cancellationToken));
[HttpPut]
public async Task<ActionResult<SystemSettingsDto>> Update(
[FromBody] UpdateSystemSettingsRequest request,
CancellationToken cancellationToken) =>
Ok(await _systemSettingsService.UpdateAsync(request, cancellationToken));
[HttpPost("test-email")]
public async Task<IActionResult> SendTestEmail(
[FromBody] SendTestEmailRequest request,
CancellationToken cancellationToken)
{
await _emailNotificationService.SendTestAsync(request, cancellationToken);
return NoContent();
}
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\LiveRecorder.Application\LiveRecorder.Application.csproj" />
<ProjectReference Include="..\LiveRecorder.Infrastructure\LiveRecorder.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
@@ -0,0 +1,6 @@
@LiveRecorder.WebApi_HostAddress = http://localhost:5135
GET {{LiveRecorder.WebApi_HostAddress}}/weatherforecast/
Accept: application/json
###
@@ -0,0 +1,49 @@
using LiveRecorder.Application.Abstractions.Auth;
namespace LiveRecorder.WebApi.Middleware;
public sealed class ApiTokenAuthenticationMiddleware
{
private readonly RequestDelegate _next;
public ApiTokenAuthenticationMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context, IAuthService authService)
{
if (HttpMethods.IsOptions(context.Request.Method))
{
await _next(context);
return;
}
var path = context.Request.Path;
if (!path.StartsWithSegments("/api") ||
path.StartsWithSegments("/api/auth/login"))
{
await _next(context);
return;
}
var authorization = context.Request.Headers.Authorization.ToString();
var token = authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
? authorization["Bearer ".Length..].Trim()
: string.Empty;
var user = await authService.ValidateTokenAsync(token, context.RequestAborted);
if (user is null)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsJsonAsync(new
{
message = "Unauthorized"
});
return;
}
context.Items["CurrentUser"] = user;
await _next(context);
}
}
@@ -0,0 +1,38 @@
namespace LiveRecorder.WebApi.Middleware;
public sealed class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
public ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception");
context.Response.StatusCode = ex switch
{
KeyNotFoundException => StatusCodes.Status404NotFound,
InvalidOperationException => StatusCodes.Status400BadRequest,
NotSupportedException => StatusCodes.Status400BadRequest,
_ => StatusCodes.Status500InternalServerError
};
await context.Response.WriteAsJsonAsync(new
{
message = ex.Message,
detail = context.Response.StatusCode == StatusCodes.Status500InternalServerError ? "Internal Server Error" : null
});
}
}
}
+181
View File
@@ -0,0 +1,181 @@
using System.Net;
using LiveRecorder.Application.Abstractions.Auth;
using LiveRecorder.Application.Abstractions.Logging;
using LiveRecorder.Application.Abstractions.Notifications;
using LiveRecorder.Application.Abstractions.Persistence;
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Application.Abstractions.Recording;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Services;
using LiveRecorder.Infrastructure.Persistence;
using LiveRecorder.Infrastructure.Persistence.Repositories;
using LiveRecorder.Infrastructure.Platforms.Bilibili;
using LiveRecorder.Infrastructure.Platforms.Douyin;
using LiveRecorder.Infrastructure.Platforms.Douyin.Danmaku;
using LiveRecorder.Infrastructure.Platforms.Douyin.Signing;
using LiveRecorder.Infrastructure.Platforms.Huya;
using LiveRecorder.Infrastructure.Services;
using LiveRecorder.WebApi.Middleware;
using Microsoft.EntityFrameworkCore;
using Microsoft.OpenApi.Models;
var builder = WebApplication.CreateBuilder(args);
var resetRecordingData = args.Contains("--reset-recording-data", StringComparer.OrdinalIgnoreCase);
var corsOrigins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? ["http://localhost:5173"];
builder.Services.AddControllers();
builder.Services.AddMemoryCache();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
{
Title = "Live Recorder API",
Version = "v1",
Description = "Multi-platform live recording service"
});
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
In = ParameterLocation.Header,
Description = "Input token as: Bearer {token}",
Name = "Authorization",
Type = SecuritySchemeType.ApiKey
});
options.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
Array.Empty<string>()
}
});
});
builder.Services.AddCors(options =>
{
options.AddPolicy("frontend", policy =>
{
policy.WithOrigins(corsOrigins)
.AllowAnyHeader()
.AllowAnyMethod();
});
});
builder.Services.AddHttpClient("douyin", client =>
{
client.Timeout = TimeSpan.FromSeconds(20);
client.DefaultRequestVersion = HttpVersion.Version11;
client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower;
})
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli,
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(30),
MaxConnectionsPerServer = 8
});
builder.Services.AddDbContext<LiveRecorderDbContext>(options =>
options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddScoped<IUnitOfWork>(provider => provider.GetRequiredService<LiveRecorderDbContext>());
builder.Services.AddScoped<IAppSettingRepository, AppSettingRepository>();
builder.Services.AddScoped<ILiveRoomRepository, LiveRoomRepository>();
builder.Services.AddScoped<IRecordSessionRepository, RecordSessionRepository>();
builder.Services.AddScoped<IRecordTaskRepository, RecordTaskRepository>();
builder.Services.AddScoped<IRecordResultRepository, RecordResultRepository>();
builder.Services.AddScoped<ISystemLogRepository, SystemLogRepository>();
builder.Services.AddScoped<IUserAccountRepository, UserAccountRepository>();
builder.Services.AddScoped<IUserSessionRepository, UserSessionRepository>();
builder.Services.AddScoped<ISystemSettingsService, SystemSettingsService>();
builder.Services.AddScoped<ISystemLogService, SystemLogService>();
builder.Services.AddScoped<IEmailNotificationService, EmailNotificationService>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<LiveRoomService>();
builder.Services.AddScoped<LiveRoomStatusService>();
builder.Services.AddScoped<RecordService>();
builder.Services.AddScoped<RecordSessionService>();
builder.Services.AddScoped<StoppedOrphanRecordSessionCleanupService>();
builder.Services.AddScoped<DatabaseInitializer>();
builder.Services.AddScoped<DouyinHttpClient>();
builder.Services.AddSingleton<DouyinXBogusSigner>();
builder.Services.AddScoped<ILivePlatformAdapter, DouyinLivePlatformAdapter>();
builder.Services.AddScoped<ILivePlatformAdapter, BilibiliLivePlatformAdapter>();
builder.Services.AddScoped<ILivePlatformAdapter, HuyaLivePlatformAdapter>();
builder.Services.AddScoped<ILivePlatformAdapterFactory, LivePlatformAdapterFactory>();
builder.Services.AddScoped<ILiveDanmakuAdapter, DouyinDanmakuAdapter>();
builder.Services.AddScoped<ILiveDanmakuAdapterFactory, LiveDanmakuAdapterFactory>();
builder.Services.AddSingleton<IFfmpegService, FfmpegService>();
builder.Services.AddScoped<IRecordMediaService, RecordMediaService>();
builder.Services.AddHostedService<LiveRoomPollingBackgroundService>();
var app = builder.Build();
app.UseMiddleware<ExceptionHandlingMiddleware>();
app.UseSwagger();
app.UseSwaggerUI();
app.UseCors("frontend");
app.UseMiddleware<ApiTokenAuthenticationMiddleware>();
app.MapGet("/", () => Results.Redirect("/swagger"));
app.MapControllers();
if (!resetRecordingData)
{
using var scope = app.Services.CreateScope();
var initializer = scope.ServiceProvider.GetRequiredService<DatabaseInitializer>();
await initializer.InitializeAsync();
}
if (resetRecordingData)
{
using var scope = app.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
var before = await ReadRecordingDataCountsAsync(dbContext);
await using (var transaction = await dbContext.Database.BeginTransactionAsync())
{
await dbContext.Database.ExecuteSqlRawAsync(
"DELETE FROM SystemLogEntries WHERE LiveRoomId IS NOT NULL OR RecordSessionId IS NOT NULL OR RecordTaskId IS NOT NULL;");
await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM RecordResults;");
await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM RecordTasks;");
await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM RecordSessions;");
await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM LiveRooms;");
await transaction.CommitAsync();
}
var after = await ReadRecordingDataCountsAsync(dbContext);
Console.WriteLine("Recording data reset complete.");
foreach (var key in before.Keys)
{
Console.WriteLine($"{key}: {before[key]} -> {after[key]}");
}
return;
}
app.Run();
static async Task<Dictionary<string, long>> ReadRecordingDataCountsAsync(LiveRecorderDbContext dbContext)
{
return new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase)
{
["LiveRooms"] = await dbContext.LiveRooms.LongCountAsync(),
["RecordSessions"] = await dbContext.RecordSessions.LongCountAsync(),
["RecordTasks"] = await dbContext.RecordTasks.LongCountAsync(),
["RecordResults"] = await dbContext.RecordResults.LongCountAsync(),
["SystemLogEntries(Related)"] = await dbContext.SystemLogEntries.LongCountAsync(
item => item.LiveRoomId != null || item.RecordSessionId != null || item.RecordTaskId != null)
};
}
@@ -0,0 +1,41 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:45325",
"sslPort": 44312
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "weatherforecast",
"applicationUrl": "http://localhost:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "weatherforecast",
"applicationUrl": "https://localhost:7033;http://localhost:5135",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "weatherforecast",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Information"
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"ConnectionStrings": {
"DefaultConnection": "Data Source=live-recorder.db"
},
"Cors": {
"Origins": [
"http://localhost:5173"
]
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
},
"AllowedHosts": "*"
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<i platform="Douyin" roomId="24482384478" liveRoomId="758496da-3485-4b50-8f36-f622a04cccaa" recordSessionId="96541586-d6a4-492f-b77e-8351f907f6b2" recordTaskId="16e3ee7b-775a-4306-af9f-030d3fc16c59" segmentIndex="1" startedAt="2026-04-15T09:21:32.9557018+00:00">
</i>
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<i platform="Douyin" roomId="24482384478" liveRoomId="758496da-3485-4b50-8f36-f622a04cccaa" recordSessionId="a6b972a1-0fa4-4cb0-a4f4-49c1886f68b9" recordTaskId="16c513ce-20a4-4b68-b781-cbe0a3866bd3" segmentIndex="1" startedAt="2026-04-15T08:40:02.3186341+00:00">
</i>
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<i platform="Douyin" roomId="24482384478" liveRoomId="758496da-3485-4b50-8f36-f622a04cccaa" recordSessionId="8066da9f-7234-4bb9-b5a6-dbda093ec805" recordTaskId="275d15d5-06a3-4592-a2e4-213f2d33a05e" segmentIndex="1" startedAt="2026-04-15T08:47:17.0035883+00:00">
</i>
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<i platform="Douyin" roomId="24482384478" liveRoomId="758496da-3485-4b50-8f36-f622a04cccaa" recordSessionId="b1f625ae-9630-40cb-b332-0911cbf9495a" recordTaskId="31681d5f-32f1-4d3b-b67c-9c76bb262ae6" segmentIndex="1" startedAt="2026-04-15T08:49:24.7664120+00:00">
</i>
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<i platform="Douyin" roomId="410202829936" liveRoomId="723ddadf-f211-488a-b3c8-af8ca26ce501" recordSessionId="6edb0432-0aad-42ca-913f-f754fe1c74a2" recordTaskId="b363a8c2-4b2f-4a66-b178-7015f2ba42bf" segmentIndex="1" startedAt="2026-04-15T13:28:47.9706852+00:00">
</i>
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<i platform="Douyin" roomId="24482384478" liveRoomId="758496da-3485-4b50-8f36-f622a04cccaa" recordSessionId="710beae4-aaab-49b3-b290-cb92e8cfbf73" recordTaskId="662d9dd1-a52c-49d3-939f-acb9047118ba" segmentIndex="1" startedAt="2026-04-15T10:05:01.8366164+00:00">
</i>
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<i platform="Douyin" roomId="24482384478" liveRoomId="5fe39aa7-adc1-4f3b-8f79-a88a8a78575a" recordSessionId="a332ebc7-1ec8-479d-be4e-ae9bd0e3b492" recordTaskId="f809528f-50f0-4294-9898-2c78077e89c6" segmentIndex="1" startedAt="2026-04-16T06:19:14.1248014+00:00">
</i>
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<i platform="Douyin" roomId="24482384478" liveRoomId="5fe39aa7-adc1-4f3b-8f79-a88a8a78575a" recordSessionId="adb12db2-cb13-4220-b674-066034168179" recordTaskId="234cabf8-2b21-44e3-90e4-3eb3f972f84b" segmentIndex="1" startedAt="2026-04-16T07:19:35.5837509+00:00">
</i>
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<i platform="Douyin" roomId="24482384478" liveRoomId="758496da-3485-4b50-8f36-f622a04cccaa" recordSessionId="4339a2f9-7971-49c3-a862-51c40b5f863a" recordTaskId="e13a9515-a3b2-4926-91e8-ab5c950451f6" segmentIndex="1" startedAt="2026-04-15T09:22:36.1844012+00:00">
</i>
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<i platform="Douyin" roomId="24482384478" liveRoomId="758496da-3485-4b50-8f36-f622a04cccaa" recordSessionId="a6b25554-b0c4-401c-b609-154900b3e7ae" recordTaskId="10b39d85-ff00-411b-8aa3-1eebf8f211b2" segmentIndex="1" startedAt="2026-04-15T09:23:39.5759339+00:00">
</i>
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<i platform="Douyin" roomId="24482384478" liveRoomId="758496da-3485-4b50-8f36-f622a04cccaa" recordSessionId="bcde0a6f-36f5-49e6-9793-123771a6a073" recordTaskId="782b119c-ea9e-4854-a19e-178699feb5ca" segmentIndex="1" startedAt="2026-04-15T11:49:40.6314631+00:00">
</i>
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<i platform="Douyin" roomId="24482384478" liveRoomId="bbcc8489-a89b-43e9-8cef-36d8a2aa25de" recordSessionId="f0ffa9e6-8200-4605-8005-badc2a025939" recordTaskId="d8131983-8ea8-484e-b306-f4086861154b" segmentIndex="1" startedAt="2026-04-15T11:58:04.5702834+00:00">
</i>
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<i platform="Douyin" roomId="386835971310" liveRoomId="082677d1-f76c-4983-aeeb-3deb2cb91bca" recordSessionId="bd9b2b8f-739e-4be6-b6f7-358388925065" recordTaskId="8b3eb0b0-598a-415d-a2c0-f7d390ed805c" segmentIndex="1" startedAt="2026-04-16T03:00:05.1545652+00:00">
</i>