Browse Source

Merge branch 'master' into NetworkPR2

Joshua M. Boniface 4 years ago
parent
commit
2c9e355e42
64 changed files with 1970 additions and 268 deletions
  1. 36 0
      .github/workflows/codeql-analysis.yml
  2. 130 0
      Emby.Server.Implementations/Library/ImageFetcherPostScanTask.cs
  3. 32 13
      Emby.Server.Implementations/Library/LibraryManager.cs
  4. 26 25
      Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs
  5. 26 25
      Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs
  6. 5 5
      Emby.Server.Implementations/Localization/Core/vi.json
  7. 84 14
      Emby.Server.Implementations/Updates/InstallationManager.cs
  8. 1 1
      Jellyfin.Api/Controllers/ApiKeyController.cs
  9. 1 1
      Jellyfin.Api/Controllers/ArtistsController.cs
  10. 166 3
      Jellyfin.Api/Controllers/AudioController.cs
  11. 1 1
      Jellyfin.Api/Controllers/BrandingController.cs
  12. 1 1
      Jellyfin.Api/Controllers/CollectionController.cs
  13. 1 1
      Jellyfin.Api/Controllers/DashboardController.cs
  14. 2 2
      Jellyfin.Api/Controllers/DynamicHlsController.cs
  15. 1 1
      Jellyfin.Api/Controllers/FilterController.cs
  16. 1 1
      Jellyfin.Api/Controllers/GenresController.cs
  17. 1 1
      Jellyfin.Api/Controllers/HlsSegmentController.cs
  18. 684 54
      Jellyfin.Api/Controllers/ImageController.cs
  19. 3 3
      Jellyfin.Api/Controllers/InstantMixController.cs
  20. 1 1
      Jellyfin.Api/Controllers/ItemLookupController.cs
  21. 1 1
      Jellyfin.Api/Controllers/ItemUpdateController.cs
  22. 251 6
      Jellyfin.Api/Controllers/ItemsController.cs
  23. 1 1
      Jellyfin.Api/Controllers/LocalizationController.cs
  24. 1 1
      Jellyfin.Api/Controllers/MediaInfoController.cs
  25. 1 1
      Jellyfin.Api/Controllers/MoviesController.cs
  26. 1 1
      Jellyfin.Api/Controllers/MusicGenresController.cs
  27. 1 1
      Jellyfin.Api/Controllers/PackageController.cs
  28. 1 1
      Jellyfin.Api/Controllers/PersonsController.cs
  29. 1 1
      Jellyfin.Api/Controllers/PlaylistsController.cs
  30. 3 2
      Jellyfin.Api/Controllers/PlaystateController.cs
  31. 1 1
      Jellyfin.Api/Controllers/PluginsController.cs
  32. 3 2
      Jellyfin.Api/Controllers/SessionController.cs
  33. 1 1
      Jellyfin.Api/Controllers/StudiosController.cs
  34. 40 2
      Jellyfin.Api/Controllers/SubtitleController.cs
  35. 1 1
      Jellyfin.Api/Controllers/SuggestionsController.cs
  36. 1 1
      Jellyfin.Api/Controllers/SyncPlayController.cs
  37. 1 1
      Jellyfin.Api/Controllers/TimeSyncController.cs
  38. 0 1
      Jellyfin.Api/Controllers/TrailersController.cs
  39. 1 1
      Jellyfin.Api/Controllers/UniversalAudioController.cs
  40. 1 1
      Jellyfin.Api/Controllers/UserController.cs
  41. 1 1
      Jellyfin.Api/Controllers/UserViewsController.cs
  42. 2 1
      Jellyfin.Api/Controllers/VideoAttachmentsController.cs
  43. 162 3
      Jellyfin.Api/Controllers/VideosController.cs
  44. 1 1
      Jellyfin.Api/Controllers/YearsController.cs
  45. 49 0
      Jellyfin.Api/ModelBinders/LegacyDateTimeModelBinder.cs
  46. 47 0
      Jellyfin.Api/ModelBinders/NullableEnumModelBinder.cs
  47. 27 0
      Jellyfin.Api/ModelBinders/NullableEnumModelBinderProvider.cs
  48. 87 0
      Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs
  49. 0 44
      Jellyfin.Api/TypeConverters/DateTimeTypeConverter.cs
  50. 3 0
      Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
  51. 2 2
      Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs
  52. 8 6
      Jellyfin.Server/Startup.cs
  53. 5 2
      MediaBrowser.Common/Updates/IInstallationManager.cs
  54. 0 1
      MediaBrowser.Controller/Entities/BaseItem.cs
  55. 0 5
      MediaBrowser.Controller/Entities/Folder.cs
  56. 2 0
      MediaBrowser.Controller/Library/ILibraryManager.cs
  57. 3 2
      MediaBrowser.Controller/Session/SessionInfo.cs
  58. 3 2
      MediaBrowser.Model/Session/ClientCapabilities.cs
  59. 1 11
      MediaBrowser.Model/Updates/PackageInfo.cs
  60. 6 0
      MediaBrowser.Model/Updates/RepositoryInfo.cs
  61. 29 1
      MediaBrowser.Model/Updates/VersionInfo.cs
  62. 8 5
      MediaBrowser.Providers/Manager/MetadataService.cs
  63. 1 0
      tests/Jellyfin.Naming.Tests/Video/CleanDateTimeTests.cs
  64. 8 0
      tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs

+ 36 - 0
.github/workflows/codeql-analysis.yml

@@ -0,0 +1,36 @@
+name: "CodeQL"
+
+on:
+  push:
+    branches: [ master ]
+  pull_request:
+    branches: [ master ]
+  schedule:
+    - cron: '24 2 * * 4'
+
+jobs:
+  analyze:
+    name: Analyze
+    runs-on: ubuntu-latest
+
+    strategy:
+      fail-fast: false
+      matrix:
+        language: [ 'csharp' ]
+
+    steps:
+    - name: Checkout repository
+      uses: actions/checkout@v2
+    - name: Setup .NET Core
+      uses: actions/setup-dotnet@v1
+      with:
+        dotnet-version: '5.0.100'
+    - name: Initialize CodeQL
+      uses: github/codeql-action/init@v1
+      with:
+        languages: ${{ matrix.language }}
+        queries: +security-extended
+    - name: Autobuild
+      uses: github/codeql-action/autobuild@v1
+    - name: Perform CodeQL Analysis
+      uses: github/codeql-action/analyze@v1

+ 130 - 0
Emby.Server.Implementations/Library/ImageFetcherPostScanTask.cs

@@ -0,0 +1,130 @@
+using System;
+using System.Collections.Concurrent;
+using System.Globalization;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Data.Events;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Net;
+using Microsoft.Extensions.Logging;
+
+namespace Emby.Server.Implementations.Library
+{
+    /// <summary>
+    /// A library post scan/refresh task for pre-fetching remote images.
+    /// </summary>
+    public class ImageFetcherPostScanTask : ILibraryPostScanTask
+    {
+        private readonly ILibraryManager _libraryManager;
+        private readonly IProviderManager _providerManager;
+        private readonly ILogger<ImageFetcherPostScanTask> _logger;
+        private readonly SemaphoreSlim _imageFetcherLock;
+
+        private ConcurrentDictionary<Guid, (BaseItem item, ItemUpdateType updateReason)> _queuedItems;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ImageFetcherPostScanTask"/> class.
+        /// </summary>
+        /// <param name="libraryManager">An instance of <see cref="ILibraryManager"/>.</param>
+        /// <param name="providerManager">An instance of <see cref="IProviderManager"/>.</param>
+        /// <param name="logger">An instance of <see cref="ILogger{ImageFetcherPostScanTask}"/>.</param>
+        public ImageFetcherPostScanTask(
+            ILibraryManager libraryManager,
+            IProviderManager providerManager,
+            ILogger<ImageFetcherPostScanTask> logger)
+        {
+            _libraryManager = libraryManager;
+            _providerManager = providerManager;
+            _logger = logger;
+            _queuedItems = new ConcurrentDictionary<Guid, (BaseItem item, ItemUpdateType updateReason)>();
+            _imageFetcherLock = new SemaphoreSlim(1, 1);
+            _libraryManager.ItemAdded += OnLibraryManagerItemAddedOrUpdated;
+            _libraryManager.ItemUpdated += OnLibraryManagerItemAddedOrUpdated;
+            _providerManager.RefreshCompleted += OnProviderManagerRefreshCompleted;
+        }
+
+        /// <inheritdoc />
+        public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
+        {
+            // Sometimes a library scan will cause this to run twice if there's an item refresh going on.
+            await _imageFetcherLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+
+            try
+            {
+                var now = DateTime.UtcNow;
+                var itemGuids = _queuedItems.Keys.ToList();
+
+                for (var i = 0; i < itemGuids.Count; i++)
+                {
+                    if (!_queuedItems.TryGetValue(itemGuids[i], out var queuedItem))
+                    {
+                        continue;
+                    }
+
+                    var itemId = queuedItem.item.Id.ToString("N", CultureInfo.InvariantCulture);
+                    var itemType = queuedItem.item.GetType();
+                    _logger.LogDebug(
+                        "Updating remote images for item {ItemId} with media type {ItemMediaType}",
+                        itemId,
+                        itemType);
+                    try
+                    {
+                        await _libraryManager.UpdateImagesAsync(queuedItem.item, queuedItem.updateReason >= ItemUpdateType.ImageUpdate).ConfigureAwait(false);
+                    }
+                    catch (Exception ex)
+                    {
+                        _logger.LogError(ex, "Failed to fetch images for {Type} item with id {ItemId}", itemType, itemId);
+                    }
+
+                    _queuedItems.TryRemove(queuedItem.item.Id, out _);
+                }
+
+                if (itemGuids.Count > 0)
+                {
+                    _logger.LogInformation(
+                        "Finished updating/pre-fetching {NumberOfImages} images. Elapsed time: {TimeElapsed}s.",
+                        itemGuids.Count.ToString(CultureInfo.InvariantCulture),
+                        (DateTime.UtcNow - now).TotalSeconds.ToString(CultureInfo.InvariantCulture));
+                }
+                else
+                {
+                    _logger.LogDebug("No images were updated.");
+                }
+            }
+            finally
+            {
+                _imageFetcherLock.Release();
+            }
+        }
+
+        private void OnLibraryManagerItemAddedOrUpdated(object sender, ItemChangeEventArgs itemChangeEventArgs)
+        {
+            if (!_queuedItems.ContainsKey(itemChangeEventArgs.Item.Id) && itemChangeEventArgs.Item.ImageInfos.Length > 0)
+            {
+                _queuedItems.AddOrUpdate(
+                    itemChangeEventArgs.Item.Id,
+                    (itemChangeEventArgs.Item, itemChangeEventArgs.UpdateReason),
+                    (key, existingValue) => existingValue);
+            }
+        }
+
+        private void OnProviderManagerRefreshCompleted(object sender, GenericEventArgs<BaseItem> e)
+        {
+            if (!_queuedItems.ContainsKey(e.Argument.Id) && e.Argument.ImageInfos.Length > 0)
+            {
+                _queuedItems.AddOrUpdate(
+                    e.Argument.Id,
+                    (e.Argument, ItemUpdateType.None),
+                    (key, existingValue) => existingValue);
+            }
+
+            // The RefreshCompleted event is a bit awkward in that it seems to _only_ be fired on
+            // the item that was refreshed regardless of children refreshes. So we take it as a signal
+            // that the refresh is entirely completed.
+            Run(null, CancellationToken.None).GetAwaiter().GetResult();
+        }
+    }
+}

+ 32 - 13
Emby.Server.Implementations/Library/LibraryManager.cs

@@ -858,7 +858,21 @@ namespace Emby.Server.Implementations.Library
         /// <returns>Task{Person}.</returns>
         public Person GetPerson(string name)
         {
-            return CreateItemByName<Person>(Person.GetPath, name, new DtoOptions(true));
+            var path = Person.GetPath(name);
+            var id = GetItemByNameId<Person>(path);
+            if (!(GetItemById(id) is Person item))
+            {
+                item = new Person
+                {
+                    Name = name,
+                    Id = id,
+                    DateCreated = DateTime.UtcNow,
+                    DateModified = DateTime.UtcNow,
+                    Path = path
+                };
+            }
+
+            return item;
         }
 
         /// <summary>
@@ -1941,19 +1955,9 @@ namespace Emby.Server.Implementations.Library
         }
 
         /// <inheritdoc />
-        public async Task UpdateItemsAsync(IReadOnlyList<BaseItem> items, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
+        public Task UpdateItemsAsync(IReadOnlyList<BaseItem> items, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
         {
-            foreach (var item in items)
-            {
-                if (item.IsFileProtocol)
-                {
-                    ProviderManager.SaveMetadata(item, updateReason);
-                }
-
-                item.DateLastSaved = DateTime.UtcNow;
-
-                await UpdateImagesAsync(item, updateReason >= ItemUpdateType.ImageUpdate).ConfigureAwait(false);
-            }
+            RunMetadataSavers(items, updateReason);
 
             _itemRepository.SaveItems(items, cancellationToken);
 
@@ -1984,12 +1988,27 @@ namespace Emby.Server.Implementations.Library
                     }
                 }
             }
+
+            return Task.CompletedTask;
         }
 
         /// <inheritdoc />
         public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
             => UpdateItemsAsync(new[] { item }, parent, updateReason, cancellationToken);
 
+        public void RunMetadataSavers(IReadOnlyList<BaseItem> items, ItemUpdateType updateReason)
+        {
+            foreach (var item in items)
+            {
+                if (item.IsFileProtocol)
+                {
+                    ProviderManager.SaveMetadata(item, updateReason);
+                }
+
+                item.DateLastSaved = DateTime.UtcNow;
+            }
+        }
+
         /// <summary>
         /// Reports the item removed.
         /// </summary>

+ 26 - 25
Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs

@@ -111,11 +111,11 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
 
         public async Task<bool> CheckTunerAvailability(IPAddress remoteIp, int tuner, CancellationToken cancellationToken)
         {
-            using (var client = new TcpClient(new IPEndPoint(remoteIp, HdHomeRunPort)))
-            using (var stream = client.GetStream())
-            {
-                return await CheckTunerAvailability(stream, tuner, cancellationToken).ConfigureAwait(false);
-            }
+            using var client = new TcpClient();
+            client.Connect(remoteIp, HdHomeRunPort);
+
+            using var stream = client.GetStream();
+            return await CheckTunerAvailability(stream, tuner, cancellationToken).ConfigureAwait(false);
         }
 
         private static async Task<bool> CheckTunerAvailability(NetworkStream stream, int tuner, CancellationToken cancellationToken)
