Compare commits

..

1 Commits

Author SHA1 Message Date
renovate[bot]
9018586e11 Update skiasharp monorepo 2025-12-09 04:16:45 +00:00
71 changed files with 504 additions and 1296 deletions

View File

@@ -87,7 +87,6 @@ body:
label: Jellyfin Server version
description: What version of Jellyfin are you using?
options:
- 10.11.5
- 10.11.4
- 10.11.3
- 10.11.2

View File

@@ -21,20 +21,17 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup .NET
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
with:
dotnet-version: '9.0.x'
- name: Initialize CodeQL
uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
uses: github/codeql-action/init@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
with:
languages: ${{ matrix.language }}
queries: +security-extended
- name: Autobuild
uses: github/codeql-action/autobuild@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
uses: github/codeql-action/autobuild@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
uses: github/codeql-action/analyze@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7

View File

@@ -17,7 +17,7 @@ jobs:
repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: Setup .NET
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
with:
dotnet-version: '9.0.x'
@@ -26,7 +26,7 @@ jobs:
dotnet build Jellyfin.Server -o ./out
- name: Upload Head
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: abi-head
retention-days: 14
@@ -47,7 +47,7 @@ jobs:
fetch-depth: 0
- name: Setup .NET
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
with:
dotnet-version: '9.0.x'
@@ -65,7 +65,7 @@ jobs:
dotnet build Jellyfin.Server -o ./out
- name: Upload Head
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: abi-base
retention-days: 14
@@ -85,13 +85,13 @@ jobs:
steps:
- name: Download abi-head
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: abi-head
path: abi-head
- name: Download abi-base
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: abi-base
path: abi-base
@@ -106,7 +106,7 @@ jobs:
{
echo 'body<<EOF'
for file in Jellyfin.Data.dll MediaBrowser.Common.dll MediaBrowser.Controller.dll MediaBrowser.Model.dll Emby.Naming.dll Jellyfin.Extensions.dll Jellyfin.MediaEncoding.Keyframes.dll Jellyfin.Database.Implementations.dll; do
COMPAT_OUTPUT="$( { apicompat --left ./abi-base/${file} --right ./abi-head/${file}; } 2>&1 || true )"
COMPAT_OUTPUT="$( { apicompat --left ./abi-base/${file} --right ./abi-head/${file}; } 2>&1 )"
if [ "APICompat ran successfully without finding any breaking changes." != "${COMPAT_OUTPUT}" ]; then
printf "\n${file}\n${COMPAT_OUTPUT}\n"
fi

View File

@@ -20,17 +20,14 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: Setup .NET
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
with:
dotnet-version: '9.0.x'
- name: Generate openapi.json
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
- name: Upload openapi.json
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: openapi-head
retention-days: 14
@@ -49,7 +46,6 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
fetch-depth: 0
- name: Checkout common ancestor
env:
HEAD_REF: ${{ github.head_ref }}
@@ -58,17 +54,14 @@ jobs:
git -c protocol.version=2 fetch --prune --progress --no-recurse-submodules upstream +refs/heads/*:refs/remotes/upstream/* +refs/tags/*:refs/tags/*
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@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
with:
dotnet-version: '9.0.x'
- name: Generate openapi.json
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
- name: Upload openapi.json
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: openapi-base
retention-days: 14
@@ -77,7 +70,7 @@ jobs:
openapi-diff:
permissions:
pull-requests: write
pull-requests: write # to create or update comment (peter-evans/create-or-update-comment)
name: OpenAPI - Difference
if: ${{ github.event_name == 'pull_request_target' }}
@@ -87,27 +80,71 @@ jobs:
- openapi-base
steps:
- name: Download openapi-head
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: openapi-head
path: openapi-head
- name: Download openapi-base
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: openapi-base
path: openapi-base
- name: Detect OpenAPI changes
id: openapi-diff
uses: jellyfin/openapi-diff-action@9274f6bda9d01ab091942a4a8334baa53692e8a4 # v1.0.0
- name: Workaround openapi-diff issue
run: |
sed -i 's/"allOf"/"oneOf"/g' openapi-head/openapi.json
sed -i 's/"allOf"/"oneOf"/g' openapi-base/openapi.json
- name: Calculate OpenAPI difference
uses: docker://openapitools/openapi-diff
continue-on-error: true
with:
old-spec: openapi-base/openapi.json
new-spec: openapi-head/openapi.json
markdown: openapi-changelog.md
add-pr-comment: true
github-token: ${{ secrets.GITHUB_TOKEN }}
args: --fail-on-changed --markdown openapi-changes.md openapi-base/openapi.json openapi-head/openapi.json
- id: read-diff
name: Read openapi-diff output
run: |
# Read and fix markdown
body=$(cat openapi-changes.md)
# Write to workflow summary
echo "$body" >> $GITHUB_STEP_SUMMARY
# Set ApiChanged var
if [ "$body" != '' ]; then
echo "ApiChanged=1" >> "$GITHUB_OUTPUT"
else
echo "ApiChanged=0" >> "$GITHUB_OUTPUT"
fi
# Add header/footer for diff comment
echo '<!--openapi-diff-workflow-comment-->' > openapi-changes-reply.md
echo "<details>" >> openapi-changes-reply.md
echo "<summary>Changes in OpenAPI specification found. Expand to see details.</summary>" >> openapi-changes-reply.md
echo "" >> openapi-changes-reply.md
echo "$body" >> openapi-changes-reply.md
echo "" >> openapi-changes-reply.md
echo "</details>" >> openapi-changes-reply.md
- name: Find difference comment
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
id: find-comment
with:
issue-number: ${{ github.event.pull_request.number }}
direction: last
body-includes: openapi-diff-workflow-comment
- name: Reply or edit difference comment (changed)
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
if: ${{ steps.read-diff.outputs.ApiChanged == '1' }}
with:
issue-number: ${{ github.event.pull_request.number }}
comment-id: ${{ steps.find-comment.outputs.comment-id }}
edit-mode: replace
body-path: openapi-changes-reply.md
- name: Edit difference comment (unchanged)
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
if: ${{ steps.read-diff.outputs.ApiChanged == '0' && steps.find-comment.outputs.comment-id != '' }}
with:
issue-number: ${{ github.event.pull_request.number }}
comment-id: ${{ steps.find-comment.outputs.comment-id }}
edit-mode: replace
body: |
<!--openapi-diff-workflow-comment-->
No changes to OpenAPI specification found. See history of this comment for previous changes.
publish-unstable:
name: OpenAPI - Publish Unstable Spec
@@ -121,7 +158,7 @@ jobs:
run: |-
echo "JELLYFIN_VERSION=$(date +'%Y%m%d%H%M%S')" >> $GITHUB_ENV
- name: Download openapi-head
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: openapi-head
path: openapi-head
@@ -141,6 +178,7 @@ jobs:
username: "${{ secrets.REPO_USER }}"
key: "${{ secrets.REPO_KEY }}"
debug: false
script_stop: false
script: |
if ! test -d /run/workflows; then
sudo mkdir -p /run/workflows
@@ -182,7 +220,7 @@ jobs:
run: |-
echo "JELLYFIN_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
- name: Download openapi-head
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: openapi-head
path: openapi-head
@@ -202,6 +240,7 @@ jobs:
username: "${{ secrets.REPO_USER }}"
key: "${{ secrets.REPO_KEY }}"
debug: false
script_stop: false
script: |
if ! test -d /run/workflows; then
sudo mkdir -p /run/workflows

View File

@@ -22,7 +22,7 @@ jobs:
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
- uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
with:
dotnet-version: ${{ env.SDK_VERSION }}

View File

@@ -43,16 +43,13 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
repository: jellyfin/jellyfin-triage-script
- name: install python
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: '3.14'
cache: 'pip'
- name: install python packages
run: pip install -r rename/requirements.txt
- name: run rename script
run: python3 rename.py
working-directory: ./rename

View File

@@ -13,16 +13,13 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
repository: jellyfin/jellyfin-triage-script
- name: install python
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: '3.14'
cache: 'pip'
- name: install python packages
run: pip install -r main-repo-triage/requirements.txt
- name: check and comment issue
working-directory: ./main-repo-triage
run: python3 single_issue_gha.py

View File

@@ -21,7 +21,6 @@ jobs:
with:
project: Current Release
action: delete
column: In progress
repo-token: ${{ secrets.JF_BOT_TOKEN }}
- name: Add to 'Release Next' project

View File

@@ -117,7 +117,6 @@
- [sachk](https://github.com/sachk)
- [sammyrc34](https://github.com/sammyrc34)
- [samuel9554](https://github.com/samuel9554)
- [SapientGuardian](https://github.com/SapientGuardian)
- [scheidleon](https://github.com/scheidleon)
- [sebPomme](https://github.com/sebPomme)
- [SegiH](https://github.com/SegiH)
@@ -206,10 +205,6 @@
- [theshoeshiner](https://github.com/theshoeshiner)
- [TokerX](https://github.com/TokerX)
- [GeneMarks](https://github.com/GeneMarks)
- [Kirill Nikiforov](https://github.com/allmazz)
- [bjorntp](https://github.com/bjorntp)
- [martenumberto](https://github.com/martenumberto)
- [ZeusCraft10](https://github.com/ZeusCraft10)
# Emby Contributors

View File

@@ -18,14 +18,14 @@
<PackageVersion Include="DiscUtils.Udf" Version="0.16.13" />
<PackageVersion Include="DotNet.Glob" Version="3.1.3" />
<PackageVersion Include="FsCheck.Xunit" Version="3.3.2" />
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="8.3.1.1" />
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="8.3.1.2" />
<PackageVersion Include="ICU4N.Transliterator" Version="60.1.0-alpha.356" />
<PackageVersion Include="IDisposableAnalyzers" Version="4.0.8" />
<PackageVersion Include="Ignore" Version="0.2.1" />
<PackageVersion Include="Jellyfin.XmlTv" Version="10.8.0" />
<PackageVersion Include="libse" Version="4.0.12" />
<PackageVersion Include="LrcParser" Version="2025.623.0" />
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="7.0.0" />
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="6.1.0" />
<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" />
@@ -74,26 +74,26 @@
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
<PackageVersion Include="SharpFuzz" Version="2.2.0" />
<!-- Pinned to 3.116.1 because https://github.com/jellyfin/jellyfin/pull/14255 -->
<PackageVersion Include="SkiaSharp" Version="[3.116.1]" />
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="[3.116.1]" />
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="[3.116.1]" />
<PackageVersion Include="SkiaSharp" Version="[3.119.1]" />
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="[3.119.1]" />
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="[3.119.1]" />
<PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" />
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
<PackageVersion Include="Svg.Skia" Version="3.2.1" />
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="6.9.0" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.9.0" />
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="6.5.0" />
<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.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.10.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" />
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.61" />
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.23" />
<PackageVersion Include="xunit" Version="2.9.3" />
</ItemGroup>
</Project>

View File

@@ -1,75 +0,0 @@
using System.Text.RegularExpressions;
namespace Emby.Naming.Book
{
/// <summary>
/// Helper class to retrieve basic metadata from a book filename.
/// </summary>
public static class BookFileNameParser
{
private const string NameMatchGroup = "name";
private const string IndexMatchGroup = "index";
private const string YearMatchGroup = "year";
private const string SeriesNameMatchGroup = "seriesName";
private static readonly Regex[] _nameMatches =
[
// seriesName (seriesYear) #index (of count) (year) where only seriesName and index are required
new Regex(@"^(?<seriesName>.+?)((\s\((?<seriesYear>[0-9]{4})\))?)\s#(?<index>[0-9]+)((\s\(of\s(?<count>[0-9]+)\))?)((\s\((?<year>[0-9]{4})\))?)$"),
new Regex(@"^(?<name>.+?)\s\((?<seriesName>.+?),\s#(?<index>[0-9]+)\)((\s\((?<year>[0-9]{4})\))?)$"),
new Regex(@"^(?<index>[0-9]+)\s\-\s(?<name>.+?)((\s\((?<year>[0-9]{4})\))?)$"),
new Regex(@"(?<name>.*)\((?<year>[0-9]{4})\)"),
// last resort matches the whole string as the name
new Regex(@"(?<name>.*)")
];
/// <summary>
/// Parse a filename name to retrieve the book name, series name, index, and year.
/// </summary>
/// <param name="name">Book filename to parse for information.</param>
/// <returns>Returns <see cref="BookFileNameParserResult"/> object.</returns>
public static BookFileNameParserResult Parse(string? name)
{
var result = new BookFileNameParserResult();
if (name == null)
{
return result;
}
foreach (var regex in _nameMatches)
{
var match = regex.Match(name);
if (!match.Success)
{
continue;
}
if (match.Groups.TryGetValue(NameMatchGroup, out Group? nameGroup) && nameGroup.Success)
{
result.Name = nameGroup.Value.Trim();
}
if (match.Groups.TryGetValue(IndexMatchGroup, out Group? indexGroup) && indexGroup.Success && int.TryParse(indexGroup.Value, out var index))
{
result.Index = index;
}
if (match.Groups.TryGetValue(YearMatchGroup, out Group? yearGroup) && yearGroup.Success && int.TryParse(yearGroup.Value, out var year))
{
result.Year = year;
}
if (match.Groups.TryGetValue(SeriesNameMatchGroup, out Group? seriesGroup) && seriesGroup.Success)
{
result.SeriesName = seriesGroup.Value.Trim();
}
break;
}
return result;
}
}
}

View File

@@ -1,41 +0,0 @@
using System;
namespace Emby.Naming.Book
{
/// <summary>
/// Data object used to pass metadata parsed from a book filename.
/// </summary>
public class BookFileNameParserResult
{
/// <summary>
/// Initializes a new instance of the <see cref="BookFileNameParserResult"/> class.
/// </summary>
public BookFileNameParserResult()
{
Name = null;
Index = null;
Year = null;
SeriesName = null;
}
/// <summary>
/// Gets or sets the name of the book.
/// </summary>
public string? Name { get; set; }
/// <summary>
/// Gets or sets the book index.
/// </summary>
public int? Index { get; set; }
/// <summary>
/// Gets or sets the publication year.
/// </summary>
public int? Year { get; set; }
/// <summary>
/// Gets or sets the series name.
/// </summary>
public string? SeriesName { get; set; }
}
}

View File

@@ -39,24 +39,22 @@ namespace Emby.Server.Implementations.Cryptography
{
if (string.Equals(hash.Id, "PBKDF2", StringComparison.Ordinal))
{
var iterations = GetIterationsParameter(hash);
return hash.Hash.SequenceEqual(
Rfc2898DeriveBytes.Pbkdf2(
password,
hash.Salt,
iterations,
int.Parse(hash.Parameters["iterations"], CultureInfo.InvariantCulture),
HashAlgorithmName.SHA1,
32));
}
if (string.Equals(hash.Id, "PBKDF2-SHA512", StringComparison.Ordinal))
{
var iterations = GetIterationsParameter(hash);
return hash.Hash.SequenceEqual(
Rfc2898DeriveBytes.Pbkdf2(
password,
hash.Salt,
iterations,
int.Parse(hash.Parameters["iterations"], CultureInfo.InvariantCulture),
HashAlgorithmName.SHA512,
DefaultOutputLength));
}
@@ -64,27 +62,6 @@ namespace Emby.Server.Implementations.Cryptography
throw new NotSupportedException($"Can't verify hash with id: {hash.Id}");
}
/// <summary>
/// Extracts and validates the iterations parameter from a password hash.
/// </summary>
/// <param name="hash">The password hash containing parameters.</param>
/// <returns>The number of iterations.</returns>
/// <exception cref="FormatException">Thrown when iterations parameter is missing or invalid.</exception>
private static int GetIterationsParameter(PasswordHash hash)
{
if (!hash.Parameters.TryGetValue("iterations", out var iterationsStr))
{
throw new FormatException($"Password hash with id '{hash.Id}' is missing required 'iterations' parameter.");
}
if (!int.TryParse(iterationsStr, CultureInfo.InvariantCulture, out var iterations))
{
throw new FormatException($"Password hash with id '{hash.Id}' has invalid 'iterations' parameter: '{iterationsStr}'.");
}
return iterations;
}
/// <inheritdoc />
public byte[] GenerateSalt()
=> GenerateSalt(DefaultSaltLength);

View File

@@ -1,6 +1,5 @@
using System;
using System.IO;
using System.Text.RegularExpressions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Resolvers;
@@ -71,55 +70,12 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule
{
// If file has content, base ignoring off the content .gitignore-style rules
var rules = ignoreFileContent.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return CheckIgnoreRules(path, rules, isDirectory);
}
/// <summary>
/// Checks whether a path should be ignored based on an array of ignore rules.
/// </summary>
/// <param name="path">The path to check.</param>
/// <param name="rules">The array of ignore rules.</param>
/// <param name="isDirectory">Whether the path is a directory.</param>
/// <returns>True if the path should be ignored.</returns>
internal static bool CheckIgnoreRules(string path, string[] rules, bool isDirectory)
=> CheckIgnoreRules(path, rules, isDirectory, IsWindows);
/// <summary>
/// Checks whether a path should be ignored based on an array of ignore rules.
/// </summary>
/// <param name="path">The path to check.</param>
/// <param name="rules">The array of ignore rules.</param>
/// <param name="isDirectory">Whether the path is a directory.</param>
/// <param name="normalizePath">Whether to normalize backslashes to forward slashes (for Windows paths).</param>
/// <returns>True if the path should be ignored.</returns>
internal static bool CheckIgnoreRules(string path, string[] rules, bool isDirectory, bool normalizePath)
{
var ignore = new Ignore.Ignore();
// Add each rule individually to catch and skip invalid patterns
var validRulesAdded = 0;
foreach (var rule in rules)
{
try
{
ignore.Add(rule);
validRulesAdded++;
}
catch (RegexParseException)
{
// Ignore invalid patterns
}
}
// If no valid rules were added, fall back to ignoring everything (like an empty .ignore file)
if (validRulesAdded == 0)
{
return true;
}
ignore.Add(rules);
// Mitigate the problem of the Ignore library not handling Windows paths correctly.
// See https://github.com/jellyfin/jellyfin/issues/15484
var pathToCheck = normalizePath ? path.NormalizePath('/') : path;
var pathToCheck = IsWindows ? path.NormalizePath('/') : path;
// Add trailing slash for directories to match "folder/"
if (isDirectory)

View File

@@ -1058,7 +1058,6 @@ namespace Emby.Server.Implementations.Library
{
IncludeItemTypes = [BaseItemKind.MusicArtist],
Name = name,
UseRawName = true,
DtoOptions = options
}).Cast<MusicArtist>()
.OrderBy(i => i.IsAccessedByName ? 1 : 0)
@@ -2144,7 +2143,7 @@ namespace Emby.Server.Implementations.Library
item.ValidateImages();
await _itemRepository.SaveImagesAsync(item).ConfigureAwait(false);
_itemRepository.SaveImages(item);
RegisterItem(item);
}

View File

@@ -5,12 +5,12 @@
using System;
using System.IO;
using System.Linq;
using Emby.Naming.Book;
using Jellyfin.Data.Enums;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Resolvers;
using MediaBrowser.Model.Entities;
namespace Emby.Server.Implementations.Library.Resolvers.Books
{
@@ -35,22 +35,17 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books
var extension = Path.GetExtension(args.Path.AsSpan());
if (!_validExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
if (_validExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
{
return null;
// It's a book
return new Book
{
Path = args.Path,
IsInMixedFolder = true
};
}
var result = BookFileNameParser.Parse(Path.GetFileNameWithoutExtension(args.Path));
return new Book
{
Path = args.Path,
Name = result.Name ?? string.Empty,
IndexNumber = result.Index,
ProductionYear = result.Year,
SeriesName = result.SeriesName ?? Path.GetFileName(Path.GetDirectoryName(args.Path)),
IsInMixedFolder = true,
};
return null;
}
private Book GetBook(ItemResolveArgs args)
@@ -64,22 +59,15 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books
StringComparison.OrdinalIgnoreCase);
}).ToList();
// directory is only considered a book when it contains exactly one supported file
// other library structures with multiple books to a directory will get picked up as individual files
// Don't return a Book if there is more (or less) than one document in the directory
if (bookFiles.Count != 1)
{
return null;
}
var result = BookFileNameParser.Parse(Path.GetFileName(args.Path));
return new Book
{
Path = bookFiles[0].FullName,
Name = result.Name ?? string.Empty,
IndexNumber = result.Index,
ProductionYear = result.Year,
SeriesName = result.SeriesName ?? string.Empty,
Path = bookFiles[0].FullName
};
}
}

View File

@@ -2,13 +2,13 @@
"Albums": "ألبومات",
"AppDeviceValues": "تطبيق: {0}, جهاز: {1}",
"Application": "تطبيق",
"Artists": "فنانون",
"Artists": "الفنانون",
"AuthenticationSucceededWithUserName": "نجحت عملية التوثيق بـ {0}",
"Books": "الكتب",
"CameraImageUploadedFrom": "رُفعت صورة الكاميرا الجديدة من {0}",
"Channels": "القنوات",
"ChapterNameValue": "الفصل {0}",
"Collections": "مجموعات",
"Collections": "المجموعات",
"DeviceOfflineWithName": "قُطِع الاتصال ب{0}",
"DeviceOnlineWithName": "{0} متصل",
"FailedLoginAttemptWithUserName": "محاولة تسجيل الدخول فاشلة من {0}",
@@ -16,7 +16,7 @@
"Folders": "المجلدات",
"Genres": "التصنيفات",
"HeaderAlbumArtists": "فناني الألبوم",
"HeaderContinueWatching": "متابعة المشاهدة",
"HeaderContinueWatching": "أكمل المشاهدة",
"HeaderFavoriteAlbums": "الألبومات المفضلة",
"HeaderFavoriteArtists": "الفنانون المفضلون",
"HeaderFavoriteEpisodes": "الحلقات المفضلة",

View File

@@ -16,7 +16,7 @@
"Collections": "Калекцыі",
"Default": "Па змаўчанні",
"FailedLoginAttemptWithUserName": "Няўдалая спроба ўваходу з {0}",
"Folders": "Папкі",
"Folders": "Тэчкі",
"Favorites": "Абранае",
"External": "Знешні",
"Genres": "Жанры",
@@ -95,7 +95,7 @@
"ServerNameNeedsToBeRestarted": "{0} патрабуе перазапуску",
"Shows": "Шоу",
"StartupEmbyServerIsLoading": "Jellyfin Server загружаецца. Калі ласка, паўтарыце спробу крыху пазней.",
"SubtitleDownloadFailureFromForItem": "Субцітры для {1} не ўдалося спампаваць з {0}",
"SubtitleDownloadFailureFromForItem": "Не атрымалася спампаваць субтытры з {0} для {1}",
"TvShows": "Тэлепраграма",
"Undefined": "Нявызначана",
"UserLockedOutWithName": "Карыстальнік {0} быў заблакіраваны",
@@ -104,7 +104,7 @@
"UserStartedPlayingItemWithValues": "{0} прайграваецца {1} на {2}",
"UserStoppedPlayingItemWithValues": "{0} скончыў прайграванне {1} на {2}",
"ValueHasBeenAddedToLibrary": "{0} быў дададзены ў вашу медыятэку",
"ValueSpecialEpisodeName": "Спецвыпуск - {0}",
"ValueSpecialEpisodeName": "Спецэпізод - {0}",
"VersionNumber": "Версія {0}",
"TasksMaintenanceCategory": "Абслугоўванне",
"TasksLibraryCategory": "Бібліятэка",
@@ -114,7 +114,7 @@
"TaskCleanCacheDescription": "Выдаляе файлы кэша, якія больш не патрэбныя сістэме.",
"TaskRefreshChapterImages": "Вынуць выявы раздзелаў",
"TaskRefreshLibrary": "Сканаваць бібліятэку",
"TaskRefreshLibraryDescription": "Сканіруе вашу медыятэку на наяўнасць новых файлаў і абнаўляе метаданыя.",
"TaskRefreshLibraryDescription": "Скануе вашу медыятэку на наяўнасць новых файлаў і абнаўляе метададзеныя.",
"TaskCleanLogs": "Ачысціць журнал",
"TaskRefreshPeople": "Абнавіць выканаўцаў",
"TaskRefreshPeopleDescription": "Абнаўленне метаданых для акцёраў і рэжысёраў у вашай медыятэцы.",
@@ -137,5 +137,5 @@
"TaskExtractMediaSegments": "Сканіраванне медыя-сегмента",
"TaskMoveTrickplayImages": "Перанесці месцазнаходжанне выявы Trickplay",
"CleanupUserDataTask": "Задача па ачыстцы дадзеных карыстальніка",
"CleanupUserDataTaskDescription": "Ачышчае ўсе даныя карыстальніка (стан прагляду, абранае і г.д.) для медыяфайлаў, што адсутнічаюць больш за 90 дзён."
"CleanupUserDataTaskDescription": "Ачысьціць усе дадзеныя карыстальніка (стан прагляду, абранае і г.д.) для медыяфайлаў, што адсутнічаюць больш за 90 дзён."
}

View File

@@ -8,7 +8,7 @@
"CameraImageUploadedFrom": "Mae delwedd camera newydd wedi'i lanlwytho o {0}",
"Books": "Llyfrau",
"AuthenticationSucceededWithUserName": "{0} wedii ddilysun llwyddiannus",
"Artists": "Crewyr",
"Artists": "Artistiaid",
"AppDeviceValues": "Ap: {0}, Dyfais: {1}",
"Albums": "Albwmau",
"Genres": "Genres",
@@ -67,7 +67,7 @@
"NotificationOptionAudioPlayback": "Dechreuwyd chwarae sain",
"MessageServerConfigurationUpdated": "Mae gosodiadau gweinydd wedi'i ddiweddaru",
"MessageNamedServerConfigurationUpdatedWithValue": "Mae adran gosodiadau gweinydd {0} wedi'i diweddaru",
"FailedLoginAttemptWithUserName": "Cais mewngofnodi wedi methu o {0}",
"FailedLoginAttemptWithUserName": "Cais mewngofnodi wedi methu gan {0}",
"ValueHasBeenAddedToLibrary": "{0} wedi'i hychwanegu at eich llyfrgell gyfryngau",
"UserStoppedPlayingItemWithValues": "{0} wedi gorffen chwarae {1} ar {2}",
"UserStartedPlayingItemWithValues": "{0} yn chwarae {1} ar {2}",
@@ -123,14 +123,5 @@
"TaskRefreshChapterImages": "Echdynnu Lluniau Pennod",
"TaskCleanCacheDescription": "Dileu ffeiliau cache nad oes eu hangen ar y system mwyach.",
"TaskCleanCache": "Gwaghau Ffolder Cache",
"HearingImpaired": "Nam ar y clyw",
"TaskAudioNormalization": "Gwastatau Sain",
"TaskAudioNormalizationDescription": "Yn sganio ffeiliau am ddata gwastatau sain.",
"TaskRefreshTrickplayImages": "Creuwch lluniau Trickplay",
"TaskRefreshTrickplayImagesDescription": "Creu rhagolygon Trickplay ar gyfer fideos mewn llyfrgelloedd gweithredol.",
"TaskDownloadMissingLyrics": "Lawrlwytho geiriau coll",
"TaskDownloadMissingLyricsDescription": "Lawrlwytho geiriau caneuon",
"TaskCleanCollectionsAndPlaylists": "Glanhau casgliadau a rhestrau chwarae",
"TaskCleanCollectionsAndPlaylistsDescription": "Dileu eitemau o gasgliadau a rhestrau chwarae sydd ddim yn bodoli bellach.",
"TaskExtractMediaSegments": "Sganio Darnau Cyfryngau"
"HearingImpaired": "Nam ar y clyw"
}

View File

@@ -9,9 +9,9 @@
"Channels": "Kanäle",
"ChapterNameValue": "Kapitel {0}",
"Collections": "Sammlungen",
"DeviceOfflineWithName": "{0} ist offline",
"DeviceOnlineWithName": "{0} ist online",
"FailedLoginAttemptWithUserName": "Anmeldung von {0} fehlgeschlagen",
"DeviceOfflineWithName": "{0} hat die Verbindung getrennt",
"DeviceOnlineWithName": "{0} ist verbunden",
"FailedLoginAttemptWithUserName": "Fehlgeschlagener Anmeldeversuch von {0}",
"Favorites": "Favoriten",
"Folders": "Verzeichnisse",
"Genres": "Genres",
@@ -21,7 +21,7 @@
"HeaderFavoriteArtists": "Lieblingsinterpreten",
"HeaderFavoriteEpisodes": "Lieblingsepisoden",
"HeaderFavoriteShows": "Lieblingsserien",
"HeaderFavoriteSongs": "Lieblingssongs",
"HeaderFavoriteSongs": "Lieblingslieder",
"HeaderLiveTV": "Live TV",
"HeaderNextUp": "Als Nächstes",
"HeaderRecordingGroups": "Aufnahme-Gruppen",
@@ -46,7 +46,7 @@
"NewVersionIsAvailable": "Eine neue Jellyfin-Serverversion steht zum Download bereit.",
"NotificationOptionApplicationUpdateAvailable": "Anwendungsaktualisierung verfügbar",
"NotificationOptionApplicationUpdateInstalled": "Anwendungsaktualisierung installiert",
"NotificationOptionAudioPlayback": "Audio wird abgespielt",
"NotificationOptionAudioPlayback": "Audiowiedergabe gestartet",
"NotificationOptionAudioPlaybackStopped": "Audiowiedergabe gestoppt",
"NotificationOptionCameraImageUploaded": "Foto hochgeladen",
"NotificationOptionInstallationFailed": "Installation fehlgeschlagen",
@@ -57,8 +57,8 @@
"NotificationOptionPluginUpdateInstalled": "Pluginaktualisierung installiert",
"NotificationOptionServerRestartRequired": "Serverneustart notwendig",
"NotificationOptionTaskFailed": "Geplante Aufgabe fehlgeschlagen",
"NotificationOptionUserLockedOut": "Benutzer gesperrt",
"NotificationOptionVideoPlayback": "Video wird abgespielt",
"NotificationOptionUserLockedOut": "Benutzer ausgeschlossen",
"NotificationOptionVideoPlayback": "Videowiedergabe gestartet",
"NotificationOptionVideoPlaybackStopped": "Videowiedergabe gestoppt",
"Photos": "Fotos",
"Playlists": "Wiedergabelisten",
@@ -82,7 +82,7 @@
"UserCreatedWithName": "Benutzer {0} wurde erstellt",
"UserDeletedWithName": "Benutzer {0} wurde gelöscht",
"UserDownloadingItemWithValues": "{0} lädt {1} herunter",
"UserLockedOutWithName": "Benutzer {0} wurde gesperrt",
"UserLockedOutWithName": "Benutzer {0} wurde ausgeschlossen",
"UserOfflineFromDevice": "{0} wurde getrennt von {1}",
"UserOnlineFromDevice": "{0} ist online von {1}",
"UserPasswordChangedWithName": "Das Passwort für Benutzer {0} wurde geändert",
@@ -97,25 +97,25 @@
"TaskRefreshChannelsDescription": "Aktualisiert Internet-Kanal-Informationen.",
"TaskRefreshChannels": "Kanäle aktualisieren",
"TaskCleanTranscodeDescription": "Löscht Transkodierungsdateien, die älter als einen Tag sind.",
"TaskCleanTranscode": "Transkodierungsverzeichnis leeren",
"TaskCleanTranscode": "Transkodierungs-Verzeichnis aufräumen",
"TaskUpdatePluginsDescription": "Lädt Updates für Plugins herunter, welche für automatische Updates konfiguriert sind und installiert diese.",
"TaskUpdatePlugins": "Plugins aktualisieren",
"TaskRefreshPeopleDescription": "Aktualisiert Metadaten für Schauspieler und Regisseure in deinen Bibliotheken.",
"TaskRefreshPeople": "Personen aktualisieren",
"TaskCleanLogsDescription": "Lösche Log-Dateien, die älter als {0} Tage sind.",
"TaskCleanLogs": "Protokollverzeichnis leeren",
"TaskRefreshLibraryDescription": "Durchsucht deine Medienbibliothek nach neuen Dateien und aktualisiert Metadaten.",
"TaskCleanLogs": "Log-Verzeichnis aufräumen",
"TaskRefreshLibraryDescription": "Durchsucht alle Bibliotheken nach neu hinzugefügten Dateien und aktualisiert Metadaten.",
"TaskRefreshLibrary": "Medien-Bibliothek scannen",
"TaskRefreshChapterImagesDescription": "Erstellt Vorschaubilder für Videokapitel.",
"TaskRefreshChapterImages": "Kapitelvorschauen erstellen",
"TaskCleanCacheDescription": "Löscht Cache-Dateien, die vom System nicht mehr benötigt werden.",
"TaskCleanCache": "Cache-Verzeichnis leeren",
"TaskRefreshChapterImagesDescription": "Erstellt Vorschaubilder für Videos, die Kapitel besitzen.",
"TaskRefreshChapterImages": "Kapitel-Bilder extrahieren",
"TaskCleanCacheDescription": "Löscht vom System nicht mehr benötigte Zwischenspeicherdateien.",
"TaskCleanCache": "Zwischenspeicher-Verzeichnis aufräumen",
"TasksChannelsCategory": "Internet-Kanäle",
"TasksApplicationCategory": "Anwendung",
"TasksLibraryCategory": "Bibliothek",
"TasksMaintenanceCategory": "Wartung",
"TaskCleanActivityLogDescription": "Löscht Aktivitätsprotokolleinträge, die älter als das konfigurierte Alter sind.",
"TaskCleanActivityLog": "Aktivitätsverlauf bereinigen",
"TaskCleanActivityLog": "Aktivitätsprotokolle aufräumen",
"Undefined": "Undefiniert",
"Forced": "Erzwungen",
"Default": "Standard",

View File

@@ -70,7 +70,7 @@
"ScheduledTaskFailedWithName": "{0} falló",
"ScheduledTaskStartedWithName": "{0} iniciado",
"ServerNameNeedsToBeRestarted": "{0} necesita ser reiniciado",
"Shows": "Series",
"Shows": "Programas",
"Songs": "Canciones",
"StartupEmbyServerIsLoading": "El servidor Jellyfin se está cargando. Vuelve a intentarlo en breve.",
"SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",

View File

@@ -72,7 +72,7 @@
"NotificationOptionApplicationUpdateAvailable": "Rakenduse uuendus on saadaval",
"NewVersionIsAvailable": "Jellyfin serveri uus versioon on allalaadimiseks saadaval.",
"NameSeasonUnknown": "Tundmatu hooaeg",
"NameSeasonNumber": "{0}. hooaeg",
"NameSeasonNumber": "Hooaeg {0}",
"NameInstallFailed": "{0} paigaldamine nurjus",
"MusicVideos": "Muusikavideod",
"Music": "Muusika",

View File

@@ -1,9 +0,0 @@
{
"Albums": "Pukaemi",
"AppDeviceValues": "Taupānga: {0}, Pūrere: {1}",
"Application": "Taupānga",
"Artists": "Kaiwaiata",
"AuthenticationSucceededWithUserName": "{0} has been successfully authenticated",
"Books": "Ngā pukapuka",
"CameraImageUploadedFrom": "Kua tuku ake he whakaahua kāmera hou mai i {0}"
}

View File

@@ -2,12 +2,12 @@
"AppDeviceValues": "അപ്ലിക്കേഷൻ: {0}, ഉപകരണം: {1}",
"Application": "അപ്ലിക്കേഷൻ",
"AuthenticationSucceededWithUserName": "{0} വിജയകരമായി പ്രാമാണീകരിച്ചു",
"CameraImageUploadedFrom": "{0} എന്നതിൽ നിന്ന് ഒരു പുതിയ ക്യാമറ ചിത്രം അപ്‌ലോഡുചെയ്‌തു",
"CameraImageUploadedFrom": "Camera 0 from എന്നതിൽ നിന്ന് ഒരു പുതിയ ക്യാമറ ചിത്രം അപ്‌ലോഡുചെയ്‌തു",
"ChapterNameValue": "അധ്യായം {0}",
"DeviceOfflineWithName": "{0} വിച്ഛേദിച്ചു",
"DeviceOnlineWithName": "{0} ബന്ധിപ്പിച്ചു",
"FailedLoginAttemptWithUserName": "{0}ൽ നിന്നുള്ള പ്രവേശന ശ്രമം പരാജയപ്പെട്ടു",
"Forced": "നിർബന്ധിതമായി",
"Forced": "നിർബന്ധിച്ചു",
"HeaderFavoriteAlbums": "പ്രിയപ്പെട്ട ആൽബങ്ങൾ",
"HeaderFavoriteArtists": "പ്രിയപ്പെട്ട കലാകാരന്മാർ",
"HeaderFavoriteEpisodes": "പ്രിയപ്പെട്ട എപ്പിസോഡുകൾ",
@@ -114,7 +114,7 @@
"Artists": "കലാകാരന്മാർ",
"Shows": "ഷോകൾ",
"Default": "സ്ഥിരസ്ഥിതി",
"Favorites": "പ്രിയപ്പെട്ടവ",
"Favorites": "പ്രിയങ്കരങ്ങൾ",
"Books": "പുസ്തകങ്ങൾ",
"Genres": "വിഭാഗങ്ങൾ",
"Channels": "ചാനലുകൾ",

View File

@@ -125,8 +125,8 @@
"TaskKeyframeExtractorDescription": "Wyodrębnia klatki kluczowe z plików wideo w celu utworzenia bardziej precyzyjnych list odtwarzania HLS. To zadanie może trwać przez długi czas.",
"TaskKeyframeExtractor": "Ekstraktor klatek kluczowych",
"HearingImpaired": "Niedosłyszący",
"TaskRefreshTrickplayImages": "Generuj obrazy Trickplay",
"TaskRefreshTrickplayImagesDescription": "Tworzy podglądy Trickplay dla filmów we włączonych bibliotekach.",
"TaskRefreshTrickplayImages": "Generuj obrazy trickplay",
"TaskRefreshTrickplayImagesDescription": "Tworzy podglądy trickplay dla filmów we włączonych bibliotekach.",
"TaskCleanCollectionsAndPlaylistsDescription": "Usuwa elementy z kolekcji i list odtwarzania, które już nie istnieją.",
"TaskCleanCollectionsAndPlaylists": "Oczyść kolekcje i listy odtwarzania",
"TaskAudioNormalization": "Normalizacja dźwięku",

View File

@@ -125,8 +125,8 @@
"TaskKeyframeExtractor": "Extrator de Quadros-chave",
"External": "Externo",
"HearingImpaired": "Surdo",
"TaskRefreshTrickplayImages": "Gerar Imagens de Trickplay",
"TaskRefreshTrickplayImagesDescription": "Cria ficheiros de trickplay para vídeos nas bibliotecas ativas.",
"TaskRefreshTrickplayImages": "Gerar imagens de truques",
"TaskRefreshTrickplayImagesDescription": "Cria vizualizações de truques para videos nas librarias ativas.",
"TaskCleanCollectionsAndPlaylistsDescription": "Remove itens de coleções e listas de reprodução que já não existem.",
"TaskCleanCollectionsAndPlaylists": "Limpar coleções e listas de reprodução",
"TaskAudioNormalizationDescription": "Analisa os ficheiros para obter dados de normalização de áudio.",

View File

@@ -135,7 +135,5 @@
"TaskExtractMediaSegments": "การสแกนส่วนของสื่อมีเดีย",
"TaskMoveTrickplayImagesDescription": "ย้ายไฟล์ Trickplay ตามการตั้งค่าของไลบรารี",
"TaskExtractMediaSegmentsDescription": "แยกหรือดึงส่วนของสื่อจากปลั๊กอินที่เปิดใช้งาน MediaSegment",
"TaskMoveTrickplayImages": "ย้ายตำแหน่งเก็บภาพตัวอย่าง Trickplay",
"CleanupUserDataTask": "ส่วนงานล้างข้อมูลผู้ใช้",
"CleanupUserDataTaskDescription": "ล้างข้อมูลผู้ใช้ทั้งหมด (สถานะการรับชม สถานะรายการโปรด ฯลฯ) จากสื่อที่ไม่ได้ใช้งานแล้วอย่างน้อย 90 วัน"
"TaskMoveTrickplayImages": "ย้ายตำแหน่งเก็บภาพตัวอย่าง Trickplay"
}

View File

@@ -38,7 +38,6 @@ namespace Emby.Server.Implementations.Localization
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
private readonly ConcurrentDictionary<string, CultureDto?> _cultureCache = new(StringComparer.OrdinalIgnoreCase);
private List<CultureDto> _cultures = [];
private FrozenDictionary<string, string> _iso6392BtoT = null!;
@@ -162,7 +161,6 @@ namespace Emby.Server.Implementations.Localization
list.Add(new CultureDto(name, displayname, twoCharName, threeLetterNames));
}
_cultureCache.Clear();
_cultures = list;
_iso6392BtoT = iso6392BtoTdict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
}
@@ -171,31 +169,20 @@ namespace Emby.Server.Implementations.Localization
/// <inheritdoc />
public CultureDto? FindLanguageInfo(string language)
{
if (string.IsNullOrEmpty(language))
// TODO language should ideally be a ReadOnlySpan but moq cannot mock ref structs
for (var i = 0; i < _cultures.Count; i++)
{
return null;
var culture = _cultures[i];
if (language.Equals(culture.DisplayName, StringComparison.OrdinalIgnoreCase)
|| language.Equals(culture.Name, StringComparison.OrdinalIgnoreCase)
|| culture.ThreeLetterISOLanguageNames.Contains(language, StringComparison.OrdinalIgnoreCase)
|| language.Equals(culture.TwoLetterISOLanguageName, StringComparison.OrdinalIgnoreCase))
{
return culture;
}
}
return _cultureCache.GetOrAdd(
language,
static (lang, cultures) =>
{
// TODO language should ideally be a ReadOnlySpan but moq cannot mock ref structs
for (var i = 0; i < cultures.Count; i++)
{
var culture = cultures[i];
if (lang.Equals(culture.DisplayName, StringComparison.OrdinalIgnoreCase)
|| lang.Equals(culture.Name, StringComparison.OrdinalIgnoreCase)
|| culture.ThreeLetterISOLanguageNames.Contains(lang, StringComparison.OrdinalIgnoreCase)
|| lang.Equals(culture.TwoLetterISOLanguageName, StringComparison.OrdinalIgnoreCase))
{
return culture;
}
}
return null;
},
_cultures);
return default;
}
/// <inheritdoc />
@@ -324,19 +311,15 @@ namespace Emby.Server.Implementations.Localization
else
{
// Fall back to server default language for ratings check
var ratingsDictionary = GetParentalRatingsDictionary();
// If it has no ratings, use the US ratings
var ratingsDictionary = GetParentalRatingsDictionary() ?? GetParentalRatingsDictionary("us");
if (ratingsDictionary is not null && ratingsDictionary.TryGetValue(rating, out ParentalRatingScore? value))
{
return value;
}
}
// If we don't find anything, check all ratings systems, starting with US
if (_allParentalRatings.TryGetValue("us", out var usRatings) && usRatings.TryGetValue(rating, out var usValue))
{
return usValue;
}
// If we don't find anything, check all ratings systems
foreach (var dictionary in _allParentalRatings.Values)
{
if (dictionary.TryGetValue(rating, out var value))

View File

@@ -156,11 +156,6 @@ namespace Emby.Server.Implementations.Updates
_logger.LogError(ex, "The URL configured for the plugin repository manifest URL is not valid: {Manifest}", manifest);
return Array.Empty<PackageInfo>();
}
catch (NotSupportedException ex)
{
_logger.LogError(ex, "The URL scheme configured for the plugin repository is not supported: {Manifest}", manifest);
return Array.Empty<PackageInfo>();
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "An error occurred while accessing the plugin manifest: {Manifest}", manifest);

View File

@@ -38,7 +38,6 @@ public class ActivityLogController : BaseJellyfinApiController
/// <param name="startIndex">The record index to start at. All items with a lower index will be dropped from the results.</param>
/// <param name="limit">The maximum number of records to return.</param>
/// <param name="minDate">The minimum date.</param>
/// <param name="maxDate">The maximum date.</param>
/// <param name="hasUserId">Filter log entries if it has user id, or not.</param>
/// <param name="name">Filter by name.</param>
/// <param name="overview">Filter by overview.</param>
@@ -57,7 +56,6 @@ public class ActivityLogController : BaseJellyfinApiController
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery] DateTime? minDate,
[FromQuery] DateTime? maxDate,
[FromQuery] bool? hasUserId,
[FromQuery] string? name,
[FromQuery] string? overview,
@@ -74,7 +72,6 @@ public class ActivityLogController : BaseJellyfinApiController
Skip = startIndex,
Limit = limit,
MinDate = minDate,
MaxDate = maxDate,
HasUserId = hasUserId,
Name = name,
Overview = overview,

View File

@@ -1839,9 +1839,8 @@ public class DynamicHlsController : BaseJellyfinApiController
{
if (isActualOutputVideoCodecHevc)
{
// Use hvc1 for 8.4. This is what Dolby uses for its official sample streams. Tagging with dvh1 would break some players with strict tag checking like Apple Safari.
var codecTag = state.VideoStream.VideoRangeType == VideoRangeType.DOVIWithHLG ? "hvc1" : "dvh1";
args += $" -tag:v:0 {codecTag} -strict -2";
// Prefer dvh1 to dvhe
args += " -tag:v:0 dvh1 -strict -2";
}
else if (isActualOutputVideoCodecAv1)
{

View File

@@ -180,14 +180,11 @@ public class ItemUpdateController : BaseJellyfinApiController
info.ContentTypeOptions = GetContentTypeOptions(true).ToArray();
info.ContentType = configuredContentType;
if (inheritedContentType is null
|| inheritedContentType == CollectionType.tvshows
|| inheritedContentType == CollectionType.movies)
if (inheritedContentType is null || inheritedContentType == CollectionType.tvshows)
{
info.ContentTypeOptions = info.ContentTypeOptions
.Where(i => string.IsNullOrWhiteSpace(i.Value)
|| string.Equals(i.Value, "TvShows", StringComparison.OrdinalIgnoreCase)
|| string.Equals(i.Value, "Movies", StringComparison.OrdinalIgnoreCase))
|| string.Equals(i.Value, "TvShows", StringComparison.OrdinalIgnoreCase))
.ToArray();
}
}

View File

@@ -23,7 +23,6 @@ using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Activity;
@@ -701,18 +700,7 @@ public class LibraryController : BaseJellyfinApiController
// Quotes are valid in linux. They'll possibly cause issues here.
var filename = Path.GetFileName(item.Path)?.Replace("\"", string.Empty, StringComparison.Ordinal);
var filePath = item.Path;
if (item.IsFileProtocol)
{
// PhysicalFile does not work well with symlinks at the moment.
var resolved = FileSystemHelper.ResolveLinkTarget(filePath, returnFinalTarget: true);
if (resolved is not null && resolved.Exists)
{
filePath = resolved.FullName;
}
}
return PhysicalFile(filePath, MimeTypes.GetMimeType(filePath), filename, true);
return PhysicalFile(item.Path, MimeTypes.GetMimeType(item.Path), filename, true);
}
/// <summary>

View File

@@ -86,7 +86,7 @@ public class TrickplayController : BaseJellyfinApiController
[FromRoute, Required] int index,
[FromQuery] Guid? mediaSourceId)
{
var item = _libraryManager.GetItemById<BaseItem>(mediaSourceId ?? itemId, User.GetUserId());
var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
if (item is null)
{
return NotFound();

View File

@@ -154,7 +154,7 @@ public class DynamicHlsHelper
// from universal audio service, need to override the AudioCodec when the actual request differs from original query
if (!string.Equals(state.OutputAudioCodec, _httpContextAccessor.HttpContext.Request.Query["AudioCodec"].ToString(), StringComparison.OrdinalIgnoreCase))
{
var newQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
var newQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(_httpContextAccessor.HttpContext.Request.QueryString.ToString());
newQuery["AudioCodec"] = state.OutputAudioCodec;
queryString = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(string.Empty, newQuery);
}
@@ -173,21 +173,10 @@ public class DynamicHlsHelper
queryString += "&TranscodeReasons=" + state.Request.TranscodeReasons;
}
// Video rotation metadata is only supported in fMP4 remuxing
if (state.VideoStream is not null
&& state.VideoRequest is not null
&& (state.VideoStream?.Rotation ?? 0) != 0
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
&& !string.IsNullOrWhiteSpace(state.Request.SegmentContainer)
&& !string.Equals(state.Request.SegmentContainer, "mp4", StringComparison.OrdinalIgnoreCase))
{
queryString += "&AllowVideoStreamCopy=false";
}
// Main stream
var baseUrl = isLiveStream ? "live.m3u8" : "main.m3u8";
var playlistUrl = baseUrl + queryString;
var playlistQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
var playlistUrl = isLiveStream ? "live.m3u8" : "main.m3u8";
playlistUrl += queryString;
var subtitleStreams = state.MediaSource
.MediaStreams
@@ -209,36 +198,37 @@ public class DynamicHlsHelper
AddSubtitles(state, subtitleStreams, builder, _httpContextAccessor.HttpContext.User);
}
// Video rotation metadata is only supported in fMP4 remuxing
if (state.VideoStream is not null
&& state.VideoRequest is not null
&& (state.VideoStream?.Rotation ?? 0) != 0
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
&& !string.IsNullOrWhiteSpace(state.Request.SegmentContainer)
&& !string.Equals(state.Request.SegmentContainer, "mp4", StringComparison.OrdinalIgnoreCase))
{
playlistUrl += "&AllowVideoStreamCopy=false";
}
var basicPlaylist = AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup);
if (state.VideoStream is not null && state.VideoRequest is not null)
{
var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
// Provide AV1 and HEVC SDR entrances for backward compatibility.
foreach (var sdrVideoCodec in new[] { "av1", "hevc" })
// Provide SDR HEVC entrance for backward compatibility.
if (encodingOptions.AllowHevcEncoding
&& !encodingOptions.AllowAv1Encoding
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
&& state.VideoStream.VideoRange == VideoRange.HDR
&& string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
{
var isAv1EncodingAllowed = encodingOptions.AllowAv1Encoding
&& string.Equals(sdrVideoCodec, "av1", StringComparison.OrdinalIgnoreCase)
&& string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase);
var isHevcEncodingAllowed = encodingOptions.AllowHevcEncoding
&& string.Equals(sdrVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
&& string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase);
var isEncodingAllowed = isAv1EncodingAllowed || isHevcEncodingAllowed;
if (isEncodingAllowed
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
&& state.VideoStream.VideoRange == VideoRange.HDR)
var requestedVideoProfiles = state.GetRequestedProfiles("hevc");
if (requestedVideoProfiles is not null && requestedVideoProfiles.Length > 0)
{
// Force AV1 and HEVC Main Profile and disable video stream copy.
state.OutputVideoCodec = sdrVideoCodec;
var sdrPlaylistQuery = playlistQuery;
sdrPlaylistQuery["VideoCodec"] = sdrVideoCodec;
sdrPlaylistQuery[sdrVideoCodec + "-profile"] = "main";
sdrPlaylistQuery["AllowVideoStreamCopy"] = "false";
var sdrVideoUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, sdrPlaylistQuery);
// Force HEVC Main Profile and disable video stream copy.
state.OutputVideoCodec = "hevc";
var sdrVideoUrl = ReplaceProfile(playlistUrl, "hevc", string.Join(',', requestedVideoProfiles), "main");
sdrVideoUrl += "&AllowVideoStreamCopy=false";
// HACK: Use the same bitrate so that the client can choose by other attributes, such as color range.
AppendPlaylist(builder, state, sdrVideoUrl, totalBitrate, subtitleGroup);
@@ -248,30 +238,12 @@ public class DynamicHlsHelper
}
}
// Provide H.264 SDR entrance for backward compatibility.
if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
&& state.VideoStream.VideoRange == VideoRange.HDR)
{
// Force H.264 and disable video stream copy.
state.OutputVideoCodec = "h264";
var sdrPlaylistQuery = playlistQuery;
sdrPlaylistQuery["VideoCodec"] = "h264";
sdrPlaylistQuery["AllowVideoStreamCopy"] = "false";
var sdrVideoUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, sdrPlaylistQuery);
// HACK: Use the same bitrate so that the client can choose by other attributes, such as color range.
AppendPlaylist(builder, state, sdrVideoUrl, totalBitrate, subtitleGroup);
// Restore the video codec
state.OutputVideoCodec = "copy";
}
// Provide Level 5.0 entrance for backward compatibility.
// e.g. Apple A10 chips refuse the master playlist containing SDR HEVC Main Level 5.1 video,
// but in fact it is capable of playing videos up to Level 6.1.
if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
if (encodingOptions.AllowHevcEncoding
&& !encodingOptions.AllowAv1Encoding
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
&& state.VideoStream.Level.HasValue
&& state.VideoStream.Level > 150
&& state.VideoStream.VideoRange == VideoRange.SDR
@@ -301,15 +273,12 @@ public class DynamicHlsHelper
var variation = GetBitrateVariation(totalBitrate);
var newBitrate = totalBitrate - variation;
var variantQuery = playlistQuery;
variantQuery["VideoBitrate"] = (requestedVideoBitrate - variation).ToString(CultureInfo.InvariantCulture);
var variantUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, variantQuery);
var variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
variation *= 2;
newBitrate = totalBitrate - variation;
variantQuery["VideoBitrate"] = (requestedVideoBitrate - variation).ToString(CultureInfo.InvariantCulture);
variantUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, variantQuery);
variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
}
@@ -754,9 +723,7 @@ public class DynamicHlsHelper
{
if (string.Equals(state.ActualOutputAudioCodec, "aac", StringComparison.OrdinalIgnoreCase))
{
string? profile = EncodingHelper.IsCopyCodec(state.OutputAudioCodec)
? state.AudioStream?.Profile : state.GetRequestedProfiles("aac").FirstOrDefault();
string? profile = state.GetRequestedProfiles("aac").FirstOrDefault();
return HlsCodecStringHelpers.GetAACString(profile);
}
@@ -790,19 +757,6 @@ public class DynamicHlsHelper
return HlsCodecStringHelpers.GetOPUSString();
}
if (string.Equals(state.ActualOutputAudioCodec, "truehd", StringComparison.OrdinalIgnoreCase))
{
return HlsCodecStringHelpers.GetTRUEHDString();
}
if (string.Equals(state.ActualOutputAudioCodec, "dts", StringComparison.OrdinalIgnoreCase))
{
// lavc only support encoding DTS core profile
string? profile = EncodingHelper.IsCopyCodec(state.OutputAudioCodec) ? state.AudioStream?.Profile : "DTS";
return HlsCodecStringHelpers.GetDTSString(profile);
}
return string.Empty;
}
@@ -909,6 +863,23 @@ public class DynamicHlsHelper
return variation;
}
private string ReplaceVideoBitrate(string url, int oldValue, int newValue)
{
return url.Replace(
"videobitrate=" + oldValue.ToString(CultureInfo.InvariantCulture),
"videobitrate=" + newValue.ToString(CultureInfo.InvariantCulture),
StringComparison.OrdinalIgnoreCase);
}
private string ReplaceProfile(string url, string codec, string oldValue, string newValue)
{
string profileStr = codec + "-profile=";
return url.Replace(
profileStr + oldValue,
profileStr + newValue,
StringComparison.OrdinalIgnoreCase);
}
private string ReplacePlaylistCodecsField(StringBuilder playlist, StringBuilder oldValue, StringBuilder newValue)
{
var oldPlaylist = playlist.ToString();

View File

@@ -41,11 +41,6 @@ public static class HlsCodecStringHelpers
/// </summary>
public const string OPUS = "Opus";
/// <summary>
/// Codec name for TRUEHD.
/// </summary>
public const string TRUEHD = "mlpa";
/// <summary>
/// Gets a MP3 codec string.
/// </summary>
@@ -64,7 +59,7 @@ public static class HlsCodecStringHelpers
{
StringBuilder result = new StringBuilder("mp4a", 9);
if (string.Equals(profile, "HE-AAC", StringComparison.OrdinalIgnoreCase))
if (string.Equals(profile, "HE", StringComparison.OrdinalIgnoreCase))
{
result.Append(".40.5");
}
@@ -122,46 +117,6 @@ public static class HlsCodecStringHelpers
return OPUS;
}
/// <summary>
/// Gets an TRUEHD codec string.
/// </summary>
/// <returns>TRUEHD codec string.</returns>
public static string GetTRUEHDString()
{
return TRUEHD;
}
/// <summary>
/// Gets an DTS codec string.
/// </summary>
/// <param name="profile">DTS profile.</param>
/// <returns>DTS codec string.</returns>
public static string GetDTSString(string? profile)
{
if (string.Equals(profile, "DTS", StringComparison.OrdinalIgnoreCase)
|| string.Equals(profile, "DTS-ES", StringComparison.OrdinalIgnoreCase)
|| string.Equals(profile, "DTS 96/24", StringComparison.OrdinalIgnoreCase))
{
return "dtsc";
}
if (string.Equals(profile, "DTS-HD HRA", StringComparison.OrdinalIgnoreCase)
|| string.Equals(profile, "DTS-HD MA", StringComparison.OrdinalIgnoreCase)
|| string.Equals(profile, "DTS-HD MA + DTS:X", StringComparison.OrdinalIgnoreCase)
|| string.Equals(profile, "DTS-HD MA + DTS:X IMAX", StringComparison.OrdinalIgnoreCase))
{
return "dtsh";
}
if (string.Equals(profile, "DTS Express", StringComparison.OrdinalIgnoreCase))
{
return "dtse";
}
// Default to DTS core if profile is invalid
return "dtsc";
}
/// <summary>
/// Gets a H.264 codec string.
/// </summary>

View File

@@ -159,13 +159,6 @@ public static class StreamingHelpers
string? containerInternal = Path.GetExtension(state.RequestedUrl);
if (string.IsNullOrEmpty(containerInternal)
&& (!string.IsNullOrWhiteSpace(streamingRequest.LiveStreamId)
|| (mediaSource != null && mediaSource.IsInfiniteStream)))
{
containerInternal = ".ts";
}
if (!string.IsNullOrEmpty(streamingRequest.Container))
{
containerInternal = streamingRequest.Container;

View File

@@ -0,0 +1,53 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Api.Middleware;
/// <summary>
/// Removes /emby and /mediabrowser from requested route.
/// </summary>
public class LegacyEmbyRouteRewriteMiddleware
{
private const string EmbyPath = "/emby";
private const string MediabrowserPath = "/mediabrowser";
private readonly RequestDelegate _next;
private readonly ILogger<LegacyEmbyRouteRewriteMiddleware> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="LegacyEmbyRouteRewriteMiddleware"/> class.
/// </summary>
/// <param name="next">The next delegate in the pipeline.</param>
/// <param name="logger">The logger.</param>
public LegacyEmbyRouteRewriteMiddleware(
RequestDelegate next,
ILogger<LegacyEmbyRouteRewriteMiddleware> logger)
{
_next = next;
_logger = logger;
}
/// <summary>
/// Executes the middleware action.
/// </summary>
/// <param name="httpContext">The current HTTP context.</param>
/// <returns>The async task.</returns>
public async Task Invoke(HttpContext httpContext)
{
var localPath = httpContext.Request.Path.ToString();
if (localPath.StartsWith(EmbyPath, StringComparison.OrdinalIgnoreCase))
{
httpContext.Request.Path = localPath[EmbyPath.Length..];
_logger.LogDebug("Removing {EmbyPath} from route.", EmbyPath);
}
else if (localPath.StartsWith(MediabrowserPath, StringComparison.OrdinalIgnoreCase))
{
httpContext.Request.Path = localPath[MediabrowserPath.Length..];
_logger.LogDebug("Removing {MediabrowserPath} from route.", MediabrowserPath);
}
await _next(httpContext).ConfigureAwait(false);
}
}

View File

@@ -21,11 +21,6 @@ public class ActivityLogQuery : PaginatedQuery
/// </summary>
public DateTime? MinDate { get; set; }
/// <summary>
/// Gets or sets the maximum date to query for.
/// </summary>
public DateTime? MaxDate { get; set; }
/// <summary>
/// Gets or sets the name filter.
/// </summary>

View File

@@ -72,11 +72,6 @@ public class ActivityManager : IActivityManager
entries = entries.Where(e => e.ActivityLog.DateCreated >= query.MinDate.Value);
}
if (query.MaxDate is not null)
{
entries = entries.Where(e => e.ActivityLog.DateCreated <= query.MaxDate.Value);
}
if (!string.IsNullOrEmpty(query.Name))
{
entries = entries.Where(e => EF.Functions.Like(e.ActivityLog.Name, $"%{query.Name}%"));
@@ -171,19 +166,9 @@ public class ActivityManager : IActivityManager
foreach (var (sortBy, sortOrder) in sorting)
{
var orderBy = MapOrderBy(sortBy);
if (ordered == null)
{
ordered = sortOrder == SortOrder.Ascending
? query.OrderBy(orderBy)
: query.OrderByDescending(orderBy);
}
else
{
ordered = sortOrder == SortOrder.Ascending
? ordered.ThenBy(orderBy)
: ordered.ThenByDescending(orderBy);
}
ordered = sortOrder == SortOrder.Ascending
? (ordered ?? query).OrderBy(orderBy)
: (ordered ?? query).OrderByDescending(orderBy);
}
return ordered;

View File

@@ -277,7 +277,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)).Where(dto => dto is not null).ToArray()!;
result.Items = dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
result.StartIndex = filter.StartIndex ?? 0;
return result;
}
@@ -297,7 +297,7 @@ public sealed class BaseItemRepository
dbQuery = ApplyQueryPaging(dbQuery, filter);
dbQuery = ApplyNavigations(dbQuery, filter);
return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).Where(dto => dto is not null).ToArray()!;
return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
}
/// <inheritdoc/>
@@ -341,7 +341,7 @@ public sealed class BaseItemRepository
mainquery = ApplyNavigations(mainquery, filter);
return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).Where(dto => dto is not null).ToArray()!;
return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
}
/// <inheritdoc />
@@ -410,25 +410,10 @@ public sealed class BaseItemRepository
private static IQueryable<BaseItemEntity> ApplyNavigations(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
{
if (filter.TrailerTypes.Length > 0 || filter.IncludeItemTypes.Contains(BaseItemKind.Trailer))
{
dbQuery = dbQuery.Include(e => e.TrailerTypes);
}
if (filter.DtoOptions.ContainsField(ItemFields.ProviderIds))
{
dbQuery = dbQuery.Include(e => e.Provider);
}
if (filter.DtoOptions.ContainsField(ItemFields.Settings))
{
dbQuery = dbQuery.Include(e => e.LockedFields);
}
if (filter.DtoOptions.EnableUserData)
{
dbQuery = dbQuery.Include(e => e.UserData);
}
dbQuery = dbQuery.Include(e => e.TrailerTypes)
.Include(e => e.Provider)
.Include(e => e.LockedFields)
.Include(e => e.UserData);
if (filter.DtoOptions.EnableImages)
{
@@ -562,34 +547,22 @@ public sealed class BaseItemRepository
}
/// <inheritdoc />
public async Task SaveImagesAsync(BaseItemDto item, CancellationToken cancellationToken = default)
public void SaveImages(BaseItemDto item)
{
ArgumentNullException.ThrowIfNull(item);
var images = item.ImageInfos.Select(e => Map(item.Id, e)).ToArray();
var images = item.ImageInfos.Select(e => Map(item.Id, e));
using var context = _dbProvider.CreateDbContext();
var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
if (!context.BaseItems.Any(bi => bi.Id == item.Id))
{
if (!await context.BaseItems
.AnyAsync(bi => bi.Id == item.Id, cancellationToken)
.ConfigureAwait(false))
{
_logger.LogWarning("Unable to save ImageInfo for non existing BaseItem");
return;
}
await context.BaseItemImageInfos
.Where(e => e.ItemId == item.Id)
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
await context.BaseItemImageInfos
.AddRangeAsync(images, cancellationToken)
.ConfigureAwait(false);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
_logger.LogWarning("Unable to save ImageInfo for non existing BaseItem");
return;
}
context.BaseItemImageInfos.Where(e => e.ItemId == item.Id).ExecuteDelete();
context.BaseItemImageInfos.AddRange(images);
context.SaveChanges();
}
/// <inheritdoc />
@@ -1159,7 +1132,7 @@ public sealed class BaseItemRepository
return type.GetCustomAttribute<RequiresSourceSerialisationAttribute>() == null;
}
private BaseItemDto? DeserializeBaseItem(BaseItemEntity baseItemEntity, bool skipDeserialization = false)
private BaseItemDto DeserializeBaseItem(BaseItemEntity baseItemEntity, bool skipDeserialization = false)
{
ArgumentNullException.ThrowIfNull(baseItemEntity, nameof(baseItemEntity));
if (_serverConfigurationManager?.Configuration is null)
@@ -1182,19 +1155,11 @@ public sealed class BaseItemRepository
/// <param name="logger">Logger.</param>
/// <param name="appHost">The application server Host.</param>
/// <param name="skipDeserialization">If only mapping should be processed.</param>
/// <returns>A mapped BaseItem, or null if the item type is unknown.</returns>
public static BaseItemDto? DeserializeBaseItem(BaseItemEntity baseItemEntity, ILogger logger, IServerApplicationHost? appHost, bool skipDeserialization = false)
/// <returns>A mapped BaseItem.</returns>
/// <exception cref="InvalidOperationException">Will be thrown if an invalid serialisation is requested.</exception>
public static BaseItemDto DeserializeBaseItem(BaseItemEntity baseItemEntity, ILogger logger, IServerApplicationHost? appHost, bool skipDeserialization = false)
{
var type = GetType(baseItemEntity.Type);
if (type is null)
{
logger.LogWarning(
"Skipping item {ItemId} with unknown type '{ItemType}'. This may indicate a removed plugin or database corruption.",
baseItemEntity.Id,
baseItemEntity.Type);
return null;
}
var type = GetType(baseItemEntity.Type) ?? throw new InvalidOperationException("Cannot deserialize unknown type.");
BaseItemDto? dto = null;
if (TypeRequiresDeserialization(type) && baseItemEntity.Data is not null && !skipDeserialization)
{
@@ -1361,9 +1326,10 @@ public sealed class BaseItemRepository
.. resultQuery
.AsEnumerable()
.Where(e => e is not null)
.Select(e => (Item: DeserializeBaseItem(e.item, filter.SkipDeserialization), e.itemCount))
.Where(e => e.Item is not null)
.Select(e => (e.Item!, e.itemCount))
.Select(e =>
{
return (DeserializeBaseItem(e.item, filter.SkipDeserialization), e.itemCount);
})
];
}
else
@@ -1374,9 +1340,10 @@ public sealed class BaseItemRepository
.. query
.AsEnumerable()
.Where(e => e is not null)
.Select(e => (Item: DeserializeBaseItem(e, filter.SkipDeserialization), ItemCounts: (ItemCounts?)null))
.Where(e => e.Item is not null)
.Select(e => (e.Item!, e.ItemCounts))
.Select<BaseItemEntity, (BaseItemDto, ItemCounts?)>(e =>
{
return (DeserializeBaseItem(e, filter.SkipDeserialization), null);
})
];
}
@@ -1592,14 +1559,14 @@ public sealed class BaseItemRepository
private IQueryable<BaseItemEntity> ApplyOrder(IQueryable<BaseItemEntity> query, InternalItemsQuery filter, JellyfinDbContext context)
{
var orderBy = filter.OrderBy.Where(e => e.OrderBy != ItemSortBy.Default).ToArray();
var orderBy = filter.OrderBy;
var hasSearch = !string.IsNullOrEmpty(filter.SearchTerm);
if (hasSearch)
{
orderBy = [(ItemSortBy.SortName, SortOrder.Ascending), .. orderBy];
orderBy = filter.OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending), .. orderBy];
}
else if (orderBy.Length == 0)
else if (orderBy.Count == 0)
{
return query.OrderBy(e => e.SortName);
}
@@ -1993,15 +1960,8 @@ public sealed class BaseItemRepository
if (!string.IsNullOrWhiteSpace(filter.Name))
{
if (filter.UseRawName == true)
{
baseQuery = baseQuery.Where(e => e.Name == filter.Name);
}
else
{
var cleanName = GetCleanValue(filter.Name);
baseQuery = baseQuery.Where(e => e.CleanName == cleanName);
}
var cleanName = GetCleanValue(filter.Name);
baseQuery = baseQuery.Where(e => e.CleanName == cleanName);
}
// These are the same, for now
@@ -2495,24 +2455,35 @@ public sealed class BaseItemRepository
if (filter.ExcludeInheritedTags.Length > 0)
{
var excludedTags = filter.ExcludeInheritedTags;
baseQuery = baseQuery.Where(e =>
!e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue))
&& (!e.SeriesId.HasValue || !context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue))));
!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)
{
var includeTags = filter.IncludeInheritedTags;
var isPlaylistOnlyQuery = includeTypes.Length == 1 && includeTypes.FirstOrDefault() == BaseItemKind.Playlist;
baseQuery = baseQuery.Where(e =>
e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue))
// 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!.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))));
}
// For seasons and episodes, we also need to check the parent series' tags.
|| (e.SeriesId.HasValue && context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue)))
// A playlist should be accessible to its owner regardless of allowed tags
|| (isPlaylistOnlyQuery && e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\"")));
// 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!.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!.Any(f => f.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue)));
}
}
if (filter.SeriesStatuses.Length > 0)
@@ -2666,6 +2637,6 @@ public sealed class BaseItemRepository
.Where(e => artistNames.Contains(e.Name))
.ToArray();
return artists.GroupBy(e => e.Name).ToDictionary(e => e.Key!, e => e.Select(f => DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray());
return artists.GroupBy(e => e.Name).ToDictionary(e => e.Key!, e => e.Select(f => DeserializeBaseItem(f)).Cast<MusicArtist>().ToArray());
}
}

View File

@@ -149,7 +149,7 @@ namespace Jellyfin.Server.Implementations.Users
ThrowIfInvalidUsername(newName);
if (user.Username.Equals(newName, StringComparison.Ordinal))
if (user.Username.Equals(newName, StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException("The new and old names must be different.");
}

View File

@@ -117,5 +117,18 @@ namespace Jellyfin.Server.Extensions
{
return appBuilder.UseMiddleware<RobotsRedirectionMiddleware>();
}
/// <summary>
/// Adds /emby and /mediabrowser route trimming to the application pipeline.
/// </summary>
/// <remarks>
/// This must be injected before any path related middleware.
/// </remarks>
/// <param name="appBuilder">The application builder.</param>
/// <returns>The updated application builder.</returns>
public static IApplicationBuilder UsePathTrim(this IApplicationBuilder appBuilder)
{
return appBuilder.UseMiddleware<LegacyEmbyRouteRewriteMiddleware>();
}
}
}

View File

@@ -255,7 +255,6 @@ namespace Jellyfin.Server.Extensions
c.AddSwaggerTypeMappings();
c.SchemaFilter<IgnoreEnumSchemaFilter>();
c.SchemaFilter<FlagsEnumSchemaFilter>();
c.OperationFilter<RetryOnTemporarilyUnavailableFilter>();
c.OperationFilter<SecurityRequirementsOperationFilter>();
c.OperationFilter<FileResponseFilter>();
@@ -343,6 +342,25 @@ namespace Jellyfin.Server.Extensions
}
});
/*
* Support BlurHash dictionary
*/
options.MapType<Dictionary<ImageType, Dictionary<string, string>>>(() =>
new OpenApiSchema
{
Type = "object",
Properties = typeof(ImageType).GetEnumNames().ToDictionary(
name => name,
_ => new OpenApiSchema
{
Type = "object",
AdditionalProperties = new OpenApiSchema
{
Type = "string"
}
})
});
// Support dictionary with nullable string value.
options.MapType<Dictionary<string, string?>>(() =>
new OpenApiSchema
@@ -355,6 +373,21 @@ namespace Jellyfin.Server.Extensions
}
});
// Manually describe Flags enum.
options.MapType<TranscodeReason>(() =>
new OpenApiSchema
{
Type = "array",
Items = new OpenApiSchema
{
Reference = new OpenApiReference
{
Id = nameof(TranscodeReason),
Type = ReferenceType.Schema,
}
}
});
// Swashbuckle doesn't use JsonOptions to describe responses, so we need to manually describe it.
options.MapType<Version>(() => new OpenApiSchema
{

View File

@@ -225,6 +225,15 @@ namespace Jellyfin.Server.Filters
context.SchemaGenerator.GenerateSchema(configuration.ConfigurationType, context.SchemaRepository);
}
context.SchemaRepository.AddDefinition(nameof(TranscodeReason), new OpenApiSchema
{
Type = "string",
Enum = Enum.GetNames<TranscodeReason>()
.Select(e => new OpenApiString(e))
.Cast<IOpenApiAny>()
.ToArray()
});
}
}
}

