< Summary - Jellyfin

Information
Class: Jellyfin.LiveTv.IO.EncodedRecorder
Assembly: Jellyfin.LiveTv
File(s): /srv/git/jellyfin/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 100
Coverable lines: 100
Total lines: 345
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 36
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 12/27/2025 - 12:11:51 AM Line coverage: 0% (0/98) Branch coverage: 0% (0/34) Total lines: 3383/24/2026 - 12:13:40 AM Line coverage: 0% (0/100) Branch coverage: 0% (0/36) Total lines: 345 12/27/2025 - 12:11:51 AM Line coverage: 0% (0/98) Branch coverage: 0% (0/34) Total lines: 3383/24/2026 - 12:13:40 AM Line coverage: 0% (0/100) Branch coverage: 0% (0/36) Total lines: 345

Coverage delta

Coverage delta 1 -1

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%210%
get_CopySubtitles()100%210%
GetOutputPath(...)100%210%
GetCommandLineArgs(...)0%272160%
GetAudioArgs(...)100%210%
GetOutputSizeParam()100%210%
Stop()0%7280%
OnFfMpegProcessExited(...)0%2040%
Dispose()100%210%
Dispose(...)0%7280%

File(s)

/srv/git/jellyfin/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs

#LineLine coverage
 1#nullable disable
 2
 3#pragma warning disable CS1591
 4
 5using System;
 6using System.Collections.Generic;
 7using System.Diagnostics;
 8using System.Globalization;
 9using System.IO;
 10using System.Text;
 11using System.Text.Json;
 12using System.Threading;
 13using System.Threading.Tasks;
 14using Jellyfin.Extensions;
 15using Jellyfin.Extensions.Json;
 16using MediaBrowser.Common;
 17using MediaBrowser.Common.Configuration;
 18using MediaBrowser.Controller;
 19using MediaBrowser.Controller.Configuration;
 20using MediaBrowser.Controller.Library;
 21using MediaBrowser.Controller.MediaEncoding;
 22using MediaBrowser.Model.Dto;
 23using MediaBrowser.Model.IO;
 24using Microsoft.Extensions.Logging;
 25
 26namespace 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;
 033        private readonly TaskCompletionSource<bool> _taskCompletionSource = new TaskCompletionSource<bool>(TaskCreationO
 34        private readonly IServerConfigurationManager _serverConfigurationManager;
 035        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        {
 048            _logger = logger;
 049            _mediaEncoder = mediaEncoder;
 050            _appPaths = appPaths;
 051            _serverConfigurationManager = serverConfigurationManager;
 052        }
 53
 054        private static bool CopySubtitles => false;
 55
 56        public string GetOutputPath(MediaSourceInfo mediaSource, string targetFile)
 57        {
 058            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        {
 0131            string videoArgs = "-codec:v:0 copy -fflags +genpts";
 132
 0133            var flags = new List<string>();
 0134            if (mediaSource.IgnoreDts)
 135            {
 0136                flags.Add("+igndts");
 137            }
 138
 0139            if (mediaSource.IgnoreIndex)
 140            {
 0141                flags.Add("+ignidx");
 142            }
 143
 0144            if (mediaSource.GenPtsInput)
 145            {
 0146                flags.Add("+genpts");
 147            }
 148
 0149            var inputModifier = "-async 1";
 150
 0151            if (flags.Count > 0)
 152            {
 0153                inputModifier += " -fflags " + string.Join(string.Empty, flags);
 154            }
 155
 0156            if (mediaSource.ReadAtNativeFramerate)
 157            {
 0158                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
 0162                if (_mediaEncoder.EncoderVersion >= new Version(8, 0))
 163                {
 0164                    inputModifier += " -readrate_catchup 100";
 165                }
 166            }
 167
 0168            if (mediaSource.RequiresLooping)
 169            {
 0170                inputModifier += " -stream_loop -1 -reconnect_at_eof 1 -reconnect_streamed 1 -reconnect_delay_max 2";
 171            }
 172
 0173            var analyzeDurationSeconds = 5;
 0174            var analyzeDuration = " -analyzeduration " +
 0175                  (analyzeDurationSeconds * 1000000).ToString(CultureInfo.InvariantCulture);
 0176            inputModifier += analyzeDuration;
 177
 0178            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
 0184            var outputParam = string.Empty;
 185
 0186            var threads = EncodingHelper.GetNumberOfThreads(null, _serverConfigurationManager.GetEncodingOptions(), null
 0187            var commandLineArgs = string.Format(
 0188                CultureInfo.InvariantCulture,
 0189                "-i \"{0}\" {2} -map_metadata -1 -threads {6} {3}{4}{5} -y \"{1}\"",
 0190                inputTempFile,
 0191                targetFile.Replace("\"", "\\\"", StringComparison.Ordinal), // Escape quotes in filename
 0192                videoArgs,
 0193                GetAudioArgs(mediaSource),
 0194                subtitleArgs,
 0195                outputParam,
 0196                threads);
 197
 0198            return inputModifier + " " + commandLineArgs;
 199        }
 200
 201        private static string GetAudioArgs(MediaSourceInfo mediaSource)
 202        {
 0203            return "-codec:a:0 copy";
 204        }
 205
 206        protected string GetOutputSizeParam()
 0207            => "-vf \"yadif=0:-1:0\"";
 208
 209        private void Stop()
 210        {
 0211            if (!_hasExited)
 212            {
 213                try
 214                {
 0215                    _logger.LogInformation("Stopping ffmpeg recording process for {Path}", _targetPath);
 216
 0217                    _process.StandardInput.WriteLine("q");
 0218                }
 0219                catch (Exception ex)
 220                {
 0221                    _logger.LogError(ex, "Error stopping recording transcoding job for {Path}", _targetPath);
 0222                }
 223
 0224                if (_hasExited)
 225                {
 0226                    return;
 227                }
 228
 229                try
 230                {
 0231                    _logger.LogInformation("Calling recording process.WaitForExit for {Path}", _targetPath);
 232
 0233                    if (_process.WaitForExit(10000))
 234                    {
 0235                        return;
 236                    }
 0237                }
 0238                catch (Exception ex)
 239                {
 0240                    _logger.LogError(ex, "Error waiting for recording process to exit for {Path}", _targetPath);
 0241                }
 242
 0243                if (_hasExited)
 244                {
 0245                    return;
 246                }
 247
 248                try
 249                {
 0250                    _logger.LogInformation("Killing ffmpeg recording process for {Path}", _targetPath);
 251
 0252                    _process.Kill();
 0253                }
 0254                catch (Exception ex)
 255                {
 0256                    _logger.LogError(ex, "Error killing recording transcoding job for {Path}", _targetPath);
 0257                }
 258            }
 0259        }
 260
 261        /// <summary>
 262        /// Processes the exited.
 263        /// </summary>
 264        private void OnFfMpegProcessExited(Process process)
 265        {
 0266            using (process)
 267            {
 0268                _hasExited = true;
 269
 0270                _logFileStream?.Dispose();
 0271                _logFileStream = null;
 272
 0273                var exitCode = process.ExitCode;
 274
 0275                _logger.LogInformation("FFMpeg recording exited with code {ExitCode} for {Path}", exitCode, _targetPath)
 276
 0277                if (exitCode == 0)
 278                {
 0279                    _taskCompletionSource.TrySetResult(true);
 280                }
 281                else
 282                {
 0283                    _taskCompletionSource.TrySetException(
 0284                        new FfmpegException(
 0285                            string.Format(
 0286                                CultureInfo.InvariantCulture,
 0287                                "Recording for {0} failed. Exit code {1}",
 0288                                _targetPath,
 0289                                exitCode)));
 290                }
 0291            }
 0292        }
 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        {
 0318            Dispose(true);
 0319            GC.SuppressFinalize(this);
 0320        }
 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        {
 0328            if (_disposed)
 329            {
 0330                return;
 331            }
 332
 0333            if (disposing)
 334            {
 0335                _logFileStream?.Dispose();
 0336                _process?.Dispose();
 337            }
 338
 0339            _logFileStream = null;
 0340            _process = null;
 341
 0342            _disposed = true;
 0343        }
 344    }
 345}