@@ -142,7 +142,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
         {
             _remoteEndPoint = new IPEndPoint(remoteIp, HdHomeRunPort);
 
-            _tcpClient = new TcpClient(_remoteEndPoint);
+            _tcpClient = new TcpClient();
+            _tcpClient.Connect(_remoteEndPoint);
 
             if (!_lockkey.HasValue)
             {
@@ -221,30 +222,30 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
                 return;
             }
 
-            using (var tcpClient = new TcpClient(_remoteEndPoint))
-            using (var stream = tcpClient.GetStream())
+            using var tcpClient = new TcpClient();
+            tcpClient.Connect(_remoteEndPoint);
+
+            using var stream = tcpClient.GetStream();
+            var commandList = commands.GetCommands();
+            byte[] buffer = ArrayPool<byte>.Shared.Rent(8192);
+            try
             {
-                var commandList = commands.GetCommands();
-                byte[] buffer = ArrayPool<byte>.Shared.Rent(8192);
-                try
+                foreach (var command in commandList)
                 {
-                    foreach (var command in commandList)
-                    {
-                        var channelMsg = CreateSetMessage(_activeTuner, command.Item1, command.Item2, _lockkey);
-                        await stream.WriteAsync(channelMsg, 0, channelMsg.Length, cancellationToken).ConfigureAwait(false);
-                        int receivedBytes = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false);
+                    var channelMsg = CreateSetMessage(_activeTuner, command.Item1, command.Item2, _lockkey);
+                    await stream.WriteAsync(channelMsg, 0, channelMsg.Length, cancellationToken).ConfigureAwait(false);
+                    int receivedBytes = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false);
 
-                        // parse response to make sure it worked
-                        if (!ParseReturnMessage(buffer, receivedBytes, out _))
-                        {
-                            return;
-                        }
+                    // parse response to make sure it worked
+                    if (!ParseReturnMessage(buffer, receivedBytes, out _))
+                    {
+                        return;
                     }
                 }
-                finally
-                {
-                    ArrayPool<byte>.Shared.Return(buffer);
-                }
+            }
+            finally
+            {
+                ArrayPool<byte>.Shared.Return(buffer);
             }
         }
 

+ 26 - 25
Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs

@@ -93,7 +93,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
                 try
                 {
                     await tcpClient.ConnectAsync(remoteAddress, HdHomerunManager.HdHomeRunPort).ConfigureAwait(false);
-                    localAddress = ((IPEndPoint)tcpClient.Client.RemoteEndPoint).Address;
+                    localAddress = ((IPEndPoint)tcpClient.Client.LocalEndPoint).Address;
                     tcpClient.Close();
                 }
                 catch (Exception ex)
@@ -103,6 +103,10 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
                 }
             }
 
+            if (localAddress.IsIPv4MappedToIPv6) {
+                localAddress = localAddress.MapToIPv4();
+            }
+
             var udpClient = new UdpClient(localPort, AddressFamily.InterNetwork);
             var hdHomerunManager = new HdHomerunManager();
 
@@ -133,12 +137,12 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
 
             var taskCompletionSource = new TaskCompletionSource<bool>();
 
-            await StartStreaming(
+            _ = StartStreaming(
                 udpClient,
                 hdHomerunManager,
                 remoteAddress,
                 taskCompletionSource,
-                LiveStreamCancellationTokenSource.Token).ConfigureAwait(false);
+                LiveStreamCancellationTokenSource.Token);
 
             // OpenedMediaSource.Protocol = MediaProtocol.File;
             // OpenedMediaSource.Path = tempFile;
@@ -159,33 +163,30 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
             return TempFilePath;
         }
 
-        private Task StartStreaming(UdpClient udpClient, HdHomerunManager hdHomerunManager, IPAddress remoteAddress, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken)
+        private async Task StartStreaming(UdpClient udpClient, HdHomerunManager hdHomerunManager, IPAddress remoteAddress, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken)
         {
-            return Task.Run(async () =>
+            using (udpClient)
+            using (hdHomerunManager)
             {
-                using (udpClient)
-                using (hdHomerunManager)
+                try
                 {
-                    try
-                    {
-                        await CopyTo(udpClient, TempFilePath, openTaskCompletionSource, cancellationToken).ConfigureAwait(false);
-                    }
-                    catch (OperationCanceledException ex)
-                    {
-                        Logger.LogInformation("HDHR UDP stream cancelled or timed out from {0}", remoteAddress);
-                        openTaskCompletionSource.TrySetException(ex);
-                    }
-                    catch (Exception ex)
-                    {
-                        Logger.LogError(ex, "Error opening live stream:");
-                        openTaskCompletionSource.TrySetException(ex);
-                    }
-
-                    EnableStreamSharing = false;
+                    await CopyTo(udpClient, TempFilePath, openTaskCompletionSource, cancellationToken).ConfigureAwait(false);
+                }
+                catch (OperationCanceledException ex)
+                {
+                    Logger.LogInformation("HDHR UDP stream cancelled or timed out from {0}", remoteAddress);
+                    openTaskCompletionSource.TrySetException(ex);
                 }
+                catch (Exception ex)
+                {
+                    Logger.LogError(ex, "Error opening live stream:");
+                    openTaskCompletionSource.TrySetException(ex);
+                }
+
+                EnableStreamSharing = false;
+            }
 
-                await DeleteTempFiles(new List<string> { TempFilePath }).ConfigureAwait(false);
-            });
+            await DeleteTempFiles(new List<string> { TempFilePath }).ConfigureAwait(false);
         }
 
         private async Task CopyTo(UdpClient udpClient, string file, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken)

+ 5 - 5
Emby.Server.Implementations/Localization/Core/vi.json

@@ -16,7 +16,7 @@
     "Albums": "Albums",
     "Artists": "Các Nghệ Sĩ",
     "TaskDownloadMissingSubtitlesDescription": "Tìm kiếm phụ đề bị thiếu trên Internet dựa trên cấu hình dữ liệu mô tả.",
-    "TaskDownloadMissingSubtitles": "Tải xuống phụ đề bị thiếu",
+    "TaskDownloadMissingSubtitles": "Tải Xuống Phụ Đề Bị Thiếu",
     "TaskRefreshChannelsDescription": "Làm mới thông tin kênh internet.",
     "TaskRefreshChannels": "Làm Mới Kênh",
     "TaskCleanTranscodeDescription": "Xóa các tệp chuyển mã cũ hơn một ngày.",
@@ -24,11 +24,11 @@
     "TaskUpdatePluginsDescription": "Tải xuống và cài đặt các bản cập nhật cho các plugin được định cấu hình để cập nhật tự động.",
     "TaskUpdatePlugins": "Cập Nhật Plugins",
     "TaskRefreshPeopleDescription": "Cập nhật thông tin chi tiết cho diễn viên và đạo diễn trong thư viện phương tiện của bạn.",
-    "TaskRefreshPeople": "Làm mới Người dùng",
+    "TaskRefreshPeople": "Làm Mới Người Dùng",
     "TaskCleanLogsDescription": "Xóa tập tin nhật ký cũ hơn {0} ngày.",
-    "TaskCleanLogs": "Làm sạch nhật ký",
-    "TaskRefreshLibraryDescription": "Quét thư viện phương tiện của bạn để tìm các tệp mới và làm mới thông tin chi tiết.",
-    "TaskRefreshLibrary": "Quét Thư viện Phương tiện",
+    "TaskCleanLogs": "Làm Sạch Thư Mục Nhật Ký",
+    "TaskRefreshLibraryDescription": "Quét thư viện phương tiện của bạn để tìm tệp mới và làm mới dữ liệu mô tả.",
+    "TaskRefreshLibrary": "Quét Thư Viện Phương Tiện",
     "TaskRefreshChapterImagesDescription": "Tạo hình thu nhỏ cho video có các phân cảnh.",
     "TaskRefreshChapterImages": "Trích Xuất Ảnh Phân Cảnh",
     "TaskCleanCacheDescription": "Xóa các tệp cache không còn cần thiết của hệ thống.",

+ 84 - 14
Emby.Server.Implementations/Updates/InstallationManager.cs

@@ -93,17 +93,29 @@ namespace Emby.Server.Implementations.Updates
         public IEnumerable<InstallationInfo> CompletedInstallations => _completedInstallationsInternal;
 
         /// <inheritdoc />
