浏览代码

update to current master to resolve merge conflict

herby2212 1 年之前
父节点
当前提交
27ceee8b6c
共有 100 个文件被更改,包括 3165 次插入918 次删除
  1. 12 0
      .config/dotnet-tools.json
  2. 4 4
      .github/workflows/codeql-analysis.yml
  3. 7 7
      .github/workflows/commands.yml
  4. 4 4
      .github/workflows/openapi.yml
  5. 2 2
      .github/workflows/repo-bump-version.yaml
  6. 1 1
      .github/workflows/repo-stale.yaml
  7. 2 0
      CONTRIBUTORS.md
  8. 12 12
      Directory.Packages.props
  9. 1 1
      Emby.Dlna/DlnaManager.cs
  10. 69 0
      Emby.Dlna/Extensions/DlnaServiceCollectionExtensions.cs
  11. 17 117
      Emby.Dlna/Main/DlnaEntryPoint.cs
  12. 4 8
      Emby.Dlna/PlayTo/Device.cs
  13. 28 27
      Emby.Dlna/PlayTo/DlnaHttpClient.cs
  14. 2 2
      Emby.Dlna/PlayTo/PlayToManager.cs
  15. 5 5
      Emby.Naming/Common/NamingOptions.cs
  16. 1 1
      Emby.Naming/ExternalFiles/ExternalPathParser.cs
  17. 3 4
      Emby.Naming/Video/StubResolver.cs
  18. 1 1
      Emby.Photos/PhotoProvider.cs
  19. 2 4
      Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs
  20. 8 86
      Emby.Server.Implementations/ApplicationHost.cs
  21. 6 3
      Emby.Server.Implementations/Channels/ChannelManager.cs
  22. 2 5
      Emby.Server.Implementations/Data/SqliteItemRepository.cs
  23. 10 1
      Emby.Server.Implementations/Dto/DtoService.cs
  24. 0 2
      Emby.Server.Implementations/Emby.Server.Implementations.csproj
  25. 22 13
      Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs
  26. 0 1
      Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
  27. 0 1
      Emby.Server.Implementations/IO/FileRefresher.cs
  28. 5 3
      Emby.Server.Implementations/IO/ManagedFileSystem.cs
  29. 17 16
      Emby.Server.Implementations/Library/LibraryManager.cs
  30. 13 5
      Emby.Server.Implementations/Library/LiveStreamHelper.cs
  31. 10 5
      Emby.Server.Implementations/Library/MediaSourceManager.cs
  32. 3 3
      Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs
  33. 1 1
      Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs
  34. 4 5
      Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs
  35. 0 1
      Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs
  36. 1 1
      Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs
  37. 9 5
      Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs
  38. 7 18
      Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs
  39. 0 1
      Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs
  40. 30 32
      Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs
  41. 0 2
      Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs
  42. 0 2
      Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs
  43. 2 0
      Emby.Server.Implementations/Localization/Core/en-US.json
  44. 18 0
      Emby.Server.Implementations/Localization/Core/fo.json
  45. 35 28
      Emby.Server.Implementations/Localization/Core/is.json
  46. 38 38
      Emby.Server.Implementations/Localization/Core/lv.json
  47. 3 1
      Emby.Server.Implementations/Localization/Core/ml.json
  48. 1 0
      Emby.Server.Implementations/Localization/Core/si.json
  49. 1 1
      Emby.Server.Implementations/Localization/Core/sk.json
  50. 20 17
      Emby.Server.Implementations/Localization/LocalizationManager.cs
  51. 1 1
      Emby.Server.Implementations/MediaEncoder/EncodingManager.cs
  52. 26 13
      Emby.Server.Implementations/Net/SocketFactory.cs
  53. 6 10
      Emby.Server.Implementations/Playlists/PlaylistManager.cs
  54. 13 5
      Emby.Server.Implementations/Plugins/PluginManager.cs
  55. 2 2
      Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs
  56. 104 0
      Emby.Server.Implementations/SystemManager.cs
  57. 13 9
      Emby.Server.Implementations/Udp/UdpServer.cs
  58. 3 5
      Emby.Server.Implementations/Updates/InstallationManager.cs
  59. 11 5
      Jellyfin.Api/Controllers/DlnaServerController.cs
  60. 28 20
      Jellyfin.Api/Controllers/DynamicHlsController.cs
  61. 5 4
      Jellyfin.Api/Controllers/HlsSegmentController.cs
  62. 19 24
      Jellyfin.Api/Controllers/ImageController.cs
  63. 3 3
      Jellyfin.Api/Controllers/LibraryController.cs
  64. 4 4
      Jellyfin.Api/Controllers/SubtitleController.cs
  65. 22 25
      Jellyfin.Api/Controllers/SystemController.cs
  66. 101 0
      Jellyfin.Api/Controllers/TrickplayController.cs
  67. 50 35
      Jellyfin.Api/Helpers/DynamicHlsHelper.cs
  68. 5 3
      Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs
  69. 6 8
      Jellyfin.Api/Helpers/StreamingHelpers.cs
  70. 1 1
      Jellyfin.Api/Helpers/TranscodingJobHelper.cs
  71. 0 2
      Jellyfin.Api/Jellyfin.Api.csproj
  72. 10 10
      Jellyfin.Api/Middleware/ExceptionMiddleware.cs
  73. 6 1
      Jellyfin.Api/Models/StreamingDtos/VideoRequestDto.cs
  74. 75 0
      Jellyfin.Data/Entities/TrickplayInfo.cs
  75. 6 0
      Jellyfin.Data/Entities/User.cs
  76. 9 7
      Jellyfin.Networking/Extensions/NetworkExtensions.cs
  77. 157 87
      Jellyfin.Networking/Manager/NetworkManager.cs
  78. 0 1
      Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationSucceededLogger.cs
  79. 1 2
      Jellyfin.Server.Implementations/Events/EventingServiceCollectionExtensions.cs
  80. 5 0
      Jellyfin.Server.Implementations/JellyfinDbContext.cs
  81. 681 0
      Jellyfin.Server.Implementations/Migrations/20230626233818_AddTrickplayInfos.Designer.cs
  82. 40 0
      Jellyfin.Server.Implementations/Migrations/20230626233818_AddTrickplayInfos.cs
  83. 654 0
      Jellyfin.Server.Implementations/Migrations/20230923170422_UserCastReceiver.Designer.cs
  84. 29 0
      Jellyfin.Server.Implementations/Migrations/20230923170422_UserCastReceiver.cs
  85. 37 2
      Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs
  86. 18 0
      Jellyfin.Server.Implementations/ModelConfiguration/TrickplayInfoConfiguration.cs
  87. 5 25
      Jellyfin.Server.Implementations/Security/AuthorizationContext.cs
  88. 474 0
      Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
  89. 20 3
      Jellyfin.Server.Implementations/Users/UserManager.cs
  90. 3 0
      Jellyfin.Server/CoreAppHost.cs
  91. 1 1
      Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
  92. 1 2
      Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs
  93. 2 1
      Jellyfin.Server/Migrations/MigrationRunner.cs
  94. 55 0
      Jellyfin.Server/Migrations/Routines/AddDefaultCastReceivers.cs
  95. 0 1
      Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs
  96. 2 18
      Jellyfin.Server/Startup.cs
  97. 4 56
      MediaBrowser.Common/Extensions/ProcessExtensions.cs
  98. 4 20
      MediaBrowser.Common/IApplicationHost.cs
  99. 0 4
      MediaBrowser.Common/MediaBrowser.Common.csproj
  100. 0 1
      MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs

+ 12 - 0
.config/dotnet-tools.json

@@ -0,0 +1,12 @@
+{
+  "version": 1,
+  "isRoot": true,
+  "tools": {
+    "dotnet-ef": {
+      "version": "7.0.12",
+      "commands": [
+        "dotnet-ef"
+      ]
+    }
+  }
+}

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

@@ -20,18 +20,18 @@ jobs:
 
     steps:
     - name: Checkout repository
-      uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
+      uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
     - name: Setup .NET
       uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0
       with:
         dotnet-version: '7.0.x'
 
     - name: Initialize CodeQL
-      uses: github/codeql-action/init@2cb752a87e96af96708ab57187ab6372ee1973ab # v2.22.0
+      uses: github/codeql-action/init@0116bc2df50751f9724a2e35ef1f24d22f90e4e1 # v2.22.3
       with:
         languages: ${{ matrix.language }}
         queries: +security-extended
     - name: Autobuild
-      uses: github/codeql-action/autobuild@2cb752a87e96af96708ab57187ab6372ee1973ab # v2.22.0
+      uses: github/codeql-action/autobuild@0116bc2df50751f9724a2e35ef1f24d22f90e4e1 # v2.22.3
     - name: Perform CodeQL Analysis
-      uses: github/codeql-action/analyze@2cb752a87e96af96708ab57187ab6372ee1973ab # v2.22.0
+      uses: github/codeql-action/analyze@0116bc2df50751f9724a2e35ef1f24d22f90e4e1 # v2.22.3

+ 7 - 7
.github/workflows/commands.yml

@@ -17,14 +17,14 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Notify as seen
-        uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3.0.2
+        uses: peter-evans/create-or-update-comment@23ff15729ef2fc348714a3bb66d2f655ca9066f2 # v3.1.0
         with:
           token: ${{ secrets.JF_BOT_TOKEN }}
           comment-id: ${{ github.event.comment.id }}
           reactions: '+1'
 
       - name: Checkout the latest code
-        uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
+        uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
         with:
           token: ${{ secrets.JF_BOT_TOKEN }}
           fetch-depth: 0
@@ -43,7 +43,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Notify as seen
-        uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3.0.2
+        uses: peter-evans/create-or-update-comment@23ff15729ef2fc348714a3bb66d2f655ca9066f2 # v3.1.0
         if: ${{ github.event.comment != null }}
         with:
           token: ${{ secrets.JF_BOT_TOKEN }}
@@ -51,14 +51,14 @@ jobs:
           reactions: eyes
 
       - name: Checkout the latest code
-        uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
+        uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
         with:
           token: ${{ secrets.JF_BOT_TOKEN }}
           fetch-depth: 0
 
       - name: Notify as running
         id: comment_running
-        uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3.0.2
+        uses: peter-evans/create-or-update-comment@23ff15729ef2fc348714a3bb66d2f655ca9066f2 # v3.1.0
         if: ${{ github.event.comment != null }}
         with:
           token: ${{ secrets.JF_BOT_TOKEN }}
@@ -93,7 +93,7 @@ jobs:
           exit ${retcode}
 
       - name: Notify with result success
-        uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3.0.2
+        uses: peter-evans/create-or-update-comment@23ff15729ef2fc348714a3bb66d2f655ca9066f2 # v3.1.0
         if: ${{ github.event.comment != null && success() }}
         with:
           token: ${{ secrets.JF_BOT_TOKEN }}
@@ -108,7 +108,7 @@ jobs:
           reactions: hooray
 
       - name: Notify with result failure
-        uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3.0.2
+        uses: peter-evans/create-or-update-comment@23ff15729ef2fc348714a3bb66d2f655ca9066f2 # v3.1.0
         if: ${{ github.event.comment != null && failure() }}
         with:
           token: ${{ secrets.JF_BOT_TOKEN }}

+ 4 - 4
.github/workflows/openapi.yml

@@ -14,7 +14,7 @@ jobs:
     permissions: read-all
     steps:
       - name: Checkout repository
-        uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
+        uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
         with:
           ref: ${{ github.event.pull_request.head.sha }}
           repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -39,7 +39,7 @@ jobs:
     permissions: read-all
     steps:
       - name: Checkout repository
-        uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
+        uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
         with:
           ref: ${{ github.event.pull_request.head.sha }}
           repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -112,7 +112,7 @@ jobs:
           direction: last
           body-includes: openapi-diff-workflow-comment
       - name: Reply or edit difference comment (changed)
-        uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3.0.2
+        uses: peter-evans/create-or-update-comment@23ff15729ef2fc348714a3bb66d2f655ca9066f2 # v3.1.0
         if: ${{ steps.read-diff.outputs.body != '' }}
         with:
           issue-number: ${{ github.event.pull_request.number }}
@@ -127,7 +127,7 @@ jobs:
 
             </details>
       - name: Edit difference comment (unchanged)
-        uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3.0.2
+        uses: peter-evans/create-or-update-comment@23ff15729ef2fc348714a3bb66d2f655ca9066f2 # v3.1.0
         if: ${{ steps.read-diff.outputs.body == '' && steps.find-comment.outputs.comment-id != '' }}
         with:
           issue-number: ${{ github.event.pull_request.number }}

+ 2 - 2
.github/workflows/repo-bump-version.yaml

@@ -33,7 +33,7 @@ jobs:
           yq-version: v4.9.8
 
       - name: Checkout Repository
-        uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
+        uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
         with:
           ref: ${{ env.TAG_BRANCH }}
 
@@ -66,7 +66,7 @@ jobs:
       NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }}
     steps:
       - name: Checkout Repository
-        uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
+        uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
         with:
           ref: ${{ env.TAG_BRANCH }}
 

+ 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

+ 2 - 0
CONTRIBUTORS.md

