Compare commits
125 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 | |||
| 8044534774 | |||
| 705368ee49 | |||
| cbc2c7c323 | |||
| 0915a61c19 | |||
| e123a13e38 | |||
| 3da05330b2 | |||
| a94588497c | |||
| 9a258c089d | |||
| 5f5c71ab75 | |||
| f6f0fed2d1 | |||
| e2adf2e2bf | |||
| 78b535b229 |
@@ -32,13 +32,13 @@ jobs:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
uses: github/codeql-action/autobuild@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
|
||||
@@ -236,6 +236,7 @@
|
||||
- [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
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
|
||||
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
|
||||
<PackageVersion Include="SharpCompress" Version="0.50.1" />
|
||||
<PackageVersion Include="SharpCompress" Version="0.50.4" />
|
||||
<PackageVersion Include="SharpFuzz" Version="2.3.0" />
|
||||
<PackageVersion Include="SkiaSharp" Version="3.119.4" />
|
||||
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.119.4" />
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -412,6 +412,13 @@ namespace Emby.Server.Implementations.Library
|
||||
}
|
||||
|
||||
_persistenceService.DeleteItem([.. pathMaps.Select(f => f.Item.Id)]);
|
||||
|
||||
// Evict the deleted items from the cache and announce each removal.
|
||||
foreach (var (item, _, _) in pathMaps)
|
||||
{
|
||||
_cache.TryRemove(item.Id, out _);
|
||||
ReportItemRemoved(item, item.GetOwner() ?? item.GetParent());
|
||||
}
|
||||
}
|
||||
|
||||
public void DeleteItem(BaseItem item, DeleteOptions options, BaseItem parent, bool notifyParentItem)
|
||||
@@ -611,6 +618,12 @@ namespace Emby.Server.Implementations.Library
|
||||
folder.UserData = null;
|
||||
}
|
||||
|
||||
// Announce the descendants before the item itself.
|
||||
foreach (var child in children)
|
||||
{
|
||||
ReportItemRemoved(child, item);
|
||||
}
|
||||
|
||||
ReportItemRemoved(item, parent);
|
||||
}
|
||||
|
||||
@@ -2234,6 +2247,12 @@ namespace Emby.Server.Implementations.Library
|
||||
return [];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlySet<Guid> GetItemIdsWithAlternateVersions(IReadOnlyList<Guid> itemIds)
|
||||
{
|
||||
return _linkedChildrenService.GetItemIdsWithAlternateVersions(itemIds);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void UpsertLinkedChild(Guid parentId, Guid childId, MediaBrowser.Controller.Entities.LinkedChildType childType)
|
||||
{
|
||||
@@ -2376,6 +2395,7 @@ namespace Emby.Server.Implementations.Library
|
||||
{
|
||||
altVideo.OwnerId = video.Id;
|
||||
altVideo.SetPrimaryVersionId(video.Id);
|
||||
altVideo.IsInMixedFolder = video.IsInMixedFolder;
|
||||
// ResolveAlternateVersion only sees the alternate's primary file.
|
||||
// If the alternate is itself a stack (e.g. 1080p part1 + part2),
|
||||
// detect its parts from sibling files so its AdditionalParts persist.
|
||||
@@ -2561,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);
|
||||
@@ -2589,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.
|
||||
@@ -2649,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)
|
||||
{
|
||||
@@ -3509,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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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": "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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -101,5 +101,15 @@
|
||||
"NotificationOptionAudioPlaybackStopped": "Ljóðspæl steðgað",
|
||||
"TaskExtractMediaSegments": "Leita eftir margmiðlabrotum",
|
||||
"TaskExtractMediaSegmentsDescription": "Framleiður upplýsingar um brot í margmiðlum, við hjálp frá MediaSegment-virktum ískoytisforritum.",
|
||||
"NotificationOptionCameraImageUploaded": "Ljósmynd uppsent"
|
||||
"NotificationOptionCameraImageUploaded": "Ljósmynd uppsend",
|
||||
"NameExtraShort": "Stuttfilmur",
|
||||
"NameExtraThemeSong": "Eyðkennislag",
|
||||
"NameExtraTrailer": "Forfilmur",
|
||||
"NameExtraInterview": "Samrøða",
|
||||
"NameExtraBehindTheScenes": "Aftanfyri leiktjøldini",
|
||||
"NameExtraClip": "Klipp",
|
||||
"NameExtraNumbered": "{0} {1}",
|
||||
"NameExtraFeaturette": "Stuttur heimildarfilmur",
|
||||
"TaskAudioNormalization": "Ljóðjavnan",
|
||||
"TaskAudioNormalizationDescription": "Kannar fílur fyri dátum til ljóðjavnan."
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "인터뷰"
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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": "额外"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,7 +223,7 @@ namespace Emby.Server.Implementations.Session
|
||||
|
||||
if (inactive.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("Sending ForceKeepAlive message to {0} inactive WebSockets.", inactive.Count);
|
||||
_logger.LogDebug("Sending ForceKeepAlive message to {0} inactive WebSockets.", inactive.Count);
|
||||
}
|
||||
|
||||
foreach (var webSocket in inactive)
|
||||
|
||||
@@ -500,8 +500,9 @@ namespace Emby.Server.Implementations.Updates
|
||||
var plugins = _pluginManager.Plugins;
|
||||
foreach (var plugin in plugins)
|
||||
{
|
||||
// Don't auto update when plugin marked not to, or when it's disabled.
|
||||
if (plugin.Manifest?.AutoUpdate == false || plugin.Manifest?.Status == PluginStatus.Disabled)
|
||||
// Don't auto update when plugin marked not to, or when it's disabled or pending removal.
|
||||
if (plugin.Manifest?.AutoUpdate == false
|
||||
|| plugin.Manifest?.Status is PluginStatus.Disabled or PluginStatus.Deleted)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -198,11 +198,6 @@ public static class StreamingHelpers
|
||||
state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(streamingRequest.AudioBitRate, streamingRequest.AudioCodec, state.AudioStream, state.OutputAudioChannels) ?? 0;
|
||||
}
|
||||
|
||||
if (outputAudioCodec.StartsWith("pcm_", StringComparison.Ordinal))
|
||||
{
|
||||
containerInternal = ".pcm";
|
||||
}
|
||||
|
||||
if (state.VideoRequest is not null)
|
||||
{
|
||||
state.OutputVideoCodec = state.Request.VideoCodec;
|
||||
|
||||
@@ -162,6 +162,23 @@ public static class UserEntityExtensions
|
||||
return Array.IndexOf(GetPreferenceValues<Guid>(entity, PreferenceKind.GroupedFolders), id) != -1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether any library, parental rating or tag rule keeps content from this user.
|
||||
/// </summary>
|
||||
/// <param name="entity">The user to check.</param>
|
||||
/// <returns><c>True</c> if some content in the library is hidden from this user.</returns>
|
||||
public static bool HasContentRestrictions(this User entity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
|
||||
return !entity.HasPermission(PermissionKind.EnableAllFolders)
|
||||
|| entity.GetPreference(PreferenceKind.BlockedMediaFolders).Length > 0
|
||||
|| entity.MaxParentalRatingScore.HasValue
|
||||
|| entity.GetPreference(PreferenceKind.BlockedTags).Length > 0
|
||||
|| entity.GetPreference(PreferenceKind.AllowedTags).Length > 0
|
||||
|| entity.GetPreference(PreferenceKind.BlockUnratedItems).Length > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the default permissions for a user. Should only be called on user creation.
|
||||
/// </summary>
|
||||
|
||||
@@ -144,11 +144,6 @@ public sealed partial class BaseItemRepository
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(filter);
|
||||
|
||||
if (!filter.Limit.HasValue)
|
||||
{
|
||||
filter.EnableTotalRecordCount = false;
|
||||
}
|
||||
|
||||
using var context = _dbProvider.CreateDbContext();
|
||||
|
||||
var innerQueryFilter = TranslateQuery(context.BaseItems.Where(e => e.Id != EF.Constant(PlaceholderId)), context, new InternalItemsQuery(filter.User)
|
||||
|
||||
@@ -428,6 +428,17 @@ public sealed partial class BaseItemRepository
|
||||
return ApplyAccessFiltering(context, baseQuery, filter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the user restricts access to items by parental rating or tags.
|
||||
/// </summary>
|
||||
/// <param name="filter">The query filter.</param>
|
||||
/// <returns><c>true</c> if the query carries parental restrictions.</returns>
|
||||
private static bool RequiresParentalRestrictions(InternalItemsQuery filter)
|
||||
=> filter.IncludeInheritedTags.Length > 0
|
||||
|| filter.ExcludeInheritedTags.Length > 0
|
||||
|| filter.MaxParentalRating is not null
|
||||
|| filter.BlockUnratedItems.Length > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Applies user access filtering to a query.
|
||||
/// Includes TopParentIds, parental rating, and tag filtering.
|
||||
@@ -438,13 +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)
|
||||
{
|
||||
@@ -495,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -169,7 +169,10 @@ public sealed partial class BaseItemRepository
|
||||
.Where(album => albumIdsWithMatchingTrack.Contains(album.Id));
|
||||
}
|
||||
|
||||
var orderedAlbums = topAlbumsQuery
|
||||
// The album is what gets returned, and neither branch above reads it through the
|
||||
// user's filters, so its own parental restrictions have to be applied here: a
|
||||
// matching track does not make an album the user may not see visible.
|
||||
var orderedAlbums = ApplyParentalRestrictions(context, topAlbumsQuery, filter)
|
||||
.OrderByDescending(album => album.DateCreated)
|
||||
.ThenByDescending(album => album.Id);
|
||||
|
||||
@@ -422,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(
|
||||
|
||||
@@ -356,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,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))
|
||||
@@ -499,16 +501,21 @@ public sealed partial class BaseItemRepository
|
||||
var inProgress = context.UserData
|
||||
.Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0);
|
||||
|
||||
// Folders are resumable when a descendant is in progress, or when they hold both played and
|
||||
// unplayed descendants (partially watched). Alternate versions keep their own progress, so
|
||||
// they count towards the in-progress check but not towards the played/unplayed one.
|
||||
// Series and Seasons are resumable when a descendant is in progress, or when they hold both
|
||||
// played and unplayed descendants (partially watched). Alternate versions keep their own
|
||||
// progress, so they count towards the in-progress check but not towards the played/unplayed one.
|
||||
var leafItems = GetAccessFilteredLeafItemsQuery(context, filter.User!);
|
||||
var inProgressLeafItems = GetAccessFilteredLeafItemsQuery(context, filter.User!, includeOwnedItems: true)
|
||||
.Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0));
|
||||
|
||||
var folderResumableFilter = BuildHasDescendantFilter(context, inProgressLeafItems)
|
||||
.Or(BuildHasDescendantFilter(context, leafItems.Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)))
|
||||
.And(BuildHasDescendantFilter(context, leafItems.Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played)))));
|
||||
// Every other folder kind is a container rather than one continuous piece of media
|
||||
var resumableFolderTypes = _resumableFolderKinds
|
||||
.Select(kind => _itemTypeLookup.BaseItemKindNames.GetValueOrDefault(kind))
|
||||
.ToArray();
|
||||
var folderIsResumableFilter = IsFolderFilter.And(e => resumableFolderTypes.Contains(e.Type))
|
||||
.And(BuildHasDescendantFilter(context, inProgressLeafItems)
|
||||
.Or(BuildHasDescendantFilter(context, leafItems.Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)))
|
||||
.And(BuildHasDescendantFilter(context, leafItems.Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played))))));
|
||||
|
||||
if (isResumable)
|
||||
{
|
||||
@@ -516,7 +523,7 @@ public sealed partial class BaseItemRepository
|
||||
// Match each version on its own progress rather than coalescing onto the primary.
|
||||
var inProgressIds = inProgress.Select(ud => ud.ItemId);
|
||||
|
||||
baseQuery = baseQuery.Where(IsFolderFilter.And(folderResumableFilter)
|
||||
baseQuery = baseQuery.Where(folderIsResumableFilter
|
||||
.Or(IsFolderFilter.Not().And(e => inProgressIds.Contains(e.Id))));
|
||||
|
||||
// When several versions of the same item are in progress, keep only the most recently played one, use id as tiebreaker.
|
||||
@@ -543,14 +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 = baseQuery.Where(IsFolderFilter.And(folderResumableFilter.Not())
|
||||
baseQuery = baseQuery.Where(IsFolderFilter.And(folderIsResumableFilter.Not())
|
||||
.Or(IsFolderFilter.Not().And(e => !resumableMovieIds.Contains(e.Id))));
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.ArtistIds.Length > 0)
|
||||
{
|
||||
baseQuery = baseQuery.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ArtistIds);
|
||||
baseQuery = baseQuery.WhereReferencedItem(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ArtistIds);
|
||||
}
|
||||
|
||||
if (filter.AlbumArtistIds.Length > 0)
|
||||
@@ -581,12 +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)
|
||||
@@ -612,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)
|
||||
@@ -958,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
|
||||
@@ -990,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)
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -21,10 +21,11 @@ namespace Jellyfin.Server.Implementations.Item;
|
||||
/// </summary>
|
||||
/// <param name="dbProvider">Efcore Factory.</param>
|
||||
/// <param name="itemTypeLookup">Items lookup service.</param>
|
||||
/// <param name="queryHelpers">Shared item query helpers.</param>
|
||||
/// <remarks>
|
||||
/// Initializes a new instance of the <see cref="PeopleRepository"/> class.
|
||||
/// </remarks>
|
||||
public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, IItemTypeLookup itemTypeLookup) : IPeopleRepository
|
||||
public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, IItemTypeLookup itemTypeLookup, IItemQueryHelpers queryHelpers) : IPeopleRepository
|
||||
{
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider = dbProvider;
|
||||
|
||||
@@ -33,12 +34,13 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
|
||||
{
|
||||
using var context = _dbProvider.CreateDbContext();
|
||||
var dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter);
|
||||
int? distinctNameCount = null;
|
||||
|
||||
// Include PeopleBaseItemMap
|
||||
if (!filter.ItemId.IsEmpty())
|
||||
{
|
||||
dbQuery = dbQuery.Include(p => p.BaseItems!.Where(m => m.ItemId == filter.ItemId))
|
||||
.OrderBy(e => e.BaseItems!.First(e => e.ItemId == filter.ItemId).ListOrder)
|
||||
.OrderBy(e => e.BaseItems!.Where(m => m.ItemId == filter.ItemId).Min(m => m.ListOrder))
|
||||
.ThenBy(e => e.PersonType)
|
||||
.ThenBy(e => e.Name);
|
||||
}
|
||||
@@ -46,17 +48,25 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
|
||||
{
|
||||
// The Peoples table has one row per (Name, PersonType), so the same person can
|
||||
// appear multiple times (e.g. as Actor and GuestStar). Collapse to one row per
|
||||
// name so /Persons doesn't return the same BaseItem id repeatedly. Lowercase the
|
||||
// grouping key so case-only duplicates collapse together.
|
||||
var representativeIds = dbQuery
|
||||
.GroupBy(e => e.Name.ToLower())
|
||||
.Select(g => g.Min(e => e.Id));
|
||||
dbQuery = context.Peoples.AsNoTracking()
|
||||
.Where(p => representativeIds.Contains(p.Id))
|
||||
.OrderBy(e => e.Name);
|
||||
// name so /Persons doesn't return the same BaseItem id repeatedly, keeping the
|
||||
// lowest id per lowercased name so case-only duplicates collapse together.
|
||||
var candidates = dbQuery;
|
||||
dbQuery = candidates
|
||||
.Where(p => !candidates.Any(other => other.Name.ToLower() == p.Name.ToLower() && other.Id < p.Id))
|
||||
.OrderBy(e => e.Name.ToLower());
|
||||
|
||||
if (filter.EnableTotalRecordCount)
|
||||
{
|
||||
distinctNameCount = candidates.Select(e => e.Name.ToLower()).Distinct().Count();
|
||||
}
|
||||
}
|
||||
|
||||
var count = 0;
|
||||
if (filter.EnableTotalRecordCount)
|
||||
{
|
||||
count = distinctNameCount ?? dbQuery.Count();
|
||||
}
|
||||
|
||||
var count = dbQuery.Count();
|
||||
if (filter.StartIndex.HasValue && filter.StartIndex > 0)
|
||||
{
|
||||
dbQuery = dbQuery.Skip(filter.StartIndex.Value);
|
||||
@@ -71,7 +81,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
|
||||
{
|
||||
StartIndex = filter.StartIndex ?? 0,
|
||||
TotalRecordCount = count,
|
||||
Items = dbQuery.AsEnumerable().Select(Map).ToArray(),
|
||||
Items = dbQuery.AsEnumerable().SelectMany(MapCredits).ToArray(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -107,9 +117,17 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
|
||||
person.Role = person.Role?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
// multiple metadata providers can provide the _same_ person; dedupe case-insensitively.
|
||||
people = people.DistinctBy(e => e.Name.ToLowerInvariant() + "-" + e.Type).ToArray();
|
||||
var personKeys = people.Select(e => e.Name.ToLowerInvariant() + "-" + e.Type).ToArray();
|
||||
// Project the values every comparison below needs once, so neither the case folding nor the
|
||||
// enum formatting is repeated per candidate.
|
||||
var credits = people.Select(e => (Person: e, LoweredName: e.Name.ToLowerInvariant(), PersonType: e.Type.ToString(), LoweredRole: e.Role.ToLowerInvariant()));
|
||||
|
||||
// multiple metadata providers can provide the _same_ credit; dedupe case-insensitively.
|
||||
// The role is part of the key because one person can hold several credits of the same type
|
||||
// on an item, e.g. a Writer credited for both the Novel and the Screenplay.
|
||||
var distinctCredits = credits.DistinctBy(e => (e.LoweredName, e.PersonType, e.LoweredRole)).ToArray();
|
||||
|
||||
var distinctPersons = distinctCredits.DistinctBy(e => (e.LoweredName, e.PersonType)).ToArray();
|
||||
var personKeys = distinctPersons.Select(e => e.LoweredName + "-" + e.PersonType).ToArray();
|
||||
|
||||
using var context = _dbProvider.CreateDbContext();
|
||||
using var transaction = context.Database.BeginTransaction();
|
||||
@@ -122,23 +140,44 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
|
||||
.Select(f => f.item)
|
||||
.ToArray();
|
||||
|
||||
var toAdd = people
|
||||
.Where(e => !existingPersons.Any(f => string.Equals(f.Name, e.Name, StringComparison.OrdinalIgnoreCase) && f.PersonType == e.Type.ToString()))
|
||||
.Select(Map);
|
||||
var existingPersonKeys = existingPersons.Select(e => (e.Name.ToLowerInvariant(), e.PersonType ?? string.Empty)).ToHashSet();
|
||||
|
||||
var toAdd = distinctPersons
|
||||
.Where(e => !existingPersonKeys.Contains((e.LoweredName, e.PersonType)))
|
||||
.Select(e => Map(e.Person))
|
||||
.ToArray();
|
||||
context.Peoples.AddRange(toAdd);
|
||||
context.SaveChanges();
|
||||
|
||||
var personsEntities = toAdd.Concat(existingPersons).ToArray();
|
||||
// The Peoples table can hold case-only duplicates, so keep the first match per key just as
|
||||
// the previous First() lookup did.
|
||||
var personsEntities = new Dictionary<(string LoweredName, string PersonType), People>();
|
||||
foreach (var entity in toAdd.Concat(existingPersons))
|
||||
{
|
||||
personsEntities.TryAdd((entity.Name.ToLowerInvariant(), entity.PersonType ?? string.Empty), entity);
|
||||
}
|
||||
|
||||
var existingMaps = context.PeopleBaseItemMap.Include(e => e.People).Where(e => e.ItemId == itemId).ToList();
|
||||
var existingMapsByCredit = new Dictionary<(string LoweredName, string PersonType, string LoweredRole), PeopleBaseItemMap>();
|
||||
foreach (var map in existingMaps)
|
||||
{
|
||||
existingMapsByCredit.TryAdd((map.People.Name.ToLowerInvariant(), map.People.PersonType ?? string.Empty, map.Role?.ToLowerInvariant() ?? string.Empty), map);
|
||||
}
|
||||
|
||||
var listOrder = 0;
|
||||
|
||||
foreach (var person in people)
|
||||
foreach (var credit in distinctCredits)
|
||||
{
|
||||
var entityPerson = personsEntities.First(e => string.Equals(e.Name, person.Name, StringComparison.OrdinalIgnoreCase) && e.PersonType == person.Type.ToString());
|
||||
var existingMap = existingMaps.FirstOrDefault(e => string.Equals(e.People.Name, person.Name, StringComparison.OrdinalIgnoreCase) && e.People.PersonType == person.Type.ToString() && e.Role == person.Role);
|
||||
if (existingMap is null)
|
||||
var entityPerson = personsEntities[(credit.LoweredName, credit.PersonType)];
|
||||
if (existingMapsByCredit.TryGetValue((credit.LoweredName, credit.PersonType, credit.LoweredRole), out var existingMap))
|
||||
{
|
||||
// Update the order for existing mappings
|
||||
existingMap.ListOrder = listOrder;
|
||||
existingMap.SortOrder = credit.Person.SortOrder;
|
||||
// person mapping already exists so remove from list
|
||||
existingMaps.Remove(existingMap);
|
||||
}
|
||||
else
|
||||
{
|
||||
context.PeopleBaseItemMap.Add(new PeopleBaseItemMap()
|
||||
{
|
||||
@@ -147,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++;
|
||||
}
|
||||
@@ -205,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,
|
||||
@@ -240,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())
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -771,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;
|
||||
|
||||
@@ -1528,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;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -47,7 +47,7 @@ namespace MediaBrowser.Controller.Entities.TV
|
||||
public int? IndexNumberEnd { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
protected override bool SupportsOwnedItems => IsStacked || MediaSourceCount > 1;
|
||||
protected override bool SupportsOwnedItems => IsStacked || LocalAlternateVersions.Length > 0 || MediaSourceCount > 1;
|
||||
|
||||
[JsonIgnore]
|
||||
public override bool SupportsInheritedParentImages => true;
|
||||
|
||||
@@ -527,7 +527,13 @@ namespace MediaBrowser.Controller.Entities
|
||||
|
||||
protected override async Task<bool> RefreshedOwnedItems(MetadataRefreshOptions options, IReadOnlyList<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken)
|
||||
{
|
||||
var hasChanges = await base.RefreshedOwnedItems(options, fileSystemChildren, cancellationToken).ConfigureAwait(false);
|
||||
var hasChanges = false;
|
||||
|
||||
// The extras of a version group are maintained by its primary.
|
||||
if (!PrimaryVersionId.HasValue)
|
||||
{
|
||||
hasChanges = await base.RefreshedOwnedItems(options, fileSystemChildren, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Clean up LocalAlternateVersions - remove paths that no longer exist
|
||||
if (LocalAlternateVersions.Length > 0)
|
||||
@@ -588,10 +594,20 @@ namespace MediaBrowser.Controller.Entities
|
||||
{
|
||||
altVideo.OwnerId = Id;
|
||||
altVideo.SetPrimaryVersionId(Id);
|
||||
altVideo.IsInMixedFolder = IsInMixedFolder;
|
||||
LibraryManager.CreateItem(altVideo, GetParent());
|
||||
}
|
||||
}
|
||||
|
||||
// A version is resolved on its own, so it does not learn whether the folder it sits in
|
||||
// holds other items. It has to share that with the version it belongs to, before the
|
||||
// refresh below acts on it.
|
||||
if (LibraryManager.GetItemById(id) is Video resolvedVersion && resolvedVersion.IsInMixedFolder != IsInMixedFolder)
|
||||
{
|
||||
resolvedVersion.IsInMixedFolder = IsInMixedFolder;
|
||||
await resolvedVersion.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await RefreshMetadataForOwnedVideo(options, copyTitleMetadata, path, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Create LinkedChild entry for this local alternate version
|
||||
@@ -671,6 +687,7 @@ namespace MediaBrowser.Controller.Entities
|
||||
|
||||
video.Id = id;
|
||||
video.OwnerId = Id;
|
||||
video.IsInMixedFolder = IsInMixedFolder;
|
||||
LibraryManager.CreateItem(video, parentFolder);
|
||||
newOptions.ForceSave = true;
|
||||
}
|
||||
|
||||
@@ -255,6 +255,14 @@ namespace MediaBrowser.Controller.Library
|
||||
/// <returns>Enumerable of linked Video items.</returns>
|
||||
IEnumerable<Video> GetLinkedAlternateVersions(Video video);
|
||||
|
||||
/// <summary>
|
||||
/// Gets, in a single query, the subset of the supplied items that own at least one alternate
|
||||
/// version (local or linked). Items absent from the result have no alternate versions.
|
||||
/// </summary>
|
||||
/// <param name="itemIds">The item IDs to check.</param>
|
||||
/// <returns>The set of item IDs that have alternate versions.</returns>
|
||||
IReadOnlySet<Guid> GetItemIdsWithAlternateVersions(IReadOnlyList<Guid> itemIds);
|
||||
|
||||
/// <summary>
|
||||
/// Creates or updates a LinkedChild entry linking a parent to a child item.
|
||||
/// </summary>
|
||||
@@ -605,6 +613,13 @@ namespace MediaBrowser.Controller.Library
|
||||
/// <returns>A dictionary mapping each item ID to its distinct people names. Items with no matching people are omitted.</returns>
|
||||
IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the people for multiple items in a single query, keyed by item id.
|
||||
/// </summary>
|
||||
/// <param name="itemIds">The item IDs.</param>
|
||||
/// <returns>A dictionary mapping each item ID to its people. Items with no people are omitted.</returns>
|
||||
IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>> GetPeopleByItems(IReadOnlyList<Guid> itemIds);
|
||||
|
||||
/// <summary>
|
||||
/// Queries the items.
|
||||
/// </summary>
|
||||
|
||||
@@ -19,7 +19,9 @@ public class SearchProviderQuery
|
||||
public Guid? UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the item types to include in the search.
|
||||
/// Gets the item types to include in the search. An empty array means every type is eligible.
|
||||
/// When this is non-empty it is the authoritative type filter and <see cref="ExcludeItemTypes"/>
|
||||
/// does not apply; excludes only take effect when no include types were requested.
|
||||
/// </summary>
|
||||
public BaseItemKind[] IncludeItemTypes { get; init; } = [];
|
||||
|
||||
@@ -29,7 +31,9 @@ public class SearchProviderQuery
|
||||
public BaseItemKind[] ExcludeItemTypes { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the media types to include in the search.
|
||||
/// Gets the media types to include in the search. This is an additional constraint rather than
|
||||
/// an alternative one: a provider must return only items that match both the requested media
|
||||
/// types and the requested item types, not the union of the two.
|
||||
/// </summary>
|
||||
public MediaType[] MediaTypes { get; init; } = [];
|
||||
|
||||
@@ -39,7 +43,9 @@ public class SearchProviderQuery
|
||||
public int? Limit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the parent ID to scope the search.
|
||||
/// Gets the parent ID to scope the search. This scopes to the whole subtree, not just direct
|
||||
/// children - callers routinely pass a library folder id and expect items nested arbitrarily
|
||||
/// deep beneath it (an episode under a season under a series) to match.
|
||||
/// </summary>
|
||||
public Guid? ParentId { get; init; }
|
||||
}
|
||||
|
||||
@@ -7864,10 +7864,16 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
audioTranscodeParams.Add("-acodec " + GetAudioEncoder(state));
|
||||
}
|
||||
|
||||
if (GetAudioEncoder(state).StartsWith("pcm_", StringComparison.Ordinal))
|
||||
// The pcm_* encoders emit raw samples that carry no header of their own, so the header
|
||||
// has to come from the muxer. Only force the matching raw muxer when the client actually
|
||||
// asked for a raw container (added in #10321 for I2S/MCU clients): applying it to every
|
||||
// pcm_* codec also strips the RIFF header from a `stream.wav` request, which then serves
|
||||
// headerless PCM behind an audio/wav content type.
|
||||
var audioEncoder = GetAudioEncoder(state);
|
||||
if (audioEncoder.StartsWith("pcm_", StringComparison.Ordinal)
|
||||
&& string.Equals(state.OutputContainer, "pcm", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
audioTranscodeParams.Add(string.Concat("-f ", GetAudioEncoder(state).AsSpan(4)));
|
||||
audioTranscodeParams.Add("-ar " + state.BaseRequest.AudioBitRate);
|
||||
audioTranscodeParams.Add(string.Concat("-f ", audioEncoder.AsSpan(4)));
|
||||
}
|
||||
|
||||
var sampleRate = state.OutputAudioSampleRate;
|
||||
|
||||
@@ -15,6 +15,7 @@ public sealed class TranscodingJob : IDisposable
|
||||
private readonly Lock _processLock = new();
|
||||
private readonly Lock _timerLock = new();
|
||||
|
||||
private int _activeRequestCount;
|
||||
private Timer? _killTimer;
|
||||
|
||||
/// <summary>
|
||||
@@ -64,7 +65,11 @@ public sealed class TranscodingJob : IDisposable
|
||||
/// <summary>
|
||||
/// Gets or sets the active request count.
|
||||
/// </summary>
|
||||
public int ActiveRequestCount { get; set; }
|
||||
public int ActiveRequestCount
|
||||
{
|
||||
get => Volatile.Read(ref _activeRequestCount);
|
||||
set => Volatile.Write(ref _activeRequestCount, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets device id.
|
||||
@@ -151,6 +156,20 @@ public sealed class TranscodingJob : IDisposable
|
||||
/// </summary>
|
||||
public int PingTimeout { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Increments the active request count.
|
||||
/// </summary>
|
||||
/// <returns>The incremented count.</returns>
|
||||
public int IncrementActiveRequestCount()
|
||||
=> Interlocked.Increment(ref _activeRequestCount);
|
||||
|
||||
/// <summary>
|
||||
/// Decrements the active request count.
|
||||
/// </summary>
|
||||
/// <returns>The decremented count.</returns>
|
||||
public int DecrementActiveRequestCount()
|
||||
=> Interlocked.Decrement(ref _activeRequestCount);
|
||||
|
||||
/// <summary>
|
||||
/// Stop kill timer.
|
||||
/// </summary>
|
||||
|
||||
@@ -19,6 +19,15 @@ public interface ILinkedChildrenService
|
||||
/// <returns>List of child item IDs.</returns>
|
||||
IReadOnlyList<Guid> GetLinkedChildrenIds(Guid parentId, int? childType = null);
|
||||
|
||||
/// <summary>
|
||||
/// Gets, in a single query, the subset of the supplied items that own at least one alternate
|
||||
/// version (local or linked). Items absent from the result have no alternate versions, so their
|
||||
/// media source count is one.
|
||||
/// </summary>
|
||||
/// <param name="itemIds">The item IDs to check.</param>
|
||||
/// <returns>The set of item IDs that have alternate versions.</returns>
|
||||
IReadOnlySet<Guid> GetItemIdsWithAlternateVersions(IReadOnlyList<Guid> itemIds);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all artist matches from the database.
|
||||
/// </summary>
|
||||
|
||||
@@ -40,4 +40,11 @@ public interface IPeopleRepository
|
||||
/// <param name="personTypes">The person types to include (e.g. "Actor", "Director").</param>
|
||||
/// <returns>A dictionary mapping each item ID to its distinct people names, ordered by cast list order. Items with no matching people are omitted.</returns>
|
||||
IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the people for multiple items in a single query, keyed by item id.
|
||||
/// </summary>
|
||||
/// <param name="itemIds">The item IDs to get people for.</param>
|
||||
/// <returns>A dictionary mapping each item ID to its people (with role, type and sort order), ordered by cast list order. Items with no people are omitted.</returns>
|
||||
IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>> GetPeopleByItems(IReadOnlyList<Guid> itemIds);
|
||||
}
|
||||
|
||||
@@ -612,9 +612,9 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
|
||||
/// <inheritdoc />
|
||||
public void OnTranscodeEndRequest(TranscodingJob job)
|
||||
{
|
||||
job.ActiveRequestCount--;
|
||||
_logger.LogDebug("OnTranscodeEndRequest job.ActiveRequestCount={ActiveRequestCount}", job.ActiveRequestCount);
|
||||
if (job.ActiveRequestCount <= 0)
|
||||
var activeRequestCount = job.DecrementActiveRequestCount();
|
||||
_logger.LogDebug("OnTranscodeEndRequest job.ActiveRequestCount={ActiveRequestCount}", activeRequestCount);
|
||||
if (activeRequestCount <= 0)
|
||||
{
|
||||
PingTimer(job, false);
|
||||
}
|
||||
@@ -697,7 +697,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
|
||||
return null;
|
||||
}
|
||||
|
||||
job.ActiveRequestCount++;
|
||||
job.IncrementActiveRequestCount();
|
||||
if (string.IsNullOrWhiteSpace(job.PlaySessionId) || job.Type == TranscodingJobType.Progressive)
|
||||
{
|
||||
job.StopKillTimer();
|
||||
|
||||
@@ -103,6 +103,35 @@ namespace MediaBrowser.Model.Drawing
|
||||
return new ImageDimensions(newWidth, newHeight);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scales a size down uniformly until it fits inside a bounding box.
|
||||
/// Returns the original size if it already fits, so this never upscales.
|
||||
/// </summary>
|
||||
/// <param name="size">The size object.</param>
|
||||
/// <param name="boundingBox">The box the result has to fit inside.</param>
|
||||
/// <returns>A new size object, or <paramref name="size"/> if it already fits.</returns>
|
||||
public static ImageDimensions ScaleDownToFit(ImageDimensions size, ImageDimensions boundingBox)
|
||||
{
|
||||
if (size.Width <= 0 || size.Height <= 0 || boundingBox.Width <= 0 || boundingBox.Height <= 0)
|
||||
{
|
||||
return size;
|
||||
}
|
||||
|
||||
double widthRatio = size.Width / (double)boundingBox.Width;
|
||||
double heightRatio = size.Height / (double)boundingBox.Height;
|
||||
double scaleRatio = Math.Max(widthRatio, heightRatio);
|
||||
|
||||
if (scaleRatio <= 1)
|
||||
{
|
||||
return size;
|
||||
}
|
||||
|
||||
var newWidth = Math.Clamp(Convert.ToInt32(Math.Round(size.Width / scaleRatio)), 1, boundingBox.Width);
|
||||
var newHeight = Math.Clamp(Convert.ToInt32(Math.Round(size.Height / scaleRatio)), 1, boundingBox.Height);
|
||||
|
||||
return new ImageDimensions(newWidth, newHeight);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the new width.
|
||||
/// </summary>
|
||||
|
||||
@@ -163,6 +163,8 @@ namespace MediaBrowser.Providers.Manager
|
||||
_externalUrlProviders = externalUrlProviders.OrderBy(i => i.Name).ToArray();
|
||||
|
||||
_savers = metadataSavers.ToArray();
|
||||
|
||||
ClearMetadataProviderCache();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
||||
@@ -468,6 +468,24 @@ namespace MediaBrowser.Providers.MediaInfo
|
||||
}
|
||||
}
|
||||
|
||||
if (audio.AlbumEntity is not null && !audio.AlbumEntity.NormalizationGain.HasValue)
|
||||
{
|
||||
TryGetSanitizedAdditionalFields(track, "REPLAYGAIN_ALBUM_GAIN", out var trackAlbumGainTag);
|
||||
|
||||
if (trackAlbumGainTag is not null)
|
||||
{
|
||||
if (trackAlbumGainTag.EndsWith("db", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
trackAlbumGainTag = trackAlbumGainTag[..^2].Trim();
|
||||
}
|
||||
|
||||
if (float.TryParse(trackAlbumGainTag, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) && float.IsFinite(value))
|
||||
{
|
||||
audio.AlbumEntity.NormalizationGain = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out _))
|
||||
{
|
||||
if ((TryGetSanitizedAdditionalFields(track, "MUSICBRAINZ_ARTISTID", out var musicBrainzArtistTag)
|
||||
|
||||
@@ -27,6 +27,7 @@ jellyfin_subprojects=(
|
||||
MediaBrowser.Model/MediaBrowser.Model.csproj
|
||||
Emby.Naming/Emby.Naming.csproj
|
||||
src/Jellyfin.Extensions/Jellyfin.Extensions.csproj
|
||||
src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj
|
||||
)
|
||||
issue_template_file="./.github/ISSUE_TEMPLATE/issue report.yml"
|
||||
|
||||
|
||||
-1
@@ -13,7 +13,6 @@
|
||||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Database.Implementations</PackageId>
|
||||
<VersionPrefix>10.11.0</VersionPrefix>
|
||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||
</PropertyGroup>
|
||||
|
||||
+160
-147
@@ -14,11 +14,17 @@ namespace Jellyfin.Database.Implementations;
|
||||
/// <summary>
|
||||
/// Contains a number of query related extensions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every helper here binds its values through <see cref="EF.Parameter{T}(T)"/>. Values embedded as bare
|
||||
/// constants are inlined into the SQL as literals, which gives each distinct value its own entry in EF's
|
||||
/// compiled query cache and its own statement for the database to plan.
|
||||
/// </remarks>
|
||||
public static class JellyfinQueryHelperExtensions
|
||||
{
|
||||
private static readonly MethodInfo _containsMethodGenericCache = typeof(Enumerable).GetMethods(BindingFlags.Public | BindingFlags.Static).First(m => m.Name == nameof(Enumerable.Contains) && m.GetParameters().Length == 2);
|
||||
private static readonly MethodInfo _efParameterInstruction = typeof(EF).GetMethod(nameof(EF.Parameter), BindingFlags.Public | BindingFlags.Static)!;
|
||||
private static readonly ConcurrentDictionary<Type, MethodInfo> _containsQueryCache = new();
|
||||
private static readonly ConcurrentDictionary<Type, MethodInfo> _efParameterCache = new();
|
||||
|
||||
/// <summary>
|
||||
/// Builds an optimised query checking one property against a list of values while maintaining an optimal query.
|
||||
@@ -26,14 +32,68 @@ public static class JellyfinQueryHelperExtensions
|
||||
/// <typeparam name="TEntity">The entity.</typeparam>
|
||||
/// <typeparam name="TProperty">The property type to compare.</typeparam>
|
||||
/// <param name="query">The source query.</param>
|
||||
/// <param name="oneOf">The list of items to check.</param>
|
||||
/// <param name="oneOf">The list of items to check. An empty list matches nothing.</param>
|
||||
/// <param name="property">Property expression.</param>
|
||||
/// <returns>A Query.</returns>
|
||||
public static IQueryable<TEntity> WhereOneOrMany<TEntity, TProperty>(this IQueryable<TEntity> query, IList<TProperty> oneOf, Expression<Func<TEntity, TProperty>> property)
|
||||
public static IQueryable<TEntity> WhereOneOrMany<TEntity, TProperty>(this IQueryable<TEntity> query, IReadOnlyList<TProperty> oneOf, Expression<Func<TEntity, TProperty>> property)
|
||||
{
|
||||
return query.Where(OneOrManyExpressionBuilder(oneOf, property));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an optimised query expression checking one property against a list of values while maintaining an optimal query.
|
||||
/// </summary>
|
||||
/// <typeparam name="TEntity">The entity.</typeparam>
|
||||
/// <typeparam name="TProperty">The property type to compare.</typeparam>
|
||||
/// <param name="oneOf">The list of items to check. An empty list matches nothing.</param>
|
||||
/// <param name="property">Property expression.</param>
|
||||
/// <returns>A Query.</returns>
|
||||
public static Expression<Func<TEntity, bool>> OneOrManyExpressionBuilder<TEntity, TProperty>(this IReadOnlyList<TProperty> oneOf, Expression<Func<TEntity, TProperty>> property)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(oneOf);
|
||||
ArgumentNullException.ThrowIfNull(property);
|
||||
|
||||
var parameter = Expression.Parameter(typeof(TEntity), "item");
|
||||
property = ParameterReplacer.Replace<Func<TEntity, TProperty>, Func<TEntity, TProperty>>(property, property.Parameters[0], parameter);
|
||||
|
||||
if (oneOf.Count == 0)
|
||||
{
|
||||
// Fail closed, and without asking the database to unpack an empty collection to prove it.
|
||||
return Expression.Lambda<Func<TEntity, bool>>(Expression.Constant(false), parameter);
|
||||
}
|
||||
|
||||
if (oneOf.Count == 1)
|
||||
{
|
||||
var value = Expression.Call(
|
||||
null,
|
||||
EfParameterFor(typeof(TProperty)),
|
||||
Expression.Constant(oneOf[0], typeof(TProperty)));
|
||||
|
||||
return Expression.Lambda<Func<TEntity, bool>>(
|
||||
typeof(TProperty).IsValueType
|
||||
? Expression.Equal(property.Body, value)
|
||||
: Expression.ReferenceEqual(property.Body, value),
|
||||
parameter);
|
||||
}
|
||||
|
||||
var containsMethodInfo = _containsQueryCache.GetOrAdd(typeof(TProperty), static (key) => _containsMethodGenericCache.MakeGenericMethod(key));
|
||||
|
||||
// Binding the whole collection as one parameter keeps the statement identical for any element
|
||||
// count, instead of emitting one placeholder per element.
|
||||
return Expression.Lambda<Func<TEntity, bool>>(
|
||||
Expression.Call(
|
||||
null,
|
||||
containsMethodInfo,
|
||||
Expression.Call(null, EfParameterFor(oneOf.GetType()), Expression.Constant(oneOf)),
|
||||
property.Body),
|
||||
parameter);
|
||||
}
|
||||
|
||||
private static MethodInfo EfParameterFor(Type type)
|
||||
{
|
||||
return _efParameterCache.GetOrAdd(type, static (key) => _efParameterInstruction.MakeGenericMethod(key));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a query that checks referenced ItemValues for a cross BaseItem lookup.
|
||||
/// </summary>
|
||||
@@ -47,207 +107,160 @@ public static class JellyfinQueryHelperExtensions
|
||||
this IQueryable<BaseItemEntity> baseQuery,
|
||||
JellyfinDbContext context,
|
||||
ItemValueType itemValueType,
|
||||
IList<Guid> referenceIds,
|
||||
IReadOnlyList<Guid> referenceIds,
|
||||
bool invert = false)
|
||||
{
|
||||
return baseQuery.Where(ReferencedItemFilterExpressionBuilder(context, itemValueType, referenceIds, invert));
|
||||
return baseQuery.WhereReferencedItem(context, [itemValueType], referenceIds, invert);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a query that checks referenced ItemValues for a cross BaseItem lookup.
|
||||
/// Builds a query that checks referenced ItemValues of any of the given types for a cross BaseItem lookup.
|
||||
/// </summary>
|
||||
/// <param name="baseQuery">The source query.</param>
|
||||
/// <param name="context">The database context.</param>
|
||||
/// <param name="itemValueTypes">The type of item value to reference.</param>
|
||||
/// <param name="itemValueTypes">The types of item value to reference.</param>
|
||||
/// <param name="referenceIds">The list of BaseItem ids to check matches.</param>
|
||||
/// <param name="invert">If set an exclusion check is performed instead.</param>
|
||||
/// <returns>A Query.</returns>
|
||||
public static IQueryable<BaseItemEntity> WhereReferencedItemMultipleTypes(
|
||||
/// <remarks>
|
||||
/// Matching is on CleanName alone. Genre/artist/album etc items do not set an ItemValue of their own
|
||||
/// type, so the referenced item's Type is never consulted and ids whose names clean to the same value
|
||||
/// are interchangeable across types.
|
||||
/// </remarks>
|
||||
public static IQueryable<BaseItemEntity> WhereReferencedItem(
|
||||
this IQueryable<BaseItemEntity> baseQuery,
|
||||
JellyfinDbContext context,
|
||||
IList<ItemValueType> itemValueTypes,
|
||||
IList<Guid> referenceIds,
|
||||
IReadOnlyList<ItemValueType> itemValueTypes,
|
||||
IReadOnlyList<Guid> referenceIds,
|
||||
bool invert = false)
|
||||
{
|
||||
var itemFilter = OneOrManyExpressionBuilder<BaseItemEntity, Guid>(referenceIds, f => f.Id);
|
||||
var typeFilter = OneOrManyExpressionBuilder<ItemValueMap, ItemValueType>(itemValueTypes, m => m.ItemValue.Type);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
// Flat sub-selects + Contains instead of a nested correlated .Any(...Any(...)).
|
||||
// Flat sub-selects rather than a correlated .Any(...Any(...)).
|
||||
var referencedCleanValues = context.BaseItems
|
||||
.Where(itemFilter)
|
||||
.Where(OneOrManyExpressionBuilder<BaseItemEntity, Guid>(referenceIds, e => e.Id))
|
||||
.Select(e => e.CleanName);
|
||||
|
||||
var matchingItemIds = context.ItemValuesMap
|
||||
.Where(typeFilter)
|
||||
.Where(OneOrManyExpressionBuilder<ItemValueMap, ItemValueType>(itemValueTypes, m => m.ItemValue.Type))
|
||||
.Where(m => referencedCleanValues.Contains(m.ItemValue.CleanValue))
|
||||
.Select(m => m.ItemId);
|
||||
|
||||
if (invert)
|
||||
{
|
||||
return baseQuery.Where(e => !matchingItemIds.Contains(e.Id));
|
||||
}
|
||||
|
||||
return baseQuery.Where(e => matchingItemIds.Contains(e.Id));
|
||||
return invert
|
||||
? baseQuery.Where(e => !matchingItemIds.Contains(e.Id))
|
||||
: baseQuery.Where(e => matchingItemIds.Contains(e.Id));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a query expression that checks referenced ItemValues for a cross BaseItem lookup.
|
||||
/// </summary>
|
||||
/// <param name="context">The database context.</param>
|
||||
/// <param name="itemValueType">The type of item value to reference.</param>
|
||||
/// <param name="referenceIds">The list of BaseItem ids to check matches.</param>
|
||||
/// <param name="invert">If set an exclusion check is performed instead.</param>
|
||||
/// <returns>A Query.</returns>
|
||||
public static Expression<Func<BaseItemEntity, bool>> ReferencedItemFilterExpressionBuilder(
|
||||
this JellyfinDbContext context,
|
||||
ItemValueType itemValueType,
|
||||
IList<Guid> referenceIds,
|
||||
bool invert = false)
|
||||
{
|
||||
// Well genre/artist/album etc items do not actually set the ItemValue of thier specitic types so we cannot match it that way.
|
||||
/*
|
||||
"(guid in (select itemid from ItemValues where CleanValue = (select CleanName from TypedBaseItems where guid=@GenreIds and Type=2)))"
|
||||
*/
|
||||
|
||||
var itemFilter = OneOrManyExpressionBuilder<BaseItemEntity, Guid>(referenceIds, f => f.Id);
|
||||
|
||||
// Flat sub-selects + Contains instead of a nested correlated .Any(...Any(...)).
|
||||
var referencedCleanValues = context.BaseItems
|
||||
.Where(itemFilter)
|
||||
.Select(e => e.CleanName);
|
||||
|
||||
var matchingItemIds = context.ItemValuesMap
|
||||
.Where(m => m.ItemValue.Type == itemValueType && referencedCleanValues.Contains(m.ItemValue.CleanValue))
|
||||
.Select(m => m.ItemId);
|
||||
|
||||
if (invert)
|
||||
{
|
||||
return item => !matchingItemIds.Contains(item.Id);
|
||||
}
|
||||
|
||||
return item => matchingItemIds.Contains(item.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters items that match any of the specified (provider name, value) pairs.
|
||||
/// Filters items that have any of the specified providers, optionally restricted to given values.
|
||||
/// </summary>
|
||||
/// <param name="baseQuery">The source query.</param>
|
||||
/// <param name="providerIds">Dictionary mapping provider names to arrays of values to match.</param>
|
||||
/// <param name="providerIds">Dictionary mapping provider names to values to match. An empty value array matches any value for that provider.</param>
|
||||
/// <returns>A filtered query.</returns>
|
||||
public static IQueryable<BaseItemEntity> WhereHasAnyProviderIds(
|
||||
this IQueryable<BaseItemEntity> baseQuery,
|
||||
IReadOnlyDictionary<string, string[]> providerIds)
|
||||
{
|
||||
var providerKeys = providerIds
|
||||
.SelectMany(kvp => kvp.Value.Select(v => $"{kvp.Key}:{v}"))
|
||||
.ToList();
|
||||
|
||||
if (providerKeys.Count == 0)
|
||||
{
|
||||
return baseQuery;
|
||||
}
|
||||
|
||||
return baseQuery.Where(e => e.Provider!.Any(p => providerKeys.Contains(p.ProviderId + ":" + p.ProviderValue)));
|
||||
return baseQuery.WhereProviderMatch(Flatten(providerIds), false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters items that have any of the specified providers. Empty/null values match any value for that provider.
|
||||
/// Filters items that have any of the specified providers, optionally restricted to a given value.
|
||||
/// </summary>
|
||||
/// <param name="baseQuery">The source query.</param>
|
||||
/// <param name="providerIds">Dictionary mapping provider names to optional values.</param>
|
||||
/// <param name="providerIds">Dictionary mapping provider names to optional values. An empty value matches any value for that provider.</param>
|
||||
/// <returns>A filtered query.</returns>
|
||||
public static IQueryable<BaseItemEntity> WhereHasAnyProviderId(
|
||||
this IQueryable<BaseItemEntity> baseQuery,
|
||||
IReadOnlyDictionary<string, string> providerIds)
|
||||
{
|
||||
var existenceOnly = providerIds
|
||||
.Where(e => string.IsNullOrEmpty(e.Value))
|
||||
.Select(e => e.Key)
|
||||
.ToList();
|
||||
return baseQuery.WhereProviderMatch(providerIds, false);
|
||||
}
|
||||
|
||||
var specificValues = providerIds
|
||||
.Where(e => !string.IsNullOrEmpty(e.Value))
|
||||
.Select(e => $"{e.Key}:{e.Value}")
|
||||
.ToList();
|
||||
/// <summary>
|
||||
/// Excludes items that have any of the specified providers, optionally restricted to a given value.
|
||||
/// </summary>
|
||||
/// <param name="baseQuery">The source query.</param>
|
||||
/// <param name="providerIds">Dictionary mapping provider names to optional values. An empty value excludes any value for that provider.</param>
|
||||
/// <returns>A filtered query.</returns>
|
||||
public static IQueryable<BaseItemEntity> WhereExcludeProviderIds(
|
||||
this IQueryable<BaseItemEntity> baseQuery,
|
||||
IReadOnlyDictionary<string, string> providerIds)
|
||||
{
|
||||
return baseQuery.WhereProviderMatch(providerIds, true);
|
||||
}
|
||||
|
||||
private static IEnumerable<KeyValuePair<string, string>> Flatten(IReadOnlyDictionary<string, string[]> providerIds)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(providerIds);
|
||||
|
||||
foreach (var (provider, values) in providerIds)
|
||||
{
|
||||
if (values is null || values.Length == 0)
|
||||
{
|
||||
yield return new KeyValuePair<string, string>(provider, string.Empty);
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var value in values)
|
||||
{
|
||||
yield return new KeyValuePair<string, string>(provider, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Matches items against a set of (provider, value) pairs, where an empty value means any value for
|
||||
/// that provider. Emits a single EXISTS over the provider collection with the predicates OR'd, rather
|
||||
/// than one subquery per predicate group.
|
||||
/// </summary>
|
||||
private static IQueryable<BaseItemEntity> WhereProviderMatch(
|
||||
this IQueryable<BaseItemEntity> baseQuery,
|
||||
IEnumerable<KeyValuePair<string, string>> providerIds,
|
||||
bool invert)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(providerIds);
|
||||
|
||||
var existenceOnly = new List<string>();
|
||||
var specificValues = new List<string>();
|
||||
foreach (var (provider, value) in providerIds)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
existenceOnly.Add(provider);
|
||||
}
|
||||
else
|
||||
{
|
||||
specificValues.Add(provider + ":" + value);
|
||||
}
|
||||
}
|
||||
|
||||
if (existenceOnly.Count == 0 && specificValues.Count == 0)
|
||||
{
|
||||
return baseQuery;
|
||||
}
|
||||
|
||||
if (existenceOnly.Count == 0)
|
||||
{
|
||||
return baseQuery.Where(e => e.Provider!.Any(p =>
|
||||
specificValues.Contains(p.ProviderId + ":" + p.ProviderValue)));
|
||||
}
|
||||
var predicate = ProviderPredicate(existenceOnly, specificValues);
|
||||
|
||||
if (specificValues.Count == 0)
|
||||
{
|
||||
return baseQuery.Where(e => e.Provider!.Any(p => existenceOnly.Contains(p.ProviderId)));
|
||||
}
|
||||
|
||||
// Single EXISTS over Provider with both predicates OR'd, instead of two separate subqueries.
|
||||
return baseQuery.Where(e => e.Provider!.Any(p =>
|
||||
existenceOnly.Contains(p.ProviderId) ||
|
||||
specificValues.Contains(p.ProviderId + ":" + p.ProviderValue)));
|
||||
// NOT EXISTS rather than NOT IN: the latter yields no rows at all if the subquery can produce NULL.
|
||||
return invert
|
||||
? baseQuery.Where(e => !e.Provider!.AsQueryable().Any(predicate))
|
||||
: baseQuery.Where(e => e.Provider!.AsQueryable().Any(predicate));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Excludes items that match any of the specified (provider name, value) pairs.
|
||||
/// </summary>
|
||||
/// <param name="baseQuery">The source query.</param>
|
||||
/// <param name="providerIds">Dictionary mapping provider names to values to exclude.</param>
|
||||
/// <returns>A filtered query.</returns>
|
||||
public static IQueryable<BaseItemEntity> WhereExcludeProviderIds(
|
||||
this IQueryable<BaseItemEntity> baseQuery,
|
||||
IReadOnlyDictionary<string, string> providerIds)
|
||||
private static Expression<Func<BaseItemProvider, bool>> ProviderPredicate(
|
||||
IReadOnlyList<string> existenceOnly,
|
||||
IReadOnlyList<string> specificValues)
|
||||
{
|
||||
var excludeKeys = providerIds
|
||||
.Select(e => $"{e.Key}:{e.Value}")
|
||||
.ToList();
|
||||
var byProvider = existenceOnly.OneOrManyExpressionBuilder<BaseItemProvider, string>(p => p.ProviderId);
|
||||
var byPair = specificValues.OneOrManyExpressionBuilder<BaseItemProvider, string>(p => p.ProviderId + ":" + p.ProviderValue);
|
||||
|
||||
if (excludeKeys.Count == 0)
|
||||
{
|
||||
return baseQuery;
|
||||
}
|
||||
// Both builders mint their own parameter; rebind so the two bodies can share one lambda.
|
||||
var parameter = byProvider.Parameters[0];
|
||||
var reboundPair = ParameterReplacer.Replace<Func<BaseItemProvider, bool>, Func<BaseItemProvider, bool>>(byPair, byPair.Parameters[0], parameter);
|
||||
|
||||
return baseQuery.Where(e => e.Provider!.All(p => !excludeKeys.Contains(p.ProviderId + ":" + p.ProviderValue)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an optimised query expression checking one property against a list of values while maintaining an optimal query.
|
||||
/// </summary>
|
||||
/// <typeparam name="TEntity">The entity.</typeparam>
|
||||
/// <typeparam name="TProperty">The property type to compare.</typeparam>
|
||||
/// <param name="oneOf">The list of items to check.</param>
|
||||
/// <param name="property">Property expression.</param>
|
||||
/// <returns>A Query.</returns>
|
||||
public static Expression<Func<TEntity, bool>> OneOrManyExpressionBuilder<TEntity, TProperty>(this IList<TProperty> oneOf, Expression<Func<TEntity, TProperty>> property)
|
||||
{
|
||||
var parameter = Expression.Parameter(typeof(TEntity), "item");
|
||||
property = ParameterReplacer.Replace<Func<TEntity, TProperty>, Func<TEntity, TProperty>>(property, property.Parameters[0], parameter);
|
||||
if (oneOf.Count == 1)
|
||||
{
|
||||
var value = oneOf[0];
|
||||
if (typeof(TProperty).IsValueType)
|
||||
{
|
||||
return Expression.Lambda<Func<TEntity, bool>>(Expression.Equal(property.Body, Expression.Constant(value)), parameter);
|
||||
}
|
||||
else
|
||||
{
|
||||
return Expression.Lambda<Func<TEntity, bool>>(Expression.ReferenceEqual(property.Body, Expression.Constant(value)), parameter);
|
||||
}
|
||||
}
|
||||
|
||||
var containsMethodInfo = _containsQueryCache.GetOrAdd(typeof(TProperty), static (key) => _containsMethodGenericCache.MakeGenericMethod(key));
|
||||
|
||||
// Always wrap the collection in EF.Parameter so EF Core caches a single compiled plan and reuses it across calls.
|
||||
return Expression.Lambda<Func<TEntity, bool>>(
|
||||
Expression.Call(
|
||||
null,
|
||||
containsMethodInfo,
|
||||
Expression.Call(null, _efParameterInstruction.MakeGenericMethod(oneOf.GetType()), Expression.Constant(oneOf)),
|
||||
property.Body),
|
||||
return Expression.Lambda<Func<BaseItemProvider, bool>>(
|
||||
Expression.OrElse(byProvider.Body, reboundPair.Body),
|
||||
parameter);
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -88,13 +88,13 @@ public class OptimisticLockBehavior : IEntityFrameworkCoreLockingBehavior
|
||||
/// <inheritdoc/>
|
||||
public void OnSaveChanges(JellyfinDbContext context, Action saveChanges)
|
||||
{
|
||||
_writePolicy.ExecuteAndCapture(saveChanges);
|
||||
_writePolicy.Execute(saveChanges);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task OnSaveChangesAsync(JellyfinDbContext context, Func<Task> saveChanges)
|
||||
{
|
||||
await _writeAsyncPolicy.ExecuteAndCaptureAsync(saveChanges).ConfigureAwait(false);
|
||||
await _writeAsyncPolicy.ExecuteAsync(saveChanges).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private sealed class TransactionLockingInterceptor : DbTransactionInterceptor
|
||||
|
||||
+9
-1
@@ -17,6 +17,13 @@ namespace Jellyfin.Database.Implementations.Locking;
|
||||
/// <summary>
|
||||
/// A locking behavior that will always block any operation while a write is requested. Mimicks the old SqliteRepository behavior.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Unsafe with asynchronous transactions; because <see cref="ReaderWriterLockSlim"/> is
|
||||
/// thread-affine, holding it from <c>TransactionStarting</c> to <c>TransactionCommitted</c>
|
||||
/// works only while continuations resume inline. A genuinely-async continuation inside a
|
||||
/// transaction releases on another thread, throwing
|
||||
/// <see cref="SynchronizationLockException"/> or deadlocking a later write.
|
||||
/// </remarks>
|
||||
public class PessimisticLockBehavior : IEntityFrameworkCoreLockingBehavior
|
||||
{
|
||||
private readonly ILogger<PessimisticLockBehavior> _logger;
|
||||
@@ -47,7 +54,8 @@ public class PessimisticLockBehavior : IEntityFrameworkCoreLockingBehavior
|
||||
/// <inheritdoc/>
|
||||
public void Initialise(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
_logger.LogInformation("The database locking mode has been set to: Pessimistic.");
|
||||
_logger.LogWarning(
|
||||
"The database locking mode has been set to: Pessimistic. This mode is not safe with asynchronous transactions and can deadlock.");
|
||||
optionsBuilder.AddInterceptors(new CommandLockingInterceptor(_loggerFactory.CreateLogger<CommandLockingInterceptor>()));
|
||||
optionsBuilder.AddInterceptors(new TransactionLockingInterceptor(_loggerFactory.CreateLogger<TransactionLockingInterceptor>()));
|
||||
}
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ public class PeopleBaseItemMapConfiguration : IEntityTypeConfiguration<PeopleBas
|
||||
builder.HasKey(e => new { e.ItemId, e.PeopleId, e.Role });
|
||||
builder.HasIndex(e => new { e.ItemId, e.SortOrder });
|
||||
builder.HasIndex(e => new { e.ItemId, e.ListOrder });
|
||||
builder.HasIndex(e => e.PeopleId);
|
||||
builder.HasIndex(e => new { e.PeopleId, e.ItemId });
|
||||
builder.HasOne(e => e.Item);
|
||||
builder.HasOne(e => e.People);
|
||||
}
|
||||
|
||||
+23
-10
@@ -117,8 +117,6 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.HasIndex("UserId", "ItemId", "Client", "Key")
|
||||
.IsUnique();
|
||||
|
||||
@@ -176,8 +174,6 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.HasIndex("UserId", "ItemId", "Client")
|
||||
.IsUnique();
|
||||
|
||||
@@ -291,12 +287,17 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Value")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Permission_Permissions_Guid");
|
||||
b.HasIndex("UserId", "Kind")
|
||||
.IsUnique()
|
||||
.HasFilter("[UserId] IS NOT NULL");
|
||||
|
||||
b.ToTable("Permissions");
|
||||
});
|
||||
@@ -317,6 +318,9 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasMaxLength(65535)
|
||||
@@ -324,7 +328,9 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Preference_Preferences_Guid");
|
||||
b.HasIndex("UserId", "Kind")
|
||||
.IsUnique()
|
||||
.HasFilter("[UserId] IS NOT NULL");
|
||||
|
||||
b.ToTable("Preferences");
|
||||
});
|
||||
@@ -431,10 +437,14 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
.HasColumnType("TEXT")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Username")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
@@ -469,7 +479,8 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
{
|
||||
b.HasOne("Jellyfin.Data.Entities.User", null)
|
||||
.WithOne("ProfileImage")
|
||||
.HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId");
|
||||
.HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b =>
|
||||
@@ -485,14 +496,16 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
{
|
||||
b.HasOne("Jellyfin.Data.Entities.User", null)
|
||||
.WithMany("Permissions")
|
||||
.HasForeignKey("Permission_Permissions_Guid");
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b =>
|
||||
{
|
||||
b.HasOne("Jellyfin.Data.Entities.User", null)
|
||||
.WithMany("Preferences")
|
||||
.HasForeignKey("Preference_Preferences_Guid");
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b =>
|
||||
|
||||
+31
@@ -445,6 +445,37 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
b.ToTable("DeviceOptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jellyfin.Data.Entities.TrickplayInfo", b =>
|
||||
{
|
||||
b.Property<Guid>("ItemId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Width")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Bandwidth")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Height")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Interval")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ThumbnailCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("TileHeight")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("TileWidth")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("ItemId", "Width");
|
||||
|
||||
b.ToTable("TrickplayInfos");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jellyfin.Data.Entities.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
||||
+3
@@ -285,6 +285,9 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
b.Property<Guid>("ItemId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SegmentProviderId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("StartTicks")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
||||
+1
-2
@@ -1247,8 +1247,7 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT")
|
||||
.UseCollation("NOCASE");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
|
||||
+1
-2
@@ -1247,8 +1247,7 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT")
|
||||
.UseCollation("NOCASE");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
|
||||
+7
-1
@@ -229,6 +229,9 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
b.Property<int?>("IndexNumber")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("InheritedParentalRatingSubValue")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("InheritedParentalRatingValue")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -1275,7 +1278,10 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
b.Property<int>("MaxActiveSessions")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("MaxParentalAgeRating")
|
||||
b.Property<int?>("MaxParentalRatingScore")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("MaxParentalRatingSubScore")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("MustUpdatePassword")
|
||||
|
||||
+36
-1
@@ -229,6 +229,9 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
b.Property<int?>("IndexNumber")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("InheritedParentalRatingSubValue")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("InheritedParentalRatingValue")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -748,6 +751,24 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b =>
|
||||
{
|
||||
b.Property<Guid>("ItemId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.PrimitiveCollection<string>("KeyframeTicks")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("TotalDuration")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("ItemId");
|
||||
|
||||
b.ToTable("KeyframeData");
|
||||
|
||||
b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1260,7 +1281,10 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
b.Property<int>("MaxActiveSessions")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("MaxParentalAgeRating")
|
||||
b.Property<int?>("MaxParentalRatingScore")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("MaxParentalRatingSubScore")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("MustUpdatePassword")
|
||||
@@ -1519,6 +1543,17 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
b.Navigation("ItemValue");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b =>
|
||||
{
|
||||
b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
|
||||
.WithMany()
|
||||
.HasForeignKey("ItemId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Item");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b =>
|
||||
{
|
||||
b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
|
||||
|
||||
+32
@@ -750,6 +750,24 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b =>
|
||||
{
|
||||
b.Property<Guid>("ItemId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.PrimitiveCollection<string>("KeyframeTicks")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("TotalDuration")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("ItemId");
|
||||
|
||||
b.ToTable("KeyframeData");
|
||||
|
||||
b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -847,6 +865,9 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
b.Property<int?>("ElPresentFlag")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool?>("Hdr10PlusPresentFlag")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("Height")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -1521,6 +1542,17 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
b.Navigation("ItemValue");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b =>
|
||||
{
|
||||
b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
|
||||
.WithMany()
|
||||
.HasForeignKey("ItemId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Item");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b =>
|
||||
{
|
||||
b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
|
||||
|
||||
+32
-1
@@ -123,7 +123,6 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Codec")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CodecTag")
|
||||
@@ -751,6 +750,24 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b =>
|
||||
{
|
||||
b.Property<Guid>("ItemId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.PrimitiveCollection<string>("KeyframeTicks")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("TotalDuration")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("ItemId");
|
||||
|
||||
b.ToTable("KeyframeData");
|
||||
|
||||
b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -848,6 +865,9 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
b.Property<int?>("ElPresentFlag")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool?>("Hdr10PlusPresentFlag")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("Height")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -1522,6 +1542,17 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
b.Navigation("ItemValue");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b =>
|
||||
{
|
||||
b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
|
||||
.WithMany()
|
||||
.HasForeignKey("ItemId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Item");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b =>
|
||||
{
|
||||
b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
|
||||
|
||||
-3
@@ -183,9 +183,6 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
b.Property<DateTime?>("DateCreated")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("DateCreatedFilesystem")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("DateLastMediaAdded")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
||||
+3
@@ -955,6 +955,9 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations
|
||||
b.Property<bool?>("IsInterlaced")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsOriginal")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("KeyFrames")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
||||
+3
@@ -952,6 +952,9 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations
|
||||
b.Property<bool?>("IsInterlaced")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsOriginal")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("KeyFrames")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
||||
+3
@@ -952,6 +952,9 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations
|
||||
b.Property<bool?>("IsInterlaced")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsOriginal")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("KeyFrames")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
||||
+3
@@ -954,6 +954,9 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations
|
||||
b.Property<bool?>("IsInterlaced")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsOriginal")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("KeyFrames")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
||||
+3
@@ -956,6 +956,9 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations
|
||||
b.Property<bool?>("IsInterlaced")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsOriginal")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("KeyFrames")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
||||
+3
@@ -956,6 +956,9 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations
|
||||
b.Property<bool?>("IsInterlaced")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsOriginal")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("KeyFrames")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
||||
+3
@@ -956,6 +956,9 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations
|
||||
b.Property<bool?>("IsInterlaced")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsOriginal")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("KeyFrames")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
||||
+3
@@ -958,6 +958,9 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations
|
||||
b.Property<bool?>("IsInterlaced")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsOriginal")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("KeyFrames")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
||||
+3
@@ -961,6 +961,9 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations
|
||||
b.Property<bool?>("IsInterlaced")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsOriginal")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("KeyFrames")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
||||
+3
@@ -961,6 +961,9 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
b.Property<bool?>("IsInterlaced")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsOriginal")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("KeyFrames")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
||||
+3
@@ -961,6 +961,9 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
b.Property<bool?>("IsInterlaced")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsOriginal")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("KeyFrames")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
||||
+4
-6
@@ -818,23 +818,21 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
b.Property<Guid>("ParentId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("ChildId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ChildType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("ParentId", "ChildId");
|
||||
b.HasKey("ParentId", "SortOrder");
|
||||
|
||||
b.HasIndex("ChildId", "ChildType");
|
||||
|
||||
b.HasIndex("ParentId", "ChildType");
|
||||
|
||||
b.HasIndex("ParentId", "SortOrder");
|
||||
|
||||
b.ToTable("LinkedChildren", (string)null);
|
||||
|
||||
b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
|
||||
|
||||
+1811
File diff suppressed because it is too large
Load Diff
+25
@@ -0,0 +1,25 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPeopleNameLowerIndex : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Expression index, so it cannot be declared on the entity type. /Persons collapses the
|
||||
// one-row-per-(Name, PersonType) table to one row per lowercased name; without this index
|
||||
// that dedup scans and groups the whole table on every request.
|
||||
migrationBuilder.Sql("CREATE INDEX IF NOT EXISTS \"IX_Peoples_NameLower\" ON \"Peoples\" (lower(\"Name\"));");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("DROP INDEX IF EXISTS \"IX_Peoples_NameLower\";");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user