Browse Source

Merge branch 'master' into xml-parsing-cleanup

Patrick Barron 1 năm trước cách đây
mục cha
commit
1ce49b4a04
48 tập tin đã thay đổi với 439 bổ sung457 xóa
  1. 3 3
      .github/workflows/codeql-analysis.yml
  2. 1 1
      .github/workflows/repo-stale.yaml
  3. 1 1
      Directory.Packages.props
  4. 1 1
      Emby.Dlna/DlnaManager.cs
  5. 1 1
      Emby.Naming/ExternalFiles/ExternalPathParser.cs
  6. 3 4
      Emby.Naming/Video/StubResolver.cs
  7. 1 1
      Emby.Photos/PhotoProvider.cs
  8. 5 75
      Emby.Server.Implementations/ApplicationHost.cs
  9. 4 2
      Emby.Server.Implementations/IO/ManagedFileSystem.cs
  10. 16 15
      Emby.Server.Implementations/Library/LibraryManager.cs
  11. 3 3
      Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs
  12. 1 1
      Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs
  13. 4 5
      Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs
  14. 3 1
      Emby.Server.Implementations/Localization/Core/ml.json
  15. 1 1
      Emby.Server.Implementations/MediaEncoder/EncodingManager.cs
  16. 6 10
      Emby.Server.Implementations/Playlists/PlaylistManager.cs
  17. 108 0
      Emby.Server.Implementations/SystemManager.cs
  18. 1 2
      Emby.Server.Implementations/Updates/InstallationManager.cs
  19. 18 15
      Jellyfin.Api/Controllers/DynamicHlsController.cs
  20. 5 4
      Jellyfin.Api/Controllers/HlsSegmentController.cs
  21. 19 24
      Jellyfin.Api/Controllers/ImageController.cs
  22. 4 4
      Jellyfin.Api/Controllers/SubtitleController.cs
  23. 22 25
      Jellyfin.Api/Controllers/SystemController.cs
  24. 1 34
      Jellyfin.Api/Helpers/DynamicHlsHelper.cs
  25. 5 3
      Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs
  26. 10 5
      Jellyfin.Api/Helpers/StreamingHelpers.cs
  27. 1 1
      Jellyfin.Api/Helpers/TranscodingJobHelper.cs
  28. 5 25
      Jellyfin.Server.Implementations/Security/AuthorizationContext.cs
  29. 4 20
      MediaBrowser.Common/IApplicationHost.cs
  30. 0 11
      MediaBrowser.Controller/Drawing/IImageProcessor.cs
  31. 2 1
      MediaBrowser.Controller/Drawing/ImageProcessingOptions.cs
  32. 0 12
      MediaBrowser.Controller/IServerApplicationHost.cs
  33. 34 0
      MediaBrowser.Controller/ISystemManager.cs
  34. 15 9
      MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
  35. 4 32
      MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
  36. 3 13
      MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
  37. 17 0
      MediaBrowser.Model/Drawing/ImageFormatExtensions.cs
  38. 5 1
      MediaBrowser.Providers/Manager/ImageSaver.cs
  39. 0 6
      MediaBrowser.Providers/MediaInfo/EmbeddedImageProvider.cs
  40. 39 2
      MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs
  41. 2 2
      MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs
  42. 3 13
      src/Jellyfin.Drawing.Skia/SkiaEncoder.cs
  43. 6 6
      src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs
  44. 2 54
      src/Jellyfin.Drawing/ImageProcessor.cs
  45. 13 0
      tests/Jellyfin.Model.Tests/Drawing/ImageFormatExtensionsTests.cs
  46. 8 5
      tests/Jellyfin.Server.Integration.Tests/AuthHelper.cs
  47. 26 0
      tests/Jellyfin.Server.Integration.Tests/Controllers/PersonsControllerTests.cs
  48. 3 3
      tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs

+ 3 - 3
.github/workflows/codeql-analysis.yml

@@ -27,11 +27,11 @@ jobs:
         dotnet-version: '7.0.x'
 
     - name: Initialize CodeQL
-      uses: github/codeql-action/init@ddccb873888234080b77e9bc2d4764d5ccaaccf9 # v2.21.9
+      uses: github/codeql-action/init@fdcae64e1484d349b3366718cdfef3d404390e85 # v2.22.1
       with:
         languages: ${{ matrix.language }}
         queries: +security-extended
     - name: Autobuild
-      uses: github/codeql-action/autobuild@ddccb873888234080b77e9bc2d4764d5ccaaccf9 # v2.21.9
+      uses: github/codeql-action/autobuild@fdcae64e1484d349b3366718cdfef3d404390e85 # v2.22.1
     - name: Perform CodeQL Analysis
-      uses: github/codeql-action/analyze@ddccb873888234080b77e9bc2d4764d5ccaaccf9 # v2.21.9
+      uses: github/codeql-action/analyze@fdcae64e1484d349b3366718cdfef3d404390e85 # v2.22.1

+ 1 - 1
.github/workflows/repo-stale.yaml

@@ -16,7 +16,7 @@ jobs:
     runs-on: ubuntu-latest
     if: ${{ contains(github.repository, 'jellyfin/') }}
     steps:
-      - uses: actions/stale@1160a2240286f5da8ec72b1c0816ce2481aabf84 # v8
+      - uses: actions/stale@1160a2240286f5da8ec72b1c0816ce2481aabf84 # v8.0.0
         with:
           repo-token: ${{ secrets.JF_BOT_TOKEN }}
           days-before-stale: 120

+ 1 - 1
Directory.Packages.props

@@ -25,7 +25,7 @@
     <PackageVersion Include="libse" Version="3.6.13" />
     <PackageVersion Include="LrcParser" Version="2023.524.0" />
     <PackageVersion Include="MetaBrainz.MusicBrainz" Version="5.0.0" />
-    <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="7.0.11" />
+    <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="7.0.12" />
     <PackageVersion Include="Microsoft.AspNetCore.HttpOverrides" Version="2.2.0" />
     <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="7.0.11" />
     <PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4" />

+ 1 - 1
Emby.Dlna/DlnaManager.cs

@@ -228,7 +228,7 @@ namespace Emby.Dlna
             try
             {
                 return _fileSystem.GetFilePaths(path)
-                    .Where(i => string.Equals(Path.GetExtension(i), ".xml", StringComparison.OrdinalIgnoreCase))
+                    .Where(i => Path.GetExtension(i.AsSpan()).Equals(".xml", StringComparison.OrdinalIgnoreCase))
                     .Select(i => ParseProfileFile(i, type))
                     .Where(i => i is not null)
                     .ToList()!; // We just filtered out all the nulls

+ 1 - 1
Emby.Naming/ExternalFiles/ExternalPathParser.cs

@@ -43,7 +43,7 @@ namespace Emby.Naming.ExternalFiles
                 return null;
             }
 
-            var extension = Path.GetExtension(path);
+            var extension = Path.GetExtension(path.AsSpan());
             if (!(_type == DlnaProfileType.Subtitle && _namingOptions.SubtitleFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
                 && !(_type == DlnaProfileType.Audio && _namingOptions.AudioFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)))
             {

+ 3 - 4
Emby.Naming/Video/StubResolver.cs

@@ -26,19 +26,18 @@ namespace Emby.Naming.Video
                 return false;
             }
 
