Преглед изворни кода

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

JPVenson пре 8 месеци
родитељ
комит
d3a3d9fce3
100 измењених фајлова са 4002 додато и 829 уклоњено
  1. 1 1
      .github/ISSUE_TEMPLATE/issue report.yml
  2. 4 4
      .github/workflows/ci-codeql-analysis.yml
  3. 2 2
      .github/workflows/ci-compat.yml
  4. 4 4
      .github/workflows/ci-openapi.yml
  5. 2 2
      .github/workflows/ci-tests.yml
  6. 3 3
      .github/workflows/commands.yml
  7. 1 1
      .github/workflows/issue-template-check.yml
  8. 2 2
      .github/workflows/release-bump-version.yaml
  9. 4 0
      CONTRIBUTORS.md
  10. 3 3
      Directory.Packages.props
  11. 3 1
      Emby.Naming/Video/VideoListResolver.cs
  12. 3 1
      Emby.Server.Implementations/ConfigurationOptions.cs
  13. 946 10
      Emby.Server.Implementations/Data/SqliteItemRepository.cs
  14. 6 0
      Emby.Server.Implementations/IO/LibraryMonitor.cs
  15. 23 15
      Emby.Server.Implementations/IO/ManagedFileSystem.cs
  16. 4 0
      Emby.Server.Implementations/Library/IgnorePatterns.cs
  17. 1 34
      Emby.Server.Implementations/Library/LibraryManager.cs
  18. 7 1
      Emby.Server.Implementations/Localization/Core/be.json
  19. 3 1
      Emby.Server.Implementations/Localization/Core/ca.json
  20. 5 1
      Emby.Server.Implementations/Localization/Core/cs.json
  21. 5 1
      Emby.Server.Implementations/Localization/Core/de.json
  22. 5 1
      Emby.Server.Implementations/Localization/Core/en-GB.json
  23. 5 1
      Emby.Server.Implementations/Localization/Core/en-US.json
  24. 5 1
      Emby.Server.Implementations/Localization/Core/es-AR.json
  25. 8 2
      Emby.Server.Implementations/Localization/Core/et.json
  26. 5 1
      Emby.Server.Implementations/Localization/Core/fa.json
  27. 2 1
      Emby.Server.Implementations/Localization/Core/fi.json
  28. 5 1
      Emby.Server.Implementations/Localization/Core/fr.json
  29. 2 2
      Emby.Server.Implementations/Localization/Core/gl.json
  30. 8 2
      Emby.Server.Implementations/Localization/Core/he.json
  31. 28 22
      Emby.Server.Implementations/Localization/Core/hu.json
  32. 3 1
      Emby.Server.Implementations/Localization/Core/it.json
  33. 6 1
      Emby.Server.Implementations/Localization/Core/ja.json
  34. 5 1
      Emby.Server.Implementations/Localization/Core/kw.json
  35. 10 2
      Emby.Server.Implementations/Localization/Core/mk.json
  36. 8 4
      Emby.Server.Implementations/Localization/Core/nb.json
  37. 17 13
      Emby.Server.Implementations/Localization/Core/nl.json
  38. 5 1
      Emby.Server.Implementations/Localization/Core/pl.json
  39. 8 2
      Emby.Server.Implementations/Localization/Core/pt-BR.json
  40. 7 1
      Emby.Server.Implementations/Localization/Core/pt.json
  41. 7 1
      Emby.Server.Implementations/Localization/Core/ro.json
  42. 7 1
      Emby.Server.Implementations/Localization/Core/sk.json
  43. 1 1
      Emby.Server.Implementations/Localization/Core/sl-SI.json
  44. 7 3
      Emby.Server.Implementations/Localization/Core/sv.json
  45. 7 1
      Emby.Server.Implementations/Localization/Core/tr.json
  46. 5 1
      Emby.Server.Implementations/Localization/Core/uk.json
  47. 5 1
      Emby.Server.Implementations/Localization/Core/vi.json
  48. 9 5
      Emby.Server.Implementations/Localization/Core/zh-CN.json
  49. 7 1
      Emby.Server.Implementations/Localization/Core/zh-TW.json
  50. 29 0
      Emby.Server.Implementations/MediaEncoder/EncodingManager.cs
  51. 118 0
      Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs
  52. 160 8
      Emby.Server.Implementations/Session/SessionManager.cs
  53. 4 6
      Jellyfin.Api/Controllers/DevicesController.cs
  54. 51 28
      Jellyfin.Api/Controllers/DynamicHlsController.cs
  55. 8 3
      Jellyfin.Api/Controllers/MediaInfoController.cs
  56. 1 1
      Jellyfin.Api/Controllers/MediaSegmentsController.cs
  57. 37 15
      Jellyfin.Api/Controllers/PlaylistsController.cs
  58. 11 64
      Jellyfin.Api/Controllers/SessionController.cs
  59. 4 3
      Jellyfin.Api/Controllers/TrickplayController.cs
  60. 1 0
      Jellyfin.Api/Controllers/UniversalAudioController.cs
  61. 1 1
      Jellyfin.Api/Controllers/VideosController.cs
  62. 45 1
      Jellyfin.Api/Helpers/DynamicHlsHelper.cs
  63. 14 1
      Jellyfin.Api/Helpers/MediaInfoHelper.cs
  64. 13 20
      Jellyfin.Api/Helpers/StreamingHelpers.cs
  65. 5 0
      Jellyfin.Api/Models/MediaInfoDtos/OpenLiveStreamDto.cs
  66. 5 0
      Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs
  67. 16 17
      Jellyfin.Data/Dtos/DeviceOptionsDto.cs
  68. 70 19
      Jellyfin.Server.Implementations/Devices/DeviceManager.cs
  69. 110 1
      Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs
  70. 712 0
      Jellyfin.Server.Implementations/Migrations/20240928082930_MarkSegmentProviderIdNonNullable.Designer.cs
  71. 36 0
      Jellyfin.Server.Implementations/Migrations/20240928082930_MarkSegmentProviderIdNonNullable.cs
  72. 4 3
      Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs
  73. 157 28
      Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
  74. 3 1
      Jellyfin.Server/Migrations/MigrationRunner.cs
  75. 0 1
      Jellyfin.Server/Migrations/PreStartupRoutines/CreateNetworkConfiguration.cs
  76. 245 0
      Jellyfin.Server/Migrations/PreStartupRoutines/MigrateEncodingOptions.cs
  77. 5 5
      Jellyfin.Server/Migrations/PreStartupRoutines/MigrateMusicBrainzTimeout.cs
  78. 44 41
      Jellyfin.Server/Migrations/PreStartupRoutines/MigrateNetworkConfiguration.cs
  79. 104 0
      Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs
  80. 11 0
      Jellyfin.Server/StartupOptions.cs
  81. 22 11
      MediaBrowser.Controller/Authentication/AuthenticationResult.cs
  82. 93 57
      MediaBrowser.Controller/Devices/IDeviceManager.cs
  83. 11 15
      MediaBrowser.Controller/Entities/BaseItem.cs
  84. 56 0
      MediaBrowser.Controller/Entities/MediaSourceWidthComparator.cs
  85. 1 4
      MediaBrowser.Controller/Entities/TV/Episode.cs
  86. 0 2
      MediaBrowser.Controller/Entities/UserViewBuilder.cs
  87. 1 2
      MediaBrowser.Controller/Events/Authentication/AuthenticationResultEventArgs.cs
  88. 18 0
      MediaBrowser.Controller/Extensions/ConfigurationExtensions.cs
  89. 2 0
      MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs
  90. 322 284
      MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
  91. 1 1
      MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs
  92. 9 0
      MediaBrowser.Controller/MediaEncoding/ISubtitleEncoder.cs
  93. 17 0
      MediaBrowser.Controller/MediaSegements/IMediaSegmentManager.cs
  94. 36 0
      MediaBrowser.Controller/MediaSegements/IMediaSegmentProvider.cs
  95. 3 2
      MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SessionsMessage.cs
  96. 7 0
      MediaBrowser.Controller/Providers/MetadataRefreshOptions.cs
  97. 12 0
      MediaBrowser.Controller/Session/ISessionManager.cs
  98. 103 17
      MediaBrowser.Controller/Session/SessionInfo.cs
  99. 33 3
      MediaBrowser.Controller/Trickplay/ITrickplayManager.cs
  100. 2 2
      MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs

+ 1 - 1
.github/ISSUE_TEMPLATE/issue report.yml

@@ -86,7 +86,7 @@ body:
       label: Jellyfin Server version
       description: What version of Jellyfin are you using?
       options:
-        - 10.9.10+
+        - 10.9.11+
         - Master
         - Unstable
         - Older*

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

@@ -20,18 +20,18 @@ jobs:
 
     steps:
     - name: Checkout repository
-      uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
+      uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
     - name: Setup .NET
       uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
       with:
         dotnet-version: '8.0.x'
 
     - name: Initialize CodeQL
-      uses: github/codeql-action/init@4dd16135b69a43b6c8efb853346f8437d92d3c93 # v3.26.6
+      uses: github/codeql-action/init@6db8d6351fd0be61f9ed8ebd12ccd35dcec51fea # v3.26.11
       with:
         languages: ${{ matrix.language }}
         queries: +security-extended
     - name: Autobuild
-      uses: github/codeql-action/autobuild@4dd16135b69a43b6c8efb853346f8437d92d3c93 # v3.26.6
+      uses: github/codeql-action/autobuild@6db8d6351fd0be61f9ed8ebd12ccd35dcec51fea # v3.26.11
     - name: Perform CodeQL Analysis
-      uses: github/codeql-action/analyze@4dd16135b69a43b6c8efb853346f8437d92d3c93 # v3.26.6
+      uses: github/codeql-action/analyze@6db8d6351fd0be61f9ed8ebd12ccd35dcec51fea # v3.26.11

+ 2 - 2
.github/workflows/ci-compat.yml

@@ -11,7 +11,7 @@ jobs:
     permissions: read-all
     steps:
       - name: Checkout repository
-        uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
+        uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
         with:
           ref: ${{ github.event.pull_request.head.sha }}
           repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -35,7 +35,7 @@ jobs:
     permissions: read-all
     steps:
       - name: Checkout repository
-        uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
+        uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
         with:
           ref: ${{ github.event.pull_request.head.sha }}
           repository: ${{ github.event.pull_request.head.repo.full_name }}

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

@@ -16,7 +16,7 @@ jobs:
     permissions: read-all
     steps:
       - name: Checkout repository
-        uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
+        uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
         with:
           ref: ${{ github.event.pull_request.head.sha }}
           repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -41,7 +41,7 @@ jobs:
     permissions: read-all
     steps:
       - name: Checkout repository
-        uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
+        uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
         with:
           ref: ${{ github.event.pull_request.head.sha }}
           repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -172,7 +172,7 @@ jobs:
           strip_components: 1
           target: "/srv/incoming/openapi/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
       - name: Move openapi.json (unstable) into place
-        uses: appleboy/ssh-action@029f5b4aeeeb58fdfe1410a5d17f967dacf36262 # v1.0.3
+        uses: appleboy/ssh-action@25ce8cbbcb08177468c7ff7ec5cbfa236f9341e1 # v1.1.0
         with:
           host: "${{ secrets.REPO_HOST }}"
           username: "${{ secrets.REPO_USER }}"
@@ -234,7 +234,7 @@ jobs:
           strip_components: 1
           target: "/srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
       - name: Move openapi.json (stable) into place
-        uses: appleboy/ssh-action@029f5b4aeeeb58fdfe1410a5d17f967dacf36262 # v1.0.3
+        uses: appleboy/ssh-action@25ce8cbbcb08177468c7ff7ec5cbfa236f9341e1 # v1.1.0
         with:
           host: "${{ secrets.REPO_HOST }}"
           username: "${{ secrets.REPO_USER }}"

+ 2 - 2
.github/workflows/ci-tests.yml

@@ -19,7 +19,7 @@ jobs:
 
     runs-on: "${{ matrix.os }}"
     steps:
-      - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
+      - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
 
       - uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
         with:
@@ -34,7 +34,7 @@ jobs:
           --verbosity minimal
 
       - name: Merge code coverage results
-        uses: danielpalme/ReportGenerator-GitHub-Action@e3af7259842d9c814021ea121f85526e0872b25f # v5.3.9
+        uses: danielpalme/ReportGenerator-GitHub-Action@b7115d212c0f7814a0cb17fb43ec36983c707ccb # v5.3.10
         with:
           reports: "**/coverage.cobertura.xml"
           targetdir: "merged/"

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

@@ -24,7 +24,7 @@ jobs:
           reactions: '+1'
 
       - name: Checkout the latest code
-        uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
+        uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
         with:
           token: ${{ secrets.JF_BOT_TOKEN }}
           fetch-depth: 0
@@ -51,7 +51,7 @@ jobs:
           reactions: eyes
 
       - name: Checkout the latest code
-        uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
+        uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
         with:
           token: ${{ secrets.JF_BOT_TOKEN }}
           fetch-depth: 0
@@ -128,7 +128,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: pull in script
-        uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
+        uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
         with:
           repository: jellyfin/jellyfin-triage-script
       - name: install python

+ 1 - 1
.github/workflows/issue-template-check.yml

@@ -10,7 +10,7 @@ jobs:
       issues: write
     steps:
       - name: pull in script
-        uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
+        uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
         with:
           repository: jellyfin/jellyfin-triage-script
       - name: install python

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

@@ -33,7 +33,7 @@ jobs:
           yq-version: v4.9.8
 
       - name: Checkout Repository
-        uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
+        uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
         with:
           ref: ${{ env.TAG_BRANCH }}
 
@@ -66,7 +66,7 @@ jobs:
       NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }}
     steps:
       - name: Checkout Repository
-        uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
+        uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
         with:
           ref: ${{ env.TAG_BRANCH }}
 

+ 4 - 0
CONTRIBUTORS.md

@@ -65,6 +65,7 @@
  - [joshuaboniface](https://github.com/joshuaboniface)
  - [JustAMan](https://github.com/JustAMan)
  - [justinfenn](https://github.com/justinfenn)
+ - [JPVenson](https://github.com/JPVenson)
  - [KerryRJ](https://github.com/KerryRJ)
  - [Larvitar](https://github.com/Larvitar)
  - [LeoVerto](https://github.com/LeoVerto)
@@ -188,6 +189,9 @@
  - [TheMelmacian](https://github.com/TheMelmacian)
  - [ItsAllAboutTheCode](https://github.com/ItsAllAboutTheCode)
  - [pret0rian8](https://github.com/pret0rian)
+ - [jaina heartles](https://github.com/heartles)
+ - [oxixes](https://github.com/oxixes)
+ - [elfalem](https://github.com/elfalem)
 
 # Emby Contributors
 

+ 3 - 3
Directory.Packages.props

@@ -55,7 +55,7 @@
     <PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
     <PackageVersion Include="PlaylistsNET" Version="1.4.1" />
     <PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
-    <PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.0" />
+    <PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" />
     <PackageVersion Include="prometheus-net" Version="8.2.1" />
     <PackageVersion Include="Serilog.AspNetCore" Version="8.0.2" />
     <PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
@@ -80,12 +80,12 @@
     <PackageVersion Include="System.Text.Json" Version="8.0.4" />
     <PackageVersion Include="System.Threading.Tasks.Dataflow" Version="8.0.1" />
     <PackageVersion Include="TagLibSharp" Version="2.3.0" />
-    <PackageVersion Include="z440.atl.core" Version="6.3.0" />
+    <PackageVersion Include="z440.atl.core" Version="6.5.0" />
     <PackageVersion Include="TMDbLib" Version="2.2.0" />
     <PackageVersion Include="UTF.Unknown" Version="2.5.1" />
     <PackageVersion Include="Xunit.Priority" Version="1.1.6" />
     <PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
     <PackageVersion Include="Xunit.SkippableFact" Version="1.4.13" />
-    <PackageVersion Include="xunit" Version="2.9.0" />
+    <PackageVersion Include="xunit" Version="2.9.2" />
   </ItemGroup>
 </Project>

+ 3 - 1
Emby.Naming/Video/VideoListResolver.cs

@@ -141,7 +141,9 @@ namespace Emby.Naming.Video
                 {
                     if (group.Key)
                     {
-                        videos.InsertRange(0, group.OrderByDescending(x => x.Files[0].FileNameWithoutExtension.ToString(), new AlphanumericComparator()));
+                        videos.InsertRange(0, group
+                            .OrderByDescending(x => ResolutionRegex().Match(x.Files[0].FileNameWithoutExtension.ToString()).Value, new AlphanumericComparator())
+                            .ThenBy(x => x.Files[0].FileNameWithoutExtension.ToString(), new AlphanumericComparator()));
                     }
                     else
                     {

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

@@ -20,7 +20,9 @@ namespace Emby.Server.Implementations
             { PlaylistsAllowDuplicatesKey, bool.FalseString },
             { BindToUnixSocketKey, bool.FalseString },
             { SqliteCacheSizeKey, "20000" },
-            { FfmpegSkipValidationKey, bool.FalseString }
+            { FfmpegSkipValidationKey, bool.FalseString },
+            { FfmpegImgExtractPerfTradeoffKey, bool.FalseString },
+            { DetectNetworkChangeKey, bool.TrueString }
         };
     }
 }

Разлика између датотеке није приказан због своје велике величине
+ 946 - 10
Emby.Server.Implementations/Data/SqliteItemRepository.cs


+ 6 - 0
Emby.Server.Implementations/IO/LibraryMonitor.cs

@@ -314,6 +314,12 @@ namespace Emby.Server.Implementations.IO
             var ex = e.GetException();
             var dw = (FileSystemWatcher)sender;
 
+            if (ex is UnauthorizedAccessException unauthorizedAccessException)
+            {
+                _logger.LogError(unauthorizedAccessException, "Permission error for Directory watcher: {Path}", dw.Path);
+                return;
+            }
+
             _logger.LogError(ex, "Error in Directory watcher for: {Path}", dw.Path);
 
             DisposeWatcher(dw, true);

+ 23 - 15
Emby.Server.Implementations/IO/ManagedFileSystem.cs

@@ -149,6 +149,26 @@ namespace Emby.Server.Implementations.IO
             }
         }
 
+        /// <inheritdoc />
+        public 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.Combine(destination, Path.GetFileName(file)), true);
+                }
+
+                Directory.Delete(source, true);
+            }
+        }
+
         /// <summary>
         /// Returns a <see cref="FileSystemMetadata"/> object for the specified file or directory path.
         /// </summary>
@@ -327,11 +347,7 @@ namespace Emby.Server.Implementations.IO
             }
         }
 
-        /// <summary>
-        /// Gets the creation time UTC.
-        /// </summary>
-        /// <param name="path">The path.</param>
-        /// <returns>DateTime.</returns>
+        /// <inheritdoc />
         public virtual DateTime GetCreationTimeUtc(string path)
         {
             return GetCreationTimeUtc(GetFileSystemInfo(path));
@@ -368,11 +384,7 @@ namespace Emby.Server.Implementations.IO
             }
         }
 
-        /// <summary>
-        /// Gets the last write time UTC.
-        /// </summary>
-        /// <param name="path">The path.</param>
-        /// <returns>DateTime.</returns>
+        /// <inheritdoc />
         public virtual DateTime GetLastWriteTimeUtc(string path)
         {
             return GetLastWriteTimeUtc(GetFileSystemInfo(path));
@@ -446,11 +458,7 @@ namespace Emby.Server.Implementations.IO
             File.SetAttributes(path, attributes);
         }
 
-        /// <summary>
-        /// Swaps the files.
-        /// </summary>
-        /// <param name="file1">The file1.</param>
-        /// <param name="file2">The file2.</param>
+        /// <inheritdoc />
         public virtual void SwapFiles(string file1, string file2)
         {
             ArgumentException.ThrowIfNullOrEmpty(file1);

+ 4 - 0
Emby.Server.Implementations/Library/IgnorePatterns.cs

@@ -50,6 +50,10 @@ namespace Emby.Server.Implementations.Library
             "**/lost+found/**",
             "**/lost+found",
 
+            // Trickplay files
+            "**/*.trickplay",
+            "**/*.trickplay/**",
+
             // WMC temp recording directories that will constantly be written to
             "**/TempRec/**",
             "**/TempRec",

+ 1 - 34
Emby.Server.Implementations/Library/LibraryManager.cs

@@ -2725,33 +2725,9 @@ namespace Emby.Server.Implementations.Library
 
         public string GetPathAfterNetworkSubstitution(string path, BaseItem? ownerItem)
         {
-            string? newPath;
-            if (ownerItem is not null)
-            {
-                var libraryOptions = GetLibraryOptions(ownerItem);
-                if (libraryOptions is not null)
-                {
-                    foreach (var pathInfo in libraryOptions.PathInfos)
-                    {
-                        if (path.TryReplaceSubPath(pathInfo.Path, pathInfo.NetworkPath, out newPath))
-                        {
-                            return newPath;
-                        }
-                    }
-                }
-            }
-
-            var metadataPath = _configurationManager.Configuration.MetadataPath;
-            var metadataNetworkPath = _configurationManager.Configuration.MetadataNetworkPath;
-
-            if (path.TryReplaceSubPath(metadataPath, metadataNetworkPath, out newPath))
-            {
-                return newPath;
-            }
-
             foreach (var map in _configurationManager.Configuration.PathSubstitutions)
             {
-                if (path.TryReplaceSubPath(map.From, map.To, out newPath))
+                if (path.TryReplaceSubPath(map.From, map.To, out var newPath))
                 {
                     return newPath;
                 }
@@ -3070,15 +3046,6 @@ namespace Emby.Server.Implementations.Library
 
             SyncLibraryOptionsToLocations(virtualFolderPath, libraryOptions);
 
-            foreach (var originalPathInfo in libraryOptions.PathInfos)
-            {
-                if (string.Equals(mediaPath.Path, originalPathInfo.Path, StringComparison.Ordinal))
-                {
-                    originalPathInfo.NetworkPath = mediaPath.NetworkPath;
-                    break;
-                }
-            }
-
             CollectionFolder.SaveLibraryOptions(virtualFolderPath, libraryOptions);
         }
 

+ 7 - 1
Emby.Server.Implementations/Localization/Core/be.json

@@ -129,5 +129,11 @@
     "TaskCleanCollectionsAndPlaylists": "Ачысціце калекцыі і спісы прайгравання",
     "TaskCleanCollectionsAndPlaylistsDescription": "Выдаляе элементы з калекцый і спісаў прайгравання, якія больш не існуюць.",
     "TaskAudioNormalizationDescription": "Сканіруе файлы на прадмет нармалізацыі гуку.",
-    "TaskAudioNormalization": "Нармалізацыя гуку"
+    "TaskAudioNormalization": "Нармалізацыя гуку",
+    "TaskExtractMediaSegmentsDescription": "Выдае або атрымлівае медыясегменты з убудоў з падтрымкай MediaSegment.",
+    "TaskMoveTrickplayImagesDescription": "Перамяшчае існуючыя файлы trickplay у адпаведнасці з наладамі бібліятэкі.",
+    "TaskDownloadMissingLyrics": "Спампаваць зніклыя тэксты песень",
+    "TaskDownloadMissingLyricsDescription": "Спампоўвае тэксты для песень",
+    "TaskExtractMediaSegments": "Сканіраванне медыя-сегмента",
+    "TaskMoveTrickplayImages": "Перанесці месцазнаходжанне выявы Trickplay"
 }

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

@@ -130,5 +130,7 @@
     "TaskCleanCollectionsAndPlaylistsDescription": "Esborra elements de col·leccions i llistes de reproducció que ja no existeixen.",
     "TaskCleanCollectionsAndPlaylists": "Neteja col·leccions i llistes de reproducció",
     "TaskAudioNormalization": "Normalització d'Àudio",
-    "TaskAudioNormalizationDescription": "Escaneja arxius per dades de normalització d'àudio."
+    "TaskAudioNormalizationDescription": "Escaneja arxius per dades de normalització d'àudio.",
+    "TaskDownloadMissingLyricsDescription": "Baixar lletres de les cançons",
+    "TaskDownloadMissingLyrics": "Baixar lletres que falten"
 }

+ 5 - 1
Emby.Server.Implementations/Localization/Core/cs.json

@@ -132,5 +132,9 @@
     "TaskAudioNormalization": "Normalizace zvuku",
     "TaskAudioNormalizationDescription": "Skenovat soubory za účelem normalizace zvuku.",
     "TaskDownloadMissingLyrics": "Stáhnout chybějící texty k písni",
-    "TaskDownloadMissingLyricsDescription": "Stáhne texty k písni"
+    "TaskDownloadMissingLyricsDescription": "Stáhne texty k písni",
+    "TaskExtractMediaSegments": "Skenování segmentů médií",
+    "TaskExtractMediaSegmentsDescription": "Extrahuje či získá segmenty médií pomocí zásuvných modulů MediaSegment.",
+    "TaskMoveTrickplayImages": "Přesunout úložiště obrázků Trickplay",
+    "TaskMoveTrickplayImagesDescription": "Přesune existující soubory Trickplay podle nastavení knihovny."
 }

+ 5 - 1
Emby.Server.Implementations/Localization/Core/de.json

@@ -132,5 +132,9 @@
     "TaskAudioNormalization": "Audio Normalisierung",
     "TaskAudioNormalizationDescription": "Durchsucht Dateien nach Audionormalisierungsdaten.",
     "TaskDownloadMissingLyricsDescription": "Lädt Songtexte herunter",
-    "TaskDownloadMissingLyrics": "Fehlende Songtexte herunterladen"
+    "TaskDownloadMissingLyrics": "Fehlende Songtexte herunterladen",
+    "TaskExtractMediaSegments": "Scanne Mediensegmente",
+    "TaskExtractMediaSegmentsDescription": "Extrahiert oder empfängt Mediensegmente von Plugins die Mediensegmente nutzen.",
+    "TaskMoveTrickplayImages": "Verzeichnis für Trickplay-Bilder migrieren",
+    "TaskMoveTrickplayImagesDescription": "Trickplay-Bilder werden entsprechend der Bibliothekseinstellungen verschoben."
 }

+ 5 - 1
Emby.Server.Implementations/Localization/Core/en-GB.json

@@ -132,5 +132,9 @@
     "TaskAudioNormalization": "Audio Normalisation",
     "TaskAudioNormalizationDescription": "Scans files for audio normalisation data.",
     "TaskDownloadMissingLyrics": "Download missing lyrics",
-    "TaskDownloadMissingLyricsDescription": "Downloads lyrics for songs"
+    "TaskDownloadMissingLyricsDescription": "Downloads lyrics for songs",
+    "TaskExtractMediaSegments": "Media Segment Scan",
+    "TaskExtractMediaSegmentsDescription": "Extracts or obtains media segments from MediaSegment enabled plugins.",
+    "TaskMoveTrickplayImages": "Migrate Trickplay Image Location",
+    "TaskMoveTrickplayImagesDescription": "Moves existing trickplay files according to the library settings."
 }

