| | | 1 | | #nullable disable |
| | | 2 | | |
| | | 3 | | #pragma warning disable CS1591 |
| | | 4 | | |
| | | 5 | | using System; |
| | | 6 | | using System.Collections.Generic; |
| | | 7 | | using System.Diagnostics; |
| | | 8 | | using System.Globalization; |
| | | 9 | | using System.IO; |
| | | 10 | | using System.Text; |
| | | 11 | | using System.Text.Json; |
| | | 12 | | using System.Threading; |
| | | 13 | | using System.Threading.Tasks; |
| | | 14 | | using Jellyfin.Extensions; |
| | | 15 | | using Jellyfin.Extensions.Json; |
| | | 16 | | using MediaBrowser.Common; |
| | | 17 | | using MediaBrowser.Common.Configuration; |
| | | 18 | | using MediaBrowser.Controller; |
| | | 19 | | using MediaBrowser.Controller.Configuration; |
| | | 20 | | using MediaBrowser.Controller.Library; |
| | | 21 | | using MediaBrowser.Controller.MediaEncoding; |
| | | 22 | | using MediaBrowser.Model.Dto; |
| | | 23 | | using MediaBrowser.Model.IO; |
| | | 24 | | using Microsoft.Extensions.Logging; |
| | | 25 | | |
| | | 26 | | namespace Jellyfin.LiveTv.IO |
| | | 27 | | { |
| | | 28 | | public class EncodedRecorder : IRecorder |
| | | 29 | | { |
| | | 30 | | private readonly ILogger _logger; |
| | | 31 | | private readonly IMediaEncoder _mediaEncoder; |
| | | 32 | | private readonly IServerApplicationPaths _appPaths; |
| | 0 | 33 | | private readonly TaskCompletionSource<bool> _taskCompletionSource = new TaskCompletionSource<bool>(TaskCreationO |
| | | 34 | | private readonly IServerConfigurationManager _serverConfigurationManager; |
| | 0 | 35 | | private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; |
| | | 36 | | private bool _hasExited; |
| | | 37 | | private FileStream _logFileStream; |
| | | 38 | | private string _targetPath; |
| | | 39 | | private Process _process; |
| | | 40 | | private bool _disposed; |
| | | 41 | | |
| | | 42 | | public EncodedRecorder( |
| | | 43 | | ILogger logger, |
| | | 44 | | IMediaEncoder mediaEncoder, |
| | | 45 | | IServerApplicationPaths appPaths, |
| | | 46 | | IServerConfigurationManager serverConfigurationManager) |
| | | 47 | | { |
| | 0 | 48 | | _logger = logger; |
| | 0 | 49 | | _mediaEncoder = mediaEncoder; |
| | 0 | 50 | | _appPaths = appPaths; |
| | 0 | 51 | | _serverConfigurationManager = serverConfigurationManager; |
| | 0 | 52 | | } |
| | | 53 | | |
| | 0 | 54 | | private static bool CopySubtitles => false; |
| | | 55 | | |
| | | 56 | | public string GetOutputPath(MediaSourceInfo mediaSource, string targetFile) |
| | | 57 | | { |
| | 0 | 58 | | return Path.ChangeExtension(targetFile, ".ts"); |
| | | 59 | | } |
| | | 60 | | |
| | | 61 | | public async Task Record(IDirectStreamProvider directStreamProvider, MediaSourceInfo mediaSource, string targetF |
| | | 62 | | { |
| | | 63 | | // The media source is infinite so we need to handle stopping ourselves |
| | | 64 | | using var durationToken = new CancellationTokenSource(duration); |
| | | 65 | | using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durat |
| | | 66 | | |
| | | 67 | | await RecordFromFile(mediaSource, mediaSource.Path, targetFile, onStarted, cancellationTokenSource.Token).Co |
| | | 68 | | |
| | | 69 | | _logger.LogInformation("Recording completed to file {Path}", targetFile); |
| | | 70 | | } |
| | | 71 | | |
| | | 72 | | private async Task RecordFromFile(MediaSourceInfo mediaSource, string inputFile, string targetFile, Action onSta |
| | | 73 | | { |
| | | 74 | | _targetPath = targetFile; |
| | | 75 | | Directory.CreateDirectory(Path.GetDirectoryName(targetFile)); |
| | | 76 | | if (!File.Exists(targetFile)) |
| | | 77 | | { |
| | | 78 | | FileHelper.CreateEmpty(targetFile); |
| | | 79 | | } |
| | | 80 | | |
| | | 81 | | var processStartInfo = new ProcessStartInfo |
| | | 82 | | { |
| | | 83 | | CreateNoWindow = true, |
| | | 84 | | UseShellExecute = false, |
| | | 85 | | |
| | | 86 | | RedirectStandardError = true, |
| | | 87 | | RedirectStandardInput = true, |
| | | 88 | | |
| | | 89 | | FileName = _mediaEncoder.EncoderPath, |
| | | 90 | | Arguments = GetCommandLineArgs(mediaSource, inputFile, targetFile), |
| | | 91 | | |
| | | 92 | | WindowStyle = ProcessWindowStyle.Hidden, |
| | | 93 | | ErrorDialog = false |
| | | 94 | | }; |
| | | 95 | | |
| | | 96 | | _logger.LogInformation("{Filename} {Arguments}", processStartInfo.FileName, processStartInfo.Arguments); |
| | | 97 | | |
| | | 98 | | var logFilePath = Path.Combine(_appPaths.LogDirectoryPath, "record-transcode-" + Guid.NewGuid() + ".txt"); |
| | | 99 | | Directory.CreateDirectory(Path.GetDirectoryName(logFilePath)); |
| | | 100 | | |
| | | 101 | | // FFMpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log direct |
| | | 102 | | _logFileStream = new FileStream(logFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.Read, IODefault |
| | | 103 | | |
| | | 104 | | await JsonSerializer.SerializeAsync(_logFileStream, mediaSource, _jsonOptions, cancellationToken).ConfigureA |
| | | 105 | | await _logFileStream.WriteAsync(Encoding.UTF8.GetBytes(Environment.NewLine + Environment.NewLine + processSt |
| | | 106 | | |
| | | 107 | | _process = new Process |
| | | 108 | | { |
| | | 109 | | StartInfo = processStartInfo, |
| | | 110 | | EnableRaisingEvents = true |
| | | 111 | | }; |
| | | 112 | | _process.Exited += (_, _) => OnFfMpegProcessExited(_process); |
| | | 113 | | |
| | | 114 | | _process.Start(); |
| | | 115 | | |
| | | 116 | | cancellationToken.Register(Stop); |
| | | 117 | | |
| | | 118 | | onStarted(); |
| | | 119 | | |
| | | 120 | | // Important - don't await the log task or we won't be able to kill ffmpeg when the user stops playback |
| | | 121 | | _ = StartStreamingLog(_process.StandardError.BaseStream, _logFileStream); |
| | | 122 | | |
| | | 123 | | _logger.LogInformation("ffmpeg recording process started for {Path}", _targetPath); |
| | | 124 | | |
| | | 125 | | // Block until ffmpeg exits |
| | | 126 | | await _taskCompletionSource.Task.ConfigureAwait(false); |
| | | 127 | | } |
| | | 128 | | |
| | | 129 | | private string GetCommandLineArgs(MediaSourceInfo mediaSource, string inputTempFile, string targetFile) |
| | | 130 | | { |
| | 0 | 131 | | string videoArgs = "-codec:v:0 copy -fflags +genpts"; |
| | | 132 | | |
| | 0 | 133 | | var flags = new List<string>(); |
| | 0 | 134 | | if (mediaSource.IgnoreDts) |
| | | 135 | | { |
| | 0 | 136 | | flags.Add("+igndts"); |
| | | 137 | | } |
| | | 138 | | |
| | 0 | 139 | | if (mediaSource.IgnoreIndex) |
| | | 140 | | { |
| | 0 | 141 | | flags.Add("+ignidx"); |
| | | 142 | | } |
| | | 143 | | |
| | 0 | 144 | | if (mediaSource.GenPtsInput) |
| | | 145 | | { |
| | 0 | 146 | | flags.Add("+genpts"); |
| | | 147 | | } |
| | | 148 | | |
| | 0 | 149 | | var inputModifier = "-async 1"; |
| | | 150 | | |
| | 0 | 151 | | if (flags.Count > 0) |
| | | 152 | | { |
| | 0 | 153 | | inputModifier += " -fflags " + string.Join(string.Empty, flags); |
| | | 154 | | } |
| | | 155 | | |
| | 0 | 156 | | if (mediaSource.ReadAtNativeFramerate) |
| | | 157 | | { |
| | 0 | 158 | | inputModifier += " -re"; |
| | | 159 | | |
| | | 160 | | // Set a larger catchup value to revert to the old behavior, |
| | | 161 | | // otherwise, remuxing might stall due to this new option |
| | 0 | 162 | | if (_mediaEncoder.EncoderVersion >= new Version(8, 0)) |
| | | 163 | | { |
| | 0 | 164 | | inputModifier += " -readrate_catchup 100"; |
| | | 165 | | } |
| | | 166 | | } |
| | | 167 | | |
| | 0 | 168 | | if (mediaSource.RequiresLooping) |
| | | 169 | | { |
| | 0 | 170 | | inputModifier += " -stream_loop -1 -reconnect_at_eof 1 -reconnect_streamed 1 -reconnect_delay_max 2"; |
| | | 171 | | } |
| | | 172 | | |
| | 0 | 173 | | var analyzeDurationSeconds = 5; |
| | 0 | 174 | | var analyzeDuration = " -analyzeduration " + |
| | 0 | 175 | | (analyzeDurationSeconds * 1000000).ToString(CultureInfo.InvariantCulture); |
| | 0 | 176 | | inputModifier += analyzeDuration; |
| | | 177 | | |
| | 0 | 178 | | var subtitleArgs = CopySubtitles ? " -codec:s copy" : " -sn"; |
| | | 179 | | |
| | | 180 | | // var outputParam = string.Equals(Path.GetExtension(targetFile), ".mp4", StringComparison.OrdinalIgnoreCase |
| | | 181 | | // " -f mp4 -movflags frag_keyframe+empty_moov" : |
| | | 182 | | // string.Empty; |
| | | 183 | | |
| | 0 | 184 | | var outputParam = string.Empty; |
| | | 185 | | |
| | 0 | 186 | | var threads = EncodingHelper.GetNumberOfThreads(null, _serverConfigurationManager.GetEncodingOptions(), null |
| | 0 | 187 | | var commandLineArgs = string.Format( |
| | 0 | 188 | | CultureInfo.InvariantCulture, |
| | 0 | 189 | | "-i \"{0}\" {2} -map_metadata -1 -threads {6} {3}{4}{5} -y \"{1}\"", |
| | 0 | 190 | | inputTempFile, |
| | 0 | 191 | | targetFile.Replace("\"", "\\\"", StringComparison.Ordinal), // Escape quotes in filename |
| | 0 | 192 | | videoArgs, |
| | 0 | 193 | | GetAudioArgs(mediaSource), |
| | 0 | 194 | | subtitleArgs, |
| | 0 | 195 | | outputParam, |
| | 0 | 196 | | threads); |
| | | 197 | | |
| | 0 | 198 | | return inputModifier + " " + commandLineArgs; |
| | | 199 | | } |
| | | 200 | | |
| | | 201 | | private static string GetAudioArgs(MediaSourceInfo mediaSource) |
| | | 202 | | { |
| | 0 | 203 | | return "-codec:a:0 copy"; |
| | | 204 | | } |
| | | 205 | | |
| | | 206 | | protected string GetOutputSizeParam() |
| | 0 | 207 | | => "-vf \"yadif=0:-1:0\""; |
| | | 208 | | |
| | | 209 | | private void Stop() |
| | | 210 | | { |
| | 0 | 211 | | if (!_hasExited) |
| | | 212 | | { |
| | | 213 | | try |
| | | 214 | | { |
| | 0 | 215 | | _logger.LogInformation("Stopping ffmpeg recording process for {Path}", _targetPath); |
| | | 216 | | |
| | 0 | 217 | | _process.StandardInput.WriteLine("q"); |
| | 0 | 218 | | } |
| | 0 | 219 | | catch (Exception ex) |
| | | 220 | | { |
| | 0 | 221 | | _logger.LogError(ex, "Error stopping recording transcoding job for {Path}", _targetPath); |
| | 0 | 222 | | } |
| | | 223 | | |
| | 0 | 224 | | if (_hasExited) |
| | | 225 | | { |
| | 0 | 226 | | return; |
| | | 227 | | } |
| | | 228 | | |
| | | 229 | | try |
| | | 230 | | { |
| | 0 | 231 | | _logger.LogInformation("Calling recording process.WaitForExit for {Path}", _targetPath); |
| | | 232 | | |
| | 0 | 233 | | if (_process.WaitForExit(10000)) |
| | | 234 | | { |
| | 0 | 235 | | return; |
| | | 236 | | } |
| | 0 | 237 | | } |
| | 0 | 238 | | catch (Exception ex) |
| | | 239 | | { |
| | 0 | 240 | | _logger.LogError(ex, "Error waiting for recording process to exit for {Path}", _targetPath); |
| | 0 | 241 | | } |
| | | 242 | | |
| | 0 | 243 | | if (_hasExited) |
| | | 244 | | { |
| | 0 | 245 | | return; |
| | | 246 | | } |
| | | 247 | | |
| | | 248 | | try |
| | | 249 | | { |
| | 0 | 250 | | _logger.LogInformation("Killing ffmpeg recording process for {Path}", _targetPath); |
| | | 251 | | |
| | 0 | 252 | | _process.Kill(); |
| | 0 | 253 | | } |
| | 0 | 254 | | catch (Exception ex) |
| | | 255 | | { |
| | 0 | 256 | | _logger.LogError(ex, "Error killing recording transcoding job for {Path}", _targetPath); |
| | 0 | 257 | | } |
| | | 258 | | } |
| | 0 | 259 | | } |
| | | 260 | | |
| | | 261 | | /// <summary> |
| | | 262 | | /// Processes the exited. |
| | | 263 | | /// </summary> |
| | | 264 | | private void OnFfMpegProcessExited(Process process) |
| | | 265 | | { |
| | 0 | 266 | | using (process) |
| | | 267 | | { |
| | 0 | 268 | | _hasExited = true; |
| | | 269 | | |
| | 0 | 270 | | _logFileStream?.Dispose(); |
| | 0 | 271 | | _logFileStream = null; |
| | | 272 | | |
| | 0 | 273 | | var exitCode = process.ExitCode; |
| | | 274 | | |
| | 0 | 275 | | _logger.LogInformation("FFMpeg recording exited with code {ExitCode} for {Path}", exitCode, _targetPath) |
| | | 276 | | |
| | 0 | 277 | | if (exitCode == 0) |
| | | 278 | | { |
| | 0 | 279 | | _taskCompletionSource.TrySetResult(true); |
| | | 280 | | } |
| | | 281 | | else |
| | | 282 | | { |
| | 0 | 283 | | _taskCompletionSource.TrySetException( |
| | 0 | 284 | | new FfmpegException( |
| | 0 | 285 | | string.Format( |
| | 0 | 286 | | CultureInfo.InvariantCulture, |
| | 0 | 287 | | "Recording for {0} failed. Exit code {1}", |
| | 0 | 288 | | _targetPath, |
| | 0 | 289 | | exitCode))); |
| | | 290 | | } |
| | 0 | 291 | | } |
| | 0 | 292 | | } |
| | | 293 | | |
| | | 294 | | private async Task StartStreamingLog(Stream source, FileStream target) |
| | | 295 | | { |
| | | 296 | | try |
| | | 297 | | { |
| | | 298 | | using (var reader = new StreamReader(source)) |
| | | 299 | | { |
| | | 300 | | await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false)) |
| | | 301 | | { |
| | | 302 | | var bytes = Encoding.UTF8.GetBytes(Environment.NewLine + line); |
| | | 303 | | |
| | | 304 | | await target.WriteAsync(bytes.AsMemory()).ConfigureAwait(false); |
| | | 305 | | await target.FlushAsync().ConfigureAwait(false); |
| | | 306 | | } |
| | | 307 | | } |
| | | 308 | | } |
| | | 309 | | catch (Exception ex) |
| | | 310 | | { |
| | | 311 | | _logger.LogError(ex, "Error reading ffmpeg recording log"); |
| | | 312 | | } |
| | | 313 | | } |
| | | 314 | | |
| | | 315 | | /// <inheritdoc /> |
| | | 316 | | public void Dispose() |
| | | 317 | | { |
| | 0 | 318 | | Dispose(true); |
| | 0 | 319 | | GC.SuppressFinalize(this); |
| | 0 | 320 | | } |
| | | 321 | | |
| | | 322 | | /// <summary> |
| | | 323 | | /// Releases unmanaged and optionally managed resources. |
| | | 324 | | /// </summary> |
| | | 325 | | /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release |
| | | 326 | | protected virtual void Dispose(bool disposing) |
| | | 327 | | { |
| | 0 | 328 | | if (_disposed) |
| | | 329 | | { |
| | 0 | 330 | | return; |
| | | 331 | | } |
| | | 332 | | |
| | 0 | 333 | | if (disposing) |
| | | 334 | | { |
| | 0 | 335 | | _logFileStream?.Dispose(); |
| | 0 | 336 | | _process?.Dispose(); |
| | | 337 | | } |
| | | 338 | | |
| | 0 | 339 | | _logFileStream = null; |
| | 0 | 340 | | _process = null; |
| | | 341 | | |
| | 0 | 342 | | _disposed = true; |
| | 0 | 343 | | } |
| | | 344 | | } |
| | | 345 | | } |