954 lines
36 KiB
C#
954 lines
36 KiB
C#
using ErsatzTV.Core.Api.LibraryBrowse;
|
|
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.Emby;
|
|
using ErsatzTV.Core.Interfaces.Search;
|
|
using ErsatzTV.Core.Jellyfin;
|
|
using ErsatzTV.Core.Search;
|
|
using ErsatzTV.Infrastructure.Data;
|
|
using ErsatzTV.Infrastructure.Search;
|
|
using Flurl;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace ErsatzTV.Application.LibraryBrowse;
|
|
|
|
public class GetLibraryBrowseItemsHandler(
|
|
ISearchIndex searchIndex,
|
|
IDbContextFactory<TvContext> dbContextFactory)
|
|
: IRequestHandler<GetLibraryBrowseItems, PagedLibraryBrowseItemsResponseModel>
|
|
{
|
|
private const string LibraryIdField = "library_id";
|
|
|
|
public async Task<PagedLibraryBrowseItemsResponseModel> Handle(
|
|
GetLibraryBrowseItems request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
int offset = request.PageNum * request.PageSize;
|
|
SearchResult mediaResult = await SearchMedia(request, offset, request.PageSize, cancellationToken);
|
|
|
|
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
|
|
|
List<LibraryBrowseItemResponseModel> mediaItems = await HydrateMediaItems(
|
|
dbContext,
|
|
mediaResult.Items,
|
|
cancellationToken);
|
|
|
|
int collectionTotal = await CountCollections(dbContext, request, cancellationToken);
|
|
// Lucene total count can briefly include stale media ids until the async rescan catches up; collection
|
|
// paging may drift in that window, matching the staleness behavior accepted by the existing search UI.
|
|
int collectionSkip = Math.Max(0, offset - mediaResult.TotalCount);
|
|
int collectionTake = request.PageSize - mediaItems.Count;
|
|
List<LibraryBrowseItemResponseModel> collectionItems = collectionTake > 0
|
|
? await GetCollectionItems(dbContext, request, collectionSkip, collectionTake, cancellationToken)
|
|
: [];
|
|
|
|
return new PagedLibraryBrowseItemsResponseModel(
|
|
mediaResult.TotalCount + collectionTotal,
|
|
mediaItems.Concat(collectionItems).ToList());
|
|
}
|
|
|
|
private async Task<SearchResult> SearchMedia(
|
|
GetLibraryBrowseItems request,
|
|
int offset,
|
|
int pageSize,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
List<string> typeFilters = MediaTypesFor(request.MediaType).ToList();
|
|
if (typeFilters.Count == 0)
|
|
{
|
|
return new SearchResult([], 0);
|
|
}
|
|
|
|
var clauses = new List<string>
|
|
{
|
|
typeFilters.Count == 1
|
|
? $"type:{typeFilters[0]}"
|
|
: $"({string.Join(" OR ", typeFilters.Map(t => $"type:{t}"))})"
|
|
};
|
|
|
|
if (request.LibraryId.HasValue)
|
|
{
|
|
clauses.Add($"{LibraryIdField}:{request.LibraryId.Value}");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(request.Query))
|
|
{
|
|
clauses.Add($"({request.Query})");
|
|
}
|
|
|
|
return await searchIndex.Search(
|
|
string.Join(" AND ", clauses),
|
|
string.Empty,
|
|
offset,
|
|
pageSize,
|
|
[LuceneSearchIndex.TitleAndYearSearchField],
|
|
cancellationToken);
|
|
}
|
|
|
|
private static IEnumerable<string> MediaTypesFor(LibraryBrowseMediaType? mediaType) =>
|
|
mediaType switch
|
|
{
|
|
LibraryBrowseMediaType.Movie => [LuceneSearchIndex.MovieType],
|
|
LibraryBrowseMediaType.TelevisionShow => [LuceneSearchIndex.ShowType],
|
|
LibraryBrowseMediaType.TelevisionSeason => [LuceneSearchIndex.SeasonType],
|
|
LibraryBrowseMediaType.Artist => [LuceneSearchIndex.ArtistType],
|
|
null =>
|
|
[
|
|
LuceneSearchIndex.MovieType,
|
|
LuceneSearchIndex.ShowType,
|
|
LuceneSearchIndex.SeasonType,
|
|
LuceneSearchIndex.ArtistType
|
|
],
|
|
_ => []
|
|
};
|
|
|
|
private static async Task<List<LibraryBrowseItemResponseModel>> HydrateMediaItems(
|
|
TvContext dbContext,
|
|
List<SearchItem> searchItems,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (searchItems.Count == 0)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
List<int> movieIds = searchItems.Where(i => i.Type == LuceneSearchIndex.MovieType).Select(i => i.Id).ToList();
|
|
List<int> showIds = searchItems.Where(i => i.Type == LuceneSearchIndex.ShowType).Select(i => i.Id).ToList();
|
|
List<int> seasonIds = searchItems.Where(i => i.Type == LuceneSearchIndex.SeasonType).Select(i => i.Id).ToList();
|
|
List<int> artistIds = searchItems.Where(i => i.Type == LuceneSearchIndex.ArtistType).Select(i => i.Id).ToList();
|
|
|
|
Dictionary<(string Type, int Id), LibraryBrowseItemResponseModel> hydrated = [];
|
|
|
|
foreach (LibraryBrowseItemResponseModel item in await GetMovies(dbContext, movieIds, cancellationToken))
|
|
{
|
|
hydrated[(LuceneSearchIndex.MovieType, item.Id)] = item;
|
|
}
|
|
|
|
foreach (LibraryBrowseItemResponseModel item in await GetShows(dbContext, showIds, cancellationToken))
|
|
{
|
|
hydrated[(LuceneSearchIndex.ShowType, item.Id)] = item;
|
|
}
|
|
|
|
foreach (LibraryBrowseItemResponseModel item in await GetSeasons(dbContext, seasonIds, cancellationToken))
|
|
{
|
|
hydrated[(LuceneSearchIndex.SeasonType, item.Id)] = item;
|
|
}
|
|
|
|
foreach (LibraryBrowseItemResponseModel item in await GetArtists(dbContext, artistIds, cancellationToken))
|
|
{
|
|
hydrated[(LuceneSearchIndex.ArtistType, item.Id)] = item;
|
|
}
|
|
|
|
return searchItems
|
|
.Where(i => hydrated.ContainsKey((i.Type, i.Id)))
|
|
.Select(i => hydrated[(i.Type, i.Id)])
|
|
.ToList();
|
|
}
|
|
|
|
private static async Task<List<LibraryBrowseItemResponseModel>> GetMovies(
|
|
TvContext dbContext,
|
|
List<int> ids,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (ids.Count == 0)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
return await dbContext.MovieMetadata
|
|
.AsNoTracking()
|
|
.Where(mm => ids.Contains(mm.MovieId))
|
|
.Include(mm => mm.Artwork)
|
|
.Include(mm => mm.Movie)
|
|
.ThenInclude(m => m.LibraryPath)
|
|
.ThenInclude(lp => lp.Library)
|
|
.Include(mm => mm.Movie)
|
|
.ThenInclude(m => m.MediaVersions)
|
|
.ToListAsync(cancellationToken)
|
|
.Map(list => list
|
|
.GroupBy(mm => mm.MovieId)
|
|
.Select(g => g.OrderBy(mm => mm.Id).First())
|
|
.Map(mm => new LibraryBrowseItemResponseModel(
|
|
mm.MovieId,
|
|
LibraryBrowseMediaType.Movie,
|
|
mm.Title ?? string.Empty,
|
|
mm.Movie.LibraryPath.LibraryId,
|
|
mm.Movie.LibraryPath.Library.Name,
|
|
Artwork(mm, ArtworkKind.Poster),
|
|
BestDuration(mm.Movie.MediaVersions),
|
|
1,
|
|
null,
|
|
CollectionType.Movie,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
mm.MovieId,
|
|
null)).ToList());
|
|
}
|
|
|
|
private static async Task<List<LibraryBrowseItemResponseModel>> GetShows(
|
|
TvContext dbContext,
|
|
List<int> ids,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (ids.Count == 0)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
Dictionary<int, int> counts = await dbContext.Episodes
|
|
.AsNoTracking()
|
|
.Where(e => ids.Contains(e.Season.ShowId))
|
|
.GroupBy(e => e.Season.ShowId)
|
|
.Select(g => new { ShowId = g.Key, Count = g.Count() })
|
|
.ToDictionaryAsync(g => g.ShowId, g => g.Count, cancellationToken);
|
|
|
|
return await dbContext.ShowMetadata
|
|
.AsNoTracking()
|
|
.Where(sm => ids.Contains(sm.ShowId))
|
|
.Include(sm => sm.Artwork)
|
|
.Include(sm => sm.Show)
|
|
.ThenInclude(s => s.LibraryPath)
|
|
.ThenInclude(lp => lp.Library)
|
|
.ToListAsync(cancellationToken)
|
|
.Map(list => list
|
|
.GroupBy(sm => sm.ShowId)
|
|
.Select(g => g.OrderBy(sm => sm.Id).First())
|
|
.Map(sm => new LibraryBrowseItemResponseModel(
|
|
sm.ShowId,
|
|
LibraryBrowseMediaType.TelevisionShow,
|
|
sm.Title ?? string.Empty,
|
|
sm.Show.LibraryPath.LibraryId,
|
|
sm.Show.LibraryPath.Library.Name,
|
|
Artwork(sm, ArtworkKind.Poster),
|
|
null,
|
|
counts.TryGetValue(sm.ShowId, out int count) ? count : 0,
|
|
null,
|
|
CollectionType.TelevisionShow,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
sm.ShowId,
|
|
null)).ToList());
|
|
}
|
|
|
|
private static async Task<List<LibraryBrowseItemResponseModel>> GetSeasons(
|
|
TvContext dbContext,
|
|
List<int> ids,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (ids.Count == 0)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
Dictionary<int, int> counts = await dbContext.Episodes
|
|
.AsNoTracking()
|
|
.Where(e => ids.Contains(e.SeasonId))
|
|
.GroupBy(e => e.SeasonId)
|
|
.Select(g => new { SeasonId = g.Key, Count = g.Count() })
|
|
.ToDictionaryAsync(g => g.SeasonId, g => g.Count, cancellationToken);
|
|
|
|
return await dbContext.SeasonMetadata
|
|
.AsNoTracking()
|
|
.Where(sm => ids.Contains(sm.SeasonId))
|
|
.Include(sm => sm.Artwork)
|
|
.Include(sm => sm.Season)
|
|
.ThenInclude(s => s.Show)
|
|
.ThenInclude(s => s.ShowMetadata)
|
|
.Include(sm => sm.Season)
|
|
.ThenInclude(s => s.LibraryPath)
|
|
.ThenInclude(lp => lp.Library)
|
|
.ToListAsync(cancellationToken)
|
|
.Map(list => list
|
|
.GroupBy(sm => sm.SeasonId)
|
|
.Select(g => g.OrderBy(sm => sm.Id).First())
|
|
.Map(sm => new LibraryBrowseItemResponseModel(
|
|
sm.SeasonId,
|
|
LibraryBrowseMediaType.TelevisionSeason,
|
|
SeasonTitle(sm),
|
|
sm.Season.LibraryPath.LibraryId,
|
|
sm.Season.LibraryPath.Library.Name,
|
|
Artwork(sm, ArtworkKind.Poster),
|
|
null,
|
|
counts.TryGetValue(sm.SeasonId, out int count) ? count : 0,
|
|
null,
|
|
CollectionType.TelevisionSeason,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
sm.SeasonId,
|
|
null)).ToList());
|
|
}
|
|
|
|
private static async Task<List<LibraryBrowseItemResponseModel>> GetArtists(
|
|
TvContext dbContext,
|
|
List<int> ids,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (ids.Count == 0)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
Dictionary<int, int> counts = await dbContext.MusicVideos
|
|
.AsNoTracking()
|
|
.Where(mv => ids.Contains(mv.ArtistId))
|
|
.GroupBy(mv => mv.ArtistId)
|
|
.Select(g => new { ArtistId = g.Key, Count = g.Count() })
|
|
.ToDictionaryAsync(g => g.ArtistId, g => g.Count, cancellationToken);
|
|
|
|
return await dbContext.ArtistMetadata
|
|
.AsNoTracking()
|
|
.Where(am => ids.Contains(am.ArtistId))
|
|
.Include(am => am.Artwork)
|
|
.Include(am => am.Artist)
|
|
.ThenInclude(a => a.LibraryPath)
|
|
.ThenInclude(lp => lp.Library)
|
|
.ToListAsync(cancellationToken)
|
|
.Map(list => list
|
|
.GroupBy(am => am.ArtistId)
|
|
.Select(g => g.OrderBy(am => am.Id).First())
|
|
.Map(am => new LibraryBrowseItemResponseModel(
|
|
am.ArtistId,
|
|
LibraryBrowseMediaType.Artist,
|
|
am.Title ?? string.Empty,
|
|
am.Artist.LibraryPath.LibraryId,
|
|
am.Artist.LibraryPath.Library.Name,
|
|
Artwork(am, ArtworkKind.Thumbnail),
|
|
null,
|
|
counts.TryGetValue(am.ArtistId, out int count) ? count : 0,
|
|
null,
|
|
CollectionType.Artist,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
am.ArtistId,
|
|
null)).ToList());
|
|
}
|
|
|
|
private static async Task<int> CountCollections(
|
|
TvContext dbContext,
|
|
GetLibraryBrowseItems request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
int count = 0;
|
|
if (ShouldInclude(request.MediaType, LibraryBrowseMediaType.Collection))
|
|
{
|
|
count += await FilterCollections(dbContext.Collections.AsNoTracking(), request)
|
|
.CountAsync(cancellationToken);
|
|
}
|
|
|
|
if (!request.LibraryId.HasValue && ShouldInclude(request.MediaType, LibraryBrowseMediaType.SmartCollection))
|
|
{
|
|
count += await FilterByName(dbContext.SmartCollections.AsNoTracking(), request.Query)
|
|
.CountAsync(cancellationToken);
|
|
}
|
|
|
|
if (!request.LibraryId.HasValue && ShouldInclude(request.MediaType, LibraryBrowseMediaType.MultiCollection))
|
|
{
|
|
count += await FilterByName(dbContext.MultiCollections.AsNoTracking(), request.Query)
|
|
.CountAsync(cancellationToken);
|
|
}
|
|
|
|
if (!request.LibraryId.HasValue && ShouldInclude(request.MediaType, LibraryBrowseMediaType.RerunCollection))
|
|
{
|
|
count += await FilterByName(dbContext.RerunCollections.AsNoTracking(), request.Query)
|
|
.CountAsync(cancellationToken);
|
|
}
|
|
|
|
if (!request.LibraryId.HasValue && ShouldInclude(request.MediaType, LibraryBrowseMediaType.Playlist))
|
|
{
|
|
count += await FilterByName(dbContext.Playlists.AsNoTracking(), request.Query)
|
|
.CountAsync(cancellationToken);
|
|
}
|
|
|
|
return count;
|
|
}
|
|
|
|
private static async Task<List<LibraryBrowseItemResponseModel>> GetCollectionItems(
|
|
TvContext dbContext,
|
|
GetLibraryBrowseItems request,
|
|
int skip,
|
|
int take,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var results = new List<LibraryBrowseItemResponseModel>();
|
|
|
|
int remainingSkip = skip;
|
|
|
|
if (ShouldInclude(request.MediaType, LibraryBrowseMediaType.Collection))
|
|
{
|
|
IQueryable<Collection> collectionQuery = FilterCollections(dbContext.Collections.AsNoTracking(), request);
|
|
int count = await collectionQuery.CountAsync(cancellationToken);
|
|
int pageSkip = Math.Min(remainingSkip, count);
|
|
remainingSkip -= pageSkip;
|
|
|
|
List<Collection> collections = await collectionQuery
|
|
.OrderBy(c => c.Name)
|
|
.Skip(pageSkip)
|
|
.Take(take)
|
|
.ToListAsync(cancellationToken);
|
|
List<int> collectionIds = collections.Map(c => c.Id).ToList();
|
|
Dictionary<int, int> itemCounts = await dbContext.CollectionItems
|
|
.AsNoTracking()
|
|
.Where(ci => collectionIds.Contains(ci.CollectionId))
|
|
.GroupBy(ci => ci.CollectionId)
|
|
.Select(g => new { CollectionId = g.Key, Count = g.Count() })
|
|
.ToDictionaryAsync(g => g.CollectionId, g => g.Count, cancellationToken);
|
|
Dictionary<int, TimeSpan?> durations = await GetManualCollectionDurations(
|
|
dbContext,
|
|
collectionIds,
|
|
cancellationToken);
|
|
Dictionary<int, string> artwork = await GetManualCollectionArtwork(dbContext, collectionIds, cancellationToken);
|
|
|
|
results.AddRange(collections.Map(c => new LibraryBrowseItemResponseModel(
|
|
c.Id,
|
|
LibraryBrowseMediaType.Collection,
|
|
c.Name,
|
|
null,
|
|
null,
|
|
artwork.TryGetValue(c.Id, out string poster) ? poster : string.Empty,
|
|
durations.TryGetValue(c.Id, out TimeSpan? duration) ? duration : null,
|
|
itemCounts.TryGetValue(c.Id, out int itemCount) ? itemCount : 0,
|
|
"Manual",
|
|
CollectionType.Collection,
|
|
c.Id,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
null)));
|
|
}
|
|
|
|
if (results.Count >= take || request.LibraryId.HasValue)
|
|
{
|
|
return results.Take(take).ToList();
|
|
}
|
|
|
|
remainingSkip = await AppendSmartCollections(dbContext, request, results, remainingSkip, take, cancellationToken);
|
|
remainingSkip = await AppendMultiCollections(dbContext, request, results, remainingSkip, take, cancellationToken);
|
|
remainingSkip = await AppendRerunCollections(dbContext, request, results, remainingSkip, take, cancellationToken);
|
|
await AppendPlaylists(dbContext, request, results, remainingSkip, take, cancellationToken);
|
|
return results.Take(take).ToList();
|
|
}
|
|
|
|
private static async Task<int> AppendSmartCollections(
|
|
TvContext dbContext,
|
|
GetLibraryBrowseItems request,
|
|
List<LibraryBrowseItemResponseModel> results,
|
|
int skip,
|
|
int take,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!ShouldInclude(request.MediaType, LibraryBrowseMediaType.SmartCollection) || results.Count >= take)
|
|
{
|
|
return skip;
|
|
}
|
|
|
|
IQueryable<SmartCollection> query = FilterByName(dbContext.SmartCollections.AsNoTracking(), request.Query);
|
|
int count = await query.CountAsync(cancellationToken);
|
|
int pageSkip = Math.Min(skip, count);
|
|
|
|
List<SmartCollection> page = await query
|
|
.OrderBy(c => c.Name)
|
|
.Skip(pageSkip)
|
|
.Take(take - results.Count)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
results.AddRange(page.Map(c => new LibraryBrowseItemResponseModel(
|
|
c.Id,
|
|
LibraryBrowseMediaType.SmartCollection,
|
|
c.Name,
|
|
null,
|
|
null,
|
|
string.Empty,
|
|
null,
|
|
null,
|
|
"Smart",
|
|
CollectionType.SmartCollection,
|
|
null,
|
|
null,
|
|
c.Id,
|
|
null,
|
|
null,
|
|
null)));
|
|
|
|
return skip - pageSkip;
|
|
}
|
|
|
|
private static async Task<int> AppendMultiCollections(
|
|
TvContext dbContext,
|
|
GetLibraryBrowseItems request,
|
|
List<LibraryBrowseItemResponseModel> results,
|
|
int skip,
|
|
int take,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!ShouldInclude(request.MediaType, LibraryBrowseMediaType.MultiCollection) || results.Count >= take)
|
|
{
|
|
return skip;
|
|
}
|
|
|
|
IQueryable<MultiCollection> query = FilterByName(dbContext.MultiCollections.AsNoTracking(), request.Query);
|
|
int count = await query.CountAsync(cancellationToken);
|
|
int pageSkip = Math.Min(skip, count);
|
|
|
|
List<MultiCollection> page = await query
|
|
.OrderBy(c => c.Name)
|
|
.Skip(pageSkip)
|
|
.Take(take - results.Count)
|
|
.ToListAsync(cancellationToken);
|
|
List<int> ids = page.Map(c => c.Id).ToList();
|
|
Dictionary<int, int> collectionCounts = await dbContext.Set<MultiCollectionItem>()
|
|
.AsNoTracking()
|
|
.Where(i => ids.Contains(i.MultiCollectionId))
|
|
.GroupBy(i => i.MultiCollectionId)
|
|
.Select(g => new { MultiCollectionId = g.Key, Count = g.Count() })
|
|
.ToDictionaryAsync(g => g.MultiCollectionId, g => g.Count, cancellationToken);
|
|
Dictionary<int, int> smartCollectionCounts = await dbContext.Set<MultiCollectionSmartItem>()
|
|
.AsNoTracking()
|
|
.Where(i => ids.Contains(i.MultiCollectionId))
|
|
.GroupBy(i => i.MultiCollectionId)
|
|
.Select(g => new { MultiCollectionId = g.Key, Count = g.Count() })
|
|
.ToDictionaryAsync(g => g.MultiCollectionId, g => g.Count, cancellationToken);
|
|
|
|
results.AddRange(page.Map(c => new LibraryBrowseItemResponseModel(
|
|
c.Id,
|
|
LibraryBrowseMediaType.MultiCollection,
|
|
c.Name,
|
|
null,
|
|
null,
|
|
string.Empty,
|
|
null,
|
|
(collectionCounts.TryGetValue(c.Id, out int collectionCount) ? collectionCount : 0) +
|
|
(smartCollectionCounts.TryGetValue(c.Id, out int smartCollectionCount) ? smartCollectionCount : 0),
|
|
"Multi",
|
|
CollectionType.MultiCollection,
|
|
null,
|
|
c.Id,
|
|
null,
|
|
null,
|
|
null,
|
|
null)));
|
|
|
|
return skip - pageSkip;
|
|
}
|
|
|
|
private static async Task<int> AppendRerunCollections(
|
|
TvContext dbContext,
|
|
GetLibraryBrowseItems request,
|
|
List<LibraryBrowseItemResponseModel> results,
|
|
int skip,
|
|
int take,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!ShouldInclude(request.MediaType, LibraryBrowseMediaType.RerunCollection) || results.Count >= take)
|
|
{
|
|
return skip;
|
|
}
|
|
|
|
IQueryable<RerunCollection> query = FilterByName(dbContext.RerunCollections.AsNoTracking(), request.Query);
|
|
int count = await query.CountAsync(cancellationToken);
|
|
int pageSkip = Math.Min(skip, count);
|
|
|
|
List<RerunCollection> page = await query
|
|
.OrderBy(c => c.Name)
|
|
.Skip(pageSkip)
|
|
.Take(take - results.Count)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
results.AddRange(page.Map(c => new LibraryBrowseItemResponseModel(
|
|
c.Id,
|
|
LibraryBrowseMediaType.RerunCollection,
|
|
c.Name,
|
|
null,
|
|
null,
|
|
string.Empty,
|
|
null,
|
|
null,
|
|
"Rerun",
|
|
// RerunFirstRun addresses the first-run side; the SPA can substitute RerunRerun for rerun schedule items.
|
|
CollectionType.RerunFirstRun,
|
|
null,
|
|
null,
|
|
null,
|
|
c.Id,
|
|
null,
|
|
null)));
|
|
|
|
return skip - pageSkip;
|
|
}
|
|
|
|
private static async Task AppendPlaylists(
|
|
TvContext dbContext,
|
|
GetLibraryBrowseItems request,
|
|
List<LibraryBrowseItemResponseModel> results,
|
|
int skip,
|
|
int take,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!ShouldInclude(request.MediaType, LibraryBrowseMediaType.Playlist) || results.Count >= take)
|
|
{
|
|
return;
|
|
}
|
|
|
|
IQueryable<Playlist> query = FilterByName(dbContext.Playlists.AsNoTracking(), request.Query);
|
|
int count = await query.CountAsync(cancellationToken);
|
|
int pageSkip = Math.Min(skip, count);
|
|
|
|
List<Playlist> page = await query
|
|
.OrderBy(c => c.Name)
|
|
.Skip(pageSkip)
|
|
.Take(take - results.Count)
|
|
.ToListAsync(cancellationToken);
|
|
List<int> ids = page.Map(c => c.Id).ToList();
|
|
Dictionary<int, int> itemCounts = await dbContext.PlaylistItems
|
|
.AsNoTracking()
|
|
.Where(i => ids.Contains(i.PlaylistId))
|
|
.GroupBy(i => i.PlaylistId)
|
|
.Select(g => new { PlaylistId = g.Key, Count = g.Count() })
|
|
.ToDictionaryAsync(g => g.PlaylistId, g => g.Count, cancellationToken);
|
|
|
|
results.AddRange(page.Map(c => new LibraryBrowseItemResponseModel(
|
|
c.Id,
|
|
LibraryBrowseMediaType.Playlist,
|
|
c.Name,
|
|
null,
|
|
null,
|
|
string.Empty,
|
|
null,
|
|
itemCounts.TryGetValue(c.Id, out int itemCount) ? itemCount : 0,
|
|
null,
|
|
CollectionType.Playlist,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
c.Id)));
|
|
}
|
|
|
|
private static IQueryable<Collection> FilterCollections(
|
|
IQueryable<Collection> query,
|
|
GetLibraryBrowseItems request)
|
|
{
|
|
query = FilterByName(query, request.Query);
|
|
if (request.LibraryId.HasValue)
|
|
{
|
|
query = query.Where(c => c.MediaItems.Any(mi => mi.LibraryPath.LibraryId == request.LibraryId.Value));
|
|
}
|
|
|
|
return query;
|
|
}
|
|
|
|
private static IQueryable<T> FilterByName<T>(IQueryable<T> query, string searchQuery)
|
|
where T : class
|
|
{
|
|
if (string.IsNullOrWhiteSpace(searchQuery))
|
|
{
|
|
return query;
|
|
}
|
|
|
|
return query.Where(c => EF.Functions.Like(EF.Property<string>(c, "Name"), $"%{EscapeLike(searchQuery)}%", "\\"));
|
|
}
|
|
|
|
private static bool ShouldInclude(LibraryBrowseMediaType? requestType, LibraryBrowseMediaType itemType) =>
|
|
requestType is null || requestType == itemType;
|
|
|
|
private static TimeSpan? BestDuration(IEnumerable<MediaVersion> versions)
|
|
{
|
|
TimeSpan duration = versions
|
|
.Select(v => v.Duration)
|
|
.Where(d => d > TimeSpan.Zero)
|
|
.DefaultIfEmpty()
|
|
.Max();
|
|
return duration > TimeSpan.Zero ? duration : null;
|
|
}
|
|
|
|
private static async Task<Dictionary<int, TimeSpan?>> GetManualCollectionDurations(
|
|
TvContext dbContext,
|
|
List<int> collectionIds,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
List<CollectionMediaItem> items = await GetCollectionMediaItems(dbContext, collectionIds, cancellationToken);
|
|
List<int> mediaItemIds = items.Map(i => i.MediaItemId).Distinct().ToList();
|
|
Dictionary<int, TimeSpan> mediaItemDurations = [];
|
|
|
|
foreach (Movie movie in await dbContext.Movies
|
|
.AsNoTracking()
|
|
.Where(m => mediaItemIds.Contains(m.Id))
|
|
.Include(m => m.MediaVersions)
|
|
.ToListAsync(cancellationToken))
|
|
{
|
|
TimeSpan? duration = BestDuration(movie.MediaVersions);
|
|
if (duration.HasValue)
|
|
{
|
|
mediaItemDurations[movie.Id] = duration.Value;
|
|
}
|
|
}
|
|
|
|
foreach (Episode episode in await dbContext.Episodes
|
|
.AsNoTracking()
|
|
.Where(e => mediaItemIds.Contains(e.Id))
|
|
.Include(e => e.MediaVersions)
|
|
.ToListAsync(cancellationToken))
|
|
{
|
|
TimeSpan? duration = BestDuration(episode.MediaVersions);
|
|
if (duration.HasValue)
|
|
{
|
|
mediaItemDurations[episode.Id] = duration.Value;
|
|
}
|
|
}
|
|
|
|
foreach (MusicVideo musicVideo in await dbContext.MusicVideos
|
|
.AsNoTracking()
|
|
.Where(mv => mediaItemIds.Contains(mv.Id))
|
|
.Include(mv => mv.MediaVersions)
|
|
.ToListAsync(cancellationToken))
|
|
{
|
|
TimeSpan? duration = BestDuration(musicVideo.MediaVersions);
|
|
if (duration.HasValue)
|
|
{
|
|
mediaItemDurations[musicVideo.Id] = duration.Value;
|
|
}
|
|
}
|
|
|
|
foreach (OtherVideo otherVideo in await dbContext.OtherVideos
|
|
.AsNoTracking()
|
|
.Where(ov => mediaItemIds.Contains(ov.Id))
|
|
.Include(ov => ov.MediaVersions)
|
|
.ToListAsync(cancellationToken))
|
|
{
|
|
TimeSpan? duration = BestDuration(otherVideo.MediaVersions);
|
|
if (duration.HasValue)
|
|
{
|
|
mediaItemDurations[otherVideo.Id] = duration.Value;
|
|
}
|
|
}
|
|
|
|
foreach (Song song in await dbContext.Songs
|
|
.AsNoTracking()
|
|
.Where(s => mediaItemIds.Contains(s.Id))
|
|
.Include(s => s.MediaVersions)
|
|
.ToListAsync(cancellationToken))
|
|
{
|
|
TimeSpan? duration = BestDuration(song.MediaVersions);
|
|
if (duration.HasValue)
|
|
{
|
|
mediaItemDurations[song.Id] = duration.Value;
|
|
}
|
|
}
|
|
|
|
foreach (Image image in await dbContext.Images
|
|
.AsNoTracking()
|
|
.Where(i => mediaItemIds.Contains(i.Id))
|
|
.Include(i => i.MediaVersions)
|
|
.ToListAsync(cancellationToken))
|
|
{
|
|
TimeSpan? duration = BestDuration(image.MediaVersions);
|
|
if (duration.HasValue)
|
|
{
|
|
mediaItemDurations[image.Id] = duration.Value;
|
|
}
|
|
}
|
|
|
|
foreach (RemoteStream remoteStream in await dbContext.RemoteStreams
|
|
.AsNoTracking()
|
|
.Where(rs => mediaItemIds.Contains(rs.Id))
|
|
.Include(rs => rs.MediaVersions)
|
|
.ToListAsync(cancellationToken))
|
|
{
|
|
TimeSpan? duration = BestDuration(remoteStream.MediaVersions);
|
|
if (duration.HasValue)
|
|
{
|
|
mediaItemDurations[remoteStream.Id] = duration.Value;
|
|
}
|
|
}
|
|
|
|
return items
|
|
.Where(i => mediaItemDurations.ContainsKey(i.MediaItemId))
|
|
.GroupBy(i => i.CollectionId)
|
|
.ToDictionary(
|
|
g => g.Key,
|
|
g => (TimeSpan?)g.Aggregate(TimeSpan.Zero, (sum, item) => sum + mediaItemDurations[item.MediaItemId]));
|
|
}
|
|
|
|
private static async Task<Dictionary<int, string>> GetManualCollectionArtwork(
|
|
TvContext dbContext,
|
|
List<int> collectionIds,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
List<CollectionMediaItem> items = await GetCollectionMediaItems(dbContext, collectionIds, cancellationToken);
|
|
List<int> mediaItemIds = items.Map(i => i.MediaItemId).Distinct().ToList();
|
|
Dictionary<int, string> mediaItemArtwork = [];
|
|
|
|
foreach (MovieMetadata metadata in await dbContext.MovieMetadata
|
|
.AsNoTracking()
|
|
.Where(mm => mediaItemIds.Contains(mm.MovieId))
|
|
.Include(mm => mm.Artwork)
|
|
.OrderBy(mm => mm.Id)
|
|
.ToListAsync(cancellationToken))
|
|
{
|
|
string poster = Artwork(metadata, ArtworkKind.Poster);
|
|
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.MovieId))
|
|
{
|
|
mediaItemArtwork[metadata.MovieId] = poster;
|
|
}
|
|
}
|
|
|
|
foreach (ShowMetadata metadata in await dbContext.ShowMetadata
|
|
.AsNoTracking()
|
|
.Where(sm => mediaItemIds.Contains(sm.ShowId))
|
|
.Include(sm => sm.Artwork)
|
|
.OrderBy(sm => sm.Id)
|
|
.ToListAsync(cancellationToken))
|
|
{
|
|
string poster = Artwork(metadata, ArtworkKind.Poster);
|
|
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.ShowId))
|
|
{
|
|
mediaItemArtwork[metadata.ShowId] = poster;
|
|
}
|
|
}
|
|
|
|
foreach (SeasonMetadata metadata in await dbContext.SeasonMetadata
|
|
.AsNoTracking()
|
|
.Where(sm => mediaItemIds.Contains(sm.SeasonId))
|
|
.Include(sm => sm.Artwork)
|
|
.OrderBy(sm => sm.Id)
|
|
.ToListAsync(cancellationToken))
|
|
{
|
|
string poster = Artwork(metadata, ArtworkKind.Poster);
|
|
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.SeasonId))
|
|
{
|
|
mediaItemArtwork[metadata.SeasonId] = poster;
|
|
}
|
|
}
|
|
|
|
foreach (OtherVideoMetadata metadata in await dbContext.OtherVideoMetadata
|
|
.AsNoTracking()
|
|
.Where(ovm => mediaItemIds.Contains(ovm.OtherVideoId))
|
|
.Include(ovm => ovm.Artwork)
|
|
.OrderBy(ovm => ovm.Id)
|
|
.ToListAsync(cancellationToken))
|
|
{
|
|
string poster = Artwork(metadata, ArtworkKind.Poster);
|
|
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.OtherVideoId))
|
|
{
|
|
mediaItemArtwork[metadata.OtherVideoId] = poster;
|
|
}
|
|
}
|
|
|
|
foreach (SongMetadata metadata in await dbContext.SongMetadata
|
|
.AsNoTracking()
|
|
.Where(sm => mediaItemIds.Contains(sm.SongId))
|
|
.Include(sm => sm.Artwork)
|
|
.OrderBy(sm => sm.Id)
|
|
.ToListAsync(cancellationToken))
|
|
{
|
|
string poster = Artwork(metadata, ArtworkKind.Poster);
|
|
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.SongId))
|
|
{
|
|
mediaItemArtwork[metadata.SongId] = poster;
|
|
}
|
|
}
|
|
|
|
foreach (ImageMetadata metadata in await dbContext.ImageMetadata
|
|
.AsNoTracking()
|
|
.Where(im => mediaItemIds.Contains(im.ImageId))
|
|
.Include(im => im.Artwork)
|
|
.OrderBy(im => im.Id)
|
|
.ToListAsync(cancellationToken))
|
|
{
|
|
string poster = Artwork(metadata, ArtworkKind.Poster);
|
|
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.ImageId))
|
|
{
|
|
mediaItemArtwork[metadata.ImageId] = poster;
|
|
}
|
|
}
|
|
|
|
foreach (RemoteStreamMetadata metadata in await dbContext.RemoteStreamMetadata
|
|
.AsNoTracking()
|
|
.Where(rsm => mediaItemIds.Contains(rsm.RemoteStreamId))
|
|
.Include(rsm => rsm.Artwork)
|
|
.OrderBy(rsm => rsm.Id)
|
|
.ToListAsync(cancellationToken))
|
|
{
|
|
string poster = Artwork(metadata, ArtworkKind.Poster);
|
|
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.RemoteStreamId))
|
|
{
|
|
mediaItemArtwork[metadata.RemoteStreamId] = poster;
|
|
}
|
|
}
|
|
|
|
return items
|
|
.Where(i => mediaItemArtwork.ContainsKey(i.MediaItemId))
|
|
.GroupBy(i => i.CollectionId)
|
|
.ToDictionary(
|
|
g => g.Key,
|
|
g => mediaItemArtwork[g.OrderBy(i => i.CustomIndex ?? int.MaxValue).ThenBy(i => i.MediaItemId).First().MediaItemId]);
|
|
}
|
|
|
|
private static async Task<List<CollectionMediaItem>> GetCollectionMediaItems(
|
|
TvContext dbContext,
|
|
List<int> collectionIds,
|
|
CancellationToken cancellationToken) =>
|
|
await dbContext.CollectionItems
|
|
.AsNoTracking()
|
|
.Where(ci => collectionIds.Contains(ci.CollectionId))
|
|
.Select(ci => new CollectionMediaItem(ci.CollectionId, ci.MediaItemId, ci.CustomIndex))
|
|
.ToListAsync(cancellationToken);
|
|
|
|
private static string SeasonTitle(SeasonMetadata metadata)
|
|
{
|
|
string showTitle = metadata.Season.Show.ShowMetadata.HeadOrNone()
|
|
.Map(sm => sm.Title ?? string.Empty)
|
|
.IfNone(string.Empty);
|
|
string seasonTitle = metadata.Season.SeasonNumber == 0
|
|
? "Specials"
|
|
: $"Season {metadata.Season.SeasonNumber}";
|
|
return string.IsNullOrWhiteSpace(showTitle) ? seasonTitle : $"{showTitle} - {seasonTitle}";
|
|
}
|
|
|
|
private static string Artwork(Metadata metadata, ArtworkKind artworkKind)
|
|
{
|
|
string artwork = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
|
|
.Match(a => a.Path, string.Empty);
|
|
|
|
if (artwork.StartsWith("jellyfin://", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
Url url = JellyfinUrl.RelativeProxyForArtwork(artwork);
|
|
if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail)
|
|
{
|
|
url.SetQueryParam("fillHeight", 440);
|
|
}
|
|
|
|
return url;
|
|
}
|
|
|
|
if (artwork.StartsWith("emby://", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
Url url = EmbyUrl.RelativeProxyForArtwork(artwork);
|
|
if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail)
|
|
{
|
|
url.SetQueryParam("maxHeight", 440);
|
|
}
|
|
|
|
return url;
|
|
}
|
|
|
|
return artwork;
|
|
}
|
|
|
|
private static string EscapeLike(string searchQuery) =>
|
|
searchQuery
|
|
.Replace("\\", "\\\\", StringComparison.Ordinal)
|
|
.Replace("%", "\\%", StringComparison.Ordinal)
|
|
.Replace("_", "\\_", StringComparison.Ordinal);
|
|
|
|
private sealed record CollectionMediaItem(int CollectionId, int MediaItemId, int? CustomIndex);
|
|
}
|