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