Compare commits
310 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4910aafa1a | |||
| 726ae93a92 | |||
| 0cec5661e0 | |||
| fe73b1a949 | |||
| 80324b19fb | |||
| 05844d60c1 | |||
| eafe5ba6ed | |||
| 7ccce8e0e7 | |||
| 40ab2c4284 | |||
| d1dda7f6c5 | |||
| ff36560575 | |||
| e9abaa519c | |||
| 359e8069d0 | |||
| 7d6633ad1e | |||
| 11adad0e67 | |||
| 420d44f638 | |||
| 9fe5a53e47 | |||
| 2996f726c1 | |||
| 37db4bda53 | |||
| 0d78e44633 | |||
| 4962e4ec33 | |||
| 07e97c0ee9 | |||
| 24288e79a9 | |||
| 27d898e59e | |||
| 95281a2205 | |||
| 5a2f45ab4b | |||
| fbb0f1afbc | |||
| ea5f83328d | |||
| a45e66d43c | |||
| 6ad1e341b1 | |||
| b46e66627c | |||
| 7116d3bb72 | |||
| 99f21f1662 | |||
| 9126de26c0 | |||
| 75df54611a | |||
| ceeeaaab8e | |||
| 6da85a0aaa | |||
| 2aad6047c8 | |||
| 1cc490fb19 | |||
| 904699ba21 | |||
| f682c22b08 | |||
| a68793d08a | |||
| e79e2cba27 | |||
| f3298eee69 | |||
| 88df882061 | |||
| 118940fff8 | |||
| 79c37bfcd5 | |||
| a81b90f570 | |||
| 1d24c1df17 | |||
| 4147a83b93 | |||
| 6978dfc294 | |||
| 8c0775e941 | |||
| 5e621d0e3f | |||
| cefa78fc1d | |||
| 0c560b22ce | |||
| 38093e2952 | |||
| 3fafdbc281 | |||
| 9cc47c4fd6 | |||
| 4c524f033f | |||
| 422b2bb3d9 | |||
| 911ac3769c | |||
| 971be1b658 | |||
| 1cc698a30b | |||
| 4cd24a19d4 | |||
| af8e19b169 | |||
| 75ae31895f | |||
| 484291b0c1 | |||
| e8927bc300 | |||
| 7758beba99 | |||
| 090b610eb1 | |||
| 19235909fe | |||
| c3ed1407ca | |||
| 0b7b53a9fd | |||
| 284c6f00db | |||
| ea2c794cb2 | |||
| e1af8dce05 | |||
| 0d02638e82 | |||
| 682c45090c | |||
| b2ba726635 | |||
| 49112163eb | |||
| 0d4cbb999b | |||
| c04f11bd76 | |||
| 6617a27de3 | |||
| 4477f41857 | |||
| 20b4a59281 | |||
| 42c70fba63 | |||
| 8e8cf57025 | |||
| 587f06dccc | |||
| 7ad5ff6aa7 | |||
| a5880c8622 | |||
| 335d97d868 | |||
| 8e80677bdd | |||
| 4df580f0e8 | |||
| 9fa0533506 | |||
| 9ba91d4583 | |||
| a8da0664a3 | |||
| d6bcad3b59 | |||
| 40fbe4a771 | |||
| 678975fbd7 | |||
| fb50b4df8b | |||
| c70933a23f | |||
| ad9056c8fc | |||
| 77988e51b6 | |||
| 80fe8c67e9 | |||
| 4029de92a1 | |||
| 19d919f8be | |||
| ef01d88912 | |||
| bd085665d6 | |||
| f586fc063f | |||
| 17221057bc | |||
| 04b77ca801 | |||
| b11734d955 | |||
| 4a659fb30c | |||
| c203da245d | |||
| 0f36c4a200 | |||
| c4dec76b9c | |||
| 46be43ad24 | |||
| 3fe35fcbc1 | |||
| 43cdbe856a | |||
| 72405bb146 | |||
| 2bb281ad4c | |||
| a2ab2fe340 | |||
| 7df3c09760 | |||
| d26d3f4967 | |||
| 8718c00a5d | |||
| 0c68b190bc | |||
| 2c26ae6724 | |||
| 300c277816 | |||
| 69b21ddc0e | |||
| 90a0e9d962 | |||
| c539ee7e81 | |||
| f97b7b120d | |||
| 7c148c3c7c | |||
| 4fe980f25b | |||
| f0c5370e27 | |||
| 5f14c12c89 | |||
| 49c78d4624 | |||
| 1b698db422 | |||
| 97d3e2cba6 | |||
| 4680d00d2e | |||
| 30c2603911 | |||
| a74246a000 | |||
| 1e04187b52 | |||
| 499e64b1c3 | |||
| 916c3c9cc3 | |||
| f70e0563ee | |||
| d2f517a858 | |||
| 0ae4a70e1c | |||
| 4de43d36dd | |||
| b1e3cf1341 | |||
| dd7de41878 | |||
| e80fc120bf | |||
| 6982ba99ce | |||
| 8cb7297e27 | |||
| 95d95de6f1 | |||
| 7704346e9c | |||
| 6a021d5dae | |||
| 6ebe66c19d | |||
| 40a449c6f2 | |||
| 06e37a7bf6 | |||
| c77649d21e | |||
| 8f97e690c8 | |||
| b557dcfa45 | |||
| 7b66b32cdf | |||
| 7f3bbd5654 | |||
| a7c0c92019 | |||
| e1a619b79e | |||
| 12226d2212 | |||
| ae8723026d | |||
| a730d8a7ba | |||
| 21fec95b07 | |||
| 7e6709f023 | |||
| a4ff630d1f | |||
| 9a07d4336b | |||
| 2b5625d1c4 | |||
| 9ae6ffe441 | |||
| 36fccdf6d4 | |||
| 4e2fc33a61 | |||
| fa7fdf5884 | |||
| 2deb97bac3 | |||
| 9220c2bd63 | |||
| 6d501ba418 | |||
| 2a8d168796 | |||
| 34cc90f5e2 | |||
| 4e9713a032 | |||
| f82101332d | |||
| 7b6ae06f3b | |||
| 26fbd1eac0 | |||
| 35e86416af | |||
| c210ea87f2 | |||
| 4a1fcca182 | |||
| f5211a6722 | |||
| 0b77a520fa | |||
| d0e0e291f2 | |||
| 82ecfde2f7 | |||
| 7be16e9f74 | |||
| f441c5f125 | |||
| e6e3099da9 | |||
| d7dd98858e | |||
| 3d3d3c26b8 | |||
| 5f3e938d09 | |||
| 3031aa91ba | |||
| fb763c47bf | |||
| a65b614449 | |||
| 505a6ebf8f | |||
| 8c4dfc0b71 | |||
| 10d108a1f4 | |||
| 428c0f19bc | |||
| afd91a3d9d | |||
| a0b291a463 | |||
| a9e4c826aa | |||
| b189813eb1 | |||
| 7c20ea2708 | |||
| e63c05137a | |||
| c091ffdc6b | |||
| 70ffd25b50 | |||
| 247ee406a9 | |||
| 2218f2931c | |||
| 6bc1c18004 | |||
| eee818094e | |||
| 0ec7cc5f5b | |||
| 871970120d | |||
| 59bef7940b | |||
| 830d93e446 | |||
| d696cea9bc | |||
| e83125c17c | |||
| c5da7db120 | |||
| f9b7f2edf7 | |||
| 4192a4dd32 | |||
| 84ffe668d0 | |||
| d6da6906a4 | |||
| b7e2d21038 | |||
| e120b7f2dd | |||
| 6c37a6ef8b | |||
| 9162c17834 | |||
| 929834c982 | |||
| 5b3da3bcd7 | |||
| 4860f945a7 | |||
| 9f74fd7354 | |||
| 1c5c95ad1d | |||
| dd81e52f81 | |||
| 868ad089f8 | |||
| 2a08a30982 | |||
| 812c819162 | |||
| 234a49903b | |||
| cd5962df50 | |||
| 9d99164c19 | |||
| 2db10787af | |||
| 614e5c7912 | |||
| 17b0453cfb | |||
| d5a5b56484 | |||
| ef1ce3d6eb | |||
| 07fb350333 | |||
| f49a501f71 | |||
| f6ca06e9bf | |||
| ef84408199 | |||
| 0db3759325 | |||
| df1611cf95 | |||
| a6d09815f2 | |||
| 38aa870514 | |||
| d13c65229d | |||
| 61e75599b3 | |||
| b2485af6e9 | |||
| 4adaf7f146 | |||
| 563a00faaa | |||
| 5e33a2d376 | |||
| d9d9efc96e | |||
| dd1380e562 | |||
| cfb00b58a2 | |||
| 990ab18353 | |||
| 3578e9a332 | |||
| f60cfc788b | |||
| e2586eed9b | |||
| 7fbc1ff8c0 | |||
| 5767a3a800 | |||
| 4b51ef2c2f | |||
| f50fc0fa86 | |||
| a817391f67 | |||
| cc877229b1 | |||
| a3e7024355 | |||
| d3c7aa6bd2 | |||
| 40601523e7 | |||
| e3664a9561 | |||
| a1ed4b9001 | |||
| e09492de62 | |||
| d10381a0e2 | |||
| 9596115c45 | |||
| ed0be243e9 | |||
| f502fd312e | |||
| 9d0dffe818 | |||
| 4e2089b6a1 | |||
| 55518c06ee | |||
| a272efb9a2 | |||
| 33a8cdfc0b | |||
| 38c6c5b478 | |||
| d759df03b5 | |||
| e2c1121079 | |||
| fe3585577b | |||
| 8044534774 | |||
| 705368ee49 | |||
| cbc2c7c323 | |||
| 0915a61c19 | |||
| e123a13e38 | |||
| 3da05330b2 | |||
| a94588497c | |||
| 9a258c089d | |||
| 5f5c71ab75 | |||
| f6f0fed2d1 | |||
| e2adf2e2bf | |||
| 78b535b229 |
@@ -3,7 +3,7 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "10.0.10",
|
||||
"version": "10.0.11",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
]
|
||||
|
||||
@@ -32,13 +32,13 @@ jobs:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
uses: github/codeql-action/autobuild@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
|
||||
+5
-1
@@ -143,6 +143,7 @@
|
||||
- [Smith00101010](https://github.com/Smith00101010)
|
||||
- [sorinyo2004](https://github.com/sorinyo2004)
|
||||
- [Soumyadip Auddy](https://github.com/SoumyadipAuddy)
|
||||
- [st7105](https://github.com/st7105)
|
||||
- [sparky8251](https://github.com/sparky8251)
|
||||
- [spookbits](https://github.com/spookbits)
|
||||
- [ssenart](https://github.com/ssenart)
|
||||
@@ -236,6 +237,9 @@
|
||||
- [Lampan-git](https://github.com/Lampan-git)
|
||||
- [elio42](https://github.com/elio42)
|
||||
- [rwebster85](https://github.com/rwebster85)
|
||||
- [Florin-Popescu](https://github.com/Florin-Popescu)
|
||||
- [m0g3r](https://github.com/m0g3r)
|
||||
- [martin-77](https://github.com/martin-77)
|
||||
|
||||
# Emby Contributors
|
||||
|
||||
@@ -246,7 +250,7 @@
|
||||
- [Mark2xv](https://github.com/Mark2xv)
|
||||
- [ScottRapsey](https://github.com/ScottRapsey)
|
||||
- [skynet600](https://github.com/skynet600)
|
||||
- [Cheesegeezer](https://githum.com/Cheesegeezer)
|
||||
- [Cheesegeezer](https://github.com/Cheesegeezer)
|
||||
- [Radeon](https://github.com/radeonorama)
|
||||
- [gcw07](https://github.com/gcw07)
|
||||
- [SivaramAdhiappan](https://github.com/shivaram1190)
|
||||
|
||||
+25
-25
@@ -26,28 +26,28 @@
|
||||
<PackageVersion Include="libse" Version="4.0.12" />
|
||||
<PackageVersion Include="LrcParser" Version="2025.623.0" />
|
||||
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="8.0.1" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="5.6.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.6.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.6.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.6.0" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.9.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.9.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.9.0" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
|
||||
<PackageVersion Include="MimeTypes" Version="2.5.2" />
|
||||
<PackageVersion Include="Morestachio" Version="5.0.1.670" />
|
||||
<PackageVersion Include="Moq" Version="4.18.4" />
|
||||
@@ -68,7 +68,7 @@
|
||||
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
|
||||
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
|
||||
<PackageVersion Include="SharpCompress" Version="0.50.1" />
|
||||
<PackageVersion Include="SharpCompress" Version="0.50.4" />
|
||||
<PackageVersion Include="SharpFuzz" Version="2.3.0" />
|
||||
<PackageVersion Include="SkiaSharp" Version="3.119.4" />
|
||||
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.119.4" />
|
||||
@@ -78,11 +78,11 @@
|
||||
<PackageVersion Include="Svg.Skia" Version="3.7.0" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.2.3" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.10" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.11" />
|
||||
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
|
||||
<PackageVersion Include="z440.atl.core" Version="7.15.3" />
|
||||
<PackageVersion Include="z440.atl.core" Version="7.16.0" />
|
||||
<PackageVersion Include="TMDbLib" Version="3.0.0" />
|
||||
<PackageVersion Include="UTF.Unknown" Version="2.6.0" />
|
||||
<PackageVersion Include="UTF.Unknown" Version="2.7.0" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||
<PackageVersion Include="xunit.v3" Version="3.2.2" />
|
||||
<PackageVersion Include="Xunit.v3.Priority" Version="1.1.18" />
|
||||
|
||||
@@ -151,7 +151,7 @@ namespace Emby.Naming.Common
|
||||
|
||||
CleanStrings =
|
||||
[
|
||||
@"^\s*(?<cleaned>.+?)[ _\,\.\(\)\[\]\-](3d|sbs|tab|hsbs|htab|mvc|HDR|HDC|UHD|UltraHD|4k|ac3|dts|custom|dc|divx|divx5|dsr|dsrip|dutch|dvd|dvdrip|dvdscr|dvdscreener|screener|dvdivx|cam|fragment|fs|hdtv|hdrip|hdtvrip|internal|limited|multi|subs|ntsc|ogg|ogm|pal|pdtv|proper|repack|rerip|retail|cd[1-9]|r5|bd5|bd|se|svcd|swedish|german|read.nfo|nfofix|unrated|ws|telesync|ts|telecine|tc|brrip|bdrip|480p|480i|576p|576i|720p|720i|1080p|1080i|2160p|hrhd|hrhdtv|hddvd|bluray|blu-ray|x264|x265|h264|h265|xvid|xvidvd|xxx|www.www|AAC|DTS)(?=[ _\,\.\(\)\[\]\-]|$)",
|
||||
@"^\s*(?<cleaned>.+?)[ _\,\.\(\)\[\]\-](3d|sbs|tab|hsbs|htab|mvc|HDR|HDC|UHD|UltraHD|4k|ac3|dts|custom|dc|divx|divx5|dsr|dsrip|dutch|dvd|dvdrip|dvdscr|dvdscreener|screener|dvdivx|cam|fragment|fs|hdtv|hdrip|hdtvrip|internal|limited|multi|subs|ntsc|ogg|ogm|pal|pdtv|proper|repack|rerip|retail|cd[1-9]|r5|bd5|bd|se|svcd|swedish|german|read.nfo|nfofix|unrated|ws|web-dl|telesync|ts|telecine|tc|brrip|bdrip|480p|480i|576p|576i|720p|720i|1080p|1080i|2160p|hrhd|hrhdtv|hddvd|bluray|blu-ray|x264|x265|h264|h265|xvid|xvidvd|xxx|www.www|AAC|DTS)(?=[ _\,\.\(\)\[\]\-]|$)",
|
||||
@"^\s*(?<cleaned>.+?)((\s*\[[^\]]+\]\s*)+)(\.[^\s]+)?$",
|
||||
@"^\s*(?<cleaned>.+?)\WE[0-9]+(-|~)E?[0-9]+(\W|$)",
|
||||
@"^\s*\[[^\]]+\](?!\.\w+$)\s*(?<cleaned>.+)",
|
||||
|
||||
@@ -107,7 +107,8 @@ namespace Emby.Server.Implementations.Collections
|
||||
SaveLocalMetadata = true
|
||||
};
|
||||
|
||||
var name = _localizationManager.GetLocalizedString("Collections");
|
||||
// This names a library for the whole server, so ignore the requesting client's language.
|
||||
var name = _localizationManager.GetServerLocalizedString("Collections");
|
||||
|
||||
await _libraryManager.AddVirtualFolder(name, CollectionTypeOptions.boxsets, libraryOptions, true).ConfigureAwait(false);
|
||||
|
||||
|
||||
@@ -192,7 +192,7 @@ namespace Emby.Server.Implementations.Dto
|
||||
var folderIds = accessibleItems.OfType<Folder>().Select(f => f.Id).ToList();
|
||||
if (folderIds.Count > 0)
|
||||
{
|
||||
childCountBatch = _libraryManager.GetChildCountBatch(folderIds, user?.Id);
|
||||
childCountBatch = _libraryManager.GetChildCountBatch(folderIds, user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,6 +242,29 @@ namespace Emby.Server.Implementations.Dto
|
||||
artistsBatch = _libraryManager.GetArtists(artistNames.ToArray());
|
||||
}
|
||||
|
||||
// Batch-fetch people across all items to avoid one GetPeople query per item.
|
||||
IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>>? peopleBatch = null;
|
||||
if (options.ContainsField(ItemFields.People))
|
||||
{
|
||||
var peopleItemIds = accessibleItems.Where(i => i.SupportsPeople).Select(i => i.Id).ToList();
|
||||
if (peopleItemIds.Count > 0)
|
||||
{
|
||||
peopleBatch = _libraryManager.GetPeopleByItems(peopleItemIds);
|
||||
}
|
||||
}
|
||||
|
||||
// Batch-detect which videos own alternate versions to avoid the per-item alternate-version
|
||||
// queries in MediaSourceCount. Videos absent from this set have a single media source.
|
||||
IReadOnlySet<Guid>? alternateVersionItemIds = null;
|
||||
if (options.ContainsField(ItemFields.MediaSourceCount))
|
||||
{
|
||||
var versionItemIds = accessibleItems.OfType<Video>().Select(i => i.Id).ToList();
|
||||
if (versionItemIds.Count > 0)
|
||||
{
|
||||
alternateVersionItemIds = _libraryManager.GetItemIdsWithAlternateVersions(versionItemIds);
|
||||
}
|
||||
}
|
||||
|
||||
for (int index = 0; index < accessibleItems.Count; index++)
|
||||
{
|
||||
var item = accessibleItems[index];
|
||||
@@ -255,7 +278,9 @@ namespace Emby.Server.Implementations.Dto
|
||||
childCountBatch,
|
||||
playedCountBatch,
|
||||
artistsBatch,
|
||||
resumeDataBatch?.GetValueOrDefault(item.Id));
|
||||
resumeDataBatch?.GetValueOrDefault(item.Id),
|
||||
peopleBatch,
|
||||
alternateVersionItemIds);
|
||||
|
||||
if (item is LiveTvChannel tvChannel)
|
||||
{
|
||||
@@ -317,7 +342,9 @@ namespace Emby.Server.Implementations.Dto
|
||||
Dictionary<Guid, int>? childCountBatch = null,
|
||||
Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null,
|
||||
IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null,
|
||||
VersionResumeData? resumeData = null)
|
||||
VersionResumeData? resumeData = null,
|
||||
IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>>? peopleBatch = null,
|
||||
IReadOnlySet<Guid>? alternateVersionItemIds = null)
|
||||
{
|
||||
var dto = new BaseItemDto
|
||||
{
|
||||
@@ -331,7 +358,15 @@ namespace Emby.Server.Implementations.Dto
|
||||
|
||||
if (options.ContainsField(ItemFields.People))
|
||||
{
|
||||
AttachPeople(dto, item, user);
|
||||
IReadOnlyList<PersonInfo>? prefetchedPeople = null;
|
||||
if (peopleBatch is not null)
|
||||
{
|
||||
// The batch omits items with no people, so a miss means "no people",
|
||||
// not "not fetched". Use an empty list to skip the per-item query.
|
||||
prefetchedPeople = peopleBatch.GetValueOrDefault(item.Id) ?? [];
|
||||
}
|
||||
|
||||
AttachPeople(dto, item, user, prefetchedPeople);
|
||||
}
|
||||
|
||||
if (options.ContainsField(ItemFields.PrimaryImageAspectRatio))
|
||||
@@ -378,7 +413,7 @@ namespace Emby.Server.Implementations.Dto
|
||||
AttachStudios(dto, item);
|
||||
}
|
||||
|
||||
AttachBasicFields(dto, item, owner, options, artistsBatch, user);
|
||||
AttachBasicFields(dto, item, owner, options, artistsBatch, user, alternateVersionItemIds);
|
||||
|
||||
if (options.ContainsField(ItemFields.CanDelete))
|
||||
{
|
||||
@@ -576,7 +611,11 @@ namespace Emby.Server.Implementations.Dto
|
||||
// For these types we can try to optimize and assume these values will be equal
|
||||
if (item is MusicAlbum || item is Season || item is Playlist)
|
||||
{
|
||||
dto.ChildCount = dto.RecursiveItemCount;
|
||||
if (dto.RecursiveItemCount > 0)
|
||||
{
|
||||
dto.ChildCount = dto.RecursiveItemCount;
|
||||
}
|
||||
|
||||
var folderChildCount = folder.LinkedChildren.Length;
|
||||
// The default is an empty array, so we can't reliably use the count when it's empty
|
||||
if (folderChildCount > 0)
|
||||
@@ -661,7 +700,8 @@ namespace Emby.Server.Implementations.Dto
|
||||
return count;
|
||||
}
|
||||
|
||||
// Fall back to individual query for special cases (Series, Season, etc.)
|
||||
// Only reached when no batch was computed: the batch holds an entry for every folder it
|
||||
// was asked about, zero included.
|
||||
return folder.GetChildCount(user);
|
||||
}
|
||||
|
||||
@@ -742,12 +782,18 @@ namespace Emby.Server.Implementations.Dto
|
||||
/// <param name="dto">The dto.</param>
|
||||
/// <param name="item">The item.</param>
|
||||
/// <param name="user">The requesting user.</param>
|
||||
private void AttachPeople(BaseItemDto dto, BaseItem item, User? user = null)
|
||||
/// <param name="prefetchedPeople">People fetched in batch by the caller; when null the people are queried per item.</param>
|
||||
private void AttachPeople(BaseItemDto dto, BaseItem item, User? user = null, IReadOnlyList<PersonInfo>? prefetchedPeople = null)
|
||||
{
|
||||
// When rendering a page of items the caller batch-fetches people for every item up
|
||||
// front and passes them in, avoiding one GetPeople query per item. Fall back to the
|
||||
// per-item query for the single item path where no batch is available.
|
||||
var source = prefetchedPeople ?? _libraryManager.GetPeople(item);
|
||||
|
||||
// Ordering by person type to ensure actors and artists are at the front.
|
||||
// This is taking advantage of the fact that they both begin with A
|
||||
// This should be improved in the future
|
||||
var people = _libraryManager.GetPeople(item).OrderBy(i => i.SortOrder ?? int.MaxValue)
|
||||
var people = source.OrderBy(i => i.SortOrder ?? int.MaxValue)
|
||||
.ThenBy(i =>
|
||||
{
|
||||
if (i.IsType(PersonKind.Actor))
|
||||
@@ -957,7 +1003,8 @@ namespace Emby.Server.Implementations.Dto
|
||||
/// <param name="options">The options.</param>
|
||||
/// <param name="artistsBatch">Optional pre-fetched artist lookup shared across a batch of items.</param>
|
||||
/// <param name="user">The user, for per-user values such as the accessible media source count.</param>
|
||||
private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null, User? user = null)
|
||||
/// <param name="alternateVersionItemIds">Optional pre-fetched set of item IDs that own alternate versions, shared across a batch of items.</param>
|
||||
private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null, User? user = null, IReadOnlySet<Guid>? alternateVersionItemIds = null)
|
||||
{
|
||||
if (options.ContainsField(ItemFields.DateCreated))
|
||||
{
|
||||
@@ -1271,15 +1318,27 @@ namespace Emby.Server.Implementations.Dto
|
||||
|
||||
if (options.ContainsField(ItemFields.MediaSourceCount))
|
||||
{
|
||||
// Match the per-user filtering of the media sources: versions the user cannot
|
||||
// access are not selectable, so they must not count towards the badge either.
|
||||
var mediaSourceCount = user is null
|
||||
|| (!video.PrimaryVersionId.HasValue && video.LinkedAlternateVersions.Length == 0 && !video.HasLocalAlternateVersions)
|
||||
? video.MediaSourceCount
|
||||
: video.GetAllVersions().Count(v => v.Id.Equals(video.Id) || v.IsVisibleStandalone(user));
|
||||
if (mediaSourceCount != 1)
|
||||
// A video with no primary version and no alternate versions always has a single
|
||||
// media source. Only compute the count for videos that might have more: a primary
|
||||
// version, or membership in the batch's set of items that own alternate versions.
|
||||
// Without the batch we can't rule it out, so fall back to computing (the single-item
|
||||
// path). Everything else is the common case and keeps the default count of one.
|
||||
var mayHaveAlternateVersions = alternateVersionItemIds is null
|
||||
|| video.PrimaryVersionId.HasValue
|
||||
|| alternateVersionItemIds.Contains(video.Id);
|
||||
|
||||
if (mayHaveAlternateVersions)
|
||||
{
|
||||
dto.MediaSourceCount = mediaSourceCount;
|
||||
// Match the per-user filtering of the media sources: versions the user cannot
|
||||
// access are not selectable, so they must not count towards the badge either.
|
||||
var mediaSourceCount = user is null
|
||||
|| (!video.PrimaryVersionId.HasValue && video.LinkedAlternateVersions.Length == 0 && !video.HasLocalAlternateVersions)
|
||||
? video.MediaSourceCount
|
||||
: video.GetAllVersions().Count(v => v.Id.Equals(video.Id) || v.IsVisibleStandalone(user));
|
||||
if (mediaSourceCount != 1)
|
||||
{
|
||||
dto.MediaSourceCount = mediaSourceCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -412,6 +412,13 @@ namespace Emby.Server.Implementations.Library
|
||||
}
|
||||
|
||||
_persistenceService.DeleteItem([.. pathMaps.Select(f => f.Item.Id)]);
|
||||
|
||||
// Evict the deleted items from the cache and announce each removal.
|
||||
foreach (var (item, _, _) in pathMaps)
|
||||
{
|
||||
_cache.TryRemove(item.Id, out _);
|
||||
ReportItemRemoved(item, item.GetOwner() ?? item.GetParent());
|
||||
}
|
||||
}
|
||||
|
||||
public void DeleteItem(BaseItem item, DeleteOptions options, BaseItem parent, bool notifyParentItem)
|
||||
@@ -611,6 +618,12 @@ namespace Emby.Server.Implementations.Library
|
||||
folder.UserData = null;
|
||||
}
|
||||
|
||||
// Announce the descendants before the item itself.
|
||||
foreach (var child in children)
|
||||
{
|
||||
ReportItemRemoved(child, item);
|
||||
}
|
||||
|
||||
ReportItemRemoved(item, parent);
|
||||
}
|
||||
|
||||
@@ -1732,9 +1745,9 @@ namespace Emby.Server.Implementations.Library
|
||||
return _countService.GetItemCountsForNameItem(kind, id, relatedItemKinds, query);
|
||||
}
|
||||
|
||||
public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId)
|
||||
public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, User? user)
|
||||
{
|
||||
return _countService.GetChildCountBatch(parentIds, userId);
|
||||
return _countService.GetChildCountBatch(parentIds, user);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -1901,14 +1914,14 @@ namespace Emby.Server.Implementations.Library
|
||||
}
|
||||
|
||||
// Optimize by querying against top level views
|
||||
query.TopParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray();
|
||||
query.AncestorIds = [];
|
||||
|
||||
// Prevent searching in all libraries due to empty filter
|
||||
if (query.TopParentIds.Length == 0)
|
||||
var topParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray();
|
||||
if (topParentIds.Length == 0)
|
||||
{
|
||||
query.TopParentIds = [Guid.NewGuid()];
|
||||
return;
|
||||
}
|
||||
|
||||
query.TopParentIds = topParentIds;
|
||||
query.AncestorIds = [];
|
||||
}
|
||||
|
||||
public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAlbumArtists(InternalItemsQuery query)
|
||||
@@ -1954,12 +1967,15 @@ namespace Emby.Server.Implementations.Library
|
||||
if (parents.All(i => i is ICollectionFolder || i is UserView))
|
||||
{
|
||||
// Optimize by querying against top level views
|
||||
query.TopParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray();
|
||||
var topParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray();
|
||||
|
||||
// Prevent searching in all libraries due to empty filter
|
||||
if (query.TopParentIds.Length == 0)
|
||||
if (topParentIds.Length > 0)
|
||||
{
|
||||
query.TopParentIds = [Guid.NewGuid()];
|
||||
query.TopParentIds = topParentIds;
|
||||
}
|
||||
else
|
||||
{
|
||||
SetAncestorIds(query, parents);
|
||||
}
|
||||
}
|
||||
else if (parents.Count == 1 && parents.First() is Folder folder
|
||||
@@ -1968,34 +1984,31 @@ namespace Emby.Server.Implementations.Library
|
||||
{
|
||||
// Playlists and BoxSets store their contents in LinkedChildren and never
|
||||
// populate AncestorIds for those items, so a recursive AncestorIds query
|
||||
// would return zero rows. Resolve to the linked child IDs up front and
|
||||
// route through the existing indexed ItemIds filter.
|
||||
query.ItemIds = folder.LinkedChildren
|
||||
.Where(lc => lc.ItemId.HasValue && !lc.ItemId.Value.IsEmpty())
|
||||
.Select(lc => lc.ItemId!.Value)
|
||||
.ToArray();
|
||||
|
||||
// Empty linked-children should still return empty rather than scanning everything.
|
||||
if (query.ItemIds.Length == 0)
|
||||
{
|
||||
query.ItemIds = [Guid.NewGuid()];
|
||||
}
|
||||
// would return zero rows. Filter by the descendant set instead, which follows
|
||||
// the links and keeps descending, so a linked folder contributes what is below
|
||||
// it as well - the episodes of a Series added to a collection, for example.
|
||||
query.DescendantOfId = folder.Id;
|
||||
}
|
||||
else
|
||||
{
|
||||
// We need to be able to query from any arbitrary ancestor up the tree
|
||||
query.AncestorIds = parents.SelectMany(i => i.GetIdsForAncestorQuery()).ToArray();
|
||||
|
||||
// Prevent searching in all libraries due to empty filter
|
||||
if (query.AncestorIds.Length == 0)
|
||||
{
|
||||
query.AncestorIds = [Guid.NewGuid()];
|
||||
}
|
||||
SetAncestorIds(query, parents);
|
||||
}
|
||||
|
||||
query.Parent = null;
|
||||
}
|
||||
|
||||
private static void SetAncestorIds(InternalItemsQuery query, IReadOnlyCollection<BaseItem> parents)
|
||||
{
|
||||
// We need to be able to query from any arbitrary ancestor up the tree
|
||||
query.AncestorIds = parents.SelectMany(i => i.GetIdsForAncestorQuery()).ToArray();
|
||||
|
||||
// Prevent searching in all libraries due to empty filter
|
||||
if (query.AncestorIds.Length == 0)
|
||||
{
|
||||
query.AncestorIds = [Guid.NewGuid()];
|
||||
}
|
||||
}
|
||||
|
||||
private void AddUserToQuery(InternalItemsQuery query, User user, bool allowExternalContent = true)
|
||||
{
|
||||
if (query.User is null)
|
||||
@@ -2234,6 +2247,12 @@ namespace Emby.Server.Implementations.Library
|
||||
return [];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlySet<Guid> GetItemIdsWithAlternateVersions(IReadOnlyList<Guid> itemIds)
|
||||
{
|
||||
return _linkedChildrenService.GetItemIdsWithAlternateVersions(itemIds);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void UpsertLinkedChild(Guid parentId, Guid childId, MediaBrowser.Controller.Entities.LinkedChildType childType)
|
||||
{
|
||||
@@ -2376,6 +2395,7 @@ namespace Emby.Server.Implementations.Library
|
||||
{
|
||||
altVideo.OwnerId = video.Id;
|
||||
altVideo.SetPrimaryVersionId(video.Id);
|
||||
altVideo.IsInMixedFolder = video.IsInMixedFolder;
|
||||
// ResolveAlternateVersion only sees the alternate's primary file.
|
||||
// If the alternate is itself a stack (e.g. 1080p part1 + part2),
|
||||
// detect its parts from sibling files so its AdditionalParts persist.
|
||||
@@ -2499,9 +2519,15 @@ namespace Emby.Server.Implementations.Library
|
||||
}
|
||||
}
|
||||
|
||||
if (!File.Exists(image.Path))
|
||||
if (string.IsNullOrEmpty(image.Path) || !File.Exists(image.Path))
|
||||
{
|
||||
_logger.LogWarning("Image not found at {ImagePath}", image.Path);
|
||||
_logger.LogWarning(
|
||||
"{ImageType} image for {ItemName} ({ItemId}) not found at \"{ImagePath}\", source was {SourcePath}",
|
||||
img.Type,
|
||||
item.Name,
|
||||
item.Id,
|
||||
image.Path,
|
||||
img.Path);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -2561,6 +2587,8 @@ namespace Emby.Server.Implementations.Library
|
||||
item.DateLastSaved = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
ForgetDroppedLocalAlternateVersions(items);
|
||||
|
||||
// Resolve and add any local alternate version items that don't exist yet
|
||||
// This ensures they exist in the database when LinkedChildren are processed
|
||||
var allItems = new List<BaseItem>(items);
|
||||
@@ -2589,6 +2617,7 @@ namespace Emby.Server.Implementations.Library
|
||||
{
|
||||
altVideo.OwnerId = video.Id;
|
||||
altVideo.SetPrimaryVersionId(video.Id);
|
||||
altVideo.IsInMixedFolder = video.IsInMixedFolder;
|
||||
// ResolveAlternateVersion only sees the alternate's primary file.
|
||||
// If the alternate is itself a stack (e.g. 1080p part1 + part2),
|
||||
// detect its parts from sibling files so its AdditionalParts persist.
|
||||
@@ -2649,6 +2678,30 @@ namespace Emby.Server.Implementations.Library
|
||||
public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
|
||||
=> UpdateItemsAsync([item], parent, updateReason, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Forgets the cached local alternate versions of the supplied items that they no longer list.
|
||||
/// </summary>
|
||||
/// <param name="items">The items about to be saved.</param>
|
||||
private void ForgetDroppedLocalAlternateVersions(IReadOnlyList<BaseItem> items)
|
||||
{
|
||||
foreach (var video in items.OfType<Video>())
|
||||
{
|
||||
var videoType = video.GetType();
|
||||
var keptIds = video.LocalAlternateVersions
|
||||
.Where(path => !string.IsNullOrEmpty(path))
|
||||
.Select(path => GetNewItemId(path, videoType))
|
||||
.ToHashSet();
|
||||
|
||||
foreach (var versionId in GetLocalAlternateVersionIds(video))
|
||||
{
|
||||
if (!keptIds.Contains(versionId))
|
||||
{
|
||||
_cache.TryRemove(versionId, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -2872,7 +2925,8 @@ namespace Emby.Server.Implementations.Library
|
||||
"views",
|
||||
_fileSystem.GetValidFilename(viewType.ToString()));
|
||||
|
||||
var id = GetNewItemId(path + "_namedview_" + name, typeof(UserView));
|
||||
// The display name is localized, so it must not take part in the id.
|
||||
var id = GetNewItemId(path + "_namedview_" + viewType.ToString(), typeof(UserView));
|
||||
|
||||
var item = GetItemById(id) as UserView;
|
||||
|
||||
@@ -2896,6 +2950,13 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
refresh = true;
|
||||
}
|
||||
else if (!string.Equals(item.Name, name, StringComparison.Ordinal))
|
||||
{
|
||||
item.Name = name;
|
||||
item.ForcedSortName = sortName;
|
||||
|
||||
refresh = true;
|
||||
}
|
||||
|
||||
if (refresh)
|
||||
{
|
||||
@@ -2916,7 +2977,9 @@ namespace Emby.Server.Implementations.Library
|
||||
var parentIdString = parentId.IsEmpty()
|
||||
? null
|
||||
: parentId.ToString("N", CultureInfo.InvariantCulture);
|
||||
var idValues = "38_namedview_" + name + user.Id.ToString("N", CultureInfo.InvariantCulture) + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty);
|
||||
|
||||
// The name is either localized (grouped views) or the library folder's own name.
|
||||
var idValues = "38_namedview_" + user.Id.ToString("N", CultureInfo.InvariantCulture) + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty);
|
||||
|
||||
var id = GetNewItemId(idValues, typeof(UserView));
|
||||
|
||||
@@ -2946,6 +3009,11 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
isNew = true;
|
||||
}
|
||||
else if (!string.Equals(item.Name, name, StringComparison.Ordinal))
|
||||
{
|
||||
item.Name = name;
|
||||
item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
var lastRefreshedUtc = item.DateLastRefreshed;
|
||||
var refresh = isNew || DateTime.UtcNow - lastRefreshedUtc >= _viewRefreshInterval;
|
||||
@@ -3047,7 +3115,7 @@ namespace Emby.Server.Implementations.Library
|
||||
var parentIdString = parentId.IsEmpty()
|
||||
? null
|
||||
: parentId.ToString("N", CultureInfo.InvariantCulture);
|
||||
var idValues = "37_namedview_" + name + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty);
|
||||
var idValues = "37_namedview_" + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty);
|
||||
if (!string.IsNullOrEmpty(uniqueId))
|
||||
{
|
||||
idValues += uniqueId;
|
||||
@@ -3081,9 +3149,10 @@ namespace Emby.Server.Implementations.Library
|
||||
isNew = true;
|
||||
}
|
||||
|
||||
if (viewType != item.ViewType)
|
||||
if (viewType != item.ViewType || !string.Equals(item.Name, name, StringComparison.Ordinal))
|
||||
{
|
||||
item.ViewType = viewType;
|
||||
item.Name = name;
|
||||
item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
@@ -3503,12 +3572,24 @@ namespace Emby.Server.Implementations.Library
|
||||
return _peopleRepository.GetPeopleNames(query);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int DeleteOrphanedCredits()
|
||||
{
|
||||
return _peopleRepository.DeleteOrphanedCredits();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes)
|
||||
{
|
||||
return _peopleRepository.GetPeopleNamesByItems(itemIds, personTypes);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>> GetPeopleByItems(IReadOnlyList<Guid> itemIds)
|
||||
{
|
||||
return _peopleRepository.GetPeopleByItems(itemIds);
|
||||
}
|
||||
|
||||
public void UpdatePeople(BaseItem item, List<PersonInfo> people)
|
||||
{
|
||||
UpdatePeopleAsync(item, people, CancellationToken.None).GetAwaiter().GetResult();
|
||||
@@ -3542,7 +3623,20 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
return item.GetImageInfo(image.Type, imageIndex);
|
||||
var localImage = item.GetImageInfo(image.Type, imageIndex);
|
||||
if (localImage is null)
|
||||
{
|
||||
throw new InvalidOperationException(string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"Downloaded {0} image {1} from {2} is not attached to {3} ({4})",
|
||||
image.Type,
|
||||
imageIndex,
|
||||
url,
|
||||
item.Name,
|
||||
item.Id));
|
||||
}
|
||||
|
||||
return localImage;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
@@ -3564,7 +3658,13 @@ namespace Emby.Server.Implementations.Library
|
||||
await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Unable to convert any images to local");
|
||||
throw new InvalidOperationException(string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"Unable to convert any {0} image url in \"{1}\" to a local file for {2} ({3})",
|
||||
image.Type,
|
||||
image.Path,
|
||||
item.Name,
|
||||
item.Id));
|
||||
}
|
||||
|
||||
public async Task AddVirtualFolder(string name, CollectionTypeOptions? collectionType, LibraryOptions options, bool refreshLibrary)
|
||||
@@ -3744,7 +3844,9 @@ namespace Emby.Server.Implementations.Library
|
||||
}
|
||||
|
||||
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
|
||||
var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName);
|
||||
var virtualFolderPath = FileSystemHelper.GetChildPath(rootFolderPath, virtualFolderName)
|
||||
?? throw new FileNotFoundException(
|
||||
string.Format(CultureInfo.InvariantCulture, "The media collection {0} does not exist", virtualFolderName));
|
||||
|
||||
CreateShortcut(virtualFolderPath, pathInfo);
|
||||
|
||||
@@ -3765,7 +3867,9 @@ namespace Emby.Server.Implementations.Library
|
||||
ArgumentNullException.ThrowIfNull(mediaPath);
|
||||
|
||||
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
|
||||
var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName);
|
||||
var virtualFolderPath = FileSystemHelper.GetChildPath(rootFolderPath, virtualFolderName)
|
||||
?? throw new FileNotFoundException(
|
||||
string.Format(CultureInfo.InvariantCulture, "The media collection {0} does not exist", virtualFolderName));
|
||||
|
||||
var libraryOptions = CollectionFolder.GetLibraryOptions(virtualFolderPath);
|
||||
|
||||
@@ -3804,9 +3908,9 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
|
||||
|
||||
var path = Path.Combine(rootFolderPath, name);
|
||||
var path = FileSystemHelper.GetChildPath(rootFolderPath, name);
|
||||
|
||||
if (!Directory.Exists(path))
|
||||
if (path is null || !Directory.Exists(path))
|
||||
{
|
||||
throw new FileNotFoundException("The media folder does not exist");
|
||||
}
|
||||
@@ -3870,9 +3974,9 @@ namespace Emby.Server.Implementations.Library
|
||||
ArgumentException.ThrowIfNullOrEmpty(mediaPath);
|
||||
|
||||
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
|
||||
var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName);
|
||||
var virtualFolderPath = FileSystemHelper.GetChildPath(rootFolderPath, virtualFolderName);
|
||||
|
||||
if (!Directory.Exists(virtualFolderPath))
|
||||
if (virtualFolderPath is null || !Directory.Exists(virtualFolderPath))
|
||||
{
|
||||
throw new FileNotFoundException(
|
||||
string.Format(CultureInfo.InvariantCulture, "The media collection {0} does not exist", virtualFolderName));
|
||||
|
||||
@@ -99,7 +99,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
|
||||
args.LibraryOptions.SeasonZeroDisplayName :
|
||||
string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
_localization.GetLocalizedString("NameSeasonNumber"),
|
||||
_localization.GetServerLocalizedString("NameSeasonNumber"),
|
||||
seasonNumber,
|
||||
args.LibraryOptions.PreferredMetadataLanguage);
|
||||
}
|
||||
@@ -129,6 +129,17 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
|
||||
|
||||
var tmdbId = justName.GetAttributeValue("tmdbid");
|
||||
item.TrySetProviderId(MetadataProvider.Tmdb, tmdbId);
|
||||
|
||||
// Anime databases model a single cour as its own entry, so a multi-season
|
||||
// series maps to one of these ids per season rather than one per series.
|
||||
var anidbId = justName.GetAttributeValue("anidbid");
|
||||
item.TrySetProviderId("AniDB", anidbId);
|
||||
|
||||
var aniListId = justName.GetAttributeValue("anilistid");
|
||||
item.TrySetProviderId("AniList", aniListId);
|
||||
|
||||
var aniSearchId = justName.GetAttributeValue("anisearchid");
|
||||
item.TrySetProviderId("AniSearch", aniSearchId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,49 +92,45 @@ public class SearchManager : ISearchManager
|
||||
await Task.WhenAll(externalTask, internalTask).ConfigureAwait(false);
|
||||
|
||||
var externalResults = await externalTask.ConfigureAwait(false);
|
||||
var fromExternal = externalResults.Count > 0;
|
||||
IReadOnlyList<SearchResult> results;
|
||||
if (fromExternal)
|
||||
{
|
||||
results = externalResults;
|
||||
}
|
||||
else
|
||||
{
|
||||
results = await internalTask.ConfigureAwait(false);
|
||||
if (_internalProviders.Length > 0)
|
||||
{
|
||||
_logger.LogDebug("No results from external providers, using internal provider results");
|
||||
}
|
||||
}
|
||||
|
||||
// Internal providers apply user-access filtering inline in their queries. External
|
||||
// providers don't know about user permissions, so they may return IDs from hidden
|
||||
// libraries or items the user is otherwise blocked from. Run the post-filter only
|
||||
// when results came from externals to close that gap. The Items controller's second
|
||||
// roundtrip via folder.GetItems applies most of these again, but it does not restrict
|
||||
// by TopParentIds when ItemIds is set.
|
||||
if (fromExternal && results.Count > 0 && query.UserId.HasValue && !query.UserId.Value.IsEmpty())
|
||||
// libraries or items the user is otherwise blocked from. Filter them here to close
|
||||
// that gap. The Items controller's second roundtrip via folder.GetItems applies most
|
||||
// of these again, but it does not restrict by TopParentIds when ItemIds is set.
|
||||
if (externalResults.Count > 0 && query.UserId.HasValue && !query.UserId.Value.IsEmpty())
|
||||
{
|
||||
var user = _userManager.GetUserById(query.UserId.Value);
|
||||
if (user is not null)
|
||||
{
|
||||
results = await FilterByUserAccessAsync(results, user, cancellationToken).ConfigureAwait(false);
|
||||
externalResults = await FilterByUserAccessAsync(externalResults, user, query, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
if (externalResults.Count > 0)
|
||||
{
|
||||
return externalResults;
|
||||
}
|
||||
|
||||
if (_internalProviders.Length > 0)
|
||||
{
|
||||
_logger.LogDebug("No results from external providers, using internal provider results");
|
||||
}
|
||||
|
||||
return await internalTask.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<SearchResult>> FilterByUserAccessAsync(
|
||||
IReadOnlyList<SearchResult> candidates,
|
||||
User user,
|
||||
SearchProviderQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// SetUser populates parental rating + blocked/allowed tags. ConfigureUserAccess populates
|
||||
// TopParentIds for the user's accessible libraries — we call it before assigning ItemIds
|
||||
// because LibraryManager.AddUserToQuery skips TopParentIds when ItemIds is non-empty.
|
||||
var accessFilter = new InternalItemsQuery(user);
|
||||
_libraryManager.ConfigureUserAccess(accessFilter, user);
|
||||
// SetUser populates parental rating + blocked/allowed tags, Build populates TopParentIds
|
||||
// for the user's accessible libraries. The candidate ids are applied to the query below
|
||||
// rather than to the filter because LibraryManager.AddUserToQuery skips TopParentIds when
|
||||
// ItemIds is non-empty.
|
||||
var accessFilter = SearchQueryAccessFilter.Build(user, query, _libraryManager);
|
||||
|
||||
Guid[] candidateIds = [.. candidates.Select(c => c.ItemId)];
|
||||
|
||||
@@ -147,17 +143,16 @@ public class SearchManager : ISearchManager
|
||||
|
||||
baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, accessFilter);
|
||||
|
||||
var allowedCount = await baseQuery.CountAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (allowedCount == candidates.Count)
|
||||
{
|
||||
return candidates;
|
||||
}
|
||||
|
||||
var allowedIds = await baseQuery
|
||||
.Select(e => e.Id)
|
||||
.ToHashSetAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (allowedIds.Count == candidates.Count)
|
||||
{
|
||||
return candidates;
|
||||
}
|
||||
|
||||
var filtered = candidates.Where(c => allowedIds.Contains(c.ItemId)).ToList();
|
||||
if (filtered.Count < candidates.Count)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
|
||||
namespace Emby.Server.Implementations.Library.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the access filter that decides which items a search may return for a user.
|
||||
/// </summary>
|
||||
internal static class SearchQueryAccessFilter
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds an access filter carrying the search's library access and type filters.
|
||||
/// </summary>
|
||||
/// <param name="user">The user the search runs for.</param>
|
||||
/// <param name="query">The search query.</param>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <returns>The access filter.</returns>
|
||||
public static InternalItemsQuery Build(User user, SearchProviderQuery query, ILibraryManager libraryManager)
|
||||
{
|
||||
// The type filters have to travel with the access filter: a by-name item belongs to no
|
||||
// library, so it carries no TopParentId to match, and the library filter only knows to
|
||||
// exempt it when the query says those types are wanted. A search scoped to a parent gets
|
||||
// no exemption because a by-name item has no parent to descend from either.
|
||||
var accessFilter = new InternalItemsQuery(user)
|
||||
{
|
||||
IncludeItemTypes = query.IncludeItemTypes,
|
||||
ExcludeItemTypes = query.ExcludeItemTypes,
|
||||
IncludeItemsByName = !query.ParentId.HasValue || query.ParentId.Value.IsEmpty()
|
||||
};
|
||||
|
||||
// ConfigureUserAccess populates TopParentIds for the libraries the user may open.
|
||||
libraryManager.ConfigureUserAccess(accessFilter, user);
|
||||
|
||||
return accessFilter;
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,7 @@ public class SqlSearchProvider : IInternalSearchProvider
|
||||
dbQuery = ApplyTypeFilter(dbQuery, query.IncludeItemTypes, query.ExcludeItemTypes);
|
||||
dbQuery = ApplyMediaTypeFilter(dbQuery, query.MediaTypes);
|
||||
dbQuery = ApplyParentFilter(dbQuery, query.ParentId);
|
||||
dbQuery = ApplyUserAccessFilter(dbContext, dbQuery, query.UserId);
|
||||
dbQuery = ApplyUserAccessFilter(dbContext, dbQuery, query);
|
||||
|
||||
// Compute the score in SQL: the ternary translates to a CASE WHEN. CleanName is
|
||||
// the pre-normalized (lowercase, diacritic-stripped) form, so we score against it
|
||||
@@ -196,8 +196,9 @@ public class SqlSearchProvider : IInternalSearchProvider
|
||||
private IQueryable<BaseItemEntity> ApplyUserAccessFilter(
|
||||
JellyfinDbContext dbContext,
|
||||
IQueryable<BaseItemEntity> query,
|
||||
Guid? userId)
|
||||
SearchProviderQuery searchQuery)
|
||||
{
|
||||
var userId = searchQuery.UserId;
|
||||
if (!userId.HasValue || userId.Value.IsEmpty())
|
||||
{
|
||||
return query;
|
||||
@@ -209,8 +210,7 @@ public class SqlSearchProvider : IInternalSearchProvider
|
||||
return query;
|
||||
}
|
||||
|
||||
var accessFilter = new InternalItemsQuery(user);
|
||||
_libraryManager.ConfigureUserAccess(accessFilter, user);
|
||||
var accessFilter = SearchQueryAccessFilter.Build(user, searchQuery, _libraryManager);
|
||||
return _queryHelpers.ApplyAccessFiltering(dbContext, query, accessFilter);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
|
||||
namespace Emby.Server.Implementations.Library.SimilarItems;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the access filter that decides which items a similar-items lookup may return for a user.
|
||||
/// </summary>
|
||||
internal static class SimilarItemsAccessFilter
|
||||
{
|
||||
private static readonly BaseItemKind[] _itemByNameKinds =
|
||||
[
|
||||
BaseItemKind.Person,
|
||||
BaseItemKind.Genre,
|
||||
BaseItemKind.MusicGenre,
|
||||
BaseItemKind.MusicArtist,
|
||||
BaseItemKind.Studio
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Builds an access filter carrying the user's library access and parental restrictions.
|
||||
/// </summary>
|
||||
/// <param name="user">The user the lookup runs for.</param>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <returns>The access filter.</returns>
|
||||
public static InternalItemsQuery Build(User user, ILibraryManager libraryManager)
|
||||
{
|
||||
// IncludeItemTypes is read only for the by-name exemption here; the caller applies this
|
||||
// filter through ApplyAccessFiltering, which does not translate it into a type restriction.
|
||||
var accessFilter = new InternalItemsQuery(user)
|
||||
{
|
||||
IncludeItemTypes = _itemByNameKinds
|
||||
};
|
||||
|
||||
// ConfigureUserAccess populates TopParentIds for the libraries the user may open.
|
||||
libraryManager.ConfigureUserAccess(accessFilter, user);
|
||||
|
||||
return accessFilter;
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,10 @@ using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using Jellyfin.Extensions.Json;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller;
|
||||
@@ -16,11 +18,13 @@ using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Emby.Server.Implementations.Library.SimilarItems;
|
||||
@@ -35,6 +39,8 @@ public class SimilarItemsManager : ISimilarItemsManager
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||
private readonly IItemQueryHelpers _queryHelpers;
|
||||
private ISimilarItemsProvider[] _similarItemsProviders = [];
|
||||
|
||||
/// <summary>
|
||||
@@ -45,18 +51,24 @@ public class SimilarItemsManager : ISimilarItemsManager
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <param name="fileSystem">The file system.</param>
|
||||
/// <param name="serverConfigurationManager">The server configuration manager.</param>
|
||||
/// <param name="dbProvider">The database context factory.</param>
|
||||
/// <param name="queryHelpers">The shared item query helpers.</param>
|
||||
public SimilarItemsManager(
|
||||
ILogger<SimilarItemsManager> logger,
|
||||
IServerApplicationPaths appPaths,
|
||||
ILibraryManager libraryManager,
|
||||
IFileSystem fileSystem,
|
||||
IServerConfigurationManager serverConfigurationManager)
|
||||
IServerConfigurationManager serverConfigurationManager,
|
||||
IDbContextFactory<JellyfinDbContext> dbProvider,
|
||||
IItemQueryHelpers queryHelpers)
|
||||
{
|
||||
_logger = logger;
|
||||
_appPaths = appPaths;
|
||||
_libraryManager = libraryManager;
|
||||
_fileSystem = fileSystem;
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
_dbProvider = dbProvider;
|
||||
_queryHelpers = queryHelpers;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -183,6 +195,7 @@ public class SimilarItemsManager : ISimilarItemsManager
|
||||
// Collect references in batches and resolve against local library.
|
||||
// Stop fetching once we have enough resolved local items.
|
||||
const int BatchSize = 20;
|
||||
const int MaxRemoteReferenceFetchLimit = 500;
|
||||
var remaining = requestedLimit - allResults.Count;
|
||||
var collectedReferences = new List<SimilarItemReference>();
|
||||
var pendingBatch = new List<SimilarItemReference>();
|
||||
@@ -199,7 +212,7 @@ public class SimilarItemsManager : ISimilarItemsManager
|
||||
remaining -= resolvedItems.Count;
|
||||
pendingBatch.Clear();
|
||||
|
||||
if (remaining <= 0)
|
||||
if (remaining <= 0 || collectedReferences.Count >= MaxRemoteReferenceFetchLimit)
|
||||
{
|
||||
break;
|
||||
}
|
||||
@@ -229,11 +242,64 @@ public class SimilarItemsManager : ISimilarItemsManager
|
||||
}
|
||||
}
|
||||
|
||||
return allResults
|
||||
var ordered = allResults
|
||||
.OrderByDescending(x => x.Score)
|
||||
.Select(x => x.Item)
|
||||
.Take(requestedLimit)
|
||||
.ToList();
|
||||
|
||||
return await FilterByLibraryAccessAsync(ordered, user, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<BaseItem>> FilterByLibraryAccessAsync(
|
||||
IReadOnlyList<BaseItem> candidates,
|
||||
User? user,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (candidates.Count == 0 || user is null)
|
||||
{
|
||||
return candidates;
|
||||
}
|
||||
|
||||
var accessFilter = SimilarItemsAccessFilter.Build(user, _libraryManager);
|
||||
|
||||
// No accessible libraries means nothing to compare against, and an empty TopParentIds set
|
||||
// would disable the filter rather than reject everything.
|
||||
if (accessFilter.TopParentIds.Length == 0)
|
||||
{
|
||||
return candidates;
|
||||
}
|
||||
|
||||
Guid[] candidateIds = [.. candidates.Select(c => c.Id)];
|
||||
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
var baseQuery = dbContext.BaseItems
|
||||
.AsNoTracking()
|
||||
.WhereOneOrMany(candidateIds, e => e.Id);
|
||||
|
||||
baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, accessFilter);
|
||||
|
||||
var allowedCount = await baseQuery.CountAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (allowedCount == candidates.Count)
|
||||
{
|
||||
return candidates;
|
||||
}
|
||||
|
||||
var allowedIds = await baseQuery
|
||||
.Select(e => e.Id)
|
||||
.ToHashSetAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var filtered = candidates.Where(c => allowedIds.Contains(c.Id)).ToList();
|
||||
_logger.LogDebug(
|
||||
"Dropped {Dropped} of {Total} similar-item candidates due to user access filtering",
|
||||
candidates.Count - filtered.Count,
|
||||
candidates.Count);
|
||||
|
||||
return filtered;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -375,19 +441,39 @@ public class SimilarItemsManager : ISimilarItemsManager
|
||||
|
||||
var batchResults = await batchProvider.GetBatchSimilarItemsAsync(baselineItems, query, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Filter once across every category rather than per baseline, so a batch provider costs one
|
||||
// access query no matter how many categories it produced.
|
||||
var allItems = batchResults.Values.SelectMany(items => items).DistinctBy(item => item.Id).ToList();
|
||||
var allowed = await FilterByLibraryAccessAsync(allItems, query.User, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
HashSet<Guid>? allowedIds = allowed.Count == allItems.Count
|
||||
? null
|
||||
: [.. allowed.Select(item => item.Id)];
|
||||
|
||||
var recommendations = new List<SimilarItemsRecommendation>(baselineItems.Count);
|
||||
foreach (var baseline in baselineItems)
|
||||
{
|
||||
if (batchResults.TryGetValue(baseline.Id, out var similar) && similar.Count > 0)
|
||||
if (!batchResults.TryGetValue(baseline.Id, out var similar) || similar.Count == 0)
|
||||
{
|
||||
recommendations.Add(new SimilarItemsRecommendation
|
||||
{
|
||||
BaselineItemName = baseline.Name,
|
||||
CategoryId = baseline.Id,
|
||||
RecommendationType = recommendationType,
|
||||
Items = similar
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (allowedIds is not null)
|
||||
{
|
||||
similar = similar.Where(item => allowedIds.Contains(item.Id)).ToList();
|
||||
if (similar.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
recommendations.Add(new SimilarItemsRecommendation
|
||||
{
|
||||
BaselineItemName = baseline.Name,
|
||||
CategoryId = baseline.Id,
|
||||
RecommendationType = recommendationType,
|
||||
Items = similar
|
||||
});
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
|
||||
@@ -112,7 +112,7 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
if (_config.Configuration.EnableFolderView)
|
||||
{
|
||||
var name = _localizationManager.GetLocalizedString("Folders");
|
||||
var name = _localizationManager.GetServerLocalizedString("Folders");
|
||||
list.Add(_libraryManager.GetNamedView(name, CollectionType.folders, string.Empty));
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
public UserView GetUserSubView(Guid parentId, CollectionType? type, string localizationKey, string sortName)
|
||||
{
|
||||
var name = _localizationManager.GetLocalizedString(localizationKey);
|
||||
var name = _localizationManager.GetServerLocalizedString(localizationKey);
|
||||
|
||||
return GetUserSubViewWithName(name, parentId, type, sortName);
|
||||
}
|
||||
@@ -191,7 +191,7 @@ namespace Emby.Server.Implementations.Library
|
||||
return GetUserView((Folder)parents[0], viewType, string.Empty);
|
||||
}
|
||||
|
||||
var name = _localizationManager.GetLocalizedString(localizationKey);
|
||||
var name = _localizationManager.GetServerLocalizedString(localizationKey);
|
||||
return _libraryManager.GetNamedView(user, name, viewType, sortName);
|
||||
}
|
||||
|
||||
@@ -396,6 +396,12 @@ namespace Emby.Server.Implementations.Library
|
||||
query.Limit = limit;
|
||||
return _libraryManager.GetLatestItemList(query, parents, CollectionType.movies);
|
||||
}
|
||||
|
||||
if (collectionType is null)
|
||||
{
|
||||
query.Limit = limit;
|
||||
return _libraryManager.GetLatestItemList(query, parents, CollectionType.unknown);
|
||||
}
|
||||
}
|
||||
|
||||
return _libraryManager.GetItemList(query, parents);
|
||||
|
||||
@@ -49,6 +49,14 @@ public class PeopleValidator
|
||||
/// <returns>Task.</returns>
|
||||
public async Task ValidatePeople(CancellationToken cancellationToken, IProgress<double> progress)
|
||||
{
|
||||
// Before the refresh below walks them: a credit no item maps to any more stands for nothing,
|
||||
// and while it is there the person it names cannot reach the dead-person sweep either.
|
||||
var numOrphaned = _libraryManager.DeleteOrphanedCredits();
|
||||
if (numOrphaned > 0)
|
||||
{
|
||||
_logger.LogDebug("Deleted {Amount} credits no item maps to", numOrphaned);
|
||||
}
|
||||
|
||||
var people = _libraryManager.GetPeopleNames(new InternalPeopleQuery());
|
||||
|
||||
var numComplete = 0;
|
||||
@@ -115,6 +123,6 @@ public class PeopleValidator
|
||||
|
||||
progress.Report(100);
|
||||
|
||||
_logger.LogInformation("People validation complete");
|
||||
_logger.LogInformation("People validation complete, deleted {Orphaned} orphaned credits", numOrphaned);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
"TaskAudioNormalization": "تطبيع الصوت",
|
||||
"TaskAudioNormalizationDescription": "يفحص الملفات لجمع بيانات تطبيع الصوت.",
|
||||
"TaskDownloadMissingLyrics": "تنزيل الكلمات المفقودة",
|
||||
"TaskDownloadMissingLyricsDescription": "ينزّل الكلمات للأغاني.",
|
||||
"TaskDownloadMissingLyricsDescription": "تحميل كلمات الأغاني",
|
||||
"TaskExtractMediaSegments": "فحص مقاطع المحتوى",
|
||||
"TaskExtractMediaSegmentsDescription": "يستخرج أو يحصل على مقاطع المحتوى من الملحقات المفعّلة لمقاطع المحتوى (MediaSegment).",
|
||||
"TaskMoveTrickplayImages": "نقل موقع صور معاينات التنقل",
|
||||
@@ -108,5 +108,18 @@
|
||||
"CleanupUserDataTask": "مهمة تنظيف بيانات المستخدم",
|
||||
"CleanupUserDataTaskDescription": "ينظف جميع بيانات المستخدم (مثل حالة المشاهدة وحالة المفضلة وغيرها) للمحتوى الذي لم يعد موجوداً لمدة 90 يوماً على الأقل.",
|
||||
"Original": "فريد",
|
||||
"LyricDownloadFailureFromForItem": "فشل تحميل الكلمات من {0} إلى {1}"
|
||||
"LyricDownloadFailureFromForItem": "فشل تحميل الكلمات من {0} إلى {1}",
|
||||
"NameExtraBehindTheScenes": "خلف المشاهد",
|
||||
"NameExtraClip": "مقطع",
|
||||
"NameExtraDeletedScene": "المشهد المحذوف",
|
||||
"NameExtraInterview": "مقابلة",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "عيّنة",
|
||||
"NameExtraScene": "مشهد",
|
||||
"NameExtraShort": "قصير",
|
||||
"NameExtraThemeSong": "الاغنية السمة",
|
||||
"NameExtraThemeVideo": "الفيديو السمة",
|
||||
"NameExtraFeaturette": "فيلم قصير إضافي",
|
||||
"NameExtraTrailer": "إعلان ترويجي",
|
||||
"NameExtraUnknown": "إضافي"
|
||||
}
|
||||
|
||||
@@ -106,5 +106,11 @@
|
||||
"TaskExtractMediaSegments": "Сканіраванне медыя-сегмента",
|
||||
"TaskMoveTrickplayImages": "Перанесці месцазнаходжанне выявы Trickplay",
|
||||
"CleanupUserDataTask": "Задача па ачыстцы даных карыстальніка",
|
||||
"CleanupUserDataTaskDescription": "Ачышчае ўсе даныя карыстальніка (стан прагляду, абранае і г.д.) для медыяфайлаў, што адсутнічаюць больш за 90 дзён."
|
||||
"CleanupUserDataTaskDescription": "Ачышчае ўсе даныя карыстальніка (стан прагляду, абранае і г.д.) для медыяфайлаў, што адсутнічаюць больш за 90 дзён.",
|
||||
"LyricDownloadFailureFromForItem": "Не ўдалося загрузіць тэкст песні з {0} для {1}",
|
||||
"NameExtraDeletedScene": "Выдаленая сцэна",
|
||||
"NameExtraInterview": "Інтэрв'ю",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraScene": "Сцэна",
|
||||
"NameExtraTrailer": "Трэйлер"
|
||||
}
|
||||
|
||||
@@ -106,5 +106,20 @@
|
||||
"TaskMoveTrickplayImagesDescription": "Премества съществуващите trickplay изображения спрямо настройките на библиотеката.",
|
||||
"TaskExtractMediaSegments": "Сканиране за сегменти",
|
||||
"CleanupUserDataTask": "Задача за почистване на потребителски данни",
|
||||
"CleanupUserDataTaskDescription": "Почиства всички потребителски данни (статус на гледане, любими и т.н.) от медия, която вече не е налична от поне 90 дни."
|
||||
"CleanupUserDataTaskDescription": "Почиства всички потребителски данни (статус на гледане, любими и т.н.) от медия, която вече не е налична от поне 90 дни.",
|
||||
"LyricDownloadFailureFromForItem": "Текстът на песента не успя да се изтегли от {0} за {1}",
|
||||
"NameExtraBehindTheScenes": "Зад кулисите",
|
||||
"NameExtraScene": "Сцена",
|
||||
"NameExtraShort": "Откъс",
|
||||
"NameExtraThemeVideo": "Тематично видео",
|
||||
"NameExtraTrailer": "Трейлър",
|
||||
"NameExtraUnknown": "Екстра",
|
||||
"NameExtraClip": "Клип",
|
||||
"NameExtraDeletedScene": "Изтрита Сцена",
|
||||
"NameExtraFeaturette": "Кратък филм",
|
||||
"NameExtraInterview": "Интервю",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Пример",
|
||||
"NameExtraThemeSong": "Тема-песен",
|
||||
"Original": "Оригинал"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"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",
|
||||
"Original": "Originál",
|
||||
"LyricDownloadFailureFromForItem": "Nepodařilo se stáhnout texty pro {1} ze služby {0}"
|
||||
"LyricDownloadFailureFromForItem": "Nepodařilo se stáhnout texty pro {1} ze služby {0}",
|
||||
"NameExtraBehindTheScenes": "Zákulisí",
|
||||
"NameExtraClip": "Klip",
|
||||
"NameExtraDeletedScene": "Vymazaná scéna",
|
||||
"NameExtraFeaturette": "Featurette",
|
||||
"NameExtraInterview": "Rozhovor",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Ukázka",
|
||||
"NameExtraScene": "Scéna",
|
||||
"NameExtraShort": "Krátké",
|
||||
"NameExtraThemeSong": "Úvodní píseň",
|
||||
"NameExtraThemeVideo": "Úvodní video",
|
||||
"NameExtraTrailer": "Upoutávka",
|
||||
"NameExtraUnknown": "Extra"
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"Shows": "Serier",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin er i gang med at starte. Prøv igen om et øjeblik.",
|
||||
"SubtitleDownloadFailureFromForItem": "Undertekster kunne ikke hentes fra {0} til {1}",
|
||||
"TvShows": "TV-serier",
|
||||
"TvShows": "Serier",
|
||||
"UserCreatedWithName": "Bruger {0} er blevet oprettet",
|
||||
"UserDeletedWithName": "Brugeren {0} er nu slettet",
|
||||
"UserDownloadingItemWithValues": "{0} henter {1}",
|
||||
@@ -80,8 +80,8 @@
|
||||
"TaskRefreshChapterImagesDescription": "Laver miniaturebilleder for videoer, der har kapitler.",
|
||||
"TaskRefreshChannelsDescription": "Opdaterer information for internetkanaler.",
|
||||
"TaskRefreshChannels": "Opdatér kanaler",
|
||||
"TaskCleanTranscodeDescription": "Fjerner omkodningsfiler, som er mere end 1 dag gamle.",
|
||||
"TaskCleanTranscode": "Tøm omkodningsmappen",
|
||||
"TaskCleanTranscodeDescription": "Fjerner transkodningsfiler, som er mere end 1 dag gamle.",
|
||||
"TaskCleanTranscode": "Tøm transkodningsmappen",
|
||||
"TaskRefreshPeople": "Opdatér personer",
|
||||
"TaskRefreshPeopleDescription": "Opdaterer metadata for skuespillere og instruktører i dit mediebibliotek.",
|
||||
"TaskCleanActivityLogDescription": "Sletter linjer i aktivitetsloggen ældre end den konfigurerede alder.",
|
||||
@@ -108,5 +108,18 @@
|
||||
"CleanupUserDataTask": "Brugerdata oprydningsopgave",
|
||||
"CleanupUserDataTaskDescription": "Rydder alle brugerdata (eks. visning- og favoritstatus) fra medier, der har været utilgængelige i mindst 90 dage.",
|
||||
"LyricDownloadFailureFromForItem": "Sangtekster kunne ikke downloades fra {0} til {1}",
|
||||
"Original": "Original"
|
||||
"Original": "Original",
|
||||
"NameExtraBehindTheScenes": "Bag scenerne",
|
||||
"NameExtraClip": "Klip",
|
||||
"NameExtraDeletedScene": "Slettet scene",
|
||||
"NameExtraFeaturette": "Featurette",
|
||||
"NameExtraInterview": "Interview",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Smagsprøve",
|
||||
"NameExtraScene": "Scene",
|
||||
"NameExtraShort": "Kort",
|
||||
"NameExtraThemeSong": "Temasang",
|
||||
"NameExtraThemeVideo": "Temavideo",
|
||||
"NameExtraTrailer": "Trailer",
|
||||
"NameExtraUnknown": "Ekstra"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"AppDeviceValues": "App: {0}, Gerät: {1}",
|
||||
"Artists": "Interpreten",
|
||||
"Artists": "Künstler",
|
||||
"AuthenticationSucceededWithUserName": "{0} erfolgreich authentifiziert",
|
||||
"Books": "Bücher",
|
||||
"ChapterNameValue": "Kapitel {0}",
|
||||
@@ -108,5 +108,18 @@
|
||||
"CleanupUserDataTask": "Aufgabe zur Bereinigung von Benutzerdaten",
|
||||
"CleanupUserDataTaskDescription": "Löscht alle Benutzerdaten (Abspielstatus, Favoritenstatus, usw.) von Medien, die seit mindestens 90 Tagen nicht mehr vorhanden sind.",
|
||||
"Original": "Original",
|
||||
"LyricDownloadFailureFromForItem": "Fehler beim Download der Songtexte von {0} für {1}"
|
||||
"LyricDownloadFailureFromForItem": "Fehler beim Download der Songtexte von {0} für {1}",
|
||||
"NameExtraBehindTheScenes": "Behind The Scenes",
|
||||
"NameExtraDeletedScene": "Entfernte Szene",
|
||||
"NameExtraInterview": "Interview",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Ausschnitt",
|
||||
"NameExtraScene": "Szene",
|
||||
"NameExtraShort": "Kurzfilm",
|
||||
"NameExtraThemeSong": "Titellied",
|
||||
"NameExtraThemeVideo": "Titelvideo",
|
||||
"NameExtraTrailer": "Trailer",
|
||||
"NameExtraUnknown": "Extra",
|
||||
"NameExtraClip": "Clip",
|
||||
"NameExtraFeaturette": "Hinter den Kulissen"
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
"Music": "Music",
|
||||
"MusicVideos": "Music Videos",
|
||||
"NameInstallFailed": "{0} installation failed",
|
||||
"NameSeasonNumber": "Season {0}",
|
||||
"NameSeasonUnknown": "Season Unknown",
|
||||
"NameSeasonNumber": "Series {0}",
|
||||
"NameSeasonUnknown": "Series Unknown",
|
||||
"NewVersionIsAvailable": "A new version of Jellyfin Server is available for download.",
|
||||
"NotificationOptionApplicationUpdateAvailable": "Application update available",
|
||||
"NotificationOptionApplicationUpdateInstalled": "Application update installed",
|
||||
@@ -108,5 +108,18 @@
|
||||
"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.",
|
||||
"LyricDownloadFailureFromForItem": "Lyrics failed to download from {0} for {1}",
|
||||
"Original": "Original"
|
||||
"Original": "Original",
|
||||
"NameExtraBehindTheScenes": "Behind The Scenes",
|
||||
"NameExtraClip": "Clip",
|
||||
"NameExtraDeletedScene": "Deleted Scene",
|
||||
"NameExtraFeaturette": "Featurette",
|
||||
"NameExtraInterview": "Interview",
|
||||
"NameExtraSample": "Sample",
|
||||
"NameExtraScene": "Scene",
|
||||
"NameExtraShort": "Short",
|
||||
"NameExtraThemeSong": "Theme Song",
|
||||
"NameExtraThemeVideo": "Theme Video",
|
||||
"NameExtraTrailer": "Trailer",
|
||||
"NameExtraUnknown": "Extra",
|
||||
"NameExtraNumbered": "{0} {1}"
|
||||
}
|
||||
|
||||
@@ -97,5 +97,9 @@
|
||||
"TaskAudioNormalizationDescription": "Skanas dosierojn por sonnivelaj normaligaj datumoj.",
|
||||
"TaskRefreshTrickplayImages": "Generi la bildojn por TrickPlay (Antaŭrigardo rapida antaŭen)",
|
||||
"TaskAudioNormalization": "Normaligo Sonnivela",
|
||||
"HearingImpaired": "Surda"
|
||||
"HearingImpaired": "Surda",
|
||||
"NameExtraDeletedScene": "Forigita sceno",
|
||||
"NameExtraScene": "Sceno",
|
||||
"NameExtraThemeSong": "Tema Kanto",
|
||||
"Original": "Originala"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, estado de los favoritos, etc.) que no están presentes en la biblioteca por al menos 90 días.",
|
||||
"CleanupUserDataTask": "Tarea de limpieza de datos de usuarios",
|
||||
"LyricDownloadFailureFromForItem": "No se pudo descargar la letra desde {0} para {1}",
|
||||
"Original": "Original"
|
||||
"Original": "Original",
|
||||
"NameExtraBehindTheScenes": "Detrás de cámaras",
|
||||
"NameExtraClip": "Clip",
|
||||
"NameExtraDeletedScene": "Escena eliminada",
|
||||
"NameExtraFeaturette": "Minidocumental",
|
||||
"NameExtraInterview": "Entrevista",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Muestra",
|
||||
"NameExtraScene": "Escena",
|
||||
"NameExtraShort": "Cortometraje",
|
||||
"NameExtraThemeSong": "Música de presentación",
|
||||
"NameExtraThemeVideo": "Video de presentación",
|
||||
"NameExtraTrailer": "Tráiler",
|
||||
"NameExtraUnknown": "Extra"
|
||||
}
|
||||
|
||||
@@ -106,5 +106,20 @@
|
||||
"TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay",
|
||||
"TaskMoveTrickplayImagesDescription": "Mueve archivos de trickplay existentes según la configuración de la biblioteca.",
|
||||
"CleanupUserDataTask": "Tarea de limpieza de los datos del usuario",
|
||||
"CleanupUserDataTaskDescription": "Limpia toda la información de usuario (Estado de última vez visto, favoritos, etc) del archivo media que no está presente por los últimos 90 días."
|
||||
"CleanupUserDataTaskDescription": "Limpia toda la información de usuario (Estado de última vez visto, favoritos, etc) del archivo media que no está presente por los últimos 90 días.",
|
||||
"LyricDownloadFailureFromForItem": "No se pudo descargar la letra desde {0} para {1}",
|
||||
"NameExtraBehindTheScenes": "Detrás de cámaras",
|
||||
"NameExtraClip": "Clip",
|
||||
"NameExtraDeletedScene": "Escena eliminada",
|
||||
"NameExtraFeaturette": "Minidocumental",
|
||||
"NameExtraInterview": "Entrevista",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Muestra",
|
||||
"NameExtraScene": "Escena",
|
||||
"NameExtraShort": "Cortometraje",
|
||||
"NameExtraThemeSong": "Música de presentación",
|
||||
"NameExtraThemeVideo": "Video de presentación",
|
||||
"NameExtraTrailer": "Tráiler",
|
||||
"NameExtraUnknown": "Extra",
|
||||
"Original": "Original"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"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.",
|
||||
"Original": "Original",
|
||||
"LyricDownloadFailureFromForItem": "No se pudieron descargar las letras desde {0} para {1}"
|
||||
"LyricDownloadFailureFromForItem": "No se pudieron descargar las letras desde {0} para {1}",
|
||||
"NameExtraBehindTheScenes": "Detrás de Cámaras",
|
||||
"NameExtraClip": "Clip",
|
||||
"NameExtraDeletedScene": "Escena eliminada",
|
||||
"NameExtraFeaturette": "Reportaje especial",
|
||||
"NameExtraInterview": "Entrevista",
|
||||
"NameExtraSample": "Muestra",
|
||||
"NameExtraScene": "Escena",
|
||||
"NameExtraShort": "Cortometraje",
|
||||
"NameExtraThemeSong": "Tema principal",
|
||||
"NameExtraThemeVideo": "Vídeo del tema principal",
|
||||
"NameExtraTrailer": "Tráiler",
|
||||
"NameExtraUnknown": "Extra",
|
||||
"NameExtraNumbered": "{0} {1}"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"CleanupUserDataTask": "Tarea de limpieza de datos de usuario",
|
||||
"CleanupUserDataTaskDescription": "Limpia todos los datos de usuario (estado de visualización, favoritos, etc.) que no están presentes en la biblioteca por al menos 90 días.",
|
||||
"LyricDownloadFailureFromForItem": "No se pudo descargar las letras de {0} para {1}",
|
||||
"Original": "Original"
|
||||
"Original": "Original",
|
||||
"NameExtraUnknown": "Extra",
|
||||
"NameExtraBehindTheScenes": "Detrás de cámaras",
|
||||
"NameExtraClip": "Clip",
|
||||
"NameExtraDeletedScene": "Escena eliminada",
|
||||
"NameExtraFeaturette": "Minidocumental",
|
||||
"NameExtraInterview": "Entrevista",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Muestra",
|
||||
"NameExtraScene": "Escena",
|
||||
"NameExtraShort": "Cortometraje",
|
||||
"NameExtraThemeSong": "Música de presentación",
|
||||
"NameExtraThemeVideo": "Video de presentación",
|
||||
"NameExtraTrailer": "Tráiler"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"CleanupUserDataTask": "Puhasta kasutajaandmed",
|
||||
"CleanupUserDataTaskDescription": "Puhastab kõik kasutajaandmed (vaatamise olek, lemmikute olek jne) meediast, mida pole enam vähemalt 90 päeva saadaval olnud.",
|
||||
"LyricDownloadFailureFromForItem": "Laulusõnade hankimine teenusest {0} loole {1} nurjus",
|
||||
"Original": "Algne"
|
||||
"Original": "Algne",
|
||||
"NameExtraBehindTheScenes": "Kulisside taga",
|
||||
"NameExtraDeletedScene": "Väljajäetud stseen",
|
||||
"NameExtraFeaturette": "Lisalõik",
|
||||
"NameExtraInterview": "Intervjuu",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Näidis",
|
||||
"NameExtraScene": "Stseen",
|
||||
"NameExtraShort": "Lühifilm",
|
||||
"NameExtraThemeSong": "Tunnusmeloodia",
|
||||
"NameExtraThemeVideo": "Tunnusvideo",
|
||||
"NameExtraTrailer": "Treiler",
|
||||
"NameExtraUnknown": "Lisamaterjal",
|
||||
"NameExtraClip": "Videoklipp"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"CleanupUserDataTaskDescription": "Gutxienez 90 egunez dagoeneko existitzen ez den multimediatik erabiltzaile-datu guztiak (ikusteko egoera, gogokoen egoera, etab.) garbitzen ditu.",
|
||||
"CleanupUserDataTask": "Erabiltzaileen datuak garbitzeko zeregina",
|
||||
"LyricDownloadFailureFromForItem": "Ezin izan dira {1}-ren letrak deskargatu {0}-tik",
|
||||
"Original": "Jatorrizkoa"
|
||||
"Original": "Jatorrizkoa",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Lagina",
|
||||
"NameExtraScene": "Eszena",
|
||||
"NameExtraShort": "Laburra",
|
||||
"NameExtraThemeSong": "Gai-abestia",
|
||||
"NameExtraThemeVideo": "Gai-bideoa",
|
||||
"NameExtraBehindTheScenes": "Eszenen atzean",
|
||||
"NameExtraClip": "Klipa",
|
||||
"NameExtraDeletedScene": "Ezabatutako eszena",
|
||||
"NameExtraFeaturette": "Erreportajea",
|
||||
"NameExtraInterview": "Elkarrizketa",
|
||||
"NameExtraTrailer": "Trailerra",
|
||||
"NameExtraUnknown": "Extra"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,10 @@
|
||||
"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.",
|
||||
"LyricDownloadFailureFromForItem": "Sanoitusten lataus kohteesta {0} kappaleelle {1} epäonnistui",
|
||||
"Original": "Alkuperäinen"
|
||||
"Original": "Alkuperäinen",
|
||||
"NameExtraBehindTheScenes": "Kulissien Takana",
|
||||
"NameExtraClip": "Klippi",
|
||||
"NameExtraDeletedScene": "Poistettu Kohtaus",
|
||||
"NameExtraFeaturette": "Lyhytelokuva",
|
||||
"NameExtraInterview": "Haastattelu"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"Artists": "Tónlistafólk",
|
||||
"Collections": "Søvn",
|
||||
"Collections": "Samlingar",
|
||||
"Default": "Forsett",
|
||||
"External": "Ytri",
|
||||
"Genres": "Greinar",
|
||||
@@ -13,7 +13,7 @@
|
||||
"FailedLoginAttemptWithUserName": "Miseydnað innritanarroynd frá {0}",
|
||||
"HeaderFavoriteEpisodes": "Yndispartar",
|
||||
"LabelIpAddressValue": "IP-atsetur: {0}",
|
||||
"AuthenticationSucceededWithUserName": "{0} varð samgildur",
|
||||
"AuthenticationSucceededWithUserName": "{0} var samgildur",
|
||||
"HeaderFavoriteShows": "Yndisrøðir",
|
||||
"HeaderLiveTV": "Beinleiðis sjónvarp",
|
||||
"HearingImpaired": "Hoyrnarveik",
|
||||
@@ -55,8 +55,8 @@
|
||||
"TaskUpdatePluginsDescription": "Niðurtekur og innleggur dagføringar til ískoytisforrit ið eru stillaði til at dagførast sjálvvirkandi.",
|
||||
"TaskCleanTranscodeDescription": "Strikar umkotaðar fílar ið eru eldri enn 1 dag.",
|
||||
"TaskOptimizeDatabase": "Albøt dátugrunn",
|
||||
"NameSeasonNumber": "Sesong {0}",
|
||||
"NameSeasonUnknown": "Ókend sesong",
|
||||
"NameSeasonNumber": "Umfar {0}",
|
||||
"NameSeasonUnknown": "Ókent umfar",
|
||||
"ScheduledTaskFailedWithName": "{0} miseydnaðist",
|
||||
"Undefined": "Óskilmarkað",
|
||||
"TasksMaintenanceCategory": "Viðlíkahald",
|
||||
@@ -68,7 +68,7 @@
|
||||
"NotificationOptionServerRestartRequired": "Tørvur er á ambætaraendurbyrjan",
|
||||
"TasksApplicationCategory": "Nýtsluskipan",
|
||||
"NotificationOptionApplicationUpdateAvailable": "Skipanardagføring er tøk",
|
||||
"NotificationOptionApplicationUpdateInstalled": "Skipanardagføring varð innløgd",
|
||||
"NotificationOptionApplicationUpdateInstalled": "Skipanardagføring var innløgd",
|
||||
"UserStoppedPlayingItemWithValues": "{0} er liðugur at spæla {1} á {2}",
|
||||
"HomeVideos": "Heimaupptøkur",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin-ambætarin er undir byrjanarinnlesing. Vinaliga royn aftur um eitt bil.",
|
||||
@@ -101,5 +101,25 @@
|
||||
"NotificationOptionAudioPlaybackStopped": "Ljóðspæl steðgað",
|
||||
"TaskExtractMediaSegments": "Leita eftir margmiðlabrotum",
|
||||
"TaskExtractMediaSegmentsDescription": "Framleiður upplýsingar um brot í margmiðlum, við hjálp frá MediaSegment-virktum ískoytisforritum.",
|
||||
"NotificationOptionCameraImageUploaded": "Ljósmynd uppsent"
|
||||
"NotificationOptionCameraImageUploaded": "Ljósmynd uppsend",
|
||||
"NameExtraShort": "Stuttfilmur",
|
||||
"NameExtraThemeSong": "Eyðkennislag",
|
||||
"NameExtraTrailer": "Forfilmur",
|
||||
"NameExtraInterview": "Samrøða",
|
||||
"NameExtraBehindTheScenes": "Aftanfyri leiktjøldini",
|
||||
"NameExtraClip": "Klipp",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraFeaturette": "Stuttur heimildarfilmur",
|
||||
"TaskAudioNormalization": "Ljóðjavnan",
|
||||
"TaskAudioNormalizationDescription": "Kannar fílur fyri dátum til ljóðjavnan.",
|
||||
"NameExtraSample": "Kut",
|
||||
"TaskRefreshTrickplayImages": "Framleið Trickplay-myndir",
|
||||
"TaskRefreshTrickplayImagesDescription": "Framleiðir trickplay-myndir fyri kykmyndir í søvnunm har tað er virkt.",
|
||||
"TaskMoveTrickplayImages": "Flyt Trickplay-myndagoymslustað",
|
||||
"TaskMoveTrickplayImagesDescription": "Flytur verandi trickplay-fílur sambært savnsstillingunum.",
|
||||
"NameExtraThemeVideo": "Eyðkenniskykmynd",
|
||||
"NameExtraDeletedScene": "Úrtikin mynd",
|
||||
"NameExtraScene": "Mynd (scena)",
|
||||
"NameExtraUnknown": "Eykatilfar",
|
||||
"Original": "Upprunalig(t/ur)"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"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",
|
||||
"LyricDownloadFailureFromForItem": "Le téléchargement des paroles a échoué de {0} pour {1}",
|
||||
"Original": "Original"
|
||||
"Original": "Original",
|
||||
"NameExtraBehindTheScenes": "Dans Les Coulisses",
|
||||
"NameExtraClip": "Clip",
|
||||
"NameExtraDeletedScene": "Scène supprimée",
|
||||
"NameExtraFeaturette": "Court-métrage",
|
||||
"NameExtraInterview": "Entrevue",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Échantillon",
|
||||
"NameExtraScene": "Scène",
|
||||
"NameExtraShort": "Court-métrage",
|
||||
"NameExtraThemeSong": "Chanson thème",
|
||||
"NameExtraThemeVideo": "Générique",
|
||||
"NameExtraTrailer": "Bande-annonce",
|
||||
"NameExtraUnknown": "Extra"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"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",
|
||||
"LyricDownloadFailureFromForItem": "Le téléchargement des paroles à échoué de {0} pour {1}",
|
||||
"Original": "Original"
|
||||
"Original": "Original",
|
||||
"NameExtraBehindTheScenes": "Dans Les Coulisses",
|
||||
"NameExtraClip": "Clip",
|
||||
"NameExtraDeletedScene": "Scène supprimée",
|
||||
"NameExtraFeaturette": "Court-métrage",
|
||||
"NameExtraInterview": "Entrevue",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Échantillon",
|
||||
"NameExtraScene": "Scène",
|
||||
"NameExtraShort": "Court-métrage",
|
||||
"NameExtraThemeSong": "Thème musical",
|
||||
"NameExtraThemeVideo": "Générique",
|
||||
"NameExtraTrailer": "Bande-annonce",
|
||||
"NameExtraUnknown": "Extra"
|
||||
}
|
||||
|
||||
@@ -107,5 +107,19 @@
|
||||
"TaskAudioNormalizationDescription": "Escanea ficheiros á procura de datos de normalización de volume.",
|
||||
"CleanupUserDataTask": "Tarefa de limpeza de datos dos usuarios",
|
||||
"CleanupUserDataTaskDescription": "Limpa todos os datos do usuario (estado de visualización, de favorito etc.) dos medios ausentes polo menos 90 días.",
|
||||
"Original": "Orixinal"
|
||||
"Original": "Orixinal",
|
||||
"LyricDownloadFailureFromForItem": "Non se puideron descargar as letras desde {0} para {1}",
|
||||
"NameExtraBehindTheScenes": "Detrás das Cámaras",
|
||||
"NameExtraClip": "Clip",
|
||||
"NameExtraDeletedScene": "Escena Eliminada",
|
||||
"NameExtraFeaturette": "Reportaxe especial",
|
||||
"NameExtraInterview": "Entrevista",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Mostra",
|
||||
"NameExtraScene": "Escena",
|
||||
"NameExtraShort": "Curtametraxe",
|
||||
"NameExtraThemeSong": "Canción principal",
|
||||
"NameExtraThemeVideo": "Vídeo da canción principal",
|
||||
"NameExtraTrailer": "Tráiler",
|
||||
"NameExtraUnknown": "Extra"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,12 @@
|
||||
"CleanupUserDataTaskDescription": "ניקוי כל המידע של המשתמש (מצב צפייה, מועדפים וכו) ממדיה שאינה קיימת מעל 90 יום.",
|
||||
"CleanupUserDataTask": "משימת ניקוי מידע משתמש",
|
||||
"LyricDownloadFailureFromForItem": "הורדת המילים מ-{0} עבור {1} נכשלה",
|
||||
"Original": "מקור"
|
||||
"Original": "מקור",
|
||||
"NameExtraBehindTheScenes": "מאחורי הקלעים",
|
||||
"NameExtraClip": "קליפ",
|
||||
"NameExtraDeletedScene": "סצנה שנמחקה",
|
||||
"NameExtraFeaturette": "סרט קצר",
|
||||
"NameExtraInterview": "ריאיון",
|
||||
"NameExtraSample": "דגימה",
|
||||
"NameExtraScene": "סצנה"
|
||||
}
|
||||
|
||||
@@ -106,5 +106,20 @@
|
||||
"TaskMoveTrickplayImages": "ट्रिकप्ले छवि स्थान माइग्रेट करें",
|
||||
"TaskMoveTrickplayImagesDescription": "लाइब्रेरी सेटिंग्स के अनुसार मौजूदा ट्रिकप्ले फ़ाइलों को स्थानांतरित करता है।",
|
||||
"CleanupUserDataTask": "यूज़र डेटा सफाई कार्य",
|
||||
"Original": "असली"
|
||||
"Original": "असली",
|
||||
"LyricDownloadFailureFromForItem": "{0} के लिए {1} से बोल (Lyrics) डाउनलोड करने में विफल रहा",
|
||||
"NameExtraBehindTheScenes": "परदे के पीछे",
|
||||
"NameExtraClip": "क्लिप",
|
||||
"NameExtraDeletedScene": "हटाया गया दृश्य",
|
||||
"NameExtraFeaturette": "फीचरेट",
|
||||
"NameExtraInterview": "साक्षात्कार",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "नमूना",
|
||||
"NameExtraScene": "दृश्य",
|
||||
"NameExtraShort": "शॉर्ट",
|
||||
"NameExtraThemeSong": "थीम सॉन्ग",
|
||||
"NameExtraThemeVideo": "थीम वीडियो",
|
||||
"NameExtraTrailer": "ट्रेलर",
|
||||
"NameExtraUnknown": "अतिरिक्त",
|
||||
"CleanupUserDataTaskDescription": "कम से कम 90 दिनों से अनुपस्थित मीडिया से सभी उपयोगकर्ता डेटा (देखने की स्थिति, पसंदीदा स्थिति आदि) को साफ़ करता है।"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"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",
|
||||
"Original": "Eredeti",
|
||||
"LyricDownloadFailureFromForItem": "Dalszöveg letöltése {0}-tól {1}-hez sikertelen"
|
||||
"LyricDownloadFailureFromForItem": "Dalszöveg letöltése {0}-tól {1}-hez sikertelen",
|
||||
"NameExtraBehindTheScenes": "Színfalak mögött",
|
||||
"NameExtraClip": "Klip",
|
||||
"NameExtraDeletedScene": "Törölt jelenet",
|
||||
"NameExtraFeaturette": "Kísérő film",
|
||||
"NameExtraInterview": "Interjú",
|
||||
"NameExtraSample": "Minta",
|
||||
"NameExtraScene": "Jelenet",
|
||||
"NameExtraShort": "Rövidfilm",
|
||||
"NameExtraThemeSong": "Főcímdal",
|
||||
"NameExtraThemeVideo": "Főcímvideó",
|
||||
"NameExtraTrailer": "Előzetes",
|
||||
"NameExtraUnknown": "Extra",
|
||||
"NameExtraNumbered": "{0} {1}"
|
||||
}
|
||||
|
||||
@@ -24,5 +24,10 @@
|
||||
"TaskDownloadMissingSubtitles": "Ներբեռնել պակասող ենթագրերը",
|
||||
"AppDeviceValues": "Հավելված` {0}, Սարք `{1}",
|
||||
"ChapterNameValue": "Գլուխ {0}",
|
||||
"Collections": "Հավաքածուներ"
|
||||
"Collections": "Հավաքածուներ",
|
||||
"Artists": "Երաժիշտներ",
|
||||
"Default": "Լռելյայն",
|
||||
"Favorites": "Ընտրյալ",
|
||||
"Forced": "Ստիպուած",
|
||||
"Genres": "Ոճ"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"Original": "Upprunaleg",
|
||||
"TaskExtractMediaSegmentsDescription": "Sækir myndbúta úr viðbótum þar sem MediaSegment er virkt.",
|
||||
"TaskMoveTrickplayImages": "Flytja geymslustað fyrir Trickplay-myndir",
|
||||
"TaskMoveTrickplayImagesDescription": "Flytur fyrirliggjandi Trickplay-skrár í samræmi við stillingar safnsins."
|
||||
"TaskMoveTrickplayImagesDescription": "Flytur fyrirliggjandi Trickplay-skrár í samræmi við stillingar safnsins.",
|
||||
"NameExtraBehindTheScenes": "Bak við tjöldin",
|
||||
"NameExtraClip": "Brot",
|
||||
"NameExtraDeletedScene": "Eydd atriði",
|
||||
"NameExtraFeaturette": "Stutt heimildarmynd",
|
||||
"NameExtraInterview": "Viðtal",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Sýnishorn",
|
||||
"NameExtraScene": "Sena",
|
||||
"NameExtraShort": "Stuttmynd",
|
||||
"NameExtraThemeSong": "Þema lag",
|
||||
"NameExtraThemeVideo": "Þema myndband",
|
||||
"NameExtraTrailer": "Stikla",
|
||||
"NameExtraUnknown": "Aukaefni"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"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.",
|
||||
"Original": "Originale",
|
||||
"LyricDownloadFailureFromForItem": "Scaricamento dei testi non riuscito da {0} per {1}"
|
||||
"LyricDownloadFailureFromForItem": "Scaricamento dei testi non riuscito da {0} per {1}",
|
||||
"NameExtraBehindTheScenes": "Dietro le scene",
|
||||
"NameExtraClip": "Filmato",
|
||||
"NameExtraDeletedScene": "Scena eliminata",
|
||||
"NameExtraInterview": "Intervista",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraScene": "Scena",
|
||||
"NameExtraSample": "Campione",
|
||||
"NameExtraShort": "Corto",
|
||||
"NameExtraThemeSong": "Sigla musicale",
|
||||
"NameExtraTrailer": "Trailer",
|
||||
"NameExtraFeaturette": "Caratteristica",
|
||||
"NameExtraThemeVideo": "Video tematico",
|
||||
"NameExtraUnknown": "Extra"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,8 @@
|
||||
"CleanupUserDataTask": "사용자 데이터 정리 작업",
|
||||
"CleanupUserDataTaskDescription": "최소 90일 이상 존재하지 않는 미디어에 대한 사용자 데이터(시청 상태, 즐겨찾기 등)를 정리합니다.",
|
||||
"LyricDownloadFailureFromForItem": "{1}에 대한 가사를 {0}에서 다운로드하지 못했습니다",
|
||||
"Original": "원본"
|
||||
"Original": "원본",
|
||||
"NameExtraClip": "클립",
|
||||
"NameExtraDeletedScene": "삭제된 장면",
|
||||
"NameExtraInterview": "인터뷰"
|
||||
}
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
"Artists": "Atlikėjai",
|
||||
"AuthenticationSucceededWithUserName": "{0} sėkmingai autentifikuota",
|
||||
"Books": "Knygos",
|
||||
"ChapterNameValue": "Scena{0}",
|
||||
"Collections": "Rinkiniai",
|
||||
"ChapterNameValue": "Skyrius{0}",
|
||||
"Collections": "Kolekcijos",
|
||||
"FailedLoginAttemptWithUserName": "Nesėkmingas {0} bandymas prisijungti",
|
||||
"Favorites": "Mėgstami",
|
||||
"Folders": "Katalogai",
|
||||
"Folders": "Aplankai",
|
||||
"Genres": "Žanrai",
|
||||
"HeaderContinueWatching": "Žiūrėti toliau",
|
||||
"HeaderFavoriteEpisodes": "Mėgstamiausios serijos",
|
||||
"HeaderFavoriteShows": "Mėgstamiausios TV Laidos",
|
||||
"HeaderFavoriteEpisodes": "Mėgstami Epizodai",
|
||||
"HeaderFavoriteShows": "Mėgstamos TV Laidos",
|
||||
"HeaderLiveTV": "Tiesioginė TV",
|
||||
"HeaderNextUp": "Toliau",
|
||||
"HomeVideos": "Namų vaizdo įrašai",
|
||||
@@ -26,14 +26,14 @@
|
||||
"NameInstallFailed": "{0} diegimo klaida",
|
||||
"NameSeasonNumber": "Sezonas {0}",
|
||||
"NameSeasonUnknown": "Sezonas neatpažintas",
|
||||
"NewVersionIsAvailable": "Nauja \"Jellyfin Server\" versija yra prieinama atsisiuntimui.",
|
||||
"NewVersionIsAvailable": "Nauja Jellyfin Server versija yra prieinama atsisiuntimui.",
|
||||
"NotificationOptionApplicationUpdateAvailable": "Galimi programos atnaujinimai",
|
||||
"NotificationOptionApplicationUpdateInstalled": "Programos atnaujinimai įdiegti",
|
||||
"NotificationOptionAudioPlayback": "Garso atkūrimas pradėtas",
|
||||
"NotificationOptionAudioPlaybackStopped": "Garso atkūrimas sustabdytas",
|
||||
"NotificationOptionCameraImageUploaded": "Kameros vaizdai įkelti",
|
||||
"NotificationOptionCameraImageUploaded": "Kameros atvaizdai įkelti",
|
||||
"NotificationOptionInstallationFailed": "Diegimo klaida",
|
||||
"NotificationOptionNewLibraryContent": "Naujas turinys įkeltas",
|
||||
"NotificationOptionNewLibraryContent": "Pridėtas naujas turinys",
|
||||
"NotificationOptionPluginError": "Įskiepio klaida",
|
||||
"NotificationOptionPluginInstalled": "Įskiepis įdiegtas",
|
||||
"NotificationOptionPluginUninstalled": "Įskiepis išdiegtas",
|
||||
@@ -51,7 +51,7 @@
|
||||
"Shows": "Laidos",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin Server kraunasi. Netrukus pabandykite dar kartą.",
|
||||
"SubtitleDownloadFailureFromForItem": "{1} subtitrai buvo nesėkmingai parsiųsti iš {0}",
|
||||
"TvShows": "TV laidos",
|
||||
"TvShows": "TV Laidos",
|
||||
"UserCreatedWithName": "Buvo sukurtas {0} naudotojas",
|
||||
"UserDeletedWithName": "Naudotojas {0} ištrintas",
|
||||
"UserDownloadingItemWithValues": "{0} siunčiasi {1}",
|
||||
@@ -59,14 +59,14 @@
|
||||
"UserOfflineFromDevice": "{0} buvo atjungtas nuo {1}",
|
||||
"UserOnlineFromDevice": "{0} prisijungęs iš {1}",
|
||||
"UserPasswordChangedWithName": "Slaptažodis pakeistas naudotojui {0}",
|
||||
"UserStartedPlayingItemWithValues": "{0} leidžia {1} į {2}",
|
||||
"UserStoppedPlayingItemWithValues": "{0} baigė leisti {1} į {2}",
|
||||
"UserStartedPlayingItemWithValues": "{0} atkuriama {1} į {2}",
|
||||
"UserStoppedPlayingItemWithValues": "{0} baigė atkūrimą {1} į {2}",
|
||||
"VersionNumber": "Versija {0}",
|
||||
"TaskUpdatePluginsDescription": "Atsisiunčia ir įdiegia įskiepių, kurie sukonfigūruoti atnaujinti automatiškai, naujinius.",
|
||||
"TaskUpdatePlugins": "Atnaujinti įskieius",
|
||||
"TaskDownloadMissingSubtitlesDescription": "Ieško trūkstamų subtitrų internete remiantis metaduomenų konfigūracija.",
|
||||
"TaskCleanTranscodeDescription": "Ištrina dienos senumo perkodavimo failus.",
|
||||
"TaskCleanTranscode": "Išvalyti perkodavimo katalogą",
|
||||
"TaskCleanTranscodeDescription": "Ištrina dienos senumo transkodavimo failus.",
|
||||
"TaskCleanTranscode": "Išvalyti transkodavimo katalogą",
|
||||
"TaskRefreshLibraryDescription": "Skenuoja medijos biblioteką, ieškodamas naujų failų, ir atnaujina metaduomenis.",
|
||||
"TaskRefreshLibrary": "Skenuoti medijos biblioteką",
|
||||
"TaskDownloadMissingSubtitles": "Atsisiųsti trūkstamus subtitrus",
|
||||
@@ -76,8 +76,8 @@
|
||||
"TaskRefreshPeople": "Atnaujinti žmones",
|
||||
"TaskCleanLogsDescription": "Ištrina žurnalo failus kurie yra senesni nei {0} dienos.",
|
||||
"TaskCleanLogs": "Išvalyti žurnalą",
|
||||
"TaskRefreshChapterImagesDescription": "Sukuria vaizdo įrašų, kuriuose yra skyrių, miniatiūras.",
|
||||
"TaskRefreshChapterImages": "Ištraukti skyrių vaizdus",
|
||||
"TaskRefreshChapterImagesDescription": "Sukuria miniatiūras vaizdo įrašams, kuriuose yra skyriai.",
|
||||
"TaskRefreshChapterImages": "Ištraukti skyrių atvaizdus",
|
||||
"TaskCleanCache": "Išvalyti talpyklą",
|
||||
"TaskCleanCacheDescription": "Ištrina talpyklos failus, kurių daugiau nereikia sistemai.",
|
||||
"TasksChannelsCategory": "Internetiniai kanalai",
|
||||
@@ -87,7 +87,7 @@
|
||||
"TaskCleanActivityLog": "Išvalyti veiklos žurnalą",
|
||||
"Undefined": "Neapibrėžtas",
|
||||
"Forced": "Priverstinis",
|
||||
"Default": "Numatytas",
|
||||
"Default": "Numatytasis",
|
||||
"TaskCleanActivityLogDescription": "Ištrina senesnius nei nustatytas amžius veiklos žurnalo įrašus.",
|
||||
"TaskOptimizeDatabase": "Optimizuoti duomenų bazę",
|
||||
"TaskKeyframeExtractorDescription": "Iš vaizdo įrašo paruošia reikšminius kadrus, kad būtų sukuriamas tikslenis HLS grojaraštis. Šios užduoties vykdymas gali ilgai užtrukti.",
|
||||
@@ -96,15 +96,30 @@
|
||||
"External": "Išorinis",
|
||||
"HearingImpaired": "Su klausos sutrikimais",
|
||||
"TaskRefreshTrickplayImages": "Generuoti Trickplay atvaizdus",
|
||||
"TaskRefreshTrickplayImagesDescription": "Sukuria trickplay peržiūras vaizdo įrašams įgalintose bibliotekose.",
|
||||
"TaskRefreshTrickplayImagesDescription": "Sukuria vaizdo įrašų, esančių įgalintose bibliotekose, Trickplay peržiūras.",
|
||||
"TaskAudioNormalization": "Garso normalizavimas",
|
||||
"TaskAudioNormalizationDescription": "Skenuoja failus, ieškant garso normalizavimo duomenų.",
|
||||
"TaskExtractMediaSegments": "Medijos segmentų nuskaitymas",
|
||||
"TaskDownloadMissingLyrics": "Parsisiųsti trūkstamus dainų tekstus",
|
||||
"TaskExtractMediaSegmentsDescription": "Ištraukia arba gauna medijos segmentus iš MediaSegment ijungtų įskiepių.",
|
||||
"TaskMoveTrickplayImages": "Pakeisti Trickplay vaizdų vietą",
|
||||
"TaskMoveTrickplayImagesDescription": "Perkelia egzistuojančius trickplay failus pagal bibliotekos nustatymus.",
|
||||
"TaskMoveTrickplayImages": "Pakeisti Trickplay atvaizdų vietą",
|
||||
"TaskMoveTrickplayImagesDescription": "Perkelia egzistuojančius Trickplay failus pagal bibliotekos nustatymus.",
|
||||
"TaskDownloadMissingLyricsDescription": "Parsisiųsti dainų žodžius",
|
||||
"CleanupUserDataTask": "Naudotojo duomenų valymo užduotis",
|
||||
"CleanupUserDataTaskDescription": "Iš medijos, kurios nebėra bent 90 dienų, išvalo visus naudotojo duomenis (žiūrėjimo būseną, mėgstamiausią būseną ir t. t.)."
|
||||
"CleanupUserDataTaskDescription": "Iš medijos, kurios nebėra bent 90 dienų, išvalo visus naudotojo duomenis (žiūrėjimo būseną, mėgstamą būseną ir t. t.).",
|
||||
"LyricDownloadFailureFromForItem": "Nepavyko atsisiųsti dainos žodžių iš {0}, skirto {1}",
|
||||
"NameExtraBehindTheScenes": "Užkulisiuose",
|
||||
"NameExtraClip": "Klipas",
|
||||
"NameExtraDeletedScene": "Ištrinta scena",
|
||||
"NameExtraInterview": "Interviu",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Pavyzdys",
|
||||
"NameExtraScene": "Scena",
|
||||
"NameExtraThemeSong": "Teminė daina",
|
||||
"NameExtraThemeVideo": "Teminis vaizdo įrašas",
|
||||
"NameExtraTrailer": "Anonsas",
|
||||
"NameExtraUnknown": "Papildomas",
|
||||
"Original": "Originalus",
|
||||
"NameExtraFeaturette": "Trumpametražis filmas",
|
||||
"NameExtraShort": "Trumpas filmukas"
|
||||
}
|
||||
|
||||
@@ -106,5 +106,11 @@
|
||||
"TaskAudioNormalization": "Normalisasi Audio",
|
||||
"TaskAudioNormalizationDescription": "Mengimbas fail-fail untuk data normalisasi audio.",
|
||||
"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"
|
||||
"CleanupUserDataTask": "Tugas pembersihan data pengguna",
|
||||
"LyricDownloadFailureFromForItem": "Lirik gagal dimuat turun dari {0} untuk {1}",
|
||||
"NameExtraBehindTheScenes": "Di Sebalik Takbir",
|
||||
"NameExtraClip": "Klip",
|
||||
"NameExtraInterview": "Temu bual",
|
||||
"NameExtraThemeSong": "Lagu Tema",
|
||||
"NameExtraThemeVideo": "Video Tema"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"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",
|
||||
"Original": "Oryginalny",
|
||||
"LyricDownloadFailureFromForItem": "Błąd podczas pobierania tekstu piosenki z {0} dla {1}"
|
||||
"LyricDownloadFailureFromForItem": "Błąd podczas pobierania tekstu piosenki z {0} dla {1}",
|
||||
"NameExtraBehindTheScenes": "Za kulisami",
|
||||
"NameExtraClip": "Urywek",
|
||||
"NameExtraDeletedScene": "Usunięta scena",
|
||||
"NameExtraFeaturette": "Film średniometrażowy",
|
||||
"NameExtraInterview": "Wywiad",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Fragment",
|
||||
"NameExtraScene": "Scena",
|
||||
"NameExtraShort": "Film krótkometrażowy",
|
||||
"NameExtraThemeSong": "Czołówka",
|
||||
"NameExtraThemeVideo": "Wideo wprowadzające",
|
||||
"NameExtraTrailer": "Zwiastun",
|
||||
"NameExtraUnknown": "Dodatek"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"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.",
|
||||
"LyricDownloadFailureFromForItem": "Download das Letras falharam em {0} para o item {1}",
|
||||
"Original": "Original"
|
||||
"Original": "Original",
|
||||
"NameExtraBehindTheScenes": "Nos Bastidores",
|
||||
"NameExtraClip": "Clipe",
|
||||
"NameExtraDeletedScene": "cena Extra",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Trecho",
|
||||
"NameExtraScene": "Cena",
|
||||
"NameExtraShort": "Curta-metragem",
|
||||
"NameExtraThemeSong": "Música Tema",
|
||||
"NameExtraThemeVideo": "Vídeo de Abertura",
|
||||
"NameExtraTrailer": "Trailer",
|
||||
"NameExtraUnknown": "Extra",
|
||||
"NameExtraFeaturette": "Nos Bastidores",
|
||||
"NameExtraInterview": "Entrevista"
|
||||
}
|
||||
|
||||
@@ -5,108 +5,121 @@
|
||||
"Books": "Livros",
|
||||
"ChapterNameValue": "Capítulo {0}",
|
||||
"Collections": "Coleções",
|
||||
"FailedLoginAttemptWithUserName": "Tentativa de login falhada a partir de {0}",
|
||||
"FailedLoginAttemptWithUserName": "Tentativa falhada de início de sessão do utilizador {0}",
|
||||
"Favorites": "Favoritos",
|
||||
"Folders": "Pastas",
|
||||
"Genres": "Géneros",
|
||||
"HeaderContinueWatching": "Continuar a ver",
|
||||
"HeaderFavoriteEpisodes": "Episódios Favoritos",
|
||||
"HeaderFavoriteShows": "Séries Favoritas",
|
||||
"HeaderLiveTV": "TV em Direto",
|
||||
"HeaderNextUp": "A Seguir",
|
||||
"HomeVideos": "Vídeos Caseiros",
|
||||
"HeaderFavoriteEpisodes": "Episódios favoritos",
|
||||
"HeaderFavoriteShows": "Séries favoritas",
|
||||
"HeaderLiveTV": "TV em direto",
|
||||
"HeaderNextUp": "A seguir",
|
||||
"HomeVideos": "Vídeos caseiros",
|
||||
"Inherit": "Herdar",
|
||||
"LabelIpAddressValue": "Endereço IP: {0}",
|
||||
"LabelRunningTimeValue": "Duração: {0}",
|
||||
"Latest": "Mais Recente",
|
||||
"MixedContent": "Conteúdo Misto",
|
||||
"Latest": "Mais recente",
|
||||
"MixedContent": "Conteúdo misto",
|
||||
"Movies": "Filmes",
|
||||
"Music": "Música",
|
||||
"MusicVideos": "Videoclipes",
|
||||
"NameInstallFailed": "{0} falha na instalação",
|
||||
"NameInstallFailed": "Falha na instalação de {0}",
|
||||
"NameSeasonNumber": "Temporada {0}",
|
||||
"NameSeasonUnknown": "Temporada Desconhecida",
|
||||
"NameSeasonUnknown": "Temporada desconhecida",
|
||||
"NewVersionIsAvailable": "Está disponível para transferência uma nova versão do servidor Jellyfin.",
|
||||
"NotificationOptionApplicationUpdateAvailable": "Atualização de aplicação disponível",
|
||||
"NotificationOptionApplicationUpdateInstalled": "Atualização de aplicação instalada",
|
||||
"NotificationOptionAudioPlayback": "Reprodução Iniciada",
|
||||
"NotificationOptionAudioPlaybackStopped": "Reprodução Parada",
|
||||
"NotificationOptionAudioPlayback": "Reprodução de áudio iniciada",
|
||||
"NotificationOptionAudioPlaybackStopped": "Reprodução de áudio interrompida",
|
||||
"NotificationOptionCameraImageUploaded": "Imagem da câmara enviada",
|
||||
"NotificationOptionInstallationFailed": "Falha na instalação",
|
||||
"NotificationOptionNewLibraryContent": "Novo conteúdo adicionado",
|
||||
"NotificationOptionPluginError": "Falha na extensão",
|
||||
"NotificationOptionPluginInstalled": "Extensão instalada",
|
||||
"NotificationOptionPluginUninstalled": "Extensão desinstalada",
|
||||
"NotificationOptionPluginUpdateInstalled": "Extensão atualizada",
|
||||
"NotificationOptionPluginError": "Falha no plugin",
|
||||
"NotificationOptionPluginInstalled": "Plugin instalado",
|
||||
"NotificationOptionPluginUninstalled": "Plugin desinstalado",
|
||||
"NotificationOptionPluginUpdateInstalled": "Atualização de plugin instalada",
|
||||
"NotificationOptionServerRestartRequired": "Necessário reiniciar o servidor",
|
||||
"NotificationOptionTaskFailed": "Falha em tarefa agendada",
|
||||
"NotificationOptionUserLockedOut": "Utilizador bloqueado",
|
||||
"NotificationOptionVideoPlayback": "Reprodução do vídeo iniciada",
|
||||
"NotificationOptionVideoPlaybackStopped": "Reprodução do vídeo parada",
|
||||
"NotificationOptionVideoPlayback": "Reprodução de vídeo iniciada",
|
||||
"NotificationOptionVideoPlaybackStopped": "Reprodução de vídeo interrompida",
|
||||
"Photos": "Fotografias",
|
||||
"PluginInstalledWithName": "{0} foi instalado",
|
||||
"PluginUninstalledWithName": "{0} foi desinstalado",
|
||||
"PluginUpdatedWithName": "{0} foi atualizado",
|
||||
"ScheduledTaskFailedWithName": "{0} falhou",
|
||||
"Shows": "Séries",
|
||||
"StartupEmbyServerIsLoading": "O servidor Jellyfin está a iniciar. Tente novamente mais tarde.",
|
||||
"SubtitleDownloadFailureFromForItem": "Falha na transferência de legendas a partir de {0} para {1}",
|
||||
"TvShows": "Séries",
|
||||
"StartupEmbyServerIsLoading": "O servidor Jellyfin está a iniciar. Tenta novamente dentro de instantes.",
|
||||
"SubtitleDownloadFailureFromForItem": "Falha ao transferir legendas de {0} para {1}",
|
||||
"TvShows": "Séries de TV",
|
||||
"UserCreatedWithName": "Utilizador {0} criado",
|
||||
"UserDeletedWithName": "Utilizador {0} apagado",
|
||||
"UserDeletedWithName": "Utilizador {0} eliminado",
|
||||
"UserDownloadingItemWithValues": "{0} está a transferir {1}",
|
||||
"UserLockedOutWithName": "Utilizador {0} bloqueado",
|
||||
"UserOfflineFromDevice": "{0} desligou-se a partir de {1}",
|
||||
"UserOnlineFromDevice": "{0} ligou-se a partir de {1}",
|
||||
"UserOfflineFromDevice": "{0} desligou-se de {1}",
|
||||
"UserOnlineFromDevice": "{0} está online em {1}",
|
||||
"UserPasswordChangedWithName": "Palavra-passe alterada para o utilizador {0}",
|
||||
"UserStartedPlayingItemWithValues": "{0} está a reproduzir {1} em {2}",
|
||||
"UserStoppedPlayingItemWithValues": "{0} terminou a reprodução de {1} em {2}",
|
||||
"VersionNumber": "Versão {0}",
|
||||
"TaskDownloadMissingSubtitlesDescription": "Procurar na internet por legendas em falta baseado na configuração de metadados.",
|
||||
"TaskDownloadMissingSubtitlesDescription": "Procura na Internet legendas em falta com base na configuração dos metadados.",
|
||||
"TaskDownloadMissingSubtitles": "Transferir legendas em falta",
|
||||
"TaskRefreshChannelsDescription": "Atualizar informação sobre canais da Internet.",
|
||||
"TaskRefreshChannels": "Atualizar Canais",
|
||||
"TaskCleanTranscodeDescription": "Apagar ficheiros de transcode com mais de um dia.",
|
||||
"TaskCleanTranscode": "Limpar a Diretoria de Transcode",
|
||||
"TaskUpdatePluginsDescription": "Faz o download e instala updates para os plugins que estão configurados para atualizar automaticamente.",
|
||||
"TaskUpdatePlugins": "Atualizar Plugins",
|
||||
"TaskRefreshPeopleDescription": "Atualizar metadados para elenco e equipa técnica da tua mediateca.",
|
||||
"TaskRefreshPeople": "Atualizar Pessoas",
|
||||
"TaskCleanLogsDescription": "Apagar ficheiros de log que têm mais de {0} dias.",
|
||||
"TaskCleanLogs": "Limpar a Diretoria de Logs",
|
||||
"TaskRefreshLibraryDescription": "Analisar a mediateca para novos ficheiros e atualizar os metadados.",
|
||||
"TaskRefreshLibrary": "Analisar mediateca",
|
||||
"TaskRefreshChapterImagesDescription": "Criar thumbnails para os vídeos que têm capítulos.",
|
||||
"TaskRefreshChapterImages": "Extrair Imagens dos Capítulos",
|
||||
"TaskCleanCacheDescription": "Apagar ficheiros em cache que já não são necessários.",
|
||||
"TaskCleanCache": "Limpar Cache",
|
||||
"TaskRefreshChannelsDescription": "Atualiza as informações dos canais da Internet.",
|
||||
"TaskRefreshChannels": "Atualizar canais",
|
||||
"TaskCleanTranscodeDescription": "Elimina ficheiros de transcodificação com mais de um dia.",
|
||||
"TaskCleanTranscode": "Limpar pasta de transcodificação",
|
||||
"TaskUpdatePluginsDescription": "Transfere e instala atualizações dos plugins configurados para atualização automática.",
|
||||
"TaskUpdatePlugins": "Atualizar plugins",
|
||||
"TaskRefreshPeopleDescription": "Atualiza os metadados de atores e realizadores na tua biblioteca multimédia.",
|
||||
"TaskRefreshPeople": "Atualizar pessoas",
|
||||
"TaskCleanLogsDescription": "Elimina ficheiros de registo com mais de {0} dias.",
|
||||
"TaskCleanLogs": "Limpar pasta de registos",
|
||||
"TaskRefreshLibraryDescription": "Analisa a biblioteca multimédia à procura de novos ficheiros e atualiza os metadados.",
|
||||
"TaskRefreshLibrary": "Analisar biblioteca multimédia",
|
||||
"TaskRefreshChapterImagesDescription": "Cria miniaturas para vídeos que têm capítulos.",
|
||||
"TaskRefreshChapterImages": "Extrair imagens dos capítulos",
|
||||
"TaskCleanCacheDescription": "Elimina ficheiros de cache que já não são necessários ao sistema.",
|
||||
"TaskCleanCache": "Limpar pasta de cache",
|
||||
"TasksChannelsCategory": "Canais da Internet",
|
||||
"TasksApplicationCategory": "Aplicação",
|
||||
"TasksLibraryCategory": "Mediateca",
|
||||
"TasksLibraryCategory": "Biblioteca",
|
||||
"TasksMaintenanceCategory": "Manutenção",
|
||||
"TaskCleanActivityLogDescription": "Apaga as entradas do registo de atividade anteriores à data configurada.",
|
||||
"TaskCleanActivityLogDescription": "Elimina as entradas do registo de atividade mais antigas do que o período configurado.",
|
||||
"TaskCleanActivityLog": "Limpar registo de atividade",
|
||||
"Undefined": "Indefinido",
|
||||
"Forced": "Forçado",
|
||||
"Default": "Predefinição",
|
||||
"TaskOptimizeDatabaseDescription": "Otimiza e liberta espaço livre na base de dados. A execução desta tarefa depois de analisar a mediateca ou efetuar outras alterações que impliquem modificações na base de dados pode melhorar o desempenho.",
|
||||
"TaskOptimizeDatabaseDescription": "Compacta a base de dados e liberta espaço não utilizado. A execução desta tarefa depois de analisar a biblioteca ou de outras alterações que modifiquem a base de dados pode melhorar o desempenho.",
|
||||
"TaskOptimizeDatabase": "Otimizar base de dados",
|
||||
"TaskKeyframeExtractorDescription": "Extrai quadros-chave de ficheiros de video para criar listas de reprodução HLS mais precisas. Esta tarefa pode demorar algum tempo.",
|
||||
"TaskKeyframeExtractor": "Extrator de Quadros-chave",
|
||||
"TaskKeyframeExtractorDescription": "Extrai fotogramas-chave de ficheiros de vídeo para criar playlists HLS mais precisas. Esta tarefa pode demorar algum tempo.",
|
||||
"TaskKeyframeExtractor": "Extrator de fotogramas-chave",
|
||||
"External": "Externo",
|
||||
"HearingImpaired": "Surdo",
|
||||
"HearingImpaired": "Deficiência auditiva",
|
||||
"TaskRefreshTrickplayImages": "Gerar imagens de trickplay",
|
||||
"TaskRefreshTrickplayImagesDescription": "Cria pré-visualizações de trickplay para vídeos nas bibliotecas ativadas.",
|
||||
"TaskAudioNormalizationDescription": "Analisa os ficheiros para obter dados de normalização de áudio.",
|
||||
"TaskAudioNormalization": "Normalização de áudio",
|
||||
"TaskExtractMediaSegments": "Analisar segmentos de multimédia",
|
||||
"TaskDownloadMissingLyrics": "Transferir letra em falta",
|
||||
"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.",
|
||||
"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.",
|
||||
"TaskExtractMediaSegments": "Analisar segmentos multimédia",
|
||||
"TaskDownloadMissingLyrics": "Transferir letras em falta",
|
||||
"TaskMoveTrickplayImages": "Migrar localização das imagens de trickplay",
|
||||
"TaskDownloadMissingLyricsDescription": "Transfere letras de músicas",
|
||||
"TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos multimédia de plugins com MediaSegment ativado.",
|
||||
"TaskMoveTrickplayImagesDescription": "Move os ficheiros de trickplay existentes de acordo com as definições da biblioteca.",
|
||||
"CleanupUserDataTaskDescription": "Remove todos os dados de utilizador (estado de reprodução, estado de favorito, etc.) de conteúdos multimédia que já não estejam presentes há pelo menos 90 dias.",
|
||||
"CleanupUserDataTask": "Limpeza de dados de utilizador",
|
||||
"Original": "Original",
|
||||
"LyricDownloadFailureFromForItem": "Erro ao descarregar letras de {0} para {1}"
|
||||
"LyricDownloadFailureFromForItem": "Falha ao transferir letras de {0} para {1}",
|
||||
"NameExtraDeletedScene": "Cena eliminada",
|
||||
"NameExtraInterview": "Entrevista",
|
||||
"NameExtraUnknown": "Extra",
|
||||
"NameExtraBehindTheScenes": "Bastidores",
|
||||
"NameExtraSample": "Amostra",
|
||||
"NameExtraScene": "Cena",
|
||||
"NameExtraClip": "Excerto",
|
||||
"NameExtraFeaturette": "Minidocumentário",
|
||||
"NameExtraThemeSong": "Tema musical",
|
||||
"NameExtraThemeVideo": "Vídeo temático",
|
||||
"NameExtraShort": "Curta-metragem",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraTrailer": "Trailer"
|
||||
}
|
||||
|
||||
@@ -1,112 +1,125 @@
|
||||
{
|
||||
"HeaderLiveTV": "TV Em Direto",
|
||||
"HeaderLiveTV": "TV em direto",
|
||||
"Collections": "Coleções",
|
||||
"Books": "Livros",
|
||||
"Artists": "Artistas",
|
||||
"HeaderNextUp": "A Seguir",
|
||||
"HeaderFavoriteEpisodes": "Episódios Favoritos",
|
||||
"HeaderFavoriteShows": "Séries Favoritas",
|
||||
"HeaderNextUp": "A seguir",
|
||||
"HeaderFavoriteEpisodes": "Episódios favoritos",
|
||||
"HeaderFavoriteShows": "Séries favoritas",
|
||||
"HeaderContinueWatching": "Continuar a ver",
|
||||
"Genres": "Géneros",
|
||||
"Folders": "Pastas",
|
||||
"Favorites": "Favoritos",
|
||||
"UserDownloadingItemWithValues": "{0} está sendo baixado {1}",
|
||||
"UserDownloadingItemWithValues": "{0} está a transferir {1}",
|
||||
"VersionNumber": "Versão {0}",
|
||||
"UserStoppedPlayingItemWithValues": "{0} terminou a reprodução de {1} em {2}",
|
||||
"UserStartedPlayingItemWithValues": "{0} está reproduzindo {1} em {2}",
|
||||
"UserPasswordChangedWithName": "A senha do usuário {0} foi alterada",
|
||||
"UserOnlineFromDevice": "{0} está online a partir de {1}",
|
||||
"UserOfflineFromDevice": "{0} desconectou-se a partir de {1}",
|
||||
"UserLockedOutWithName": "O usuário {0} foi bloqueado",
|
||||
"UserDeletedWithName": "O usuário {0} foi removido",
|
||||
"UserCreatedWithName": "O usuário {0} foi criado",
|
||||
"TvShows": "Séries",
|
||||
"SubtitleDownloadFailureFromForItem": "Falha na transferência de legendas de {0} para {1}",
|
||||
"StartupEmbyServerIsLoading": "O servidor Jellyfin está iniciando. Tente novamente dentro de momentos.",
|
||||
"UserStartedPlayingItemWithValues": "{0} está a reproduzir {1} em {2}",
|
||||
"UserPasswordChangedWithName": "Palavra-passe alterada para o utilizador {0}",
|
||||
"UserOnlineFromDevice": "{0} está online em {1}",
|
||||
"UserOfflineFromDevice": "{0} desligou-se de {1}",
|
||||
"UserLockedOutWithName": "Utilizador {0} bloqueado",
|
||||
"UserDeletedWithName": "Utilizador {0} eliminado",
|
||||
"UserCreatedWithName": "Utilizador {0} criado",
|
||||
"TvShows": "Séries de TV",
|
||||
"SubtitleDownloadFailureFromForItem": "Falha ao transferir legendas de {0} para {1}",
|
||||
"StartupEmbyServerIsLoading": "O servidor Jellyfin está a iniciar. Tenta novamente dentro de instantes.",
|
||||
"ScheduledTaskFailedWithName": "{0} falhou",
|
||||
"PluginUpdatedWithName": "{0} foi atualizado",
|
||||
"PluginUninstalledWithName": "{0} foi desinstalado",
|
||||
"PluginInstalledWithName": "{0} foi instalado",
|
||||
"NotificationOptionVideoPlaybackStopped": "Reprodução de vídeo parada",
|
||||
"NotificationOptionVideoPlaybackStopped": "Reprodução de vídeo interrompida",
|
||||
"NotificationOptionVideoPlayback": "Reprodução de vídeo iniciada",
|
||||
"NotificationOptionUserLockedOut": "Usuário bloqueado",
|
||||
"NotificationOptionTaskFailed": "Falha na tarefa agendada",
|
||||
"NotificationOptionServerRestartRequired": "É necessário reiniciar o servidor",
|
||||
"NotificationOptionPluginUpdateInstalled": "Plugin atualizado",
|
||||
"NotificationOptionUserLockedOut": "Utilizador bloqueado",
|
||||
"NotificationOptionTaskFailed": "Falha em tarefa agendada",
|
||||
"NotificationOptionServerRestartRequired": "Necessário reiniciar o servidor",
|
||||
"NotificationOptionPluginUpdateInstalled": "Atualização de plugin instalada",
|
||||
"NotificationOptionPluginUninstalled": "Plugin desinstalado",
|
||||
"NotificationOptionPluginInstalled": "Plugin instalado",
|
||||
"NotificationOptionPluginError": "Falha no plugin",
|
||||
"NotificationOptionNewLibraryContent": "Novo conteúdo adicionado",
|
||||
"NotificationOptionInstallationFailed": "Falha de instalação",
|
||||
"NotificationOptionCameraImageUploaded": "Imagem de câmera enviada",
|
||||
"NotificationOptionAudioPlaybackStopped": "Reprodução Parada",
|
||||
"NotificationOptionAudioPlayback": "Reprodução Iniciada",
|
||||
"NotificationOptionApplicationUpdateInstalled": "A atualização do aplicativo foi instalada",
|
||||
"NotificationOptionApplicationUpdateAvailable": "Uma atualização do aplicativo está disponível",
|
||||
"NewVersionIsAvailable": "Uma nova versão do servidor Jellyfin está disponível para download.",
|
||||
"NameSeasonUnknown": "Temporada Desconhecida",
|
||||
"NotificationOptionInstallationFailed": "Falha na instalação",
|
||||
"NotificationOptionCameraImageUploaded": "Imagem da câmara enviada",
|
||||
"NotificationOptionAudioPlaybackStopped": "Reprodução de áudio interrompida",
|
||||
"NotificationOptionAudioPlayback": "Reprodução de áudio iniciada",
|
||||
"NotificationOptionApplicationUpdateInstalled": "Atualização de aplicação instalada",
|
||||
"NotificationOptionApplicationUpdateAvailable": "Atualização de aplicação disponível",
|
||||
"NewVersionIsAvailable": "Está disponível para transferência uma nova versão do servidor Jellyfin.",
|
||||
"NameSeasonUnknown": "Temporada desconhecida",
|
||||
"NameSeasonNumber": "Temporada {0}",
|
||||
"NameInstallFailed": "Falha na instalação de {0}",
|
||||
"MusicVideos": "Videoclipes",
|
||||
"Music": "Música",
|
||||
"MixedContent": "Conteúdo diverso",
|
||||
"Latest": "Mais Recente",
|
||||
"MixedContent": "Conteúdo misto",
|
||||
"Latest": "Mais recente",
|
||||
"LabelRunningTimeValue": "Duração: {0}",
|
||||
"LabelIpAddressValue": "Endereço de IP: {0}",
|
||||
"LabelIpAddressValue": "Endereço IP: {0}",
|
||||
"Inherit": "Herdar",
|
||||
"HomeVideos": "Vídeos Caseiros",
|
||||
"HomeVideos": "Vídeos caseiros",
|
||||
"Shows": "Séries",
|
||||
"Photos": "Fotografias",
|
||||
"Movies": "Filmes",
|
||||
"FailedLoginAttemptWithUserName": "Tentativa de início de sessão falhada a partir de {0}",
|
||||
"FailedLoginAttemptWithUserName": "Tentativa falhada de início de sessão do utilizador {0}",
|
||||
"ChapterNameValue": "Capítulo {0}",
|
||||
"AuthenticationSucceededWithUserName": "{0} autenticado com sucesso",
|
||||
"AppDeviceValues": "Aplicação: {0}, Dispositivo: {1}",
|
||||
"TaskCleanCache": "Limpar Diretório de Cache",
|
||||
"TaskCleanCache": "Limpar pasta de cache",
|
||||
"TasksApplicationCategory": "Aplicação",
|
||||
"TasksLibraryCategory": "Mediateca",
|
||||
"TasksLibraryCategory": "Biblioteca",
|
||||
"TasksMaintenanceCategory": "Manutenção",
|
||||
"TaskRefreshChannels": "Atualizar Canais",
|
||||
"TaskUpdatePlugins": "Atualizar Plugins",
|
||||
"TaskCleanLogsDescription": "Deletar arquivos de log que existe a mais de {0} dias.",
|
||||
"TaskCleanLogs": "Limpar diretório de logs",
|
||||
"TaskRefreshLibrary": "Analisar mediateca",
|
||||
"TaskRefreshChannels": "Atualizar canais",
|
||||
"TaskUpdatePlugins": "Atualizar plugins",
|
||||
"TaskCleanLogsDescription": "Elimina ficheiros de registo com mais de {0} dias.",
|
||||
"TaskCleanLogs": "Limpar pasta de registos",
|
||||
"TaskRefreshLibrary": "Analisar biblioteca multimédia",
|
||||
"TaskRefreshChapterImagesDescription": "Cria miniaturas para vídeos que têm capítulos.",
|
||||
"TaskCleanCacheDescription": "Apaga ficheiros em cache que já não são usados pelo sistema.",
|
||||
"TasksChannelsCategory": "Canais de Internet",
|
||||
"TaskRefreshChapterImages": "Extrair Imagens do Capítulo",
|
||||
"TaskDownloadMissingSubtitlesDescription": "Pesquisa na Internet as legendas em falta com base na configuração de metadados.",
|
||||
"TaskCleanCacheDescription": "Elimina ficheiros de cache que já não são necessários ao sistema.",
|
||||
"TasksChannelsCategory": "Canais da Internet",
|
||||
"TaskRefreshChapterImages": "Extrair imagens dos capítulos",
|
||||
"TaskDownloadMissingSubtitlesDescription": "Procura na Internet legendas em falta com base na configuração dos metadados.",
|
||||
"TaskDownloadMissingSubtitles": "Transferir legendas em falta",
|
||||
"TaskRefreshChannelsDescription": "Atualiza as informações do canal da Internet.",
|
||||
"TaskCleanTranscodeDescription": "Apagar os ficheiros com mais de um dia, de Transcode.",
|
||||
"TaskCleanTranscode": "Limpar o diretório de Transcode",
|
||||
"TaskUpdatePluginsDescription": "Baixa e instala as atualizações para plug-ins configurados para atualização automática.",
|
||||
"TaskRefreshPeopleDescription": "Atualizar metadados para elenco e equipa técnica da tua mediateca.",
|
||||
"TaskRefreshChannelsDescription": "Atualiza as informações dos canais da Internet.",
|
||||
"TaskCleanTranscodeDescription": "Elimina ficheiros de transcodificação com mais de um dia.",
|
||||
"TaskCleanTranscode": "Limpar pasta de transcodificação",
|
||||
"TaskUpdatePluginsDescription": "Transfere e instala atualizações dos plugins configurados para atualização automática.",
|
||||
"TaskRefreshPeopleDescription": "Atualiza os metadados de atores e realizadores na tua biblioteca multimédia.",
|
||||
"TaskRefreshPeople": "Atualizar pessoas",
|
||||
"TaskRefreshLibraryDescription": "Analisar a mediateca para novos ficheiros e atualizar os metadados.",
|
||||
"TaskCleanActivityLog": "Limpar registro de atividade",
|
||||
"TaskRefreshLibraryDescription": "Analisa a biblioteca multimédia à procura de novos ficheiros e atualiza os metadados.",
|
||||
"TaskCleanActivityLog": "Limpar registo de atividade",
|
||||
"Undefined": "Indefinido",
|
||||
"Forced": "Forçado",
|
||||
"Default": "Predefinição",
|
||||
"TaskCleanActivityLogDescription": "Apaga itens no registro com idade acima do que é configurado.",
|
||||
"TaskCleanActivityLogDescription": "Elimina as entradas do registo de atividade mais antigas do que o período configurado.",
|
||||
"TaskOptimizeDatabase": "Otimizar base de dados",
|
||||
"TaskOptimizeDatabaseDescription": "Otimiza e liberta espaço livre na base de dados. A execução desta tarefa depois de analisar a mediateca ou efetuar outras alterações que impliquem modificações na base de dados pode melhorar o desempenho.",
|
||||
"TaskOptimizeDatabaseDescription": "Compacta a base de dados e liberta espaço não utilizado. A execução desta tarefa depois de analisar a biblioteca ou de outras alterações que modifiquem a base de dados pode melhorar o desempenho.",
|
||||
"External": "Externo",
|
||||
"HearingImpaired": "Problemas auditivos",
|
||||
"TaskKeyframeExtractor": "Extrator de quadro-chave",
|
||||
"TaskKeyframeExtractorDescription": "Retira frames chave do video para criar listas HLS precisas. Esta tarefa pode correr durante algum tempo.",
|
||||
"HearingImpaired": "Deficiência auditiva",
|
||||
"TaskKeyframeExtractor": "Extrator de fotogramas-chave",
|
||||
"TaskKeyframeExtractorDescription": "Extrai fotogramas-chave de ficheiros de vídeo para criar playlists HLS mais precisas. Esta tarefa pode demorar algum tempo.",
|
||||
"TaskRefreshTrickplayImages": "Gerar imagens de trickplay",
|
||||
"TaskRefreshTrickplayImagesDescription": "Cria pré-visualizações de trickplay para vídeos nas bibliotecas ativadas.",
|
||||
"TaskAudioNormalizationDescription": "Analisa os ficheiros para obter dados de normalização de áudio.",
|
||||
"TaskAudioNormalization": "Normalização de áudio",
|
||||
"TaskDownloadMissingLyrics": "Transferir letra em falta",
|
||||
"TaskDownloadMissingLyricsDescription": "Transferir letra para músicas",
|
||||
"TaskMoveTrickplayImagesDescription": "Move os ficheiros trickplay existentes de acordo com as definições da mediateca.",
|
||||
"TaskExtractMediaSegments": "Analisar segmentos de multimédia",
|
||||
"TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de multimédia a partir de plugins com suporte para MediaSegment.",
|
||||
"TaskMoveTrickplayImages": "Migrar a localização da imagem do Trickplay",
|
||||
"CleanupUserDataTask": "Task de limpeza de dados do usuário",
|
||||
"CleanupUserDataTaskDescription": "Remove todos os dados do usuário (progresso, favoritos etc) de mídias que não estão presentes há pelo menos 90 dias.",
|
||||
"TaskDownloadMissingLyrics": "Transferir letras em falta",
|
||||
"TaskDownloadMissingLyricsDescription": "Transfere letras de músicas",
|
||||
"TaskMoveTrickplayImagesDescription": "Move os ficheiros de trickplay existentes de acordo com as definições da biblioteca.",
|
||||
"TaskExtractMediaSegments": "Analisar segmentos multimédia",
|
||||
"TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos multimédia de plugins com MediaSegment ativado.",
|
||||
"TaskMoveTrickplayImages": "Migrar localização das imagens de trickplay",
|
||||
"CleanupUserDataTask": "Limpeza de dados de utilizador",
|
||||
"CleanupUserDataTaskDescription": "Remove todos os dados de utilizador (estado de reprodução, estado de favorito, etc.) de conteúdos multimédia que já não estejam presentes há pelo menos 90 dias.",
|
||||
"Original": "Original",
|
||||
"LyricDownloadFailureFromForItem": "Erro ao descarregar letras de {0} para {1}"
|
||||
"LyricDownloadFailureFromForItem": "Falha ao transferir letras de {0} para {1}",
|
||||
"NameExtraBehindTheScenes": "Bastidores",
|
||||
"NameExtraClip": "Excerto",
|
||||
"NameExtraDeletedScene": "Cena eliminada",
|
||||
"NameExtraFeaturette": "Minidocumentário",
|
||||
"NameExtraInterview": "Entrevista",
|
||||
"NameExtraSample": "Amostra",
|
||||
"NameExtraShort": "Curta-metragem",
|
||||
"NameExtraThemeSong": "Tema musical",
|
||||
"NameExtraThemeVideo": "Vídeo temático",
|
||||
"NameExtraScene": "Cena",
|
||||
"NameExtraUnknown": "Extra",
|
||||
"NameExtraTrailer": "Trailer",
|
||||
"NameExtraNumbered": "{0} {1}"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,8 @@
|
||||
"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.",
|
||||
"LyricDownloadFailureFromForItem": "Versurile nu au putut fi descărcate din {0} pentru {1}",
|
||||
"Original": "Original"
|
||||
"Original": "Original",
|
||||
"NameExtraBehindTheScenes": "În culise",
|
||||
"NameExtraClip": "Clip",
|
||||
"NameExtraDeletedScene": "Scenă ștearsă"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"CleanupUserDataTask": "Задача очистки пользовательских данных",
|
||||
"CleanupUserDataTaskDescription": "Очищает все пользовательские данные (состояние просмотра, статус избранного и т.д.) с медиа, отсутствующих по меньшей мере в течение 90 дней.",
|
||||
"Original": "Оригинальный",
|
||||
"LyricDownloadFailureFromForItem": "Не получилось скачать текст песни с {0} для {1}"
|
||||
"LyricDownloadFailureFromForItem": "Не получилось скачать текст песни с {0} для {1}",
|
||||
"NameExtraBehindTheScenes": "За кадром",
|
||||
"NameExtraClip": "Отрывок",
|
||||
"NameExtraDeletedScene": "Удалённая сцена",
|
||||
"NameExtraFeaturette": "Короткометражка",
|
||||
"NameExtraInterview": "Интервью",
|
||||
"NameExtraSample": "Образец",
|
||||
"NameExtraScene": "Сцена",
|
||||
"NameExtraThemeSong": "Заглавная песня",
|
||||
"NameExtraThemeVideo": "Заглавное видео",
|
||||
"NameExtraTrailer": "Трейлер",
|
||||
"NameExtraUnknown": "Дополнительный материал",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraShort": "Короткометражка"
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
"Default": "Predvolené",
|
||||
"TaskOptimizeDatabaseDescription": "Zmenší databázu a odstráni prázdne miesto. Spustenie tejto úlohy po skenovaní knižnice alebo po iných zmenách zahŕňajúcich úpravy databáze môže zlepšiť výkon.",
|
||||
"TaskOptimizeDatabase": "Optimalizovať databázu",
|
||||
"TaskKeyframeExtractorDescription": "Extrahuje kľúčové snímky z video súborov na vytvorenie presnejších HLS playlistov. Táto úloha môže trvať dlhšiu dobu.",
|
||||
"TaskKeyframeExtractorDescription": "Extrahuje kľúčové snímky z video súborov na vytvorenie presnejších HLS zoznamov prehrávania. Táto úloha môže trvať dlhšiu dobu.",
|
||||
"TaskKeyframeExtractor": "Extraktor kľúčových snímkov",
|
||||
"External": "Externé",
|
||||
"HearingImpaired": "Sluchovo postihnutí",
|
||||
@@ -108,5 +108,18 @@
|
||||
"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í.",
|
||||
"LyricDownloadFailureFromForItem": "Text piesne sa nepodarilo stiahnuť z {0} pre {1}",
|
||||
"Original": "Originál"
|
||||
"Original": "Originál",
|
||||
"NameExtraBehindTheScenes": "Zo zákulisia",
|
||||
"NameExtraClip": "Klip",
|
||||
"NameExtraDeletedScene": "Vystrihnutá scéna",
|
||||
"NameExtraFeaturette": "Bonus",
|
||||
"NameExtraInterview": "Rozhovor",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Ukážka",
|
||||
"NameExtraScene": "Scéna",
|
||||
"NameExtraShort": "Krátky film",
|
||||
"NameExtraThemeSong": "Úvodná pieseň",
|
||||
"NameExtraThemeVideo": "Úvodné video",
|
||||
"NameExtraTrailer": "Trailer",
|
||||
"NameExtraUnknown": "Extra"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"CleanupUserDataTaskDescription": "Pastron të gjitha të dhënat e përdorueseve (gjendja e shikimit, statusi i të preferuarave etj.) nga mediat që nuk janë më të pranishme për të paktën 90 ditë.",
|
||||
"CleanupUserDataTask": "Veprim për pastrimin të dhënave të përdorueseve",
|
||||
"LyricDownloadFailureFromForItem": "Teksti i këngës nuk arriti të shkarkohej nga {0} për {1}",
|
||||
"Original": "Origjinal"
|
||||
"Original": "Origjinal",
|
||||
"NameExtraBehindTheScenes": "Pamje nga prapaskenat",
|
||||
"NameExtraClip": "Pjesë",
|
||||
"NameExtraDeletedScene": "Skenë e fshirë",
|
||||
"NameExtraFeaturette": "Film i shkurtër",
|
||||
"NameExtraInterview": "Intervistë",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Shembull",
|
||||
"NameExtraScene": "Skenë",
|
||||
"NameExtraShort": "Film i shkurtër",
|
||||
"NameExtraThemeSong": "Kënga e temës",
|
||||
"NameExtraThemeVideo": "Videoja e temës",
|
||||
"NameExtraTrailer": "Parapamje",
|
||||
"NameExtraUnknown": "Shtesë"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"TaskMoveTrickplayImages": "Промени локацију сличица за визуелно премотавање",
|
||||
"TaskDownloadMissingLyricsDescription": "Преузми стихове песама",
|
||||
"LyricDownloadFailureFromForItem": "Није успело преузимање стихова са {0} за {1}",
|
||||
"Original": "Изворно"
|
||||
"Original": "Изворно",
|
||||
"NameExtraBehindTheScenes": "Иза кулиса",
|
||||
"NameExtraClip": "Исечак",
|
||||
"NameExtraDeletedScene": "Обрисана сцена",
|
||||
"NameExtraFeaturette": "Кратки прилог",
|
||||
"NameExtraInterview": "Интервју",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Узорак",
|
||||
"NameExtraScene": "Сцена",
|
||||
"NameExtraShort": "Кратки филм",
|
||||
"NameExtraThemeSong": "Музичка тема",
|
||||
"NameExtraThemeVideo": "Спот музичке теме",
|
||||
"NameExtraTrailer": "Реклама",
|
||||
"NameExtraUnknown": "Додатак"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"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",
|
||||
"Original": "Original",
|
||||
"LyricDownloadFailureFromForItem": "Misslyckades att ladda ner låttexter från {0} för {1}"
|
||||
"LyricDownloadFailureFromForItem": "Misslyckades att ladda ner låttexter från {0} för {1}",
|
||||
"NameExtraBehindTheScenes": "Bakom kulisserna",
|
||||
"NameExtraDeletedScene": "Borttagen scen",
|
||||
"NameExtraInterview": "Intervju",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraScene": "Scen",
|
||||
"NameExtraShort": "Kortfilm",
|
||||
"NameExtraThemeSong": "Signaturmelodi",
|
||||
"NameExtraTrailer": "Trailer",
|
||||
"NameExtraClip": "Klipp",
|
||||
"NameExtraFeaturette": "Kortfilm",
|
||||
"NameExtraSample": "Prov",
|
||||
"NameExtraThemeVideo": "Signaturvideo",
|
||||
"NameExtraUnknown": "Extra"
|
||||
}
|
||||
|
||||
@@ -106,5 +106,20 @@
|
||||
"TaskExtractMediaSegments": "மீடியா பிரிவு ஸ்கேன்",
|
||||
"TaskExtractMediaSegmentsDescription": "மீடியாசெக்மென்ட் இயக்கப்பட்ட செருகுநிரல்களிலிருந்து மீடியா பிரிவுகளைப் பிரித்தெடுக்கிறது அல்லது பெறுகிறது.",
|
||||
"CleanupUserDataTaskDescription": "குறைந்தது 90 நாட்களுக்கு இல்லாத மீடியாவிலிருந்து அனைத்து பயனர் தரவையும் (கண்காணிப்பு நிலை, பிடித்த நிலை போன்றவை) சுத்தம் செய்கிறது.",
|
||||
"CleanupUserDataTask": "பயனர் தரவை சுத்தம் செய்யும் பணி"
|
||||
"CleanupUserDataTask": "பயனர் தரவை சுத்தம் செய்யும் பணி",
|
||||
"LyricDownloadFailureFromForItem": "{0} இலிருந்து {1} க்கு பாடல் வரிகளைப் பதிவிறக்க முடியவில்லை",
|
||||
"NameExtraBehindTheScenes": "திரைக்குப் பின்னால்",
|
||||
"NameExtraClip": "துண்டுக்காட்சி",
|
||||
"NameExtraDeletedScene": "நீக்கப்பட்ட காட்சிகள்",
|
||||
"NameExtraFeaturette": "திரைப்பின்னணித் தொகுப்பு",
|
||||
"NameExtraInterview": "நேர்காணல்",
|
||||
"NameExtraSample": "மாதிரி",
|
||||
"NameExtraScene": "காட்சி",
|
||||
"NameExtraShort": "குறுகிய",
|
||||
"NameExtraThemeSong": "மையப் பாடல்",
|
||||
"NameExtraThemeVideo": "மையக் காணொளி",
|
||||
"NameExtraTrailer": "முன்னோட்டம்",
|
||||
"NameExtraUnknown": "கூடுதல்",
|
||||
"Original": "அசல்",
|
||||
"NameExtraNumbered": "{0} {1}"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"AppDeviceValues": "Uygulama: {0}, Aygıt: {1}",
|
||||
"AppDeviceValues": "Uygulama: {0}, Cihaz: {1}",
|
||||
"Artists": "Sanatçılar",
|
||||
"AuthenticationSucceededWithUserName": "{0} kimliği başarıyla doğrulandı",
|
||||
"Books": "Kitaplar",
|
||||
@@ -108,5 +108,18 @@
|
||||
"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.",
|
||||
"LyricDownloadFailureFromForItem": "{1} şarkı sözleri {0} adresinden indirilemedi",
|
||||
"Original": "Orijinal"
|
||||
"Original": "Orijinal",
|
||||
"NameExtraBehindTheScenes": "Kamera Arkası",
|
||||
"NameExtraClip": "Klip",
|
||||
"NameExtraDeletedScene": "Silinmiş Sahne",
|
||||
"NameExtraFeaturette": "Kısa film",
|
||||
"NameExtraInterview": "Röportaj",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Örnek",
|
||||
"NameExtraScene": "Sahne",
|
||||
"NameExtraShort": "Kısa",
|
||||
"NameExtraThemeSong": "Tanıtım Müziği",
|
||||
"NameExtraThemeVideo": "Tanıtım Videosu",
|
||||
"NameExtraTrailer": "Fragman",
|
||||
"NameExtraUnknown": "Fazladan"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"CleanupUserDataTask": "Завдання очищення даних користувача",
|
||||
"CleanupUserDataTaskDescription": "Очищає всі дані користувача (стан перегляду, статус обраного тощо) з медіа, які перестали бути доступними щонайменше 90 днів тому.",
|
||||
"Original": "Оригінал",
|
||||
"LyricDownloadFailureFromForItem": "Не вдалося завантажити текст пісні з {0} для {1}"
|
||||
"LyricDownloadFailureFromForItem": "Не вдалося завантажити текст пісні з {0} для {1}",
|
||||
"NameExtraBehindTheScenes": "За лаштунками",
|
||||
"NameExtraClip": "Кліп",
|
||||
"NameExtraDeletedScene": "Видалена сцена",
|
||||
"NameExtraFeaturette": "Фічуретка",
|
||||
"NameExtraInterview": "Інтерв’ю",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Приклад",
|
||||
"NameExtraScene": "Сцена",
|
||||
"NameExtraShort": "Коротко",
|
||||
"NameExtraThemeSong": "Тематична пісня",
|
||||
"NameExtraThemeVideo": "Тематичне вiдео",
|
||||
"NameExtraTrailer": "Трейлер",
|
||||
"NameExtraUnknown": "Додатково"
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"TasksLibraryCategory": "Thư Viện",
|
||||
"TasksMaintenanceCategory": "Bảo Trì",
|
||||
"VersionNumber": "Phiên Bản {0}",
|
||||
"UserStoppedPlayingItemWithValues": "{0} đã kết thúc phát {1} trên {2}",
|
||||
"UserStoppedPlayingItemWithValues": "{0} đã phát xong {1} trên {2}",
|
||||
"UserStartedPlayingItemWithValues": "{0} đang phát {1} trên {2}",
|
||||
"UserPasswordChangedWithName": "Mật khẩu đã được thay đổi cho người dùng {0}",
|
||||
"UserOnlineFromDevice": "{0} trực tuyến từ {1}",
|
||||
@@ -79,7 +79,7 @@
|
||||
"HeaderNextUp": "Tiếp Theo",
|
||||
"HeaderFavoriteShows": "Chương Trình Yêu Thích",
|
||||
"HeaderFavoriteEpisodes": "Tập Phim Yêu Thích",
|
||||
"FailedLoginAttemptWithUserName": "Nỗ lực đăng nhập không thành công từ {0}",
|
||||
"FailedLoginAttemptWithUserName": "Cố gắng đăng nhập thất bại từ {0}",
|
||||
"ChapterNameValue": "Phân Cảnh {0}",
|
||||
"Books": "Sách",
|
||||
"AuthenticationSucceededWithUserName": "{0} xác thực thành công",
|
||||
@@ -95,18 +95,31 @@
|
||||
"TaskKeyframeExtractorDescription": "Trích xuất khung hình chính từ các tệp video để tạo danh sách phát HLS chính xác hơn. Tác vụ này có thể chạy trong một thời gian dài.",
|
||||
"External": "Bên ngoài",
|
||||
"HearingImpaired": "Khiếm Thính",
|
||||
"TaskRefreshTrickplayImages": "Tạo Ảnh Xem Trước Trickplay",
|
||||
"TaskRefreshTrickplayImagesDescription": "Tạo bản xem trước trịckplay cho video trong thư viện đã bật.",
|
||||
"TaskRefreshTrickplayImages": "Tạo Ảnh Tua Nhanh (Trickplay)",
|
||||
"TaskRefreshTrickplayImagesDescription": "Tạo ảnh tua nhanh (trịckplay) xem thử cho các video trong các thư viện được kích hoạt.",
|
||||
"TaskAudioNormalization": "Chuẩn Hóa Âm Thanh",
|
||||
"TaskAudioNormalizationDescription": "Quét tập tin để tìm dữ liệu chuẩn hóa âm thanh.",
|
||||
"TaskDownloadMissingLyricsDescription": "Tải xuống lời cho bài hát",
|
||||
"TaskDownloadMissingLyrics": "Tải xuống lời bị thiếu",
|
||||
"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.",
|
||||
"TaskMoveTrickplayImages": "Di Chuyển Vị Trí Ảnh Tua Nhanh (Trickplay)",
|
||||
"TaskMoveTrickplayImagesDescription": "Di chuyển các tệp ảnh tua nhanh (trickplay) hiện có theo cài đặt thư việ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.",
|
||||
"Original": "Gốc",
|
||||
"LyricDownloadFailureFromForItem": "Lời bài hát không tải xuống được từ {0} cho {1}"
|
||||
"LyricDownloadFailureFromForItem": "Lời bài hát không tải xuống được từ {0} cho {1}",
|
||||
"NameExtraBehindTheScenes": "Hậu Trường",
|
||||
"NameExtraDeletedScene": "Cảnh Bị Xóa",
|
||||
"NameExtraInterview": "Phỏng vấn",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Mẫu",
|
||||
"NameExtraScene": "Cảnh",
|
||||
"NameExtraShort": "Ngắn",
|
||||
"NameExtraThemeSong": "Bài Hát Chủ Đề",
|
||||
"NameExtraThemeVideo": "Video Chủ Đề",
|
||||
"NameExtraFeaturette": "Nội dung phụ",
|
||||
"NameExtraClip": "Clip ngắn",
|
||||
"NameExtraTrailer": "Đoạn giới thiệu",
|
||||
"NameExtraUnknown": "Nội dung bổ sung"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"CleanupUserDataTask": "用户数据清理任务",
|
||||
"CleanupUserDataTaskDescription": "清理已被删除超过90天的媒体中的所有用户数据(观看状态、收藏夹状态等)。",
|
||||
"LyricDownloadFailureFromForItem": "无法从 {0} 下载 {1} 的歌词",
|
||||
"Original": "原始"
|
||||
"Original": "原始",
|
||||
"NameExtraBehindTheScenes": "幕后花絮",
|
||||
"NameExtraClip": "片段",
|
||||
"NameExtraDeletedScene": "删减场景",
|
||||
"NameExtraFeaturette": "花絮",
|
||||
"NameExtraInterview": "采访",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "试看",
|
||||
"NameExtraScene": "精选片段",
|
||||
"NameExtraShort": "短篇",
|
||||
"NameExtraThemeSong": "主题曲",
|
||||
"NameExtraThemeVideo": "主题视频",
|
||||
"NameExtraTrailer": "预告片",
|
||||
"NameExtraUnknown": "额外内容"
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
"Folders": "資料夾",
|
||||
"Genres": "風格",
|
||||
"HeaderContinueWatching": "繼續睇返",
|
||||
"HeaderFavoriteEpisodes": "心水嘅劇集",
|
||||
"HeaderFavoriteShows": "心水嘅節目",
|
||||
"HeaderFavoriteEpisodes": "心水劇集",
|
||||
"HeaderFavoriteShows": "心水節目",
|
||||
"HeaderLiveTV": "電視直播",
|
||||
"HeaderNextUp": "跟住落嚟",
|
||||
"HomeVideos": "家庭影片",
|
||||
@@ -108,5 +108,18 @@
|
||||
"CleanupUserDataTask": "清理使用者資料嘅任務",
|
||||
"CleanupUserDataTaskDescription": "清理已消失至少 90 日嘅媒體用家數據(包括觀看狀態、心水狀態等)。",
|
||||
"LyricDownloadFailureFromForItem": "冇辦法從 {0} 下載 {1} 嘅歌詞",
|
||||
"Original": "原始"
|
||||
"Original": "原始",
|
||||
"NameExtraBehindTheScenes": "幕後花絮",
|
||||
"NameExtraClip": "片段",
|
||||
"NameExtraDeletedScene": "刪減場景",
|
||||
"NameExtraFeaturette": "花絮",
|
||||
"NameExtraInterview": "采訪",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "試看",
|
||||
"NameExtraScene": "精選片段",
|
||||
"NameExtraShort": "短篇",
|
||||
"NameExtraThemeSong": "主題曲",
|
||||
"NameExtraThemeVideo": "主題影片",
|
||||
"NameExtraTrailer": "預告片",
|
||||
"NameExtraUnknown": "額外內容"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"CleanupUserDataTask": "用戶資料清理工作",
|
||||
"CleanupUserDataTaskDescription": "從用戶資料中清除已被刪除超過 90 天的媒體的相關資料。",
|
||||
"Original": "原作",
|
||||
"LyricDownloadFailureFromForItem": "無法從 {0} 下載 {1} 的歌詞"
|
||||
"LyricDownloadFailureFromForItem": "無法從 {0} 下載 {1} 的歌詞",
|
||||
"NameExtraBehindTheScenes": "幕後花絮",
|
||||
"NameExtraClip": "片段",
|
||||
"NameExtraDeletedScene": "刪減場景",
|
||||
"NameExtraFeaturette": "花絮",
|
||||
"NameExtraInterview": "采訪",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "試看",
|
||||
"NameExtraScene": "精選片段",
|
||||
"NameExtraShort": "短篇",
|
||||
"NameExtraThemeSong": "主題曲",
|
||||
"NameExtraThemeVideo": "主題影片",
|
||||
"NameExtraTrailer": "預告片",
|
||||
"NameExtraUnknown": "額外內容"
|
||||
}
|
||||
|
||||
@@ -255,6 +255,14 @@ namespace Emby.Server.Implementations.Plugins
|
||||
}
|
||||
|
||||
_plugins.Add(plugin);
|
||||
|
||||
// Updating a disabled plugin must not enable it again.
|
||||
if (plugin.Manifest.Status == PluginStatus.Disabled)
|
||||
{
|
||||
ProcessAlternative(plugin);
|
||||
return;
|
||||
}
|
||||
|
||||
EnablePlugin(plugin);
|
||||
}
|
||||
|
||||
@@ -632,9 +640,10 @@ namespace Emby.Server.Implementations.Plugins
|
||||
return;
|
||||
}
|
||||
|
||||
var predecessor = _plugins.OrderByDescending(p => p.Version)
|
||||
.FirstOrDefault(p => p.Id.Equals(plugin.Id) && p.IsEnabledAndSupported && p.Version != plugin.Version);
|
||||
if (predecessor is not null)
|
||||
var successor = _plugins.FirstOrDefault(p => p.Id.Equals(plugin.Id)
|
||||
&& p.Version > plugin.Version
|
||||
&& (p.IsEnabledAndSupported || p.Manifest.Status == PluginStatus.Disabled));
|
||||
if (successor is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -763,6 +772,8 @@ namespace Emby.Server.Implementations.Plugins
|
||||
var entry = versions[x];
|
||||
if (!string.Equals(lastName, entry.Name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
lastName = string.Empty;
|
||||
|
||||
if (!TryGetPluginDlls(entry, out var allowedDlls))
|
||||
{
|
||||
_logger.LogError("One or more assembly paths was invalid. Marking plugin {Plugin} as \"Malfunctioned\".", entry.Name);
|
||||
@@ -772,15 +783,18 @@ namespace Emby.Server.Implementations.Plugins
|
||||
|
||||
entry.DllFiles = allowedDlls;
|
||||
|
||||
// Only clean up older versions when this version will actually be loaded.
|
||||
if (entry.IsEnabledAndSupported)
|
||||
{
|
||||
lastName = entry.Name;
|
||||
continue;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(lastName))
|
||||
{
|
||||
// Unnamed plugin, so there is nothing to match older versions against.
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -891,9 +905,9 @@ namespace Emby.Server.Implementations.Plugins
|
||||
|
||||
if (previousVersion is null)
|
||||
{
|
||||
// This value is memory only - so that the web will show restart required.
|
||||
plugin.Manifest.Status = PluginStatus.Restart;
|
||||
plugin.Manifest.AutoUpdate = false;
|
||||
// Memory only, so that the web will show restart required. The manifest must keep
|
||||
// holding the persisted state, or a later save would write the wrong state to disk.
|
||||
plugin.RestartRequired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -906,9 +920,7 @@ namespace Emby.Server.Implementations.Plugins
|
||||
_logger.LogError("Unable to supercede version {Version} of {Name}", previousVersion.Version, previousVersion.Name);
|
||||
}
|
||||
|
||||
// This value is memory only - so that the web will show restart required.
|
||||
plugin.Manifest.Status = PluginStatus.Restart;
|
||||
plugin.Manifest.AutoUpdate = false;
|
||||
plugin.RestartRequired = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ public partial class AudioNormalizationTask : IScheduledTask
|
||||
if (!t.NormalizationGain.HasValue && !t.LUFS.HasValue && t.IsFileProtocol)
|
||||
{
|
||||
t.LUFS = await CalculateLUFSAsync(
|
||||
string.Format(CultureInfo.InvariantCulture, "-i \"{0}\"", t.Path.Replace("\"", "\\\"", StringComparison.Ordinal)),
|
||||
string.Format(CultureInfo.InvariantCulture, "-i \"{0}\"", t.Path.EscapeProcessArgument()),
|
||||
false,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
toSaveDbItems.Add(t);
|
||||
|
||||
@@ -109,6 +109,8 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
||||
var dupQuery = context.Peoples
|
||||
.GroupBy(e => new { e.Name, e.PersonType })
|
||||
.Where(e => e.Count() > 1)
|
||||
.OrderBy(e => e.Key.Name)
|
||||
.ThenBy(e => e.Key.PersonType)
|
||||
.Select(e => e.Select(f => f.Id).ToArray());
|
||||
|
||||
var total = dupQuery.Count();
|
||||
@@ -177,33 +179,14 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
||||
var thirtyDaysAgo = DateTime.UtcNow.AddDays(-30);
|
||||
var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
|
||||
|
||||
List<Guid> peopleIds;
|
||||
|
||||
var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (context.ConfigureAwait(false))
|
||||
{
|
||||
const int PartitionSize = 100;
|
||||
|
||||
var numPeople = await context.BaseItems
|
||||
.AsNoTracking()
|
||||
.Where(b => b.Type == personTypeName)
|
||||
.Where(b => b.DateLastRefreshed == null || b.DateLastRefreshed < thirtyDaysAgo)
|
||||
.Where(b =>
|
||||
!b.Images!.Any(i => i.ImageType == ImageInfoImageType.Primary) ||
|
||||
string.IsNullOrEmpty(b.Overview))
|
||||
.CountAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug("Found {Count} people needing image/overview refresh", numPeople);
|
||||
|
||||
if (numPeople == 0)
|
||||
{
|
||||
progress.Report(100);
|
||||
return;
|
||||
}
|
||||
|
||||
var numComplete = 0;
|
||||
var numRefreshed = 0;
|
||||
|
||||
await foreach (var entry in context.BaseItems
|
||||
// Read the candidates in one go rather than paging them. A refresh stamps the person and takes
|
||||
// it out of this set, so a growing offset over a shrinking set walks past people it never visits.
|
||||
peopleIds = await context.BaseItems
|
||||
.AsNoTracking()
|
||||
.Where(b => b.Type == personTypeName)
|
||||
.Where(b => b.DateLastRefreshed == null || b.DateLastRefreshed < thirtyDaysAgo)
|
||||
@@ -211,22 +194,36 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
||||
!b.Images!.Any(i => i.ImageType == ImageInfoImageType.Primary) ||
|
||||
string.IsNullOrEmpty(b.Overview))
|
||||
.OrderBy(b => b.Id)
|
||||
.WithPartitionProgress(partition => _logger.LogDebug("Processing people partition {Partition}", partition))
|
||||
.PartitionEagerAsync(PartitionSize, cancellationToken)
|
||||
.WithCancellation(cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
if (await RefreshPersonAsync(entry.Id, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
numRefreshed++;
|
||||
}
|
||||
.Select(b => b.Id)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
numComplete++;
|
||||
progress.Report(100.0 * numComplete / numPeople);
|
||||
_logger.LogDebug("Found {Count} people needing image/overview refresh", peopleIds.Count);
|
||||
|
||||
if (peopleIds.Count == 0)
|
||||
{
|
||||
progress.Report(100);
|
||||
return;
|
||||
}
|
||||
|
||||
var numComplete = 0;
|
||||
var numRefreshed = 0;
|
||||
|
||||
foreach (var personId in peopleIds)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (await RefreshPersonAsync(personId, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
numRefreshed++;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Refreshed metadata for {Count} people missing images or overview", numRefreshed);
|
||||
numComplete++;
|
||||
progress.Report(100.0 * numComplete / peopleIds.Count);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Refreshed metadata for {Count} people missing images or overview", numRefreshed);
|
||||
}
|
||||
|
||||
private async Task<bool> RefreshPersonAsync(Guid personId, CancellationToken cancellationToken)
|
||||
@@ -243,8 +240,8 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
||||
|
||||
var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
|
||||
{
|
||||
ImageRefreshMode = hasImage ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default,
|
||||
MetadataRefreshMode = hasOverview ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default
|
||||
ImageRefreshMode = hasImage ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.FullRefresh,
|
||||
MetadataRefreshMode = hasOverview ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.FullRefresh
|
||||
};
|
||||
|
||||
await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -309,7 +309,7 @@ namespace Emby.Server.Implementations.Session
|
||||
{
|
||||
if (!session.SessionControllers.Any(i => i.IsSessionActive))
|
||||
{
|
||||
var key = GetSessionKey(session.Client, session.DeviceId);
|
||||
var key = GetSessionKey(session.Client, session.DeviceId, session.UserId);
|
||||
|
||||
_activeConnections.TryRemove(key, out _);
|
||||
if (!string.IsNullOrEmpty(session.PlayState?.LiveStreamId))
|
||||
@@ -369,7 +369,7 @@ namespace Emby.Server.Implementations.Session
|
||||
|
||||
if (session is not null)
|
||||
{
|
||||
var key = GetSessionKey(session.Client, session.DeviceId);
|
||||
var key = GetSessionKey(session.Client, session.DeviceId, session.UserId);
|
||||
|
||||
_activeConnections.TryRemove(key, out _);
|
||||
|
||||
@@ -475,8 +475,11 @@ namespace Emby.Server.Implementations.Session
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetSessionKey(string appName, string deviceId)
|
||||
=> appName + deviceId;
|
||||
// The user is part of the key because the client name and the device id are taken from the
|
||||
// request headers and are not bound to the access token. Without it, any authenticated user
|
||||
// could claim another user's client/device pair and take over their session.
|
||||
private static string GetSessionKey(string appName, string deviceId, Guid userId)
|
||||
=> appName + deviceId + userId.ToString("N", CultureInfo.InvariantCulture);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the connection.
|
||||
@@ -500,7 +503,7 @@ namespace Emby.Server.Implementations.Session
|
||||
|
||||
ArgumentException.ThrowIfNullOrEmpty(deviceId);
|
||||
|
||||
var key = GetSessionKey(appName, deviceId);
|
||||
var key = GetSessionKey(appName, deviceId, user?.Id ?? Guid.Empty);
|
||||
SessionInfo newSession = CreateSessionInfo(key, appName, appVersion, deviceId, deviceName, remoteEndPoint, user);
|
||||
SessionInfo sessionInfo = _activeConnections.GetOrAdd(key, newSession);
|
||||
if (ReferenceEquals(newSession, sessionInfo))
|
||||
@@ -655,7 +658,7 @@ namespace Emby.Server.Implementations.Session
|
||||
ItemId = session.NowPlayingItem is null ? Guid.Empty : session.NowPlayingItem.Id,
|
||||
SessionId = session.Id,
|
||||
MediaSourceId = session.PlayState?.MediaSourceId,
|
||||
PositionTicks = session.PlayState?.PositionTicks
|
||||
PositionTicks = session.LastPlaybackCheckInPositionTicks
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -1537,11 +1540,52 @@ namespace Emby.Server.Implementations.Session
|
||||
return SendMessageToSession(session, SessionMessageType.Playstate, command, cancellationToken);
|
||||
}
|
||||
|
||||
private static void AssertCanControl(SessionInfo session, SessionInfo controllingSession)
|
||||
private void AssertCanControl(SessionInfo session, SessionInfo controllingSession)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
|
||||
ArgumentNullException.ThrowIfNull(controllingSession);
|
||||
|
||||
var controllingUserId = controllingSession.UserId;
|
||||
|
||||
// Controlling a session is always allowed when:
|
||||
// - the caller has no associated user (an API key, which is a privileged context),
|
||||
// - the target session is public (has no owning user), or
|
||||
// - the caller's user is associated with the target session.
|
||||
// Controlling a session owned by a different user requires the
|
||||
// EnableRemoteControlOfOtherUsers permission.
|
||||
if (controllingUserId.IsEmpty()
|
||||
|| session.UserId.IsEmpty()
|
||||
|| session.ContainsUser(controllingUserId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var controllingUser = _userManager.GetUserById(controllingUserId);
|
||||
if (controllingUser is null
|
||||
|| !controllingUser.HasPermission(PermissionKind.EnableRemoteControlOfOtherUsers))
|
||||
{
|
||||
throw new SecurityException("The current user does not have permission to remote control other users.");
|
||||
}
|
||||
}
|
||||
|
||||
private void AssertCanAttachUser(SessionInfo controllingSession, Guid userId)
|
||||
{
|
||||
var controllingUserId = controllingSession.UserId;
|
||||
|
||||
// Playback reported by a session is also written to the user data of its additional users,
|
||||
// so attaching anyone but the calling user requires administrative privileges.
|
||||
if (controllingUserId.IsEmpty() || controllingUserId.Equals(userId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var controllingUser = _userManager.GetUserById(controllingUserId);
|
||||
if (controllingUser is null
|
||||
|| !controllingUser.HasPermission(PermissionKind.IsAdministrator))
|
||||
{
|
||||
throw new SecurityException("The current user does not have permission to attach another user to a session.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1559,16 +1603,24 @@ namespace Emby.Server.Implementations.Session
|
||||
/// <summary>
|
||||
/// Adds the additional user.
|
||||
/// </summary>
|
||||
/// <param name="controllingSessionId">The controlling session identifier.</param>
|
||||
/// <param name="sessionId">The session identifier.</param>
|
||||
/// <param name="userId">The user identifier.</param>
|
||||
/// <exception cref="UnauthorizedAccessException">Cannot modify additional users without authenticating first.</exception>
|
||||
/// <exception cref="SecurityException">The controlling user is not allowed to attach the user to the session.</exception>
|
||||
/// <exception cref="ArgumentException">The requested user is already the primary user of the session.</exception>
|
||||
public void AddAdditionalUser(string sessionId, Guid userId)
|
||||
public void AddAdditionalUser(string controllingSessionId, string sessionId, Guid userId)
|
||||
{
|
||||
CheckDisposed();
|
||||
|
||||
var session = GetSession(sessionId);
|
||||
|
||||
if (!string.IsNullOrEmpty(controllingSessionId))
|
||||
{
|
||||
var controllingSession = GetSession(controllingSessionId);
|
||||
AssertCanControl(session, controllingSession);
|
||||
AssertCanAttachUser(controllingSession, userId);
|
||||
}
|
||||
|
||||
if (session.UserId.Equals(userId))
|
||||
{
|
||||
throw new ArgumentException("The requested user is already the primary user of the session.");
|
||||
@@ -1576,7 +1628,8 @@ namespace Emby.Server.Implementations.Session
|
||||
|
||||
if (session.AdditionalUsers.All(i => !i.UserId.Equals(userId)))
|
||||
{
|
||||
var user = _userManager.GetUserById(userId);
|
||||
var user = _userManager.GetUserById(userId)
|
||||
?? throw new ArgumentException("The requested user does not exist.");
|
||||
var newUser = new SessionUserInfo
|
||||
{
|
||||
UserId = userId,
|
||||
@@ -1590,16 +1643,22 @@ namespace Emby.Server.Implementations.Session
|
||||
/// <summary>
|
||||
/// Removes the additional user.
|
||||
/// </summary>
|
||||
/// <param name="controllingSessionId">The controlling session identifier.</param>
|
||||
/// <param name="sessionId">The session identifier.</param>
|
||||
/// <param name="userId">The user identifier.</param>
|
||||
/// <exception cref="UnauthorizedAccessException">Cannot modify additional users without authenticating first.</exception>
|
||||
/// <exception cref="SecurityException">The controlling user is not allowed to control the session.</exception>
|
||||
/// <exception cref="ArgumentException">The requested user is already the primary user of the session.</exception>
|
||||
public void RemoveAdditionalUser(string sessionId, Guid userId)
|
||||
public void RemoveAdditionalUser(string controllingSessionId, string sessionId, Guid userId)
|
||||
{
|
||||
CheckDisposed();
|
||||
|
||||
var session = GetSession(sessionId);
|
||||
|
||||
if (!string.IsNullOrEmpty(controllingSessionId))
|
||||
{
|
||||
AssertCanControl(session, GetSession(controllingSessionId));
|
||||
}
|
||||
|
||||
if (session.UserId.Equals(userId))
|
||||
{
|
||||
throw new ArgumentException("The requested user is already the primary user of the session.");
|
||||
@@ -1803,14 +1862,21 @@ namespace Emby.Server.Implementations.Session
|
||||
/// <summary>
|
||||
/// Reports the capabilities.
|
||||
/// </summary>
|
||||
/// <param name="controllingSessionId">The controlling session identifier.</param>
|
||||
/// <param name="sessionId">The session identifier.</param>
|
||||
/// <param name="capabilities">The capabilities.</param>
|
||||
public void ReportCapabilities(string sessionId, ClientCapabilities capabilities)
|
||||
/// <exception cref="SecurityException">The controlling user is not allowed to control the session.</exception>
|
||||
public void ReportCapabilities(string controllingSessionId, string sessionId, ClientCapabilities capabilities)
|
||||
{
|
||||
CheckDisposed();
|
||||
|
||||
var session = GetSession(sessionId);
|
||||
|
||||
if (!string.IsNullOrEmpty(controllingSessionId))
|
||||
{
|
||||
AssertCanControl(session, GetSession(controllingSessionId));
|
||||
}
|
||||
|
||||
ReportCapabilities(session, capabilities, true);
|
||||
}
|
||||
|
||||
@@ -1905,13 +1971,18 @@ namespace Emby.Server.Implementations.Session
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ReportNowViewingItem(string sessionId, string itemId)
|
||||
public void ReportNowViewingItem(string controllingSessionId, string sessionId, string itemId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(itemId);
|
||||
|
||||
var item = _libraryManager.GetItemById(new Guid(itemId));
|
||||
var session = GetSession(sessionId);
|
||||
|
||||
if (!string.IsNullOrEmpty(controllingSessionId))
|
||||
{
|
||||
AssertCanControl(session, GetSession(controllingSessionId));
|
||||
}
|
||||
|
||||
session.NowViewingItem = GetItemInfo(item, null);
|
||||
}
|
||||
|
||||
|
||||
@@ -223,7 +223,7 @@ namespace Emby.Server.Implementations.Session
|
||||
|
||||
if (inactive.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("Sending ForceKeepAlive message to {0} inactive WebSockets.", inactive.Count);
|
||||
_logger.LogDebug("Sending ForceKeepAlive message to {0} inactive WebSockets.", inactive.Count);
|
||||
}
|
||||
|
||||
foreach (var webSocket in inactive)
|
||||
|
||||
@@ -500,8 +500,9 @@ namespace Emby.Server.Implementations.Updates
|
||||
var plugins = _pluginManager.Plugins;
|
||||
foreach (var plugin in plugins)
|
||||
{
|
||||
// Don't auto update when plugin marked not to, or when it's disabled.
|
||||
if (plugin.Manifest?.AutoUpdate == false || plugin.Manifest?.Status == PluginStatus.Disabled)
|
||||
// Don't auto update when plugin marked not to, or when it's disabled or pending removal.
|
||||
if (plugin.Manifest?.AutoUpdate == false
|
||||
|| plugin.Manifest?.Status is PluginStatus.Disabled or PluginStatus.Deleted)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -126,6 +126,12 @@ public class ArtistsController : BaseJellyfinApiController
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
|
||||
// Asking for a type filter has always implied wanting that type's counts back.
|
||||
if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts))
|
||||
{
|
||||
dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts];
|
||||
}
|
||||
|
||||
User? user = null;
|
||||
BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId);
|
||||
|
||||
@@ -193,31 +199,7 @@ public class ArtistsController : BaseJellyfinApiController
|
||||
|
||||
var result = _libraryManager.GetArtists(query);
|
||||
|
||||
var dtos = result.Items.Select(i =>
|
||||
{
|
||||
var (baseItem, itemCounts) = i;
|
||||
var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
|
||||
|
||||
if (includeItemTypes.Length != 0)
|
||||
{
|
||||
dto.ChildCount = itemCounts.ItemCount;
|
||||
dto.ProgramCount = itemCounts.ProgramCount;
|
||||
dto.SeriesCount = itemCounts.SeriesCount;
|
||||
dto.EpisodeCount = itemCounts.EpisodeCount;
|
||||
dto.MovieCount = itemCounts.MovieCount;
|
||||
dto.TrailerCount = itemCounts.TrailerCount;
|
||||
dto.AlbumCount = itemCounts.AlbumCount;
|
||||
dto.SongCount = itemCounts.SongCount;
|
||||
dto.ArtistCount = itemCounts.ArtistCount;
|
||||
}
|
||||
|
||||
return dto;
|
||||
});
|
||||
|
||||
return new QueryResult<BaseItemDto>(
|
||||
query.StartIndex,
|
||||
result.TotalRecordCount,
|
||||
dtos.ToArray());
|
||||
return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -298,6 +280,12 @@ public class ArtistsController : BaseJellyfinApiController
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
|
||||
// Asking for a type filter has always implied wanting that type's counts back.
|
||||
if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts))
|
||||
{
|
||||
dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts];
|
||||
}
|
||||
|
||||
User? user = null;
|
||||
BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId);
|
||||
|
||||
@@ -365,31 +353,7 @@ public class ArtistsController : BaseJellyfinApiController
|
||||
|
||||
var result = _libraryManager.GetAlbumArtists(query);
|
||||
|
||||
var dtos = result.Items.Select(i =>
|
||||
{
|
||||
var (baseItem, itemCounts) = i;
|
||||
var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
|
||||
|
||||
if (includeItemTypes.Length != 0)
|
||||
{
|
||||
dto.ChildCount = itemCounts.ItemCount;
|
||||
dto.ProgramCount = itemCounts.ProgramCount;
|
||||
dto.SeriesCount = itemCounts.SeriesCount;
|
||||
dto.EpisodeCount = itemCounts.EpisodeCount;
|
||||
dto.MovieCount = itemCounts.MovieCount;
|
||||
dto.TrailerCount = itemCounts.TrailerCount;
|
||||
dto.AlbumCount = itemCounts.AlbumCount;
|
||||
dto.SongCount = itemCounts.SongCount;
|
||||
dto.ArtistCount = itemCounts.ArtistCount;
|
||||
}
|
||||
|
||||
return dto;
|
||||
});
|
||||
|
||||
return new QueryResult<BaseItemDto>(
|
||||
query.StartIndex,
|
||||
result.TotalRecordCount,
|
||||
dtos.ToArray());
|
||||
return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -20,7 +20,6 @@ using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Controller.Streaming;
|
||||
using MediaBrowser.MediaEncoding.Encoder;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using MediaBrowser.Model.Dlna;
|
||||
using MediaBrowser.Model.Entities;
|
||||
@@ -1456,22 +1455,16 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
|
||||
var segmentExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer);
|
||||
|
||||
TranscodingJob? job;
|
||||
|
||||
if (System.IO.File.Exists(segmentPath))
|
||||
{
|
||||
job = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
|
||||
_logger.LogDebug("returning {0} [it exists, try 1]", segmentPath);
|
||||
return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Keep segment selection and transcoding replacement under the same playlist lock.
|
||||
// An out-of-order request must not replace a job while another request is using its output.
|
||||
using (await _transcodeManager.LockAsync(playlistPath, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
TranscodingJob? job;
|
||||
var startTranscoding = false;
|
||||
if (System.IO.File.Exists(segmentPath))
|
||||
{
|
||||
job = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
|
||||
_logger.LogDebug("returning {0} [it exists, try 2]", segmentPath);
|
||||
_logger.LogDebug("returning {0} [it exists]", segmentPath);
|
||||
return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -1505,6 +1498,9 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
// If the playlist doesn't already exist, startup ffmpeg
|
||||
try
|
||||
{
|
||||
var currentJob = _transcodeManager.GetTranscodingJob(playlistPath, TranscodingJobType);
|
||||
await WaitForActiveTranscodingRequests(currentJob, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await _transcodeManager.KillTranscodingJobs(streamingRequest.DeviceId, streamingRequest.PlaySessionId, p => false)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
@@ -1540,11 +1536,19 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("returning {0} [general case]", segmentPath);
|
||||
job ??= _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
|
||||
return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogDebug("returning {0} [general case]", segmentPath);
|
||||
job ??= _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
|
||||
return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
internal static async Task WaitForActiveTranscodingRequests(TranscodingJob? job, CancellationToken cancellationToken)
|
||||
{
|
||||
while (job?.ActiveRequestCount > 0)
|
||||
{
|
||||
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static double[] GetSegmentLengths(StreamState state)
|
||||
@@ -1647,9 +1651,9 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
segmentFormat,
|
||||
startNumber.ToString(CultureInfo.InvariantCulture),
|
||||
baseUrlParam,
|
||||
EncodingUtils.NormalizePath(outputTsArg),
|
||||
outputTsArg.EscapeProcessArgument(),
|
||||
hlsArguments,
|
||||
EncodingUtils.NormalizePath(outputPath)).Trim();
|
||||
outputPath.EscapeProcessArgument()).Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -97,6 +97,12 @@ public class GenresController : BaseJellyfinApiController
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes);
|
||||
|
||||
// Asking for a type filter has always implied wanting that type's counts back.
|
||||
if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts))
|
||||
{
|
||||
dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts];
|
||||
}
|
||||
|
||||
User? user = userId.IsNullOrEmpty()
|
||||
? null
|
||||
: _userManager.GetUserById(userId.Value);
|
||||
@@ -143,8 +149,7 @@ public class GenresController : BaseJellyfinApiController
|
||||
result = _libraryManager.GetGenres(query);
|
||||
}
|
||||
|
||||
var shouldIncludeItemTypes = includeItemTypes.Length != 0;
|
||||
return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
|
||||
return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -13,6 +13,7 @@ using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Model.Providers;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -263,7 +264,7 @@ public class ItemLookupController : BaseJellyfinApiController
|
||||
searchResult.ProviderIds);
|
||||
|
||||
// Since the refresh process won't erase provider Ids, we need to set this explicitly now.
|
||||
item.ProviderIds = searchResult.ProviderIds;
|
||||
item.SetProviderIds(searchResult.ProviderIds);
|
||||
await _providerManager.RefreshFullItem(
|
||||
item,
|
||||
new MetadataRefreshOptions(new DirectoryService(_fileSystem))
|
||||
|
||||
@@ -428,15 +428,7 @@ public class ItemUpdateController : BaseJellyfinApiController
|
||||
|
||||
if (request.ProviderIds is not null)
|
||||
{
|
||||
foreach (var pair in request.ProviderIds.ToList())
|
||||
{
|
||||
if (string.IsNullOrEmpty(pair.Value))
|
||||
{
|
||||
request.ProviderIds.Remove(pair.Key);
|
||||
}
|
||||
}
|
||||
|
||||
item.ProviderIds = request.ProviderIds;
|
||||
item.SetProviderIds(request.ProviderIds);
|
||||
}
|
||||
|
||||
if (item is Video video)
|
||||
|
||||
@@ -14,6 +14,7 @@ using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using MediaBrowser.Model.Entities;
|
||||
@@ -122,12 +123,14 @@ public class LibraryStructureController : BaseJellyfinApiController
|
||||
/// <param name="newName">The new name.</param>
|
||||
/// <param name="refreshLibrary">Whether to refresh the library.</param>
|
||||
/// <response code="204">Folder renamed.</response>
|
||||
/// <response code="400">The new name is not a valid library name.</response>
|
||||
/// <response code="404">Library doesn't exist.</response>
|
||||
/// <response code="409">Library already exists.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> on success, a <see cref="NotFoundResult"/> if the library doesn't exist, a <see cref="ConflictResult"/> if the new name is already taken.</returns>
|
||||
/// <returns>A <see cref="NoContentResult"/> on success, a <see cref="BadRequestResult"/> if the new name is invalid, a <see cref="NotFoundResult"/> if the library doesn't exist, a <see cref="ConflictResult"/> if the new name is already taken.</returns>
|
||||
/// <exception cref="ArgumentNullException">The new name may not be null.</exception>
|
||||
[HttpPost("Name")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public ActionResult RenameVirtualFolder(
|
||||
@@ -147,10 +150,15 @@ public class LibraryStructureController : BaseJellyfinApiController
|
||||
|
||||
var rootFolderPath = _appPaths.DefaultUserViewsPath;
|
||||
|
||||
var currentPath = Path.Combine(rootFolderPath, name);
|
||||
var newPath = Path.Combine(rootFolderPath, newName);
|
||||
// Both names are caller supplied, so they have to be confined to the libraries root.
|
||||
var newPath = FileSystemHelper.GetChildPath(rootFolderPath, newName);
|
||||
if (newPath is null)
|
||||
{
|
||||
return BadRequest("The new name is not a valid library name.");
|
||||
}
|
||||
|
||||
if (!Directory.Exists(currentPath))
|
||||
var currentPath = FileSystemHelper.GetChildPath(rootFolderPath, name);
|
||||
if (currentPath is null || !Directory.Exists(currentPath))
|
||||
{
|
||||
return NotFound("The media collection does not exist.");
|
||||
}
|
||||
|
||||
@@ -98,6 +98,12 @@ public class MusicGenresController : BaseJellyfinApiController
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes);
|
||||
|
||||
// Asking for a type filter has always implied wanting that type's counts back.
|
||||
if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts))
|
||||
{
|
||||
dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts];
|
||||
}
|
||||
|
||||
User? user = userId.IsNullOrEmpty()
|
||||
? null
|
||||
: _userManager.GetUserById(userId.Value);
|
||||
@@ -134,8 +140,7 @@ public class MusicGenresController : BaseJellyfinApiController
|
||||
|
||||
var result = _libraryManager.GetMusicGenres(query);
|
||||
|
||||
var shouldIncludeItemTypes = includeItemTypes.Length != 0;
|
||||
return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
|
||||
return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Linq;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Api.ModelBinders;
|
||||
using Jellyfin.Data;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
@@ -103,6 +104,7 @@ public class PersonsController : BaseJellyfinApiController
|
||||
personTypes,
|
||||
excludePersonTypes)
|
||||
{
|
||||
AccessFilter = BuildAccessFilter(user),
|
||||
NameContains = searchTerm,
|
||||
NameStartsWith = nameStartsWith,
|
||||
NameLessThan = nameLessThan,
|
||||
@@ -123,6 +125,20 @@ public class PersonsController : BaseJellyfinApiController
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
// People are not owned by a library, so nothing in the Peoples table says which of them a user is
|
||||
// allowed to see; that only follows from the items they are credited on.
|
||||
private InternalItemsQuery? BuildAccessFilter(User? user)
|
||||
{
|
||||
if (user is null || !user.HasContentRestrictions())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var accessFilter = new InternalItemsQuery(user) { IncludeOwnedItems = true };
|
||||
_libraryManager.ConfigureUserAccess(accessFilter, user);
|
||||
return accessFilter;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get person by name.
|
||||
/// </summary>
|
||||
|
||||
@@ -521,7 +521,7 @@ public class PlaylistsController : BaseJellyfinApiController
|
||||
[FromQuery] int? imageTypeLimit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes)
|
||||
{
|
||||
var callingUserId = userId ?? User.GetUserId();
|
||||
var callingUserId = RequestHelpers.GetUserId(User, userId);
|
||||
var playlist = _playlistManager.GetPlaylistForUser(playlistId, callingUserId);
|
||||
if (playlist is null)
|
||||
{
|
||||
|
||||
@@ -306,11 +306,14 @@ public class SessionController : BaseJellyfinApiController
|
||||
[HttpPost("Sessions/{sessionId}/User/{userId}")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public ActionResult AddUserToSession(
|
||||
public async Task<ActionResult> AddUserToSession(
|
||||
[FromRoute, Required] string sessionId,
|
||||
[FromRoute, Required] Guid userId)
|
||||
{
|
||||
_sessionManager.AddAdditionalUser(sessionId, userId);
|
||||
_sessionManager.AddAdditionalUser(
|
||||
await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false),
|
||||
sessionId,
|
||||
userId);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -324,11 +327,14 @@ public class SessionController : BaseJellyfinApiController
|
||||
[HttpDelete("Sessions/{sessionId}/User/{userId}")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public ActionResult RemoveUserFromSession(
|
||||
public async Task<ActionResult> RemoveUserFromSession(
|
||||
[FromRoute, Required] string sessionId,
|
||||
[FromRoute, Required] Guid userId)
|
||||
{
|
||||
_sessionManager.RemoveAdditionalUser(sessionId, userId);
|
||||
_sessionManager.RemoveAdditionalUser(
|
||||
await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false),
|
||||
sessionId,
|
||||
userId);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -352,12 +358,13 @@ public class SessionController : BaseJellyfinApiController
|
||||
[FromQuery] bool supportsMediaControl = false,
|
||||
[FromQuery] bool supportsPersistentIdentifier = true)
|
||||
{
|
||||
var currentSessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
id = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
id = currentSessionId;
|
||||
}
|
||||
|
||||
_sessionManager.ReportCapabilities(id, new ClientCapabilities
|
||||
_sessionManager.ReportCapabilities(currentSessionId, id, new ClientCapabilities
|
||||
{
|
||||
PlayableMediaTypes = playableMediaTypes,
|
||||
SupportedCommands = supportedCommands,
|
||||
@@ -381,12 +388,13 @@ public class SessionController : BaseJellyfinApiController
|
||||
[FromQuery] string? id,
|
||||
[FromBody, Required] ClientCapabilitiesDto capabilities)
|
||||
{
|
||||
var currentSessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
id = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
id = currentSessionId;
|
||||
}
|
||||
|
||||
_sessionManager.ReportCapabilities(id, capabilities.ToClientCapabilities());
|
||||
_sessionManager.ReportCapabilities(currentSessionId, id, capabilities.ToClientCapabilities());
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
@@ -405,9 +413,9 @@ public class SessionController : BaseJellyfinApiController
|
||||
[FromQuery] string? sessionId,
|
||||
[FromQuery, Required] string? itemId)
|
||||
{
|
||||
string session = sessionId ?? await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
var currentSessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
|
||||
_sessionManager.ReportNowViewingItem(session, itemId);
|
||||
_sessionManager.ReportNowViewingItem(currentSessionId, sessionId ?? currentSessionId, itemId);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
|
||||
@@ -92,6 +92,12 @@ public class StudiosController : BaseJellyfinApiController
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
|
||||
// Asking for a type filter has always implied wanting that type's counts back.
|
||||
if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts))
|
||||
{
|
||||
dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts];
|
||||
}
|
||||
|
||||
User? user = userId.IsNullOrEmpty()
|
||||
? null
|
||||
: _userManager.GetUserById(userId.Value);
|
||||
@@ -126,8 +132,7 @@ public class StudiosController : BaseJellyfinApiController
|
||||
}
|
||||
|
||||
var result = _libraryManager.GetStudios(query);
|
||||
var shouldIncludeItemTypes = includeItemTypes.Length != 0;
|
||||
return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
|
||||
return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -34,6 +34,8 @@ namespace Jellyfin.Api.Controllers;
|
||||
[Tags("Library")]
|
||||
public class UserLibraryController : BaseJellyfinApiController
|
||||
{
|
||||
private static readonly TimeSpan RefreshOnDemandTimeout = TimeSpan.FromSeconds(3);
|
||||
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IUserDataManager _userDataRepository;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
@@ -79,7 +81,7 @@ public class UserLibraryController : BaseJellyfinApiController
|
||||
/// <returns>An <see cref="OkResult"/> containing the item.</returns>
|
||||
[HttpGet("Items/{itemId}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<BaseItemDto> GetItem(
|
||||
public async Task<ActionResult<BaseItemDto>> GetItem(
|
||||
[FromQuery] Guid? userId,
|
||||
[FromRoute, Required] Guid itemId)
|
||||
{
|
||||
@@ -98,7 +100,7 @@ public class UserLibraryController : BaseJellyfinApiController
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
QueueRefreshOnDemandIfNeeded(item);
|
||||
await RefreshOnDemandIfNeeded(item).ConfigureAwait(false);
|
||||
|
||||
var dtoOptions = new DtoOptions();
|
||||
|
||||
@@ -116,7 +118,7 @@ public class UserLibraryController : BaseJellyfinApiController
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[Obsolete("Kept for backwards compatibility")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public ActionResult<BaseItemDto> GetItemLegacy(
|
||||
public Task<ActionResult<BaseItemDto>> GetItemLegacy(
|
||||
[FromRoute, Required] Guid userId,
|
||||
[FromRoute, Required] Guid itemId)
|
||||
=> GetItem(userId, itemId);
|
||||
@@ -643,7 +645,7 @@ public class UserLibraryController : BaseJellyfinApiController
|
||||
limit,
|
||||
groupItems);
|
||||
|
||||
private void QueueRefreshOnDemandIfNeeded(BaseItem item)
|
||||
private async Task RefreshOnDemandIfNeeded(BaseItem item)
|
||||
{
|
||||
if (item is not Person)
|
||||
{
|
||||
@@ -656,15 +658,24 @@ public class UserLibraryController : BaseJellyfinApiController
|
||||
return;
|
||||
}
|
||||
|
||||
_providerManager.QueueRefresh(
|
||||
item.Id,
|
||||
new MetadataRefreshOptions(new DirectoryService(_fileSystem))
|
||||
{
|
||||
MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
|
||||
ImageRefreshMode = MetadataRefreshMode.FullRefresh,
|
||||
ForceSave = true
|
||||
},
|
||||
RefreshPriority.High);
|
||||
var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
|
||||
{
|
||||
MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
|
||||
ImageRefreshMode = MetadataRefreshMode.FullRefresh,
|
||||
ForceSave = true
|
||||
};
|
||||
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(HttpContext.RequestAborted);
|
||||
timeout.CancelAfter(RefreshOnDemandTimeout);
|
||||
|
||||
try
|
||||
{
|
||||
await item.RefreshMetadata(options, timeout.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!HttpContext.RequestAborted.IsCancellationRequested)
|
||||
{
|
||||
_providerManager.QueueRefresh(item.Id, options, RefreshPriority.High);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -156,7 +156,6 @@ public static class RequestHelpers
|
||||
QueryResult<(BaseItem Item, ItemCounts ItemCounts)> result,
|
||||
DtoOptions dtoOptions,
|
||||
IDtoService dtoService,
|
||||
bool includeItemTypes,
|
||||
User? user)
|
||||
{
|
||||
var dtos = result.Items.Select(i =>
|
||||
@@ -164,7 +163,7 @@ public static class RequestHelpers
|
||||
var (baseItem, counts) = i;
|
||||
var dto = dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
|
||||
|
||||
if (includeItemTypes)
|
||||
if (counts is not null)
|
||||
{
|
||||
dto.ChildCount = counts.ItemCount;
|
||||
dto.ProgramCount = counts.ProgramCount;
|
||||
@@ -175,6 +174,7 @@ public static class RequestHelpers
|
||||
dto.AlbumCount = counts.AlbumCount;
|
||||
dto.SongCount = counts.SongCount;
|
||||
dto.ArtistCount = counts.ArtistCount;
|
||||
dto.MusicVideoCount = counts.MusicVideoCount;
|
||||
}
|
||||
|
||||
return dto;
|
||||
|
||||
@@ -198,11 +198,6 @@ public static class StreamingHelpers
|
||||
state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(streamingRequest.AudioBitRate, streamingRequest.AudioCodec, state.AudioStream, state.OutputAudioChannels) ?? 0;
|
||||
}
|
||||
|
||||
if (outputAudioCodec.StartsWith("pcm_", StringComparison.Ordinal))
|
||||
{
|
||||
containerInternal = ".pcm";
|
||||
}
|
||||
|
||||
if (state.VideoRequest is not null)
|
||||
{
|
||||
state.OutputVideoCodec = state.Request.VideoCodec;
|
||||
|
||||
@@ -162,6 +162,23 @@ public static class UserEntityExtensions
|
||||
return Array.IndexOf(GetPreferenceValues<Guid>(entity, PreferenceKind.GroupedFolders), id) != -1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether any library, parental rating or tag rule keeps content from this user.
|
||||
/// </summary>
|
||||
/// <param name="entity">The user to check.</param>
|
||||
/// <returns><c>True</c> if some content in the library is hidden from this user.</returns>
|
||||
public static bool HasContentRestrictions(this User entity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
|
||||
return !entity.HasPermission(PermissionKind.EnableAllFolders)
|
||||
|| entity.GetPreference(PreferenceKind.BlockedMediaFolders).Length > 0
|
||||
|| entity.MaxParentalRatingScore.HasValue
|
||||
|| entity.GetPreference(PreferenceKind.BlockedTags).Length > 0
|
||||
|| entity.GetPreference(PreferenceKind.AllowedTags).Length > 0
|
||||
|| entity.GetPreference(PreferenceKind.BlockUnratedItems).Length > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the default permissions for a user. Should only be called on user creation.
|
||||
/// </summary>
|
||||
|
||||
@@ -144,11 +144,6 @@ public sealed partial class BaseItemRepository
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(filter);
|
||||
|
||||
if (!filter.Limit.HasValue)
|
||||
{
|
||||
filter.EnableTotalRecordCount = false;
|
||||
}
|
||||
|
||||
using var context = _dbProvider.CreateDbContext();
|
||||
|
||||
var innerQueryFilter = TranslateQuery(context.BaseItems.Where(e => e.Id != EF.Constant(PlaceholderId)), context, new InternalItemsQuery(filter.User)
|
||||
@@ -251,14 +246,20 @@ public sealed partial class BaseItemRepository
|
||||
}
|
||||
|
||||
result.StartIndex = filter.StartIndex ?? 0;
|
||||
if (filter.IncludeItemTypes.Length > 0)
|
||||
var page = query.AsEnumerable().Where(e => e is not null).ToList();
|
||||
|
||||
if (filter.DtoOptions.ContainsField(ItemFields.ItemCounts))
|
||||
{
|
||||
var countsByCleanName = BuildItemCountsByCleanName(context, filter, itemValueTypes);
|
||||
var pageCleanNames = page
|
||||
.Where(e => !string.IsNullOrEmpty(e.CleanName))
|
||||
.Select(e => e.CleanName!)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var countsByCleanName = BuildItemCountsByCleanName(context, filter, itemValueTypes, pageCleanNames);
|
||||
result.Items =
|
||||
[
|
||||
.. query
|
||||
.AsEnumerable()
|
||||
.Where(e => e is not null)
|
||||
.. page
|
||||
.Select(e =>
|
||||
{
|
||||
var item = DeserializeBaseItem(e, filter.SkipDeserialization);
|
||||
@@ -273,9 +274,7 @@ public sealed partial class BaseItemRepository
|
||||
{
|
||||
result.Items =
|
||||
[
|
||||
.. query
|
||||
.AsEnumerable()
|
||||
.Where(e => e != null)
|
||||
.. page
|
||||
.Select(e => DeserializeBaseItem(e, filter.SkipDeserialization))
|
||||
.Where(item => item != null)
|
||||
.Select(item => (item!, (ItemCounts?)null))
|
||||
@@ -286,14 +285,22 @@ public sealed partial class BaseItemRepository
|
||||
}
|
||||
|
||||
private Dictionary<string, ItemCounts> BuildItemCountsByCleanName(
|
||||
Database.Implementations.JellyfinDbContext context,
|
||||
JellyfinDbContext context,
|
||||
InternalItemsQuery filter,
|
||||
IReadOnlyList<ItemValueType> itemValueTypes)
|
||||
IReadOnlyList<ItemValueType> itemValueTypes,
|
||||
IReadOnlyList<string> cleanNames)
|
||||
{
|
||||
var typeSubQuery = new InternalItemsQuery(filter.User)
|
||||
var countsByCleanName = new Dictionary<string, ItemCounts>();
|
||||
if (cleanNames.Count == 0)
|
||||
{
|
||||
return countsByCleanName;
|
||||
}
|
||||
|
||||
// The counts describe everything the value is attached to, not only the types the list was
|
||||
// filtered down to.
|
||||
var scopeQuery = new InternalItemsQuery(filter.User)
|
||||
{
|
||||
ExcludeItemTypes = filter.ExcludeItemTypes,
|
||||
IncludeItemTypes = filter.IncludeItemTypes,
|
||||
MediaTypes = filter.MediaTypes,
|
||||
AncestorIds = filter.AncestorIds,
|
||||
ExcludeItemIds = filter.ExcludeItemIds,
|
||||
@@ -303,33 +310,51 @@ public sealed partial class BaseItemRepository
|
||||
IsPlayed = filter.IsPlayed
|
||||
};
|
||||
|
||||
var 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 scopedItems = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, scopeQuery);
|
||||
var valueLinks = context.ItemValuesMap
|
||||
.AsNoTracking()
|
||||
.Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type))
|
||||
.WhereOneOrMany(cleanNames, ivm => ivm.ItemValue.CleanValue);
|
||||
|
||||
var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
|
||||
var movieTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie];
|
||||
var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode];
|
||||
var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum];
|
||||
var musicArtistTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist];
|
||||
var musicVideoTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicVideo];
|
||||
var programTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.LiveTvProgram];
|
||||
var audioTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio];
|
||||
var trailerTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Trailer];
|
||||
var itemIds = itemCountQuery.Select(e => e.Id);
|
||||
|
||||
// Rewrite query to avoid SelectMany on navigation properties (which requires SQL APPLY, not supported on SQLite)
|
||||
// Instead, start from ItemValueMaps and join with BaseItems.
|
||||
var rawCounts = context.ItemValuesMap
|
||||
.Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type))
|
||||
.Where(ivm => itemIds.Contains(ivm.ItemId))
|
||||
var rawCounts = valueLinks
|
||||
.Join(
|
||||
context.BaseItems,
|
||||
scopedItems,
|
||||
ivm => ivm.ItemId,
|
||||
e => e.Id,
|
||||
(ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type })
|
||||
.GroupBy(x => new { x.CleanName, x.Type })
|
||||
.Select(g => new { g.Key.CleanName, g.Key.Type, Count = g.Count() })
|
||||
.AsEnumerable();
|
||||
(ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type, e.SeriesId })
|
||||
.GroupBy(x => new { x.CleanName, x.Type, x.SeriesId })
|
||||
.Select(g => new { g.Key.CleanName, g.Key.Type, g.Key.SeriesId, Count = g.Count() })
|
||||
.ToList();
|
||||
|
||||
// Only studios and genres pass down from a series to its episodes; an artist credit does not.
|
||||
var inheritsToEpisodes = itemValueTypes.Contains(ItemValueType.Studios) || itemValueTypes.Contains(ItemValueType.Genre);
|
||||
var episodeCounts = inheritsToEpisodes
|
||||
? BuildEpisodeCountsByCleanName(
|
||||
scopedItems,
|
||||
valueLinks,
|
||||
rawCounts
|
||||
.Where(x => x.Type == episodeTypeName)
|
||||
.Select(x => (x.CleanName, x.SeriesId, x.Count))
|
||||
.ToList(),
|
||||
seriesTypeName,
|
||||
episodeTypeName)
|
||||
: rawCounts
|
||||
.Where(x => x.Type == episodeTypeName)
|
||||
.GroupBy(x => x.CleanName)
|
||||
.ToDictionary(g => g.Key, g => g.Sum(x => x.Count));
|
||||
|
||||
var countsByCleanName = new Dictionary<string, ItemCounts>();
|
||||
foreach (var group in rawCounts.GroupBy(x => x.CleanName))
|
||||
{
|
||||
var counts = new ItemCounts();
|
||||
@@ -339,10 +364,6 @@ public sealed partial class BaseItemRepository
|
||||
{
|
||||
counts.SeriesCount += row.Count;
|
||||
}
|
||||
else if (row.Type == episodeTypeName)
|
||||
{
|
||||
counts.EpisodeCount += row.Count;
|
||||
}
|
||||
else if (row.Type == movieTypeName)
|
||||
{
|
||||
counts.MovieCount += row.Count;
|
||||
@@ -355,6 +376,14 @@ public sealed partial class BaseItemRepository
|
||||
{
|
||||
counts.ArtistCount += row.Count;
|
||||
}
|
||||
else if (row.Type == musicVideoTypeName)
|
||||
{
|
||||
counts.MusicVideoCount += row.Count;
|
||||
}
|
||||
else if (row.Type == programTypeName)
|
||||
{
|
||||
counts.ProgramCount += row.Count;
|
||||
}
|
||||
else if (row.Type == audioTypeName)
|
||||
{
|
||||
counts.SongCount += row.Count;
|
||||
@@ -365,9 +394,72 @@ public sealed partial class BaseItemRepository
|
||||
}
|
||||
}
|
||||
|
||||
// Episodes are counted separately: the value is usually only written on the series.
|
||||
counts.EpisodeCount = episodeCounts.GetValueOrDefault(group.Key);
|
||||
counts.ItemCount = counts.TotalItemCount();
|
||||
countsByCleanName[group.Key] = counts;
|
||||
}
|
||||
|
||||
// A value carried by nothing but the episodes below a tagged series has no row of its own.
|
||||
foreach (var (cleanName, episodeCount) in episodeCounts)
|
||||
{
|
||||
if (!countsByCleanName.ContainsKey(cleanName))
|
||||
{
|
||||
countsByCleanName[cleanName] = new ItemCounts { EpisodeCount = episodeCount, ItemCount = episodeCount };
|
||||
}
|
||||
}
|
||||
|
||||
return countsByCleanName;
|
||||
}
|
||||
|
||||
private static Dictionary<string, int> BuildEpisodeCountsByCleanName(
|
||||
IQueryable<BaseItemEntity> scopedItems,
|
||||
IQueryable<ItemValueMap> valueLinks,
|
||||
IReadOnlyList<(string CleanName, Guid? SeriesId, int Count)> taggedEpisodes,
|
||||
string seriesTypeName,
|
||||
string episodeTypeName)
|
||||
{
|
||||
// Resolved in steps rather than as one union: each of these drives off an index, while the
|
||||
// single-statement form leaves SQLite free to scan every episode in the library instead.
|
||||
var taggedSeries = valueLinks
|
||||
.Join(
|
||||
scopedItems.Where(e => e.Type == seriesTypeName),
|
||||
ivm => ivm.ItemId,
|
||||
e => e.Id,
|
||||
(ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, SeriesId = e.Id })
|
||||
.ToList();
|
||||
|
||||
var seriesIds = taggedSeries.Select(x => x.SeriesId).Distinct().ToArray();
|
||||
var episodesPerSeries = seriesIds.Length == 0
|
||||
? []
|
||||
: scopedItems
|
||||
.Where(e => e.Type == episodeTypeName && e.SeriesId != null)
|
||||
.WhereOneOrMany(seriesIds, e => e.SeriesId!.Value)
|
||||
.GroupBy(e => e.SeriesId!.Value)
|
||||
.Select(g => new { SeriesId = g.Key, Count = g.Count() })
|
||||
.ToDictionary(x => x.SeriesId, x => x.Count);
|
||||
|
||||
var episodeCounts = new Dictionary<string, int>();
|
||||
var seriesByCleanName = new Dictionary<string, HashSet<Guid>>();
|
||||
foreach (var group in taggedSeries.GroupBy(x => x.CleanName))
|
||||
{
|
||||
var series = group.Select(x => x.SeriesId).ToHashSet();
|
||||
seriesByCleanName[group.Key] = series;
|
||||
episodeCounts[group.Key] = series.Sum(id => episodesPerSeries.GetValueOrDefault(id));
|
||||
}
|
||||
|
||||
foreach (var (cleanName, seriesId, count) in taggedEpisodes)
|
||||
{
|
||||
if (seriesId is not null
|
||||
&& seriesByCleanName.TryGetValue(cleanName, out var series)
|
||||
&& series.Contains(seriesId.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
episodeCounts[cleanName] = episodeCounts.GetValueOrDefault(cleanName) + count;
|
||||
}
|
||||
|
||||
return episodeCounts;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using Jellyfin.Server.Implementations.Extensions;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Querying;
|
||||
@@ -323,10 +324,21 @@ public sealed partial class BaseItemRepository
|
||||
orderedQuery = query.OrderBy(relevanceExpression);
|
||||
}
|
||||
|
||||
// Folders carry no played flag of their own, so these two keys go through the same predicate
|
||||
// the isPlayed filter uses rather than through the stored-column lookup in OrderMapper.
|
||||
Expression<Func<BaseItemEntity, object?>> MapOrderByField(ItemSortBy sortBy) => sortBy switch
|
||||
{
|
||||
ItemSortBy.IsPlayed when filter.User is not null
|
||||
=> AsOrderKey(BuildIsPlayedFilter(context, filter.User)),
|
||||
ItemSortBy.IsUnplayed when filter.User is not null
|
||||
=> AsOrderKey(BuildIsPlayedFilter(context, filter.User).Not()),
|
||||
_ => OrderMapper.MapOrderByField(sortBy, filter, context)
|
||||
};
|
||||
|
||||
if (orderBy.Length > 0)
|
||||
{
|
||||
var firstOrdering = orderBy[0];
|
||||
var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter, context);
|
||||
var expression = MapOrderByField(firstOrdering.OrderBy);
|
||||
|
||||
if (orderedQuery is null)
|
||||
{
|
||||
@@ -350,7 +362,7 @@ public sealed partial class BaseItemRepository
|
||||
|
||||
foreach (var item in orderBy.Skip(1))
|
||||
{
|
||||
expression = OrderMapper.MapOrderByField(item.OrderBy, filter, context);
|
||||
expression = MapOrderByField(item.OrderBy);
|
||||
orderedQuery = item.SortOrder == SortOrder.Ascending
|
||||
? orderedQuery.ThenBy(expression)
|
||||
: orderedQuery.ThenByDescending(expression);
|
||||
@@ -428,6 +440,17 @@ public sealed partial class BaseItemRepository
|
||||
return ApplyAccessFiltering(context, baseQuery, filter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the user restricts access to items by parental rating or tags.
|
||||
/// </summary>
|
||||
/// <param name="filter">The query filter.</param>
|
||||
/// <returns><c>true</c> if the query carries parental restrictions.</returns>
|
||||
private static bool RequiresParentalRestrictions(InternalItemsQuery filter)
|
||||
=> filter.IncludeInheritedTags.Length > 0
|
||||
|| filter.ExcludeInheritedTags.Length > 0
|
||||
|| filter.MaxParentalRating is not null
|
||||
|| filter.BlockUnratedItems.Length > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Applies user access filtering to a query.
|
||||
/// Includes TopParentIds, parental rating, and tag filtering.
|
||||
@@ -438,13 +461,127 @@ public sealed partial class BaseItemRepository
|
||||
IQueryable<BaseItemEntity> baseQuery,
|
||||
InternalItemsQuery filter)
|
||||
{
|
||||
// Apply TopParentIds filtering (library folder access)
|
||||
if (filter.TopParentIds.Length > 0)
|
||||
baseQuery = ApplyTopParentFiltering(context, baseQuery, filter);
|
||||
|
||||
baseQuery = ApplyParentalRestrictions(context, baseQuery, filter);
|
||||
|
||||
// Exclude alternate versions (have PrimaryVersionId set) and owned non-extra items.
|
||||
// Extras (trailers, etc.) have OwnerId set but also have ExtraType set — keep those.
|
||||
if (!filter.IncludeOwnedItems)
|
||||
{
|
||||
var topParentIds = filter.TopParentIds;
|
||||
baseQuery = baseQuery.Where(e => topParentIds.Contains(e.TopParentId!.Value));
|
||||
baseQuery = baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null));
|
||||
}
|
||||
|
||||
return baseQuery;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restricts a query to the libraries the user may open, exempting requested by-name items.
|
||||
/// </summary>
|
||||
/// <param name="context">The database context.</param>
|
||||
/// <param name="baseQuery">The query to filter.</param>
|
||||
/// <param name="filter">The query filter.</param>
|
||||
/// <returns>The filtered query.</returns>
|
||||
private IQueryable<BaseItemEntity> ApplyTopParentFiltering(
|
||||
JellyfinDbContext context,
|
||||
IQueryable<BaseItemEntity> baseQuery,
|
||||
InternalItemsQuery filter)
|
||||
{
|
||||
var queryTopParentIds = filter.TopParentIds;
|
||||
if (queryTopParentIds.Length == 0)
|
||||
{
|
||||
return baseQuery;
|
||||
}
|
||||
|
||||
var exemptedItemByNameTypes = GetExemptedItemByNameTypes(filter);
|
||||
if (exemptedItemByNameTypes.Count == 0)
|
||||
{
|
||||
return baseQuery.WhereOneOrMany(queryTopParentIds, e => e.TopParentId!.Value);
|
||||
}
|
||||
|
||||
baseQuery = baseQuery.Where(e => exemptedItemByNameTypes.Contains(e.Type) || queryTopParentIds.Any(w => w == e.TopParentId!.Value));
|
||||
if (filter.UserHasContentRestrictions)
|
||||
{
|
||||
baseQuery = ApplyItemByNameAccessFiltering(baseQuery, context, filter, exemptedItemByNameTypes, queryTopParentIds);
|
||||
}
|
||||
|
||||
return baseQuery;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the by-name types a query asks for, which carry no TopParentId to filter on.
|
||||
/// </summary>
|
||||
/// <param name="filter">The query filter.</param>
|
||||
/// <returns>The type names exempt from library filtering.</returns>
|
||||
private List<string> GetExemptedItemByNameTypes(InternalItemsQuery filter)
|
||||
{
|
||||
var includedItemByNameTypes = GetItemByNameTypesInQuery(filter);
|
||||
if ((filter.IncludeItemsByName ?? false) && includedItemByNameTypes.Count > 0)
|
||||
{
|
||||
return includedItemByNameTypes;
|
||||
}
|
||||
|
||||
return _itemByNameKinds.Where(filter.IncludeItemTypes.Contains).Select(e => _itemTypeLookup.BaseItemKindNames[e]!).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps a by-name row only when at least one item behind its name is reachable for the user.
|
||||
/// </summary>
|
||||
/// <param name="baseQuery">The query to filter.</param>
|
||||
/// <param name="context">The database context.</param>
|
||||
/// <param name="filter">The query filter.</param>
|
||||
/// <param name="itemByNameTypes">The exempted by-name type names.</param>
|
||||
/// <param name="topParentIds">The libraries the user may open.</param>
|
||||
/// <returns>The filtered query.</returns>
|
||||
private IQueryable<BaseItemEntity> ApplyItemByNameAccessFiltering(
|
||||
IQueryable<BaseItemEntity> baseQuery,
|
||||
JellyfinDbContext context,
|
||||
InternalItemsQuery filter,
|
||||
IReadOnlyList<string> itemByNameTypes,
|
||||
Guid[] topParentIds)
|
||||
{
|
||||
// IncludeOwnedItems: a credit on an alternate version of a reachable movie still counts.
|
||||
var accessibleItems = ApplyAccessFiltering(
|
||||
context,
|
||||
context.BaseItems.AsNoTracking(),
|
||||
new InternalItemsQuery(filter.User) { TopParentIds = topParentIds, IncludeOwnedItems = true });
|
||||
|
||||
var personType = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
|
||||
if (itemByNameTypes.Contains(personType))
|
||||
{
|
||||
baseQuery = baseQuery.Where(e => e.Type != personType
|
||||
|| context.Peoples.Any(p => p.Name == e.Name
|
||||
&& context.PeopleBaseItemMap.Any(m => m.PeopleId == p.Id && accessibleItems.Any(i => i.Id == m.ItemId))));
|
||||
}
|
||||
|
||||
foreach (var (kind, valueTypes) in _itemByNameValueTypes)
|
||||
{
|
||||
var typeName = _itemTypeLookup.BaseItemKindNames[kind];
|
||||
if (!itemByNameTypes.Contains(typeName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
baseQuery = baseQuery.Where(e => e.Type != typeName
|
||||
|| context.ItemValues.Any(v => valueTypes.Contains(v.Type) && v.CleanValue == e.CleanName
|
||||
&& context.ItemValuesMap.Any(m => m.ItemValueId == v.ItemValueId && accessibleItems.Any(i => i.Id == m.ItemId))));
|
||||
}
|
||||
|
||||
return baseQuery;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the user's parental rating and tag restrictions to a query.
|
||||
/// </summary>
|
||||
/// <param name="context">The database context.</param>
|
||||
/// <param name="baseQuery">The query to filter.</param>
|
||||
/// <param name="filter">The query filter.</param>
|
||||
/// <returns>The filtered query.</returns>
|
||||
private IQueryable<BaseItemEntity> ApplyParentalRestrictions(
|
||||
JellyfinDbContext context,
|
||||
IQueryable<BaseItemEntity> baseQuery,
|
||||
InternalItemsQuery filter)
|
||||
{
|
||||
// Apply parental rating filtering
|
||||
if (filter.MaxParentalRating is not null)
|
||||
{
|
||||
@@ -495,13 +632,6 @@ public sealed partial class BaseItemRepository
|
||||
|| e.Type == personTypeName);
|
||||
}
|
||||
|
||||
// Exclude alternate versions (have PrimaryVersionId set) and owned non-extra items.
|
||||
// Extras (trailers, etc.) have OwnerId set but also have ExtraType set — keep those.
|
||||
if (!filter.IncludeOwnedItems)
|
||||
{
|
||||
baseQuery = baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null));
|
||||
}
|
||||
|
||||
return baseQuery;
|
||||
}
|
||||
|
||||
@@ -548,6 +678,9 @@ public sealed partial class BaseItemRepository
|
||||
return ApplyAccessFiltering(context, leafItems, new InternalItemsQuery(user) { IncludeOwnedItems = includeOwnedItems });
|
||||
}
|
||||
|
||||
private static Expression<Func<BaseItemEntity, object?>> AsOrderKey(Expression<Func<BaseItemEntity, bool>> predicate)
|
||||
=> Expression.Lambda<Func<BaseItemEntity, object?>>(Expression.Convert(predicate.Body, typeof(object)), predicate.Parameters);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Expression<Func<BaseItemEntity, bool>> BuildHasDescendantFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> descendants)
|
||||
{
|
||||
|
||||
@@ -110,7 +110,7 @@ public sealed partial class BaseItemRepository
|
||||
PrepareFilterQuery(filter);
|
||||
|
||||
// Early exit if collection type is not supported
|
||||
if (collectionType is not CollectionType.movies and not CollectionType.tvshows and not CollectionType.music)
|
||||
if (collectionType is not CollectionType.movies and not CollectionType.tvshows and not CollectionType.music and not CollectionType.unknown)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
@@ -121,30 +121,27 @@ public sealed partial class BaseItemRepository
|
||||
var baseQuery = PrepareItemQuery(context, filter);
|
||||
baseQuery = TranslateQuery(baseQuery, context, filter);
|
||||
|
||||
if (collectionType == CollectionType.tvshows)
|
||||
if (collectionType is CollectionType.tvshows)
|
||||
{
|
||||
return GetLatestTvShowItems(context, baseQuery, filter, limit);
|
||||
}
|
||||
|
||||
if (collectionType is CollectionType.movies)
|
||||
{
|
||||
// Pick, per PresentationUniqueKey, the newest item; return the newest `limit` of those.
|
||||
// Build up until limit by streaming through results and deduplicating on the fly.
|
||||
var orderedIds = baseQuery
|
||||
.Where(e => e.PresentationUniqueKey != null)
|
||||
.OrderByDescending(e => e.DateCreated)
|
||||
.ThenByDescending(e => e.Id)
|
||||
.Select(e => new { e.Id, e.PresentationUniqueKey });
|
||||
return GetLatestMovieItems(context, baseQuery, filter, limit);
|
||||
}
|
||||
|
||||
// DistinctBy and Take are lazy, so enumeration stops as soon as limit distinct keys are read.
|
||||
var firstIds = orderedIds
|
||||
.AsEnumerable()
|
||||
.DistinctBy(row => row.PresentationUniqueKey)
|
||||
.Select(row => row.Id)
|
||||
if (collectionType is CollectionType.unknown)
|
||||
{
|
||||
var moviesQuery = baseQuery.Where(e => e.SeriesName == null);
|
||||
var latestMovies = GetLatestMovieItems(context, moviesQuery, filter, limit);
|
||||
var latestShows = GetLatestTvShowItems(context, baseQuery, filter, limit);
|
||||
|
||||
return latestMovies.Concat(latestShows)
|
||||
.OrderByDescending(dto => dto.DateCreated)
|
||||
.ThenByDescending(dto => dto.Id)
|
||||
.Take(limit ?? int.MaxValue)
|
||||
.ToList();
|
||||
|
||||
return LoadLatestByIds(context, firstIds, filter);
|
||||
}
|
||||
|
||||
var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum]!;
|
||||
@@ -169,7 +166,10 @@ public sealed partial class BaseItemRepository
|
||||
.Where(album => albumIdsWithMatchingTrack.Contains(album.Id));
|
||||
}
|
||||
|
||||
var orderedAlbums = topAlbumsQuery
|
||||
// The album is what gets returned, and neither branch above reads it through the
|
||||
// user's filters, so its own parental restrictions have to be applied here: a
|
||||
// matching track does not make an album the user may not see visible.
|
||||
var orderedAlbums = ApplyParentalRestrictions(context, topAlbumsQuery, filter)
|
||||
.OrderByDescending(album => album.DateCreated)
|
||||
.ThenByDescending(album => album.Id);
|
||||
|
||||
@@ -222,6 +222,39 @@ public sealed partial class BaseItemRepository
|
||||
.ToArray()!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest movies, deduplicated so each movie only appears once.
|
||||
/// </summary>
|
||||
/// <param name="context">The database context.</param>
|
||||
/// <param name="baseQuery">The query to pull movies from, with filters already applied.</param>
|
||||
/// <param name="filter">The original query filter, used when loading the final items.</param>
|
||||
/// <param name="limit">How many items to return.</param>
|
||||
/// <returns>The latest movies, newest first.</returns>
|
||||
private IReadOnlyList<BaseItemDto> GetLatestMovieItems(
|
||||
JellyfinDbContext context,
|
||||
IQueryable<BaseItemEntity> baseQuery,
|
||||
InternalItemsQuery filter,
|
||||
int? limit)
|
||||
{
|
||||
// Pick, per PresentationUniqueKey, the newest item; return the newest `limit` of those.
|
||||
// Build up until limit by streaming through results and deduplicating on the fly.
|
||||
var orderedIds = baseQuery
|
||||
.Where(e => e.PresentationUniqueKey != null)
|
||||
.OrderByDescending(e => e.DateCreated)
|
||||
.ThenByDescending(e => e.Id)
|
||||
.Select(e => new { e.Id, e.PresentationUniqueKey });
|
||||
|
||||
// DistinctBy and Take are lazy, so enumeration stops as soon as limit distinct keys are read.
|
||||
var firstIds = orderedIds
|
||||
.AsEnumerable()
|
||||
.DistinctBy(row => row.PresentationUniqueKey)
|
||||
.Select(row => row.Id)
|
||||
.Take(limit ?? int.MaxValue)
|
||||
.ToList();
|
||||
|
||||
return LoadLatestByIds(context, firstIds, filter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest TV show items with smart Season/Series container selection.
|
||||
/// </summary>
|
||||
@@ -422,6 +455,40 @@ public sealed partial class BaseItemRepository
|
||||
seriesResults.Add((seasonId, seriesId, maxDate, mostRecentEpisodeId));
|
||||
}
|
||||
|
||||
// Step 5b: A container is what gets returned, so it has to pass the user's access
|
||||
// filters on its own - a matching episode does not make a Season or Series the user
|
||||
// may not see visible. Containers that don't pass are replaced by their episode.
|
||||
if (RequiresParentalRestrictions(filter) && entitiesToFetch.Count > 0)
|
||||
{
|
||||
var allowedContainerIds = ApplyParentalRestrictions(
|
||||
context,
|
||||
context.BaseItems.AsNoTracking().Where(e => entitiesToFetch.Contains(e.Id)),
|
||||
filter)
|
||||
.Select(e => e.Id)
|
||||
.ToHashSet();
|
||||
|
||||
for (var i = 0; i < seriesResults.Count; i++)
|
||||
{
|
||||
var (seasonId, seriesId, maxDate, mostRecentEpisodeId) = seriesResults[i];
|
||||
if (seasonId.HasValue && !allowedContainerIds.Contains(seasonId.Value))
|
||||
{
|
||||
seasonId = null;
|
||||
}
|
||||
|
||||
if (seriesId.HasValue && !allowedContainerIds.Contains(seriesId.Value))
|
||||
{
|
||||
seriesId = null;
|
||||
}
|
||||
|
||||
if (seasonId is null && seriesId is null)
|
||||
{
|
||||
entitiesToFetch.Add(mostRecentEpisodeId);
|
||||
}
|
||||
|
||||
seriesResults[i] = (seasonId, seriesId, maxDate, mostRecentEpisodeId);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: Fetch the Season/Series entities we decided to return
|
||||
var entities = entitiesToFetch.Count > 0
|
||||
? ApplyNavigations(
|
||||
|
||||
@@ -35,6 +35,39 @@ public sealed partial class BaseItemRepository
|
||||
// instance across several lambdas, and this filter is combined into a tree more than once.
|
||||
private static Expression<Func<BaseItemEntity, bool>> IsFolderFilter => e => e.IsFolder;
|
||||
|
||||
// Shared by the isPlayed filter and the IsPlayed/IsUnplayed ordering so the two cannot disagree.
|
||||
private Expression<Func<BaseItemEntity, bool>> BuildIsPlayedFilter(JellyfinDbContext context, User user)
|
||||
{
|
||||
var userId = user.Id;
|
||||
|
||||
// Leaf items carry their own played state.
|
||||
var playedItemIds = context.UserData
|
||||
.Where(ud => ud.UserId == userId && ud.Played)
|
||||
.Select(ud => ud.ItemId);
|
||||
|
||||
// Folders (Series, Seasons, BoxSets, albums, ...) have none and count as played once no
|
||||
// descendant is left unplayed, matching what the DTO reports for them. This has to key off
|
||||
// the item itself rather than off the requested item types: tag and collection listings mix
|
||||
// folders and leaf items in a single query.
|
||||
var unplayedLeafItems = GetAccessFilteredLeafItemsQuery(context, user)
|
||||
.Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
|
||||
|
||||
return IsFolderFilter.And(BuildHasDescendantFilter(context, unplayedLeafItems).Not())
|
||||
.Or(IsFolderFilter.Not().And(e => playedItemIds.Contains(e.Id)));
|
||||
}
|
||||
|
||||
// "und" is the language filters' stand-in for a track that declares no language at all.
|
||||
private static string NormalizeLanguage(string language)
|
||||
=> string.Equals(language, "und", StringComparison.OrdinalIgnoreCase) ? "und" : language;
|
||||
|
||||
// The primary versions whose alternate version satisfies a dimension bound. Anchored on
|
||||
// PrimaryVersionId so the filtered index carries it rather than a scan of every item.
|
||||
private static IQueryable<Guid> VersionsMatchingDimension(JellyfinDbContext context, Expression<Func<BaseItemEntity, bool>> bound)
|
||||
=> context.BaseItems
|
||||
.Where(v => v.PrimaryVersionId != null)
|
||||
.Where(bound)
|
||||
.Select(v => v.PrimaryVersionId!.Value);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IQueryable<BaseItemEntity> TranslateQuery(
|
||||
IQueryable<BaseItemEntity> baseQuery,
|
||||
@@ -70,47 +103,86 @@ public sealed partial class BaseItemRepository
|
||||
include4K = true;
|
||||
}
|
||||
|
||||
// Non-folders: check own resolution directly (no subquery).
|
||||
// Folders (Series, BoxSets): EXISTS check on descendants/linked children.
|
||||
// Using navigation properties (a.Item, lc.Child) produces efficient
|
||||
// EXISTS + JOIN instead of nested IN (SELECT ...) subqueries.
|
||||
// A 4K remux of an SD primary is a version of the same item, so the bucket a caller filters
|
||||
// on is the best any of the item's versions offers, not just the primary file's. Three sets,
|
||||
// because a bucket is as much about what the version group does not have as what it does, and
|
||||
// because an unprobed primary can still be placed by a version that does carry dimensions.
|
||||
// The filtered PrimaryVersionId index keeps all three to the few items that have versions.
|
||||
var versionsSd = VersionsMatchingDimension(context, v => v.Width > 0 && v.Width < HDWidth);
|
||||
var versionsHd = VersionsMatchingDimension(context, v => v.Width >= HDWidth);
|
||||
var versions4K = VersionsMatchingDimension(context, v => v.Width >= UHDWidth || v.Height >= UHDHeight);
|
||||
|
||||
// Only the SD test needs the Width > 0 guard against a row with no dimensions: such a row
|
||||
// cannot reach the HD or 4K bound anyway, and EF lowers the HD bucket's negated "not itself
|
||||
// 4K" guard to CASE WHEN ... THEN 0 ELSE 1, which already reads unknown as not 4K rather
|
||||
// than propagating a null. Folders (Series, BoxSets) answer on their descendants, bucketed
|
||||
// exactly as a top-level item is so that the two cannot disagree; the navigation properties
|
||||
// (a.Item, lc.Child) give EXISTS + JOIN rather than nested IN (SELECT ...).
|
||||
baseQuery = baseQuery.Where(e =>
|
||||
(!e.IsFolder && e.Width > 0
|
||||
&& ((includeSD && e.Width < HDWidth)
|
||||
|| (includeHD && e.Width >= HDWidth && !(e.Width >= UHDWidth || e.Height >= UHDHeight))
|
||||
|| (include4K && (e.Width >= UHDWidth || e.Height >= UHDHeight))))
|
||||
(!e.IsFolder
|
||||
&& ((includeSD
|
||||
&& ((e.Width > 0 && e.Width < HDWidth) || versionsSd.Contains(e.Id))
|
||||
&& !versionsHd.Contains(e.Id)
|
||||
&& !versions4K.Contains(e.Id))
|
||||
|| (includeHD
|
||||
&& (e.Width >= HDWidth || versionsHd.Contains(e.Id))
|
||||
&& !(e.Width >= UHDWidth || e.Height >= UHDHeight)
|
||||
&& !versions4K.Contains(e.Id))
|
||||
|| (include4K
|
||||
&& (e.Width >= UHDWidth || e.Height >= UHDHeight || versions4K.Contains(e.Id)))))
|
||||
|| (e.IsFolder
|
||||
&& (e.Children!.Any(a =>
|
||||
a.Item.Width > 0
|
||||
&& ((includeSD && a.Item.Width < HDWidth)
|
||||
|| (includeHD && a.Item.Width >= HDWidth && !(a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight))
|
||||
|| (include4K && (a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight))))
|
||||
(includeSD
|
||||
&& ((a.Item.Width > 0 && a.Item.Width < HDWidth) || versionsSd.Contains(a.ItemId))
|
||||
&& !versionsHd.Contains(a.ItemId)
|
||||
&& !versions4K.Contains(a.ItemId))
|
||||
|| (includeHD
|
||||
&& (a.Item.Width >= HDWidth || versionsHd.Contains(a.ItemId))
|
||||
&& !(a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight)
|
||||
&& !versions4K.Contains(a.ItemId))
|
||||
|| (include4K
|
||||
&& (a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight || versions4K.Contains(a.ItemId))))
|
||||
|| context.LinkedChildren.Any(lc =>
|
||||
lc.ParentId == e.Id
|
||||
&& lc.Child!.Width > 0
|
||||
&& ((includeSD && lc.Child.Width < HDWidth)
|
||||
|| (includeHD && lc.Child.Width >= HDWidth && !(lc.Child.Width >= UHDWidth || lc.Child.Height >= UHDHeight))
|
||||
|| (include4K && (lc.Child.Width >= UHDWidth || lc.Child.Height >= UHDHeight)))))));
|
||||
&& ((includeSD
|
||||
&& ((lc.Child!.Width > 0 && lc.Child!.Width < HDWidth) || versionsSd.Contains(lc.ChildId))
|
||||
&& !versionsHd.Contains(lc.ChildId)
|
||||
&& !versions4K.Contains(lc.ChildId))
|
||||
|| (includeHD
|
||||
&& (lc.Child!.Width >= HDWidth || versionsHd.Contains(lc.ChildId))
|
||||
&& !(lc.Child!.Width >= UHDWidth || lc.Child!.Height >= UHDHeight)
|
||||
&& !versions4K.Contains(lc.ChildId))
|
||||
|| (include4K
|
||||
&& (lc.Child!.Width >= UHDWidth || lc.Child!.Height >= UHDHeight || versions4K.Contains(lc.ChildId))))))));
|
||||
}
|
||||
|
||||
// Same reasoning as the resolution filter: a dimension bound is met if any version meets it.
|
||||
if (minWidth.HasValue)
|
||||
{
|
||||
baseQuery = baseQuery.Where(e => e.Width >= minWidth);
|
||||
var versionsWideEnough = VersionsMatchingDimension(context, v => v.Width >= minWidth);
|
||||
baseQuery = baseQuery.Where(e => e.Width >= minWidth || versionsWideEnough.Contains(e.Id));
|
||||
}
|
||||
|
||||
if (filter.MinHeight.HasValue)
|
||||
{
|
||||
baseQuery = baseQuery.Where(e => e.Height >= filter.MinHeight);
|
||||
var minHeight = filter.MinHeight;
|
||||
var versionsTallEnough = VersionsMatchingDimension(context, v => v.Height >= minHeight);
|
||||
baseQuery = baseQuery.Where(e => e.Height >= minHeight || versionsTallEnough.Contains(e.Id));
|
||||
}
|
||||
|
||||
// An upper bound inverts that: it is met only if no version breaches it, since the item's
|
||||
// resolution is the best its version group offers.
|
||||
if (maxWidth.HasValue)
|
||||
{
|
||||
baseQuery = baseQuery.Where(e => e.Width <= maxWidth);
|
||||
var versionsTooWide = VersionsMatchingDimension(context, v => v.Width > maxWidth);
|
||||
baseQuery = baseQuery.Where(e => e.Width <= maxWidth && !versionsTooWide.Contains(e.Id));
|
||||
}
|
||||
|
||||
if (filter.MaxHeight.HasValue)
|
||||
{
|
||||
baseQuery = baseQuery.Where(e => e.Height <= filter.MaxHeight);
|
||||
var maxHeight = filter.MaxHeight;
|
||||
var versionsTooTall = VersionsMatchingDimension(context, v => v.Height > maxHeight);
|
||||
baseQuery = baseQuery.Where(e => e.Height <= maxHeight && !versionsTooTall.Contains(e.Id));
|
||||
}
|
||||
|
||||
if (filter.IsLocked.HasValue)
|
||||
@@ -356,7 +428,7 @@ public sealed partial class BaseItemRepository
|
||||
}
|
||||
else
|
||||
{
|
||||
baseQuery = baseQuery.Where(e => e.StartDate > now && e.EndDate < now);
|
||||
baseQuery = baseQuery.Where(e => e.StartDate > now || e.EndDate < now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,14 +442,16 @@ public sealed partial class BaseItemRepository
|
||||
p => p.Name,
|
||||
(b, p) => p.Id);
|
||||
|
||||
var personTypes = filter.PersonTypes;
|
||||
baseQuery = baseQuery
|
||||
.Where(e => context.PeopleBaseItemMap
|
||||
.Any(m => m.ItemId == e.Id && peopleEntityIds.Contains(m.PeopleId)));
|
||||
.Any(m => m.ItemId == e.Id && peopleEntityIds.Contains(m.PeopleId) && (personTypes.Length == 0 || personTypes.Contains(m.People.PersonType))));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Person))
|
||||
{
|
||||
baseQuery = baseQuery.Where(e => e.Peoples!.Any(f => f.People.Name == filter.Person));
|
||||
var personTypes = filter.PersonTypes;
|
||||
baseQuery = baseQuery.Where(e => e.Peoples!.Any(f => f.People.Name == filter.Person && (personTypes.Length == 0 || personTypes.Contains(f.People.PersonType))));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.ExternalSeriesId))
|
||||
@@ -470,22 +544,7 @@ public sealed partial class BaseItemRepository
|
||||
|
||||
if (filter.IsPlayed.HasValue)
|
||||
{
|
||||
var userId = filter.User!.Id;
|
||||
|
||||
// Leaf items carry their own played state.
|
||||
var playedItemIds = context.UserData
|
||||
.Where(ud => ud.UserId == userId && ud.Played)
|
||||
.Select(ud => ud.ItemId);
|
||||
|
||||
// Folders (Series, Seasons, BoxSets, albums, ...) have none and count as played once no
|
||||
// descendant is left unplayed, matching what the DTO reports for them. This has to key off
|
||||
// the item itself rather than off the requested item types: tag and collection listings mix
|
||||
// folders and leaf items in a single query.
|
||||
var unplayedLeafItems = GetAccessFilteredLeafItemsQuery(context, filter.User!)
|
||||
.Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
|
||||
|
||||
var isPlayedFilter = IsFolderFilter.And(BuildHasDescendantFilter(context, unplayedLeafItems).Not())
|
||||
.Or(IsFolderFilter.Not().And(e => playedItemIds.Contains(e.Id)));
|
||||
var isPlayedFilter = BuildIsPlayedFilter(context, filter.User!);
|
||||
|
||||
baseQuery = baseQuery.Where(filter.IsPlayed.Value ? isPlayedFilter : isPlayedFilter.Not());
|
||||
}
|
||||
@@ -499,16 +558,21 @@ public sealed partial class BaseItemRepository
|
||||
var inProgress = context.UserData
|
||||
.Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0);
|
||||
|
||||
// Folders are resumable when a descendant is in progress, or when they hold both played and
|
||||
// unplayed descendants (partially watched). Alternate versions keep their own progress, so
|
||||
// they count towards the in-progress check but not towards the played/unplayed one.
|
||||
// Series and Seasons are resumable when a descendant is in progress, or when they hold both
|
||||
// played and unplayed descendants (partially watched). Alternate versions keep their own
|
||||
// progress, so they count towards the in-progress check but not towards the played/unplayed one.
|
||||
var leafItems = GetAccessFilteredLeafItemsQuery(context, filter.User!);
|
||||
var inProgressLeafItems = GetAccessFilteredLeafItemsQuery(context, filter.User!, includeOwnedItems: true)
|
||||
.Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0));
|
||||
|
||||
var folderResumableFilter = BuildHasDescendantFilter(context, inProgressLeafItems)
|
||||
.Or(BuildHasDescendantFilter(context, leafItems.Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)))
|
||||
.And(BuildHasDescendantFilter(context, leafItems.Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played)))));
|
||||
// Every other folder kind is a container rather than one continuous piece of media
|
||||
var resumableFolderTypes = _resumableFolderKinds
|
||||
.Select(kind => _itemTypeLookup.BaseItemKindNames.GetValueOrDefault(kind))
|
||||
.ToArray();
|
||||
var folderIsResumableFilter = IsFolderFilter.And(e => resumableFolderTypes.Contains(e.Type))
|
||||
.And(BuildHasDescendantFilter(context, inProgressLeafItems)
|
||||
.Or(BuildHasDescendantFilter(context, leafItems.Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)))
|
||||
.And(BuildHasDescendantFilter(context, leafItems.Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played))))));
|
||||
|
||||
if (isResumable)
|
||||
{
|
||||
@@ -516,7 +580,7 @@ public sealed partial class BaseItemRepository
|
||||
// Match each version on its own progress rather than coalescing onto the primary.
|
||||
var inProgressIds = inProgress.Select(ud => ud.ItemId);
|
||||
|
||||
baseQuery = baseQuery.Where(IsFolderFilter.And(folderResumableFilter)
|
||||
baseQuery = baseQuery.Where(folderIsResumableFilter
|
||||
.Or(IsFolderFilter.Not().And(e => inProgressIds.Contains(e.Id))));
|
||||
|
||||
// When several versions of the same item are in progress, keep only the most recently played one, use id as tiebreaker.
|
||||
@@ -543,14 +607,14 @@ public sealed partial class BaseItemRepository
|
||||
var resumableMovieIds = inProgress
|
||||
.Join(context.BaseItems, ud => ud.ItemId, bi => bi.Id, (ud, bi) => bi.PrimaryVersionId ?? bi.Id);
|
||||
|
||||
baseQuery = baseQuery.Where(IsFolderFilter.And(folderResumableFilter.Not())
|
||||
baseQuery = baseQuery.Where(IsFolderFilter.And(folderIsResumableFilter.Not())
|
||||
.Or(IsFolderFilter.Not().And(e => !resumableMovieIds.Contains(e.Id))));
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.ArtistIds.Length > 0)
|
||||
{
|
||||
baseQuery = baseQuery.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ArtistIds);
|
||||
baseQuery = baseQuery.WhereReferencedItem(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ArtistIds);
|
||||
}
|
||||
|
||||
if (filter.AlbumArtistIds.Length > 0)
|
||||
@@ -581,12 +645,12 @@ public sealed partial class BaseItemRepository
|
||||
|
||||
if (filter.ExcludeArtistIds.Length > 0)
|
||||
{
|
||||
baseQuery = baseQuery.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ExcludeArtistIds, true);
|
||||
baseQuery = baseQuery.WhereReferencedItem(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ExcludeArtistIds, true);
|
||||
}
|
||||
|
||||
if (filter.GenreIds.Count > 0)
|
||||
{
|
||||
baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Genre, filter.GenreIds.ToArray());
|
||||
baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Genre, filter.GenreIds);
|
||||
}
|
||||
|
||||
if (filter.Genres.Count > 0)
|
||||
@@ -612,7 +676,7 @@ public sealed partial class BaseItemRepository
|
||||
|
||||
if (filter.StudioIds.Length > 0)
|
||||
{
|
||||
baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Studios, filter.StudioIds.ToArray());
|
||||
baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Studios, filter.StudioIds);
|
||||
}
|
||||
|
||||
if (filter.OfficialRatings.Length > 0)
|
||||
@@ -754,104 +818,144 @@ public sealed partial class BaseItemRepository
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.HasNoAudioTrackWithLanguage))
|
||||
{
|
||||
var lang = filter.HasNoAudioTrackWithLanguage;
|
||||
var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Audio, lang));
|
||||
var lang = NormalizeLanguage(filter.HasNoAudioTrackWithLanguage);
|
||||
var undetermined = string.Equals(lang, "und", StringComparison.Ordinal);
|
||||
var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Audio, lang);
|
||||
// A track only an alternate version carries still belongs to the item a caller sees, so the
|
||||
// item's own streams alone do not decide this. Same for every stream filter below.
|
||||
var versionsWithAudio = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
|
||||
var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
|
||||
|
||||
baseQuery = baseQuery
|
||||
.Where(e =>
|
||||
(!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Audio && ms.Language == lang))
|
||||
(!e.IsFolder
|
||||
&& !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Audio
|
||||
&& (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language))))
|
||||
&& !versionsWithAudio.Contains(e.Id))
|
||||
|| (e.IsFolder && !foldersWithAudio.Contains(e.Id)));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.HasNoInternalSubtitleTrackWithLanguage))
|
||||
{
|
||||
var lang = filter.HasNoInternalSubtitleTrackWithLanguage;
|
||||
var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: false));
|
||||
var lang = NormalizeLanguage(filter.HasNoInternalSubtitleTrackWithLanguage);
|
||||
var undetermined = string.Equals(lang, "und", StringComparison.Ordinal);
|
||||
var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: false);
|
||||
var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
|
||||
var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
|
||||
|
||||
baseQuery = baseQuery
|
||||
.Where(e =>
|
||||
(!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && !ms.IsExternal && ms.Language == lang))
|
||||
(!e.IsFolder
|
||||
&& !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && !ms.IsExternal
|
||||
&& (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language))))
|
||||
&& !versionsWithSubtitles.Contains(e.Id))
|
||||
|| (e.IsFolder && !foldersWithSubtitles.Contains(e.Id)));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.HasNoExternalSubtitleTrackWithLanguage))
|
||||
{
|
||||
var lang = filter.HasNoExternalSubtitleTrackWithLanguage;
|
||||
var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: true));
|
||||
var lang = NormalizeLanguage(filter.HasNoExternalSubtitleTrackWithLanguage);
|
||||
var undetermined = string.Equals(lang, "und", StringComparison.Ordinal);
|
||||
var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: true);
|
||||
var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
|
||||
var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
|
||||
|
||||
baseQuery = baseQuery
|
||||
.Where(e =>
|
||||
(!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.IsExternal && ms.Language == lang))
|
||||
(!e.IsFolder
|
||||
&& !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.IsExternal
|
||||
&& (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language))))
|
||||
&& !versionsWithSubtitles.Contains(e.Id))
|
||||
|| (e.IsFolder && !foldersWithSubtitles.Contains(e.Id)));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.HasNoSubtitleTrackWithLanguage))
|
||||
{
|
||||
var lang = filter.HasNoSubtitleTrackWithLanguage;
|
||||
var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang));
|
||||
var lang = NormalizeLanguage(filter.HasNoSubtitleTrackWithLanguage);
|
||||
var undetermined = string.Equals(lang, "und", StringComparison.Ordinal);
|
||||
var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang);
|
||||
var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
|
||||
var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
|
||||
|
||||
baseQuery = baseQuery
|
||||
.Where(e =>
|
||||
(!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.Language == lang))
|
||||
(!e.IsFolder
|
||||
&& !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle
|
||||
&& (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language))))
|
||||
&& !versionsWithSubtitles.Contains(e.Id))
|
||||
|| (e.IsFolder && !foldersWithSubtitles.Contains(e.Id)));
|
||||
}
|
||||
|
||||
if (filter.HasSubtitles.HasValue)
|
||||
{
|
||||
var hasSubtitles = filter.HasSubtitles.Value;
|
||||
var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasSubtitles());
|
||||
var criteria = new HasSubtitles();
|
||||
var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
|
||||
var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
|
||||
if (hasSubtitles)
|
||||
{
|
||||
baseQuery = baseQuery
|
||||
.Where(e =>
|
||||
(!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle))
|
||||
(!e.IsFolder && (e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle)
|
||||
|| versionsWithSubtitles.Contains(e.Id)))
|
||||
|| (e.IsFolder && foldersWithSubtitles.Contains(e.Id)));
|
||||
}
|
||||
else
|
||||
{
|
||||
baseQuery = baseQuery
|
||||
.Where(e =>
|
||||
(!e.IsFolder && !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle))
|
||||
(!e.IsFolder && !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle)
|
||||
&& !versionsWithSubtitles.Contains(e.Id))
|
||||
|| (e.IsFolder && !foldersWithSubtitles.Contains(e.Id)));
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.SubtitleLanguages.Count > 0)
|
||||
{
|
||||
var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, filter.SubtitleLanguages));
|
||||
var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, filter.SubtitleLanguages);
|
||||
var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
|
||||
var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
|
||||
baseQuery = baseQuery
|
||||
.Where(e =>
|
||||
(!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle
|
||||
&& (filter.SubtitleLanguages.Contains(f.Language) || (filter.SubtitleLanguages.Contains("und") && string.IsNullOrEmpty(f.Language)))))
|
||||
(!e.IsFolder && (e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle
|
||||
&& (filter.SubtitleLanguages.Contains(f.Language) || (filter.SubtitleLanguages.Contains("und") && string.IsNullOrEmpty(f.Language))))
|
||||
|| versionsWithSubtitles.Contains(e.Id)))
|
||||
|| (e.IsFolder && foldersWithSubtitles.Contains(e.Id)));
|
||||
}
|
||||
|
||||
if (filter.AudioLanguages.Count > 0)
|
||||
{
|
||||
var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Audio, filter.AudioLanguages));
|
||||
var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Audio, filter.AudioLanguages);
|
||||
var versionsWithAudio = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
|
||||
var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
|
||||
baseQuery = baseQuery
|
||||
.Where(e =>
|
||||
(!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio
|
||||
&& (filter.AudioLanguages.Contains(f.Language) || (filter.AudioLanguages.Contains("und") && string.IsNullOrEmpty(f.Language)))))
|
||||
(!e.IsFolder && (e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio
|
||||
&& (filter.AudioLanguages.Contains(f.Language) || (filter.AudioLanguages.Contains("und") && string.IsNullOrEmpty(f.Language))))
|
||||
|| versionsWithAudio.Contains(e.Id)))
|
||||
|| (e.IsFolder && foldersWithAudio.Contains(e.Id)));
|
||||
}
|
||||
|
||||
if (filter.HasChapterImages.HasValue)
|
||||
{
|
||||
var hasChapterImages = filter.HasChapterImages.Value;
|
||||
var foldersWithChapterImages = DescendantQueryHelper.GetFolderIdsMatching(context, new HasChapterImages());
|
||||
var criteria = new HasChapterImages();
|
||||
var versionsWithChapterImages = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
|
||||
var foldersWithChapterImages = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
|
||||
if (hasChapterImages)
|
||||
{
|
||||
baseQuery = baseQuery
|
||||
.Where(e =>
|
||||
(!e.IsFolder && e.Chapters!.Any(f => f.ImagePath != null))
|
||||
(!e.IsFolder && (e.Chapters!.Any(f => f.ImagePath != null)
|
||||
|| versionsWithChapterImages.Contains(e.Id)))
|
||||
|| (e.IsFolder && foldersWithChapterImages.Contains(e.Id)));
|
||||
}
|
||||
else
|
||||
{
|
||||
baseQuery = baseQuery
|
||||
.Where(e =>
|
||||
(!e.IsFolder && !e.Chapters!.Any(f => f.ImagePath != null))
|
||||
(!e.IsFolder && !e.Chapters!.Any(f => f.ImagePath != null)
|
||||
&& !versionsWithChapterImages.Contains(e.Id))
|
||||
|| (e.IsFolder && !foldersWithChapterImages.Contains(e.Id)));
|
||||
}
|
||||
}
|
||||
@@ -958,17 +1062,6 @@ public sealed partial class BaseItemRepository
|
||||
baseQuery = baseQuery.WhereHasAnyProviderIds(filter.HasAnyProviderIds);
|
||||
}
|
||||
|
||||
if (filter.HasAnyProviderIds is not null && filter.HasAnyProviderIds.Count > 0)
|
||||
{
|
||||
var includeAny = filter.HasAnyProviderIds
|
||||
.SelectMany(kvp => kvp.Value.Select(v => $"{kvp.Key}:{v}"))
|
||||
.ToArray();
|
||||
if (includeAny.Length > 0)
|
||||
{
|
||||
baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.Any(f => includeAny.Contains(f)));
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.HasImdbId.HasValue)
|
||||
{
|
||||
baseQuery = filter.HasImdbId.Value
|
||||
@@ -990,21 +1083,7 @@ public sealed partial class BaseItemRepository
|
||||
: baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != TvdbProviderName));
|
||||
}
|
||||
|
||||
var queryTopParentIds = filter.TopParentIds;
|
||||
|
||||
if (queryTopParentIds.Length > 0)
|
||||
{
|
||||
var includedItemByNameTypes = GetItemByNameTypesInQuery(filter);
|
||||
var enableItemsByName = (filter.IncludeItemsByName ?? false) && includedItemByNameTypes.Count > 0;
|
||||
if (enableItemsByName && includedItemByNameTypes.Count > 0)
|
||||
{
|
||||
baseQuery = baseQuery.Where(e => includedItemByNameTypes.Contains(e.Type) || queryTopParentIds.Any(w => w == e.TopParentId!.Value));
|
||||
}
|
||||
else
|
||||
{
|
||||
baseQuery = baseQuery.WhereOneOrMany(queryTopParentIds, e => e.TopParentId!.Value);
|
||||
}
|
||||
}
|
||||
baseQuery = ApplyTopParentFiltering(context, baseQuery, filter);
|
||||
|
||||
if (filter.AncestorIds.Length > 0)
|
||||
{
|
||||
@@ -1012,6 +1091,12 @@ public sealed partial class BaseItemRepository
|
||||
baseQuery = baseQuery.Where(e => e.Parents!.AsQueryable().Any(ancestorFilter));
|
||||
}
|
||||
|
||||
if (filter.DescendantOfId.HasValue)
|
||||
{
|
||||
var descendantIds = DescendantQueryHelper.GetAllDescendantIds(context, filter.DescendantOfId.Value);
|
||||
baseQuery = baseQuery.Where(e => descendantIds.Contains(e.Id));
|
||||
}
|
||||
|
||||
if (filter.LinkedChildAncestorIds.Length > 0)
|
||||
{
|
||||
// Keep folder-like items (BoxSets, Playlists) whose linked children descend from any of the requested ancestor ids.
|
||||
|
||||
@@ -46,6 +46,31 @@ public sealed partial class BaseItemRepository
|
||||
private static readonly IReadOnlyList<ItemValueType> _getStudiosValueTypes = [ItemValueType.Studios];
|
||||
private static readonly IReadOnlyList<ItemValueType> _getGenreValueTypes = [ItemValueType.Genre];
|
||||
|
||||
private static readonly BaseItemKind[] _itemByNameKinds =
|
||||
[
|
||||
BaseItemKind.Person,
|
||||
BaseItemKind.Genre,
|
||||
BaseItemKind.MusicGenre,
|
||||
BaseItemKind.MusicArtist,
|
||||
BaseItemKind.Studio
|
||||
];
|
||||
|
||||
private static readonly (BaseItemKind Kind, IReadOnlyList<ItemValueType> ValueTypes)[] _itemByNameValueTypes =
|
||||
[
|
||||
(BaseItemKind.Genre, _getGenreValueTypes),
|
||||
(BaseItemKind.MusicGenre, _getGenreValueTypes),
|
||||
(BaseItemKind.MusicArtist, _getAllArtistsValueTypes),
|
||||
(BaseItemKind.Studio, _getStudiosValueTypes)
|
||||
];
|
||||
|
||||
// The only folder kinds whose children form a single viewing sequence, so playback progress on a
|
||||
// child rolls up to them. Every other folder kind is a container that cannot be resumed.
|
||||
private static readonly BaseItemKind[] _resumableFolderKinds =
|
||||
[
|
||||
BaseItemKind.Series,
|
||||
BaseItemKind.Season
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="BaseItemRepository"/> class.
|
||||
/// </summary>
|
||||
|
||||
@@ -249,30 +249,72 @@ public class ItemCountService : IItemCountService
|
||||
}
|
||||
}
|
||||
|
||||
if (kind is BaseItemKind.Studio or BaseItemKind.Genre or BaseItemKind.MusicGenre
|
||||
&& relatedItemKinds.Contains(BaseItemKind.Episode)
|
||||
&& relatedItemKinds.Contains(BaseItemKind.Series))
|
||||
{
|
||||
var rolledUpEpisodeCount = CountEpisodesOfTaggedSeries(context, baseQuery, accessFilter, out var directEpisodeCount);
|
||||
totalCount += rolledUpEpisodeCount - result.EpisodeCount + directEpisodeCount;
|
||||
result.EpisodeCount = rolledUpEpisodeCount + directEpisodeCount;
|
||||
}
|
||||
|
||||
result.ItemCount = totalCount;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private int CountEpisodesOfTaggedSeries(
|
||||
JellyfinDbContext context,
|
||||
IQueryable<BaseItemEntity> taggedItems,
|
||||
InternalItemsQuery accessFilter,
|
||||
out int unrelatedEpisodeCount)
|
||||
{
|
||||
var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
|
||||
var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode];
|
||||
|
||||
var taggedSeriesIds = taggedItems.Where(e => e.Type == seriesTypeName).Select(e => e.Id);
|
||||
unrelatedEpisodeCount = taggedItems.Count(e => e.Type == episodeTypeName
|
||||
&& (e.SeriesId == null || !taggedSeriesIds.Contains(e.SeriesId.Value)));
|
||||
|
||||
// Materialised so the episode count drives off IX_BaseItems_SeriesId.
|
||||
var seriesIds = taggedItems
|
||||
.Where(e => e.Type == seriesTypeName)
|
||||
.Select(e => e.Id)
|
||||
.ToArray();
|
||||
|
||||
if (seriesIds.Length == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var episodes = context.BaseItems.AsNoTracking()
|
||||
.Where(e => e.Type == episodeTypeName && e.SeriesId != null)
|
||||
.WhereOneOrMany(seriesIds, e => e.SeriesId!.Value);
|
||||
|
||||
return _queryHelpers.ApplyAccessFiltering(context, episodes, accessFilter).Count();
|
||||
}
|
||||
|
||||
private static IQueryable<BaseItemEntity> ItemsById(JellyfinDbContext context, IQueryable<Guid> itemIds)
|
||||
=> context.BaseItems.AsNoTracking().Where(e => itemIds.Contains(e.Id));
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int GetPlayedCount(InternalItemsQuery filter, Guid ancestorId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(filter);
|
||||
ArgumentNullException.ThrowIfNull(filter.User);
|
||||
using var dbContext = _dbProvider.CreateDbContext();
|
||||
|
||||
var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId);
|
||||
var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId);
|
||||
return baseQuery.Count(b => b.UserData!.Any(u => u.UserId == filter.User.Id && u.Played));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int GetTotalCount(InternalItemsQuery filter, Guid ancestorId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(filter);
|
||||
using var dbContext = _dbProvider.CreateDbContext();
|
||||
|
||||
var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId);
|
||||
var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId);
|
||||
return baseQuery.Count();
|
||||
}
|
||||
|
||||
@@ -283,10 +325,23 @@ public class ItemCountService : IItemCountService
|
||||
ArgumentNullException.ThrowIfNull(filter.User);
|
||||
using var dbContext = _dbProvider.CreateDbContext();
|
||||
|
||||
var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId);
|
||||
var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId);
|
||||
return GetPlayedAndTotalCountFromQuery(baseQuery, filter.User.Id);
|
||||
}
|
||||
|
||||
private IQueryable<BaseItemEntity> BuildGroupedDescendantsQuery(JellyfinDbContext dbContext, InternalItemsQuery filter, Guid ancestorId)
|
||||
{
|
||||
var ancestorIds = GetPresentationKeyGroups(dbContext, [ancestorId])[ancestorId];
|
||||
var descendantIds = DescendantQueryHelper.GetAllDescendantIdsBatch(dbContext, ancestorIds).ToArray();
|
||||
|
||||
var baseQuery = dbContext.BaseItems
|
||||
.AsNoTracking()
|
||||
.WhereOneOrMany(descendantIds, b => b.Id)
|
||||
.Where(DescendantQueryHelper.IsCountableLeaf);
|
||||
|
||||
return _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public (int Played, int Total) GetPlayedAndTotalCountFromLinkedChildren(InternalItemsQuery filter, Guid parentId)
|
||||
{
|
||||
@@ -294,9 +349,9 @@ public class ItemCountService : IItemCountService
|
||||
ArgumentNullException.ThrowIfNull(filter.User);
|
||||
using var dbContext = _dbProvider.CreateDbContext();
|
||||
|
||||
var allDescendantIds = DescendantQueryHelper.GetAllDescendantIds(dbContext, parentId);
|
||||
var allDescendantIds = DescendantQueryHelper.GetAllDescendantIdsBatch(dbContext, [parentId]).ToArray();
|
||||
var baseQuery = dbContext.BaseItems
|
||||
.Where(b => allDescendantIds.Contains(b.Id))
|
||||
.WhereOneOrMany(allDescendantIds, b => b.Id)
|
||||
.Where(DescendantQueryHelper.IsCountableLeaf);
|
||||
baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter);
|
||||
|
||||
@@ -304,7 +359,7 @@ public class ItemCountService : IItemCountService
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId)
|
||||
public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, User? user)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(parentIds);
|
||||
|
||||
@@ -317,22 +372,44 @@ public class ItemCountService : IItemCountService
|
||||
|
||||
var parentIdsArray = parentIds.ToArray();
|
||||
|
||||
var includeVirtual = user is null || user.DisplayMissingEpisodes;
|
||||
|
||||
var hierarchicalCounts = dbContext.BaseItems
|
||||
.Where(b => b.ParentId.HasValue && parentIdsArray.Contains(b.ParentId.Value))
|
||||
.Where(b => b.ParentId.HasValue && !b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem))
|
||||
.WhereOneOrMany(parentIdsArray, b => b.ParentId!.Value)
|
||||
.GroupBy(b => b.ParentId!.Value)
|
||||
.Select(g => new { ParentId = g.Key, Count = g.Count() })
|
||||
.ToDictionary(x => x.ParentId, x => x.Count);
|
||||
|
||||
// An episode is a child of its season even when it is not stored under one: with a flat
|
||||
// structure ParentId points at the series, so counting by ParentId alone leaves the season
|
||||
// empty and counts its episodes towards the series instead.
|
||||
var seasonCounts = dbContext.BaseItems
|
||||
.Where(b => b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem))
|
||||
.WhereOneOrMany(parentIdsArray, b => b.SeasonId!.Value)
|
||||
.GroupBy(b => b.SeasonId!.Value)
|
||||
.Select(g => new { SeasonId = g.Key, Count = g.Count() })
|
||||
.ToDictionary(x => x.SeasonId, x => x.Count);
|
||||
|
||||
var linkedCounts = dbContext.LinkedChildren
|
||||
.Where(lc => parentIdsArray.Contains(lc.ParentId))
|
||||
.WhereOneOrMany(parentIdsArray, lc => lc.ParentId)
|
||||
.GroupBy(lc => lc.ParentId)
|
||||
.Select(g => new { ParentId = g.Key, Count = g.Count() })
|
||||
.ToDictionary(x => x.ParentId, x => x.Count);
|
||||
|
||||
var mergedChildCounts = GetMergedChildCounts(dbContext, parentIdsArray, includeVirtual);
|
||||
|
||||
var result = new Dictionary<Guid, int>();
|
||||
foreach (var parentId in parentIds)
|
||||
{
|
||||
var hierarchicalCount = hierarchicalCounts.GetValueOrDefault(parentId, 0);
|
||||
if (mergedChildCounts.TryGetValue(parentId, out var mergedCount))
|
||||
{
|
||||
result[parentId] = mergedCount;
|
||||
continue;
|
||||
}
|
||||
|
||||
var hierarchicalCount = hierarchicalCounts.GetValueOrDefault(parentId, 0)
|
||||
+ seasonCounts.GetValueOrDefault(parentId, 0);
|
||||
var linkedCount = linkedCounts.GetValueOrDefault(parentId, 0);
|
||||
|
||||
result[parentId] = linkedCount > 0 ? linkedCount : hierarchicalCount;
|
||||
@@ -341,6 +418,56 @@ public class ItemCountService : IItemCountService
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Dictionary<Guid, int> GetMergedChildCounts(JellyfinDbContext dbContext, IReadOnlyList<Guid> parentIds, bool includeVirtual)
|
||||
{
|
||||
var mergedGroups = GetPresentationKeyGroups(dbContext, parentIds)
|
||||
.Where(group => group.Value.Count > 1)
|
||||
.ToArray();
|
||||
|
||||
if (mergedGroups.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
// Only merged folders.
|
||||
var memberIds = mergedGroups.SelectMany(group => group.Value).Distinct().ToArray();
|
||||
var children = dbContext.BaseItems
|
||||
.AsNoTracking()
|
||||
.Where(b => b.ParentId.HasValue && !b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem))
|
||||
.WhereOneOrMany(memberIds, b => b.ParentId!.Value)
|
||||
.Select(b => new { ParentId = b.ParentId!.Value, b.Id, b.PresentationUniqueKey })
|
||||
.ToArray()
|
||||
.Concat(dbContext.BaseItems
|
||||
.AsNoTracking()
|
||||
.Where(b => b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem))
|
||||
.WhereOneOrMany(memberIds, b => b.SeasonId!.Value)
|
||||
.Select(b => new { ParentId = b.SeasonId!.Value, b.Id, b.PresentationUniqueKey })
|
||||
.ToArray())
|
||||
.GroupBy(b => b.ParentId)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.Select(b => string.IsNullOrEmpty(b.PresentationUniqueKey)
|
||||
? b.Id.ToString("N", CultureInfo.InvariantCulture)
|
||||
: b.PresentationUniqueKey).ToArray());
|
||||
|
||||
var result = new Dictionary<Guid, int>();
|
||||
foreach (var (parentId, members) in mergedGroups)
|
||||
{
|
||||
var childKeys = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var member in members)
|
||||
{
|
||||
if (children.TryGetValue(member, out var keys))
|
||||
{
|
||||
childKeys.UnionWith(keys);
|
||||
}
|
||||
}
|
||||
|
||||
result[parentId] = childKeys.Count;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Dictionary<Guid, (int Played, int Total)> GetPlayedAndTotalCountBatch(IReadOnlyList<Guid> folderIds, User user)
|
||||
{
|
||||
@@ -353,10 +480,13 @@ public class ItemCountService : IItemCountService
|
||||
}
|
||||
|
||||
using var dbContext = _dbProvider.CreateDbContext();
|
||||
var folderIdsArray = folderIds.ToArray();
|
||||
var filter = new InternalItemsQuery(user);
|
||||
var userId = user.Id;
|
||||
|
||||
// Merged series and seasons are stored as one row per folder-item sharing a presentation key.
|
||||
var groups = GetPresentationKeyGroups(dbContext, folderIds);
|
||||
var folderIdsArray = groups.Values.SelectMany(members => members).Distinct().ToArray();
|
||||
|
||||
var leafItems = dbContext.BaseItems
|
||||
.Where(DescendantQueryHelper.IsCountableLeaf);
|
||||
leafItems = _queryHelpers.ApplyAccessFiltering(dbContext, leafItems, filter);
|
||||
@@ -398,7 +528,7 @@ public class ItemCountService : IItemCountService
|
||||
b => b.Id,
|
||||
(x, b) => new { FolderId = x.ParentId, b.Id, b.Played });
|
||||
|
||||
var results = ancestorLeaves
|
||||
var countsByFolder = ancestorLeaves
|
||||
.Union(linkedLeaves)
|
||||
.Union(linkedFolderLeaves)
|
||||
.GroupBy(x => x.FolderId)
|
||||
@@ -410,9 +540,73 @@ public class ItemCountService : IItemCountService
|
||||
})
|
||||
.ToDictionary(x => x.FolderId, x => (x.Played, x.Total));
|
||||
|
||||
var results = new Dictionary<Guid, (int Played, int Total)>();
|
||||
foreach (var (folderId, members) in groups)
|
||||
{
|
||||
var played = 0;
|
||||
var total = 0;
|
||||
|
||||
// Members of a group are distinct folders, so their leaves cannot overlap.
|
||||
foreach (var member in members)
|
||||
{
|
||||
if (countsByFolder.TryGetValue(member, out var counts))
|
||||
{
|
||||
played += counts.Played;
|
||||
total += counts.Total;
|
||||
}
|
||||
}
|
||||
|
||||
if (total > 0 || played > 0)
|
||||
{
|
||||
results[folderId] = (played, total);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static Dictionary<Guid, List<Guid>> GetPresentationKeyGroups(JellyfinDbContext dbContext, IReadOnlyList<Guid> folderIds)
|
||||
{
|
||||
var requested = dbContext.BaseItems
|
||||
.AsNoTracking()
|
||||
.WhereOneOrMany(folderIds, e => e.Id)
|
||||
.Select(e => new { e.Id, e.PresentationUniqueKey })
|
||||
.ToArray();
|
||||
|
||||
var keys = requested
|
||||
.Select(e => e.PresentationUniqueKey)
|
||||
.Where(key => !string.IsNullOrEmpty(key))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
// Every item that is not merged carries a key derived from its own id, so in the common case
|
||||
// each group resolves back to the single folder that was asked for.
|
||||
var membersByKey = keys.Length == 0
|
||||
? []
|
||||
: dbContext.BaseItems
|
||||
.AsNoTracking()
|
||||
.Where(e => e.IsFolder)
|
||||
.WhereOneOrMany(keys, e => e.PresentationUniqueKey!)
|
||||
.Select(e => new { e.Id, Key = e.PresentationUniqueKey! })
|
||||
.ToArray()
|
||||
.GroupBy(e => e.Key, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.Select(e => e.Id).ToList(), StringComparer.Ordinal);
|
||||
|
||||
var keyById = requested.ToDictionary(e => e.Id, e => e.PresentationUniqueKey);
|
||||
var groups = new Dictionary<Guid, List<Guid>>();
|
||||
foreach (var folderId in folderIds)
|
||||
{
|
||||
groups[folderId] = keyById.TryGetValue(folderId, out var key)
|
||||
&& !string.IsNullOrEmpty(key)
|
||||
&& membersByKey.TryGetValue(key, out var members)
|
||||
&& members.Count > 0
|
||||
? members
|
||||
: [folderId];
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
private static (int Played, int Total) GetPlayedAndTotalCountFromQuery(IQueryable<BaseItemEntity> query, Guid userId)
|
||||
{
|
||||
var result = query
|
||||
|
||||
@@ -257,23 +257,19 @@ public class ItemPersistenceService : IItemPersistenceService
|
||||
using var transaction = context.Database.BeginTransaction();
|
||||
|
||||
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 existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToHashSet();
|
||||
|
||||
foreach (var item in tuples)
|
||||
{
|
||||
var entity = BaseItemMapper.Map(item.Item, _appHost);
|
||||
entity.TopParentId = item.TopParent?.Id;
|
||||
|
||||
if (!existingItems.Any(e => e == entity.Id))
|
||||
if (!existingItems.Contains(entity.Id))
|
||||
{
|
||||
context.BaseItems.Add(entity);
|
||||
}
|
||||
else
|
||||
{
|
||||
context.BaseItemProviders.Where(e => e.ItemId == entity.Id).ExecuteDelete();
|
||||
context.BaseItemImageInfos.Where(e => e.ItemId == entity.Id).ExecuteDelete();
|
||||
context.BaseItemMetadataFields.Where(e => e.ItemId == entity.Id).ExecuteDelete();
|
||||
|
||||
if (entity.Images is { Count: > 0 })
|
||||
{
|
||||
context.BaseItemImageInfos.AddRange(entity.Images);
|
||||
@@ -314,9 +310,11 @@ public class ItemPersistenceService : IItemPersistenceService
|
||||
}).ToArray();
|
||||
context.ItemValues.AddRange(missingItemValues);
|
||||
|
||||
var itemValuesStore = existingValues.Concat(missingItemValues).ToArray();
|
||||
var itemValuesStore = existingValues
|
||||
.Concat(missingItemValues)
|
||||
.ToDictionary(e => (e.Type, e.Value));
|
||||
var valueMap = itemValueMaps
|
||||
.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()))
|
||||
.Select(f => (f.Item, Values: f.Values.Select(e => itemValuesStore[(e.MagicNumber, e.Value)]).DistinctBy(e => e.ItemValueId).ToArray()))
|
||||
.ToArray();
|
||||
|
||||
var mappedValues = context.ItemValuesMap.Where(e => ids.Contains(e.ItemId)).ToList();
|
||||
@@ -401,6 +399,15 @@ public class ItemPersistenceService : IItemPersistenceService
|
||||
}
|
||||
}
|
||||
|
||||
// Owned rows of updated items are rewritten wholesale; cleared in one statement per table.
|
||||
if (existingItems.Count > 0)
|
||||
{
|
||||
var updatedIds = existingItems.ToArray();
|
||||
context.BaseItemProviders.WhereOneOrMany(updatedIds, e => e.ItemId).ExecuteDelete();
|
||||
context.BaseItemImageInfos.WhereOneOrMany(updatedIds, e => e.ItemId).ExecuteDelete();
|
||||
context.BaseItemMetadataFields.WhereOneOrMany(updatedIds, e => e.ItemId).ExecuteDelete();
|
||||
}
|
||||
|
||||
context.SaveChanges();
|
||||
|
||||
var folderIds = tuples
|
||||
@@ -531,7 +538,7 @@ public class ItemPersistenceService : IItemPersistenceService
|
||||
var childIdsToCheck = resolvedChildren.Select(c => c.ChildId).Distinct().ToList();
|
||||
var existingChildIds = childIdsToCheck.Count > 0
|
||||
? context.BaseItems
|
||||
.Where(e => childIdsToCheck.Contains(e.Id))
|
||||
.WhereOneOrMany(childIdsToCheck, e => e.Id)
|
||||
.Select(e => e.Id)
|
||||
.ToHashSet()
|
||||
: [];
|
||||
|
||||
@@ -7,6 +7,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Entities.Audio;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -59,31 +60,51 @@ public class LinkedChildrenService : ILinkedChildrenService
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlySet<Guid> GetItemIdsWithAlternateVersions(IReadOnlyList<Guid> itemIds)
|
||||
{
|
||||
if (itemIds.Count == 0)
|
||||
{
|
||||
return new HashSet<Guid>();
|
||||
}
|
||||
|
||||
using var dbContext = _dbProvider.CreateDbContext();
|
||||
|
||||
return dbContext.LinkedChildren
|
||||
.Where(lc => lc.ChildType == DbLinkedChildType.LocalAlternateVersion
|
||||
|| lc.ChildType == DbLinkedChildType.LinkedAlternateVersion)
|
||||
.WhereOneOrMany(itemIds, lc => lc.ParentId)
|
||||
.Select(lc => lc.ParentId)
|
||||
.Distinct()
|
||||
.ToHashSet();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlyDictionary<string, MusicArtist[]> FindArtists(IReadOnlyList<string> artistNames)
|
||||
{
|
||||
using var dbContext = _dbProvider.CreateDbContext();
|
||||
|
||||
var lowerNames = artistNames.Select(n => n.ToLowerInvariant()).ToArray();
|
||||
var cleanNames = artistNames.Select(n => (Original: n, Clean: n.GetCleanValue())).ToArray();
|
||||
var cleanValues = cleanNames.Select(x => x.Clean).ToArray();
|
||||
|
||||
var artists = dbContext.BaseItems
|
||||
.AsNoTracking()
|
||||
.Where(e => e.Type == _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]!)
|
||||
.Where(e => lowerNames.Contains(e.Name!.ToLower()))
|
||||
.Where(e => cleanValues.Contains(e.CleanName))
|
||||
.ToArray();
|
||||
|
||||
var lookup = artists
|
||||
.GroupBy(e => e.Name!, StringComparer.OrdinalIgnoreCase)
|
||||
.GroupBy(e => e.CleanName!)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.Select(f => _queryHelpers.DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray(),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
g => g.Select(f => _queryHelpers.DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray());
|
||||
|
||||
var result = new Dictionary<string, MusicArtist[]>(artistNames.Count);
|
||||
foreach (var name in artistNames)
|
||||
var result = new Dictionary<string, MusicArtist[]>(cleanNames.Length);
|
||||
foreach (var (original, clean) in cleanNames)
|
||||
{
|
||||
if (lookup.TryGetValue(name, out var artistArray))
|
||||
if (lookup.TryGetValue(clean, out var artistArray))
|
||||
{
|
||||
result[name] = artistArray;
|
||||
result[original] = artistArray;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,10 +21,11 @@ namespace Jellyfin.Server.Implementations.Item;
|
||||
/// </summary>
|
||||
/// <param name="dbProvider">Efcore Factory.</param>
|
||||
/// <param name="itemTypeLookup">Items lookup service.</param>
|
||||
/// <param name="queryHelpers">Shared item query helpers.</param>
|
||||
/// <remarks>
|
||||
/// Initializes a new instance of the <see cref="PeopleRepository"/> class.
|
||||
/// </remarks>
|
||||
public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, IItemTypeLookup itemTypeLookup) : IPeopleRepository
|
||||
public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, IItemTypeLookup itemTypeLookup, IItemQueryHelpers queryHelpers) : IPeopleRepository
|
||||
{
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider = dbProvider;
|
||||
|
||||
@@ -33,12 +34,13 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
|
||||
{
|
||||
using var context = _dbProvider.CreateDbContext();
|
||||
var dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter);
|
||||
int? distinctNameCount = null;
|
||||
|
||||
// Include PeopleBaseItemMap
|
||||
if (!filter.ItemId.IsEmpty())
|
||||
{
|
||||
dbQuery = dbQuery.Include(p => p.BaseItems!.Where(m => m.ItemId == filter.ItemId))
|
||||
.OrderBy(e => e.BaseItems!.First(e => e.ItemId == filter.ItemId).ListOrder)
|
||||
.OrderBy(e => e.BaseItems!.Where(m => m.ItemId == filter.ItemId).Min(m => m.ListOrder))
|
||||
.ThenBy(e => e.PersonType)
|
||||
.ThenBy(e => e.Name);
|
||||
}
|
||||
@@ -46,17 +48,25 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
|
||||
{
|
||||
// The Peoples table has one row per (Name, PersonType), so the same person can
|
||||
// appear multiple times (e.g. as Actor and GuestStar). Collapse to one row per
|
||||
// name so /Persons doesn't return the same BaseItem id repeatedly. Lowercase the
|
||||
// grouping key so case-only duplicates collapse together.
|
||||
var representativeIds = dbQuery
|
||||
.GroupBy(e => e.Name.ToLower())
|
||||
.Select(g => g.Min(e => e.Id));
|
||||
dbQuery = context.Peoples.AsNoTracking()
|
||||
.Where(p => representativeIds.Contains(p.Id))
|
||||
.OrderBy(e => e.Name);
|
||||
// name so /Persons doesn't return the same BaseItem id repeatedly, keeping the
|
||||
// lowest id per lowercased name so case-only duplicates collapse together.
|
||||
var candidates = dbQuery;
|
||||
dbQuery = candidates
|
||||
.Where(p => !candidates.Any(other => other.Name.ToLower() == p.Name.ToLower() && other.Id < p.Id))
|
||||
.OrderBy(e => e.Name.ToLower());
|
||||
|
||||
if (filter.EnableTotalRecordCount)
|
||||
{
|
||||
distinctNameCount = candidates.Select(e => e.Name.ToLower()).Distinct().Count();
|
||||
}
|
||||
}
|
||||
|
||||
var count = 0;
|
||||
if (filter.EnableTotalRecordCount)
|
||||
{
|
||||
count = distinctNameCount ?? dbQuery.Count();
|
||||
}
|
||||
|
||||
var count = dbQuery.Count();
|
||||
if (filter.StartIndex.HasValue && filter.StartIndex > 0)
|
||||
{
|
||||
dbQuery = dbQuery.Skip(filter.StartIndex.Value);
|
||||
@@ -71,7 +81,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
|
||||
{
|
||||
StartIndex = filter.StartIndex ?? 0,
|
||||
TotalRecordCount = count,
|
||||
Items = dbQuery.AsEnumerable().Select(Map).ToArray(),
|
||||
Items = dbQuery.AsEnumerable().SelectMany(MapCredits).ToArray(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -107,9 +117,17 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
|
||||
person.Role = person.Role?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
// multiple metadata providers can provide the _same_ person; dedupe case-insensitively.
|
||||
people = people.DistinctBy(e => e.Name.ToLowerInvariant() + "-" + e.Type).ToArray();
|
||||
var personKeys = people.Select(e => e.Name.ToLowerInvariant() + "-" + e.Type).ToArray();
|
||||
// Project the values every comparison below needs once, so neither the case folding nor the
|
||||
// enum formatting is repeated per candidate.
|
||||
var credits = people.Select(e => (Person: e, LoweredName: e.Name.ToLowerInvariant(), PersonType: e.Type.ToString(), LoweredRole: e.Role.ToLowerInvariant()));
|
||||
|
||||
// multiple metadata providers can provide the _same_ credit; dedupe case-insensitively.
|
||||
// The role is part of the key because one person can hold several credits of the same type
|
||||
// on an item, e.g. a Writer credited for both the Novel and the Screenplay.
|
||||
var distinctCredits = credits.DistinctBy(e => (e.LoweredName, e.PersonType, e.LoweredRole)).ToArray();
|
||||
|
||||
var distinctPersons = distinctCredits.DistinctBy(e => (e.LoweredName, e.PersonType)).ToArray();
|
||||
var personKeys = distinctPersons.Select(e => e.LoweredName + "-" + e.PersonType).ToArray();
|
||||
|
||||
using var context = _dbProvider.CreateDbContext();
|
||||
using var transaction = context.Database.BeginTransaction();
|
||||
@@ -122,23 +140,44 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
|
||||
.Select(f => f.item)
|
||||
.ToArray();
|
||||
|
||||
var toAdd = people
|
||||
.Where(e => !existingPersons.Any(f => string.Equals(f.Name, e.Name, StringComparison.OrdinalIgnoreCase) && f.PersonType == e.Type.ToString()))
|
||||
.Select(Map);
|
||||
var existingPersonKeys = existingPersons.Select(e => (e.Name.ToLowerInvariant(), e.PersonType ?? string.Empty)).ToHashSet();
|
||||
|
||||
var toAdd = distinctPersons
|
||||
.Where(e => !existingPersonKeys.Contains((e.LoweredName, e.PersonType)))
|
||||
.Select(e => Map(e.Person))
|
||||
.ToArray();
|
||||
context.Peoples.AddRange(toAdd);
|
||||
context.SaveChanges();
|
||||
|
||||
var personsEntities = toAdd.Concat(existingPersons).ToArray();
|
||||
// The Peoples table can hold case-only duplicates, so keep the first match per key just as
|
||||
// the previous First() lookup did.
|
||||
var personsEntities = new Dictionary<(string LoweredName, string PersonType), People>();
|
||||
foreach (var entity in toAdd.Concat(existingPersons))
|
||||
{
|
||||
personsEntities.TryAdd((entity.Name.ToLowerInvariant(), entity.PersonType ?? string.Empty), entity);
|
||||
}
|
||||
|
||||
var existingMaps = context.PeopleBaseItemMap.Include(e => e.People).Where(e => e.ItemId == itemId).ToList();
|
||||
var existingMapsByCredit = new Dictionary<(string LoweredName, string PersonType, string LoweredRole), PeopleBaseItemMap>();
|
||||
foreach (var map in existingMaps)
|
||||
{
|
||||
existingMapsByCredit.TryAdd((map.People.Name.ToLowerInvariant(), map.People.PersonType ?? string.Empty, map.Role?.ToLowerInvariant() ?? string.Empty), map);
|
||||
}
|
||||
|
||||
var listOrder = 0;
|
||||
|
||||
foreach (var person in people)
|
||||
foreach (var credit in distinctCredits)
|
||||
{
|
||||
var entityPerson = personsEntities.First(e => string.Equals(e.Name, person.Name, StringComparison.OrdinalIgnoreCase) && e.PersonType == person.Type.ToString());
|
||||
var existingMap = existingMaps.FirstOrDefault(e => string.Equals(e.People.Name, person.Name, StringComparison.OrdinalIgnoreCase) && e.People.PersonType == person.Type.ToString() && e.Role == person.Role);
|
||||
if (existingMap is null)
|
||||
var entityPerson = personsEntities[(credit.LoweredName, credit.PersonType)];
|
||||
if (existingMapsByCredit.TryGetValue((credit.LoweredName, credit.PersonType, credit.LoweredRole), out var existingMap))
|
||||
{
|
||||
// Update the order for existing mappings
|
||||
existingMap.ListOrder = listOrder;
|
||||
existingMap.SortOrder = credit.Person.SortOrder;
|
||||
// person mapping already exists so remove from list
|
||||
existingMaps.Remove(existingMap);
|
||||
}
|
||||
else
|
||||
{
|
||||
context.PeopleBaseItemMap.Add(new PeopleBaseItemMap()
|
||||
{
|
||||
@@ -147,28 +186,52 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
|
||||
People = null!,
|
||||
PeopleId = entityPerson.Id,
|
||||
ListOrder = listOrder,
|
||||
SortOrder = person.SortOrder,
|
||||
Role = person.Role
|
||||
SortOrder = credit.Person.SortOrder,
|
||||
Role = credit.Person.Role
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
// Update the order for existing mappings
|
||||
existingMap.ListOrder = listOrder;
|
||||
existingMap.SortOrder = person.SortOrder;
|
||||
// person mapping already exists so remove from list
|
||||
existingMaps.Remove(existingMap);
|
||||
}
|
||||
|
||||
listOrder++;
|
||||
}
|
||||
|
||||
var droppedCredits = existingMaps.Select(e => e.PeopleId).Distinct().ToArray();
|
||||
context.PeopleBaseItemMap.RemoveRange(existingMaps);
|
||||
|
||||
context.SaveChanges();
|
||||
|
||||
// Nothing else ever deletes a credit row, so one left without a single mapping outlives the
|
||||
// credit it stood for: it keeps a person of that name off the dead-person sweep, which only
|
||||
// sees items no credit names, and keeps the name in every by-name list. That is how a credit
|
||||
// a provider dropped, or one a broken provider result invented, becomes impossible to clean up.
|
||||
DeleteCreditsWithoutMapping(context, droppedCredits);
|
||||
|
||||
context.SaveChanges();
|
||||
transaction.Commit();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int DeleteOrphanedCredits()
|
||||
{
|
||||
using var context = _dbProvider.CreateDbContext();
|
||||
|
||||
return DeleteCreditsWithoutMapping(context, null);
|
||||
}
|
||||
|
||||
// A null candidate list sweeps every credit, anything else only the ones just unmapped.
|
||||
private int DeleteCreditsWithoutMapping(JellyfinDbContext context, IReadOnlyList<Guid>? candidates)
|
||||
{
|
||||
if (candidates is not null && candidates.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var credits = candidates is null
|
||||
? context.Peoples.AsQueryable()
|
||||
: context.Peoples.WhereOneOrMany(candidates, e => e.Id);
|
||||
|
||||
return credits.Where(e => !context.PeopleBaseItemMap.Any(f => f.PeopleId == e.Id)).ExecuteDelete();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes)
|
||||
{
|
||||
@@ -205,9 +268,66 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
|
||||
return result;
|
||||
}
|
||||
|
||||
private PersonInfo Map(People people)
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>> GetPeopleByItems(IReadOnlyList<Guid> itemIds)
|
||||
{
|
||||
using var context = _dbProvider.CreateDbContext();
|
||||
var rows = context.PeopleBaseItemMap
|
||||
.AsNoTracking()
|
||||
.Where(m => itemIds.Contains(m.ItemId))
|
||||
.OrderBy(m => m.ListOrder)
|
||||
.Select(m => new
|
||||
{
|
||||
m.ItemId,
|
||||
m.Role,
|
||||
m.SortOrder,
|
||||
m.People.Id,
|
||||
m.People.Name,
|
||||
m.People.PersonType
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var result = new Dictionary<Guid, IReadOnlyList<PersonInfo>>();
|
||||
foreach (var group in rows.GroupBy(r => r.ItemId))
|
||||
{
|
||||
var people = new List<PersonInfo>();
|
||||
foreach (var row in group)
|
||||
{
|
||||
var personInfo = new PersonInfo
|
||||
{
|
||||
ItemId = row.ItemId,
|
||||
Id = row.Id,
|
||||
Name = row.Name,
|
||||
Role = row.Role,
|
||||
SortOrder = row.SortOrder
|
||||
};
|
||||
if (Enum.TryParse<PersonKind>(row.PersonType, out var kind))
|
||||
{
|
||||
personInfo.Type = kind;
|
||||
}
|
||||
|
||||
people.Add(personInfo);
|
||||
}
|
||||
|
||||
result[group.Key] = people;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private IEnumerable<PersonInfo> MapCredits(People people)
|
||||
{
|
||||
var mappings = people.BaseItems;
|
||||
if (mappings is null || mappings.Count == 0)
|
||||
{
|
||||
return [Map(people, null)];
|
||||
}
|
||||
|
||||
return mappings.OrderBy(m => m.ListOrder).Select(m => Map(people, m));
|
||||
}
|
||||
|
||||
private PersonInfo Map(People people, PeopleBaseItemMap? mapping)
|
||||
{
|
||||
var mapping = people.BaseItems?.FirstOrDefault();
|
||||
var personInfo = new PersonInfo()
|
||||
{
|
||||
Id = people.Id,
|
||||
@@ -240,18 +360,34 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
|
||||
if (filter.User is not null && filter.IsFavorite.HasValue)
|
||||
{
|
||||
var personType = itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
|
||||
var oldQuery = query;
|
||||
var userId = filter.User.Id;
|
||||
var isFavorite = filter.IsFavorite.Value;
|
||||
var favoriteItemIds = context.UserData
|
||||
.Where(u => u.UserId.Equals(userId) && u.IsFavorite == isFavorite)
|
||||
.Select(u => u.ItemId);
|
||||
|
||||
query = context.UserData
|
||||
.Where(u => u.Item!.Type == personType && u.IsFavorite == filter.IsFavorite && u.UserId.Equals(filter.User.Id))
|
||||
.Join(oldQuery, e => e.Item!.Name, e => e.Name, (item, person) => person)
|
||||
.Distinct()
|
||||
.AsNoTracking();
|
||||
var favoriteNames = context.BaseItems
|
||||
.Where(b => b.Type == personType && favoriteItemIds.Contains(b.Id))
|
||||
.Select(b => b.Name);
|
||||
|
||||
query = query.Where(e => favoriteNames.Contains(e.Name));
|
||||
}
|
||||
|
||||
if (filter.AccessFilter is not null)
|
||||
{
|
||||
// Keep only people credited on at least one item the user can see.
|
||||
var accessibleItems = queryHelpers.ApplyAccessFiltering(context, context.BaseItems.AsNoTracking(), filter.AccessFilter);
|
||||
query = query.Where(e => context.PeopleBaseItemMap
|
||||
.Any(m => m.PeopleId == e.Id && accessibleItems.Any(i => i.Id == m.ItemId)));
|
||||
}
|
||||
|
||||
if (!filter.ItemId.IsEmpty())
|
||||
{
|
||||
query = query.Where(e => e.BaseItems!.Any(w => w.ItemId.Equals(filter.ItemId)));
|
||||
var itemId = filter.ItemId;
|
||||
query = query.Where(e => context.PeopleBaseItemMap
|
||||
.Where(m => m.ItemId.Equals(itemId))
|
||||
.Select(m => m.PeopleId)
|
||||
.Contains(e.Id));
|
||||
}
|
||||
|
||||
if (filter.ParentId != null)
|
||||
@@ -261,7 +397,11 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
|
||||
|
||||
if (!filter.AppearsInItemId.IsEmpty())
|
||||
{
|
||||
query = query.Where(e => e.BaseItems!.Any(w => w.ItemId.Equals(filter.AppearsInItemId)));
|
||||
var appearsInItemId = filter.AppearsInItemId;
|
||||
query = query.Where(e => context.PeopleBaseItemMap
|
||||
.Where(m => m.ItemId.Equals(appearsInItemId))
|
||||
.Select(m => m.PeopleId)
|
||||
.Contains(e.Id));
|
||||
}
|
||||
|
||||
var queryPersonTypes = filter.PersonTypes.Where(IsValidPersonType).ToList();
|
||||
|
||||
@@ -225,17 +225,8 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
?? throw new ResourceNotFoundException(nameof(user.Id));
|
||||
|
||||
dbContext.Entry(dbUser).CurrentValues.SetValues(user);
|
||||
dbUser.Permissions.Clear();
|
||||
foreach (var permission in user.Permissions)
|
||||
{
|
||||
dbUser.Permissions.Add(new Permission(permission.Kind, permission.Value));
|
||||
}
|
||||
|
||||
dbUser.Preferences.Clear();
|
||||
foreach (var preference in user.Preferences)
|
||||
{
|
||||
dbUser.Preferences.Add(new Preference(preference.Kind, preference.Value));
|
||||
}
|
||||
SyncPermissions(dbUser, user.Permissions);
|
||||
SyncPreferences(dbUser, user.Preferences);
|
||||
|
||||
dbUser.AccessSchedules.Clear();
|
||||
foreach (var accessSchedule in user.AccessSchedules)
|
||||
@@ -269,6 +260,60 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
}
|
||||
}
|
||||
|
||||
private static void SyncPermissions(User dbUser, ICollection<Permission> source)
|
||||
{
|
||||
var incoming = new Dictionary<PermissionKind, bool>();
|
||||
foreach (var permission in source)
|
||||
{
|
||||
incoming[permission.Kind] = permission.Value;
|
||||
}
|
||||
|
||||
foreach (var existing in dbUser.Permissions)
|
||||
{
|
||||
if (incoming.Remove(existing.Kind, out var value))
|
||||
{
|
||||
// EF only marks the row modified if the value actually differs, so an update that
|
||||
// touches nothing but the user row - a session activity stamp - writes no children.
|
||||
existing.Value = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
dbUser.Permissions.Remove(existing);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (kind, value) in incoming)
|
||||
{
|
||||
dbUser.Permissions.Add(new Permission(kind, value));
|
||||
}
|
||||
}
|
||||
|
||||
private static void SyncPreferences(User dbUser, ICollection<Preference> source)
|
||||
{
|
||||
var incoming = new Dictionary<PreferenceKind, string>();
|
||||
foreach (var preference in source)
|
||||
{
|
||||
incoming[preference.Kind] = preference.Value;
|
||||
}
|
||||
|
||||
foreach (var existing in dbUser.Preferences)
|
||||
{
|
||||
if (incoming.Remove(existing.Kind, out var value))
|
||||
{
|
||||
existing.Value = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
dbUser.Preferences.Remove(existing);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (kind, value) in incoming)
|
||||
{
|
||||
dbUser.Preferences.Add(new Preference(kind, value));
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task<User> CreateUserInternalAsync(string name, JellyfinDbContext dbContext)
|
||||
{
|
||||
// TODO: Remove after user item data is migrated.
|
||||
|
||||
@@ -22,6 +22,11 @@ namespace Jellyfin.Server.Migrations.Routines;
|
||||
[JellyfinMigrationBackup(JellyfinDb = true)]
|
||||
internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
{
|
||||
private const int ParseProgressLogStep = 25_000;
|
||||
private const int FileCheckProgressLogStep = 10_000;
|
||||
private const int ResolveProgressLogStep = 10_000;
|
||||
private const int DeleteProgressLogStep = 25;
|
||||
|
||||
private readonly ILogger<MigrateLinkedChildren> _logger;
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
@@ -85,7 +90,6 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
var droppedChildren = 0;
|
||||
var linkedChildrenToAdd = new List<LinkedChildEntity>();
|
||||
var processedCount = 0;
|
||||
const int progressLogStep = 1000;
|
||||
var totalItems = itemsWithData.Count;
|
||||
|
||||
foreach (var item in itemsWithData)
|
||||
@@ -95,7 +99,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
continue;
|
||||
}
|
||||
|
||||
if (processedCount > 0 && processedCount % progressLogStep == 0)
|
||||
if (processedCount > 0 && processedCount % ParseProgressLogStep == 0)
|
||||
{
|
||||
_logger.LogInformation("Processing LinkedChildren: {Processed}/{Total} items", processedCount, totalItems);
|
||||
}
|
||||
@@ -311,11 +315,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
|
||||
_logger.LogInformation("Found {Count} wrong-type alternate version items to remove.", wrongTypeChildIds.Count);
|
||||
|
||||
var itemsToDelete = wrongTypeChildIds
|
||||
.Select(id => _libraryManager.GetItemById(id))
|
||||
.Where(item => item is not null)
|
||||
.ToList();
|
||||
var deleted = DeleteItems(itemsToDelete!);
|
||||
var deleted = ResolveAndDeleteItems(wrongTypeChildIds, "wrong-type alternate version items");
|
||||
|
||||
_logger.LogInformation("Removed {Count} wrong-type alternate version items. They will be recreated with the correct type on next library scan.", deleted);
|
||||
}
|
||||
@@ -342,11 +342,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
|
||||
_logger.LogInformation("Found {Count} orphaned alternate version BaseItems to remove.", orphanedVersionIds.Count);
|
||||
|
||||
var itemsToDelete = orphanedVersionIds
|
||||
.Select(id => _libraryManager.GetItemById(id))
|
||||
.Where(item => item is not null)
|
||||
.ToList();
|
||||
var deleted = DeleteItems(itemsToDelete!);
|
||||
var deleted = ResolveAndDeleteItems(orphanedVersionIds, "orphaned alternate version BaseItems");
|
||||
|
||||
_logger.LogInformation("Removed {Count} orphaned alternate version BaseItems.", deleted);
|
||||
}
|
||||
@@ -371,11 +367,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
|
||||
_logger.LogInformation("Found {Count} items from deleted libraries to remove.", orphanedIds.Count);
|
||||
|
||||
var itemsToDelete = orphanedIds
|
||||
.Select(id => _libraryManager.GetItemById(id))
|
||||
.Where(item => item is not null)
|
||||
.ToList();
|
||||
var deleted = DeleteItems(itemsToDelete!);
|
||||
var deleted = ResolveAndDeleteItems(orphanedIds, "items from deleted libraries");
|
||||
|
||||
_logger.LogInformation("Removed {Count} items from deleted libraries.", deleted);
|
||||
}
|
||||
@@ -427,8 +419,23 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
var skippedUnrootedItems = 0;
|
||||
|
||||
var staleIds = new List<Guid>();
|
||||
var checkedCount = 0;
|
||||
_logger.LogInformation("Checking {Total} items for missing files.", itemsWithPaths.Count);
|
||||
|
||||
foreach (var item in itemsWithPaths)
|
||||
{
|
||||
// A miss on offline storage can block for the mount timeout, so report while scanning.
|
||||
if (checkedCount > 0 && checkedCount % FileCheckProgressLogStep == 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Checking for missing files: {Checked}/{Total} items, {Stale} stale so far.",
|
||||
checkedCount,
|
||||
itemsWithPaths.Count,
|
||||
staleIds.Count);
|
||||
}
|
||||
|
||||
checkedCount++;
|
||||
|
||||
// Expand virtual path placeholders (%AppDataPath%, %MetadataPath%) to real paths
|
||||
var path = _appHost.ExpandVirtualPath(item.Path!);
|
||||
|
||||
@@ -482,16 +489,47 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
|
||||
_logger.LogInformation("Found {Count} stale items to remove.", staleIds.Count);
|
||||
|
||||
var itemsToDelete = staleIds
|
||||
.Select(id => _libraryManager.GetItemById(id))
|
||||
.Where(item => item is not null)
|
||||
.ToList();
|
||||
var deleted = DeleteItems(itemsToDelete!);
|
||||
var deleted = ResolveAndDeleteItems(staleIds, "items with missing files");
|
||||
|
||||
_logger.LogInformation("Removed {Count} stale items.", deleted);
|
||||
}
|
||||
|
||||
private int DeleteItems(IReadOnlyCollection<BaseItem> items)
|
||||
private int ResolveAndDeleteItems(IReadOnlyCollection<Guid> ids, string description)
|
||||
{
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return DeleteItems(ResolveItems(ids, description), description);
|
||||
}
|
||||
|
||||
private List<BaseItem> ResolveItems(IReadOnlyCollection<Guid> ids, string description)
|
||||
{
|
||||
// Each lookup is a separate repository read; cached ones are fast, so this only reports
|
||||
// once a set is large enough for the reads to add up to a noticeable stretch.
|
||||
var items = new List<BaseItem>(ids.Count);
|
||||
var processed = 0;
|
||||
foreach (var id in ids)
|
||||
{
|
||||
if (processed > 0 && processed % ResolveProgressLogStep == 0)
|
||||
{
|
||||
_logger.LogInformation("Loading {Description}: {Processed}/{Total} items", description, processed, ids.Count);
|
||||
}
|
||||
|
||||
processed++;
|
||||
|
||||
var item = _libraryManager.GetItemById(id);
|
||||
if (item is not null)
|
||||
{
|
||||
items.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private int DeleteItems(IReadOnlyCollection<BaseItem> items, string description)
|
||||
{
|
||||
if (items.Count == 0)
|
||||
{
|
||||
@@ -500,8 +538,16 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
|
||||
var options = new DeleteOptions { DeleteFileLocation = false, DeleteFromExternalProvider = false };
|
||||
var deleted = 0;
|
||||
var processed = 0;
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (processed > 0 && processed % DeleteProgressLogStep == 0)
|
||||
{
|
||||
_logger.LogInformation("Removing {Description}: {Processed}/{Total} items", description, processed, items.Count);
|
||||
}
|
||||
|
||||
processed++;
|
||||
|
||||
try
|
||||
{
|
||||
_libraryManager.DeleteItem(item, options);
|
||||
|
||||
+64
-15
@@ -1,5 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -15,9 +17,9 @@ using Microsoft.Extensions.Logging;
|
||||
namespace Jellyfin.Server.Migrations.Routines;
|
||||
|
||||
/// <summary>
|
||||
/// Recomputes the presentation unique key for every series so existing items adopt the folder-set-free key format.
|
||||
/// Recomputes the presentation unique key of every series and season so merged series are scoped to their own library.
|
||||
/// </summary>
|
||||
[JellyfinMigration("2026-07-23T12:00:00", nameof(RecomputeSeriesPresentationKey))]
|
||||
[JellyfinMigration("2026-08-21T12:00:00", nameof(RecomputeSeriesPresentationKey))]
|
||||
[JellyfinMigrationBackup(JellyfinDb = true)]
|
||||
internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine
|
||||
{
|
||||
@@ -53,6 +55,7 @@ internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine
|
||||
|
||||
const int ProgressInterval = 250;
|
||||
var sw = Stopwatch.StartNew();
|
||||
var newSeriesKeys = new Dictionary<Guid, string>();
|
||||
var processed = 0;
|
||||
var updated = 0;
|
||||
|
||||
@@ -68,9 +71,10 @@ internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine
|
||||
_logger.LogInformation("Processed {Processed}/{Total} series - Updated: {Updated} - Time: {Elapsed}", processed, series.Length, updated, sw.Elapsed);
|
||||
}
|
||||
|
||||
var oldKey = item.PresentationUniqueKey;
|
||||
var newKey = item.CreatePresentationUniqueKey();
|
||||
if (string.Equals(oldKey, newKey, StringComparison.Ordinal))
|
||||
newSeriesKeys[item.Id] = newKey;
|
||||
|
||||
if (string.Equals(item.PresentationUniqueKey, newKey, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -82,21 +86,66 @@ internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine
|
||||
.ExecuteUpdateAsync(e => e.SetProperty(f => f.PresentationUniqueKey, newKey), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Seasons and episodes cache the series key in SeriesPresentationUniqueKey and are matched
|
||||
// to the series by it. Re-point every child still carrying the old key in a single set-based
|
||||
// update so they stay attached without waiting for the next scan.
|
||||
if (!string.IsNullOrEmpty(oldKey))
|
||||
{
|
||||
await dbContext.BaseItems
|
||||
.Where(e => e.SeriesPresentationUniqueKey == oldKey)
|
||||
.ExecuteUpdateAsync(e => e.SetProperty(f => f.SeriesPresentationUniqueKey, newKey), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
// Seasons and episodes are matched to their series by SeriesPresentationUniqueKey, so
|
||||
// re-point them here instead of waiting for the next scan. Scoped by SeriesId rather than
|
||||
// by the old key: that key can be shared by every library holding the series, so matching
|
||||
// on it would drag the other libraries' children along.
|
||||
await dbContext.BaseItems
|
||||
.Where(e => e.SeriesId.HasValue && e.SeriesId.Value.Equals(id))
|
||||
.ExecuteUpdateAsync(e => e.SetProperty(f => f.SeriesPresentationUniqueKey, newKey), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
updated++;
|
||||
}
|
||||
|
||||
var updatedSeasons = await RecomputeSeasonsAsync(dbContext, newSeriesKeys, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Recomputed presentation unique key for {Updated} of {Count} series and {UpdatedSeasons} seasons in {Elapsed}",
|
||||
updated,
|
||||
series.Length,
|
||||
updatedSeasons,
|
||||
sw.Elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> RecomputeSeasonsAsync(JellyfinDbContext dbContext, Dictionary<Guid, string> newSeriesKeys, CancellationToken cancellationToken)
|
||||
{
|
||||
// A season's own key embeds its series' key, so it goes stale with it.
|
||||
var seasons = _libraryManager.GetItemList(new InternalItemsQuery
|
||||
{
|
||||
IncludeItemTypes = [BaseItemKind.Season]
|
||||
}).OfType<Season>().ToArray();
|
||||
|
||||
var updated = 0;
|
||||
|
||||
foreach (var season in seasons)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Without an index number the season keeps the base key, which carries no series key at all.
|
||||
if (!season.IndexNumber.HasValue
|
||||
|| !newSeriesKeys.TryGetValue(season.SeriesId, out var seriesKey))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mirrors Season.CreatePresentationUniqueKey.
|
||||
var newKey = seriesKey + "-" + season.IndexNumber.Value.ToString("000", CultureInfo.InvariantCulture);
|
||||
if (string.Equals(season.PresentationUniqueKey, newKey, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var id = season.Id;
|
||||
await dbContext.BaseItems
|
||||
.Where(e => e.Id.Equals(id))
|
||||
.ExecuteUpdateAsync(e => e.SetProperty(f => f.PresentationUniqueKey, newKey), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
updated++;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Recomputed presentation unique key for {Updated} of {Count} series in {Elapsed}", updated, series.Length, sw.Elapsed);
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Server.ServerSetupApp;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.IO;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Server.Migrations.Routines;
|
||||
|
||||
/// <summary>
|
||||
/// Moves the views whose id used to be derived from their localized name onto their name independent id.
|
||||
/// </summary>
|
||||
[JellyfinMigration("2026-08-25T20:00:00", nameof(ConsolidateLocalizedUserViews))]
|
||||
[JellyfinMigrationBackup(JellyfinDb = true)]
|
||||
internal class ConsolidateLocalizedUserViews : IAsyncMigrationRoutine
|
||||
{
|
||||
private readonly IStartupLogger<ConsolidateLocalizedUserViews> _logger;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IServerConfigurationManager _configurationManager;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ConsolidateLocalizedUserViews"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The startup logger.</param>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <param name="configurationManager">The server configuration manager.</param>
|
||||
/// <param name="fileSystem">The file system.</param>
|
||||
/// <param name="dbProvider">The database context factory.</param>
|
||||
public ConsolidateLocalizedUserViews(
|
||||
IStartupLogger<ConsolidateLocalizedUserViews> logger,
|
||||
ILibraryManager libraryManager,
|
||||
IServerConfigurationManager configurationManager,
|
||||
IFileSystem fileSystem,
|
||||
IDbContextFactory<JellyfinDbContext> dbProvider)
|
||||
{
|
||||
_logger = logger;
|
||||
_libraryManager = libraryManager;
|
||||
_configurationManager = configurationManager;
|
||||
_fileSystem = fileSystem;
|
||||
_dbProvider = dbProvider;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task PerformAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// The Live TV view is the one that hurts: every channel and program is parented to it, so a
|
||||
// translation update or a change of UI culture used to leave them behind under a view nothing
|
||||
// looks up any more.
|
||||
var views = _libraryManager.GetItemList(new InternalItemsQuery
|
||||
{
|
||||
IncludeItemTypes = [BaseItemKind.UserView]
|
||||
}).OfType<UserView>().Where(view => view.ViewType.HasValue).ToArray();
|
||||
|
||||
if (views.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
foreach (var group in views.GroupBy(view => view.ViewType!.Value))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var viewType = group.Key;
|
||||
var folderName = _fileSystem.GetValidFilename(viewType.ToString());
|
||||
var path = Path.Combine(_configurationManager.ApplicationPaths.InternalMetadataPath, "views", folderName);
|
||||
|
||||
// Only the views created for a view type as a whole are named after it. The per user and
|
||||
// per parent ones get a folder of their own, and carry no children to lose. Match on the
|
||||
// folder rather than the whole path so a metadata directory that has since moved still
|
||||
// lines up.
|
||||
var candidates = group
|
||||
.Where(view => !string.IsNullOrEmpty(view.Path)
|
||||
&& string.Equals(Path.GetFileName(view.Path.TrimEnd(Path.DirectorySeparatorChar)), folderName, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
if (candidates.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mirrors LibraryManager.GetNamedView(name, viewType, sortName).
|
||||
var canonicalId = _libraryManager.GetNewItemId(path + "_namedview_" + viewType.ToString(), typeof(UserView));
|
||||
|
||||
var stale = candidates.Where(view => !view.Id.Equals(canonicalId)).ToArray();
|
||||
if (stale.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await ConsolidateAsync(dbContext, viewType, path, canonicalId, candidates, stale, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ConsolidateAsync(
|
||||
JellyfinDbContext dbContext,
|
||||
CollectionType viewType,
|
||||
string path,
|
||||
Guid canonicalId,
|
||||
IReadOnlyList<UserView> candidates,
|
||||
IReadOnlyList<UserView> stale,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var staleIds = stale.Select(view => view.Id).ToArray();
|
||||
Guid? newParentId = canonicalId;
|
||||
var sourceId = Guid.Empty;
|
||||
|
||||
if (!candidates.Any(view => view.Id.Equals(canonicalId)))
|
||||
{
|
||||
// Whichever of the old views the items ended up under is the one worth keeping, so give the
|
||||
// canonical id a copy of it.
|
||||
var source = await PickSourceAsync(dbContext, stale, staleIds, cancellationToken).ConfigureAwait(false);
|
||||
sourceId = source.Id;
|
||||
|
||||
_libraryManager.CreateItem(
|
||||
new UserView
|
||||
{
|
||||
Path = path,
|
||||
Id = canonicalId,
|
||||
DateCreated = source.DateCreated,
|
||||
DateModified = source.DateModified,
|
||||
Name = source.Name,
|
||||
ViewType = viewType,
|
||||
ForcedSortName = source.ForcedSortName
|
||||
},
|
||||
null);
|
||||
}
|
||||
|
||||
var reparented = await dbContext.BaseItems
|
||||
.Where(e => e.ParentId.HasValue)
|
||||
.WhereOneOrMany(staleIds, e => e.ParentId!.Value)
|
||||
.ExecuteUpdateAsync(e => e.SetProperty(f => f.ParentId, newParentId), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await dbContext.BaseItems
|
||||
.Where(e => e.TopParentId.HasValue)
|
||||
.WhereOneOrMany(staleIds, e => e.TopParentId!.Value)
|
||||
.ExecuteUpdateAsync(e => e.SetProperty(f => f.TopParentId, newParentId), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await MoveAncestorsAsync(dbContext, canonicalId, staleIds, cancellationToken).ConfigureAwait(false);
|
||||
await MoveUserSettingsAsync(dbContext, canonicalId, sourceId, staleIds, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Nothing points at them any more, and BaseItems cascades on ParentId, so this has to come last.
|
||||
await dbContext.BaseItems
|
||||
.WhereOneOrMany(staleIds, e => e.Id)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Moved {Reparented} items and dropped {Stale} stale {ViewType} views in favour of {CanonicalId}",
|
||||
reparented,
|
||||
staleIds.Length,
|
||||
viewType,
|
||||
canonicalId);
|
||||
}
|
||||
|
||||
private async Task<UserView> PickSourceAsync(
|
||||
JellyfinDbContext dbContext,
|
||||
IReadOnlyList<UserView> stale,
|
||||
IReadOnlyList<Guid> staleIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var childCounts = await dbContext.BaseItems
|
||||
.Where(e => e.ParentId.HasValue)
|
||||
.WhereOneOrMany(staleIds, e => e.ParentId!.Value)
|
||||
.GroupBy(e => e.ParentId!.Value)
|
||||
.Select(g => new { ParentId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(e => e.ParentId, e => e.Count, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return stale
|
||||
.OrderByDescending(view => childCounts.GetValueOrDefault(view.Id))
|
||||
.ThenBy(view => view.DateCreated)
|
||||
.First();
|
||||
}
|
||||
|
||||
private static async Task MoveUserSettingsAsync(
|
||||
JellyfinDbContext dbContext,
|
||||
Guid canonicalId,
|
||||
Guid sourceId,
|
||||
IReadOnlyList<Guid> staleIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Everything below is keyed by the view's id, and a view holding no children still holds the
|
||||
// ordering it was given and whether it was hidden. Only the view that was promoted can hand
|
||||
// those over - the rest would collide on the one row per user, item and client - so the others
|
||||
// are dropped instead.
|
||||
var dropped = staleIds.Where(id => !id.Equals(sourceId)).ToArray();
|
||||
|
||||
if (!sourceId.Equals(Guid.Empty))
|
||||
{
|
||||
var moved = new[] { sourceId };
|
||||
|
||||
await dbContext.DisplayPreferences
|
||||
.WhereOneOrMany(moved, e => e.ItemId)
|
||||
.ExecuteUpdateAsync(e => e.SetProperty(f => f.ItemId, canonicalId), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await dbContext.ItemDisplayPreferences
|
||||
.WhereOneOrMany(moved, e => e.ItemId)
|
||||
.ExecuteUpdateAsync(e => e.SetProperty(f => f.ItemId, canonicalId), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await dbContext.CustomItemDisplayPreferences
|
||||
.WhereOneOrMany(moved, e => e.ItemId)
|
||||
.ExecuteUpdateAsync(e => e.SetProperty(f => f.ItemId, canonicalId), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (dropped.Length > 0)
|
||||
{
|
||||
await dbContext.DisplayPreferences.WhereOneOrMany(dropped, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
|
||||
await dbContext.ItemDisplayPreferences.WhereOneOrMany(dropped, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
|
||||
await dbContext.CustomItemDisplayPreferences.WhereOneOrMany(dropped, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var stale = staleIds.ToHashSet();
|
||||
var preferences = await dbContext.Preferences
|
||||
.Where(e => e.Kind == PreferenceKind.OrderedViews || e.Kind == PreferenceKind.MyMediaExcludes)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var changed = false;
|
||||
|
||||
foreach (var preference in preferences)
|
||||
{
|
||||
var values = preference.Value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var rewritten = new List<string>(values.Length);
|
||||
var seen = new HashSet<Guid>();
|
||||
var touched = false;
|
||||
|
||||
foreach (var value in values)
|
||||
{
|
||||
// Clients write these in both the dashed and the plain form, so compare them parsed.
|
||||
if (!Guid.TryParse(value, out var parsed))
|
||||
{
|
||||
rewritten.Add(value);
|
||||
continue;
|
||||
}
|
||||
|
||||
var isStale = stale.Contains(parsed);
|
||||
if (isStale)
|
||||
{
|
||||
parsed = canonicalId;
|
||||
touched = true;
|
||||
}
|
||||
|
||||
// The same view can be listed twice once both of its ids point at the same place.
|
||||
if (!seen.Add(parsed))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
rewritten.Add(isStale
|
||||
? parsed.ToString(value.Contains('-', StringComparison.Ordinal) ? "D" : "N", CultureInfo.InvariantCulture)
|
||||
: value);
|
||||
}
|
||||
|
||||
if (!touched)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
preference.Value = string.Join(',', rewritten);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task MoveAncestorsAsync(
|
||||
JellyfinDbContext dbContext,
|
||||
Guid canonicalId,
|
||||
IReadOnlyList<Guid> staleIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var items = await dbContext.AncestorIds
|
||||
.WhereOneOrMany(staleIds, e => e.ParentItemId)
|
||||
.Select(e => e.ItemId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await dbContext.AncestorIds
|
||||
.WhereOneOrMany(staleIds, e => e.ParentItemId)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (items.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// The pair is the primary key, so anything already recorded against the canonical view stays put.
|
||||
var existing = await dbContext.AncestorIds
|
||||
.Where(e => e.ParentItemId.Equals(canonicalId))
|
||||
.Select(e => e.ItemId)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
foreach (var itemId in items.Except(existing))
|
||||
{
|
||||
dbContext.AncestorIds.Add(new AncestorId
|
||||
{
|
||||
ItemId = itemId,
|
||||
ParentItemId = canonicalId,
|
||||
Item = null!,
|
||||
ParentItem = null!
|
||||
});
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Server.Migrations.Stages;
|
||||
using Jellyfin.Server.ServerSetupApp;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Server.Migrations.Routines;
|
||||
|
||||
/// <summary>
|
||||
/// Enables the local similarity providers on libraries that predate the similar items settings.
|
||||
/// </summary>
|
||||
[JellyfinMigration("2026-08-31T10:00:00", nameof(EnableLocalSimilarityProviders), Stage = JellyfinMigrationStageTypes.AppInitialisation)]
|
||||
internal class EnableLocalSimilarityProviders : IAsyncMigrationRoutine
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IProviderManager _providerManager;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="EnableLocalSimilarityProviders"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <param name="providerManager">The provider manager.</param>
|
||||
/// <param name="startupLogger">The startup logger for Startup UI integration.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public EnableLocalSimilarityProviders(
|
||||
ILibraryManager libraryManager,
|
||||
IProviderManager providerManager,
|
||||
IStartupLogger<EnableLocalSimilarityProviders> startupLogger,
|
||||
ILogger<EnableLocalSimilarityProviders> logger)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_providerManager = providerManager;
|
||||
_logger = startupLogger.With(logger);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task PerformAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Libraries created before similar items became configurable have an empty provider list,
|
||||
// which the library editor renders as "everything unchecked" instead of falling back to the
|
||||
// defaults it uses for new libraries. Seed the local providers so they stay enabled.
|
||||
var localProvidersByType = GetLocalProvidersByItemType();
|
||||
if (localProvidersByType.Count == 0)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
foreach (var virtualFolder in _libraryManager.GetVirtualFolders(false))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
EnableLocalProviders(virtualFolder, localProvidersByType);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void EnableLocalProviders(VirtualFolderInfo virtualFolder, Dictionary<string, string[]> localProvidersByType)
|
||||
{
|
||||
var options = virtualFolder.LibraryOptions;
|
||||
if (options?.TypeOptions is null || options.TypeOptions.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Some virtual folders don't have a proper item id.
|
||||
if (!Guid.TryParse(virtualFolder.ItemId, out var folderId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var collectionFolder = _libraryManager.GetItemById<CollectionFolder>(folderId);
|
||||
if (collectionFolder is null)
|
||||
{
|
||||
_logger.LogWarning("Could not find collection folder for virtual folder '{LibraryName}' with id '{FolderId}'. Skipping.", virtualFolder.Name, folderId);
|
||||
return;
|
||||
}
|
||||
|
||||
var changed = false;
|
||||
foreach (var typeOptions in options.TypeOptions)
|
||||
{
|
||||
changed |= EnableLocalProviders(typeOptions, localProvidersByType, virtualFolder.Name);
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
collectionFolder.UpdateLibraryOptions(options);
|
||||
}
|
||||
}
|
||||
|
||||
private bool EnableLocalProviders(TypeOptions typeOptions, Dictionary<string, string[]> localProvidersByType, string libraryName)
|
||||
{
|
||||
if (typeOptions.Type is null || !localProvidersByType.TryGetValue(typeOptions.Type, out var localProviders))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var enabled = typeOptions.SimilarItemProviders ?? [];
|
||||
var missing = localProviders.Where(name => !enabled.Contains(name, StringComparer.OrdinalIgnoreCase)).ToArray();
|
||||
if (missing.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Local providers rank ahead of remote ones, and the enabled list doubles as the
|
||||
// priority order when no explicit order was saved.
|
||||
typeOptions.SimilarItemProviders = [.. missing, .. enabled];
|
||||
if (typeOptions.SimilarItemProviderOrder is { Length: > 0 } order)
|
||||
{
|
||||
typeOptions.SimilarItemProviderOrder = [.. missing, .. order];
|
||||
}
|
||||
|
||||
_logger.LogInformation("Enabled local similarity providers {Providers} for '{ItemType}' in library '{LibraryName}'.", missing, typeOptions.Type, libraryName);
|
||||
return true;
|
||||
}
|
||||
|
||||
private Dictionary<string, string[]> GetLocalProvidersByItemType()
|
||||
{
|
||||
var result = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var summary in _providerManager.GetAllMetadataPlugins())
|
||||
{
|
||||
var names = summary.Plugins
|
||||
.Where(p => p.Type == MetadataPluginType.LocalSimilarityProvider)
|
||||
.Select(p => p.Name)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
if (names.Length > 0)
|
||||
{
|
||||
result[summary.ItemType] = names;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Implement
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.CodeAnalysis", "src\Jellyfin.CodeAnalysis\Jellyfin.CodeAnalysis.csproj", "{11643D0F-6761-4EF7-AB71-6F9F8DE00714}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Drawing.Skia.Tests", "tests\Jellyfin.Drawing.Skia.Tests\Jellyfin.Drawing.Skia.Tests.csproj", "{E24A279C-9A37-419A-8F9C-853C11FBE753}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -265,6 +267,10 @@ Global
|
||||
{11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E24A279C-9A37-419A-8F9C-853C11FBE753}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E24A279C-9A37-419A-8F9C-853C11FBE753}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E24A279C-9A37-419A-8F9C-853C11FBE753}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E24A279C-9A37-419A-8F9C-853C11FBE753}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -297,6 +303,7 @@ Global
|
||||
{A5590358-33CC-4B39-BDE7-DC62FEB03C76} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}
|
||||
{8C9F9221-8415-496C-B1F5-E7756F03FA59} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}
|
||||
{11643D0F-6761-4EF7-AB71-6F9F8DE00714} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
|
||||
{E24A279C-9A37-419A-8F9C-853C11FBE753} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {3448830C-EBDC-426C-85CD-7BBB9651A7FE}
|
||||
|
||||
@@ -72,6 +72,14 @@ namespace MediaBrowser.Common.Plugins
|
||||
/// </summary>
|
||||
public bool IsEnabledAndSupported => _supported && Manifest.Status >= PluginStatus.Active;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether a restart is required for the plugin's state to take effect.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Memory only. <see cref="Manifest"/> holds the state that is persisted to disk.
|
||||
/// </remarks>
|
||||
public bool RestartRequired { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the plugin has a manifest.
|
||||
/// </summary>
|
||||
@@ -108,7 +116,7 @@ namespace MediaBrowser.Common.Plugins
|
||||
public PluginInfo GetPluginInfo()
|
||||
{
|
||||
var inst = Instance?.GetPluginInfo() ?? new PluginInfo(Manifest.Name, Version, Manifest.Description, Manifest.Id, true);
|
||||
inst.Status = Manifest.Status;
|
||||
inst.Status = RestartRequired ? PluginStatus.Restart : Manifest.Status;
|
||||
inst.HasImage = !string.IsNullOrEmpty(Manifest.ImagePath) || !string.IsNullOrEmpty(Manifest.ImageResourceName);
|
||||
return inst;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ namespace MediaBrowser.Controller.Drawing
|
||||
// Determine the output size based on incoming parameters
|
||||
var newSize = DrawingUtils.Resize(originalImageSize, options.Width ?? 0, options.Height ?? 0, options.MaxWidth ?? 0, options.MaxHeight ?? 0);
|
||||
newSize = DrawingUtils.ResizeFill(newSize, options.FillWidth, options.FillHeight);
|
||||
return newSize;
|
||||
|
||||
// Never encode larger than the source.
|
||||
return DrawingUtils.ScaleDownToFit(newSize, originalImageSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,10 +173,7 @@ namespace MediaBrowser.Controller.Entities.Audio
|
||||
|
||||
public static string GetPath(string name, bool normalizeName)
|
||||
{
|
||||
// Trim the period at the end because windows will have a hard time with that
|
||||
var validName = normalizeName ?
|
||||
FileSystem.GetValidFilename(name).Trim().TrimEnd('.') :
|
||||
name;
|
||||
var validName = normalizeName ? GetItemByNameFolderName(name) : name;
|
||||
|
||||
return System.IO.Path.Combine(ConfigurationManager.ApplicationPaths.ArtistsPath, validName);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user