View File

@@ -2,7 +2,6 @@ using System;
using AsyncKeyedLock;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.Swagger;
@@ -24,7 +23,6 @@ internal sealed class CachingOpenApiProvider : ISwaggerProvider
private readonly IMemoryCache _memoryCache;
private readonly SwaggerGenerator _swaggerGenerator;
private readonly SwaggerGeneratorOptions _swaggerGeneratorOptions;
private readonly ILogger<CachingOpenApiProvider> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="CachingOpenApiProvider"/> class.
@@ -33,18 +31,15 @@ internal sealed class CachingOpenApiProvider : ISwaggerProvider
/// <param name="apiDescriptionsProvider">The api descriptions provider.</param>
/// <param name="schemaGenerator">The schema generator.</param>
/// <param name="memoryCache">The memory cache.</param>
/// <param name="logger">The logger.</param>
public CachingOpenApiProvider(
IOptions<SwaggerGeneratorOptions> optionsAccessor,
IApiDescriptionGroupCollectionProvider apiDescriptionsProvider,
ISchemaGenerator schemaGenerator,
IMemoryCache memoryCache,
ILogger<CachingOpenApiProvider> logger)
IMemoryCache memoryCache)
{
_swaggerGeneratorOptions = optionsAccessor.Value;
_swaggerGenerator = new SwaggerGenerator(_swaggerGeneratorOptions, apiDescriptionsProvider, schemaGenerator);
_memoryCache = memoryCache;
_logger = logger;
}
/// <inheritdoc />
@@ -66,16 +61,7 @@ internal sealed class CachingOpenApiProvider : ISwaggerProvider
throw new InvalidOperationException("OpenApi document is generating");
}
try
{
openApiDocument = _swaggerGenerator.GetSwagger(documentName);
}
catch (Exception ex)
{
_logger.LogError(ex, "OpenAPI generation error");
throw;
}
_memoryCache.Set(CacheKey, openApiDocument, _cacheOptions);
return AdjustDocument(openApiDocument, host, basePath);
}

