Procházet zdrojové kódy

Merge remote-tracking branch 'origin/master' into feature/EFUserData

JPVenson před 6 měsíci
rodič
revize
0dd6dacc4f
25 změnil soubory, kde provedl 280 přidání a 101 odebrání
  1. 0 1
      Emby.Server.Implementations/ConfigurationOptions.cs
  2. 1 1
      Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs
  3. 10 10
      Emby.Server.Implementations/Localization/Core/ko.json
  4. 25 15
      Emby.Server.Implementations/Playlists/PlaylistManager.cs
  5. 5 2
      Jellyfin.Api/Controllers/ItemRefreshController.cs
  6. 10 0
      Jellyfin.Api/Controllers/LibraryController.cs
  7. 1 1
      Jellyfin.Api/Controllers/MediaSegmentsController.cs
  8. 6 4
      Jellyfin.Api/Controllers/PlaylistsController.cs
  9. 5 0
      Jellyfin.Api/Models/LibraryDtos/LibraryOptionsResultDto.cs
  10. 34 4
      Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs
  11. 1 1
      Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
  12. 15 5
      Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs
  13. 1 0
      Jellyfin.Server/Migrations/MigrationRunner.cs
  14. 2 2
      Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs
  15. 69 0
      Jellyfin.Server/Migrations/Routines/RemoveDuplicatePlaylistChildren.cs
  16. 2 5
      MediaBrowser.Controller/Entities/LinkedChild.cs
  17. 0 13
      MediaBrowser.Controller/Extensions/ConfigurationExtensions.cs
  18. 42 14
      MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
  19. 11 1
      MediaBrowser.Controller/MediaSegements/IMediaSegmentManager.cs
  20. 2 1
      MediaBrowser.Controller/Playlists/IPlaylistManager.cs
  21. 10 0
      MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
  22. 4 2
      MediaBrowser.Model/Extensions/LibraryOptionsExtension.cs
  23. 1 1
      MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs
  24. 23 5
      MediaBrowser.Providers/MediaInfo/AudioFileProber.cs
  25. 0 13
      src/Jellyfin.Extensions/Json/Converters/JsonDelimitedArrayConverter.cs

+ 0 - 1
Emby.Server.Implementations/ConfigurationOptions.cs

@@ -17,7 +17,6 @@ namespace Emby.Server.Implementations
             { DefaultRedirectKey, "web/" },
             { FfmpegProbeSizeKey, "1G" },
             { FfmpegAnalyzeDurationKey, "200M" },
-            { PlaylistsAllowDuplicatesKey, bool.FalseString },
             { BindToUnixSocketKey, bool.FalseString },
             { SqliteCacheSizeKey, "20000" },
             { FfmpegSkipValidationKey, bool.FalseString },

+ 1 - 1
Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs

