diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml index 55fa65ee27..96c06019b7 100644 --- a/.github/workflows/ci-codeql-analysis.yml +++ b/.github/workflows/ci-codeql-analysis.yml @@ -27,11 +27,11 @@ jobs: dotnet-version: '8.0.x' - name: Initialize CodeQL - uses: github/codeql-action/init@8f596b4ae3cb3c588a5c46780b86dd53fef16c52 # v3.25.2 + uses: github/codeql-action/init@d39d31e687223d841ef683f52467bd88e9b21c14 # v3.25.3 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@8f596b4ae3cb3c588a5c46780b86dd53fef16c52 # v3.25.2 + uses: github/codeql-action/autobuild@d39d31e687223d841ef683f52467bd88e9b21c14 # v3.25.3 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@8f596b4ae3cb3c588a5c46780b86dd53fef16c52 # v3.25.2 + uses: github/codeql-action/analyze@d39d31e687223d841ef683f52467bd88e9b21c14 # v3.25.3 diff --git a/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs b/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs index a2f38c8c2d..9e98d5ce09 100644 --- a/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs +++ b/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs @@ -127,15 +127,11 @@ namespace Emby.Server.Implementations.AppBase if (_configurationFactories is null) { - _configurationFactories = new[] { factory }; + _configurationFactories = [factory]; } else { - var oldLen = _configurationFactories.Length; - var arr = new IConfigurationFactory[oldLen + 1]; - _configurationFactories.CopyTo(arr, 0); - arr[oldLen] = factory; - _configurationFactories = arr; + _configurationFactories = [.._configurationFactories, factory]; } _configurationStores = _configurationFactories diff --git a/Emby.Server.Implementations/Data/SqliteItemRepository.cs b/Emby.Server.Implementations/Data/SqliteItemRepository.cs index 59e4ff1a96..9ef1bd66d5 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; @@ -2315,14 +2323,7 @@ namespace Emby.Server.Implementations.Data columns.Add(builder.ToString()); - var oldLen = query.ExcludeItemIds.Length; - var newLen = oldLen + item.ExtraIds.Length + 1; - var excludeIds = new Guid[newLen]; - query.ExcludeItemIds.CopyTo(excludeIds, 0); - excludeIds[oldLen] = item.Id; - item.ExtraIds.CopyTo(excludeIds, oldLen + 1); - - query.ExcludeItemIds = excludeIds; + query.ExcludeItemIds = [..query.ExcludeItemIds, item.Id, ..item.ExtraIds]; query.ExcludeProviderIds = item.ProviderIds; } @@ -2830,10 +2831,7 @@ namespace Emby.Server.Implementations.Data prepend.Add((ItemSortBy.Random, SortOrder.Ascending)); } - var arr = new (ItemSortBy, SortOrder)[prepend.Count + orderBy.Count]; - prepend.CopyTo(arr, 0); - orderBy.CopyTo(arr, prepend.Count); - orderBy = query.OrderBy = arr; + orderBy = query.OrderBy = [..prepend, ..orderBy]; } else if (orderBy.Count == 0) { @@ -4194,7 +4192,19 @@ namespace Emby.Server.Implementations.Data { int index = 0; string includedTags = string.Join(',', query.IncludeInheritedTags.Select(_ => paramName + index++)); - whereClauses.Add("((select CleanValue from ItemValues where ItemId=Guid and Type=6 and cleanvalue in (" + includedTags + ")) is not null)"); + // Episodes do not store inherit tags from their parents in the database, and the tag may be still required by the client. + // In addtion to the tags for the episodes themselves, we need to manually query its parent (the season)'s tags as well. + if (includeTypes.Length == 1 && includeTypes.FirstOrDefault() is BaseItemKind.Episode) + { + whereClauses.Add($""" + ((select CleanValue from ItemValues where ItemId=Guid and Type=6 and CleanValue in ({includedTags})) is not null + OR (select CleanValue from ItemValues where ItemId=ParentId and Type=6 and CleanValue in ({includedTags})) is not null) + """); + } + else + { + whereClauses.Add("((select CleanValue from ItemValues where ItemId=Guid and Type=6 and cleanvalue in (" + includedTags + ")) is not null)"); + } } else { diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index 98eacb52b2..19902b26a0 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -898,7 +898,15 @@ namespace Emby.Server.Implementations.Dto dto.IsPlaceHolder = supportsPlaceHolders.IsPlaceHolder; } - dto.LUFS = item.LUFS; + 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) + { + dto.NormalizationGain = item.NormalizationGain; + } // Add audio info if (item is Audio audio) diff --git a/Emby.Server.Implementations/Library/IgnorePatterns.cs b/Emby.Server.Implementations/Library/IgnorePatterns.cs index cf6fc18456..a2301c8aed 100644 --- a/Emby.Server.Implementations/Library/IgnorePatterns.cs +++ b/Emby.Server.Implementations/Library/IgnorePatterns.cs @@ -103,7 +103,7 @@ namespace Emby.Server.Implementations.Library } }; - private static readonly Glob[] _globs = _patterns.Select(p => Glob.Parse(p, _globOptions)).ToArray(); + private static readonly Glob[] _globs = Array.ConvertAll(_patterns, p => Glob.Parse(p, _globOptions)); /// /// Returns true if the supplied path should be ignored. diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index fa52bbae49..96b3d1957f 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -3059,8 +3059,7 @@ namespace Emby.Server.Implementations.Library SyncLibraryOptionsToLocations(virtualFolderPath, libraryOptions); - var list = libraryOptions.PathInfos.ToList(); - foreach (var originalPathInfo in list) + foreach (var originalPathInfo in libraryOptions.PathInfos) { if (string.Equals(mediaPath.Path, originalPathInfo.Path, StringComparison.Ordinal)) { diff --git a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs index 0bfb7fbe6a..9405f21027 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/Library/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs index 83a66c8e47..d9a5590140 100644 --- a/Emby.Server.Implementations/Library/UserViewManager.cs +++ b/Emby.Server.Implementations/Library/UserViewManager.cs @@ -303,8 +303,8 @@ namespace Emby.Server.Implementations.Library { // Handle situations with the grouping setting, e.g. movies showing up in tv, etc. // Thanks to mixed content libraries included in the UserView - var hasCollectionType = parents.OfType().ToArray(); - if (hasCollectionType.Length > 0) + var hasCollectionType = parents.OfType().ToList(); + if (hasCollectionType.Count > 0) { if (hasCollectionType.All(i => i.CollectionType == CollectionType.movies)) { diff --git a/Emby.Server.Implementations/Localization/Core/ar.json b/Emby.Server.Implementations/Localization/Core/ar.json index 35387d0325..706df8f0a6 100644 --- a/Emby.Server.Implementations/Localization/Core/ar.json +++ b/Emby.Server.Implementations/Localization/Core/ar.json @@ -126,5 +126,7 @@ "External": "خارجي", "HearingImpaired": "ضعاف السمع", "TaskRefreshTrickplayImages": "توليد صور Trickplay", - "TaskRefreshTrickplayImagesDescription": "يُنشئ معاينات Trickplay لمقاطع الفيديو في المكتبات المُمكّنة." + "TaskRefreshTrickplayImagesDescription": "يُنشئ معاينات Trickplay لمقاطع الفيديو في المكتبات المُمكّنة.", + "TaskCleanCollectionsAndPlaylists": "حذف المجموعات وقوائم التشغيل", + "TaskCleanCollectionsAndPlaylistsDescription": "حذف عناصر من المجموعات وقوائم التشغيل التي لم تعد موجودة." } diff --git a/Emby.Server.Implementations/Localization/Core/en-US.json b/Emby.Server.Implementations/Localization/Core/en-US.json index 4ba31bee03..c229f35381 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/Localization/Core/fr-CA.json b/Emby.Server.Implementations/Localization/Core/fr-CA.json index b816738c2c..73bef3af74 100644 --- a/Emby.Server.Implementations/Localization/Core/fr-CA.json +++ b/Emby.Server.Implementations/Localization/Core/fr-CA.json @@ -126,5 +126,7 @@ "External": "Externe", "HearingImpaired": "Malentendants", "TaskRefreshTrickplayImages": "Générer des images Trickplay", - "TaskRefreshTrickplayImagesDescription": "Crée des aperçus Trickplay pour les vidéos dans les médiathèques activées." + "TaskRefreshTrickplayImagesDescription": "Crée des aperçus Trickplay pour les vidéos dans les médiathèques activées.", + "TaskCleanCollectionsAndPlaylists": "Nettoyer les collections et les listes de lecture", + "TaskCleanCollectionsAndPlaylistsDescription": "Supprimer les liens inexistants des collections et des listes de lecture" } diff --git a/Emby.Server.Implementations/Localization/Core/fr.json b/Emby.Server.Implementations/Localization/Core/fr.json index db83d4b47d..a13ee48d5b 100644 --- a/Emby.Server.Implementations/Localization/Core/fr.json +++ b/Emby.Server.Implementations/Localization/Core/fr.json @@ -128,5 +128,7 @@ "TaskRefreshTrickplayImages": "Générer des images Trickplay", "TaskRefreshTrickplayImagesDescription": "Crée des aperçus Trickplay pour les vidéos dans les médiathèques activées.", "TaskCleanCollectionsAndPlaylists": "Nettoyer les collections et les listes de lecture", - "TaskCleanCollectionsAndPlaylistsDescription": "Supprime les éléments des collections et des listes de lecture qui n'existent plus." + "TaskCleanCollectionsAndPlaylistsDescription": "Supprime les éléments des collections et des listes de lecture qui n'existent plus.", + "TaskAudioNormalization": "Normalisation audio", + "TaskAudioNormalizationDescription": "Analyse les fichiers à la recherche de données de normalisation audio." } diff --git a/Emby.Server.Implementations/Playlists/PlaylistManager.cs b/Emby.Server.Implementations/Playlists/PlaylistManager.cs index 7a6cf9efff..6007591b2d 100644 --- a/Emby.Server.Implementations/Playlists/PlaylistManager.cs +++ b/Emby.Server.Implementations/Playlists/PlaylistManager.cs @@ -226,13 +226,8 @@ namespace Emby.Server.Implementations.Playlists return; } - // Create a new array with the updated playlist items - var newLinkedChildren = new LinkedChild[playlist.LinkedChildren.Length + childrenToAdd.Count]; - playlist.LinkedChildren.CopyTo(newLinkedChildren, 0); - childrenToAdd.CopyTo(newLinkedChildren, playlist.LinkedChildren.Length); - // Update the playlist in the repository - playlist.LinkedChildren = newLinkedChildren; + playlist.LinkedChildren = [..playlist.LinkedChildren, ..childrenToAdd]; await UpdatePlaylistInternal(playlist).ConfigureAwait(false); @@ -526,8 +521,8 @@ namespace Emby.Server.Implementations.Playlists foreach (var playlist in playlists) { // Update owner if shared - var rankedShares = playlist.Shares.OrderByDescending(x => x.CanEdit).ToArray(); - if (rankedShares.Length > 0) + var rankedShares = playlist.Shares.OrderByDescending(x => x.CanEdit).ToList(); + if (rankedShares.Count > 0) { playlist.OwnerUserId = rankedShares[0].UserId; playlist.Shares = rankedShares.Skip(1).ToArray(); diff --git a/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs b/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs index efb6436ae9..40e1bbf159 100644 --- a/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs +++ b/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs @@ -256,8 +256,7 @@ namespace Emby.Server.Implementations.ScheduledTasks { get { - var triggers = InternalTriggers; - return triggers.Select(i => i.Item1).ToArray(); + return Array.ConvertAll(InternalTriggers, i => i.Item1); } set @@ -269,7 +268,7 @@ namespace Emby.Server.Implementations.ScheduledTasks SaveTriggers(triggerList); - InternalTriggers = triggerList.Select(i => new Tuple(i, GetTrigger(i))).ToArray(); + InternalTriggers = Array.ConvertAll(triggerList, i => new Tuple(i, GetTrigger(i))); } } @@ -503,7 +502,7 @@ namespace Emby.Server.Implementations.ScheduledTasks private Tuple[] LoadTriggers() { // This null check is not great, but is needed to handle bad user input, or user mucking with the config file incorrectly - var settings = LoadTriggerSettings().Where(i => i is not null).ToArray(); + var settings = LoadTriggerSettings().Where(i => i is not null); return settings.Select(i => new Tuple(i, GetTrigger(i))).ToArray(); } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs new file mode 100644 index 0000000000..04d6ed0f29 --- /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; + } + } +} diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs index d03d408633..36456504be 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; diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index 06798628f3..10d5b4f97f 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -400,7 +400,7 @@ namespace Emby.Server.Implementations.Session { session.NowPlayingQueue = nowPlayingQueue; - var itemIds = nowPlayingQueue.Select(queue => queue.Id).ToArray(); + var itemIds = Array.ConvertAll(nowPlayingQueue, queue => queue.Id); session.NowPlayingQueueFullItems = _dtoService.GetBaseItemDtos( _libraryManager.GetItemList(new InternalItemsQuery { ItemIds = itemIds }), new DtoOptions(true)); @@ -1386,16 +1386,13 @@ namespace Emby.Server.Implementations.Session if (session.AdditionalUsers.All(i => !i.UserId.Equals(userId))) { var user = _userManager.GetUserById(userId); - - var list = session.AdditionalUsers.ToList(); - - list.Add(new SessionUserInfo + var newUser = new SessionUserInfo { UserId = userId, UserName = user.Username - }); + }; - session.AdditionalUsers = list.ToArray(); + session.AdditionalUsers = [..session.AdditionalUsers, newUser]; } } diff --git a/Jellyfin.Api/Controllers/DashboardController.cs b/Jellyfin.Api/Controllers/DashboardController.cs index 076084c7a3..ee912a9be8 100644 --- a/Jellyfin.Api/Controllers/DashboardController.cs +++ b/Jellyfin.Api/Controllers/DashboardController.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Net.Mime; using Jellyfin.Api.Attributes; using Jellyfin.Api.Models; +using MediaBrowser.Common.Api; using MediaBrowser.Common.Plugins; using MediaBrowser.Model.Net; using MediaBrowser.Model.Plugins; @@ -45,9 +46,9 @@ public class DashboardController : BaseJellyfinApiController /// Server still loading. /// An with infos about the plugins. [HttpGet("web/ConfigurationPages")] + [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - [Authorize] public ActionResult> GetConfigurationPages( [FromQuery] bool? enableInMainMenu) { diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index 24edd79580..68602c80d5 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -1481,7 +1481,7 @@ public class DynamicHlsController : BaseJellyfinApiController if (currentTranscodingIndex.HasValue) { - DeleteLastFile(playlistPath, segmentExtension, 0); + await DeleteLastFile(playlistPath, segmentExtension, 0).ConfigureAwait(false); } streamingRequest.StartTimeTicks = streamingRequest.CurrentRuntimeTicks; @@ -2009,17 +2009,19 @@ public class DynamicHlsController : BaseJellyfinApiController } } - private void DeleteLastFile(string playlistPath, string segmentExtension, int retryCount) + private Task DeleteLastFile(string playlistPath, string segmentExtension, int retryCount) { var file = GetLastTranscodingFile(playlistPath, segmentExtension, _fileSystem); - if (file is not null) + if (file is null) { - DeleteFile(file.FullName, retryCount); + return Task.CompletedTask; } + + return DeleteFile(file.FullName, retryCount); } - private void DeleteFile(string path, int retryCount) + private async Task DeleteFile(string path, int retryCount) { if (retryCount >= 5) { @@ -2036,9 +2038,8 @@ public class DynamicHlsController : BaseJellyfinApiController { _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path); - var task = Task.Delay(100); - task.Wait(); - DeleteFile(path, retryCount + 1); + await Task.Delay(100).ConfigureAwait(false); + await DeleteFile(path, retryCount + 1).ConfigureAwait(false); } catch (Exception ex) { diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs index 56ef5f8f14..b4ce343bef 100644 --- a/Jellyfin.Api/Controllers/ItemUpdateController.cs +++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs @@ -264,7 +264,7 @@ public class ItemUpdateController : BaseJellyfinApiController if (request.Studios is not null) { - item.Studios = request.Studios.Select(x => x.Name).ToArray(); + item.Studios = Array.ConvertAll(request.Studios, x => x.Name); } if (request.DateCreated.HasValue) @@ -379,10 +379,7 @@ public class ItemUpdateController : BaseJellyfinApiController { if (item is IHasAlbumArtist hasAlbumArtists) { - hasAlbumArtists.AlbumArtists = request - .AlbumArtists - .Select(i => i.Name) - .ToArray(); + hasAlbumArtists.AlbumArtists = Array.ConvertAll(request.AlbumArtists, i => i.Name); } } @@ -390,10 +387,7 @@ public class ItemUpdateController : BaseJellyfinApiController { if (item is IHasArtist hasArtists) { - hasArtists.Artists = request - .ArtistItems - .Select(i => i.Name) - .ToArray(); + hasArtists.Artists = Array.ConvertAll(request.ArtistItems, i => i.Name); } } diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs index 6ffe6e7da1..70f805336b 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -256,6 +256,13 @@ public class ItemsController : BaseJellyfinApiController return BadRequest("userId is required"); } + if (user is not null + && user.GetPreference(PreferenceKind.AllowedTags).Length != 0 + && !fields.Contains(ItemFields.Tags)) + { + fields = [..fields, ItemFields.Tags]; + } + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index 3b4e80ff3c..64df4c4f04 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -158,13 +158,13 @@ public class LibraryController : BaseJellyfinApiController return NotFound(); } - IEnumerable themeItems; + IReadOnlyList themeItems; while (true) { themeItems = item.GetThemeSongs(); - if (themeItems.Any() || !inheritFromParent) + if (themeItems.Count > 0 || !inheritFromParent) { break; } diff --git a/Jellyfin.Api/Controllers/PackageController.cs b/Jellyfin.Api/Controllers/PackageController.cs index c5e940108c..274e94ee6d 100644 --- a/Jellyfin.Api/Controllers/PackageController.cs +++ b/Jellyfin.Api/Controllers/PackageController.cs @@ -18,7 +18,7 @@ namespace Jellyfin.Api.Controllers; /// Package Controller. /// [Route("")] -[Authorize] +[Authorize(Policy = Policies.RequiresElevation)] public class PackageController : BaseJellyfinApiController { private readonly IInstallationManager _installationManager; @@ -90,7 +90,6 @@ public class PackageController : BaseJellyfinApiController [HttpPost("Packages/Installed/{name}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] - [Authorize(Policy = Policies.RequiresElevation)] public async Task InstallPackage( [FromRoute, Required] string name, [FromQuery] Guid? assemblyGuid, @@ -128,7 +127,6 @@ public class PackageController : BaseJellyfinApiController /// Installation cancelled. /// A on successfully cancelling a package installation. [HttpDelete("Packages/Installing/{packageId}")] - [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult CancelPackageInstallation( [FromRoute, Required] Guid packageId) @@ -156,7 +154,6 @@ public class PackageController : BaseJellyfinApiController /// Package repositories saved. /// A . [HttpPost("Repositories")] - [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult SetRepositories([FromBody, Required] RepositoryInfo[] repositoryInfos) { diff --git a/Jellyfin.Api/Controllers/PluginsController.cs b/Jellyfin.Api/Controllers/PluginsController.cs index f63e639276..6abd7a23ee 100644 --- a/Jellyfin.Api/Controllers/PluginsController.cs +++ b/Jellyfin.Api/Controllers/PluginsController.cs @@ -22,7 +22,7 @@ namespace Jellyfin.Api.Controllers; /// /// Plugins controller. /// -[Authorize] +[Authorize(Policy = Policies.RequiresElevation)] public class PluginsController : BaseJellyfinApiController { private readonly IInstallationManager _installationManager; @@ -66,7 +66,6 @@ public class PluginsController : BaseJellyfinApiController /// Plugin not found. /// An on success, or a if the plugin could not be found. [HttpPost("{pluginId}/{version}/Enable")] - [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult EnablePlugin([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version) @@ -90,7 +89,6 @@ public class PluginsController : BaseJellyfinApiController /// Plugin not found. /// An on success, or a if the plugin could not be found. [HttpPost("{pluginId}/{version}/Disable")] - [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult DisablePlugin([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version) @@ -114,7 +112,6 @@ public class PluginsController : BaseJellyfinApiController /// Plugin not found. /// An on success, or a if the plugin could not be found. [HttpDelete("{pluginId}/{version}")] - [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult UninstallPluginByVersion([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version) @@ -137,7 +134,6 @@ public class PluginsController : BaseJellyfinApiController /// Plugin not found. /// An on success, or a if the plugin could not be found. [HttpDelete("{pluginId}")] - [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] [Obsolete("Please use the UninstallPluginByVersion API.")] diff --git a/Jellyfin.Api/Controllers/UserViewsController.cs b/Jellyfin.Api/Controllers/UserViewsController.cs index bf3ce1d396..01da50d02a 100644 --- a/Jellyfin.Api/Controllers/UserViewsController.cs +++ b/Jellyfin.Api/Controllers/UserViewsController.cs @@ -85,16 +85,11 @@ public class UserViewsController : BaseJellyfinApiController var folders = _userViewManager.GetUserViews(query); var dtoOptions = new DtoOptions().AddClientFields(User); - var fields = dtoOptions.Fields.ToList(); - - fields.Add(ItemFields.PrimaryImageAspectRatio); - fields.Add(ItemFields.DisplayPreferencesId); - dtoOptions.Fields = fields.ToArray(); + dtoOptions.Fields = [..dtoOptions.Fields, ItemFields.PrimaryImageAspectRatio, ItemFields.DisplayPreferencesId]; var user = _userManager.GetUserById(userId.Value); - var dtos = folders.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user)) - .ToArray(); + var dtos = Array.ConvertAll(folders, i => _dtoService.GetBaseItemDto(i, dtoOptions, user)); return new QueryResult(dtos); } diff --git a/Jellyfin.Api/Extensions/DtoExtensions.cs b/Jellyfin.Api/Extensions/DtoExtensions.cs index 7d9823c25c..3d17dbda18 100644 --- a/Jellyfin.Api/Extensions/DtoExtensions.cs +++ b/Jellyfin.Api/Extensions/DtoExtensions.cs @@ -43,11 +43,7 @@ public static class DtoExtensions client.Contains("media center", StringComparison.OrdinalIgnoreCase) || client.Contains("classic", StringComparison.OrdinalIgnoreCase)) { - int oldLen = dtoOptions.Fields.Count; - var arr = new ItemFields[oldLen + 1]; - dtoOptions.Fields.CopyTo(arr, 0); - arr[oldLen] = ItemFields.RecursiveItemCount; - dtoOptions.Fields = arr; + dtoOptions.Fields = [..dtoOptions.Fields, ItemFields.RecursiveItemCount]; } } @@ -61,11 +57,7 @@ public static class DtoExtensions client.Contains("samsung", StringComparison.OrdinalIgnoreCase) || client.Contains("androidtv", StringComparison.OrdinalIgnoreCase)) { - int oldLen = dtoOptions.Fields.Count; - var arr = new ItemFields[oldLen + 1]; - dtoOptions.Fields.CopyTo(arr, 0); - arr[oldLen] = ItemFields.ChildCount; - dtoOptions.Fields = arr; + dtoOptions.Fields = [..dtoOptions.Fields, ItemFields.ChildCount]; } } diff --git a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs index fed5dab696..31c0be52f9 100644 --- a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs +++ b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs @@ -106,18 +106,11 @@ public class TrickplayManager : ITrickplayManager } var imgTempDir = string.Empty; - var outputDir = GetTrickplayDirectory(video, width); using (await _resourcePool.LockAsync(cancellationToken).ConfigureAwait(false)) { try { - if (!replace && Directory.Exists(outputDir) && (await GetTrickplayResolutions(video.Id).ConfigureAwait(false)).ContainsKey(width)) - { - _logger.LogDebug("Found existing trickplay files for {ItemId}. Exiting.", video.Id); - return; - } - // Extract images // Note: Media sources under parent items exist as their own video/item as well. Only use this video stream for trickplay. var mediaSource = video.GetMediaSources(false).Find(source => Guid.Parse(source.Id).Equals(video.Id)); @@ -128,17 +121,35 @@ public class TrickplayManager : ITrickplayManager return; } + // The width has to be even, otherwise a lot of filters will not be able to sample it + var actualWidth = 2 * (width / 2); + + // Force using the video width when the trickplay setting has a too large width + if (mediaSource.VideoStream.Width is not null && mediaSource.VideoStream.Width < width) + { + _logger.LogWarning("Video width {VideoWidth} is smaller than trickplay setting {TrickPlayWidth}, using video width for thumbnails", mediaSource.VideoStream.Width, width); + actualWidth = 2 * ((int)mediaSource.VideoStream.Width / 2); + } + + var outputDir = GetTrickplayDirectory(video, actualWidth); + + if (!replace && Directory.Exists(outputDir) && (await GetTrickplayResolutions(video.Id).ConfigureAwait(false)).ContainsKey(actualWidth)) + { + _logger.LogDebug("Found existing trickplay files for {ItemId}. Exiting", video.Id); + return; + } + var mediaPath = mediaSource.Path; var mediaStream = mediaSource.VideoStream; var container = mediaSource.Container; - _logger.LogInformation("Creating trickplay files at {Width} width, for {Path} [ID: {ItemId}]", width, mediaPath, video.Id); + _logger.LogInformation("Creating trickplay files at {Width} width, for {Path} [ID: {ItemId}]", actualWidth, mediaPath, video.Id); imgTempDir = await _mediaEncoder.ExtractVideoImagesOnIntervalAccelerated( mediaPath, container, mediaSource, mediaStream, - width, + actualWidth, TimeSpan.FromMilliseconds(options.Interval), options.EnableHwAcceleration, options.EnableHwEncoding, @@ -159,7 +170,7 @@ public class TrickplayManager : ITrickplayManager .ToList(); // Create tiles - var trickplayInfo = CreateTiles(images, width, options, outputDir); + var trickplayInfo = CreateTiles(images, actualWidth, options, outputDir); // Save tiles info try diff --git a/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs b/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs index 237345206f..5b3349108e 100644 --- a/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs +++ b/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs @@ -183,14 +183,13 @@ namespace MediaBrowser.Controller.Entities.Audio progress.Report(percent * 95); } - // get album LUFS - LUFS = items.OfType