@@ -57,6 +57,7 @@
  - [hawken93](https://github.com/hawken93)
  - [HelloWorld017](https://github.com/HelloWorld017)
  - [ikomhoog](https://github.com/ikomhoog)
+ - [iwalton3](https://github.com/iwalton3)
  - [jftuga](https://github.com/jftuga)
  - [jmshrv](https://github.com/jmshrv)
  - [joern-h](https://github.com/joern-h)
@@ -88,6 +89,7 @@
  - [neilsb](https://github.com/neilsb)
  - [nevado](https://github.com/nevado)
  - [Nickbert7](https://github.com/Nickbert7)
+ - [nicknsy](https://github.com/nicknsy)
  - [nvllsvm](https://github.com/nvllsvm)
  - [nyanmisaka](https://github.com/nyanmisaka)
  - [OancaAndrei](https://github.com/OancaAndrei)

+ 12 - 12
Directory.Packages.props

@@ -17,7 +17,7 @@
     <PackageVersion Include="Diacritics" Version="3.3.18" />
     <PackageVersion Include="DiscUtils.Udf" Version="0.16.13" />
     <PackageVersion Include="DotNet.Glob" Version="3.1.3" />
-    <PackageVersion Include="EFCoreSecondLevelCacheInterceptor" Version="3.9.2" />
+    <PackageVersion Include="EFCoreSecondLevelCacheInterceptor" Version="3.9.4" />
     <PackageVersion Include="FsCheck.Xunit" Version="2.16.6" />
     <PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="7.3.0" />
     <PackageVersion Include="IDisposableAnalyzers" Version="4.0.7" />
@@ -25,15 +25,15 @@
     <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.AspNetCore.Mvc.Testing" Version="7.0.12" />
     <PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4" />
-    <PackageVersion Include="Microsoft.Data.Sqlite" Version="7.0.11" />
-    <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.11" />
-    <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="7.0.11" />
-    <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.11" />
-    <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.11" />
+    <PackageVersion Include="Microsoft.Data.Sqlite" Version="7.0.12" />
+    <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.12" />
+    <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="7.0.12" />
+    <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.12" />
+    <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.12" />
     <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="7.0.0" />
     <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="7.0.0" />
     <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="7.0.0" />
@@ -42,8 +42,8 @@
     <PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="7.0.0" />
     <PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="7.0.0" />
     <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
-    <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="7.0.11" />
-    <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="7.0.11" />
+    <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="7.0.12" />
+    <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="7.0.12" />
     <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="7.0.0" />
     <PackageVersion Include="Microsoft.Extensions.Http" Version="7.0.0" />
     <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.1" />
@@ -86,8 +86,8 @@
     <PackageVersion Include="TMDbLib" Version="2.0.0" />
     <PackageVersion Include="UTF.Unknown" Version="2.5.1" />
     <PackageVersion Include="Xunit.Priority" Version="1.1.6" />
-    <PackageVersion Include="xunit.runner.visualstudio" Version="2.5.1" />
+    <PackageVersion Include="xunit.runner.visualstudio" Version="2.5.3" />
     <PackageVersion Include="Xunit.SkippableFact" Version="1.4.13" />
-    <PackageVersion Include="xunit" Version="2.5.1" />
+    <PackageVersion Include="xunit" Version="2.5.3" />
   </ItemGroup>
 </Project>

+ 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

+ 69 - 0
Emby.Dlna/Extensions/DlnaServiceCollectionExtensions.cs

@@ -0,0 +1,69 @@
+using System;
+using System.Globalization;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using Emby.Dlna.ConnectionManager;
+using Emby.Dlna.ContentDirectory;
+using Emby.Dlna.MediaReceiverRegistrar;
+using Emby.Dlna.Ssdp;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Dlna;
+using MediaBrowser.Model.Dlna;
+using MediaBrowser.Model.Net;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Rssdp.Infrastructure;
+
+namespace Emby.Dlna.Extensions;
+
+/// <summary>
+/// Extension methods for adding DLNA services.
+/// </summary>
+public static class DlnaServiceCollectionExtensions
+{
+    /// <summary>
+    /// Adds DLNA services to the provided <see cref="IServiceCollection"/>.
+    /// </summary>
+    /// <param name="services">The <see cref="IServiceCollection"/>.</param>
+    /// <param name="applicationHost">The <see cref="IServerApplicationHost"/>.</param>
+    public static void AddDlnaServices(
+        this IServiceCollection services,
+        IServerApplicationHost applicationHost)
+    {
+        services.AddHttpClient(NamedClient.Dlna, c =>
+            {
+                c.DefaultRequestHeaders.UserAgent.ParseAdd(
+                    string.Format(
+                        CultureInfo.InvariantCulture,
+                        "{0}/{1} UPnP/1.0 {2}/{3}",
+                        Environment.OSVersion.Platform,
+                        Environment.OSVersion,
+                        applicationHost.Name,
+                        applicationHost.ApplicationVersionString));
+
+                c.DefaultRequestHeaders.Add("CPFN.UPNP.ORG", applicationHost.FriendlyName); // Required for UPnP DeviceArchitecture v2.0
+                c.DefaultRequestHeaders.Add("FriendlyName.DLNA.ORG", applicationHost.FriendlyName); // REVIEW: where does this come from?
+            })
+            .ConfigurePrimaryHttpMessageHandler(_ => new SocketsHttpHandler
+            {
+                AutomaticDecompression = DecompressionMethods.All,
+                RequestHeaderEncodingSelector = (_, _) => Encoding.UTF8
+            });
+
+        services.AddSingleton<IDlnaManager, DlnaManager>();
+        services.AddSingleton<IDeviceDiscovery, DeviceDiscovery>();
+        services.AddSingleton<IContentDirectory, ContentDirectoryService>();
+        services.AddSingleton<IConnectionManager, ConnectionManagerService>();
+        services.AddSingleton<IMediaReceiverRegistrar, MediaReceiverRegistrarService>();
+
+        services.AddSingleton<ISsdpCommunicationsServer>(provider => new SsdpCommunicationsServer(
+            provider.GetRequiredService<ISocketFactory>(),
+            provider.GetRequiredService<INetworkManager>(),
+            provider.GetRequiredService<ILogger<SsdpCommunicationsServer>>())
+        {
+            IsShared = true
+        });
+    }
+}

+ 17 - 117
Emby.Dlna/Main/DlnaEntryPoint.cs

@@ -23,10 +23,8 @@ using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.MediaEncoding;
 using MediaBrowser.Controller.Plugins;
 using MediaBrowser.Controller.Session;
-using MediaBrowser.Controller.TV;
 using MediaBrowser.Model.Dlna;
 using MediaBrowser.Model.Globalization;
-using MediaBrowser.Model.Net;
 using Microsoft.Extensions.Logging;
 using Rssdp;
 using Rssdp.Infrastructure;
@@ -49,14 +47,13 @@ namespace Emby.Dlna.Main
         private readonly IMediaSourceManager _mediaSourceManager;
         private readonly IMediaEncoder _mediaEncoder;
         private readonly IDeviceDiscovery _deviceDiscovery;
-        private readonly ISocketFactory _socketFactory;
+        private readonly ISsdpCommunicationsServer _communicationsServer;
         private readonly INetworkManager _networkManager;
-        private readonly object _syncLock = new object();
+        private readonly object _syncLock = new();
         private readonly bool _disabled;
 
         private PlayToManager _manager;
         private SsdpDevicePublisher _publisher;
-        private ISsdpCommunicationsServer _communicationsServer;
 
         private bool _disposed;
 
@@ -75,10 +72,8 @@ namespace Emby.Dlna.Main
             IMediaSourceManager mediaSourceManager,
             IDeviceDiscovery deviceDiscovery,
             IMediaEncoder mediaEncoder,
-            ISocketFactory socketFactory,
-            INetworkManager networkManager,
-            IUserViewManager userViewManager,
-            ITVSeriesManager tvSeriesManager)
+            ISsdpCommunicationsServer communicationsServer,
+            INetworkManager networkManager)
         {
             _config = config;
             _appHost = appHost;
@@ -93,37 +88,10 @@ namespace Emby.Dlna.Main
             _mediaSourceManager = mediaSourceManager;
             _deviceDiscovery = deviceDiscovery;
             _mediaEncoder = mediaEncoder;
-            _socketFactory = socketFactory;
+            _communicationsServer = communicationsServer;
             _networkManager = networkManager;
             _logger = loggerFactory.CreateLogger<DlnaEntryPoint>();
 
-            ContentDirectory = new ContentDirectory.ContentDirectoryService(
-                dlnaManager,
-                userDataManager,
-                imageProcessor,
-                libraryManager,
-                config,
-                userManager,
-                loggerFactory.CreateLogger<ContentDirectory.ContentDirectoryService>(),
-                httpClientFactory,
-                localizationManager,
-                mediaSourceManager,
-                userViewManager,
-                mediaEncoder,
-                tvSeriesManager);
-
-            ConnectionManager = new ConnectionManager.ConnectionManagerService(
-                dlnaManager,
-                config,
-                loggerFactory.CreateLogger<ConnectionManager.ConnectionManagerService>(),
-                httpClientFactory);
-
-            MediaReceiverRegistrar = new MediaReceiverRegistrar.MediaReceiverRegistrarService(
-                loggerFactory.CreateLogger<MediaReceiverRegistrar.MediaReceiverRegistrarService>(),
-                httpClientFactory,
-                config);
-            Current = this;
-
             var netConfig = config.GetConfiguration<NetworkConfiguration>(NetworkConfigurationStore.StoreKey);
             _disabled = appHost.ListenWithHttps && netConfig.RequireHttps;
 
@@ -133,19 +101,6 @@ namespace Emby.Dlna.Main
             }
         }
 
-        public static DlnaEntryPoint Current { get; private set; }
-
-        /// <summary>
-        /// Gets a value indicating whether the dlna server is enabled.
-        /// </summary>
-        public static bool Enabled { get; private set; }
-
-        public IContentDirectory ContentDirectory { get; private set; }
-
-        public IConnectionManager ConnectionManager { get; private set; }
-
-        public IMediaReceiverRegistrar MediaReceiverRegistrar { get; private set; }
-
         public async Task RunAsync()
         {
             await ((DlnaManager)_dlnaManager).InitProfilesAsync().ConfigureAwait(false);
@@ -172,9 +127,7 @@ namespace Emby.Dlna.Main
         private void ReloadComponents()
         {
             var options = _config.GetDlnaConfiguration();
-            Enabled = options.EnableServer;
-
-            StartSsdpHandler();
+            StartDeviceDiscovery();
 
             if (options.EnableServer)
             {
@@ -195,36 +148,11 @@ namespace Emby.Dlna.Main
             }
         }
 
-        private void StartSsdpHandler()
-        {
-            try
-            {
-                if (_communicationsServer is null)
-                {
-                    var enableMultiSocketBinding = OperatingSystem.IsWindows() || OperatingSystem.IsLinux();
-
-                    _communicationsServer = new SsdpCommunicationsServer(_socketFactory, _networkManager, _logger, enableMultiSocketBinding)
-                    {
-                        IsShared = true
-                    };
-
-                    StartDeviceDiscovery(_communicationsServer);
-                }
-            }
-            catch (Exception ex)
-            {
-                _logger.LogError(ex, "Error starting ssdp handlers");
-            }
-        }
-
-        private void StartDeviceDiscovery(ISsdpCommunicationsServer communicationsServer)
+        private void StartDeviceDiscovery()
         {
             try
             {
-                if (communicationsServer is not null)
-                {
-                    ((DeviceDiscovery)_deviceDiscovery).Start(communicationsServer);
-                }
+                ((DeviceDiscovery)_deviceDiscovery).Start(_communicationsServer);
             }
             catch (Exception ex)
             {
@@ -232,19 +160,6 @@ namespace Emby.Dlna.Main
             }
         }
 
-        private void DisposeDeviceDiscovery()
-        {
-            try
-            {
-                _logger.LogInformation("Disposing DeviceDiscovery");
-                ((DeviceDiscovery)_deviceDiscovery).Dispose();
-            }
-            catch (Exception ex)
-            {
-                _logger.LogError(ex, "Error stopping device discovery");
-            }
-        }
-
         public void StartDevicePublisher(Configuration.DlnaOptions options)
         {
             if (_publisher is not null)
@@ -317,7 +232,7 @@ namespace Emby.Dlna.Main
                     // This must be a globally unique value that survives reboots etc. Get from storage or embedded hardware etc.
                 };
 
-                SetProperies(device, fullService);
+                SetProperties(device, fullService);
                 _publisher.AddDevice(device);
 
                 var embeddedDevices = new[]
@@ -338,13 +253,13 @@ namespace Emby.Dlna.Main
                         // This must be a globally unique value that survives reboots etc. Get from storage or embedded hardware etc.
                     };
 
-                    SetProperies(embeddedDevice, subDevice);
+                    SetProperties(embeddedDevice, subDevice);
                     device.AddDevice(embeddedDevice);
                 }
             }
         }
 
-        private string CreateUuid(string text)
+        private static string CreateUuid(string text)
         {
             if (!Guid.TryParse(text, out var guid))
             {
@@ -354,15 +269,14 @@ namespace Emby.Dlna.Main
             return guid.ToString("D", CultureInfo.InvariantCulture);
         }
 
-        private void SetProperies(SsdpDevice device, string fullDeviceType)
+        private static void SetProperties(SsdpDevice device, string fullDeviceType)
         {
-            var service = fullDeviceType.Replace("urn:", string.Empty, StringComparison.OrdinalIgnoreCase).Replace(":1", string.Empty, StringComparison.OrdinalIgnoreCase);
-
-            var serviceParts = service.Split(':');
-
-            var deviceTypeNamespace = serviceParts[0].Replace('.', '-');
+            var serviceParts = fullDeviceType
+                .Replace("urn:", string.Empty, StringComparison.OrdinalIgnoreCase)
+                .Replace(":1", string.Empty, StringComparison.OrdinalIgnoreCase)
+                .Split(':');
 
-            device.DeviceTypeNamespace = deviceTypeNamespace;
+            device.DeviceTypeNamespace = serviceParts[0].Replace('.', '-');
             device.DeviceClass = serviceParts[1];
             device.DeviceType = serviceParts[2];
         }
@@ -443,20 +357,6 @@ namespace Emby.Dlna.Main
 
             DisposeDevicePublisher();
             DisposePlayToManager();
-            DisposeDeviceDiscovery();
-
-            if (_communicationsServer is not null)
-            {
-                _logger.LogInformation("Disposing SsdpCommunicationsServer");
-                _communicationsServer.Dispose();
-                _communicationsServer = null;
-            }
-
-            ContentDirectory = null;
-            ConnectionManager = null;
-            MediaReceiverRegistrar = null;
-            Current = null;
-
             _disposed = true;
         }
     }

+ 4 - 8
Emby.Dlna/PlayTo/Device.cs

@@ -927,14 +927,11 @@ namespace Emby.Dlna.PlayTo
 
             var resElement = container.Element(UPnpNamespaces.Res);
 
-            if (resElement is not null)
-            {
-                var info = resElement.Attribute(UPnpNamespaces.ProtocolInfo);
+            var info = resElement?.Attribute(UPnpNamespaces.ProtocolInfo);
 
-                if (info is not null && !string.IsNullOrWhiteSpace(info.Value))
-                {
-                    return info.Value.Split(':');
-                }
+            if (info is not null && !string.IsNullOrWhiteSpace(info.Value))
+            {
+                return info.Value.Split(':');
             }
 
             return new string[4];
@@ -1139,7 +1136,6 @@ namespace Emby.Dlna.PlayTo
             return new Device(deviceProperties, httpClientFactory, logger);
         }
 
-#nullable enable
         private static DeviceIcon CreateIcon(XElement element)
         {
             ArgumentNullException.ThrowIfNull(element);

+ 28 - 27
Emby.Dlna/PlayTo/DlnaHttpClient.cs

@@ -55,41 +55,42 @@ namespace Emby.Dlna.PlayTo
             var client = _httpClientFactory.CreateClient(NamedClient.Dlna);
             using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
             response.EnsureSuccessStatusCode();
-            await using MemoryStream ms = new MemoryStream();
-            await response.Content.CopyToAsync(ms, cancellationToken).ConfigureAwait(false);
-            ms.Position = 0;
-            try
+            Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+            await using (stream.ConfigureAwait(false))
             {
-                return await XDocument.LoadAsync(
-                    ms,
-                    LoadOptions.None,
-                    cancellationToken).ConfigureAwait(false);
-            }
-            catch (XmlException)
-            {
-                // try correcting the Xml response with common errors
-                ms.Position = 0;
-                using StreamReader sr = new StreamReader(ms);
-                var xmlString = await sr.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
-
-                // find and replace unescaped ampersands (&)
-                xmlString = EscapeAmpersandRegex().Replace(xmlString, "&amp;");
-
                 try
                 {
-                    // retry reading Xml
-                    using var xmlReader = new StringReader(xmlString);
                     return await XDocument.LoadAsync(
-                        xmlReader,
+                        stream,
                         LoadOptions.None,
                         cancellationToken).ConfigureAwait(false);
                 }
-                catch (XmlException ex)
+                catch (XmlException)
                 {
-                    _logger.LogError(ex, "Failed to parse response");
-                    _logger.LogDebug("Malformed response: {Content}\n", xmlString);
-
-                    return null;
+                    // try correcting the Xml response with common errors
+                    stream.Position = 0;
+                    using StreamReader sr = new StreamReader(stream);
+                    var xmlString = await sr.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
+
+                    // find and replace unescaped ampersands (&)
+                    xmlString = EscapeAmpersandRegex().Replace(xmlString, "&amp;");
+
+                    try
+                    {
+                        // retry reading Xml
+                        using var xmlReader = new StringReader(xmlString);
+                        return await XDocument.LoadAsync(
+                            xmlReader,
+                            LoadOptions.None,
+                            cancellationToken).ConfigureAwait(false);
+                    }
+                    catch (XmlException ex)
+                    {
+                        _logger.LogError(ex, "Failed to parse response");
+                        _logger.LogDebug("Malformed response: {Content}\n", xmlString);
+
+                        return null;
+                    }
                 }
             }
         }

+ 2 - 2
Emby.Dlna/PlayTo/PlayToManager.cs

@@ -39,9 +39,9 @@ namespace Emby.Dlna.PlayTo
         private readonly IMediaSourceManager _mediaSourceManager;
         private readonly IMediaEncoder _mediaEncoder;
 
+        private readonly SemaphoreSlim _sessionLock = new SemaphoreSlim(1, 1);
+        private readonly CancellationTokenSource _disposeCancellationTokenSource = new CancellationTokenSource();
         private bool _disposed;
-        private SemaphoreSlim _sessionLock = new SemaphoreSlim(1, 1);
-        private CancellationTokenSource _disposeCancellationTokenSource = new CancellationTokenSource();
 
         public PlayToManager(ILogger logger, ISessionManager sessionManager, ILibraryManager libraryManager, IUserManager userManager, IDlnaManager dlnaManager, IServerApplicationHost appHost, IImageProcessor imageProcessor, IDeviceDiscovery deviceDiscovery, IHttpClientFactory httpClientFactory, IUserDataManager userDataManager, ILocalizationManager localization, IMediaSourceManager mediaSourceManager, IMediaEncoder mediaEncoder)
         {

+ 5 - 5
Emby.Naming/Common/NamingOptions.cs

@@ -318,7 +318,7 @@ namespace Emby.Naming.Common
                 new EpisodeExpression(@"[\._ -]()[Ee][Pp]_?([0-9]+)([^\\/]*)$"),
                 // <!-- foo.E01., foo.e01. -->
                 new EpisodeExpression(@"[^\\/]*?()\.?[Ee]([0-9]+)\.([^\\/]*)$"),
-                new EpisodeExpression(@"(?<year>[0-9]{4})[._ -](?<month>[0-9]{2})[._ -](?<day>[0-9]{2})", true)
+                new EpisodeExpression("(?<year>[0-9]{4})[._ -](?<month>[0-9]{2})[._ -](?<day>[0-9]{2})", true)
                 {
                     DateTimeFormats = new[]
                     {
@@ -328,7 +328,7 @@ namespace Emby.Naming.Common
                         "yyyy MM dd"
                     }
                 },
-                new EpisodeExpression(@"(?<day>[0-9]{2})[._ -](?<month>[0-9]{2})[._ -](?<year>[0-9]{4})", true)
+                new EpisodeExpression("(?<day>[0-9]{2})[._ -](?<month>[0-9]{2})[._ -](?<year>[0-9]{4})", true)
                 {
                     DateTimeFormats = new[]
                     {
@@ -376,7 +376,7 @@ namespace Emby.Naming.Common
                     IsNamed = true,
                     SupportsAbsoluteEpisodeNumbers = false
                 },
-                new EpisodeExpression("[\\/._ -]p(?:ar)?t[_. -]()([ivx]+|[0-9]+)([._ -][^\\/]*)$")
+                new EpisodeExpression(@"[\/._ -]p(?:ar)?t[_. -]()([ivx]+|[0-9]+)([._ -][^\/]*)$")
                 {
                     SupportsAbsoluteEpisodeNumbers = true
                 },
@@ -417,7 +417,7 @@ namespace Emby.Naming.Common
                 },
 
                 // "1-12 episode title"
-                new EpisodeExpression(@"([0-9]+)-([0-9]+)"),
+                new EpisodeExpression("([0-9]+)-([0-9]+)"),
 
                 // "01 - blah.avi", "01-blah.avi"
                 new EpisodeExpression(@".*(\\|\/)(?<epnumber>[0-9]{1,3})(-(?<endingepnumber>[0-9]{2,3}))*\s?-\s?[^\\\/]*$")
@@ -712,7 +712,7 @@ namespace Emby.Naming.Common
                 // Chapter is often beginning of filename
                 "^(?<chapter>[0-9]+)",
                 // Part if often ending of filename
-                @"(?<!ch(?:apter) )(?<part>[0-9]+)$",
+                "(?<!ch(?:apter) )(?<part>[0-9]+)$",
                 // Sometimes named as 0001_005 (chapter_part)
                 "(?<chapter>[0-9]+)_(?<part>[0-9]+)",
                 // Some audiobooks are ripped from cd's, and will be named by disk number.

+ 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
                 {

+ 2 - 4
Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs

@@ -10,8 +10,6 @@ namespace Emby.Server.Implementations.AppBase
     /// </summary>
     public abstract class BaseApplicationPaths : IApplicationPaths
     {
-        private string _dataPath;
-
         /// <summary>
         /// Initializes a new instance of the <see cref="BaseApplicationPaths"/> class.
         /// </summary>
@@ -33,7 +31,7 @@ namespace Emby.Server.Implementations.AppBase
             CachePath = cacheDirectoryPath;
             WebPath = webDirectoryPath;
 
-            _dataPath = Directory.CreateDirectory(Path.Combine(ProgramDataPath, "data")).FullName;
+            DataPath = Directory.CreateDirectory(Path.Combine(ProgramDataPath, "data")).FullName;
         }
 
         /// <summary>
@@ -55,7 +53,7 @@ namespace Emby.Server.Implementations.AppBase
         /// Gets the folder path to the data directory.
         /// </summary>
         /// <value>The data directory.</value>
-        public string DataPath => _dataPath;
+        public string DataPath { get; }
 
         /// <inheritdoc />
         public string VirtualDataPath => "%AppDataPath%";

+ 8 - 86
Emby.Server.Implementations/ApplicationHost.cs

@@ -13,9 +13,7 @@ using System.Net;
 using System.Reflection;
 using System.Security.Cryptography.X509Certificates;
 using System.Threading.Tasks;
-using Emby.Dlna;
 using Emby.Dlna.Main;
-using Emby.Dlna.Ssdp;
 using Emby.Naming.Common;
 using Emby.Photos;
 using Emby.Server.Implementations.Channels;
@@ -58,7 +56,6 @@ using MediaBrowser.Controller.Chapters;
 using MediaBrowser.Controller.ClientEvent;
 using MediaBrowser.Controller.Collections;
 using MediaBrowser.Controller.Configuration;
-using MediaBrowser.Controller.Dlna;
 using MediaBrowser.Controller.Drawing;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
@@ -82,7 +79,6 @@ using MediaBrowser.LocalMetadata.Savers;
 using MediaBrowser.MediaEncoding.BdInfo;
 using MediaBrowser.MediaEncoding.Subtitles;
 using MediaBrowser.Model.Cryptography;
-using MediaBrowser.Model.Dlna;
 using MediaBrowser.Model.Globalization;
 using MediaBrowser.Model.IO;
 using MediaBrowser.Model.MediaInfo;
@@ -101,7 +97,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 +128,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 +179,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.
@@ -461,7 +446,7 @@ namespace Emby.Server.Implementations
 
             ConfigurationManager.AddParts(GetExports<IConfigurationFactory>());
 
-            NetManager = new NetworkManager(ConfigurationManager, LoggerFactory.CreateLogger<NetworkManager>());
+            NetManager = new NetworkManager(ConfigurationManager, _startupConfig, LoggerFactory.CreateLogger<NetworkManager>());
 
             // Initialize runtime stat collection
             if (ConfigurationManager.Configuration.EnableMetrics)
@@ -507,6 +492,8 @@ namespace Emby.Server.Implementations
             serviceCollection.AddSingleton<IFileSystem, ManagedFileSystem>();
             serviceCollection.AddSingleton<IShortcutHandler, MbLinkShortcutHandler>();
 
+            serviceCollection.AddScoped<ISystemManager, SystemManager>();
+
             serviceCollection.AddSingleton<TmdbClientManager>();
 
             serviceCollection.AddSingleton(NetManager);
@@ -572,8 +559,6 @@ namespace Emby.Server.Implementations
 
             serviceCollection.AddSingleton<ISessionManager, SessionManager>();
 
-            serviceCollection.AddSingleton<IDlnaManager, DlnaManager>();
-
             serviceCollection.AddSingleton<ICollectionManager, CollectionManager>();
 
             serviceCollection.AddSingleton<IPlaylistManager, PlaylistManager>();
@@ -585,8 +570,6 @@ namespace Emby.Server.Implementations
 
             serviceCollection.AddSingleton<IUserViewManager, UserViewManager>();
 
-            serviceCollection.AddSingleton<IDeviceDiscovery, DeviceDiscovery>();
-
             serviceCollection.AddSingleton<IChapterManager, ChapterManager>();
 
             serviceCollection.AddSingleton<IEncodingManager, MediaEncoder.EncodingManager>();
@@ -850,24 +833,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 +888,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)
         {
@@ -983,7 +905,7 @@ namespace Emby.Server.Implementations
         /// <inheritdoc/>
         public string GetSmartApiUrl(HttpRequest request)
         {
-            // Return the host in the HTTP request as the API url
+            // Return the host in the HTTP request as the API URL if not configured otherwise
             if (ConfigurationManager.GetNetworkConfiguration().EnablePublishedServerUriByRequest)
             {
                 int? requestPort = request.Host.Port;
@@ -1018,7 +940,7 @@ namespace Emby.Server.Implementations
         public string GetApiUrlForLocalAccess(IPAddress ipAddress = null, bool allowHttps = true)
         {
             // With an empty source, the port will be null
-            var smart = NetManager.GetBindAddress(ipAddress, out _, true);
+            var smart = NetManager.GetBindAddress(ipAddress, out _, false);
             var scheme = !allowHttps ? Uri.UriSchemeHttp : null;
             int? port = !allowHttps ? HttpPort : null;
             return GetLocalApiUrl(smart, scheme, port);

+ 6 - 3
Emby.Server.Implementations/Channels/ChannelManager.cs

@@ -371,8 +371,11 @@ namespace Emby.Server.Implementations.Channels
 
             Directory.CreateDirectory(Path.GetDirectoryName(path));
 
-            await using FileStream createStream = File.Create(path);
-            await JsonSerializer.SerializeAsync(createStream, mediaSources, _jsonOptions).ConfigureAwait(false);
+            FileStream createStream = File.Create(path);
+            await using (createStream.ConfigureAwait(false))
+            {
+                await JsonSerializer.SerializeAsync(createStream, mediaSources, _jsonOptions).ConfigureAwait(false);
+            }
         }
 
         /// <inheritdoc />
@@ -1156,7 +1159,7 @@ namespace Emby.Server.Implementations.Channels
 
                 if (info.People is not null && info.People.Count > 0)
                 {
-                    _libraryManager.UpdatePeople(item, info.People);
+                    await _libraryManager.UpdatePeopleAsync(item, info.People, cancellationToken).ConfigureAwait(false);
                 }
             }
             else if (forceUpdate)

+ 2 - 5
Emby.Server.Implementations/Data/SqliteItemRepository.cs

@@ -3540,10 +3540,7 @@ namespace Emby.Server.Implementations.Data
                         .Append(paramName)
                         .Append("))) OR ");
 
-                    if (statement is not null)
-                    {
-                        statement.TryBind(paramName, query.PersonIds[i]);
-                    }
+                    statement?.TryBind(paramName, query.PersonIds[i]);
                 }
 
                 clauseBuilder.Length -= Or.Length;
@@ -4382,7 +4379,7 @@ namespace Emby.Server.Implementations.Data
 
                 foreach (var videoType in query.VideoTypes)
                 {
-                    videoTypes.Add("data like '%\"VideoType\":\"" + videoType.ToString() + "\"%'");
+                    videoTypes.Add("data like '%\"VideoType\":\"" + videoType + "\"%'");
                 }
 
                 whereClauses.Add("(" + string.Join(" OR ", videoTypes) + ")");

+ 10 - 1
Emby.Server.Implementations/Dto/DtoService.cs

@@ -22,6 +22,7 @@ using MediaBrowser.Controller.Lyrics;
 using MediaBrowser.Controller.Persistence;
 using MediaBrowser.Controller.Playlists;
 using MediaBrowser.Controller.Providers;
+using MediaBrowser.Controller.Trickplay;
 using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Querying;
@@ -52,6 +53,7 @@ namespace Emby.Server.Implementations.Dto
         private readonly Lazy<ILiveTvManager> _livetvManagerFactory;
 
         private readonly ILyricManager _lyricManager;
+        private readonly ITrickplayManager _trickplayManager;
 
         public DtoService(
             ILogger<DtoService> logger,
@@ -63,7 +65,8 @@ namespace Emby.Server.Implementations.Dto
             IApplicationHost appHost,
             IMediaSourceManager mediaSourceManager,
             Lazy<ILiveTvManager> livetvManagerFactory,
-            ILyricManager lyricManager)
+            ILyricManager lyricManager,
+            ITrickplayManager trickplayManager)
         {
             _logger = logger;
             _libraryManager = libraryManager;
@@ -75,6 +78,7 @@ namespace Emby.Server.Implementations.Dto
             _mediaSourceManager = mediaSourceManager;
             _livetvManagerFactory = livetvManagerFactory;
             _lyricManager = lyricManager;
+            _trickplayManager = trickplayManager;
         }
 
         private ILiveTvManager LivetvManager => _livetvManagerFactory.Value;
@@ -1059,6 +1063,11 @@ namespace Emby.Server.Implementations.Dto
                     dto.Chapters = _itemRepo.GetChapters(item);
                 }
 
+                if (options.ContainsField(ItemFields.Trickplay))
+                {
+                    dto.Trickplay = _trickplayManager.GetTrickplayManifest(item).GetAwaiter().GetResult();
+                }
+
                 if (video.ExtraType.HasValue)
                 {
                     dto.ExtraType = video.ExtraType.Value.ToString();

+ 0 - 2
Emby.Server.Implementations/Emby.Server.Implementations.csproj

@@ -43,8 +43,6 @@
     <TargetFramework>net7.0</TargetFramework>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
-    <!-- https://github.com/microsoft/ApplicationInsights-dotnet/issues/2047 -->
-    <NoWarn>AD0001</NoWarn>
   </PropertyGroup>
 
   <PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">

+ 22 - 13
Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs

@@ -18,7 +18,7 @@ using Microsoft.Extensions.Logging;
 namespace Emby.Server.Implementations.EntryPoints
 {
     /// <summary>
-    /// Class UdpServerEntryPoint.
+    /// Class responsible for registering all UDP broadcast endpoints and their handlers.
     /// </summary>
     public sealed class UdpServerEntryPoint : IServerEntryPoint
     {
@@ -35,14 +35,13 @@ namespace Emby.Server.Implementations.EntryPoints
         private readonly IConfiguration _config;
         private readonly IConfigurationManager _configurationManager;
         private readonly INetworkManager _networkManager;
-        private readonly bool _enableMultiSocketBinding;
 
         /// <summary>
         /// The UDP server.
         /// </summary>
-        private List<UdpServer> _udpServers;
-        private CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource();
-        private bool _disposed = false;
+        private readonly List<UdpServer> _udpServers;
+        private readonly CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource();
+        private bool _disposed;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="UdpServerEntryPoint" /> class.
@@ -65,7 +64,6 @@ namespace Emby.Server.Implementations.EntryPoints
             _configurationManager = configurationManager;
             _networkManager = networkManager;
             _udpServers = new List<UdpServer>();
-            _enableMultiSocketBinding = OperatingSystem.IsWindows() || OperatingSystem.IsLinux();
         }
 
         /// <inheritdoc />
@@ -80,14 +78,16 @@ namespace Emby.Server.Implementations.EntryPoints
 
             try
             {
-                if (_enableMultiSocketBinding)
+                // Linux needs to bind to the broadcast addresses to get broadcast traffic
+                // Windows receives broadcast fine when binding to just the interface, it is unable to bind to broadcast addresses
+                if (OperatingSystem.IsLinux())
                 {
-                    // Add global broadcast socket
+                    // Add global broadcast listener
                     var server = new UdpServer(_logger, _appHost, _config, IPAddress.Broadcast, PortNumber);
                     server.Start(_cancellationTokenSource.Token);
                     _udpServers.Add(server);
 
-                    // Add bind address specific broadcast sockets
+                    // Add bind address specific broadcast listeners
                     // IPv6 is currently unsupported
                     var validInterfaces = _networkManager.GetInternalBindAddresses().Where(i => i.AddressFamily == AddressFamily.InterNetwork);
                     foreach (var intf in validInterfaces)
@@ -102,9 +102,18 @@ namespace Emby.Server.Implementations.EntryPoints
                 }
                 else
                 {
-                    var server = new UdpServer(_logger, _appHost, _config, IPAddress.Any, PortNumber);
-                    server.Start(_cancellationTokenSource.Token);
-                    _udpServers.Add(server);
+                    // Add bind address specific broadcast listeners
+                    // IPv6 is currently unsupported
+                    var validInterfaces = _networkManager.GetInternalBindAddresses().Where(i => i.AddressFamily == AddressFamily.InterNetwork);
+                    foreach (var intf in validInterfaces)
+                    {
+                        var intfAddress = intf.Address;
+                        _logger.LogDebug("Binding UDP server to {Address} on port {PortNumber}", intfAddress, PortNumber);
+
+                        var server = new UdpServer(_logger, _appHost, _config, intfAddress, PortNumber);
+                        server.Start(_cancellationTokenSource.Token);
+                        _udpServers.Add(server);
+                    }
                 }
             }
             catch (SocketException ex)
@@ -119,7 +128,7 @@ namespace Emby.Server.Implementations.EntryPoints
         {
             if (_disposed)
             {
-                throw new ObjectDisposedException(this.GetType().Name);
+                throw new ObjectDisposedException(GetType().Name);
             }
         }
 

+ 0 - 1
Emby.Server.Implementations/HttpServer/WebSocketConnection.cs

@@ -12,7 +12,6 @@ using MediaBrowser.Controller.Net;
 using MediaBrowser.Controller.Net.WebSocketMessages;
 using MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
 using MediaBrowser.Model.Session;
-using Microsoft.AspNetCore.Http;
 using Microsoft.Extensions.Logging;
 
 namespace Emby.Server.Implementations.HttpServer

+ 0 - 1
Emby.Server.Implementations/IO/FileRefresher.cs

@@ -210,7 +210,6 @@ namespace Emby.Server.Implementations.IO
 
             DisposeTimer();
             _disposed = true;
-            GC.SuppressFinalize(this);
         }
     }
 }

+ 5 - 3
Emby.Server.Implementations/IO/ManagedFileSystem.cs

@@ -91,7 +91,7 @@ namespace Emby.Server.Implementations.IO
             }
 
             // unc path
-            if (filePath.StartsWith("\\\\", StringComparison.Ordinal))
+            if (filePath.StartsWith(@"\\", StringComparison.Ordinal))
             {
                 return filePath;
             }
@@ -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)
             {

+ 17 - 16
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
@@ -2858,7 +2850,7 @@ namespace Emby.Server.Implementations.Library
                 {
                     var path = Path.Combine(virtualFolderPath, collectionType.ToString().ToLowerInvariant() + ".collection");
 
-                    File.WriteAllBytes(path, Array.Empty<byte>());
+                    await File.WriteAllBytesAsync(path, Array.Empty<byte>()).ConfigureAwait(false);
                 }
 
                 CollectionFolder.SaveLibraryOptions(virtualFolderPath, options);
@@ -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))

+ 13 - 5
Emby.Server.Implementations/Library/LiveStreamHelper.cs

@@ -48,15 +48,20 @@ namespace Emby.Server.Implementations.Library
 
             if (!string.IsNullOrEmpty(cacheKey))
             {
+                FileStream jsonStream = AsyncFile.OpenRead(cacheFilePath);
                 try
                 {
-                    await using FileStream jsonStream = AsyncFile.OpenRead(cacheFilePath);
                     mediaInfo = await JsonSerializer.DeserializeAsync<MediaInfo>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false);
 
                     // _logger.LogDebug("Found cached media info");
                 }
-                catch
+                catch (Exception ex)
                 {
+                    _logger.LogError(ex, "Error deserializing mediainfo cache");
+                }
+                finally
+                {
+                    await jsonStream.DisposeAsync().ConfigureAwait(false);
                 }
             }
 
@@ -84,10 +89,13 @@ namespace Emby.Server.Implementations.Library
                 if (cacheFilePath is not null)
                 {
                     Directory.CreateDirectory(Path.GetDirectoryName(cacheFilePath));
-                    await using FileStream createStream = AsyncFile.OpenWrite(cacheFilePath);
-                    await JsonSerializer.SerializeAsync(createStream, mediaInfo, _jsonOptions, cancellationToken).ConfigureAwait(false);
+                    FileStream createStream = AsyncFile.OpenWrite(cacheFilePath);
+                    await using (createStream.ConfigureAwait(false))
+                    {
+                        await JsonSerializer.SerializeAsync(createStream, mediaInfo, _jsonOptions, cancellationToken).ConfigureAwait(false);
+                    }
 
-                    // _logger.LogDebug("Saved media info to {0}", cacheFilePath);
+                    _logger.LogDebug("Saved media info to {0}", cacheFilePath);
                 }
             }
 

+ 10 - 5
Emby.Server.Implementations/Library/MediaSourceManager.cs

@@ -625,17 +625,19 @@ namespace Emby.Server.Implementations.Library
 
             if (!string.IsNullOrEmpty(cacheKey))
             {
+                FileStream jsonStream = AsyncFile.OpenRead(cacheFilePath);
                 try
                 {
-                    await using FileStream jsonStream = AsyncFile.OpenRead(cacheFilePath);
                     mediaInfo = await JsonSerializer.DeserializeAsync<MediaInfo>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false);
-
-                    // _logger.LogDebug("Found cached media info");
                 }
                 catch (Exception ex)
                 {
                     _logger.LogDebug(ex, "_jsonSerializer.DeserializeFromFile threw an exception.");
                 }
+                finally
+                {
+                    await jsonStream.DisposeAsync().ConfigureAwait(false);
+                }
             }
 
             if (mediaInfo is null)
