aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.config/dotnet-tools.json2
-rw-r--r--.github/CODEOWNERS15
-rw-r--r--.github/ISSUE_TEMPLATE/issue report.yml5
-rw-r--r--.github/workflows/ci-codeql-analysis.yml10
-rw-r--r--.github/workflows/ci-compat.yml8
-rw-r--r--.github/workflows/ci-openapi.yml12
-rw-r--r--.github/workflows/ci-tests.yml6
-rw-r--r--.github/workflows/commands.yml6
-rw-r--r--.github/workflows/issue-template-check.yml4
-rw-r--r--.github/workflows/release-bump-version.yaml4
-rw-r--r--Directory.Packages.props60
-rw-r--r--Emby.Naming/Emby.Naming.csproj2
-rw-r--r--Emby.Naming/TV/SeasonPathParser.cs59
-rw-r--r--Emby.Naming/TV/SeriesResolver.cs21
-rw-r--r--Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs14
-rw-r--r--Emby.Server.Implementations/Chapters/ChapterManager.cs6
-rw-r--r--Emby.Server.Implementations/IO/ManagedFileSystem.cs52
-rw-r--r--Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs91
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs18
-rw-r--r--Emby.Server.Implementations/Library/MediaSourceManager.cs5
-rw-r--r--Emby.Server.Implementations/Library/MusicManager.cs4
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs11
-rw-r--r--Emby.Server.Implementations/Localization/Core/et.json2
-rw-r--r--Emby.Server.Implementations/Localization/Core/gsw.json2
-rw-r--r--Emby.Server.Implementations/Localization/Core/mn.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/pr.json9
-rw-r--r--Emby.Server.Implementations/Localization/Core/pt-PT.json2
-rw-r--r--Emby.Server.Implementations/Localization/Core/vi.json2
-rw-r--r--Emby.Server.Implementations/Localization/Core/zh-HK.json9
-rw-r--r--Emby.Server.Implementations/Playlists/PlaylistManager.cs1
-rw-r--r--Emby.Server.Implementations/Updates/InstallationManager.cs9
-rw-r--r--Jellyfin.Api/Controllers/DynamicHlsController.cs7
-rw-r--r--Jellyfin.Api/Formatters/XmlOutputFormatter.cs25
-rw-r--r--Jellyfin.Data/Jellyfin.Data.csproj2
-rw-r--r--Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs223
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.cs96
-rw-r--r--Jellyfin.Server.Implementations/Item/OrderMapper.cs72
-rw-r--r--Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs7
-rw-r--r--Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs4
-rw-r--r--Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs6
-rw-r--r--Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs40
-rw-r--r--Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs6
-rw-r--r--Jellyfin.Server.Implementations/Users/UserManager.cs27
-rw-r--r--Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs151
-rw-r--r--Jellyfin.Server/Jellyfin.Server.csproj2
-rw-r--r--Jellyfin.Server/Migrations/Routines/DisableLegacyAuthorization.cs32
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs18
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs21
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs18
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs25
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs2
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs19
-rw-r--r--Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs24
-rw-r--r--Jellyfin.Server/Program.cs6
-rw-r--r--Jellyfin.Server/ServerSetupApp/SetupServer.cs2
-rw-r--r--Jellyfin.Server/ServerSetupApp/index.mstemplate.html7
-rw-r--r--Jellyfin.Server/Startup.cs5
-rw-r--r--Jellyfin.Server/wwwroot/api-docs/banner-dark.svg34
-rw-r--r--Jellyfin.Server/wwwroot/api-docs/jellyfin.svg26
-rw-r--r--Jellyfin.Server/wwwroot/api-docs/swagger/custom.css12
-rw-r--r--MediaBrowser.Common/Configuration/IApplicationPaths.cs4
-rw-r--r--MediaBrowser.Common/MediaBrowser.Common.csproj2
-rw-r--r--MediaBrowser.Controller/Authentication/IAuthenticationProvider.cs2
-rw-r--r--MediaBrowser.Controller/Authentication/IPasswordResetProvider.cs5
-rw-r--r--MediaBrowser.Controller/Entities/BaseItem.cs12
-rw-r--r--MediaBrowser.Controller/Entities/Folder.cs110
-rw-r--r--MediaBrowser.Controller/IO/FileSystemHelper.cs79
-rw-r--r--MediaBrowser.Controller/MediaBrowser.Controller.csproj2
-rw-r--r--MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs4
-rw-r--r--MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs10
-rw-r--r--MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs16
-rw-r--r--MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs2
-rw-r--r--MediaBrowser.Model/Configuration/ServerConfiguration.cs2
-rw-r--r--MediaBrowser.Model/Dlna/StreamInfo.cs47
-rw-r--r--MediaBrowser.Model/Dto/UserDto.cs9
-rw-r--r--MediaBrowser.Model/MediaBrowser.Model.csproj2
-rw-r--r--MediaBrowser.Model/Users/ForgotPasswordAction.cs4
-rw-r--r--MediaBrowser.Providers/Manager/MetadataService.cs89
-rw-r--r--MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs2
-rw-r--r--MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs2
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs4
-rw-r--r--MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs7
-rw-r--r--SharedVersion.cs4
-rwxr-xr-xbump_version2
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs5
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs8
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs1
-rw-r--r--src/Jellyfin.Extensions/DictionaryExtensions.cs33
-rw-r--r--src/Jellyfin.Extensions/Jellyfin.Extensions.csproj2
-rw-r--r--src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs10
-rw-r--r--tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs27
-rw-r--r--tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs1
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/OrderMapperTests.cs2
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs15
94 files changed, 1116 insertions, 786 deletions
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index df2b50e26..029a48f6a 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"dotnet-ef": {
- "version": "9.0.10",
+ "version": "9.0.11",
"commands": [
"dotnet-ef"
]
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index e1900d583..ef81678dd 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1,4 +1,11 @@
-# Joshua must review all changes to deployment and build.sh
-.ci/* @joshuaboniface
-deployment/* @joshuaboniface
-build.sh @joshuaboniface
+# Joshua must review all changes to bump_version and any files it touches
+bump_version @joshuaboniface
+.github/ISSUE_TEMPLATE @joshuaboniface
+MediaBrowser.Common/MediaBrowser.Common.csproj @joshuaboniface
+Jellyfin.Data/Jellyfin.Data.csproj @joshuaboniface
+MediaBrowser.Controller/MediaBrowser.Controller.csproj @joshuaboniface
+MediaBrowser.Model/MediaBrowser.Model.csproj @joshuaboniface
+Emby.Naming/Emby.Naming.csproj @joshuaboniface
+src/Jellyfin.Extensions/Jellyfin.Extensions.csproj @joshuaboniface
+# Core must approve all changes within the repo config
+.github/ @jellyfin/core
diff --git a/.github/ISSUE_TEMPLATE/issue report.yml b/.github/ISSUE_TEMPLATE/issue report.yml
index 269968839..2ed8b0512 100644
--- a/.github/ISSUE_TEMPLATE/issue report.yml
+++ b/.github/ISSUE_TEMPLATE/issue report.yml
@@ -87,7 +87,10 @@ body:
label: Jellyfin Server version
description: What version of Jellyfin are you using?
options:
- - 10.10.0+
+ - 10.11.3
+ - 10.11.2
+ - 10.11.1
+ - 10.11.0
- Master
- Unstable
- Older*
diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml
index 53daf0991..2918bd6a7 100644
--- a/.github/workflows/ci-codeql-analysis.yml
+++ b/.github/workflows/ci-codeql-analysis.yml
@@ -20,18 +20,18 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Setup .NET
- uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
+ uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
with:
dotnet-version: '9.0.x'
- name: Initialize CodeQL
- uses: github/codeql-action/init@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
+ uses: github/codeql-action/init@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
with:
languages: ${{ matrix.language }}
queries: +security-extended
- name: Autobuild
- uses: github/codeql-action/autobuild@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
+ uses: github/codeql-action/autobuild@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
+ uses: github/codeql-action/analyze@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
diff --git a/.github/workflows/ci-compat.yml b/.github/workflows/ci-compat.yml
index 065123338..298c21d7f 100644
--- a/.github/workflows/ci-compat.yml
+++ b/.github/workflows/ci-compat.yml
@@ -11,13 +11,13 @@ jobs:
permissions: read-all
steps:
- name: Checkout repository
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: Setup .NET
- uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
+ uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
with:
dotnet-version: '9.0.x'
@@ -40,14 +40,14 @@ jobs:
permissions: read-all
steps:
- name: Checkout repository
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
fetch-depth: 0
- name: Setup .NET
- uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
+ uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
with:
dotnet-version: '9.0.x'
diff --git a/.github/workflows/ci-openapi.yml b/.github/workflows/ci-openapi.yml
index d37602690..64742220a 100644
--- a/.github/workflows/ci-openapi.yml
+++ b/.github/workflows/ci-openapi.yml
@@ -16,12 +16,12 @@ jobs:
permissions: read-all
steps:
- name: Checkout repository
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: Setup .NET
- uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
+ uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
with:
dotnet-version: '9.0.x'
- name: Generate openapi.json
@@ -41,7 +41,7 @@ jobs:
permissions: read-all
steps:
- name: Checkout repository
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -55,7 +55,7 @@ jobs:
ANCESTOR_REF=$(git merge-base upstream/${{ github.base_ref }} origin/$HEAD_REF)
git checkout --progress --force $ANCESTOR_REF
- name: Setup .NET
- uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
+ uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
with:
dotnet-version: '9.0.x'
- name: Generate openapi.json
@@ -172,7 +172,7 @@ jobs:
strip_components: 1
target: "/srv/incoming/openapi/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
- name: Move openapi.json (unstable) into place
- uses: appleboy/ssh-action@2ead5e36573f08b82fbfce1504f1a4b05a647c6f # v1.2.2
+ uses: appleboy/ssh-action@91f3272fc5907f4699dcf59761eb622a07342f5a # v1.2.3
with:
host: "${{ secrets.REPO_HOST }}"
username: "${{ secrets.REPO_USER }}"
@@ -234,7 +234,7 @@ jobs:
strip_components: 1
target: "/srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
- name: Move openapi.json (stable) into place
- uses: appleboy/ssh-action@2ead5e36573f08b82fbfce1504f1a4b05a647c6f # v1.2.2
+ uses: appleboy/ssh-action@91f3272fc5907f4699dcf59761eb622a07342f5a # v1.2.3
with:
host: "${{ secrets.REPO_HOST }}"
username: "${{ secrets.REPO_USER }}"
diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml
index b9fdd456f..b2ac7a56e 100644
--- a/.github/workflows/ci-tests.yml
+++ b/.github/workflows/ci-tests.yml
@@ -20,9 +20,9 @@ jobs:
runs-on: "${{ matrix.os }}"
steps:
- - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- - uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
+ - uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
with:
dotnet-version: ${{ env.SDK_VERSION }}
@@ -35,7 +35,7 @@ jobs:
--verbosity minimal
- name: Merge code coverage results
- uses: danielpalme/ReportGenerator-GitHub-Action@f3c6b3f8a29686284ef7a7cf6dccb79a01d98444 # v5.4.18
+ uses: danielpalme/ReportGenerator-GitHub-Action@dcdfb6e704e87df6b2ed0cf123a6c9f69e364869 # v5.5.0
with:
reports: "**/coverage.cobertura.xml"
targetdir: "merged/"
diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml
index ba12d4747..4ec92d42e 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
token: ${{ secrets.JF_BOT_TOKEN }}
fetch-depth: 0
@@ -40,11 +40,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: pull in script
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
repository: jellyfin/jellyfin-triage-script
- name: install python
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
+ uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.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 b49647d33..50453d112 100644
--- a/.github/workflows/issue-template-check.yml
+++ b/.github/workflows/issue-template-check.yml
@@ -10,11 +10,11 @@ jobs:
issues: write
steps:
- name: pull in script
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
repository: jellyfin/jellyfin-triage-script
- name: install python
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
+ uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: '3.14'
cache: 'pip'
diff --git a/.github/workflows/release-bump-version.yaml b/.github/workflows/release-bump-version.yaml
index ec91744f3..fa4025de5 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
ref: ${{ env.TAG_BRANCH }}
@@ -66,7 +66,7 @@ jobs:
NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }}
steps:
- name: Checkout Repository
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
ref: ${{ env.TAG_BRANCH }}
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 12aab3828..210cbd35d 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -17,7 +17,7 @@
<PackageVersion Include="Diacritics" Version="4.0.17" />
<PackageVersion Include="DiscUtils.Udf" Version="0.16.13" />
<PackageVersion Include="DotNet.Glob" Version="3.1.3" />
- <PackageVersion Include="FsCheck.Xunit" Version="3.3.1" />
+ <PackageVersion Include="FsCheck.Xunit" Version="3.3.2" />
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="8.3.1.1" />
<PackageVersion Include="ICU4N.Transliterator" Version="60.1.0-alpha.356" />
<PackageVersion Include="IDisposableAnalyzers" Version="4.0.8" />
@@ -26,33 +26,33 @@
<PackageVersion Include="libse" Version="4.0.12" />
<PackageVersion Include="LrcParser" Version="2025.623.0" />
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="6.1.0" />
- <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.10" />
- <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.10" />
+ <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.11" />
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="4.14.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
- <PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.10" />
- <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10" />
- <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.10" />
- <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.10" />
- <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.10" />
- <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.10" />
- <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.10" />
- <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.10" />
- <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.10" />
- <PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.10" />
- <PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.10" />
- <PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.10" />
- <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.10" />
- <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.10" />
- <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.10" />
- <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.10" />
- <PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.10" />
- <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />
- <PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.10" />
- <PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.10" />
- <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
+ <PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageVersion Include="MimeTypes" Version="2.5.2" />
<PackageVersion Include="Morestachio" Version="5.0.1.631" />
<PackageVersion Include="Moq" Version="4.18.4" />
@@ -62,13 +62,13 @@
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
<PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" />
<PackageVersion Include="prometheus-net" Version="8.2.1" />
- <PackageVersion Include="Polly" Version="8.6.4" />
+ <PackageVersion Include="Polly" Version="8.6.5" />
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
<PackageVersion Include="Serilog.Expressions" Version="5.0.0" />
<PackageVersion Include="Serilog.Settings.Configuration" Version="9.0.0" />
<PackageVersion Include="Serilog.Sinks.Async" Version="2.1.0" />
- <PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />
+ <PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
<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" />
@@ -84,11 +84,11 @@
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.2.3" />
<PackageVersion Include="System.Globalization" Version="4.3.0" />
<PackageVersion Include="System.Linq.Async" Version="6.0.3" />
- <PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.10" />
- <PackageVersion Include="System.Text.Json" Version="9.0.10" />
- <PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.10" />
+ <PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.11" />
+ <PackageVersion Include="System.Text.Json" Version="9.0.11" />
+ <PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.11" />
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
- <PackageVersion Include="z440.atl.core" Version="7.6.0" />
+ <PackageVersion Include="z440.atl.core" Version="7.9.0" />
<PackageVersion Include="TMDbLib" Version="2.3.0" />
<PackageVersion Include="UTF.Unknown" Version="2.6.0" />
<PackageVersion Include="Xunit.Priority" Version="1.1.6" />
diff --git a/Emby.Naming/Emby.Naming.csproj b/Emby.Naming/Emby.Naming.csproj
index 20b32f3a6..b84c96116 100644
--- a/Emby.Naming/Emby.Naming.csproj
+++ b/Emby.Naming/Emby.Naming.csproj
@@ -36,7 +36,7 @@
<PropertyGroup>
<Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Naming</PackageId>
- <VersionPrefix>10.11.0</VersionPrefix>
+ <VersionPrefix>10.12.0</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
diff --git a/Emby.Naming/TV/SeasonPathParser.cs b/Emby.Naming/TV/SeasonPathParser.cs
index 98ee1e4b8..72adfb2d9 100644
--- a/Emby.Naming/TV/SeasonPathParser.cs
+++ b/Emby.Naming/TV/SeasonPathParser.cs
@@ -10,12 +10,17 @@ namespace Emby.Naming.TV
/// </summary>
public static partial class SeasonPathParser
{
- [GeneratedRegex(@"^\s*((?<seasonnumber>(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<rightpart>.*)$")]
+ private static readonly Regex CleanNameRegex = new(@"[ ._\-\[\]]", RegexOptions.Compiled);
+
+ [GeneratedRegex(@"^\s*((?<seasonnumber>(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
private static partial Regex ProcessPre();
- [GeneratedRegex(@"^\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<seasonnumber>(?>\d+)(?!\s*[Ee]\d+))(?<rightpart>.*)$")]
+ [GeneratedRegex(@"^\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<seasonnumber>\d+?)(?=\d{3,4}p|[^\d]|$)(?!\s*[Ee]\d)(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
private static partial Regex ProcessPost();
+ [GeneratedRegex(@"[sS](\d{1,4})(?!\d|[eE]\d)(?=\.|_|-|\[|\]|\s|$)", RegexOptions.None)]
+ private static partial Regex SeasonPrefix();
+
/// <summary>
/// Attempts to parse season number from path.
/// </summary>
@@ -56,44 +61,34 @@ namespace Emby.Naming.TV
bool supportSpecialAliases,
bool supportNumericSeasonFolders)
{
- string filename = Path.GetFileName(path);
- filename = Regex.Replace(filename, "[ ._-]", string.Empty);
+ var fileName = Path.GetFileName(path);
- if (parentFolderName is not null)
+ var seasonPrefixMatch = SeasonPrefix().Match(fileName);
+ if (seasonPrefixMatch.Success &&
+ int.TryParse(seasonPrefixMatch.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
{
- parentFolderName = Regex.Replace(parentFolderName, "[ ._-]", string.Empty);
- filename = filename.Replace(parentFolderName, string.Empty, StringComparison.OrdinalIgnoreCase);
+ return (val, true);
}
- if (supportSpecialAliases)
- {
- if (string.Equals(filename, "specials", StringComparison.OrdinalIgnoreCase))
- {
- return (0, true);
- }
+ string filename = CleanNameRegex.Replace(fileName, string.Empty);
- if (string.Equals(filename, "extras", StringComparison.OrdinalIgnoreCase))
- {
- return (0, true);
- }
+ if (parentFolderName is not null)
+ {
+ var cleanParent = CleanNameRegex.Replace(parentFolderName, string.Empty);
+ filename = filename.Replace(cleanParent, string.Empty, StringComparison.OrdinalIgnoreCase);
}
- if (supportNumericSeasonFolders)
+ if (supportSpecialAliases &&
+ (filename.Equals("specials", StringComparison.OrdinalIgnoreCase) ||
+ filename.Equals("extras", StringComparison.OrdinalIgnoreCase)))
{
- if (int.TryParse(filename, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
- {
- return (val, true);
- }
+ return (0, true);
}
- if (filename.StartsWith('s'))
+ if (supportNumericSeasonFolders &&
+ int.TryParse(filename, NumberStyles.Integer, CultureInfo.InvariantCulture, out val))
{
- var testFilename = filename.AsSpan()[1..];
-
- if (int.TryParse(testFilename, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
- {
- return (val, true);
- }
+ return (val, true);
}
var preMatch = ProcessPre().Match(filename);
@@ -113,8 +108,10 @@ namespace Emby.Naming.TV
var numberString = match.Groups["seasonnumber"];
if (numberString.Success)
{
- var seasonNumber = int.Parse(numberString.Value, CultureInfo.InvariantCulture);
- return (seasonNumber, true);
+ if (int.TryParse(numberString.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seasonNumber))
+ {
+ return (seasonNumber, true);
+ }
}
return (null, false);
diff --git a/Emby.Naming/TV/SeriesResolver.cs b/Emby.Naming/TV/SeriesResolver.cs
index c955b8a0d..0b7309bae 100644
--- a/Emby.Naming/TV/SeriesResolver.cs
+++ b/Emby.Naming/TV/SeriesResolver.cs
@@ -18,6 +18,13 @@ namespace Emby.Naming.TV
private static partial Regex SeriesNameRegex();
/// <summary>
+ /// Regex that matches titles with year in parentheses. Captures the title (which may be
+ /// numeric) before the year, i.e. turns "1923 (2022)" into "1923".
+ /// </summary>
+ [GeneratedRegex(@"(?<title>.+?)\s*\(\d{4}\)")]
+ private static partial Regex TitleWithYearRegex();
+
+ /// <summary>
/// Resolve information about series from path.
/// </summary>
/// <param name="options"><see cref="NamingOptions"/> object passed to <see cref="SeriesPathParser"/>.</param>
@@ -27,6 +34,20 @@ namespace Emby.Naming.TV
{
string seriesName = Path.GetFileName(path);
+ // First check if the filename matches a title with year pattern (handles numeric titles)
+ if (!string.IsNullOrEmpty(seriesName))
+ {
+ var titleWithYearMatch = TitleWithYearRegex().Match(seriesName);
+ if (titleWithYearMatch.Success)
+ {
+ seriesName = titleWithYearMatch.Groups["title"].Value.Trim();
+ return new SeriesInfo(path)
+ {
+ Name = seriesName
+ };
+ }
+ }
+
SeriesPathParserResult result = SeriesPathParser.Parse(options, path);
if (result.Success)
{
diff --git a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs
index c69bcfef7..de722332a 100644
--- a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs
+++ b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs
@@ -107,10 +107,20 @@ namespace Emby.Server.Implementations.AppBase
private void CheckOrCreateMarker(string path, string markerName, bool recursive = false)
{
- var otherMarkers = GetMarkers(path, recursive).FirstOrDefault(e => Path.GetFileName(e) != markerName);
+ string? otherMarkers = null;
+ try
+ {
+ otherMarkers = GetMarkers(path, recursive).FirstOrDefault(e => !Path.GetFileName(e.AsSpan()).Equals(markerName, StringComparison.OrdinalIgnoreCase));
+ }
+ catch
+ {
+ // Error while checking for marker files, assume none exist and keep going
+ // TODO: add some logging
+ }
+
if (otherMarkers is not null)
{
- throw new InvalidOperationException($"Exepected to find only {markerName} but found marker for {otherMarkers}.");
+ throw new InvalidOperationException($"Expected to find only {markerName} but found marker for {otherMarkers}.");
}
var markerPath = Path.Combine(path, markerName);
diff --git a/Emby.Server.Implementations/Chapters/ChapterManager.cs b/Emby.Server.Implementations/Chapters/ChapterManager.cs
index fea05931d..d09ed30ae 100644
--- a/Emby.Server.Implementations/Chapters/ChapterManager.cs
+++ b/Emby.Server.Implementations/Chapters/ChapterManager.cs
@@ -223,7 +223,7 @@ public class ChapterManager : IChapterManager
if (saveChapters && changesMade)
{
- _chapterRepository.SaveChapters(video.Id, chapters);
+ SaveChapters(video, chapters);
}
DeleteDeadImages(currentImages, chapters);
@@ -234,7 +234,9 @@ public class ChapterManager : IChapterManager
/// <inheritdoc />
public void SaveChapters(Video video, IReadOnlyList<ChapterInfo> chapters)
{
- _chapterRepository.SaveChapters(video.Id, chapters);
+ // Remove any chapters that are outside of the runtime of the video
+ var validChapters = chapters.Where(c => c.StartPositionTicks < video.RunTimeTicks).ToList();
+ _chapterRepository.SaveChapters(video.Id, validChapters);
}
/// <inheritdoc />
diff --git a/Emby.Server.Implementations/IO/ManagedFileSystem.cs b/Emby.Server.Implementations/IO/ManagedFileSystem.cs
index c9630b894..fad97344b 100644
--- a/Emby.Server.Implementations/IO/ManagedFileSystem.cs
+++ b/Emby.Server.Implementations/IO/ManagedFileSystem.cs
@@ -6,6 +6,7 @@ using System.Linq;
using System.Security;
using Jellyfin.Extensions;
using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging;
@@ -152,6 +153,10 @@ namespace Emby.Server.Implementations.IO
/// <inheritdoc />
public void MoveDirectory(string source, string destination)
{
+ // Make sure parent directory of target exists
+ var parent = Directory.GetParent(destination);
+ parent?.Create();
+
try
{
Directory.Move(source, destination);
@@ -248,47 +253,40 @@ namespace Emby.Server.Implementations.IO
{
result.IsDirectory = info is DirectoryInfo || (info.Attributes & FileAttributes.Directory) == FileAttributes.Directory;
- // if (!result.IsDirectory)
- // {
- // result.IsHidden = (info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden;
- // }
-
if (info is FileInfo fileInfo)
{
- result.Length = fileInfo.Length;
-
- // Issue #2354 get the size of files behind symbolic links. Also Enum.HasFlag is bad as it boxes!
- if ((fileInfo.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint)
+ result.CreationTimeUtc = GetCreationTimeUtc(info);
+ result.LastWriteTimeUtc = GetLastWriteTimeUtc(info);
+ if (fileInfo.LinkTarget is not null)
{
try
{
- using (var fileHandle = File.OpenHandle(fileInfo.FullName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
+ var targetFileInfo = FileSystemHelper.ResolveLinkTarget(fileInfo, returnFinalTarget: true);
+ if (targetFileInfo is not null)
{
- result.Length = RandomAccess.GetLength(fileHandle);
+ result.Exists = targetFileInfo.Exists;
+ if (result.Exists)
+ {
+ result.Length = targetFileInfo.Length;
+ result.CreationTimeUtc = GetCreationTimeUtc(targetFileInfo);
+ result.LastWriteTimeUtc = GetLastWriteTimeUtc(targetFileInfo);
+ }
+ }
+ else
+ {
+ result.Exists = false;
}
- }
- catch (FileNotFoundException ex)
- {
- // Dangling symlinks cannot be detected before opening the file unfortunately...
- _logger.LogError(ex, "Reading the file size of the symlink at {Path} failed. Marking the file as not existing.", fileInfo.FullName);
- result.Exists = false;
}
catch (UnauthorizedAccessException ex)
{
_logger.LogError(ex, "Reading the file at {Path} failed due to a permissions exception.", fileInfo.FullName);
}
- catch (IOException ex)
- {
- // IOException generally means the file is not accessible due to filesystem issues
- // Catch this exception and mark the file as not exist to ignore it
- _logger.LogError(ex, "Reading the file at {Path} failed due to an IO Exception. Marking the file as not existing", fileInfo.FullName);
- result.Exists = false;
- }
+ }
+ else
+ {
+ result.Length = fileInfo.Length;
}
}
-
- result.CreationTimeUtc = GetCreationTimeUtc(info);
- result.LastWriteTimeUtc = GetLastWriteTimeUtc(info);
}
else
{
diff --git a/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs b/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs
index bafe3ad43..473ff8e1d 100644
--- a/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs
+++ b/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs
@@ -1,6 +1,7 @@
using System;
using System.IO;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Resolvers;
using MediaBrowser.Model.IO;
@@ -11,28 +12,24 @@ namespace Emby.Server.Implementations.Library;
/// </summary>
public class DotIgnoreIgnoreRule : IResolverIgnoreRule
{
+ private static readonly bool IsWindows = OperatingSystem.IsWindows();
+
private static FileInfo? FindIgnoreFile(DirectoryInfo directory)
{
- var ignoreFile = new FileInfo(Path.Join(directory.FullName, ".ignore"));
- if (ignoreFile.Exists)
- {
- return ignoreFile;
- }
-
- var parentDir = directory.Parent;
- if (parentDir is null)
+ for (var current = directory; current is not null; current = current.Parent)
{
- return null;
+ var ignorePath = Path.Join(current.FullName, ".ignore");
+ if (File.Exists(ignorePath))
+ {
+ return new FileInfo(ignorePath);
+ }
}
- return FindIgnoreFile(parentDir);
+ return null;
}
/// <inheritdoc />
- public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem? parent)
- {
- return IsIgnored(fileInfo, parent);
- }
+ public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem? parent) => IsIgnored(fileInfo, parent);
/// <summary>
/// Checks whether or not the file is ignored.
@@ -42,60 +39,58 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule
/// <returns>True if the file should be ignored.</returns>
public static bool IsIgnored(FileSystemMetadata fileInfo, BaseItem? parent)
{
- if (fileInfo.IsDirectory)
- {
- var dirIgnoreFile = FindIgnoreFile(new DirectoryInfo(fileInfo.FullName));
- if (dirIgnoreFile is null)
- {
- return false;
- }
-
- // Fast path in case the ignore files isn't a symlink and is empty
- if ((dirIgnoreFile.Attributes & FileAttributes.ReparsePoint) == 0
- && dirIgnoreFile.Length == 0)
- {
- return true;
- }
-
- // ignore the directory only if the .ignore file is empty
- // evaluate individual files otherwise
- return string.IsNullOrWhiteSpace(GetFileContent(dirIgnoreFile));
- }
+ var searchDirectory = fileInfo.IsDirectory
+ ? new DirectoryInfo(fileInfo.FullName)
+ : new DirectoryInfo(Path.GetDirectoryName(fileInfo.FullName) ?? string.Empty);
- var parentDirPath = Path.GetDirectoryName(fileInfo.FullName);
- if (string.IsNullOrEmpty(parentDirPath))
+ if (string.IsNullOrEmpty(searchDirectory.FullName))
{
return false;
}
- var folder = new DirectoryInfo(parentDirPath);
- var ignoreFile = FindIgnoreFile(folder);
+ var ignoreFile = FindIgnoreFile(searchDirectory);
if (ignoreFile is null)
{
return false;
}
- string ignoreFileString = GetFileContent(ignoreFile);
-
- if (string.IsNullOrWhiteSpace(ignoreFileString))
+ // Fast path in case the ignore files isn't a symlink and is empty
+ if (ignoreFile.LinkTarget is null && ignoreFile.Length == 0)
{
// Ignore directory if we just have the file
return true;
}
+ var content = GetFileContent(ignoreFile);
+ return string.IsNullOrWhiteSpace(content)
+ || CheckIgnoreRules(fileInfo.FullName, content, fileInfo.IsDirectory);
+ }
+
+ private static bool CheckIgnoreRules(string path, string ignoreFileContent, bool isDirectory)
+ {
// If file has content, base ignoring off the content .gitignore-style rules
- var ignoreRules = ignoreFileString.Split('\n', StringSplitOptions.RemoveEmptyEntries);
+ var rules = ignoreFileContent.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var ignore = new Ignore.Ignore();
- ignore.Add(ignoreRules);
+ ignore.Add(rules);
- return ignore.IsIgnored(fileInfo.FullName);
- }
+ // Mitigate the problem of the Ignore library not handling Windows paths correctly.
+ // See https://github.com/jellyfin/jellyfin/issues/15484
+ var pathToCheck = IsWindows ? path.NormalizePath('/') : path;
- private static string GetFileContent(FileInfo dirIgnoreFile)
- {
- using (var reader = dirIgnoreFile.OpenText())
+ // Add trailing slash for directories to match "folder/"
+ if (isDirectory)
{
- return reader.ReadToEnd();
+ pathToCheck = string.Concat(pathToCheck.AsSpan().TrimEnd('/'), "/");
}
+
+ return ignore.IsIgnored(pathToCheck);
+ }
+
+ private static string GetFileContent(FileInfo ignoreFile)
+ {
+ ignoreFile = FileSystemHelper.ResolveLinkTarget(ignoreFile, returnFinalTarget: true) ?? ignoreFile;
+ return ignoreFile.Exists
+ ? File.ReadAllText(ignoreFile.FullName)
+ : string.Empty;
}
}
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index a400cb092..cab87e53d 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -457,6 +457,12 @@ namespace Emby.Server.Implementations.Library
_cache.TryRemove(child.Id, out _);
}
+ if (parent is Folder folder)
+ {
+ folder.Children = null;
+ folder.UserData = null;
+ }
+
ReportItemRemoved(item, parent);
}
@@ -1993,6 +1999,12 @@ namespace Emby.Server.Implementations.Library
RegisterItem(item);
}
+ if (parent is Folder folder)
+ {
+ folder.Children = null;
+ folder.UserData = null;
+ }
+
if (ItemAdded is not null)
{
foreach (var item in items)
@@ -2150,6 +2162,12 @@ namespace Emby.Server.Implementations.Library
_itemRepository.SaveItems(items, cancellationToken);
+ if (parent is Folder folder)
+ {
+ folder.Children = null;
+ folder.UserData = null;
+ }
+
if (ItemUpdated is not null)
{
foreach (var item in items)
diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs
index 750346169..c667fb060 100644
--- a/Emby.Server.Implementations/Library/MediaSourceManager.cs
+++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs
@@ -226,6 +226,11 @@ namespace Emby.Server.Implementations.Library
/// <inheritdoc />>
public MediaProtocol GetPathProtocol(string path)
{
+ if (string.IsNullOrEmpty(path))
+ {
+ return MediaProtocol.File;
+ }
+
if (path.StartsWith("Rtsp", StringComparison.OrdinalIgnoreCase))
{
return MediaProtocol.Rtsp;
diff --git a/Emby.Server.Implementations/Library/MusicManager.cs b/Emby.Server.Implementations/Library/MusicManager.cs
index e0c8ae371..e19ad3ef6 100644
--- a/Emby.Server.Implementations/Library/MusicManager.cs
+++ b/Emby.Server.Implementations/Library/MusicManager.cs
@@ -28,7 +28,9 @@ namespace Emby.Server.Implementations.Library
public IReadOnlyList<BaseItem> GetInstantMixFromSong(Audio item, User? user, DtoOptions dtoOptions)
{
- return GetInstantMixFromGenres(item.Genres, user, dtoOptions);
+ var instantMixItems = GetInstantMixFromGenres(item.Genres, user, dtoOptions);
+
+ return [item, .. instantMixItems.Where(i => !i.Id.Equals(item.Id))];
}
/// <inheritdoc />
diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs
index 333c8c34b..98e8f5350 100644
--- a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs
+++ b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs
@@ -369,13 +369,16 @@ 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());
- if (!justName.IsEmpty)
+ var tmdbid = justName.GetAttributeValue("tmdbid");
+
+ // If not in a mixed folder and ID not found in folder path, check filename
+ if (string.IsNullOrEmpty(tmdbid) && !item.IsInMixedFolder)
{
- // Check for TMDb id
- var tmdbid = justName.GetAttributeValue("tmdbid");
- item.TrySetProviderId(MetadataProvider.Tmdb, tmdbid);
+ tmdbid = Path.GetFileName(item.Path.AsSpan()).GetAttributeValue("tmdbid");
}
+ item.TrySetProviderId(MetadataProvider.Tmdb, tmdbid);
+
if (!string.IsNullOrEmpty(item.Path))
{
// Check for IMDb id - we use full media path, as we can assume that this will match in any use case (whether id in parent dir or in file name)
diff --git a/Emby.Server.Implementations/Localization/Core/et.json b/Emby.Server.Implementations/Localization/Core/et.json
index a3f9dc2f8..2e692009b 100644
--- a/Emby.Server.Implementations/Localization/Core/et.json
+++ b/Emby.Server.Implementations/Localization/Core/et.json
@@ -137,5 +137,5 @@
"TaskExtractMediaSegmentsDescription": "Eraldab või võtab meediasegmendid MediaSegment'i lubavatest pluginatest.",
"TaskMoveTrickplayImages": "Muuda trickplay piltide asukoht",
"CleanupUserDataTask": "Puhasta kasutajaandmed",
- "CleanupUserDataTaskDescription": "Puhastab kõik kasutajaandmed (vaatamise olek, lemmikute olek jne) meediast, mis pole enam vähemalt 90 päeva saadaval olnud."
+ "CleanupUserDataTaskDescription": "Puhastab kõik kasutajaandmed (vaatamise olek, lemmikute olek jne) meediast, mida pole enam vähemalt 90 päeva saadaval olnud."
}
diff --git a/Emby.Server.Implementations/Localization/Core/gsw.json b/Emby.Server.Implementations/Localization/Core/gsw.json
index f847d83d1..e1ee8cf7c 100644
--- a/Emby.Server.Implementations/Localization/Core/gsw.json
+++ b/Emby.Server.Implementations/Localization/Core/gsw.json
@@ -11,7 +11,7 @@
"Collections": "Sammlungen",
"DeviceOfflineWithName": "{0} wurde getrennt",
"DeviceOnlineWithName": "{0} ist verbunden",
- "FailedLoginAttemptWithUserName": "Fehlgeschlagener Anmeldeversuch von {0}",
+ "FailedLoginAttemptWithUserName": "Fählgschlagene Ameldeversuech vo {0}",
"Favorites": "Favorite",
"Folders": "Ordner",
"Genres": "Genre",
diff --git a/Emby.Server.Implementations/Localization/Core/mn.json b/Emby.Server.Implementations/Localization/Core/mn.json
index 9202b65de..a684ff204 100644
--- a/Emby.Server.Implementations/Localization/Core/mn.json
+++ b/Emby.Server.Implementations/Localization/Core/mn.json
@@ -3,7 +3,7 @@
"HeaderNextUp": "Дараа нь",
"HeaderContinueWatching": "Үргэлжлүүлэн үзэх",
"Songs": "Дуунууд",
- "Playlists": "Playlist-ууд",
+ "Playlists": "Тоглуулах жагсаалтууд",
"Movies": "Кинонууд",
"Latest": "Сүүлийн үеийн",
"Genres": "Төрлүүд",
@@ -54,7 +54,7 @@
"TaskDownloadMissingSubtitles": "Алга болсон хадмал орчуулгыг татах",
"External": "Гадны",
"HeaderFavoriteArtists": "Дуртай уран бүтээлчид",
- "HeaderFavoriteEpisodes": "Хайртай Ангиуд",
+ "HeaderFavoriteEpisodes": "Дуртай ангиуд",
"HeaderFavoriteShows": "Дуртай нэвтрүүлэг",
"HeaderFavoriteSongs": "Дуртай дуу",
"AppDeviceValues": "Aпп: {0}, Төхөөрөмж: {1}",
@@ -109,7 +109,7 @@
"ScheduledTaskStartedWithName": "{0}-г эхлүүлэв",
"ServerNameNeedsToBeRestarted": "{0}-г дахин асаана уу",
"Shows": "Шоу",
- "Sync": "Дахин",
+ "Sync": "Синхрончлох",
"System": "Систем",
"TvShows": "ТВ нэвтрүүлгүүд",
"Undefined": "Танисангүй",
diff --git a/Emby.Server.Implementations/Localization/Core/pr.json b/Emby.Server.Implementations/Localization/Core/pr.json
index 9076b9c87..fee7e65f1 100644
--- a/Emby.Server.Implementations/Localization/Core/pr.json
+++ b/Emby.Server.Implementations/Localization/Core/pr.json
@@ -16,7 +16,7 @@
"Collections": "Barrels",
"ItemAddedWithName": "{0} is now with yer treasure",
"Default": "Normal-like",
- "FailedLoginAttemptWithUserName": "Ye failed to get in, try from {0}",
+ "FailedLoginAttemptWithUserName": "Ye failed to enter from {0}",
"Favorites": "Finest Loot",
"ItemRemovedWithName": "{0} was taken from yer treasure",
"LabelIpAddressValue": "Ship's coordinates: {0}",
@@ -113,5 +113,10 @@
"TaskCleanCache": "Sweep the Cache Chest",
"TaskRefreshChapterImages": "Claim chapter portraits",
"TaskRefreshChapterImagesDescription": "Paints wee portraits fer videos that own chapters.",
- "TaskRefreshLibrary": "Scan the Treasure Trove"
+ "TaskRefreshLibrary": "Scan the Treasure Trove",
+ "TasksChannelsCategory": "Channels o' thy Internet",
+ "TaskRefreshTrickplayImages": "Summon the picture tricks",
+ "TaskRefreshTrickplayImagesDescription": "Summons picture trick previews for videos in ye enabled book roost",
+ "TaskUpdatePlugins": "Resummon yer Plugins",
+ "TaskCleanTranscode": "Swab Ye Transcode Directory"
}
diff --git a/Emby.Server.Implementations/Localization/Core/pt-PT.json b/Emby.Server.Implementations/Localization/Core/pt-PT.json
index f188822d6..c3eba362d 100644
--- a/Emby.Server.Implementations/Localization/Core/pt-PT.json
+++ b/Emby.Server.Implementations/Localization/Core/pt-PT.json
@@ -5,7 +5,7 @@
"Artists": "Artistas",
"AuthenticationSucceededWithUserName": "{0} autenticado com sucesso",
"Books": "Livros",
- "CameraImageUploadedFrom": "Uma nova imagem de câmara foi enviada a partir de {0}",
+ "CameraImageUploadedFrom": "Uma nova imagem da câmara foi enviada a partir de {0}",
"Channels": "Canais",
"ChapterNameValue": "Capítulo {0}",
"Collections": "Coleções",
diff --git a/Emby.Server.Implementations/Localization/Core/vi.json b/Emby.Server.Implementations/Localization/Core/vi.json
index d1c5166cb..3f4bf1f7f 100644
--- a/Emby.Server.Implementations/Localization/Core/vi.json
+++ b/Emby.Server.Implementations/Localization/Core/vi.json
@@ -39,7 +39,7 @@
"TasksMaintenanceCategory": "Bảo Trì",
"VersionNumber": "Phiên Bản {0}",
"ValueHasBeenAddedToLibrary": "{0} đã được thêm vào thư viện của bạn",
- "UserStoppedPlayingItemWithValues": "{0} đã phát xong {1} trên {2}",
+ "UserStoppedPlayingItemWithValues": "{0} đã kết thúc phát {1} trên {2}",
"UserStartedPlayingItemWithValues": "{0} đang phát {1} trên {2}",
"UserPolicyUpdatedWithName": "Chính sách người dùng đã được cập nhật cho {0}",
"UserPasswordChangedWithName": "Mật khẩu đã được thay đổi cho người dùng {0}",
diff --git a/Emby.Server.Implementations/Localization/Core/zh-HK.json b/Emby.Server.Implementations/Localization/Core/zh-HK.json
index 39141d841..c8800e256 100644
--- a/Emby.Server.Implementations/Localization/Core/zh-HK.json
+++ b/Emby.Server.Implementations/Localization/Core/zh-HK.json
@@ -23,7 +23,7 @@
"HeaderFavoriteShows": "最愛的節目",
"HeaderFavoriteSongs": "最愛的歌曲",
"HeaderLiveTV": "電視直播",
- "HeaderNextUp": "接著播放",
+ "HeaderNextUp": "繼續觀看",
"HeaderRecordingGroups": "錄製組",
"HomeVideos": "家庭影片",
"Inherit": "繼承",
@@ -127,8 +127,8 @@
"HearingImpaired": "聽力障礙",
"TaskRefreshTrickplayImages": "建立 Trickplay 圖像",
"TaskRefreshTrickplayImagesDescription": "為已啟用 Trickplay 的媒體庫內的影片建立 Trickplay 預覽圖。",
- "TaskExtractMediaSegments": "掃描媒體段落",
- "TaskExtractMediaSegmentsDescription": "從MediaSegment中被允許的插件獲取媒體段落。",
+ "TaskExtractMediaSegments": "掃描媒體分段資訊",
+ "TaskExtractMediaSegmentsDescription": "從允許MediaSegment 功能的插件中獲取媒體片段。",
"TaskDownloadMissingLyrics": "下載欠缺歌詞",
"TaskDownloadMissingLyricsDescription": "下載歌詞",
"TaskCleanCollectionsAndPlaylists": "整理媒體與播放清單",
@@ -137,5 +137,6 @@
"TaskCleanCollectionsAndPlaylistsDescription": "從資料庫及播放清單中移除已不存在的項目。",
"TaskMoveTrickplayImagesDescription": "根據媒體庫設定移動現有的 Trickplay 檔案。",
"TaskMoveTrickplayImages": "轉移 Trickplay 影像位置",
- "CleanupUserDataTask": "用戶資料清理工作"
+ "CleanupUserDataTask": "用戶資料清理工作",
+ "CleanupUserDataTaskDescription": "從用戶數據中清除已經被刪除超過 90 日的媒體相關資料。"
}
diff --git a/Emby.Server.Implementations/Playlists/PlaylistManager.cs b/Emby.Server.Implementations/Playlists/PlaylistManager.cs
index c9d76df0b..1577c5c9c 100644
--- a/Emby.Server.Implementations/Playlists/PlaylistManager.cs
+++ b/Emby.Server.Implementations/Playlists/PlaylistManager.cs
@@ -244,6 +244,7 @@ namespace Emby.Server.Implementations.Playlists
// Update the playlist in the repository
playlist.LinkedChildren = [.. playlist.LinkedChildren, .. childrenToAdd];
+ playlist.DateLastMediaAdded = DateTime.UtcNow;
await UpdatePlaylistInternal(playlist).ConfigureAwait(false);
diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs
index 678475b31..5ff400160 100644
--- a/Emby.Server.Implementations/Updates/InstallationManager.cs
+++ b/Emby.Server.Implementations/Updates/InstallationManager.cs
@@ -223,15 +223,14 @@ namespace Emby.Server.Implementations.Updates
Guid id = default,
Version? specificVersion = null)
{
- if (name is not null)
- {
- availablePackages = availablePackages.Where(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
- }
-
if (!id.IsEmpty())
{
availablePackages = availablePackages.Where(x => x.Id.Equals(id));
}
+ else if (name is not null)
+ {
+ availablePackages = availablePackages.Where(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
+ }
if (specificVersion is not null)
{
diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs
index 2614fe995..fe6f855b5 100644
--- a/Jellyfin.Api/Controllers/DynamicHlsController.cs
+++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs
@@ -1625,8 +1625,11 @@ public class DynamicHlsController : BaseJellyfinApiController
var useLegacySegmentOption = _mediaEncoder.EncoderVersion < _minFFmpegHlsSegmentOptions;
- // fMP4 needs this flag to write the audio packet DTS/PTS including the initial delay into MOOF::TRAF::TFDT
- hlsArguments += $" {(useLegacySegmentOption ? "-hls_ts_options" : "-hls_segment_options")} movflags=+frag_discont";
+ if (state.VideoStream is not null && state.IsOutputVideo)
+ {
+ // fMP4 needs this flag to write the audio packet DTS/PTS including the initial delay into MOOF::TRAF::TFDT
+ hlsArguments += $" {(useLegacySegmentOption ? "-hls_ts_options" : "-hls_segment_options")} movflags=+frag_discont";
+ }
segmentFormat = "fmp4" + outputFmp4HeaderArg;
}
diff --git a/Jellyfin.Api/Formatters/XmlOutputFormatter.cs b/Jellyfin.Api/Formatters/XmlOutputFormatter.cs
index 8dbb91d0a..46256c09d 100644
--- a/Jellyfin.Api/Formatters/XmlOutputFormatter.cs
+++ b/Jellyfin.Api/Formatters/XmlOutputFormatter.cs
@@ -1,4 +1,8 @@
+using System;
using System.Net.Mime;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Formatters;
namespace Jellyfin.Api.Formatters;
@@ -6,7 +10,7 @@ namespace Jellyfin.Api.Formatters;
/// <summary>
/// Xml output formatter.
/// </summary>
-public sealed class XmlOutputFormatter : StringOutputFormatter
+public sealed class XmlOutputFormatter : TextOutputFormatter
{
/// <summary>
/// Initializes a new instance of the <see cref="XmlOutputFormatter"/> class.
@@ -15,5 +19,24 @@ public sealed class XmlOutputFormatter : StringOutputFormatter
{
SupportedMediaTypes.Clear();
SupportedMediaTypes.Add(MediaTypeNames.Text.Xml);
+
+ SupportedEncodings.Add(Encoding.UTF8);
+ SupportedEncodings.Add(Encoding.Unicode);
+ }
+
+ /// <inheritdoc />
+ public override async Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ ArgumentNullException.ThrowIfNull(selectedEncoding);
+
+ var valueAsString = context.Object?.ToString();
+ if (string.IsNullOrEmpty(valueAsString))
+ {
+ return;
+ }
+
+ var response = context.HttpContext.Response;
+ await response.WriteAsync(valueAsString, selectedEncoding).ConfigureAwait(false);
}
}
diff --git a/Jellyfin.Data/Jellyfin.Data.csproj b/Jellyfin.Data/Jellyfin.Data.csproj
index 45374c22f..fd852ece9 100644
--- a/Jellyfin.Data/Jellyfin.Data.csproj
+++ b/Jellyfin.Data/Jellyfin.Data.csproj
@@ -18,7 +18,7 @@
<PropertyGroup>
<Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Data</PackageId>
- <VersionPrefix>10.11.0</VersionPrefix>
+ <VersionPrefix>10.12.0</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs
index e5c3cef3d..70483c36c 100644
--- a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs
+++ b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs
@@ -128,7 +128,8 @@ public class BackupService : IBackupService
var targetPath = Path.GetFullPath(Path.Combine(target, Path.GetRelativePath(source, item.FullName)));
if (!sourcePath.StartsWith(fullSourcePath, StringComparison.Ordinal)
- || !targetPath.StartsWith(fullTargetRoot, StringComparison.Ordinal))
+ || !targetPath.StartsWith(fullTargetRoot, StringComparison.Ordinal)
+ || Path.EndsInDirectorySeparator(item.FullName))
{
continue;
}
@@ -199,7 +200,7 @@ public class BackupService : IBackupService
var zipEntry = zipArchive.GetEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.Type.Name}.json")));
if (zipEntry is null)
{
- _logger.LogInformation("No backup of expected table {Table} is present in backup. Continue anyway.", entityType.Type.Name);
+ _logger.LogInformation("No backup of expected table {Table} is present in backup, continuing anyway", entityType.Type.Name);
continue;
}
@@ -223,7 +224,7 @@ public class BackupService : IBackupService
}
catch (Exception ex)
{
- _logger.LogError(ex, "Could not store entity {Entity} continue anyway.", item);
+ _logger.LogError(ex, "Could not store entity {Entity}, continuing anyway", item);
}
}
@@ -233,11 +234,11 @@ public class BackupService : IBackupService
_logger.LogInformation("Try restore Database");
await dbContext.SaveChangesAsync().ConfigureAwait(false);
- _logger.LogInformation("Restored database.");
+ _logger.LogInformation("Restored database");
}
}
- _logger.LogInformation("Restored Jellyfin system from {Date}.", manifest.DateCreated);
+ _logger.LogInformation("Restored Jellyfin system from {Date}", manifest.DateCreated);
}
}
@@ -263,6 +264,8 @@ public class BackupService : IBackupService
Options = Map(backupOptions)
};
+ _logger.LogInformation("Running database optimization before backup");
+
await _jellyfinDatabaseProvider.RunScheduledOptimisation(CancellationToken.None).ConfigureAwait(false);
var backupFolder = Path.Combine(_applicationPaths.BackupPath);
@@ -281,130 +284,154 @@ public class BackupService : IBackupService
}
var backupPath = Path.Combine(backupFolder, $"jellyfin-backup-{manifest.DateCreated.ToLocalTime():yyyyMMddHHmmss}.zip");
- _logger.LogInformation("Attempt to create a new backup at {BackupPath}", backupPath);
- var fileStream = File.OpenWrite(backupPath);
- await using (fileStream.ConfigureAwait(false))
- using (var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create, false))
+
+ try
{
- _logger.LogInformation("Start backup process.");
- var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
- await using (dbContext.ConfigureAwait(false))
+ _logger.LogInformation("Attempting to create a new backup at {BackupPath}", backupPath);
+ var fileStream = File.OpenWrite(backupPath);
+ await using (fileStream.ConfigureAwait(false))
+ using (var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create, false))
{
- dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
- static IAsyncEnumerable<object> GetValues(IQueryable dbSet)
+ _logger.LogInformation("Starting backup process");
+ var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
{
- var method = dbSet.GetType().GetMethod(nameof(DbSet<object>.AsAsyncEnumerable))!;
- var enumerable = method.Invoke(dbSet, null)!;
- return (IAsyncEnumerable<object>)enumerable;
- }
+ dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
- // include the migration history as well
- var historyRepository = dbContext.GetService<IHistoryRepository>();
- var migrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
-
- ICollection<(Type Type, string SourceName, Func<IAsyncEnumerable<object>> ValueFactory)> entityTypes = [
- .. typeof(JellyfinDbContext)
- .GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
- .Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable)))
- .Select(e => (Type: e.PropertyType, dbContext.Model.FindEntityType(e.PropertyType.GetGenericArguments()[0])!.GetSchemaQualifiedTableName()!, ValueFactory: new Func<IAsyncEnumerable<object>>(() => GetValues((IQueryable)e.GetValue(dbContext)!)))),
- (Type: typeof(HistoryRow), SourceName: nameof(HistoryRow), ValueFactory: () => migrations.ToAsyncEnumerable())
- ];
- manifest.DatabaseTables = entityTypes.Select(e => e.Type.Name).ToArray();
- var transaction = await dbContext.Database.BeginTransactionAsync().ConfigureAwait(false);
-
- await using (transaction.ConfigureAwait(false))
- {
- _logger.LogInformation("Begin Database backup");
+ static IAsyncEnumerable<object> GetValues(IQueryable dbSet)
+ {
+ var method = dbSet.GetType().GetMethod(nameof(DbSet<object>.AsAsyncEnumerable))!;
+ var enumerable = method.Invoke(dbSet, null)!;
+ return (IAsyncEnumerable<object>)enumerable;
+ }
- foreach (var entityType in entityTypes)
+ // include the migration history as well
+ var historyRepository = dbContext.GetService<IHistoryRepository>();
+ var migrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
+
+ ICollection<(Type Type, string SourceName, Func<IAsyncEnumerable<object>> ValueFactory)> entityTypes =
+ [
+ .. typeof(JellyfinDbContext)
+ .GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
+ .Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable)))
+ .Select(e => (Type: e.PropertyType, dbContext.Model.FindEntityType(e.PropertyType.GetGenericArguments()[0])!.GetSchemaQualifiedTableName()!, ValueFactory: new Func<IAsyncEnumerable<object>>(() => GetValues((IQueryable)e.GetValue(dbContext)!)))),
+ (Type: typeof(HistoryRow), SourceName: nameof(HistoryRow), ValueFactory: () => migrations.ToAsyncEnumerable())
+ ];
+ manifest.DatabaseTables = entityTypes.Select(e => e.Type.Name).ToArray();
+ var transaction = await dbContext.Database.BeginTransactionAsync().ConfigureAwait(false);
+
+ await using (transaction.ConfigureAwait(false))
{
- _logger.LogInformation("Begin backup of entity {Table}", entityType.SourceName);
- var zipEntry = zipArchive.CreateEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.SourceName}.json")));
- var entities = 0;
- var zipEntryStream = zipEntry.Open();
- await using (zipEntryStream.ConfigureAwait(false))
+ _logger.LogInformation("Begin Database backup");
+
+ foreach (var entityType in entityTypes)
{
- var jsonSerializer = new Utf8JsonWriter(zipEntryStream);
- await using (jsonSerializer.ConfigureAwait(false))
+ _logger.LogInformation("Begin backup of entity {Table}", entityType.SourceName);
+ var zipEntry = zipArchive.CreateEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.SourceName}.json")));
+ var entities = 0;
+ var zipEntryStream = zipEntry.Open();
+ await using (zipEntryStream.ConfigureAwait(false))
{
- jsonSerializer.WriteStartArray();
-
- var set = entityType.ValueFactory().ConfigureAwait(false);
- await foreach (var item in set.ConfigureAwait(false))
+ var jsonSerializer = new Utf8JsonWriter(zipEntryStream);
+ await using (jsonSerializer.ConfigureAwait(false))
{
- entities++;
- try
- {
- JsonSerializer.SerializeToDocument(item, _serializerSettings).WriteTo(jsonSerializer);
- }
- catch (Exception ex)
+ jsonSerializer.WriteStartArray();
+
+ var set = entityType.ValueFactory().ConfigureAwait(false);
+ await foreach (var item in set.ConfigureAwait(false))
{
- _logger.LogError(ex, "Could not load entity {Entity}", item);
- throw;
+ 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;
+ }
}
- }
- jsonSerializer.WriteEndArray();
+ jsonSerializer.WriteEndArray();
+ }
}
- }
- _logger.LogInformation("backup of entity {Table} with {Number} created", entityType.Type.Name, entities);
+ _logger.LogInformation("Backup of entity {Table} with {Number} created", entityType.SourceName, entities);
+ }
}
}
- }
- _logger.LogInformation("Backup of folder {Table}", _applicationPaths.ConfigurationDirectoryPath);
- foreach (var item in Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.xml", SearchOption.TopDirectoryOnly)
- .Union(Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.json", SearchOption.TopDirectoryOnly)))
- {
- zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine("Config", Path.GetFileName(item))));
- }
+ _logger.LogInformation("Backup of folder {Table}", _applicationPaths.ConfigurationDirectoryPath);
+ foreach (var item in Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.xml", SearchOption.TopDirectoryOnly)
+ .Union(Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.json", SearchOption.TopDirectoryOnly)))
+ {
+ zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine("Config", Path.GetFileName(item))));
+ }
- void CopyDirectory(string source, string target, string filter = "*")
- {
- if (!Directory.Exists(source))
+ void CopyDirectory(string source, string target, string filter = "*")
{
- return;
+ if (!Directory.Exists(source))
+ {
+ return;
+ }
+
+ _logger.LogInformation("Backup of folder {Table}", source);
+
+ foreach (var item in Directory.EnumerateFiles(source, filter, SearchOption.AllDirectories))
+ {
+ zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine(target, Path.GetRelativePath(source, item))));
+ }
}
- _logger.LogInformation("Backup of folder {Table}", source);
+ CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "users"), Path.Combine("Config", "users"));
+ CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "ScheduledTasks"), Path.Combine("Config", "ScheduledTasks"));
+ CopyDirectory(Path.Combine(_applicationPaths.RootFolderPath), "Root");
+ CopyDirectory(Path.Combine(_applicationPaths.DataPath, "collections"), Path.Combine("Data", "collections"));
+ CopyDirectory(Path.Combine(_applicationPaths.DataPath, "playlists"), Path.Combine("Data", "playlists"));
+ CopyDirectory(Path.Combine(_applicationPaths.DataPath, "ScheduledTasks"), Path.Combine("Data", "ScheduledTasks"));
+ if (backupOptions.Subtitles)
+ {
+ CopyDirectory(Path.Combine(_applicationPaths.DataPath, "subtitles"), Path.Combine("Data", "subtitles"));
+ }
- foreach (var item in Directory.EnumerateFiles(source, filter, SearchOption.AllDirectories))
+ if (backupOptions.Trickplay)
{
- zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine(target, Path.GetRelativePath(source, item))));
+ CopyDirectory(Path.Combine(_applicationPaths.DataPath, "trickplay"), Path.Combine("Data", "trickplay"));
}
- }
- CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "users"), Path.Combine("Config", "users"));
- CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "ScheduledTasks"), Path.Combine("Config", "ScheduledTasks"));
- CopyDirectory(Path.Combine(_applicationPaths.RootFolderPath), "Root");
- CopyDirectory(Path.Combine(_applicationPaths.DataPath, "collections"), Path.Combine("Data", "collections"));
- CopyDirectory(Path.Combine(_applicationPaths.DataPath, "playlists"), Path.Combine("Data", "playlists"));
- CopyDirectory(Path.Combine(_applicationPaths.DataPath, "ScheduledTasks"), Path.Combine("Data", "ScheduledTasks"));
- if (backupOptions.Subtitles)
- {
- CopyDirectory(Path.Combine(_applicationPaths.DataPath, "subtitles"), Path.Combine("Data", "subtitles"));
- }
+ if (backupOptions.Metadata)
+ {
+ CopyDirectory(Path.Combine(_applicationPaths.InternalMetadataPath), Path.Combine("Data", "metadata"));
+ }
- if (backupOptions.Trickplay)
- {
- CopyDirectory(Path.Combine(_applicationPaths.DataPath, "trickplay"), Path.Combine("Data", "trickplay"));
+ var manifestStream = zipArchive.CreateEntry(ManifestEntryName).Open();
+ await using (manifestStream.ConfigureAwait(false))
+ {
+ await JsonSerializer.SerializeAsync(manifestStream, manifest).ConfigureAwait(false);
+ }
}
- if (backupOptions.Metadata)
+ _logger.LogInformation("Backup created");
+ return Map(manifest, backupPath);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to create backup, removing {BackupPath}", backupPath);
+ try
{
- CopyDirectory(Path.Combine(_applicationPaths.InternalMetadataPath), Path.Combine("Data", "metadata"));
+ if (File.Exists(backupPath))
+ {
+ File.Delete(backupPath);
+ }
}
-
- var manifestStream = zipArchive.CreateEntry(ManifestEntryName).Open();
- await using (manifestStream.ConfigureAwait(false))
+ catch (Exception innerEx)
{
- await JsonSerializer.SerializeAsync(manifestStream, manifest).ConfigureAwait(false);
+ _logger.LogWarning(innerEx, "Unable to remove failed backup");
}
- }
- _logger.LogInformation("Backup created");
- return Map(manifest, backupPath);
+ throw;
+ }
}
/// <inheritdoc/>
@@ -422,7 +449,7 @@ public class BackupService : IBackupService
}
catch (Exception ex)
{
- _logger.LogError(ex, "Tried to load archive from {Path} but failed.", archivePath);
+ _logger.LogWarning(ex, "Tried to load manifest from archive {Path} but failed", archivePath);
return null;
}
@@ -459,7 +486,7 @@ public class BackupService : IBackupService
}
catch (Exception ex)
{
- _logger.LogError(ex, "Could not load {BackupArchive} path.", item);
+ _logger.LogWarning(ex, "Tried to load manifest from archive {Path} but failed", item);
}
}
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
index eb88eac00..2c18ce69a 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
@@ -275,6 +275,7 @@ public sealed class BaseItemRepository
}
dbQuery = ApplyQueryPaging(dbQuery, filter);
+ dbQuery = ApplyNavigations(dbQuery, filter);
result.Items = dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
result.StartIndex = filter.StartIndex ?? 0;
@@ -294,6 +295,7 @@ public sealed class BaseItemRepository
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
dbQuery = ApplyQueryPaging(dbQuery, filter);
+ dbQuery = ApplyNavigations(dbQuery, filter);
return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
}
@@ -337,6 +339,8 @@ public sealed class BaseItemRepository
mainquery = ApplyGroupingFilter(context, mainquery, filter);
mainquery = ApplyQueryPaging(mainquery, filter);
+ mainquery = ApplyNavigations(mainquery, filter);
+
return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
}
@@ -399,9 +403,7 @@ public sealed class BaseItemRepository
dbQuery = dbQuery.Distinct();
}
- dbQuery = ApplyOrder(dbQuery, filter);
-
- dbQuery = ApplyNavigations(dbQuery, filter);
+ dbQuery = ApplyOrder(dbQuery, filter, context);
return dbQuery;
}
@@ -446,6 +448,7 @@ public sealed class BaseItemRepository
dbQuery = TranslateQuery(dbQuery, context, filter);
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
dbQuery = ApplyQueryPaging(dbQuery, filter);
+ dbQuery = ApplyNavigations(dbQuery, filter);
return dbQuery;
}
@@ -614,6 +617,13 @@ public sealed class BaseItemRepository
else
{
context.BaseItemProviders.Where(e => e.ItemId == entity.Id).ExecuteDelete();
+ context.BaseItemImageInfos.Where(e => e.ItemId == entity.Id).ExecuteDelete();
+
+ if (entity.Images is { Count: > 0 })
+ {
+ context.BaseItemImageInfos.AddRange(entity.Images);
+ }
+
context.BaseItems.Attach(entity).State = EntityState.Modified;
}
}
@@ -1245,7 +1255,7 @@ public sealed class BaseItemRepository
.AsSingleQuery()
.Where(e => masterQuery.Contains(e.Id));
- query = ApplyOrder(query, filter);
+ query = ApplyOrder(query, filter, context);
var result = new QueryResult<(BaseItemDto, ItemCounts?)>();
if (filter.EnableTotalRecordCount)
@@ -1511,7 +1521,7 @@ public sealed class BaseItemRepository
|| query.IncludeItemTypes.Contains(BaseItemKind.Season);
}
- private IQueryable<BaseItemEntity> ApplyOrder(IQueryable<BaseItemEntity> query, InternalItemsQuery filter)
+ private IQueryable<BaseItemEntity> ApplyOrder(IQueryable<BaseItemEntity> query, InternalItemsQuery filter, JellyfinDbContext context)
{
var orderBy = filter.OrderBy;
var hasSearch = !string.IsNullOrEmpty(filter.SearchTerm);
@@ -1530,7 +1540,7 @@ public sealed class BaseItemRepository
var firstOrdering = orderBy.FirstOrDefault();
if (firstOrdering != default)
{
- var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter);
+ var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter, context);
if (firstOrdering.SortOrder == SortOrder.Ascending)
{
orderedQuery = query.OrderBy(expression);
@@ -1555,7 +1565,7 @@ public sealed class BaseItemRepository
foreach (var item in orderBy.Skip(1))
{
- var expression = OrderMapper.MapOrderByField(item.OrderBy, filter);
+ var expression = OrderMapper.MapOrderByField(item.OrderBy, filter, context);
if (item.SortOrder == SortOrder.Ascending)
{
orderedQuery = orderedQuery!.ThenBy(expression);
@@ -1694,15 +1704,16 @@ public sealed class BaseItemRepository
if (!string.IsNullOrEmpty(filter.SearchTerm))
{
- var searchTerm = filter.SearchTerm.ToLower();
- if (SearchWildcardTerms.Any(f => searchTerm.Contains(f)))
+ var cleanedSearchTerm = GetCleanValue(filter.SearchTerm);
+ var originalSearchTerm = filter.SearchTerm.ToLower();
+ if (SearchWildcardTerms.Any(f => cleanedSearchTerm.Contains(f)))
{
- searchTerm = $"%{searchTerm.Trim('%')}%";
- baseQuery = baseQuery.Where(e => EF.Functions.Like(e.CleanName!.ToLower(), searchTerm) || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle.ToLower(), searchTerm)));
+ cleanedSearchTerm = $"%{cleanedSearchTerm.Trim('%')}%";
+ baseQuery = baseQuery.Where(e => EF.Functions.Like(e.CleanName!, cleanedSearchTerm) || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle.ToLower(), originalSearchTerm)));
}
else
{
- baseQuery = baseQuery.Where(e => e.CleanName!.ToLower().Contains(searchTerm) || (e.OriginalTitle != null && e.OriginalTitle.ToLower().Contains(searchTerm)));
+ baseQuery = baseQuery.Where(e => e.CleanName!.Contains(cleanedSearchTerm) || (e.OriginalTitle != null && e.OriginalTitle.ToLower().Contains(originalSearchTerm)));
}
}
@@ -1756,7 +1767,8 @@ public sealed class BaseItemRepository
if (!string.IsNullOrWhiteSpace(filter.Path))
{
- baseQuery = baseQuery.Where(e => e.Path == filter.Path);
+ var pathToQuery = GetPathToSave(filter.Path);
+ baseQuery = baseQuery.Where(e => e.Path == pathToQuery);
}
if (!string.IsNullOrWhiteSpace(filter.PresentationUniqueKey))
@@ -1936,19 +1948,20 @@ public sealed class BaseItemRepository
if (!string.IsNullOrWhiteSpace(filter.NameStartsWith))
{
- baseQuery = baseQuery.Where(e => e.SortName!.StartsWith(filter.NameStartsWith));
+ var startsWithLower = filter.NameStartsWith.ToLowerInvariant();
+ baseQuery = baseQuery.Where(e => e.SortName!.StartsWith(startsWithLower));
}
if (!string.IsNullOrWhiteSpace(filter.NameStartsWithOrGreater))
{
- // i hate this
- baseQuery = baseQuery.Where(e => e.SortName!.FirstOrDefault() > filter.NameStartsWithOrGreater[0] || e.Name!.FirstOrDefault() > filter.NameStartsWithOrGreater[0]);
+ var startsOrGreaterLower = filter.NameStartsWithOrGreater.ToLowerInvariant();
+ baseQuery = baseQuery.Where(e => e.SortName!.CompareTo(startsOrGreaterLower) >= 0);
}
if (!string.IsNullOrWhiteSpace(filter.NameLessThan))
{
- // i hate this
- baseQuery = baseQuery.Where(e => e.SortName!.FirstOrDefault() < filter.NameLessThan[0] || e.Name!.FirstOrDefault() < filter.NameLessThan[0]);
+ var lessThanLower = filter.NameLessThan.ToLowerInvariant();
+ baseQuery = baseQuery.Where(e => e.SortName!.CompareTo(lessThanLower ) < 0);
}
if (filter.ImageTypes.Length > 0)
@@ -2046,7 +2059,7 @@ public sealed class BaseItemRepository
if (filter.ExcludeArtistIds.Length > 0)
{
- baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Artist, filter.ExcludeArtistIds, true);
+ baseQuery = baseQuery.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ExcludeArtistIds, true);
}
if (filter.GenreIds.Count > 0)
@@ -2353,17 +2366,23 @@ public sealed class BaseItemRepository
if (filter.HasImdbId.HasValue)
{
- baseQuery = baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId == "imdb"));
+ baseQuery = filter.HasImdbId.Value
+ ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Imdb.ToString().ToLower()))
+ : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Imdb.ToString().ToLower()));
}
if (filter.HasTmdbId.HasValue)
{
- baseQuery = baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId == "tmdb"));
+ baseQuery = filter.HasTmdbId.Value
+ ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Tmdb.ToString().ToLower()))
+ : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Tmdb.ToString().ToLower()));
}
if (filter.HasTvdbId.HasValue)
{
- baseQuery = baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId == "tvdb"));
+ baseQuery = filter.HasTvdbId.Value
+ ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Tvdb.ToString().ToLower()))
+ : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Tvdb.ToString().ToLower()));
}
var queryTopParentIds = filter.TopParentIds;
@@ -2401,39 +2420,34 @@ public sealed class BaseItemRepository
if (filter.ExcludeInheritedTags.Length > 0)
{
- baseQuery = baseQuery
- .Where(e => !e.ItemValues!.Where(w => w.ItemValue.Type == ItemValueType.InheritedTags || w.ItemValue.Type == ItemValueType.Tags)
- .Any(f => filter.ExcludeInheritedTags.Contains(f.ItemValue.CleanValue)));
+ baseQuery = baseQuery.Where(e =>
+ !e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && filter.ExcludeInheritedTags.Contains(f.ItemValue.CleanValue))
+ && (e.Type != _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode] || !e.SeriesId.HasValue ||
+ !context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && filter.ExcludeInheritedTags.Contains(f.ItemValue.CleanValue))));
}
if (filter.IncludeInheritedTags.Length > 0)
{
- // Episodes do not store inherit tags from their parents in the database, and the tag may be still required by the client.
- // In addition to the tags for the episodes themselves, we need to manually query its parent (the season)'s tags as well.
- if (includeTypes.Length == 1 && includeTypes.FirstOrDefault() is BaseItemKind.Episode)
+ // For seasons and episodes, we also need to check the parent series' tags.
+ if (includeTypes.Any(t => t == BaseItemKind.Episode || t == BaseItemKind.Season))
{
- baseQuery = baseQuery
- .Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags || f.ItemValue.Type == ItemValueType.Tags)
- .Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))
- ||
- (e.ParentId.HasValue && context.ItemValuesMap.Where(w => w.ItemId == e.ParentId.Value && (w.ItemValue.Type == ItemValueType.InheritedTags || w.ItemValue.Type == ItemValueType.Tags))
- .Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))));
+ baseQuery = baseQuery.Where(e =>
+ e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))
+ || (e.SeriesId.HasValue && context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))));
}
// A playlist should be accessible to its owner regardless of allowed tags.
else if (includeTypes.Length == 1 && includeTypes.FirstOrDefault() is BaseItemKind.Playlist)
{
- baseQuery = baseQuery
- .Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags || f.ItemValue.Type == ItemValueType.Tags)
- .Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))
- || e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\""));
+ baseQuery = baseQuery.Where(e =>
+ e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))
+ || e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\""));
// d ^^ this is stupid it hate this.
}
else
{
- baseQuery = baseQuery
- .Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags || f.ItemValue.Type == ItemValueType.Tags)
- .Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue)));
+ baseQuery = baseQuery.Where(e =>
+ e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue)));
}
}
diff --git a/Jellyfin.Server.Implementations/Item/OrderMapper.cs b/Jellyfin.Server.Implementations/Item/OrderMapper.cs
index a0c127031..192ee7499 100644
--- a/Jellyfin.Server.Implementations/Item/OrderMapper.cs
+++ b/Jellyfin.Server.Implementations/Item/OrderMapper.cs
@@ -1,7 +1,10 @@
+#pragma warning disable RS0030 // Do not use banned APIs
+
using System;
using System.Linq;
using System.Linq.Expressions;
using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
using Microsoft.EntityFrameworkCore;
@@ -18,39 +21,50 @@ public static class OrderMapper
/// </summary>
/// <param name="sortBy">Item property to sort by.</param>
/// <param name="query">Context Query.</param>
+ /// <param name="jellyfinDbContext">Context.</param>
/// <returns>Func to be executed later for sorting query.</returns>
- public static Expression<Func<BaseItemEntity, object?>> MapOrderByField(ItemSortBy sortBy, InternalItemsQuery query)
+ public static Expression<Func<BaseItemEntity, object?>> MapOrderByField(ItemSortBy sortBy, InternalItemsQuery query, JellyfinDbContext jellyfinDbContext)
{
- return sortBy switch
+ return (sortBy, query.User) switch
{
- ItemSortBy.AirTime => e => e.SortName, // TODO
- ItemSortBy.Runtime => e => e.RunTimeTicks,
- ItemSortBy.Random => e => EF.Functions.Random(),
- ItemSortBy.DatePlayed => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.LastPlayedDate,
- ItemSortBy.PlayCount => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.PlayCount,
- ItemSortBy.IsFavoriteOrLiked => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.IsFavorite,
- ItemSortBy.IsFolder => e => e.IsFolder,
- ItemSortBy.IsPlayed => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played,
- ItemSortBy.IsUnplayed => e => !e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played,
- ItemSortBy.DateLastContentAdded => e => e.DateLastMediaAdded,
- ItemSortBy.Artist => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Artist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
- ItemSortBy.AlbumArtist => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.AlbumArtist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
- ItemSortBy.Studio => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Studios).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
- ItemSortBy.OfficialRating => e => e.InheritedParentalRatingValue,
- // ItemSortBy.SeriesDatePlayed => "(Select MAX(LastPlayedDate) from TypedBaseItems B" + GetJoinUserDataText(query) + " where Played=1 and B.SeriesPresentationUniqueKey=A.PresentationUniqueKey)",
- ItemSortBy.SeriesSortName => e => e.SeriesName,
+ (ItemSortBy.AirTime, _) => e => e.SortName, // TODO
+ (ItemSortBy.Runtime, _) => e => e.RunTimeTicks,
+ (ItemSortBy.Random, _) => e => EF.Functions.Random(),
+ (ItemSortBy.DatePlayed, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.LastPlayedDate,
+ (ItemSortBy.PlayCount, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.PlayCount,
+ (ItemSortBy.IsFavoriteOrLiked, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.IsFavorite,
+ (ItemSortBy.IsFolder, _) => e => e.IsFolder,
+ (ItemSortBy.IsPlayed, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played,
+ (ItemSortBy.IsUnplayed, _) => e => !e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played,
+ (ItemSortBy.DateLastContentAdded, _) => e => e.DateLastMediaAdded,
+ (ItemSortBy.Artist, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Artist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
+ (ItemSortBy.AlbumArtist, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.AlbumArtist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
+ (ItemSortBy.Studio, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Studios).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
+ (ItemSortBy.OfficialRating, _) => e => e.InheritedParentalRatingValue,
+ (ItemSortBy.SeriesSortName, _) => e => e.SeriesName,
+ (ItemSortBy.Album, _) => e => e.Album,
+ (ItemSortBy.DateCreated, _) => e => e.DateCreated,
+ (ItemSortBy.PremiereDate, _) => e => (e.PremiereDate ?? (e.ProductionYear.HasValue ? DateTime.MinValue.AddYears(e.ProductionYear.Value - 1) : null)),
+ (ItemSortBy.StartDate, _) => e => e.StartDate,
+ (ItemSortBy.Name, _) => e => e.CleanName,
+ (ItemSortBy.CommunityRating, _) => e => e.CommunityRating,
+ (ItemSortBy.ProductionYear, _) => e => e.ProductionYear,
+ (ItemSortBy.CriticRating, _) => e => e.CriticRating,
+ (ItemSortBy.VideoBitRate, _) => e => e.TotalBitrate,
+ (ItemSortBy.ParentIndexNumber, _) => e => e.ParentIndexNumber,
+ (ItemSortBy.IndexNumber, _) => e => e.IndexNumber,
+ (ItemSortBy.SeriesDatePlayed, not null) => e =>
+ jellyfinDbContext.BaseItems
+ .Where(w => w.SeriesPresentationUniqueKey == e.PresentationUniqueKey)
+ .Join(jellyfinDbContext.UserData.Where(w => w.UserId == query.User.Id && w.Played), f => f.Id, f => f.ItemId, (item, userData) => userData.LastPlayedDate)
+ .Max(f => f),
+ (ItemSortBy.SeriesDatePlayed, null) => e => jellyfinDbContext.BaseItems.Where(w => w.SeriesPresentationUniqueKey == e.PresentationUniqueKey)
+ .Join(jellyfinDbContext.UserData.Where(w => w.Played), f => f.Id, f => f.ItemId, (item, userData) => userData.LastPlayedDate)
+ .Max(f => f),
+ // ItemSortBy.SeriesDatePlayed => e => jellyfinDbContext.UserData
+ // .Where(u => u.Item!.SeriesPresentationUniqueKey == e.PresentationUniqueKey && u.Played)
+ // .Max(f => f.LastPlayedDate),
// ItemSortBy.AiredEpisodeOrder => "AiredEpisodeOrder",
- ItemSortBy.Album => e => e.Album,
- ItemSortBy.DateCreated => e => e.DateCreated,
- ItemSortBy.PremiereDate => e => (e.PremiereDate ?? (e.ProductionYear.HasValue ? DateTime.MinValue.AddYears(e.ProductionYear.Value - 1) : null)),
- ItemSortBy.StartDate => e => e.StartDate,
- ItemSortBy.Name => e => e.CleanName,
- ItemSortBy.CommunityRating => e => e.CommunityRating,
- ItemSortBy.ProductionYear => e => e.ProductionYear,
- ItemSortBy.CriticRating => e => e.CriticRating,
- ItemSortBy.VideoBitRate => e => e.TotalBitrate,
- ItemSortBy.ParentIndexNumber => e => e.ParentIndexNumber,
- ItemSortBy.IndexNumber => e => e.IndexNumber,
_ => e => e.SortName
};
}
diff --git a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs
index b2f54be7e..ce628a04d 100644
--- a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs
+++ b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs
@@ -13,8 +13,7 @@ namespace Jellyfin.Server.Implementations.StorageHelpers;
public static class StorageHelper
{
private const long TwoGigabyte = 2_147_483_647L;
- private const long FiveHundredAndTwelveMegaByte = 536_870_911L;
- private static readonly string[] _byteHumanizedSuffixes = ["B", "KB", "MB", "GB", "TB", "PB", "EB"];
+ private static readonly string[] _byteHumanizedSuffixes = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"];
/// <summary>
/// Tests the available storage capacity on the jellyfin paths with estimated minimum values.
@@ -24,10 +23,8 @@ public static class StorageHelper
public static void TestCommonPathsForStorageCapacity(IApplicationPaths applicationPaths, ILogger logger)
{
TestDataDirectorySize(applicationPaths.DataPath, logger, TwoGigabyte);
- TestDataDirectorySize(applicationPaths.LogDirectoryPath, logger, FiveHundredAndTwelveMegaByte);
TestDataDirectorySize(applicationPaths.CachePath, logger, TwoGigabyte);
TestDataDirectorySize(applicationPaths.ProgramDataPath, logger, TwoGigabyte);
- TestDataDirectorySize(applicationPaths.TempDirectory, logger, TwoGigabyte);
}
/// <summary>
@@ -77,7 +74,7 @@ public static class StorageHelper
var drive = new DriveInfo(path);
if (threshold != -1 && drive.AvailableFreeSpace < threshold)
{
- throw new InvalidOperationException($"The path `{path}` has insufficient free space. Required: at least {HumanizeStorageSize(threshold)}.");
+ throw new InvalidOperationException($"The path `{path}` has insufficient free space. Available: {HumanizeStorageSize(drive.AvailableFreeSpace)}, Required: {HumanizeStorageSize(threshold)}.");
}
logger.LogInformation(
diff --git a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
index 6f2d2a107..4505a377c 100644
--- a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
+++ b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
@@ -254,10 +254,10 @@ public class TrickplayManager : ITrickplayManager
}
// We support video backdrops, but we should not generate trickplay images for them
- var parentDirectory = Directory.GetParent(mediaPath);
+ var parentDirectory = Directory.GetParent(video.Path);
if (parentDirectory is not null && string.Equals(parentDirectory.Name, "backdrops", StringComparison.OrdinalIgnoreCase))
{
- _logger.LogDebug("Ignoring backdrop media found at {Path} for item {ItemID}", mediaPath, video.Id);
+ _logger.LogDebug("Ignoring backdrop media found at {Path} for item {ItemID}", video.Path, video.Id);
return;
}
diff --git a/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs b/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs
index 35c43b176..446849b6f 100644
--- a/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs
+++ b/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs
@@ -59,7 +59,7 @@ namespace Jellyfin.Server.Implementations.Users
}
// As long as jellyfin supports password-less users, we need this little block here to accommodate
- if (!HasPassword(resolvedUser) && string.IsNullOrEmpty(password))
+ if (string.IsNullOrEmpty(resolvedUser.Password) && string.IsNullOrEmpty(password))
{
return Task.FromResult(new ProviderAuthenticationResult
{
@@ -94,10 +94,6 @@ namespace Jellyfin.Server.Implementations.Users
}
/// <inheritdoc />
- public bool HasPassword(User user)
- => !string.IsNullOrEmpty(user?.Password);
-
- /// <inheritdoc />
public Task ChangePassword(User user, string newPassword)
{
if (string.IsNullOrEmpty(newPassword))
diff --git a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs
index f20fb2d92..49a9fda94 100644
--- a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs
+++ b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.IO;
using System.Security.Cryptography;
using System.Text.Json;
@@ -92,33 +93,38 @@ namespace Jellyfin.Server.Implementations.Users
}
/// <inheritdoc />
- public async Task<ForgotPasswordResult> StartForgotPasswordProcess(User user, bool isInNetwork)
+ public async Task<ForgotPasswordResult> StartForgotPasswordProcess(User? user, string enteredUsername, bool isInNetwork)
{
- byte[] bytes = new byte[4];
- RandomNumberGenerator.Fill(bytes);
- string pin = BitConverter.ToString(bytes);
-
DateTime expireTime = DateTime.UtcNow.AddMinutes(30);
- string filePath = _passwordResetFileBase + user.Id + ".json";
- SerializablePasswordReset spr = new SerializablePasswordReset
- {
- ExpirationDate = expireTime,
- Pin = pin,
- PinFile = filePath,
- UserName = user.Username
- };
+ var usernameHash = enteredUsername.ToUpperInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture);
+ var pinFile = _passwordResetFileBase + usernameHash + ".json";
- FileStream fileStream = AsyncFile.Create(filePath);
- await using (fileStream.ConfigureAwait(false))
+ if (user is not null && isInNetwork)
{
- await JsonSerializer.SerializeAsync(fileStream, spr).ConfigureAwait(false);
+ byte[] bytes = new byte[4];
+ RandomNumberGenerator.Fill(bytes);
+ string pin = BitConverter.ToString(bytes);
+
+ SerializablePasswordReset spr = new SerializablePasswordReset
+ {
+ ExpirationDate = expireTime,
+ Pin = pin,
+ PinFile = pinFile,
+ UserName = user.Username
+ };
+
+ FileStream fileStream = AsyncFile.Create(pinFile);
+ await using (fileStream.ConfigureAwait(false))
+ {
+ await JsonSerializer.SerializeAsync(fileStream, spr).ConfigureAwait(false);
+ }
}
return new ForgotPasswordResult
{
Action = ForgotPasswordAction.PinCode,
PinExpirationDate = expireTime,
- PinFile = filePath
+ PinFile = pinFile
};
}
diff --git a/Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs b/Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs
index caf9d5bd9..56b8a7fc4 100644
--- a/Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs
+++ b/Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs
@@ -22,12 +22,6 @@ namespace Jellyfin.Server.Implementations.Users
}
/// <inheritdoc />
- public bool HasPassword(User user)
- {
- return true;
- }
-
- /// <inheritdoc />
public Task ChangePassword(User user, string newPassword)
{
return Task.CompletedTask;
diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs
index d0b41a7f6..63d962ad9 100644
--- a/Jellyfin.Server.Implementations/Users/UserManager.cs
+++ b/Jellyfin.Server.Implementations/Users/UserManager.cs
@@ -306,15 +306,12 @@ namespace Jellyfin.Server.Implementations.Users
/// <inheritdoc/>
public UserDto GetUserDto(User user, string? remoteEndPoint = null)
{
- var hasPassword = GetAuthenticationProvider(user).HasPassword(user);
var castReceiverApplications = _serverConfigurationManager.Configuration.CastReceiverApplications;
return new UserDto
{
Name = user.Username,
Id = user.Id,
ServerId = _appHost.SystemId,
- HasPassword = hasPassword,
- HasConfiguredPassword = hasPassword,
EnableAutoLogin = user.EnableAutoLogin,
LastLoginDate = user.LastLoginDate,
LastActivityDate = user.LastActivityDate,
@@ -508,23 +505,18 @@ namespace Jellyfin.Server.Implementations.Users
public async Task<ForgotPasswordResult> StartForgotPasswordProcess(string enteredUsername, bool isInNetwork)
{
var user = string.IsNullOrWhiteSpace(enteredUsername) ? null : GetUserByName(enteredUsername);
+ var passwordResetProvider = GetPasswordResetProvider(user);
+
+ var result = await passwordResetProvider
+ .StartForgotPasswordProcess(user, enteredUsername, isInNetwork)
+ .ConfigureAwait(false);
if (user is not null && isInNetwork)
{
- var passwordResetProvider = GetPasswordResetProvider(user);
- var result = await passwordResetProvider
- .StartForgotPasswordProcess(user, isInNetwork)
- .ConfigureAwait(false);
-
await UpdateUserAsync(user).ConfigureAwait(false);
- return result;
}
- return new ForgotPasswordResult
- {
- Action = ForgotPasswordAction.InNetworkRequired,
- PinFile = string.Empty
- };
+ return result;
}
/// <inheritdoc/>
@@ -760,8 +752,13 @@ namespace Jellyfin.Server.Implementations.Users
return GetAuthenticationProviders(user)[0];
}
- private IPasswordResetProvider GetPasswordResetProvider(User user)
+ private IPasswordResetProvider GetPasswordResetProvider(User? user)
{
+ if (user is null)
+ {
+ return _defaultPasswordResetProvider;
+ }
+
return GetPasswordResetProviders(user)[0];
}
diff --git a/Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs b/Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs
deleted file mode 100644
index 910b5c467..000000000
--- a/Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs
+++ /dev/null
@@ -1,151 +0,0 @@
-// The MIT License (MIT)
-//
-// Copyright (c) .NET Foundation and Contributors
-//
-// All rights reserved.
-//
-// Permission is hereby granted, free of charge, to any person obtaining a copy
-// of this software and associated documentation files (the "Software"), to deal
-// in the Software without restriction, including without limitation the rights
-// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-// copies of the Software, and to permit persons to whom the Software is
-// furnished to do so, subject to the following conditions:
-//
-// The above copyright notice and this permission notice shall be included in all
-// copies or substantial portions of the Software.
-//
-// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-// SOFTWARE.
-
-using System;
-using System.IO;
-using System.Threading;
-using System.Threading.Tasks;
-using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.Http.Extensions;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.Mvc.Infrastructure;
-using Microsoft.Extensions.Logging;
-using Microsoft.Net.Http.Headers;
-
-namespace Jellyfin.Server.Infrastructure
-{
- /// <inheritdoc />
- public class SymlinkFollowingPhysicalFileResultExecutor : PhysicalFileResultExecutor
- {
- /// <summary>
- /// Initializes a new instance of the <see cref="SymlinkFollowingPhysicalFileResultExecutor"/> class.
- /// </summary>
- /// <param name="loggerFactory">An instance of the <see cref="ILoggerFactory"/> interface.</param>
- public SymlinkFollowingPhysicalFileResultExecutor(ILoggerFactory loggerFactory) : base(loggerFactory)
- {
- }
-
- /// <inheritdoc />
- protected override FileMetadata GetFileInfo(string path)
- {
- var fileInfo = new FileInfo(path);
- var length = fileInfo.Length;
- // This may or may not be fixed in .NET 6, but looks like it will not https://github.com/dotnet/aspnetcore/issues/34371
- if ((fileInfo.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint)
- {
- using var fileHandle = File.OpenHandle(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
- length = RandomAccess.GetLength(fileHandle);
- }
-
- return new FileMetadata
- {
- Exists = fileInfo.Exists,
- Length = length,
- LastModified = fileInfo.LastWriteTimeUtc
- };
- }
-
- /// <inheritdoc />
- protected override async Task WriteFileAsync(ActionContext context, PhysicalFileResult result, RangeItemHeaderValue? range, long rangeLength)
- {
- ArgumentNullException.ThrowIfNull(context);
- ArgumentNullException.ThrowIfNull(result);
-
- if (range is not null && rangeLength == 0)
- {
- return;
- }
-
- // It's a bit of wasted IO to perform this check again, but non-symlinks shouldn't use this code
- if (!IsSymLink(result.FileName))
- {
- await base.WriteFileAsync(context, result, range, rangeLength).ConfigureAwait(false);
- return;
- }
-
- var response = context.HttpContext.Response;
-
- if (range is not null)
- {
- await SendFileAsync(
- result.FileName,
- response,
- offset: range.From ?? 0L,
- count: rangeLength).ConfigureAwait(false);
- return;
- }
-
- await SendFileAsync(
- result.FileName,
- response,
- offset: 0,
- count: null).ConfigureAwait(false);
- }
-
- private async Task SendFileAsync(string filePath, HttpResponse response, long offset, long? count, CancellationToken cancellationToken = default)
- {
- var fileInfo = GetFileInfo(filePath);
- if (offset < 0 || offset > fileInfo.Length)
- {
- throw new ArgumentOutOfRangeException(nameof(offset), offset, string.Empty);
- }
-
- if (count.HasValue
- && (count.Value < 0 || count.Value > fileInfo.Length - offset))
- {
- throw new ArgumentOutOfRangeException(nameof(count), count, string.Empty);
- }
-
- // Copied from SendFileFallback.SendFileAsync
- const int BufferSize = 1024 * 16;
-
- var useRequestAborted = !cancellationToken.CanBeCanceled;
- var localCancel = useRequestAborted ? response.HttpContext.RequestAborted : cancellationToken;
-
- var fileStream = new FileStream(
- filePath,
- FileMode.Open,
- FileAccess.Read,
- FileShare.ReadWrite,
- bufferSize: BufferSize,
- options: FileOptions.Asynchronous | FileOptions.SequentialScan);
- await using (fileStream.ConfigureAwait(false))
- {
- try
- {
- localCancel.ThrowIfCancellationRequested();
- fileStream.Seek(offset, SeekOrigin.Begin);
- await StreamCopyOperation
- .CopyToAsync(fileStream, response.Body, count, BufferSize, localCancel)
- .ConfigureAwait(true);
- }
- catch (OperationCanceledException) when (useRequestAborted)
- {
- }
- }
- }
-
- private static bool IsSymLink(string path) => (File.GetAttributes(path) & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint;
- }
-}
diff --git a/Jellyfin.Server/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj
index df630922a..14ab114fb 100644
--- a/Jellyfin.Server/Jellyfin.Server.csproj
+++ b/Jellyfin.Server/Jellyfin.Server.csproj
@@ -78,7 +78,7 @@
<None Update="wwwroot\api-docs\swagger\custom.css">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
- <None Update="wwwroot\api-docs\banner-dark.svg">
+ <None Update="wwwroot\api-docs\jellyfin.svg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="ServerSetupApp/index.mstemplate.html">
diff --git a/Jellyfin.Server/Migrations/Routines/DisableLegacyAuthorization.cs b/Jellyfin.Server/Migrations/Routines/DisableLegacyAuthorization.cs
new file mode 100644
index 000000000..6edfcbcfd
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/DisableLegacyAuthorization.cs
@@ -0,0 +1,32 @@
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Configuration;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Migration to disable legacy authorization in the system config.
+/// </summary>
+[JellyfinMigration("2025-11-18T16:00:00", nameof(DisableLegacyAuthorization))]
+public class DisableLegacyAuthorization : IAsyncMigrationRoutine
+{
+ private readonly IServerConfigurationManager _serverConfigurationManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="DisableLegacyAuthorization"/> class.
+ /// </summary>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+ public DisableLegacyAuthorization(IServerConfigurationManager serverConfigurationManager)
+ {
+ _serverConfigurationManager = serverConfigurationManager;
+ }
+
+ /// <inheritdoc />
+ public Task PerformAsync(CancellationToken cancellationToken)
+ {
+ _serverConfigurationManager.Configuration.EnableLegacyAuthorization = false;
+ _serverConfigurationManager.SaveConfiguration();
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs
index a954d307e..8c8563190 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs
@@ -55,9 +55,25 @@ namespace Jellyfin.Server.Migrations.Routines
};
var dataPath = _paths.DataPath;
- using (var connection = new SqliteConnection($"Filename={Path.Combine(dataPath, DbFilename)}"))
+ var activityLogPath = Path.Combine(dataPath, DbFilename);
+ if (!File.Exists(activityLogPath))
+ {
+ _logger.LogWarning("{ActivityLogDb} doesn't exist, nothing to migrate", activityLogPath);
+ return;
+ }
+
+ using (var connection = new SqliteConnection($"Filename={activityLogPath}"))
{
connection.Open();
+ var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='ActivityLog';");
+ foreach (var row in tableQuery)
+ {
+ if (row.GetInt32(0) == 0)
+ {
+ _logger.LogWarning("Table 'ActivityLog' doesn't exist in {ActivityLogPath}, nothing to migrate", activityLogPath);
+ return;
+ }
+ }
using var userDbConnection = new SqliteConnection($"Filename={Path.Combine(dataPath, "users.db")}");
userDbConnection.Open();
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs
index c6699c21d..0de775e03 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs
@@ -50,9 +50,28 @@ namespace Jellyfin.Server.Migrations.Routines
public void Perform()
{
var dataPath = _appPaths.DataPath;
- using (var connection = new SqliteConnection($"Filename={Path.Combine(dataPath, DbFilename)}"))
+ var dbFilePath = Path.Combine(dataPath, DbFilename);
+
+ if (!File.Exists(dbFilePath))
+ {
+ _logger.LogWarning("{Path} doesn't exist, nothing to migrate", dbFilePath);
+ return;
+ }
+
+ using (var connection = new SqliteConnection($"Filename={dbFilePath}"))
{
connection.Open();
+
+ var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='Tokens';");
+ foreach (var row in tableQuery)
+ {
+ if (row.GetInt32(0) == 0)
+ {
+ _logger.LogWarning("Table 'Tokens' doesn't exist in {Path}, nothing to migrate", dbFilePath);
+ return;
+ }
+ }
+
using var dbContext = _dbProvider.CreateDbContext();
var authenticatedDevices = connection.Query("SELECT * FROM Tokens");
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs
index 0d9952ce9..ffd06fea0 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs
@@ -78,9 +78,27 @@ namespace Jellyfin.Server.Migrations.Routines
var displayPrefs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var customDisplayPrefs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var dbFilePath = Path.Combine(_paths.DataPath, DbFilename);
+
+ if (!File.Exists(dbFilePath))
+ {
+ _logger.LogWarning("{Path} doesn't exist, nothing to migrate", dbFilePath);
+ return;
+ }
+
using (var connection = new SqliteConnection($"Filename={dbFilePath}"))
{
connection.Open();
+
+ var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='userdisplaypreferences';");
+ foreach (var row in tableQuery)
+ {
+ if (row.GetInt32(0) == 0)
+ {
+ _logger.LogWarning("Table 'userdisplaypreferences' doesn't exist in {Path}, nothing to migrate", dbFilePath);
+ return;
+ }
+ }
+
using var dbContext = _provider.CreateDbContext();
var results = connection.Query("SELECT * FROM userdisplaypreferences");
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs b/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs
index c199ee4d6..aa5530926 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs
@@ -122,6 +122,16 @@ public class MigrateKeyframeData : IDatabaseMigrationRoutine
{
lastWriteTimeUtc = File.GetLastWriteTimeUtc(filePath);
}
+ catch (ArgumentOutOfRangeException e)
+ {
+ _logger.LogDebug("Skipping {Path}: {Exception}", filePath, e.Message);
+ return null;
+ }
+ catch (UnauthorizedAccessException e)
+ {
+ _logger.LogDebug("Skipping {Path}: {Exception}", filePath, e.Message);
+ return null;
+ }
catch (IOException e)
{
_logger.LogDebug("Skipping {Path}: {Exception}", filePath, e.Message);
@@ -135,14 +145,21 @@ public class MigrateKeyframeData : IDatabaseMigrationRoutine
return Path.Join(keyframeCachePath, prefix, filename);
}
- private static bool TryReadFromCache(string? cachePath, [NotNullWhen(true)] out MediaEncoding.Keyframes.KeyframeData? cachedResult)
+ private bool TryReadFromCache(string? cachePath, [NotNullWhen(true)] out MediaEncoding.Keyframes.KeyframeData? cachedResult)
{
if (File.Exists(cachePath))
{
- var bytes = File.ReadAllBytes(cachePath);
- cachedResult = JsonSerializer.Deserialize<MediaEncoding.Keyframes.KeyframeData>(bytes, _jsonOptions);
+ try
+ {
+ var bytes = File.ReadAllBytes(cachePath);
+ cachedResult = JsonSerializer.Deserialize<MediaEncoding.Keyframes.KeyframeData>(bytes, _jsonOptions);
- return cachedResult is not null;
+ return cachedResult is not null;
+ }
+ catch (JsonException jsonException)
+ {
+ _logger.LogWarning(jsonException, "Failed to read {Path}", cachePath);
+ }
}
cachedResult = null;
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs
index b90da9f7d..d221d1853 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs
@@ -383,8 +383,6 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
});
}
- baseItemIds.Clear();
-
foreach (var item in peopleCache)
{
operation.JellyfinDbContext.Peoples.Add(item.Value.Person);
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs
index e5584fb94..8c3361ee1 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs
@@ -57,11 +57,28 @@ public class MigrateUserDb : IMigrationRoutine
public void Perform()
{
var dataPath = _paths.DataPath;
+ var userDbPath = Path.Combine(dataPath, DbFilename);
+ if (!File.Exists(userDbPath))
+ {
+ _logger.LogWarning("{UserDbPath} doesn't exist, nothing to migrate", userDbPath);
+ return;
+ }
+
_logger.LogInformation("Migrating the user database may take a while, do not stop Jellyfin.");
- using (var connection = new SqliteConnection($"Filename={Path.Combine(dataPath, DbFilename)}"))
+ using (var connection = new SqliteConnection($"Filename={userDbPath}"))
{
connection.Open();
+ var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='LocalUsersv2';");
+ foreach (var row in tableQuery)
+ {
+ if (row.GetInt32(0) == 0)
+ {
+ _logger.LogWarning("Table 'LocalUsersv2' doesn't exist in {UserDbPath}, nothing to migrate", userDbPath);
+ return;
+ }
+ }
+
using var dbContext = _provider.CreateDbContext();
var queryResult = connection.Query("SELECT * FROM LocalUsersv2");
diff --git a/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs b/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs
index 8b394dd7a..fbf9c1637 100644
--- a/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs
+++ b/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs
@@ -224,6 +224,18 @@ public class MoveExtractedFiles : IAsyncMigrationRoutine
return null;
}
+ catch (UnauthorizedAccessException e)
+ {
+ _logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", attachmentStreamIndex, mediaPath, e.Message);
+
+ return null;
+ }
+ catch (ArgumentOutOfRangeException e)
+ {
+ _logger.LogDebug("Skipping attachment at index {Index} for {Path}: {Exception}", attachmentStreamIndex, mediaPath, e.Message);
+
+ return null;
+ }
filename = (mediaPath + attachmentStreamIndex.ToString(CultureInfo.InvariantCulture) + "_" + date.Value.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("D", CultureInfo.InvariantCulture);
}
@@ -263,6 +275,18 @@ public class MoveExtractedFiles : IAsyncMigrationRoutine
{
date = File.GetLastWriteTimeUtc(path);
}
+ catch (ArgumentOutOfRangeException e)
+ {
+ _logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", streamIndex, path, e.Message);
+
+ return null;
+ }
+ catch (UnauthorizedAccessException e)
+ {
+ _logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", streamIndex, path, e.Message);
+
+ return null;
+ }
catch (IOException e)
{
_logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", streamIndex, path, e.Message);
diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs
index dc7fa5eb3..93f71fdc6 100644
--- a/Jellyfin.Server/Program.cs
+++ b/Jellyfin.Server/Program.cs
@@ -184,6 +184,12 @@ namespace Jellyfin.Server
.AddSingleton<IServiceCollection>(e))
.Build();
+ /*
+ * Initialize the transcode path marker so we avoid starting Jellyfin in a broken state.
+ * This should really be a part of IApplicationPaths but this path is configured differently.
+ */
+ _ = appHost.ConfigurationManager.GetTranscodePath();
+
// Re-use the host service provider in the app host since ASP.NET doesn't allow a custom service collection.
appHost.ServiceProvider = _jellyfinHost.Services;
PrepareDatabaseProvider(appHost.ServiceProvider);
diff --git a/Jellyfin.Server/ServerSetupApp/SetupServer.cs b/Jellyfin.Server/ServerSetupApp/SetupServer.cs
index 72626e853..4340969a3 100644
--- a/Jellyfin.Server/ServerSetupApp/SetupServer.cs
+++ b/Jellyfin.Server/ServerSetupApp/SetupServer.cs
@@ -249,7 +249,9 @@ public sealed class SetupServer : IDisposable
{
{ "isInReportingMode", _isUnhealthy },
{ "retryValue", retryAfterValue },
+ { "version", typeof(Emby.Server.Implementations.ApplicationHost).Assembly.GetName().Version! },
{ "logs", startupLogEntries },
+ { "networkManagerReady", networkManager is not null },
{ "localNetworkRequest", networkManager is not null && context.Connection.RemoteIpAddress is not null && networkManager.IsInLocalNetwork(context.Connection.RemoteIpAddress) }
},
new ByteCounterStream(context.Response.BodyWriter.AsStream(), IODefaults.FileStreamBufferSize, true, _startupUiRenderer.ParserOptions))
diff --git a/Jellyfin.Server/ServerSetupApp/index.mstemplate.html b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html
index 523f38d74..890a77619 100644
--- a/Jellyfin.Server/ServerSetupApp/index.mstemplate.html
+++ b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html
@@ -173,7 +173,7 @@
<header class="flex-row">
{{^IF isInReportingMode}}
- <p>Jellyfin Server still starting. Please wait.</p>
+ <p>Jellyfin Server {{version}} still starting. Please wait.</p>
{{#ELSE}}
<p>Jellyfin Server has encountered an error and was not able to start.</p>
{{/ELSE}}
@@ -213,7 +213,12 @@
</ol>
</div>
{{#ELSE}}
+ {{#IF networkManagerReady}}
<p>Please visit this page from your local network to view detailed startup logs.</p>
+ {{#ELSE}}
+ <p>Initializing network settings. Please wait.</p>
+ {{/ELSE}}
+ {{/IF}}
{{/ELSE}}
{{/IF}}
</div>
diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs
index aa8f6dd1c..5032b2aec 100644
--- a/Jellyfin.Server/Startup.cs
+++ b/Jellyfin.Server/Startup.cs
@@ -16,15 +16,12 @@ using Jellyfin.Networking.HappyEyeballs;
using Jellyfin.Server.Extensions;
using Jellyfin.Server.HealthChecks;
using Jellyfin.Server.Implementations.Extensions;
-using Jellyfin.Server.Infrastructure;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Extensions;
using MediaBrowser.XbmcMetadata;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -69,8 +66,6 @@ namespace Jellyfin.Server
options.HttpsPort = _serverApplicationHost.HttpsPort;
});
- // TODO remove once this is fixed upstream https://github.com/dotnet/aspnetcore/issues/34371
- services.AddSingleton<IActionResultExecutor<PhysicalFileResult>, SymlinkFollowingPhysicalFileResultExecutor>();
services.AddJellyfinApi(_serverApplicationHost.GetApiPluginAssemblies(), _serverConfigurationManager.GetNetworkConfiguration());
services.AddJellyfinDbContext(_serverApplicationHost.ConfigurationManager, _configuration);
services.AddJellyfinApiSwagger();
diff --git a/Jellyfin.Server/wwwroot/api-docs/banner-dark.svg b/Jellyfin.Server/wwwroot/api-docs/banner-dark.svg
deleted file mode 100644
index b62b7545c..000000000
--- a/Jellyfin.Server/wwwroot/api-docs/banner-dark.svg
+++ /dev/null
@@ -1,34 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!-- ***** BEGIN LICENSE BLOCK *****
- - Part of the Jellyfin project (https://jellyfin.media)
- -
- - All copyright belongs to the Jellyfin contributors; a full list can
- - be found in the file CONTRIBUTORS.md
- -
- - This work is licensed under the Creative Commons Attribution-ShareAlike 4.0 International License.
- - To view a copy of this license, visit http://creativecommons.org/licenses/by-sa/4.0/.
-- ***** END LICENSE BLOCK ***** -->
-<svg id="banner-dark" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1536 512">
- <defs>
- <linearGradient id="linear-gradient" x1="110.25" y1="213.3" x2="496.14" y2="436.09" gradientUnits="userSpaceOnUse">
- <stop offset="0" stop-color="#aa5cc3"/>
- <stop offset="1" stop-color="#00a4dc"/>
- </linearGradient>
- </defs>
- <title>banner-dark</title>
- <g id="banner-dark">
- <g id="banner-dark-icon">
- <path id="inner-shape" d="M261.42,201.62c-20.44,0-86.24,119.29-76.2,139.43s142.48,19.92,152.4,0S281.86,201.63,261.42,201.62Z" fill="url(#linear-gradient)"/>
- <path id="outer-shape" d="M261.42,23.3C199.83,23.3,1.57,382.73,31.8,443.43s429.34,60,459.24,0S323,23.3,261.42,23.3ZM411.9,390.76c-19.59,39.33-281.08,39.77-300.9,0S221.1,115.48,261.45,115.48,431.49,351.42,411.9,390.76Z" fill="url(#linear-gradient)"/>
- </g>
- <g id="jellyfin-light-outlines" style="isolation:isolate" transform="translate(43.8)">
- <path d="M556.64,350.75a67,67,0,0,1-22.87-27.47,8.91,8.91,0,0,1-1.49-4.75,7.42,7.42,0,0,1,2.83-5.94,9.25,9.25,0,0,1,6.09-2.38c3.16,0,5.94,1.69,8.31,5.05a48.09,48.09,0,0,0,16.34,20.34,40.59,40.59,0,0,0,24,7.58q20.51,0,33.27-12.62t12.77-33.12V159a8.44,8.44,0,0,1,2.67-6.39,9.56,9.56,0,0,1,6.83-2.52,9,9,0,0,1,6.68,2.52,8.7,8.7,0,0,1,2.53,6.39v138.4a64.7,64.7,0,0,1-8.32,32.67,59,59,0,0,1-23,22.72Q608.62,361,589.9,361A57.21,57.21,0,0,1,556.64,350.75Z" fill="#fff"/>
- <path d="M831.66,279.47a8.77,8.77,0,0,1-6.24,2.53H713.16q0,17.82,7.27,31.92a54.91,54.91,0,0,0,20.79,22.28q13.51,8.18,31.93,8.17a54,54,0,0,0,25.54-5.94,52.7,52.7,0,0,0,18.12-15.15,10,10,0,0,1,6.24-2.67,8.14,8.14,0,0,1,7.72,7.72,8.81,8.81,0,0,1-3,6.24,74.7,74.7,0,0,1-23.91,19A65.56,65.56,0,0,1,773.45,361q-22.87,0-40.4-9.8a69.51,69.51,0,0,1-27.32-27.48q-9.79-17.66-9.8-40.83,0-24.36,9.65-42.62t25.69-27.92a65.2,65.2,0,0,1,34.16-9.65A70,70,0,0,1,798.84,211a65.78,65.78,0,0,1,25.39,24.36q9.81,16,10.1,38A8.07,8.07,0,0,1,831.66,279.47ZM733.5,231.8Q718.8,243.68,714.64,266H815.92v-2.38A46.91,46.91,0,0,0,807,240.27a48.47,48.47,0,0,0-18.56-15.15,54,54,0,0,0-23-5.2Q748.2,219.92,733.5,231.8Z" fill="#fff"/>
- <path d="M888.24,355.5a8.92,8.92,0,0,1-15.3-6.38v-202a8.91,8.91,0,1,1,17.82,0v202A8.65,8.65,0,0,1,888.24,355.5Z" fill="#fff"/>
- <path d="M956.55,355.5a8.92,8.92,0,0,1-15.3-6.38v-202a8.91,8.91,0,1,1,17.82,0v202A8.65,8.65,0,0,1,956.55,355.5Z" fill="#fff"/>
- <path d="M1122.86,206.11a8.7,8.7,0,0,1,2.53,6.39v131q0,23.44-9.21,40.09a61.58,61.58,0,0,1-25.54,25.25q-16.34,8.61-36.83,8.61a96.73,96.73,0,0,1-23.31-2.68,61.72,61.72,0,0,1-18-7.12q-6.24-3.87-6.24-8.62a17.94,17.94,0,0,1,.6-3,8.06,8.06,0,0,1,3-4.45,7.49,7.49,0,0,1,4.45-1.49,7.91,7.91,0,0,1,3.56.89q19,10.39,36.24,10.4,24.65,0,39.06-15.44t14.4-42.18V333.38a54.37,54.37,0,0,1-21.38,20,62.55,62.55,0,0,1-30.3,7.58q-25.83,0-39.2-15.45t-13.37-41.87V212.5a8.91,8.91,0,1,1,17.82,0V301q0,21.39,9.36,32.38t29.25,11a48,48,0,0,0,23.32-6.09,49.88,49.88,0,0,0,17.82-16,37.44,37.44,0,0,0,6.68-21.24V212.5a9,9,0,0,1,15.29-6.39Z" fill="#fff"/>
- <path d="M1210.18,161.41q-5.21,6.24-5.2,17.23v30.59h33.27a8.19,8.19,0,0,1,5.79,2.38,8.26,8.26,0,0,1,0,11.88,8.22,8.22,0,0,1-5.79,2.37H1205V349.12a8.91,8.91,0,1,1-17.82,0V225.86h-21.68a7.83,7.83,0,0,1-5.94-2.52,8.21,8.21,0,0,1-2.37-5.79,8,8,0,0,1,2.37-6.09,8.33,8.33,0,0,1,5.94-2.23h21.68V178.64q0-18.7,10.84-29t29-10.24a46.1,46.1,0,0,1,15.45,2.52q7.13,2.53,7.12,8.17a8.07,8.07,0,0,1-2.37,5.94,7.37,7.37,0,0,1-5.35,2.37,18.81,18.81,0,0,1-6.53-1.48,42,42,0,0,0-10.4-1.78Q1215.37,155.18,1210.18,161.41ZM1276,180.87c-2.19-1.88-3.27-4.61-3.27-8.17v-3q0-5.34,3.41-8.17t9.36-2.82q11.88,0,11.88,11v3c0,3.56-1,6.29-3.12,8.17s-5.1,2.82-9.06,2.82S1278.14,182.75,1276,180.87Zm15.59,174.63a8.92,8.92,0,0,1-15.3-6.38V212.5a8.91,8.91,0,1,1,17.82,0V349.12A8.65,8.65,0,0,1,1291.56,355.5Z" fill="#fff"/>
- <path d="M1452.53,218.88q12.92,16.2,12.92,42.92v87.32a8.4,8.4,0,0,1-2.67,6.38,8.8,8.8,0,0,1-6.24,2.53,8.64,8.64,0,0,1-8.91-8.91V262.69q0-19.31-9.65-31.33t-29.85-12a53.28,53.28,0,0,0-42.77,21.83,36.24,36.24,0,0,0-7.13,21.53v86.43a8.91,8.91,0,1,1-17.82,0V216.06a8.91,8.91,0,1,1,17.82,0V232.4q8-12.77,23-21.24A61.84,61.84,0,0,1,1412,202.7Q1439.61,202.7,1452.53,218.88Z" fill="#fff"/>
- </g>
- </g>
-</svg> \ No newline at end of file
diff --git a/Jellyfin.Server/wwwroot/api-docs/jellyfin.svg b/Jellyfin.Server/wwwroot/api-docs/jellyfin.svg
new file mode 100644
index 000000000..692530319
--- /dev/null
+++ b/Jellyfin.Server/wwwroot/api-docs/jellyfin.svg
@@ -0,0 +1,26 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="251" height="72" fill="none" viewBox="0 0 251 72">
+ <g clip-path="url(#a)">
+ <path fill="url(#b)"
+ d="M24.212 49.158C22.66 46.042 32.838 27.588 36 27.588c3.167.002 13.323 18.488 11.788 21.57-1.534 3.082-22.025 3.116-23.576 0" />
+ <path fill="url(#c)" fill-rule="evenodd"
+ d="M.482 64.995C-4.195 55.605 26.477 0 36 0c9.533 0 40.153 55.713 35.527 64.995s-66.368 9.39-71.045 0m12.254-8.148c3.064 6.152 43.518 6.084 46.548 0 3.03-6.086-17.032-42.586-23.275-42.586S9.671 50.694 12.736 56.847"
+ clip-rule="evenodd" />
+ <path fill="#fff"
+ d="M225.22 56c-.28 0-.42 0-.527-.055a.5.5 0 0 1-.219-.218c-.054-.107-.054-.247-.054-.527V26.8c0-.28 0-.42.054-.527a.5.5 0 0 1 .219-.219c.107-.054.247-.054.527-.054h5.183c.28 0 .42 0 .527.054a.5.5 0 0 1 .218.219c.055.107.055.247.055.527v2.895a7.9 7.9 0 0 1 3.419-3.254q2.261-1.103 5.074-1.103 3.308 0 5.845 1.434a10.1 10.1 0 0 1 4.026 4.026q1.434 2.536 1.434 5.9V55.2c0 .28 0 .42-.055.527a.5.5 0 0 1-.218.218c-.107.055-.247.055-.527.055h-5.625c-.28 0-.42 0-.527-.055a.5.5 0 0 1-.218-.218c-.055-.107-.055-.247-.055-.527V38.408q0-2.978-1.709-4.688-1.654-1.764-4.357-1.764-2.702 0-4.412 1.764-1.654 1.766-1.654 4.688V55.2c0 .28 0 .42-.054.527a.5.5 0 0 1-.219.218c-.107.055-.247.055-.527.055zm-11.54-33.363c-.28 0-.42 0-.527-.055a.5.5 0 0 1-.218-.218c-.055-.107-.055-.247-.055-.527v-6.121c0-.28 0-.42.055-.527a.5.5 0 0 1 .218-.219c.107-.054.247-.054.527-.054h5.624c.28 0 .42 0 .527.054a.5.5 0 0 1 .219.219c.054.107.054.247.054.527v6.12c0 .28 0 .42-.054.528a.5.5 0 0 1-.219.218c-.107.055-.247.055-.527.055zm0 33.363c-.28 0-.42 0-.527-.054a.5.5 0 0 1-.218-.219c-.055-.107-.055-.247-.055-.527V26.8c0-.28 0-.42.055-.527a.5.5 0 0 1 .218-.218c.107-.055.247-.055.527-.055h5.624c.28 0 .42 0 .527.055a.5.5 0 0 1 .219.218c.054.107.054.247.054.527v28.4c0 .28 0 .42-.054.527a.5.5 0 0 1-.219.219c-.107.054-.247.054-.527.054zm-16.712-.054c.107.054.247.054.527.054h5.625c.28 0 .42 0 .526-.054a.5.5 0 0 0 .219-.219c.055-.107.055-.247.055-.527V32.452h5.872c.28 0 .42 0 .527-.054a.5.5 0 0 0 .219-.219c.054-.107.054-.247.054-.527V26.8c0-.28 0-.42-.054-.527a.5.5 0 0 0-.219-.218c-.107-.055-.247-.055-.527-.055h-5.872v-.992q0-2.261 1.323-3.31 1.379-1.102 3.75-1.102.454 0 .939.044c.345.031.518.047.634-.004a.48.48 0 0 0 .241-.22c.061-.111.061-.274.061-.6V15.39c0-.304 0-.457-.061-.589a.7.7 0 0 0-.248-.284c-.122-.078-.261-.097-.537-.136a14.5 14.5 0 0 0-1.966-.126q-5.184 0-8.273 2.812t-3.088 7.942V26H186.53c-.3 0-.451 0-.58.05a.75.75 0 0 0-.296.205c-.091.104-.143.244-.248.526l-7.43 19.9-7.483-19.903c-.105-.28-.158-.42-.249-.524a.75.75 0 0 0-.296-.205c-.129-.049-.279-.049-.578-.049h-5.769c-.394 0-.591 0-.717.083a.5.5 0 0 0-.213.314c-.031.147.041.33.186.697L174.281 56l-.661 1.6q-.883 1.874-2.041 3.033-1.103 1.158-3.584 1.158-.883 0-1.875-.166a13 13 0 0 1-.73-.1c-.389-.066-.584-.099-.709-.053a.47.47 0 0 0-.26.22c-.066.116-.066.298-.066.663v4.329c0 .243 0 .365.045.481a.7.7 0 0 0 .189.266c.095.081.194.116.392.185q.684.24 1.47.351 1.158.22 2.371.22 4.246 0 7.059-2.426 2.867-2.37 4.577-6.728l10.517-26.58h5.72V55.2c0 .28 0 .42.055.527a.5.5 0 0 0 .218.219M154.363 56c-.28 0-.42 0-.527-.054a.5.5 0 0 1-.219-.219c-.054-.107-.054-.247-.054-.527V15.054c0-.28 0-.42.054-.527a.5.5 0 0 1 .219-.219c.107-.054.247-.054.527-.054h5.624c.28 0 .42 0 .527.054a.5.5 0 0 1 .218.219c.055.107.055.247.055.527V55.2c0 .28 0 .42-.055.527a.5.5 0 0 1-.218.219c-.107.054-.247.054-.527.054zm-11.621 0c-.28 0-.42 0-.527-.054a.5.5 0 0 1-.219-.219c-.054-.107-.054-.247-.054-.527V15.054c0-.28 0-.42.054-.527a.5.5 0 0 1 .219-.219c.107-.054.247-.054.527-.054h5.624c.28 0 .42 0 .527.054a.5.5 0 0 1 .219.219c.054.107.054.247.054.527V55.2c0 .28 0 .42-.054.527a.5.5 0 0 1-.219.219c-.107.054-.247.054-.527.054zm-18.132.662q-4.632-.001-8.107-2.096a14.6 14.6 0 0 1-5.404-5.68q-1.93-3.585-1.93-7.942 0-4.522 1.93-7.996 1.985-3.53 5.349-5.57 3.42-2.04 7.61-2.04 4.688 0 7.942 2.04 3.253 1.986 4.963 5.294 1.71 3.309 1.709 7.335 0 .828-.11 1.654-.031.45-.12.841c-.037.165-.055.247-.115.33a.55.55 0 0 1-.208.168c-.095.04-.194.04-.393.04h-21.057q.33 3.309 2.537 5.294 2.205 1.986 5.459 1.985 2.482 0 4.191-1.047a8.2 8.2 0 0 0 2.206-1.986c.241-.316.362-.474.484-.542a.6.6 0 0 1 .352-.083c.139.006.296.083.608.236l4.269 2.094c.239.118.359.176.431.275a.52.52 0 0 1 .098.298c0 .122-.058.231-.172.45q-1.432 2.742-4.526 4.607-3.419 2.04-7.996 2.04m-.552-25.368q-2.702 0-4.687 1.654-1.93 1.6-2.537 4.577h14.118q-.22-2.757-2.151-4.466-1.875-1.765-4.743-1.765M90.801 56c-.28 0-.42 0-.527-.054a.5.5 0 0 1-.218-.218C90 55.62 90 55.48 90 55.2v-5.294c0-.28 0-.42.055-.527a.5.5 0 0 1 .218-.218c.107-.055.247-.055.527-.055h1.572q2.646 0 4.19-1.489 1.6-1.545 1.6-4.08V15.715c0-.28 0-.42.055-.527a.5.5 0 0 1 .218-.219c.107-.054.247-.054.527-.054h5.956c.28 0 .42 0 .527.054a.5.5 0 0 1 .218.219c.055.107.055.247.055.527v27.546q0 3.804-1.655 6.672-1.599 2.868-4.632 4.467-2.979 1.6-7.06 1.6z" />
+ </g>
+ <defs>
+ <linearGradient id="b" x1="12" x2="71.999" y1="30.001" y2="63.002"
+ gradientUnits="userSpaceOnUse">
+ <stop stop-color="#aa5cc3" />
+ <stop offset="1" stop-color="#00a4dc" />
+ </linearGradient>
+ <linearGradient id="c" x1="12" x2="71.999" y1="29.999" y2="63.001"
+ gradientUnits="userSpaceOnUse">
+ <stop stop-color="#aa5cc3" />
+ <stop offset="1" stop-color="#00a4dc" />
+ </linearGradient>
+ <clipPath id="a">
+ <path fill="#fff" d="M0 0h251v72H0z" />
+ </clipPath>
+ </defs>
+</svg> \ No newline at end of file
diff --git a/Jellyfin.Server/wwwroot/api-docs/swagger/custom.css b/Jellyfin.Server/wwwroot/api-docs/swagger/custom.css
index acb59888e..c14ad6021 100644
--- a/Jellyfin.Server/wwwroot/api-docs/swagger/custom.css
+++ b/Jellyfin.Server/wwwroot/api-docs/swagger/custom.css
@@ -4,12 +4,14 @@
}
.topbar-wrapper .link:after {
- content: url(../banner-dark.svg);
+ content: '';
display: block;
- -moz-box-sizing: border-box;
+ background-image: url(../jellyfin.svg);
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: contain;
box-sizing: border-box;
- max-width: 100%;
- max-height: 100%;
- width: 150px;
+ width: 220px;
+ height: 40px;
}
/* end logo */
diff --git a/MediaBrowser.Common/Configuration/IApplicationPaths.cs b/MediaBrowser.Common/Configuration/IApplicationPaths.cs
index 6d1a72b04..3a6197490 100644
--- a/MediaBrowser.Common/Configuration/IApplicationPaths.cs
+++ b/MediaBrowser.Common/Configuration/IApplicationPaths.cs
@@ -103,11 +103,11 @@ namespace MediaBrowser.Common.Configuration
void MakeSanityCheckOrThrow();
/// <summary>
- /// Checks and creates the given path and adds it with a marker file if non existant.
+ /// Checks and creates the given path and adds it with a marker file if non existent.
/// </summary>
/// <param name="path">The path to check.</param>
/// <param name="markerName">The common marker file name.</param>
- /// <param name="recursive">Check for other settings paths recursivly.</param>
+ /// <param name="recursive">Check for other settings paths recursively.</param>
void CreateAndCheckMarker(string path, string markerName, bool recursive = false);
}
}
diff --git a/MediaBrowser.Common/MediaBrowser.Common.csproj b/MediaBrowser.Common/MediaBrowser.Common.csproj
index de6be4707..9af13b0a7 100644
--- a/MediaBrowser.Common/MediaBrowser.Common.csproj
+++ b/MediaBrowser.Common/MediaBrowser.Common.csproj
@@ -8,7 +8,7 @@
<PropertyGroup>
<Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Common</PackageId>
- <VersionPrefix>10.11.0</VersionPrefix>
+ <VersionPrefix>10.12.0</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
diff --git a/MediaBrowser.Controller/Authentication/IAuthenticationProvider.cs b/MediaBrowser.Controller/Authentication/IAuthenticationProvider.cs
index 976a667ac..c993ceea8 100644
--- a/MediaBrowser.Controller/Authentication/IAuthenticationProvider.cs
+++ b/MediaBrowser.Controller/Authentication/IAuthenticationProvider.cs
@@ -14,8 +14,6 @@ namespace MediaBrowser.Controller.Authentication
Task<ProviderAuthenticationResult> Authenticate(string username, string password);
- bool HasPassword(User user);
-
Task ChangePassword(User user, string newPassword);
}
diff --git a/MediaBrowser.Controller/Authentication/IPasswordResetProvider.cs b/MediaBrowser.Controller/Authentication/IPasswordResetProvider.cs
index 592ce9955..36cd5c5d1 100644
--- a/MediaBrowser.Controller/Authentication/IPasswordResetProvider.cs
+++ b/MediaBrowser.Controller/Authentication/IPasswordResetProvider.cs
@@ -1,5 +1,3 @@
-#nullable disable
-
#pragma warning disable CS1591
using System;
@@ -15,11 +13,12 @@ namespace MediaBrowser.Controller.Authentication
bool IsEnabled { get; }
- Task<ForgotPasswordResult> StartForgotPasswordProcess(User user, bool isInNetwork);
+ Task<ForgotPasswordResult> StartForgotPasswordProcess(User? user, string enteredUsername, bool isInNetwork);
Task<PinRedeemResult> RedeemPasswordResetPin(string pin);
}
+#nullable disable
public class PasswordPinCreationResult
{
public string PinFile { get; set; }
diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs
index 4989f0f3f..3c46d53e5 100644
--- a/MediaBrowser.Controller/Entities/BaseItem.cs
+++ b/MediaBrowser.Controller/Entities/BaseItem.cs
@@ -24,6 +24,7 @@ using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaSegments;
using MediaBrowser.Controller.Persistence;
@@ -1127,6 +1128,15 @@ namespace MediaBrowser.Controller.Entities
var protocol = item.PathProtocol;
+ // Resolve the item path so everywhere we use the media source it will always point to
+ // the correct path even if symlinks are in use. Calling ResolveLinkTarget on a non-link
+ // path will return null, so it's safe to check for all paths.
+ var itemPath = item.Path;
+ if (protocol is MediaProtocol.File && FileSystemHelper.ResolveLinkTarget(itemPath, returnFinalTarget: true) is { Exists: true } linkInfo)
+ {
+ itemPath = linkInfo.FullName;
+ }
+
var info = new MediaSourceInfo
{
Id = item.Id.ToString("N", CultureInfo.InvariantCulture),
@@ -1134,7 +1144,7 @@ namespace MediaBrowser.Controller.Entities
MediaStreams = MediaSourceManager.GetMediaStreams(item.Id),
MediaAttachments = MediaSourceManager.GetMediaAttachments(item.Id),
Name = GetMediaSourceName(item),
- Path = enablePathSubstitution ? GetMappedPath(item, item.Path, protocol) : item.Path,
+ Path = enablePathSubstitution ? GetMappedPath(item, itemPath, protocol) : itemPath,
RunTimeTicks = item.RunTimeTicks,
Container = item.Container,
Size = item.Size,
diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs
index e9a383690..151b957fe 100644
--- a/MediaBrowser.Controller/Entities/Folder.cs
+++ b/MediaBrowser.Controller/Entities/Folder.cs
@@ -715,14 +715,21 @@ namespace MediaBrowser.Controller.Entities
}
else
{
- items = GetRecursiveChildren(user, query, out totalCount);
+ // Save pagination params before clearing them to prevent pagination from happening
+ // before sorting. PostFilterAndSort will apply pagination after sorting.
+ var limit = query.Limit;
+ var startIndex = query.StartIndex;
query.Limit = null;
- query.StartIndex = null; // override these here as they have already been applied
+ query.StartIndex = null;
+
+ items = GetRecursiveChildren(user, query, out totalCount);
+
+ // Restore pagination params so PostFilterAndSort can apply them after sorting
+ query.Limit = limit;
+ query.StartIndex = startIndex;
}
- var result = PostFilterAndSort(items, query);
- result.TotalRecordCount = totalCount;
- return result;
+ return PostFilterAndSort(items, query);
}
if (this is not UserRootFolder
@@ -980,25 +987,19 @@ namespace MediaBrowser.Controller.Entities
else
{
// need to pass this param to the children.
+ // Note: Don't pass Limit/StartIndex here as pagination should happen after sorting in PostFilterAndSort
var childQuery = new InternalItemsQuery
{
DisplayAlbumFolders = query.DisplayAlbumFolders,
- Limit = query.Limit,
- StartIndex = query.StartIndex,
NameStartsWith = query.NameStartsWith,
NameStartsWithOrGreater = query.NameStartsWithOrGreater,
NameLessThan = query.NameLessThan
};
items = GetChildren(user, true, out totalItemCount, childQuery).Where(filter);
-
- query.Limit = null;
- query.StartIndex = null;
}
- var result = PostFilterAndSort(items, query);
- result.TotalRecordCount = totalItemCount;
- return result;
+ return PostFilterAndSort(items, query);
}
protected QueryResult<BaseItem> PostFilterAndSort(IEnumerable<BaseItem> items, InternalItemsQuery query)
@@ -1034,7 +1035,15 @@ namespace MediaBrowser.Controller.Entities
items = UserViewBuilder.FilterForAdjacency(items.ToList(), query.AdjacentTo.Value);
}
- return UserViewBuilder.SortAndPage(items, null, query, LibraryManager);
+ var filteredItems = items as IReadOnlyList<BaseItem> ?? items.ToList();
+ var result = UserViewBuilder.SortAndPage(filteredItems, null, query, LibraryManager);
+
+ if (query.EnableTotalRecordCount)
+ {
+ result.TotalRecordCount = filteredItems.Count;
+ }
+
+ return result;
}
private static IEnumerable<BaseItem> CollapseBoxSetItemsIfNeeded(
@@ -1047,12 +1056,49 @@ namespace MediaBrowser.Controller.Entities
{
ArgumentNullException.ThrowIfNull(items);
- if (CollapseBoxSetItems(query, queryParent, user, configurationManager))
+ if (!CollapseBoxSetItems(query, queryParent, user, configurationManager))
+ {
+ return items;
+ }
+
+ var config = configurationManager.Configuration;
+
+ bool collapseMovies = config.EnableGroupingMoviesIntoCollections;
+ bool collapseSeries = config.EnableGroupingShowsIntoCollections;
+
+ if (user is null || (collapseMovies && collapseSeries))
+ {
+ return collectionManager.CollapseItemsWithinBoxSets(items, user);
+ }
+
+ if (!collapseMovies && !collapseSeries)
+ {
+ return items;
+ }
+
+ var collapsibleItems = new List<BaseItem>();
+ var remainingItems = new List<BaseItem>();
+
+ foreach (var item in items)
+ {
+ if ((collapseMovies && item is Movie) || (collapseSeries && item is Series))
+ {
+ collapsibleItems.Add(item);
+ }
+ else
+ {
+ remainingItems.Add(item);
+ }
+ }
+
+ if (collapsibleItems.Count == 0)
{
- items = collectionManager.CollapseItemsWithinBoxSets(items, user);
+ return remainingItems;
}
- return items;
+ var collapsedItems = collectionManager.CollapseItemsWithinBoxSets(collapsibleItems, user);
+
+ return collapsedItems.Concat(remainingItems);
}
private static bool CollapseBoxSetItems(
@@ -1083,24 +1129,26 @@ namespace MediaBrowser.Controller.Entities
}
var param = query.CollapseBoxSetItems;
-
- if (!param.HasValue)
+ if (param.HasValue)
{
- if (user is not null && query.IncludeItemTypes.Any(type =>
- (type == BaseItemKind.Movie && !configurationManager.Configuration.EnableGroupingMoviesIntoCollections) ||
- (type == BaseItemKind.Series && !configurationManager.Configuration.EnableGroupingShowsIntoCollections)))
- {
- return false;
- }
+ return param.Value && AllowBoxSetCollapsing(query);
+ }
- if (query.IncludeItemTypes.Length == 0
- || query.IncludeItemTypes.Any(type => type == BaseItemKind.Movie || type == BaseItemKind.Series))
- {
- param = true;
- }
+ var config = configurationManager.Configuration;
+
+ bool queryHasMovies = query.IncludeItemTypes.Length == 0 || query.IncludeItemTypes.Contains(BaseItemKind.Movie);
+ bool queryHasSeries = query.IncludeItemTypes.Length == 0 || query.IncludeItemTypes.Contains(BaseItemKind.Series);
+
+ bool collapseMovies = config.EnableGroupingMoviesIntoCollections;
+ bool collapseSeries = config.EnableGroupingShowsIntoCollections;
+
+ if (user is not null)
+ {
+ bool canCollapse = (queryHasMovies && collapseMovies) || (queryHasSeries && collapseSeries);
+ return canCollapse && AllowBoxSetCollapsing(query);
}
- return param.HasValue && param.Value && AllowBoxSetCollapsing(query);
+ return (queryHasMovies || queryHasSeries) && AllowBoxSetCollapsing(query);
}
private static bool AllowBoxSetCollapsing(InternalItemsQuery request)
diff --git a/MediaBrowser.Controller/IO/FileSystemHelper.cs b/MediaBrowser.Controller/IO/FileSystemHelper.cs
index 1a33c3aa8..3e390ca42 100644
--- a/MediaBrowser.Controller/IO/FileSystemHelper.cs
+++ b/MediaBrowser.Controller/IO/FileSystemHelper.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.IO;
using System.Linq;
using MediaBrowser.Model.IO;
@@ -61,4 +62,82 @@ public static class FileSystemHelper
}
}
}
+
+ /// <summary>
+ /// Gets the target of the specified file link.
+ /// </summary>
+ /// <remarks>
+ /// This helper exists because of this upstream runtime issue; https://github.com/dotnet/runtime/issues/92128.
+ /// </remarks>
+ /// <param name="linkPath">The path of the file link.</param>
+ /// <param name="returnFinalTarget">true to follow links to the final target; false to return the immediate next link.</param>
+ /// <returns>
+ /// A <see cref="FileInfo"/> if the <paramref name="linkPath"/> is a link, regardless of if the target exists; otherwise, <c>null</c>.
+ /// </returns>
+ public static FileInfo? ResolveLinkTarget(string linkPath, bool returnFinalTarget = false)
+ {
+ // Check if the file exists so the native resolve handler won't throw at us.
+ if (!File.Exists(linkPath))
+ {
+ return null;
+ }
+
+ if (!returnFinalTarget)
+ {
+ return File.ResolveLinkTarget(linkPath, returnFinalTarget: false) as FileInfo;
+ }
+
+ if (File.ResolveLinkTarget(linkPath, returnFinalTarget: false) is not FileInfo targetInfo)
+ {
+ return null;
+ }
+
+ if (!targetInfo.Exists)
+ {
+ return targetInfo;
+ }
+
+ var currentPath = targetInfo.FullName;
+ var visited = new HashSet<string>(StringComparer.Ordinal) { linkPath, currentPath };
+ while (File.ResolveLinkTarget(currentPath, returnFinalTarget: false) is FileInfo linkInfo)
+ {
+ var targetPath = linkInfo.FullName;
+
+ // If an infinite loop is detected, return the file info for the
+ // first link in the loop we encountered.
+ if (!visited.Add(targetPath))
+ {
+ return new FileInfo(targetPath);
+ }
+
+ targetInfo = linkInfo;
+ currentPath = targetPath;
+
+ // Exit if the target doesn't exist, so the native resolve handler won't throw at us.
+ if (!targetInfo.Exists)
+ {
+ break;
+ }
+ }
+
+ return targetInfo;
+ }
+
+ /// <summary>
+ /// Gets the target of the specified file link.
+ /// </summary>
+ /// <remarks>
+ /// This helper exists because of this upstream runtime issue; https://github.com/dotnet/runtime/issues/92128.
+ /// </remarks>
+ /// <param name="fileInfo">The file info of the file link.</param>
+ /// <param name="returnFinalTarget">true to follow links to the final target; false to return the immediate next link.</param>
+ /// <returns>
+ /// A <see cref="FileInfo"/> if the <paramref name="fileInfo"/> is a link, regardless of if the target exists; otherwise, <c>null</c>.
+ /// </returns>
+ public static FileInfo? ResolveLinkTarget(FileInfo fileInfo, bool returnFinalTarget = false)
+ {
+ ArgumentNullException.ThrowIfNull(fileInfo);
+
+ return ResolveLinkTarget(fileInfo.FullName, returnFinalTarget);
+ }
}
diff --git a/MediaBrowser.Controller/MediaBrowser.Controller.csproj b/MediaBrowser.Controller/MediaBrowser.Controller.csproj
index 3353ad63f..b5d14e94b 100644
--- a/MediaBrowser.Controller/MediaBrowser.Controller.csproj
+++ b/MediaBrowser.Controller/MediaBrowser.Controller.csproj
@@ -8,7 +8,7 @@
<PropertyGroup>
<Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Controller</PackageId>
- <VersionPrefix>10.11.0</VersionPrefix>
+ <VersionPrefix>10.12.0</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
index c81e639a2..a1d891535 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
@@ -2390,8 +2390,8 @@ namespace MediaBrowser.Controller.MediaEncoding
|| (requestHasSDR && videoStream.VideoRangeType == VideoRangeType.DOVIWithSDR)
|| (requestHasHDR10 && videoStream.VideoRangeType == VideoRangeType.HDR10Plus)))
{
- // If the video stream is in a static HDR format, don't allow copy if the client does not support HDR10 or HLG.
- if (videoStream.VideoRangeType is VideoRangeType.HDR10 or VideoRangeType.HLG)
+ // If the video stream is in HDR10+ or a static HDR format, don't allow copy if the client does not support HDR10 or HLG.
+ if (videoStream.VideoRangeType is VideoRangeType.HDR10Plus or VideoRangeType.HDR10 or VideoRangeType.HLG)
{
return false;
}
diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
index 8350d1613..b7fef842b 100644
--- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
+++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
@@ -1122,7 +1122,15 @@ namespace MediaBrowser.MediaEncoding.Encoder
private void StartProcess(ProcessWrapper process)
{
process.Process.Start();
- process.Process.PriorityClass = ProcessPriorityClass.BelowNormal;
+
+ try
+ {
+ process.Process.PriorityClass = ProcessPriorityClass.BelowNormal;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Unable to set process priority to BelowNormal for {ProcessFileName}", process.Process.StartInfo.FileName);
+ }
lock (_runningProcessesLock)
{
diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
index 00a9ae797..1823496bf 100644
--- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
+++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
@@ -154,11 +154,12 @@ namespace MediaBrowser.MediaEncoding.Probing
info.Name = tags.GetFirstNotNullNorWhiteSpaceValue("title", "title-eng");
info.ForcedSortName = tags.GetFirstNotNullNorWhiteSpaceValue("sort_name", "title-sort", "titlesort");
- info.Overview = tags.GetFirstNotNullNorWhiteSpaceValue("synopsis", "description", "desc");
+ info.Overview = tags.GetFirstNotNullNorWhiteSpaceValue("synopsis", "description", "desc", "comment");
- info.IndexNumber = FFProbeHelpers.GetDictionaryNumericValue(tags, "episode_sort");
info.ParentIndexNumber = FFProbeHelpers.GetDictionaryNumericValue(tags, "season_number");
- info.ShowName = tags.GetValueOrDefault("show_name");
+ info.IndexNumber = FFProbeHelpers.GetDictionaryNumericValue(tags, "episode_sort") ??
+ FFProbeHelpers.GetDictionaryNumericValue(tags, "episode_id");
+ info.ShowName = tags.GetValueOrDefault("show_name", "show");
info.ProductionYear = FFProbeHelpers.GetDictionaryNumericValue(tags, "date");
// Several different forms of retail/premiere date
@@ -930,6 +931,15 @@ namespace MediaBrowser.MediaEncoding.Probing
{
stream.Rotation = data.Rotation;
}
+
+ // Parse video frame cropping metadata from side_data
+ // TODO: save them and make HW filters to apply them in HWA pipelines
+ else if (string.Equals(data.SideDataType, "Frame Cropping", StringComparison.OrdinalIgnoreCase))
+ {
+ // Streams containing artificially added frame cropping
+ // metadata should not be marked as anamorphic.
+ stream.IsAnamorphic = false;
+ }
}
}
diff --git a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
index 0cda803d6..2fd054f11 100644
--- a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
+++ b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
@@ -396,7 +396,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
ArgumentException.ThrowIfNullOrEmpty(_mediaEncoder.EncoderPath);
// If subtitles get burned in fonts may need to be extracted from the media file
- if (state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode)
+ if (state.SubtitleStream is not null && (state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode || state.BaseRequest.AlwaysBurnInSubtitleWhenTranscoding))
{
if (state.MediaSource.VideoType == VideoType.Dvd || state.MediaSource.VideoType == VideoType.BluRay)
{
diff --git a/MediaBrowser.Model/Configuration/ServerConfiguration.cs b/MediaBrowser.Model/Configuration/ServerConfiguration.cs
index a58c01c96..ac5c12304 100644
--- a/MediaBrowser.Model/Configuration/ServerConfiguration.cs
+++ b/MediaBrowser.Model/Configuration/ServerConfiguration.cs
@@ -287,5 +287,5 @@ public class ServerConfiguration : BaseApplicationConfiguration
/// <summary>
/// Gets or sets a value indicating whether old authorization methods are allowed.
/// </summary>
- public bool EnableLegacyAuthorization { get; set; } = true;
+ public bool EnableLegacyAuthorization { get; set; }
}
diff --git a/MediaBrowser.Model/Dlna/StreamInfo.cs b/MediaBrowser.Model/Dlna/StreamInfo.cs
index 13acd15a3..92404de50 100644
--- a/MediaBrowser.Model/Dlna/StreamInfo.cs
+++ b/MediaBrowser.Model/Dlna/StreamInfo.cs
@@ -1250,30 +1250,37 @@ public class StreamInfo
if (info.DeliveryMethod == SubtitleDeliveryMethod.External)
{
- if (MediaSource.Protocol == MediaProtocol.File || !string.Equals(stream.Codec, subtitleProfile.Format, StringComparison.OrdinalIgnoreCase) || !stream.IsExternal)
- {
- info.Url = string.Format(
- CultureInfo.InvariantCulture,
- "{0}/Videos/{1}/{2}/Subtitles/{3}/{4}/Stream.{5}",
- baseUrl,
- ItemId,
- MediaSourceId,
- stream.Index.ToString(CultureInfo.InvariantCulture),
- startPositionTicks.ToString(CultureInfo.InvariantCulture),
- subtitleProfile.Format);
-
- if (!string.IsNullOrEmpty(accessToken))
- {
- info.Url += "?ApiKey=" + accessToken;
- }
-
- info.IsExternalUrl = false;
- }
- else
+ // Default to using the API URL
+ info.Url = string.Format(
+ CultureInfo.InvariantCulture,
+ "{0}/Videos/{1}/{2}/Subtitles/{3}/{4}/Stream.{5}",
+ baseUrl,
+ ItemId,
+ MediaSourceId,
+ stream.Index.ToString(CultureInfo.InvariantCulture),
+ startPositionTicks.ToString(CultureInfo.InvariantCulture),
+ subtitleProfile.Format);
+ info.IsExternalUrl = false; // Default to API URL
+
+ // Check conditions for potentially using the direct path
+ if (stream.IsExternal // Must be external
+ && MediaSource?.Protocol != MediaProtocol.File // Main media must not be a local file
+ && string.Equals(stream.Codec, subtitleProfile.Format, StringComparison.OrdinalIgnoreCase) // Format must match (no conversion needed)
+ && !string.IsNullOrEmpty(stream.Path) // Path must exist
+ && Uri.TryCreate(stream.Path, UriKind.Absolute, out Uri? uriResult) // Path must be an absolute URI
+ && (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps)) // Scheme must be HTTP or HTTPS
{
+ // All conditions met, override with the direct path
info.Url = stream.Path;
info.IsExternalUrl = true;
}
+
+ // Append ApiKey only if we are using the API URL
+ if (!info.IsExternalUrl && !string.IsNullOrEmpty(accessToken))
+ {
+ // Use "?ApiKey=" as seen in HEAD and other parts of the code
+ info.Url += "?ApiKey=" + accessToken;
+ }
}
return info;
diff --git a/MediaBrowser.Model/Dto/UserDto.cs b/MediaBrowser.Model/Dto/UserDto.cs
index 05019741e..c6b4a4d14 100644
--- a/MediaBrowser.Model/Dto/UserDto.cs
+++ b/MediaBrowser.Model/Dto/UserDto.cs
@@ -1,5 +1,6 @@
#nullable disable
using System;
+using System.ComponentModel;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Users;
@@ -54,20 +55,22 @@ namespace MediaBrowser.Model.Dto
/// Gets or sets a value indicating whether this instance has password.
/// </summary>
/// <value><c>true</c> if this instance has password; otherwise, <c>false</c>.</value>
- public bool HasPassword { get; set; }
+ [Obsolete("This information is no longer provided")]
+ public bool? HasPassword { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether this instance has configured password.
/// </summary>
/// <value><c>true</c> if this instance has configured password; otherwise, <c>false</c>.</value>
- public bool HasConfiguredPassword { get; set; }
+ [Obsolete("This is always true")]
+ public bool? HasConfiguredPassword { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether this instance has configured easy password.
/// </summary>
/// <value><c>true</c> if this instance has configured easy password; otherwise, <c>false</c>.</value>
[Obsolete("Easy Password has been replaced with Quick Connect")]
- public bool HasConfiguredEasyPassword { get; set; }
+ public bool? HasConfiguredEasyPassword { get; set; } = false;
/// <summary>
/// Gets or sets whether async login is enabled or not.
diff --git a/MediaBrowser.Model/MediaBrowser.Model.csproj b/MediaBrowser.Model/MediaBrowser.Model.csproj
index e9dab6bc8..ef025d02d 100644
--- a/MediaBrowser.Model/MediaBrowser.Model.csproj
+++ b/MediaBrowser.Model/MediaBrowser.Model.csproj
@@ -8,7 +8,7 @@
<PropertyGroup>
<Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Model</PackageId>
- <VersionPrefix>10.11.0</VersionPrefix>
+ <VersionPrefix>10.12.0</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
diff --git a/MediaBrowser.Model/Users/ForgotPasswordAction.cs b/MediaBrowser.Model/Users/ForgotPasswordAction.cs
index f198476e3..55907e6c8 100644
--- a/MediaBrowser.Model/Users/ForgotPasswordAction.cs
+++ b/MediaBrowser.Model/Users/ForgotPasswordAction.cs
@@ -1,11 +1,15 @@
#pragma warning disable CS1591
+using System;
+
namespace MediaBrowser.Model.Users
{
public enum ForgotPasswordAction
{
+ [Obsolete("Returning different actions represents a security concern.")]
ContactAdmin = 0,
PinCode = 1,
+ [Obsolete("Returning different actions represents a security concern.")]
InNetworkRequired = 2
}
}
diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs
index 1d83263c5..f220ec4a1 100644
--- a/MediaBrowser.Providers/Manager/MetadataService.cs
+++ b/MediaBrowser.Providers/Manager/MetadataService.cs
@@ -151,7 +151,10 @@ namespace MediaBrowser.Providers.Manager
.ConfigureAwait(false);
updateType |= beforeSaveResult;
- updateType = await SaveInternal(item, refreshOptions, updateType, isFirstRefresh, requiresRefresh, metadataResult, cancellationToken).ConfigureAwait(false);
+ if (!isFirstRefresh)
+ {
+ updateType = await SaveInternal(item, refreshOptions, updateType, isFirstRefresh, requiresRefresh, metadataResult, cancellationToken).ConfigureAwait(false);
+ }
// Next run metadata providers
if (refreshOptions.MetadataRefreshMode != MetadataRefreshMode.None)
@@ -229,6 +232,11 @@ namespace MediaBrowser.Providers.Manager
if (file is not null)
{
item.DateModified = file.LastWriteTimeUtc;
+
+ if (!file.IsDirectory)
+ {
+ item.Size = file.Length;
+ }
}
}
@@ -312,12 +320,8 @@ namespace MediaBrowser.Providers.Manager
{
if (EnableUpdateMetadataFromChildren(item, isFullRefresh, updateType))
{
- if (isFullRefresh || updateType > ItemUpdateType.None)
- {
- var children = GetChildrenForMetadataUpdates(item);
-
- updateType = UpdateMetadataFromChildren(item, children, isFullRefresh, updateType);
- }
+ var children = GetChildrenForMetadataUpdates(item);
+ updateType = UpdateMetadataFromChildren(item, children, isFullRefresh, updateType);
}
var presentationUniqueKey = item.CreatePresentationUniqueKey();
@@ -339,7 +343,10 @@ namespace MediaBrowser.Providers.Manager
item.DateModified = info.LastWriteTimeUtc;
if (ServerConfigurationManager.GetMetadataConfiguration().UseFileCreationTimeForDateAdded)
{
- item.DateCreated = info.CreationTimeUtc;
+ if (info.CreationTimeUtc > DateTime.MinValue)
+ {
+ item.DateCreated = info.CreationTimeUtc;
+ }
}
if (item is Video video)
@@ -357,16 +364,24 @@ namespace MediaBrowser.Providers.Manager
protected virtual bool EnableUpdateMetadataFromChildren(TItemType item, bool isFullRefresh, ItemUpdateType currentUpdateType)
{
- if (isFullRefresh || currentUpdateType > ItemUpdateType.None)
+ if (item is Folder folder)
{
- if (EnableUpdatingPremiereDateFromChildren || EnableUpdatingGenresFromChildren || EnableUpdatingStudiosFromChildren || EnableUpdatingOfficialRatingFromChildren)
+ if (!isFullRefresh && currentUpdateType == ItemUpdateType.None)
{
- return true;
+ return folder.SupportsDateLastMediaAdded;
}
- if (item is Folder folder)
+ if (isFullRefresh || currentUpdateType > ItemUpdateType.None)
{
- return folder.SupportsDateLastMediaAdded || folder.SupportsCumulativeRunTimeTicks;
+ if (EnableUpdatingPremiereDateFromChildren || EnableUpdatingGenresFromChildren || EnableUpdatingStudiosFromChildren || EnableUpdatingOfficialRatingFromChildren)
+ {
+ return true;
+ }
+
+ if (folder.SupportsDateLastMediaAdded || folder.SupportsCumulativeRunTimeTicks)
+ {
+ return true;
+ }
}
}
@@ -387,36 +402,42 @@ namespace MediaBrowser.Providers.Manager
{
var updateType = ItemUpdateType.None;
- if (isFullRefresh || currentUpdateType > ItemUpdateType.None)
+ if (item is Folder folder)
{
- updateType |= UpdateCumulativeRunTimeTicks(item, children);
- updateType |= UpdateDateLastMediaAdded(item, children);
-
- // don't update user-changeable metadata for locked items
- if (item.IsLocked)
+ if (folder.SupportsDateLastMediaAdded)
{
- return updateType;
+ updateType |= UpdateDateLastMediaAdded(item, children);
}
- if (EnableUpdatingPremiereDateFromChildren)
+ if ((isFullRefresh || currentUpdateType > ItemUpdateType.None) && folder.SupportsCumulativeRunTimeTicks)
{
- updateType |= UpdatePremiereDate(item, children);
+ updateType |= UpdateCumulativeRunTimeTicks(item, children);
}
+ }
- if (EnableUpdatingGenresFromChildren)
- {
- updateType |= UpdateGenres(item, children);
- }
+ if (!(isFullRefresh || currentUpdateType > ItemUpdateType.None) || item.IsLocked)
+ {
+ return updateType;
+ }
- if (EnableUpdatingStudiosFromChildren)
- {
- updateType |= UpdateStudios(item, children);
- }
+ if (EnableUpdatingPremiereDateFromChildren)
+ {
+ updateType |= UpdatePremiereDate(item, children);
+ }
- if (EnableUpdatingOfficialRatingFromChildren)
- {
- updateType |= UpdateOfficialRating(item, children);
- }
+ if (EnableUpdatingGenresFromChildren)
+ {
+ updateType |= UpdateGenres(item, children);
+ }
+
+ if (EnableUpdatingStudiosFromChildren)
+ {
+ updateType |= UpdateStudios(item, children);
+ }
+
+ if (EnableUpdatingOfficialRatingFromChildren)
+ {
+ updateType |= UpdateOfficialRating(item, children);
}
return updateType;
diff --git a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs
index bdb6b93be..bde23e842 100644
--- a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs
+++ b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs
@@ -520,7 +520,7 @@ namespace MediaBrowser.Providers.MediaInfo
{
Name = person.Name,
Type = person.Type,
- Role = person.Role.Trim()
+ Role = person.Role?.Trim()
});
}
}
diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs
index ad9edb031..82c6e3011 100644
--- a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs
@@ -138,6 +138,8 @@ namespace MediaBrowser.Providers.Plugins.Omdb
}
var item = itemResult.Item;
+ item.IndexNumber = episodeNumber;
+ item.ParentIndexNumber = seasonNumber;
var seasonResult = await GetSeasonRootObject(seriesImdbId, seasonNumber, cancellationToken).ConfigureAwait(false);
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs
index 0953dde1c..e30c555cb 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs
@@ -177,8 +177,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
var item = new Episode
{
- IndexNumber = info.IndexNumber,
- ParentIndexNumber = info.ParentIndexNumber,
+ IndexNumber = episodeNumber,
+ ParentIndexNumber = seasonNumber,
IndexNumberEnd = info.IndexNumberEnd,
Name = episodeResult.Name,
PremiereDate = episodeResult.AirDate,
diff --git a/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs b/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs
index c671e7a93..5ac672f10 100644
--- a/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs
+++ b/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs
@@ -68,12 +68,15 @@ namespace MediaBrowser.XbmcMetadata.Providers
{
var file = GetXmlFile(new ItemInfo(item), directoryService);
- if (file is null)
+ if (file?.Exists is not true)
{
return false;
}
- return file.Exists && _fileSystem.GetLastWriteTimeUtc(file) > item.DateLastSaved;
+ var fileTime = _fileSystem.GetLastWriteTimeUtc(file);
+
+ // 1 minute tolerance to avoid detecting our own file writes
+ return (fileTime - item.DateLastSaved) > TimeSpan.FromMinutes(1);
}
protected abstract void Fetch(MetadataResult<T> result, string path, CancellationToken cancellationToken);
diff --git a/SharedVersion.cs b/SharedVersion.cs
index d26eb31ae..3b394d28b 100644
--- a/SharedVersion.cs
+++ b/SharedVersion.cs
@@ -1,4 +1,4 @@
using System.Reflection;
-[assembly: AssemblyVersion("10.11.0")]
-[assembly: AssemblyFileVersion("10.11.0")]
+[assembly: AssemblyVersion("10.12.0")]
+[assembly: AssemblyFileVersion("10.12.0")]
diff --git a/bump_version b/bump_version
index 6d08dc72f..0516a1806 100755
--- a/bump_version
+++ b/bump_version
@@ -58,7 +58,7 @@ for subproject in ${jellyfin_subprojects[@]}; do
done
# Set the version in the GitHub issue template file
-sed -i "s|${old_version}|${new_version_sed}|g" ${issue_template_file}
+sed -i "s|${old_version}|${new_version_sed}|g" "${issue_template_file}"
# Stage the changed files for commit
git add .
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs
index 8cb483f49..f386e882e 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs
@@ -70,13 +70,14 @@ public static class JellyfinQueryHelperExtensions
bool invert = false)
{
var itemFilter = OneOrManyExpressionBuilder<BaseItemEntity, Guid>(referenceIds, f => f.Id);
+ var typeFilter = OneOrManyExpressionBuilder<ItemValue, ItemValueType>(itemValueTypes, iv => iv.Type);
return baseQuery.Where(item =>
context.ItemValues
+ .Where(typeFilter)
.Join(context.ItemValuesMap, e => e.ItemValueId, e => e.ItemValueId, (itemVal, map) => new { itemVal, map })
.Any(val =>
- itemValueTypes.Contains(val.itemVal.Type)
- && context.BaseItems.Where(itemFilter).Any(e => e.CleanName == val.itemVal.CleanValue)
+ context.BaseItems.Where(itemFilter).Any(e => e.CleanName == val.itemVal.CleanValue)
&& val.map.ItemId == item.Id) == EF.Constant(!invert));
}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs
index b90a2e056..7bcc7eeca 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs
@@ -52,10 +52,14 @@ public class OptimisticLockBehavior : IEntityFrameworkCoreLockingBehavior
_logger = logger;
_writePolicy = Policy
- .HandleInner<Exception>(e => e.Message.Contains("database is locked", StringComparison.InvariantCultureIgnoreCase))
+ .HandleInner<Exception>(e =>
+ e.Message.Contains("database is locked", StringComparison.InvariantCultureIgnoreCase) ||
+ e.Message.Contains("database table is locked", StringComparison.InvariantCultureIgnoreCase))
.WaitAndRetry(sleepDurations.Length, backoffProvider, RetryHandle);
_writeAsyncPolicy = Policy
- .HandleInner<Exception>(e => e.Message.Contains("database is locked", StringComparison.InvariantCultureIgnoreCase))
+ .HandleInner<Exception>(e =>
+ e.Message.Contains("database is locked", StringComparison.InvariantCultureIgnoreCase) ||
+ e.Message.Contains("database table is locked", StringComparison.InvariantCultureIgnoreCase))
.WaitAndRetryAsync(sleepDurations.Length, backoffProvider, RetryHandle);
void RetryHandle(Exception exception, TimeSpan timespan, int retryNo, Context context)
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs
index 2b000b257..da63df8e2 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs
@@ -64,6 +64,7 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider
sqliteConnectionBuilder.DataSource = Path.Combine(_applicationPaths.DataPath, "jellyfin.db");
sqliteConnectionBuilder.Cache = GetOption(customOptions, "cache", Enum.Parse<SqliteCacheMode>, () => SqliteCacheMode.Default);
sqliteConnectionBuilder.Pooling = GetOption(customOptions, "pooling", e => e.Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase), () => true);
+ sqliteConnectionBuilder.DefaultTimeout = GetOption(customOptions, "command-timeout", int.Parse, () => 30);
var connectionString = sqliteConnectionBuilder.ToString();
diff --git a/src/Jellyfin.Extensions/DictionaryExtensions.cs b/src/Jellyfin.Extensions/DictionaryExtensions.cs
index 5bb828d01..814297093 100644
--- a/src/Jellyfin.Extensions/DictionaryExtensions.cs
+++ b/src/Jellyfin.Extensions/DictionaryExtensions.cs
@@ -13,35 +13,11 @@ namespace Jellyfin.Extensions
/// </summary>
/// <param name="dictionary">The dictionary.</param>
/// <param name="key1">The first checked key.</param>
- /// <returns>System.String.</returns>
- public static string? GetFirstNotNullNorWhiteSpaceValue(this IReadOnlyDictionary<string, string> dictionary, string key1)
- {
- return dictionary.GetFirstNotNullNorWhiteSpaceValue(key1, string.Empty, string.Empty);
- }
-
- /// <summary>
- /// Gets a string from a string dictionary, checking all keys sequentially,
- /// stopping at the first key that returns a result that's neither null nor blank.
- /// </summary>
- /// <param name="dictionary">The dictionary.</param>
- /// <param name="key1">The first checked key.</param>
- /// <param name="key2">The second checked key.</param>
- /// <returns>System.String.</returns>
- public static string? GetFirstNotNullNorWhiteSpaceValue(this IReadOnlyDictionary<string, string> dictionary, string key1, string key2)
- {
- return dictionary.GetFirstNotNullNorWhiteSpaceValue(key1, key2, string.Empty);
- }
-
- /// <summary>
- /// Gets a string from a string dictionary, checking all keys sequentially,
- /// stopping at the first key that returns a result that's neither null nor blank.
- /// </summary>
- /// <param name="dictionary">The dictionary.</param>
- /// <param name="key1">The first checked key.</param>
/// <param name="key2">The second checked key.</param>
/// <param name="key3">The third checked key.</param>
+ /// <param name="key4">The fourth checked key.</param>
/// <returns>System.String.</returns>
- public static string? GetFirstNotNullNorWhiteSpaceValue(this IReadOnlyDictionary<string, string> dictionary, string key1, string key2, string key3)
+ public static string? GetFirstNotNullNorWhiteSpaceValue(this IReadOnlyDictionary<string, string> dictionary, string key1, string? key2 = null, string? key3 = null, string? key4 = null)
{
if (dictionary.TryGetValue(key1, out var val) && !string.IsNullOrWhiteSpace(val))
{
@@ -58,6 +34,11 @@ namespace Jellyfin.Extensions
return val;
}
+ if (!string.IsNullOrEmpty(key4) && dictionary.TryGetValue(key4, out val) && !string.IsNullOrWhiteSpace(val))
+ {
+ return val;
+ }
+
return null;
}
}
diff --git a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj
index 1613d83bc..f52fd014d 100644
--- a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj
+++ b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj
@@ -15,7 +15,7 @@
<PropertyGroup>
<Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Extensions</PackageId>
- <VersionPrefix>10.11.0</VersionPrefix>
+ <VersionPrefix>10.12.0</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
diff --git a/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs b/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs
index a0dafb8f1..cbe97a821 100644
--- a/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs
+++ b/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs
@@ -42,7 +42,15 @@ public static class FfProbeKeyframeExtractor
try
{
process.Start();
- process.PriorityClass = ProcessPriorityClass.BelowNormal;
+ try
+ {
+ process.PriorityClass = ProcessPriorityClass.BelowNormal;
+ }
+ catch
+ {
+ // We do not care if process priority setting fails
+ // Ideally log a warning but this does not have a logger available
+ }
return ParseStream(process.StandardOutput);
}
diff --git a/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs b/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs
index 4c8ba58d0..4dbe769bf 100644
--- a/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs
+++ b/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs
@@ -7,23 +7,38 @@ public class SeasonPathParserTests
{
[Theory]
[InlineData("/Drive/Season 1", "/Drive", 1, true)]
+ [InlineData("/Drive/SEASON 1", "/Drive", 1, true)]
[InlineData("/Drive/Staffel 1", "/Drive", 1, true)]
+ [InlineData("/Drive/STAFFEL 1", "/Drive", 1, true)]
[InlineData("/Drive/Stagione 1", "/Drive", 1, true)]
+ [InlineData("/Drive/STAGIONE 1", "/Drive", 1, true)]
[InlineData("/Drive/sæson 1", "/Drive", 1, true)]
+ [InlineData("/Drive/SÆSON 1", "/Drive", 1, true)]
[InlineData("/Drive/Temporada 1", "/Drive", 1, true)]
+ [InlineData("/Drive/TEMPORADA 1", "/Drive", 1, true)]
[InlineData("/Drive/series 1", "/Drive", 1, true)]
+ [InlineData("/Drive/SERIES 1", "/Drive", 1, true)]
[InlineData("/Drive/Kausi 1", "/Drive", 1, true)]
+ [InlineData("/Drive/KAUSI 1", "/Drive", 1, true)]
[InlineData("/Drive/Säsong 1", "/Drive", 1, true)]
+ [InlineData("/Drive/SÄSONG 1", "/Drive", 1, true)]
[InlineData("/Drive/Seizoen 1", "/Drive", 1, true)]
+ [InlineData("/Drive/SEIZOEN 1", "/Drive", 1, true)]
[InlineData("/Drive/Seasong 1", "/Drive", 1, true)]
+ [InlineData("/Drive/SEASONG 1", "/Drive", 1, true)]
[InlineData("/Drive/Sezon 1", "/Drive", 1, true)]
+ [InlineData("/Drive/SEZON 1", "/Drive", 1, true)]
[InlineData("/Drive/sezona 1", "/Drive", 1, true)]
+ [InlineData("/Drive/SEZONA 1", "/Drive", 1, true)]
[InlineData("/Drive/sezóna 1", "/Drive", 1, true)]
+ [InlineData("/Drive/SEZÓNA 1", "/Drive", 1, true)]
[InlineData("/Drive/Sezonul 1", "/Drive", 1, true)]
+ [InlineData("/Drive/SEZONUL 1", "/Drive", 1, true)]
[InlineData("/Drive/시즌 1", "/Drive", 1, true)]
[InlineData("/Drive/シーズン 1", "/Drive", 1, true)]
[InlineData("/Drive/сезон 1", "/Drive", 1, true)]
[InlineData("/Drive/Сезон 1", "/Drive", 1, true)]
+ [InlineData("/Drive/СЕЗОН 1", "/Drive", 1, true)]
[InlineData("/Drive/Season 10", "/Drive", 10, true)]
[InlineData("/Drive/Season 100", "/Drive", 100, true)]
[InlineData("/Drive/s1", "/Drive", 1, true)]
@@ -46,8 +61,20 @@ public class SeasonPathParserTests
[InlineData("/Drive/s06e05", "/Drive", null, false)]
[InlineData("/Drive/The.Legend.of.Condor.Heroes.2017.V2.web-dl.1080p.h264.aac-hdctv", "/Drive", null, false)]
[InlineData("/Drive/extras", "/Drive", 0, true)]
+ [InlineData("/Drive/EXTRAS", "/Drive", 0, true)]
[InlineData("/Drive/specials", "/Drive", 0, true)]
+ [InlineData("/Drive/SPECIALS", "/Drive", 0, true)]
[InlineData("/Drive/Episode 1 Season 2", "/Drive", null, false)]
+ [InlineData("/Drive/Episode 1 SEASON 2", "/Drive", null, false)]
+ [InlineData("/media/YouTube/Devyn Johnston/2024-01-24 4070 Ti SUPER in under 7 minutes", "/media/YouTube/Devyn Johnston", null, false)]
+ [InlineData("/media/YouTube/Devyn Johnston/2025-01-28 5090 vs 2 SFF Cases", "/media/YouTube/Devyn Johnston", null, false)]
+ [InlineData("/Drive/202401244070", "/Drive", null, false)]
+ [InlineData("/Drive/Drive.S01.2160p.WEB-DL.DDP5.1.H.265-XXXX", "/Drive", 1, true)]
+ [InlineData("The Wonder Years/The.Wonder.Years.S04.1080p.PDTV.x264-JCH", "/The Wonder Years", 4, true)]
+ [InlineData("The Wonder Years/[The.Wonder.Years.S04.1080p.PDTV.x264-JCH]", "/The Wonder Years", 4, true)]
+ [InlineData("The Wonder Years/The.Wonder.Years [S04][1080p.PDTV.x264-JCH]", "/The Wonder Years", 4, true)]
+ [InlineData("The Wonder Years/The Wonder Years Season 01 1080p", "/The Wonder Years", 1, true)]
+
public void GetSeasonNumberFromPathTest(string path, string? parentPath, int? seasonNumber, bool isSeasonDirectory)
{
var result = SeasonPathParser.Parse(path, parentPath, true, true);
diff --git a/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs b/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs
index 84758c9c3..b81b7934c 100644
--- a/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs
+++ b/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs
@@ -19,6 +19,7 @@ namespace Jellyfin.Naming.Tests.TV
[InlineData("/some/path/The Show", "The Show")]
[InlineData("/some/path/The Show s02e10 720p hdtv", "The Show")]
[InlineData("/some/path/The Show s02e10 the episode 720p hdtv", "The Show")]
+ [InlineData("/some/path/1923 (2022)", "1923")]
public void SeriesResolverResolveTest(string path, string name)
{
var res = SeriesResolver.Resolve(_namingOptions, path);
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/OrderMapperTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/OrderMapperTests.cs
index caf2b06b7..8ac3e5e31 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/OrderMapperTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/OrderMapperTests.cs
@@ -12,7 +12,7 @@ public class OrderMapperTests
[Fact]
public void ShouldReturnMappedOrderForSortingByPremierDate()
{
- var orderFunc = OrderMapper.MapOrderByField(ItemSortBy.PremiereDate, new InternalItemsQuery()).Compile();
+ var orderFunc = OrderMapper.MapOrderByField(ItemSortBy.PremiereDate, new InternalItemsQuery(), null!).Compile();
var expectedDate = new DateTime(1, 2, 3);
var expectedProductionYearDate = new DateTime(4, 1, 1);
diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs
index 16c63ed49..04d1b3dc2 100644
--- a/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs
+++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs
@@ -61,7 +61,6 @@ namespace Jellyfin.Server.Integration.Tests.Controllers
var users = await response.Content.ReadFromJsonAsync<UserDto[]>(_jsonOptions);
Assert.NotNull(users);
Assert.Single(users);
- Assert.False(users![0].HasConfiguredPassword);
}
[Fact]
@@ -92,8 +91,6 @@ namespace Jellyfin.Server.Integration.Tests.Controllers
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var user = await response.Content.ReadFromJsonAsync<UserDto>(_jsonOptions);
Assert.Equal(TestUsername, user!.Name);
- Assert.False(user.HasPassword);
- Assert.False(user.HasConfiguredPassword);
_testUserId = user.Id;
@@ -149,12 +146,6 @@ namespace Jellyfin.Server.Integration.Tests.Controllers
using var response = await UpdateUserPassword(client, _testUserId, createRequest);
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
-
- var users = await JsonSerializer.DeserializeAsync<UserDto[]>(
- await client.GetStreamAsync("Users"), _jsonOptions);
- var user = users!.First(x => x.Id.Equals(_testUserId));
- Assert.True(user.HasPassword);
- Assert.True(user.HasConfiguredPassword);
}
[Fact]
@@ -172,12 +163,6 @@ namespace Jellyfin.Server.Integration.Tests.Controllers
using var response = await UpdateUserPassword(client, _testUserId, createRequest);
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
-
- var users = await JsonSerializer.DeserializeAsync<UserDto[]>(
- await client.GetStreamAsync("Users"), _jsonOptions);
- var user = users!.First(x => x.Id.Equals(_testUserId));
- Assert.False(user.HasPassword);
- Assert.False(user.HasConfiguredPassword);
}
}
}