@@ -28,7 +28,7 @@ namespace Emby.Server.Implementations.Library.Resolvers
         {
             if (args.IsDirectory)
             {
-                // It's a boxset if the path is a directory with [playlist] in its name
+                // It's a playlist if the path is a directory with [playlist] in its name
                 var filename = Path.GetFileName(Path.TrimEndingDirectorySeparator(args.Path));
                 if (string.IsNullOrEmpty(filename))
                 {

+ 10 - 10
Emby.Server.Implementations/Localization/Core/ko.json

@@ -3,7 +3,7 @@
     "AppDeviceValues": "앱: {0}, 장치: {1}",
     "Application": "애플리케이션",
     "Artists": "아티스트",
-    "AuthenticationSucceededWithUserName": "{0}이(가) 성공적으로 인증됨",
+    "AuthenticationSucceededWithUserName": "{0} 사용자가 성공적으로 인증됨",
     "Books": "도서",
     "CameraImageUploadedFrom": "{0}에서 새로운 카메라 이미지가 업로드됨",
     "Channels": "채널",
@@ -70,7 +70,7 @@
     "ScheduledTaskFailedWithName": "{0} 실패",
     "ScheduledTaskStartedWithName": "{0} 시작",
     "ServerNameNeedsToBeRestarted": "{0}를 재시작해야합니다",
-    "Shows": "",
+    "Shows": "시리즈",
     "Songs": "노래",
     "StartupEmbyServerIsLoading": "Jellyfin 서버를 불러오고 있습니다. 잠시 후에 다시 시도하십시오.",
     "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
@@ -81,14 +81,14 @@
     "User": "사용자",
     "UserCreatedWithName": "사용자 {0} 생성됨",
     "UserDeletedWithName": "사용자 {0} 삭제됨",
-    "UserDownloadingItemWithValues": "{0}이(가) {1}을 다운로드 중입니다",
-    "UserLockedOutWithName": "유저 {0} 은(는) 잠금처리 되었습니다",
-    "UserOfflineFromDevice": "{1}에서 {0}의 연결이 끊킴",
-    "UserOnlineFromDevice": "{0}이 {1}으로 접속",
-    "UserPasswordChangedWithName": "사용자 {0}의 비밀번호가 변경되었습니다",
-    "UserPolicyUpdatedWithName": "{0}의 사용자 정책이 업데이트되었습니다",
-    "UserStartedPlayingItemWithValues": "{2}에서 {0}이 {1} 재생 중",
-    "UserStoppedPlayingItemWithValues": "{2}에서 {0}이 {1} 재생을 마침",
+    "UserDownloadingItemWithValues": "{0} 사용자가 {1} 다운로드 중",
+    "UserLockedOutWithName": "{0} 사용자 잠김",
+    "UserOfflineFromDevice": "{0} 사용자의 {1}에서 연결이 끊김",
+    "UserOnlineFromDevice": "{0} 사용자가 {1}에서 접속함",
+    "UserPasswordChangedWithName": "{0} 사용자 비밀번호 변경됨",
+    "UserPolicyUpdatedWithName": "{0} 사용자 정책 업데이트됨",
+    "UserStartedPlayingItemWithValues": "{0} 사용자의 {2}에서 {1} 재생 중",
+    "UserStoppedPlayingItemWithValues": "{0} 사용자의 {2}에서 {1} 재생을 마침",
     "ValueHasBeenAddedToLibrary": "{0}가 미디어 라이브러리에 추가되었습니다",
     "ValueSpecialEpisodeName": "스페셜 - {0}",
     "VersionNumber": "버전 {0}",

+ 25 - 15
Emby.Server.Implementations/Playlists/PlaylistManager.cs

@@ -216,14 +216,11 @@ namespace Emby.Server.Implementations.Playlists
             var newItems = GetPlaylistItems(newItemIds, user, options)
                 .Where(i => i.SupportsAddingToPlaylist);
 
-            // Filter out duplicate items, if necessary
-            if (!_appConfig.DoPlaylistsAllowDuplicates())
-            {
-                var existingIds = playlist.LinkedChildren.Select(c => c.ItemId).ToHashSet();
-                newItems = newItems
-                    .Where(i => !existingIds.Contains(i.Id))
-                    .Distinct();
-            }
+            // Filter out duplicate items
+            var existingIds = playlist.LinkedChildren.Select(c => c.ItemId).ToHashSet();
+            newItems = newItems
+                .Where(i => !existingIds.Contains(i.Id))
+                .Distinct();
 
             // Create a list of the new linked children to add to the playlist
             var childrenToAdd = newItems
@@ -269,7 +266,7 @@ namespace Emby.Server.Implementations.Playlists
 
             var idList = entryIds.ToList();
 
-            var removals = children.Where(i => idList.Contains(i.Item1.Id));
+            var removals = children.Where(i => idList.Contains(i.Item1.ItemId?.ToString("N", CultureInfo.InvariantCulture)));
 
             playlist.LinkedChildren = children.Except(removals)
                 .Select(i => i.Item1)
@@ -286,26 +283,39 @@ namespace Emby.Server.Implementations.Playlists
                 RefreshPriority.High);
         }
 
-        public async Task MoveItemAsync(string playlistId, string entryId, int newIndex)
+        public async Task MoveItemAsync(string playlistId, string entryId, int newIndex, Guid callingUserId)
         {
             if (_libraryManager.GetItemById(playlistId) is not Playlist playlist)
             {
                 throw new ArgumentException("No Playlist exists with the supplied Id");
             }
 
+            var user = _userManager.GetUserById(callingUserId);
             var children = playlist.GetManageableItems().ToList();
+            var accessibleChildren = children.Where(c => c.Item2.IsVisible(user)).ToArray();
 
-            var oldIndex = children.FindIndex(i => string.Equals(entryId, i.Item1.Id, StringComparison.OrdinalIgnoreCase));
+            var oldIndexAll = children.FindIndex(i => string.Equals(entryId, i.Item1.ItemId?.ToString("N", CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase));
+            var oldIndexAccessible = accessibleChildren.FindIndex(i => string.Equals(entryId, i.Item1.ItemId?.ToString("N", CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase));
 
-            if (oldIndex == newIndex)
+            if (oldIndexAccessible == newIndex)
             {
                 return;
             }
 
-            var item = playlist.LinkedChildren[oldIndex];
+            var newPriorItemIndex = newIndex > oldIndexAccessible ? newIndex : newIndex - 1 < 0 ? 0 : newIndex - 1;
+            var newPriorItemId = accessibleChildren[newPriorItemIndex].Item1.ItemId;
+            var newPriorItemIndexOnAllChildren = children.FindIndex(c => c.Item1.ItemId.Equals(newPriorItemId));
+            var adjustedNewIndex = newPriorItemIndexOnAllChildren + 1;
 
-            var newList = playlist.LinkedChildren.ToList();
+            var item = playlist.LinkedChildren.FirstOrDefault(i => string.Equals(entryId, i.ItemId?.ToString("N", CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase));
+            if (item is null)
+            {
+                _logger.LogWarning("Modified item not found in playlist. ItemId: {ItemId}, PlaylistId: {PlaylistId}", item.ItemId, playlistId);
 
+                return;
+            }
+
+            var newList = playlist.LinkedChildren.ToList();
             newList.Remove(item);
 
             if (newIndex >= newList.Count)
@@ -314,7 +324,7 @@ namespace Emby.Server.Implementations.Playlists
             }
             else
             {
-                newList.Insert(newIndex, item);
+                newList.Insert(adjustedNewIndex, item);
             }
 
             playlist.LinkedChildren = [.. newList];

+ 5 - 2
Jellyfin.Api/Controllers/ItemRefreshController.cs

@@ -50,6 +50,7 @@ public class ItemRefreshController : BaseJellyfinApiController
     /// <param name="imageRefreshMode">(Optional) Specifies the image refresh mode.</param>
     /// <param name="replaceAllMetadata">(Optional) Determines if metadata should be replaced. Only applicable if mode is FullRefresh.</param>
     /// <param name="replaceAllImages">(Optional) Determines if images should be replaced. Only applicable if mode is FullRefresh.</param>
+    /// <param name="regenerateTrickplay">(Optional) Determines if trickplay images should be replaced. Only applicable if mode is FullRefresh.</param>
     /// <response code="204">Item metadata refresh queued.</response>
     /// <response code="404">Item to refresh not found.</response>
     /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns>
@@ -62,7 +63,8 @@ public class ItemRefreshController : BaseJellyfinApiController
         [FromQuery] MetadataRefreshMode metadataRefreshMode = MetadataRefreshMode.None,
         [FromQuery] MetadataRefreshMode imageRefreshMode = MetadataRefreshMode.None,
         [FromQuery] bool replaceAllMetadata = false,
-        [FromQuery] bool replaceAllImages = false)
+        [FromQuery] bool replaceAllImages = false,
+        [FromQuery] bool regenerateTrickplay = false)
     {
         var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
         if (item is null)
@@ -81,7 +83,8 @@ public class ItemRefreshController : BaseJellyfinApiController
                 || replaceAllImages
                 || replaceAllMetadata,
             IsAutomated = false,
-            RemoveOldMetadata = replaceAllMetadata
+            RemoveOldMetadata = replaceAllMetadata,
+            RegenerateTrickplay = regenerateTrickplay
         };
 
         _providerManager.QueueRefresh(item.Id, refreshOptions, RefreshPriority.High);

+ 10 - 0
Jellyfin.Api/Controllers/LibraryController.cs

@@ -865,6 +865,16 @@ public class LibraryController : BaseJellyfinApiController
             .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
             .ToArray();
 
+        result.MediaSegmentProviders = plugins
+            .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.MediaSegmentProvider))
+            .Select(i => new LibraryOptionInfoDto
+            {
+                Name = i.Name,
+                DefaultEnabled = true
+            })
+            .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
+            .ToArray();
+
         var typeOptions = new List<LibraryTypeOptionsDto>();
 
         foreach (var type in types)

