aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/ISSUE_TEMPLATE/issue report.yml1
-rw-r--r--.github/workflows/ci-codeql-analysis.yml8
-rw-r--r--.github/workflows/ci-compat.yml4
-rw-r--r--.github/workflows/ci-format.yml2
-rw-r--r--.github/workflows/ci-tests.yml2
-rw-r--r--.github/workflows/commands.yml4
-rw-r--r--.github/workflows/issue-template-check.yml2
-rw-r--r--.github/workflows/openapi-generate.yml2
-rw-r--r--.github/workflows/openapi-pull-request.yml2
-rw-r--r--.github/workflows/pull-request-conflict.yml11
-rw-r--r--.github/workflows/release-bump-version.yaml4
-rw-r--r--Directory.Packages.props2
-rw-r--r--Emby.Naming/TV/SeasonPathParser.cs23
-rw-r--r--Emby.Server.Implementations/ApplicationHost.cs1
-rw-r--r--Emby.Server.Implementations/Dto/DtoService.cs50
-rw-r--r--Emby.Server.Implementations/Library/ExternalDataManager.cs40
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs22
-rw-r--r--Emby.Server.Implementations/Library/MediaSourceManager.cs25
-rw-r--r--Emby.Server.Implementations/Library/PathManager.cs6
-rw-r--r--Emby.Server.Implementations/Localization/Core/el.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/sr.json4
-rw-r--r--Jellyfin.Api/Controllers/ItemUpdateController.cs2
-rw-r--r--Jellyfin.Api/Controllers/ItemsController.cs10
-rw-r--r--Jellyfin.Api/Controllers/LibraryController.cs13
-rw-r--r--Jellyfin.Api/Controllers/TvShowsController.cs6
-rw-r--r--Jellyfin.Api/Controllers/UserLibraryController.cs14
-rw-r--r--Jellyfin.Api/Controllers/VideosController.cs2
-rw-r--r--Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs175
-rw-r--r--Jellyfin.Server.Implementations/Users/UserManager.cs204
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260525010000_CleanupOrphanedExternalData.cs182
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260531160000_DisableLegacyAuthorization.cs32
-rw-r--r--MediaBrowser.Controller/Dto/DtoOptions.cs56
-rw-r--r--MediaBrowser.Controller/Entities/BaseItem.cs23
-rw-r--r--MediaBrowser.Controller/Entities/Folder.cs7
-rw-r--r--MediaBrowser.Controller/Entities/TagExtensions.cs1
-rw-r--r--MediaBrowser.Controller/Entities/Video.cs7
-rw-r--r--MediaBrowser.Controller/IO/IExternalDataManager.cs7
-rw-r--r--MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs147
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/AssWriter.cs57
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/ISubtitleWriter.cs20
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/JsonWriter.cs72
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/SrtWriter.cs49
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/SsaWriter.cs57
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs182
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/TtmlWriter.cs60
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/VttWriter.cs53
-rw-r--r--MediaBrowser.Model/Configuration/EncodingOptions.cs3
-rw-r--r--MediaBrowser.Model/Configuration/ServerConfiguration.cs2
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs2
-rw-r--r--MediaBrowser.Providers/TV/SeriesMetadataService.cs17
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Subtitles/FilterEventsTests.cs282
-rw-r--r--tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs22
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs137
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs131
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerLockHelperTests.cs93
55 files changed, 1548 insertions, 800 deletions
diff --git a/.github/ISSUE_TEMPLATE/issue report.yml b/.github/ISSUE_TEMPLATE/issue report.yml
index 0689db7a87..7a00dedbff 100644
--- a/.github/ISSUE_TEMPLATE/issue report.yml
+++ b/.github/ISSUE_TEMPLATE/issue report.yml
@@ -87,6 +87,7 @@ body:
label: Jellyfin Server version
description: What version of Jellyfin are you using?
options:
+ - 10.11.11
- 10.11.10
- 10.11.9
- 10.11.8
diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml
index 8d3e8fe6d5..06a66bab53 100644
--- a/.github/workflows/ci-codeql-analysis.yml
+++ b/.github/workflows/ci-codeql-analysis.yml
@@ -24,7 +24,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Setup .NET
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
@@ -32,13 +32,13 @@ jobs:
dotnet-version: '10.0.x'
- name: Initialize CodeQL
- uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
+ uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
languages: ${{ matrix.language }}
queries: +security-extended
- name: Autobuild
- uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
+ uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
+ uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
diff --git a/.github/workflows/ci-compat.yml b/.github/workflows/ci-compat.yml
index 99aa56f54f..a0564027e3 100644
--- a/.github/workflows/ci-compat.yml
+++ b/.github/workflows/ci-compat.yml
@@ -11,7 +11,7 @@ jobs:
permissions: read-all
steps:
- name: Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -40,7 +40,7 @@ jobs:
permissions: read-all
steps:
- name: Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
diff --git a/.github/workflows/ci-format.yml b/.github/workflows/ci-format.yml
index d6ba603fbb..a9eebf0663 100644
--- a/.github/workflows/ci-format.yml
+++ b/.github/workflows/ci-format.yml
@@ -15,7 +15,7 @@ jobs:
format-check:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
with:
diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml
index 928249d93e..6da1334039 100644
--- a/.github/workflows/ci-tests.yml
+++ b/.github/workflows/ci-tests.yml
@@ -20,7 +20,7 @@ jobs:
runs-on: "${{ matrix.os }}"
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
with:
diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml
index 9d3d99cb71..43ef0aab37 100644
--- a/.github/workflows/commands.yml
+++ b/.github/workflows/commands.yml
@@ -24,7 +24,7 @@ jobs:
reactions: '+1'
- name: Checkout the latest code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
token: ${{ secrets.JF_BOT_TOKEN }}
fetch-depth: 0
@@ -40,7 +40,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: pull in script
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
repository: jellyfin/jellyfin-triage-script
diff --git a/.github/workflows/issue-template-check.yml b/.github/workflows/issue-template-check.yml
index dcd1fb7cfe..ef5c7c09f2 100644
--- a/.github/workflows/issue-template-check.yml
+++ b/.github/workflows/issue-template-check.yml
@@ -10,7 +10,7 @@ jobs:
issues: write
steps:
- name: pull in script
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
repository: jellyfin/jellyfin-triage-script
diff --git a/.github/workflows/openapi-generate.yml b/.github/workflows/openapi-generate.yml
index c2a5199c54..122bbd69ac 100644
--- a/.github/workflows/openapi-generate.yml
+++ b/.github/workflows/openapi-generate.yml
@@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ inputs.ref }}
repository: ${{ inputs.repository }}
diff --git a/.github/workflows/openapi-pull-request.yml b/.github/workflows/openapi-pull-request.yml
index 4acd0f4d4f..d11b0140f7 100644
--- a/.github/workflows/openapi-pull-request.yml
+++ b/.github/workflows/openapi-pull-request.yml
@@ -10,7 +10,7 @@ jobs:
base_ref: ${{ steps.ancestor.outputs.base_ref }}
steps:
- name: Checkout Repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
diff --git a/.github/workflows/pull-request-conflict.yml b/.github/workflows/pull-request-conflict.yml
index 3f6af02a48..ce671eb72e 100644
--- a/.github/workflows/pull-request-conflict.yml
+++ b/.github/workflows/pull-request-conflict.yml
@@ -5,18 +5,19 @@ on:
branches:
- master
pull_request_target:
- issue_comment:
+ types: [synchronize]
permissions: {}
jobs:
- label:
- name: Labeling
+ main:
runs-on: ubuntu-latest
- if: ${{ github.repository == 'jellyfin/jellyfin' && github.event.issue.pull_request }}
+ permissions:
+ contents: read
+ pull-requests: write
+ if: ${{ github.repository == 'jellyfin/jellyfin' }}
steps:
- name: Apply label
uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0
- if: ${{ github.event_name == 'push' || github.event_name == 'pull_request_target'}}
with:
dirtyLabel: 'merge conflict'
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
diff --git a/.github/workflows/release-bump-version.yaml b/.github/workflows/release-bump-version.yaml
index 963b4a6023..5bb668c89c 100644
--- a/.github/workflows/release-bump-version.yaml
+++ b/.github/workflows/release-bump-version.yaml
@@ -33,7 +33,7 @@ jobs:
yq-version: v4.9.8
- name: Checkout Repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ env.TAG_BRANCH }}
@@ -66,7 +66,7 @@ jobs:
NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }}
steps:
- name: Checkout Repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ env.TAG_BRANCH }}
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 7c70b5a9e9..1c26dd34e8 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -78,7 +78,7 @@
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.0" />
<PackageVersion Include="System.Text.Json" Version="10.0.8" />
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
- <PackageVersion Include="z440.atl.core" Version="7.14.0" />
+ <PackageVersion Include="z440.atl.core" Version="7.15.3" />
<PackageVersion Include="TMDbLib" Version="3.0.0" />
<PackageVersion Include="UTF.Unknown" Version="2.6.0" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
diff --git a/Emby.Naming/TV/SeasonPathParser.cs b/Emby.Naming/TV/SeasonPathParser.cs
index ea4875e00a..9caebaf7ac 100644
--- a/Emby.Naming/TV/SeasonPathParser.cs
+++ b/Emby.Naming/TV/SeasonPathParser.cs
@@ -10,17 +10,25 @@ namespace Emby.Naming.TV
/// </summary>
public static partial class SeasonPathParser
{
+ private const string SeasonKeywordPattern =
+ @"시즌|シーズン|сезон" +
+ @"|season|sæson|saison|staffel|series|stagione|säsong|seizoen|seasong" +
+ @"|sezon|sezona|sezóna|sezonul|série|séria|serie|seria|temporada|kausi";
+
private static readonly Regex CleanNameRegex = new(@"[ ._\-\[\]]", RegexOptions.Compiled);
- [GeneratedRegex(@"^\s*((?<seasonnumber>(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul|érie|éria|erie|eria)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
+ [GeneratedRegex(@"^\s*((?<seasonnumber>(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:" + SeasonKeywordPattern + @")\s*(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
private static partial Regex ProcessPre();
- [GeneratedRegex(@"^\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul|érie|éria|erie|eria)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<seasonnumber>\d+?)(?=\d{3,4}p|[^\d]|$)(?!\s*[Ee]\d)(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
+ [GeneratedRegex(@"^\s*(?:" + SeasonKeywordPattern + @")\s*(?<seasonnumber>\d+?)(?=\d{3,4}p|[^\d]|$)(?!\s*[Ee]\d)(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
private static partial Regex ProcessPost();
[GeneratedRegex(@"[sS](\d{1,4})(?!\d|[eE]\d)(?=\.|_|-|\[|\]|\s|$)", RegexOptions.None)]
private static partial Regex SeasonPrefix();
+ [GeneratedRegex(SeasonKeywordPattern, RegexOptions.IgnoreCase)]
+ private static partial Regex SeasonKeyword();
+
/// <summary>
/// Attempts to parse season number from path.
/// </summary>
@@ -91,14 +99,25 @@ namespace Emby.Naming.TV
return (val, true);
}
+ bool isMixedLibrary = !supportNumericSeasonFolders && !supportSpecialAliases;
var preMatch = ProcessPre().Match(filename);
if (preMatch.Success)
{
+ if (isMixedLibrary && !SeasonKeyword().IsMatch(fileName))
+ {
+ return (null, false);
+ }
+
return CheckMatch(preMatch);
}
else
{
var postMatch = ProcessPost().Match(filename);
+ if (postMatch.Success && isMixedLibrary && !SeasonKeyword().IsMatch(fileName))
+ {
+ return (null, false);
+ }
+
return CheckMatch(postMatch);
}
}
diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs
index 787eee654a..14380c33bf 100644
--- a/Emby.Server.Implementations/ApplicationHost.cs
+++ b/Emby.Server.Implementations/ApplicationHost.cs
@@ -540,6 +540,7 @@ namespace Emby.Server.Implementations
serviceCollection.AddTransient(provider => new Lazy<ILibraryMonitor>(provider.GetRequiredService<ILibraryMonitor>));
serviceCollection.AddTransient(provider => new Lazy<IProviderManager>(provider.GetRequiredService<IProviderManager>));
serviceCollection.AddTransient(provider => new Lazy<IUserViewManager>(provider.GetRequiredService<IUserViewManager>));
+ serviceCollection.AddTransient(provider => new Lazy<IExternalDataManager>(provider.GetRequiredService<IExternalDataManager>));
serviceCollection.AddSingleton<ILibraryManager, LibraryManager>();
serviceCollection.AddSingleton<NamingOptions>();
serviceCollection.AddSingleton<VideoListResolver>();
diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs
index 321c7da1c4..3cd72a8ac1 100644
--- a/Emby.Server.Implementations/Dto/DtoService.cs
+++ b/Emby.Server.Implementations/Dto/DtoService.cs
@@ -1366,6 +1366,41 @@ namespace Emby.Server.Implementations.Dto
}
}
+ if (options.PreferEpisodeParentPoster)
+ {
+ var episodeSeason = episode.Season;
+ var seasonPrimaryTag = episodeSeason is not null
+ ? GetTagAndFillBlurhash(dto, episodeSeason, ImageType.Primary)
+ : null;
+
+ BaseItem? posterParent = null;
+ if (seasonPrimaryTag is not null)
+ {
+ dto.ParentPrimaryImageItemId = episodeSeason!.Id;
+ dto.ParentPrimaryImageTag = seasonPrimaryTag;
+ posterParent = episodeSeason;
+ }
+ else if (episodeSeries is not null && dto.SeriesPrimaryImageTag is not null)
+ {
+ dto.ParentPrimaryImageItemId = episodeSeries.Id;
+ dto.ParentPrimaryImageTag = dto.SeriesPrimaryImageTag;
+ posterParent = episodeSeries;
+ }
+
+ if (posterParent is not null)
+ {
+ if (dto.ImageTags is not null && dto.ImageTags.Remove(ImageType.Primary, out var ownPrimaryTag))
+ {
+ // Only drop the episode's own primary blurhash; keep the poster parent's.
+ dto.ImageBlurHashes?.GetValueOrDefault(ImageType.Primary)?.Remove(ownPrimaryTag);
+ }
+
+ dto.SeriesPrimaryImageTag = null;
+ dto.PrimaryImageAspectRatio = null;
+ AttachPrimaryImageAspectRatio(dto, posterParent);
+ }
+ }
+
if (options.ContainsField(ItemFields.SeriesStudio))
{
episodeSeries ??= episode.Series;
@@ -1504,6 +1539,21 @@ namespace Emby.Server.Implementations.Dto
private void AddInheritedImages(BaseItemDto dto, BaseItem item, DtoOptions options, BaseItem? owner)
{
+ if (item is UserView { ViewType: CollectionType.playlists } playlistsView
+ && options.GetImageLimit(ImageType.Primary) > 0
+ && !playlistsView.DisplayParentId.IsEmpty())
+ {
+ var displayParent = _libraryManager.GetItemById(playlistsView.DisplayParentId);
+ var displayParentPrimaryImage = displayParent?.GetImageInfo(ImageType.Primary, 0);
+
+ if (displayParentPrimaryImage is not null)
+ {
+ dto.ImageTags?.Remove(ImageType.Primary);
+ dto.ParentPrimaryImageItemId = displayParent!.Id;
+ dto.ParentPrimaryImageTag = GetTagAndFillBlurhash(dto, displayParent, displayParentPrimaryImage);
+ }
+ }
+
if (!item.SupportsInheritedParentImages)
{
return;
diff --git a/Emby.Server.Implementations/Library/ExternalDataManager.cs b/Emby.Server.Implementations/Library/ExternalDataManager.cs
index 4ad0f999bf..2c18e56df7 100644
--- a/Emby.Server.Implementations/Library/ExternalDataManager.cs
+++ b/Emby.Server.Implementations/Library/ExternalDataManager.cs
@@ -1,6 +1,5 @@
using System;
using System.IO;
-using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Chapters;
@@ -52,26 +51,33 @@ public class ExternalDataManager : IExternalDataManager
/// <inheritdoc/>
public async Task DeleteExternalItemDataAsync(BaseItem item, CancellationToken cancellationToken)
{
- var validPaths = _pathManager.GetExtractedDataPaths(item).Where(Directory.Exists).ToList();
- var itemId = item.Id;
- if (validPaths.Count > 0)
- {
- foreach (var path in validPaths)
- {
- try
- {
- Directory.Delete(path, true);
- }
- catch (Exception ex)
- {
- _logger.LogWarning("Unable to prune external item data at {Path}: {Exception}", path, ex);
- }
- }
- }
+ DeleteExternalItemFiles(item);
+ var itemId = item.Id;
await _keyframeManager.DeleteKeyframeDataAsync(itemId, cancellationToken).ConfigureAwait(false);
await _mediaSegmentManager.DeleteSegmentsAsync(itemId, cancellationToken).ConfigureAwait(false);
await _trickplayManager.DeleteTrickplayDataAsync(itemId, cancellationToken).ConfigureAwait(false);
await _chapterManager.DeleteChapterDataAsync(itemId, cancellationToken).ConfigureAwait(false);
}
+
+ /// <inheritdoc/>
+ public void DeleteExternalItemFiles(BaseItem item)
+ {
+ foreach (var path in _pathManager.GetExtractedDataPaths(item))
+ {
+ if (!Directory.Exists(path))
+ {
+ continue;
+ }
+
+ try
+ {
+ Directory.Delete(path, true);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning("Unable to prune external item data at {Path}: {Exception}", path, ex);
+ }
+ }
+ }
}
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index a826db090f..6ed417c395 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -89,6 +89,7 @@ namespace Emby.Server.Implementations.Library
private readonly FastConcurrentLru<Guid, BaseItem> _cache;
private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule;
private readonly IMediaStreamRepository _mediaStreamRepository;
+ private readonly Lazy<IExternalDataManager> _externalDataManagerFactory;
/// <summary>
/// The _root folder sync lock.
@@ -132,6 +133,7 @@ namespace Emby.Server.Implementations.Library
/// <param name="pathManager">The path manager.</param>
/// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param>
/// <param name="mediaStreamRepository">The media stream repository.</param>
+ /// <param name="externalDataManagerFactory">The external data manager (lazy, to break the DI cycle through ChapterManager).</param>
public LibraryManager(
IServerApplicationHost appHost,
ILoggerFactory loggerFactory,
@@ -155,7 +157,8 @@ namespace Emby.Server.Implementations.Library
IPeopleRepository peopleRepository,
IPathManager pathManager,
DotIgnoreIgnoreRule dotIgnoreIgnoreRule,
- IMediaStreamRepository mediaStreamRepository)
+ IMediaStreamRepository mediaStreamRepository,
+ Lazy<IExternalDataManager> externalDataManagerFactory)
{
_appHost = appHost;
_logger = loggerFactory.CreateLogger<LibraryManager>();
@@ -186,6 +189,7 @@ namespace Emby.Server.Implementations.Library
_configurationManager.ConfigurationUpdated += ConfigurationUpdated;
_mediaStreamRepository = mediaStreamRepository;
+ _externalDataManagerFactory = externalDataManagerFactory;
RecordConfigurationValues(_configurationManager.Configuration);
}
@@ -396,6 +400,12 @@ namespace Emby.Server.Implementations.Library
}
}
+ var externalDataManager = _externalDataManagerFactory.Value;
+ foreach (var (item, _, _) in pathMaps)
+ {
+ externalDataManager.DeleteExternalItemFiles(item);
+ }
+
_persistenceService.DeleteItem([.. pathMaps.Select(f => f.Item.Id)]);
}
@@ -576,6 +586,13 @@ namespace Emby.Server.Implementations.Library
item.SetParent(null);
+ var externalDataManager = _externalDataManagerFactory.Value;
+ externalDataManager.DeleteExternalItemFiles(item);
+ foreach (var child in children)
+ {
+ externalDataManager.DeleteExternalItemFiles(child);
+ }
+
_persistenceService.DeleteItem([item.Id, .. children.Select(f => f.Id)]);
_cache.TryRemove(item.Id, out _);
foreach (var child in children)
@@ -1987,7 +2004,8 @@ namespace Emby.Server.Implementations.Library
query.TopParentIds.Length == 0 &&
string.IsNullOrEmpty(query.AncestorWithPresentationUniqueKey) &&
string.IsNullOrEmpty(query.SeriesPresentationUniqueKey) &&
- query.ItemIds.Length == 0)
+ query.ItemIds.Length == 0 &&
+ query.OwnerIds.Length == 0)
{
var userViews = UserViewManager.GetUserViews(new UserViewQuery
{
diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs
index 0caf66555a..9ccfefa86e 100644
--- a/Emby.Server.Implementations/Library/MediaSourceManager.cs
+++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs
@@ -24,6 +24,7 @@ using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.MediaEncoding;
@@ -176,6 +177,7 @@ namespace Emby.Server.Implementations.Library
public async Task<IReadOnlyList<MediaSourceInfo>> GetPlaybackMediaSources(BaseItem item, User user, bool allowMediaProbe, bool enablePathSubstitution, CancellationToken cancellationToken)
{
var mediaSources = GetStaticMediaSources(item, enablePathSubstitution, user);
+ ResolveSymlinkPaths(mediaSources, enablePathSubstitution);
// If file is strm or main media stream is missing, force a metadata refresh with remote probing
if (allowMediaProbe && mediaSources[0].Type != MediaSourceType.Placeholder
@@ -192,6 +194,7 @@ namespace Emby.Server.Implementations.Library
cancellationToken).ConfigureAwait(false);
mediaSources = GetStaticMediaSources(item, enablePathSubstitution, user);
+ ResolveSymlinkPaths(mediaSources, enablePathSubstitution);
}
var dynamicMediaSources = await GetDynamicMediaSources(item, cancellationToken).ConfigureAwait(false);
@@ -324,6 +327,28 @@ namespace Emby.Server.Implementations.Library
}
}
+ /// <summary>
+ /// Resolves symlinked file paths on the supplied sources to the real on-disk target.
+ /// Skipped when <paramref name="enablePathSubstitution"/> is set because the path may
+ /// already have been rewritten to a UNC/URL meant for the client to consume directly.
+ /// </summary>
+ private static void ResolveSymlinkPaths(IReadOnlyList<MediaSourceInfo> sources, bool enablePathSubstitution)
+ {
+ if (enablePathSubstitution)
+ {
+ return;
+ }
+
+ foreach (var source in sources)
+ {
+ if (source.Protocol == MediaProtocol.File
+ && FileSystemHelper.ResolveLinkTarget(source.Path, returnFinalTarget: true) is { Exists: true } target)
+ {
+ source.Path = target.FullName;
+ }
+ }
+ }
+
private static void SetKeyProperties(IMediaSourceProvider provider, MediaSourceInfo mediaSource)
{
var prefix = provider.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture) + LiveStreamIdDelimiter;
diff --git a/Emby.Server.Implementations/Library/PathManager.cs b/Emby.Server.Implementations/Library/PathManager.cs
index ef5edb9afa..fad948ad97 100644
--- a/Emby.Server.Implementations/Library/PathManager.cs
+++ b/Emby.Server.Implementations/Library/PathManager.cs
@@ -121,7 +121,11 @@ public class PathManager : IPathManager
}
paths.Add(GetTrickplayDirectory(item, false));
- paths.Add(GetTrickplayDirectory(item, true));
+ if (!string.IsNullOrEmpty(item.Path))
+ {
+ paths.Add(GetTrickplayDirectory(item, true));
+ }
+
paths.Add(GetChapterImageFolderPath(item));
return paths;
diff --git a/Emby.Server.Implementations/Localization/Core/el.json b/Emby.Server.Implementations/Localization/Core/el.json
index d84afdc1b6..c0ad2c165a 100644
--- a/Emby.Server.Implementations/Localization/Core/el.json
+++ b/Emby.Server.Implementations/Localization/Core/el.json
@@ -50,7 +50,7 @@
"ScheduledTaskFailedWithName": "{0} αποτυχία",
"Shows": "Σειρές",
"StartupEmbyServerIsLoading": "Ο διακομιστής Jellyfin φορτώνει. Περιμένετε λίγο και δοκιμάστε ξανά.",
- "SubtitleDownloadFailureFromForItem": "Αποτυχίες μεταφόρτωσης υποτίτλων από {0} για {1}",
+ "SubtitleDownloadFailureFromForItem": "Αποτυχία λήψης υποτίτλων από {0} για {1}",
"TvShows": "Τηλεοπτικές Σειρές",
"UserCreatedWithName": "Ο χρήστης {0} δημιουργήθηκε",
"UserDeletedWithName": "Ο χρήστης {0} έχει διαγραφεί",
@@ -106,5 +106,7 @@
"TaskExtractMediaSegments": "Σάρωση τμημάτων πολυμέσων",
"TaskExtractMediaSegmentsDescription": "Εξάγει ή βρίσκει τμήματα πολυμέσων από επεκτάσεις που χρησιμοποιούν το MediaSegment.",
"CleanupUserDataTaskDescription": "Καθαρίζει όλα τα δεδομένα χρήστη (κατάσταση παρακολούθησης, κατάσταση αγαπημένων κ.λπ.) από πολυμέσα που δεν υπάρχουν πλέον για τουλάχιστον 90 ημέρες.",
- "CleanupUserDataTask": "Εργασία εκκαθάρισης δεδομένων χρήστη"
+ "CleanupUserDataTask": "Εργασία εκκαθάρισης δεδομένων χρήστη",
+ "LyricDownloadFailureFromForItem": "Αποτυχία λήψης στίχων από {0} για {1}",
+ "Original": "Πρωτότυπο"
}
diff --git a/Emby.Server.Implementations/Localization/Core/sr.json b/Emby.Server.Implementations/Localization/Core/sr.json
index 56806e25c1..92f309c80c 100644
--- a/Emby.Server.Implementations/Localization/Core/sr.json
+++ b/Emby.Server.Implementations/Localization/Core/sr.json
@@ -106,5 +106,7 @@
"CleanupUserDataTask": "Задатак чишћења корисничких података",
"CleanupUserDataTaskDescription": "Чисти све корисничке податке (напредак гледања, ознаке за омиљено...) медија који нису доступни 90 дана или дуже.",
"TaskMoveTrickplayImages": "Промени локацију сличица за визуелно премотавање",
- "TaskDownloadMissingLyricsDescription": "Преузми стихове песама"
+ "TaskDownloadMissingLyricsDescription": "Преузми стихове песама",
+ "LyricDownloadFailureFromForItem": "Није успело преузимање стихова са {0} за {1}",
+ "Original": "Изворно"
}
diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs
index 4d697ab854..d560ee8238 100644
--- a/Jellyfin.Api/Controllers/ItemUpdateController.cs
+++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs
@@ -288,7 +288,7 @@ public class ItemUpdateController : BaseJellyfinApiController
item.CustomRating = request.CustomRating;
var currentTags = item.Tags;
- var newTags = request.Tags.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
+ var newTags = request.Tags.Select(t => t.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
var removedTags = currentTags.Except(newTags).ToList();
var addedTags = newTags.Except(currentTags).ToList();
item.Tags = newTags;
diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs
index 15c2e922f8..9115227707 100644
--- a/Jellyfin.Api/Controllers/ItemsController.cs
+++ b/Jellyfin.Api/Controllers/ItemsController.cs
@@ -323,9 +323,6 @@ public class ItemsController : BaseJellyfinApiController
}
else if (folder is ICollectionFolder)
{
- // When the client doesn't specify recursive/includeItemTypes, force the query
- // through the database path where all filters (IsHD, genres, etc.) are applied.
- recursive ??= true;
if (includeItemTypes.Length == 0)
{
includeItemTypes = collectionType switch
@@ -335,6 +332,13 @@ public class ItemsController : BaseJellyfinApiController
_ => []
};
}
+
+ // When the client doesn't specify recursive/includeItemTypes, force the query
+ // through the database path where all filters (IsHD, genres, etc.) are applied.
+ if (includeItemTypes.Length > 0)
+ {
+ recursive ??= true;
+ }
}
if (item is not UserRootFolder
diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs
index 6a30a80f1d..39a6fbace8 100644
--- a/Jellyfin.Api/Controllers/LibraryController.cs
+++ b/Jellyfin.Api/Controllers/LibraryController.cs
@@ -119,7 +119,18 @@ public class LibraryController : BaseJellyfinApiController
return NotFound();
}
- return PhysicalFile(item.Path, MimeTypes.GetMimeType(item.Path), true);
+ var filePath = item.Path;
+ if (item.IsFileProtocol)
+ {
+ // PhysicalFile does not work well with symlinks at the moment.
+ var resolved = FileSystemHelper.ResolveLinkTarget(filePath, returnFinalTarget: true);
+ if (resolved is not null && resolved.Exists)
+ {
+ filePath = resolved.FullName;
+ }
+ }
+
+ return PhysicalFile(filePath, MimeTypes.GetMimeType(filePath), true);
}
/// <summary>
diff --git a/Jellyfin.Api/Controllers/TvShowsController.cs b/Jellyfin.Api/Controllers/TvShowsController.cs
index e45a100b77..340a54e13b 100644
--- a/Jellyfin.Api/Controllers/TvShowsController.cs
+++ b/Jellyfin.Api/Controllers/TvShowsController.cs
@@ -232,7 +232,7 @@ public class TvShowsController : BaseJellyfinApiController
if (seasonId.HasValue) // Season id was supplied. Get episodes by season id.
{
- var item = _libraryManager.GetItemById<BaseItem>(seasonId.Value);
+ var item = _libraryManager.GetItemById<BaseItem>(seasonId.Value, user);
if (item is not Season seasonItem)
{
return NotFound("No season exists with Id " + seasonId);
@@ -242,7 +242,7 @@ public class TvShowsController : BaseJellyfinApiController
}
else if (season.HasValue) // Season number was supplied. Get episodes by season number
{
- var series = _libraryManager.GetItemById<Series>(seriesId);
+ var series = _libraryManager.GetItemById<Series>(seriesId, user);
if (series is null)
{
return NotFound("Series not found");
@@ -258,7 +258,7 @@ public class TvShowsController : BaseJellyfinApiController
}
else // No season number or season id was supplied. Returning all episodes.
{
- if (_libraryManager.GetItemById<BaseItem>(seriesId) is not Series series)
+ if (_libraryManager.GetItemById<BaseItem>(seriesId, user) is not Series series)
{
return NotFound("Series not found");
}
diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs
index 779186942a..25f781e496 100644
--- a/Jellyfin.Api/Controllers/UserLibraryController.cs
+++ b/Jellyfin.Api/Controllers/UserLibraryController.cs
@@ -429,14 +429,8 @@ public class UserLibraryController : BaseJellyfinApiController
}
var dtoOptions = new DtoOptions();
- if (item is IHasTrailers hasTrailers)
- {
- var trailers = hasTrailers.LocalTrailers;
- return Ok(_dtoService.GetBaseItemDtos(trailers, dtoOptions, user, item).AsEnumerable());
- }
- return Ok(item.GetExtras()
- .Where(e => e.ExtraType == ExtraType.Trailer)
+ return Ok(item.GetExtras([ExtraType.Trailer], user)
.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)));
}
@@ -487,7 +481,7 @@ public class UserLibraryController : BaseJellyfinApiController
var dtoOptions = new DtoOptions();
return Ok(item
- .GetExtras()
+ .GetExtras(user)
.Where(i => i.ExtraType.HasValue && BaseItem.DisplayExtraTypes.Contains(i.ExtraType.Value))
.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)));
}
@@ -557,6 +551,8 @@ public class UserLibraryController : BaseJellyfinApiController
var dtoOptions = new DtoOptions { Fields = fields }
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+ dtoOptions.PreferEpisodeParentPoster = true;
+
var list = _userViewManager.GetLatestItems(
new LatestItemsQuery
{
@@ -577,7 +573,7 @@ public class UserLibraryController : BaseJellyfinApiController
var item = tuple.Item2[0];
var childCount = 0;
- if (tuple.Item1 is not null && (tuple.Item2.Count > 1 || tuple.Item1 is MusicAlbum || tuple.Item1 is Series))
+ if (tuple.Item1 is not null && (tuple.Item2.Count > 1 || tuple.Item1 is MusicAlbum))
{
item = tuple.Item1;
childCount = tuple.Item2.Count;
diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs
index ed6d3f5bde..29a92cdb90 100644
--- a/Jellyfin.Api/Controllers/VideosController.cs
+++ b/Jellyfin.Api/Controllers/VideosController.cs
@@ -116,7 +116,7 @@ public class VideosController : BaseJellyfinApiController
BaseItemDto[] items;
if (item is Video video)
{
- items = video.GetAdditionalParts()
+ items = video.GetAdditionalParts(user)
.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, video))
.ToArray();
}
diff --git a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
index 0791e04e85..58b9f7f822 100644
--- a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
+++ b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
@@ -4,6 +4,7 @@ using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
+using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using AsyncKeyedLock;
@@ -28,7 +29,7 @@ namespace Jellyfin.Server.Implementations.Trickplay;
/// <summary>
/// ITrickplayManager implementation.
/// </summary>
-public class TrickplayManager : ITrickplayManager
+public partial class TrickplayManager : ITrickplayManager
{
private readonly ILogger<TrickplayManager> _logger;
private readonly IMediaEncoder _mediaEncoder;
@@ -135,6 +136,147 @@ public class TrickplayManager : ITrickplayManager
}
}
+ private async Task DiscoverExistingTrickplayAsync(Video video, bool saveWithMedia, CancellationToken cancellationToken)
+ {
+ var options = _config.Configuration.TrickplayOptions;
+ var existing = await GetTrickplayResolutions(video.Id).ConfigureAwait(false);
+
+ // Remove DB rows whose on-disk folder no longer exists in either possible location.
+ // Checking both locations avoids dropping rows mid-`SaveTrickplayWithMedia` migration.
+ var orphanedWidths = new List<int>();
+ foreach (var (width, info) in existing)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var localDir = GetTrickplayDirectory(video, info.TileWidth, info.TileHeight, info.Width, false);
+ var mediaDir = GetTrickplayDirectory(video, info.TileWidth, info.TileHeight, info.Width, true);
+ if (!HasTrickplayTiles(localDir) && !HasTrickplayTiles(mediaDir))
+ {
+ orphanedWidths.Add(width);
+ }
+ }
+
+ if (orphanedWidths.Count > 0)
+ {
+ var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
+ {
+ await dbContext.TrickplayInfos
+ .Where(i => i.ItemId.Equals(video.Id) && orphanedWidths.Contains(i.Width))
+ .ExecuteDeleteAsync(cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ foreach (var width in orphanedWidths)
+ {
+ _logger.LogInformation("Removed orphaned trickplay DB entry width={Width} for {Path}", width, video.Path);
+ existing.Remove(width);
+ }
+ }
+
+ var trickplayDirectory = _pathManager.GetTrickplayDirectory(video, saveWithMedia);
+ if (!Directory.Exists(trickplayDirectory))
+ {
+ return;
+ }
+
+ foreach (var subdir in new DirectoryInfo(trickplayDirectory).EnumerateDirectories())
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var match = TrickplaySubdirRegex().Match(subdir.Name);
+ if (!match.Success)
+ {
+ continue;
+ }
+
+ var width = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
+ var tileWidth = int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture);
+ var tileHeight = int.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture);
+
+ if (existing.ContainsKey(width))
+ {
+ continue;
+ }
+
+ var tiles = subdir.GetFiles("*.jpg")
+ .OrderBy(t => t.Name, StringComparer.Ordinal)
+ .ToArray();
+ if (tiles.Length == 0)
+ {
+ continue;
+ }
+
+ // The encoder pads the last tile to a full TileWidth*TileHeight grid, so the real
+ // thumbnail count cannot be read from tile dimensions. Instead, bound the count from
+ // the tile count and per-tile capacity, then pick an interval consistent with the
+ // video runtime - snapping to the server's configured interval when it fits.
+ var thumbsPerTile = tileWidth * tileHeight;
+ var maxThumbs = tiles.Length * thumbsPerTile;
+ var minThumbs = tiles.Length > 1 ? ((tiles.Length - 1) * thumbsPerTile) + 1 : 1;
+
+ int interval;
+ int thumbnailCount;
+ if (video.RunTimeTicks is long ticks)
+ {
+ var runtimeMs = ticks / TimeSpan.TicksPerMillisecond;
+ var minInterval = Math.Max(1000L, (long)Math.Ceiling(runtimeMs / (double)maxThumbs));
+ var maxInterval = Math.Max(minInterval, (long)Math.Floor(runtimeMs / (double)minThumbs));
+
+ if (options.Interval >= minInterval && options.Interval <= maxInterval)
+ {
+ interval = options.Interval;
+ }
+ else
+ {
+ var midpoint = (minInterval + maxInterval) / 2.0;
+ var snapped = (long)Math.Round(midpoint / 1000d) * 1000L;
+ interval = (int)Math.Clamp(snapped, minInterval, maxInterval);
+ }
+
+ thumbnailCount = Math.Clamp(
+ (int)Math.Round(runtimeMs / (double)interval),
+ minThumbs,
+ maxThumbs);
+ }
+ else
+ {
+ interval = Math.Max(1000, options.Interval);
+ thumbnailCount = maxThumbs;
+ }
+
+ var firstSize = _imageEncoder.GetImageSize(tiles[0].FullName);
+ var thumbPxH = Math.Max(1, (int)Math.Ceiling((double)firstSize.Height / tileHeight));
+
+ var info = new TrickplayInfo
+ {
+ ItemId = video.Id,
+ Width = width,
+ Interval = interval,
+ TileWidth = tileWidth,
+ TileHeight = tileHeight,
+ ThumbnailCount = thumbnailCount,
+ Height = thumbPxH,
+ Bandwidth = 0,
+ };
+
+ foreach (var tile in tiles)
+ {
+ var bitrate = (int)Math.Ceiling((decimal)tile.Length * 8 / tileWidth / tileHeight / (interval / 1000m));
+ info.Bandwidth = Math.Max(info.Bandwidth, bitrate);
+ }
+
+ await SaveTrickplayInfo(info).ConfigureAwait(false);
+ _logger.LogInformation(
+ "Discovered existing trickplay {Width} - {TileWidth}x{TileHeight} ({ThumbnailCount} thumbnails, {Interval}ms interval) for {Path}",
+ width,
+ tileWidth,
+ tileHeight,
+ thumbnailCount,
+ interval,
+ video.Path);
+ }
+ }
+
/// <inheritdoc />
public async Task RefreshTrickplayDataAsync(Video video, bool replace, LibraryOptions libraryOptions, CancellationToken cancellationToken)
{
@@ -144,11 +286,27 @@ public class TrickplayManager : ITrickplayManager
return;
}
+ var saveWithMedia = libraryOptions.SaveTrickplayWithMedia;
+
+ // Catalog any existing trickplay folders on disk before any prune/generate. This picks up
+ // user-placed files even when their (width, tile dims) don't match the server's configured values.
+ if (!replace)
+ {
+ await DiscoverExistingTrickplayAsync(video, saveWithMedia, cancellationToken).ConfigureAwait(false);
+ }
+
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
- var saveWithMedia = libraryOptions.SaveTrickplayWithMedia;
var trickplayDirectory = _pathManager.GetTrickplayDirectory(video, saveWithMedia);
+
+ // When extraction is disabled and files live next to media, treat them as user-managed:
+ // discovery above already catalogued whatever is on disk, leave it alone.
+ if (!libraryOptions.EnableTrickplayImageExtraction && !replace && saveWithMedia)
+ {
+ return;
+ }
+
if (!libraryOptions.EnableTrickplayImageExtraction || replace)
{
// Prune existing data
@@ -688,6 +846,19 @@ public class TrickplayManager : ITrickplayManager
return Path.Combine(path, subdirectory);
}
+ [GeneratedRegex(@"^(\d+) - (\d+)x(\d+)$")]
+ private static partial Regex TrickplaySubdirRegex();
+
+ private static bool HasTrickplayTiles(string directory)
+ {
+ if (!Directory.Exists(directory))
+ {
+ return false;
+ }
+
+ return new DirectoryInfo(directory).EnumerateFiles("*.jpg").Any();
+ }
+
private async Task<bool> HasTrickplayResolutionAsync(Guid itemId, int width)
{
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs
index 37c4106496..9be2eac4a1 100644
--- a/Jellyfin.Server.Implementations/Users/UserManager.cs
+++ b/Jellyfin.Server.Implementations/Users/UserManager.cs
@@ -51,7 +51,7 @@ namespace Jellyfin.Server.Implementations.Users
private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider;
private readonly IServerConfigurationManager _serverConfigurationManager;
- private readonly AsyncKeyedLocker<Guid> _userLock = new();
+ private readonly LockHelper _userLock = new();
/// <summary>
/// Initializes a new instance of the <see cref="UserManager"/> class.
@@ -214,7 +214,58 @@ namespace Jellyfin.Server.Implementations.Users
{
using (await _userLock.LockAsync(user.Id).ConfigureAwait(false))
{
- await UpdateUserInternalAsync(user).ConfigureAwait(false);
+ var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
+ {
+ // TODO: this is a bit of a hack. Because the user entity can be created in another context, it is maybe tracked elsewhere and navigation properties do not easily move between context. Solution is to use proper DTOs instead.
+ var dbUser = await UserQuery(dbContext)
+ .AsTracking()
+ .FirstOrDefaultAsync(u => u.Id == user.Id)
+ .ConfigureAwait(false)
+ ?? throw new ResourceNotFoundException(nameof(user.Id));
+
+ dbContext.Entry(dbUser).CurrentValues.SetValues(user);
+ dbUser.Permissions.Clear();
+ foreach (var permission in user.Permissions)
+ {
+ dbUser.Permissions.Add(new Permission(permission.Kind, permission.Value));
+ }
+
+ dbUser.Preferences.Clear();
+ foreach (var preference in user.Preferences)
+ {
+ dbUser.Preferences.Add(new Preference(preference.Kind, preference.Value));
+ }
+
+ dbUser.AccessSchedules.Clear();
+ foreach (var accessSchedule in user.AccessSchedules)
+ {
+ dbUser.AccessSchedules.Add(new AccessSchedule(accessSchedule.DayOfWeek, accessSchedule.StartHour, accessSchedule.EndHour, dbUser.Id));
+ }
+
+ if (user.ProfileImage is null)
+ {
+ if (dbUser.ProfileImage is not null)
+ {
+ dbContext.Remove(dbUser.ProfileImage);
+ dbUser.ProfileImage = null;
+ }
+ }
+ else if (dbUser.ProfileImage is null)
+ {
+ dbUser.ProfileImage = new Jellyfin.Database.Implementations.Entities.ImageInfo(user.ProfileImage.Path)
+ {
+ LastModified = user.ProfileImage.LastModified
+ };
+ }
+ else
+ {
+ dbUser.ProfileImage.Path = user.ProfileImage.Path;
+ dbUser.ProfileImage.LastModified = user.ProfileImage.LastModified;
+ }
+
+ await dbContext.SaveChangesAsync().ConfigureAwait(false);
+ }
}
}
@@ -453,12 +504,14 @@ namespace Jellyfin.Server.Implementations.Users
var user = GetUserByName(username);
using (await _userLock.LockAsync(user?.Id ?? Guid.Empty).ConfigureAwait(false))
{
+ using var dbContext = _dbProvider.CreateDbContext();
+
// Reload the user now that we hold the lock so the RowVersion is current.
// GetUserByName uses AsNoTracking and the snapshot may be stale if another
// write (e.g. a concurrent login) incremented RowVersion after our initial load.
if (user is not null)
{
- user = GetUserById(user.Id) ?? user;
+ user = await UserQuery(dbContext).FirstOrDefaultAsync(e => e.Id == user.Id).ConfigureAwait(false) ?? user;
}
var authResult = await AuthenticateLocalUser(username, password, user)
@@ -466,6 +519,13 @@ namespace Jellyfin.Server.Implementations.Users
var authenticationProvider = authResult.AuthenticationProvider;
success = authResult.Success;
+ if (success && user is not null)
+ {
+ // refresh the user if the auth provider might have updated it in the auth method.
+ // this is a hack, this needs removal once the LDAP plugin uses the correct interface to get the user we hand in here and update that one instead.
+ user = await UserQuery(dbContext).FirstOrDefaultAsync(e => e.Id == user.Id).ConfigureAwait(false);
+ }
+
if (user is null)
{
string updatedUsername = authResult.Username;
@@ -479,11 +539,16 @@ namespace Jellyfin.Server.Implementations.Users
// Search the database for the user again
// the authentication provider might have created it
- user = GetUserByName(username);
+#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
+ user = await UserQuery(dbContext)
+ .FirstOrDefaultAsync(e => e.NormalizedUsername == username.ToUpperInvariant()).ConfigureAwait(false);
if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user is not null)
{
await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false);
+ user = await UserQuery(dbContext)
+ .FirstOrDefaultAsync(e => e.NormalizedUsername == username.ToUpperInvariant()).ConfigureAwait(false);
+#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
}
}
}
@@ -494,8 +559,10 @@ namespace Jellyfin.Server.Implementations.Users
if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase))
{
- user.AuthenticationProviderId = providerId;
- await UpdateUserInternalAsync(user).ConfigureAwait(false);
+ await dbContext.Users
+ .Where(e => e.Id == user.Id)
+ .ExecuteUpdateAsync(e => e.SetProperty(f => f.AuthenticationProviderId, providerId))
+ .ConfigureAwait(false);
}
}
@@ -542,16 +609,42 @@ namespace Jellyfin.Server.Implementations.Users
{
if (isUserSession)
{
- user.LastActivityDate = user.LastLoginDate = DateTime.UtcNow;
+ var date = DateTime.UtcNow;
+ await dbContext.Users
+ .Where(e => e.Id == user.Id)
+ .ExecuteUpdateAsync(e => e
+ .SetProperty(f => f.LastActivityDate, date)
+ .SetProperty(f => f.LastLoginDate, date))
+ .ConfigureAwait(false);
}
- user.InvalidLoginAttemptCount = 0;
- await UpdateUserInternalAsync(user).ConfigureAwait(false);
+ await dbContext.Users
+ .Where(e => e.Id == user.Id)
+ .ExecuteUpdateAsync(e => e.SetProperty(f => f.InvalidLoginAttemptCount, 0))
+ .ConfigureAwait(false);
_logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Username);
}
else
{
- await IncrementInvalidLoginAttemptCount(user).ConfigureAwait(false);
+ user.InvalidLoginAttemptCount++;
+ int? maxInvalidLogins = user.LoginAttemptsBeforeLockout;
+ if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins)
+ {
+ user.SetPermission(PermissionKind.IsDisabled, true);
+ await dbContext.SaveChangesAsync()
+ .ConfigureAwait(false);
+ await _eventManager.PublishAsync(new UserLockedOutEventArgs(user)).ConfigureAwait(false);
+ _logger.LogWarning(
+ "Disabling user {Username} due to {Attempts} unsuccessful login attempts.",
+ user.Username,
+ user.InvalidLoginAttemptCount);
+ }
+
+ await dbContext.Users
+ .Where(e => e.Id == user.Id)
+ .ExecuteUpdateAsync(e => e.SetProperty(f => f.InvalidLoginAttemptCount, f => f.InvalidLoginAttemptCount + 1))
+ .ConfigureAwait(false);
+
_logger.LogInformation(
"Authentication request for {UserName} has been denied (IP: {IP}).",
user.Username,
@@ -926,32 +1019,6 @@ namespace Jellyfin.Server.Implementations.Users
}
}
- private async Task IncrementInvalidLoginAttemptCount(User user)
- {
- user.InvalidLoginAttemptCount++;
- int? maxInvalidLogins = user.LoginAttemptsBeforeLockout;
- if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins)
- {
- user.SetPermission(PermissionKind.IsDisabled, true);
- await _eventManager.PublishAsync(new UserLockedOutEventArgs(user)).ConfigureAwait(false);
- _logger.LogWarning(
- "Disabling user {Username} due to {Attempts} unsuccessful login attempts.",
- user.Username,
- user.InvalidLoginAttemptCount);
- }
-
- await UpdateUserInternalAsync(user).ConfigureAwait(false);
- }
-
- private async Task UpdateUserInternalAsync(User user)
- {
- var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
- await using (dbContext.ConfigureAwait(false))
- {
- await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false);
- }
- }
-
private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user)
{
dbContext.Users.Attach(user);
@@ -977,5 +1044,70 @@ namespace Jellyfin.Server.Implementations.Users
_userLock.Dispose();
}
}
+
+ internal sealed class LockHelper : IDisposable
+ {
+ private readonly AsyncKeyedLocker<Guid> _userLock = new();
+
+ private bool _disposed;
+
+ public static AsyncLocal<int> IsNestedLock { get; set; } = new();
+
+ public bool ShouldLock()
+ {
+ return IsNestedLock.Value == 0;
+ }
+
+ public ValueTask<IDisposable> LockAsync(Guid key)
+ {
+ ThrowIfDisposed();
+ var isNested = LockHelper.IsNestedLock.Value != 0;
+ LockHelper.IsNestedLock.Value = LockHelper.IsNestedLock.Value + 1;
+ if (isNested)
+ {
+ return new ValueTask<IDisposable>(new LockHandle { Parent = null });
+ }
+
+ return AcquireLockAsync(key);
+ }
+
+ private async ValueTask<IDisposable> AcquireLockAsync(Guid key)
+ {
+ var lockHandle = await _userLock.LockAsync(key, true).ConfigureAwait(false);
+ return new LockHandle { Parent = lockHandle };
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ _userLock.Dispose();
+ }
+
+ private void ThrowIfDisposed()
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ }
+
+ private sealed class LockHandle : IDisposable
+ {
+ public required IDisposable? Parent { get; init; }
+
+ public void Dispose()
+ {
+ Parent?.Dispose();
+ LockHelper.IsNestedLock.Value = LockHelper.IsNestedLock.Value - 1;
+
+ if (LockHelper.IsNestedLock.Value < 0)
+ {
+ throw new InvalidOperationException("Mismatched locking detected. Threads internal NestedLock is less then 0 which should not be possible.");
+ }
+ }
+ }
+ }
}
}
diff --git a/Jellyfin.Server/Migrations/Routines/20260525010000_CleanupOrphanedExternalData.cs b/Jellyfin.Server/Migrations/Routines/20260525010000_CleanupOrphanedExternalData.cs
new file mode 100644
index 0000000000..d8dfe181ca
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/20260525010000_CleanupOrphanedExternalData.cs
@@ -0,0 +1,182 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Server.ServerSetupApp;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Removes on-disk external item data (attachments, subtitles, trickplay tiles, chapter images) for items that
+/// no longer exist in the <c>BaseItems</c> table. The database side is cleaned up synchronously by
+/// <c>IItemPersistenceService.DeleteItem</c>, so the leftover orphans live on the filesystem.
+/// </summary>
+[JellyfinMigration("2026-05-25T01:00:00", nameof(CleanupOrphanedExternalData))]
+[JellyfinMigrationBackup(JellyfinDb = true)]
+public class CleanupOrphanedExternalData : IAsyncMigrationRoutine
+{
+ private const int ProgressLogStep = 500;
+
+ private readonly IStartupLogger<CleanupOrphanedExternalData> _logger;
+ private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
+ private readonly IApplicationPaths _appPaths;
+ private readonly IServerApplicationPaths _serverPaths;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="CleanupOrphanedExternalData"/> class.
+ /// </summary>
+ /// <param name="logger">The startup logger.</param>
+ /// <param name="dbContextFactory">The database context factory.</param>
+ /// <param name="appPaths">The application paths.</param>
+ /// <param name="serverPaths">The server application paths.</param>
+ public CleanupOrphanedExternalData(
+ IStartupLogger<CleanupOrphanedExternalData> logger,
+ IDbContextFactory<JellyfinDbContext> dbContextFactory,
+ IApplicationPaths appPaths,
+ IServerApplicationPaths serverPaths)
+ {
+ _logger = logger;
+ _dbContextFactory = dbContextFactory;
+ _appPaths = appPaths;
+ _serverPaths = serverPaths;
+ }
+
+ /// <inheritdoc/>
+ public async Task PerformAsync(CancellationToken cancellationToken)
+ {
+ var knownIds = await LoadKnownItemIdsAsync(cancellationToken).ConfigureAwait(false);
+
+ CleanupGuidIndexedRoot(
+ "attachment",
+ Path.Combine(_appPaths.DataPath, "attachments"),
+ knownIds,
+ deleteSubPath: null,
+ cancellationToken);
+
+ CleanupGuidIndexedRoot(
+ "subtitle",
+ Path.Combine(_appPaths.DataPath, "subtitles"),
+ knownIds,
+ deleteSubPath: null,
+ cancellationToken);
+
+ CleanupGuidIndexedRoot(
+ "trickplay",
+ _appPaths.TrickplayPath,
+ knownIds,
+ deleteSubPath: null,
+ cancellationToken);
+
+ CleanupGuidIndexedRoot(
+ "chapter image",
+ Path.Combine(_serverPaths.InternalMetadataPath, "library"),
+ knownIds,
+ deleteSubPath: "chapters",
+ cancellationToken);
+ }
+
+ private async Task<HashSet<Guid>> LoadKnownItemIdsAsync(CancellationToken cancellationToken)
+ {
+ var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (context.ConfigureAwait(false))
+ {
+ var ids = await context.BaseItems
+ .AsNoTracking()
+ .Select(b => b.Id)
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+ return [.. ids];
+ }
+ }
+
+ private void CleanupGuidIndexedRoot(
+ string label,
+ string root,
+ HashSet<Guid> knownIds,
+ string? deleteSubPath,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrEmpty(root) || !Directory.Exists(root))
+ {
+ _logger.LogInformation("Skipping {Label} cleanup; root {Root} does not exist", label, root);
+ return;
+ }
+
+ _logger.LogInformation("Scanning for orphaned {Label} data under {Root}", label, root);
+
+ var scanned = 0;
+ var removed = 0;
+ foreach (var prefixDir in Directory.EnumerateDirectories(root))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var prefixName = Path.GetFileName(prefixDir);
+ if (prefixName.Length != 2)
+ {
+ continue;
+ }
+
+ foreach (var guidDir in Directory.EnumerateDirectories(prefixDir))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ scanned++;
+ if (scanned % ProgressLogStep == 0)
+ {
+ _logger.LogInformation("Scanning {Label}: {Scanned} directories examined, {Removed} orphans removed so far", label, scanned, removed);
+ }
+
+ var leafName = Path.GetFileName(guidDir);
+ if (!Guid.TryParse(leafName, CultureInfo.InvariantCulture, out var id))
+ {
+ continue;
+ }
+
+ if (knownIds.Contains(id))
+ {
+ continue;
+ }
+
+ var target = deleteSubPath is null ? guidDir : Path.Combine(guidDir, deleteSubPath);
+ if (deleteSubPath is not null && !Directory.Exists(target))
+ {
+ continue;
+ }
+
+ if (TryDelete(target))
+ {
+ removed++;
+ }
+ }
+ }
+
+ _logger.LogInformation("Finished {Label} cleanup: scanned {Scanned} directories, removed {Removed} orphans", label, scanned, removed);
+ }
+
+ private bool TryDelete(string dir)
+ {
+ try
+ {
+ Directory.Delete(dir, recursive: true);
+ return true;
+ }
+ catch (IOException ex)
+ {
+ _logger.LogWarning(ex, "Failed to delete orphaned directory {Dir}", dir);
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ _logger.LogWarning(ex, "Permission denied deleting orphaned directory {Dir}", dir);
+ }
+
+ return false;
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Routines/20260531160000_DisableLegacyAuthorization.cs b/Jellyfin.Server/Migrations/Routines/20260531160000_DisableLegacyAuthorization.cs
new file mode 100644
index 0000000000..4b8ced90ac
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/20260531160000_DisableLegacyAuthorization.cs
@@ -0,0 +1,32 @@
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Configuration;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Migration to disable legacy authorization in the system config.
+/// </summary>
+[JellyfinMigration("2026-05-31T16:00:00", nameof(DisableLegacyAuthorization), Stage = Stages.JellyfinMigrationStageTypes.CoreInitialisation)]
+public class DisableLegacyAuthorization : IAsyncMigrationRoutine
+{
+ private readonly IServerConfigurationManager _serverConfigurationManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="DisableLegacyAuthorization"/> class.
+ /// </summary>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+ public DisableLegacyAuthorization(IServerConfigurationManager serverConfigurationManager)
+ {
+ _serverConfigurationManager = serverConfigurationManager;
+ }
+
+ /// <inheritdoc />
+ public Task PerformAsync(CancellationToken cancellationToken)
+ {
+ _serverConfigurationManager.Configuration.EnableLegacyAuthorization = false;
+ _serverConfigurationManager.SaveConfiguration();
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/MediaBrowser.Controller/Dto/DtoOptions.cs b/MediaBrowser.Controller/Dto/DtoOptions.cs
index a71cdbd62c..d319feb6b2 100644
--- a/MediaBrowser.Controller/Dto/DtoOptions.cs
+++ b/MediaBrowser.Controller/Dto/DtoOptions.cs
@@ -1,5 +1,3 @@
-#pragma warning disable CS1591
-
using System;
using System.Collections.Generic;
using System.Linq;
@@ -8,13 +6,16 @@ using MediaBrowser.Model.Querying;
namespace MediaBrowser.Controller.Dto
{
+ /// <summary>
+ /// Options that control which fields and images are populated when building a <see cref="MediaBrowser.Model.Dto.BaseItemDto"/>.
+ /// </summary>
public class DtoOptions
{
- private static readonly ItemFields[] DefaultExcludedFields = new[]
- {
+ private static readonly ItemFields[] DefaultExcludedFields =
+ [
ItemFields.SeasonUserData,
ItemFields.RefreshState
- };
+ ];
private static readonly ImageType[] AllImageTypes = Enum.GetValues<ImageType>();
@@ -22,11 +23,18 @@ namespace MediaBrowser.Controller.Dto
.Except(DefaultExcludedFields)
.ToArray();
+ /// <summary>
+ /// Initializes a new instance of the <see cref="DtoOptions"/> class with all fields enabled.
+ /// </summary>
public DtoOptions()
: this(true)
{
}
+ /// <summary>
+ /// Initializes a new instance of the <see cref="DtoOptions"/> class.
+ /// </summary>
+ /// <param name="allFields">Whether to populate all available fields.</param>
public DtoOptions(bool allFields)
{
ImageTypeLimit = int.MaxValue;
@@ -38,23 +46,61 @@ namespace MediaBrowser.Controller.Dto
ImageTypes = AllImageTypes;
}
+ /// <summary>
+ /// Gets or sets the fields to populate on the DTO.
+ /// </summary>
public IReadOnlyList<ItemFields> Fields { get; set; }
+ /// <summary>
+ /// Gets or sets the image types to populate on the DTO.
+ /// </summary>
public IReadOnlyList<ImageType> ImageTypes { get; set; }
+ /// <summary>
+ /// Gets or sets the maximum number of images to return per image type.
+ /// </summary>
public int ImageTypeLimit { get; set; }
+ /// <summary>
+ /// Gets or sets a value indicating whether image information is populated.
+ /// </summary>
public bool EnableImages { get; set; }
+ /// <summary>
+ /// Gets or sets a value indicating whether program recording information is populated.
+ /// </summary>
public bool AddProgramRecordingInfo { get; set; }
+ /// <summary>
+ /// Gets or sets a value indicating whether user data is populated.
+ /// </summary>
public bool EnableUserData { get; set; }
+ /// <summary>
+ /// Gets or sets a value indicating whether the currently airing program is populated.
+ /// </summary>
public bool AddCurrentProgram { get; set; }
+ /// <summary>
+ /// Gets or sets a value indicating whether an episode's portrait poster (its season's primary
+ /// image, falling back to the series') should replace the episode's own (16:9) primary image.
+ /// Used by views that render episodes as poster cards, e.g. "Latest".
+ /// </summary>
+ public bool PreferEpisodeParentPoster { get; set; }
+
+ /// <summary>
+ /// Gets a value indicating whether the specified field is populated.
+ /// </summary>
+ /// <param name="field">The field to check.</param>
+ /// <returns><c>true</c> if the field is populated; otherwise, <c>false</c>.</returns>
public bool ContainsField(ItemFields field)
=> Fields.Contains(field);
+ /// <summary>
+ /// Gets the number of images to return for the specified image type.
+ /// </summary>
+ /// <param name="type">The image type.</param>
+ /// <returns>The image limit for the type, or 0 if the type is not enabled.</returns>
public int GetImageLimit(ImageType type)
{
if (EnableImages && ImageTypes.Contains(type))
diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs
index e24b60f69f..21304768bd 100644
--- a/MediaBrowser.Controller/Entities/BaseItem.cs
+++ b/MediaBrowser.Controller/Entities/BaseItem.cs
@@ -23,7 +23,6 @@ using MediaBrowser.Controller.Chapters;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities.TV;
-using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaSegments;
using MediaBrowser.Controller.Persistence;
@@ -1134,15 +1133,7 @@ namespace MediaBrowser.Controller.Entities
ArgumentNullException.ThrowIfNull(item);
var protocol = item.PathProtocol;
-
- // Resolve the item path so everywhere we use the media source it will always point to
- // the correct path even if symlinks are in use. Calling ResolveLinkTarget on a non-link
- // path will return null, so it's safe to check for all paths.
var itemPath = item.Path;
- if (protocol is MediaProtocol.File && FileSystemHelper.ResolveLinkTarget(itemPath, returnFinalTarget: true) is { Exists: true } linkInfo)
- {
- itemPath = linkInfo.FullName;
- }
var info = new MediaSourceInfo
{
@@ -2727,7 +2718,7 @@ namespace MediaBrowser.Controller.Entities
public IReadOnlyList<BaseItem> GetThemeSongs(User user, IEnumerable<(ItemSortBy SortBy, SortOrder SortOrder)> orderBy)
{
- return LibraryManager.Sort(GetExtras().Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeSong), user, orderBy).ToArray();
+ return LibraryManager.Sort(GetExtras(user).Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeSong), user, orderBy).ToArray();
}
public IReadOnlyList<BaseItem> GetThemeVideos(User user = null)
@@ -2737,16 +2728,17 @@ namespace MediaBrowser.Controller.Entities
public IReadOnlyList<BaseItem> GetThemeVideos(User user, IEnumerable<(ItemSortBy SortBy, SortOrder SortOrder)> orderBy)
{
- return LibraryManager.Sort(GetExtras().Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeVideo), user, orderBy).ToArray();
+ return LibraryManager.Sort(GetExtras(user).Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeVideo), user, orderBy).ToArray();
}
/// <summary>
/// Get all extras associated with this item, sorted by <see cref="SortName"/>.
/// </summary>
+ /// <param name="user">The user to apply parental restrictions for, or <c>null</c> to skip restriction checks.</param>
/// <returns>An enumerable containing the items.</returns>
- public IEnumerable<BaseItem> GetExtras()
+ public IEnumerable<BaseItem> GetExtras(User user = null)
{
- return LibraryManager.GetItemList(new InternalItemsQuery()
+ return LibraryManager.GetItemList(new InternalItemsQuery(user)
{
OwnerIds = [Id],
OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)]
@@ -2757,10 +2749,11 @@ namespace MediaBrowser.Controller.Entities
/// Get all extras with specific types that are associated with this item.
/// </summary>
/// <param name="extraTypes">The types of extras to retrieve.</param>
+ /// <param name="user">The user to apply parental restrictions for, or <c>null</c> to skip restriction checks.</param>
/// <returns>An enumerable containing the extras.</returns>
- public IEnumerable<BaseItem> GetExtras(IReadOnlyCollection<ExtraType> extraTypes)
+ public IEnumerable<BaseItem> GetExtras(IReadOnlyCollection<ExtraType> extraTypes, User user = null)
{
- return LibraryManager.GetItemList(new InternalItemsQuery()
+ return LibraryManager.GetItemList(new InternalItemsQuery(user)
{
OwnerIds = [Id],
ExtraTypes = extraTypes.ToArray(),
diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs
index 5fa1213db3..25cbcedc5f 100644
--- a/MediaBrowser.Controller/Entities/Folder.cs
+++ b/MediaBrowser.Controller/Entities/Folder.cs
@@ -906,7 +906,10 @@ namespace MediaBrowser.Controller.Entities
query.Parent = this;
}
- if (query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.BoxSet)
+ // BoxSets and Playlists can have per-user visibility (shares/open access) that is stored in the
+ // serialized item data and cannot be evaluated by the database query, so filter them in memory.
+ if (query.IncludeItemTypes.Length > 0
+ && query.IncludeItemTypes.All(t => t == BaseItemKind.BoxSet || t == BaseItemKind.Playlist))
{
return QueryWithPostFiltering(query);
}
@@ -927,7 +930,7 @@ namespace MediaBrowser.Controller.Entities
if (user is not null)
{
- // needed for boxsets
+ // needed for boxsets and playlists
itemsList = itemsList.Where(i => i.IsVisibleStandalone(query.User));
}
diff --git a/MediaBrowser.Controller/Entities/TagExtensions.cs b/MediaBrowser.Controller/Entities/TagExtensions.cs
index 4ddba9835b..07c2298fce 100644
--- a/MediaBrowser.Controller/Entities/TagExtensions.cs
+++ b/MediaBrowser.Controller/Entities/TagExtensions.cs
@@ -15,6 +15,7 @@ namespace MediaBrowser.Controller.Entities
throw new ArgumentNullException(nameof(name));
}
+ name = name.Trim();
var current = item.Tags;
if (!current.Contains(name, StringComparison.OrdinalIgnoreCase))
diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs
index 44cae5197a..e7a5672ebd 100644
--- a/MediaBrowser.Controller/Entities/Video.cs
+++ b/MediaBrowser.Controller/Entities/Video.cs
@@ -10,6 +10,7 @@ using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
@@ -390,13 +391,13 @@ namespace MediaBrowser.Controller.Entities
/// <summary>
/// Gets the additional parts.
/// </summary>
+ /// <param name="user">The user to apply parental restrictions for, or <c>null</c> to skip restriction checks.</param>
/// <returns>IEnumerable{Video}.</returns>
- public IOrderedEnumerable<Video> GetAdditionalParts()
+ public IOrderedEnumerable<Video> GetAdditionalParts(User user = null)
{
return GetAdditionalPartIds()
- .Select(i => LibraryManager.GetItemById(i))
+ .Select(i => LibraryManager.GetItemById<Video>(i, user))
.Where(i => i is not null)
- .OfType<Video>()
.OrderBy(i => i.SortName);
}
diff --git a/MediaBrowser.Controller/IO/IExternalDataManager.cs b/MediaBrowser.Controller/IO/IExternalDataManager.cs
index f69f4586c6..b2eb8fc3f1 100644
--- a/MediaBrowser.Controller/IO/IExternalDataManager.cs
+++ b/MediaBrowser.Controller/IO/IExternalDataManager.cs
@@ -16,4 +16,11 @@ public interface IExternalDataManager
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Task.</returns>
Task DeleteExternalItemDataAsync(BaseItem item, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Deletes only the filesystem-side external item data (attachments, subtitles, trickplay, chapter images).
+ /// Use this when DB-side cleanup is already handled by another code path (e.g. <c>IItemPersistenceService.DeleteItem</c>).
+ /// </summary>
+ /// <param name="item">The item.</param>
+ void DeleteExternalItemFiles(BaseItem item);
}
diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
index d9cb7a450f..9dd3dcecba 100644
--- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
+++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
@@ -1,8 +1,10 @@
using System;
+using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
+using System.Text;
using System.Threading;
using System.Threading.Tasks;
using AsyncKeyedLock;
@@ -102,13 +104,10 @@ namespace MediaBrowser.MediaEncoding.Attachments
&& (a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase)));
if (shouldExtractOneByOne && !inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
{
- foreach (var attachment in mediaSource.MediaAttachments)
- {
- if (!string.Equals(attachment.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase))
- {
- await ExtractAttachment(inputFile, mediaSource, attachment, cancellationToken).ConfigureAwait(false);
- }
- }
+ await ExtractAllAttachmentsIndividuallyInternal(
+ inputFile,
+ mediaSource,
+ cancellationToken).ConfigureAwait(false);
}
else
{
@@ -119,6 +118,140 @@ namespace MediaBrowser.MediaEncoding.Attachments
}
}
+ private async Task ExtractAllAttachmentsIndividuallyInternal(
+ string inputFile,
+ MediaSourceInfo mediaSource,
+ CancellationToken cancellationToken)
+ {
+ var inputPath = _mediaEncoder.GetInputArgument(inputFile, mediaSource);
+
+ ArgumentException.ThrowIfNullOrEmpty(inputPath);
+
+ var outputFolder = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
+ if (outputFolder is null)
+ {
+ _logger.LogDebug("Skipping attachment extraction for input {InputFile}: MediaSource Id is not a GUID.", inputFile);
+ return;
+ }
+
+ using (await _semaphoreLocks.LockAsync(outputFolder, cancellationToken).ConfigureAwait(false))
+ {
+ Directory.CreateDirectory(outputFolder);
+
+ var dumpArgs = new StringBuilder();
+ var missingPaths = new List<string>();
+ foreach (var attachment in mediaSource.MediaAttachments)
+ {
+ if (string.Equals(attachment.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ var indexName = attachment.Index.ToString(CultureInfo.InvariantCulture);
+ var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, attachment.FileName ?? indexName)
+ ?? _pathManager.GetAttachmentPath(mediaSource.Id, indexName)!;
+ if (File.Exists(attachmentPath))
+ {
+ continue;
+ }
+
+ dumpArgs.AppendFormat(
+ CultureInfo.InvariantCulture,
+ "-dump_attachment:{0} \"{1}\" ",
+ attachment.Index,
+ EncodingUtils.NormalizePath(attachmentPath));
+ missingPaths.Add(attachmentPath);
+ }
+
+ if (missingPaths.Count == 0)
+ {
+ // Skip extraction if all files already exist
+ return;
+ }
+
+ var hasVideoOrAudioStream = mediaSource.MediaStreams
+ .Any(s => s.Type == MediaStreamType.Video || s.Type == MediaStreamType.Audio);
+ var processArgs = string.Format(
+ CultureInfo.InvariantCulture,
+ "{0}{1} -i {2} {3}",
+ dumpArgs,
+ inputPath.EndsWith(".concat\"", StringComparison.OrdinalIgnoreCase) ? "-f concat -safe 0" : string.Empty,
+ inputPath,
+ hasVideoOrAudioStream ? "-t 0 -f null null" : string.Empty);
+
+ int exitCode;
+
+ using (var process = new Process
+ {
+ StartInfo = new ProcessStartInfo
+ {
+ Arguments = processArgs,
+ FileName = _mediaEncoder.EncoderPath,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ WindowStyle = ProcessWindowStyle.Hidden,
+ ErrorDialog = false
+ },
+ EnableRaisingEvents = true
+ })
+ {
+ _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
+
+ process.Start();
+
+ try
+ {
+ await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
+ exitCode = process.ExitCode;
+ }
+ catch (OperationCanceledException)
+ {
+ process.Kill(true);
+ exitCode = -1;
+ }
+ }
+
+ var failed = false;
+
+ if (exitCode != 0 && (hasVideoOrAudioStream || exitCode != 1))
+ {
+ failed = true;
+
+ foreach (var path in missingPaths)
+ {
+ if (!File.Exists(path))
+ {
+ continue;
+ }
+
+ try
+ {
+ _fileSystem.DeleteFile(path);
+ }
+ catch (IOException ex)
+ {
+ _logger.LogError(ex, "Error deleting extracted attachment {Path}", path);
+ }
+ }
+ }
+
+ if (!failed && missingPaths.Exists(p => !File.Exists(p)))
+ {
+ failed = true;
+ }
+
+ if (failed)
+ {
+ _logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, outputFolder);
+
+ throw new InvalidOperationException(
+ string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}", inputPath, outputFolder));
+ }
+
+ _logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, outputFolder);
+ }
+ }
+
private async Task ExtractAllAttachmentsInternal(
string inputFile,
MediaSourceInfo mediaSource,
diff --git a/MediaBrowser.MediaEncoding/Subtitles/AssWriter.cs b/MediaBrowser.MediaEncoding/Subtitles/AssWriter.cs
deleted file mode 100644
index 7d7b80e99d..0000000000
--- a/MediaBrowser.MediaEncoding/Subtitles/AssWriter.cs
+++ /dev/null
@@ -1,57 +0,0 @@
-using System;
-using System.Globalization;
-using System.IO;
-using System.Text;
-using System.Text.RegularExpressions;
-using System.Threading;
-using MediaBrowser.Model.MediaInfo;
-
-namespace MediaBrowser.MediaEncoding.Subtitles
-{
- /// <summary>
- /// ASS subtitle writer.
- /// </summary>
- public partial class AssWriter : ISubtitleWriter
- {
- [GeneratedRegex(@"\n", RegexOptions.IgnoreCase)]
- private static partial Regex NewLineRegex();
-
- /// <inheritdoc />
- public void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken)
- {
- using (var writer = new StreamWriter(stream, Encoding.UTF8, 1024, true))
- {
- var trackEvents = info.TrackEvents;
- var timeFormat = @"hh\:mm\:ss\.ff";
-
- // Write ASS header
- writer.WriteLine("[Script Info]");
- writer.WriteLine("Title: Jellyfin transcoded ASS subtitle");
- writer.WriteLine("ScriptType: v4.00+");
- writer.WriteLine();
- writer.WriteLine("[V4+ Styles]");
- writer.WriteLine("Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding");
- writer.WriteLine("Style: Default,Arial,20,&H00FFFFFF,&H00FFFFFF,&H19333333,&H910E0807,0,0,0,0,100,100,0,0,0,1,0,2,10,10,10,1");
- writer.WriteLine();
- writer.WriteLine("[Events]");
- writer.WriteLine("Format: Layer, Start, End, Style, Text");
-
- for (int i = 0; i < trackEvents.Count; i++)
- {
- cancellationToken.ThrowIfCancellationRequested();
-
- var trackEvent = trackEvents[i];
- var startTime = TimeSpan.FromTicks(trackEvent.StartPositionTicks).ToString(timeFormat, CultureInfo.InvariantCulture);
- var endTime = TimeSpan.FromTicks(trackEvent.EndPositionTicks).ToString(timeFormat, CultureInfo.InvariantCulture);
- var text = NewLineRegex().Replace(trackEvent.Text, "\\n");
-
- writer.WriteLine(
- "Dialogue: 0,{0},{1},Default,{2}",
- startTime,
- endTime,
- text);
- }
- }
- }
- }
-}
diff --git a/MediaBrowser.MediaEncoding/Subtitles/ISubtitleWriter.cs b/MediaBrowser.MediaEncoding/Subtitles/ISubtitleWriter.cs
deleted file mode 100644
index dec714121d..0000000000
--- a/MediaBrowser.MediaEncoding/Subtitles/ISubtitleWriter.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-using System.IO;
-using System.Threading;
-using MediaBrowser.Model.MediaInfo;
-
-namespace MediaBrowser.MediaEncoding.Subtitles
-{
- /// <summary>
- /// Interface ISubtitleWriter.
- /// </summary>
- public interface ISubtitleWriter
- {
- /// <summary>
- /// Writes the specified information.
- /// </summary>
- /// <param name="info">The information.</param>
- /// <param name="stream">The stream.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken);
- }
-}
diff --git a/MediaBrowser.MediaEncoding/Subtitles/JsonWriter.cs b/MediaBrowser.MediaEncoding/Subtitles/JsonWriter.cs
index 1b452b0cec..0e40181016 100644
--- a/MediaBrowser.MediaEncoding/Subtitles/JsonWriter.cs
+++ b/MediaBrowser.MediaEncoding/Subtitles/JsonWriter.cs
@@ -1,44 +1,58 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
using System.IO;
+using System.Text;
using System.Text.Json;
-using System.Threading;
-using MediaBrowser.Model.MediaInfo;
+using Nikse.SubtitleEdit.Core.Common;
+using Nikse.SubtitleEdit.Core.SubtitleFormats;
-namespace MediaBrowser.MediaEncoding.Subtitles
+namespace MediaBrowser.MediaEncoding.Subtitles;
+
+/// <summary>
+/// JSON subtitle writer.
+/// </summary>
+public class JsonWriter : SubtitleFormat
{
- /// <summary>
- /// JSON subtitle writer.
- /// </summary>
- public class JsonWriter : ISubtitleWriter
+ /// <inheritdoc />
+ public override string Extension => ".json";
+
+ /// <inheritdoc />
+ public override string Name => "JSON Jellyfin";
+
+ /// <inheritdoc />
+ public override string ToText(Subtitle subtitle, string title)
{
- /// <inheritdoc />
- public void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken)
+ using var ms = new MemoryStream();
+ using (var writer = new Utf8JsonWriter(ms))
{
- using (var writer = new Utf8JsonWriter(stream))
+ var trackevents = subtitle.Paragraphs;
+ writer.WriteStartObject();
+ writer.WriteStartArray("TrackEvents");
+
+ for (int i = 0; i < trackevents.Count; i++)
{
- var trackevents = info.TrackEvents;
+ var current = trackevents[i];
writer.WriteStartObject();
- writer.WriteStartArray("TrackEvents");
-
- for (int i = 0; i < trackevents.Count; i++)
- {
- cancellationToken.ThrowIfCancellationRequested();
-
- var current = trackevents[i];
- writer.WriteStartObject();
- writer.WriteString("Id", current.Id);
- writer.WriteString("Text", current.Text);
- writer.WriteNumber("StartPositionTicks", current.StartPositionTicks);
- writer.WriteNumber("EndPositionTicks", current.EndPositionTicks);
+ writer.WriteString("Id", current.Number.ToString(CultureInfo.InvariantCulture));
+ writer.WriteString("Text", current.Text);
+ writer.WriteNumber("StartPositionTicks", current.StartTime.TimeSpan.Ticks);
+ writer.WriteNumber("EndPositionTicks", current.EndTime.TimeSpan.Ticks);
- writer.WriteEndObject();
- }
-
- writer.WriteEndArray();
writer.WriteEndObject();
-
- writer.Flush();
}
+
+ writer.WriteEndArray();
+ writer.WriteEndObject();
+
+ writer.Flush();
}
+
+ return Encoding.UTF8.GetString(ms.GetBuffer(), 0, (int)ms.Length);
}
+
+ /// <inheritdoc />
+ public override void LoadSubtitle(Subtitle subtitle, List<string> lines, string fileName)
+ => throw new NotImplementedException();
}
diff --git a/MediaBrowser.MediaEncoding/Subtitles/SrtWriter.cs b/MediaBrowser.MediaEncoding/Subtitles/SrtWriter.cs
deleted file mode 100644
index 86f77aa067..0000000000
--- a/MediaBrowser.MediaEncoding/Subtitles/SrtWriter.cs
+++ /dev/null
@@ -1,49 +0,0 @@
-using System;
-using System.Globalization;
-using System.IO;
-using System.Text;
-using System.Text.RegularExpressions;
-using System.Threading;
-using MediaBrowser.Model.MediaInfo;
-
-namespace MediaBrowser.MediaEncoding.Subtitles
-{
- /// <summary>
- /// SRT subtitle writer.
- /// </summary>
- public partial class SrtWriter : ISubtitleWriter
- {
- [GeneratedRegex(@"\\n", RegexOptions.IgnoreCase)]
- private static partial Regex NewLineEscapedRegex();
-
- /// <inheritdoc />
- public void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken)
- {
- using (var writer = new StreamWriter(stream, Encoding.UTF8, 1024, true))
- {
- var trackEvents = info.TrackEvents;
-
- for (int i = 0; i < trackEvents.Count; i++)
- {
- cancellationToken.ThrowIfCancellationRequested();
-
- var trackEvent = trackEvents[i];
-
- writer.WriteLine((i + 1).ToString(CultureInfo.InvariantCulture));
- writer.WriteLine(
- @"{0:hh\:mm\:ss\,fff} --> {1:hh\:mm\:ss\,fff}",
- TimeSpan.FromTicks(trackEvent.StartPositionTicks),
- TimeSpan.FromTicks(trackEvent.EndPositionTicks));
-
- var text = trackEvent.Text;
-
- // TODO: Not sure how to handle these
- text = NewLineEscapedRegex().Replace(text, " ");
-
- writer.WriteLine(text);
- writer.WriteLine();
- }
- }
- }
- }
-}
diff --git a/MediaBrowser.MediaEncoding/Subtitles/SsaWriter.cs b/MediaBrowser.MediaEncoding/Subtitles/SsaWriter.cs
deleted file mode 100644
index b5fd1ed935..0000000000
--- a/MediaBrowser.MediaEncoding/Subtitles/SsaWriter.cs
+++ /dev/null
@@ -1,57 +0,0 @@
-using System;
-using System.Globalization;
-using System.IO;
-using System.Text;
-using System.Text.RegularExpressions;
-using System.Threading;
-using MediaBrowser.Model.MediaInfo;
-
-namespace MediaBrowser.MediaEncoding.Subtitles
-{
- /// <summary>
- /// SSA subtitle writer.
- /// </summary>
- public partial class SsaWriter : ISubtitleWriter
- {
- [GeneratedRegex(@"\n", RegexOptions.IgnoreCase)]
- private static partial Regex NewLineRegex();
-
- /// <inheritdoc />
- public void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken)
- {
- using (var writer = new StreamWriter(stream, Encoding.UTF8, 1024, true))
- {
- var trackEvents = info.TrackEvents;
- var timeFormat = @"hh\:mm\:ss\.ff";
-
- // Write SSA header
- writer.WriteLine("[Script Info]");
- writer.WriteLine("Title: Jellyfin transcoded SSA subtitle");
- writer.WriteLine("ScriptType: v4.00");
- writer.WriteLine();
- writer.WriteLine("[V4 Styles]");
- writer.WriteLine("Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, TertiaryColour, BackColour, Bold, Italic, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, AlphaLevel, Encoding");
- writer.WriteLine("Style: Default,Arial,20,&H00FFFFFF,&H00FFFFFF,&H19333333,&H19333333,0,0,0,1,0,2,10,10,10,0,1");
- writer.WriteLine();
- writer.WriteLine("[Events]");
- writer.WriteLine("Format: Layer, Start, End, Style, Text");
-
- for (int i = 0; i < trackEvents.Count; i++)
- {
- cancellationToken.ThrowIfCancellationRequested();
-
- var trackEvent = trackEvents[i];
- var startTime = TimeSpan.FromTicks(trackEvent.StartPositionTicks).ToString(timeFormat, CultureInfo.InvariantCulture);
- var endTime = TimeSpan.FromTicks(trackEvent.EndPositionTicks).ToString(timeFormat, CultureInfo.InvariantCulture);
- var text = NewLineRegex().Replace(trackEvent.Text, "\\n");
-
- writer.WriteLine(
- "Dialogue: 0,{0},{1},Default,{2}",
- startTime,
- endTime,
- text);
- }
- }
- }
- }
-}
diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
index 8d237473a3..67e323177b 100644
--- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
+++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
@@ -26,7 +26,10 @@ using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
+using Nikse.SubtitleEdit.Core.Common;
+using Nikse.SubtitleEdit.Core.SubtitleFormats;
using UtfUnknown;
+using SubtitleFormat = MediaBrowser.Model.MediaInfo.SubtitleFormat;
namespace MediaBrowser.MediaEncoding.Subtitles
{
@@ -72,55 +75,42 @@ namespace MediaBrowser.MediaEncoding.Subtitles
private MemoryStream ConvertSubtitles(
Stream stream,
- string inputFormat,
+ SubtitleInfo inputInfo,
string outputFormat,
long startTimeTicks,
long endTimeTicks,
- bool preserveOriginalTimestamps,
- CancellationToken cancellationToken)
+ bool preserveOriginalTimestamps)
{
- var ms = new MemoryStream();
-
- try
- {
- var trackInfo = _subtitleParser.Parse(stream, inputFormat);
+ var subtitle = Subtitle.Parse(stream, Path.GetExtension(inputInfo.Path));
- FilterEvents(trackInfo, startTimeTicks, endTimeTicks, preserveOriginalTimestamps);
+ FilterEvents(subtitle, startTimeTicks, endTimeTicks, preserveOriginalTimestamps);
- var writer = GetWriter(outputFormat);
+ var formatter = GetWriter(outputFormat);
- writer.Write(trackInfo, ms, cancellationToken);
- ms.Position = 0;
- }
- catch
- {
- ms.Dispose();
- throw;
- }
+ var text = formatter.ToText(subtitle, "untitled");
+ var bytes = Encoding.UTF8.GetBytes(text);
- return ms;
+ return new MemoryStream(bytes, 0, bytes.Length, false, true);
}
- internal void FilterEvents(SubtitleTrackInfo track, long startPositionTicks, long endTimeTicks, bool preserveTimestamps)
+ internal void FilterEvents(Subtitle track, long startPositionTicks, long endTimeTicks, bool preserveTimestamps)
{
// Drop subs that have fully elapsed before the requested start position
- track.TrackEvents = track.TrackEvents
- .SkipWhile(i => (i.StartPositionTicks - startPositionTicks) < 0 && (i.EndPositionTicks - startPositionTicks) < 0)
- .ToArray();
+ track.Paragraphs
+ .RemoveAll(i => (i.StartTime.TimeSpan.Ticks - startPositionTicks) < 0 && (i.EndTime.TimeSpan.Ticks - startPositionTicks) < 0);
if (endTimeTicks > 0)
{
- track.TrackEvents = track.TrackEvents
- .TakeWhile(i => i.StartPositionTicks <= endTimeTicks)
- .ToArray();
+ track.Paragraphs
+ .RemoveAll(i => i.StartTime.TimeSpan.Ticks > endTimeTicks);
}
if (!preserveTimestamps)
{
- foreach (var trackEvent in track.TrackEvents)
+ foreach (var trackEvent in track.Paragraphs)
{
- trackEvent.EndPositionTicks = Math.Max(0, trackEvent.EndPositionTicks - startPositionTicks);
- trackEvent.StartPositionTicks = Math.Max(0, trackEvent.StartPositionTicks - startPositionTicks);
+ trackEvent.StartTime = new TimeCode(TimeSpan.FromTicks(Math.Max(0, trackEvent.StartTime.TimeSpan.Ticks - startPositionTicks)));
+ trackEvent.EndTime = new TimeCode(TimeSpan.FromTicks(Math.Max(0, trackEvent.EndTime.TimeSpan.Ticks - startPositionTicks)));
}
}
}
@@ -142,14 +132,14 @@ namespace MediaBrowser.MediaEncoding.Subtitles
var subtitleStream = mediaSource.MediaStreams
.First(i => i.Type == MediaStreamType.Subtitle && i.Index == subtitleStreamIndex);
- var (stream, inputFormat) = await GetSubtitleStream(mediaSource, subtitleStream, cancellationToken)
+ var (stream, info) = await GetSubtitleStream(mediaSource, subtitleStream, cancellationToken)
.ConfigureAwait(false);
// Return the original if the same format is being requested
// Character encoding was already handled in GetSubtitleStream
// ASS is a superset of SSA, skipping the conversion and preserving the styles
- if (string.Equals(inputFormat, outputFormat, StringComparison.OrdinalIgnoreCase)
- || (string.Equals(inputFormat, SubtitleFormat.SSA, StringComparison.OrdinalIgnoreCase)
+ if (string.Equals(info.Format, outputFormat, StringComparison.OrdinalIgnoreCase)
+ || (string.Equals(info.Format, SubtitleFormat.SSA, StringComparison.OrdinalIgnoreCase)
&& string.Equals(outputFormat, SubtitleFormat.ASS, StringComparison.OrdinalIgnoreCase)))
{
return stream;
@@ -157,11 +147,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles
using (stream)
{
- return ConvertSubtitles(stream, inputFormat, outputFormat, startTimeTicks, endTimeTicks, preserveOriginalTimestamps, cancellationToken);
+ return ConvertSubtitles(stream, info, outputFormat, startTimeTicks, endTimeTicks, preserveOriginalTimestamps);
}
}
- private async Task<(Stream Stream, string Format)> GetSubtitleStream(
+ private async Task<(Stream Stream, SubtitleInfo Info)> GetSubtitleStream(
MediaSourceInfo mediaSource,
MediaStream subtitleStream,
CancellationToken cancellationToken)
@@ -170,7 +160,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
var stream = await GetSubtitleStream(fileInfo, cancellationToken).ConfigureAwait(false);
- return (stream, fileInfo.Format);
+ return (stream, fileInfo);
}
private async Task<Stream> GetSubtitleStream(SubtitleInfo fileInfo, CancellationToken cancellationToken)
@@ -224,7 +214,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles
};
}
- var currentFormat = subtitleStream.Codec ?? Path.GetExtension(subtitleStream.Path).TrimStart('.');
+ // Normalize ffmpeg codec names to the file extensions the parser is keyed on
+ var currentFormat = NormalizeCodecToParserExtension((Path.GetExtension(subtitleStream.Path) ?? subtitleStream.Codec).TrimStart('.'));
// Handle PGS subtitles as raw streams for the client to render
if (MediaStream.IsPgsFormat(currentFormat))
@@ -266,13 +257,13 @@ namespace MediaBrowser.MediaEncoding.Subtitles
};
}
- private bool TryGetWriter(string format, [NotNullWhen(true)] out ISubtitleWriter? value)
+ private bool TryGetWriter(string format, [NotNullWhen(true)] out Nikse.SubtitleEdit.Core.SubtitleFormats.SubtitleFormat? value)
{
ArgumentException.ThrowIfNullOrEmpty(format);
if (string.Equals(format, SubtitleFormat.ASS, StringComparison.OrdinalIgnoreCase))
{
- value = new AssWriter();
+ value = new AdvancedSubStationAlpha();
return true;
}
@@ -282,27 +273,29 @@ namespace MediaBrowser.MediaEncoding.Subtitles
return true;
}
- if (string.Equals(format, SubtitleFormat.SRT, StringComparison.OrdinalIgnoreCase) || string.Equals(format, SubtitleFormat.SUBRIP, StringComparison.OrdinalIgnoreCase))
+ if (string.Equals(format, SubtitleFormat.SRT, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(format, SubtitleFormat.SUBRIP, StringComparison.OrdinalIgnoreCase))
{
- value = new SrtWriter();
+ value = new SubRip();
return true;
}
if (string.Equals(format, SubtitleFormat.SSA, StringComparison.OrdinalIgnoreCase))
{
- value = new SsaWriter();
+ value = new SubStationAlpha();
return true;
}
- if (string.Equals(format, SubtitleFormat.VTT, StringComparison.OrdinalIgnoreCase) || string.Equals(format, SubtitleFormat.WEBVTT, StringComparison.OrdinalIgnoreCase))
+ if (string.Equals(format, SubtitleFormat.VTT, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(format, SubtitleFormat.WEBVTT, StringComparison.OrdinalIgnoreCase))
{
- value = new VttWriter();
+ value = new WebVTT();
return true;
}
if (string.Equals(format, SubtitleFormat.TTML, StringComparison.OrdinalIgnoreCase))
{
- value = new TtmlWriter();
+ value = new TimedText10();
return true;
}
@@ -310,7 +303,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
return false;
}
- private ISubtitleWriter GetWriter(string format)
+ private Nikse.SubtitleEdit.Core.SubtitleFormats.SubtitleFormat GetWriter(string format)
{
if (TryGetWriter(format, out var writer))
{
@@ -332,13 +325,91 @@ namespace MediaBrowser.MediaEncoding.Subtitles
{
using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
{
- if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
+ if (!IsCachedSubtitleFresh(outputPath, subtitleStream.Path))
{
await ConvertTextSubtitleToSrtInternal(subtitleStream, mediaSource, outputPath, cancellationToken).ConfigureAwait(false);
}
}
}
+ // ffmpeg codec names don't always match the file extensions the subtitle parser is keyed on.
+ private static string NormalizeCodecToParserExtension(string codecOrExtension)
+ {
+ return codecOrExtension switch
+ {
+ "subrip" => "srt",
+ "webvtt" => "vtt",
+ _ => codecOrExtension
+ };
+ }
+
+ // Records "this cache was built from this exact source revision" in a sidecar file next to the cache: "<sizeBytes>:<mtimeTicks>"
+ private static string GetCacheMetaPath(string cachePath) => cachePath + ".meta";
+
+ private static string FormatCacheMeta(long length, DateTime lastWriteUtc)
+ => string.Create(CultureInfo.InvariantCulture, $"{length}:{lastWriteUtc.Ticks}");
+
+ private bool IsCachedSubtitleFresh(string cachePath, string? sourcePath)
+ {
+ if (!File.Exists(cachePath))
+ {
+ return false;
+ }
+
+ var cacheInfo = _fileSystem.GetFileInfo(cachePath);
+ if (cacheInfo.Length == 0)
+ {
+ return false;
+ }
+
+ if (string.IsNullOrEmpty(sourcePath) || !File.Exists(sourcePath))
+ {
+ return true;
+ }
+
+ var metaPath = GetCacheMetaPath(cachePath);
+ if (!File.Exists(metaPath))
+ {
+ // Pre-existing cache from before metadata tracking - regenerate so we can record the source state.
+ return false;
+ }
+
+ try
+ {
+ var sourceInfo = _fileSystem.GetFileInfo(sourcePath);
+ var expected = FormatCacheMeta(sourceInfo.Length, sourceInfo.LastWriteTimeUtc);
+ var actual = File.ReadAllText(metaPath);
+ return string.Equals(expected, actual, StringComparison.Ordinal);
+ }
+ catch (IOException)
+ {
+ return false;
+ }
+ }
+
+ private void WriteCacheMeta(string cachePath, string? sourcePath)
+ {
+ if (string.IsNullOrEmpty(sourcePath))
+ {
+ return;
+ }
+
+ try
+ {
+ var sourceInfo = _fileSystem.GetFileInfo(sourcePath);
+ if (!sourceInfo.Exists)
+ {
+ return;
+ }
+
+ File.WriteAllText(GetCacheMetaPath(cachePath), FormatCacheMeta(sourceInfo.Length, sourceInfo.LastWriteTimeUtc));
+ }
+ catch (IOException ex)
+ {
+ _logger.LogWarning(ex, "Failed to record subtitle cache metadata for {CachePath}", cachePath);
+ }
+ }
+
/// <summary>
/// Converts the text subtitle to SRT internal.
/// </summary>
@@ -383,7 +454,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
CreateNoWindow = true,
UseShellExecute = false,
FileName = _mediaEncoder.EncoderPath,
- Arguments = string.Format(CultureInfo.InvariantCulture, "{0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath),
+ Arguments = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath),
WindowStyle = ProcessWindowStyle.Hidden,
ErrorDialog = false
},
@@ -463,6 +534,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles
await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
+ WriteCacheMeta(outputPath, inputPath);
+
_logger.LogInformation("ffmpeg subtitle conversion succeeded for {Path}", inputPath);
}
@@ -539,7 +612,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles
var releaser = await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false);
- if (File.Exists(outputPath) && _fileSystem.GetFileInfo(outputPath).Length > 0)
+ var sourcePath = string.IsNullOrEmpty(subtitleStream.Path) ? mediaSource.Path : subtitleStream.Path;
+ if (IsCachedSubtitleFresh(outputPath, sourcePath))
{
releaser.Dispose();
continue;
@@ -596,7 +670,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
var outputPaths = new List<string>();
var args = string.Format(
CultureInfo.InvariantCulture,
- "-i {0}",
+ "-y -i {0}",
inputPath);
foreach (var subtitleStream in subtitleStreams)
@@ -636,6 +710,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles
}
await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false);
+
+ foreach (var outputPath in outputPaths)
+ {
+ WriteCacheMeta(outputPath, mksFile);
+ }
}
}
@@ -691,6 +770,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles
if (outputPaths.Count > 0)
{
await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false);
+
+ foreach (var outputPath in outputPaths)
+ {
+ WriteCacheMeta(outputPath, mediaSource.Path);
+ }
}
}
diff --git a/MediaBrowser.MediaEncoding/Subtitles/TtmlWriter.cs b/MediaBrowser.MediaEncoding/Subtitles/TtmlWriter.cs
deleted file mode 100644
index ea45f2070a..0000000000
--- a/MediaBrowser.MediaEncoding/Subtitles/TtmlWriter.cs
+++ /dev/null
@@ -1,60 +0,0 @@
-using System.IO;
-using System.Text;
-using System.Text.RegularExpressions;
-using System.Threading;
-using MediaBrowser.Model.MediaInfo;
-
-namespace MediaBrowser.MediaEncoding.Subtitles
-{
- /// <summary>
- /// TTML subtitle writer.
- /// </summary>
- public partial class TtmlWriter : ISubtitleWriter
- {
- [GeneratedRegex(@"\\n", RegexOptions.IgnoreCase)]
- private static partial Regex NewLineEscapeRegex();
-
- /// <inheritdoc />
- public void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken)
- {
- // Example: https://github.com/zmalltalker/ttml2vtt/blob/master/data/sample.xml
- // Parser example: https://github.com/mozilla/popcorn-js/blob/master/parsers/parserTTML/popcorn.parserTTML.js
-
- using (var writer = new StreamWriter(stream, Encoding.UTF8, 1024, true))
- {
- writer.WriteLine("<?xml version=\"1.0\" encoding=\"utf-8\"?>");
- writer.WriteLine("<tt xmlns=\"http://www.w3.org/ns/ttml\" xmlns:tts=\"http://www.w3.org/2006/04/ttaf1#styling\" lang=\"no\">");
-
- writer.WriteLine("<head>");
- writer.WriteLine("<styling>");
- writer.WriteLine("<style id=\"italic\" tts:fontStyle=\"italic\" />");
- writer.WriteLine("<style id=\"left\" tts:textAlign=\"left\" />");
- writer.WriteLine("<style id=\"center\" tts:textAlign=\"center\" />");
- writer.WriteLine("<style id=\"right\" tts:textAlign=\"right\" />");
- writer.WriteLine("</styling>");
- writer.WriteLine("</head>");
-
- writer.WriteLine("<body>");
- writer.WriteLine("<div>");
-
- foreach (var trackEvent in info.TrackEvents)
- {
- var text = trackEvent.Text;
-
- text = NewLineEscapeRegex().Replace(text, "<br/>");
-
- writer.WriteLine(
- "<p begin=\"{0}\" dur=\"{1}\">{2}</p>",
- trackEvent.StartPositionTicks,
- trackEvent.EndPositionTicks - trackEvent.StartPositionTicks,
- text);
- }
-
- writer.WriteLine("</div>");
- writer.WriteLine("</body>");
-
- writer.WriteLine("</tt>");
- }
- }
- }
-}
diff --git a/MediaBrowser.MediaEncoding/Subtitles/VttWriter.cs b/MediaBrowser.MediaEncoding/Subtitles/VttWriter.cs
deleted file mode 100644
index 3e0f47b5ae..0000000000
--- a/MediaBrowser.MediaEncoding/Subtitles/VttWriter.cs
+++ /dev/null
@@ -1,53 +0,0 @@
-using System;
-using System.IO;
-using System.Text;
-using System.Text.RegularExpressions;
-using System.Threading;
-using MediaBrowser.Model.MediaInfo;
-
-namespace MediaBrowser.MediaEncoding.Subtitles
-{
- /// <summary>
- /// Subtitle writer for the WebVTT format.
- /// </summary>
- public partial class VttWriter : ISubtitleWriter
- {
- [GeneratedRegex(@"\\n", RegexOptions.IgnoreCase)]
- private static partial Regex NewlineEscapeRegex();
-
- /// <inheritdoc />
- public void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken)
- {
- using (var writer = new StreamWriter(stream, Encoding.UTF8, 1024, true))
- {
- writer.WriteLine("WEBVTT");
- writer.WriteLine();
- writer.WriteLine("Region: id:subtitle width:80% lines:3 regionanchor:50%,100% viewportanchor:50%,90%");
- writer.WriteLine();
- foreach (var trackEvent in info.TrackEvents)
- {
- cancellationToken.ThrowIfCancellationRequested();
-
- var startTime = TimeSpan.FromTicks(trackEvent.StartPositionTicks);
- var endTime = TimeSpan.FromTicks(trackEvent.EndPositionTicks);
-
- // make sure the start and end times are different and sequential
- if (endTime.TotalMilliseconds <= startTime.TotalMilliseconds)
- {
- endTime = startTime.Add(TimeSpan.FromMilliseconds(1));
- }
-
- writer.WriteLine(@"{0:hh\:mm\:ss\.fff} --> {1:hh\:mm\:ss\.fff} region:subtitle line:90%", startTime, endTime);
-
- var text = trackEvent.Text;
-
- // TODO: Not sure how to handle these
- text = NewlineEscapeRegex().Replace(text, " ");
-
- writer.WriteLine(text);
- writer.WriteLine();
- }
- }
- }
- }
-}
diff --git a/MediaBrowser.Model/Configuration/EncodingOptions.cs b/MediaBrowser.Model/Configuration/EncodingOptions.cs
index f5bb5330ed..4d052d8012 100644
--- a/MediaBrowser.Model/Configuration/EncodingOptions.cs
+++ b/MediaBrowser.Model/Configuration/EncodingOptions.cs
@@ -43,6 +43,7 @@ public class EncodingOptions
VppTonemappingContrast = 1;
H264Crf = 23;
H265Crf = 28;
+ EncoderPreset = EncoderPreset.auto;
DeinterlaceDoubleRate = false;
DeinterlaceMethod = DeinterlaceMethod.yadif;
EnableDecodingColorDepth10Hevc = true;
@@ -217,7 +218,7 @@ public class EncodingOptions
/// <summary>
/// Gets or sets the encoder preset.
/// </summary>
- public EncoderPreset? EncoderPreset { get; set; }
+ public EncoderPreset EncoderPreset { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the framerate is doubled when deinterlacing.
diff --git a/MediaBrowser.Model/Configuration/ServerConfiguration.cs b/MediaBrowser.Model/Configuration/ServerConfiguration.cs
index a58c01c960..ac5c12304e 100644
--- a/MediaBrowser.Model/Configuration/ServerConfiguration.cs
+++ b/MediaBrowser.Model/Configuration/ServerConfiguration.cs
@@ -287,5 +287,5 @@ public class ServerConfiguration : BaseApplicationConfiguration
/// <summary>
/// Gets or sets a value indicating whether old authorization methods are allowed.
/// </summary>
- public bool EnableLegacyAuthorization { get; set; } = true;
+ public bool EnableLegacyAuthorization { get; set; }
}
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs
index 714c57d361..b188f5deb4 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs
@@ -95,7 +95,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
var posters = movie.Images.Posters;
var backdrops = movie.Images.Backdrops;
var logos = movie.Images.Logos;
- var remoteImages = new List<RemoteImageInfo>(posters?.Count ?? 0 + backdrops?.Count ?? 0 + logos?.Count ?? 0);
+ var remoteImages = new List<RemoteImageInfo>((posters?.Count ?? 0) + (backdrops?.Count ?? 0) + (logos?.Count ?? 0));
if (posters is not null)
{
diff --git a/MediaBrowser.Providers/TV/SeriesMetadataService.cs b/MediaBrowser.Providers/TV/SeriesMetadataService.cs
index 61a31fbfd6..078c396730 100644
--- a/MediaBrowser.Providers/TV/SeriesMetadataService.cs
+++ b/MediaBrowser.Providers/TV/SeriesMetadataService.cs
@@ -210,16 +210,19 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo>
return true;
}
- // Not yet processed
- if (episode.SeasonId.IsEmpty())
+ // Episode has been processed and linked to a season, only needs a virtual season
+ // if it isn't already linked to a known physical season by ID or path
+ if (!episode.SeasonId.IsEmpty())
{
- return false;
+ return !physicalSeasonIds.Contains(episode.SeasonId)
+ && !physicalSeasonPaths.Contains(System.IO.Path.GetDirectoryName(episode.Path) ?? string.Empty);
}
- // Episode has been processed, only needs a virtual season if it isn't
- // already linked to a known physical season by ID or path
- return !physicalSeasonIds.Contains(episode.SeasonId)
- && !physicalSeasonPaths.Contains(System.IO.Path.GetDirectoryName(episode.Path) ?? string.Empty);
+ // Episode not yet linked, check if it's in a physical season folder
+ // If yes then skip it, processing not finished
+ // If no then include it, needs Season Unknown
+ var episodeDirectory = System.IO.Path.GetDirectoryName(episode.Path) ?? string.Empty;
+ return !physicalSeasonPaths.Contains(episodeDirectory);
}
/// <summary>
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/FilterEventsTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/FilterEventsTests.cs
deleted file mode 100644
index 5f84e85592..0000000000
--- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/FilterEventsTests.cs
+++ /dev/null
@@ -1,282 +0,0 @@
-using System;
-using AutoFixture;
-using AutoFixture.AutoMoq;
-using MediaBrowser.MediaEncoding.Subtitles;
-using MediaBrowser.Model.MediaInfo;
-using Xunit;
-
-namespace Jellyfin.MediaEncoding.Subtitles.Tests
-{
- public class FilterEventsTests
- {
- private readonly SubtitleEncoder _encoder;
-
- public FilterEventsTests()
- {
- var fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true });
- _encoder = fixture.Create<SubtitleEncoder>();
- }
-
- [Fact]
- public void FilterEvents_SubtitleSpanningSegmentBoundary_IsRetained()
- {
- // Subtitle starts at 5s, ends at 15s.
- // Segment requested from 10s to 20s.
- // The subtitle is still on screen at 10s and should NOT be dropped.
- var track = new SubtitleTrackInfo
- {
- TrackEvents = new[]
- {
- new SubtitleTrackEvent("1", "Still on screen")
- {
- StartPositionTicks = TimeSpan.FromSeconds(5).Ticks,
- EndPositionTicks = TimeSpan.FromSeconds(15).Ticks
- },
- new SubtitleTrackEvent("2", "Next subtitle")
- {
- StartPositionTicks = TimeSpan.FromSeconds(12).Ticks,
- EndPositionTicks = TimeSpan.FromSeconds(17).Ticks
- }
- }
- };
-
- _encoder.FilterEvents(
- track,
- startPositionTicks: TimeSpan.FromSeconds(10).Ticks,
- endTimeTicks: TimeSpan.FromSeconds(20).Ticks,
- preserveTimestamps: true);
-
- Assert.Equal(2, track.TrackEvents.Count);
- Assert.Equal("1", track.TrackEvents[0].Id);
- Assert.Equal("2", track.TrackEvents[1].Id);
- }
-
- [Fact]
- public void FilterEvents_SubtitleFullyBeforeSegment_IsDropped()
- {
- // Subtitle starts at 2s, ends at 5s.
- // Segment requested from 10s.
- // The subtitle ended before the segment — should be dropped.
- var track = new SubtitleTrackInfo
- {
- TrackEvents = new[]
- {
- new SubtitleTrackEvent("1", "Already gone")
- {
- StartPositionTicks = TimeSpan.FromSeconds(2).Ticks,
- EndPositionTicks = TimeSpan.FromSeconds(5).Ticks
- },
- new SubtitleTrackEvent("2", "Visible")
- {
- StartPositionTicks = TimeSpan.FromSeconds(12).Ticks,
- EndPositionTicks = TimeSpan.FromSeconds(17).Ticks
- }
- }
- };
-
- _encoder.FilterEvents(
- track,
- startPositionTicks: TimeSpan.FromSeconds(10).Ticks,
- endTimeTicks: TimeSpan.FromSeconds(20).Ticks,
- preserveTimestamps: true);
-
- Assert.Single(track.TrackEvents);
- Assert.Equal("2", track.TrackEvents[0].Id);
- }
-
- [Fact]
- public void FilterEvents_SubtitleAfterSegment_IsDropped()
- {
- // Segment is 10s-20s, subtitle starts at 25s.
- var track = new SubtitleTrackInfo
- {
- TrackEvents = new[]
- {
- new SubtitleTrackEvent("1", "In range")
- {
- StartPositionTicks = TimeSpan.FromSeconds(12).Ticks,
- EndPositionTicks = TimeSpan.FromSeconds(15).Ticks
- },
- new SubtitleTrackEvent("2", "After segment")
- {
- StartPositionTicks = TimeSpan.FromSeconds(25).Ticks,
- EndPositionTicks = TimeSpan.FromSeconds(30).Ticks
- }
- }
- };
-
- _encoder.FilterEvents(
- track,
- startPositionTicks: TimeSpan.FromSeconds(10).Ticks,
- endTimeTicks: TimeSpan.FromSeconds(20).Ticks,
- preserveTimestamps: true);
-
- Assert.Single(track.TrackEvents);
- Assert.Equal("1", track.TrackEvents[0].Id);
- }
-
- [Fact]
- public void FilterEvents_PreserveTimestampsFalse_AdjustsTimestamps()
- {
- var track = new SubtitleTrackInfo
- {
- TrackEvents = new[]
- {
- new SubtitleTrackEvent("1", "Subtitle")
- {
- StartPositionTicks = TimeSpan.FromSeconds(15).Ticks,
- EndPositionTicks = TimeSpan.FromSeconds(20).Ticks
- }
- }
- };
-
- _encoder.FilterEvents(
- track,
- startPositionTicks: TimeSpan.FromSeconds(10).Ticks,
- endTimeTicks: TimeSpan.FromSeconds(30).Ticks,
- preserveTimestamps: false);
-
- Assert.Single(track.TrackEvents);
- // Timestamps should be shifted back by 10s
- Assert.Equal(TimeSpan.FromSeconds(5).Ticks, track.TrackEvents[0].StartPositionTicks);
- Assert.Equal(TimeSpan.FromSeconds(10).Ticks, track.TrackEvents[0].EndPositionTicks);
- }
-
- [Fact]
- public void FilterEvents_PreserveTimestampsTrue_KeepsOriginalTimestamps()
- {
- var startTicks = TimeSpan.FromSeconds(15).Ticks;
- var endTicks = TimeSpan.FromSeconds(20).Ticks;
-
- var track = new SubtitleTrackInfo
- {
- TrackEvents = new[]
- {
- new SubtitleTrackEvent("1", "Subtitle")
- {
- StartPositionTicks = startTicks,
- EndPositionTicks = endTicks
- }
- }
- };
-
- _encoder.FilterEvents(
- track,
- startPositionTicks: TimeSpan.FromSeconds(10).Ticks,
- endTimeTicks: TimeSpan.FromSeconds(30).Ticks,
- preserveTimestamps: true);
-
- Assert.Single(track.TrackEvents);
- Assert.Equal(startTicks, track.TrackEvents[0].StartPositionTicks);
- Assert.Equal(endTicks, track.TrackEvents[0].EndPositionTicks);
- }
-
- [Fact]
- public void FilterEvents_SubtitleEndingExactlyAtSegmentStart_IsRetained()
- {
- // Subtitle ends exactly when the segment begins.
- // EndPositionTicks == startPositionTicks means (end - start) == 0, not < 0,
- // so SkipWhile stops and the subtitle is retained.
- var track = new SubtitleTrackInfo
- {
- TrackEvents = new[]
- {
- new SubtitleTrackEvent("1", "Boundary subtitle")
- {
- StartPositionTicks = TimeSpan.FromSeconds(5).Ticks,
- EndPositionTicks = TimeSpan.FromSeconds(10).Ticks
- },
- new SubtitleTrackEvent("2", "In range")
- {
- StartPositionTicks = TimeSpan.FromSeconds(12).Ticks,
- EndPositionTicks = TimeSpan.FromSeconds(15).Ticks
- }
- }
- };
-
- _encoder.FilterEvents(
- track,
- startPositionTicks: TimeSpan.FromSeconds(10).Ticks,
- endTimeTicks: TimeSpan.FromSeconds(20).Ticks,
- preserveTimestamps: true);
-
- Assert.Equal(2, track.TrackEvents.Count);
- Assert.Equal("1", track.TrackEvents[0].Id);
- }
-
- [Fact]
- public void FilterEvents_SpanningBoundaryWithTimestampAdjustment_DoesNotProduceNegativeTimestamps()
- {
- // Subtitle starts at 5s, ends at 15s.
- // Segment requested from 10s to 20s, preserveTimestamps = false.
- // The subtitle spans the boundary and is retained, but shifting
- // StartPositionTicks by -10s would produce -5s (negative).
- var track = new SubtitleTrackInfo
- {
- TrackEvents = new[]
- {
- new SubtitleTrackEvent("1", "Spans boundary")
- {
- StartPositionTicks = TimeSpan.FromSeconds(5).Ticks,
- EndPositionTicks = TimeSpan.FromSeconds(15).Ticks
- },
- new SubtitleTrackEvent("2", "Fully in range")
- {
- StartPositionTicks = TimeSpan.FromSeconds(12).Ticks,
- EndPositionTicks = TimeSpan.FromSeconds(17).Ticks
- }
- }
- };
-
- _encoder.FilterEvents(
- track,
- startPositionTicks: TimeSpan.FromSeconds(10).Ticks,
- endTimeTicks: TimeSpan.FromSeconds(20).Ticks,
- preserveTimestamps: false);
-
- Assert.Equal(2, track.TrackEvents.Count);
- // Subtitle 1: start should be clamped to 0, not -5s
- Assert.True(track.TrackEvents[0].StartPositionTicks >= 0, "StartPositionTicks must not be negative");
- Assert.Equal(TimeSpan.FromSeconds(5).Ticks, track.TrackEvents[0].EndPositionTicks);
- // Subtitle 2: normal shift (12s - 10s = 2s, 17s - 10s = 7s)
- Assert.Equal(TimeSpan.FromSeconds(2).Ticks, track.TrackEvents[1].StartPositionTicks);
- Assert.Equal(TimeSpan.FromSeconds(7).Ticks, track.TrackEvents[1].EndPositionTicks);
- }
-
- [Fact]
- public void FilterEvents_NoEndTimeTicks_ReturnsAllFromStartPosition()
- {
- var track = new SubtitleTrackInfo
- {
- TrackEvents = new[]
- {
- new SubtitleTrackEvent("1", "Before")
- {
- StartPositionTicks = TimeSpan.FromSeconds(2).Ticks,
- EndPositionTicks = TimeSpan.FromSeconds(4).Ticks
- },
- new SubtitleTrackEvent("2", "After")
- {
- StartPositionTicks = TimeSpan.FromSeconds(12).Ticks,
- EndPositionTicks = TimeSpan.FromSeconds(15).Ticks
- },
- new SubtitleTrackEvent("3", "Much later")
- {
- StartPositionTicks = TimeSpan.FromSeconds(500).Ticks,
- EndPositionTicks = TimeSpan.FromSeconds(505).Ticks
- }
- }
- };
-
- _encoder.FilterEvents(
- track,
- startPositionTicks: TimeSpan.FromSeconds(10).Ticks,
- endTimeTicks: 0,
- preserveTimestamps: true);
-
- Assert.Equal(2, track.TrackEvents.Count);
- Assert.Equal("2", track.TrackEvents[0].Id);
- Assert.Equal("3", track.TrackEvents[1].Id);
- }
- }
-}
diff --git a/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs b/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs
index 4dbe769bf4..2035140f00 100644
--- a/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs
+++ b/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs
@@ -83,4 +83,26 @@ public class SeasonPathParserTests
Assert.Equal(seasonNumber, result.SeasonNumber);
Assert.Equal(isSeasonDirectory, result.IsSeasonFolder);
}
+
+ [Theory]
+ [InlineData("/Drive/300 Collection/300 (2006)", "/Drive/300 Collection", null, false)]
+ [InlineData("/Drive/300 Collection/300 Rise of an Empire", "/Drive/300 Collection", null, false)]
+ [InlineData("/Drive/300 Collection/1", "/Drive/300 Collection", null, false)]
+ [InlineData("/Drive/300 Collection/300 Disc 1", "/Drive/300 Collection", null, false)]
+ [InlineData("/Drive/28 Years Later Collection/28 Days Later", "/Drive/28 Years Later Collection", null, false)]
+ [InlineData("/Drive/28 Years Later Collection/28 Weeks Later (2007)", "/Drive/28 Years Later Collection", null, false)]
+ [InlineData("/Drive/28 Years Later Collection/28 Years Later 2025", "/Drive/28 Years Later Collection", null, false)]
+ [InlineData("/Drive/300 Collection/Season 1", "/Drive/300 Collection", 1, true)]
+ [InlineData("/Drive/28 Years Later Collection/Season 01", "/Drive/28 Years Later Collection", 1, true)]
+ [InlineData("/Drive/300 Collection/S01", "/Drive/300 Collection", 1, true)]
+ [InlineData("/Drive/300 Collection/S1", "/Drive/300 Collection", 1, true)]
+
+ public void GetSeasonNumberFromPathMixedLibraryTest(string path, string? parentPath, int? seasonNumber, bool isSeasonDirectory)
+ {
+ var result = SeasonPathParser.Parse(path, parentPath, false, false);
+
+ Assert.Equal(result.SeasonNumber is not null, result.Success);
+ Assert.Equal(seasonNumber, result.SeasonNumber);
+ Assert.Equal(isSeasonDirectory, result.IsSeasonFolder);
+ }
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs
new file mode 100644
index 0000000000..96625ae670
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs
@@ -0,0 +1,137 @@
+using System;
+using Emby.Server.Implementations.Dto;
+using Emby.Server.Implementations.Playlists;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Common;
+using MediaBrowser.Controller.Chapters;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Controller.Trickplay;
+using MediaBrowser.Model.Entities;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Dto;
+
+public class DtoServiceImageInheritanceTests
+{
+ [Fact]
+ public void GetBaseItemDto_PlaylistsUserViewWithDisplayParentPrimary_UsesDisplayParentPrimaryImage()
+ {
+ var displayParent = new PlaylistsFolder
+ {
+ Id = Guid.NewGuid(),
+ ImageInfos =
+ [
+ new ItemImageInfo
+ {
+ Type = ImageType.Primary,
+ Path = "/images/playlists-custom.jpg",
+ DateModified = new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc)
+ }
+ ]
+ };
+
+ var userView = new UserView
+ {
+ Id = Guid.NewGuid(),
+ ViewType = CollectionType.playlists,
+ DisplayParentId = displayParent.Id,
+ ImageInfos =
+ [
+ new ItemImageInfo
+ {
+ Type = ImageType.Primary,
+ Path = "/images/generated.png",
+ DateModified = new DateTime(2026, 1, 10, 12, 0, 0, DateTimeKind.Utc)
+ }
+ ]
+ };
+
+ var dtoService = BuildDtoService(displayParent);
+
+ var dto = dtoService.GetBaseItemDto(userView, new DtoOptions(false));
+
+ Assert.NotNull(dto.ParentPrimaryImageItemId);
+ Assert.Equal(displayParent.Id, dto.ParentPrimaryImageItemId);
+ Assert.Equal("/images/playlists-custom.jpg", dto.ParentPrimaryImageTag);
+ Assert.False(dto.ImageTags?.ContainsKey(ImageType.Primary));
+ }
+
+ [Fact]
+ public void GetBaseItemDto_PlaylistsUserViewWithoutDisplayParentPrimary_KeepsOwnPrimaryImage()
+ {
+ var displayParent = new PlaylistsFolder
+ {
+ Id = Guid.NewGuid(),
+ ImageInfos = []
+ };
+
+ var userView = new UserView
+ {
+ Id = Guid.NewGuid(),
+ ViewType = CollectionType.playlists,
+ DisplayParentId = displayParent.Id,
+ ImageInfos =
+ [
+ new ItemImageInfo
+ {
+ Type = ImageType.Primary,
+ Path = "/images/generated.png",
+ DateModified = new DateTime(2026, 1, 10, 12, 0, 0, DateTimeKind.Utc)
+ }
+ ]
+ };
+
+ var dtoService = BuildDtoService(displayParent);
+
+ var dto = dtoService.GetBaseItemDto(userView, new DtoOptions(false));
+
+ Assert.Null(dto.ParentPrimaryImageItemId);
+ Assert.Null(dto.ParentPrimaryImageTag);
+ Assert.NotNull(dto.ImageTags);
+ Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary));
+ Assert.Equal("/images/generated.png", dto.ImageTags[ImageType.Primary]);
+ }
+
+ private static DtoService BuildDtoService(BaseItem displayParent)
+ {
+ var libraryManager = new Mock<ILibraryManager>();
+ var userDataManager = new Mock<IUserDataManager>();
+ var imageProcessor = new Mock<IImageProcessor>();
+ var providerManager = new Mock<IProviderManager>();
+ var recordingsManager = new Mock<IRecordingsManager>();
+ var appHost = new Mock<IApplicationHost>();
+ var mediaSourceManager = new Mock<IMediaSourceManager>();
+ var liveTvManager = new Mock<ILiveTvManager>();
+ var trickplayManager = new Mock<ITrickplayManager>();
+ var chapterManager = new Mock<IChapterManager>();
+ var logger = new Mock<Microsoft.Extensions.Logging.ILogger<DtoService>>();
+
+ libraryManager
+ .Setup(x => x.GetItemById(displayParent.Id))
+ .Returns(displayParent);
+
+ imageProcessor
+ .Setup(x => x.GetImageCacheTag(It.IsAny<BaseItem>(), It.IsAny<ItemImageInfo>()))
+ .Returns<BaseItem, ItemImageInfo>((_, image) => image.Path);
+
+ return new DtoService(
+ logger.Object,
+ libraryManager.Object,
+ userDataManager.Object,
+ imageProcessor.Object,
+ providerManager.Object,
+ recordingsManager.Object,
+ appHost.Object,
+ mediaSourceManager.Object,
+ new Lazy<ILiveTvManager>(() => liveTvManager.Object),
+ trickplayManager.Object,
+ chapterManager.Object);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
new file mode 100644
index 0000000000..a5de0a4416
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
@@ -0,0 +1,131 @@
+using System;
+using Emby.Server.Implementations.Dto;
+using MediaBrowser.Common;
+using MediaBrowser.Controller.Chapters;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Controller.Trickplay;
+using MediaBrowser.Model.Entities;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Dto;
+
+public class DtoServiceTests
+{
+ private readonly Mock<ILibraryManager> _libraryManagerMock;
+ private readonly DtoService _dtoService;
+
+ public DtoServiceTests()
+ {
+ _libraryManagerMock = new Mock<ILibraryManager>();
+
+ var imageProcessor = new Mock<IImageProcessor>();
+ // Deterministic tag derived from the image so each item gets a distinct, assertable tag.
+ imageProcessor
+ .Setup(x => x.GetImageCacheTag(It.IsAny<BaseItem>(), It.IsAny<ItemImageInfo>()))
+ .Returns((BaseItem _, ItemImageInfo image) => "tag:" + image.Path);
+
+ var appHost = new Mock<IApplicationHost>();
+ appHost.Setup(x => x.SystemId).Returns("test-server");
+
+ // Video.SourceType probes the active-recording manager; provide one so it doesn't NRE.
+ Video.RecordingsManager = new Mock<IRecordingsManager>().Object;
+
+ _dtoService = new DtoService(
+ NullLogger<DtoService>.Instance,
+ _libraryManagerMock.Object,
+ new Mock<IUserDataManager>().Object,
+ imageProcessor.Object,
+ new Mock<IProviderManager>().Object,
+ new Mock<IRecordingsManager>().Object,
+ appHost.Object,
+ new Mock<IMediaSourceManager>().Object,
+ new Lazy<ILiveTvManager>(() => new Mock<ILiveTvManager>().Object),
+ new Mock<ITrickplayManager>().Object,
+ new Mock<IChapterManager>().Object);
+
+ // Episode.Series / Episode.Season resolve through the static BaseItem.LibraryManager.
+ BaseItem.LibraryManager = _libraryManagerMock.Object;
+ }
+
+ [Fact]
+ public void GetBaseItemDto_PreferEpisodeParentPoster_PrefersSeasonPosterOverEpisodeAndSeries()
+ {
+ var (episode, season, series) = BuildEpisode(seasonHasPoster: true);
+ var options = new DtoOptions(false) { PreferEpisodeParentPoster = true };
+
+ var dto = _dtoService.GetBaseItemDto(episode, options);
+
+ // The episode's own 16:9 primary is dropped in favor of the season's portrait poster.
+ Assert.False(dto.ImageTags is not null && dto.ImageTags.ContainsKey(ImageType.Primary));
+ Assert.Null(dto.SeriesPrimaryImageTag);
+ Assert.Equal(season.Id, dto.ParentPrimaryImageItemId);
+ Assert.Equal("tag:" + season.GetImageInfo(ImageType.Primary, 0)!.Path, dto.ParentPrimaryImageTag);
+ // Aspect ratio follows the (portrait) poster, not the episode's 16:9 image.
+ Assert.Equal(season.GetDefaultPrimaryImageAspectRatio(), dto.PrimaryImageAspectRatio);
+ }
+
+ [Fact]
+ public void GetBaseItemDto_PreferEpisodeParentPoster_FallsBackToSeriesWhenSeasonHasNoPoster()
+ {
+ var (episode, _, series) = BuildEpisode(seasonHasPoster: false);
+ var options = new DtoOptions(false) { PreferEpisodeParentPoster = true };
+
+ var dto = _dtoService.GetBaseItemDto(episode, options);
+
+ Assert.False(dto.ImageTags is not null && dto.ImageTags.ContainsKey(ImageType.Primary));
+ Assert.Null(dto.SeriesPrimaryImageTag);
+ Assert.Equal(series.Id, dto.ParentPrimaryImageItemId);
+ Assert.Equal("tag:" + series.GetImageInfo(ImageType.Primary, 0)!.Path, dto.ParentPrimaryImageTag);
+ }
+
+ [Fact]
+ public void GetBaseItemDto_WithoutPreferEpisodeParentPoster_KeepsEpisodePrimary()
+ {
+ var (episode, _, _) = BuildEpisode(seasonHasPoster: true);
+ var options = new DtoOptions(false);
+
+ var dto = _dtoService.GetBaseItemDto(episode, options);
+
+ // Default behavior: the episode keeps its own primary and exposes the series poster as a tag.
+ Assert.NotNull(dto.ImageTags);
+ Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary));
+ Assert.NotNull(dto.SeriesPrimaryImageTag);
+ Assert.Null(dto.ParentPrimaryImageItemId);
+ }
+
+ private (Episode Episode, Season Season, Series Series) BuildEpisode(bool seasonHasPoster)
+ {
+ // Non-local (http) paths keep aspect-ratio resolution off the image processor and on the
+ // item's default ratio, which is portrait (2/3) for Season/Series and 16:9 for Episode.
+ var series = new Series { Id = Guid.NewGuid(), Name = "Series" };
+ series.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/series.jpg" }, 0);
+
+ var season = new Season { Id = Guid.NewGuid(), Name = "Season", SeriesId = series.Id };
+ if (seasonHasPoster)
+ {
+ season.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/season.jpg" }, 0);
+ }
+
+ var episode = new Episode
+ {
+ Id = Guid.NewGuid(),
+ Name = "Episode",
+ SeasonId = season.Id,
+ SeriesId = series.Id
+ };
+ episode.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/episode.jpg" }, 0);
+
+ _libraryManagerMock.Setup(x => x.GetItemById(season.Id)).Returns(season);
+ _libraryManagerMock.Setup(x => x.GetItemById(series.Id)).Returns(series);
+
+ return (episode, season, series);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerLockHelperTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerLockHelperTests.cs
new file mode 100644
index 0000000000..8149938b4d
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerLockHelperTests.cs
@@ -0,0 +1,93 @@
+using System;
+using System.Threading.Tasks;
+using Jellyfin.Server.Implementations.Users;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Users
+{
+ public class UserManagerLockHelperTests
+ {
+ [Fact]
+ public async Task LockAsync_WhenNested_DoesNotAcquireSecondLockAndRestoresStateOnDispose()
+ {
+ UserManager.LockHelper.IsNestedLock.Value = 0;
+ using var helper = new UserManager.LockHelper();
+ var key = Guid.NewGuid();
+
+ Assert.True(helper.ShouldLock());
+
+ var outerHandle = await helper.LockAsync(key);
+ Assert.False(helper.ShouldLock());
+
+ var innerHandle = await helper.LockAsync(key);
+ Assert.False(helper.ShouldLock());
+
+ innerHandle.Dispose();
+ Assert.False(helper.ShouldLock());
+
+ outerHandle.Dispose();
+ Assert.True(helper.ShouldLock());
+ }
+
+ [Fact]
+ public async Task LockAsync_WithSameKey_BlocksSecondLockUntilFirstIsReleased()
+ {
+ UserManager.LockHelper.IsNestedLock.Value = 0;
+ using var helper = new UserManager.LockHelper();
+ var key = Guid.NewGuid();
+
+ var firstAcquired = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
+ var releaseFirst = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
+ var secondEntered = false;
+
+ var firstTask = Task.Run(
+ async () =>
+ {
+ using var firstHandle = await helper.LockAsync(key);
+ firstAcquired.SetResult(true);
+ await releaseFirst.Task;
+ },
+ TestContext.Current.CancellationToken);
+
+ await firstAcquired.Task;
+
+ var secondTask = Task.Run(
+ async () =>
+ {
+ using var secondHandle = await helper.LockAsync(key);
+ secondEntered = true;
+ },
+ TestContext.Current.CancellationToken);
+
+ await Task.Delay(100, TestContext.Current.CancellationToken);
+ Assert.False(secondEntered);
+
+ releaseFirst.SetResult(true);
+
+ await Task.WhenAll(firstTask, secondTask);
+ Assert.True(secondEntered);
+ }
+
+ [Fact]
+ public async Task LockAsync_WhenDisposed_ThrowsObjectDisposedException()
+ {
+ UserManager.LockHelper.IsNestedLock.Value = 0;
+ using var helper = new UserManager.LockHelper();
+ helper.Dispose();
+
+ await Assert.ThrowsAsync<ObjectDisposedException>(async () => await helper.LockAsync(Guid.NewGuid()));
+ }
+
+ [Fact]
+ public void Dispose_WhenCalledMultipleTimes_DoesNotThrow()
+ {
+ UserManager.LockHelper.IsNestedLock.Value = 0;
+ using var helper = new UserManager.LockHelper();
+
+ helper.Dispose();
+ var ex = Record.Exception(() => helper.Dispose());
+
+ Assert.Null(ex);
+ }
+ }
+}