mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-05-03 23:36:38 +01:00
Merge remote-tracking branch 'upstream/master' into perf-rebased
This commit is contained in:
1
.github/ISSUE_TEMPLATE/issue report.yml
vendored
1
.github/ISSUE_TEMPLATE/issue report.yml
vendored
@@ -87,6 +87,7 @@ body:
|
||||
label: Jellyfin Server version
|
||||
description: What version of Jellyfin are you using?
|
||||
options:
|
||||
- 10.11.6
|
||||
- 10.11.5
|
||||
- 10.11.4
|
||||
- 10.11.3
|
||||
|
||||
8
.github/workflows/ci-codeql-analysis.yml
vendored
8
.github/workflows/ci-codeql-analysis.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||
@@ -28,13 +28,13 @@ jobs:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
uses: github/codeql-action/init@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
uses: github/codeql-action/autobuild@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
uses: github/codeql-action/analyze@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
||||
|
||||
4
.github/workflows/ci-compat.yml
vendored
4
.github/workflows/ci-compat.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
permissions: read-all
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
permissions: read-all
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
8
.github/workflows/ci-openapi.yml
vendored
8
.github/workflows/ci-openapi.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
permissions: read-all
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
permissions: read-all
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
@@ -133,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@823bd89e131d8d508129f9443cad5855e9ba96f0 # v1.2.4
|
||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
with:
|
||||
host: "${{ secrets.REPO_HOST }}"
|
||||
username: "${{ secrets.REPO_USER }}"
|
||||
@@ -194,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@823bd89e131d8d508129f9443cad5855e9ba96f0 # v1.2.4
|
||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
with:
|
||||
host: "${{ secrets.REPO_HOST }}"
|
||||
username: "${{ secrets.REPO_USER }}"
|
||||
|
||||
2
.github/workflows/ci-tests.yml
vendored
2
.github/workflows/ci-tests.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
|
||||
runs-on: "${{ matrix.os }}"
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||
with:
|
||||
|
||||
6
.github/workflows/commands.yml
vendored
6
.github/workflows/commands.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
reactions: '+1'
|
||||
|
||||
- name: Checkout the latest code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
fetch-depth: 0
|
||||
@@ -40,12 +40,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: pull in script
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: jellyfin/jellyfin-triage-script
|
||||
|
||||
- name: install python
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.14'
|
||||
cache: 'pip'
|
||||
|
||||
4
.github/workflows/issue-template-check.yml
vendored
4
.github/workflows/issue-template-check.yml
vendored
@@ -10,12 +10,12 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: pull in script
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: jellyfin/jellyfin-triage-script
|
||||
|
||||
- name: install python
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.14'
|
||||
cache: 'pip'
|
||||
|
||||
4
.github/workflows/release-bump-version.yaml
vendored
4
.github/workflows/release-bump-version.yaml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
yq-version: v4.9.8
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ env.TAG_BRANCH }}
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }}
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ env.TAG_BRANCH }}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<PackageVersion Include="BlurHashSharp" Version="1.4.0-pre.1" />
|
||||
<PackageVersion Include="CommandLineParser" Version="2.9.1" />
|
||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageVersion Include="Diacritics" Version="4.0.17" />
|
||||
<PackageVersion Include="Diacritics" Version="4.1.4" />
|
||||
<PackageVersion Include="DiscUtils.Udf" Version="0.16.13" />
|
||||
<PackageVersion Include="DotNet.Glob" Version="3.1.3" />
|
||||
<PackageVersion Include="FsCheck.Xunit" Version="3.3.2" />
|
||||
|
||||
@@ -1123,16 +1123,16 @@ namespace Emby.Server.Implementations.Dto
|
||||
|
||||
// Include artists that are not in the database yet, e.g., just added via metadata editor
|
||||
// var foundArtists = artistItems.Items.Select(i => i.Item1.Name).ToList();
|
||||
dto.ArtistItems = _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))])
|
||||
.Where(e => e.Value.Length > 0)
|
||||
.Select(i =>
|
||||
{
|
||||
return new NameGuidPair
|
||||
{
|
||||
Name = i.Key,
|
||||
Id = i.Value.First().Id
|
||||
};
|
||||
}).Where(i => i is not null).ToArray();
|
||||
var artistsLookup = _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))]);
|
||||
|
||||
dto.ArtistItems = hasArtist.Artists
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Distinct()
|
||||
.Select(name => artistsLookup.TryGetValue(name, out var artists) && artists.Length > 0
|
||||
? new NameGuidPair { Name = name, Id = artists[0].Id }
|
||||
: null)
|
||||
.Where(item => item is not null)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
if (item is IHasAlbumArtist hasAlbumArtist)
|
||||
@@ -1157,31 +1157,16 @@ namespace Emby.Server.Implementations.Dto
|
||||
// })
|
||||
// .ToList();
|
||||
|
||||
var albumArtistsLookup = _libraryManager.GetArtists([.. hasAlbumArtist.AlbumArtists.Where(e => !string.IsNullOrWhiteSpace(e))]);
|
||||
|
||||
dto.AlbumArtists = hasAlbumArtist.AlbumArtists
|
||||
// .Except(foundArtists, new DistinctNameComparer())
|
||||
.Select(i =>
|
||||
{
|
||||
// This should not be necessary but we're seeing some cases of it
|
||||
if (string.IsNullOrEmpty(i))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var artist = _libraryManager.GetArtist(i, new DtoOptions(false)
|
||||
{
|
||||
EnableImages = false
|
||||
});
|
||||
if (artist is not null)
|
||||
{
|
||||
return new NameGuidPair
|
||||
{
|
||||
Name = artist.Name,
|
||||
Id = artist.Id
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}).Where(i => i is not null).ToArray();
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Distinct()
|
||||
.Select(name => albumArtistsLookup.TryGetValue(name, out var albumArtists) && albumArtists.Length > 0
|
||||
? new NameGuidPair { Name = name, Id = albumArtists[0].Id }
|
||||
: null)
|
||||
.Where(item => item is not null)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
// Add video info
|
||||
|
||||
@@ -50,6 +50,10 @@ namespace Emby.Server.Implementations.Library
|
||||
"**/lost+found",
|
||||
"**/subs/**",
|
||||
"**/subs",
|
||||
"**/.snapshots/**",
|
||||
"**/.snapshots",
|
||||
"**/.snapshot/**",
|
||||
"**/.snapshot",
|
||||
|
||||
// Trickplay files
|
||||
"**/*.trickplay",
|
||||
@@ -83,7 +87,6 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
// Unix hidden files
|
||||
"**/.*",
|
||||
"**/.*/**",
|
||||
|
||||
// Mac - if you ever remove the above.
|
||||
// "**/._*",
|
||||
|
||||
@@ -73,7 +73,6 @@
|
||||
"Shows": "العروض",
|
||||
"Songs": "الأغاني",
|
||||
"StartupEmbyServerIsLoading": "يتم تحميل خادم Jellyfin . الرجاء المحاولة بعد قليل.",
|
||||
"SubtitleDownloadFailureForItem": "عملية إنزال الترجمة فشلت لـ{0}",
|
||||
"SubtitleDownloadFailureFromForItem": "فشل تحميل الترجمات من {0} ل {1}",
|
||||
"Sync": "مزامنة",
|
||||
"System": "النظام",
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
"User": "Карыстальнік",
|
||||
"UserDeletedWithName": "Карыстальнік {0} быў выдалены",
|
||||
"UserDownloadingItemWithValues": "{0} спампоўваецца {1}",
|
||||
"TaskOptimizeDatabase": "Аптымізаваць базу дадзеных",
|
||||
"TaskOptimizeDatabase": "Аптымізацыя базы даных",
|
||||
"Artists": "Выканаўцы",
|
||||
"UserOfflineFromDevice": "{0} адлучыўся ад {1}",
|
||||
"UserPolicyUpdatedWithName": "Палітыка карыстальніка абноўлена для {0}",
|
||||
@@ -59,8 +59,8 @@
|
||||
"TaskCleanLogsDescription": "Выдаляе файлы журналу, якім больш за {0} дзён.",
|
||||
"TaskUpdatePluginsDescription": "Спампоўвае і ўсталёўвае абнаўленні для плагінаў, якія сканфігураваныя на аўтаматычнае абнаўленне.",
|
||||
"TaskRefreshChannelsDescription": "Абнаўляе інфармацыю аб інтэрнэт-канале.",
|
||||
"TaskDownloadMissingSubtitlesDescription": "Шукае ў інтэрнэце адсутныя субцітры на аснове канфігурацыі метададзеных.",
|
||||
"TaskOptimizeDatabaseDescription": "Ушчыльняе базу дадзеных і скарачае вольную прастору. Выкананне гэтай задачы пасля сканавання бібліятэкі або ўнясення іншых зменаў, якія прадугледжваюць мадыфікацыю базы дадзеных, можа палепшыць выдайнасць.",
|
||||
"TaskDownloadMissingSubtitlesDescription": "Шукае ў інтэрнэце адсутныя субцітры на аснове канфігурацыі метаданых.",
|
||||
"TaskOptimizeDatabaseDescription": "Сціскае базу даных і вызваляе вольную прастору. Выкананне гэтай задачы пасля сканіравання бібліятэкі або іншых змяненняў, якія мадыфікуюць базу даных, можа палепшыць прадукцыйнасць.",
|
||||
"TaskKeyframeExtractor": "Экстрактар ключавых кадраў",
|
||||
"TasksApplicationCategory": "Праграма",
|
||||
"AppDeviceValues": "Праграма: {0}, Прылада: {1}",
|
||||
@@ -136,6 +136,6 @@
|
||||
"TaskDownloadMissingLyricsDescription": "Спампоўвае тэксты для песняў",
|
||||
"TaskExtractMediaSegments": "Сканіраванне медыя-сегмента",
|
||||
"TaskMoveTrickplayImages": "Перанесці месцазнаходжанне выявы Trickplay",
|
||||
"CleanupUserDataTask": "Задача па ачыстцы дадзеных карыстальніка",
|
||||
"CleanupUserDataTask": "Задача па ачыстцы даных карыстальніка",
|
||||
"CleanupUserDataTaskDescription": "Ачышчае ўсе даныя карыстальніка (стан прагляду, абранае і г.д.) для медыяфайлаў, што адсутнічаюць больш за 90 дзён."
|
||||
}
|
||||
|
||||
@@ -73,7 +73,6 @@
|
||||
"Shows": "Сериали",
|
||||
"Songs": "Песни",
|
||||
"StartupEmbyServerIsLoading": "Сървърът зарежда. Моля, опитайте отново след малко.",
|
||||
"SubtitleDownloadFailureForItem": "Неуспешно изтегляне на субтитри за {0}",
|
||||
"SubtitleDownloadFailureFromForItem": "Субтитрите за {1} от {0} не можаха да бъдат изтеглени",
|
||||
"Sync": "Синхронизиране",
|
||||
"System": "Система",
|
||||
|
||||
@@ -73,7 +73,6 @@
|
||||
"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,7 +73,6 @@
|
||||
"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,7 +73,6 @@
|
||||
"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,7 +73,6 @@
|
||||
"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,7 +73,6 @@
|
||||
"Shows": "Σειρές",
|
||||
"Songs": "Τραγούδια",
|
||||
"StartupEmbyServerIsLoading": "Ο διακομιστής Jellyfin φορτώνει. Περιμένετε λίγο και δοκιμάστε ξανά.",
|
||||
"SubtitleDownloadFailureForItem": "Οι υπότιτλοι απέτυχαν να κατέβουν για {0}",
|
||||
"SubtitleDownloadFailureFromForItem": "Αποτυχίες μεταφόρτωσης υποτίτλων από {0} για {1}",
|
||||
"Sync": "Συγχρονισμός",
|
||||
"System": "Σύστημα",
|
||||
|
||||
@@ -73,7 +73,6 @@
|
||||
"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",
|
||||
|
||||
@@ -73,7 +73,6 @@
|
||||
"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,7 +73,6 @@
|
||||
"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,7 +73,6 @@
|
||||
"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,7 +73,6 @@
|
||||
"Shows": "سریالها",
|
||||
"Songs": "موسیقیها",
|
||||
"StartupEmbyServerIsLoading": "سرور Jellyfin در حال بارگیری است. لطفا کمی بعد دوباره تلاش کنید.",
|
||||
"SubtitleDownloadFailureForItem": "دانلود زیرنویس برای {0} ناموفق بود",
|
||||
"SubtitleDownloadFailureFromForItem": "بارگیری زیرنویس برای {1} از {0} شکست خورد",
|
||||
"Sync": "همگامسازی",
|
||||
"System": "سیستم",
|
||||
|
||||
@@ -73,7 +73,6 @@
|
||||
"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,7 +73,6 @@
|
||||
"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,7 +73,6 @@
|
||||
"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,7 +73,6 @@
|
||||
"Shows": "סדרות",
|
||||
"Songs": "שירים",
|
||||
"StartupEmbyServerIsLoading": "שרת Jellyfin בתהליך טעינה. נא לנסות שוב בקרוב.",
|
||||
"SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
|
||||
"SubtitleDownloadFailureFromForItem": "הורדת כתוביות מ־{0} עבור {1} נכשלה",
|
||||
"Sync": "סנכרון",
|
||||
"System": "מערכת",
|
||||
|
||||
@@ -73,7 +73,6 @@
|
||||
"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",
|
||||
|
||||
@@ -73,7 +73,6 @@
|
||||
"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,7 +73,6 @@
|
||||
"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,7 +73,6 @@
|
||||
"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,7 +73,6 @@
|
||||
"Shows": "시리즈",
|
||||
"Songs": "노래",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin 서버를 불러오고 있습니다. 잠시 후에 다시 시도하십시오.",
|
||||
"SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
|
||||
"SubtitleDownloadFailureFromForItem": "{0}에서 {1} 자막 다운로드에 실패했습니다",
|
||||
"Sync": "동기화",
|
||||
"System": "시스템",
|
||||
|
||||
@@ -73,7 +73,6 @@
|
||||
"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,7 +73,6 @@
|
||||
"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",
|
||||
|
||||
@@ -126,5 +126,7 @@
|
||||
"TaskRefreshTrickplayImages": "ထရစ်ခ်ပလေး ပုံများကို ထုတ်မည်",
|
||||
"TaskKeyframeExtractor": "ကီးဖရိန်များကို ထုတ်နုတ်ခြင်း",
|
||||
"TaskCleanCollectionsAndPlaylists": "စုစည်းမှုများနှင့် အစဉ်လိုက်ပြသမှုများကို ရှင်းလင်းမည်",
|
||||
"HearingImpaired": "အကြားအာရုံ ချို့တဲ့သူ"
|
||||
"HearingImpaired": "အကြားအာရုံ ချို့တဲ့သူ",
|
||||
"TaskDownloadMissingLyrics": "ကျန်နေသောသီချင်းစာသားများအား ဒေါင်းလုတ်ဆွဲပါ",
|
||||
"TaskDownloadMissingLyricsDescription": "သီချင်းများအတွက် သီချင်းစာသား ဒေါင်းလုတ်ဆွဲပါ"
|
||||
}
|
||||
|
||||
@@ -73,7 +73,6 @@
|
||||
"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,7 +73,6 @@
|
||||
"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,7 +73,6 @@
|
||||
"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,7 +73,6 @@
|
||||
"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,7 +73,6 @@
|
||||
"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,7 +73,6 @@
|
||||
"Shows": "Сериалы",
|
||||
"Songs": "Композиции",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin Server загружается. Повторите попытку в ближайшее время.",
|
||||
"SubtitleDownloadFailureForItem": "Субтитры к {0} не удалось загрузить",
|
||||
"SubtitleDownloadFailureFromForItem": "Субтитры к {1} не удалось загрузить с {0}",
|
||||
"Sync": "Синхронизация",
|
||||
"System": "Система",
|
||||
|
||||
@@ -73,7 +73,6 @@
|
||||
"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,7 +73,6 @@
|
||||
"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,7 +73,6 @@
|
||||
"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": "Çalışma süresi: {0}",
|
||||
"LabelRunningTimeValue": "Oynatma 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": "Bilinmeyen Sezon",
|
||||
"NameSeasonUnknown": "Sezon Bilinmiyor",
|
||||
"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ı kilitlendi",
|
||||
"NotificationOptionUserLockedOut": "Kullanıcı hesabı kilitlendi",
|
||||
"NotificationOptionVideoPlayback": "Video oynatma başladı",
|
||||
"NotificationOptionVideoPlaybackStopped": "Video oynatma durduruldu",
|
||||
"Photos": "Fotoğraflar",
|
||||
@@ -73,8 +73,7 @@
|
||||
"Shows": "Diziler",
|
||||
"Songs": "Şarkılar",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin Sunucusu yükleniyor. Lütfen kısa süre sonra tekrar deneyin.",
|
||||
"SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
|
||||
"SubtitleDownloadFailureFromForItem": "{1} için alt yazılar {0} sağlayıcısından indirilemedi",
|
||||
"SubtitleDownloadFailureFromForItem": "{1} için altyazılar {0} sağlayıcısından indirilemedi",
|
||||
"Sync": "Eşzamanlama",
|
||||
"System": "Sistem",
|
||||
"TvShows": "Diziler",
|
||||
@@ -82,7 +81,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ı kilitlendi",
|
||||
"UserLockedOutWithName": "{0} adlı kullanıcı hesabı 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",
|
||||
@@ -98,8 +97,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 alt yazılar için internette arama yapar.",
|
||||
"TaskDownloadMissingSubtitles": "Eksik alt yazıları indir",
|
||||
"TaskDownloadMissingSubtitlesDescription": "Meta veri yapılandırmasına dayalı olarak eksik altyazılar için internette arama yapar.",
|
||||
"TaskDownloadMissingSubtitles": "Eksik altyazıları indir",
|
||||
"TaskRefreshChannelsDescription": "Internet kanal bilgilerini yenile.",
|
||||
"TaskRefreshChannels": "Kanalları Yenile",
|
||||
"TaskCleanTranscodeDescription": "Bir günden daha eski kod dönüştürme dosyalarını siler.",
|
||||
@@ -125,15 +124,15 @@
|
||||
"TaskKeyframeExtractor": "Ana Kare Çıkarıcı",
|
||||
"External": "Harici",
|
||||
"HearingImpaired": "Duyma Engelli",
|
||||
"TaskRefreshTrickplayImages": "Trickplay Görselleri Oluştur",
|
||||
"TaskRefreshTrickplayImagesDescription": "Etkin kütüphanelerdeki videolar için trickplay önizlemeleri oluşturur.",
|
||||
"TaskRefreshTrickplayImages": "Hızlı Önizleme Görsellerini Oluştur",
|
||||
"TaskRefreshTrickplayImagesDescription": "Etkin kütüphanelerdeki videolar için hızlı önizleme görselleri 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": "Trickplay Görsel Konumunu Taşıma",
|
||||
"TaskMoveTrickplayImagesDescription": "Mevcut trickplay dosyalarını kütüphane ayarlarına göre taşır.",
|
||||
"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.",
|
||||
"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 Server 版本已更新为 {0}",
|
||||
"MessageApplicationUpdatedTo": "Jellyfin 服务器版本已更新到 {0}",
|
||||
"MessageNamedServerConfigurationUpdatedWithValue": "服务器配置 {0} 部分已更新",
|
||||
"MessageServerConfigurationUpdated": "服务器配置已更新",
|
||||
"MixedContent": "混合内容",
|
||||
"Movies": "电影",
|
||||
"Music": "音乐",
|
||||
"MusicVideos": "音乐视频",
|
||||
"MusicVideos": "MV",
|
||||
"NameInstallFailed": "{0} 安装失败",
|
||||
"NameSeasonNumber": "第 {0} 季",
|
||||
"NameSeasonUnknown": "未知季",
|
||||
"NewVersionIsAvailable": "Jellyfin Server 有新版本可以下载。",
|
||||
"NewVersionIsAvailable": "Jellyfin 服务器有新版本可供下载。",
|
||||
"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,23 +72,22 @@
|
||||
"ServerNameNeedsToBeRestarted": "{0} 需要重新启动",
|
||||
"Shows": "节目",
|
||||
"Songs": "歌曲",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin 服务器加载中。请稍后再试。",
|
||||
"SubtitleDownloadFailureForItem": "为 {0} 下载字幕失败",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin 服务器正在启动,请稍后再试。",
|
||||
"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,7 +73,6 @@
|
||||
"Shows": "節目",
|
||||
"Songs": "歌曲",
|
||||
"StartupEmbyServerIsLoading": "正在載入 Jellyfin,請稍後再試。",
|
||||
"SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
|
||||
"SubtitleDownloadFailureFromForItem": "無法從 {0} 下載 {1} 的字幕",
|
||||
"Sync": "同步",
|
||||
"System": "系統",
|
||||
|
||||
@@ -793,6 +793,16 @@ 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
|
||||
@@ -1060,11 +1070,12 @@ namespace Emby.Server.Implementations.Session
|
||||
var msString = info.PositionTicks.HasValue ? (info.PositionTicks.Value / 10000).ToString(CultureInfo.InvariantCulture) : "unknown";
|
||||
|
||||
_logger.LogInformation(
|
||||
"Playback stopped reported by app {0} {1} playing {2}. Stopped at {3} ms",
|
||||
session.Client,
|
||||
session.ApplicationVersion,
|
||||
"User {0} stopped playback of '{1}' at {2}ms ({3} {4})",
|
||||
session.UserName,
|
||||
info.Item.Name,
|
||||
msString);
|
||||
msString,
|
||||
session.Client,
|
||||
session.ApplicationVersion);
|
||||
}
|
||||
|
||||
if (info.NowPlayingQueue is not null)
|
||||
@@ -1175,7 +1186,8 @@ namespace Emby.Server.Implementations.Session
|
||||
return session;
|
||||
}
|
||||
|
||||
private SessionInfoDto ToSessionInfoDto(SessionInfo sessionInfo)
|
||||
/// <inheritdoc />
|
||||
public SessionInfoDto ToSessionInfoDto(SessionInfo sessionInfo)
|
||||
{
|
||||
return new SessionInfoDto
|
||||
{
|
||||
|
||||
@@ -3,8 +3,6 @@ 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;
|
||||
@@ -143,22 +141,4 @@ 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,10 +1400,20 @@ 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,
|
||||
state.SegmentLength * 1000,
|
||||
segmentLength,
|
||||
state.RunTimeTicks ?? 0,
|
||||
state.Request.SegmentContainer ?? string.Empty,
|
||||
"hls1/main/",
|
||||
|
||||
@@ -3,7 +3,6 @@ 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;
|
||||
@@ -128,20 +127,6 @@ 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,16 +65,6 @@ 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,7 +2,6 @@ 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;
|
||||
@@ -69,7 +68,6 @@ 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>
|
||||
@@ -88,7 +86,6 @@ 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,29 +337,6 @@ 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>
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
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,4 +1,3 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Jellyfin.Api.Models.StartupDtos;
|
||||
@@ -13,11 +12,4 @@ 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; }
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
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,6 +7,7 @@ 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;
|
||||
|
||||
@@ -15,7 +16,7 @@ namespace Jellyfin.Api.WebSocketListeners;
|
||||
/// <summary>
|
||||
/// Class SessionInfoWebSocketListener.
|
||||
/// </summary>
|
||||
public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnumerable<SessionInfo>, WebSocketListenerState>
|
||||
public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnumerable<SessionInfoDto>, WebSocketListenerState>
|
||||
{
|
||||
private readonly ISessionManager _sessionManager;
|
||||
private bool _disposed;
|
||||
@@ -52,24 +53,26 @@ public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnume
|
||||
/// Gets the data to send.
|
||||
/// </summary>
|
||||
/// <returns>Task{SystemInfo}.</returns>
|
||||
protected override Task<IEnumerable<SessionInfo>> GetDataToSend()
|
||||
protected override Task<IEnumerable<SessionInfoDto>> GetDataToSend()
|
||||
{
|
||||
return Task.FromResult(_sessionManager.Sessions);
|
||||
return Task.FromResult(_sessionManager.Sessions.Select(_sessionManager.ToSessionInfoDto));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task<IEnumerable<SessionInfo>> GetDataToSendForConnection(IWebSocketConnection connection)
|
||||
protected override Task<IEnumerable<SessionInfoDto>> 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;
|
||||
return Task.FromResult(_sessionManager.Sessions.Where(s => s.UserId.Equals(userId) || s.ContainsUser(userId)));
|
||||
sessions = sessions.Where(s => s.UserId.Equals(userId) || s.ContainsUser(userId));
|
||||
}
|
||||
|
||||
return Task.FromResult(_sessionManager.Sessions);
|
||||
return Task.FromResult(sessions.Select(_sessionManager.ToSessionInfoDto));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -322,6 +322,25 @@ 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()!;
|
||||
@@ -1552,16 +1571,30 @@ public sealed class BaseItemRepository
|
||||
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
var userKeys = item.GetUserDataKeys().ToArray();
|
||||
var retentionDate = (DateTime?)null;
|
||||
await dbContext.UserData
|
||||
.Where(e => e.ItemId == PlaceholderId)
|
||||
.Where(e => userKeys.Contains(e.CustomDataKey))
|
||||
.ExecuteUpdateAsync(
|
||||
e => e
|
||||
.SetProperty(f => f.ItemId, item.Id)
|
||||
.SetProperty(f => f.RetentionDate, retentionDate),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3793,6 +3826,21 @@ public sealed class BaseItemRepository
|
||||
.Where(e => artistNames.Contains(e.Name))
|
||||
.ToArray();
|
||||
|
||||
return artists.GroupBy(e => e.Name).ToDictionary(e => e.Key!, e => e.Select(f => DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray());
|
||||
var lookup = artists
|
||||
.GroupBy(e => e.Name!)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.Select(f => DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray());
|
||||
|
||||
var result = new Dictionary<string, MusicArtist[]>(artistNames.Count);
|
||||
foreach (var name in artistNames)
|
||||
{
|
||||
if (lookup.TryGetValue(name, out var artistArray))
|
||||
{
|
||||
result[name] = artistArray;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,5 +350,12 @@ 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,23 +172,25 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
|
||||
private async Task<Stream> GetSubtitleStream(SubtitleInfo fileInfo, CancellationToken cancellationToken)
|
||||
{
|
||||
if (fileInfo.IsExternal)
|
||||
if (fileInfo.Protocol == MediaProtocol.Http)
|
||||
{
|
||||
var stream = await GetStream(fileInfo.Path, fileInfo.Protocol, cancellationToken).ConfigureAwait(false);
|
||||
await using (stream.ConfigureAwait(false))
|
||||
var result = await DetectCharset(fileInfo.Path, fileInfo.Protocol, cancellationToken).ConfigureAwait(false);
|
||||
var detected = result.Detected;
|
||||
|
||||
if (detected is not null)
|
||||
{
|
||||
var result = await CharsetDetector.DetectFromStreamAsync(stream, cancellationToken).ConfigureAwait(false);
|
||||
var detected = result.Detected;
|
||||
stream.Position = 0;
|
||||
_logger.LogDebug("charset {CharSet} detected for {Path}", detected.EncodingName, fileInfo.Path);
|
||||
|
||||
if (detected is not null)
|
||||
using var stream = await _httpClientFactory.CreateClient(NamedClient.Default)
|
||||
.GetStreamAsync(new Uri(fileInfo.Path), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await using (stream.ConfigureAwait(false))
|
||||
{
|
||||
_logger.LogDebug("charset {CharSet} detected for {Path}", detected.EncodingName, fileInfo.Path);
|
||||
using var reader = new StreamReader(stream, detected.Encoding);
|
||||
var text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var reader = new StreamReader(stream, detected.Encoding);
|
||||
var text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new MemoryStream(Encoding.UTF8.GetBytes(text));
|
||||
return new MemoryStream(Encoding.UTF8.GetBytes(text));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,7 +220,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
};
|
||||
}
|
||||
|
||||
var currentFormat = (Path.GetExtension(subtitleStream.Path) ?? subtitleStream.Codec)
|
||||
var currentFormat = subtitleStream.Codec ?? Path.GetExtension(subtitleStream.Path)
|
||||
.TrimStart('.');
|
||||
|
||||
// Handle PGS subtitles as raw streams for the client to render
|
||||
@@ -941,42 +943,44 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var stream = await GetStream(path, mediaSource.Protocol, cancellationToken).ConfigureAwait(false);
|
||||
await using (stream.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 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;
|
||||
charset = string.Empty;
|
||||
}
|
||||
|
||||
_logger.LogDebug("charset {0} detected for {Path}", charset, path);
|
||||
|
||||
return charset;
|
||||
}
|
||||
|
||||
private async Task<Stream> GetStream(string path, MediaProtocol protocol, CancellationToken cancellationToken)
|
||||
private async Task<DetectionResult> DetectCharset(string path, MediaProtocol protocol, CancellationToken cancellationToken)
|
||||
{
|
||||
switch (protocol)
|
||||
{
|
||||
case MediaProtocol.Http:
|
||||
{
|
||||
using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
|
||||
.GetAsync(new Uri(path), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
{
|
||||
using var stream = await _httpClientFactory
|
||||
.CreateClient(NamedClient.Default)
|
||||
.GetStreamAsync(new Uri(path), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return await CharsetDetector.DetectFromStreamAsync(stream, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
case MediaProtocol.File:
|
||||
return AsyncFile.OpenRead(path);
|
||||
{
|
||||
return await CharsetDetector.DetectFromFileAsync(path, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(protocol));
|
||||
throw new ArgumentOutOfRangeException(nameof(protocol), protocol, "Unsupported protocol");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1252,11 +1252,11 @@ public class StreamInfo
|
||||
stream.Index.ToString(CultureInfo.InvariantCulture),
|
||||
startPositionTicks.ToString(CultureInfo.InvariantCulture),
|
||||
subtitleProfile.Format);
|
||||
info.IsExternalUrl = false; // Default to API URL
|
||||
info.IsExternalUrl = false;
|
||||
|
||||
// Check conditions for potentially using the direct path
|
||||
if (stream.IsExternal // Must be external
|
||||
&& MediaSource?.Protocol != MediaProtocol.File // Main media must not be a local file
|
||||
&& stream.SupportsExternalStream
|
||||
&& 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,7 +3,6 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using Jellyfin.Data.Enums;
|
||||
using MediaBrowser.Model.Dlna;
|
||||
|
||||
@@ -31,15 +30,5 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
100
MediaBrowser.Providers/Books/OpenPackagingFormat/EpubProvider.cs
Normal file
100
MediaBrowser.Providers/Books/OpenPackagingFormat/EpubProvider.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
329
MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs
Normal file
329
MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs
Normal file
@@ -0,0 +1,329 @@
|
||||
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("production", StringComparison.OrdinalIgnoreCase)
|
||||
if (crew.Department.Equals("directing", 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("writer", StringComparison.OrdinalIgnoreCase) || crew.Job.Equals("screenplay", 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", true)]
|
||||
[InlineData("/media/.hiddendir/file.mp4", false)]
|
||||
[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