@@ -664,8 +666,11 @@ namespace Emby.Server.Implementations.Library
                 if (cacheFilePath is not null)
                 {
                     Directory.CreateDirectory(Path.GetDirectoryName(cacheFilePath));
-                    await using FileStream createStream = File.Create(cacheFilePath);
-                    await JsonSerializer.SerializeAsync(createStream, mediaInfo, _jsonOptions, cancellationToken).ConfigureAwait(false);
+                    FileStream createStream = File.Create(cacheFilePath);
+                    await using (createStream.ConfigureAwait(false))
+                    {
+                        await JsonSerializer.SerializeAsync(createStream, mediaInfo, _jsonOptions, cancellationToken).ConfigureAwait(false);
+                    }
 
                     // _logger.LogDebug("Saved media info to {0}", cacheFilePath);
                 }

+ 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

+ 0 - 1
Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs

@@ -1,5 +1,4 @@
 using System;
-using System.Collections.Generic;
 using System.IO;
 using System.Linq;
 using Emby.Naming.Common;

+ 1 - 1
Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs

@@ -62,7 +62,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
                     var resolver = new Naming.TV.EpisodeResolver(namingOptions);
 
                     var folderName = System.IO.Path.GetFileName(path);
-                    var testPath = "\\\\test\\" + folderName;
+                    var testPath = @"\\test\" + folderName;
 
                     var episodeInfo = resolver.Resolve(testPath, true);
 

+ 9 - 5
Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs

@@ -1851,7 +1851,8 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV
                 return;
             }
 
-            await using (var stream = new FileStream(nfoPath, FileMode.CreateNew, FileAccess.Write, FileShare.None))
+            var stream = new FileStream(nfoPath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
+            await using (stream.ConfigureAwait(false))
             {
                 var settings = new XmlWriterSettings
                 {
@@ -1860,7 +1861,8 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV
                     Async = true
                 };
 
-                await using (var writer = XmlWriter.Create(stream, settings))
+                var writer = XmlWriter.Create(stream, settings);
+                await using (writer.ConfigureAwait(false))
                 {
                     await writer.WriteStartDocumentAsync(true).ConfigureAwait(false);
                     await writer.WriteStartElementAsync(null, "tvshow", null).ConfigureAwait(false);
@@ -1914,7 +1916,8 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV
                 return;
             }
 
-            await using (var stream = new FileStream(nfoPath, FileMode.CreateNew, FileAccess.Write, FileShare.None))
+            var stream = new FileStream(nfoPath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
+            await using (stream.ConfigureAwait(false))
             {
                 var settings = new XmlWriterSettings
                 {
@@ -1927,7 +1930,8 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV
 
                 var isSeriesEpisode = timer.IsProgramSeries;
 
-                await using (var writer = XmlWriter.Create(stream, settings))
+                var writer = XmlWriter.Create(stream, settings);
+                await using (writer.ConfigureAwait(false))
                 {
                     await writer.WriteStartDocumentAsync(true).ConfigureAwait(false);
 
@@ -1965,7 +1969,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV
                     }
                     else
                     {
-                        await writer.WriteStartElementAsync(null, "movie", null);
+                        await writer.WriteStartElementAsync(null, "movie", null).ConfigureAwait(false);
 
                         if (!string.IsNullOrWhiteSpace(item.Name))
                         {

+ 7 - 18
Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs

@@ -106,8 +106,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings
             options.Content = JsonContent.Create(requestList, options: _jsonOptions);
             options.Headers.TryAddWithoutValidation("token", token);
             using var response = await Send(options, true, info, cancellationToken).ConfigureAwait(false);
-            await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
-            var dailySchedules = await JsonSerializer.DeserializeAsync<IReadOnlyList<DayDto>>(responseStream, _jsonOptions, cancellationToken).ConfigureAwait(false);
+            var dailySchedules = await response.Content.ReadFromJsonAsync<IReadOnlyList<DayDto>>(_jsonOptions, cancellationToken).ConfigureAwait(false);
             if (dailySchedules is null)
             {
                 return Array.Empty<ProgramInfo>();
@@ -122,8 +121,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings
             programRequestOptions.Content = JsonContent.Create(programIds, options: _jsonOptions);
 
             using var innerResponse = await Send(programRequestOptions, true, info, cancellationToken).ConfigureAwait(false);
-            await using var innerResponseStream = await innerResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
-            var programDetails = await JsonSerializer.DeserializeAsync<IReadOnlyList<ProgramDetailsDto>>(innerResponseStream, _jsonOptions, cancellationToken).ConfigureAwait(false);
+            var programDetails = await innerResponse.Content.ReadFromJsonAsync<IReadOnlyList<ProgramDetailsDto>>(_jsonOptions, cancellationToken).ConfigureAwait(false);
             if (programDetails is null)
             {
                 return Array.Empty<ProgramInfo>();
@@ -482,8 +480,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings
             try
             {
                 using var innerResponse2 = await Send(message, true, info, cancellationToken).ConfigureAwait(false);
-                await using var response = await innerResponse2.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
-                return await JsonSerializer.DeserializeAsync<IReadOnlyList<ShowImagesDto>>(response, _jsonOptions, cancellationToken).ConfigureAwait(false);
+                return await innerResponse2.Content.ReadFromJsonAsync<IReadOnlyList<ShowImagesDto>>(_jsonOptions, cancellationToken).ConfigureAwait(false);
             }
             catch (Exception ex)
             {
@@ -510,10 +507,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings
             try
             {
                 using var httpResponse = await Send(options, false, info, cancellationToken).ConfigureAwait(false);
-                await using var response = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
-
-                var root = await JsonSerializer.DeserializeAsync<IReadOnlyList<HeadendsDto>>(response, _jsonOptions, cancellationToken).ConfigureAwait(false);
-
+                var root = await httpResponse.Content.ReadFromJsonAsync<IReadOnlyList<HeadendsDto>>(_jsonOptions, cancellationToken).ConfigureAwait(false);
                 if (root is not null)
                 {
                     foreach (HeadendsDto headend in root)
@@ -649,8 +643,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings
 
             using var response = await Send(options, false, null, cancellationToken).ConfigureAwait(false);
             response.EnsureSuccessStatusCode();
-            await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
-            var root = await JsonSerializer.DeserializeAsync<TokenDto>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false);
+            var root = await response.Content.ReadFromJsonAsync<TokenDto>(_jsonOptions, cancellationToken).ConfigureAwait(false);
             if (string.Equals(root?.Message, "OK", StringComparison.Ordinal))
             {
                 _logger.LogInformation("Authenticated with Schedules Direct token: {Token}", root.Token);
@@ -691,10 +684,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings
             {
                 using var httpResponse = await Send(options, false, null, cancellationToken).ConfigureAwait(false);
                 httpResponse.EnsureSuccessStatusCode();
-                await using var stream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
-                using var response = httpResponse.Content;
-                var root = await JsonSerializer.DeserializeAsync<LineupsDto>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false);
-
+                var root = await httpResponse.Content.ReadFromJsonAsync<LineupsDto>(_jsonOptions, cancellationToken).ConfigureAwait(false);
                 return root?.Lineups.Any(i => string.Equals(info.ListingsId, i.Lineup, StringComparison.OrdinalIgnoreCase)) ?? false;
             }
             catch (HttpRequestException ex)
@@ -748,8 +738,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings
             options.Headers.TryAddWithoutValidation("token", token);
 
             using var httpResponse = await Send(options, true, info, cancellationToken).ConfigureAwait(false);
-            await using var stream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
-            var root = await JsonSerializer.DeserializeAsync<ChannelDto>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false);
+            var root = await httpResponse.Content.ReadFromJsonAsync<ChannelDto>(_jsonOptions, cancellationToken).ConfigureAwait(false);
             if (root is null)
             {
                 return new List<ChannelInfo>();

+ 0 - 1
Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs

@@ -17,7 +17,6 @@ using MediaBrowser.Controller.LiveTv;
 using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.IO;
 using MediaBrowser.Model.LiveTv;
-using Microsoft.Extensions.Caching.Memory;
 using Microsoft.Extensions.Logging;
 
 namespace Emby.Server.Implementations.LiveTv.TunerHosts

+ 30 - 32
Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs

@@ -9,6 +9,7 @@ using System.IO;
 using System.Linq;
 using System.Net;
 using System.Net.Http;
+using System.Net.Http.Json;
 using System.Text.Json;
 using System.Threading;
 using System.Threading.Tasks;
@@ -27,7 +28,6 @@ using MediaBrowser.Model.IO;
 using MediaBrowser.Model.LiveTv;
 using MediaBrowser.Model.MediaInfo;
 using MediaBrowser.Model.Net;
-using Microsoft.Extensions.Caching.Memory;
 using Microsoft.Extensions.Logging;
 
 namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
@@ -76,13 +76,10 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
             var model = await GetModelInfo(info, false, cancellationToken).ConfigureAwait(false);
 
             using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(model.LineupURL ?? model.BaseURL + "/lineup.json", HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
-            await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
-            var lineup = await JsonSerializer.DeserializeAsync<List<Channels>>(stream, _jsonOptions, cancellationToken)
-                .ConfigureAwait(false) ?? new List<Channels>();
-
+            var lineup = await response.Content.ReadFromJsonAsync<IEnumerable<Channels>>(_jsonOptions, cancellationToken).ConfigureAwait(false) ?? Enumerable.Empty<Channels>();
             if (info.ImportFavoritesOnly)
             {
-                lineup = lineup.Where(i => i.Favorite).ToList();
+                lineup = lineup.Where(i => i.Favorite);
             }
 
             return lineup.Where(i => !i.DRM).ToList();
@@ -129,9 +126,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
                     .GetAsync(GetApiUrl(info) + "/discover.json", HttpCompletionOption.ResponseHeadersRead, cancellationToken)
                     .ConfigureAwait(false);
                 response.EnsureSuccessStatusCode();
-                await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
-                var discoverResponse = await JsonSerializer.DeserializeAsync<DiscoverResponse>(stream, _jsonOptions, cancellationToken)
-                    .ConfigureAwait(false);
+                var discoverResponse = await response.Content.ReadFromJsonAsync<DiscoverResponse>(_jsonOptions, cancellationToken).ConfigureAwait(false);
 
                 if (!string.IsNullOrEmpty(cacheKey))
                 {
@@ -175,34 +170,37 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
             using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
                 .GetAsync(string.Format(CultureInfo.InvariantCulture, "{0}/tuners.html", GetApiUrl(info)), HttpCompletionOption.ResponseHeadersRead, cancellationToken)
                 .ConfigureAwait(false);
-            await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
-            using var sr = new StreamReader(stream, System.Text.Encoding.UTF8);
             var tuners = new List<LiveTvTunerInfo>();
-            await foreach (var line in sr.ReadAllLinesAsync().ConfigureAwait(false))
+            var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+            await using (stream.ConfigureAwait(false))
             {
-                string stripedLine = StripXML(line);
-                if (stripedLine.Contains("Channel", StringComparison.Ordinal))
+                using var sr = new StreamReader(stream, System.Text.Encoding.UTF8);
+                await foreach (var line in sr.ReadAllLinesAsync().ConfigureAwait(false))
                 {
-                    LiveTvTunerStatus status;
-                    var index = stripedLine.IndexOf("Channel", StringComparison.OrdinalIgnoreCase);
-                    var name = stripedLine.Substring(0, index - 1);
-                    var currentChannel = stripedLine.Substring(index + 7);
-                    if (string.Equals(currentChannel, "none", StringComparison.Ordinal))
+                    string stripedLine = StripXML(line);
+                    if (stripedLine.Contains("Channel", StringComparison.Ordinal))
                     {
-                        status = LiveTvTunerStatus.LiveTv;
-                    }
-                    else
-                    {
-                        status = LiveTvTunerStatus.Available;
-                    }
+                        LiveTvTunerStatus status;
+                        var index = stripedLine.IndexOf("Channel", StringComparison.OrdinalIgnoreCase);
+                        var name = stripedLine.Substring(0, index - 1);
+                        var currentChannel = stripedLine.Substring(index + 7);
+                        if (string.Equals(currentChannel, "none", StringComparison.Ordinal))
+                        {
+                            status = LiveTvTunerStatus.LiveTv;
+                        }
+                        else
+                        {
+                            status = LiveTvTunerStatus.Available;
+                        }
 
-                    tuners.Add(new LiveTvTunerInfo
-                    {
-                        Name = name,
-                        SourceType = string.IsNullOrWhiteSpace(model.ModelNumber) ? Name : model.ModelNumber,
-                        ProgramName = currentChannel,
-                        Status = status
-                    });
+                        tuners.Add(new LiveTvTunerInfo
+                        {
+                            Name = name,
+                            SourceType = string.IsNullOrWhiteSpace(model.ModelNumber) ? Name : model.ModelNumber,
+                            ProgramName = currentChannel,
+                            Status = status
+                        });
+                    }
                 }
             }
 

+ 0 - 2
Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs

@@ -44,8 +44,6 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
                     StopStreaming(socket).GetAwaiter().GetResult();
                 }
             }
-
-            GC.SuppressFinalize(this);
         }
 
         public async Task<bool> CheckTunerAvailability(IPAddress remoteIP, int tuner, CancellationToken cancellationToken)

+ 0 - 2
Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs

@@ -5,7 +5,6 @@
 using System;
 using System.Collections.Generic;
 using System.Globalization;
-using System.IO;
 using System.Linq;
 using System.Net.Http;
 using System.Threading;
@@ -22,7 +21,6 @@ using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.IO;
 using MediaBrowser.Model.LiveTv;
 using MediaBrowser.Model.MediaInfo;
-using Microsoft.Extensions.Caching.Memory;
 using Microsoft.Extensions.Logging;
 using Microsoft.Net.Http.Headers;
 

+ 2 - 0
Emby.Server.Implementations/Localization/Core/en-US.json

@@ -112,6 +112,8 @@
     "TaskCleanLogsDescription": "Deletes log files that are more than {0} days old.",
     "TaskRefreshPeople": "Refresh People",
     "TaskRefreshPeopleDescription": "Updates metadata for actors and directors in your media library.",
+    "TaskRefreshTrickplayImages": "Generate Trickplay Images",
+    "TaskRefreshTrickplayImagesDescription": "Creates trickplay previews for videos in enabled libraries.",
     "TaskUpdatePlugins": "Update Plugins",
     "TaskUpdatePluginsDescription": "Downloads and installs updates for plugins that are configured to update automatically.",
     "TaskCleanTranscode": "Clean Transcode Directory",

+ 18 - 0
Emby.Server.Implementations/Localization/Core/fo.json

@@ -0,0 +1,18 @@
+{
+    "Artists": "Listafólk",
+    "Collections": "Søvn",
+    "Default": "Sjálvgildi",
+    "DeviceOfflineWithName": "{0} hevur slitið sambandið",
+    "External": "Ytri",
+    "Genres": "Greinar",
+    "Albums": "Album",
+    "AppDeviceValues": "App: {0}, Eind: {1}",
+    "Application": "Nýtsluskipan",
+    "Books": "Bøkur",
+    "Channels": "Rásir",
+    "ChapterNameValue": "Kapittul {0}",
+    "DeviceOnlineWithName": "{0} er sambundið",
+    "Favorites": "Yndis",
+    "Folders": "Mappur",
+    "Forced": "Kravt"
+}

+ 35 - 28
Emby.Server.Implementations/Localization/Core/is.json

@@ -13,8 +13,8 @@
     "HeaderFavoriteArtists": "Uppáhalds Listamenn",
     "HeaderFavoriteAlbums": "Uppáhalds Plötur",
     "HeaderContinueWatching": "Halda áfram að horfa",
-    "HeaderAlbumArtists": "Höfundur plötu",
-    "Genres": "Tegundir",
+    "HeaderAlbumArtists": "Listamaður á umslagi",
+    "Genres": "Stefnur",
     "Folders": "Möppur",
     "Favorites": "Uppáhalds",
     "FailedLoginAttemptWithUserName": "{0} reyndi að auðkenna sig",
@@ -22,32 +22,32 @@
     "DeviceOfflineWithName": "{0} hefur aftengst",
     "Collections": "Söfn",
     "ChapterNameValue": "Kafli {0}",
-    "Channels": "Stöðvar",
-    "CameraImageUploadedFrom": "Ný ljósmynd frá myndavél hefur verið hlaðið upp frá {0}",
+    "Channels": "Rásir",
+    "CameraImageUploadedFrom": "{0} hefur hlaðið upp nýrri ljósmynd úr myndavél sinni",
     "Books": "Bækur",
-    "AuthenticationSucceededWithUserName": "{0} auðkenning tókst",
-    "Artists": "Listamaður",
+    "AuthenticationSucceededWithUserName": "Auðkenning fyrir {0} tókst",
+    "Artists": "Listamenn",
     "Application": "Forrit",
     "AppDeviceValues": "Snjallforrit: {0}, Tæki: {1}",
     "Albums": "Plötur",
-    "Plugin": "Viðbót",
-    "Photos": "Myndir",
-    "NotificationOptionVideoPlaybackStopped": "Myndbandafspilun stöðvuð",
-    "NotificationOptionVideoPlayback": "Myndbandafspilun hafin",
+    "Plugin": "Viðbótarvirkni",
+    "Photos": "Ljósmyndir",
+    "NotificationOptionVideoPlaybackStopped": "Myndbandsafspilun stöðvuð",
+    "NotificationOptionVideoPlayback": "Myndbandsafspilun hafin",
     "NotificationOptionUserLockedOut": "Notandi læstur úti",
-    "NotificationOptionServerRestartRequired": "Endurræsing þjóns er nauðsynileg",
-    "NotificationOptionPluginUpdateInstalled": "Viðbótar uppfærsla uppsett",
-    "NotificationOptionPluginUninstalled": "Viðbót fjarlægð",
-    "NotificationOptionPluginInstalled": "Viðbót sett upp",
+    "NotificationOptionServerRestartRequired": "Endurræsing þjóns er nauðsynleg",
+    "NotificationOptionPluginUpdateInstalled": "Uppfærslu á viðbótarvirkni lokið",
+    "NotificationOptionPluginUninstalled": "Viðbótarvirkni fjarlægð",
+    "NotificationOptionPluginInstalled": "Viðbótarvirkni sett upp",
     "NotificationOptionPluginError": "Bilun í viðbót",
     "NotificationOptionInstallationFailed": "Uppsetning tókst ekki",
-    "NotificationOptionCameraImageUploaded": "Myndavélarmynd hlaðið upp",
+    "NotificationOptionCameraImageUploaded": "Ljósmynd hlaðið upp",
     "NotificationOptionAudioPlaybackStopped": "Hljóðafspilun stöðvuð",
     "NotificationOptionAudioPlayback": "Hljóðafspilun hafin",
     "NotificationOptionApplicationUpdateInstalled": "Uppfærsla uppsett",
     "NotificationOptionApplicationUpdateAvailable": "Uppfærsla í boði",
-    "NameSeasonUnknown": "Sería óþekkt",
-    "NameSeasonNumber": "Sería {0}",
+    "NameSeasonUnknown": "Þáttaröð óþekkt",
+    "NameSeasonNumber": "Þáttaröð {0}",
     "MixedContent": "Blandað efni",
     "MessageServerConfigurationUpdated": "Stillingar þjóns hafa verið uppfærðar",
     "MessageApplicationUpdatedTo": "Jellyfin þjónn hefur verið uppfærður í {0}",
@@ -57,24 +57,24 @@
     "User": "Notandi",
     "System": "Kerfi",
     "NotificationOptionNewLibraryContent": "Nýju efni bætt við",
-    "NewVersionIsAvailable": "Ný útgáfa af Jellyfin þjón er fáanleg til niðurhals.",
+    "NewVersionIsAvailable": "Ný útgáfa af Jellyfin þjón er tilbúin til niðurhals.",
     "NameInstallFailed": "{0} uppsetning mistókst",
     "MusicVideos": "Tónlistarmyndbönd",
     "Music": "Tónlist",
     "Movies": "Kvikmyndir",
     "UserDeletedWithName": "Notanda {0} hefur verið eytt",
     "UserCreatedWithName": "Notandi {0} hefur verið stofnaður",
-    "TvShows": "Þættir",
+    "TvShows": "Sjónvarpsþættir",
     "Sync": "Samstilla",
     "Songs": "Lög",
-    "ServerNameNeedsToBeRestarted": "{0} þarf að endurræsa",
+    "ServerNameNeedsToBeRestarted": "{0} þarf að vera endurræstur",
     "ScheduledTaskStartedWithName": "{0} hafin",
     "ScheduledTaskFailedWithName": "{0} mistókst",
     "PluginUpdatedWithName": "{0} var uppfært",
     "PluginUninstalledWithName": "{0} var fjarlægt",
     "PluginInstalledWithName": "{0} var sett upp",
     "NotificationOptionTaskFailed": "Tímasett verkefni mistókst",
-    "StartupEmbyServerIsLoading": "Jellyfin netþjónnin er að hlaðast. Vinsamlega prufaðu aftur fljótlega.",
+    "StartupEmbyServerIsLoading": "Jellyfin netþjónnin er að ræsa sig upp. Vinsamlegast reyndu aftur fljótlega.",
     "VersionNumber": "Útgáfa {0}",
     "ValueHasBeenAddedToLibrary": "{0} hefur verið bætt við í gagnasafnið þitt",
     "UserStoppedPlayingItemWithValues": "{0} hefur lokið spilunar af {1} á {2}",
@@ -83,14 +83,14 @@
     "UserPasswordChangedWithName": "Lykilorði fyrir notandann {0} hefur verið breytt",
     "UserOnlineFromDevice": "{0} hefur verið virkur síðan {1}",
     "UserOfflineFromDevice": "{0} hefur aftengst frá {1}",
-    "UserLockedOutWithName": "Notanda {0} hefur verið heflaður aðgangur",
-    "UserDownloadingItemWithValues": "{0} Hleður niður {1}",
+    "UserLockedOutWithName": "Notandi {0} hefur verið læstur úti",
+    "UserDownloadingItemWithValues": "{0} hleður niður {1}",
     "SubtitleDownloadFailureFromForItem": "Tókst ekki að hala niður skjátextum frá {0} til {1}",
-    "ProviderValue": "Veitandi: {0}",
+    "ProviderValue": "Efnisveita: {0}",
     "MessageNamedServerConfigurationUpdatedWithValue": "Stilling {0} hefur verið uppfærð á netþjón",
-    "ValueSpecialEpisodeName": "Sérstakt - {0}",
-    "Shows": "Sýningar",
-    "Playlists": "Spilunarlisti",
+    "ValueSpecialEpisodeName": "Sérstaktur - {0}",
+    "Shows": "Þættir",
+    "Playlists": "Efnisskrár",
     "TaskRefreshChannelsDescription": "Endurhlaða upplýsingum netrása.",
     "TaskRefreshChannels": "Endurhlaða Rásir",
     "TaskCleanTranscodeDescription": "Eyða umkóðuðum skrám sem eru meira en einum degi eldri.",
@@ -116,5 +116,12 @@
     "TaskCleanLogsDescription": "Eyðir færslu skrám sem eru meira en {0} gömul.",
     "TaskCleanLogs": "Hreinsa færslu skrá",
     "TaskDownloadMissingSubtitlesDescription": "Leitar á netinu að texta sem vantar miðað við uppsetningu lýsigagna.",
-    "HearingImpaired": "Heyrnarskertur"
+    "HearingImpaired": "Heyrnarskertur",
+    "TaskOptimizeDatabaseDescription": "Þjappar gagnagrunni og bætir við lausu diskaplássi. Að keyra þessa aðgerð eftir skönnun safnsins, eða eftir einhverjar breytingar sem fela í sér gagnagrunnsbreytingar, gætu aukið hraðvirkni.",
+    "TaskKeyframeExtractor": "Lykilrammaplokkari",
+    "TaskKeyframeExtractorDescription": "Plokkar lykilramma úr myndbandsskrám til að búa til nákvæmari HLS uppskiptingarlista. Þetta verk getur tekið langan tíma.",
+    "TaskRefreshChapterImages": "Plokka kafla-myndir",
+    "TaskCleanActivityLogDescription": "Eyðir virkniskráningarfærslum sem hafa náð settum hámarksaldri.",
+    "Forced": "Þvingað",
+    "External": "Útvær"
 }

+ 38 - 38
Emby.Server.Implementations/Localization/Core/lv.json

@@ -1,7 +1,7 @@
 {
     "ServerNameNeedsToBeRestarted": "{0} ir vajadzīgs restarts",
     "NotificationOptionTaskFailed": "Plānota uzdevuma kļūme",
-    "HeaderRecordingGroups": "Ierakstu Grupas",
+    "HeaderRecordingGroups": "Ierakstu grupas",
     "UserPolicyUpdatedWithName": "Lietotāju politika atjaunota priekš {0}",
     "SubtitleDownloadFailureFromForItem": "Subtitru lejupielāde no {0} priekš {1} neizdevās",
     "NotificationOptionVideoPlaybackStopped": "Video atskaņošana apturēta",
@@ -14,7 +14,7 @@
     "Photos": "Attēli",
     "NotificationOptionUserLockedOut": "Lietotājs bloķēts",
     "LabelRunningTimeValue": "Garums: {0}",
-    "Inherit": "Mantot",
+    "Inherit": "Pārmantot",
     "AppDeviceValues": "Lietotne: {0}, Ierīce: {1}",
     "VersionNumber": "Versija {0}",
     "ValueHasBeenAddedToLibrary": "{0} ir ticis pievienots jūsu multvides bibliotēkai",
@@ -28,7 +28,7 @@
     "UserDeletedWithName": "Lietotājs {0} ir izdzēsts",
     "UserCreatedWithName": "Lietotājs {0} ir ticis izveidots",
     "User": "Lietotājs",
-    "TvShows": "TV Raidījumi",
+    "TvShows": "TV raidījumi",
     "Sync": "Sinhronizācija",
     "System": "Sistēma",
     "StartupEmbyServerIsLoading": "Jellyfin Serveris lādējas. Lūdzu mēģiniet vēlreiz pēc brīža.",
@@ -38,11 +38,11 @@
     "PluginUninstalledWithName": "{0} tika noņemts",
     "PluginInstalledWithName": "{0} tika uzstādīts",
     "Plugin": "Paplašinājums",
-    "Playlists": "Atskaņošanas Saraksti",
+    "Playlists": "Atskaņošanas saraksti",
     "MixedContent": "Jaukts saturs",
-    "HomeVideos": "Mājas Video",
+    "HomeVideos": "Mājas video",
     "HeaderNextUp": "Nākamais",
-    "ChapterNameValue": "Nodaļa {0}",
+    "ChapterNameValue": "{0}. nodaļa",
     "Application": "Lietotne",
     "NotificationOptionServerRestartRequired": "Vajadzīgs servera restarts",
     "NotificationOptionPluginUpdateInstalled": "Paplašinājuma atjauninājums uzstādīts",
@@ -56,14 +56,14 @@
     "NotificationOptionApplicationUpdateInstalled": "Lietotnes atjauninājums uzstādīts",
     "NotificationOptionApplicationUpdateAvailable": "Lietotnes atjauninājums pieejams",
     "NewVersionIsAvailable": "Lejupielādei ir pieejama jauna Jellyfin Server versija.",
-    "NameSeasonUnknown": "Nezināma Sezona",
-    "NameSeasonNumber": "Sezona {0}",
+    "NameSeasonUnknown": "Nezināma sezona",
+    "NameSeasonNumber": "{0}. sezona",
     "NameInstallFailed": "{0} instalācija neizdevās",
     "MusicVideos": "Mūzikas video",
     "Music": "Mūzika",
     "Movies": "Filmas",
     "MessageServerConfigurationUpdated": "Servera konfigurācija ir tikusi atjaunota",
-    "MessageNamedServerConfigurationUpdatedWithValue": "Servera konfigurācijas sadaļa {0} ir tikusi atjaunota",
+    "MessageNamedServerConfigurationUpdatedWithValue": "Servera konfigurācijas sadaļa {0} tika atjaunota",
     "MessageApplicationUpdatedTo": "Jellyfin Server ir ticis atjaunots uz {0}",
     "MessageApplicationUpdated": "Jellyfin Server ir ticis atjaunots",
     "Latest": "Jaunākais",
@@ -71,57 +71,57 @@
     "ItemRemovedWithName": "{0} tika noņemts no bibliotēkas",
     "ItemAddedWithName": "{0} tika pievienots bibliotēkai",
     "HeaderLiveTV": "Tiešraides TV",
-    "HeaderContinueWatching": "Turpināt Skatīšanos",
-    "HeaderAlbumArtists": "Albumu Izpildītāji",
+    "HeaderContinueWatching": "Turpināt skatīšanos",
+    "HeaderAlbumArtists": "Albumu izpildītāji",
     "Genres": "Žanri",
     "Folders": "Mapes",
-    "Favorites": "Favorīti",
-    "FailedLoginAttemptWithUserName": "Neizdevies pieslēgšanās mēģinājums no {0}",
-    "DeviceOnlineWithName": "{0} ir pievienojies",
-    "DeviceOfflineWithName": "{0} ir atvienojies",
+    "Favorites": "Izlase",
+    "FailedLoginAttemptWithUserName": "Neizdevies ieiešanas mēģinājums no {0}",
+    "DeviceOnlineWithName": "Savienojums ar {0} ir izveidots",
+    "DeviceOfflineWithName": "Savienojums ar {0} ir pārtraukts",
     "Collections": "Kolekcijas",
     "Channels": "Kanāli",
-    "CameraImageUploadedFrom": "Jauns kameras attēls ir ticis augšupielādēts no {0}",
+    "CameraImageUploadedFrom": "Jauns kameras attēls tika augšupielādēts no {0}",
     "Books": "Grāmatas",
     "Artists": "Izpildītāji",
     "Albums": "Albumi",
     "ProviderValue": "Provider: {0}",
-    "HeaderFavoriteSongs": "Dziesmu Favorīti",
-    "HeaderFavoriteShows": "Raidījumu Favorīti",
-    "HeaderFavoriteEpisodes": "Episožu Favorīti",
-    "HeaderFavoriteArtists": "Izpildītāju Favorīti",
-    "HeaderFavoriteAlbums": "Albumu Favorīti",
-    "TaskCleanCacheDescription": "Nodzēš keša datnes, kas vairs nav sistēmai vajadzīgas.",
-    "TaskRefreshChapterImages": "Izvilkt Nodaļu Attēlus",
+    "HeaderFavoriteSongs": "Dziesmu izlase",
+    "HeaderFavoriteShows": "Raidījumu izlase",
+    "HeaderFavoriteEpisodes": "Sēriju izlase",
+    "HeaderFavoriteArtists": "Izpildītāju izlase",
+    "HeaderFavoriteAlbums": "Albumu izlase",
+    "TaskCleanCacheDescription": "Nodzēš kešatmiņas datnes, kas vairs nav sistēmai vajadzīgas.",
+    "TaskRefreshChapterImages": "Izvilkt nodaļu attēlus",
     "TasksApplicationCategory": "Lietotne",
     "TasksLibraryCategory": "Bibliotēka",
     "TaskDownloadMissingSubtitlesDescription": "Internetā meklē trūkstošus subtitrus balstoties uz metadatu uzstādījumiem.",
-    "TaskDownloadMissingSubtitles": "Lejupielādēt trūkstošus subtitrus",
+    "TaskDownloadMissingSubtitles": "Lejupielādēt trūkstošos subtitrus",
     "TaskRefreshChannelsDescription": "Atjauno interneta kanālu informāciju.",
-    "TaskRefreshChannels": "Atjaunot Kanālus",
-    "TaskCleanTranscodeDescription": "Izdzēš trans-kodēšanas datnes, kas ir vecākas par vienu dienu.",
-    "TaskCleanTranscode": "Iztīrīt Trans-kodēšanas Mapi",
+    "TaskRefreshChannels": "Atjaunot kanālus",
+    "TaskCleanTranscodeDescription": "Izdzēš transkodēšanas datnes, kas ir senākas par vienu dienu.",
+    "TaskCleanTranscode": "Iztīrīt transkodēšanas mapi",
     "TaskUpdatePluginsDescription": "Lejupielādē un uzstāda atjauninājumus paplašinājumiem, kam ir uzstādīta automātiskā atjaunināšana.",
-    "TaskUpdatePlugins": "Atjaunot Paplašinājumus",
+    "TaskUpdatePlugins": "Atjaunot paplašinājumus",
     "TaskRefreshPeopleDescription": "Atjauno metadatus aktieriem un direktoriem jūsu multivides bibliotēkā.",
-    "TaskRefreshPeople": "Atjaunot Cilvēkus",
-    "TaskCleanLogsDescription": "Nodzēš log datnes, kas ir vairāk par {0} dienām vecas.",
-    "TaskCleanLogs": "Iztīrīt Logdatņu Mapi",
+    "TaskRefreshPeople": "Atjaunot cilvēkus",
+    "TaskCleanLogsDescription": "Nodzēš logdatnes, kas ir senākas par {0} dienām.",
+    "TaskCleanLogs": "Iztīrīt logdatņu mapi",
     "TaskRefreshLibraryDescription": "Skenē jūsu multivides bibliotēku, lai atrastu jaunas datnes, un atsvaidzina metadatus.",
-    "TaskRefreshLibrary": "Skenēt Multivides Bibliotēku",
+    "TaskRefreshLibrary": "Skenēt multivides bibliotēku",
     "TaskRefreshChapterImagesDescription": "Izveido sīktēlus priekš video ar sadaļām.",
-    "TaskCleanCache": "Iztīrīt Kešošanas Mapi",
-    "TasksChannelsCategory": "Interneta Kanāli",
+    "TaskCleanCache": "Iztīrīt kešatmiņas mapi",
+    "TasksChannelsCategory": "Interneta kanāli",
     "TasksMaintenanceCategory": "Apkope",
-    "Forced": "Piespiests",
+    "Forced": "Piespiedu",
     "TaskCleanActivityLogDescription": "Nodzēš darbību žurnāla ierakstus, kuri ir vecāki par doto vecumu.",
-    "TaskCleanActivityLog": "Notīrīt Darbību Žurnālu",
+    "TaskCleanActivityLog": "Notīrīt darbību žurnālu",
     "Undefined": "Nenoteikts",
     "Default": "Noklusējuma",
-    "TaskOptimizeDatabaseDescription": "Saspiež datubāzi un atbrīvo atmiņu. Uzdevum palaišana pēc bibliotēku skenēšanas vai citām, ar datubāzi saistītām, izmaiņām iespējams uzlabos ātrdarbību.",
+    "TaskOptimizeDatabaseDescription": "Saspiež datubāzi un atbrīvo atmiņu. Šī uzdevuma palaišana pēc bibliotēku skenēšanas vai citām, ar datubāzi saistītām, izmaiņām iespējams uzlabos ātrdarbību.",
     "TaskOptimizeDatabase": "Optimizēt datubāzi",
     "External": "Ārējais",
     "HearingImpaired": "Ar dzirdes traucējumiem",
-    "TaskKeyframeExtractor": "Atslēgkadru Ekstraktors",
+    "TaskKeyframeExtractor": "Atslēgkadru ekstraktors",
     "TaskKeyframeExtractorDescription": "Ekstraktē atslēgkadrus no video failiem lai izveidotu precīzākus HLS atskaņošanas sarakstus. Šis process var būt ilgs."
 }

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

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

+ 1 - 0
Emby.Server.Implementations/Localization/Core/si.json

@@ -0,0 +1 @@
+{}

+ 1 - 1
Emby.Server.Implementations/Localization/Core/sk.json

@@ -124,5 +124,5 @@
     "TaskKeyframeExtractorDescription": "Extrahuje kľúčové snímky z video súborov na vytvorenie presnejších HLS playlistov. Táto úloha môže trvať dlhšiu dobu.",
     "TaskKeyframeExtractor": "Extraktor kľúčových snímkov",
     "External": "Externé",
-    "HearingImpaired": "Sluchovo Postihnutý"
+    "HearingImpaired": "Sluchovo postihnutí"
 }

+ 20 - 17
Emby.Server.Implementations/Localization/LocalizationManager.cs

@@ -71,25 +71,28 @@ namespace Emby.Server.Implementations.Localization
                 string countryCode = resource.Substring(RatingsPath.Length, 2);
                 var dict = new Dictionary<string, ParentalRating>(StringComparer.OrdinalIgnoreCase);
 
-                await using var stream = _assembly.GetManifestResourceStream(resource);
-                using var reader = new StreamReader(stream!); // shouldn't be null here, we just got the resource path from Assembly.GetManifestResourceNames()
-                await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false))
+                var stream = _assembly.GetManifestResourceStream(resource);
+                await using (stream!.ConfigureAwait(false)) // shouldn't be null here, we just got the resource path from Assembly.GetManifestResourceNames()
                 {
-                    if (string.IsNullOrWhiteSpace(line))
+                    using var reader = new StreamReader(stream!);
+                    await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false))
                     {
-                        continue;
-                    }
-
-                    string[] parts = line.Split(',');
-                    if (parts.Length == 2
-                        && int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
-                    {
-                        var name = parts[0];
-                        dict.Add(name, new ParentalRating(name, value));
-                    }
-                    else
-                    {
-                        _logger.LogWarning("Malformed line in ratings file for country {CountryCode}", countryCode);
+                        if (string.IsNullOrWhiteSpace(line))
+                        {
+                            continue;
+                        }
+
+                        string[] parts = line.Split(',');
+                        if (parts.Length == 2
+                            && int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
+                        {
+                            var name = parts[0];
+                            dict.Add(name, new ParentalRating(name, value));
+                        }
+                        else
+                        {
+                            _logger.LogWarning("Malformed line in ratings file for country {CountryCode}", countryCode);
+                        }
                     }
                 }
 

+ 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)

+ 26 - 13
Emby.Server.Implementations/Net/SocketFactory.cs

@@ -1,12 +1,15 @@
-#pragma warning disable CS1591
-
 using System;
+using System.Linq;
 using System.Net;
+using System.Net.NetworkInformation;
 using System.Net.Sockets;
 using MediaBrowser.Model.Net;
 
 namespace Emby.Server.Implementations.Net
 {
+    /// <summary>
+    /// Factory class to create different kinds of sockets.
+    /// </summary>
     public class SocketFactory : ISocketFactory
     {
         /// <inheritdoc />
@@ -29,7 +32,7 @@ namespace Emby.Server.Implementations.Net
             }
             catch
             {
-                socket?.Dispose();
+                socket.Dispose();
 
                 throw;
             }
@@ -38,7 +41,8 @@ namespace Emby.Server.Implementations.Net
         /// <inheritdoc />
         public Socket CreateSsdpUdpSocket(IPData bindInterface, int localPort)
         {
-            ArgumentNullException.ThrowIfNull(bindInterface.Address);
+            var interfaceAddress = bindInterface.Address;
+            ArgumentNullException.ThrowIfNull(interfaceAddress);
 
             if (localPort < 0)
             {
@@ -49,13 +53,13 @@ namespace Emby.Server.Implementations.Net
             try
             {
                 socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
-                socket.Bind(new IPEndPoint(bindInterface.Address, localPort));
+                socket.Bind(new IPEndPoint(interfaceAddress, localPort));
 
                 return socket;
             }
             catch
             {
-                socket?.Dispose();
+                socket.Dispose();
 
                 throw;
             }
@@ -82,22 +86,31 @@ namespace Emby.Server.Implementations.Net
 
             try
             {
-                var interfaceIndex = bindInterface.Index;
-                var interfaceIndexSwapped = (int)IPAddress.HostToNetworkOrder(interfaceIndex);
-
                 socket.MulticastLoopback = false;
                 socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
                 socket.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.PacketInformation, true);
                 socket.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, multicastTimeToLive);
-                socket.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastInterface, interfaceIndexSwapped);
-                socket.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.AddMembership, new MulticastOption(multicastAddress, interfaceIndex));
-                socket.Bind(new IPEndPoint(multicastAddress, localPort));
+
+                if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
+                {
+                    socket.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.AddMembership, new MulticastOption(multicastAddress));
+                    socket.Bind(new IPEndPoint(multicastAddress, localPort));
+                }
+                else
+                {
+                    // Only create socket if interface supports multicast
+                    var interfaceIndex = bindInterface.Index;
+                    var interfaceIndexSwapped = IPAddress.HostToNetworkOrder(interfaceIndex);
+
+                    socket.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.AddMembership, new MulticastOption(multicastAddress, interfaceIndex));
+                    socket.Bind(new IPEndPoint(bindIPAddress, localPort));
+                }
 
                 return socket;
             }
             catch
             {
-                socket?.Dispose();
+                socket.Dispose();
 
                 throw;
             }

+ 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())

