mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-04-11 04:42:18 +01:00
Compare commits
138 Commits
v10.11.0-r
...
v10.11.0-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
982e0c9370 | ||
|
|
55e681b9a6 | ||
|
|
7ba77804c4 | ||
|
|
af6f5a8ed0 | ||
|
|
1162fcebf8 | ||
|
|
38d0367c42 | ||
|
|
7d3372018f | ||
|
|
8629831658 | ||
|
|
db55d983f8 | ||
|
|
4d5ba8d7a5 | ||
|
|
6d4169a449 | ||
|
|
8dcb0bfecb | ||
|
|
844d69ab64 | ||
|
|
5c36b44484 | ||
|
|
4e4d7e7764 | ||
|
|
4c268a3579 | ||
|
|
77bcd2f5f6 | ||
|
|
8406924471 | ||
|
|
67fd4ce187 | ||
|
|
b37b39773a | ||
|
|
6f98767aed | ||
|
|
643460f484 | ||
|
|
a4231bf428 | ||
|
|
9c817a97a9 | ||
|
|
dde306b170 | ||
|
|
e2b61d951b | ||
|
|
9eff25bfed | ||
|
|
ff4484eb4a | ||
|
|
62b2adbf66 | ||
|
|
9ac8c2a2fa | ||
|
|
90e72fb687 | ||
|
|
630846798d | ||
|
|
9d5be19a27 | ||
|
|
6058ab50f8 | ||
|
|
e3b379052d | ||
|
|
0b6f4b2bd9 | ||
|
|
4f6db1bc22 | ||
|
|
8c8c71125c | ||
|
|
c6e568692e | ||
|
|
d5a76bdff8 | ||
|
|
ebdc756547 | ||
|
|
10d0cec7b9 | ||
|
|
10cc651790 | ||
|
|
7d18f3d6ed | ||
|
|
9b8c12d433 | ||
|
|
ba0eb87371 | ||
|
|
d561cef81f | ||
|
|
b528c1100f | ||
|
|
96c9f4fdad | ||
|
|
6d077fcf40 | ||
|
|
ab99b2bad3 | ||
|
|
db36be7a6b | ||
|
|
85f158e1dd | ||
|
|
e1365bd253 | ||
|
|
1ec66adc30 | ||
|
|
af0bcbc652 | ||
|
|
b2312466e1 | ||
|
|
cc7915c2e6 | ||
|
|
a537c66da1 | ||
|
|
a43adf42f3 | ||
|
|
6996c8a1de | ||
|
|
f976630003 | ||
|
|
965cf93419 | ||
|
|
70ea3f863a | ||
|
|
989aef18af | ||
|
|
ccb917b8df | ||
|
|
7cf6389ab5 | ||
|
|
2473b89a8d | ||
|
|
6575c69a4e | ||
|
|
66d594836c | ||
|
|
43028f735f | ||
|
|
e83b992eef | ||
|
|
8368d10d1b | ||
|
|
e8291fc856 | ||
|
|
308707476d | ||
|
|
e252589900 | ||
|
|
1220cac255 | ||
|
|
7218d82c21 | ||
|
|
a4524eb2ad | ||
|
|
553ba56389 | ||
|
|
afa2103d42 | ||
|
|
7256c9c89d | ||
|
|
f3cdaeaa12 | ||
|
|
368808eba4 | ||
|
|
0fc8ed6aeb | ||
|
|
f60281d8fd | ||
|
|
2936588c0f | ||
|
|
0e1be6ce30 | ||
|
|
4cd0a2ed8d | ||
|
|
aa05185917 | ||
|
|
2d9257b203 | ||
|
|
d1d9c8ed06 | ||
|
|
23c25289da | ||
|
|
aad6bca955 | ||
|
|
9f0f9a276f | ||
|
|
6016159860 | ||
|
|
6ffc044af1 | ||
|
|
c22f24319b | ||
|
|
1c4c9cf733 | ||
|
|
ea34a38f09 | ||
|
|
bbcfb2f421 | ||
|
|
0873fa8a86 | ||
|
|
9dc50b4ac6 | ||
|
|
617ab0d0ca | ||
|
|
dee9629037 | ||
|
|
31f3b5f6bb | ||
|
|
2ac6a7ba3f | ||
|
|
ece77779f8 | ||
|
|
c15c1f82a3 | ||
|
|
a15352b80c | ||
|
|
304b944152 | ||
|
|
e81c8ac6d1 | ||
|
|
97c1cb2f26 | ||
|
|
ac9d84f602 | ||
|
|
f3bf3c9853 | ||
|
|
644245bb7c | ||
|
|
a18c0007b4 | ||
|
|
c8a51160b4 | ||
|
|
4a0a45a045 | ||
|
|
91da1c035d | ||
|
|
6b5ce934b3 | ||
|
|
7174bb6a93 | ||
|
|
7037121bd0 | ||
|
|
7417da0e5c | ||
|
|
1e8bf1ce8d | ||
|
|
d4c3d24e52 | ||
|
|
d3ad2aec60 | ||
|
|
3554f068fb | ||
|
|
6dac1fde0a | ||
|
|
56fe4a158e | ||
|
|
c2332d340c | ||
|
|
d5b5c71baf | ||
|
|
7aee5b1e70 | ||
|
|
a8601b3797 | ||
|
|
1e9e4ffda9 | ||
|
|
d7faf9a327 | ||
|
|
bdb3adeb30 | ||
|
|
1f5cfb1e23 |
@@ -3,7 +3,7 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "9.0.5",
|
||||
"version": "9.0.7",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
]
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"dotnetRuntimeVersions": "9.0",
|
||||
"aspNetCoreRuntimeVersions": "9.0"
|
||||
},
|
||||
"ghcr.io/devcontainers-contrib/features/apt-packages:1": {
|
||||
"ghcr.io/devcontainers-extra/features/apt-packages:1": {
|
||||
"preserve_apt_list": false,
|
||||
"packages": [
|
||||
"libfontconfig1"
|
||||
|
||||
6
.github/workflows/ci-codeql-analysis.yml
vendored
6
.github/workflows/ci-codeql-analysis.yml
vendored
@@ -27,11 +27,11 @@ jobs:
|
||||
dotnet-version: '9.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19
|
||||
uses: github/codeql-action/init@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19
|
||||
uses: github/codeql-action/autobuild@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19
|
||||
uses: github/codeql-action/analyze@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||
|
||||
2
.github/workflows/ci-tests.yml
vendored
2
.github/workflows/ci-tests.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
--verbosity minimal
|
||||
|
||||
- name: Merge code coverage results
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@c9576654e2fea2faa7b69e59550b3805bf6a9977 # v5.4.7
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@4c0f60daf67483745c34efdeadd4c4e78a19991e # v5.4.8
|
||||
with:
|
||||
reports: "**/coverage.cobertura.xml"
|
||||
targetdir: "merged/"
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
- [cryptobank](https://github.com/cryptobank)
|
||||
- [cvium](https://github.com/cvium)
|
||||
- [dannymichel](https://github.com/dannymichel)
|
||||
- [darioackermann](https://github.com/darioackermann)
|
||||
- [DaveChild](https://github.com/DaveChild)
|
||||
- [DavidFair](https://github.com/DavidFair)
|
||||
- [Delgan](https://github.com/Delgan)
|
||||
@@ -196,6 +197,8 @@
|
||||
- [benedikt257](https://github.com/benedikt257)
|
||||
- [revam](https://github.com/revam)
|
||||
- [allesmi](https://github.com/allesmi)
|
||||
- [ThunderClapLP](https://github.com/ThunderClapLP)
|
||||
- [Shoham Peller](https://github.com/spellr)
|
||||
|
||||
# Emby Contributors
|
||||
|
||||
|
||||
@@ -9,12 +9,12 @@
|
||||
<PackageVersion Include="AutoFixture.Xunit2" Version="4.18.1" />
|
||||
<PackageVersion Include="AutoFixture" Version="4.18.1" />
|
||||
<PackageVersion Include="BDInfo" Version="0.8.0" />
|
||||
<PackageVersion Include="BitFaster.Caching" Version="2.5.3" />
|
||||
<PackageVersion Include="BitFaster.Caching" Version="2.5.4" />
|
||||
<PackageVersion Include="BlurHashSharp.SkiaSharp" Version="1.4.0-pre.1" />
|
||||
<PackageVersion Include="BlurHashSharp" Version="1.4.0-pre.1" />
|
||||
<PackageVersion Include="CommandLineParser" Version="2.9.1" />
|
||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageVersion Include="Diacritics" Version="3.3.29" />
|
||||
<PackageVersion Include="Diacritics" Version="4.0.14" />
|
||||
<PackageVersion Include="DiscUtils.Udf" Version="0.16.13" />
|
||||
<PackageVersion Include="DotNet.Glob" Version="3.1.3" />
|
||||
<PackageVersion Include="FsCheck.Xunit" Version="3.3.0" />
|
||||
@@ -24,31 +24,31 @@
|
||||
<PackageVersion Include="Ignore" Version="0.2.1" />
|
||||
<PackageVersion Include="Jellyfin.XmlTv" Version="10.8.0" />
|
||||
<PackageVersion Include="libse" Version="4.0.12" />
|
||||
<PackageVersion Include="LrcParser" Version="2025.228.1" />
|
||||
<PackageVersion Include="LrcParser" Version="2025.623.0" />
|
||||
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="6.1.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.5" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.5" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.7" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.7" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.5" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.5" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.5" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.5" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.5" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.5" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.5" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.5" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.5" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.5" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.5" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.5" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.5" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.5" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.5" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.5" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.5" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.5" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.5" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.5" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.7" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.7" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.7" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.7" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageVersion Include="MimeTypes" Version="2.5.2" />
|
||||
<PackageVersion Include="Morestachio" Version="5.0.1.631" />
|
||||
@@ -59,9 +59,10 @@
|
||||
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
||||
<PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" />
|
||||
<PackageVersion Include="prometheus-net" Version="8.2.1" />
|
||||
<PackageVersion Include="Polly" Version="8.5.2" />
|
||||
<PackageVersion Include="Polly" Version="8.6.2" />
|
||||
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
|
||||
<PackageVersion Include="Serilog.Expressions" Version="5.0.0" />
|
||||
<PackageVersion Include="Serilog.Settings.Configuration" Version="9.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Async" Version="2.1.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
@@ -69,21 +70,22 @@
|
||||
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
|
||||
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
|
||||
<PackageVersion Include="SharpFuzz" Version="2.2.0" />
|
||||
<PackageVersion Include="SkiaSharp" Version="3.119.0" />
|
||||
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.119.0" />
|
||||
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.119.0" />
|
||||
<!-- Pinned to 3.116.1 because https://github.com/jellyfin/jellyfin/pull/14255 -->
|
||||
<PackageVersion Include="SkiaSharp" Version="3.116.1" />
|
||||
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.116.1" />
|
||||
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.116.1" />
|
||||
<PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" />
|
||||
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
|
||||
<PackageVersion Include="Svg.Skia" Version="3.0.3" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="6.5.0" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.2.3" />
|
||||
<PackageVersion Include="System.Globalization" Version="4.3.0" />
|
||||
<PackageVersion Include="System.Linq.Async" Version="6.0.1" />
|
||||
<PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.5" />
|
||||
<PackageVersion Include="System.Text.Json" Version="9.0.5" />
|
||||
<PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.5" />
|
||||
<PackageVersion Include="System.Linq.Async" Version="6.0.3" />
|
||||
<PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.7" />
|
||||
<PackageVersion Include="System.Text.Json" Version="9.0.7" />
|
||||
<PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.7" />
|
||||
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
|
||||
<PackageVersion Include="z440.atl.core" Version="6.24.0" />
|
||||
<PackageVersion Include="z440.atl.core" Version="7.0.0" />
|
||||
<PackageVersion Include="TMDbLib" Version="2.2.0" />
|
||||
<PackageVersion Include="UTF.Unknown" Version="2.5.1" />
|
||||
<PackageVersion Include="Xunit.Priority" Version="1.1.6" />
|
||||
@@ -91,4 +93,4 @@
|
||||
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.23" />
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -188,7 +188,8 @@ namespace Emby.Naming.Common
|
||||
"disk",
|
||||
"vol",
|
||||
"volume",
|
||||
"part"
|
||||
"part",
|
||||
"act"
|
||||
};
|
||||
|
||||
ArtistSubfolders = new[]
|
||||
|
||||
@@ -108,7 +108,7 @@ public class PhotoProvider : ICustomMetadataProvider<Photo>, IForcedProvider, IH
|
||||
var dateTaken = image.ImageTag.DateTime;
|
||||
if (dateTaken.HasValue)
|
||||
{
|
||||
item.DateCreated = dateTaken.Value;
|
||||
item.DateCreated = dateTaken.Value.ToUniversalTime();
|
||||
item.PremiereDate = dateTaken.Value;
|
||||
item.ProductionYear = dateTaken.Value.Year;
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.LibraryTaskScheduler;
|
||||
using MediaBrowser.Controller.LiveTv;
|
||||
using MediaBrowser.Controller.Lyrics;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
@@ -552,6 +553,7 @@ namespace Emby.Server.Implementations
|
||||
serviceCollection.AddSingleton<ISessionManager, SessionManager>();
|
||||
|
||||
serviceCollection.AddSingleton<ICollectionManager, CollectionManager>();
|
||||
serviceCollection.AddSingleton<ILimitedConcurrencyLibraryScheduler, LimitedConcurrencyLibraryScheduler>();
|
||||
|
||||
serviceCollection.AddSingleton<IPlaylistManager, PlaylistManager>();
|
||||
|
||||
@@ -650,6 +652,7 @@ namespace Emby.Server.Implementations
|
||||
CollectionFolder.ApplicationHost = this;
|
||||
Folder.UserViewManager = Resolve<IUserViewManager>();
|
||||
Folder.CollectionManager = Resolve<ICollectionManager>();
|
||||
Folder.LimitedConcurrencyLibraryScheduler = Resolve<ILimitedConcurrencyLibraryScheduler>();
|
||||
Episode.MediaEncoder = Resolve<IMediaEncoder>();
|
||||
UserView.TVSeriesManager = Resolve<ITVSeriesManager>();
|
||||
Video.RecordingsManager = Resolve<IRecordingsManager>();
|
||||
|
||||
@@ -1065,7 +1065,12 @@ namespace Emby.Server.Implementations.Dto
|
||||
|
||||
if (options.ContainsField(ItemFields.Trickplay))
|
||||
{
|
||||
dto.Trickplay = _trickplayManager.GetTrickplayManifest(item).GetAwaiter().GetResult();
|
||||
var trickplay = _trickplayManager.GetTrickplayManifest(item).GetAwaiter().GetResult();
|
||||
dto.Trickplay = trickplay.ToDictionary(
|
||||
mediaStream => mediaStream.Key,
|
||||
mediaStream => mediaStream.Value.ToDictionary(
|
||||
width => width.Key,
|
||||
width => new TrickplayInfoDto(width.Value)));
|
||||
}
|
||||
|
||||
dto.ExtraType = video.ExtraType;
|
||||
|
||||
@@ -57,7 +57,7 @@ namespace Emby.Server.Implementations.HttpServer
|
||||
RemoteEndPoint = remoteEndPoint;
|
||||
|
||||
_jsonOptions = JsonDefaults.Options;
|
||||
LastActivityDate = DateTime.Now;
|
||||
LastActivityDate = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -43,13 +43,11 @@ namespace Emby.Server.Implementations.Images
|
||||
protected IImageProcessor ImageProcessor { get; set; }
|
||||
|
||||
protected virtual IReadOnlyCollection<ImageType> SupportedImages { get; }
|
||||
= new ImageType[] { ImageType.Primary };
|
||||
= [ImageType.Primary];
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Dynamic Image Provider";
|
||||
|
||||
protected virtual int MaxImageAgeDays => 7;
|
||||
|
||||
public int Order => 0;
|
||||
|
||||
protected virtual bool Supports(BaseItem item) => true;
|
||||
@@ -292,8 +290,14 @@ namespace Emby.Server.Implementations.Images
|
||||
|
||||
protected virtual bool HasChangedByDate(BaseItem item, ItemImageInfo image)
|
||||
{
|
||||
var age = DateTime.UtcNow - image.DateModified;
|
||||
return age.TotalDays > MaxImageAgeDays;
|
||||
var path = image.Path;
|
||||
if (!string.IsNullOrEmpty(path))
|
||||
{
|
||||
var modificationDate = FileSystem.GetLastWriteTimeUtc(path);
|
||||
return image.DateModified != modificationDate;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected string CreateSingleImage(IEnumerable<BaseItem> itemsWithImages, string outputPathWithoutExtension, ImageType imageType)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
@@ -6,6 +7,7 @@ using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using MediaBrowser.Controller.MediaSegments;
|
||||
using MediaBrowser.Controller.Trickplay;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Emby.Server.Implementations.Library;
|
||||
|
||||
@@ -18,6 +20,7 @@ public class ExternalDataManager : IExternalDataManager
|
||||
private readonly IMediaSegmentManager _mediaSegmentManager;
|
||||
private readonly IPathManager _pathManager;
|
||||
private readonly ITrickplayManager _trickplayManager;
|
||||
private readonly ILogger<ExternalDataManager> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ExternalDataManager"/> class.
|
||||
@@ -26,16 +29,19 @@ public class ExternalDataManager : IExternalDataManager
|
||||
/// <param name="mediaSegmentManager">The media segment manager.</param>
|
||||
/// <param name="pathManager">The path manager.</param>
|
||||
/// <param name="trickplayManager">The trickplay manager.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public ExternalDataManager(
|
||||
IKeyframeManager keyframeManager,
|
||||
IMediaSegmentManager mediaSegmentManager,
|
||||
IPathManager pathManager,
|
||||
ITrickplayManager trickplayManager)
|
||||
ITrickplayManager trickplayManager,
|
||||
ILogger<ExternalDataManager> logger)
|
||||
{
|
||||
_keyframeManager = keyframeManager;
|
||||
_mediaSegmentManager = mediaSegmentManager;
|
||||
_pathManager = pathManager;
|
||||
_trickplayManager = trickplayManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -47,7 +53,14 @@ public class ExternalDataManager : IExternalDataManager
|
||||
{
|
||||
foreach (var path in validPaths)
|
||||
{
|
||||
Directory.Delete(path, true);
|
||||
try
|
||||
{
|
||||
Directory.Delete(path, true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning("Unable to prune external item data at {Path}: {Exception}", path, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1981,6 +1981,8 @@ namespace Emby.Server.Implementations.Library
|
||||
return;
|
||||
}
|
||||
|
||||
var anyChange = false;
|
||||
|
||||
foreach (var img in outdated)
|
||||
{
|
||||
var image = img;
|
||||
@@ -2012,6 +2014,7 @@ namespace Emby.Server.Implementations.Library
|
||||
try
|
||||
{
|
||||
size = _imageProcessor.GetImageDimensions(item, image);
|
||||
anyChange = image.Width != size.Width || image.Height != size.Height;
|
||||
image.Width = size.Width;
|
||||
image.Height = size.Height;
|
||||
}
|
||||
@@ -2019,23 +2022,29 @@ namespace Emby.Server.Implementations.Library
|
||||
{
|
||||
_logger.LogError(ex, "Cannot get image dimensions for {ImagePath}", image.Path);
|
||||
size = default;
|
||||
anyChange = image.Width != size.Width || image.Height != size.Height;
|
||||
image.Width = 0;
|
||||
image.Height = 0;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
image.BlurHash = _imageProcessor.GetImageBlurHash(image.Path, size);
|
||||
var blurhash = _imageProcessor.GetImageBlurHash(image.Path, size);
|
||||
anyChange = anyChange || !blurhash.Equals(image.BlurHash, StringComparison.Ordinal);
|
||||
image.BlurHash = blurhash;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Cannot compute blurhash for {ImagePath}", image.Path);
|
||||
anyChange = anyChange || !string.IsNullOrEmpty(image.BlurHash);
|
||||
image.BlurHash = string.Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
image.DateModified = _fileSystem.GetLastWriteTimeUtc(image.Path);
|
||||
var modifiedDate = _fileSystem.GetLastWriteTimeUtc(image.Path);
|
||||
anyChange = anyChange || modifiedDate != image.DateModified;
|
||||
image.DateModified = modifiedDate;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -2043,20 +2052,28 @@ namespace Emby.Server.Implementations.Library
|
||||
}
|
||||
}
|
||||
|
||||
_itemRepository.SaveImages(item);
|
||||
if (anyChange)
|
||||
{
|
||||
_itemRepository.SaveImages(item);
|
||||
}
|
||||
|
||||
RegisterItem(item);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpdateItemsAsync(IReadOnlyList<BaseItem> items, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
|
||||
{
|
||||
_itemRepository.SaveItems(items, cancellationToken);
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
item.DateLastSaved = DateTime.UtcNow;
|
||||
await RunMetadataSavers(item, updateReason).ConfigureAwait(false);
|
||||
|
||||
// Modify again, so saved value is after write time of externally saved metadata
|
||||
item.DateLastSaved = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
_itemRepository.SaveItems(items, cancellationToken);
|
||||
|
||||
if (ItemUpdated is not null)
|
||||
{
|
||||
foreach (var item in items)
|
||||
@@ -2097,8 +2114,6 @@ namespace Emby.Server.Implementations.Library
|
||||
await ProviderManager.SaveMetadataAsync(item, updateReason).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
item.DateLastSaved = DateTime.UtcNow;
|
||||
|
||||
await UpdateImagesAsync(item, updateReason >= ItemUpdateType.ImageUpdate).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -2384,12 +2399,13 @@ namespace Emby.Server.Implementations.Library
|
||||
isNew = true;
|
||||
}
|
||||
|
||||
var refresh = isNew || DateTime.UtcNow - item.DateLastRefreshed >= _viewRefreshInterval;
|
||||
var lastRefreshedUtc = item.DateLastRefreshed;
|
||||
var refresh = isNew || DateTime.UtcNow - lastRefreshedUtc >= _viewRefreshInterval;
|
||||
|
||||
if (!refresh && !item.DisplayParentId.IsEmpty())
|
||||
{
|
||||
var displayParent = GetItemById(item.DisplayParentId);
|
||||
refresh = displayParent is not null && displayParent.DateLastSaved > item.DateLastRefreshed;
|
||||
refresh = displayParent is not null && displayParent.DateLastSaved > lastRefreshedUtc;
|
||||
}
|
||||
|
||||
if (refresh)
|
||||
@@ -2447,12 +2463,13 @@ namespace Emby.Server.Implementations.Library
|
||||
isNew = true;
|
||||
}
|
||||
|
||||
var refresh = isNew || DateTime.UtcNow - item.DateLastRefreshed >= _viewRefreshInterval;
|
||||
var lastRefreshedUtc = item.DateLastRefreshed;
|
||||
var refresh = isNew || DateTime.UtcNow - lastRefreshedUtc >= _viewRefreshInterval;
|
||||
|
||||
if (!refresh && !item.DisplayParentId.IsEmpty())
|
||||
{
|
||||
var displayParent = GetItemById(item.DisplayParentId);
|
||||
refresh = displayParent is not null && displayParent.DateLastSaved > item.DateLastRefreshed;
|
||||
refresh = displayParent is not null && displayParent.DateLastSaved > lastRefreshedUtc;
|
||||
}
|
||||
|
||||
if (refresh)
|
||||
@@ -2522,12 +2539,13 @@ namespace Emby.Server.Implementations.Library
|
||||
item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
var refresh = isNew || DateTime.UtcNow - item.DateLastRefreshed >= _viewRefreshInterval;
|
||||
var lastRefreshedUtc = item.DateLastRefreshed;
|
||||
var refresh = isNew || DateTime.UtcNow - lastRefreshedUtc >= _viewRefreshInterval;
|
||||
|
||||
if (!refresh && !item.DisplayParentId.IsEmpty())
|
||||
{
|
||||
var displayParent = GetItemById(item.DisplayParentId);
|
||||
refresh = displayParent is not null && displayParent.DateLastSaved > item.DateLastRefreshed;
|
||||
refresh = displayParent is not null && displayParent.DateLastSaved > lastRefreshedUtc;
|
||||
}
|
||||
|
||||
if (refresh)
|
||||
@@ -2987,21 +3005,28 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
if (personEntity is null)
|
||||
{
|
||||
var path = Person.GetPath(person.Name);
|
||||
var info = Directory.CreateDirectory(path);
|
||||
var lastWriteTime = info.LastWriteTimeUtc;
|
||||
personEntity = new Person()
|
||||
try
|
||||
{
|
||||
Name = person.Name,
|
||||
Id = GetItemByNameId<Person>(path),
|
||||
DateCreated = info.CreationTimeUtc,
|
||||
DateModified = lastWriteTime,
|
||||
Path = path
|
||||
};
|
||||
var path = Person.GetPath(person.Name);
|
||||
var info = Directory.CreateDirectory(path);
|
||||
personEntity = new Person()
|
||||
{
|
||||
Name = person.Name,
|
||||
Id = GetItemByNameId<Person>(path),
|
||||
DateCreated = info.CreationTimeUtc,
|
||||
DateModified = info.LastWriteTimeUtc,
|
||||
Path = path
|
||||
};
|
||||
|
||||
personEntity.PresentationUniqueKey = personEntity.CreatePresentationUniqueKey();
|
||||
saveEntity = true;
|
||||
createEntity = true;
|
||||
personEntity.PresentationUniqueKey = personEntity.CreatePresentationUniqueKey();
|
||||
saveEntity = true;
|
||||
createEntity = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to create person {Name}", person.Name);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var id in person.ProviderIds)
|
||||
|
||||
@@ -140,7 +140,7 @@ namespace Emby.Server.Implementations.Library
|
||||
if (fileCreationDate is not null)
|
||||
{
|
||||
var dateCreated = fileCreationDate;
|
||||
if (dateCreated.Equals(DateTime.MinValue))
|
||||
if (dateCreated == DateTime.MinValue)
|
||||
{
|
||||
dateCreated = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -462,7 +462,11 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
|
||||
{
|
||||
var movie = (T)result.Items[0];
|
||||
movie.IsInMixedFolder = false;
|
||||
movie.Name = Path.GetFileName(movie.ContainingFolderPath);
|
||||
if (collectionType == CollectionType.movies || collectionType is null)
|
||||
{
|
||||
movie.Name = Path.GetFileName(movie.ContainingFolderPath);
|
||||
}
|
||||
|
||||
return movie;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +125,6 @@ public class CollectionPostScanTask : ILibraryPostScanTask
|
||||
boxSet = await _collectionManager.CreateCollectionAsync(new CollectionCreationOptions
|
||||
{
|
||||
Name = collectionName,
|
||||
IsLocked = true
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
await _collectionManager.AddToCollectionAsync(boxSet.Id, movieIds).ConfigureAwait(false);
|
||||
|
||||
@@ -136,5 +136,7 @@
|
||||
"TaskExtractMediaSegments": "فحص مقاطع الوسائط",
|
||||
"TaskExtractMediaSegmentsDescription": "يستخرج مقاطع وسائط من إضافات MediaSegment المُفعّلة.",
|
||||
"TaskMoveTrickplayImages": "تغيير مكان صور المعاينة السريعة",
|
||||
"TaskMoveTrickplayImagesDescription": "تُنقل ملفات التشغيل السريع الحالية بناءً على إعدادات المكتبة."
|
||||
"TaskMoveTrickplayImagesDescription": "تُنقل ملفات التشغيل السريع الحالية بناءً على إعدادات المكتبة.",
|
||||
"CleanupUserDataTask": "مهمة تنظيف بيانات المستخدم",
|
||||
"CleanupUserDataTaskDescription": "مسح جميع بيانات المستخدم (حالة المشاهدة، والحالة المفضلة وما إلى ذلك) من الوسائط التي لم تعد موجودة لمدة 90 يومًا على الأقل."
|
||||
}
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
"DeviceOnlineWithName": "{0} està connectat",
|
||||
"FailedLoginAttemptWithUserName": "Intent de connexió fallit des de {0}",
|
||||
"Favorites": "Preferits",
|
||||
"Folders": "Carpetes",
|
||||
"Folders": "Directoris",
|
||||
"Genres": "Gèneres",
|
||||
"HeaderAlbumArtists": "Artistes de l'àlbum",
|
||||
"HeaderContinueWatching": "Continua veient",
|
||||
"HeaderContinueWatching": "Continueu mirant",
|
||||
"HeaderFavoriteAlbums": "Àlbums preferits",
|
||||
"HeaderFavoriteArtists": "Artistes preferits",
|
||||
"HeaderFavoriteEpisodes": "Episodis preferits",
|
||||
@@ -24,11 +24,11 @@
|
||||
"HeaderFavoriteSongs": "Cançons preferides",
|
||||
"HeaderLiveTV": "TV en directe",
|
||||
"HeaderNextUp": "A continuació",
|
||||
"HeaderRecordingGroups": "Grups Musicals",
|
||||
"HeaderRecordingGroups": "Grups musicals",
|
||||
"HomeVideos": "Vídeos domèstics",
|
||||
"Inherit": "Heretat",
|
||||
"ItemAddedWithName": "{0} s'ha afegit a la biblioteca",
|
||||
"ItemRemovedWithName": "{0} s'ha eliminat de la biblioteca",
|
||||
"ItemAddedWithName": "{0} s'ha afegit a la mediateca",
|
||||
"ItemRemovedWithName": "{0} s'ha eliminat de la mediateca",
|
||||
"LabelIpAddressValue": "Adreça IP: {0}",
|
||||
"LabelRunningTimeValue": "Temps en marxa: {0}",
|
||||
"Latest": "Darrers",
|
||||
@@ -43,7 +43,7 @@
|
||||
"NameInstallFailed": "{0} instal·lació fallida",
|
||||
"NameSeasonNumber": "Temporada {0}",
|
||||
"NameSeasonUnknown": "Temporada desconeguda",
|
||||
"NewVersionIsAvailable": "Una nova versió del servidor de Jellyfin està disponible per a descarregar.",
|
||||
"NewVersionIsAvailable": "Hi ha disponible una versió nova del servidor de Jellyfin per a la descàrrega.",
|
||||
"NotificationOptionApplicationUpdateAvailable": "Actualització de l'aplicatiu disponible",
|
||||
"NotificationOptionApplicationUpdateInstalled": "Actualització de l'aplicatiu instal·lada",
|
||||
"NotificationOptionAudioPlayback": "Reproducció d'àudio iniciada",
|
||||
@@ -64,7 +64,7 @@
|
||||
"Playlists": "Llistes de reproducció",
|
||||
"Plugin": "Complement",
|
||||
"PluginInstalledWithName": "{0} ha estat instal·lat",
|
||||
"PluginUninstalledWithName": "S'ha instalat {0}",
|
||||
"PluginUninstalledWithName": "S'ha instal·lat {0}",
|
||||
"PluginUpdatedWithName": "S'ha actualitzat {0}",
|
||||
"ProviderValue": "Proveïdor: {0}",
|
||||
"ScheduledTaskFailedWithName": "{0} ha fallat",
|
||||
@@ -72,10 +72,10 @@
|
||||
"ServerNameNeedsToBeRestarted": "S'ha de reiniciar {0}",
|
||||
"Shows": "Sèries",
|
||||
"Songs": "Cançons",
|
||||
"StartupEmbyServerIsLoading": "El servidor de Jellyfin s'està carregant. Proveu de nou en una estona.",
|
||||
"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": "Sincronitzar",
|
||||
"Sync": "Sincronitza",
|
||||
"System": "Sistema",
|
||||
"TvShows": "Sèries de TV",
|
||||
"User": "Usuari",
|
||||
@@ -89,52 +89,54 @@
|
||||
"UserPolicyUpdatedWithName": "La política d'usuari s'ha actualitzat per a {0}",
|
||||
"UserStartedPlayingItemWithValues": "{0} ha començat a reproduir {1} a {2}",
|
||||
"UserStoppedPlayingItemWithValues": "{0} ha parat de reproduir {1} a {2}",
|
||||
"ValueHasBeenAddedToLibrary": "S'ha afegit {0} a la teva biblioteca",
|
||||
"ValueHasBeenAddedToLibrary": "S'ha afegit {0} a la mediateca",
|
||||
"ValueSpecialEpisodeName": "Especial - {0}",
|
||||
"VersionNumber": "Versió {0}",
|
||||
"TaskDownloadMissingSubtitlesDescription": "Cerca a internet els subtítols que faltin a partir de la configuració de metadades.",
|
||||
"TaskDownloadMissingSubtitles": "Descarrega els subtítols que faltin",
|
||||
"TaskDownloadMissingSubtitles": "Descàrrega dels subtítols que faltin",
|
||||
"TaskRefreshChannelsDescription": "Actualitza la informació dels canals per internet.",
|
||||
"TaskRefreshChannels": "Actualitza els canals",
|
||||
"TaskCleanTranscodeDescription": "Elimina els arxius de transcodificacions que tinguin més d'un dia.",
|
||||
"TaskCleanTranscode": "Neteja les transcodificacions",
|
||||
"TaskUpdatePluginsDescription": "Actualitza els complements que estan configurats per a actualitzar-se automàticament.",
|
||||
"TaskUpdatePlugins": "Actualitza els complements",
|
||||
"TaskRefreshPeopleDescription": "Actualitza les metadades dels actors i directors de la teva biblioteca de mitjans.",
|
||||
"TaskRefreshPeople": "Actualitza les persones",
|
||||
"TaskCleanLogsDescription": "Esborra els logs que tinguin més de {0} dies.",
|
||||
"TaskCleanLogs": "Neteja els registres",
|
||||
"TaskRefreshLibraryDescription": "Escaneja la biblioteca de mitjans buscant fitxers nous i refresca les metadades.",
|
||||
"TaskRefreshLibrary": "Escaneja la biblioteca de mitjans",
|
||||
"TaskRefreshChapterImagesDescription": "Crea les miniatures dels vídeos que tinguin capítols.",
|
||||
"TaskRefreshChapterImages": "Extreure les imatges dels capítols",
|
||||
"TaskCleanCacheDescription": "Elimina la memòria cau no necessària per al servidor.",
|
||||
"TaskCleanCache": "Elimina la memòria cau",
|
||||
"TaskCleanTranscode": "Neteja de les transcodificacions",
|
||||
"TaskUpdatePluginsDescription": "Descarrega i instal·la els complements que estiguin configurats per a actualitzar-se automàticament.",
|
||||
"TaskUpdatePlugins": "Actualització dels complements",
|
||||
"TaskRefreshPeopleDescription": "Actualització de les metadades dels actors i directors de la mediateca.",
|
||||
"TaskRefreshPeople": "Actualització de les persones",
|
||||
"TaskCleanLogsDescription": "Esborra els registres que tinguin més de {0} dies.",
|
||||
"TaskCleanLogs": "Neteja dels registres",
|
||||
"TaskRefreshLibraryDescription": "Escaneig de la mediateca, a la cerca de fitxers nous i refresca les metadades.",
|
||||
"TaskRefreshLibrary": "Escaneig de la mediateca",
|
||||
"TaskRefreshChapterImagesDescription": "Creació de les miniatures dels vídeos que tinguin capítols.",
|
||||
"TaskRefreshChapterImages": "Extracció de les imatges dels capítols",
|
||||
"TaskCleanCacheDescription": "Eliminació de la memòria cau no necessària per al servidor.",
|
||||
"TaskCleanCache": "Eliminació de la memòria cau",
|
||||
"TasksChannelsCategory": "Canals per internet",
|
||||
"TasksApplicationCategory": "Aplicatiu",
|
||||
"TasksLibraryCategory": "Biblioteca",
|
||||
"TasksLibraryCategory": "Mediateca",
|
||||
"TasksMaintenanceCategory": "Manteniment",
|
||||
"TaskCleanActivityLogDescription": "Eliminades les entrades del registre d'activitats més antigues que l'antiguitat configurada.",
|
||||
"TaskCleanActivityLog": "Buidar el registre d'activitat",
|
||||
"TaskCleanActivityLogDescription": "Eliminació de les entrades del registre d'activitats més antigues que l'antiguitat configurada.",
|
||||
"TaskCleanActivityLog": "Buidat del registre d'activitat",
|
||||
"Undefined": "Indefinit",
|
||||
"Forced": "Forçat",
|
||||
"Default": "Per defecte",
|
||||
"TaskOptimizeDatabaseDescription": "Compacta la base de dades i trunca l'espai lliure. Executar aquesta tasca després d’escanejar la biblioteca o fer altres canvis que impliquin modificacions a la base de dades pot millorar el rendiment.",
|
||||
"TaskOptimizeDatabase": "Optimitzar la base de dades",
|
||||
"TaskKeyframeExtractorDescription": "Extreu fotogrames clau dels fitxers de vídeo per crear llistes de reproducció HLS més precises. Aquesta tasca pot durar molt de temps.",
|
||||
"TaskOptimizeDatabaseDescription": "Compacta la base de dades i trunca l'espai lliure. Executar aquesta tasca després d’escanejar la mediateca o fer d'altres canvis que impliquin modificacions a la base de dades pot millorar el rendiment.",
|
||||
"TaskOptimizeDatabase": "Optimització de la base de dades",
|
||||
"TaskKeyframeExtractorDescription": "Extractor de fotogrames clau dels fitxers de vídeo per a crear llistes de reproducció HLS més precises. Aquesta tasca pot allargar-se molt en el temps.",
|
||||
"TaskKeyframeExtractor": "Extractor de fotogrames clau",
|
||||
"External": "Extern",
|
||||
"HearingImpaired": "Discapacitat auditiva",
|
||||
"TaskRefreshTrickplayImages": "Generar miniatures de línia de temps",
|
||||
"TaskRefreshTrickplayImagesDescription": "Crear miniatures de línia de temps per vídeos en les biblioteques habilitades.",
|
||||
"TaskRefreshTrickplayImages": "Generació d'imatges de previsualització",
|
||||
"TaskRefreshTrickplayImagesDescription": "Creació d'imatges de previsualització per a vídeos en les mediateques habilitades.",
|
||||
"TaskCleanCollectionsAndPlaylistsDescription": "Esborra elements de col·leccions i llistes de reproducció que ja no existeixen.",
|
||||
"TaskCleanCollectionsAndPlaylists": "Neteja les col·leccions i llistes de reproducció",
|
||||
"TaskAudioNormalization": "Estabilització d'Àudio",
|
||||
"TaskAudioNormalizationDescription": "Escaneja arxius per dades d'estabilització d'àudio.",
|
||||
"TaskDownloadMissingLyricsDescription": "Baixar les lletres de les cançons",
|
||||
"TaskDownloadMissingLyrics": "Baixar les lletres que falten",
|
||||
"TaskCleanCollectionsAndPlaylists": "Neteja de les col·leccions i llistes de reproducció",
|
||||
"TaskAudioNormalization": "Estabilització de l'àudio",
|
||||
"TaskAudioNormalizationDescription": "Escaneja arxius per dades d'estabilització de l'àudio.",
|
||||
"TaskDownloadMissingLyricsDescription": "Descàrrega de les lletres de les cançons",
|
||||
"TaskDownloadMissingLyrics": "Descàrrega de les lletres que faltin",
|
||||
"TaskExtractMediaSegments": "Escaneig de segments multimèdia",
|
||||
"TaskExtractMediaSegmentsDescription": "Extreu o obté segments multimèdia usant els connectors MediaSegment activats.",
|
||||
"TaskMoveTrickplayImages": "Migra la ubicació de la imatge de Trickplay",
|
||||
"TaskMoveTrickplayImagesDescription": "Mou els fitxers trickplay existents segons la configuració de la biblioteca."
|
||||
"TaskMoveTrickplayImages": "Migració de la ubicació de la imatge de previsualització",
|
||||
"TaskMoveTrickplayImagesDescription": "Mou els fitxers existents d'imatges de previsualització segons la configuració de la mediateca.",
|
||||
"CleanupUserDataTaskDescription": "Neteja totes les dades d'usuari (estat de la visualització, estat dels preferits, etc.) del contingut multimèdia que no ha estat present durant almenys 90 dies.",
|
||||
"CleanupUserDataTask": "Tasca de neteja de dades d'usuari"
|
||||
}
|
||||
|
||||
@@ -136,5 +136,7 @@
|
||||
"TaskExtractMediaSegments": "Skenování segmentů médií",
|
||||
"TaskExtractMediaSegmentsDescription": "Extrahuje či získá segmenty médií pomocí zásuvných modulů MediaSegment.",
|
||||
"TaskMoveTrickplayImages": "Přesunout úložiště obrázků Trickplay",
|
||||
"TaskMoveTrickplayImagesDescription": "Přesune existující soubory Trickplay podle nastavení knihovny."
|
||||
"TaskMoveTrickplayImagesDescription": "Přesune existující soubory Trickplay podle nastavení knihovny.",
|
||||
"CleanupUserDataTaskDescription": "Odstraní všechna uživatelská data (stav zhlédnutí, oblíbené atd.) z médií, které již neexistují více než 90 dní.",
|
||||
"CleanupUserDataTask": "Pročistit uživatelská data"
|
||||
}
|
||||
|
||||
@@ -136,5 +136,7 @@
|
||||
"TaskExtractMediaSegments": "Mediensegmente scannen",
|
||||
"TaskExtractMediaSegmentsDescription": "Extrahiert oder empfängt Mediensegmente von Plugins die Mediensegmente nutzen.",
|
||||
"TaskMoveTrickplayImages": "Verzeichnis für Trickplay-Bilder migrieren",
|
||||
"TaskMoveTrickplayImagesDescription": "Trickplay-Bilder werden entsprechend der Bibliothekseinstellungen verschoben."
|
||||
"TaskMoveTrickplayImagesDescription": "Trickplay-Bilder werden entsprechend der Bibliothekseinstellungen verschoben.",
|
||||
"CleanupUserDataTask": "Aufgabe zur Bereinigung von Benutzerdaten",
|
||||
"CleanupUserDataTaskDescription": "Löscht alle Benutzerdaten (Anschaustatus, Favoritenstatus, usw.) von Medien, die seit mindestens 90 Tagen nicht mehr vorhanden sind."
|
||||
}
|
||||
|
||||
@@ -136,5 +136,7 @@
|
||||
"TaskExtractMediaSegments": "Media Segment Scan",
|
||||
"TaskExtractMediaSegmentsDescription": "Extracts or obtains media segments from MediaSegment enabled plugins.",
|
||||
"TaskMoveTrickplayImages": "Migrate Trickplay Image Location",
|
||||
"TaskMoveTrickplayImagesDescription": "Moves existing trickplay files according to the library settings."
|
||||
"TaskMoveTrickplayImagesDescription": "Moves existing trickplay files according to the library settings.",
|
||||
"CleanupUserDataTask": "User data cleanup task",
|
||||
"CleanupUserDataTaskDescription": "Cleans all user data (Watch state, favourite status etc) from media that is no longer present for at least 90 days."
|
||||
}
|
||||
|
||||
@@ -135,5 +135,7 @@
|
||||
"TaskExtractMediaSegments": "Media Segment Scan",
|
||||
"TaskExtractMediaSegmentsDescription": "Extracts or obtains media segments from MediaSegment enabled plugins.",
|
||||
"TaskMoveTrickplayImages": "Migrate Trickplay Image Location",
|
||||
"TaskMoveTrickplayImagesDescription": "Moves existing trickplay files according to the library settings."
|
||||
"TaskMoveTrickplayImagesDescription": "Moves existing trickplay files according to the library settings.",
|
||||
"CleanupUserDataTask": "User data cleanup task",
|
||||
"CleanupUserDataTaskDescription": "Cleans all user data (Watch state, favorite status etc) from media that is no longer present for at least 90 days."
|
||||
}
|
||||
|
||||
@@ -136,5 +136,7 @@
|
||||
"TaskMoveTrickplayImagesDescription": "Mueve archivos de trickplay existentes según la configuración de la biblioteca.",
|
||||
"TaskExtractMediaSegments": "Escaneo de segmentos de medios",
|
||||
"TaskExtractMediaSegmentsDescription": "Extrae u obtiene segmentos de medios de plugins habilitados para MediaSegment.",
|
||||
"TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay"
|
||||
"TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay",
|
||||
"CleanupUserDataTask": "Tarea de limpieza de datos del usuario",
|
||||
"CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, favoritos, etc.) de los medios que ya no están disponibles desde hace al menos 90 días."
|
||||
}
|
||||
|
||||
@@ -135,5 +135,7 @@
|
||||
"TaskExtractMediaSegmentsDescription": "Media segmentuak atera edo lortzen ditu MediaSegment gaituta duten pluginetik.",
|
||||
"TaskMoveTrickplayImages": "Aldatu Trickplay irudien kokalekua",
|
||||
"TaskMoveTrickplayImagesDescription": "Lehendik dauden trickplay fitxategiak liburutegiaren ezarpenen arabera mugitzen dira.",
|
||||
"TaskAudioNormalizationDescription": "Audio normalizazio datuak lortzeko fitxategiak eskaneatzen ditu."
|
||||
"TaskAudioNormalizationDescription": "Audio normalizazio datuak lortzeko fitxategiak eskaneatzen ditu.",
|
||||
"CleanupUserDataTaskDescription": "Gutxienez 90 egunez dagoeneko existitzen ez den multimediatik erabiltzaile-datu guztiak (ikusteko egoera, gogokoen egoera, etab.) garbitzen ditu.",
|
||||
"CleanupUserDataTask": "Erabiltzaileen datuak garbitzeko zeregina"
|
||||
}
|
||||
|
||||
@@ -135,5 +135,7 @@
|
||||
"TaskDownloadMissingLyricsDescription": "Ladataan sanoituksia",
|
||||
"TaskExtractMediaSegmentsDescription": "Poimii tai hankkii mediasegmenttejä MediaSegment-yhteensopivista laajennuksista.",
|
||||
"TaskMoveTrickplayImages": "Siirrä Trickplay-kuvien sijainti",
|
||||
"TaskMoveTrickplayImagesDescription": "Siirtää olemassa olevia trickplay-tiedostoja kirjaston asetusten mukaan."
|
||||
"TaskMoveTrickplayImagesDescription": "Siirtää olemassa olevia trickplay-tiedostoja kirjaston asetusten mukaan.",
|
||||
"CleanupUserDataTask": "Käyttäjätietojen puhdistustehtävä",
|
||||
"CleanupUserDataTaskDescription": "Puhdistaa kaikki käyttäjätiedot (katselutila, suosikit ym.) medioista, joita ei ole ollut saatavilla yli 90 päivään."
|
||||
}
|
||||
|
||||
@@ -136,5 +136,7 @@
|
||||
"TaskMoveTrickplayImagesDescription": "Déplace les fichiers trickplay existants en fonction des paramètres de la bibliothèque.",
|
||||
"TaskDownloadMissingLyrics": "Télécharger les paroles des chansons manquantes",
|
||||
"TaskMoveTrickplayImages": "Changer l'emplacement des images Trickplay",
|
||||
"TaskExtractMediaSegmentsDescription": "Extrait ou obtient des segments de média à partir des plugins compatibles avec MediaSegment."
|
||||
"TaskExtractMediaSegmentsDescription": "Extrait ou obtient des segments de média à partir des plugins compatibles avec MediaSegment.",
|
||||
"CleanupUserDataTaskDescription": "Nettoie toutes les données utilisateur (état de la montre, statut favori, etc.) des supports qui ne sont plus présents depuis au moins 90 jours.",
|
||||
"CleanupUserDataTask": "Tâche de nettoyage des données utilisateur"
|
||||
}
|
||||
|
||||
@@ -136,5 +136,7 @@
|
||||
"TaskExtractMediaSegments": "Analyse des segments de média",
|
||||
"TaskMoveTrickplayImages": "Changer l'emplacement des images Trickplay",
|
||||
"TaskExtractMediaSegmentsDescription": "Extrait ou obtient des segments de média à partir des plugins compatibles avec MediaSegment.",
|
||||
"TaskMoveTrickplayImagesDescription": "Déplace les fichiers trickplay existants en fonction des paramètres de la bibliothèque."
|
||||
"TaskMoveTrickplayImagesDescription": "Déplace les fichiers trickplay existants en fonction des paramètres de la bibliothèque.",
|
||||
"CleanupUserDataTaskDescription": "Nettoie toutes les données utilisateur (état de la montre, statut favori, etc.) des supports qui ne sont plus présents depuis au moins 90 jours.",
|
||||
"CleanupUserDataTask": "Tâche de nettoyage des données utilisateur"
|
||||
}
|
||||
|
||||
@@ -135,5 +135,7 @@
|
||||
"TaskUpdatePlugins": "Nuashonraigh Breiseáin",
|
||||
"TaskCleanTranscodeDescription": "Scriostar comhaid traschódaithe níos mó ná lá amháin d'aois.",
|
||||
"TaskCleanTranscode": "Eolaire Transcode Glan",
|
||||
"TaskDownloadMissingSubtitles": "Íosluchtaigh fotheidil ar iarraidh"
|
||||
"TaskDownloadMissingSubtitles": "Íosluchtaigh fotheidil ar iarraidh",
|
||||
"CleanupUserDataTask": "Tasc glantacháin sonraí úsáideora",
|
||||
"CleanupUserDataTaskDescription": "Glanann sé gach sonraí úsáideora (stádas faire, stádas is fearr leat srl.) ó mheáin nach bhfuil i láthair a thuilleadh ar feadh 90 lá ar a laghad."
|
||||
}
|
||||
|
||||
@@ -135,5 +135,6 @@
|
||||
"TaskMoveTrickplayImages": "Migrar a localización da imaxe de Trickplay",
|
||||
"TaskMoveTrickplayImagesDescription": "Move os ficheiros de reprodución con trickplay existentes segundo a configuración da biblioteca.",
|
||||
"TaskRefreshTrickplayImages": "Xerar imaxes de Trickplay",
|
||||
"TaskAudioNormalizationDescription": "Analiza ficheiros para obter datos de normalización de audio."
|
||||
"TaskAudioNormalizationDescription": "Analiza ficheiros para obter datos de normalización de audio.",
|
||||
"CleanupUserDataTask": "Tarefa de limpeza de datos do usuario"
|
||||
}
|
||||
|
||||
@@ -32,8 +32,8 @@
|
||||
"LabelIpAddressValue": "Ip כתובת: {0}",
|
||||
"LabelRunningTimeValue": "משך צפייה: {0}",
|
||||
"Latest": "אחרון",
|
||||
"MessageApplicationUpdated": "שרת ג'ליפין עודכן",
|
||||
"MessageApplicationUpdatedTo": "שרת ג'ליפין עודכן לגרסה {0}",
|
||||
"MessageApplicationUpdated": "שרת Jellyfin עודכן",
|
||||
"MessageApplicationUpdatedTo": "שרת Jellyfin עודכן לגרסה {0}",
|
||||
"MessageNamedServerConfigurationUpdatedWithValue": "סעיף הגדרת השרת {0} עודכן",
|
||||
"MessageServerConfigurationUpdated": "תצורת השרת עודכנה",
|
||||
"MixedContent": "תוכן מעורב",
|
||||
@@ -43,7 +43,7 @@
|
||||
"NameInstallFailed": "התקנת {0} נכשלה",
|
||||
"NameSeasonNumber": "עונה {0}",
|
||||
"NameSeasonUnknown": "עונה לא ידועה",
|
||||
"NewVersionIsAvailable": "גרסה חדשה של שרת ג'ליפין זמינה להורדה.",
|
||||
"NewVersionIsAvailable": "גרסה חדשה של שרת Jellyfin זמינה להורדה.",
|
||||
"NotificationOptionApplicationUpdateAvailable": "קיים עדכון זמין ליישום",
|
||||
"NotificationOptionApplicationUpdateInstalled": "עדכון ליישום הותקן",
|
||||
"NotificationOptionAudioPlayback": "ניגון שמע החל",
|
||||
@@ -72,7 +72,7 @@
|
||||
"ServerNameNeedsToBeRestarted": "{0} דורש הפעלה מחדש",
|
||||
"Shows": "סדרות",
|
||||
"Songs": "שירים",
|
||||
"StartupEmbyServerIsLoading": "שרת ג'ליפין טוען. נא לנסות שוב בקרוב.",
|
||||
"StartupEmbyServerIsLoading": "שרת Jellyfin בתהליך טעינה. נא לנסות שוב בקרוב.",
|
||||
"SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
|
||||
"SubtitleDownloadFailureFromForItem": "הורדת כתוביות מ־{0} עבור {1} נכשלה",
|
||||
"Sync": "סנכרון",
|
||||
@@ -100,14 +100,14 @@
|
||||
"TasksLibraryCategory": "ספרייה",
|
||||
"TasksMaintenanceCategory": "תחזוקה",
|
||||
"TaskUpdatePlugins": "עדכן תוספים",
|
||||
"TaskRefreshPeopleDescription": "מעדכן מטא נתונים עבור שחקנים ובמאים בספריית המדיה שלך.",
|
||||
"TaskRefreshPeopleDescription": "מעדכן מטא-דאטה עבור שחקנים ובמאים בספריית המדיה שלך.",
|
||||
"TaskRefreshPeople": "רענן אנשים",
|
||||
"TaskCleanLogsDescription": "מוחק קבצי יומן בני יותר מ- {0} ימים.",
|
||||
"TaskCleanLogs": "ניקוי תיקיית יומן",
|
||||
"TaskRefreshLibraryDescription": "סורק את ספריית המדיה שלך אחר קבצים חדשים ומרענן מטא נתונים.",
|
||||
"TaskRefreshLibraryDescription": "סורק את ספריית המדיה שלך אחר קבצים חדשים ומרענן מטא-דאטה.",
|
||||
"TaskRefreshChapterImagesDescription": "יוצר תמונות ממוזערות לסרטונים שיש להם פרקים.",
|
||||
"TasksChannelsCategory": "ערוצי אינטרנט",
|
||||
"TaskDownloadMissingSubtitlesDescription": "חפש באינטרנט עבור הכתוביות החסרות בהתבסס על המטה-דיאטה.",
|
||||
"TaskDownloadMissingSubtitlesDescription": "חפש באינטרנט כתוביות חסרות בהתבסס על המטא-דאטה.",
|
||||
"TaskDownloadMissingSubtitles": "הורד כתוביות חסרות",
|
||||
"TaskRefreshChannelsDescription": "רענן פרטי ערוץ אינטרנטי.",
|
||||
"TaskRefreshChannels": "רענן ערוץ",
|
||||
@@ -136,5 +136,7 @@
|
||||
"TaskMoveTrickplayImages": "העברת מיקום של תמונות Trickplay",
|
||||
"TaskExtractMediaSegments": "סריקת מדיה",
|
||||
"TaskExtractMediaSegmentsDescription": "מחלץ חלקי מדיה מתוספים המאפשרים זאת.",
|
||||
"TaskMoveTrickplayImagesDescription": "הזזת קבצי Trickplay קיימים בהתאם להגדרות הספרייה."
|
||||
"TaskMoveTrickplayImagesDescription": "הזזת קבצי Trickplay קיימים בהתאם להגדרות הספרייה.",
|
||||
"CleanupUserDataTaskDescription": "ניקוי כל המידע של המשתמש (מצב צפייה, מועדפים וכו) ממדיה שאינה קיימת מעל 90 יום.",
|
||||
"CleanupUserDataTask": "משימת ניקוי מידע משתמש"
|
||||
}
|
||||
|
||||
@@ -136,5 +136,7 @@
|
||||
"TaskDownloadMissingLyricsDescription": "Zenék szövegének letöltése",
|
||||
"TaskMoveTrickplayImages": "Trickplay képek helyének átköltöztetése",
|
||||
"TaskMoveTrickplayImagesDescription": "A médiatár-beállításoknak megfelelően áthelyezi a meglévő trickplay fájlokat.",
|
||||
"TaskExtractMediaSegmentsDescription": "Kinyeri vagy megszerzi a médiaszegmenseket a MediaSegment támogatással rendelkező bővítményekből."
|
||||
"TaskExtractMediaSegmentsDescription": "Kinyeri vagy megszerzi a médiaszegmenseket a MediaSegment támogatással rendelkező bővítményekből.",
|
||||
"CleanupUserDataTaskDescription": "Legalább 90 napja nem elérhető médiákhoz kapcsolódó összes felhasználói adat (pl. megtekintési állapot, kedvencek) törlése.",
|
||||
"CleanupUserDataTask": "Felhasználói adatok tisztítása feladat"
|
||||
}
|
||||
|
||||
@@ -129,5 +129,13 @@
|
||||
"TaskAudioNormalizationDescription": "Pindai file untuk data normalisasi audio.",
|
||||
"TaskAudioNormalization": "Normalisasi Audio",
|
||||
"TaskCleanCollectionsAndPlaylists": "Bersihkan koleksi dan daftar putar",
|
||||
"TaskCleanCollectionsAndPlaylistsDescription": "Menghapus item dari koleksi dan daftar putar yang sudah tidak ada."
|
||||
"TaskCleanCollectionsAndPlaylistsDescription": "Menghapus item dari koleksi dan daftar putar yang sudah tidak ada.",
|
||||
"TaskDownloadMissingLyricsDescription": "Unduh lirik untuk lagu",
|
||||
"TaskExtractMediaSegmentsDescription": "Mengekstrak atau memperoleh segmen media dari plugin yang mendukung MediaSegment.",
|
||||
"TaskMoveTrickplayImagesDescription": "Memindahkan file trickplay yang sudah ada sesuai dengan pengaturan pustaka.",
|
||||
"CleanupUserDataTaskDescription": "Membersihkan semua data pengguna (status tontonan, status favorit, dll.) dari media yang sudah tidak ada selama setidaknya 90 hari.",
|
||||
"TaskExtractMediaSegments": "Scan Segmen media",
|
||||
"TaskMoveTrickplayImages": "Migrasikan Lokasi Gambar Trickplay",
|
||||
"TaskDownloadMissingLyrics": "Unduh Lirik yang Hilang",
|
||||
"CleanupUserDataTask": "Tugas Pembersihan Data Pengguna"
|
||||
}
|
||||
|
||||
@@ -136,5 +136,7 @@
|
||||
"TaskMoveTrickplayImages": "Sposta le immagini Trickplay",
|
||||
"TaskMoveTrickplayImagesDescription": "Sposta le immagini Trickplay esistenti secondo la configurazione della libreria.",
|
||||
"TaskExtractMediaSegmentsDescription": "Estrae o ottiene segmenti multimediali dai plugin abilitati MediaSegment.",
|
||||
"TaskExtractMediaSegments": "Scansiona Segmento Media"
|
||||
"TaskExtractMediaSegments": "Scansiona Segmento Media",
|
||||
"CleanupUserDataTask": "Task di pulizia dei dati utente",
|
||||
"CleanupUserDataTaskDescription": "Pulisce tutti i dati utente (stato di visione, status preferiti, ecc.) dai contenuti non più presenti da almeno 90 giorni."
|
||||
}
|
||||
|
||||
@@ -135,5 +135,7 @@
|
||||
"TaskMoveTrickplayImages": "Trickplayの画像を移動",
|
||||
"TaskMoveTrickplayImagesDescription": "ライブラリ設定によりTrickplayのファイルを移動。",
|
||||
"TaskDownloadMissingLyrics": "失われた歌詞をダウンロード",
|
||||
"TaskExtractMediaSegmentsDescription": "MediaSegment 対応プラグインからメディア セグメントを抽出または取得します。"
|
||||
"TaskExtractMediaSegmentsDescription": "MediaSegment 対応プラグインからメディア セグメントを抽出または取得します。",
|
||||
"CleanupUserDataTask": "ユーザーデータのクリーンアップタスク",
|
||||
"CleanupUserDataTaskDescription": "90日以上存在しないメディアに対して、視聴状態やお気に入り状態などのユーザーデータをすべて削除します。"
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"DeviceOfflineWithName": "{0} ಸಂಪರ್ಕ ಕಡಿತಗೊಂಡಿದೆ",
|
||||
"DeviceOnlineWithName": "{0} ಸಂಪರ್ಕಗೊಂಡಿದೆ",
|
||||
"External": "ಹೊರಗಿನ",
|
||||
"FailedLoginAttemptWithUserName": "{0} ರಿಂದ ವಿಫಲ ಲಾಗಿನ್ ಪ್ರಯತ್ನ",
|
||||
"FailedLoginAttemptWithUserName": "ವಿಫಲ ಲಾಗಿನ್ ಪ್ರಯತ್ನ ಸಂಖ್ಯೆ {0}",
|
||||
"Favorites": "ಮೆಚ್ಚಿನವುಗಳು",
|
||||
"Folders": "ಫೋಲ್ಡರ್ಗಳು",
|
||||
"Forced": "ಬಲವಂತವಾಗಿ",
|
||||
@@ -123,5 +123,13 @@
|
||||
"TaskUpdatePlugins": "ಪ್ಲಗಿನ್ಗಳನ್ನು ನವೀಕರಿಸಿ",
|
||||
"TaskCleanTranscode": "ಟ್ರಾನ್ಸ್ಕೋಡ್ ಡೈರೆಕ್ಟರಿಯನ್ನು ಸ್ವಚ್ಛಗೊಳಿಸಿ",
|
||||
"TaskRefreshChannels": "ಚಾನಲ್ಗಳನ್ನು ರಿಫ್ರೆಶ್ ಮಾಡಿ",
|
||||
"TaskRefreshChannelsDescription": "ಇಂಟರ್ನೆಟ್ ಚಾನಲ್ ಮಾಹಿತಿಯನ್ನು ರಿಫ್ರೆಶ್ ಮಾಡುತ್ತದೆ."
|
||||
"TaskRefreshChannelsDescription": "ಇಂಟರ್ನೆಟ್ ಚಾನಲ್ ಮಾಹಿತಿಯನ್ನು ರಿಫ್ರೆಶ್ ಮಾಡುತ್ತದೆ.",
|
||||
"TaskAudioNormalizationDescription": "ಧ್ವನಿ ಸಾಮಾನ್ಯೀಕರಣ ಮಾಹಿತಿಗಾಗಿ ಕಡತಗಳನ್ನು ಸ್ಕ್ಯಾನ್ ಮಾಡುತ್ತದೆ.",
|
||||
"TaskDownloadMissingLyricsDescription": "ಹಾಡುಗಳಿಗೆ ಸಾಹಿತ್ಯ ಪಡೆಯಿರಿ",
|
||||
"TaskExtractMediaSegments": "ಮಾಧ್ಯಮ ವಿಭಾಗದ ಹುಡುಕು",
|
||||
"TaskDownloadMissingLyrics": "ಇಲ್ಲದ ಸಾಹಿತ್ಯವನ್ನು ಪಡೆಯಿರಿ",
|
||||
"TaskAudioNormalization": "ಧ್ವನಿ ಸಾಮಾನ್ಯೀಕರಣ",
|
||||
"TaskRefreshTrickplayImages": "ಟ್ರಿಕ್ಪ್ಲೇ ಚಿತ್ರಗಳನ್ನು ರಚಿಸಿ",
|
||||
"TaskCleanCollectionsAndPlaylists": "ಸಂಗ್ರಹಗಳು ಮತ್ತು ಪ್ಲೇಪಟ್ಟಿಗಳನ್ನು ಸ್ವಚ್ಛಗೊಳಿಸಿ",
|
||||
"TaskCleanCollectionsAndPlaylistsDescription": "ಇಲ್ಲದ ಸಂಗ್ರಹಗಳು ಮತ್ತು ಪ್ಲೇಪಟ್ಟಿಗಳಿಂದ ವಸ್ತುಗಳನ್ನು ತೆಗೆದುಹಾಕುತ್ತದೆ."
|
||||
}
|
||||
|
||||
@@ -135,5 +135,7 @@
|
||||
"TaskMoveTrickplayImages": "Trickplay attēlu pārvietošana",
|
||||
"TaskMoveTrickplayImagesDescription": "Pārvieto esošos trickplay failus atbilstoši bibliotēkas iestatījumiem.",
|
||||
"TaskDownloadMissingLyrics": "Lejupielādēt trūkstošos vārdus",
|
||||
"TaskDownloadMissingLyricsDescription": "Lejupielādēt vārdus dziesmām"
|
||||
"TaskDownloadMissingLyricsDescription": "Lejupielādēt vārdus dziesmām",
|
||||
"CleanupUserDataTask": "Lietotāju datu tīrīšanas uzdevums",
|
||||
"CleanupUserDataTaskDescription": "Notīra visus lietotāja datus (skatīšanās stāvokļus, favorītu statusi utt.) no medijiem, kas vairs nav pieejami vismaz 90 dienas."
|
||||
}
|
||||
|
||||
@@ -1,14 +1,141 @@
|
||||
{
|
||||
"Books": "Номууд",
|
||||
"HeaderNextUp": "Дараах",
|
||||
"HeaderNextUp": "Дараа нь",
|
||||
"HeaderContinueWatching": "Үргэлжлүүлэн үзэх",
|
||||
"Songs": "Дуунууд",
|
||||
"Playlists": "Тоглуулах жагсаалт",
|
||||
"Movies": "Кино",
|
||||
"Latest": "Сүүлийн үеийн",
|
||||
"Genres": "Төрөл зүйл",
|
||||
"Genres": "Төрлүүд",
|
||||
"Favorites": "Дуртай",
|
||||
"Collections": "Багц",
|
||||
"Artists": "Зураачуд",
|
||||
"Albums": "Цомгууд"
|
||||
"Artists": "Уран бүтээлчид",
|
||||
"Albums": "Цомгууд",
|
||||
"TaskExtractMediaSegments": "Медиа сегмент шалга",
|
||||
"TaskExtractMediaSegmentsDescription": "MediaSegment идэвхжүүлсэн залгаасуудаас медиа сегментүүдийг задлах эсвэл олж авах.",
|
||||
"TaskMoveTrickplayImages": "Трикплэй зургуудын байршлыг шилжүүлэх",
|
||||
"TaskMoveTrickplayImagesDescription": "Одоогоор байгаа трикплэй файлуудыг сангийн тохиргоонд тохируулан шилжүүлнэ.",
|
||||
"TaskDownloadMissingLyrics": "Алга болсон дууны үгийг татаж авах",
|
||||
"TaskDownloadMissingLyricsDescription": "Дууны үгийг татаж авах",
|
||||
"TaskOptimizeDatabase": "Датабаазыг сайжруулах",
|
||||
"TaskKeyframeExtractor": "Түлхүүр кадр гаргагч",
|
||||
"TaskCleanCache": "Кэш санг цэвэрлэх",
|
||||
"NewVersionIsAvailable": "Jellyfin Server-н шинэ хувилбар татаж авахад нээлттэй боллоо.",
|
||||
"MessageNamedServerConfigurationUpdatedWithValue": "Server-н {0}-р хэсгийн тохиргоо шинэчлэгдлээ",
|
||||
"NotificationOptionAudioPlaybackStopped": "Дууг зогсоов",
|
||||
"NotificationOptionNewLibraryContent": "Шинэ агуулга орлоо",
|
||||
"NotificationOptionServerRestartRequired": "Server-г дахин асаана уу",
|
||||
"NotificationOptionVideoPlaybackStopped": "Бичлэгийг зогсоов",
|
||||
"UserPasswordChangedWithName": "Хэрэглэгч {0}-н нууц үгийг өөрчиллөө",
|
||||
"TaskCleanCollectionsAndPlaylists": "Цуглуулга ба тоглуулах жагсаалтыг цэвэрлэх",
|
||||
"ScheduledTaskFailedWithName": "{0} амжилтгүй",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin Server ачааллаж байна. Хэсэг хугацааны дараа дахин оролдоно уу.",
|
||||
"TaskCleanActivityLog": "Үйл ажиллагааны бүртгэлийг цэвэрлэх",
|
||||
"SubtitleDownloadFailureFromForItem": "{0}-г {1}-д зориулсан хадмал орчуулгыг татаж авч чадсангүй",
|
||||
"TaskRefreshLibraryDescription": "Таны медиа санг шинэ файлуудын хувьд шалгаж, мета мэдээллийг шинэчилнэ.",
|
||||
"UserOfflineFromDevice": "{0}-г {1}-с салгалаа",
|
||||
"ValueHasBeenAddedToLibrary": "{0}-г медиа сан руу нэмэгдлээ",
|
||||
"TaskRefreshPeopleDescription": "Таны медиа санд байгаа жүжигчид болон найруулагчдын мета мэдээллийг шинэчилнэ.",
|
||||
"TaskCleanTranscodeDescription": "Нэг өдрөөс илүү настай транскодлох файлуудыг устгана.",
|
||||
"TaskRefreshChannelsDescription": "Интернет сувгуудын мэдээллийг шинэчлэх.",
|
||||
"TaskDownloadMissingSubtitlesDescription": "Мета мэдээллийн тохиргоонд үндэслэн интернетээс алга болсон дэд гарчгийг хайна.",
|
||||
"TaskOptimizeDatabaseDescription": "Мэдээллийн сантайг шахаж, чөлөөтэй зайг багасгана. Санг шалгаж, мэдээллийн сантай холбоотой өөрчлөлт хийхийн дараа энэ үйлдлийг гүйцэтгэх нь гүйцэтгэлийг сайжруулах боломжтой.",
|
||||
"TaskKeyframeExtractorDescription": "Видео файлуудаас түлхүүр кадруудыг гаргаж, илүү нарийвчилсан HLS тоглуулах жагсаалт үүсгэнэ. Энэ үйлдэл удаан хугацаанд үргэлжлэх боломжтой.",
|
||||
"NotificationOptionAudioPlayback": "Дууг тоглууллаа",
|
||||
"TaskRefreshTrickplayImages": "Трикплэй зургуудыг үүсгэх",
|
||||
"TaskUpdatePlugins": "Plugin-уудыг шинэчлэх",
|
||||
"TaskCleanCollectionsAndPlaylistsDescription": "Одоо байхгүй болсон зүйлсийг цуглуулга ба тоглуулах жагсаалтаас устгана.",
|
||||
"TaskAudioNormalization": "Аудиог хэвшүүлэх",
|
||||
"TaskAudioNormalizationDescription": "Файлуудаас дууны хэвийн хэмжээсийн мэдээллийг шалгана.",
|
||||
"TaskRefreshTrickplayImagesDescription": "Идэвхжсэн сангуудад байгаа видеонуудын трикплэй урьдчилсан харагдацыг үүсгэнэ.",
|
||||
"TaskUpdatePluginsDescription": "Автомат шинэчлэлд тохируулсан залгаасуудын шинэчлэлтийг татаж авч суулгана.",
|
||||
"TaskCleanTranscode": "Транскодлох санг цэвэрлэх",
|
||||
"TaskRefreshChannels": "Сувгуудыг шинэчлэх",
|
||||
"TaskDownloadMissingSubtitles": "Алга болсон хадмал орчуулгыг татах",
|
||||
"External": "Гадны",
|
||||
"HeaderFavoriteArtists": "Дуртай уран бүтээлчид",
|
||||
"HeaderFavoriteEpisodes": "Дуртай ангиуд",
|
||||
"HeaderFavoriteShows": "Дуртай нэвтрүүлэг",
|
||||
"HeaderFavoriteSongs": "Дуртай дуу",
|
||||
"AppDeviceValues": "Aпп: {0}, Төхөөрөмж: {1}",
|
||||
"Application": "Aпп",
|
||||
"AuthenticationSucceededWithUserName": "{0} амжилттай нэвтэрлээ",
|
||||
"CameraImageUploadedFrom": "{0}-с шинэ зураг байршуулагдлаа",
|
||||
"Channels": "Сувгууд",
|
||||
"ChapterNameValue": "{0}-р бүлэг",
|
||||
"Default": "Өгөгдмөл",
|
||||
"DeviceOfflineWithName": "{0}-н холболт саллаа",
|
||||
"DeviceOnlineWithName": "{0} холбогдлоо",
|
||||
"FailedLoginAttemptWithUserName": "{0}-н нэвтрэх оролдлого амжилтгүй",
|
||||
"Folders": "Хавтаснууд",
|
||||
"Forced": "Хүчээр",
|
||||
"HeaderAlbumArtists": "Цомгийн уран бүтээлчид",
|
||||
"HeaderFavoriteAlbums": "Дуртай цомгууд",
|
||||
"HeaderLiveTV": "Шууд",
|
||||
"HeaderRecordingGroups": "Бичлэгийн бүлгүүд",
|
||||
"HearingImpaired": "Сонсголын бэрхшээлтэй",
|
||||
"HomeVideos": "Үндсэн дүрсүүд",
|
||||
"Inherit": "Уламжлах",
|
||||
"ItemAddedWithName": "{0}-г санд нэмлээ",
|
||||
"ItemRemovedWithName": "{0}-с сангаас хаслаа",
|
||||
"LabelIpAddressValue": "IP хаяг: {0}",
|
||||
"LabelRunningTimeValue": "Үргэлжлэх хугацаа: {0}",
|
||||
"MessageApplicationUpdated": "Jellyfin Server шинэчлэгдлээ",
|
||||
"MessageApplicationUpdatedTo": "Jellyfin Server {0} болж шинэчлэгдлээ",
|
||||
"MessageServerConfigurationUpdated": "Server-н тохиргоо шинэчлэгдлээ",
|
||||
"MixedContent": "Холимог агуулга",
|
||||
"Music": "Дуу",
|
||||
"MusicVideos": "Дууны клип",
|
||||
"NameInstallFailed": "{0} суулгахад алдаа гарлаа",
|
||||
"NameSeasonNumber": "{0}-р улирал",
|
||||
"NameSeasonUnknown": "Улирал олдсонгүй",
|
||||
"NotificationOptionApplicationUpdateAvailable": "Апп шинэчлэлт бий болсон байна",
|
||||
"NotificationOptionApplicationUpdateInstalled": "Апп-н шинэчлэлийг суулгалаа",
|
||||
"NotificationOptionCameraImageUploaded": "Камерын зураг орууллаа",
|
||||
"NotificationOptionInstallationFailed": "Суулгалт амжилтгүй",
|
||||
"NotificationOptionPluginError": "Plugin-д алдаа гарлаа",
|
||||
"NotificationOptionPluginInstalled": "Plugin-г суулгалаа",
|
||||
"NotificationOptionPluginUninstalled": "Plugin-г устгалаа",
|
||||
"NotificationOptionPluginUpdateInstalled": "Plugin-ны шинэчлэн суулгалаа",
|
||||
"NotificationOptionTaskFailed": "Товолсон ажил амжилтгүй",
|
||||
"NotificationOptionUserLockedOut": "Хэрэглэгчийг түгжив",
|
||||
"NotificationOptionVideoPlayback": "Бичлэгийг тоглуулж эхлэв",
|
||||
"Photos": "Зургууд",
|
||||
"Plugin": "Plugin",
|
||||
"PluginInstalledWithName": "{0}-г суулгалаа",
|
||||
"PluginUninstalledWithName": "{0}-г устгалаа",
|
||||
"PluginUpdatedWithName": "{0}-г шинэчиллээ",
|
||||
"ProviderValue": "Нийлүүлэгч: {0}",
|
||||
"ScheduledTaskStartedWithName": "{0}-г эхлүүлэв",
|
||||
"ServerNameNeedsToBeRestarted": "{0}-г дахин асаана уу",
|
||||
"Shows": "Нэвтрүүлгүүд",
|
||||
"Sync": "Дахин",
|
||||
"System": "Систем",
|
||||
"TvShows": "ТВ нэвтрүүлгүүд",
|
||||
"Undefined": "Танисангүй",
|
||||
"User": "Хэрэглэгч",
|
||||
"UserCreatedWithName": "Хэрэглэгч {0}-г үүсгэлээ",
|
||||
"UserDeletedWithName": "Хэрэглэгч {0}-г устгалаа",
|
||||
"UserDownloadingItemWithValues": "{0} нь {1}-г татаж байна",
|
||||
"UserLockedOutWithName": "Хэрэглэгч {0}-г түгжлээ",
|
||||
"UserOnlineFromDevice": "{0} нь {1}-тэй холбоотой байна",
|
||||
"UserPolicyUpdatedWithName": "Хэрэглэгчийн журмыг {0}-д зориулан шинэчиллээ",
|
||||
"UserStartedPlayingItemWithValues": "{0}-г {2} дээр {1}-г тоглуулж байна",
|
||||
"UserStoppedPlayingItemWithValues": "{0}-г {2} дээр {1}-г тоглуулж дуусгалаа",
|
||||
"ValueSpecialEpisodeName": "Тусгай - {0}",
|
||||
"VersionNumber": "Хувилбар {0}",
|
||||
"TasksMaintenanceCategory": "Засвар",
|
||||
"TasksLibraryCategory": "Сан",
|
||||
"TasksApplicationCategory": "Апп",
|
||||
"TasksChannelsCategory": "Интернет сувгууд",
|
||||
"TaskCleanActivityLogDescription": "Тохируулсан хугацаанаас хуучин үйл ажиллагааны бүртгэлийн бичлэгүүдийг устгана.",
|
||||
"TaskCleanLogs": "Бүртгэлийн санг цэвэрлэх",
|
||||
"TaskCleanLogsDescription": "{0} өдрөөс илүү настай бүртгэлийн файлуудыг устгана.",
|
||||
"TaskRefreshPeople": "Хүмүүсийг шинэчлэх",
|
||||
"TaskCleanCacheDescription": "Системд хэрэггүй болсон кэш файлуудыг устгана.",
|
||||
"TaskRefreshChapterImages": "Бүлгийн зураг авах",
|
||||
"TaskRefreshChapterImagesDescription": "Бүлгүүдтэй видеонуудын хуудсан зураг үүсгэнэ.",
|
||||
"TaskRefreshLibrary": "Медиа санг шалгах",
|
||||
"CleanupUserDataTask": "Хэрэглэгчийн өгөгдлийн цэвэрлэгээний үүрэг",
|
||||
"CleanupUserDataTaskDescription": "Хугацаа нь 90 хоногоос дээш хугацаанд байхгүй болсон медианаас бүх хэрэглэгчийн өгөгдлийг (үзсэн төлөв, дуртай жагсаалт гэх мэт) цэвэрлэнэ."
|
||||
}
|
||||
|
||||
@@ -130,5 +130,7 @@
|
||||
"TaskExtractMediaSegments": "मिडिया विभाग तपासणी",
|
||||
"TaskMoveTrickplayImages": "ट्रिकप्ले प्रतिमेचे स्थान स्थलांतर करा",
|
||||
"TaskDownloadMissingLyrics": "उपलब्ध नसलेली गीतपट्टी (Lyrics) डाउनलोड करा",
|
||||
"TaskAudioNormalization": "ऑडिओ सामान्यीकरण"
|
||||
"TaskAudioNormalization": "ऑडिओ सामान्यीकरण",
|
||||
"TaskAudioNormalizationDescription": "ऑडिओ सामान्यीकरणाचा डाटा स्कॅन करतो.",
|
||||
"TaskDownloadMissingLyricsDescription": "गाण्यांची गीतपट्टी (Lyrics) डाउनलोड करतो"
|
||||
}
|
||||
|
||||
@@ -136,5 +136,7 @@
|
||||
"TaskCleanCollectionsAndPlaylists": "Bersihkan koleksi dan senarai audio video",
|
||||
"TaskAudioNormalization": "Normalisasi Audio",
|
||||
"TaskAudioNormalizationDescription": "Mengimbas fail-fail untuk data normalisasi audio.",
|
||||
"TaskCleanCollectionsAndPlaylistsDescription": "Mengalih keluar item daripada koleksi dan senarai audio video yang tidak wujud lagi."
|
||||
"TaskCleanCollectionsAndPlaylistsDescription": "Mengalih keluar item daripada koleksi dan senarai audio video yang tidak wujud lagi.",
|
||||
"CleanupUserDataTaskDescription": "Membersihkan semua data pengguna (keadaan tontonan, status kegemaran, dan sebagainya) daripada media yang tidak lagi wujud sekurang-kurangnya selama 90 hari.",
|
||||
"CleanupUserDataTask": "Tugas pembersihan data pengguna"
|
||||
}
|
||||
|
||||
@@ -136,5 +136,7 @@
|
||||
"TaskExtractMediaSegmentsDescription": "Verkrijgt mediasegmenten vanuit plug-ins met MediaSegment-ondersteuning.",
|
||||
"TaskMoveTrickplayImages": "Locatie trickplay-afbeeldingen migreren",
|
||||
"TaskMoveTrickplayImagesDescription": "Verplaatst bestaande trickplay-bestanden op basis van de bibliotheekinstellingen.",
|
||||
"TaskExtractMediaSegments": "Scannen op mediasegmenten"
|
||||
"TaskExtractMediaSegments": "Scannen op mediasegmenten",
|
||||
"CleanupUserDataTaskDescription": "Wist alle gebruikersgegevens (kijkstatus, favorieten, etc.) van media die al minstens 90 dagen niet meer aanwezig is.",
|
||||
"CleanupUserDataTask": "Opruimtaak gebruikersdata"
|
||||
}
|
||||
|
||||
@@ -136,5 +136,7 @@
|
||||
"TaskExtractMediaSegments": "Skanowanie segmentów mediów",
|
||||
"TaskMoveTrickplayImages": "Migruj lokalizację obrazu Trickplay",
|
||||
"TaskExtractMediaSegmentsDescription": "Wyodrębnia lub pobiera segmenty mediów z wtyczek obsługujących MediaSegment.",
|
||||
"TaskMoveTrickplayImagesDescription": "Przenosi istniejące pliki Trickplay zgodnie z ustawieniami biblioteki."
|
||||
"TaskMoveTrickplayImagesDescription": "Przenosi istniejące pliki Trickplay zgodnie z ustawieniami biblioteki.",
|
||||
"CleanupUserDataTaskDescription": "Usuwa wszystkie dane użytkownika (stan oglądanych, status ulubionych itp.) z mediów, które nie są dostępne od co najmniej 90 dni.",
|
||||
"CleanupUserDataTask": "Zadanie czyszczenia danych użytkownika"
|
||||
}
|
||||
|
||||
@@ -136,5 +136,7 @@
|
||||
"TaskMoveTrickplayImagesDescription": "Move os arquivos do trickplay de acordo com as configurações da biblioteca.",
|
||||
"TaskExtractMediaSegments": "Varredura do segmento de mídia",
|
||||
"TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de mídia de plug-ins habilitados para MediaSegment.",
|
||||
"TaskMoveTrickplayImages": "Migrar o local da imagem do Trickplay"
|
||||
"TaskMoveTrickplayImages": "Migrar o local da imagem do Trickplay",
|
||||
"CleanupUserDataTask": "Tarefa de limpeza de dados do usuário",
|
||||
"CleanupUserDataTaskDescription": "Limpa todos os dados do usuário (estado de visualização, status de favorito, etc.) de mídias que não estão presentes por pelo menos 90 dias."
|
||||
}
|
||||
|
||||
@@ -136,5 +136,7 @@
|
||||
"TaskMoveTrickplayImages": "Migrar a localização da imagem do Trickplay",
|
||||
"TaskDownloadMissingLyricsDescription": "Transferir letra para músicas",
|
||||
"TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de multimédia a partir de plugins com suporte para MediaSegment.",
|
||||
"TaskMoveTrickplayImagesDescription": "Move os ficheiros trickplay existentes de acordo com as definições da mediateca."
|
||||
"TaskMoveTrickplayImagesDescription": "Move os ficheiros trickplay existentes de acordo com as definições da mediateca.",
|
||||
"CleanupUserDataTaskDescription": "Apaga todos os dados de utilizador (estados de reprodução, favoritos, etc) de arquivos média não presentes há 90 dias ou mais.",
|
||||
"CleanupUserDataTask": "Limpeza de dados de utilizador"
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
"TaskCleanTranscodeDescription": "Șterge fișierele de transcodare mai vechi de o zi.",
|
||||
"TaskCleanTranscode": "Curățați directorul de transcodare",
|
||||
"TaskUpdatePluginsDescription": "Descarcă și instalează actualizări pentru extensiile care sunt configurate să se actualizeze automat.",
|
||||
"TaskUpdatePlugins": "Actualizați Extensile",
|
||||
"TaskUpdatePlugins": "Actualizați Extensiile",
|
||||
"TaskRefreshPeopleDescription": "Actualizează metadatele pentru actori și regizori din biblioteca media.",
|
||||
"TaskRefreshPeople": "Actualizează Persoanele",
|
||||
"TaskCleanLogsDescription": "Șterge fișierele jurnal care au mai mult de {0} zile.",
|
||||
@@ -135,5 +135,7 @@
|
||||
"TaskExtractMediaSegmentsDescription": "Extrage sau obține segmentele media de la pluginurile MediaSegment activate.",
|
||||
"TaskMoveTrickplayImages": "Migrează locația imaginii Trickplay",
|
||||
"TaskDownloadMissingLyrics": "Descarcă versurile lipsă",
|
||||
"TaskDownloadMissingLyricsDescription": "Descarcă versuri pentru melodii"
|
||||
"TaskDownloadMissingLyricsDescription": "Descarcă versuri pentru melodii",
|
||||
"CleanupUserDataTask": "Sarcina de curatare a datelor utilizatorului",
|
||||
"CleanupUserDataTaskDescription": "Sterge toate datele utilizatorului (starea vizionarii, starea favoritelor etc.) de pe suporturile media care nu mai sunt prezente timp de cel puțin 90 de zile."
|
||||
}
|
||||
|
||||
@@ -136,5 +136,7 @@
|
||||
"TaskMoveTrickplayImages": "Перенесение местоположения изображений Trickplay",
|
||||
"TaskExtractMediaSegments": "Сканирование медиасегментов",
|
||||
"TaskExtractMediaSegmentsDescription": "Извлекает или получает медиасегменты из плагинов MediaSegment.",
|
||||
"TaskMoveTrickplayImagesDescription": "Перемещает существующие файлы trickplay в соответствии с настройками медиатеки."
|
||||
"TaskMoveTrickplayImagesDescription": "Перемещает существующие файлы trickplay в соответствии с настройками медиатеки.",
|
||||
"CleanupUserDataTask": "Задача очистки пользовательских данных",
|
||||
"CleanupUserDataTaskDescription": "Очищает все пользовательские данные (состояние просмотра, статус избранного и т.д.) с медиа, отсутствующих по меньшей мере в течение 90 дней."
|
||||
}
|
||||
|
||||
@@ -136,5 +136,7 @@
|
||||
"TaskMoveTrickplayImages": "Presunúť umiestnenie obrázkov Trickplay",
|
||||
"TaskMoveTrickplayImagesDescription": "Presunie existujúce súbory Trickplay podľa nastavení knižnice.",
|
||||
"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",
|
||||
"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í."
|
||||
}
|
||||
|
||||
@@ -136,5 +136,7 @@
|
||||
"TaskExtractMediaSegments": "Skanning av mediesegment",
|
||||
"TaskExtractMediaSegmentsDescription": "Extraherar eller hämtar ut mediesegmen från tillägg som stöder MediaSegment.",
|
||||
"TaskMoveTrickplayImages": "Migrera platsen för Trickplay-bilder",
|
||||
"TaskMoveTrickplayImagesDescription": "Flyttar befintliga trickplay-filer enligt bibliotekets inställningar."
|
||||
"TaskMoveTrickplayImagesDescription": "Flyttar befintliga trickplay-filer enligt bibliotekets inställningar.",
|
||||
"CleanupUserDataTaskDescription": "Tar bort all användardata (såsom vad du sett, favoriter med mera) för media som inte funnits på enheten på minst 90 dagar.",
|
||||
"CleanupUserDataTask": "Uppgift för rensning av användardata"
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"Inherit": "மரபுரிமையாகப் பெறு",
|
||||
"HeaderRecordingGroups": "பதிவு குழுக்கள்",
|
||||
"Folders": "கோப்புறைகள்",
|
||||
"FailedLoginAttemptWithUserName": "{0} இன் உள்நுழைவு முயற்சி தோல்வியடைந்தது",
|
||||
"FailedLoginAttemptWithUserName": "{0} இலிருந்து உள்நுழைவு முயற்சி தோல்வியடைந்தது",
|
||||
"DeviceOnlineWithName": "{0} இணைக்கப்பட்டது",
|
||||
"DeviceOfflineWithName": "{0} துண்டிக்கப்பட்டது",
|
||||
"Collections": "தொகுப்புகள்",
|
||||
@@ -133,5 +133,9 @@
|
||||
"TaskDownloadMissingLyrics": "விடுபட்ட பாடல் வரிகளைப் பதிவிறக்கவும்",
|
||||
"TaskDownloadMissingLyricsDescription": "பாடல்களுக்கான வரிகளைப் பதிவிறக்குகிறது",
|
||||
"TaskMoveTrickplayImages": "ட்ரிக்பிளே பட இருப்பிடத்தை நகர்த்து",
|
||||
"TaskMoveTrickplayImagesDescription": "நூலக அமைப்புகளுக்கு ஏற்ப ஏற்கனவே உள்ள ட்ரிக்பிளே கோப்புகளை நகர்த்துகிறது."
|
||||
"TaskMoveTrickplayImagesDescription": "நூலக அமைப்புகளுக்கு ஏற்ப ஏற்கனவே உள்ள ட்ரிக்பிளே கோப்புகளை நகர்த்துகிறது.",
|
||||
"TaskExtractMediaSegments": "மீடியா பிரிவு ஸ்கேன்",
|
||||
"TaskExtractMediaSegmentsDescription": "மீடியாசெக்மென்ட் இயக்கப்பட்ட செருகுநிரல்களிலிருந்து மீடியா பிரிவுகளைப் பிரித்தெடுக்கிறது அல்லது பெறுகிறது.",
|
||||
"CleanupUserDataTaskDescription": "குறைந்தது 90 நாட்களுக்கு இல்லாத மீடியாவிலிருந்து அனைத்து பயனர் தரவையும் (கண்காணிப்பு நிலை, பிடித்த நிலை போன்றவை) சுத்தம் செய்கிறது.",
|
||||
"CleanupUserDataTask": "பயனர் தரவை சுத்தம் செய்யும் பணி"
|
||||
}
|
||||
|
||||
@@ -58,11 +58,11 @@
|
||||
"DeviceOnlineWithName": "{0} เชื่อมต่อสำเร็จแล้ว",
|
||||
"DeviceOfflineWithName": "{0} ยกเลิกการเชื่อมต่อแล้ว",
|
||||
"Collections": "คอลเลกชัน",
|
||||
"ChapterNameValue": "บท {0}",
|
||||
"ChapterNameValue": "บทที่ {0}",
|
||||
"Channels": "ช่อง",
|
||||
"CameraImageUploadedFrom": "ภาพถ่ายใหม่ได้ถูกอัปโหลดมาจาก {0}",
|
||||
"Books": "หนังสือ",
|
||||
"AuthenticationSucceededWithUserName": "{0} ยืนยันตัวสำเร็จแล้ว",
|
||||
"AuthenticationSucceededWithUserName": "{0} ยืนยันตัวตนสำเร็จแล้ว",
|
||||
"Artists": "ศิลปิน",
|
||||
"Application": "แอปพลิเคชัน",
|
||||
"AppDeviceValues": "แอป: {0}, อุปกรณ์: {1}",
|
||||
@@ -132,5 +132,8 @@
|
||||
"TaskAudioNormalizationDescription": "สแกนไฟล์เพื่อค้นหาข้อมูลการปรับระดับเสียงให้สม่ำเสมอ",
|
||||
"TaskCleanCollectionsAndPlaylists": "จัดระเบียบคอลเลกชันและเพลย์ลิสต์",
|
||||
"TaskCleanCollectionsAndPlaylistsDescription": "ลบรายการออกจากคอลเลกชันและเพลย์ลิสต์ที่ไม่มีแล้ว",
|
||||
"TaskExtractMediaSegments": "การสแกนส่วนของสื่อมีเดีย"
|
||||
"TaskExtractMediaSegments": "การสแกนส่วนของสื่อมีเดีย",
|
||||
"TaskMoveTrickplayImagesDescription": "ย้ายไฟล์ Trickplay ตามการตั้งค่าของไลบรารี",
|
||||
"TaskExtractMediaSegmentsDescription": "แยกหรือดึงส่วนของสื่อจากปลั๊กอินที่เปิดใช้งาน MediaSegment",
|
||||
"TaskMoveTrickplayImages": "ย้ายตำแหน่งเก็บภาพตัวอย่าง Trickplay"
|
||||
}
|
||||
|
||||
@@ -136,5 +136,7 @@
|
||||
"TaskMoveTrickplayImagesDescription": "Mevcut trickplay dosyalarını kütüphane ayarlarına göre taşır.",
|
||||
"TaskDownloadMissingLyrics": "Eksik şarkı sözlerini indir",
|
||||
"TaskDownloadMissingLyricsDescription": "Şarkı sözlerini indirir",
|
||||
"TaskExtractMediaSegmentsDescription": "MediaSegment özelliği etkin olan eklentilerden medya segmentlerini çıkarır veya alır."
|
||||
"TaskExtractMediaSegmentsDescription": "MediaSegment özelliği etkin olan eklentilerden medya segmentlerini çıkarır veya alır.",
|
||||
"CleanupUserDataTask": "Kullanıcı verisi temizleme görevi",
|
||||
"CleanupUserDataTaskDescription": "En az 90 gün boyunca artık mevcut olmayan medyadaki tüm kullanıcı verilerini (İzleme durumu, favori durumu vb.) temizler."
|
||||
}
|
||||
|
||||
@@ -135,5 +135,7 @@
|
||||
"TaskMoveTrickplayImagesDescription": "Переміщує наявні Trickplay-зображення відповідно до налаштувань медіатеки.",
|
||||
"TaskExtractMediaSegments": "Сканування медіа-сегментів",
|
||||
"TaskMoveTrickplayImages": "Змінити місце розташування Trickplay-зображень",
|
||||
"TaskExtractMediaSegmentsDescription": "Витягує або отримує медіа-сегменти з плагінів з підтримкою MediaSegment."
|
||||
"TaskExtractMediaSegmentsDescription": "Витягує або отримує медіа-сегменти з плагінів з підтримкою MediaSegment.",
|
||||
"CleanupUserDataTask": "Завдання очищення даних користувача",
|
||||
"CleanupUserDataTaskDescription": "Очищає всі дані користувача (стан перегляду, статус обраного тощо) з медіа, які перестали бути доступними щонайменше 90 днів тому."
|
||||
}
|
||||
|
||||
@@ -135,5 +135,7 @@
|
||||
"TaskExtractMediaSegmentsDescription": "Trích xuất hoặc lấy các phân đoạn phương tiện từ các plugin hỗ trợ MediaSegment.",
|
||||
"TaskMoveTrickplayImages": "Di chuyển vị trí hình ảnh Trickplay",
|
||||
"TaskMoveTrickplayImagesDescription": "Di chuyển các tập tin trickplay hiện có theo cài đặt thư viện.",
|
||||
"TaskExtractMediaSegments": "Quét Phân Đoạn Phương Tiện"
|
||||
"TaskExtractMediaSegments": "Quét Phân Đoạn Phương Tiện",
|
||||
"CleanupUserDataTask": "Tác vụ dọn dẹp dữ liệu người dùng",
|
||||
"CleanupUserDataTaskDescription": "Làm sạch tất cả dữ liệu người dùng (trạng thái xem, trạng thái yêu thích, v.v.) từ phương tiện không còn có mặt trong ít nhất 90 ngày."
|
||||
}
|
||||
|
||||
@@ -136,5 +136,7 @@
|
||||
"TaskMoveTrickplayImages": "迁移进度条预览图的存储位置",
|
||||
"TaskExtractMediaSegments": "媒体分段扫描",
|
||||
"TaskExtractMediaSegmentsDescription": "从支持 MediaSegment 的插件中提取或获取媒体分段。",
|
||||
"TaskMoveTrickplayImagesDescription": "根据媒体库设置移动现有的进度条预览图文件。"
|
||||
"TaskMoveTrickplayImagesDescription": "根据媒体库设置移动现有的进度条预览图文件。",
|
||||
"CleanupUserDataTask": "用户数据清理任务",
|
||||
"CleanupUserDataTaskDescription": "清理已被删除超过90天的媒体中的所有用户数据(观看状态、收藏夹状态等)。"
|
||||
}
|
||||
|
||||
@@ -423,7 +423,7 @@ namespace Emby.Server.Implementations.Plugins
|
||||
Overview = packageInfo.Overview,
|
||||
Owner = packageInfo.Owner,
|
||||
TargetAbi = versionInfo.TargetAbi ?? string.Empty,
|
||||
Timestamp = string.IsNullOrEmpty(versionInfo.Timestamp) ? DateTime.MinValue : DateTime.Parse(versionInfo.Timestamp, CultureInfo.InvariantCulture),
|
||||
Timestamp = string.IsNullOrEmpty(versionInfo.Timestamp) ? DateTime.MinValue : DateTime.Parse(versionInfo.Timestamp, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal),
|
||||
Version = versionInfo.Version,
|
||||
Status = status == PluginStatus.Disabled ? PluginStatus.Disabled : PluginStatus.Active, // Keep disabled state.
|
||||
AutoUpdate = true,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
#pragma warning disable RS0030 // Do not use banned APIs
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Server.Implementations.Item;
|
||||
using MediaBrowser.Model.Globalization;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Task to clean up any detached userdata from the database.
|
||||
/// </summary>
|
||||
public class CleanupUserDataTask : IScheduledTask
|
||||
{
|
||||
private readonly ILocalizationManager _localization;
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||
private readonly ILogger<CleanupUserDataTask> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CleanupUserDataTask"/> class.
|
||||
/// </summary>
|
||||
/// <param name="localization">The localisation Provider.</param>
|
||||
/// <param name="dbProvider">The DB context factory.</param>
|
||||
/// <param name="logger">A logger.</param>
|
||||
public CleanupUserDataTask(ILocalizationManager localization, IDbContextFactory<JellyfinDbContext> dbProvider, ILogger<CleanupUserDataTask> logger)
|
||||
{
|
||||
_localization = localization;
|
||||
_dbProvider = dbProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => _localization.GetLocalizedString("CleanupUserDataTask");
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => _localization.GetLocalizedString("CleanupUserDataTaskDescription");
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory");
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Key => nameof(CleanupUserDataTask);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
const int LimitDays = 90;
|
||||
var userDataDate = DateTime.UtcNow.AddDays(LimitDays * -1);
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
var detachedUserData = dbContext.UserData.Where(e => e.ItemId == BaseItemRepository.PlaceholderId);
|
||||
_logger.LogInformation("There are {NoDetached} detached UserData entries.", detachedUserData.Count());
|
||||
|
||||
detachedUserData = detachedUserData.Where(e => e.RetentionDate < userDataDate);
|
||||
|
||||
_logger.LogInformation("{NoDetached} are older then {Limit} days.", detachedUserData.Count(), LimitDays);
|
||||
|
||||
await detachedUserData.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
progress.Report(100);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
@@ -54,12 +54,12 @@ public class RefreshMediaLibraryTask : IScheduledTask
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
progress.Report(0);
|
||||
|
||||
return ((LibraryManager)_libraryManager).ValidateMediaLibraryInternal(progress, cancellationToken);
|
||||
await ((LibraryManager)_libraryManager).ValidateMediaLibraryInternal(progress, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,7 +456,7 @@ namespace Emby.Server.Implementations.Session
|
||||
|
||||
var nowPlayingQueue = info.NowPlayingQueue;
|
||||
|
||||
if (nowPlayingQueue?.Length > 0)
|
||||
if (nowPlayingQueue?.Length > 0 && !nowPlayingQueue.SequenceEqual(session.NowPlayingQueue))
|
||||
{
|
||||
session.NowPlayingQueue = nowPlayingQueue;
|
||||
|
||||
@@ -474,6 +474,7 @@ namespace Emby.Server.Implementations.Session
|
||||
private void RemoveNowPlayingItem(SessionInfo session)
|
||||
{
|
||||
session.NowPlayingItem = null;
|
||||
session.FullNowPlayingItem = null;
|
||||
session.PlayState = new PlayerStateInfo();
|
||||
|
||||
if (!string.IsNullOrEmpty(session.DeviceId))
|
||||
|
||||
@@ -5,7 +5,6 @@ using Jellyfin.Data.Enums;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.LiveTv;
|
||||
using MediaBrowser.Controller.Sorting;
|
||||
using MediaBrowser.Model.Querying;
|
||||
|
||||
namespace Emby.Server.Implementations.Sorting
|
||||
{
|
||||
|
||||
@@ -85,7 +85,10 @@ public class SystemManager : ISystemManager
|
||||
/// <inheritdoc/>
|
||||
public SystemStorageInfo GetSystemStorageInfo()
|
||||
{
|
||||
var virtualFolderInfos = _libraryManager.GetVirtualFolders().Select(e => new LibraryStorageInfo()
|
||||
var virtualFolderInfos = _libraryManager
|
||||
.GetVirtualFolders()
|
||||
.Where(e => !string.IsNullOrWhiteSpace(e.ItemId)) // this should not be null but for some users it is.
|
||||
.Select(e => new LibraryStorageInfo()
|
||||
{
|
||||
Id = Guid.Parse(e.ItemId),
|
||||
Name = e.Name,
|
||||
|
||||
@@ -292,12 +292,12 @@ public class BackupService : IBackupService
|
||||
var historyRepository = dbContext.GetService<IHistoryRepository>();
|
||||
var migrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
|
||||
|
||||
ICollection<(Type Type, Func<IAsyncEnumerable<object>> ValueFactory)> entityTypes = [
|
||||
ICollection<(Type Type, string SourceName, Func<IAsyncEnumerable<object>> ValueFactory)> entityTypes = [
|
||||
.. typeof(JellyfinDbContext)
|
||||
.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
|
||||
.Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable)))
|
||||
.Select(e => (Type: e.PropertyType, ValueFactory: new Func<IAsyncEnumerable<object>>(() => GetValues((IQueryable)e.GetValue(dbContext)!, e.PropertyType)))),
|
||||
(Type: typeof(HistoryRow), ValueFactory: new Func<IAsyncEnumerable<object>>(() => migrations.ToAsyncEnumerable()))
|
||||
.Select(e => (Type: e.PropertyType, dbContext.Model.FindEntityType(e.PropertyType.GetGenericArguments()[0])!.GetSchemaQualifiedTableName()!, ValueFactory: new Func<IAsyncEnumerable<object>>(() => GetValues((IQueryable)e.GetValue(dbContext)!, e.PropertyType)))),
|
||||
(Type: typeof(HistoryRow), SourceName: nameof(HistoryRow), ValueFactory: new Func<IAsyncEnumerable<object>>(() => migrations.ToAsyncEnumerable()))
|
||||
];
|
||||
manifest.DatabaseTables = entityTypes.Select(e => e.Type.Name).ToArray();
|
||||
var transaction = await dbContext.Database.BeginTransactionAsync().ConfigureAwait(false);
|
||||
@@ -308,8 +308,8 @@ public class BackupService : IBackupService
|
||||
|
||||
foreach (var entityType in entityTypes)
|
||||
{
|
||||
_logger.LogInformation("Begin backup of entity {Table}", entityType.Type.Name);
|
||||
var zipEntry = zipArchive.CreateEntry($"Database\\{entityType.Type.Name}.json");
|
||||
_logger.LogInformation("Begin backup of entity {Table}", entityType.SourceName);
|
||||
var zipEntry = zipArchive.CreateEntry($"Database\\{entityType.SourceName}.json");
|
||||
var entities = 0;
|
||||
var zipEntryStream = zipEntry.Open();
|
||||
await using (zipEntryStream.ConfigureAwait(false))
|
||||
|
||||
@@ -14,6 +14,7 @@ using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
@@ -53,6 +54,11 @@ namespace Jellyfin.Server.Implementations.Item;
|
||||
public sealed class BaseItemRepository
|
||||
: IItemRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the placeholder id for UserData detached items.
|
||||
/// </summary>
|
||||
public static readonly Guid PlaceholderId = Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||
|
||||
/// <summary>
|
||||
/// This holds all the types in the running assemblies
|
||||
/// so that we can de-serialize properly when we don't have strong types.
|
||||
@@ -95,13 +101,21 @@ public sealed class BaseItemRepository
|
||||
/// <inheritdoc />
|
||||
public void DeleteItem(Guid id)
|
||||
{
|
||||
if (id.IsEmpty())
|
||||
if (id.IsEmpty() || id.Equals(PlaceholderId))
|
||||
{
|
||||
throw new ArgumentException("Guid can't be empty", nameof(id));
|
||||
throw new ArgumentException("Guid can't be empty or the placeholder id.", nameof(id));
|
||||
}
|
||||
|
||||
using var context = _dbProvider.CreateDbContext();
|
||||
using var transaction = context.Database.BeginTransaction();
|
||||
|
||||
var date = (DateTime?)DateTime.UtcNow;
|
||||
// Detach all user watch data
|
||||
context.UserData.Where(e => e.ItemId == id)
|
||||
.ExecuteUpdate(e => e
|
||||
.SetProperty(f => f.RetentionDate, date)
|
||||
.SetProperty(f => f.ItemId, PlaceholderId));
|
||||
|
||||
context.AncestorIds.Where(e => e.ItemId == id || e.ParentItemId == id).ExecuteDelete();
|
||||
context.AttachmentStreamInfos.Where(e => e.ItemId == id).ExecuteDelete();
|
||||
context.BaseItemImageInfos.Where(e => e.ItemId == id).ExecuteDelete();
|
||||
@@ -144,7 +158,7 @@ public sealed class BaseItemRepository
|
||||
PrepareFilterQuery(filter);
|
||||
|
||||
using var context = _dbProvider.CreateDbContext();
|
||||
return ApplyQueryFilter(context.BaseItems.AsNoTracking(), context, filter).Select(e => e.Id).ToArray();
|
||||
return ApplyQueryFilter(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, filter).Select(e => e.Id).ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -242,7 +256,7 @@ public sealed class BaseItemRepository
|
||||
dbQuery = ApplyGroupingFilter(dbQuery, filter);
|
||||
dbQuery = ApplyQueryPaging(dbQuery, filter);
|
||||
|
||||
result.Items = dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserialiseBaseItem(w, filter.SkipDeserialization)).ToArray();
|
||||
result.Items = dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
|
||||
result.StartIndex = filter.StartIndex ?? 0;
|
||||
return result;
|
||||
}
|
||||
@@ -261,7 +275,7 @@ public sealed class BaseItemRepository
|
||||
dbQuery = ApplyGroupingFilter(dbQuery, filter);
|
||||
dbQuery = ApplyQueryPaging(dbQuery, filter);
|
||||
|
||||
return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserialiseBaseItem(w, filter.SkipDeserialization)).ToArray();
|
||||
return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -303,7 +317,7 @@ public sealed class BaseItemRepository
|
||||
mainquery = ApplyGroupingFilter(mainquery, filter);
|
||||
mainquery = ApplyQueryPaging(mainquery, filter);
|
||||
|
||||
return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserialiseBaseItem(w, filter.SkipDeserialization)).ToArray();
|
||||
return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -319,7 +333,7 @@ public sealed class BaseItemRepository
|
||||
.Where(i => filter.TopParentIds.Contains(i.TopParentId!.Value))
|
||||
.Where(i => i.Type == _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode])
|
||||
.Join(
|
||||
context.UserData.AsNoTracking(),
|
||||
context.UserData.AsNoTracking().Where(e => e.ItemId != EF.Constant(PlaceholderId)),
|
||||
i => new { UserId = filter.User.Id, ItemId = i.Id },
|
||||
u => new { UserId = u.UserId, ItemId = u.ItemId },
|
||||
(entity, data) => new { Item = entity, UserData = data })
|
||||
@@ -472,7 +486,7 @@ public sealed class BaseItemRepository
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var tuples = new List<(BaseItemDto Item, List<Guid>? AncestorIds, BaseItemDto TopParent, IEnumerable<string> UserDataKey, List<string> InheritedTags)>();
|
||||
foreach (var item in items.GroupBy(e => e.Id).Select(e => e.Last()))
|
||||
foreach (var item in items.GroupBy(e => e.Id).Select(e => e.Last()).Where(e => e.Id != PlaceholderId))
|
||||
{
|
||||
var ancestorIds = item.SupportsAncestors ?
|
||||
item.GetAncestorIds().Distinct().ToList() :
|
||||
@@ -491,6 +505,7 @@ public sealed class BaseItemRepository
|
||||
|
||||
var ids = tuples.Select(f => f.Item.Id).ToArray();
|
||||
var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToArray();
|
||||
var newItems = tuples.Where(e => !existingItems.Contains(e.Item.Id)).ToArray();
|
||||
|
||||
foreach (var item in tuples)
|
||||
{
|
||||
@@ -511,8 +526,21 @@ public sealed class BaseItemRepository
|
||||
|
||||
context.SaveChanges();
|
||||
|
||||
foreach (var item in newItems)
|
||||
{
|
||||
// reattach old userData entries
|
||||
var userKeys = item.UserDataKey.ToArray();
|
||||
var retentionDate = (DateTime?)null;
|
||||
context.UserData
|
||||
.Where(e => e.ItemId == PlaceholderId)
|
||||
.Where(e => userKeys.Contains(e.CustomDataKey))
|
||||
.ExecuteUpdate(e => e
|
||||
.SetProperty(f => f.ItemId, item.Item.Id)
|
||||
.SetProperty(f => f.RetentionDate, retentionDate));
|
||||
}
|
||||
|
||||
var itemValueMaps = tuples
|
||||
.Select(e => (Item: e.Item, Values: GetItemValuesToSave(e.Item, e.InheritedTags)))
|
||||
.Select(e => (e.Item, Values: GetItemValuesToSave(e.Item, e.InheritedTags)))
|
||||
.ToArray();
|
||||
var allListedItemValues = itemValueMaps
|
||||
.SelectMany(f => f.Values)
|
||||
@@ -539,7 +567,7 @@ public sealed class BaseItemRepository
|
||||
|
||||
var itemValuesStore = existingValues.Concat(missingItemValues).ToArray();
|
||||
var valueMap = itemValueMaps
|
||||
.Select(f => (Item: f.Item, Values: f.Values.Select(e => itemValuesStore.First(g => g.Value == e.Value && g.Type == e.MagicNumber)).ToArray()))
|
||||
.Select(f => (f.Item, Values: f.Values.Select(e => itemValuesStore.First(g => g.Value == e.Value && g.Type == e.MagicNumber)).DistinctBy(e => e.ItemValueId).ToArray()))
|
||||
.ToArray();
|
||||
|
||||
var mappedValues = context.ItemValuesMap.Where(e => ids.Contains(e.ItemId)).ToList();
|
||||
@@ -627,7 +655,7 @@ public sealed class BaseItemRepository
|
||||
return null;
|
||||
}
|
||||
|
||||
return DeserialiseBaseItem(item);
|
||||
return DeserializeBaseItem(item);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -673,12 +701,12 @@ public sealed class BaseItemRepository
|
||||
dto.TotalBitrate = entity.TotalBitrate;
|
||||
dto.ExternalId = entity.ExternalId;
|
||||
dto.Size = entity.Size;
|
||||
dto.Genres = entity.Genres?.Split('|') ?? [];
|
||||
dto.DateCreated = entity.DateCreated.GetValueOrDefault();
|
||||
dto.DateModified = entity.DateModified.GetValueOrDefault();
|
||||
dto.Genres = string.IsNullOrWhiteSpace(entity.Genres) ? [] : entity.Genres.Split('|');
|
||||
dto.DateCreated = entity.DateCreated ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
|
||||
dto.DateModified = entity.DateModified ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
|
||||
dto.ChannelId = entity.ChannelId ?? Guid.Empty;
|
||||
dto.DateLastRefreshed = entity.DateLastRefreshed.GetValueOrDefault();
|
||||
dto.DateLastSaved = entity.DateLastSaved.GetValueOrDefault();
|
||||
dto.DateLastRefreshed = entity.DateLastRefreshed ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
|
||||
dto.DateLastSaved = entity.DateLastSaved ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
|
||||
dto.OwnerId = string.IsNullOrWhiteSpace(entity.OwnerId) ? Guid.Empty : (Guid.TryParse(entity.OwnerId, out var ownerId) ? ownerId : Guid.Empty);
|
||||
dto.Width = entity.Width.GetValueOrDefault();
|
||||
dto.Height = entity.Height.GetValueOrDefault();
|
||||
@@ -705,7 +733,7 @@ public sealed class BaseItemRepository
|
||||
dto.ExtraIds = string.IsNullOrWhiteSpace(entity.ExtraIds) ? [] : entity.ExtraIds.Split('|').Select(e => Guid.Parse(e)).ToArray();
|
||||
dto.ProductionLocations = entity.ProductionLocations?.Split('|') ?? [];
|
||||
dto.Studios = entity.Studios?.Split('|') ?? [];
|
||||
dto.Tags = entity.Tags?.Split('|') ?? [];
|
||||
dto.Tags = string.IsNullOrWhiteSpace(entity.Tags) ? [] : entity.Tags.Split('|');
|
||||
|
||||
if (dto is IHasProgramAttributes hasProgramAttributes)
|
||||
{
|
||||
@@ -779,7 +807,7 @@ public sealed class BaseItemRepository
|
||||
|
||||
if (dto is Folder folder)
|
||||
{
|
||||
folder.DateLastMediaAdded = entity.DateLastMediaAdded;
|
||||
folder.DateLastMediaAdded = entity.DateLastMediaAdded ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
return dto;
|
||||
@@ -839,11 +867,11 @@ public sealed class BaseItemRepository
|
||||
entity.ExternalId = dto.ExternalId;
|
||||
entity.Size = dto.Size;
|
||||
entity.Genres = string.Join('|', dto.Genres);
|
||||
entity.DateCreated = dto.DateCreated;
|
||||
entity.DateModified = dto.DateModified;
|
||||
entity.DateCreated = dto.DateCreated == DateTime.MinValue ? null : dto.DateCreated;
|
||||
entity.DateModified = dto.DateModified == DateTime.MinValue ? null : dto.DateModified;
|
||||
entity.ChannelId = dto.ChannelId;
|
||||
entity.DateLastRefreshed = dto.DateLastRefreshed;
|
||||
entity.DateLastSaved = dto.DateLastSaved;
|
||||
entity.DateLastRefreshed = dto.DateLastRefreshed == DateTime.MinValue ? null : dto.DateLastRefreshed;
|
||||
entity.DateLastSaved = dto.DateLastSaved == DateTime.MinValue ? null : dto.DateLastSaved;
|
||||
entity.OwnerId = dto.OwnerId.ToString();
|
||||
entity.Width = dto.Width;
|
||||
entity.Height = dto.Height;
|
||||
@@ -953,7 +981,7 @@ public sealed class BaseItemRepository
|
||||
|
||||
if (dto is Folder folder)
|
||||
{
|
||||
entity.DateLastMediaAdded = folder.DateLastMediaAdded;
|
||||
entity.DateLastMediaAdded = folder.DateLastMediaAdded == DateTime.MinValue ? null : folder.DateLastMediaAdded;
|
||||
entity.IsFolder = folder.IsFolder;
|
||||
}
|
||||
|
||||
@@ -989,7 +1017,7 @@ public sealed class BaseItemRepository
|
||||
return type.GetCustomAttribute<RequiresSourceSerialisationAttribute>() == null;
|
||||
}
|
||||
|
||||
private BaseItemDto DeserialiseBaseItem(BaseItemEntity baseItemEntity, bool skipDeserialization = false)
|
||||
private BaseItemDto DeserializeBaseItem(BaseItemEntity baseItemEntity, bool skipDeserialization = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(baseItemEntity, nameof(baseItemEntity));
|
||||
if (_serverConfigurationManager?.Configuration is null)
|
||||
@@ -998,7 +1026,7 @@ public sealed class BaseItemRepository
|
||||
}
|
||||
|
||||
var typeToSerialise = GetType(baseItemEntity.Type);
|
||||
return BaseItemRepository.DeserialiseBaseItem(
|
||||
return BaseItemRepository.DeserializeBaseItem(
|
||||
baseItemEntity,
|
||||
_logger,
|
||||
_appHost,
|
||||
@@ -1006,7 +1034,7 @@ public sealed class BaseItemRepository
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserialises a BaseItemEntity and sets all properties.
|
||||
/// Deserializes a BaseItemEntity and sets all properties.
|
||||
/// </summary>
|
||||
/// <param name="baseItemEntity">The DB entity.</param>
|
||||
/// <param name="logger">Logger.</param>
|
||||
@@ -1014,9 +1042,9 @@ public sealed class BaseItemRepository
|
||||
/// <param name="skipDeserialization">If only mapping should be processed.</param>
|
||||
/// <returns>A mapped BaseItem.</returns>
|
||||
/// <exception cref="InvalidOperationException">Will be thrown if an invalid serialisation is requested.</exception>
|
||||
public static BaseItemDto DeserialiseBaseItem(BaseItemEntity baseItemEntity, ILogger logger, IServerApplicationHost? appHost, bool skipDeserialization = false)
|
||||
public static BaseItemDto DeserializeBaseItem(BaseItemEntity baseItemEntity, ILogger logger, IServerApplicationHost? appHost, bool skipDeserialization = false)
|
||||
{
|
||||
var type = GetType(baseItemEntity.Type) ?? throw new InvalidOperationException("Cannot deserialise unknown type.");
|
||||
var type = GetType(baseItemEntity.Type) ?? throw new InvalidOperationException("Cannot deserialize unknown type.");
|
||||
BaseItemDto? dto = null;
|
||||
if (TypeRequiresDeserialization(type) && baseItemEntity.Data is not null && !skipDeserialization)
|
||||
{
|
||||
@@ -1032,7 +1060,7 @@ public sealed class BaseItemRepository
|
||||
|
||||
if (dto is null)
|
||||
{
|
||||
dto = Activator.CreateInstance(type) as BaseItemDto ?? throw new InvalidOperationException("Cannot deserialise unknown type.");
|
||||
dto = Activator.CreateInstance(type) as BaseItemDto ?? throw new InvalidOperationException("Cannot deserialize unknown type.");
|
||||
}
|
||||
|
||||
return Map(baseItemEntity, dto, appHost);
|
||||
@@ -1049,7 +1077,7 @@ public sealed class BaseItemRepository
|
||||
|
||||
using var context = _dbProvider.CreateDbContext();
|
||||
|
||||
var innerQueryFilter = TranslateQuery(context.BaseItems, context, new InternalItemsQuery(filter.User)
|
||||
var innerQueryFilter = TranslateQuery(context.BaseItems.Where(e => e.Id != EF.Constant(PlaceholderId)), context, new InternalItemsQuery(filter.User)
|
||||
{
|
||||
ExcludeItemTypes = filter.ExcludeItemTypes,
|
||||
IncludeItemTypes = filter.IncludeItemTypes,
|
||||
@@ -1138,7 +1166,7 @@ public sealed class BaseItemRepository
|
||||
IsPlayed = filter.IsPlayed
|
||||
};
|
||||
|
||||
itemCountQuery = TranslateQuery(context.BaseItems.AsNoTracking(), context, typeSubQuery)
|
||||
itemCountQuery = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, typeSubQuery)
|
||||
.Where(e => e.ItemValues!.Any(f => itemValueTypes!.Contains(f.ItemValue.Type)));
|
||||
|
||||
var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
|
||||
@@ -1178,7 +1206,7 @@ public sealed class BaseItemRepository
|
||||
.Where(e => e is not null)
|
||||
.Select(e =>
|
||||
{
|
||||
return (DeserialiseBaseItem(e.item, filter.SkipDeserialization), e.itemCount);
|
||||
return (DeserializeBaseItem(e.item, filter.SkipDeserialization), e.itemCount);
|
||||
})
|
||||
];
|
||||
}
|
||||
@@ -1193,7 +1221,7 @@ public sealed class BaseItemRepository
|
||||
.Where(e => e is not null)
|
||||
.Select<BaseItemEntity, (BaseItemDto, ItemCounts?)>(e =>
|
||||
{
|
||||
return (DeserialiseBaseItem(e, filter.SkipDeserialization), null);
|
||||
return (DeserializeBaseItem(e, filter.SkipDeserialization), null);
|
||||
})
|
||||
];
|
||||
}
|
||||
@@ -1274,7 +1302,7 @@ public sealed class BaseItemRepository
|
||||
{
|
||||
Path = appHost?.ExpandVirtualPath(e.Path) ?? e.Path,
|
||||
BlurHash = e.Blurhash is null ? null : Encoding.UTF8.GetString(e.Blurhash),
|
||||
DateModified = e.DateModified,
|
||||
DateModified = e.DateModified ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc),
|
||||
Height = e.Height,
|
||||
Width = e.Width,
|
||||
Type = (ImageType)e.ImageType
|
||||
@@ -1814,7 +1842,7 @@ public sealed class BaseItemRepository
|
||||
// We should probably figure this out for all folders, but for right now, this is the only place where we need it
|
||||
if (filter.IncludeItemTypes.Length == 1 && filter.IncludeItemTypes[0] == BaseItemKind.Series)
|
||||
{
|
||||
baseQuery = baseQuery.Where(e => context.BaseItems
|
||||
baseQuery = baseQuery.Where(e => context.BaseItems.Where(e => e.Id != EF.Constant(PlaceholderId))
|
||||
.Where(e => e.IsFolder == false && e.IsVirtualItem == false)
|
||||
.Where(f => f.UserData!.FirstOrDefault(e => e.UserId == filter.User!.Id && e.Played)!.Played)
|
||||
.Any(f => f.SeriesPresentationUniqueKey == e.PresentationUniqueKey) == filter.IsPlayed);
|
||||
@@ -2064,7 +2092,7 @@ public sealed class BaseItemRepository
|
||||
if (filter.HasDeadParentId.HasValue && filter.HasDeadParentId.Value)
|
||||
{
|
||||
baseQuery = baseQuery
|
||||
.Where(e => e.ParentId.HasValue && !context.BaseItems.Any(f => f.Id == e.ParentId.Value));
|
||||
.Where(e => e.ParentId.HasValue && !context.BaseItems.Where(e => e.Id != EF.Constant(PlaceholderId)).Any(f => f.Id == e.ParentId.Value));
|
||||
}
|
||||
|
||||
if (filter.IsDeadArtist.HasValue && filter.IsDeadArtist.Value)
|
||||
@@ -2145,17 +2173,19 @@ public sealed class BaseItemRepository
|
||||
if (filter.ExcludeItemIds.Length > 0)
|
||||
{
|
||||
baseQuery = baseQuery
|
||||
.Where(e => !filter.ItemIds.Contains(e.Id));
|
||||
.Where(e => !filter.ExcludeItemIds.Contains(e.Id));
|
||||
}
|
||||
|
||||
if (filter.ExcludeProviderIds is not null && filter.ExcludeProviderIds.Count > 0)
|
||||
{
|
||||
baseQuery = baseQuery.Where(e => !e.Provider!.All(f => !filter.ExcludeProviderIds.All(w => f.ProviderId == w.Key && f.ProviderValue == w.Value)));
|
||||
var exclude = filter.ExcludeProviderIds.Select(e => $"{e.Key}:{e.Value}").ToArray();
|
||||
baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.All(f => !exclude.Contains(f)));
|
||||
}
|
||||
|
||||
if (filter.HasAnyProviderId is not null && filter.HasAnyProviderId.Count > 0)
|
||||
{
|
||||
baseQuery = baseQuery.Where(e => e.Provider!.Any(f => !filter.HasAnyProviderId.Any(w => f.ProviderId == w.Key && f.ProviderValue == w.Value)));
|
||||
var include = filter.HasAnyProviderId.Select(e => $"{e.Key}:{e.Value}").ToArray();
|
||||
baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.Any(f => include.Contains(f)));
|
||||
}
|
||||
|
||||
if (filter.HasImdbId.HasValue)
|
||||
@@ -2197,7 +2227,7 @@ public sealed class BaseItemRepository
|
||||
if (!string.IsNullOrWhiteSpace(filter.AncestorWithPresentationUniqueKey))
|
||||
{
|
||||
baseQuery = baseQuery
|
||||
.Where(e => context.BaseItems.Where(f => f.PresentationUniqueKey == filter.AncestorWithPresentationUniqueKey).Any(f => f.Children!.Any(w => w.ItemId == e.Id)));
|
||||
.Where(e => context.BaseItems.Where(e => e.Id != EF.Constant(PlaceholderId)).Where(f => f.PresentationUniqueKey == filter.AncestorWithPresentationUniqueKey).Any(f => f.Children!.Any(w => w.ItemId == e.Id)));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.SeriesPresentationUniqueKey))
|
||||
@@ -2324,4 +2354,14 @@ public sealed class BaseItemRepository
|
||||
|
||||
return baseQuery;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<bool> ItemExistsAsync(Guid id)
|
||||
{
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
return await dbContext.BaseItems.AnyAsync(f => f.Id == id).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ public class MediaSegmentManager : IMediaSegmentManager
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task RunSegmentPluginProviders(BaseItem baseItem, LibraryOptions libraryOptions, bool overwrite, CancellationToken cancellationToken)
|
||||
public async Task RunSegmentPluginProviders(BaseItem baseItem, LibraryOptions libraryOptions, bool forceOverwrite, CancellationToken cancellationToken)
|
||||
{
|
||||
var providers = _segmentProviders
|
||||
.Where(e => !libraryOptions.DisabledMediaSegmentProviders.Contains(GetProviderId(e.Name)))
|
||||
@@ -70,18 +70,13 @@ public class MediaSegmentManager : IMediaSegmentManager
|
||||
|
||||
using var db = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!overwrite && (await db.MediaSegments.AnyAsync(e => e.ItemId.Equals(baseItem.Id), cancellationToken).ConfigureAwait(false)))
|
||||
{
|
||||
_logger.LogDebug("Skip {MediaPath} as it already contains media segments", baseItem.Path);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Start media segment extraction for {MediaPath} with {CountProviders} providers enabled", baseItem.Path, providers.Count);
|
||||
|
||||
await db.MediaSegments.Where(e => e.ItemId.Equals(baseItem.Id)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// no need to recreate the request object every time.
|
||||
var requestItem = new MediaSegmentGenerationRequest() { ItemId = baseItem.Id };
|
||||
if (forceOverwrite)
|
||||
{
|
||||
// delete all existing media segments if forceOverwrite is set.
|
||||
await db.MediaSegments.Where(e => e.ItemId.Equals(baseItem.Id)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
foreach (var provider in providers)
|
||||
{
|
||||
@@ -91,15 +86,56 @@ public class MediaSegmentManager : IMediaSegmentManager
|
||||
continue;
|
||||
}
|
||||
|
||||
IQueryable<MediaSegment> existingSegments;
|
||||
if (forceOverwrite)
|
||||
{
|
||||
existingSegments = Array.Empty<MediaSegment>().AsQueryable();
|
||||
}
|
||||
else
|
||||
{
|
||||
existingSegments = db.MediaSegments.Where(e => e.ItemId.Equals(baseItem.Id) && e.SegmentProviderId == GetProviderId(provider.Name));
|
||||
}
|
||||
|
||||
var requestItem = new MediaSegmentGenerationRequest()
|
||||
{
|
||||
ItemId = baseItem.Id,
|
||||
ExistingSegments = existingSegments.Select(e => Map(e)).ToArray()
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var segments = await provider.GetMediaSegments(requestItem, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (segments.Count == 0)
|
||||
|
||||
if (!forceOverwrite)
|
||||
{
|
||||
var existingSegmentsList = existingSegments.ToArray(); // Cannot use requestItem's list, as the provider might tamper with its items.
|
||||
if (segments.Count == requestItem.ExistingSegments.Count && segments.All(e => existingSegmentsList.Any(f =>
|
||||
{
|
||||
return
|
||||
e.StartTicks == f.StartTicks &&
|
||||
e.EndTicks == f.EndTicks &&
|
||||
e.Type == f.Type;
|
||||
})))
|
||||
{
|
||||
_logger.LogDebug("Media Segment provider {ProviderName} did not modify any segments for {MediaPath}", provider.Name, baseItem.Path);
|
||||
continue;
|
||||
}
|
||||
|
||||
// delete existing media segments that were re-generated.
|
||||
await existingSegments.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (segments.Count == 0 && !requestItem.ExistingSegments.Any())
|
||||
{
|
||||
_logger.LogDebug("Media Segment provider {ProviderName} did not find any segments for {MediaPath}", provider.Name, baseItem.Path);
|
||||
continue;
|
||||
}
|
||||
else if (segments.Count == 0 && requestItem.ExistingSegments.Any())
|
||||
{
|
||||
_logger.LogDebug("Media Segment provider {ProviderName} deleted all segments for {MediaPath}", provider.Name, baseItem.Path);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Media Segment provider {ProviderName} found {CountSegments} for {MediaPath}", provider.Name, segments.Count, baseItem.Path);
|
||||
var providerId = GetProviderId(provider.Name);
|
||||
|
||||
@@ -116,26 +116,7 @@ namespace Jellyfin.Server.Extensions
|
||||
.AddTransient<ICorsPolicyProvider, CorsPolicyProvider>()
|
||||
.Configure<ForwardedHeadersOptions>(options =>
|
||||
{
|
||||
// https://github.com/dotnet/aspnetcore/blob/master/src/Middleware/HttpOverrides/src/ForwardedHeadersMiddleware.cs
|
||||
// Enable debug logging on Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware to help investigate issues.
|
||||
|
||||
if (config.KnownProxies.Length == 0)
|
||||
{
|
||||
options.ForwardedHeaders = ForwardedHeaders.None;
|
||||
options.KnownNetworks.Clear();
|
||||
options.KnownProxies.Clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost;
|
||||
AddProxyAddresses(config, config.KnownProxies, options);
|
||||
}
|
||||
|
||||
// Only set forward limit if we have some known proxies or some known networks.
|
||||
if (options.KnownProxies.Count != 0 || options.KnownNetworks.Count != 0)
|
||||
{
|
||||
options.ForwardLimit = null;
|
||||
}
|
||||
ConfigureForwardHeaders(config, options);
|
||||
})
|
||||
.AddMvc(opts =>
|
||||
{
|
||||
@@ -183,6 +164,30 @@ namespace Jellyfin.Server.Extensions
|
||||
return mvcBuilder.AddControllersAsServices();
|
||||
}
|
||||
|
||||
internal static void ConfigureForwardHeaders(NetworkConfiguration config, ForwardedHeadersOptions options)
|
||||
{
|
||||
// https://github.com/dotnet/aspnetcore/blob/master/src/Middleware/HttpOverrides/src/ForwardedHeadersMiddleware.cs
|
||||
// Enable debug logging on Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware to help investigate issues.
|
||||
|
||||
if (config.KnownProxies.Length == 0)
|
||||
{
|
||||
options.ForwardedHeaders = ForwardedHeaders.None;
|
||||
options.KnownNetworks.Clear();
|
||||
options.KnownProxies.Clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost;
|
||||
AddProxyAddresses(config, config.KnownProxies, options);
|
||||
}
|
||||
|
||||
// Only set forward limit if we have some known proxies or some known networks.
|
||||
if (options.KnownProxies.Count != 0 || options.KnownNetworks.Count != 0)
|
||||
{
|
||||
options.ForwardLimit = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds Swagger to the service collection.
|
||||
/// </summary>
|
||||
@@ -248,7 +253,7 @@ namespace Jellyfin.Server.Extensions
|
||||
c.AddSwaggerTypeMappings();
|
||||
|
||||
c.SchemaFilter<IgnoreEnumSchemaFilter>();
|
||||
c.OperationFilter<RetryOnTemporarlyUnavailableFilter>();
|
||||
c.OperationFilter<RetryOnTemporarilyUnavailableFilter>();
|
||||
c.OperationFilter<SecurityRequirementsOperationFilter>();
|
||||
c.OperationFilter<FileResponseFilter>();
|
||||
c.OperationFilter<FileRequestFilter>();
|
||||
|
||||
@@ -6,13 +6,13 @@ using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
namespace Jellyfin.Server.Filters;
|
||||
|
||||
internal class RetryOnTemporarlyUnavailableFilter : IOperationFilter
|
||||
internal class RetryOnTemporarilyUnavailableFilter : IOperationFilter
|
||||
{
|
||||
public void Apply(OpenApiOperation operation, OperationFilterContext context)
|
||||
{
|
||||
operation.Responses.Add("503", new OpenApiResponse()
|
||||
{
|
||||
Description = "The server is currently starting or is temporarly not available.",
|
||||
Description = "The server is currently starting or is temporarily not available.",
|
||||
Headers = new Dictionary<string, OpenApiHeader>()
|
||||
{
|
||||
{
|
||||
@@ -53,6 +53,7 @@
|
||||
<PackageReference Include="prometheus-net.AspNetCore" />
|
||||
<PackageReference Include="Serilog.AspNetCore" />
|
||||
<PackageReference Include="Serilog.Enrichers.Thread" />
|
||||
<PackageReference Include="Serilog.Expressions" />
|
||||
<PackageReference Include="Serilog.Settings.Configuration" />
|
||||
<PackageReference Include="Serilog.Sinks.Async" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" />
|
||||
|
||||
@@ -47,7 +47,7 @@ internal class JellyfinMigrationService
|
||||
public JellyfinMigrationService(
|
||||
IDbContextFactory<JellyfinDbContext> dbContextFactory,
|
||||
ILoggerFactory loggerFactory,
|
||||
IStartupLogger startupLogger,
|
||||
IStartupLogger<JellyfinMigrationService> startupLogger,
|
||||
IApplicationPaths applicationPaths,
|
||||
IBackupService? backupService = null,
|
||||
IJellyfinDatabaseProvider? jellyfinDatabaseProvider = null)
|
||||
|
||||
168
Jellyfin.Server/Migrations/Routines/FixDates.cs
Normal file
168
Jellyfin.Server/Migrations/Routines/FixDates.cs
Normal file
@@ -0,0 +1,168 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Server.ServerSetupApp;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Server.Migrations.Routines;
|
||||
|
||||
/// <summary>
|
||||
/// Migration to fix dates saved in the database to always be UTC.
|
||||
/// </summary>
|
||||
[JellyfinMigration("2025-06-20T18:00:00", nameof(FixDates))]
|
||||
public class FixDates : IAsyncMigrationRoutine
|
||||
{
|
||||
private const int PageSize = 5000;
|
||||
|
||||
private readonly ILogger _logger;
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FixDates"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="startupLogger">The startup logger for Startup UI integration.</param>
|
||||
/// <param name="dbProvider">Instance of the <see cref="IDbContextFactory{JellyfinDbContext}"/> interface.</param>
|
||||
public FixDates(
|
||||
ILogger<FixDates> logger,
|
||||
IStartupLogger<FixDates> startupLogger,
|
||||
IDbContextFactory<JellyfinDbContext> dbProvider)
|
||||
{
|
||||
_logger = startupLogger.With(logger);
|
||||
_dbProvider = dbProvider;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task PerformAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TimeZoneInfo.Local.Equals(TimeZoneInfo.Utc))
|
||||
{
|
||||
using var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
await FixBaseItemsAsync(context, sw, cancellationToken).ConfigureAwait(false);
|
||||
sw.Reset();
|
||||
await FixChaptersAsync(context, sw, cancellationToken).ConfigureAwait(false);
|
||||
sw.Reset();
|
||||
await FixBaseItemImageInfos(context, sw, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FixBaseItemsAsync(JellyfinDbContext context, Stopwatch sw, CancellationToken cancellationToken)
|
||||
{
|
||||
int itemCount = 0;
|
||||
|
||||
var baseQuery = context.BaseItems.OrderBy(e => e.Id);
|
||||
var records = baseQuery.Count();
|
||||
_logger.LogInformation("Fixing dates for {Count} BaseItems.", records);
|
||||
|
||||
sw.Start();
|
||||
await foreach (var result in context.BaseItems.OrderBy(e => e.Id)
|
||||
.WithPartitionProgress(
|
||||
(partition) =>
|
||||
_logger.LogInformation(
|
||||
"Processing BaseItems batch {BatchNumber} ({ProcessedSoFar}/{TotalRecords}) - Time: {ElapsedTime}",
|
||||
partition + 1,
|
||||
Math.Min((partition + 1) * PageSize, records),
|
||||
records,
|
||||
sw.Elapsed))
|
||||
.PartitionEagerAsync(PageSize, cancellationToken)
|
||||
.WithCancellation(cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
result.DateCreated = ToUniversalTime(result.DateCreated);
|
||||
result.DateLastMediaAdded = ToUniversalTime(result.DateLastMediaAdded);
|
||||
result.DateLastRefreshed = ToUniversalTime(result.DateLastRefreshed);
|
||||
result.DateLastSaved = ToUniversalTime(result.DateLastSaved);
|
||||
result.DateModified = ToUniversalTime(result.DateModified);
|
||||
itemCount++;
|
||||
}
|
||||
|
||||
var saveCount = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("BaseItems: Processed {ItemCount} items, saved {SaveCount} changes in {ElapsedTime}", itemCount, saveCount, sw.Elapsed);
|
||||
}
|
||||
|
||||
private async Task FixChaptersAsync(JellyfinDbContext context, Stopwatch sw, CancellationToken cancellationToken)
|
||||
{
|
||||
int itemCount = 0;
|
||||
|
||||
var baseQuery = context.Chapters;
|
||||
var records = baseQuery.Count();
|
||||
_logger.LogInformation("Fixing dates for {Count} Chapters.", records);
|
||||
|
||||
sw.Start();
|
||||
await foreach (var result in context.Chapters.OrderBy(e => e.ItemId)
|
||||
.WithPartitionProgress(
|
||||
(partition) =>
|
||||
_logger.LogInformation(
|
||||
"Processing Chapter batch {BatchNumber} ({ProcessedSoFar}/{TotalRecords}) - Time: {ElapsedTime}",
|
||||
partition + 1,
|
||||
Math.Min((partition + 1) * PageSize, records),
|
||||
records,
|
||||
sw.Elapsed))
|
||||
.PartitionEagerAsync(PageSize, cancellationToken)
|
||||
.WithCancellation(cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
result.ImageDateModified = ToUniversalTime(result.ImageDateModified, true);
|
||||
itemCount++;
|
||||
}
|
||||
|
||||
var saveCount = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("Chapters: Processed {ItemCount} items, saved {SaveCount} changes in {ElapsedTime}", itemCount, saveCount, sw.Elapsed);
|
||||
}
|
||||
|
||||
private async Task FixBaseItemImageInfos(JellyfinDbContext context, Stopwatch sw, CancellationToken cancellationToken)
|
||||
{
|
||||
int itemCount = 0;
|
||||
|
||||
var baseQuery = context.BaseItemImageInfos;
|
||||
var records = baseQuery.Count();
|
||||
_logger.LogInformation("Fixing dates for {Count} BaseItemImageInfos.", records);
|
||||
|
||||
sw.Start();
|
||||
await foreach (var result in context.BaseItemImageInfos.OrderBy(e => e.Id)
|
||||
.WithPartitionProgress(
|
||||
(partition) =>
|
||||
_logger.LogInformation(
|
||||
"Processing BaseItemImageInfos batch {BatchNumber} ({ProcessedSoFar}/{TotalRecords}) - Time: {ElapsedTime}",
|
||||
partition + 1,
|
||||
Math.Min((partition + 1) * PageSize, records),
|
||||
records,
|
||||
sw.Elapsed))
|
||||
.PartitionEagerAsync(PageSize, cancellationToken)
|
||||
.WithCancellation(cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
result.DateModified = ToUniversalTime(result.DateModified);
|
||||
itemCount++;
|
||||
}
|
||||
|
||||
var saveCount = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("BaseItemImageInfos: Processed {ItemCount} items, saved {SaveCount} changes in {ElapsedTime}", itemCount, saveCount, sw.Elapsed);
|
||||
}
|
||||
|
||||
private DateTime? ToUniversalTime(DateTime? dateTime, bool isUTC = false)
|
||||
{
|
||||
if (dateTime is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (dateTime.Value.Year == 1 && dateTime.Value.Month == 1 && dateTime.Value.Day == 1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (dateTime.Value.Kind == DateTimeKind.Utc || isUTC)
|
||||
{
|
||||
return dateTime.Value;
|
||||
}
|
||||
|
||||
return dateTime.Value.ToUniversalTime();
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ public class MigrateKeyframeData : IDatabaseMigrationRoutine
|
||||
/// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
|
||||
/// <param name="dbProvider">The EFCore db factory.</param>
|
||||
public MigrateKeyframeData(
|
||||
IStartupLogger startupLogger,
|
||||
IStartupLogger<MigrateKeyframeData> startupLogger,
|
||||
IApplicationPaths appPaths,
|
||||
IDbContextFactory<JellyfinDbContext> dbProvider)
|
||||
{
|
||||
|
||||
@@ -48,7 +48,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
||||
/// <param name="paths">The server application paths.</param>
|
||||
/// <param name="jellyfinDatabaseProvider">The database provider for special access.</param>
|
||||
public MigrateLibraryDb(
|
||||
IStartupLogger startupLogger,
|
||||
IStartupLogger<MigrateLibraryDb> startupLogger,
|
||||
IDbContextFactory<JellyfinDbContext> provider,
|
||||
IServerApplicationPaths paths,
|
||||
IJellyfinDatabaseProvider jellyfinDatabaseProvider)
|
||||
@@ -94,7 +94,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
||||
connection.Open();
|
||||
|
||||
var baseItemIds = new HashSet<Guid>();
|
||||
using (var operation = GetPreparedDbContext("moving TypedBaseItem"))
|
||||
using (var operation = GetPreparedDbContext("Moving TypedBaseItem"))
|
||||
{
|
||||
const string typedBaseItemsQuery =
|
||||
"""
|
||||
@@ -121,13 +121,13 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
||||
}
|
||||
}
|
||||
|
||||
using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.BaseItems.Local.Count} BaseItem entries", _logger))
|
||||
using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.BaseItems.Local.Count} BaseItem entries", _logger))
|
||||
{
|
||||
operation.JellyfinDbContext.SaveChanges();
|
||||
}
|
||||
}
|
||||
|
||||
using (var operation = GetPreparedDbContext("moving ItemValues"))
|
||||
using (var operation = GetPreparedDbContext("Moving ItemValues"))
|
||||
{
|
||||
// do not migrate inherited types as they are now properly mapped in search and lookup.
|
||||
const string itemValueQuery =
|
||||
@@ -138,7 +138,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
||||
|
||||
// EFCores local lookup sucks. We cannot use context.ItemValues.Local here because its just super slow.
|
||||
var localItems = new Dictionary<(int Type, string Value), (Database.Implementations.Entities.ItemValue ItemValue, List<Guid> ItemIds)>();
|
||||
using (new TrackedMigrationStep("loading ItemValues", _logger))
|
||||
using (new TrackedMigrationStep("Loading ItemValues", _logger))
|
||||
{
|
||||
foreach (SqliteDataReader dto in connection.Query(itemValueQuery))
|
||||
{
|
||||
@@ -166,13 +166,13 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
||||
}
|
||||
}
|
||||
|
||||
using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.ItemValues.Local.Count} ItemValues entries", _logger))
|
||||
using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.ItemValues.Local.Count} ItemValues entries", _logger))
|
||||
{
|
||||
operation.JellyfinDbContext.SaveChanges();
|
||||
}
|
||||
}
|
||||
|
||||
using (var operation = GetPreparedDbContext("moving UserData"))
|
||||
using (var operation = GetPreparedDbContext("Moving UserData"))
|
||||
{
|
||||
var queryResult = connection.Query(
|
||||
"""
|
||||
@@ -181,14 +181,14 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
||||
WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.UserDataKey = UserDatas.key)
|
||||
""");
|
||||
|
||||
using (new TrackedMigrationStep("loading UserData", _logger))
|
||||
using (new TrackedMigrationStep("Loading UserData", _logger))
|
||||
{
|
||||
var users = operation.JellyfinDbContext.Users.AsNoTracking().ToImmutableArray();
|
||||
var users = operation.JellyfinDbContext.Users.AsNoTracking().ToArray();
|
||||
var userIdBlacklist = new HashSet<int>();
|
||||
|
||||
foreach (var entity in queryResult)
|
||||
{
|
||||
var userData = GetUserData(users, entity, userIdBlacklist);
|
||||
var userData = GetUserData(users, entity, userIdBlacklist, _logger);
|
||||
if (userData is null)
|
||||
{
|
||||
var userDataId = entity.GetString(0);
|
||||
@@ -212,19 +212,17 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
||||
userData.ItemId = refItem.Id;
|
||||
operation.JellyfinDbContext.UserData.Add(userData);
|
||||
}
|
||||
|
||||
users.Clear();
|
||||
}
|
||||
|
||||
legacyBaseItemWithUserKeys.Clear();
|
||||
|
||||
using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.UserData.Local.Count} UserData entries", _logger))
|
||||
using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.UserData.Local.Count} UserData entries", _logger))
|
||||
{
|
||||
operation.JellyfinDbContext.SaveChanges();
|
||||
}
|
||||
}
|
||||
|
||||
using (var operation = GetPreparedDbContext("moving MediaStreamInfos"))
|
||||
using (var operation = GetPreparedDbContext("Moving MediaStreamInfos"))
|
||||
{
|
||||
const string mediaStreamQuery =
|
||||
"""
|
||||
@@ -237,7 +235,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
||||
WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = MediaStreams.ItemId)
|
||||
""";
|
||||
|
||||
using (new TrackedMigrationStep("loading MediaStreamInfos", _logger))
|
||||
using (new TrackedMigrationStep("Loading MediaStreamInfos", _logger))
|
||||
{
|
||||
foreach (SqliteDataReader dto in connection.Query(mediaStreamQuery))
|
||||
{
|
||||
@@ -245,13 +243,13 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
||||
}
|
||||
}
|
||||
|
||||
using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.MediaStreamInfos.Local.Count} MediaStreamInfos entries", _logger))
|
||||
using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.MediaStreamInfos.Local.Count} MediaStreamInfos entries", _logger))
|
||||
{
|
||||
operation.JellyfinDbContext.SaveChanges();
|
||||
}
|
||||
}
|
||||
|
||||
using (var operation = GetPreparedDbContext("moving AttachmentStreamInfos"))
|
||||
using (var operation = GetPreparedDbContext("Moving AttachmentStreamInfos"))
|
||||
{
|
||||
const string mediaAttachmentQuery =
|
||||
"""
|
||||
@@ -260,7 +258,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
||||
WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = mediaattachments.ItemId)
|
||||
""";
|
||||
|
||||
using (new TrackedMigrationStep("loading AttachmentStreamInfos", _logger))
|
||||
using (new TrackedMigrationStep("Loading AttachmentStreamInfos", _logger))
|
||||
{
|
||||
foreach (SqliteDataReader dto in connection.Query(mediaAttachmentQuery))
|
||||
{
|
||||
@@ -268,13 +266,13 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
||||
}
|
||||
}
|
||||
|
||||
using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.AttachmentStreamInfos.Local.Count} AttachmentStreamInfos entries", _logger))
|
||||
using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.AttachmentStreamInfos.Local.Count} AttachmentStreamInfos entries", _logger))
|
||||
{
|
||||
operation.JellyfinDbContext.SaveChanges();
|
||||
}
|
||||
}
|
||||
|
||||
using (var operation = GetPreparedDbContext("moving People"))
|
||||
using (var operation = GetPreparedDbContext("Moving People"))
|
||||
{
|
||||
const string personsQuery =
|
||||
"""
|
||||
@@ -284,14 +282,14 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
||||
|
||||
var peopleCache = new Dictionary<string, (People Person, List<PeopleBaseItemMap> Items)>();
|
||||
|
||||
using (new TrackedMigrationStep("loading People", _logger))
|
||||
using (new TrackedMigrationStep("Loading People", _logger))
|
||||
{
|
||||
foreach (SqliteDataReader reader in connection.Query(personsQuery))
|
||||
{
|
||||
var itemId = reader.GetGuid(0);
|
||||
if (!baseItemIds.Contains(itemId))
|
||||
{
|
||||
_logger.LogError("Dont save person {0} because its not in use by any BaseItem", reader.GetString(1));
|
||||
_logger.LogError("Not saving person {0} because it's not in use by any BaseItem", reader.GetString(1));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -330,13 +328,13 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
||||
peopleCache.Clear();
|
||||
}
|
||||
|
||||
using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.Peoples.Local.Count} People entries and {operation.JellyfinDbContext.PeopleBaseItemMap.Local.Count} maps", _logger))
|
||||
using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.Peoples.Local.Count} People entries and {operation.JellyfinDbContext.PeopleBaseItemMap.Local.Count} maps", _logger))
|
||||
{
|
||||
operation.JellyfinDbContext.SaveChanges();
|
||||
}
|
||||
}
|
||||
|
||||
using (var operation = GetPreparedDbContext("moving Chapters"))
|
||||
using (var operation = GetPreparedDbContext("Moving Chapters"))
|
||||
{
|
||||
const string chapterQuery =
|
||||
"""
|
||||
@@ -344,7 +342,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
||||
WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = Chapters2.ItemId)
|
||||
""";
|
||||
|
||||
using (new TrackedMigrationStep("loading Chapters", _logger))
|
||||
using (new TrackedMigrationStep("Loading Chapters", _logger))
|
||||
{
|
||||
foreach (SqliteDataReader dto in connection.Query(chapterQuery))
|
||||
{
|
||||
@@ -353,13 +351,13 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
||||
}
|
||||
}
|
||||
|
||||
using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.Chapters.Local.Count} Chapters entries", _logger))
|
||||
using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.Chapters.Local.Count} Chapters entries", _logger))
|
||||
{
|
||||
operation.JellyfinDbContext.SaveChanges();
|
||||
}
|
||||
}
|
||||
|
||||
using (var operation = GetPreparedDbContext("moving AncestorIds"))
|
||||
using (var operation = GetPreparedDbContext("Moving AncestorIds"))
|
||||
{
|
||||
const string ancestorIdsQuery =
|
||||
"""
|
||||
@@ -370,7 +368,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
||||
EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = AncestorIds.AncestorId)
|
||||
""";
|
||||
|
||||
using (new TrackedMigrationStep("loading AncestorIds", _logger))
|
||||
using (new TrackedMigrationStep("Loading AncestorIds", _logger))
|
||||
{
|
||||
foreach (SqliteDataReader dto in connection.Query(ancestorIdsQuery))
|
||||
{
|
||||
@@ -379,7 +377,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
||||
}
|
||||
}
|
||||
|
||||
using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.AncestorIds.Local.Count} AncestorId entries", _logger))
|
||||
using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.AncestorIds.Local.Count} AncestorId entries", _logger))
|
||||
{
|
||||
operation.JellyfinDbContext.SaveChanges();
|
||||
}
|
||||
@@ -404,19 +402,20 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
||||
return new DatabaseMigrationStep(dbContext, operationName, _logger);
|
||||
}
|
||||
|
||||
private UserData? GetUserData(ImmutableArray<User> users, SqliteDataReader dto, HashSet<int> userIdBlacklist)
|
||||
internal static UserData? GetUserData(User[] users, SqliteDataReader dto, HashSet<int> userIdBlacklist, ILogger logger)
|
||||
{
|
||||
var internalUserId = dto.GetInt32(1);
|
||||
var user = users.FirstOrDefault(e => e.InternalId == internalUserId);
|
||||
if (userIdBlacklist.Contains(internalUserId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var user = users.FirstOrDefault(e => e.InternalId == internalUserId);
|
||||
if (user is null)
|
||||
{
|
||||
if (userIdBlacklist.Contains(internalUserId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
userIdBlacklist.Add(internalUserId);
|
||||
|
||||
_logger.LogError("Tried to find user with index '{Idx}' but there are only '{MaxIdx}' users.", internalUserId, users.Length);
|
||||
logger.LogError("Tried to find user with index '{Idx}' but there are only '{MaxIdx}' users.", internalUserId, users.Length);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1168,7 +1167,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
||||
entity.UnratedType = unratedType;
|
||||
}
|
||||
|
||||
var baseItem = BaseItemRepository.DeserialiseBaseItem(entity, _logger, null, false);
|
||||
var baseItem = BaseItemRepository.DeserializeBaseItem(entity, _logger, null, false);
|
||||
var dataKeys = baseItem.GetUserDataKeys();
|
||||
userDataKeys.AddRange(dataKeys);
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ public class MigrateLibraryDbCompatibilityCheck : IAsyncMigrationRoutine
|
||||
/// </summary>
|
||||
/// <param name="startupLogger">The startup logger.</param>
|
||||
/// <param name="paths">The Path service.</param>
|
||||
public MigrateLibraryDbCompatibilityCheck(IStartupLogger startupLogger, IServerApplicationPaths paths)
|
||||
public MigrateLibraryDbCompatibilityCheck(IStartupLogger<MigrateLibraryDbCompatibilityCheck> startupLogger, IServerApplicationPaths paths)
|
||||
{
|
||||
_logger = startupLogger;
|
||||
_paths = paths;
|
||||
|
||||
107
Jellyfin.Server/Migrations/Routines/MigrateLibraryUserData.cs
Normal file
107
Jellyfin.Server/Migrations/Routines/MigrateLibraryUserData.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
#pragma warning disable RS0030 // Do not use banned APIs
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Emby.Server.Implementations.Data;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Server.Implementations.Item;
|
||||
using Jellyfin.Server.ServerSetupApp;
|
||||
using MediaBrowser.Controller;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Server.Migrations.Routines;
|
||||
|
||||
[JellyfinMigration("2025-06-18T01:00:00", nameof(MigrateLibraryUserData))]
|
||||
[JellyfinMigrationBackup(JellyfinDb = true)]
|
||||
internal class MigrateLibraryUserData : IAsyncMigrationRoutine
|
||||
{
|
||||
private const string DbFilename = "library.db.old";
|
||||
|
||||
private readonly IStartupLogger _logger;
|
||||
private readonly IServerApplicationPaths _paths;
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _provider;
|
||||
|
||||
public MigrateLibraryUserData(
|
||||
IStartupLogger<MigrateLibraryDb> startupLogger,
|
||||
IDbContextFactory<JellyfinDbContext> provider,
|
||||
IServerApplicationPaths paths)
|
||||
{
|
||||
_logger = startupLogger;
|
||||
_provider = provider;
|
||||
_paths = paths;
|
||||
}
|
||||
|
||||
public async Task PerformAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Migrating the userdata from library.db.old may take a while, do not stop Jellyfin.");
|
||||
|
||||
var dataPath = _paths.DataPath;
|
||||
var libraryDbPath = Path.Combine(dataPath, DbFilename);
|
||||
if (!File.Exists(libraryDbPath))
|
||||
{
|
||||
_logger.LogError("Cannot migrate userdata from {LibraryDb} as it does not exist. This migration expects the MigrateLibraryDb to run first.", libraryDbPath);
|
||||
return;
|
||||
}
|
||||
|
||||
var dbContext = await _provider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
if (!await dbContext.BaseItems.AnyAsync(e => e.Id == BaseItemRepository.PlaceholderId, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
// the placeholder baseitem has been deleted by the librarydb migration so we need to readd it.
|
||||
await dbContext.BaseItems.AddAsync(
|
||||
new Database.Implementations.Entities.BaseItemEntity()
|
||||
{
|
||||
Id = BaseItemRepository.PlaceholderId,
|
||||
Type = "PLACEHOLDER",
|
||||
Name = "This is a placeholder item for UserData that has been detacted from its original item"
|
||||
},
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var users = dbContext.Users.AsNoTracking().ToArray();
|
||||
var userIdBlacklist = new HashSet<int>();
|
||||
using var connection = new SqliteConnection($"Filename={libraryDbPath};Mode=ReadOnly");
|
||||
var retentionDate = DateTime.UtcNow;
|
||||
|
||||
var queryResult = connection.Query(
|
||||
"""
|
||||
SELECT key, userId, rating, played, playCount, isFavorite, playbackPositionTicks, lastPlayedDate, AudioStreamIndex, SubtitleStreamIndex FROM UserDatas
|
||||
|
||||
WHERE NOT EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.UserDataKey = UserDatas.key)
|
||||
""");
|
||||
foreach (var entity in queryResult)
|
||||
{
|
||||
var userData = MigrateLibraryDb.GetUserData(users, entity, userIdBlacklist, _logger);
|
||||
if (userData is null)
|
||||
{
|
||||
var userDataId = entity.GetString(0);
|
||||
var internalUserId = entity.GetInt32(1);
|
||||
|
||||
if (!userIdBlacklist.Contains(internalUserId))
|
||||
{
|
||||
_logger.LogError("Was not able to migrate user data with key {0} because its id {InternalId} does not match any existing user.", userDataId, internalUserId);
|
||||
userIdBlacklist.Add(internalUserId);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
userData.ItemId = BaseItemRepository.PlaceholderId;
|
||||
userData.RetentionDate = retentionDate;
|
||||
dbContext.UserData.Add(userData);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Try saving {NewSaved} UserData entries.", dbContext.UserData.Local.Count);
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ internal class MigrateRatingLevels : IDatabaseMigrationRoutine
|
||||
|
||||
public MigrateRatingLevels(
|
||||
IDbContextFactory<JellyfinDbContext> provider,
|
||||
IStartupLogger logger,
|
||||
IStartupLogger<MigrateRatingLevels> logger,
|
||||
ILocalizationManager localizationManager)
|
||||
{
|
||||
_provider = provider;
|
||||
|
||||
@@ -47,7 +47,7 @@ public class MoveExtractedFiles : IAsyncMigrationRoutine
|
||||
public MoveExtractedFiles(
|
||||
IApplicationPaths appPaths,
|
||||
ILogger<MoveExtractedFiles> logger,
|
||||
IStartupLogger startupLogger,
|
||||
IStartupLogger<MoveExtractedFiles> startupLogger,
|
||||
IPathManager pathManager,
|
||||
IFileSystem fileSystem,
|
||||
IDbContextFactory<JellyfinDbContext> dbProvider)
|
||||
|
||||
@@ -37,7 +37,7 @@ public class MoveTrickplayFiles : IMigrationRoutine
|
||||
ITrickplayManager trickplayManager,
|
||||
IFileSystem fileSystem,
|
||||
ILibraryManager libraryManager,
|
||||
IStartupLogger logger)
|
||||
IStartupLogger<MoveTrickplayFiles> logger)
|
||||
{
|
||||
_trickplayManager = trickplayManager;
|
||||
_fileSystem = fileSystem;
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Threading.Tasks;
|
||||
using Jellyfin.Server.ServerSetupApp;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Server.Migrations.Stages;
|
||||
|
||||
@@ -21,11 +22,13 @@ internal class CodeMigration(Type migrationType, JellyfinMigrationAttribute meta
|
||||
return Metadata.Order.ToString("yyyyMMddHHmmsss", CultureInfo.InvariantCulture) + "_" + Metadata.Name!;
|
||||
}
|
||||
|
||||
private ServiceCollection MigrationServices(IServiceProvider serviceProvider, IStartupLogger logger)
|
||||
private IServiceCollection MigrationServices(IServiceProvider serviceProvider, IStartupLogger logger)
|
||||
{
|
||||
var childServiceCollection = new ServiceCollection();
|
||||
childServiceCollection.AddSingleton(serviceProvider);
|
||||
childServiceCollection.AddSingleton(logger);
|
||||
var childServiceCollection = new ServiceCollection()
|
||||
.AddSingleton(serviceProvider)
|
||||
.AddSingleton(logger)
|
||||
.AddSingleton(typeof(IStartupLogger<>), typeof(NestedStartupLogger<>))
|
||||
.AddSingleton<StartupLogTopic>(logger.Topic!);
|
||||
|
||||
foreach (ServiceDescriptor service in serviceProvider.GetRequiredService<IServiceCollection>())
|
||||
{
|
||||
@@ -78,4 +81,11 @@ internal class CodeMigration(Type migrationType, JellyfinMigrationAttribute meta
|
||||
throw new InvalidOperationException($"The type {MigrationType} does not implement either IMigrationRoutine or IAsyncMigrationRoutine and is not a valid migration type");
|
||||
}
|
||||
}
|
||||
|
||||
private class NestedStartupLogger<TCategory> : StartupLogger<TCategory>
|
||||
{
|
||||
public NestedStartupLogger(ILogger logger, StartupLogTopic topic) : base(logger, topic)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ namespace Jellyfin.Server
|
||||
private static long _startTimestamp;
|
||||
private static ILogger _logger = NullLogger.Instance;
|
||||
private static bool _restartOnShutdown;
|
||||
private static IStartupLogger? _migrationLogger;
|
||||
private static IStartupLogger<JellyfinMigrationService>? _migrationLogger;
|
||||
private static string? _restoreFromBackup;
|
||||
|
||||
/// <summary>
|
||||
@@ -103,6 +103,7 @@ namespace Jellyfin.Server
|
||||
_setupServer = new SetupServer(static () => _jellyfinHost?.Services?.GetService<INetworkManager>(), appPaths, static () => _appHost, _loggerFactory, startupConfig);
|
||||
await _setupServer.RunAsync().ConfigureAwait(false);
|
||||
_logger = _loggerFactory.CreateLogger("Main");
|
||||
StartupLogger.Logger = new StartupLogger(_logger);
|
||||
|
||||
// Use the logging framework for uncaught exceptions instead of std error
|
||||
AppDomain.CurrentDomain.UnhandledException += (_, e)
|
||||
@@ -178,7 +179,9 @@ namespace Jellyfin.Server
|
||||
})
|
||||
.ConfigureAppConfiguration(config => config.ConfigureAppConfiguration(options, appPaths, startupConfig))
|
||||
.UseSerilog()
|
||||
.ConfigureServices(e => e.AddTransient<IStartupLogger, StartupLogger>().AddSingleton<IServiceCollection>(e))
|
||||
.ConfigureServices(e => e
|
||||
.RegisterStartupLogger()
|
||||
.AddSingleton<IServiceCollection>(e))
|
||||
.Build();
|
||||
|
||||
// Re-use the host service provider in the app host since ASP.NET doesn't allow a custom service collection.
|
||||
@@ -268,7 +271,7 @@ namespace Jellyfin.Server
|
||||
/// <returns>A task.</returns>
|
||||
public static async Task ApplyStartupMigrationAsync(ServerApplicationPaths appPaths, IConfiguration startupConfig)
|
||||
{
|
||||
_migrationLogger = StartupLogger.Logger.BeginGroup($"Migration Service");
|
||||
_migrationLogger = StartupLogger.Logger.BeginGroup<JellyfinMigrationService>($"Migration Service");
|
||||
var startupConfigurationManager = new ServerConfigurationManager(appPaths, _loggerFactory, new MyXmlSerializer());
|
||||
startupConfigurationManager.AddParts([new DatabaseConfigurationFactory()]);
|
||||
var migrationStartupServiceProvider = new ServiceCollection()
|
||||
@@ -276,7 +279,7 @@ namespace Jellyfin.Server
|
||||
.AddJellyfinDbContext(startupConfigurationManager, startupConfig)
|
||||
.AddSingleton<IApplicationPaths>(appPaths)
|
||||
.AddSingleton<ServerApplicationPaths>(appPaths)
|
||||
.AddSingleton<IStartupLogger>(_migrationLogger);
|
||||
.RegisterStartupLogger();
|
||||
|
||||
migrationStartupServiceProvider.AddSingleton(migrationStartupServiceProvider);
|
||||
var startupService = migrationStartupServiceProvider.BuildServiceProvider();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Morestachio.Helper.Logging;
|
||||
using ILogger = Microsoft.Extensions.Logging.ILogger;
|
||||
|
||||
namespace Jellyfin.Server.ServerSetupApp;
|
||||
@@ -9,6 +8,11 @@ namespace Jellyfin.Server.ServerSetupApp;
|
||||
/// </summary>
|
||||
public interface IStartupLogger : ILogger
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the topic this logger is assigned to.
|
||||
/// </summary>
|
||||
StartupLogTopic? Topic { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Adds another logger instance to this logger for combined logging.
|
||||
/// </summary>
|
||||
@@ -22,4 +26,41 @@ public interface IStartupLogger : ILogger
|
||||
/// <param name="logEntry">Defines the log message that introduces the new group.</param>
|
||||
/// <returns>A new logger that can write to the group.</returns>
|
||||
IStartupLogger BeginGroup(FormattableString logEntry);
|
||||
|
||||
/// <summary>
|
||||
/// Adds another logger instance to this logger for combined logging.
|
||||
/// </summary>
|
||||
/// <param name="logger">Other logger to rely messages to.</param>
|
||||
/// <returns>A combined logger.</returns>
|
||||
/// <typeparam name="TCategory">The logger cateogry.</typeparam>
|
||||
IStartupLogger<TCategory> With<TCategory>(ILogger logger);
|
||||
|
||||
/// <summary>
|
||||
/// Opens a new Group logger within the parent logger.
|
||||
/// </summary>
|
||||
/// <param name="logEntry">Defines the log message that introduces the new group.</param>
|
||||
/// <returns>A new logger that can write to the group.</returns>
|
||||
/// <typeparam name="TCategory">The logger cateogry.</typeparam>
|
||||
IStartupLogger<TCategory> BeginGroup<TCategory>(FormattableString logEntry);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines a logger that can be injected via DI to get a startup logger initialised with an logger framework connected <see cref="ILogger"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TCategory">The logger cateogry.</typeparam>
|
||||
public interface IStartupLogger<TCategory> : IStartupLogger
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds another logger instance to this logger for combined logging.
|
||||
/// </summary>
|
||||
/// <param name="logger">Other logger to rely messages to.</param>
|
||||
/// <returns>A combined logger.</returns>
|
||||
new IStartupLogger<TCategory> With(ILogger logger);
|
||||
|
||||
/// <summary>
|
||||
/// Opens a new Group logger within the parent logger.
|
||||
/// </summary>
|
||||
/// <param name="logEntry">Defines the log message that introduces the new group.</param>
|
||||
/// <returns>A new logger that can write to the group.</returns>
|
||||
new IStartupLogger<TCategory> BeginGroup(FormattableString logEntry);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ using System.Threading.Tasks;
|
||||
using Emby.Server.Implementations.Configuration;
|
||||
using Emby.Server.Implementations.Serialization;
|
||||
using Jellyfin.Networking.Manager;
|
||||
using Jellyfin.Server.Extensions;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller;
|
||||
@@ -27,6 +28,8 @@ using Microsoft.Extensions.Primitives;
|
||||
using Morestachio;
|
||||
using Morestachio.Framework.IO.SingleStream;
|
||||
using Morestachio.Rendering;
|
||||
using Serilog;
|
||||
using ILogger = Microsoft.Extensions.Logging.ILogger;
|
||||
|
||||
namespace Jellyfin.Server.ServerSetupApp;
|
||||
|
||||
@@ -71,7 +74,7 @@ public sealed class SetupServer : IDisposable
|
||||
_configurationManager.RegisterConfiguration<NetworkConfigurationFactory>();
|
||||
}
|
||||
|
||||
internal static ConcurrentQueue<StartupLogEntry>? LogQueue { get; set; } = new();
|
||||
internal static ConcurrentQueue<StartupLogTopic>? LogQueue { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether Startup server is currently running.
|
||||
@@ -88,12 +91,12 @@ public sealed class SetupServer : IDisposable
|
||||
_startupUiRenderer = (await ParserOptionsBuilder.New()
|
||||
.WithTemplate(fileTemplate)
|
||||
.WithFormatter(
|
||||
(StartupLogEntry logEntry, IEnumerable<StartupLogEntry> children) =>
|
||||
(StartupLogTopic logEntry, IEnumerable<StartupLogTopic> children) =>
|
||||
{
|
||||
if (children.Any())
|
||||
{
|
||||
var maxLevel = logEntry.LogLevel;
|
||||
var stack = new Stack<StartupLogEntry>(children);
|
||||
var stack = new Stack<StartupLogTopic>(children);
|
||||
|
||||
while (maxLevel != LogLevel.Error && stack.Count > 0 && (logEntry = stack.Pop()) != null) // error is the highest inherted error level.
|
||||
{
|
||||
@@ -138,19 +141,25 @@ public sealed class SetupServer : IDisposable
|
||||
|
||||
ThrowIfDisposed();
|
||||
var retryAfterValue = TimeSpan.FromSeconds(5);
|
||||
_startupServer = Host.CreateDefaultBuilder()
|
||||
var config = _configurationManager.GetNetworkConfiguration()!;
|
||||
_startupServer = Host.CreateDefaultBuilder(["hostBuilder:reloadConfigOnChange=false"])
|
||||
.UseConsoleLifetime()
|
||||
.UseSerilog()
|
||||
.ConfigureServices(serv =>
|
||||
{
|
||||
serv.AddSingleton(this);
|
||||
serv.AddHealthChecks()
|
||||
.AddCheck<SetupHealthcheck>("StartupCheck");
|
||||
serv.Configure<ForwardedHeadersOptions>(options =>
|
||||
{
|
||||
ApiServiceCollectionExtensions.ConfigureForwardHeaders(config, options);
|
||||
});
|
||||
})
|
||||
.ConfigureWebHostDefaults(webHostBuilder =>
|
||||
{
|
||||
webHostBuilder
|
||||
.UseKestrel((builderContext, options) =>
|
||||
{
|
||||
var config = _configurationManager.GetNetworkConfiguration()!;
|
||||
var knownBindInterfaces = NetworkManager.GetInterfacesCore(_loggerFactory.CreateLogger<SetupServer>(), config.EnableIPv4, config.EnableIPv6);
|
||||
knownBindInterfaces = NetworkManager.FilterBindSettings(config, knownBindInterfaces.ToList(), config.EnableIPv4, config.EnableIPv6);
|
||||
var bindInterfaces = NetworkManager.GetAllBindInterfaces(false, _configurationManager, knownBindInterfaces, config.EnableIPv4, config.EnableIPv6);
|
||||
@@ -168,7 +177,7 @@ public sealed class SetupServer : IDisposable
|
||||
.Configure(app =>
|
||||
{
|
||||
app.UseHealthChecks("/health");
|
||||
|
||||
app.UseForwardedHeaders();
|
||||
app.Map("/startup/logger", loggerRoute =>
|
||||
{
|
||||
loggerRoute.Run(async context =>
|
||||
@@ -362,15 +371,4 @@ public sealed class SetupServer : IDisposable
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
internal class StartupLogEntry
|
||||
{
|
||||
public LogLevel LogLevel { get; set; }
|
||||
|
||||
public string? Content { get; set; }
|
||||
|
||||
public DateTimeOffset DateOfCreation { get; set; }
|
||||
|
||||
public List<StartupLogEntry> Children { get; set; } = [];
|
||||
}
|
||||
}
|
||||
|
||||
31
Jellyfin.Server/ServerSetupApp/StartupLogTopic.cs
Normal file
31
Jellyfin.Server/ServerSetupApp/StartupLogTopic.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Server.ServerSetupApp;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a topic for the Startup UI.
|
||||
/// </summary>
|
||||
public class StartupLogTopic
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or Sets the LogLevel.
|
||||
/// </summary>
|
||||
public LogLevel LogLevel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or Sets the descriptor for the topic.
|
||||
/// </summary>
|
||||
public string? Content { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the time the topic was created.
|
||||
/// </summary>
|
||||
public DateTimeOffset DateOfCreation { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the child items of this topic.
|
||||
/// </summary>
|
||||
public Collection<StartupLogTopic> Children { get; } = [];
|
||||
}
|
||||
@@ -1,56 +1,86 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Jellyfin.Server.Migrations.Routines;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Jellyfin.Server.ServerSetupApp;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public class StartupLogger : IStartupLogger
|
||||
{
|
||||
private readonly SetupServer.StartupLogEntry? _groupEntry;
|
||||
private readonly StartupLogTopic? _topic;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="StartupLogger"/> class.
|
||||
/// </summary>
|
||||
public StartupLogger()
|
||||
/// <param name="logger">The underlying base logger.</param>
|
||||
public StartupLogger(ILogger logger)
|
||||
{
|
||||
Loggers = [];
|
||||
BaseLogger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="StartupLogger"/> class.
|
||||
/// </summary>
|
||||
private StartupLogger(SetupServer.StartupLogEntry? groupEntry) : this()
|
||||
/// <param name="logger">The underlying base logger.</param>
|
||||
/// <param name="topic">The group for this logger.</param>
|
||||
internal StartupLogger(ILogger logger, StartupLogTopic? topic) : this(logger)
|
||||
{
|
||||
_groupEntry = groupEntry;
|
||||
_topic = topic;
|
||||
}
|
||||
|
||||
internal static IStartupLogger Logger { get; } = new StartupLogger();
|
||||
internal static IStartupLogger Logger { get; set; } = new StartupLogger(NullLogger.Instance);
|
||||
|
||||
private List<ILogger> Loggers { get; set; }
|
||||
/// <inheritdoc/>
|
||||
public StartupLogTopic? Topic => _topic;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or Sets the underlying base logger.
|
||||
/// </summary>
|
||||
protected ILogger BaseLogger { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IStartupLogger BeginGroup(FormattableString logEntry)
|
||||
{
|
||||
var startupEntry = new SetupServer.StartupLogEntry()
|
||||
return new StartupLogger(BaseLogger, AddToTopic(logEntry));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IStartupLogger With(ILogger logger)
|
||||
{
|
||||
return new StartupLogger(logger, Topic);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IStartupLogger<TCategory> With<TCategory>(ILogger logger)
|
||||
{
|
||||
return new StartupLogger<TCategory>(logger, Topic);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IStartupLogger<TCategory> BeginGroup<TCategory>(FormattableString logEntry)
|
||||
{
|
||||
return new StartupLogger<TCategory>(BaseLogger, AddToTopic(logEntry));
|
||||
}
|
||||
|
||||
private StartupLogTopic AddToTopic(FormattableString logEntry)
|
||||
{
|
||||
var startupEntry = new StartupLogTopic()
|
||||
{
|
||||
Content = logEntry.ToString(CultureInfo.InvariantCulture),
|
||||
DateOfCreation = DateTimeOffset.Now
|
||||
};
|
||||
|
||||
if (_groupEntry is null)
|
||||
if (Topic is null)
|
||||
{
|
||||
SetupServer.LogQueue?.Enqueue(startupEntry);
|
||||
}
|
||||
else
|
||||
{
|
||||
_groupEntry.Children.Add(startupEntry);
|
||||
Topic.Children.Add(startupEntry);
|
||||
}
|
||||
|
||||
return new StartupLogger(startupEntry);
|
||||
return startupEntry;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -69,34 +99,26 @@ public class StartupLogger : IStartupLogger
|
||||
/// <inheritdoc/>
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
foreach (var item in Loggers.Where(e => e.IsEnabled(logLevel)))
|
||||
if (BaseLogger.IsEnabled(logLevel))
|
||||
{
|
||||
item.Log(logLevel, eventId, state, exception, formatter);
|
||||
// if enabled allow the base logger also to receive the message
|
||||
BaseLogger.Log(logLevel, eventId, state, exception, formatter);
|
||||
}
|
||||
|
||||
var startupEntry = new SetupServer.StartupLogEntry()
|
||||
var startupEntry = new StartupLogTopic()
|
||||
{
|
||||
LogLevel = logLevel,
|
||||
Content = formatter(state, exception),
|
||||
DateOfCreation = DateTimeOffset.Now
|
||||
};
|
||||
|
||||
if (_groupEntry is null)
|
||||
if (Topic is null)
|
||||
{
|
||||
SetupServer.LogQueue?.Enqueue(startupEntry);
|
||||
}
|
||||
else
|
||||
{
|
||||
_groupEntry.Children.Add(startupEntry);
|
||||
Topic.Children.Add(startupEntry);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IStartupLogger With(ILogger logger)
|
||||
{
|
||||
return new StartupLogger(_groupEntry)
|
||||
{
|
||||
Loggers = [.. Loggers, logger]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
18
Jellyfin.Server/ServerSetupApp/StartupLoggerExtensions.cs
Normal file
18
Jellyfin.Server/ServerSetupApp/StartupLoggerExtensions.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Jellyfin.Server.ServerSetupApp;
|
||||
|
||||
internal static class StartupLoggerExtensions
|
||||
{
|
||||
public static IServiceCollection RegisterStartupLogger(this IServiceCollection services)
|
||||
{
|
||||
return services
|
||||
.AddTransient<IStartupLogger, StartupLogger<Startup>>()
|
||||
.AddTransient(typeof(IStartupLogger<>), typeof(StartupLogger<>));
|
||||
}
|
||||
}
|
||||
56
Jellyfin.Server/ServerSetupApp/StartupLoggerOfCategory.cs
Normal file
56
Jellyfin.Server/ServerSetupApp/StartupLoggerOfCategory.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Server.ServerSetupApp;
|
||||
|
||||
/// <summary>
|
||||
/// Startup logger for usage with DI that utilises an underlying logger from the DI.
|
||||
/// </summary>
|
||||
/// <typeparam name="TCategory">The category of the underlying logger.</typeparam>
|
||||
#pragma warning disable SA1649 // File name should match first type name
|
||||
public class StartupLogger<TCategory> : StartupLogger, IStartupLogger<TCategory>
|
||||
#pragma warning restore SA1649 // File name should match first type name
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="StartupLogger{TCategory}"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The injected base logger.</param>
|
||||
public StartupLogger(ILogger<TCategory> logger) : base(logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="StartupLogger{TCategory}"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The underlying base logger.</param>
|
||||
/// <param name="groupEntry">The group for this logger.</param>
|
||||
internal StartupLogger(ILogger logger, StartupLogTopic? groupEntry) : base(logger, groupEntry)
|
||||
{
|
||||
}
|
||||
|
||||
IStartupLogger<TCategory> IStartupLogger<TCategory>.BeginGroup(FormattableString logEntry)
|
||||
{
|
||||
var startupEntry = new StartupLogTopic()
|
||||
{
|
||||
Content = logEntry.ToString(CultureInfo.InvariantCulture),
|
||||
DateOfCreation = DateTimeOffset.Now
|
||||
};
|
||||
|
||||
if (Topic is null)
|
||||
{
|
||||
SetupServer.LogQueue?.Enqueue(startupEntry);
|
||||
}
|
||||
else
|
||||
{
|
||||
Topic.Children.Add(startupEntry);
|
||||
}
|
||||
|
||||
return new StartupLogger<TCategory>(BaseLogger, startupEntry);
|
||||
}
|
||||
|
||||
IStartupLogger<TCategory> IStartupLogger<TCategory>.With(ILogger logger)
|
||||
{
|
||||
return new StartupLogger<TCategory>(logger, Topic);
|
||||
}
|
||||
}
|
||||
@@ -204,6 +204,7 @@
|
||||
</li>
|
||||
{{--| /DECLARE}}
|
||||
|
||||
{{#IF localNetworkRequest}}
|
||||
<div class="flex-col">
|
||||
<ol class="action-list">
|
||||
{{#FOREACH log IN logs.Reverse()}}
|
||||
@@ -211,6 +212,10 @@
|
||||
{{/FOREACH}}
|
||||
</ol>
|
||||
</div>
|
||||
{{#ELSE}}
|
||||
<p>Please visit this page from your local network to view detailed startup logs.</p>
|
||||
{{/ELSE}}
|
||||
{{/IF}}
|
||||
</div>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -88,6 +88,9 @@ EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.LiveTv", "src\Jellyfin.LiveTv\Jellyfin.LiveTv.csproj", "{8C6B2B13-58A4-4506-9DAB-1F882A093FE0}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Jellyfin.Database", "Jellyfin.Database", "{4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
src\Jellyfin.Database\readme.md = src\Jellyfin.Database\readme.md
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Providers.Sqlite", "src\Jellyfin.Database\Jellyfin.Database.Providers.Sqlite\Jellyfin.Database.Providers.Sqlite.csproj", "{A5590358-33CC-4B39-BDE7-DC62FEB03C76}"
|
||||
EndProject
|
||||
|
||||
@@ -1423,23 +1423,16 @@ namespace MediaBrowser.Controller.Entities
|
||||
|
||||
public virtual bool RequiresRefresh()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Path) || DateModified == default)
|
||||
if (string.IsNullOrEmpty(Path) || DateModified == DateTime.MinValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var info = FileSystem.GetFileSystemInfo(Path);
|
||||
if (info.Exists)
|
||||
{
|
||||
if (info.IsDirectory)
|
||||
{
|
||||
return info.LastWriteTimeUtc != DateModified;
|
||||
}
|
||||
|
||||
return info.LastWriteTimeUtc != DateModified;
|
||||
}
|
||||
|
||||
return false;
|
||||
return info.Exists
|
||||
? info.LastWriteTimeUtc != DateModified
|
||||
: false;
|
||||
}
|
||||
|
||||
public virtual List<string> GetUserDataKeys()
|
||||
@@ -2002,9 +1995,10 @@ namespace MediaBrowser.Controller.Entities
|
||||
}
|
||||
|
||||
// Remove from file system
|
||||
if (info.IsLocalFile)
|
||||
var path = info.Path;
|
||||
if (info.IsLocalFile && !string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
FileSystem.DeleteFile(info.Path);
|
||||
FileSystem.DeleteFile(path);
|
||||
}
|
||||
|
||||
// Remove from item
|
||||
|
||||
@@ -11,7 +11,6 @@ using System.Security;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading.Tasks.Dataflow;
|
||||
using J2N.Collections.Generic.Extensions;
|
||||
using Jellyfin.Data;
|
||||
using Jellyfin.Data.Enums;
|
||||
@@ -25,6 +24,7 @@ using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities.Audio;
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.LibraryTaskScheduler;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.IO;
|
||||
@@ -49,6 +49,8 @@ namespace MediaBrowser.Controller.Entities
|
||||
|
||||
public static IUserViewManager UserViewManager { get; set; }
|
||||
|
||||
public static ILimitedConcurrencyLibraryScheduler LimitedConcurrencyLibraryScheduler { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether this instance is root.
|
||||
/// </summary>
|
||||
@@ -598,51 +600,13 @@ namespace MediaBrowser.Controller.Entities
|
||||
/// <returns>Task.</returns>
|
||||
private async Task RunTasks<T>(Func<T, IProgress<double>, Task> task, IList<T> children, IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
var childrenCount = children.Count;
|
||||
var childrenProgress = new double[childrenCount];
|
||||
|
||||
void UpdateProgress()
|
||||
{
|
||||
progress.Report(childrenProgress.Average());
|
||||
}
|
||||
|
||||
var fanoutConcurrency = ConfigurationManager.Configuration.LibraryScanFanoutConcurrency;
|
||||
var parallelism = fanoutConcurrency > 0 ? fanoutConcurrency : Environment.ProcessorCount;
|
||||
|
||||
var actionBlock = new ActionBlock<int>(
|
||||
async i =>
|
||||
{
|
||||
var innerProgress = new Progress<double>(innerPercent =>
|
||||
{
|
||||
// round the percent and only update progress if it changed to prevent excessive UpdateProgress calls
|
||||
var innerPercentRounded = Math.Round(innerPercent);
|
||||
if (childrenProgress[i] != innerPercentRounded)
|
||||
{
|
||||
childrenProgress[i] = innerPercentRounded;
|
||||
UpdateProgress();
|
||||
}
|
||||
});
|
||||
|
||||
await task(children[i], innerProgress).ConfigureAwait(false);
|
||||
|
||||
childrenProgress[i] = 100;
|
||||
|
||||
UpdateProgress();
|
||||
},
|
||||
new ExecutionDataflowBlockOptions
|
||||
{
|
||||
MaxDegreeOfParallelism = parallelism,
|
||||
CancellationToken = cancellationToken,
|
||||
});
|
||||
|
||||
for (var i = 0; i < childrenCount; i++)
|
||||
{
|
||||
await actionBlock.SendAsync(i, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
actionBlock.Complete();
|
||||
|
||||
await actionBlock.Completion.ConfigureAwait(false);
|
||||
await LimitedConcurrencyLibraryScheduler
|
||||
.Enqueue(
|
||||
children.ToArray(),
|
||||
task,
|
||||
progress,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -731,7 +695,7 @@ namespace MediaBrowser.Controller.Entities
|
||||
items = GetRecursiveChildren(user, query);
|
||||
}
|
||||
|
||||
return PostFilterAndSort(items, query, true);
|
||||
return PostFilterAndSort(items, query);
|
||||
}
|
||||
|
||||
if (this is not UserRootFolder
|
||||
@@ -995,10 +959,10 @@ namespace MediaBrowser.Controller.Entities
|
||||
items = GetChildren(user, true, childQuery).Where(filter);
|
||||
}
|
||||
|
||||
return PostFilterAndSort(items, query, true);
|
||||
return PostFilterAndSort(items, query);
|
||||
}
|
||||
|
||||
protected QueryResult<BaseItem> PostFilterAndSort(IEnumerable<BaseItem> items, InternalItemsQuery query, bool enableSorting)
|
||||
protected QueryResult<BaseItem> PostFilterAndSort(IEnumerable<BaseItem> items, InternalItemsQuery query)
|
||||
{
|
||||
var user = query.User;
|
||||
|
||||
@@ -1008,7 +972,7 @@ namespace MediaBrowser.Controller.Entities
|
||||
items = CollapseBoxSetItemsIfNeeded(items, query, this, user, ConfigurationManager, CollectionManager);
|
||||
}
|
||||
|
||||
#pragma warning disable CA1309
|
||||
#pragma warning disable CA1309
|
||||
if (!string.IsNullOrEmpty(query.NameStartsWithOrGreater))
|
||||
{
|
||||
items = items.Where(i => string.Compare(query.NameStartsWithOrGreater, i.SortName, StringComparison.InvariantCultureIgnoreCase) < 1);
|
||||
@@ -1023,7 +987,7 @@ namespace MediaBrowser.Controller.Entities
|
||||
{
|
||||
items = items.Where(i => string.Compare(query.NameLessThan, i.SortName, StringComparison.InvariantCultureIgnoreCase) == 1);
|
||||
}
|
||||
#pragma warning restore CA1309
|
||||
#pragma warning restore CA1309
|
||||
|
||||
// This must be the last filter
|
||||
if (!query.AdjacentTo.IsNullOrEmpty())
|
||||
@@ -1031,7 +995,7 @@ namespace MediaBrowser.Controller.Entities
|
||||
items = UserViewBuilder.FilterForAdjacency(items.ToList(), query.AdjacentTo.Value);
|
||||
}
|
||||
|
||||
return UserViewBuilder.SortAndPage(items, null, query, LibraryManager, enableSorting);
|
||||
return UserViewBuilder.SortAndPage(items, null, query, LibraryManager);
|
||||
}
|
||||
|
||||
private static IEnumerable<BaseItem> CollapseBoxSetItemsIfNeeded(
|
||||
|
||||
@@ -179,7 +179,7 @@ namespace MediaBrowser.Controller.Entities.TV
|
||||
|
||||
var items = GetEpisodes(user, query.DtoOptions, true).Where(filter);
|
||||
|
||||
return PostFilterAndSort(items, query, false);
|
||||
return PostFilterAndSort(items, query);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -80,7 +80,7 @@ namespace MediaBrowser.Controller.Entities
|
||||
PresetViews = query.PresetViews
|
||||
});
|
||||
|
||||
return UserViewBuilder.SortAndPage(result, null, query, LibraryManager, true);
|
||||
return UserViewBuilder.SortAndPage(result, null, query, LibraryManager);
|
||||
}
|
||||
|
||||
public override int GetChildCount(User user)
|
||||
|
||||
@@ -438,22 +438,18 @@ namespace MediaBrowser.Controller.Entities
|
||||
items = FilterForAdjacency(items.ToList(), query.AdjacentTo.Value);
|
||||
}
|
||||
|
||||
return SortAndPage(items, totalRecordLimit, query, libraryManager, true);
|
||||
return SortAndPage(items, totalRecordLimit, query, libraryManager);
|
||||
}
|
||||
|
||||
public static QueryResult<BaseItem> SortAndPage(
|
||||
IEnumerable<BaseItem> items,
|
||||
int? totalRecordLimit,
|
||||
InternalItemsQuery query,
|
||||
ILibraryManager libraryManager,
|
||||
bool enableSorting)
|
||||
ILibraryManager libraryManager)
|
||||
{
|
||||
if (enableSorting)
|
||||
if (query.OrderBy.Count > 0)
|
||||
{
|
||||
if (query.OrderBy.Count > 0)
|
||||
{
|
||||
items = libraryManager.Sort(items, query.User, query.OrderBy);
|
||||
}
|
||||
items = libraryManager.Sort(items, query.User, query.OrderBy);
|
||||
}
|
||||
|
||||
var itemsArray = totalRecordLimit.HasValue ? items.Take(totalRecordLimit.Value).ToArray() : items.ToArray();
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
|
||||
namespace MediaBrowser.Controller.LibraryTaskScheduler;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a shared scheduler to run library related tasks based on the <see cref="ServerConfiguration.LibraryScanFanoutConcurrency"/>.
|
||||
/// </summary>
|
||||
public interface ILimitedConcurrencyLibraryScheduler
|
||||
{
|
||||
/// <summary>
|
||||
/// Enqueues an action that will be invoked with the set data.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The data Type.</typeparam>
|
||||
/// <param name="data">The data.</param>
|
||||
/// <param name="worker">The callback to process the data.</param>
|
||||
/// <param name="progress">A progress reporter.</param>
|
||||
/// <param name="cancellationToken">Stop token.</param>
|
||||
/// <returns>A task that finishes when all data has been processed by the worker.</returns>
|
||||
Task Enqueue<T>(T[] data, Func<T, IProgress<double>, Task> worker, IProgress<double> progress, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MediaBrowser.Controller.LibraryTaskScheduler;
|
||||
|
||||
/// <summary>
|
||||
/// Provides Parallel action interface to process tasks with a set concurrency level.
|
||||
/// </summary>
|
||||
public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibraryScheduler, IAsyncDisposable
|
||||
{
|
||||
private const int CleanupGracePeriod = 60;
|
||||
private readonly IHostApplicationLifetime _hostApplicationLifetime;
|
||||
private readonly ILogger<LimitedConcurrencyLibraryScheduler> _logger;
|
||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||
private readonly Dictionary<CancellationTokenSource, Task> _taskRunners = new();
|
||||
|
||||
private static readonly AsyncLocal<CancellationTokenSource> _deadlockDetector = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets used to lock all operations on the Tasks queue and creating workers.
|
||||
/// </summary>
|
||||
private readonly Lock _taskLock = new();
|
||||
|
||||
private readonly BlockingCollection<TaskQueueItem> _tasks = new();
|
||||
|
||||
private volatile int _workCounter;
|
||||
private Task? _cleanupTask;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="LimitedConcurrencyLibraryScheduler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="hostApplicationLifetime">The hosting lifetime.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="serverConfigurationManager">The server configuration manager.</param>
|
||||
public LimitedConcurrencyLibraryScheduler(
|
||||
IHostApplicationLifetime hostApplicationLifetime,
|
||||
ILogger<LimitedConcurrencyLibraryScheduler> logger,
|
||||
IServerConfigurationManager serverConfigurationManager)
|
||||
{
|
||||
_hostApplicationLifetime = hostApplicationLifetime;
|
||||
_logger = logger;
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
}
|
||||
|
||||
private void ScheduleTaskCleanup()
|
||||
{
|
||||
lock (_taskLock)
|
||||
{
|
||||
if (_cleanupTask is not null)
|
||||
{
|
||||
_logger.LogDebug("Cleanup task already scheduled.");
|
||||
// cleanup task is already running.
|
||||
return;
|
||||
}
|
||||
|
||||
_cleanupTask = RunCleanupTask();
|
||||
}
|
||||
|
||||
async Task RunCleanupTask()
|
||||
{
|
||||
_logger.LogDebug("Schedule cleanup task in {CleanupGracePerioid} sec.", CleanupGracePeriod);
|
||||
await Task.Delay(TimeSpan.FromSeconds(CleanupGracePeriod)).ConfigureAwait(false);
|
||||
if (_disposed)
|
||||
{
|
||||
_logger.LogDebug("Abort cleaning up, already disposed.");
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_taskLock)
|
||||
{
|
||||
if (_tasks.Count > 0 || _workCounter > 0)
|
||||
{
|
||||
_logger.LogDebug("Delay cleanup task, operations still running.");
|
||||
// tasks are still there so its still in use. Reschedule cleanup task.
|
||||
// we cannot just exit here and rely on the other invoker because there is a considerable timeframe where it could have already ended.
|
||||
_cleanupTask = RunCleanupTask();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("Cleanup runners.");
|
||||
foreach (var item in _taskRunners.ToArray())
|
||||
{
|
||||
await item.Key.CancelAsync().ConfigureAwait(false);
|
||||
_taskRunners.Remove(item.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool ShouldForceSequentialOperation()
|
||||
{
|
||||
// if the user either set the setting to 1 or it's unset and we have fewer than 4 cores it's better to run sequentially.
|
||||
var fanoutSetting = _serverConfigurationManager.Configuration.LibraryScanFanoutConcurrency;
|
||||
return fanoutSetting == 1 || (fanoutSetting <= 0 && Environment.ProcessorCount <= 3);
|
||||
}
|
||||
|
||||
private int CalculateScanConcurrencyLimit()
|
||||
{
|
||||
// when this is invoked, we already checked ShouldForceSequentialOperation for the sequential check.
|
||||
var fanoutConcurrency = _serverConfigurationManager.Configuration.LibraryScanFanoutConcurrency;
|
||||
if (fanoutConcurrency <= 0)
|
||||
{
|
||||
// in case the user did not set a limit manually, we can assume he has 3 or more cores as already checked by ShouldForceSequentialOperation.
|
||||
return Environment.ProcessorCount - 3;
|
||||
}
|
||||
|
||||
return fanoutConcurrency;
|
||||
}
|
||||
|
||||
private void Worker()
|
||||
{
|
||||
lock (_taskLock)
|
||||
{
|
||||
var operationFanout = Math.Max(0, CalculateScanConcurrencyLimit() - _taskRunners.Count);
|
||||
_logger.LogDebug("Spawn {NumberRunners} new runners.", operationFanout);
|
||||
for (int i = 0; i < operationFanout; i++)
|
||||
{
|
||||
var stopToken = new CancellationTokenSource();
|
||||
var combinedSource = CancellationTokenSource.CreateLinkedTokenSource(stopToken.Token, _hostApplicationLifetime.ApplicationStopping);
|
||||
_taskRunners.Add(
|
||||
combinedSource,
|
||||
Task.Factory.StartNew(
|
||||
ItemWorker,
|
||||
(combinedSource, stopToken),
|
||||
combinedSource.Token,
|
||||
TaskCreationOptions.PreferFairness,
|
||||
TaskScheduler.Default));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ItemWorker(object? obj)
|
||||
{
|
||||
var stopToken = ((CancellationTokenSource TaskStop, CancellationTokenSource GlobalStop))obj!;
|
||||
_deadlockDetector.Value = stopToken.TaskStop;
|
||||
try
|
||||
{
|
||||
foreach (var item in _tasks.GetConsumingEnumerable(stopToken.GlobalStop.Token))
|
||||
{
|
||||
stopToken.GlobalStop.Token.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
var newWorkerLimit = Interlocked.Increment(ref _workCounter) > 0;
|
||||
Debug.Assert(newWorkerLimit, "_workCounter > 0");
|
||||
_logger.LogDebug("Process new item '{Data}'.", item.Data);
|
||||
await ProcessItem(item).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
var newWorkerLimit = Interlocked.Decrement(ref _workCounter) >= 0;
|
||||
Debug.Assert(newWorkerLimit, "_workCounter > 0");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stopToken.TaskStop.IsCancellationRequested)
|
||||
{
|
||||
// thats how you do it, interupt the waiter thread. There is nothing to do here when it was on purpose.
|
||||
}
|
||||
finally
|
||||
{
|
||||
_logger.LogDebug("Cleanup Runner'.");
|
||||
_deadlockDetector.Value = default!;
|
||||
_taskRunners.Remove(stopToken.TaskStop);
|
||||
stopToken.GlobalStop.Dispose();
|
||||
stopToken.TaskStop.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessItem(TaskQueueItem item)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (item.CancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// if item is cancelled, just skip it
|
||||
return;
|
||||
}
|
||||
|
||||
await item.Worker(item.Data).ConfigureAwait(true);
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error while performing a library operation");
|
||||
}
|
||||
finally
|
||||
{
|
||||
item.Progress.Report(100);
|
||||
item.Done.SetResult();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task Enqueue<T>(T[] data, Func<T, IProgress<double>, Task> worker, IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.Length == 0 || cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
progress.Report(100);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Enqueue new Workset of {NoItems} items.", data.Length);
|
||||
|
||||
TaskQueueItem[] workItems = null!;
|
||||
|
||||
void UpdateProgress()
|
||||
{
|
||||
progress.Report(workItems.Select(e => e.ProgressValue).Average());
|
||||
}
|
||||
|
||||
workItems = data.Select(item =>
|
||||
{
|
||||
TaskQueueItem queueItem = null!;
|
||||
return queueItem = new TaskQueueItem()
|
||||
{
|
||||
Data = item!,
|
||||
Progress = new Progress<double>(innerPercent =>
|
||||
{
|
||||
// round the percent and only update progress if it changed to prevent excessive UpdateProgress calls
|
||||
var innerPercentRounded = Math.Round(innerPercent);
|
||||
if (queueItem.ProgressValue != innerPercentRounded)
|
||||
{
|
||||
queueItem.ProgressValue = innerPercentRounded;
|
||||
UpdateProgress();
|
||||
}
|
||||
}),
|
||||
Worker = (val) => worker((T)val, queueItem.Progress),
|
||||
CancellationToken = cancellationToken
|
||||
};
|
||||
}).ToArray();
|
||||
|
||||
if (ShouldForceSequentialOperation())
|
||||
{
|
||||
_logger.LogDebug("Process sequentially.");
|
||||
try
|
||||
{
|
||||
foreach (var item in workItems)
|
||||
{
|
||||
await ProcessItem(item).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// operation is cancelled. Do nothing.
|
||||
}
|
||||
|
||||
_logger.LogDebug("Process sequentially done.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < workItems.Length; i++)
|
||||
{
|
||||
var item = workItems[i]!;
|
||||
_tasks.Add(item, CancellationToken.None);
|
||||
}
|
||||
|
||||
if (_deadlockDetector.Value is not null)
|
||||
{
|
||||
_logger.LogDebug("Nested invocation detected, process in-place.");
|
||||
try
|
||||
{
|
||||
// we are in a nested loop. There is no reason to spawn a task here as that would just lead to deadlocks and no additional concurrency is achieved
|
||||
while (workItems.Any(e => !e.Done.Task.IsCompleted) && _tasks.TryTake(out var item, 200, _deadlockDetector.Value.Token))
|
||||
{
|
||||
await ProcessItem(item).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (_deadlockDetector.Value.IsCancellationRequested)
|
||||
{
|
||||
// operation is cancelled. Do nothing.
|
||||
}
|
||||
|
||||
_logger.LogDebug("process in-place done.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Worker();
|
||||
_logger.LogDebug("Wait for {NoWorkers} to complete.", workItems.Length);
|
||||
await Task.WhenAll([.. workItems.Select(f => f.Done.Task)]).ConfigureAwait(false);
|
||||
_logger.LogDebug("{NoWorkers} completed.", workItems.Length);
|
||||
ScheduleTaskCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
_tasks.CompleteAdding();
|
||||
foreach (var item in _taskRunners)
|
||||
{
|
||||
await item.Key.CancelAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_tasks.Dispose();
|
||||
if (_cleanupTask is not null)
|
||||
{
|
||||
await _cleanupTask.ConfigureAwait(false);
|
||||
_cleanupTask?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private class TaskQueueItem
|
||||
{
|
||||
public required object Data { get; init; }
|
||||
|
||||
public double ProgressValue { get; set; }
|
||||
|
||||
public required Func<object, Task> Worker { get; init; }
|
||||
|
||||
public required IProgress<double> Progress { get; init; }
|
||||
|
||||
public TaskCompletionSource Done { get; } = new();
|
||||
|
||||
public CancellationToken CancellationToken { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -230,10 +230,10 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
{
|
||||
var hwType = encodingOptions.HardwareAccelerationType;
|
||||
|
||||
// Only Intel has VA-API MJPEG encoder
|
||||
// Only enable VA-API MJPEG encoder on Intel iHD driver.
|
||||
// Legacy platforms supported ONLY by i965 do not support MJPEG encoder.
|
||||
if (hwType == HardwareAccelerationType.vaapi
|
||||
&& !(_mediaEncoder.IsVaapiDeviceInteliHD
|
||||
|| _mediaEncoder.IsVaapiDeviceInteli965))
|
||||
&& !_mediaEncoder.IsVaapiDeviceInteliHD)
|
||||
{
|
||||
return _defaultMjpegEncoder;
|
||||
}
|
||||
@@ -2376,6 +2376,13 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
var requestHasHDR10 = requestedRangeTypes.Contains(VideoRangeType.HDR10.ToString(), StringComparison.OrdinalIgnoreCase);
|
||||
var requestHasHLG = requestedRangeTypes.Contains(VideoRangeType.HLG.ToString(), StringComparison.OrdinalIgnoreCase);
|
||||
var requestHasSDR = requestedRangeTypes.Contains(VideoRangeType.SDR.ToString(), StringComparison.OrdinalIgnoreCase);
|
||||
var requestHasDOVI = requestedRangeTypes.Contains(VideoRangeType.DOVI.ToString(), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// If the client does not support DOVI and the video stream is DOVI without fallback, we should not copy it.
|
||||
if (!requestHasDOVI && videoStream.VideoRangeType == VideoRangeType.DOVI)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!requestedRangeTypes.Contains(videoStream.VideoRangeType.ToString(), StringComparison.OrdinalIgnoreCase)
|
||||
&& !((requestHasHDR10 && videoStream.VideoRangeType == VideoRangeType.DOVIWithHDR10)
|
||||
@@ -2383,6 +2390,12 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
|| (requestHasSDR && videoStream.VideoRangeType == VideoRangeType.DOVIWithSDR)
|
||||
|| (requestHasHDR10 && videoStream.VideoRangeType == VideoRangeType.HDR10Plus)))
|
||||
{
|
||||
// If the video stream is in a static HDR format, don't allow copy if the client does not support HDR10 or HLG.
|
||||
if (videoStream.VideoRangeType is VideoRangeType.HDR10 or VideoRangeType.HLG)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check complicated cases where we need to remove dynamic metadata
|
||||
// Conservatively refuse to copy if the encoder can't remove dynamic metadata,
|
||||
// but a removal is required for compatability reasons.
|
||||
@@ -4435,6 +4448,13 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
var swapOutputWandH = doVppTranspose && swapWAndH;
|
||||
var hwScaleFilter = GetHwScaleFilter("vpp", "qsv", outFormat, swapOutputWandH, swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH);
|
||||
|
||||
// d3d11va doesn't support dynamic pool size, use vpp filter ctx to relay
|
||||
// to prevent encoder async and bframes from exhausting the decoder pool.
|
||||
if (!string.IsNullOrEmpty(hwScaleFilter) && isD3d11vaDecoder)
|
||||
{
|
||||
hwScaleFilter += ":passthrough=0";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(hwScaleFilter) && doVppTranspose)
|
||||
{
|
||||
hwScaleFilter += $":transpose={transposeDir}";
|
||||
@@ -7131,7 +7151,8 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
inputModifier += " -async " + state.InputAudioSync;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(state.InputVideoSync))
|
||||
// The -fps_mode option cannot be applied to input
|
||||
if (!string.IsNullOrEmpty(state.InputVideoSync) && _mediaEncoder.EncoderVersion < new Version(5, 1))
|
||||
{
|
||||
inputModifier += GetVideoSyncOption(state.InputVideoSync, _mediaEncoder.EncoderVersion);
|
||||
}
|
||||
|
||||
@@ -20,10 +20,10 @@ public interface IMediaSegmentManager
|
||||
/// </summary>
|
||||
/// <param name="baseItem">The Item to evaluate.</param>
|
||||
/// <param name="libraryOptions">The library options.</param>
|
||||
/// <param name="overwrite">If set, will remove existing segments and replace it with new ones otherwise will check for existing segments and if found any, stops.</param>
|
||||
/// <param name="forceOverwrite">If set, will force to remove existing segments and replace it with new ones otherwise will check for existing segments and if found any that should not be deleted, stops.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task that indicates the Operation is finished.</returns>
|
||||
Task RunSegmentPluginProviders(BaseItem baseItem, LibraryOptions libraryOptions, bool overwrite, CancellationToken cancellationToken);
|
||||
Task RunSegmentPluginProviders(BaseItem baseItem, LibraryOptions libraryOptions, bool forceOverwrite, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Returns if this item supports media segments.
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Model.Dto;
|
||||
@@ -102,4 +103,11 @@ public interface IItemRepository
|
||||
IReadOnlyList<string> GetGenreNames();
|
||||
|
||||
IReadOnlyList<string> GetAllArtistNames();
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an item has been persisted to the database.
|
||||
/// </summary>
|
||||
/// <param name="id">The id to check.</param>
|
||||
/// <returns>True if the item exists, otherwise false.</returns>
|
||||
Task<bool> ItemExistsAsync(Guid id);
|
||||
}
|
||||
|
||||
@@ -235,11 +235,11 @@ namespace MediaBrowser.LocalMetadata.Savers
|
||||
{
|
||||
if (item is Person)
|
||||
{
|
||||
await writer.WriteElementStringAsync(null, "DeathDate", null, item.EndDate.Value.ToLocalTime().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)).ConfigureAwait(false);
|
||||
await writer.WriteElementStringAsync(null, "DeathDate", null, item.EndDate.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)).ConfigureAwait(false);
|
||||
}
|
||||
else if (item is not Episode)
|
||||
{
|
||||
await writer.WriteElementStringAsync(null, "EndDate", null, item.EndDate.Value.ToLocalTime().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)).ConfigureAwait(false);
|
||||
await writer.WriteElementStringAsync(null, "EndDate", null, item.EndDate.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,12 @@ namespace MediaBrowser.Model.Dlna
|
||||
}
|
||||
}
|
||||
|
||||
var referenceBitrate = h264EquivalentOutputBitrate * (30.0f / (targetFps ?? 30.0f));
|
||||
// Our reference bitrate is based on SDR h264 at 30fps
|
||||
var referenceFps = targetFps ?? 30.0f;
|
||||
var referenceScale = referenceFps <= 30.0f
|
||||
? 30.0f / referenceFps
|
||||
: 1.0f / MathF.Sqrt(referenceFps / 30.0f);
|
||||
var referenceBitrate = h264EquivalentOutputBitrate * referenceScale;
|
||||
|
||||
if (isHdr)
|
||||
{
|
||||
|
||||
@@ -569,7 +569,7 @@ namespace MediaBrowser.Model.Dto
|
||||
/// Gets or sets the trickplay manifest.
|
||||
/// </summary>
|
||||
/// <value>The trickplay manifest.</value>
|
||||
public Dictionary<string, Dictionary<int, TrickplayInfo>> Trickplay { get; set; }
|
||||
public Dictionary<string, Dictionary<int, TrickplayInfoDto>> Trickplay { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the type of the location.
|
||||
|
||||
62
MediaBrowser.Model/Dto/TrickplayInfoDto.cs
Normal file
62
MediaBrowser.Model/Dto/TrickplayInfoDto.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using System;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
|
||||
namespace MediaBrowser.Model.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// The trickplay api model.
|
||||
/// </summary>
|
||||
public record TrickplayInfoDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TrickplayInfoDto"/> class.
|
||||
/// </summary>
|
||||
/// <param name="info">The trickplay info.</param>
|
||||
public TrickplayInfoDto(TrickplayInfo info)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(info);
|
||||
|
||||
Width = info.Width;
|
||||
Height = info.Height;
|
||||
TileWidth = info.TileWidth;
|
||||
TileHeight = info.TileHeight;
|
||||
ThumbnailCount = info.ThumbnailCount;
|
||||
Interval = info.Interval;
|
||||
Bandwidth = info.Bandwidth;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the width of an individual thumbnail.
|
||||
/// </summary>
|
||||
public int Width { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the height of an individual thumbnail.
|
||||
/// </summary>
|
||||
public int Height { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the amount of thumbnails per row.
|
||||
/// </summary>
|
||||
public int TileWidth { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the amount of thumbnails per column.
|
||||
/// </summary>
|
||||
public int TileHeight { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total amount of non-black thumbnails.
|
||||
/// </summary>
|
||||
public int ThumbnailCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the interval in milliseconds between each trickplay thumbnail.
|
||||
/// </summary>
|
||||
public int Interval { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the peak bandwidth usage in bits per second.
|
||||
/// </summary>
|
||||
public int Bandwidth { get; init; }
|
||||
}
|
||||
@@ -8,21 +8,28 @@ public class LyricLineCue
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="LyricLineCue"/> class.
|
||||
/// </summary>
|
||||
/// <param name="position">The start of the character index of the lyric.</param>
|
||||
/// <param name="position">The start character index of the cue.</param>
|
||||
/// <param name="endPosition">The end character index of the cue.</param>
|
||||
/// <param name="start">The start of the timestamp the lyric is synced to in ticks.</param>
|
||||
/// <param name="end">The end of the timestamp the lyric is synced to in ticks.</param>
|
||||
public LyricLineCue(int position, long start, long? end)
|
||||
public LyricLineCue(int position, int endPosition, long start, long? end)
|
||||
{
|
||||
Position = position;
|
||||
EndPosition = endPosition;
|
||||
Start = start;
|
||||
End = end;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the character index of the lyric.
|
||||
/// Gets the start character index of the cue.
|
||||
/// </summary>
|
||||
public int Position { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the end character index of the cue.
|
||||
/// </summary>
|
||||
public int EndPosition { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp the lyric is synced to in ticks.
|
||||
/// </summary>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user