< 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: 153
Coverable lines: 153
Total lines: 346
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 40
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 4/15/2026 - 12:14:34 AM Line coverage: 0% (0/100) Branch coverage: 0% (0/36) Total lines: 3454/19/2026 - 12:14:27 AM Line coverage: 0% (0/152) Branch coverage: 0% (0/40) Total lines: 3457/18/2026 - 12:15:19 AM Line coverage: 0% (0/153) Branch coverage: 0% (0/40) Total lines: 346 4/15/2026 - 12:14:34 AM Line coverage: 0% (0/100) Branch coverage: 0% (0/36) Total lines: 3454/19/2026 - 12:14:27 AM Line coverage: 0% (0/152) Branch coverage: 0% (0/40) Total lines: 3457/18/2026 - 12:15:19 AM Line coverage: 0% (0/153) Branch coverage: 0% (0/40) Total lines: 346

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%
Record()100%210%
RecordFromFile()0%620%
GetCommandLineArgs(...)0%272160%
GetAudioArgs(...)100%210%
GetOutputSizeParam()100%210%
Stop()0%7280%
OnFfMpegProcessExited(...)0%2040%
StartStreamingLog()0%620%
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
 064            using var durationToken = new CancellationTokenSource(duration);
 065            using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durat
 66
 067            await RecordFromFile(mediaSource, mediaSource.Path, targetFile, onStarted, cancellationTokenSource.Token).Co
 68
 069            _logger.LogInformation("Recording completed to file {Path}", targetFile);
 070        }
 71
 72        private async Task RecordFromFile(MediaSourceInfo mediaSource, string inputFile, string targetFile, Action onSta
 73        {
 074            _targetPath = targetFile;
 075            Directory.CreateDirectory(Path.GetDirectoryName(targetFile));
 076            if (!File.Exists(targetFile))
 77            {
 078                FileHelper.CreateEmpty(targetFile);
 79            }
 80
 081            var processStartInfo = new ProcessStartInfo
 082            {
 083                CreateNoWindow = true,
 084                UseShellExecute = false,
 085
 086                StandardErrorEncoding = Encoding.UTF8,
 087                RedirectStandardError = true,
 088                RedirectStandardInput = true,
 089
 090                FileName = _mediaEncoder.EncoderPath,
 091                Arguments = GetCommandLineArgs(mediaSource, inputFile, targetFile),
 092
 093                WindowStyle = ProcessWindowStyle.Hidden,
 094                ErrorDialog = false
 095            };
 96
 097            _logger.LogInformation("{Filename} {Arguments}", processStartInfo.FileName, processStartInfo.Arguments);
 98
 099            var logFilePath = Path.Combine(_appPaths.LogDirectoryPath, "record-transcode-" + Guid.NewGuid() + ".txt");
 0100            Directory.CreateDirectory(Path.GetDirectoryName(logFilePath));
 101
 102            // FFMpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log direct
 0103            _logFileStream = new FileStream(logFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.Read, IODefault
 104
 0105            await JsonSerializer.SerializeAsync(_logFileStream, mediaSource, _jsonOptions, cancellationToken).ConfigureA
 0106            await _logFileStream.WriteAsync(Encoding.UTF8.GetBytes(Environment.NewLine + Environment.NewLine + processSt
 107
 0108            _process = new Process
 0109            {
 0110                StartInfo = processStartInfo,
 0111                EnableRaisingEvents = true
 0112            };
 113            _process.Exited += (_, _) => OnFfMpegProcessExited(_process);
 114
 0115            _process.Start();
 116
 0117            cancellationToken.Register(Stop);
 118
 0119            onStarted();
 120
 121            // Important - don't await the log task or we won't be able to kill ffmpeg when the user stops playback
 0122            _ = StartStreamingLog(_process.StandardError.BaseStream, _logFileStream);
 123
 0124            _logger.LogInformation("ffmpeg recording process started for {Path}", _targetPath);
 125
 126            // Block until ffmpeg exits
 0127            await _taskCompletionSource.Task.ConfigureAwait(false);
 0128        }
 129
 130        private string GetCommandLineArgs(MediaSourceInfo mediaSource, string inputTempFile, string targetFile)
 131        {
 0132            string videoArgs = "-codec:v:0 copy -fflags +genpts";
 133
 0134            var flags = new List<string>();
 0135            if (mediaSource.IgnoreDts)
 136            {
 0137                flags.Add("+igndts");
 138            }
 139
 0140            if (mediaSource.IgnoreIndex)
 141            {
 0142                flags.Add("+ignidx");
 143            }
 144
 0145            if (mediaSource.GenPtsInput)
 146            {
 0147                flags.Add("+genpts");
 148            }
 149
 0150            var inputModifier = "-async 1";
 151
 0152            if (flags.Count > 0)
 153            {
 0154                inputModifier += " -fflags " + string.Join(string.Empty, flags);
 155            }
 156
 0157            if (mediaSource.ReadAtNativeFramerate)
 158            {
 0159                inputModifier += " -re";
 160
 161                // Set a larger catchup value to revert to the old behavior,
 162                // otherwise, remuxing might stall due to this new option
 0163                if (_mediaEncoder.EncoderVersion >= new Version(8, 0))
 164                {
 0165                    inputModifier += " -readrate_catchup 100";
 166                }
 167            }
 168
 0169            if (mediaSource.RequiresLooping)
 170            {
 0171                inputModifier += " -stream_loop -1 -reconnect_at_eof 1 -reconnect_streamed 1 -reconnect_delay_max 2";
 172            }
 173
 0174            var analyzeDurationSeconds = 5;
 0175            var analyzeDuration = " -analyzeduration " +
 0176                  (analyzeDurationSeconds * 1000000).ToString(CultureInfo.InvariantCulture);
 0177            inputModifier += analyzeDuration;
 178
 0179            var subtitleArgs = CopySubtitles ? " -codec:s copy" : " -sn";
 180
 181            // var outputParam = string.Equals(Path.GetExtension(targetFile), ".mp4", StringComparison.OrdinalIgnoreCase
 182            //    " -f mp4 -movflags frag_keyframe+empty_moov" :
 183            //    string.Empty;
 184
 0185            var outputParam = string.Empty;
 186
 0187            var threads = EncodingHelper.GetNumberOfThreads(null, _serverConfigurationManager.GetEncodingOptions(), null
 0188            var commandLineArgs = string.Format(
 0189                CultureInfo.InvariantCulture,
 0190                "-i \"{0}\" {2} -map_metadata -1 -threads {6} {3}{4}{5} -y \"{1}\"",
 0191                inputTempFile,
 0192                targetFile.Replace("\"", "\\\"", StringComparison.Ordinal), // Escape quotes in filename
 0193                videoArgs,
 0194                GetAudioArgs(mediaSource),
 0195                subtitleArgs,
 0196                outputParam,
 0197                threads);
 198
 0199            return inputModifier + " " + commandLineArgs;
 200        }
 201
 202        private static string GetAudioArgs(MediaSourceInfo mediaSource)
 203        {
 0204            return "-codec:a:0 copy";
 205        }
 206
 207        protected string GetOutputSizeParam()
 0208            => "-vf \"yadif=0:-1:0\"";
 209
 210        private void Stop()
 211        {
 0212            if (!_hasExited)
 213            {
 214                try
 215                {
 0216                    _logger.LogInformation("Stopping ffmpeg recording process for {Path}", _targetPath);
 217
 0218                    _process.StandardInput.WriteLine("q");
 0219                }
 0220                catch (Exception ex)
 221                {
 0222                    _logger.LogError(ex, "Error stopping recording transcoding job for {Path}", _targetPath);
 0223                }
 224
 0225                if (_hasExited)
 226                {
 0227                    return;
 228                }
 229
 230                try
 231                {
 0232                    _logger.LogInformation("Calling recording process.WaitForExit for {Path}", _targetPath);
 233
 0234                    if (_process.WaitForExit(10000))
 235                    {
 0236                        return;
 237                    }
 0238                }
 0239                catch (Exception ex)
 240                {
 0241                    _logger.LogError(ex, "Error waiting for recording process to exit for {Path}", _targetPath);
 0242                }
 243
 0244                if (_hasExited)
 245                {
 0246                    return;
 247                }
 248
 249                try
 250                {
 0251                    _logger.LogInformation("Killing ffmpeg recording process for {Path}", _targetPath);
 252
 0253                    _process.Kill();
 0254                }
 0255                catch (Exception ex)
 256                {
 0257                    _logger.LogError(ex, "Error killing recording transcoding job for {Path}", _targetPath);
 0258                }
 259            }
 0260        }
 261
 262        /// <summary>
 263        /// Processes the exited.
 264        /// </summary>
 265        private void OnFfMpegProcessExited(Process process)
 266        {
 0267            using (process)
 268            {
 0269                _hasExited = true;
 270
 0271                _logFileStream?.Dispose();
 0272                _logFileStream = null;
 273
 0274                var exitCode = process.ExitCode;
 275
 0276                _logger.LogInformation("FFMpeg recording exited with code {ExitCode} for {Path}", exitCode, _targetPath)
 277
 0278                if (exitCode == 0)
 279                {
 0280                    _taskCompletionSource.TrySetResult(true);
 281                }
 282                else
 283                {
 0284                    _taskCompletionSource.TrySetException(
 0285                        new FfmpegException(
 0286                            string.Format(
 0287                                CultureInfo.InvariantCulture,
 0288                                "Recording for {0} failed. Exit code {1}",
 0289                                _targetPath,
 0290                                exitCode)));
 291                }
 0292            }
 0293        }
 294
 295        private async Task StartStreamingLog(Stream source, FileStream target)
 296        {
 297            try
 298            {
 0299                using (var reader = new StreamReader(source))
 300                {
 0301                    await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false))
 302                    {
 0303                        var bytes = Encoding.UTF8.GetBytes(Environment.NewLine + line);
 304
 0305                        await target.WriteAsync(bytes.AsMemory()).ConfigureAwait(false);
 0306                        await target.FlushAsync().ConfigureAwait(false);
 307                    }
 0308                }
 0309            }
 0310            catch (Exception ex)
 311            {
 0312                _logger.LogError(ex, "Error reading ffmpeg recording log");
 0313            }
 0314        }
 315
 316        /// <inheritdoc />
 317        public void Dispose()
 318        {
 0319            Dispose(true);
 0320            GC.SuppressFinalize(this);
 0321        }
 322
 323        /// <summary>
 324        /// Releases unmanaged and optionally managed resources.
 325        /// </summary>
 326        /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release
 327        protected virtual void Dispose(bool disposing)
 328        {
 0329            if (_disposed)
 330            {
 0331                return;
 332            }
 333
 0334            if (disposing)
 335            {
 0336                _logFileStream?.Dispose();
 0337                _process?.Dispose();
 338            }
 339
 0340            _logFileStream = null;
 0341            _process = null;
 342
 0343            _disposed = true;
 0344        }
 345    }
 346}