-            var extension = Path.GetExtension(path);
+            var extension = Path.GetExtension(path.AsSpan());
 
             if (!options.StubFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
             {
                 return false;
             }
 
-            path = Path.GetFileNameWithoutExtension(path);
-            var token = Path.GetExtension(path).TrimStart('.');
+            var token = Path.GetExtension(Path.GetFileNameWithoutExtension(path.AsSpan())).TrimStart('.');
 
             foreach (var rule in options.StubTypes)
             {
-                if (string.Equals(rule.Token, token, StringComparison.OrdinalIgnoreCase))
+                if (token.Equals(rule.Token, StringComparison.OrdinalIgnoreCase))
                 {
                     stubType = rule.StubType;
                     return true;

+ 1 - 1
Emby.Photos/PhotoProvider.cs

@@ -61,7 +61,7 @@ namespace Emby.Photos
             item.SetImagePath(ImageType.Primary, item.Path);
 
             // Examples: https://github.com/mono/taglib-sharp/blob/a5f6949a53d09ce63ee7495580d6802921a21f14/tests/fixtures/TagLib.Tests.Images/NullOrientationTest.cs
-            if (_includeExtensions.Contains(Path.GetExtension(item.Path), StringComparison.OrdinalIgnoreCase))
+            if (_includeExtensions.Contains(Path.GetExtension(item.Path.AsSpan()), StringComparison.OrdinalIgnoreCase))
             {
                 try
                 {

+ 5 - 75
Emby.Server.Implementations/ApplicationHost.cs

@@ -101,7 +101,6 @@ using Microsoft.AspNetCore.Mvc;
 using Microsoft.EntityFrameworkCore;
 using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Hosting;
 using Microsoft.Extensions.Logging;
 using Prometheus.DotNetRuntime;
 using static MediaBrowser.Controller.Extensions.ConfigurationExtensions;
@@ -133,7 +132,7 @@ namespace Emby.Server.Implementations
         /// <value>All concrete types.</value>
         private Type[] _allConcreteTypes;
 
-        private bool _disposed = false;
+        private bool _disposed;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="ApplicationHost"/> class.
@@ -184,26 +183,16 @@ namespace Emby.Server.Implementations
 
         public bool CoreStartupHasCompleted { get; private set; }
 
-        public virtual bool CanLaunchWebBrowser => Environment.UserInteractive
-            && !_startupOptions.IsService
-            && (OperatingSystem.IsWindows() || OperatingSystem.IsMacOS());
-
         /// <summary>
         /// Gets the <see cref="INetworkManager"/> singleton instance.
         /// </summary>
         public INetworkManager NetManager { get; private set; }
 
-        /// <summary>
-        /// Gets a value indicating whether this instance has changes that require the entire application to restart.
-        /// </summary>
-        /// <value><c>true</c> if this instance has pending application restart; otherwise, <c>false</c>.</value>
-        public bool HasPendingRestart { get; private set; }
-
         /// <inheritdoc />
-        public bool IsShuttingDown { get; private set; }
+        public bool HasPendingRestart { get; private set; }
 
         /// <inheritdoc />
-        public bool ShouldRestart { get; private set; }
+        public bool ShouldRestart { get; set; }
 
         /// <summary>
         /// Gets the logger.
@@ -507,6 +496,8 @@ namespace Emby.Server.Implementations
             serviceCollection.AddSingleton<IFileSystem, ManagedFileSystem>();
             serviceCollection.AddSingleton<IShortcutHandler, MbLinkShortcutHandler>();
 
+            serviceCollection.AddScoped<ISystemManager, SystemManager>();
+
             serviceCollection.AddSingleton<TmdbClientManager>();
 
             serviceCollection.AddSingleton(NetManager);
@@ -850,24 +841,6 @@ namespace Emby.Server.Implementations
             }
         }
 
-        /// <inheritdoc />
-        public void Restart()
-        {
-            ShouldRestart = true;
-            Shutdown();
-        }
-
-        /// <inheritdoc />
-        public void Shutdown()
-        {
-            Task.Run(async () =>
-            {
-                await Task.Delay(100).ConfigureAwait(false);
-                IsShuttingDown = true;
-                Resolve<IHostApplicationLifetime>().StopApplication();
-            });
-        }
-
         /// <summary>
         /// Gets the composable part assemblies.
         /// </summary>
@@ -923,49 +896,6 @@ namespace Emby.Server.Implementations
 
         protected abstract IEnumerable<Assembly> GetAssembliesWithPartsInternal();
 
-        /// <summary>
-        /// Gets the system status.
-        /// </summary>
-        /// <param name="request">Where this request originated.</param>
-        /// <returns>SystemInfo.</returns>
-        public SystemInfo GetSystemInfo(HttpRequest request)
-        {
-            return new SystemInfo
-            {
-                HasPendingRestart = HasPendingRestart,
-                IsShuttingDown = IsShuttingDown,
-                Version = ApplicationVersionString,
-                WebSocketPortNumber = HttpPort,
-                CompletedInstallations = Resolve<IInstallationManager>().CompletedInstallations.ToArray(),
-                Id = SystemId,
-                ProgramDataPath = ApplicationPaths.ProgramDataPath,
-                WebPath = ApplicationPaths.WebPath,
-                LogPath = ApplicationPaths.LogDirectoryPath,
-                ItemsByNamePath = ApplicationPaths.InternalMetadataPath,
-                InternalMetadataPath = ApplicationPaths.InternalMetadataPath,
-                CachePath = ApplicationPaths.CachePath,
-                CanLaunchWebBrowser = CanLaunchWebBrowser,
-                TranscodingTempPath = ConfigurationManager.GetTranscodePath(),
-                ServerName = FriendlyName,
-                LocalAddress = GetSmartApiUrl(request),
-                SupportsLibraryMonitor = true,
-                PackageName = _startupOptions.PackageName
-            };
-        }
-
-        public PublicSystemInfo GetPublicSystemInfo(HttpRequest request)
-        {
-            return new PublicSystemInfo
-            {
-                Version = ApplicationVersionString,
-                ProductName = ApplicationProductName,
-                Id = SystemId,
-                ServerName = FriendlyName,
-                LocalAddress = GetSmartApiUrl(request),
-                StartupWizardCompleted = ConfigurationManager.CommonConfiguration.IsStartupWizardCompleted
-            };
-        }
-
         /// <inheritdoc/>
         public string GetSmartApiUrl(IPAddress remoteAddr)
         {

+ 4 - 2
Emby.Server.Implementations/IO/ManagedFileSystem.cs

@@ -103,15 +103,17 @@ namespace Emby.Server.Implementations.IO
                 return filePath;
             }
 
+            var filePathSpan = filePath.AsSpan();
+
             // relative path
             if (firstChar == '\\')
             {
-                filePath = filePath.Substring(1);
+                filePathSpan = filePathSpan.Slice(1);
             }
 
             try
             {
-                return Path.GetFullPath(Path.Combine(folderPath, filePath));
+                return Path.GetFullPath(Path.Join(folderPath, filePathSpan));
             }
             catch (ArgumentException)
             {

+ 16 - 15
Emby.Server.Implementations/Library/LibraryManager.cs

@@ -46,7 +46,6 @@ using MediaBrowser.Model.IO;
 using MediaBrowser.Model.Library;
 using MediaBrowser.Model.Querying;
 using MediaBrowser.Model.Tasks;
-using Microsoft.Extensions.Caching.Memory;
 using Microsoft.Extensions.Logging;
 using Episode = MediaBrowser.Controller.Entities.TV.Episode;
 using EpisodeInfo = Emby.Naming.TV.EpisodeInfo;
@@ -839,19 +838,12 @@ namespace Emby.Server.Implementations.Library
         {
             var path = Person.GetPath(name);
             var id = GetItemByNameId<Person>(path);
-            if (GetItemById(id) is not Person item)
+            if (GetItemById(id) is Person item)
             {
-                item = new Person
-                {
-                    Name = name,
-                    Id = id,
-                    DateCreated = DateTime.UtcNow,
-                    DateModified = DateTime.UtcNow,
-                    Path = path
-                };
+                return item;
             }
 
-            return item;
+            return null;
         }
 
         /// <summary>
@@ -1162,7 +1154,7 @@ namespace Emby.Server.Implementations.Library
                 Name = Path.GetFileName(dir),
 
                 Locations = _fileSystem.GetFilePaths(dir, false)
-                .Where(i => string.Equals(ShortcutFileExtension, Path.GetExtension(i), StringComparison.OrdinalIgnoreCase))
+                .Where(i => Path.GetExtension(i.AsSpan()).Equals(ShortcutFileExtension, StringComparison.OrdinalIgnoreCase))
                     .Select(i =>
                     {
                         try
@@ -2900,9 +2892,18 @@ namespace Emby.Server.Implementations.Library
                 var saveEntity = false;
                 var personEntity = GetPerson(person.Name);
 
-                // if PresentationUniqueKey is empty it's likely a new item.
-                if (string.IsNullOrEmpty(personEntity.PresentationUniqueKey))
+                if (personEntity is null)
                 {
+                    var path = Person.GetPath(person.Name);
+                    personEntity = new Person()
+                    {
+                        Name = person.Name,
+                        Id = GetItemByNameId<Person>(path),
+                        DateCreated = DateTime.UtcNow,
+                        DateModified = DateTime.UtcNow,
+                        Path = path
+                    };
+
                     personEntity.PresentationUniqueKey = personEntity.CreatePresentationUniqueKey();
                     saveEntity = true;
                 }
@@ -3135,7 +3136,7 @@ namespace Emby.Server.Implementations.Library
             }
 
             var shortcut = _fileSystem.GetFilePaths(virtualFolderPath, true)
-                .Where(i => string.Equals(ShortcutFileExtension, Path.GetExtension(i), StringComparison.OrdinalIgnoreCase))
+                .Where(i => Path.GetExtension(i.AsSpan()).Equals(ShortcutFileExtension, StringComparison.OrdinalIgnoreCase))
                 .FirstOrDefault(f => _appHost.ExpandVirtualPath(_fileSystem.ResolveShortcut(f)).Equals(mediaPath, StringComparison.OrdinalIgnoreCase));
 
             if (!string.IsNullOrEmpty(shortcut))

+ 3 - 3
Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs

@@ -94,9 +94,9 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio
 
             if (AudioFileParser.IsAudioFile(args.Path, _namingOptions))
             {
-                var extension = Path.GetExtension(args.Path);
+                var extension = Path.GetExtension(args.Path.AsSpan());
 
-                if (string.Equals(extension, ".cue", StringComparison.OrdinalIgnoreCase))
+                if (extension.Equals(".cue", StringComparison.OrdinalIgnoreCase))
                 {
                     // if audio file exists of same name, return null
                     return null;
@@ -128,7 +128,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio
 
                 if (item is not null)
                 {
-                    item.IsShortcut = string.Equals(extension, ".strm", StringComparison.OrdinalIgnoreCase);
+                    item.IsShortcut = extension.Equals(".strm", StringComparison.OrdinalIgnoreCase);
 
                     item.IsInMixedFolder = true;
                 }

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

@@ -263,7 +263,7 @@ namespace Emby.Server.Implementations.Library.Resolvers
                 return false;
             }
 
-            return directoryService.GetFilePaths(fullPath).Any(i => string.Equals(Path.GetExtension(i), ".vob", StringComparison.OrdinalIgnoreCase));
+            return directoryService.GetFilePaths(fullPath).Any(i => Path.GetExtension(i.AsSpan()).Equals(".vob", StringComparison.OrdinalIgnoreCase));
         }
 
         /// <summary>

+ 4 - 5
Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs

@@ -32,9 +32,9 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books
                 return GetBook(args);
             }
 
-            var extension = Path.GetExtension(args.Path);
+            var extension = Path.GetExtension(args.Path.AsSpan());
 
-            if (extension is not null && _validExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
+            if (_validExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
             {
                 // It's a book
                 return new Book
@@ -51,12 +51,11 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books
         {
             var bookFiles = args.FileSystemChildren.Where(f =>
             {
-                var fileExtension = Path.GetExtension(f.FullName)
-                    ?? string.Empty;
+                var fileExtension = Path.GetExtension(f.FullName.AsSpan());
 
                 return _validExtensions.Contains(
                     fileExtension,
-                    StringComparer.OrdinalIgnoreCase);
+                    StringComparison.OrdinalIgnoreCase);
             }).ToList();
 
             // Don't return a Book if there is more (or less) than one document in the directory

+ 3 - 1
Emby.Server.Implementations/Localization/Core/ml.json

@@ -121,5 +121,7 @@
     "TaskOptimizeDatabaseDescription": "ഡാറ്റാബേസ് ചുരുക്കുകയും സ്വതന്ത്ര ഇടം വെട്ടിച്ചുരുക്കുകയും ചെയ്യുന്നു. ലൈബ്രറി സ്‌കാൻ ചെയ്‌തതിനുശേഷം അല്ലെങ്കിൽ ഡാറ്റാബേസ് പരിഷ്‌ക്കരണങ്ങളെ സൂചിപ്പിക്കുന്ന മറ്റ് മാറ്റങ്ങൾ ചെയ്‌തതിന് ശേഷം ഈ ടാസ്‌ക് പ്രവർത്തിപ്പിക്കുന്നത് പ്രകടനം മെച്ചപ്പെടുത്തും.",
     "TaskOptimizeDatabase": "ഡാറ്റാബേസ് ഒപ്റ്റിമൈസ് ചെയ്യുക",
     "HearingImpaired": "കേൾവി തകരാറുകൾ",
-    "External": "പുറമേയുള്ള"
+    "External": "പുറമേയുള്ള",
+    "TaskKeyframeExtractorDescription": "കൂടുതൽ കൃത്യമായ HLS പ്ലേലിസ്റ്റുകൾ സൃഷ്‌ടിക്കുന്നതിന് വീഡിയോ ഫയലുകളിൽ നിന്ന് കീഫ്രെയിമുകൾ എക്‌സ്‌ട്രാക്‌റ്റ് ചെയ്യുന്നു. ഈ പ്രവർത്തനം പൂർത്തിയാവാൻ കുറച്ചധികം സമയം എടുത്തേക്കാം.",
+    "TaskKeyframeExtractor": "കീഫ്രെയിം എക്സ്ട്രാക്റ്റർ"
 }

+ 1 - 1
Emby.Server.Implementations/MediaEncoder/EncodingManager.cs

@@ -222,7 +222,7 @@ namespace Emby.Server.Implementations.MediaEncoder
         {
             var deadImages = images
                 .Except(chapters.Select(i => i.ImagePath).Where(i => !string.IsNullOrEmpty(i)), StringComparer.OrdinalIgnoreCase)
-                .Where(i => BaseItem.SupportedImageExtensions.Contains(Path.GetExtension(i), StringComparison.OrdinalIgnoreCase))
+                .Where(i => BaseItem.SupportedImageExtensions.Contains(Path.GetExtension(i.AsSpan()), StringComparison.OrdinalIgnoreCase))
                 .ToList();
 
             foreach (var image in deadImages)

+ 6 - 10
Emby.Server.Implementations/Playlists/PlaylistManager.cs

@@ -327,9 +327,9 @@ namespace Emby.Server.Implementations.Playlists
             // this is probably best done as a metadata provider
             // saving a file over itself will require some work to prevent this from happening when not needed
             var playlistPath = item.Path;
-            var extension = Path.GetExtension(playlistPath);
+            var extension = Path.GetExtension(playlistPath.AsSpan());
 
-            if (string.Equals(".wpl", extension, StringComparison.OrdinalIgnoreCase))
+            if (extension.Equals(".wpl", StringComparison.OrdinalIgnoreCase))
             {
                 var playlist = new WplPlaylist();
                 foreach (var child in item.GetLinkedChildren())
@@ -362,8 +362,7 @@ namespace Emby.Server.Implementations.Playlists
                 string text = new WplContent().ToText(playlist);
                 File.WriteAllText(playlistPath, text);
             }
-
-            if (string.Equals(".zpl", extension, StringComparison.OrdinalIgnoreCase))
+            else if (extension.Equals(".zpl", StringComparison.OrdinalIgnoreCase))
             {
                 var playlist = new ZplPlaylist();
                 foreach (var child in item.GetLinkedChildren())
@@ -396,8 +395,7 @@ namespace Emby.Server.Implementations.Playlists
                 string text = new ZplContent().ToText(playlist);
                 File.WriteAllText(playlistPath, text);
             }
-
-            if (string.Equals(".m3u", extension, StringComparison.OrdinalIgnoreCase))
+            else if (extension.Equals(".m3u", StringComparison.OrdinalIgnoreCase))
             {
                 var playlist = new M3uPlaylist
                 {
@@ -428,8 +426,7 @@ namespace Emby.Server.Implementations.Playlists
                 string text = new M3uContent().ToText(playlist);
                 File.WriteAllText(playlistPath, text);
             }
-
-            if (string.Equals(".m3u8", extension, StringComparison.OrdinalIgnoreCase))
+            else if (extension.Equals(".m3u8", StringComparison.OrdinalIgnoreCase))
             {
                 var playlist = new M3uPlaylist();
                 playlist.IsExtended = true;
@@ -458,8 +455,7 @@ namespace Emby.Server.Implementations.Playlists
                 string text = new M3uContent().ToText(playlist);
                 File.WriteAllText(playlistPath, text);
             }
-
-            if (string.Equals(".pls", extension, StringComparison.OrdinalIgnoreCase))
+            else if (extension.Equals(".pls", StringComparison.OrdinalIgnoreCase))
             {
                 var playlist = new PlsPlaylist();
                 foreach (var child in item.GetLinkedChildren())

+ 108 - 0
Emby.Server.Implementations/SystemManager.cs

@@ -0,0 +1,108 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Updates;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Model.System;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Hosting;
+
+namespace Emby.Server.Implementations;
+
+/// <inheritdoc />
+public class SystemManager : ISystemManager
+{
+    private readonly IHostApplicationLifetime _applicationLifetime;
+    private readonly IServerApplicationHost _applicationHost;
+    private readonly IServerApplicationPaths _applicationPaths;
+    private readonly IServerConfigurationManager _configurationManager;
+    private readonly IStartupOptions _startupOptions;
+    private readonly IInstallationManager _installationManager;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="SystemManager"/> class.
+    /// </summary>
+    /// <param name="applicationLifetime">Instance of <see cref="IHostApplicationLifetime"/>.</param>
+    /// <param name="applicationHost">Instance of <see cref="IServerApplicationHost"/>.</param>
+    /// <param name="applicationPaths">Instance of <see cref="IServerApplicationPaths"/>.</param>
+    /// <param name="configurationManager">Instance of <see cref="IServerConfigurationManager"/>.</param>
+    /// <param name="startupOptions">Instance of <see cref="IStartupOptions"/>.</param>
+    /// <param name="installationManager">Instance of <see cref="IInstallationManager"/>.</param>
+    public SystemManager(
+        IHostApplicationLifetime applicationLifetime,
+        IServerApplicationHost applicationHost,
+        IServerApplicationPaths applicationPaths,
+        IServerConfigurationManager configurationManager,
+        IStartupOptions startupOptions,
+        IInstallationManager installationManager)
+    {
+        _applicationLifetime = applicationLifetime;
+        _applicationHost = applicationHost;
+        _applicationPaths = applicationPaths;
+        _configurationManager = configurationManager;
+        _startupOptions = startupOptions;
+        _installationManager = installationManager;
+    }
+
+    private bool CanLaunchWebBrowser => Environment.UserInteractive
+        && !_startupOptions.IsService
+        && (OperatingSystem.IsWindows() || OperatingSystem.IsMacOS());
+
+    /// <inheritdoc />
+    public SystemInfo GetSystemInfo(HttpRequest request)
+    {
+        return new SystemInfo
+        {
+            HasPendingRestart = _applicationHost.HasPendingRestart,
+            IsShuttingDown = _applicationLifetime.ApplicationStopping.IsCancellationRequested,
+            Version = _applicationHost.ApplicationVersionString,
+            WebSocketPortNumber = _applicationHost.HttpPort,
+            CompletedInstallations = _installationManager.CompletedInstallations.ToArray(),
+            Id = _applicationHost.SystemId,
+            ProgramDataPath = _applicationPaths.ProgramDataPath,
+            WebPath = _applicationPaths.WebPath,
+            LogPath = _applicationPaths.LogDirectoryPath,
+            ItemsByNamePath = _applicationPaths.InternalMetadataPath,
+            InternalMetadataPath = _applicationPaths.InternalMetadataPath,
+            CachePath = _applicationPaths.CachePath,
+            CanLaunchWebBrowser = CanLaunchWebBrowser,
+            TranscodingTempPath = _configurationManager.GetTranscodePath(),
+            ServerName = _applicationHost.FriendlyName,
+            LocalAddress = _applicationHost.GetSmartApiUrl(request),
+            SupportsLibraryMonitor = true,
+            PackageName = _startupOptions.PackageName
+        };
+    }
+
+    /// <inheritdoc />
+    public PublicSystemInfo GetPublicSystemInfo(HttpRequest request)
+    {
+        return new PublicSystemInfo
+        {
+            Version = _applicationHost.ApplicationVersionString,
+            ProductName = _applicationHost.Name,
+            Id = _applicationHost.SystemId,
+            ServerName = _applicationHost.FriendlyName,
+            LocalAddress = _applicationHost.GetSmartApiUrl(request),
+            StartupWizardCompleted = _configurationManager.CommonConfiguration.IsStartupWizardCompleted
+        };
+    }
+
+    /// <inheritdoc />
+    public void Restart() => ShutdownInternal(true);
+
+    /// <inheritdoc />
+    public void Shutdown() => ShutdownInternal(false);
+
+    private void ShutdownInternal(bool restart)
+    {
+        Task.Run(async () =>
+        {
+            await Task.Delay(100).ConfigureAwait(false);
+            _applicationHost.ShouldRestart = restart;
+            _applicationLifetime.StopApplication();
+        });
+    }
+}

+ 1 - 2
Emby.Server.Implementations/Updates/InstallationManager.cs

@@ -504,8 +504,7 @@ namespace Emby.Server.Implementations.Updates
 
         private async Task PerformPackageInstallation(InstallationInfo package, PluginStatus status, CancellationToken cancellationToken)
         {
-            var extension = Path.GetExtension(package.SourceUrl);
-            if (!string.Equals(extension, ".zip", StringComparison.OrdinalIgnoreCase))
+            if (!Path.GetExtension(package.SourceUrl.AsSpan()).Equals(".zip", StringComparison.OrdinalIgnoreCase))
             {
                 _logger.LogError("Only zip packages are supported. {SourceUrl} is not a zip archive.", package.SourceUrl);
                 return;

+ 18 - 15
Jellyfin.Api/Controllers/DynamicHlsController.cs

@@ -45,6 +45,8 @@ public class DynamicHlsController : BaseJellyfinApiController
     private const string DefaultEventEncoderPreset = "superfast";
     private const TranscodingJobType TranscodingJobType = MediaBrowser.Controller.MediaEncoding.TranscodingJobType.Hls;
 
+    private readonly Version _minFFmpegFlacInMp4 = new Version(6, 0);
+
     private readonly ILibraryManager _libraryManager;
     private readonly IUserManager _userManager;
     private readonly IDlnaManager _dlnaManager;
@@ -1705,16 +1707,28 @@ public class DynamicHlsController : BaseJellyfinApiController
         var audioCodec = _encodingHelper.GetAudioEncoder(state);
         var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container);
 
+        // opus, dts, truehd and flac (in FFmpeg 5 and older) are experimental in mp4 muxer
+        var strictArgs = string.Empty;
+        var actualOutputAudioCodec = state.ActualOutputAudioCodec;
+        if (string.Equals(actualOutputAudioCodec, "opus", StringComparison.OrdinalIgnoreCase)
+            || string.Equals(actualOutputAudioCodec, "dts", StringComparison.OrdinalIgnoreCase)
+            || string.Equals(actualOutputAudioCodec, "truehd", StringComparison.OrdinalIgnoreCase)
+            || (string.Equals(actualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase)
+                && _mediaEncoder.EncoderVersion < _minFFmpegFlacInMp4))
+        {
+            strictArgs = " -strict -2";
+        }
+
         if (!state.IsOutputVideo)
         {
             if (EncodingHelper.IsCopyCodec(audioCodec))
             {
-                return "-acodec copy -strict -2" + bitStreamArgs;
+                return "-acodec copy" + bitStreamArgs + strictArgs;
             }
 
             var audioTranscodeParams = string.Empty;
 
-            audioTranscodeParams += "-acodec " + audioCodec + bitStreamArgs;
+            audioTranscodeParams += "-acodec " + audioCodec + bitStreamArgs + strictArgs;
 
             var audioBitrate = state.OutputAudioBitrate;
             var audioChannels = state.OutputAudioChannels;
@@ -1746,17 +1760,6 @@ public class DynamicHlsController : BaseJellyfinApiController
             return audioTranscodeParams;
         }
 
-        // dts, flac, opus and truehd are experimental in mp4 muxer
-        var strictArgs = string.Empty;
-        var actualOutputAudioCodec = state.ActualOutputAudioCodec;
-        if (string.Equals(actualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase)
-            || string.Equals(actualOutputAudioCodec, "opus", StringComparison.OrdinalIgnoreCase)
-            || string.Equals(actualOutputAudioCodec, "dts", StringComparison.OrdinalIgnoreCase)
-            || string.Equals(actualOutputAudioCodec, "truehd", StringComparison.OrdinalIgnoreCase))
-        {
-            strictArgs = " -strict -2";
-        }
-
         if (EncodingHelper.IsCopyCodec(audioCodec))
         {
             var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
@@ -2041,9 +2044,9 @@ public class DynamicHlsController : BaseJellyfinApiController
             return null;
         }
 
-        var playlistFilename = Path.GetFileNameWithoutExtension(playlist);
+        var playlistFilename = Path.GetFileNameWithoutExtension(playlist.AsSpan());
 
-        var indexString = Path.GetFileNameWithoutExtension(file.Name).Substring(playlistFilename.Length);
+        var indexString = Path.GetFileNameWithoutExtension(file.Name.AsSpan()).Slice(playlistFilename.Length);
 
         return int.Parse(indexString, NumberStyles.Integer, CultureInfo.InvariantCulture);
     }

+ 5 - 4
Jellyfin.Api/Controllers/HlsSegmentController.cs

@@ -59,7 +59,7 @@ public class HlsSegmentController : BaseJellyfinApiController
     public ActionResult GetHlsAudioSegmentLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string segmentId)
     {
         // TODO: Deprecate with new iOS app
-        var file = segmentId + Path.GetExtension(Request.Path);
+        var file = string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan()));
         var transcodePath = _serverConfigurationManager.GetTranscodePath();
         file = Path.GetFullPath(Path.Combine(transcodePath, file));
         var fileDir = Path.GetDirectoryName(file);
@@ -85,11 +85,12 @@ public class HlsSegmentController : BaseJellyfinApiController
     [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")]
     public ActionResult GetHlsPlaylistLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string playlistId)
     {
-        var file = playlistId + Path.GetExtension(Request.Path);
+        var file = string.Concat(playlistId, Path.GetExtension(Request.Path.Value.AsSpan()));
         var transcodePath = _serverConfigurationManager.GetTranscodePath();
         file = Path.GetFullPath(Path.Combine(transcodePath, file));
         var fileDir = Path.GetDirectoryName(file);
-        if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture) || Path.GetExtension(file) != ".m3u8")
+        if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture)
+            || Path.GetExtension(file.AsSpan()).Equals(".m3u8", StringComparison.OrdinalIgnoreCase))
         {
             return BadRequest("Invalid segment.");
         }
@@ -138,7 +139,7 @@ public class HlsSegmentController : BaseJellyfinApiController
         [FromRoute, Required] string segmentId,
         [FromRoute, Required] string segmentContainer)
     {
-        var file = segmentId + Path.GetExtension(Request.Path);
+        var file = string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan()));
         var transcodeFolderPath = _serverConfigurationManager.GetTranscodePath();
 
         file = Path.GetFullPath(Path.Combine(transcodeFolderPath, file));

+ 19 - 24
Jellyfin.Api/Controllers/ImageController.cs

@@ -7,6 +7,7 @@ using System.Globalization;
 using System.IO;
 using System.Linq;
 using System.Net.Mime;
+using System.Security.Cryptography;
 using System.Threading;
 using System.Threading.Tasks;
 using Jellyfin.Api.Attributes;
@@ -78,6 +79,9 @@ public class ImageController : BaseJellyfinApiController
         _appPaths = appPaths;
     }
 
+    private static Stream GetFromBase64Stream(Stream inputStream)
+        => new CryptoStream(inputStream, new FromBase64Transform(), CryptoStreamMode.Read);
+
     /// <summary>
     /// Sets the user image.
     /// </summary>
@@ -116,8 +120,8 @@ public class ImageController : BaseJellyfinApiController
             return BadRequest("Incorrect ContentType.");
         }
 