+ 13 - 5
Emby.Server.Implementations/Plugins/PluginManager.cs

@@ -386,11 +386,11 @@ namespace Emby.Server.Implementations.Plugins
                 var url = new Uri(packageInfo.ImageUrl);
                 imagePath = Path.Join(path, url.Segments[^1]);
 
-                await using var fileStream = AsyncFile.OpenWrite(imagePath);
-
+                var fileStream = AsyncFile.OpenWrite(imagePath);
+                Stream? downloadStream = null;
                 try
                 {
-                    await using var downloadStream = await HttpClientFactory
+                    downloadStream = await HttpClientFactory
                         .CreateClient(NamedClient.Default)
                         .GetStreamAsync(url)
                         .ConfigureAwait(false);
@@ -402,6 +402,14 @@ namespace Emby.Server.Implementations.Plugins
                     _logger.LogError(ex, "Failed to download image to path {Path} on disk.", imagePath);
                     imagePath = string.Empty;
                 }
+                finally
+                {
+                    await fileStream.DisposeAsync().ConfigureAwait(false);
+                    if (downloadStream is not null)
+                    {
+                        await downloadStream.DisposeAsync().ConfigureAwait(false);
+                    }
+                }
             }
 
             var manifest = new PluginManifest
@@ -421,7 +429,7 @@ namespace Emby.Server.Implementations.Plugins
                 ImagePath = imagePath
             };
 
-            if (!await ReconcileManifest(manifest, path))
+            if (!await ReconcileManifest(manifest, path).ConfigureAwait(false))
             {
                 // An error occurred during reconciliation and saving could be undesirable.
                 return false;
@@ -458,7 +466,7 @@ namespace Emby.Server.Implementations.Plugins
                 }
 
                 using var metaStream = File.OpenRead(metafile);
-                var localManifest = await JsonSerializer.DeserializeAsync<PluginManifest>(metaStream, _jsonOptions);
+                var localManifest = await JsonSerializer.DeserializeAsync<PluginManifest>(metaStream, _jsonOptions).ConfigureAwait(false);
                 localManifest ??= new PluginManifest();
 
                 if (!Equals(localManifest.Id, manifest.Id))

+ 2 - 2
Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs

@@ -115,7 +115,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks
             {
                 try
                 {
-                    previouslyFailedImages = File.ReadAllText(failHistoryPath)
+                    previouslyFailedImages = (await File.ReadAllTextAsync(failHistoryPath, cancellationToken).ConfigureAwait(false))
                         .Split('|', StringSplitOptions.RemoveEmptyEntries)
                         .ToList();
                 }
@@ -156,7 +156,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks
                         }
 
                         string text = string.Join('|', previouslyFailedImages);
-                        File.WriteAllText(failHistoryPath, text);
+                        await File.WriteAllTextAsync(failHistoryPath, text, cancellationToken).ConfigureAwait(false);
                     }
 
                     numComplete++;

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

@@ -0,0 +1,104 @@
+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;
+    }
+
+    /// <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,
+            TranscodingTempPath = _configurationManager.GetTranscodePath(),
+            ServerName = _applicationHost.FriendlyName,
+            LocalAddress = _applicationHost.GetSmartApiUrl(request),
+            SupportsLibraryMonitor = true,
+            PackageName = _startupOptions.PackageName,
+            CastReceiverApplications = _configurationManager.Configuration.CastReceiverApplications
+        };
+    }
+
+    /// <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();
+        });
+    }
+}

+ 13 - 9
Emby.Server.Implementations/Udp/UdpServer.cs

@@ -27,9 +27,9 @@ namespace Emby.Server.Implementations.Udp
 
         private readonly byte[] _receiveBuffer = new byte[8192];
 
-        private Socket _udpSocket;
-        private IPEndPoint _endpoint;
-        private bool _disposed = false;
+        private readonly Socket _udpSocket;
+        private readonly IPEndPoint _endpoint;
+        private bool _disposed;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="UdpServer" /> class.
@@ -52,7 +52,10 @@ namespace Emby.Server.Implementations.Udp
 
             _endpoint = new IPEndPoint(bindAddress, port);
 
-            _udpSocket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
+            _udpSocket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp)
+            {
+                MulticastLoopback = false,
+            };
             _udpSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
         }
 
@@ -74,6 +77,7 @@ namespace Emby.Server.Implementations.Udp
 
             try
             {
+                _logger.LogDebug("Sending AutoDiscovery response");
                 await _udpSocket.SendToAsync(JsonSerializer.SerializeToUtf8Bytes(response), SocketFlags.None, endpoint, cancellationToken).ConfigureAwait(false);
             }
             catch (SocketException ex)
@@ -99,7 +103,8 @@ namespace Emby.Server.Implementations.Udp
             {
                 try
                 {
-                    var result = await _udpSocket.ReceiveFromAsync(_receiveBuffer, SocketFlags.None, _endpoint, cancellationToken).ConfigureAwait(false);
+                    var endpoint = (EndPoint)new IPEndPoint(IPAddress.Any, 0);
+                    var result = await _udpSocket.ReceiveFromAsync(_receiveBuffer, endpoint, cancellationToken).ConfigureAwait(false);
                     var text = Encoding.UTF8.GetString(_receiveBuffer, 0, result.ReceivedBytes);
                     if (text.Contains("who is JellyfinServer?", StringComparison.OrdinalIgnoreCase))
                     {
@@ -112,7 +117,7 @@ namespace Emby.Server.Implementations.Udp
                 }
                 catch (OperationCanceledException)
                 {
-                    // Don't throw
+                    _logger.LogDebug("Broadcast socket operation cancelled");
                 }
             }
         }
@@ -125,9 +130,8 @@ namespace Emby.Server.Implementations.Udp
                 return;
             }
 
-            _udpSocket?.Dispose();
-
-            GC.SuppressFinalize(this);
+            _udpSocket.Dispose();
+            _disposed = true;
         }
     }
 }