+ 1 - 1
Jellyfin.Api/Controllers/MediaSegmentsController.cs

@@ -55,7 +55,7 @@ public class MediaSegmentsController : BaseJellyfinApiController
             return NotFound();
         }
 
-        var items = await _mediaSegmentManager.GetSegmentsAsync(item.Id, includeSegmentTypes).ConfigureAwait(false);
+        var items = await _mediaSegmentManager.GetSegmentsAsync(item, includeSegmentTypes).ConfigureAwait(false);
         return Ok(new QueryResult<MediaSegmentDto>(items.ToArray()));
     }
 }

+ 6 - 4
Jellyfin.Api/Controllers/PlaylistsController.cs

@@ -1,6 +1,7 @@
 using System;
 using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
+using System.Globalization;
 using System.Linq;
 using System.Threading.Tasks;
 using Jellyfin.Api.Attributes;
@@ -426,7 +427,7 @@ public class PlaylistsController : BaseJellyfinApiController
             return Forbid();
         }
 
-        await _playlistManager.MoveItemAsync(playlistId, itemId, newIndex).ConfigureAwait(false);
+        await _playlistManager.MoveItemAsync(playlistId, itemId, newIndex, callingUserId).ConfigureAwait(false);
         return NoContent();
     }
 
@@ -514,7 +515,8 @@ public class PlaylistsController : BaseJellyfinApiController
             return Forbid();
         }
 
