mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-03-04 17:26:19 +00:00
Compare commits
89 Commits
fix/ci-wor
...
renovate/p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b444d2c66a | ||
|
|
b83378d656 | ||
|
|
e378f63b70 | ||
|
|
e4500303bb | ||
|
|
2f18e6e7f6 | ||
|
|
ca0b2aa7a6 | ||
|
|
f2ed024296 | ||
|
|
b6f4ffd251 | ||
|
|
8715cb5b9e | ||
|
|
d87fe973f3 | ||
|
|
f680495ca3 | ||
|
|
d2f733f9a4 | ||
|
|
d55f082579 | ||
|
|
bc316b3dc8 | ||
|
|
e6d73ae367 | ||
|
|
2068be1221 | ||
|
|
bdfb6edfa3 | ||
|
|
25e8c6d591 | ||
|
|
c11c33e1a8 | ||
|
|
e8232d31ab | ||
|
|
b456afe00e | ||
|
|
01b3c6f902 | ||
|
|
ccf2d15d5d | ||
|
|
56a469d8c3 | ||
|
|
2dfebb51be | ||
|
|
716f4c8198 | ||
|
|
9da046abc1 | ||
|
|
01eb56f047 | ||
|
|
94dcaf2ea2 | ||
|
|
37b50fe13c | ||
|
|
6829794aa0 | ||
|
|
06a6c6e16b | ||
|
|
daf88a5ca2 | ||
|
|
b346370dfc | ||
|
|
fc6419685c | ||
|
|
04ffbe5e9a | ||
|
|
d0809ce58b | ||
|
|
a56aa2dd53 | ||
|
|
106f33227a | ||
|
|
1311f66f72 | ||
|
|
217068eeb7 | ||
|
|
44d55f7fa3 | ||
|
|
29582ed461 | ||
|
|
ca6d499680 | ||
|
|
3b69859867 | ||
|
|
c1c7de6a81 | ||
|
|
7bcbe20641 | ||
|
|
2edf23bb40 | ||
|
|
074aa7e639 | ||
|
|
a37e83d448 | ||
|
|
d8543351e2 | ||
|
|
dce91cf8c8 | ||
|
|
48e456903e | ||
|
|
2757c18312 | ||
|
|
a1117a1fbd | ||
|
|
f685a65241 | ||
|
|
e73ebc9741 | ||
|
|
18a1cd388a | ||
|
|
6fff4a7bfa | ||
|
|
5aad260767 | ||
|
|
8b5914001d | ||
|
|
5eaaad660d | ||
|
|
de36952f53 | ||
|
|
6b8400cc3d | ||
|
|
a0bf5199ba | ||
|
|
01264c10a6 | ||
|
|
558b31f386 | ||
|
|
5656df4339 | ||
|
|
fa4d51c5e6 | ||
|
|
21bb702fd3 | ||
|
|
4a494271dd | ||
|
|
1f6768178a | ||
|
|
fead4acae1 | ||
|
|
ccd042750d | ||
|
|
e4619556ba | ||
|
|
9f2dc178f5 | ||
|
|
4c751e0a86 | ||
|
|
32d8086121 | ||
|
|
7c200899d7 | ||
|
|
613d72fa26 | ||
|
|
909e2142d6 | ||
|
|
8083ab78b5 | ||
|
|
cbc0138507 | ||
|
|
7b10888c95 | ||
|
|
0a1dd56af6 | ||
|
|
acb9da6f93 | ||
|
|
79061f4635 | ||
|
|
cd9154f110 | ||
|
|
2b6febc8da |
@@ -3,7 +3,7 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "10.0.2",
|
||||
"version": "10.0.3",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
]
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
{
|
||||
"name": "Development Jellyfin Server",
|
||||
"image": "mcr.microsoft.com/devcontainers/dotnet:9.0-bookworm",
|
||||
"image": "mcr.microsoft.com/devcontainers/dotnet:10.0-noble",
|
||||
"service": "app",
|
||||
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
|
||||
// restores nuget packages, installs the dotnet workloads and installs the dev https certificate
|
||||
"postStartCommand": "sudo dotnet restore; sudo dotnet workload update; sudo dotnet dev-certs https --trust; sudo bash \"./.devcontainer/install-ffmpeg.sh\"",
|
||||
// reads the extensions list and installs them
|
||||
"postAttachCommand": "cat .vscode/extensions.json | jq -r .recommendations[] | xargs -n 1 code --install-extension",
|
||||
// The previous way of installing extensions via the vs command dont work on selfhosted devcontainers
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"ms-dotnettools.csharp",
|
||||
"editorconfig.editorconfig",
|
||||
"github.vscode-github-actions",
|
||||
"ms-dotnettools.vscode-dotnet-runtime",
|
||||
"ms-dotnettools.csdevkit",
|
||||
"alexcvzz.vscode-sqlite",
|
||||
"streetsidesoftware.code-spell-checker",
|
||||
"eamodio.gitlens",
|
||||
"redhat.vscode-xml"
|
||||
]
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/dotnet:2": {
|
||||
"version": "none",
|
||||
"dotnetRuntimeVersions": "9.0",
|
||||
"aspNetCoreRuntimeVersions": "9.0"
|
||||
"dotnetRuntimeVersions": "10.0",
|
||||
"aspNetCoreRuntimeVersions": "10.0"
|
||||
},
|
||||
"ghcr.io/devcontainers-extra/features/apt-packages:1": {
|
||||
"preserve_apt_list": false,
|
||||
|
||||
6
.github/workflows/ci-codeql-analysis.yml
vendored
6
.github/workflows/ci-codeql-analysis.yml
vendored
@@ -28,13 +28,13 @@ jobs:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
||||
uses: github/codeql-action/init@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
||||
uses: github/codeql-action/autobuild@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
||||
uses: github/codeql-action/analyze@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
|
||||
|
||||
12
.github/workflows/ci-compat.yml
vendored
12
.github/workflows/ci-compat.yml
vendored
@@ -1,6 +1,6 @@
|
||||
name: ABI Compatibility
|
||||
on:
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
|
||||
permissions: {}
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
dotnet build Jellyfin.Server -o ./out
|
||||
|
||||
- name: Upload Head
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: abi-head
|
||||
retention-days: 14
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
dotnet build Jellyfin.Server -o ./out
|
||||
|
||||
- name: Upload Head
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: abi-base
|
||||
retention-days: 14
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
pull-requests: write # to create or update comment (peter-evans/create-or-update-comment)
|
||||
|
||||
name: ABI - Difference
|
||||
if: ${{ github.event_name == 'pull_request_target' }}
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- abi-head
|
||||
@@ -85,13 +85,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download abi-head
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
with:
|
||||
name: abi-head
|
||||
path: abi-head
|
||||
|
||||
- name: Download abi-base
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
with:
|
||||
name: abi-base
|
||||
path: abi-base
|
||||
|
||||
18
.github/workflows/ci-openapi.yml
vendored
18
.github/workflows/ci-openapi.yml
vendored
@@ -5,7 +5,7 @@ on:
|
||||
- master
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
|
||||
permissions: {}
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
|
||||
|
||||
- name: Upload openapi.json
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: openapi-head
|
||||
retention-days: 14
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
|
||||
|
||||
- name: Upload openapi.json
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: openapi-base
|
||||
retention-days: 14
|
||||
@@ -78,20 +78,20 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
name: OpenAPI - Difference
|
||||
if: ${{ github.event_name == 'pull_request_target' }}
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- openapi-head
|
||||
- openapi-base
|
||||
steps:
|
||||
- name: Download openapi-head
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
with:
|
||||
name: openapi-head
|
||||
path: openapi-head
|
||||
|
||||
- name: Download openapi-base
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
with:
|
||||
name: openapi-base
|
||||
path: openapi-base
|
||||
@@ -109,7 +109,7 @@ jobs:
|
||||
|
||||
publish-unstable:
|
||||
name: OpenAPI - Publish Unstable Spec
|
||||
if: ${{ github.event_name != 'pull_request_target' && !startsWith(github.ref, 'refs/tags/v') && contains(github.repository_owner, 'jellyfin') }}
|
||||
if: ${{ github.event_name != 'pull_request' && !startsWith(github.ref, 'refs/tags/v') && contains(github.repository_owner, 'jellyfin') }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- openapi-head
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
run: |-
|
||||
echo "JELLYFIN_VERSION=$(date +'%Y%m%d%H%M%S')" >> $GITHUB_ENV
|
||||
- name: Download openapi-head
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
with:
|
||||
name: openapi-head
|
||||
path: openapi-head
|
||||
@@ -180,7 +180,7 @@ jobs:
|
||||
run: |-
|
||||
echo "JELLYFIN_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
|
||||
- name: Download openapi-head
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
with:
|
||||
name: openapi-head
|
||||
path: openapi-head
|
||||
|
||||
2
.github/workflows/ci-tests.yml
vendored
2
.github/workflows/ci-tests.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
--verbosity minimal
|
||||
|
||||
- name: Merge code coverage results
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@ee0ae774f6d3afedcbd1683c1ab21b83670bdf8e # v5.5.1
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@2a7030e9775aab6c78e80cb66843051acdacee3e # v5.5.2
|
||||
with:
|
||||
reports: "**/coverage.cobertura.xml"
|
||||
targetdir: "merged/"
|
||||
|
||||
2
.github/workflows/commands.yml
vendored
2
.github/workflows/commands.yml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
types:
|
||||
- created
|
||||
- edited
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
types:
|
||||
- labeled
|
||||
- synchronize
|
||||
|
||||
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@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
ascending: true
|
||||
|
||||
2
.github/workflows/project-automation.yml
vendored
2
.github/workflows/project-automation.yml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
issue_comment:
|
||||
|
||||
permissions: {}
|
||||
|
||||
4
.github/workflows/pull-request-conflict.yml
vendored
4
.github/workflows/pull-request-conflict.yml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
issue_comment:
|
||||
|
||||
permissions: {}
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
steps:
|
||||
- name: Apply label
|
||||
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
|
||||
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request_target'}}
|
||||
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request'}}
|
||||
with:
|
||||
dirtyLabel: 'merge conflict'
|
||||
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
||||
|
||||
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@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
ascending: true
|
||||
|
||||
@@ -287,3 +287,4 @@
|
||||
- [Martin Reuter](https://github.com/reuterma24)
|
||||
- [Michael McElroy](https://github.com/mcmcelro)
|
||||
- [Soumyadip Auddy](https://github.com/SoumyadipAuddy)
|
||||
- [DerMaddis](https://github.com/dermaddis)
|
||||
|
||||
@@ -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="8.0.0" />
|
||||
<PackageVersion Include="AsyncKeyedLock" Version="8.0.2" />
|
||||
<PackageVersion Include="AutoFixture.AutoMoq" Version="4.18.1" />
|
||||
<PackageVersion Include="AutoFixture.Xunit2" Version="4.18.1" />
|
||||
<PackageVersion Include="AutoFixture" Version="4.18.1" />
|
||||
@@ -13,7 +13,7 @@
|
||||
<PackageVersion Include="BlurHashSharp.SkiaSharp" Version="1.4.0-pre.1" />
|
||||
<PackageVersion Include="BlurHashSharp" Version="1.4.0-pre.1" />
|
||||
<PackageVersion Include="CommandLineParser" Version="2.9.1" />
|
||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageVersion Include="coverlet.collector" Version="8.0.0" />
|
||||
<PackageVersion Include="Diacritics" Version="4.1.4" />
|
||||
<PackageVersion Include="DiscUtils.Udf" Version="0.16.13" />
|
||||
<PackageVersion Include="DotNet.Glob" Version="3.1.3" />
|
||||
@@ -26,28 +26,28 @@
|
||||
<PackageVersion Include="libse" Version="4.0.12" />
|
||||
<PackageVersion Include="LrcParser" Version="2025.623.0" />
|
||||
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="8.0.1" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.3" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.3" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.0.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.0.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.3" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.3" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.3" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.3" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.3" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.3" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.3" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.3" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.3" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.3" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.3" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.3" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.3" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.3" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
|
||||
<PackageVersion Include="MimeTypes" Version="2.5.2" />
|
||||
<PackageVersion Include="Morestachio" Version="5.0.1.631" />
|
||||
<PackageVersion Include="Moq" Version="4.18.4" />
|
||||
@@ -57,7 +57,7 @@
|
||||
<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.5" />
|
||||
<PackageVersion Include="Polly" Version="8.6.6" />
|
||||
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
|
||||
<PackageVersion Include="Serilog.Expressions" Version="5.0.0" />
|
||||
@@ -74,13 +74,13 @@
|
||||
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="[3.116.1]" />
|
||||
<PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" />
|
||||
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
|
||||
<PackageVersion Include="Svg.Skia" Version="3.2.1" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="6.9.0" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="7.3.2" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.2" />
|
||||
<PackageVersion Include="Svg.Skia" Version="3.4.1" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.1.4" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.1.4" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.3" />
|
||||
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
|
||||
<PackageVersion Include="z440.atl.core" Version="7.10.0" />
|
||||
<PackageVersion Include="TMDbLib" Version="2.3.0" />
|
||||
<PackageVersion Include="z440.atl.core" Version="7.11.0" />
|
||||
<PackageVersion Include="TMDbLib" Version="3.0.0" />
|
||||
<PackageVersion Include="UTF.Unknown" Version="2.6.0" />
|
||||
<PackageVersion Include="Xunit.Priority" Version="1.1.6" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
|
||||
@@ -225,6 +225,7 @@ namespace Emby.Naming.Common
|
||||
".afc",
|
||||
".amf",
|
||||
".aif",
|
||||
".aifc",
|
||||
".aiff",
|
||||
".alac",
|
||||
".amr",
|
||||
|
||||
@@ -18,7 +18,7 @@ public static class TvParserHelpers
|
||||
/// <param name="status">The status string.</param>
|
||||
/// <param name="enumValue">The <see cref="SeriesStatus"/>.</param>
|
||||
/// <returns>Returns true if parsing was successful.</returns>
|
||||
public static bool TryParseSeriesStatus(string status, out SeriesStatus? enumValue)
|
||||
public static bool TryParseSeriesStatus(string? status, out SeriesStatus? enumValue)
|
||||
{
|
||||
if (Enum.TryParse(status, true, out SeriesStatus seriesStatus))
|
||||
{
|
||||
|
||||
@@ -267,22 +267,24 @@ namespace Emby.Server.Implementations.Images
|
||||
{
|
||||
var image = item.GetImageInfo(type, 0);
|
||||
|
||||
if (image is not null)
|
||||
if (image is null)
|
||||
{
|
||||
if (!image.IsLocalFile)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return GetItemsWithImages(item).Count is not 0;
|
||||
}
|
||||
|
||||
if (!FileSystem.ContainsSubPath(item.GetInternalMetadataPath(), image.Path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!image.IsLocalFile)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!HasChangedByDate(item, image))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!FileSystem.ContainsSubPath(item.GetInternalMetadataPath(), image.Path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!HasChangedByDate(item, image))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -40,7 +40,7 @@ namespace Emby.Server.Implementations.Images
|
||||
includeItemTypes = new[] { BaseItemKind.Series };
|
||||
break;
|
||||
case CollectionType.music:
|
||||
includeItemTypes = new[] { BaseItemKind.MusicAlbum };
|
||||
includeItemTypes = new[] { BaseItemKind.MusicArtist }; // Music albums usually don't have dedicated backdrops, so use artist instead
|
||||
break;
|
||||
case CollectionType.musicvideos:
|
||||
includeItemTypes = new[] { BaseItemKind.MusicVideo };
|
||||
|
||||
@@ -37,15 +37,25 @@ namespace Emby.Server.Implementations.Library
|
||||
while (attributeIndex > -1 && attributeIndex < maxIndex)
|
||||
{
|
||||
var attributeEnd = attributeIndex + attribute.Length;
|
||||
if (attributeIndex > 0
|
||||
&& str[attributeIndex - 1] == '['
|
||||
&& (str[attributeEnd] == '=' || str[attributeEnd] == '-'))
|
||||
if (attributeIndex > 0)
|
||||
{
|
||||
var closingIndex = str[attributeEnd..].IndexOf(']');
|
||||
// Must be at least 1 character before the closing bracket.
|
||||
if (closingIndex > 1)
|
||||
var attributeOpener = str[attributeIndex - 1];
|
||||
var attributeCloser = attributeOpener switch
|
||||
{
|
||||
return str[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim().ToString();
|
||||
'[' => ']',
|
||||
'(' => ')',
|
||||
'{' => '}',
|
||||
_ => '\0'
|
||||
};
|
||||
if (attributeCloser != '\0' && (str[attributeEnd] == '=' || str[attributeEnd] == '-'))
|
||||
{
|
||||
var closingIndex = str[attributeEnd..].IndexOf(attributeCloser);
|
||||
|
||||
// Must be at least 1 character before the closing bracket.
|
||||
if (closingIndex > 1)
|
||||
{
|
||||
return str[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim().ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"Playlists": "Плэй-лісты",
|
||||
"Latest": "Апошняе",
|
||||
"LabelIpAddressValue": "IP-адрас: {0}",
|
||||
"ItemAddedWithName": "{0} даданы ў бібліятэку",
|
||||
"ItemAddedWithName": "{0} дададзены ў бібліятэку",
|
||||
"MessageApplicationUpdated": "Сервер Jellyfin абноўлены",
|
||||
"NotificationOptionApplicationUpdateInstalled": "Абнаўленне праграмы ўсталявана",
|
||||
"PluginInstalledWithName": "{0} быў усталяваны",
|
||||
@@ -14,7 +14,7 @@
|
||||
"Channels": "Каналы",
|
||||
"ChapterNameValue": "Раздзел {0}",
|
||||
"Collections": "Калекцыі",
|
||||
"Default": "Па змаўчанні",
|
||||
"Default": "Прадвызначана",
|
||||
"FailedLoginAttemptWithUserName": "Няўдалая спроба ўваходу з {0}",
|
||||
"Folders": "Папкі",
|
||||
"Favorites": "Абранае",
|
||||
@@ -81,8 +81,8 @@
|
||||
"NotificationOptionInstallationFailed": "Збой усталёўкі",
|
||||
"NewVersionIsAvailable": "Новая версія сервера Jellyfin даступная для cпампоўкі.",
|
||||
"NotificationOptionCameraImageUploaded": "Выява камеры запампавана",
|
||||
"NotificationOptionAudioPlaybackStopped": "Прайграванне аўдыё спынена",
|
||||
"NotificationOptionAudioPlayback": "Прайграванне аўдыё пачалося",
|
||||
"NotificationOptionAudioPlaybackStopped": "Прайграванне аўдыя спынена",
|
||||
"NotificationOptionAudioPlayback": "Прайграванне аўдыя пачалося",
|
||||
"NotificationOptionNewLibraryContent": "Дададзены новы кантэнт",
|
||||
"NotificationOptionPluginError": "Збой плагіна",
|
||||
"NotificationOptionPluginUninstalled": "Плагін выдалены",
|
||||
@@ -123,10 +123,10 @@
|
||||
"TaskCleanTranscodeDescription": "Выдаляе перакадзіраваныя файлы, старэйшыя за адзін дзень.",
|
||||
"TaskRefreshChannels": "Абнавіць каналы",
|
||||
"TaskDownloadMissingSubtitles": "Спампаваць адсутныя субцітры",
|
||||
"TaskKeyframeExtractorDescription": "Выдае ключавыя кадры з відэафайлаў для стварэння больш дакладных плэй-лістоў HLS. Гэта задача можа працягнуцца шмат часу.",
|
||||
"TaskKeyframeExtractorDescription": "Выдае ключавыя кадры з відэафайлаў для стварэння больш дакладных плэй-лістоў HLS. Гэта задача можа выконвацца доўга.",
|
||||
"TaskRefreshTrickplayImages": "Стварыць выявы Trickplay",
|
||||
"TaskRefreshTrickplayImagesDescription": "Стварае перадпрагляды відэаролікаў для Trickplay у падключаных бібліятэках.",
|
||||
"TaskCleanCollectionsAndPlaylists": "Ачысціце калекцыі і плэй-лісты",
|
||||
"TaskCleanCollectionsAndPlaylists": "Ачысціць калекцыі і плэй-лісты",
|
||||
"TaskCleanCollectionsAndPlaylistsDescription": "Выдаляе элементы з калекцый і плэй-лістоў, якія больш не існуюць.",
|
||||
"TaskAudioNormalizationDescription": "Скануе файлы на прадмет нармалізацыі гуку.",
|
||||
"TaskAudioNormalization": "Нармалізацыя гуку",
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
"TaskCleanLogsDescription": "Esborra els registres que tinguin més de {0} dies.",
|
||||
"TaskCleanLogs": "Neteja dels registres",
|
||||
"TaskRefreshLibraryDescription": "Escaneja les mediateques, a la cerca de fitxers nous i refresca les metadades.",
|
||||
"TaskRefreshLibrary": "Escaneig de les mediateques",
|
||||
"TaskRefreshLibrary": "Escaneja la mediateca",
|
||||
"TaskRefreshChapterImagesDescription": "Creació de les miniatures dels vídeos que tinguin capítols.",
|
||||
"TaskRefreshChapterImages": "Extracció de les imatges dels capítols",
|
||||
"TaskCleanCacheDescription": "Eliminació de la memòria cau no necessària per al servidor.",
|
||||
|
||||
@@ -1 +1,8 @@
|
||||
{}
|
||||
{
|
||||
"Books": "ספרים",
|
||||
"NameSeasonNumber": "עונה {0}",
|
||||
"Channels": "ערוצים",
|
||||
"Movies": "סרטים",
|
||||
"Music": "מוזיקה",
|
||||
"Collections": "אוספים"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"CameraImageUploadedFrom": "Nova fotografija sa kamere je učitana iz {0}",
|
||||
"Channels": "Kanali",
|
||||
"ChapterNameValue": "Poglavlje {0}",
|
||||
"Collections": "Kolekcije",
|
||||
"Collections": "Zbirke",
|
||||
"DeviceOfflineWithName": "{0} je prekinuo vezu",
|
||||
"DeviceOnlineWithName": "{0} je povezan",
|
||||
"FailedLoginAttemptWithUserName": "Neuspješan pokušaj prijave od {0}",
|
||||
@@ -70,7 +70,7 @@
|
||||
"ScheduledTaskFailedWithName": "{0} neuspjelo",
|
||||
"ScheduledTaskStartedWithName": "{0} pokrenuto",
|
||||
"ServerNameNeedsToBeRestarted": "{0} treba ponovno pokrenuti",
|
||||
"Shows": "Serije",
|
||||
"Shows": "Emisije",
|
||||
"Songs": "Pjesme",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin server se učitava. Pokušajte ponovo uskoro.",
|
||||
"SubtitleDownloadFailureFromForItem": "Prijevod nije uspješno preuzet od {0} za {1}",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"AppDeviceValues": "App: {0}, Dispositivo: {1}",
|
||||
"Application": "Applicazione",
|
||||
"Artists": "Artisti",
|
||||
"AuthenticationSucceededWithUserName": "{0} autenticato con successo",
|
||||
"AuthenticationSucceededWithUserName": "{0} autenticato correttamente",
|
||||
"Books": "Libri",
|
||||
"CameraImageUploadedFrom": "È stata caricata una nuova fotografia da {0}",
|
||||
"Channels": "Canali",
|
||||
@@ -11,36 +11,36 @@
|
||||
"Collections": "Collezioni",
|
||||
"DeviceOfflineWithName": "{0} si è disconnesso",
|
||||
"DeviceOnlineWithName": "{0} è connesso",
|
||||
"FailedLoginAttemptWithUserName": "Tentativo di accesso fallito da {0}",
|
||||
"FailedLoginAttemptWithUserName": "Tentativo di accesso non riuscito da {0}",
|
||||
"Favorites": "Preferiti",
|
||||
"Folders": "Cartelle",
|
||||
"Genres": "Generi",
|
||||
"HeaderAlbumArtists": "Artisti dell'album",
|
||||
"HeaderContinueWatching": "Continua a guardare",
|
||||
"HeaderFavoriteAlbums": "Album Preferiti",
|
||||
"HeaderFavoriteArtists": "Artisti Preferiti",
|
||||
"HeaderFavoriteEpisodes": "Episodi Preferiti",
|
||||
"HeaderFavoriteShows": "Serie TV Preferite",
|
||||
"HeaderFavoriteSongs": "Brani Preferiti",
|
||||
"HeaderFavoriteAlbums": "Album preferiti",
|
||||
"HeaderFavoriteArtists": "Artisti preferiti",
|
||||
"HeaderFavoriteEpisodes": "Episodi preferiti",
|
||||
"HeaderFavoriteShows": "Serie TV preferite",
|
||||
"HeaderFavoriteSongs": "Brani preferiti",
|
||||
"HeaderLiveTV": "Diretta TV",
|
||||
"HeaderNextUp": "Prossimo",
|
||||
"HeaderRecordingGroups": "Gruppi di Registrazione",
|
||||
"HomeVideos": "Video Personali",
|
||||
"HeaderRecordingGroups": "Gruppi di registrazione",
|
||||
"HomeVideos": "Video personali",
|
||||
"Inherit": "Eredita",
|
||||
"ItemAddedWithName": "{0} è stato aggiunto alla libreria",
|
||||
"ItemRemovedWithName": "{0} è stato rimosso dalla libreria",
|
||||
"LabelIpAddressValue": "Indirizzo IP: {0}",
|
||||
"LabelRunningTimeValue": "Durata: {0}",
|
||||
"Latest": "Novità",
|
||||
"MessageApplicationUpdated": "Il Server Jellyfin è stato aggiornato",
|
||||
"MessageApplicationUpdated": "Jellyfin Server è stato aggiornato",
|
||||
"MessageApplicationUpdatedTo": "Jellyfin Server è stato aggiornato a {0}",
|
||||
"MessageNamedServerConfigurationUpdatedWithValue": "La sezione {0} della configurazione server è stata aggiornata",
|
||||
"MessageServerConfigurationUpdated": "La configurazione del server è stata aggiornata",
|
||||
"MixedContent": "Contenuto misto",
|
||||
"Movies": "Film",
|
||||
"Music": "Musica",
|
||||
"MusicVideos": "Video Musicali",
|
||||
"NameInstallFailed": "{0} installazione fallita",
|
||||
"MusicVideos": "Video musicali",
|
||||
"NameInstallFailed": "{0} installazione non riuscita",
|
||||
"NameSeasonNumber": "Stagione {0}",
|
||||
"NameSeasonUnknown": "Stagione sconosciuta",
|
||||
"NewVersionIsAvailable": "Una nuova versione di Jellyfin Server è disponibile per il download.",
|
||||
@@ -49,37 +49,37 @@
|
||||
"NotificationOptionAudioPlayback": "La riproduzione audio è iniziata",
|
||||
"NotificationOptionAudioPlaybackStopped": "La riproduzione audio è stata interrotta",
|
||||
"NotificationOptionCameraImageUploaded": "Immagine fotocamera caricata",
|
||||
"NotificationOptionInstallationFailed": "Installazione fallita",
|
||||
"NotificationOptionInstallationFailed": "Installazione non riuscita",
|
||||
"NotificationOptionNewLibraryContent": "Nuovo contenuto aggiunto",
|
||||
"NotificationOptionPluginError": "Errore del plugin",
|
||||
"NotificationOptionPluginInstalled": "Plugin installato",
|
||||
"NotificationOptionPluginUninstalled": "Plugin disinstallato",
|
||||
"NotificationOptionPluginUpdateInstalled": "Aggiornamento plugin installato",
|
||||
"NotificationOptionServerRestartRequired": "Riavvio del server necessario",
|
||||
"NotificationOptionTaskFailed": "Operazione pianificata fallita",
|
||||
"NotificationOptionTaskFailed": "Operazione pianificata non riuscita",
|
||||
"NotificationOptionUserLockedOut": "Utente bloccato",
|
||||
"NotificationOptionVideoPlayback": "Riproduzione video iniziata",
|
||||
"NotificationOptionVideoPlaybackStopped": "Riproduzione video interrotta",
|
||||
"Photos": "Foto",
|
||||
"Playlists": "Playlist",
|
||||
"Playlists": "Scalette",
|
||||
"Plugin": "Plugin",
|
||||
"PluginInstalledWithName": "{0} è stato Installato",
|
||||
"PluginInstalledWithName": "{0} è stato installato",
|
||||
"PluginUninstalledWithName": "{0} è stato disinstallato",
|
||||
"PluginUpdatedWithName": "{0} è stato aggiornato",
|
||||
"ProviderValue": "Provider: {0}",
|
||||
"ScheduledTaskFailedWithName": "{0} fallito",
|
||||
"ScheduledTaskFailedWithName": "{0} non riuscito",
|
||||
"ScheduledTaskStartedWithName": "{0} avviato",
|
||||
"ServerNameNeedsToBeRestarted": "{0} deve essere riavviato",
|
||||
"Shows": "Serie TV",
|
||||
"Songs": "Brani",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin server si sta avviando. Per favore riprova più tardi.",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin Server si sta avviando. Riprova più tardi.",
|
||||
"SubtitleDownloadFailureFromForItem": "Impossibile scaricare i sottotitoli da {0} per {1}",
|
||||
"Sync": "Sincronizza",
|
||||
"System": "Sistema",
|
||||
"TvShows": "Serie TV",
|
||||
"User": "Utente",
|
||||
"UserCreatedWithName": "L'utente {0} è stato creato",
|
||||
"UserDeletedWithName": "L'utente {0} è stato rimosso",
|
||||
"UserDeletedWithName": "L'utente {0} è stato eliminato",
|
||||
"UserDownloadingItemWithValues": "{0} sta scaricando {1}",
|
||||
"UserLockedOutWithName": "L'utente {0} è stato bloccato",
|
||||
"UserOfflineFromDevice": "{0} si è disconnesso da {1}",
|
||||
@@ -114,20 +114,20 @@
|
||||
"TasksLibraryCategory": "Libreria",
|
||||
"TasksMaintenanceCategory": "Manutenzione",
|
||||
"TaskCleanActivityLog": "Attività di Registro Completate",
|
||||
"TaskCleanActivityLogDescription": "Elimina le voci del registro delle attività più vecchie dell’età configurata.",
|
||||
"Undefined": "Non Definito",
|
||||
"TaskCleanActivityLogDescription": "Elimina le voci del registro delle attività più vecchie dell'età configurata.",
|
||||
"Undefined": "Non specificato",
|
||||
"Forced": "Forzato",
|
||||
"Default": "Predefinito",
|
||||
"TaskOptimizeDatabaseDescription": "Compatta database e tronca spazi liberi. Eseguire questa azione dopo la scansione o dopo aver fatto altre modifiche inerenti il database potrebbe aumentarne le prestazioni.",
|
||||
"TaskOptimizeDatabase": "Ottimizza database",
|
||||
"TaskKeyframeExtractor": "Estrattore di Keyframe",
|
||||
"TaskKeyframeExtractorDescription": "Estrae i keyframe dai video per creare migliori playlist HLS. Questa procedura potrebbe richiedere molto tempo.",
|
||||
"TaskKeyframeExtractorDescription": "Estrae i keyframe dai video per creare migliori scalette HLS. Questa procedura potrebbe richiedere molto tempo.",
|
||||
"External": "Esterno",
|
||||
"HearingImpaired": "Non Udenti",
|
||||
"HearingImpaired": "Non udenti",
|
||||
"TaskRefreshTrickplayImages": "Genera immagini Trickplay",
|
||||
"TaskRefreshTrickplayImagesDescription": "Crea anteprime trickplay per i video nelle librerie abilitate.",
|
||||
"TaskCleanCollectionsAndPlaylists": "Ripulire le collezioni e le playlist",
|
||||
"TaskCleanCollectionsAndPlaylistsDescription": "Rimuove gli elementi dalle collezioni e dalle playlist che non esistono più.",
|
||||
"TaskCleanCollectionsAndPlaylists": "Ripulisci le collezioni e le scalette",
|
||||
"TaskCleanCollectionsAndPlaylistsDescription": "Rimuove gli elementi dalle collezioni e dalle scalette che non esistono più.",
|
||||
"TaskAudioNormalization": "Normalizzazione dell'audio",
|
||||
"TaskAudioNormalizationDescription": "Scansiona i file alla ricerca dei dati per la normalizzazione dell'audio.",
|
||||
"TaskDownloadMissingLyricsDescription": "Scarica testi per le canzoni",
|
||||
|
||||
@@ -43,32 +43,32 @@
|
||||
"NameInstallFailed": "{0}のインストールに失敗しました",
|
||||
"NameSeasonNumber": "シーズン {0}",
|
||||
"NameSeasonUnknown": "シーズン不明",
|
||||
"NewVersionIsAvailable": "新しいバージョンの Jellyfin Server がダウンロード可能です。",
|
||||
"NewVersionIsAvailable": "新しいバージョンの Jellyfin Server がダウンロードできます。",
|
||||
"NotificationOptionApplicationUpdateAvailable": "アプリケーションの更新があります",
|
||||
"NotificationOptionApplicationUpdateInstalled": "アプリケーションは最新です",
|
||||
"NotificationOptionAudioPlayback": "オーディオの再生を開始",
|
||||
"NotificationOptionAudioPlaybackStopped": "オーディオの再生をストップしました",
|
||||
"NotificationOptionAudioPlaybackStopped": "オーディオの再生を停止",
|
||||
"NotificationOptionCameraImageUploaded": "カメライメージがアップロードされました",
|
||||
"NotificationOptionInstallationFailed": "インストール失敗",
|
||||
"NotificationOptionNewLibraryContent": "新しいコンテンツを追加しました",
|
||||
"NotificationOptionPluginError": "プラグインに障害が発生しました",
|
||||
"NotificationOptionPluginInstalled": "プラグインがインストールされました",
|
||||
"NotificationOptionPluginUninstalled": "プラグインがアンインストールされました",
|
||||
"NotificationOptionPluginInstalled": "プラグインをインストールしました",
|
||||
"NotificationOptionPluginUninstalled": "プラグインをアンインストールしました",
|
||||
"NotificationOptionPluginUpdateInstalled": "プラグインのアップデートをインストールしました",
|
||||
"NotificationOptionServerRestartRequired": "サーバーを再起動してください",
|
||||
"NotificationOptionTaskFailed": "スケジュールされていたタスクの失敗",
|
||||
"NotificationOptionUserLockedOut": "ユーザーはロックされています",
|
||||
"NotificationOptionVideoPlayback": "ビデオの再生を開始しました",
|
||||
"NotificationOptionVideoPlaybackStopped": "ビデオを停止しました",
|
||||
"NotificationOptionVideoPlayback": "ビデオの再生を開始",
|
||||
"NotificationOptionVideoPlaybackStopped": "ビデオの再生を停止",
|
||||
"Photos": "フォト",
|
||||
"Playlists": "プレイリスト",
|
||||
"Plugin": "プラグイン",
|
||||
"PluginInstalledWithName": "{0} がインストールされました",
|
||||
"PluginUninstalledWithName": "{0} がアンインストールされました",
|
||||
"PluginUpdatedWithName": "{0} が更新されました",
|
||||
"PluginInstalledWithName": "{0} をインストールしました",
|
||||
"PluginUninstalledWithName": "{0} をアンインストールしました",
|
||||
"PluginUpdatedWithName": "{0} を更新しました",
|
||||
"ProviderValue": "プロバイダ: {0}",
|
||||
"ScheduledTaskFailedWithName": "{0} が失敗しました",
|
||||
"ScheduledTaskStartedWithName": "{0} が開始されました",
|
||||
"ScheduledTaskStartedWithName": "{0} を開始",
|
||||
"ServerNameNeedsToBeRestarted": "{0} を再起動してください",
|
||||
"Shows": "番組",
|
||||
"Songs": "曲",
|
||||
|
||||
@@ -124,8 +124,8 @@
|
||||
"TaskKeyframeExtractor": "Extrator de Quadros-chave",
|
||||
"External": "Externo",
|
||||
"HearingImpaired": "Surdo",
|
||||
"TaskRefreshTrickplayImages": "Gerar Imagens de Trickplay",
|
||||
"TaskRefreshTrickplayImagesDescription": "Cria ficheiros de trickplay para vídeos nas bibliotecas ativas.",
|
||||
"TaskRefreshTrickplayImages": "Gerar imagens de trickplay",
|
||||
"TaskRefreshTrickplayImagesDescription": "Cria pré-visualizações de trickplay para vídeos nas bibliotecas ativadas.",
|
||||
"TaskCleanCollectionsAndPlaylistsDescription": "Remove itens de coleções e listas de reprodução que já não existem.",
|
||||
"TaskCleanCollectionsAndPlaylists": "Limpar coleções e listas de reprodução",
|
||||
"TaskAudioNormalizationDescription": "Analisa os ficheiros para obter dados de normalização de áudio.",
|
||||
|
||||
@@ -124,8 +124,8 @@
|
||||
"HearingImpaired": "Problemas auditivos",
|
||||
"TaskKeyframeExtractor": "Extrator de quadro-chave",
|
||||
"TaskKeyframeExtractorDescription": "Retira frames chave do video para criar listas HLS precisas. Esta tarefa pode correr durante algum tempo.",
|
||||
"TaskRefreshTrickplayImages": "Gerar miniaturas de vídeo",
|
||||
"TaskRefreshTrickplayImagesDescription": "Cria miniaturas de vídeo para vídeos nas bibliotecas definidas.",
|
||||
"TaskRefreshTrickplayImages": "Gerar imagens de trickplay",
|
||||
"TaskRefreshTrickplayImagesDescription": "Cria pré-visualizações de trickplay para vídeos nas bibliotecas ativadas.",
|
||||
"TaskCleanCollectionsAndPlaylistsDescription": "Remove itens de coleções e listas de reprodução que já não existem.",
|
||||
"TaskCleanCollectionsAndPlaylists": "Limpar coleções e listas de reprodução",
|
||||
"TaskAudioNormalizationDescription": "Analisa os ficheiros para obter dados de normalização de áudio.",
|
||||
|
||||
@@ -198,17 +198,22 @@ namespace Emby.Server.Implementations.Playlists
|
||||
return Playlist.GetPlaylistItems(items, user, options);
|
||||
}
|
||||
|
||||
public Task AddItemToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, Guid userId)
|
||||
public Task AddItemToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, int? position, Guid userId)
|
||||
{
|
||||
var user = userId.IsEmpty() ? null : _userManager.GetUserById(userId);
|
||||
|
||||
return AddToPlaylistInternal(playlistId, itemIds, user, new DtoOptions(false)
|
||||
{
|
||||
EnableImages = true
|
||||
});
|
||||
return AddToPlaylistInternal(
|
||||
playlistId,
|
||||
itemIds,
|
||||
user,
|
||||
new DtoOptions(false)
|
||||
{
|
||||
EnableImages = true
|
||||
},
|
||||
position);
|
||||
}
|
||||
|
||||
private async Task AddToPlaylistInternal(Guid playlistId, IReadOnlyCollection<Guid> newItemIds, User user, DtoOptions options)
|
||||
private async Task AddToPlaylistInternal(Guid playlistId, IReadOnlyCollection<Guid> newItemIds, User user, DtoOptions options, int? position = null)
|
||||
{
|
||||
// Retrieve the existing playlist
|
||||
var playlist = _libraryManager.GetItemById(playlistId) as Playlist
|
||||
@@ -243,7 +248,30 @@ namespace Emby.Server.Implementations.Playlists
|
||||
}
|
||||
|
||||
// Update the playlist in the repository
|
||||
playlist.LinkedChildren = [.. playlist.LinkedChildren, .. childrenToAdd];
|
||||
if (position.HasValue)
|
||||
{
|
||||
if (position.Value <= 0)
|
||||
{
|
||||
playlist.LinkedChildren = [.. childrenToAdd, .. playlist.LinkedChildren];
|
||||
}
|
||||
else if (position.Value >= playlist.LinkedChildren.Length)
|
||||
{
|
||||
playlist.LinkedChildren = [.. playlist.LinkedChildren, .. childrenToAdd];
|
||||
}
|
||||
else
|
||||
{
|
||||
playlist.LinkedChildren = [
|
||||
.. playlist.LinkedChildren[0..position.Value],
|
||||
.. childrenToAdd,
|
||||
.. playlist.LinkedChildren[position.Value..playlist.LinkedChildren.Length]
|
||||
];
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
playlist.LinkedChildren = [.. playlist.LinkedChildren, .. childrenToAdd];
|
||||
}
|
||||
|
||||
playlist.DateLastMediaAdded = DateTime.UtcNow;
|
||||
|
||||
await UpdatePlaylistInternal(playlist).ConfigureAwait(false);
|
||||
|
||||
@@ -191,9 +191,17 @@ public class DisplayPreferencesController : BaseJellyfinApiController
|
||||
|
||||
foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("landing-", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
if (!Enum.TryParse<ViewType>(displayPreferences.CustomPrefs[key], true, out _))
|
||||
var viewType = displayPreferences.CustomPrefs[key];
|
||||
|
||||
if (string.IsNullOrEmpty(viewType))
|
||||
{
|
||||
_logger.LogError("Invalid ViewType: {LandingScreenOption}", displayPreferences.CustomPrefs[key]);
|
||||
displayPreferences.CustomPrefs.Remove(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<ViewType>(viewType, true, out _))
|
||||
{
|
||||
_logger.LogError("Invalid ViewType: {LandingScreenOption}", viewType);
|
||||
displayPreferences.CustomPrefs.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,6 +359,7 @@ public class PlaylistsController : BaseJellyfinApiController
|
||||
/// </summary>
|
||||
/// <param name="playlistId">The playlist id.</param>
|
||||
/// <param name="ids">Item id, comma delimited.</param>
|
||||
/// <param name="position">Optional. 0-based index where to place the items or at the end if <c>null</c>.</param>
|
||||
/// <param name="userId">The userId.</param>
|
||||
/// <response code="204">Items added to playlist.</response>
|
||||
/// <response code="403">Access forbidden.</response>
|
||||
@@ -371,6 +372,7 @@ public class PlaylistsController : BaseJellyfinApiController
|
||||
public async Task<ActionResult> AddItemToPlaylist(
|
||||
[FromRoute, Required] Guid playlistId,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] ids,
|
||||
[FromQuery] int? position,
|
||||
[FromQuery] Guid? userId)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
@@ -388,7 +390,7 @@ public class PlaylistsController : BaseJellyfinApiController
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
await _playlistManager.AddItemToPlaylistAsync(playlistId, ids, userId.Value).ConfigureAwait(false);
|
||||
await _playlistManager.AddItemToPlaylistAsync(playlistId, ids, position, userId.Value).ConfigureAwait(false);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
|
||||
@@ -268,7 +268,7 @@ public static class StreamingHelpers
|
||||
Dictionary<string, string?> streamOptions = new Dictionary<string, string?>();
|
||||
foreach (var param in queryString)
|
||||
{
|
||||
if (char.IsLower(param.Key[0]))
|
||||
if (param.Key.Length > 0 && char.IsLower(param.Key[0]))
|
||||
{
|
||||
// This was probably not parsed initially and should be a StreamOptions
|
||||
// or the generated URL should correctly serialize it
|
||||
|
||||
@@ -74,9 +74,10 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
|
||||
/// <inheritdoc />
|
||||
public void UpdatePeople(Guid itemId, IReadOnlyList<PersonInfo> people)
|
||||
{
|
||||
foreach (var item in people.Where(e => e.Role is null))
|
||||
foreach (var person in people)
|
||||
{
|
||||
item.Role = string.Empty;
|
||||
person.Name = person.Name.Trim();
|
||||
person.Role = person.Role?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
// multiple metadata providers can provide the _same_ person
|
||||
|
||||
@@ -3,7 +3,7 @@ using Jellyfin.Api.Middleware;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Microsoft.OpenApi;
|
||||
|
||||
namespace Jellyfin.Server.Extensions
|
||||
{
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Reflection;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json.Nodes;
|
||||
using Emby.Server.Implementations;
|
||||
using Jellyfin.Api.Auth;
|
||||
using Jellyfin.Api.Auth.AnonymousLanAccessPolicy;
|
||||
@@ -26,7 +26,6 @@ using Jellyfin.Server.Filters;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Session;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
@@ -34,9 +33,7 @@ using Microsoft.AspNetCore.Cors.Infrastructure;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.OpenApi.Any;
|
||||
using Microsoft.OpenApi.Interfaces;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Microsoft.OpenApi;
|
||||
using Swashbuckle.AspNetCore.Swagger;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
using AuthenticationSchemes = Jellyfin.Api.Constants.AuthenticationSchemes;
|
||||
@@ -208,7 +205,7 @@ namespace Jellyfin.Server.Extensions
|
||||
{
|
||||
{
|
||||
"x-jellyfin-version",
|
||||
new OpenApiString(version)
|
||||
new JsonNodeExtension(JsonValue.Create(version))
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -262,6 +259,7 @@ namespace Jellyfin.Server.Extensions
|
||||
c.OperationFilter<FileRequestFilter>();
|
||||
c.OperationFilter<ParameterObsoleteFilter>();
|
||||
c.DocumentFilter<AdditionalModelFilter>();
|
||||
c.DocumentFilter<SecuritySchemeReferenceFixupFilter>();
|
||||
})
|
||||
.Replace(ServiceDescriptor.Transient<ISwaggerProvider, CachingOpenApiProvider>());
|
||||
}
|
||||
@@ -333,10 +331,10 @@ namespace Jellyfin.Server.Extensions
|
||||
options.MapType<Dictionary<ImageType, string>>(() =>
|
||||
new OpenApiSchema
|
||||
{
|
||||
Type = "object",
|
||||
Type = JsonSchemaType.Object,
|
||||
AdditionalProperties = new OpenApiSchema
|
||||
{
|
||||
Type = "string"
|
||||
Type = JsonSchemaType.String
|
||||
}
|
||||
});
|
||||
|
||||
@@ -344,18 +342,17 @@ namespace Jellyfin.Server.Extensions
|
||||
options.MapType<Dictionary<string, string?>>(() =>
|
||||
new OpenApiSchema
|
||||
{
|
||||
Type = "object",
|
||||
Type = JsonSchemaType.Object,
|
||||
AdditionalProperties = new OpenApiSchema
|
||||
{
|
||||
Type = "string",
|
||||
Nullable = true
|
||||
Type = JsonSchemaType.String | JsonSchemaType.Null
|
||||
}
|
||||
});
|
||||
|
||||
// Swashbuckle doesn't use JsonOptions to describe responses, so we need to manually describe it.
|
||||
options.MapType<Version>(() => new OpenApiSchema
|
||||
{
|
||||
Type = "string"
|
||||
Type = JsonSchemaType.String
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,18 +3,17 @@ using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.Json.Nodes;
|
||||
using Jellyfin.Extensions;
|
||||
using Jellyfin.Server.Migrations;
|
||||
using MediaBrowser.Common.Plugins;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Net;
|
||||
using MediaBrowser.Controller.Net.WebSocketMessages;
|
||||
using MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
|
||||
using MediaBrowser.Model.ApiClient;
|
||||
using MediaBrowser.Model.Session;
|
||||
using MediaBrowser.Model.SyncPlay;
|
||||
using Microsoft.OpenApi.Any;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Microsoft.OpenApi;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
namespace Jellyfin.Server.Filters
|
||||
@@ -25,7 +24,7 @@ namespace Jellyfin.Server.Filters
|
||||
public class AdditionalModelFilter : IDocumentFilter
|
||||
{
|
||||
// Array of options that should not be visible in the api spec.
|
||||
private static readonly Type[] _ignoredConfigurations = { typeof(MigrationOptions), typeof(MediaBrowser.Model.Branding.BrandingOptions) };
|
||||
private static readonly Type[] _ignoredConfigurations = [typeof(MigrationOptions), typeof(MediaBrowser.Model.Branding.BrandingOptions)];
|
||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||
|
||||
/// <summary>
|
||||
@@ -48,8 +47,8 @@ namespace Jellyfin.Server.Filters
|
||||
&& t != typeof(WebSocketMessageInfo))
|
||||
.ToList();
|
||||
|
||||
var inboundWebSocketSchemas = new List<OpenApiSchema>();
|
||||
var inboundWebSocketDiscriminators = new Dictionary<string, string>();
|
||||
var inboundWebSocketSchemas = new List<IOpenApiSchema>();
|
||||
var inboundWebSocketDiscriminators = new Dictionary<string, OpenApiSchemaReference>();
|
||||
foreach (var type in webSocketTypes.Where(t => typeof(IInboundWebSocketMessage).IsAssignableFrom(t)))
|
||||
{
|
||||
var messageType = (SessionMessageType?)type.GetProperty(nameof(WebSocketMessage.MessageType))?.GetCustomAttribute<DefaultValueAttribute>()?.Value;
|
||||
@@ -60,18 +59,16 @@ namespace Jellyfin.Server.Filters
|
||||
|
||||
var schema = context.SchemaGenerator.GenerateSchema(type, context.SchemaRepository);
|
||||
inboundWebSocketSchemas.Add(schema);
|
||||
inboundWebSocketDiscriminators[messageType.ToString()!] = schema.Reference.ReferenceV3;
|
||||
if (schema is OpenApiSchemaReference schemaRef)
|
||||
{
|
||||
inboundWebSocketDiscriminators[messageType.ToString()!] = schemaRef;
|
||||
}
|
||||
}
|
||||
|
||||
var inboundWebSocketMessageSchema = new OpenApiSchema
|
||||
{
|
||||
Type = "object",
|
||||
Type = JsonSchemaType.Object,
|
||||
Description = "Represents the list of possible inbound websocket types",
|
||||
Reference = new OpenApiReference
|
||||
{
|
||||
Id = nameof(InboundWebSocketMessage),
|
||||
Type = ReferenceType.Schema
|
||||
},
|
||||
OneOf = inboundWebSocketSchemas,
|
||||
Discriminator = new OpenApiDiscriminator
|
||||
{
|
||||
@@ -82,8 +79,8 @@ namespace Jellyfin.Server.Filters
|
||||
|
||||
context.SchemaRepository.AddDefinition(nameof(InboundWebSocketMessage), inboundWebSocketMessageSchema);
|
||||
|
||||
var outboundWebSocketSchemas = new List<OpenApiSchema>();
|
||||
var outboundWebSocketDiscriminators = new Dictionary<string, string>();
|
||||
var outboundWebSocketSchemas = new List<IOpenApiSchema>();
|
||||
var outboundWebSocketDiscriminators = new Dictionary<string, OpenApiSchemaReference>();
|
||||
foreach (var type in webSocketTypes.Where(t => typeof(IOutboundWebSocketMessage).IsAssignableFrom(t)))
|
||||
{
|
||||
var messageType = (SessionMessageType?)type.GetProperty(nameof(WebSocketMessage.MessageType))?.GetCustomAttribute<DefaultValueAttribute>()?.Value;
|
||||
@@ -94,58 +91,55 @@ namespace Jellyfin.Server.Filters
|
||||
|
||||
var schema = context.SchemaGenerator.GenerateSchema(type, context.SchemaRepository);
|
||||
outboundWebSocketSchemas.Add(schema);
|
||||
outboundWebSocketDiscriminators.Add(messageType.ToString()!, schema.Reference.ReferenceV3);
|
||||
if (schema is OpenApiSchemaReference schemaRef)
|
||||
{
|
||||
outboundWebSocketDiscriminators.Add(messageType.ToString()!, schemaRef);
|
||||
}
|
||||
}
|
||||
|
||||
// Add custom "SyncPlayGroupUpdateMessage" schema because Swashbuckle cannot generate it for us
|
||||
var syncPlayGroupUpdateMessageSchema = new OpenApiSchema
|
||||
{
|
||||
Type = "object",
|
||||
Type = JsonSchemaType.Object,
|
||||
Description = "Untyped sync play command.",
|
||||
Properties = new Dictionary<string, OpenApiSchema>
|
||||
Properties = new Dictionary<string, IOpenApiSchema>
|
||||
{
|
||||
{
|
||||
"Data", new OpenApiSchema
|
||||
{
|
||||
AllOf =
|
||||
[
|
||||
new OpenApiSchema { Reference = new OpenApiReference { Type = ReferenceType.Schema, Id = nameof(GroupUpdate<object>) } }
|
||||
],
|
||||
AllOf = new List<IOpenApiSchema>
|
||||
{
|
||||
new OpenApiSchemaReference(nameof(GroupUpdate<object>), null, null)
|
||||
},
|
||||
Description = "Group update data",
|
||||
Nullable = false,
|
||||
}
|
||||
},
|
||||
{ "MessageId", new OpenApiSchema { Type = "string", Format = "uuid", Description = "Gets or sets the message id." } },
|
||||
{ "MessageId", new OpenApiSchema { Type = JsonSchemaType.String, Format = "uuid", Description = "Gets or sets the message id." } },
|
||||
{
|
||||
"MessageType", new OpenApiSchema
|
||||
{
|
||||
Enum = Enum.GetValues<SessionMessageType>().Select(type => new OpenApiString(type.ToString())).ToList<IOpenApiAny>(),
|
||||
AllOf =
|
||||
[
|
||||
new OpenApiSchema { Reference = new OpenApiReference { Type = ReferenceType.Schema, Id = nameof(SessionMessageType) } }
|
||||
],
|
||||
Enum = Enum.GetValues<SessionMessageType>().Select(type => (JsonNode)JsonValue.Create(type.ToString())!).ToList(),
|
||||
AllOf = new List<IOpenApiSchema>
|
||||
{
|
||||
new OpenApiSchemaReference(nameof(SessionMessageType), null, null)
|
||||
},
|
||||
Description = "The different kinds of messages that are used in the WebSocket api.",
|
||||
Default = new OpenApiString(nameof(SessionMessageType.SyncPlayGroupUpdate)),
|
||||
Default = JsonValue.Create(nameof(SessionMessageType.SyncPlayGroupUpdate)),
|
||||
ReadOnly = true
|
||||
}
|
||||
},
|
||||
},
|
||||
AdditionalPropertiesAllowed = false,
|
||||
Reference = new OpenApiReference { Type = ReferenceType.Schema, Id = "SyncPlayGroupUpdateMessage" }
|
||||
};
|
||||
context.SchemaRepository.AddDefinition("SyncPlayGroupUpdateMessage", syncPlayGroupUpdateMessageSchema);
|
||||
outboundWebSocketSchemas.Add(syncPlayGroupUpdateMessageSchema);
|
||||
outboundWebSocketDiscriminators[nameof(SessionMessageType.SyncPlayGroupUpdate)] = syncPlayGroupUpdateMessageSchema.Reference.ReferenceV3;
|
||||
var syncPlayRef = new OpenApiSchemaReference("SyncPlayGroupUpdateMessage", null, null);
|
||||
outboundWebSocketSchemas.Add(syncPlayRef);
|
||||
outboundWebSocketDiscriminators[nameof(SessionMessageType.SyncPlayGroupUpdate)] = syncPlayRef;
|
||||
|
||||
var outboundWebSocketMessageSchema = new OpenApiSchema
|
||||
{
|
||||
Type = "object",
|
||||
Type = JsonSchemaType.Object,
|
||||
Description = "Represents the list of possible outbound websocket types",
|
||||
Reference = new OpenApiReference
|
||||
{
|
||||
Id = nameof(OutboundWebSocketMessage),
|
||||
Type = ReferenceType.Schema
|
||||
},
|
||||
OneOf = outboundWebSocketSchemas,
|
||||
Discriminator = new OpenApiDiscriminator
|
||||
{
|
||||
@@ -159,17 +153,12 @@ namespace Jellyfin.Server.Filters
|
||||
nameof(WebSocketMessage),
|
||||
new OpenApiSchema
|
||||
{
|
||||
Type = "object",
|
||||
Type = JsonSchemaType.Object,
|
||||
Description = "Represents the possible websocket types",
|
||||
Reference = new OpenApiReference
|
||||
OneOf = new List<IOpenApiSchema>
|
||||
{
|
||||
Id = nameof(WebSocketMessage),
|
||||
Type = ReferenceType.Schema
|
||||
},
|
||||
OneOf = new[]
|
||||
{
|
||||
inboundWebSocketMessageSchema,
|
||||
outboundWebSocketMessageSchema
|
||||
new OpenApiSchemaReference(nameof(InboundWebSocketMessage), null, null),
|
||||
new OpenApiSchemaReference(nameof(OutboundWebSocketMessage), null, null)
|
||||
}
|
||||
});
|
||||
|
||||
@@ -180,8 +169,8 @@ namespace Jellyfin.Server.Filters
|
||||
&& t.BaseType.GetGenericTypeDefinition() == typeof(GroupUpdate<>))
|
||||
.ToList();
|
||||
|
||||
var groupUpdateSchemas = new List<OpenApiSchema>();
|
||||
var groupUpdateDiscriminators = new Dictionary<string, string>();
|
||||
var groupUpdateSchemas = new List<IOpenApiSchema>();
|
||||
var groupUpdateDiscriminators = new Dictionary<string, OpenApiSchemaReference>();
|
||||
foreach (var type in groupUpdateTypes)
|
||||
{
|
||||
var groupUpdateType = (GroupUpdateType?)type.GetProperty(nameof(GroupUpdate<object>.Type))?.GetCustomAttribute<DefaultValueAttribute>()?.Value;
|
||||
@@ -192,18 +181,16 @@ namespace Jellyfin.Server.Filters
|
||||
|
||||
var schema = context.SchemaGenerator.GenerateSchema(type, context.SchemaRepository);
|
||||
groupUpdateSchemas.Add(schema);
|
||||
groupUpdateDiscriminators[groupUpdateType.ToString()!] = schema.Reference.ReferenceV3;
|
||||
if (schema is OpenApiSchemaReference schemaRef)
|
||||
{
|
||||
groupUpdateDiscriminators[groupUpdateType.ToString()!] = schemaRef;
|
||||
}
|
||||
}
|
||||
|
||||
var groupUpdateSchema = new OpenApiSchema
|
||||
{
|
||||
Type = "object",
|
||||
Type = JsonSchemaType.Object,
|
||||
Description = "Represents the list of possible group update types",
|
||||
Reference = new OpenApiReference
|
||||
{
|
||||
Id = nameof(GroupUpdate<object>),
|
||||
Type = ReferenceType.Schema
|
||||
},
|
||||
OneOf = groupUpdateSchemas,
|
||||
Discriminator = new OpenApiDiscriminator
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Mvc.ApiExplorer;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Microsoft.OpenApi;
|
||||
using Swashbuckle.AspNetCore.Swagger;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
@@ -48,7 +48,7 @@ internal sealed class CachingOpenApiProvider : ISwaggerProvider
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public OpenApiDocument GetSwagger(string documentName, string? host = null, string? basePath = null)
|
||||
public OpenApiDocument GetSwagger(string documentName, string host, string basePath)
|
||||
{
|
||||
if (_memoryCache.TryGetValue(CacheKey, out OpenApiDocument? openApiDocument) && openApiDocument is not null)
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using Jellyfin.Api.Attributes;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Microsoft.OpenApi;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
namespace Jellyfin.Server.Filters
|
||||
@@ -28,10 +28,11 @@ namespace Jellyfin.Server.Filters
|
||||
{
|
||||
Schema = new OpenApiSchema
|
||||
{
|
||||
Type = "string",
|
||||
Type = JsonSchemaType.String,
|
||||
Format = "binary"
|
||||
}
|
||||
};
|
||||
body.Content ??= new System.Collections.Generic.Dictionary<string, OpenApiMediaType>();
|
||||
foreach (var contentType in contentTypes)
|
||||
{
|
||||
body.Content.Add(contentType, mediaType);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Jellyfin.Api.Attributes;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Microsoft.OpenApi;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
namespace Jellyfin.Server.Filters
|
||||
@@ -14,7 +14,7 @@ namespace Jellyfin.Server.Filters
|
||||
{
|
||||
Schema = new OpenApiSchema
|
||||
{
|
||||
Type = "string",
|
||||
Type = JsonSchemaType.String,
|
||||
Format = "binary"
|
||||
}
|
||||
};
|
||||
@@ -22,6 +22,11 @@ namespace Jellyfin.Server.Filters
|
||||
/// <inheritdoc />
|
||||
public void Apply(OpenApiOperation operation, OperationFilterContext context)
|
||||
{
|
||||
if (operation.Responses is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var attribute in context.ApiDescription.ActionDescriptor.EndpointMetadata)
|
||||
{
|
||||
if (attribute is ProducesFileAttribute producesFileAttribute)
|
||||
@@ -31,7 +36,7 @@ namespace Jellyfin.Server.Filters
|
||||
.FirstOrDefault(o => o.Key.Equals(SuccessCode, StringComparison.Ordinal));
|
||||
|
||||
// Operation doesn't have a response.
|
||||
if (response.Value is null)
|
||||
if (response.Value?.Content is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Microsoft.OpenApi;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
namespace Jellyfin.Server.Filters;
|
||||
@@ -15,7 +15,7 @@ namespace Jellyfin.Server.Filters;
|
||||
public class FlagsEnumSchemaFilter : ISchemaFilter
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void Apply(OpenApiSchema schema, SchemaFilterContext context)
|
||||
public void Apply(IOpenApiSchema schema, SchemaFilterContext context)
|
||||
{
|
||||
var type = context.Type.IsEnum ? context.Type : Nullable.GetUnderlyingType(context.Type);
|
||||
if (type is null || !type.IsEnum)
|
||||
@@ -29,11 +29,16 @@ public class FlagsEnumSchemaFilter : ISchemaFilter
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema is not OpenApiSchema concreteSchema)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.MemberInfo is null)
|
||||
{
|
||||
// Processing the enum definition itself - ensure it's type "string" not "integer"
|
||||
schema.Type = "string";
|
||||
schema.Format = null;
|
||||
concreteSchema.Type = JsonSchemaType.String;
|
||||
concreteSchema.Format = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -43,11 +48,11 @@ public class FlagsEnumSchemaFilter : ISchemaFilter
|
||||
|
||||
// Flags enums should be represented as arrays referencing the enum schema
|
||||
// since multiple values can be combined
|
||||
schema.Type = "array";
|
||||
schema.Format = null;
|
||||
schema.Enum = null;
|
||||
schema.AllOf = null;
|
||||
schema.Items = enumSchema;
|
||||
concreteSchema.Type = JsonSchemaType.Array;
|
||||
concreteSchema.Format = null;
|
||||
concreteSchema.Enum = null;
|
||||
concreteSchema.AllOf = null;
|
||||
concreteSchema.Items = enumSchema;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.Json.Nodes;
|
||||
using Jellyfin.Data.Attributes;
|
||||
using Microsoft.OpenApi.Any;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Microsoft.OpenApi;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
namespace Jellyfin.Server.Filters;
|
||||
@@ -15,7 +15,7 @@ namespace Jellyfin.Server.Filters;
|
||||
public class IgnoreEnumSchemaFilter : ISchemaFilter
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void Apply(OpenApiSchema schema, SchemaFilterContext context)
|
||||
public void Apply(IOpenApiSchema schema, SchemaFilterContext context)
|
||||
{
|
||||
if (context.Type.IsEnum || (Nullable.GetUnderlyingType(context.Type)?.IsEnum ?? false))
|
||||
{
|
||||
@@ -25,18 +25,23 @@ public class IgnoreEnumSchemaFilter : ISchemaFilter
|
||||
return;
|
||||
}
|
||||
|
||||
var enumOpenApiStrings = new List<IOpenApiAny>();
|
||||
if (schema is not OpenApiSchema concreteSchema)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var enumOpenApiNodes = new List<JsonNode>();
|
||||
|
||||
foreach (var enumName in Enum.GetNames(type))
|
||||
{
|
||||
var member = type.GetMember(enumName)[0];
|
||||
if (!member.GetCustomAttributes<OpenApiIgnoreEnumAttribute>().Any())
|
||||
{
|
||||
enumOpenApiStrings.Add(new OpenApiString(enumName));
|
||||
enumOpenApiNodes.Add(JsonValue.Create(enumName)!);
|
||||
}
|
||||
}
|
||||
|
||||
schema.Enum = enumOpenApiStrings;
|
||||
concreteSchema.Enum = enumOpenApiNodes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Jellyfin.Api.Attributes;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Microsoft.OpenApi;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
namespace Jellyfin.Server.Filters
|
||||
@@ -21,11 +21,17 @@ namespace Jellyfin.Server.Filters
|
||||
.OfType<ParameterObsoleteAttribute>()
|
||||
.Any())
|
||||
{
|
||||
if (operation.Parameters is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var parameter in operation.Parameters)
|
||||
{
|
||||
if (parameter.Name.Equals(parameterDescription.Name, StringComparison.Ordinal))
|
||||
if (parameter is OpenApiParameter concreteParam
|
||||
&& string.Equals(concreteParam.Name, parameterDescription.Name, StringComparison.Ordinal))
|
||||
{
|
||||
parameter.Deprecated = true;
|
||||
concreteParam.Deprecated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Microsoft.OpenApi;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
namespace Jellyfin.Server.Filters;
|
||||
@@ -8,12 +8,12 @@ internal class RetryOnTemporarilyUnavailableFilter : IOperationFilter
|
||||
{
|
||||
public void Apply(OpenApiOperation operation, OperationFilterContext context)
|
||||
{
|
||||
operation.Responses.TryAdd(
|
||||
operation.Responses?.TryAdd(
|
||||
"503",
|
||||
new OpenApiResponse
|
||||
{
|
||||
Description = "The server is currently starting or is temporarily not available.",
|
||||
Headers = new Dictionary<string, OpenApiHeader>
|
||||
Headers = new Dictionary<string, IOpenApiHeader>
|
||||
{
|
||||
{
|
||||
"Retry-After", new OpenApiHeader
|
||||
@@ -23,7 +23,7 @@ internal class RetryOnTemporarilyUnavailableFilter : IOperationFilter
|
||||
Description = "A hint for when to retry the operation in full seconds.",
|
||||
Schema = new OpenApiSchema
|
||||
{
|
||||
Type = "integer",
|
||||
Type = JsonSchemaType.Integer,
|
||||
Format = "int32"
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ internal class RetryOnTemporarilyUnavailableFilter : IOperationFilter
|
||||
Description = "A short plain-text reason why the server is not available.",
|
||||
Schema = new OpenApiSchema
|
||||
{
|
||||
Type = "string",
|
||||
Type = JsonSchemaType.String,
|
||||
Format = "text"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ using Jellyfin.Api.Auth.DefaultAuthorizationPolicy;
|
||||
using Jellyfin.Api.Constants;
|
||||
using Jellyfin.Extensions;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Microsoft.OpenApi;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
namespace Jellyfin.Server.Filters;
|
||||
@@ -66,17 +66,10 @@ public class SecurityRequirementsOperationFilter : IOperationFilter
|
||||
return;
|
||||
}
|
||||
|
||||
operation.Responses.TryAdd("401", new OpenApiResponse { Description = "Unauthorized" });
|
||||
operation.Responses.TryAdd("403", new OpenApiResponse { Description = "Forbidden" });
|
||||
operation.Responses?.TryAdd("401", new OpenApiResponse { Description = "Unauthorized" });
|
||||
operation.Responses?.TryAdd("403", new OpenApiResponse { Description = "Forbidden" });
|
||||
|
||||
var scheme = new OpenApiSecurityScheme
|
||||
{
|
||||
Reference = new OpenApiReference
|
||||
{
|
||||
Type = ReferenceType.SecurityScheme,
|
||||
Id = AuthenticationSchemes.CustomAuthentication
|
||||
},
|
||||
};
|
||||
var scheme = new OpenApiSecuritySchemeReference(AuthenticationSchemes.CustomAuthentication, null, null);
|
||||
|
||||
// Add DefaultAuthorization scope to any endpoint that has a policy with a requirement that is a subset of DefaultAuthorization.
|
||||
if (!requiredScopes.Contains(DefaultAuthPolicy.AsSpan(), StringComparison.Ordinal))
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using Microsoft.OpenApi;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
namespace Jellyfin.Server.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// Document filter that fixes security scheme references after document generation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// In Microsoft.OpenApi v2, <see cref="OpenApiSecuritySchemeReference"/> requires a resolved
|
||||
/// <c>Target</c> to serialize correctly. References created without a host document (as in
|
||||
/// operation filters) serialize as empty objects. This filter re-creates all security scheme
|
||||
/// references with the document context so they resolve properly during serialization.
|
||||
/// </remarks>
|
||||
internal class SecuritySchemeReferenceFixupFilter : IDocumentFilter
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
|
||||
{
|
||||
swaggerDoc.RegisterComponents();
|
||||
|
||||
if (swaggerDoc.Paths is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var pathItem in swaggerDoc.Paths.Values)
|
||||
{
|
||||
if (pathItem.Operations is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var operation in pathItem.Operations.Values)
|
||||
{
|
||||
if (operation.Security is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
for (int i = 0; i < operation.Security.Count; i++)
|
||||
{
|
||||
var oldReq = operation.Security[i];
|
||||
var newReq = new OpenApiSecurityRequirement();
|
||||
foreach (var kvp in oldReq)
|
||||
{
|
||||
var fixedRef = new OpenApiSecuritySchemeReference(kvp.Key.Reference.Id!, swaggerDoc);
|
||||
newReq[fixedRef] = kvp.Value;
|
||||
}
|
||||
|
||||
operation.Security[i] = newReq;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Server.ServerSetupApp;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Globalization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Server.Migrations.Routines;
|
||||
|
||||
/// <summary>
|
||||
/// Migration to fix broken library subtitle download languages.
|
||||
/// </summary>
|
||||
[JellyfinMigration("2026-02-06T20:00:00", nameof(FixLibrarySubtitleDownloadLanguages))]
|
||||
internal class FixLibrarySubtitleDownloadLanguages : IAsyncMigrationRoutine
|
||||
{
|
||||
private readonly ILocalizationManager _localizationManager;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FixLibrarySubtitleDownloadLanguages"/> class.
|
||||
/// </summary>
|
||||
/// <param name="localizationManager">The Localization manager.</param>
|
||||
/// <param name="startupLogger">The startup logger for Startup UI integration.</param>
|
||||
/// <param name="libraryManager">The Library manager.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public FixLibrarySubtitleDownloadLanguages(
|
||||
ILocalizationManager localizationManager,
|
||||
IStartupLogger<FixLibrarySubtitleDownloadLanguages> startupLogger,
|
||||
ILibraryManager libraryManager,
|
||||
ILogger<FixLibrarySubtitleDownloadLanguages> logger)
|
||||
{
|
||||
_localizationManager = localizationManager;
|
||||
_libraryManager = libraryManager;
|
||||
_logger = startupLogger.With(logger);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task PerformAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Starting to fix library subtitle download languages.");
|
||||
|
||||
var virtualFolders = _libraryManager.GetVirtualFolders(false);
|
||||
|
||||
foreach (var virtualFolder in virtualFolders)
|
||||
{
|
||||
var options = virtualFolder.LibraryOptions;
|
||||
if (options.SubtitleDownloadLanguages is null || options.SubtitleDownloadLanguages.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Some virtual folders don't have a proper item id.
|
||||
if (!Guid.TryParse(virtualFolder.ItemId, out var folderId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var collectionFolder = _libraryManager.GetItemById<CollectionFolder>(folderId);
|
||||
if (collectionFolder is null)
|
||||
{
|
||||
_logger.LogWarning("Could not find collection folder for virtual folder '{LibraryName}' with id '{FolderId}'. Skipping.", virtualFolder.Name, folderId);
|
||||
continue;
|
||||
}
|
||||
|
||||
var fixedLanguages = new List<string>();
|
||||
|
||||
foreach (var language in options.SubtitleDownloadLanguages)
|
||||
{
|
||||
var foundLanguage = _localizationManager.FindLanguageInfo(language)?.ThreeLetterISOLanguageName;
|
||||
if (foundLanguage is not null)
|
||||
{
|
||||
// Converted ISO 639-2/B to T (ger to deu)
|
||||
if (!string.Equals(foundLanguage, language, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogInformation("Converted '{Language}' to '{ResolvedLanguage}' in library '{LibraryName}'.", language, foundLanguage, virtualFolder.Name);
|
||||
}
|
||||
|
||||
if (fixedLanguages.Contains(foundLanguage, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogInformation("Language '{Language}' already exists for library '{LibraryName}'. Skipping duplicate.", foundLanguage, virtualFolder.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
fixedLanguages.Add(foundLanguage);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Could not resolve language '{Language}' in library '{LibraryName}'. Skipping.", language, virtualFolder.Name);
|
||||
}
|
||||
}
|
||||
|
||||
options.SubtitleDownloadLanguages = [.. fixedLanguages];
|
||||
collectionFolder.UpdateLibraryOptions(options);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Library subtitle download languages fixed.");
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -464,6 +464,16 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
||||
|
||||
SqliteConnection.ClearAllPools();
|
||||
|
||||
using (var checkpointConnection = new SqliteConnection($"Filename={libraryDbPath}"))
|
||||
{
|
||||
checkpointConnection.Open();
|
||||
using var cmd = checkpointConnection.CreateCommand();
|
||||
cmd.CommandText = "PRAGMA wal_checkpoint(TRUNCATE);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
SqliteConnection.ClearAllPools();
|
||||
|
||||
_logger.LogInformation("Move {0} to {1}.", libraryDbPath, libraryDbPath + ".old");
|
||||
File.Move(libraryDbPath, libraryDbPath + ".old", true);
|
||||
}
|
||||
@@ -1163,7 +1173,9 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
||||
Item = null!,
|
||||
ProviderId = e[0],
|
||||
ProviderValue = string.Join('|', e.Skip(1))
|
||||
}).ToArray();
|
||||
})
|
||||
.DistinctBy(e => e.ProviderId)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
if (reader.TryGetString(index++, out var imageInfos))
|
||||
|
||||
@@ -22,7 +22,6 @@ using MediaBrowser.Controller.Channels;
|
||||
using MediaBrowser.Controller.Chapters;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities.Audio;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using MediaBrowser.Controller.Library;
|
||||
@@ -2129,17 +2128,6 @@ namespace MediaBrowser.Controller.Entities
|
||||
};
|
||||
}
|
||||
|
||||
// Music albums usually don't have dedicated backdrops, so return one from the artist instead
|
||||
if (GetType() == typeof(MusicAlbum) && imageType == ImageType.Backdrop)
|
||||
{
|
||||
var artist = FindParent<MusicArtist>();
|
||||
|
||||
if (artist is not null)
|
||||
{
|
||||
return artist.GetImages(imageType).ElementAtOrDefault(imageIndex);
|
||||
}
|
||||
}
|
||||
|
||||
return GetImages(imageType)
|
||||
.ElementAtOrDefault(imageIndex);
|
||||
}
|
||||
|
||||
@@ -452,6 +452,7 @@ namespace MediaBrowser.Controller.Entities
|
||||
// That's all the new and changed ones - now see if any have been removed and need cleanup
|
||||
var itemsRemoved = currentChildren.Values.Except(validChildren).ToList();
|
||||
var shouldRemove = !IsRoot || allowRemoveRoot;
|
||||
var actuallyRemoved = new List<BaseItem>();
|
||||
// If it's an AggregateFolder, don't remove
|
||||
if (shouldRemove && itemsRemoved.Count > 0)
|
||||
{
|
||||
@@ -467,6 +468,7 @@ namespace MediaBrowser.Controller.Entities
|
||||
{
|
||||
Logger.LogDebug("Removed item: {Path}", item.Path);
|
||||
|
||||
actuallyRemoved.Add(item);
|
||||
item.SetParent(null);
|
||||
LibraryManager.DeleteItem(item, new DeleteOptions { DeleteFileLocation = false }, this, false);
|
||||
}
|
||||
@@ -477,6 +479,20 @@ namespace MediaBrowser.Controller.Entities
|
||||
{
|
||||
LibraryManager.CreateItems(newItems, this, cancellationToken);
|
||||
}
|
||||
|
||||
// After removing items, reattach any detached user data to remaining children
|
||||
// that share the same user data keys (eg. same episode replaced with a new file).
|
||||
if (actuallyRemoved.Count > 0)
|
||||
{
|
||||
var removedKeys = actuallyRemoved.SelectMany(i => i.GetUserDataKeys()).ToHashSet();
|
||||
foreach (var child in validChildren)
|
||||
{
|
||||
if (child.GetUserDataKeys().Any(removedKeys.Contains))
|
||||
{
|
||||
await child.ReattachUserDataAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -201,12 +201,17 @@ namespace MediaBrowser.Controller.Entities.TV
|
||||
|
||||
public List<BaseItem> GetEpisodes(Series series, User user, IEnumerable<Episode> allSeriesEpisodes, DtoOptions options, bool shouldIncludeMissingEpisodes)
|
||||
{
|
||||
if (series is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return series.GetSeasonEpisodes(this, user, allSeriesEpisodes, options, shouldIncludeMissingEpisodes);
|
||||
}
|
||||
|
||||
public List<BaseItem> GetEpisodes()
|
||||
{
|
||||
return Series.GetSeasonEpisodes(this, null, null, new DtoOptions(true), true);
|
||||
return GetEpisodes(Series, null, null, new DtoOptions(true), true);
|
||||
}
|
||||
|
||||
public override List<BaseItem> GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query)
|
||||
|
||||
@@ -451,7 +451,7 @@ namespace MediaBrowser.Controller.Entities.TV
|
||||
|
||||
if (!currentSeasonNumber.HasValue && !seasonNumber.HasValue && parentSeason.LocationType == LocationType.Virtual)
|
||||
{
|
||||
return true;
|
||||
return episodeItem.Season is null or { LocationType: LocationType.Virtual };
|
||||
}
|
||||
|
||||
var season = episodeItem.Season;
|
||||
|
||||
@@ -61,9 +61,10 @@ namespace MediaBrowser.Controller.Playlists
|
||||
/// </summary>
|
||||
/// <param name="playlistId">The playlist identifier.</param>
|
||||
/// <param name="itemIds">The item ids.</param>
|
||||
/// <param name="position">Optional. 0-based index where to place the items or at the end if null.</param>
|
||||
/// <param name="userId">The user identifier.</param>
|
||||
/// <returns>Task.</returns>
|
||||
Task AddItemToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, Guid userId);
|
||||
Task AddItemToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, int? position, Guid userId);
|
||||
|
||||
/// <summary>
|
||||
/// Removes from playlist.
|
||||
|
||||
@@ -83,6 +83,7 @@ namespace MediaBrowser.MediaEncoding.Probing
|
||||
"Smith/Kotzen",
|
||||
"We;Na",
|
||||
"LSR/CITY",
|
||||
"Kairon; IRSE!",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
@@ -862,7 +863,7 @@ namespace MediaBrowser.MediaEncoding.Probing
|
||||
{
|
||||
stream.IsAnamorphic = false;
|
||||
}
|
||||
else if (string.Equals(streamInfo.SampleAspectRatio, "1:1", StringComparison.Ordinal))
|
||||
else if (IsNearSquarePixelSar(streamInfo.SampleAspectRatio))
|
||||
{
|
||||
stream.IsAnamorphic = false;
|
||||
}
|
||||
@@ -1153,6 +1154,34 @@ namespace MediaBrowser.MediaEncoding.Probing
|
||||
return Math.Abs(d1 - d2) <= variance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a sample aspect ratio represents square (or near-square) pixels.
|
||||
/// Some encoders produce SARs like 3201:3200 for content that is effectively 1:1,
|
||||
/// which would be falsely classified as anamorphic by an exact string comparison.
|
||||
/// A 1% tolerance safely covers encoder rounding artifacts while preserving detection
|
||||
/// of genuine anamorphic content (closest standard is PAL 4:3 at 16:15 = 6.67% off).
|
||||
/// </summary>
|
||||
/// <param name="sar">The sample aspect ratio string in "N:D" format.</param>
|
||||
/// <returns><c>true</c> if the SAR is within 1% of 1:1; otherwise <c>false</c>.</returns>
|
||||
internal static bool IsNearSquarePixelSar(string sar)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sar))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var parts = sar.Split(':');
|
||||
if (parts.Length == 2
|
||||
&& double.TryParse(parts[0], CultureInfo.InvariantCulture, out var num)
|
||||
&& double.TryParse(parts[1], CultureInfo.InvariantCulture, out var den)
|
||||
&& den > 0)
|
||||
{
|
||||
return IsClose(num / den, 1.0, 0.01);
|
||||
}
|
||||
|
||||
return string.Equals(sar, "1:1", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a frame rate from a string value in ffprobe output
|
||||
/// This could be a number or in the format of 2997/125.
|
||||
|
||||
@@ -895,7 +895,7 @@ public class StreamInfo
|
||||
|
||||
if (SubProtocol == MediaStreamProtocol.hls)
|
||||
{
|
||||
sb.Append("/master.m3u8?");
|
||||
sb.Append("/master.m3u8");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -906,10 +906,10 @@ public class StreamInfo
|
||||
sb.Append('.');
|
||||
sb.Append(Container);
|
||||
}
|
||||
|
||||
sb.Append('?');
|
||||
}
|
||||
|
||||
var queryStart = sb.Length;
|
||||
|
||||
if (!string.IsNullOrEmpty(DeviceProfileId))
|
||||
{
|
||||
sb.Append("&DeviceProfileId=");
|
||||
@@ -1133,6 +1133,12 @@ public class StreamInfo
|
||||
sb.Append(query);
|
||||
}
|
||||
|
||||
// Replace the first '&' with '?' to form a valid query string.
|
||||
if (sb.Length > queryStart)
|
||||
{
|
||||
sb[queryStart] = '?';
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
#nullable disable
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
@@ -19,7 +18,7 @@ namespace MediaBrowser.Model.Providers
|
||||
/// Gets or sets the name.
|
||||
/// </summary>
|
||||
/// <value>The name.</value>
|
||||
public string Name { get; set; }
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the provider ids.
|
||||
@@ -41,13 +40,13 @@ namespace MediaBrowser.Model.Providers
|
||||
|
||||
public DateTime? PremiereDate { get; set; }
|
||||
|
||||
public string ImageUrl { get; set; }
|
||||
public string? ImageUrl { get; set; }
|
||||
|
||||
public string SearchProviderName { get; set; }
|
||||
public string? SearchProviderName { get; set; }
|
||||
|
||||
public string Overview { get; set; }
|
||||
public string? Overview { get; set; }
|
||||
|
||||
public RemoteSearchResult AlbumArtist { get; set; }
|
||||
public RemoteSearchResult? AlbumArtist { get; set; }
|
||||
|
||||
public RemoteSearchResult[] Artists { get; set; }
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Api
|
||||
/// <returns>The image portion of the TMDb client configuration.</returns>
|
||||
[HttpGet("ClientConfiguration")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<ConfigImageTypes> TmdbClientConfiguration()
|
||||
public async Task<ConfigImageTypes?> TmdbClientConfiguration()
|
||||
{
|
||||
return (await _tmdbClientManager.GetClientConfiguration().ConfigureAwait(false)).Images;
|
||||
}
|
||||
|
||||
@@ -75,10 +75,17 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets
|
||||
|
||||
var posters = collection.Images.Posters;
|
||||
var backdrops = collection.Images.Backdrops;
|
||||
var remoteImages = new List<RemoteImageInfo>(posters.Count + backdrops.Count);
|
||||
var remoteImages = new List<RemoteImageInfo>(posters?.Count ?? 0 + backdrops?.Count ?? 0);
|
||||
|
||||
remoteImages.AddRange(_tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language));
|
||||
remoteImages.AddRange(_tmdbClientManager.ConvertBackdropsToRemoteImageInfo(backdrops, language));
|
||||
if (posters is not null)
|
||||
{
|
||||
remoteImages.AddRange(_tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language));
|
||||
}
|
||||
|
||||
if (backdrops is not null)
|
||||
{
|
||||
remoteImages.AddRange(_tmdbClientManager.ConvertBackdropsToRemoteImageInfo(backdrops, language));
|
||||
}
|
||||
|
||||
return remoteImages;
|
||||
}
|
||||
|
||||
@@ -67,10 +67,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets
|
||||
|
||||
result.SetProviderId(MetadataProvider.Tmdb, collection.Id.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
return new[] { result };
|
||||
return [result];
|
||||
}
|
||||
|
||||
var collectionSearchResults = await _tmdbClientManager.SearchCollectionAsync(searchInfo.Name, language, searchInfo.MetadataCountryCode, cancellationToken).ConfigureAwait(false);
|
||||
if (collectionSearchResults is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var collections = new RemoteSearchResult[collectionSearchResults.Count];
|
||||
for (var i = 0; i < collectionSearchResults.Count; i++)
|
||||
|
||||
@@ -79,7 +79,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
|
||||
|
||||
if (movieTmdbId <= 0)
|
||||
{
|
||||
return Enumerable.Empty<RemoteImageInfo>();
|
||||
return [];
|
||||
}
|
||||
|
||||
// TODO use image languages if All Languages isn't toggled, but there's currently no way to get that value in here
|
||||
@@ -89,17 +89,28 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
|
||||
|
||||
if (movie?.Images is null)
|
||||
{
|
||||
return Enumerable.Empty<RemoteImageInfo>();
|
||||
return [];
|
||||
}
|
||||
|
||||
var posters = movie.Images.Posters;
|
||||
var backdrops = movie.Images.Backdrops;
|
||||
var logos = movie.Images.Logos;
|
||||
var remoteImages = new List<RemoteImageInfo>(posters.Count + backdrops.Count + logos.Count);
|
||||
var remoteImages = new List<RemoteImageInfo>(posters?.Count ?? 0 + backdrops?.Count ?? 0 + logos?.Count ?? 0);
|
||||
|
||||
remoteImages.AddRange(_tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language));
|
||||
remoteImages.AddRange(_tmdbClientManager.ConvertBackdropsToRemoteImageInfo(backdrops, language));
|
||||
remoteImages.AddRange(_tmdbClientManager.ConvertLogosToRemoteImageInfo(logos, language));
|
||||
if (posters is not null)
|
||||
{
|
||||
remoteImages.AddRange(_tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language));
|
||||
}
|
||||
|
||||
if (backdrops is not null)
|
||||
{
|
||||
remoteImages.AddRange(_tmdbClientManager.ConvertBackdropsToRemoteImageInfo(backdrops, language));
|
||||
}
|
||||
|
||||
if (logos is not null)
|
||||
{
|
||||
remoteImages.AddRange(_tmdbClientManager.ConvertLogosToRemoteImageInfo(logos, language));
|
||||
}
|
||||
|
||||
return remoteImages;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Providers;
|
||||
using TMDbLib.Objects.Find;
|
||||
using TMDbLib.Objects.General;
|
||||
using TMDbLib.Objects.Search;
|
||||
|
||||
namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
|
||||
@@ -84,7 +85,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
|
||||
remoteResult.SetProviderId(MetadataProvider.Tmdb, movie.Id.ToString(CultureInfo.InvariantCulture));
|
||||
remoteResult.TrySetProviderId(MetadataProvider.Imdb, movie.ImdbId);
|
||||
|
||||
return new[] { remoteResult };
|
||||
return [remoteResult];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +119,11 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (movieResults is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var len = movieResults.Count;
|
||||
var remoteSearchResults = new RemoteSearchResult[len];
|
||||
for (var i = 0; i < len; i++)
|
||||
@@ -158,7 +164,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
|
||||
|
||||
var searchResults = await _tmdbClientManager.SearchMovieAsync(cleanedName, info.Year ?? parsedName.Year ?? 0, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (searchResults.Count > 0)
|
||||
if (searchResults?.Count > 0)
|
||||
{
|
||||
tmdbId = searchResults[0].Id.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -167,7 +173,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
|
||||
if (string.IsNullOrEmpty(tmdbId) && !string.IsNullOrEmpty(imdbId))
|
||||
{
|
||||
var movieResultFromImdbId = await _tmdbClientManager.FindByExternalIdAsync(imdbId, FindExternalSource.Imdb, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false);
|
||||
if (movieResultFromImdbId?.MovieResults.Count > 0)
|
||||
if (movieResultFromImdbId?.MovieResults?.Count > 0)
|
||||
{
|
||||
tmdbId = movieResultFromImdbId.MovieResults[0].Id.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -193,7 +199,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
|
||||
OriginalTitle = movieResult.OriginalTitle,
|
||||
Overview = movieResult.Overview?.Replace("\n\n", "\n", StringComparison.InvariantCulture),
|
||||
Tagline = movieResult.Tagline,
|
||||
ProductionLocations = movieResult.ProductionCountries.Select(pc => pc.Name).ToArray()
|
||||
ProductionLocations = movieResult.ProductionCountries?.Select(pc => pc.Name).ToArray() ?? Array.Empty<string>()
|
||||
};
|
||||
var metadataResult = new MetadataResult<Movie>
|
||||
{
|
||||
@@ -218,14 +224,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
|
||||
|
||||
var ourRelease = releases.FirstOrDefault(c => string.Equals(c.Iso_3166_1, info.MetadataCountryCode, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (ourRelease is not null)
|
||||
if (ourRelease?.Certification is not null)
|
||||
{
|
||||
movie.OfficialRating = TmdbUtils.BuildParentalRating(ourRelease.Iso_3166_1, ourRelease.Certification);
|
||||
movie.OfficialRating = TmdbUtils.BuildParentalRating(info.MetadataCountryCode, ourRelease.Certification);
|
||||
}
|
||||
else
|
||||
{
|
||||
var usRelease = releases.FirstOrDefault(c => string.Equals(c.Iso_3166_1, "US", StringComparison.OrdinalIgnoreCase));
|
||||
if (usRelease is not null)
|
||||
if (usRelease?.Certification is not null)
|
||||
{
|
||||
movie.OfficialRating = usRelease.Certification;
|
||||
}
|
||||
@@ -242,16 +248,23 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
|
||||
|
||||
var genres = movieResult.Genres;
|
||||
|
||||
foreach (var genre in genres.Select(g => g.Name).Trimmed())
|
||||
if (genres is not null)
|
||||
{
|
||||
movie.AddGenre(genre);
|
||||
foreach (var genre in genres.Select(g => g.Name).Trimmed())
|
||||
{
|
||||
movie.AddGenre(genre);
|
||||
}
|
||||
}
|
||||
|
||||
if (movieResult.Keywords?.Keywords is not null)
|
||||
{
|
||||
for (var i = 0; i < movieResult.Keywords.Keywords.Count; i++)
|
||||
foreach (var keyword in movieResult.Keywords.Keywords)
|
||||
{
|
||||
movie.AddTag(movieResult.Keywords.Keywords[i].Name);
|
||||
var name = keyword.Name;
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
movie.AddTag(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,13 +56,17 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People
|
||||
}
|
||||
|
||||
result.SetProviderId(MetadataProvider.Tmdb, personResult.Id.ToString(CultureInfo.InvariantCulture));
|
||||
result.TrySetProviderId(MetadataProvider.Imdb, personResult.ExternalIds.ImdbId);
|
||||
result.TrySetProviderId(MetadataProvider.Imdb, personResult.ExternalIds?.ImdbId);
|
||||
|
||||
return new[] { result };
|
||||
return [result];
|
||||
}
|
||||
}
|
||||
|
||||
var personSearchResult = await _tmdbClientManager.SearchPersonAsync(searchInfo.Name, cancellationToken).ConfigureAwait(false);
|
||||
if (personSearchResult is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var remoteSearchResults = new RemoteSearchResult[personSearchResult.Count];
|
||||
for (var i = 0; i < personSearchResult.Count; i++)
|
||||
@@ -91,7 +95,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People
|
||||
if (personTmdbId <= 0)
|
||||
{
|
||||
var personSearchResults = await _tmdbClientManager.SearchPersonAsync(info.Name, cancellationToken).ConfigureAwait(false);
|
||||
if (personSearchResults.Count > 0)
|
||||
if (personSearchResults?.Count > 0)
|
||||
{
|
||||
personTmdbId = personSearchResults[0].Id;
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
|
||||
result.Item.Name = seasonResult.Name;
|
||||
}
|
||||
|
||||
result.Item.TrySetProviderId(MetadataProvider.Tvdb, seasonResult.ExternalIds.TvdbId);
|
||||
result.Item.TrySetProviderId(MetadataProvider.Tvdb, seasonResult.ExternalIds?.TvdbId);
|
||||
|
||||
// TODO why was this disabled?
|
||||
var credits = seasonResult.Credits;
|
||||
|
||||
@@ -79,11 +79,22 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
|
||||
var posters = series.Images.Posters;
|
||||
var backdrops = series.Images.Backdrops;
|
||||
var logos = series.Images.Logos;
|
||||
var remoteImages = new List<RemoteImageInfo>(posters.Count + backdrops.Count + logos.Count);
|
||||
var remoteImages = new List<RemoteImageInfo>(posters?.Count ?? 0 + backdrops?.Count ?? 0 + logos?.Count ?? 0);
|
||||
|
||||
remoteImages.AddRange(_tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language));
|
||||
remoteImages.AddRange(_tmdbClientManager.ConvertBackdropsToRemoteImageInfo(backdrops, language));
|
||||
remoteImages.AddRange(_tmdbClientManager.ConvertLogosToRemoteImageInfo(logos, language));
|
||||
if (posters is not null)
|
||||
{
|
||||
remoteImages.AddRange(_tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language));
|
||||
}
|
||||
|
||||
if (backdrops is not null)
|
||||
{
|
||||
remoteImages.AddRange(_tmdbClientManager.ConvertBackdropsToRemoteImageInfo(backdrops, language));
|
||||
}
|
||||
|
||||
if (logos is not null)
|
||||
{
|
||||
remoteImages.AddRange(_tmdbClientManager.ConvertLogosToRemoteImageInfo(logos, language));
|
||||
}
|
||||
|
||||
return remoteImages;
|
||||
}
|
||||
|
||||
@@ -112,6 +112,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
|
||||
|
||||
var tvSearchResults = await _tmdbClientManager.SearchSeriesAsync(searchInfo.Name, searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (tvSearchResults is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var remoteResults = new RemoteSearchResult[tvSearchResults.Count];
|
||||
for (var i = 0; i < tvSearchResults.Count; i++)
|
||||
@@ -141,6 +145,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
|
||||
}
|
||||
|
||||
remoteResult.PremiereDate = series.FirstAirDate?.ToUniversalTime();
|
||||
remoteResult.ProductionYear = series.FirstAirDate?.Year;
|
||||
|
||||
return remoteResult;
|
||||
}
|
||||
@@ -157,6 +162,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
|
||||
|
||||
remoteResult.SetProviderId(MetadataProvider.Tmdb, series.Id.ToString(CultureInfo.InvariantCulture));
|
||||
remoteResult.PremiereDate = series.FirstAirDate?.ToUniversalTime();
|
||||
remoteResult.ProductionYear = series.FirstAirDate?.Year;
|
||||
|
||||
return remoteResult;
|
||||
}
|
||||
@@ -174,7 +180,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
|
||||
if (string.IsNullOrEmpty(tmdbId) && info.TryGetProviderId(MetadataProvider.Imdb, out var imdbId))
|
||||
{
|
||||
var searchResult = await _tmdbClientManager.FindByExternalIdAsync(imdbId, FindExternalSource.Imdb, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false);
|
||||
if (searchResult?.TvResults.Count > 0)
|
||||
if (searchResult?.TvResults?.Count > 0)
|
||||
{
|
||||
tmdbId = searchResult.TvResults[0].Id.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -183,7 +189,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
|
||||
if (string.IsNullOrEmpty(tmdbId) && info.TryGetProviderId(MetadataProvider.Tvdb, out var tvdbId))
|
||||
{
|
||||
var searchResult = await _tmdbClientManager.FindByExternalIdAsync(tvdbId, FindExternalSource.TvDb, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false);
|
||||
if (searchResult?.TvResults.Count > 0)
|
||||
if (searchResult?.TvResults?.Count > 0)
|
||||
{
|
||||
tmdbId = searchResult.TvResults[0].Id.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -198,7 +204,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
|
||||
var cleanedName = TmdbUtils.CleanName(parsedName.Name);
|
||||
var searchResults = await _tmdbClientManager.SearchSeriesAsync(cleanedName, info.MetadataLanguage, info.MetadataCountryCode, info.Year ?? parsedName.Year ?? 0, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (searchResults.Count > 0)
|
||||
if (searchResults?.Count > 0)
|
||||
{
|
||||
tmdbId = searchResults[0].Id.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -262,15 +268,19 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
|
||||
|
||||
if (seriesResult.Keywords?.Results is not null)
|
||||
{
|
||||
for (var i = 0; i < seriesResult.Keywords.Results.Count; i++)
|
||||
foreach (var result in seriesResult.Keywords.Results)
|
||||
{
|
||||
series.AddTag(seriesResult.Keywords.Results[i].Name);
|
||||
var name = result.Name;
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
series.AddTag(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
series.HomePageUrl = seriesResult.Homepage;
|
||||
|
||||
series.RunTimeTicks = seriesResult.EpisodeRunTime.Select(i => TimeSpan.FromMinutes(i).Ticks).FirstOrDefault();
|
||||
series.RunTimeTicks = seriesResult.EpisodeRunTime?.Select(i => TimeSpan.FromMinutes(i).Ticks).FirstOrDefault();
|
||||
|
||||
if (Emby.Naming.TV.TvParserHelpers.TryParseSeriesStatus(seriesResult.Status, out var seriesStatus))
|
||||
{
|
||||
@@ -279,6 +289,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
|
||||
|
||||
series.EndDate = seriesResult.LastAirDate;
|
||||
series.PremiereDate = seriesResult.FirstAirDate;
|
||||
series.ProductionYear = seriesResult.FirstAirDate?.Year;
|
||||
|
||||
var ids = seriesResult.ExternalIds;
|
||||
if (ids is not null)
|
||||
@@ -288,21 +299,21 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
|
||||
series.TrySetProviderId(MetadataProvider.Tvdb, ids.TvdbId);
|
||||
}
|
||||
|
||||
var contentRatings = seriesResult.ContentRatings.Results ?? new List<ContentRating>();
|
||||
var contentRatings = seriesResult.ContentRatings?.Results ?? new List<ContentRating>();
|
||||
|
||||
var ourRelease = contentRatings.FirstOrDefault(c => string.Equals(c.Iso_3166_1, preferredCountryCode, StringComparison.OrdinalIgnoreCase));
|
||||
var usRelease = contentRatings.FirstOrDefault(c => string.Equals(c.Iso_3166_1, "US", StringComparison.OrdinalIgnoreCase));
|
||||
var minimumRelease = contentRatings.FirstOrDefault();
|
||||
|
||||
if (ourRelease is not null)
|
||||
if (ourRelease?.Rating is not null)
|
||||
{
|
||||
series.OfficialRating = TmdbUtils.BuildParentalRating(ourRelease.Iso_3166_1, ourRelease.Rating);
|
||||
series.OfficialRating = TmdbUtils.BuildParentalRating(preferredCountryCode, ourRelease.Rating);
|
||||
}
|
||||
else if (usRelease is not null)
|
||||
else if (usRelease?.Rating is not null)
|
||||
{
|
||||
series.OfficialRating = usRelease.Rating;
|
||||
}
|
||||
else if (minimumRelease is not null)
|
||||
else if (minimumRelease?.Rating is not null)
|
||||
{
|
||||
series.OfficialRating = minimumRelease.Rating;
|
||||
}
|
||||
@@ -347,7 +358,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
|
||||
Role = actor.Character?.Trim() ?? string.Empty,
|
||||
Type = PersonKind.Actor,
|
||||
SortOrder = actor.Order,
|
||||
ImageUrl = _tmdbClientManager.GetProfileUrl(actor.ProfilePath)
|
||||
// NOTE: Null values are filtered out above
|
||||
ImageUrl = _tmdbClientManager.GetProfileUrl(actor.ProfilePath!)
|
||||
};
|
||||
|
||||
if (actor.Id > 0)
|
||||
@@ -388,7 +400,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
|
||||
Name = crewMember.Name.Trim(),
|
||||
Role = crewMember.Job?.Trim() ?? string.Empty,
|
||||
Type = entry.PersonType,
|
||||
ImageUrl = _tmdbClientManager.GetProfileUrl(crewMember.ProfilePath)
|
||||
// NOTE: Null values are filtered out above
|
||||
ImageUrl = _tmdbClientManager.GetProfileUrl(crewMember.ProfilePath!)
|
||||
};
|
||||
|
||||
if (crewMember.Id > 0)
|
||||
|
||||
@@ -3,7 +3,6 @@ using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Providers;
|
||||
@@ -195,7 +194,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
|
||||
await EnsureClientConfigAsync().ConfigureAwait(false);
|
||||
|
||||
var series = await GetSeriesAsync(tvShowId, language, imageLanguages, countryCode, cancellationToken).ConfigureAwait(false);
|
||||
var episodeGroupId = series?.EpisodeGroups.Results.Find(g => g.Type == groupType)?.Id;
|
||||
var episodeGroupId = series?.EpisodeGroups?.Results?.Find(g => g.Type == groupType)?.Id;
|
||||
|
||||
if (episodeGroupId is null)
|
||||
{
|
||||
@@ -263,7 +262,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
|
||||
/// <param name="countryCode">The country code, ISO 3166-1.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The TMDb tv episode information or null if not found.</returns>
|
||||
public async Task<TvEpisode?> GetEpisodeAsync(int tvShowId, int seasonNumber, int episodeNumber, string displayOrder, string? language, string? imageLanguages, string? countryCode, CancellationToken cancellationToken)
|
||||
public async Task<TvEpisode?> GetEpisodeAsync(int tvShowId, int seasonNumber, long episodeNumber, string displayOrder, string? language, string? imageLanguages, string? countryCode, CancellationToken cancellationToken)
|
||||
{
|
||||
var key = $"episode-{tvShowId.ToString(CultureInfo.InvariantCulture)}-s{seasonNumber.ToString(CultureInfo.InvariantCulture)}e{episodeNumber.ToString(CultureInfo.InvariantCulture)}-{displayOrder}-{language}";
|
||||
if (_memoryCache.TryGetValue(key, out TvEpisode? episode))
|
||||
@@ -276,9 +275,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
|
||||
var group = await GetSeriesGroupAsync(tvShowId, displayOrder, language, imageLanguages, countryCode, cancellationToken).ConfigureAwait(false);
|
||||
if (group is not null)
|
||||
{
|
||||
var season = group.Groups.Find(s => s.Order == seasonNumber);
|
||||
var season = group.Groups?.Find(s => s.Order == seasonNumber);
|
||||
// Episode order starts at 0
|
||||
var ep = season?.Episodes.Find(e => e.Order == episodeNumber - 1);
|
||||
var ep = season?.Episodes?.Find(e => e.Order == episodeNumber - 1);
|
||||
if (ep is not null)
|
||||
{
|
||||
seasonNumber = ep.SeasonNumber;
|
||||
@@ -382,7 +381,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
|
||||
/// <param name="year">The year the tv show first aired.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The TMDb tv show information.</returns>
|
||||
public async Task<IReadOnlyList<SearchTv>> SearchSeriesAsync(string name, string language, string? countryCode, int year = 0, CancellationToken cancellationToken = default)
|
||||
public async Task<IReadOnlyList<SearchTv>?> SearchSeriesAsync(string name, string language, string? countryCode, int year = 0, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = $"searchseries-{name}-{year.ToString(CultureInfo.InvariantCulture)}-{language}";
|
||||
if (_memoryCache.TryGetValue(key, out SearchContainer<SearchTv>? series) && series is not null)
|
||||
@@ -396,12 +395,12 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
|
||||
.SearchTvShowAsync(name, TmdbUtils.NormalizeLanguage(language, countryCode), includeAdult: Plugin.Instance.Configuration.IncludeAdult, firstAirDateYear: year, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (searchResults.Results.Count > 0)
|
||||
if (searchResults?.Results?.Count > 0)
|
||||
{
|
||||
_memoryCache.Set(key, searchResults, TimeSpan.FromHours(CacheDurationInHours));
|
||||
}
|
||||
|
||||
return searchResults.Results;
|
||||
return searchResults?.Results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -410,7 +409,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
|
||||
/// <param name="name">The name of the person.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The TMDb person information.</returns>
|
||||
public async Task<IReadOnlyList<SearchPerson>> SearchPersonAsync(string name, CancellationToken cancellationToken)
|
||||
public async Task<IReadOnlyList<SearchPerson>?> SearchPersonAsync(string name, CancellationToken cancellationToken)
|
||||
{
|
||||
var key = $"searchperson-{name}";
|
||||
if (_memoryCache.TryGetValue(key, out SearchContainer<SearchPerson>? person) && person is not null)
|
||||
@@ -424,12 +423,12 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
|
||||
.SearchPersonAsync(name, includeAdult: Plugin.Instance.Configuration.IncludeAdult, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (searchResults.Results.Count > 0)
|
||||
if (searchResults?.Results?.Count > 0)
|
||||
{
|
||||
_memoryCache.Set(key, searchResults, TimeSpan.FromHours(CacheDurationInHours));
|
||||
}
|
||||
|
||||
return searchResults.Results;
|
||||
return searchResults?.Results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -439,7 +438,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
|
||||
/// <param name="language">The movie's language.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The TMDb movie information.</returns>
|
||||
public Task<IReadOnlyList<SearchMovie>> SearchMovieAsync(string name, string language, CancellationToken cancellationToken)
|
||||
public Task<IReadOnlyList<SearchMovie>?> SearchMovieAsync(string name, string language, CancellationToken cancellationToken)
|
||||
{
|
||||
return SearchMovieAsync(name, 0, language, null, cancellationToken);
|
||||
}
|
||||
@@ -453,7 +452,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
|
||||
/// <param name="countryCode">The country code, ISO 3166-1.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The TMDb movie information.</returns>
|
||||
public async Task<IReadOnlyList<SearchMovie>> SearchMovieAsync(string name, int year, string language, string? countryCode, CancellationToken cancellationToken)
|
||||
public async Task<IReadOnlyList<SearchMovie>?> SearchMovieAsync(string name, int year, string language, string? countryCode, CancellationToken cancellationToken)
|
||||
{
|
||||
var key = $"moviesearch-{name}-{year.ToString(CultureInfo.InvariantCulture)}-{language}";
|
||||
if (_memoryCache.TryGetValue(key, out SearchContainer<SearchMovie>? movies) && movies is not null)
|
||||
@@ -467,12 +466,12 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
|
||||
.SearchMovieAsync(name, TmdbUtils.NormalizeLanguage(language, countryCode), includeAdult: Plugin.Instance.Configuration.IncludeAdult, year: year, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (searchResults.Results.Count > 0)
|
||||
if (searchResults?.Results?.Count > 0)
|
||||
{
|
||||
_memoryCache.Set(key, searchResults, TimeSpan.FromHours(CacheDurationInHours));
|
||||
}
|
||||
|
||||
return searchResults.Results;
|
||||
return searchResults?.Results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -483,7 +482,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
|
||||
/// <param name="countryCode">The country code, ISO 3166-1.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The TMDb collection information.</returns>
|
||||
public async Task<IReadOnlyList<SearchCollection>> SearchCollectionAsync(string name, string language, string? countryCode, CancellationToken cancellationToken)
|
||||
public async Task<IReadOnlyList<SearchCollection>?> SearchCollectionAsync(string name, string language, string? countryCode, CancellationToken cancellationToken)
|
||||
{
|
||||
var key = $"collectionsearch-{name}-{language}";
|
||||
if (_memoryCache.TryGetValue(key, out SearchContainer<SearchCollection>? collections) && collections is not null)
|
||||
@@ -497,12 +496,12 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
|
||||
.SearchCollectionAsync(name, TmdbUtils.NormalizeLanguage(language, countryCode), cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (searchResults.Results.Count > 0)
|
||||
if (searchResults?.Results?.Count > 0)
|
||||
{
|
||||
_memoryCache.Set(key, searchResults, TimeSpan.FromHours(CacheDurationInHours));
|
||||
}
|
||||
|
||||
return searchResults.Results;
|
||||
return searchResults?.Results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -511,14 +510,17 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
|
||||
/// <param name="size">The image size to fetch.</param>
|
||||
/// <param name="path">The relative URL of the image.</param>
|
||||
/// <returns>The absolute URL.</returns>
|
||||
private string? GetUrl(string? size, string path)
|
||||
private string? GetUrl(string? size, string? path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return _tmDbClient.GetImageUrl(size, path, true).ToString();
|
||||
// Use "original" as default size if size is null or empty to prevent malformed URLs
|
||||
var imageSize = string.IsNullOrEmpty(size) ? "original" : size;
|
||||
|
||||
return _tmDbClient.GetImageUrl(imageSize, path, true).ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -526,7 +528,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
|
||||
/// </summary>
|
||||
/// <param name="posterPath">The relative URL of the poster.</param>
|
||||
/// <returns>The absolute URL.</returns>
|
||||
public string? GetPosterUrl(string posterPath)
|
||||
public string? GetPosterUrl(string? posterPath)
|
||||
{
|
||||
return GetUrl(Plugin.Instance.Configuration.PosterSize, posterPath);
|
||||
}
|
||||
@@ -536,7 +538,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
|
||||
/// </summary>
|
||||
/// <param name="actorProfilePath">The relative URL of the profile image.</param>
|
||||
/// <returns>The absolute URL.</returns>
|
||||
public string? GetProfileUrl(string actorProfilePath)
|
||||
public string? GetProfileUrl(string? actorProfilePath)
|
||||
{
|
||||
return GetUrl(Plugin.Instance.Configuration.ProfileSize, actorProfilePath);
|
||||
}
|
||||
@@ -639,30 +641,44 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
|
||||
private static void ValidatePreferences(TMDbConfig config)
|
||||
{
|
||||
var imageConfig = config.Images;
|
||||
if (imageConfig is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pluginConfig = Plugin.Instance.Configuration;
|
||||
|
||||
if (!imageConfig.PosterSizes.Contains(pluginConfig.PosterSize))
|
||||
if (imageConfig.PosterSizes is not null
|
||||
&& pluginConfig.PosterSize is not null
|
||||
&& !imageConfig.PosterSizes.Contains(pluginConfig.PosterSize))
|
||||
{
|
||||
pluginConfig.PosterSize = imageConfig.PosterSizes[^1];
|
||||
}
|
||||
|
||||
if (!imageConfig.BackdropSizes.Contains(pluginConfig.BackdropSize))
|
||||
if (imageConfig.BackdropSizes is not null
|
||||
&& pluginConfig.BackdropSize is not null
|
||||
&& !imageConfig.BackdropSizes.Contains(pluginConfig.BackdropSize))
|
||||
{
|
||||
pluginConfig.BackdropSize = imageConfig.BackdropSizes[^1];
|
||||
}
|
||||
|
||||
if (!imageConfig.LogoSizes.Contains(pluginConfig.LogoSize))
|
||||
if (imageConfig.LogoSizes is not null
|
||||
&& pluginConfig.LogoSize is not null
|
||||
&& !imageConfig.LogoSizes.Contains(pluginConfig.LogoSize))
|
||||
{
|
||||
pluginConfig.LogoSize = imageConfig.LogoSizes[^1];
|
||||
}
|
||||
|
||||
if (!imageConfig.ProfileSizes.Contains(pluginConfig.ProfileSize))
|
||||
if (imageConfig.ProfileSizes is not null
|
||||
&& pluginConfig.ProfileSize is not null
|
||||
&& !imageConfig.ProfileSizes.Contains(pluginConfig.ProfileSize))
|
||||
{
|
||||
pluginConfig.ProfileSize = imageConfig.ProfileSizes[^1];
|
||||
}
|
||||
|
||||
if (!imageConfig.StillSizes.Contains(pluginConfig.StillSize))
|
||||
if (imageConfig.StillSizes is not null
|
||||
&& pluginConfig.StillSize is not null
|
||||
&& !imageConfig.StillSizes.Contains(pluginConfig.StillSize))
|
||||
{
|
||||
pluginConfig.StillSize = imageConfig.StillSizes[^1];
|
||||
}
|
||||
|
||||
@@ -69,20 +69,20 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
|
||||
/// <returns>The Jellyfin person type.</returns>
|
||||
public static PersonKind MapCrewToPersonType(Crew crew)
|
||||
{
|
||||
if (crew.Department.Equals("directing", StringComparison.OrdinalIgnoreCase)
|
||||
&& crew.Job.Equals("director", StringComparison.OrdinalIgnoreCase))
|
||||
if (string.Equals(crew.Department, "directing", StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(crew.Job, "director", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return PersonKind.Director;
|
||||
}
|
||||
|
||||
if (crew.Department.Equals("production", StringComparison.OrdinalIgnoreCase)
|
||||
&& crew.Job.Equals("producer", StringComparison.OrdinalIgnoreCase))
|
||||
if (string.Equals(crew.Department, "production", StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(crew.Job, "producer", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return PersonKind.Producer;
|
||||
}
|
||||
|
||||
if (crew.Department.Equals("writing", StringComparison.OrdinalIgnoreCase)
|
||||
&& (crew.Job.Equals("writer", StringComparison.OrdinalIgnoreCase) || crew.Job.Equals("screenplay", StringComparison.OrdinalIgnoreCase)))
|
||||
if (string.Equals(crew.Department, "writing", StringComparison.OrdinalIgnoreCase)
|
||||
&& (string.Equals(crew.Job, "writer", StringComparison.OrdinalIgnoreCase) || string.Equals(crew.Job, "screenplay", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return PersonKind.Writer;
|
||||
}
|
||||
@@ -97,9 +97,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
|
||||
/// <returns>A boolean indicating whether the video is a trailer.</returns>
|
||||
public static bool IsTrailerType(Video video)
|
||||
{
|
||||
return video.Site.Equals("youtube", StringComparison.OrdinalIgnoreCase)
|
||||
&& (video.Type.Equals("trailer", StringComparison.OrdinalIgnoreCase)
|
||||
|| video.Type.Equals("teaser", StringComparison.OrdinalIgnoreCase));
|
||||
return string.Equals(video.Site, "youtube", StringComparison.OrdinalIgnoreCase)
|
||||
&& (string.Equals(video.Type, "trailer", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(video.Type, "teaser", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -117,14 +117,6 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
|
||||
preferredLanguage = NormalizeLanguage(preferredLanguage, countryCode);
|
||||
|
||||
languages.Add(preferredLanguage);
|
||||
|
||||
if (preferredLanguage.Length == 5) // Like en-US
|
||||
{
|
||||
// Currently, TMDb supports 2-letter language codes only.
|
||||
// They are planning to change this in the future, thus we're
|
||||
// supplying both codes if we're having a 5-letter code.
|
||||
languages.Add(preferredLanguage.Substring(0, 2));
|
||||
}
|
||||
}
|
||||
|
||||
languages.Add("null");
|
||||
@@ -185,10 +177,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
|
||||
/// <param name="imageLanguage">The image's actual language code.</param>
|
||||
/// <param name="requestLanguage">The requested language code.</param>
|
||||
/// <returns>The language code.</returns>
|
||||
public static string AdjustImageLanguage(string imageLanguage, string requestLanguage)
|
||||
public static string AdjustImageLanguage(string? imageLanguage, string requestLanguage)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(imageLanguage)
|
||||
&& !string.IsNullOrEmpty(requestLanguage)
|
||||
if (string.IsNullOrEmpty(imageLanguage))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(requestLanguage)
|
||||
&& requestLanguage.Length > 2
|
||||
&& imageLanguage.Length == 2
|
||||
&& requestLanguage.StartsWith(imageLanguage, StringComparison.OrdinalIgnoreCase))
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
@@ -201,6 +202,26 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo>
|
||||
false);
|
||||
}
|
||||
|
||||
private static bool NeedsVirtualSeason(Episode episode, HashSet<Guid> physicalSeasonIds, HashSet<string> physicalSeasonPaths)
|
||||
{
|
||||
// Episode has a known season number, needs a season
|
||||
if (episode.ParentIndexNumber.HasValue)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Not yet processed
|
||||
if (episode.SeasonId.IsEmpty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Episode has been processed, only needs a virtual season if it isn't
|
||||
// already linked to a known physical season by ID or path
|
||||
return !physicalSeasonIds.Contains(episode.SeasonId)
|
||||
&& !physicalSeasonPaths.Contains(System.IO.Path.GetDirectoryName(episode.Path) ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates seasons for all episodes if they don't exist.
|
||||
/// If no season number can be determined, a dummy season will be created.
|
||||
@@ -212,8 +233,20 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo>
|
||||
{
|
||||
var seriesChildren = series.GetRecursiveChildren(i => i is Episode || i is Season);
|
||||
var seasons = seriesChildren.OfType<Season>().ToList();
|
||||
|
||||
var physicalSeasonIds = seasons
|
||||
.Where(e => e.LocationType != LocationType.Virtual)
|
||||
.Select(e => e.Id)
|
||||
.ToHashSet();
|
||||
|
||||
var physicalSeasonPathSet = seasons
|
||||
.Where(e => e.LocationType != LocationType.Virtual && !string.IsNullOrEmpty(e.Path))
|
||||
.Select(e => e.Path)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var uniqueSeasonNumbers = seriesChildren
|
||||
.OfType<Episode>()
|
||||
.Where(e => NeedsVirtualSeason(e, physicalSeasonIds, physicalSeasonPathSet))
|
||||
.Select(e => e.ParentIndexNumber >= 0 ? e.ParentIndexNumber : null)
|
||||
.Distinct();
|
||||
|
||||
|
||||
11
README.md
11
README.md
@@ -94,13 +94,12 @@ git clone https://github.com/jellyfin/jellyfin.git
|
||||
|
||||
The server is configured to host the static files required for the [web client](https://github.com/jellyfin/jellyfin-web) in addition to serving the backend by default. Before you can run the server, you will need to get a copy of the web client since they are not included in this repository directly.
|
||||
|
||||
Note that it is also possible to [host the web client separately](#hosting-the-web-client-separately) from the web server with some additional configuration, in which case you can skip this step.
|
||||
Note that it is recommended for development to [host the web client separately](#hosting-the-web-client-separately) from the web server with some additional configuration, in which case you can skip this step.
|
||||
|
||||
There are three options to get the files for the web client.
|
||||
There are two options to get the files for the web client.
|
||||
|
||||
1. Download one of the finished builds from the [Azure DevOps pipeline](https://dev.azure.com/jellyfin-project/jellyfin/_build?definitionId=27). You can download the build for a specific release by looking at the [branches tab](https://dev.azure.com/jellyfin-project/jellyfin/_build?definitionId=27&_a=summary&repositoryFilter=6&view=branches) of the pipelines page.
|
||||
2. Build them from source following the instructions on the [jellyfin-web repository](https://github.com/jellyfin/jellyfin-web)
|
||||
3. Get the pre-built files from an existing installation of the server. For example, with a Windows server installation the client files are located at `C:\Program Files\Jellyfin\Server\jellyfin-web`
|
||||
1. Build them from source following the instructions on the [jellyfin-web repository](https://github.com/jellyfin/jellyfin-web)
|
||||
2. Get the pre-built files from an existing installation of the server. For example, with a Windows server installation the client files are located at `C:\Program Files\Jellyfin\Server\jellyfin-web`
|
||||
|
||||
### Running The Server
|
||||
|
||||
@@ -198,5 +197,5 @@ This project is supported by:
|
||||
<br/>
|
||||
<a href="https://www.digitalocean.com"><img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" height="50px" alt="DigitalOcean"></a>
|
||||
|
||||
<a href="https://www.jetbrains.com"><img src="https://gist.githubusercontent.com/anthonylavado/e8b2403deee9581e0b4cb8cd675af7db/raw/fa104b7d73f759d7262794b94569f1b89df41c0b/jetbrains.svg" height="50px" alt="JetBrains logo"></a>
|
||||
<a href="https://www.jetbrains.com"><img src="https://gist.githubusercontent.com/anthonylavado/e8b2403deee9581e0b4cb8cd675af7db/raw/199ae22980ef5da64882ec2de3e8e5c03fe535b8/jetbrains.svg" height="50px" alt="JetBrains logo"></a>
|
||||
</p>
|
||||
|
||||
@@ -85,7 +85,6 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable
|
||||
"jpeg",
|
||||
"jpg",
|
||||
"png",
|
||||
"aiff",
|
||||
"cr2",
|
||||
"crw",
|
||||
"nef",
|
||||
|
||||
@@ -131,7 +131,7 @@ namespace Jellyfin.Extensions
|
||||
/// </summary>
|
||||
/// <param name="values">The enumerable of strings to trim.</param>
|
||||
/// <returns>The enumeration of trimmed strings.</returns>
|
||||
public static IEnumerable<string> Trimmed(this IEnumerable<string> values)
|
||||
public static IEnumerable<string> Trimmed(this IEnumerable<string?> values)
|
||||
{
|
||||
return values.Select(i => (i ?? string.Empty).Trim());
|
||||
}
|
||||
|
||||
@@ -39,6 +39,23 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
|
||||
public void GetFrameRate_Success(string value, float? expected)
|
||||
=> Assert.Equal(expected, ProbeResultNormalizer.GetFrameRate(value));
|
||||
|
||||
[Theory]
|
||||
[InlineData("1:1", true)]
|
||||
[InlineData("3201:3200", true)]
|
||||
[InlineData("1215:1216", true)]
|
||||
[InlineData("1001:1000", true)]
|
||||
[InlineData("16:15", false)]
|
||||
[InlineData("8:9", false)]
|
||||
[InlineData("32:27", false)]
|
||||
[InlineData("10:11", false)]
|
||||
[InlineData("64:45", false)]
|
||||
[InlineData("4:3", false)]
|
||||
[InlineData("0:1", false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsNearSquarePixelSar_DetectsCorrectly(string? sar, bool expected)
|
||||
=> Assert.Equal(expected, ProbeResultNormalizer.IsNearSquarePixelSar(sar));
|
||||
|
||||
[Fact]
|
||||
public void GetMediaInfo_MetaData_Success()
|
||||
{
|
||||
@@ -123,6 +140,7 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
|
||||
Assert.Equal(358, res.VideoStream.Height);
|
||||
Assert.Equal(720, res.VideoStream.Width);
|
||||
Assert.Equal("2.40:1", res.VideoStream.AspectRatio);
|
||||
Assert.True(res.VideoStream.IsAnamorphic); // SAR 32:27 — genuinely anamorphic NTSC DVD 16:9
|
||||
Assert.Equal("yuv420p", res.VideoStream.PixelFormat);
|
||||
Assert.Equal(31d, res.VideoStream.Level);
|
||||
Assert.Equal(1, res.VideoStream.RefFrames);
|
||||
|
||||
@@ -216,8 +216,7 @@ public class StreamInfoTests
|
||||
|
||||
string legacyUrl = streamInfo.ToUrl_Original(BaseUrl, "123");
|
||||
|
||||
// New version will return and & after the ? due to optional parameters.
|
||||
string newUrl = streamInfo.ToUrl(BaseUrl, "123", null).Replace("?&", "?", StringComparison.OrdinalIgnoreCase);
|
||||
string newUrl = streamInfo.ToUrl(BaseUrl, "123", null);
|
||||
|
||||
Assert.Equal(legacyUrl, newUrl, ignoreCase: true);
|
||||
}
|
||||
@@ -234,8 +233,7 @@ public class StreamInfoTests
|
||||
FillAllProperties(streamInfo);
|
||||
string legacyUrl = streamInfo.ToUrl_Original(BaseUrl, "123");
|
||||
|
||||
// New version will return and & after the ? due to optional parameters.
|
||||
string newUrl = streamInfo.ToUrl(BaseUrl, "123", null).Replace("?&", "?", StringComparison.OrdinalIgnoreCase);
|
||||
string newUrl = streamInfo.ToUrl(BaseUrl, "123", null);
|
||||
|
||||
Assert.Equal(legacyUrl, newUrl, ignoreCase: true);
|
||||
}
|
||||
|
||||
@@ -11,21 +11,29 @@ namespace Jellyfin.Server.Implementations.Tests.Library
|
||||
[InlineData("Superman: Red Son [imdbid=tt10985510]", "imdbid", "tt10985510")]
|
||||
[InlineData("Superman: Red Son [imdbid-tt10985510]", "imdbid", "tt10985510")]
|
||||
[InlineData("Superman: Red Son - tt10985510", "imdbid", "tt10985510")]
|
||||
[InlineData("Superman: Red Son {imdbid=tt10985510}", "imdbid", "tt10985510")]
|
||||
[InlineData("Superman: Red Son (imdbid-tt10985510)", "imdbid", "tt10985510")]
|
||||
[InlineData("Superman: Red Son", "imdbid", null)]
|
||||
[InlineData("Superman: Red Son", "something", null)]
|
||||
[InlineData("Superman: Red Son [imdbid1=tt11111111][imdbid=tt10985510]", "imdbid", "tt10985510")]
|
||||
[InlineData("Superman: Red Son [imdbid1-tt11111111][imdbid=tt10985510]", "imdbid", "tt10985510")]
|
||||
[InlineData("Superman: Red Son {imdbid1=tt11111111}(imdbid=tt10985510)", "imdbid", "tt10985510")]
|
||||
[InlineData("Superman: Red Son (imdbid1-tt11111111)[imdbid=tt10985510]", "imdbid", "tt10985510")]
|
||||
[InlineData("Superman: Red Son [tmdbid=618355][imdbid=tt10985510]", "imdbid", "tt10985510")]
|
||||
[InlineData("Superman: Red Son [tmdbid-618355][imdbid-tt10985510]", "imdbid", "tt10985510")]
|
||||
[InlineData("Superman: Red Son [tmdbid-618355][imdbid-tt10985510]", "tmdbid", "618355")]
|
||||
[InlineData("Superman: Red Son [tmdbid-618355]{imdbid-tt10985510}", "imdbid", "tt10985510")]
|
||||
[InlineData("Superman: Red Son (tmdbid-618355)[imdbid-tt10985510]", "tmdbid", "618355")]
|
||||
[InlineData("Superman: Red Son [providera-id=1]", "providera-id", "1")]
|
||||
[InlineData("Superman: Red Son [providerb-id=2]", "providerb-id", "2")]
|
||||
[InlineData("Superman: Red Son [providera id=4]", "providera id", "4")]
|
||||
[InlineData("Superman: Red Son [providerb id=5]", "providerb id", "5")]
|
||||
[InlineData("Superman: Red Son [tmdbid=3]", "tmdbid", "3")]
|
||||
[InlineData("Superman: Red Son [tvdbid-6]", "tvdbid", "6")]
|
||||
[InlineData("Superman: Red Son {tmdbid=3}", "tmdbid", "3")]
|
||||
[InlineData("Superman: Red Son (tvdbid-6)", "tvdbid", "6")]
|
||||
[InlineData("[tmdbid=618355]", "tmdbid", "618355")]
|
||||
[InlineData("{tmdbid=618355}", "tmdbid", "618355")]
|
||||
[InlineData("(tmdbid=618355)", "tmdbid", "618355")]
|
||||
[InlineData("[tmdbid-618355]", "tmdbid", "618355")]
|
||||
[InlineData("{tmdbid-618355)", "tmdbid", null)]
|
||||
[InlineData("[tmdbid-618355}", "tmdbid", null)]
|
||||
[InlineData("tmdbid=111111][tmdbid=618355]", "tmdbid", "618355")]
|
||||
[InlineData("[tmdbid=618355]tmdbid=111111]", "tmdbid", "618355")]
|
||||
[InlineData("tmdbid=618355]", "tmdbid", null)]
|
||||
@@ -36,6 +44,9 @@ namespace Jellyfin.Server.Implementations.Tests.Library
|
||||
[InlineData("[tmdbid=][imdbid=tt10985510]", "tmdbid", null)]
|
||||
[InlineData("[tmdbid-][imdbid-tt10985510]", "tmdbid", null)]
|
||||
[InlineData("Superman: Red Son [tmdbid-618355][tmdbid=1234567]", "tmdbid", "618355")]
|
||||
[InlineData("{tmdbid=}{imdbid=tt10985510}", "tmdbid", null)]
|
||||
[InlineData("(tmdbid-)(imdbid-tt10985510)", "tmdbid", null)]
|
||||
[InlineData("Superman: Red Son {tmdbid-618355}{tmdbid=1234567}", "tmdbid", "618355")]
|
||||
public void GetAttributeValue_ValidArgs_Correct(string input, string attribute, string? expectedResult)
|
||||
{
|
||||
Assert.Equal(expectedResult, PathExtensions.GetAttributeValue(input, attribute));
|
||||
|
||||
Reference in New Issue
Block a user