This commit is contained in:
Dan Watson 2025-08-05 12:07:51 +02:00 committed by GitHub
commit 3c6a7b2776
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 161 additions and 47 deletions

View File

@ -200,6 +200,7 @@
- [Jxiced](https://github.com/Jxiced)
- [allesmi](https://github.com/allesmi)
- [ThunderClapLP](https://github.com/ThunderClapLP)
- [theshoeshiner](https://github.com/theshoeshiner)
- [Shoham Peller](https://github.com/spellr)
- [theshoeshiner](https://github.com/theshoeshiner)
- [TokerX](https://github.com/TokerX)

View File

@ -19,6 +19,7 @@ namespace MediaBrowser.Controller.LiveTv
ProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
SeriesProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
Tags = Array.Empty<string>();
RecordingPartPaths = Array.Empty<string>();
}
public Dictionary<string, string> ProviderIds { get; set; }
@ -112,6 +113,8 @@ namespace MediaBrowser.Controller.LiveTv
public int RetryCount { get; set; }
public int FailedRetryCount { get; set; }
// Program properties
public int? SeasonNumber { get; set; }
@ -161,6 +164,10 @@ namespace MediaBrowser.Controller.LiveTv
public string RecordingPath { get; set; }
public string CurrentRecordingPath { get; set; }
public string[] RecordingPartPaths { get; set; }
public KeepUntil KeepUntil { get; set; }
}
}

View File

@ -7,7 +7,6 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.Drawing;
using MediaBrowser.Model.Dto;
@ -281,5 +280,14 @@ namespace MediaBrowser.Controller.MediaEncoding
/// <param name="source">The <see cref="MediaSourceInfo"/>.</param>
/// <param name="concatFilePath">The path the config should be written to.</param>
void GenerateConcatConfig(MediaSourceInfo source, string concatFilePath);
/// <summary>
/// Runs a simple FFmpeg concatenation job on a list of files and writes them to the output file.
/// </summary>
/// <param name="filePaths">The file paths to concatenate.</param>
/// <param name="outputFile">The output file.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Task.</returns>
Task ConcatenateMedia(IReadOnlyCollection<string> filePaths, string outputFile, CancellationToken cancellationToken);
}
}

View File

@ -51,6 +51,11 @@ namespace MediaBrowser.MediaEncoding.Encoder
/// </summary>
internal const int DefaultHdrImageExtractionTimeout = 20000;
/// <summary>
/// The default media concatenation timeout in milliseconds.
/// </summary>
internal const int DefaultConcatTimeout = 60000;
private readonly ILogger<MediaEncoder> _logger;
private readonly IServerConfigurationManager _configurationManager;
private readonly IFileSystem _fileSystem;
@ -1292,6 +1297,11 @@ namespace MediaBrowser.MediaEncoding.Encoder
return;
}
GenerateConcatConfig(concatFilePath, files, videoType);
}
internal void GenerateConcatConfig(string concatFilePath, IEnumerable<string> files, VideoType? videoType)
{
// Generate concat configuration entries for each file and write to file
Directory.CreateDirectory(Path.GetDirectoryName(concatFilePath));
using var sw = new FormattingStreamWriter(concatFilePath, CultureInfo.InvariantCulture);
@ -1320,6 +1330,69 @@ namespace MediaBrowser.MediaEncoding.Encoder
}
}
/// <inheritdoc />
public async Task ConcatenateMedia(IReadOnlyCollection<string> filePaths, string outputFile, CancellationToken cancellationToken)
{
if (filePaths.Count == 1)
{
string file = filePaths.First();
File.Move(file, outputFile, true);
}
else if (filePaths.Count > 1)
{
var tempDirectory = Path.Combine(_configurationManager.ApplicationPaths.TempDirectory, Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDirectory);
var concatConfigFile = Path.Combine(tempDirectory, Guid.NewGuid() + ".concat");
GenerateConcatConfig(concatConfigFile, filePaths, VideoType.VideoFile);
var tempOutputFile = Path.Combine(tempDirectory, Guid.NewGuid() + Path.GetExtension(outputFile));
var processStartInfo = new ProcessStartInfo
{
CreateNoWindow = true,
UseShellExecute = false,
FileName = _ffmpegPath,
Arguments = "-f concat -safe 0 -i \"" + concatConfigFile + "\" -v quiet -c copy \"" + tempOutputFile + "\"",
WindowStyle = ProcessWindowStyle.Hidden,
ErrorDialog = false,
};
var process = new Process
{
StartInfo = processStartInfo,
EnableRaisingEvents = true,
};
using (var processWrapper = new ProcessWrapper(process, this))
{
StartProcess(processWrapper);
try
{
await process.WaitForExitAsync(TimeSpan.FromMilliseconds(DefaultConcatTimeout)).ConfigureAwait(false);
}
catch (OperationCanceledException ex)
{
process.Kill(true);
throw new FfmpegException(string.Format(CultureInfo.InvariantCulture, "ffmpeg concatenation cancelled for {0}", tempOutputFile), ex);
}
var outputFileInfo = _fileSystem.GetFileInfo(tempOutputFile);
if (processWrapper.ExitCode > 0 || !outputFileInfo.Exists || outputFileInfo.Length == 0)
{
throw new FfmpegException(string.Format(CultureInfo.InvariantCulture, "ffmpeg concatenation failed for {0}", tempOutputFile));
}
// Remove concatenated files, must be done before moving temp file
foreach (var file in filePaths)
{
_fileSystem.DeleteFile(file);
}
// Move concatenated file
File.Move(tempOutputFile, outputFile, true);
_logger.LogInformation("Concatenation successful for {0}", outputFile);
}
}
}
public bool CanExtractSubtitles(string codec)
{
// TODO is there ever a case when a subtitle can't be extracted??

View File

@ -308,8 +308,8 @@ public sealed class RecordingsManager : IRecordingsManager, IDisposable
var timer = recordingInfo.Timer;
var remoteMetadata = await FetchInternetMetadata(timer, CancellationToken.None).ConfigureAwait(false);
var recordingPath = GetRecordingPath(timer, remoteMetadata, out var seriesPath);
var baseRecordingPath = GetRecordingPath(timer, remoteMetadata, out var seriesPath);
string? currentRecordingPath = null;
string? liveStreamId = null;
RecordingStatus recordingStatus;
try
@ -336,51 +336,50 @@ public sealed class RecordingsManager : IRecordingsManager, IDisposable
using var recorder = GetRecorder(mediaStreamInfo);
recordingPath = recorder.GetOutputPath(mediaStreamInfo, recordingPath);
recordingPath = EnsureFileUnique(recordingPath, timer.Id);
_libraryMonitor.ReportFileSystemChangeBeginning(recordingPath);
if (timer.RecordingPath == null)
{
timer.RecordingPath = recorder.GetOutputPath(mediaStreamInfo, baseRecordingPath);
}
currentRecordingPath = EnsureFileUnique(timer.RecordingPath, timer.Id);
_libraryMonitor.ReportFileSystemChangeBeginning(currentRecordingPath);
var duration = recordingEndDate - DateTime.UtcNow;
_logger.LogInformation("Beginning recording. Will record for {Duration} minutes.", duration.TotalMinutes);
_logger.LogInformation("Writing file to: {Path}", recordingPath);
_logger.LogInformation("Writing file to: {0}", currentRecordingPath);
async void OnStarted()
{
recordingInfo.Path = recordingPath;
recordingInfo.Path = currentRecordingPath;
_activeRecordings.TryAdd(timer.Id, recordingInfo);
timer.Status = RecordingStatus.InProgress;
timer.CurrentRecordingPath = currentRecordingPath;
_timerManager.AddOrUpdate(timer, false);
await _recordingsMetadataManager.SaveRecordingMetadata(timer, recordingPath, seriesPath).ConfigureAwait(false);
await _recordingsMetadataManager.SaveRecordingMetadata(timer, timer.RecordingPath, seriesPath).ConfigureAwait(false);
await CreateRecordingFolders().ConfigureAwait(false);
TriggerRefresh(recordingPath);
TriggerRefresh(currentRecordingPath);
await EnforceKeepUpTo(timer, seriesPath).ConfigureAwait(false);
}
await recorder.Record(
directStreamProvider,
mediaStreamInfo,
recordingPath,
currentRecordingPath,
duration,
OnStarted,
recordingInfo.CancellationTokenSource.Token).ConfigureAwait(false);
recordingStatus = RecordingStatus.Completed;
_logger.LogInformation("Recording completed: {RecordPath}", recordingPath);
_logger.LogInformation("Recording completed: {CurrentRecordingPath}", currentRecordingPath);
}
catch (OperationCanceledException)
{
_logger.LogInformation("Recording stopped: {RecordPath}", recordingPath);
recordingStatus = RecordingStatus.Completed;
_logger.LogInformation("Recording stopped currentRecordingPath: {CurrentRecordingPath}", currentRecordingPath);
recordingStatus = RecordingStatus.Cancelled;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error recording to {RecordPath}", recordingPath);
recordingStatus = RecordingStatus.Error;
_logger.LogError(ex, "Error recording to: currentRecordingPath: {CurrentRecordingPath}", currentRecordingPath);
recordingStatus = timer.Status == RecordingStatus.Cancelled ? RecordingStatus.Cancelled : RecordingStatus.Error;
}
if (!string.IsNullOrWhiteSpace(liveStreamId))
@ -395,32 +394,53 @@ public sealed class RecordingsManager : IRecordingsManager, IDisposable
}
}
DeleteFileIfEmpty(recordingPath);
TriggerRefresh(recordingPath);
_libraryMonitor.ReportFileSystemChangeComplete(recordingPath, false);
_activeRecordings.TryRemove(timer.Id, out _);
if (recordingStatus != RecordingStatus.Completed && DateTime.UtcNow < timer.EndDate && timer.RetryCount < 10)
DeleteFileIfEmpty(currentRecordingPath);
if (File.Exists(currentRecordingPath))
{
const int RetryIntervalSeconds = 60;
_logger.LogInformation("Retrying recording in {0} seconds.", RetryIntervalSeconds);
_libraryMonitor.ReportFileSystemChangeComplete(currentRecordingPath, false);
timer.RecordingPartPaths = timer.RecordingPartPaths.Append(currentRecordingPath).ToArray();
}
if (recordingStatus == RecordingStatus.Error && DateTime.UtcNow < timer.EndDate && timer.FailedRetryCount < 20)
{
// For errors as long as we're getting data then we should try to keep on recording, so don't increment the retry count or timer
int retryIntervalSeconds = 0;
if (!File.Exists(currentRecordingPath))
{
retryIntervalSeconds = Math.Min(60, 1 << timer.FailedRetryCount);
timer.FailedRetryCount++;
}
else
{
timer.FailedRetryCount = 0;
}
_logger.LogInformation("Retrying recording in {0} seconds.", retryIntervalSeconds);
timer.Status = RecordingStatus.New;
timer.PrePaddingSeconds = 0;
timer.StartDate = DateTime.UtcNow.AddSeconds(RetryIntervalSeconds);
timer.StartDate = DateTime.UtcNow.AddSeconds(retryIntervalSeconds);
timer.RetryCount++;
_timerManager.AddOrUpdate(timer);
}
else if (File.Exists(recordingPath))
{
timer.RecordingPath = recordingPath;
timer.Status = RecordingStatus.Completed;
_timerManager.AddOrUpdate(timer, false);
await PostProcessRecording(recordingPath).ConfigureAwait(false);
}
else
{
_timerManager.Delete(timer);
// For all other cases we want to update the timer and collect all files
timer.Status = recordingStatus;
if (timer.RecordingPartPaths.Length > 0)
{
await _mediaEncoder.ConcatenateMedia(timer.RecordingPartPaths, timer.RecordingPath, CancellationToken.None).ConfigureAwait(false);
timer.RecordingPartPaths = [timer.RecordingPath];
timer.CurrentRecordingPath = null;
TriggerRefresh(timer.RecordingPath);
_timerManager.AddOrUpdate(timer, false);
await PostProcessRecording(timer.RecordingPath).ConfigureAwait(false);
}
else
{
_timerManager.AddOrUpdate(timer, false);
}
}
}
@ -584,8 +604,13 @@ public sealed class RecordingsManager : IRecordingsManager, IDisposable
return Path.Combine(recordingPath, recordingFileName);
}
private void DeleteFileIfEmpty(string path)
private void DeleteFileIfEmpty(string? path)
{
if (path == null)
{
return;
}
var file = _fileSystem.GetFileInfo(path);
if (file.Exists && file.Length == 0)
@ -773,18 +798,18 @@ public sealed class RecordingsManager : IRecordingsManager, IDisposable
var parent = Path.GetDirectoryName(path)!;
var name = Path.GetFileNameWithoutExtension(path);
var extension = Path.GetExtension(path);
var index = 1;
while (File.Exists(path) || _activeRecordings.Any(i
=> string.Equals(i.Value.Path, path, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(i.Value.Timer.Id, timerId, StringComparison.OrdinalIgnoreCase)))
string uniqueName;
var index = 0;
do
{
name += " - " + index.ToString(CultureInfo.InvariantCulture);
path = Path.ChangeExtension(Path.Combine(parent, name), extension);
uniqueName = name + "_" + index.ToString(CultureInfo.InvariantCulture);
path = Path.ChangeExtension(Path.Combine(parent, uniqueName), extension);
index++;
}
while (File.Exists(path) || _activeRecordings.Any(i =>
string.Equals(i.Value.Path, path, StringComparison.OrdinalIgnoreCase) &&
!string.Equals(i.Value.Timer.Id, timerId, StringComparison.OrdinalIgnoreCase))
);
return path;
}