-        var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
-        await using (memoryStream.ConfigureAwait(false))
+        var stream = GetFromBase64Stream(Request.Body);
+        await using (stream.ConfigureAwait(false))
         {
             // Handle image/png; charset=utf-8
             var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
@@ -130,7 +134,7 @@ public class ImageController : BaseJellyfinApiController
             user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + extension));
 
             await _providerManager
-                .SaveImage(memoryStream, mimeType, user.ProfileImage.Path)
+                .SaveImage(stream, mimeType, user.ProfileImage.Path)
                 .ConfigureAwait(false);
             await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
 
@@ -176,8 +180,8 @@ public class ImageController : BaseJellyfinApiController
             return BadRequest("Incorrect ContentType.");
         }
 
-        var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
-        await using (memoryStream.ConfigureAwait(false))
+        var stream = GetFromBase64Stream(Request.Body);
+        await using (stream.ConfigureAwait(false))
         {
             // Handle image/png; charset=utf-8
             var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
@@ -190,7 +194,7 @@ public class ImageController : BaseJellyfinApiController
             user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + extension));
 
             await _providerManager
-                .SaveImage(memoryStream, mimeType, user.ProfileImage.Path)
+                .SaveImage(stream, mimeType, user.ProfileImage.Path)
                 .ConfigureAwait(false);
             await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
 