View File

@@ -1,53 +0,0 @@
using System;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace Jellyfin.Server.Filters;
/// <summary>
/// Schema filter to ensure flags enums are represented correctly in OpenAPI.
/// </summary>
/// <remarks>
/// For flags enums:
/// - The enum schema definition is set to type "string" (not integer).
/// - Properties using flags enums are transformed to arrays referencing the enum schema.
/// </remarks>
public class FlagsEnumSchemaFilter : ISchemaFilter
{
/// <inheritdoc />
public void Apply(OpenApiSchema schema, SchemaFilterContext context)
{
var type = context.Type.IsEnum ? context.Type : Nullable.GetUnderlyingType(context.Type);
if (type is null || !type.IsEnum)
{
return;
}
// Check if enum has [Flags] attribute
if (!type.IsDefined(typeof(FlagsAttribute), false))
{
return;
}
if (context.MemberInfo is null)
{
// Processing the enum definition itself - ensure it's type "string" not "integer"
schema.Type = "string";
schema.Format = null;
}
else
{
// Processing a property that uses the flags enum - transform to array
// Generate the enum schema to ensure it exists in the repository
var enumSchema = context.SchemaGenerator.GenerateSchema(type, context.SchemaRepository);
// Flags enums should be represented as arrays referencing the enum schema
// since multiple values can be combined
schema.Type = "array";
schema.Format = null;
schema.Enum = null;
schema.AllOf = null;
schema.Items = enumSchema;
}
}
}