-        public async Task<IReadOnlyList<PackageInfo>> GetPackages(string manifest, CancellationToken cancellationToken = default)
+        public async Task<IList<PackageInfo>> GetPackages(string manifestName, string manifest, CancellationToken cancellationToken = default)
         {
             try
             {
                 using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
-                    .GetAsync(manifest, cancellationToken).ConfigureAwait(false);
+                    .GetAsync(new Uri(manifest), cancellationToken).ConfigureAwait(false);
                 await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
 
                 try
                 {
-                    return await _jsonSerializer.DeserializeFromStreamAsync<IReadOnlyList<PackageInfo>>(stream).ConfigureAwait(false);
+                    var package = await _jsonSerializer.DeserializeFromStreamAsync<IList<PackageInfo>>(stream).ConfigureAwait(false);
+
+                    // Store the repository and repository url with each version, as they may be spread apart.
+                    foreach (var entry in package)
+                    {
+                        foreach (var ver in entry.versions)
+                        {
+                            ver.repositoryName = manifestName;
+                            ver.repositoryUrl = manifest;
+                        }
+                    }
+
+                    return package;
                 }
                 catch (SerializationException ex)
                 {
@@ -123,17 +135,69 @@ namespace Emby.Server.Implementations.Updates
             }
         }
 
+        private static void MergeSort(IList<VersionInfo> source, IList<VersionInfo> dest)
+        {
+            int sLength = source.Count - 1;
+            int dLength = dest.Count;
+            int s = 0, d = 0;
+            var sourceVersion = source[0].VersionNumber;
+            var destVersion = dest[0].VersionNumber;
+
+            while (d < dLength)
+            {
+                if (sourceVersion.CompareTo(destVersion) >= 0)
+                {
+                    if (s < sLength)
+                    {
+                        sourceVersion = source[++s].VersionNumber;
+                    }
+                    else
+                    {
+                        // Append all of destination to the end of source.
+                        while (d < dLength)
+                        {
+                            source.Add(dest[d++]);
+                        }
+
+                        break;
+                    }
+                }
+                else
+                {
+                    source.Insert(s++, dest[d++]);
+                    if (d >= dLength)
+                    {
+                        break;
+                    }
+
+                    sLength++;
+                    destVersion = dest[d].VersionNumber;
+                }
+            }
+        }
+
         /// <inheritdoc />
         public async Task<IReadOnlyList<PackageInfo>> GetAvailablePackages(CancellationToken cancellationToken = default)
         {
             var result = new List<PackageInfo>();
             foreach (RepositoryInfo repository in _config.Configuration.PluginRepositories)
             {
-                foreach (var package in await GetPackages(repository.Url, cancellationToken).ConfigureAwait(true))
+                if (repository.Enabled)
                 {
-                    package.repositoryName = repository.Name;
-                    package.repositoryUrl = repository.Url;
-                    result.Add(package);
+                    // Where repositories have the same content, the details of the first is taken.
+                    foreach (var package in await GetPackages(repository.Name, repository.Url, cancellationToken).ConfigureAwait(true))
+                    {
+                        var existing = FilterPackages(result, package.name, Guid.Parse(package.guid)).FirstOrDefault();
+                        if (existing != null)
+                        {
+                            // Assumption is both lists are ordered, so slot these into the correct place.
+                            MergeSort(existing.versions, package.versions);
+                        }
+                        else
+                        {
+                            result.Add(package);
+                        }
+                    }
                 }
             }
 
@@ -144,7 +208,8 @@ namespace Emby.Server.Implementations.Updates
         public IEnumerable<PackageInfo> FilterPackages(
             IEnumerable<PackageInfo> availablePackages,
             string name = null,
-            Guid guid = default)
+            Guid guid = default,
+            Version specificVersion = null)
         {
             if (name != null)
             {
@@ -156,6 +221,11 @@ namespace Emby.Server.Implementations.Updates
                 availablePackages = availablePackages.Where(x => Guid.Parse(x.guid) == guid);
             }
 
+            if (specificVersion != null)
+            {
+                availablePackages = availablePackages.Where(x => x.versions.Where(y => y.VersionNumber.Equals(specificVersion)).Any());
+            }
+
             return availablePackages;
         }
 
@@ -167,7 +237,7 @@ namespace Emby.Server.Implementations.Updates
             Version minVersion = null,
             Version specificVersion = null)
         {
-            var package = FilterPackages(availablePackages, name, guid).FirstOrDefault();
+            var package = FilterPackages(availablePackages, name, guid, specificVersion).FirstOrDefault();
 
             // Package not found in repository
             if (package == null)
@@ -181,21 +251,21 @@ namespace Emby.Server.Implementations.Updates
 
             if (specificVersion != null)
             {
-                availableVersions = availableVersions.Where(x => new Version(x.version) == specificVersion);
+                availableVersions = availableVersions.Where(x => x.VersionNumber.Equals(specificVersion));
             }
             else if (minVersion != null)
             {
-                availableVersions = availableVersions.Where(x => new Version(x.version) >= minVersion);
+                availableVersions = availableVersions.Where(x => x.VersionNumber >= minVersion);
             }
 
-            foreach (var v in availableVersions.OrderByDescending(x => x.version))
+            foreach (var v in availableVersions.OrderByDescending(x => x.VersionNumber))
             {
                 yield return new InstallationInfo
                 {
                     Changelog = v.changelog,
                     Guid = new Guid(package.guid),
                     Name = package.name,
-                    Version = new Version(v.version),
+                    Version = v.VersionNumber,
                     SourceUrl = v.sourceUrl,
                     Checksum = v.checksum
                 };
@@ -333,7 +403,7 @@ namespace Emby.Server.Implementations.Updates
             string targetDir = Path.Combine(_appPaths.PluginsPath, package.Name);
 
             using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
-                .GetAsync(package.SourceUrl, cancellationToken).ConfigureAwait(false);
+                .GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false);
             await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
 
             // CA5351: Do Not Use Broken Cryptographic Algorithms

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

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.ComponentModel.DataAnnotations;
 using System.Globalization;
 using Jellyfin.Api.Constants;

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

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.ComponentModel.DataAnnotations;
 using System.Linq;
 using Jellyfin.Api.Constants;

+ 166 - 3
Jellyfin.Api/Controllers/AudioController.cs

@@ -85,15 +85,178 @@ namespace Jellyfin.Api.Controllers
         /// <param name="streamOptions">Optional. The streaming options.</param>
         /// <response code="200">Audio stream returned.</response>
         /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
-        [HttpGet("{itemId}/stream.{container:required}", Name = "GetAudioStreamByContainer")]
         [HttpGet("{itemId}/stream", Name = "GetAudioStream")]
-        [HttpHead("{itemId}/stream.{container:required}", Name = "HeadAudioStreamByContainer")]
         [HttpHead("{itemId}/stream", Name = "HeadAudioStream")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesAudioFile]
         public async Task<ActionResult> GetAudioStream(
             [FromRoute, Required] Guid itemId,
-            [FromRoute] string? container,
+            [FromQuery] string? container,
+            [FromQuery] bool? @static,
+            [FromQuery] string? @params,
+            [FromQuery] string? tag,
+            [FromQuery] string? deviceProfileId,
+            [FromQuery] string? playSessionId,
+            [FromQuery] string? segmentContainer,
+            [FromQuery] int? segmentLength,
+            [FromQuery] int? minSegments,
+            [FromQuery] string? mediaSourceId,
+            [FromQuery] string? deviceId,
+            [FromQuery] string? audioCodec,
+            [FromQuery] bool? enableAutoStreamCopy,
+            [FromQuery] bool? allowVideoStreamCopy,
+            [FromQuery] bool? allowAudioStreamCopy,
+            [FromQuery] bool? breakOnNonKeyFrames,
+            [FromQuery] int? audioSampleRate,
+            [FromQuery] int? maxAudioBitDepth,
+            [FromQuery] int? audioBitRate,
+            [FromQuery] int? audioChannels,
+            [FromQuery] int? maxAudioChannels,
+            [FromQuery] string? profile,
+            [FromQuery] string? level,
+            [FromQuery] float? framerate,
+            [FromQuery] float? maxFramerate,
+            [FromQuery] bool? copyTimestamps,
+            [FromQuery] long? startTimeTicks,
+            [FromQuery] int? width,
+            [FromQuery] int? height,
+            [FromQuery] int? videoBitRate,
+            [FromQuery] int? subtitleStreamIndex,
+            [FromQuery] SubtitleDeliveryMethod subtitleMethod,
+            [FromQuery] int? maxRefFrames,
+            [FromQuery] int? maxVideoBitDepth,
+            [FromQuery] bool? requireAvc,
+            [FromQuery] bool? deInterlace,
+            [FromQuery] bool? requireNonAnamorphic,
+            [FromQuery] int? transcodingMaxAudioChannels,
+            [FromQuery] int? cpuCoreLimit,
+            [FromQuery] string? liveStreamId,
+            [FromQuery] bool? enableMpegtsM2TsMode,
+            [FromQuery] string? videoCodec,
+            [FromQuery] string? subtitleCodec,
+            [FromQuery] string? transcodingReasons,
+            [FromQuery] int? audioStreamIndex,
+            [FromQuery] int? videoStreamIndex,
+            [FromQuery] EncodingContext? context,
+            [FromQuery] Dictionary<string, string>? streamOptions)
+        {
+            StreamingRequestDto streamingRequest = new StreamingRequestDto
+            {
+                Id = itemId,
+                Container = container,
+                Static = @static ?? true,
+                Params = @params,
+                Tag = tag,
+                DeviceProfileId = deviceProfileId,
+                PlaySessionId = playSessionId,
+                SegmentContainer = segmentContainer,
+                SegmentLength = segmentLength,
+                MinSegments = minSegments,
+                MediaSourceId = mediaSourceId,
+                DeviceId = deviceId,
+                AudioCodec = audioCodec,
+                EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
+                AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
+                AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
+                BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
+                AudioSampleRate = audioSampleRate,
+                MaxAudioChannels = maxAudioChannels,
+                AudioBitRate = audioBitRate,
+                MaxAudioBitDepth = maxAudioBitDepth,
+                AudioChannels = audioChannels,
+                Profile = profile,
+                Level = level,
+                Framerate = framerate,
+                MaxFramerate = maxFramerate,
+                CopyTimestamps = copyTimestamps ?? true,
+                StartTimeTicks = startTimeTicks,
+                Width = width,
+                Height = height,
+                VideoBitRate = videoBitRate,
+                SubtitleStreamIndex = subtitleStreamIndex,
+                SubtitleMethod = subtitleMethod,
+                MaxRefFrames = maxRefFrames,
+                MaxVideoBitDepth = maxVideoBitDepth,
+                RequireAvc = requireAvc ?? true,
+                DeInterlace = deInterlace ?? true,
+                RequireNonAnamorphic = requireNonAnamorphic ?? true,
+                TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
+                CpuCoreLimit = cpuCoreLimit,
+                LiveStreamId = liveStreamId,
+                EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true,
+                VideoCodec = videoCodec,
+                SubtitleCodec = subtitleCodec,
+                TranscodeReasons = transcodingReasons,
+                AudioStreamIndex = audioStreamIndex,
+                VideoStreamIndex = videoStreamIndex,
+                Context = context ?? EncodingContext.Static,
+                StreamOptions = streamOptions
+            };
+
+            return await _audioHelper.GetAudioStream(_transcodingJobType, streamingRequest).ConfigureAwait(false);
+        }
+
+        /// <summary>
+        /// Gets an audio stream.
+        /// </summary>
+        /// <param name="itemId">The item id.</param>
+        /// <param name="container">The audio container.</param>
+        /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
+        /// <param name="params">The streaming parameters.</param>
+        /// <param name="tag">The tag.</param>
+        /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
+        /// <param name="playSessionId">The play session id.</param>
+        /// <param name="segmentContainer">The segment container.</param>
+        /// <param name="segmentLength">The segment lenght.</param>
+        /// <param name="minSegments">The minimum number of segments.</param>
+        /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
+        /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
+        /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
+        /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
+        /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
+        /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
+        /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
+        /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
+        /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
+        /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
+        /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
+        /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
+        /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
+        /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
+        /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+        /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+        /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
+        /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
+        /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
+        /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
+        /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
+        /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
+        /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
+        /// <param name="maxRefFrames">Optional.</param>
+        /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
+        /// <param name="requireAvc">Optional. Whether to require avc.</param>
+        /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
+        /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param>
+        /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
+        /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
+        /// <param name="liveStreamId">The live stream id.</param>
+        /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
+        /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param>
+        /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
+        /// <param name="transcodingReasons">Optional. The transcoding reason.</param>
+        /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
+        /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
+        /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
+        /// <param name="streamOptions">Optional. The streaming options.</param>
+        /// <response code="200">Audio stream returned.</response>
+        /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
+        [HttpGet("{itemId}/stream.{container}", Name = "GetAudioStreamByContainer")]
+        [HttpHead("{itemId}/stream.{container}", Name = "HeadAudioStreamByContainer")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesAudioFile]
+        public async Task<ActionResult> GetAudioStreamByContainer(
+            [FromRoute, Required] Guid itemId,
+            [FromRoute, Required] string container,
             [FromQuery] bool? @static,
             [FromQuery] string? @params,
             [FromQuery] string? tag,

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

@@ -1,4 +1,4 @@
-using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Configuration;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Model.Branding;
 using Microsoft.AspNetCore.Http;

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

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.ComponentModel.DataAnnotations;
 using System.Threading.Tasks;
 using Jellyfin.Api.Constants;

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

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.Collections.Generic;
 using System.IO;
 using System.Linq;

+ 2 - 2
Jellyfin.Api/Controllers/DynamicHlsController.cs

@@ -838,7 +838,7 @@ namespace Jellyfin.Api.Controllers
             [FromRoute, Required] Guid itemId,
             [FromRoute, Required] string playlistId,
             [FromRoute, Required] int segmentId,
-            [FromRoute] string container,
+            [FromRoute, Required] string container,
             [FromQuery] bool? @static,
             [FromQuery] string? @params,
             [FromQuery] string? tag,
@@ -1009,7 +1009,7 @@ namespace Jellyfin.Api.Controllers
             [FromRoute, Required] Guid itemId,
             [FromRoute, Required] string playlistId,
             [FromRoute, Required] int segmentId,
-            [FromRoute] string container,
+            [FromRoute, Required] string container,
             [FromQuery] bool? @static,
             [FromQuery] string? @params,
             [FromQuery] string? tag,

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

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.Linq;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.ModelBinders;

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

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.ComponentModel.DataAnnotations;
 using System.Linq;
 using Jellyfin.Api.Constants;

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

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.ComponentModel.DataAnnotations;
 using System.Diagnostics.CodeAnalysis;
 using System.IO;

+ 684 - 54
Jellyfin.Api/Controllers/ImageController.cs

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
 using System.Diagnostics.CodeAnalysis;
@@ -86,7 +86,6 @@ namespace Jellyfin.Api.Controllers
         /// <response code="403">User does not have permission to delete the image.</response>
         /// <returns>A <see cref="NoContentResult"/>.</returns>
         [HttpPost("Users/{userId}/Images/{imageType}")]
-        [HttpPost("Users/{userId}/Images/{imageType}/{index?}", Name = "PostUserImage_2")]
         [Authorize(Policy = Policies.DefaultAuthorization)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status403Forbidden)]
@@ -95,7 +94,53 @@ namespace Jellyfin.Api.Controllers
         public async Task<ActionResult> PostUserImage(
             [FromRoute, Required] Guid userId,
             [FromRoute, Required] ImageType imageType,
-            [FromRoute] int? index = null)
+            [FromQuery] int? index = null)
+        {
+            if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
+            {
+                return Forbid("User is not allowed to update the image.");
+            }
+
+            var user = _userManager.GetUserById(userId);
+            await using var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
+
+            // Handle image/png; charset=utf-8
+            var mimeType = Request.ContentType.Split(';').FirstOrDefault();
+            var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username);
+            if (user.ProfileImage != null)
+            {
+                await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
+            }
+
+            user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType)));
+
+            await _providerManager
+                .SaveImage(memoryStream, mimeType, user.ProfileImage.Path)
+                .ConfigureAwait(false);
+            await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Sets the user image.
+        /// </summary>
+        /// <param name="userId">User Id.</param>
+        /// <param name="imageType">(Unused) Image type.</param>
+        /// <param name="index">(Unused) Image index.</param>
+        /// <response code="204">Image updated.</response>
+        /// <response code="403">User does not have permission to delete the image.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpPost("Users/{userId}/Images/{imageType}/{index}")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status403Forbidden)]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
+        public async Task<ActionResult> PostUserImageByIndex(
+            [FromRoute, Required] Guid userId,
+            [FromRoute, Required] ImageType imageType,
+            [FromRoute] int index)
         {
             if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
             {
@@ -132,8 +177,7 @@ namespace Jellyfin.Api.Controllers
         /// <response code="204">Image deleted.</response>
         /// <response code="403">User does not have permission to delete the image.</response>
         /// <returns>A <see cref="NoContentResult"/>.</returns>
-        [HttpDelete("Users/{userId}/Images/{itemType}")]
-        [HttpDelete("Users/{userId}/Images/{itemType}/{index?}", Name = "DeleteUserImage_2")]
+        [HttpDelete("Users/{userId}/Images/{imageType}")]
         [Authorize(Policy = Policies.DefaultAuthorization)]
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
@@ -142,7 +186,46 @@ namespace Jellyfin.Api.Controllers
         public async Task<ActionResult> DeleteUserImage(
             [FromRoute, Required] Guid userId,
             [FromRoute, Required] ImageType imageType,
-            [FromRoute] int? index = null)
+            [FromQuery] int? index = null)
+        {
+            if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
+            {
+                return Forbid("User is not allowed to delete the image.");
+            }
+
+            var user = _userManager.GetUserById(userId);
+            try
+            {
+                System.IO.File.Delete(user.ProfileImage.Path);
+            }
+            catch (IOException e)
+            {
+                _logger.LogError(e, "Error deleting user profile image:");
+            }
+
+            await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Delete the user's image.
+        /// </summary>
+        /// <param name="userId">User Id.</param>
+        /// <param name="imageType">(Unused) Image type.</param>
+        /// <param name="index">(Unused) Image index.</param>
+        /// <response code="204">Image deleted.</response>
+        /// <response code="403">User does not have permission to delete the image.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpDelete("Users/{userId}/Images/{imageType}/{index}")]
+        [Authorize(Policy = Policies.DefaultAuthorization)]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status403Forbidden)]
+        public async Task<ActionResult> DeleteUserImageByIndex(
+            [FromRoute, Required] Guid userId,
+            [FromRoute, Required] ImageType imageType,
+            [FromRoute] int index)
         {
             if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
             {
@@ -173,14 +256,13 @@ namespace Jellyfin.Api.Controllers
         /// <response code="404">Item not found.</response>
         /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
         [HttpDelete("Items/{itemId}/Images/{imageType}")]
-        [HttpDelete("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "DeleteItemImage_2")]
         [Authorize(Policy = Policies.RequiresElevation)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         public async Task<ActionResult> DeleteItemImage(
             [FromRoute, Required] Guid itemId,
             [FromRoute, Required] ImageType imageType,
-            [FromRoute] int? imageIndex = null)
+            [FromQuery] int? imageIndex)
         {
             var item = _libraryManager.GetItemById(itemId);
             if (item == null)
@@ -192,25 +274,83 @@ namespace Jellyfin.Api.Controllers
             return NoContent();
         }
 
+        /// <summary>
+        /// Delete an item's image.
+        /// </summary>
+        /// <param name="itemId">Item id.</param>
+        /// <param name="imageType">Image type.</param>
+        /// <param name="imageIndex">The image index.</param>
+        /// <response code="204">Image deleted.</response>
+        /// <response code="404">Item not found.</response>
+        /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
+        [HttpDelete("Items/{itemId}/Images/{imageType}/{imageIndex}")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public async Task<ActionResult> DeleteItemImageByIndex(
+            [FromRoute, Required] Guid itemId,
+            [FromRoute, Required] ImageType imageType,
+            [FromRoute] int imageIndex)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            await item.DeleteImageAsync(imageType, imageIndex).ConfigureAwait(false);
+            return NoContent();
+        }
+
         /// <summary>
         /// Set item image.
         /// </summary>
         /// <param name="itemId">Item id.</param>
         /// <param name="imageType">Image type.</param>
-        /// <param name="imageIndex">(Unused) Image index.</param>
         /// <response code="204">Image saved.</response>
         /// <response code="404">Item not found.</response>
         /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
         [HttpPost("Items/{itemId}/Images/{imageType}")]
-        [HttpPost("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "SetItemImage_2")]
         [Authorize(Policy = Policies.RequiresElevation)]
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
         public async Task<ActionResult> SetItemImage(
+            [FromRoute, Required] Guid itemId,
+            [FromRoute, Required] ImageType imageType)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            // Handle image/png; charset=utf-8
+            var mimeType = Request.ContentType.Split(';').FirstOrDefault();
+            await _providerManager.SaveImage(item, Request.Body, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false);
+            await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
+
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Set item image.
+        /// </summary>
+        /// <param name="itemId">Item id.</param>
+        /// <param name="imageType">Image type.</param>
+        /// <param name="imageIndex">(Unused) Image index.</param>
+        /// <response code="204">Image saved.</response>
+        /// <response code="404">Item not found.</response>
+        /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
+        [HttpPost("Items/{itemId}/Images/{imageType}/{imageIndex}")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
+        public async Task<ActionResult> SetItemImageByIndex(
             [FromRoute, Required] Guid itemId,
             [FromRoute, Required] ImageType imageType,
-            [FromRoute] int? imageIndex = null)
+            [FromRoute] int imageIndex)
         {
             var item = _libraryManager.GetItemById(itemId);
             if (item == null)
@@ -350,8 +490,6 @@ namespace Jellyfin.Api.Controllers
         /// </returns>
         [HttpGet("Items/{itemId}/Images/{imageType}")]
         [HttpHead("Items/{itemId}/Images/{imageType}", Name = "HeadItemImage")]
-        [HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "GetItemImage_2")]
-        [HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "HeadItemImage_2")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesImageFile]
@@ -372,7 +510,86 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? blur,
             [FromQuery] string? backgroundColor,
             [FromQuery] string? foregroundLayer,
-            [FromRoute] int? imageIndex = null)
+            [FromQuery] int? imageIndex)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            return await GetImageInternal(
+                    itemId,
+                    imageType,
+                    imageIndex,
+                    tag,
+                    format,
+                    maxWidth,
+                    maxHeight,
+                    percentPlayed,
+                    unplayedCount,
+                    width,
+                    height,
+                    quality,
+                    cropWhitespace,
+                    addPlayedIndicator,
+                    blur,
+                    backgroundColor,
+                    foregroundLayer,
+                    item,
+                    Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase))
+                .ConfigureAwait(false);
+        }
+
+        /// <summary>
+        /// Gets the item's image.
+        /// </summary>
+        /// <param name="itemId">Item id.</param>
+        /// <param name="imageType">Image type.</param>
+        /// <param name="imageIndex">Image index.</param>
+        /// <param name="maxWidth">The maximum image width to return.</param>
+        /// <param name="maxHeight">The maximum image height to return.</param>
+        /// <param name="width">The fixed image width to return.</param>
+        /// <param name="height">The fixed image height to return.</param>
+        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+        /// <param name="format">Optional. The <see cref="ImageFormat"/> of the returned image.</param>
+        /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
+        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+        /// <param name="blur">Optional. Blur image.</param>
+        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+        /// <response code="200">Image stream returned.</response>
+        /// <response code="404">Item not found.</response>
+        /// <returns>
+        /// A <see cref="FileStreamResult"/> containing the file stream on success,
+        /// or a <see cref="NotFoundResult"/> if item not found.
+        /// </returns>
+        [HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex}")]
+        [HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex}", Name = "HeadItemImageByIndex")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        [ProducesImageFile]
+        public async Task<ActionResult> GetItemImageByIndex(
+            [FromRoute, Required] Guid itemId,
+            [FromRoute, Required] ImageType imageType,
+            [FromRoute] int imageIndex,
+            [FromQuery] int? maxWidth,
+            [FromQuery] int? maxHeight,
+            [FromQuery] int? width,
+            [FromQuery] int? height,
+            [FromQuery] int? quality,
+            [FromQuery] string? tag,
+            [FromQuery] bool? cropWhitespace,
+            [FromQuery] ImageFormat? format,
+            [FromQuery] bool? addPlayedIndicator,
+            [FromQuery] double? percentPlayed,
+            [FromQuery] int? unplayedCount,
+            [FromQuery] int? blur,
+            [FromQuery] string? backgroundColor,
+            [FromQuery] string? foregroundLayer)
         {
             var item = _libraryManager.GetItemById(itemId);
             if (item == null)
@@ -508,8 +725,8 @@ namespace Jellyfin.Api.Controllers
         /// A <see cref="FileStreamResult"/> containing the file stream on success,
         /// or a <see cref="NotFoundResult"/> if item not found.
         /// </returns>
-        [HttpGet("Artists/{name}/Images/{imageType}/{imageIndex?}")]
-        [HttpHead("Artists/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadArtistImage")]
+        [HttpGet("Artists/{name}/Images/{imageType}/{imageIndex}")]
+        [HttpHead("Artists/{name}/Images/{imageType}/{imageIndex}", Name = "HeadArtistImage")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesImageFile]
@@ -587,8 +804,8 @@ namespace Jellyfin.Api.Controllers
         /// A <see cref="FileStreamResult"/> containing the file stream on success,
         /// or a <see cref="NotFoundResult"/> if item not found.
         /// </returns>
-        [HttpGet("Genres/{name}/Images/{imageType}/{imageIndex?}")]
-        [HttpHead("Genres/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadGenreImage")]
+        [HttpGet("Genres/{name}/Images/{imageType}")]
+        [HttpHead("Genres/{name}/Images/{imageType}", Name = "HeadGenreImage")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesImageFile]
@@ -609,7 +826,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? blur,
             [FromQuery] string? backgroundColor,
             [FromQuery] string? foregroundLayer,
-            [FromRoute] int? imageIndex = null)
+            [FromQuery] int? imageIndex)
         {
             var item = _libraryManager.GetGenre(name);
             if (item == null)
@@ -641,10 +858,11 @@ namespace Jellyfin.Api.Controllers
         }
 
         /// <summary>
