From 88a38a61b59c20b64b5d993364dea2e1d7160d9f Mon Sep 17 00:00:00 2001 From: Bond_009 Date: Sun, 28 Apr 2024 15:18:53 +0200 Subject: Improve audio normalization * Move calculation of LUFS to a scheduled task as it's pretty slow * Correctly calculate album LUFS * Don't try to convert replaygain tags to LUFS values --- .../Data/SqliteItemRepository.cs | 12 +- Emby.Server.Implementations/Dto/DtoService.cs | 9 +- .../Library/Resolvers/Audio/MusicAlbumResolver.cs | 1 - .../Localization/Core/en-US.json | 2 + .../Tasks/AudioNormalizationPostScanTask.cs | 196 +++++++++++++++++++++ .../ScheduledTasks/Tasks/ChapterImagesTask.cs | 1 - 6 files changed, 216 insertions(+), 5 deletions(-) create mode 100644 Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationPostScanTask.cs (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/Data/SqliteItemRepository.cs b/Emby.Server.Implementations/Data/SqliteItemRepository.cs index 59e4ff1a9..0a8a36ebc 100644 --- a/Emby.Server.Implementations/Data/SqliteItemRepository.cs +++ b/Emby.Server.Implementations/Data/SqliteItemRepository.cs @@ -49,8 +49,8 @@ namespace Emby.Server.Implementations.Data private const string SaveItemCommandText = @"replace into TypedBaseItems - (guid,type,data,Path,StartDate,EndDate,ChannelId,IsMovie,IsSeries,EpisodeTitle,IsRepeat,CommunityRating,CustomRating,IndexNumber,IsLocked,Name,OfficialRating,MediaType,Overview,ParentIndexNumber,PremiereDate,ProductionYear,ParentId,Genres,InheritedParentalRatingValue,SortName,ForcedSortName,RunTimeTicks,Size,DateCreated,DateModified,PreferredMetadataLanguage,PreferredMetadataCountryCode,Width,Height,DateLastRefreshed,DateLastSaved,IsInMixedFolder,LockedFields,Studios,Audio,ExternalServiceId,Tags,IsFolder,UnratedType,TopParentId,TrailerTypes,CriticRating,CleanName,PresentationUniqueKey,OriginalTitle,PrimaryVersionId,DateLastMediaAdded,Album,LUFS,IsVirtualItem,SeriesName,UserDataKey,SeasonName,SeasonId,SeriesId,ExternalSeriesId,Tagline,ProviderIds,Images,ProductionLocations,ExtraIds,TotalBitrate,ExtraType,Artists,AlbumArtists,ExternalId,SeriesPresentationUniqueKey,ShowId,OwnerId) - values (@guid,@type,@data,@Path,@StartDate,@EndDate,@ChannelId,@IsMovie,@IsSeries,@EpisodeTitle,@IsRepeat,@CommunityRating,@CustomRating,@IndexNumber,@IsLocked,@Name,@OfficialRating,@MediaType,@Overview,@ParentIndexNumber,@PremiereDate,@ProductionYear,@ParentId,@Genres,@InheritedParentalRatingValue,@SortName,@ForcedSortName,@RunTimeTicks,@Size,@DateCreated,@DateModified,@PreferredMetadataLanguage,@PreferredMetadataCountryCode,@Width,@Height,@DateLastRefreshed,@DateLastSaved,@IsInMixedFolder,@LockedFields,@Studios,@Audio,@ExternalServiceId,@Tags,@IsFolder,@UnratedType,@TopParentId,@TrailerTypes,@CriticRating,@CleanName,@PresentationUniqueKey,@OriginalTitle,@PrimaryVersionId,@DateLastMediaAdded,@Album,@LUFS,@IsVirtualItem,@SeriesName,@UserDataKey,@SeasonName,@SeasonId,@SeriesId,@ExternalSeriesId,@Tagline,@ProviderIds,@Images,@ProductionLocations,@ExtraIds,@TotalBitrate,@ExtraType,@Artists,@AlbumArtists,@ExternalId,@SeriesPresentationUniqueKey,@ShowId,@OwnerId)"; + (guid,type,data,Path,StartDate,EndDate,ChannelId,IsMovie,IsSeries,EpisodeTitle,IsRepeat,CommunityRating,CustomRating,IndexNumber,IsLocked,Name,OfficialRating,MediaType,Overview,ParentIndexNumber,PremiereDate,ProductionYear,ParentId,Genres,InheritedParentalRatingValue,SortName,ForcedSortName,RunTimeTicks,Size,DateCreated,DateModified,PreferredMetadataLanguage,PreferredMetadataCountryCode,Width,Height,DateLastRefreshed,DateLastSaved,IsInMixedFolder,LockedFields,Studios,Audio,ExternalServiceId,Tags,IsFolder,UnratedType,TopParentId,TrailerTypes,CriticRating,CleanName,PresentationUniqueKey,OriginalTitle,PrimaryVersionId,DateLastMediaAdded,Album,LUFS,NormalizationGain,IsVirtualItem,SeriesName,UserDataKey,SeasonName,SeasonId,SeriesId,ExternalSeriesId,Tagline,ProviderIds,Images,ProductionLocations,ExtraIds,TotalBitrate,ExtraType,Artists,AlbumArtists,ExternalId,SeriesPresentationUniqueKey,ShowId,OwnerId) + values (@guid,@type,@data,@Path,@StartDate,@EndDate,@ChannelId,@IsMovie,@IsSeries,@EpisodeTitle,@IsRepeat,@CommunityRating,@CustomRating,@IndexNumber,@IsLocked,@Name,@OfficialRating,@MediaType,@Overview,@ParentIndexNumber,@PremiereDate,@ProductionYear,@ParentId,@Genres,@InheritedParentalRatingValue,@SortName,@ForcedSortName,@RunTimeTicks,@Size,@DateCreated,@DateModified,@PreferredMetadataLanguage,@PreferredMetadataCountryCode,@Width,@Height,@DateLastRefreshed,@DateLastSaved,@IsInMixedFolder,@LockedFields,@Studios,@Audio,@ExternalServiceId,@Tags,@IsFolder,@UnratedType,@TopParentId,@TrailerTypes,@CriticRating,@CleanName,@PresentationUniqueKey,@OriginalTitle,@PrimaryVersionId,@DateLastMediaAdded,@Album,@LUFS,@NormalizationGain,@IsVirtualItem,@SeriesName,@UserDataKey,@SeasonName,@SeasonId,@SeriesId,@ExternalSeriesId,@Tagline,@ProviderIds,@Images,@ProductionLocations,@ExtraIds,@TotalBitrate,@ExtraType,@Artists,@AlbumArtists,@ExternalId,@SeriesPresentationUniqueKey,@ShowId,@OwnerId)"; private readonly IServerConfigurationManager _config; private readonly IServerApplicationHost _appHost; @@ -111,6 +111,7 @@ namespace Emby.Server.Implementations.Data "DateLastMediaAdded", "Album", "LUFS", + "NormalizationGain", "CriticRating", "IsVirtualItem", "SeriesName", @@ -478,6 +479,7 @@ namespace Emby.Server.Implementations.Data AddColumn(connection, "TypedBaseItems", "DateLastMediaAdded", "DATETIME", existingColumnNames); AddColumn(connection, "TypedBaseItems", "Album", "Text", existingColumnNames); AddColumn(connection, "TypedBaseItems", "LUFS", "Float", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "NormalizationGain", "Float", existingColumnNames); AddColumn(connection, "TypedBaseItems", "IsVirtualItem", "BIT", existingColumnNames); AddColumn(connection, "TypedBaseItems", "SeriesName", "Text", existingColumnNames); AddColumn(connection, "TypedBaseItems", "UserDataKey", "Text", existingColumnNames); @@ -886,6 +888,7 @@ namespace Emby.Server.Implementations.Data saveItemStatement.TryBind("@Album", item.Album); saveItemStatement.TryBind("@LUFS", item.LUFS); + saveItemStatement.TryBind("@NormalizationGain", item.NormalizationGain); saveItemStatement.TryBind("@IsVirtualItem", item.IsVirtualItem); if (item is IHasSeries hasSeriesName) @@ -1672,6 +1675,11 @@ namespace Emby.Server.Implementations.Data item.LUFS = lUFS; } + if (reader.TryGetSingle(index++, out var normalizationGain)) + { + item.NormalizationGain = normalizationGain; + } + if (reader.TryGetSingle(index++, out var criticRating)) { item.CriticRating = criticRating; diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index 98eacb52b..a82258bae 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -898,7 +898,14 @@ namespace Emby.Server.Implementations.Dto dto.IsPlaceHolder = supportsPlaceHolders.IsPlaceHolder; } - dto.LUFS = item.LUFS; + if (item.LUFS.HasValue) + { + dto.NormalizationGain = -18f - item.LUFS; + } + else if (item.NormalizationGain.HasValue) + { + dto.NormalizationGain = item.NormalizationGain; + } // Add audio info if (item is Audio audio) diff --git a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs index 0bfb7fbe6..9405f2102 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs @@ -13,7 +13,6 @@ using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Resolvers; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; diff --git a/Emby.Server.Implementations/Localization/Core/en-US.json b/Emby.Server.Implementations/Localization/Core/en-US.json index 4ba31bee0..c229f3538 100644 --- a/Emby.Server.Implementations/Localization/Core/en-US.json +++ b/Emby.Server.Implementations/Localization/Core/en-US.json @@ -106,6 +106,8 @@ "TaskCleanCacheDescription": "Deletes cache files no longer needed by the system.", "TaskRefreshChapterImages": "Extract Chapter Images", "TaskRefreshChapterImagesDescription": "Creates thumbnails for videos that have chapters.", + "TaskAudioNormalization": "Audio Normalization", + "TaskAudioNormalizationDescription": "Scans files for audio normalization data.", "TaskRefreshLibrary": "Scan Media Library", "TaskRefreshLibraryDescription": "Scans your media library for new files and refreshes metadata.", "TaskCleanLogs": "Clean Log Directory", diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationPostScanTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationPostScanTask.cs new file mode 100644 index 000000000..f6739de79 --- /dev/null +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationPostScanTask.cs @@ -0,0 +1,196 @@ +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 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 splashscreen post scan task. +/// +public partial class AudioNormalizationTask : IScheduledTask +{ + private readonly IItemRepository _itemRepository; + private readonly ILibraryManager _libraryManager; + private readonly IMediaEncoder _mediaEncoder; + private readonly IConfigurationManager _configurationManager; + 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, + IConfigurationManager configurationManager, + ILocalizationManager localizationManager, + ILogger logger) + { + _itemRepository = itemRepository; + _libraryManager = libraryManager; + _mediaEncoder = mediaEncoder; + _configurationManager = configurationManager; + _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(@"I:\s+(.*?)\s+LUFS")] + private static partial Regex LUFSRegex(); + + /// + public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) + { + foreach (var library in _libraryManager.RootFolder.Children) + { + var libraryOptions = _libraryManager.GetLibraryOptions(library); + if (!libraryOptions.EnableLUFSScan) + { + continue; + } + + // Album gain + var albums = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.MusicAlbum], + Parent = library, + Recursive = true + }); + + foreach (var a in albums) + { + if (a.NormalizationGain.HasValue || a.LUFS.HasValue) + { + continue; + } + + var albumTracks = ((MusicAlbum)a).Tracks.Where(x => x.IsFileProtocol).ToList(); + if (albumTracks.Count == 0) + { + continue; + } + + var tempFile = Path.Join(_configurationManager.GetTranscodePath(), Guid.NewGuid() + ".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); + a.LUFS = await CalculateLUFSAsync( + string.Format(CultureInfo.InvariantCulture, "-f concat -safe 0 -i \"{0}\"", tempFile), + cancellationToken).ConfigureAwait(false); + File.Delete(tempFile); + } + + _itemRepository.SaveItems(albums, cancellationToken); + + var tracks = _libraryManager.GetItemList(new InternalItemsQuery + { + MediaTypes = [MediaType.Audio], + IncludeItemTypes = [BaseItemKind.Audio], + Parent = library, + Recursive = true + }); + + foreach (var t in tracks) + { + if (t.NormalizationGain.HasValue || t.LUFS.HasValue || !t.IsFileProtocol) + { + continue; + } + + t.LUFS = await CalculateLUFSAsync(string.Format(CultureInfo.InvariantCulture, "-i \"{0}\"", t.Path.Replace("\"", "\\\"", StringComparison.Ordinal)), cancellationToken); + } + + _itemRepository.SaveItems(tracks, cancellationToken); + } + } + + /// + public IEnumerable GetDefaultTriggers() + { + return + [ + new TaskTriggerInfo + { + Type = TaskTriggerInfo.TriggerInterval, + IntervalTicks = TimeSpan.FromHours(24).Ticks + } + ]; + } + + private string EscapeFilename(string filename) + => filename; + + private async Task CalculateLUFSAsync(string inputArgs, 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; + var output = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + MatchCollection split = LUFSRegex().Matches(output); + + if (split.Count != 0) + { + return float.Parse(split[0].Groups[1].ValueSpan, CultureInfo.InvariantCulture.NumberFormat); + } + + _logger.LogError("Failed to find LUFS value in output:\n{Output}", output); + return null; + } + } +} diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs index d03d40863..36456504b 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs @@ -13,7 +13,6 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using MediaBrowser.Model.Tasks; -- cgit v1.2.3 From 2ad872001dc1276d71964584a11f4bedc742b3f7 Mon Sep 17 00:00:00 2001 From: Bond_009 Date: Sun, 28 Apr 2024 17:16:33 +0200 Subject: Address comments --- Emby.Server.Implementations/Dto/DtoService.cs | 1 + MediaBrowser.Controller/Entities/BaseItem.cs | 2 +- MediaBrowser.Model/Dto/BaseItemDto.cs | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index a82258bae..19902b26a 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -900,6 +900,7 @@ namespace Emby.Server.Implementations.Dto if (item.LUFS.HasValue) { + // -18 LUFS reference, same as ReplayGain 2.0, compatible with ReplayGain 1.0 dto.NormalizationGain = -18f - item.LUFS; } else if (item.NormalizationGain.HasValue) diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index 3dd4d1e39..adbbbaa03 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -140,7 +140,7 @@ namespace MediaBrowser.Controller.Entities /// /// Gets or sets the gain required for audio normalization. /// - /// The gain required for audio normalization.. + /// The gain required for audio normalization. [JsonIgnore] public float? NormalizationGain { get; set; } diff --git a/MediaBrowser.Model/Dto/BaseItemDto.cs b/MediaBrowser.Model/Dto/BaseItemDto.cs index 623e58807..7e8949e1f 100644 --- a/MediaBrowser.Model/Dto/BaseItemDto.cs +++ b/MediaBrowser.Model/Dto/BaseItemDto.cs @@ -784,7 +784,7 @@ namespace MediaBrowser.Model.Dto /// /// Gets or sets the gain required for audio normalization. /// - /// The gain required for audio normalization.. + /// The gain required for audio normalization. public float? NormalizationGain { get; set; } /// -- cgit v1.2.3 From 276ae3b8b7e9a7d489db63a5685684e45296eb91 Mon Sep 17 00:00:00 2001 From: Bond_009 Date: Mon, 29 Apr 2024 14:50:46 +0200 Subject: Skip albums that don't have multiple tracks --- .../ScheduledTasks/Tasks/AudioNormalizationPostScanTask.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationPostScanTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationPostScanTask.cs index f6739de79..8c107e4be 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationPostScanTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationPostScanTask.cs @@ -98,8 +98,9 @@ public partial class AudioNormalizationTask : IScheduledTask continue; } + // Skip albums that don't have multiple tracks, album gain is useless here var albumTracks = ((MusicAlbum)a).Tracks.Where(x => x.IsFileProtocol).ToList(); - if (albumTracks.Count == 0) + if (albumTracks.Count <= 1) { continue; } @@ -115,6 +116,7 @@ public partial class AudioNormalizationTask : IScheduledTask _itemRepository.SaveItems(albums, cancellationToken); + // Track gain var tracks = _libraryManager.GetItemList(new InternalItemsQuery { MediaTypes = [MediaType.Audio], -- cgit v1.2.3 From 8c9d0df7f27c61e4084e70f05ce5ed2254466b80 Mon Sep 17 00:00:00 2001 From: Bond_009 Date: Tue, 30 Apr 2024 16:14:01 +0200 Subject: Address comments --- .../ScheduledTasks/Tasks/AudioNormalizationPostScanTask.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationPostScanTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationPostScanTask.cs index 8c107e4be..04d6ed0f2 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationPostScanTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationPostScanTask.cs @@ -21,7 +21,7 @@ using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.ScheduledTasks.Tasks; /// -/// The splashscreen post scan task. +/// The audio normalization task. /// public partial class AudioNormalizationTask : IScheduledTask { @@ -152,9 +152,6 @@ public partial class AudioNormalizationTask : IScheduledTask ]; } - private string EscapeFilename(string filename) - => filename; - private async Task CalculateLUFSAsync(string inputArgs, CancellationToken cancellationToken) { var args = $"-hide_banner {inputArgs} -af ebur128=framelog=verbose -f null -"; -- cgit v1.2.3 From 9ffb07d67fd7d2c08cafff9081f38faac51b7e43 Mon Sep 17 00:00:00 2001 From: Bond_009 Date: Tue, 30 Apr 2024 16:16:42 +0200 Subject: Fix filename --- .../Tasks/AudioNormalizationPostScanTask.cs | 195 --------------------- .../ScheduledTasks/Tasks/AudioNormalizationTask.cs | 195 +++++++++++++++++++++ 2 files changed, 195 insertions(+), 195 deletions(-) delete mode 100644 Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationPostScanTask.cs create mode 100644 Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationPostScanTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationPostScanTask.cs deleted file mode 100644 index 04d6ed0f2..000000000 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationPostScanTask.cs +++ /dev/null @@ -1,195 +0,0 @@ -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 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 IConfigurationManager _configurationManager; - 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, - IConfigurationManager configurationManager, - ILocalizationManager localizationManager, - ILogger logger) - { - _itemRepository = itemRepository; - _libraryManager = libraryManager; - _mediaEncoder = mediaEncoder; - _configurationManager = configurationManager; - _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(@"I:\s+(.*?)\s+LUFS")] - private static partial Regex LUFSRegex(); - - /// - public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) - { - foreach (var library in _libraryManager.RootFolder.Children) - { - var libraryOptions = _libraryManager.GetLibraryOptions(library); - if (!libraryOptions.EnableLUFSScan) - { - continue; - } - - // Album gain - var albums = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = [BaseItemKind.MusicAlbum], - Parent = library, - Recursive = true - }); - - foreach (var a in albums) - { - if (a.NormalizationGain.HasValue || a.LUFS.HasValue) - { - continue; - } - - // Skip albums that don't have multiple tracks, album gain is useless here - var albumTracks = ((MusicAlbum)a).Tracks.Where(x => x.IsFileProtocol).ToList(); - if (albumTracks.Count <= 1) - { - continue; - } - - var tempFile = Path.Join(_configurationManager.GetTranscodePath(), Guid.NewGuid() + ".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); - a.LUFS = await CalculateLUFSAsync( - string.Format(CultureInfo.InvariantCulture, "-f concat -safe 0 -i \"{0}\"", tempFile), - cancellationToken).ConfigureAwait(false); - File.Delete(tempFile); - } - - _itemRepository.SaveItems(albums, cancellationToken); - - // Track gain - var tracks = _libraryManager.GetItemList(new InternalItemsQuery - { - MediaTypes = [MediaType.Audio], - IncludeItemTypes = [BaseItemKind.Audio], - Parent = library, - Recursive = true - }); - - foreach (var t in tracks) - { - if (t.NormalizationGain.HasValue || t.LUFS.HasValue || !t.IsFileProtocol) - { - continue; - } - - t.LUFS = await CalculateLUFSAsync(string.Format(CultureInfo.InvariantCulture, "-i \"{0}\"", t.Path.Replace("\"", "\\\"", StringComparison.Ordinal)), cancellationToken); - } - - _itemRepository.SaveItems(tracks, cancellationToken); - } - } - - /// - public IEnumerable GetDefaultTriggers() - { - return - [ - new TaskTriggerInfo - { - Type = TaskTriggerInfo.TriggerInterval, - IntervalTicks = TimeSpan.FromHours(24).Ticks - } - ]; - } - - private async Task CalculateLUFSAsync(string inputArgs, 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; - var output = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); - cancellationToken.ThrowIfCancellationRequested(); - MatchCollection split = LUFSRegex().Matches(output); - - if (split.Count != 0) - { - return float.Parse(split[0].Groups[1].ValueSpan, CultureInfo.InvariantCulture.NumberFormat); - } - - _logger.LogError("Failed to find LUFS value in output:\n{Output}", output); - return null; - } - } -} diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs new file mode 100644 index 000000000..04d6ed0f2 --- /dev/null +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs @@ -0,0 +1,195 @@ +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 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 IConfigurationManager _configurationManager; + 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, + IConfigurationManager configurationManager, + ILocalizationManager localizationManager, + ILogger logger) + { + _itemRepository = itemRepository; + _libraryManager = libraryManager; + _mediaEncoder = mediaEncoder; + _configurationManager = configurationManager; + _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(@"I:\s+(.*?)\s+LUFS")] + private static partial Regex LUFSRegex(); + + /// + public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) + { + foreach (var library in _libraryManager.RootFolder.Children) + { + var libraryOptions = _libraryManager.GetLibraryOptions(library); + if (!libraryOptions.EnableLUFSScan) + { + continue; + } + + // Album gain + var albums = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.MusicAlbum], + Parent = library, + Recursive = true + }); + + foreach (var a in albums) + { + if (a.NormalizationGain.HasValue || a.LUFS.HasValue) + { + continue; + } + + // Skip albums that don't have multiple tracks, album gain is useless here + var albumTracks = ((MusicAlbum)a).Tracks.Where(x => x.IsFileProtocol).ToList(); + if (albumTracks.Count <= 1) + { + continue; + } + + var tempFile = Path.Join(_configurationManager.GetTranscodePath(), Guid.NewGuid() + ".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); + a.LUFS = await CalculateLUFSAsync( + string.Format(CultureInfo.InvariantCulture, "-f concat -safe 0 -i \"{0}\"", tempFile), + cancellationToken).ConfigureAwait(false); + File.Delete(tempFile); + } + + _itemRepository.SaveItems(albums, cancellationToken); + + // Track gain + var tracks = _libraryManager.GetItemList(new InternalItemsQuery + { + MediaTypes = [MediaType.Audio], + IncludeItemTypes = [BaseItemKind.Audio], + Parent = library, + Recursive = true + }); + + foreach (var t in tracks) + { + if (t.NormalizationGain.HasValue || t.LUFS.HasValue || !t.IsFileProtocol) + { + continue; + } + + t.LUFS = await CalculateLUFSAsync(string.Format(CultureInfo.InvariantCulture, "-i \"{0}\"", t.Path.Replace("\"", "\\\"", StringComparison.Ordinal)), cancellationToken); + } + + _itemRepository.SaveItems(tracks, cancellationToken); + } + } + + /// + public IEnumerable GetDefaultTriggers() + { + return + [ + new TaskTriggerInfo + { + Type = TaskTriggerInfo.TriggerInterval, + IntervalTicks = TimeSpan.FromHours(24).Ticks + } + ]; + } + + private async Task CalculateLUFSAsync(string inputArgs, 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; + var output = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + MatchCollection split = LUFSRegex().Matches(output); + + if (split.Count != 0) + { + return float.Parse(split[0].Groups[1].ValueSpan, CultureInfo.InvariantCulture.NumberFormat); + } + + _logger.LogError("Failed to find LUFS value in output:\n{Output}", output); + return null; + } + } +} -- cgit v1.2.3