feat: add transcode workspace and media browser
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user