< 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: 296
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 5/1/2026 - 12:13:05 AM Line coverage: 10.6% (14/131) Branch coverage: 0% (0/40) Total lines: 2957/18/2026 - 12:15:19 AM Line coverage: 10.6% (14/131) Branch coverage: 0% (0/40) Total lines: 296 5/1/2026 - 12:13:05 AM Line coverage: 10.6% (14/131) Branch coverage: 0% (0/40) Total lines: 2957/18/2026 - 12:15:19 AM Line coverage: 10.6% (14/131) Branch coverage: 0% (0/40) Total lines: 296

Coverage delta

Coverage delta 1 -1

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;
 8using System.Text.RegularExpressions;
 9using System.Threading;
 10using System.Threading.Tasks;
 11using Jellyfin.Data.Enums;
 12using Jellyfin.Extensions;
 13using MediaBrowser.Common.Configuration;
 14using MediaBrowser.Controller.Entities;
 15using MediaBrowser.Controller.Entities.Audio;
 16using MediaBrowser.Controller.Library;
 17using MediaBrowser.Controller.MediaEncoding;
 18using MediaBrowser.Controller.Persistence;
 19using MediaBrowser.Model.Globalization;
 20using MediaBrowser.Model.Tasks;
 21using Microsoft.Extensions.Logging;
 22
 23namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
 24
 25/// <summary>
 26/// The audio normalization task.
 27/// </summary>
 28public partial class AudioNormalizationTask : IScheduledTask
 29{
 30    private readonly IItemPersistenceService _persistenceService;
 31    private readonly ILibraryManager _libraryManager;
 32    private readonly IMediaEncoder _mediaEncoder;
 33    private readonly IApplicationPaths _applicationPaths;
 34    private readonly ILocalizationManager _localization;
 35    private readonly ILogger<AudioNormalizationTask> _logger;
 36
 037    private static readonly TimeSpan _dbSaveInterval = TimeSpan.FromMinutes(5);
 38
 39    /// <summary>
 40    /// Initializes a new instance of the <see cref="AudioNormalizationTask"/> class.
 41    /// </summary>
 42    /// <param name="persistenceService">Instance of the <see cref="IItemPersistenceService"/> interface.</param>
 43    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
 44    /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
 45    /// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
 46    /// <param name="localizationManager">Instance of the <see cref="ILocalizationManager"/> interface.</param>
 47    /// <param name="logger">Instance of the <see cref="ILogger{AudioNormalizationTask}"/> interface.</param>
 48    public AudioNormalizationTask(
 49        IItemPersistenceService persistenceService,
 50        ILibraryManager libraryManager,
 51        IMediaEncoder mediaEncoder,
 52        IApplicationPaths applicationPaths,
 53        ILocalizationManager localizationManager,
 54        ILogger<AudioNormalizationTask> logger)
 55    {
 2256        _persistenceService = persistenceService;
 2257        _libraryManager = libraryManager;
 2258        _mediaEncoder = mediaEncoder;
 2259        _applicationPaths = applicationPaths;
 2260        _localization = localizationManager;
 2261        _logger = logger;
 2262    }
 63
 64    /// <inheritdoc />
 2265    public string Name => _localization.GetLocalizedString("TaskAudioNormalization");
 66
 67    /// <inheritdoc />
 068    public string Description => _localization.GetLocalizedString("TaskAudioNormalizationDescription");
 69
 70    /// <inheritdoc />
 071    public string Category => _localization.GetLocalizedString("TasksLibraryCategory");
 72
 73    /// <inheritdoc />
 074    public string Key => "AudioNormalization";
 75
 76    [GeneratedRegex(@"^\s+I:\s+(.*?)\s+LUFS")]
 77    private static partial Regex LUFSRegex();
 78
 79    /// <inheritdoc />
 80    public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
 81    {
 082        var numComplete = 0;
 83        var libraries = _libraryManager.RootFolder.Children.Where(library => _libraryManager.GetLibraryOptions(library).
 084        double percent = 0;
 85
 086        foreach (var library in libraries)
 87        {
 088            var startDbSaveInterval = Stopwatch.GetTimestamp();
 089            var albums = _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.MusicAlbu
 090            var toSaveDbItems = new List<BaseItem>();
 91
 092            double nextPercent = numComplete + 1;
 093            nextPercent /= libraries.Length;
 094            nextPercent -= percent;
 95            // Split the progress for this single library into two halves: album gain and track gain.
 96            // The first half will be for album gain, the second half for track gain.
 097            nextPercent /= 2;
 098            var albumComplete = 0;
 99
 0100            foreach (var a in albums)
 101            {
 0102                if (!a.NormalizationGain.HasValue && !a.LUFS.HasValue)
 103                {
 104                    // Album gain
 0105                    var albumTracks = ((MusicAlbum)a).Tracks.Where(x => x.IsFileProtocol).ToList();
 106
 107                    // Skip albums that don't have multiple tracks, album gain is useless here
 0108                    if (albumTracks.Count > 1)
 109                    {
 0110                        _logger.LogInformation("Calculating LUFS for album: {Album} with id: {Id}", a.Name, a.Id);
 0111                        var tempDir = _applicationPaths.TempDirectory;
 0112                        Directory.CreateDirectory(tempDir);
 0113                        var tempFile = Path.Join(tempDir, a.Id + ".concat");
 0114                        var inputLines = albumTracks.Select(x => string.Format(CultureInfo.InvariantCulture, "file '{0}'
 0115                        await File.WriteAllLinesAsync(tempFile, inputLines, cancellationToken).ConfigureAwait(false);
 116                        try
 117                        {
 0118                            a.LUFS = await CalculateLUFSAsync(
 0119                                string.Format(CultureInfo.InvariantCulture, "-f concat -safe 0 -i \"{0}\"", tempFile),
 0120                                OperatingSystem.IsWindows(), // Wait for process to exit on Windows before we try deleti
 0121                                cancellationToken).ConfigureAwait(false);
 0122                            toSaveDbItems.Add(a);
 0123                        }
 124                        finally
 125                        {
 126                            try
 127                            {
 0128                                File.Delete(tempFile);
 0129                            }
 0130                            catch (Exception ex)
 131                            {
 0132                                _logger.LogError(ex, "Failed to delete concat file: {FileName}.", tempFile);
 0133                            }
 134                        }
 0135                    }
 136                }
 137
 0138                if (Stopwatch.GetElapsedTime(startDbSaveInterval) > _dbSaveInterval)
 139                {
 0140                    if (toSaveDbItems.Count > 1)
 141                    {
 0142                        _persistenceService.SaveItems(toSaveDbItems, cancellationToken);
 0143                        toSaveDbItems.Clear();
 144                    }
 145
 0146                    startDbSaveInterval = Stopwatch.GetTimestamp();
 147                }
 148
 149                // Update sub-progress for album gain
 0150                albumComplete++;
 0151                double albumPercent = albumComplete;
 0152                albumPercent /= albums.Count;
 153
 0154                progress.Report(100 * (percent + (albumPercent * nextPercent)));
 0155            }
 156
 157            // Update progress to start at the track gain percent calculation
 0158            percent += nextPercent;
 159
 0160            if (toSaveDbItems.Count > 1)
 161            {
 0162                _persistenceService.SaveItems(toSaveDbItems, cancellationToken);
 0163                toSaveDbItems.Clear();
 164            }
 165
 0166            startDbSaveInterval = Stopwatch.GetTimestamp();
 167
 168            // Track gain
 0169            var tracks = _libraryManager.GetItemList(new InternalItemsQuery { MediaTypes = [MediaType.Audio], IncludeIte
 170
 0171            var tracksComplete = 0;
 0172            foreach (var t in tracks)
 173            {
 0174                if (!t.NormalizationGain.HasValue && !t.LUFS.HasValue && t.IsFileProtocol)
 175                {
 0176                    t.LUFS = await CalculateLUFSAsync(
 0177                        string.Format(CultureInfo.InvariantCulture, "-i \"{0}\"", t.Path.Replace("\"", "\\\"", StringCom
 0178                        false,
 0179                        cancellationToken).ConfigureAwait(false);
 0180                    toSaveDbItems.Add(t);
 181                }
 182
 0183                if (Stopwatch.GetElapsedTime(startDbSaveInterval) > _dbSaveInterval)
 184                {
 0185                    if (toSaveDbItems.Count > 1)
 186                    {
 0187                        _persistenceService.SaveItems(toSaveDbItems, cancellationToken);
 0188                        toSaveDbItems.Clear();
 189                    }
 190
 0191                    startDbSaveInterval = Stopwatch.GetTimestamp();
 192                }
 193
 194                // Update sub-progress for track gain
 0195                tracksComplete++;
 0196                double trackPercent = tracksComplete;
 0197                trackPercent /= tracks.Count;
 198
 0199                progress.Report(100 * (percent + (trackPercent * nextPercent)));
 0200            }
 201
 0202            if (toSaveDbItems.Count > 1)
 203            {
 0204                _persistenceService.SaveItems(toSaveDbItems, cancellationToken);
 205            }
 206
 207            // Update progress
 0208            numComplete++;
 0209            percent = numComplete;
 0210            percent /= libraries.Length;
 211
 0212            progress.Report(100 * percent);
 0213        }
 214
 0215        progress.Report(100.0);
 0216    }
 217
 218    /// <inheritdoc />
 219    public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
 220    {
 22221        yield return new TaskTriggerInfo
 22222        {
 22223            Type = TaskTriggerInfoType.IntervalTrigger,
 22224            IntervalTicks = TimeSpan.FromHours(24).Ticks
 22225        };
 22226    }
 227
 228    private async Task<float?> CalculateLUFSAsync(string inputArgs, bool waitForExit, CancellationToken cancellationToke
 229    {
 0230        var args = $"-hide_banner {inputArgs} -af ebur128=framelog=verbose -f null -";
 231
 0232        using (var process = new Process()
 0233        {
 0234            StartInfo = new ProcessStartInfo
 0235            {
 0236                FileName = _mediaEncoder.EncoderPath,
 0237                Arguments = args,
 0238                StandardErrorEncoding = Encoding.UTF8,
 0239                RedirectStandardError = true
 0240            },
 0241        })
 242        {
 0243            _logger.LogDebug("Starting ffmpeg with arguments: {Arguments}", args);
 244            try
 245            {
 0246                process.Start();
 0247            }
 0248            catch (Exception ex)
 249            {
 0250                _logger.LogError(ex, "Error starting ffmpeg with arguments: {Arguments}", args);
 0251                return null;
 252            }
 253
 254            try
 255            {
 0256                process.PriorityClass = ProcessPriorityClass.BelowNormal;
 0257            }
 0258            catch (Exception ex)
 259            {
 0260                _logger.LogWarning(ex, "Error setting ffmpeg process priority");
 0261            }
 262
 0263            using var reader = process.StandardError;
 0264            float? lufs = null;
 0265            var foundLufs = false;
 0266            await foreach (var line in reader.ReadAllLinesAsync(cancellationToken).ConfigureAwait(false))
 267            {
 0268                if (foundLufs)
 269                {
 270                    continue;
 271                }
 272
 0273                Match match = LUFSRegex().Match(line);
 0274                if (!match.Success)
 275                {
 276                    continue;
 277                }
 278
 0279                lufs = float.Parse(match.Groups[1].ValueSpan, CultureInfo.InvariantCulture.NumberFormat);
 0280                foundLufs = true;
 281            }
 282
 0283            if (lufs is null)
 284            {
 0285                _logger.LogError("Failed to find LUFS value in output");
 286            }
 287
 0288            if (waitForExit)
 289            {
 0290                await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
 291            }
 292
 0293            return lufs;
 294        }
 0295    }
 296}