-        /// Get music genre image by name.
+        /// Get genre image by name.
         /// </summary>
-        /// <param name="name">Music genre name.</param>
+        /// <param name="name">Genre name.</param>
         /// <param name="imageType">Image type.</param>
+        /// <param name="imageIndex">Image index.</param>
         /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
         /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
         /// <param name="maxWidth">The maximum image width to return.</param>
@@ -659,21 +877,21 @@ namespace Jellyfin.Api.Controllers
         /// <param name="blur">Optional. Blur image.</param>
         /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
         /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
-        /// <param name="imageIndex">Image index.</param>
         /// <response code="200">Image stream returned.</response>
         /// <response code="404">Item not found.</response>
         /// <returns>
         /// A <see cref="FileStreamResult"/> containing the file stream on success,
         /// or a <see cref="NotFoundResult"/> if item not found.
         /// </returns>
-        [HttpGet("MusicGenres/{name}/Images/{imageType}/{imageIndex?}")]
-        [HttpHead("MusicGenres/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadMusicGenreImage")]
+        [HttpGet("Genres/{name}/Images/{imageType}/{imageIndex}")]
+        [HttpHead("Genres/{name}/Images/{imageType}/{imageIndex}", Name = "HeadGenreImageByIndex")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesImageFile]
-        public async Task<ActionResult> GetMusicGenreImage(
+        public async Task<ActionResult> GetGenreImageByIndex(
             [FromRoute, Required] string name,
             [FromRoute, Required] ImageType imageType,
+            [FromRoute, Required] int imageIndex,
             [FromQuery] string tag,
             [FromQuery] ImageFormat? format,
             [FromQuery] int? maxWidth,
@@ -687,10 +905,9 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? addPlayedIndicator,
             [FromQuery] int? blur,
             [FromQuery] string? backgroundColor,
-            [FromQuery] string? foregroundLayer,
-            [FromRoute] int? imageIndex = null)
+            [FromQuery] string? foregroundLayer)
         {
-            var item = _libraryManager.GetMusicGenre(name);
+            var item = _libraryManager.GetGenre(name);
             if (item == null)
             {
                 return NotFound();
@@ -720,9 +937,9 @@ namespace Jellyfin.Api.Controllers
         }
 
         /// <summary>
-        /// Get person image by name.
+        /// Get music genre image by name.
         /// </summary>
-        /// <param name="name">Person name.</param>
+        /// <param name="name">Music genre name.</param>
         /// <param name="imageType">Image type.</param>
         /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
         /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
@@ -745,12 +962,12 @@ namespace Jellyfin.Api.Controllers
         /// A <see cref="FileStreamResult"/> containing the file stream on success,
         /// or a <see cref="NotFoundResult"/> if item not found.
         /// </returns>
-        [HttpGet("Persons/{name}/Images/{imageType}/{imageIndex?}")]
-        [HttpHead("Persons/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadPersonImage")]
+        [HttpGet("MusicGenres/{name}/Images/{imageType}")]
+        [HttpHead("MusicGenres/{name}/Images/{imageType}", Name = "HeadMusicGenreImage")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesImageFile]
-        public async Task<ActionResult> GetPersonImage(
+        public async Task<ActionResult> GetMusicGenreImage(
             [FromRoute, Required] string name,
             [FromRoute, Required] ImageType imageType,
             [FromQuery] string tag,
@@ -767,9 +984,9 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? blur,
             [FromQuery] string? backgroundColor,
             [FromQuery] string? foregroundLayer,
-            [FromRoute] int? imageIndex = null)
+            [FromQuery] int? imageIndex)
         {
-            var item = _libraryManager.GetPerson(name);
+            var item = _libraryManager.GetMusicGenre(name);
             if (item == null)
             {
                 return NotFound();
@@ -799,10 +1016,11 @@ namespace Jellyfin.Api.Controllers
         }
 
         /// <summary>
-        /// Get studio image by name.
+        /// Get music genre image by name.
         /// </summary>
-        /// <param name="name">Studio name.</param>
+        /// <param name="name">Music genre name.</param>
         /// <param name="imageType">Image type.</param>
+        /// <param name="imageIndex">Image index.</param>
         /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
         /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
         /// <param name="maxWidth">The maximum image width to return.</param>
@@ -817,23 +1035,23 @@ namespace Jellyfin.Api.Controllers
         /// <param name="blur">Optional. Blur image.</param>
         /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
         /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
-        /// <param name="imageIndex">Image index.</param>
         /// <response code="200">Image stream returned.</response>
         /// <response code="404">Item not found.</response>
         /// <returns>
         /// A <see cref="FileStreamResult"/> containing the file stream on success,
         /// or a <see cref="NotFoundResult"/> if item not found.
         /// </returns>
-        [HttpGet("Studios/{name}/Images/{imageType}/{imageIndex?}")]
-        [HttpHead("Studios/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadStudioImage")]
+        [HttpGet("MusicGenres/{name}/Images/{imageType}/{imageIndex}")]
+        [HttpHead("MusicGenres/{name}/Images/{imageType}/{imageIndex}", Name = "HeadMusicGenreImageByIndex")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesImageFile]
-        public async Task<ActionResult> GetStudioImage(
+        public async Task<ActionResult> GetMusicGenreImageByIndex(
             [FromRoute, Required] string name,
             [FromRoute, Required] ImageType imageType,
-            [FromRoute, Required] string tag,
-            [FromRoute, Required] ImageFormat format,
+            [FromRoute, Required] int imageIndex,
+            [FromQuery] string tag,
+            [FromQuery] ImageFormat? format,
             [FromQuery] int? maxWidth,
             [FromQuery] int? maxHeight,
             [FromQuery] double? percentPlayed,
@@ -845,10 +1063,9 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool? addPlayedIndicator,
             [FromQuery] int? blur,
             [FromQuery] string? backgroundColor,
-            [FromQuery] string? foregroundLayer,
-            [FromRoute] int? imageIndex = null)
+            [FromQuery] string? foregroundLayer)
         {
-            var item = _libraryManager.GetStudio(name);
+            var item = _libraryManager.GetMusicGenre(name);
             if (item == null)
             {
                 return NotFound();
@@ -878,9 +1095,9 @@ namespace Jellyfin.Api.Controllers
         }
 
         /// <summary>
-        /// Get user profile image.
+        /// Get person image by name.
         /// </summary>
-        /// <param name="userId">User id.</param>
+        /// <param name="name">Person name.</param>
         /// <param name="imageType">Image type.</param>
         /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
         /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
@@ -903,15 +1120,15 @@ namespace Jellyfin.Api.Controllers
         /// A <see cref="FileStreamResult"/> containing the file stream on success,
         /// or a <see cref="NotFoundResult"/> if item not found.
         /// </returns>
-        [HttpGet("Users/{userId}/Images/{imageType}/{imageIndex?}")]
-        [HttpHead("Users/{userId}/Images/{imageType}/{imageIndex?}", Name = "HeadUserImage")]
+        [HttpGet("Persons/{name}/Images/{imageType}")]
+        [HttpHead("Persons/{name}/Images/{imageType}", Name = "HeadPersonImage")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         [ProducesImageFile]
-        public async Task<ActionResult> GetUserImage(
-            [FromRoute, Required] Guid userId,
+        public async Task<ActionResult> GetPersonImage(
+            [FromRoute, Required] string name,
             [FromRoute, Required] ImageType imageType,
-            [FromQuery] string? tag,
+            [FromQuery] string tag,
             [FromQuery] ImageFormat? format,
             [FromQuery] int? maxWidth,
             [FromQuery] int? maxHeight,
@@ -925,7 +1142,420 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? blur,
             [FromQuery] string? backgroundColor,
             [FromQuery] string? foregroundLayer,
-            [FromRoute] int? imageIndex = null)
+            [FromQuery] int? imageIndex)
+        {
+            var item = _libraryManager.GetPerson(name);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            return await GetImageInternal(
+                    item.Id,
+                    imageType,
+                    imageIndex,
+                    tag,
+                    format,
+                    maxWidth,
+                    maxHeight,
+                    percentPlayed,
+                    unplayedCount,
+                    width,
+                    height,
+                    quality,
+                    cropWhitespace,
+                    addPlayedIndicator,
+                    blur,
+                    backgroundColor,
+                    foregroundLayer,
+                    item,
+                    Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase))
+                .ConfigureAwait(false);
+        }
+
+        /// <summary>
+        /// Get person image by name.
+        /// </summary>
+        /// <param name="name">Person name.</param>
+        /// <param name="imageType">Image type.</param>
+        /// <param name="imageIndex">Image index.</param>
+        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+        /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+        /// <param name="maxWidth">The maximum image width to return.</param>
+        /// <param name="maxHeight">The maximum image height to return.</param>
+        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+        /// <param name="width">The fixed image width to return.</param>
+        /// <param name="height">The fixed image height to return.</param>
+        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+        /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
+        /// <param name="blur">Optional. Blur image.</param>
+        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+        /// <response code="200">Image stream returned.</response>
+        /// <response code="404">Item not found.</response>
+        /// <returns>
+        /// A <see cref="FileStreamResult"/> containing the file stream on success,
+        /// or a <see cref="NotFoundResult"/> if item not found.
+        /// </returns>
+        [HttpGet("Persons/{name}/Images/{imageType}/{imageIndex}")]
+        [HttpHead("Persons/{name}/Images/{imageType}/{imageIndex}", Name = "HeadPersonImageByIndex")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        [ProducesImageFile]
+        public async Task<ActionResult> GetPersonImageByIndex(
+            [FromRoute, Required] string name,
+            [FromRoute, Required] ImageType imageType,
+            [FromRoute, Required] int imageIndex,
+            [FromQuery] string tag,
+            [FromQuery] ImageFormat? format,
+            [FromQuery] int? maxWidth,
+            [FromQuery] int? maxHeight,
+            [FromQuery] double? percentPlayed,
+            [FromQuery] int? unplayedCount,
+            [FromQuery] int? width,
+            [FromQuery] int? height,
+            [FromQuery] int? quality,
+            [FromQuery] bool? cropWhitespace,
+            [FromQuery] bool? addPlayedIndicator,
+            [FromQuery] int? blur,
+            [FromQuery] string? backgroundColor,
+            [FromQuery] string? foregroundLayer)
+        {
+            var item = _libraryManager.GetPerson(name);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            return await GetImageInternal(
+                    item.Id,
+                    imageType,
+                    imageIndex,
+                    tag,
+                    format,
+                    maxWidth,
+                    maxHeight,
+                    percentPlayed,
+                    unplayedCount,
+                    width,
+                    height,
+                    quality,
+                    cropWhitespace,
+                    addPlayedIndicator,
+                    blur,
+                    backgroundColor,
+                    foregroundLayer,
+                    item,
+                    Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase))
+                .ConfigureAwait(false);
+        }
+
+        /// <summary>
+        /// Get studio image by name.
+        /// </summary>
+        /// <param name="name">Studio name.</param>
+        /// <param name="imageType">Image type.</param>
+        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+        /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+        /// <param name="maxWidth">The maximum image width to return.</param>
+        /// <param name="maxHeight">The maximum image height to return.</param>
+        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+        /// <param name="width">The fixed image width to return.</param>
+        /// <param name="height">The fixed image height to return.</param>
+        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+        /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
+        /// <param name="blur">Optional. Blur image.</param>
+        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+        /// <param name="imageIndex">Image index.</param>
+        /// <response code="200">Image stream returned.</response>
+        /// <response code="404">Item not found.</response>
+        /// <returns>
+        /// A <see cref="FileStreamResult"/> containing the file stream on success,
+        /// or a <see cref="NotFoundResult"/> if item not found.
+        /// </returns>
+        [HttpGet("Studios/{name}/Images/{imageType}")]
+        [HttpHead("Studios/{name}/Images/{imageType}", Name = "HeadStudioImage")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        [ProducesImageFile]
+        public async Task<ActionResult> GetStudioImage(
+            [FromRoute, Required] string name,
+            [FromRoute, Required] ImageType imageType,
+            [FromQuery] string? tag,
+            [FromQuery] ImageFormat? format,
+            [FromQuery] int? maxWidth,
+            [FromQuery] int? maxHeight,
+            [FromQuery] double? percentPlayed,
+            [FromQuery] int? unplayedCount,
+            [FromQuery] int? width,
+            [FromQuery] int? height,
+            [FromQuery] int? quality,
+            [FromQuery] bool? cropWhitespace,
+            [FromQuery] bool? addPlayedIndicator,
+            [FromQuery] int? blur,
+            [FromQuery] string? backgroundColor,
+            [FromQuery] string? foregroundLayer,
+            [FromQuery] int? imageIndex)
+        {
+            var item = _libraryManager.GetStudio(name);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            return await GetImageInternal(
+                    item.Id,
+                    imageType,
+                    imageIndex,
+                    tag,
+                    format,
+                    maxWidth,
+                    maxHeight,
+                    percentPlayed,
+                    unplayedCount,
+                    width,
+                    height,
+                    quality,
+                    cropWhitespace,
+                    addPlayedIndicator,
+                    blur,
+                    backgroundColor,
+                    foregroundLayer,
+                    item,
+                    Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase))
+                .ConfigureAwait(false);
+        }
+
+        /// <summary>
+        /// Get studio image by name.
+        /// </summary>
+        /// <param name="name">Studio name.</param>
+        /// <param name="imageType">Image type.</param>
+        /// <param name="imageIndex">Image index.</param>
+        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+        /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+        /// <param name="maxWidth">The maximum image width to return.</param>
+        /// <param name="maxHeight">The maximum image height to return.</param>
+        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+        /// <param name="width">The fixed image width to return.</param>
+        /// <param name="height">The fixed image height to return.</param>
+        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+        /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
+        /// <param name="blur">Optional. Blur image.</param>
+        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+        /// <response code="200">Image stream returned.</response>
+        /// <response code="404">Item not found.</response>
+        /// <returns>
+        /// A <see cref="FileStreamResult"/> containing the file stream on success,
+        /// or a <see cref="NotFoundResult"/> if item not found.
+        /// </returns>
+        [HttpGet("Studios/{name}/Images/{imageType}/{imageIndex}")]
+        [HttpHead("Studios/{name}/Images/{imageType}/{imageIndex}", Name = "HeadStudioImageByIndex")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        [ProducesImageFile]
+        public async Task<ActionResult> GetStudioImageByIndex(
+            [FromRoute, Required] string name,
+            [FromRoute, Required] ImageType imageType,
+            [FromRoute, Required] int imageIndex,
+            [FromQuery] string? tag,
+            [FromQuery] ImageFormat? format,
+            [FromQuery] int? maxWidth,
+            [FromQuery] int? maxHeight,
+            [FromQuery] double? percentPlayed,
+            [FromQuery] int? unplayedCount,
+            [FromQuery] int? width,
+            [FromQuery] int? height,
+            [FromQuery] int? quality,
+            [FromQuery] bool? cropWhitespace,
+            [FromQuery] bool? addPlayedIndicator,
+            [FromQuery] int? blur,
+            [FromQuery] string? backgroundColor,
+            [FromQuery] string? foregroundLayer)
+        {
+            var item = _libraryManager.GetStudio(name);
+            if (item == null)
+            {
+                return NotFound();
+            }
+
+            return await GetImageInternal(
+                    item.Id,
+                    imageType,
+                    imageIndex,
+                    tag,
+                    format,
+                    maxWidth,
+                    maxHeight,
+                    percentPlayed,
+                    unplayedCount,
+                    width,
+                    height,
+                    quality,
+                    cropWhitespace,
+                    addPlayedIndicator,
+                    blur,
+                    backgroundColor,
+                    foregroundLayer,
+                    item,
+                    Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase))
+                .ConfigureAwait(false);
+        }
+
+        /// <summary>
+        /// Get user profile image.
+        /// </summary>
+        /// <param name="userId">User id.</param>
+        /// <param name="imageType">Image type.</param>
+        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+        /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+        /// <param name="maxWidth">The maximum image width to return.</param>
+        /// <param name="maxHeight">The maximum image height to return.</param>
+        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+        /// <param name="width">The fixed image width to return.</param>
+        /// <param name="height">The fixed image height to return.</param>
+        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+        /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
+        /// <param name="blur">Optional. Blur image.</param>
+        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+        /// <param name="imageIndex">Image index.</param>
+        /// <response code="200">Image stream returned.</response>
+        /// <response code="404">Item not found.</response>
+        /// <returns>
+        /// A <see cref="FileStreamResult"/> containing the file stream on success,
+        /// or a <see cref="NotFoundResult"/> if item not found.
+        /// </returns>
+        [HttpGet("Users/{userId}/Images/{imageType}")]
+        [HttpHead("Users/{userId}/Images/{imageType}", Name = "HeadUserImage")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        [ProducesImageFile]
+        public async Task<ActionResult> GetUserImage(
+            [FromRoute, Required] Guid userId,
+            [FromRoute, Required] ImageType imageType,
+            [FromQuery] string? tag,
+            [FromQuery] ImageFormat? format,
+            [FromQuery] int? maxWidth,
+            [FromQuery] int? maxHeight,
+            [FromQuery] double? percentPlayed,
+            [FromQuery] int? unplayedCount,
+            [FromQuery] int? width,
+            [FromQuery] int? height,
+            [FromQuery] int? quality,
+            [FromQuery] bool? cropWhitespace,
+            [FromQuery] bool? addPlayedIndicator,
+            [FromQuery] int? blur,
+            [FromQuery] string? backgroundColor,
+            [FromQuery] string? foregroundLayer,
+            [FromQuery] int? imageIndex)
+        {
+            var user = _userManager.GetUserById(userId);
+            if (user == null)
+            {
+                return NotFound();
+            }
+
+            var info = new ItemImageInfo
+            {
+                Path = user.ProfileImage.Path,
+                Type = ImageType.Profile,
+                DateModified = user.ProfileImage.LastModified
+            };
+
+            if (width.HasValue)
+            {
+                info.Width = width.Value;
+            }
+
+            if (height.HasValue)
+            {
+                info.Height = height.Value;
+            }
+
+            return await GetImageInternal(
+                    user.Id,
+                    imageType,
+                    imageIndex,
+                    tag,
+                    format,
+                    maxWidth,
+                    maxHeight,
+                    percentPlayed,
+                    unplayedCount,
+                    width,
+                    height,
+                    quality,
+                    cropWhitespace,
+                    addPlayedIndicator,
+                    blur,
+                    backgroundColor,
+                    foregroundLayer,
+                    null,
+                    Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase),
+                    info)
+                .ConfigureAwait(false);
+        }
+
+        /// <summary>
+        /// Get user profile image.
+        /// </summary>
+        /// <param name="userId">User id.</param>
+        /// <param name="imageType">Image type.</param>
+        /// <param name="imageIndex">Image index.</param>
+        /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+        /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+        /// <param name="maxWidth">The maximum image width to return.</param>
+        /// <param name="maxHeight">The maximum image height to return.</param>
+        /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+        /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+        /// <param name="width">The fixed image width to return.</param>
+        /// <param name="height">The fixed image height to return.</param>
+        /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+        /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+        /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
+        /// <param name="blur">Optional. Blur image.</param>
+        /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+        /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+        /// <response code="200">Image stream returned.</response>
+        /// <response code="404">Item not found.</response>
+        /// <returns>
+        /// A <see cref="FileStreamResult"/> containing the file stream on success,
+        /// or a <see cref="NotFoundResult"/> if item not found.
+        /// </returns>
+        [HttpGet("Users/{userId}/Images/{imageType}/{imageIndex}")]
+        [HttpHead("Users/{userId}/Images/{imageType}/{imageIndex}", Name = "HeadUserImageByIndex")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        [ProducesImageFile]
+        public async Task<ActionResult> GetUserImageByIndex(
+            [FromRoute, Required] Guid userId,
+            [FromRoute, Required] ImageType imageType,
+            [FromRoute, Required] int imageIndex,
+            [FromQuery] string? tag,
+            [FromQuery] ImageFormat? format,
+            [FromQuery] int? maxWidth,
+            [FromQuery] int? maxHeight,
+            [FromQuery] double? percentPlayed,
+            [FromQuery] int? unplayedCount,
+            [FromQuery] int? width,
+            [FromQuery] int? height,
+            [FromQuery] int? quality,
+            [FromQuery] bool? cropWhitespace,
+            [FromQuery] bool? addPlayedIndicator,
+            [FromQuery] int? blur,
+            [FromQuery] string? backgroundColor,
+            [FromQuery] string? foregroundLayer)
         {
             var user = _userManager.GetUserById(userId);
             if (user?.ProfileImage == null)

+ 3 - 3
Jellyfin.Api/Controllers/InstantMixController.cs

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
 using System.Linq;
@@ -206,7 +206,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
         /// <response code="200">Instant playlist returned.</response>
         /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
-        [HttpGet("Artists/InstantMix")]
+        [HttpGet("Artists/{id}/InstantMix")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists(
             [FromRoute, Required] Guid id,
@@ -242,7 +242,7 @@ namespace Jellyfin.Api.Controllers
         /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
         /// <response code="200">Instant playlist returned.</response>
         /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
-        [HttpGet("MusicGenres/InstantMix")]
+        [HttpGet("MusicGenres/{id}/InstantMix")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenres(
             [FromRoute, Required] Guid id,

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

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
 using System.IO;

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

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
 using System.Linq;

+ 251 - 6
Jellyfin.Api/Controllers/ItemsController.cs

@@ -60,7 +60,6 @@ namespace Jellyfin.Api.Controllers
         /// <summary>
         /// Gets items based on a query.
         /// </summary>
-        /// <param name="uId">The user id supplied in the /Users/{uid}/Items.</param>
         /// <param name="userId">The user id supplied as query parameter.</param>
         /// <param name="maxOfficialRating">Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).</param>
         /// <param name="hasThemeSong">Optional filter by items with theme songs.</param>
@@ -143,10 +142,8 @@ namespace Jellyfin.Api.Controllers
         /// <param name="enableImages">Optional, include image information in output.</param>
         /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items.</returns>
         [HttpGet("Items")]
-        [HttpGet("Users/{uId}/Items", Name = "GetItems_2")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         public ActionResult<QueryResult<BaseItemDto>> GetItems(
-            [FromRoute] Guid? uId,
             [FromQuery] Guid? userId,
             [FromQuery] string? maxOfficialRating,
             [FromQuery] bool? hasThemeSong,
@@ -228,9 +225,6 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] bool enableTotalRecordCount = true,
             [FromQuery] bool? enableImages = true)
         {
-            // use user id route parameter over query parameter
-            userId = uId ?? userId;
-
             var user = userId.HasValue && !userId.Equals(Guid.Empty)
                 ? _userManager.GetUserById(userId.Value)
                 : null;
@@ -502,6 +496,257 @@ namespace Jellyfin.Api.Controllers
             return new QueryResult<BaseItemDto> { StartIndex = startIndex.GetValueOrDefault(), TotalRecordCount = result.TotalRecordCount, Items = _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user) };
         }
 
+        /// <summary>
+        /// Gets items based on a query.
+        /// </summary>
+        /// <param name="userId">The user id supplied as query parameter.</param>
+        /// <param name="maxOfficialRating">Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).</param>
+        /// <param name="hasThemeSong">Optional filter by items with theme songs.</param>
+        /// <param name="hasThemeVideo">Optional filter by items with theme videos.</param>
+        /// <param name="hasSubtitles">Optional filter by items with subtitles.</param>
+        /// <param name="hasSpecialFeature">Optional filter by items with special features.</param>
+        /// <param name="hasTrailer">Optional filter by items with trailers.</param>
+        /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param>
+        /// <param name="parentIndexNumber">Optional filter by parent index number.</param>
+        /// <param name="hasParentalRating">Optional filter by items that have or do not have a parental rating.</param>
+        /// <param name="isHd">Optional filter by items that are HD or not.</param>
+        /// <param name="is4K">Optional filter by items that are 4K or not.</param>
+        /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimeted.</param>
+        /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimeted.</param>
+        /// <param name="isMissing">Optional filter by items that are missing episodes or not.</param>
+        /// <param name="isUnaired">Optional filter by items that are unaired episodes or not.</param>
+        /// <param name="minCommunityRating">Optional filter by minimum community rating.</param>
+        /// <param name="minCriticRating">Optional filter by minimum critic rating.</param>
+        /// <param name="minPremiereDate">Optional. The minimum premiere date. Format = ISO.</param>
+        /// <param name="minDateLastSaved">Optional. The minimum last saved date. Format = ISO.</param>
+        /// <param name="minDateLastSavedForUser">Optional. The minimum last saved date for the current user. Format = ISO.</param>
+        /// <param name="maxPremiereDate">Optional. The maximum premiere date. Format = ISO.</param>
+        /// <param name="hasOverview">Optional filter by items that have an overview or not.</param>
+        /// <param name="hasImdbId">Optional filter by items that have an imdb id or not.</param>
+        /// <param name="hasTmdbId">Optional filter by items that have a tmdb id or not.</param>
+        /// <param name="hasTvdbId">Optional filter by items that have a tvdb id or not.</param>
+        /// <param name="excludeItemIds">Optional. If specified, results will be filtered by exxcluding item ids. This allows multiple, comma delimeted.</param>
+        /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+        /// <param name="limit">Optional. The maximum number of records to return.</param>
+        /// <param name="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param>
+        /// <param name="searchTerm">Optional. Filter based on a search term.</param>
+        /// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
+        /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+        /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
+        /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.</param>
+        /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimeted.</param>
+        /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimeted. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param>
+        /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
+        /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
+        /// <param name="imageTypes">Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.</param>
+        /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimeted. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
+        /// <param name="isPlayed">Optional filter by items that are played, or not.</param>
+        /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimeted.</param>
+        /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimeted.</param>
+        /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimeted.</param>
+        /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimeted.</param>
+        /// <param name="enableUserData">Optional, include user data.</param>
+        /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
+        /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+        /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
+        /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param>
+        /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
+        /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimeted.</param>
+        /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimeted.</param>
+        /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimeted.</param>
+        /// <param name="artistIds">Optional. If specified, results will be filtered to include only those containing the specified artist id.</param>
+        /// <param name="albumArtistIds">Optional. If specified, results will be filtered to include only those containing the specified album artist id.</param>
+        /// <param name="contributingArtistIds">Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.</param>
+        /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimeted.</param>
+        /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimeted.</param>
+        /// <param name="ids">Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.</param>
+        /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimeted.</param>
+        /// <param name="minOfficialRating">Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).</param>
+        /// <param name="isLocked">Optional filter by items that are locked.</param>
+        /// <param name="isPlaceHolder">Optional filter by items that are placeholders.</param>
+        /// <param name="hasOfficialRating">Optional filter by items that have official ratings.</param>
+        /// <param name="collapseBoxSetItems">Whether or not to hide items behind their boxsets.</param>
+        /// <param name="minWidth">Optional. Filter by the minimum width of the item.</param>
+        /// <param name="minHeight">Optional. Filter by the minimum height of the item.</param>
+        /// <param name="maxWidth">Optional. Filter by the maximum width of the item.</param>
+        /// <param name="maxHeight">Optional. Filter by the maximum height of the item.</param>
+        /// <param name="is3D">Optional filter by items that are 3D, or not.</param>
+        /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimeted.</param>
+        /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
+        /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
+        /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
+        /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimeted.</param>
+        /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimeted.</param>
+        /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param>
+        /// <param name="enableImages">Optional, include image information in output.</param>
+        /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items.</returns>
+        [HttpGet("Users/{userId}/Items")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<QueryResult<BaseItemDto>> GetItemsByUserId(
+            [FromRoute] Guid userId,
+            [FromQuery] string? maxOfficialRating,
+            [FromQuery] bool? hasThemeSong,
+            [FromQuery] bool? hasThemeVideo,
+            [FromQuery] bool? hasSubtitles,
+            [FromQuery] bool? hasSpecialFeature,
+            [FromQuery] bool? hasTrailer,
+            [FromQuery] string? adjacentTo,
+            [FromQuery] int? parentIndexNumber,
+            [FromQuery] bool? hasParentalRating,
+            [FromQuery] bool? isHd,
+            [FromQuery] bool? is4K,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes,
+            [FromQuery] bool? isMissing,
+            [FromQuery] bool? isUnaired,
+            [FromQuery] double? minCommunityRating,
+            [FromQuery] double? minCriticRating,
+            [FromQuery] DateTime? minPremiereDate,
+            [FromQuery] DateTime? minDateLastSaved,
+            [FromQuery] DateTime? minDateLastSavedForUser,
+            [FromQuery] DateTime? maxPremiereDate,
+            [FromQuery] bool? hasOverview,
+            [FromQuery] bool? hasImdbId,
+            [FromQuery] bool? hasTmdbId,
+            [FromQuery] bool? hasTvdbId,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds,
+            [FromQuery] int? startIndex,
+            [FromQuery] int? limit,
+            [FromQuery] bool? recursive,
+            [FromQuery] string? searchTerm,
+            [FromQuery] string? sortOrder,
+            [FromQuery] string? parentId,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
+            [FromQuery] bool? isFavorite,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes,
+            [FromQuery] string? sortBy,
+            [FromQuery] bool? isPlayed,
+            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
+            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings,
+            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years,
+            [FromQuery] bool? enableUserData,
+            [FromQuery] int? imageTypeLimit,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+            [FromQuery] string? person,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
+            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios,
+            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] artists,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds,
+            [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] albums,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes,
+            [FromQuery] string? minOfficialRating,
+            [FromQuery] bool? isLocked,
+            [FromQuery] bool? isPlaceHolder,
+            [FromQuery] bool? hasOfficialRating,
+            [FromQuery] bool? collapseBoxSetItems,
+            [FromQuery] int? minWidth,
+            [FromQuery] int? minHeight,
+            [FromQuery] int? maxWidth,
+            [FromQuery] int? maxHeight,
+            [FromQuery] bool? is3D,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus,
+            [FromQuery] string? nameStartsWithOrGreater,
+            [FromQuery] string? nameStartsWith,
+            [FromQuery] string? nameLessThan,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
+            [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
+            [FromQuery] bool enableTotalRecordCount = true,
+            [FromQuery] bool? enableImages = true)
+        {
+            return GetItems(
+                userId,
+                maxOfficialRating,
+                hasThemeSong,
+                hasThemeVideo,
+                hasSubtitles,
+                hasSpecialFeature,
+                hasTrailer,
+                adjacentTo,
+                parentIndexNumber,
+                hasParentalRating,
+                isHd,
+                is4K,
+                locationTypes,
+                excludeLocationTypes,
+                isMissing,
+                isUnaired,
+                minCommunityRating,
+                minCriticRating,
+                minPremiereDate,
+                minDateLastSaved,
+                minDateLastSavedForUser,
+                maxPremiereDate,
+                hasOverview,
+                hasImdbId,
+                hasTmdbId,
+                hasTvdbId,
+                excludeItemIds,
+                startIndex,
+                limit,
+                recursive,
+                searchTerm,
+                sortOrder,
+                parentId,
+                fields,
+                excludeItemTypes,
+                includeItemTypes,
+                filters,
+                isFavorite,
+                mediaTypes,
+                imageTypes,
+                sortBy,
+                isPlayed,
+                genres,
+                officialRatings,
+                tags,
+                years,
+                enableUserData,
+                imageTypeLimit,
+                enableImageTypes,
+                person,
+                personIds,
+                personTypes,
+                studios,
+                artists,
+                excludeArtistIds,
+                artistIds,
+                albumArtistIds,
+                contributingArtistIds,
+                albums,
+                albumIds,
+                ids,
+                videoTypes,
+                minOfficialRating,
+                isLocked,
+                isPlaceHolder,
+                hasOfficialRating,
+                collapseBoxSetItems,
+                minWidth,
+                minHeight,
+                maxWidth,
+                maxHeight,
+                is3D,
+                seriesStatus,
+                nameStartsWithOrGreater,
+                nameStartsWith,
+                nameLessThan,
+                studioIds,
+                genreIds,
+                enableTotalRecordCount,
+                enableImages);
+        }
+
         /// <summary>
         /// Gets items based on a query.
         /// </summary>

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

@@ -1,4 +1,4 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
 using Jellyfin.Api.Constants;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Globalization;

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

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.Buffers;
 using System.ComponentModel.DataAnnotations;
 using System.Linq;

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

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.Collections.Generic;
 using System.Globalization;
 using System.Linq;

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

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.ComponentModel.DataAnnotations;
 using System.Linq;
 using Jellyfin.Api.Constants;

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

@@ -99,7 +99,7 @@ namespace Jellyfin.Api.Controllers
             var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
             if (!string.IsNullOrEmpty(repositoryUrl))
             {
-                packages = packages.Where(p => p.repositoryUrl.Equals(repositoryUrl, StringComparison.OrdinalIgnoreCase))
+                packages = packages.Where(p => p.versions.Where(q => q.repositoryUrl.Equals(repositoryUrl, StringComparison.OrdinalIgnoreCase)).Any())
                     .ToList();
             }
 

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

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.ComponentModel.DataAnnotations;
 using System.Linq;
 using Jellyfin.Api.Constants;

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

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.ComponentModel.DataAnnotations;
 using System.Linq;
 using System.Threading.Tasks;

+ 3 - 2
Jellyfin.Api/Controllers/PlaystateController.cs

@@ -1,9 +1,10 @@
-using System;
+using System;
 using System.ComponentModel.DataAnnotations;
 using System.Diagnostics.CodeAnalysis;
 using System.Threading.Tasks;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Helpers;
+using Jellyfin.Api.ModelBinders;
 using Jellyfin.Data.Entities;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Net;
@@ -74,7 +75,7 @@ namespace Jellyfin.Api.Controllers
         public ActionResult<UserItemDataDto> MarkPlayedItem(
             [FromRoute, Required] Guid userId,
             [FromRoute, Required] Guid itemId,
-            [FromQuery] DateTime? datePlayed)
+            [FromQuery, ModelBinder(typeof(LegacyDateTimeModelBinder))] DateTime? datePlayed)
         {
             var user = _userManager.GetUserById(userId);
             var session = RequestHelpers.GetSession(_sessionManager, _authContext, Request);

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

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
 using System.Linq;

+ 3 - 2
Jellyfin.Api/Controllers/SessionController.cs

@@ -6,6 +6,7 @@ using System.Threading;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Helpers;
 using Jellyfin.Api.ModelBinders;
+using Jellyfin.Api.Models.SessionDtos;
 using Jellyfin.Data.Enums;
 using MediaBrowser.Controller.Devices;
 using MediaBrowser.Controller.Library;
@@ -412,14 +413,14 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status204NoContent)]
         public ActionResult PostFullCapabilities(
             [FromQuery] string? id,
-            [FromBody, Required] ClientCapabilities capabilities)
+            [FromBody, Required] ClientCapabilitiesDto capabilities)
         {
             if (string.IsNullOrWhiteSpace(id))
             {
                 id = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
             }
 
-            _sessionManager.ReportCapabilities(id, capabilities);
+            _sessionManager.ReportCapabilities(id, capabilities.ToClientCapabilities());
 
             return NoContent();
         }

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

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.ComponentModel.DataAnnotations;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;

+ 40 - 2
Jellyfin.Api/Controllers/SubtitleController.cs

@@ -193,7 +193,6 @@ namespace Jellyfin.Api.Controllers
         /// <response code="200">File returned.</response>
         /// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns>
         [HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/Stream.{format}")]
-        [HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/{startPositionTicks?}/Stream.{format}", Name = "GetSubtitle_2")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesFile("text/*")]
         public async Task<ActionResult> GetSubtitle(
@@ -204,7 +203,7 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] long? endPositionTicks,
             [FromQuery] bool copyTimestamps = false,
             [FromQuery] bool addVttTimeMap = false,
-            [FromRoute] long startPositionTicks = 0)
+            [FromQuery] long startPositionTicks = 0)
         {
             if (string.Equals(format, "js", StringComparison.OrdinalIgnoreCase))
             {
@@ -249,6 +248,43 @@ namespace Jellyfin.Api.Controllers
                 MimeTypes.GetMimeType("file." + format));
         }
 
+        /// <summary>
+        /// Gets subtitles in a specified format.
+        /// </summary>
+        /// <param name="itemId">The item id.</param>
+        /// <param name="mediaSourceId">The media source id.</param>
+        /// <param name="index">The subtitle stream index.</param>
+        /// <param name="startPositionTicks">Optional. The start position of the subtitle in ticks.</param>
+        /// <param name="format">The format of the returned subtitle.</param>
+        /// <param name="endPositionTicks">Optional. The end position of the subtitle in ticks.</param>
+        /// <param name="copyTimestamps">Optional. Whether to copy the timestamps.</param>
+        /// <param name="addVttTimeMap">Optional. Whether to add a VTT time map.</param>
+        /// <response code="200">File returned.</response>
+        /// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns>
+        [HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/{startPositionTicks}/Stream.{format}")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesFile("text/*")]
+        public Task<ActionResult> GetSubtitleWithTicks(
+            [FromRoute, Required] Guid itemId,
+            [FromRoute, Required] string mediaSourceId,
+            [FromRoute, Required] int index,
+            [FromRoute, Required] long startPositionTicks,
+            [FromRoute, Required] string format,
+            [FromQuery] long? endPositionTicks,
+            [FromQuery] bool copyTimestamps = false,
+            [FromQuery] bool addVttTimeMap = false)
+        {
+            return GetSubtitle(
+                itemId,
+                mediaSourceId,
+                index,
+                format,
+                endPositionTicks,
+                copyTimestamps,
+                addVttTimeMap,
+                startPositionTicks);
+        }
+
         /// <summary>
         /// Gets an HLS subtitle playlist.
         /// </summary>
@@ -335,6 +371,7 @@ namespace Jellyfin.Api.Controllers
         /// <response code="204">Subtitle uploaded.</response>
         /// <returns>A <see cref="NoContentResult"/>.</returns>
         [HttpPost("Videos/{itemId}/Subtitles")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
         public async Task<ActionResult> UploadSubtitle(
             [FromRoute, Required] Guid itemId,
             [FromBody, Required] UploadSubtitleDto body)
@@ -446,6 +483,7 @@ namespace Jellyfin.Api.Controllers
         [HttpGet("FallbackFont/Fonts/{name}")]
         [Authorize(Policy = Policies.DefaultAuthorization)]
         [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesFile("font/*")]
         public ActionResult GetFallbackFont([FromRoute, Required] string name)
         {
             var encodingOptions = _serverConfigurationManager.GetEncodingOptions();

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

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.ComponentModel.DataAnnotations;
 using System.Linq;
 using Jellyfin.Api.Constants;

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

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
 using System.Threading;

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

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.Globalization;
 using MediaBrowser.Model.SyncPlay;
 using Microsoft.AspNetCore.Http;

+ 0 - 1
Jellyfin.Api/Controllers/TrailersController.cs

@@ -197,7 +197,6 @@ namespace Jellyfin.Api.Controllers
 
             return _itemsController
                 .GetItems(
-                    userId,
                     userId,
                     maxOfficialRating,
                     hasThemeSong,

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

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
 using System.Globalization;

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

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
 using System.Linq;

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

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
 using System.Globalization;

+ 2 - 1
Jellyfin.Api/Controllers/VideoAttachmentsController.cs

@@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations;
 using System.Net.Mime;
 using System.Threading;
 using System.Threading.Tasks;
+using Jellyfin.Api.Attributes;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.MediaEncoding;
@@ -43,7 +44,7 @@ namespace Jellyfin.Api.Controllers
         /// <response code="404">Video or attachment not found.</response>
         /// <returns>An <see cref="FileStreamResult"/> containing the attachment stream on success, or a <see cref="NotFoundResult"/> if the attachment could not be found.</returns>
         [HttpGet("{videoId}/{mediaSourceId}/Attachments/{index}")]
-        [Produces(MediaTypeNames.Application.Octet)]
+        [ProducesFile(MediaTypeNames.Application.Octet)]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesResponseType(StatusCodes.Status404NotFound)]
         public async Task<ActionResult> GetAttachment(

+ 162 - 3
Jellyfin.Api/Controllers/VideosController.cs

@@ -327,15 +327,13 @@ namespace Jellyfin.Api.Controllers
         /// <param name="streamOptions">Optional. The streaming options.</param>
         /// <response code="200">Video stream returned.</response>
         /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
-        [HttpGet("{itemId}/{stream=stream}.{container?}", Name = "GetVideoStreamWithExt")]
         [HttpGet("{itemId}/stream")]
-        [HttpHead("{itemId}/{stream=stream}.{container?}", Name = "HeadVideoStreamWithExt")]
         [HttpHead("{itemId}/stream", Name = "HeadVideoStream")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         [ProducesVideoFile]
         public async Task<ActionResult> GetVideoStream(
             [FromRoute, Required] Guid itemId,
-            [FromRoute] string? container,
+            [FromQuery] string? container,
             [FromQuery] bool? @static,
             [FromQuery] string? @params,
             [FromQuery] string? tag,
@@ -530,5 +528,166 @@ namespace Jellyfin.Api.Controllers
                 _transcodingJobType,
                 cancellationTokenSource).ConfigureAwait(false);
         }
+
+        /// <summary>
+        /// Gets a video stream.
+        /// </summary>
+        /// <param name="itemId">The item id.</param>
+        /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param>
+        /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
+        /// <param name="params">The streaming parameters.</param>
+        /// <param name="tag">The tag.</param>
+        /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
+        /// <param name="playSessionId">The play session id.</param>
+        /// <param name="segmentContainer">The segment container.</param>
+        /// <param name="segmentLength">The segment lenght.</param>
+        /// <param name="minSegments">The minimum number of segments.</param>
+        /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
+        /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
+        /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
+        /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
+        /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
+        /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
+        /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
+        /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
+        /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
+        /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
+        /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
+        /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
+        /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
+        /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
+        /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+        /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+        /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
+        /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
+        /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
+        /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
+        /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
+        /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
+        /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
+        /// <param name="maxRefFrames">Optional.</param>
+        /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
+        /// <param name="requireAvc">Optional. Whether to require avc.</param>
+        /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
+        /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param>
+        /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
+        /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
+        /// <param name="liveStreamId">The live stream id.</param>
+        /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
+        /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param>
+        /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
+        /// <param name="transcodingReasons">Optional. The transcoding reason.</param>
+        /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
+        /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
+        /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
+        /// <param name="streamOptions">Optional. The streaming options.</param>
+        /// <response code="200">Video stream returned.</response>
+        /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
+        [HttpGet("{itemId}/{stream=stream}.{container}")]
+        [HttpHead("{itemId}/{stream=stream}.{container}", Name = "HeadVideoStreamByContainer")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesVideoFile]
+        public Task<ActionResult> GetVideoStreamByContainer(
+            [FromRoute, Required] Guid itemId,
+            [FromRoute, Required] string container,
+            [FromQuery] bool? @static,
+            [FromQuery] string? @params,
+            [FromQuery] string? tag,
+            [FromQuery] string? deviceProfileId,
+            [FromQuery] string? playSessionId,
+            [FromQuery] string? segmentContainer,
+            [FromQuery] int? segmentLength,
+            [FromQuery] int? minSegments,
+            [FromQuery] string? mediaSourceId,
+            [FromQuery] string? deviceId,
+            [FromQuery] string? audioCodec,
+            [FromQuery] bool? enableAutoStreamCopy,
+            [FromQuery] bool? allowVideoStreamCopy,
+            [FromQuery] bool? allowAudioStreamCopy,
+            [FromQuery] bool? breakOnNonKeyFrames,
+            [FromQuery] int? audioSampleRate,
+            [FromQuery] int? maxAudioBitDepth,
+            [FromQuery] int? audioBitRate,
+            [FromQuery] int? audioChannels,
+            [FromQuery] int? maxAudioChannels,
+            [FromQuery] string? profile,
+            [FromQuery] string? level,
+            [FromQuery] float? framerate,
+            [FromQuery] float? maxFramerate,
+            [FromQuery] bool? copyTimestamps,
+            [FromQuery] long? startTimeTicks,
+            [FromQuery] int? width,
+            [FromQuery] int? height,
+            [FromQuery] int? videoBitRate,
+            [FromQuery] int? subtitleStreamIndex,
+            [FromQuery] SubtitleDeliveryMethod subtitleMethod,
+            [FromQuery] int? maxRefFrames,
+            [FromQuery] int? maxVideoBitDepth,
+            [FromQuery] bool? requireAvc,
+            [FromQuery] bool? deInterlace,
+            [FromQuery] bool? requireNonAnamorphic,
+            [FromQuery] int? transcodingMaxAudioChannels,
+            [FromQuery] int? cpuCoreLimit,
+            [FromQuery] string? liveStreamId,
+            [FromQuery] bool? enableMpegtsM2TsMode,
+            [FromQuery] string? videoCodec,
+            [FromQuery] string? subtitleCodec,
+            [FromQuery] string? transcodingReasons,
+            [FromQuery] int? audioStreamIndex,
+            [FromQuery] int? videoStreamIndex,
+            [FromQuery] EncodingContext context,
+            [FromQuery] Dictionary<string, string> streamOptions)
+        {
+            return GetVideoStream(
+                itemId,
+                container,
+                @static,
+                @params,
+                tag,
+                deviceProfileId,
+                playSessionId,
+                segmentContainer,
+                segmentLength,
+                minSegments,
+                mediaSourceId,
+                deviceId,
+                audioCodec,
+                enableAutoStreamCopy,
+                allowVideoStreamCopy,
+                allowAudioStreamCopy,
+                breakOnNonKeyFrames,
+                audioSampleRate,
+                maxAudioBitDepth,
+                audioBitRate,
+                audioChannels,
+                maxAudioChannels,
+                profile,
+                level,
+                framerate,
+                maxFramerate,
+                copyTimestamps,
+                startTimeTicks,
+                width,
+                height,
+                videoBitRate,
+                subtitleStreamIndex,
+                subtitleMethod,
+                maxRefFrames,
+                maxVideoBitDepth,
+                requireAvc,
+                deInterlace,
+                requireNonAnamorphic,
+                transcodingMaxAudioChannels,
+                cpuCoreLimit,
+                liveStreamId,
+                enableMpegtsM2TsMode,
+                videoCodec,
+                subtitleCodec,
+                transcodingReasons,
+                audioStreamIndex,
+                videoStreamIndex,
+                context,
+                streamOptions);
+        }
     }
 }

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

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
 using System.Linq;

+ 49 - 0
Jellyfin.Api/ModelBinders/LegacyDateTimeModelBinder.cs

@@ -0,0 +1,49 @@
+using System;
+using System.Globalization;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.ModelBinders
+{
+    /// <summary>
+    /// DateTime model binder.
+    /// </summary>
+    public class LegacyDateTimeModelBinder : IModelBinder
+    {
+        // Borrowed from the DateTimeModelBinderProvider
+        private const DateTimeStyles SupportedStyles = DateTimeStyles.AdjustToUniversal | DateTimeStyles.AllowWhiteSpaces;
+        private readonly DateTimeModelBinder _defaultModelBinder;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="LegacyDateTimeModelBinder"/> class.
+        /// </summary>
+        /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
+        public LegacyDateTimeModelBinder(ILoggerFactory loggerFactory)
+        {
+            _defaultModelBinder = new DateTimeModelBinder(SupportedStyles, loggerFactory);
+        }
+
+        /// <inheritdoc />
+        public Task BindModelAsync(ModelBindingContext bindingContext)
+        {
+            var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
+            if (valueProviderResult.Values.Count == 1)
+            {
+                var dateTimeString = valueProviderResult.FirstValue;
+                // Mark Played Item.
+                if (DateTime.TryParseExact(dateTimeString, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var dateTime))
+                {
+                    bindingContext.Result = ModelBindingResult.Success(dateTime);
+                }
+                else
+                {
+                    return _defaultModelBinder.BindModelAsync(bindingContext);
+                }
+            }
+
+            return Task.CompletedTask;
+        }
+    }
+}

+ 47 - 0
Jellyfin.Api/ModelBinders/NullableEnumModelBinder.cs

@@ -0,0 +1,47 @@
+using System;
+using System.ComponentModel;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.ModelBinders
+{
+    /// <summary>
+    /// Nullable enum model binder.
+    /// </summary>
+    public class NullableEnumModelBinder : IModelBinder
+    {
+        private readonly ILogger<NullableEnumModelBinder> _logger;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="NullableEnumModelBinder"/> class.
+        /// </summary>
+        /// <param name="logger">Instance of the <see cref="ILogger{NullableEnumModelBinder}"/> interface.</param>
+        public NullableEnumModelBinder(ILogger<NullableEnumModelBinder> logger)
+        {
+            _logger = logger;
+        }
+
+        /// <inheritdoc />
+        public Task BindModelAsync(ModelBindingContext bindingContext)
+        {
+            var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
+            var elementType = bindingContext.ModelType.GetElementType() ?? bindingContext.ModelType.GenericTypeArguments[0];
+            var converter = TypeDescriptor.GetConverter(elementType);
+            if (valueProviderResult.Length != 0)
+            {
+                try
+                {
+                    var convertedValue = converter.ConvertFromString(valueProviderResult.FirstValue);
+                    bindingContext.Result = ModelBindingResult.Success(convertedValue);
+                }
+                catch (FormatException e)
+                {
+                    _logger.LogWarning(e, "Error converting value.");
+                }
+            }
+
+            return Task.CompletedTask;
+        }
+    }
+}

+ 27 - 0
Jellyfin.Api/ModelBinders/NullableEnumModelBinderProvider.cs

@@ -0,0 +1,27 @@
+using System;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.ModelBinders
+{
+    /// <summary>
+    /// Nullable enum model binder provider.
+    /// </summary>
+    public class NullableEnumModelBinderProvider : IModelBinderProvider
+    {
+        /// <inheritdoc />
+        public IModelBinder? GetBinder(ModelBinderProviderContext context)
+        {
+            var nullableType = Nullable.GetUnderlyingType(context.Metadata.ModelType);
+            if (nullableType == null || !nullableType.IsEnum)
+            {
+                // Type isn't nullable or isn't an enum.
+                return null;
+            }
+
+            var logger = context.Services.GetRequiredService<ILogger<NullableEnumModelBinder>>();
+            return new NullableEnumModelBinder(logger);
+        }
+    }
+}

+ 87 - 0
Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs

@@ -0,0 +1,87 @@
+using System;
+using System.Collections.Generic;
+using MediaBrowser.Common.Json.Converters;
+using MediaBrowser.Model.Dlna;
+using MediaBrowser.Model.Session;
+using Newtonsoft.Json;
+
+namespace Jellyfin.Api.Models.SessionDtos
+{
+    /// <summary>
+    /// Client capabilities dto.
+    /// </summary>
+    public class ClientCapabilitiesDto
+    {
+        /// <summary>
+        /// Gets or sets the list of playable media types.
+        /// </summary>
+        public IReadOnlyList<string> PlayableMediaTypes { get; set; } = Array.Empty<string>();
+
+        /// <summary>
+        /// Gets or sets the list of supported commands.
+        /// </summary>
+        [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+        public IReadOnlyList<GeneralCommandType> SupportedCommands { get; set; } = Array.Empty<GeneralCommandType>();
+
+        /// <summary>
+        /// Gets or sets a value indicating whether session supports media control.
+        /// </summary>
+        public bool SupportsMediaControl { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether session supports content uploading.
+        /// </summary>
+        public bool SupportsContentUploading { get; set; }
+
+        /// <summary>
+        /// Gets or sets the message callback url.
+        /// </summary>
+        public string? MessageCallbackUrl { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether session supports a persistent identifier.
+        /// </summary>
+        public bool SupportsPersistentIdentifier { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether session supports sync.
+        /// </summary>
+        public bool SupportsSync { get; set; }
+
+        /// <summary>
+        /// Gets or sets the device profile.
+        /// </summary>
+        public DeviceProfile? DeviceProfile { get; set; }
+
+        /// <summary>
+        /// Gets or sets the app store url.
+        /// </summary>
+        public string? AppStoreUrl { get; set; }
+
+        /// <summary>
+        /// Gets or sets the icon url.
+        /// </summary>
+        public string? IconUrl { get; set; }
+
+        /// <summary>
+        /// Convert the dto to the full <see cref="ClientCapabilities"/> model.
+        /// </summary>
+        /// <returns>The converted <see cref="ClientCapabilities"/> model.</returns>
+        public ClientCapabilities ToClientCapabilities()
+        {
+            return new ClientCapabilities
+            {
+                PlayableMediaTypes = PlayableMediaTypes,
+                SupportedCommands = SupportedCommands,
+                SupportsMediaControl = SupportsMediaControl,
+                SupportsContentUploading = SupportsContentUploading,
+                MessageCallbackUrl = MessageCallbackUrl,
+                SupportsPersistentIdentifier = SupportsPersistentIdentifier,
+                SupportsSync = SupportsSync,
+                DeviceProfile = DeviceProfile,
+                AppStoreUrl = AppStoreUrl,
+                IconUrl = IconUrl
+            };
+        }
+    }
+}

+ 0 - 44
Jellyfin.Api/TypeConverters/DateTimeTypeConverter.cs

@@ -1,44 +0,0 @@
-using System;
-using System.ComponentModel;
-using System.Globalization;
-
-namespace Jellyfin.Api.TypeConverters
-{
-    /// <summary>
-    /// Custom datetime parser.
-    /// </summary>
-    public class DateTimeTypeConverter : TypeConverter
-    {
-        /// <inheritdoc />
-        public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
-        {
-            if (sourceType == typeof(string))
-            {
-                return true;
-            }
-
-            return base.CanConvertFrom(context, sourceType);
-        }
-
-        /// <inheritdoc />
-        public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
-        {
-            if (value is string dateString)
-            {
-                // Mark Played Item.
-                if (DateTime.TryParseExact(dateString, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var dateTime))
-                {
-                    return dateTime;
-                }
-
-                // Get Activity Logs.
-                if (DateTime.TryParse(dateString, null, DateTimeStyles.RoundtripKind, out dateTime))
-                {
-                    return dateTime;
-                }
-            }
-
-            return base.ConvertFrom(context, culture, value);
-        }
-    }
-}

+ 3 - 0
Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs

@@ -17,6 +17,7 @@ using Jellyfin.Api.Auth.LocalAccessPolicy;
 using Jellyfin.Api.Auth.RequiresElevationPolicy;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Controllers;
+using Jellyfin.Api.ModelBinders;
 using Jellyfin.Server.Configuration;
 using Jellyfin.Server.Filters;
 using Jellyfin.Server.Formatters;
@@ -169,6 +170,8 @@ namespace Jellyfin.Server.Extensions
 
                     opts.OutputFormatters.Add(new CssOutputFormatter());
                     opts.OutputFormatters.Add(new XmlOutputFormatter());
+
+                    opts.ModelBinderProviders.Insert(0, new NullableEnumModelBinderProvider());
                 })
 
                 // Clear app parts to avoid other assemblies being picked up

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

@@ -1,4 +1,4 @@
-using System;
+using System;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Model.Updates;
 
@@ -46,4 +46,4 @@ namespace Jellyfin.Server.Migrations.Routines
             }
         }
     }
-}
+}

+ 8 - 6
Jellyfin.Server/Startup.cs

@@ -1,8 +1,5 @@
-using System;
-using System.ComponentModel;
 using System.Net.Http.Headers;
 using System.Net.Mime;
-using Jellyfin.Api.TypeConverters;
 using Jellyfin.Networking.Configuration;
 using Jellyfin.Server.Extensions;
 using Jellyfin.Server.Implementations;
@@ -67,10 +64,16 @@ namespace Jellyfin.Server
             var productHeader = new ProductInfoHeaderValue(
                 _serverApplicationHost.Name.Replace(' ', '-'),
                 _serverApplicationHost.ApplicationVersionString);
+            var acceptJsonHeader = new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json, 1.0);
+            var acceptXmlHeader = new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Xml, 0.9);
+            var acceptAnyHeader = new MediaTypeWithQualityHeaderValue("*/*", 0.8);
             services
                 .AddHttpClient(NamedClient.Default, c =>
                 {
                     c.DefaultRequestHeaders.UserAgent.Add(productHeader);
+                    c.DefaultRequestHeaders.Accept.Add(acceptJsonHeader);
+                    c.DefaultRequestHeaders.Accept.Add(acceptXmlHeader);
+                    c.DefaultRequestHeaders.Accept.Add(acceptAnyHeader);
                 })
                 .ConfigurePrimaryHttpMessageHandler(x => new DefaultHttpClientHandler());
 
@@ -78,6 +81,8 @@ namespace Jellyfin.Server
                 {
                     c.DefaultRequestHeaders.UserAgent.Add(productHeader);
                     c.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue($"({_serverApplicationHost.ApplicationUserAgentAddress})"));
+                    c.DefaultRequestHeaders.Accept.Add(acceptJsonHeader);
+                    c.DefaultRequestHeaders.Accept.Add(acceptAnyHeader);
                 })
                 .ConfigurePrimaryHttpMessageHandler(x => new DefaultHttpClientHandler());
 
@@ -165,9 +170,6 @@ namespace Jellyfin.Server
                     endpoints.MapHealthChecks("/health");
                 });
             });
-
-            // Add type descriptor for legacy datetime parsing.
-            TypeDescriptor.AddAttributes(typeof(DateTime?), new TypeConverterAttribute(typeof(DateTimeTypeConverter)));
         }
     }
 }