View File

@@ -1247,11 +1247,8 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
}
var baseItem = BaseItemRepository.DeserializeBaseItem(entity, _logger, null, false);
if (baseItem is not null)
{
var dataKeys = baseItem.GetUserDataKeys();
userDataKeys.AddRange(dataKeys);
}
var dataKeys = baseItem.GetUserDataKeys();
userDataKeys.AddRange(dataKeys);
return (entity, userDataKeys.ToArray());
}

View File

@@ -173,6 +173,9 @@ namespace Jellyfin.Server
mainApp.UseHttpsRedirection();
}
// This must be injected before any path related middleware.
mainApp.UsePathTrim();
if (appConfig.HostWebClient())
{
var extensionProvider = new FileExtensionContentTypeProvider();

View File

@@ -1620,17 +1620,12 @@ namespace MediaBrowser.Controller.Entities
return isAllowed;
}
if (!maxAllowedRating.HasValue)
if (maxAllowedSubRating is not null)
{
return true;
return (ratingScore.SubScore ?? 0) <= maxAllowedSubRating && ratingScore.Score <= maxAllowedRating.Value;
}
if (ratingScore.Score != maxAllowedRating.Value)
{
return ratingScore.Score < maxAllowedRating.Value;
}
return !maxAllowedSubRating.HasValue || (ratingScore.SubScore ?? 0) <= maxAllowedSubRating.Value;
return !maxAllowedRating.HasValue || ratingScore.Score <= maxAllowedRating.Value;
}
public ParentalRatingScore GetParentalRatingScore()

