Compare commits
77 Commits
v10.11.4
...
feature/cr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f88cab9ed2 | ||
|
|
0da3d7e29a | ||
|
|
f63a22e65e | ||
|
|
1325603fd0 | ||
|
|
26668426d4 | ||
|
|
945cb3a116 | ||
|
|
d12a39e0c0 | ||
|
|
b9b7b6f7ef | ||
|
|
dd73b1d309 | ||
|
|
464c02885e | ||
|
|
fb4b87df07 | ||
|
|
7574c7eb83 | ||
|
|
d725f9a583 | ||
|
|
cddaff96c2 | ||
|
|
6fbc0f87df | ||
|
|
0bc8b92b6a | ||
|
|
10662e75e4 | ||
|
|
a2b1936e73 | ||
|
|
2df546af6d | ||
|
|
338b480217 | ||
|
|
2943bb6fdd | ||
|
|
94edcbd2d1 | ||
|
|
a8d1cdefac | ||
|
|
a518160a6f | ||
|
|
b56de6493f | ||
|
|
093cfc3f3b | ||
|
|
49775b1f6a | ||
|
|
22d593b8e9 | ||
|
|
2cb7fb52d2 | ||
|
|
8433b6d8a4 | ||
|
|
32d2414de0 | ||
|
|
317a3a47c3 | ||
|
|
7cc33291f5 | ||
|
|
28854ebf2e | ||
|
|
7500f4a37f | ||
|
|
9ae454aa18 | ||
|
|
49beabce9e | ||
|
|
845b8cdc8f | ||
|
|
c86f6439c5 | ||
|
|
559e0088e5 | ||
|
|
adaca95590 | ||
|
|
09a1c31fa3 | ||
|
|
e4b82025b8 | ||
|
|
78e3702cb0 | ||
|
|
01b20d3b75 | ||
|
|
156761405e | ||
|
|
1805f2259f | ||
|
|
4c587776d6 | ||
|
|
8379b4634a | ||
|
|
9470439cfa | ||
|
|
18096e48e0 | ||
|
|
f2d0ac7b28 | ||
|
|
2ccf08f547 | ||
|
|
1e27f460fe | ||
|
|
4cdd8c8233 | ||
|
|
6e60634c9f | ||
|
|
12c5d6b636 | ||
|
|
b617c62f8e | ||
|
|
035b5895b0 | ||
|
|
22da5187c8 | ||
|
|
5804d6840c | ||
|
|
b50ce1ad6b | ||
|
|
481ee03f35 | ||
|
|
d91adb5d54 | ||
|
|
ef7f138a4e | ||
|
|
2e8d9a311b | ||
|
|
4c5a3fbff3 | ||
|
|
636908fc4d | ||
|
|
997362fc97 | ||
|
|
c5147341e3 | ||
|
|
ca33bcebf0 | ||
|
|
d32f487e8e | ||
|
|
fb65f8f853 | ||
|
|
2a0b90e385 | ||
|
|
dde70fd8a2 | ||
|
|
98d1d0cb35 | ||
|
|
026f7472cb |
@@ -3,7 +3,7 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "9.0.10",
|
||||
"version": "9.0.11",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
]
|
||||
|
||||
10
.github/workflows/ci-codeql-analysis.yml
vendored
10
.github/workflows/ci-codeql-analysis.yml
vendored
@@ -20,18 +20,18 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
|
||||
uses: github/codeql-action/init@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
|
||||
uses: github/codeql-action/autobuild@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
|
||||
uses: github/codeql-action/analyze@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6
|
||||
|
||||
16
.github/workflows/ci-compat.yml
vendored
16
.github/workflows/ci-compat.yml
vendored
@@ -11,13 +11,13 @@ jobs:
|
||||
permissions: read-all
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
dotnet build Jellyfin.Server -o ./out
|
||||
|
||||
- name: Upload Head
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: abi-head
|
||||
retention-days: 14
|
||||
@@ -40,14 +40,14 @@ jobs:
|
||||
permissions: read-all
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
dotnet build Jellyfin.Server -o ./out
|
||||
|
||||
- name: Upload Head
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
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@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.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@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: abi-base
|
||||
path: abi-base
|
||||
|
||||
24
.github/workflows/ci-openapi.yml
vendored
24
.github/workflows/ci-openapi.yml
vendored
@@ -16,18 +16,18 @@ jobs:
|
||||
permissions: read-all
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
- name: Generate openapi.json
|
||||
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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: openapi-head
|
||||
retention-days: 14
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
permissions: read-all
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
@@ -55,13 +55,13 @@ jobs:
|
||||
ANCESTOR_REF=$(git merge-base upstream/${{ github.base_ref }} origin/$HEAD_REF)
|
||||
git checkout --progress --force $ANCESTOR_REF
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
- name: Generate openapi.json
|
||||
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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: openapi-base
|
||||
retention-days: 14
|
||||
@@ -80,12 +80,12 @@ jobs:
|
||||
- openapi-base
|
||||
steps:
|
||||
- name: Download openapi-head
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.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@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: openapi-base
|
||||
path: openapi-base
|
||||
@@ -158,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@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: openapi-head
|
||||
path: openapi-head
|
||||
@@ -172,7 +172,7 @@ jobs:
|
||||
strip_components: 1
|
||||
target: "/srv/incoming/openapi/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
|
||||
- name: Move openapi.json (unstable) into place
|
||||
uses: appleboy/ssh-action@2ead5e36573f08b82fbfce1504f1a4b05a647c6f # v1.2.2
|
||||
uses: appleboy/ssh-action@823bd89e131d8d508129f9443cad5855e9ba96f0 # v1.2.4
|
||||
with:
|
||||
host: "${{ secrets.REPO_HOST }}"
|
||||
username: "${{ secrets.REPO_USER }}"
|
||||
@@ -220,7 +220,7 @@ jobs:
|
||||
run: |-
|
||||
echo "JELLYFIN_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
|
||||
- name: Download openapi-head
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: openapi-head
|
||||
path: openapi-head
|
||||
@@ -234,7 +234,7 @@ jobs:
|
||||
strip_components: 1
|
||||
target: "/srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
|
||||
- name: Move openapi.json (stable) into place
|
||||
uses: appleboy/ssh-action@2ead5e36573f08b82fbfce1504f1a4b05a647c6f # v1.2.2
|
||||
uses: appleboy/ssh-action@823bd89e131d8d508129f9443cad5855e9ba96f0 # v1.2.4
|
||||
with:
|
||||
host: "${{ secrets.REPO_HOST }}"
|
||||
username: "${{ secrets.REPO_USER }}"
|
||||
|
||||
6
.github/workflows/ci-tests.yml
vendored
6
.github/workflows/ci-tests.yml
vendored
@@ -20,9 +20,9 @@ jobs:
|
||||
|
||||
runs-on: "${{ matrix.os }}"
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
- uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
with:
|
||||
dotnet-version: ${{ env.SDK_VERSION }}
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
--verbosity minimal
|
||||
|
||||
- name: Merge code coverage results
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@9870ed167742d546b99962ff815fcc1098355ed8 # v5.4.17
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@ee0ae774f6d3afedcbd1683c1ab21b83670bdf8e # v5.5.1
|
||||
with:
|
||||
reports: "**/coverage.cobertura.xml"
|
||||
targetdir: "merged/"
|
||||
|
||||
6
.github/workflows/commands.yml
vendored
6
.github/workflows/commands.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
reactions: '+1'
|
||||
|
||||
- name: Checkout the latest code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
fetch-depth: 0
|
||||
@@ -40,11 +40,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: pull in script
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
repository: jellyfin/jellyfin-triage-script
|
||||
- name: install python
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: '3.14'
|
||||
cache: 'pip'
|
||||
|
||||
2
.github/workflows/issue-stale.yml
vendored
2
.github/workflows/issue-stale.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ contains(github.repository, 'jellyfin/') }}
|
||||
steps:
|
||||
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
with:
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
ascending: true
|
||||
|
||||
4
.github/workflows/issue-template-check.yml
vendored
4
.github/workflows/issue-template-check.yml
vendored
@@ -10,11 +10,11 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: pull in script
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
repository: jellyfin/jellyfin-triage-script
|
||||
- name: install python
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: '3.14'
|
||||
cache: 'pip'
|
||||
|
||||
2
.github/workflows/pull-request-stale.yaml
vendored
2
.github/workflows/pull-request-stale.yaml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ contains(github.repository, 'jellyfin/') }}
|
||||
steps:
|
||||
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
with:
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
ascending: true
|
||||
|
||||
4
.github/workflows/release-bump-version.yaml
vendored
4
.github/workflows/release-bump-version.yaml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
yq-version: v4.9.8
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ env.TAG_BRANCH }}
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }}
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ env.TAG_BRANCH }}
|
||||
|
||||
|
||||
@@ -117,6 +117,7 @@
|
||||
- [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)
|
||||
@@ -205,6 +206,8 @@
|
||||
- [theshoeshiner](https://github.com/theshoeshiner)
|
||||
- [TokerX](https://github.com/TokerX)
|
||||
- [GeneMarks](https://github.com/GeneMarks)
|
||||
- [martenumberto](https://github.com/martenumberto)
|
||||
- [MarcoCoreDuo](https://github.com/MarcoCoreDuo)
|
||||
|
||||
# Emby Contributors
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
</PropertyGroup>
|
||||
<!-- Run "dotnet list package (dash,dash)outdated" to see the latest versions of each package.-->
|
||||
<ItemGroup Label="Package Dependencies">
|
||||
<PackageVersion Include="AsyncKeyedLock" Version="7.1.7" />
|
||||
<PackageVersion Include="AsyncKeyedLock" Version="7.1.8" />
|
||||
<PackageVersion Include="AutoFixture.AutoMoq" Version="4.18.1" />
|
||||
<PackageVersion Include="AutoFixture.Xunit2" Version="4.18.1" />
|
||||
<PackageVersion Include="AutoFixture" Version="4.18.1" />
|
||||
@@ -17,7 +17,7 @@
|
||||
<PackageVersion Include="Diacritics" Version="4.0.17" />
|
||||
<PackageVersion Include="DiscUtils.Udf" Version="0.16.13" />
|
||||
<PackageVersion Include="DotNet.Glob" Version="3.1.3" />
|
||||
<PackageVersion Include="FsCheck.Xunit" Version="3.3.1" />
|
||||
<PackageVersion Include="FsCheck.Xunit" Version="3.3.2" />
|
||||
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="8.3.1.1" />
|
||||
<PackageVersion Include="ICU4N.Transliterator" Version="60.1.0-alpha.356" />
|
||||
<PackageVersion Include="IDisposableAnalyzers" Version="4.0.8" />
|
||||
@@ -26,33 +26,33 @@
|
||||
<PackageVersion Include="libse" Version="4.0.12" />
|
||||
<PackageVersion Include="LrcParser" Version="2025.623.0" />
|
||||
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="6.1.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="4.14.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageVersion Include="MimeTypes" Version="2.5.2" />
|
||||
<PackageVersion Include="Morestachio" Version="5.0.1.631" />
|
||||
<PackageVersion Include="Moq" Version="4.18.4" />
|
||||
@@ -62,13 +62,13 @@
|
||||
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
||||
<PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" />
|
||||
<PackageVersion Include="prometheus-net" Version="8.2.1" />
|
||||
<PackageVersion Include="Polly" Version="8.6.4" />
|
||||
<PackageVersion Include="Polly" Version="8.6.5" />
|
||||
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
|
||||
<PackageVersion Include="Serilog.Expressions" Version="5.0.0" />
|
||||
<PackageVersion Include="Serilog.Settings.Configuration" Version="9.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Async" Version="2.1.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
|
||||
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
|
||||
@@ -84,11 +84,11 @@
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.2.3" />
|
||||
<PackageVersion Include="System.Globalization" Version="4.3.0" />
|
||||
<PackageVersion Include="System.Linq.Async" Version="6.0.3" />
|
||||
<PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.10" />
|
||||
<PackageVersion Include="System.Text.Json" Version="9.0.10" />
|
||||
<PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.10" />
|
||||
<PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.11" />
|
||||
<PackageVersion Include="System.Text.Json" Version="9.0.11" />
|
||||
<PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.11" />
|
||||
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
|
||||
<PackageVersion Include="z440.atl.core" Version="7.6.0" />
|
||||
<PackageVersion Include="z440.atl.core" Version="7.9.0" />
|
||||
<PackageVersion Include="TMDbLib" Version="2.3.0" />
|
||||
<PackageVersion Include="UTF.Unknown" Version="2.6.0" />
|
||||
<PackageVersion Include="Xunit.Priority" Version="1.1.6" />
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Naming</PackageId>
|
||||
<VersionPrefix>10.11.4</VersionPrefix>
|
||||
<VersionPrefix>10.11.6</VersionPrefix>
|
||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -1051,16 +1051,16 @@ namespace Emby.Server.Implementations.Dto
|
||||
|
||||
// Include artists that are not in the database yet, e.g., just added via metadata editor
|
||||
// var foundArtists = artistItems.Items.Select(i => i.Item1.Name).ToList();
|
||||
dto.ArtistItems = _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))])
|
||||
.Where(e => e.Value.Length > 0)
|
||||
.Select(i =>
|
||||
{
|
||||
return new NameGuidPair
|
||||
{
|
||||
Name = i.Key,
|
||||
Id = i.Value.First().Id
|
||||
};
|
||||
}).Where(i => i is not null).ToArray();
|
||||
var artistsLookup = _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))]);
|
||||
|
||||
dto.ArtistItems = hasArtist.Artists
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Distinct()
|
||||
.Select(name => artistsLookup.TryGetValue(name, out var artists) && artists.Length > 0
|
||||
? new NameGuidPair { Name = name, Id = artists[0].Id }
|
||||
: null)
|
||||
.Where(item => item is not null)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
if (item is IHasAlbumArtist hasAlbumArtist)
|
||||
@@ -1085,31 +1085,16 @@ namespace Emby.Server.Implementations.Dto
|
||||
// })
|
||||
// .ToList();
|
||||
|
||||
var albumArtistsLookup = _libraryManager.GetArtists([.. hasAlbumArtist.AlbumArtists.Where(e => !string.IsNullOrWhiteSpace(e))]);
|
||||
|
||||
dto.AlbumArtists = hasAlbumArtist.AlbumArtists
|
||||
// .Except(foundArtists, new DistinctNameComparer())
|
||||
.Select(i =>
|
||||
{
|
||||
// This should not be necessary but we're seeing some cases of it
|
||||
if (string.IsNullOrEmpty(i))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var artist = _libraryManager.GetArtist(i, new DtoOptions(false)
|
||||
{
|
||||
EnableImages = false
|
||||
});
|
||||
if (artist is not null)
|
||||
{
|
||||
return new NameGuidPair
|
||||
{
|
||||
Name = artist.Name,
|
||||
Id = artist.Id
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}).Where(i => i is not null).ToArray();
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Distinct()
|
||||
.Select(name => albumArtistsLookup.TryGetValue(name, out var albumArtists) && albumArtists.Length > 0
|
||||
? new NameGuidPair { Name = name, Id = albumArtists[0].Id }
|
||||
: null)
|
||||
.Where(item => item is not null)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
// Add video info
|
||||
|
||||
@@ -352,6 +352,12 @@ namespace Emby.Server.Implementations.IO
|
||||
return;
|
||||
}
|
||||
|
||||
var fileInfo = _fileSystem.GetFileSystemInfo(path);
|
||||
if (DotIgnoreIgnoreRule.IsIgnored(fileInfo, null))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore certain files, If the parent of an ignored path has a change event, ignore that too
|
||||
foreach (var i in _tempIgnoredPaths.Keys)
|
||||
{
|
||||
|
||||
@@ -98,5 +98,11 @@ namespace Emby.Server.Implementations.Images
|
||||
|
||||
return base.CreateImage(item, itemsWithImages, outputPath, imageType, imageIndex);
|
||||
}
|
||||
|
||||
protected override bool HasChangedByDate(BaseItem item, ItemImageInfo image)
|
||||
{
|
||||
var age = DateTime.UtcNow - image.DateModified;
|
||||
return age.TotalDays > 7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using MediaBrowser.Controller.Resolvers;
|
||||
@@ -70,12 +71,55 @@ 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();
|
||||
ignore.Add(rules);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Mitigate the problem of the Ignore library not handling Windows paths correctly.
|
||||
// See https://github.com/jellyfin/jellyfin/issues/15484
|
||||
var pathToCheck = IsWindows ? path.NormalizePath('/') : path;
|
||||
var pathToCheck = normalizePath ? path.NormalizePath('/') : path;
|
||||
|
||||
// Add trailing slash for directories to match "folder/"
|
||||
if (isDirectory)
|
||||
|
||||
@@ -83,6 +83,7 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
// Unix hidden files
|
||||
"**/.*",
|
||||
"**/.*/**",
|
||||
|
||||
// Mac - if you ever remove the above.
|
||||
// "**/._*",
|
||||
|
||||
@@ -1058,6 +1058,7 @@ namespace Emby.Server.Implementations.Library
|
||||
{
|
||||
IncludeItemTypes = [BaseItemKind.MusicArtist],
|
||||
Name = name,
|
||||
UseRawName = true,
|
||||
DtoOptions = options
|
||||
}).Cast<MusicArtist>()
|
||||
.OrderBy(i => i.IsAccessedByName ? 1 : 0)
|
||||
@@ -2201,6 +2202,12 @@ namespace Emby.Server.Implementations.Library
|
||||
public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
|
||||
=> UpdateItemsAsync([item], parent, updateReason, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken)
|
||||
{
|
||||
await _itemRepository.ReattachUserDataAsync(item, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task RunMetadataSavers(BaseItem item, ItemUpdateType updateReason)
|
||||
{
|
||||
if (item.IsFileProtocol)
|
||||
@@ -3194,19 +3201,7 @@ namespace Emby.Server.Implementations.Library
|
||||
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
|
||||
var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName);
|
||||
|
||||
var shortcutFilename = Path.GetFileNameWithoutExtension(path);
|
||||
|
||||
var lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
|
||||
|
||||
while (File.Exists(lnk))
|
||||
{
|
||||
shortcutFilename += "1";
|
||||
lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
|
||||
}
|
||||
|
||||
_fileSystem.CreateShortcut(lnk, _appHost.ReverseVirtualPath(path));
|
||||
|
||||
RemoveContentTypeOverrides(path);
|
||||
CreateShortcut(virtualFolderPath, pathInfo);
|
||||
|
||||
if (saveLibraryOptions)
|
||||
{
|
||||
@@ -3371,5 +3366,24 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
return item is UserRootFolder || item.IsVisibleStandalone(user);
|
||||
}
|
||||
|
||||
public void CreateShortcut(string virtualFolderPath, MediaPathInfo pathInfo)
|
||||
{
|
||||
var path = pathInfo.Path;
|
||||
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
|
||||
|
||||
var shortcutFilename = Path.GetFileNameWithoutExtension(path);
|
||||
|
||||
var lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
|
||||
|
||||
while (File.Exists(lnk))
|
||||
{
|
||||
shortcutFilename += "1";
|
||||
lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
|
||||
}
|
||||
|
||||
_fileSystem.CreateShortcut(lnk, _appHost.ReverseVirtualPath(path));
|
||||
RemoveContentTypeOverrides(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ 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!;
|
||||
@@ -161,6 +162,7 @@ namespace Emby.Server.Implementations.Localization
|
||||
list.Add(new CultureDto(name, displayname, twoCharName, threeLetterNames));
|
||||
}
|
||||
|
||||
_cultureCache.Clear();
|
||||
_cultures = list;
|
||||
_iso6392BtoT = iso6392BtoTdict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -169,20 +171,31 @@ namespace Emby.Server.Implementations.Localization
|
||||
/// <inheritdoc />
|
||||
public CultureDto? FindLanguageInfo(string language)
|
||||
{
|
||||
// TODO language should ideally be a ReadOnlySpan but moq cannot mock ref structs
|
||||
for (var i = 0; i < _cultures.Count; i++)
|
||||
if (string.IsNullOrEmpty(language))
|
||||
{
|
||||
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 null;
|
||||
}
|
||||
|
||||
return default;
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -311,15 +324,19 @@ namespace Emby.Server.Implementations.Localization
|
||||
else
|
||||
{
|
||||
// Fall back to server default language for ratings check
|
||||
// If it has no ratings, use the US ratings
|
||||
var ratingsDictionary = GetParentalRatingsDictionary() ?? GetParentalRatingsDictionary("us");
|
||||
var ratingsDictionary = GetParentalRatingsDictionary();
|
||||
if (ratingsDictionary is not null && ratingsDictionary.TryGetValue(rating, out ParentalRatingScore? value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// If we don't find anything, check all ratings systems
|
||||
// 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;
|
||||
}
|
||||
|
||||
foreach (var dictionary in _allParentalRatings.Values)
|
||||
{
|
||||
if (dictionary.TryGetValue(rating, out var value))
|
||||
|
||||
@@ -156,6 +156,11 @@ 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);
|
||||
|
||||
@@ -1839,8 +1839,9 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
{
|
||||
if (isActualOutputVideoCodecHevc)
|
||||
{
|
||||
// Prefer dvh1 to dvhe
|
||||
args += " -tag:v:0 dvh1 -strict -2";
|
||||
// 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";
|
||||
}
|
||||
else if (isActualOutputVideoCodecAv1)
|
||||
{
|
||||
|
||||
@@ -418,7 +418,7 @@ public class ItemUpdateController : BaseJellyfinApiController
|
||||
{
|
||||
if (item is IHasAlbumArtist hasAlbumArtists)
|
||||
{
|
||||
hasAlbumArtists.AlbumArtists = Array.ConvertAll(request.AlbumArtists, i => i.Name);
|
||||
hasAlbumArtists.AlbumArtists = Array.ConvertAll(request.AlbumArtists, i => i.Name.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,7 +426,7 @@ public class ItemUpdateController : BaseJellyfinApiController
|
||||
{
|
||||
if (item is IHasArtist hasArtists)
|
||||
{
|
||||
hasArtists.Artists = Array.ConvertAll(request.ArtistItems, i => i.Name);
|
||||
hasArtists.Artists = Array.ConvertAll(request.ArtistItems, i => i.Name.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ 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;
|
||||
@@ -700,7 +701,18 @@ 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);
|
||||
|
||||
return PhysicalFile(item.Path, MimeTypes.GetMimeType(item.Path), filename, true);
|
||||
var filePath = item.Path;
|
||||
if (item.IsFileProtocol)
|
||||
{
|
||||
// PhysicalFile does not work well with symlinks at the moment.
|
||||
var resolved = FileSystemHelper.ResolveLinkTarget(filePath, returnFinalTarget: true);
|
||||
if (resolved is not null && resolved.Exists)
|
||||
{
|
||||
filePath = resolved.FullName;
|
||||
}
|
||||
}
|
||||
|
||||
return PhysicalFile(filePath, MimeTypes.GetMimeType(filePath), filename, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -342,6 +342,17 @@ public class LibraryStructureController : BaseJellyfinApiController
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
LibraryOptions options = item.GetLibraryOptions();
|
||||
foreach (var mediaPath in request.LibraryOptions!.PathInfos)
|
||||
{
|
||||
if (options.PathInfos.Any(i => i.Path == mediaPath.Path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_libraryManager.CreateShortcut(item.Path, mediaPath);
|
||||
}
|
||||
|
||||
item.UpdateLibraryOptions(request.LibraryOptions);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ public class TrickplayController : BaseJellyfinApiController
|
||||
[FromRoute, Required] int index,
|
||||
[FromQuery] Guid? mediaSourceId)
|
||||
{
|
||||
var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
|
||||
var item = _libraryManager.GetItemById<BaseItem>(mediaSourceId ?? itemId, User.GetUserId());
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
|
||||
@@ -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(_httpContextAccessor.HttpContext.Request.QueryString.ToString());
|
||||
var newQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
|
||||
newQuery["AudioCodec"] = state.OutputAudioCodec;
|
||||
queryString = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(string.Empty, newQuery);
|
||||
}
|
||||
@@ -173,10 +173,21 @@ public class DynamicHlsHelper
|
||||
queryString += "&TranscodeReasons=" + state.Request.TranscodeReasons;
|
||||
}
|
||||
|
||||
// Main stream
|
||||
var playlistUrl = isLiveStream ? "live.m3u8" : "main.m3u8";
|
||||
// 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";
|
||||
}
|
||||
|
||||
playlistUrl += queryString;
|
||||
// Main stream
|
||||
var baseUrl = isLiveStream ? "live.m3u8" : "main.m3u8";
|
||||
var playlistUrl = baseUrl + queryString;
|
||||
var playlistQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
|
||||
|
||||
var subtitleStreams = state.MediaSource
|
||||
.MediaStreams
|
||||
@@ -198,37 +209,36 @@ 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 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))
|
||||
// Provide AV1 and HEVC SDR entrances for backward compatibility.
|
||||
foreach (var sdrVideoCodec in new[] { "av1", "hevc" })
|
||||
{
|
||||
var requestedVideoProfiles = state.GetRequestedProfiles("hevc");
|
||||
if (requestedVideoProfiles is not null && requestedVideoProfiles.Length > 0)
|
||||
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)
|
||||
{
|
||||
// Force HEVC Main Profile and disable video stream copy.
|
||||
state.OutputVideoCodec = "hevc";
|
||||
var sdrVideoUrl = ReplaceProfile(playlistUrl, "hevc", string.Join(',', requestedVideoProfiles), "main");
|
||||
sdrVideoUrl += "&AllowVideoStreamCopy=false";
|
||||
// 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);
|
||||
|
||||
// HACK: Use the same bitrate so that the client can choose by other attributes, such as color range.
|
||||
AppendPlaylist(builder, state, sdrVideoUrl, totalBitrate, subtitleGroup);
|
||||
@@ -238,12 +248,30 @@ 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 (encodingOptions.AllowHevcEncoding
|
||||
&& !encodingOptions.AllowAv1Encoding
|
||||
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||
if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||
&& state.VideoStream.Level.HasValue
|
||||
&& state.VideoStream.Level > 150
|
||||
&& state.VideoStream.VideoRange == VideoRange.SDR
|
||||
@@ -273,12 +301,15 @@ public class DynamicHlsHelper
|
||||
var variation = GetBitrateVariation(totalBitrate);
|
||||
|
||||
var newBitrate = totalBitrate - variation;
|
||||
var variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
|
||||
var variantQuery = playlistQuery;
|
||||
variantQuery["VideoBitrate"] = (requestedVideoBitrate - variation).ToString(CultureInfo.InvariantCulture);
|
||||
var variantUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, variantQuery);
|
||||
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
|
||||
|
||||
variation *= 2;
|
||||
newBitrate = totalBitrate - variation;
|
||||
variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
|
||||
variantQuery["VideoBitrate"] = (requestedVideoBitrate - variation).ToString(CultureInfo.InvariantCulture);
|
||||
variantUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, variantQuery);
|
||||
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
|
||||
}
|
||||
|
||||
@@ -863,23 +894,6 @@ 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();
|
||||
|
||||
@@ -252,6 +252,15 @@ public class MediaInfoHelper
|
||||
options.EnableDirectStream = false;
|
||||
}
|
||||
|
||||
// Force transcoding for remote sources if user has the permission
|
||||
if (mediaSource.IsRemote && user.HasPermission(PermissionKind.ForceRemoteSourceTranscoding))
|
||||
{
|
||||
options.EnableDirectPlay = false;
|
||||
options.EnableDirectStream = false;
|
||||
options.AllowAudioStreamCopy = false;
|
||||
options.AllowVideoStreamCopy = false;
|
||||
}
|
||||
|
||||
// Beginning of Playback Determination
|
||||
var streamInfo = item.MediaType == MediaType.Audio
|
||||
? streamBuilder.GetOptimalAudioStream(options)
|
||||
|
||||
@@ -159,6 +159,13 @@ 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;
|
||||
|
||||
@@ -75,18 +75,22 @@ public class ExceptionMiddleware
|
||||
if (ignoreStackTrace)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Error processing request: {ExceptionMessage}. URL {Method} {Url}.",
|
||||
"Error processing request: {ExceptionMessage}. URL {Method} {Url}. Client IP: {RemoteIp}, User-Agent: {UserAgent}",
|
||||
ex.Message.TrimEnd('.'),
|
||||
context.Request.Method,
|
||||
context.Request.Path);
|
||||
context.Request.Path,
|
||||
context.Connection.RemoteIpAddress,
|
||||
context.Request.Headers.UserAgent.ToString());
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Error processing request. URL {Method} {Url}.",
|
||||
"Error processing request. URL {Method} {Url}. Client IP: {RemoteIp}, User-Agent: {UserAgent}",
|
||||
context.Request.Method,
|
||||
context.Request.Path);
|
||||
context.Request.Path,
|
||||
context.Connection.RemoteIpAddress,
|
||||
context.Request.Headers.UserAgent.ToString());
|
||||
}
|
||||
|
||||
context.Response.StatusCode = GetStatusCode(ex);
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Data</PackageId>
|
||||
<VersionPrefix>10.11.4</VersionPrefix>
|
||||
<VersionPrefix>10.11.6</VersionPrefix>
|
||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -410,10 +410,25 @@ public sealed class BaseItemRepository
|
||||
|
||||
private static IQueryable<BaseItemEntity> ApplyNavigations(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
|
||||
{
|
||||
dbQuery = dbQuery.Include(e => e.TrailerTypes)
|
||||
.Include(e => e.Provider)
|
||||
.Include(e => e.LockedFields)
|
||||
.Include(e => e.UserData);
|
||||
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);
|
||||
}
|
||||
|
||||
if (filter.DtoOptions.EnableImages)
|
||||
{
|
||||
@@ -602,7 +617,6 @@ public sealed class BaseItemRepository
|
||||
|
||||
var ids = tuples.Select(f => f.Item.Id).ToArray();
|
||||
var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToArray();
|
||||
var newItems = tuples.Where(e => !existingItems.Contains(e.Item.Id)).ToArray();
|
||||
|
||||
foreach (var item in tuples)
|
||||
{
|
||||
@@ -636,19 +650,6 @@ public sealed class BaseItemRepository
|
||||
|
||||
context.SaveChanges();
|
||||
|
||||
foreach (var item in newItems)
|
||||
{
|
||||
// reattach old userData entries
|
||||
var userKeys = item.UserDataKey.ToArray();
|
||||
var retentionDate = (DateTime?)null;
|
||||
context.UserData
|
||||
.Where(e => e.ItemId == PlaceholderId)
|
||||
.Where(e => userKeys.Contains(e.CustomDataKey))
|
||||
.ExecuteUpdate(e => e
|
||||
.SetProperty(f => f.ItemId, item.Item.Id)
|
||||
.SetProperty(f => f.RetentionDate, retentionDate));
|
||||
}
|
||||
|
||||
var itemValueMaps = tuples
|
||||
.Select(e => (e.Item, Values: GetItemValuesToSave(e.Item, e.InheritedTags)))
|
||||
.ToArray();
|
||||
@@ -744,6 +745,29 @@ public sealed class BaseItemRepository
|
||||
transaction.Commit();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ReattachUserDataAsync(BaseItemDto item, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(item);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
var userKeys = item.GetUserDataKeys().ToArray();
|
||||
var retentionDate = (DateTime?)null;
|
||||
await dbContext.UserData
|
||||
.Where(e => e.ItemId == PlaceholderId)
|
||||
.Where(e => userKeys.Contains(e.CustomDataKey))
|
||||
.ExecuteUpdateAsync(
|
||||
e => e
|
||||
.SetProperty(f => f.ItemId, item.Id)
|
||||
.SetProperty(f => f.RetentionDate, retentionDate),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public BaseItemDto? RetrieveItem(Guid id)
|
||||
{
|
||||
@@ -851,7 +875,7 @@ public sealed class BaseItemRepository
|
||||
}
|
||||
|
||||
dto.ExtraIds = string.IsNullOrWhiteSpace(entity.ExtraIds) ? [] : entity.ExtraIds.Split('|').Select(e => Guid.Parse(e)).ToArray();
|
||||
dto.ProductionLocations = entity.ProductionLocations?.Split('|') ?? [];
|
||||
dto.ProductionLocations = entity.ProductionLocations?.Split('|', StringSplitOptions.RemoveEmptyEntries) ?? [];
|
||||
dto.Studios = entity.Studios?.Split('|') ?? [];
|
||||
dto.Tags = string.IsNullOrWhiteSpace(entity.Tags) ? [] : entity.Tags.Split('|');
|
||||
|
||||
@@ -1013,7 +1037,7 @@ public sealed class BaseItemRepository
|
||||
}
|
||||
|
||||
entity.ExtraIds = dto.ExtraIds is not null ? string.Join('|', dto.ExtraIds) : null;
|
||||
entity.ProductionLocations = dto.ProductionLocations is not null ? string.Join('|', dto.ProductionLocations) : null;
|
||||
entity.ProductionLocations = dto.ProductionLocations is not null ? string.Join('|', dto.ProductionLocations.Where(p => !string.IsNullOrWhiteSpace(p))) : null;
|
||||
entity.Studios = dto.Studios is not null ? string.Join('|', dto.Studios) : null;
|
||||
entity.Tags = dto.Tags is not null ? string.Join('|', dto.Tags) : null;
|
||||
entity.LockedFields = dto.LockedFields is not null ? dto.LockedFields
|
||||
@@ -1529,43 +1553,50 @@ public sealed class BaseItemRepository
|
||||
|
||||
private IQueryable<BaseItemEntity> ApplyOrder(IQueryable<BaseItemEntity> query, InternalItemsQuery filter, JellyfinDbContext context)
|
||||
{
|
||||
var orderBy = filter.OrderBy;
|
||||
var orderBy = filter.OrderBy.Where(e => e.OrderBy != ItemSortBy.Default).ToArray();
|
||||
var hasSearch = !string.IsNullOrEmpty(filter.SearchTerm);
|
||||
|
||||
if (hasSearch)
|
||||
{
|
||||
orderBy = filter.OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending), .. orderBy];
|
||||
orderBy = [(ItemSortBy.SortName, SortOrder.Ascending), .. orderBy];
|
||||
}
|
||||
else if (orderBy.Count == 0)
|
||||
else if (orderBy.Length == 0)
|
||||
{
|
||||
return query.OrderBy(e => e.SortName);
|
||||
}
|
||||
|
||||
IOrderedQueryable<BaseItemEntity>? orderedQuery = null;
|
||||
|
||||
// When searching, prioritize by match quality: exact match > prefix match > contains
|
||||
if (hasSearch)
|
||||
{
|
||||
orderedQuery = query.OrderBy(OrderMapper.MapSearchRelevanceOrder(filter.SearchTerm!));
|
||||
}
|
||||
|
||||
var firstOrdering = orderBy.FirstOrDefault();
|
||||
if (firstOrdering != default)
|
||||
{
|
||||
var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter, context);
|
||||
if (firstOrdering.SortOrder == SortOrder.Ascending)
|
||||
if (orderedQuery is null)
|
||||
{
|
||||
orderedQuery = query.OrderBy(expression);
|
||||
// No search relevance ordering, start fresh
|
||||
orderedQuery = firstOrdering.SortOrder == SortOrder.Ascending
|
||||
? query.OrderBy(expression)
|
||||
: query.OrderByDescending(expression);
|
||||
}
|
||||
else
|
||||
{
|
||||
orderedQuery = query.OrderByDescending(expression);
|
||||
// Search relevance ordering already applied, chain with ThenBy
|
||||
orderedQuery = firstOrdering.SortOrder == SortOrder.Ascending
|
||||
? orderedQuery.ThenBy(expression)
|
||||
: orderedQuery.ThenByDescending(expression);
|
||||
}
|
||||
|
||||
if (firstOrdering.OrderBy is ItemSortBy.Default or ItemSortBy.SortName)
|
||||
{
|
||||
if (firstOrdering.SortOrder is SortOrder.Ascending)
|
||||
{
|
||||
orderedQuery = orderedQuery.ThenBy(e => e.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
orderedQuery = orderedQuery.ThenByDescending(e => e.Name);
|
||||
}
|
||||
orderedQuery = firstOrdering.SortOrder is SortOrder.Ascending
|
||||
? orderedQuery.ThenBy(e => e.Name)
|
||||
: orderedQuery.ThenByDescending(e => e.Name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1930,8 +1961,15 @@ public sealed class BaseItemRepository
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Name))
|
||||
{
|
||||
var cleanName = GetCleanValue(filter.Name);
|
||||
baseQuery = baseQuery.Where(e => e.CleanName == cleanName);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// These are the same, for now
|
||||
@@ -2425,35 +2463,24 @@ 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 && 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))));
|
||||
!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))));
|
||||
}
|
||||
|
||||
if (filter.IncludeInheritedTags.Length > 0)
|
||||
{
|
||||
// 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))));
|
||||
}
|
||||
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))
|
||||
|
||||
// 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)));
|
||||
}
|
||||
// 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}\"")));
|
||||
}
|
||||
|
||||
if (filter.SeriesStatuses.Length > 0)
|
||||
@@ -2607,6 +2634,21 @@ 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)).Cast<MusicArtist>().ToArray());
|
||||
var lookup = artists
|
||||
.GroupBy(e => e.Name!)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.Select(f => DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray());
|
||||
|
||||
var result = new Dictionary<string, MusicArtist[]>(artistNames.Count);
|
||||
foreach (var name in artistNames)
|
||||
{
|
||||
if (lookup.TryGetValue(name, out var artistArray))
|
||||
{
|
||||
result[name] = artistArray;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Linq.Expressions;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -68,4 +69,30 @@ public static class OrderMapper
|
||||
_ => e => e.SortName
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an expression to order search results by match quality.
|
||||
/// Prioritizes: exact match (0) > prefix match with word boundary (1) > prefix match (2) > contains (3).
|
||||
/// </summary>
|
||||
/// <param name="searchTerm">The search term to match against.</param>
|
||||
/// <returns>An expression that returns an integer representing match quality (lower is better).</returns>
|
||||
public static Expression<Func<BaseItemEntity, int>> MapSearchRelevanceOrder(string searchTerm)
|
||||
{
|
||||
var cleanSearchTerm = GetCleanValue(searchTerm);
|
||||
var searchPrefix = cleanSearchTerm + " ";
|
||||
return e =>
|
||||
e.CleanName == cleanSearchTerm ? 0 :
|
||||
e.CleanName!.StartsWith(searchPrefix) ? 1 :
|
||||
e.CleanName!.StartsWith(cleanSearchTerm) ? 2 : 3;
|
||||
}
|
||||
|
||||
private static string GetCleanValue(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return value.RemoveDiacritics().ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
|
||||
ThrowIfInvalidUsername(newName);
|
||||
|
||||
if (user.Username.Equals(newName, StringComparison.OrdinalIgnoreCase))
|
||||
if (user.Username.Equals(newName, StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException("The new and old names must be different.");
|
||||
}
|
||||
|
||||
@@ -84,6 +84,9 @@
|
||||
<None Update="ServerSetupApp/index.mstemplate.html">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Resources/Shaders/crt_lottes.cl">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
340
Jellyfin.Server/Resources/Shaders/crt_lottes.cl
Normal file
340
Jellyfin.Server/Resources/Shaders/crt_lottes.cl
Normal file
@@ -0,0 +1,340 @@
|
||||
// CRT Lottes shader — OpenCL implementation for FFmpeg program_opencl
|
||||
// Port of Timothy Lottes' CRT shader (public domain).
|
||||
// Adapted from the mpv-retro-shaders GLSL version.
|
||||
//
|
||||
// Copyright (c) 2022, The mpv-retro-shaders Contributors
|
||||
// Copyright (c) 2024, Jellyfin Contributors
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// Input/output: NV12 (Y plane + interleaved CbCr plane).
|
||||
// Kernel signature: (src_y, src_uv, dst_y, dst_uv)
|
||||
// FFmpeg program_opencl passes one image2d_t per plane in plane order.
|
||||
|
||||
// fract() requires 2 arguments in OpenCL 1.x; define a 1-arg wrapper.
|
||||
#define crt_fract(x) ((x) - floor(x))
|
||||
|
||||
// ── Parameters ────────────────────────────────────────────────────────────────
|
||||
#ifndef HARD_SCAN
|
||||
#define HARD_SCAN (-8.0f)
|
||||
#endif
|
||||
#ifndef CURVATURE_X
|
||||
#define CURVATURE_X (0.031f)
|
||||
#endif
|
||||
#ifndef CURVATURE_Y
|
||||
#define CURVATURE_Y (0.041f)
|
||||
#endif
|
||||
#ifndef MASK_DARK
|
||||
#define MASK_DARK (0.5f)
|
||||
#endif
|
||||
#ifndef MASK_LIGHT
|
||||
#define MASK_LIGHT (1.5f)
|
||||
#endif
|
||||
#ifndef SHADOW_MASK
|
||||
#define SHADOW_MASK 2
|
||||
#endif
|
||||
#ifndef BRIGHTNESS_BOOST
|
||||
#define BRIGHTNESS_BOOST (1.0f)
|
||||
#endif
|
||||
#ifndef HARD_BLOOM_SCAN
|
||||
#define HARD_BLOOM_SCAN (-2.0f)
|
||||
#endif
|
||||
#ifndef BLOOM_AMOUNT
|
||||
#define BLOOM_AMOUNT (1.0f / 16.0f)
|
||||
#endif
|
||||
#ifndef SHAPE
|
||||
#define SHAPE (2.0f)
|
||||
#endif
|
||||
|
||||
// ── sRGB ↔ linear ─────────────────────────────────────────────────────────────
|
||||
|
||||
static float3 linearize_rgb(float3 c)
|
||||
{
|
||||
const float k0 = 0.05958483740687370300f;
|
||||
const float k1 = 0.87031054496765136718f;
|
||||
c = max(c, 0.0f);
|
||||
c = k1 * pow(c + (float3)(k0), (float3)(2.4f));
|
||||
return c;
|
||||
}
|
||||
|
||||
static float3 delinearize_rgb(float3 c)
|
||||
{
|
||||
const float k0 = 0.05958483740687370300f;
|
||||
const float k1 = 1.14901518821716308593f;
|
||||
c = max(c, 0.0f);
|
||||
c = pow(k1 * c, (float3)(1.0f / 2.4f)) - (float3)(k0);
|
||||
return c;
|
||||
}
|
||||
|
||||
// ── NV12 fetch helper ─────────────────────────────────────────────────────────
|
||||
// Reads Y + CbCr from NV12 planes and returns linearised RGB.
|
||||
// Both planes use the same normalised coordinates [0,1]; the UV plane is
|
||||
// half-resolution but the sampler maps the same normalised position to the
|
||||
// corresponding chroma sample automatically.
|
||||
|
||||
static float3 fetch_linear_nv12(
|
||||
__read_only image2d_t y_plane,
|
||||
__read_only image2d_t uv_plane,
|
||||
sampler_t smp,
|
||||
float2 pos,
|
||||
float2 off_texels,
|
||||
float2 src_size)
|
||||
{
|
||||
float2 p = pos + off_texels / src_size;
|
||||
float y = BRIGHTNESS_BOOST * read_imagef(y_plane, smp, p).x;
|
||||
float2 cbcr = read_imagef(uv_plane, smp, p).xy - 0.5f; // centre Cb/Cr
|
||||
|
||||
// BT.709 full-range YCbCr → RGB
|
||||
float r = clamp(y + 1.5748f * cbcr.y, 0.0f, 1.0f);
|
||||
float g = clamp(y - 0.1873f * cbcr.x - 0.4681f * cbcr.y, 0.0f, 1.0f);
|
||||
float b = clamp(y + 1.8556f * cbcr.x, 0.0f, 1.0f);
|
||||
return linearize_rgb((float3)(r, g, b));
|
||||
}
|
||||
|
||||
// ── Gaussian kernel ───────────────────────────────────────────────────────────
|
||||
|
||||
static float gauss1d(float pos, float scale)
|
||||
{
|
||||
return exp2(scale * pow(fabs(pos), SHAPE));
|
||||
}
|
||||
|
||||
static float2 distance_to_texel(float2 pos, float2 src_size)
|
||||
{
|
||||
return -1.0f * crt_fract(pos * src_size - 0.5f);
|
||||
}
|
||||
|
||||
// ── Horizontal reconstruction (3 / 5 / 7 tap) ────────────────────────────────
|
||||
|
||||
static float3 horz3(
|
||||
__read_only image2d_t y_plane,
|
||||
__read_only image2d_t uv_plane,
|
||||
sampler_t smp,
|
||||
float2 pos, float off_y, float scale, float2 src_size)
|
||||
{
|
||||
float3 c = fetch_linear_nv12(y_plane, uv_plane, smp, pos, (float2)(-1.0f, off_y), src_size);
|
||||
float3 d = fetch_linear_nv12(y_plane, uv_plane, smp, pos, (float2)( 0.0f, off_y), src_size);
|
||||
float3 e = fetch_linear_nv12(y_plane, uv_plane, smp, pos, (float2)( 1.0f, off_y), src_size);
|
||||
float dst = distance_to_texel(pos, src_size).x;
|
||||
float wc = gauss1d(dst - 1.0f, scale);
|
||||
float wd = gauss1d(dst, scale);
|
||||
float we = gauss1d(dst + 1.0f, scale);
|
||||
return (c * wc + d * wd + e * we) / (wc + wd + we);
|
||||
}
|
||||
|
||||
static float3 horz5(
|
||||
__read_only image2d_t y_plane,
|
||||
__read_only image2d_t uv_plane,
|
||||
sampler_t smp,
|
||||
float2 pos, float off_y, float scale, float2 src_size)
|
||||
{
|
||||
float3 b = fetch_linear_nv12(y_plane, uv_plane, smp, pos, (float2)(-2.0f, off_y), src_size);
|
||||
float3 c = fetch_linear_nv12(y_plane, uv_plane, smp, pos, (float2)(-1.0f, off_y), src_size);
|
||||
float3 d = fetch_linear_nv12(y_plane, uv_plane, smp, pos, (float2)( 0.0f, off_y), src_size);
|
||||
float3 e = fetch_linear_nv12(y_plane, uv_plane, smp, pos, (float2)( 1.0f, off_y), src_size);
|
||||
float3 f = fetch_linear_nv12(y_plane, uv_plane, smp, pos, (float2)( 2.0f, off_y), src_size);
|
||||
float dst = distance_to_texel(pos, src_size).x;
|
||||
float wb = gauss1d(dst - 2.0f, scale);
|
||||
float wc = gauss1d(dst - 1.0f, scale);
|
||||
float wd = gauss1d(dst, scale);
|
||||
float we = gauss1d(dst + 1.0f, scale);
|
||||
float wf = gauss1d(dst + 2.0f, scale);
|
||||
return (b * wb + c * wc + d * wd + e * we + f * wf) / (wb + wc + wd + we + wf);
|
||||
}
|
||||
|
||||
static float3 horz7(
|
||||
__read_only image2d_t y_plane,
|
||||
__read_only image2d_t uv_plane,
|
||||
sampler_t smp,
|
||||
float2 pos, float off_y, float scale, float2 src_size)
|
||||
{
|
||||
float3 a = fetch_linear_nv12(y_plane, uv_plane, smp, pos, (float2)(-3.0f, off_y), src_size);
|
||||
float3 b = fetch_linear_nv12(y_plane, uv_plane, smp, pos, (float2)(-2.0f, off_y), src_size);
|
||||
float3 c = fetch_linear_nv12(y_plane, uv_plane, smp, pos, (float2)(-1.0f, off_y), src_size);
|
||||
float3 d = fetch_linear_nv12(y_plane, uv_plane, smp, pos, (float2)( 0.0f, off_y), src_size);
|
||||
float3 e = fetch_linear_nv12(y_plane, uv_plane, smp, pos, (float2)( 1.0f, off_y), src_size);
|
||||
float3 f = fetch_linear_nv12(y_plane, uv_plane, smp, pos, (float2)( 2.0f, off_y), src_size);
|
||||
float3 g = fetch_linear_nv12(y_plane, uv_plane, smp, pos, (float2)( 3.0f, off_y), src_size);
|
||||
float dst = distance_to_texel(pos, src_size).x;
|
||||
float wa = gauss1d(dst - 3.0f, scale);
|
||||
float wb = gauss1d(dst - 2.0f, scale);
|
||||
float wc = gauss1d(dst - 1.0f, scale);
|
||||
float wd = gauss1d(dst, scale);
|
||||
float we = gauss1d(dst + 1.0f, scale);
|
||||
float wf = gauss1d(dst + 2.0f, scale);
|
||||
float wg = gauss1d(dst + 3.0f, scale);
|
||||
return (a * wa + b * wb + c * wc + d * wd + e * we + f * wf + g * wg)
|
||||
/ (wa + wb + wc + wd + we + wf + wg);
|
||||
}
|
||||
|
||||
// ── Screen curvature ──────────────────────────────────────────────────────────
|
||||
|
||||
static float2 bend_screen(float2 pos)
|
||||
{
|
||||
pos = pos * 2.0f - 1.0f;
|
||||
pos *= (float2)(1.0f + (pos.y * pos.y) * CURVATURE_X,
|
||||
1.0f + (pos.x * pos.x) * CURVATURE_Y);
|
||||
return pos * 0.5f + 0.5f;
|
||||
}
|
||||
|
||||
// ── Scanline weights ──────────────────────────────────────────────────────────
|
||||
|
||||
static float scan_weight(float2 pos, float off, float2 src_size)
|
||||
{
|
||||
float dst = distance_to_texel(pos, src_size).y;
|
||||
return gauss1d(dst + off, HARD_SCAN);
|
||||
}
|
||||
|
||||
static float bloom_scan_weight(float2 pos, float off, float2 src_size)
|
||||
{
|
||||
float dst = distance_to_texel(pos, src_size).y;
|
||||
return gauss1d(dst + off, HARD_BLOOM_SCAN);
|
||||
}
|
||||
|
||||
// ── Main CRT reconstruction ───────────────────────────────────────────────────
|
||||
|
||||
static float3 tri(
|
||||
__read_only image2d_t y_plane,
|
||||
__read_only image2d_t uv_plane,
|
||||
sampler_t smp,
|
||||
float2 pos, float2 src_size)
|
||||
{
|
||||
float3 a = horz3(y_plane, uv_plane, smp, pos, -1.0f, -10.0f, src_size);
|
||||
float3 b = horz5(y_plane, uv_plane, smp, pos, 0.0f, -10.0f, src_size);
|
||||
float3 c = horz3(y_plane, uv_plane, smp, pos, 1.0f, -10.0f, src_size);
|
||||
float wa = scan_weight(pos, -1.0f, src_size);
|
||||
float wb = scan_weight(pos, 0.0f, src_size);
|
||||
float wc = scan_weight(pos, 1.0f, src_size);
|
||||
return a * wa + b * wb + c * wc;
|
||||
}
|
||||
|
||||
static float3 bloom(
|
||||
__read_only image2d_t y_plane,
|
||||
__read_only image2d_t uv_plane,
|
||||
sampler_t smp,
|
||||
float2 pos, float2 src_size)
|
||||
{
|
||||
float3 a = horz5(y_plane, uv_plane, smp, pos, -2.0f, -3.0f, src_size);
|
||||
float3 b = horz7(y_plane, uv_plane, smp, pos, -1.0f, -1.5f, src_size);
|
||||
float3 c = horz7(y_plane, uv_plane, smp, pos, 0.0f, -1.5f, src_size);
|
||||
float3 d = horz7(y_plane, uv_plane, smp, pos, 1.0f, -1.5f, src_size);
|
||||
float3 e = horz5(y_plane, uv_plane, smp, pos, 2.0f, -3.0f, src_size);
|
||||
float wa = bloom_scan_weight(pos, -2.0f, src_size);
|
||||
float wb = bloom_scan_weight(pos, -1.0f, src_size);
|
||||
float wc = bloom_scan_weight(pos, 0.0f, src_size);
|
||||
float wd = bloom_scan_weight(pos, 1.0f, src_size);
|
||||
float we = bloom_scan_weight(pos, 2.0f, src_size);
|
||||
return a * wa + b * wb + c * wc + d * wd + e * we;
|
||||
}
|
||||
|
||||
// ── Shadow mask ───────────────────────────────────────────────────────────────
|
||||
|
||||
static float3 apply_mask(float2 px)
|
||||
{
|
||||
float3 m = (float3)(MASK_DARK);
|
||||
|
||||
#if SHADOW_MASK == 1
|
||||
float line = MASK_LIGHT;
|
||||
float odd = (crt_fract(px.x / 6.0f) < 0.5f) ? 1.0f : 0.0f;
|
||||
if (crt_fract((px.y + odd) / 2.0f) < 0.5f) line = MASK_DARK;
|
||||
float mx = crt_fract(px.x / 3.0f);
|
||||
if (mx < 1.0f / 3.0f) m.x = MASK_LIGHT;
|
||||
else if (mx < 2.0f / 3.0f) m.y = MASK_LIGHT;
|
||||
else m.z = MASK_LIGHT;
|
||||
m *= line;
|
||||
|
||||
#elif SHADOW_MASK == 2
|
||||
float mx2 = crt_fract(px.x / 3.0f);
|
||||
if (mx2 < 1.0f / 3.0f) m.x = MASK_LIGHT;
|
||||
else if (mx2 < 2.0f / 3.0f) m.y = MASK_LIGHT;
|
||||
else m.z = MASK_LIGHT;
|
||||
|
||||
#elif SHADOW_MASK == 3
|
||||
px.x += px.y * 3.0f;
|
||||
float mx3 = crt_fract(px.x / 6.0f);
|
||||
if (mx3 < 1.0f / 3.0f) m.x = MASK_LIGHT;
|
||||
else if (mx3 < 2.0f / 3.0f) m.y = MASK_LIGHT;
|
||||
else m.z = MASK_LIGHT;
|
||||
|
||||
#elif SHADOW_MASK == 4
|
||||
px = floor(px * (float2)(1.0f, 0.5f));
|
||||
px.x += px.y * 3.0f;
|
||||
float mx4 = crt_fract(px.x / 6.0f);
|
||||
if (mx4 < 1.0f / 3.0f) m.x = MASK_LIGHT;
|
||||
else if (mx4 < 2.0f / 3.0f) m.y = MASK_LIGHT;
|
||||
else m.z = MASK_LIGHT;
|
||||
#endif
|
||||
|
||||
return m;
|
||||
}
|
||||
|
||||
// ── Entry point ───────────────────────────────────────────────────────────────
|
||||
// NV12: FFmpeg program_opencl passes planes in order, so for 2-plane NV12:
|
||||
// arg 0 = src_y (input Y, R channel, full resolution)
|
||||
// arg 1 = src_uv (input UV, RG channels, half resolution)
|
||||
// arg 2 = dst_y (output Y, full resolution)
|
||||
// arg 3 = dst_uv (output UV, half resolution)
|
||||
// Global work size is set to dst_y dimensions (full resolution).
|
||||
|
||||
__kernel void crt_lottes(
|
||||
__read_only image2d_t src_y,
|
||||
__read_only image2d_t src_uv,
|
||||
__write_only image2d_t dst_y,
|
||||
__write_only image2d_t dst_uv)
|
||||
{
|
||||
int2 coord = (int2)(get_global_id(0), get_global_id(1));
|
||||
|
||||
const int dst_w = get_image_width(dst_y);
|
||||
const int dst_h = get_image_height(dst_y);
|
||||
|
||||
if (coord.x >= dst_w || coord.y >= dst_h)
|
||||
return;
|
||||
|
||||
const int src_w = get_image_width(src_y);
|
||||
const int src_h = get_image_height(src_y);
|
||||
|
||||
const float2 dst_size = (float2)(dst_w, dst_h);
|
||||
const float2 src_size = (float2)(src_w, src_h);
|
||||
|
||||
// Normalised position in output space
|
||||
const float2 out_pos = ((float2)(coord.x, coord.y) + 0.5f) / dst_size;
|
||||
|
||||
// Sampler: normalised coords, linear filter, clamp-to-edge
|
||||
const sampler_t smp =
|
||||
CLK_NORMALIZED_COORDS_TRUE |
|
||||
CLK_ADDRESS_CLAMP_TO_EDGE |
|
||||
CLK_FILTER_LINEAR;
|
||||
|
||||
// Apply CRT barrel-curvature
|
||||
float2 bent = bend_screen(out_pos);
|
||||
|
||||
// Main scanline reconstruction + bloom
|
||||
float3 color = tri(src_y, src_uv, smp, bent, src_size);
|
||||
color += bloom(src_y, src_uv, smp, bent, src_size) * BLOOM_AMOUNT;
|
||||
|
||||
// Shadow mask
|
||||
#if SHADOW_MASK != 0
|
||||
float2 px = floor(out_pos * dst_size) + 0.5f;
|
||||
color *= apply_mask(px);
|
||||
#endif
|
||||
|
||||
// Black outside the curved screen border
|
||||
int in_bounds = (bent.x >= 0.0f && bent.x <= 1.0f &&
|
||||
bent.y >= 0.0f && bent.y <= 1.0f) ? 1 : 0;
|
||||
|
||||
float3 rgb = in_bounds ? delinearize_rgb(color) : (float3)(0.0f);
|
||||
|
||||
// BT.709 full-range RGB → YCbCr
|
||||
float y_out = 0.2126f * rgb.x + 0.7152f * rgb.y + 0.0722f * rgb.z;
|
||||
float cb_out = -0.1146f * rgb.x - 0.3854f * rgb.y + 0.5000f * rgb.z + 0.5f;
|
||||
float cr_out = 0.5000f * rgb.x - 0.4542f * rgb.y - 0.0458f * rgb.z + 0.5f;
|
||||
|
||||
// Write Y at full resolution
|
||||
write_imagef(dst_y, coord, (float4)(y_out, 0.0f, 0.0f, 1.0f));
|
||||
|
||||
// Write UV at half resolution (one thread per 2x2 Y block, no races)
|
||||
if ((coord.x & 1) == 0 && (coord.y & 1) == 0) {
|
||||
write_imagef(dst_uv, coord >> 1, (float4)(cb_out, cr_out, 0.0f, 1.0f));
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Common</PackageId>
|
||||
<VersionPrefix>10.11.4</VersionPrefix>
|
||||
<VersionPrefix>10.11.6</VersionPrefix>
|
||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -1620,12 +1620,17 @@ namespace MediaBrowser.Controller.Entities
|
||||
return isAllowed;
|
||||
}
|
||||
|
||||
if (maxAllowedSubRating is not null)
|
||||
if (!maxAllowedRating.HasValue)
|
||||
{
|
||||
return (ratingScore.SubScore ?? 0) <= maxAllowedSubRating && ratingScore.Score <= maxAllowedRating.Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
return !maxAllowedRating.HasValue || ratingScore.Score <= maxAllowedRating.Value;
|
||||
if (ratingScore.Score != maxAllowedRating.Value)
|
||||
{
|
||||
return ratingScore.Score < maxAllowedRating.Value;
|
||||
}
|
||||
|
||||
return !maxAllowedSubRating.HasValue || (ratingScore.SubScore ?? 0) <= maxAllowedSubRating.Value;
|
||||
}
|
||||
|
||||
public ParentalRatingScore GetParentalRatingScore()
|
||||
@@ -2048,6 +2053,9 @@ namespace MediaBrowser.Controller.Entities
|
||||
public virtual async Task UpdateToRepositoryAsync(ItemUpdateType updateReason, CancellationToken cancellationToken)
|
||||
=> await LibraryManager.UpdateItemAsync(this, GetParent(), updateReason, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
public async Task ReattachUserDataAsync(CancellationToken cancellationToken) =>
|
||||
await LibraryManager.ReattachUserDataAsync(this, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
/// <summary>
|
||||
/// Validates that images within the item are still on the filesystem.
|
||||
/// </summary>
|
||||
|
||||
@@ -1406,13 +1406,6 @@ 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)
|
||||
{
|
||||
|
||||
@@ -125,6 +125,8 @@ namespace MediaBrowser.Controller.Entities
|
||||
|
||||
public string? Name { get; set; }
|
||||
|
||||
public bool? UseRawName { get; set; }
|
||||
|
||||
public string? Person { get; set; }
|
||||
|
||||
public Guid[] PersonIds { get; set; }
|
||||
|
||||
@@ -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,6 +136,12 @@ 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);
|
||||
|
||||
@@ -214,7 +214,7 @@ namespace MediaBrowser.Controller.Entities.TV
|
||||
query.AncestorWithPresentationUniqueKey = null;
|
||||
query.SeriesPresentationUniqueKey = seriesKey;
|
||||
query.IncludeItemTypes = new[] { BaseItemKind.Season };
|
||||
query.OrderBy = new[] { (ItemSortBy.IndexNumber, SortOrder.Ascending) };
|
||||
query.OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) };
|
||||
|
||||
if (user is not null && !user.DisplayMissingEpisodes)
|
||||
{
|
||||
@@ -247,6 +247,10 @@ 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)
|
||||
{
|
||||
|
||||
@@ -281,6 +281,14 @@ namespace MediaBrowser.Controller.Library
|
||||
/// <returns>Returns a Task that can be awaited.</returns>
|
||||
Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Reattaches the user data to the item.
|
||||
/// </summary>
|
||||
/// <param name="item">The item.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous reattachment operation.</returns>
|
||||
Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the item.
|
||||
/// </summary>
|
||||
@@ -652,5 +660,12 @@ namespace MediaBrowser.Controller.Library
|
||||
/// This exists so plugins can trigger a library scan.
|
||||
/// </remarks>
|
||||
void QueueLibraryScan();
|
||||
|
||||
/// <summary>
|
||||
/// Add mblink file for a media path.
|
||||
/// </summary>
|
||||
/// <param name="virtualFolderPath">The path to the virtualfolder.</param>
|
||||
/// <param name="pathInfo">The new virtualfolder.</param>
|
||||
public void CreateShortcut(string virtualFolderPath, MediaPathInfo pathInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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;
|
||||
@@ -29,7 +30,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
|
||||
/// </summary>
|
||||
private readonly Lock _taskLock = new();
|
||||
|
||||
private readonly BlockingCollection<TaskQueueItem> _tasks = new();
|
||||
private readonly Channel<TaskQueueItem> _tasks = Channel.CreateUnbounded<TaskQueueItem>();
|
||||
|
||||
private volatile int _workCounter;
|
||||
private Task? _cleanupTask;
|
||||
@@ -77,7 +78,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
|
||||
|
||||
lock (_taskLock)
|
||||
{
|
||||
if (_tasks.Count > 0 || _workCounter > 0)
|
||||
if (_tasks.Reader.Count > 0 || _workCounter > 0)
|
||||
{
|
||||
_logger.LogDebug("Delay cleanup task, operations still running.");
|
||||
// tasks are still there so its still in use. Reschedule cleanup task.
|
||||
@@ -144,9 +145,9 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
|
||||
_deadlockDetector.Value = stopToken.TaskStop;
|
||||
try
|
||||
{
|
||||
foreach (var item in _tasks.GetConsumingEnumerable(stopToken.GlobalStop.Token))
|
||||
while (!stopToken.GlobalStop.Token.IsCancellationRequested)
|
||||
{
|
||||
stopToken.GlobalStop.Token.ThrowIfCancellationRequested();
|
||||
var item = await _tasks.Reader.ReadAsync(stopToken.GlobalStop.Token).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var newWorkerLimit = Interlocked.Increment(ref _workCounter) > 0;
|
||||
@@ -242,7 +243,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
|
||||
};
|
||||
}).ToArray();
|
||||
|
||||
if (ShouldForceSequentialOperation())
|
||||
if (ShouldForceSequentialOperation() || _deadlockDetector.Value is not null)
|
||||
{
|
||||
_logger.LogDebug("Process sequentially.");
|
||||
try
|
||||
@@ -264,35 +265,14 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
|
||||
for (var i = 0; i < workItems.Length; i++)
|
||||
{
|
||||
var item = workItems[i]!;
|
||||
_tasks.Add(item, CancellationToken.None);
|
||||
await _tasks.Writer.WriteAsync(item, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
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/>
|
||||
@@ -304,13 +284,12 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
_tasks.CompleteAdding();
|
||||
_tasks.Writer.Complete();
|
||||
foreach (var item in _taskRunners)
|
||||
{
|
||||
await item.Key.CancelAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_tasks.Dispose();
|
||||
if (_cleanupTask is not null)
|
||||
{
|
||||
await _cleanupTask.ConfigureAwait(false);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Controller</PackageId>
|
||||
<VersionPrefix>10.11.4</VersionPrefix>
|
||||
<VersionPrefix>10.11.6</VersionPrefix>
|
||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -26,6 +26,7 @@ using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.MediaInfo;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using IConfigurationManager = MediaBrowser.Common.Configuration.IConfigurationManager;
|
||||
|
||||
namespace MediaBrowser.Controller.MediaEncoding
|
||||
@@ -157,13 +158,16 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
"wavpack"
|
||||
];
|
||||
|
||||
private readonly ILogger<EncodingHelper> _logger;
|
||||
|
||||
public EncodingHelper(
|
||||
IApplicationPaths appPaths,
|
||||
IMediaEncoder mediaEncoder,
|
||||
ISubtitleEncoder subtitleEncoder,
|
||||
IConfiguration config,
|
||||
IConfigurationManager configurationManager,
|
||||
IPathManager pathManager)
|
||||
IPathManager pathManager,
|
||||
ILogger<EncodingHelper> logger)
|
||||
{
|
||||
_appPaths = appPaths;
|
||||
_mediaEncoder = mediaEncoder;
|
||||
@@ -171,6 +175,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
_config = config;
|
||||
_configurationManager = configurationManager;
|
||||
_pathManager = pathManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private enum DynamicHdrMetadataRemovalPlan
|
||||
@@ -3708,6 +3713,87 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the CRT-Lottes shader is requested via streamOptions and
|
||||
/// the compiled kernel file exists next to the server binary.
|
||||
/// </summary>
|
||||
public bool IsCrtShaderEnabled(EncodingJobInfo state)
|
||||
{
|
||||
var hasOption = state.BaseRequest.StreamOptions.TryGetValue("crtShader", out var val);
|
||||
var optionTrue = hasOption && string.Equals(val, "true", StringComparison.OrdinalIgnoreCase);
|
||||
var shaderPath = Path.Combine(AppContext.BaseDirectory, "Resources", "Shaders", "crt_lottes.cl");
|
||||
var fileExists = File.Exists(shaderPath);
|
||||
var hasOcl = _mediaEncoder.SupportsHwaccel("opencl");
|
||||
var hasProgramOcl = _mediaEncoder.SupportsFilter("program_opencl");
|
||||
var hasScaleOcl = _mediaEncoder.SupportsFilter("scale_opencl");
|
||||
|
||||
_logger.LogInformation(
|
||||
"CRT shader check: hasOption={HasOption} val={Val} fileExists={FileExists} path={Path} hasOcl={HasOcl} hasProgramOcl={HasProgramOcl} hasScaleOcl={HasScaleOcl}",
|
||||
hasOption, val, fileExists, shaderPath, hasOcl, hasProgramOcl, hasScaleOcl);
|
||||
|
||||
return optionTrue && fileExists && hasOcl && hasProgramOcl && hasScaleOcl;
|
||||
}
|
||||
|
||||
private string GetCrtEscapedShaderPath()
|
||||
{
|
||||
return Path.Combine(AppContext.BaseDirectory, "Resources", "Shaders", "crt_lottes.cl")
|
||||
.Replace("\\", "/", StringComparison.Ordinal)
|
||||
.Replace(":", "\\:", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private string GetCrtBuildOpts(EncodingJobInfo state)
|
||||
{
|
||||
state.BaseRequest.StreamOptions.TryGetValue("crtShadowMask", out var maskVal);
|
||||
var shadowMask = int.TryParse(maskVal, out var m) && m >= 0 && m <= 4 ? m : 2;
|
||||
return FormattableString.Invariant($"-DSHADOW_MASK={shadowMask}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns three OpenCL-native filters (scale_opencl → program_opencl → scale_opencl)
|
||||
/// that apply the CRT shader while the frame stays in GPU VRAM.
|
||||
/// Use this when the pipeline is already in an OpenCL hardware context.
|
||||
/// Returns an empty list when the shader is disabled.
|
||||
/// </summary>
|
||||
public List<string> GetCrtShaderOclFilters(EncodingJobInfo state)
|
||||
{
|
||||
if (!IsCrtShaderEnabled(state))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var escapedPath = GetCrtEscapedShaderPath();
|
||||
var buildOpts = GetCrtBuildOpts(state);
|
||||
|
||||
// No scale_opencl format conversion needed: the shader reads and writes
|
||||
// NV12 planes directly (src_y, src_uv, dst_y, dst_uv).
|
||||
return
|
||||
[
|
||||
FormattableString.Invariant(
|
||||
$"program_opencl=source={escapedPath}:kernel=crt_lottes")
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the FFmpeg filter chain that applies the CRT-Lottes OpenCL shader
|
||||
/// for the software (CPU-memory) pipeline path.
|
||||
/// Includes hwupload/hwdownload around the OpenCL kernel.
|
||||
/// Returns an empty string when the shader is disabled.
|
||||
/// </summary>
|
||||
public string GetCrtShaderFilter(EncodingJobInfo state)
|
||||
{
|
||||
if (!IsCrtShaderEnabled(state))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var escapedPath = GetCrtEscapedShaderPath();
|
||||
var buildOpts = GetCrtBuildOpts(state);
|
||||
|
||||
// Shader works with NV12 planes directly; no format conversion needed.
|
||||
return FormattableString.Invariant(
|
||||
$"hwupload=derive_device=opencl,program_opencl=source={escapedPath}:kernel=crt_lottes,hwdownload,format=nv12");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the parameter of software filter chain.
|
||||
/// </summary>
|
||||
@@ -3829,6 +3915,14 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
overlayFilters.Add("overlay=eof_action=pass:repeatlast=0");
|
||||
}
|
||||
|
||||
// CRT-Lottes OpenCL post-processing (applied after subtitle burn-in,
|
||||
// only when explicitly requested via streamOptions[crtShader]=true).
|
||||
var crtFilter = GetCrtShaderFilter(state);
|
||||
if (!string.IsNullOrEmpty(crtFilter))
|
||||
{
|
||||
mainFilters.Add(crtFilter);
|
||||
}
|
||||
|
||||
return (mainFilters, subFilters, overlayFilters);
|
||||
}
|
||||
|
||||
@@ -4815,6 +4909,12 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
{
|
||||
if (doOclTonemap)
|
||||
{
|
||||
// CRT shader while already in OpenCL memory (after tonemap)
|
||||
if (IsCrtShaderEnabled(state))
|
||||
{
|
||||
mainFilters.AddRange(GetCrtShaderOclFilters(state));
|
||||
}
|
||||
|
||||
// OUTPUT qsv(nv12) surface(vram)
|
||||
// reverse-mapping via qsv(vaapi)-opencl interop.
|
||||
// add extra pool size to avoid the 'cannot allocate memory' error on hevc_qsv.
|
||||
@@ -4823,8 +4923,45 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
}
|
||||
else if (isVaapiDecoder)
|
||||
{
|
||||
mainFilters.Add("hwmap=derive_device=qsv");
|
||||
mainFilters.Add("format=qsv");
|
||||
if (IsCrtShaderEnabled(state))
|
||||
{
|
||||
// VAAPI → CPU → OpenCL (writable) → CRT (NV12) → CPU → QSV.
|
||||
// program_opencl inherits the hw frames context from its input link.
|
||||
// If that context was created via hwmap:mode=read, the output images are
|
||||
// CL_MEM_READ_ONLY and kernel writes are silently discarded (black output).
|
||||
// Solution: hwdownload+hwupload=derive_device=opencl creates a fresh
|
||||
// writable OpenCL context, matching Jellyfin's iHD doOclTonemap pattern.
|
||||
mainFilters.Add("hwdownload");
|
||||
mainFilters.Add("format=nv12");
|
||||
mainFilters.Add("hwupload=derive_device=opencl");
|
||||
mainFilters.AddRange(GetCrtShaderOclFilters(state));
|
||||
mainFilters.Add("hwdownload");
|
||||
mainFilters.Add("format=nv12");
|
||||
mainFilters.Add("hwupload");
|
||||
mainFilters.Add("format=qsv");
|
||||
}
|
||||
else
|
||||
{
|
||||
// VAAPI → QSV (zero-copy, shared libva surface)
|
||||
mainFilters.Add("hwmap=derive_device=qsv");
|
||||
mainFilters.Add("format=qsv");
|
||||
}
|
||||
}
|
||||
else if (isQsvDecoder)
|
||||
{
|
||||
if (IsCrtShaderEnabled(state))
|
||||
{
|
||||
// QSV → CPU → OpenCL (writable) → CRT (NV12) → CPU → QSV.
|
||||
// Same reason as isVaapiDecoder: avoid read-only output pool.
|
||||
mainFilters.Add("hwdownload");
|
||||
mainFilters.Add("format=nv12");
|
||||
mainFilters.Add("hwupload=derive_device=opencl");
|
||||
mainFilters.AddRange(GetCrtShaderOclFilters(state));
|
||||
mainFilters.Add("hwdownload");
|
||||
mainFilters.Add("format=nv12");
|
||||
mainFilters.Add("hwupload");
|
||||
mainFilters.Add("format=qsv");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5084,6 +5221,10 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
{
|
||||
var tonemapFilter = GetHwTonemapFilter(options, "opencl", "nv12", isMjpegEncoder);
|
||||
mainFilters.Add(tonemapFilter);
|
||||
|
||||
// CRT shader inline — frame is already in OpenCL NV12, zero PCIe transfer.
|
||||
// scale_opencl converts NV12→RGBA for the kernel, then back to NV12.
|
||||
mainFilters.AddRange(GetCrtShaderOclFilters(state));
|
||||
}
|
||||
|
||||
if (doOclTonemap && isVaInVaOut)
|
||||
@@ -5093,6 +5234,15 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
mainFilters.Add("hwmap=derive_device=vaapi:mode=write:reverse=1");
|
||||
mainFilters.Add("format=vaapi");
|
||||
}
|
||||
else if (!doOclTonemap && isVaInVaOut && IsCrtShaderEnabled(state))
|
||||
{
|
||||
// CRT shader for pure-VAAPI path (no OCL tonemap already done).
|
||||
// VAAPI → OpenCL (GPU-internal via hwmap, no PCIe) → CRT → back to VAAPI.
|
||||
mainFilters.Add("hwmap=derive_device=opencl:mode=read");
|
||||
mainFilters.AddRange(GetCrtShaderOclFilters(state));
|
||||
mainFilters.Add("hwmap=derive_device=vaapi:mode=write:reverse=1");
|
||||
mainFilters.Add("format=vaapi");
|
||||
}
|
||||
|
||||
var memoryOutput = false;
|
||||
var isUploadForOclTonemap = isSwDecoder && doOclTonemap;
|
||||
@@ -5103,7 +5253,19 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
|
||||
// OUTPUT nv12 surface(memory)
|
||||
// prefer hwmap to hwdownload on opencl/vaapi.
|
||||
mainFilters.Add(isHwmapNotUsable ? "hwdownload" : "hwmap=mode=read");
|
||||
if (isVaapiDecoder && isSwEncoder && !doOclTonemap && IsCrtShaderEnabled(state))
|
||||
{
|
||||
// Frame still in VAAPI (no prior OCL step). Route through OpenCL for CRT,
|
||||
// then download to CPU in one step (avoids double PCIe vs. SW-path CRT).
|
||||
mainFilters.Add("hwmap=derive_device=opencl:mode=read");
|
||||
mainFilters.AddRange(GetCrtShaderOclFilters(state));
|
||||
mainFilters.Add("hwdownload");
|
||||
}
|
||||
else
|
||||
{
|
||||
mainFilters.Add(isHwmapNotUsable ? "hwdownload" : "hwmap=mode=read");
|
||||
}
|
||||
|
||||
mainFilters.Add("format=nv12");
|
||||
}
|
||||
|
||||
@@ -5343,6 +5505,15 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
// clear the surf->meta_offset and output nv12
|
||||
mainFilters.Add("scale_vaapi=format=nv12");
|
||||
|
||||
// CRT shader via VAAPI→OpenCL→VAAPI round-trip (all in GPU VRAM).
|
||||
if (IsCrtShaderEnabled(state))
|
||||
{
|
||||
mainFilters.Add("hwmap=derive_device=opencl:mode=read");
|
||||
mainFilters.AddRange(GetCrtShaderOclFilters(state));
|
||||
mainFilters.Add("hwmap=derive_device=vaapi:mode=write:reverse=1");
|
||||
mainFilters.Add("format=vaapi");
|
||||
}
|
||||
|
||||
// hw deint
|
||||
if (doDeintH2645)
|
||||
{
|
||||
@@ -5356,7 +5527,19 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
// OUTPUT nv12 surface(memory)
|
||||
if (isSwEncoder && (doVkTonemap || isVaapiDecoder))
|
||||
{
|
||||
mainFilters.Add("hwdownload");
|
||||
if (IsCrtShaderEnabled(state) && !doVkTonemap)
|
||||
{
|
||||
// Frame in VAAPI (no Vulkan tonemap done). Route through OpenCL for CRT
|
||||
// then download to CPU in one step.
|
||||
mainFilters.Add("hwmap=derive_device=opencl:mode=read");
|
||||
mainFilters.AddRange(GetCrtShaderOclFilters(state));
|
||||
mainFilters.Add("hwdownload");
|
||||
}
|
||||
else
|
||||
{
|
||||
mainFilters.Add("hwdownload");
|
||||
}
|
||||
|
||||
mainFilters.Add("format=nv12");
|
||||
}
|
||||
|
||||
@@ -5949,28 +6132,37 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
|
||||
var isFullAfbcPipeline = isEncoderSupportAfbc && isDrmInDrmOut && !doOclTonemap;
|
||||
var swapOutputWandH = doRkVppTranspose && swapWAndH;
|
||||
var outFormat = doOclTonemap ? "p010" : (isMjpegEncoder ? "bgra" : "nv12"); // RGA only support full range in rgb fmts
|
||||
var outFormat = doOclTonemap ? "p010" : "nv12";
|
||||
var hwScaleFilter = GetHwScaleFilter("vpp", "rkrga", outFormat, swapOutputWandH, swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH);
|
||||
var doScaling = GetHwScaleFilter("vpp", "rkrga", string.Empty, swapOutputWandH, swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH);
|
||||
var doScaling = !string.IsNullOrEmpty(GetHwScaleFilter("vpp", "rkrga", string.Empty, swapOutputWandH, swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH));
|
||||
|
||||
if (!hasSubs
|
||||
|| doRkVppTranspose
|
||||
|| !isFullAfbcPipeline
|
||||
|| !string.IsNullOrEmpty(doScaling))
|
||||
|| doScaling)
|
||||
{
|
||||
var isScaleRatioSupported = IsScaleRatioSupported(inW, inH, reqW, reqH, reqMaxW, reqMaxH, 8.0f);
|
||||
|
||||
// RGA3 hardware only support (1/8 ~ 8) scaling in each blit operation,
|
||||
// but in Trickplay there's a case: (3840/320 == 12), enable 2pass for it
|
||||
if (!string.IsNullOrEmpty(doScaling)
|
||||
&& !IsScaleRatioSupported(inW, inH, reqW, reqH, reqMaxW, reqMaxH, 8.0f))
|
||||
if (doScaling && !isScaleRatioSupported)
|
||||
{
|
||||
// Vendor provided BSP kernel has an RGA driver bug that causes the output to be corrupted for P010 format.
|
||||
// Use NV15 instead of P010 to avoid the issue.
|
||||
// SDR inputs are using BGRA formats already which is not affected.
|
||||
var intermediateFormat = string.Equals(outFormat, "p010", StringComparison.OrdinalIgnoreCase) ? "nv15" : outFormat;
|
||||
var intermediateFormat = doOclTonemap ? "nv15" : (isMjpegEncoder ? "bgra" : outFormat);
|
||||
var hwScaleFilterFirstPass = $"scale_rkrga=w=iw/7.9:h=ih/7.9:format={intermediateFormat}:force_original_aspect_ratio=increase:force_divisible_by=4:afbc=1";
|
||||
mainFilters.Add(hwScaleFilterFirstPass);
|
||||
}
|
||||
|
||||
// The RKMPP MJPEG encoder on some newer chip models no longer supports RGB input.
|
||||
// Use 2pass here to enable RGA output of full-range YUV in the 2nd pass.
|
||||
if (isMjpegEncoder && !doOclTonemap && ((doScaling && isScaleRatioSupported) || !doScaling))
|
||||
{
|
||||
var hwScaleFilterFirstPass = "vpp_rkrga=format=bgra:afbc=1";
|
||||
mainFilters.Add(hwScaleFilterFirstPass);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(hwScaleFilter) && doRkVppTranspose)
|
||||
{
|
||||
hwScaleFilter += $":transpose={transposeDir}";
|
||||
@@ -6350,6 +6542,21 @@ 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),
|
||||
@@ -7030,8 +7237,8 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
|
||||
if (string.Equals(videoStream.Codec, "av1", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var accelType = GetHwaccelType(state, options, "av1", bitDepth, hwSurface);
|
||||
return accelType + ((!string.IsNullOrEmpty(accelType) && isAfbcSupported) ? " -afbc rga" : string.Empty);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7097,6 +7304,13 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
public string GetInputModifier(EncodingJobInfo state, EncodingOptions encodingOptions, string segmentContainer)
|
||||
{
|
||||
var inputModifier = string.Empty;
|
||||
|
||||
// Apply FFmpeg log level from configuration
|
||||
if (!string.IsNullOrEmpty(encodingOptions.FfmpegLogLevel))
|
||||
{
|
||||
inputModifier = $"-loglevel {encodingOptions.FfmpegLogLevel} -stats";
|
||||
}
|
||||
|
||||
var analyzeDurationArgument = string.Empty;
|
||||
|
||||
// Apply -analyzeduration as per the environment variable,
|
||||
|
||||
@@ -35,6 +35,14 @@ public interface IItemRepository
|
||||
|
||||
void SaveImages(BaseItem item);
|
||||
|
||||
/// <summary>
|
||||
/// Reattaches the user data to the item.
|
||||
/// </summary>
|
||||
/// <param name="item">The item.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous reattachment operation.</returns>
|
||||
Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the item.
|
||||
/// </summary>
|
||||
|
||||
@@ -125,6 +125,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
||||
"transpose_opencl",
|
||||
"yadif_opencl",
|
||||
"bwdif_opencl",
|
||||
"program_opencl",
|
||||
// vaapi
|
||||
"scale_vaapi",
|
||||
"deinterlace_vaapi",
|
||||
|
||||
@@ -299,9 +299,12 @@ namespace MediaBrowser.MediaEncoding.Probing
|
||||
// Handle WebM
|
||||
else if (string.Equals(splitFormat[i], "webm", 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))))
|
||||
// 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))))
|
||||
{
|
||||
splitFormat[i] = string.Empty;
|
||||
}
|
||||
@@ -853,7 +856,12 @@ namespace MediaBrowser.MediaEncoding.Probing
|
||||
}
|
||||
|
||||
// http://stackoverflow.com/questions/17353387/how-to-detect-anamorphic-video-with-ffprobe
|
||||
if (string.Equals(streamInfo.SampleAspectRatio, "1:1", StringComparison.Ordinal))
|
||||
if (string.IsNullOrEmpty(streamInfo.SampleAspectRatio)
|
||||
&& string.IsNullOrEmpty(streamInfo.DisplayAspectRatio))
|
||||
{
|
||||
stream.IsAnamorphic = false;
|
||||
}
|
||||
else if (string.Equals(streamInfo.SampleAspectRatio, "1:1", StringComparison.Ordinal))
|
||||
{
|
||||
stream.IsAnamorphic = false;
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ public class EncodingOptions
|
||||
EnableSubtitleExtraction = true;
|
||||
AllowOnDemandMetadataBasedKeyframeExtractionForExtensions = ["mkv"];
|
||||
HardwareDecodingCodecs = ["h264", "vc1"];
|
||||
FfmpegLogLevel = "error";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -295,4 +296,9 @@ public class EncodingOptions
|
||||
/// Gets or sets the file extensions on-demand metadata based keyframe extraction is enabled for.
|
||||
/// </summary>
|
||||
public string[] AllowOnDemandMetadataBasedKeyframeExtractionForExtensions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the FFmpeg log level (quiet, panic, fatal, error, warning, info, verbose, debug, trace).
|
||||
/// </summary>
|
||||
public string FfmpegLogLevel { get; set; }
|
||||
}
|
||||
|
||||
@@ -1122,7 +1122,9 @@ public class StreamInfo
|
||||
|
||||
foreach (var pair in StreamOptions)
|
||||
{
|
||||
// Strip spaces to avoid having to encode h264 profile names
|
||||
// Strip spaces to avoid having to encode h264 profile names.
|
||||
// Use plain key=value (no streamOptions[] wrapper) so ParseStreamOptions
|
||||
// on the streaming endpoint picks them up correctly.
|
||||
sb.Append('&');
|
||||
sb.Append(pair.Key);
|
||||
sb.Append('=');
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Model</PackageId>
|
||||
<VersionPrefix>10.11.4</VersionPrefix>
|
||||
<VersionPrefix>10.11.6</VersionPrefix>
|
||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -88,7 +88,15 @@ namespace MediaBrowser.Providers.Manager
|
||||
}
|
||||
}
|
||||
|
||||
singular.AddRange(item.GetImages(ImageType.Backdrop));
|
||||
foreach (var backdrop in item.GetImages(ImageType.Backdrop))
|
||||
{
|
||||
var imageInMetadataFolder = backdrop.Path.StartsWith(itemMetadataPath, StringComparison.OrdinalIgnoreCase);
|
||||
if (imageInMetadataFolder || canDeleteLocal || item.IsSaveLocalMetadataEnabled())
|
||||
{
|
||||
singular.Add(backdrop);
|
||||
}
|
||||
}
|
||||
|
||||
PruneImages(item, singular);
|
||||
|
||||
return singular.Count > 0;
|
||||
@@ -466,10 +474,36 @@ namespace MediaBrowser.Providers.Manager
|
||||
}
|
||||
}
|
||||
|
||||
if (UpdateMultiImages(item, images, ImageType.Backdrop))
|
||||
bool hasBackdrop = false;
|
||||
bool backdropStoredWithMedia = false;
|
||||
|
||||
foreach (var image in images)
|
||||
{
|
||||
changed = true;
|
||||
foundImageTypes.Add(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);
|
||||
}
|
||||
}
|
||||
|
||||
if (foundImageTypes.Count > 0)
|
||||
|
||||
@@ -153,7 +153,7 @@ namespace MediaBrowser.Providers.Manager
|
||||
|
||||
if (isFirstRefresh)
|
||||
{
|
||||
await SaveItemAsync(metadataResult, ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false);
|
||||
await SaveItemAsync(metadataResult, ItemUpdateType.MetadataImport, false, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Next run metadata providers
|
||||
@@ -247,7 +247,7 @@ namespace MediaBrowser.Providers.Manager
|
||||
}
|
||||
|
||||
// Save to database
|
||||
await SaveItemAsync(metadataResult, updateType, cancellationToken).ConfigureAwait(false);
|
||||
await SaveItemAsync(metadataResult, updateType, isFirstRefresh, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return updateType;
|
||||
@@ -275,9 +275,14 @@ namespace MediaBrowser.Providers.Manager
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task SaveItemAsync(MetadataResult<TItemType> result, ItemUpdateType reason, CancellationToken cancellationToken)
|
||||
protected async Task SaveItemAsync(MetadataResult<TItemType> result, ItemUpdateType reason, bool reattachUserData, CancellationToken cancellationToken)
|
||||
{
|
||||
await result.Item.UpdateToRepositoryAsync(reason, cancellationToken).ConfigureAwait(false);
|
||||
if (reattachUserData)
|
||||
{
|
||||
await result.Item.ReattachUserDataAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (result.Item.SupportsPeople && result.People is not null)
|
||||
{
|
||||
var baseItem = result.Item;
|
||||
|
||||
@@ -721,8 +721,6 @@ namespace MediaBrowser.Providers.Manager
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_libraryManager.CreateItem(item, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -72,7 +72,7 @@ public class PlaylistMetadataService : MetadataService<Playlist, ItemLookupInfo>
|
||||
}
|
||||
else
|
||||
{
|
||||
targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).Distinct().ToArray();
|
||||
targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).DistinctBy(i => i.Path).ToArray();
|
||||
}
|
||||
|
||||
if (replaceData || targetItem.Shares.Count == 0)
|
||||
|
||||
@@ -303,9 +303,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
|
||||
CrewMember = crewMember,
|
||||
PersonType = TmdbUtils.MapCrewToPersonType(crewMember)
|
||||
})
|
||||
.Where(entry =>
|
||||
TmdbUtils.WantedCrewKinds.Contains(entry.PersonType) ||
|
||||
TmdbUtils.WantedCrewTypes.Contains(entry.CrewMember.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase));
|
||||
.Where(entry => TmdbUtils.WantedCrewKinds.Contains(entry.PersonType));
|
||||
|
||||
if (config.HideMissingCrewMembers)
|
||||
{
|
||||
|
||||
@@ -275,9 +275,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
|
||||
CrewMember = crewMember,
|
||||
PersonType = TmdbUtils.MapCrewToPersonType(crewMember)
|
||||
})
|
||||
.Where(entry =>
|
||||
TmdbUtils.WantedCrewKinds.Contains(entry.PersonType) ||
|
||||
TmdbUtils.WantedCrewTypes.Contains(entry.CrewMember.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase));
|
||||
.Where(entry => TmdbUtils.WantedCrewKinds.Contains(entry.PersonType));
|
||||
|
||||
if (config.HideMissingCrewMembers)
|
||||
{
|
||||
|
||||
@@ -120,9 +120,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
|
||||
CrewMember = crewMember,
|
||||
PersonType = TmdbUtils.MapCrewToPersonType(crewMember)
|
||||
})
|
||||
.Where(entry =>
|
||||
TmdbUtils.WantedCrewKinds.Contains(entry.PersonType) ||
|
||||
TmdbUtils.WantedCrewTypes.Contains(entry.CrewMember.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase));
|
||||
.Where(entry => TmdbUtils.WantedCrewKinds.Contains(entry.PersonType));
|
||||
|
||||
if (config.HideMissingCrewMembers)
|
||||
{
|
||||
|
||||
@@ -367,9 +367,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
|
||||
CrewMember = crewMember,
|
||||
PersonType = TmdbUtils.MapCrewToPersonType(crewMember)
|
||||
})
|
||||
.Where(entry =>
|
||||
TmdbUtils.WantedCrewKinds.Contains(entry.PersonType) ||
|
||||
TmdbUtils.WantedCrewTypes.Contains(entry.CrewMember.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase));
|
||||
.Where(entry => TmdbUtils.WantedCrewKinds.Contains(entry.PersonType));
|
||||
|
||||
if (config.HideMissingCrewMembers)
|
||||
{
|
||||
|
||||
@@ -70,18 +70,19 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
|
||||
public static PersonKind MapCrewToPersonType(Crew crew)
|
||||
{
|
||||
if (crew.Department.Equals("production", StringComparison.OrdinalIgnoreCase)
|
||||
&& crew.Job.Contains("director", StringComparison.OrdinalIgnoreCase))
|
||||
&& crew.Job.Equals("director", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return PersonKind.Director;
|
||||
}
|
||||
|
||||
if (crew.Department.Equals("production", StringComparison.OrdinalIgnoreCase)
|
||||
&& crew.Job.Contains("producer", StringComparison.OrdinalIgnoreCase))
|
||||
&& crew.Job.Equals("producer", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return PersonKind.Producer;
|
||||
}
|
||||
|
||||
if (crew.Department.Equals("writing", StringComparison.OrdinalIgnoreCase))
|
||||
if (crew.Department.Equals("writing", StringComparison.OrdinalIgnoreCase)
|
||||
&& crew.Job.Equals("writer", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return PersonKind.Writer;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Reflection;
|
||||
|
||||
[assembly: AssemblyVersion("10.11.4")]
|
||||
[assembly: AssemblyFileVersion("10.11.4")]
|
||||
[assembly: AssemblyVersion("10.11.6")]
|
||||
[assembly: AssemblyFileVersion("10.11.6")]
|
||||
|
||||
@@ -209,39 +209,69 @@ public class SkiaEncoder : IImageEncoder
|
||||
return default;
|
||||
}
|
||||
|
||||
using var codec = SKCodec.Create(safePath, out var result);
|
||||
|
||||
switch (result)
|
||||
SKCodec? codec = null;
|
||||
bool isSafePathTemp = !string.Equals(Path.GetFullPath(safePath), Path.GetFullPath(path), StringComparison.OrdinalIgnoreCase);
|
||||
try
|
||||
{
|
||||
case SKCodecResult.Success:
|
||||
// Skia/SkiaSharp edge‑case: when the image header is parsed but the actual pixel
|
||||
// decode fails (truncated JPEG/PNG, exotic ICC/EXIF, CMYK without color‑transform, etc.)
|
||||
// `SKCodec.Create` returns a *non‑null* 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:
|
||||
codec = SKCodec.Create(safePath, out var result);
|
||||
switch (result)
|
||||
{
|
||||
var boundsInfo = SKBitmap.DecodeBounds(safePath);
|
||||
case SKCodecResult.Success:
|
||||
// Skia/SkiaSharp edge‑case: when the image header is parsed but the actual pixel
|
||||
// decode fails (truncated JPEG/PNG, exotic ICC/EXIF, CMYK without color‑transform, etc.)
|
||||
// `SKCodec.Create` returns a *non‑null* 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);
|
||||
|
||||
if (boundsInfo.Width > 0 && boundsInfo.Height > 0)
|
||||
case SKCodecResult.Unimplemented:
|
||||
_logger.LogDebug("Image format not supported: {FilePath}", path);
|
||||
return default;
|
||||
|
||||
default:
|
||||
{
|
||||
return new ImageDimensions(boundsInfo.Width, boundsInfo.Height);
|
||||
}
|
||||
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;
|
||||
_logger.LogWarning(
|
||||
"Unable to determine image dimensions for {FilePath}: {SkCodecResult}",
|
||||
path,
|
||||
result);
|
||||
|
||||
return default;
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Extensions</PackageId>
|
||||
<VersionPrefix>10.11.4</VersionPrefix>
|
||||
<VersionPrefix>10.11.6</VersionPrefix>
|
||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -153,7 +153,15 @@ namespace Jellyfin.LiveTv.TunerHosts
|
||||
var isRemote = true;
|
||||
if (Uri.TryCreate(path, UriKind.Absolute, out var uri))
|
||||
{
|
||||
isRemote = !_networkManager.IsInLocalNetwork(uri.Host);
|
||||
// For HTTP/HTTPS streams, always consider them remote unless explicitly localhost
|
||||
if (protocol == MediaProtocol.Http)
|
||||
{
|
||||
isRemote = !uri.IsLoopback;
|
||||
}
|
||||
else
|
||||
{
|
||||
isRemote = !_networkManager.IsInLocalNetwork(uri.Host);
|
||||
}
|
||||
}
|
||||
|
||||
var httpHeaders = new Dictionary<string, string>();
|
||||
|
||||
@@ -195,6 +195,18 @@ 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()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,81 @@
|
||||
using Emby.Server.Implementations.Library;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.Library;
|
||||
|
||||
public class DotIgnoreIgnoreRuleTest
|
||||
{
|
||||
[Fact]
|
||||
public void Test()
|
||||
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)
|
||||
{
|
||||
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"));
|
||||
Assert.Equal(expectedIgnored, DotIgnoreIgnoreRule.CheckIgnoreRules(path, rules, isDirectory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestNegatePattern()
|
||||
[Theory]
|
||||
[MemberData(nameof(WindowsPathNormalizationTestData))]
|
||||
public void CheckIgnoreRules_WithWindowsPaths_NormalizesBackslashes(string[] rules, string path, bool isDirectory, bool expectedIgnored)
|
||||
{
|
||||
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"));
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace Jellyfin.Server.Implementations.Tests.Library
|
||||
[InlineData("/media/movies/#recycle", true)]
|
||||
[InlineData("thumbs.db", true)]
|
||||
[InlineData(@"C:\media\movies\movie.avi", false)]
|
||||
[InlineData("/media/.hiddendir/file.mp4", false)]
|
||||
[InlineData("/media/.hiddendir/file.mp4", true)]
|
||||
[InlineData("/media/dir/.hiddenfile.mp4", true)]
|
||||
[InlineData("/media/dir/._macjunk.mp4", true)]
|
||||
[InlineData("/volume1/video/Series/@eaDir", true)]
|
||||
@@ -32,7 +32,7 @@ namespace Jellyfin.Server.Implementations.Tests.Library
|
||||
[InlineData("/media/music/Foo B.A.R", false)]
|
||||
[InlineData("/media/music/Foo B.A.R.", false)]
|
||||
[InlineData("/movies/.zfs/snapshot/AutoM-2023-09", true)]
|
||||
public void PathIgnored(string path, bool expected)
|
||||
public void PathIgnored(string path, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, IgnorePatterns.ShouldIgnore(path));
|
||||
}
|
||||
|
||||
@@ -203,6 +203,25 @@ 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")]
|
||||
|
||||
Reference in New Issue
Block a user