+ 5 - 2
MediaBrowser.Common/Updates/IInstallationManager.cs

@@ -19,10 +19,11 @@ namespace MediaBrowser.Common.Updates
         /// <summary>
         /// Parses a plugin manifest at the supplied URL.
         /// </summary>
+        /// <param name="manifestName">Name of the repository.</param>
         /// <param name="manifest">The URL to query.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
         /// <returns>Task{IReadOnlyList{PackageInfo}}.</returns>
-        Task<IReadOnlyList<PackageInfo>> GetPackages(string manifest, CancellationToken cancellationToken = default);
+        Task<IList<PackageInfo>> GetPackages(string manifestName, string manifest, CancellationToken cancellationToken = default);
 
         /// <summary>
         /// Gets all available packages.
@@ -37,11 +38,13 @@ namespace MediaBrowser.Common.Updates
         /// <param name="availablePackages">The available packages.</param>
         /// <param name="name">The name of the plugin.</param>
         /// <param name="guid">The id of the plugin.</param>
+        /// <param name="specificVersion">The version of the plugin.</param>
         /// <returns>All plugins matching the requirements.</returns>
         IEnumerable<PackageInfo> FilterPackages(
             IEnumerable<PackageInfo> availablePackages,
             string name = null,
-            Guid guid = default);
+            Guid guid = default,
+            Version specificVersion = null);
 
         /// <summary>
         /// Returns all compatible versions ordered from newest to oldest.