+ 5 - 1
Emby.Server.Implementations/Localization/Core/en-US.json

@@ -131,5 +131,9 @@
     "TaskKeyframeExtractor": "Keyframe Extractor",
     "TaskKeyframeExtractorDescription": "Extracts keyframes from video files to create more precise HLS playlists. This task may run for a long time.",
     "TaskCleanCollectionsAndPlaylists": "Clean up collections and playlists",
-    "TaskCleanCollectionsAndPlaylistsDescription": "Removes items from collections and playlists that no longer exist."
+    "TaskCleanCollectionsAndPlaylistsDescription": "Removes items from collections and playlists that no longer exist.",
+    "TaskExtractMediaSegments": "Media Segment Scan",
+    "TaskExtractMediaSegmentsDescription": "Extracts or obtains media segments from MediaSegment enabled plugins.",
+    "TaskMoveTrickplayImages": "Migrate Trickplay Image Location",
+    "TaskMoveTrickplayImagesDescription": "Moves existing trickplay files according to the library settings."
 }

+ 5 - 1
Emby.Server.Implementations/Localization/Core/es-AR.json

@@ -132,5 +132,9 @@
     "TaskCleanCollectionsAndPlaylists": "Limpiar colecciones y listas de reproducción",
     "TaskCleanCollectionsAndPlaylistsDescription": "Elimina elementos de colecciones y listas de reproducción que ya no existen.",
     "TaskDownloadMissingLyrics": "Descargar letra faltante",
-    "TaskDownloadMissingLyricsDescription": "Descarga letras de canciones"
+    "TaskDownloadMissingLyricsDescription": "Descarga letras de canciones",
+    "TaskExtractMediaSegments": "Escanear Segmentos de Media",
+    "TaskExtractMediaSegmentsDescription": "Extrae u obtiene segmentos de medio de plugins habilitados para MediaSegment.",
+    "TaskMoveTrickplayImagesDescription": "Mueve archivos existentes de trickplay de acuerdo a la configuración de la biblioteca.",
+    "TaskMoveTrickplayImages": "Migrar Ubicación de Imagen de Trickplay"
 }

+ 8 - 2
Emby.Server.Implementations/Localization/Core/et.json

@@ -102,7 +102,7 @@
     "Forced": "Sunnitud",
     "Folders": "Kaustad",
     "Favorites": "Lemmikud",
-    "FailedLoginAttemptWithUserName": "{0} - sisselogimine nurjus",
+    "FailedLoginAttemptWithUserName": "Sisselogimine nurjus aadressilt {0}",
     "DeviceOnlineWithName": "{0} on ühendatud",
     "DeviceOfflineWithName": "{0} katkestas ühenduse",
     "Default": "Vaikimisi",
@@ -129,5 +129,11 @@
     "TaskAudioNormalization": "Heli Normaliseerimine",
     "TaskAudioNormalizationDescription": "Skaneerib faile heli normaliseerimise andmete jaoks.",
     "TaskCleanCollectionsAndPlaylistsDescription": "Eemaldab kogumikest ja esitusloenditest asjad, mida enam ei eksisteeri.",
-    "TaskCleanCollectionsAndPlaylists": "Puhasta kogumikud ja esitusloendid"
+    "TaskCleanCollectionsAndPlaylists": "Puhasta kogumikud ja esitusloendid",
+    "TaskDownloadMissingLyrics": "Lae alla puuduolev lüürika",
+    "TaskDownloadMissingLyricsDescription": "Lae lauludele alla lüürika",
+    "TaskMoveTrickplayImagesDescription": "Liigutab trickplay pildid meediakogu sätete kohaselt.",
+    "TaskExtractMediaSegments": "Meediasegmentide skaneerimine",
+    "TaskExtractMediaSegmentsDescription": "Eraldab või võtab meediasegmendid MediaSegment'i lubavatest pluginatest.",
+    "TaskMoveTrickplayImages": "Migreeri trickplay piltide asukoht"
 }

+ 5 - 1
Emby.Server.Implementations/Localization/Core/fa.json

@@ -132,5 +132,9 @@
     "TaskAudioNormalizationDescription": "بررسی فایل برای داده‌های نرمال کردن صدا.",
     "TaskDownloadMissingLyrics": "دانلود متن‌های ناموجود",
     "TaskDownloadMissingLyricsDescription": "دانلود متن شعر‌ها",
-    "TaskAudioNormalization": "نرمال کردن صدا"
+    "TaskAudioNormalization": "نرمال کردن صدا",
+    "TaskExtractMediaSegments": "بررسی بخش محتوا",
+    "TaskExtractMediaSegmentsDescription": "بخش‌های محتوا را از افزونه‌های مربوط استخراح می‌کند.",
+    "TaskMoveTrickplayImages": "جابه‌جایی عکس‌های Trickplay",
+    "TaskMoveTrickplayImagesDescription": "داده‌های Trickplay را با توجه به تنظیمات کتاب‌خانه جابه‌جا می‌کند."
 }

+ 2 - 1
Emby.Server.Implementations/Localization/Core/fi.json

@@ -129,5 +129,6 @@
     "TaskCleanCollectionsAndPlaylistsDescription": "Poistaa kohteet kokoelmista ja soittolistoista joita ei ole enää olemassa.",
     "TaskCleanCollectionsAndPlaylists": "Puhdista kokoelmat ja soittolistat",
     "TaskAudioNormalization": "Äänenvoimakkuuden normalisointi",
-    "TaskAudioNormalizationDescription": "Etsii tiedostoista äänenvoimakkuuden normalisointitietoja."
+    "TaskAudioNormalizationDescription": "Etsii tiedostoista äänenvoimakkuuden normalisointitietoja.",
+    "TaskDownloadMissingLyrics": "Lataa puuttuva lyriikka"
 }

+ 5 - 1
Emby.Server.Implementations/Localization/Core/fr.json

@@ -132,5 +132,9 @@
     "TaskAudioNormalization": "Normalisation audio",
     "TaskAudioNormalizationDescription": "Analyse les fichiers à la recherche de données de normalisation audio.",
     "TaskDownloadMissingLyricsDescription": "Téléchargement des paroles des chansons",
-    "TaskDownloadMissingLyrics": "Télécharger les paroles des chansons manquantes"
+    "TaskDownloadMissingLyrics": "Télécharger les paroles des chansons manquantes",
+    "TaskExtractMediaSegments": "Analyse des segments de média",
+    "TaskMoveTrickplayImages": "Changer l'emplacement des images Trickplay",
+    "TaskExtractMediaSegmentsDescription": "Extrait ou obtient des segments de média à partir des plugins compatibles avec MediaSegment.",
+    "TaskMoveTrickplayImagesDescription": "Déplace les fichiers trickplay existants en fonction des paramètres de la bibliothèque."
 }

+ 2 - 2
Emby.Server.Implementations/Localization/Core/gl.json

@@ -1,7 +1,7 @@
 {
     "Albums": "Álbumes",
-    "Collections": "Colecións",
-    "ChapterNameValue": "Capítulos {0}",
+    "Collections": "Coleccións",
+    "ChapterNameValue": "Capítulo {0}",
     "Channels": "Canles",
     "CameraImageUploadedFrom": "Cargouse unha nova imaxe da cámara desde {0}",
     "Books": "Libros",

+ 8 - 2
Emby.Server.Implementations/Localization/Core/he.json

@@ -60,7 +60,7 @@
     "NotificationOptionUserLockedOut": "משתמש ננעל",
     "NotificationOptionVideoPlayback": "ניגון וידאו החל",
     "NotificationOptionVideoPlaybackStopped": "ניגון וידאו הופסק",
-    "Photos": "תמונות",
+    "Photos": "צילומים",
     "Playlists": "רשימות נגינה",
     "Plugin": "תוסף",
     "PluginInstalledWithName": "{0} הותקן",
@@ -130,5 +130,11 @@
     "TaskAudioNormalization": "נרמול שמע",
     "TaskCleanCollectionsAndPlaylistsDescription": "מנקה פריטים לא קיימים מאוספים ורשימות השמעה.",
     "TaskAudioNormalizationDescription": "מחפש קבצי נורמליזציה של שמע.",
-    "TaskCleanCollectionsAndPlaylists": "מנקה אוספים ורשימות השמעה"
+    "TaskCleanCollectionsAndPlaylists": "מנקה אוספים ורשימות השמעה",
+    "TaskDownloadMissingLyrics": "הורדת מילים חסרות",
+    "TaskDownloadMissingLyricsDescription": "הורדת מילים לשירים",
+    "TaskMoveTrickplayImages": "מעביר את מיקום תמונות Trickplay",
+    "TaskExtractMediaSegments": "סריקת מדיה",
+    "TaskExtractMediaSegmentsDescription": "מחלץ חלקי מדיה מתוספים המאפשרים זאת.",
+    "TaskMoveTrickplayImagesDescription": "מזיז קבצי trickplay קיימים בהתאם להגדרות הספרייה."
 }

+ 28 - 22
Emby.Server.Implementations/Localization/Core/hu.json

@@ -1,13 +1,13 @@
 {
     "Albums": "Albumok",
-    "AppDeviceValues": "Program: {0}, Eszköz: {1}",
+    "AppDeviceValues": "Program: {0}, eszköz: {1}",
     "Application": "Alkalmazás",
     "Artists": "Előadók",
     "AuthenticationSucceededWithUserName": "{0} sikeresen hitelesítve",
     "Books": "Könyvek",
     "CameraImageUploadedFrom": "Új kamerakép lett feltöltve innen: {0}",
     "Channels": "Csatornák",
-    "ChapterNameValue": "Jelenet {0}",
+    "ChapterNameValue": "{0}. jelenet",
     "Collections": "Gyűjtemények",
     "DeviceOfflineWithName": "{0} kijelentkezett",
     "DeviceOnlineWithName": "{0} belépett",
@@ -15,31 +15,31 @@
     "Favorites": "Kedvencek",
     "Folders": "Könyvtárak",
     "Genres": "Műfajok",
-    "HeaderAlbumArtists": "Album előadók",
+    "HeaderAlbumArtists": "Albumelőadók",
     "HeaderContinueWatching": "Megtekintés folytatása",
-    "HeaderFavoriteAlbums": "Kedvenc Albumok",
-    "HeaderFavoriteArtists": "Kedvenc Előadók",
-    "HeaderFavoriteEpisodes": "Kedvenc Epizódok",
-    "HeaderFavoriteShows": "Kedvenc Sorozatok",
-    "HeaderFavoriteSongs": "Kedvenc Dalok",
+    "HeaderFavoriteAlbums": "Kedvenc albumok",
+    "HeaderFavoriteArtists": "Kedvenc előadók",
+    "HeaderFavoriteEpisodes": "Kedvenc epizódok",
+    "HeaderFavoriteShows": "Kedvenc sorozatok",
+    "HeaderFavoriteSongs": "Kedvenc számok",
     "HeaderLiveTV": "Élő TV",
     "HeaderNextUp": "Következik",
-    "HeaderRecordingGroups": "Felvevő Csoportok",
-    "HomeVideos": "Otthoni Videók",
-    "Inherit": "Örökölt",
-    "ItemAddedWithName": "{0} hozzáadva a könyvtárhoz",
-    "ItemRemovedWithName": "{0} eltávolítva a könyvtárból",
+    "HeaderRecordingGroups": "Felvételi csoportok",
+    "HomeVideos": "Otthoni videók",
+    "Inherit": "Öröklés",
+    "ItemAddedWithName": "{0} hozzáadva a médiatárhoz",
+    "ItemRemovedWithName": "{0} eltávolítva a médiatárból",
     "LabelIpAddressValue": "IP-cím: {0}",
     "LabelRunningTimeValue": "Lejátszási idő: {0}",
     "Latest": "Legújabb",
     "MessageApplicationUpdated": "A Jellyfin kiszolgáló frissítve lett",
     "MessageApplicationUpdatedTo": "A Jellyfin kiszolgáló frissítve lett a következőre: {0}",
     "MessageNamedServerConfigurationUpdatedWithValue": "A kiszolgálókonfigurációs rész frissítve lett: {0}",
-    "MessageServerConfigurationUpdated": "Kiszolgálókonfiguráció frissítve lett",
+    "MessageServerConfigurationUpdated": "A kiszolgálókonfiguráció frissítve lett",
     "MixedContent": "Vegyes tartalom",
     "Movies": "Filmek",
     "Music": "Zenék",
-    "MusicVideos": "Zenei videóklippek",
+    "MusicVideos": "Zenei videóklipek",
     "NameInstallFailed": "{0} sikertelen telepítés",
     "NameSeasonNumber": "{0}. évad",
     "NameSeasonUnknown": "Ismeretlen évad",
@@ -56,7 +56,7 @@
     "NotificationOptionPluginUninstalled": "Bővítmény eltávolítva",
     "NotificationOptionPluginUpdateInstalled": "Bővítményfrissítés telepítve",
     "NotificationOptionServerRestartRequired": "A kiszolgáló újraindítása szükséges",
-    "NotificationOptionTaskFailed": "Ütemezett feladat hiba",
+    "NotificationOptionTaskFailed": "Hiba az ütemezett feladatban",
     "NotificationOptionUserLockedOut": "Felhasználó tiltva",
     "NotificationOptionVideoPlayback": "Videólejátszás elkezdve",
     "NotificationOptionVideoPlaybackStopped": "Videólejátszás leállítva",
@@ -107,7 +107,7 @@
     "TaskCleanCache": "Gyorsítótár könyvtárának ürítése",
     "TasksChannelsCategory": "Internetes csatornák",
     "TasksApplicationCategory": "Alkalmazás",
-    "TasksLibraryCategory": "Könyvtár",
+    "TasksLibraryCategory": "Médiatár",
     "TasksMaintenanceCategory": "Karbantartás",
     "TaskDownloadMissingSubtitlesDescription": "A metaadat-konfiguráció alapján ellenőrzi és letölti a hiányzó feliratokat az internetről.",
     "TaskDownloadMissingSubtitles": "Hiányzó feliratok letöltése",
@@ -119,16 +119,22 @@
     "Undefined": "Meghatározatlan",
     "Forced": "Kényszerített",
     "Default": "Alapértelmezett",
-    "TaskOptimizeDatabaseDescription": "Tömöríti az adatbázist és csonkolja a szabad helyet. A feladat futtatása a könyvtár beolvasása után, vagy egyéb, adatbázis-módosítást igénylő változtatások végrehajtása javíthatja a teljesítményt.",
+    "TaskOptimizeDatabaseDescription": "Tömöríti az adatbázist és csonkolja a szabad helyet. A feladat futtatása a médiatár beolvasása, vagy egyéb adatbázis-módosítást igénylő változtatás végrehajtása után, javíthatja a teljesítményt.",
     "TaskOptimizeDatabase": "Adatbázis optimalizálása",
     "TaskKeyframeExtractor": "Kulcsképkockák kibontása",
     "TaskKeyframeExtractorDescription": "Kibontja a kulcsképkockákat a videófájlokból, hogy pontosabb HLS lejátszási listákat hozzon létre. Ez a feladat hosszú ideig tarthat.",
     "External": "Külső",
     "HearingImpaired": "Hallássérült",
-    "TaskRefreshTrickplayImages": "Trickplay képek generálása",
+    "TaskRefreshTrickplayImages": "Trickplay képek előállítása",
     "TaskRefreshTrickplayImagesDescription": "Trickplay előnézetet készít az engedélyezett könyvtárakban lévő videókhoz.",
-    "TaskAudioNormalization": "Hangerő Normalizáció",
+    "TaskAudioNormalization": "Hangerő-normalizálás",
     "TaskCleanCollectionsAndPlaylistsDescription": "Nem létező elemek törlése a gyűjteményekből és lejátszási listákról.",
-    "TaskAudioNormalizationDescription": "Hangerő normalizációs adatok keresése.",
-    "TaskCleanCollectionsAndPlaylists": "Gyűjtemények és lejátszási listák optimalizálása"
+    "TaskAudioNormalizationDescription": "Hangerő-normalizálási adatok keresése.",
+    "TaskCleanCollectionsAndPlaylists": "Gyűjtemények és lejátszási listák optimalizálása",
+    "TaskExtractMediaSegments": "Médiaszegmens felismerése",
+    "TaskDownloadMissingLyrics": "Hiányzó szöveg letöltése",
+    "TaskDownloadMissingLyricsDescription": "Zenék szövegének letöltése",
+    "TaskMoveTrickplayImages": "Trickplay képek helyének átköltöztetése",
+    "TaskMoveTrickplayImagesDescription": "A médiatár-beállításoknak megfelelően áthelyezi a meglévő trickplay fájlokat.",
+    "TaskExtractMediaSegmentsDescription": "Kinyeri vagy megszerzi a médiaszegmenseket a MediaSegment támogatással rendelkező bővítményekből."
 }

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

@@ -132,5 +132,7 @@
     "TaskAudioNormalization": "Normalizzazione dell'audio",
     "TaskAudioNormalizationDescription": "Scansiona i file alla ricerca dei dati per la normalizzazione dell'audio.",
     "TaskDownloadMissingLyricsDescription": "Scarica testi per le canzoni",
-    "TaskDownloadMissingLyrics": "Scarica testi mancanti"
+    "TaskDownloadMissingLyrics": "Scarica testi mancanti",
+    "TaskMoveTrickplayImages": "Sposta le immagini Trickplay",
+    "TaskMoveTrickplayImagesDescription": "Sposta le immagini Trickplay esistenti secondo la configurazione della libreria."
 }

+ 6 - 1
Emby.Server.Implementations/Localization/Core/ja.json

@@ -129,5 +129,10 @@
     "TaskCleanCollectionsAndPlaylists": "コレクションとプレイリストをクリーンアップ",
     "TaskAudioNormalization": "音声の正規化",
     "TaskAudioNormalizationDescription": "音声の正規化データのためにファイルをスキャンします。",
-    "TaskCleanCollectionsAndPlaylistsDescription": "在しなくなったコレクションやプレイリストからアイテムを削除します。"
+    "TaskCleanCollectionsAndPlaylistsDescription": "在しなくなったコレクションやプレイリストからアイテムを削除します。",
+    "TaskDownloadMissingLyricsDescription": "歌詞をダウンロード",
+    "TaskExtractMediaSegments": "メディアセグメントを読み取る",
+    "TaskMoveTrickplayImages": "Trickplayの画像を移動",
+    "TaskMoveTrickplayImagesDescription": "ライブラリ設定によりTrickplayのファイルを移動。",
+    "TaskDownloadMissingLyrics": "記録されていない歌詞をダウンロード"
 }

+ 5 - 1
Emby.Server.Implementations/Localization/Core/kw.json

@@ -131,5 +131,9 @@
     "TaskCleanCollectionsAndPlaylists": "Glanhe kuntellow ha rolyow-gwari",
     "TaskKeyframeExtractor": "Estennell Framalhwedh",
     "TaskCleanCollectionsAndPlaylistsDescription": "Y hwra dilea taklow a-dhyworth kuntellow ha rolyow-gwari na vos na moy.",
-    "TaskKeyframeExtractorDescription": "Y hwra kuntel framyowalhwedh a-dhyworth restrennow gwydhyowyow rag gul rolyow-gwari HLS moy poran. Martesen y hwra an oberen ma ow ponya rag termyn hir."
+    "TaskKeyframeExtractorDescription": "Y hwra kuntel framyowalhwedh a-dhyworth restrennow gwydhyowyow rag gul rolyow-gwari HLS moy poran. Martesen y hwra an oberen ma ow ponya rag termyn hir.",
+    "TaskExtractMediaSegments": "Arhwilas Rann Media",
+    "TaskExtractMediaSegmentsDescription": "Kavos rannow media a-dhyworth ystynansow gallosegys MediaSegment.",
+    "TaskMoveTrickplayImages": "Divroa Tyller Imach TrickPlay",
+    "TaskMoveTrickplayImagesDescription": "Y hwra movya restrennow a-lemmyn trickplay herwydh settyansow lyverva."
 }

+ 10 - 2
Emby.Server.Implementations/Localization/Core/mk.json

@@ -55,7 +55,7 @@
     "Genres": "Жанрови",
     "Folders": "Папки",
     "Favorites": "Омилени",
-    "FailedLoginAttemptWithUserName": "Неуспешно поврзување од {0}",
+    "FailedLoginAttemptWithUserName": "Неуспешен обид за најавување од {0}",
     "DeviceOnlineWithName": "{0} е приклучен",
     "DeviceOfflineWithName": "{0} се исклучи",
     "Collections": "Колекции",
@@ -123,5 +123,13 @@
     "TaskCleanActivityLogDescription": "Избришува логови на активности постари од определеното време.",
     "TaskCleanActivityLog": "Избриши Лог на Активности",
     "External": "Надворешен",
-    "HearingImpaired": "Оштетен слух"
+    "HearingImpaired": "Оштетен слух",
+    "TaskCleanCollectionsAndPlaylists": "Исчисти ги колекциите и плејлистите",
+    "TaskAudioNormalizationDescription": "Скенирање датотеки за податоци за нормализација на звукот.",
+    "TaskDownloadMissingLyrics": "Преземи стихови кои недостасуваат",
+    "TaskDownloadMissingLyricsDescription": "Преземи стихови/текстови за песни",
+    "TaskRefreshTrickplayImages": "Генерирај слики за прегледување (Trickplay)",
+    "TaskAudioNormalization": "Нормализација на звукот",
+    "TaskRefreshTrickplayImagesDescription": "Креира трикплеј прегледи за видеа во овозможените библиотеки.",
+    "TaskCleanCollectionsAndPlaylistsDescription": "Отстранува ставки од колекциите и плејлистите што веќе не постојат."
 }

+ 8 - 4
Emby.Server.Implementations/Localization/Core/nb.json

@@ -128,9 +128,13 @@
     "TaskRefreshTrickplayImages": "Generer Trickplay bilder",
     "TaskRefreshTrickplayImagesDescription": "Oppretter trickplay-forhåndsvisninger for videoer i aktiverte biblioteker.",
     "TaskCleanCollectionsAndPlaylists": "Rydd kolleksjoner og spillelister",
-    "TaskAudioNormalization": "Lyd Normalisering",
-    "TaskAudioNormalizationDescription": "Skan filer for lyd normaliserende data",
-    "TaskCleanCollectionsAndPlaylistsDescription": "Fjerner elementer fra kolleksjoner og spillelister som ikke lengere finnes",
+    "TaskAudioNormalization": "Lydnormalisering",
+    "TaskAudioNormalizationDescription": "Skan filer for lydnormaliserende data.",
+    "TaskCleanCollectionsAndPlaylistsDescription": "Fjerner elementer fra kolleksjoner og spillelister som ikke lengere finnes.",
     "TaskDownloadMissingLyrics": "Last ned manglende tekster",
-    "TaskDownloadMissingLyricsDescription": "Last ned sangtekster"
+    "TaskDownloadMissingLyricsDescription": "Last ned sangtekster",
+    "TaskExtractMediaSegments": "Skann mediasegment",
+    "TaskMoveTrickplayImages": "Migrer bildeplassering for Trickplay",
+    "TaskMoveTrickplayImagesDescription": "Flytter eksisterende Trickplay-filer i henhold til bibliotekseinstillingene.",
+    "TaskExtractMediaSegmentsDescription": "Trekker ut eller henter mediasegmenter fra plugins som støtter MediaSegment."
 }

+ 17 - 13
Emby.Server.Implementations/Localization/Core/nl.json

@@ -16,13 +16,13 @@
     "Folders": "Mappen",
     "Genres": "Genres",
     "HeaderAlbumArtists": "Albumartiesten",
-    "HeaderContinueWatching": "Kijken hervatten",
+    "HeaderContinueWatching": "Verderkijken",
     "HeaderFavoriteAlbums": "Favoriete albums",
     "HeaderFavoriteArtists": "Favoriete artiesten",
     "HeaderFavoriteEpisodes": "Favoriete afleveringen",
-    "HeaderFavoriteShows": "Favoriete shows",
+    "HeaderFavoriteShows": "Favoriete series",
     "HeaderFavoriteSongs": "Favoriete nummers",
-    "HeaderLiveTV": "Live TV",
+    "HeaderLiveTV": "Live-tv",
     "HeaderNextUp": "Volgende",
     "HeaderRecordingGroups": "Opnamegroepen",
     "HomeVideos": "Homevideo's",
@@ -34,8 +34,8 @@
     "Latest": "Nieuwste",
     "MessageApplicationUpdated": "Jellyfin Server is bijgewerkt",
     "MessageApplicationUpdatedTo": "Jellyfin Server is bijgewerkt naar {0}",
-    "MessageNamedServerConfigurationUpdatedWithValue": "Sectie {0} van de server configuratie is bijgewerkt",
-    "MessageServerConfigurationUpdated": "Server configuratie is bijgewerkt",
+    "MessageNamedServerConfigurationUpdatedWithValue": "Sectie {0} van de serverconfiguratie is bijgewerkt",
+    "MessageServerConfigurationUpdated": "Serverconfiguratie is bijgewerkt",
     "MixedContent": "Gemengde inhoud",
     "Movies": "Films",
     "Music": "Muziek",
@@ -50,12 +50,12 @@
     "NotificationOptionAudioPlaybackStopped": "Muziek gestopt",
     "NotificationOptionCameraImageUploaded": "Camera-afbeelding geüpload",
     "NotificationOptionInstallationFailed": "Installatie mislukt",
-    "NotificationOptionNewLibraryContent": "Nieuwe content toegevoegd",
-    "NotificationOptionPluginError": "Plug-in fout",
+    "NotificationOptionNewLibraryContent": "Nieuwe inhoud toegevoegd",
+    "NotificationOptionPluginError": "Plug-in-fout",
     "NotificationOptionPluginInstalled": "Plug-in geïnstalleerd",
     "NotificationOptionPluginUninstalled": "Plug-in verwijderd",
     "NotificationOptionPluginUpdateInstalled": "Plug-in-update geïnstalleerd",
-    "NotificationOptionServerRestartRequired": "Server herstart nodig",
+    "NotificationOptionServerRestartRequired": "Herstarten server vereist",
     "NotificationOptionTaskFailed": "Geplande taak mislukt",
     "NotificationOptionUserLockedOut": "Gebruiker is vergrendeld",
     "NotificationOptionVideoPlayback": "Afspelen van video gestart",
@@ -72,16 +72,16 @@
     "ServerNameNeedsToBeRestarted": "{0} moet herstart worden",
     "Shows": "Series",
     "Songs": "Nummers",
-    "StartupEmbyServerIsLoading": "Jellyfin Server is aan het laden, probeer het later opnieuw.",
+    "StartupEmbyServerIsLoading": "Jellyfin Server is aan het laden. Probeer het later opnieuw.",
     "SubtitleDownloadFailureForItem": "Downloaden van ondertiteling voor {0} is mislukt",
