| | 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 | |
|
| 0 | 161 | | if (mediaSource.RequiresLooping) |
| | 162 | | { |
| 0 | 163 | | inputModifier += " -stream_loop -1 -reconnect_at_eof 1 -reconnect_streamed 1 -reconnect_delay_max 2"; |
| | 164 | | } |
| | 165 | |
|
| 0 | 166 | | var analyzeDurationSeconds = 5; |
| 0 | 167 | | var analyzeDuration = " -analyzeduration " + |
| 0 | 168 | | (analyzeDurationSeconds * 1000000).ToString(CultureInfo.InvariantCulture); |
| 0 | 169 | | inputModifier += analyzeDuration; |
| | 170 | |
|
| 0 | 171 | | var subtitleArgs = CopySubtitles ? " -codec:s copy" : " -sn"; |
| | 172 | |
|
| | 173 | | // var outputParam = string.Equals(Path.GetExtension(targetFile), ".mp4", StringComparison.OrdinalIgnoreCase |
| | 174 | | // " -f mp4 -movflags frag_keyframe+empty_moov" : |
| | 175 | | // string.Empty; |
| | 176 | |
|
| 0 | 177 | | var outputParam = string.Empty; |
| | 178 | |
|
| 0 | 179 | | var threads = EncodingHelper.GetNumberOfThreads(null, _serverConfigurationManager.GetEncodingOptions(), null |
| 0 | 180 | | var commandLineArgs = string.Format( |
| 0 | 181 | | CultureInfo.InvariantCulture, |
| 0 | 182 | | "-i \"{0}\" {2} -map_metadata -1 -threads {6} {3}{4}{5} -y \"{1}\"", |
| 0 | 183 | | inputTempFile, |
| 0 | 184 | | targetFile.Replace("\"", "\\\"", StringComparison.Ordinal), // Escape quotes in filename |
| 0 | 185 | | videoArgs, |
| 0 | 186 | | GetAudioArgs(mediaSource), |
| 0 | 187 | | subtitleArgs, |
| 0 | 188 | | outputParam, |
| 0 | 189 | | threads); |
| | 190 | |
|
| 0 | 191 | | return inputModifier + " " + commandLineArgs; |
| | 192 | | } |
| | 193 | |
|
| | 194 | | private static string GetAudioArgs(MediaSourceInfo mediaSource) |
| | 195 | | { |
| 0 | 196 | | return "-codec:a:0 copy"; |
| | 197 | | } |
| | 198 | |
|
| | 199 | | protected string GetOutputSizeParam() |
| 0 | 200 | | => "-vf \"yadif=0:-1:0\""; |
| | 201 | |
|
| | 202 | | private void Stop() |
| | 203 | | { |
| 0 | 204 | | if (!_hasExited) |
| | 205 | | { |
| | 206 | | try |
| | 207 | | { |
| 0 | 208 | | _logger.LogInformation("Stopping ffmpeg recording process for {Path}", _targetPath); |
| | 209 | |
|
| 0 | 210 | | _process.StandardInput.WriteLine("q"); |
| 0 | 211 | | } |
| 0 | 212 | | catch (Exception ex) |
| | 213 | | { |
| 0 | 214 | | _logger.LogError(ex, "Error stopping recording transcoding job for {Path}", _targetPath); |
| 0 | 215 | | } |
| | 216 | |
|
| 0 | 217 | | if (_hasExited) |
| | 218 | | { |
| 0 | 219 | | return; |
| | 220 | | } |
| | 221 | |
|
| | 222 | | try |
| | 223 | | { |
| 0 | 224 | | _logger.LogInformation("Calling recording process.WaitForExit for {Path}", _targetPath); |
| | 225 | |
|
| 0 | 226 | | if (_process.WaitForExit(10000)) |
| | 227 | | { |
| 0 | 228 | | return; |
| | 229 | | } |
| 0 | 230 | | } |
| 0 | 231 | | catch (Exception ex) |
| | 232 | | { |
| 0 | 233 | | _logger.LogError(ex, "Error waiting for recording process to exit for {Path}", _targetPath); |
| 0 | 234 | | } |
| | 235 | |
|
| 0 | 236 | | if (_hasExited) |
| | 237 | | { |
| 0 | 238 | | return; |
| | 239 | | } |
| | 240 | |
|
| | 241 | | try |
| | 242 | | { |
| 0 | 243 | | _logger.LogInformation("Killing ffmpeg recording process for {Path}", _targetPath); |
| | 244 | |
|
| 0 | 245 | | _process.Kill(); |
| 0 | 246 | | } |
| 0 | 247 | | catch (Exception ex) |
| | 248 | | { |
| 0 | 249 | | _logger.LogError(ex, "Error killing recording transcoding job for {Path}", _targetPath); |
| 0 | 250 | | } |
| | 251 | | } |
| 0 | 252 | | } |
| | 253 | |
|
| | 254 | | /// <summary> |
| | 255 | | /// Processes the exited. |
| | 256 | | /// </summary> |
| | 257 | | private void OnFfMpegProcessExited(Process process) |
| | 258 | | { |
| 0 | 259 | | using (process) |
| | 260 | | { |
| 0 | 261 | | _hasExited = true; |
| | 262 | |
|
| 0 | 263 | | _logFileStream?.Dispose(); |
| 0 | 264 | | _logFileStream = null; |
| | 265 | |
|
| 0 | 266 | | var exitCode = process.ExitCode; |
| | 267 | |
|
| 0 | 268 | | _logger.LogInformation("FFMpeg recording exited with code {ExitCode} for {Path}", exitCode, _targetPath) |
| | 269 | |
|
| 0 | 270 | | if (exitCode == 0) |
| | 271 | | { |
| 0 | 272 | | _taskCompletionSource.TrySetResult(true); |
| | 273 | | } |
| | 274 | | else |
| | 275 | | { |
| 0 | 276 | | _taskCompletionSource.TrySetException( |
| 0 | 277 | | new FfmpegException( |
| 0 | 278 | | string.Format( |
| 0 | 279 | | CultureInfo.InvariantCulture, |
| 0 | 280 | | "Recording for {0} failed. Exit code {1}", |
| 0 | 281 | | _targetPath, |
| 0 | 282 | | exitCode))); |
| | 283 | | } |
| 0 | 284 | | } |
| 0 | 285 | | } |
| | 286 | |
|
| | 287 | | private async Task StartStreamingLog(Stream source, FileStream target) |
| | 288 | | { |
| | 289 | | try |
| | 290 | | { |
| | 291 | | using (var reader = new StreamReader(source)) |
| | 292 | | { |
| | 293 | | await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false)) |
| | 294 | | { |
| | 295 | | var bytes = Encoding.UTF8.GetBytes(Environment.NewLine + line); |
| | 296 | |
|
| | 297 | | await target.WriteAsync(bytes.AsMemory()).ConfigureAwait(false); |
| | 298 | | await target.FlushAsync().ConfigureAwait(false); |
| | 299 | | } |
| | 300 | | } |
| | 301 | | } |
| | 302 | | catch (Exception ex) |
| | 303 | | { |
| | 304 | | _logger.LogError(ex, "Error reading ffmpeg recording log"); |
| | 305 | | } |
| | 306 | | } |
| | 307 | |
|
| | 308 | | /// <inheritdoc /> |
| | 309 | | public void Dispose() |
| | 310 | | { |
| 0 | 311 | | Dispose(true); |
| 0 | 312 | | GC.SuppressFinalize(this); |
| 0 | 313 | | } |
| | 314 | |
|
| | 315 | | /// <summary> |
| | 316 | | /// Releases unmanaged and optionally managed resources. |
| | 317 | | /// </summary> |
| | 318 | | /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release |
| | 319 | | protected virtual void Dispose(bool disposing) |
| | 320 | | { |
| 0 | 321 | | if (_disposed) |
| | 322 | | { |
| 0 | 323 | | return; |
| | 324 | | } |
| | 325 | |
|
| 0 | 326 | | if (disposing) |
| | 327 | | { |
| 0 | 328 | | _logFileStream?.Dispose(); |
| 0 | 329 | | _process?.Dispose(); |
| | 330 | | } |
| | 331 | |
|
| 0 | 332 | | _logFileStream = null; |
| 0 | 333 | | _process = null; |
| | 334 | |
|
| 0 | 335 | | _disposed = true; |
| 0 | 336 | | } |
| | 337 | | } |
| | 338 | | } |