+ 3 - 5
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;
@@ -521,10 +520,9 @@ namespace Emby.Server.Implementations.Updates
 
             // CA5351: Do Not Use Broken Cryptographic Algorithms
 #pragma warning disable CA5351
-            using var md5 = MD5.Create();
             cancellationToken.ThrowIfCancellationRequested();
 
-            var hash = Convert.ToHexString(md5.ComputeHash(stream));
+            var hash = Convert.ToHexString(await MD5.HashDataAsync(stream, cancellationToken).ConfigureAwait(false));
             if (!string.Equals(package.Checksum, hash, StringComparison.OrdinalIgnoreCase))
             {
                 _logger.LogError(
@@ -557,7 +555,7 @@ namespace Emby.Server.Implementations.Updates
             reader.ExtractToDirectory(targetDir, true);
 
             // Ensure we create one or populate existing ones with missing data.
-            await _pluginManager.PopulateManifest(package.PackageInfo, package.Version, targetDir, status);
+            await _pluginManager.PopulateManifest(package.PackageInfo, package.Version, targetDir, status).ConfigureAwait(false);
 
             _pluginManager.ImportPluginFrom(targetDir);
         }

+ 11 - 5
Jellyfin.Api/Controllers/DlnaServerController.cs

@@ -5,7 +5,6 @@ using System.IO;
 using System.Net.Mime;
 using System.Threading.Tasks;
 using Emby.Dlna;
-using Emby.Dlna.Main;
 using Jellyfin.Api.Attributes;
 using Jellyfin.Api.Constants;
 using MediaBrowser.Controller.Dlna;
@@ -33,12 +32,19 @@ public class DlnaServerController : BaseJellyfinApiController
     /// Initializes a new instance of the <see cref="DlnaServerController"/> class.
     /// </summary>
     /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
-    public DlnaServerController(IDlnaManager dlnaManager)
+    /// <param name="contentDirectory">Instance of the <see cref="IContentDirectory"/> interface.</param>
+    /// <param name="connectionManager">Instance of the <see cref="IConnectionManager"/> interface.</param>
+    /// <param name="mediaReceiverRegistrar">Instance of the <see cref="IMediaReceiverRegistrar"/> interface.</param>
+    public DlnaServerController(
+        IDlnaManager dlnaManager,
+        IContentDirectory contentDirectory,
+        IConnectionManager connectionManager,
+        IMediaReceiverRegistrar mediaReceiverRegistrar)
     {
         _dlnaManager = dlnaManager;
-        _contentDirectory = DlnaEntryPoint.Current.ContentDirectory;
-        _connectionManager = DlnaEntryPoint.Current.ConnectionManager;
-        _mediaReceiverRegistrar = DlnaEntryPoint.Current.MediaReceiverRegistrar;
+        _contentDirectory = contentDirectory;
+        _connectionManager = connectionManager;
+        _mediaReceiverRegistrar = mediaReceiverRegistrar;
     }
 
     /// <summary>

+ 28 - 20
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;
@@ -408,6 +410,7 @@ public class DynamicHlsController : BaseJellyfinApiController
     /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
     /// <param name="streamOptions">Optional. The streaming options.</param>
     /// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param>
+    /// <param name="enableTrickplay">Enable trickplay image playlists being added to master playlist.</param>
     /// <response code="200">Video stream returned.</response>
     /// <returns>A <see cref="FileResult"/> containing the playlist file.</returns>
     [HttpGet("Videos/{itemId}/master.m3u8")]
@@ -465,7 +468,8 @@ public class DynamicHlsController : BaseJellyfinApiController
         [FromQuery] int? videoStreamIndex,
         [FromQuery] EncodingContext? context,
         [FromQuery] Dictionary<string, string> streamOptions,
-        [FromQuery] bool enableAdaptiveBitrateStreaming = true)
+        [FromQuery] bool enableAdaptiveBitrateStreaming = true,
+        [FromQuery] bool enableTrickplay = true)
     {
         var streamingRequest = new HlsVideoRequestDto
         {
@@ -519,7 +523,8 @@ public class DynamicHlsController : BaseJellyfinApiController
             VideoStreamIndex = videoStreamIndex,
             Context = context ?? EncodingContext.Streaming,
             StreamOptions = streamOptions,
-            EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming
+            EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming,
+            EnableTrickplay = enableTrickplay
         };
 
         return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false);
@@ -1705,16 +1710,31 @@ 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)
         {
+            var audioTranscodeParams = string.Empty;
+
+            // -vn to drop any video streams
+            audioTranscodeParams += "-vn";
+
             if (EncodingHelper.IsCopyCodec(audioCodec))
             {
-                return "-acodec copy -strict -2" + bitStreamArgs;
+                return audioTranscodeParams + " -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;
@@ -1742,21 +1762,9 @@ public class DynamicHlsController : BaseJellyfinApiController
                 audioTranscodeParams += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture);
             }
 
-            audioTranscodeParams += " -vn";
             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 +2049,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;

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

@@ -294,8 +294,8 @@ public class LibraryController : BaseJellyfinApiController
 
         return new AllThemeMediaResult
         {
-            ThemeSongsResult = themeSongs?.Value,
-            ThemeVideosResult = themeVideos?.Value,
+            ThemeSongsResult = themeSongs.Value,
+            ThemeVideosResult = themeVideos.Value,
             SoundtrackSongsResult = new ThemeMediaResult()
         };
     }
@@ -490,7 +490,7 @@ public class LibraryController : BaseJellyfinApiController
 
             baseItemDtos.Add(_dtoService.GetBaseItemDto(parent, dtoOptions, user));
 
-            parent = parent?.GetParent();
+            parent = parent.GetParent();
         }
 
         return baseItemDtos;

+ 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);
     }

+ 101 - 0
Jellyfin.Api/Controllers/TrickplayController.cs

@@ -0,0 +1,101 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.Net.Mime;
+using System.Text;
+using System.Threading.Tasks;
+using Jellyfin.Api.Attributes;
+using Jellyfin.Api.Extensions;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Trickplay;
+using MediaBrowser.Model;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// Trickplay controller.
+/// </summary>
+[Route("")]
+[Authorize]
+public class TrickplayController : BaseJellyfinApiController
+{
+    private readonly ILibraryManager _libraryManager;
+    private readonly ITrickplayManager _trickplayManager;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="TrickplayController"/> class.
+    /// </summary>
+    /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/>.</param>
+    /// <param name="trickplayManager">Instance of <see cref="ITrickplayManager"/>.</param>
+    public TrickplayController(
+        ILibraryManager libraryManager,
+        ITrickplayManager trickplayManager)
+    {
+        _libraryManager = libraryManager;
+        _trickplayManager = trickplayManager;
+    }
+
+    /// <summary>
+    /// Gets an image tiles playlist for trickplay.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="width">The width of a single tile.</param>
+    /// <param name="mediaSourceId">The media version id, if using an alternate version.</param>
+    /// <response code="200">Tiles playlist returned.</response>
+    /// <returns>A <see cref="FileResult"/> containing the trickplay playlist file.</returns>
+    [HttpGet("Videos/{itemId}/Trickplay/{width}/tiles.m3u8")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesPlaylistFile]
+    public async Task<ActionResult> GetTrickplayHlsPlaylist(
+        [FromRoute, Required] Guid itemId,
+        [FromRoute, Required] int width,
+        [FromQuery] Guid? mediaSourceId)
+    {
+        string? playlist = await _trickplayManager.GetHlsPlaylist(mediaSourceId ?? itemId, width, User.GetToken()).ConfigureAwait(false);
+
+        if (string.IsNullOrEmpty(playlist))
+        {
+            return NotFound();
+        }
+
+        return Content(playlist, MimeTypes.GetMimeType("playlist.m3u8"), Encoding.UTF8);
+    }
+
+    /// <summary>
+    /// Gets a trickplay tile image.
+    /// </summary>
+    /// <param name="itemId">The item id.</param>
+    /// <param name="width">The width of a single tile.</param>
+    /// <param name="index">The index of the desired tile.</param>
+    /// <param name="mediaSourceId">The media version id, if using an alternate version.</param>
+    /// <response code="200">Tile image returned.</response>
+    /// <response code="200">Tile image not found at specified index.</response>
+    /// <returns>A <see cref="FileResult"/> containing the trickplay tiles image.</returns>
+    [HttpGet("Videos/{itemId}/Trickplay/{width}/{index}.jpg")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    [ProducesImageFile]
+    public ActionResult GetTrickplayTileImage(
+        [FromRoute, Required] Guid itemId,
+        [FromRoute, Required] int width,
+        [FromRoute, Required] int index,
+        [FromQuery] Guid? mediaSourceId)
+    {
+        var item = _libraryManager.GetItemById(mediaSourceId ?? itemId);
+        if (item is null)
+        {
+            return NotFound();
+        }
+
+        var path = _trickplayManager.GetTrickplayTilePath(item, width, index);
+        if (System.IO.File.Exists(path))
+        {
+            return PhysicalFile(path, MediaTypeNames.Image.Jpeg);
+        }
+
+        return NotFound();
+    }
+}

+ 50 - 35
Jellyfin.Api/Helpers/DynamicHlsHelper.cs

@@ -9,6 +9,7 @@ using System.Threading;
 using System.Threading.Tasks;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Models.StreamingDtos;
+using Jellyfin.Data.Entities;
 using Jellyfin.Data.Enums;
 using Jellyfin.Extensions;
 using MediaBrowser.Common.Configuration;
@@ -19,6 +20,7 @@ using MediaBrowser.Controller.Devices;
 using MediaBrowser.Controller.Dlna;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Trickplay;
 using MediaBrowser.Model.Dlna;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Net;
@@ -46,6 +48,7 @@ public class DynamicHlsHelper
     private readonly ILogger<DynamicHlsHelper> _logger;
     private readonly IHttpContextAccessor _httpContextAccessor;
     private readonly EncodingHelper _encodingHelper;
+    private readonly ITrickplayManager _trickplayManager;
 
     /// <summary>
     /// Initializes a new instance of the <see cref="DynamicHlsHelper"/> class.
@@ -62,6 +65,7 @@ public class DynamicHlsHelper
     /// <param name="logger">Instance of the <see cref="ILogger{DynamicHlsHelper}"/> interface.</param>
     /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
     /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param>
+    /// <param name="trickplayManager">Instance of <see cref="ITrickplayManager"/>.</param>
     public DynamicHlsHelper(
         ILibraryManager libraryManager,
         IUserManager userManager,
@@ -74,7 +78,8 @@ public class DynamicHlsHelper
         INetworkManager networkManager,
         ILogger<DynamicHlsHelper> logger,
         IHttpContextAccessor httpContextAccessor,
-        EncodingHelper encodingHelper)
+        EncodingHelper encodingHelper,
+        ITrickplayManager trickplayManager)
     {
         _libraryManager = libraryManager;
         _userManager = userManager;
@@ -88,6 +93,7 @@ public class DynamicHlsHelper
         _logger = logger;
         _httpContextAccessor = httpContextAccessor;
         _encodingHelper = encodingHelper;
+        _trickplayManager = trickplayManager;
     }
 
     /// <summary>
@@ -200,13 +206,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 +235,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 +266,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);
-                }
             }
         }
 
@@ -301,6 +286,13 @@ public class DynamicHlsHelper
             AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
         }
 
+        if (!isLiveStream && (state.VideoRequest?.EnableTrickplay ?? false))
+        {
+            var sourceId = Guid.Parse(state.Request.MediaSourceId);
+            var trickplayResolutions = await _trickplayManager.GetTrickplayResolutions(sourceId).ConfigureAwait(false);
+            AddTrickplay(state, trickplayResolutions, builder, _httpContextAccessor.HttpContext.User);
+        }
+
         return new FileContentResult(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8"));
     }
 
@@ -529,6 +521,41 @@ public class DynamicHlsHelper
         }
     }
 
+    /// <summary>
+    /// Appends EXT-X-IMAGE-STREAM-INF playlists for each available trickplay resolution.
+    /// </summary>
+    /// <param name="state">StreamState of the current stream.</param>
+    /// <param name="trickplayResolutions">Dictionary of widths to corresponding tiles info.</param>
+    /// <param name="builder">StringBuilder to append the field to.</param>
+    /// <param name="user">Http user context.</param>
+    private void AddTrickplay(StreamState state, Dictionary<int, TrickplayInfo> trickplayResolutions, StringBuilder builder, ClaimsPrincipal user)
+    {
+        const string playlistFormat = "#EXT-X-IMAGE-STREAM-INF:BANDWIDTH={0},RESOLUTION={1}x{2},CODECS=\"jpeg\",URI=\"{3}\"";
+
+        foreach (var resolution in trickplayResolutions)
+        {
+            var width = resolution.Key;
+            var trickplayInfo = resolution.Value;
+
+            var url = string.Format(
+                CultureInfo.InvariantCulture,
+                "Trickplay/{0}/tiles.m3u8?MediaSourceId={1}&api_key={2}",
+                width.ToString(CultureInfo.InvariantCulture),
+                state.Request.MediaSourceId,
+                user.GetToken());
+
+            builder.AppendFormat(
+                CultureInfo.InvariantCulture,
+                playlistFormat,
+                trickplayInfo.Bandwidth.ToString(CultureInfo.InvariantCulture),
+                trickplayInfo.Width.ToString(CultureInfo.InvariantCulture),
+                trickplayInfo.Height.ToString(CultureInfo.InvariantCulture),
+                url);
+
+            builder.AppendLine();
+        }
+    }
+
     /// <summary>
     /// Get the H.26X level of the output video stream.
     /// </summary>
@@ -767,16 +794,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.

+ 6 - 8
Jellyfin.Api/Helpers/StreamingHelpers.cs

@@ -248,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;
     }
@@ -421,10 +421,9 @@ public static class StreamingHelpers
     /// <param name="state">The state.</param>
     /// <param name="mediaSource">The mediaSource.</param>
     /// <returns>System.String.</returns>
-    private static string? GetOutputFileExtension(StreamState state, MediaSourceInfo? mediaSource)
+    private static string GetOutputFileExtension(StreamState state, MediaSourceInfo? mediaSource)
     {
         var ext = Path.GetExtension(state.RequestedUrl);
-
         if (!string.IsNullOrEmpty(ext))
         {
             return ext;
@@ -463,10 +462,9 @@ public static class StreamingHelpers
                 return ".asf";
             }
         }
-
-        // Try to infer based on the desired audio codec
-        if (!state.IsVideoRequest)
+        else
         {
+            // Try to infer based on the desired audio codec
             var audioCodec = state.Request.AudioCodec;
 
             if (string.Equals("aac", audioCodec, StringComparison.OrdinalIgnoreCase))
@@ -497,7 +495,7 @@ public static class StreamingHelpers
             return '.' + (idx == -1 ? mediaSource.Container : mediaSource.Container[..idx]).Trim();
         }
 
-        return null;
+        throw new InvalidOperationException("Failed to find an appropriate file extension");
     }
 
     /// <summary>
@@ -514,7 +512,7 @@ public static class StreamingHelpers
         var data = $"{state.MediaPath}-{state.UserAgent}-{deviceId!}-{playSessionId!}";
 
         var filename = data.GetMD5().ToString("N", CultureInfo.InvariantCulture);
-        var ext = outputFileExtension?.ToLowerInvariant();
+        var ext = outputFileExtension.ToLowerInvariant();
         var folder = serverConfigurationManager.GetTranscodePath();
 
         return Path.Combine(folder, filename + ext);

+ 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));

+ 0 - 2
Jellyfin.Api/Jellyfin.Api.csproj

@@ -8,8 +8,6 @@
   <PropertyGroup>
     <TargetFramework>net7.0</TargetFramework>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
-    <!-- https://github.com/microsoft/ApplicationInsights-dotnet/issues/2047 -->
-    <NoWarn>AD0001</NoWarn>
   </PropertyGroup>
 
   <ItemGroup>

+ 10 - 10
Jellyfin.Api/Middleware/ExceptionMiddleware.cs

@@ -122,17 +122,17 @@ public class ExceptionMiddleware
 
     private static int GetStatusCode(Exception ex)
     {
-        switch (ex)
+        return ex switch
         {
-            case ArgumentException _: return StatusCodes.Status400BadRequest;
-            case AuthenticationException _: return StatusCodes.Status401Unauthorized;
-            case SecurityException _: return StatusCodes.Status403Forbidden;
-            case DirectoryNotFoundException _:
-            case FileNotFoundException _:
-            case ResourceNotFoundException _: return StatusCodes.Status404NotFound;
-            case MethodNotAllowedException _: return StatusCodes.Status405MethodNotAllowed;
-            default: return StatusCodes.Status500InternalServerError;
-        }
+            ArgumentException => StatusCodes.Status400BadRequest,
+            AuthenticationException => StatusCodes.Status401Unauthorized,
+            SecurityException => StatusCodes.Status403Forbidden,
+            DirectoryNotFoundException => StatusCodes.Status404NotFound,
+            FileNotFoundException => StatusCodes.Status404NotFound,
+            ResourceNotFoundException => StatusCodes.Status404NotFound,
+            MethodNotAllowedException => StatusCodes.Status405MethodNotAllowed,
+            _ => StatusCodes.Status500InternalServerError
+        };
     }
 
     private string NormalizeExceptionMessage(string msg)

+ 6 - 1
Jellyfin.Api/Models/StreamingDtos/VideoRequestDto.cs

@@ -1,4 +1,4 @@
-namespace Jellyfin.Api.Models.StreamingDtos;
+namespace Jellyfin.Api.Models.StreamingDtos;
 
 /// <summary>
 /// The video request dto.
@@ -15,4 +15,9 @@ public class VideoRequestDto : StreamingRequestDto
     /// Gets or sets a value indicating whether to enable subtitles in the manifest.
     /// </summary>
     public bool EnableSubtitlesInManifest { get; set; }
+
+    /// <summary>
+    /// Gets or sets a value indicating whether to enable trickplay images.
+    /// </summary>
+    public bool EnableTrickplay { get; set; }
 }

+ 75 - 0
Jellyfin.Data/Entities/TrickplayInfo.cs

@@ -0,0 +1,75 @@
+using System;
+using System.Text.Json.Serialization;
+
+namespace Jellyfin.Data.Entities;
+
+/// <summary>
+/// An entity representing the metadata for a group of trickplay tiles.
+/// </summary>
+public class TrickplayInfo
+{
+    /// <summary>
+    /// Gets or sets the id of the associated item.
+    /// </summary>
+    /// <remarks>
+    /// Required.
+    /// </remarks>
+    [JsonIgnore]
+    public Guid ItemId { get; set; }
+
+    /// <summary>
+    /// Gets or sets width of an individual thumbnail.
+    /// </summary>
+    /// <remarks>
+    /// Required.
+    /// </remarks>
+    public int Width { get; set; }
+
+    /// <summary>
+    /// Gets or sets height of an individual thumbnail.
+    /// </summary>
+    /// <remarks>
+    /// Required.
+    /// </remarks>
+    public int Height { get; set; }
+
+    /// <summary>
+    /// Gets or sets amount of thumbnails per row.
+    /// </summary>
+    /// <remarks>
+    /// Required.
+    /// </remarks>
+    public int TileWidth { get; set; }
+
+    /// <summary>
+    /// Gets or sets amount of thumbnails per column.
+    /// </summary>
+    /// <remarks>
+    /// Required.
+    /// </remarks>
+    public int TileHeight { get; set; }
+
+    /// <summary>
+    /// Gets or sets total amount of non-black thumbnails.
+    /// </summary>
+    /// <remarks>
+    /// Required.
+    /// </remarks>
+    public int ThumbnailCount { get; set; }
+
+    /// <summary>
+    /// Gets or sets interval in milliseconds between each trickplay thumbnail.
+    /// </summary>
+    /// <remarks>
+    /// Required.
+    /// </remarks>
+    public int Interval { get; set; }
+
+    /// <summary>
+    /// Gets or sets peak bandwith usage in bits per second.
+    /// </summary>
+    /// <remarks>
+    /// Required.
+    /// </remarks>
+    public int Bandwidth { get; set; }
+}

+ 6 - 0
Jellyfin.Data/Entities/User.cs

@@ -288,6 +288,12 @@ namespace Jellyfin.Data.Entities
         /// </summary>
         public SyncPlayUserAccessType SyncPlayAccess { get; set; }
 
+        /// <summary>
+        /// Gets or sets the cast receiver id.
+        /// </summary>
+        [StringLength(32)]
+        public string? CastReceiverId { get; set; }
+
         /// <inheritdoc />
         [ConcurrencyCheck]
         public uint RowVersion { get; private set; }

+ 9 - 7
Jellyfin.Networking/Extensions/NetworkExtensions.cs

@@ -1,7 +1,6 @@
 using System;
 using System.Collections.Generic;
 using System.Diagnostics.CodeAnalysis;
-using System.Linq;
 using System.Net;
 using System.Net.Sockets;
 using System.Text.RegularExpressions;
@@ -204,7 +203,7 @@ public static partial class NetworkExtensions
         {
             var ipBlock = splitString.Current;
             var address = IPAddress.None;
-            if (negated && ipBlock.StartsWith<char>("!") && IPAddress.TryParse(ipBlock[1..], out var tmpAddress))
+            if (negated && ipBlock.StartsWith("!") && IPAddress.TryParse(ipBlock[1..], out var tmpAddress))
             {
                 address = tmpAddress;
             }
@@ -231,12 +230,12 @@ public static partial class NetworkExtensions
                 }
                 else if (address.AddressFamily == AddressFamily.InterNetwork)
                 {
-                    result = new IPNetwork(address, Network.MinimumIPv4PrefixSize);
+                    result = address.Equals(IPAddress.Any) ? Network.IPv4Any : new IPNetwork(address, Network.MinimumIPv4PrefixSize);
                     return true;
                 }
                 else if (address.AddressFamily == AddressFamily.InterNetworkV6)
                 {
-                    result = new IPNetwork(address, Network.MinimumIPv6PrefixSize);
+                    result = address.Equals(IPAddress.IPv6Any) ? Network.IPv6Any : new IPNetwork(address, Network.MinimumIPv6PrefixSize);
                     return true;
                 }
             }