-    "SubtitleDownloadFailureFromForItem": "Ondertitels konden niet gedownload worden van {0} voor {1}",
+    "SubtitleDownloadFailureFromForItem": "Ondertiteling kon niet gedownload worden van {0} voor {1}",
     "Sync": "Synchronisatie",
     "System": "Systeem",
     "TvShows": "TV-series",
     "User": "Gebruiker",
     "UserCreatedWithName": "Gebruiker {0} is aangemaakt",
     "UserDeletedWithName": "Gebruiker {0} is verwijderd",
-    "UserDownloadingItemWithValues": "{0} download {1}",
+    "UserDownloadingItemWithValues": "{0} downloadt {1}",
     "UserLockedOutWithName": "Gebruikersaccount {0} is vergrendeld",
     "UserOfflineFromDevice": "Verbinding van {0} met {1} is verbroken",
     "UserOnlineFromDevice": "{0} heeft verbinding met {1}",
@@ -90,7 +90,7 @@
     "UserStartedPlayingItemWithValues": "{0} speelt {1} af op {2}",
     "UserStoppedPlayingItemWithValues": "{0} heeft afspelen van {1} gestopt op {2}",
     "ValueHasBeenAddedToLibrary": "{0} is toegevoegd aan je mediabibliotheek",
-    "ValueSpecialEpisodeName": "Speciaal - {0}",
+    "ValueSpecialEpisodeName": "Special - {0}",
     "VersionNumber": "Versie {0}",
     "TaskDownloadMissingSubtitlesDescription": "Zoekt op het internet naar ontbrekende ondertiteling gebaseerd op metadataconfiguratie.",
     "TaskDownloadMissingSubtitles": "Ontbrekende ondertiteling downloaden",
@@ -132,5 +132,9 @@
     "TaskAudioNormalization": "Geluidsnormalisatie",
     "TaskAudioNormalizationDescription": "Scant bestanden op gegevens voor geluidsnormalisatie.",
     "TaskDownloadMissingLyrics": "Ontbrekende liedteksten downloaden",
-    "TaskDownloadMissingLyricsDescription": "Downloadt liedteksten"
+    "TaskDownloadMissingLyricsDescription": "Downloadt liedteksten",
+    "TaskExtractMediaSegmentsDescription": "Verkrijgt mediasegmenten vanuit plug-ins met MediaSegment-ondersteuning.",
+    "TaskMoveTrickplayImages": "Locatie trickplay-afbeeldingen migreren",
+    "TaskMoveTrickplayImagesDescription": "Verplaatst bestaande trickplay-bestanden op basis van de bibliotheekinstellingen.",
+    "TaskExtractMediaSegments": "Scannen op mediasegmenten"
 }

+ 5 - 1
Emby.Server.Implementations/Localization/Core/pl.json

@@ -132,5 +132,9 @@
     "TaskAudioNormalization": "Normalizacja dźwięku",
     "TaskAudioNormalizationDescription": "Skanuje pliki w poszukiwaniu danych normalizacji dźwięku.",
     "TaskDownloadMissingLyrics": "Pobierz brakujące słowa",
-    "TaskDownloadMissingLyricsDescription": "Pobierz słowa piosenek"
+    "TaskDownloadMissingLyricsDescription": "Pobierz słowa piosenek",
+    "TaskExtractMediaSegments": "Skanowanie segmentów mediów",
+    "TaskMoveTrickplayImages": "Migruj lokalizację obrazu Trickplay",
+    "TaskExtractMediaSegmentsDescription": "Wyodrębnia lub pobiera segmenty mediów z wtyczek obsługujących MediaSegment.",
+    "TaskMoveTrickplayImagesDescription": "Przenosi istniejące pliki Trickplay zgodnie z ustawieniami biblioteki."
 }

+ 8 - 2
Emby.Server.Implementations/Localization/Core/pt-BR.json

@@ -8,7 +8,7 @@
     "CameraImageUploadedFrom": "Uma nova imagem da câmera foi enviada de {0}",
     "Channels": "Canais",
     "ChapterNameValue": "Capítulo {0}",
-    "Collections": "Coletâneas",
+    "Collections": "Coleções",
     "DeviceOfflineWithName": "{0} se desconectou",
     "DeviceOnlineWithName": "{0} se conectou",
     "FailedLoginAttemptWithUserName": "Falha na tentativa de login de {0}",
@@ -130,5 +130,11 @@
     "TaskCleanCollectionsAndPlaylists": "Limpe coleções e playlists",
     "TaskCleanCollectionsAndPlaylistsDescription": "Remove itens de coleções e playlists que não existem mais.",
     "TaskAudioNormalization": "Normalização de áudio",
-    "TaskAudioNormalizationDescription": "Examina os ficheiros em busca de dados de normalização de áudio."
+    "TaskAudioNormalizationDescription": "Examina os ficheiros em busca de dados de normalização de áudio.",
+    "TaskDownloadMissingLyricsDescription": "Baixar letras para músicas",
+    "TaskDownloadMissingLyrics": "Baixar letra faltante",
+    "TaskMoveTrickplayImagesDescription": "Move os arquivos do trickplay de acordo com as configurações da biblioteca.",
+    "TaskExtractMediaSegments": "Varredura do segmento de mídia",
+    "TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de mídia de plug-ins habilitados para MediaSegment.",
+    "TaskMoveTrickplayImages": "Migrar o local da imagem do Trickplay"
 }

+ 7 - 1
Emby.Server.Implementations/Localization/Core/pt.json

@@ -129,5 +129,11 @@
     "TaskCleanCollectionsAndPlaylistsDescription": "Remove itens de coleções e listas de reprodução que já não existem.",
     "TaskCleanCollectionsAndPlaylists": "Limpar coleções e listas de reprodução",
     "TaskAudioNormalizationDescription": "Analisa os ficheiros para obter dados de normalização de áudio.",
-    "TaskAudioNormalization": "Normalização de áudio"
+    "TaskAudioNormalization": "Normalização de áudio",
+    "TaskDownloadMissingLyrics": "Baixar letras faltantes",
+    "TaskDownloadMissingLyricsDescription": "Baixa letras para músicas",
+    "TaskMoveTrickplayImagesDescription": "Transfere ficheiros de miniatura de vídeo, conforme as definições da biblioteca.",
+    "TaskExtractMediaSegments": "Varrimento de segmentos da média",
+    "TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de média de extensões com suporte a MediaSegment.",
+    "TaskMoveTrickplayImages": "Migração de miniaturas de vídeo"
 }

+ 7 - 1
Emby.Server.Implementations/Localization/Core/ro.json

@@ -129,5 +129,11 @@
     "TaskAudioNormalizationDescription": "Scanează fișiere pentru date necesare normalizării sunetului.",
     "TaskAudioNormalization": "Normalizare sunet",
     "TaskCleanCollectionsAndPlaylists": "Curăță colecțiile și listele de redare",
-    "TaskCleanCollectionsAndPlaylistsDescription": "Elimină elementele care nu mai există din colecții și liste de redare."
+    "TaskCleanCollectionsAndPlaylistsDescription": "Elimină elementele care nu mai există din colecții și liste de redare.",
+    "TaskExtractMediaSegments": "Scanează segmentele media",
+    "TaskMoveTrickplayImagesDescription": "Mută fișierele trickplay existente conform setărilor librăriei.",
+    "TaskExtractMediaSegmentsDescription": "Extrage sau obține segmentele media de la pluginurile MediaSegment activate.",
+    "TaskMoveTrickplayImages": "Migrează locația imaginii Trickplay",
+    "TaskDownloadMissingLyrics": "Descarcă versurile lipsă",
+    "TaskDownloadMissingLyricsDescription": "Descarcă versuri pentru melodii"
 }

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

@@ -130,5 +130,11 @@
     "TaskCleanCollectionsAndPlaylists": "Vyčistiť kolekcie a playlisty",
     "TaskCleanCollectionsAndPlaylistsDescription": "Odstráni položky z kolekcií a playlistov, ktoré už neexistujú.",
     "TaskAudioNormalization": "Normalizácia zvuku",
-    "TaskAudioNormalizationDescription": "Skenovať súbory za účelom normalizácie zvuku."
+    "TaskAudioNormalizationDescription": "Skenovať súbory za účelom normalizácie zvuku.",
+    "TaskExtractMediaSegments": "Skenovanie segmentov médií",
+    "TaskExtractMediaSegmentsDescription": "Extrahuje alebo získava segmenty médií zo zásuvných modulov s povolenou funkciou MediaSegment.",
+    "TaskMoveTrickplayImages": "Presunúť umiestnenie obrázkov Trickplay",
+    "TaskMoveTrickplayImagesDescription": "Presunie existujúce súbory Trickplay podľa nastavení knižnice.",
+    "TaskDownloadMissingLyrics": "Stiahnuť chýbajúce texty piesní",
+    "TaskDownloadMissingLyricsDescription": "Stiahne texty pre piesne"
 }

+ 1 - 1
Emby.Server.Implementations/Localization/Core/sl-SI.json

@@ -3,7 +3,7 @@
     "AppDeviceValues": "Aplikacija: {0}, Naprava: {1}",
     "Application": "Aplikacija",
     "Artists": "Izvajalci",
-    "AuthenticationSucceededWithUserName": "{0} se je uspešno prijavil",
+    "AuthenticationSucceededWithUserName": "{0} se je uspešno prijavil/a",
     "Books": "Knjige",
     "CameraImageUploadedFrom": "Nova fotografija je bila naložena iz {0}",
     "Channels": "Kanali",

+ 7 - 3
Emby.Server.Implementations/Localization/Core/sv.json

@@ -9,7 +9,7 @@
     "Channels": "Kanaler",
     "ChapterNameValue": "Kapitel {0}",
     "Collections": "Samlingar",
-    "DeviceOfflineWithName": "{0} har avbrutit uppkopplingen",
+    "DeviceOfflineWithName": "{0} har kopplat ned",
     "DeviceOnlineWithName": "{0} är ansluten",
     "FailedLoginAttemptWithUserName": "Misslyckat inloggningsförsök från {0}",
     "Favorites": "Favoriter",
@@ -121,7 +121,7 @@
     "Default": "Standard",
     "TaskOptimizeDatabase": "Optimera databasen",
     "TaskOptimizeDatabaseDescription": "Komprimerar databasen och trunkerar ledigt utrymme. Prestandan kan förbättras genom att köra denna aktivitet efter att du har skannat biblioteket eller gjort andra förändringar som indikerar att databasen har modifierats.",
-    "TaskKeyframeExtractorDescription": "Exporterar nyckelbildrutor från videofiler för att skapa mer exakta HLS-spellistor. Denna rutin kan ta lång tid.",
+    "TaskKeyframeExtractorDescription": "Exporterar nyckelbildrutor från videofiler för att skapa mer exakta HLS-spellistor. Denna körning kan ta lång tid.",
     "TaskKeyframeExtractor": "Extraktor för nyckelbildrutor",
     "External": "Extern",
     "HearingImpaired": "Hörselskadad",
@@ -132,5 +132,9 @@
     "TaskCleanCollectionsAndPlaylistsDescription": "Tar bort objekt från samlingar och spellistor som inte längre finns.",
     "TaskAudioNormalizationDescription": "Skannar filer för ljudnormaliseringsdata.",
     "TaskDownloadMissingLyrics": "Ladda ner saknad låttext",
-    "TaskDownloadMissingLyricsDescription": "Laddar ner låttexter"
+    "TaskDownloadMissingLyricsDescription": "Laddar ner låttexter",
+    "TaskExtractMediaSegments": "Skanning av mediesegment",
+    "TaskExtractMediaSegmentsDescription": "Extraherar eller hämtar ut mediesegmen från tillägg som stöder MediaSegment.",
+    "TaskMoveTrickplayImages": "Migrera platsen för Trickplay-bilder",
+    "TaskMoveTrickplayImagesDescription": "Flyttar befintliga trickplay-filer enligt bibliotekets inställningar."
 }

+ 7 - 1
Emby.Server.Implementations/Localization/Core/tr.json

@@ -130,5 +130,11 @@
     "TaskCleanCollectionsAndPlaylistsDescription": "Artık var olmayan koleksiyon ve çalma listelerindeki ögeleri kaldırır.",
     "TaskCleanCollectionsAndPlaylists": "Koleksiyonları ve çalma listelerini temizleyin",
     "TaskAudioNormalizationDescription": "Ses normalleştirme verileri için dosyaları tarar.",
-    "TaskAudioNormalization": "Ses Normalleştirme"
+    "TaskAudioNormalization": "Ses Normalleştirme",
+    "TaskExtractMediaSegments": "Medya Segmenti Tarama",
+    "TaskMoveTrickplayImages": "Trickplay Görsel Konumunu Taşıma",
+    "TaskMoveTrickplayImagesDescription": "Mevcut trickplay dosyalarını kütüphane ayarlarına göre taşır.",
+    "TaskDownloadMissingLyrics": "Eksik şarkı sözlerini indir",
+    "TaskDownloadMissingLyricsDescription": "Şarkı sözlerini indirir",
+    "TaskExtractMediaSegmentsDescription": "MediaSegment özelliği etkin olan eklentilerden medya segmentlerini çıkarır veya alır."
 }

+ 5 - 1
Emby.Server.Implementations/Localization/Core/uk.json

@@ -131,5 +131,9 @@
     "TaskAudioNormalizationDescription": "Сканує файли на наявність даних для нормалізації звуку.",
     "TaskAudioNormalization": "Нормалізація аудіо",
     "TaskDownloadMissingLyrics": "Завантажити відсутні тексти пісень",
-    "TaskDownloadMissingLyricsDescription": "Завантаження текстів пісень"
+    "TaskDownloadMissingLyricsDescription": "Завантаження текстів пісень",
+    "TaskMoveTrickplayImagesDescription": "Переміщує наявні Trickplay-зображення відповідно до налаштувань медіатеки.",
+    "TaskExtractMediaSegments": "Сканування медіа-сегментів",
+    "TaskMoveTrickplayImages": "Змінити місце розташування Trickplay-зображень",
+    "TaskExtractMediaSegmentsDescription": "Витягує або отримує медіа-сегменти з плагінів з підтримкою MediaSegment."
 }

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

@@ -131,5 +131,9 @@
     "TaskAudioNormalization": "Chuẩn Hóa Âm Thanh",
     "TaskAudioNormalizationDescription": "Quét tập tin để tìm dữ liệu chuẩn hóa âm thanh.",
     "TaskDownloadMissingLyricsDescription": "Tải xuống lời cho bài hát",
-    "TaskDownloadMissingLyrics": "Tải xuống lời bị thiếu"
+    "TaskDownloadMissingLyrics": "Tải xuống lời bị thiếu",
+    "TaskExtractMediaSegmentsDescription": "Trích xuất hoặc lấy các phân đoạn phương tiện từ các plugin hỗ trợ MediaSegment.",
+    "TaskMoveTrickplayImages": "Di chuyển vị trí hình ảnh Trickplay",
+    "TaskMoveTrickplayImagesDescription": "Di chuyển các tập tin trickplay hiện có theo cài đặt thư viện.",
+    "TaskExtractMediaSegments": "Quét Phân Đoạn Phương Tiện"
 }

+ 9 - 5
Emby.Server.Implementations/Localization/Core/zh-CN.json

@@ -93,7 +93,7 @@
     "ValueSpecialEpisodeName": "特典 - {0}",
     "VersionNumber": "版本 {0}",
     "TaskUpdatePluginsDescription": "为已设置为自动更新的插件下载和安装更新。",
-    "TaskRefreshPeople": "刷新人员",
+    "TaskRefreshPeople": "刷新演职人员",
     "TasksChannelsCategory": "互联网频道",
     "TasksLibraryCategory": "媒体库",
     "TaskDownloadMissingSubtitlesDescription": "根据元数据设置在互联网上搜索缺少的字幕。",
@@ -122,15 +122,19 @@
     "TaskOptimizeDatabaseDescription": "压缩数据库并优化可用空间,在扫描库或执行其他数据库修改后运行此任务可能会提高性能。",
     "TaskOptimizeDatabase": "优化数据库",
     "TaskKeyframeExtractorDescription": "从视频文件中提取关键帧以创建更准确的 HLS 播放列表。这项任务可能需要很长时间。",
-    "TaskKeyframeExtractor": "关键帧提取",
+    "TaskKeyframeExtractor": "关键帧提取",
     "External": "外部",
     "HearingImpaired": "听力障碍",
-    "TaskRefreshTrickplayImages": "生成时间轴缩略图",
-    "TaskRefreshTrickplayImagesDescription": "为启用的媒体库中的视频生成时间轴缩略图。",
+    "TaskRefreshTrickplayImages": "生成进度条预览图",
+    "TaskRefreshTrickplayImagesDescription": "为启用的媒体库中的视频生成进度条预览图。",
     "TaskCleanCollectionsAndPlaylists": "清理合集和播放列表",
     "TaskCleanCollectionsAndPlaylistsDescription": "清理合集和播放列表中已不存在的项目。",
     "TaskAudioNormalization": "音频标准化",
     "TaskAudioNormalizationDescription": "扫描文件以寻找音频标准化数据。",
     "TaskDownloadMissingLyrics": "下载缺失的歌词",
-    "TaskDownloadMissingLyricsDescription": "下载歌曲歌词"
+    "TaskDownloadMissingLyricsDescription": "下载歌曲歌词",
+    "TaskMoveTrickplayImages": "迁移进度条预览图的存储位置",
+    "TaskExtractMediaSegments": "媒体片段扫描",
+    "TaskExtractMediaSegmentsDescription": "从支持 MediaSegment 的插件中提取或获取媒体片段。",
+    "TaskMoveTrickplayImagesDescription": "根据媒体库设置移动现有的进度条预览图文件。"
 }

+ 7 - 1
Emby.Server.Implementations/Localization/Core/zh-TW.json

@@ -129,5 +129,11 @@
     "TaskCleanCollectionsAndPlaylists": "清理系列和播放清單",
     "TaskCleanCollectionsAndPlaylistsDescription": "清理系列和播放清單中已不存在的項目。",
     "TaskAudioNormalization": "音量標準化",
-    "TaskAudioNormalizationDescription": "掃描文件以找出音量標準化資料。"
+    "TaskAudioNormalizationDescription": "掃描文件以找出音量標準化資料。",
+    "TaskDownloadMissingLyrics": "下載缺少的歌詞",
+    "TaskDownloadMissingLyricsDescription": "卡在歌曲歌詞",
+    "TaskExtractMediaSegments": "掃描媒體片段",
+    "TaskExtractMediaSegmentsDescription": "從使用媒體片段的擴充功能取得媒體片段。",
+    "TaskMoveTrickplayImages": "遷移快轉縮圖位置",
+    "TaskMoveTrickplayImagesDescription": "根據媒體庫的設定遷移快轉縮圖的檔案。"
 }

+ 29 - 0
Emby.Server.Implementations/MediaEncoder/EncodingManager.cs

@@ -91,8 +91,29 @@ namespace Emby.Server.Implementations.MediaEncoder
             return video.DefaultVideoStreamIndex.HasValue;
         }
 
+        private long GetAverageDurationBetweenChapters(IReadOnlyList<ChapterInfo> chapters)
+        {
+            if (chapters.Count < 2)
+            {
+                return 0;
+            }
+
+            long sum = 0;
+            for (int i = 1; i < chapters.Count; i++)
+            {
+                sum += chapters[i].StartPositionTicks - chapters[i - 1].StartPositionTicks;
+            }
+
+            return sum / chapters.Count;
+        }
+
         public async Task<bool> RefreshChapterImages(Video video, IDirectoryService directoryService, IReadOnlyList<ChapterInfo> chapters, bool extractImages, bool saveChapters, CancellationToken cancellationToken)
         {
+            if (chapters.Count == 0)
+            {
+                return true;
+            }
+
             var libraryOptions = _libraryManager.GetLibraryOptions(video);
 
             if (!IsEligibleForChapterImageExtraction(video, libraryOptions))
@@ -100,6 +121,14 @@ namespace Emby.Server.Implementations.MediaEncoder
                 extractImages = false;
             }
 
+            var averageChapterDuration = GetAverageDurationBetweenChapters(chapters);
+            var threshold = TimeSpan.FromSeconds(1).Ticks;
+            if (averageChapterDuration < threshold)
+            {
+                _logger.LogInformation("Skipping chapter image extraction for {Video} as the average chapter duration {AverageDuration} was lower than the minimum threshold {Threshold}", video.Name, averageChapterDuration, threshold);
+                extractImages = false;
+            }
+
             var success = true;
             var changesMade = false;
 

+ 118 - 0
Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs

@@ -0,0 +1,118 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.Tasks;
+
+namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
+
+/// <summary>
+/// Task to obtain media segments.
+/// </summary>
+public class MediaSegmentExtractionTask : IScheduledTask
+{
+    /// <summary>
+    /// The library manager.
+    /// </summary>
+    private readonly ILibraryManager _libraryManager;
+    private readonly ILocalizationManager _localization;
+    private readonly IMediaSegmentManager _mediaSegmentManager;
+    private static readonly BaseItemKind[] _itemTypes = [BaseItemKind.Episode, BaseItemKind.Movie, BaseItemKind.Audio, BaseItemKind.AudioBook];
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="MediaSegmentExtractionTask" /> class.
+    /// </summary>
+    /// <param name="libraryManager">The library manager.</param>
+    /// <param name="localization">The localization manager.</param>
+    /// <param name="mediaSegmentManager">The segment manager.</param>
+    public MediaSegmentExtractionTask(ILibraryManager libraryManager, ILocalizationManager localization, IMediaSegmentManager mediaSegmentManager)
+    {
+        _libraryManager = libraryManager;
+        _localization = localization;
+        _mediaSegmentManager = mediaSegmentManager;
+    }
+
+    /// <inheritdoc/>
+    public string Name => _localization.GetLocalizedString("TaskExtractMediaSegments");
+
+    /// <inheritdoc/>
+    public string Description => _localization.GetLocalizedString("TaskExtractMediaSegmentsDescription");
+
+    /// <inheritdoc/>
+    public string Category => _localization.GetLocalizedString("TasksLibraryCategory");
+
+    /// <inheritdoc/>
+    public string Key => "TaskExtractMediaSegments";
+
+    /// <inheritdoc/>
+    public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
+    {
+        cancellationToken.ThrowIfCancellationRequested();
+
+        progress.Report(0);
+
+        var pagesize = 100;
+
+        var query = new InternalItemsQuery
+        {
+            MediaTypes = new[] { MediaType.Video, MediaType.Audio },
+            IsVirtualItem = false,
+            IncludeItemTypes = _itemTypes,
+            DtoOptions = new DtoOptions(true),
+            SourceTypes = new[] { SourceType.Library },
+            Recursive = true,
+            Limit = pagesize
+        };
+
+        var numberOfVideos = _libraryManager.GetCount(query);
+
+        var startIndex = 0;
+        var numComplete = 0;
+
+        while (startIndex < numberOfVideos)
+        {
+            query.StartIndex = startIndex;
+
+            var baseItems = _libraryManager.GetItemList(query);
+            var currentPageCount = baseItems.Count;
+            // TODO parallelize with Parallel.ForEach?
+            for (var i = 0; i < currentPageCount; i++)
+            {
+                cancellationToken.ThrowIfCancellationRequested();
+
+                var item = baseItems[i];
+                // Only local files supported
+                if (item.IsFileProtocol && File.Exists(item.Path))
+                {
+                    await _mediaSegmentManager.RunSegmentPluginProviders(item, false, cancellationToken).ConfigureAwait(false);
+                }
+
+                // Update progress
+                numComplete++;
+                double percent = (double)numComplete / numberOfVideos;
+                progress.Report(100 * percent);
+            }
+
+            startIndex += pagesize;
+        }
+
+        progress.Report(100);
+    }
+
+    /// <inheritdoc/>
+    public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+    {
+        yield return new TaskTriggerInfo
+        {
+            Type = TaskTriggerInfo.TriggerInterval,
+            IntervalTicks = TimeSpan.FromHours(12).Ticks
+        };
+    }
+}

+ 160 - 8
Emby.Server.Implementations/Session/SessionManager.cs

@@ -1,7 +1,5 @@
 #nullable disable
 
-#pragma warning disable CS1591
-
 using System;
 using System.Collections.Concurrent;
 using System.Collections.Generic;
@@ -68,13 +66,29 @@ namespace Emby.Server.Implementations.Session
         private Timer _inactiveTimer;
 
         private DtoOptions _itemInfoDtoOptions;
-        private bool _disposed = false;
+        private bool _disposed;
 
+        /// <summary>
+        /// Initializes a new instance of the <see cref="SessionManager"/> class.
+        /// </summary>
+        /// <param name="logger">Instance of <see cref="ILogger{SessionManager}"/> interface.</param>
+        /// <param name="eventManager">Instance of <see cref="IEventManager"/> interface.</param>
+        /// <param name="userDataManager">Instance of <see cref="IUserDataManager"/> interface.</param>
+        /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param>
+        /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
+        /// <param name="userManager">Instance of <see cref="IUserManager"/> interface.</param>
+        /// <param name="musicManager">Instance of <see cref="IMusicManager"/> interface.</param>
+        /// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param>
+        /// <param name="imageProcessor">Instance of <see cref="IImageProcessor"/> interface.</param>
+        /// <param name="appHost">Instance of <see cref="IServerApplicationHost"/> interface.</param>
+        /// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param>
+        /// <param name="mediaSourceManager">Instance of <see cref="IMediaSourceManager"/> interface.</param>
+        /// <param name="hostApplicationLifetime">Instance of <see cref="IHostApplicationLifetime"/> interface.</param>
         public SessionManager(
             ILogger<SessionManager> logger,
             IEventManager eventManager,
             IUserDataManager userDataManager,
-            IServerConfigurationManager config,
+            IServerConfigurationManager serverConfigurationManager,
             ILibraryManager libraryManager,
             IUserManager userManager,
             IMusicManager musicManager,
@@ -88,7 +102,7 @@ namespace Emby.Server.Implementations.Session
             _logger = logger;
             _eventManager = eventManager;
             _userDataManager = userDataManager;
-            _config = config;
+            _config = serverConfigurationManager;
             _libraryManager = libraryManager;
             _userManager = userManager;
             _musicManager = musicManager;
@@ -508,7 +522,10 @@ namespace Emby.Server.Implementations.Session
                 deviceName = "Network Device";
             }
 
-            var deviceOptions = _deviceManager.GetDeviceOptions(deviceId);
+            var deviceOptions = _deviceManager.GetDeviceOptions(deviceId) ?? new()
+            {
+                DeviceId = deviceId
+            };
             if (string.IsNullOrEmpty(deviceOptions.CustomName))
             {
                 sessionInfo.DeviceName = deviceName;
@@ -1076,6 +1093,42 @@ namespace Emby.Server.Implementations.Session
             return session;
         }
 
