Compare commits
242 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d501ba418 | |||
| 2a8d168796 | |||
| 34cc90f5e2 | |||
| 26fbd1eac0 | |||
| 35e86416af | |||
| c210ea87f2 | |||
| 4a1fcca182 | |||
| f5211a6722 | |||
| 0b77a520fa | |||
| d0e0e291f2 | |||
| 82ecfde2f7 | |||
| 7be16e9f74 | |||
| f441c5f125 | |||
| e6e3099da9 | |||
| d7dd98858e | |||
| 3d3d3c26b8 | |||
| 5f3e938d09 | |||
| 3031aa91ba | |||
| fb763c47bf | |||
| a65b614449 | |||
| 505a6ebf8f | |||
| 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 | |||
| b3a0611302 | |||
| c20cc766e1 | |||
| 8044534774 | |||
| cac463d4c3 | |||
| 7faa35dab4 | |||
| 24022092ad | |||
| e2e3094d2d | |||
| d669994bf0 | |||
| 45b8f6c4c8 | |||
| 044f651299 | |||
| f865910a90 | |||
| f2360c07a3 | |||
| 4c812c9ba4 | |||
| 26261dbfe7 | |||
| ae90e0e52e | |||
| 72d0895401 | |||
| 705368ee49 | |||
| cbc2c7c323 | |||
| e16c8a07bd | |||
| a7a138db31 | |||
| 76956c0abc | |||
| d4376e0539 | |||
| b94ba9d409 | |||
| 744ca84a8b | |||
| c55fde25a5 | |||
| 030031dcff | |||
| 341c19bace | |||
| e3a8d209b4 | |||
| e816870f67 | |||
| f28acc7fa1 | |||
| b2ae39e0d9 | |||
| 893662ba50 | |||
| 314004bc19 | |||
| 0915a61c19 | |||
| e123a13e38 | |||
| b15d3c9ad3 | |||
| 3da05330b2 | |||
| 8293eb26b9 | |||
| d8fc0a9914 | |||
| 1f4f4acb46 | |||
| d64e18b69a | |||
| 7a4271c85f | |||
| afb716566d | |||
| e5c68e1257 | |||
| a94588497c | |||
| 4cedbe28e3 | |||
| 9a258c089d | |||
| 9ff92f3f0d | |||
| 5f5c71ab75 | |||
| d92e59aa72 | |||
| d593436281 | |||
| 41b8bd4459 | |||
| f6f0fed2d1 | |||
| e2adf2e2bf | |||
| 78b535b229 | |||
| 7215950bd4 | |||
| c435f63d09 | |||
| eed664b7d3 | |||
| 5b550517b2 | |||
| e565073fd5 | |||
| 2b93191714 | |||
| 046225654a | |||
| 0db27d7b1b | |||
| f571cd5a6a | |||
| 79a55327dc | |||
| dbc796b0b0 | |||
| 02d8e7d828 | |||
| 1e4d126cb9 | |||
| f3a1d56c56 | |||
| 67b8684f3c | |||
| b04614e18d | |||
| 689a90d275 | |||
| 766be1e8bb | |||
| f2f66606d7 | |||
| 949fa4b513 | |||
| 40fa2fd390 | |||
| 7f26bd1091 | |||
| 86ac1aaa6b | |||
| 3c9727d36c | |||
| 5a2809e337 | |||
| d1d89dfb12 | |||
| 45ec0ed8b5 | |||
| ebb66f6ca3 | |||
| b85c9186ef | |||
| c606102f6d | |||
| 8b70582561 | |||
| b62fef3c1f | |||
| 71ab342838 | |||
| 83c4681e99 | |||
| 42e52f60ad | |||
| 6ac64c5319 | |||
| 83e0cfd7ee | |||
| 700e53a807 | |||
| 4d1f90b2f7 | |||
| d74babd8f3 | |||
| b7b2700425 | |||
| 04e4505402 | |||
| 95330223f4 | |||
| f28fa563c9 | |||
| d6ce6ae8b9 | |||
| 8f57f35372 | |||
| 9aa79682ba | |||
| d4cddb8a5d | |||
| dc300fae53 | |||
| 70980f09de | |||
| ca0cf763ff | |||
| 3d4c52092e | |||
| fc0af10509 | |||
| 88216e0ec4 | |||
| 474ae50c36 | |||
| 929e1936eb | |||
| 6382563440 | |||
| 733b4ba73a | |||
| 5d580abb08 | |||
| ca1f7af445 | |||
| 0c7428f136 | |||
| 97e666c566 | |||
| d36c8ebce8 | |||
| 5be844e1b7 | |||
| 6f189bf2b8 | |||
| f3ff7a446b | |||
| a3ec1a3712 | |||
| 6bbd6dcd44 | |||
| ce43df6f43 | |||
| 6883cd0969 | |||
| 5221584b06 | |||
| c4ace4ac95 | |||
| 2fcf4084f8 | |||
| 1bfbad2420 |
@@ -32,13 +32,13 @@ jobs:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
uses: github/codeql-action/autobuild@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
--verbosity minimal
|
||||
|
||||
- name: Merge code coverage results
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@049f7ec958c672fd31d5cc1cb01622dc8d2e23ab # v5.5.10
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@d3ebf1f760f7d8ab92cc44d9bcfee7ad73722a31 # v5.5.11
|
||||
with:
|
||||
reports: "**/coverage.cobertura.xml"
|
||||
targetdir: "merged/"
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ contains(github.repository, 'jellyfin/') }}
|
||||
steps:
|
||||
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
|
||||
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
|
||||
with:
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
ascending: true
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ contains(github.repository, 'jellyfin/') }}
|
||||
steps:
|
||||
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
|
||||
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
|
||||
with:
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
ascending: true
|
||||
|
||||
@@ -172,6 +172,7 @@
|
||||
- [whooo](https://github.com/whooo)
|
||||
- [WiiPlayer2](https://github.com/WiiPlayer2)
|
||||
- [WillWill56](https://github.com/WillWill56)
|
||||
- [WizardOfYendor1](https://github.com/WizardOfYendor1)
|
||||
- [wtayl0r](https://github.com/wtayl0r)
|
||||
- [Wuerfelbecher](https://github.com/Wuerfelbecher)
|
||||
- [Wunax](https://github.com/Wunax)
|
||||
@@ -233,6 +234,9 @@
|
||||
- [MSalman5230](https://github.com/MSalman5230)
|
||||
- [dwandw](https://github.com/dwandw)
|
||||
- [Lampan-git](https://github.com/Lampan-git)
|
||||
- [elio42](https://github.com/elio42)
|
||||
- [rwebster85](https://github.com/rwebster85)
|
||||
- [Florin-Popescu](https://github.com/Florin-Popescu)
|
||||
|
||||
# Emby Contributors
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<PackageVersion Include="Diacritics" Version="4.1.8" />
|
||||
<PackageVersion Include="DiscUtils.Udf" Version="0.16.13" />
|
||||
<PackageVersion Include="DotNet.Glob" Version="3.1.3" />
|
||||
<PackageVersion Include="FsCheck.Xunit.v3" Version="3.3.3" />
|
||||
<PackageVersion Include="FsCheck.Xunit.v3" Version="3.3.4" />
|
||||
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="8.3.1.5" />
|
||||
<PackageVersion Include="ICU4N.Transliterator" Version="60.1.0-alpha.356" />
|
||||
<PackageVersion Include="IDisposableAnalyzers" Version="4.0.8" />
|
||||
@@ -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.0" />
|
||||
<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" />
|
||||
|
||||
@@ -57,7 +57,6 @@ namespace Emby.Naming.Common
|
||||
".nrg",
|
||||
".nsv",
|
||||
".nuv",
|
||||
".ogg",
|
||||
".ogm",
|
||||
".ogv",
|
||||
".pva",
|
||||
@@ -362,7 +361,10 @@ namespace Emby.Naming.Common
|
||||
// Not a Kodi rule as well, but the expression below also causes false positives,
|
||||
// so we make sure this one gets tested first.
|
||||
// "Foo Bar 889"
|
||||
new EpisodeExpression(@".*[\\\/](?![Ee]pisode)(?<seriesname>[\w\s]+?)\s(?<epnumber>[0-9]{1,4})(-(?<endingepnumber>[0-9]{2,4}))*[^\\\/x]*$")
|
||||
// Names carrying an SxxEyy marker are excluded because the Kodi expression above already covers them.
|
||||
// Without that guard this expression reads digits out of the title instead, turning
|
||||
// "S01E01 1-23-45 [Bluray-1080p]" into episodes 1 through 45.
|
||||
new EpisodeExpression(@".*[\\\/](?![Ee]pisode)(?![^\\\/]*[Ss][0-9]+[][ ._-]*[Ee][0-9]+)(?<seriesname>[\w\s]+?)\s(?<epnumber>[0-9]{1,4})(-(?<endingepnumber>[0-9]{2,4}))*[^\\\/x]*$")
|
||||
{
|
||||
IsNamed = true
|
||||
},
|
||||
|
||||
@@ -52,13 +52,18 @@ namespace Emby.Naming.Video
|
||||
while (path.Length > 0)
|
||||
{
|
||||
var index = path.IndexOfAny(delimiters);
|
||||
ReadOnlySpan<char> currentSlice;
|
||||
if (index == -1)
|
||||
{
|
||||
index = path.Length - 1;
|
||||
// No delimiter left, the last token is the remainder of the path
|
||||
currentSlice = path;
|
||||
path = default;
|
||||
}
|
||||
else
|
||||
{
|
||||
currentSlice = path[..index];
|
||||
path = path[(index + 1)..];
|
||||
}
|
||||
|
||||
var currentSlice = path[..index];
|
||||
path = path[(index + 1)..];
|
||||
|
||||
if (!foundPrefix)
|
||||
{
|
||||
|
||||
@@ -987,8 +987,9 @@ namespace Emby.Server.Implementations
|
||||
/// <inheritdoc/>
|
||||
public string GetLocalApiUrl(string hostname, string scheme = null, int? port = null)
|
||||
{
|
||||
// If the smartAPI doesn't start with http then treat it as a host or ip.
|
||||
if (hostname.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
||||
// If the smartAPI isn't already a complete URL then treat it as a host or ip.
|
||||
if (hostname.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|
||||
|| hostname.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return hostname.TrimEnd('/');
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
@@ -742,12 +777,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 +998,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))
|
||||
{
|
||||
@@ -1088,7 +1130,7 @@ namespace Emby.Server.Implementations.Dto
|
||||
dto.ParentId = item.DisplayParentId;
|
||||
}
|
||||
|
||||
AddInheritedImages(dto, item, options, owner);
|
||||
AddInheritedImages(dto, item, options, owner, artistsBatch);
|
||||
|
||||
if (options.ContainsField(ItemFields.Path))
|
||||
{
|
||||
@@ -1271,15 +1313,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1519,11 +1573,11 @@ namespace Emby.Server.Implementations.Dto
|
||||
}
|
||||
}
|
||||
|
||||
private BaseItem? GetImageDisplayParent(BaseItem currentItem, BaseItem originalItem)
|
||||
private BaseItem? GetImageDisplayParent(BaseItem currentItem, BaseItem originalItem, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch)
|
||||
{
|
||||
if (currentItem is MusicAlbum musicAlbum)
|
||||
{
|
||||
var artist = musicAlbum.GetMusicArtist(new DtoOptions(false));
|
||||
var artist = GetBatchedAlbumArtist(musicAlbum, artistsBatch) ?? musicAlbum.GetMusicArtist(new DtoOptions(false));
|
||||
if (artist is not null)
|
||||
{
|
||||
return artist;
|
||||
@@ -1540,7 +1594,20 @@ namespace Emby.Server.Implementations.Dto
|
||||
return parent;
|
||||
}
|
||||
|
||||
private void AddInheritedImages(BaseItemDto dto, BaseItem item, DtoOptions options, BaseItem? owner)
|
||||
private static MusicArtist? GetBatchedAlbumArtist(MusicAlbum album, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch)
|
||||
{
|
||||
if (artistsBatch is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var name = album.AlbumArtists.Count > 0 ? album.AlbumArtists[0] : null;
|
||||
return !string.IsNullOrEmpty(name) && artistsBatch.TryGetValue(name, out var artists) && artists.Length > 0
|
||||
? artists[0]
|
||||
: null;
|
||||
}
|
||||
|
||||
private void AddInheritedImages(BaseItemDto dto, BaseItem item, DtoOptions options, BaseItem? owner, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch)
|
||||
{
|
||||
if (item is UserView { ViewType: CollectionType.playlists } playlistsView
|
||||
&& options.GetImageLimit(ImageType.Primary) > 0
|
||||
@@ -1585,7 +1652,7 @@ namespace Emby.Server.Implementations.Dto
|
||||
|| (!(imageTags is not null && imageTags.ContainsKey(ImageType.Thumb)) && thumbLimit > 0)
|
||||
|| parent is Series)
|
||||
{
|
||||
parent ??= isFirst ? GetImageDisplayParent(item, item) ?? owner : parent;
|
||||
parent ??= isFirst ? GetImageDisplayParent(item, item, artistsBatch) ?? owner : parent;
|
||||
if (parent is null)
|
||||
{
|
||||
break;
|
||||
@@ -1644,7 +1711,7 @@ namespace Emby.Server.Implementations.Dto
|
||||
break;
|
||||
}
|
||||
|
||||
parent = GetImageDisplayParent(parent, item);
|
||||
parent = GetImageDisplayParent(parent, item, artistsBatch);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ using MediaBrowser.Model.Configuration;
|
||||
using MediaBrowser.Model.Drawing;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Globalization;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Model.Library;
|
||||
using MediaBrowser.Model.Querying;
|
||||
@@ -86,6 +87,7 @@ namespace Emby.Server.Implementations.Library
|
||||
private readonly IPeopleRepository _peopleRepository;
|
||||
private readonly ExtraResolver _extraResolver;
|
||||
private readonly IPathManager _pathManager;
|
||||
private readonly ILocalizationManager _localization;
|
||||
private readonly FastConcurrentLru<Guid, BaseItem> _cache;
|
||||
private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule;
|
||||
private readonly IMediaStreamRepository _mediaStreamRepository;
|
||||
@@ -132,6 +134,7 @@ namespace Emby.Server.Implementations.Library
|
||||
/// <param name="peopleRepository">The people repository.</param>
|
||||
/// <param name="pathManager">The path manager.</param>
|
||||
/// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param>
|
||||
/// <param name="localization">The localization manager.</param>
|
||||
/// <param name="mediaStreamRepository">The media stream repository.</param>
|
||||
/// <param name="externalDataManagerFactory">The external data manager (lazy, to break the DI cycle through ChapterManager).</param>
|
||||
public LibraryManager(
|
||||
@@ -157,6 +160,7 @@ namespace Emby.Server.Implementations.Library
|
||||
IPeopleRepository peopleRepository,
|
||||
IPathManager pathManager,
|
||||
DotIgnoreIgnoreRule dotIgnoreIgnoreRule,
|
||||
ILocalizationManager localization,
|
||||
IMediaStreamRepository mediaStreamRepository,
|
||||
Lazy<IExternalDataManager> externalDataManagerFactory)
|
||||
{
|
||||
@@ -184,6 +188,7 @@ namespace Emby.Server.Implementations.Library
|
||||
_peopleRepository = peopleRepository;
|
||||
_pathManager = pathManager;
|
||||
_dotIgnoreIgnoreRule = dotIgnoreIgnoreRule;
|
||||
_localization = localization;
|
||||
_extraResolver = new ExtraResolver(loggerFactory.CreateLogger<ExtraResolver>(), namingOptions, directoryService);
|
||||
|
||||
_configurationManager.ConfigurationUpdated += ConfigurationUpdated;
|
||||
@@ -407,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)
|
||||
@@ -606,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);
|
||||
}
|
||||
|
||||
@@ -2229,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)
|
||||
{
|
||||
@@ -2315,9 +2339,13 @@ namespace Emby.Server.Implementations.Library
|
||||
{
|
||||
var comparer = Comparers.FirstOrDefault(c => name == c.Type);
|
||||
|
||||
// If it requires a user, create a new one, and assign the user
|
||||
if (comparer is IUserBaseItemComparer)
|
||||
{
|
||||
if (user is null)
|
||||
{
|
||||
throw new ArgumentException($"Sort key '{name}' requires a user, but none was provided.");
|
||||
}
|
||||
|
||||
var userComparer = (IUserBaseItemComparer)Activator.CreateInstance(comparer.GetType())!; // only null for Nullable<T> instances
|
||||
|
||||
userComparer.User = user;
|
||||
@@ -2367,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.
|
||||
@@ -2552,6 +2581,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);
|
||||
@@ -2580,6 +2611,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.
|
||||
@@ -2640,6 +2672,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)
|
||||
{
|
||||
@@ -3195,11 +3251,11 @@ namespace Emby.Server.Implementations.Library
|
||||
}
|
||||
}
|
||||
|
||||
if (!episode.ProductionYear.HasValue)
|
||||
if (episode.ProductionYear is null)
|
||||
{
|
||||
episode.ProductionYear = episodeInfo.Year;
|
||||
|
||||
if (episode.ProductionYear.HasValue)
|
||||
if (episode.ProductionYear is not null)
|
||||
{
|
||||
changed = true;
|
||||
}
|
||||
@@ -3276,9 +3332,11 @@ namespace Emby.Server.Implementations.Library
|
||||
var ownerVideoInfo = VideoResolver.Resolve(owner.Path, isFolder, _namingOptions, libraryRoot: owner.ContainingFolderPath);
|
||||
if (ownerVideoInfo is null)
|
||||
{
|
||||
yield break;
|
||||
return [];
|
||||
}
|
||||
|
||||
var candidates = new List<ExtraCandidate>();
|
||||
|
||||
var count = filtered.Count;
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
@@ -3292,35 +3350,50 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
foreach (var file in filesInSubFolderList)
|
||||
{
|
||||
if (!_extraResolver.TryGetExtraTypeForOwner(file.FullName, ownerVideoInfo, out var extraType))
|
||||
if (!_extraResolver.TryGetExtraTypeForOwner(file.FullName, ownerVideoInfo, out var extraType, out var extraRule))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var extra = GetExtra(file, extraType.Value, subFolderIsMixedFolder);
|
||||
if (extra is not null)
|
||||
{
|
||||
yield return extra;
|
||||
}
|
||||
AddCandidate(file, extraType.Value, extraRule, subFolderIsMixedFolder);
|
||||
}
|
||||
}
|
||||
else if (!current.IsDirectory && _extraResolver.TryGetExtraTypeForOwner(current.FullName, ownerVideoInfo, out var extraType))
|
||||
else if (!current.IsDirectory && _extraResolver.TryGetExtraTypeForOwner(current.FullName, ownerVideoInfo, out var extraType, out var extraRule))
|
||||
{
|
||||
var extra = GetExtra(current, extraType.Value, false);
|
||||
if (extra is not null)
|
||||
{
|
||||
yield return extra;
|
||||
}
|
||||
AddCandidate(current, extraType.Value, extraRule, false);
|
||||
}
|
||||
}
|
||||
|
||||
BaseItem? GetExtra(FileSystemMetadata file, ExtraType extraType, bool isInMixedFolder)
|
||||
var extras = new List<BaseItem>();
|
||||
var typeCounters = new Dictionary<ExtraType, int>();
|
||||
|
||||
// Order by path so that the numbering handed out below does not depend on the
|
||||
// order the file system happened to list the folder in
|
||||
foreach (var candidate in candidates.OrderBy(c => c.Extra.Path, StringComparer.Ordinal))
|
||||
{
|
||||
var extra = PrepareExtra(candidate);
|
||||
if (extra is not null)
|
||||
{
|
||||
extras.Add(extra);
|
||||
}
|
||||
}
|
||||
|
||||
return extras;
|
||||
|
||||
void AddCandidate(FileSystemMetadata file, ExtraType extraType, ExtraRule extraRule, bool isInMixedFolder)
|
||||
{
|
||||
var extra = ResolvePath(_fileSystem.GetFileInfo(file.FullName), directoryService, _extraResolver.GetResolversForExtraType(extraType));
|
||||
if (extra is not Video && extra is not Audio)
|
||||
if (extra is Video or Audio)
|
||||
{
|
||||
return null;
|
||||
candidates.Add(new ExtraCandidate(extra, extraType, extraRule, isInMixedFolder));
|
||||
}
|
||||
}
|
||||
|
||||
BaseItem? PrepareExtra(ExtraCandidate candidate)
|
||||
{
|
||||
var resolved = candidate.Extra;
|
||||
var extra = resolved;
|
||||
var name = GetExtraName(candidate, ownerVideoInfo, typeCounters);
|
||||
|
||||
// Try to retrieve it from the db. If we don't find it, use the resolved version
|
||||
var itemById = GetItemById(extra.Id);
|
||||
@@ -3329,10 +3402,18 @@ namespace Emby.Server.Implementations.Library
|
||||
extra = itemById;
|
||||
}
|
||||
|
||||
// Only update extra type if it is more specific then the currently known extra type
|
||||
if (extra.ExtraType is null or ExtraType.Unknown || extraType != ExtraType.Unknown)
|
||||
// An extra is named after its file, so the file is the source of truth. Items created
|
||||
// by older versions, or renamed by a metadata provider, are corrected here;
|
||||
// RefreshExtras persists the change.
|
||||
if (!string.IsNullOrEmpty(name) && extra.LockedFields?.Contains(MetadataField.Name) != true)
|
||||
{
|
||||
extra.ExtraType = extraType;
|
||||
extra.Name = name;
|
||||
}
|
||||
|
||||
// Only update extra type if it is more specific then the currently known extra type
|
||||
if (extra.ExtraType is null or ExtraType.Unknown || candidate.ExtraType != ExtraType.Unknown)
|
||||
{
|
||||
extra.ExtraType = candidate.ExtraType;
|
||||
}
|
||||
|
||||
// Only return items that are actual extras (have ExtraType set)
|
||||
@@ -3340,7 +3421,7 @@ namespace Emby.Server.Implementations.Library
|
||||
// so that RefreshExtras can detect when they need updating and set ForceSave.
|
||||
if (extra.ExtraType is not null)
|
||||
{
|
||||
extra.IsInMixedFolder = isInMixedFolder;
|
||||
extra.IsInMixedFolder = candidate.IsInMixedFolder;
|
||||
return extra;
|
||||
}
|
||||
|
||||
@@ -3348,6 +3429,57 @@ namespace Emby.Server.Implementations.Library
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name to give an extra.
|
||||
/// </summary>
|
||||
/// <param name="candidate">The resolved extra.</param>
|
||||
/// <param name="ownerVideoInfo">The naming info of the owner.</param>
|
||||
/// <param name="typeCounters">Number of extras named after their type so far, per type.</param>
|
||||
/// <returns>The name.</returns>
|
||||
private string GetExtraName(ExtraCandidate candidate, VideoFileInfo ownerVideoInfo, Dictionary<ExtraType, int> typeCounters)
|
||||
{
|
||||
var isNamedAfterOwner = candidate.ExtraRule.RuleType switch
|
||||
{
|
||||
ExtraRuleType.Filename => true,
|
||||
ExtraRuleType.Suffix => string.Equals(candidate.Extra.Name, ownerVideoInfo.Name, StringComparison.OrdinalIgnoreCase),
|
||||
_ => false
|
||||
};
|
||||
|
||||
if (!isNamedAfterOwner)
|
||||
{
|
||||
return candidate.Extra.Name;
|
||||
}
|
||||
|
||||
typeCounters.TryGetValue(candidate.ExtraType, out var seen);
|
||||
typeCounters[candidate.ExtraType] = seen + 1;
|
||||
|
||||
var typeName = _localization.GetServerLocalizedString(GetExtraTypeNameKey(candidate.ExtraType));
|
||||
|
||||
return seen == 0
|
||||
? typeName
|
||||
: string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
_localization.GetServerLocalizedString("NameExtraNumbered"),
|
||||
typeName,
|
||||
seen + 1);
|
||||
}
|
||||
|
||||
private static string GetExtraTypeNameKey(ExtraType extraType) => extraType switch
|
||||
{
|
||||
ExtraType.Clip => "NameExtraClip",
|
||||
ExtraType.Trailer => "NameExtraTrailer",
|
||||
ExtraType.BehindTheScenes => "NameExtraBehindTheScenes",
|
||||
ExtraType.DeletedScene => "NameExtraDeletedScene",
|
||||
ExtraType.Interview => "NameExtraInterview",
|
||||
ExtraType.Scene => "NameExtraScene",
|
||||
ExtraType.Sample => "NameExtraSample",
|
||||
ExtraType.ThemeSong => "NameExtraThemeSong",
|
||||
ExtraType.ThemeVideo => "NameExtraThemeVideo",
|
||||
ExtraType.Featurette => "NameExtraFeaturette",
|
||||
ExtraType.Short => "NameExtraShort",
|
||||
_ => "NameExtraUnknown"
|
||||
};
|
||||
|
||||
public string GetPathAfterNetworkSubstitution(string path, BaseItem? ownerItem)
|
||||
{
|
||||
foreach (var map in _configurationManager.Configuration.PathSubstitutions)
|
||||
@@ -3424,6 +3556,12 @@ namespace Emby.Server.Implementations.Library
|
||||
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();
|
||||
@@ -3898,5 +4036,7 @@ namespace Emby.Server.Implementations.Library
|
||||
SetTopParentOrAncestorIds(query);
|
||||
return _itemRepository.GetMediaStreamLanguages(query, mediaStreamType);
|
||||
}
|
||||
|
||||
private sealed record ExtraCandidate(BaseItem Extra, ExtraType ExtraType, ExtraRule ExtraRule, bool IsInMixedFolder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,8 +32,8 @@ namespace Emby.Server.Implementations.Library.Resolvers
|
||||
: base(logger, namingOptions, directoryService)
|
||||
{
|
||||
_namingOptions = namingOptions;
|
||||
_trailerResolvers = new IItemResolver[] { new GenericVideoResolver<Trailer>(logger, namingOptions, directoryService) };
|
||||
_videoResolvers = new IItemResolver[] { this };
|
||||
_trailerResolvers = [new GenericVideoResolver<Trailer>(logger, namingOptions, directoryService, parseName: true)];
|
||||
_videoResolvers = [this];
|
||||
}
|
||||
|
||||
protected override Video Resolve(ItemResolveArgs args)
|
||||
@@ -54,12 +54,13 @@ namespace Emby.Server.Implementations.Library.Resolvers
|
||||
_ => _videoResolvers
|
||||
};
|
||||
|
||||
public bool TryGetExtraTypeForOwner(string path, VideoFileInfo ownerVideoFileInfo, [NotNullWhen(true)] out ExtraType? extraType, string? libraryRoot = "")
|
||||
public bool TryGetExtraTypeForOwner(string path, VideoFileInfo ownerVideoFileInfo, [NotNullWhen(true)] out ExtraType? extraType, [NotNullWhen(true)] out ExtraRule? extraRule, string? libraryRoot = "")
|
||||
{
|
||||
var extraResult = GetExtraInfo(path, _namingOptions, libraryRoot);
|
||||
if (extraResult.ExtraType is null)
|
||||
if (extraResult.ExtraType is null || extraResult.Rule is null)
|
||||
{
|
||||
extraType = null;
|
||||
extraRule = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -88,6 +89,7 @@ namespace Emby.Server.Implementations.Library.Resolvers
|
||||
}
|
||||
|
||||
extraType = extraResult.ExtraType;
|
||||
extraRule = extraResult.Rule;
|
||||
return isValid;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
using Emby.Naming.Common;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -14,15 +15,25 @@ namespace Emby.Server.Implementations.Library.Resolvers
|
||||
public class GenericVideoResolver<T> : BaseVideoResolver<T>
|
||||
where T : Video, new()
|
||||
{
|
||||
private readonly bool _parseName;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="GenericVideoResolver{T}"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="namingOptions">The naming options.</param>
|
||||
/// <param name="directoryService">The directory service.</param>
|
||||
public GenericVideoResolver(ILogger logger, NamingOptions namingOptions, IDirectoryService directoryService)
|
||||
/// <param name="parseName">Whether to parse the file name for metadata such as the year.</param>
|
||||
public GenericVideoResolver(ILogger logger, NamingOptions namingOptions, IDirectoryService directoryService, bool parseName = false)
|
||||
: base(logger, namingOptions, directoryService)
|
||||
{
|
||||
_parseName = parseName;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override T Resolve(ItemResolveArgs args)
|
||||
{
|
||||
return ResolveVideo<T>(args, _parseName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Emby.Server.Implementations.Playlists;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Library;
|
||||
@@ -46,6 +47,19 @@ namespace Emby.Server.Implementations.Library.Resolvers
|
||||
};
|
||||
}
|
||||
|
||||
// Anything directly inside the internal playlists folder is a playlist, even when its
|
||||
// playlist.xml is missing: failing to resolve here makes the library scan treat the
|
||||
// playlist as deleted from disk and remove it, taking its items with it.
|
||||
if (args.Parent is PlaylistsFolder)
|
||||
{
|
||||
return new Playlist
|
||||
{
|
||||
Path = args.Path,
|
||||
Name = filename,
|
||||
OpenAccess = true
|
||||
};
|
||||
}
|
||||
|
||||
// It's a directory-based playlist if the directory contains a playlist file
|
||||
IEnumerable<string> filePaths;
|
||||
try
|
||||
|
||||
@@ -92,49 +92,46 @@ 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;
|
||||
}
|
||||
|
||||
var internalResults = await internalTask.ConfigureAwait(false);
|
||||
if (_internalProviders.Length > 0)
|
||||
{
|
||||
_logger.LogDebug("No results from external providers, using internal provider results");
|
||||
}
|
||||
|
||||
return internalResults;
|
||||
}
|
||||
|
||||
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)];
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -183,6 +183,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 +200,7 @@ public class SimilarItemsManager : ISimilarItemsManager
|
||||
remaining -= resolvedItems.Count;
|
||||
pendingBatch.Clear();
|
||||
|
||||
if (remaining <= 0)
|
||||
if (remaining <= 0 || collectedReferences.Count >= MaxRemoteReferenceFetchLimit)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -352,6 +352,7 @@ namespace Emby.Server.Implementations.Library
|
||||
/// <inheritdoc />
|
||||
public UserItemData? GetUserData(User user, BaseItem item)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
var row = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id)));
|
||||
return row is not null ? Map(row) : new UserItemData()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Emby.Server.Implementations.Library.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// Ensures top-level library folders have a primary poster after scans.
|
||||
/// Poster extraction is attempted before library scanning. When a library is
|
||||
/// empty at that point, no poster can be extracted. This post-scan task reruns
|
||||
/// metadata extraction for top-level folders that are still missing images.
|
||||
/// </summary>
|
||||
public class CollectionPosterVerifyPostScanTask : ILibraryPostScanTask
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ILogger<CollectionPosterVerifyPostScanTask> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CollectionPosterVerifyPostScanTask" /> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public CollectionPosterVerifyPostScanTask(
|
||||
ILibraryManager libraryManager,
|
||||
ILogger<CollectionPosterVerifyPostScanTask> logger)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the specified progress.
|
||||
/// </summary>
|
||||
/// <param name="progress">The progress.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>Task.</returns>
|
||||
public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
var libraries = _libraryManager.GetUserRootFolder().Children.OfType<CollectionFolder>().ToList();
|
||||
var totalLibraries = libraries.Count;
|
||||
var processedLibraries = 0;
|
||||
|
||||
foreach (var library in libraries)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!library.HasImage(ImageType.Primary))
|
||||
{
|
||||
_logger.LogDebug("Library {LibraryName} is missing a primary image. Refreshing metadata.", library.Name);
|
||||
await library.RefreshMetadata(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
processedLibraries++;
|
||||
progress.Report((double)processedLibraries / totalLibraries * 100);
|
||||
}
|
||||
|
||||
progress.Report(100);
|
||||
}
|
||||
}
|
||||
@@ -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": "إضافي"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"CleanupUserDataTaskDescription": "Neteja totes les dades d'usuari (estat de la visualització, estat dels preferits, etc.) del contingut multimèdia que no ha estat present durant almenys 90 dies.",
|
||||
"CleanupUserDataTask": "Tasca de neteja de dades d'usuari",
|
||||
"Original": "Original",
|
||||
"LyricDownloadFailureFromForItem": "No s'han pogut descarregar les lletres des de {0} per a {1}"
|
||||
"LyricDownloadFailureFromForItem": "No s'han pogut descarregar les lletres des de {0} per a {1}",
|
||||
"NameExtraBehindTheScenes": "Rere les càmeres",
|
||||
"NameExtraClip": "Tall",
|
||||
"NameExtraDeletedScene": "Escena eliminada",
|
||||
"NameExtraFeaturette": "Migmetratge",
|
||||
"NameExtraInterview": "Entrevista",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Mostra",
|
||||
"NameExtraScene": "Escena",
|
||||
"NameExtraShort": "Curt",
|
||||
"NameExtraThemeSong": "Tema musical",
|
||||
"NameExtraThemeVideo": "Vídeo temàtic",
|
||||
"NameExtraTrailer": "Tràiler",
|
||||
"NameExtraUnknown": "Extra"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Tema Sang",
|
||||
"NameExtraThemeVideo": "Tema Video",
|
||||
"NameExtraTrailer": "Trailer",
|
||||
"NameExtraUnknown": "Ekstra"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -28,6 +28,19 @@
|
||||
"Movies": "Movies",
|
||||
"Music": "Music",
|
||||
"MusicVideos": "Music Videos",
|
||||
"NameExtraBehindTheScenes": "Behind The Scenes",
|
||||
"NameExtraClip": "Clip",
|
||||
"NameExtraDeletedScene": "Deleted Scene",
|
||||
"NameExtraFeaturette": "Featurette",
|
||||
"NameExtraInterview": "Interview",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Sample",
|
||||
"NameExtraScene": "Scene",
|
||||
"NameExtraShort": "Short",
|
||||
"NameExtraThemeSong": "Theme Song",
|
||||
"NameExtraThemeVideo": "Theme Video",
|
||||
"NameExtraTrailer": "Trailer",
|
||||
"NameExtraUnknown": "Extra",
|
||||
"NameInstallFailed": "{0} installation failed",
|
||||
"NameSeasonNumber": "Season {0}",
|
||||
"NameSeasonUnknown": "Season Unknown",
|
||||
|
||||
@@ -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,11 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"Artists": "Listafólk",
|
||||
"Artists": "Tónlistafólk",
|
||||
"Collections": "Søvn",
|
||||
"Default": "Forsett",
|
||||
"External": "Ytri",
|
||||
@@ -29,9 +29,9 @@
|
||||
"NotificationOptionPluginUpdateInstalled": "Ískoytisforrit dagført",
|
||||
"NotificationOptionUserLockedOut": "Brúkari útihýstur",
|
||||
"Photos": "Ljósmyndir",
|
||||
"PluginInstalledWithName": "{0} var innlagt",
|
||||
"PluginUninstalledWithName": "{0} var strikað",
|
||||
"PluginUpdatedWithName": "{0} varð dagført",
|
||||
"PluginInstalledWithName": "{0} innlagt",
|
||||
"PluginUninstalledWithName": "{0} strikað",
|
||||
"PluginUpdatedWithName": "{0} dagført",
|
||||
"Shows": "Røðir",
|
||||
"SubtitleDownloadFailureFromForItem": "Miseydnaðist at niðurtakað undirtekstir til {1} frá {0}",
|
||||
"TvShows": "Sjónvarpsrøðir",
|
||||
@@ -48,5 +48,68 @@
|
||||
"TaskDownloadMissingLyricsDescription": "Niðurtekur sangtekstir",
|
||||
"Movies": "Filmar",
|
||||
"MixedContent": "Blandað innihald",
|
||||
"Music": "Tónleikur"
|
||||
"Music": "Tónleikur",
|
||||
"UserStartedPlayingItemWithValues": "{0} spælur {1} á {2}",
|
||||
"HeaderContinueWatching": "Hald áfram at hyggja",
|
||||
"MusicVideos": "Sjónbandaløg",
|
||||
"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",
|
||||
"ScheduledTaskFailedWithName": "{0} miseydnaðist",
|
||||
"Undefined": "Óskilmarkað",
|
||||
"TasksMaintenanceCategory": "Viðlíkahald",
|
||||
"TaskCleanLogs": "Reinsa gerðalistaskjáttu",
|
||||
"UserOnlineFromDevice": "{0} er íbundin frá {1}",
|
||||
"HeaderNextUp": "Næst á skránni",
|
||||
"NotificationOptionPluginError": "Brek í ískoytisforriti",
|
||||
"NotificationOptionInstallationFailed": "Innleggingarbrek",
|
||||
"NotificationOptionServerRestartRequired": "Tørvur er á ambætaraendurbyrjan",
|
||||
"TasksApplicationCategory": "Nýtsluskipan",
|
||||
"NotificationOptionApplicationUpdateAvailable": "Skipanardagføring er tøk",
|
||||
"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.",
|
||||
"UserOfflineFromDevice": "{0} breyt av á {1}",
|
||||
"UserPasswordChangedWithName": "Loyniorðið hjá brúkaranum {0} er broytt",
|
||||
"TasksChannelsCategory": "Alnetsrásir",
|
||||
"TaskCleanActivityLog": "Reinsa virksemisskrá",
|
||||
"TaskCleanActivityLogDescription": "Strikar skrásetingar eldri enn ásetta aldur.",
|
||||
"TaskCleanCache": "Reinsa kovaskjáttu",
|
||||
"TaskCleanCacheDescription": "Strikar kovafílar ið kervið ikki hevur tørv á longur.",
|
||||
"TaskCleanTranscode": "Reinsa umkotuskjáttu",
|
||||
"TaskDownloadMissingLyrics": "Niðurtak vantandi sangtekstir",
|
||||
"TaskDownloadMissingSubtitles": "Niðurtak vantandi undirtekstir",
|
||||
"CleanupUserDataTaskDescription": "Strikar allar brúkaradátur, so sum spælistøðu, yndislistastøðu o.s.fr., fyri miðlar ið ikki hava verið tøkir í í minsta lagi 90 dagar.",
|
||||
"CleanupUserDataTask": "Koyrsla ið reinsar brúkaradátur",
|
||||
"TaskRefreshPeople": "Dagfør persónsupplýsingar",
|
||||
"TaskRefreshPeopleDescription": "Dagførur metadátur um leikarar og leikstjórar í tínum margmiðlasavni.",
|
||||
"TaskRefreshChannelsDescription": "Dagførur upplýsingar um alnetsrásir.",
|
||||
"TaskDownloadMissingSubtitlesDescription": "Leitar á alnótini eftir vantandi undirtekstum grundað á metadátauppsetan.",
|
||||
"NotificationOptionTaskFailed": "Brek undir fyriskipaðari koyrslu",
|
||||
"TaskRefreshLibraryDescription": "Skannar títt miðlasavn fyri nýggjum fílum og dagførur metadátur.",
|
||||
"TaskKeyframeExtractor": "Lyklamyndaúttøka",
|
||||
"TaskKeyframeExtractorDescription": "Úttekur lyklamyndir frá kykmynda-fílum til tess at byggja nágreiniligari HLS-spælilistar. Koyrslan kann taka langa tíð.",
|
||||
"TaskOptimizeDatabaseDescription": "Trýstur dátugrunninin saman og loysur tóma goymslu. Koyrslan kann bøta um avrikið, eftir skanning ella aðrar broytingar í savninum ið elva til dátugrunnsbroytingar.",
|
||||
"TaskRefreshChapterImagesDescription": "Ger smámyndir fyri kykmyndir ið hava kapitlar.",
|
||||
"TaskRefreshChapterImages": "Kapitlamyndaúttøkur",
|
||||
"NotificationOptionVideoPlayback": "Kykmyndaspæl byrjað",
|
||||
"NotificationOptionVideoPlaybackStopped": "Kykmyndaspæl steðgað",
|
||||
"NotificationOptionAudioPlayback": "Ljóðspæl byrjað",
|
||||
"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 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."
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -105,5 +105,6 @@
|
||||
"TaskExtractMediaSegmentsDescription": "मीडियासेगमेंट सक्षम प्लगइन्स से मीडिया सेगमेंट निकालता है या प्राप्त करता है।",
|
||||
"TaskMoveTrickplayImages": "ट्रिकप्ले छवि स्थान माइग्रेट करें",
|
||||
"TaskMoveTrickplayImagesDescription": "लाइब्रेरी सेटिंग्स के अनुसार मौजूदा ट्रिकप्ले फ़ाइलों को स्थानांतरित करता है।",
|
||||
"CleanupUserDataTask": "यूज़र डेटा सफाई कार्य"
|
||||
"CleanupUserDataTask": "यूज़र डेटा सफाई कार्य",
|
||||
"Original": "असली"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,17 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
"UserDownloadingItemWithValues": "{0} hleður niður {1}",
|
||||
"SubtitleDownloadFailureFromForItem": "Tókst ekki að hala niður skjátextum frá {0} til {1}",
|
||||
"Shows": "Þættir",
|
||||
"TaskRefreshChannelsDescription": "Endurhlaða upplýsingum netrása.",
|
||||
"TaskRefreshChannelsDescription": "Endurhleður upplýsingum netrása.",
|
||||
"TaskRefreshChannels": "Endurhlaða Rásir",
|
||||
"TaskCleanTranscodeDescription": "Eyða umkóðuðum skrám sem eru meira en einum degi eldri.",
|
||||
"TaskCleanTranscode": "Hreinsa Umkóðunarmöppu",
|
||||
@@ -80,7 +80,7 @@
|
||||
"TasksMaintenanceCategory": "Viðhald",
|
||||
"Default": "Sjálfgefið",
|
||||
"TaskCleanActivityLog": "Hreinsa athafnaskrá",
|
||||
"TaskRefreshPeople": "Endurnýja fólk",
|
||||
"TaskRefreshPeople": "Endurnýja upplýsingar um fólk",
|
||||
"TaskDownloadMissingSubtitles": "Sækja texta sem vantar",
|
||||
"TaskOptimizeDatabase": "Fínstilla gagnagrunn",
|
||||
"Undefined": "Óskilgreint",
|
||||
@@ -95,8 +95,8 @@
|
||||
"TaskCleanActivityLogDescription": "Eyðir virkniskráningarfærslum sem hafa náð settum hámarksaldri.",
|
||||
"Forced": "Þvingað",
|
||||
"External": "Útvær",
|
||||
"TaskRefreshTrickplayImagesDescription": "Býr til hraðspilunarmyndir fyrir myndbönd í virkum söfnum.",
|
||||
"TaskRefreshTrickplayImages": "Búa til hraðspilunarmyndir",
|
||||
"TaskRefreshTrickplayImagesDescription": "Býr til hraðspilunarmyndir (Trickplay) fyrir myndbönd í virkum söfnum.",
|
||||
"TaskRefreshTrickplayImages": "Búa til hraðspilunarmyndir (Trickplay)",
|
||||
"TaskAudioNormalization": "Hljóðstöðlun",
|
||||
"TaskAudioNormalizationDescription": "Leitar að hljóðstöðlunargögnum í skrám.",
|
||||
"TaskDownloadMissingLyricsDescription": "Sækja söngtexta fyrir lög",
|
||||
@@ -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,15 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,8 @@
|
||||
"CleanupUserDataTask": "사용자 데이터 정리 작업",
|
||||
"CleanupUserDataTaskDescription": "최소 90일 이상 존재하지 않는 미디어에 대한 사용자 데이터(시청 상태, 즐겨찾기 등)를 정리합니다.",
|
||||
"LyricDownloadFailureFromForItem": "{1}에 대한 가사를 {0}에서 다운로드하지 못했습니다",
|
||||
"Original": "원본"
|
||||
"Original": "원본",
|
||||
"NameExtraClip": "클립",
|
||||
"NameExtraDeletedScene": "삭제된 장면",
|
||||
"NameExtraInterview": "인터뷰"
|
||||
}
|
||||
|
||||
@@ -104,5 +104,9 @@
|
||||
"TaskKeyframeExtractorDescription": "Extrahéiert Schlësselbiller aus Videodateien, fir méi präzis HLS-Playlisten ze erstellen. Dës Aufgab kann eng längere Zäit daueren.",
|
||||
"TaskRefreshChannelsDescription": "Aktualiséiert Informatiounen iwwer Internetkanäl.",
|
||||
"TaskExtractMediaSegmentsDescription": "Extrahéiert oder kritt Mediesegmenter aus Plugins, déi MediaSegment ënnerstëtzen.",
|
||||
"TaskOptimizeDatabaseDescription": "Kompriméiert d’Datebank a schneit de fräie Speicherplatz zou. Dës Aufgab no engem Bibliothéik-Scan oder anere Ännerungen, déi Datebankmodifikatioune mat sech bréngen, auszeféieren, kann d’Performance verbesseren."
|
||||
"TaskOptimizeDatabaseDescription": "Kompriméiert d’Datebank a schneit de fräie Speicherplatz zou. Dës Aufgab no engem Bibliothéik-Scan oder anere Ännerungen, déi Datebankmodifikatioune mat sech bréngen, auszeféieren, kann d’Performance verbesseren.",
|
||||
"LyricDownloadFailureFromForItem": "Feeler beim Download vun de Songtexter vun {0} fir {1}",
|
||||
"Original": "Original",
|
||||
"CleanupUserDataTask": "Aufgab fir Berengege vu Benotzerdaten",
|
||||
"CleanupUserDataTaskDescription": "Läscht all Benotzerdaten (Ofspillstatus, Favoritestatus, asw.) vu Medien, déi zënter mindestens 90 Deeg net méi besteeënd sinn."
|
||||
}
|
||||
|
||||
@@ -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}",
|
||||
@@ -65,8 +65,8 @@
|
||||
"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",
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,18 @@
|
||||
"CleanupUserDataTask": "Opruimtaak gebruikersdata",
|
||||
"Genres": "Genres",
|
||||
"Original": "Oorspronkelijk",
|
||||
"LyricDownloadFailureFromForItem": "Downloaden van liedteksten voor {1} van {0} mislukt"
|
||||
"LyricDownloadFailureFromForItem": "Downloaden van liedteksten voor {1} van {0} mislukt",
|
||||
"NameExtraBehindTheScenes": "Achter de schermen",
|
||||
"NameExtraClip": "Clip",
|
||||
"NameExtraDeletedScene": "Geschrapte scène",
|
||||
"NameExtraFeaturette": "Featurette",
|
||||
"NameExtraInterview": "Vraaggesprek",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraSample": "Voorbeeldfragment",
|
||||
"NameExtraScene": "Scène",
|
||||
"NameExtraShort": "Korte film",
|
||||
"NameExtraThemeSong": "Themamuziek",
|
||||
"NameExtraThemeVideo": "Themavideo",
|
||||
"NameExtraTrailer": "Trailer",
|
||||
"NameExtraUnknown": "Extra inhoud"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,27 @@
|
||||
{
|
||||
"AppDeviceValues": "Aplicacion: {0}, Periferic: {1}"
|
||||
"AppDeviceValues": "Aplicacion: {0}, Periferic: {1}",
|
||||
"Books": "Libres",
|
||||
"Artists": "Artistas",
|
||||
"Collections": "Collecciones",
|
||||
"ChapterNameValue": "Capitol {0}",
|
||||
"External": "Extèrn",
|
||||
"Folders": "Dorsièrs",
|
||||
"Favorites": "Favorits",
|
||||
"HeaderContinueWatching": "Contunhar de regardar",
|
||||
"HeaderFavoriteEpisodes": "Episòdis Favorits",
|
||||
"AuthenticationSucceededWithUserName": "{0} autentificat amb succès",
|
||||
"HeaderFavoriteShows": "Serias Favoritas",
|
||||
"HeaderLiveTV": "TV en dirècte",
|
||||
"HeaderNextUp": "Seguent",
|
||||
"HearingImpaired": "Amb de deficiéncias auditivas",
|
||||
"Movies": "Filmes",
|
||||
"Music": "Musica",
|
||||
"Latest": "Darrièr",
|
||||
"Forced": "Forçat",
|
||||
"Default": "Defaut",
|
||||
"Genres": "Genres",
|
||||
"HomeVideos": "Vidèos d'Acuèlh",
|
||||
"Inherit": "Eiretar",
|
||||
"LabelIpAddressValue": "Adreça IP: {0}",
|
||||
"LabelRunningTimeValue": "Temps d'execucion : {0}"
|
||||
}
|
||||
|
||||
@@ -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,17 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -95,18 +95,30 @@
|
||||
"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.",
|
||||
"TaskRefreshTrickplayImages": "Gerar imagens de trickplay",
|
||||
"TaskRefreshTrickplayImagesDescription": "Cria pré-visualizações de trickplay para vídeos nas bibliotecas ativadas.",
|
||||
"TaskRefreshTrickplayImages": "Gerar imagens de Trickplay",
|
||||
"TaskRefreshTrickplayImagesDescription": "Cria miniaturas de pré-visualização (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.",
|
||||
"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",
|
||||
"TaskMoveTrickplayImages": "Migrar a localização das imagens de 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.",
|
||||
"Original": "Original",
|
||||
"LyricDownloadFailureFromForItem": "Erro ao descarregar letras de {0} para {1}"
|
||||
"LyricDownloadFailureFromForItem": "Erro ao descarregar letras de {0} para {1}",
|
||||
"NameExtraBehindTheScenes": "Bastidores",
|
||||
"NameExtraClip": "Clip",
|
||||
"NameExtraDeletedScene": "Cena Eliminada",
|
||||
"NameExtraFeaturette": "Média-metragem",
|
||||
"NameExtraInterview": "Entrevista",
|
||||
"NameExtraSample": "Amostra",
|
||||
"NameExtraShort": "Curta-metragem",
|
||||
"NameExtraThemeSong": "Tema Principal",
|
||||
"NameExtraThemeVideo": "Vídeo de Abertura",
|
||||
"NameExtraScene": "Cena",
|
||||
"NameExtraUnknown": "Extra",
|
||||
"NameExtraTrailer": "Trailer"
|
||||
}
|
||||
|
||||
@@ -108,5 +108,16 @@
|
||||
"CleanupUserDataTask": "Задача очистки пользовательских данных",
|
||||
"CleanupUserDataTaskDescription": "Очищает все пользовательские данные (состояние просмотра, статус избранного и т.д.) с медиа, отсутствующих по меньшей мере в течение 90 дней.",
|
||||
"Original": "Оригинальный",
|
||||
"LyricDownloadFailureFromForItem": "Не получилось скачать текст песни с {0} для {1}"
|
||||
"LyricDownloadFailureFromForItem": "Не получилось скачать текст песни с {0} для {1}",
|
||||
"NameExtraBehindTheScenes": "За кулисами",
|
||||
"NameExtraClip": "Отрывок",
|
||||
"NameExtraDeletedScene": "Удалённая сцена",
|
||||
"NameExtraFeaturette": "Короткометражка",
|
||||
"NameExtraInterview": "Интервью",
|
||||
"NameExtraSample": "Образец",
|
||||
"NameExtraScene": "Сцена",
|
||||
"NameExtraThemeSong": "Заглавная песня",
|
||||
"NameExtraThemeVideo": "Заглавное видео",
|
||||
"NameExtraTrailer": "Трейлер",
|
||||
"NameExtraUnknown": "Дополнительный материал"
|
||||
}
|
||||
|
||||
@@ -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,13 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -106,5 +106,7 @@
|
||||
"TaskExtractMediaSegmentsDescription": "แยกหรือดึงส่วนของสื่อจากปลั๊กอินที่เปิดใช้งาน MediaSegment",
|
||||
"TaskMoveTrickplayImages": "ย้ายตำแหน่งเก็บภาพตัวอย่าง Trickplay",
|
||||
"CleanupUserDataTask": "ส่วนงานล้างข้อมูลผู้ใช้",
|
||||
"CleanupUserDataTaskDescription": "ล้างข้อมูลผู้ใช้ทั้งหมด (สถานะการรับชม สถานะรายการโปรด ฯลฯ) จากสื่อที่ไม่ได้ใช้งานแล้วอย่างน้อย 90 วัน"
|
||||
"CleanupUserDataTaskDescription": "ล้างข้อมูลผู้ใช้ทั้งหมด (สถานะการรับชม สถานะรายการโปรด ฯลฯ) จากสื่อที่ไม่ได้ใช้งานแล้วอย่างน้อย 90 วัน",
|
||||
"LyricDownloadFailureFromForItem": "ไม่สามารถดาวน์โหลดเนื้อเพลงจาก {0} สำหรับ {1}",
|
||||
"Original": "ต้นฉบับ"
|
||||
}
|
||||
|
||||
@@ -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": "额外"
|
||||
}
|
||||
|
||||
@@ -219,28 +219,15 @@ namespace Emby.Server.Implementations.Playlists
|
||||
var playlist = _libraryManager.GetItemById(playlistId) as Playlist
|
||||
?? throw new ArgumentException("No Playlist exists with Id " + playlistId);
|
||||
|
||||
// Retrieve all the items to be added to the playlist
|
||||
// Retrieve all the items to be added to the playlist.
|
||||
var newItems = GetPlaylistItems(newItemIds, user, options)
|
||||
.Where(i => i.SupportsAddingToPlaylist);
|
||||
|
||||
// Filter out duplicate items
|
||||
var existingIds = playlist.LinkedChildren.Select(c => c.ItemId).ToHashSet();
|
||||
newItems = newItems
|
||||
.Where(i => !existingIds.Contains(i.Id))
|
||||
.Distinct();
|
||||
|
||||
// Create a list of the new linked children to add to the playlist
|
||||
var childrenToAdd = newItems
|
||||
.Select(LinkedChild.Create)
|
||||
.ToList();
|
||||
|
||||
// Log duplicates that have been ignored, if any
|
||||
int numDuplicates = newItemIds.Count - childrenToAdd.Count;
|
||||
if (numDuplicates > 0)
|
||||
{
|
||||
_logger.LogWarning("Ignored adding {DuplicateCount} duplicate items to playlist {PlaylistName}.", numDuplicates, playlist.Name);
|
||||
}
|
||||
|
||||
// Do nothing else if there are no items to add to the playlist
|
||||
if (childrenToAdd.Count == 0)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -641,8 +641,7 @@ namespace Emby.Server.Implementations.Session
|
||||
if (playingSessions.Count > 0)
|
||||
{
|
||||
var idle = playingSessions
|
||||
.Where(i => (DateTime.UtcNow - i.LastPlaybackCheckIn).TotalMinutes > 5)
|
||||
.ToList();
|
||||
.Where(i => (DateTime.UtcNow - i.LastPlaybackCheckIn).TotalMinutes > 5);
|
||||
|
||||
foreach (var session in idle)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -3,34 +3,14 @@
|
||||
|
||||
using System;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Sorting;
|
||||
using MediaBrowser.Model.Querying;
|
||||
|
||||
namespace Emby.Server.Implementations.Sorting
|
||||
{
|
||||
public class DateLastMediaAddedComparer : IUserBaseItemComparer
|
||||
public class DateLastMediaAddedComparer : IBaseItemComparer
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the user.
|
||||
/// </summary>
|
||||
/// <value>The user.</value>
|
||||
public User User { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the user manager.
|
||||
/// </summary>
|
||||
/// <value>The user manager.</value>
|
||||
public IUserManager UserManager { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the user data manager.
|
||||
/// </summary>
|
||||
/// <value>The user data manager.</value>
|
||||
public IUserDataManager UserDataManager { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name.
|
||||
/// </summary>
|
||||
|
||||
@@ -45,7 +45,7 @@ namespace Emby.Server.Implementations.Sorting
|
||||
return x.PremiereDate.Value;
|
||||
}
|
||||
|
||||
if (x.ProductionYear.HasValue)
|
||||
if (x.ProductionYear is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
@@ -39,7 +39,7 @@ namespace Emby.Server.Implementations.Sorting
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (x.ProductionYear.HasValue)
|
||||
if (x.ProductionYear is not null)
|
||||
{
|
||||
return x.ProductionYear.Value;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1456,22 +1456,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 +1499,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 +1537,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)
|
||||
@@ -1607,8 +1612,9 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
|
||||
if (state.VideoStream is not null && state.IsOutputVideo)
|
||||
{
|
||||
// fMP4 needs this flag to write the audio packet DTS/PTS including the initial delay into MOOF::TRAF::TFDT
|
||||
hlsArguments += $" {(useLegacySegmentOption ? "-hls_ts_options" : "-hls_segment_options")} movflags=+frag_discont";
|
||||
// fMP4 needs frag_discont to write the audio packet DTS/PTS including the initial delay into MOOF::TRAF::TFDT
|
||||
// HLS does not use SIDX, and skipping it avoids FFmpeg rewriting open-GOP boundary packet PTS
|
||||
hlsArguments += $" {(useLegacySegmentOption ? "-hls_ts_options" : "-hls_segment_options")} movflags=+frag_discont+skip_sidx";
|
||||
}
|
||||
|
||||
segmentFormat = "fmp4" + outputFmp4HeaderArg;
|
||||
|
||||
@@ -84,7 +84,7 @@ public class MediaInfoController : BaseJellyfinApiController
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return await _mediaInfoHelper.GetPlaybackInfo(item, user).ConfigureAwait(false);
|
||||
return await _mediaInfoHelper.GetPlaybackInfo(item, user, Request).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -177,6 +177,7 @@ public class MediaInfoController : BaseJellyfinApiController
|
||||
var info = await _mediaInfoHelper.GetPlaybackInfo(
|
||||
item,
|
||||
user,
|
||||
Request,
|
||||
mediaSourceId,
|
||||
liveStreamId)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -133,6 +133,7 @@ public class UniversalAudioController : BaseJellyfinApiController
|
||||
var info = await _mediaInfoHelper.GetPlaybackInfo(
|
||||
item,
|
||||
user,
|
||||
Request,
|
||||
mediaSourceId)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ public class UserLibraryController : BaseJellyfinApiController
|
||||
private readonly IDtoService _dtoService;
|
||||
private readonly IUserViewManager _userViewManager;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly IProviderManager _providerManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UserLibraryController"/> class.
|
||||
@@ -50,13 +51,15 @@ public class UserLibraryController : BaseJellyfinApiController
|
||||
/// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
|
||||
/// <param name="userViewManager">Instance of the <see cref="IUserViewManager"/> interface.</param>
|
||||
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
|
||||
/// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
|
||||
public UserLibraryController(
|
||||
IUserManager userManager,
|
||||
IUserDataManager userDataRepository,
|
||||
ILibraryManager libraryManager,
|
||||
IDtoService dtoService,
|
||||
IUserViewManager userViewManager,
|
||||
IFileSystem fileSystem)
|
||||
IFileSystem fileSystem,
|
||||
IProviderManager providerManager)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_userDataRepository = userDataRepository;
|
||||
@@ -64,6 +67,7 @@ public class UserLibraryController : BaseJellyfinApiController
|
||||
_dtoService = dtoService;
|
||||
_userViewManager = userViewManager;
|
||||
_fileSystem = fileSystem;
|
||||
_providerManager = providerManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -75,7 +79,7 @@ public class UserLibraryController : BaseJellyfinApiController
|
||||
/// <returns>An <see cref="OkResult"/> containing the item.</returns>
|
||||
[HttpGet("Items/{itemId}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BaseItemDto>> GetItem(
|
||||
public ActionResult<BaseItemDto> GetItem(
|
||||
[FromQuery] Guid? userId,
|
||||
[FromRoute, Required] Guid itemId)
|
||||
{
|
||||
@@ -94,7 +98,7 @@ public class UserLibraryController : BaseJellyfinApiController
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
await RefreshItemOnDemandIfNeeded(item).ConfigureAwait(false);
|
||||
QueueRefreshOnDemandIfNeeded(item);
|
||||
|
||||
var dtoOptions = new DtoOptions();
|
||||
|
||||
@@ -112,7 +116,7 @@ public class UserLibraryController : BaseJellyfinApiController
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[Obsolete("Kept for backwards compatibility")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public Task<ActionResult<BaseItemDto>> GetItemLegacy(
|
||||
public ActionResult<BaseItemDto> GetItemLegacy(
|
||||
[FromRoute, Required] Guid userId,
|
||||
[FromRoute, Required] Guid itemId)
|
||||
=> GetItem(userId, itemId);
|
||||
@@ -639,25 +643,28 @@ public class UserLibraryController : BaseJellyfinApiController
|
||||
limit,
|
||||
groupItems);
|
||||
|
||||
private async Task RefreshItemOnDemandIfNeeded(BaseItem item)
|
||||
private void QueueRefreshOnDemandIfNeeded(BaseItem item)
|
||||
{
|
||||
if (item is Person)
|
||||
if (item is not Person)
|
||||
{
|
||||
var hasMetadata = !string.IsNullOrWhiteSpace(item.Overview) && item.HasImage(ImageType.Primary);
|
||||
var performFullRefresh = !hasMetadata && (DateTime.UtcNow - item.DateLastRefreshed).TotalDays >= 3;
|
||||
|
||||
if (performFullRefresh)
|
||||
{
|
||||
var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
|
||||
{
|
||||
MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
|
||||
ImageRefreshMode = MetadataRefreshMode.FullRefresh,
|
||||
ForceSave = true
|
||||
};
|
||||
|
||||
await item.RefreshMetadata(options, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var hasMetadata = !string.IsNullOrWhiteSpace(item.Overview) && item.HasImage(ImageType.Primary);
|
||||
if (hasMetadata || (DateTime.UtcNow - item.DateLastRefreshed).TotalDays < 3)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_providerManager.QueueRefresh(
|
||||
item.Id,
|
||||
new MetadataRefreshOptions(new DirectoryService(_fileSystem))
|
||||
{
|
||||
MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
|
||||
ImageRefreshMode = MetadataRefreshMode.FullRefresh,
|
||||
ForceSave = true
|
||||
},
|
||||
RefreshPriority.High);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -14,6 +14,7 @@ using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Devices;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
@@ -44,6 +45,7 @@ public class MediaInfoHelper
|
||||
private readonly ILogger<MediaInfoHelper> _logger;
|
||||
private readonly INetworkManager _networkManager;
|
||||
private readonly IDeviceManager _deviceManager;
|
||||
private readonly IServerApplicationHost _appHost;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MediaInfoHelper"/> class.
|
||||
@@ -56,6 +58,7 @@ public class MediaInfoHelper
|
||||
/// <param name="logger">Instance of the <see cref="ILogger{MediaInfoHelper}"/> interface.</param>
|
||||
/// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
|
||||
/// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
|
||||
/// <param name="appHost">Instance of the <see cref="IServerApplicationHost"/> interface.</param>
|
||||
public MediaInfoHelper(
|
||||
IUserManager userManager,
|
||||
ILibraryManager libraryManager,
|
||||
@@ -64,7 +67,8 @@ public class MediaInfoHelper
|
||||
IServerConfigurationManager serverConfigurationManager,
|
||||
ILogger<MediaInfoHelper> logger,
|
||||
INetworkManager networkManager,
|
||||
IDeviceManager deviceManager)
|
||||
IDeviceManager deviceManager,
|
||||
IServerApplicationHost appHost)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_libraryManager = libraryManager;
|
||||
@@ -74,6 +78,7 @@ public class MediaInfoHelper
|
||||
_logger = logger;
|
||||
_networkManager = networkManager;
|
||||
_deviceManager = deviceManager;
|
||||
_appHost = appHost;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -81,40 +86,20 @@ public class MediaInfoHelper
|
||||
/// </summary>
|
||||
/// <param name="item">The item.</param>
|
||||
/// <param name="user">The user.</param>
|
||||
/// <param name="request">The current <see cref="HttpRequest"/>.</param>
|
||||
/// <param name="mediaSourceId">Media source id.</param>
|
||||
/// <param name="liveStreamId">Live stream id.</param>
|
||||
/// <returns>A <see cref="Task"/> containing the <see cref="PlaybackInfoResponse"/>.</returns>
|
||||
public async Task<PlaybackInfoResponse> GetPlaybackInfo(
|
||||
BaseItem item,
|
||||
User? user,
|
||||
HttpRequest request,
|
||||
string? mediaSourceId = null,
|
||||
string? liveStreamId = null)
|
||||
{
|
||||
var result = new PlaybackInfoResponse();
|
||||
|
||||
MediaSourceInfo[] mediaSources;
|
||||
if (string.IsNullOrWhiteSpace(liveStreamId))
|
||||
{
|
||||
// TODO (moved from MediaBrowser.Api) handle supportedLiveMediaTypes?
|
||||
var mediaSourcesList = await _mediaSourceManager.GetPlaybackMediaSources(item, user, true, true, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(mediaSourceId))
|
||||
{
|
||||
mediaSources = mediaSourcesList.ToArray();
|
||||
}
|
||||
else
|
||||
{
|
||||
mediaSources = mediaSourcesList
|
||||
.Where(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var mediaSource = await _mediaSourceManager.GetLiveStream(liveStreamId, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
mediaSources = new[] { mediaSource };
|
||||
}
|
||||
var mediaSources = await ResolvePlaybackMediaSources(item, user, mediaSourceId, liveStreamId).ConfigureAwait(false);
|
||||
|
||||
if (mediaSources.Length == 0)
|
||||
{
|
||||
@@ -136,6 +121,11 @@ public class MediaInfoHelper
|
||||
mediaSourcesClone[i].DefaultAudioIndexSource = mediaSources[i].DefaultAudioIndexSource;
|
||||
}
|
||||
|
||||
foreach (var mediaSource in mediaSourcesClone)
|
||||
{
|
||||
RewritePublishedLiveStreamPath(mediaSource, request);
|
||||
}
|
||||
|
||||
result.MediaSources = mediaSourcesClone;
|
||||
}
|
||||
|
||||
@@ -145,6 +135,28 @@ public class MediaInfoHelper
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<MediaSourceInfo[]> ResolvePlaybackMediaSources(BaseItem item, User? user, string? mediaSourceId, string? liveStreamId)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(liveStreamId))
|
||||
{
|
||||
var mediaSource = await _mediaSourceManager.GetLiveStream(liveStreamId, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
return new[] { mediaSource };
|
||||
}
|
||||
|
||||
// TODO (moved from MediaBrowser.Api) handle supportedLiveMediaTypes?
|
||||
var mediaSourcesList = await _mediaSourceManager.GetPlaybackMediaSources(item, user, true, true, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(mediaSourceId))
|
||||
{
|
||||
return mediaSourcesList.ToArray();
|
||||
}
|
||||
|
||||
return mediaSourcesList
|
||||
.Where(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SetDeviceSpecificData.
|
||||
/// </summary>
|
||||
@@ -415,6 +427,8 @@ public class MediaInfoHelper
|
||||
{
|
||||
var result = await _mediaSourceManager.OpenLiveStream(request, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
RewritePublishedLiveStreamPath(result.MediaSource, httpContext.Request);
|
||||
|
||||
var profile = request.DeviceProfile;
|
||||
if (profile is null)
|
||||
{
|
||||
@@ -524,4 +538,90 @@ public class MediaInfoHelper
|
||||
|
||||
return maxBitrate;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rewrites a Live TV media source's <see cref="MediaSourceInfo.Path"/> to the request-appropriate published
|
||||
/// URL when it points at a Jellyfin-hosted live stream buffer, so response copies never leak server-local
|
||||
/// addresses. Only opened live streams are eligible. The shared instance held by
|
||||
/// <see cref="IMediaSourceManager"/> is never touched by this method.
|
||||
/// </summary>
|
||||
/// <param name="mediaSource">The media source clone to rewrite in place.</param>
|
||||
/// <param name="request">The current <see cref="HttpRequest"/>.</param>
|
||||
private void RewritePublishedLiveStreamPath(MediaSourceInfo mediaSource, HttpRequest request)
|
||||
{
|
||||
// Opened live streams always carry a LiveStreamId; this excludes pre-open and plugin/remote sources.
|
||||
if (string.IsNullOrEmpty(mediaSource.LiveStreamId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var baseUrl = _serverConfigurationManager.GetNetworkConfiguration().BaseUrl;
|
||||
var publishedPath = GetPublishedLiveStreamPath(_appHost.GetSmartApiUrl(request), mediaSource.Path, mediaSource.Protocol, baseUrl);
|
||||
|
||||
if (publishedPath is not null)
|
||||
{
|
||||
mediaSource.Path = publishedPath;
|
||||
return;
|
||||
}
|
||||
|
||||
if (mediaSource.Path is not null && mediaSource.Path.Contains("/LiveTv/LiveStreamFiles/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogDebug("Not rewriting live stream path for media source {MediaSourceId}: the local path did not resolve under the request's smart API URL/BaseUrl", mediaSource.Id);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a Jellyfin-hosted Live TV buffer path to its request-appropriate published equivalent.
|
||||
/// Returns null when the path isn't a Jellyfin-hosted <c>/LiveTv/LiveStreamFiles/</c> HTTP URL.
|
||||
/// </summary>
|
||||
/// <param name="smartApiUrl">The request-appropriate base URL, as returned by <see cref="IServerApplicationHost.GetSmartApiUrl(HttpRequest)"/>.</param>
|
||||
/// <param name="localPath">The media source's local (LAN-access) path, as built from <see cref="IServerApplicationHost.GetApiUrlForLocalAccess"/>.</param>
|
||||
/// <param name="protocol">The media source's protocol.</param>
|
||||
/// <param name="baseUrl">The server's configured BaseUrl, if any.</param>
|
||||
/// <returns>The published path, or null if the local path should be left unchanged.</returns>
|
||||
internal static string? GetPublishedLiveStreamPath(
|
||||
string smartApiUrl,
|
||||
string? localPath,
|
||||
MediaProtocol protocol,
|
||||
string baseUrl)
|
||||
{
|
||||
if (protocol != MediaProtocol.Http
|
||||
|| !Uri.TryCreate(localPath, UriKind.Absolute, out var localUri))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var relativePath = localUri.PathAndQuery;
|
||||
if (!string.IsNullOrEmpty(baseUrl))
|
||||
{
|
||||
var basePrefix = baseUrl + "/";
|
||||
if (!relativePath.StartsWith(basePrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
relativePath = relativePath[baseUrl.Length..];
|
||||
}
|
||||
|
||||
if (!relativePath.StartsWith("/LiveTv/LiveStreamFiles/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var prefix = smartApiUrl.TrimEnd('/');
|
||||
if (!string.IsNullOrEmpty(baseUrl))
|
||||
{
|
||||
var includesBaseUrl = Uri.TryCreate(prefix, UriKind.Absolute, out var publishedUri)
|
||||
&& Uri.UnescapeDataString(publishedUri.AbsolutePath)
|
||||
.TrimEnd('/')
|
||||
.EndsWith(baseUrl, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!includesBaseUrl)
|
||||
{
|
||||
prefix += baseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return prefix + relativePath;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -39,6 +39,19 @@ public static class ExpressionExtensions
|
||||
return predicates.Aggregate((aggregatePredicate, nextPredicate) => aggregatePredicate.Or(nextPredicate));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Negates a predicate.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The predicate parameter type.</typeparam>
|
||||
/// <param name="predicate">The predicate expression to negate.</param>
|
||||
/// <returns>A new expression representing the negation of the input predicate.</returns>
|
||||
public static Expression<Func<T, bool>> Not<T>(this Expression<Func<T, bool>> predicate)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(predicate);
|
||||
|
||||
return Expression.Lambda<Func<T, bool>>(Expression.Not(predicate.Body), predicate.Parameters);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Combines two predicates into a single predicate using a logical AND operation.
|
||||
/// </summary>
|
||||
|
||||
@@ -134,6 +134,21 @@ public static class BaseItemMapper
|
||||
if (dto is Video video)
|
||||
{
|
||||
video.PrimaryVersionId = entity.PrimaryVersionId;
|
||||
|
||||
// The LinkedChildren table is the source of truth for version links
|
||||
if (entity.LinkedChildEntities is not null)
|
||||
{
|
||||
video.LinkedAlternateVersions = entity.LinkedChildEntities
|
||||
// LocalAlternateVersion links belong to Video.LocalAlternateVersions, not here
|
||||
.Where(e => e.ChildType == Database.Implementations.Entities.LinkedChildType.LinkedAlternateVersion)
|
||||
.OrderBy(e => e.SortOrder)
|
||||
.Select(e => new LinkedChild
|
||||
{
|
||||
ItemId = e.ChildId,
|
||||
Type = (MediaBrowser.Controller.Entities.LinkedChildType)e.ChildType
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
if (dto is IHasSeries hasSeriesName)
|
||||
@@ -183,7 +198,7 @@ public static class BaseItemMapper
|
||||
if (dto is Folder folder)
|
||||
{
|
||||
folder.DateLastMediaAdded = entity.DateLastMediaAdded ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
|
||||
if (entity.LinkedChildEntities is not null && entity.LinkedChildEntities.Count > 0)
|
||||
if (entity.LinkedChildEntities is not null)
|
||||
{
|
||||
folder.LinkedChildren = entity.LinkedChildEntities
|
||||
.OrderBy(e => e.SortOrder)
|
||||
|
||||
@@ -4,6 +4,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Model.Dto;
|
||||
@@ -109,7 +110,6 @@ public sealed partial class BaseItemRepository
|
||||
IsNews = filter.IsNews,
|
||||
IsSeries = filter.IsSeries
|
||||
})
|
||||
.Where(e => e.MediaStreams != null)
|
||||
.SelectMany(e => e.MediaStreams!)
|
||||
.Where(e => e.StreamType == (MediaStreamTypeEntity)mediaStreamType)
|
||||
.Select(s => string.IsNullOrEmpty(s.Language) ? "und" : s.Language) // und = undetermined
|
||||
@@ -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)
|
||||
@@ -168,21 +163,16 @@ public sealed partial class BaseItemRepository
|
||||
IsSeries = filter.IsSeries
|
||||
});
|
||||
|
||||
// Keep this as an IQueryable sub-select. Materializing to a list would inline one
|
||||
// bound parameter per CleanValue and hit SQLite's variable cap on libraries with
|
||||
// high-cardinality value types (e.g. tens of thousands of artists).
|
||||
var matchingCleanValues = context.ItemValuesMap
|
||||
.Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type))
|
||||
.Join(
|
||||
innerQueryFilter,
|
||||
ivm => ivm.ItemId,
|
||||
g => g.Id,
|
||||
(ivm, g) => ivm.ItemValue.CleanValue)
|
||||
.Distinct();
|
||||
|
||||
var innerQuery = PrepareItemQuery(context, filter)
|
||||
.Where(e => e.Type == returnType)
|
||||
.Where(e => matchingCleanValues.Contains(e.CleanName!));
|
||||
.Where(e => context.ItemValuesMap
|
||||
.Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type) && ivm.ItemValue.CleanValue == e.CleanName)
|
||||
.Join(
|
||||
innerQueryFilter,
|
||||
ivm => ivm.ItemId,
|
||||
g => g.Id,
|
||||
(ivm, g) => ivm.ItemId)
|
||||
.Any());
|
||||
|
||||
var outerQueryFilter = new InternalItemsQuery(filter.User)
|
||||
{
|
||||
@@ -205,32 +195,42 @@ public sealed partial class BaseItemRepository
|
||||
ExcludeItemIds = filter.ExcludeItemIds
|
||||
};
|
||||
|
||||
// Collapse rows that share a PresentationUniqueKey (e.g. alternate versions) by picking
|
||||
// the lowest Id per group. For MusicArtist, prefer the entity from a library the user
|
||||
// can actually access,since the same artist can have a folder in multiple libraries.
|
||||
// Keep as an IQueryable sub-select so paging is applied AFTER
|
||||
// ApplyOrder runs the caller's actual sort.
|
||||
// Collapse rows that share a PresentationUniqueKey (e.g. alternate versions) into one
|
||||
// representative id per group, then materialize the representative ids once.
|
||||
var masterQuery = TranslateQuery(innerQuery, context, outerQueryFilter);
|
||||
var isMusicArtist = returnType == _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist];
|
||||
var representativeIds = isMusicArtist
|
||||
? masterQuery
|
||||
List<Guid> representativeIds;
|
||||
if (isMusicArtist)
|
||||
{
|
||||
// For MusicArtist, prefer the entity from a library the user can actually access.
|
||||
// Materialize to prevent correlated per-group first-row queries which hurt performance.
|
||||
var topParentIds = filter.TopParentIds;
|
||||
representativeIds = masterQuery
|
||||
.Select(e => new { e.Id, e.PresentationUniqueKey, e.TopParentId })
|
||||
.AsEnumerable()
|
||||
.GroupBy(e => e.PresentationUniqueKey)
|
||||
.Select(g => g
|
||||
.OrderBy(e => filter.TopParentIds.Contains(e.TopParentId ?? Guid.Empty) ? 0 : 1)
|
||||
.OrderBy(e => topParentIds.Contains(e.TopParentId ?? Guid.Empty) ? 0 : 1)
|
||||
.ThenBy(e => e.Id)
|
||||
.First().Id)
|
||||
: masterQuery
|
||||
.ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
representativeIds = masterQuery
|
||||
.GroupBy(e => e.PresentationUniqueKey)
|
||||
.Select(g => g.Min(e => e.Id));
|
||||
.Select(g => g.Min(e => e.Id))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var result = new QueryResult<(BaseItemDto, ItemCounts?)>();
|
||||
if (filter.EnableTotalRecordCount)
|
||||
{
|
||||
result.TotalRecordCount = representativeIds.Count();
|
||||
result.TotalRecordCount = representativeIds.Count;
|
||||
}
|
||||
|
||||
var query = ApplyNavigations(
|
||||
context.BaseItems.AsNoTracking().AsSingleQuery().Where(e => representativeIds.Contains(e.Id)),
|
||||
context.BaseItems.AsNoTracking().AsSingleQuery().WhereOneOrMany(representativeIds, e => e.Id),
|
||||
filter);
|
||||
|
||||
query = ApplyOrder(query, filter, context);
|
||||
@@ -311,8 +311,8 @@ public sealed partial class BaseItemRepository
|
||||
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
|
||||
return context.ItemValuesMap
|
||||
// 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))
|
||||
.Join(
|
||||
@@ -322,18 +322,47 @@ public sealed partial class BaseItemRepository
|
||||
(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() })
|
||||
.GroupBy(x => x.CleanName)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => new ItemCounts
|
||||
.AsEnumerable();
|
||||
|
||||
var countsByCleanName = new Dictionary<string, ItemCounts>();
|
||||
foreach (var group in rawCounts.GroupBy(x => x.CleanName))
|
||||
{
|
||||
var counts = new ItemCounts();
|
||||
foreach (var row in group)
|
||||
{
|
||||
if (row.Type == seriesTypeName)
|
||||
{
|
||||
SeriesCount = g.Where(x => x.Type == seriesTypeName).Sum(x => x.Count),
|
||||
EpisodeCount = g.Where(x => x.Type == episodeTypeName).Sum(x => x.Count),
|
||||
MovieCount = g.Where(x => x.Type == movieTypeName).Sum(x => x.Count),
|
||||
AlbumCount = g.Where(x => x.Type == musicAlbumTypeName).Sum(x => x.Count),
|
||||
ArtistCount = g.Where(x => x.Type == musicArtistTypeName).Sum(x => x.Count),
|
||||
SongCount = g.Where(x => x.Type == audioTypeName).Sum(x => x.Count),
|
||||
TrailerCount = g.Where(x => x.Type == trailerTypeName).Sum(x => x.Count),
|
||||
});
|
||||
counts.SeriesCount += row.Count;
|
||||
}
|
||||
else if (row.Type == episodeTypeName)
|
||||
{
|
||||
counts.EpisodeCount += row.Count;
|
||||
}
|
||||
else if (row.Type == movieTypeName)
|
||||
{
|
||||
counts.MovieCount += row.Count;
|
||||
}
|
||||
else if (row.Type == musicAlbumTypeName)
|
||||
{
|
||||
counts.AlbumCount += row.Count;
|
||||
}
|
||||
else if (row.Type == musicArtistTypeName)
|
||||
{
|
||||
counts.ArtistCount += row.Count;
|
||||
}
|
||||
else if (row.Type == audioTypeName)
|
||||
{
|
||||
counts.SongCount += row.Count;
|
||||
}
|
||||
else if (row.Type == trailerTypeName)
|
||||
{
|
||||
counts.TrailerCount += row.Count;
|
||||
}
|
||||
}
|
||||
|
||||
countsByCleanName[group.Key] = counts;
|
||||
}
|
||||
|
||||
return countsByCleanName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,11 +35,40 @@ public sealed partial class BaseItemRepository
|
||||
{
|
||||
dbQuery = TranslateQuery(dbQuery, context, filter);
|
||||
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
|
||||
dbQuery = ApplyAdjacencyFilter(context, dbQuery, filter);
|
||||
dbQuery = ApplyQueryPaging(dbQuery, filter);
|
||||
dbQuery = ApplyNavigations(dbQuery, filter);
|
||||
return dbQuery;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trims an ordered query down to the AdjacentTo item and its immediate neighbours.
|
||||
/// </summary>
|
||||
private IQueryable<BaseItemEntity> ApplyAdjacencyFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
|
||||
{
|
||||
if (filter.AdjacentTo.IsNullOrEmpty())
|
||||
{
|
||||
return dbQuery;
|
||||
}
|
||||
|
||||
// Adjacency is relative to the result set and the order the query asked for, so the ids have
|
||||
// to be read back in that order.
|
||||
var orderedIds = dbQuery.Select(e => e.Id).ToList();
|
||||
var index = orderedIds.IndexOf(filter.AdjacentTo.Value);
|
||||
if (index < 0)
|
||||
{
|
||||
// The item isn't part of this result set, so it has no neighbours in it either.
|
||||
return dbQuery.Take(0);
|
||||
}
|
||||
|
||||
var start = Math.Max(index - 1, 0);
|
||||
var adjacentIds = orderedIds.GetRange(start, Math.Min(index + 2, orderedIds.Count) - start);
|
||||
|
||||
var adjacentQuery = context.BaseItems.AsNoTracking().AsSingleQuery().Where(e => adjacentIds.Contains(e.Id));
|
||||
|
||||
return ApplyOrder(adjacentQuery, filter, context);
|
||||
}
|
||||
|
||||
private IQueryable<BaseItemEntity> ApplyQueryPaging(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
|
||||
{
|
||||
if (filter.Limit.HasValue || filter.StartIndex.HasValue)
|
||||
@@ -244,8 +273,8 @@ public sealed partial class BaseItemRepository
|
||||
dbQuery = dbQuery.Include(e => e.Images);
|
||||
}
|
||||
|
||||
// Include LinkedChildEntities for container types and videos that use them
|
||||
// (BoxSet, Playlist, CollectionFolder for manual linking; Video, Movie for alternate versions).
|
||||
// Include LinkedChildEntities for container types and videos that use them (BoxSet, Playlist,
|
||||
// CollectionFolder for manual linking; every video type for alternate versions).
|
||||
// When IncludeItemTypes is empty (any type may be returned), always include them to ensure
|
||||
// LinkedChildren are loaded before items are saved back, preventing accidental deletion.
|
||||
var linkedChildTypes = new[]
|
||||
@@ -254,7 +283,10 @@ public sealed partial class BaseItemRepository
|
||||
BaseItemKind.Playlist,
|
||||
BaseItemKind.CollectionFolder,
|
||||
BaseItemKind.Video,
|
||||
BaseItemKind.Movie
|
||||
BaseItemKind.Movie,
|
||||
BaseItemKind.Episode,
|
||||
BaseItemKind.MusicVideo,
|
||||
BaseItemKind.Trailer
|
||||
};
|
||||
if (filter.IncludeItemTypes.Length == 0 || filter.IncludeItemTypes.Any(linkedChildTypes.Contains))
|
||||
{
|
||||
@@ -390,11 +422,23 @@ public sealed partial class BaseItemRepository
|
||||
|
||||
var baseQuery = context.BaseItems
|
||||
.AsNoTracking()
|
||||
.Where(b => allDescendantIds.Contains(b.Id) && !b.IsFolder && !b.IsVirtualItem);
|
||||
.Where(b => allDescendantIds.Contains(b.Id))
|
||||
.Where(DescendantQueryHelper.IsCountableLeaf);
|
||||
|
||||
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.
|
||||
@@ -405,13 +449,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)
|
||||
{
|
||||
@@ -462,13 +620,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;
|
||||
}
|
||||
|
||||
@@ -503,62 +654,31 @@ public sealed partial class BaseItemRepository
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IQueryable<Guid> GetFullyPlayedFolderIdsQuery(JellyfinDbContext context, IQueryable<Guid> folderIds, User user)
|
||||
public IQueryable<BaseItemEntity> GetAccessFilteredLeafItemsQuery(JellyfinDbContext context, User user, bool includeOwnedItems = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(folderIds);
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
|
||||
var filter = new InternalItemsQuery(user);
|
||||
var userId = user.Id;
|
||||
|
||||
var leafItems = context.BaseItems
|
||||
.AsNoTracking()
|
||||
.Where(b => !b.IsFolder && !b.IsVirtualItem);
|
||||
leafItems = ApplyAccessFiltering(context, leafItems, filter);
|
||||
.Where(DescendantQueryHelper.IsCountableLeaf);
|
||||
|
||||
var playedLeafItems = leafItems
|
||||
.Select(b => new { b.Id, Played = b.UserData!.Any(ud => ud.UserId == userId && ud.Played) });
|
||||
return ApplyAccessFiltering(context, leafItems, new InternalItemsQuery(user) { IncludeOwnedItems = includeOwnedItems });
|
||||
}
|
||||
|
||||
var ancestorLeaves = context.AncestorIds
|
||||
.Where(a => folderIds.Contains(a.ParentItemId))
|
||||
.Join(
|
||||
playedLeafItems,
|
||||
a => a.ItemId,
|
||||
b => b.Id,
|
||||
(a, b) => new { FolderId = a.ParentItemId, b.Id, b.Played });
|
||||
/// <inheritdoc />
|
||||
public Expression<Func<BaseItemEntity, bool>> BuildHasDescendantFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> descendants)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(descendants);
|
||||
|
||||
var linkedLeaves = context.LinkedChildren
|
||||
.Where(lc => folderIds.Contains(lc.ParentId))
|
||||
.Join(
|
||||
playedLeafItems,
|
||||
lc => lc.ChildId,
|
||||
b => b.Id,
|
||||
(lc, b) => new { FolderId = lc.ParentId, b.Id, b.Played });
|
||||
|
||||
var linkedFolderLeaves = context.LinkedChildren
|
||||
.Where(lc => folderIds.Contains(lc.ParentId))
|
||||
.Join(
|
||||
context.BaseItems.Where(b => b.IsFolder),
|
||||
lc => lc.ChildId,
|
||||
b => b.Id,
|
||||
(lc, b) => new { lc.ParentId, FolderChildId = b.Id })
|
||||
.Join(
|
||||
context.AncestorIds,
|
||||
x => x.FolderChildId,
|
||||
a => a.ParentItemId,
|
||||
(x, a) => new { x.ParentId, DescendantId = a.ItemId })
|
||||
.Join(
|
||||
playedLeafItems,
|
||||
x => x.DescendantId,
|
||||
b => b.Id,
|
||||
(x, b) => new { FolderId = x.ParentId, b.Id, b.Played });
|
||||
|
||||
return ancestorLeaves
|
||||
.Union(linkedLeaves)
|
||||
.Union(linkedFolderLeaves)
|
||||
.GroupBy(x => x.FolderId)
|
||||
.Where(g => g.Select(x => x.Id).Distinct().Count() == g.Where(x => x.Played).Select(x => x.Id).Distinct().Count())
|
||||
.Select(g => g.Key);
|
||||
// Descendants are reachable through the ancestor chain and - for BoxSets and Playlists - as
|
||||
// linked children, which can themselves be folders contributing their own descendants.
|
||||
// Every step is a correlated index seek, so only the rows the outer query keeps are visited
|
||||
// and a folder is left as soon as its first matching descendant is found.
|
||||
return e => context.AncestorIds.Any(a => a.ParentItemId == e.Id && descendants.Any(d => d.Id == a.ItemId))
|
||||
|| context.LinkedChildren.Any(lc => lc.ParentId == e.Id
|
||||
&& (descendants.Any(d => d.Id == lc.ChildId)
|
||||
|| context.AncestorIds.Any(a => a.ParentItemId == lc.ChildId && descendants.Any(d => d.Id == a.ItemId))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ public sealed partial class BaseItemRepository
|
||||
|
||||
dbQuery = TranslateQuery(dbQuery, context, filter);
|
||||
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
|
||||
dbQuery = ApplyAdjacencyFilter(context, dbQuery, filter);
|
||||
|
||||
if (filter.EnableTotalRecordCount)
|
||||
{
|
||||
@@ -75,6 +76,7 @@ public sealed partial class BaseItemRepository
|
||||
dbQuery = TranslateQuery(dbQuery, context, filter);
|
||||
|
||||
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
|
||||
dbQuery = ApplyAdjacencyFilter(context, dbQuery, filter);
|
||||
dbQuery = ApplyQueryPaging(dbQuery, filter);
|
||||
|
||||
var hasRandomSort = filter.OrderBy.Any(e => e.OrderBy == ItemSortBy.Random);
|
||||
@@ -126,38 +128,57 @@ public sealed partial class BaseItemRepository
|
||||
|
||||
if (collectionType is CollectionType.movies)
|
||||
{
|
||||
// Group by PresentationUniqueKey, pick the newest item per group.
|
||||
var topGroupItems = baseQuery
|
||||
// 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)
|
||||
.GroupBy(e => e.PresentationUniqueKey)
|
||||
.Select(g => new
|
||||
{
|
||||
MaxDate = g.Max(e => e.DateCreated),
|
||||
FirstId = g.OrderByDescending(e => e.DateCreated).ThenByDescending(e => e.Id).Select(e => e.Id).First()
|
||||
})
|
||||
.OrderByDescending(g => g.MaxDate);
|
||||
.OrderByDescending(e => e.DateCreated)
|
||||
.ThenByDescending(e => e.Id)
|
||||
.Select(e => new { e.Id, e.PresentationUniqueKey });
|
||||
|
||||
var firstIdsQuery = filter.Limit.HasValue
|
||||
? topGroupItems.Take(filter.Limit.Value).Select(g => g.FirstId)
|
||||
: topGroupItems.Select(g => g.FirstId);
|
||||
// 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, firstIdsQuery, filter);
|
||||
return LoadLatestByIds(context, firstIds, filter);
|
||||
}
|
||||
|
||||
// Albums whose Id is the parent of any track matching the user's filter.
|
||||
var albumIdsWithMatchingTrack = context.AncestorIds
|
||||
.Join(baseQuery, ai => ai.ItemId, t => t.Id, (ai, _) => ai.ParentItemId);
|
||||
|
||||
var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum]!;
|
||||
var topAlbumsQuery = context.BaseItems.AsNoTracking()
|
||||
.Where(album => album.Type == musicAlbumTypeName)
|
||||
.Where(album => albumIdsWithMatchingTrack.Contains(album.Id))
|
||||
IQueryable<BaseItemEntity> topAlbumsQuery;
|
||||
|
||||
// When the query is scoped to whole libraries, read the newest albums directly by their own TopParentId.
|
||||
if (filter.TopParentIds.Length > 0)
|
||||
{
|
||||
topAlbumsQuery = context.BaseItems.AsNoTracking()
|
||||
.Where(album => album.Type == musicAlbumTypeName
|
||||
&& !album.IsVirtualItem
|
||||
&& album.TopParentId.HasValue)
|
||||
.WhereOneOrMany(filter.TopParentIds, album => album.TopParentId!.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback (e.g. AncestorIds-scoped callers): albums that are the parent of a matching track.
|
||||
var albumIdsWithMatchingTrack = context.AncestorIds
|
||||
.Join(baseQuery, ai => ai.ItemId, t => t.Id, (ai, _) => ai.ParentItemId);
|
||||
topAlbumsQuery = context.BaseItems.AsNoTracking()
|
||||
.Where(album => album.Type == musicAlbumTypeName)
|
||||
.Where(album => albumIdsWithMatchingTrack.Contains(album.Id));
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
var albumIdsQuery = filter.Limit.HasValue
|
||||
? topAlbumsQuery.Take(filter.Limit.Value).Select(a => a.Id)
|
||||
: topAlbumsQuery.Select(a => a.Id);
|
||||
var albumIdsQuery = limit.HasValue
|
||||
? orderedAlbums.Take(limit.Value).Select(a => a.Id)
|
||||
: orderedAlbums.Select(a => a.Id);
|
||||
|
||||
return LoadLatestByIds(context, albumIdsQuery, filter);
|
||||
}
|
||||
@@ -181,6 +202,29 @@ public sealed partial class BaseItemRepository
|
||||
.ToArray()!;
|
||||
}
|
||||
|
||||
private IReadOnlyList<BaseItemDto> LoadLatestByIds(
|
||||
JellyfinDbContext context,
|
||||
List<Guid> ids,
|
||||
InternalItemsQuery filter)
|
||||
{
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var itemsQuery = ApplyNavigations(
|
||||
context.BaseItems.AsNoTracking().WhereOneOrMany(ids, e => e.Id),
|
||||
filter);
|
||||
|
||||
return itemsQuery
|
||||
.OrderByDescending(e => e.DateCreated)
|
||||
.ThenByDescending(e => e.Id)
|
||||
.AsEnumerable()
|
||||
.Select(w => DeserializeBaseItem(w, filter.SkipDeserialization))
|
||||
.Where(dto => dto != null)
|
||||
.ToArray()!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest TV show items with smart Season/Series container selection.
|
||||
/// </summary>
|
||||
@@ -381,6 +425,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(
|
||||
|
||||
@@ -31,6 +31,10 @@ public sealed partial class BaseItemRepository
|
||||
private static readonly string TmdbProviderName = MetadataProvider.Tmdb.ToString().ToLowerInvariant();
|
||||
private static readonly string TvdbProviderName = MetadataProvider.Tvdb.ToString().ToLowerInvariant();
|
||||
|
||||
// A fresh expression per access: EF rejects a query tree that reuses one lambda parameter
|
||||
// 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;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IQueryable<BaseItemEntity> TranslateQuery(
|
||||
IQueryable<BaseItemEntity> baseQuery,
|
||||
@@ -352,7 +356,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,14 +370,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))
|
||||
@@ -434,115 +440,82 @@ public sealed partial class BaseItemRepository
|
||||
|
||||
if (filter.IsLiked.HasValue)
|
||||
{
|
||||
var isLiked = filter.IsLiked.Value;
|
||||
baseQuery = baseQuery.Where(e => e.UserData!.Any(ud => ud.UserId == filter.User!.Id && ud.Rating >= UserItemData.MinLikeValue) == isLiked);
|
||||
var likedIds = context.UserData
|
||||
.Where(ud => ud.UserId == filter.User!.Id && ud.Rating >= UserItemData.MinLikeValue)
|
||||
.Select(ud => ud.ItemId);
|
||||
|
||||
baseQuery = filter.IsLiked.Value
|
||||
? baseQuery.Where(e => likedIds.Contains(e.Id))
|
||||
: baseQuery.Where(e => !likedIds.Contains(e.Id));
|
||||
}
|
||||
|
||||
if (filter.IsFavoriteOrLiked.HasValue)
|
||||
if (filter.IsFavoriteOrLiked.HasValue || filter.IsFavorite.HasValue)
|
||||
{
|
||||
var isFavoriteOrLiked = filter.IsFavoriteOrLiked.Value;
|
||||
baseQuery = baseQuery.Where(e => e.UserData!.Any(ud => ud.UserId == filter.User!.Id && ud.IsFavorite) == isFavoriteOrLiked);
|
||||
}
|
||||
var favoriteIds = context.UserData
|
||||
.Where(ud => ud.UserId == filter.User!.Id && ud.IsFavorite)
|
||||
.Select(ud => ud.ItemId);
|
||||
|
||||
if (filter.IsFavorite.HasValue)
|
||||
{
|
||||
var isFavorite = filter.IsFavorite.Value;
|
||||
baseQuery = baseQuery.Where(e => e.UserData!.Any(ud => ud.UserId == filter.User!.Id && ud.IsFavorite) == isFavorite);
|
||||
if (filter.IsFavoriteOrLiked.HasValue)
|
||||
{
|
||||
baseQuery = filter.IsFavoriteOrLiked.Value
|
||||
? baseQuery.Where(e => favoriteIds.Contains(e.Id))
|
||||
: baseQuery.Where(e => !favoriteIds.Contains(e.Id));
|
||||
}
|
||||
|
||||
if (filter.IsFavorite.HasValue)
|
||||
{
|
||||
baseQuery = filter.IsFavorite.Value
|
||||
? baseQuery.Where(e => favoriteIds.Contains(e.Id))
|
||||
: baseQuery.Where(e => !favoriteIds.Contains(e.Id));
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.IsPlayed.HasValue)
|
||||
{
|
||||
var hasSeries = filter.IncludeItemTypes.Contains(BaseItemKind.Series);
|
||||
var hasBoxSet = filter.IncludeItemTypes.Contains(BaseItemKind.BoxSet);
|
||||
var userId = filter.User!.Id;
|
||||
|
||||
if (hasSeries || hasBoxSet)
|
||||
{
|
||||
var userId = filter.User!.Id;
|
||||
var isPlayed = filter.IsPlayed.Value;
|
||||
var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
|
||||
var boxSetTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.BoxSet];
|
||||
// Leaf items carry their own played state.
|
||||
var playedItemIds = context.UserData
|
||||
.Where(ud => ud.UserId == userId && ud.Played)
|
||||
.Select(ud => ud.ItemId);
|
||||
|
||||
// Series: played = at least one episode AND all episodes played; unplayed = otherwise.
|
||||
IQueryable<Guid> playedSeriesIds = hasSeries
|
||||
? context.BaseItems
|
||||
.AsNoTracking()
|
||||
.Where(e => !e.IsFolder && !e.IsVirtualItem && e.SeriesId.HasValue)
|
||||
.GroupBy(e => e.SeriesId!.Value)
|
||||
.Where(g => !g.Any(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played)))
|
||||
.Select(g => g.Key)
|
||||
: Enumerable.Empty<Guid>().AsQueryable();
|
||||
// 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));
|
||||
|
||||
// BoxSet: played = all children played.
|
||||
IQueryable<Guid> playedBoxSetIds = hasBoxSet
|
||||
? GetFullyPlayedFolderIdsQuery(
|
||||
context,
|
||||
baseQuery.Where(e => e.Type == boxSetTypeName).Select(e => e.Id),
|
||||
filter.User!)
|
||||
: Enumerable.Empty<Guid>().AsQueryable();
|
||||
var isPlayedFilter = IsFolderFilter.And(BuildHasDescendantFilter(context, unplayedLeafItems).Not())
|
||||
.Or(IsFolderFilter.Not().And(e => playedItemIds.Contains(e.Id)));
|
||||
|
||||
// Non-folder items: check UserData directly
|
||||
var playedItemIds = context.UserData
|
||||
.Where(ud => ud.UserId == userId && ud.Played)
|
||||
.Select(ud => ud.ItemId);
|
||||
|
||||
if (isPlayed)
|
||||
{
|
||||
baseQuery = baseQuery.Where(e =>
|
||||
(e.Type == seriesTypeName && playedSeriesIds.Contains(e.Id))
|
||||
|| (e.Type == boxSetTypeName && playedBoxSetIds.Contains(e.Id))
|
||||
|| (e.Type != seriesTypeName && e.Type != boxSetTypeName && playedItemIds.Contains(e.Id)));
|
||||
}
|
||||
else
|
||||
{
|
||||
baseQuery = baseQuery.Where(e =>
|
||||
(e.Type == seriesTypeName && !playedSeriesIds.Contains(e.Id))
|
||||
|| (e.Type == boxSetTypeName && !playedBoxSetIds.Contains(e.Id))
|
||||
|| (e.Type != seriesTypeName && e.Type != boxSetTypeName && !playedItemIds.Contains(e.Id)));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var playedItemIds = context.UserData
|
||||
.Where(ud => ud.UserId == filter.User!.Id && ud.Played)
|
||||
.Select(ud => ud.ItemId);
|
||||
var isPlayedItem = filter.IsPlayed.Value;
|
||||
baseQuery = baseQuery.Where(e => playedItemIds.Contains(e.Id) == isPlayedItem);
|
||||
}
|
||||
baseQuery = baseQuery.Where(filter.IsPlayed.Value ? isPlayedFilter : isPlayedFilter.Not());
|
||||
}
|
||||
|
||||
if (filter.IsResumable.HasValue)
|
||||
{
|
||||
var hasSeries = filter.IncludeItemTypes.Contains(BaseItemKind.Series);
|
||||
var userId = filter.User!.Id;
|
||||
var isResumable = filter.IsResumable.Value;
|
||||
var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
|
||||
|
||||
// In-progress user data rows; alternate versions track their own progress.
|
||||
var inProgress = context.UserData
|
||||
.Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0);
|
||||
|
||||
IQueryable<Guid>? resumableSeriesIds = null;
|
||||
if (hasSeries)
|
||||
{
|
||||
// Aggregate per series in a single GROUP BY pass, instead of three full scans.
|
||||
var seriesEpisodeStats = context.BaseItems
|
||||
.AsNoTracking()
|
||||
.Where(e => !e.IsFolder && !e.IsVirtualItem && e.SeriesId.HasValue)
|
||||
.GroupBy(e => e.SeriesId!.Value)
|
||||
.Select(g => new
|
||||
{
|
||||
SeriesId = g.Key,
|
||||
HasInProgress = g.Any(e => e.UserData!.Any(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0)),
|
||||
HasPlayed = g.Any(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)),
|
||||
HasUnplayed = g.Any(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played))
|
||||
});
|
||||
// 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));
|
||||
|
||||
// A series is resumable if it has an in-progress episode,
|
||||
// or if it has both played and unplayed episodes (partially watched).
|
||||
resumableSeriesIds = seriesEpisodeStats
|
||||
.Where(s => s.HasInProgress || (s.HasPlayed && s.HasUnplayed))
|
||||
.Select(s => s.SeriesId);
|
||||
}
|
||||
// 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)
|
||||
{
|
||||
@@ -550,26 +523,26 @@ 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 = hasSeries
|
||||
? baseQuery.Where(e =>
|
||||
(e.Type == seriesTypeName && resumableSeriesIds!.Contains(e.Id))
|
||||
|| (e.Type != seriesTypeName && inProgressIds.Contains(e.Id)))
|
||||
: baseQuery.Where(e => inProgressIds.Contains(e.Id));
|
||||
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.
|
||||
// Only in-progress siblings can eliminate a candidate: a version without progress has a NULL max LastPlayedDate,
|
||||
// which is never greater and never ties. Restricting the sibling scan to the in-progress set keeps this bounded by
|
||||
// the user's Continue Watching count instead of forcing a full BaseItems scan (COALESCE keys are non-indexable) per row.
|
||||
baseQuery = baseQuery.Where(e => e.Type == seriesTypeName || !context.BaseItems
|
||||
.Where(s => s.Id != e.Id
|
||||
&& inProgressIds.Contains(s.Id)
|
||||
&& (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
|
||||
.Any(s =>
|
||||
inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
|
||||
> inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
|
||||
|| (inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
|
||||
== inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
|
||||
&& s.Id.CompareTo(e.Id) < 0)));
|
||||
// Items in no version group at all have no sibling that could eliminate them, so short-circuit the scan for those.
|
||||
baseQuery = baseQuery.Where(e => e.IsFolder
|
||||
|| (e.PrimaryVersionId == null && !context.BaseItems.Any(a => a.PrimaryVersionId == e.Id))
|
||||
|| !context.BaseItems
|
||||
.Where(s => s.Id != e.Id
|
||||
&& inProgressIds.Contains(s.Id)
|
||||
&& (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
|
||||
.Any(s =>
|
||||
inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
|
||||
> inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
|
||||
|| (inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
|
||||
== inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
|
||||
&& s.Id.CompareTo(e.Id) < 0)));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -577,17 +550,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 = hasSeries
|
||||
? baseQuery.Where(e =>
|
||||
(e.Type == seriesTypeName && !resumableSeriesIds!.Contains(e.Id))
|
||||
|| (e.Type != seriesTypeName && !resumableMovieIds.Contains(e.Id)))
|
||||
: baseQuery.Where(e => !resumableMovieIds.Contains(e.Id));
|
||||
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)
|
||||
@@ -618,12 +588,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)
|
||||
@@ -649,7 +619,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)
|
||||
@@ -995,17 +965,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
|
||||
@@ -1027,21 +986,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)
|
||||
{
|
||||
@@ -1150,16 +1095,23 @@ public sealed partial class BaseItemRepository
|
||||
: baseQuery.WhereNeitherItemNorDescendantMatches(context, isPlaceHolder);
|
||||
}
|
||||
|
||||
// An extra is owned by the single version of an item it is named after, so an extra on any
|
||||
// version counts for the item itself
|
||||
IQueryable<Guid> WithPrimaryVersions(IQueryable<Guid> ownerIds)
|
||||
=> ownerIds.Concat(context.BaseItems
|
||||
.Where(version => version.PrimaryVersionId != null && ownerIds.Contains(version.Id))
|
||||
.Select(version => version.PrimaryVersionId!.Value));
|
||||
|
||||
if (filter.HasSpecialFeature.HasValue)
|
||||
{
|
||||
var itemsWithExtras = context.BaseItems
|
||||
var itemsWithExtras = WithPrimaryVersions(context.BaseItems
|
||||
.Where(extra => extra.OwnerId != null
|
||||
&& extra.ExtraType != null
|
||||
&& extra.ExtraType != BaseItemExtraType.Unknown
|
||||
&& extra.ExtraType != BaseItemExtraType.Trailer
|
||||
&& extra.ExtraType != BaseItemExtraType.ThemeSong
|
||||
&& extra.ExtraType != BaseItemExtraType.ThemeVideo)
|
||||
.Select(extra => extra.OwnerId!.Value)
|
||||
.Select(extra => extra.OwnerId!.Value))
|
||||
.Distinct();
|
||||
|
||||
Expression<Func<BaseItemEntity, bool>> hasExtras = e => itemsWithExtras.Contains(e.Id);
|
||||
@@ -1171,9 +1123,9 @@ public sealed partial class BaseItemRepository
|
||||
|
||||
if (filter.HasTrailer.HasValue)
|
||||
{
|
||||
var trailerOwnerIds = context.BaseItems
|
||||
var trailerOwnerIds = WithPrimaryVersions(context.BaseItems
|
||||
.Where(extra => extra.ExtraType == BaseItemExtraType.Trailer && extra.OwnerId != null)
|
||||
.Select(extra => extra.OwnerId!.Value);
|
||||
.Select(extra => extra.OwnerId!.Value));
|
||||
|
||||
Expression<Func<BaseItemEntity, bool>> hasTrailer = e => trailerOwnerIds.Contains(e.Id);
|
||||
|
||||
@@ -1184,9 +1136,9 @@ public sealed partial class BaseItemRepository
|
||||
|
||||
if (filter.HasThemeSong.HasValue)
|
||||
{
|
||||
var themeSongOwnerIds = context.BaseItems
|
||||
var themeSongOwnerIds = WithPrimaryVersions(context.BaseItems
|
||||
.Where(extra => extra.ExtraType == BaseItemExtraType.ThemeSong && extra.OwnerId != null)
|
||||
.Select(extra => extra.OwnerId!.Value);
|
||||
.Select(extra => extra.OwnerId!.Value));
|
||||
|
||||
Expression<Func<BaseItemEntity, bool>> hasThemeSong = e => themeSongOwnerIds.Contains(e.Id);
|
||||
|
||||
@@ -1197,9 +1149,9 @@ public sealed partial class BaseItemRepository
|
||||
|
||||
if (filter.HasThemeVideo.HasValue)
|
||||
{
|
||||
var themeVideoOwnerIds = context.BaseItems
|
||||
var themeVideoOwnerIds = WithPrimaryVersions(context.BaseItems
|
||||
.Where(extra => extra.ExtraType == BaseItemExtraType.ThemeVideo && extra.OwnerId != null)
|
||||
.Select(extra => extra.OwnerId!.Value);
|
||||
.Select(extra => extra.OwnerId!.Value));
|
||||
|
||||
Expression<Func<BaseItemEntity, bool>> hasThemeVideo = e => themeVideoOwnerIds.Contains(e.Id);
|
||||
|
||||
@@ -1226,33 +1178,6 @@ public sealed partial class BaseItemRepository
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.AdjacentTo.HasValue && !filter.AdjacentTo.Value.IsEmpty())
|
||||
{
|
||||
var adjacentToId = filter.AdjacentTo.Value;
|
||||
var targetItem = context.BaseItems.Where(e => e.Id == adjacentToId).Select(e => new { e.SortName, e.Id }).FirstOrDefault();
|
||||
if (targetItem is not null)
|
||||
{
|
||||
var targetSortName = targetItem.SortName ?? string.Empty;
|
||||
|
||||
// Fetch both prev and next adjacent items in a single query using Concat (UNION ALL).
|
||||
var adjacentIds = context.BaseItems
|
||||
.Where(e => string.Compare(e.SortName, targetSortName) < 0)
|
||||
.OrderByDescending(e => e.SortName)
|
||||
.Select(e => e.Id)
|
||||
.Take(1)
|
||||
.Concat(
|
||||
context.BaseItems
|
||||
.Where(e => string.Compare(e.SortName, targetSortName) > 0)
|
||||
.OrderBy(e => e.SortName)
|
||||
.Select(e => e.Id)
|
||||
.Take(1))
|
||||
.ToList();
|
||||
|
||||
adjacentIds.Add(adjacentToId);
|
||||
baseQuery = baseQuery.Where(e => adjacentIds.Contains(e.Id));
|
||||
}
|
||||
}
|
||||
|
||||
return baseQuery;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -141,32 +141,32 @@ public class ItemCountService : IItemCountService
|
||||
switch (kind)
|
||||
{
|
||||
case BaseItemKind.Person:
|
||||
baseQuery = context.PeopleBaseItemMap
|
||||
baseQuery = ItemsById(context, context.PeopleBaseItemMap
|
||||
.AsNoTracking()
|
||||
.Where(m => m.People.Name == item.Name)
|
||||
.Select(m => m.Item);
|
||||
.Select(m => m.ItemId));
|
||||
break;
|
||||
case BaseItemKind.MusicArtist:
|
||||
baseQuery = context.ItemValuesMap
|
||||
baseQuery = ItemsById(context, context.ItemValuesMap
|
||||
.AsNoTracking()
|
||||
.Where(ivm => ivm.ItemValue.CleanValue == item.CleanName
|
||||
&& (ivm.ItemValue.Type == ItemValueType.Artist || ivm.ItemValue.Type == ItemValueType.AlbumArtist))
|
||||
.Select(ivm => ivm.Item);
|
||||
.Select(ivm => ivm.ItemId));
|
||||
break;
|
||||
case BaseItemKind.Genre:
|
||||
case BaseItemKind.MusicGenre:
|
||||
baseQuery = context.ItemValuesMap
|
||||
baseQuery = ItemsById(context, context.ItemValuesMap
|
||||
.AsNoTracking()
|
||||
.Where(ivm => ivm.ItemValue.CleanValue == item.CleanName
|
||||
&& ivm.ItemValue.Type == ItemValueType.Genre)
|
||||
.Select(ivm => ivm.Item);
|
||||
.Select(ivm => ivm.ItemId));
|
||||
break;
|
||||
case BaseItemKind.Studio:
|
||||
baseQuery = context.ItemValuesMap
|
||||
baseQuery = ItemsById(context, context.ItemValuesMap
|
||||
.AsNoTracking()
|
||||
.Where(ivm => ivm.ItemValue.CleanValue == item.CleanName
|
||||
&& ivm.ItemValue.Type == ItemValueType.Studios)
|
||||
.Select(ivm => ivm.Item);
|
||||
.Select(ivm => ivm.ItemId));
|
||||
break;
|
||||
case BaseItemKind.Year:
|
||||
if (int.TryParse(item.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year))
|
||||
@@ -254,6 +254,9 @@ public class ItemCountService : IItemCountService
|
||||
return result;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -293,7 +296,8 @@ public class ItemCountService : IItemCountService
|
||||
|
||||
var allDescendantIds = DescendantQueryHelper.GetAllDescendantIds(dbContext, parentId);
|
||||
var baseQuery = dbContext.BaseItems
|
||||
.Where(b => allDescendantIds.Contains(b.Id) && !b.IsFolder && !b.IsVirtualItem);
|
||||
.Where(b => allDescendantIds.Contains(b.Id))
|
||||
.Where(DescendantQueryHelper.IsCountableLeaf);
|
||||
baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter);
|
||||
|
||||
return GetPlayedAndTotalCountFromQuery(baseQuery, filter.User.Id);
|
||||
@@ -354,7 +358,7 @@ public class ItemCountService : IItemCountService
|
||||
var userId = user.Id;
|
||||
|
||||
var leafItems = dbContext.BaseItems
|
||||
.Where(b => !b.IsFolder && !b.IsVirtualItem);
|
||||
.Where(DescendantQueryHelper.IsCountableLeaf);
|
||||
leafItems = _queryHelpers.ApplyAccessFiltering(dbContext, leafItems, filter);
|
||||
|
||||
var playedLeafItems = leafItems
|
||||
|
||||
@@ -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
|
||||
@@ -428,106 +435,144 @@ public class ItemPersistenceService : IItemPersistenceService
|
||||
|
||||
foreach (var item in tuples)
|
||||
{
|
||||
if (item.Item is Folder folder)
|
||||
// A container that was never hydrated cannot be used to rewrite its links: its empty
|
||||
// array means "unknown", so clearing the stored rows would silently empty the item.
|
||||
if (item.Item is Folder { LinkedChildrenLoaded: false })
|
||||
{
|
||||
var existingLinkedChildren = allLinkedChildrenByParent.GetValueOrDefault(item.Item.Id)?.ToList() ?? new List<LinkedChildEntity>();
|
||||
if (folder.LinkedChildren.Length > 0)
|
||||
{
|
||||
#pragma warning disable CS0618 // Type or member is obsolete - legacy path resolution for old data
|
||||
var pathsToResolve = folder.LinkedChildren
|
||||
.Where(lc => (!lc.ItemId.HasValue || lc.ItemId.Value.IsEmpty()) && !string.IsNullOrEmpty(lc.Path))
|
||||
.Select(lc => lc.Path)
|
||||
.Distinct()
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.Item is Folder or Video
|
||||
&& allLinkedChildrenByParent.TryGetValue(item.Item.Id, out var existingLinks)
|
||||
&& existingLinks.Count > 0)
|
||||
{
|
||||
// A video only owns its alternate version links; any other link on that parent is
|
||||
// written by the folder branch below and must survive.
|
||||
var staleLinks = item.Item is Folder
|
||||
? existingLinks
|
||||
: existingLinks
|
||||
.Where(e => e.ChildType is DbLinkedChildType.LocalAlternateVersion or DbLinkedChildType.LinkedAlternateVersion)
|
||||
.ToList();
|
||||
|
||||
var pathToIdMap = pathsToResolve.Count > 0
|
||||
? context.BaseItems
|
||||
.Where(e => e.Path != null && pathsToResolve.Contains(e.Path))
|
||||
.Select(e => new { e.Path, e.Id })
|
||||
.GroupBy(e => e.Path!)
|
||||
.ToDictionary(g => g.Key, g => g.First().Id)
|
||||
: [];
|
||||
if (staleLinks.Count > 0)
|
||||
{
|
||||
context.LinkedChildren.RemoveRange(staleLinks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var resolvedChildren = new List<(LinkedChild Child, Guid ChildId)>();
|
||||
foreach (var linkedChild in folder.LinkedChildren)
|
||||
context.SaveChanges();
|
||||
|
||||
// A LinkedChild's ItemId is only a cache.
|
||||
var cachedChildIds = tuples
|
||||
.Select(t => t.Item)
|
||||
.OfType<Folder>()
|
||||
.Where(f => f.LinkedChildrenLoaded)
|
||||
.SelectMany(f => f.LinkedChildren)
|
||||
.Where(lc => lc.ItemId.HasValue && !lc.ItemId.Value.IsEmpty())
|
||||
.Select(lc => lc.ItemId!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var knownChildIds = cachedChildIds.Count > 0
|
||||
? context.BaseItems
|
||||
.WhereOneOrMany(cachedChildIds, e => e.Id)
|
||||
.Select(e => e.Id)
|
||||
.ToHashSet()
|
||||
: [];
|
||||
|
||||
foreach (var item in tuples)
|
||||
{
|
||||
if (item.Item is Folder { LinkedChildrenLoaded: true } folder && folder.LinkedChildren.Length > 0)
|
||||
{
|
||||
#pragma warning disable CS0618 // Type or member is obsolete - legacy path resolution for old data
|
||||
var pathsToResolve = folder.LinkedChildren
|
||||
.Where(lc => !string.IsNullOrEmpty(lc.Path)
|
||||
&& (!lc.ItemId.HasValue || lc.ItemId.Value.IsEmpty() || !knownChildIds.Contains(lc.ItemId.Value)))
|
||||
.Select(lc => lc.Path)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var pathToIdMap = pathsToResolve.Count > 0
|
||||
? context.BaseItems
|
||||
.Where(e => e.Path != null && pathsToResolve.Contains(e.Path))
|
||||
.Select(e => new { e.Path, e.Id })
|
||||
.GroupBy(e => e.Path!)
|
||||
.ToDictionary(g => g.Key, g => g.First().Id)
|
||||
: [];
|
||||
|
||||
var resolvedChildren = new List<(LinkedChild Child, Guid ChildId)>();
|
||||
foreach (var linkedChild in folder.LinkedChildren)
|
||||
{
|
||||
var childItemId = linkedChild.ItemId;
|
||||
if (!childItemId.HasValue || childItemId.Value.IsEmpty() || !knownChildIds.Contains(childItemId.Value))
|
||||
{
|
||||
var childItemId = linkedChild.ItemId;
|
||||
if (!childItemId.HasValue || childItemId.Value.IsEmpty())
|
||||
if (!string.IsNullOrEmpty(linkedChild.Path) && pathToIdMap.TryGetValue(linkedChild.Path, out var resolvedId))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(linkedChild.Path) && pathToIdMap.TryGetValue(linkedChild.Path, out var resolvedId))
|
||||
{
|
||||
childItemId = resolvedId;
|
||||
}
|
||||
childItemId = resolvedId;
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
|
||||
if (childItemId.HasValue && !childItemId.Value.IsEmpty())
|
||||
else if (Guid.TryParse(linkedChild.LibraryItemId, out var libraryItemId) && !libraryItemId.IsEmpty())
|
||||
{
|
||||
resolvedChildren.Add((linkedChild, childItemId.Value));
|
||||
childItemId = libraryItemId;
|
||||
}
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
|
||||
if (childItemId.HasValue && !childItemId.Value.IsEmpty())
|
||||
{
|
||||
resolvedChildren.Add((linkedChild, childItemId.Value));
|
||||
}
|
||||
}
|
||||
|
||||
// Playlists may legitimately contain the same item multiple times (e.g. a song repeated
|
||||
// in an .m3u file). Every other container type keeps a single entry per child.
|
||||
var isPlaylist = folder is Playlist;
|
||||
if (!isPlaylist)
|
||||
{
|
||||
resolvedChildren = resolvedChildren
|
||||
.GroupBy(c => c.ChildId)
|
||||
.Select(g => g.Last())
|
||||
.ToList();
|
||||
|
||||
var childIdsToCheck = resolvedChildren.Select(c => c.ChildId).ToList();
|
||||
var existingChildIds = childIdsToCheck.Count > 0
|
||||
? context.BaseItems
|
||||
.Where(e => childIdsToCheck.Contains(e.Id))
|
||||
.Select(e => e.Id)
|
||||
.ToHashSet()
|
||||
: [];
|
||||
|
||||
var isPlaylist = folder is Playlist;
|
||||
var sortOrder = 0;
|
||||
foreach (var (linkedChild, childId) in resolvedChildren)
|
||||
{
|
||||
if (!existingChildIds.Contains(childId))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Skipping LinkedChild for parent {ParentName} ({ParentId}): child item {ChildId} does not exist in database",
|
||||
item.Item.Name,
|
||||
item.Item.Id,
|
||||
childId);
|
||||
continue;
|
||||
}
|
||||
|
||||
var existingLink = existingLinkedChildren.FirstOrDefault(e => e.ChildId == childId);
|
||||
if (existingLink is null)
|
||||
{
|
||||
context.LinkedChildren.Add(new LinkedChildEntity()
|
||||
{
|
||||
ParentId = item.Item.Id,
|
||||
ChildId = childId,
|
||||
ChildType = (DbLinkedChildType)linkedChild.Type,
|
||||
SortOrder = isPlaylist ? sortOrder : null
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existingLink.SortOrder = isPlaylist ? sortOrder : null;
|
||||
existingLink.ChildType = (DbLinkedChildType)linkedChild.Type;
|
||||
existingLinkedChildren.Remove(existingLink);
|
||||
}
|
||||
|
||||
sortOrder++;
|
||||
}
|
||||
}
|
||||
|
||||
if (existingLinkedChildren.Count > 0)
|
||||
var childIdsToCheck = resolvedChildren.Select(c => c.ChildId).Distinct().ToList();
|
||||
var existingChildIds = childIdsToCheck.Count > 0
|
||||
? context.BaseItems
|
||||
.Where(e => childIdsToCheck.Contains(e.Id))
|
||||
.Select(e => e.Id)
|
||||
.ToHashSet()
|
||||
: [];
|
||||
|
||||
var sortOrder = 0;
|
||||
foreach (var (linkedChild, childId) in resolvedChildren)
|
||||
{
|
||||
context.LinkedChildren.RemoveRange(existingLinkedChildren);
|
||||
if (!existingChildIds.Contains(childId))
|
||||
{
|
||||
#pragma warning disable CS0618 // Type or member is obsolete - legacy path is logged for diagnostics
|
||||
_logger.LogWarning(
|
||||
"Skipping LinkedChild for parent {ParentName} ({ParentId}): child item {ChildId} (path {ChildPath}) does not exist in database",
|
||||
item.Item.Name,
|
||||
item.Item.Id,
|
||||
childId,
|
||||
linkedChild.Path ?? "unknown");
|
||||
#pragma warning restore CS0618
|
||||
continue;
|
||||
}
|
||||
|
||||
context.LinkedChildren.Add(new LinkedChildEntity()
|
||||
{
|
||||
ParentId = item.Item.Id,
|
||||
ChildId = childId,
|
||||
ChildType = (DbLinkedChildType)linkedChild.Type,
|
||||
SortOrder = sortOrder
|
||||
});
|
||||
|
||||
sortOrder++;
|
||||
}
|
||||
}
|
||||
|
||||
if (item.Item is Video video)
|
||||
{
|
||||
var existingLinkedChildren = (allLinkedChildrenByParent.GetValueOrDefault(video.Id) ?? new List<LinkedChildEntity>())
|
||||
.Where(e => (int)e.ChildType == 2 || (int)e.ChildType == 3)
|
||||
.ToList();
|
||||
|
||||
var newLinkedChildren = new List<(Guid ChildId, LinkedChildType Type)>();
|
||||
|
||||
if (video.LocalAlternateVersions.Length > 0)
|
||||
@@ -577,7 +622,7 @@ public class ItemPersistenceService : IItemPersistenceService
|
||||
.ToHashSet()
|
||||
: [];
|
||||
|
||||
int sortOrder = 0;
|
||||
var sortOrder = 0;
|
||||
foreach (var (childId, childType) in newLinkedChildren)
|
||||
{
|
||||
if (!existingChildIds.Contains(childId))
|
||||
@@ -590,36 +635,27 @@ public class ItemPersistenceService : IItemPersistenceService
|
||||
continue;
|
||||
}
|
||||
|
||||
var existingLink = existingLinkedChildren.FirstOrDefault(e => e.ChildId == childId);
|
||||
if (existingLink is null)
|
||||
context.LinkedChildren.Add(new LinkedChildEntity
|
||||
{
|
||||
context.LinkedChildren.Add(new LinkedChildEntity
|
||||
{
|
||||
ParentId = video.Id,
|
||||
ChildId = childId,
|
||||
ChildType = (DbLinkedChildType)childType,
|
||||
SortOrder = sortOrder
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existingLink.ChildType = (DbLinkedChildType)childType;
|
||||
existingLink.SortOrder = sortOrder;
|
||||
existingLinkedChildren.Remove(existingLink);
|
||||
}
|
||||
ParentId = video.Id,
|
||||
ChildId = childId,
|
||||
ChildType = (DbLinkedChildType)childType,
|
||||
SortOrder = sortOrder
|
||||
});
|
||||
|
||||
sortOrder++;
|
||||
}
|
||||
|
||||
if (existingLinkedChildren.Count > 0)
|
||||
// A previously-linked LocalAlternateVersion that is no longer present becomes orphaned;
|
||||
var previousLinkedChildren = allLinkedChildrenByParent.GetValueOrDefault(video.Id);
|
||||
if (previousLinkedChildren is { Count: > 0 })
|
||||
{
|
||||
var orphanedLocalVersionIds = existingLinkedChildren
|
||||
.Where(e => e.ChildType == DbLinkedChildType.LocalAlternateVersion)
|
||||
var newChildIds = newLinkedChildren.Select(c => c.ChildId).ToHashSet();
|
||||
var orphanedLocalVersionIds = previousLinkedChildren
|
||||
.Where(e => e.ChildType == DbLinkedChildType.LocalAlternateVersion && !newChildIds.Contains(e.ChildId))
|
||||
.Select(e => e.ChildId)
|
||||
.ToList();
|
||||
|
||||
context.LinkedChildren.RemoveRange(existingLinkedChildren);
|
||||
|
||||
if (orphanedLocalVersionIds.Count > 0)
|
||||
{
|
||||
var orphanedItems = context.BaseItems
|
||||
|
||||
@@ -59,6 +59,25 @@ 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)
|
||||
{
|
||||
@@ -159,12 +178,16 @@ public class LinkedChildrenService : ILinkedChildrenService
|
||||
|
||||
if (existingLink is null)
|
||||
{
|
||||
var nextSortOrder = (context.LinkedChildren
|
||||
.Where(lc => lc.ParentId == parentId)
|
||||
.Max(lc => (int?)lc.SortOrder) ?? -1) + 1;
|
||||
|
||||
context.LinkedChildren.Add(new Jellyfin.Database.Implementations.Entities.LinkedChildEntity
|
||||
{
|
||||
ParentId = parentId,
|
||||
ChildId = childId,
|
||||
ChildType = dbChildType,
|
||||
SortOrder = null
|
||||
SortOrder = nextSortOrder
|
||||
});
|
||||
}
|
||||
else
|
||||
|
||||
@@ -98,7 +98,7 @@ public class NextUpService : INextUpService
|
||||
.Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
|
||||
lastWatchedBase = _queryHelpers.ApplyAccessFiltering(context, lastWatchedBase, filter);
|
||||
|
||||
// Use lightweight projection + client-side grouping to avoid correlated scalar subquery
|
||||
// Use lightweight projection + client-side dedup to avoid the correlated scalar subquery
|
||||
// per group that EF generates for GroupBy+OrderByDescending+FirstOrDefault.
|
||||
var allPlayedLite = lastWatchedBase
|
||||
.Select(e => new
|
||||
@@ -110,15 +110,11 @@ public class NextUpService : INextUpService
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var lastWatchedInfo = new Dictionary<string, Guid>();
|
||||
foreach (var group in allPlayedLite.GroupBy(e => e.SeriesPresentationUniqueKey))
|
||||
{
|
||||
var lastWatched = group
|
||||
.OrderByDescending(e => e.ParentIndexNumber)
|
||||
.ThenByDescending(e => e.IndexNumber)
|
||||
.First();
|
||||
lastWatchedInfo[group.Key!] = lastWatched.Id;
|
||||
}
|
||||
var lastWatchedInfo = allPlayedLite
|
||||
.OrderByDescending(e => e.ParentIndexNumber)
|
||||
.ThenByDescending(e => e.IndexNumber)
|
||||
.DistinctBy(e => e.SeriesPresentationUniqueKey)
|
||||
.ToDictionary(e => e.SeriesPresentationUniqueKey!, e => e.Id);
|
||||
|
||||
Dictionary<string, Guid> lastWatchedByDateInfo = new();
|
||||
if (includeWatchedForRewatching)
|
||||
@@ -144,11 +140,10 @@ public class NextUpService : INextUpService
|
||||
(e, ud) => new { EpisodeId = e.Id, e.SeriesPresentationUniqueKey, ud.LastPlayedDate })
|
||||
.ToList();
|
||||
|
||||
foreach (var group in playedWithDates.GroupBy(x => x.SeriesPresentationUniqueKey))
|
||||
{
|
||||
var mostRecent = group.OrderByDescending(x => x.LastPlayedDate).First();
|
||||
lastWatchedByDateInfo[group.Key!] = mostRecent.EpisodeId;
|
||||
}
|
||||
lastWatchedByDateInfo = playedWithDates
|
||||
.OrderByDescending(x => x.LastPlayedDate)
|
||||
.DistinctBy(x => x.SeriesPresentationUniqueKey)
|
||||
.ToDictionary(x => x.SeriesPresentationUniqueKey!, x => x.EpisodeId);
|
||||
}
|
||||
|
||||
var allLastWatchedIds = lastWatchedInfo.Values
|
||||
|
||||
@@ -29,19 +29,30 @@ public static class OrderMapper
|
||||
/// <returns>Func to be executed later for sorting query.</returns>
|
||||
public static Expression<Func<BaseItemEntity, object?>> MapOrderByField(ItemSortBy sortBy, InternalItemsQuery query, JellyfinDbContext jellyfinDbContext)
|
||||
{
|
||||
if (sortBy == ItemSortBy.DatePlayed)
|
||||
{
|
||||
// An item's played date is the newest of its own progress and that of its alternate versions,
|
||||
// which track progress under their own ids. Matching both in one predicate ORs them together,
|
||||
// which no index can serve: the user's whole UserData table gets scanned per sorted row.
|
||||
// Two indexed lookups combined by MAX cost a seek each instead.
|
||||
var userData = query.User is null
|
||||
? jellyfinDbContext.UserData
|
||||
: jellyfinDbContext.UserData.Where(w => w.UserId == query.User.Id);
|
||||
|
||||
return e => userData
|
||||
.Where(w => w.ItemId == e.Id)
|
||||
.Select(w => w.LastPlayedDate)
|
||||
.Concat(userData
|
||||
.Where(w => w.Item!.PrimaryVersionId == e.Id)
|
||||
.Select(w => w.LastPlayedDate))
|
||||
.Max();
|
||||
}
|
||||
|
||||
return (sortBy, query.User) switch
|
||||
{
|
||||
(ItemSortBy.AirTime, _) => e => e.SortName,
|
||||
(ItemSortBy.Runtime, _) => e => e.RunTimeTicks,
|
||||
(ItemSortBy.Random, _) => e => EF.Functions.Random(),
|
||||
(ItemSortBy.DatePlayed, not null) => e =>
|
||||
jellyfinDbContext.UserData
|
||||
.Where(w => w.UserId == query.User.Id && (w.ItemId == e.Id || w.Item!.PrimaryVersionId == e.Id))
|
||||
.Max(f => f.LastPlayedDate),
|
||||
(ItemSortBy.DatePlayed, null) => e =>
|
||||
jellyfinDbContext.UserData
|
||||
.Where(w => w.ItemId == e.Id || w.Item!.PrimaryVersionId == e.Id)
|
||||
.Max(f => f.LastPlayedDate),
|
||||
(ItemSortBy.PlayCount, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).FirstOrDefault()!.PlayCount,
|
||||
(ItemSortBy.IsFavoriteOrLiked, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).Select(f => (bool?)f.IsFavorite).FirstOrDefault() ?? false,
|
||||
(ItemSortBy.IsFolder, _) => e => e.IsFolder,
|
||||
@@ -57,7 +68,7 @@ public static class OrderMapper
|
||||
(ItemSortBy.DateCreated, _) => e => e.DateCreated,
|
||||
(ItemSortBy.PremiereDate, _) => e => e.PremiereDate ?? (e.ProductionYear.HasValue ? DateTime.MinValue.AddYears(e.ProductionYear.Value - 1) : null),
|
||||
(ItemSortBy.StartDate, _) => e => e.StartDate,
|
||||
(ItemSortBy.Name, _) => e => e.SortName,
|
||||
(ItemSortBy.Name, _) => e => e.CleanName,
|
||||
(ItemSortBy.CommunityRating, _) => e => e.CommunityRating,
|
||||
(ItemSortBy.ProductionYear, _) => e => e.ProductionYear,
|
||||
(ItemSortBy.CriticRating, _) => e => e.CriticRating,
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,7 +89,11 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
|
||||
public IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery filter)
|
||||
{
|
||||
using var context = _dbProvider.CreateDbContext();
|
||||
var dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter).Select(e => e.Name).Distinct();
|
||||
|
||||
IQueryable<string> dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter)
|
||||
.Select(e => e.Name)
|
||||
.Distinct()
|
||||
.OrderBy(e => e);
|
||||
|
||||
if (filter.StartIndex.HasValue && filter.StartIndex > 0)
|
||||
{
|
||||
@@ -88,7 +102,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
|
||||
|
||||
if (filter.Limit > 0)
|
||||
{
|
||||
dbQuery = dbQuery.OrderBy(e => e).Take(filter.Limit);
|
||||
dbQuery = dbQuery.Take(filter.Limit);
|
||||
}
|
||||
|
||||
return dbQuery.ToArray();
|
||||
@@ -103,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();
|
||||
@@ -118,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()
|
||||
{
|
||||
@@ -143,18 +186,10 @@ 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++;
|
||||
}
|
||||
@@ -201,9 +236,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,
|
||||
@@ -236,13 +328,25 @@ 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())
|
||||
|
||||
@@ -87,8 +87,9 @@ public static class StorageHelper
|
||||
/// </summary>
|
||||
private static string ResolvePath(string path)
|
||||
{
|
||||
var parts = path.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries);
|
||||
var current = Path.DirectorySeparatorChar.ToString();
|
||||
var root = Path.GetPathRoot(path) ?? Path.DirectorySeparatorChar.ToString();
|
||||
var parts = path.Substring(root.Length).Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries);
|
||||
var current = root;
|
||||
foreach (var part in parts)
|
||||
{
|
||||
current = Path.Combine(current, part);
|
||||
|
||||
@@ -193,10 +193,15 @@ internal class JellyfinMigrationService
|
||||
{
|
||||
var historyRepository = dbContext.GetService<IHistoryRepository>();
|
||||
var migrationsAssembly = dbContext.GetService<IMigrationsAssembly>();
|
||||
(string Key, IInternalMigration Migration)[] migrations = [];
|
||||
var completedMigrations = 0;
|
||||
string? lastMigrationKey = null;
|
||||
|
||||
do
|
||||
{ // migrations may alter the migration state. Reevaluate the applicable migrations after every stage ran until there are no more to apply.
|
||||
while (true)
|
||||
{
|
||||
// A single migration can change which migrations still apply: IMigrator.MigrateAsync treats its argument as the
|
||||
// state to end up in, so it reverts everything applied after it, and a reverted migration can take code migrations
|
||||
// with it (AddNormalizedUsername.Down drops the UpdateNormalizedUsername history row). Anything computed before
|
||||
// that point is stale, so only ever run the next migration and then work out the pending set again.
|
||||
var appliedMigrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
|
||||
var pendingCodeMigrations = migrationStage
|
||||
.Where(e => appliedMigrations.All(f => f.MigrationId != e.BuildCodeMigrationId()))
|
||||
@@ -212,73 +217,86 @@ internal class JellyfinMigrationService
|
||||
}
|
||||
|
||||
(string Key, IInternalMigration Migration)[] pendingMigrations = [.. pendingCodeMigrations, .. pendingDatabaseMigrations];
|
||||
logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingCodeMigrations.Length, stage);
|
||||
migrations = pendingMigrations.OrderBy(e => e.Key).ToArray();
|
||||
|
||||
var migrationIndex = 0;
|
||||
foreach (var item in migrations)
|
||||
if (pendingMigrations.Length == 0)
|
||||
{
|
||||
// Surface generic "Running migration X of Y" progress in the always-visible startup UI header.
|
||||
SetupServer.ReportActivity(StartupActivity.Migration(++migrationIndex, migrations.Length));
|
||||
var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}");
|
||||
try
|
||||
{
|
||||
migrationLogger.LogInformation("Perform migration {Name}", item.Key);
|
||||
await item.Migration.PerformAsync(migrationLogger).ConfigureAwait(false);
|
||||
migrationLogger.LogInformation("Migration {Name} was successfully applied", item.Key);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
migrationLogger.LogCritical("Error: {Error}", ex.Message);
|
||||
migrationLogger.LogError(ex, "Migration {Name} failed", item.Key);
|
||||
break;
|
||||
}
|
||||
|
||||
if (_backupKey != default && _backupService is not null && _jellyfinDatabaseProvider is not null)
|
||||
if (completedMigrations == 0)
|
||||
{
|
||||
logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingMigrations.Length, stage);
|
||||
}
|
||||
|
||||
var item = pendingMigrations.OrderBy(e => e.Key, StringComparer.Ordinal).First();
|
||||
if (string.Equals(item.Key, lastMigrationKey, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException($"Migration {item.Key} ran but did not record itself as applied and would repeat indefinitely.");
|
||||
}
|
||||
|
||||
lastMigrationKey = item.Key;
|
||||
|
||||
// Surface generic "Running migration X of Y" progress in the always-visible startup UI header.
|
||||
SetupServer.ReportActivity(StartupActivity.Migration(completedMigrations + 1, completedMigrations + pendingMigrations.Length));
|
||||
var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}");
|
||||
try
|
||||
{
|
||||
migrationLogger.LogInformation("Perform migration {Name}", item.Key);
|
||||
await item.Migration.PerformAsync(migrationLogger).ConfigureAwait(false);
|
||||
migrationLogger.LogInformation("Migration {Name} was successfully applied", item.Key);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
migrationLogger.LogCritical("Error: {Error}", ex.Message);
|
||||
migrationLogger.LogError(ex, "Migration {Name} failed", item.Key);
|
||||
|
||||
if (_backupKey != default && _backupService is not null && _jellyfinDatabaseProvider is not null)
|
||||
{
|
||||
if (_backupKey.LibraryDb is not null)
|
||||
{
|
||||
if (_backupKey.LibraryDb is not null)
|
||||
migrationLogger.LogInformation("Attempt to rollback librarydb.");
|
||||
try
|
||||
{
|
||||
migrationLogger.LogInformation("Attempt to rollback librarydb.");
|
||||
try
|
||||
{
|
||||
var libraryDbPath = Path.Combine(_applicationPaths.DataPath, DbFilename);
|
||||
File.Move(_backupKey.LibraryDb, libraryDbPath, true);
|
||||
}
|
||||
catch (Exception inner)
|
||||
{
|
||||
migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.LibraryDb);
|
||||
}
|
||||
var libraryDbPath = Path.Combine(_applicationPaths.DataPath, DbFilename);
|
||||
File.Move(_backupKey.LibraryDb, libraryDbPath, true);
|
||||
}
|
||||
|
||||
if (_backupKey.JellyfinDb is not null)
|
||||
catch (Exception inner)
|
||||
{
|
||||
migrationLogger.LogInformation("Attempt to rollback JellyfinDb.");
|
||||
try
|
||||
{
|
||||
await _jellyfinDatabaseProvider.RestoreBackupFast(_backupKey.JellyfinDb, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception inner)
|
||||
{
|
||||
migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.JellyfinDb);
|
||||
}
|
||||
}
|
||||
|
||||
if (_backupKey.FullBackup is not null)
|
||||
{
|
||||
migrationLogger.LogInformation("Attempt to rollback from backup.");
|
||||
try
|
||||
{
|
||||
await _backupService.RestoreBackupAsync(_backupKey.FullBackup.Path).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception inner)
|
||||
{
|
||||
migrationLogger.LogCritical(inner, "Could not rollback from backup {Backup}. Manual intervention might be required to restore a operational state.", _backupKey.FullBackup.Path);
|
||||
}
|
||||
migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.LibraryDb);
|
||||
}
|
||||
}
|
||||
|
||||
throw;
|
||||
if (_backupKey.JellyfinDb is not null)
|
||||
{
|
||||
migrationLogger.LogInformation("Attempt to rollback JellyfinDb.");
|
||||
try
|
||||
{
|
||||
await _jellyfinDatabaseProvider.RestoreBackupFast(_backupKey.JellyfinDb, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception inner)
|
||||
{
|
||||
migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.JellyfinDb);
|
||||
}
|
||||
}
|
||||
|
||||
if (_backupKey.FullBackup is not null)
|
||||
{
|
||||
migrationLogger.LogInformation("Attempt to rollback from backup.");
|
||||
try
|
||||
{
|
||||
await _backupService.RestoreBackupAsync(_backupKey.FullBackup.Path).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception inner)
|
||||
{
|
||||
migrationLogger.LogCritical(inner, "Could not rollback from backup {Backup}. Manual intervention might be required to restore a operational state.", _backupKey.FullBackup.Path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
} while (migrations.Length != 0);
|
||||
|
||||
completedMigrations++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Jellyfin.Data.Enums;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Playlists;
|
||||
|
||||
namespace Jellyfin.Server.Migrations.Routines;
|
||||
|
||||
/// <summary>
|
||||
/// Remove duplicate playlist entries.
|
||||
/// </summary>
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
[JellyfinMigration("2025-04-20T19:00:00", nameof(RemoveDuplicatePlaylistChildren), "96C156A2-7A13-4B3B-A8B8-FB80C94D20C0")]
|
||||
internal class RemoveDuplicatePlaylistChildren : IMigrationRoutine
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IPlaylistManager _playlistManager;
|
||||
|
||||
public RemoveDuplicatePlaylistChildren(
|
||||
ILibraryManager libraryManager,
|
||||
IPlaylistManager playlistManager)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_playlistManager = playlistManager;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Perform()
|
||||
{
|
||||
var playlists = _libraryManager.GetItemList(new InternalItemsQuery
|
||||
{
|
||||
IncludeItemTypes = [BaseItemKind.Playlist]
|
||||
})
|
||||
.Cast<Playlist>()
|
||||
.Where(p => !p.OpenAccess || !p.OwnerUserId.Equals(Guid.Empty))
|
||||
.ToArray();
|
||||
|
||||
if (playlists.Length > 0)
|
||||
{
|
||||
foreach (var playlist in playlists)
|
||||
{
|
||||
var linkedChildren = playlist.LinkedChildren;
|
||||
if (linkedChildren.Length > 0)
|
||||
{
|
||||
var newLinkedChildren = linkedChildren
|
||||
.Where(c => c.ItemId is null || c.ItemId.Value.Equals(Guid.Empty))
|
||||
.Concat(linkedChildren
|
||||
.Where(c => c.ItemId.HasValue && !c.ItemId.Value.Equals(Guid.Empty))
|
||||
.DistinctBy(c => c.ItemId))
|
||||
.ToArray();
|
||||
playlist.LinkedChildren = newLinkedChildren;
|
||||
playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult();
|
||||
_playlistManager.SavePlaylistFile(playlist);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
|
||||
var itemsWithData = context.BaseItems
|
||||
.Where(b => b.Data != null && (containerTypes.Contains(b.Type) || videoTypes.Contains(b.Type)))
|
||||
.Select(b => new { b.Id, b.Data, b.Type })
|
||||
.Select(b => new { b.Id, b.Data, b.Type, b.Path, b.IsFolder })
|
||||
.ToList();
|
||||
|
||||
_logger.LogInformation("Found {Count} potential items with LinkedChildren data to process.", itemsWithData.Count);
|
||||
@@ -74,6 +74,15 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
.GroupBy(b => b.Path!)
|
||||
.ToDictionary(g => g.Key, g => g.First().Id);
|
||||
|
||||
// Needed to tell a stale cached ItemId apart from one that still points at a real item.
|
||||
var allItemIds = context.BaseItems.Select(b => b.Id).ToHashSet();
|
||||
|
||||
var playlistParentIds = itemsWithData
|
||||
.Where(b => b.Type == "MediaBrowser.Controller.Playlists.Playlist")
|
||||
.Select(b => b.Id)
|
||||
.ToHashSet();
|
||||
|
||||
var droppedChildren = 0;
|
||||
var linkedChildrenToAdd = new List<LinkedChildEntity>();
|
||||
var processedCount = 0;
|
||||
const int progressLogStep = 1000;
|
||||
@@ -100,7 +109,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
// Handle Video alternate versions
|
||||
if (isVideo)
|
||||
{
|
||||
ProcessVideoAlternateVersions(doc.RootElement, item.Id, pathToIdMap, linkedChildrenToAdd);
|
||||
ProcessVideoAlternateVersions(doc.RootElement, item.Id, pathToIdMap, allItemIds, linkedChildrenToAdd);
|
||||
}
|
||||
|
||||
// Handle LinkedChildren (for containers and other items)
|
||||
@@ -110,46 +119,22 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
continue;
|
||||
}
|
||||
|
||||
var isPlaylist = item.Type == "MediaBrowser.Controller.Playlists.Playlist";
|
||||
// Legacy entries may hold a path relative to the container that holds them, so the
|
||||
// container's own location has to be a real path, not a virtual one.
|
||||
var itemPath = item.Path is null ? null : _appHost.ExpandVirtualPath(item.Path);
|
||||
var containingFolderPath = item.IsFolder ? itemPath : Path.GetDirectoryName(itemPath);
|
||||
var sortOrder = 0;
|
||||
foreach (var childElement in linkedChildrenElement.EnumerateArray())
|
||||
{
|
||||
Guid? childId = null;
|
||||
if (childElement.TryGetProperty("ItemId", out var itemIdProp) && itemIdProp.ValueKind != JsonValueKind.Null)
|
||||
{
|
||||
var itemIdStr = itemIdProp.GetString();
|
||||
if (!string.IsNullOrEmpty(itemIdStr) && Guid.TryParse(itemIdStr, out var parsedId))
|
||||
{
|
||||
childId = parsedId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!childId.HasValue || childId.Value.IsEmpty())
|
||||
{
|
||||
if (childElement.TryGetProperty("Path", out var pathProp))
|
||||
{
|
||||
var path = pathProp.GetString();
|
||||
if (!string.IsNullOrEmpty(path) && pathToIdMap.TryGetValue(path, out var resolvedId))
|
||||
{
|
||||
childId = resolvedId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!childId.HasValue || childId.Value.IsEmpty())
|
||||
{
|
||||
if (childElement.TryGetProperty("LibraryItemId", out var libIdProp))
|
||||
{
|
||||
var libIdStr = libIdProp.GetString();
|
||||
if (!string.IsNullOrEmpty(libIdStr) && Guid.TryParse(libIdStr, out var parsedLibId))
|
||||
{
|
||||
childId = parsedLibId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!childId.HasValue || childId.Value.IsEmpty())
|
||||
var childId = ResolveChildId(childElement, containingFolderPath, pathToIdMap, allItemIds);
|
||||
if (!childId.HasValue)
|
||||
{
|
||||
droppedChildren++;
|
||||
_logger.LogWarning(
|
||||
"Dropping unresolvable LinkedChild of {ParentId}: ItemId {ItemId}, path {ChildPath}",
|
||||
item.Id,
|
||||
GetStringProperty(childElement, "ItemId") ?? "none",
|
||||
GetStringProperty(childElement, "Path") ?? "none");
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -175,7 +160,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
ParentId = item.Id,
|
||||
ChildId = childId.Value,
|
||||
ChildType = childType,
|
||||
SortOrder = isPlaylist ? sortOrder : null
|
||||
SortOrder = sortOrder
|
||||
});
|
||||
|
||||
sortOrder++;
|
||||
@@ -197,23 +182,37 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
.Select(lc => new { lc.ParentId, lc.ChildId })
|
||||
.ToHashSet();
|
||||
|
||||
// A playlist may list the same child more than once, so it cannot be keyed by
|
||||
// (ParentId, ChildId): skip a playlist wholesale if it already has rows instead, which
|
||||
// keeps the routine re-runnable without collapsing repeated entries.
|
||||
var populatedParentIds = context.LinkedChildren
|
||||
.Select(lc => lc.ParentId)
|
||||
.Distinct()
|
||||
.ToHashSet();
|
||||
|
||||
var toInsert = linkedChildrenToAdd
|
||||
.Where(lc => !existingKeys.Contains(new { lc.ParentId, lc.ChildId }))
|
||||
.Where(lc => playlistParentIds.Contains(lc.ParentId)
|
||||
? !populatedParentIds.Contains(lc.ParentId)
|
||||
: !existingKeys.Contains(new { lc.ParentId, lc.ChildId }))
|
||||
.ToList();
|
||||
|
||||
if (toInsert.Count > 0)
|
||||
{
|
||||
// Deduplicate by composite key (ParentId, ChildId)
|
||||
// Every container type other than a playlist keeps a single entry per child.
|
||||
// Priority: LocalAlternateVersion > LinkedAlternateVersion > Other
|
||||
toInsert = toInsert
|
||||
.OrderBy(lc => lc.ChildType switch
|
||||
{
|
||||
LinkedChildType.LocalAlternateVersion => 0,
|
||||
LinkedChildType.LinkedAlternateVersion => 1,
|
||||
_ => 2
|
||||
})
|
||||
.DistinctBy(lc => new { lc.ParentId, lc.ChildId })
|
||||
.ToList();
|
||||
toInsert =
|
||||
[
|
||||
.. toInsert.Where(lc => playlistParentIds.Contains(lc.ParentId)),
|
||||
.. toInsert
|
||||
.Where(lc => !playlistParentIds.Contains(lc.ParentId))
|
||||
.OrderBy(lc => lc.ChildType switch
|
||||
{
|
||||
LinkedChildType.LocalAlternateVersion => 0,
|
||||
LinkedChildType.LinkedAlternateVersion => 1,
|
||||
_ => 2
|
||||
})
|
||||
.DistinctBy(lc => new { lc.ParentId, lc.ChildId })
|
||||
];
|
||||
|
||||
var childIds = toInsert.Select(lc => lc.ChildId).Distinct().ToList();
|
||||
var existingChildIds = context.BaseItems
|
||||
@@ -267,7 +266,10 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
_logger.LogInformation("No LinkedChildren data found to migrate.");
|
||||
}
|
||||
|
||||
_logger.LogInformation("LinkedChildren migration completed. Processed {Count} items.", processedCount);
|
||||
_logger.LogInformation(
|
||||
"LinkedChildren migration completed. Processed {Count} items, dropped {DroppedCount} unresolvable children.",
|
||||
processedCount,
|
||||
droppedChildren);
|
||||
|
||||
CleanupWrongTypeAlternateVersions(context);
|
||||
CleanupOrphanedAlternateVersionBaseItems(context);
|
||||
@@ -418,6 +420,12 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
|
||||
var internalMetadataPath = _appPaths.InternalMetadataPath;
|
||||
|
||||
// An item outside every library location is normally left over from a removed media path, but
|
||||
// it looks exactly the same as one whose storage failed to mount (a wrong bind mount on the
|
||||
// first container start, for example). Only act on it while every location is readable.
|
||||
var canRemoveUnrootedItems = inaccessiblePaths.Count == 0;
|
||||
var skippedUnrootedItems = 0;
|
||||
|
||||
var staleIds = new List<Guid>();
|
||||
foreach (var item in itemsWithPaths)
|
||||
{
|
||||
@@ -436,6 +444,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
// Directory check covers BDMV/DVD items whose Path points to a folder
|
||||
if (!File.Exists(path) && !Directory.Exists(path))
|
||||
{
|
||||
_logger.LogDebug("Removing item {ItemId}: file {Path} no longer exists.", item.Id, path);
|
||||
staleIds.Add(item.Id);
|
||||
}
|
||||
}
|
||||
@@ -443,12 +452,28 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
{
|
||||
// Item is not under ANY library location (accessible or not) —
|
||||
// it's orphaned from all libraries (e.g. media path was removed from config)
|
||||
staleIds.Add(item.Id);
|
||||
if (canRemoveUnrootedItems)
|
||||
{
|
||||
_logger.LogDebug("Removing item {ItemId}: path {Path} is outside every library location.", item.Id, path);
|
||||
staleIds.Add(item.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
skippedUnrootedItems++;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise: item is under an inaccessible location — skip (storage may be offline)
|
||||
}
|
||||
|
||||
if (skippedUnrootedItems > 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Keeping {Count} items that are outside every library location because {LocationCount} library location(s) are currently unavailable.",
|
||||
skippedUnrootedItems,
|
||||
inaccessiblePaths.Count);
|
||||
}
|
||||
|
||||
if (staleIds.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No stale items found.");
|
||||
@@ -518,18 +543,86 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
orphanedLinkedChildren.AddRange(orphanedByParent);
|
||||
}
|
||||
|
||||
// Remove all orphaned records
|
||||
var distinctOrphaned = orphanedLinkedChildren.DistinctBy(lc => new { lc.ParentId, lc.ChildId }).ToList();
|
||||
// Remove all orphaned records. Both queries can return the same row, and a playlist may hold
|
||||
// several rows for one child, so the position is what identifies an entry here.
|
||||
var distinctOrphaned = orphanedLinkedChildren.DistinctBy(lc => new { lc.ParentId, lc.SortOrder }).ToList();
|
||||
context.LinkedChildren.RemoveRange(distinctOrphaned);
|
||||
context.SaveChanges();
|
||||
|
||||
_logger.LogInformation("Successfully removed {Count} orphaned LinkedChildren records.", distinctOrphaned.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the item a legacy LinkedChild entry points at.
|
||||
/// </summary>
|
||||
private static Guid? ResolveChildId(
|
||||
JsonElement childElement,
|
||||
string? containingFolderPath,
|
||||
Dictionary<string, Guid> pathToIdMap,
|
||||
HashSet<Guid> allItemIds)
|
||||
{
|
||||
// Pre-12 data only cached ItemId and re-resolved it from the path whenever the cached value
|
||||
// went stale (BaseItem.GetLinkedChild in 10.x). An id that no longer exists must therefore
|
||||
// fall through to the path, or the entry is lost even though its file is still in the library.
|
||||
if (TryGetGuidProperty(childElement, "ItemId", out var itemId) && allItemIds.Contains(itemId))
|
||||
{
|
||||
return itemId;
|
||||
}
|
||||
|
||||
var path = GetStringProperty(childElement, "Path");
|
||||
if (!string.IsNullOrEmpty(path))
|
||||
{
|
||||
if (pathToIdMap.TryGetValue(path, out var idByPath))
|
||||
{
|
||||
return idByPath;
|
||||
}
|
||||
|
||||
// 10.x resolved entries relative to the container that holds them.
|
||||
if (!Path.IsPathRooted(path) && !string.IsNullOrEmpty(containingFolderPath))
|
||||
{
|
||||
string? absolutePath = null;
|
||||
try
|
||||
{
|
||||
absolutePath = Path.GetFullPath(Path.Combine(containingFolderPath, path));
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// Malformed path, nothing to resolve.
|
||||
}
|
||||
|
||||
if (absolutePath is not null && pathToIdMap.TryGetValue(absolutePath, out var idByAbsolutePath))
|
||||
{
|
||||
return idByAbsolutePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (TryGetGuidProperty(childElement, "LibraryItemId", out var libraryItemId) && allItemIds.Contains(libraryItemId))
|
||||
{
|
||||
return libraryItemId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? GetStringProperty(JsonElement element, string propertyName)
|
||||
=> element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String
|
||||
? property.GetString()
|
||||
: null;
|
||||
|
||||
private static bool TryGetGuidProperty(JsonElement element, string propertyName, out Guid value)
|
||||
{
|
||||
value = Guid.Empty;
|
||||
var raw = GetStringProperty(element, propertyName);
|
||||
|
||||
return !string.IsNullOrEmpty(raw) && Guid.TryParse(raw, out value) && !value.IsEmpty();
|
||||
}
|
||||
|
||||
private void ProcessVideoAlternateVersions(
|
||||
JsonElement root,
|
||||
Guid parentId,
|
||||
Dictionary<string, Guid> pathToIdMap,
|
||||
HashSet<Guid> allItemIds,
|
||||
List<LinkedChildEntity> linkedChildrenToAdd)
|
||||
{
|
||||
int sortOrder = 0;
|
||||
@@ -582,45 +675,8 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
{
|
||||
foreach (var linkedChildElement in linkedAlternateVersionsElement.EnumerateArray())
|
||||
{
|
||||
Guid? childId = null;
|
||||
|
||||
// Try to get ItemId
|
||||
if (linkedChildElement.TryGetProperty("ItemId", out var itemIdProp) && itemIdProp.ValueKind != JsonValueKind.Null)
|
||||
{
|
||||
var itemIdStr = itemIdProp.GetString();
|
||||
if (!string.IsNullOrEmpty(itemIdStr) && Guid.TryParse(itemIdStr, out var parsedId))
|
||||
{
|
||||
childId = parsedId;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get from Path if ItemId not available
|
||||
if (!childId.HasValue || childId.Value.IsEmpty())
|
||||
{
|
||||
if (linkedChildElement.TryGetProperty("Path", out var pathProp))
|
||||
{
|
||||
var path = pathProp.GetString();
|
||||
if (!string.IsNullOrEmpty(path) && pathToIdMap.TryGetValue(path, out var resolvedId))
|
||||
{
|
||||
childId = resolvedId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try LibraryItemId as fallback
|
||||
if (!childId.HasValue || childId.Value.IsEmpty())
|
||||
{
|
||||
if (linkedChildElement.TryGetProperty("LibraryItemId", out var libIdProp))
|
||||
{
|
||||
var libIdStr = libIdProp.GetString();
|
||||
if (!string.IsNullOrEmpty(libIdStr) && Guid.TryParse(libIdStr, out var parsedLibId))
|
||||
{
|
||||
childId = parsedLibId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!childId.HasValue || childId.Value.IsEmpty())
|
||||
var childId = ResolveChildId(linkedChildElement, null, pathToIdMap, allItemIds);
|
||||
if (!childId.HasValue)
|
||||
{
|
||||
_logger.LogWarning("Could not resolve LinkedAlternateVersion child ID for parent {ParentId}", parentId);
|
||||
continue;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Extensions;
|
||||
using Jellyfin.Server.ServerSetupApp;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Server.Migrations.Routines;
|
||||
|
||||
/// <summary>
|
||||
/// Migration to recompute the SortName of all items that have a forced sort name.
|
||||
/// </summary>
|
||||
[JellyfinMigration("2026-07-22T12:00:00", nameof(RefreshForcedSortNames))]
|
||||
[JellyfinMigrationBackup(JellyfinDb = true)]
|
||||
public class RefreshForcedSortNames : IAsyncMigrationRoutine
|
||||
{
|
||||
private readonly IStartupLogger<RefreshForcedSortNames> _logger;
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||
private readonly IServerConfigurationManager _configurationManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RefreshForcedSortNames"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="dbProvider">Instance of the <see cref="IDbContextFactory{JellyfinDbContext}"/> interface.</param>
|
||||
/// <param name="configurationManager">The server configuration manager providing the sort rules.</param>
|
||||
public RefreshForcedSortNames(
|
||||
IStartupLogger<RefreshForcedSortNames> logger,
|
||||
IDbContextFactory<JellyfinDbContext> dbProvider,
|
||||
IServerConfigurationManager configurationManager)
|
||||
{
|
||||
_logger = logger;
|
||||
_dbProvider = dbProvider;
|
||||
_configurationManager = configurationManager;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task PerformAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
const int Limit = 10000;
|
||||
int itemCount = 0;
|
||||
|
||||
var configuration = _configurationManager.Configuration;
|
||||
// Only the Person type disables alphanumeric sorting; everything else uses the cleaning rules.
|
||||
var personType = typeof(Person).ToString();
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
using var context = _dbProvider.CreateDbContext();
|
||||
var records = context.BaseItems.Count(b => !string.IsNullOrEmpty(b.ForcedSortName));
|
||||
_logger.LogInformation("Refreshing SortName for {Count} library items with a forced sort name", records);
|
||||
|
||||
var processedInPartition = 0;
|
||||
|
||||
await foreach (var item in context.BaseItems
|
||||
.Where(b => !string.IsNullOrEmpty(b.ForcedSortName))
|
||||
.OrderBy(e => e.Id)
|
||||
.WithPartitionProgress((partition) => _logger.LogInformation("Processed: {Offset}/{Total} - Updated: {UpdatedCount} - Time: {Elapsed}", partition * Limit, records, itemCount, sw.Elapsed))
|
||||
.PartitionEagerAsync(Limit, cancellationToken)
|
||||
.WithCancellation(cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
try
|
||||
{
|
||||
var enableAlphaNumericSorting = !string.Equals(item.Type, personType, StringComparison.Ordinal);
|
||||
var newSortName = BaseItem.GetSortName(item.ForcedSortName!, enableAlphaNumericSorting, configuration);
|
||||
if (!string.Equals(newSortName, item.SortName, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Updating SortName for item {Id}: '{OldValue}' -> '{NewValue}'",
|
||||
item.Id,
|
||||
item.SortName,
|
||||
newSortName);
|
||||
item.SortName = newSortName;
|
||||
itemCount++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to update SortName for item {Id} ({Name})", item.Id, item.Name);
|
||||
}
|
||||
|
||||
processedInPartition++;
|
||||
|
||||
if (processedInPartition >= Limit)
|
||||
{
|
||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
// Clear tracked entities to avoid memory growth across partitions
|
||||
context.ChangeTracker.Clear();
|
||||
processedInPartition = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Save any remaining changes after the loop
|
||||
if (processedInPartition > 0)
|
||||
{
|
||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
context.ChangeTracker.Clear();
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Refreshed SortName for {UpdatedCount} out of {TotalCount} items in {Time}",
|
||||
itemCount,
|
||||
records,
|
||||
sw.Elapsed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Server.ServerSetupApp;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
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.
|
||||
/// </summary>
|
||||
[JellyfinMigration("2026-07-23T12:00:00", nameof(RecomputeSeriesPresentationKey))]
|
||||
[JellyfinMigrationBackup(JellyfinDb = true)]
|
||||
internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine
|
||||
{
|
||||
private readonly IStartupLogger<RecomputeSeriesPresentationKey> _logger;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RecomputeSeriesPresentationKey"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The startup logger.</param>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <param name="dbProvider">The database context factory.</param>
|
||||
public RecomputeSeriesPresentationKey(
|
||||
IStartupLogger<RecomputeSeriesPresentationKey> logger,
|
||||
ILibraryManager libraryManager,
|
||||
IDbContextFactory<JellyfinDbContext> dbProvider)
|
||||
{
|
||||
_logger = logger;
|
||||
_libraryManager = libraryManager;
|
||||
_dbProvider = dbProvider;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task PerformAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var series = _libraryManager.GetItemList(new InternalItemsQuery
|
||||
{
|
||||
IncludeItemTypes = [BaseItemKind.Series]
|
||||
}).OfType<Series>().ToArray();
|
||||
|
||||
_logger.LogInformation("Recomputing presentation unique key for {Count} series", series.Length);
|
||||
|
||||
const int ProgressInterval = 250;
|
||||
var sw = Stopwatch.StartNew();
|
||||
var processed = 0;
|
||||
var updated = 0;
|
||||
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
foreach (var item in series)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (++processed % ProgressInterval == 0)
|
||||
{
|
||||
_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))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Write only the changed column instead of re-persisting the whole item.
|
||||
var id = item.Id;
|
||||
await dbContext.BaseItems
|
||||
.Where(e => e.Id.Equals(id))
|
||||
.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);
|
||||
}
|
||||
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Recomputed presentation unique key for {Updated} of {Count} series in {Elapsed}", updated, series.Length, sw.Elapsed);
|
||||
}
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Xml;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using MediaBrowser.Controller;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Server.Migrations.Routines;
|
||||
|
||||
/// <summary>
|
||||
/// Restores playlist entries from playlist.xml for playlists that lost all of their children.
|
||||
/// </summary>
|
||||
[JellyfinMigration("2026-07-29T12:00:00", nameof(RestorePlaylistChildrenFromMetadata))]
|
||||
internal class RestorePlaylistChildrenFromMetadata : IDatabaseMigrationRoutine
|
||||
{
|
||||
private const string PlaylistTypeName = "MediaBrowser.Controller.Playlists.Playlist";
|
||||
private const string PlaylistFileName = "playlist.xml";
|
||||
|
||||
private readonly ILogger<RestorePlaylistChildrenFromMetadata> _logger;
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||
private readonly IServerApplicationHost _appHost;
|
||||
|
||||
public RestorePlaylistChildrenFromMetadata(
|
||||
ILoggerFactory loggerFactory,
|
||||
IDbContextFactory<JellyfinDbContext> dbProvider,
|
||||
IServerApplicationHost appHost)
|
||||
{
|
||||
_logger = loggerFactory.CreateLogger<RestorePlaylistChildrenFromMetadata>();
|
||||
_dbProvider = dbProvider;
|
||||
_appHost = appHost;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Perform()
|
||||
{
|
||||
using var context = _dbProvider.CreateDbContext();
|
||||
|
||||
var playlists = context.BaseItems
|
||||
.Where(b => b.Type == PlaylistTypeName && b.Path != null)
|
||||
.Select(b => new { b.Id, b.Name, b.Path })
|
||||
.ToList();
|
||||
|
||||
if (playlists.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var childCountByPlaylist = context.LinkedChildren
|
||||
.Where(lc => context.BaseItems.Any(b => b.Id.Equals(lc.ParentId) && b.Type == PlaylistTypeName))
|
||||
.GroupBy(lc => lc.ParentId)
|
||||
.Select(g => new { ParentId = g.Key, Count = g.Count() })
|
||||
.ToDictionary(g => g.ParentId, g => g.Count);
|
||||
|
||||
var pathToIdMap = context.BaseItems
|
||||
.Where(b => b.Path != null)
|
||||
.Select(b => new { b.Id, b.Path })
|
||||
.GroupBy(b => b.Path!)
|
||||
.ToDictionary(g => g.Key, g => g.First().Id);
|
||||
|
||||
var restoredPlaylists = 0;
|
||||
var restoredEntries = 0;
|
||||
|
||||
foreach (var playlist in playlists)
|
||||
{
|
||||
// Only directory-based (Jellyfin-managed) playlists keep their entries in playlist.xml.
|
||||
// A playlist that is itself a file (.m3u and friends) is re-read by the library scan.
|
||||
var playlistPath = _appHost.ExpandVirtualPath(playlist.Path!);
|
||||
var metadataPath = Path.Combine(playlistPath, PlaylistFileName);
|
||||
if (!Directory.Exists(playlistPath) || !File.Exists(metadataPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var storedPaths = ReadEntryPaths(metadataPath, playlist.Id);
|
||||
if (storedPaths.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var childCount = childCountByPlaylist.GetValueOrDefault(playlist.Id);
|
||||
if (childCount > 0)
|
||||
{
|
||||
// Merging into a playlist that still has entries would resurrect anything the user
|
||||
// removed while the metadata file was not rewritten, and there is no way to tell the
|
||||
// two apart. Report the mismatch instead so it can be checked by hand.
|
||||
if (storedPaths.Count > childCount)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Playlist {PlaylistName} ({PlaylistId}) holds {ChildCount} entries but {MetadataPath} lists {StoredCount}. Not restoring automatically.",
|
||||
playlist.Name,
|
||||
playlist.Id,
|
||||
childCount,
|
||||
metadataPath,
|
||||
storedPaths.Count);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var sortOrder = 0;
|
||||
foreach (var storedPath in storedPaths)
|
||||
{
|
||||
if (!pathToIdMap.TryGetValue(storedPath, out var childId))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Cannot restore entry {EntryPath} of playlist {PlaylistName}: no library item has that path.",
|
||||
storedPath,
|
||||
playlist.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
context.LinkedChildren.Add(new LinkedChildEntity
|
||||
{
|
||||
ParentId = playlist.Id,
|
||||
ChildId = childId,
|
||||
ChildType = LinkedChildType.Manual,
|
||||
SortOrder = sortOrder
|
||||
});
|
||||
|
||||
sortOrder++;
|
||||
}
|
||||
|
||||
if (sortOrder > 0)
|
||||
{
|
||||
restoredPlaylists++;
|
||||
restoredEntries += sortOrder;
|
||||
_logger.LogInformation(
|
||||
"Restored {Count} entries of empty playlist {PlaylistName} ({PlaylistId}) from {MetadataPath}.",
|
||||
sortOrder,
|
||||
playlist.Name,
|
||||
playlist.Id,
|
||||
metadataPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (restoredEntries > 0)
|
||||
{
|
||||
context.SaveChanges();
|
||||
_logger.LogInformation("Restored {EntryCount} entries across {PlaylistCount} playlists.", restoredEntries, restoredPlaylists);
|
||||
}
|
||||
}
|
||||
|
||||
private List<string> ReadEntryPaths(string metadataPath, Guid playlistId)
|
||||
{
|
||||
var paths = new List<string>();
|
||||
var settings = new XmlReaderSettings
|
||||
{
|
||||
IgnoreComments = true,
|
||||
IgnoreWhitespace = true,
|
||||
IgnoreProcessingInstructions = true,
|
||||
DtdProcessing = DtdProcessing.Prohibit
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
using var reader = XmlReader.Create(metadataPath, settings);
|
||||
var inEntry = false;
|
||||
while (reader.Read())
|
||||
{
|
||||
if (reader.NodeType != XmlNodeType.Element)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(reader.Name, "PlaylistItem", StringComparison.Ordinal))
|
||||
{
|
||||
inEntry = true;
|
||||
}
|
||||
else if (inEntry && string.Equals(reader.Name, "Path", StringComparison.Ordinal))
|
||||
{
|
||||
inEntry = false;
|
||||
var value = reader.ReadElementContentAsString();
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
paths.Add(value.Trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is XmlException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not read playlist metadata {MetadataPath} of playlist {PlaylistId}.", metadataPath, playlistId);
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.MediaSegments;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Globalization;
|
||||
@@ -87,7 +88,7 @@ namespace MediaBrowser.Controller.Entities
|
||||
Model.Entities.ExtraType.Short
|
||||
};
|
||||
|
||||
private static readonly char[] VersionDelimiters = ['-', '_', '.'];
|
||||
private protected static readonly char[] VersionDelimiters = ['-', '_', '.'];
|
||||
|
||||
private string _sortName;
|
||||
|
||||
@@ -540,8 +541,8 @@ namespace MediaBrowser.Controller.Entities
|
||||
{
|
||||
if (!string.IsNullOrEmpty(ForcedSortName))
|
||||
{
|
||||
// Need the ToLower because that's what CreateSortName does
|
||||
_sortName = ModifySortChunks(ForcedSortName).ToLowerInvariant();
|
||||
// Run the forced sort name through the same cleaning as auto-generated sort names.
|
||||
_sortName = GetSortName(ForcedSortName, EnableAlphaNumericSorting, ConfigurationManager.Configuration);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -770,6 +771,17 @@ namespace MediaBrowser.Controller.Entities
|
||||
[JsonIgnore]
|
||||
protected virtual bool SupportsOwnedItems => !ParentId.IsEmpty() && IsFileProtocol;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this item searches the folder it lives in for its own extras.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
protected virtual bool SearchesContainingFolderForExtras =>
|
||||
IsFileProtocol
|
||||
&& SupportsOwnedItems
|
||||
&& !IsInMixedFolder
|
||||
&& this is not (ICollectionFolder or UserRootFolder or AggregateFolder)
|
||||
&& GetType() != typeof(Folder);
|
||||
|
||||
[JsonIgnore]
|
||||
public virtual bool SupportsPeople => false;
|
||||
|
||||
@@ -926,19 +938,31 @@ namespace MediaBrowser.Controller.Entities
|
||||
/// <returns>System.String.</returns>
|
||||
protected virtual string CreateSortName()
|
||||
{
|
||||
if (Name is null)
|
||||
return GetSortName(Name, EnableAlphaNumericSorting, ConfigurationManager.Configuration);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans a raw name into its sortable form by applying the configured sort rules.
|
||||
/// </summary>
|
||||
/// <param name="name">The raw name to clean.</param>
|
||||
/// <param name="enableAlphaNumericSorting">Whether alphanumeric sorting rules should be applied.</param>
|
||||
/// <param name="configuration">The server configuration providing the sort rules.</param>
|
||||
/// <returns>The cleaned, sortable name, or <c>null</c> if <paramref name="name"/> is <c>null</c>.</returns>
|
||||
public static string GetSortName(string name, bool enableAlphaNumericSorting, ServerConfiguration configuration)
|
||||
{
|
||||
if (name is null)
|
||||
{
|
||||
return null; // some items may not have name filled in properly
|
||||
}
|
||||
|
||||
if (!EnableAlphaNumericSorting)
|
||||
if (!enableAlphaNumericSorting)
|
||||
{
|
||||
return Name.TrimStart();
|
||||
return name.TrimStart();
|
||||
}
|
||||
|
||||
var sortable = Name.Trim().ToLowerInvariant();
|
||||
var sortable = name.Trim().ToLowerInvariant();
|
||||
|
||||
foreach (var search in ConfigurationManager.Configuration.SortRemoveWords)
|
||||
foreach (var search in configuration.SortRemoveWords)
|
||||
{
|
||||
// Remove from beginning if a space follows
|
||||
if (sortable.StartsWith(search + " ", StringComparison.Ordinal))
|
||||
@@ -956,12 +980,12 @@ namespace MediaBrowser.Controller.Entities
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var removeChar in ConfigurationManager.Configuration.SortRemoveCharacters)
|
||||
foreach (var removeChar in configuration.SortRemoveCharacters)
|
||||
{
|
||||
sortable = sortable.Replace(removeChar, string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
foreach (var replaceChar in ConfigurationManager.Configuration.SortReplaceCharacters)
|
||||
foreach (var replaceChar in configuration.SortReplaceCharacters)
|
||||
{
|
||||
sortable = sortable.Replace(replaceChar, " ", StringComparison.Ordinal);
|
||||
}
|
||||
@@ -1515,7 +1539,14 @@ namespace MediaBrowser.Controller.Entities
|
||||
/// <returns><c>true</c> if any items have changed, else <c>false</c>.</returns>
|
||||
protected virtual async Task<bool> RefreshedOwnedItems(MetadataRefreshOptions options, IReadOnlyList<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsFileProtocol || !SupportsOwnedItems || IsInMixedFolder || this is ICollectionFolder or UserRootFolder or AggregateFolder || this.GetType() == typeof(Folder))
|
||||
if (!SearchesContainingFolderForExtras)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (GetParent() is Folder container
|
||||
&& container.SearchesContainingFolderForExtras
|
||||
&& string.Equals(container.Path, ContainingFolderPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -1530,33 +1561,59 @@ namespace MediaBrowser.Controller.Entities
|
||||
|
||||
private async Task<bool> RefreshExtras(BaseItem item, MetadataRefreshOptions options, IReadOnlyList<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken)
|
||||
{
|
||||
// An extra is owned by the version it is named after, so all of them are maintained together.
|
||||
var currentExtras = LibraryManager.GetItemList(new InternalItemsQuery()
|
||||
{
|
||||
OwnerIds = item.GetOwnedVersionIds()
|
||||
}).Where(e => e.ExtraType.HasValue).ToList();
|
||||
|
||||
var currentExtraIds = currentExtras.Select(e => e.Id).ToArray();
|
||||
|
||||
// Snapshot the persisted names before resolving, as FindExtras corrects the name on the
|
||||
// items it hands back and may well hand back these very instances.
|
||||
var currentExtraNames = new Dictionary<Guid, string>();
|
||||
foreach (var extra in currentExtras)
|
||||
{
|
||||
currentExtraNames[extra.Id] = extra.Name;
|
||||
}
|
||||
|
||||
var extras = LibraryManager.FindExtras(item, fileSystemChildren, options.DirectoryService).ToArray();
|
||||
var newExtraIds = Array.ConvertAll(extras, x => x.Id);
|
||||
|
||||
var currentExtraIds = LibraryManager.GetItemList(new InternalItemsQuery()
|
||||
{
|
||||
OwnerIds = [item.Id]
|
||||
}).Select(e => e.Id).ToArray();
|
||||
var renamedExtraIds = extras
|
||||
.Where(e => currentExtraNames.TryGetValue(e.Id, out var oldName) && !string.Equals(oldName, e.Name, StringComparison.Ordinal))
|
||||
.Select(e => e.Id)
|
||||
.ToHashSet();
|
||||
|
||||
var extrasChanged = !currentExtraIds.OrderBy(x => x).SequenceEqual(newExtraIds.OrderBy(x => x));
|
||||
|
||||
if (!extrasChanged && !options.ReplaceAllMetadata && options.MetadataRefreshMode != MetadataRefreshMode.FullRefresh)
|
||||
if (!extrasChanged && renamedExtraIds.Count == 0 && !options.ReplaceAllMetadata && options.MetadataRefreshMode != MetadataRefreshMode.FullRefresh)
|
||||
{
|
||||
// The owner's dates may only have become known after its extras were created, so keep
|
||||
// them in sync even when there is nothing to refresh.
|
||||
foreach (var extra in currentExtras)
|
||||
{
|
||||
if (extra.ExtraType is not null && InheritDatesFromOwner(item, extra))
|
||||
{
|
||||
await extra.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
var ownerId = item.Id;
|
||||
|
||||
var tasks = extras.Select(i =>
|
||||
{
|
||||
var ownerId = item.GetOwnerIdForExtra(i);
|
||||
var subOptions = new MetadataRefreshOptions(options);
|
||||
if (!i.OwnerId.Equals(ownerId) || !i.ParentId.IsEmpty())
|
||||
if (!i.OwnerId.Equals(ownerId) || !i.ParentId.IsEmpty() || renamedExtraIds.Contains(i.Id))
|
||||
{
|
||||
subOptions.ForceSave = true;
|
||||
}
|
||||
|
||||
i.OwnerId = ownerId;
|
||||
i.ParentId = Guid.Empty;
|
||||
|
||||
return RefreshMetadataForOwnedItem(i, true, subOptions, cancellationToken);
|
||||
});
|
||||
|
||||
@@ -2639,6 +2696,32 @@ namespace MediaBrowser.Controller.Entities
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the owner's premiere date and production year to an owned item, returning whether anything changed.
|
||||
/// </summary>
|
||||
/// <param name="owner">The owner.</param>
|
||||
/// <param name="ownedItem">The owned item.</param>
|
||||
/// <returns><c>true</c> if the owned item was changed, else <c>false</c>.</returns>
|
||||
internal static bool InheritDatesFromOwner(BaseItem owner, BaseItem ownedItem)
|
||||
{
|
||||
// Extras have no release date of their own, so the owner's is authoritative.
|
||||
var changed = false;
|
||||
|
||||
if (owner.ProductionYear is not null && ownedItem.ProductionYear != owner.ProductionYear)
|
||||
{
|
||||
ownedItem.ProductionYear = owner.ProductionYear;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (owner.PremiereDate is not null && ownedItem.PremiereDate != owner.PremiereDate)
|
||||
{
|
||||
ownedItem.PremiereDate = owner.PremiereDate;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
protected async Task RefreshMetadataForOwnedItem(BaseItem ownedItem, bool copyTitleMetadata, MetadataRefreshOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
var newOptions = new MetadataRefreshOptions(options)
|
||||
@@ -2698,6 +2781,11 @@ namespace MediaBrowser.Controller.Entities
|
||||
ownedItem.CustomRating = item.CustomRating;
|
||||
newOptions.ForceSave = true;
|
||||
}
|
||||
|
||||
if (InheritDatesFromOwner(item, ownedItem))
|
||||
{
|
||||
newOptions.ForceSave = true;
|
||||
}
|
||||
}
|
||||
|
||||
await ownedItem.RefreshMetadata(newOptions, cancellationToken).ConfigureAwait(false);
|
||||
@@ -2863,6 +2951,25 @@ namespace MediaBrowser.Controller.Entities
|
||||
return [Id];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ids of this item and the versions of it whose extras it maintains.
|
||||
/// </summary>
|
||||
/// <returns>An array containing the version ids.</returns>
|
||||
protected virtual Guid[] GetOwnedVersionIds()
|
||||
{
|
||||
return [Id];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the id of the version an extra belongs to.
|
||||
/// </summary>
|
||||
/// <param name="extra">The extra.</param>
|
||||
/// <returns>The id of the owning version.</returns>
|
||||
protected virtual Guid GetOwnerIdForExtra(BaseItem extra)
|
||||
{
|
||||
return Id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all extras associated with this item, sorted by <see cref="SortName"/>.
|
||||
/// </summary>
|
||||
|
||||
@@ -43,11 +43,7 @@ namespace MediaBrowser.Controller.Entities
|
||||
public class Folder : BaseItem
|
||||
{
|
||||
private IEnumerable<BaseItem> _children;
|
||||
|
||||
public Folder()
|
||||
{
|
||||
LinkedChildren = Array.Empty<LinkedChild>();
|
||||
}
|
||||
private LinkedChild[] _linkedChildren = [];
|
||||
|
||||
public static IUserViewManager UserViewManager { get; set; }
|
||||
|
||||
@@ -63,7 +59,27 @@ namespace MediaBrowser.Controller.Entities
|
||||
/// Gets or sets the linked children.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public LinkedChild[] LinkedChildren { get; set; }
|
||||
public LinkedChild[] LinkedChildren
|
||||
{
|
||||
get => _linkedChildren;
|
||||
set
|
||||
{
|
||||
_linkedChildren = value;
|
||||
|
||||
// Assigning the collection means the caller knows the complete set of links.
|
||||
LinkedChildrenLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether <see cref="LinkedChildren"/> holds the stored set of links.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An unloaded instance carries an empty array that means "unknown", not "no children" —
|
||||
/// persisting it would delete every link the item has.
|
||||
/// </remarks>
|
||||
[JsonIgnore]
|
||||
public bool LinkedChildrenLoaded { get; private set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public DateTime? DateLastMediaAdded { get; set; }
|
||||
@@ -1085,15 +1101,7 @@ namespace MediaBrowser.Controller.Entities
|
||||
items = ApplyNameFilter(items, query);
|
||||
}
|
||||
|
||||
var filteredItems = items as IReadOnlyList<BaseItem> ?? items.ToList();
|
||||
var result = UserViewBuilder.SortAndPage(filteredItems, null, query, LibraryManager);
|
||||
|
||||
if (query.EnableTotalRecordCount)
|
||||
{
|
||||
result.TotalRecordCount = filteredItems.Count;
|
||||
}
|
||||
|
||||
return result;
|
||||
return UserViewBuilder.SortAndPage(items, null, query, LibraryManager);
|
||||
}
|
||||
|
||||
private static IEnumerable<BaseItem> ApplyNameFilter(IEnumerable<BaseItem> items, InternalItemsQuery query)
|
||||
|
||||
@@ -496,6 +496,12 @@ namespace MediaBrowser.Controller.Entities
|
||||
|
||||
public IReadOnlyList<string> SubtitleLanguages { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether some content in the library is hidden from <see cref="User"/>.
|
||||
/// Filters that only exist to hide content can be skipped entirely when this is false.
|
||||
/// </summary>
|
||||
public bool UserHasContentRestrictions { get; private set; }
|
||||
|
||||
public void SetUser(User user)
|
||||
{
|
||||
var maxRating = user.MaxParentalRatingScore;
|
||||
@@ -519,6 +525,7 @@ namespace MediaBrowser.Controller.Entities
|
||||
.Select(tag => tag.RemoveDiacritics().ToLowerInvariant())
|
||||
.ToArray();
|
||||
|
||||
UserHasContentRestrictions = user.HasContentRestrictions();
|
||||
User = user;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,16 @@ namespace MediaBrowser.Controller.Entities
|
||||
{
|
||||
PersonTypes = personTypes;
|
||||
ExcludePersonTypes = excludePersonTypes;
|
||||
EnableTotalRecordCount = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to count the matching people. Under an
|
||||
/// <see cref="AccessFilter"/> the count is the expensive half of the query: the page walk stops
|
||||
/// at the limit, the count has to check every person.
|
||||
/// </summary>
|
||||
public bool EnableTotalRecordCount { get; set; }
|
||||
|
||||
public int? StartIndex { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -51,5 +59,11 @@ namespace MediaBrowser.Controller.Entities
|
||||
public User User { get; set; }
|
||||
|
||||
public bool? IsFavorite { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the item query whose access settings (library access, parental rating, tags)
|
||||
/// people must satisfy through at least one of the items they are credited on.
|
||||
/// </summary>
|
||||
public InternalItemsQuery AccessFilter { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ namespace MediaBrowser.Controller.Entities.Movies
|
||||
{
|
||||
var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata);
|
||||
|
||||
if (!ProductionYear.HasValue)
|
||||
if (ProductionYear is null)
|
||||
{
|
||||
var info = LibraryManager.ParseName(Name);
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ namespace MediaBrowser.Controller.Entities
|
||||
{
|
||||
var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata);
|
||||
|
||||
if (!ProductionYear.HasValue)
|
||||
if (ProductionYear is null)
|
||||
{
|
||||
var info = LibraryManager.ParseName(Name);
|
||||
|
||||
|
||||
@@ -35,57 +35,61 @@ namespace MediaBrowser.Controller.Entities
|
||||
person.Type = PersonKind.Writer;
|
||||
}
|
||||
|
||||
// If the type is GuestStar and there's already an Actor entry, then update it to avoid dupes
|
||||
if (person.Type == PersonKind.GuestStar)
|
||||
{
|
||||
var existing = people.FirstOrDefault(p => p.Name.Equals(person.Name, StringComparison.OrdinalIgnoreCase) && p.Type == PersonKind.Actor);
|
||||
// Check for dupes based on the combination of Name, Type and Role.
|
||||
var existing = people.FirstOrDefault(p => IsSameCredit(p, person)
|
||||
&& string.Equals(p.Role ?? string.Empty, person.Role ?? string.Empty, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
existing.Type = PersonKind.GuestStar;
|
||||
MergeExisting(existing, person);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (person.Type == PersonKind.Actor)
|
||||
if (existing is null)
|
||||
{
|
||||
// If the actor already exists without a role and we have one, fill it in
|
||||
var existing = people.FirstOrDefault(p => p.Name.Equals(person.Name, StringComparison.OrdinalIgnoreCase) && (p.Type == PersonKind.Actor || p.Type == PersonKind.GuestStar));
|
||||
if (existing is null)
|
||||
if (string.IsNullOrEmpty(person.Role))
|
||||
{
|
||||
// Wasn't there - add it
|
||||
people.Add(person);
|
||||
existing = people.FirstOrDefault(p => IsSameCredit(p, person));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Was there, if no role and we have one - fill it in
|
||||
if (string.IsNullOrEmpty(existing.Role) && !string.IsNullOrEmpty(person.Role))
|
||||
// If the person already exists without a role and we have one, fill it in
|
||||
existing = people.FirstOrDefault(p => IsSameCredit(p, person) && string.IsNullOrEmpty(p.Role));
|
||||
if (existing is not null)
|
||||
{
|
||||
existing.Role = person.Role;
|
||||
}
|
||||
|
||||
MergeExisting(existing, person);
|
||||
}
|
||||
}
|
||||
else
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
var existing = people.FirstOrDefault(p =>
|
||||
string.Equals(p.Name, person.Name, StringComparison.OrdinalIgnoreCase)
|
||||
&& p.Type == person.Type);
|
||||
|
||||
// Check for dupes based on the combination of Name and Type
|
||||
if (existing is null)
|
||||
{
|
||||
people.Add(person);
|
||||
}
|
||||
else
|
||||
{
|
||||
MergeExisting(existing, person);
|
||||
}
|
||||
people.Add(person);
|
||||
return;
|
||||
}
|
||||
|
||||
// If the type is GuestStar and there's already an Actor entry, then promote it to avoid dupes
|
||||
if (person.Type == PersonKind.GuestStar)
|
||||
{
|
||||
existing.Type = PersonKind.GuestStar;
|
||||
}
|
||||
|
||||
MergeExisting(existing, person);
|
||||
}
|
||||
|
||||
private static bool IsSameCredit(PersonInfo existing, PersonInfo person)
|
||||
{
|
||||
if (!string.Equals(existing.Name, person.Name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Actor and GuestStar describe the same credit, a guest star is just a promoted actor.
|
||||
if (IsCastKind(existing.Type) && IsCastKind(person.Type))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return existing.Type == person.Type;
|
||||
}
|
||||
|
||||
private static bool IsCastKind(PersonKind kind)
|
||||
=> kind is PersonKind.Actor or PersonKind.GuestStar;
|
||||
|
||||
private static void MergeExisting(PersonInfo existing, PersonInfo person)
|
||||
{
|
||||
existing.SortOrder = person.SortOrder ?? existing.SortOrder;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user