View File

@@ -1406,6 +1406,13 @@ namespace MediaBrowser.Controller.Entities
.Where(e => query is null || UserViewBuilder.FilterItem(e, query))
.ToArray();
if (this is BoxSet && (query.OrderBy is null || query.OrderBy.Count == 0))
{
realChildren = realChildren
.OrderBy(e => e.PremiereDate ?? DateTime.MaxValue)
.ToArray();
}
var childCount = realChildren.Length;
if (result.Count < limit)
{

View File

@@ -125,8 +125,6 @@ namespace MediaBrowser.Controller.Entities
public string? Name { get; set; }
public bool? UseRawName { get; set; }
public string? Person { get; set; }
public Guid[] PersonIds { get; set; }

View File

@@ -124,7 +124,7 @@ namespace MediaBrowser.Controller.Entities.Movies
if (sortBy == ItemSortBy.Default)
{
return items;
return items;
}
return LibraryManager.Sort(items, user, new[] { sortBy }, SortOrder.Ascending);
@@ -136,12 +136,6 @@ namespace MediaBrowser.Controller.Entities.Movies
return Sort(children, user).ToArray();
}
public override IReadOnlyList<BaseItem> GetChildren(User user, bool includeLinkedChildren, out int totalItemCount, InternalItemsQuery query = null)
{
var children = base.GetChildren(user, includeLinkedChildren, out totalItemCount, query);
return Sort(children, user).ToArray();
}
public override IReadOnlyList<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query, out int totalCount)
{
var children = base.GetRecursiveChildren(user, query, out totalCount);

View File

@@ -214,7 +214,7 @@ namespace MediaBrowser.Controller.Entities.TV
query.AncestorWithPresentationUniqueKey = null;
query.SeriesPresentationUniqueKey = seriesKey;
query.IncludeItemTypes = new[] { BaseItemKind.Season };
query.OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) };
query.OrderBy = new[] { (ItemSortBy.IndexNumber, SortOrder.Ascending) };
if (user is not null && !user.DisplayMissingEpisodes)
{
@@ -247,10 +247,6 @@ namespace MediaBrowser.Controller.Entities.TV
query.AncestorWithPresentationUniqueKey = null;
query.SeriesPresentationUniqueKey = seriesKey;
if (query.OrderBy.Count == 0)
{
query.OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) };
}
if (query.IncludeItemTypes.Length == 0)
{

View File

@@ -4,7 +4,6 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using MediaBrowser.Controller.Configuration;
using Microsoft.Extensions.Hosting;
@@ -30,7 +29,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
/// </summary>
private readonly Lock _taskLock = new();
private readonly Channel<TaskQueueItem> _tasks = Channel.CreateUnbounded<TaskQueueItem>();
private readonly BlockingCollection<TaskQueueItem> _tasks = new();
private volatile int _workCounter;
private Task? _cleanupTask;
@@ -78,7 +77,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
lock (_taskLock)
{
if (_tasks.Reader.Count > 0 || _workCounter > 0)
if (_tasks.Count > 0 || _workCounter > 0)
{
_logger.LogDebug("Delay cleanup task, operations still running.");
// tasks are still there so its still in use. Reschedule cleanup task.
@@ -145,9 +144,9 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
_deadlockDetector.Value = stopToken.TaskStop;
try
{
while (!stopToken.GlobalStop.Token.IsCancellationRequested)
foreach (var item in _tasks.GetConsumingEnumerable(stopToken.GlobalStop.Token))
{
var item = await _tasks.Reader.ReadAsync(stopToken.GlobalStop.Token).ConfigureAwait(false);
stopToken.GlobalStop.Token.ThrowIfCancellationRequested();
try
{
var newWorkerLimit = Interlocked.Increment(ref _workCounter) > 0;
@@ -243,7 +242,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
};
}).ToArray();
if (ShouldForceSequentialOperation() || _deadlockDetector.Value is not null)
if (ShouldForceSequentialOperation())
{
_logger.LogDebug("Process sequentially.");
try
@@ -265,14 +264,35 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
for (var i = 0; i < workItems.Length; i++)
{
var item = workItems[i]!;
await _tasks.Writer.WriteAsync(item, CancellationToken.None).ConfigureAwait(false);
_tasks.Add(item, CancellationToken.None);
}
Worker();
_logger.LogDebug("Wait for {NoWorkers} to complete.", workItems.Length);
await Task.WhenAll([.. workItems.Select(f => f.Done.Task)]).ConfigureAwait(false);
_logger.LogDebug("{NoWorkers} completed.", workItems.Length);
ScheduleTaskCleanup();
if (_deadlockDetector.Value is not null)
{
_logger.LogDebug("Nested invocation detected, process in-place.");
try
{
// we are in a nested loop. There is no reason to spawn a task here as that would just lead to deadlocks and no additional concurrency is achieved
while (workItems.Any(e => !e.Done.Task.IsCompleted) && _tasks.TryTake(out var item, 200, _deadlockDetector.Value.Token))
{
await ProcessItem(item).ConfigureAwait(false);
}
}
catch (OperationCanceledException) when (_deadlockDetector.Value.IsCancellationRequested)
{
// operation is cancelled. Do nothing.
}
_logger.LogDebug("process in-place done.");
}
else
{
Worker();
_logger.LogDebug("Wait for {NoWorkers} to complete.", workItems.Length);
await Task.WhenAll([.. workItems.Select(f => f.Done.Task)]).ConfigureAwait(false);
_logger.LogDebug("{NoWorkers} completed.", workItems.Length);
ScheduleTaskCleanup();
}
}
/// <inheritdoc/>
@@ -284,12 +304,13 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
}
_disposed = true;
_tasks.Writer.Complete();
_tasks.CompleteAdding();
foreach (var item in _taskRunners)
{
await item.Key.CancelAsync().ConfigureAwait(false);
}
_tasks.Dispose();
if (_cleanupTask is not null)
{
await _cleanupTask.ConfigureAwait(false);

View File

@@ -6359,21 +6359,6 @@ namespace MediaBrowser.Controller.MediaEncoding
}
}
// Block unsupported H.264 Hi422P and Hi444PP profiles, which can be encoded with 4:2:0 pixel format
if (string.Equals(videoStream.Codec, "h264", StringComparison.OrdinalIgnoreCase))
{
if (videoStream.Profile.Contains("4:2:2", StringComparison.OrdinalIgnoreCase)
|| videoStream.Profile.Contains("4:4:4", StringComparison.OrdinalIgnoreCase))
{
// VideoToolbox on Apple Silicon has H.264 Hi444PP and theoretically also has Hi422P
if (!(hardwareAccelerationType == HardwareAccelerationType.videotoolbox
&& RuntimeInformation.OSArchitecture.Equals(Architecture.Arm64)))
{
return null;
}
}
}
var decoder = hardwareAccelerationType switch
{
HardwareAccelerationType.vaapi => GetVaapiVidDecoder(state, options, videoStream, bitDepth),
@@ -7054,8 +7039,8 @@ namespace MediaBrowser.Controller.MediaEncoding
if (string.Equals(videoStream.Codec, "av1", StringComparison.OrdinalIgnoreCase))
{
// there's an issue about AV1 AFBC on RK3588, disable it for now until it's fixed upstream
return GetHwaccelType(state, options, "av1", bitDepth, hwSurface);
var accelType = GetHwaccelType(state, options, "av1", bitDepth, hwSurface);
return accelType + ((!string.IsNullOrEmpty(accelType) && isAfbcSupported) ? " -afbc rga" : string.Empty);
}
}

View File

@@ -33,7 +33,7 @@ public interface IItemRepository
/// <param name="cancellationToken">The cancellation token.</param>
void SaveItems(IReadOnlyList<BaseItem> items, CancellationToken cancellationToken);
Task SaveImagesAsync(BaseItem item, CancellationToken cancellationToken = default);
void SaveImages(BaseItem item);
/// <summary>
/// Retrieves the item.

View File

@@ -300,12 +300,9 @@ namespace MediaBrowser.MediaEncoding.Probing
// Handle WebM
else if (string.Equals(splitFormat[i], "webm", StringComparison.OrdinalIgnoreCase))
{
// Limit WebM to supported stream types and codecs.
// FFprobe can report "matroska,webm" for Matroska-like containers, so only keep "webm" if all streams are WebM-compatible.
// Any stream that is not video nor audio is not supported in WebM and should disqualify the webm container probe result.
if (mediaStreams.Any(stream => stream.Type is not MediaStreamType.Video and not MediaStreamType.Audio)
|| mediaStreams.Any(stream => (stream.Type == MediaStreamType.Video && !_webmVideoCodecs.Contains(stream.Codec, StringComparison.OrdinalIgnoreCase))
|| (stream.Type == MediaStreamType.Audio && !_webmAudioCodecs.Contains(stream.Codec, StringComparison.OrdinalIgnoreCase))))
// Limit WebM to supported codecs
if (mediaStreams.Any(stream => (stream.Type == MediaStreamType.Video && !_webmVideoCodecs.Contains(stream.Codec, StringComparison.OrdinalIgnoreCase))
|| (stream.Type == MediaStreamType.Audio && !_webmAudioCodecs.Contains(stream.Codec, StringComparison.OrdinalIgnoreCase))))
{
splitFormat[i] = string.Empty;
}
@@ -857,12 +854,7 @@ namespace MediaBrowser.MediaEncoding.Probing
}
// http://stackoverflow.com/questions/17353387/how-to-detect-anamorphic-video-with-ffprobe
if (string.IsNullOrEmpty(streamInfo.SampleAspectRatio)
&& string.IsNullOrEmpty(streamInfo.DisplayAspectRatio))
{
stream.IsAnamorphic = false;
}
else if (string.Equals(streamInfo.SampleAspectRatio, "1:1", StringComparison.Ordinal))
if (string.Equals(streamInfo.SampleAspectRatio, "1:1", StringComparison.Ordinal))
{
stream.IsAnamorphic = false;
}

View File

@@ -1,5 +1,3 @@
using System;
namespace MediaBrowser.Model.Extensions
{
/// <summary>
@@ -27,11 +25,14 @@ namespace MediaBrowser.Model.Extensions
return string.Create(
str.Length,
str.AsSpan(),
str,
(chars, buf) =>
{
chars[0] = char.ToUpperInvariant(buf[0]);
buf.Slice(1).CopyTo(chars.Slice(1));
for (int i = 1; i < chars.Length; i++)
{
chars[i] = buf[i];
}
});
}
}