+ 0 - 1
MediaBrowser.Controller/Entities/BaseItem.cs

@@ -1385,7 +1385,6 @@ namespace MediaBrowser.Controller.Entities
                         new List<FileSystemMetadata>();
 
                     var ownedItemsChanged = await RefreshedOwnedItems(options, files, cancellationToken).ConfigureAwait(false);
-                    await LibraryManager.UpdateImagesAsync(this).ConfigureAwait(false); // ensure all image properties in DB are fresh
 
                     if (ownedItemsChanged)
                     {

+ 0 - 5
MediaBrowser.Controller/Entities/Folder.cs

@@ -353,11 +353,6 @@ namespace MediaBrowser.Controller.Entities
                         {
                             await currentChild.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false);
                         }
-                        else
-                        {
-                            // metadata is up-to-date; make sure DB has correct images dimensions and hash
-                            await LibraryManager.UpdateImagesAsync(currentChild).ConfigureAwait(false);
-                        }
 
                         continue;
                     }

+ 2 - 0
MediaBrowser.Controller/Library/ILibraryManager.cs

@@ -571,6 +571,8 @@ namespace MediaBrowser.Controller.Library
             string videoPath,
             string[] files);
 
+        void RunMetadataSavers(IReadOnlyList<BaseItem> items, ItemUpdateType updateReason);
+
         BaseItem GetParentItem(string parentId, Guid? userId);
     }
 }