@@ -372,12 +376,12 @@ public class ImageController : BaseJellyfinApiController
             return BadRequest("Incorrect ContentType.");
         }
 
-        var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
-        await using (memoryStream.ConfigureAwait(false))
+        var stream = GetFromBase64Stream(Request.Body);
+        await using (stream.ConfigureAwait(false))
         {
             // Handle image/png; charset=utf-8
             var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
-            await _providerManager.SaveImage(item, memoryStream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false);
+            await _providerManager.SaveImage(item, stream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false);
             await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
 
             return NoContent();
@@ -416,12 +420,12 @@ public class ImageController : BaseJellyfinApiController
             return BadRequest("Incorrect ContentType.");
         }
 
-        var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
-        await using (memoryStream.ConfigureAwait(false))
+        var stream = GetFromBase64Stream(Request.Body);
+        await using (stream.ConfigureAwait(false))
         {
             // Handle image/png; charset=utf-8
             var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
-            await _providerManager.SaveImage(item, memoryStream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false);
+            await _providerManager.SaveImage(item, stream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false);
             await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
 
             return NoContent();
@@ -1792,8 +1796,8 @@ public class ImageController : BaseJellyfinApiController
             return BadRequest("Incorrect ContentType.");
         }
 
-        var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
-        await using (memoryStream.ConfigureAwait(false))
+        var stream = GetFromBase64Stream(Request.Body);
+        await using (stream.ConfigureAwait(false))
         {
             var filePath = Path.Combine(_appPaths.DataPath, "splashscreen-upload" + extension);
             var brandingOptions = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding");
@@ -1803,7 +1807,7 @@ public class ImageController : BaseJellyfinApiController
             var fs = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous);
             await using (fs.ConfigureAwait(false))
             {
-                await memoryStream.CopyToAsync(fs, CancellationToken.None).ConfigureAwait(false);
+                await stream.CopyToAsync(fs, CancellationToken.None).ConfigureAwait(false);
             }
 
             return NoContent();
@@ -1833,15 +1837,6 @@ public class ImageController : BaseJellyfinApiController
         return NoContent();
     }
 
-    private static async Task<MemoryStream> GetMemoryStream(Stream inputStream)
-    {
-        using var reader = new StreamReader(inputStream);
-        var text = await reader.ReadToEndAsync().ConfigureAwait(false);
-
-        var bytes = Convert.FromBase64String(text);
-        return new MemoryStream(bytes, 0, bytes.Length, false, true);
-    }
-
     private ImageInfo? GetImageInfo(BaseItem item, ItemImageInfo info, int? imageIndex)
     {
         int? width = null;

+ 4 - 4
Jellyfin.Api/Controllers/SubtitleController.cs

@@ -6,6 +6,7 @@ using System.Globalization;
 using System.IO;
 using System.Linq;
 using System.Net.Mime;
+using System.Security.Cryptography;
 using System.Text;
 using System.Threading;
 using System.Threading.Tasks;
@@ -405,9 +406,8 @@ public class SubtitleController : BaseJellyfinApiController
         [FromBody, Required] UploadSubtitleDto body)
     {
         var video = (Video)_libraryManager.GetItemById(itemId);
-        var data = Convert.FromBase64String(body.Data);
-        var memoryStream = new MemoryStream(data, 0, data.Length, false, true);
-        await using (memoryStream.ConfigureAwait(false))
+        var stream = new CryptoStream(Request.Body, new FromBase64Transform(), CryptoStreamMode.Read);
+        await using (stream.ConfigureAwait(false))
         {
             await _subtitleManager.UploadSubtitle(
                 video,
@@ -417,7 +417,7 @@ public class SubtitleController : BaseJellyfinApiController
                     Language = body.Language,
                     IsForced = body.IsForced,
                     IsHearingImpaired = body.IsHearingImpaired,
-                    Stream = memoryStream
+                    Stream = stream
                 }).ConfigureAwait(false);
             _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
 

+ 22 - 25
Jellyfin.Api/Controllers/SystemController.cs

@@ -10,7 +10,6 @@ using MediaBrowser.Common.Configuration;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Controller;
-using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Model.IO;
 using MediaBrowser.Model.Net;
 using MediaBrowser.Model.System;
@@ -26,32 +25,36 @@ namespace Jellyfin.Api.Controllers;
 /// </summary>
 public class SystemController : BaseJellyfinApiController
 {
+    private readonly ILogger<SystemController> _logger;
     private readonly IServerApplicationHost _appHost;
     private readonly IApplicationPaths _appPaths;
     private readonly IFileSystem _fileSystem;
-    private readonly INetworkManager _network;
-    private readonly ILogger<SystemController> _logger;
+    private readonly INetworkManager _networkManager;
+    private readonly ISystemManager _systemManager;
 
     /// <summary>
     /// Initializes a new instance of the <see cref="SystemController"/> class.
     /// </summary>
-    /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param>
+    /// <param name="logger">Instance of <see cref="ILogger{SystemController}"/> interface.</param>
+    /// <param name="appPaths">Instance of <see cref="IServerApplicationPaths"/> interface.</param>
     /// <param name="appHost">Instance of <see cref="IServerApplicationHost"/> interface.</param>
     /// <param name="fileSystem">Instance of <see cref="IFileSystem"/> interface.</param>
-    /// <param name="network">Instance of <see cref="INetworkManager"/> interface.</param>
-    /// <param name="logger">Instance of <see cref="ILogger{SystemController}"/> interface.</param>
+    /// <param name="networkManager">Instance of <see cref="INetworkManager"/> interface.</param>
+    /// <param name="systemManager">Instance of <see cref="ISystemManager"/> interface.</param>
     public SystemController(
-        IServerConfigurationManager serverConfigurationManager,
+        ILogger<SystemController> logger,
         IServerApplicationHost appHost,
+        IServerApplicationPaths appPaths,
         IFileSystem fileSystem,
-        INetworkManager network,
-        ILogger<SystemController> logger)
+        INetworkManager networkManager,
+        ISystemManager systemManager)
     {
-        _appPaths = serverConfigurationManager.ApplicationPaths;
+        _logger = logger;
         _appHost = appHost;
+        _appPaths = appPaths;
         _fileSystem = fileSystem;
-        _network = network;
-        _logger = logger;
+        _networkManager = networkManager;
+        _systemManager = systemManager;
     }
 
     /// <summary>
@@ -65,9 +68,7 @@ public class SystemController : BaseJellyfinApiController
     [ProducesResponseType(StatusCodes.Status200OK)]
     [ProducesResponseType(StatusCodes.Status403Forbidden)]
     public ActionResult<SystemInfo> GetSystemInfo()
-    {
-        return _appHost.GetSystemInfo(Request);
-    }
+        => _systemManager.GetSystemInfo(Request);
 
     /// <summary>
     /// Gets public information about the server.
@@ -77,9 +78,7 @@ public class SystemController : BaseJellyfinApiController
     [HttpGet("Info/Public")]
     [ProducesResponseType(StatusCodes.Status200OK)]
     public ActionResult<PublicSystemInfo> GetPublicSystemInfo()
-    {
-        return _appHost.GetPublicSystemInfo(Request);
-    }
+        => _systemManager.GetPublicSystemInfo(Request);
 
     /// <summary>
     /// Pings the system.
@@ -90,9 +89,7 @@ public class SystemController : BaseJellyfinApiController
     [HttpPost("Ping", Name = "PostPingSystem")]
     [ProducesResponseType(StatusCodes.Status200OK)]
     public ActionResult<string> PingSystem()
-    {
-        return _appHost.Name;
-    }
+        => _appHost.Name;
 
     /// <summary>
     /// Restarts the application.
@@ -106,7 +103,7 @@ public class SystemController : BaseJellyfinApiController
     [ProducesResponseType(StatusCodes.Status403Forbidden)]
     public ActionResult RestartApplication()
     {
-        _appHost.Restart();
+        _systemManager.Restart();
         return NoContent();
     }
 
@@ -122,7 +119,7 @@ public class SystemController : BaseJellyfinApiController
     [ProducesResponseType(StatusCodes.Status403Forbidden)]
     public ActionResult ShutdownApplication()
     {
-        _appHost.Shutdown();
+        _systemManager.Shutdown();
         return NoContent();
     }
 
@@ -180,7 +177,7 @@ public class SystemController : BaseJellyfinApiController
         return new EndPointInfo
         {
             IsLocal = HttpContext.IsLocal(),
-            IsInNetwork = _network.IsInLocalNetwork(HttpContext.GetNormalizedRemoteIP())
+            IsInNetwork = _networkManager.IsInLocalNetwork(HttpContext.GetNormalizedRemoteIP())
         };
     }
 