View File

@@ -88,15 +88,7 @@ namespace MediaBrowser.Providers.Manager
}
}
foreach (var backdrop in item.GetImages(ImageType.Backdrop))
{
var imageInMetadataFolder = backdrop.Path.StartsWith(itemMetadataPath, StringComparison.OrdinalIgnoreCase);
if (imageInMetadataFolder || canDeleteLocal || item.IsSaveLocalMetadataEnabled())
{
singular.Add(backdrop);
}
}
singular.AddRange(item.GetImages(ImageType.Backdrop));
PruneImages(item, singular);
return singular.Count > 0;
@@ -474,36 +466,10 @@ namespace MediaBrowser.Providers.Manager
}
}
bool hasBackdrop = false;
bool backdropStoredWithMedia = false;
foreach (var image in images)
if (UpdateMultiImages(item, images, ImageType.Backdrop))
{
if (image.Type != ImageType.Backdrop)
{
continue;
}
hasBackdrop = true;
if (item.ContainingFolderPath is not null && item.ContainingFolderPath.Contains(Path.GetDirectoryName(image.FileInfo.FullName), StringComparison.OrdinalIgnoreCase))
{
backdropStoredWithMedia = true;
break;
}
}
if (hasBackdrop)
{
if (UpdateMultiImages(item, images, ImageType.Backdrop))
{
changed = true;
}
if (backdropStoredWithMedia)
{
foundImageTypes.Add(ImageType.Backdrop);
}
changed = true;
foundImageTypes.Add(ImageType.Backdrop);
}
if (foundImageTypes.Count > 0)