-        var items = playlist.GetManageableItems().ToArray();
+        var user = _userManager.GetUserById(callingUserId);
+        var items = playlist.GetManageableItems().Where(i => i.Item2.IsVisible(user)).ToArray();
         var count = items.Length;
         if (startIndex.HasValue)
         {
@@ -529,11 +531,11 @@ public class PlaylistsController : BaseJellyfinApiController
         var dtoOptions = new DtoOptions { Fields = fields }
             .AddClientFields(User)
             .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
-        var user = _userManager.GetUserById(callingUserId);
+
         var dtos = _dtoService.GetBaseItemDtos(items.Select(i => i.Item2).ToList(), dtoOptions, user);
         for (int index = 0; index < dtos.Count; index++)
         {
-            dtos[index].PlaylistItemId = items[index].Item1.Id;
+            dtos[index].PlaylistItemId = items[index].Item1.ItemId?.ToString("N", CultureInfo.InvariantCulture);
         }
 
         var result = new QueryResult<BaseItemDto>(

+ 5 - 0
Jellyfin.Api/Models/LibraryDtos/LibraryOptionsResultDto.cs

@@ -28,6 +28,11 @@ public class LibraryOptionsResultDto
     /// </summary>
     public IReadOnlyList<LibraryOptionInfoDto> LyricFetchers { get; set; } = Array.Empty<LibraryOptionInfoDto>();
 
+    /// <summary>
+    /// Gets or sets the list of MediaSegment Providers.
+    /// </summary>
+    public IReadOnlyList<LibraryOptionInfoDto> MediaSegmentProviders { get; set; } = Array.Empty<LibraryOptionInfoDto>();
+
     /// <summary>
     /// Gets or sets the type options.
     /// </summary>

+ 34 - 4
Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs

@@ -139,23 +139,53 @@ public class MediaSegmentManager : IMediaSegmentManager
     }
 
     /// <inheritdoc />
-    public async Task<IEnumerable<MediaSegmentDto>> GetSegmentsAsync(Guid itemId, IEnumerable<MediaSegmentType>? typeFilter)
+    public async Task<IEnumerable<MediaSegmentDto>> GetSegmentsAsync(Guid itemId, IEnumerable<MediaSegmentType>? typeFilter, bool filterByProvider = true)
+    {
+        var baseItem = _libraryManager.GetItemById(itemId);
+
+        if (baseItem is null)
+        {
+            _logger.LogError("Tried to request segments for an invalid item");
+            return [];
+        }
+
+        return await GetSegmentsAsync(baseItem, typeFilter, filterByProvider).ConfigureAwait(false);
+    }
+
+    /// <inheritdoc />
+    public async Task<IEnumerable<MediaSegmentDto>> GetSegmentsAsync(BaseItem item, IEnumerable<MediaSegmentType>? typeFilter, bool filterByProvider = true)
     {
         using var db = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
 
         var query = db.MediaSegments
-            .Where(e => e.ItemId.Equals(itemId));
+            .Where(e => e.ItemId.Equals(item.Id));
 
         if (typeFilter is not null)
         {
             query = query.Where(e => typeFilter.Contains(e.Type));
         }
 
+        if (filterByProvider)
+        {
+            var libraryOptions = _libraryManager.GetLibraryOptions(item);
+            var providerIds = _segmentProviders
+                .Where(e => !libraryOptions.DisabledMediaSegmentProviders.Contains(GetProviderId(e.Name)))
+                .Select(f => GetProviderId(f.Name))
+                .ToArray();
+            if (providerIds.Length == 0)
+            {
+                return [];
+            }
+
+            query = query.Where(e => providerIds.Contains(e.SegmentProviderId));
+        }
+
         return query
             .OrderBy(e => e.StartTicks)
             .AsNoTracking()
-            .ToArray()
-            .Select(Map);
+            .AsEnumerable()
+            .Select(Map)
+            .ToArray();
     }
 
     private static MediaSegmentDto Map(MediaSegment segment)

+ 1 - 1
Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs

@@ -238,7 +238,7 @@ public class TrickplayManager : ITrickplayManager
                         foreach (var tile in existingFiles)
                         {
                             var image = _imageEncoder.GetImageSize(tile);
-                            localTrickplayInfo.Height = Math.Max(localTrickplayInfo.Height, image.Height);
+                            localTrickplayInfo.Height = Math.Max(localTrickplayInfo.Height, (int)Math.Ceiling((double)image.Height / localTrickplayInfo.TileHeight));
                             var bitrate = (int)Math.Ceiling((decimal)new FileInfo(tile).Length * 8 / localTrickplayInfo.TileWidth / localTrickplayInfo.TileHeight / (localTrickplayInfo.Interval / 1000));
                             localTrickplayInfo.Bandwidth = Math.Max(localTrickplayInfo.Bandwidth, bitrate);
                         }

+ 15 - 5
Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs

@@ -101,7 +101,7 @@ namespace Jellyfin.Server.Infrastructure
                 count: null);
         }
 
-        private async Task SendFileAsync(string filePath, HttpResponse response, long offset, long? count)
+        private async Task SendFileAsync(string filePath, HttpResponse response, long offset, long? count, CancellationToken cancellationToken = default)
         {
             var fileInfo = GetFileInfo(filePath);
             if (offset < 0 || offset > fileInfo.Length)
@@ -118,6 +118,9 @@ namespace Jellyfin.Server.Infrastructure
             // Copied from SendFileFallback.SendFileAsync
             const int BufferSize = 1024 * 16;
 
+            var useRequestAborted = !cancellationToken.CanBeCanceled;
+            var localCancel = useRequestAborted ? response.HttpContext.RequestAborted : cancellationToken;
+
             var fileStream = new FileStream(
                 filePath,
                 FileMode.Open,
@@ -127,10 +130,17 @@ namespace Jellyfin.Server.Infrastructure
                 options: FileOptions.Asynchronous | FileOptions.SequentialScan);
             await using (fileStream.ConfigureAwait(false))
             {
-                fileStream.Seek(offset, SeekOrigin.Begin);
-                await StreamCopyOperation
-                    .CopyToAsync(fileStream, response.Body, count, BufferSize, CancellationToken.None)
-                    .ConfigureAwait(true);
+                try
+                {
+                    localCancel.ThrowIfCancellationRequested();
+                    fileStream.Seek(offset, SeekOrigin.Begin);
+                    await StreamCopyOperation
+                        .CopyToAsync(fileStream, response.Body, count, BufferSize, localCancel)
+                        .ConfigureAwait(true);
+                }
+                catch (OperationCanceledException) when (useRequestAborted)
+                {
+                }
             }
         }
 

+ 1 - 0
Jellyfin.Server/Migrations/MigrationRunner.cs

@@ -48,6 +48,7 @@ namespace Jellyfin.Server.Migrations
             typeof(Routines.UpdateDefaultPluginRepository),
             typeof(Routines.FixAudioData),
             typeof(Routines.MoveTrickplayFiles),
+            typeof(Routines.RemoveDuplicatePlaylistChildren),
             typeof(Routines.MigrateLibraryDb),
         };
 

+ 2 - 2
Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs

@@ -15,12 +15,12 @@ namespace Jellyfin.Server.Migrations.Routines;
 /// </summary>
 internal class FixPlaylistOwner : IMigrationRoutine
 {
-    private readonly ILogger<RemoveDuplicateExtras> _logger;
+    private readonly ILogger<FixPlaylistOwner> _logger;
     private readonly ILibraryManager _libraryManager;
     private readonly IPlaylistManager _playlistManager;
 
     public FixPlaylistOwner(
-        ILogger<RemoveDuplicateExtras> logger,
+        ILogger<FixPlaylistOwner> logger,
         ILibraryManager libraryManager,
         IPlaylistManager playlistManager)
     {

+ 69 - 0
Jellyfin.Server/Migrations/Routines/RemoveDuplicatePlaylistChildren.cs

@@ -0,0 +1,69 @@
+using System;
+using System.Linq;
+using System.Threading;
+
+using Jellyfin.Data.Enums;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Playlists;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Remove duplicate playlist entries.
+/// </summary>
+internal class RemoveDuplicatePlaylistChildren : IMigrationRoutine
+{
+    private readonly ILogger<RemoveDuplicatePlaylistChildren> _logger;
+    private readonly ILibraryManager _libraryManager;
+    private readonly IPlaylistManager _playlistManager;
+
+    public RemoveDuplicatePlaylistChildren(
+        ILogger<RemoveDuplicatePlaylistChildren> logger,
+        ILibraryManager libraryManager,
+        IPlaylistManager playlistManager)
+    {
+        _logger = logger;
+        _libraryManager = libraryManager;
+        _playlistManager = playlistManager;
+    }
+
+    /// <inheritdoc/>
+    public Guid Id => Guid.Parse("{96C156A2-7A13-4B3B-A8B8-FB80C94D20C0}");
+
+    /// <inheritdoc/>
+    public string Name => "RemoveDuplicatePlaylistChildren";
+
+    /// <inheritdoc/>
+    public bool PerformOnNewInstall => false;
+
+    /// <inheritdoc/>
+    public void Perform()
+    {
+        var playlists = _libraryManager.GetItemList(new InternalItemsQuery
+        {
+            IncludeItemTypes = [BaseItemKind.Playlist]
+        })
+        .Cast<Playlist>()
+        .Where(p => !p.OpenAccess || !p.OwnerUserId.Equals(Guid.Empty))
+        .ToArray();
+
+        if (playlists.Length > 0)
+        {
+            foreach (var playlist in playlists)
+            {
+                var linkedChildren = playlist.LinkedChildren;
+                if (linkedChildren.Length > 0)
+                {
+                    var nullItemChildren = linkedChildren.Where(c => c.ItemId is null);
+                    var deduplicatedChildren = linkedChildren.DistinctBy(c => c.ItemId);
+                    var newLinkedChildren = nullItemChildren.Concat(deduplicatedChildren);
+                    playlist.LinkedChildren = linkedChildren;
+                    playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult();
+                    _playlistManager.SavePlaylistFile(playlist);
+                }
+            }
+        }
+    }
+}

+ 2 - 5
MediaBrowser.Controller/Entities/LinkedChild.cs

@@ -4,7 +4,6 @@
 
 using System;
 using System.Globalization;
-using System.Text.Json.Serialization;
 
 namespace MediaBrowser.Controller.Entities
 {
@@ -12,7 +11,6 @@ namespace MediaBrowser.Controller.Entities
     {
         public LinkedChild()
         {
-            Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
         }
 
         public string Path { get; set; }
@@ -21,9 +19,6 @@ namespace MediaBrowser.Controller.Entities
 
         public string LibraryItemId { get; set; }
 
-        [JsonIgnore]
-        public string Id { get; set; }
-
         /// <summary>
         /// Gets or sets the linked item id.
         /// </summary>
@@ -31,6 +26,8 @@ namespace MediaBrowser.Controller.Entities
 
         public static LinkedChild Create(BaseItem item)
         {
+            ArgumentNullException.ThrowIfNull(item);
+
             var child = new LinkedChild
             {
                 Path = item.Path,

+ 0 - 13
MediaBrowser.Controller/Extensions/ConfigurationExtensions.cs

@@ -49,11 +49,6 @@ namespace MediaBrowser.Controller.Extensions
         /// </summary>
         public const string FfmpegPathKey = "ffmpeg";
 
-        /// <summary>
-        /// The key for a setting that indicates whether playlists should allow duplicate entries.
-        /// </summary>
-        public const string PlaylistsAllowDuplicatesKey = "playlists:allowDuplicates";
-
         /// <summary>
         /// The key for a setting that indicates whether kestrel should bind to a unix socket.
         /// </summary>
@@ -120,14 +115,6 @@ namespace MediaBrowser.Controller.Extensions
         public static bool GetFFmpegImgExtractPerfTradeoff(this IConfiguration configuration)
             => configuration.GetValue<bool>(FfmpegImgExtractPerfTradeoffKey);
 
-        /// <summary>
-        /// Gets a value indicating whether playlists should allow duplicate entries from the <see cref="IConfiguration"/>.
-        /// </summary>
-        /// <param name="configuration">The configuration to read the setting from.</param>
-        /// <returns>True if playlists should allow duplicates, otherwise false.</returns>
-        public static bool DoPlaylistsAllowDuplicates(this IConfiguration configuration)
-            => configuration.GetValue<bool>(PlaylistsAllowDuplicatesKey);
-
         /// <summary>
         /// Gets a value indicating whether kestrel should bind to a unix socket from the <see cref="IConfiguration" />.
         /// </summary>

+ 42 - 14
MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs

@@ -2196,7 +2196,10 @@ namespace MediaBrowser.Controller.MediaEncoding
             {
                 var videoFrameRate = videoStream.ReferenceFrameRate;
 
-                if (!videoFrameRate.HasValue || videoFrameRate.Value > requestedFramerate.Value)
+                // Add a little tolerance to the framerate check because some videos might record a framerate
+                // that is slightly higher than the intended framerate, but the device can still play it correctly.
+                // 0.05 fps tolerance should be safe enough.
+                if (!videoFrameRate.HasValue || videoFrameRate.Value > requestedFramerate.Value + 0.05f)
                 {
                     return false;
                 }
@@ -3318,24 +3321,25 @@ namespace MediaBrowser.Controller.MediaEncoding
                     && options.VppTonemappingBrightness >= -100
                     && options.VppTonemappingBrightness <= 100)
                 {
-                    procampParams += $"=b={options.VppTonemappingBrightness}";
+                    procampParams += "procamp_vaapi=b={0}";
                     doVaVppProcamp = true;
                 }
 
                 if (options.VppTonemappingContrast > 1
                     && options.VppTonemappingContrast <= 10)
                 {
-                    procampParams += doVaVppProcamp ? ":" : "=";
-                    procampParams += $"c={options.VppTonemappingContrast}";
+                    procampParams += doVaVppProcamp ? ":c={1}" : "procamp_vaapi=c={1}";
                     doVaVppProcamp = true;
                 }
 
-                args = "{0}tonemap_vaapi=format={1}:p=bt709:t=bt709:m=bt709:extra_hw_frames=32";
+                args = procampParams + "{2}tonemap_vaapi=format={3}:p=bt709:t=bt709:m=bt709:extra_hw_frames=32";
 
                 return string.Format(
                         CultureInfo.InvariantCulture,
                         args,
-                        doVaVppProcamp ? $"procamp_vaapi{procampParams}," : string.Empty,
+                        options.VppTonemappingBrightness,
+                        options.VppTonemappingContrast,
+                        doVaVppProcamp ? "," : string.Empty,
                         videoFormat ?? "nv12");
             }
             else
@@ -3523,20 +3527,29 @@ namespace MediaBrowser.Controller.MediaEncoding
             {
                 // tonemapx requires yuv420p10 input for dovi reshaping, let ffmpeg convert the frame when necessary
                 var tonemapFormat = requireDoviReshaping ? "yuv420p" : outFormat;
-
-                var tonemapArgs = $"tonemapx=tonemap={options.TonemappingAlgorithm}:desat={options.TonemappingDesat}:peak={options.TonemappingPeak}:t=bt709:m=bt709:p=bt709:format={tonemapFormat}";
+                var tonemapArgString = "tonemapx=tonemap={0}:desat={1}:peak={2}:t=bt709:m=bt709:p=bt709:format={3}";
 
                 if (options.TonemappingParam != 0)
                 {
-                    tonemapArgs += $":param={options.TonemappingParam}";
+                    tonemapArgString += ":param={4}";
                 }
 
                 var range = options.TonemappingRange;
                 if (range == TonemappingRange.tv || range == TonemappingRange.pc)
                 {
-                    tonemapArgs += $":range={options.TonemappingRange}";
+                    tonemapArgString += ":range={5}";
                 }
 
+                var tonemapArgs = string.Format(
+                    CultureInfo.InvariantCulture,
+                    tonemapArgString,
+                    options.TonemappingAlgorithm,
+                    options.TonemappingDesat,
+                    options.TonemappingPeak,
+                    tonemapFormat,
+                    options.TonemappingParam,
+                    options.TonemappingRange);
+
                 mainFilters.Add(tonemapArgs);
             }
             else
@@ -4128,31 +4141,46 @@ namespace MediaBrowser.Controller.MediaEncoding
             else if (isD3d11vaDecoder || isQsvDecoder)
             {
                 var isRext = IsVideoStreamHevcRext(state);
-                var twoPassVppTonemap = isRext;
+                var twoPassVppTonemap = false;
                 var doVppFullRangeOut = isMjpegEncoder
                     && _mediaEncoder.EncoderVersion >= _minFFmpegQsvVppOutRangeOption;
                 var doVppScaleModeHq = isMjpegEncoder
                     && _mediaEncoder.EncoderVersion >= _minFFmpegQsvVppScaleModeOption;
                 var doVppProcamp = false;
                 var procampParams = string.Empty;
+                var procampParamsString = string.Empty;
                 if (doVppTonemap)
                 {
+                    if (isRext)
+                    {
+                        // VPP tonemap requires p010 input
+                        twoPassVppTonemap = true;
+                    }
+
                     if (options.VppTonemappingBrightness != 0
                         && options.VppTonemappingBrightness >= -100
                         && options.VppTonemappingBrightness <= 100)
                     {
-                        procampParams += $":brightness={options.VppTonemappingBrightness}";
+                        procampParamsString += ":brightness={0}";
                         twoPassVppTonemap = doVppProcamp = true;
                     }
 
                     if (options.VppTonemappingContrast > 1
                         && options.VppTonemappingContrast <= 10)
                     {
-                        procampParams += $":contrast={options.VppTonemappingContrast}";
+                        procampParamsString += ":contrast={1}";
                         twoPassVppTonemap = doVppProcamp = true;
                     }
 
-                    procampParams += doVppProcamp ? ":procamp=1:async_depth=2" : string.Empty;
+                    if (doVppProcamp)
+                    {
+                        procampParamsString += ":procamp=1:async_depth=2";
+                        procampParams = string.Format(
+                            CultureInfo.InvariantCulture,
+                            procampParamsString,
+                            options.VppTonemappingBrightness,
+                            options.VppTonemappingContrast);
+                    }
                 }
 
                 var outFormat = doOclTonemap ? ((doVppTranspose || isRext) ? "p010" : string.Empty) : "nv12";

+ 11 - 1
MediaBrowser.Controller/MediaSegements/IMediaSegmentManager.cs

@@ -50,8 +50,18 @@ public interface IMediaSegmentManager
     /// </summary>
     /// <param name="itemId">The id of the <see cref="BaseItem"/>.</param>
     /// <param name="typeFilter">filteres all media segments of the given type to be included. If null all types are included.</param>
+    /// <param name="filterByProvider">When set filteres the segments to only return those that which providers are currently enabled on their library.</param>
     /// <returns>An enumerator of <see cref="MediaSegmentDto"/>'s.</returns>
-    Task<IEnumerable<MediaSegmentDto>> GetSegmentsAsync(Guid itemId, IEnumerable<MediaSegmentType>? typeFilter);
+    Task<IEnumerable<MediaSegmentDto>> GetSegmentsAsync(Guid itemId, IEnumerable<MediaSegmentType>? typeFilter, bool filterByProvider = true);
+
+    /// <summary>
+    /// Obtains all segments accociated with the itemId.
+    /// </summary>
+    /// <param name="item">The <see cref="BaseItem"/>.</param>
+    /// <param name="typeFilter">filteres all media segments of the given type to be included. If null all types are included.</param>
+    /// <param name="filterByProvider">When set filteres the segments to only return those that which providers are currently enabled on their library.</param>
+    /// <returns>An enumerator of <see cref="MediaSegmentDto"/>'s.</returns>
+    Task<IEnumerable<MediaSegmentDto>> GetSegmentsAsync(BaseItem item, IEnumerable<MediaSegmentType>? typeFilter, bool filterByProvider = true);
 
     /// <summary>
     /// Gets information about any media segments stored for the given itemId.

+ 2 - 1
MediaBrowser.Controller/Playlists/IPlaylistManager.cs

@@ -92,8 +92,9 @@ namespace MediaBrowser.Controller.Playlists
         /// <param name="playlistId">The playlist identifier.</param>
         /// <param name="entryId">The entry identifier.</param>
         /// <param name="newIndex">The new index.</param>
+        /// <param name="callingUserId">The calling user.</param>
         /// <returns>Task.</returns>
-        Task MoveItemAsync(string playlistId, string entryId, int newIndex);
+        Task MoveItemAsync(string playlistId, string entryId, int newIndex, Guid callingUserId);
 
         /// <summary>
         /// Removed all playlists of a user.

+ 10 - 0
MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs

@@ -1035,6 +1035,16 @@ namespace MediaBrowser.MediaEncoding.Encoder
                 if (exitCode == -1)
                 {
                     _logger.LogError("ffmpeg image extraction failed for {ProcessDescription}", processDescription);
+                    // Cleanup temp folder here, because the targetDirectory is not returned and the cleanup for failed ffmpeg process is not possible for caller.
+                    // Ideally the ffmpeg should not write any files if it fails, but it seems like it is not guaranteed.
+                    try
+                    {
+                        Directory.Delete(targetDirectory, true);
+                    }
+                    catch (Exception e)
+                    {
+                        _logger.LogError(e, "Failed to delete ffmpeg temp directory {TargetDirectory}", targetDirectory);
+                    }
 
                     throw new FfmpegException(string.Format(CultureInfo.InvariantCulture, "ffmpeg image extraction failed for {0}", processDescription));
                 }

+ 4 - 2
MediaBrowser.Model/Extensions/LibraryOptionsExtension.cs

@@ -18,7 +18,7 @@ public static class LibraryOptionsExtension
     {
         ArgumentNullException.ThrowIfNull(options);
 
-        return options.CustomTagDelimiters.Select<string, char?>(x =>
+        var delimiterList = options.CustomTagDelimiters.Select<string, char?>(x =>
         {
             var isChar = char.TryParse(x, out var c);
             if (isChar)
@@ -27,6 +27,8 @@ public static class LibraryOptionsExtension
             }
 
             return null;
-        }).Where(x => x is not null).Select(x => x!.Value).ToArray();
+        }).Where(x => x is not null).Select(x => x!.Value).ToList();
+        delimiterList.Add('\0');
+        return delimiterList.ToArray();
     }
 }

+ 1 - 1
MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs

@@ -38,5 +38,5 @@ public class PlaylistCreationRequest
     /// <summary>
     /// Gets or sets a value indicating whether the playlist is public.
     /// </summary>
-    public bool? Public { get; set; } = true;
+    public bool? Public { get; set; } = false;
 }

+ 23 - 5
MediaBrowser.Providers/MediaInfo/AudioFileProber.cs

@@ -351,7 +351,8 @@ namespace MediaBrowser.Providers.MediaInfo
                      || track.AdditionalFields.TryGetValue("MusicBrainz Artist Id", out musicBrainzArtistTag))
                     && !string.IsNullOrEmpty(musicBrainzArtistTag))
                 {
-                    audio.TrySetProviderId(MetadataProvider.MusicBrainzArtist, musicBrainzArtistTag);
+                    var id = GetFirstMusicBrainzId(musicBrainzArtistTag, libraryOptions.UseCustomTagDelimiters, libraryOptions.GetCustomTagDelimiters(), libraryOptions.DelimiterWhitelist);
+                    audio.TrySetProviderId(MetadataProvider.MusicBrainzArtist, id);
                 }
             }
 