@@ -218,7 +215,7 @@ public class SystemController : BaseJellyfinApiController
     [ProducesResponseType(StatusCodes.Status200OK)]
     public ActionResult<IEnumerable<WakeOnLanInfo>> GetWakeOnLanInfo()
     {
-        var result = _network.GetMacAddresses()
+        var result = _networkManager.GetMacAddresses()
             .Select(i => new WakeOnLanInfo(i));
         return Ok(result);
     }

+ 1 - 34
Jellyfin.Api/Helpers/DynamicHlsHelper.cs

@@ -200,13 +200,6 @@ public class DynamicHlsHelper
 
         if (state.VideoStream is not null && state.VideoRequest is not null)
         {
-            // Provide a workaround for the case issue between flac and fLaC.
-            var flacWaPlaylist = ApplyFlacCaseWorkaround(state, basicPlaylist.ToString());
-            if (!string.IsNullOrEmpty(flacWaPlaylist))
-            {
-                builder.Append(flacWaPlaylist);
-            }
-
             var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
 
             // Provide SDR HEVC entrance for backward compatibility.
@@ -236,14 +229,7 @@ public class DynamicHlsHelper
                     }
 
                     var sdrTotalBitrate = sdrOutputAudioBitrate + sdrOutputVideoBitrate;
-                    var sdrPlaylist = AppendPlaylist(builder, state, sdrVideoUrl, sdrTotalBitrate, subtitleGroup);
-
-                    // Provide a workaround for the case issue between flac and fLaC.
-                    flacWaPlaylist = ApplyFlacCaseWorkaround(state, sdrPlaylist.ToString());
-                    if (!string.IsNullOrEmpty(flacWaPlaylist))
-                    {
-                        builder.Append(flacWaPlaylist);
-                    }
+                    AppendPlaylist(builder, state, sdrVideoUrl, sdrTotalBitrate, subtitleGroup);
 
                     // Restore the video codec
                     state.OutputVideoCodec = "copy";
@@ -274,13 +260,6 @@ public class DynamicHlsHelper
                 state.VideoStream.Level = originalLevel;
                 var newPlaylist = ReplacePlaylistCodecsField(basicPlaylist, playlistCodecsField, newPlaylistCodecsField);
                 builder.Append(newPlaylist);
-
-                // Provide a workaround for the case issue between flac and fLaC.
-                flacWaPlaylist = ApplyFlacCaseWorkaround(state, newPlaylist);
-                if (!string.IsNullOrEmpty(flacWaPlaylist))
-                {
-                    builder.Append(flacWaPlaylist);
-                }
             }
         }
 
@@ -767,16 +746,4 @@ public class DynamicHlsHelper
             newValue.ToString(),
             StringComparison.Ordinal);
     }
-
-    private string ApplyFlacCaseWorkaround(StreamState state, string srcPlaylist)
-    {
-        if (!string.Equals(state.ActualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase))
-        {
-            return string.Empty;
-        }
-
-        var newPlaylist = srcPlaylist.Replace(",flac\"", ",fLaC\"", StringComparison.Ordinal);
-
-        return newPlaylist.Contains(",fLaC\"", StringComparison.Ordinal) ? newPlaylist : string.Empty;
-    }
 }

+ 5 - 3
Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs

@@ -5,7 +5,9 @@ using System.Text;
 namespace Jellyfin.Api.Helpers;
 
 /// <summary>
-/// Hls Codec string helpers.
+/// Helpers to generate HLS codec strings according to
+/// <a href="https://datatracker.ietf.org/doc/html/rfc6381#section-3.3">RFC 6381 section 3.3</a>
+/// and the <a href="https://mp4ra.org">MP4 Registration Authority</a>.
 /// </summary>
 public static class HlsCodecStringHelpers
 {
@@ -27,7 +29,7 @@ public static class HlsCodecStringHelpers
     /// <summary>
     /// Codec name for FLAC.
     /// </summary>
-    public const string FLAC = "flac";
+    public const string FLAC = "fLaC";
 
     /// <summary>
     /// Codec name for ALAC.
@@ -37,7 +39,7 @@ public static class HlsCodecStringHelpers
     /// <summary>
     /// Codec name for OPUS.
     /// </summary>
-    public const string OPUS = "opus";
+    public const string OPUS = "Opus";
 
     /// <summary>
     /// Gets a MP3 codec string.

+ 10 - 5
Jellyfin.Api/Helpers/StreamingHelpers.cs

@@ -191,6 +191,11 @@ public static class StreamingHelpers
             state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(streamingRequest.AudioBitRate, streamingRequest.AudioCodec, state.AudioStream, state.OutputAudioChannels) ?? 0;
         }
 
+        if (outputAudioCodec.StartsWith("pcm_", StringComparison.Ordinal))
+        {
+            containerInternal = ".pcm";
+        }
+
         state.OutputAudioCodec = outputAudioCodec;
         state.OutputContainer = (containerInternal ?? string.Empty).TrimStart('.');
         state.OutputAudioChannels = encodingHelper.GetNumAudioChannelsParam(state, state.AudioStream, state.OutputAudioCodec);
@@ -243,7 +248,7 @@ public static class StreamingHelpers
             ? GetOutputFileExtension(state, mediaSource)
             : ("." + state.OutputContainer);
 
-        state.OutputFilePath = GetOutputFilePath(state, ext!, serverConfigurationManager, streamingRequest.DeviceId, streamingRequest.PlaySessionId);
+        state.OutputFilePath = GetOutputFilePath(state, ext, serverConfigurationManager, streamingRequest.DeviceId, streamingRequest.PlaySessionId);
 
         return state;
     }
