mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-02-02 00:18:30 +00:00
Compare commits
1 Commits
fix/ci-wor
...
renovate/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b88c05393 |
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@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
||||
uses: github/codeql-action/autobuild@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
||||
uses: github/codeql-action/analyze@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11
|
||||
|
||||
55
.github/workflows/ci-compat-build.yml
vendored
55
.github/workflows/ci-compat-build.yml
vendored
@@ -1,55 +0,0 @@
|
||||
name: ABI Compatibility Build
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
abi-head:
|
||||
name: ABI - HEAD
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Build
|
||||
run: dotnet build Jellyfin.Server -o ./out
|
||||
|
||||
- name: Upload Head
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: abi-head
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
path: out/
|
||||
|
||||
abi-base:
|
||||
name: ABI - BASE
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Build
|
||||
run: dotnet build Jellyfin.Server -o ./out
|
||||
|
||||
- name: Upload Base
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: abi-base
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
path: out/
|
||||
91
.github/workflows/ci-compat.yml
vendored
91
.github/workflows/ci-compat.yml
vendored
@@ -1,20 +1,87 @@
|
||||
name: ABI Compatibility
|
||||
|
||||
name: ABI Compatibility
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["ABI Compatibility Build"]
|
||||
types: [completed]
|
||||
pull_request_target:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
abi-head:
|
||||
name: ABI - HEAD
|
||||
runs-on: ubuntu-latest
|
||||
permissions: read-all
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
dotnet build Jellyfin.Server -o ./out
|
||||
|
||||
- name: Upload Head
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: abi-head
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
path: out/
|
||||
|
||||
abi-base:
|
||||
name: ABI - BASE
|
||||
if: ${{ github.base_ref != '' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions: read-all
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Checkout common ancestor
|
||||
env:
|
||||
HEAD_REF: ${{ github.head_ref }}
|
||||
run: |
|
||||
git remote add upstream https://github.com/${{ github.event.pull_request.base.repo.full_name }}
|
||||
git -c protocol.version=2 fetch --prune --progress --no-recurse-submodules upstream +refs/heads/*:refs/remotes/upstream/* +refs/tags/*:refs/tags/*
|
||||
ANCESTOR_REF=$(git merge-base upstream/${{ github.base_ref }} origin/$HEAD_REF)
|
||||
git checkout --progress --force $ANCESTOR_REF
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
dotnet build Jellyfin.Server -o ./out
|
||||
|
||||
- name: Upload Head
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: abi-base
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
path: out/
|
||||
|
||||
abi-diff:
|
||||
permissions:
|
||||
pull-requests: write
|
||||
pull-requests: write # to create or update comment (peter-evans/create-or-update-comment)
|
||||
|
||||
name: ABI - Difference
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' }}
|
||||
if: ${{ github.event_name == 'pull_request_target' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- abi-head
|
||||
- abi-base
|
||||
|
||||
steps:
|
||||
- name: Download abi-head
|
||||
@@ -22,16 +89,12 @@ jobs:
|
||||
with:
|
||||
name: abi-head
|
||||
path: abi-head
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Download abi-base
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: abi-base
|
||||
path: abi-base
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup ApiCompat
|
||||
run: |
|
||||
@@ -55,7 +118,7 @@ jobs:
|
||||
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
|
||||
id: find-comment
|
||||
with:
|
||||
issue-number: ${{ github.event.workflow_run.pull_requests[0].number }}
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
direction: last
|
||||
body-includes: abi-diff-workflow-comment
|
||||
|
||||
@@ -63,7 +126,7 @@ jobs:
|
||||
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
||||
if: ${{ steps.diff.outputs.body != '' }}
|
||||
with:
|
||||
issue-number: ${{ github.event.workflow_run.pull_requests[0].number }}
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
comment-id: ${{ steps.find-comment.outputs.comment-id }}
|
||||
edit-mode: replace
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
@@ -82,7 +145,7 @@ jobs:
|
||||
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
||||
if: ${{ steps.diff.outputs.body == '' && steps.find-comment.outputs.comment-id != '' }}
|
||||
with:
|
||||
issue-number: ${{ github.event.workflow_run.pull_requests[0].number }}
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
comment-id: ${{ steps.find-comment.outputs.comment-id }}
|
||||
edit-mode: replace
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
|
||||
55
.github/workflows/ci-openapi-build.yml
vendored
55
.github/workflows/ci-openapi-build.yml
vendored
@@ -1,55 +0,0 @@
|
||||
name: OpenAPI Build
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
openapi-head:
|
||||
name: OpenAPI - HEAD
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Generate openapi.json
|
||||
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
|
||||
|
||||
- name: Upload openapi.json
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: openapi-head
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
path: tests/Jellyfin.Server.Integration.Tests/bin/Release/net10.0/openapi.json
|
||||
|
||||
openapi-base:
|
||||
name: OpenAPI - BASE
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Generate openapi.json
|
||||
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
|
||||
|
||||
- name: Upload openapi.json
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: openapi-base
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
path: tests/Jellyfin.Server.Integration.Tests/bin/Release/net10.0/openapi.json
|
||||
66
.github/workflows/ci-openapi.yml
vendored
66
.github/workflows/ci-openapi.yml
vendored
@@ -1,31 +1,30 @@
|
||||
name: OpenAPI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_run:
|
||||
workflows: ["OpenAPI Build"]
|
||||
types: [completed]
|
||||
pull_request_target:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
openapi-head:
|
||||
name: OpenAPI - HEAD
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions: read-all
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Generate openapi.json
|
||||
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
|
||||
|
||||
@@ -37,29 +36,65 @@ jobs:
|
||||
if-no-files-found: error
|
||||
path: tests/Jellyfin.Server.Integration.Tests/bin/Release/net10.0/openapi.json
|
||||
|
||||
openapi-base:
|
||||
name: OpenAPI - BASE
|
||||
if: ${{ github.base_ref != '' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions: read-all
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Checkout common ancestor
|
||||
env:
|
||||
HEAD_REF: ${{ github.head_ref }}
|
||||
run: |
|
||||
git remote add upstream https://github.com/${{ github.event.pull_request.base.repo.full_name }}
|
||||
git -c protocol.version=2 fetch --prune --progress --no-recurse-submodules upstream +refs/heads/*:refs/remotes/upstream/* +refs/tags/*:refs/tags/*
|
||||
ANCESTOR_REF=$(git merge-base upstream/${{ github.base_ref }} origin/$HEAD_REF)
|
||||
git checkout --progress --force $ANCESTOR_REF
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
- name: Generate openapi.json
|
||||
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
|
||||
|
||||
- name: Upload openapi.json
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: openapi-base
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
path: tests/Jellyfin.Server.Integration.Tests/bin/Release/net10.0/openapi.json
|
||||
|
||||
openapi-diff:
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
name: OpenAPI - Difference
|
||||
if: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' }}
|
||||
if: ${{ github.event_name == 'pull_request_target' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- openapi-head
|
||||
- openapi-base
|
||||
steps:
|
||||
- name: Download openapi-head
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: openapi-head
|
||||
path: openapi-head
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Download openapi-base
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: openapi-base
|
||||
path: openapi-base
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Detect OpenAPI changes
|
||||
id: openapi-diff
|
||||
@@ -70,12 +105,11 @@ jobs:
|
||||
markdown: openapi-changelog.md
|
||||
add-pr-comment: true
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
pr-number: ${{ github.event.workflow_run.pull_requests[0].number }}
|
||||
|
||||
|
||||
publish-unstable:
|
||||
name: OpenAPI - Publish Unstable Spec
|
||||
if: ${{ github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/v') && contains(github.repository_owner, 'jellyfin') }}
|
||||
if: ${{ github.event_name != 'pull_request_target' && !startsWith(github.ref, 'refs/tags/v') && contains(github.repository_owner, 'jellyfin') }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- openapi-head
|
||||
@@ -99,7 +133,7 @@ jobs:
|
||||
strip_components: 1
|
||||
target: "/srv/incoming/openapi/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
|
||||
- name: Move openapi.json (unstable) into place
|
||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
uses: appleboy/ssh-action@823bd89e131d8d508129f9443cad5855e9ba96f0 # v1.2.4
|
||||
with:
|
||||
host: "${{ secrets.REPO_HOST }}"
|
||||
username: "${{ secrets.REPO_USER }}"
|
||||
@@ -136,7 +170,7 @@ jobs:
|
||||
|
||||
publish-stable:
|
||||
name: OpenAPI - Publish Stable Spec
|
||||
if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && contains(github.repository_owner, 'jellyfin') }}
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') && contains(github.repository_owner, 'jellyfin') }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- openapi-head
|
||||
@@ -160,7 +194,7 @@ jobs:
|
||||
strip_components: 1
|
||||
target: "/srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
|
||||
- name: Move openapi.json (stable) into place
|
||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
uses: appleboy/ssh-action@823bd89e131d8d508129f9443cad5855e9ba96f0 # v1.2.4
|
||||
with:
|
||||
host: "${{ secrets.REPO_HOST }}"
|
||||
username: "${{ secrets.REPO_USER }}"
|
||||
|
||||
@@ -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.1" />
|
||||
<PackageVersion Include="AutoFixture.AutoMoq" Version="4.18.1" />
|
||||
<PackageVersion Include="AutoFixture.Xunit2" Version="4.18.1" />
|
||||
<PackageVersion Include="AutoFixture" Version="4.18.1" />
|
||||
@@ -25,7 +25,7 @@
|
||||
<PackageVersion Include="Jellyfin.XmlTv" Version="10.8.0" />
|
||||
<PackageVersion Include="libse" Version="4.0.12" />
|
||||
<PackageVersion Include="LrcParser" Version="2025.623.0" />
|
||||
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="8.0.1" />
|
||||
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="7.0.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" />
|
||||
|
||||
@@ -1051,16 +1051,16 @@ namespace Emby.Server.Implementations.Dto
|
||||
|
||||
// Include artists that are not in the database yet, e.g., just added via metadata editor
|
||||
// var foundArtists = artistItems.Items.Select(i => i.Item1.Name).ToList();
|
||||
var artistsLookup = _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))]);
|
||||
|
||||
dto.ArtistItems = hasArtist.Artists
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Distinct()
|
||||
.Select(name => artistsLookup.TryGetValue(name, out var artists) && artists.Length > 0
|
||||
? new NameGuidPair { Name = name, Id = artists[0].Id }
|
||||
: null)
|
||||
.Where(item => item is not null)
|
||||
.ToArray();
|
||||
dto.ArtistItems = _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))])
|
||||
.Where(e => e.Value.Length > 0)
|
||||
.Select(i =>
|
||||
{
|
||||
return new NameGuidPair
|
||||
{
|
||||
Name = i.Key,
|
||||
Id = i.Value.First().Id
|
||||
};
|
||||
}).Where(i => i is not null).ToArray();
|
||||
}
|
||||
|
||||
if (item is IHasAlbumArtist hasAlbumArtist)
|
||||
@@ -1085,16 +1085,31 @@ namespace Emby.Server.Implementations.Dto
|
||||
// })
|
||||
// .ToList();
|
||||
|
||||
var albumArtistsLookup = _libraryManager.GetArtists([.. hasAlbumArtist.AlbumArtists.Where(e => !string.IsNullOrWhiteSpace(e))]);
|
||||
|
||||
dto.AlbumArtists = hasAlbumArtist.AlbumArtists
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Distinct()
|
||||
.Select(name => albumArtistsLookup.TryGetValue(name, out var albumArtists) && albumArtists.Length > 0
|
||||
? new NameGuidPair { Name = name, Id = albumArtists[0].Id }
|
||||
: null)
|
||||
.Where(item => item is not null)
|
||||
.ToArray();
|
||||
// .Except(foundArtists, new DistinctNameComparer())
|
||||
.Select(i =>
|
||||
{
|
||||
// This should not be necessary but we're seeing some cases of it
|
||||
if (string.IsNullOrEmpty(i))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var artist = _libraryManager.GetArtist(i, new DtoOptions(false)
|
||||
{
|
||||
EnableImages = false
|
||||
});
|
||||
if (artist is not null)
|
||||
{
|
||||
return new NameGuidPair
|
||||
{
|
||||
Name = artist.Name,
|
||||
Id = artist.Id
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}).Where(i => i is not null).ToArray();
|
||||
}
|
||||
|
||||
// Add video info
|
||||
|
||||
@@ -50,10 +50,6 @@ namespace Emby.Server.Implementations.Library
|
||||
"**/lost+found",
|
||||
"**/subs/**",
|
||||
"**/subs",
|
||||
"**/.snapshots/**",
|
||||
"**/.snapshots",
|
||||
"**/.snapshot/**",
|
||||
"**/.snapshot",
|
||||
|
||||
// Trickplay files
|
||||
"**/*.trickplay",
|
||||
@@ -87,6 +83,7 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
// Unix hidden files
|
||||
"**/.*",
|
||||
"**/.*/**",
|
||||
|
||||
// Mac - if you ever remove the above.
|
||||
// "**/._*",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"Shows": "العروض",
|
||||
"Songs": "الأغاني",
|
||||
"StartupEmbyServerIsLoading": "يتم تحميل خادم Jellyfin . الرجاء المحاولة بعد قليل.",
|
||||
"SubtitleDownloadFailureForItem": "عملية إنزال الترجمة فشلت لـ{0}",
|
||||
"SubtitleDownloadFailureFromForItem": "فشل تحميل الترجمات من {0} ل {1}",
|
||||
"Sync": "مزامنة",
|
||||
"System": "النظام",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"Shows": "Сериали",
|
||||
"Songs": "Песни",
|
||||
"StartupEmbyServerIsLoading": "Сървърът зарежда. Моля, опитайте отново след малко.",
|
||||
"SubtitleDownloadFailureForItem": "Неуспешно изтегляне на субтитри за {0}",
|
||||
"SubtitleDownloadFailureFromForItem": "Субтитрите за {1} от {0} не можаха да бъдат изтеглени",
|
||||
"Sync": "Синхронизиране",
|
||||
"System": "Система",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"Shows": "Sèries",
|
||||
"Songs": "Cançons",
|
||||
"StartupEmbyServerIsLoading": "El servidor de Jellyfin s'està carregant. Proveu-ho de nou en una estona.",
|
||||
"SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
|
||||
"SubtitleDownloadFailureFromForItem": "Els subtítols per a {1} no s'han pogut baixar de {0}",
|
||||
"Sync": "Sincronitza",
|
||||
"System": "Sistema",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"Shows": "Seriály",
|
||||
"Songs": "Skladby",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin Server je spouštěn. Zkuste to prosím v brzké době znovu.",
|
||||
"SubtitleDownloadFailureForItem": "Stahování titulků selhalo pro {0}",
|
||||
"SubtitleDownloadFailureFromForItem": "Stažení titulků pro {1} z {0} selhalo",
|
||||
"Sync": "Synchronizace",
|
||||
"System": "Systém",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"Shows": "Serier",
|
||||
"Songs": "Sange",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin er i gang med at starte. Prøv igen om et øjeblik.",
|
||||
"SubtitleDownloadFailureForItem": "Fejlet i download af undertekster for {0}",
|
||||
"SubtitleDownloadFailureFromForItem": "Undertekster kunne ikke hentes fra {0} til {1}",
|
||||
"Sync": "Synkroniser",
|
||||
"System": "System",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"Shows": "Serien",
|
||||
"Songs": "Lieder",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin-Server lädt. Bitte versuche es gleich noch einmal.",
|
||||
"SubtitleDownloadFailureForItem": "Download der Untertitel fehlgeschlagen für {0}",
|
||||
"SubtitleDownloadFailureFromForItem": "Untertitel von {0} für {1} konnten nicht heruntergeladen werden",
|
||||
"Sync": "Synchronisation",
|
||||
"System": "System",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"Shows": "Σειρές",
|
||||
"Songs": "Τραγούδια",
|
||||
"StartupEmbyServerIsLoading": "Ο διακομιστής Jellyfin φορτώνει. Περιμένετε λίγο και δοκιμάστε ξανά.",
|
||||
"SubtitleDownloadFailureForItem": "Οι υπότιτλοι απέτυχαν να κατέβουν για {0}",
|
||||
"SubtitleDownloadFailureFromForItem": "Αποτυχίες μεταφόρτωσης υποτίτλων από {0} για {1}",
|
||||
"Sync": "Συγχρονισμός",
|
||||
"System": "Σύστημα",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"Shows": "Shows",
|
||||
"Songs": "Songs",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin Server is loading. Please try again shortly.",
|
||||
"SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
|
||||
"SubtitleDownloadFailureFromForItem": "Subtitles failed to download from {0} for {1}",
|
||||
"Sync": "Sync",
|
||||
"System": "System",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"HeaderFavoriteAlbums": "Álbumes favoritos",
|
||||
"HeaderFavoriteArtists": "Artistas favoritos",
|
||||
"HeaderFavoriteEpisodes": "Capítulos favoritos",
|
||||
"HeaderFavoriteShows": "Series favoritas",
|
||||
"HeaderFavoriteShows": "Programas favoritos",
|
||||
"HeaderFavoriteSongs": "Canciones favoritas",
|
||||
"HeaderLiveTV": "TV en vivo",
|
||||
"HeaderNextUp": "Siguiente",
|
||||
@@ -73,6 +73,7 @@
|
||||
"Shows": "Series",
|
||||
"Songs": "Canciones",
|
||||
"StartupEmbyServerIsLoading": "El servidor Jellyfin se está cargando. Vuelve a intentarlo en breve.",
|
||||
"SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
|
||||
"SubtitleDownloadFailureFromForItem": "Falló la descarga de subtitulos desde {0} para {1}",
|
||||
"Sync": "Sincronizar",
|
||||
"System": "Sistema",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"Shows": "Programas",
|
||||
"Songs": "Canciones",
|
||||
"StartupEmbyServerIsLoading": "El servidor Jellyfin está cargando. Por favor, intente de nuevo pronto.",
|
||||
"SubtitleDownloadFailureForItem": "Falló la descarga de subtítulos para {0}",
|
||||
"SubtitleDownloadFailureFromForItem": "Falló la descarga de subtítulos desde {0} para {1}",
|
||||
"Sync": "Sincronizar",
|
||||
"System": "Sistema",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"Shows": "Series",
|
||||
"Songs": "Canciones",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin Server se está cargando. Vuelve a intentarlo en breve.",
|
||||
"SubtitleDownloadFailureForItem": "Error al descargar subtítulos para {0}",
|
||||
"SubtitleDownloadFailureFromForItem": "Fallo en la descarga de subtítulos desde {0} para {1}",
|
||||
"Sync": "Sincronizar",
|
||||
"System": "Sistema",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"Shows": "سریالها",
|
||||
"Songs": "موسیقیها",
|
||||
"StartupEmbyServerIsLoading": "سرور Jellyfin در حال بارگیری است. لطفا کمی بعد دوباره تلاش کنید.",
|
||||
"SubtitleDownloadFailureForItem": "دانلود زیرنویس برای {0} ناموفق بود",
|
||||
"SubtitleDownloadFailureFromForItem": "بارگیری زیرنویس برای {1} از {0} شکست خورد",
|
||||
"Sync": "همگامسازی",
|
||||
"System": "سیستم",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"Shows": "Séries",
|
||||
"Songs": "Chansons",
|
||||
"StartupEmbyServerIsLoading": "Serveur Jellyfin en cours de chargement. Réessayez dans quelques instants.",
|
||||
"SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
|
||||
"SubtitleDownloadFailureFromForItem": "Échec du téléchargement des sous-titres depuis {0} pour {1}",
|
||||
"Sync": "Synchroniser",
|
||||
"System": "Système",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"Shows": "Séries",
|
||||
"Songs": "Chansons",
|
||||
"StartupEmbyServerIsLoading": "Le serveur Jellyfin est en cours de chargement. Veuillez réessayer dans quelques instants.",
|
||||
"SubtitleDownloadFailureForItem": "Le téléchargement des sous-titres pour {0} a échoué.",
|
||||
"SubtitleDownloadFailureFromForItem": "Échec du téléchargement des sous-titres depuis {0} pour {1}",
|
||||
"Sync": "Synchroniser",
|
||||
"System": "Système",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"Shows": "Serie",
|
||||
"Songs": "Lieder",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin Server ladt. Bitte grad noeinisch probiere.",
|
||||
"SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
|
||||
"SubtitleDownloadFailureFromForItem": "Ondertetle vo {0} för {1} hend ned chönne abeglade wärde",
|
||||
"Sync": "Synchronisation",
|
||||
"System": "System",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"Shows": "סדרות",
|
||||
"Songs": "שירים",
|
||||
"StartupEmbyServerIsLoading": "שרת Jellyfin בתהליך טעינה. נא לנסות שוב בקרוב.",
|
||||
"SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
|
||||
"SubtitleDownloadFailureFromForItem": "הורדת כתוביות מ־{0} עבור {1} נכשלה",
|
||||
"Sync": "סנכרון",
|
||||
"System": "מערכת",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"Shows": "Serije",
|
||||
"Songs": "Pjesme",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin server se učitava. Pokušajte ponovo uskoro.",
|
||||
"SubtitleDownloadFailureForItem": "Titlovi prijevoda nisu preuzeti za {0}",
|
||||
"SubtitleDownloadFailureFromForItem": "Prijevod nije uspješno preuzet od {0} za {1}",
|
||||
"Sync": "Sinkronizacija",
|
||||
"System": "Sustav",
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
"NotificationOptionPluginInstalled": "Bővítmény telepítve",
|
||||
"NotificationOptionPluginUninstalled": "Bővítmény eltávolítva",
|
||||
"NotificationOptionPluginUpdateInstalled": "Bővítményfrissítés telepítve",
|
||||
"NotificationOptionServerRestartRequired": "A szerver újraindítása szükséges",
|
||||
"NotificationOptionServerRestartRequired": "A kiszolgáló újraindítása szükséges",
|
||||
"NotificationOptionTaskFailed": "Hiba az ütemezett feladatban",
|
||||
"NotificationOptionUserLockedOut": "Felhasználó tiltva",
|
||||
"NotificationOptionVideoPlayback": "Videólejátszás elkezdve",
|
||||
@@ -73,6 +73,7 @@
|
||||
"Shows": "Sorozatok",
|
||||
"Songs": "Számok",
|
||||
"StartupEmbyServerIsLoading": "A Jellyfin kiszolgáló betöltődik. Próbálja újra hamarosan.",
|
||||
"SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
|
||||
"SubtitleDownloadFailureFromForItem": "Nem sikerült a felirat letöltése innen: {0}, ehhez: {1}",
|
||||
"Sync": "Szinkronizálás",
|
||||
"System": "Rendszer",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"Shows": "Serie TV",
|
||||
"Songs": "Brani",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin server si sta avviando. Per favore riprova più tardi.",
|
||||
"SubtitleDownloadFailureForItem": "Impossibile scaricare i sottotitoli per {0}",
|
||||
"SubtitleDownloadFailureFromForItem": "Impossibile scaricare i sottotitoli da {0} per {1}",
|
||||
"Sync": "Sincronizza",
|
||||
"System": "Sistema",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"Shows": "Körsetımder",
|
||||
"Songs": "Äuender",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin Server jüktelude. Ärekettı köp ūzamai qaitalañyz.",
|
||||
"SubtitleDownloadFailureForItem": "Субтитрлер {0} үшін жүктеліп алынуы сәтсіз",
|
||||
"SubtitleDownloadFailureFromForItem": "{1} üşın subtitrlerdı {0} közınen jüktep alu sätsız",
|
||||
"Sync": "Ündestıru",
|
||||
"System": "Jüie",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"Shows": "시리즈",
|
||||
"Songs": "노래",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin 서버를 불러오고 있습니다. 잠시 후에 다시 시도하십시오.",
|
||||
"SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
|
||||
"SubtitleDownloadFailureFromForItem": "{0}에서 {1} 자막 다운로드에 실패했습니다",
|
||||
"Sync": "동기화",
|
||||
"System": "시스템",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"Shows": "Laidos",
|
||||
"Songs": "Kūriniai",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin Server kraunasi. Netrukus pabandykite dar kartą.",
|
||||
"SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
|
||||
"SubtitleDownloadFailureFromForItem": "{1} subtitrai buvo nesėkmingai parsiųsti iš {0}",
|
||||
"Sync": "Sinchronizuoti",
|
||||
"System": "Sistema",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"Shows": "Tayangan",
|
||||
"Songs": "Lagu-lagu",
|
||||
"StartupEmbyServerIsLoading": "Pelayan Jellyfin sedang dimuatkan. Sila cuba sebentar lagi.",
|
||||
"SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
|
||||
"SubtitleDownloadFailureFromForItem": "Muat turun sarikata gagal dari {0} untuk {1}",
|
||||
"Sync": "Segerak",
|
||||
"System": "Sistem",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"Shows": "Serier",
|
||||
"Songs": "Sanger",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin Server laster. Prøv igjen snart.",
|
||||
"SubtitleDownloadFailureForItem": "En feil oppstå under nedlasting av undertekster for {0}",
|
||||
"SubtitleDownloadFailureFromForItem": "Kunne ikke laste ned undertekster fra {0} for {1}",
|
||||
"Sync": "Synkroniser",
|
||||
"System": "System",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"Shows": "Series",
|
||||
"Songs": "Nummers",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin Server is aan het laden. Probeer het later opnieuw.",
|
||||
"SubtitleDownloadFailureForItem": "Downloaden van ondertiteling voor {0} is mislukt",
|
||||
"SubtitleDownloadFailureFromForItem": "Ondertiteling kon niet gedownload worden van {0} voor {1}",
|
||||
"Sync": "Synchronisatie",
|
||||
"System": "Systeem",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"Shows": "Seriale",
|
||||
"Songs": "Utwory",
|
||||
"StartupEmbyServerIsLoading": "Trwa wczytywanie serwera Jellyfin. Spróbuj ponownie za chwilę.",
|
||||
"SubtitleDownloadFailureForItem": "Pobieranie napisów dla {0} zakończone niepowodzeniem",
|
||||
"SubtitleDownloadFailureFromForItem": "Nieudane pobieranie napisów z {0} dla {1}",
|
||||
"Sync": "Synchronizacja",
|
||||
"System": "System",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"Shows": "Séries",
|
||||
"Songs": "Músicas",
|
||||
"StartupEmbyServerIsLoading": "O Servidor Jellyfin está carregando. Por favor, tente novamente mais tarde.",
|
||||
"SubtitleDownloadFailureForItem": "Download de legendas falhou para {0}",
|
||||
"SubtitleDownloadFailureFromForItem": "Houve um problema ao baixar as legendas de {0} para {1}",
|
||||
"Sync": "Sincronizar",
|
||||
"System": "Sistema",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"Shows": "Séries",
|
||||
"Songs": "Músicas",
|
||||
"StartupEmbyServerIsLoading": "O servidor Jellyfin está a iniciar. Tente novamente mais tarde.",
|
||||
"SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
|
||||
"SubtitleDownloadFailureFromForItem": "Falha na transferência de legendas a partir de {0} para {1}",
|
||||
"Sync": "Sincronização",
|
||||
"System": "Sistema",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"Shows": "Сериалы",
|
||||
"Songs": "Композиции",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin Server загружается. Повторите попытку в ближайшее время.",
|
||||
"SubtitleDownloadFailureForItem": "Субтитры к {0} не удалось загрузить",
|
||||
"SubtitleDownloadFailureFromForItem": "Субтитры к {1} не удалось загрузить с {0}",
|
||||
"Sync": "Синхронизация",
|
||||
"System": "Система",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"Shows": "Seriály",
|
||||
"Songs": "Skladby",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin Server sa spúšťa. Prosím, skúste to o chvíľu znova.",
|
||||
"SubtitleDownloadFailureForItem": "Sťahovanie titulkov pre {0} zlyhalo",
|
||||
"SubtitleDownloadFailureFromForItem": "Sťahovanie titulkov z {0} pre {1} zlyhalo",
|
||||
"Sync": "Synchronizácia",
|
||||
"System": "Systém",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"Shows": "Serije",
|
||||
"Songs": "Pesmi",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin strežnik se zaganja. Poskusite ponovno kasneje.",
|
||||
"SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
|
||||
"SubtitleDownloadFailureFromForItem": "Neuspešen prenos podnapisov iz {0} za {1}",
|
||||
"Sync": "Sinhroniziraj",
|
||||
"System": "Sistem",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"Shows": "Serier",
|
||||
"Songs": "Låtar",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin Server arbetar. Pröva igen snart.",
|
||||
"SubtitleDownloadFailureForItem": "Nerladdning av undertexter för {0} misslyckades",
|
||||
"SubtitleDownloadFailureFromForItem": "Undertexter kunde inte laddas ner från {0} till {1}",
|
||||
"Sync": "Synk",
|
||||
"System": "System",
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"ItemAddedWithName": "{0} kütüphaneye eklendi",
|
||||
"ItemRemovedWithName": "{0} kütüphaneden silindi",
|
||||
"LabelIpAddressValue": "IP adresi: {0}",
|
||||
"LabelRunningTimeValue": "Oynatma süresi: {0}",
|
||||
"LabelRunningTimeValue": "Çalışma süresi: {0}",
|
||||
"Latest": "En son",
|
||||
"MessageApplicationUpdated": "Jellyfin Sunucusu güncellendi",
|
||||
"MessageApplicationUpdatedTo": "Jellyfin Sunucusu {0} sürümüne güncellendi",
|
||||
@@ -42,7 +42,7 @@
|
||||
"MusicVideos": "Müzik Videoları",
|
||||
"NameInstallFailed": "{0} kurulumu başarısız",
|
||||
"NameSeasonNumber": "{0}. Sezon",
|
||||
"NameSeasonUnknown": "Sezon Bilinmiyor",
|
||||
"NameSeasonUnknown": "Bilinmeyen Sezon",
|
||||
"NewVersionIsAvailable": "Jellyfin Sunucusunun yeni bir sürümü indirmek için hazır.",
|
||||
"NotificationOptionApplicationUpdateAvailable": "Uygulama güncellemesi mevcut",
|
||||
"NotificationOptionApplicationUpdateInstalled": "Uygulama güncellemesi yüklendi",
|
||||
@@ -57,7 +57,7 @@
|
||||
"NotificationOptionPluginUpdateInstalled": "Eklenti güncellemesi yüklendi",
|
||||
"NotificationOptionServerRestartRequired": "Sunucunun yeniden başlatılması gerekiyor",
|
||||
"NotificationOptionTaskFailed": "Zamanlanmış görev hatası",
|
||||
"NotificationOptionUserLockedOut": "Kullanıcı hesabı kilitlendi",
|
||||
"NotificationOptionUserLockedOut": "Kullanıcı kilitlendi",
|
||||
"NotificationOptionVideoPlayback": "Video oynatma başladı",
|
||||
"NotificationOptionVideoPlaybackStopped": "Video oynatma durduruldu",
|
||||
"Photos": "Fotoğraflar",
|
||||
@@ -73,7 +73,8 @@
|
||||
"Shows": "Diziler",
|
||||
"Songs": "Şarkılar",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin Sunucusu yükleniyor. Lütfen kısa süre sonra tekrar deneyin.",
|
||||
"SubtitleDownloadFailureFromForItem": "{1} için altyazılar {0} sağlayıcısından indirilemedi",
|
||||
"SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
|
||||
"SubtitleDownloadFailureFromForItem": "{1} için alt yazılar {0} sağlayıcısından indirilemedi",
|
||||
"Sync": "Eşzamanlama",
|
||||
"System": "Sistem",
|
||||
"TvShows": "Diziler",
|
||||
@@ -81,7 +82,7 @@
|
||||
"UserCreatedWithName": "{0} kullanıcısı oluşturuldu",
|
||||
"UserDeletedWithName": "{0} kullanıcısı silindi",
|
||||
"UserDownloadingItemWithValues": "{0} kullanıcısı {1} medyasını indiriyor",
|
||||
"UserLockedOutWithName": "{0} adlı kullanıcı hesabı kilitlendi",
|
||||
"UserLockedOutWithName": "{0} adlı kullanıcı kilitlendi",
|
||||
"UserOfflineFromDevice": "{0} kullanıcısının {1} ile bağlantısı kesildi",
|
||||
"UserOnlineFromDevice": "{0} kullanıcısı {1} ile çevrimiçi",
|
||||
"UserPasswordChangedWithName": "{0} kullanıcısının parolası değiştirildi",
|
||||
@@ -97,8 +98,8 @@
|
||||
"TasksLibraryCategory": "Kütüphane",
|
||||
"TasksMaintenanceCategory": "Bakım",
|
||||
"TaskRefreshPeopleDescription": "Medya kütüphanenizdeki videoların oyuncu ve yönetmen bilgilerini günceller.",
|
||||
"TaskDownloadMissingSubtitlesDescription": "Meta veri yapılandırmasına dayalı olarak eksik altyazılar için internette arama yapar.",
|
||||
"TaskDownloadMissingSubtitles": "Eksik altyazıları indir",
|
||||
"TaskDownloadMissingSubtitlesDescription": "Meta veri yapılandırmasına dayalı olarak eksik alt yazılar için internette arama yapar.",
|
||||
"TaskDownloadMissingSubtitles": "Eksik alt yazıları indir",
|
||||
"TaskRefreshChannelsDescription": "Internet kanal bilgilerini yenile.",
|
||||
"TaskRefreshChannels": "Kanalları Yenile",
|
||||
"TaskCleanTranscodeDescription": "Bir günden daha eski kod dönüştürme dosyalarını siler.",
|
||||
@@ -124,15 +125,15 @@
|
||||
"TaskKeyframeExtractor": "Ana Kare Çıkarıcı",
|
||||
"External": "Harici",
|
||||
"HearingImpaired": "Duyma Engelli",
|
||||
"TaskRefreshTrickplayImages": "Hızlı Önizleme Görsellerini Oluştur",
|
||||
"TaskRefreshTrickplayImagesDescription": "Etkin kütüphanelerdeki videolar için hızlı önizleme görselleri oluşturur.",
|
||||
"TaskRefreshTrickplayImages": "Trickplay Görselleri Oluştur",
|
||||
"TaskRefreshTrickplayImagesDescription": "Etkin kütüphanelerdeki videolar için trickplay önizlemeleri oluşturur.",
|
||||
"TaskCleanCollectionsAndPlaylistsDescription": "Artık var olmayan koleksiyon ve çalma listelerindeki ögeleri kaldırır.",
|
||||
"TaskCleanCollectionsAndPlaylists": "Koleksiyonları ve çalma listelerini temizleyin",
|
||||
"TaskAudioNormalizationDescription": "Ses normalleştirme verileri için dosyaları tarar.",
|
||||
"TaskAudioNormalization": "Ses Normalleştirme",
|
||||
"TaskExtractMediaSegments": "Medya Segmenti Tarama",
|
||||
"TaskMoveTrickplayImages": "Hızlı Önizleme Görsel Konumunu Taşıma",
|
||||
"TaskMoveTrickplayImagesDescription": "Mevcut hızlı önizleme dosyalarını kütüphane ayarlarına göre taşır.",
|
||||
"TaskMoveTrickplayImages": "Trickplay Görsel Konumunu Taşıma",
|
||||
"TaskMoveTrickplayImagesDescription": "Mevcut trickplay dosyalarını kütüphane ayarlarına göre taşır.",
|
||||
"TaskDownloadMissingLyrics": "Eksik şarkı sözlerini indir",
|
||||
"TaskDownloadMissingLyricsDescription": "Şarkı sözlerini indirir",
|
||||
"TaskExtractMediaSegmentsDescription": "MediaSegment özelliği etkin olan eklentilerden medya segmentlerini çıkarır veya alır.",
|
||||
|
||||
@@ -5,60 +5,60 @@
|
||||
"Artists": "艺术家",
|
||||
"AuthenticationSucceededWithUserName": "{0} 认证成功",
|
||||
"Books": "书籍",
|
||||
"CameraImageUploadedFrom": "已从 {0} 上传新的相机照片",
|
||||
"CameraImageUploadedFrom": "新的相机图像已从 {0} 上传",
|
||||
"Channels": "频道",
|
||||
"ChapterNameValue": "章节 {0}",
|
||||
"Collections": "合集",
|
||||
"DeviceOfflineWithName": "{0} 已断开连接",
|
||||
"DeviceOfflineWithName": "{0} 已断开",
|
||||
"DeviceOnlineWithName": "{0} 已连接",
|
||||
"FailedLoginAttemptWithUserName": "来自 {0} 的登录失败",
|
||||
"Favorites": "收藏夹",
|
||||
"FailedLoginAttemptWithUserName": "来自 {0} 的登录尝试失败",
|
||||
"Favorites": "我的最爱",
|
||||
"Folders": "文件夹",
|
||||
"Genres": "类型",
|
||||
"HeaderAlbumArtists": "专辑艺术家",
|
||||
"HeaderContinueWatching": "继续观看",
|
||||
"HeaderFavoriteAlbums": "收藏的专辑",
|
||||
"HeaderFavoriteArtists": "收藏的艺术家",
|
||||
"HeaderFavoriteEpisodes": "收藏的剧集",
|
||||
"HeaderFavoriteShows": "收藏的节目",
|
||||
"HeaderFavoriteSongs": "收藏的歌曲",
|
||||
"HeaderFavoriteArtists": "最爱的艺术家",
|
||||
"HeaderFavoriteEpisodes": "最爱的剧集",
|
||||
"HeaderFavoriteShows": "最爱的节目",
|
||||
"HeaderFavoriteSongs": "最爱的歌曲",
|
||||
"HeaderLiveTV": "电视直播",
|
||||
"HeaderNextUp": "接下来播放",
|
||||
"HeaderNextUp": "接下来",
|
||||
"HeaderRecordingGroups": "录制组",
|
||||
"HomeVideos": "家庭视频",
|
||||
"Inherit": "继承",
|
||||
"ItemAddedWithName": "{0} 已添加到媒体库",
|
||||
"ItemRemovedWithName": "{0} 已从媒体库移除",
|
||||
"ItemRemovedWithName": "{0} 已从媒体库中移除",
|
||||
"LabelIpAddressValue": "IP 地址:{0}",
|
||||
"LabelRunningTimeValue": "运行时间:{0}",
|
||||
"Latest": "最新",
|
||||
"MessageApplicationUpdated": "Jellyfin 服务器已更新",
|
||||
"MessageApplicationUpdatedTo": "Jellyfin 服务器版本已更新到 {0}",
|
||||
"MessageApplicationUpdatedTo": "Jellyfin Server 版本已更新为 {0}",
|
||||
"MessageNamedServerConfigurationUpdatedWithValue": "服务器配置 {0} 部分已更新",
|
||||
"MessageServerConfigurationUpdated": "服务器配置已更新",
|
||||
"MixedContent": "混合内容",
|
||||
"Movies": "电影",
|
||||
"Music": "音乐",
|
||||
"MusicVideos": "MV",
|
||||
"MusicVideos": "音乐视频",
|
||||
"NameInstallFailed": "{0} 安装失败",
|
||||
"NameSeasonNumber": "第 {0} 季",
|
||||
"NameSeasonUnknown": "未知季",
|
||||
"NewVersionIsAvailable": "Jellyfin 服务器有新版本可供下载。",
|
||||
"NewVersionIsAvailable": "Jellyfin Server 有新版本可以下载。",
|
||||
"NotificationOptionApplicationUpdateAvailable": "有可用的应用程序更新",
|
||||
"NotificationOptionApplicationUpdateInstalled": "应用程序更新已安装",
|
||||
"NotificationOptionAudioPlayback": "音频已开始播放",
|
||||
"NotificationOptionAudioPlayback": "音频开始播放",
|
||||
"NotificationOptionAudioPlaybackStopped": "音频播放已停止",
|
||||
"NotificationOptionCameraImageUploaded": "相机照片已上传",
|
||||
"NotificationOptionCameraImageUploaded": "相机图片已上传",
|
||||
"NotificationOptionInstallationFailed": "安装失败",
|
||||
"NotificationOptionNewLibraryContent": "已添加新内容",
|
||||
"NotificationOptionPluginError": "插件出错",
|
||||
"NotificationOptionPluginError": "插件失败",
|
||||
"NotificationOptionPluginInstalled": "插件已安装",
|
||||
"NotificationOptionPluginUninstalled": "插件已卸载",
|
||||
"NotificationOptionPluginUpdateInstalled": "插件已更新",
|
||||
"NotificationOptionPluginUpdateInstalled": "插件更新已安装",
|
||||
"NotificationOptionServerRestartRequired": "服务器需要重启",
|
||||
"NotificationOptionTaskFailed": "计划任务失败",
|
||||
"NotificationOptionUserLockedOut": "用户已被锁定",
|
||||
"NotificationOptionVideoPlayback": "视频已开始播放",
|
||||
"NotificationOptionUserLockedOut": "用户已锁定",
|
||||
"NotificationOptionVideoPlayback": "视频开始播放",
|
||||
"NotificationOptionVideoPlaybackStopped": "视频播放已停止",
|
||||
"Photos": "照片",
|
||||
"Playlists": "播放列表",
|
||||
@@ -72,22 +72,23 @@
|
||||
"ServerNameNeedsToBeRestarted": "{0} 需要重新启动",
|
||||
"Shows": "节目",
|
||||
"Songs": "歌曲",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin 服务器正在启动,请稍后再试。",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin 服务器加载中。请稍后再试。",
|
||||
"SubtitleDownloadFailureForItem": "为 {0} 下载字幕失败",
|
||||
"SubtitleDownloadFailureFromForItem": "无法从 {0} 下载 {1} 的字幕",
|
||||
"Sync": "同步",
|
||||
"System": "系统",
|
||||
"TvShows": "电视剧",
|
||||
"User": "用户",
|
||||
"UserCreatedWithName": "已创建用户 {0}",
|
||||
"UserDeletedWithName": "已删除用户 {0}",
|
||||
"UserCreatedWithName": "用户 {0} 已创建",
|
||||
"UserDeletedWithName": "用户 {0} 已删除",
|
||||
"UserDownloadingItemWithValues": "{0} 正在下载 {1}",
|
||||
"UserLockedOutWithName": "用户 {0} 已被锁定",
|
||||
"UserOfflineFromDevice": "{0} 已从 {1} 断开",
|
||||
"UserOnlineFromDevice": "{0} 已在 {1} 上线",
|
||||
"UserPasswordChangedWithName": "用户 {0} 的密码已更改",
|
||||
"UserPolicyUpdatedWithName": "用户协议已更新为 {0}",
|
||||
"UserStartedPlayingItemWithValues": "{0} 在 {2} 上开始播放 {1}",
|
||||
"UserStoppedPlayingItemWithValues": "{0} 在 {2} 上停止播放 {1}",
|
||||
"UserOnlineFromDevice": "{0} 在线,来自 {1}",
|
||||
"UserPasswordChangedWithName": "已为用户 {0} 更改密码",
|
||||
"UserPolicyUpdatedWithName": "用户协议已经被更新为 {0}",
|
||||
"UserStartedPlayingItemWithValues": "{0} 已在 {2} 上开始播放 {1}",
|
||||
"UserStoppedPlayingItemWithValues": "{0} 已在 {2} 上停止播放 {1}",
|
||||
"ValueHasBeenAddedToLibrary": "{0} 已添加至您的媒体库中",
|
||||
"ValueSpecialEpisodeName": "特典 - {0}",
|
||||
"VersionNumber": "版本 {0}",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"Shows": "節目",
|
||||
"Songs": "歌曲",
|
||||
"StartupEmbyServerIsLoading": "正在載入 Jellyfin,請稍後再試。",
|
||||
"SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
|
||||
"SubtitleDownloadFailureFromForItem": "無法從 {0} 下載 {1} 的字幕",
|
||||
"Sync": "同步",
|
||||
"System": "系統",
|
||||
|
||||
@@ -793,16 +793,6 @@ namespace Emby.Server.Implementations.Session
|
||||
PlaySessionId = info.PlaySessionId
|
||||
};
|
||||
|
||||
if (info.Item is not null)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"User {0} started playback of '{1}' ({2} {3})",
|
||||
session.UserName,
|
||||
info.Item.Name,
|
||||
session.Client,
|
||||
session.ApplicationVersion);
|
||||
}
|
||||
|
||||
await _eventManager.PublishAsync(eventArgs).ConfigureAwait(false);
|
||||
|
||||
// Nothing to save here
|
||||
@@ -1070,12 +1060,11 @@ namespace Emby.Server.Implementations.Session
|
||||
var msString = info.PositionTicks.HasValue ? (info.PositionTicks.Value / 10000).ToString(CultureInfo.InvariantCulture) : "unknown";
|
||||
|
||||
_logger.LogInformation(
|
||||
"User {0} stopped playback of '{1}' at {2}ms ({3} {4})",
|
||||
session.UserName,
|
||||
info.Item.Name,
|
||||
msString,
|
||||
"Playback stopped reported by app {0} {1} playing {2}. Stopped at {3} ms",
|
||||
session.Client,
|
||||
session.ApplicationVersion);
|
||||
session.ApplicationVersion,
|
||||
info.Item.Name,
|
||||
msString);
|
||||
}
|
||||
|
||||
if (info.NowPlayingQueue is not null)
|
||||
@@ -1186,8 +1175,7 @@ namespace Emby.Server.Implementations.Session
|
||||
return session;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public SessionInfoDto ToSessionInfoDto(SessionInfo sessionInfo)
|
||||
private SessionInfoDto ToSessionInfoDto(SessionInfo sessionInfo)
|
||||
{
|
||||
return new SessionInfoDto
|
||||
{
|
||||
|
||||
@@ -3,6 +3,8 @@ using System.ComponentModel.DataAnnotations;
|
||||
using System.Net.Mime;
|
||||
using System.Text.Json;
|
||||
using Jellyfin.Api.Attributes;
|
||||
using Jellyfin.Api.Constants;
|
||||
using Jellyfin.Api.Models.ConfigurationDtos;
|
||||
using Jellyfin.Extensions.Json;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
@@ -141,4 +143,22 @@ public class ConfigurationController : BaseJellyfinApiController
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the path to the media encoder.
|
||||
/// </summary>
|
||||
/// <param name="mediaEncoderPath">Media encoder path form body.</param>
|
||||
/// <response code="204">Media encoder path updated.</response>
|
||||
/// <returns>Status.</returns>
|
||||
[Obsolete("This endpoint is obsolete.")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
[HttpPost("MediaEncoder/Path")]
|
||||
[Authorize(Policy = Policies.FirstTimeSetupOrElevated)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public ActionResult UpdateMediaEncoderPath([FromBody, Required] MediaEncoderPathDto mediaEncoderPath)
|
||||
{
|
||||
// API ENDPOINT DISABLED (NOOP) FOR SECURITY PURPOSES
|
||||
// _mediaEncoder.UpdateEncoderPath(mediaEncoderPath.Path, mediaEncoderPath.PathType);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1400,20 +1400,10 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
cancellationTokenSource.Token)
|
||||
.ConfigureAwait(false);
|
||||
var mediaSourceId = state.BaseRequest.MediaSourceId;
|
||||
double fps = state.TargetFramerate ?? 0.0f;
|
||||
int segmentLength = state.SegmentLength * 1000;
|
||||
|
||||
// If framerate is fractional (i.e. 23.976), we need to slightly adjust segment length
|
||||
if (Math.Abs(fps - Math.Floor(fps + 0.001f)) > 0.001)
|
||||
{
|
||||
double nearestIntFramerate = Math.Ceiling(fps);
|
||||
segmentLength = (int)Math.Ceiling(segmentLength * (nearestIntFramerate / fps));
|
||||
}
|
||||
|
||||
var request = new CreateMainPlaylistRequest(
|
||||
mediaSourceId is null ? null : Guid.Parse(mediaSourceId),
|
||||
state.MediaPath,
|
||||
segmentLength,
|
||||
state.SegmentLength * 1000,
|
||||
state.RunTimeTicks ?? 0,
|
||||
state.Request.SegmentContainer ?? string.Empty,
|
||||
"hls1/main/",
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Jellyfin.Api.Constants;
|
||||
using Jellyfin.Api.Models.EnvironmentDtos;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
@@ -127,6 +128,20 @@ public class EnvironmentController : BaseJellyfinApiController
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets network paths.
|
||||
/// </summary>
|
||||
/// <response code="200">Empty array returned.</response>
|
||||
/// <returns>List of entries.</returns>
|
||||
[Obsolete("This endpoint is obsolete.")]
|
||||
[HttpGet("NetworkShares")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<IEnumerable<FileSystemEntryInfo>> GetNetworkShares()
|
||||
{
|
||||
_logger.LogWarning("Obsolete endpoint accessed: /Environment/NetworkShares");
|
||||
return Array.Empty<FileSystemEntryInfo>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets available drives from the server's file system.
|
||||
/// </summary>
|
||||
|
||||
@@ -65,6 +65,16 @@ public class QuickConnectController : BaseJellyfinApiController
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Old version of <see cref="InitiateQuickConnect" /> using a GET method.
|
||||
/// Still available to avoid breaking compatibility.
|
||||
/// </summary>
|
||||
/// <returns>The result of <see cref="InitiateQuickConnect" />.</returns>
|
||||
[Obsolete("Use POST request instead")]
|
||||
[HttpGet("Initiate")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public Task<ActionResult<QuickConnectResult>> InitiateQuickConnectLegacy() => InitiateQuickConnect();
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to retrieve authentication information.
|
||||
/// </summary>
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using Jellyfin.Api.Attributes;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Api.ModelBinders;
|
||||
@@ -68,6 +69,7 @@ public class TvShowsController : BaseJellyfinApiController
|
||||
/// <param name="enableUserData">Optional. Include user data.</param>
|
||||
/// <param name="nextUpDateCutoff">Optional. Starting date of shows to show in Next Up section.</param>
|
||||
/// <param name="enableTotalRecordCount">Whether to enable the total records count. Defaults to true.</param>
|
||||
/// <param name="disableFirstEpisode">Whether to disable sending the first episode in a series as next up.</param>
|
||||
/// <param name="enableResumable">Whether to include resumable episodes in next up results.</param>
|
||||
/// <param name="enableRewatching">Whether to include watched episodes in next up results.</param>
|
||||
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the next up episodes.</returns>
|
||||
@@ -86,6 +88,7 @@ public class TvShowsController : BaseJellyfinApiController
|
||||
[FromQuery] bool? enableUserData,
|
||||
[FromQuery] DateTime? nextUpDateCutoff,
|
||||
[FromQuery] bool enableTotalRecordCount = true,
|
||||
[FromQuery][ParameterObsolete] bool disableFirstEpisode = false,
|
||||
[FromQuery] bool enableResumable = true,
|
||||
[FromQuery] bool enableRewatching = false)
|
||||
{
|
||||
|
||||
@@ -337,6 +337,29 @@ public class UserController : BaseJellyfinApiController
|
||||
[FromBody, Required] UpdateUserPassword request)
|
||||
=> UpdateUserPassword(userId, request);
|
||||
|
||||
/// <summary>
|
||||
/// Updates a user's easy password.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user id.</param>
|
||||
/// <param name="request">The <see cref="UpdateUserEasyPassword"/> request.</param>
|
||||
/// <response code="204">Password successfully reset.</response>
|
||||
/// <response code="403">User is not allowed to update the password.</response>
|
||||
/// <response code="404">User not found.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="ForbidResult"/> or a <see cref="NotFoundResult"/> on failure.</returns>
|
||||
[HttpPost("{userId}/EasyPassword")]
|
||||
[Obsolete("Use Quick Connect instead")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult UpdateUserEasyPassword(
|
||||
[FromRoute, Required] Guid userId,
|
||||
[FromBody, Required] UpdateUserEasyPassword request)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a user.
|
||||
/// </summary>
|
||||
|
||||
17
Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs
Normal file
17
Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace Jellyfin.Api.Models.ConfigurationDtos;
|
||||
|
||||
/// <summary>
|
||||
/// Media Encoder Path Dto.
|
||||
/// </summary>
|
||||
public class MediaEncoderPathDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets media encoder path.
|
||||
/// </summary>
|
||||
public string Path { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets media encoder path type.
|
||||
/// </summary>
|
||||
public string PathType { get; set; } = null!;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Jellyfin.Api.Models.StartupDtos;
|
||||
@@ -12,4 +13,11 @@ public class StartupRemoteAccessDto
|
||||
/// </summary>
|
||||
[Required]
|
||||
public bool EnableRemoteAccess { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether enable automatic port mapping.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Obsolete("No longer supported")]
|
||||
public bool EnableAutomaticPortMapping { get; set; }
|
||||
}
|
||||
|
||||
22
Jellyfin.Api/Models/UserDtos/UpdateUserEasyPassword.cs
Normal file
22
Jellyfin.Api/Models/UserDtos/UpdateUserEasyPassword.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
namespace Jellyfin.Api.Models.UserDtos;
|
||||
|
||||
/// <summary>
|
||||
/// The update user easy password request body.
|
||||
/// </summary>
|
||||
public class UpdateUserEasyPassword
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the new sha1-hashed password.
|
||||
/// </summary>
|
||||
public string? NewPassword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the new password.
|
||||
/// </summary>
|
||||
public string? NewPw { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to reset the password.
|
||||
/// </summary>
|
||||
public bool ResetPassword { get; set; }
|
||||
}
|
||||
@@ -7,7 +7,6 @@ using MediaBrowser.Controller.Authentication;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Net;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Session;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -16,7 +15,7 @@ namespace Jellyfin.Api.WebSocketListeners;
|
||||
/// <summary>
|
||||
/// Class SessionInfoWebSocketListener.
|
||||
/// </summary>
|
||||
public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnumerable<SessionInfoDto>, WebSocketListenerState>
|
||||
public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnumerable<SessionInfo>, WebSocketListenerState>
|
||||
{
|
||||
private readonly ISessionManager _sessionManager;
|
||||
private bool _disposed;
|
||||
@@ -53,26 +52,24 @@ public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnume
|
||||
/// Gets the data to send.
|
||||
/// </summary>
|
||||
/// <returns>Task{SystemInfo}.</returns>
|
||||
protected override Task<IEnumerable<SessionInfoDto>> GetDataToSend()
|
||||
protected override Task<IEnumerable<SessionInfo>> GetDataToSend()
|
||||
{
|
||||
return Task.FromResult(_sessionManager.Sessions.Select(_sessionManager.ToSessionInfoDto));
|
||||
return Task.FromResult(_sessionManager.Sessions);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task<IEnumerable<SessionInfoDto>> GetDataToSendForConnection(IWebSocketConnection connection)
|
||||
protected override Task<IEnumerable<SessionInfo>> GetDataToSendForConnection(IWebSocketConnection connection)
|
||||
{
|
||||
var sessions = _sessionManager.Sessions;
|
||||
|
||||
// For non-admin users, filter the sessions to only include their own sessions
|
||||
if (connection.AuthorizationInfo?.User is not null &&
|
||||
!connection.AuthorizationInfo.IsApiKey &&
|
||||
!connection.AuthorizationInfo.User.HasPermission(PermissionKind.IsAdministrator))
|
||||
{
|
||||
var userId = connection.AuthorizationInfo.User.Id;
|
||||
sessions = sessions.Where(s => s.UserId.Equals(userId) || s.ContainsUser(userId));
|
||||
return Task.FromResult(_sessionManager.Sessions.Where(s => s.UserId.Equals(userId) || s.ContainsUser(userId)));
|
||||
}
|
||||
|
||||
return Task.FromResult(sessions.Select(_sessionManager.ToSessionInfoDto));
|
||||
return Task.FromResult(_sessionManager.Sessions);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -295,25 +295,6 @@ public sealed class BaseItemRepository
|
||||
|
||||
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
|
||||
dbQuery = ApplyQueryPaging(dbQuery, filter);
|
||||
|
||||
var hasRandomSort = filter.OrderBy.Any(e => e.OrderBy == ItemSortBy.Random);
|
||||
if (hasRandomSort)
|
||||
{
|
||||
var orderedIds = dbQuery.Select(e => e.Id).ToList();
|
||||
if (orderedIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<BaseItemDto>();
|
||||
}
|
||||
|
||||
var itemsById = ApplyNavigations(context.BaseItems.Where(e => orderedIds.Contains(e.Id)), filter)
|
||||
.AsEnumerable()
|
||||
.Select(w => DeserializeBaseItem(w, filter.SkipDeserialization))
|
||||
.Where(dto => dto is not null)
|
||||
.ToDictionary(i => i!.Id);
|
||||
|
||||
return orderedIds.Where(itemsById.ContainsKey).Select(id => itemsById[id]).ToArray()!;
|
||||
}
|
||||
|
||||
dbQuery = ApplyNavigations(dbQuery, filter);
|
||||
|
||||
return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).Where(dto => dto is not null).ToArray()!;
|
||||
@@ -781,30 +762,16 @@ public sealed class BaseItemRepository
|
||||
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (transaction.ConfigureAwait(false))
|
||||
{
|
||||
var userKeys = item.GetUserDataKeys().ToArray();
|
||||
var retentionDate = (DateTime?)null;
|
||||
|
||||
await dbContext.UserData
|
||||
.Where(e => e.ItemId == PlaceholderId)
|
||||
.Where(e => userKeys.Contains(e.CustomDataKey))
|
||||
.ExecuteUpdateAsync(
|
||||
e => e
|
||||
.SetProperty(f => f.ItemId, item.Id)
|
||||
.SetProperty(f => f.RetentionDate, retentionDate),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Rehydrate the cached userdata
|
||||
item.UserData = await dbContext.UserData
|
||||
.AsNoTracking()
|
||||
.Where(e => e.ItemId == item.Id)
|
||||
.ToArrayAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
var userKeys = item.GetUserDataKeys().ToArray();
|
||||
var retentionDate = (DateTime?)null;
|
||||
await dbContext.UserData
|
||||
.Where(e => e.ItemId == PlaceholderId)
|
||||
.Where(e => userKeys.Contains(e.CustomDataKey))
|
||||
.ExecuteUpdateAsync(
|
||||
e => e
|
||||
.SetProperty(f => f.ItemId, item.Id)
|
||||
.SetProperty(f => f.RetentionDate, retentionDate),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2715,21 +2682,6 @@ public sealed class BaseItemRepository
|
||||
.Where(e => artistNames.Contains(e.Name))
|
||||
.ToArray();
|
||||
|
||||
var lookup = artists
|
||||
.GroupBy(e => e.Name!)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.Select(f => DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray());
|
||||
|
||||
var result = new Dictionary<string, MusicArtist[]>(artistNames.Count);
|
||||
foreach (var name in artistNames)
|
||||
{
|
||||
if (lookup.TryGetValue(name, out var artistArray))
|
||||
{
|
||||
result[name] = artistArray;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return artists.GroupBy(e => e.Name).ToDictionary(e => e.Key!, e => e.Select(f => DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,12 +350,5 @@ namespace MediaBrowser.Controller.Session
|
||||
/// <param name="sessionIdOrPlaySessionId">The session id or playsession id.</param>
|
||||
/// <returns>Task.</returns>
|
||||
Task CloseLiveStreamIfNeededAsync(string liveStreamId, string sessionIdOrPlaySessionId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the dto for session info.
|
||||
/// </summary>
|
||||
/// <param name="sessionInfo">The session info.</param>
|
||||
/// <returns><see cref="SessionInfoDto"/> of the session.</returns>
|
||||
SessionInfoDto ToSessionInfoDto(SessionInfo sessionInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,25 +172,23 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
|
||||
private async Task<Stream> GetSubtitleStream(SubtitleInfo fileInfo, CancellationToken cancellationToken)
|
||||
{
|
||||
if (fileInfo.Protocol == MediaProtocol.Http)
|
||||
if (fileInfo.IsExternal)
|
||||
{
|
||||
var result = await DetectCharset(fileInfo.Path, fileInfo.Protocol, cancellationToken).ConfigureAwait(false);
|
||||
var detected = result.Detected;
|
||||
|
||||
if (detected is not null)
|
||||
var stream = await GetStream(fileInfo.Path, fileInfo.Protocol, cancellationToken).ConfigureAwait(false);
|
||||
await using (stream.ConfigureAwait(false))
|
||||
{
|
||||
_logger.LogDebug("charset {CharSet} detected for {Path}", detected.EncodingName, fileInfo.Path);
|
||||
var result = await CharsetDetector.DetectFromStreamAsync(stream, cancellationToken).ConfigureAwait(false);
|
||||
var detected = result.Detected;
|
||||
stream.Position = 0;
|
||||
|
||||
using var stream = await _httpClientFactory.CreateClient(NamedClient.Default)
|
||||
.GetStreamAsync(new Uri(fileInfo.Path), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await using (stream.ConfigureAwait(false))
|
||||
if (detected is not null)
|
||||
{
|
||||
using var reader = new StreamReader(stream, detected.Encoding);
|
||||
var text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogDebug("charset {CharSet} detected for {Path}", detected.EncodingName, fileInfo.Path);
|
||||
|
||||
return new MemoryStream(Encoding.UTF8.GetBytes(text));
|
||||
using var reader = new StreamReader(stream, detected.Encoding);
|
||||
var text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new MemoryStream(Encoding.UTF8.GetBytes(text));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -220,7 +218,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
};
|
||||
}
|
||||
|
||||
var currentFormat = subtitleStream.Codec ?? Path.GetExtension(subtitleStream.Path)
|
||||
var currentFormat = (Path.GetExtension(subtitleStream.Path) ?? subtitleStream.Codec)
|
||||
.TrimStart('.');
|
||||
|
||||
// Handle PGS subtitles as raw streams for the client to render
|
||||
@@ -943,44 +941,42 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var result = await DetectCharset(path, mediaSource.Protocol, cancellationToken).ConfigureAwait(false);
|
||||
var charset = result.Detected?.EncodingName ?? string.Empty;
|
||||
|
||||
// UTF16 is automatically converted to UTF8 by FFmpeg, do not specify a character encoding
|
||||
if ((path.EndsWith(".ass", StringComparison.Ordinal) || path.EndsWith(".ssa", StringComparison.Ordinal) || path.EndsWith(".srt", StringComparison.Ordinal))
|
||||
&& (string.Equals(charset, "utf-16le", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(charset, "utf-16be", StringComparison.OrdinalIgnoreCase)))
|
||||
var stream = await GetStream(path, mediaSource.Protocol, cancellationToken).ConfigureAwait(false);
|
||||
await using (stream.ConfigureAwait(false))
|
||||
{
|
||||
charset = string.Empty;
|
||||
var result = await CharsetDetector.DetectFromStreamAsync(stream, cancellationToken).ConfigureAwait(false);
|
||||
var charset = result.Detected?.EncodingName ?? string.Empty;
|
||||
|
||||
// UTF16 is automatically converted to UTF8 by FFmpeg, do not specify a character encoding
|
||||
if ((path.EndsWith(".ass", StringComparison.Ordinal) || path.EndsWith(".ssa", StringComparison.Ordinal) || path.EndsWith(".srt", StringComparison.Ordinal))
|
||||
&& (string.Equals(charset, "utf-16le", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(charset, "utf-16be", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
charset = string.Empty;
|
||||
}
|
||||
|
||||
_logger.LogDebug("charset {0} detected for {Path}", charset, path);
|
||||
|
||||
return charset;
|
||||
}
|
||||
|
||||
_logger.LogDebug("charset {0} detected for {Path}", charset, path);
|
||||
|
||||
return charset;
|
||||
}
|
||||
|
||||
private async Task<DetectionResult> DetectCharset(string path, MediaProtocol protocol, CancellationToken cancellationToken)
|
||||
private async Task<Stream> GetStream(string path, MediaProtocol protocol, CancellationToken cancellationToken)
|
||||
{
|
||||
switch (protocol)
|
||||
{
|
||||
case MediaProtocol.Http:
|
||||
{
|
||||
using var stream = await _httpClientFactory
|
||||
.CreateClient(NamedClient.Default)
|
||||
.GetStreamAsync(new Uri(path), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return await CharsetDetector.DetectFromStreamAsync(stream, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
{
|
||||
using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
|
||||
.GetAsync(new Uri(path), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
case MediaProtocol.File:
|
||||
{
|
||||
return await CharsetDetector.DetectFromFileAsync(path, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return AsyncFile.OpenRead(path);
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(protocol), protocol, "Unsupported protocol");
|
||||
throw new ArgumentOutOfRangeException(nameof(protocol));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1252,11 +1252,11 @@ public class StreamInfo
|
||||
stream.Index.ToString(CultureInfo.InvariantCulture),
|
||||
startPositionTicks.ToString(CultureInfo.InvariantCulture),
|
||||
subtitleProfile.Format);
|
||||
info.IsExternalUrl = false;
|
||||
info.IsExternalUrl = false; // Default to API URL
|
||||
|
||||
// Check conditions for potentially using the direct path
|
||||
if (stream.IsExternal // Must be external
|
||||
&& stream.SupportsExternalStream
|
||||
&& MediaSource?.Protocol != MediaProtocol.File // Main media must not be a local file
|
||||
&& string.Equals(stream.Codec, subtitleProfile.Format, StringComparison.OrdinalIgnoreCase) // Format must match (no conversion needed)
|
||||
&& !string.IsNullOrEmpty(stream.Path) // Path must exist
|
||||
&& Uri.TryCreate(stream.Path, UriKind.Absolute, out Uri? uriResult) // Path must be an absolute URI
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using Jellyfin.Data.Enums;
|
||||
using MediaBrowser.Model.Dlna;
|
||||
|
||||
@@ -30,5 +31,15 @@ namespace MediaBrowser.Model.Session
|
||||
public string AppStoreUrl { get; set; }
|
||||
|
||||
public string IconUrl { get; set; }
|
||||
|
||||
// TODO: Remove after 10.9
|
||||
[Obsolete("Unused")]
|
||||
[DefaultValue(false)]
|
||||
public bool? SupportsContentUploading { get; set; } = false;
|
||||
|
||||
// TODO: Remove after 10.9
|
||||
[Obsolete("Unused")]
|
||||
[DefaultValue(false)]
|
||||
public bool? SupportsSync { get; set; } = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Xml;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MediaBrowser.Providers.Books.OpenPackagingFormat
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides the primary image for EPUB items that have embedded covers.
|
||||
/// </summary>
|
||||
public class EpubImageProvider : IDynamicImageProvider
|
||||
{
|
||||
private readonly ILogger<EpubImageProvider> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="EpubImageProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">Instance of the <see cref="ILogger{EpubImageProvider}"/> interface.</param>
|
||||
public EpubImageProvider(ILogger<EpubImageProvider> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "EPUB Metadata";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Supports(BaseItem item)
|
||||
{
|
||||
return item is Book;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
|
||||
{
|
||||
yield return ImageType.Primary;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DynamicImageResponse> GetImage(BaseItem item, ImageType type, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.Equals(Path.GetExtension(item.Path), ".epub", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return GetFromZip(item, cancellationToken);
|
||||
}
|
||||
|
||||
return Task.FromResult(new DynamicImageResponse { HasImage = false });
|
||||
}
|
||||
|
||||
private async Task<DynamicImageResponse> LoadCover(ZipArchive epub, XmlDocument opf, string opfRootDirectory, CancellationToken cancellationToken)
|
||||
{
|
||||
var utilities = new OpfReader<EpubImageProvider>(opf, _logger);
|
||||
var coverReference = utilities.ReadCoverPath(opfRootDirectory);
|
||||
if (coverReference == null)
|
||||
{
|
||||
return new DynamicImageResponse { HasImage = false };
|
||||
}
|
||||
|
||||
var cover = coverReference.Value;
|
||||
var coverFile = epub.GetEntry(cover.Path);
|
||||
|
||||
if (coverFile == null)
|
||||
{
|
||||
return new DynamicImageResponse { HasImage = false };
|
||||
}
|
||||
|
||||
var memoryStream = new MemoryStream();
|
||||
|
||||
var coverStream = await coverFile.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (coverStream.ConfigureAwait(false))
|
||||
{
|
||||
await coverStream.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
memoryStream.Position = 0;
|
||||
|
||||
var response = new DynamicImageResponse { HasImage = true, Stream = memoryStream };
|
||||
response.SetFormatFromMimeType(cover.MimeType);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private async Task<DynamicImageResponse> GetFromZip(BaseItem item, CancellationToken cancellationToken)
|
||||
{
|
||||
using var epub = await ZipFile.OpenReadAsync(item.Path, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var opfFilePath = EpubUtils.ReadContentFilePath(epub);
|
||||
if (opfFilePath == null)
|
||||
{
|
||||
return new DynamicImageResponse { HasImage = false };
|
||||
}
|
||||
|
||||
var opfRootDirectory = Path.GetDirectoryName(opfFilePath);
|
||||
if (opfRootDirectory == null)
|
||||
{
|
||||
return new DynamicImageResponse { HasImage = false };
|
||||
}
|
||||
|
||||
var opfFile = epub.GetEntry(opfFilePath);
|
||||
if (opfFile == null)
|
||||
{
|
||||
return new DynamicImageResponse { HasImage = false };
|
||||
}
|
||||
|
||||
using var opfStream = await opfFile.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var opfDocument = new XmlDocument();
|
||||
opfDocument.Load(opfStream);
|
||||
|
||||
return await LoadCover(epub, opfDocument, opfRootDirectory, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Xml;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.IO;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MediaBrowser.Providers.Books.OpenPackagingFormat
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides book metadata from OPF content in an EPUB item.
|
||||
/// </summary>
|
||||
public class EpubProvider : ILocalMetadataProvider<Book>
|
||||
{
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly ILogger<EpubProvider> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="EpubProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
|
||||
/// <param name="logger">Instance of the <see cref="ILogger{EpubProvider}"/> interface.</param>
|
||||
public EpubProvider(IFileSystem fileSystem, ILogger<EpubProvider> logger)
|
||||
{
|
||||
_fileSystem = fileSystem;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "EPUB Metadata";
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<MetadataResult<Book>> GetMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken)
|
||||
{
|
||||
var path = GetEpubFile(info.Path)?.FullName;
|
||||
|
||||
if (path is null)
|
||||
{
|
||||
return Task.FromResult(new MetadataResult<Book> { HasMetadata = false });
|
||||
}
|
||||
|
||||
var result = ReadEpubAsZip(path, cancellationToken);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
return Task.FromResult(new MetadataResult<Book> { HasMetadata = false });
|
||||
}
|
||||
else
|
||||
{
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
|
||||
private FileSystemMetadata? GetEpubFile(string path)
|
||||
{
|
||||
var fileInfo = _fileSystem.GetFileSystemInfo(path);
|
||||
|
||||
if (fileInfo.IsDirectory)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!string.Equals(Path.GetExtension(fileInfo.FullName), ".epub", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return fileInfo;
|
||||
}
|
||||
|
||||
private MetadataResult<Book>? ReadEpubAsZip(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
using var epub = ZipFile.OpenRead(path);
|
||||
|
||||
var opfFilePath = EpubUtils.ReadContentFilePath(epub);
|
||||
if (opfFilePath == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var opf = epub.GetEntry(opfFilePath);
|
||||
if (opf == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var opfStream = opf.Open();
|
||||
|
||||
var opfDocument = new XmlDocument();
|
||||
opfDocument.Load(opfStream);
|
||||
|
||||
var utilities = new OpfReader<EpubProvider>(opfDocument, _logger);
|
||||
return utilities.ReadOpfData(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace MediaBrowser.Providers.Books.OpenPackagingFormat
|
||||
{
|
||||
/// <summary>
|
||||
/// Utilities for EPUB files.
|
||||
/// </summary>
|
||||
public static class EpubUtils
|
||||
{
|
||||
/// <summary>
|
||||
/// Attempt to read content from ZIP archive.
|
||||
/// </summary>
|
||||
/// <param name="epub">The ZIP archive.</param>
|
||||
/// <returns>The content file path.</returns>
|
||||
public static string? ReadContentFilePath(ZipArchive epub)
|
||||
{
|
||||
var container = epub.GetEntry(Path.Combine("META-INF", "container.xml"));
|
||||
if (container == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var containerStream = container.Open();
|
||||
|
||||
XNamespace containerNamespace = "urn:oasis:names:tc:opendocument:xmlns:container";
|
||||
var containerDocument = XDocument.Load(containerStream);
|
||||
var element = containerDocument.Descendants(containerNamespace + "rootfile").FirstOrDefault();
|
||||
|
||||
return element?.Attribute("full-path")?.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Xml;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.IO;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MediaBrowser.Providers.Books.OpenPackagingFormat
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides metadata for book items that have an OPF file in the same directory. Supports the standard
|
||||
/// content.opf filename, bespoke metadata.opf name from Calibre libraries, and OPF files that have the
|
||||
/// same name as their respective books for directories with several books.
|
||||
/// </summary>
|
||||
public class OpfProvider : ILocalMetadataProvider<Book>, IHasItemChangeMonitor
|
||||
{
|
||||
private const string StandardOpfFile = "content.opf";
|
||||
private const string CalibreOpfFile = "metadata.opf";
|
||||
|
||||
private readonly IFileSystem _fileSystem;
|
||||
|
||||
private readonly ILogger<OpfProvider> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="OpfProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
|
||||
/// <param name="logger">Instance of the <see cref="ILogger{OpfProvider}"/> interface.</param>
|
||||
public OpfProvider(IFileSystem fileSystem, ILogger<OpfProvider> logger)
|
||||
{
|
||||
_fileSystem = fileSystem;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Open Packaging Format";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool HasChanged(BaseItem item, IDirectoryService directoryService)
|
||||
{
|
||||
var file = GetXmlFile(item.Path);
|
||||
|
||||
return file.Exists && _fileSystem.GetLastWriteTimeUtc(file) > item.DateLastSaved;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<MetadataResult<Book>> GetMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken)
|
||||
{
|
||||
var path = GetXmlFile(info.Path).FullName;
|
||||
|
||||
try
|
||||
{
|
||||
return Task.FromResult(ReadOpfData(path, cancellationToken));
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
return Task.FromResult(new MetadataResult<Book> { HasMetadata = false });
|
||||
}
|
||||
}
|
||||
|
||||
private FileSystemMetadata GetXmlFile(string path)
|
||||
{
|
||||
var fileInfo = _fileSystem.GetFileSystemInfo(path);
|
||||
var directoryInfo = fileInfo.IsDirectory ? fileInfo : _fileSystem.GetDirectoryInfo(Path.GetDirectoryName(path)!);
|
||||
|
||||
// check for OPF with matching name first since it's the most specific filename
|
||||
var specificFile = Path.Combine(directoryInfo.FullName, Path.GetFileNameWithoutExtension(path) + ".opf");
|
||||
var file = _fileSystem.GetFileInfo(specificFile);
|
||||
|
||||
if (file.Exists)
|
||||
{
|
||||
return file;
|
||||
}
|
||||
|
||||
file = _fileSystem.GetFileInfo(Path.Combine(directoryInfo.FullName, StandardOpfFile));
|
||||
|
||||
// check metadata.opf last since it's really only used by Calibre
|
||||
return file.Exists ? file : _fileSystem.GetFileInfo(Path.Combine(directoryInfo.FullName, CalibreOpfFile));
|
||||
}
|
||||
|
||||
private MetadataResult<Book> ReadOpfData(string file, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var doc = new XmlDocument();
|
||||
doc.Load(file);
|
||||
|
||||
var utilities = new OpfReader<OpfProvider>(doc, _logger);
|
||||
return utilities.ReadOpfData(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Xml;
|
||||
using Jellyfin.Data.Enums;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Net;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MediaBrowser.Providers.Books.OpenPackagingFormat
|
||||
{
|
||||
/// <summary>
|
||||
/// Methods used to pull metadata and other information from Open Packaging Format in XML objects.
|
||||
/// </summary>
|
||||
/// <typeparam name="TCategoryName">The type of category.</typeparam>
|
||||
public class OpfReader<TCategoryName>
|
||||
{
|
||||
private const string DcNamespace = @"http://purl.org/dc/elements/1.1/";
|
||||
private const string OpfNamespace = @"http://www.idpf.org/2007/opf";
|
||||
|
||||
private readonly XmlNamespaceManager _namespaceManager;
|
||||
private readonly XmlDocument _document;
|
||||
|
||||
private readonly ILogger<TCategoryName> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="OpfReader{TCategoryName}"/> class.
|
||||
/// </summary>
|
||||
/// <param name="document">The XML document to parse.</param>
|
||||
/// <param name="logger">Instance of the <see cref="ILogger{TCategoryName}"/> interface.</param>
|
||||
public OpfReader(XmlDocument document, ILogger<TCategoryName> logger)
|
||||
{
|
||||
_document = document;
|
||||
_logger = logger;
|
||||
_namespaceManager = new XmlNamespaceManager(_document.NameTable);
|
||||
|
||||
_namespaceManager.AddNamespace("dc", DcNamespace);
|
||||
_namespaceManager.AddNamespace("opf", OpfNamespace);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks for the existence of a cover image.
|
||||
/// </summary>
|
||||
/// <param name="opfRootDirectory">The root directory in which the OPF file is located.</param>
|
||||
/// <returns>Returns the found cover and its type or null.</returns>
|
||||
public (string MimeType, string Path)? ReadCoverPath(string opfRootDirectory)
|
||||
{
|
||||
var coverImage = ReadEpubCoverInto(opfRootDirectory, "//opf:item[@properties='cover-image']");
|
||||
if (coverImage is not null)
|
||||
{
|
||||
return coverImage;
|
||||
}
|
||||
|
||||
var coverId = ReadEpubCoverInto(opfRootDirectory, "//opf:item[@id='cover' and @media-type='image/*']");
|
||||
if (coverId is not null)
|
||||
{
|
||||
return coverId;
|
||||
}
|
||||
|
||||
var coverImageId = ReadEpubCoverInto(opfRootDirectory, "//opf:item[@id='*cover-image']");
|
||||
if (coverImageId is not null)
|
||||
{
|
||||
return coverImageId;
|
||||
}
|
||||
|
||||
var metaCoverImage = _document.SelectSingleNode("//opf:meta[@name='cover']", _namespaceManager);
|
||||
var content = metaCoverImage?.Attributes?["content"]?.Value;
|
||||
if (string.IsNullOrEmpty(content) || metaCoverImage is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var coverPath = Path.Combine("Images", content);
|
||||
var coverFileManifest = _document.SelectSingleNode($"//opf:item[@href='{coverPath}']", _namespaceManager);
|
||||
var mediaType = coverFileManifest?.Attributes?["media-type"]?.Value;
|
||||
if (coverFileManifest?.Attributes is not null && !string.IsNullOrEmpty(mediaType) && IsValidImage(mediaType))
|
||||
{
|
||||
return (mediaType, Path.Combine(opfRootDirectory, coverPath));
|
||||
}
|
||||
|
||||
var coverFileIdManifest = _document.SelectSingleNode($"//opf:item[@id='{content}']", _namespaceManager);
|
||||
if (coverFileIdManifest is not null)
|
||||
{
|
||||
return ReadManifestItem(coverFileIdManifest, opfRootDirectory);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read all supported OPF data from the file.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The metadata result to update.</returns>
|
||||
public MetadataResult<Book> ReadOpfData(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var book = CreateBookFromOpf();
|
||||
var result = new MetadataResult<Book> { Item = book, HasMetadata = true };
|
||||
|
||||
FindAuthors(result);
|
||||
ReadStringInto("//dc:language", language => result.ResultLanguage = language);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private Book CreateBookFromOpf()
|
||||
{
|
||||
var book = new Book
|
||||
{
|
||||
Name = FindMainTitle(),
|
||||
ForcedSortName = FindSortTitle(),
|
||||
};
|
||||
|
||||
ReadStringInto("//dc:description", summary => book.Overview = summary);
|
||||
ReadStringInto("//dc:publisher", publisher => book.AddStudio(publisher));
|
||||
ReadStringInto("//dc:identifier[@opf:scheme='AMAZON']", amazon => book.SetProviderId("Amazon", amazon));
|
||||
ReadStringInto("//dc:identifier[@opf:scheme='GOOGLE']", google => book.SetProviderId("GoogleBooks", google));
|
||||
ReadStringInto("//dc:identifier[@opf:scheme='ISBN']", isbn => book.SetProviderId("ISBN", isbn));
|
||||
|
||||
ReadStringInto("//dc:date", date =>
|
||||
{
|
||||
if (DateTime.TryParse(date, out var dateValue))
|
||||
{
|
||||
book.PremiereDate = dateValue.Date;
|
||||
book.ProductionYear = dateValue.Date.Year;
|
||||
}
|
||||
});
|
||||
|
||||
var genreNodes = _document.SelectNodes("//dc:subject", _namespaceManager);
|
||||
|
||||
if (genreNodes?.Count > 0)
|
||||
{
|
||||
foreach (var node in genreNodes.Cast<XmlNode>().Where(node => !string.IsNullOrEmpty(node.InnerText) && !book.Genres.Contains(node.InnerText)))
|
||||
{
|
||||
// specification has no rules about content and some books combine every genre into a single element
|
||||
foreach (var item in node.InnerText.Split(["/", "&", ",", ";", " - "], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
book.AddGenre(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ReadInt32AttributeInto("//opf:meta[@name='calibre:series_index']", index => book.IndexNumber = index);
|
||||
ReadInt32AttributeInto("//opf:meta[@name='calibre:rating']", rating => book.CommunityRating = rating);
|
||||
|
||||
var seriesNameNode = _document.SelectSingleNode("//opf:meta[@name='calibre:series']", _namespaceManager);
|
||||
|
||||
if (!string.IsNullOrEmpty(seriesNameNode?.Attributes?["content"]?.Value))
|
||||
{
|
||||
try
|
||||
{
|
||||
book.SeriesName = seriesNameNode.Attributes["content"]?.Value;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_logger.LogError("error parsing Calibre series name");
|
||||
}
|
||||
}
|
||||
|
||||
return book;
|
||||
}
|
||||
|
||||
private string FindMainTitle()
|
||||
{
|
||||
var title = string.Empty;
|
||||
var titleTypes = _document.SelectNodes("//opf:meta[@property='title-type']", _namespaceManager);
|
||||
|
||||
if (titleTypes is not null && titleTypes.Count > 0)
|
||||
{
|
||||
foreach (XmlElement titleNode in titleTypes)
|
||||
{
|
||||
string refines = titleNode.GetAttribute("refines").TrimStart('#');
|
||||
string titleType = titleNode.InnerText;
|
||||
|
||||
var titleElement = _document.SelectSingleNode($"//dc:title[@id='{refines}']", _namespaceManager);
|
||||
if (titleElement is not null && string.Equals(titleType, "main", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
title = titleElement.InnerText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fallback in case there is no main title definition
|
||||
if (string.IsNullOrEmpty(title))
|
||||
{
|
||||
ReadStringInto("//dc:title", titleString => title = titleString);
|
||||
}
|
||||
|
||||
return title;
|
||||
}
|
||||
|
||||
private string? FindSortTitle()
|
||||
{
|
||||
var titleTypes = _document.SelectNodes("//opf:meta[@property='file-as']", _namespaceManager);
|
||||
|
||||
if (titleTypes is not null && titleTypes.Count > 0)
|
||||
{
|
||||
foreach (XmlElement titleNode in titleTypes)
|
||||
{
|
||||
string refines = titleNode.GetAttribute("refines").TrimStart('#');
|
||||
string sortTitle = titleNode.InnerText;
|
||||
|
||||
var titleElement = _document.SelectSingleNode($"//dc:title[@id='{refines}']", _namespaceManager);
|
||||
if (titleElement is not null)
|
||||
{
|
||||
return sortTitle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// search for OPF 2.0 style title_sort node
|
||||
var resultElement = _document.SelectSingleNode("//opf:meta[@name='calibre:title_sort']", _namespaceManager);
|
||||
var titleSort = resultElement?.Attributes?["content"]?.Value;
|
||||
|
||||
return titleSort;
|
||||
}
|
||||
|
||||
private void FindAuthors(MetadataResult<Book> book)
|
||||
{
|
||||
var resultElement = _document.SelectNodes("//dc:creator", _namespaceManager);
|
||||
|
||||
if (resultElement != null && resultElement.Count > 0)
|
||||
{
|
||||
foreach (XmlElement creator in resultElement)
|
||||
{
|
||||
var creatorName = creator.InnerText;
|
||||
var role = creator.GetAttribute("opf:role");
|
||||
var person = new PersonInfo { Name = creatorName, Type = GetRole(role) };
|
||||
|
||||
book.AddPerson(person);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private PersonKind GetRole(string? role)
|
||||
{
|
||||
switch (role)
|
||||
{
|
||||
case "arr":
|
||||
return PersonKind.Arranger;
|
||||
case "art":
|
||||
return PersonKind.Artist;
|
||||
case "aut":
|
||||
case "aqt":
|
||||
case "aft":
|
||||
case "aui":
|
||||
default:
|
||||
return PersonKind.Author;
|
||||
case "edt":
|
||||
return PersonKind.Editor;
|
||||
case "ill":
|
||||
return PersonKind.Illustrator;
|
||||
case "lyr":
|
||||
return PersonKind.Lyricist;
|
||||
case "mus":
|
||||
return PersonKind.AlbumArtist;
|
||||
case "oth":
|
||||
return PersonKind.Unknown;
|
||||
case "trl":
|
||||
return PersonKind.Translator;
|
||||
}
|
||||
}
|
||||
|
||||
private void ReadStringInto(string xmlPath, Action<string> commitResult)
|
||||
{
|
||||
var resultElement = _document.SelectSingleNode(xmlPath, _namespaceManager);
|
||||
if (resultElement is not null && !string.IsNullOrWhiteSpace(resultElement.InnerText))
|
||||
{
|
||||
commitResult(resultElement.InnerText);
|
||||
}
|
||||
}
|
||||
|
||||
private void ReadInt32AttributeInto(string xmlPath, Action<int> commitResult)
|
||||
{
|
||||
var resultElement = _document.SelectSingleNode(xmlPath, _namespaceManager);
|
||||
var resultValue = resultElement?.Attributes?["content"]?.Value;
|
||||
|
||||
if (!string.IsNullOrEmpty(resultValue))
|
||||
{
|
||||
try
|
||||
{
|
||||
commitResult(Convert.ToInt32(Convert.ToDouble(resultValue, CultureInfo.InvariantCulture)));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "error converting to Int32");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private (string MimeType, string Path)? ReadEpubCoverInto(string opfRootDirectory, string xmlPath)
|
||||
{
|
||||
var resultElement = _document.SelectSingleNode(xmlPath, _namespaceManager);
|
||||
|
||||
if (resultElement is not null)
|
||||
{
|
||||
return ReadManifestItem(resultElement, opfRootDirectory);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private (string MimeType, string Path)? ReadManifestItem(XmlNode manifestNode, string opfRootDirectory)
|
||||
{
|
||||
var href = manifestNode.Attributes?["href"]?.Value;
|
||||
var mediaType = manifestNode.Attributes?["media-type"]?.Value;
|
||||
|
||||
if (string.IsNullOrEmpty(href) || string.IsNullOrEmpty(mediaType) || !IsValidImage(mediaType))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var coverPath = Path.Combine(opfRootDirectory, href);
|
||||
|
||||
return (MimeType: mediaType, Path: coverPath);
|
||||
}
|
||||
|
||||
private static bool IsValidImage(string? mimeType)
|
||||
{
|
||||
return !string.IsNullOrEmpty(mimeType) && !string.IsNullOrWhiteSpace(MimeTypes.ToExtension(mimeType));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
|
||||
/// <returns>The Jellyfin person type.</returns>
|
||||
public static PersonKind MapCrewToPersonType(Crew crew)
|
||||
{
|
||||
if (crew.Department.Equals("directing", StringComparison.OrdinalIgnoreCase)
|
||||
if (crew.Department.Equals("production", StringComparison.OrdinalIgnoreCase)
|
||||
&& crew.Job.Equals("director", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return PersonKind.Director;
|
||||
@@ -82,7 +82,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
|
||||
}
|
||||
|
||||
if (crew.Department.Equals("writing", StringComparison.OrdinalIgnoreCase)
|
||||
&& (crew.Job.Equals("writer", StringComparison.OrdinalIgnoreCase) || crew.Job.Equals("screenplay", StringComparison.OrdinalIgnoreCase)))
|
||||
&& crew.Job.Equals("writer", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return PersonKind.Writer;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace Jellyfin.Server.Implementations.Tests.Library
|
||||
[InlineData("/media/movies/#recycle", true)]
|
||||
[InlineData("thumbs.db", true)]
|
||||
[InlineData(@"C:\media\movies\movie.avi", false)]
|
||||
[InlineData("/media/.hiddendir/file.mp4", false)]
|
||||
[InlineData("/media/.hiddendir/file.mp4", true)]
|
||||
[InlineData("/media/dir/.hiddenfile.mp4", true)]
|
||||
[InlineData("/media/dir/._macjunk.mp4", true)]
|
||||
[InlineData("/volume1/video/Series/@eaDir", true)]
|
||||
@@ -32,7 +32,7 @@ namespace Jellyfin.Server.Implementations.Tests.Library
|
||||
[InlineData("/media/music/Foo B.A.R", false)]
|
||||
[InlineData("/media/music/Foo B.A.R.", false)]
|
||||
[InlineData("/movies/.zfs/snapshot/AutoM-2023-09", true)]
|
||||
public void PathIgnored(string path, bool expected)
|
||||
public void PathIgnored(string path, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, IgnorePatterns.ShouldIgnore(path));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user