@@ -284,12 +283,15 @@ public static partial class NetworkExtensions
 
         if (hosts.Count <= 2)
         {
+            var firstPart = hosts[0];
+
             // Is hostname or hostname:port
-            if (FqdnGeneratedRegex().IsMatch(hosts[0]))
+            if (FqdnGeneratedRegex().IsMatch(firstPart))
             {
                 try
                 {
-                    addresses = Dns.GetHostAddresses(hosts[0]);
+                    // .NET automatically filters only supported returned addresses based on OS support.
+                    addresses = Dns.GetHostAddresses(firstPart);
                     return true;
                 }
                 catch (SocketException)
@@ -299,7 +301,7 @@ public static partial class NetworkExtensions
             }
 
             // Is an IPv4 or IPv4:port
-            if (IPAddress.TryParse(hosts[0].AsSpan().LeftPart('/'), out var address))
+            if (IPAddress.TryParse(firstPart.AsSpan().LeftPart('/'), out var address))
             {
                 if (((address.AddressFamily == AddressFamily.InterNetwork) && (!isIPv4Enabled && isIPv6Enabled))
                     || ((address.AddressFamily == AddressFamily.InterNetworkV6) && (isIPv4Enabled && !isIPv6Enabled)))

+ 157 - 87
Jellyfin.Networking/Manager/NetworkManager.cs

@@ -15,7 +15,9 @@ using MediaBrowser.Common.Net;
 using MediaBrowser.Model.Net;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.HttpOverrides;
+using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.Logging;
+using static MediaBrowser.Controller.Extensions.ConfigurationExtensions;
 
 namespace Jellyfin.Networking.Manager
 {
@@ -33,12 +35,14 @@ namespace Jellyfin.Networking.Manager
 
         private readonly IConfigurationManager _configurationManager;
 
+        private readonly IConfiguration _startupConfig;
+
         private readonly object _networkEventLock;
 
         /// <summary>
         /// Holds the published server URLs and the IPs to use them on.
         /// </summary>
-        private IReadOnlyDictionary<IPData, string> _publishedServerUrls;
+        private IReadOnlyList<PublishedServerUriOverride> _publishedServerUrls;
 
         private IReadOnlyList<IPNetwork> _remoteAddressFilter;
 
@@ -76,20 +80,22 @@ namespace Jellyfin.Networking.Manager
         /// <summary>
         /// Initializes a new instance of the <see cref="NetworkManager"/> class.
         /// </summary>
-        /// <param name="configurationManager">IServerConfigurationManager instance.</param>
+        /// <param name="configurationManager">The <see cref="IConfigurationManager"/> instance.</param>
+        /// <param name="startupConfig">The <see cref="IConfiguration"/> instance holding startup parameters.</param>
         /// <param name="logger">Logger to use for messages.</param>
 #pragma warning disable CS8618 // Non-nullable field is uninitialized. : Values are set in UpdateSettings function. Compiler doesn't yet recognise this.
-        public NetworkManager(IConfigurationManager configurationManager, ILogger<NetworkManager> logger)
+        public NetworkManager(IConfigurationManager configurationManager, IConfiguration startupConfig, ILogger<NetworkManager> logger)
         {
             ArgumentNullException.ThrowIfNull(logger);
             ArgumentNullException.ThrowIfNull(configurationManager);
 
             _logger = logger;
             _configurationManager = configurationManager;
+            _startupConfig = startupConfig;
             _initLock = new();
             _interfaces = new List<IPData>();
             _macAddresses = new List<PhysicalAddress>();
-            _publishedServerUrls = new Dictionary<IPData, string>();
+            _publishedServerUrls = new List<PublishedServerUriOverride>();
             _networkEventLock = new object();
             _remoteAddressFilter = new List<IPNetwork>();
 
@@ -130,7 +136,7 @@ namespace Jellyfin.Networking.Manager
         /// <summary>
         /// Gets the Published server override list.
         /// </summary>
-        public IReadOnlyDictionary<IPData, string> PublishedServerUrls => _publishedServerUrls;
+        public IReadOnlyList<PublishedServerUriOverride> PublishedServerUrls => _publishedServerUrls;
 
         /// <inheritdoc/>
         public void Dispose()
@@ -170,7 +176,6 @@ namespace Jellyfin.Networking.Manager
             {
                 if (!_eventfire)
                 {
-                    _logger.LogDebug("Network Address Change Event.");
                     // As network events tend to fire one after the other only fire once every second.
                     _eventfire = true;
                     OnNetworkChange();
@@ -193,11 +198,12 @@ namespace Jellyfin.Networking.Manager
                 }
                 else
                 {
-                    InitialiseInterfaces();
-                    InitialiseLan(networkConfig);
+                    InitializeInterfaces();
+                    InitializeLan(networkConfig);
                     EnforceBindSettings(networkConfig);
                 }
 
+                PrintNetworkInformation(networkConfig);
                 NetworkChanged?.Invoke(this, EventArgs.Empty);
             }
             finally
@@ -210,7 +216,7 @@ namespace Jellyfin.Networking.Manager
         /// Generate a list of all the interface ip addresses and submasks where that are in the active/unknown state.
         /// Generate a list of all active mac addresses that aren't loopback addresses.
         /// </summary>
-        private void InitialiseInterfaces()
+        private void InitializeInterfaces()
         {
             lock (_initLock)
             {
@@ -222,7 +228,7 @@ namespace Jellyfin.Networking.Manager
                 try
                 {
                     var nics = NetworkInterface.GetAllNetworkInterfaces()
-                        .Where(i => i.SupportsMulticast && i.OperationalStatus == OperationalStatus.Up);
+                        .Where(i => i.OperationalStatus == OperationalStatus.Up);
 
                     foreach (NetworkInterface adapter in nics)
                     {
@@ -242,34 +248,36 @@ namespace Jellyfin.Networking.Manager
                             {
                                 if (IsIPv4Enabled && info.Address.AddressFamily == AddressFamily.InterNetwork)
                                 {
-                                    var interfaceObject = new IPData(info.Address, new IPNetwork(info.Address, info.PrefixLength), adapter.Name);
-                                    interfaceObject.Index = ipProperties.GetIPv4Properties().Index;
-                                    interfaceObject.Name = adapter.Name;
+                                    var interfaceObject = new IPData(info.Address, new IPNetwork(info.Address, info.PrefixLength), adapter.Name)
+                                    {
+                                        Index = ipProperties.GetIPv4Properties().Index,
+                                        Name = adapter.Name,
+                                        SupportsMulticast = adapter.SupportsMulticast
+                                    };
 
                                     interfaces.Add(interfaceObject);
                                 }
                                 else if (IsIPv6Enabled && info.Address.AddressFamily == AddressFamily.InterNetworkV6)
                                 {
-                                    var interfaceObject = new IPData(info.Address, new IPNetwork(info.Address, info.PrefixLength), adapter.Name);
-                                    interfaceObject.Index = ipProperties.GetIPv6Properties().Index;
-                                    interfaceObject.Name = adapter.Name;
+                                    var interfaceObject = new IPData(info.Address, new IPNetwork(info.Address, info.PrefixLength), adapter.Name)
+                                    {
+                                        Index = ipProperties.GetIPv6Properties().Index,
+                                        Name = adapter.Name,
+                                        SupportsMulticast = adapter.SupportsMulticast
+                                    };
 
                                     interfaces.Add(interfaceObject);
                                 }
                             }
                         }
-#pragma warning disable CA1031 // Do not catch general exception types
                         catch (Exception ex)
-#pragma warning restore CA1031 // Do not catch general exception types
                         {
                             // Ignore error, and attempt to continue.
                             _logger.LogError(ex, "Error encountered parsing interfaces.");
                         }
                     }
                 }
-#pragma warning disable CA1031 // Do not catch general exception types
                 catch (Exception ex)
-#pragma warning restore CA1031 // Do not catch general exception types
                 {
                     _logger.LogError(ex, "Error obtaining interfaces.");
                 }
@@ -279,14 +287,14 @@ namespace Jellyfin.Networking.Manager
                 {
                     _logger.LogWarning("No interface information available. Using loopback interface(s).");
 
-                    if (IsIPv4Enabled && !IsIPv6Enabled)
+                    if (IsIPv4Enabled)
                     {
-                        interfaces.Add(new IPData(IPAddress.Loopback, new IPNetwork(IPAddress.Loopback, 8), "lo"));
+                        interfaces.Add(new IPData(IPAddress.Loopback, Network.IPv4RFC5735Loopback, "lo"));
                     }
 
-                    if (!IsIPv4Enabled && IsIPv6Enabled)
+                    if (IsIPv6Enabled)
                     {
-                        interfaces.Add(new IPData(IPAddress.IPv6Loopback, new IPNetwork(IPAddress.IPv6Loopback, 128), "lo"));
+                        interfaces.Add(new IPData(IPAddress.IPv6Loopback, Network.IPv6RFC4291Loopback, "lo"));
                     }
                 }
 
@@ -299,9 +307,9 @@ namespace Jellyfin.Networking.Manager
         }
 
         /// <summary>
-        /// Initialises internal LAN cache.
+        /// Initializes internal LAN cache.
         /// </summary>
-        private void InitialiseLan(NetworkConfiguration config)
+        private void InitializeLan(NetworkConfiguration config)
         {
             lock (_initLock)
             {
@@ -341,10 +349,6 @@ namespace Jellyfin.Networking.Manager
                 _excludedSubnets = NetworkExtensions.TryParseToSubnets(subnets, out var excludedSubnets, true)
                     ? excludedSubnets
                     : new List<IPNetwork>();
-
-                _logger.LogInformation("Defined LAN addresses: {0}", _lanSubnets.Select(s => s.Prefix + "/" + s.PrefixLength));
-                _logger.LogInformation("Defined LAN exclusions: {0}", _excludedSubnets.Select(s => s.Prefix + "/" + s.PrefixLength));
-                _logger.LogInformation("Using LAN addresses: {0}", _lanSubnets.Where(s => !_excludedSubnets.Contains(s)).Select(s => s.Prefix + "/" + s.PrefixLength));
             }
         }
 
@@ -369,12 +373,12 @@ namespace Jellyfin.Networking.Manager
                         .ToHashSet();
                     interfaces = interfaces.Where(x => bindAddresses.Contains(x.Address)).ToList();
 
-                    if (bindAddresses.Contains(IPAddress.Loopback))
+                    if (bindAddresses.Contains(IPAddress.Loopback) && !interfaces.Any(i => i.Address.Equals(IPAddress.Loopback)))
                     {
                         interfaces.Add(new IPData(IPAddress.Loopback, Network.IPv4RFC5735Loopback, "lo"));
                     }
 
-                    if (bindAddresses.Contains(IPAddress.IPv6Loopback))
+                    if (bindAddresses.Contains(IPAddress.IPv6Loopback) && !interfaces.Any(i => i.Address.Equals(IPAddress.IPv6Loopback)))
                     {
                         interfaces.Add(new IPData(IPAddress.IPv6Loopback, Network.IPv6RFC4291Loopback, "lo"));
                     }
@@ -409,15 +413,14 @@ namespace Jellyfin.Networking.Manager
                     interfaces.RemoveAll(x => x.AddressFamily == AddressFamily.InterNetworkV6);
                 }
 
-                _logger.LogInformation("Using bind addresses: {0}", interfaces.OrderByDescending(x => x.AddressFamily == AddressFamily.InterNetwork).Select(x => x.Address));
                 _interfaces = interfaces;
             }
         }
 
         /// <summary>
-        /// Initialises the remote address values.
+        /// Initializes the remote address values.
         /// </summary>
-        private void InitialiseRemote(NetworkConfiguration config)
+        private void InitializeRemote(NetworkConfiguration config)
         {
             lock (_initLock)
             {
@@ -455,13 +458,33 @@ namespace Jellyfin.Networking.Manager
         /// format is subnet=ipaddress|host|uri
         /// when subnet = 0.0.0.0, any external address matches.
         /// </summary>
-        private void InitialiseOverrides(NetworkConfiguration config)
+        private void InitializeOverrides(NetworkConfiguration config)
         {
             lock (_initLock)
             {
-                var publishedServerUrls = new Dictionary<IPData, string>();
-                var overrides = config.PublishedServerUriBySubnet;
+                var publishedServerUrls = new List<PublishedServerUriOverride>();
+
+                // Prefer startup configuration.
+                var startupOverrideKey = _startupConfig[AddressOverrideKey];
+                if (!string.IsNullOrEmpty(startupOverrideKey))
+                {
+                    publishedServerUrls.Add(
+                        new PublishedServerUriOverride(
+                            new IPData(IPAddress.Any, Network.IPv4Any),
+                            startupOverrideKey,
+                            true,
+                            true));
+                    publishedServerUrls.Add(
+                        new PublishedServerUriOverride(
+                            new IPData(IPAddress.IPv6Any, Network.IPv6Any),
+                            startupOverrideKey,
+                            true,
+                            true));
+                    _publishedServerUrls = publishedServerUrls;
+                    return;
+                }
 
+                var overrides = config.PublishedServerUriBySubnet;
                 foreach (var entry in overrides)
                 {
                     var parts = entry.Split('=');
@@ -475,31 +498,70 @@ namespace Jellyfin.Networking.Manager
                     var identifier = parts[0];
                     if (string.Equals(identifier, "all", StringComparison.OrdinalIgnoreCase))
                     {
-                        publishedServerUrls[new IPData(IPAddress.Broadcast, null)] = replacement;
+                        // Drop any other overrides in case an "all" override exists
+                        publishedServerUrls.Clear();
+                        publishedServerUrls.Add(
+                            new PublishedServerUriOverride(
+                                new IPData(IPAddress.Any, Network.IPv4Any),
+                                replacement,
+                                true,
+                                true));
+                        publishedServerUrls.Add(
+                            new PublishedServerUriOverride(
+                                new IPData(IPAddress.IPv6Any, Network.IPv6Any),
+                                replacement,
+                                true,
+                                true));
+                        break;
                     }
                     else if (string.Equals(identifier, "external", StringComparison.OrdinalIgnoreCase))
                     {
-                        publishedServerUrls[new IPData(IPAddress.Any, Network.IPv4Any)] = replacement;
-                        publishedServerUrls[new IPData(IPAddress.IPv6Any, Network.IPv6Any)] = replacement;
+                        publishedServerUrls.Add(
+                            new PublishedServerUriOverride(
+                                new IPData(IPAddress.Any, Network.IPv4Any),
+                                replacement,
+                                false,
+                                true));
+                        publishedServerUrls.Add(
+                            new PublishedServerUriOverride(
+                                new IPData(IPAddress.IPv6Any, Network.IPv6Any),
+                                replacement,
+                                false,
+                                true));
                     }
                     else if (string.Equals(identifier, "internal", StringComparison.OrdinalIgnoreCase))
                     {
                         foreach (var lan in _lanSubnets)
                         {
                             var lanPrefix = lan.Prefix;
-                            publishedServerUrls[new IPData(lanPrefix, new IPNetwork(lanPrefix, lan.PrefixLength))] = replacement;
+                            publishedServerUrls.Add(
+                                new PublishedServerUriOverride(
+                                    new IPData(lanPrefix, new IPNetwork(lanPrefix, lan.PrefixLength)),
+                                    replacement,
+                                    true,
+                                    false));
                         }
                     }
                     else if (NetworkExtensions.TryParseToSubnet(identifier, out var result) && result is not null)
                     {
                         var data = new IPData(result.Prefix, result);
-                        publishedServerUrls[data] = replacement;
+                        publishedServerUrls.Add(
+                            new PublishedServerUriOverride(
+                                data,
+                                replacement,
+                                true,
+                                true));
                     }
                     else if (TryParseInterface(identifier, out var ifaces))
                     {
                         foreach (var iface in ifaces)
                         {
-                            publishedServerUrls[iface] = replacement;
+                            publishedServerUrls.Add(
+                            new PublishedServerUriOverride(
+                                iface,
+                                replacement,
+                                true,
+                                true));
                         }
                     }
                     else
@@ -521,7 +583,7 @@ namespace Jellyfin.Networking.Manager
         }
 
         /// <summary>
-        /// Reloads all settings and re-initialises the instance.
+        /// Reloads all settings and re-Initializes the instance.
         /// </summary>
         /// <param name="configuration">The <see cref="NetworkConfiguration"/> to use.</param>
         public void UpdateSettings(object configuration)
@@ -531,12 +593,12 @@ namespace Jellyfin.Networking.Manager
             var config = (NetworkConfiguration)configuration;
             HappyEyeballs.HttpClientExtension.UseIPv6 = config.EnableIPv6;
 
-            InitialiseLan(config);
-            InitialiseRemote(config);
+            InitializeLan(config);
+            InitializeRemote(config);
 
             if (string.IsNullOrEmpty(MockNetworkSettings))
             {
-                InitialiseInterfaces();
+                InitializeInterfaces();
             }
             else // Used in testing only.
             {
@@ -552,8 +614,10 @@ namespace Jellyfin.Networking.Manager
                         var index = int.Parse(parts[1], CultureInfo.InvariantCulture);
                         if (address.AddressFamily == AddressFamily.InterNetwork || address.AddressFamily == AddressFamily.InterNetworkV6)
                         {
-                            var data = new IPData(address, subnet, parts[2]);
-                            data.Index = index;
+                            var data = new IPData(address, subnet, parts[2])
+                            {
+                                Index = index
+                            };
                             interfaces.Add(data);
                         }
                     }
@@ -567,7 +631,9 @@ namespace Jellyfin.Networking.Manager
             }
 
             EnforceBindSettings(config);
-            InitialiseOverrides(config);
+            InitializeOverrides(config);
+
+            PrintNetworkInformation(config, false);
         }
 
         /// <summary>
@@ -672,20 +738,13 @@ namespace Jellyfin.Networking.Manager
         /// <inheritdoc/>
         public IReadOnlyList<IPData> GetAllBindInterfaces(bool individualInterfaces = false)
         {
-            if (_interfaces.Count != 0)
+            if (_interfaces.Count > 0 || individualInterfaces)
             {
                 return _interfaces;
             }
 
             // No bind address and no exclusions, so listen on all interfaces.
             var result = new List<IPData>();
-
-            if (individualInterfaces)
-            {
-                result.AddRange(_interfaces);
-                return result;
-            }
-
             if (IsIPv4Enabled && IsIPv6Enabled)
             {
                 // Kestrel source code shows it uses Sockets.DualMode - so this also covers IPAddress.Any by default
@@ -892,31 +951,34 @@ namespace Jellyfin.Networking.Manager
             bindPreference = string.Empty;
             int? port = null;
 
-            var validPublishedServerUrls = _publishedServerUrls.Where(x => x.Key.Address.Equals(IPAddress.Any)
-                                                || x.Key.Address.Equals(IPAddress.IPv6Any)
-                                                || x.Key.Subnet.Contains(source))
-                                            .DistinctBy(x => x.Key)
-                                            .OrderBy(x => x.Key.Address.Equals(IPAddress.Any)
-                                                || x.Key.Address.Equals(IPAddress.IPv6Any))
+            // Only consider subnets including the source IP, prefering specific overrides
+            List<PublishedServerUriOverride> validPublishedServerUrls;
+            if (!isInExternalSubnet)
+            {
+                // Only use matching internal subnets
+                // Prefer more specific (bigger subnet prefix) overrides
+                validPublishedServerUrls = _publishedServerUrls.Where(x => x.IsInternalOverride && x.Data.Subnet.Contains(source))
+                                            .OrderByDescending(x => x.Data.Subnet.PrefixLength)
+                                            .ToList();
+            }
+            else
+            {
+                // Only use matching external subnets
+                // Prefer more specific (bigger subnet prefix) overrides
+                validPublishedServerUrls = _publishedServerUrls.Where(x => x.IsExternalOverride && x.Data.Subnet.Contains(source))
+                                            .OrderByDescending(x => x.Data.Subnet.PrefixLength)
                                             .ToList();
+            }
 
-            // Check for user override.
             foreach (var data in validPublishedServerUrls)
             {
-                if (isInExternalSubnet && (data.Key.Address.Equals(IPAddress.Any) || data.Key.Address.Equals(IPAddress.IPv6Any)))
-                {
-                    // External.
-                    bindPreference = data.Value;
-                    break;
-                }
-
-                // Get address interface.
-                var intf = _interfaces.OrderBy(x => x.Index).FirstOrDefault(x => data.Key.Subnet.Contains(x.Address));
+                // Get interface matching override subnet
+                var intf = _interfaces.OrderBy(x => x.Index).FirstOrDefault(x => data.Data.Subnet.Contains(x.Address));
 
                 if (intf?.Address is not null)
                 {
-                    // Match IP address.
-                    bindPreference = data.Value;
+                    // If matching interface is found, use override
+                    bindPreference = data.OverrideUri;
                     break;
                 }
             }
@@ -927,7 +989,7 @@ namespace Jellyfin.Networking.Manager
                 return false;
             }
 
-            // Has it got a port defined?
+            // Handle override specifying port
             var parts = bindPreference.Split(':');
             if (parts.Length > 1)
             {
@@ -935,18 +997,12 @@ namespace Jellyfin.Networking.Manager
                 {
                     bindPreference = parts[0];
                     port = p;
+                    _logger.LogDebug("{Source}: Matching bind address override found: {Address}:{Port}", source, bindPreference, port);
+                    return true;
                 }
             }
 
-            if (port is not null)
-            {
-                _logger.LogDebug("{Source}: Matching bind address override found: {Address}:{Port}", source, bindPreference, port);
-            }
-            else
-            {
-                _logger.LogDebug("{Source}: Matching bind address override found: {Address}", source, bindPreference);
-            }
-
+            _logger.LogDebug("{Source}: Matching bind address override found: {Address}", source, bindPreference);
             return true;
         }
 
@@ -1053,5 +1109,19 @@ namespace Jellyfin.Networking.Manager
             _logger.LogDebug("{Source}: Using first external interface as bind address: {Result}", source, result);
             return true;
         }
+
+        private void PrintNetworkInformation(NetworkConfiguration config, bool debug = true)
+        {
+            var logLevel = debug ? LogLevel.Debug : LogLevel.Information;
+            if (_logger.IsEnabled(logLevel))
+            {
+                _logger.Log(logLevel, "Defined LAN addresses: {0}", _lanSubnets.Select(s => s.Prefix + "/" + s.PrefixLength));
+                _logger.Log(logLevel, "Defined LAN exclusions: {0}", _excludedSubnets.Select(s => s.Prefix + "/" + s.PrefixLength));
+                _logger.Log(logLevel, "Using LAN addresses: {0}", _lanSubnets.Where(s => !_excludedSubnets.Contains(s)).Select(s => s.Prefix + "/" + s.PrefixLength));
+                _logger.Log(logLevel, "Using bind addresses: {0}", _interfaces.OrderByDescending(x => x.AddressFamily == AddressFamily.InterNetwork).Select(x => x.Address));
+                _logger.Log(logLevel, "Remote IP filter is {0}", config.IsRemoteIPFilterBlacklist ? "Blocklist" : "Allowlist");
+                _logger.Log(logLevel, "Filter list: {0}", _remoteAddressFilter.Select(s => s.Prefix + "/" + s.PrefixLength));
+            }
+        }
     }
 }

+ 0 - 1
Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationSucceededLogger.cs

@@ -1,7 +1,6 @@
 using System.Globalization;
 using System.Threading.Tasks;
 using Jellyfin.Data.Entities;
-using Jellyfin.Data.Events;
 using MediaBrowser.Controller.Events;
 using MediaBrowser.Controller.Events.Authentication;
 using MediaBrowser.Model.Activity;

+ 1 - 2
Jellyfin.Server.Implementations/Events/EventingServiceCollectionExtensions.cs

@@ -1,5 +1,4 @@
-using Jellyfin.Data.Events;
-using Jellyfin.Data.Events.System;
+using Jellyfin.Data.Events.System;
 using Jellyfin.Data.Events.Users;
 using Jellyfin.Server.Implementations.Events.Consumers.Library;
 using Jellyfin.Server.Implementations.Events.Consumers.Security;

+ 5 - 0
Jellyfin.Server.Implementations/JellyfinDbContext.cs

@@ -78,6 +78,11 @@ public class JellyfinDbContext : DbContext
     /// </summary>
     public DbSet<User> Users => Set<User>();
 