@@ -418,11 +423,11 @@ public static class StreamingHelpers
     /// <returns>System.String.</returns>
     private static string? GetOutputFileExtension(StreamState state, MediaSourceInfo? mediaSource)
     {
-        var ext = Path.GetExtension(state.RequestedUrl);
+        var ext = Path.GetExtension(state.RequestedUrl.AsSpan());
 
-        if (!string.IsNullOrEmpty(ext))
+        if (ext.IsEmpty)
         {
-            return ext;
+            return null;
         }
 
         // Try to infer based on the desired video codec
@@ -504,7 +509,7 @@ public static class StreamingHelpers
     /// <param name="deviceId">The device id.</param>
     /// <param name="playSessionId">The play session id.</param>
     /// <returns>The complete file path, including the folder, for the transcoding file.</returns>
-    private static string GetOutputFilePath(StreamState state, string outputFileExtension, IServerConfigurationManager serverConfigurationManager, string? deviceId, string? playSessionId)
+    private static string GetOutputFilePath(StreamState state, string? outputFileExtension, IServerConfigurationManager serverConfigurationManager, string? deviceId, string? playSessionId)
     {
         var data = $"{state.MediaPath}-{state.UserAgent}-{deviceId!}-{playSessionId!}";
 

+ 1 - 1
Jellyfin.Api/Helpers/TranscodingJobHelper.cs

@@ -538,7 +538,7 @@ public class TranscodingJobHelper : IDisposable
                 await _attachmentExtractor.ExtractAllAttachments(state.MediaPath, state.MediaSource, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false);
             }
 
-            if (state.SubtitleStream.IsExternal && string.Equals(Path.GetExtension(state.SubtitleStream.Path), ".mks", StringComparison.OrdinalIgnoreCase))
+            if (state.SubtitleStream.IsExternal && Path.GetExtension(state.SubtitleStream.Path.AsSpan()).Equals(".mks", StringComparison.OrdinalIgnoreCase))
             {
                 string subtitlePath = state.SubtitleStream.Path;
                 string subtitlePathArgument = string.Format(CultureInfo.InvariantCulture, "file:\"{0}\"", subtitlePath.Replace("\"", "\\\"", StringComparison.Ordinal));

+ 5 - 25
Jellyfin.Server.Implementations/Security/AuthorizationContext.cs

@@ -49,14 +49,13 @@ namespace Jellyfin.Server.Implementations.Security
         /// <summary>
         /// Gets the authorization.
         /// </summary>
-        /// <param name="httpReq">The HTTP req.</param>
+        /// <param name="httpContext">The HTTP context.</param>
         /// <returns>Dictionary{System.StringSystem.String}.</returns>
-        private async Task<AuthorizationInfo> GetAuthorization(HttpContext httpReq)
+        private async Task<AuthorizationInfo> GetAuthorization(HttpContext httpContext)
         {
-            var auth = GetAuthorizationDictionary(httpReq);
-            var authInfo = await GetAuthorizationInfoFromDictionary(auth, httpReq.Request.Headers, httpReq.Request.Query).ConfigureAwait(false);
+            var authInfo = await GetAuthorizationInfo(httpContext.Request).ConfigureAwait(false);
 
-            httpReq.Request.HttpContext.Items["AuthorizationInfo"] = authInfo;
+            httpContext.Request.HttpContext.Items["AuthorizationInfo"] = authInfo;
             return authInfo;
         }
 
@@ -80,7 +79,6 @@ namespace Jellyfin.Server.Implementations.Security
                 auth.TryGetValue("Token", out token);
             }
 
-#pragma warning disable CA1508 // string.IsNullOrEmpty(token) is always false.
             if (string.IsNullOrEmpty(token))
             {
                 token = headers["X-Emby-Token"];
@@ -118,7 +116,6 @@ namespace Jellyfin.Server.Implementations.Security
                 // Request doesn't contain a token.
                 return authInfo;
             }
-#pragma warning restore CA1508
 
             authInfo.HasToken = true;
             var dbContext = await _jellyfinDbProvider.CreateDbContextAsync().ConfigureAwait(false);
@@ -219,24 +216,7 @@ namespace Jellyfin.Server.Implementations.Security
         /// <summary>
         /// Gets the auth.
         /// </summary>
-        /// <param name="httpReq">The HTTP req.</param>
-        /// <returns>Dictionary{System.StringSystem.String}.</returns>
-        private static Dictionary<string, string>? GetAuthorizationDictionary(HttpContext httpReq)
-        {
-            var auth = httpReq.Request.Headers["X-Emby-Authorization"];
-
-            if (string.IsNullOrEmpty(auth))
-            {
-                auth = httpReq.Request.Headers[HeaderNames.Authorization];
-            }
-
-            return auth.Count > 0 ? GetAuthorization(auth[0]) : null;
-        }
-
-        /// <summary>
-        /// Gets the auth.
-        /// </summary>
-        /// <param name="httpReq">The HTTP req.</param>
+        /// <param name="httpReq">The HTTP request.</param>
         /// <returns>Dictionary{System.StringSystem.String}.</returns>
         private static Dictionary<string, string>? GetAuthorizationDictionary(HttpRequest httpReq)
         {

+ 4 - 20
MediaBrowser.Common/IApplicationHost.cs

@@ -35,21 +35,15 @@ namespace MediaBrowser.Common
         string SystemId { get; }
 
         /// <summary>
-        /// Gets a value indicating whether this instance has pending kernel reload.
+        /// Gets a value indicating whether this instance has pending changes requiring a restart.
         /// </summary>
-        /// <value><c>true</c> if this instance has pending kernel reload; otherwise, <c>false</c>.</value>
+        /// <value><c>true</c> if this instance has a pending restart; otherwise, <c>false</c>.</value>
         bool HasPendingRestart { get; }
 
         /// <summary>
-        /// Gets a value indicating whether this instance is currently shutting down.
+        /// Gets or sets a value indicating whether the application should restart.
         /// </summary>
-        /// <value><c>true</c> if this instance is shutting down; otherwise, <c>false</c>.</value>
-        bool IsShuttingDown { get; }
-
-        /// <summary>
-        /// Gets a value indicating whether the application should restart.
-        /// </summary>
-        bool ShouldRestart { get; }
+        bool ShouldRestart { get; set; }
 
         /// <summary>
         /// Gets the application version.
@@ -91,11 +85,6 @@ namespace MediaBrowser.Common
         /// </summary>
         void NotifyPendingRestart();
 
-        /// <summary>
-        /// Restarts this instance.
-        /// </summary>
-        void Restart();
-
         /// <summary>
         /// Gets the exports.
         /// </summary>
@@ -127,11 +116,6 @@ namespace MediaBrowser.Common
         /// <returns>``0.</returns>
         T Resolve<T>();
 
-        /// <summary>
-        /// Shuts down.
-        /// </summary>
-        void Shutdown();
-
         /// <summary>
         /// Initializes this instance.
         /// </summary>

+ 0 - 11
MediaBrowser.Controller/Drawing/IImageProcessor.cs

@@ -2,7 +2,6 @@
 
 using System;
 using System.Collections.Generic;
-using System.IO;
 using System.Threading.Tasks;
 using Jellyfin.Data.Entities;
 using MediaBrowser.Controller.Entities;
@@ -70,14 +69,6 @@ namespace MediaBrowser.Controller.Drawing
 
         string? GetImageCacheTag(User user);
 
-        /// <summary>
-        /// Processes the image.
-        /// </summary>
-        /// <param name="options">The options.</param>
-        /// <param name="toStream">To stream.</param>
-        /// <returns>Task.</returns>
-        Task ProcessImage(ImageProcessingOptions options, Stream toStream);
-
         /// <summary>
         /// Processes the image.
         /// </summary>
@@ -97,7 +88,5 @@ namespace MediaBrowser.Controller.Drawing
         /// <param name="options">The options.</param>
         /// <param name="libraryName">The library name to draw onto the collage.</param>
         void CreateImageCollage(ImageCollageOptions options, string? libraryName);
-
-        bool SupportsTransparency(string path);
     }
 }

+ 2 - 1
MediaBrowser.Controller/Drawing/ImageProcessingOptions.cs

@@ -119,7 +119,8 @@ namespace MediaBrowser.Controller.Drawing
         private bool IsFormatSupported(string originalImagePath)
         {
             var ext = Path.GetExtension(originalImagePath);
-            return SupportedOutputFormats.Any(outputFormat => string.Equals(ext, "." + outputFormat, StringComparison.OrdinalIgnoreCase));
+            ext = ext.Replace(".jpeg", ".jpg", StringComparison.OrdinalIgnoreCase);
+            return SupportedOutputFormats.Any(outputFormat => string.Equals(ext, outputFormat.GetExtension(), StringComparison.OrdinalIgnoreCase));
         }
     }
 }

+ 0 - 12
MediaBrowser.Controller/IServerApplicationHost.cs

@@ -4,7 +4,6 @@
 
 using System.Net;
 using MediaBrowser.Common;
-using MediaBrowser.Model.System;
 using Microsoft.AspNetCore.Http;
 
 namespace MediaBrowser.Controller
