aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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.yml6
-rw-r--r--.github/workflows/issue-template-check.yml4
-rw-r--r--.github/workflows/openapi-generate.yml2
-rw-r--r--.github/workflows/openapi-pull-request.yml2
-rw-r--r--.github/workflows/release-bump-version.yaml4
-rw-r--r--CONTRIBUTORS.md1
-rw-r--r--Directory.Packages.props3
-rw-r--r--Emby.Server.Implementations/ApplicationHost.cs22
-rw-r--r--Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs6
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs12
-rw-r--r--Emby.Server.Implementations/Library/PathExtensions.cs81
-rw-r--r--Emby.Server.Implementations/Library/PathManager.cs15
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs21
-rw-r--r--Emby.Server.Implementations/Library/UserDataManager.cs88
-rw-r--r--Emby.Server.Implementations/Localization/Core/fo.json40
-rw-r--r--Emby.Server.Implementations/Localization/Core/hr.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/is.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/lv.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/zh-TW.json4
-rw-r--r--Emby.Server.Implementations/Localization/LocalizationManager.cs18
-rw-r--r--Emby.Server.Implementations/Session/SessionManager.cs3
-rw-r--r--Jellyfin.Api/Controllers/ActivityLogController.cs31
-rw-r--r--Jellyfin.Api/Controllers/FilterController.cs41
-rw-r--r--Jellyfin.Api/Controllers/ImageController.cs8
-rw-r--r--Jellyfin.Api/Controllers/ItemUpdateController.cs39
-rw-r--r--Jellyfin.Api/Controllers/PluginsController.cs1
-rw-r--r--Jellyfin.Api/Controllers/StartupController.cs5
-rw-r--r--Jellyfin.Api/Extensions/DtoExtensions.cs2
-rw-r--r--Jellyfin.Api/Helpers/RequestHelpers.cs8
-rw-r--r--Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs41
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs36
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs7
-rw-r--r--Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs3
-rw-r--r--Jellyfin.Server.Implementations/Users/UserManager.cs24
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260722120000_RefreshForcedSortNames.cs113
-rw-r--r--MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs10
-rw-r--r--MediaBrowser.Controller/Entities/BaseItem.cs31
-rw-r--r--MediaBrowser.Controller/Entities/Book.cs5
-rw-r--r--MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs5
-rw-r--r--MediaBrowser.Controller/Entities/IHasStartDate.cs8
-rw-r--r--MediaBrowser.Controller/Entities/IItemByName.cs15
-rw-r--r--MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs5
-rw-r--r--MediaBrowser.Controller/Entities/SourceType.cs16
-rw-r--r--MediaBrowser.Controller/Library/ILibraryManager.cs8
-rw-r--r--MediaBrowser.Controller/Persistence/IItemRepository.cs9
-rw-r--r--MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs25
-rw-r--r--MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs7
-rw-r--r--MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs15
-rw-r--r--MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs19
-rw-r--r--MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs42
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/ISubtitleParser.cs6
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs20
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs38
-rw-r--r--MediaBrowser.Model/Configuration/ImageSavingConvention.cs12
-rw-r--r--MediaBrowser.Model/Dlna/StreamBuilder.cs30
-rw-r--r--MediaBrowser.Model/Dto/IHasServerId.cs7
-rw-r--r--MediaBrowser.Model/Dto/MediaSourceType.cs16
-rw-r--r--MediaBrowser.Model/Dto/RatingType.cs12
-rw-r--r--MediaBrowser.Model/Globalization/ILocalizationManager.cs8
-rw-r--r--MediaBrowser.Model/Library/PlayAccess.cs12
-rw-r--r--MediaBrowser.Model/LiveTv/DayPattern.cs16
-rw-r--r--MediaBrowser.Model/LiveTv/LiveTvServiceStatus.cs12
-rw-r--r--MediaBrowser.Model/MediaInfo/TransportStreamTimestamp.cs16
-rw-r--r--MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs2
-rw-r--r--MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs11
-rw-r--r--MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs2
-rw-r--r--MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs26
-rw-r--r--MediaBrowser.Providers/Lyric/LyricManager.cs4
-rw-r--r--MediaBrowser.Providers/Manager/MetadataService.cs2
-rw-r--r--MediaBrowser.Providers/MediaBrowser.Providers.csproj1
-rw-r--r--MediaBrowser.Providers/MediaInfo/ProbeProvider.cs54
-rwxr-xr-xMediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs19
-rw-r--r--README.md36
-rw-r--r--src/Jellyfin.Extensions/PathHelper.cs77
-rw-r--r--src/Jellyfin.LiveTv/Guide/GuideManager.cs27
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs18
-rw-r--r--src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs24
-rw-r--r--src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs184
-rw-r--r--tests/Jellyfin.Api.Tests/Controllers/ItemUpdateControllerTests.cs77
-rw-r--r--tests/Jellyfin.Api.Tests/Controllers/StartupControllerTests.cs70
-rw-r--r--tests/Jellyfin.Controller.Tests/ClientEventLoggerTests.cs44
-rw-r--r--tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs31
-rw-r--r--tests/Jellyfin.Extensions.Tests/PathHelperTests.cs60
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs57
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Listings/XmlTvProgramEtagTests.cs59
-rw-r--r--tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs24
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-base.xml17
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml17
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml17
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml17
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml17
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml17
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml17
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml18
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/metadata_programs_image_limit_response.json1
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs2
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs9
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs2
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Subtitles/AssParserTests.cs12
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs52
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs24
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs338
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_mp4_metadata.json6
-rw-r--r--tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs42
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/FullSystemBackup/BackupServiceTests.cs182
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs8
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs63
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs209
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs34
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs156
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerProfileImageTests.cs142
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs2
116 files changed, 3036 insertions, 436 deletions
diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml
index bc7ef7f5da..ca7213a690 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup .NET
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
@@ -32,13 +32,13 @@ jobs:
dotnet-version: '10.0.x'
- name: Initialize CodeQL
- uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
+ uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
with:
languages: ${{ matrix.language }}
queries: +security-extended
- name: Autobuild
- uses: github/codeql-action/autobuild@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
+ uses: github/codeql-action/autobuild@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
+ uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
diff --git a/.github/workflows/ci-compat.yml b/.github/workflows/ci-compat.yml
index f2c0a5c4cd..bf28129368 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
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 4531c824ce..73c8e7e50c 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml
index f8704fb1eb..5b29a66382 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml
index 6a0d7caa2e..d14ce6d508 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
token: ${{ secrets.JF_BOT_TOKEN }}
fetch-depth: 0
@@ -40,12 +40,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: pull in script
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: jellyfin/jellyfin-triage-script
- name: install python
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.14'
cache: 'pip'
diff --git a/.github/workflows/issue-template-check.yml b/.github/workflows/issue-template-check.yml
index 9dec170d70..3220bcbeac 100644
--- a/.github/workflows/issue-template-check.yml
+++ b/.github/workflows/issue-template-check.yml
@@ -10,12 +10,12 @@ jobs:
issues: write
steps:
- name: pull in script
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: jellyfin/jellyfin-triage-script
- name: install python
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.14'
cache: 'pip'
diff --git a/.github/workflows/openapi-generate.yml b/.github/workflows/openapi-generate.yml
index 09ab727c15..48bd8adb03 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref }}
repository: ${{ inputs.repository }}
diff --git a/.github/workflows/openapi-pull-request.yml b/.github/workflows/openapi-pull-request.yml
index 6b5d035420..9ed1873942 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
diff --git a/.github/workflows/release-bump-version.yaml b/.github/workflows/release-bump-version.yaml
index d22e8bf6bd..4d6e1ad6fa 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ env.TAG_BRANCH }}
@@ -66,7 +66,7 @@ jobs:
NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }}
steps:
- name: Checkout Repository
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ env.TAG_BRANCH }}
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index 4e323e332a..0df74c2bc3 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -233,6 +233,7 @@
- [MSalman5230](https://github.com/MSalman5230)
- [dwandw](https://github.com/dwandw)
- [Lampan-git](https://github.com/Lampan-git)
+ - [rwebster85](https://github.com/rwebster85)
# Emby Contributors
diff --git a/Directory.Packages.props b/Directory.Packages.props
index cb02433c06..15f36872e0 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -53,6 +53,7 @@
<PackageVersion Include="Moq" Version="4.18.4" />
<PackageVersion Include="NEbml" Version="1.1.0.5" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
+ <PackageVersion Include="PDFtoImage" Version="5.2.1" />
<PackageVersion Include="PlaylistsNET" Version="1.4.1" />
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
<PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" />
@@ -67,7 +68,7 @@
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
- <PackageVersion Include="SharpCompress" Version="0.49.1" />
+ <PackageVersion Include="SharpCompress" Version="0.50.0" />
<PackageVersion Include="SharpFuzz" Version="2.3.0" />
<PackageVersion Include="SkiaSharp" Version="3.119.4" />
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.119.4" />
diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs
index 69e23bcb63..0c1c7d3f5b 100644
--- a/Emby.Server.Implementations/ApplicationHost.cs
+++ b/Emby.Server.Implementations/ApplicationHost.cs
@@ -39,6 +39,8 @@ using Emby.Server.Implementations.SyncPlay;
using Emby.Server.Implementations.TV;
using Emby.Server.Implementations.Updates;
using Jellyfin.Api.Helpers;
+using Jellyfin.Data;
+using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Drawing;
using Jellyfin.MediaEncoding.Hls.Playlist;
using Jellyfin.Networking.Manager;
@@ -417,6 +419,8 @@ namespace Emby.Server.Implementations
{
Logger.LogInformation("Running startup tasks");
+ EnsureStartupWizardIntegrity();
+
Resolve<ITaskManager>().AddTasks(GetExports<IScheduledTask>(false));
ConfigurationManager.ConfigurationUpdated += OnConfigurationUpdated;
@@ -436,6 +440,24 @@ namespace Emby.Server.Implementations
return Task.CompletedTask;
}
+ private void EnsureStartupWizardIntegrity()
+ {
+ if (ConfigurationManager.CommonConfiguration.IsStartupWizardCompleted)
+ {
+ return;
+ }
+
+ var hasConfiguredAdministrator = Resolve<IUserManager>().GetUsers()
+ .Any(user => user.HasPermission(PermissionKind.IsAdministrator) && !string.IsNullOrEmpty(user.Password));
+
+ if (hasConfiguredAdministrator)
+ {
+ Logger.LogWarning("The startup wizard is marked incomplete but a configured administrator already exists. Marking setup as completed to prevent the unauthenticated setup endpoints from being reachable.");
+ ConfigurationManager.Configuration.IsStartupWizardCompleted = true;
+ ConfigurationManager.SaveConfiguration();
+ }
+ }
+
/// <inheritdoc/>
public void Init(IServiceCollection serviceCollection)
{
diff --git a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs
index b701e7eb6d..7cae2a671b 100644
--- a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs
+++ b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs
@@ -31,6 +31,12 @@ namespace Emby.Server.Implementations.Images
var includeItemTypes = DtoExtensions.GetBaseItemKindsForCollectionType(viewType);
var recursive = viewType != CollectionType.playlists;
+ if (viewType == CollectionType.music)
+ {
+ // Music albums usually don't have dedicated backdrops, so use artist instead
+ includeItemTypes = [BaseItemKind.MusicArtist];
+ }
+
return view.GetItemList(new InternalItemsQuery
{
CollapseBoxSetItems = false,
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index 3691f4e19d..f684f4dca3 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -3886,5 +3886,17 @@ namespace Emby.Server.Implementations.Library
{
return _mediaStreamRepository.GetMediaStreamLanguages(mediaStreamType);
}
+
+ /// <inheritdoc />
+ public IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStreamType, InternalItemsQuery query)
+ {
+ if (query.User is not null)
+ {
+ AddUserToQuery(query, query.User);
+ }
+
+ SetTopParentOrAncestorIds(query);
+ return _itemRepository.GetMediaStreamLanguages(query, mediaStreamType);
+ }
}
}
diff --git a/Emby.Server.Implementations/Library/PathExtensions.cs b/Emby.Server.Implementations/Library/PathExtensions.cs
index 7591359ea4..7d0f3900c5 100644
--- a/Emby.Server.Implementations/Library/PathExtensions.cs
+++ b/Emby.Server.Implementations/Library/PathExtensions.cs
@@ -29,17 +29,41 @@ namespace Emby.Server.Implementations.Library
throw new ArgumentException("String can't be empty.", nameof(attribute));
}
- var attributeIndex = str.IndexOf(attribute, StringComparison.OrdinalIgnoreCase);
+ // Allow tmdb as an alias for tmdbid, tvdb for tvdbid, etc.
+ // The code below only supports aliases for attributes in the form of "<alias>id".
+ ReadOnlySpan<char> shortAttr = attribute switch
+ {
+ _ when attribute.Equals("tmdbid", StringComparison.OrdinalIgnoreCase) => "tmdb",
+ _ when attribute.Equals("tvdbid", StringComparison.OrdinalIgnoreCase) => "tvdb",
+ _ when attribute.Equals("imdbid", StringComparison.OrdinalIgnoreCase) => "imdb",
+ _ => ReadOnlySpan<char>.Empty
+ };
- // Must be at least 3 characters after the attribute =, ], any character,
- // then we offset it by 1, because we want the index and not length.
- var maxIndex = str.Length - attribute.Length - 2;
- while (attributeIndex > -1 && attributeIndex < maxIndex)
+ for (int strIndex = 0, attributeIndex = 0; attributeIndex > -1;)
{
- var attributeEnd = attributeIndex + attribute.Length;
+ // We may want to use imdbid pattern matching later, so we don't want to modify the original 'str'.
+ var subStr = str[strIndex..];
+ int attributeEnd = 0;
+
+ if (shortAttr.Length > 0)
+ {
+ // If we are using an alias it should be shorter (and a prefix), so let's search for that.
+ attributeIndex = subStr.IndexOf(shortAttr, StringComparison.OrdinalIgnoreCase);
+ attributeEnd = attributeIndex + shortAttr.Length;
+ }
+ else
+ {
+ attributeIndex = subStr.IndexOf(attribute, StringComparison.OrdinalIgnoreCase);
+ attributeEnd = attributeIndex + attribute.Length;
+ }
+
+ // The next iteration should start at the end of the attribute we just found.
+ // If attributeIndex < 0, the loop will end and strIndex won't be used again.
+ strIndex += attributeEnd;
+
if (attributeIndex > 0)
{
- var attributeOpener = str[attributeIndex - 1];
+ var attributeOpener = subStr[attributeIndex - 1];
var attributeCloser = attributeOpener switch
{
'[' => ']',
@@ -47,20 +71,37 @@ namespace Emby.Server.Implementations.Library
'{' => '}',
_ => '\0'
};
- if (attributeCloser != '\0' && (str[attributeEnd] == '=' || str[attributeEnd] == '-'))
+
+ if (attributeCloser != '\0')
{
- var closingIndex = str[attributeEnd..].IndexOf(attributeCloser);
+ if (shortAttr.Length > 0
+ && attributeEnd + 1 < subStr.Length
+ && (subStr[attributeEnd] is 'i' or 'I')
+ && (subStr[attributeEnd + 1] is 'd' or 'D'))
+ {
+ // We were searching for a shortened attribute, but it's followed by "id" - let's skip it.
+ attributeEnd += 2;
+ }
- // Must be at least 1 character before the closing bracket.
- if (closingIndex > 1)
+ // attributeEnd points at '='.
+ // We need at least 1 more character and the closing bracket after that.
+ if (attributeEnd + 2 < subStr.Length && (subStr[attributeEnd] is '=' or '-'))
{
- return str[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim().ToString();
+ var closingIndex = subStr[attributeEnd..].IndexOf(attributeCloser);
+
+ // Must be at least 1 character before the closing bracket.
+ if (closingIndex > 1)
+ {
+ var trimmed = subStr[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim();
+
+ if (trimmed.Length > 0)
+ {
+ return trimmed.ToString();
+ }
+ }
}
}
}
-
- str = str[attributeEnd..];
- attributeIndex = str.IndexOf(attribute, StringComparison.OrdinalIgnoreCase);
}
// for imdbid we also accept pattern matching
@@ -70,16 +111,6 @@ namespace Emby.Server.Implementations.Library
return match ? imdbId.ToString() : null;
}
- // Allow tmdb as an alias for tmdbid
- if (attribute.Equals("tmdbid", StringComparison.OrdinalIgnoreCase))
- {
- var tmdbValue = str.GetAttributeValue("tmdb");
- if (tmdbValue is not null)
- {
- return tmdbValue;
- }
- }
-
return null;
}
diff --git a/Emby.Server.Implementations/Library/PathManager.cs b/Emby.Server.Implementations/Library/PathManager.cs
index fad948ad97..2a50fcc7fe 100644
--- a/Emby.Server.Implementations/Library/PathManager.cs
+++ b/Emby.Server.Implementations/Library/PathManager.cs
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
+using Jellyfin.Extensions;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
@@ -43,7 +44,19 @@ public class PathManager : IPathManager
public string? GetAttachmentPath(string mediaSourceId, string fileName)
{
var folder = GetAttachmentFolderPath(mediaSourceId);
- return folder is null ? null : Path.Combine(folder, fileName);
+ if (folder is null)
+ {
+ return null;
+ }
+
+ var safeName = PathHelper.GetSafeLeafFileName(fileName);
+ if (safeName is null)
+ {
+ _logger.LogWarning("Rejecting attachment filename '{FileName}' for MediaSource {MediaSourceId}: not a valid leaf name.", fileName, mediaSourceId);
+ return null;
+ }
+
+ return Path.Combine(folder, safeName);
}
/// <inheritdoc />
diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs
index 68b66ab7f5..80375ae12d 100644
--- a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs
+++ b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs
@@ -376,15 +376,24 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
// We need to only look at the name of this actual item (not parents)
var justName = item.IsInMixedFolder ? Path.GetFileName(item.Path.AsSpan()) : Path.GetFileName(item.ContainingFolderPath.AsSpan());
- var tmdbid = justName.GetAttributeValue("tmdbid");
+ // The fallback filename is only used when the item isn't in a mixed folder
+ var fileName = item.IsInMixedFolder ? ReadOnlySpan<char>.Empty : Path.GetFileName(item.Path.AsSpan());
- // If not in a mixed folder and ID not found in folder path, check filename
- if (string.IsNullOrEmpty(tmdbid) && !item.IsInMixedFolder)
+ item.TrySetProviderId(MetadataProvider.Tmdb, GetIdFromNameOrPath(justName, fileName, "tmdbid"));
+ item.TrySetProviderId(MetadataProvider.Tvdb, GetIdFromNameOrPath(justName, fileName, "tvdbid"));
+
+ string GetIdFromNameOrPath(ReadOnlySpan<char> name, ReadOnlySpan<char> fallbackName, string attribute)
{
- tmdbid = Path.GetFileName(item.Path.AsSpan()).GetAttributeValue("tmdbid");
- }
+ var id = name.GetAttributeValue(attribute);
+
+ // If not in a mixed folder and ID not found in folder path, check filename
+ if (string.IsNullOrEmpty(id) && !item.IsInMixedFolder)
+ {
+ id = fallbackName.GetAttributeValue(attribute);
+ }
- item.TrySetProviderId(MetadataProvider.Tmdb, tmdbid);
+ return id;
+ }
if (!string.IsNullOrEmpty(item.Path))
{
diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs
index 40cd2bb69c..f5c41e5670 100644
--- a/Emby.Server.Implementations/Library/UserDataManager.cs
+++ b/Emby.Server.Implementations/Library/UserDataManager.cs
@@ -192,7 +192,8 @@ namespace Emby.Server.Implementations.Library
}
else
{
- var userData = item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault();
+ var userDataRow = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id)));
+ var userData = userDataRow is not null ? Map(userDataRow) : null;
if (userData is not null)
{
result[item.Id] = userData;
@@ -211,37 +212,32 @@ namespace Emby.Server.Implementations.Library
return result;
}
- // Build a single query for all missing items
+ // Build a single query for all missing items. Fetch rows by item alone so rows kept
+ // under keys from older metadata resolve the same way as the in-memory path.
var allItemIds = itemsNeedingQuery.Select(x => x.Item.Id).ToList();
- var allKeys = itemsNeedingQuery.SelectMany(x => x.Keys).Distinct().ToList();
- if (allKeys.Count > 0)
- {
- using var context = _repository.CreateDbContext();
- var userDataArray = context.UserData
- .AsNoTracking()
- .Where(e => e.UserId.Equals(user.Id))
- .WhereOneOrMany(allItemIds, e => e.ItemId)
- .WhereOneOrMany(allKeys, e => e.CustomDataKey)
- .ToArray();
-
- var userDataByItem = userDataArray.GroupBy(e => e.ItemId).ToDictionary(g => g.Key, g => g.ToArray());
- foreach (var (item, keys) in itemsNeedingQuery)
+ using var context = _repository.CreateDbContext();
+ var userDataArray = context.UserData
+ .AsNoTracking()
+ .Where(e => e.UserId.Equals(user.Id))
+ .WhereOneOrMany(allItemIds, e => e.ItemId)
+ .ToArray();
+
+ var userDataByItem = userDataArray.GroupBy(e => e.ItemId).ToDictionary(g => g.Key, g => g.ToArray());
+ foreach (var (item, keys) in itemsNeedingQuery)
+ {
+ UserItemData userData;
+ if (userDataByItem.TryGetValue(item.Id, out var itemUserData) && itemUserData.Length > 0)
{
- UserItemData userData;
- if (userDataByItem.TryGetValue(item.Id, out var itemUserData) && itemUserData.Length > 0)
- {
- var directDataReference = itemUserData.FirstOrDefault(e => e.CustomDataKey == item.Id.ToString("N"));
- userData = directDataReference is not null ? Map(directDataReference) : Map(itemUserData.First());
- }
- else
- {
- userData = new UserItemData { Key = keys.Count > 0 ? keys[0] : string.Empty };
- }
-
- result[item.Id] = userData;
- var cacheKey = GetCacheKey(user.InternalId, item.Id);
- _cache.AddOrUpdate(cacheKey, userData);
+ userData = Map(ResolveUserDataRow(item, itemUserData)!);
}
+ else
+ {
+ userData = new UserItemData { Key = keys.Count > 0 ? keys[0] : string.Empty };
+ }
+
+ result[item.Id] = userData;
+ var cacheKey = GetCacheKey(user.InternalId, item.Id);
+ _cache.AddOrUpdate(cacheKey, userData);
}
return result;
@@ -291,8 +287,8 @@ namespace Emby.Server.Implementations.Library
{
using var dbContext = _repository.CreateDbContext();
withLocalAlternates = dbContext.LinkedChildren
- .Where(lc => lc.ChildType == Jellyfin.Database.Implementations.Entities.LinkedChildType.LocalAlternateVersion
- && localProbeIds.Contains(lc.ParentId))
+ .Where(lc => lc.ChildType == Jellyfin.Database.Implementations.Entities.LinkedChildType.LocalAlternateVersion)
+ .WhereOneOrMany(localProbeIds, lc => lc.ParentId)
.Select(lc => lc.ParentId)
.Distinct()
.ToHashSet();
@@ -356,12 +352,40 @@ namespace Emby.Server.Implementations.Library
/// <inheritdoc />
public UserItemData? GetUserData(User user, BaseItem item)
{
- return item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault() ?? new UserItemData()
+ var row = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id)));
+ return row is not null ? Map(row) : new UserItemData()
{
Key = item.GetUserDataKeys()[0],
};
}
+ /// <summary>
+ /// Picks the row matching the item's current user data keys, in key order, so rows left behind
+ /// under keys from older metadata don't take priority over the rows the write path updates.
+ /// </summary>
+ /// <param name="item">The item whose keys to match.</param>
+ /// <param name="rows">The candidate user data rows for a single user.</param>
+ /// <returns>The best matching row, or <c>null</c> when there are none.</returns>
+ private static UserData? ResolveUserDataRow(BaseItem item, IEnumerable<UserData>? rows)
+ {
+ var candidates = rows?.ToList();
+ if (candidates is null || candidates.Count == 0)
+ {
+ return null;
+ }
+
+ foreach (var key in item.GetUserDataKeys())
+ {
+ var match = candidates.Find(e => string.Equals(e.CustomDataKey, key, StringComparison.Ordinal));
+ if (match is not null)
+ {
+ return match;
+ }
+ }
+
+ return candidates[0];
+ }
+
/// <inheritdoc />
public UserItemDataDto? GetUserDataDto(BaseItem item, User user)
=> GetUserDataDto(item, null, user, new DtoOptions());
diff --git a/Emby.Server.Implementations/Localization/Core/fo.json b/Emby.Server.Implementations/Localization/Core/fo.json
index 4fb9f4329c..8605a752db 100644
--- a/Emby.Server.Implementations/Localization/Core/fo.json
+++ b/Emby.Server.Implementations/Localization/Core/fo.json
@@ -1,7 +1,7 @@
{
"Artists": "Listafólk",
"Collections": "Søvn",
- "Default": "Sjálvgildi",
+ "Default": "Forsett",
"External": "Ytri",
"Genres": "Greinar",
"AppDeviceValues": "App: {0}, Eind: {1}",
@@ -12,5 +12,41 @@
"Forced": "Kravt",
"FailedLoginAttemptWithUserName": "Miseydnað innritanarroynd frá {0}",
"HeaderFavoriteEpisodes": "Yndispartar",
- "LabelIpAddressValue": "IP atsetur: {0}"
+ "LabelIpAddressValue": "IP-atsetur: {0}",
+ "AuthenticationSucceededWithUserName": "{0} varð samgildur",
+ "HeaderFavoriteShows": "Yndisrøðir",
+ "HeaderLiveTV": "Beinleiðis sjónvarp",
+ "HearingImpaired": "Hoyrnarveik",
+ "Inherit": "Arvar",
+ "LabelRunningTimeValue": "Spælitíð: {0}",
+ "Latest": "Seinastu",
+ "LyricDownloadFailureFromForItem": "Miseydnaðist at niðurtakað sangtekst fyri {1} frá {0}",
+ "NameInstallFailed": "{0} innlegging miseydnaðist",
+ "NewVersionIsAvailable": "Ein nýggj útgáva av Jellyfin ambætaranum er tøk.",
+ "NotificationOptionNewLibraryContent": "Nýtt tilfar innlagt",
+ "NotificationOptionPluginInstalled": "Ískoytisforrit innlagt",
+ "NotificationOptionPluginUninstalled": "Ískoytisforrit strikað",
+ "NotificationOptionPluginUpdateInstalled": "Ískoytisforrit dagført",
+ "NotificationOptionUserLockedOut": "Brúkari útihýstur",
+ "Photos": "Ljósmyndir",
+ "PluginInstalledWithName": "{0} var innlagt",
+ "PluginUninstalledWithName": "{0} var strikað",
+ "PluginUpdatedWithName": "{0} varð dagført",
+ "Shows": "Røðir",
+ "SubtitleDownloadFailureFromForItem": "Miseydnaðist at niðurtakað undirtekstir til {1} frá {0}",
+ "TvShows": "Sjónvarpsrøðir",
+ "UserCreatedWithName": "Brúkari {0} er stovnaður",
+ "UserDeletedWithName": "Brúkari {0} er strikaður",
+ "UserDownloadingItemWithValues": "{0} niðurtekur {1}",
+ "UserLockedOutWithName": "Brúkari {0} er útihýstur",
+ "VersionNumber": "Útgáva {0}",
+ "TasksLibraryCategory": "Savn",
+ "TaskRefreshLibrary": "Skanna miðlasavn",
+ "TaskCleanLogsDescription": "Strikar gerðalistafílur eldri enn {0} dagar.",
+ "TaskUpdatePlugins": "Dagfør ískoytisforrit",
+ "TaskRefreshChannels": "Endurinnles rásir",
+ "TaskDownloadMissingLyricsDescription": "Niðurtekur sangtekstir",
+ "Movies": "Filmar",
+ "MixedContent": "Blandað innihald",
+ "Music": "Tónleikur"
}
diff --git a/Emby.Server.Implementations/Localization/Core/hr.json b/Emby.Server.Implementations/Localization/Core/hr.json
index 8794339fb1..442c26b30b 100644
--- a/Emby.Server.Implementations/Localization/Core/hr.json
+++ b/Emby.Server.Implementations/Localization/Core/hr.json
@@ -107,5 +107,6 @@
"TaskMoveTrickplayImagesDescription": "Premješta postojeće datoteke brzog pregledavanja u postavke biblioteke.",
"CleanupUserDataTask": "Zadatak čišćenja korisničkih podataka",
"CleanupUserDataTaskDescription": "Briše sve korisničke podatke (stanje gledanja, status favorita itd.) s medija koji više nisu prisutni najmanje 90 dana.",
- "Original": "Original"
+ "Original": "Original",
+ "LyricDownloadFailureFromForItem": "Preuzimanje tekstova pjesmi od {0} za {1} nije uspjelo"
}
diff --git a/Emby.Server.Implementations/Localization/Core/is.json b/Emby.Server.Implementations/Localization/Core/is.json
index 44e057e4de..825ad3084e 100644
--- a/Emby.Server.Implementations/Localization/Core/is.json
+++ b/Emby.Server.Implementations/Localization/Core/is.json
@@ -106,5 +106,7 @@
"CleanupUserDataTaskDescription": "Hreinsar öll notendagögn (spilunarstöðu, uppáhöld o.s.frv.) um gögn sem hafa ekki verið til staðar í að lámarki 90 daga.",
"LyricDownloadFailureFromForItem": "Ekki tókst að niðurhala texta frá {0} fyrir {1}",
"Original": "Upprunaleg",
- "TaskExtractMediaSegmentsDescription": "Sækir myndbúta úr viðbótum þar sem MediaSegment er virkt."
+ "TaskExtractMediaSegmentsDescription": "Sækir myndbúta úr viðbótum þar sem MediaSegment er virkt.",
+ "TaskMoveTrickplayImages": "Flytja geymslustað fyrir Trickplay-myndir",
+ "TaskMoveTrickplayImagesDescription": "Flytur fyrirliggjandi Trickplay-skrár í samræmi við stillingar safnsins."
}
diff --git a/Emby.Server.Implementations/Localization/Core/lv.json b/Emby.Server.Implementations/Localization/Core/lv.json
index 4a1b248e76..76fa9e3cf7 100644
--- a/Emby.Server.Implementations/Localization/Core/lv.json
+++ b/Emby.Server.Implementations/Localization/Core/lv.json
@@ -107,5 +107,6 @@
"TaskDownloadMissingLyricsDescription": "Lejupielādēt vārdus dziesmām",
"CleanupUserDataTask": "Lietotāju datu tīrīšanas uzdevums",
"CleanupUserDataTaskDescription": "Notīra visus lietotāja datus (skatīšanās stāvokļus, favorītu statusi utt.) no medijiem, kas vairs nav pieejami vismaz 90 dienas.",
- "Original": "Oriģināls"
+ "Original": "Oriģināls",
+ "LyricDownloadFailureFromForItem": "Dziesmu vārdi nevarēja tikt lejupielādēti no {0} priekš {1}"
}
diff --git a/Emby.Server.Implementations/Localization/Core/zh-TW.json b/Emby.Server.Implementations/Localization/Core/zh-TW.json
index 5dace3b0b7..4e0dff87b9 100644
--- a/Emby.Server.Implementations/Localization/Core/zh-TW.json
+++ b/Emby.Server.Implementations/Localization/Core/zh-TW.json
@@ -106,5 +106,7 @@
"TaskMoveTrickplayImages": "遷移快轉縮圖位置",
"TaskMoveTrickplayImagesDescription": "根據媒體庫的設定遷移快轉縮圖的檔案。",
"CleanupUserDataTask": "用戶資料清理工作",
- "CleanupUserDataTaskDescription": "從用戶資料中清除已被刪除超過 90 天的媒體的相關資料。"
+ "CleanupUserDataTaskDescription": "從用戶資料中清除已被刪除超過 90 天的媒體的相關資料。",
+ "Original": "原作",
+ "LyricDownloadFailureFromForItem": "無法從 {0} 下載 {1} 的歌詞"
}
diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs
index e7dd984ec4..0331ec39e5 100644
--- a/Emby.Server.Implementations/Localization/LocalizationManager.cs
+++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs
@@ -262,6 +262,24 @@ namespace Emby.Server.Implementations.Localization
}
/// <inheritdoc />
+ public string? GetLanguageDisplayName(string language)
+ {
+ if (string.IsNullOrEmpty(language))
+ {
+ return null;
+ }
+
+ var displayName = FindLanguageInfo(language)?.DisplayName;
+ if (displayName is null)
+ {
+ return null;
+ }
+
+ // Truncate at the first delimiter to avoid cluttered display names
+ return displayName.Split([';', ','], StringSplitOptions.None)[0].Trim();
+ }
+
+ /// <inheritdoc />
public IReadOnlyList<CountryInfo> GetCountries()
{
using var stream = _assembly.GetManifestResourceStream(CountriesPath) ?? throw new InvalidOperationException($"Invalid resource path: '{CountriesPath}'");
diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs
index 828bdd6859..5d62332552 100644
--- a/Emby.Server.Implementations/Session/SessionManager.cs
+++ b/Emby.Server.Implementations/Session/SessionManager.cs
@@ -641,8 +641,7 @@ namespace Emby.Server.Implementations.Session
if (playingSessions.Count > 0)
{
var idle = playingSessions
- .Where(i => (DateTime.UtcNow - i.LastPlaybackCheckIn).TotalMinutes > 5)
- .ToList();
+ .Where(i => (DateTime.UtcNow - i.LastPlaybackCheckIn).TotalMinutes > 5);
foreach (var session in idle)
{
diff --git a/Jellyfin.Api/Controllers/ActivityLogController.cs b/Jellyfin.Api/Controllers/ActivityLogController.cs
index d6cc0e71a4..8e17dd7d53 100644
--- a/Jellyfin.Api/Controllers/ActivityLogController.cs
+++ b/Jellyfin.Api/Controllers/ActivityLogController.cs
@@ -1,6 +1,6 @@
using System;
-using System.Collections.Generic;
using System.Threading.Tasks;
+using Jellyfin.Api.Helpers;
using Jellyfin.Data.Enums;
using Jellyfin.Data.Queries;
using Jellyfin.Database.Implementations.Enums;
@@ -84,36 +84,9 @@ public class ActivityLogController : BaseJellyfinApiController
ItemId = itemId,
Username = username,
Severity = severity,
- OrderBy = GetOrderBy(sortBy ?? [], sortOrder ?? []),
+ OrderBy = RequestHelpers.GetOrderBy(sortBy ?? [], sortOrder ?? []),
};
return await _activityManager.GetPagedResultAsync(query).ConfigureAwait(false);
}
-
- private static (ActivityLogSortBy SortBy, SortOrder SortOrder)[] GetOrderBy(
- IReadOnlyList<ActivityLogSortBy> sortBy,
- IReadOnlyList<SortOrder> requestedSortOrder)
- {
- if (sortBy.Count == 0)
- {
- return [];
- }
-
- var result = new (ActivityLogSortBy, SortOrder)[sortBy.Count];
- var i = 0;
- for (; i < requestedSortOrder.Count; i++)
- {
- result[i] = (sortBy[i], requestedSortOrder[i]);
- }
-
- // Add remaining elements with the first specified SortOrder
- // or the default one if no SortOrders are specified
- var order = requestedSortOrder.Count > 0 ? requestedSortOrder[0] : SortOrder.Ascending;
- for (; i < sortBy.Count; i++)
- {
- result[i] = (sortBy[i], order);
- }
-
- return result;
- }
}
diff --git a/Jellyfin.Api/Controllers/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs
index cfc8be28ae..b458bd90f3 100644
--- a/Jellyfin.Api/Controllers/FilterController.cs
+++ b/Jellyfin.Api/Controllers/FilterController.cs
@@ -158,13 +158,43 @@ public class FilterController : BaseJellyfinApiController
IsSeries = isSeries
};
+ var streamLanguageQuery = new InternalItemsQuery(user)
+ {
+ // It's possible that different langauges are only available on alternative versions.
+ // To fetch them all, owned items are included.
+ IncludeOwnedItems = true,
+ IncludeItemTypes = includeItemTypes,
+ DtoOptions = new DtoOptions
+ {
+ Fields = Array.Empty<ItemFields>(),
+ EnableImages = false,
+ EnableUserData = false
+ },
+ IsAiring = isAiring,
+ IsMovie = isMovie,
+ IsSports = isSports,
+ IsKids = isKids,
+ IsNews = isNews,
+ IsSeries = isSeries
+ };
+
if ((recursive ?? true) || parentItem is UserView || parentItem is ICollectionFolder)
{
- genreQuery.AncestorIds = parentItem is null ? Array.Empty<Guid>() : new[] { parentItem.Id };
+ var ancestorIds = parentItem is null ? Array.Empty<Guid>() : new[] { parentItem.Id };
+ genreQuery.AncestorIds = ancestorIds;
+ streamLanguageQuery.AncestorIds = ancestorIds;
}
else
{
genreQuery.Parent = parentItem;
+ streamLanguageQuery.Parent = parentItem;
+ }
+
+ if ((includeItemTypes.Contains(BaseItemKind.Series) || includeItemTypes.Contains(BaseItemKind.Season))
+ && !includeItemTypes.Contains(BaseItemKind.Episode))
+ {
+ // streams are joined on epsiodes not shows or seasons
+ streamLanguageQuery.IncludeItemTypes = [.. includeItemTypes, BaseItemKind.Episode];
}
if (includeItemTypes.Length == 1
@@ -188,10 +218,13 @@ public class FilterController : BaseJellyfinApiController
}).ToArray();
}
- if (includeItemTypes.Contains(BaseItemKind.Movie) || includeItemTypes.Contains(BaseItemKind.Series))
+ if (includeItemTypes.Contains(BaseItemKind.Movie)
+ || includeItemTypes.Contains(BaseItemKind.Series)
+ || includeItemTypes.Contains(BaseItemKind.Season)
+ || includeItemTypes.Contains(BaseItemKind.Episode))
{
filters.AudioLanguages = _libraryManager
- .GetMediaStreamLanguages(MediaStreamType.Audio)
+ .GetMediaStreamLanguages(MediaStreamType.Audio, streamLanguageQuery)
.Select(language =>
{
var culture = _localization.FindLanguageInfo(language);
@@ -204,7 +237,7 @@ public class FilterController : BaseJellyfinApiController
.OrderBy(l => l.Name)
.ToArray();
filters.SubtitleLanguages = _libraryManager
- .GetMediaStreamLanguages(MediaStreamType.Subtitle)
+ .GetMediaStreamLanguages(MediaStreamType.Subtitle, streamLanguageQuery)
.Select(language =>
{
var culture = _localization.FindLanguageInfo(language);
diff --git a/Jellyfin.Api/Controllers/ImageController.cs b/Jellyfin.Api/Controllers/ImageController.cs
index 52d8b4dad1..d492a2f5ba 100644
--- a/Jellyfin.Api/Controllers/ImageController.cs
+++ b/Jellyfin.Api/Controllers/ImageController.cs
@@ -125,7 +125,13 @@ public class ImageController : BaseJellyfinApiController
{
// Handle image/png; charset=utf-8
var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
- var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username);
+ var userConfigurationDirectoryPath = _serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath;
+ var userDataPath = Path.Combine(userConfigurationDirectoryPath, user.Username);
+ if (!PathHelper.IsContainedIn(userConfigurationDirectoryPath, userDataPath))
+ {
+ return BadRequest("Invalid user.");
+ }
+
if (user.ProfileImage is not null)
{
await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs
index e26264d292..36c82cf461 100644
--- a/Jellyfin.Api/Controllers/ItemUpdateController.cs
+++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs
@@ -236,7 +236,7 @@ public class ItemUpdateController : BaseJellyfinApiController
return NoContent();
}
- private async Task UpdateItem(BaseItemDto request, BaseItem item)
+ internal async Task UpdateItem(BaseItemDto request, BaseItem item)
{
item.Name = request.Name;
item.ForcedSortName = request.ForcedSortName;
@@ -250,7 +250,11 @@ public class ItemUpdateController : BaseJellyfinApiController
item.IndexNumber = request.IndexNumber;
item.ParentIndexNumber = request.ParentIndexNumber;
item.Overview = request.Overview;
- item.Genres = request.Genres.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
+
+ if (request.Genres is not null)
+ {
+ item.Genres = request.Genres.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
+ }
if (item is Episode episode)
{
@@ -293,10 +297,20 @@ public class ItemUpdateController : BaseJellyfinApiController
item.CustomRating = request.CustomRating;
var currentTags = item.Tags;
- 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;
+ List<string> removedTags;
+ List<string> addedTags;
+ if (request.Tags is not null)
+ {
+ var newTags = request.Tags.Select(t => t.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
+ removedTags = currentTags.Except(newTags).ToList();
+ addedTags = newTags.Except(currentTags).ToList();
+ item.Tags = newTags;
+ }
+ else
+ {
+ removedTags = [];
+ addedTags = [];
+ }
if (item is Series rseries)
{
@@ -412,15 +426,18 @@ public class ItemUpdateController : BaseJellyfinApiController
item.RunTimeTicks = request.RunTimeTicks;
}
- foreach (var pair in request.ProviderIds.ToList())
+ if (request.ProviderIds is not null)
{
- if (string.IsNullOrEmpty(pair.Value))
+ foreach (var pair in request.ProviderIds.ToList())
{
- request.ProviderIds.Remove(pair.Key);
+ if (string.IsNullOrEmpty(pair.Value))
+ {
+ request.ProviderIds.Remove(pair.Key);
+ }
}
- }
- item.ProviderIds = request.ProviderIds;
+ item.ProviderIds = request.ProviderIds;
+ }
if (item is Video video)
{
diff --git a/Jellyfin.Api/Controllers/PluginsController.cs b/Jellyfin.Api/Controllers/PluginsController.cs
index 2c84fde972..79b8b60cb7 100644
--- a/Jellyfin.Api/Controllers/PluginsController.cs
+++ b/Jellyfin.Api/Controllers/PluginsController.cs
@@ -6,6 +6,7 @@ using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Jellyfin.Api.Attributes;
+using Jellyfin.Extensions;
using Jellyfin.Extensions.Json;
using MediaBrowser.Common.Api;
using MediaBrowser.Common.Plugins;
diff --git a/Jellyfin.Api/Controllers/StartupController.cs b/Jellyfin.Api/Controllers/StartupController.cs
index fa6d9efe36..47c8f21241 100644
--- a/Jellyfin.Api/Controllers/StartupController.cs
+++ b/Jellyfin.Api/Controllers/StartupController.cs
@@ -140,6 +140,11 @@ public class StartupController : BaseJellyfinApiController
return NotFound();
}
+ if (!string.IsNullOrEmpty(user.Password))
+ {
+ return Forbid();
+ }
+
if (string.IsNullOrWhiteSpace(startupUserDto.Password))
{
return BadRequest("Password must not be empty");
diff --git a/Jellyfin.Api/Extensions/DtoExtensions.cs b/Jellyfin.Api/Extensions/DtoExtensions.cs
index 09793553c2..36a8e197a2 100644
--- a/Jellyfin.Api/Extensions/DtoExtensions.cs
+++ b/Jellyfin.Api/Extensions/DtoExtensions.cs
@@ -24,7 +24,7 @@ public static class DtoExtensions
case CollectionType.tvshows:
return [BaseItemKind.Series];
case CollectionType.music:
- return [BaseItemKind.MusicAlbum, BaseItemKind.MusicArtist];
+ return [BaseItemKind.MusicAlbum];
case CollectionType.musicvideos:
return [BaseItemKind.MusicVideo];
case CollectionType.books:
diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs
index 5072f902da..d14c3a9343 100644
--- a/Jellyfin.Api/Helpers/RequestHelpers.cs
+++ b/Jellyfin.Api/Helpers/RequestHelpers.cs
@@ -5,7 +5,6 @@ using System.Security.Claims;
using System.Threading.Tasks;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Extensions;
-using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Extensions;
@@ -31,15 +30,16 @@ public static class RequestHelpers
/// </summary>
/// <param name="sortBy">Sort By. Comma delimited string.</param>
/// <param name="requestedSortOrder">Sort Order. Comma delimited string.</param>
+ /// <typeparam name="TSortBy">The type of the sort by field.</typeparam>
/// <returns>Order By.</returns>
- public static (ItemSortBy, SortOrder)[] GetOrderBy(IReadOnlyList<ItemSortBy> sortBy, IReadOnlyList<SortOrder> requestedSortOrder)
+ public static (TSortBy, SortOrder)[] GetOrderBy<TSortBy>(IReadOnlyList<TSortBy> sortBy, IReadOnlyList<SortOrder> requestedSortOrder)
{
if (sortBy.Count == 0)
{
- return Array.Empty<(ItemSortBy, SortOrder)>();
+ return Array.Empty<(TSortBy, SortOrder)>();
}
- var result = new (ItemSortBy, SortOrder)[sortBy.Count];
+ var result = new (TSortBy, SortOrder)[sortBy.Count];
var i = 0;
// Add elements which have a SortOrder specified
for (; i < requestedSortOrder.Count; i++)
diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs
index a534fa5fa0..7c10a5dc77 100644
--- a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs
+++ b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs
@@ -359,18 +359,39 @@ public class BackupService : IBackupService
jsonSerializer.WriteStartArray();
var set = entityType.ValueFactory().ConfigureAwait(false);
- await foreach (var item in set.ConfigureAwait(false))
+ var enumerator = set.GetAsyncEnumerator();
+ await using (enumerator)
{
- entities++;
- try
+ while (true)
{
- using var document = JsonSerializer.SerializeToDocument(item, _serializerSettings);
- document.WriteTo(jsonSerializer);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Could not load entity {Entity}", item);
- throw;
+ bool hasNext;
+ try
+ {
+ hasNext = await enumerator.MoveNextAsync();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Could not read next entity of type {Table}, the underlying data appears to be corrupt. Skipping this row and continuing backup; the affected database row should be inspected and fixed manually", entityType.SourceName);
+ continue;
+ }
+
+ if (!hasNext)
+ {
+ break;
+ }
+
+ var item = enumerator.Current;
+ entities++;
+ try
+ {
+ using var document = JsonSerializer.SerializeToDocument(item, _serializerSettings);
+ document.WriteTo(jsonSerializer);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Could not load entity {Entity}", item);
+ throw;
+ }
}
}
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
index c5b5fbf6d8..6fe8563bf5 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
@@ -7,6 +7,7 @@ using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
using Microsoft.EntityFrameworkCore;
using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem;
@@ -81,6 +82,41 @@ public sealed partial class BaseItemRepository
_itemTypeLookup.MusicGenreTypes);
}
+ /// <inheritdoc />
+ public IReadOnlyList<string> GetMediaStreamLanguages(InternalItemsQuery filter, MediaStreamType mediaStreamType)
+ {
+ ArgumentNullException.ThrowIfNull(filter);
+
+ using var context = _dbProvider.CreateDbContext();
+
+ return TranslateQuery(
+ context.BaseItems.Include(e => e.MediaStreams).Where(e => e.Id != EF.Constant(PlaceholderId)),
+ context,
+ new InternalItemsQuery(filter.User)
+ {
+ IncludeOwnedItems = filter.IncludeOwnedItems,
+ ExcludeItemTypes = filter.ExcludeItemTypes,
+ IncludeItemTypes = filter.IncludeItemTypes,
+ MediaTypes = filter.MediaTypes,
+ AncestorIds = filter.AncestorIds,
+ ItemIds = filter.ItemIds,
+ TopParentIds = filter.TopParentIds,
+ ParentId = filter.ParentId,
+ IsAiring = filter.IsAiring,
+ IsMovie = filter.IsMovie,
+ IsSports = filter.IsSports,
+ IsKids = filter.IsKids,
+ IsNews = filter.IsNews,
+ IsSeries = filter.IsSeries
+ })
+ .Where(e => e.MediaStreams != null)
+ .SelectMany(e => e.MediaStreams!)
+ .Where(e => e.StreamType == (MediaStreamTypeEntity)mediaStreamType)
+ .Select(s => string.IsNullOrEmpty(s.Language) ? "und" : s.Language) // und = undetermined
+ .Distinct()
+ .ToArray();
+ }
+
private string[] GetItemValueNames(IReadOnlyList<ItemValueType> itemValueTypes, IReadOnlyList<string> withItemTypes, IReadOnlyList<string> excludeItemTypes)
{
using var context = _dbProvider.CreateDbContext();
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
index d69a589d65..f19df6259e 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
@@ -557,8 +557,13 @@ public sealed partial class BaseItemRepository
: baseQuery.Where(e => inProgressIds.Contains(e.Id));
// When several versions of the same item are in progress, keep only the most recently played one, use id as tiebreaker.
+ // Only in-progress siblings can eliminate a candidate: a version without progress has a NULL max LastPlayedDate,
+ // which is never greater and never ties. Restricting the sibling scan to the in-progress set keeps this bounded by
+ // the user's Continue Watching count instead of forcing a full BaseItems scan (COALESCE keys are non-indexable) per row.
baseQuery = baseQuery.Where(e => e.Type == seriesTypeName || !context.BaseItems
- .Where(s => s.Id != e.Id && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
+ .Where(s => s.Id != e.Id
+ && inProgressIds.Contains(s.Id)
+ && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
.Any(s =>
inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
> inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
diff --git a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs
index 7fa33c8639..a25629132b 100644
--- a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs
@@ -172,8 +172,7 @@ public class MediaStreamRepository : IMediaStreamRepository
if (!string.IsNullOrEmpty(dto.Language))
{
- var culture = _localization.FindLanguageInfo(dto.Language);
- dto.LocalizedLanguage = culture?.DisplayName;
+ dto.LocalizedLanguage = _localization.GetLanguageDisplayName(dto.Language);
}
if (dto.Type is MediaStreamType.Audio)
diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs
index 583f29f94f..81408d9aa8 100644
--- a/Jellyfin.Server.Implementations/Users/UserManager.cs
+++ b/Jellyfin.Server.Implementations/Users/UserManager.cs
@@ -616,6 +616,12 @@ namespace Jellyfin.Server.Implementations.Users
.SetProperty(f => f.LastActivityDate, date)
.SetProperty(f => f.LastLoginDate, date))
.ConfigureAwait(false);
+
+ // ExecuteUpdateAsync bypasses the change tracker, so keep the
+ // returned entity in sync. Otherwise SessionManager.LogSessionActivity
+ // saves this (stale) entity in full and reverts LastLoginDate.
+ user.LastActivityDate = date;
+ user.LastLoginDate = date;
}
await dbContext.Users
@@ -883,8 +889,20 @@ namespace Jellyfin.Server.Implementations.Users
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
- dbContext.Remove(user.ProfileImage);
- await dbContext.SaveChangesAsync().ConfigureAwait(false);
+ // Remove the tracked profile image loaded from the database instead of the
+ // detached instance on the passed in user. That instance can carry a stale,
+ // never-persisted (temporary) key, which makes EF Core throw when it is marked
+ // for deletion, leaving the profile image impossible to clear or replace.
+ var dbUser = await UserQuery(dbContext)
+ .AsTracking()
+ .FirstOrDefaultAsync(u => u.Id == user.Id)
+ .ConfigureAwait(false);
+ if (dbUser?.ProfileImage is not null)
+ {
+ dbContext.Remove(dbUser.ProfileImage);
+ dbUser.ProfileImage = null;
+ await dbContext.SaveChangesAsync().ConfigureAwait(false);
+ }
}
user.ProfileImage = null;
@@ -893,7 +911,7 @@ namespace Jellyfin.Server.Implementations.Users
internal static void ThrowIfInvalidUsername(string name)
{
- if (!string.IsNullOrWhiteSpace(name) && ValidUsernameRegex().IsMatch(name))
+ if (!string.IsNullOrWhiteSpace(name) && ValidUsernameRegex().IsMatch(name) && !string.Equals(name, ".", StringComparison.Ordinal) && !string.Equals(name, "..", StringComparison.Ordinal))
{
return;
}
diff --git a/Jellyfin.Server/Migrations/Routines/20260722120000_RefreshForcedSortNames.cs b/Jellyfin.Server/Migrations/Routines/20260722120000_RefreshForcedSortNames.cs
new file mode 100644
index 0000000000..e9eefc20dc
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/20260722120000_RefreshForcedSortNames.cs
@@ -0,0 +1,113 @@
+using System;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Extensions;
+using Jellyfin.Server.ServerSetupApp;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Migration to recompute the SortName of all items that have a forced sort name.
+/// </summary>
+[JellyfinMigration("2026-07-22T12:00:00", nameof(RefreshForcedSortNames))]
+[JellyfinMigrationBackup(JellyfinDb = true)]
+public class RefreshForcedSortNames : IAsyncMigrationRoutine
+{
+ private readonly IStartupLogger<RefreshForcedSortNames> _logger;
+ private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
+ private readonly IServerConfigurationManager _configurationManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="RefreshForcedSortNames"/> class.
+ /// </summary>
+ /// <param name="logger">The logger.</param>
+ /// <param name="dbProvider">Instance of the <see cref="IDbContextFactory{JellyfinDbContext}"/> interface.</param>
+ /// <param name="configurationManager">The server configuration manager providing the sort rules.</param>
+ public RefreshForcedSortNames(
+ IStartupLogger<RefreshForcedSortNames> logger,
+ IDbContextFactory<JellyfinDbContext> dbProvider,
+ IServerConfigurationManager configurationManager)
+ {
+ _logger = logger;
+ _dbProvider = dbProvider;
+ _configurationManager = configurationManager;
+ }
+
+ /// <inheritdoc />
+ public async Task PerformAsync(CancellationToken cancellationToken)
+ {
+ const int Limit = 10000;
+ int itemCount = 0;
+
+ var configuration = _configurationManager.Configuration;
+ // Only the Person type disables alphanumeric sorting; everything else uses the cleaning rules.
+ var personType = typeof(Person).ToString();
+
+ var sw = Stopwatch.StartNew();
+
+ using var context = _dbProvider.CreateDbContext();
+ var records = context.BaseItems.Count(b => !string.IsNullOrEmpty(b.ForcedSortName));
+ _logger.LogInformation("Refreshing SortName for {Count} library items with a forced sort name", records);
+
+ var processedInPartition = 0;
+
+ await foreach (var item in context.BaseItems
+ .Where(b => !string.IsNullOrEmpty(b.ForcedSortName))
+ .OrderBy(e => e.Id)
+ .WithPartitionProgress((partition) => _logger.LogInformation("Processed: {Offset}/{Total} - Updated: {UpdatedCount} - Time: {Elapsed}", partition * Limit, records, itemCount, sw.Elapsed))
+ .PartitionEagerAsync(Limit, cancellationToken)
+ .WithCancellation(cancellationToken)
+ .ConfigureAwait(false))
+ {
+ try
+ {
+ var enableAlphaNumericSorting = !string.Equals(item.Type, personType, StringComparison.Ordinal);
+ var newSortName = BaseItem.GetSortName(item.ForcedSortName!, enableAlphaNumericSorting, configuration);
+ if (!string.Equals(newSortName, item.SortName, StringComparison.Ordinal))
+ {
+ _logger.LogDebug(
+ "Updating SortName for item {Id}: '{OldValue}' -> '{NewValue}'",
+ item.Id,
+ item.SortName,
+ newSortName);
+ item.SortName = newSortName;
+ itemCount++;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to update SortName for item {Id} ({Name})", item.Id, item.Name);
+ }
+
+ processedInPartition++;
+
+ if (processedInPartition >= Limit)
+ {
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ // Clear tracked entities to avoid memory growth across partitions
+ context.ChangeTracker.Clear();
+ processedInPartition = 0;
+ }
+ }
+
+ // Save any remaining changes after the loop
+ if (processedInPartition > 0)
+ {
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ context.ChangeTracker.Clear();
+ }
+
+ _logger.LogInformation(
+ "Refreshed SortName for {UpdatedCount} out of {TotalCount} items in {Time}",
+ itemCount,
+ records,
+ sw.Elapsed);
+ }
+}
diff --git a/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs b/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs
index 14dc64dabd..36f0d2195c 100644
--- a/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs
+++ b/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs
@@ -1,6 +1,7 @@
using System;
using System.IO;
using System.Threading.Tasks;
+using Jellyfin.Extensions;
namespace MediaBrowser.Controller.ClientEvent
{
@@ -21,8 +22,15 @@ namespace MediaBrowser.Controller.ClientEvent
/// <inheritdoc />
public async Task<string> WriteDocumentAsync(string clientName, string clientVersion, Stream fileContents)
{
- var fileName = $"upload_{clientName}_{clientVersion}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.log";
+ var safeClientName = PathHelper.GetSafeLeafFileName(clientName) ?? "unknown-client";
+ var safeClientVersion = PathHelper.GetSafeLeafFileName(clientVersion) ?? "unknown-version";
+ var fileName = $"upload_{safeClientName}_{safeClientVersion}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.log";
var logFilePath = Path.Combine(_applicationPaths.LogDirectoryPath, fileName);
+ if (!PathHelper.IsContainedIn(_applicationPaths.LogDirectoryPath, logFilePath))
+ {
+ throw new ArgumentException("Path resolved to filename not in log directory");
+ }
+
var fileStream = new FileStream(logFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
await using (fileStream.ConfigureAwait(false))
{
diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs
index 49a4ed4bf6..21a726aaec 100644
--- a/MediaBrowser.Controller/Entities/BaseItem.cs
+++ b/MediaBrowser.Controller/Entities/BaseItem.cs
@@ -27,6 +27,7 @@ using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaSegments;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization;
@@ -540,8 +541,8 @@ namespace MediaBrowser.Controller.Entities
{
if (!string.IsNullOrEmpty(ForcedSortName))
{
- // Need the ToLower because that's what CreateSortName does
- _sortName = ModifySortChunks(ForcedSortName).ToLowerInvariant();
+ // Run the forced sort name through the same cleaning as auto-generated sort names.
+ _sortName = GetSortName(ForcedSortName, EnableAlphaNumericSorting, ConfigurationManager.Configuration);
}
else
{
@@ -926,19 +927,31 @@ namespace MediaBrowser.Controller.Entities
/// <returns>System.String.</returns>
protected virtual string CreateSortName()
{
- if (Name is null)
+ return GetSortName(Name, EnableAlphaNumericSorting, ConfigurationManager.Configuration);
+ }
+
+ /// <summary>
+ /// Cleans a raw name into its sortable form by applying the configured sort rules.
+ /// </summary>
+ /// <param name="name">The raw name to clean.</param>
+ /// <param name="enableAlphaNumericSorting">Whether alphanumeric sorting rules should be applied.</param>
+ /// <param name="configuration">The server configuration providing the sort rules.</param>
+ /// <returns>The cleaned, sortable name, or <c>null</c> if <paramref name="name"/> is <c>null</c>.</returns>
+ public static string GetSortName(string name, bool enableAlphaNumericSorting, ServerConfiguration configuration)
+ {
+ if (name is null)
{
return null; // some items may not have name filled in properly
}
- if (!EnableAlphaNumericSorting)
+ if (!enableAlphaNumericSorting)
{
- return Name.TrimStart();
+ return name.TrimStart();
}
- var sortable = Name.Trim().ToLowerInvariant();
+ var sortable = name.Trim().ToLowerInvariant();
- foreach (var search in ConfigurationManager.Configuration.SortRemoveWords)
+ foreach (var search in configuration.SortRemoveWords)
{
// Remove from beginning if a space follows
if (sortable.StartsWith(search + " ", StringComparison.Ordinal))
@@ -956,12 +969,12 @@ namespace MediaBrowser.Controller.Entities
}
}
- foreach (var removeChar in ConfigurationManager.Configuration.SortRemoveCharacters)
+ foreach (var removeChar in configuration.SortRemoveCharacters)
{
sortable = sortable.Replace(removeChar, string.Empty, StringComparison.Ordinal);
}
- foreach (var replaceChar in ConfigurationManager.Configuration.SortReplaceCharacters)
+ foreach (var replaceChar in configuration.SortReplaceCharacters)
{
sortable = sortable.Replace(replaceChar, " ", StringComparison.Ordinal);
}
diff --git a/MediaBrowser.Controller/Entities/Book.cs b/MediaBrowser.Controller/Entities/Book.cs
index 5187669373..8559681bdc 100644
--- a/MediaBrowser.Controller/Entities/Book.cs
+++ b/MediaBrowser.Controller/Entities/Book.cs
@@ -13,11 +13,6 @@ namespace MediaBrowser.Controller.Entities
[Common.RequiresSourceSerialisation]
public class Book : BaseItem, IHasLookupInfo<BookInfo>, IHasSeries
{
- public Book()
- {
- this.RunTimeTicks = TimeSpan.TicksPerSecond;
- }
-
[JsonIgnore]
public override MediaType MediaType => MediaType.Book;
diff --git a/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs b/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs
index f47d2162f7..0cdc8bce03 100644
--- a/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs
+++ b/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs
@@ -1,12 +1,13 @@
#nullable disable
-#pragma warning disable CS1591
-
using System;
using System.Collections.Generic;
namespace MediaBrowser.Controller.Entities
{
+ /// <summary>
+ /// Interface for items that have special features.
+ /// </summary>
public interface IHasSpecialFeatures
{
/// <summary>
diff --git a/MediaBrowser.Controller/Entities/IHasStartDate.cs b/MediaBrowser.Controller/Entities/IHasStartDate.cs
index dab15eb018..47df09d1ce 100644
--- a/MediaBrowser.Controller/Entities/IHasStartDate.cs
+++ b/MediaBrowser.Controller/Entities/IHasStartDate.cs
@@ -1,11 +1,15 @@
-#pragma warning disable CS1591
-
using System;
namespace MediaBrowser.Controller.Entities
{
+ /// <summary>
+ /// Interface for items that have a start date.
+ /// </summary>
public interface IHasStartDate
{
+ /// <summary>
+ /// Gets or sets the start date.
+ /// </summary>
DateTime StartDate { get; set; }
}
}
diff --git a/MediaBrowser.Controller/Entities/IItemByName.cs b/MediaBrowser.Controller/Entities/IItemByName.cs
index 4928bda7a2..756dbecb98 100644
--- a/MediaBrowser.Controller/Entities/IItemByName.cs
+++ b/MediaBrowser.Controller/Entities/IItemByName.cs
@@ -1,19 +1,28 @@
-#pragma warning disable CS1591
-
using System.Collections.Generic;
namespace MediaBrowser.Controller.Entities
{
/// <summary>
- /// Marker interface.
+ /// Marker interface for items that represent a name, like a genre or a studio.
/// </summary>
public interface IItemByName
{
+ /// <summary>
+ /// Gets the items tagged with this name.
+ /// </summary>
+ /// <param name="query">The query.</param>
+ /// <returns>The tagged items.</returns>
IReadOnlyList<BaseItem> GetTaggedItems(InternalItemsQuery query);
}
+ /// <summary>
+ /// Interface for by-name items that can also be accessed as a regular library item.
+ /// </summary>
public interface IHasDualAccess : IItemByName
{
+ /// <summary>
+ /// Gets a value indicating whether the item is accessed by name.
+ /// </summary>
bool IsAccessedByName { get; }
}
}
diff --git a/MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs b/MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs
index cdda8ea399..0f8904df5c 100644
--- a/MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs
+++ b/MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs
@@ -1,7 +1,8 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Entities
{
+ /// <summary>
+ /// Interface for items that can be placeholders.
+ /// </summary>
public interface ISupportsPlaceHolders
{
/// <summary>
diff --git a/MediaBrowser.Controller/Entities/SourceType.cs b/MediaBrowser.Controller/Entities/SourceType.cs
index be19e1bdae..97aa22dc04 100644
--- a/MediaBrowser.Controller/Entities/SourceType.cs
+++ b/MediaBrowser.Controller/Entities/SourceType.cs
@@ -1,11 +1,23 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Entities
{
+ /// <summary>
+ /// The source of an item.
+ /// </summary>
public enum SourceType
{
+ /// <summary>
+ /// The item comes from a library.
+ /// </summary>
Library = 0,
+
+ /// <summary>
+ /// The item comes from a channel.
+ /// </summary>
Channel = 1,
+
+ /// <summary>
+ /// The item comes from live TV.
+ /// </summary>
LiveTV = 2
}
}
diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs
index 0b64da291c..6d85a1e401 100644
--- a/MediaBrowser.Controller/Library/ILibraryManager.cs
+++ b/MediaBrowser.Controller/Library/ILibraryManager.cs
@@ -799,5 +799,13 @@ namespace MediaBrowser.Controller.Library
/// <param name="mediaStreamType">The stream type.</param>
/// <returns>List of language codes.</returns>
IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStreamType);
+
+ /// <summary>
+ /// Gets a list of all language codes for the matching items and the the provided stream type.
+ /// </summary>
+ /// <param name="mediaStreamType">The stream type.</param>
+ /// <param name="query">The query filter.</param>
+ /// <returns>List of language codes.</returns>
+ IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStreamType, InternalItemsQuery query);
}
}
diff --git a/MediaBrowser.Controller/Persistence/IItemRepository.cs b/MediaBrowser.Controller/Persistence/IItemRepository.cs
index 291916ab25..d44fe57bed 100644
--- a/MediaBrowser.Controller/Persistence/IItemRepository.cs
+++ b/MediaBrowser.Controller/Persistence/IItemRepository.cs
@@ -7,6 +7,7 @@ using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
namespace MediaBrowser.Controller.Persistence;
@@ -120,6 +121,14 @@ public interface IItemRepository
IReadOnlyList<string> GetGenreNames();
/// <summary>
+ /// Gets all language codes of the matching base items and the provided stream type.
+ /// </summary>
+ /// <param name="filter">The query filter.</param>
+ /// <param name="mediaStreamType">The type of the media stream.</param>
+ /// <returns>List of language codes.</returns>
+ public IReadOnlyList<string> GetMediaStreamLanguages(InternalItemsQuery filter, MediaStreamType mediaStreamType);
+
+ /// <summary>
/// Gets all artist names.
/// </summary>
/// <returns>The list of artist names.</returns>
diff --git a/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs
index c0a168192e..9326864d78 100644
--- a/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs
+++ b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs
@@ -272,7 +272,7 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
public void SetPlayingItemByIndex(int playlistIndex)
{
var playlist = GetPlaylistInternal();
- if (playlistIndex < 0 || playlistIndex > playlist.Count)
+ if (playlistIndex < 0 || playlistIndex >= playlist.Count)
{
PlayingItemIndex = NoPlayingItemIndex;
}
@@ -293,6 +293,15 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
{
var playingItem = GetPlayingItem();
+ // Removed items that precede the playing item shift its index as well.
+ var removedBeforePlayingItem = 0;
+ if (playingItem is not null)
+ {
+ removedBeforePlayingItem = GetPlaylistInternal()
+ .Take(PlayingItemIndex)
+ .Count(item => playlistItemIds.Contains(item.PlaylistItemId));
+ }
+
_sortedPlaylist.RemoveAll(item => playlistItemIds.Contains(item.PlaylistItemId));
_shuffledPlaylist.RemoveAll(item => playlistItemIds.Contains(item.PlaylistItemId));
@@ -303,12 +312,12 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
if (playlistItemIds.Contains(playingItem.PlaylistItemId))
{
// Playing item has been removed, picking previous item.
- PlayingItemIndex--;
+ PlayingItemIndex -= removedBeforePlayingItem + 1;
if (PlayingItemIndex < 0)
{
// Was first element, picking next if available.
// Default to no playing item otherwise.
- PlayingItemIndex = _sortedPlaylist.Count > 0 ? 0 : NoPlayingItemIndex;
+ PlayingItemIndex = GetPlaylistInternal().Count > 0 ? 0 : NoPlayingItemIndex;
}
return true;
@@ -444,6 +453,11 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
/// <returns><c>true</c> if the playing item changed; <c>false</c> otherwise.</returns>
public bool Next()
{
+ if (GetPlaylistInternal().Count == 0)
+ {
+ return false;
+ }
+
if (RepeatMode.Equals(GroupRepeatMode.RepeatOne))
{
LastChange = DateTime.UtcNow;
@@ -474,6 +488,11 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
/// <returns><c>true</c> if the playing item changed; <c>false</c> otherwise.</returns>
public bool Previous()
{
+ if (GetPlaylistInternal().Count == 0)
+ {
+ return false;
+ }
+
if (RepeatMode.Equals(GroupRepeatMode.RepeatOne))
{
LastChange = DateTime.UtcNow;
diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
index 9dd3dcecba..12a5ab877c 100644
--- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
+++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
@@ -8,6 +8,7 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using AsyncKeyedLock;
+using Jellyfin.Extensions;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
@@ -101,7 +102,7 @@ namespace MediaBrowser.MediaEncoding.Attachments
CancellationToken cancellationToken)
{
var shouldExtractOneByOne = mediaSource.MediaAttachments.Any(a => !string.IsNullOrEmpty(a.FileName)
- && (a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase)));
+ && !string.Equals(PathHelper.GetSafeLeafFileName(a.FileName), a.FileName, StringComparison.Ordinal));
if (shouldExtractOneByOne && !inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
{
await ExtractAllAttachmentsIndividuallyInternal(
@@ -387,7 +388,9 @@ namespace MediaBrowser.MediaEncoding.Attachments
using (await _semaphoreLocks.LockAsync(attachmentFolderPath, cancellationToken).ConfigureAwait(false))
{
- var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? mediaAttachment.Index.ToString(CultureInfo.InvariantCulture))!;
+ var indexName = mediaAttachment.Index.ToString(CultureInfo.InvariantCulture);
+ var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? indexName)
+ ?? _pathManager.GetAttachmentPath(mediaSource.Id, indexName)!;
if (!File.Exists(attachmentPath))
{
await ExtractAttachmentInternal(
diff --git a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs
index c8670c67cc..91d0c3d5a6 100644
--- a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs
+++ b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs
@@ -8,6 +8,7 @@ using System.Linq;
using System.Runtime.Versioning;
using System.Text;
using System.Text.RegularExpressions;
+using System.Threading.Tasks;
using MediaBrowser.Controller.MediaEncoding;
using Microsoft.Extensions.Logging;
@@ -185,8 +186,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
{ "libavdevice", new Version(58, 13) },
{ "libavfilter", new Version(7, 110) },
{ "libswscale", new Version(5, 9) },
- { "libswresample", new Version(3, 9) },
- { "libpostproc", new Version(55, 9) }
+ { "libswresample", new Version(3, 9) }
};
private readonly ILogger _logger;
@@ -663,8 +663,15 @@ namespace MediaBrowser.MediaEncoding.Encoder
writer.Write(testKey);
}
- using var reader = readStdErr ? process.StandardError : process.StandardOutput;
- return reader.ReadToEnd();
+ // Drain both streams concurrently to prevent pipe hanging, see #17429
+ using var standardOutput = process.StandardOutput;
+ using var standardError = process.StandardError;
+ var standardOutputTask = standardOutput.ReadToEndAsync();
+ var standardErrorTask = standardError.ReadToEndAsync();
+ process.WaitForExit();
+ Task.WaitAll(standardOutputTask, standardErrorTask);
+
+ return (readStdErr ? standardErrorTask : standardOutputTask).GetAwaiter().GetResult();
}
}
diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
index 1199fd7d70..0ddd378352 100644
--- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
+++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
@@ -928,6 +928,25 @@ namespace MediaBrowser.MediaEncoding.Encoder
throw new InvalidOperationException("EncodingHelper returned empty or invalid filter parameters.");
}
+ // Normalize invalid PTS from containers for non keyframe only mode
+ if (!enableKeyFrameOnlyExtraction)
+ {
+ var fpsFilterIndex = filterParam.IndexOf("fps=", StringComparison.Ordinal);
+ if (fpsFilterIndex >= 0)
+ {
+ var inputFrameRate = (imageStream.ReferenceFrameRate.HasValue && imageStream.ReferenceFrameRate > 0)
+ ? imageStream.ReferenceFrameRate.Value : 30;
+
+ var setPtsFilter = string.Create(CultureInfo.InvariantCulture, $"setpts=N/{inputFrameRate:F3}/TB,");
+
+ filterParam = filterParam.Insert(fpsFilterIndex, setPtsFilter);
+ }
+ else
+ {
+ throw new InvalidOperationException("EncodingHelper returned invalid filter parameters.");
+ }
+ }
+
try
{
return await ExtractVideoImagesOnIntervalInternal(
diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
index 203e565ac7..b6acfdbf3b 100644
--- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
+++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
@@ -732,9 +732,10 @@ namespace MediaBrowser.MediaEncoding.Probing
stream.LocalizedDefault = _localization.GetLocalizedString("Default");
stream.LocalizedExternal = _localization.GetLocalizedString("External");
stream.LocalizedOriginal = _localization.GetLocalizedString("Original");
- stream.LocalizedLanguage = string.IsNullOrEmpty(stream.Language)
- ? null
- : _localization.FindLanguageInfo(stream.Language)?.DisplayName;
+ if (!string.IsNullOrEmpty(stream.Language))
+ {
+ stream.LocalizedLanguage = _localization.GetLanguageDisplayName(stream.Language);
+ }
stream.Channels = streamInfo.Channels;
@@ -756,11 +757,17 @@ namespace MediaBrowser.MediaEncoding.Probing
if (string.IsNullOrEmpty(stream.Title))
{
- // mp4 missing track title workaround: fall back to handler_name if populated and not the default "SoundHandler"
- string handlerName = GetDictionaryValue(streamInfo.Tags, "handler_name");
- if (!string.IsNullOrEmpty(handlerName) && !string.Equals(handlerName, "SoundHandler", StringComparison.OrdinalIgnoreCase))
+ // FFprobe exposes MP4 track names via the name tag rather than title
+ stream.Title = GetDictionaryValue(streamInfo.Tags, "name");
+
+ if (string.IsNullOrEmpty(stream.Title))
{
- stream.Title = handlerName;
+ // fall back to handler_name if populated and not the default "SoundHandler"
+ string handlerName = GetDictionaryValue(streamInfo.Tags, "handler_name");
+ if (!string.IsNullOrEmpty(handlerName) && !string.Equals(handlerName, "SoundHandler", StringComparison.OrdinalIgnoreCase))
+ {
+ stream.Title = handlerName;
+ }
}
}
}
@@ -773,17 +780,24 @@ namespace MediaBrowser.MediaEncoding.Probing
stream.LocalizedForced = _localization.GetLocalizedString("Forced");
stream.LocalizedExternal = _localization.GetLocalizedString("External");
stream.LocalizedHearingImpaired = _localization.GetLocalizedString("HearingImpaired");
- stream.LocalizedLanguage = string.IsNullOrEmpty(stream.Language)
- ? null
- : _localization.FindLanguageInfo(stream.Language)?.DisplayName;
+ if (!string.IsNullOrEmpty(stream.Language))
+ {
+ stream.LocalizedLanguage = _localization.GetLanguageDisplayName(stream.Language);
+ }
if (string.IsNullOrEmpty(stream.Title))
{
- // mp4 missing track title workaround: fall back to handler_name if populated and not the default "SubtitleHandler"
- string handlerName = GetDictionaryValue(streamInfo.Tags, "handler_name");
- if (!string.IsNullOrEmpty(handlerName) && !string.Equals(handlerName, "SubtitleHandler", StringComparison.OrdinalIgnoreCase))
+ // FFprobe exposes MP4 track names via the name tag rather than title
+ stream.Title = GetDictionaryValue(streamInfo.Tags, "name");
+
+ if (string.IsNullOrEmpty(stream.Title))
{
- stream.Title = handlerName;
+ // fall back to handler_name if populated and not the default "SubtitleHandler"
+ string handlerName = GetDictionaryValue(streamInfo.Tags, "handler_name");
+ if (!string.IsNullOrEmpty(handlerName) && !string.Equals(handlerName, "SubtitleHandler", StringComparison.OrdinalIgnoreCase))
+ {
+ stream.Title = handlerName;
+ }
}
}
}
diff --git a/MediaBrowser.MediaEncoding/Subtitles/ISubtitleParser.cs b/MediaBrowser.MediaEncoding/Subtitles/ISubtitleParser.cs
index bd13437fb6..7566616f70 100644
--- a/MediaBrowser.MediaEncoding/Subtitles/ISubtitleParser.cs
+++ b/MediaBrowser.MediaEncoding/Subtitles/ISubtitleParser.cs
@@ -1,7 +1,7 @@
#pragma warning disable CS1591
using System.IO;
-using MediaBrowser.Model.MediaInfo;
+using Nikse.SubtitleEdit.Core.Common;
namespace MediaBrowser.MediaEncoding.Subtitles
{
@@ -12,8 +12,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles
/// </summary>
/// <param name="stream">The stream.</param>
/// <param name="fileExtension">The file extension.</param>
- /// <returns>SubtitleTrackInfo.</returns>
- SubtitleTrackInfo Parse(Stream stream, string fileExtension);
+ /// <returns>The parsed subtitle.</returns>
+ Subtitle Parse(Stream stream, string fileExtension);
/// <summary>
/// Determines whether the file extension is supported by the parser.
diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs
index d060b247da..d75eea5904 100644
--- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs
+++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs
@@ -1,10 +1,8 @@
using System;
using System.Collections.Generic;
-using System.Globalization;
using System.IO;
using System.Linq;
using Jellyfin.Extensions;
-using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
using Nikse.SubtitleEdit.Core.Common;
using SubtitleFormat = Nikse.SubtitleEdit.Core.SubtitleFormats.SubtitleFormat;
@@ -30,7 +28,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
}
/// <inheritdoc />
- public SubtitleTrackInfo Parse(Stream stream, string fileExtension)
+ public Subtitle Parse(Stream stream, string fileExtension)
{
var subtitle = new Subtitle();
var lines = stream.ReadAllLines().ToList();
@@ -76,21 +74,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
throw new ArgumentException("Unsupported format: " + fileExtension);
}
- var trackInfo = new SubtitleTrackInfo();
- int len = subtitle.Paragraphs.Count;
- var trackEvents = new SubtitleTrackEvent[len];
- for (int i = 0; i < len; i++)
- {
- var p = subtitle.Paragraphs[i];
- trackEvents[i] = new SubtitleTrackEvent(p.Number.ToString(CultureInfo.InvariantCulture), p.Text)
- {
- StartPositionTicks = p.StartTime.TimeSpan.Ticks,
- EndPositionTicks = p.EndTime.TimeSpan.Ticks
- };
- }
-
- trackInfo.TrackEvents = trackEvents;
- return trackInfo;
+ return subtitle;
}
/// <inheritdoc />
diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
index 28f7b92268..bd516f0a9f 100644
--- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
+++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
@@ -73,7 +73,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
_serverConfigurationManager = serverConfigurationManager;
}
- private MemoryStream ConvertSubtitles(
+ internal MemoryStream ConvertSubtitles(
Stream stream,
SubtitleInfo inputInfo,
string outputFormat,
@@ -81,7 +81,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
long endTimeTicks,
bool preserveOriginalTimestamps)
{
- var subtitle = Subtitle.Parse(stream, Path.GetExtension(inputInfo.Path));
+ var subtitle = _subtitleParser.Parse(stream, inputInfo.Format);
FilterEvents(subtitle, startTimeTicks, endTimeTicks, preserveOriginalTimestamps);
@@ -163,28 +163,36 @@ namespace MediaBrowser.MediaEncoding.Subtitles
return (stream, fileInfo);
}
- private async Task<Stream> GetSubtitleStream(SubtitleInfo fileInfo, CancellationToken cancellationToken)
+ internal async Task<Stream> GetSubtitleStream(SubtitleInfo fileInfo, CancellationToken cancellationToken)
{
- if (fileInfo.Protocol == MediaProtocol.Http)
+ if (fileInfo.IsExternal && MediaStream.IsTextFormat(fileInfo.Format))
{
var result = await DetectCharset(fileInfo.Path, cancellationToken).ConfigureAwait(false);
var detected = result.Detected;
- if (detected is not null)
+ var stream = fileInfo.Protocol == MediaProtocol.Http
+ ? await _httpClientFactory.CreateClient(NamedClient.Default)
+ .GetStreamAsync(new Uri(fileInfo.Path), cancellationToken)
+ .ConfigureAwait(false)
+ : AsyncFile.OpenRead(fileInfo.Path);
+
+ // Short-circuit when the file is already UTF-8/ASCII.
+ if (detected is null
+ || string.Equals(detected.EncodingName, "utf-8", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(detected.EncodingName, "ascii", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(detected.EncodingName, "us-ascii", StringComparison.OrdinalIgnoreCase))
{
- _logger.LogDebug("charset {CharSet} detected for {Path}", detected.EncodingName, fileInfo.Path);
+ return stream;
+ }
- using var stream = await _httpClientFactory.CreateClient(NamedClient.Default)
- .GetStreamAsync(new Uri(fileInfo.Path), cancellationToken)
- .ConfigureAwait(false);
+ _logger.LogDebug("charset {CharSet} detected for {Path}", detected.EncodingName, fileInfo.Path);
- await using (stream.ConfigureAwait(false))
- {
- using var reader = new StreamReader(stream, detected.Encoding);
- var text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
+ await using (stream.ConfigureAwait(false))
+ {
+ using var reader = new StreamReader(stream, detected.Encoding);
+ var text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
- return new MemoryStream(Encoding.UTF8.GetBytes(text));
- }
+ return new MemoryStream(Encoding.UTF8.GetBytes(text));
}
}
diff --git a/MediaBrowser.Model/Configuration/ImageSavingConvention.cs b/MediaBrowser.Model/Configuration/ImageSavingConvention.cs
index 485a4d2f80..c67f379fde 100644
--- a/MediaBrowser.Model/Configuration/ImageSavingConvention.cs
+++ b/MediaBrowser.Model/Configuration/ImageSavingConvention.cs
@@ -1,10 +1,18 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.Configuration
{
+ /// <summary>
+ /// The convention used for naming saved images.
+ /// </summary>
public enum ImageSavingConvention
{
+ /// <summary>
+ /// The legacy naming convention.
+ /// </summary>
Legacy,
+
+ /// <summary>
+ /// The naming convention compatible with other media servers and metadata managers.
+ /// </summary>
Compatible
}
}
diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs
index 59f97d8c7c..a9ab7d6db0 100644
--- a/MediaBrowser.Model/Dlna/StreamBuilder.cs
+++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs
@@ -576,11 +576,8 @@ namespace MediaBrowser.Model.Dlna
foreach (var profile in subtitleProfiles)
{
if (profile.Method == SubtitleDeliveryMethod.External
- && (string.Equals(profile.Format, stream.Codec, StringComparison.OrdinalIgnoreCase)
- // FFmpeg cannot mux VobSub back into an .idx/.sub pair, so extracted VobSub streams are exposed as .mks.
- || (string.Equals(profile.Format, "mks", StringComparison.OrdinalIgnoreCase)
- && stream.IsVobSubSubtitleStream
- && (!stream.IsExternal || stream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase)))))
+ && (IsVobSubMksProfile(profile, stream)
+ || (!IsVobSubMksDeliveryProfile(profile) && string.Equals(profile.Format, stream.Codec, StringComparison.OrdinalIgnoreCase))))
{
return stream.Index;
}
@@ -1590,13 +1587,11 @@ namespace MediaBrowser.Model.Dlna
continue;
}
- // FFmpeg cannot mux VobSub back into an .idx/.sub pair, so extracted VobSub streams are matched against external .mks delivery profiles.
- bool isVobSubMksProfile = string.Equals(profile.Format, "mks", StringComparison.OrdinalIgnoreCase)
- && subtitleStream.IsVobSubSubtitleStream
- && (!subtitleStream.IsExternal || subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase));
+ bool isVobSubMksProfile = IsVobSubMksProfile(profile, subtitleStream);
if ((profile.Method == SubtitleDeliveryMethod.External
- && (isVobSubMksProfile || subtitleStream.IsTextSubtitleStream == MediaStream.IsTextFormat(profile.Format))) ||
+ && (isVobSubMksProfile
+ || (!IsVobSubMksDeliveryProfile(profile) && subtitleStream.IsTextSubtitleStream == MediaStream.IsTextFormat(profile.Format)))) ||
(profile.Method == SubtitleDeliveryMethod.Hls && subtitleStream.IsTextSubtitleStream))
{
bool requiresConversion = !isVobSubMksProfile
@@ -1628,6 +1623,21 @@ namespace MediaBrowser.Model.Dlna
return null;
}
+ private static bool IsVobSubMksDeliveryProfile(SubtitleProfile profile)
+ {
+ return MediaStream.IsVobSubFormat(profile.Format)
+ && !string.IsNullOrWhiteSpace(profile.Container)
+ && ContainerHelper.ContainsContainer(profile.Container, "mks");
+ }
+
+ private static bool IsVobSubMksProfile(SubtitleProfile profile, MediaStream subtitleStream)
+ {
+ // FFmpeg cannot mux VobSub back into an .idx/.sub pair, so extracted VobSub streams are exposed as .mks.
+ return IsVobSubMksDeliveryProfile(profile)
+ && subtitleStream.IsVobSubSubtitleStream
+ && (!subtitleStream.IsExternal || subtitleStream.Path?.EndsWith(".mks", StringComparison.OrdinalIgnoreCase) == true);
+ }
+
private bool IsBitrateLimitExceeded(MediaSourceInfo item, long maxBitrate)
{
// Don't restrict bitrate if item is remote.
diff --git a/MediaBrowser.Model/Dto/IHasServerId.cs b/MediaBrowser.Model/Dto/IHasServerId.cs
index c754d276c5..49452d736a 100644
--- a/MediaBrowser.Model/Dto/IHasServerId.cs
+++ b/MediaBrowser.Model/Dto/IHasServerId.cs
@@ -1,10 +1,15 @@
#nullable disable
-#pragma warning disable CS1591
namespace MediaBrowser.Model.Dto
{
+ /// <summary>
+ /// Interface for DTOs that reference the id of the server they originate from.
+ /// </summary>
public interface IHasServerId
{
+ /// <summary>
+ /// Gets the server id.
+ /// </summary>
string ServerId { get; }
}
}
diff --git a/MediaBrowser.Model/Dto/MediaSourceType.cs b/MediaBrowser.Model/Dto/MediaSourceType.cs
index 42314d5198..ca6649e64b 100644
--- a/MediaBrowser.Model/Dto/MediaSourceType.cs
+++ b/MediaBrowser.Model/Dto/MediaSourceType.cs
@@ -1,11 +1,23 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.Dto
{
+ /// <summary>
+ /// The type of a media source.
+ /// </summary>
public enum MediaSourceType
{
+ /// <summary>
+ /// A default media source.
+ /// </summary>
Default = 0,
+
+ /// <summary>
+ /// A grouping of media sources.
+ /// </summary>
Grouping = 1,
+
+ /// <summary>
+ /// A placeholder media source, for example a disc that has to be inserted.
+ /// </summary>
Placeholder = 2
}
}
diff --git a/MediaBrowser.Model/Dto/RatingType.cs b/MediaBrowser.Model/Dto/RatingType.cs
index 033776f9c6..2c2b7b705d 100644
--- a/MediaBrowser.Model/Dto/RatingType.cs
+++ b/MediaBrowser.Model/Dto/RatingType.cs
@@ -1,10 +1,18 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.Dto
{
+ /// <summary>
+ /// The type of a community rating.
+ /// </summary>
public enum RatingType
{
+ /// <summary>
+ /// The rating is a numeric score.
+ /// </summary>
Score,
+
+ /// <summary>
+ /// The rating is based on likes.
+ /// </summary>
Likes
}
}
diff --git a/MediaBrowser.Model/Globalization/ILocalizationManager.cs b/MediaBrowser.Model/Globalization/ILocalizationManager.cs
index 7ad240abfb..0fff70c4e0 100644
--- a/MediaBrowser.Model/Globalization/ILocalizationManager.cs
+++ b/MediaBrowser.Model/Globalization/ILocalizationManager.cs
@@ -73,6 +73,14 @@ public interface ILocalizationManager
CultureDto? FindLanguageInfo(string language);
/// <summary>
+ /// Gets a human-readable display name for the given language code.
+ /// Truncates at the first semicolon or comma to avoid cluttered ISO-639-2 names.
+ /// </summary>
+ /// <param name="language">An ISO language code.</param>
+ /// <returns>The display name, or null if not found.</returns>
+ string? GetLanguageDisplayName(string language);
+
+ /// <summary>
/// Returns the language in ISO 639-2/T when the input is ISO 639-2/B.
/// </summary>
/// <param name="isoB">The language in ISO 639-2/B.</param>
diff --git a/MediaBrowser.Model/Library/PlayAccess.cs b/MediaBrowser.Model/Library/PlayAccess.cs
index a2f263ce54..22daaf7254 100644
--- a/MediaBrowser.Model/Library/PlayAccess.cs
+++ b/MediaBrowser.Model/Library/PlayAccess.cs
@@ -1,10 +1,18 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.Library
{
+ /// <summary>
+ /// The play access of an item.
+ /// </summary>
public enum PlayAccess
{
+ /// <summary>
+ /// The item can be played.
+ /// </summary>
Full = 0,
+
+ /// <summary>
+ /// The item cannot be played.
+ /// </summary>
None = 1
}
}
diff --git a/MediaBrowser.Model/LiveTv/DayPattern.cs b/MediaBrowser.Model/LiveTv/DayPattern.cs
index 17efe39088..dab69e8974 100644
--- a/MediaBrowser.Model/LiveTv/DayPattern.cs
+++ b/MediaBrowser.Model/LiveTv/DayPattern.cs
@@ -1,11 +1,23 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.LiveTv
{
+ /// <summary>
+ /// The day pattern of a recurring timer.
+ /// </summary>
public enum DayPattern
{
+ /// <summary>
+ /// Every day.
+ /// </summary>
Daily,
+
+ /// <summary>
+ /// Monday through Friday.
+ /// </summary>
Weekdays,
+
+ /// <summary>
+ /// Saturday and Sunday.
+ /// </summary>
Weekends
}
}
diff --git a/MediaBrowser.Model/LiveTv/LiveTvServiceStatus.cs b/MediaBrowser.Model/LiveTv/LiveTvServiceStatus.cs
index 72a0e2d7bf..a3df1dc411 100644
--- a/MediaBrowser.Model/LiveTv/LiveTvServiceStatus.cs
+++ b/MediaBrowser.Model/LiveTv/LiveTvServiceStatus.cs
@@ -1,10 +1,18 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.LiveTv
{
+ /// <summary>
+ /// The status of a live TV service.
+ /// </summary>
public enum LiveTvServiceStatus
{
+ /// <summary>
+ /// The service is available.
+ /// </summary>
Ok = 0,
+
+ /// <summary>
+ /// The service is unavailable.
+ /// </summary>
Unavailable = 1
}
}
diff --git a/MediaBrowser.Model/MediaInfo/TransportStreamTimestamp.cs b/MediaBrowser.Model/MediaInfo/TransportStreamTimestamp.cs
index b7ee5747ab..1988dd8078 100644
--- a/MediaBrowser.Model/MediaInfo/TransportStreamTimestamp.cs
+++ b/MediaBrowser.Model/MediaInfo/TransportStreamTimestamp.cs
@@ -1,11 +1,23 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.MediaInfo
{
+ /// <summary>
+ /// The type of timestamps used in a transport stream.
+ /// </summary>
public enum TransportStreamTimestamp
{
+ /// <summary>
+ /// The stream contains no timestamps.
+ /// </summary>
None,
+
+ /// <summary>
+ /// The stream contains zero-value timestamps.
+ /// </summary>
Zero,
+
+ /// <summary>
+ /// The stream contains valid timestamps.
+ /// </summary>
Valid
}
}
diff --git a/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs
index 787d2ad878..2bd2676ceb 100644
--- a/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs
+++ b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs
@@ -42,7 +42,7 @@ public class ComicBookInfoProvider : IComicProvider
if (path is null)
{
- _logger.LogError("could not load comic: {Path}", info.Path);
+ _logger.LogDebug("could not load comic: {Path}", info.Path);
return new MetadataResult<Book> { HasMetadata = false };
}
diff --git a/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs b/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs
index 02cc02b7f3..cfd22a850e 100644
--- a/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs
+++ b/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs
@@ -38,7 +38,7 @@ public class ExternalComicInfoProvider : IComicProvider
if (comicInfoXml is null)
{
- _logger.LogInformation("Could not load ComicInfo metadata for {Path} from XML file.", info.Path);
+ _logger.LogDebug("No external ComicInfo metadata found for {Path}.", info.Path);
return new MetadataResult<Book> { HasMetadata = false };
}
@@ -67,23 +67,22 @@ public class ExternalComicInfoProvider : IComicProvider
private async Task<XDocument?> LoadXml(ItemInfo info, CancellationToken cancellationToken)
{
- var path = GetXmlFilePath(info.Path).FullName;
-
- if (path is null)
+ var file = GetXmlFilePath(info.Path);
+ if (!file.Exists)
{
return null;
}
try
{
- using var reader = XmlReader.Create(path, new XmlReaderSettings { Async = true });
+ using var reader = XmlReader.Create(file.FullName, new XmlReaderSettings { Async = true });
var comicInfoXml = XDocument.LoadAsync(reader, LoadOptions.None, cancellationToken);
return await comicInfoXml.ConfigureAwait(false);
}
catch (Exception e)
{
- _logger.LogInformation(e, "Could not load external XML from {Path}. This could mean there is no separate ComicInfo metadata file for this comic or the metadata is bundled within the comic.", path);
+ _logger.LogWarning(e, "Could not load external ComicInfo XML from {Path}.", file.FullName);
return null;
}
}
diff --git a/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs b/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs
index 98a6aba7d6..19062452b9 100644
--- a/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs
+++ b/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs
@@ -36,7 +36,7 @@ public class InternalComicInfoProvider : IComicProvider
if (comicInfoXml is null)
{
- _logger.LogInformation("Could not load ComicInfo metadata for {Path} from XML file. No internal XML in comic archive.", info.Path);
+ _logger.LogDebug("Could not load ComicInfo metadata for {Path} from XML file. No internal XML in comic archive.", info.Path);
return new MetadataResult<Book> { HasMetadata = false };
}
diff --git a/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs b/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs
index b4e3fd3089..6266413dfc 100644
--- a/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs
+++ b/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs
@@ -2,6 +2,7 @@ using System;
using System.Globalization;
using System.IO;
using System.Linq;
+using System.Text.RegularExpressions;
using System.Threading;
using System.Xml;
using Jellyfin.Data.Enums;
@@ -17,7 +18,7 @@ namespace MediaBrowser.Providers.Books.OpenPackagingFormat
/// Methods used to pull metadata and other information from Open Packaging Format in XML objects.
/// </summary>
/// <typeparam name="TCategoryName">The type of category.</typeparam>
- public class OpfReader<TCategoryName>
+ public partial class OpfReader<TCategoryName>
{
private const string DcNamespace = @"http://purl.org/dc/elements/1.1/";
private const string OpfNamespace = @"http://www.idpf.org/2007/opf";
@@ -42,6 +43,9 @@ namespace MediaBrowser.Providers.Books.OpenPackagingFormat
_namespaceManager.AddNamespace("opf", OpfNamespace);
}
+ [GeneratedRegex(@"(?<=\p{L})\.(?!\s|$)")]
+ private static partial Regex InitialsRegex();
+
/// <summary>
/// Checks for the existence of a cover image.
/// </summary>
@@ -229,11 +233,23 @@ namespace MediaBrowser.Providers.Books.OpenPackagingFormat
{
foreach (XmlElement creator in resultElement)
{
- var creatorName = creator.InnerText;
var role = creator.GetAttribute("opf:role");
- var person = new PersonInfo { Name = creatorName, Type = GetRole(role) };
-
- book.AddPerson(person);
+ var normalizedCreators = creator.InnerText
+ .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Select(fullName =>
+ {
+ if (fullName.Split(',', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) is [var lastName, var firstName])
+ {
+ fullName = $"{firstName} {lastName}";
+ }
+
+ return InitialsRegex().Replace(fullName, ". ");
+ });
+
+ foreach (var fullName in normalizedCreators)
+ {
+ book.AddPerson(new PersonInfo { Name = fullName, Type = GetRole(role) });
+ }
}
}
}
diff --git a/MediaBrowser.Providers/Lyric/LyricManager.cs b/MediaBrowser.Providers/Lyric/LyricManager.cs
index 913a104a0d..af31e373ef 100644
--- a/MediaBrowser.Providers/Lyric/LyricManager.cs
+++ b/MediaBrowser.Providers/Lyric/LyricManager.cs
@@ -398,7 +398,7 @@ public class LyricManager : ILyricManager
{
var mediaFolderPath = Path.GetFullPath(Path.Combine(audio.ContainingFolderPath, saveFileName));
// TODO: Add some error handling to the API user: return BadRequest("Could not save lyric, bad path.");
- if (mediaFolderPath.StartsWith(audio.ContainingFolderPath, StringComparison.Ordinal))
+ if (PathHelper.IsContainedIn(audio.ContainingFolderPath, mediaFolderPath))
{
savePaths.Add(mediaFolderPath);
}
@@ -407,7 +407,7 @@ public class LyricManager : ILyricManager
var internalPath = Path.GetFullPath(Path.Combine(audio.GetInternalMetadataPath(), saveFileName));
// TODO: Add some error to the user: return BadRequest("Could not save lyric, bad path.");
- if (internalPath.StartsWith(audio.GetInternalMetadataPath(), StringComparison.Ordinal))
+ if (PathHelper.IsContainedIn(audio.GetInternalMetadataPath(), internalPath))
{
savePaths.Add(internalPath);
}
diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs
index 60eda1628c..a438a94c40 100644
--- a/MediaBrowser.Providers/Manager/MetadataService.cs
+++ b/MediaBrowser.Providers/Manager/MetadataService.cs
@@ -1123,7 +1123,7 @@ namespace MediaBrowser.Providers.Manager
{
if (replaceData || !target.RunTimeTicks.HasValue)
{
- if (target is not Audio && target is not Video)
+ if (target is not Audio && target is not Video && target is not Book)
{
target.RunTimeTicks = source.RunTimeTicks;
}
diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj
index df51dd8421..2b0f480b1c 100644
--- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj
+++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj
@@ -21,6 +21,7 @@
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Newtonsoft.Json" />
+ <PackageReference Include="PDFtoImage" />
<PackageReference Include="PlaylistsNET" />
<PackageReference Include="SharpCompress" />
<PackageReference Include="z440.atl.core" />
diff --git a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs
index 789df8f061..221c6bff5e 100644
--- a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs
+++ b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs
@@ -24,6 +24,8 @@ using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
+using PDFtoImage;
+using SharpCompress.Archives;
namespace MediaBrowser.Providers.MediaInfo
{
@@ -37,6 +39,7 @@ namespace MediaBrowser.Providers.MediaInfo
ICustomMetadataProvider<Video>,
ICustomMetadataProvider<Audio>,
ICustomMetadataProvider<AudioBook>,
+ ICustomMetadataProvider<Book>,
IHasOrder,
IForcedProvider,
IPreRefreshProvider,
@@ -214,6 +217,57 @@ namespace MediaBrowser.Providers.MediaInfo
return FetchAudioInfo(item, options, cancellationToken);
}
+ /// <inheritdoc />
+ public Task<ItemUpdateType> FetchAsync(Book item, MetadataRefreshOptions options, CancellationToken cancellationToken)
+ {
+ if (item.IsVirtualItem || !item.IsFileProtocol)
+ {
+ return _cachedTask;
+ }
+
+ long pageCount;
+ switch (Path.GetExtension(item.Path).ToLowerInvariant())
+ {
+ case ".cb7":
+ case ".cbr":
+ case ".cbt":
+ case ".cbz":
+ using (var stream = File.OpenRead(item.Path))
+ using (var archive = ArchiveFactory.OpenArchive(stream))
+ {
+ pageCount = archive.Entries.Count(e => !e.IsDirectory);
+ }
+
+ break;
+
+#pragma warning disable CA1416
+ case ".pdf":
+ using (var stream = File.OpenRead(item.Path))
+ {
+ pageCount = Conversion.GetPageCount(stream);
+ }
+
+ break;
+#pragma warning restore CA1416
+
+ case ".epub":
+ // TODO process CFI and store as a string when multiple progress types are supported
+ // current progress value is percentage stored as a proportion of one second worth of ticks
+ item.RunTimeTicks = TimeSpan.TicksPerSecond;
+
+ return Task.FromResult(ItemUpdateType.MetadataImport);
+
+ default:
+ return _cachedTask;
+ }
+
+ // TODO use page count without modification when multiple progress types are supported
+ // book players report page count and the web client multiplies that value by 10000 to convert the expected milliseconds into ticks
+ item.RunTimeTicks = pageCount * 10000;
+
+ return Task.FromResult(ItemUpdateType.MetadataImport);
+ }
+
/// <summary>
/// Fetches video information for an item.
/// </summary>
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs
index b44e54f888..9bb15ca479 100755
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs
@@ -329,13 +329,26 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
if (seriesResult.Videos?.Results is not null)
{
- foreach (var video in seriesResult.Videos.Results)
+ var trailers = new List<MediaUrl>();
+
+ var sortedVideos = seriesResult.Videos.Results
+ .OrderByDescending(video => string.Equals(video.Type, "trailer", StringComparison.OrdinalIgnoreCase));
+
+ foreach (var video in sortedVideos)
{
- if (TmdbUtils.IsTrailerType(video))
+ if (!TmdbUtils.IsTrailerType(video))
{
- series.AddTrailerUrl("https://www.youtube.com/watch?v=" + video.Key);
+ continue;
}
+
+ trailers.Add(new MediaUrl
+ {
+ Url = string.Format(CultureInfo.InvariantCulture, "https://www.youtube.com/watch?v={0}", video.Key),
+ Name = video.Name
+ });
}
+
+ series.RemoteTrailers = trailers;
}
if (!string.IsNullOrEmpty(seriesResult.OriginalLanguage))
diff --git a/README.md b/README.md
index 5e066f3d31..f3a5a2a4ef 100644
--- a/README.md
+++ b/README.md
@@ -7,34 +7,16 @@
<img alt="Logo Banner" src="https://raw.githubusercontent.com/jellyfin/jellyfin-ux/master/branding/SVG/banner-logo-solid.svg?sanitize=true"/>
<br/>
<br/>
-<a href="https://github.com/jellyfin/jellyfin">
-<img alt="GPL 2.0 License" src="https://img.shields.io/github/license/jellyfin/jellyfin.svg"/>
-</a>
-<a href="https://github.com/jellyfin/jellyfin/releases">
-<img alt="Current Release" src="https://img.shields.io/github/release/jellyfin/jellyfin.svg"/>
-</a>
-<a href="https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/?utm_source=widget">
-<img alt="Translation Status" src="https://translate.jellyfin.org/widgets/jellyfin/-/jellyfin-core/svg-badge.svg"/>
-</a>
-<a href="https://hub.docker.com/r/jellyfin/jellyfin">
-<img alt="Docker Pull Count" src="https://img.shields.io/docker/pulls/jellyfin/jellyfin.svg"/>
-</a>
+<a href="https://github.com/jellyfin/jellyfin"><img alt="GPL 2.0 License" src="https://img.shields.io/github/license/jellyfin/jellyfin.svg"/></a>
+<a href="https://github.com/jellyfin/jellyfin/releases"><img alt="Current Release" src="https://img.shields.io/github/release/jellyfin/jellyfin.svg"/></a>
+<a href="https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/?utm_source=widget"><img alt="Translation Status" src="https://translate.jellyfin.org/widgets/jellyfin/-/jellyfin-core/svg-badge.svg"/></a>
+<a href="https://hub.docker.com/r/jellyfin/jellyfin"><img alt="Docker Pull Count" src="https://img.shields.io/docker/pulls/jellyfin/jellyfin.svg"/></a>
<br/>
-<a href="https://opencollective.com/jellyfin">
-<img alt="Donate" src="https://img.shields.io/opencollective/all/jellyfin.svg?label=backers"/>
-</a>
-<a href="https://features.jellyfin.org">
-<img alt="Submit Feature Requests" src="https://img.shields.io/badge/fider-vote%20on%20features-success.svg"/>
-</a>
-<a href="https://matrix.to/#/#jellyfinorg:matrix.org">
-<img alt="Chat on Matrix" src="https://img.shields.io/matrix/jellyfinorg:matrix.org.svg?logo=matrix"/>
-</a>
-<a href="https://github.com/jellyfin/jellyfin/releases.atom">
-<img alt="Release RSS Feed" src="https://img.shields.io/badge/rss-releases-ffa500?logo=rss" />
-</a>
-<a href="https://github.com/jellyfin/jellyfin/commits/master.atom">
-<img alt="Master Commits RSS Feed" src="https://img.shields.io/badge/rss-commits-ffa500?logo=rss" />
-</a>
+<a href="https://opencollective.com/jellyfin"><img alt="Donate" src="https://img.shields.io/opencollective/all/jellyfin.svg?label=backers"/></a>
+<a href="https://features.jellyfin.org"><img alt="Submit Feature Requests" src="https://img.shields.io/badge/fider-vote%20on%20features-success.svg"/></a>
+<a href="https://matrix.to/#/#jellyfinorg:matrix.org"><img alt="Chat on Matrix" src="https://img.shields.io/matrix/jellyfinorg:matrix.org.svg?logo=matrix"/></a>
+<a href="https://github.com/jellyfin/jellyfin/releases.atom"><img alt="Release RSS Feed" src="https://img.shields.io/badge/rss-releases-ffa500?logo=rss" /></a>
+<a href="https://github.com/jellyfin/jellyfin/commits/master.atom"><img alt="Master Commits RSS Feed" src="https://img.shields.io/badge/rss-commits-ffa500?logo=rss" /></a>
</p>
---
diff --git a/src/Jellyfin.Extensions/PathHelper.cs b/src/Jellyfin.Extensions/PathHelper.cs
new file mode 100644
index 0000000000..ab74a7749d
--- /dev/null
+++ b/src/Jellyfin.Extensions/PathHelper.cs
@@ -0,0 +1,77 @@
+using System;
+using System.IO;
+
+namespace Jellyfin.Extensions;
+
+/// <summary>
+/// Helpers for safely composing filesystem paths from untrusted input.
+/// </summary>
+/// <remarks>
+/// <see cref="Path.Combine(string, string)"/> has two issues that matter in
+/// any code that joins a trusted directory with an externally-supplied name:
+/// it neither normalises <c>..</c> nor rejects a rooted second argument
+/// (a rooted second arg silently discards the first). Use the helpers below
+/// any time the name comes from media metadata, request input, archive
+/// entries, or any other channel that can be influenced by a third party.
+/// </remarks>
+public static class PathHelper
+{
+ /// <summary>
+ /// Reduces a possibly-untrusted file name to a safe leaf-only name with no
+ /// directory components.
+ /// </summary>
+ /// <param name="fileName">The candidate file name.</param>
+ /// <returns>
+ /// The leaf component of <paramref name="fileName"/>, or <c>null</c> if
+ /// the input has no usable leaf (empty, <c>.</c>, or <c>..</c>).
+ /// </returns>
+ public static string? GetSafeLeafFileName(string? fileName)
+ {
+ if (string.IsNullOrEmpty(fileName))
+ {
+ return null;
+ }
+
+ var leaf = Path.GetFileName(fileName);
+ if (string.IsNullOrEmpty(leaf) || string.Equals(leaf, ".", StringComparison.Ordinal) || string.Equals(leaf, "..", StringComparison.Ordinal))
+ {
+ return null;
+ }
+
+ return leaf;
+ }
+
+ /// <summary>
+ /// Returns whether <paramref name="candidate"/> resolves to a path that
+ /// equals or is contained inside <paramref name="root"/>.
+ /// </summary>
+ /// <param name="root">The directory the candidate must remain inside.</param>
+ /// <param name="candidate">The candidate absolute or relative path.</param>
+ /// <returns><c>true</c> if the candidate is inside or equal to root; otherwise <c>false</c>.</returns>
+ /// <remarks>
+ /// Both arguments are resolved via <see cref="Path.GetFullPath(string)"/>
+ /// so <c>..</c> segments are collapsed before the comparison. The root is
+ /// compared with a trailing directory separator to prevent prefix
+ /// collisions (e.g. <c>/var/data</c> must not be accepted as a parent of
+ /// <c>/var/dataset</c>).
+ /// </remarks>
+ public static bool IsContainedIn(string root, string candidate)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(root);
+ ArgumentException.ThrowIfNullOrEmpty(candidate);
+
+ var fullRoot = Path.GetFullPath(root);
+ var fullCandidate = Path.GetFullPath(candidate);
+
+ if (string.Equals(fullCandidate, fullRoot, StringComparison.Ordinal))
+ {
+ return true;
+ }
+
+ var rootWithSep = fullRoot.EndsWith(Path.DirectorySeparatorChar)
+ ? fullRoot
+ : fullRoot + Path.DirectorySeparatorChar;
+
+ return fullCandidate.StartsWith(rootWithSep, StringComparison.Ordinal);
+ }
+}
diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs
index b8545cbb64..4e1b62cdf9 100644
--- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs
+++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs
@@ -7,6 +7,7 @@ using Jellyfin.Data.Enums;
using Jellyfin.Extensions;
using Jellyfin.LiveTv;
using Jellyfin.LiveTv.Configuration;
+using Jellyfin.LiveTv.Listings;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
@@ -486,8 +487,13 @@ public class GuideManager : IGuideManager
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
-
- item.TrySetProviderId(EtagKey, info.Etag);
+ }
+ else if (XmlTvProgramEtag.MatchesStored(info.Etag, item.GetProviderId(EtagKey)))
+ {
+ // XMLTV ETags are generated from the final ProgramInfo fields Jellyfin consumes,
+ // so an exact match means nothing relevant changed. Other providers stay on the
+ // field-by-field update path.
+ return (item, false, false);
}
if (!string.Equals(info.ShowId, item.ShowId, StringComparison.OrdinalIgnoreCase))
@@ -613,13 +619,9 @@ public class GuideManager : IGuideManager
forceUpdate |= UpdateImages(item, info);
- if (isNew)
- {
- item.OnMetadataChanged();
-
- return (item, true, false);
- }
-
+ // Restore the etag wiped by `item.ProviderIds = info.ProviderIds` above and
+ // persist it on new items so they join the fast path on the next refresh
+ // instead of taking an extra full processing cycle.
var isUpdated = forceUpdate;
var etag = info.Etag;
if (string.IsNullOrWhiteSpace(etag))
@@ -632,6 +634,13 @@ public class GuideManager : IGuideManager
isUpdated = true;
}
+ if (isNew)
+ {
+ item.OnMetadataChanged();
+
+ return (item, true, false);
+ }
+
if (isUpdated)
{
item.OnMetadataChanged();
diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs
index d456bea469..c93d1f039c 100644
--- a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs
+++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs
@@ -491,6 +491,12 @@ namespace Jellyfin.LiveTv.Listings
var results = new List<ShowImagesDto>();
for (int i = 0; i < programIds.Count; i += BatchSize)
{
+ // The daily image limit may be surfaced mid-batch.
+ if (IsImageDailyLimitActive())
+ {
+ break;
+ }
+
var batch = programIds.Skip(i).Take(BatchSize);
using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs/");
@@ -511,6 +517,18 @@ namespace Jellyfin.LiveTv.Listings
entry.ProgramId,
entry.Code,
entry.Message);
+
+ // The image download limit can be reported per-entry inside an
+ // otherwise successful (HTTP 200) response when the limit is hit
+ // mid-batch. Back off so we stop requesting images until SD resets.
+ if (entry.Code is (int)SdErrorCode.MaxImageDownloads or (int)SdErrorCode.MaxImageDownloadsTrial)
+ {
+ _logger.LogError(
+ "Schedules Direct image download limit hit (code {Code}). Disabling image acquisition until SD reset.",
+ entry.Code);
+ SetImageLimitHit();
+ }
+
continue;
}
diff --git a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs
index 7088d1f0bf..0aeb7ad05d 100644
--- a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs
+++ b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs
@@ -173,7 +173,29 @@ namespace Jellyfin.LiveTv.Listings
var reader = new XmlTvReader(path, GetLanguage(info));
return reader.GetProgrammes(channelId, startDateUtc, endDateUtc, cancellationToken)
- .Select(p => GetProgramInfo(p, info));
+ .Select(p => GetProgramInfoWithEtag(p, info));
+ }
+
+ private ProgramInfo GetProgramInfoWithEtag(XmlTvProgram program, ListingsProviderInfo info)
+ {
+ var programInfo = GetProgramInfo(program, info);
+
+ if (XmlTvProgramEtag.TryCreate(programInfo, out var etag, out var reason))
+ {
+ programInfo.Etag = etag;
+ }
+ else
+ {
+ _logger.LogDebug(
+ "Unable to create XMLTV program ETag for program {ProgramId} on channel {ChannelId} from {StartDate} to {EndDate}: {Reason}. The program will be treated as updated on each guide refresh.",
+ programInfo.Id,
+ programInfo.ChannelId,
+ programInfo.StartDate,
+ programInfo.EndDate,
+ reason);
+ }
+
+ return programInfo;
}
private static ProgramInfo GetProgramInfo(XmlTvProgram program, ListingsProviderInfo info)
diff --git a/src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs b/src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs
new file mode 100644
index 0000000000..b5ddb1530f
--- /dev/null
+++ b/src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs
@@ -0,0 +1,184 @@
+#pragma warning disable CS1591
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+using MediaBrowser.Controller.LiveTv;
+
+namespace Jellyfin.LiveTv.Listings
+{
+ internal static class XmlTvProgramEtag
+ {
+ internal const string Prefix = "xmltv-sha256-v1:";
+
+ internal static bool IsXmlTvEtag(string? etag)
+ => !string.IsNullOrWhiteSpace(etag)
+ && etag.StartsWith(Prefix, StringComparison.Ordinal);
+
+ // Returns true only when the incoming etag is XMLTV-style AND equals the stored value.
+ // The IsXmlTvEtag gate keeps other providers (e.g. Schedules Direct) on the
+ // field-by-field update path even if their etag strings happen to match.
+ internal static bool MatchesStored(string? incomingEtag, string? storedEtag)
+ => IsXmlTvEtag(incomingEtag)
+ && string.Equals(incomingEtag, storedEtag, StringComparison.OrdinalIgnoreCase);
+
+ internal static bool TryCreate(ProgramInfo programInfo, out string? etag, out string? reason)
+ {
+ etag = null;
+
+ if (string.IsNullOrWhiteSpace(programInfo.Id))
+ {
+ reason = "program id is empty";
+ return false;
+ }
+
+ if (string.IsNullOrWhiteSpace(programInfo.ChannelId))
+ {
+ reason = "channel id is empty";
+ return false;
+ }
+
+ if (programInfo.StartDate == default)
+ {
+ reason = "start date is empty";
+ return false;
+ }
+
+ if (programInfo.EndDate == default)
+ {
+ reason = "end date is empty";
+ return false;
+ }
+
+ if (programInfo.EndDate <= programInfo.StartDate)
+ {
+ reason = "end date is not after start date";
+ return false;
+ }
+
+ var builder = new StringBuilder(1024);
+
+ // Keep this list aligned with the ProgramInfo fields consumed by GuideManager.
+ AppendValue(builder, "schema", "xmltv-programinfo-v1");
+ AppendValue(builder, nameof(programInfo.Id), programInfo.Id);
+ AppendValue(builder, nameof(programInfo.ChannelId), programInfo.ChannelId);
+ AppendValue(builder, nameof(programInfo.Name), programInfo.Name);
+ AppendValue(builder, nameof(programInfo.OfficialRating), programInfo.OfficialRating);
+ AppendValue(builder, nameof(programInfo.Overview), programInfo.Overview);
+ AppendValue(builder, nameof(programInfo.StartDate), programInfo.StartDate);
+ AppendValue(builder, nameof(programInfo.EndDate), programInfo.EndDate);
+ AppendList(builder, nameof(programInfo.Genres), programInfo.Genres);
+ AppendValue(builder, nameof(programInfo.OriginalAirDate), programInfo.OriginalAirDate);
+ AppendValue(builder, nameof(programInfo.IsHD), programInfo.IsHD);
+ AppendValue(builder, nameof(programInfo.Audio), programInfo.Audio?.ToString());
+ AppendValue(builder, nameof(programInfo.CommunityRating), programInfo.CommunityRating);
+ AppendValue(builder, nameof(programInfo.IsRepeat), programInfo.IsRepeat);
+ AppendValue(builder, nameof(programInfo.EpisodeTitle), programInfo.EpisodeTitle);
+ AppendValue(builder, nameof(programInfo.ImagePath), programInfo.ImagePath);
+ AppendValue(builder, nameof(programInfo.ImageUrl), programInfo.ImageUrl);
+ AppendValue(builder, nameof(programInfo.ThumbImageUrl), programInfo.ThumbImageUrl);
+ AppendValue(builder, nameof(programInfo.LogoImageUrl), programInfo.LogoImageUrl);
+ AppendValue(builder, nameof(programInfo.BackdropImageUrl), programInfo.BackdropImageUrl);
+ AppendValue(builder, nameof(programInfo.IsMovie), programInfo.IsMovie);
+ AppendValue(builder, nameof(programInfo.IsSports), programInfo.IsSports);
+ AppendValue(builder, nameof(programInfo.IsSeries), programInfo.IsSeries);
+ AppendValue(builder, nameof(programInfo.IsLive), programInfo.IsLive);
+ AppendValue(builder, nameof(programInfo.IsNews), programInfo.IsNews);
+ AppendValue(builder, nameof(programInfo.IsKids), programInfo.IsKids);
+ AppendValue(builder, nameof(programInfo.IsPremiere), programInfo.IsPremiere);
+ AppendValue(builder, nameof(programInfo.ProductionYear), programInfo.ProductionYear);
+ AppendValue(builder, nameof(programInfo.SeriesId), programInfo.SeriesId);
+ AppendValue(builder, nameof(programInfo.ShowId), programInfo.ShowId);
+ AppendValue(builder, nameof(programInfo.SeasonNumber), programInfo.SeasonNumber);
+ AppendValue(builder, nameof(programInfo.EpisodeNumber), programInfo.EpisodeNumber);
+ AppendDictionary(builder, nameof(programInfo.ProviderIds), programInfo.ProviderIds);
+ AppendDictionary(builder, nameof(programInfo.SeriesProviderIds), programInfo.SeriesProviderIds);
+
+ var hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
+ etag = Prefix + Convert.ToHexString(hash);
+ reason = null;
+ return true;
+ }
+
+ private static void AppendValue(StringBuilder builder, string name, string? value)
+ {
+ builder.Append(name).Append('|');
+ if (value is null)
+ {
+ builder.Append('N').Append("|0|");
+ }
+ else
+ {
+ builder.Append('S')
+ .Append('|')
+ .Append(value.Length.ToString(CultureInfo.InvariantCulture))
+ .Append('|')
+ .Append(value);
+ }
+
+ builder.Append('\n');
+ }
+
+ private static void AppendValue(StringBuilder builder, string name, DateTime value)
+ => AppendValue(builder, name, FormatDateTime(value));
+
+ private static void AppendValue(StringBuilder builder, string name, DateTime? value)
+ => AppendValue(builder, name, value.HasValue ? FormatDateTime(value.Value) : null);
+
+ private static void AppendValue(StringBuilder builder, string name, bool value)
+ => AppendValue(builder, name, value ? "true" : "false");
+
+ private static void AppendValue(StringBuilder builder, string name, bool? value)
+ => AppendValue(builder, name, value switch { true => "true", false => "false", null => null });
+
+ private static void AppendValue(StringBuilder builder, string name, int? value)
+ => AppendValue(builder, name, value?.ToString(CultureInfo.InvariantCulture));
+
+ private static void AppendValue(StringBuilder builder, string name, float? value)
+ => AppendValue(builder, name, value?.ToString("R", CultureInfo.InvariantCulture));
+
+ // Treat Unspecified as UTC so the etag does not vary with the server's local timezone.
+ private static string FormatDateTime(DateTime value)
+ {
+ var utc = value.Kind switch
+ {
+ DateTimeKind.Utc => value,
+ DateTimeKind.Unspecified => DateTime.SpecifyKind(value, DateTimeKind.Utc),
+ _ => value.ToUniversalTime(),
+ };
+
+ return utc.ToString("O", CultureInfo.InvariantCulture);
+ }
+
+ private static void AppendList(StringBuilder builder, string name, IReadOnlyList<string> values)
+ {
+ AppendValue(builder, name + ".Count", values.Count.ToString(CultureInfo.InvariantCulture));
+ for (var i = 0; i < values.Count; i++)
+ {
+ AppendValue(builder, $"{name}[{i}]", values[i]);
+ }
+ }
+
+ private static void AppendDictionary(StringBuilder builder, string name, IReadOnlyDictionary<string, string?> values)
+ {
+ AppendValue(builder, name + ".Count", values.Count.ToString(CultureInfo.InvariantCulture));
+ if (values.Count == 0)
+ {
+ return;
+ }
+
+ var index = 0;
+ foreach (var (key, value) in values
+ .OrderBy(i => i.Key, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(i => i.Key, StringComparer.Ordinal))
+ {
+ AppendValue(builder, $"{name}[{index}].Key", key);
+ AppendValue(builder, $"{name}[{index}].Value", value);
+ index++;
+ }
+ }
+ }
+}
diff --git a/tests/Jellyfin.Api.Tests/Controllers/ItemUpdateControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/ItemUpdateControllerTests.cs
new file mode 100644
index 0000000000..1a91efe4f2
--- /dev/null
+++ b/tests/Jellyfin.Api.Tests/Controllers/ItemUpdateControllerTests.cs
@@ -0,0 +1,77 @@
+using System;
+using System.Threading.Tasks;
+using Jellyfin.Api.Controllers;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.IO;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Api.Tests.Controllers;
+
+public class ItemUpdateControllerTests
+{
+ private readonly ItemUpdateController _subject;
+
+ public ItemUpdateControllerTests()
+ {
+ _subject = new ItemUpdateController(
+ Mock.Of<IFileSystem>(),
+ Mock.Of<ILibraryManager>(),
+ Mock.Of<IProviderManager>(),
+ Mock.Of<ILocalizationManager>(),
+ Mock.Of<IServerConfigurationManager>());
+ }
+
+ [Fact]
+ public async Task UpdateItem_WhenOnlyTagsFieldSupplied_DoesNotThrowAndAppliesTags()
+ {
+ // Regression test for https://github.com/jellyfin/jellyfin/issues/17366
+ // A partial update payload that only sets "Tags" leaves every other
+ // BaseItemDto collection property null (they have no default
+ // initializer). Genres and ProviderIds used to be fed straight into
+ // Distinct()/ToList() without a null check, so this call used to throw
+ // ArgumentNullException before the fix below was applied.
+ var movie = new Movie();
+ var request = new BaseItemDto
+ {
+ Tags = new[] { "new-tag-1", "new-tag-2" }
+ };
+
+ await InvokeUpdateItem(request, movie);
+
+ Assert.Equal(new[] { "new-tag-1", "new-tag-2" }, movie.Tags);
+ Assert.Empty(movie.Genres);
+ Assert.Empty(movie.ProviderIds);
+ }
+
+ [Fact]
+ public async Task UpdateItem_WhenGenresAndProviderIdsOmitted_LeavesExistingValuesUnchanged()
+ {
+ var movie = new Movie
+ {
+ Genres = new[] { "Action" }
+ };
+ movie.ProviderIds["Imdb"] = "tt1234567";
+
+ var request = new BaseItemDto
+ {
+ Tags = Array.Empty<string>()
+ };
+
+ await InvokeUpdateItem(request, movie);
+
+ Assert.Equal(new[] { "Action" }, movie.Genres);
+ Assert.Equal("tt1234567", movie.ProviderIds["Imdb"]);
+ }
+
+ private Task InvokeUpdateItem(BaseItemDto request, BaseItem item)
+ {
+ return _subject.UpdateItem(request, item);
+ }
+}
diff --git a/tests/Jellyfin.Api.Tests/Controllers/StartupControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/StartupControllerTests.cs
new file mode 100644
index 0000000000..bad11a9257
--- /dev/null
+++ b/tests/Jellyfin.Api.Tests/Controllers/StartupControllerTests.cs
@@ -0,0 +1,70 @@
+using System.Threading.Tasks;
+using Jellyfin.Api.Controllers;
+using Jellyfin.Api.Models.StartupDtos;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Users;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Library;
+using Microsoft.AspNetCore.Mvc;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Api.Tests.Controllers;
+
+public class StartupControllerTests
+{
+ private readonly StartupController _subject;
+ private readonly Mock<IUserManager> _mockUserManager;
+ private readonly Mock<IServerConfigurationManager> _mockConfig;
+
+ public StartupControllerTests()
+ {
+ _mockUserManager = new Mock<IUserManager>();
+ _mockConfig = new Mock<IServerConfigurationManager>();
+ _subject = new StartupController(_mockConfig.Object, _mockUserManager.Object);
+ }
+
+ private static User CreateUser()
+ => new User(
+ "jellyfin",
+ typeof(DefaultAuthenticationProvider).FullName!,
+ typeof(DefaultPasswordResetProvider).FullName!);
+
+ [Fact]
+ public async Task UpdateStartupUser_WhenNoUserExists_ReturnsNotFound()
+ {
+ _mockUserManager.Setup(m => m.GetFirstUser()).Returns((User?)null);
+
+ var result = await _subject.UpdateStartupUser(new StartupUserDto { Name = "admin", Password = "pw" });
+
+ Assert.IsType<NotFoundResult>(result);
+ }
+
+ [Fact]
+ public async Task UpdateStartupUser_WhenPasswordAlreadyConfigured_ReturnsForbidden()
+ {
+ var user = CreateUser();
+ user.Password = "already-set-hash";
+ _mockUserManager.Setup(m => m.GetFirstUser()).Returns(user);
+
+ var result = await _subject.UpdateStartupUser(new StartupUserDto { Name = "attacker", Password = "new-pw" });
+
+ // The startup wizard must never overwrite the password of an already-provisioned
+ // account, even if IsStartupWizardCompleted has been cleared.
+ Assert.IsType<ForbidResult>(result);
+ _mockUserManager.Verify(m => m.ChangePassword(It.IsAny<System.Guid>(), It.IsAny<string>()), Times.Never);
+ }
+
+ [Fact]
+ public async Task UpdateStartupUser_WhenNoPasswordYet_SetsPassword()
+ {
+ var user = CreateUser();
+ Assert.True(string.IsNullOrEmpty(user.Password));
+ _mockUserManager.Setup(m => m.GetFirstUser()).Returns(user);
+
+ var result = await _subject.UpdateStartupUser(new StartupUserDto { Name = "jellyfin", Password = "first-pw" });
+
+ Assert.IsType<NoContentResult>(result);
+ _mockUserManager.Verify(m => m.ChangePassword(user.Id, "first-pw"), Times.Once);
+ }
+}
diff --git a/tests/Jellyfin.Controller.Tests/ClientEventLoggerTests.cs b/tests/Jellyfin.Controller.Tests/ClientEventLoggerTests.cs
new file mode 100644
index 0000000000..5132e529dd
--- /dev/null
+++ b/tests/Jellyfin.Controller.Tests/ClientEventLoggerTests.cs
@@ -0,0 +1,44 @@
+using System;
+using System.IO;
+using System.Text;
+using System.Threading.Tasks;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.ClientEvent;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Controller.Tests
+{
+ public class ClientEventLoggerTests
+ {
+ [Theory]
+ [InlineData("../../../../etc/passwd", "1.0")]
+ [InlineData("..\\..\\windows\\system32", "1.0")]
+ [InlineData("normal-client", "../../../etc/passwd")]
+ [InlineData("/absolute/path", "1.0")]
+ public async Task WriteDocumentAsync_TraversalInput_StaysInsideLogDirectory(string clientName, string clientVersion)
+ {
+ var logDir = Path.Combine(Path.GetTempPath(), "jellyfin-clientlog-test-" + Path.GetRandomFileName());
+ Directory.CreateDirectory(logDir);
+ try
+ {
+ var paths = new Mock<IServerApplicationPaths>();
+ paths.Setup(p => p.LogDirectoryPath).Returns(logDir);
+
+ var logger = new ClientEventLogger(paths.Object);
+ using var contents = new MemoryStream(Encoding.UTF8.GetBytes("payload"));
+
+ var fileName = await logger.WriteDocumentAsync(clientName, clientVersion, contents);
+
+ var resolved = Path.GetFullPath(Path.Combine(logDir, fileName));
+ var rootWithSep = Path.GetFullPath(logDir) + Path.DirectorySeparatorChar;
+ Assert.StartsWith(rootWithSep, resolved, StringComparison.Ordinal);
+ Assert.True(File.Exists(resolved));
+ }
+ finally
+ {
+ Directory.Delete(logDir, recursive: true);
+ }
+ }
+ }
+}
diff --git a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
index c0a2b0ecca..de109c8d65 100644
--- a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
+++ b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
@@ -4,10 +4,12 @@ using System.Linq;
using System.Reflection;
using System.Threading;
using Jellyfin.Database.Implementations.Entities;
+using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.MediaSegments;
+using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
@@ -29,6 +31,35 @@ public class BaseItemTests
=> Assert.Equal(expected, BaseItem.ModifySortChunks(input));
[Theory]
+ [InlineData("The Matrix", "matrix")]
+ [InlineData("Spider-Man", "spiderman")]
+ [InlineData("A Movie: Part 2", "movie: part 0000000002")]
+ public void GetSortName_AppliesConfiguredCleaning(string input, string expected)
+ => Assert.Equal(expected, BaseItem.GetSortName(input, true, new ServerConfiguration()));
+
+ [Fact]
+ public void GetSortName_WithoutAlphaNumericSorting_ReturnsTrimmedInput()
+ => Assert.Equal("The Matrix", BaseItem.GetSortName(" The Matrix", false, new ServerConfiguration()));
+
+ [Fact]
+ public void SortName_ForcedSortName_IsCleanedLikeAutoSortName()
+ {
+ var configManager = new Mock<IServerConfigurationManager>();
+ configManager.Setup(x => x.Configuration).Returns(new ServerConfiguration());
+ BaseItem.ConfigurationManager = configManager.Object;
+
+ const string Raw = "The Spider-Man: Homecoming";
+
+ var auto = new Video { Name = Raw };
+ var forced = new Video { Name = "zzz unrelated name", ForcedSortName = Raw };
+
+ // A forced sort name must be cleaned the same way as an auto-generated one so both sort together (#17388).
+ Assert.Equal(auto.SortName, forced.SortName);
+ // Sanity: cleaning actually ran (leading article and hyphen removed, colon kept, lowercased).
+ Assert.Equal("spiderman: homecoming", forced.SortName);
+ }
+
+ [Theory]
[InlineData("/Movies/Ted/Ted.mp4", "/Movies/Ted/Ted - Unrated Edition.mp4", "Ted", "Unrated Edition")]
[InlineData("/Movies/Deadpool 2 (2018)/Deadpool 2 (2018).mkv", "/Movies/Deadpool 2 (2018)/Deadpool 2 (2018) - Super Duper Cut.mkv", "Deadpool 2 (2018)", "Super Duper Cut")]
public void GetMediaSourceName_Valid(string primaryPath, string altPath, string name, string altName)
diff --git a/tests/Jellyfin.Extensions.Tests/PathHelperTests.cs b/tests/Jellyfin.Extensions.Tests/PathHelperTests.cs
new file mode 100644
index 0000000000..71fd853ba2
--- /dev/null
+++ b/tests/Jellyfin.Extensions.Tests/PathHelperTests.cs
@@ -0,0 +1,60 @@
+using System.IO;
+using Jellyfin.Extensions;
+using Xunit;
+
+namespace Jellyfin.Extensions.Tests
+{
+ public static class PathHelperTests
+ {
+ [Theory]
+ [InlineData("file.txt", "file.txt")]
+ [InlineData("sub/file.txt", "file.txt")]
+ [InlineData("../../etc/passwd", "passwd")]
+ public static void GetSafeLeafFileName_ReducesToLeaf(string input, string expected)
+ {
+ Assert.Equal(expected, PathHelper.GetSafeLeafFileName(input));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(".")]
+ [InlineData("..")]
+ public static void GetSafeLeafFileName_RejectsUnusableLeaf(string? input)
+ {
+ Assert.Null(PathHelper.GetSafeLeafFileName(input));
+ }
+
+ [Fact]
+ public static void IsContainedIn_ChildPath_ReturnsTrue()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "root");
+ var child = Path.Combine(root, "sub", "file.txt");
+ Assert.True(PathHelper.IsContainedIn(root, child));
+ }
+
+ [Fact]
+ public static void IsContainedIn_RootItself_ReturnsTrue()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "root");
+ Assert.True(PathHelper.IsContainedIn(root, root));
+ }
+
+ [Fact]
+ public static void IsContainedIn_TraversalEscape_ReturnsFalse()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "root");
+ var escape = Path.Combine(root, "..", "..", "etc", "passwd");
+ Assert.False(PathHelper.IsContainedIn(root, escape));
+ }
+
+ [Fact]
+ public static void IsContainedIn_SiblingPrefixCollision_ReturnsFalse()
+ {
+ // "/var/data" must not be accepted as a parent of "/var/dataset".
+ var root = Path.Combine(Path.GetTempPath(), "data");
+ var sibling = Path.Combine(Path.GetTempPath(), "dataset", "file.txt");
+ Assert.False(PathHelper.IsContainedIn(root, sibling));
+ }
+ }
+}
diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs
index b71dc15201..f698edc637 100644
--- a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs
+++ b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs
@@ -7,6 +7,7 @@ using System.Threading.Tasks;
using AutoFixture;
using AutoFixture.AutoMoq;
using Jellyfin.LiveTv.Listings;
+using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Model.LiveTv;
using Moq;
using Moq.Protected;
@@ -66,6 +67,7 @@ public class XmlTvListingsProviderTests
Assert.True(program.HasImage);
Assert.Equal("https://domain.tld/image.png", program.ImageUrl);
Assert.Equal("3297", program.ChannelId);
+ AssertXmlTvEtag(program.Etag);
}
[Theory]
@@ -85,5 +87,60 @@ public class XmlTvListingsProviderTests
var program = programsList[0];
Assert.DoesNotContain(program.Genres, g => string.IsNullOrEmpty(g));
Assert.Equal("3297", program.ChannelId);
+ AssertXmlTvEtag(program.Etag);
+ }
+
+ [Fact]
+ public async Task GetProgramsAsync_Etag_SameContentIsStable()
+ {
+ var first = await GetSingleProgramAsync("Test Data/LiveTv/Listings/XmlTv/etag-base.xml");
+ var second = await GetSingleProgramAsync("Test Data/LiveTv/Listings/XmlTv/etag-base.xml");
+
+ Assert.Equal(first.Etag, second.Etag);
+ }
+
+ [Theory]
+ [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml")]
+ [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml")]
+ [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml")]
+ [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml")]
+ [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml")]
+ public async Task GetProgramsAsync_Etag_ChangesWhenMappedContentChanges(string changedPath)
+ {
+ var original = await GetSingleProgramAsync("Test Data/LiveTv/Listings/XmlTv/etag-base.xml");
+ var changed = await GetSingleProgramAsync(changedPath);
+
+ Assert.NotEqual(original.Etag, changed.Etag);
+ }
+
+ [Theory]
+ [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml")]
+ [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml")]
+ public async Task GetProgramsAsync_Etag_DoesNotChangeWhenMappedContentIsEquivalent(string equivalentPath)
+ {
+ var original = await GetSingleProgramAsync("Test Data/LiveTv/Listings/XmlTv/etag-base.xml");
+ var equivalent = await GetSingleProgramAsync(equivalentPath);
+
+ Assert.Equal(original.Etag, equivalent.Etag);
+ }
+
+ private async Task<ProgramInfo> GetSingleProgramAsync(string path)
+ {
+ var info = new ListingsProviderInfo()
+ {
+ Id = Path.GetFileNameWithoutExtension(path),
+ Path = path
+ };
+
+ var startDate = new DateTime(2022, 11, 4, 0, 0, 0, DateTimeKind.Utc);
+ var programs = await _xmlTvListingsProvider.GetProgramsAsync(info, "3297", startDate, startDate.AddDays(1), CancellationToken.None);
+
+ return Assert.Single(programs.ToList());
+ }
+
+ private static void AssertXmlTvEtag(string? etag)
+ {
+ Assert.NotNull(etag);
+ Assert.StartsWith("xmltv-sha256-v1:", etag!, StringComparison.Ordinal);
}
}
diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvProgramEtagTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvProgramEtagTests.cs
new file mode 100644
index 0000000000..b8d1c60e1a
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvProgramEtagTests.cs
@@ -0,0 +1,59 @@
+using System;
+using Jellyfin.LiveTv.Listings;
+using MediaBrowser.Controller.LiveTv;
+using Xunit;
+
+namespace Jellyfin.LiveTv.Tests.Listings;
+
+public class XmlTvProgramEtagTests
+{
+ [Fact]
+ public void TryCreate_GenreOrderIsSignificant()
+ {
+ // GuideManager assigns item.Genres = info.Genres.ToArray() preserving order,
+ // so the same genres in a different order is a real mapped-content change.
+ var first = NewProgram();
+ first.Genres = new() { "Drama", "Action" };
+
+ var second = NewProgram();
+ second.Genres = new() { "Action", "Drama" };
+
+ Assert.True(XmlTvProgramEtag.TryCreate(first, out var firstEtag, out _));
+ Assert.True(XmlTvProgramEtag.TryCreate(second, out var secondEtag, out _));
+ Assert.NotEqual(firstEtag, secondEtag);
+ }
+
+ [Fact]
+ public void MatchesStored_EqualXmlTvEtags_ReturnsTrue()
+ {
+ const string Etag = XmlTvProgramEtag.Prefix + "ABCDEF0123456789";
+ Assert.True(XmlTvProgramEtag.MatchesStored(Etag, Etag));
+ }
+
+ [Fact]
+ public void MatchesStored_DifferentXmlTvEtags_ReturnsFalse()
+ {
+ Assert.False(XmlTvProgramEtag.MatchesStored(
+ XmlTvProgramEtag.Prefix + "AAAA",
+ XmlTvProgramEtag.Prefix + "BBBB"));
+ }
+
+ [Fact]
+ public void MatchesStored_EqualNonXmlTvEtags_ReturnsFalse()
+ {
+ // Other providers (e.g. Schedules Direct) use their own etag schemes.
+ // The IsXmlTvEtag gate must keep them on the field-by-field update path
+ // even when their incoming and stored values happen to match exactly.
+ const string Etag = "sd-abc123";
+ Assert.False(XmlTvProgramEtag.MatchesStored(Etag, Etag));
+ }
+
+ private static ProgramInfo NewProgram() => new()
+ {
+ Id = "program-id",
+ ChannelId = "channel-id",
+ Name = "Program Name",
+ StartDate = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc),
+ EndDate = new DateTime(2026, 1, 1, 13, 0, 0, DateTimeKind.Utc),
+ };
+}
diff --git a/tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs b/tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs
index 59cd42c05b..1bc42d5fe5 100644
--- a/tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs
+++ b/tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs
@@ -176,6 +176,30 @@ namespace Jellyfin.LiveTv.Tests.SchedulesDirect
}
/// <summary>
+ /// /metadata/programs response where the daily image limit is hit mid-batch,
+ /// so individual entries carry an error code inside an otherwise successful response.
+ /// </summary>
+ [Fact]
+ public void Deserialize_Metadata_Programs_Image_Limit_Response_Success()
+ {
+ var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/metadata_programs_image_limit_response.json");
+ var showImagesDtos = JsonSerializer.Deserialize<IReadOnlyList<ShowImagesDto>>(bytes, _jsonOptions);
+
+ Assert.NotNull(showImagesDtos);
+ Assert.Equal(2, showImagesDtos!.Count);
+
+ // First entry is a normal result with image data and no error code.
+ Assert.Equal("SH00712240", showImagesDtos[0].ProgramId);
+ Assert.Null(showImagesDtos[0].Code);
+ Assert.Single(showImagesDtos[0].Data);
+
+ // Second entry is a per-entry trial image download limit error (SD code 5003).
+ Assert.Equal("SH00712241", showImagesDtos[1].ProgramId);
+ Assert.Equal((int)SdErrorCode.MaxImageDownloadsTrial, showImagesDtos[1].Code);
+ Assert.Empty(showImagesDtos[1].Data);
+ }
+
+ /// <summary>
/// /headends response.
/// </summary>
[Fact]
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-base.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-base.xml
new file mode 100644
index 0000000000..15f85f57e6
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-base.xml
@@ -0,0 +1,17 @@
+<tv date="20221104">
+ <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000">
+ <title lang="en">Base Program</title>
+ <sub-title lang="en">Base Episode</sub-title>
+ <desc lang="en">Base description.</desc>
+ <category lang="en">series</category>
+ <episode-num system="xmltv_ns">0 . 1 . </episode-num>
+ <episode-num system="dd_progid">EP123456789012</episode-num>
+ <rating system="VCHIP">
+ <value>TV-G</value>
+ </rating>
+ <star-rating>
+ <value>3/5</value>
+ </star-rating>
+ <icon src="https://domain.tld/base.png"/>
+ </programme>
+</tv>
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml
new file mode 100644
index 0000000000..2b49c3bccd
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml
@@ -0,0 +1,17 @@
+<tv date="20221104">
+ <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000">
+ <title lang="en">Base Program</title>
+ <sub-title lang="en">Base Episode</sub-title>
+ <desc lang="en">Base description.</desc>
+ <category lang="en">sports</category>
+ <episode-num system="xmltv_ns">0 . 1 . </episode-num>
+ <episode-num system="dd_progid">EP123456789012</episode-num>
+ <rating system="VCHIP">
+ <value>TV-G</value>
+ </rating>
+ <star-rating>
+ <value>3/5</value>
+ </star-rating>
+ <icon src="https://domain.tld/base.png"/>
+ </programme>
+</tv>
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml
new file mode 100644
index 0000000000..090273ac98
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml
@@ -0,0 +1,17 @@
+<tv date="20221104">
+ <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000">
+ <title lang="en">Base Program</title>
+ <sub-title lang="en">Base Episode</sub-title>
+ <desc lang="en">Changed description.</desc>
+ <category lang="en">series</category>
+ <episode-num system="xmltv_ns">0 . 1 . </episode-num>
+ <episode-num system="dd_progid">EP123456789012</episode-num>
+ <rating system="VCHIP">
+ <value>TV-G</value>
+ </rating>
+ <star-rating>
+ <value>3/5</value>
+ </star-rating>
+ <icon src="https://domain.tld/base.png"/>
+ </programme>
+</tv>
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml
new file mode 100644
index 0000000000..532b91da20
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml
@@ -0,0 +1,17 @@
+<tv date="20221104">
+ <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000">
+ <title lang="en">Base Program</title>
+ <sub-title lang="en">Base Episode</sub-title>
+ <desc lang="en">Base description.</desc>
+ <category lang="en">series</category>
+ <episode-num system="xmltv_ns">0 . 1 . </episode-num>
+ <episode-num system="dd_progid">EP123456789012</episode-num>
+ <rating system="VCHIP">
+ <value>TV-G</value>
+ </rating>
+ <star-rating>
+ <value>3/5</value>
+ </star-rating>
+ <icon src="https://domain.tld/changed.png"/>
+ </programme>
+</tv>
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml
new file mode 100644
index 0000000000..db0d5e86de
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml
@@ -0,0 +1,17 @@
+<tv date="20221104">
+ <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000">
+ <title lang="en">Base Program</title>
+ <sub-title lang="en">Base Episode</sub-title>
+ <desc lang="en">Base description.</desc>
+ <category lang="en">series</category>
+ <episode-num system="xmltv_ns">0 . 1 . </episode-num>
+ <episode-num system="dd_progid">EP123456789013</episode-num>
+ <rating system="VCHIP">
+ <value>TV-G</value>
+ </rating>
+ <star-rating>
+ <value>3/5</value>
+ </star-rating>
+ <icon src="https://domain.tld/base.png"/>
+ </programme>
+</tv>
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml
new file mode 100644
index 0000000000..168c0a643b
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml
@@ -0,0 +1,17 @@
+<tv date="20221104">
+ <programme channel="3297" stop="20221104140000 +0000" start="20221104130000 +0000">
+ <icon src="https://domain.tld/base.png"/>
+ <star-rating>
+ <value>3/5</value>
+ </star-rating>
+ <rating system="VCHIP">
+ <value>TV-G</value>
+ </rating>
+ <episode-num system="xmltv_ns">0 . 1 . </episode-num>
+ <episode-num system="dd_progid">EP123456789012</episode-num>
+ <category lang="en">series</category>
+ <desc lang="en">Base description.</desc>
+ <sub-title lang="en">Base Episode</sub-title>
+ <title lang="en">Base Program</title>
+ </programme>
+</tv>
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml
new file mode 100644
index 0000000000..73288e7c57
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml
@@ -0,0 +1,17 @@
+<tv date="20221104">
+ <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000">
+ <title lang="en">Changed Program</title>
+ <sub-title lang="en">Base Episode</sub-title>
+ <desc lang="en">Base description.</desc>
+ <category lang="en">series</category>
+ <episode-num system="xmltv_ns">0 . 1 . </episode-num>
+ <episode-num system="dd_progid">EP123456789012</episode-num>
+ <rating system="VCHIP">
+ <value>TV-G</value>
+ </rating>
+ <star-rating>
+ <value>3/5</value>
+ </star-rating>
+ <icon src="https://domain.tld/base.png"/>
+ </programme>
+</tv>
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml
new file mode 100644
index 0000000000..d0ff1b82f5
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml
@@ -0,0 +1,18 @@
+<tv date="20221104">
+ <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000">
+ <title lang="en">Base Program</title>
+ <sub-title lang="en">Base Episode</sub-title>
+ <desc lang="en">Base description.</desc>
+ <category lang="en">series</category>
+ <episode-num system="xmltv_ns">0 . 1 . </episode-num>
+ <episode-num system="dd_progid">EP123456789012</episode-num>
+ <rating system="VCHIP">
+ <value>TV-G</value>
+ </rating>
+ <star-rating>
+ <value>3/5</value>
+ </star-rating>
+ <previously-unknown-field>Ignored by Jellyfin XMLTV mapping.</previously-unknown-field>
+ <icon src="https://domain.tld/base.png"/>
+ </programme>
+</tv>
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/metadata_programs_image_limit_response.json b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/metadata_programs_image_limit_response.json
new file mode 100644
index 0000000000..34931aa769
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/metadata_programs_image_limit_response.json
@@ -0,0 +1 @@
+[{"programID":"SH00712240","data":[{"width":"135","height":"180","uri":"assets/p282288_b_v2_aa.jpg","size":"Sm","aspect":"3x4","category":"Banner-L3","text":"yes","primary":"true","tier":"Series"}]},{"programID":"SH00712241","code":5003,"message":"Image download limit exceeded. Try again tomorrow."}]
diff --git a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs
index 988073074b..bfe6ade1fe 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs
+++ b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs
@@ -24,6 +24,7 @@ namespace Jellyfin.MediaEncoding.Tests
[InlineData(EncoderValidatorTestsData.FFmpegV44Output, true)]
[InlineData(EncoderValidatorTestsData.FFmpegV432Output, false)]
[InlineData(EncoderValidatorTestsData.FFmpegGitUnknownOutput2, true)]
+ [InlineData(EncoderValidatorTestsData.FFmpegGitWithoutLibpostprocOutput, true)]
[InlineData(EncoderValidatorTestsData.FFmpegGitUnknownOutput, false)]
public void ValidateVersionInternalTest(string versionOutput, bool valid)
{
@@ -41,6 +42,7 @@ namespace Jellyfin.MediaEncoding.Tests
Add(EncoderValidatorTestsData.FFmpegV44Output, new Version(4, 4));
Add(EncoderValidatorTestsData.FFmpegV432Output, new Version(4, 3, 2));
Add(EncoderValidatorTestsData.FFmpegGitUnknownOutput2, new Version(4, 4));
+ Add(EncoderValidatorTestsData.FFmpegGitWithoutLibpostprocOutput, new Version(4, 4));
Add(EncoderValidatorTestsData.FFmpegGitUnknownOutput, null);
}
}
diff --git a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs
index 1f2d618aa4..604b862fbe 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs
+++ b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs
@@ -86,6 +86,15 @@ libswscale 5. 9.100 / 5. 9.100
libswresample 3. 9.100 / 3. 9.100
libpostproc 55. 9.100 / 55. 9.100";
+ public const string FFmpegGitWithoutLibpostprocOutput = @"ffmpeg version N-122128-gdeadbeef Copyright (c) 2000-2026 the FFmpeg developers
+libavutil 60. 26.102 / 60. 26.102
+libavcodec 62. 28.102 / 62. 28.102
+libavformat 62. 12.102 / 62. 12.102
+libavdevice 62. 3.102 / 62. 3.102
+libavfilter 11. 14.102 / 11. 14.102
+libswscale 9. 5.102 / 9. 5.102
+libswresample 6. 3.102 / 6. 3.102";
+
public const string FFmpegGitUnknownOutput = @"ffmpeg version N-45325-gb173e0353-static https://johnvansickle.com/ffmpeg/ Copyright (c) 2000-2018 the FFmpeg developers
built with gcc 6.3.0 (Debian 6.3.0-18+deb9u1) 20170516
configuration: --enable-gpl --enable-version3 --enable-static --disable-debug --disable-ffplay --disable-indev=sndio --disable-outdev=sndio --cc=gcc-6 --enable-fontconfig --enable-frei0r --enable-gnutls --enable-gray --enable-libfribidi --enable-libass --enable-libfreetype --enable-libmp3lame --enable-libopencore-amrnb --enable-libopencore-amrwb --enable-libopenjpeg --enable-librubberband --enable-libsoxr --enable-libspeex --enable-libvorbis --enable-libopus --enable-libtheora --enable-libvidstab --enable-libvo-amrwbenc --enable-libvpx --enable-libwebp --enable-libx264 --enable-libx265 --enable-libxvid --enable-libzimg
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs
index b723fc7208..52e0b19700 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs
+++ b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs
@@ -219,7 +219,7 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
Assert.Equal("eng", res.MediaStreams[4].Language);
Assert.Equal(MediaStreamType.Subtitle, res.MediaStreams[4].Type);
Assert.Equal("mov_text", res.MediaStreams[4].Codec);
- Assert.Null(res.MediaStreams[4].Title);
+ Assert.Equal("SDH", res.MediaStreams[4].Title);
Assert.True(res.MediaStreams[4].IsHearingImpaired);
Assert.Equal("eng", res.MediaStreams[5].Language);
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/AssParserTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/AssParserTests.cs
index 1f908d7e0e..b03651e5e9 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/AssParserTests.cs
+++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/AssParserTests.cs
@@ -15,13 +15,13 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests
using var stream = File.OpenRead("Test Data/example.ass");
var parsed = new SubtitleEditParser(new NullLogger<SubtitleEditParser>()).Parse(stream, "ass");
- Assert.Single(parsed.TrackEvents);
- var trackEvent = parsed.TrackEvents[0];
+ Assert.Single(parsed.Paragraphs);
+ var paragraph = parsed.Paragraphs[0];
- Assert.Equal("1", trackEvent.Id);
- Assert.Equal(TimeSpan.Parse("00:00:01.18", CultureInfo.InvariantCulture).Ticks, trackEvent.StartPositionTicks);
- Assert.Equal(TimeSpan.Parse("00:00:06.85", CultureInfo.InvariantCulture).Ticks, trackEvent.EndPositionTicks);
- Assert.Equal("{\\pos(400,570)}Like an Angel with pity on nobody" + Environment.NewLine + "The second line in subtitle", trackEvent.Text);
+ Assert.Equal(1, paragraph.Number);
+ Assert.Equal(TimeSpan.Parse("00:00:01.18", CultureInfo.InvariantCulture).Ticks, paragraph.StartTime.TimeSpan.Ticks);
+ Assert.Equal(TimeSpan.Parse("00:00:06.85", CultureInfo.InvariantCulture).Ticks, paragraph.EndTime.TimeSpan.Ticks);
+ Assert.Equal("{\\pos(400,570)}Like an Angel with pity on nobody" + Environment.NewLine + "The second line in subtitle", paragraph.Text);
}
}
}
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs
index b7152961cd..01a35e6cb0 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs
+++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs
@@ -15,19 +15,19 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests
using var stream = File.OpenRead("Test Data/example.srt");
var parsed = new SubtitleEditParser(new NullLogger<SubtitleEditParser>()).Parse(stream, "srt");
- Assert.Equal(2, parsed.TrackEvents.Count);
-
- var trackEvent1 = parsed.TrackEvents[0];
- Assert.Equal("1", trackEvent1.Id);
- Assert.Equal(TimeSpan.Parse("00:02:17.440", CultureInfo.InvariantCulture).Ticks, trackEvent1.StartPositionTicks);
- Assert.Equal(TimeSpan.Parse("00:02:20.375", CultureInfo.InvariantCulture).Ticks, trackEvent1.EndPositionTicks);
- Assert.Equal("Senator, we're making" + Environment.NewLine + "our final approach into Coruscant.", trackEvent1.Text);
-
- var trackEvent2 = parsed.TrackEvents[1];
- Assert.Equal("2", trackEvent2.Id);
- Assert.Equal(TimeSpan.Parse("00:02:20.476", CultureInfo.InvariantCulture).Ticks, trackEvent2.StartPositionTicks);
- Assert.Equal(TimeSpan.Parse("00:02:22.501", CultureInfo.InvariantCulture).Ticks, trackEvent2.EndPositionTicks);
- Assert.Equal("Very good, Lieutenant.", trackEvent2.Text);
+ Assert.Equal(2, parsed.Paragraphs.Count);
+
+ var paragraph1 = parsed.Paragraphs[0];
+ Assert.Equal(1, paragraph1.Number);
+ Assert.Equal(TimeSpan.Parse("00:02:17.440", CultureInfo.InvariantCulture).Ticks, paragraph1.StartTime.TimeSpan.Ticks);
+ Assert.Equal(TimeSpan.Parse("00:02:20.375", CultureInfo.InvariantCulture).Ticks, paragraph1.EndTime.TimeSpan.Ticks);
+ Assert.Equal("Senator, we're making" + Environment.NewLine + "our final approach into Coruscant.", paragraph1.Text);
+
+ var paragraph2 = parsed.Paragraphs[1];
+ Assert.Equal(2, paragraph2.Number);
+ Assert.Equal(TimeSpan.Parse("00:02:20.476", CultureInfo.InvariantCulture).Ticks, paragraph2.StartTime.TimeSpan.Ticks);
+ Assert.Equal(TimeSpan.Parse("00:02:22.501", CultureInfo.InvariantCulture).Ticks, paragraph2.EndTime.TimeSpan.Ticks);
+ Assert.Equal("Very good, Lieutenant.", paragraph2.Text);
}
[Fact]
@@ -36,19 +36,19 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests
using var stream = File.OpenRead("Test Data/example2.srt");
var parsed = new SubtitleEditParser(new NullLogger<SubtitleEditParser>()).Parse(stream, "srt");
- Assert.Equal(2, parsed.TrackEvents.Count);
-
- var trackEvent1 = parsed.TrackEvents[0];
- Assert.Equal("311", trackEvent1.Id);
- Assert.Equal(TimeSpan.Parse("00:16:46.465", CultureInfo.InvariantCulture).Ticks, trackEvent1.StartPositionTicks);
- Assert.Equal(TimeSpan.Parse("00:16:49.009", CultureInfo.InvariantCulture).Ticks, trackEvent1.EndPositionTicks);
- Assert.Equal("Una vez que la gente se entere" + Environment.NewLine + Environment.NewLine + "de que ustedes están aquí,", trackEvent1.Text);
-
- var trackEvent2 = parsed.TrackEvents[1];
- Assert.Equal("312", trackEvent2.Id);
- Assert.Equal(TimeSpan.Parse("00:16:49.092", CultureInfo.InvariantCulture).Ticks, trackEvent2.StartPositionTicks);
- Assert.Equal(TimeSpan.Parse("00:16:51.470", CultureInfo.InvariantCulture).Ticks, trackEvent2.EndPositionTicks);
- Assert.Equal("este lugar se convertirá" + Environment.NewLine + Environment.NewLine + "en un maldito zoológico.", trackEvent2.Text);
+ Assert.Equal(2, parsed.Paragraphs.Count);
+
+ var paragraph1 = parsed.Paragraphs[0];
+ Assert.Equal(311, paragraph1.Number);
+ Assert.Equal(TimeSpan.Parse("00:16:46.465", CultureInfo.InvariantCulture).Ticks, paragraph1.StartTime.TimeSpan.Ticks);
+ Assert.Equal(TimeSpan.Parse("00:16:49.009", CultureInfo.InvariantCulture).Ticks, paragraph1.EndTime.TimeSpan.Ticks);
+ Assert.Equal("Una vez que la gente se entere" + Environment.NewLine + Environment.NewLine + "de que ustedes están aquí,", paragraph1.Text);
+
+ var paragraph2 = parsed.Paragraphs[1];
+ Assert.Equal(312, paragraph2.Number);
+ Assert.Equal(TimeSpan.Parse("00:16:49.092", CultureInfo.InvariantCulture).Ticks, paragraph2.StartTime.TimeSpan.Ticks);
+ Assert.Equal(TimeSpan.Parse("00:16:51.470", CultureInfo.InvariantCulture).Ticks, paragraph2.EndTime.TimeSpan.Ticks);
+ Assert.Equal("este lugar se convertirá" + Environment.NewLine + Environment.NewLine + "en un maldito zoológico.", paragraph2.Text);
}
}
}
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs
index 5b7aa7eaa9..d814088593 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs
+++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs
@@ -20,19 +20,19 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests
{
using Stream stream = new MemoryStream(Encoding.UTF8.GetBytes(ssa));
- SubtitleTrackInfo subtitleTrackInfo = _parser.Parse(stream, "ssa");
+ var subtitle = _parser.Parse(stream, "ssa");
- Assert.Equal(expectedSubtitleTrackEvents.Count, subtitleTrackInfo.TrackEvents.Count);
+ Assert.Equal(expectedSubtitleTrackEvents.Count, subtitle.Paragraphs.Count);
for (int i = 0; i < expectedSubtitleTrackEvents.Count; ++i)
{
SubtitleTrackEvent expected = expectedSubtitleTrackEvents[i];
- SubtitleTrackEvent actual = subtitleTrackInfo.TrackEvents[i];
+ var actual = subtitle.Paragraphs[i];
- Assert.Equal(expected.Id, actual.Id);
+ Assert.Equal(expected.Id, actual.Number.ToString(CultureInfo.InvariantCulture));
Assert.Equal(expected.Text, actual.Text);
- Assert.Equal(expected.StartPositionTicks, actual.StartPositionTicks);
- Assert.Equal(expected.EndPositionTicks, actual.EndPositionTicks);
+ Assert.Equal(expected.StartPositionTicks, actual.StartTime.TimeSpan.Ticks);
+ Assert.Equal(expected.EndPositionTicks, actual.EndTime.TimeSpan.Ticks);
}
}
@@ -75,13 +75,13 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests
using var stream = File.OpenRead("Test Data/example.ssa");
var parsed = _parser.Parse(stream, "ssa");
- Assert.Single(parsed.TrackEvents);
- var trackEvent = parsed.TrackEvents[0];
+ Assert.Single(parsed.Paragraphs);
+ var paragraph = parsed.Paragraphs[0];
- Assert.Equal("1", trackEvent.Id);
- Assert.Equal(TimeSpan.Parse("00:00:01.18", CultureInfo.InvariantCulture).Ticks, trackEvent.StartPositionTicks);
- Assert.Equal(TimeSpan.Parse("00:00:06.85", CultureInfo.InvariantCulture).Ticks, trackEvent.EndPositionTicks);
- Assert.Equal("{\\pos(400,570)}Like an angel with pity on nobody", trackEvent.Text);
+ Assert.Equal(1, paragraph.Number);
+ Assert.Equal(TimeSpan.Parse("00:00:01.18", CultureInfo.InvariantCulture).Ticks, paragraph.StartTime.TimeSpan.Ticks);
+ Assert.Equal(TimeSpan.Parse("00:00:06.85", CultureInfo.InvariantCulture).Ticks, paragraph.EndTime.TimeSpan.Ticks);
+ Assert.Equal("{\\pos(400,570)}Like an angel with pity on nobody", paragraph.Text);
}
}
}
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs
index ce1f005f40..48850b2f67 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs
+++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs
@@ -1,3 +1,8 @@
+using System;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text;
using System.Threading;
using System.Threading.Tasks;
using AutoFixture;
@@ -6,87 +11,120 @@ using MediaBrowser.MediaEncoding.Subtitles;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
+using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace Jellyfin.MediaEncoding.Subtitles.Tests
{
public class SubtitleEncoderTests
{
- public static TheoryData<MediaSourceInfo, MediaStream, SubtitleEncoder.SubtitleInfo> GetReadableFile_Valid_TestData()
+ private const int StreamCount = 8;
+ private const int CueCount = 500;
+
+ // A Greek line that requires a non-UTF-8 legacy encoding to reproduce the bug. The accented
+ // characters (ά, έ, ή, ί, ό, ύ, ώ) share the same code points in windows-1253 and iso-8859-7,
+ // so a Greek-vs-Greek charset misdetection still round-trips correctly.
+ private const string GreekText = "Καλημέρα κόσμε, αυτό είναι ένας υπότιτλος.";
+
+ static SubtitleEncoderTests()
{
- var data = new TheoryData<MediaSourceInfo, MediaStream, SubtitleEncoder.SubtitleInfo>();
+ // Mirrors Jellyfin.Server startup so legacy code pages (e.g. Greek windows-1253) are available.
+ Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
+ }
- data.Add(
- new MediaSourceInfo()
- {
- Protocol = MediaProtocol.File
- },
- new MediaStream()
- {
- Path = "/media/sub.ass",
- IsExternal = true
- },
- new SubtitleEncoder.SubtitleInfo()
- {
- Path = "/media/sub.ass",
- Protocol = MediaProtocol.File,
- Format = "ass",
- IsExternal = true
- });
+ // Enough Greek text to give the charset detector a strong, unambiguous signal.
+ private static string BuildGreekSrt()
+ {
+ var builder = new StringBuilder();
+ for (var i = 1; i <= 8; i++)
+ {
+ builder.Append(i.ToString(CultureInfo.InvariantCulture)).Append('\n');
+ builder.Append("00:00:0").Append(i.ToString(CultureInfo.InvariantCulture))
+ .Append(",000 --> 00:00:0").Append((i + 1).ToString(CultureInfo.InvariantCulture)).Append(",000\n");
+ builder.Append(GreekText).Append('\n');
+ builder.Append("Η γρήγορη καφέ αλεπού πηδάει πάνω από το τεμπέλικο σκυλί.\n\n");
+ }
- data.Add(
- new MediaSourceInfo()
- {
- Protocol = MediaProtocol.File
- },
- new MediaStream()
- {
- Path = "/media/sub.ssa",
- IsExternal = true
- },
- new SubtitleEncoder.SubtitleInfo()
- {
- Path = "/media/sub.ssa",
- Protocol = MediaProtocol.File,
- Format = "ssa",
- IsExternal = true
- });
+ return builder.ToString();
+ }
- data.Add(
- new MediaSourceInfo()
- {
- Protocol = MediaProtocol.File
- },
- new MediaStream()
+ public static TheoryData<MediaSourceInfo, MediaStream, SubtitleEncoder.SubtitleInfo> GetReadableFile_Valid_TestData()
+ {
+ var data = new TheoryData<MediaSourceInfo, MediaStream, SubtitleEncoder.SubtitleInfo>
+ {
{
- Path = "/media/sub.srt",
- IsExternal = true
+ new MediaSourceInfo()
+ {
+ Protocol = MediaProtocol.File
+ },
+ new MediaStream()
+ {
+ Path = "/media/sub.ass",
+ IsExternal = true
+ },
+ new SubtitleEncoder.SubtitleInfo()
+ {
+ Path = "/media/sub.ass",
+ Protocol = MediaProtocol.File,
+ Format = "ass",
+ IsExternal = true
+ }
},
- new SubtitleEncoder.SubtitleInfo()
- {
- Path = "/media/sub.srt",
- Protocol = MediaProtocol.File,
- Format = "srt",
- IsExternal = true
- });
-
- data.Add(
- new MediaSourceInfo()
{
- Protocol = MediaProtocol.Http
+ new MediaSourceInfo()
+ {
+ Protocol = MediaProtocol.File
+ },
+ new MediaStream()
+ {
+ Path = "/media/sub.ssa",
+ IsExternal = true
+ },
+ new SubtitleEncoder.SubtitleInfo()
+ {
+ Path = "/media/sub.ssa",
+ Protocol = MediaProtocol.File,
+ Format = "ssa",
+ IsExternal = true
+ }
},
- new MediaStream()
{
- Path = "/media/sub.ass",
- IsExternal = true
+ new MediaSourceInfo()
+ {
+ Protocol = MediaProtocol.File
+ },
+ new MediaStream()
+ {
+ Path = "/media/sub.srt",
+ IsExternal = true
+ },
+ new SubtitleEncoder.SubtitleInfo()
+ {
+ Path = "/media/sub.srt",
+ Protocol = MediaProtocol.File,
+ Format = "srt",
+ IsExternal = true
+ }
},
- new SubtitleEncoder.SubtitleInfo()
{
- Path = "/media/sub.ass",
- Protocol = MediaProtocol.File,
- Format = "ass",
- IsExternal = true
- });
+ new MediaSourceInfo()
+ {
+ Protocol = MediaProtocol.Http
+ },
+ new MediaStream()
+ {
+ Path = "/media/sub.ass",
+ IsExternal = true
+ },
+ new SubtitleEncoder.SubtitleInfo()
+ {
+ Path = "/media/sub.ass",
+ Protocol = MediaProtocol.File,
+ Format = "ass",
+ IsExternal = true
+ }
+ }
+ };
return data;
}
@@ -103,5 +141,177 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests
Assert.Equal(subtitleInfo.Format, result.Format);
Assert.Equal(subtitleInfo.IsExternal, result.IsExternal);
}
+
+ public static TheoryData<Encoding> GetSubtitleStream_NonUtf8LocalFile_TestData()
+ {
+ return
+ [
+ // Greek legacy encodings – the exact scenario reported in issue #17267.
+ Encoding.GetEncoding("windows-1253"),
+ Encoding.GetEncoding("iso-8859-7"),
+ // Wide encoding with a BOM.
+ new UnicodeEncoding(bigEndian: false, byteOrderMark: true),
+ ];
+ }
+
+ [Theory]
+ [MemberData(nameof(GetSubtitleStream_NonUtf8LocalFile_TestData))]
+ public async Task GetSubtitleStream_NonUtf8LocalFile_ConvertedToUtf8(Encoding sourceEncoding)
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var srt = BuildGreekSrt();
+ var path = Path.GetTempFileName();
+ try
+ {
+ await File.WriteAllTextAsync(path, srt, sourceEncoding, cancellationToken);
+
+ var fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true });
+ var subtitleEncoder = fixture.Create<SubtitleEncoder>();
+
+ var fileInfo = new SubtitleEncoder.SubtitleInfo
+ {
+ Path = path,
+ Protocol = MediaProtocol.File,
+ Format = "srt",
+ IsExternal = true
+ };
+
+ using var stream = await subtitleEncoder.GetSubtitleStream(fileInfo, cancellationToken);
+ using var reader = new StreamReader(stream, new UTF8Encoding(false));
+ var text = await reader.ReadToEndAsync(cancellationToken);
+
+ // The Greek text must survive round-trip and contain no replacement characters.
+ Assert.Contains(GreekText, text, StringComparison.Ordinal);
+ Assert.DoesNotContain('�', text);
+ Assert.DoesNotContain('?', text);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public void ConvertSubtitles_SequentialCalls_AreDeterministic()
+ {
+ using var encoder = CreateEncoder();
+ var sources = GenerateSources();
+
+ var first = ConvertAllSequential(encoder, sources);
+ var second = ConvertAllSequential(encoder, sources);
+
+ for (var i = 0; i < StreamCount; i++)
+ {
+ Assert.Contains($"S{i}C{CueCount - 1}", first[i], StringComparison.Ordinal);
+ Assert.Equal(first[i], second[i]);
+ }
+ }
+
+ [Fact]
+ public async Task GetSubtitleStream_Utf8LocalFile_PreservesContent()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var srt = BuildGreekSrt();
+ var path = Path.GetTempFileName();
+ try
+ {
+ await File.WriteAllTextAsync(path, srt, new UTF8Encoding(false), cancellationToken);
+
+ var fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true });
+ var subtitleEncoder = fixture.Create<SubtitleEncoder>();
+
+ var fileInfo = new SubtitleEncoder.SubtitleInfo
+ {
+ Path = path,
+ Protocol = MediaProtocol.File,
+ Format = "srt",
+ IsExternal = true
+ };
+
+ using var stream = await subtitleEncoder.GetSubtitleStream(fileInfo, cancellationToken);
+
+ // An already-UTF-8 file must be short-circuited and served directly from disk,
+ // not read into memory and re-encoded (which would produce a MemoryStream).
+ Assert.IsNotType<MemoryStream>(stream);
+
+ using var reader = new StreamReader(stream, new UTF8Encoding(false));
+ var text = await reader.ReadToEndAsync(cancellationToken);
+
+ Assert.Contains(GreekText, text, StringComparison.Ordinal);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public async Task ConvertSubtitles_ConcurrentCalls_MatchSequentialBaseline()
+ {
+ const int Iterations = 10;
+
+ using var encoder = CreateEncoder();
+ var sources = GenerateSources();
+ var baseline = ConvertAllSequential(encoder, sources);
+
+ for (var iteration = 0; iteration < Iterations; iteration++)
+ {
+ var results = await Task.WhenAll(Enumerable.Range(0, StreamCount)
+ .Select(i => Task.Run(() => Convert(encoder, sources[i], i)))
+ .ToArray());
+
+ for (var i = 0; i < StreamCount; i++)
+ {
+ Assert.True(
+ string.Equals(baseline[i], results[i], StringComparison.Ordinal),
+ $"Iteration {iteration}: stream {i} returned corrupted content ({results[i].Length} chars vs {baseline[i].Length} baseline)");
+ }
+ }
+ }
+
+ private static SubtitleEncoder CreateEncoder()
+ {
+ var fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true });
+ fixture.Inject<ISubtitleParser>(new SubtitleEditParser(NullLogger<SubtitleEditParser>.Instance));
+ return fixture.Create<SubtitleEncoder>();
+ }
+
+ private static byte[][] GenerateSources()
+ {
+ return Enumerable.Range(0, StreamCount)
+ .Select(i => Encoding.UTF8.GetBytes(GenerateSrt(i, CueCount)))
+ .ToArray();
+ }
+
+ private static string Convert(SubtitleEncoder encoder, byte[] source, int streamIndex)
+ {
+ using var input = new MemoryStream(source);
+ var info = new SubtitleEncoder.SubtitleInfo { Path = $"track{streamIndex}.srt", Format = "srt" };
+ using var output = encoder.ConvertSubtitles(input, info, "vtt", 0, 0, false);
+ return Encoding.UTF8.GetString(output.ToArray());
+ }
+
+ private static string[] ConvertAllSequential(SubtitleEncoder encoder, byte[][] sources)
+ {
+ return sources.Select((source, i) => Convert(encoder, source, i)).ToArray();
+ }
+
+ private static string GenerateSrt(int streamIndex, int cueCount)
+ {
+ var builder = new StringBuilder();
+ for (var i = 0; i < cueCount; i++)
+ {
+ var start = TimeSpan.FromSeconds(i * 4);
+ var end = start + TimeSpan.FromSeconds(2);
+ builder.Append(i + 1).AppendLine()
+ .Append(start.ToString(@"hh\:mm\:ss\,fff", CultureInfo.InvariantCulture))
+ .Append(" --> ")
+ .AppendLine(end.ToString(@"hh\:mm\:ss\,fff", CultureInfo.InvariantCulture))
+ .Append('S').Append(streamIndex).Append('C').Append(i).AppendLine()
+ .AppendLine();
+ }
+
+ return builder.ToString();
+ }
}
}
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_mp4_metadata.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_mp4_metadata.json
index 9a7a4ba373..e406cc18b0 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_mp4_metadata.json
+++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_mp4_metadata.json
@@ -95,7 +95,8 @@
"tags": {
"creation_time": "2021-09-13T22:42:42.000000Z",
"language": "eng",
- "handler_name": "Surround 6.1",
+ "handler_name": "SoundHandler",
+ "name": "Surround 6.1",
"vendor_id": "[0][0][0][0]"
}
},
@@ -215,7 +216,8 @@
"tags": {
"creation_time": "2021-09-13T22:42:42.000000Z",
"language": "eng",
- "handler_name": "SubtitleHandler"
+ "handler_name": "SubtitleHandler",
+ "name": "SDH"
}
},
{
diff --git a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
index d94d56bc20..5ba061296a 100644
--- a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
+++ b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
@@ -677,6 +677,48 @@ namespace Jellyfin.Model.Tests
}
[Theory]
+ [InlineData(false, null, true, SubtitleDeliveryMethod.External)]
+ [InlineData(false, null, false, SubtitleDeliveryMethod.Encode)]
+ [InlineData(true, "/media/sub.mks", true, SubtitleDeliveryMethod.External)]
+ [InlineData(true, "/media/sub.idx", true, SubtitleDeliveryMethod.Encode)]
+ [InlineData(true, "/media/sub.sub", true, SubtitleDeliveryMethod.Encode)]
+ public void GetSubtitleProfile_MatchesVobSubMksProfileOnlyWhenDeliveredAsMks(
+ bool isExternal,
+ string? path,
+ bool enableSubtitleExtraction,
+ SubtitleDeliveryMethod expectedMethod)
+ {
+ var mediaSource = new MediaSourceInfo();
+ var subtitleStream = new MediaStream
+ {
+ Type = MediaStreamType.Subtitle,
+ Index = 0,
+ IsExternal = isExternal,
+ Path = path,
+ Codec = "vobsub"
+ };
+
+ var subtitleProfiles = new[]
+ {
+ new SubtitleProfile { Format = "vobsub", Container = "mks", Method = SubtitleDeliveryMethod.External }
+ };
+
+ var transcoderSupport = new Mock<ITranscoderSupport>();
+ transcoderSupport.Setup(t => t.CanExtractSubtitles(It.IsAny<string>())).Returns(enableSubtitleExtraction);
+
+ var result = StreamBuilder.GetSubtitleProfile(
+ mediaSource,
+ subtitleStream,
+ subtitleProfiles,
+ PlayMethod.Transcode,
+ transcoderSupport.Object,
+ null,
+ null);
+
+ Assert.Equal(expectedMethod, result.Method);
+ }
+
+ [Theory]
// External text subs embedded into MKV when transcoding (#16403)
[InlineData("srt", true, PlayMethod.Transcode, "mkv", MediaStreamProtocol.http, SubtitleDeliveryMethod.Embed)]
[InlineData("ass", true, PlayMethod.Transcode, "mkv", MediaStreamProtocol.http, SubtitleDeliveryMethod.Embed)]
diff --git a/tests/Jellyfin.Server.Implementations.Tests/FullSystemBackup/BackupServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/FullSystemBackup/BackupServiceTests.cs
new file mode 100644
index 0000000000..66c392a6ad
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/FullSystemBackup/BackupServiceTests.cs
@@ -0,0 +1,182 @@
+using System;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Locking;
+using Jellyfin.Database.Providers.Sqlite;
+using Jellyfin.Server.Implementations.FullSystemBackup;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.SystemBackupService;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
+
+namespace Jellyfin.Server.Implementations.Tests.FullSystemBackup;
+
+/// <summary>
+/// Tests for <see cref="BackupService"/>, in particular that a single row of corrupt
+/// <see cref="KeyframeData"/> (e.g. malformed <c>KeyframeTicks</c> JSON) does not abort
+/// an otherwise healthy backup. See https://github.com/jellyfin/jellyfin/issues/17216.
+/// </summary>
+public sealed class BackupServiceTests : IDisposable
+{
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
+ private readonly string _testRoot;
+ private readonly string _backupPath;
+ private readonly string _configurationDirectoryPath;
+
+ public BackupServiceTests()
+ {
+ _connection = new SqliteConnection("Data Source=:memory:");
+ _connection.Open();
+
+ _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
+ .UseSqlite(_connection)
+ .Options;
+
+ using (var ctx = CreateDbContext())
+ {
+ ctx.Database.EnsureCreated();
+ }
+
+ // Use the test assembly's own output directory instead of Path.GetTempPath(). On GitHub-hosted
+ // windows-latest runners, the system temp directory lives on the constrained C: drive, which can have
+ // less than the 5GiB BackupService requires free, causing spurious failures. AppContext.BaseDirectory
+ // is under the repo checkout (the much larger D: drive on Windows runners) on all platforms.
+ _testRoot = Path.Combine(AppContext.BaseDirectory, "jellyfin-backup-service-tests-" + Guid.NewGuid().ToString("N"));
+ _backupPath = Path.Combine(_testRoot, "Backup");
+ _configurationDirectoryPath = Path.Combine(_testRoot, "Config");
+ Directory.CreateDirectory(_backupPath);
+ Directory.CreateDirectory(_configurationDirectoryPath);
+ }
+
+ public void Dispose()
+ {
+ _connection.Dispose();
+
+ if (Directory.Exists(_testRoot))
+ {
+ Directory.Delete(_testRoot, true);
+ }
+ }
+
+ [Fact]
+ public async Task CreateBackupAsync_WithCorruptKeyframeDataRow_SkipsRowAndCompletesBackup()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var validItemId = Guid.Parse("11111111-1111-1111-1111-111111111111");
+ var corruptItemId = Guid.Parse("22222222-2222-2222-2222-222222222222");
+
+ await using (var ctx = CreateDbContext())
+ {
+ // A healthy item + keyframe row, written the normal way.
+ ctx.BaseItems.Add(CreateMovieEntity(validItemId, "Good Movie"));
+ ctx.BaseItems.Add(CreateMovieEntity(corruptItemId, "Corrupt Movie"));
+ await ctx.SaveChangesAsync(cancellationToken).ConfigureAwait(true);
+
+ ctx.KeyframeData.Add(new KeyframeData
+ {
+ ItemId = validItemId,
+ TotalDuration = 60_000,
+ KeyframeTicks = [0, 1000, 2000]
+ });
+ await ctx.SaveChangesAsync(cancellationToken).ConfigureAwait(true);
+
+ // Simulate a corrupted database row: truncated JSON array for KeyframeTicks,
+ // written directly via SQL to bypass EF's normal (well-formed) write path.
+ await ctx.Database.ExecuteSqlInterpolatedAsync(
+ $"INSERT INTO KeyframeData (ItemId, TotalDuration, KeyframeTicks) VALUES ({corruptItemId.ToString()}, {5000L}, {"[1,2,3"})",
+ cancellationToken).ConfigureAwait(true);
+ }
+
+ var backupService = CreateBackupService();
+
+ var manifest = await backupService.CreateBackupAsync(new BackupOptionsDto()).ConfigureAwait(true);
+
+ Assert.True(File.Exists(manifest.Path));
+
+ using var archive = await ZipFile.OpenReadAsync(manifest.Path, cancellationToken).ConfigureAwait(true);
+ var keyframeEntry = archive.GetEntry("Database/KeyframeData.json");
+ Assert.NotNull(keyframeEntry);
+
+ await using var entryStream = await keyframeEntry!.OpenAsync(cancellationToken).ConfigureAwait(true);
+ using var document = await JsonDocument.ParseAsync(entryStream, cancellationToken: cancellationToken).ConfigureAwait(true);
+
+ var rows = document.RootElement.EnumerateArray().ToList();
+
+ // The corrupt row must be skipped, but the valid row must still make it into the backup.
+ var singleRow = Assert.Single(rows);
+ Assert.Equal(validItemId, singleRow.GetProperty("ItemId").GetGuid());
+ }
+
+ private BackupService CreateBackupService()
+ {
+ var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
+ factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
+ factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>())).ReturnsAsync(CreateDbContext);
+
+ var applicationHost = new Mock<IServerApplicationHost>();
+ applicationHost.Setup(a => a.ApplicationVersion).Returns(new Version(10, 11, 0));
+
+ var applicationPaths = new Mock<IServerApplicationPaths>();
+ applicationPaths.Setup(a => a.BackupPath).Returns(_backupPath);
+ applicationPaths.Setup(a => a.ConfigurationDirectoryPath).Returns(_configurationDirectoryPath);
+ applicationPaths.Setup(a => a.DataPath).Returns(Path.Combine(_testRoot, "Data"));
+ applicationPaths.Setup(a => a.RootFolderPath).Returns(Path.Combine(_testRoot, "Root"));
+ applicationPaths.Setup(a => a.InternalMetadataPath).Returns(Path.Combine(_testRoot, "Metadata"));
+ applicationPaths.Setup(a => a.DefaultInternalMetadataPath).Returns(Path.Combine(_testRoot, "MetadataDefault"));
+
+ var jellyfinDatabaseProvider = new Mock<IJellyfinDatabaseProvider>();
+ jellyfinDatabaseProvider.Setup(p => p.RunScheduledOptimisation(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
+ jellyfinDatabaseProvider.Setup(p => p.PurgeDatabase(It.IsAny<JellyfinDbContext>(), It.IsAny<System.Collections.Generic.IEnumerable<string>>())).Returns(Task.CompletedTask);
+
+ var applicationLifetime = new Mock<IHostApplicationLifetime>();
+
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(l => l.IsScanRunning).Returns(false);
+
+ return new BackupService(
+ NullLogger<BackupService>.Instance,
+ factory.Object,
+ applicationHost.Object,
+ applicationPaths.Object,
+ jellyfinDatabaseProvider.Object,
+ applicationLifetime.Object,
+ libraryManager.Object);
+ }
+
+ private static BaseItemEntity CreateMovieEntity(Guid id, string name)
+ {
+ return new BaseItemEntity
+ {
+ Id = id,
+ Type = "Movie",
+ Name = name,
+ PresentationUniqueKey = id.ToString("N"),
+ MediaType = "Video",
+ IsMovie = true,
+ IsFolder = false,
+ IsVirtualItem = false
+ };
+ }
+
+ private JellyfinDbContext CreateDbContext()
+ {
+ return new JellyfinDbContext(
+ _dbOptions,
+ NullLogger<JellyfinDbContext>.Instance,
+ new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
+ new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs
index c8aa14af58..b7fca74310 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs
@@ -60,7 +60,9 @@ public sealed class AlternateVersionQueryTranslationTests : IDisposable
.Where(e => seededIds.Contains(e.Id))
.Where(e => inProgressIds.Contains(e.Id))
.Where(e => !ctx.BaseItems
- .Where(s => s.Id != e.Id && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
+ .Where(s => s.Id != e.Id
+ && inProgressIds.Contains(s.Id)
+ && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
.Any(s =>
inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
> inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
@@ -110,7 +112,9 @@ public sealed class AlternateVersionQueryTranslationTests : IDisposable
.Where(e => seededIds.Contains(e.Id))
.Where(e => inProgressIds.Contains(e.Id))
.Where(e => !ctx.BaseItems
- .Where(s => s.Id != e.Id && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
+ .Where(s => s.Id != e.Id
+ && inProgressIds.Contains(s.Id)
+ && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
.Any(s =>
inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
> inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs
index 650d67b195..e65bc1d31f 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs
@@ -9,44 +9,105 @@ namespace Jellyfin.Server.Implementations.Tests.Library
{
[Theory]
[InlineData("Superman: Red Son [imdbid=tt10985510]", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son [imdb=tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son [imdbid-tt10985510]", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son [imdb-tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son - tt10985510", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son {imdbid=tt10985510}", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son {imdb=tt10985510}", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son {imdbid-tt10985510}", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son {imdb-tt10985510}", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son (imdbid=tt10985510)", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son (imdb=tt10985510)", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son (imdbid-tt10985510)", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son (imdb-tt10985510)", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son", "imdbid", null)]
[InlineData("Superman: Red Son [imdbid1=tt11111111][imdbid=tt10985510]", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son [imdbid1=tt11111111][imdb=tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son {imdbid1=tt11111111}(imdbid=tt10985510)", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son {imdbid1=tt11111111}(imdb=tt10985510)", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son (imdbid1-tt11111111)[imdbid=tt10985510]", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son (imdbid1-tt11111111)[imdb=tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son [tmdbid=618355][imdbid=tt10985510]", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son [tmdbid=618355][imdb=tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son [tmdbid-618355]{imdbid-tt10985510}", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son [tmdbid-618355]{imdb-tt10985510}", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son (tmdbid-618355)[imdbid-tt10985510]", "tmdbid", "618355")]
+ [InlineData("Superman: Red Son (tmdbid-618355)[imdb-tt10985510]", "tmdbid", "618355")]
[InlineData("Superman: Red Son [providera-id=1]", "providera-id", "1")]
[InlineData("Superman: Red Son [providerb-id=2]", "providerb-id", "2")]
[InlineData("Superman: Red Son [providera id=4]", "providera id", "4")]
[InlineData("Superman: Red Son [providerb id=5]", "providerb id", "5")]
+ [InlineData("Superman: Red Son [provider=99][providerid=5]", "providerid", "5")]
[InlineData("Superman: Red Son [tmdbid=3]", "tmdbid", "3")]
- [InlineData("Superman: Red Son [tvdbid-6]", "tvdbid", "6")]
+ [InlineData("Superman: Red Son [tmdb=3]", "tmdbid", "3")]
+ [InlineData("Superman: Red Son [tmdbid-3]", "tmdbid", "3")]
+ [InlineData("Superman: Red Son [tmdb-3]", "tmdbid", "3")]
[InlineData("Superman: Red Son {tmdbid=3}", "tmdbid", "3")]
+ [InlineData("Superman: Red Son {tmdb=3}", "tmdbid", "3")]
+ [InlineData("Superman: Red Son {tmdbid-3}", "tmdbid", "3")]
+ [InlineData("Superman: Red Son {tmdb-3}", "tmdbid", "3")]
+ [InlineData("Superman: Red Son (tmdbid=6)", "tmdbid", "6")]
+ [InlineData("Superman: Red Son (tmdb=6)", "tmdbid", "6")]
+ [InlineData("Superman: Red Son (tmdbid-6)", "tmdbid", "6")]
+ [InlineData("Superman: Red Son (tmdb-6)", "tmdbid", "6")]
+ [InlineData("Superman: Red Son [tvdbid=6]", "tvdbid", "6")]
+ [InlineData("Superman: Red Son [tvdb=6]", "tvdbid", "6")]
+ [InlineData("Superman: Red Son [tvdbid-6]", "tvdbid", "6")]
+ [InlineData("Superman: Red Son [tvdb-6]", "tvdbid", "6")]
+ [InlineData("Superman: Red Son {tvdbid=3}", "tvdbid", "3")]
+ [InlineData("Superman: Red Son {tvdb=3}", "tvdbid", "3")]
+ [InlineData("Superman: Red Son {tvdbid-3}", "tvdbid", "3")]
+ [InlineData("Superman: Red Son {tvdb-3}", "tvdbid", "3")]
+ [InlineData("Superman: Red Son (tvdbid=6)", "tvdbid", "6")]
+ [InlineData("Superman: Red Son (tvdb=6)", "tvdbid", "6")]
[InlineData("Superman: Red Son (tvdbid-6)", "tvdbid", "6")]
+ [InlineData("Superman: Red Son (tvdb-6)", "tvdbid", "6")]
[InlineData("[tmdbid=618355]", "tmdbid", "618355")]
+ [InlineData("[tmdb=618355]", "tmdbid", "618355")]
[InlineData("{tmdbid=618355}", "tmdbid", "618355")]
+ [InlineData("{tmdb=618355}", "tmdbid", "618355")]
[InlineData("(tmdbid=618355)", "tmdbid", "618355")]
+ [InlineData("(tmdb=618355)", "tmdbid", "618355")]
[InlineData("[tmdbid-618355]", "tmdbid", "618355")]
+ [InlineData("[tmdb-618355]", "tmdbid", "618355")]
[InlineData("{tmdbid-618355)", "tmdbid", null)]
+ [InlineData("{tmdb-618355)", "tmdbid", null)]
[InlineData("[tmdbid-618355}", "tmdbid", null)]
+ [InlineData("[tmdb-618355}", "tmdbid", null)]
[InlineData("tmdbid=111111][tmdbid=618355]", "tmdbid", "618355")]
+ [InlineData("tmdbid=111111][tmdb=618355]", "tmdbid", "618355")]
[InlineData("[tmdbid=618355]tmdbid=111111]", "tmdbid", "618355")]
+ [InlineData("[tmdb=618355]tmdbid=111111]", "tmdbid", "618355")]
[InlineData("tmdbid=618355]", "tmdbid", null)]
+ [InlineData("tmdb=618355]", "tmdbid", null)]
[InlineData("[tmdbid=618355", "tmdbid", null)]
+ [InlineData("[tmdb=618355", "tmdbid", null)]
[InlineData("tmdbid=618355", "tmdbid", null)]
+ [InlineData("tmdb=618355", "tmdbid", null)]
[InlineData("tmdbid=", "tmdbid", null)]
+ [InlineData("tmdb=", "tmdbid", null)]
[InlineData("tmdbid", "tmdbid", null)]
+ [InlineData("tmdb", "tmdbid", null)]
+ [InlineData("[tmdbid= ][tmdbid=223344]", "tmdbid", "223344")]
+ [InlineData("[tmdb= ][tmdb=223344]", "tmdbid", "223344")]
+ [InlineData("[tmdbid= ][tmdb=223344]", "tmdbid", "223344")]
+ [InlineData("[tmdb= ][tmdbid=223344]", "tmdbid", "223344")]
[InlineData("[tmdbid=][imdbid=tt10985510]", "tmdbid", null)]
+ [InlineData("[tmdb=][imdbid=tt10985510]", "tmdbid", null)]
[InlineData("[tmdbid-][imdbid-tt10985510]", "tmdbid", null)]
+ [InlineData("[tmdb-][imdbid-tt10985510]", "tmdbid", null)]
[InlineData("Superman: Red Son [tmdbid-618355][tmdbid=1234567]", "tmdbid", "618355")]
+ [InlineData("Superman: Red Son [tmdb-618355][tmdbid=1234567]", "tmdbid", "618355")]
[InlineData("{tmdbid=}{imdbid=tt10985510}", "tmdbid", null)]
+ [InlineData("{tmdb=}{imdbid=tt10985510}", "tmdbid", null)]
[InlineData("(tmdbid-)(imdbid-tt10985510)", "tmdbid", null)]
+ [InlineData("(tmdb-)(imdbid-tt10985510)", "tmdbid", null)]
[InlineData("Superman: Red Son {tmdbid-618355}{tmdbid=1234567}", "tmdbid", "618355")]
+ [InlineData("Superman: Red Son {tmdb-618355}{tmdbid=1234567}", "tmdbid", "618355")]
+ [InlineData("Superman: Red Son - tt10985510 [imdbid1=tt11]", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son [tmdb=618355][tmdbid1=1]", "tmdbid", "618355")]
+ [InlineData("Superman: Red Son [tmdb=618355][tmdbid=12345]", "tmdbid", "618355")]
public void GetAttributeValue_ValidArgs_Correct(string input, string attribute, string? expectedResult)
{
Assert.Equal(expectedResult, PathExtensions.GetAttributeValue(input, attribute));
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs
new file mode 100644
index 0000000000..bd14ca008d
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs
@@ -0,0 +1,209 @@
+using System;
+using System.Collections.Generic;
+using Emby.Server.Implementations.Library;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Locking;
+using Jellyfin.Database.Providers.Sqlite;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Configuration;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+using AudioBook = MediaBrowser.Controller.Entities.AudioBook;
+
+namespace Jellyfin.Server.Implementations.Tests.Library;
+
+public sealed class UserDataManagerTests : IDisposable
+{
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
+ private readonly UserDataManager _userDataManager;
+ private readonly User _user;
+
+ public UserDataManagerTests()
+ {
+ _connection = new SqliteConnection("Data Source=:memory:");
+ _connection.Open();
+
+ _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
+ .UseSqlite(_connection)
+ .Options;
+
+ using (var ctx = CreateDbContext())
+ {
+ ctx.Database.EnsureCreated();
+ }
+
+ var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
+ factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
+
+ var config = new Mock<IServerConfigurationManager>();
+ config.SetupGet(c => c.Configuration).Returns(new ServerConfiguration());
+
+ _userDataManager = new UserDataManager(config.Object, factory.Object);
+ _user = new User("user", "auth-provider", "reset-provider")
+ {
+ Id = Guid.NewGuid()
+ };
+ }
+
+ public void Dispose()
+ {
+ _connection.Dispose();
+ }
+
+ private JellyfinDbContext CreateDbContext()
+ {
+ return new JellyfinDbContext(
+ _dbOptions,
+ NullLogger<JellyfinDbContext>.Instance,
+ new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
+ new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
+ }
+
+ private AudioBook CreateAudioBook()
+ {
+ // GetUserDataKeys(): ["Author-Series-0001Book Title", "<item id N>"]
+ return new AudioBook
+ {
+ Id = Guid.NewGuid(),
+ Name = "Book Title",
+ Album = "Series",
+ AlbumArtists = new[] { "Author" },
+ IndexNumber = 1
+ };
+ }
+
+ private UserData CreateUserDataRow(AudioBook item, string key, long positionTicks)
+ {
+ return new UserData
+ {
+ ItemId = item.Id,
+ Item = null,
+ UserId = _user.Id,
+ User = null,
+ CustomDataKey = key,
+ PlaybackPositionTicks = positionTicks
+ };
+ }
+
+ [Fact]
+ public void GetUserData_RowsUnderCurrentAndRetiredKeys_PrefersCurrentKeyRow()
+ {
+ var item = CreateAudioBook();
+ var currentKey = item.GetUserDataKeys()[0];
+
+ // the retired-key row comes first to ensure selection is by key, not row order
+ item.UserData = new List<UserData>
+ {
+ CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111),
+ CreateUserDataRow(item, currentKey, 222)
+ };
+
+ var userData = _userDataManager.GetUserData(_user, item);
+
+ Assert.NotNull(userData);
+ Assert.Equal(currentKey, userData.Key);
+ Assert.Equal(222, userData.PlaybackPositionTicks);
+ }
+
+ [Fact]
+ public void GetUserData_NoPrimaryKeyRow_UsesNextCurrentKeyRow()
+ {
+ var item = CreateAudioBook();
+ var idKey = item.GetUserDataKeys()[1];
+
+ item.UserData = new List<UserData>
+ {
+ CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111),
+ CreateUserDataRow(item, idKey, 333)
+ };
+
+ var userData = _userDataManager.GetUserData(_user, item);
+
+ Assert.NotNull(userData);
+ Assert.Equal(idKey, userData.Key);
+ Assert.Equal(333, userData.PlaybackPositionTicks);
+ }
+
+ [Fact]
+ public void GetUserData_OnlyRetiredKeyRows_ReturnsRetiredKeyRow()
+ {
+ var item = CreateAudioBook();
+
+ item.UserData = new List<UserData>
+ {
+ CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111)
+ };
+
+ var userData = _userDataManager.GetUserData(_user, item);
+
+ Assert.NotNull(userData);
+ Assert.Equal(111, userData.PlaybackPositionTicks);
+ }
+
+ [Fact]
+ public void GetUserData_NoRows_ReturnsDefaultWithPrimaryKey()
+ {
+ var item = CreateAudioBook();
+ item.UserData = new List<UserData>();
+
+ var userData = _userDataManager.GetUserData(_user, item);
+
+ Assert.NotNull(userData);
+ Assert.Equal(item.GetUserDataKeys()[0], userData.Key);
+ Assert.Equal(0, userData.PlaybackPositionTicks);
+ }
+
+ [Fact]
+ public void GetUserData_RowsForOtherUsers_AreIgnored()
+ {
+ var item = CreateAudioBook();
+ var currentKey = item.GetUserDataKeys()[0];
+
+ var otherUserRow = CreateUserDataRow(item, currentKey, 999);
+ otherUserRow.UserId = Guid.NewGuid();
+
+ item.UserData = new List<UserData>
+ {
+ otherUserRow,
+ CreateUserDataRow(item, currentKey, 222)
+ };
+
+ var userData = _userDataManager.GetUserData(_user, item);
+
+ Assert.NotNull(userData);
+ Assert.Equal(222, userData.PlaybackPositionTicks);
+ }
+
+ [Fact]
+ public void GetUserDataBatch_DatabaseFallback_ResolvesRowsByKeyOrder()
+ {
+ // no preloaded navigation data, so the batch takes the database fallback
+ var fossilItem = CreateAudioBook();
+ var retiredItem = CreateAudioBook();
+
+ using (var ctx = CreateDbContext())
+ {
+ ctx.Users.Add(_user);
+ ctx.BaseItems.Add(new BaseItemEntity { Id = fossilItem.Id, Type = typeof(AudioBook).FullName! });
+ ctx.BaseItems.Add(new BaseItemEntity { Id = retiredItem.Id, Type = typeof(AudioBook).FullName! });
+
+ // the stale id-key row is inserted first so selection by row order would return it
+ ctx.UserData.AddRange(
+ CreateUserDataRow(fossilItem, fossilItem.GetUserDataKeys()[1], 111),
+ CreateUserDataRow(fossilItem, fossilItem.GetUserDataKeys()[0], 222),
+ CreateUserDataRow(retiredItem, "Author-Old Album-0001Old File Name", 333));
+ ctx.SaveChanges();
+ }
+
+ var result = _userDataManager.GetUserDataBatch([fossilItem, retiredItem], _user);
+
+ Assert.Equal(222, result[fossilItem.Id].PlaybackPositionTicks);
+ Assert.Equal(333, result[retiredItem.Id].PlaybackPositionTicks);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
index bdb726f06d..2ed880ed9c 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
@@ -119,6 +119,40 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
Assert.Equal(code, culture.ThreeLetterISOLanguageName);
}
+ [Theory]
+ [InlineData("ell", "Greek")] // Comma truncation
+ [InlineData("nld", "Dutch")] // Semicolon truncation
+ [InlineData("ron", "Romanian")] // Semicolon truncation, multiple
+ [InlineData("eng", "English")] // No truncation
+ [InlineData("zh-CN", "Chinese (Simplified)")] // No truncation, with parentheses
+ public async Task GetLanguageDisplayName_DelimitedName_ReturnsTruncatedName(string language, string expected)
+ {
+ var localizationManager = Setup(new ServerConfiguration
+ {
+ UICulture = "en-US"
+ });
+ await localizationManager.LoadAll();
+
+ var result = localizationManager.GetLanguageDisplayName(language);
+ Assert.Equal(expected, result);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("xyz")]
+ public async Task GetLanguageDisplayName_InvalidInput_ReturnsNull(string? language)
+ {
+ var localizationManager = Setup(new ServerConfiguration
+ {
+ UICulture = "en-US"
+ });
+ await localizationManager.LoadAll();
+
+ var result = localizationManager.GetLanguageDisplayName(language!);
+ Assert.Null(result);
+ }
+
[Fact]
public async Task GetParentalRatings_Default_Success()
{
diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs
new file mode 100644
index 0000000000..32685556b2
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs
@@ -0,0 +1,156 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using MediaBrowser.Controller.SyncPlay.Queue;
+using MediaBrowser.Model.SyncPlay;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.SyncPlay;
+
+public class PlayQueueManagerTests
+{
+ private static PlayQueueManager CreateQueue(int itemCount)
+ {
+ var items = Enumerable.Range(0, itemCount).Select(_ => Guid.NewGuid()).ToList();
+ var queue = new PlayQueueManager();
+ queue.SetPlaylist(items);
+ return queue;
+ }
+
+ [Fact]
+ public void RemoveFromPlaylist_PlayingItemAndPrecedingItemRemoved_PicksPreviousItem()
+ {
+ var queue = CreateQueue(5);
+ queue.SetPlayingItemByIndex(3);
+
+ var playlist = queue.GetPlaylist();
+ var expectedItemId = playlist[2].ItemId;
+ var toRemove = new List<Guid> { playlist[0].PlaylistItemId, playlist[3].PlaylistItemId };
+ var playingItemRemoved = queue.RemoveFromPlaylist(toRemove);
+
+ Assert.True(playingItemRemoved);
+ Assert.Equal(3, queue.GetPlaylist().Count);
+ Assert.Equal(1, queue.PlayingItemIndex);
+ Assert.Equal(expectedItemId, queue.GetPlayingItemId());
+ }
+
+ [Fact]
+ public void RemoveFromPlaylist_PlayingItemAndAllPrecedingItemsRemoved_PicksFirstRemainingItem()
+ {
+ var queue = CreateQueue(3);
+ queue.SetPlayingItemByIndex(2);
+
+ var playlist = queue.GetPlaylist();
+ var expectedItemId = playlist[1].ItemId;
+ var toRemove = new List<Guid> { playlist[0].PlaylistItemId, playlist[2].PlaylistItemId };
+ var playingItemRemoved = queue.RemoveFromPlaylist(toRemove);
+
+ Assert.True(playingItemRemoved);
+ Assert.Single(queue.GetPlaylist());
+ Assert.Equal(0, queue.PlayingItemIndex);
+ Assert.Equal(expectedItemId, queue.GetPlayingItemId());
+ }
+
+ [Fact]
+ public void RemoveFromPlaylist_AllItemsRemoved_ResetsPlayingItem()
+ {
+ var queue = CreateQueue(2);
+ queue.SetPlayingItemByIndex(1);
+
+ var toRemove = queue.GetPlaylist().Select(item => item.PlaylistItemId).ToList();
+ var playingItemRemoved = queue.RemoveFromPlaylist(toRemove);
+
+ Assert.True(playingItemRemoved);
+ Assert.Empty(queue.GetPlaylist());
+ Assert.False(queue.IsItemPlaying());
+ Assert.Equal(Guid.Empty, queue.GetPlayingItemPlaylistId());
+ }
+
+ [Fact]
+ public void RemoveFromPlaylist_ShuffleMode_PicksPreviousItem()
+ {
+ var queue = CreateQueue(5);
+ queue.SetShuffleMode(GroupShuffleMode.Shuffle);
+ queue.SetPlayingItemByIndex(3);
+
+ var playlist = queue.GetPlaylist();
+ var expectedItemId = playlist[2].ItemId;
+ var toRemove = new List<Guid> { playlist[0].PlaylistItemId, playlist[3].PlaylistItemId };
+ var playingItemRemoved = queue.RemoveFromPlaylist(toRemove);
+
+ Assert.True(playingItemRemoved);
+ Assert.Equal(3, queue.GetPlaylist().Count);
+ Assert.Equal(1, queue.PlayingItemIndex);
+ Assert.Equal(expectedItemId, queue.GetPlayingItemId());
+ }
+
+ [Fact]
+ public void RemoveFromPlaylist_PlayingItemNotRemoved_RestoresPlayingItem()
+ {
+ var queue = CreateQueue(3);
+ queue.SetPlayingItemByIndex(2);
+
+ var playlist = queue.GetPlaylist();
+ var expectedItemId = playlist[2].ItemId;
+ var toRemove = new List<Guid> { playlist[0].PlaylistItemId };
+ var playingItemRemoved = queue.RemoveFromPlaylist(toRemove);
+
+ Assert.False(playingItemRemoved);
+ Assert.Equal(1, queue.PlayingItemIndex);
+ Assert.Equal(expectedItemId, queue.GetPlayingItemId());
+ }
+
+ [Theory]
+ [InlineData(GroupRepeatMode.RepeatNone)]
+ [InlineData(GroupRepeatMode.RepeatOne)]
+ [InlineData(GroupRepeatMode.RepeatAll)]
+ public void Next_EmptyPlaylist_ReturnsFalse(GroupRepeatMode repeatMode)
+ {
+ var queue = new PlayQueueManager();
+ queue.SetRepeatMode(repeatMode);
+
+ Assert.False(queue.Next());
+ Assert.False(queue.IsItemPlaying());
+ Assert.Equal(Guid.Empty, queue.GetPlayingItemPlaylistId());
+ }
+
+ [Theory]
+ [InlineData(GroupRepeatMode.RepeatNone)]
+ [InlineData(GroupRepeatMode.RepeatOne)]
+ [InlineData(GroupRepeatMode.RepeatAll)]
+ public void Previous_EmptyPlaylist_ReturnsFalse(GroupRepeatMode repeatMode)
+ {
+ var queue = new PlayQueueManager();
+ queue.SetRepeatMode(repeatMode);
+
+ Assert.False(queue.Previous());
+ Assert.False(queue.IsItemPlaying());
+ Assert.Equal(Guid.Empty, queue.GetPlayingItemPlaylistId());
+ }
+
+ [Theory]
+ [InlineData(-1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ public void SetPlayingItemByIndex_OutOfBounds_ResetsPlayingItem(int playlistIndex)
+ {
+ var queue = CreateQueue(2);
+
+ queue.SetPlayingItemByIndex(playlistIndex);
+
+ Assert.False(queue.IsItemPlaying());
+ Assert.Equal(Guid.Empty, queue.GetPlayingItemPlaylistId());
+ }
+
+ [Fact]
+ public void SetPlayingItemByIndex_InBounds_SetsPlayingItem()
+ {
+ var queue = CreateQueue(2);
+ var expectedItemId = queue.GetPlaylist()[1].ItemId;
+
+ queue.SetPlayingItemByIndex(1);
+
+ Assert.True(queue.IsItemPlaying());
+ Assert.Equal(expectedItemId, queue.GetPlayingItemId());
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerProfileImageTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerProfileImageTests.cs
new file mode 100644
index 0000000000..cb714a4014
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerProfileImageTests.cs
@@ -0,0 +1,142 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Locking;
+using Jellyfin.Database.Providers.Sqlite;
+using Jellyfin.Server.Implementations.Users;
+using MediaBrowser.Common;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Authentication;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Cryptography;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Users
+{
+ public sealed class UserManagerProfileImageTests : IDisposable
+ {
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
+ private readonly UserManager _userManager;
+
+ public UserManagerProfileImageTests()
+ {
+ _connection = new SqliteConnection("Data Source=:memory:");
+ _connection.Open();
+
+ _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
+ .UseSqlite(_connection)
+ .Options;
+
+ // Create the schema
+ using var ctx = CreateDbContext();
+ ctx.Database.EnsureCreated();
+
+ var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
+ factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
+ factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
+ .ReturnsAsync(CreateDbContext);
+
+ var cryptoProvider = new Mock<ICryptoProvider>();
+ var configManager = new Mock<IServerConfigurationManager>();
+ var appPaths = new Mock<IServerApplicationPaths>();
+ appPaths.Setup(x => x.ProgramDataPath).Returns(Path.GetTempPath());
+ configManager.Setup(x => x.ApplicationPaths).Returns(appPaths.Object);
+
+ var appHost = new Mock<IApplicationHost>();
+
+ var defaultAuthProvider = new DefaultAuthenticationProvider(
+ NullLogger<DefaultAuthenticationProvider>.Instance,
+ cryptoProvider.Object);
+ var invalidAuthProvider = new InvalidAuthProvider();
+ var defaultPasswordResetProvider = new DefaultPasswordResetProvider(
+ configManager.Object,
+ appHost.Object);
+
+ _userManager = new UserManager(
+ factory.Object,
+ new NoopEventManager(),
+ new Mock<INetworkManager>().Object,
+ appHost.Object,
+ new Mock<IImageProcessor>().Object,
+ NullLogger<UserManager>.Instance,
+ configManager.Object,
+ new IPasswordResetProvider[] { defaultPasswordResetProvider },
+ new IAuthenticationProvider[] { defaultAuthProvider, invalidAuthProvider });
+ }
+
+ public void Dispose()
+ {
+ _userManager.Dispose();
+ _connection.Dispose();
+ }
+
+ private JellyfinDbContext CreateDbContext()
+ {
+ return new JellyfinDbContext(
+ _dbOptions,
+ NullLogger<JellyfinDbContext>.Instance,
+ new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
+ new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
+ }
+
+ [Fact]
+ public async Task ClearProfileImageAsync_WhenInMemoryImageHasTemporaryKey_RemovesPersistedImage()
+ {
+ var user = await _userManager.CreateUserAsync("profileimageuser");
+
+ // Assign a profile image the same way the image endpoint does and persist it.
+ // UpdateUserAsync creates the persisted ImageInfo on a separately loaded db entity,
+ // so the in-memory instance below is never assigned the database generated key.
+ user.ProfileImage = new ImageInfo(Path.Combine(Path.GetTempPath(), "profile.png"));
+ await _userManager.UpdateUserAsync(user);
+
+ // Precondition reproducing the bug: the in-memory image still carries the default,
+ // never-persisted (temporary) key, while a real image row exists in the database.
+ Assert.Equal(0, user.ProfileImage.Id);
+ Assert.NotNull(_userManager.GetUserById(user.Id)!.ProfileImage);
+
+ // This used to throw InvalidOperationException:
+ // "The property 'ImageInfo.Id' has a temporary value while attempting to change the entity's state to 'Deleted'."
+ var exception = await Record.ExceptionAsync(() => _userManager.ClearProfileImageAsync(user));
+
+ Assert.Null(exception);
+ Assert.Null(user.ProfileImage);
+ Assert.Null(_userManager.GetUserById(user.Id)!.ProfileImage);
+ }
+
+ [Fact]
+ public async Task ClearProfileImageAsync_WhenNoProfileImage_DoesNothing()
+ {
+ var user = await _userManager.CreateUserAsync("noprofileimageuser");
+
+ var exception = await Record.ExceptionAsync(() => _userManager.ClearProfileImageAsync(user));
+
+ Assert.Null(exception);
+ Assert.Null(user.ProfileImage);
+ }
+
+ private sealed class NoopEventManager : IEventManager
+ {
+ public void Publish<T>(T eventArgs)
+ where T : EventArgs
+ {
+ }
+
+ public Task PublishAsync<T>(T eventArgs)
+ where T : EventArgs
+ => Task.CompletedTask;
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs
index 4cea53bd3d..2bf1d1d05b 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs
@@ -27,6 +27,8 @@ namespace Jellyfin.Server.Implementations.Tests.Users
[InlineData(" thishasaspaceatthestart")]
[InlineData(" thishasaspaceatbothends ")]
[InlineData(" this has a space at both ends and inbetween ")]
+ [InlineData(".")]
+ [InlineData("..")]
public void ThrowIfInvalidUsername_WhenInvalidUsername_ThrowsArgumentException(string username)
{
Assert.Throws<ArgumentException>(() => UserManager.ThrowIfInvalidUsername(username));