using System; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Persistence; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.ScheduledTasks.Tasks; /// /// The audio normalization task. /// public partial class AudioNormalizationTask : IScheduledTask { private readonly IItemRepository _itemRepository; private readonly ILibraryManager _libraryManager; private readonly IMediaEncoder _mediaEncoder; private readonly IApplicationPaths _applicationPaths; private readonly ILocalizationManager _localization; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. public AudioNormalizationTask( IItemRepository itemRepository, ILibraryManager libraryManager, IMediaEncoder mediaEncoder, IApplicationPaths applicationPaths, ILocalizationManager localizationManager, ILogger logger) { _itemRepository = itemRepository; _libraryManager = libraryManager; _mediaEncoder = mediaEncoder; _applicationPaths = applicationPaths; _localization = localizationManager; _logger = logger; } /// public string Name => _localization.GetLocalizedString("TaskAudioNormalization"); /// public string Description => _localization.GetLocalizedString("TaskAudioNormalizationDescription"); /// public string Category => _localization.GetLocalizedString("TasksLibraryCategory"); /// public string Key => "AudioNormalization"; [GeneratedRegex(@"^\s+I:\s+(.*?)\s+LUFS")] private static partial Regex LUFSRegex(); /// public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) { var numComplete = 0; var libraries = _libraryManager.RootFolder.Children.Where(library => _libraryManager.GetLibraryOptions(library).EnableLUFSScan).ToArray(); double percent = 0; foreach (var library in libraries) { var albums = _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.MusicAlbum], Parent = library, Recursive = true }); double nextPercent = numComplete + 1; nextPercent /= libraries.Length; nextPercent -= percent; // Split the progress for this single library into two halves: album gain and track gain. // The first half will be for album gain, the second half for track gain. nextPercent /= 2; var albumComplete = 0; foreach (var a in albums) { if (!a.NormalizationGain.HasValue && !a.LUFS.HasValue) { // Album gain var albumTracks = ((MusicAlbum)a).Tracks.Where(x => x.IsFileProtocol).ToList(); // Skip albums that don't have multiple tracks, album gain is useless here if (albumTracks.Count > 1) { _logger.LogInformation("Calculating LUFS for album: {Album} with id: {Id}", a.Name, a.Id); var tempDir = _applicationPaths.TempDirectory; Directory.CreateDirectory(tempDir); var tempFile = Path.Join(tempDir, a.Id + ".concat"); var inputLines = albumTracks.Select(x => string.Format(CultureInfo.InvariantCulture, "file '{0}'", x.Path.Replace("'", @"'\''", StringComparison.Ordinal))); await File.WriteAllLinesAsync(tempFile, inputLines, cancellationToken).ConfigureAwait(false); try { a.LUFS = await CalculateLUFSAsync( string.Format(CultureInfo.InvariantCulture, "-f concat -safe 0 -i \"{0}\"", tempFile), OperatingSystem.IsWindows(), // Wait for process to exit on Windows before we try deleting the concat file cancellationToken).ConfigureAwait(false); } finally { File.Delete(tempFile); } } } // Update sub-progress for album gain albumComplete++; double albumPercent = albumComplete; albumPercent /= albums.Count; progress.Report(100 * (percent + (albumPercent * nextPercent))); } // Update progress to start at the track gain percent calculation percent += nextPercent; _itemRepository.SaveItems(albums, cancellationToken); // Track gain var tracks = _libraryManager.GetItemList(new InternalItemsQuery { MediaTypes = [MediaType.Audio], IncludeItemTypes = [BaseItemKind.Audio], Parent = library, Recursive = true }); var tracksComplete = 0; foreach (var t in tracks) { if (!t.NormalizationGain.HasValue && !t.LUFS.HasValue && t.IsFileProtocol) { t.LUFS = await CalculateLUFSAsync( string.Format(CultureInfo.InvariantCulture, "-i \"{0}\"", t.Path.Replace("\"", "\\\"", StringComparison.Ordinal)), false, cancellationToken).ConfigureAwait(false); } // Update sub-progress for track gain tracksComplete++; double trackPercent = tracksComplete; trackPercent /= tracks.Count; progress.Report(100 * (percent + (trackPercent * nextPercent))); } _itemRepository.SaveItems(tracks, cancellationToken); // Update progress numComplete++; percent = numComplete; percent /= libraries.Length; progress.Report(100 * percent); } progress.Report(100.0); } /// public IEnumerable GetDefaultTriggers() { yield return new TaskTriggerInfo { Type = TaskTriggerInfoType.IntervalTrigger, IntervalTicks = TimeSpan.FromHours(24).Ticks }; } private async Task CalculateLUFSAsync(string inputArgs, bool waitForExit, CancellationToken cancellationToken) { var args = $"-hide_banner {inputArgs} -af ebur128=framelog=verbose -f null -"; using (var process = new Process() { StartInfo = new ProcessStartInfo { FileName = _mediaEncoder.EncoderPath, Arguments = args, RedirectStandardOutput = false, RedirectStandardError = true }, }) { try { _logger.LogDebug("Starting ffmpeg with arguments: {Arguments}", args); process.Start(); } catch (Exception ex) { _logger.LogError(ex, "Error starting ffmpeg with arguments: {Arguments}", args); return null; } using var reader = process.StandardError; float? lufs = null; await foreach (var line in reader.ReadAllLinesAsync(cancellationToken).ConfigureAwait(false)) { Match match = LUFSRegex().Match(line); if (match.Success) { lufs = float.Parse(match.Groups[1].ValueSpan, CultureInfo.InvariantCulture.NumberFormat); break; } } if (lufs is null) { _logger.LogError("Failed to find LUFS value in output"); } if (waitForExit) { await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); } return lufs; } } }