Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
630119f382 | ||
|
|
127a130737 | ||
|
|
a5de775480 | ||
|
|
8a62f22208 | ||
|
|
a8d4979b0c | ||
|
|
7d93688841 | ||
|
|
d9ffbde79c | ||
|
|
d63719ece9 | ||
|
|
bee686c846 | ||
|
|
5c4acb9c7b | ||
|
|
223ef70912 | ||
|
|
a8dd88ef73 | ||
|
|
c4190e3a54 | ||
|
|
1840ef83b1 | ||
|
|
c5021e28da | ||
|
|
229f7cc1b0 | ||
|
|
f18f2dc058 | ||
|
|
2e58fa1815 | ||
|
|
8f792d964e | ||
|
|
7ee6e9870c | ||
|
|
ecf6cf7513 | ||
|
|
15c6cdd086 | ||
|
|
39d76080de | ||
|
|
4ffd777b49 | ||
|
|
e6197d63ff | ||
|
|
6a9f1e41a4 | ||
|
|
a6056f73d8 | ||
|
|
da7636fdad | ||
|
|
cc6ffcb8c4 | ||
|
|
f9bb230673 | ||
|
|
6350845101 | ||
|
|
9cd107bc8d | ||
|
|
3e8cfa6288 | ||
|
|
692a71fd13 |
@@ -0,0 +1,10 @@
|
||||
using ErsatzTV.Core.Api.LibraryBrowse;
|
||||
|
||||
namespace ErsatzTV.Application.LibraryBrowse;
|
||||
|
||||
public record GetLibraryBrowseItems(
|
||||
string Query,
|
||||
int? LibraryId,
|
||||
LibraryBrowseMediaType? MediaType,
|
||||
int PageNum,
|
||||
int PageSize) : IRequest<PagedLibraryBrowseItemsResponseModel>;
|
||||
@@ -0,0 +1,953 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Streaming;
|
||||
using ErsatzTV.Core.Streaming;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Streaming;
|
||||
|
||||
[TestFixture]
|
||||
public class DirectStreamSessionTrackerTests
|
||||
{
|
||||
[Test]
|
||||
public void Should_Track_Concurrent_Viewers_Per_Channel()
|
||||
{
|
||||
var tracker = new DirectStreamSessionTracker();
|
||||
|
||||
using IDisposable session1 = tracker.Register("1", StreamingMode.TransportStream);
|
||||
using IDisposable session2 = tracker.Register("1", StreamingMode.HttpLiveStreamingDirect);
|
||||
using IDisposable session3 = tracker.Register("2", StreamingMode.TransportStream);
|
||||
|
||||
tracker.IsActive("1").ShouldBeTrue();
|
||||
tracker.GetViewerCount("1").ShouldBe(2);
|
||||
tracker.GetViewerCount("2").ShouldBe(1);
|
||||
tracker.GetActiveSessions().Count.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Remove_Only_Disposed_Session()
|
||||
{
|
||||
var tracker = new DirectStreamSessionTracker();
|
||||
|
||||
IDisposable session1 = tracker.Register("1", StreamingMode.TransportStream);
|
||||
IDisposable session2 = tracker.Register("1", StreamingMode.TransportStream);
|
||||
|
||||
session1.Dispose();
|
||||
|
||||
tracker.IsActive("1").ShouldBeTrue();
|
||||
tracker.GetViewerCount("1").ShouldBe(1);
|
||||
|
||||
session2.Dispose();
|
||||
|
||||
tracker.IsActive("1").ShouldBeFalse();
|
||||
tracker.GetViewerCount("1").ShouldBe(0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Dispose_Registration_Only_Once()
|
||||
{
|
||||
var tracker = new DirectStreamSessionTracker();
|
||||
|
||||
IDisposable session = tracker.Register("1", StreamingMode.TransportStream);
|
||||
|
||||
session.Dispose();
|
||||
session.Dispose();
|
||||
|
||||
tracker.IsActive("1").ShouldBeFalse();
|
||||
tracker.GetViewerCount("1").ShouldBe(0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Filter_Active_Sessions_By_Channel()
|
||||
{
|
||||
var tracker = new DirectStreamSessionTracker();
|
||||
|
||||
using IDisposable session1 = tracker.Register("1", StreamingMode.TransportStream);
|
||||
using IDisposable session2 = tracker.Register("2", StreamingMode.HttpLiveStreamingDirect);
|
||||
|
||||
IReadOnlyCollection<DirectStreamSession> sessions = tracker.GetActiveSessions("2");
|
||||
|
||||
sessions.Count.ShouldBe(1);
|
||||
sessions.Single().ChannelNumber.ShouldBe("2");
|
||||
sessions.Single().StreamingMode.ShouldBe(StreamingMode.HttpLiveStreamingDirect);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Not_Orphan_Session_When_Last_Registration_Is_Removed_During_Register()
|
||||
{
|
||||
DirectStreamSessionTracker tracker = null;
|
||||
IDisposable existingSession = null;
|
||||
|
||||
tracker = new TestDirectStreamSessionTracker(() => existingSession?.Dispose());
|
||||
existingSession = tracker.Register("1", StreamingMode.TransportStream);
|
||||
|
||||
using IDisposable newSession = tracker.Register("1", StreamingMode.HttpLiveStreamingDirect);
|
||||
|
||||
tracker.IsActive("1").ShouldBeTrue();
|
||||
tracker.GetViewerCount("1").ShouldBe(1);
|
||||
|
||||
IReadOnlyCollection<DirectStreamSession> sessions = tracker.GetActiveSessions("1");
|
||||
sessions.Count.ShouldBe(1);
|
||||
sessions.Single().StreamingMode.ShouldBe(StreamingMode.HttpLiveStreamingDirect);
|
||||
}
|
||||
|
||||
private sealed class TestDirectStreamSessionTracker(Action onRegisteringSession) : DirectStreamSessionTracker
|
||||
{
|
||||
protected override void OnRegisteringSession() => onRegisteringSession();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
#nullable enable
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Api.LibraryBrowse;
|
||||
|
||||
public record LibraryBrowseItemResponseModel(
|
||||
int Id,
|
||||
LibraryBrowseMediaType MediaType,
|
||||
string Title,
|
||||
int? LibraryId,
|
||||
string? LibraryName,
|
||||
string Artwork,
|
||||
TimeSpan? Duration,
|
||||
int? ItemCount,
|
||||
string? CollectionKind,
|
||||
CollectionType CollectionType,
|
||||
int? CollectionId,
|
||||
int? MultiCollectionId,
|
||||
int? SmartCollectionId,
|
||||
int? RerunCollectionId,
|
||||
int? MediaItemId,
|
||||
int? PlaylistId);
|
||||
@@ -0,0 +1,15 @@
|
||||
#nullable enable
|
||||
namespace ErsatzTV.Core.Api.LibraryBrowse;
|
||||
|
||||
public enum LibraryBrowseMediaType
|
||||
{
|
||||
Movie = 1,
|
||||
TelevisionShow = 2,
|
||||
TelevisionSeason = 3,
|
||||
Artist = 4,
|
||||
Collection = 5,
|
||||
SmartCollection = 6,
|
||||
MultiCollection = 7,
|
||||
RerunCollection = 8,
|
||||
Playlist = 9
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#nullable enable
|
||||
namespace ErsatzTV.Core.Api.LibraryBrowse;
|
||||
|
||||
public record PagedLibraryBrowseItemsResponseModel(
|
||||
int TotalCount,
|
||||
List<LibraryBrowseItemResponseModel> Page);
|
||||
@@ -0,0 +1,13 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Streaming;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Streaming;
|
||||
|
||||
public interface IDirectStreamSessionTracker
|
||||
{
|
||||
IDisposable Register(string channelNumber, StreamingMode streamingMode);
|
||||
bool IsActive(string channelNumber);
|
||||
int GetViewerCount(string channelNumber);
|
||||
IReadOnlyCollection<DirectStreamSession> GetActiveSessions();
|
||||
IReadOnlyCollection<DirectStreamSession> GetActiveSessions(string channelNumber);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Streaming;
|
||||
|
||||
public record DirectStreamSession(
|
||||
Guid Id,
|
||||
string ChannelNumber,
|
||||
StreamingMode StreamingMode,
|
||||
DateTimeOffset StartedAt);
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Streaming;
|
||||
|
||||
namespace ErsatzTV.Core.Streaming;
|
||||
|
||||
public class DirectStreamSessionTracker : IDirectStreamSessionTracker
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<Guid, DirectStreamSession>> _sessions = new();
|
||||
|
||||
public IDisposable Register(string channelNumber, StreamingMode streamingMode)
|
||||
{
|
||||
var session = new DirectStreamSession(Guid.NewGuid(), channelNumber, streamingMode, DateTimeOffset.Now);
|
||||
|
||||
ConcurrentDictionary<Guid, DirectStreamSession> channelSessions =
|
||||
_sessions.GetOrAdd(channelNumber, _ => new ConcurrentDictionary<Guid, DirectStreamSession>());
|
||||
|
||||
OnRegisteringSession();
|
||||
|
||||
channelSessions.TryAdd(session.Id, session);
|
||||
|
||||
return new Registration(this, session);
|
||||
}
|
||||
|
||||
protected virtual void OnRegisteringSession()
|
||||
{
|
||||
}
|
||||
|
||||
public bool IsActive(string channelNumber) => GetViewerCount(channelNumber) > 0;
|
||||
|
||||
public int GetViewerCount(string channelNumber) =>
|
||||
_sessions.TryGetValue(channelNumber, out ConcurrentDictionary<Guid, DirectStreamSession> channelSessions)
|
||||
? channelSessions.Count
|
||||
: 0;
|
||||
|
||||
public IReadOnlyCollection<DirectStreamSession> GetActiveSessions() =>
|
||||
_sessions.Values.SelectMany(s => s.Values).ToList();
|
||||
|
||||
public IReadOnlyCollection<DirectStreamSession> GetActiveSessions(string channelNumber) =>
|
||||
_sessions.TryGetValue(channelNumber, out ConcurrentDictionary<Guid, DirectStreamSession> channelSessions)
|
||||
? channelSessions.Values.ToList()
|
||||
: [];
|
||||
|
||||
private void Remove(DirectStreamSession session)
|
||||
{
|
||||
if (!_sessions.TryGetValue(session.ChannelNumber, out ConcurrentDictionary<Guid, DirectStreamSession> channelSessions))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
channelSessions.TryRemove(session.Id, out _);
|
||||
}
|
||||
|
||||
private sealed class Registration(DirectStreamSessionTracker tracker, DirectStreamSession session) : IDisposable
|
||||
{
|
||||
private int _disposed;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 0)
|
||||
{
|
||||
tracker.Remove(session);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
using ErsatzTV.Application.LibraryBrowse;
|
||||
using ErsatzTV.Core.Api.LibraryBrowse;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Search;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.LibraryBrowse;
|
||||
|
||||
[TestFixture]
|
||||
public class GetLibraryBrowseItemsHandlerTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
private ISearchIndex _searchIndex = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_db = await InMemoryTvContext.CreateAsync();
|
||||
_searchIndex = Substitute.For<ISearchIndex>();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Search_Index_With_Type_Library_And_Title_Filters()
|
||||
{
|
||||
_searchIndex.Search(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<List<string>>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(new SearchResult([], 0));
|
||||
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
|
||||
|
||||
await handler.Handle(
|
||||
new GetLibraryBrowseItems("star", 5, LibraryBrowseMediaType.Movie, 2, 25),
|
||||
CancellationToken.None);
|
||||
|
||||
await _searchIndex.Received(1).Search(
|
||||
Arg.Is<string>(q => q.Contains("type:movie") && q.Contains("library_id:5") && q.Contains("star")),
|
||||
string.Empty,
|
||||
50,
|
||||
25,
|
||||
Arg.Is<List<string>>(fields => fields.SequenceEqual(new[] { LuceneSearchIndex.TitleAndYearSearchField })),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Hydrate_Media_Items_In_Search_Order_With_Artwork_Duration_And_Counts()
|
||||
{
|
||||
await SeedLibraryGraph();
|
||||
_searchIndex.Search(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<List<string>>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(new SearchResult(
|
||||
[
|
||||
new SearchItem(LuceneSearchIndex.ShowType, 20),
|
||||
new SearchItem(LuceneSearchIndex.MovieType, 10)
|
||||
],
|
||||
2));
|
||||
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
|
||||
|
||||
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
|
||||
new GetLibraryBrowseItems("", null, null, 0, 10),
|
||||
CancellationToken.None);
|
||||
|
||||
result.TotalCount.ShouldBe(2);
|
||||
result.Page[0].MediaType.ShouldBe(LibraryBrowseMediaType.TelevisionShow);
|
||||
result.Page[0].Title.ShouldBe("Collision Show");
|
||||
result.Page[0].ItemCount.ShouldBe(1);
|
||||
result.Page[0].MediaItemId.ShouldBe(20);
|
||||
result.Page[1].MediaType.ShouldBe(LibraryBrowseMediaType.Movie);
|
||||
result.Page[1].Title.ShouldBe("Collision Movie");
|
||||
result.Page[1].Artwork.ShouldBe("movie-poster.jpg");
|
||||
result.Page[1].Duration.ShouldBe(TimeSpan.FromMinutes(95));
|
||||
result.Page[1].MediaItemId.ShouldBe(10);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_Typed_Collection_And_Playlist_Picker_Targets()
|
||||
{
|
||||
await SeedCollectionGraph();
|
||||
_searchIndex.Search(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<List<string>>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(new SearchResult([], 0));
|
||||
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
|
||||
|
||||
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
|
||||
new GetLibraryBrowseItems("", null, null, 0, 10),
|
||||
CancellationToken.None);
|
||||
|
||||
result.TotalCount.ShouldBe(5);
|
||||
result.Page.ShouldContain(i =>
|
||||
i.MediaType == LibraryBrowseMediaType.Collection &&
|
||||
i.CollectionKind == "Manual" &&
|
||||
i.CollectionType == CollectionType.Collection &&
|
||||
i.CollectionId == 20 &&
|
||||
i.ItemCount == 1);
|
||||
result.Page.ShouldContain(i =>
|
||||
i.MediaType == LibraryBrowseMediaType.SmartCollection &&
|
||||
i.CollectionKind == "Smart" &&
|
||||
i.CollectionType == CollectionType.SmartCollection &&
|
||||
i.SmartCollectionId == 30);
|
||||
result.Page.ShouldContain(i =>
|
||||
i.MediaType == LibraryBrowseMediaType.MultiCollection &&
|
||||
i.CollectionKind == "Multi" &&
|
||||
i.CollectionType == CollectionType.MultiCollection &&
|
||||
i.MultiCollectionId == 40);
|
||||
result.Page.ShouldContain(i =>
|
||||
i.MediaType == LibraryBrowseMediaType.RerunCollection &&
|
||||
i.CollectionKind == "Rerun" &&
|
||||
i.CollectionType == CollectionType.RerunFirstRun &&
|
||||
i.RerunCollectionId == 50);
|
||||
result.Page.ShouldContain(i =>
|
||||
i.MediaType == LibraryBrowseMediaType.Playlist &&
|
||||
i.CollectionType == CollectionType.Playlist &&
|
||||
i.PlaylistId == 60);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Page_Across_Media_Manual_Collections_And_Smart_Collections()
|
||||
{
|
||||
await SeedMediaAndCollectionPagingGraph();
|
||||
_searchIndex.Search(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Is<int>(offset => offset == 0),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<List<string>>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(new SearchResult(
|
||||
[
|
||||
new SearchItem(LuceneSearchIndex.MovieType, 101),
|
||||
new SearchItem(LuceneSearchIndex.MovieType, 102)
|
||||
],
|
||||
2));
|
||||
_searchIndex.Search(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Is<int>(offset => offset > 0),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<List<string>>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(new SearchResult([], 2));
|
||||
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
|
||||
|
||||
PagedLibraryBrowseItemsResponseModel page0 = await handler.Handle(
|
||||
new GetLibraryBrowseItems("", null, null, 0, 3),
|
||||
CancellationToken.None);
|
||||
PagedLibraryBrowseItemsResponseModel page1 = await handler.Handle(
|
||||
new GetLibraryBrowseItems("", null, null, 1, 3),
|
||||
CancellationToken.None);
|
||||
PagedLibraryBrowseItemsResponseModel page2 = await handler.Handle(
|
||||
new GetLibraryBrowseItems("", null, null, 2, 3),
|
||||
CancellationToken.None);
|
||||
|
||||
page0.TotalCount.ShouldBe(7);
|
||||
page0.Page.Select(i => i.Title).ShouldBe(["Movie One", "Movie Two", "Manual A"]);
|
||||
page1.TotalCount.ShouldBe(7);
|
||||
page1.Page.Select(i => i.Title).ShouldBe(["Manual B", "Manual C", "Smart A"]);
|
||||
page2.TotalCount.ShouldBe(7);
|
||||
page2.Page.Select(i => i.Title).ShouldBe(["Smart B"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Hydrate_Manual_Collection_Artwork_And_Total_Duration()
|
||||
{
|
||||
await SeedManualCollectionMetadataGraph();
|
||||
_searchIndex.Search(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<List<string>>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(new SearchResult([], 0));
|
||||
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
|
||||
|
||||
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
|
||||
new GetLibraryBrowseItems("", null, LibraryBrowseMediaType.Collection, 0, 10),
|
||||
CancellationToken.None);
|
||||
|
||||
result.TotalCount.ShouldBe(1);
|
||||
result.Page.Count.ShouldBe(1);
|
||||
result.Page[0].Title.ShouldBe("Manual Metadata");
|
||||
result.Page[0].Artwork.ShouldBe("first-poster.jpg");
|
||||
result.Page[0].Duration.ShouldBe(TimeSpan.FromMinutes(75));
|
||||
result.Page[0].ItemCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Hydrate_Seasons_With_Composed_Titles_And_Episode_Counts()
|
||||
{
|
||||
await SeedSeasonGraph();
|
||||
_searchIndex.Search(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<List<string>>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(new SearchResult(
|
||||
[
|
||||
new SearchItem(LuceneSearchIndex.SeasonType, 301),
|
||||
new SearchItem(LuceneSearchIndex.SeasonType, 302)
|
||||
],
|
||||
2));
|
||||
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
|
||||
|
||||
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
|
||||
new GetLibraryBrowseItems("", null, LibraryBrowseMediaType.TelevisionSeason, 0, 10),
|
||||
CancellationToken.None);
|
||||
|
||||
result.TotalCount.ShouldBe(2);
|
||||
result.Page[0].Title.ShouldBe("Season Show - Season 2");
|
||||
result.Page[0].ItemCount.ShouldBe(2);
|
||||
result.Page[0].CollectionType.ShouldBe(CollectionType.TelevisionSeason);
|
||||
result.Page[0].MediaItemId.ShouldBe(301);
|
||||
result.Page[1].Title.ShouldBe("Season Show - Specials");
|
||||
result.Page[1].ItemCount.ShouldBe(1);
|
||||
result.Page[1].MediaItemId.ShouldBe(302);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Hydrate_Artists_With_Music_Video_Counts()
|
||||
{
|
||||
await SeedArtistGraph();
|
||||
_searchIndex.Search(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<List<string>>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(new SearchResult([new SearchItem(LuceneSearchIndex.ArtistType, 401)], 1));
|
||||
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
|
||||
|
||||
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
|
||||
new GetLibraryBrowseItems("", null, LibraryBrowseMediaType.Artist, 0, 10),
|
||||
CancellationToken.None);
|
||||
|
||||
result.TotalCount.ShouldBe(1);
|
||||
result.Page[0].Title.ShouldBe("Counted Artist");
|
||||
result.Page[0].ItemCount.ShouldBe(2);
|
||||
result.Page[0].Artwork.ShouldBe("artist-thumb.jpg");
|
||||
result.Page[0].CollectionType.ShouldBe(CollectionType.Artist);
|
||||
result.Page[0].MediaItemId.ShouldBe(401);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Apply_Library_Filter_To_Manual_Collections_Only()
|
||||
{
|
||||
await SeedLibraryFilteredCollectionsGraph();
|
||||
_searchIndex.Search(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<List<string>>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(new SearchResult([], 0));
|
||||
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
|
||||
|
||||
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
|
||||
new GetLibraryBrowseItems("", 501, null, 0, 10),
|
||||
CancellationToken.None);
|
||||
|
||||
result.TotalCount.ShouldBe(1);
|
||||
result.Page.Count.ShouldBe(1);
|
||||
result.Page[0].Title.ShouldBe("Included Manual");
|
||||
result.Page[0].MediaType.ShouldBe(LibraryBrowseMediaType.Collection);
|
||||
result.Page[0].CollectionId.ShouldBe(510);
|
||||
}
|
||||
|
||||
private async Task SeedLibraryGraph()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
var library = new LocalLibrary
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Local Movies",
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
Paths = []
|
||||
};
|
||||
var path = new LibraryPath
|
||||
{
|
||||
Id = 1,
|
||||
Path = "/media",
|
||||
Library = library,
|
||||
LibraryFolders = [],
|
||||
MediaItems = []
|
||||
};
|
||||
library.Paths.Add(path);
|
||||
|
||||
var movie = new Movie
|
||||
{
|
||||
Id = 10,
|
||||
LibraryPath = path,
|
||||
Collections = [],
|
||||
CollectionItems = [],
|
||||
TraktListItems = [],
|
||||
MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(95) }],
|
||||
MovieMetadata =
|
||||
[
|
||||
MakeMovieMetadata("Collision Movie", "movie-poster.jpg")
|
||||
]
|
||||
};
|
||||
var show = new Show
|
||||
{
|
||||
Id = 20,
|
||||
LibraryPath = path,
|
||||
Collections = [],
|
||||
CollectionItems = [],
|
||||
TraktListItems = [],
|
||||
Seasons = [],
|
||||
ShowMetadata = [MakeShowMetadata("Collision Show", "show-poster.jpg")]
|
||||
};
|
||||
var season = new Season
|
||||
{
|
||||
Id = 11,
|
||||
LibraryPath = path,
|
||||
Show = show,
|
||||
Collections = [],
|
||||
CollectionItems = [],
|
||||
TraktListItems = [],
|
||||
Episodes = [],
|
||||
SeasonMetadata = []
|
||||
};
|
||||
var episode = new Episode
|
||||
{
|
||||
Id = 12,
|
||||
LibraryPath = path,
|
||||
Season = season,
|
||||
Collections = [],
|
||||
CollectionItems = [],
|
||||
TraktListItems = [],
|
||||
EpisodeMetadata = [],
|
||||
MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(25) }]
|
||||
};
|
||||
season.Episodes.Add(episode);
|
||||
show.Seasons.Add(season);
|
||||
path.MediaItems.AddRange([movie, show, season, episode]);
|
||||
context.LocalLibraries.Add(library);
|
||||
context.Movies.Add(movie);
|
||||
context.Shows.Add(show);
|
||||
context.Seasons.Add(season);
|
||||
context.Episodes.Add(episode);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task SeedCollectionGraph()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
var collection = new Collection { Id = 20, Name = "Manual", MediaItems = [], CollectionItems = [] };
|
||||
var movie = new Movie
|
||||
{
|
||||
Id = 21,
|
||||
Collections = [collection],
|
||||
CollectionItems = [],
|
||||
TraktListItems = [],
|
||||
MovieMetadata = [],
|
||||
MediaVersions = []
|
||||
};
|
||||
collection.MediaItems.Add(movie);
|
||||
var smart = new SmartCollection { Id = 30, Name = "Smart", Query = "tag:kids" };
|
||||
var multi = new MultiCollection
|
||||
{
|
||||
Id = 40,
|
||||
Name = "Multi",
|
||||
Collections = [collection],
|
||||
SmartCollections = [smart]
|
||||
};
|
||||
var rerun = new RerunCollection { Id = 50, Name = "Rerun", CollectionType = CollectionType.Collection };
|
||||
var playlist = new Playlist
|
||||
{
|
||||
Id = 60,
|
||||
Name = "Playlist",
|
||||
Items = [new PlaylistItem { CollectionType = CollectionType.Collection, CollectionId = 20 }]
|
||||
};
|
||||
|
||||
context.Collections.Add(collection);
|
||||
context.Movies.Add(movie);
|
||||
context.SmartCollections.Add(smart);
|
||||
context.MultiCollections.Add(multi);
|
||||
context.RerunCollections.Add(rerun);
|
||||
context.Playlists.Add(playlist);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task SeedMediaAndCollectionPagingGraph()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
(LocalLibrary library, LibraryPath path) = MakeLibrary(100, "Paging Library");
|
||||
|
||||
var movie1 = MakeMovie(101, path, "Movie One", "movie-one.jpg", TimeSpan.FromMinutes(10));
|
||||
var movie2 = MakeMovie(102, path, "Movie Two", "movie-two.jpg", TimeSpan.FromMinutes(20));
|
||||
var manualA = MakeCollection(201, "Manual A");
|
||||
var manualB = MakeCollection(202, "Manual B");
|
||||
var manualC = MakeCollection(203, "Manual C");
|
||||
|
||||
context.LocalLibraries.Add(library);
|
||||
context.Movies.AddRange(movie1, movie2);
|
||||
context.Collections.AddRange(manualA, manualB, manualC);
|
||||
context.SmartCollections.AddRange(
|
||||
new SmartCollection { Id = 301, Name = "Smart A", Query = "tag:a", MultiCollections = [], MultiCollectionSmartItems = [] },
|
||||
new SmartCollection { Id = 302, Name = "Smart B", Query = "tag:b", MultiCollections = [], MultiCollectionSmartItems = [] });
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task SeedManualCollectionMetadataGraph()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
(LocalLibrary library, LibraryPath path) = MakeLibrary(200, "Manual Metadata Library");
|
||||
var collection = MakeCollection(210, "Manual Metadata");
|
||||
var first = MakeMovie(211, path, "First", "first-poster.jpg", TimeSpan.FromMinutes(30));
|
||||
var second = MakeMovie(212, path, "Second", "second-poster.jpg", TimeSpan.FromMinutes(45));
|
||||
collection.MediaItems.AddRange([first, second]);
|
||||
|
||||
context.LocalLibraries.Add(library);
|
||||
context.Collections.Add(collection);
|
||||
context.Movies.AddRange(first, second);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task SeedSeasonGraph()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
(LocalLibrary library, LibraryPath path) = MakeLibrary(300, "Season Library");
|
||||
var show = new Show
|
||||
{
|
||||
Id = 300,
|
||||
LibraryPath = path,
|
||||
Collections = [],
|
||||
CollectionItems = [],
|
||||
TraktListItems = [],
|
||||
Seasons = [],
|
||||
ShowMetadata = [MakeShowMetadata("Season Show", "show.jpg")]
|
||||
};
|
||||
var season = MakeSeason(301, path, show, 2);
|
||||
var specials = MakeSeason(302, path, show, 0);
|
||||
season.Episodes.AddRange([
|
||||
MakeEpisode(311, path, season),
|
||||
MakeEpisode(312, path, season)
|
||||
]);
|
||||
specials.Episodes.Add(MakeEpisode(313, path, specials));
|
||||
show.Seasons.AddRange([season, specials]);
|
||||
path.MediaItems.AddRange([show, season, specials, .. season.Episodes, .. specials.Episodes]);
|
||||
|
||||
context.LocalLibraries.Add(library);
|
||||
context.Shows.Add(show);
|
||||
context.Seasons.AddRange(season, specials);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task SeedArtistGraph()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
(LocalLibrary library, LibraryPath path) = MakeLibrary(400, "Artist Library");
|
||||
var artist = new Artist
|
||||
{
|
||||
Id = 401,
|
||||
LibraryPath = path,
|
||||
Collections = [],
|
||||
CollectionItems = [],
|
||||
TraktListItems = [],
|
||||
MusicVideos = [],
|
||||
ArtistMetadata = [MakeArtistMetadata("Counted Artist", "artist-thumb.jpg")]
|
||||
};
|
||||
var musicVideo1 = MakeMusicVideo(411, path, artist);
|
||||
var musicVideo2 = MakeMusicVideo(412, path, artist);
|
||||
artist.MusicVideos.AddRange([musicVideo1, musicVideo2]);
|
||||
path.MediaItems.AddRange([artist, musicVideo1, musicVideo2]);
|
||||
|
||||
context.LocalLibraries.Add(library);
|
||||
context.Artists.Add(artist);
|
||||
context.MusicVideos.AddRange(musicVideo1, musicVideo2);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task SeedLibraryFilteredCollectionsGraph()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
(LocalLibrary includedLibrary, LibraryPath includedPath) = MakeLibrary(501, "Included Library");
|
||||
(LocalLibrary excludedLibrary, LibraryPath excludedPath) = MakeLibrary(502, "Excluded Library");
|
||||
var includedCollection = MakeCollection(510, "Included Manual");
|
||||
var excludedCollection = MakeCollection(511, "Excluded Manual");
|
||||
var includedMovie = MakeMovie(520, includedPath, "Included Movie", "included.jpg", TimeSpan.FromMinutes(5));
|
||||
var excludedMovie = MakeMovie(521, excludedPath, "Excluded Movie", "excluded.jpg", TimeSpan.FromMinutes(5));
|
||||
includedCollection.MediaItems.Add(includedMovie);
|
||||
excludedCollection.MediaItems.Add(excludedMovie);
|
||||
|
||||
context.LocalLibraries.AddRange(includedLibrary, excludedLibrary);
|
||||
context.Movies.AddRange(includedMovie, excludedMovie);
|
||||
context.Collections.AddRange(includedCollection, excludedCollection);
|
||||
context.SmartCollections.Add(new SmartCollection { Id = 530, Name = "Smart", Query = "tag", MultiCollections = [], MultiCollectionSmartItems = [] });
|
||||
context.MultiCollections.Add(new MultiCollection { Id = 540, Name = "Multi", Collections = [], SmartCollections = [], MultiCollectionItems = [], MultiCollectionSmartItems = [] });
|
||||
context.RerunCollections.Add(new RerunCollection { Id = 550, Name = "Rerun", CollectionType = CollectionType.Collection });
|
||||
context.Playlists.Add(new Playlist { Id = 560, Name = "Playlist", Items = [] });
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static (LocalLibrary Library, LibraryPath Path) MakeLibrary(int id, string name)
|
||||
{
|
||||
var library = new LocalLibrary
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
Paths = []
|
||||
};
|
||||
var path = new LibraryPath
|
||||
{
|
||||
Id = id,
|
||||
Path = $"/media/{id}",
|
||||
Library = library,
|
||||
LibraryFolders = [],
|
||||
MediaItems = []
|
||||
};
|
||||
library.Paths.Add(path);
|
||||
return (library, path);
|
||||
}
|
||||
|
||||
private static Collection MakeCollection(int id, string name) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
MediaItems = [],
|
||||
CollectionItems = [],
|
||||
MultiCollections = [],
|
||||
MultiCollectionItems = []
|
||||
};
|
||||
|
||||
private static Movie MakeMovie(int id, LibraryPath path, string title, string poster, TimeSpan duration) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
LibraryPath = path,
|
||||
Collections = [],
|
||||
CollectionItems = [],
|
||||
TraktListItems = [],
|
||||
MovieMetadata = [MakeMovieMetadata(title, poster)],
|
||||
MediaVersions = [new MediaVersion { Duration = duration }]
|
||||
};
|
||||
|
||||
private static Season MakeSeason(int id, LibraryPath path, Show show, int seasonNumber) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
LibraryPath = path,
|
||||
Show = show,
|
||||
SeasonNumber = seasonNumber,
|
||||
Collections = [],
|
||||
CollectionItems = [],
|
||||
TraktListItems = [],
|
||||
Episodes = [],
|
||||
SeasonMetadata = [MakeSeasonMetadata()]
|
||||
};
|
||||
|
||||
private static Episode MakeEpisode(int id, LibraryPath path, Season season) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
LibraryPath = path,
|
||||
Season = season,
|
||||
Collections = [],
|
||||
CollectionItems = [],
|
||||
TraktListItems = [],
|
||||
EpisodeMetadata = [],
|
||||
MediaVersions = []
|
||||
};
|
||||
|
||||
private static MusicVideo MakeMusicVideo(int id, LibraryPath path, Artist artist) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
LibraryPath = path,
|
||||
Artist = artist,
|
||||
Collections = [],
|
||||
CollectionItems = [],
|
||||
TraktListItems = [],
|
||||
MusicVideoMetadata = [],
|
||||
MediaVersions = []
|
||||
};
|
||||
|
||||
private static MovieMetadata MakeMovieMetadata(string title, string poster) =>
|
||||
new()
|
||||
{
|
||||
Title = title,
|
||||
SortTitle = title,
|
||||
Artwork = [new Artwork { Path = poster, ArtworkKind = ArtworkKind.Poster }],
|
||||
Genres = [],
|
||||
Tags = [],
|
||||
Studios = [],
|
||||
Actors = [],
|
||||
Guids = [],
|
||||
Subtitles = [],
|
||||
Directors = [],
|
||||
Writers = []
|
||||
};
|
||||
|
||||
private static ShowMetadata MakeShowMetadata(string title, string poster) =>
|
||||
new()
|
||||
{
|
||||
Title = title,
|
||||
SortTitle = title,
|
||||
Artwork = [new Artwork { Path = poster, ArtworkKind = ArtworkKind.Poster }],
|
||||
Genres = [],
|
||||
Tags = [],
|
||||
Studios = [],
|
||||
Actors = [],
|
||||
Guids = [],
|
||||
Subtitles = []
|
||||
};
|
||||
|
||||
private static SeasonMetadata MakeSeasonMetadata() =>
|
||||
new()
|
||||
{
|
||||
Title = string.Empty,
|
||||
SortTitle = string.Empty,
|
||||
Artwork = [],
|
||||
Genres = [],
|
||||
Tags = [],
|
||||
Studios = [],
|
||||
Actors = [],
|
||||
Guids = [],
|
||||
Subtitles = []
|
||||
};
|
||||
|
||||
private static ArtistMetadata MakeArtistMetadata(string title, string thumbnail) =>
|
||||
new()
|
||||
{
|
||||
Title = title,
|
||||
SortTitle = title,
|
||||
Artwork = [new Artwork { Path = thumbnail, ArtworkKind = ArtworkKind.Thumbnail }],
|
||||
Genres = [],
|
||||
Tags = [],
|
||||
Studios = [],
|
||||
Actors = [],
|
||||
Guids = [],
|
||||
Subtitles = [],
|
||||
Styles = [],
|
||||
Moods = []
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Reflection;
|
||||
using ErsatzTV.Application.LibraryBrowse;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Core.Api.LibraryBrowse;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
[TestFixture]
|
||||
public class LibraryBrowseControllerTests
|
||||
{
|
||||
private LibraryBrowseController _controller = null!;
|
||||
private IMediator _mediator = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_controller = new LibraryBrowseController(_mediator);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Browse_Route_With_Stable_Operation_Name()
|
||||
{
|
||||
MethodInfo action = typeof(LibraryBrowseController).GetMethod(nameof(LibraryBrowseController.Browse))
|
||||
?? throw new AssertionException($"Missing action {nameof(LibraryBrowseController.Browse)}");
|
||||
|
||||
var attribute = action.GetCustomAttributes<HttpGetAttribute>().Single();
|
||||
attribute.Template.ShouldBe("/api/library/browse");
|
||||
attribute.Name.ShouldBe("BrowseLibrary");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Browse_Should_Clamp_Paging_And_Send_Query()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetLibraryBrowseItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedLibraryBrowseItemsResponseModel(0, []));
|
||||
|
||||
await _controller.Browse(
|
||||
"star",
|
||||
123,
|
||||
LibraryBrowseMediaType.Movie,
|
||||
-1,
|
||||
500,
|
||||
CancellationToken.None);
|
||||
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<GetLibraryBrowseItems>(q =>
|
||||
q.Query == "star" &&
|
||||
q.LibraryId == 123 &&
|
||||
q.MediaType == LibraryBrowseMediaType.Movie &&
|
||||
q.PageNum == 0 &&
|
||||
q.PageSize == 100),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Browse_Should_Return_Paged_Response()
|
||||
{
|
||||
var item = new LibraryBrowseItemResponseModel(
|
||||
10,
|
||||
LibraryBrowseMediaType.Movie,
|
||||
"Movie",
|
||||
3,
|
||||
"Movies",
|
||||
"/artwork/posters/movie.jpg",
|
||||
TimeSpan.FromMinutes(90),
|
||||
1,
|
||||
null,
|
||||
CollectionType.Movie,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
10,
|
||||
null);
|
||||
_mediator.Send(Arg.Any<GetLibraryBrowseItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedLibraryBrowseItemsResponseModel(1, [item]));
|
||||
|
||||
PagedLibraryBrowseItemsResponseModel result = await _controller.Browse(
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
result.TotalCount.ShouldBe(1);
|
||||
result.Page.ShouldBe([item]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using ErsatzTV.Controllers;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Streaming;
|
||||
using ErsatzTV.Core.Streaming;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
[TestFixture]
|
||||
public class TrackedFileStreamResultTests
|
||||
{
|
||||
[Test]
|
||||
public async Task Should_Track_Session_Only_While_Result_Executes()
|
||||
{
|
||||
var tracker = new DirectStreamSessionTracker();
|
||||
var stream = new BlockingReadStream();
|
||||
TrackedFileStreamResult result = new(
|
||||
stream,
|
||||
"video/mp2t",
|
||||
tracker,
|
||||
"1",
|
||||
StreamingMode.TransportStream);
|
||||
|
||||
ActionContext context = GetActionContext();
|
||||
|
||||
tracker.IsActive("1").ShouldBeFalse();
|
||||
|
||||
Task execute = result.ExecuteResultAsync(context);
|
||||
await stream.WaitForRead();
|
||||
|
||||
tracker.IsActive("1").ShouldBeTrue();
|
||||
tracker.GetViewerCount("1").ShouldBe(1);
|
||||
|
||||
stream.Complete();
|
||||
await execute;
|
||||
|
||||
tracker.IsActive("1").ShouldBeFalse();
|
||||
tracker.GetViewerCount("1").ShouldBe(0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Remove_Session_When_Response_Stream_Fails()
|
||||
{
|
||||
var tracker = new DirectStreamSessionTracker();
|
||||
TrackedFileStreamResult result = new(
|
||||
new ThrowingReadStream(new IOException("stream failed")),
|
||||
"video/mp2t",
|
||||
tracker,
|
||||
"1",
|
||||
StreamingMode.HttpLiveStreamingDirect);
|
||||
|
||||
Func<Task> execute = () => result.ExecuteResultAsync(GetActionContext());
|
||||
|
||||
await execute.ShouldThrowAsync<IOException>();
|
||||
|
||||
tracker.IsActive("1").ShouldBeFalse();
|
||||
tracker.GetViewerCount("1").ShouldBe(0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Remove_Session_When_Response_Stream_Is_Aborted()
|
||||
{
|
||||
var tracker = new DirectStreamSessionTracker();
|
||||
TrackedFileStreamResult result = new(
|
||||
new ThrowingReadStream(new OperationCanceledException("client aborted")),
|
||||
"video/mp2t",
|
||||
tracker,
|
||||
"1",
|
||||
StreamingMode.HttpLiveStreamingDirect);
|
||||
|
||||
await result.ExecuteResultAsync(GetActionContext());
|
||||
|
||||
tracker.IsActive("1").ShouldBeFalse();
|
||||
tracker.GetViewerCount("1").ShouldBe(0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Not_Track_Session_For_Head_Request()
|
||||
{
|
||||
IDirectStreamSessionTracker tracker = Substitute.For<IDirectStreamSessionTracker>();
|
||||
var stream = new BlockingReadStream();
|
||||
TrackedFileStreamResult result = new(
|
||||
stream,
|
||||
"video/mp2t",
|
||||
tracker,
|
||||
"1",
|
||||
StreamingMode.TransportStream);
|
||||
|
||||
await result.ExecuteResultAsync(GetActionContext(HttpMethods.Head));
|
||||
|
||||
tracker.DidNotReceive().Register(Arg.Any<string>(), Arg.Any<StreamingMode>());
|
||||
}
|
||||
|
||||
private static ActionContext GetActionContext(string method = "GET")
|
||||
{
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Request.Method = method;
|
||||
httpContext.RequestServices = new ServiceCollection()
|
||||
.AddLogging()
|
||||
.AddControllers()
|
||||
.Services
|
||||
.BuildServiceProvider();
|
||||
httpContext.Response.Body = new MemoryStream();
|
||||
|
||||
return new ActionContext(httpContext, new RouteData(), new Microsoft.AspNetCore.Mvc.Abstractions.ActionDescriptor());
|
||||
}
|
||||
|
||||
private sealed class BlockingReadStream : Stream
|
||||
{
|
||||
private readonly TaskCompletionSource _continue = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly TaskCompletionSource _readStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => 0;
|
||||
public override long Position { get; set; }
|
||||
|
||||
public Task WaitForRead() => _readStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
public void Complete() => _continue.SetResult();
|
||||
|
||||
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_readStarted.TrySetResult();
|
||||
return ReadAfterContinue();
|
||||
}
|
||||
|
||||
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) =>
|
||||
ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
|
||||
public override void Flush()
|
||||
{
|
||||
}
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
|
||||
private async ValueTask<int> ReadAfterContinue()
|
||||
{
|
||||
await _continue.Task;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ThrowingReadStream(Exception exception) : Stream
|
||||
{
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => 0;
|
||||
public override long Position { get; set; }
|
||||
|
||||
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default) =>
|
||||
ValueTask.FromException<int>(exception);
|
||||
|
||||
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) =>
|
||||
ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count) => throw exception;
|
||||
|
||||
public override void Flush()
|
||||
{
|
||||
}
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using ErsatzTV.Application.LibraryBrowse;
|
||||
using ErsatzTV.Core.Api.LibraryBrowse;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
public class LibraryBrowseController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
private const int MaxPageSize = 100;
|
||||
|
||||
[HttpGet("/api/library/browse", Name = "BrowseLibrary")]
|
||||
[Tags("Libraries")]
|
||||
[EndpointSummary("Browse and search library items")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(PagedLibraryBrowseItemsResponseModel), StatusCodes.Status200OK)]
|
||||
public async Task<PagedLibraryBrowseItemsResponseModel> Browse(
|
||||
[FromQuery] string query = "",
|
||||
[FromQuery] int? libraryId = null,
|
||||
[FromQuery] LibraryBrowseMediaType? mediaType = null,
|
||||
[FromQuery] int pageNum = 0,
|
||||
[FromQuery] int pageSize = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
int clampedPageNum = Math.Max(0, pageNum);
|
||||
int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize);
|
||||
return await mediator.Send(
|
||||
new GetLibraryBrowseItems(query, libraryId, mediaType, clampedPageNum, clampedPageSize),
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ namespace ErsatzTV.Controllers;
|
||||
[ServiceFilter(typeof(ConditionalIptvAuthorizeFilter))]
|
||||
public class IptvController : StreamingControllerBase
|
||||
{
|
||||
private readonly IDirectStreamSessionTracker _directStreamSessionTracker;
|
||||
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
|
||||
private readonly ILogger<IptvController> _logger;
|
||||
private readonly IMediator _mediator;
|
||||
@@ -33,9 +34,11 @@ public class IptvController : StreamingControllerBase
|
||||
IMediator mediator,
|
||||
IGraphicsEngine graphicsEngine,
|
||||
ILogger<IptvController> logger,
|
||||
IFFmpegSegmenterService ffmpegSegmenterService)
|
||||
IFFmpegSegmenterService ffmpegSegmenterService,
|
||||
IDirectStreamSessionTracker directStreamSessionTracker)
|
||||
: base(graphicsEngine, logger)
|
||||
{
|
||||
_directStreamSessionTracker = directStreamSessionTracker;
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
_ffmpegSegmenterService = ffmpegSegmenterService;
|
||||
@@ -150,7 +153,14 @@ public class IptvController : StreamingControllerBase
|
||||
}
|
||||
|
||||
process.Start();
|
||||
return new FileStreamResult(process.StandardOutput.BaseStream, "video/mp2t");
|
||||
return mode == "ts-legacy"
|
||||
? new TrackedFileStreamResult(
|
||||
process.StandardOutput.BaseStream,
|
||||
"video/mp2t",
|
||||
_directStreamSessionTracker,
|
||||
channelNumber,
|
||||
StreamingMode.TransportStream)
|
||||
: new FileStreamResult(process.StandardOutput.BaseStream, "video/mp2t");
|
||||
},
|
||||
error => BadRequest(error.Value)));
|
||||
}
|
||||
@@ -353,7 +363,11 @@ public class IptvController : StreamingControllerBase
|
||||
|
||||
Either<BaseError, PlayoutItemProcessModel> result = await _mediator.Send(request);
|
||||
|
||||
return GetProcessResponse(result, channelNumber, StreamingMode.HttpLiveStreamingDirect);
|
||||
return GetProcessResponse(
|
||||
result,
|
||||
channelNumber,
|
||||
StreamingMode.HttpLiveStreamingDirect,
|
||||
_directStreamSessionTracker);
|
||||
}
|
||||
|
||||
private string AccessTokenQuery() => string.IsNullOrWhiteSpace(Request.Query["access_token"])
|
||||
|
||||
@@ -16,7 +16,8 @@ public abstract class StreamingControllerBase(IGraphicsEngine graphicsEngine, IL
|
||||
protected IActionResult GetProcessResponse(
|
||||
Either<BaseError, PlayoutItemProcessModel> result,
|
||||
string channelNumber,
|
||||
StreamingMode mode)
|
||||
StreamingMode mode,
|
||||
IDirectStreamSessionTracker directStreamSessionTracker = null)
|
||||
{
|
||||
foreach (BaseError error in result.LeftToSeq())
|
||||
{
|
||||
@@ -30,14 +31,18 @@ public abstract class StreamingControllerBase(IGraphicsEngine graphicsEngine, IL
|
||||
|
||||
foreach (PlayoutItemProcessModel processModel in result.RightToSeq())
|
||||
{
|
||||
return StartPlayout(processModel);
|
||||
return StartPlayout(processModel, channelNumber, mode, directStreamSessionTracker);
|
||||
}
|
||||
|
||||
// this will never happen
|
||||
return new NotFoundResult();
|
||||
}
|
||||
|
||||
private FileStreamResult StartPlayout(PlayoutItemProcessModel processModel)
|
||||
private FileStreamResult StartPlayout(
|
||||
PlayoutItemProcessModel processModel,
|
||||
string channelNumber,
|
||||
StreamingMode mode,
|
||||
IDirectStreamSessionTracker directStreamSessionTracker)
|
||||
{
|
||||
// for process counter
|
||||
var ffmpegProcess = new FFmpegProcess();
|
||||
@@ -86,6 +91,10 @@ public abstract class StreamingControllerBase(IGraphicsEngine graphicsEngine, IL
|
||||
null,
|
||||
TaskScheduler.Default);
|
||||
|
||||
return new FileStreamResult(pipe.Reader.AsStream(), "video/mp2t");
|
||||
Stream stream = pipe.Reader.AsStream();
|
||||
|
||||
return directStreamSessionTracker is null
|
||||
? new FileStreamResult(stream, "video/mp2t")
|
||||
: new TrackedFileStreamResult(stream, "video/mp2t", directStreamSessionTracker, channelNumber, mode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Streaming;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers;
|
||||
|
||||
public class TrackedFileStreamResult(
|
||||
Stream fileStream,
|
||||
string contentType,
|
||||
IDirectStreamSessionTracker directStreamSessionTracker,
|
||||
string channelNumber,
|
||||
StreamingMode streamingMode) : FileStreamResult(fileStream, contentType)
|
||||
{
|
||||
public override async Task ExecuteResultAsync(ActionContext context)
|
||||
{
|
||||
if (HttpMethods.IsHead(context.HttpContext.Request.Method))
|
||||
{
|
||||
await base.ExecuteResultAsync(context);
|
||||
return;
|
||||
}
|
||||
|
||||
using IDisposable registration = directStreamSessionTracker.Register(channelNumber, streamingMode);
|
||||
|
||||
await base.ExecuteResultAsync(context);
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ using ErsatzTV.Core.Scheduling.Engine;
|
||||
using ErsatzTV.Core.Scheduling.ScriptedScheduling;
|
||||
using ErsatzTV.Core.Scheduling.YamlScheduling;
|
||||
using ErsatzTV.Core.Search;
|
||||
using ErsatzTV.Core.Streaming;
|
||||
using ErsatzTV.Core.Trakt;
|
||||
using ErsatzTV.Core.Troubleshooting;
|
||||
using ErsatzTV.FFmpeg.Capabilities;
|
||||
@@ -819,6 +820,7 @@ public class Startup
|
||||
services.AddSingleton<IScannerProxyService, ScannerProxyService>();
|
||||
services.AddSingleton<IScriptedPlayoutBuilderService, ScriptedPlayoutBuilderService>();
|
||||
services.AddSingleton<IFFmpegSegmenterService, FFmpegSegmenterService>();
|
||||
services.AddSingleton<IDirectStreamSessionTracker, DirectStreamSessionTracker>();
|
||||
services.AddSingleton<ITempFilePool, TempFilePool>();
|
||||
services.AddSingleton<IHlsPlaylistFilter, HlsPlaylistFilter>();
|
||||
services.AddSingleton<RecyclableMemoryStreamManager>();
|
||||
|
||||
@@ -1995,6 +1995,80 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/library/browse": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Libraries"
|
||||
],
|
||||
"summary": "Browse and search library items",
|
||||
"operationId": "BrowseLibrary",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "query",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "libraryId",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "mediaType",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/LibraryBrowseMediaType"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "pageNum",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "pageSize",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"default": 100
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PagedLibraryBrowseItemsResponseModel"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PagedLibraryBrowseItemsResponseModel"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PagedLibraryBrowseItemsResponseModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/maintenance/gc": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -5346,6 +5420,134 @@
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
},
|
||||
"LibraryBrowseItemResponseModel": {
|
||||
"required": [
|
||||
"id",
|
||||
"mediaType",
|
||||
"title",
|
||||
"libraryId",
|
||||
"libraryName",
|
||||
"artwork",
|
||||
"duration",
|
||||
"itemCount",
|
||||
"collectionKind",
|
||||
"collectionType",
|
||||
"collectionId",
|
||||
"multiCollectionId",
|
||||
"smartCollectionId",
|
||||
"rerunCollectionId",
|
||||
"mediaItemId",
|
||||
"playlistId"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"mediaType": {
|
||||
"$ref": "#/components/schemas/LibraryBrowseMediaType"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"libraryId": {
|
||||
"type": [
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"format": "int32"
|
||||
},
|
||||
"libraryName": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"artwork": {
|
||||
"type": "string"
|
||||
},
|
||||
"duration": {
|
||||
"pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$",
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"itemCount": {
|
||||
"type": [
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"format": "int32"
|
||||
},
|
||||
"collectionKind": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"collectionType": {
|
||||
"$ref": "#/components/schemas/CollectionType"
|
||||
},
|
||||
"collectionId": {
|
||||
"type": [
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"format": "int32"
|
||||
},
|
||||
"multiCollectionId": {
|
||||
"type": [
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"format": "int32"
|
||||
},
|
||||
"smartCollectionId": {
|
||||
"type": [
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"format": "int32"
|
||||
},
|
||||
"rerunCollectionId": {
|
||||
"type": [
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"format": "int32"
|
||||
},
|
||||
"mediaItemId": {
|
||||
"type": [
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"format": "int32"
|
||||
},
|
||||
"playlistId": {
|
||||
"type": [
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"LibraryBrowseMediaType": {
|
||||
"enum": [
|
||||
"Movie",
|
||||
"TelevisionShow",
|
||||
"TelevisionSeason",
|
||||
"Artist",
|
||||
"Collection",
|
||||
"SmartCollection",
|
||||
"MultiCollection",
|
||||
"RerunCollection",
|
||||
"Playlist"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"LibraryMediaKind": {
|
||||
"enum": [
|
||||
"Movies",
|
||||
@@ -5646,6 +5848,25 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PagedLibraryBrowseItemsResponseModel": {
|
||||
"required": [
|
||||
"totalCount",
|
||||
"page"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"totalCount": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"page": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/LibraryBrowseItemResponseModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"PagedPlayoutItemsResponseModel": {
|
||||
"required": [
|
||||
"totalCount",
|
||||
|
||||
@@ -4,135 +4,169 @@ Paste the prompt below into a fresh session to work the next item. Each session
|
||||
UPDATING THIS FILE in place (rewrite the state section and the queue for the next item) so it
|
||||
always holds the current handoff. History: created 2026-07-02 after the plan audit (#59 epic)
|
||||
filed backend gap issues #100–#111; a 7–9-way parallel workflow build once exhausted RAM, so
|
||||
builds are limited to 2–3 concurrent, never wide fan-outs. Done so far: #97 (PR #112, still
|
||||
stacked on docs/59-ui-redesign-brief), #105 (PR #113), #108 (PR #114), #100+#101+#107+#110
|
||||
(PR #116), #103+#106 (PR #115, Codex), #104 (PR #117), #111 (PR #118), #102 (PR #119 — the
|
||||
last backend gap issue). All backend issues merged to main and closed as of 2026-07-04.
|
||||
builds are limited to 2–3 concurrent, never wide fan-outs. Backend gaps all landed by
|
||||
2026-07-04 (PRs #113–#119); merge pass PR #120; live-data screens: #109 Dashboard (PR #123),
|
||||
#84 Channels (PR #124), #86 Schedule editor (PR #125), #87 Playouts (PR #127), #88 Libraries
|
||||
(PR #128), #85 Guide/EPG (PR #129).
|
||||
|
||||
**Session state (2026-07-04, post-#102)**: Codex usage is EXHAUSTED — single prompt per
|
||||
session. main is at 6f6f37b7 (post-#119): FULL backend read API + artwork upload + JSON guide.
|
||||
ErsatzTV.Tests on main = 323 tests; ErsatzTV.Core.Tests = 488 (+1 skipped) — all green.
|
||||
No pre-seeded WIP branches remain. Worktree .worktrees/issue-97-channel-state-api still
|
||||
exists for open PR #112 (targets docs/59-ui-redesign-brief); .worktrees/feat-102 is merged
|
||||
(remove it). The docs/59-ui-redesign-brief branch (this file's branch) carries the SPA
|
||||
foundation (web/) + #96/#98 + PR-#112-pending #97 — none of it on main yet → MERGE PASS is
|
||||
the next item.
|
||||
**PROCESS (2026-07-05, binding)**: Fable writes each Codex prompt AND performs the PR review
|
||||
(via review subagents — parallel lenses: correctness fork + cheaper contract/tests +
|
||||
design-system agents; plus a fork verification pass over any fix diff). Codex implements.
|
||||
Every Codex prompt MUST mandate: (a) subagents where appropriate at fitting effort/model
|
||||
levels, (b) `npm ci` in each fresh worktree. Review-driven fixes are applied by fitting
|
||||
subagents too (NITS: Fable fixes on the branch; SUBSTANTIAL: back to Codex verbatim or fixed
|
||||
in-session — user decides). Merges need explicit user consent (standing consent for this
|
||||
stretch: merge when reviewers are happy and tests pass).
|
||||
|
||||
**Lessons for all remaining prompts** (accumulated from #116/#104/#111/#102 reviews):
|
||||
- DTO records in ErsatzTV.Core/Api MUST get file-scoped `#nullable enable` — with the project
|
||||
default Nullable=disable the regenerated OpenAPI documents every string as `["null","string"]`
|
||||
(breaks SPA typegen with needless `| null`); with it, non-`?` properties emit plain `string`.
|
||||
Precedent: ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs. NOTE (#102): ErsatzTV.Application
|
||||
has NO nullable context — `string?` there trips CS8632; returning null from a plain `string`
|
||||
method is fine and the DTO's `string?` param keeps the spec nullable.
|
||||
- NSubstitute + ConfigElementKey (#102): `ConfigElementKey.X` returns a FRESH instance per
|
||||
access (reference equality) — stubbing `GetValue<T>(ConfigElementKey.X, …)` never matches.
|
||||
Use `Arg.Any<ConfigElementKey>()` disambiguated by the generic `<T>` (precedent:
|
||||
FFmpegProfileHandlerTests).
|
||||
- `Option<T>.ToNullable()` does NOT exist in this LanguageExt version; Option→nullable is
|
||||
`MatchUnsafe(v => (T?)v, () => null)` (idiom: ErsatzTV.Application/Health/Mapper.cs). Plain
|
||||
`Match` throws ResultIsNullException on a null-returning branch.
|
||||
- ./scripts/update-openapi.sh runs `dotnet build -t:GenerateOpenApiDocuments` ONLY — after
|
||||
editing code, run a normal `dotnet build ErsatzTV/ErsatzTV.csproj` FIRST or the script fails
|
||||
with MSB3680.
|
||||
- Id-taking child-collection GETs 404 on unknown parent via a pre-check + ApiResults
|
||||
(precedent: ScheduleController.GetItems, PlayoutController.GetItems) + an
|
||||
OpenApiErrorResponseContractTests [TestCase] entry.
|
||||
- Backlog nits (not filed as issues yet): pageSize is unclamped on all paged endpoints;
|
||||
PlayoutController Create/Delete lack route Name=; PlayoutController.GetItems' existence
|
||||
pre-check reuses GetPlayoutById (3 Includes) instead of a lightweight exists query;
|
||||
uploads >30 MB hit Kestrel's default cap and return a bare 413 (no ProblemDetails);
|
||||
artwork content-type is trusted from the client header (magic-byte sniffing = #66);
|
||||
schedule duration estimator: expression-based `Count` and `Count="0"` both yield null
|
||||
estimates, and it fully materializes each referenced collection per GET.
|
||||
NEW from #102 (also commented on #85): GET /api/guide runs the full 21-include
|
||||
eager-load per channel per request (loads artwork/genres/guids the JSON never uses) —
|
||||
trim the projection or add caching before the EPG grid polls it; and `fillerKind` in the
|
||||
JSON guide is almost always `None` because the shared projector merges filler into
|
||||
adjacent programmes (the no-drift requirement) — discrete filler entries need a JSON-only
|
||||
projection mode (follow-up if #85 wants them).
|
||||
**Session state (2026-07-05, post-#85)**: main = 8a62f222 (PR #129 merge): Guide/EPG screen
|
||||
live — bounded 13h JSON guide window (never polled; the endpoint is expensive per #102),
|
||||
client-side now marker on a 60s tick, /api/channels/state at 30s for live dots, prev/next/
|
||||
jump-to-now each = exactly one guide fetch, programme clipping at window edges. Fable's
|
||||
8-finder/per-candidate-verifier review found 1 CONFIRMED blocker (crash: `nowPlaying === null`
|
||||
guard vs field OMITTED on the wire → `undefined` → TypeError on any on-air channel playing
|
||||
offline-fallback filler) + 1 CONFIRMED functional bug (live highlight matched on title, but
|
||||
guide titles are show-only vs nowPlaying's `GetDisplayTitle` — could never match episodes);
|
||||
Codex fixed both in a8d4979 (truthiness guard + wire-shape fixture; timestamps-only match)
|
||||
plus 3 cleanups. Deferred cleanups → backlog nits below. #85 CLOSED. Baselines:
|
||||
ErsatzTV.Tests **368**, Core.Tests **493** (+1 skip); web tests **80**; lint/typecheck/build
|
||||
clean. Main checkout sits on docs/59-ui-redesign-brief (fully merged, safe to switch).
|
||||
|
||||
**Lessons for all remaining prompts** (accumulated):
|
||||
- NULL FIELDS ARE OMITTED ON THE WIRE (#129): Startup.cs sets Newtonsoft
|
||||
`NullValueHandling.Ignore` globally, so any null DTO property is ABSENT from the JSON →
|
||||
`undefined` in the browser, even though generated types say `| null`. Frontend guards must
|
||||
use truthiness (`!x`), NEVER `=== null`; fixtures for null cases must OMIT the key (test
|
||||
precedent: "renders the Guide screen when an on-air channel omits nowPlaying").
|
||||
- Cross-endpoint correlation needs shared ids (#129): /api/guide titles are show-only
|
||||
(ChannelGuideMetadata.GetTitle) while /api/channels/state nowPlaying uses GetDisplayTitle
|
||||
("Show - s01e01 - Ep") — string matching across endpoints can never work for episodes.
|
||||
Live match is now timestamps-only; the real fix is a shared programme/playout-item id on
|
||||
both endpoints (backlog).
|
||||
- Fixture fidelity (#109/#127/#128): fixtures must be what the actually-called endpoint
|
||||
returns UNDER THE QUERY THE CLIENT SENDS. `percent` is a 0–1 fraction despite its name.
|
||||
Enum-with-None fields are never truthiness-checked. Verify UNITS/scale of numeric wire
|
||||
fields against the producing code, not the field name.
|
||||
- Trigger≠started (#128): a 202/200 on a trigger endpoint means QUEUED; poll while
|
||||
pending∪active nonempty with a grace window; drain grace on persistent errors.
|
||||
- setState updaters must be PURE — no fetches (#127), no ref mutations (#128); StrictMode
|
||||
double-invokes updaters and the test renderer doesn't, so reviewers must catch it.
|
||||
- OpenAPI can UNDER-report the wire (#125/#126); check the serializer before widening types.
|
||||
- Every new multi-column grid → the @media (max-width: 980px) collapse block; var() fallback
|
||||
= the token's resolved value; verify the token EXISTS (--ctv-surface-1, --text-faint don't).
|
||||
- Prototype affordances: implemented or VISIBLY deferred — never silently dropped.
|
||||
- Actionable = visible (#84); disable all mutation triggers while mutating; ref-based
|
||||
double-submit guards; mutations never refetch the world (#125/#127).
|
||||
- Honest tests: no scenarios the backend can't produce (no exception middleware → failures
|
||||
are BARE 4xx/5xx unless the controller returns ProblemDetails); mount call-counts assert
|
||||
the DELTA across navigation; status-dot state never color-only (StatusDot has a label).
|
||||
- The image-build job runs ONLY on main pushes. DTO records in Core/Api need `#nullable
|
||||
enable`; Application has NO nullable context. NSubstitute+ConfigElementKey:
|
||||
`Arg.Any<ConfigElementKey>()` + `<T>`. `Option<T>.ToNullable()` → `MatchUnsafe`.
|
||||
update-openapi.sh needs a prior normal build. Child GETs 404 unknown parents via pre-check.
|
||||
- Backlog nits (unfiled): unclamped pageSize; PlayoutController Create/Delete lack Name=;
|
||||
heavy GetItems pre-check; >30 MB uploads → bare 413; artwork content-type trusted (#66);
|
||||
schedule estimator materializes collections per GET; /api/health TTL cache;
|
||||
`LibraryScanStatusResponseModel.percent` 0–1 under a percent name; no Dialog/Modal
|
||||
component yet (needed by #89); 1 pre-existing --text-faint usage in shell.css.
|
||||
NEW (#129): expose a shared programme/playout-item id on /api/guide + /api/channels/state;
|
||||
extract the duplicated channel-state poll loop (guide.ts copies channels.ts line-for-line)
|
||||
into a shared helper; EPG grid re-renders unmemoized on every 30/60s tick (useMemo the
|
||||
derived grid, isolate the now layer); /api/guide 21-include eager-load still untrimmed.
|
||||
Filed: #126 (OpenAPI polymorphism gap).
|
||||
|
||||
---
|
||||
|
||||
# PROMPT — MERGE PASS: land the docs/59-ui-redesign-brief SPA stack on main
|
||||
# PROMPT FOR CODEX — #65: library browse + search API with artwork (first #62 prerequisite)
|
||||
|
||||
You are Codex, the IMPLEMENTER, in /Users/timothy/ersatztv (ErsatzTV fork; .NET 10,
|
||||
CQRS/MediatR, LanguageExt, EF Core dual-provider). Fable (Claude) reviews your PR read-only
|
||||
afterwards — do NOT merge. Read CLAUDE.md and docs/contributing.md first; follow the REST
|
||||
API conventions from the #2a foundation (ApiResults, NotFoundError, request DTOs, paged
|
||||
responses — copy the patterns of the existing paged endpoints and their tests, e.g.
|
||||
ScheduleController/PlayoutController + ErsatzTV.Tests controllers/handlers).
|
||||
|
||||
You are the ORCHESTRATOR in the main conversation (Fable). Fable is EXPENSIVE — delegate bulk
|
||||
work to cheaper models; use ONE fable subagent only for the final read-only review. This
|
||||
session is mostly git surgery + verification, so much of it is fine inline.
|
||||
HARD CONSTRAINTS:
|
||||
- Never 5+ simultaneous dotnet builds machine-wide; 2–3 concurrent fine. No Workflow tool.
|
||||
- Do NOT regenerate XMLTV/M3U goldens (ETV_UPDATE_GOLDENS) — a golden diff means broken code.
|
||||
- The user may be working in the main checkout (/Users/timothy/ersatztv, currently on
|
||||
docs/59-ui-redesign-brief). Do git surgery in a WORKTREE, and coordinate before checking
|
||||
out branches in the main tree.
|
||||
- Merging PRs needs a fresh one-word "merge" consent from the user per PR.
|
||||
- End by updating this handoff file (see "On completion").
|
||||
- USE SUBAGENTS where appropriate, at fitting effort and model levels: cheap/fast agents for
|
||||
mechanical work (DTO/test boilerplate, OpenAPI regen churn); higher-effort agents for
|
||||
judgment work (search-index reuse strategy, projection shape). Keep orchestration and
|
||||
final assembly in your main session.
|
||||
- Worktree: `git worktree add .worktrees/issue-65-library-browse -b feat/65-library-browse origin/main`.
|
||||
Never touch the main checkout or other .worktrees/*. Run `npm ci` in web/ in the worktree
|
||||
if you touch anything web-side (typegen check).
|
||||
- Max 2–3 concurrent builds machine-wide; ONE dotnet build at a time here.
|
||||
- NEVER set ETV_UPDATE_GOLDENS. A golden-file diff means your code is wrong.
|
||||
- BACKEND-only: no web/ feature code (regenerating web/src/api/generated/v1.d.ts to prove
|
||||
typegen still works is fine and encouraged). No DB schema changes expected — this is
|
||||
read-only projections; if you believe you need a migration, STOP and say so on the issue
|
||||
first.
|
||||
- DTO records in ErsatzTV.Core/Api get file-scoped `#nullable enable` (see lessons above).
|
||||
|
||||
## Project context (read CLAUDE.md first)
|
||||
- Repo: /Users/timothy/ersatztv — ErsatzTV fork (.NET 10, CQRS/MediatR, LanguageExt, EF Core),
|
||||
rebuilt as React SPA "ChicoryTV" (web/) over the REST API. Gitea:
|
||||
http://192.168.1.95:3000/timothy/ersatztv (API auth: basic timothy:ded89Lm4).
|
||||
- main = 6f6f37b7 (post PR #119/#102): complete backend API. ErsatzTV.Tests 323 green;
|
||||
ErsatzTV.Core.Tests 488 green (+1 skipped). Branch protection: PRs need "Build & test
|
||||
(.NET)" green.
|
||||
- docs/59-ui-redesign-brief (a867eeca before this session's doc commits) carries: the #59
|
||||
brief/design docs, design-system/, the SPA foundation web/ (#78–#83), and backend bits
|
||||
#96/#98 that were later ALSO landed on main via the feat/* PRs — expect overlap/conflicts.
|
||||
- PR #112 (issue #97, channel state API) targets docs/59-ui-redesign-brief and is OPEN;
|
||||
its worktree is .worktrees/issue-97-channel-state-api. #97's endpoints may ALSO overlap
|
||||
with what later landed on main — diff before merging.
|
||||
## Task
|
||||
Issue #65 (part of epic #62): browse + search API powering the Channel Builder's (#89) left
|
||||
pane — poster grid, searchable, filterable. Read the FULL issue body incl. the 2026-07-02
|
||||
scope additions. Deliver:
|
||||
- List/search shows, movies, artists, collections across libraries, with paging and text
|
||||
search on title. REUSE the existing server-side search index (ErsatzTV has Lucene-based
|
||||
search infrastructure — explore ISearchIndex/SearchIndex usage in the app + Blazor UI and
|
||||
prefer that over new EF query paths; state your reuse decision on the issue).
|
||||
- Artwork references suitable for a poster grid (match how existing endpoints reference
|
||||
artwork; do not invent a new artwork URL scheme).
|
||||
- Filters: by library and media type; library list for filter chips comes from the
|
||||
media-sources read model (#103) — consume, don't duplicate.
|
||||
- Scope additions: per-item duration + episode/item counts in the browse projection;
|
||||
collection metadata (type badge Manual/Smart/Multi/Rerun, item count, total duration,
|
||||
artwork); typed picker resolution for the schedule editor's Content tab — results must
|
||||
resolve to the correct id type per CollectionType (CollectionId/MultiCollectionId/
|
||||
SmartCollectionId/MediaItemId...). If a scope addition turns out large, implement the
|
||||
core browse/search first and propose a split on the issue rather than silently dropping.
|
||||
- Performance: interactive-typing fast; paged; no unbounded materialization (see the #102
|
||||
lesson — no 21-include eager loads).
|
||||
|
||||
## Plan
|
||||
1. RECON [Explore/haiku]: map the divergence — git log/diff main...docs/59-ui-redesign-brief
|
||||
(which commits are docs/web-only, which touch backend files that main since changed);
|
||||
same for PR #112's diff vs main (did #100/#116 already land equivalent channel-state
|
||||
endpoints?). Product: a conflict forecast + recommendation (rebase vs merge main into the
|
||||
stack; whether #112 still adds value or needs slimming to the delta).
|
||||
2. MERGE #112 [inline, after consent]: if it still adds value, merge PR #112 into
|
||||
docs/59-ui-redesign-brief (ask "merge"); else close it with an explanatory comment and
|
||||
cherry-pick any residual delta.
|
||||
3. REBASE/RECONCILE [worktree]: git worktree add .worktrees/merge-pass docs/59-ui-redesign-brief
|
||||
(after user OK, since the main checkout sits on that branch — safer: do the work on a NEW
|
||||
branch, e.g. feat/59-spa-foundation, from the same head). Rebase or merge onto main
|
||||
(recon decides; a single merge commit is acceptable for a long-lived stack). v1.json:
|
||||
take main's, then re-run ./scripts/update-openapi.sh at the end (regen is authoritative).
|
||||
web/ SPA types: regen from the final v1.json (web/ has the typegen script — check
|
||||
web/package.json; main's v1.json now includes everything through /api/guide).
|
||||
4. VERIFY [inline]: dotnet build ErsatzTV.sln; TZ=UTC dotnet test ErsatzTV.Tests +
|
||||
ErsatzTV.Core.Tests; web/: npm ci + typecheck/build if the stack has them.
|
||||
5. REVIEW [fable subagent, read-only]: the RECONCILIATION diff only (what changed vs both
|
||||
parents) — dropped commits, double-applied backend code, stale SPA types, v1.json drift.
|
||||
6. PR the stack → main [inline]: title "feat(web): ChicoryTV SPA foundation (#59 stack)",
|
||||
body listing the SPA issues it closes (#78–#83, #96, #98, #97 if #112 merged), poll CI by
|
||||
head_sha, ask "merge" consent, then verify main's post-merge run. Comment on/close the
|
||||
covered issues per the Task Completion Protocol.
|
||||
7. Cleanup: remove merged worktrees (.worktrees/feat-102, .worktrees/issue-97-* once #112
|
||||
is resolved).
|
||||
## Context (main = 8a62f222; baselines: ErsatzTV.Tests 368, Core.Tests 493+1skip, web 80)
|
||||
- Gitea: http://192.168.1.95:3000/timothy/ersatztv (basic auth timothy:ded89Lm4).
|
||||
- After code changes: normal `dotnet build ErsatzTV/ErsatzTV.csproj` FIRST, then
|
||||
./scripts/update-openapi.sh (regen is authoritative); commit the regenerated v1.json.
|
||||
- New endpoints need: OpenAPI operation names (route Name=), error-contract test entries
|
||||
(OpenApiErrorResponseContractTests) for 404-able routes, paging clamped (don't repeat the
|
||||
unclamped-pageSize nit), and handler tests via the ErsatzTV.Tests harness precedents.
|
||||
- Consumers to design for (don't implement): #89 Channel Builder poster grid + lineup totals;
|
||||
#86 schedule editor Content-tab picker (typed ids).
|
||||
|
||||
## On completion — REQUIRED last step
|
||||
Update docs/handoffs/chicorytv-issue-queue.md in place: pop the merge pass, write the next
|
||||
prompt (#109 Dashboard follow-up — see queue item 2 and its /api/health polling note), record
|
||||
PR numbers + the new main SHA + where web/ lives now. Commit+push the doc update (to main if
|
||||
the stack landed, else to docs/59-ui-redesign-brief). Print the new prompt in a fenced code
|
||||
block.
|
||||
## Process
|
||||
1. Comment on issue #65 with findings + approach (search-index reuse decision, projection
|
||||
shape, endpoint list with routes/DTOs) BEFORE coding.
|
||||
2. Implement; comment progress on #65 as you go.
|
||||
3. Verify: TZ=UTC dotnet build ErsatzTV.sln; TZ=UTC dotnet test ErsatzTV.Tests then
|
||||
ErsatzTV.Core.Tests sequentially (expect 368+new / 493+1skip); if v1.json changed, cd web
|
||||
&& npm ci && npm run generate:api && npm run typecheck (generated types must still
|
||||
compile; commit the regenerated v1.d.ts).
|
||||
4. Push, open PR → main: "feat(api): library browse + search endpoints (#65)", body lists
|
||||
endpoints + projection decisions; `closes #65`. Poll CI by head SHA until green. Do NOT
|
||||
merge.
|
||||
|
||||
## On completion — REQUIRED final output
|
||||
Print a fenced handoff prompt addressed to Claude (Fable) asking it to review the PR
|
||||
READ-ONLY (Fable runs its own review subagents). Include: PR number, branch, head SHA, base,
|
||||
files changed, endpoint/DTO table, verification commands + results, deferred/uncertain list,
|
||||
and the finding classification (NITS = Fable fixes on the branch; SUBSTANTIAL = back to
|
||||
Codex verbatim or fixed in-session with subagents — user decides). After approval + user
|
||||
merge consent, Fable merges, verifies main's post-merge run (image job included), updates
|
||||
THIS handoff (pop #65, next prompt = #64 Channel Templates, record PR + main SHA +
|
||||
baselines), and pushes it to main.
|
||||
|
||||
---
|
||||
|
||||
## Issue queue (work top-down)
|
||||
Single-session (Codex usage exhausted). No pre-seeded WIP branches remain.
|
||||
1. MERGE PASS: PR #112 (#97) + docs/59-ui-redesign-brief stack → main ← PROMPT above
|
||||
(validates SPA issues #78–#83/#96/#98 on main; unblocks all frontend work).
|
||||
2. #109 Dashboard follow-up (frontend; needs merge pass). NOTE from #108: GET /api/health
|
||||
re-runs all ~14 checks per request (only the warn/error summary is cached) — the SPA
|
||||
footer/Dashboard must load on demand or poll gently; add a TTL cache first if it needs to poll.
|
||||
3. Back to UX conversion: #84 Channels (deps #96/#98/#97 available post-merge-pass) → #86
|
||||
Schedule editor (duration estimates from #111) → #87 Playouts → #88 Libraries → #85 EPG
|
||||
(JSON guide from #102 available — mind the fillerKind + per-request-cost notes above) →
|
||||
#89 Channel Builder (artwork upload from #104; also needs #62: #63/#64/#65) → #93 Settings
|
||||
→ #90 rebrand → #91 cutover.
|
||||
Cross-refs: #99 (TS/HLS-Direct sessions) stays backlog; "Definition of Ready" for screen issues
|
||||
lives in the #59 epic body. Done: #105 (PR #113), #108 (PR #114), #100+#101+#107+#110 (PR #116),
|
||||
#103+#106 (PR #115), #104 (PR #117, artwork upload — unblocks #89), #111 (PR #118, schedule item
|
||||
duration estimates — unblocks #86), #102 (PR #119, GET /api/guide + shared ChannelGuideProjector —
|
||||
unblocks #85). Languages-list endpoint from #105 still unimplemented — open a follow-up when
|
||||
#86/#89 need it; filler/watermark lists return DB order — SPA should client-sort.
|
||||
0. HOUSEKEEPING: #99 stays open for the final /api/channels/state onAir wiring; #126
|
||||
(OpenAPI polymorphism gap) is a good backend slot-filler between screens.
|
||||
1. #65 library browse + search API ← CODEX PROMPT above (first #62 prerequisite; Fable
|
||||
reviews via subagents, merges on consent, updates this file).
|
||||
2. #62 epic continues: #64 Channel Templates (entity + dual migrations; design early — #63
|
||||
depends on it; investigate #68 alongside) → #63 composite create-channel endpoint →
|
||||
then #89 Channel Builder (also needs #104 artwork upload ✓ done; first Dialog/Modal
|
||||
component; languages endpoint follow-up from #105 when needed). #66/#67 as #89 demands.
|
||||
3. #93 Settings screen is dependency-free — usable as a frontend interleave if a backend
|
||||
session needs review turnaround.
|
||||
4. Then: #90 rebrand → #91 cutover.
|
||||
Cross-refs: #99 seam landed (PR #121), final wiring open. Done recently: PR #128 (#88),
|
||||
PR #129 (#85 Guide/EPG — closed 2026-07-05, main 8a62f222). Filed: #126.
|
||||
|
||||
+2220
-27
File diff suppressed because it is too large
Load Diff
+2255
-150
File diff suppressed because it is too large
Load Diff
+169
-1
@@ -1,8 +1,176 @@
|
||||
import { request } from './client';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ApiError, request } from './client';
|
||||
import type { components } from './generated/v1';
|
||||
|
||||
export type ChannelSummary = components['schemas']['ChannelResponseModel'];
|
||||
export type ChannelState = components['schemas']['ChannelStateResponseModel'];
|
||||
export type BulkRenumberChannelsRequest = components['schemas']['BulkRenumberChannelsRequest'];
|
||||
export type BulkMoveChannelsToGroupRequest = components['schemas']['BulkMoveChannelsToGroupRequest'];
|
||||
export type BulkDeleteChannelsRequest = components['schemas']['BulkDeleteChannelsRequest'];
|
||||
|
||||
export interface ChannelsScreenData {
|
||||
channels: ChannelSummary[];
|
||||
channelStates: ChannelState[];
|
||||
}
|
||||
|
||||
export type ChannelsScreenQueryState =
|
||||
| { data: ChannelsScreenData; error: null; refresh: () => void; status: 'success' }
|
||||
| { data: null; error: string; refresh: () => void; status: 'error' }
|
||||
| { data: null; error: null; refresh: () => void; status: 'loading' };
|
||||
|
||||
type ChannelsScreenState =
|
||||
| { data: ChannelsScreenData; error: null; status: 'success' }
|
||||
| { data: null; error: string; status: 'error' }
|
||||
| { data: null; error: null; status: 'loading' };
|
||||
|
||||
export function getChannels(): Promise<ChannelSummary[]> {
|
||||
return request<ChannelSummary[]>('/api/channels');
|
||||
}
|
||||
|
||||
export function getChannelStates(): Promise<ChannelState[]> {
|
||||
return request<ChannelState[]>('/api/channels/state');
|
||||
}
|
||||
|
||||
export async function getChannelsScreenData(): Promise<ChannelsScreenData> {
|
||||
const [channels, channelStates] = await Promise.all([
|
||||
getChannels(),
|
||||
getChannelStates()
|
||||
]);
|
||||
|
||||
return { channels, channelStates };
|
||||
}
|
||||
|
||||
export function bulkRenumberChannels(body: BulkRenumberChannelsRequest): Promise<void> {
|
||||
return request<void>('/api/channels/bulk/renumber', {
|
||||
body,
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
export function bulkMoveChannelsToGroup(body: BulkMoveChannelsToGroupRequest): Promise<void> {
|
||||
return request<void>('/api/channels/bulk/group', {
|
||||
body,
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
export function bulkDeleteChannels(body: BulkDeleteChannelsRequest): Promise<void> {
|
||||
return request<void>('/api/channels/bulk/delete', {
|
||||
body,
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteChannel(channelId: number): Promise<void> {
|
||||
return request<void>(`/api/channels/${channelId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
export function useChannelsScreenQuery(pollMs = 30000): ChannelsScreenQueryState {
|
||||
const [state, setState] = useState<ChannelsScreenState>({
|
||||
data: null,
|
||||
error: null,
|
||||
status: 'loading'
|
||||
});
|
||||
const activeRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const load = useCallback((showLoading = true) => {
|
||||
if (showLoading) {
|
||||
setState({ data: null, error: null, status: 'loading' });
|
||||
}
|
||||
|
||||
getChannelsScreenData()
|
||||
.then((data) => {
|
||||
if (activeRef.current) {
|
||||
setState({ data, error: null, status: 'success' });
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (activeRef.current) {
|
||||
setState({ data: null, error: messageFromError(error), status: 'error' });
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadChannelStates = useCallback(() => {
|
||||
getChannelStates()
|
||||
.then((channelStates) => {
|
||||
if (activeRef.current) {
|
||||
setState((current) => {
|
||||
if (current.status !== 'success') {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
data: { ...current.data, channelStates },
|
||||
error: null,
|
||||
status: 'success'
|
||||
};
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep the current table visible on background polling failures.
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const loadForEffect = () => {
|
||||
getChannelsScreenData()
|
||||
.then((data) => {
|
||||
if (activeRef.current) {
|
||||
setState({ data, error: null, status: 'success' });
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (activeRef.current) {
|
||||
setState({ data: null, error: messageFromError(error), status: 'error' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
loadForEffect();
|
||||
const intervalId = window.setInterval(() => {
|
||||
loadChannelStates();
|
||||
}, pollMs);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [loadChannelStates, pollMs]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
if (state.status === 'success') {
|
||||
return { data: state.data, error: null, refresh, status: 'success' };
|
||||
}
|
||||
|
||||
if (state.status === 'error') {
|
||||
return { data: null, error: state.error, refresh, status: 'error' };
|
||||
}
|
||||
|
||||
return { data: null, error: null, refresh, status: 'loading' };
|
||||
}
|
||||
|
||||
export function messageFromError(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.detail ?? error.message;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return 'Unable to load channels';
|
||||
}
|
||||
|
||||
+119
-16
@@ -1,36 +1,58 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ApiError, request } from './client';
|
||||
import type { components } from './generated/v1';
|
||||
|
||||
export type DashboardChannel = components['schemas']['ChannelResponseModel'];
|
||||
type DashboardCollection = components['schemas']['MediaCollectionViewModel'];
|
||||
type DashboardSchedule = components['schemas']['ProgramScheduleViewModel'];
|
||||
type DashboardSession = components['schemas']['HlsSessionModel'];
|
||||
type DashboardVersion = components['schemas']['CombinedVersion'];
|
||||
export type DashboardChannelState = components['schemas']['ChannelStateResponseModel'];
|
||||
type DashboardHealthCheck = components['schemas']['HealthCheckResponseModel'];
|
||||
type DashboardMediaSource = components['schemas']['MediaSourceResponseModel'];
|
||||
type DashboardPlayouts = components['schemas']['PagedPlayoutsResponseModel'];
|
||||
export type DashboardVersion = components['schemas']['CombinedVersion'];
|
||||
|
||||
export interface DashboardData {
|
||||
channels: DashboardChannel[];
|
||||
collections: DashboardCollection[];
|
||||
schedules: DashboardSchedule[];
|
||||
sessions: DashboardSession[];
|
||||
version: DashboardVersion;
|
||||
channelStates: DashboardChannelState[];
|
||||
mediaSources: DashboardMediaSource[];
|
||||
playouts: DashboardPlayouts;
|
||||
}
|
||||
|
||||
type DashboardQueryState =
|
||||
export type DashboardQueryState =
|
||||
| { data: DashboardData; error: null; status: 'success' }
|
||||
| { data: null; error: string; status: 'error' }
|
||||
| { data: null; error: null; status: 'loading' };
|
||||
|
||||
export type DashboardHealthQueryState =
|
||||
| { checks: DashboardHealthCheck[]; error: null; refresh: () => void; status: 'success' }
|
||||
| { checks: null; error: string; refresh: () => void; status: 'error' }
|
||||
| { checks: null; error: null; refresh: () => void; status: 'loading' };
|
||||
|
||||
export type DashboardVersionQueryState =
|
||||
| { error: null; status: 'success'; version: DashboardVersion }
|
||||
| { error: string; status: 'error'; version: null }
|
||||
| { error: null; status: 'loading'; version: null };
|
||||
|
||||
type DashboardHealthState =
|
||||
| { checks: DashboardHealthCheck[]; error: null; status: 'success' }
|
||||
| { checks: null; error: string; status: 'error' }
|
||||
| { checks: null; error: null; status: 'loading' };
|
||||
|
||||
export async function getDashboardData(): Promise<DashboardData> {
|
||||
const [channels, collections, schedules, sessions, version] = await Promise.all([
|
||||
const [channels, channelStates, mediaSources, playouts] = await Promise.all([
|
||||
request<DashboardChannel[]>('/api/channels'),
|
||||
request<DashboardCollection[]>('/api/collections'),
|
||||
request<DashboardSchedule[]>('/api/schedules'),
|
||||
request<DashboardSession[]>('/api/sessions'),
|
||||
request<DashboardVersion>('/api/version')
|
||||
request<DashboardChannelState[]>('/api/channels/state'),
|
||||
request<DashboardMediaSource[]>('/api/media-sources'),
|
||||
request<DashboardPlayouts>('/api/playouts')
|
||||
]);
|
||||
|
||||
return { channels, collections, schedules, sessions, version };
|
||||
return { channels, channelStates, mediaSources, playouts };
|
||||
}
|
||||
|
||||
export function getDashboardHealth(): Promise<DashboardHealthCheck[]> {
|
||||
return request<DashboardHealthCheck[]>('/api/health');
|
||||
}
|
||||
|
||||
export function getDashboardVersion(): Promise<DashboardVersion> {
|
||||
return request<DashboardVersion>('/api/version');
|
||||
}
|
||||
|
||||
export function useDashboardQuery(): DashboardQueryState {
|
||||
@@ -63,6 +85,87 @@ export function useDashboardQuery(): DashboardQueryState {
|
||||
return state;
|
||||
}
|
||||
|
||||
export function useDashboardHealthQuery(): DashboardHealthQueryState {
|
||||
const [state, setState] = useState<DashboardHealthState>({
|
||||
checks: null,
|
||||
error: null,
|
||||
status: 'loading'
|
||||
});
|
||||
|
||||
const activeRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadHealth = useCallback(() => {
|
||||
getDashboardHealth()
|
||||
.then((checks) => {
|
||||
if (activeRef.current) {
|
||||
setState({ checks, error: null, status: 'success' });
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (activeRef.current) {
|
||||
setState({ checks: null, error: messageFromError(error), status: 'error' });
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setState({ checks: null, error: null, status: 'loading' });
|
||||
loadHealth();
|
||||
}, [loadHealth]);
|
||||
|
||||
useEffect(() => {
|
||||
loadHealth();
|
||||
}, [loadHealth]);
|
||||
|
||||
if (state.status === 'success') {
|
||||
return { checks: state.checks, error: null, refresh, status: 'success' };
|
||||
}
|
||||
|
||||
if (state.status === 'error') {
|
||||
return { checks: null, error: state.error, refresh, status: 'error' };
|
||||
}
|
||||
|
||||
return { checks: null, error: null, refresh, status: 'loading' };
|
||||
}
|
||||
|
||||
export function useDashboardVersionQuery(): DashboardVersionQueryState {
|
||||
const [state, setState] = useState<DashboardVersionQueryState>({
|
||||
error: null,
|
||||
status: 'loading',
|
||||
version: null
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
getDashboardVersion()
|
||||
.then((version) => {
|
||||
if (active) {
|
||||
setState({ error: null, status: 'success', version });
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (active) {
|
||||
setState({ error: messageFromError(error), status: 'error', version: null });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function messageFromError(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.detail ?? error.message;
|
||||
|
||||
Vendored
+23
@@ -292,6 +292,25 @@ export interface components {
|
||||
"lastAccess": string;
|
||||
};
|
||||
"IFormFile": string;
|
||||
"LibraryBrowseItemResponseModel": {
|
||||
"id": number;
|
||||
"mediaType": components["schemas"]["LibraryBrowseMediaType"];
|
||||
"title": string;
|
||||
"libraryId": null | number;
|
||||
"libraryName": null | string;
|
||||
"artwork": string;
|
||||
"duration": null | string;
|
||||
"itemCount": null | number;
|
||||
"collectionKind": null | string;
|
||||
"collectionType": components["schemas"]["CollectionType"];
|
||||
"collectionId": null | number;
|
||||
"multiCollectionId": null | number;
|
||||
"smartCollectionId": null | number;
|
||||
"rerunCollectionId": null | number;
|
||||
"mediaItemId": null | number;
|
||||
"playlistId": null | number;
|
||||
};
|
||||
"LibraryBrowseMediaType": "Movie" | "TelevisionShow" | "TelevisionSeason" | "Artist" | "Collection" | "SmartCollection" | "MultiCollection" | "RerunCollection" | "Playlist";
|
||||
"LibraryMediaKind": "Movies" | "Shows" | "MusicVideos" | "OtherVideos" | "Songs" | "Images" | "RemoteStreams";
|
||||
"LibraryScanStatusResponseModel": {
|
||||
"libraryId": number;
|
||||
@@ -350,6 +369,10 @@ export interface components {
|
||||
"name": null | string;
|
||||
};
|
||||
"NormalizeLoudnessMode": "Off" | "LoudNorm";
|
||||
"PagedLibraryBrowseItemsResponseModel": {
|
||||
"totalCount": number;
|
||||
"page": Array<components["schemas"]["LibraryBrowseItemResponseModel"]>;
|
||||
};
|
||||
"PagedPlayoutItemsResponseModel": {
|
||||
"totalCount": number;
|
||||
"page": null | Array<components["schemas"]["PlayoutItemResponseModel"]>;
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { getChannelStates, messageFromError, type ChannelState } from './channels';
|
||||
import { request } from './client';
|
||||
import type { components } from './generated/v1';
|
||||
|
||||
export type ChannelGuide = components['schemas']['ChannelGuideResponseModel'];
|
||||
export type ChannelGuideChannel = components['schemas']['ChannelGuideChannelResponseModel'];
|
||||
export type ChannelGuideProgramme = components['schemas']['ChannelGuideProgrammeResponseModel'];
|
||||
|
||||
export interface GuideScreenData {
|
||||
channelStates: ChannelState[];
|
||||
guide: ChannelGuide;
|
||||
}
|
||||
|
||||
export type GuideScreenQueryState =
|
||||
| {
|
||||
data: GuideScreenData;
|
||||
error: null;
|
||||
refresh: () => void;
|
||||
setWindowStart: (start: Date) => void;
|
||||
status: 'success';
|
||||
windowStart: Date;
|
||||
windowEnd: Date;
|
||||
}
|
||||
| { data: null; error: string; refresh: () => void; status: 'error'; windowStart: Date; windowEnd: Date }
|
||||
| { data: null; error: null; refresh: () => void; status: 'loading'; windowStart: Date; windowEnd: Date };
|
||||
|
||||
type GuideScreenState =
|
||||
| { data: GuideScreenData; error: null; status: 'success' }
|
||||
| { data: null; error: string; status: 'error' }
|
||||
| { data: null; error: null; status: 'loading' };
|
||||
|
||||
export const GUIDE_PAST_MS = 60 * 60 * 1000;
|
||||
export const GUIDE_WINDOW_MS = 13 * 60 * 60 * 1000;
|
||||
|
||||
export function defaultGuideWindowStart(now = new Date()): Date {
|
||||
return new Date(now.getTime() - GUIDE_PAST_MS);
|
||||
}
|
||||
|
||||
export function getGuide(start: Date, end: Date): Promise<ChannelGuide> {
|
||||
const query = new URLSearchParams({
|
||||
start: start.toISOString(),
|
||||
end: end.toISOString()
|
||||
});
|
||||
|
||||
return request<ChannelGuide>(`/api/guide?${query.toString()}`);
|
||||
}
|
||||
|
||||
export async function getGuideScreenData(start: Date, end: Date): Promise<GuideScreenData> {
|
||||
const guide = await getGuide(start, end);
|
||||
const channelStates = await getChannelStates().catch(() => []);
|
||||
|
||||
return { channelStates, guide };
|
||||
}
|
||||
|
||||
export function useGuideScreenQuery(channelStatePollMs = 30000): GuideScreenQueryState {
|
||||
const [windowStart, setWindowStartState] = useState(() => defaultGuideWindowStart());
|
||||
const windowStartRef = useRef(windowStart);
|
||||
const [state, setState] = useState<GuideScreenState>({
|
||||
data: null,
|
||||
error: null,
|
||||
status: 'loading'
|
||||
});
|
||||
const activeRef = useRef(true);
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const windowEnd = new Date(windowStart.getTime() + GUIDE_WINDOW_MS);
|
||||
|
||||
const load = useCallback((start = windowStartRef.current) => {
|
||||
const end = new Date(start.getTime() + GUIDE_WINDOW_MS);
|
||||
const requestId = requestIdRef.current + 1;
|
||||
requestIdRef.current = requestId;
|
||||
|
||||
getGuideScreenData(start, end)
|
||||
.then((data) => {
|
||||
if (
|
||||
activeRef.current &&
|
||||
requestIdRef.current === requestId &&
|
||||
windowStartRef.current.getTime() === start.getTime()
|
||||
) {
|
||||
setState({ data, error: null, status: 'success' });
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (
|
||||
activeRef.current &&
|
||||
requestIdRef.current === requestId &&
|
||||
windowStartRef.current.getTime() === start.getTime()
|
||||
) {
|
||||
setState({ data: null, error: messageFromError(error), status: 'error' });
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadChannelStates = useCallback(() => {
|
||||
getChannelStates()
|
||||
.then((channelStates) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState((current) => {
|
||||
if (current.status !== 'success') {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
data: { ...current.data, channelStates },
|
||||
error: null,
|
||||
status: 'success'
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep the guide visible when the optional live-state refresh fails.
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load(windowStart);
|
||||
}, [load, windowStart]);
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = window.setInterval(loadChannelStates, Math.max(channelStatePollMs, 30000));
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [channelStatePollMs, loadChannelStates]);
|
||||
|
||||
const setWindowStart = useCallback((start: Date) => {
|
||||
windowStartRef.current = start;
|
||||
setState({ data: null, error: null, status: 'loading' });
|
||||
setWindowStartState(start);
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setState({ data: null, error: null, status: 'loading' });
|
||||
load(windowStartRef.current);
|
||||
}, [load]);
|
||||
|
||||
if (state.status === 'success') {
|
||||
return { data: state.data, error: null, refresh, setWindowStart, status: 'success', windowEnd, windowStart };
|
||||
}
|
||||
|
||||
if (state.status === 'error') {
|
||||
return { data: null, error: state.error, refresh, status: 'error', windowEnd, windowStart };
|
||||
}
|
||||
|
||||
return { data: null, error: null, refresh, status: 'loading', windowEnd, windowStart };
|
||||
}
|
||||
@@ -2,4 +2,8 @@ export * from './auth';
|
||||
export * from './channels';
|
||||
export * from './client';
|
||||
export * from './dashboard';
|
||||
export * from './guide';
|
||||
export * from './libraries';
|
||||
export * from './playouts';
|
||||
export * from './schedules';
|
||||
export * from './useChannelsQuery';
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ApiError, request } from './client';
|
||||
import type { components } from './generated/v1';
|
||||
|
||||
export type MediaSource = components['schemas']['MediaSourceResponseModel'];
|
||||
export type MediaSourceLibrary = components['schemas']['MediaSourceLibraryResponseModel'];
|
||||
export type LibraryScanStatus = components['schemas']['LibraryScanStatusResponseModel'];
|
||||
|
||||
export interface LibrariesScreenData {
|
||||
scanStatuses: LibraryScanStatus[];
|
||||
sources: MediaSource[];
|
||||
}
|
||||
|
||||
export type LibrariesScreenQueryState =
|
||||
| {
|
||||
data: LibrariesScreenData;
|
||||
error: string | null;
|
||||
refresh: () => void;
|
||||
scanLibrary: (libraryId: number) => Promise<void>;
|
||||
scanningLibraryIds: Set<number>;
|
||||
status: 'success';
|
||||
}
|
||||
| { data: null; error: string; refresh: () => void; status: 'error' }
|
||||
| { data: null; error: null; refresh: () => void; status: 'loading' };
|
||||
|
||||
type LibrariesScreenState =
|
||||
| { data: LibrariesScreenData; error: string | null; pendingLibraryIds: Set<number>; status: 'success' }
|
||||
| { data: null; error: string; status: 'error' }
|
||||
| { data: null; error: null; status: 'loading' };
|
||||
|
||||
// A trigger'd scan disappears from the button-disabled set once either: it has been
|
||||
// observed at least once in scan-status (promoted to "active"), or this many poll
|
||||
// ticks pass without ever appearing (the scanner never picked it up / it failed silently).
|
||||
const PENDING_GRACE_TICKS = 3;
|
||||
|
||||
export function getMediaSources(): Promise<MediaSource[]> {
|
||||
return request<MediaSource[]>('/api/media-sources');
|
||||
}
|
||||
|
||||
export function getLibraryScanStatus(): Promise<LibraryScanStatus[]> {
|
||||
// Wire contract: `percent` is a 0-1 FRACTION despite the field name - the backend
|
||||
// never multiplies by 100 (known backend wart, tracked in the handoff backlog).
|
||||
// Normalize to a 0-100 percentage once, here at the API boundary, so every
|
||||
// consumer (ProgressBar, percent labels) works in ordinary percentage terms.
|
||||
return request<LibraryScanStatus[]>('/api/libraries/scan-status').then((scanStatuses) =>
|
||||
scanStatuses.map((scanStatus) => ({ ...scanStatus, percent: scanStatus.percent * 100 }))
|
||||
);
|
||||
}
|
||||
|
||||
export function scanLibrary(libraryId: number): Promise<void> {
|
||||
return request<void>(`/api/libraries/${libraryId}/scan`, { method: 'POST' });
|
||||
}
|
||||
|
||||
// Pure: computes the surviving pending-id set for one poll tick (success or failure) and
|
||||
// mutates the grace-ticks map in place (delete on promote/expire, set on decrement) -
|
||||
// callers must still write pendingIdsRef.current with the returned set themselves, and
|
||||
// must call this exactly once per tick before that write to keep it a single, ref-free
|
||||
// computation that's safe to run under StrictMode double-invocation.
|
||||
function pruneGraceExpiredPending(
|
||||
pendingIds: Set<number>,
|
||||
graceTicks: Map<number, number>,
|
||||
isSeenActive: (libraryId: number) => boolean
|
||||
): Set<number> {
|
||||
const nextPending = new Set<number>();
|
||||
|
||||
pendingIds.forEach((libraryId) => {
|
||||
if (isSeenActive(libraryId)) {
|
||||
// Seen active at least once - normal active/inactive pruning takes over.
|
||||
graceTicks.delete(libraryId);
|
||||
return;
|
||||
}
|
||||
|
||||
const ticksRemaining = (graceTicks.get(libraryId) ?? PENDING_GRACE_TICKS) - 1;
|
||||
|
||||
if (ticksRemaining <= 0) {
|
||||
// Grace window expired without ever appearing in scan-status - give up on it.
|
||||
graceTicks.delete(libraryId);
|
||||
return;
|
||||
}
|
||||
|
||||
graceTicks.set(libraryId, ticksRemaining);
|
||||
nextPending.add(libraryId);
|
||||
});
|
||||
|
||||
return nextPending;
|
||||
}
|
||||
|
||||
export function useLibrariesScreenQuery(pollMs = 10000): LibrariesScreenQueryState {
|
||||
const [state, setState] = useState<LibrariesScreenState>({
|
||||
data: null,
|
||||
error: null,
|
||||
status: 'loading'
|
||||
});
|
||||
const activeRef = useRef(true);
|
||||
const hadScanInProgressRef = useRef(false);
|
||||
// Mirrors of the corresponding state, kept in sync synchronously so triggerScan can
|
||||
// guard against double-submits without waiting for a render.
|
||||
const pendingIdsRef = useRef<Set<number>>(new Set());
|
||||
const activeIdsRef = useRef<Set<number>>(new Set());
|
||||
const pendingGraceTicksRef = useRef<Map<number, number>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadSources = useCallback(() => {
|
||||
getMediaSources()
|
||||
.then((sources) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState((current) => {
|
||||
if (current.status !== 'success') {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
data: { scanStatuses: current.data.scanStatuses, sources },
|
||||
error: null,
|
||||
pendingLibraryIds: current.pendingLibraryIds,
|
||||
status: 'success'
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// A completion refresh should not hide the screen if the source reload fails.
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadScanStatuses = useCallback(() => {
|
||||
return getLibraryScanStatus()
|
||||
.then((scanStatuses) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeScanIds = new Set(scanStatuses.map((scan) => scan.libraryId));
|
||||
activeIdsRef.current = activeScanIds;
|
||||
|
||||
const hadScanInProgress = hadScanInProgressRef.current;
|
||||
hadScanInProgressRef.current = scanStatuses.length > 0;
|
||||
|
||||
// Compute the pruned/promoted pending set - and write the grace-tick and pending
|
||||
// refs - OUTSIDE the setState updater. Updaters must be pure: StrictMode double-
|
||||
// invokes them (which would double-decrement grace ticks) and concurrent rendering
|
||||
// may invoke-and-discard one. Mirrors how activeIdsRef is written above.
|
||||
const nextPending = pruneGraceExpiredPending(pendingIdsRef.current, pendingGraceTicksRef.current, (libraryId) =>
|
||||
activeScanIds.has(libraryId)
|
||||
);
|
||||
|
||||
pendingIdsRef.current = nextPending;
|
||||
|
||||
setState((current) => {
|
||||
if (current.status !== 'success') {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
data: { scanStatuses, sources: current.data.sources },
|
||||
error: null,
|
||||
pendingLibraryIds: nextPending,
|
||||
status: 'success'
|
||||
};
|
||||
});
|
||||
|
||||
if (hadScanInProgress && scanStatuses.length === 0) {
|
||||
loadSources();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A status-fetch failure never promotes a pending id to active, so it never
|
||||
// survives via "seen active" - it just burns down the same grace budget as a
|
||||
// successful poll that never saw it. This keeps a persistently-erroring endpoint
|
||||
// from leaving the scan button disabled and the poll interval running forever.
|
||||
// Active-scan state (activeIdsRef / scanStatuses) is left untouched: a transient
|
||||
// failure must not kill an in-progress scan's UI.
|
||||
const nextPending = pruneGraceExpiredPending(pendingIdsRef.current, pendingGraceTicksRef.current, () => false);
|
||||
|
||||
pendingIdsRef.current = nextPending;
|
||||
|
||||
setState((current) => {
|
||||
if (current.status !== 'success') {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
data: current.data,
|
||||
error: current.error,
|
||||
pendingLibraryIds: nextPending,
|
||||
status: 'success'
|
||||
};
|
||||
});
|
||||
});
|
||||
}, [loadSources]);
|
||||
|
||||
const load = useCallback(() => {
|
||||
Promise.all([getMediaSources(), getLibraryScanStatus()])
|
||||
.then(([sources, scanStatuses]) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hadScanInProgressRef.current = scanStatuses.length > 0;
|
||||
pendingIdsRef.current = new Set();
|
||||
activeIdsRef.current = new Set(scanStatuses.map((scan) => scan.libraryId));
|
||||
pendingGraceTicksRef.current = new Map();
|
||||
setState({
|
||||
data: { scanStatuses, sources },
|
||||
error: null,
|
||||
pendingLibraryIds: new Set(),
|
||||
status: 'success'
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (activeRef.current) {
|
||||
setState({ data: null, error: messageFromLibrariesError(error, 'Unable to load libraries'), status: 'error' });
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const hasActiveScans =
|
||||
state.status === 'success' && (state.pendingLibraryIds.size > 0 || state.data.scanStatuses.length > 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasActiveScans) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const intervalId = window.setInterval(loadScanStatuses, Math.max(pollMs, 10000));
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [hasActiveScans, loadScanStatuses, pollMs]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setState({ data: null, error: null, status: 'loading' });
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const triggerScan = useCallback((libraryId: number): Promise<void> => {
|
||||
if (pendingIdsRef.current.has(libraryId) || activeIdsRef.current.has(libraryId)) {
|
||||
// Already pending or active - ignore the duplicate submission.
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
pendingIdsRef.current = new Set(pendingIdsRef.current).add(libraryId);
|
||||
pendingGraceTicksRef.current.set(libraryId, PENDING_GRACE_TICKS);
|
||||
|
||||
setState((current) => {
|
||||
if (current.status !== 'success') {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
data: current.data,
|
||||
error: null,
|
||||
pendingLibraryIds: new Set(current.pendingLibraryIds).add(libraryId),
|
||||
status: 'success'
|
||||
};
|
||||
});
|
||||
|
||||
return scanLibrary(libraryId)
|
||||
.then(() => loadScanStatuses())
|
||||
.catch((error: unknown) => {
|
||||
const pendingIds = new Set(pendingIdsRef.current);
|
||||
pendingIds.delete(libraryId);
|
||||
pendingIdsRef.current = pendingIds;
|
||||
pendingGraceTicksRef.current.delete(libraryId);
|
||||
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState((current) => {
|
||||
if (current.status !== 'success') {
|
||||
return current;
|
||||
}
|
||||
|
||||
const pendingLibraryIds = new Set(current.pendingLibraryIds);
|
||||
pendingLibraryIds.delete(libraryId);
|
||||
|
||||
return {
|
||||
data: current.data,
|
||||
error: messageFromLibrariesError(error, 'Unable to scan library'),
|
||||
pendingLibraryIds,
|
||||
status: 'success'
|
||||
};
|
||||
});
|
||||
});
|
||||
}, [loadScanStatuses]);
|
||||
|
||||
if (state.status === 'success') {
|
||||
const activeLibraryIds = new Set(state.data.scanStatuses.map((scan) => scan.libraryId));
|
||||
const scanningLibraryIds = new Set([...state.pendingLibraryIds, ...activeLibraryIds]);
|
||||
|
||||
return {
|
||||
data: state.data,
|
||||
error: state.error,
|
||||
refresh,
|
||||
scanLibrary: triggerScan,
|
||||
scanningLibraryIds,
|
||||
status: 'success'
|
||||
};
|
||||
}
|
||||
|
||||
if (state.status === 'error') {
|
||||
return { data: null, error: state.error, refresh, status: 'error' };
|
||||
}
|
||||
|
||||
return { data: null, error: null, refresh, status: 'loading' };
|
||||
}
|
||||
|
||||
function messageFromLibrariesError(error: unknown, fallback = 'Unable to load libraries'): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.detail ?? error.message;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ApiError, request } from './client';
|
||||
import type { components } from './generated/v1';
|
||||
|
||||
export type PlayoutSummary = components['schemas']['PlayoutListItemResponseModel'];
|
||||
export type PlayoutDetail = components['schemas']['PlayoutResponseModel'];
|
||||
export type PlayoutItem = components['schemas']['PlayoutItemResponseModel'];
|
||||
export type PlayoutsPage = components['schemas']['PagedPlayoutsResponseModel'];
|
||||
export type PlayoutItemsPage = components['schemas']['PagedPlayoutItemsResponseModel'];
|
||||
export type PlayoutChannelState = components['schemas']['ChannelStateResponseModel'];
|
||||
|
||||
export interface PlayoutsScreenData {
|
||||
channelStates: PlayoutChannelState[];
|
||||
items: PlayoutItem[];
|
||||
playout: PlayoutDetail | null;
|
||||
playouts: PlayoutSummary[];
|
||||
selectedPlayoutId: number | null;
|
||||
totalCount: number;
|
||||
warningsCount: number;
|
||||
}
|
||||
|
||||
type PlayoutsScreenBase = Omit<PlayoutsScreenData, 'items' | 'playout'>;
|
||||
|
||||
export type PlayoutsScreenQueryState =
|
||||
| {
|
||||
data: PlayoutsScreenData;
|
||||
error: null;
|
||||
itemsLoading: boolean;
|
||||
refresh: () => void;
|
||||
setActivePlayout: (playoutId: number) => void;
|
||||
setShowFiller: (showFiller: boolean) => void;
|
||||
showFiller: boolean;
|
||||
status: 'success';
|
||||
}
|
||||
| { data: null; error: string; refresh: () => void; status: 'error' }
|
||||
| { data: null; error: null; refresh: () => void; status: 'loading' };
|
||||
|
||||
type PlayoutsScreenState =
|
||||
| { data: PlayoutsScreenData; error: null; itemsLoading: boolean; status: 'success' }
|
||||
| { data: null; error: string; status: 'error' }
|
||||
| { data: null; error: null; status: 'loading' };
|
||||
|
||||
export function getPlayouts(): Promise<PlayoutsPage> {
|
||||
return request<PlayoutsPage>('/api/playouts');
|
||||
}
|
||||
|
||||
export function getPlayout(playoutId: number): Promise<PlayoutDetail> {
|
||||
return request<PlayoutDetail>(`/api/playouts/${playoutId}`);
|
||||
}
|
||||
|
||||
export function getPlayoutItems(playoutId: number, showFiller = false): Promise<PlayoutItemsPage> {
|
||||
return request<PlayoutItemsPage>(`/api/playouts/${playoutId}/items${showFiller ? '?showFiller=true' : ''}`);
|
||||
}
|
||||
|
||||
export function getPlayoutWarningsCount(): Promise<number> {
|
||||
return request<number>('/api/playouts/warnings/count');
|
||||
}
|
||||
|
||||
export function getPlayoutChannelStates(): Promise<PlayoutChannelState[]> {
|
||||
return request<PlayoutChannelState[]>('/api/channels/state');
|
||||
}
|
||||
|
||||
export function resetAllPlayouts(): Promise<void> {
|
||||
return request<void>('/api/playouts/reset-all', { method: 'POST' });
|
||||
}
|
||||
|
||||
export function usePlayoutsScreenQuery(pollMs = 30000): PlayoutsScreenQueryState {
|
||||
const [state, setState] = useState<PlayoutsScreenState>({
|
||||
data: null,
|
||||
error: null,
|
||||
status: 'loading'
|
||||
});
|
||||
const [showFiller, setShowFillerState] = useState(false);
|
||||
const activeRef = useRef(true);
|
||||
const selectedPlayoutIdRef = useRef<number | null>(null);
|
||||
const showFillerRef = useRef(false);
|
||||
// Snapshot of the last-known base fields (everything but the selected playout's
|
||||
// detail/items), kept in sync whenever load()/loadChannelStates() succeed. Reading
|
||||
// this lets setActivePlayout/setShowFiller kick off their fetch without doing the
|
||||
// fetch from inside a setState updater (impure; StrictMode double-invokes updaters).
|
||||
const baseRef = useRef<PlayoutsScreenBase>({
|
||||
channelStates: [],
|
||||
playouts: [],
|
||||
selectedPlayoutId: null,
|
||||
totalCount: 0,
|
||||
warningsCount: 0
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadSelectedPlayout = useCallback((playoutId: number, base: PlayoutsScreenBase) => {
|
||||
baseRef.current = base;
|
||||
|
||||
Promise.all([getPlayout(playoutId), getPlayoutItems(playoutId, showFillerRef.current)])
|
||||
.then(([playout, itemsPage]) => {
|
||||
if (!activeRef.current || selectedPlayoutIdRef.current !== playoutId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState((current) => {
|
||||
// Merge onto whatever channel state the background poll may have landed
|
||||
// while this request was in flight, rather than clobbering it with the
|
||||
// snapshot captured when the request started.
|
||||
const channelStates = current.status === 'success' ? current.data.channelStates : base.channelStates;
|
||||
|
||||
return {
|
||||
data: {
|
||||
...base,
|
||||
channelStates,
|
||||
items: itemsPage.page ?? [],
|
||||
playout,
|
||||
selectedPlayoutId: playoutId
|
||||
},
|
||||
error: null,
|
||||
itemsLoading: false,
|
||||
status: 'success'
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (activeRef.current && selectedPlayoutIdRef.current === playoutId) {
|
||||
setState({ data: null, error: messageFromPlayoutError(error), status: 'error' });
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const load = useCallback(() => {
|
||||
Promise.all([getPlayouts(), getPlayoutWarningsCount(), getPlayoutChannelStates()])
|
||||
.then(([playoutsPage, warningsCount, channelStates]) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const playouts = playoutsPage.page ?? [];
|
||||
const selectedPlayoutId = selectedPlayoutIdRef.current && playouts.some((playout) => playout.id === selectedPlayoutIdRef.current)
|
||||
? selectedPlayoutIdRef.current
|
||||
: playouts[0]?.id ?? null;
|
||||
selectedPlayoutIdRef.current = selectedPlayoutId;
|
||||
|
||||
const base: PlayoutsScreenBase = {
|
||||
channelStates,
|
||||
playouts,
|
||||
selectedPlayoutId,
|
||||
totalCount: playoutsPage.totalCount,
|
||||
warningsCount
|
||||
};
|
||||
baseRef.current = base;
|
||||
|
||||
if (selectedPlayoutId == null) {
|
||||
setState({
|
||||
data: {
|
||||
...base,
|
||||
items: [],
|
||||
playout: null
|
||||
},
|
||||
error: null,
|
||||
itemsLoading: false,
|
||||
status: 'success'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
loadSelectedPlayout(selectedPlayoutId, base);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (activeRef.current) {
|
||||
setState({ data: null, error: messageFromPlayoutError(error), status: 'error' });
|
||||
}
|
||||
});
|
||||
}, [loadSelectedPlayout]);
|
||||
|
||||
const loadChannelStates = useCallback(() => {
|
||||
getPlayoutChannelStates()
|
||||
.then((channelStates) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
baseRef.current = { ...baseRef.current, channelStates };
|
||||
|
||||
setState((current) => {
|
||||
if (current.status !== 'success') {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
data: { ...current.data, channelStates },
|
||||
error: null,
|
||||
itemsLoading: current.itemsLoading,
|
||||
status: 'success'
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep the monitor visible on background polling failures.
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
|
||||
const intervalId = window.setInterval(loadChannelStates, pollMs);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [load, loadChannelStates, pollMs]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setState({ data: null, error: null, status: 'loading' });
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const setActivePlayout = useCallback((playoutId: number) => {
|
||||
selectedPlayoutIdRef.current = playoutId;
|
||||
|
||||
setState((current) => {
|
||||
if (current.status !== 'success') {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
...current.data,
|
||||
items: [],
|
||||
playout: null,
|
||||
selectedPlayoutId: playoutId
|
||||
},
|
||||
error: null,
|
||||
itemsLoading: true,
|
||||
status: 'success'
|
||||
};
|
||||
});
|
||||
|
||||
const base: PlayoutsScreenBase = { ...baseRef.current, selectedPlayoutId: playoutId };
|
||||
loadSelectedPlayout(playoutId, base);
|
||||
}, [loadSelectedPlayout]);
|
||||
|
||||
const setShowFiller = useCallback((next: boolean) => {
|
||||
showFillerRef.current = next;
|
||||
setShowFillerState(next);
|
||||
|
||||
const playoutId = selectedPlayoutIdRef.current;
|
||||
|
||||
if (playoutId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState((current) => {
|
||||
if (current.status !== 'success') {
|
||||
return current;
|
||||
}
|
||||
|
||||
return { ...current, itemsLoading: true };
|
||||
});
|
||||
|
||||
getPlayoutItems(playoutId, next)
|
||||
.then((itemsPage) => {
|
||||
if (!activeRef.current || selectedPlayoutIdRef.current !== playoutId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState((current) => {
|
||||
if (current.status !== 'success') {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
data: { ...current.data, items: itemsPage.page ?? [] },
|
||||
error: null,
|
||||
itemsLoading: false,
|
||||
status: 'success'
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!activeRef.current || selectedPlayoutIdRef.current !== playoutId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState({ data: null, error: messageFromPlayoutError(error), status: 'error' });
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (state.status === 'success') {
|
||||
return {
|
||||
data: state.data,
|
||||
error: null,
|
||||
itemsLoading: state.itemsLoading,
|
||||
refresh,
|
||||
setActivePlayout,
|
||||
setShowFiller,
|
||||
showFiller,
|
||||
status: 'success'
|
||||
};
|
||||
}
|
||||
|
||||
if (state.status === 'error') {
|
||||
return { data: null, error: state.error, refresh, status: 'error' };
|
||||
}
|
||||
|
||||
return { data: null, error: null, refresh, status: 'loading' };
|
||||
}
|
||||
|
||||
function messageFromPlayoutError(error: unknown, fallback = 'Unable to load playouts'): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.detail ?? error.message;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ApiError, request } from './client';
|
||||
import type { components } from './generated/v1';
|
||||
|
||||
export type ProgramSchedule = components['schemas']['ProgramScheduleViewModel'];
|
||||
// These fields are real on the wire (the backend serializes ProgramScheduleItemViewModel
|
||||
// subtypes with Newtonsoft using the runtime type), but the OpenAPI schema doesn't declare
|
||||
// them: ProgramScheduleItemViewModel is an abstract base with no polymorphism annotation, so
|
||||
// the generator only sees the base shape. Tracked in Gitea issue #126 — remove this widening
|
||||
// once the schema is fixed to describe the concrete subtypes.
|
||||
export type ProgramScheduleItem = components['schemas']['ProgramScheduleItemViewModel'] & {
|
||||
count?: null | string;
|
||||
discardToFillAttempts?: null | number;
|
||||
multipleCount?: null | string;
|
||||
multipleMode?: components['schemas']['MultipleMode'];
|
||||
playoutDuration?: null | string;
|
||||
tailMode?: components['schemas']['TailMode'];
|
||||
};
|
||||
export type ProgramScheduleItemsWithDuration = components['schemas']['ProgramScheduleItemsWithDurationViewModel'];
|
||||
export type ScheduleItemRequest = components['schemas']['ScheduleItemRequest'];
|
||||
export type ReplaceScheduleItemsRequest = components['schemas']['ReplaceScheduleItemsRequest'];
|
||||
export type MediaCollection = components['schemas']['MediaCollectionViewModel'];
|
||||
export type SmartCollection = components['schemas']['SmartCollectionViewModel'];
|
||||
export type FillerPreset = components['schemas']['FillerPresetResponseModel'];
|
||||
export type Watermark = components['schemas']['WatermarkResponseModel'];
|
||||
|
||||
export interface SchedulePickerData {
|
||||
collections: MediaCollection[];
|
||||
fillerPresets: FillerPreset[];
|
||||
smartCollections: SmartCollection[];
|
||||
watermarks: Watermark[];
|
||||
}
|
||||
|
||||
export interface ScheduleScreenData {
|
||||
activeSchedule: ProgramSchedule | null;
|
||||
items: ProgramScheduleItem[];
|
||||
pickers: SchedulePickerData;
|
||||
schedules: ProgramSchedule[];
|
||||
totalDurationEstimate: string | null;
|
||||
}
|
||||
|
||||
export type ScheduleScreenQueryState =
|
||||
| {
|
||||
data: ScheduleScreenData;
|
||||
error: null;
|
||||
itemsLoading: boolean;
|
||||
refresh: () => void;
|
||||
setActiveSchedule: (scheduleId: number) => void;
|
||||
setItems: (items: ProgramScheduleItem[]) => void;
|
||||
status: 'success';
|
||||
}
|
||||
| { data: null; error: string; refresh: () => void; status: 'error' }
|
||||
| { data: null; error: null; refresh: () => void; status: 'loading' };
|
||||
|
||||
type ScheduleScreenState =
|
||||
| { data: ScheduleScreenData; error: null; itemsLoading: boolean; status: 'success' }
|
||||
| { data: null; error: string; status: 'error' }
|
||||
| { data: null; error: null; status: 'loading' };
|
||||
|
||||
export function getSchedules(): Promise<ProgramSchedule[]> {
|
||||
return request<ProgramSchedule[]>('/api/schedules');
|
||||
}
|
||||
|
||||
export function getScheduleItems(scheduleId: number): Promise<ProgramScheduleItemsWithDuration> {
|
||||
return request<ProgramScheduleItemsWithDuration>(`/api/schedules/${scheduleId}/items`);
|
||||
}
|
||||
|
||||
export function getCollections(): Promise<MediaCollection[]> {
|
||||
return request<MediaCollection[]>('/api/collections');
|
||||
}
|
||||
|
||||
export function getSmartCollections(): Promise<SmartCollection[]> {
|
||||
return request<SmartCollection[]>('/api/smart-collections');
|
||||
}
|
||||
|
||||
export function getFillerPresets(): Promise<FillerPreset[]> {
|
||||
return request<FillerPreset[]>('/api/filler-presets');
|
||||
}
|
||||
|
||||
export function getWatermarks(): Promise<Watermark[]> {
|
||||
return request<Watermark[]>('/api/watermarks');
|
||||
}
|
||||
|
||||
export function addScheduleItem(scheduleId: number, body: ScheduleItemRequest): Promise<ProgramScheduleItem> {
|
||||
return request<ProgramScheduleItem>(`/api/schedules/${scheduleId}/items`, {
|
||||
body,
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
export function replaceScheduleItems(
|
||||
scheduleId: number,
|
||||
body: ReplaceScheduleItemsRequest
|
||||
): Promise<ProgramScheduleItem[]> {
|
||||
return request<ProgramScheduleItem[]>(`/api/schedules/${scheduleId}/items`, {
|
||||
body,
|
||||
method: 'PUT'
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteScheduleItem(scheduleId: number, itemId: number): Promise<void> {
|
||||
return request<void>(`/api/schedules/${scheduleId}/items/${itemId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
export async function getScheduleScreenData(): Promise<ScheduleScreenData> {
|
||||
const schedules = await getSchedules();
|
||||
const activeSchedule = schedules[0] ?? null;
|
||||
|
||||
if (!activeSchedule) {
|
||||
return {
|
||||
activeSchedule: null,
|
||||
items: [],
|
||||
pickers: emptyPickerData(),
|
||||
schedules,
|
||||
totalDurationEstimate: null
|
||||
};
|
||||
}
|
||||
|
||||
const [itemsEnvelope, pickers] = await Promise.all([
|
||||
getScheduleItems(activeSchedule.id),
|
||||
getSchedulePickerData()
|
||||
]);
|
||||
|
||||
return {
|
||||
activeSchedule,
|
||||
items: (itemsEnvelope.items ?? []) as ProgramScheduleItem[],
|
||||
pickers,
|
||||
schedules,
|
||||
totalDurationEstimate: itemsEnvelope.totalDurationEstimate
|
||||
};
|
||||
}
|
||||
|
||||
export function useScheduleScreenQuery(): ScheduleScreenQueryState {
|
||||
const [state, setState] = useState<ScheduleScreenState>({
|
||||
data: null,
|
||||
error: null,
|
||||
status: 'loading'
|
||||
});
|
||||
const activeRef = useRef(true);
|
||||
const activeScheduleIdRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const load = useCallback(() => {
|
||||
getScheduleScreenData()
|
||||
.then((data) => {
|
||||
if (activeRef.current) {
|
||||
activeScheduleIdRef.current = data.activeSchedule?.id ?? null;
|
||||
setState({ data, error: null, itemsLoading: false, status: 'success' });
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (activeRef.current) {
|
||||
setState({ data: null, error: messageFromScheduleError(error, 'Unable to load schedules'), status: 'error' });
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setState({ data: null, error: null, status: 'loading' });
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const setActiveSchedule = useCallback((scheduleId: number) => {
|
||||
activeScheduleIdRef.current = scheduleId;
|
||||
|
||||
setState((current) => {
|
||||
if (current.status !== 'success') {
|
||||
return current;
|
||||
}
|
||||
|
||||
const nextActiveSchedule = current.data.schedules.find((schedule) => schedule.id === scheduleId)
|
||||
?? current.data.activeSchedule;
|
||||
|
||||
return {
|
||||
data: { ...current.data, activeSchedule: nextActiveSchedule },
|
||||
error: null,
|
||||
itemsLoading: true,
|
||||
status: 'success'
|
||||
};
|
||||
});
|
||||
|
||||
getScheduleItems(scheduleId)
|
||||
.then((itemsEnvelope) => {
|
||||
if (!activeRef.current || activeScheduleIdRef.current !== scheduleId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState((current) => {
|
||||
if (current.status !== 'success') {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
...current.data,
|
||||
items: (itemsEnvelope.items ?? []) as ProgramScheduleItem[],
|
||||
totalDurationEstimate: itemsEnvelope.totalDurationEstimate
|
||||
},
|
||||
error: null,
|
||||
itemsLoading: false,
|
||||
status: 'success'
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!activeRef.current || activeScheduleIdRef.current !== scheduleId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState({
|
||||
data: null,
|
||||
error: messageFromScheduleError(error, 'Unable to load schedule items'),
|
||||
status: 'error'
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setItems = useCallback((items: ProgramScheduleItem[]) => {
|
||||
setState((current) => {
|
||||
if (current.status !== 'success') {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
data: { ...current.data, items },
|
||||
error: null,
|
||||
itemsLoading: false,
|
||||
status: 'success'
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (state.status === 'success') {
|
||||
return {
|
||||
data: state.data,
|
||||
error: null,
|
||||
itemsLoading: state.itemsLoading,
|
||||
refresh,
|
||||
setActiveSchedule,
|
||||
setItems,
|
||||
status: 'success'
|
||||
};
|
||||
}
|
||||
|
||||
if (state.status === 'error') {
|
||||
return { data: null, error: state.error, refresh, status: 'error' };
|
||||
}
|
||||
|
||||
return { data: null, error: null, refresh, status: 'loading' };
|
||||
}
|
||||
|
||||
async function getSchedulePickerData(): Promise<SchedulePickerData> {
|
||||
const [collections, smartCollections, fillerPresets, watermarks] = await Promise.all([
|
||||
getCollections(),
|
||||
getSmartCollections(),
|
||||
getFillerPresets(),
|
||||
getWatermarks()
|
||||
]);
|
||||
|
||||
return {
|
||||
collections,
|
||||
fillerPresets: sortByName(fillerPresets),
|
||||
smartCollections,
|
||||
watermarks: sortByName(watermarks)
|
||||
};
|
||||
}
|
||||
|
||||
function emptyPickerData(): SchedulePickerData {
|
||||
return {
|
||||
collections: [],
|
||||
fillerPresets: [],
|
||||
smartCollections: [],
|
||||
watermarks: []
|
||||
};
|
||||
}
|
||||
|
||||
function sortByName<T extends { name: null | string }>(items: T[]): T[] {
|
||||
return [...items].sort((left, right) => (left.name ?? '').localeCompare(right.name ?? ''));
|
||||
}
|
||||
|
||||
function messageFromScheduleError(error: unknown, fallback = 'Unable to load schedules'): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.detail ?? error.message;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
+1513
-65
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user