+    /// <summary>
+    /// Gets the <see cref="DbSet{TEntity}"/> containing the trickplay metadata.
+    /// </summary>
+    public DbSet<TrickplayInfo> TrickplayInfos => Set<TrickplayInfo>();
+
     /*public DbSet<Artwork> Artwork => Set<Artwork>();
 
     public DbSet<Book> Books => Set<Book>();

+ 681 - 0
Jellyfin.Server.Implementations/Migrations/20230626233818_AddTrickplayInfos.Designer.cs

@@ -0,0 +1,681 @@
+// <auto-generated />
+using System;
+using Jellyfin.Server.Implementations;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+    [DbContext(typeof(JellyfinDbContext))]
+    [Migration("20230626233818_AddTrickplayInfos")]
+    partial class AddTrickplayInfos
+    {
+        /// <inheritdoc />
+        protected override void BuildTargetModel(ModelBuilder modelBuilder)
+        {
+#pragma warning disable 612, 618
+            modelBuilder.HasAnnotation("ProductVersion", "7.0.7");
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("DayOfWeek")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<double>("EndHour")
+                        .HasColumnType("REAL");
+
+                    b.Property<double>("StartHour")
+                        .HasColumnType("REAL");
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId");
+
+                    b.ToTable("AccessSchedules");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.ActivityLog", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<DateTime>("DateCreated")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("ItemId")
+                        .HasMaxLength(256)
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("LogSeverity")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Name")
+                        .IsRequired()
+                        .HasMaxLength(512)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Overview")
+                        .HasMaxLength(512)
+                        .HasColumnType("TEXT");
+
+                    b.Property<uint>("RowVersion")
+                        .IsConcurrencyToken()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("ShortOverview")
+                        .HasMaxLength(512)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Type")
+                        .IsRequired()
+                        .HasMaxLength(256)
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("DateCreated");
+
+                    b.ToTable("ActivityLogs");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.CustomItemDisplayPreferences", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Client")
+                        .IsRequired()
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Key")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Value")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId", "ItemId", "Client", "Key")
+                        .IsUnique();
+
+                    b.ToTable("CustomItemDisplayPreferences");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("ChromecastVersion")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Client")
+                        .IsRequired()
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("DashboardTheme")
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
+
+                    b.Property<bool>("EnableNextVideoInfoOverlay")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int?>("IndexBy")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("ScrollDirection")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("ShowBackdrop")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("ShowSidebar")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("SkipBackwardLength")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("SkipForwardLength")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("TvHome")
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId", "ItemId", "Client")
+                        .IsUnique();
+
+                    b.ToTable("DisplayPreferences");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("DisplayPreferencesId")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Order")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Type")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("DisplayPreferencesId");
+
+                    b.ToTable("HomeSection");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<DateTime>("LastModified")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Path")
+                        .IsRequired()
+                        .HasMaxLength(512)
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid?>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId")
+                        .IsUnique();
+
+                    b.ToTable("ImageInfos");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Client")
+                        .IsRequired()
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
+
+                    b.Property<int?>("IndexBy")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<bool>("RememberIndexing")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("RememberSorting")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("SortBy")
+                        .IsRequired()
+                        .HasMaxLength(64)
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("SortOrder")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("ViewType")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId");
+
+                    b.ToTable("ItemDisplayPreferences");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Kind")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid?>("Permission_Permissions_Guid")
+                        .HasColumnType("TEXT");
+
+                    b.Property<uint>("RowVersion")
+                        .IsConcurrencyToken()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid?>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<bool>("Value")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId", "Kind")
+                        .IsUnique()
+                        .HasFilter("[UserId] IS NOT NULL");
+
+                    b.ToTable("Permissions");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Kind")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid?>("Preference_Preferences_Guid")
+                        .HasColumnType("TEXT");
+
+                    b.Property<uint>("RowVersion")
+                        .IsConcurrencyToken()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid?>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Value")
+                        .IsRequired()
+                        .HasMaxLength(65535)
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId", "Kind")
+                        .IsUnique()
+                        .HasFilter("[UserId] IS NOT NULL");
+
+                    b.ToTable("Preferences");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Security.ApiKey", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("AccessToken")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime>("DateCreated")
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime>("DateLastActivity")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Name")
+                        .IsRequired()
+                        .HasMaxLength(64)
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("AccessToken")
+                        .IsUnique();
+
+                    b.ToTable("ApiKeys");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("AccessToken")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("AppName")
+                        .IsRequired()
+                        .HasMaxLength(64)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("AppVersion")
+                        .IsRequired()
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime>("DateCreated")
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime>("DateLastActivity")
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime>("DateModified")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("DeviceId")
+                        .IsRequired()
+                        .HasMaxLength(256)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("DeviceName")
+                        .IsRequired()
+                        .HasMaxLength(64)
+                        .HasColumnType("TEXT");
+
+                    b.Property<bool>("IsActive")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("DeviceId");
+
+                    b.HasIndex("AccessToken", "DateLastActivity");
+
+                    b.HasIndex("DeviceId", "DateLastActivity");
+
+                    b.HasIndex("UserId", "DeviceId");
+
+                    b.ToTable("Devices");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Security.DeviceOptions", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("CustomName")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("DeviceId")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("DeviceId")
+                        .IsUnique();
+
+                    b.ToTable("DeviceOptions");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.TrickplayInfo", b =>
+                {
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("Width")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Bandwidth")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Height")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Interval")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("ThumbnailCount")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("TileHeight")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("TileWidth")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("ItemId", "Width");
+
+                    b.ToTable("TrickplayInfos");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.User", b =>
+                {
+                    b.Property<Guid>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("AudioLanguagePreference")
+                        .HasMaxLength(255)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("AuthenticationProviderId")
+                        .IsRequired()
+                        .HasMaxLength(255)
+                        .HasColumnType("TEXT");
+
+                    b.Property<bool>("DisplayCollectionsView")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("DisplayMissingEpisodes")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("EnableAutoLogin")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("EnableLocalPassword")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("EnableNextEpisodeAutoPlay")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("EnableUserPreferenceAccess")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("HidePlayedInLatest")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<long>("InternalId")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("InvalidLoginAttemptCount")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<DateTime?>("LastActivityDate")
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime?>("LastLoginDate")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int?>("LoginAttemptsBeforeLockout")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("MaxActiveSessions")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int?>("MaxParentalAgeRating")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("MustUpdatePassword")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Password")
+                        .HasMaxLength(65535)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("PasswordResetProviderId")
+                        .IsRequired()
+                        .HasMaxLength(255)
+                        .HasColumnType("TEXT");
+
+                    b.Property<bool>("PlayDefaultAudioTrack")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("RememberAudioSelections")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("RememberSubtitleSelections")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int?>("RemoteClientBitrateLimit")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<uint>("RowVersion")
+                        .IsConcurrencyToken()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("SubtitleLanguagePreference")
+                        .HasMaxLength(255)
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("SubtitleMode")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("SyncPlayAccess")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Username")
+                        .IsRequired()
+                        .HasMaxLength(255)
+                        .HasColumnType("TEXT")
+                        .UseCollation("NOCASE");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("Username")
+                        .IsUnique();
+
+                    b.ToTable("Users");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", null)
+                        .WithMany("AccessSchedules")
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", null)
+                        .WithMany("DisplayPreferences")
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.DisplayPreferences", null)
+                        .WithMany("HomeSections")
+                        .HasForeignKey("DisplayPreferencesId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", null)
+                        .WithOne("ProfileImage")
+                        .HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId")
+                        .OnDelete(DeleteBehavior.Cascade);
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", null)
+                        .WithMany("ItemDisplayPreferences")
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", null)
+                        .WithMany("Permissions")
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade);
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", null)
+                        .WithMany("Preferences")
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade);
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", "User")
+                        .WithMany()
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("User");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b =>
+                {
+                    b.Navigation("HomeSections");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.User", b =>
+                {
+                    b.Navigation("AccessSchedules");
+
+                    b.Navigation("DisplayPreferences");
+
+                    b.Navigation("ItemDisplayPreferences");
+
+                    b.Navigation("Permissions");
+
+                    b.Navigation("Preferences");
+
+                    b.Navigation("ProfileImage");
+                });
+#pragma warning restore 612, 618
+        }
+    }
+}

+ 40 - 0
Jellyfin.Server.Implementations/Migrations/20230626233818_AddTrickplayInfos.cs

@@ -0,0 +1,40 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+    /// <inheritdoc />
+    public partial class AddTrickplayInfos : Migration
+    {
+        /// <inheritdoc />
+        protected override void Up(MigrationBuilder migrationBuilder)
+        {
+            migrationBuilder.CreateTable(
+                name: "TrickplayInfos",
+                columns: table => new
+                {
+                    ItemId = table.Column<Guid>(type: "TEXT", nullable: false),
+                    Width = table.Column<int>(type: "INTEGER", nullable: false),
+                    Height = table.Column<int>(type: "INTEGER", nullable: false),
+                    TileWidth = table.Column<int>(type: "INTEGER", nullable: false),
+                    TileHeight = table.Column<int>(type: "INTEGER", nullable: false),
+                    ThumbnailCount = table.Column<int>(type: "INTEGER", nullable: false),
+                    Interval = table.Column<int>(type: "INTEGER", nullable: false),
+                    Bandwidth = table.Column<int>(type: "INTEGER", nullable: false)
+                },
+                constraints: table =>
+                {
+                    table.PrimaryKey("PK_TrickplayInfos", x => new { x.ItemId, x.Width });
+                });
+        }
+
+        /// <inheritdoc />
+        protected override void Down(MigrationBuilder migrationBuilder)
+        {
+            migrationBuilder.DropTable(
+                name: "TrickplayInfos");
+        }
+    }
+}

+ 654 - 0
Jellyfin.Server.Implementations/Migrations/20230923170422_UserCastReceiver.Designer.cs

@@ -0,0 +1,654 @@
+// <auto-generated />
+using System;
+using Jellyfin.Server.Implementations;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+    [DbContext(typeof(JellyfinDbContext))]
+    [Migration("20230923170422_UserCastReceiver")]
+    partial class UserCastReceiver
+    {
+        /// <inheritdoc />
+        protected override void BuildTargetModel(ModelBuilder modelBuilder)
+        {
+#pragma warning disable 612, 618
+            modelBuilder.HasAnnotation("ProductVersion", "7.0.11");
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("DayOfWeek")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<double>("EndHour")
+                        .HasColumnType("REAL");
+
+                    b.Property<double>("StartHour")
+                        .HasColumnType("REAL");
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId");
+
+                    b.ToTable("AccessSchedules");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.ActivityLog", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<DateTime>("DateCreated")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("ItemId")
+                        .HasMaxLength(256)
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("LogSeverity")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Name")
+                        .IsRequired()
+                        .HasMaxLength(512)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Overview")
+                        .HasMaxLength(512)
+                        .HasColumnType("TEXT");
+
+                    b.Property<uint>("RowVersion")
+                        .IsConcurrencyToken()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("ShortOverview")
+                        .HasMaxLength(512)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Type")
+                        .IsRequired()
+                        .HasMaxLength(256)
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("DateCreated");
+
+                    b.ToTable("ActivityLogs");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.CustomItemDisplayPreferences", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Client")
+                        .IsRequired()
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Key")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Value")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId", "ItemId", "Client", "Key")
+                        .IsUnique();
+
+                    b.ToTable("CustomItemDisplayPreferences");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("ChromecastVersion")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Client")
+                        .IsRequired()
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("DashboardTheme")
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
+
+                    b.Property<bool>("EnableNextVideoInfoOverlay")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int?>("IndexBy")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("ScrollDirection")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("ShowBackdrop")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("ShowSidebar")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("SkipBackwardLength")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("SkipForwardLength")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("TvHome")
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId", "ItemId", "Client")
+                        .IsUnique();
+
+                    b.ToTable("DisplayPreferences");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("DisplayPreferencesId")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Order")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Type")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("DisplayPreferencesId");
+
+                    b.ToTable("HomeSection");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<DateTime>("LastModified")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Path")
+                        .IsRequired()
+                        .HasMaxLength(512)
+                        .HasColumnType("TEXT");
+
+                    b.Property<Guid?>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId")
+                        .IsUnique();
+
+                    b.ToTable("ImageInfos");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Client")
+                        .IsRequired()
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
+
+                    b.Property<int?>("IndexBy")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<bool>("RememberIndexing")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("RememberSorting")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("SortBy")
+                        .IsRequired()
+                        .HasMaxLength(64)
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("SortOrder")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("ViewType")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId");
+
+                    b.ToTable("ItemDisplayPreferences");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Kind")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid?>("Permission_Permissions_Guid")
+                        .HasColumnType("TEXT");
+
+                    b.Property<uint>("RowVersion")
+                        .IsConcurrencyToken()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid?>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<bool>("Value")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId", "Kind")
+                        .IsUnique()
+                        .HasFilter("[UserId] IS NOT NULL");
+
+                    b.ToTable("Permissions");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Kind")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid?>("Preference_Preferences_Guid")
+                        .HasColumnType("TEXT");
+
+                    b.Property<uint>("RowVersion")
+                        .IsConcurrencyToken()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid?>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Value")
+                        .IsRequired()
+                        .HasMaxLength(65535)
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("UserId", "Kind")
+                        .IsUnique()
+                        .HasFilter("[UserId] IS NOT NULL");
+
+                    b.ToTable("Preferences");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Security.ApiKey", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("AccessToken")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime>("DateCreated")
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime>("DateLastActivity")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("Name")
+                        .IsRequired()
+                        .HasMaxLength(64)
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("AccessToken")
+                        .IsUnique();
+
+                    b.ToTable("ApiKeys");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("AccessToken")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("AppName")
+                        .IsRequired()
+                        .HasMaxLength(64)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("AppVersion")
+                        .IsRequired()
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime>("DateCreated")
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime>("DateLastActivity")
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime>("DateModified")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("DeviceId")
+                        .IsRequired()
+                        .HasMaxLength(256)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("DeviceName")
+                        .IsRequired()
+                        .HasMaxLength(64)
+                        .HasColumnType("TEXT");
+
+                    b.Property<bool>("IsActive")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid>("UserId")
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("DeviceId");
+
+                    b.HasIndex("AccessToken", "DateLastActivity");
+
+                    b.HasIndex("DeviceId", "DateLastActivity");
+
+                    b.HasIndex("UserId", "DeviceId");
+
+                    b.ToTable("Devices");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Security.DeviceOptions", b =>
+                {
+                    b.Property<int>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("CustomName")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("DeviceId")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("DeviceId")
+                        .IsUnique();
+
+                    b.ToTable("DeviceOptions");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.User", b =>
+                {
+                    b.Property<Guid>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("AudioLanguagePreference")
+                        .HasMaxLength(255)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("AuthenticationProviderId")
+                        .IsRequired()
+                        .HasMaxLength(255)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("CastReceiverId")
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
+
+                    b.Property<bool>("DisplayCollectionsView")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("DisplayMissingEpisodes")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("EnableAutoLogin")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("EnableLocalPassword")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("EnableNextEpisodeAutoPlay")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("EnableUserPreferenceAccess")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("HidePlayedInLatest")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<long>("InternalId")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("InvalidLoginAttemptCount")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<DateTime?>("LastActivityDate")
+                        .HasColumnType("TEXT");
+
+                    b.Property<DateTime?>("LastLoginDate")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int?>("LoginAttemptsBeforeLockout")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("MaxActiveSessions")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int?>("MaxParentalAgeRating")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("MustUpdatePassword")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Password")
+                        .HasMaxLength(65535)
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("PasswordResetProviderId")
+                        .IsRequired()
+                        .HasMaxLength(255)
+                        .HasColumnType("TEXT");
+
+                    b.Property<bool>("PlayDefaultAudioTrack")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("RememberAudioSelections")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<bool>("RememberSubtitleSelections")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int?>("RemoteClientBitrateLimit")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<uint>("RowVersion")
+                        .IsConcurrencyToken()
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("SubtitleLanguagePreference")
+                        .HasMaxLength(255)
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("SubtitleMode")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("SyncPlayAccess")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<string>("Username")
+                        .IsRequired()
+                        .HasMaxLength(255)
+                        .HasColumnType("TEXT")
+                        .UseCollation("NOCASE");
+
+                    b.HasKey("Id");
+
+                    b.HasIndex("Username")
+                        .IsUnique();
+
+                    b.ToTable("Users");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", null)
+                        .WithMany("AccessSchedules")
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", null)
+                        .WithMany("DisplayPreferences")
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.DisplayPreferences", null)
+                        .WithMany("HomeSections")
+                        .HasForeignKey("DisplayPreferencesId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", null)
+                        .WithOne("ProfileImage")
+                        .HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId")
+                        .OnDelete(DeleteBehavior.Cascade);
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", null)
+                        .WithMany("ItemDisplayPreferences")
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", null)
+                        .WithMany("Permissions")
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade);
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", null)
+                        .WithMany("Preferences")
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade);
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b =>
+                {
+                    b.HasOne("Jellyfin.Data.Entities.User", "User")
+                        .WithMany()
+                        .HasForeignKey("UserId")
+                        .OnDelete(DeleteBehavior.Cascade)
+                        .IsRequired();
+
+                    b.Navigation("User");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b =>
+                {
+                    b.Navigation("HomeSections");
+                });
+
+            modelBuilder.Entity("Jellyfin.Data.Entities.User", b =>
+                {
+                    b.Navigation("AccessSchedules");
+
+                    b.Navigation("DisplayPreferences");
+
+                    b.Navigation("ItemDisplayPreferences");
+
+                    b.Navigation("Permissions");
+
+                    b.Navigation("Preferences");
+
+                    b.Navigation("ProfileImage");
+                });
+#pragma warning restore 612, 618
+        }
+    }
+}

+ 29 - 0
Jellyfin.Server.Implementations/Migrations/20230923170422_UserCastReceiver.cs

@@ -0,0 +1,29 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+    /// <inheritdoc />
+    public partial class UserCastReceiver : Migration
+    {
+        /// <inheritdoc />
+        protected override void Up(MigrationBuilder migrationBuilder)
+        {
+            migrationBuilder.AddColumn<string>(
+                name: "CastReceiverId",
+                table: "Users",
+                type: "TEXT",
+                maxLength: 32,
+                nullable: true);
+        }
+
+        /// <inheritdoc />
+        protected override void Down(MigrationBuilder migrationBuilder)
+        {
+            migrationBuilder.DropColumn(
+                name: "CastReceiverId",
+                table: "Users");
+        }
+    }
+}

+ 37 - 2
Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs

@@ -1,4 +1,4 @@
-// <auto-generated />
+// <auto-generated />
 using System;
 using Jellyfin.Server.Implementations;
 using Microsoft.EntityFrameworkCore;
@@ -15,7 +15,7 @@ namespace Jellyfin.Server.Implementations.Migrations
         protected override void BuildModel(ModelBuilder modelBuilder)
         {
 #pragma warning disable 612, 618
-            modelBuilder.HasAnnotation("ProductVersion", "7.0.5");
+            modelBuilder.HasAnnotation("ProductVersion", "7.0.11");
 
             modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b =>
                 {
@@ -442,6 +442,37 @@ namespace Jellyfin.Server.Implementations.Migrations
                     b.ToTable("DeviceOptions");
                 });
 
+            modelBuilder.Entity("Jellyfin.Data.Entities.TrickplayInfo", b =>
+                {
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<int>("Width")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Bandwidth")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Height")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Interval")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("ThumbnailCount")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("TileHeight")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("TileWidth")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("ItemId", "Width");
+
+                    b.ToTable("TrickplayInfos");
+                });
+
             modelBuilder.Entity("Jellyfin.Data.Entities.User", b =>
                 {
                     b.Property<Guid>("Id")
@@ -457,6 +488,10 @@ namespace Jellyfin.Server.Implementations.Migrations
                         .HasMaxLength(255)
                         .HasColumnType("TEXT");
 
+                    b.Property<string>("CastReceiverId")
+                        .HasMaxLength(32)
+                        .HasColumnType("TEXT");
+
                     b.Property<bool>("DisplayCollectionsView")
                         .HasColumnType("INTEGER");
 

+ 18 - 0
Jellyfin.Server.Implementations/ModelConfiguration/TrickplayInfoConfiguration.cs

@@ -0,0 +1,18 @@
+using Jellyfin.Data.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace Jellyfin.Server.Implementations.ModelConfiguration
+{
+    /// <summary>
+    /// FluentAPI configuration for the TrickplayInfo entity.
+    /// </summary>
+    public class TrickplayInfoConfiguration : IEntityTypeConfiguration<TrickplayInfo>
+    {
+        /// <inheritdoc/>
+        public void Configure(EntityTypeBuilder<TrickplayInfo> builder)
+        {
+            builder.HasKey(info => new { info.ItemId, info.Width });
+        }
+    }
+}

+ 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)
         {

+ 474 - 0
Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs

@@ -0,0 +1,474 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Trickplay;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Implementations.Trickplay;
+
+/// <summary>
+/// ITrickplayManager implementation.
+/// </summary>
+public class TrickplayManager : ITrickplayManager
+{
+    private readonly ILogger<TrickplayManager> _logger;
+    private readonly IMediaEncoder _mediaEncoder;
+    private readonly IFileSystem _fileSystem;
+    private readonly EncodingHelper _encodingHelper;
+    private readonly ILibraryManager _libraryManager;
+    private readonly IServerConfigurationManager _config;
+    private readonly IImageEncoder _imageEncoder;
+    private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
+    private readonly IApplicationPaths _appPaths;
+
+    private static readonly SemaphoreSlim _resourcePool = new(1, 1);
+    private static readonly string[] _trickplayImgExtensions = { ".jpg" };
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="TrickplayManager"/> class.
+    /// </summary>
+    /// <param name="logger">The logger.</param>
+    /// <param name="mediaEncoder">The media encoder.</param>
+    /// <param name="fileSystem">The file systen.</param>
+    /// <param name="encodingHelper">The encoding helper.</param>
+    /// <param name="libraryManager">The library manager.</param>
+    /// <param name="config">The server configuration manager.</param>
+    /// <param name="imageEncoder">The image encoder.</param>
+    /// <param name="dbProvider">The database provider.</param>
+    /// <param name="appPaths">The application paths.</param>
+    public TrickplayManager(
+        ILogger<TrickplayManager> logger,
+        IMediaEncoder mediaEncoder,
+        IFileSystem fileSystem,
+        EncodingHelper encodingHelper,
+        ILibraryManager libraryManager,
+        IServerConfigurationManager config,
+        IImageEncoder imageEncoder,
+        IDbContextFactory<JellyfinDbContext> dbProvider,
+        IApplicationPaths appPaths)
+    {
+        _logger = logger;
+        _mediaEncoder = mediaEncoder;
+        _fileSystem = fileSystem;
+        _encodingHelper = encodingHelper;
+        _libraryManager = libraryManager;
+        _config = config;
+        _imageEncoder = imageEncoder;
+        _dbProvider = dbProvider;
+        _appPaths = appPaths;
+    }
+
+    /// <inheritdoc />
+    public async Task RefreshTrickplayDataAsync(Video video, bool replace, CancellationToken cancellationToken)
+    {
+        _logger.LogDebug("Trickplay refresh for {ItemId} (replace existing: {Replace})", video.Id, replace);
+
+        var options = _config.Configuration.TrickplayOptions;
+        foreach (var width in options.WidthResolutions)
+        {
+            cancellationToken.ThrowIfCancellationRequested();
+            await RefreshTrickplayDataInternal(
+                video,
+                replace,
+                width,
+                options,
+                cancellationToken).ConfigureAwait(false);
+        }
+    }
+
+    private async Task RefreshTrickplayDataInternal(
+        Video video,
+        bool replace,
+        int width,
+        TrickplayOptions options,
+        CancellationToken cancellationToken)
+    {
+        if (!CanGenerateTrickplay(video, options.Interval))
+        {
+            return;
+        }
+
+        var imgTempDir = string.Empty;
+        var outputDir = GetTrickplayDirectory(video, width);
+
+        await _resourcePool.WaitAsync(cancellationToken).ConfigureAwait(false);
+
+        try
+        {
+            if (!replace && Directory.Exists(outputDir) && (await GetTrickplayResolutions(video.Id).ConfigureAwait(false)).ContainsKey(width))
+            {
+                _logger.LogDebug("Found existing trickplay files for {ItemId}. Exiting.", video.Id);
+                return;
+            }
+
+            // Extract images
+            // Note: Media sources under parent items exist as their own video/item as well. Only use this video stream for trickplay.
+            var mediaSource = video.GetMediaSources(false).Find(source => Guid.Parse(source.Id).Equals(video.Id));
+
+            if (mediaSource is null)
+            {
+                _logger.LogDebug("Found no matching media source for item {ItemId}", video.Id);
+                return;
+            }
+
+            var mediaPath = mediaSource.Path;
+            var mediaStream = mediaSource.VideoStream;
+            var container = mediaSource.Container;
+
+            _logger.LogInformation("Creating trickplay files at {Width} width, for {Path} [ID: {ItemId}]", width, mediaPath, video.Id);
+            imgTempDir = await _mediaEncoder.ExtractVideoImagesOnIntervalAccelerated(
+                mediaPath,
+                container,
+                mediaSource,
+                mediaStream,
+                width,
+                TimeSpan.FromMilliseconds(options.Interval),
+                options.EnableHwAcceleration,
+                options.ProcessThreads,
+                options.Qscale,
+                options.ProcessPriority,
+                _encodingHelper,
+                cancellationToken).ConfigureAwait(false);
+
+            if (string.IsNullOrEmpty(imgTempDir) || !Directory.Exists(imgTempDir))
+            {
+                throw new InvalidOperationException("Null or invalid directory from media encoder.");
+            }
+
+            var images = _fileSystem.GetFiles(imgTempDir, _trickplayImgExtensions, false, false)
+                .Select(i => i.FullName)
+                .OrderBy(i => i)
+                .ToList();
+
+            // Create tiles
+            var trickplayInfo = CreateTiles(images, width, options, outputDir);
+
+            // Save tiles info
+            try
+            {
+                if (trickplayInfo is not null)
+                {
+                    trickplayInfo.ItemId = video.Id;
+                    await SaveTrickplayInfo(trickplayInfo).ConfigureAwait(false);
+
+                    _logger.LogInformation("Finished creation of trickplay files for {0}", mediaPath);
+                }
+                else
+                {
+                    throw new InvalidOperationException("Null trickplay tiles info from CreateTiles.");
+                }
+            }
+            catch (Exception ex)
+            {
+                _logger.LogError(ex, "Error while saving trickplay tiles info.");
+
+                // Make sure no files stay in metadata folders on failure
+                // if tiles info wasn't saved.
+                Directory.Delete(outputDir, true);
+            }
+        }
+        catch (Exception ex)
+        {
+            _logger.LogError(ex, "Error creating trickplay images.");
+        }
+        finally
+        {
+            _resourcePool.Release();
+
+            if (!string.IsNullOrEmpty(imgTempDir))
+            {
+                Directory.Delete(imgTempDir, true);
+            }
+        }
+    }
+
+    /// <inheritdoc />
+    public TrickplayInfo CreateTiles(List<string> images, int width, TrickplayOptions options, string outputDir)
+    {
+        if (images.Count == 0)
+        {
+            throw new ArgumentException("Can't create trickplay from 0 images.");
+        }
+
+        var workDir = Path.Combine(_appPaths.TempDirectory, Guid.NewGuid().ToString("N"));
+        Directory.CreateDirectory(workDir);
+
+        var trickplayInfo = new TrickplayInfo
+        {
+            Width = width,
+            Interval = options.Interval,
+            TileWidth = options.TileWidth,
+            TileHeight = options.TileHeight,
+            ThumbnailCount = images.Count,
+            // Set during image generation
+            Height = 0,
+            Bandwidth = 0
+        };
+
+        /*
+         * Generate trickplay tiles from sets of thumbnails
+         */
+        var imageOptions = new ImageCollageOptions
+        {
+            Width = trickplayInfo.TileWidth,
+            Height = trickplayInfo.TileHeight
+        };
+
+        var thumbnailsPerTile = trickplayInfo.TileWidth * trickplayInfo.TileHeight;
+        var requiredTiles = (int)Math.Ceiling((double)images.Count / thumbnailsPerTile);
+
+        for (int i = 0; i < requiredTiles; i++)
+        {
+            // Set output/input paths
+            var tilePath = Path.Combine(workDir, $"{i}.jpg");
+
+            imageOptions.OutputPath = tilePath;
+            imageOptions.InputPaths = images.GetRange(i * thumbnailsPerTile, Math.Min(thumbnailsPerTile, images.Count - (i * thumbnailsPerTile)));
+
+            // Generate image and use returned height for tiles info
+            var height = _imageEncoder.CreateTrickplayTile(imageOptions, options.JpegQuality, trickplayInfo.Width, trickplayInfo.Height != 0 ? trickplayInfo.Height : null);
+            if (trickplayInfo.Height == 0)
+            {
+                trickplayInfo.Height = height;
+            }
+
+            // Update bitrate
+            var bitrate = (int)Math.Ceiling((decimal)new FileInfo(tilePath).Length * 8 / trickplayInfo.TileWidth / trickplayInfo.TileHeight / (trickplayInfo.Interval / 1000));
+            trickplayInfo.Bandwidth = Math.Max(trickplayInfo.Bandwidth, bitrate);
+        }
+
+        /*
+         * Move trickplay tiles to output directory
+         */
+        Directory.CreateDirectory(Directory.GetParent(outputDir)!.FullName);
+
+        // Replace existing tiles if they already exist
+        if (Directory.Exists(outputDir))
+        {
+            Directory.Delete(outputDir, true);
+        }
+
+        MoveDirectory(workDir, outputDir);
+
+        return trickplayInfo;
+    }
+
+    private bool CanGenerateTrickplay(Video video, int interval)
+    {
+        var videoType = video.VideoType;
+        if (videoType == VideoType.Iso || videoType == VideoType.Dvd || videoType == VideoType.BluRay)
+        {
+            return false;
+        }
+
+        if (video.IsPlaceHolder)
+        {
+            return false;
+        }
+
+        if (video.IsShortcut)
+        {
+            return false;
+        }
+
+        if (!video.IsCompleteMedia)
+        {
+            return false;
+        }
+
+        if (!video.RunTimeTicks.HasValue || video.RunTimeTicks.Value < TimeSpan.FromMilliseconds(interval).Ticks)
+        {
+            return false;
+        }
+
+        var libraryOptions = _libraryManager.GetLibraryOptions(video);
+        if (libraryOptions is null || !libraryOptions.EnableTrickplayImageExtraction)
+        {
+            return false;
+        }
+
+        // Can't extract images if there are no video streams
+        return video.GetMediaStreams().Count > 0;
+    }
+
+    /// <inheritdoc />
+    public async Task<Dictionary<int, TrickplayInfo>> GetTrickplayResolutions(Guid itemId)
+    {
+        var trickplayResolutions = new Dictionary<int, TrickplayInfo>();
+
+        var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
+        await using (dbContext.ConfigureAwait(false))
+        {
+            var trickplayInfos = await dbContext.TrickplayInfos
+                .AsNoTracking()
+                .Where(i => i.ItemId.Equals(itemId))
+                .ToListAsync()
+                .ConfigureAwait(false);
+
+            foreach (var info in trickplayInfos)
+            {
+                trickplayResolutions[info.Width] = info;
+            }
+        }
+
+        return trickplayResolutions;
+    }
+
+    /// <inheritdoc />
+    public async Task SaveTrickplayInfo(TrickplayInfo info)
+    {
+        var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
+        await using (dbContext.ConfigureAwait(false))
+        {
+            var oldInfo = await dbContext.TrickplayInfos.FindAsync(info.ItemId, info.Width).ConfigureAwait(false);
+            if (oldInfo is not null)
+            {
+                dbContext.TrickplayInfos.Remove(oldInfo);
+            }
+
+            dbContext.Add(info);
+
+            await dbContext.SaveChangesAsync().ConfigureAwait(false);
+        }
+    }
+
+    /// <inheritdoc />
+    public async Task<Dictionary<string, Dictionary<int, TrickplayInfo>>> GetTrickplayManifest(BaseItem item)
+    {
+        var trickplayManifest = new Dictionary<string, Dictionary<int, TrickplayInfo>>();
+        foreach (var mediaSource in item.GetMediaSources(false))
+        {
+            var mediaSourceId = Guid.Parse(mediaSource.Id);
+            var trickplayResolutions = await GetTrickplayResolutions(mediaSourceId).ConfigureAwait(false);
+
+            if (trickplayResolutions.Count > 0)
+            {
+                trickplayManifest[mediaSource.Id] = trickplayResolutions;
+            }
+        }
+
+        return trickplayManifest;
+    }
+
+    /// <inheritdoc />
+    public string GetTrickplayTilePath(BaseItem item, int width, int index)
+    {
+        return Path.Combine(GetTrickplayDirectory(item, width), index + ".jpg");
+    }
+
+    /// <inheritdoc />
+    public async Task<string?> GetHlsPlaylist(Guid itemId, int width, string? apiKey)
+    {
+        var trickplayResolutions = await GetTrickplayResolutions(itemId).ConfigureAwait(false);
+        if (trickplayResolutions is not null && trickplayResolutions.TryGetValue(width, out var trickplayInfo))
+        {
+            var builder = new StringBuilder(128);
+
+            if (trickplayInfo.ThumbnailCount > 0)
+            {
+                const string urlFormat = "Trickplay/{0}/{1}.jpg?MediaSourceId={2}&api_key={3}";
+                const string decimalFormat = "{0:0.###}";
+
+                var resolution = $"{trickplayInfo.Width}x{trickplayInfo.Height}";
+                var layout = $"{trickplayInfo.TileWidth}x{trickplayInfo.TileHeight}";
+                var thumbnailsPerTile = trickplayInfo.TileWidth * trickplayInfo.TileHeight;
+                var thumbnailDuration = trickplayInfo.Interval / 1000d;
+                var infDuration = thumbnailDuration * thumbnailsPerTile;
+                var tileCount = (int)Math.Ceiling((decimal)trickplayInfo.ThumbnailCount / thumbnailsPerTile);
+
+                builder
+                    .AppendLine("#EXTM3U")
+                    .Append("#EXT-X-TARGETDURATION:")
+                    .AppendLine(tileCount.ToString(CultureInfo.InvariantCulture))
+                    .AppendLine("#EXT-X-VERSION:7")
+                    .AppendLine("#EXT-X-MEDIA-SEQUENCE:1")
+                    .AppendLine("#EXT-X-PLAYLIST-TYPE:VOD")
+                    .AppendLine("#EXT-X-IMAGES-ONLY");
+
+                for (int i = 0; i < tileCount; i++)
+                {
+                    // All tiles prior to the last must contain full amount of thumbnails (no black).
+                    if (i == tileCount - 1)
+                    {
+                        thumbnailsPerTile = trickplayInfo.ThumbnailCount - (i * thumbnailsPerTile);
+                        infDuration = thumbnailDuration * thumbnailsPerTile;
+                    }
+
+                    // EXTINF
+                    builder
+                        .Append("#EXTINF:")
+                        .AppendFormat(CultureInfo.InvariantCulture, decimalFormat, infDuration)
+                        .AppendLine(",");
+
+                    // EXT-X-TILES
+                    builder
+                        .Append("#EXT-X-TILES:RESOLUTION=")
+                        .Append(resolution)
+                        .Append(",LAYOUT=")
+                        .Append(layout)
+                        .Append(",DURATION=")
+                        .AppendFormat(CultureInfo.InvariantCulture, decimalFormat, thumbnailDuration)
+                        .AppendLine();
+
+                    // URL
+                    builder
+                        .AppendFormat(
+                            CultureInfo.InvariantCulture,
+                            urlFormat,
+                            width.ToString(CultureInfo.InvariantCulture),
+                            i.ToString(CultureInfo.InvariantCulture),
+                            itemId.ToString("N"),
+                            apiKey)
+                        .AppendLine();
+                }
+
+                builder.AppendLine("#EXT-X-ENDLIST");
+                return builder.ToString();
+            }
+        }
+
+        return null;
+    }
+
+    private string GetTrickplayDirectory(BaseItem item, int? width = null)
+    {
+        var path = Path.Combine(item.GetInternalMetadataPath(), "trickplay");
+
+        return width.HasValue ? Path.Combine(path, width.Value.ToString(CultureInfo.InvariantCulture)) : path;
+    }
+
+    private void MoveDirectory(string source, string destination)
+    {
+        try
+        {
+            Directory.Move(source, destination);
+        }
+        catch (IOException)
+        {
+            // Cross device move requires a copy
+            Directory.CreateDirectory(destination);
+            foreach (string file in Directory.GetFiles(source))
+            {
+                File.Copy(file, Path.Join(destination, Path.GetFileName(file)), true);
+            }
+
+            Directory.Delete(source, true);
+        }
+    }
+}

