mirror of
https://github.com/jellyfin/jellyfin.git
synced 2025-08-06 06:07:05 +02:00
Merge 1b241df9b3
into 6d4efe6523
This commit is contained in:
commit
3c6a7b2776
@ -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)
|
||||
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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??
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user