+        private SessionInfoDto ToSessionInfoDto(SessionInfo sessionInfo)
+        {
+            return new SessionInfoDto
+            {
+                PlayState = sessionInfo.PlayState,
+                AdditionalUsers = sessionInfo.AdditionalUsers,
+                Capabilities = _deviceManager.ToClientCapabilitiesDto(sessionInfo.Capabilities),
+                RemoteEndPoint = sessionInfo.RemoteEndPoint,
+                PlayableMediaTypes = sessionInfo.PlayableMediaTypes,
+                Id = sessionInfo.Id,
+                UserId = sessionInfo.UserId,
+                UserName = sessionInfo.UserName,
+                Client = sessionInfo.Client,
+                LastActivityDate = sessionInfo.LastActivityDate,
+                LastPlaybackCheckIn = sessionInfo.LastPlaybackCheckIn,
+                LastPausedDate = sessionInfo.LastPausedDate,
+                DeviceName = sessionInfo.DeviceName,
+                DeviceType = sessionInfo.DeviceType,
+                NowPlayingItem = sessionInfo.NowPlayingItem,
+                NowViewingItem = sessionInfo.NowViewingItem,
+                DeviceId = sessionInfo.DeviceId,
+                ApplicationVersion = sessionInfo.ApplicationVersion,
+                TranscodingInfo = sessionInfo.TranscodingInfo,
+                IsActive = sessionInfo.IsActive,
+                SupportsMediaControl = sessionInfo.SupportsMediaControl,
+                SupportsRemoteControl = sessionInfo.SupportsRemoteControl,
+                NowPlayingQueue = sessionInfo.NowPlayingQueue,
+                NowPlayingQueueFullItems = sessionInfo.NowPlayingQueueFullItems,
+                HasCustomDeviceName = sessionInfo.HasCustomDeviceName,
+                PlaylistItemId = sessionInfo.PlaylistItemId,
+                ServerId = sessionInfo.ServerId,
+                UserPrimaryImageTag = sessionInfo.UserPrimaryImageTag,
+                SupportedCommands = sessionInfo.SupportedCommands
+            };
+        }
+
         /// <inheritdoc />
         public Task SendMessageCommand(string controllingSessionId, string sessionId, MessageCommand command, CancellationToken cancellationToken)
         {
@@ -1393,7 +1446,7 @@ namespace Emby.Server.Implementations.Session
                     UserName = user.Username
                 };
 
-                session.AdditionalUsers = [..session.AdditionalUsers, newUser];
+                session.AdditionalUsers = [.. session.AdditionalUsers, newUser];
             }
         }
 
@@ -1505,7 +1558,7 @@ namespace Emby.Server.Implementations.Session
             var returnResult = new AuthenticationResult
             {
                 User = _userManager.GetUserDto(user, request.RemoteEndPoint),
-                SessionInfo = session,
+                SessionInfo = ToSessionInfoDto(session),
                 AccessToken = token,
                 ServerId = _appHost.SystemId
             };
@@ -1800,6 +1853,105 @@ namespace Emby.Server.Implementations.Session
             return await GetSessionByAuthenticationToken(items[0], deviceId, remoteEndpoint, null).ConfigureAwait(false);
         }
 
+        /// <inheritdoc/>
+        public IReadOnlyList<SessionInfoDto> GetSessions(
+            Guid userId,
+            string deviceId,
+            int? activeWithinSeconds,
+            Guid? controllableUserToCheck,
+            bool isApiKey)
+        {
+            var result = Sessions;
+            if (!string.IsNullOrEmpty(deviceId))
+            {
+                result = result.Where(i => string.Equals(i.DeviceId, deviceId, StringComparison.OrdinalIgnoreCase));
+            }
+
+            var userCanControlOthers = false;
+            var userIsAdmin = false;
+            User user = null;
+
+            if (isApiKey)
+            {
+                userCanControlOthers = true;
+                userIsAdmin = true;
+            }
+            else if (!userId.IsEmpty())
+            {
+                user = _userManager.GetUserById(userId);
+                if (user is not null)
+                {
+                    userCanControlOthers = user.HasPermission(PermissionKind.EnableRemoteControlOfOtherUsers);
+                    userIsAdmin = user.HasPermission(PermissionKind.IsAdministrator);
+                }
+                else
+                {
+                    return [];
+                }
+            }
+
+            if (!controllableUserToCheck.IsNullOrEmpty())
+            {
+                result = result.Where(i => i.SupportsRemoteControl);
+
+                var controlledUser = _userManager.GetUserById(controllableUserToCheck.Value);
+                if (controlledUser is null)
+                {
+                    return [];
+                }
+
+                if (!controlledUser.HasPermission(PermissionKind.EnableSharedDeviceControl))
+                {
+                    // Controlled user has device sharing disabled
+                    result = result.Where(i => !i.UserId.IsEmpty());
+                }
+
+                if (!userCanControlOthers)
+                {
+                    // User cannot control other user's sessions, validate user id.
+                    result = result.Where(i => i.UserId.IsEmpty() || i.ContainsUser(userId));
+                }
+
+                result = result.Where(i =>
+                {
+                    if (isApiKey)
+                    {
+                        return true;
+                    }
+
+                    if (user is null)
+                    {
+                        return false;
+                    }
+
+                    return string.IsNullOrWhiteSpace(i.DeviceId) || _deviceManager.CanAccessDevice(user, i.DeviceId);
+                });
+            }
+            else if (!userIsAdmin)
+            {
+                // Request isn't from administrator, limit to "own" sessions.
+                result = result.Where(i => i.UserId.IsEmpty() || i.ContainsUser(userId));
+            }
+
+            if (!userIsAdmin)
+            {
+                // Don't report acceleration type for non-admin users.
+                result = result.Select(r =>
+                {
+                    r.TranscodingInfo.HardwareAccelerationType = HardwareAccelerationType.none;
+                    return r;
+                });
+            }
+
+            if (activeWithinSeconds.HasValue && activeWithinSeconds.Value > 0)
+            {
+                var minActiveDate = DateTime.UtcNow.AddSeconds(0 - activeWithinSeconds.Value);
+                result = result.Where(i => i.LastActivityDate >= minActiveDate);
+            }
+
+            return result.Select(ToSessionInfoDto).ToList();
+        }
+
         /// <inheritdoc />
         public Task SendMessageToAdminSessions<T>(SessionMessageType name, T data, CancellationToken cancellationToken)
         {

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

@@ -1,15 +1,13 @@
 using System;
 using System.ComponentModel.DataAnnotations;
 using System.Threading.Tasks;
-using Jellyfin.Api.Constants;
 using Jellyfin.Api.Helpers;
 using Jellyfin.Data.Dtos;
-using Jellyfin.Data.Entities.Security;
 using Jellyfin.Data.Queries;
 using MediaBrowser.Common.Api;
 using MediaBrowser.Controller.Devices;
 using MediaBrowser.Controller.Session;
-using MediaBrowser.Model.Devices;
+using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Querying;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
@@ -47,7 +45,7 @@ public class DevicesController : BaseJellyfinApiController
     /// <returns>An <see cref="OkResult"/> containing the list of devices.</returns>
     [HttpGet]
     [ProducesResponseType(StatusCodes.Status200OK)]
-    public ActionResult<QueryResult<DeviceInfo>> GetDevices([FromQuery] Guid? userId)
+    public ActionResult<QueryResult<DeviceInfoDto>> GetDevices([FromQuery] Guid? userId)
     {
         userId = RequestHelpers.GetUserId(User, userId);
         return _deviceManager.GetDevicesForUser(userId);
@@ -63,7 +61,7 @@ public class DevicesController : BaseJellyfinApiController
     [HttpGet("Info")]
     [ProducesResponseType(StatusCodes.Status200OK)]
     [ProducesResponseType(StatusCodes.Status404NotFound)]
-    public ActionResult<DeviceInfo> GetDeviceInfo([FromQuery, Required] string id)
+    public ActionResult<DeviceInfoDto> GetDeviceInfo([FromQuery, Required] string id)
     {
         var deviceInfo = _deviceManager.GetDevice(id);
         if (deviceInfo is null)
@@ -84,7 +82,7 @@ public class DevicesController : BaseJellyfinApiController
     [HttpGet("Options")]
     [ProducesResponseType(StatusCodes.Status200OK)]
     [ProducesResponseType(StatusCodes.Status404NotFound)]
-    public ActionResult<DeviceOptions> GetDeviceOptions([FromQuery, Required] string id)
+    public ActionResult<DeviceOptionsDto> GetDeviceOptions([FromQuery, Required] string id)
     {
         var deviceInfo = _deviceManager.GetDeviceOptions(id);
         if (deviceInfo is null)

+ 51 - 28
Jellyfin.Api/Controllers/DynamicHlsController.cs

@@ -40,8 +40,8 @@ namespace Jellyfin.Api.Controllers;
 [Authorize]
 public class DynamicHlsController : BaseJellyfinApiController
 {
-    private const string DefaultVodEncoderPreset = "veryfast";
-    private const string DefaultEventEncoderPreset = "superfast";
+    private const EncoderPreset DefaultVodEncoderPreset = EncoderPreset.veryfast;
+    private const EncoderPreset DefaultEventEncoderPreset = EncoderPreset.superfast;
     private const TranscodingJobType TranscodingJobType = MediaBrowser.Controller.MediaEncoding.TranscodingJobType.Hls;
 
     private readonly Version _minFFmpegFlacInMp4 = new Version(6, 0);
@@ -158,6 +158,7 @@ public class DynamicHlsController : BaseJellyfinApiController
     /// <param name="maxHeight">Optional. The max height.</param>
     /// <param name="enableSubtitlesInManifest">Optional. Whether to enable subtitles in the manifest.</param>
     /// <param name="enableAudioVbrEncoding">Optional. Whether to enable Audio Encoding.</param>
+    /// <param name="alwaysBurnInSubtitleWhenTranscoding">Whether to always burn in subtitles when transcoding.</param>
     /// <response code="200">Hls live stream retrieved.</response>
     /// <returns>A <see cref="FileResult"/> containing the hls file.</returns>
     [HttpGet("Videos/{itemId}/live.m3u8")]
@@ -216,7 +217,8 @@ public class DynamicHlsController : BaseJellyfinApiController
         [FromQuery] int? maxWidth,
         [FromQuery] int? maxHeight,
         [FromQuery] bool? enableSubtitlesInManifest,
-        [FromQuery] bool enableAudioVbrEncoding = true)
+        [FromQuery] bool enableAudioVbrEncoding = true,
+        [FromQuery] bool alwaysBurnInSubtitleWhenTranscoding = false)
     {
         VideoRequestDto streamingRequest = new VideoRequestDto
         {
@@ -251,7 +253,7 @@ public class DynamicHlsController : BaseJellyfinApiController
             Height = height,
             VideoBitRate = videoBitRate,
             SubtitleStreamIndex = subtitleStreamIndex,
-            SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
+            SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.External,
             MaxRefFrames = maxRefFrames,
             MaxVideoBitDepth = maxVideoBitDepth,
             RequireAvc = requireAvc ?? false,
@@ -271,7 +273,8 @@ public class DynamicHlsController : BaseJellyfinApiController
             MaxHeight = maxHeight,
             MaxWidth = maxWidth,
             EnableSubtitlesInManifest = enableSubtitlesInManifest ?? true,
-            EnableAudioVbrEncoding = enableAudioVbrEncoding
+            EnableAudioVbrEncoding = enableAudioVbrEncoding,
+            AlwaysBurnInSubtitleWhenTranscoding = alwaysBurnInSubtitleWhenTranscoding
         };
 
         // CTS lifecycle is managed internally.
@@ -398,6 +401,7 @@ public class DynamicHlsController : BaseJellyfinApiController
     /// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param>
     /// <param name="enableTrickplay">Enable trickplay image playlists being added to master playlist.</param>
     /// <param name="enableAudioVbrEncoding">Whether to enable Audio Encoding.</param>
+    /// <param name="alwaysBurnInSubtitleWhenTranscoding">Whether to always burn in subtitles when transcoding.</param>
     /// <response code="200">Video stream returned.</response>
     /// <returns>A <see cref="FileResult"/> containing the playlist file.</returns>
     [HttpGet("Videos/{itemId}/master.m3u8")]
@@ -457,7 +461,8 @@ public class DynamicHlsController : BaseJellyfinApiController
         [FromQuery] Dictionary<string, string> streamOptions,
         [FromQuery] bool enableAdaptiveBitrateStreaming = true,
         [FromQuery] bool enableTrickplay = true,
-        [FromQuery] bool enableAudioVbrEncoding = true)
+        [FromQuery] bool enableAudioVbrEncoding = true,
+        [FromQuery] bool alwaysBurnInSubtitleWhenTranscoding = false)
     {
         var streamingRequest = new HlsVideoRequestDto
         {
@@ -493,7 +498,7 @@ public class DynamicHlsController : BaseJellyfinApiController
             MaxHeight = maxHeight,
             VideoBitRate = videoBitRate,
             SubtitleStreamIndex = subtitleStreamIndex,
-            SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
+            SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.External,
             MaxRefFrames = maxRefFrames,
             MaxVideoBitDepth = maxVideoBitDepth,
             RequireAvc = requireAvc ?? false,
@@ -512,7 +517,8 @@ public class DynamicHlsController : BaseJellyfinApiController
             StreamOptions = streamOptions,
             EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming,
             EnableTrickplay = enableTrickplay,
-            EnableAudioVbrEncoding = enableAudioVbrEncoding
+            EnableAudioVbrEncoding = enableAudioVbrEncoding,
+            AlwaysBurnInSubtitleWhenTranscoding = alwaysBurnInSubtitleWhenTranscoding
         };
 
         return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false);
@@ -663,7 +669,7 @@ public class DynamicHlsController : BaseJellyfinApiController
             Height = height,
             VideoBitRate = videoBitRate,
             SubtitleStreamIndex = subtitleStreamIndex,
-            SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
+            SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.External,
             MaxRefFrames = maxRefFrames,
             MaxVideoBitDepth = maxVideoBitDepth,
             RequireAvc = requireAvc ?? false,
@@ -681,7 +687,8 @@ public class DynamicHlsController : BaseJellyfinApiController
             Context = context ?? EncodingContext.Streaming,
             StreamOptions = streamOptions,
             EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming,
-            EnableAudioVbrEncoding = enableAudioVbrEncoding
+            EnableAudioVbrEncoding = enableAudioVbrEncoding,
+            AlwaysBurnInSubtitleWhenTranscoding = false
         };
 
         return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false);
@@ -741,6 +748,7 @@ public class DynamicHlsController : BaseJellyfinApiController
     /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
     /// <param name="streamOptions">Optional. The streaming options.</param>
     /// <param name="enableAudioVbrEncoding">Optional. Whether to enable Audio Encoding.</param>
+    /// <param name="alwaysBurnInSubtitleWhenTranscoding">Whether to always burn in subtitles when transcoding.</param>
     /// <response code="200">Video stream returned.</response>
     /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
     [HttpGet("Videos/{itemId}/main.m3u8")]
@@ -797,7 +805,8 @@ public class DynamicHlsController : BaseJellyfinApiController
         [FromQuery] int? videoStreamIndex,
         [FromQuery] EncodingContext? context,
         [FromQuery] Dictionary<string, string> streamOptions,
-        [FromQuery] bool enableAudioVbrEncoding = true)
+        [FromQuery] bool enableAudioVbrEncoding = true,
+        [FromQuery] bool alwaysBurnInSubtitleWhenTranscoding = false)
     {
         using var cancellationTokenSource = new CancellationTokenSource();
         var streamingRequest = new VideoRequestDto
@@ -834,7 +843,7 @@ public class DynamicHlsController : BaseJellyfinApiController
             MaxHeight = maxHeight,
             VideoBitRate = videoBitRate,
             SubtitleStreamIndex = subtitleStreamIndex,
-            SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
+            SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.External,
             MaxRefFrames = maxRefFrames,
             MaxVideoBitDepth = maxVideoBitDepth,
             RequireAvc = requireAvc ?? false,
@@ -851,7 +860,8 @@ public class DynamicHlsController : BaseJellyfinApiController
             VideoStreamIndex = videoStreamIndex,
             Context = context ?? EncodingContext.Streaming,
             StreamOptions = streamOptions,
-            EnableAudioVbrEncoding = enableAudioVbrEncoding
+            EnableAudioVbrEncoding = enableAudioVbrEncoding,
+            AlwaysBurnInSubtitleWhenTranscoding = alwaysBurnInSubtitleWhenTranscoding
         };
 
         return await GetVariantPlaylistInternal(streamingRequest, cancellationTokenSource)
@@ -1001,7 +1011,7 @@ public class DynamicHlsController : BaseJellyfinApiController
             Height = height,
             VideoBitRate = videoBitRate,
             SubtitleStreamIndex = subtitleStreamIndex,
-            SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
+            SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.External,
             MaxRefFrames = maxRefFrames,
             MaxVideoBitDepth = maxVideoBitDepth,
             RequireAvc = requireAvc ?? false,
@@ -1018,7 +1028,8 @@ public class DynamicHlsController : BaseJellyfinApiController
             VideoStreamIndex = videoStreamIndex,
             Context = context ?? EncodingContext.Streaming,
             StreamOptions = streamOptions,
-            EnableAudioVbrEncoding = enableAudioVbrEncoding
+            EnableAudioVbrEncoding = enableAudioVbrEncoding,
+            AlwaysBurnInSubtitleWhenTranscoding = false
         };
 
         return await GetVariantPlaylistInternal(streamingRequest, cancellationTokenSource)
@@ -1084,6 +1095,7 @@ public class DynamicHlsController : BaseJellyfinApiController
     /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
     /// <param name="streamOptions">Optional. The streaming options.</param>
     /// <param name="enableAudioVbrEncoding">Optional. Whether to enable Audio Encoding.</param>
+    /// <param name="alwaysBurnInSubtitleWhenTranscoding">Whether to always burn in subtitles when transcoding.</param>
     /// <response code="200">Video stream returned.</response>
     /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
     [HttpGet("Videos/{itemId}/hls1/{playlistId}/{segmentId}.{container}")]
@@ -1146,7 +1158,8 @@ public class DynamicHlsController : BaseJellyfinApiController
         [FromQuery] int? videoStreamIndex,
         [FromQuery] EncodingContext? context,
         [FromQuery] Dictionary<string, string> streamOptions,
-        [FromQuery] bool enableAudioVbrEncoding = true)
+        [FromQuery] bool enableAudioVbrEncoding = true,
+        [FromQuery] bool alwaysBurnInSubtitleWhenTranscoding = false)
     {
         var streamingRequest = new VideoRequestDto
         {
@@ -1185,7 +1198,7 @@ public class DynamicHlsController : BaseJellyfinApiController
             MaxHeight = maxHeight,
             VideoBitRate = videoBitRate,
             SubtitleStreamIndex = subtitleStreamIndex,
-            SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
+            SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.External,
             MaxRefFrames = maxRefFrames,
             MaxVideoBitDepth = maxVideoBitDepth,
             RequireAvc = requireAvc ?? false,
@@ -1202,7 +1215,8 @@ public class DynamicHlsController : BaseJellyfinApiController
             VideoStreamIndex = videoStreamIndex,
             Context = context ?? EncodingContext.Streaming,
             StreamOptions = streamOptions,
-            EnableAudioVbrEncoding = enableAudioVbrEncoding
+            EnableAudioVbrEncoding = enableAudioVbrEncoding,
+            AlwaysBurnInSubtitleWhenTranscoding = alwaysBurnInSubtitleWhenTranscoding
         };
 
         return await GetDynamicSegment(streamingRequest, segmentId)
@@ -1365,7 +1379,7 @@ public class DynamicHlsController : BaseJellyfinApiController
             Height = height,
             VideoBitRate = videoBitRate,
             SubtitleStreamIndex = subtitleStreamIndex,
-            SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
+            SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.External,
             MaxRefFrames = maxRefFrames,
             MaxVideoBitDepth = maxVideoBitDepth,
             RequireAvc = requireAvc ?? false,
@@ -1382,7 +1396,8 @@ public class DynamicHlsController : BaseJellyfinApiController
             VideoStreamIndex = videoStreamIndex,
             Context = context ?? EncodingContext.Streaming,
             StreamOptions = streamOptions,
-            EnableAudioVbrEncoding = enableAudioVbrEncoding
+            EnableAudioVbrEncoding = enableAudioVbrEncoding,
+            AlwaysBurnInSubtitleWhenTranscoding = false
         };
 
         return await GetDynamicSegment(streamingRequest, segmentId)
@@ -1797,10 +1812,11 @@ public class DynamicHlsController : BaseJellyfinApiController
 
         var args = "-codec:v:0 " + codec;
 
-        if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
-            || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
-            || string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase)
-            || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase))
+        var isActualOutputVideoCodecAv1 = string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase);
+        var isActualOutputVideoCodecHevc = string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
+                                           || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase);
+
+        if (isActualOutputVideoCodecHevc || isActualOutputVideoCodecAv1)
         {
             var requestedRange = state.GetRequestedRangeTypes(state.ActualOutputVideoCodec);
             var requestHasDOVI = requestedRange.Contains(VideoRangeType.DOVI.ToString(), StringComparison.OrdinalIgnoreCase);
@@ -1814,10 +1830,17 @@ public class DynamicHlsController : BaseJellyfinApiController
                     || (state.VideoStream.VideoRangeType == VideoRangeType.DOVIWithHLG && requestHasDOVIWithHLG)
                     || (state.VideoStream.VideoRangeType == VideoRangeType.DOVIWithSDR && requestHasDOVIWithSDR)))
             {
-                // Prefer dvh1 to dvhe
-                args += " -tag:v:0 dvh1 -strict -2";
+                if (isActualOutputVideoCodecHevc)
+                {
+                    // Prefer dvh1 to dvhe
+                    args += " -tag:v:0 dvh1 -strict -2";
+                }
+                else if (isActualOutputVideoCodecAv1)
+                {
+                    args += " -tag:v:0 dav1 -strict -2";
+                }
             }
-            else
+            else if (isActualOutputVideoCodecHevc)
             {
                 // Prefer hvc1 to hev1
                 args += " -tag:v:0 hvc1";
@@ -1885,7 +1908,7 @@ public class DynamicHlsController : BaseJellyfinApiController
 
         if (!string.IsNullOrEmpty(state.OutputVideoSync))
         {
-            args += " -vsync " + state.OutputVideoSync;
+            args += EncodingHelper.GetVideoSyncOption(state.OutputVideoSync, _mediaEncoder.EncoderVersion);
         }
 
         args += _encodingHelper.GetOutputFFlags(state);

+ 8 - 3
Jellyfin.Api/Controllers/MediaInfoController.cs

@@ -209,6 +209,7 @@ public class MediaInfoController : BaseJellyfinApiController
                     enableTranscoding.Value,
                     allowVideoStreamCopy.Value,
                     allowAudioStreamCopy.Value,
+                    playbackInfoDto?.AlwaysBurnInSubtitleWhenTranscoding ?? false,
                     Request.HttpContext.GetNormalizedRemoteIP());
             }
 
@@ -236,7 +237,8 @@ public class MediaInfoController : BaseJellyfinApiController
                         StartTimeTicks = startTimeTicks,
                         SubtitleStreamIndex = subtitleStreamIndex,
                         UserId = userId ?? Guid.Empty,
-                        OpenToken = mediaSource.OpenToken
+                        OpenToken = mediaSource.OpenToken,
+                        AlwaysBurnInSubtitleWhenTranscoding = playbackInfoDto?.AlwaysBurnInSubtitleWhenTranscoding ?? false
                     }).ConfigureAwait(false);
 
                 info.MediaSources = new[] { openStreamResult.MediaSource };
@@ -261,6 +263,7 @@ public class MediaInfoController : BaseJellyfinApiController
     /// <param name="openLiveStreamDto">The open live stream dto.</param>
     /// <param name="enableDirectPlay">Whether to enable direct play. Default: true.</param>
     /// <param name="enableDirectStream">Whether to enable direct stream. Default: true.</param>
+    /// <param name="alwaysBurnInSubtitleWhenTranscoding">Always burn-in subtitle when transcoding.</param>
     /// <response code="200">Media source opened.</response>
     /// <returns>A <see cref="Task"/> containing a <see cref="LiveStreamResponse"/>.</returns>
     [HttpPost("LiveStreams/Open")]
@@ -277,7 +280,8 @@ public class MediaInfoController : BaseJellyfinApiController
         [FromQuery] Guid? itemId,
         [FromBody] OpenLiveStreamDto? openLiveStreamDto,
         [FromQuery] bool? enableDirectPlay,
-        [FromQuery] bool? enableDirectStream)
+        [FromQuery] bool? enableDirectStream,
+        [FromQuery] bool? alwaysBurnInSubtitleWhenTranscoding)
     {
         userId ??= openLiveStreamDto?.UserId;
         userId = RequestHelpers.GetUserId(User, userId);
@@ -295,7 +299,8 @@ public class MediaInfoController : BaseJellyfinApiController
             DeviceProfile = openLiveStreamDto?.DeviceProfile,
             EnableDirectPlay = enableDirectPlay ?? openLiveStreamDto?.EnableDirectPlay ?? true,
             EnableDirectStream = enableDirectStream ?? openLiveStreamDto?.EnableDirectStream ?? true,
-            DirectPlayProtocols = openLiveStreamDto?.DirectPlayProtocols ?? new[] { MediaProtocol.Http }
+            DirectPlayProtocols = openLiveStreamDto?.DirectPlayProtocols ?? new[] { MediaProtocol.Http },
+            AlwaysBurnInSubtitleWhenTranscoding = alwaysBurnInSubtitleWhenTranscoding ?? openLiveStreamDto?.AlwaysBurnInSubtitleWhenTranscoding ?? false
         };
         return await _mediaInfoHelper.OpenMediaSource(HttpContext, request).ConfigureAwait(false);
     }

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

@@ -45,7 +45,7 @@ public class MediaSegmentsController : BaseJellyfinApiController
     [HttpGet("{itemId}")]
     [ProducesResponseType(StatusCodes.Status200OK)]
     [ProducesResponseType(StatusCodes.Status404NotFound)]
