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
@@ -7,6 +7,7 @@ using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Application.Abstractions.Recording;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Abstractions.Storage;
using LiveRecorder.Application.Models.Media;
using LiveRecorder.Application.Services;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
@@ -409,6 +410,128 @@ public sealed partial class FfmpegService : IFfmpegService
return true;
}
public async Task<TranscodeMediaFileResultDto> StartManualFinalizeFileAsync(
string sourceFilePath,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(sourceFilePath))
{
return new TranscodeMediaFileResultDto
{
Success = false,
Message = "The selected source file is empty."
};
}
var absoluteSourcePath = NormalizeAbsolutePath(sourceFilePath);
if (!File.Exists(absoluteSourcePath))
{
return new TranscodeMediaFileResultDto
{
Success = false,
Message = "The selected .ts file does not exist.",
SourcePath = absoluteSourcePath
};
}
if (!absoluteSourcePath.EndsWith(".ts", StringComparison.OrdinalIgnoreCase))
{
return new TranscodeMediaFileResultDto
{
Success = false,
Message = "Only .ts files can be transcoded to MP4.",
SourcePath = absoluteSourcePath
};
}
var absoluteTargetPath = Path.ChangeExtension(absoluteSourcePath, ".mp4");
if (File.Exists(absoluteTargetPath))
{
return new TranscodeMediaFileResultDto
{
Success = false,
Message = "A target MP4 file already exists for the selected .ts file.",
SourcePath = absoluteSourcePath,
OutputPath = absoluteTargetPath
};
}
using var settingsScope = _serviceScopeFactory.CreateScope();
var settingsService = settingsScope.ServiceProvider.GetRequiredService<ISystemSettingsService>();
var settings = await settingsService.GetAsync(cancellationToken);
var syntheticSessionId = Guid.NewGuid();
var syntheticTaskId = Guid.NewGuid();
SetPostProcessState(
syntheticSessionId,
syntheticTaskId,
"Queued",
null,
$"Manual file transcode queued for {Path.GetFileName(absoluteSourcePath)}");
_ = Task.Run(async () =>
{
try
{
var result = await TryFinalizeMp4Async(
settings.FfmpegPath,
settings.MaxConcurrentFfmpegTranscodeTasks,
settings.Mp4FinalizeTimeoutMinutes,
syntheticSessionId,
syntheticTaskId,
absoluteSourcePath,
absoluteTargetPath,
expectedDurationSeconds: null,
CancellationToken.None);
using var scope = _serviceScopeFactory.CreateScope();
var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>();
if (string.IsNullOrWhiteSpace(result.ErrorMessage))
{
await logService.WriteAsync(
Domain.Enums.SystemLogLevel.Info,
"FFmpeg",
"Manual file transcode completed.",
$"source={absoluteSourcePath}; output={result.OutputPath}",
cancellationToken: CancellationToken.None);
}
else
{
await logService.WriteAsync(
Domain.Enums.SystemLogLevel.Warning,
"FFmpeg",
"Manual file transcode failed.",
$"source={absoluteSourcePath}; output={result.OutputPath}; error={result.ErrorMessage}",
cancellationToken: CancellationToken.None);
}
}
catch (Exception ex)
{
using var scope = _serviceScopeFactory.CreateScope();
var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>();
await logService.WriteAsync(
Domain.Enums.SystemLogLevel.Error,
"FFmpeg",
"Manual file transcode crashed.",
$"source={absoluteSourcePath}; output={absoluteTargetPath}; error={ex}",
cancellationToken: CancellationToken.None);
}
finally
{
ClearPostProcessState(syntheticTaskId);
}
}, CancellationToken.None);
return new TranscodeMediaFileResultDto
{
Success = true,
Message = "Manual file transcode started. Refresh later to verify the output file.",
SourcePath = absoluteSourcePath,
OutputPath = absoluteTargetPath
};
}
public async Task<int> ResumePausedFinalizationsAsync(CancellationToken cancellationToken = default)
{
using var scope = _serviceScopeFactory.CreateScope();