@@ -16,8 +15,6 @@ namespace MediaBrowser.Controller
     {
         bool CoreStartupHasCompleted { get; }
 
-        bool CanLaunchWebBrowser { get; }
-
         /// <summary>
         /// Gets the HTTP server port.
         /// </summary>
@@ -41,15 +38,6 @@ namespace MediaBrowser.Controller
         /// <value>The name of the friendly.</value>
         string FriendlyName { get; }
 
-        /// <summary>
-        /// Gets the system info.
-        /// </summary>
-        /// <param name="request">The HTTP request.</param>
-        /// <returns>SystemInfo.</returns>
-        SystemInfo GetSystemInfo(HttpRequest request);
-
-        PublicSystemInfo GetPublicSystemInfo(HttpRequest request);
-
         /// <summary>
         /// Gets a URL specific for the request.
         /// </summary>

+ 34 - 0
MediaBrowser.Controller/ISystemManager.cs

@@ -0,0 +1,34 @@
+using MediaBrowser.Model.System;
+using Microsoft.AspNetCore.Http;
+
+namespace MediaBrowser.Controller;
+
+/// <summary>
+/// A service for managing the application instance.
+/// </summary>
+public interface ISystemManager
+{
+    /// <summary>
+    /// Gets the system info.
+    /// </summary>
+    /// <param name="request">The HTTP request.</param>
+    /// <returns>The <see cref="SystemInfo"/>.</returns>
+    SystemInfo GetSystemInfo(HttpRequest request);
+
+    /// <summary>
+    /// Gets the public system info.
+    /// </summary>
+    /// <param name="request">The HTTP request.</param>
+    /// <returns>The <see cref="PublicSystemInfo"/>.</returns>
+    PublicSystemInfo GetPublicSystemInfo(HttpRequest request);
+
+    /// <summary>
+    /// Starts the application restart process.
+    /// </summary>
+    void Restart();
+
+    /// <summary>
+    /// Starts the application shutdown process.
+    /// </summary>
+    void Shutdown();
+}

+ 15 - 9
MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs

@@ -548,25 +548,25 @@ namespace MediaBrowser.Controller.MediaEncoding
         /// <returns>System.Nullable{VideoCodecs}.</returns>
         public string InferVideoCodec(string url)
         {
-            var ext = Path.GetExtension(url);
+            var ext = Path.GetExtension(url.AsSpan());
 
-            if (string.Equals(ext, ".asf", StringComparison.OrdinalIgnoreCase))
+            if (ext.Equals(".asf", StringComparison.OrdinalIgnoreCase))
             {
                 return "wmv";
             }
 
-            if (string.Equals(ext, ".webm", StringComparison.OrdinalIgnoreCase))
+            if (ext.Equals(".webm", StringComparison.OrdinalIgnoreCase))
             {
                 // TODO: this may not always mean VP8, as the codec ages
                 return "vp8";
             }
 
-            if (string.Equals(ext, ".ogg", StringComparison.OrdinalIgnoreCase) || string.Equals(ext, ".ogv", StringComparison.OrdinalIgnoreCase))
+            if (ext.Equals(".ogg", StringComparison.OrdinalIgnoreCase) || ext.Equals(".ogv", StringComparison.OrdinalIgnoreCase))
             {
                 return "theora";
             }
 
-            if (string.Equals(ext, ".m3u8", StringComparison.OrdinalIgnoreCase) || string.Equals(ext, ".ts", StringComparison.OrdinalIgnoreCase))
+            if (ext.Equals(".m3u8", StringComparison.OrdinalIgnoreCase) || ext.Equals(".ts", StringComparison.OrdinalIgnoreCase))
             {
                 return "h264";
             }
@@ -1080,10 +1080,10 @@ namespace MediaBrowser.Controller.MediaEncoding
                 && state.SubtitleStream.IsExternal)
             {
                 var subtitlePath = state.SubtitleStream.Path;
-                var subtitleExtension = Path.GetExtension(subtitlePath);
+                var subtitleExtension = Path.GetExtension(subtitlePath.AsSpan());
 
-                if (string.Equals(subtitleExtension, ".sub", StringComparison.OrdinalIgnoreCase)
-                    || string.Equals(subtitleExtension, ".sup", StringComparison.OrdinalIgnoreCase))
+                if (subtitleExtension.Equals(".sub", StringComparison.OrdinalIgnoreCase)
+                    || subtitleExtension.Equals(".sup", StringComparison.OrdinalIgnoreCase))
                 {
                     var idxFile = Path.ChangeExtension(subtitlePath, ".idx");
                     if (File.Exists(idxFile))
@@ -6040,7 +6040,7 @@ namespace MediaBrowser.Controller.MediaEncoding
             var format = string.Empty;
             var keyFrame = string.Empty;
 
-            if (string.Equals(Path.GetExtension(outputPath), ".mp4", StringComparison.OrdinalIgnoreCase)
+            if (Path.GetExtension(outputPath.AsSpan()).Equals(".mp4", StringComparison.OrdinalIgnoreCase)
                 && state.BaseRequest.Context == EncodingContext.Streaming)
             {
                 // Comparison: https://github.com/jansmolders86/mediacenterjs/blob/master/lib/transcoding/desktop.js
@@ -6249,6 +6249,12 @@ namespace MediaBrowser.Controller.MediaEncoding
                 audioTranscodeParams.Add("-acodec " + GetAudioEncoder(state));
             }
 
+            if (GetAudioEncoder(state).StartsWith("pcm_", StringComparison.Ordinal))
+            {
+                audioTranscodeParams.Add(string.Concat("-f ", GetAudioEncoder(state).AsSpan(4)));
+                audioTranscodeParams.Add("-ar " + state.BaseRequest.AudioBitRate);
+            }
+
             if (!string.Equals(outputCodec, "opus", StringComparison.OrdinalIgnoreCase))
             {
                 // opus only supports specific sampling rates

+ 4 - 32
MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs

@@ -1,4 +1,3 @@
-#nullable disable
 #pragma warning disable CS1591
 
 using System;
@@ -23,7 +22,7 @@ using Microsoft.Extensions.Logging;
 
 namespace MediaBrowser.MediaEncoding.Attachments
 {
-    public class AttachmentExtractor : IAttachmentExtractor, IDisposable
+    public sealed class AttachmentExtractor : IAttachmentExtractor
     {
         private readonly ILogger<AttachmentExtractor> _logger;
         private readonly IApplicationPaths _appPaths;
@@ -34,8 +33,6 @@ namespace MediaBrowser.MediaEncoding.Attachments
         private readonly ConcurrentDictionary<string, SemaphoreSlim> _semaphoreLocks =
             new ConcurrentDictionary<string, SemaphoreSlim>();
 
-        private bool _disposed = false;
-
         public AttachmentExtractor(
             ILogger<AttachmentExtractor> logger,
             IApplicationPaths appPaths,
@@ -296,7 +293,7 @@ namespace MediaBrowser.MediaEncoding.Attachments
 
             ArgumentException.ThrowIfNullOrEmpty(outputPath);
 
-            Directory.CreateDirectory(Path.GetDirectoryName(outputPath));
+            Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException("Path can't be a root directory.", nameof(outputPath)));
 
             var processArgs = string.Format(
                 CultureInfo.InvariantCulture,
@@ -391,33 +388,8 @@ namespace MediaBrowser.MediaEncoding.Attachments
                 filename = (mediaPath + attachmentStreamIndex.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("D", CultureInfo.InvariantCulture);
             }
 
-            var prefix = filename.Substring(0, 1);
-            return Path.Combine(_appPaths.DataPath, "attachments", prefix, filename);
-        }
-
-        /// <inheritdoc />
-        public void Dispose()
-        {
-            Dispose(true);
-            GC.SuppressFinalize(this);
-        }
-
-        /// <summary>
-        /// Releases unmanaged and - optionally - managed resources.
-        /// </summary>
-        /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
-        protected virtual void Dispose(bool disposing)
-        {
-            if (_disposed)
-            {
-                return;
-            }
-
-            if (disposing)
-            {
-            }
-
-            _disposed = true;
+            var prefix = filename.AsSpan(0, 1);
+            return Path.Join(_appPaths.DataPath, "attachments", prefix, filename);
         }
     }
 }

+ 3 - 13
MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs

@@ -316,10 +316,8 @@ namespace MediaBrowser.MediaEncoding.Encoder
             {
                 var files = _fileSystem.GetFilePaths(path, recursive);
 
-                var excludeExtensions = new[] { ".c" };
-
-                return files.FirstOrDefault(i => string.Equals(Path.GetFileNameWithoutExtension(i), filename, StringComparison.OrdinalIgnoreCase)
-                                                    && !excludeExtensions.Contains(Path.GetExtension(i) ?? string.Empty));
+                return files.FirstOrDefault(i => Path.GetFileNameWithoutExtension(i.AsSpan()).Equals(filename, StringComparison.OrdinalIgnoreCase)
+                                                    && !Path.GetExtension(i.AsSpan()).Equals(".c", StringComparison.OrdinalIgnoreCase));
             }
             catch (Exception)
             {
@@ -652,15 +650,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
         {
             ArgumentException.ThrowIfNullOrEmpty(inputPath);
 
-            var outputExtension = targetFormat switch
-            {
-                ImageFormat.Bmp => ".bmp",
-                ImageFormat.Gif => ".gif",
-                ImageFormat.Jpg => ".jpg",
-                ImageFormat.Png => ".png",
-                ImageFormat.Webp => ".webp",
-                _ => ".jpg"
-            };
+            var outputExtension = targetFormat?.GetExtension() ?? ".jpg";
 
             var tempExtractPath = Path.Combine(_configurationManager.ApplicationPaths.TempDirectory, Guid.NewGuid() + outputExtension);
             Directory.CreateDirectory(Path.GetDirectoryName(tempExtractPath));

+ 17 - 0
MediaBrowser.Model/Drawing/ImageFormatExtensions.cs

@@ -24,4 +24,21 @@ public static class ImageFormatExtensions
             ImageFormat.Webp => "image/webp",
             _ => throw new InvalidEnumArgumentException(nameof(format), (int)format, typeof(ImageFormat))
         };
+
+    /// <summary>
+    /// Returns the correct extension for this <see cref="ImageFormat" />.
+    /// </summary>
+    /// <param name="format">This <see cref="ImageFormat" />.</param>
+    /// <exception cref="InvalidEnumArgumentException">The <paramref name="format"/> is an invalid enumeration value.</exception>
+    /// <returns>The correct extension for this <see cref="ImageFormat" />.</returns>
+    public static string GetExtension(this ImageFormat format)
+        => format switch
+        {
+            ImageFormat.Bmp => ".bmp",
+            ImageFormat.Gif => ".gif",
+            ImageFormat.Jpg => ".jpg",
+            ImageFormat.Png => ".png",
+            ImageFormat.Webp => ".webp",
+            _ => throw new InvalidEnumArgumentException(nameof(format), (int)format, typeof(ImageFormat))
+        };
 }

+ 5 - 1
MediaBrowser.Providers/Manager/ImageSaver.cs

@@ -263,7 +263,11 @@ namespace MediaBrowser.Providers.Manager
 
                 var fileStreamOptions = AsyncFile.WriteOptions;
                 fileStreamOptions.Mode = FileMode.Create;
-                fileStreamOptions.PreallocationSize = source.Length;
+                if (source.CanSeek)
+                {
+                    fileStreamOptions.PreallocationSize = source.Length;
+                }
+
                 var fs = new FileStream(path, fileStreamOptions);
                 await using (fs.ConfigureAwait(false))
                 {

+ 0 - 6
MediaBrowser.Providers/MediaInfo/EmbeddedImageProvider.cs

@@ -204,16 +204,10 @@ namespace MediaBrowser.Providers.MediaInfo
                 ? Path.GetExtension(attachmentStream.FileName)
                 : MimeTypes.ToExtension(attachmentStream.MimeType);
 
-            if (string.IsNullOrEmpty(extension))
-            {
-                extension = ".jpg";
-            }
-
             ImageFormat format = extension switch
             {
                 ".bmp" => ImageFormat.Bmp,
                 ".gif" => ImageFormat.Gif,
-                ".jpg" => ImageFormat.Jpg,
                 ".png" => ImageFormat.Png,
                 ".webp" => ImageFormat.Webp,
                 _ => ImageFormat.Jpg

+ 39 - 2
MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs

@@ -1,5 +1,6 @@
 using System;
 using System.IO;
+using System.Text;
 using System.Threading;
 using System.Xml;
 using MediaBrowser.Common.Configuration;
@@ -81,7 +82,10 @@ namespace MediaBrowser.XbmcMetadata.Parsers
                 }
 
                 // Extract the last episode number from nfo
+                // Retrieves all title and plot tags from the rest of the nfo and concatenates them with the first episode
                 // This is needed because XBMC metadata uses multiple episodedetails blocks instead of episodenumberend tag
+                var name = new StringBuilder(item.Item.Name);
+                var overview = new StringBuilder(item.Item.Overview);
                 while ((index = xmlFile.IndexOf(srch, StringComparison.OrdinalIgnoreCase)) != -1)
                 {
                     xml = xmlFile.Substring(0, index + srch.Length);
@@ -92,12 +96,44 @@ namespace MediaBrowser.XbmcMetadata.Parsers
                     {
                         reader.MoveToContent();
 
-                        if (reader.ReadToDescendant("episode") && int.TryParse(reader.ReadElementContentAsString(), out var num))
+                        while (!reader.EOF && reader.ReadState == ReadState.Interactive)
                         {
-                            item.Item.IndexNumberEnd = Math.Max(num, item.Item.IndexNumberEnd ?? num);
+                            cancellationToken.ThrowIfCancellationRequested();
+
+                            if (reader.NodeType == XmlNodeType.Element)
+                            {
+                                switch (reader.Name)
+                                {
+                                    case "name":
+                                    case "title":
+                                    case "localtitle":
+                                        name.Append(" / ").Append(reader.ReadElementContentAsString());
+                                        break;
+                                    case "episode":
+                                        {
+                                            if (int.TryParse(reader.ReadElementContentAsString(), out var num))
+                                            {
+                                                item.Item.IndexNumberEnd = Math.Max(num, item.Item.IndexNumberEnd ?? num);
+                                            }
+
+                                            break;
+                                        }
+
+                                    case "biography":
+                                    case "plot":
+                                    case "review":
+                                        overview.Append(" / ").Append(reader.ReadElementContentAsString());
+                                        break;
+                                }
+                            }
+
+                            reader.Read();
                         }
                     }
                 }
+
+                item.Item.Name = name.ToString();
+                item.Item.Overview = overview.ToString();
             }
             catch (XmlException)
             {
@@ -141,6 +177,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers
 
                     break;
                 case "airsafter_season":
+                case "displayafterseason":
                     if (reader.TryReadInt(out var airsAfterSeason))
                     {
                         item.AirsAfterSeasonNumber = airsAfterSeason;

+ 2 - 2
MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs

@@ -60,13 +60,13 @@ namespace MediaBrowser.XbmcMetadata.Savers
             }
             else
             {
-                yield return Path.ChangeExtension(item.Path, ".nfo");
-
                 // only allow movie object to read movie.nfo, not owned videos (which will be itemtype video, not movie)
                 if (!item.IsInMixedFolder && item.ItemType == typeof(Movie))
                 {
                     yield return Path.Combine(item.ContainingFolderPath, "movie.nfo");
                 }
+
+                yield return Path.ChangeExtension(item.Path, ".nfo");
             }
         }
 

+ 3 - 13
src/Jellyfin.Drawing.Skia/SkiaEncoder.cs

@@ -188,7 +188,7 @@ public class SkiaEncoder : IImageEncoder
             return path;
         }
 
-        var tempPath = Path.Combine(_appPaths.TempDirectory, Guid.NewGuid() + Path.GetExtension(path));
+        var tempPath = Path.Combine(_appPaths.TempDirectory, string.Concat(Guid.NewGuid().ToString(), Path.GetExtension(path.AsSpan())));
         var directory = Path.GetDirectoryName(tempPath) ?? throw new ResourceNotFoundException($"Provided path ({tempPath}) is not valid.");
         Directory.CreateDirectory(directory);
         File.Copy(path, tempPath, true);
@@ -200,20 +200,10 @@ public class SkiaEncoder : IImageEncoder
     {
         if (!orientation.HasValue)
         {
-            return SKEncodedOrigin.TopLeft;
+            return SKEncodedOrigin.Default;
         }
 
-        return orientation.Value switch
-        {
-            ImageOrientation.TopRight => SKEncodedOrigin.TopRight,
-            ImageOrientation.RightTop => SKEncodedOrigin.RightTop,
-            ImageOrientation.RightBottom => SKEncodedOrigin.RightBottom,
-            ImageOrientation.LeftTop => SKEncodedOrigin.LeftTop,
-            ImageOrientation.LeftBottom => SKEncodedOrigin.LeftBottom,
-            ImageOrientation.BottomRight => SKEncodedOrigin.BottomRight,
-            ImageOrientation.BottomLeft => SKEncodedOrigin.BottomLeft,
-            _ => SKEncodedOrigin.TopLeft
-        };
+        return (SKEncodedOrigin)orientation.Value;
     }
 
     /// <summary>

+ 6 - 6
src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs

@@ -38,25 +38,25 @@ public partial class StripCollageBuilder
     {
         ArgumentNullException.ThrowIfNull(outputPath);
 
-        var ext = Path.GetExtension(outputPath);
+        var ext = Path.GetExtension(outputPath.AsSpan());
 
-        if (string.Equals(ext, ".jpg", StringComparison.OrdinalIgnoreCase)
-            || string.Equals(ext, ".jpeg", StringComparison.OrdinalIgnoreCase))
+        if (ext.Equals(".jpg", StringComparison.OrdinalIgnoreCase)
+            || ext.Equals(".jpeg", StringComparison.OrdinalIgnoreCase))
         {
             return SKEncodedImageFormat.Jpeg;
         }
 
-        if (string.Equals(ext, ".webp", StringComparison.OrdinalIgnoreCase))
+        if (ext.Equals(".webp", StringComparison.OrdinalIgnoreCase))
         {
             return SKEncodedImageFormat.Webp;
         }
 
-        if (string.Equals(ext, ".gif", StringComparison.OrdinalIgnoreCase))
+        if (ext.Equals(".gif", StringComparison.OrdinalIgnoreCase))
         {
             return SKEncodedImageFormat.Gif;
         }
 
-        if (string.Equals(ext, ".bmp", StringComparison.OrdinalIgnoreCase))
+        if (ext.Equals(".bmp", StringComparison.OrdinalIgnoreCase))
         {
             return SKEncodedImageFormat.Bmp;
         }

+ 2 - 54
src/Jellyfin.Drawing/ImageProcessor.cs

@@ -107,22 +107,10 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable
     /// <inheritdoc />
     public bool SupportsImageCollageCreation => _imageEncoder.SupportsImageCollageCreation;
 
-    /// <inheritdoc />
-    public async Task ProcessImage(ImageProcessingOptions options, Stream toStream)
-    {
-        var file = await ProcessImage(options).ConfigureAwait(false);
-        using var fileStream = AsyncFile.OpenRead(file.Path);
-        await fileStream.CopyToAsync(toStream).ConfigureAwait(false);
-    }
-
     /// <inheritdoc />
     public IReadOnlyCollection<ImageFormat> GetSupportedImageOutputFormats()
         => _imageEncoder.SupportedOutputFormats;
 
-    /// <inheritdoc />
-    public bool SupportsTransparency(string path)
-        => _transparentImageTypes.Contains(Path.GetExtension(path));
-
     /// <inheritdoc />
     public async Task<(string Path, string? MimeType, DateTime DateModified)> ProcessImage(ImageProcessingOptions options)
     {
@@ -224,7 +212,7 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable
                 }
             }
 
-            return (cacheFilePath, GetMimeType(outputFormat, cacheFilePath), _fileSystem.GetLastWriteTimeUtc(cacheFilePath));
+            return (cacheFilePath, outputFormat.GetMimeType(), _fileSystem.GetLastWriteTimeUtc(cacheFilePath));
         }
         catch (Exception ex)
         {
@@ -262,17 +250,6 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable
         return ImageFormat.Jpg;
     }
 
-    private string GetMimeType(ImageFormat format, string path)
-        => format switch
-        {
-            ImageFormat.Bmp => MimeTypes.GetMimeType("i.bmp"),
-            ImageFormat.Gif => MimeTypes.GetMimeType("i.gif"),
-            ImageFormat.Jpg => MimeTypes.GetMimeType("i.jpg"),
-            ImageFormat.Png => MimeTypes.GetMimeType("i.png"),
-            ImageFormat.Webp => MimeTypes.GetMimeType("i.webp"),
-            _ => MimeTypes.GetMimeType(path)
-        };
-
     /// <summary>
     /// Gets the cache file path based on a set of parameters.
     /// </summary>
@@ -374,7 +351,7 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable
         filename.Append(",v=");
         filename.Append(Version);
 
-        return GetCachePath(ResizedImageCachePath, filename.ToString(), "." + format.ToString().ToLowerInvariant());
+        return GetCachePath(ResizedImageCachePath, filename.ToString(), format.GetExtension());
     }
 
     /// <inheritdoc />
@@ -471,35 +448,6 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable
             return Task.FromResult((originalImagePath, dateModified));
         }
 
-        // TODO _mediaEncoder.ConvertImage is not implemented
-        // if (!_imageEncoder.SupportedInputFormats.Contains(inputFormat))
-        // {
-        //     try
-        //     {
-        //         string filename = (originalImagePath + dateModified.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("N", CultureInfo.InvariantCulture);
-        //
-        //         string cacheExtension = _mediaEncoder.SupportsEncoder("libwebp") ? ".webp" : ".png";
-        //         var outputPath = Path.Combine(_appPaths.ImageCachePath, "converted-images", filename + cacheExtension);
-        //
-        //         var file = _fileSystem.GetFileInfo(outputPath);
-        //         if (!file.Exists)
-        //         {
-        //             await _mediaEncoder.ConvertImage(originalImagePath, outputPath).ConfigureAwait(false);
-        //             dateModified = _fileSystem.GetLastWriteTimeUtc(outputPath);
-        //         }
-        //         else
-        //         {
-        //             dateModified = file.LastWriteTimeUtc;
-        //         }
-        //
-        //         originalImagePath = outputPath;
-        //     }
-        //     catch (Exception ex)
-        //     {
-        //         _logger.LogError(ex, "Image conversion failed for {Path}", originalImagePath);
-        //     }
-        // }
-
         return Task.FromResult((originalImagePath, dateModified));
     }
 