-    public async Task<ActionResult<QueryResult<MediaSegmentDto>>> GetSegmentsAsync(
+    public async Task<ActionResult<QueryResult<MediaSegmentDto>>> GetItemSegments(
         [FromRoute, Required] Guid itemId,
         [FromQuery] IEnumerable<MediaSegmentType>? includeSegmentTypes = null)
     {

+ 37 - 15
Jellyfin.Api/Controllers/PlaylistsController.cs

@@ -149,6 +149,37 @@ public class PlaylistsController : BaseJellyfinApiController
         return NoContent();
     }
 
+    /// <summary>
+    /// Get a playlist.
+    /// </summary>
+    /// <param name="playlistId">The playlist id.</param>
+    /// <response code="200">The playlist.</response>
+    /// <response code="404">Playlist not found.</response>
+    /// <returns>
+    /// A <see cref="Playlist"/> objects.
+    /// </returns>
+    [HttpGet("{playlistId}")]
+    [ProducesResponseType(StatusCodes.Status200OK)]
+    [ProducesResponseType(StatusCodes.Status404NotFound)]
+    public ActionResult<PlaylistDto> GetPlaylist(
+        [FromRoute, Required] Guid playlistId)
+    {
+        var userId = User.GetUserId();
+
+        var playlist = _playlistManager.GetPlaylistForUser(playlistId, userId);
+        if (playlist is null)
+        {
+            return NotFound("Playlist not found");
+        }
+
+        return new PlaylistDto()
+        {
+            Shares = playlist.Shares,
+            OpenAccess = playlist.OpenAccess,
+            ItemIds = playlist.GetManageableItems().Select(t => t.Item2.Id).ToList()
+        };
+    }
+
     /// <summary>
     /// Get a playlist's users.
     /// </summary>
@@ -467,32 +498,23 @@ public class PlaylistsController : BaseJellyfinApiController
         [FromQuery] int? imageTypeLimit,
         [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
     {
-        userId = RequestHelpers.GetUserId(User, userId);
-        var playlist = _playlistManager.GetPlaylistForUser(playlistId, userId.Value);
+        var callingUserId = userId ?? User.GetUserId();
+        var playlist = _playlistManager.GetPlaylistForUser(playlistId, callingUserId);
         if (playlist is null)
         {
             return NotFound("Playlist not found");
         }
 
         var isPermitted = playlist.OpenAccess
-            || playlist.OwnerUserId.Equals(userId.Value)
-            || playlist.Shares.Any(s => s.UserId.Equals(userId.Value));
+            || playlist.OwnerUserId.Equals(callingUserId)
+            || playlist.Shares.Any(s => s.UserId.Equals(callingUserId));
 
         if (!isPermitted)
         {
             return Forbid();
         }
 
-        var user = userId.IsNullOrEmpty()
-            ? null
-            : _userManager.GetUserById(userId.Value);
-        var item = _libraryManager.GetItemById<Playlist>(playlistId, user);
-        if (item is null)
-        {
-            return NotFound();
-        }
-
-        var items = item.GetManageableItems().ToArray();
+        var items = playlist.GetManageableItems().ToArray();
         var count = items.Length;
         if (startIndex.HasValue)
         {
@@ -507,7 +529,7 @@ public class PlaylistsController : BaseJellyfinApiController
         var dtoOptions = new DtoOptions { Fields = fields }
             .AddClientFields(User)
             .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
-
+        var user = _userManager.GetUserById(callingUserId);
         var dtos = _dtoService.GetBaseItemDtos(items.Select(i => i.Item2).ToList(), dtoOptions, user);
         for (int index = 0; index < dtos.Count; index++)
         {

+ 11 - 64
Jellyfin.Api/Controllers/SessionController.cs

@@ -1,21 +1,17 @@
 using System;
 using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
-using System.Linq;
 using System.Threading;
 using System.Threading.Tasks;
-using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.Helpers;
 using Jellyfin.Api.ModelBinders;
-using Jellyfin.Api.Models.SessionDtos;
 using Jellyfin.Data.Enums;
-using Jellyfin.Extensions;
 using MediaBrowser.Common.Api;
-using MediaBrowser.Controller.Devices;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Session;
 using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Session;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Http;
@@ -31,22 +27,18 @@ public class SessionController : BaseJellyfinApiController
 {
     private readonly ISessionManager _sessionManager;
     private readonly IUserManager _userManager;
-    private readonly IDeviceManager _deviceManager;
 
     /// <summary>
     /// Initializes a new instance of the <see cref="SessionController"/> class.
     /// </summary>
     /// <param name="sessionManager">Instance of <see cref="ISessionManager"/> interface.</param>
     /// <param name="userManager">Instance of <see cref="IUserManager"/> interface.</param>
-    /// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param>
     public SessionController(
         ISessionManager sessionManager,
-        IUserManager userManager,
-        IDeviceManager deviceManager)
+        IUserManager userManager)
     {
         _sessionManager = sessionManager;
         _userManager = userManager;
-        _deviceManager = deviceManager;
     }
 
     /// <summary>
@@ -56,67 +48,22 @@ public class SessionController : BaseJellyfinApiController
     /// <param name="deviceId">Filter by device Id.</param>
     /// <param name="activeWithinSeconds">Optional. Filter by sessions that were active in the last n seconds.</param>
     /// <response code="200">List of sessions returned.</response>
-    /// <returns>An <see cref="IEnumerable{SessionInfo}"/> with the available sessions.</returns>
+    /// <returns>An <see cref="IReadOnlyList{SessionInfoDto}"/> with the available sessions.</returns>
     [HttpGet("Sessions")]
     [Authorize]
     [ProducesResponseType(StatusCodes.Status200OK)]
-    public ActionResult<IEnumerable<SessionInfo>> GetSessions(
+    public ActionResult<IReadOnlyList<SessionInfoDto>> GetSessions(
         [FromQuery] Guid? controllableByUserId,
         [FromQuery] string? deviceId,
         [FromQuery] int? activeWithinSeconds)
     {
-        var result = _sessionManager.Sessions;
-
-        if (!string.IsNullOrEmpty(deviceId))
-        {
-            result = result.Where(i => string.Equals(i.DeviceId, deviceId, StringComparison.OrdinalIgnoreCase));
-        }
-
-        if (!controllableByUserId.IsNullOrEmpty())
-        {
-            result = result.Where(i => i.SupportsRemoteControl);
-
-            var user = _userManager.GetUserById(controllableByUserId.Value);
-            if (user is null)
-            {
-                return NotFound();
-            }
-
-            if (!user.HasPermission(PermissionKind.EnableRemoteControlOfOtherUsers))
-            {
-                // User cannot control other user's sessions, validate user id.
-                result = result.Where(i => i.UserId.IsEmpty() || i.ContainsUser(RequestHelpers.GetUserId(User, controllableByUserId)));
-            }
-
-            if (!user.HasPermission(PermissionKind.EnableSharedDeviceControl))
-            {
-                result = result.Where(i => !i.UserId.IsEmpty());
-            }
-
-            result = result.Where(i =>
-            {
-                if (!string.IsNullOrWhiteSpace(i.DeviceId))
-                {
-                    if (!_deviceManager.CanAccessDevice(user, i.DeviceId))
-                    {
-                        return false;
-                    }
-                }
-
-                return true;
-            });
-        }
-        else if (!User.IsInRole(UserRoles.Administrator))
-        {
-            // Request isn't from administrator, limit to "own" sessions.
-            result = result.Where(i => i.UserId.IsEmpty() || i.ContainsUser(User.GetUserId()));
-        }
-
-        if (activeWithinSeconds.HasValue && activeWithinSeconds.Value > 0)
-        {
-            var minActiveDate = DateTime.UtcNow.AddSeconds(0 - activeWithinSeconds.Value);
-            result = result.Where(i => i.LastActivityDate >= minActiveDate);
-        }
+        Guid? controllableUserToCheck = controllableByUserId is null ? null : RequestHelpers.GetUserId(User, controllableByUserId);
+        var result = _sessionManager.GetSessions(
+            User.GetUserId(),
+            deviceId,
+            activeWithinSeconds,
+            controllableUserToCheck,
+            User.GetIsApiKey());
 
         return Ok(result);
     }

+ 4 - 3
Jellyfin.Api/Controllers/TrickplayController.cs

@@ -80,7 +80,7 @@ public class TrickplayController : BaseJellyfinApiController
     [ProducesResponseType(StatusCodes.Status200OK)]
     [ProducesResponseType(StatusCodes.Status404NotFound)]
     [ProducesImageFile]
-    public ActionResult GetTrickplayTileImage(
+    public async Task<ActionResult> GetTrickplayTileImage(
         [FromRoute, Required] Guid itemId,
         [FromRoute, Required] int width,
         [FromRoute, Required] int index,
@@ -92,8 +92,9 @@ public class TrickplayController : BaseJellyfinApiController
             return NotFound();
         }
 
-        var path = _trickplayManager.GetTrickplayTilePath(item, width, index);
-        if (System.IO.File.Exists(path))
+        var saveWithMedia = _libraryManager.GetLibraryOptions(item).SaveTrickplayWithMedia;
+        var path = await _trickplayManager.GetTrickplayTilePathAsync(item, width, index, saveWithMedia).ConfigureAwait(false);
+        if (!string.IsNullOrEmpty(path) && System.IO.File.Exists(path))
         {
             Response.Headers.ContentDisposition = "attachment";
             return PhysicalFile(path, MediaTypeNames.Image.Jpeg);

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

@@ -160,6 +160,7 @@ public class UniversalAudioController : BaseJellyfinApiController
                 true,
                 true,
                 true,
+                false,
                 Request.HttpContext.GetNormalizedRemoteIP());
         }
 

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

@@ -482,7 +482,7 @@ public class VideosController : BaseJellyfinApiController
 
         // Need to start ffmpeg (because media can't be returned directly)
         var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
-        var ffmpegCommandLineArguments = _encodingHelper.GetProgressiveVideoFullCommandLine(state, encodingOptions, "superfast");
+        var ffmpegCommandLineArguments = _encodingHelper.GetProgressiveVideoFullCommandLine(state, encodingOptions, EncoderPreset.superfast);
         return await FileStreamResponseHelpers.GetTranscodedFile(
             state,
             isHeadRequest,

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

@@ -304,6 +304,8 @@ public class DynamicHlsHelper
 
         AppendPlaylistCodecsField(playlistBuilder, state);
 
+        AppendPlaylistSupplementalCodecsField(playlistBuilder, state);
+
         AppendPlaylistResolutionField(playlistBuilder, state);
 
         AppendPlaylistFramerateField(playlistBuilder, state);
@@ -406,6 +408,48 @@ public class DynamicHlsHelper
         }
     }
 
+    /// <summary>
+    /// Appends a SUPPLEMENTAL-CODECS field containing formatted strings of
+    /// the active streams output Dolby Vision Videos.
+    /// </summary>
+    /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/>
+    /// <seealso cref="GetPlaylistVideoCodecs(StreamState, string, int)"/>
+    /// <param name="builder">StringBuilder to append the field to.</param>
+    /// <param name="state">StreamState of the current stream.</param>
+    private void AppendPlaylistSupplementalCodecsField(StringBuilder builder, StreamState state)
+    {
+        // Dolby Vision currently cannot exist when transcoding
+        if (!EncodingHelper.IsCopyCodec(state.OutputVideoCodec))
+        {
+            return;
+        }
+
+        var dvProfile = state.VideoStream.DvProfile;
+        var dvLevel = state.VideoStream.DvLevel;
+        var dvRangeString = state.VideoStream.VideoRangeType switch
+        {
+            VideoRangeType.DOVIWithHDR10 => "db1p",
+            VideoRangeType.DOVIWithHLG => "db4h",
+            _ => string.Empty
+        };
+
+        if (dvProfile is null || dvLevel is null || string.IsNullOrEmpty(dvRangeString))
+        {
+            return;
+        }
+
+        var dvFourCc = string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase) ? "dav1" : "dvh1";
+        builder.Append(",SUPPLEMENTAL-CODECS=\"")
+            .Append(dvFourCc)
+            .Append('.')
+            .Append(dvProfile.Value.ToString("D2", CultureInfo.InvariantCulture))
+            .Append('.')
+            .Append(dvLevel.Value.ToString("D2", CultureInfo.InvariantCulture))
+            .Append('/')
+            .Append(dvRangeString)
+            .Append('"');
+    }
+
     /// <summary>
     /// Appends a RESOLUTION field containing the resolution of the output stream.
     /// </summary>
@@ -738,7 +782,7 @@ public class DynamicHlsHelper
         {
             var width = state.VideoStream.Width ?? 0;
             var height = state.VideoStream.Height ?? 0;
-            var framerate = state.VideoStream.AverageFrameRate ?? 30;
+            var framerate = state.VideoStream.ReferenceFrameRate ?? 30;
             var bitDepth = state.VideoStream.BitDepth ?? 8;
             return HlsCodecStringHelpers.GetVp9String(
                 width,

+ 14 - 1
Jellyfin.Api/Helpers/MediaInfoHelper.cs

@@ -156,6 +156,7 @@ public class MediaInfoHelper
     /// <param name="enableTranscoding">Enable transcoding.</param>
     /// <param name="allowVideoStreamCopy">Allow video stream copy.</param>
     /// <param name="allowAudioStreamCopy">Allow audio stream copy.</param>
+    /// <param name="alwaysBurnInSubtitleWhenTranscoding">Always burn-in subtitle when transcoding.</param>
     /// <param name="ipAddress">Requesting IP address.</param>
     public void SetDeviceSpecificData(
         BaseItem item,
@@ -175,6 +176,7 @@ public class MediaInfoHelper
         bool enableTranscoding,
         bool allowVideoStreamCopy,
         bool allowAudioStreamCopy,
+        bool alwaysBurnInSubtitleWhenTranscoding,
         IPAddress ipAddress)
     {
         var streamBuilder = new StreamBuilder(_mediaEncoder, _logger);
@@ -188,7 +190,8 @@ public class MediaInfoHelper
             Profile = profile,
             MaxAudioChannels = maxAudioChannels,
             AllowAudioStreamCopy = allowAudioStreamCopy,
-            AllowVideoStreamCopy = allowVideoStreamCopy
+            AllowVideoStreamCopy = allowVideoStreamCopy,
+            AlwaysBurnInSubtitleWhenTranscoding = alwaysBurnInSubtitleWhenTranscoding,
         };
 
         if (string.Equals(mediaSourceId, mediaSource.Id, StringComparison.OrdinalIgnoreCase))
@@ -290,6 +293,10 @@ public class MediaInfoHelper
                 mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false";
                 mediaSource.TranscodingContainer = streamInfo.Container;
                 mediaSource.TranscodingSubProtocol = streamInfo.SubProtocol;
+                if (streamInfo.AlwaysBurnInSubtitleWhenTranscoding)
+                {
+                    mediaSource.TranscodingUrl += "&alwaysBurnInSubtitleWhenTranscoding=true";
+                }
             }
             else
             {
@@ -307,6 +314,11 @@ public class MediaInfoHelper
                     {
                         mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false";
                     }
+
+                    if (streamInfo.AlwaysBurnInSubtitleWhenTranscoding)
+                    {
+                        mediaSource.TranscodingUrl += "&alwaysBurnInSubtitleWhenTranscoding=true";
+                    }
                 }
             }
 
@@ -420,6 +432,7 @@ public class MediaInfoHelper
                 true,
                 true,
                 true,
+                request.AlwaysBurnInSubtitleWhenTranscoding,
                 httpContext.GetNormalizedRemoteIP());
         }
         else

+ 13 - 20
Jellyfin.Api/Helpers/StreamingHelpers.cs

@@ -142,28 +142,15 @@ public static class StreamingHelpers
         }
         else
         {
-            // Enforce more restrictive transcoding profile for LiveTV due to compatability reasons
-            // Cap the MaxStreamingBitrate to 30Mbps, because we are unable to reliably probe source bitrate,
-            // which will cause the client to request extremely high bitrate that may fail the player/encoder
-            streamingRequest.VideoBitRate = streamingRequest.VideoBitRate > 30000000 ? 30000000 : streamingRequest.VideoBitRate;
-
-            if (streamingRequest.SegmentContainer is not null)
-            {
-                // Remove all fmp4 transcoding profiles, because it causes playback error and/or A/V sync issues
-                // Notably: Some channels won't play on FireFox and LG webOS
-                // Some channels from HDHomerun will experience A/V sync issues
-                streamingRequest.SegmentContainer = "ts";
-                streamingRequest.VideoCodec = "h264";
-                streamingRequest.AudioCodec = "aac";
-                state.SupportedVideoCodecs = ["h264"];
-                state.Request.VideoCodec = "h264";
-                state.SupportedAudioCodecs = ["aac"];
-                state.Request.AudioCodec = "aac";
-            }
-
             var liveStreamInfo = await mediaSourceManager.GetLiveStreamWithDirectStreamProvider(streamingRequest.LiveStreamId, cancellationToken).ConfigureAwait(false);
             mediaSource = liveStreamInfo.Item1;
             state.DirectStreamProvider = liveStreamInfo.Item2;
+
+            // Cap the max bitrate when it is too high. This is usually due to ffmpeg is unable to probe the source liveTV streams' bitrate.
+            if (mediaSource.FallbackMaxStreamingBitrate is not null && streamingRequest.VideoBitRate is not null)
+            {
+                streamingRequest.VideoBitRate = Math.Min(streamingRequest.VideoBitRate.Value, mediaSource.FallbackMaxStreamingBitrate.Value);
+            }
         }
 
         var encodingOptions = serverConfigurationManager.GetEncodingOptions();
@@ -232,11 +219,17 @@ public static class StreamingHelpers
                 }
                 else
                 {
+                    var h264EquivalentBitrate = EncodingHelper.ScaleBitrate(
+                        state.OutputVideoBitrate.Value,
+                        state.ActualOutputVideoCodec,
+                        "h264");
                     var resolution = ResolutionNormalizer.Normalize(
                         state.VideoStream?.BitRate,
                         state.OutputVideoBitrate.Value,
+                        h264EquivalentBitrate,
                         state.VideoRequest.MaxWidth,
-                        state.VideoRequest.MaxHeight);
+                        state.VideoRequest.MaxHeight,
+                        state.TargetFramerate);
 
                     state.VideoRequest.MaxWidth = resolution.MaxWidth;
                     state.VideoRequest.MaxHeight = resolution.MaxHeight;

+ 5 - 0
Jellyfin.Api/Models/MediaInfoDtos/OpenLiveStreamDto.cs

@@ -65,6 +65,11 @@ public class OpenLiveStreamDto
     /// </summary>
     public bool? EnableDirectStream { get; set; }
 
+    /// <summary>
+    /// Gets or sets a value indicating whether always burn in subtitles when transcoding.
+    /// </summary>
+    public bool? AlwaysBurnInSubtitleWhenTranscoding { get; set; }
+
     /// <summary>
     /// Gets or sets the device profile.
     /// </summary>

+ 5 - 0
Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs

@@ -82,4 +82,9 @@ public class PlaybackInfoDto
     /// Gets or sets a value indicating whether to auto open the live stream.
     /// </summary>
     public bool? AutoOpenLiveStream { get; set; }
+
+    /// <summary>
+    /// Gets or sets a value indicating whether always burn in subtitles when transcoding.
+    /// </summary>
+    public bool? AlwaysBurnInSubtitleWhenTranscoding { get; set; }
 }

+ 16 - 17
Jellyfin.Data/Dtos/DeviceOptionsDto.cs

@@ -1,23 +1,22 @@
-namespace Jellyfin.Data.Dtos
+namespace Jellyfin.Data.Dtos;
+
+/// <summary>
+/// A dto representing custom options for a device.
+/// </summary>
+public class DeviceOptionsDto
 {
     /// <summary>
-    /// A dto representing custom options for a device.
+    /// Gets or sets the id.
     /// </summary>
-    public class DeviceOptionsDto
-    {
-        /// <summary>
-        /// Gets or sets the id.
-        /// </summary>
-        public int Id { get; set; }
+    public int Id { get; set; }
 
-        /// <summary>
-        /// Gets or sets the device id.
-        /// </summary>
-        public string? DeviceId { get; set; }
+    /// <summary>
+    /// Gets or sets the device id.
+    /// </summary>
+    public string? DeviceId { get; set; }
 
-        /// <summary>
-        /// Gets or sets the custom name.
-        /// </summary>
-        public string? CustomName { get; set; }
-    }
+    /// <summary>
+    /// Gets or sets the custom name.
+    /// </summary>
+    public string? CustomName { get; set; }
 }

+ 70 - 19
Jellyfin.Server.Implementations/Devices/DeviceManager.cs

@@ -3,6 +3,7 @@ using System.Collections.Concurrent;
 using System.Collections.Generic;
 using System.Linq;
 using System.Threading.Tasks;
+using Jellyfin.Data.Dtos;
 using Jellyfin.Data.Entities;
 using Jellyfin.Data.Entities.Security;
 using Jellyfin.Data.Enums;
@@ -13,6 +14,7 @@ using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Devices;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Model.Devices;
+using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Querying;
 using MediaBrowser.Model.Session;
 using Microsoft.EntityFrameworkCore;
@@ -68,7 +70,7 @@ namespace Jellyfin.Server.Implementations.Devices
         }
 
         /// <inheritdoc />
-        public async Task UpdateDeviceOptions(string deviceId, string deviceName)
+        public async Task UpdateDeviceOptions(string deviceId, string? deviceName)
         {
             DeviceOptions? deviceOptions;
             var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
@@ -105,29 +107,37 @@ namespace Jellyfin.Server.Implementations.Devices
         }
 
         /// <inheritdoc />
-        public DeviceOptions GetDeviceOptions(string deviceId)
+        public DeviceOptionsDto? GetDeviceOptions(string deviceId)
         {
-            _deviceOptions.TryGetValue(deviceId, out var deviceOptions);
+            if (_deviceOptions.TryGetValue(deviceId, out var deviceOptions))
+            {
+                return ToDeviceOptionsDto(deviceOptions);
+            }
 
-            return deviceOptions ?? new DeviceOptions(deviceId);
+            return null;
         }
 
         /// <inheritdoc />
-        public ClientCapabilities GetCapabilities(string deviceId)
+        public ClientCapabilities GetCapabilities(string? deviceId)
         {
+            if (deviceId is null)
+            {
+                return new();
+            }
+
             return _capabilitiesMap.TryGetValue(deviceId, out ClientCapabilities? result)
                 ? result
-                : new ClientCapabilities();
+                : new();
         }
 
         /// <inheritdoc />
-        public DeviceInfo? GetDevice(string id)
+        public DeviceInfoDto? GetDevice(string id)
         {
             var device = _devices.Values.Where(d => d.DeviceId == id).OrderByDescending(d => d.DateLastActivity).FirstOrDefault();
             _deviceOptions.TryGetValue(id, out var deviceOption);
 
             var deviceInfo = device is null ? null : ToDeviceInfo(device, deviceOption);
-            return deviceInfo;
+            return deviceInfo is null ? null : ToDeviceInfoDto(deviceInfo);
         }
 
         /// <inheritdoc />
@@ -135,8 +145,8 @@ namespace Jellyfin.Server.Implementations.Devices
         {
             IEnumerable<Device> devices = _devices.Values
                 .Where(device => !query.UserId.HasValue || device.UserId.Equals(query.UserId.Value))
-                .Where(device => query.DeviceId == null || device.DeviceId == query.DeviceId)
-                .Where(device => query.AccessToken == null || device.AccessToken == query.AccessToken)
+                .Where(device => query.DeviceId is null || device.DeviceId == query.DeviceId)
+                .Where(device => query.AccessToken is null || device.AccessToken == query.AccessToken)
                 .OrderBy(d => d.Id)
                 .ToList();
             var count = devices.Count();
@@ -166,7 +176,7 @@ namespace Jellyfin.Server.Implementations.Devices
         }
 
         /// <inheritdoc />
-        public QueryResult<DeviceInfo> GetDevicesForUser(Guid? userId)
+        public QueryResult<DeviceInfoDto> GetDevicesForUser(Guid? userId)
         {
             IEnumerable<Device> devices = _devices.Values
                 .OrderByDescending(d => d.DateLastActivity)
@@ -187,9 +197,11 @@ namespace Jellyfin.Server.Implementations.Devices
                 {
                     _deviceOptions.TryGetValue(device.DeviceId, out var option);
                     return ToDeviceInfo(device, option);
-                }).ToArray();
+                })
+                .Select(ToDeviceInfoDto)
+                .ToArray();
 
-            return new QueryResult<DeviceInfo>(array);
+            return new QueryResult<DeviceInfoDto>(array);
         }
 
         /// <inheritdoc />
@@ -235,13 +247,9 @@ namespace Jellyfin.Server.Implementations.Devices
         private DeviceInfo ToDeviceInfo(Device authInfo, DeviceOptions? options = null)
         {
             var caps = GetCapabilities(authInfo.DeviceId);
-            var user = _userManager.GetUserById(authInfo.UserId);
-            if (user is null)
-            {
-                throw new ResourceNotFoundException("User with UserId " + authInfo.UserId + " not found");
-            }
+            var user = _userManager.GetUserById(authInfo.UserId) ?? throw new ResourceNotFoundException("User with UserId " + authInfo.UserId + " not found");
 
-            return new DeviceInfo
+            return new()
             {
                 AppName = authInfo.AppName,
                 AppVersion = authInfo.AppVersion,
@@ -254,5 +262,48 @@ namespace Jellyfin.Server.Implementations.Devices
                 CustomName = options?.CustomName,
             };
         }
+
+        private DeviceOptionsDto ToDeviceOptionsDto(DeviceOptions options)
+        {
+            return new()
+            {
+                Id = options.Id,
+                DeviceId = options.DeviceId,
+                CustomName = options.CustomName,
+            };
+        }
+
+        private DeviceInfoDto ToDeviceInfoDto(DeviceInfo info)
+        {
+            return new()
+            {
+                Name = info.Name,
+                CustomName = info.CustomName,
+                AccessToken = info.AccessToken,
+                Id = info.Id,
+                LastUserName = info.LastUserName,
+                AppName = info.AppName,
+                AppVersion = info.AppVersion,
+                LastUserId = info.LastUserId,
+                DateLastActivity = info.DateLastActivity,
+                Capabilities = ToClientCapabilitiesDto(info.Capabilities),
+                IconUrl = info.IconUrl
+            };
+        }
+
+        /// <inheritdoc />
+        public ClientCapabilitiesDto ToClientCapabilitiesDto(ClientCapabilities capabilities)
+        {
+            return new()
+            {
+                PlayableMediaTypes = capabilities.PlayableMediaTypes,
+                SupportedCommands = capabilities.SupportedCommands,
+                SupportsMediaControl = capabilities.SupportsMediaControl,
+                SupportsPersistentIdentifier = capabilities.SupportsPersistentIdentifier,
+                DeviceProfile = capabilities.DeviceProfile,
+                AppStoreUrl = capabilities.AppStoreUrl,
+                IconUrl = capabilities.IconUrl
+            };
+        }
     }
 }

+ 110 - 1
Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs

@@ -1,14 +1,23 @@
 using System;
 using System.Collections.Generic;
 using System.Collections.Immutable;
+using System.Globalization;
 using System.Linq;
+using System.Threading;
 using System.Threading.Tasks;
 using Jellyfin.Data.Entities;
 using Jellyfin.Data.Enums;
+using Jellyfin.Extensions;
+using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller;
 using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model;
 using MediaBrowser.Model.MediaSegments;
 using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
 
 namespace Jellyfin.Server.Implementations.MediaSegments;
 