+ 3 - 2
MediaBrowser.Controller/Session/SessionInfo.cs

@@ -1,6 +1,7 @@
 #pragma warning disable CS1591
 
 using System;
+using System.Collections.Generic;
 using System.Linq;
 using System.Text.Json.Serialization;
 using System.Threading;
@@ -54,7 +55,7 @@ namespace MediaBrowser.Controller.Session
         /// Gets or sets the playable media types.
         /// </summary>
         /// <value>The playable media types.</value>
-        public string[] PlayableMediaTypes
+        public IReadOnlyList<string> PlayableMediaTypes
         {
             get
             {
@@ -230,7 +231,7 @@ namespace MediaBrowser.Controller.Session
         /// Gets or sets the supported commands.
         /// </summary>
         /// <value>The supported commands.</value>
-        public GeneralCommandType[] SupportedCommands
+        public IReadOnlyList<GeneralCommandType> SupportedCommands
             => Capabilities == null ? Array.Empty<GeneralCommandType>() : Capabilities.SupportedCommands;
 
         public Tuple<ISessionController, bool> EnsureController<T>(Func<SessionInfo, ISessionController> factory)

+ 3 - 2
MediaBrowser.Model/Session/ClientCapabilities.cs

@@ -2,15 +2,16 @@
 #pragma warning disable CS1591
 
 using System;
+using System.Collections.Generic;
 using MediaBrowser.Model.Dlna;
 
 namespace MediaBrowser.Model.Session
 {
     public class ClientCapabilities
     {
-        public string[] PlayableMediaTypes { get; set; }
+        public IReadOnlyList<string> PlayableMediaTypes { get; set; }
 
-        public GeneralCommandType[] SupportedCommands { get; set; }
+        public IReadOnlyList<GeneralCommandType> SupportedCommands { get; set; }
 
         public bool SupportsMediaControl { get; set; }
 

+ 1 - 11
MediaBrowser.Model/Updates/PackageInfo.cs

@@ -50,17 +50,7 @@ namespace MediaBrowser.Model.Updates
         /// Gets or sets the versions.
         /// </summary>
         /// <value>The versions.</value>
-        public IReadOnlyList<VersionInfo> versions { get; set; }
-
-        /// <summary>
-        /// Gets or sets the repository name.
-        /// </summary>
-        public string repositoryName { get; set; }
-
-        /// <summary>
-        /// Gets or sets the repository url.
-        /// </summary>
-        public string repositoryUrl { get; set; }
+        public IList<VersionInfo> versions { get; set; }
 
         /// <summary>
         /// Initializes a new instance of the <see cref="PackageInfo"/> class.

+ 6 - 0
MediaBrowser.Model/Updates/RepositoryInfo.cs

@@ -16,5 +16,11 @@ namespace MediaBrowser.Model.Updates
         /// </summary>
         /// <value>The URL.</value>
         public string? Url { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether the repository is enabled.
+        /// </summary>
+        /// <value><c>true</c> if enabled.</value>
+        public bool Enabled { get; set; } = true;
     }
 }

+ 29 - 1
MediaBrowser.Model/Updates/VersionInfo.cs

@@ -9,11 +9,29 @@ namespace MediaBrowser.Model.Updates
     /// </summary>
     public class VersionInfo
     {
+        private Version _version;
+
         /// <summary>
         /// Gets or sets the version.
         /// </summary>
         /// <value>The version.</value>
-        public string version { get; set; }
+        public string version
+        {
+            get
+            {
+                return _version == null ? string.Empty : _version.ToString();
+            }
+
+            set
+            {
+                _version = Version.Parse(value);
+            }
+        }
+
+        /// <summary>
+        /// Gets the version as a <see cref="Version"/>.
+        /// </summary>
+        public Version VersionNumber => _version;
 
         /// <summary>
         /// Gets or sets the changelog for this version.
@@ -44,5 +62,15 @@ namespace MediaBrowser.Model.Updates
         /// </summary>
         /// <value>The timestamp.</value>
         public string timestamp { get; set; }
+
+        /// <summary>
+        /// Gets or sets the repository name.
+        /// </summary>
+        public string repositoryName { get; set; }
+
+        /// <summary>
+        /// Gets or sets the repository url.
+        /// </summary>
+        public string repositoryUrl { get; set; }
     }
 }

+ 8 - 5
MediaBrowser.Providers/Manager/MetadataService.cs

@@ -229,16 +229,16 @@ namespace MediaBrowser.Providers.Manager
             await result.Item.UpdateToRepositoryAsync(reason, cancellationToken).ConfigureAwait(false);
         }
 
-        private async Task SavePeopleMetadataAsync(List<PersonInfo> people, LibraryOptions libraryOptions, CancellationToken cancellationToken)
+        private Task SavePeopleMetadataAsync(List<PersonInfo> people, LibraryOptions libraryOptions, CancellationToken cancellationToken)
         {
+            var personsToSave = new List<BaseItem>();
+
             foreach (var person in people)
             {
                 cancellationToken.ThrowIfCancellationRequested();
 
                 if (person.ProviderIds.Count > 0 || !string.IsNullOrWhiteSpace(person.ImageUrl))
                 {
-                    var updateType = ItemUpdateType.MetadataDownload;
-
                     var saveEntity = false;
                     var personEntity = LibraryManager.GetPerson(person.Name);
                     foreach (var id in person.ProviderIds)
@@ -261,15 +261,18 @@ namespace MediaBrowser.Providers.Manager
                             0);
 
                         saveEntity = true;
-                        updateType |= ItemUpdateType.ImageUpdate;
                     }
 
                     if (saveEntity)
                     {
-                        await personEntity.UpdateToRepositoryAsync(updateType, cancellationToken).ConfigureAwait(false);
+                        personsToSave.Add(personEntity);
                     }
                 }
             }