View File

@@ -721,6 +721,8 @@ namespace MediaBrowser.Providers.Manager
}
}
}
_libraryManager.CreateItem(item, null);
}
/// <summary>

View File

@@ -72,7 +72,7 @@ public class PlaylistMetadataService : MetadataService<Playlist, ItemLookupInfo>
}
else
{
targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).DistinctBy(i => i.Path).ToArray();
targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).Distinct().ToArray();
}
if (replaceData || targetItem.Shares.Count == 0)

View File

@@ -2,7 +2,6 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Extensions;
@@ -84,9 +83,7 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
if (!string.IsNullOrEmpty(releaseGroupId))
{
var releaseGroupResult = await _musicBrainzQuery.LookupReleaseGroupAsync(new Guid(releaseGroupId), Include.Releases, null, cancellationToken).ConfigureAwait(false);
// No need to pass the cancellation token to GetReleaseGroupResultAsync as we're already passing it to ToBlockingEnumerable
return GetReleaseGroupResultAsync(releaseGroupResult.Releases, CancellationToken.None).ToBlockingEnumerable(cancellationToken);
return GetReleaseGroupResult(releaseGroupResult.Releases);
}
var artistMusicBrainzId = searchInfo.GetMusicBrainzArtistId();
@@ -131,7 +128,7 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
}
}
private async IAsyncEnumerable<RemoteSearchResult> GetReleaseGroupResultAsync(IEnumerable<IRelease>? releaseSearchResults, [EnumeratorCancellation] CancellationToken cancellationToken = default)
private IEnumerable<RemoteSearchResult> GetReleaseGroupResult(IEnumerable<IRelease>? releaseSearchResults)
{
if (releaseSearchResults is null)
{
@@ -141,7 +138,7 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
foreach (var result in releaseSearchResults)
{
// Fetch full release info, otherwise artists are missing
var fullResult = await _musicBrainzQuery.LookupReleaseAsync(result.Id, Include.Artists | Include.ReleaseGroups, cancellationToken).ConfigureAwait(false);
var fullResult = _musicBrainzQuery.LookupRelease(result.Id, Include.Artists | Include.ReleaseGroups);
yield return GetReleaseResult(fullResult);
}
}

View File

@@ -209,69 +209,39 @@ public class SkiaEncoder : IImageEncoder
return default;
}
SKCodec? codec = null;
bool isSafePathTemp = !string.Equals(Path.GetFullPath(safePath), Path.GetFullPath(path), StringComparison.OrdinalIgnoreCase);
try
using var codec = SKCodec.Create(safePath, out var result);
switch (result)
{
codec = SKCodec.Create(safePath, out var result);
switch (result)
case SKCodecResult.Success:
// Skia/SkiaSharp edgecase: when the image header is parsed but the actual pixel
// decode fails (truncated JPEG/PNG, exotic ICC/EXIF, CMYK without colortransform, etc.)
// `SKCodec.Create` returns a *nonnull* codec together with
// SKCodecResult.InternalError. The header still contains valid dimensions,
// which is all we need here so we fall back to them instead of aborting.
// See e.g. Skia bugs #4139, #6092.
case SKCodecResult.InternalError when codec is not null:
var info = codec.Info;
return new ImageDimensions(info.Width, info.Height);
case SKCodecResult.Unimplemented:
_logger.LogDebug("Image format not supported: {FilePath}", path);
return default;
default:
{
case SKCodecResult.Success:
// Skia/SkiaSharp edgecase: when the image header is parsed but the actual pixel
// decode fails (truncated JPEG/PNG, exotic ICC/EXIF, CMYK without colortransform, etc.)
// `SKCodec.Create` returns a *nonnull* codec together with
// SKCodecResult.InternalError. The header still contains valid dimensions,
// which is all we need here so we fall back to them instead of aborting.
// See e.g. Skia bugs #4139, #6092.
case SKCodecResult.InternalError when codec is not null:
var info = codec.Info;
return new ImageDimensions(info.Width, info.Height);
var boundsInfo = SKBitmap.DecodeBounds(safePath);
case SKCodecResult.Unimplemented:
_logger.LogDebug("Image format not supported: {FilePath}", path);
return default;
default:
if (boundsInfo.Width > 0 && boundsInfo.Height > 0)
{
var boundsInfo = SKBitmap.DecodeBounds(safePath);
if (boundsInfo.Width > 0 && boundsInfo.Height > 0)
{
return new ImageDimensions(boundsInfo.Width, boundsInfo.Height);
}
_logger.LogWarning(
"Unable to determine image dimensions for {FilePath}: {SkCodecResult}",
path,
result);
return default;
return new ImageDimensions(boundsInfo.Width, boundsInfo.Height);
}
}
}
finally
{
try
{
codec?.Dispose();
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error by closing codec for {FilePath}", safePath);
}
if (isSafePathTemp)
{
try
{
if (File.Exists(safePath))
{
File.Delete(safePath);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Unable to remove temporary file '{TempPath}'", safePath);
}
_logger.LogWarning(
"Unable to determine image dimensions for {FilePath}: {SkCodecResult}",
path,
result);
return default;
}
}
}

View File

@@ -64,13 +64,13 @@ public static class EnumerableExtensions
/// <typeparam name="T">The type of item.</typeparam>
/// <returns>The IEnumerable{Enum}.</returns>
public static IEnumerable<T> GetUniqueFlags<T>(this T flags)
where T : struct, Enum
where T : Enum
{
foreach (T value in Enum.GetValues<T>())
foreach (Enum value in Enum.GetValues(flags.GetType()))
{
if (flags.HasFlag(value))
{
yield return value;
yield return (T)value;
}
}
}

View File