@@ -17,15 +26,98 @@ namespace Jellyfin.Server.Implementations.MediaSegments;
 /// </summary>
 public class MediaSegmentManager : IMediaSegmentManager
 {
+    private readonly ILogger<MediaSegmentManager> _logger;
     private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
+    private readonly IMediaSegmentProvider[] _segmentProviders;
+    private readonly ILibraryManager _libraryManager;
 
     /// <summary>
     /// Initializes a new instance of the <see cref="MediaSegmentManager"/> class.
     /// </summary>
+    /// <param name="logger">Logger.</param>
     /// <param name="dbProvider">EFCore Database factory.</param>
-    public MediaSegmentManager(IDbContextFactory<JellyfinDbContext> dbProvider)
+    /// <param name="segmentProviders">List of all media segment providers.</param>
+    /// <param name="libraryManager">Library manager.</param>
+    public MediaSegmentManager(
+        ILogger<MediaSegmentManager> logger,
+        IDbContextFactory<JellyfinDbContext> dbProvider,
+        IEnumerable<IMediaSegmentProvider> segmentProviders,
+        ILibraryManager libraryManager)
     {
+        _logger = logger;
         _dbProvider = dbProvider;
+
+        _segmentProviders = segmentProviders
+            .OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0)
+            .ToArray();
+        _libraryManager = libraryManager;
+    }
+
+    /// <inheritdoc/>
+    public async Task RunSegmentPluginProviders(BaseItem baseItem, bool overwrite, CancellationToken cancellationToken)
+    {
+        var libraryOptions = _libraryManager.GetLibraryOptions(baseItem);
+        var providers = _segmentProviders
+            .Where(e => !libraryOptions.DisabledMediaSegmentProviders.Contains(GetProviderId(e.Name)))
+            .OrderBy(i =>
+                {
+                    var index = libraryOptions.MediaSegmentProvideOrder.IndexOf(i.Name);
+                    return index == -1 ? int.MaxValue : index;
+                })
+            .ToList();
+
+        if (providers.Count == 0)
+        {
+            _logger.LogDebug("Skipping media segment extraction as no providers are enabled for {MediaPath}", baseItem.Path);
+            return;
+        }
+
+        using var db = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+
+        if (!overwrite && (await db.MediaSegments.AnyAsync(e => e.ItemId.Equals(baseItem.Id), cancellationToken).ConfigureAwait(false)))
+        {
+            _logger.LogDebug("Skip {MediaPath} as it already contains media segments", baseItem.Path);
+            return;
+        }
+
+        _logger.LogDebug("Start media segment extraction for {MediaPath} with {CountProviders} providers enabled", baseItem.Path, providers.Count);
+
+        await db.MediaSegments.Where(e => e.ItemId.Equals(baseItem.Id)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
+
+        // no need to recreate the request object every time.
+        var requestItem = new MediaSegmentGenerationRequest() { ItemId = baseItem.Id };
+
+        foreach (var provider in providers)
+        {
+            if (!await provider.Supports(baseItem).ConfigureAwait(false))
+            {
+                _logger.LogDebug("Media Segment provider {ProviderName} does not support item with path {MediaPath}", provider.Name, baseItem.Path);
+                continue;
+            }
+
+            try
+            {
+                var segments = await provider.GetMediaSegments(requestItem, cancellationToken)
+                    .ConfigureAwait(false);
+                if (segments.Count == 0)
+                {
+                    _logger.LogDebug("Media Segment provider {ProviderName} did not find any segments for {MediaPath}", provider.Name, baseItem.Path);
+                    continue;
+                }
+
+                _logger.LogInformation("Media Segment provider {ProviderName} found {CountSegments} for {MediaPath}", provider.Name, segments.Count, baseItem.Path);
+                var providerId = GetProviderId(provider.Name);
+                foreach (var segment in segments)
+                {
+                    segment.ItemId = baseItem.Id;
+                    await CreateSegmentAsync(segment, providerId).ConfigureAwait(false);
+                }
+            }
+            catch (Exception ex)
+            {
+                _logger.LogError(ex, "Provider {ProviderName} failed to extract segments from {MediaPath}", provider.Name, baseItem.Path);
+            }
+        }
     }
 
     /// <inheritdoc />
@@ -103,4 +195,21 @@ public class MediaSegmentManager : IMediaSegmentManager
     {
         return baseItem.MediaType is Data.Enums.MediaType.Video or Data.Enums.MediaType.Audio;
     }
+
+    /// <inheritdoc/>
+    public IEnumerable<(string Name, string Id)> GetSupportedProviders(BaseItem item)
+    {
+        if (item is not (Video or Audio))
+        {
+            return [];
+        }
+
+        return _segmentProviders
+            .Select(p => (p.Name, GetProviderId(p.Name)));
+    }
+
+    private string GetProviderId(string name)
+        => name.ToLowerInvariant()
+            .GetMD5()
+            .ToString("N", CultureInfo.InvariantCulture);
 }

+ 712 - 0
Jellyfin.Server.Implementations/Migrations/20240928082930_MarkSegmentProviderIdNonNullable.Designer.cs

@@ -0,0 +1,712 @@
+// <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("20240928082930_MarkSegmentProviderIdNonNullable")]
+    partial class MarkSegmentProviderIdNonNullable
+    {
+        /// <inheritdoc />
+        protected override void BuildTargetModel(ModelBuilder modelBuilder)
+        {
+#pragma warning disable 612, 618
+            modelBuilder.HasAnnotation("ProductVersion", "8.0.8");
+
+            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.MediaSegment", b =>
+                {
+                    b.Property<Guid>("Id")
+                        .ValueGeneratedOnAdd()
+                        .HasColumnType("TEXT");
+
+                    b.Property<long>("EndTicks")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<Guid>("ItemId")
+                        .HasColumnType("TEXT");
+
+                    b.Property<string>("SegmentProviderId")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
+                    b.Property<long>("StartTicks")
+                        .HasColumnType("INTEGER");
+
+                    b.Property<int>("Type")
+                        .HasColumnType("INTEGER");
+
+                    b.HasKey("Id");
+
+                    b.ToTable("MediaSegments");
+                });
+
+            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<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
+        }
+    }
+}

+ 36 - 0
Jellyfin.Server.Implementations/Migrations/20240928082930_MarkSegmentProviderIdNonNullable.cs

@@ -0,0 +1,36 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+    /// <inheritdoc />
+    public partial class MarkSegmentProviderIdNonNullable : Migration
+    {
+        /// <inheritdoc />
+        protected override void Up(MigrationBuilder migrationBuilder)
+        {
+            migrationBuilder.AlterColumn<string>(
+                name: "SegmentProviderId",
+                table: "MediaSegments",
+                type: "TEXT",
+                nullable: false,
+                defaultValue: string.Empty,
+                oldClrType: typeof(string),
+                oldType: "TEXT",
+                oldNullable: true);
+        }
+
+        /// <inheritdoc />
+        protected override void Down(MigrationBuilder migrationBuilder)
+        {
+            migrationBuilder.AlterColumn<string>(
+                name: "SegmentProviderId",
+                table: "MediaSegments",
+                type: "TEXT",
+                nullable: true,
+                oldClrType: typeof(string),
+                oldType: "TEXT");
+        }
+    }
+}

+ 4 - 3
Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs

@@ -282,15 +282,16 @@ namespace Jellyfin.Server.Implementations.Migrations
                     b.Property<Guid>("ItemId")
                         .HasColumnType("TEXT");
 
+                    b.Property<string>("SegmentProviderId")
+                        .IsRequired()
+                        .HasColumnType("TEXT");
+
                     b.Property<long>("StartTicks")
                         .HasColumnType("INTEGER");
 
                     b.Property<int>("Type")
                         .HasColumnType("INTEGER");
 
-                    b.Property<string>("SegmentProviderId")
-                        .HasColumnType("TEXT");
-
                     b.HasKey("Id");
 
                     b.ToTable("MediaSegments");

+ 157 - 28
Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs

@@ -76,7 +76,65 @@ public class TrickplayManager : ITrickplayManager
     }
 
     /// <inheritdoc />
-    public async Task RefreshTrickplayDataAsync(Video video, bool replace, CancellationToken cancellationToken)
+    public async Task MoveGeneratedTrickplayDataAsync(Video video, LibraryOptions? libraryOptions, CancellationToken cancellationToken)
+    {
+        var options = _config.Configuration.TrickplayOptions;
+        if (!CanGenerateTrickplay(video, options.Interval))
+        {
+            return;
+        }
+
+        var existingTrickplayResolutions = await GetTrickplayResolutions(video.Id).ConfigureAwait(false);
+        foreach (var resolution in existingTrickplayResolutions)
+        {
+            cancellationToken.ThrowIfCancellationRequested();
+            var existingResolution = resolution.Key;
+            var tileWidth = resolution.Value.TileWidth;
+            var tileHeight = resolution.Value.TileHeight;
+            var shouldBeSavedWithMedia = libraryOptions is null ? false : libraryOptions.SaveTrickplayWithMedia;
+            var localOutputDir = GetTrickplayDirectory(video, tileWidth, tileHeight, existingResolution, false);
+            var mediaOutputDir = GetTrickplayDirectory(video, tileWidth, tileHeight, existingResolution, true);
+            if (shouldBeSavedWithMedia && Directory.Exists(localOutputDir))
+            {
+                var localDirFiles = Directory.GetFiles(localOutputDir);
+                var mediaDirExists = Directory.Exists(mediaOutputDir);
+                if (localDirFiles.Length > 0 && ((mediaDirExists && Directory.GetFiles(mediaOutputDir).Length == 0) || !mediaDirExists))
+                {
+                    // Move images from local dir to media dir
+                    MoveContent(localOutputDir, mediaOutputDir);
+                    _logger.LogInformation("Moved trickplay images for {ItemName} to {Location}", video.Name, mediaOutputDir);
+                }
+            }
+            else if (!shouldBeSavedWithMedia && Directory.Exists(mediaOutputDir))
+            {
+                var mediaDirFiles = Directory.GetFiles(mediaOutputDir);
+                var localDirExists = Directory.Exists(localOutputDir);
+                if (mediaDirFiles.Length > 0 && ((localDirExists && Directory.GetFiles(localOutputDir).Length == 0) || !localDirExists))
+                {
+                    // Move images from media dir to local dir
+                    MoveContent(mediaOutputDir, localOutputDir);
+                    _logger.LogInformation("Moved trickplay images for {ItemName} to {Location}", video.Name, localOutputDir);
+                }
+            }
+        }
+    }
+
+    private void MoveContent(string sourceFolder, string destinationFolder)
+    {
+        _fileSystem.MoveDirectory(sourceFolder, destinationFolder);
+        var parent = Directory.GetParent(sourceFolder);
+        if (parent is not null)
+        {
+            var parentContent = Directory.GetDirectories(parent.FullName);
+            if (parentContent.Length == 0)
+            {
+                Directory.Delete(parent.FullName);
+            }
+        }
+    }
+
+    /// <inheritdoc />
+    public async Task RefreshTrickplayDataAsync(Video video, bool replace, LibraryOptions? libraryOptions, CancellationToken cancellationToken)
     {
         _logger.LogDebug("Trickplay refresh for {ItemId} (replace existing: {Replace})", video.Id, replace);
 
@@ -95,6 +153,7 @@ public class TrickplayManager : ITrickplayManager
                 replace,
                 width,
                 options,
+                libraryOptions,
                 cancellationToken).ConfigureAwait(false);
         }
     }
@@ -104,6 +163,7 @@ public class TrickplayManager : ITrickplayManager
         bool replace,
         int width,
         TrickplayOptions options,
+        LibraryOptions? libraryOptions,
         CancellationToken cancellationToken)
     {
         if (!CanGenerateTrickplay(video, options.Interval))
@@ -144,14 +204,53 @@ public class TrickplayManager : ITrickplayManager
                     actualWidth = 2 * ((int)mediaSource.VideoStream.Width / 2);
                 }
 
-                var outputDir = GetTrickplayDirectory(video, actualWidth);
+                var tileWidth = options.TileWidth;
+                var tileHeight = options.TileHeight;
+                var saveWithMedia = libraryOptions is null ? false : libraryOptions.SaveTrickplayWithMedia;
+                var outputDir = GetTrickplayDirectory(video, tileWidth, tileHeight, actualWidth, saveWithMedia);
 
-                if (!replace && Directory.Exists(outputDir) && (await GetTrickplayResolutions(video.Id).ConfigureAwait(false)).ContainsKey(actualWidth))
+                // Import existing trickplay tiles
+                if (!replace && Directory.Exists(outputDir))
                 {
-                    _logger.LogDebug("Found existing trickplay files for {ItemId}. Exiting", video.Id);
-                    return;
+                    var existingFiles = Directory.GetFiles(outputDir);
+                    if (existingFiles.Length > 0)
+                    {
+                        var hasTrickplayResolution = await HasTrickplayResolutionAsync(video.Id, actualWidth).ConfigureAwait(false);
+                        if (hasTrickplayResolution)
+                        {
+                            _logger.LogDebug("Found existing trickplay files for {ItemId}.", video.Id);
+                            return;
+                        }
+
+                        // Import tiles
+                        var localTrickplayInfo = new TrickplayInfo
+                        {
+                            ItemId = video.Id,
+                            Width = width,
+                            Interval = options.Interval,
+                            TileWidth = options.TileWidth,
+                            TileHeight = options.TileHeight,
+                            ThumbnailCount = existingFiles.Length,
+                            Height = 0,
+                            Bandwidth = 0
+                        };
+
+                        foreach (var tile in existingFiles)
+                        {
+                            var image = _imageEncoder.GetImageSize(tile);
+                            localTrickplayInfo.Height = Math.Max(localTrickplayInfo.Height, image.Height);
+                            var bitrate = (int)Math.Ceiling((decimal)new FileInfo(tile).Length * 8 / localTrickplayInfo.TileWidth / localTrickplayInfo.TileHeight / (localTrickplayInfo.Interval / 1000));
+                            localTrickplayInfo.Bandwidth = Math.Max(localTrickplayInfo.Bandwidth, bitrate);
+                        }
+
+                        await SaveTrickplayInfo(localTrickplayInfo).ConfigureAwait(false);
+
+                        _logger.LogDebug("Imported existing trickplay files for {ItemId}.", video.Id);
+                        return;
+                    }
                 }
 
+                // Generate trickplay tiles
                 var mediaStream = mediaSource.VideoStream;
                 var container = mediaSource.Container;
 
@@ -224,7 +323,7 @@ public class TrickplayManager : ITrickplayManager
     }
 
     /// <inheritdoc />
-    public TrickplayInfo CreateTiles(List<string> images, int width, TrickplayOptions options, string outputDir)
+    public TrickplayInfo CreateTiles(IReadOnlyList<string> images, int width, TrickplayOptions options, string outputDir)
     {
         if (images.Count == 0)
         {
@@ -264,7 +363,7 @@ public class TrickplayManager : ITrickplayManager
             var tilePath = Path.Combine(workDir, $"{i}.jpg");
 
             imageOptions.OutputPath = tilePath;
-            imageOptions.InputPaths = images.GetRange(i * thumbnailsPerTile, Math.Min(thumbnailsPerTile, images.Count - (i * thumbnailsPerTile)));
+            imageOptions.InputPaths = images.Skip(i * thumbnailsPerTile).Take(Math.Min(thumbnailsPerTile, images.Count - (i * thumbnailsPerTile))).ToList();
 
             // Generate image and use returned height for tiles info
             var height = _imageEncoder.CreateTrickplayTile(imageOptions, options.JpegQuality, trickplayInfo.Width, trickplayInfo.Height != 0 ? trickplayInfo.Height : null);
@@ -289,7 +388,7 @@ public class TrickplayManager : ITrickplayManager
             Directory.Delete(outputDir, true);
         }
 
-        MoveDirectory(workDir, outputDir);
+        _fileSystem.MoveDirectory(workDir, outputDir);
 
         return trickplayInfo;
     }
@@ -355,6 +454,26 @@ public class TrickplayManager : ITrickplayManager
         return trickplayResolutions;
     }
 
+    /// <inheritdoc />
+    public async Task<IReadOnlyList<TrickplayInfo>> GetTrickplayItemsAsync(int limit, int offset)
+    {
+        IReadOnlyList<TrickplayInfo> trickplayItems;
+
+        var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
+        await using (dbContext.ConfigureAwait(false))
+        {
+            trickplayItems = await dbContext.TrickplayInfos
+                .AsNoTracking()
+                .OrderBy(i => i.ItemId)
+                .Skip(offset)
+                .Take(limit)
+                .ToListAsync()
+                .ConfigureAwait(false);
+        }
+
+        return trickplayItems;
+    }
+
     /// <inheritdoc />
     public async Task SaveTrickplayInfo(TrickplayInfo info)
     {
@@ -392,9 +511,15 @@ public class TrickplayManager : ITrickplayManager
     }
 
     /// <inheritdoc />
-    public string GetTrickplayTilePath(BaseItem item, int width, int index)
+    public async Task<string> GetTrickplayTilePathAsync(BaseItem item, int width, int index, bool saveWithMedia)
     {
-        return Path.Combine(GetTrickplayDirectory(item, width), index + ".jpg");
+        var trickplayResolutions = await GetTrickplayResolutions(item.Id).ConfigureAwait(false);
+        if (trickplayResolutions is not null && trickplayResolutions.TryGetValue(width, out var trickplayInfo))
+        {
+            return Path.Combine(GetTrickplayDirectory(item, trickplayInfo.TileWidth, trickplayInfo.TileHeight, width, saveWithMedia), index + ".jpg");
+        }
+
+        return string.Empty;
     }
 
     /// <inheritdoc />
@@ -470,29 +595,33 @@ public class TrickplayManager : ITrickplayManager
         return null;
     }
 
-    private string GetTrickplayDirectory(BaseItem item, int? width = null)
+    /// <inheritdoc />
+    public string GetTrickplayDirectory(BaseItem item, int tileWidth, int tileHeight, int width, bool saveWithMedia = false)
     {
-        var path = Path.Combine(item.GetInternalMetadataPath(), "trickplay");
-
-        return width.HasValue ? Path.Combine(path, width.Value.ToString(CultureInfo.InvariantCulture)) : path;
+        var path = saveWithMedia
+            ? Path.Combine(item.ContainingFolderPath, Path.ChangeExtension(item.Path, ".trickplay"))
+            : Path.Combine(item.GetInternalMetadataPath(), "trickplay");
+
+        var subdirectory = string.Format(
+            CultureInfo.InvariantCulture,
+            "{0} - {1}x{2}",
+            width.ToString(CultureInfo.InvariantCulture),
+            tileWidth.ToString(CultureInfo.InvariantCulture),
+            tileHeight.ToString(CultureInfo.InvariantCulture));
+
+        return Path.Combine(path, subdirectory);
     }
 
-    private void MoveDirectory(string source, string destination)
+    private async Task<bool> HasTrickplayResolutionAsync(Guid itemId, int width)
     {
-        try
-        {
-            Directory.Move(source, destination);
-        }
-        catch (IOException)
+        var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
+        await using (dbContext.ConfigureAwait(false))
         {
-            // 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);
+            return await dbContext.TrickplayInfos
+                .AsNoTracking()
+                .Where(i => i.ItemId.Equals(itemId))
+                .AnyAsync(i => i.Width == width)
+                .ConfigureAwait(false);
         }
     }
 }

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

@@ -23,7 +23,8 @@ namespace Jellyfin.Server.Migrations
         {
             typeof(PreStartupRoutines.CreateNetworkConfiguration),
             typeof(PreStartupRoutines.MigrateMusicBrainzTimeout),
-            typeof(PreStartupRoutines.MigrateNetworkConfiguration)
+            typeof(PreStartupRoutines.MigrateNetworkConfiguration),
+            typeof(PreStartupRoutines.MigrateEncodingOptions)
         };
 
         /// <summary>
@@ -46,6 +47,7 @@ namespace Jellyfin.Server.Migrations
             typeof(Routines.AddDefaultCastReceivers),
             typeof(Routines.UpdateDefaultPluginRepository),
             typeof(Routines.FixAudioData),
+            typeof(Routines.MoveTrickplayFiles)
         };
 
         /// <summary>

+ 0 - 1
Jellyfin.Server/Migrations/PreStartupRoutines/CreateNetworkConfiguration.cs

@@ -132,5 +132,4 @@ public class CreateNetworkConfiguration : IMigrationRoutine
 
         public string[] KnownProxies { get; set; } = Array.Empty<string>();
     }
-#pragma warning restore
 }

+ 245 - 0
Jellyfin.Server/Migrations/PreStartupRoutines/MigrateEncodingOptions.cs

@@ -0,0 +1,245 @@
+using System;
+using System.IO;
+using System.Xml;
+using System.Xml.Serialization;
+using Emby.Server.Implementations;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Entities;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.PreStartupRoutines;
+
+/// <inheritdoc />
+public class MigrateEncodingOptions : IMigrationRoutine
+{
+    private readonly ServerApplicationPaths _applicationPaths;
+    private readonly ILogger<MigrateEncodingOptions> _logger;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="MigrateEncodingOptions"/> class.
+    /// </summary>
+    /// <param name="applicationPaths">An instance of <see cref="ServerApplicationPaths"/>.</param>
+    /// <param name="loggerFactory">An instance of the <see cref="ILoggerFactory"/> interface.</param>
+    public MigrateEncodingOptions(ServerApplicationPaths applicationPaths, ILoggerFactory loggerFactory)
+    {
+        _applicationPaths = applicationPaths;
+        _logger = loggerFactory.CreateLogger<MigrateEncodingOptions>();
+    }
+
+    /// <inheritdoc />
+    public Guid Id => Guid.Parse("A8E61960-7726-4450-8F3D-82C12DAABBCB");
+
+    /// <inheritdoc />
+    public string Name => nameof(MigrateEncodingOptions);
+
+    /// <inheritdoc />
+    public bool PerformOnNewInstall => false;
+
+    /// <inheritdoc />
+    public void Perform()
+    {
+        string path = Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "encoding.xml");
+        var oldSerializer = new XmlSerializer(typeof(OldEncodingOptions), new XmlRootAttribute("EncodingOptions"));
+        OldEncodingOptions? oldConfig = null;
+
+        try
+        {
+            using var xmlReader = XmlReader.Create(path);
+            oldConfig = (OldEncodingOptions?)oldSerializer.Deserialize(xmlReader);
+        }
+        catch (InvalidOperationException ex)
+        {
+            _logger.LogError(ex, "Migrate EncodingOptions deserialize Invalid Operation error");
+        }
+        catch (Exception ex)
+        {
+            _logger.LogError(ex, "Migrate EncodingOptions deserialize error");
+        }
+
+        if (oldConfig is null)
+        {
+            return;
+        }
+
+        var hardwareAccelerationType = HardwareAccelerationType.none;
+        if (Enum.TryParse<HardwareAccelerationType>(oldConfig.HardwareAccelerationType, true, out var parsedHardwareAccelerationType))
+        {
+            hardwareAccelerationType = parsedHardwareAccelerationType;
+        }
+
+        var tonemappingAlgorithm = TonemappingAlgorithm.none;
+        if (Enum.TryParse<TonemappingAlgorithm>(oldConfig.TonemappingAlgorithm, true, out var parsedTonemappingAlgorithm))
+        {
+            tonemappingAlgorithm = parsedTonemappingAlgorithm;
+        }
+
+        var tonemappingMode = TonemappingMode.auto;
+        if (Enum.TryParse<TonemappingMode>(oldConfig.TonemappingMode, true, out var parsedTonemappingMode))
+        {
+            tonemappingMode = parsedTonemappingMode;
+        }
+
+        var tonemappingRange = TonemappingRange.auto;
+        if (Enum.TryParse<TonemappingRange>(oldConfig.TonemappingRange, true, out var parsedTonemappingRange))
+        {
+            tonemappingRange = parsedTonemappingRange;
+        }
+
+        var encoderPreset = EncoderPreset.superfast;
+        if (Enum.TryParse<EncoderPreset>(oldConfig.TonemappingRange, true, out var parsedEncoderPreset))
+        {
+            encoderPreset = parsedEncoderPreset;
+        }
+
+        var deinterlaceMethod = DeinterlaceMethod.yadif;
+        if (Enum.TryParse<DeinterlaceMethod>(oldConfig.TonemappingRange, true, out var parsedDeinterlaceMethod))
+        {
+            deinterlaceMethod = parsedDeinterlaceMethod;
+        }
+
+        var encodingOptions = new EncodingOptions()
+        {
+            EncodingThreadCount = oldConfig.EncodingThreadCount,
+            TranscodingTempPath = oldConfig.TranscodingTempPath,
+            FallbackFontPath = oldConfig.FallbackFontPath,
+            EnableFallbackFont = oldConfig.EnableFallbackFont,
+            EnableAudioVbr = oldConfig.EnableAudioVbr,
+            DownMixAudioBoost = oldConfig.DownMixAudioBoost,
+            DownMixStereoAlgorithm = oldConfig.DownMixStereoAlgorithm,
+            MaxMuxingQueueSize = oldConfig.MaxMuxingQueueSize,
+            EnableThrottling = oldConfig.EnableThrottling,
+            ThrottleDelaySeconds = oldConfig.ThrottleDelaySeconds,
+            EnableSegmentDeletion = oldConfig.EnableSegmentDeletion,
+            SegmentKeepSeconds = oldConfig.SegmentKeepSeconds,
+            HardwareAccelerationType = hardwareAccelerationType,
+            EncoderAppPath = oldConfig.EncoderAppPath,
+            EncoderAppPathDisplay = oldConfig.EncoderAppPathDisplay,
+            VaapiDevice = oldConfig.VaapiDevice,
+            EnableTonemapping = oldConfig.EnableTonemapping,
+            EnableVppTonemapping = oldConfig.EnableVppTonemapping,
+            EnableVideoToolboxTonemapping = oldConfig.EnableVideoToolboxTonemapping,
+            TonemappingAlgorithm = tonemappingAlgorithm,
+            TonemappingMode = tonemappingMode,
+            TonemappingRange = tonemappingRange,
+            TonemappingDesat = oldConfig.TonemappingDesat,
+            TonemappingPeak = oldConfig.TonemappingPeak,
+            TonemappingParam = oldConfig.TonemappingParam,
+            VppTonemappingBrightness = oldConfig.VppTonemappingBrightness,
+            VppTonemappingContrast = oldConfig.VppTonemappingContrast,
+            H264Crf = oldConfig.H264Crf,
+            H265Crf = oldConfig.H265Crf,
+            EncoderPreset = encoderPreset,
+            DeinterlaceDoubleRate = oldConfig.DeinterlaceDoubleRate,
+            DeinterlaceMethod = deinterlaceMethod,
+            EnableDecodingColorDepth10Hevc = oldConfig.EnableDecodingColorDepth10Hevc,
+            EnableDecodingColorDepth10Vp9 = oldConfig.EnableDecodingColorDepth10Vp9,
+            EnableEnhancedNvdecDecoder = oldConfig.EnableEnhancedNvdecDecoder,
+            PreferSystemNativeHwDecoder = oldConfig.PreferSystemNativeHwDecoder,
+            EnableIntelLowPowerH264HwEncoder = oldConfig.EnableIntelLowPowerH264HwEncoder,
+            EnableIntelLowPowerHevcHwEncoder = oldConfig.EnableIntelLowPowerHevcHwEncoder,
+            EnableHardwareEncoding = oldConfig.EnableHardwareEncoding,
+            AllowHevcEncoding = oldConfig.AllowHevcEncoding,
+            AllowAv1Encoding = oldConfig.AllowAv1Encoding,
+            EnableSubtitleExtraction = oldConfig.EnableSubtitleExtraction,
+            HardwareDecodingCodecs = oldConfig.HardwareDecodingCodecs,
+            AllowOnDemandMetadataBasedKeyframeExtractionForExtensions = oldConfig.AllowOnDemandMetadataBasedKeyframeExtractionForExtensions
+        };
+
+        var newSerializer = new XmlSerializer(typeof(EncodingOptions));
+        var xmlWriterSettings = new XmlWriterSettings { Indent = true };
+        using var xmlWriter = XmlWriter.Create(path, xmlWriterSettings);
+        newSerializer.Serialize(xmlWriter, encodingOptions);
+    }
+
+#pragma warning disable
+    public sealed class OldEncodingOptions
+    {
+        public int EncodingThreadCount { get; set; }
+
+        public string TranscodingTempPath { get; set; }
+
+        public string FallbackFontPath { get; set; }
+
+        public bool EnableFallbackFont { get; set; }
+
+        public bool EnableAudioVbr { get; set; }
+
+        public double DownMixAudioBoost { get; set; }
+
+        public DownMixStereoAlgorithms DownMixStereoAlgorithm { get; set; }
+
+        public int MaxMuxingQueueSize { get; set; }
+
+        public bool EnableThrottling { get; set; }
+
+        public int ThrottleDelaySeconds { get; set; }
+
+        public bool EnableSegmentDeletion { get; set; }
+
+        public int SegmentKeepSeconds { get; set; }
+
+        public string HardwareAccelerationType { get; set; }
+
+        public string EncoderAppPath { get; set; }
+
+        public string EncoderAppPathDisplay { get; set; }
+
+        public string VaapiDevice { get; set; }
+
+        public bool EnableTonemapping { get; set; }
+
+        public bool EnableVppTonemapping { get; set; }
+
+        public bool EnableVideoToolboxTonemapping { get; set; }
+
+        public string TonemappingAlgorithm { get; set; }
+
+        public string TonemappingMode { get; set; }
+
+        public string TonemappingRange { get; set; }
+
+        public double TonemappingDesat { get; set; }
+
+        public double TonemappingPeak { get; set; }
+
+        public double TonemappingParam { get; set; }
+
+        public double VppTonemappingBrightness { get; set; }
+
+        public double VppTonemappingContrast { get; set; }
+
+        public int H264Crf { get; set; }
+
+        public int H265Crf { get; set; }
+
+        public string EncoderPreset { get; set; }
+
+        public bool DeinterlaceDoubleRate { get; set; }
+
+        public string DeinterlaceMethod { get; set; }
+
+        public bool EnableDecodingColorDepth10Hevc { get; set; }
+
+        public bool EnableDecodingColorDepth10Vp9 { get; set; }
+
+        public bool EnableEnhancedNvdecDecoder { get; set; }
+
+        public bool PreferSystemNativeHwDecoder { get; set; }
+
+        public bool EnableIntelLowPowerH264HwEncoder { get; set; }
+
+        public bool EnableIntelLowPowerHevcHwEncoder { get; set; }
+
+        public bool EnableHardwareEncoding { get; set; }
+
+        public bool AllowHevcEncoding { get; set; }
+
+        public bool AllowAv1Encoding { get; set; }
+
+        public bool EnableSubtitleExtraction { get; set; }
+
+        public string[] HardwareDecodingCodecs { get; set; }
+
+        public string[] AllowOnDemandMetadataBasedKeyframeExtractionForExtensions { get; set; }
+    }
+}