@@ -361,7 +362,8 @@ namespace MediaBrowser.Providers.MediaInfo
                      || track.AdditionalFields.TryGetValue("MusicBrainz Album Artist Id", out musicBrainzReleaseArtistIdTag))
                     && !string.IsNullOrEmpty(musicBrainzReleaseArtistIdTag))
                 {
-                    audio.TrySetProviderId(MetadataProvider.MusicBrainzAlbumArtist, musicBrainzReleaseArtistIdTag);
+                    var id = GetFirstMusicBrainzId(musicBrainzReleaseArtistIdTag, libraryOptions.UseCustomTagDelimiters, libraryOptions.GetCustomTagDelimiters(), libraryOptions.DelimiterWhitelist);
+                    audio.TrySetProviderId(MetadataProvider.MusicBrainzAlbumArtist, id);
                 }
             }
 
@@ -371,7 +373,8 @@ namespace MediaBrowser.Providers.MediaInfo
                      || track.AdditionalFields.TryGetValue("MusicBrainz Album Id", out musicBrainzReleaseIdTag))
                     && !string.IsNullOrEmpty(musicBrainzReleaseIdTag))
                 {
-                    audio.TrySetProviderId(MetadataProvider.MusicBrainzAlbum, musicBrainzReleaseIdTag);
+                    var id = GetFirstMusicBrainzId(musicBrainzReleaseIdTag, libraryOptions.UseCustomTagDelimiters, libraryOptions.GetCustomTagDelimiters(), libraryOptions.DelimiterWhitelist);
+                    audio.TrySetProviderId(MetadataProvider.MusicBrainzAlbum, id);
                 }
             }
 
