feat: add transcode workspace and media browser

This commit is contained in:
2026-05-05 02:14:52 +08:00
parent f0f9ed3456
commit 56432a9ece
26 changed files with 2013 additions and 267 deletions
@@ -1,5 +1,6 @@
using LiveRecorder.Domain.Entities;
using LiveRecorder.Application.Models.Settings;
using LiveRecorder.Application.Models.Reports;
namespace LiveRecorder.Application.Abstractions.Notifications;
@@ -16,4 +17,6 @@ public interface IEmailNotificationService
CancellationToken cancellationToken = default);
Task SendTestAsync(SendTestEmailRequest request, CancellationToken cancellationToken = default);
Task SendDailyReviewAsync(DailyReviewReportDto report, CancellationToken cancellationToken = default);
}
@@ -1,4 +1,5 @@
using LiveRecorder.Application.Models.Settings;
using LiveRecorder.Application.Models.Reports;
using LiveRecorder.Domain.Entities;
namespace LiveRecorder.Application.Abstractions.Notifications;
@@ -18,4 +19,8 @@ public interface IWebhookNotificationService
Task<WebhookTestResultDto> SendTestAsync(
SendTestWebhookRequest request,
CancellationToken cancellationToken = default);
Task SendDailyReviewAsync(
DailyReviewReportDto report,
CancellationToken cancellationToken = default);
}
@@ -32,6 +32,10 @@ public interface IFfmpegService
Task<bool> StartManualFinalizeTaskAsync(Guid recordTaskId, CancellationToken cancellationToken = default);
Task<LiveRecorder.Application.Models.Media.TranscodeMediaFileResultDto> StartManualFinalizeFileAsync(
string sourceFilePath,
CancellationToken cancellationToken = default);
Task<int> ResumePausedFinalizationsAsync(CancellationToken cancellationToken = default);
bool IsRunning(Guid recordSessionId);
@@ -0,0 +1,52 @@
namespace LiveRecorder.Application.Models.Media;
public sealed class MediaBrowserBreadcrumbDto
{
public required string Label { get; init; }
public string RelativePath { get; init; } = string.Empty;
}
public sealed class MediaBrowserItemDto
{
public required string Name { get; init; }
public required string RelativePath { get; init; }
public required string Type { get; init; }
public long? SizeBytes { get; init; }
public DateTimeOffset? ModifiedAt { get; init; }
public bool CanTranscode { get; init; }
public bool CanPreview { get; init; }
}
public sealed class MediaBrowserResponseDto
{
public required string CurrentPath { get; init; }
public string? ParentPath { get; init; }
public required IReadOnlyList<MediaBrowserBreadcrumbDto> Breadcrumbs { get; init; }
public required IReadOnlyList<MediaBrowserItemDto> Items { get; init; }
}
public sealed class TranscodeMediaFileRequest
{
public string RelativePath { get; set; } = string.Empty;
}
public sealed class TranscodeMediaFileResultDto
{
public bool Success { get; init; }
public required string Message { get; init; }
public string? SourcePath { get; init; }
public string? OutputPath { get; init; }
}
@@ -0,0 +1,12 @@
namespace LiveRecorder.Application.Models.RecordTasks;
public sealed class TranscodeTaskItemDto
{
public required RecordTaskDto Task { get; init; }
public RecordResultDto? Result { get; init; }
public string? SourceFilePath { get; init; }
public bool CanManualTranscode { get; init; }
}
@@ -124,3 +124,30 @@ public sealed class DailyReviewMomentDto
public int DanmakuCount { get; init; }
}
public sealed class PushDailyReviewRequest
{
public string? Date { get; set; }
public int UtcOffsetMinutes { get; set; }
public IReadOnlyList<string> Channels { get; set; } = [];
}
public sealed class DailyReviewPushChannelResultDto
{
public required string Channel { get; init; }
public bool Success { get; init; }
public required string Message { get; init; }
public string? Detail { get; init; }
}
public sealed class DailyReviewPushResultDto
{
public required string Date { get; init; }
public required IReadOnlyList<DailyReviewPushChannelResultDto> Results { get; init; }
}
@@ -211,6 +211,8 @@ public sealed class SystemSettingsDto
public string WebhookHeaders { get; set; } = string.Empty;
public string WebhookBodyTemplate { get; set; } = string.Empty;
public int WebhookTimeoutSeconds { get; set; } = 15;
public bool NotifyWebhookOnLiveStarted { get; set; } = true;
@@ -392,6 +394,8 @@ public sealed class UpdateSystemSettingsRequest
public string WebhookHeaders { get; set; } = string.Empty;
public string WebhookBodyTemplate { get; set; } = string.Empty;
public int WebhookTimeoutSeconds { get; set; } = 15;
public bool NotifyWebhookOnLiveStarted { get; set; } = true;
@@ -490,6 +494,8 @@ public sealed class SendTestWebhookRequest
public string WebhookHeaders { get; set; } = string.Empty;
public string WebhookBodyTemplate { get; set; } = string.Empty;
public int WebhookTimeoutSeconds { get; set; } = 15;
}
@@ -0,0 +1,221 @@
using LiveRecorder.Application.Abstractions.Recording;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Models.Media;
namespace LiveRecorder.Application.Services;
public sealed class MediaBrowserService
{
private static readonly string[] PreviewableExtensions = [".mp4"];
private static readonly string[] TranscodableExtensions = [".ts"];
private readonly ISystemSettingsService _systemSettingsService;
private readonly IFfmpegService _ffmpegService;
public MediaBrowserService(
ISystemSettingsService systemSettingsService,
IFfmpegService ffmpegService)
{
_systemSettingsService = systemSettingsService;
_ffmpegService = ffmpegService;
}
public async Task<MediaBrowserResponseDto> BrowseAsync(string? relativePath, CancellationToken cancellationToken = default)
{
var settings = await _systemSettingsService.GetAsync(cancellationToken);
var rootPath = ResolveOutputRoot(settings.OutputRoot);
var normalizedRelativePath = NormalizeRelativePath(relativePath);
var targetPath = ResolveScopedPath(rootPath, normalizedRelativePath);
if (!Directory.Exists(targetPath))
{
throw new DirectoryNotFoundException("The requested media directory does not exist.");
}
var directories = Directory
.EnumerateDirectories(targetPath)
.Select(directoryPath =>
{
var info = new DirectoryInfo(directoryPath);
return new MediaBrowserItemDto
{
Name = info.Name,
RelativePath = Path.GetRelativePath(rootPath, info.FullName).Replace('\\', '/'),
Type = "directory",
ModifiedAt = info.LastWriteTimeUtc == DateTime.MinValue
? null
: new DateTimeOffset(info.LastWriteTimeUtc, TimeSpan.Zero),
CanPreview = false,
CanTranscode = false
};
});
var files = Directory
.EnumerateFiles(targetPath)
.Select(filePath =>
{
var info = new FileInfo(filePath);
var extension = info.Extension.ToLowerInvariant();
return new MediaBrowserItemDto
{
Name = info.Name,
RelativePath = Path.GetRelativePath(rootPath, info.FullName).Replace('\\', '/'),
Type = ResolveItemType(extension),
SizeBytes = info.Length,
ModifiedAt = info.LastWriteTimeUtc == DateTime.MinValue
? null
: new DateTimeOffset(info.LastWriteTimeUtc, TimeSpan.Zero),
CanPreview = PreviewableExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase),
CanTranscode = TranscodableExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)
};
});
var items = directories
.Concat(files)
.OrderBy(static item => item.Type != "directory")
.ThenBy(static item => item.Name, StringComparer.OrdinalIgnoreCase)
.ToArray();
return new MediaBrowserResponseDto
{
CurrentPath = normalizedRelativePath,
ParentPath = string.IsNullOrWhiteSpace(normalizedRelativePath)
? null
: GetParentRelativePath(normalizedRelativePath),
Breadcrumbs = BuildBreadcrumbs(normalizedRelativePath),
Items = items
};
}
public async Task<TranscodeMediaFileResultDto> TranscodeFileAsync(
TranscodeMediaFileRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var settings = await _systemSettingsService.GetAsync(cancellationToken);
var rootPath = ResolveOutputRoot(settings.OutputRoot);
var normalizedRelativePath = NormalizeRelativePath(request.RelativePath);
var sourcePath = ResolveScopedPath(rootPath, normalizedRelativePath);
if (!File.Exists(sourcePath))
{
throw new FileNotFoundException("The selected .ts file does not exist.", normalizedRelativePath);
}
if (!sourcePath.EndsWith(".ts", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Only .ts files can be transcoded from the media browser.");
}
return await _ffmpegService.StartManualFinalizeFileAsync(sourcePath, cancellationToken);
}
public async Task<string> ResolveFilePathAsync(string? relativePath, CancellationToken cancellationToken = default)
{
var settings = await _systemSettingsService.GetAsync(cancellationToken);
var rootPath = ResolveOutputRoot(settings.OutputRoot);
var normalizedRelativePath = NormalizeRelativePath(relativePath);
var filePath = ResolveScopedPath(rootPath, normalizedRelativePath);
if (!File.Exists(filePath))
{
throw new FileNotFoundException("The requested file does not exist.", normalizedRelativePath);
}
return filePath;
}
internal static string ResolveOutputRoot(string outputRoot) =>
Path.IsPathRooted(outputRoot)
? outputRoot
: Path.GetFullPath(outputRoot, AppContext.BaseDirectory);
internal static string NormalizeRelativePath(string? relativePath)
{
if (string.IsNullOrWhiteSpace(relativePath))
{
return string.Empty;
}
return relativePath
.Replace('\\', '/')
.Trim('/')
.Trim();
}
internal static string ResolveScopedPath(string rootPath, string relativePath)
{
var combinedPath = string.IsNullOrWhiteSpace(relativePath)
? rootPath
: Path.GetFullPath(Path.Combine(rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar)));
var normalizedRoot = Path.GetFullPath(rootPath)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var normalizedCombined = Path.GetFullPath(combinedPath)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var isRoot = string.Equals(normalizedCombined, normalizedRoot, StringComparison.OrdinalIgnoreCase);
var isChild = normalizedCombined.StartsWith(
normalizedRoot + Path.DirectorySeparatorChar,
StringComparison.OrdinalIgnoreCase);
if (!isRoot && !isChild)
{
throw new InvalidOperationException("The requested path is outside the recording output root.");
}
return normalizedCombined;
}
private static string ResolveItemType(string extension) =>
extension switch
{
".mp4" => "mp4",
".ts" => "ts",
".xml" => "xml",
_ => "other"
};
private static string? GetParentRelativePath(string relativePath)
{
var parts = relativePath
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length <= 1)
{
return string.Empty;
}
return string.Join('/', parts.Take(parts.Length - 1));
}
private static IReadOnlyList<MediaBrowserBreadcrumbDto> BuildBreadcrumbs(string relativePath)
{
var breadcrumbs = new List<MediaBrowserBreadcrumbDto>
{
new()
{
Label = "平台目录",
RelativePath = string.Empty
}
};
if (string.IsNullOrWhiteSpace(relativePath))
{
return breadcrumbs;
}
var current = string.Empty;
foreach (var part in relativePath.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
current = string.IsNullOrWhiteSpace(current) ? part : $"{current}/{part}";
breadcrumbs.Add(new MediaBrowserBreadcrumbDto
{
Label = part,
RelativePath = current
});
}
return breadcrumbs;
}
}
@@ -89,6 +89,7 @@ public sealed class SystemSettingsService : ISystemSettingsService
private const string EnableWebhookNotificationKey = "notification.webhook.enabled";
private const string WebhookUrlKey = "notification.webhook.url";
private const string WebhookHeadersKey = "notification.webhook.headers";
private const string WebhookBodyTemplateKey = "notification.webhook.body_template";
private const string WebhookTimeoutSecondsKey = "notification.webhook.timeout_seconds";
private const string NotifyWebhookOnLiveStartedKey = "notification.webhook.notify_live_started";
private const string NotifyWebhookOnExceptionKey = "notification.webhook.notify_exception";
@@ -264,6 +265,7 @@ public sealed class SystemSettingsService : ISystemSettingsService
EnableWebhookNotification = bool.TryParse(GetValue(lookup, EnableWebhookNotificationKey, "false"), out var enableWebhookNotification) && enableWebhookNotification,
WebhookUrl = GetValue(lookup, WebhookUrlKey, string.Empty),
WebhookHeaders = GetValue(lookup, WebhookHeadersKey, string.Empty),
WebhookBodyTemplate = GetValue(lookup, WebhookBodyTemplateKey, string.Empty),
WebhookTimeoutSeconds = GetIntValue(lookup, WebhookTimeoutSecondsKey, 15, 1, 300),
NotifyWebhookOnLiveStarted = bool.TryParse(GetValue(lookup, NotifyWebhookOnLiveStartedKey, "true"), out var notifyWebhookOnLiveStarted) && notifyWebhookOnLiveStarted,
NotifyWebhookOnException = bool.TryParse(GetValue(lookup, NotifyWebhookOnExceptionKey, "true"), out var notifyWebhookOnException) && notifyWebhookOnException,
@@ -384,6 +386,7 @@ public sealed class SystemSettingsService : ISystemSettingsService
await UpsertAsync(EnableWebhookNotificationKey, request.EnableWebhookNotification.ToString(), now, cancellationToken);
await UpsertAsync(WebhookUrlKey, request.WebhookUrl.Trim(), now, cancellationToken);
await UpsertAsync(WebhookHeadersKey, request.WebhookHeaders, now, cancellationToken);
await UpsertAsync(WebhookBodyTemplateKey, request.WebhookBodyTemplate, now, cancellationToken);
await UpsertAsync(WebhookTimeoutSecondsKey, Math.Clamp(request.WebhookTimeoutSeconds, 1, 300).ToString(), now, cancellationToken);
await UpsertAsync(NotifyWebhookOnLiveStartedKey, request.NotifyWebhookOnLiveStarted.ToString(), now, cancellationToken);
await UpsertAsync(NotifyWebhookOnExceptionKey, request.NotifyWebhookOnException.ToString(), now, cancellationToken);
@@ -0,0 +1,134 @@
using LiveRecorder.Application.Abstractions.Persistence;
using LiveRecorder.Application.Abstractions.Recording;
using LiveRecorder.Application.Models.RecordTasks;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Application.Services;
public sealed class TranscodeTaskService
{
private readonly IRecordTaskRepository _recordTaskRepository;
private readonly IFfmpegService _ffmpegService;
public TranscodeTaskService(
IRecordTaskRepository recordTaskRepository,
IFfmpegService ffmpegService)
{
_recordTaskRepository = recordTaskRepository;
_ffmpegService = ffmpegService;
}
public async Task<IReadOnlyList<TranscodeTaskItemDto>> ListAsync(CancellationToken cancellationToken = default)
{
var tasks = await _recordTaskRepository.ListAsync(null, cancellationToken);
if (tasks.Count == 0)
{
return [];
}
var runtimeStates = _ffmpegService.GetTaskRuntimeStates(tasks.Select(static item => item.Id).ToArray());
return tasks
.Select(task =>
{
runtimeStates.TryGetValue(task.Id, out var runtimeState);
var mappedTask = RecordModelMapper.MapTask(task, runtimeState);
var sourceFilePath = ResolveManualTranscodeSourcePath(task);
var canManualTranscode = CanManuallyTranscode(task, runtimeState, sourceFilePath);
return new TranscodeTaskItemDto
{
Task = mappedTask,
Result = task.Result is null ? null : RecordModelMapper.MapResult(task.Result),
SourceFilePath = sourceFilePath,
CanManualTranscode = canManualTranscode
};
})
.Where(static item => ShouldInclude(item))
.OrderByDescending(static item => item.Task.CreatedAt)
.ToArray();
}
private static bool ShouldInclude(TranscodeTaskItemDto item)
{
if (!string.IsNullOrWhiteSpace(item.Task.PostProcessStage))
{
return true;
}
if (item.CanManualTranscode)
{
return true;
}
return item.Task.OutputFormat == RecordOutputFormat.Mp4 &&
item.Task.Status == RecordTaskStatus.Processing;
}
private static bool CanManuallyTranscode(
RecordTask recordTask,
RecordTaskRuntimeState? runtimeState,
string? sourceFilePath)
{
if (recordTask.OutputFormat != RecordOutputFormat.Mp4 ||
runtimeState is not null ||
IsActiveStatus(recordTask.Status))
{
return false;
}
if (!string.IsNullOrWhiteSpace(sourceFilePath) && File.Exists(sourceFilePath))
{
return true;
}
var resultPath = recordTask.Result?.FilePath?.ToLowerInvariant() ?? string.Empty;
var errorText = $"{recordTask.ErrorMessage ?? string.Empty} {recordTask.Result?.ErrorMessage ?? string.Empty}".ToLowerInvariant();
return resultPath.EndsWith(".ts", StringComparison.OrdinalIgnoreCase) ||
errorText.Contains("finaliz", StringComparison.Ordinal) ||
errorText.Contains("intermediate ts", StringComparison.Ordinal);
}
private static string? ResolveManualTranscodeSourcePath(RecordTask recordTask)
{
if (!string.IsNullOrWhiteSpace(recordTask.Result?.FilePath) &&
recordTask.Result.FilePath.EndsWith(".ts", StringComparison.OrdinalIgnoreCase))
{
return recordTask.Result.FilePath;
}
if (!string.IsNullOrWhiteSpace(recordTask.OutputFilePath))
{
var normalizedOutput = NormalizeAbsolutePath(recordTask.OutputFilePath);
if (normalizedOutput.EndsWith(".ts", StringComparison.OrdinalIgnoreCase))
{
return normalizedOutput;
}
var candidate = Path.ChangeExtension(normalizedOutput, ".ts");
if (File.Exists(candidate))
{
return candidate;
}
var singleFileCandidate = Path.Combine(
Path.GetDirectoryName(normalizedOutput) ?? string.Empty,
$"{Path.GetFileNameWithoutExtension(normalizedOutput)}.recording.ts");
if (File.Exists(singleFileCandidate))
{
return singleFileCandidate;
}
}
return null;
}
private static string NormalizeAbsolutePath(string path) =>
Path.IsPathRooted(path)
? path
: Path.GetFullPath(path, AppContext.BaseDirectory);
private static bool IsActiveStatus(RecordTaskStatus status) =>
status is RecordTaskStatus.Starting or RecordTaskStatus.Running or RecordTaskStatus.Stopping or RecordTaskStatus.Processing;
}