Compare commits

..

1 Commits

Author SHA1 Message Date
renovate[bot]
8b88c05393 Update dependency AsyncKeyedLock to 8.0.1 2026-01-24 14:41:24 +00:00
66 changed files with 438 additions and 1037 deletions

View File

@@ -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

View File

@@ -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/

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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 }}"

View File

@@ -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" />

View File

@@ -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

View File

@@ -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.
// "**/._*",

View File

@@ -73,6 +73,7 @@
"Shows": "العروض",
"Songs": "الأغاني",
"StartupEmbyServerIsLoading": "يتم تحميل خادم Jellyfin . الرجاء المحاولة بعد قليل.",
"SubtitleDownloadFailureForItem": "عملية إنزال الترجمة فشلت لـ{0}",
"SubtitleDownloadFailureFromForItem": "فشل تحميل الترجمات من {0} ل {1}",
"Sync": "مزامنة",
"System": "النظام",

View File

@@ -73,6 +73,7 @@
"Shows": "Сериали",
"Songs": "Песни",
"StartupEmbyServerIsLoading": "Сървърът зарежда. Моля, опитайте отново след малко.",
"SubtitleDownloadFailureForItem": "Неуспешно изтегляне на субтитри за {0}",
"SubtitleDownloadFailureFromForItem": "Субтитрите за {1} от {0} не можаха да бъдат изтеглени",
"Sync": "Синхронизиране",
"System": "Система",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -73,6 +73,7 @@
"Shows": "Σειρές",
"Songs": "Τραγούδια",
"StartupEmbyServerIsLoading": "Ο διακομιστής Jellyfin φορτώνει. Περιμένετε λίγο και δοκιμάστε ξανά.",
"SubtitleDownloadFailureForItem": "Οι υπότιτλοι απέτυχαν να κατέβουν για {0}",
"SubtitleDownloadFailureFromForItem": "Αποτυχίες μεταφόρτωσης υποτίτλων από {0} για {1}",
"Sync": "Συγχρονισμός",
"System": "Σύστημα",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -73,6 +73,7 @@
"Shows": "سریال‌ها",
"Songs": "موسیقی‌ها",
"StartupEmbyServerIsLoading": "سرور Jellyfin در حال بارگیری است. لطفا کمی بعد دوباره تلاش کنید.",
"SubtitleDownloadFailureForItem": "دانلود زیرنویس برای {0} ناموفق بود",
"SubtitleDownloadFailureFromForItem": "بارگیری زیرنویس برای {1} از {0} شکست خورد",
"Sync": "همگام‌سازی",
"System": "سیستم",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -73,6 +73,7 @@
"Shows": "סדרות",
"Songs": "שירים",
"StartupEmbyServerIsLoading": "שרת Jellyfin בתהליך טעינה. נא לנסות שוב בקרוב.",
"SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
"SubtitleDownloadFailureFromForItem": "הורדת כתוביות מ־{0} עבור {1} נכשלה",
"Sync": "סנכרון",
"System": "מערכת",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -73,6 +73,7 @@
"Shows": "시리즈",
"Songs": "노래",
"StartupEmbyServerIsLoading": "Jellyfin 서버를 불러오고 있습니다. 잠시 후에 다시 시도하십시오.",
"SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
"SubtitleDownloadFailureFromForItem": "{0}에서 {1} 자막 다운로드에 실패했습니다",
"Sync": "동기화",
"System": "시스템",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -73,6 +73,7 @@
"Shows": "Сериалы",
"Songs": "Композиции",
"StartupEmbyServerIsLoading": "Jellyfin Server загружается. Повторите попытку в ближайшее время.",
"SubtitleDownloadFailureForItem": "Субтитры к {0} не удалось загрузить",
"SubtitleDownloadFailureFromForItem": "Субтитры к {1} не удалось загрузить с {0}",
"Sync": "Синхронизация",
"System": "Система",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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.",

View File

@@ -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}",

View File

@@ -73,6 +73,7 @@
"Shows": "節目",
"Songs": "歌曲",
"StartupEmbyServerIsLoading": "正在載入 Jellyfin請稍後再試。",
"SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
"SubtitleDownloadFailureFromForItem": "無法從 {0} 下載 {1} 的字幕",
"Sync": "同步",
"System": "系統",

View File

@@ -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
{

View File

@@ -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();
}
}

View File

@@ -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/",

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)
{

View File

@@ -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>

View 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!;
}

View File

@@ -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; }
}

View 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; }
}

View File

@@ -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 />

View File

@@ -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());
}
}

View File

@@ -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);
}
}

View File

@@ -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));
}
}

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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));
}
}
}

View File

@@ -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;
}

View File

@@ -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));
}