+ 20 - 3
Jellyfin.Server.Implementations/Users/UserManager.cs

@@ -15,6 +15,7 @@ using MediaBrowser.Common;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Net;
 using MediaBrowser.Controller.Authentication;
+using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Drawing;
 using MediaBrowser.Controller.Events;
 using MediaBrowser.Controller.Library;
@@ -43,6 +44,7 @@ namespace Jellyfin.Server.Implementations.Users
         private readonly InvalidAuthProvider _invalidAuthProvider;
         private readonly DefaultAuthenticationProvider _defaultAuthenticationProvider;
         private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider;
+        private readonly IServerConfigurationManager _serverConfigurationManager;
 
         private readonly IDictionary<Guid, User> _users;
 
@@ -55,13 +57,15 @@ namespace Jellyfin.Server.Implementations.Users
         /// <param name="appHost">The application host.</param>
         /// <param name="imageProcessor">The image processor.</param>
         /// <param name="logger">The logger.</param>
+        /// <param name="serverConfigurationManager">The system config manager.</param>
         public UserManager(
             IDbContextFactory<JellyfinDbContext> dbProvider,
             IEventManager eventManager,
             INetworkManager networkManager,
             IApplicationHost appHost,
             IImageProcessor imageProcessor,
-            ILogger<UserManager> logger)
+            ILogger<UserManager> logger,
+            IServerConfigurationManager serverConfigurationManager)
         {
             _dbProvider = dbProvider;
             _eventManager = eventManager;
@@ -69,6 +73,7 @@ namespace Jellyfin.Server.Implementations.Users
             _appHost = appHost;
             _imageProcessor = imageProcessor;
             _logger = logger;
+            _serverConfigurationManager = serverConfigurationManager;
 
             _passwordResetProviders = appHost.GetExports<IPasswordResetProvider>();
             _authenticationProviders = appHost.GetExports<IAuthenticationProvider>();
@@ -103,7 +108,7 @@ namespace Jellyfin.Server.Implementations.Users
         // This is some regex that matches only on unicode "word" characters, as well as -, _ and @
         // In theory this will cut out most if not all 'control' characters which should help minimize any weirdness
         // Usernames can contain letters (a-z + whatever else unicode is cool with), numbers (0-9), at-signs (@), dashes (-), underscores (_), apostrophes ('), periods (.) and spaces ( )
-        [GeneratedRegex("^[\\w\\ \\-'._@]+$")]
+        [GeneratedRegex(@"^[\w\ \-'._@]+$")]
         private static partial Regex ValidUsernameRegex();
 
         /// <inheritdoc/>
@@ -288,6 +293,7 @@ namespace Jellyfin.Server.Implementations.Users
         public UserDto GetUserDto(User user, string? remoteEndPoint = null)
         {
             var hasPassword = GetAuthenticationProvider(user).HasPassword(user);
+            var castReceiverApplications = _serverConfigurationManager.Configuration.CastReceiverApplications;
             return new UserDto
             {
                 Name = user.Username,
@@ -315,7 +321,11 @@ namespace Jellyfin.Server.Implementations.Users
                     OrderedViews = user.GetPreferenceValues<Guid>(PreferenceKind.OrderedViews),
                     GroupedFolders = user.GetPreferenceValues<Guid>(PreferenceKind.GroupedFolders),
                     MyMediaExcludes = user.GetPreferenceValues<Guid>(PreferenceKind.MyMediaExcludes),
-                    LatestItemsExcludes = user.GetPreferenceValues<Guid>(PreferenceKind.LatestItemExcludes)
+                    LatestItemsExcludes = user.GetPreferenceValues<Guid>(PreferenceKind.LatestItemExcludes),
+                    CastReceiverId = string.IsNullOrEmpty(user.CastReceiverId)
+                        ? castReceiverApplications.FirstOrDefault()?.Id
+                        : castReceiverApplications.FirstOrDefault(c => string.Equals(c.Id, user.CastReceiverId, StringComparison.Ordinal))?.Id
+                          ?? castReceiverApplications.FirstOrDefault()?.Id
                 },
                 Policy = new UserPolicy
                 {
@@ -604,6 +614,13 @@ namespace Jellyfin.Server.Implementations.Users
                 user.RememberSubtitleSelections = config.RememberSubtitleSelections;
                 user.SubtitleLanguagePreference = config.SubtitleLanguagePreference;
 
+                // Only set cast receiver id if it is passed in and it exists in the server config.
+                if (!string.IsNullOrEmpty(config.CastReceiverId)
+                    && _serverConfigurationManager.Configuration.CastReceiverApplications.Any(c => string.Equals(c.Id, config.CastReceiverId, StringComparison.Ordinal)))
+                {
+                    user.CastReceiverId = config.CastReceiverId;
+                }
+
                 user.SetPreference(PreferenceKind.OrderedViews, config.OrderedViews);
                 user.SetPreference(PreferenceKind.GroupedFolders, config.GroupedFolders);
                 user.SetPreference(PreferenceKind.MyMediaExcludes, config.MyMediaExcludes);

+ 3 - 0
Jellyfin.Server/CoreAppHost.cs

@@ -11,6 +11,7 @@ using Jellyfin.Server.Implementations.Activity;
 using Jellyfin.Server.Implementations.Devices;
 using Jellyfin.Server.Implementations.Events;
 using Jellyfin.Server.Implementations.Security;
+using Jellyfin.Server.Implementations.Trickplay;
 using Jellyfin.Server.Implementations.Users;
 using MediaBrowser.Controller;
 using MediaBrowser.Controller.BaseItemManager;
@@ -21,6 +22,7 @@ using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Lyrics;
 using MediaBrowser.Controller.Net;
 using MediaBrowser.Controller.Security;
+using MediaBrowser.Controller.Trickplay;
 using MediaBrowser.Model.Activity;
 using MediaBrowser.Providers.Lyric;
 using Microsoft.Extensions.Configuration;
@@ -78,6 +80,7 @@ namespace Jellyfin.Server
             serviceCollection.AddSingleton<IUserManager, UserManager>();
             serviceCollection.AddScoped<IDisplayPreferencesManager, DisplayPreferencesManager>();
             serviceCollection.AddSingleton<IDeviceManager, DeviceManager>();
+            serviceCollection.AddSingleton<ITrickplayManager, TrickplayManager>();
 
             // TODO search the assemblies instead of adding them manually?
             serviceCollection.AddSingleton<IWebSocketListener, SessionWebSocketListener>();

+ 1 - 1
Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs

@@ -282,7 +282,7 @@ namespace Jellyfin.Server.Extensions
                         AddIPAddress(config, options, subnet.Prefix, subnet.PrefixLength);
                     }
                 }
-                else if (NetworkExtensions.TryParseHost(allowedProxies[i], out var addresses))
+                else if (NetworkExtensions.TryParseHost(allowedProxies[i], out var addresses, config.EnableIPv4, config.EnableIPv6))
                 {
                     foreach (var address in addresses)
                     {

+ 1 - 2
Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs

@@ -3,7 +3,6 @@ using System.IO;
 using System.Net;
 using Jellyfin.Server.Helpers;
 using MediaBrowser.Common.Configuration;
-using MediaBrowser.Common.Net;
 using MediaBrowser.Controller.Extensions;
 using Microsoft.AspNetCore.Hosting;
 using Microsoft.Extensions.Configuration;
@@ -36,7 +35,7 @@ public static class WebHostBuilderExtensions
         return builder
             .UseKestrel((builderContext, options) =>
             {
-                var addresses = appHost.NetManager.GetAllBindInterfaces();
+                var addresses = appHost.NetManager.GetAllBindInterfaces(true);
 
                 bool flagged = false;
                 foreach (var netAdd in addresses)

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

@@ -42,7 +42,8 @@ namespace Jellyfin.Server.Migrations
             typeof(Routines.RemoveDownloadImagesInAdvance),
             typeof(Routines.MigrateAuthenticationDb),
             typeof(Routines.FixPlaylistOwner),
-            typeof(Routines.MigrateRatingLevels)
+            typeof(Routines.MigrateRatingLevels),
+            typeof(Routines.AddDefaultCastReceivers)
         };
 
         /// <summary>

+ 55 - 0
Jellyfin.Server/Migrations/Routines/AddDefaultCastReceivers.cs

@@ -0,0 +1,55 @@
+using System;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Model.System;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Migration to add the default cast receivers to the system config.
+/// </summary>
+public class AddDefaultCastReceivers : IMigrationRoutine
+{
+    private readonly IServerConfigurationManager _serverConfigurationManager;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="AddDefaultCastReceivers"/> class.
+    /// </summary>
+    /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+    public AddDefaultCastReceivers(IServerConfigurationManager serverConfigurationManager)
+    {
+        _serverConfigurationManager = serverConfigurationManager;
+    }
+
+    /// <inheritdoc />
+    public Guid Id => new("34A1A1C4-5572-418E-A2F8-32CDFE2668E8");
+
+    /// <inheritdoc />
+    public string Name => "AddDefaultCastReceivers";
+
+    /// <inheritdoc />
+    public bool PerformOnNewInstall => true;
+
+    /// <inheritdoc />
+    public void Perform()
+    {
+        // Only add if receiver list is empty.
+        if (_serverConfigurationManager.Configuration.CastReceiverApplications.Length == 0)
+        {
+            _serverConfigurationManager.Configuration.CastReceiverApplications = new CastReceiverApplication[]
+            {
+                new()
+                {
+                    Id = "F007D354",
+                    Name = "Stable"
+                },
+                new()
+                {
+                    Id = "6F511C87",
+                    Name = "Unstable"
+                }
+            };
+
+            _serverConfigurationManager.SaveConfiguration();
+        }
+    }
+}

+ 0 - 1
Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs

@@ -3,7 +3,6 @@ using System.Globalization;
 using System.IO;
 using Emby.Server.Implementations.Data;
 using MediaBrowser.Controller;
-using MediaBrowser.Controller.Persistence;
 using MediaBrowser.Model.Globalization;
 using Microsoft.Data.Sqlite;
 using Microsoft.Extensions.Logging;

+ 2 - 18
Jellyfin.Server/Startup.cs

@@ -1,10 +1,10 @@
 using System;
-using System.Globalization;
 using System.Net;
 using System.Net.Http;
 using System.Net.Http.Headers;
 using System.Net.Mime;
 using System.Text;
+using Emby.Dlna.Extensions;
 using Jellyfin.Api.Middleware;
 using Jellyfin.MediaEncoding.Hls.Extensions;
 using Jellyfin.Networking.Configuration;
@@ -27,7 +27,6 @@ using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.FileProviders;
 using Microsoft.Extensions.Hosting;
-using Microsoft.VisualBasic;
 using Prometheus;
 
 namespace Jellyfin.Server
@@ -120,26 +119,11 @@ namespace Jellyfin.Server
                 })
                 .ConfigurePrimaryHttpMessageHandler(defaultHttpClientHandlerDelegate);
 
-            services.AddHttpClient(NamedClient.Dlna, c =>
-                {
-                    c.DefaultRequestHeaders.UserAgent.ParseAdd(
-                        string.Format(
-                            CultureInfo.InvariantCulture,
-                            "{0}/{1} UPnP/1.0 {2}/{3}",
-                            Environment.OSVersion.Platform,
-                            Environment.OSVersion,
-                            _serverApplicationHost.Name,
-                            _serverApplicationHost.ApplicationVersionString));
-
-                    c.DefaultRequestHeaders.Add("CPFN.UPNP.ORG", _serverApplicationHost.FriendlyName); // Required for UPnP DeviceArchitecture v2.0
-                    c.DefaultRequestHeaders.Add("FriendlyName.DLNA.ORG", _serverApplicationHost.FriendlyName); // REVIEW: where does this come from?
-                })
-                .ConfigurePrimaryHttpMessageHandler(defaultHttpClientHandlerDelegate);
-
             services.AddHealthChecks()
                 .AddCheck<DbContextFactoryHealthCheck<JellyfinDbContext>>(nameof(JellyfinDbContext));
 
             services.AddHlsPlaylistGenerator();
+            services.AddDlnaServices(_serverApplicationHost);
         }
 
         /// <summary>

+ 4 - 56
MediaBrowser.Common/Extensions/ProcessExtensions.cs

@@ -15,65 +15,13 @@ namespace MediaBrowser.Common.Extensions
         /// </summary>
         /// <param name="process">The process to wait for.</param>
         /// <param name="timeout">The duration to wait before cancelling waiting for the task.</param>
-        /// <returns>True if the task exited normally, false if the timeout elapsed before the process exited.</returns>
-        /// <exception cref="InvalidOperationException">If <see cref="Process.EnableRaisingEvents"/> is not set to true for the process.</exception>
-        public static async Task<bool> WaitForExitAsync(this Process process, TimeSpan timeout)
+        /// <returns>A task that will complete when the process has exited, cancellation has been requested, or an error occurs.</returns>
+        /// <exception cref="OperationCanceledException">The timeout ended.</exception>
+        public static async Task WaitForExitAsync(this Process process, TimeSpan timeout)
         {
             using (var cancelTokenSource = new CancellationTokenSource(timeout))
             {
-                return await WaitForExitAsync(process, cancelTokenSource.Token).ConfigureAwait(false);
-            }
-        }
-
-        /// <summary>
-        /// Asynchronously wait for the process to exit.
-        /// </summary>
-        /// <param name="process">The process to wait for.</param>
-        /// <param name="cancelToken">A <see cref="CancellationToken"/> to observe while waiting for the process to exit.</param>
-        /// <returns>True if the task exited normally, false if cancelled before the process exited.</returns>
-        public static async Task<bool> WaitForExitAsync(this Process process, CancellationToken cancelToken)
-        {
-            if (!process.EnableRaisingEvents)
-            {
-                throw new InvalidOperationException("EnableRisingEvents must be enabled to async wait for a task to exit.");
-            }
-
-            // Add an event handler for the process exit event
-            var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
-            process.Exited += (_, _) => tcs.TrySetResult(true);
-
-            // Return immediately if the process has already exited
-            if (process.HasExitedSafe())
-            {
-                return true;
-            }
-
-            // Register with the cancellation token then await
-            using (var cancelRegistration = cancelToken.Register(() => tcs.TrySetResult(process.HasExitedSafe())))
-            {
-                return await tcs.Task.ConfigureAwait(false);
-            }
-        }
-
-        /// <summary>
-        /// Gets a value indicating whether the associated process has been terminated using
-        /// <see cref="Process.HasExited"/>. This is safe to call even if there is no operating system process
-        /// associated with the <see cref="Process"/>.
-        /// </summary>
-        /// <param name="process">The process to check the exit status for.</param>
-        /// <returns>
-        /// True if the operating system process referenced by the <see cref="Process"/> component has
-        /// terminated, or if there is no associated operating system process; otherwise, false.
-        /// </returns>
-        private static bool HasExitedSafe(this Process process)
-        {
-            try
-            {
-                return process.HasExited;
-            }
-            catch (InvalidOperationException)
-            {
-                return true;
+                await process.WaitForExitAsync(cancelTokenSource.Token).ConfigureAwait(false);
             }
         }
     }

+ 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 - 4
MediaBrowser.Common/MediaBrowser.Common.csproj

@@ -38,10 +38,6 @@
     <SymbolPackageFormat>snupkg</SymbolPackageFormat>
   </PropertyGroup>
 
-  <PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
-    <CodeAnalysisTreatWarningsAsErrors>false</CodeAnalysisTreatWarningsAsErrors>
-  </PropertyGroup>
-
   <PropertyGroup Condition=" '$(Stability)'=='Unstable'">
     <!-- Include all symbols in the main nupkg until Azure Artifact Feed starts supporting ingesting NuGet symbol packages. -->
     <AllowedOutputExtensionsInPackageBuildOutputFolder>$(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb</AllowedOutputExtensionsInPackageBuildOutputFolder>

+ 0 - 1
MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs

@@ -1,5 +1,4 @@
 using System;
-using System.Linq;
 using Jellyfin.Extensions;
 using MediaBrowser.Controller.Channels;
 using MediaBrowser.Controller.Configuration;

部分文件因为文件数量过多而无法显示