@@ -381,7 +384,8 @@ namespace MediaBrowser.Providers.MediaInfo
                      || track.AdditionalFields.TryGetValue("MusicBrainz Release Group Id", out musicBrainzReleaseGroupIdTag))
                     && !string.IsNullOrEmpty(musicBrainzReleaseGroupIdTag))
                 {
-                    audio.TrySetProviderId(MetadataProvider.MusicBrainzReleaseGroup, musicBrainzReleaseGroupIdTag);
+                    var id = GetFirstMusicBrainzId(musicBrainzReleaseGroupIdTag, libraryOptions.UseCustomTagDelimiters, libraryOptions.GetCustomTagDelimiters(), libraryOptions.DelimiterWhitelist);
+                    audio.TrySetProviderId(MetadataProvider.MusicBrainzReleaseGroup, id);
                 }
             }
 
@@ -391,7 +395,8 @@ namespace MediaBrowser.Providers.MediaInfo
                      || track.AdditionalFields.TryGetValue("MusicBrainz Release Track Id", out trackMbId))
                     && !string.IsNullOrEmpty(trackMbId))
                 {
-                    audio.TrySetProviderId(MetadataProvider.MusicBrainzTrack, trackMbId);
+                    var id = GetFirstMusicBrainzId(trackMbId, libraryOptions.UseCustomTagDelimiters, libraryOptions.GetCustomTagDelimiters(), libraryOptions.DelimiterWhitelist);
+                    audio.TrySetProviderId(MetadataProvider.MusicBrainzTrack, id);
                 }
             }
 
