mirror of
https://github.com/jellyfin/jellyfin.git
synced 2025-08-07 06:37:09 +02:00
Merge 1b241df9b3
into 6d4efe6523
This commit is contained in:
commit
3c6a7b2776
@ -200,6 +200,7 @@
|
|||||||
- [Jxiced](https://github.com/Jxiced)
|
- [Jxiced](https://github.com/Jxiced)
|
||||||
- [allesmi](https://github.com/allesmi)
|
- [allesmi](https://github.com/allesmi)
|
||||||
- [ThunderClapLP](https://github.com/ThunderClapLP)
|
- [ThunderClapLP](https://github.com/ThunderClapLP)
|
||||||
|
- [theshoeshiner](https://github.com/theshoeshiner)
|
||||||
- [Shoham Peller](https://github.com/spellr)
|
- [Shoham Peller](https://github.com/spellr)
|
||||||
- [theshoeshiner](https://github.com/theshoeshiner)
|
- [theshoeshiner](https://github.com/theshoeshiner)
|
||||||
- [TokerX](https://github.com/TokerX)
|
- [TokerX](https://github.com/TokerX)
|
||||||
|
@ -19,6 +19,7 @@ namespace MediaBrowser.Controller.LiveTv
|
|||||||
ProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
ProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
SeriesProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
SeriesProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
Tags = Array.Empty<string>();
|
Tags = Array.Empty<string>();
|
||||||
|
RecordingPartPaths = Array.Empty<string>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Dictionary<string, string> ProviderIds { get; set; }
|
public Dictionary<string, string> ProviderIds { get; set; }
|
||||||
@ -112,6 +113,8 @@ namespace MediaBrowser.Controller.LiveTv
|
|||||||
|
|
||||||
public int RetryCount { get; set; }
|
public int RetryCount { get; set; }
|
||||||
|
|
||||||
|
public int FailedRetryCount { get; set; }
|
||||||
|
|
||||||
// Program properties
|
// Program properties
|
||||||
public int? SeasonNumber { get; set; }
|
public int? SeasonNumber { get; set; }
|
||||||
|
|
||||||
@ -161,6 +164,10 @@ namespace MediaBrowser.Controller.LiveTv
|
|||||||
|
|
||||||
public string RecordingPath { get; set; }
|
public string RecordingPath { get; set; }
|
||||||
|
|
||||||
|
public string CurrentRecordingPath { get; set; }
|
||||||
|
|
||||||
|
public string[] RecordingPartPaths { get; set; }
|
||||||
|
|
||||||
public KeepUntil KeepUntil { get; set; }
|
public KeepUntil KeepUntil { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,6 @@ using System.Collections.Generic;
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using MediaBrowser.Model.Configuration;
|
|
||||||
using MediaBrowser.Model.Dlna;
|
using MediaBrowser.Model.Dlna;
|
||||||
using MediaBrowser.Model.Drawing;
|
using MediaBrowser.Model.Drawing;
|
||||||
using MediaBrowser.Model.Dto;
|
using MediaBrowser.Model.Dto;
|
||||||
@ -281,5 +280,14 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
/// <param name="source">The <see cref="MediaSourceInfo"/>.</param>
|
/// <param name="source">The <see cref="MediaSourceInfo"/>.</param>
|
||||||
/// <param name="concatFilePath">The path the config should be written to.</param>
|
/// <param name="concatFilePath">The path the config should be written to.</param>
|
||||||
void GenerateConcatConfig(MediaSourceInfo source, string concatFilePath);
|
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>
|
/// </summary>
|
||||||
internal const int DefaultHdrImageExtractionTimeout = 20000;
|
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 ILogger<MediaEncoder> _logger;
|
||||||
private readonly IServerConfigurationManager _configurationManager;
|
private readonly IServerConfigurationManager _configurationManager;
|
||||||
private readonly IFileSystem _fileSystem;
|
private readonly IFileSystem _fileSystem;
|
||||||
@ -1292,6 +1297,11 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
|||||||
return;
|
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
|
// Generate concat configuration entries for each file and write to file
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(concatFilePath));
|
Directory.CreateDirectory(Path.GetDirectoryName(concatFilePath));
|
||||||
using var sw = new FormattingStreamWriter(concatFilePath, CultureInfo.InvariantCulture);
|
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)
|
public bool CanExtractSubtitles(string codec)
|
||||||
{
|
{
|
||||||
// TODO is there ever a case when a subtitle can't be extracted??
|
// 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 timer = recordingInfo.Timer;
|
||||||
var remoteMetadata = await FetchInternetMetadata(timer, CancellationToken.None).ConfigureAwait(false);
|
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;
|
string? liveStreamId = null;
|
||||||
RecordingStatus recordingStatus;
|
RecordingStatus recordingStatus;
|
||||||
try
|
try
|
||||||
@ -336,51 +336,50 @@ public sealed class RecordingsManager : IRecordingsManager, IDisposable
|
|||||||
|
|
||||||
using var recorder = GetRecorder(mediaStreamInfo);
|
using var recorder = GetRecorder(mediaStreamInfo);
|
||||||
|
|
||||||
recordingPath = recorder.GetOutputPath(mediaStreamInfo, recordingPath);
|
if (timer.RecordingPath == null)
|
||||||
recordingPath = EnsureFileUnique(recordingPath, timer.Id);
|
{
|
||||||
|
timer.RecordingPath = recorder.GetOutputPath(mediaStreamInfo, baseRecordingPath);
|
||||||
_libraryMonitor.ReportFileSystemChangeBeginning(recordingPath);
|
}
|
||||||
|
|
||||||
|
currentRecordingPath = EnsureFileUnique(timer.RecordingPath, timer.Id);
|
||||||
|
_libraryMonitor.ReportFileSystemChangeBeginning(currentRecordingPath);
|
||||||
var duration = recordingEndDate - DateTime.UtcNow;
|
var duration = recordingEndDate - DateTime.UtcNow;
|
||||||
|
|
||||||
_logger.LogInformation("Beginning recording. Will record for {Duration} minutes.", duration.TotalMinutes);
|
_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()
|
async void OnStarted()
|
||||||
{
|
{
|
||||||
recordingInfo.Path = recordingPath;
|
recordingInfo.Path = currentRecordingPath;
|
||||||
_activeRecordings.TryAdd(timer.Id, recordingInfo);
|
_activeRecordings.TryAdd(timer.Id, recordingInfo);
|
||||||
|
|
||||||
timer.Status = RecordingStatus.InProgress;
|
timer.Status = RecordingStatus.InProgress;
|
||||||
|
timer.CurrentRecordingPath = currentRecordingPath;
|
||||||
_timerManager.AddOrUpdate(timer, false);
|
_timerManager.AddOrUpdate(timer, false);
|
||||||
|
await _recordingsMetadataManager.SaveRecordingMetadata(timer, timer.RecordingPath, seriesPath).ConfigureAwait(false);
|
||||||
await _recordingsMetadataManager.SaveRecordingMetadata(timer, recordingPath, seriesPath).ConfigureAwait(false);
|
|
||||||
await CreateRecordingFolders().ConfigureAwait(false);
|
await CreateRecordingFolders().ConfigureAwait(false);
|
||||||
|
TriggerRefresh(currentRecordingPath);
|
||||||
TriggerRefresh(recordingPath);
|
|
||||||
await EnforceKeepUpTo(timer, seriesPath).ConfigureAwait(false);
|
await EnforceKeepUpTo(timer, seriesPath).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
await recorder.Record(
|
await recorder.Record(
|
||||||
directStreamProvider,
|
directStreamProvider,
|
||||||
mediaStreamInfo,
|
mediaStreamInfo,
|
||||||
recordingPath,
|
currentRecordingPath,
|
||||||
duration,
|
duration,
|
||||||
OnStarted,
|
OnStarted,
|
||||||
recordingInfo.CancellationTokenSource.Token).ConfigureAwait(false);
|
recordingInfo.CancellationTokenSource.Token).ConfigureAwait(false);
|
||||||
|
|
||||||
recordingStatus = RecordingStatus.Completed;
|
recordingStatus = RecordingStatus.Completed;
|
||||||
_logger.LogInformation("Recording completed: {RecordPath}", recordingPath);
|
_logger.LogInformation("Recording completed: {CurrentRecordingPath}", currentRecordingPath);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Recording stopped: {RecordPath}", recordingPath);
|
_logger.LogInformation("Recording stopped currentRecordingPath: {CurrentRecordingPath}", currentRecordingPath);
|
||||||
recordingStatus = RecordingStatus.Completed;
|
recordingStatus = RecordingStatus.Cancelled;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error recording to {RecordPath}", recordingPath);
|
_logger.LogError(ex, "Error recording to: currentRecordingPath: {CurrentRecordingPath}", currentRecordingPath);
|
||||||
recordingStatus = RecordingStatus.Error;
|
recordingStatus = timer.Status == RecordingStatus.Cancelled ? RecordingStatus.Cancelled : RecordingStatus.Error;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(liveStreamId))
|
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 _);
|
_activeRecordings.TryRemove(timer.Id, out _);
|
||||||
|
DeleteFileIfEmpty(currentRecordingPath);
|
||||||
if (recordingStatus != RecordingStatus.Completed && DateTime.UtcNow < timer.EndDate && timer.RetryCount < 10)
|
if (File.Exists(currentRecordingPath))
|
||||||
{
|
{
|
||||||
const int RetryIntervalSeconds = 60;
|
_libraryMonitor.ReportFileSystemChangeComplete(currentRecordingPath, false);
|
||||||
_logger.LogInformation("Retrying recording in {0} seconds.", RetryIntervalSeconds);
|
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.Status = RecordingStatus.New;
|
||||||
timer.PrePaddingSeconds = 0;
|
timer.PrePaddingSeconds = 0;
|
||||||
timer.StartDate = DateTime.UtcNow.AddSeconds(RetryIntervalSeconds);
|
timer.StartDate = DateTime.UtcNow.AddSeconds(retryIntervalSeconds);
|
||||||
timer.RetryCount++;
|
timer.RetryCount++;
|
||||||
_timerManager.AddOrUpdate(timer);
|
_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
|
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);
|
return Path.Combine(recordingPath, recordingFileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DeleteFileIfEmpty(string path)
|
private void DeleteFileIfEmpty(string? path)
|
||||||
{
|
{
|
||||||
|
if (path == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var file = _fileSystem.GetFileInfo(path);
|
var file = _fileSystem.GetFileInfo(path);
|
||||||
|
|
||||||
if (file.Exists && file.Length == 0)
|
if (file.Exists && file.Length == 0)
|
||||||
@ -773,18 +798,18 @@ public sealed class RecordingsManager : IRecordingsManager, IDisposable
|
|||||||
var parent = Path.GetDirectoryName(path)!;
|
var parent = Path.GetDirectoryName(path)!;
|
||||||
var name = Path.GetFileNameWithoutExtension(path);
|
var name = Path.GetFileNameWithoutExtension(path);
|
||||||
var extension = Path.GetExtension(path);
|
var extension = Path.GetExtension(path);
|
||||||
|
string uniqueName;
|
||||||
var index = 1;
|
var index = 0;
|
||||||
while (File.Exists(path) || _activeRecordings.Any(i
|
do
|
||||||
=> string.Equals(i.Value.Path, path, StringComparison.OrdinalIgnoreCase)
|
|
||||||
&& !string.Equals(i.Value.Timer.Id, timerId, StringComparison.OrdinalIgnoreCase)))
|
|
||||||
{
|
{
|
||||||
name += " - " + index.ToString(CultureInfo.InvariantCulture);
|
uniqueName = name + "_" + index.ToString(CultureInfo.InvariantCulture);
|
||||||
|
path = Path.ChangeExtension(Path.Combine(parent, uniqueName), extension);
|
||||||
path = Path.ChangeExtension(Path.Combine(parent, name), extension);
|
|
||||||
index++;
|
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;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user