@@ -195,18 +195,6 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
Assert.False(res.MediaStreams[0].IsAVC);
}
[Fact]
public void GetMediaInfo_WebM_Like_Mkv()
{
var bytes = File.ReadAllBytes("Test Data/Probing/video_web_like_mkv_with_subtitle.json");
var internalMediaInfoResult = JsonSerializer.Deserialize<InternalMediaInfoResult>(bytes, _jsonOptions);
MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/video_metadata.mkv", MediaProtocol.File);
Assert.Equal("mkv", res.Container);
Assert.Equal(3, res.MediaStreams.Count);
}
[Fact]
public void GetMediaInfo_ProgressiveVideoNoFieldOrder_Success()
{

View File

@@ -1,137 +0,0 @@
{
"streams": [
{
"index": 0,
"codec_name": "vp8",
"codec_long_name": "On2 VP8",
"profile": "1",
"codec_type": "video",
"codec_tag_string": "[0][0][0][0]",
"codec_tag": "0x0000",
"width": 540,
"height": 360,
"coded_width": 540,
"coded_height": 360,
"closed_captions": 0,
"film_grain": 0,
"has_b_frames": 0,
"sample_aspect_ratio": "1:1",
"display_aspect_ratio": "3:2",
"pix_fmt": "yuv420p",
"level": -99,
"field_order": "progressive",
"refs": 1,
"r_frame_rate": "2997/125",
"avg_frame_rate": "2997/125",
"time_base": "1/1000",
"start_pts": 0,
"start_time": "0.000000",
"disposition": {
"default": 1,
"dub": 0,
"original": 0,
"comment": 0,
"lyrics": 0,
"karaoke": 0,
"forced": 0,
"hearing_impaired": 0,
"visual_impaired": 0,
"clean_effects": 0,
"attached_pic": 0,
"timed_thumbnails": 0,
"captions": 0,
"descriptions": 0,
"metadata": 0,
"dependent": 0,
"still_image": 0
},
"tags": {
"language": "eng"
}
},
{
"index": 1,
"codec_name": "vorbis",
"codec_long_name": "Vorbis",
"codec_type": "audio",
"codec_tag_string": "[0][0][0][0]",
"codec_tag": "0x0000",
"sample_fmt": "fltp",
"sample_rate": "44100",
"channels": 2,
"channel_layout": "stereo",
"bits_per_sample": 0,
"r_frame_rate": "0/0",
"avg_frame_rate": "0/0",
"time_base": "1/1000",
"start_pts": 0,
"start_time": "0.000000",
"duration": "117.707000",
"bit_rate": "127998",
"disposition": {
"default": 1,
"dub": 0,
"original": 0,
"comment": 0,
"lyrics": 0,
"karaoke": 0,
"forced": 0,
"hearing_impaired": 0,
"visual_impaired": 0,
"clean_effects": 0,
"attached_pic": 0,
"timed_thumbnails": 0,
"captions": 0,
"descriptions": 0,
"metadata": 0,
"dependent": 0,
"still_image": 0
},
"tags": {
"language": "eng"
}
},
{
"index": 2,
"codec_name": "subrip",
"codec_long_name": "SubRip subtitle",
"codec_type": "subtitle",
"codec_tag_string": "[0][0][0][0]",
"codec_tag": "0x0000",
"disposition": {
"default": 0,
"dub": 0,
"original": 0,
"comment": 0,
"lyrics": 0,
"karaoke": 0,
"forced": 0,
"hearing_impaired": 0,
"visual_impaired": 0,
"clean_effects": 0,
"attached_pic": 0,
"timed_thumbnails": 0,
"captions": 0,
"descriptions": 0,
"metadata": 0,
"dependent": 0,
"still_image": 0
},
"tags": {
"language": "eng"
}
}
],
"format": {
"filename": "sample.mkv",
"nb_streams": 3,
"nb_programs": 0,
"format_name": "matroska,webm",
"format_long_name": "Matroska / WebM",
"start_time": "0.000000",
"duration": "117.700914",
"size": "8566268",
"bit_rate": "582239",
"probe_score": 100
}
}

View File

@@ -1,102 +0,0 @@
using System;
using Emby.Server.Implementations.Cryptography;
using MediaBrowser.Model.Cryptography;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Cryptography;
public class CryptographyProviderTests
{
private readonly CryptographyProvider _sut = new();
[Fact]
public void CreatePasswordHash_WithPassword_ReturnsHashWithIterations()
{
var hash = _sut.CreatePasswordHash("testpassword");
Assert.Equal("PBKDF2-SHA512", hash.Id);
Assert.True(hash.Parameters.ContainsKey("iterations"));
Assert.NotEmpty(hash.Salt.ToArray());
Assert.NotEmpty(hash.Hash.ToArray());
}
[Fact]
public void Verify_WithValidPassword_ReturnsTrue()
{
const string password = "testpassword";
var hash = _sut.CreatePasswordHash(password);
Assert.True(_sut.Verify(hash, password));
}
[Fact]
public void Verify_WithWrongPassword_ReturnsFalse()
{
var hash = _sut.CreatePasswordHash("correctpassword");
Assert.False(_sut.Verify(hash, "wrongpassword"));
}
[Fact]
public void Verify_PBKDF2_MissingIterations_ThrowsFormatException()
{
var hash = PasswordHash.Parse("$PBKDF2$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D");
var exception = Assert.Throws<FormatException>(() => _sut.Verify(hash, "password"));
Assert.Contains("missing required 'iterations' parameter", exception.Message, StringComparison.Ordinal);
}
[Fact]
public void Verify_PBKDF2SHA512_MissingIterations_ThrowsFormatException()
{
var hash = PasswordHash.Parse("$PBKDF2-SHA512$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D");
var exception = Assert.Throws<FormatException>(() => _sut.Verify(hash, "password"));
Assert.Contains("missing required 'iterations' parameter", exception.Message, StringComparison.Ordinal);
}
[Fact]
public void Verify_PBKDF2_InvalidIterationsFormat_ThrowsFormatException()
{
var hash = PasswordHash.Parse("$PBKDF2$iterations=abc$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D");
var exception = Assert.Throws<FormatException>(() => _sut.Verify(hash, "password"));
Assert.Contains("invalid 'iterations' parameter", exception.Message, StringComparison.Ordinal);
}
[Fact]
public void Verify_PBKDF2SHA512_InvalidIterationsFormat_ThrowsFormatException()
{
var hash = PasswordHash.Parse("$PBKDF2-SHA512$iterations=notanumber$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D");
var exception = Assert.Throws<FormatException>(() => _sut.Verify(hash, "password"));
Assert.Contains("invalid 'iterations' parameter", exception.Message, StringComparison.Ordinal);
}
[Fact]
public void Verify_UnsupportedHashId_ThrowsNotSupportedException()
{
var hash = PasswordHash.Parse("$UNKNOWN$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D");
Assert.Throws<NotSupportedException>(() => _sut.Verify(hash, "password"));
}
[Fact]
public void GenerateSalt_ReturnsNonEmptyArray()
{
var salt = _sut.GenerateSalt();
Assert.NotEmpty(salt);
}
[Theory]
[InlineData(16)]
[InlineData(32)]
[InlineData(64)]
public void GenerateSalt_WithLength_ReturnsArrayOfSpecifiedLength(int length)
{
var salt = _sut.GenerateSalt(length);
Assert.Equal(length, salt.Length);
}
}

View File

@@ -1,72 +0,0 @@
using System;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Server.Implementations.Item;
using MediaBrowser.Controller;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Item;
public class BaseItemRepositoryTests
{
[Fact]
public void DeserializeBaseItem_WithUnknownType_ReturnsNull()
{
// Arrange
var entity = new BaseItemEntity
{
Id = Guid.NewGuid(),
Type = "NonExistent.Plugin.CustomItemType"
};
// Act
var result = BaseItemRepository.DeserializeBaseItem(entity, NullLogger.Instance, null, false);
// Assert
Assert.Null(result);
}
[Fact]
public void DeserializeBaseItem_WithUnknownType_LogsWarning()
{
// Arrange
var entity = new BaseItemEntity
{
Id = Guid.NewGuid(),
Type = "NonExistent.Plugin.CustomItemType"
};
var loggerMock = new Mock<ILogger>();
// Act
BaseItemRepository.DeserializeBaseItem(entity, loggerMock.Object, null, false);
// Assert
loggerMock.Verify(
x => x.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("unknown type", StringComparison.OrdinalIgnoreCase)),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public void DeserializeBaseItem_WithKnownType_ReturnsItem()
{
// Arrange
var entity = new BaseItemEntity
{
Id = Guid.NewGuid(),
Type = "MediaBrowser.Controller.Entities.Movies.Movie"
};
// Act
var result = BaseItemRepository.DeserializeBaseItem(entity, NullLogger.Instance, null, false);
// Assert
Assert.NotNull(result);
}
}

View File

@@ -1,81 +1,30 @@
using Emby.Server.Implementations.Library;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Library;
public class DotIgnoreIgnoreRuleTest
{
private static readonly string[] _rule1 = ["SPs"];
private static readonly string[] _rule2 = ["SPs", "!thebestshot.mkv"];
private static readonly string[] _rule3 = ["*.txt", @"{\colortbl;\red255\green255\blue255;}", "videos/", @"\invalid\escape\sequence", "*.mkv"];
private static readonly string[] _rule4 = [@"{\colortbl;\red255\green255\blue255;}", @"\invalid\escape\sequence"];
public static TheoryData<string[], string, bool, bool> CheckIgnoreRulesTestData =>
new()
{
// Basic pattern matching
{ _rule1, "f:/cd/sps/ffffff.mkv", false, true },
{ _rule1, "cd/sps/ffffff.mkv", false, true },
{ _rule1, "/cd/sps/ffffff.mkv", false, true },
// Negate pattern
{ _rule2, "f:/cd/sps/ffffff.mkv", false, true },
{ _rule2, "cd/sps/ffffff.mkv", false, true },
{ _rule2, "/cd/sps/ffffff.mkv", false, true },
{ _rule2, "f:/cd/sps/thebestshot.mkv", false, false },
{ _rule2, "cd/sps/thebestshot.mkv", false, false },
{ _rule2, "/cd/sps/thebestshot.mkv", false, false },
// Mixed valid and invalid patterns - skips invalid, applies valid
{ _rule3, "test.txt", false, true },
{ _rule3, "videos/movie.mp4", false, true },
{ _rule3, "movie.mkv", false, true },
{ _rule3, "test.mp3", false, false },
// Only invalid patterns - falls back to ignore all
{ _rule4, "any-file.txt", false, true },
{ _rule4, "any/path/to/file.mkv", false, true },
};
public static TheoryData<string[], string, bool, bool> WindowsPathNormalizationTestData =>
new()
{
// Windows paths with backslashes - should match when normalizePath is true
{ _rule1, @"C:\cd\sps\ffffff.mkv", false, true },
{ _rule1, @"D:\media\sps\movie.mkv", false, true },
{ _rule1, @"\\server\share\sps\file.mkv", false, true },
// Negate pattern with Windows paths
{ _rule2, @"C:\cd\sps\ffffff.mkv", false, true },
{ _rule2, @"C:\cd\sps\thebestshot.mkv", false, false },
// Directory matching with Windows paths
{ _rule3, @"C:\videos\movie.mp4", false, true },
{ _rule3, @"D:\documents\test.txt", false, true },
{ _rule3, @"E:\music\song.mp3", false, false },
};
[Theory]
[MemberData(nameof(CheckIgnoreRulesTestData))]
public void CheckIgnoreRules_ReturnsExpectedResult(string[] rules, string path, bool isDirectory, bool expectedIgnored)
[Fact]
public void Test()
{
Assert.Equal(expectedIgnored, DotIgnoreIgnoreRule.CheckIgnoreRules(path, rules, isDirectory));
var ignore = new Ignore.Ignore();
ignore.Add("SPs");
Assert.True(ignore.IsIgnored("f:/cd/sps/ffffff.mkv"));
Assert.True(ignore.IsIgnored("cd/sps/ffffff.mkv"));
Assert.True(ignore.IsIgnored("/cd/sps/ffffff.mkv"));
}
[Theory]
[MemberData(nameof(WindowsPathNormalizationTestData))]
public void CheckIgnoreRules_WithWindowsPaths_NormalizesBackslashes(string[] rules, string path, bool isDirectory, bool expectedIgnored)
[Fact]
public void TestNegatePattern()
{
// With normalizePath=true, backslashes should be converted to forward slashes
Assert.Equal(expectedIgnored, DotIgnoreIgnoreRule.CheckIgnoreRules(path, rules, isDirectory, normalizePath: true));
}
[Theory]
[InlineData(@"C:\cd\sps\ffffff.mkv")]
[InlineData(@"D:\media\sps\movie.mkv")]
public void CheckIgnoreRules_WithWindowsPaths_WithoutNormalization_DoesNotMatch(string path)
{
// Without normalization, Windows paths with backslashes won't match patterns expecting forward slashes
Assert.False(DotIgnoreIgnoreRule.CheckIgnoreRules(path, _rule1, isDirectory: false, normalizePath: false));
var ignore = new Ignore.Ignore();
ignore.Add("SPs");
ignore.Add("!thebestshot.mkv");
Assert.True(ignore.IsIgnored("f:/cd/sps/ffffff.mkv"));
Assert.True(ignore.IsIgnored("cd/sps/ffffff.mkv"));
Assert.True(ignore.IsIgnored("/cd/sps/ffffff.mkv"));
Assert.True(!ignore.IsIgnored("f:/cd/sps/thebestshot.mkv"));
Assert.True(!ignore.IsIgnored("cd/sps/thebestshot.mkv"));
Assert.True(!ignore.IsIgnored("/cd/sps/thebestshot.mkv"));
}
}

View File

@@ -203,25 +203,6 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
Assert.Null(localizationManager.GetRatingScore(value));
}
[Theory]
[InlineData("TV-MA", "DE", 17, 1)] // US-only rating, DE country code
[InlineData("PG-13", "FR", 13, 0)] // US-only rating, FR country code
[InlineData("R", "JP", 17, 0)] // US-only rating, JP country code
public async Task GetRatingScore_FallbackPrioritizesUS_Success(string rating, string countryCode, int expectedScore, int? expectedSubScore)
{
var localizationManager = Setup(new ServerConfiguration()
{
MetadataCountryCode = countryCode
});
await localizationManager.LoadAll();
var score = localizationManager.GetRatingScore(rating);
Assert.NotNull(score);
Assert.Equal(expectedScore, score.Score);
Assert.Equal(expectedSubScore, score.SubScore);
}
[Theory]
[InlineData("Default", "Default")]
[InlineData("HeaderLiveTV", "Live TV")]