+ 5 - 5
Jellyfin.Server/Migrations/PreStartupRoutines/MigrateMusicBrainzTimeout.cs

@@ -48,9 +48,11 @@ public class MigrateMusicBrainzTimeout : IMigrationRoutine
 
         if (oldPluginConfiguration is not null)
         {
-            var newPluginConfiguration = new PluginConfiguration();
-            newPluginConfiguration.Server = oldPluginConfiguration.Server;
-            newPluginConfiguration.ReplaceArtistName = oldPluginConfiguration.ReplaceArtistName;
+            var newPluginConfiguration = new PluginConfiguration
+            {
+                Server = oldPluginConfiguration.Server,
+                ReplaceArtistName = oldPluginConfiguration.ReplaceArtistName
+            };
             var newRateLimit = oldPluginConfiguration.RateLimit / 1000.0;
             newPluginConfiguration.RateLimit = newRateLimit < 1.0 ? 1.0 : newRateLimit;
             WriteNew(path, newPluginConfiguration);
@@ -93,6 +95,4 @@ public class MigrateMusicBrainzTimeout : IMigrationRoutine
 
         public bool ReplaceArtistName { get; set; }
     }
-#pragma warning restore
-
 }

+ 44 - 41
Jellyfin.Server/Migrations/PreStartupRoutines/MigrateNetworkConfiguration.cs

@@ -55,49 +55,53 @@ public class MigrateNetworkConfiguration : IMigrationRoutine
             _logger.LogError(ex, "Migrate NetworkConfiguration deserialize error");
         }
 
-        if (oldNetworkConfiguration is not null)
+        if (oldNetworkConfiguration is null)
         {
-            // Migrate network config values to new config schema
-            var networkConfiguration = new NetworkConfiguration();
-            networkConfiguration.AutoDiscovery = oldNetworkConfiguration.AutoDiscovery;
-            networkConfiguration.BaseUrl = oldNetworkConfiguration.BaseUrl;
-            networkConfiguration.CertificatePassword = oldNetworkConfiguration.CertificatePassword;
-            networkConfiguration.CertificatePath = oldNetworkConfiguration.CertificatePath;
-            networkConfiguration.EnableHttps = oldNetworkConfiguration.EnableHttps;
-            networkConfiguration.EnableIPv4 = oldNetworkConfiguration.EnableIPV4;
-            networkConfiguration.EnableIPv6 = oldNetworkConfiguration.EnableIPV6;
-            networkConfiguration.EnablePublishedServerUriByRequest = oldNetworkConfiguration.EnablePublishedServerUriByRequest;
-            networkConfiguration.EnableRemoteAccess = oldNetworkConfiguration.EnableRemoteAccess;
-            networkConfiguration.EnableUPnP = oldNetworkConfiguration.EnableUPnP;
-            networkConfiguration.IgnoreVirtualInterfaces = oldNetworkConfiguration.IgnoreVirtualInterfaces;
-            networkConfiguration.InternalHttpPort = oldNetworkConfiguration.HttpServerPortNumber;
-            networkConfiguration.InternalHttpsPort = oldNetworkConfiguration.HttpsPortNumber;
-            networkConfiguration.IsRemoteIPFilterBlacklist = oldNetworkConfiguration.IsRemoteIPFilterBlacklist;
-            networkConfiguration.KnownProxies = oldNetworkConfiguration.KnownProxies;
-            networkConfiguration.LocalNetworkAddresses = oldNetworkConfiguration.LocalNetworkAddresses;
-            networkConfiguration.LocalNetworkSubnets = oldNetworkConfiguration.LocalNetworkSubnets;
-            networkConfiguration.PublicHttpPort = oldNetworkConfiguration.PublicPort;
-            networkConfiguration.PublicHttpsPort = oldNetworkConfiguration.PublicHttpsPort;
-            networkConfiguration.PublishedServerUriBySubnet = oldNetworkConfiguration.PublishedServerUriBySubnet;
-            networkConfiguration.RemoteIPFilter = oldNetworkConfiguration.RemoteIPFilter;
-            networkConfiguration.RequireHttps = oldNetworkConfiguration.RequireHttps;
-
-            // Migrate old virtual interface name schema
-            var oldVirtualInterfaceNames = oldNetworkConfiguration.VirtualInterfaceNames;
-            if (oldVirtualInterfaceNames.Equals("vEthernet*", StringComparison.OrdinalIgnoreCase))
-            {
-                networkConfiguration.VirtualInterfaceNames = new string[] { "veth" };
-            }
-            else
-            {
-                networkConfiguration.VirtualInterfaceNames = oldVirtualInterfaceNames.Replace("*", string.Empty, StringComparison.OrdinalIgnoreCase).Split(',');
-            }
+            return;
+        }
 
-            var networkConfigSerializer = new XmlSerializer(typeof(NetworkConfiguration));
-            var xmlWriterSettings = new XmlWriterSettings { Indent = true };
-            using var xmlWriter = XmlWriter.Create(path, xmlWriterSettings);
-            networkConfigSerializer.Serialize(xmlWriter, networkConfiguration);
+        // Migrate network config values to new config schema
+        var networkConfiguration = new NetworkConfiguration
+        {
+            AutoDiscovery = oldNetworkConfiguration.AutoDiscovery,
+            BaseUrl = oldNetworkConfiguration.BaseUrl,
+            CertificatePassword = oldNetworkConfiguration.CertificatePassword,
+            CertificatePath = oldNetworkConfiguration.CertificatePath,
+            EnableHttps = oldNetworkConfiguration.EnableHttps,
+            EnableIPv4 = oldNetworkConfiguration.EnableIPV4,
+            EnableIPv6 = oldNetworkConfiguration.EnableIPV6,
+            EnablePublishedServerUriByRequest = oldNetworkConfiguration.EnablePublishedServerUriByRequest,
+            EnableRemoteAccess = oldNetworkConfiguration.EnableRemoteAccess,
+            EnableUPnP = oldNetworkConfiguration.EnableUPnP,
+            IgnoreVirtualInterfaces = oldNetworkConfiguration.IgnoreVirtualInterfaces,
+            InternalHttpPort = oldNetworkConfiguration.HttpServerPortNumber,
+            InternalHttpsPort = oldNetworkConfiguration.HttpsPortNumber,
+            IsRemoteIPFilterBlacklist = oldNetworkConfiguration.IsRemoteIPFilterBlacklist,
+            KnownProxies = oldNetworkConfiguration.KnownProxies,
+            LocalNetworkAddresses = oldNetworkConfiguration.LocalNetworkAddresses,
+            LocalNetworkSubnets = oldNetworkConfiguration.LocalNetworkSubnets,
+            PublicHttpPort = oldNetworkConfiguration.PublicPort,
+            PublicHttpsPort = oldNetworkConfiguration.PublicHttpsPort,
+            PublishedServerUriBySubnet = oldNetworkConfiguration.PublishedServerUriBySubnet,
+            RemoteIPFilter = oldNetworkConfiguration.RemoteIPFilter,
+            RequireHttps = oldNetworkConfiguration.RequireHttps
+        };
+
+        // Migrate old virtual interface name schema
+        var oldVirtualInterfaceNames = oldNetworkConfiguration.VirtualInterfaceNames;
+        if (oldVirtualInterfaceNames.Equals("vEthernet*", StringComparison.OrdinalIgnoreCase))
+        {
+            networkConfiguration.VirtualInterfaceNames = new string[] { "veth" };
         }
+        else
+        {
+            networkConfiguration.VirtualInterfaceNames = oldVirtualInterfaceNames.Replace("*", string.Empty, StringComparison.OrdinalIgnoreCase).Split(',');
+        }
+
+        var networkConfigSerializer = new XmlSerializer(typeof(NetworkConfiguration));
+        var xmlWriterSettings = new XmlWriterSettings { Indent = true };
+        using var xmlWriter = XmlWriter.Create(path, xmlWriterSettings);
+        networkConfigSerializer.Serialize(xmlWriter, networkConfiguration);
     }
 
 #pragma warning disable
@@ -204,5 +208,4 @@ public class MigrateNetworkConfiguration : IMigrationRoutine
 
         public bool EnablePublishedServerUriByRequest { get; set; } = false;
     }
-#pragma warning restore
 }

+ 104 - 0
Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs

@@ -0,0 +1,104 @@
+using System;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Common;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Trickplay;
+using MediaBrowser.Model.IO;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Migration to move trickplay files to the new directory.
+/// </summary>
+public class MoveTrickplayFiles : IMigrationRoutine
+{
+    private readonly ITrickplayManager _trickplayManager;
+    private readonly IFileSystem _fileSystem;
+    private readonly ILibraryManager _libraryManager;
+    private readonly ILogger<MoveTrickplayFiles> _logger;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="MoveTrickplayFiles"/> class.
+    /// </summary>
+    /// <param name="trickplayManager">Instance of the <see cref="ITrickplayManager"/> interface.</param>
+    /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+    /// <param name="logger">The logger.</param>
+    public MoveTrickplayFiles(ITrickplayManager trickplayManager, IFileSystem fileSystem, ILibraryManager libraryManager, ILogger<MoveTrickplayFiles> logger)
+    {
+        _trickplayManager = trickplayManager;
+        _fileSystem = fileSystem;
+        _libraryManager = libraryManager;
+        _logger = logger;
+    }
+
+    /// <inheritdoc />
+    public Guid Id => new("4EF123D5-8EFF-4B0B-869D-3AED07A60E1B");
+
+    /// <inheritdoc />
+    public string Name => "MoveTrickplayFiles";
+
+    /// <inheritdoc />
+    public bool PerformOnNewInstall => true;
+
+    /// <inheritdoc />
+    public void Perform()
+    {
+        const int Limit = 100;
+        int itemCount = 0, offset = 0, previousCount;
+
+        var sw = Stopwatch.StartNew();
+        var trickplayQuery = new InternalItemsQuery
+        {
+            MediaTypes = [MediaType.Video],
+            SourceTypes = [SourceType.Library],
+            IsVirtualItem = false,
+            IsFolder = false
+        };
+
+        do
+        {
+            var trickplayInfos = _trickplayManager.GetTrickplayItemsAsync(Limit, offset).GetAwaiter().GetResult();
+            previousCount = trickplayInfos.Count;
+            offset += Limit;
+
+            trickplayQuery.ItemIds = trickplayInfos.Select(i => i.ItemId).Distinct().ToArray();
+            var items = _libraryManager.GetItemList(trickplayQuery);
+            foreach (var trickplayInfo in trickplayInfos)
+            {
+                var item = items.OfType<Video>().FirstOrDefault(i => i.Id.Equals(trickplayInfo.ItemId));
+                if (item is null)
+                {
+                    continue;
+                }
+
+                if (++itemCount % 1_000 == 0)
+                {
+                    _logger.LogInformation("Moved {Count} items in {Time}", itemCount, sw.Elapsed);
+                }
+
+                var oldPath = GetOldTrickplayDirectory(item, trickplayInfo.Width);
+                var newPath = _trickplayManager.GetTrickplayDirectory(item, trickplayInfo.TileWidth, trickplayInfo.TileHeight, trickplayInfo.Width, false);
+                if (_fileSystem.DirectoryExists(oldPath))
+                {
+                    _fileSystem.MoveDirectory(oldPath, newPath);
+                }
+            }
+        } while (previousCount == Limit);
+
+        _logger.LogInformation("Moved {Count} items in {Time}", itemCount, sw.Elapsed);
+    }
+
+    private string GetOldTrickplayDirectory(BaseItem item, int? width = null)
+    {
+        var path = Path.Combine(item.GetInternalMetadataPath(), "trickplay");
+
+        return width.HasValue ? Path.Combine(path, width.Value.ToString(CultureInfo.InvariantCulture)) : path;
+    }
+}

+ 11 - 0
Jellyfin.Server/StartupOptions.cs

@@ -67,6 +67,12 @@ namespace Jellyfin.Server
         [Option("published-server-url", Required = false, HelpText = "Jellyfin Server URL to publish via auto discover process")]
         public string? PublishedServerUrl { get; set; }
 
+        /// <summary>
+        /// Gets or sets a value indicating whether the server should not detect network status change.
+        /// </summary>
+        [Option("nonetchange", Required = false, HelpText = "Indicates that the server should not detect network status change.")]
+        public bool NoDetectNetworkChange { get; set; }
+
         /// <summary>
         /// Gets the command line options as a dictionary that can be used in the .NET configuration system.
         /// </summary>
@@ -90,6 +96,11 @@ namespace Jellyfin.Server
                 config.Add(FfmpegPathKey, FFmpegPath);
             }
 
+            if (NoDetectNetworkChange)
+            {
+                config.Add(DetectNetworkChangeKey, bool.FalseString);
+            }
+
             return config;
         }
     }

+ 22 - 11
MediaBrowser.Controller/Authentication/AuthenticationResult.cs

@@ -1,20 +1,31 @@
 #nullable disable
 
-#pragma warning disable CS1591
-
-using MediaBrowser.Controller.Session;
 using MediaBrowser.Model.Dto;
 
-namespace MediaBrowser.Controller.Authentication
+namespace MediaBrowser.Controller.Authentication;
+
+/// <summary>
+/// A class representing an authentication result.
+/// </summary>
+public class AuthenticationResult
 {
-    public class AuthenticationResult
-    {
-        public UserDto User { get; set; }
+    /// <summary>
+    /// Gets or sets the user.
+    /// </summary>
+    public UserDto User { get; set; }
 
-        public SessionInfo SessionInfo { get; set; }
+    /// <summary>
+    /// Gets or sets the session info.
+    /// </summary>
+    public SessionInfoDto SessionInfo { get; set; }
 
-        public string AccessToken { get; set; }
+    /// <summary>
+    /// Gets or sets the access token.
+    /// </summary>
+    public string AccessToken { get; set; }
 
-        public string ServerId { get; set; }
-    }
+    /// <summary>
+    /// Gets or sets the server id.
+    /// </summary>
+    public string ServerId { get; set; }
 }

+ 93 - 57
MediaBrowser.Controller/Devices/IDeviceManager.cs

@@ -1,81 +1,117 @@
-#nullable disable
-
-#pragma warning disable CS1591
-
 using System;
 using System.Threading.Tasks;
+using Jellyfin.Data.Dtos;
 using Jellyfin.Data.Entities;
 using Jellyfin.Data.Entities.Security;
 using Jellyfin.Data.Events;
 using Jellyfin.Data.Queries;
 using MediaBrowser.Model.Devices;
+using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Querying;
 using MediaBrowser.Model.Session;
 
-namespace MediaBrowser.Controller.Devices
+namespace MediaBrowser.Controller.Devices;
+
+/// <summary>
+/// Device manager interface.
+/// </summary>
+public interface IDeviceManager
 {
-    public interface IDeviceManager
-    {
-        event EventHandler<GenericEventArgs<Tuple<string, DeviceOptions>>> DeviceOptionsUpdated;
+    /// <summary>
+    /// Event handler for updated device options.
+    /// </summary>
+    event EventHandler<GenericEventArgs<Tuple<string, DeviceOptions>>> DeviceOptionsUpdated;
+
+    /// <summary>
+    /// Creates a new device.
+    /// </summary>
+    /// <param name="device">The device to create.</param>
+    /// <returns>A <see cref="Task{Device}"/> representing the creation of the device.</returns>
+    Task<Device> CreateDevice(Device device);
 
-        /// <summary>
-        /// Creates a new device.
-        /// </summary>
-        /// <param name="device">The device to create.</param>
-        /// <returns>A <see cref="Task{Device}"/> representing the creation of the device.</returns>
-        Task<Device> CreateDevice(Device device);
+    /// <summary>
+    /// Saves the capabilities.
+    /// </summary>
+    /// <param name="deviceId">The device id.</param>
+    /// <param name="capabilities">The capabilities.</param>
+    void SaveCapabilities(string deviceId, ClientCapabilities capabilities);
 
-        /// <summary>
-        /// Saves the capabilities.
-        /// </summary>
-        /// <param name="deviceId">The device id.</param>
-        /// <param name="capabilities">The capabilities.</param>
-        void SaveCapabilities(string deviceId, ClientCapabilities capabilities);
+    /// <summary>
+    /// Gets the capabilities.
+    /// </summary>
+    /// <param name="deviceId">The device id.</param>
+    /// <returns>ClientCapabilities.</returns>
+    ClientCapabilities GetCapabilities(string? deviceId);
 
-        /// <summary>
-        /// Gets the capabilities.
-        /// </summary>
-        /// <param name="deviceId">The device id.</param>
-        /// <returns>ClientCapabilities.</returns>
-        ClientCapabilities GetCapabilities(string deviceId);
+    /// <summary>
+    /// Gets the device information.
+    /// </summary>
+    /// <param name="id">The identifier.</param>
+    /// <returns>DeviceInfoDto.</returns>
+    DeviceInfoDto? GetDevice(string id);
 
-        /// <summary>
-        /// Gets the device information.
-        /// </summary>
-        /// <param name="id">The identifier.</param>
-        /// <returns>DeviceInfo.</returns>
-        DeviceInfo GetDevice(string id);
+    /// <summary>
+    /// Gets devices based on the provided query.
+    /// </summary>
+    /// <param name="query">The device query.</param>
+    /// <returns>A <see cref="Task{QueryResult}"/> representing the retrieval of the devices.</returns>
+    QueryResult<Device> GetDevices(DeviceQuery query);
 
-        /// <summary>
-        /// Gets devices based on the provided query.
-        /// </summary>
-        /// <param name="query">The device query.</param>
-        /// <returns>A <see cref="Task{QueryResult}"/> representing the retrieval of the devices.</returns>
-        QueryResult<Device> GetDevices(DeviceQuery query);
+    /// <summary>
+    /// Gets device infromation based on the provided query.
+    /// </summary>
+    /// <param name="query">The device query.</param>
+    /// <returns>A <see cref="Task{QueryResult}"/> representing the retrieval of the device information.</returns>
+    QueryResult<DeviceInfo> GetDeviceInfos(DeviceQuery query);
 
-        QueryResult<DeviceInfo> GetDeviceInfos(DeviceQuery query);
+    /// <summary>
+    /// Gets the device information.
+    /// </summary>
+    /// <param name="userId">The user's id, or <c>null</c>.</param>
+    /// <returns>IEnumerable&lt;DeviceInfoDto&gt;.</returns>
+    QueryResult<DeviceInfoDto> GetDevicesForUser(Guid? userId);
 
-        /// <summary>
-        /// Gets the devices.
-        /// </summary>
-        /// <param name="userId">The user's id, or <c>null</c>.</param>
-        /// <returns>IEnumerable&lt;DeviceInfo&gt;.</returns>
-        QueryResult<DeviceInfo> GetDevicesForUser(Guid? userId);
+    /// <summary>
+    /// Deletes a device.
+    /// </summary>
+    /// <param name="device">The device.</param>
+    /// <returns>A <see cref="Task"/> representing the deletion of the device.</returns>
+    Task DeleteDevice(Device device);
 
-        Task DeleteDevice(Device device);
+    /// <summary>
+    /// Updates a device.
+    /// </summary>
+    /// <param name="device">The device.</param>
+    /// <returns>A <see cref="Task"/> representing the update of the device.</returns>
+    Task UpdateDevice(Device device);
 
-        Task UpdateDevice(Device device);
+    /// <summary>
+    /// Determines whether this instance [can access device] the specified user identifier.
+    /// </summary>
+    /// <param name="user">The user to test.</param>
+    /// <param name="deviceId">The device id to test.</param>
+    /// <returns>Whether the user can access the device.</returns>
+    bool CanAccessDevice(User user, string deviceId);
 
-        /// <summary>
-        /// Determines whether this instance [can access device] the specified user identifier.
-        /// </summary>
-        /// <param name="user">The user to test.</param>
-        /// <param name="deviceId">The device id to test.</param>
-        /// <returns>Whether the user can access the device.</returns>
-        bool CanAccessDevice(User user, string deviceId);
+    /// <summary>
+    /// Updates the options of a device.
+    /// </summary>
+    /// <param name="deviceId">The device id.</param>
+    /// <param name="deviceName">The device name.</param>
+    /// <returns>A <see cref="Task"/> representing the update of the device options.</returns>
+    Task UpdateDeviceOptions(string deviceId, string? deviceName);
 
-        Task UpdateDeviceOptions(string deviceId, string deviceName);
+    /// <summary>
+    /// Gets the options of a device.
+    /// </summary>
+    /// <param name="deviceId">The device id.</param>
+    /// <returns><see cref="DeviceOptions"/> of the device.</returns>
+    DeviceOptionsDto? GetDeviceOptions(string deviceId);
 
-        DeviceOptions GetDeviceOptions(string deviceId);
-    }
+    /// <summary>
+    /// Gets the dto for client capabilites.
+    /// </summary>
+    /// <param name="capabilities">The client capabilities.</param>
+    /// <returns><see cref="ClientCapabilitiesDto"/> of the device.</returns>
+    ClientCapabilitiesDto ToClientCapabilitiesDto(ClientCapabilities capabilities);
 }

+ 11 - 15
MediaBrowser.Controller/Entities/BaseItem.cs

@@ -1087,12 +1087,7 @@ namespace MediaBrowser.Controller.Entities
 
                 return 1;
             }).ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
-            .ThenByDescending(i =>
-            {
-                var stream = i.VideoStream;
-
-                return stream is null || stream.Width is null ? 0 : stream.Width.Value;
-            })
+            .ThenByDescending(i => i, new MediaSourceWidthComparator())
             .ToList();
         }
 
@@ -1185,28 +1180,29 @@ namespace MediaBrowser.Controller.Entities
             return info;
         }
 
-        private string GetMediaSourceName(BaseItem item)
+        internal string GetMediaSourceName(BaseItem item)
         {
             var terms = new List<string>();
 
             var path = item.Path;
             if (item.IsFileProtocol && !string.IsNullOrEmpty(path))
             {
+                var displayName = System.IO.Path.GetFileNameWithoutExtension(path);
                 if (HasLocalAlternateVersions)
                 {
-                    var displayName = System.IO.Path.GetFileNameWithoutExtension(path)
-                        .Replace(System.IO.Path.GetFileName(ContainingFolderPath), string.Empty, StringComparison.OrdinalIgnoreCase)
-                        .TrimStart(new char[] { ' ', '-' });
-
-                    if (!string.IsNullOrEmpty(displayName))
+                    var containingFolderName = System.IO.Path.GetFileName(ContainingFolderPath);
+                    if (displayName.Length > containingFolderName.Length && displayName.StartsWith(containingFolderName, StringComparison.OrdinalIgnoreCase))
                     {
-                        terms.Add(displayName);
+                        var name = displayName.AsSpan(containingFolderName.Length).TrimStart([' ', '-']);
+                        if (!name.IsWhiteSpace())
+                        {
+                            terms.Add(name.ToString());
+                        }
                     }
                 }
 
                 if (terms.Count == 0)
                 {
-                    var displayName = System.IO.Path.GetFileNameWithoutExtension(path);
                     terms.Add(displayName);
                 }
             }
@@ -1612,7 +1608,7 @@ namespace MediaBrowser.Controller.Entities
             }
 
             var parent = GetParents().FirstOrDefault() ?? this;