@@ -445,5 +450,18 @@ namespace MediaBrowser.Providers.MediaInfo
 
             return items;
         }
+
+        // MusicBrainz IDs are multi-value tags, so we need to split them
+        // However, our current provider can only have one single ID, which means we need to pick the first one
+        private string? GetFirstMusicBrainzId(string tag, bool useCustomTagDelimiters, char[] tagDelimiters, string[] whitelist)
+        {
+            var val = tag.Split(InternalValueSeparator).FirstOrDefault();
+            if (val is not null && useCustomTagDelimiters)
+            {
+                val = SplitWithCustomDelimiter(val, tagDelimiters, whitelist).FirstOrDefault();
+            }
+
+            return val;
+        }
     }
 }

+ 0 - 13
src/Jellyfin.Extensions/Json/Converters/JsonDelimitedArrayConverter.cs

@@ -70,24 +70,11 @@ namespace Jellyfin.Extensions.Json.Converters
                 writer.WriteStartArray();
                 if (value.Length > 0)
                 {
-                    var toWrite = value.Length - 1;
                     foreach (var it in value)
                     {
-                        var wrote = false;
                         if (it is not null)
                         {
                             writer.WriteStringValue(it.ToString());
-                            wrote = true;
-                        }
-
-                        if (toWrite > 0)
-                        {
-                            if (wrote)
-                            {
-                                writer.WriteStringValue(Delimiter.ToString());
-                            }
-
-                            toWrite--;
                         }
                     }
                 }