1111
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user