+ 13 - 0
tests/Jellyfin.Model.Tests/Drawing/ImageFormatExtensionsTests.cs

@@ -30,4 +30,17 @@ public static class ImageFormatExtensionsTests
     [InlineData((ImageFormat)5)]
     public static void GetMimeType_Valid_ThrowsInvalidEnumArgumentException(ImageFormat format)
         => Assert.Throws<InvalidEnumArgumentException>(() => format.GetMimeType());
+
+    [Theory]
+    [MemberData(nameof(GetAllImageFormats))]
+    public static void GetExtension_Valid_Valid(ImageFormat format)
+        => Assert.Null(Record.Exception(() => format.GetExtension()));
+
+    [Theory]
+    [InlineData((ImageFormat)int.MinValue)]
+    [InlineData((ImageFormat)int.MaxValue)]
+    [InlineData((ImageFormat)(-1))]
+    [InlineData((ImageFormat)5)]
+    public static void GetExtension_Valid_ThrowsInvalidEnumArgumentException(ImageFormat format)
+        => Assert.Throws<InvalidEnumArgumentException>(() => format.GetExtension());
 }

+ 8 - 5
tests/Jellyfin.Server.Integration.Tests/AuthHelper.cs

@@ -15,8 +15,8 @@ namespace Jellyfin.Server.Integration.Tests
 {
     public static class AuthHelper
     {
-        public const string AuthHeaderName = "X-Emby-Authorization";
-        public const string DummyAuthHeader = "MediaBrowser Client=\"Jellyfin.Server Integration Tests\", DeviceId=\"69420\", Device=\"Apple II\", Version=\"10.8.0\"";
+        public const string AuthHeaderName = "Authorization";
+        public const string DummyAuthHeader = "MediaBrowser Client=\"Jellyfin.Server%20Integration%20Tests\", DeviceId=\"69420\", Device=\"Apple%20II\", Version=\"10.8.0\"";
 
         public static async Task<string> CompleteStartupAsync(HttpClient client)
         {
@@ -27,16 +27,19 @@ namespace Jellyfin.Server.Integration.Tests
             using var completeResponse = await client.PostAsync("/Startup/Complete", new ByteArrayContent(Array.Empty<byte>()));
             Assert.Equal(HttpStatusCode.NoContent, completeResponse.StatusCode);
 
-            using var content = JsonContent.Create(
+            using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/Users/AuthenticateByName");
+            httpRequest.Headers.TryAddWithoutValidation(AuthHeaderName, DummyAuthHeader);
+            httpRequest.Content = JsonContent.Create(
                 new AuthenticateUserByName()
                 {
                     Username = user!.Name,
                     Pw = user.Password,
                 },
                 options: jsonOptions);
-            content.Headers.Add("X-Emby-Authorization", DummyAuthHeader);
 
-            using var authResponse = await client.PostAsync("/Users/AuthenticateByName", content);
+            using var authResponse = await client.SendAsync(httpRequest);
+            authResponse.EnsureSuccessStatusCode();
+
             var auth = await JsonSerializer.DeserializeAsync<AuthenticationResultDto>(
                 await authResponse.Content.ReadAsStreamAsync(),
                 jsonOptions);

+ 26 - 0
tests/Jellyfin.Server.Integration.Tests/Controllers/PersonsControllerTests.cs

@@ -0,0 +1,26 @@
+using System.Net;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace Jellyfin.Server.Integration.Tests.Controllers;
+
+public class PersonsControllerTests : IClassFixture<JellyfinApplicationFactory>
+{
+    private readonly JellyfinApplicationFactory _factory;
+    private static string? _accessToken;
+
+    public PersonsControllerTests(JellyfinApplicationFactory factory)
+    {
+        _factory = factory;
+    }
+
+    [Fact]
+    public async Task GetPerson_DoesntExist_NotFound()
+    {
+        var client = _factory.CreateClient();
+        client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client));
+
+        using var response = await client.GetAsync($"Persons/DoesntExist");
+        Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+    }
+}

+ 3 - 3
tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.Linq;
 using System.Threading;
 using Jellyfin.Data.Enums;
@@ -114,11 +114,11 @@ namespace Jellyfin.XbmcMetadata.Tests.Parsers
             _parser.Fetch(result, "Test Data/Rising.nfo", CancellationToken.None);
 
             var item = result.Item;
-            Assert.Equal("Rising (1)", item.Name);
+            Assert.Equal("Rising (1) / Rising (2)", item.Name);
             Assert.Equal(1, item.IndexNumber);
             Assert.Equal(2, item.IndexNumberEnd);
             Assert.Equal(1, item.ParentIndexNumber);
-            Assert.Equal("A new Stargate team embarks on a dangerous mission to a distant galaxy, where they discover a mythical lost city -- and a deadly new enemy.", item.Overview);
+            Assert.Equal("A new Stargate team embarks on a dangerous mission to a distant galaxy, where they discover a mythical lost city -- and a deadly new enemy. / Sheppard tries to convince Weir to mount a rescue mission to free Colonel Sumner, Teyla, and the others captured by the Wraith.", item.Overview);
             Assert.Equal(new DateTime(2004, 7, 16), item.PremiereDate);
             Assert.Equal(2004, item.ProductionYear);
         }