mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-06-29 11:02:14 +01:00
Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
feef2403c4 | ||
|
|
1b6342e217 | ||
|
|
62e6cf0196 | ||
|
|
8c6ee890cb | ||
|
|
eee26e6fee | ||
|
|
fb07067f8f | ||
|
|
a83920c5a7 | ||
|
|
75d71cb73c | ||
|
|
c158418e0b | ||
|
|
cbef19c313 | ||
|
|
fc13a7ca7d | ||
|
|
ff36b1b417 | ||
|
|
9ec19b8244 | ||
|
|
ed5e868a6b | ||
|
|
58de9b7a99 | ||
|
|
aa037c748a | ||
|
|
1efdad3443 | ||
|
|
f2ed842b4b | ||
|
|
c2cb18a9d1 | ||
|
|
f398b6d08b | ||
|
|
fa07a3abe8 | ||
|
|
b9db4566a7 | ||
|
|
d71b17fcc7 | ||
|
|
dff84c8490 | ||
|
|
1947296edd | ||
|
|
31070e8208 | ||
|
|
2c98ad99db | ||
|
|
e26f4a1005 | ||
|
|
e41f415594 | ||
|
|
da515e94b1 | ||
|
|
987744529a | ||
|
|
917244ab1d | ||
|
|
af82aceadb | ||
|
|
7f2cd5cf57 | ||
|
|
2feb588db3 | ||
|
|
58e9e3423a | ||
|
|
da2b994fff | ||
|
|
7f7e4dfa40 | ||
|
|
c257fd5004 | ||
|
|
0046adda29 | ||
|
|
b60c535c84 | ||
|
|
069eb40ebf | ||
|
|
310a47c1d4 | ||
|
|
24886d4849 | ||
|
|
c6c72f30ec | ||
|
|
528593efbf | ||
|
|
1ea525a408 | ||
|
|
0ed27bad65 | ||
|
|
26a149a970 | ||
|
|
5104497331 | ||
|
|
372c1681d8 |
4
.github/workflows/ci-codeql-analysis.yml
vendored
4
.github/workflows/ci-codeql-analysis.yml
vendored
@@ -24,10 +24,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
|
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
|
||||||
with:
|
with:
|
||||||
dotnet-version: '10.0.x'
|
dotnet-version: '10.0.x'
|
||||||
|
|
||||||
|
|||||||
8
.github/workflows/ci-compat.yml
vendored
8
.github/workflows/ci-compat.yml
vendored
@@ -11,13 +11,13 @@ jobs:
|
|||||||
permissions: read-all
|
permissions: read-all
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
|
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
|
||||||
with:
|
with:
|
||||||
dotnet-version: '10.0.x'
|
dotnet-version: '10.0.x'
|
||||||
|
|
||||||
@@ -40,14 +40,14 @@ jobs:
|
|||||||
permissions: read-all
|
permissions: read-all
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
|
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
|
||||||
with:
|
with:
|
||||||
dotnet-version: '10.0.x'
|
dotnet-version: '10.0.x'
|
||||||
|
|
||||||
|
|||||||
4
.github/workflows/ci-format.yml
vendored
4
.github/workflows/ci-format.yml
vendored
@@ -15,9 +15,9 @@ jobs:
|
|||||||
format-check:
|
format-check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|
||||||
- uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
|
- uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
|
||||||
with:
|
with:
|
||||||
dotnet-version: ${{ env.SDK_VERSION }}
|
dotnet-version: ${{ env.SDK_VERSION }}
|
||||||
|
|
||||||
|
|||||||
4
.github/workflows/ci-tests.yml
vendored
4
.github/workflows/ci-tests.yml
vendored
@@ -20,9 +20,9 @@ jobs:
|
|||||||
|
|
||||||
runs-on: "${{ matrix.os }}"
|
runs-on: "${{ matrix.os }}"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|
||||||
- uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
|
- uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
|
||||||
with:
|
with:
|
||||||
dotnet-version: ${{ env.SDK_VERSION }}
|
dotnet-version: ${{ env.SDK_VERSION }}
|
||||||
|
|
||||||
|
|||||||
6
.github/workflows/commands.yml
vendored
6
.github/workflows/commands.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
|||||||
reactions: '+1'
|
reactions: '+1'
|
||||||
|
|
||||||
- name: Checkout the latest code
|
- name: Checkout the latest code
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -40,12 +40,12 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: pull in script
|
- name: pull in script
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
repository: jellyfin/jellyfin-triage-script
|
repository: jellyfin/jellyfin-triage-script
|
||||||
|
|
||||||
- name: install python
|
- name: install python
|
||||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||||
with:
|
with:
|
||||||
python-version: '3.14'
|
python-version: '3.14'
|
||||||
cache: 'pip'
|
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
|
issues: write
|
||||||
steps:
|
steps:
|
||||||
- name: pull in script
|
- name: pull in script
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
repository: jellyfin/jellyfin-triage-script
|
repository: jellyfin/jellyfin-triage-script
|
||||||
|
|
||||||
- name: install python
|
- name: install python
|
||||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||||
with:
|
with:
|
||||||
python-version: '3.14'
|
python-version: '3.14'
|
||||||
cache: 'pip'
|
cache: 'pip'
|
||||||
|
|||||||
4
.github/workflows/openapi-generate.yml
vendored
4
.github/workflows/openapi-generate.yml
vendored
@@ -22,13 +22,13 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.ref }}
|
ref: ${{ inputs.ref }}
|
||||||
repository: ${{ inputs.repository }}
|
repository: ${{ inputs.repository }}
|
||||||
|
|
||||||
- name: Configure .NET
|
- name: Configure .NET
|
||||||
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
|
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
|
||||||
with:
|
with:
|
||||||
dotnet-version: '10.0.x'
|
dotnet-version: '10.0.x'
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/openapi-pull-request.yml
vendored
2
.github/workflows/openapi-pull-request.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
|||||||
base_ref: ${{ steps.ancestor.outputs.base_ref }}
|
base_ref: ${{ steps.ancestor.outputs.base_ref }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
|
|||||||
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
|
yq-version: v4.9.8
|
||||||
|
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
ref: ${{ env.TAG_BRANCH }}
|
ref: ${{ env.TAG_BRANCH }}
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ jobs:
|
|||||||
NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }}
|
NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
ref: ${{ env.TAG_BRANCH }}
|
ref: ${{ env.TAG_BRANCH }}
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
|
||||||
<PackageVersion Include="MimeTypes" Version="2.5.2" />
|
<PackageVersion Include="MimeTypes" Version="2.5.2" />
|
||||||
<PackageVersion Include="Morestachio" Version="5.0.1.670" />
|
<PackageVersion Include="Morestachio" Version="5.0.1.670" />
|
||||||
<PackageVersion Include="Moq" Version="4.18.4" />
|
<PackageVersion Include="Moq" Version="4.18.4" />
|
||||||
@@ -75,8 +75,8 @@
|
|||||||
<PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" />
|
<PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" />
|
||||||
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
|
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
|
||||||
<PackageVersion Include="Svg.Skia" Version="3.7.0" />
|
<PackageVersion Include="Svg.Skia" Version="3.7.0" />
|
||||||
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.2.1" />
|
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.2.3" />
|
||||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.1" />
|
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||||
<PackageVersion Include="System.Text.Json" Version="10.0.9" />
|
<PackageVersion Include="System.Text.Json" Version="10.0.9" />
|
||||||
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
|
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
|
||||||
<PackageVersion Include="z440.atl.core" Version="7.15.3" />
|
<PackageVersion Include="z440.atl.core" Version="7.15.3" />
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ using MediaBrowser.Model.Net;
|
|||||||
using MediaBrowser.Model.Serialization;
|
using MediaBrowser.Model.Serialization;
|
||||||
using MediaBrowser.Model.System;
|
using MediaBrowser.Model.System;
|
||||||
using MediaBrowser.Model.Tasks;
|
using MediaBrowser.Model.Tasks;
|
||||||
|
using MediaBrowser.Providers.Books;
|
||||||
|
using MediaBrowser.Providers.Books.ComicBookInfo;
|
||||||
|
using MediaBrowser.Providers.Books.ComicInfo;
|
||||||
using MediaBrowser.Providers.Lyric;
|
using MediaBrowser.Providers.Lyric;
|
||||||
using MediaBrowser.Providers.Manager;
|
using MediaBrowser.Providers.Manager;
|
||||||
using MediaBrowser.Providers.Plugins.ListenBrainz;
|
using MediaBrowser.Providers.Plugins.ListenBrainz;
|
||||||
@@ -496,6 +499,14 @@ namespace Emby.Server.Implementations
|
|||||||
serviceCollection.AddSingleton<ListenBrainzLabsClient>();
|
serviceCollection.AddSingleton<ListenBrainzLabsClient>();
|
||||||
serviceCollection.AddSingleton<ListenBrainzSimilarArtistProvider>();
|
serviceCollection.AddSingleton<ListenBrainzSimilarArtistProvider>();
|
||||||
|
|
||||||
|
// register the generic local metadata provider for comic files
|
||||||
|
serviceCollection.AddSingleton<ComicProvider>();
|
||||||
|
|
||||||
|
// register the actual implementations of the local metadata provider for comic files
|
||||||
|
serviceCollection.AddSingleton<IComicProvider, ComicBookInfoProvider>();
|
||||||
|
serviceCollection.AddSingleton<IComicProvider, ExternalComicInfoProvider>();
|
||||||
|
serviceCollection.AddSingleton<IComicProvider, InternalComicInfoProvider>();
|
||||||
|
|
||||||
serviceCollection.AddSingleton(NetManager);
|
serviceCollection.AddSingleton(NetManager);
|
||||||
|
|
||||||
serviceCollection.AddSingleton<ITaskManager, TaskManager>();
|
serviceCollection.AddSingleton<ITaskManager, TaskManager>();
|
||||||
|
|||||||
19
Emby.Server.Implementations/Localization/Core/az.json
Normal file
19
Emby.Server.Implementations/Localization/Core/az.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"Books": "Kitablar",
|
||||||
|
"HomeVideos": "Ev Videoları",
|
||||||
|
"Latest": "Ən son",
|
||||||
|
"MixedContent": "Qarışıq məzmun",
|
||||||
|
"Movies": "Filmlər",
|
||||||
|
"Music": "Musiqi",
|
||||||
|
"MusicVideos": "Musiqi Videoları",
|
||||||
|
"NameSeasonUnknown": "Mövsüm Naməlum",
|
||||||
|
"NewVersionIsAvailable": "Jellyfin Serverin yeni versiyası yükləmək üçün əlçatandır.",
|
||||||
|
"NotificationOptionApplicationUpdateAvailable": "Tətbiq yeniləməsi mövcuddur",
|
||||||
|
"NotificationOptionApplicationUpdateInstalled": "Tətbiq yeniləməsi quraşdırılıb",
|
||||||
|
"NotificationOptionAudioPlayback": "Audio oxutma başladı",
|
||||||
|
"NotificationOptionAudioPlaybackStopped": "Audio oxutma dayandırıldı",
|
||||||
|
"NotificationOptionCameraImageUploaded": "Kamera şəkli yükləndi",
|
||||||
|
"NotificationOptionInstallationFailed": "Quraşdırma uğursuzluğu",
|
||||||
|
"NotificationOptionNewLibraryContent": "Yeni məzmun əlavə edildi",
|
||||||
|
"NotificationOptionPluginError": "Plugin uğursuzluğu"
|
||||||
|
}
|
||||||
@@ -106,5 +106,6 @@
|
|||||||
"TaskRefreshTrickplayImages": "Xerar miniaturas de previsualización",
|
"TaskRefreshTrickplayImages": "Xerar miniaturas de previsualización",
|
||||||
"TaskAudioNormalizationDescription": "Escanea ficheiros á procura de datos de normalización de volume.",
|
"TaskAudioNormalizationDescription": "Escanea ficheiros á procura de datos de normalización de volume.",
|
||||||
"CleanupUserDataTask": "Tarefa de limpeza de datos dos usuarios",
|
"CleanupUserDataTask": "Tarefa de limpeza de datos dos usuarios",
|
||||||
"CleanupUserDataTaskDescription": "Limpa todos os datos do usuario (estado de visualización, de favorito etc.) dos medios ausentes polo menos 90 días."
|
"CleanupUserDataTaskDescription": "Limpa todos os datos do usuario (estado de visualización, de favorito etc.) dos medios ausentes polo menos 90 días.",
|
||||||
|
"Original": "Orixinal"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,5 +106,7 @@
|
|||||||
"TaskDownloadMissingLyrics": "누락된 가사 다운로드",
|
"TaskDownloadMissingLyrics": "누락된 가사 다운로드",
|
||||||
"TaskDownloadMissingLyricsDescription": "가사 다운로드",
|
"TaskDownloadMissingLyricsDescription": "가사 다운로드",
|
||||||
"CleanupUserDataTask": "사용자 데이터 정리 작업",
|
"CleanupUserDataTask": "사용자 데이터 정리 작업",
|
||||||
"CleanupUserDataTaskDescription": "최소 90일 이상 존재하지 않는 미디어에 대한 사용자 데이터(시청 상태, 즐겨찾기 등)를 정리합니다."
|
"CleanupUserDataTaskDescription": "최소 90일 이상 존재하지 않는 미디어에 대한 사용자 데이터(시청 상태, 즐겨찾기 등)를 정리합니다.",
|
||||||
|
"LyricDownloadFailureFromForItem": "{1}에 대한 가사를 {0}에서 다운로드하지 못했습니다",
|
||||||
|
"Original": "원본"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,5 +106,7 @@
|
|||||||
"TaskDownloadMissingLyrics": "Stiahnuť chýbajúce texty piesní",
|
"TaskDownloadMissingLyrics": "Stiahnuť chýbajúce texty piesní",
|
||||||
"TaskDownloadMissingLyricsDescription": "Stiahne texty pre piesne",
|
"TaskDownloadMissingLyricsDescription": "Stiahne texty pre piesne",
|
||||||
"CleanupUserDataTask": "Prečistiť používateľské dáta",
|
"CleanupUserDataTask": "Prečistiť používateľské dáta",
|
||||||
"CleanupUserDataTaskDescription": "Vyčistí všetky dáta používateľa (stav sledovania, stav obľúbených atď.) z médií, ktoré už neexistujú aspoň 90 dní."
|
"CleanupUserDataTaskDescription": "Vyčistí všetky dáta používateľa (stav sledovania, stav obľúbených atď.) z médií, ktoré už neexistujú aspoň 90 dní.",
|
||||||
|
"LyricDownloadFailureFromForItem": "Text piesne sa nepodarilo stiahnuť z {0} pre {1}",
|
||||||
|
"Original": "Originál"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,5 @@
|
|||||||
{}
|
{
|
||||||
|
"Artists": "Wasanii",
|
||||||
|
"Books": "Vitabu",
|
||||||
|
"Collections": "Mikusanyiko"
|
||||||
|
}
|
||||||
|
|||||||
@@ -566,11 +566,15 @@ namespace Emby.Server.Implementations.Localization
|
|||||||
|
|
||||||
private static string GetResourceFilename(string culture)
|
private static string GetResourceFilename(string culture)
|
||||||
{
|
{
|
||||||
var parts = culture.Split('-');
|
// Region codes may use a '-' (BCP-47, e.g. "pt-BR") or '_' (e.g. "es_419", "ar_SA") separator.
|
||||||
|
// Normalize the casing (lower-case language, upper-case region) while preserving the separator
|
||||||
|
// so the result matches the embedded resource file name, which is case-sensitive.
|
||||||
|
var separatorIndex = culture.IndexOfAny(['-', '_']);
|
||||||
|
|
||||||
if (parts.Length == 2)
|
if (separatorIndex > 0)
|
||||||
{
|
{
|
||||||
culture = parts[0].ToLowerInvariant() + "-" + parts[1].ToUpperInvariant();
|
var separator = culture[separatorIndex];
|
||||||
|
culture = culture[..separatorIndex].ToLowerInvariant() + separator + culture[(separatorIndex + 1)..].ToUpperInvariant();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Database.Implementations;
|
using Jellyfin.Database.Implementations;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Model.Globalization;
|
using MediaBrowser.Model.Globalization;
|
||||||
using MediaBrowser.Model.Tasks;
|
using MediaBrowser.Model.Tasks;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -17,6 +18,7 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
|
|||||||
private readonly ILogger<OptimizeDatabaseTask> _logger;
|
private readonly ILogger<OptimizeDatabaseTask> _logger;
|
||||||
private readonly ILocalizationManager _localization;
|
private readonly ILocalizationManager _localization;
|
||||||
private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider;
|
private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider;
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="OptimizeDatabaseTask" /> class.
|
/// Initializes a new instance of the <see cref="OptimizeDatabaseTask" /> class.
|
||||||
@@ -24,14 +26,17 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
|
|||||||
/// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
|
/// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
|
||||||
/// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
|
/// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
|
||||||
/// <param name="jellyfinDatabaseProvider">Instance of the JellyfinDatabaseProvider that can be used for provider specific operations.</param>
|
/// <param name="jellyfinDatabaseProvider">Instance of the JellyfinDatabaseProvider that can be used for provider specific operations.</param>
|
||||||
|
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||||
public OptimizeDatabaseTask(
|
public OptimizeDatabaseTask(
|
||||||
ILogger<OptimizeDatabaseTask> logger,
|
ILogger<OptimizeDatabaseTask> logger,
|
||||||
ILocalizationManager localization,
|
ILocalizationManager localization,
|
||||||
IJellyfinDatabaseProvider jellyfinDatabaseProvider)
|
IJellyfinDatabaseProvider jellyfinDatabaseProvider,
|
||||||
|
ILibraryManager libraryManager)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_localization = localization;
|
_localization = localization;
|
||||||
_jellyfinDatabaseProvider = jellyfinDatabaseProvider;
|
_jellyfinDatabaseProvider = jellyfinDatabaseProvider;
|
||||||
|
_libraryManager = libraryManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -68,6 +73,15 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
// Vacuuming/checkpointing requires an exclusive lock on the database. Running it while a library scan is in
|
||||||
|
// progress causes both operations to contend for the database and can stall the scan, so defer optimization
|
||||||
|
// until no scan is running. The task will run again on its next trigger.
|
||||||
|
if (_libraryManager.IsScanRunning)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Skipping database optimization because a library scan is currently running.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Optimizing and vacuuming jellyfin.db...");
|
_logger.LogInformation("Optimizing and vacuuming jellyfin.db...");
|
||||||
|
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using MediaBrowser.Controller.Library;
|
|||||||
using MediaBrowser.Model.Globalization;
|
using MediaBrowser.Model.Globalization;
|
||||||
using MediaBrowser.Model.Tasks;
|
using MediaBrowser.Model.Tasks;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
|
namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
|||||||
private readonly ILibraryManager _libraryManager;
|
private readonly ILibraryManager _libraryManager;
|
||||||
private readonly ILocalizationManager _localization;
|
private readonly ILocalizationManager _localization;
|
||||||
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
|
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
|
||||||
|
private readonly ILogger<PeopleValidationTask> _logger;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="PeopleValidationTask" /> class.
|
/// Initializes a new instance of the <see cref="PeopleValidationTask" /> class.
|
||||||
@@ -27,11 +29,13 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
|||||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||||
/// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
|
/// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
|
||||||
/// <param name="dbContextFactory">Instance of the <see cref="IDbContextFactory{TContext}"/> interface.</param>
|
/// <param name="dbContextFactory">Instance of the <see cref="IDbContextFactory{TContext}"/> interface.</param>
|
||||||
public PeopleValidationTask(ILibraryManager libraryManager, ILocalizationManager localization, IDbContextFactory<JellyfinDbContext> dbContextFactory)
|
/// <param name="logger">Instance of the <see cref="ILogger{PeopleValidationTask}"/> interface.</param>
|
||||||
|
public PeopleValidationTask(ILibraryManager libraryManager, ILocalizationManager localization, IDbContextFactory<JellyfinDbContext> dbContextFactory, ILogger<PeopleValidationTask> logger)
|
||||||
{
|
{
|
||||||
_libraryManager = libraryManager;
|
_libraryManager = libraryManager;
|
||||||
_localization = localization;
|
_localization = localization;
|
||||||
_dbContextFactory = dbContextFactory;
|
_dbContextFactory = dbContextFactory;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -71,13 +75,18 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 2));
|
// People validation performs heavy database writes that contend with an active library scan.
|
||||||
await _libraryManager.ValidatePeopleAsync(subProgress, cancellationToken).ConfigureAwait(false);
|
// Defer it until the scan has finished; the task will run again on its next trigger.
|
||||||
|
if (_libraryManager.IsScanRunning)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Skipping people validation because a library scan is currently running.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
subProgress = new Progress<double>((val) => progress.Report((val / 2) + 50));
|
|
||||||
var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
await using (context.ConfigureAwait(false))
|
await using (context.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
|
IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 2));
|
||||||
var dupQuery = context.Peoples
|
var dupQuery = context.Peoples
|
||||||
.GroupBy(e => new { e.Name, e.PersonType })
|
.GroupBy(e => new { e.Name, e.PersonType })
|
||||||
.Where(e => e.Count() > 1)
|
.Where(e => e.Count() > 1)
|
||||||
@@ -123,7 +132,18 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
|||||||
ArrayPool<Guid[]>.Shared.Return(buffer);
|
ArrayPool<Guid[]>.Shared.Return(buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var peopleToDelete = await context.Peoples
|
||||||
|
.Where(p => !context.PeopleBaseItemMap.Any(m => m.PeopleId.Equals(p.Id)))
|
||||||
|
.ExecuteDeleteAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
_logger.LogInformation("Removed {Count} orphaned people.", peopleToDelete);
|
||||||
|
|
||||||
subProgress.Report(100);
|
subProgress.Report(100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IProgress<double> validateProgress = new Progress<double>((val) => progress.Report((val / 2) + 50));
|
||||||
|
await _libraryManager.ValidatePeopleAsync(validateProgress, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
progress.Report(100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -343,6 +343,10 @@ namespace Emby.Server.Implementations.Session
|
|||||||
_activeLiveStreamSessions.TryRemove(liveStreamId, out _);
|
_activeLiveStreamSessions.TryRemove(liveStreamId, out _);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
liveStreamNeedsToBeClosed = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (liveStreamNeedsToBeClosed)
|
if (liveStreamNeedsToBeClosed)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Buffers;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
@@ -32,6 +33,8 @@ namespace Emby.Server.Implementations.Updates
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class InstallationManager : IInstallationManager
|
public class InstallationManager : IInstallationManager
|
||||||
{
|
{
|
||||||
|
private static readonly SearchValues<char> InvalidPackageNameChars = SearchValues.Create([.. Path.GetInvalidFileNameChars(), '/', '\\']);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The logger.
|
/// The logger.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -521,9 +524,27 @@ namespace Emby.Server.Implementations.Updates
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!IsValidPackageDirectoryName(package.Name))
|
||||||
|
{
|
||||||
|
_logger.LogError("Refusing to install package with invalid name {PackageName}.", package.Name);
|
||||||
|
throw new InvalidDataException($"Plugin package name '{package.Name}' is not a valid directory name.");
|
||||||
|
}
|
||||||
|
|
||||||
// Always override the passed-in target (which is a file) and figure it out again
|
// Always override the passed-in target (which is a file) and figure it out again
|
||||||
string targetDir = Path.Combine(_appPaths.PluginsPath, package.Name);
|
string targetDir = Path.Combine(_appPaths.PluginsPath, package.Name);
|
||||||
|
|
||||||
|
var pluginsRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(_appPaths.PluginsPath));
|
||||||
|
var resolvedTarget = Path.GetFullPath(targetDir);
|
||||||
|
if (!resolvedTarget.StartsWith(pluginsRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
"Refusing to install package {PackageName}: resolved target {Resolved} is outside plugins directory {Root}.",
|
||||||
|
package.Name,
|
||||||
|
resolvedTarget,
|
||||||
|
pluginsRoot);
|
||||||
|
throw new InvalidDataException($"Plugin package name '{package.Name}' resolves outside the plugins directory.");
|
||||||
|
}
|
||||||
|
|
||||||
using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
|
using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
|
||||||
.GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false);
|
.GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false);
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
@@ -572,6 +593,26 @@ namespace Emby.Server.Implementations.Updates
|
|||||||
_pluginManager.ImportPluginFrom(targetDir);
|
_pluginManager.ImportPluginFrom(targetDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsValidPackageDirectoryName(string? name)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.Equals(".", StringComparison.Ordinal) || name.Equals("..", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.IndexOfAny(InvalidPackageNameChars) >= 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<bool> InstallPackageInternal(InstallationInfo package, CancellationToken cancellationToken)
|
private async Task<bool> InstallPackageInternal(InstallationInfo package, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
LocalPlugin? plugin = _pluginManager.Plugins.FirstOrDefault(p => p.Id.Equals(package.Id) && p.Version.Equals(package.Version))
|
LocalPlugin? plugin = _pluginManager.Plugins.FirstOrDefault(p => p.Id.Equals(package.Id) && p.Version.Equals(package.Version))
|
||||||
|
|||||||
@@ -1002,9 +1002,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(pw))
|
if (!string.IsNullOrEmpty(pw))
|
||||||
{
|
{
|
||||||
// TODO: remove ToLower when Convert.ToHexString supports lowercase
|
listingsProviderInfo.Password = Convert.ToHexStringLower(SHA1.HashData(Encoding.UTF8.GetBytes(pw)));
|
||||||
// Schedules Direct requires the hex to be lowercase
|
|
||||||
listingsProviderInfo.Password = Convert.ToHexString(SHA1.HashData(Encoding.UTF8.GetBytes(pw))).ToLowerInvariant();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return await _listingsManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false);
|
return await _listingsManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ using Jellyfin.Database.Implementations;
|
|||||||
using Jellyfin.Server.Implementations.StorageHelpers;
|
using Jellyfin.Server.Implementations.StorageHelpers;
|
||||||
using Jellyfin.Server.Implementations.SystemBackupService;
|
using Jellyfin.Server.Implementations.SystemBackupService;
|
||||||
using MediaBrowser.Controller;
|
using MediaBrowser.Controller;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Controller.SystemBackupService;
|
using MediaBrowser.Controller.SystemBackupService;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
@@ -33,6 +34,7 @@ public class BackupService : IBackupService
|
|||||||
private readonly IServerApplicationPaths _applicationPaths;
|
private readonly IServerApplicationPaths _applicationPaths;
|
||||||
private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider;
|
private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider;
|
||||||
private readonly IHostApplicationLifetime _hostApplicationLifetime;
|
private readonly IHostApplicationLifetime _hostApplicationLifetime;
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
private static readonly JsonSerializerOptions _serializerSettings = new JsonSerializerOptions(JsonSerializerDefaults.General)
|
private static readonly JsonSerializerOptions _serializerSettings = new JsonSerializerOptions(JsonSerializerDefaults.General)
|
||||||
{
|
{
|
||||||
AllowTrailingCommas = true,
|
AllowTrailingCommas = true,
|
||||||
@@ -50,13 +52,15 @@ public class BackupService : IBackupService
|
|||||||
/// <param name="applicationPaths">The application paths.</param>
|
/// <param name="applicationPaths">The application paths.</param>
|
||||||
/// <param name="jellyfinDatabaseProvider">The Jellyfin database Provider in use.</param>
|
/// <param name="jellyfinDatabaseProvider">The Jellyfin database Provider in use.</param>
|
||||||
/// <param name="applicationLifetime">The SystemManager.</param>
|
/// <param name="applicationLifetime">The SystemManager.</param>
|
||||||
|
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||||
public BackupService(
|
public BackupService(
|
||||||
ILogger<BackupService> logger,
|
ILogger<BackupService> logger,
|
||||||
IDbContextFactory<JellyfinDbContext> dbProvider,
|
IDbContextFactory<JellyfinDbContext> dbProvider,
|
||||||
IServerApplicationHost applicationHost,
|
IServerApplicationHost applicationHost,
|
||||||
IServerApplicationPaths applicationPaths,
|
IServerApplicationPaths applicationPaths,
|
||||||
IJellyfinDatabaseProvider jellyfinDatabaseProvider,
|
IJellyfinDatabaseProvider jellyfinDatabaseProvider,
|
||||||
IHostApplicationLifetime applicationLifetime)
|
IHostApplicationLifetime applicationLifetime,
|
||||||
|
ILibraryManager libraryManager)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_dbProvider = dbProvider;
|
_dbProvider = dbProvider;
|
||||||
@@ -64,6 +68,7 @@ public class BackupService : IBackupService
|
|||||||
_applicationPaths = applicationPaths;
|
_applicationPaths = applicationPaths;
|
||||||
_jellyfinDatabaseProvider = jellyfinDatabaseProvider;
|
_jellyfinDatabaseProvider = jellyfinDatabaseProvider;
|
||||||
_hostApplicationLifetime = applicationLifetime;
|
_hostApplicationLifetime = applicationLifetime;
|
||||||
|
_libraryManager = libraryManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
@@ -263,6 +268,14 @@ public class BackupService : IBackupService
|
|||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<BackupManifestDto> CreateBackupAsync(BackupOptionsDto backupOptions)
|
public async Task<BackupManifestDto> CreateBackupAsync(BackupOptionsDto backupOptions)
|
||||||
{
|
{
|
||||||
|
// Creating a backup runs a database optimization and reads the entire database under a transaction, both of
|
||||||
|
// which heavily contend with an active library scan and could capture an inconsistent database state.
|
||||||
|
if (_libraryManager.IsScanRunning)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Cannot create a backup while a library scan is running.");
|
||||||
|
throw new InvalidOperationException("Cannot create a backup while a library scan is running. Please try again once the scan has finished.");
|
||||||
|
}
|
||||||
|
|
||||||
var manifest = new BackupManifest()
|
var manifest = new BackupManifest()
|
||||||
{
|
{
|
||||||
DateCreated = DateTime.UtcNow,
|
DateCreated = DateTime.UtcNow,
|
||||||
|
|||||||
@@ -65,8 +65,13 @@ public class ItemPersistenceService : IItemPersistenceService
|
|||||||
descendantIds.Add(id);
|
descendantIds.Add(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use WhereOneOrMany instead of a raw HashSet.Contains so large id sets are bound as a
|
||||||
|
// single parameter (json_each) rather than one SQL variable per id, which would otherwise
|
||||||
|
// overflow SQLite's variable limit when deleting many items at once (e.g. migrations).
|
||||||
|
var ownerIds = descendantIds.ToArray();
|
||||||
var extraIds = context.BaseItems
|
var extraIds = context.BaseItems
|
||||||
.Where(e => e.OwnerId.HasValue && descendantIds.Contains(e.OwnerId.Value))
|
.Where(e => e.OwnerId.HasValue)
|
||||||
|
.WhereOneOrMany(ownerIds, e => e.OwnerId!.Value)
|
||||||
.Select(e => e.Id)
|
.Select(e => e.Id)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
|
|||||||
@@ -215,8 +215,11 @@ internal class JellyfinMigrationService
|
|||||||
logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingCodeMigrations.Length, stage);
|
logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingCodeMigrations.Length, stage);
|
||||||
migrations = pendingMigrations.OrderBy(e => e.Key).ToArray();
|
migrations = pendingMigrations.OrderBy(e => e.Key).ToArray();
|
||||||
|
|
||||||
|
var migrationIndex = 0;
|
||||||
foreach (var item in migrations)
|
foreach (var item in migrations)
|
||||||
{
|
{
|
||||||
|
// Surface generic "Running migration X of Y" progress in the always-visible startup UI header.
|
||||||
|
SetupServer.ReportActivity(StartupActivity.Migration(++migrationIndex, migrations.Length));
|
||||||
var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}");
|
var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -76,25 +76,36 @@ public class CleanupOrphanedExtras : IAsyncMigrationRoutine
|
|||||||
|
|
||||||
_logger.LogInformation("Found {Count} orphaned extras to remove", orphanedItemIds.Count);
|
_logger.LogInformation("Found {Count} orphaned extras to remove", orphanedItemIds.Count);
|
||||||
|
|
||||||
// Batch-resolve items for metadata path cleanup, then delete all at once
|
// Resolve items for metadata path cleanup, then delete in batches so we never issue one
|
||||||
var itemsToDelete = new List<BaseItem>();
|
// massive delete transaction and progress stays visible on large libraries.
|
||||||
foreach (var itemId in orphanedItemIds)
|
_logger.LogInformation("Deleting {Count} orphaned extras...", orphanedItemIds.Count);
|
||||||
|
const int deleteBatchSize = 500;
|
||||||
|
var deletedSoFar = 0;
|
||||||
|
for (var offset = 0; offset < orphanedItemIds.Count; offset += deleteBatchSize)
|
||||||
{
|
{
|
||||||
itemsToDelete.Add(BaseItemMapper.DeserializeBaseItem(
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
new Database.Implementations.Entities.BaseItemEntity()
|
|
||||||
{
|
var batch = orphanedItemIds.GetRange(offset, Math.Min(deleteBatchSize, orphanedItemIds.Count - offset));
|
||||||
Id = itemId.Id,
|
var itemsToDelete = batch
|
||||||
Path = itemId.Path,
|
.Select(itemId => BaseItemMapper.DeserializeBaseItem(
|
||||||
Type = itemId.Type
|
new Database.Implementations.Entities.BaseItemEntity()
|
||||||
},
|
{
|
||||||
_logger,
|
Id = itemId.Id,
|
||||||
null,
|
Path = itemId.Path,
|
||||||
true)!);
|
Type = itemId.Type
|
||||||
|
},
|
||||||
|
_logger,
|
||||||
|
null,
|
||||||
|
true)!)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete);
|
||||||
|
|
||||||
|
deletedSoFar += batch.Count;
|
||||||
|
_logger.LogInformation("Deleting orphaned extras: {Deleted}/{Total}", deletedSoFar, orphanedItemIds.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete);
|
_logger.LogInformation("Successfully removed {Count} orphaned extras", orphanedItemIds.Count);
|
||||||
|
|
||||||
_logger.LogInformation("Successfully removed {Count} orphaned extras", itemsToDelete.Count);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,19 +136,38 @@ public class FixIncorrectOwnerIdRelationships : IAsyncMigrationRoutine
|
|||||||
|
|
||||||
if (allIdsToDelete.Count > 0)
|
if (allIdsToDelete.Count > 0)
|
||||||
{
|
{
|
||||||
// Batch-resolve items for metadata path cleanup, then delete all at once
|
_logger.LogInformation("Deleting {Count} duplicate database entries...", allIdsToDelete.Count);
|
||||||
var itemsToDelete = allIdsToDelete
|
|
||||||
.Select(id => _libraryManager.GetItemById(id))
|
|
||||||
.Where(item => item is not null)
|
|
||||||
.ToList();
|
|
||||||
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
|
|
||||||
|
|
||||||
// Fall back to direct DB deletion for any items that couldn't be resolved via LibraryManager
|
// Delete in batches so progress is visible (item resolution and deletion can take a
|
||||||
var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
|
// long time on large libraries) and so we never issue one massive delete transaction.
|
||||||
var unresolvedIds = allIdsToDelete.Where(id => !deletedIds.Contains(id)).ToList();
|
const int deleteBatchSize = 500;
|
||||||
if (unresolvedIds.Count > 0)
|
var deletedSoFar = 0;
|
||||||
|
for (var offset = 0; offset < allIdsToDelete.Count; offset += deleteBatchSize)
|
||||||
{
|
{
|
||||||
_persistenceService.DeleteItem(unresolvedIds);
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var batchIds = allIdsToDelete.GetRange(offset, Math.Min(deleteBatchSize, allIdsToDelete.Count - offset));
|
||||||
|
|
||||||
|
// Resolve items for metadata path cleanup, then delete this batch
|
||||||
|
var itemsToDelete = batchIds
|
||||||
|
.Select(id => _libraryManager.GetItemById(id))
|
||||||
|
.Where(item => item is not null)
|
||||||
|
.ToList();
|
||||||
|
if (itemsToDelete.Count > 0)
|
||||||
|
{
|
||||||
|
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to direct DB deletion for any items that couldn't be resolved via LibraryManager
|
||||||
|
var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
|
||||||
|
var unresolvedIds = batchIds.Where(id => !deletedIds.Contains(id)).ToList();
|
||||||
|
if (unresolvedIds.Count > 0)
|
||||||
|
{
|
||||||
|
_persistenceService.DeleteItem(unresolvedIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
deletedSoFar += batchIds.Count;
|
||||||
|
_logger.LogInformation("Deleting duplicates: {Deleted}/{Total} items", deletedSoFar, allIdsToDelete.Count);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -182,23 +182,35 @@ public class MergeDuplicateMusicArtists : IAsyncMigrationRoutine
|
|||||||
// Resolve via LibraryManager so DeleteItemsUnsafeFast can also remove the
|
// Resolve via LibraryManager so DeleteItemsUnsafeFast can also remove the
|
||||||
// %MetadataPath%/artists/<Name> directories that the duplicate stubs left behind.
|
// %MetadataPath%/artists/<Name> directories that the duplicate stubs left behind.
|
||||||
// Fall back to the persistence service for any items the LibraryManager can't resolve.
|
// Fall back to the persistence service for any items the LibraryManager can't resolve.
|
||||||
var itemsToDelete = idsToDelete
|
// Delete in batches so we never issue one massive delete transaction and progress stays visible.
|
||||||
.Select(id => _libraryManager.GetItemById(id))
|
_logger.LogInformation("Deleting {Count} duplicate MusicArtist records...", idsToDelete.Count);
|
||||||
.Where(item => item is not null)
|
const int deleteBatchSize = 500;
|
||||||
.ToList();
|
var deletedSoFar = 0;
|
||||||
if (itemsToDelete.Count > 0)
|
for (var offset = 0; offset < idsToDelete.Count; offset += deleteBatchSize)
|
||||||
{
|
{
|
||||||
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
}
|
|
||||||
|
|
||||||
var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
|
var batchIds = idsToDelete.GetRange(offset, Math.Min(deleteBatchSize, idsToDelete.Count - offset));
|
||||||
var unresolvedIds = idsToDelete.Where(id => !deletedIds.Contains(id)).ToList();
|
|
||||||
if (unresolvedIds.Count > 0)
|
|
||||||
{
|
|
||||||
_persistenceService.DeleteItem(unresolvedIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("Removed {Count} duplicate MusicArtist records.", idsToDelete.Count);
|
var itemsToDelete = batchIds
|
||||||
|
.Select(id => _libraryManager.GetItemById(id))
|
||||||
|
.Where(item => item is not null)
|
||||||
|
.ToList();
|
||||||
|
if (itemsToDelete.Count > 0)
|
||||||
|
{
|
||||||
|
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
|
||||||
|
}
|
||||||
|
|
||||||
|
var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
|
||||||
|
var unresolvedIds = batchIds.Where(id => !deletedIds.Contains(id)).ToList();
|
||||||
|
if (unresolvedIds.Count > 0)
|
||||||
|
{
|
||||||
|
_persistenceService.DeleteItem(unresolvedIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
deletedSoFar += batchIds.Count;
|
||||||
|
_logger.LogInformation("Deleting duplicate MusicArtist records: {Deleted}/{Total}", deletedSoFar, idsToDelete.Count);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,23 +184,35 @@ public class MergeDuplicatePeople : IAsyncMigrationRoutine
|
|||||||
|
|
||||||
// Resolve via LibraryManager so DeleteItemsUnsafeFast can also remove the
|
// Resolve via LibraryManager so DeleteItemsUnsafeFast can also remove the
|
||||||
// %MetadataPath%/People/<Letter>/<Name> directories the duplicate stubs left behind.
|
// %MetadataPath%/People/<Letter>/<Name> directories the duplicate stubs left behind.
|
||||||
var itemsToDelete = idsToDelete
|
// Delete in batches so we never issue one massive delete transaction and progress stays visible.
|
||||||
.Select(id => _libraryManager.GetItemById(id))
|
_logger.LogInformation("Deleting {Count} duplicate Person BaseItems...", idsToDelete.Count);
|
||||||
.Where(item => item is not null)
|
const int deleteBatchSize = 500;
|
||||||
.ToList();
|
var deletedSoFar = 0;
|
||||||
if (itemsToDelete.Count > 0)
|
for (var offset = 0; offset < idsToDelete.Count; offset += deleteBatchSize)
|
||||||
{
|
{
|
||||||
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
}
|
|
||||||
|
|
||||||
var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
|
var batchIds = idsToDelete.GetRange(offset, Math.Min(deleteBatchSize, idsToDelete.Count - offset));
|
||||||
var unresolvedIds = idsToDelete.Where(id => !deletedIds.Contains(id)).ToList();
|
|
||||||
if (unresolvedIds.Count > 0)
|
|
||||||
{
|
|
||||||
_persistenceService.DeleteItem(unresolvedIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("Removed {Count} duplicate Person BaseItems.", idsToDelete.Count);
|
var itemsToDelete = batchIds
|
||||||
|
.Select(id => _libraryManager.GetItemById(id))
|
||||||
|
.Where(item => item is not null)
|
||||||
|
.ToList();
|
||||||
|
if (itemsToDelete.Count > 0)
|
||||||
|
{
|
||||||
|
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
|
||||||
|
}
|
||||||
|
|
||||||
|
var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
|
||||||
|
var unresolvedIds = batchIds.Where(id => !deletedIds.Contains(id)).ToList();
|
||||||
|
if (unresolvedIds.Count > 0)
|
||||||
|
{
|
||||||
|
_persistenceService.DeleteItem(unresolvedIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
deletedSoFar += batchIds.Count;
|
||||||
|
_logger.LogInformation("Deleting duplicate Person BaseItems: {Deleted}/{Total}", deletedSoFar, idsToDelete.Count);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task MergePeoplesRowsAsync(JellyfinDbContext context, CancellationToken cancellationToken)
|
private async Task MergePeoplesRowsAsync(JellyfinDbContext context, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -133,10 +133,12 @@ namespace Jellyfin.Server
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SetupServer.ReportActivity(StartupActivity.CheckingStorage);
|
||||||
StorageHelper.TestCommonPathsForStorageCapacity(appPaths, StartupLogger.Logger.With(_loggerFactory.CreateLogger<Startup>()).BeginGroup($"Storage Check"));
|
StorageHelper.TestCommonPathsForStorageCapacity(appPaths, StartupLogger.Logger.With(_loggerFactory.CreateLogger<Startup>()).BeginGroup($"Storage Check"));
|
||||||
|
|
||||||
StartupHelpers.PerformStaticInitialization();
|
StartupHelpers.PerformStaticInitialization();
|
||||||
|
|
||||||
|
SetupServer.ReportActivity(StartupActivity.Initializing);
|
||||||
await ApplyStartupMigrationAsync(appPaths, startupConfig, options).ConfigureAwait(false);
|
await ApplyStartupMigrationAsync(appPaths, startupConfig, options).ConfigureAwait(false);
|
||||||
|
|
||||||
do
|
do
|
||||||
@@ -195,6 +197,7 @@ namespace Jellyfin.Server
|
|||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(_restoreFromBackup))
|
if (!string.IsNullOrWhiteSpace(_restoreFromBackup))
|
||||||
{
|
{
|
||||||
|
SetupServer.ReportActivity(StartupActivity.RestoringBackup);
|
||||||
await appHost.ServiceProvider.GetService<IBackupService>()!.RestoreBackupAsync(_restoreFromBackup).ConfigureAwait(false);
|
await appHost.ServiceProvider.GetService<IBackupService>()!.RestoreBackupAsync(_restoreFromBackup).ConfigureAwait(false);
|
||||||
_restoreFromBackup = null;
|
_restoreFromBackup = null;
|
||||||
_restartOnShutdown = true;
|
_restartOnShutdown = true;
|
||||||
@@ -202,9 +205,13 @@ namespace Jellyfin.Server
|
|||||||
}
|
}
|
||||||
|
|
||||||
var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(appHost.ServiceProvider);
|
var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(appHost.ServiceProvider);
|
||||||
|
SetupServer.ReportActivity(StartupActivity.PreparingMigrations);
|
||||||
await jellyfinMigrationService.PrepareSystemForMigration(_logger).ConfigureAwait(false);
|
await jellyfinMigrationService.PrepareSystemForMigration(_logger).ConfigureAwait(false);
|
||||||
|
// "Preparing migrations" carries through the DB read; per-migration progress is reported
|
||||||
|
// as "Running migration X of Y" from inside the step once the pending set is known.
|
||||||
await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.CoreInitialisation, appHost.ServiceProvider).ConfigureAwait(false);
|
await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.CoreInitialisation, appHost.ServiceProvider).ConfigureAwait(false);
|
||||||
|
|
||||||
|
SetupServer.ReportActivity(StartupActivity.InitializingServices);
|
||||||
await appHost.InitializeServices(startupConfig).ConfigureAwait(false);
|
await appHost.InitializeServices(startupConfig).ConfigureAwait(false);
|
||||||
_appHost = appHost;
|
_appHost = appHost;
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ using Jellyfin.Server.Extensions;
|
|||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
using MediaBrowser.Common.Net;
|
using MediaBrowser.Common.Net;
|
||||||
using MediaBrowser.Controller;
|
using MediaBrowser.Controller;
|
||||||
using MediaBrowser.Model.IO;
|
|
||||||
using MediaBrowser.Model.System;
|
using MediaBrowser.Model.System;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
@@ -25,9 +24,6 @@ using Microsoft.Extensions.Diagnostics.HealthChecks;
|
|||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Primitives;
|
using Microsoft.Extensions.Primitives;
|
||||||
using Morestachio;
|
|
||||||
using Morestachio.Framework.IO.SingleStream;
|
|
||||||
using Morestachio.Rendering;
|
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using ILogger = Microsoft.Extensions.Logging.ILogger;
|
using ILogger = Microsoft.Extensions.Logging.ILogger;
|
||||||
|
|
||||||
@@ -44,7 +40,8 @@ public sealed class SetupServer : IDisposable
|
|||||||
private readonly ILoggerFactory _loggerFactory;
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
private readonly IConfiguration _startupConfiguration;
|
private readonly IConfiguration _startupConfiguration;
|
||||||
private readonly ServerConfigurationManager _configurationManager;
|
private readonly ServerConfigurationManager _configurationManager;
|
||||||
private IRenderer? _startupUiRenderer;
|
private static volatile string _currentActivity = StartupActivity.Starting;
|
||||||
|
private StartupUiRenderer? _startupUiRenderer;
|
||||||
private IHost? _startupServer;
|
private IHost? _startupServer;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
private bool _isUnhealthy;
|
private bool _isUnhealthy;
|
||||||
@@ -76,6 +73,12 @@ public sealed class SetupServer : IDisposable
|
|||||||
|
|
||||||
internal static ConcurrentQueue<StartupLogTopic>? LogQueue { get; set; } = new();
|
internal static ConcurrentQueue<StartupLogTopic>? LogQueue { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a generic, non-identifying summary of what startup is currently doing. This is shown in the
|
||||||
|
/// always-visible header of the startup UI to unauthenticated clients, so it never contains server specific details.
|
||||||
|
/// </summary>
|
||||||
|
internal static string CurrentActivity => _currentActivity;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets a value indicating whether Startup server is currently running.
|
/// Gets a value indicating whether Startup server is currently running.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -87,64 +90,9 @@ public sealed class SetupServer : IDisposable
|
|||||||
/// <returns>A Task.</returns>
|
/// <returns>A Task.</returns>
|
||||||
public async Task RunAsync()
|
public async Task RunAsync()
|
||||||
{
|
{
|
||||||
var fileTemplate = await File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "ServerSetupApp", "index.mstemplate.html")).ConfigureAwait(false);
|
ReportActivity(StartupActivity.Starting);
|
||||||
_startupUiRenderer = (await ParserOptionsBuilder.New()
|
_startupUiRenderer = await StartupUiRenderer.CreateAsync(
|
||||||
.WithTemplate(fileTemplate)
|
Path.Combine(AppContext.BaseDirectory, "ServerSetupApp", "index.mstemplate.html")).ConfigureAwait(false);
|
||||||
.WithFormatter(
|
|
||||||
(Version version, int arg) =>
|
|
||||||
{
|
|
||||||
// version type does not for some stupid reason implement IFormattable which morestachio relies on for ToString support therefor we need to do it manually.
|
|
||||||
return version.ToString(arg);
|
|
||||||
},
|
|
||||||
"ToString")
|
|
||||||
.WithFormatter(
|
|
||||||
(StartupLogTopic logEntry, IEnumerable<StartupLogTopic> children) =>
|
|
||||||
{
|
|
||||||
if (children.Any())
|
|
||||||
{
|
|
||||||
var maxLevel = logEntry.LogLevel;
|
|
||||||
var stack = new Stack<StartupLogTopic>(children);
|
|
||||||
|
|
||||||
while (maxLevel != LogLevel.Error && stack.Count > 0 && (logEntry = stack.Pop()) is not null) // error is the highest inherted error level.
|
|
||||||
{
|
|
||||||
maxLevel = maxLevel < logEntry.LogLevel ? logEntry.LogLevel : maxLevel;
|
|
||||||
foreach (var child in logEntry.Children)
|
|
||||||
{
|
|
||||||
stack.Push(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return maxLevel;
|
|
||||||
}
|
|
||||||
|
|
||||||
return logEntry.LogLevel;
|
|
||||||
},
|
|
||||||
"FormatLogLevel")
|
|
||||||
.WithFormatter(
|
|
||||||
(LogLevel logLevel) =>
|
|
||||||
{
|
|
||||||
switch (logLevel)
|
|
||||||
{
|
|
||||||
case LogLevel.Trace:
|
|
||||||
case LogLevel.Debug:
|
|
||||||
case LogLevel.None:
|
|
||||||
return "success";
|
|
||||||
case LogLevel.Information:
|
|
||||||
return "info";
|
|
||||||
case LogLevel.Warning:
|
|
||||||
return "warn";
|
|
||||||
case LogLevel.Error:
|
|
||||||
return "danger";
|
|
||||||
case LogLevel.Critical:
|
|
||||||
return "danger-strong";
|
|
||||||
}
|
|
||||||
|
|
||||||
return string.Empty;
|
|
||||||
},
|
|
||||||
"ToString")
|
|
||||||
.BuildAndParseAsync()
|
|
||||||
.ConfigureAwait(false))
|
|
||||||
.CreateCompiledRenderer();
|
|
||||||
|
|
||||||
ThrowIfDisposed();
|
ThrowIfDisposed();
|
||||||
var retryAfterValue = TimeSpan.FromSeconds(5);
|
var retryAfterValue = TimeSpan.FromSeconds(5);
|
||||||
@@ -257,13 +205,14 @@ public sealed class SetupServer : IDisposable
|
|||||||
new Dictionary<string, object>()
|
new Dictionary<string, object>()
|
||||||
{
|
{
|
||||||
{ "isInReportingMode", _isUnhealthy },
|
{ "isInReportingMode", _isUnhealthy },
|
||||||
|
{ "currentActivity", CurrentActivity },
|
||||||
{ "retryValue", retryAfterValue },
|
{ "retryValue", retryAfterValue },
|
||||||
{ "version", version },
|
{ "version", version },
|
||||||
{ "logs", startupLogEntries },
|
{ "logs", startupLogEntries },
|
||||||
{ "networkManagerReady", networkManager is not null },
|
{ "networkManagerReady", networkManager is not null },
|
||||||
{ "localNetworkRequest", networkManager is not null && context.Connection.RemoteIpAddress is not null && networkManager.IsInLocalNetwork(context.Connection.RemoteIpAddress) }
|
{ "localNetworkRequest", networkManager is not null && context.Connection.RemoteIpAddress is not null && networkManager.IsInLocalNetwork(context.Connection.RemoteIpAddress) }
|
||||||
},
|
},
|
||||||
new ByteCounterStream(context.Response.BodyWriter.AsStream(), IODefaults.FileStreamBufferSize, true, _startupUiRenderer.ParserOptions))
|
context.Response.BodyWriter.AsStream())
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -309,6 +258,16 @@ public sealed class SetupServer : IDisposable
|
|||||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reports the current startup activity shown to all clients in the startup UI header.
|
||||||
|
/// Only pass generic, non-identifying text from <see cref="StartupActivity"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="activity">A generic description such as <see cref="StartupActivity.PreparingMigrations"/>.</param>
|
||||||
|
internal static void ReportActivity(string activity)
|
||||||
|
{
|
||||||
|
_currentActivity = activity;
|
||||||
|
}
|
||||||
|
|
||||||
internal void SoftStop()
|
internal void SoftStop()
|
||||||
{
|
{
|
||||||
_isUnhealthy = true;
|
_isUnhealthy = true;
|
||||||
|
|||||||
41
Jellyfin.Server/ServerSetupApp/StartupActivity.cs
Normal file
41
Jellyfin.Server/ServerSetupApp/StartupActivity.cs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.ServerSetupApp;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A curated vocabulary of generic, non-identifying descriptions of what the server is doing during startup.
|
||||||
|
/// These are shown in the always-visible header of the startup UI to <b>unauthenticated</b> clients, so every
|
||||||
|
/// value must stay generic and must never contain server specific details (paths, names, plugin or migration ids, counts of items, etc.).
|
||||||
|
/// </summary>
|
||||||
|
public static class StartupActivity
|
||||||
|
{
|
||||||
|
/// <summary>The default state before any work has been reported.</summary>
|
||||||
|
public const string Starting = "Starting up";
|
||||||
|
|
||||||
|
/// <summary>Validating that the configured storage locations are usable.</summary>
|
||||||
|
public const string CheckingStorage = "Checking storage";
|
||||||
|
|
||||||
|
/// <summary>Bringing up the migration subsystem and running early startup checks.</summary>
|
||||||
|
public const string Initializing = "Initializing server";
|
||||||
|
|
||||||
|
/// <summary>Preparing the system for migrations (e.g. taking safety backups).</summary>
|
||||||
|
public const string PreparingMigrations = "Preparing migrations";
|
||||||
|
|
||||||
|
/// <summary>Restoring from a backup.</summary>
|
||||||
|
public const string RestoringBackup = "Restoring backup";
|
||||||
|
|
||||||
|
/// <summary>Bringing up core services and plugins.</summary>
|
||||||
|
public const string InitializingServices = "Initializing services";
|
||||||
|
|
||||||
|
/// <summary>Running the final startup tasks.</summary>
|
||||||
|
public const string FinishingStartup = "Finishing startup";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a generic "Running migration X of Y" description. Only the numeric position and total are exposed.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="current">The 1-based index of the migration currently running.</param>
|
||||||
|
/// <param name="total">The total number of migrations in this batch.</param>
|
||||||
|
/// <returns>A generic progress description.</returns>
|
||||||
|
public static string Migration(int current, int total)
|
||||||
|
=> string.Format(CultureInfo.InvariantCulture, "Running migration {0} of {1}", current, total);
|
||||||
|
}
|
||||||
109
Jellyfin.Server/ServerSetupApp/StartupUiRenderer.cs
Normal file
109
Jellyfin.Server/ServerSetupApp/StartupUiRenderer.cs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MediaBrowser.Model.IO;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Morestachio;
|
||||||
|
using Morestachio.Framework.IO.SingleStream;
|
||||||
|
using Morestachio.Rendering;
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.ServerSetupApp;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compiles and renders the startup UI Morestachio template.
|
||||||
|
/// Shared by the live <see cref="SetupServer"/> and the standalone startup UI preview tool so both
|
||||||
|
/// exercise the exact same template and formatters.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StartupUiRenderer
|
||||||
|
{
|
||||||
|
private readonly IRenderer _renderer;
|
||||||
|
|
||||||
|
private StartupUiRenderer(IRenderer renderer)
|
||||||
|
{
|
||||||
|
_renderer = renderer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compiles the startup UI template located at <paramref name="templatePath"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="templatePath">The full path to the <c>index.mstemplate.html</c> template.</param>
|
||||||
|
/// <returns>A ready to use <see cref="StartupUiRenderer"/>.</returns>
|
||||||
|
public static async Task<StartupUiRenderer> CreateAsync(string templatePath)
|
||||||
|
{
|
||||||
|
var fileTemplate = await File.ReadAllTextAsync(templatePath).ConfigureAwait(false);
|
||||||
|
var renderer = (await ParserOptionsBuilder.New()
|
||||||
|
.WithTemplate(fileTemplate)
|
||||||
|
.WithFormatter(
|
||||||
|
(Version version, int arg) =>
|
||||||
|
{
|
||||||
|
// version type does not for some stupid reason implement IFormattable which morestachio relies on for ToString support therefor we need to do it manually.
|
||||||
|
return version.ToString(arg);
|
||||||
|
},
|
||||||
|
"ToString")
|
||||||
|
.WithFormatter(
|
||||||
|
(StartupLogTopic logEntry, IEnumerable<StartupLogTopic> children) =>
|
||||||
|
{
|
||||||
|
if (children.Any())
|
||||||
|
{
|
||||||
|
var maxLevel = logEntry.LogLevel;
|
||||||
|
var stack = new Stack<StartupLogTopic>(children);
|
||||||
|
|
||||||
|
while (maxLevel != LogLevel.Error && stack.Count > 0 && (logEntry = stack.Pop()) is not null) // error is the highest inherted error level.
|
||||||
|
{
|
||||||
|
maxLevel = maxLevel < logEntry.LogLevel ? logEntry.LogLevel : maxLevel;
|
||||||
|
foreach (var child in logEntry.Children)
|
||||||
|
{
|
||||||
|
stack.Push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return maxLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
return logEntry.LogLevel;
|
||||||
|
},
|
||||||
|
"FormatLogLevel")
|
||||||
|
.WithFormatter(
|
||||||
|
(LogLevel logLevel) =>
|
||||||
|
{
|
||||||
|
switch (logLevel)
|
||||||
|
{
|
||||||
|
case LogLevel.Trace:
|
||||||
|
case LogLevel.Debug:
|
||||||
|
case LogLevel.None:
|
||||||
|
return "success";
|
||||||
|
case LogLevel.Information:
|
||||||
|
return "info";
|
||||||
|
case LogLevel.Warning:
|
||||||
|
return "warn";
|
||||||
|
case LogLevel.Error:
|
||||||
|
return "danger";
|
||||||
|
case LogLevel.Critical:
|
||||||
|
return "danger-strong";
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Empty;
|
||||||
|
},
|
||||||
|
"ToString")
|
||||||
|
.BuildAndParseAsync()
|
||||||
|
.ConfigureAwait(false))
|
||||||
|
.CreateCompiledRenderer();
|
||||||
|
|
||||||
|
return new StartupUiRenderer(renderer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders the template with the provided model into the target stream.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="model">The values made available to the template.</param>
|
||||||
|
/// <param name="output">The stream the rendered HTML is written to.</param>
|
||||||
|
/// <returns>A Task.</returns>
|
||||||
|
public Task RenderAsync(IDictionary<string, object> model, Stream output)
|
||||||
|
{
|
||||||
|
return _renderer.RenderAsync(
|
||||||
|
model,
|
||||||
|
new ByteCounterStream(output, IODefaults.FileStreamBufferSize, true, _renderer.ParserOptions));
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,23 +0,0 @@
|
|||||||
using MediaBrowser.Controller;
|
|
||||||
using MediaBrowser.Controller.Plugins;
|
|
||||||
using MediaBrowser.Providers.Books.ComicBookInfo;
|
|
||||||
using MediaBrowser.Providers.Books.ComicInfo;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
|
|
||||||
namespace MediaBrowser.Providers.Books;
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public class ComicServiceRegistrator : IPluginServiceRegistrator
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
|
|
||||||
{
|
|
||||||
// register the generic local metadata provider for comic files
|
|
||||||
serviceCollection.AddSingleton<ComicProvider>();
|
|
||||||
|
|
||||||
// register the actual implementations of the local metadata provider for comic files
|
|
||||||
serviceCollection.AddSingleton<IComicProvider, ComicBookInfoProvider>();
|
|
||||||
serviceCollection.AddSingleton<IComicProvider, ExternalComicInfoProvider>();
|
|
||||||
serviceCollection.AddSingleton<IComicProvider, InternalComicInfoProvider>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -549,7 +549,7 @@ namespace MediaBrowser.Providers.MediaInfo
|
|||||||
var candidateUnsynchronizedLyric = supportedLyrics.FirstOrDefault(l => l.Format is LyricsInfo.LyricsFormat.UNSYNCHRONIZED or LyricsInfo.LyricsFormat.OTHER && l.UnsynchronizedLyrics is not null);
|
var candidateUnsynchronizedLyric = supportedLyrics.FirstOrDefault(l => l.Format is LyricsInfo.LyricsFormat.UNSYNCHRONIZED or LyricsInfo.LyricsFormat.OTHER && l.UnsynchronizedLyrics is not null);
|
||||||
var lyrics = candidateSynchronizedLyric is not null ? candidateSynchronizedLyric.FormatSynch() : candidateUnsynchronizedLyric?.UnsynchronizedLyrics;
|
var lyrics = candidateSynchronizedLyric is not null ? candidateSynchronizedLyric.FormatSynch() : candidateUnsynchronizedLyric?.UnsynchronizedLyrics;
|
||||||
if (!string.IsNullOrWhiteSpace(lyrics)
|
if (!string.IsNullOrWhiteSpace(lyrics)
|
||||||
&& tryExtractEmbeddedLyrics)
|
&& (tryExtractEmbeddedLyrics || options.ReplaceAllMetadata))
|
||||||
{
|
{
|
||||||
await _lyricManager.SaveLyricAsync(audio, "lrc", lyrics).ConfigureAwait(false);
|
await _lyricManager.SaveLyricAsync(audio, "lrc", lyrics).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ using Jellyfin.Database.Implementations.Entities;
|
|||||||
using Jellyfin.Database.Implementations.Enums;
|
using Jellyfin.Database.Implementations.Enums;
|
||||||
using Jellyfin.Extensions;
|
using Jellyfin.Extensions;
|
||||||
using Jellyfin.Extensions.Json;
|
using Jellyfin.Extensions.Json;
|
||||||
|
using Jellyfin.LiveTv;
|
||||||
using MediaBrowser.Common.Extensions;
|
using MediaBrowser.Common.Extensions;
|
||||||
using MediaBrowser.Controller.Channels;
|
using MediaBrowser.Controller.Channels;
|
||||||
using MediaBrowser.Controller.Configuration;
|
using MediaBrowser.Controller.Configuration;
|
||||||
@@ -1109,9 +1110,8 @@ namespace Jellyfin.LiveTv.Channels
|
|||||||
item.Path = mediaSource?.Path;
|
item.Path = mediaSource?.Path;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(info.ImageUrl) && !item.HasImage(ImageType.Primary))
|
if (LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(item, null, info.ImageUrl))
|
||||||
{
|
{
|
||||||
item.SetImagePath(ImageType.Primary, info.ImageUrl);
|
|
||||||
_logger.LogDebug("Forcing update due to ImageUrl {0}", item.Name);
|
_logger.LogDebug("Forcing update due to ImageUrl {0}", item.Name);
|
||||||
forceUpdate = true;
|
forceUpdate = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
using Jellyfin.Extensions;
|
using Jellyfin.Extensions;
|
||||||
|
using Jellyfin.LiveTv;
|
||||||
using Jellyfin.LiveTv.Configuration;
|
using Jellyfin.LiveTv.Configuration;
|
||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
using MediaBrowser.Controller.Dto;
|
using MediaBrowser.Controller.Dto;
|
||||||
@@ -448,23 +449,9 @@ public class GuideManager : IGuideManager
|
|||||||
|
|
||||||
item.Name = channelInfo.Name;
|
item.Name = channelInfo.Name;
|
||||||
|
|
||||||
var currentPrimary = item.GetImageInfo(ImageType.Primary, 0);
|
if (LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(item, channelInfo.ImagePath, channelInfo.ImageUrl))
|
||||||
var imageUrlIsNull = string.IsNullOrWhiteSpace(channelInfo.ImageUrl);
|
|
||||||
|
|
||||||
// Update channel image if image URL has changed
|
|
||||||
if (currentPrimary is null
|
|
||||||
|| (!imageUrlIsNull && !string.Equals(currentPrimary.Path, channelInfo.ImageUrl, StringComparison.Ordinal)))
|
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath))
|
forceUpdate = true;
|
||||||
{
|
|
||||||
item.SetImagePath(ImageType.Primary, channelInfo.ImagePath);
|
|
||||||
forceUpdate = true;
|
|
||||||
}
|
|
||||||
else if (!imageUrlIsNull)
|
|
||||||
{
|
|
||||||
item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl);
|
|
||||||
forceUpdate = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNew)
|
if (isNew)
|
||||||
|
|||||||
@@ -748,9 +748,7 @@ namespace Jellyfin.LiveTv.Listings
|
|||||||
#pragma warning disable CA5350 // SchedulesDirect is always SHA1.
|
#pragma warning disable CA5350 // SchedulesDirect is always SHA1.
|
||||||
var hashedPasswordBytes = SHA1.HashData(Encoding.ASCII.GetBytes(password));
|
var hashedPasswordBytes = SHA1.HashData(Encoding.ASCII.GetBytes(password));
|
||||||
#pragma warning restore CA5350
|
#pragma warning restore CA5350
|
||||||
// TODO: remove ToLower when Convert.ToHexString supports lowercase
|
string hashedPassword = Convert.ToHexStringLower(hashedPasswordBytes);
|
||||||
// Schedules Direct requires the hex to be lowercase
|
|
||||||
string hashedPassword = Convert.ToHexString(hashedPasswordBytes).ToLowerInvariant();
|
|
||||||
options.Content = new StringContent("{\"username\":\"" + username + "\",\"password\":\"" + hashedPassword + "\"}", Encoding.UTF8, MediaTypeNames.Application.Json);
|
options.Content = new StringContent("{\"username\":\"" + username + "\",\"password\":\"" + hashedPassword + "\"}", Encoding.UTF8, MediaTypeNames.Application.Json);
|
||||||
|
|
||||||
var root = await Request<TokenDto>(options, false, null, cancellationToken).ConfigureAwait(false);
|
var root = await Request<TokenDto>(options, false, null, cancellationToken).ConfigureAwait(false);
|
||||||
|
|||||||
33
src/Jellyfin.LiveTv/LiveTvChannelImageHelper.cs
Normal file
33
src/Jellyfin.LiveTv/LiveTvChannelImageHelper.cs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
|
||||||
|
namespace Jellyfin.LiveTv;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helpers for keeping Live TV channel icons in sync with guide data.
|
||||||
|
/// </summary>
|
||||||
|
internal static class LiveTvChannelImageHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Applies the channel icon from guide or tuner metadata.
|
||||||
|
/// Called on each guide refresh so remote icons are re-downloaded even when the URL is unchanged.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="item">The channel item.</param>
|
||||||
|
/// <param name="imagePath">The local image path from the tuner, if any.</param>
|
||||||
|
/// <param name="imageUrl">The remote image URL from the guide provider, if any.</param>
|
||||||
|
/// <returns><c>true</c> when the item image metadata was updated.</returns>
|
||||||
|
internal static bool UpdateChannelImageIfNeeded(BaseItem item, string? imagePath, string? imageUrl)
|
||||||
|
{
|
||||||
|
var newImageSource = !string.IsNullOrWhiteSpace(imagePath)
|
||||||
|
? imagePath
|
||||||
|
: imageUrl;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(newImageSource))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.SetImagePath(ImageType.Primary, newImageSource);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
51
tests/Jellyfin.LiveTv.Tests/LiveTvChannelImageHelperTests.cs
Normal file
51
tests/Jellyfin.LiveTv.Tests/LiveTvChannelImageHelperTests.cs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
using Jellyfin.LiveTv;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.LiveTv;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Jellyfin.LiveTv.Tests;
|
||||||
|
|
||||||
|
public class LiveTvChannelImageHelperTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void UpdateChannelImageIfNeeded_NoSource_DoesNotUpdate()
|
||||||
|
{
|
||||||
|
var channel = new LiveTvChannel { Name = "Test Channel" };
|
||||||
|
|
||||||
|
var updated = LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(channel, null, null);
|
||||||
|
|
||||||
|
Assert.False(updated);
|
||||||
|
Assert.False(channel.HasImage(ImageType.Primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UpdateChannelImageIfNeeded_WithUrl_AppliesUrl()
|
||||||
|
{
|
||||||
|
var channel = new LiveTvChannel { Name = "Test Channel" };
|
||||||
|
|
||||||
|
var updated = LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(
|
||||||
|
channel,
|
||||||
|
null,
|
||||||
|
"https://example.com/icon.png");
|
||||||
|
|
||||||
|
Assert.True(updated);
|
||||||
|
Assert.True(channel.HasImage(ImageType.Primary));
|
||||||
|
Assert.Equal("https://example.com/icon.png", channel.GetImagePath(ImageType.Primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UpdateChannelImageIfNeeded_SameUrl_StillUpdates()
|
||||||
|
{
|
||||||
|
var channel = new LiveTvChannel { Name = "Test Channel" };
|
||||||
|
LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(channel, null, "https://example.com/icon.png");
|
||||||
|
|
||||||
|
var updated = LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(
|
||||||
|
channel,
|
||||||
|
null,
|
||||||
|
"https://example.com/icon.png");
|
||||||
|
|
||||||
|
Assert.True(updated);
|
||||||
|
Assert.Equal("https://example.com/icon.png", channel.GetImagePath(ImageType.Primary));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -344,6 +344,20 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
|
|||||||
Assert.NotEqual("Default", translated);
|
Assert.NotEqual("Default", translated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetLocalizedString_WithBcp47NormalizationToUppercaseRegion_ReturnsTranslation()
|
||||||
|
{
|
||||||
|
var localizationManager = Setup(new ServerConfiguration
|
||||||
|
{
|
||||||
|
UICulture = "en-US"
|
||||||
|
});
|
||||||
|
|
||||||
|
// he-IL normalizes to the underscore resource he_IL. The resource lookup is case-sensitive,
|
||||||
|
// so the region casing has to be preserved or the file is not found and we fall back to en-US.
|
||||||
|
var translated = localizationManager.GetLocalizedString("Books", "he-IL");
|
||||||
|
Assert.Equal("ספרים", translated);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void GetServerLocalizedString_UsesServerCulture()
|
public void GetServerLocalizedString_UsesServerCulture()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -109,5 +109,29 @@ namespace Jellyfin.Server.Implementations.Tests.Updates
|
|||||||
var ex = await Record.ExceptionAsync(() => _installationManager.InstallPackage(packageInfo, CancellationToken.None));
|
var ex = await Record.ExceptionAsync(() => _installationManager.InstallPackage(packageInfo, CancellationToken.None));
|
||||||
Assert.Null(ex);
|
Assert.Null(ex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("../evil")]
|
||||||
|
[InlineData("..\\evil")]
|
||||||
|
[InlineData("../../escape_attempt")]
|
||||||
|
[InlineData("..")]
|
||||||
|
[InlineData(".")]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
[InlineData("foo/bar")]
|
||||||
|
[InlineData("foo\\bar")]
|
||||||
|
[InlineData("/absolute")]
|
||||||
|
[InlineData("foo\0bar")]
|
||||||
|
public async Task InstallPackage_InvalidName_ThrowsInvalidDataException(string name)
|
||||||
|
{
|
||||||
|
var packageInfo = new InstallationInfo()
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
SourceUrl = "https://repo.jellyfin.org/releases/plugin/empty/empty.zip",
|
||||||
|
Checksum = "11b5b2f1a9ebc4f66d6ef19018543361"
|
||||||
|
};
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidDataException>(() => _installationManager.InstallPackage(packageInfo, CancellationToken.None));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user