+
+            LibraryManager.RunMetadataSavers(personsToSave, ItemUpdateType.MetadataDownload);
+            LibraryManager.CreateItems(personsToSave, null, CancellationToken.None);
+            return Task.CompletedTask;
         }
 
         protected virtual Task AfterMetadataRefresh(TItemType item, MetadataRefreshOptions refreshOptions, CancellationToken cancellationToken)

+ 1 - 0
tests/Jellyfin.Naming.Tests/Video/CleanDateTimeTests.cs

@@ -47,6 +47,7 @@ namespace Jellyfin.Naming.Tests.Video
         // FIXME: [InlineData("Robin Hood [Multi-Subs] [2018].mkv", "Robin Hood", 2018)]
         [InlineData(@"3.Days.to.Kill.2014.720p.BluRay.x264.YIFY.mkv", "3.Days.to.Kill", 2014)] // In this test case, running CleanDateTime first produces no date, so it will attempt to run CleanString first and then CleanDateTime again
         [InlineData("3 days to kill (2005).mkv", "3 days to kill", 2005)]
+        [InlineData(@"Rain Man 1988 REMASTERED 1080p BluRay x264 AAC - Ozlem.mp4", "Rain Man", 1988)]
         [InlineData("My Movie 2013.12.09", "My Movie 2013.12.09", null)]
         [InlineData("My Movie 2013-12-09", "My Movie 2013-12-09", null)]
         [InlineData("My Movie 20131209", "My Movie 20131209", null)]

+ 8 - 0
tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs

@@ -145,6 +145,14 @@ namespace Jellyfin.Naming.Tests.Video
                     name: "Brave",
                     year: 2006)
             };
+            yield return new object[]
+            {
+                new VideoFileInfo(
+                    path: @"/server/Movies/Rain Man 1988 REMASTERED 1080p BluRay x264 AAC - Ozlem/Rain Man 1988 REMASTERED 1080p BluRay x264 AAC - Ozlem.mp4",
+                    container: "mp4",
+                    name: "Rain Man",
+                    year: 1988)
+            };
         }
 
         [Theory]