< Summary - Jellyfin

Information
Class: Emby.Server.Implementations.ScheduledTasks.Tasks.AudioNormalizationTask
Assembly: Emby.Server.Implementations
File(s): /srv/git/jellyfin/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs
Line coverage
10%
Covered lines: 14
Uncovered lines: 117
Coverable lines: 131
Total lines: 295
Line coverage: 10.6%
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 1/23/2026 - 12:11:06 AM Line coverage: 66.6% (8/12) Total lines: 2954/19/2026 - 12:14:27 AM Line coverage: 10.6% (14/131) Branch coverage: 0% (0/40) Total lines: 295 4/19/2026 - 12:14:27 AM Line coverage: 10.6% (14/131) Branch coverage: 0% (0/40) Total lines: 295

Coverage delta

Coverage delta 56 -56

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%210%
.ctor(...)100%11100%
get_Name()100%11100%
get_Description()100%210%
get_Category()100%210%
get_Key()100%210%
ExecuteAsync()0%930300%
GetDefaultTriggers()100%11100%
CalculateLUFSAsync()0%110100%

File(s)

/srv/git/jellyfin/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.Diagnostics;
 4using System.Globalization;
 5using System.IO;
 6using System.Linq;
 7using System.Text.RegularExpressions;
 8using System.Threading;
 9using System.Threading.Tasks;
 10using Jellyfin.Data.Enums;
 11using Jellyfin.Extensions;
 12using MediaBrowser.Common.Configuration;
 13using MediaBrowser.Controller.Entities;
 14using MediaBrowser.Controller.Entities.Audio;
 15using MediaBrowser.Controller.Library;
 16using MediaBrowser.Controller.MediaEncoding;
 17using MediaBrowser.Controller.Persistence;
 18using MediaBrowser.Model.Globalization;
 19using MediaBrowser.Model.Tasks;
 20using Microsoft.Extensions.Logging;
 21
 22namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
 23
 24/// <summary>
 25/// The audio normalization task.
 26/// </summary>
 27public partial class AudioNormalizationTask : IScheduledTask
 28{
 29    private readonly IItemPersistenceService _persistenceService;
 30    private readonly ILibraryManager _libraryManager;
 31    private readonly IMediaEncoder _mediaEncoder;
 32    private readonly IApplicationPaths _applicationPaths;
 33    private readonly ILocalizationManager _localization;
 34    private readonly ILogger<AudioNormalizationTask> _logger;
 35
 036    private static readonly TimeSpan _dbSaveInterval = TimeSpan.FromMinutes(5);
 37
 38    /// <summary>
 39    /// Initializes a new instance of the <see cref="AudioNormalizationTask"/> class.
 40    /// </summary>
 41    /// <param name="persistenceService">Instance of the <see cref="IItemPersistenceService"/> interface.</param>
 42    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
 43    /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
 44    /// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
 45    /// <param name="localizationManager">Instance of the <see cref="ILocalizationManager"/> interface.</param>
 46    /// <param name="logger">Instance of the <see cref="ILogger{AudioNormalizationTask}"/> interface.</param>
 47    public AudioNormalizationTask(
 48        IItemPersistenceService persistenceService,
 49        ILibraryManager libraryManager,
 50        IMediaEncoder mediaEncoder,
 51        IApplicationPaths applicationPaths,
 52        ILocalizationManager localizationManager,
 53        ILogger<AudioNormalizationTask> logger)
 54    {
 2155        _persistenceService = persistenceService;
 2156        _libraryManager = libraryManager;
 2157        _mediaEncoder = mediaEncoder;
 2158        _applicationPaths = applicationPaths;
 2159        _localization = localizationManager;
 2160        _logger = logger;
 2161    }
 62
 63    /// <inheritdoc />
 2164    public string Name => _localization.GetLocalizedString("TaskAudioNormalization");
 65
 66    /// <inheritdoc />
 067    public string Description => _localization.GetLocalizedString("TaskAudioNormalizationDescription");
 68
 69    /// <inheritdoc />
 070    public string Category => _localization.GetLocalizedString("TasksLibraryCategory");
 71
 72    /// <inheritdoc />
 073    public string Key => "AudioNormalization";
 74
 75    [GeneratedRegex(@"^\s+I:\s+(.*?)\s+LUFS")]
 76    private static partial Regex LUFSRegex();
 77
 78    /// <inheritdoc />
 79    public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
 80    {
 081        var numComplete = 0;
 82        var libraries = _libraryManager.RootFolder.Children.Where(library => _libraryManager.GetLibraryOptions(library).
 083        double percent = 0;
 84
 085        foreach (var library in libraries)
 86        {
 087            var startDbSaveInterval = Stopwatch.GetTimestamp();
 088            var albums = _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.MusicAlbu
 089            var toSaveDbItems = new List<BaseItem>();
 90
 091            double nextPercent = numComplete + 1;
 092            nextPercent /= libraries.Length;
 093            nextPercent -= percent;
 94            // Split the progress for this single library into two halves: album gain and track gain.
 95            // The first half will be for album gain, the second half for track gain.
 096            nextPercent /= 2;
 097            var albumComplete = 0;
 98
 099            foreach (var a in albums)
 100            {
 0101                if (!a.NormalizationGain.HasValue && !a.LUFS.HasValue)
 102                {
 103                    // Album gain
 0104                    var albumTracks = ((MusicAlbum)a).Tracks.Where(x => x.IsFileProtocol).ToList();
 105
 106                    // Skip albums that don't have multiple tracks, album gain is useless here
 0107                    if (albumTracks.Count > 1)
 108                    {
 0109                        _logger.LogInformation("Calculating LUFS for album: {Album} with id: {Id}", a.Name, a.Id);
 0110                        var tempDir = _applicationPaths.TempDirectory;
 0111                        Directory.CreateDirectory(tempDir);
 0112                        var tempFile = Path.Join(tempDir, a.Id + ".concat");
 0113                        var inputLines = albumTracks.Select(x => string.Format(CultureInfo.InvariantCulture, "file '{0}'
 0114                        await File.WriteAllLinesAsync(tempFile, inputLines, cancellationToken).ConfigureAwait(false);
 115                        try
 116                        {
 0117                            a.LUFS = await CalculateLUFSAsync(
 0118                                string.Format(CultureInfo.InvariantCulture, "-f concat -safe 0 -i \"{0}\"", tempFile),
 0119                                OperatingSystem.IsWindows(), // Wait for process to exit on Windows before we try deleti
 0120                                cancellationToken).ConfigureAwait(false);
 0121                            toSaveDbItems.Add(a);
 0122                        }
 123                        finally
 124                        {
 125                            try
 126                            {
 0127                                File.Delete(tempFile);
 0128                            }
 0129                            catch (Exception ex)
 130                            {
 0131                                _logger.LogError(ex, "Failed to delete concat file: {FileName}.", tempFile);
 0132                            }
 133                        }
 0134                    }
 135                }
 136
 0137                if (Stopwatch.GetElapsedTime(startDbSaveInterval) > _dbSaveInterval)
 138                {
 0139                    if (toSaveDbItems.Count > 1)
 140                    {
 0141                        _persistenceService.SaveItems(toSaveDbItems, cancellationToken);
 0142                        toSaveDbItems.Clear();
 143                    }
 144
 0145                    startDbSaveInterval = Stopwatch.GetTimestamp();
 146                }
 147
 148                // Update sub-progress for album gain
 0149                albumComplete++;
 0150                double albumPercent = albumComplete;
 0151                albumPercent /= albums.Count;
 152
 0153                progress.Report(100 * (percent + (albumPercent * nextPercent)));
 0154            }
 155
 156            // Update progress to start at the track gain percent calculation
 0157            percent += nextPercent;
 158
 0159            if (toSaveDbItems.Count > 1)
 160            {
 0161                _persistenceService.SaveItems(toSaveDbItems, cancellationToken);
 0162                toSaveDbItems.Clear();
 163            }
 164
 0165            startDbSaveInterval = Stopwatch.GetTimestamp();
 166
 167            // Track gain
 0168            var tracks = _libraryManager.GetItemList(new InternalItemsQuery { MediaTypes = [MediaType.Audio], IncludeIte
 169
 0170            var tracksComplete = 0;
 0171            foreach (var t in tracks)
 172            {
 0173                if (!t.NormalizationGain.HasValue && !t.LUFS.HasValue && t.IsFileProtocol)
 174                {
 0175                    t.LUFS = await CalculateLUFSAsync(
 0176                        string.Format(CultureInfo.InvariantCulture, "-i \"{0}\"", t.Path.Replace("\"", "\\\"", StringCom
 0177                        false,
 0178                        cancellationToken).ConfigureAwait(false);
 0179                    toSaveDbItems.Add(t);
 180                }
 181
 0182                if (Stopwatch.GetElapsedTime(startDbSaveInterval) > _dbSaveInterval)
 183                {
 0184                    if (toSaveDbItems.Count > 1)
 185                    {
 0186                        _persistenceService.SaveItems(toSaveDbItems, cancellationToken);
 0187                        toSaveDbItems.Clear();
 188                    }
 189
 0190                    startDbSaveInterval = Stopwatch.GetTimestamp();
 191                }
 192
 193                // Update sub-progress for track gain
 0194                tracksComplete++;
 0195                double trackPercent = tracksComplete;
 0196                trackPercent /= tracks.Count;
 197
 0198                progress.Report(100 * (percent + (trackPercent * nextPercent)));
 0199            }
 200
 0201            if (toSaveDbItems.Count > 1)
 202            {
 0203                _persistenceService.SaveItems(toSaveDbItems, cancellationToken);
 204            }
 205
 206            // Update progress
 0207            numComplete++;
 0208            percent = numComplete;
 0209            percent /= libraries.Length;
 210
 0211            progress.Report(100 * percent);
 0212        }
 213
 0214        progress.Report(100.0);
 0215    }
 216
 217    /// <inheritdoc />
 218    public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
 219    {
 21220        yield return new TaskTriggerInfo
 21221        {
 21222            Type = TaskTriggerInfoType.IntervalTrigger,
 21223            IntervalTicks = TimeSpan.FromHours(24).Ticks
 21224        };
 21225    }
 226
 227    private async Task<float?> CalculateLUFSAsync(string inputArgs, bool waitForExit, CancellationToken cancellationToke
 228    {
 0229        var args = $"-hide_banner {inputArgs} -af ebur128=framelog=verbose -f null -";
 230
 0231        using (var process = new Process()
 0232        {
 0233            StartInfo = new ProcessStartInfo
 0234            {
 0235                FileName = _mediaEncoder.EncoderPath,
 0236                Arguments = args,
 0237                RedirectStandardOutput = false,
 0238                RedirectStandardError = true
 0239            },
 0240        })
 241        {
 0242            _logger.LogDebug("Starting ffmpeg with arguments: {Arguments}", args);
 243            try
 244            {
 0245                process.Start();
 0246            }
 0247            catch (Exception ex)
 248            {
 0249                _logger.LogError(ex, "Error starting ffmpeg with arguments: {Arguments}", args);
 0250                return null;
 251            }
 252
 253            try
 254            {
 0255                process.PriorityClass = ProcessPriorityClass.BelowNormal;
 0256            }
 0257            catch (Exception ex)
 258            {
 0259                _logger.LogWarning(ex, "Error setting ffmpeg process priority");
 0260            }
 261
 0262            using var reader = process.StandardError;
 0263            float? lufs = null;
 0264            var foundLufs = false;
 0265            await foreach (var line in reader.ReadAllLinesAsync(cancellationToken).ConfigureAwait(false))
 266            {
 0267                if (foundLufs)
 268                {
 269                    continue;
 270                }
 271
 0272                Match match = LUFSRegex().Match(line);
 0273                if (!match.Success)
 274                {
 275                    continue;
 276                }
 277
 0278                lufs = float.Parse(match.Groups[1].ValueSpan, CultureInfo.InvariantCulture.NumberFormat);
 0279                foundLufs = true;
 280            }
 281
 0282            if (lufs is null)
 283            {
 0284                _logger.LogError("Failed to find LUFS value in output");
 285            }
 286
 0287            if (waitForExit)
 288            {
 0289                await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
 290            }
 291
 0292            return lufs;
 293        }
 0294    }
 295}