-            if (parent is UserRootFolder or AggregateFolder)
+            if (parent is UserRootFolder or AggregateFolder or UserView)
             {
                 return true;
             }

+ 56 - 0
MediaBrowser.Controller/Entities/MediaSourceWidthComparator.cs

@@ -0,0 +1,56 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.Intrinsics.X86;
+using MediaBrowser.Model.Dto;
+
+namespace MediaBrowser.Controller.Entities;
+
+/// <summary>
+/// Compare MediaSource of the same file by Video width <see cref="IComparer{T}" />.
+/// </summary>
+public class MediaSourceWidthComparator : IComparer<MediaSourceInfo>
+{
+    /// <inheritdoc />
+    public int Compare(MediaSourceInfo? x, MediaSourceInfo? y)
+    {
+        if (x is null && y is null)
+        {
+            return 0;
+        }
+
+        if (x is null)
+        {
+            return -1;
+        }
+
+        if (y is null)
+        {
+            return 1;
+        }
+
+        if (string.Equals(x.Path, y.Path, StringComparison.OrdinalIgnoreCase))
+        {
+            if (x.VideoStream is null && y.VideoStream is null)
+            {
+                return 0;
+            }
+
+            if (x.VideoStream is null)
+            {
+                return -1;
+            }
+
+            if (y.VideoStream is null)
+            {
+                return 1;
+            }
+
+            var xWidth = x.VideoStream.Width ?? 0;
+            var yWidth = y.VideoStream.Width ?? 0;
+
+            return xWidth - yWidth;
+        }
+
+        return 0;
+    }
+}

+ 1 - 4
MediaBrowser.Controller/Entities/TV/Episode.cs

@@ -180,10 +180,7 @@ namespace MediaBrowser.Controller.Entities.TV
         }
 
         public string FindSeriesPresentationUniqueKey()
-        {
-            var series = Series;
-            return series is null ? null : series.PresentationUniqueKey;
-        }
+            => Series?.PresentationUniqueKey;
 
         public string FindSeasonName()
         {

+ 0 - 2
MediaBrowser.Controller/Entities/UserViewBuilder.cs

@@ -430,8 +430,6 @@ namespace MediaBrowser.Controller.Entities
             InternalItemsQuery query,
             ILibraryManager libraryManager)
         {
-            var user = query.User;
-
             // This must be the last filter
             if (!query.AdjacentTo.IsNullOrEmpty())
             {

+ 1 - 2
MediaBrowser.Controller/Events/Authentication/AuthenticationResultEventArgs.cs

@@ -1,6 +1,5 @@
 using System;
 using MediaBrowser.Controller.Authentication;
-using MediaBrowser.Controller.Session;
 using MediaBrowser.Model.Dto;
 
 namespace MediaBrowser.Controller.Events.Authentication;
@@ -29,7 +28,7 @@ public class AuthenticationResultEventArgs : EventArgs
     /// <summary>
     /// Gets or sets the session information.
     /// </summary>
-    public SessionInfo? SessionInfo { get; set; }
+    public SessionInfoDto? SessionInfo { get; set; }
 
     /// <summary>
     /// Gets or sets the server id.

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

@@ -39,6 +39,11 @@ namespace MediaBrowser.Controller.Extensions
         /// </summary>
         public const string FfmpegAnalyzeDurationKey = "FFmpeg:analyzeduration";
 
+        /// <summary>
+        /// The key for the FFmpeg image extraction performance tradeoff option.
+        /// </summary>
+        public const string FfmpegImgExtractPerfTradeoffKey = "FFmpeg:imgExtractPerfTradeoff";
+
         /// <summary>
         /// The key for the FFmpeg path option.
         /// </summary>
@@ -69,6 +74,11 @@ namespace MediaBrowser.Controller.Extensions
         /// </summary>
         public const string SqliteCacheSizeKey = "sqlite:cacheSize";
 
+        /// <summary>
+        /// The key for a setting that indicates whether the application should detect network status change.
+        /// </summary>
+        public const string DetectNetworkChangeKey = "DetectNetworkChange";
+
         /// <summary>
         /// Gets a value indicating whether the application should host static web content from the <see cref="IConfiguration"/>.
         /// </summary>
@@ -102,6 +112,14 @@ namespace MediaBrowser.Controller.Extensions
         public static bool GetFFmpegSkipValidation(this IConfiguration configuration)
             => configuration.GetValue<bool>(FfmpegSkipValidationKey);
 
+        /// <summary>
+        /// Gets a value indicating whether the server should trade off for performance during FFmpeg image extraction.
+        /// </summary>
+        /// <param name="configuration">The configuration to read the setting from.</param>
+        /// <returns><c>true</c> if the server should trade off for performance during FFmpeg image extraction, otherwise <c>false</c>.</returns>
+        public static bool GetFFmpegImgExtractPerfTradeoff(this IConfiguration configuration)
+            => configuration.GetValue<bool>(FfmpegImgExtractPerfTradeoffKey);
+
         /// <summary>
         /// Gets a value indicating whether playlists should allow duplicate entries from the <see cref="IConfiguration"/>.
         /// </summary>

+ 2 - 0
MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs

@@ -193,6 +193,8 @@ namespace MediaBrowser.Controller.MediaEncoding
 
         public bool EnableAudioVbrEncoding { get; set; }
 
+        public bool AlwaysBurnInSubtitleWhenTranscoding { get; set; }
+
         public string GetOption(string qualifier, string name)
         {
             var value = GetOption(qualifier + "-" + name);

Разлика између датотеке није приказан због своје велике величине
+ 322 - 284
MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs


+ 1 - 1
MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs

@@ -305,7 +305,7 @@ namespace MediaBrowser.Controller.MediaEncoding
                 if (BaseRequest.Static
                     || EncodingHelper.IsCopyCodec(OutputVideoCodec))
                 {
-                    return VideoStream is null ? null : (VideoStream.AverageFrameRate ?? VideoStream.RealFrameRate);
+                    return VideoStream?.ReferenceFrameRate;
                 }
 
                 return BaseRequest.MaxFramerate ?? BaseRequest.Framerate;

+ 9 - 0
MediaBrowser.Controller/MediaEncoding/ISubtitleEncoder.cs

@@ -44,5 +44,14 @@ namespace MediaBrowser.Controller.MediaEncoding
         /// <param name="cancellationToken">The cancellation token.</param>
         /// <returns>System.String.</returns>
         Task<string> GetSubtitleFileCharacterSet(MediaStream subtitleStream, string language, MediaSourceInfo mediaSource, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Gets the path to a subtitle file.
+        /// </summary>
+        /// <param name="subtitleStream">The subtitle stream.</param>
+        /// <param name="mediaSource">The media source.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        /// <returns>System.String.</returns>
+        Task<string> GetSubtitleFilePath(MediaStream subtitleStream, MediaSourceInfo mediaSource, CancellationToken cancellationToken);
     }
 }

+ 17 - 0
MediaBrowser.Controller/MediaSegements/IMediaSegmentManager.cs

@@ -1,5 +1,6 @@
 using System;
 using System.Collections.Generic;
+using System.Threading;
 using System.Threading.Tasks;
 using Jellyfin.Data.Entities;
 using Jellyfin.Data.Enums;
@@ -13,6 +14,15 @@ namespace MediaBrowser.Controller;
 /// </summary>
 public interface IMediaSegmentManager
 {
+    /// <summary>
+    /// Uses all segment providers enabled for the <see cref="BaseItem"/>'s library to get the Media Segments.
+    /// </summary>
+    /// <param name="baseItem">The Item to evaluate.</param>
+    /// <param name="overwrite">If set, will remove existing segments and replace it with new ones otherwise will check for existing segments and if found any, stops.</param>
+    /// <param name="cancellationToken">stop request token.</param>
+    /// <returns>A task that indicates the Operation is finished.</returns>
+    Task RunSegmentPluginProviders(BaseItem baseItem, bool overwrite, CancellationToken cancellationToken);
+
     /// <summary>
     /// Returns if this item supports media segments.
     /// </summary>
@@ -50,4 +60,11 @@ public interface IMediaSegmentManager
     /// <returns>True if there are any segments stored for the item, otherwise false.</returns>
     /// TODO: this should be async but as the only caller BaseItem.GetVersionInfo isn't async, this is also not. Venson.
     bool HasSegments(Guid itemId);
+
+    /// <summary>
+    /// Gets a list of all registered Segment Providers and their IDs.
+    /// </summary>
+    /// <param name="item">The media item that should be tested for providers.</param>
+    /// <returns>A list of all providers for the tested item.</returns>
+    IEnumerable<(string Name, string Id)> GetSupportedProviders(BaseItem item);
 }

+ 36 - 0
MediaBrowser.Controller/MediaSegements/IMediaSegmentProvider.cs

@@ -0,0 +1,36 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model;
+using MediaBrowser.Model.MediaSegments;
+
+namespace MediaBrowser.Controller;
+
+/// <summary>
+/// Provides methods for Obtaining the Media Segments from an Item.
+/// </summary>
+public interface IMediaSegmentProvider
+{
+    /// <summary>
+    /// Gets the provider name.
+    /// </summary>
+    string Name { get; }
+
+    /// <summary>
+    /// Enumerates all Media Segments from an Media Item.
+    /// </summary>
+    /// <param name="request">Arguments to enumerate MediaSegments.</param>
+    /// <param name="cancellationToken">Abort token.</param>
+    /// <returns>A list of all MediaSegments found from this provider.</returns>
+    Task<IReadOnlyList<MediaSegmentDto>> GetMediaSegments(MediaSegmentGenerationRequest request, CancellationToken cancellationToken);
+
+    /// <summary>
+    /// Should return support state for the given item.
+    /// </summary>
+    /// <param name="item">The base item to extract segments from.</param>
+    /// <returns>True if item is supported, otherwise false.</returns>
+    ValueTask<bool> Supports(BaseItem item);
+}

+ 3 - 2
MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SessionsMessage.cs

@@ -1,6 +1,7 @@
 using System.Collections.Generic;
 using System.ComponentModel;
 using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Session;
 
 namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
@@ -8,13 +9,13 @@ namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
 /// <summary>
 /// Sessions message.
 /// </summary>
-public class SessionsMessage : OutboundWebSocketMessage<IReadOnlyList<SessionInfo>>
+public class SessionsMessage : OutboundWebSocketMessage<IReadOnlyList<SessionInfoDto>>
 {
     /// <summary>
     /// Initializes a new instance of the <see cref="SessionsMessage"/> class.
     /// </summary>
     /// <param name="data">Session info.</param>
-    public SessionsMessage(IReadOnlyList<SessionInfo> data)
+    public SessionsMessage(IReadOnlyList<SessionInfoDto> data)
         : base(data)
     {
     }

+ 7 - 0
MediaBrowser.Controller/Providers/MetadataRefreshOptions.cs

@@ -29,6 +29,7 @@ namespace MediaBrowser.Controller.Providers
             IsAutomated = copy.IsAutomated;
             ImageRefreshMode = copy.ImageRefreshMode;
             ReplaceAllImages = copy.ReplaceAllImages;
+            RegenerateTrickplay = copy.RegenerateTrickplay;
             ReplaceImages = copy.ReplaceImages;
             SearchResult = copy.SearchResult;
             RemoveOldMetadata = copy.RemoveOldMetadata;
@@ -47,6 +48,12 @@ namespace MediaBrowser.Controller.Providers
         /// </summary>
         public bool ReplaceAllMetadata { get; set; }
 
+        /// <summary>
+        /// Gets or sets a value indicating whether all existing trickplay images should be overwritten
+        /// when paired with MetadataRefreshMode=FullRefresh.
+        /// </summary>
+        public bool RegenerateTrickplay { get; set; }
+
         public MetadataRefreshMode MetadataRefreshMode { get; set; }
 
         public RemoteSearchResult SearchResult { get; set; }

+ 12 - 0
MediaBrowser.Controller/Session/ISessionManager.cs

@@ -9,6 +9,7 @@ using System.Threading.Tasks;
 using Jellyfin.Data.Entities.Security;
 using MediaBrowser.Controller.Authentication;
 using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Session;
 using MediaBrowser.Model.SyncPlay;
 
@@ -292,6 +293,17 @@ namespace MediaBrowser.Controller.Session
         /// <returns>SessionInfo.</returns>
         SessionInfo GetSession(string deviceId, string client, string version);
 
+        /// <summary>
+        /// Gets all sessions available to a user.
+        /// </summary>
+        /// <param name="userId">The session identifier.</param>
+        /// <param name="deviceId">The device id.</param>
+        /// <param name="activeWithinSeconds">Active within session limit.</param>
+        /// <param name="controllableUserToCheck">Filter for sessions remote controllable for this user.</param>
+        /// <param name="isApiKey">Is the request authenticated with API key.</param>
+        /// <returns>IReadOnlyList{SessionInfoDto}.</returns>
+        IReadOnlyList<SessionInfoDto> GetSessions(Guid userId, string deviceId, int? activeWithinSeconds, Guid? controllableUserToCheck, bool isApiKey);
+
         /// <summary>
         /// Gets the session by authentication token.
         /// </summary>

+ 103 - 17
MediaBrowser.Controller/Session/SessionInfo.cs

@@ -1,7 +1,5 @@
 #nullable disable
 
-#pragma warning disable CS1591
-
 using System;
 using System.Collections.Generic;
 using System.Linq;
@@ -27,28 +25,45 @@ namespace MediaBrowser.Controller.Session
         private readonly ISessionManager _sessionManager;
         private readonly ILogger _logger;
 
-        private readonly object _progressLock = new object();
+        private readonly object _progressLock = new();
         private Timer _progressTimer;
         private PlaybackProgressInfo _lastProgressInfo;
 
-        private bool _disposed = false;
+        private bool _disposed;
 
+        /// <summary>
+        /// Initializes a new instance of the <see cref="SessionInfo"/> class.
+        /// </summary>
+        /// <param name="sessionManager">Instance of <see cref="ISessionManager"/> interface.</param>
+        /// <param name="logger">Instance of <see cref="ILogger"/> interface.</param>
         public SessionInfo(ISessionManager sessionManager, ILogger logger)
         {
             _sessionManager = sessionManager;
             _logger = logger;
 
-            AdditionalUsers = Array.Empty<SessionUserInfo>();
+            AdditionalUsers = [];
             PlayState = new PlayerStateInfo();
-            SessionControllers = Array.Empty<ISessionController>();
-            NowPlayingQueue = Array.Empty<QueueItem>();
-            NowPlayingQueueFullItems = Array.Empty<BaseItemDto>();
+            SessionControllers = [];
+            NowPlayingQueue = [];
+            NowPlayingQueueFullItems = [];
         }
 
+        /// <summary>
+        /// Gets or sets the play state.
+        /// </summary>
+        /// <value>The play state.</value>
         public PlayerStateInfo PlayState { get; set; }
 
-        public SessionUserInfo[] AdditionalUsers { get; set; }
+        /// <summary>
+        /// Gets or sets the additional users.
+        /// </summary>
+        /// <value>The additional users.</value>
+        public IReadOnlyList<SessionUserInfo> AdditionalUsers { get; set; }
 
+        /// <summary>
+        /// Gets or sets the client capabilities.
+        /// </summary>
+        /// <value>The client capabilities.</value>
         public ClientCapabilities Capabilities { get; set; }
 
         /// <summary>
@@ -67,7 +82,7 @@ namespace MediaBrowser.Controller.Session
             {
                 if (Capabilities is null)
                 {
-                    return Array.Empty<MediaType>();
+                    return [];
                 }
 
                 return Capabilities.PlayableMediaTypes;
@@ -134,9 +149,17 @@ namespace MediaBrowser.Controller.Session
         /// <value>The now playing item.</value>
         public BaseItemDto NowPlayingItem { get; set; }
 
+        /// <summary>
+        /// Gets or sets the now playing queue full items.
+        /// </summary>
+        /// <value>The now playing queue full items.</value>
         [JsonIgnore]
         public BaseItem FullNowPlayingItem { get; set; }
 
+        /// <summary>
+        /// Gets or sets the now viewing item.
+        /// </summary>
+        /// <value>The now viewing item.</value>
         public BaseItemDto NowViewingItem { get; set; }
 
         /// <summary>
@@ -156,8 +179,12 @@ namespace MediaBrowser.Controller.Session
         /// </summary>
         /// <value>The session controller.</value>
         [JsonIgnore]
-        public ISessionController[] SessionControllers { get; set; }
+        public IReadOnlyList<ISessionController> SessionControllers { get; set; }
 
+        /// <summary>
+        /// Gets or sets the transcoding info.
+        /// </summary>
+        /// <value>The transcoding info.</value>
         public TranscodingInfo TranscodingInfo { get; set; }
 
         /// <summary>
@@ -177,7 +204,7 @@ namespace MediaBrowser.Controller.Session
                     }
                 }
 
-                if (controllers.Length > 0)
+                if (controllers.Count > 0)
                 {
                     return false;
                 }
@@ -186,6 +213,10 @@ namespace MediaBrowser.Controller.Session
             }
         }
 
+        /// <summary>
+        /// Gets a value indicating whether the session supports media control.
+        /// </summary>
+        /// <value><c>true</c> if this session supports media control; otherwise, <c>false</c>.</value>
         public bool SupportsMediaControl
         {
             get
@@ -208,6 +239,10 @@ namespace MediaBrowser.Controller.Session
             }
         }
 
+        /// <summary>
+        /// Gets a value indicating whether the session supports remote control.
+        /// </summary>
+        /// <value><c>true</c> if this session supports remote control; otherwise, <c>false</c>.</value>
         public bool SupportsRemoteControl
         {
             get
@@ -230,16 +265,40 @@ namespace MediaBrowser.Controller.Session
             }
         }
 
+        /// <summary>
+        /// Gets or sets the now playing queue.
+        /// </summary>
+        /// <value>The now playing queue.</value>
         public IReadOnlyList<QueueItem> NowPlayingQueue { get; set; }
 
+        /// <summary>
+        /// Gets or sets the now playing queue full items.
+        /// </summary>
+        /// <value>The now playing queue full items.</value>
         public IReadOnlyList<BaseItemDto> NowPlayingQueueFullItems { get; set; }
 
+        /// <summary>
+        /// Gets or sets a value indicating whether the session has a custom device name.
+        /// </summary>
+        /// <value><c>true</c> if this session has a custom device name; otherwise, <c>false</c>.</value>
         public bool HasCustomDeviceName { get; set; }
 
+        /// <summary>
+        /// Gets or sets the playlist item id.
+        /// </summary>
+        /// <value>The splaylist item id.</value>
         public string PlaylistItemId { get; set; }
 
+        /// <summary>
+        /// Gets or sets the server id.
+        /// </summary>
+        /// <value>The server id.</value>
         public string ServerId { get; set; }
 
+        /// <summary>
+        /// Gets or sets the user primary image tag.
+        /// </summary>
+        /// <value>The user primary image tag.</value>
         public string UserPrimaryImageTag { get; set; }
 
         /// <summary>
@@ -247,8 +306,14 @@ namespace MediaBrowser.Controller.Session
         /// </summary>
         /// <value>The supported commands.</value>
         public IReadOnlyList<GeneralCommandType> SupportedCommands
-            => Capabilities is null ? Array.Empty<GeneralCommandType>() : Capabilities.SupportedCommands;
+            => Capabilities is null ? [] : Capabilities.SupportedCommands;
 
+        /// <summary>
+        /// Ensures a controller of type exists.
+        /// </summary>
+        /// <typeparam name="T">Class to register.</typeparam>
+        /// <param name="factory">The factory.</param>
+        /// <returns>Tuple{ISessionController, bool}.</returns>
         public Tuple<ISessionController, bool> EnsureController<T>(Func<SessionInfo, ISessionController> factory)
         {
             var controllers = SessionControllers.ToList();
@@ -261,18 +326,27 @@ namespace MediaBrowser.Controller.Session
             }
 
             var newController = factory(this);
-            _logger.LogDebug("Creating new {0}", newController.GetType().Name);
+            _logger.LogDebug("Creating new {Factory}", newController.GetType().Name);
             controllers.Add(newController);
 
-            SessionControllers = controllers.ToArray();
+            SessionControllers = [.. controllers];
             return new Tuple<ISessionController, bool>(newController, true);
         }
 
+        /// <summary>
+        /// Adds a controller to the session.
+        /// </summary>
+        /// <param name="controller">The controller.</param>
         public void AddController(ISessionController controller)
         {
-            SessionControllers = [..SessionControllers, controller];
+            SessionControllers = [.. SessionControllers, controller];
         }
 
+        /// <summary>
+        /// Gets a value indicating whether the session contains a user.
+        /// </summary>
+        /// <param name="userId">The user id to check.</param>
+        /// <returns><c>true</c> if this session contains the user; otherwise, <c>false</c>.</returns>
         public bool ContainsUser(Guid userId)
         {
             if (UserId.Equals(userId))
@@ -291,6 +365,11 @@ namespace MediaBrowser.Controller.Session
             return false;
         }
 
+        /// <summary>
+        /// Starts automatic progressing.
+        /// </summary>
+        /// <param name="progressInfo">The playback progress info.</param>
+        /// <value>The supported commands.</value>
         public void StartAutomaticProgress(PlaybackProgressInfo progressInfo)
         {
             if (_disposed)
@@ -359,6 +438,9 @@ namespace MediaBrowser.Controller.Session
             }
         }
 
+        /// <summary>
+        /// Stops automatic progressing.
+        /// </summary>
         public void StopAutomaticProgress()
         {
             lock (_progressLock)
@@ -373,6 +455,10 @@ namespace MediaBrowser.Controller.Session
             }
         }
 
+        /// <summary>
+        /// Disposes the instance async.
+        /// </summary>
+        /// <returns>ValueTask.</returns>
         public async ValueTask DisposeAsync()
         {
             _disposed = true;
@@ -380,7 +466,7 @@ namespace MediaBrowser.Controller.Session
             StopAutomaticProgress();
 
             var controllers = SessionControllers.ToList();
-            SessionControllers = Array.Empty<ISessionController>();
+            SessionControllers = [];
 
             foreach (var controller in controllers)
             {

+ 33 - 3
MediaBrowser.Controller/Trickplay/ITrickplayManager.cs

@@ -18,9 +18,10 @@ public interface ITrickplayManager
     /// </summary>
     /// <param name="video">The video.</param>
     /// <param name="replace">Whether or not existing data should be replaced.</param>
+    /// <param name="libraryOptions">The library options.</param>
     /// <param name="cancellationToken">CancellationToken to use for operation.</param>
     /// <returns>Task.</returns>
-    Task RefreshTrickplayDataAsync(Video video, bool replace, CancellationToken cancellationToken);
+    Task RefreshTrickplayDataAsync(Video video, bool replace, LibraryOptions? libraryOptions, CancellationToken cancellationToken);
 
     /// <summary>
     /// Creates trickplay tiles out of individual thumbnails.
@@ -33,7 +34,7 @@ public interface ITrickplayManager
     /// <remarks>
     /// The output directory will be DELETED and replaced if it already exists.
     /// </remarks>
-    TrickplayInfo CreateTiles(List<string> images, int width, TrickplayOptions options, string outputDir);
+    TrickplayInfo CreateTiles(IReadOnlyList<string> images, int width, TrickplayOptions options, string outputDir);
 
     /// <summary>
     /// Get available trickplay resolutions and corresponding info.
@@ -42,6 +43,14 @@ public interface ITrickplayManager
     /// <returns>Map of width resolutions to trickplay tiles info.</returns>
     Task<Dictionary<int, TrickplayInfo>> GetTrickplayResolutions(Guid itemId);
 
+    /// <summary>
+    /// Gets the item ids of all items with trickplay info.
+    /// </summary>
+    /// <param name="limit">The limit of items to return.</param>
+    /// <param name="offset">The offset to start the query at.</param>
+    /// <returns>The list of item ids that have trickplay info.</returns>
+    Task<IReadOnlyList<TrickplayInfo>> GetTrickplayItemsAsync(int limit, int offset);
+
     /// <summary>
     /// Saves trickplay info.
     /// </summary>
@@ -62,8 +71,29 @@ public interface ITrickplayManager
     /// <param name="item">The item.</param>
     /// <param name="width">The width of a single thumbnail.</param>
     /// <param name="index">The tile's index.</param>
+    /// <param name="saveWithMedia">Whether or not the tile should be saved next to the media file.</param>
+    /// <returns>The absolute path.</returns>
+    Task<string> GetTrickplayTilePathAsync(BaseItem item, int width, int index, bool saveWithMedia);
+
+    /// <summary>
+    /// Gets the path to a trickplay tile image.
+    /// </summary>
+    /// <param name="item">The item.</param>
+    /// <param name="tileWidth">The amount of images for the tile width.</param>
+    /// <param name="tileHeight">The amount of images for the tile height.</param>
+    /// <param name="width">The width of a single thumbnail.</param>
+    /// <param name="saveWithMedia">Whether or not the tile should be saved next to the media file.</param>
     /// <returns>The absolute path.</returns>
-    string GetTrickplayTilePath(BaseItem item, int width, int index);
+    string GetTrickplayDirectory(BaseItem item, int tileWidth, int tileHeight, int width, bool saveWithMedia = false);
+
+    /// <summary>
+    /// Migrates trickplay images between local and media directories.
+    /// </summary>
+    /// <param name="video">The video.</param>
+    /// <param name="libraryOptions">The library options.</param>
+    /// <param name="cancellationToken">CancellationToken to use for operation.</param>
+    /// <returns>Task.</returns>
+    Task MoveGeneratedTrickplayDataAsync(Video video, LibraryOptions? libraryOptions, CancellationToken cancellationToken);
 
     /// <summary>
     /// Gets the trickplay HLS playlist.

+ 2 - 2
MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs

@@ -284,7 +284,7 @@ namespace MediaBrowser.MediaEncoding.Attachments
 
                 if (extractableAttachmentIds.Count > 0)
                 {
-                    await CacheAllAttachmentsInternal(mediaPath, inputFile, mediaSource, extractableAttachmentIds, cancellationToken).ConfigureAwait(false);
+                    await CacheAllAttachmentsInternal(mediaPath, _mediaEncoder.GetInputArgument(inputFile, mediaSource), mediaSource, extractableAttachmentIds, cancellationToken).ConfigureAwait(false);
                 }
             }
             catch (Exception ex)
@@ -323,7 +323,7 @@ namespace MediaBrowser.MediaEncoding.Attachments
 
             processArgs += string.Format(
                 CultureInfo.InvariantCulture,
-                " -i \"{0}\" -t 0 -f null null",
+                " -i {0} -t 0 -f null null",
                 inputFile);
 
             int exitCode;

Неке датотеке нису приказане због велике количине промена