Compare commits

..
Author SHA1 Message Date
timothyandClaude Opus 4.8 cf85bf3093 docs: regenerate endpoint-index after merging main (includes GET collection items) (#155)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m27s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m45s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 18:28:20 +02:00
timothy 72fc8cc850 Merge remote-tracking branch 'origin/main' into feat/collections-api 2026-07-08 18:28:03 +02:00
timothyandClaude Opus 4.8 f869dfe87a feat(api): GET /api/collections/{id}/items (paged) + confirm POST-items 422 guard (#155)
Adds a paged collection-items endpoint reusing LibraryBrowseItemResponseModel
so the SPA lists a manual collection's full contents (all media kinds), replacing
the lossy Lucene name-based preview. Confirms POST /items already returns 422 for
bogus ids (guarded by ValidateMediaItems, fb3f2856); adds endpoint-level coverage.

fixes #155

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 18:26:52 +02:00
timothy 311411fa38 Merge pull request 'feat: quick wins — security-registry scan, Trakt SPA link, parallel search, dead "New Group" sweep' (#187) from feat/quick-wins into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m44s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m55s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m3s
2026-07-08 06:03:24 +00:00
timothy a7d3fc3055 Merge pull request 'docs: testing map + generated endpoint index (#185)' (#186) from feat/185-docs-part2 into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m55s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m59s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
2026-07-08 05:52:30 +00:00
timothyandClaude Opus 4.8 6902f559f0 docs: guard endpoint-index regen on successful spec build (#185 review)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m34s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m41s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Only run generate-endpoint-index.py if the GenerateOpenApiDocuments build
succeeded, so a failed build can't render docs/endpoint-index.md from a
stale/partial v1.json.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 07:42:38 +02:00
timothyandClaude Opus 4.8 0290594f0b docs: testing map + generated endpoint index (#185)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m36s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m45s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Adds docs/testing.md as the authoritative testing map (consolidated
from docs/contributing.md §8, now shrunk to a pointer), and a
generated docs/endpoint-index.md via scripts/generate-endpoint-index.py
(hooked into scripts/update-openapi.sh). Updates docs/README.md's
reading order and removes the "still to come" placeholder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 07:38:47 +02:00
20 changed files with 1592 additions and 684 deletions
@@ -0,0 +1,620 @@
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Infrastructure.Data;
using Flurl;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.LibraryBrowse;
// Shared MediaItem -> LibraryBrowseItemResponseModel projection used by both the library-browse
// search handler and the collection-items handler (#155). Keeping the per-kind hydration and the
// rooted-artwork logic in one place avoids duplicating the Blazor-vs-SPA artwork rooting rules
// (see the Artwork helper below and docs/api-conventions.md §4).
internal static class LibraryBrowseItemMapper
{
// Hydrates an arbitrary set of media item ids (any kinds mixed) into response models. MediaItem
// ids are globally unique across kinds, so passing the full id list to every per-kind query is
// safe: each query only matches its own kind. Callers order/page the result themselves.
public static async Task<List<LibraryBrowseItemResponseModel>> HydrateMediaItemsByIds(
TvContext dbContext,
IReadOnlyList<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
var idList = ids.Distinct().ToList();
var results = new List<LibraryBrowseItemResponseModel>();
results.AddRange(await GetMovies(dbContext, idList, cancellationToken));
results.AddRange(await GetShows(dbContext, idList, cancellationToken));
results.AddRange(await GetSeasons(dbContext, idList, cancellationToken));
results.AddRange(await GetArtists(dbContext, idList, cancellationToken));
results.AddRange(await GetEpisodes(dbContext, idList, cancellationToken));
results.AddRange(await GetMusicVideos(dbContext, idList, cancellationToken));
results.AddRange(await GetSongs(dbContext, idList, cancellationToken));
results.AddRange(await GetOtherVideos(dbContext, idList, cancellationToken));
results.AddRange(await GetImages(dbContext, idList, cancellationToken));
results.AddRange(await GetRemoteStreams(dbContext, idList, cancellationToken));
return results;
}
public 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());
}
public 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());
}
public 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)
.ThenInclude(shm => shm.Artwork)
.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,
SeasonArtwork(sm),
null,
counts.TryGetValue(sm.SeasonId, out int count) ? count : 0,
null,
CollectionType.TelevisionSeason,
null,
null,
null,
null,
sm.SeasonId,
null)).ToList());
}
public 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());
}
public static async Task<List<LibraryBrowseItemResponseModel>> GetEpisodes(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
return await dbContext.EpisodeMetadata
.AsNoTracking()
.Where(em => ids.Contains(em.EpisodeId))
.Include(em => em.Artwork)
.Include(em => em.Episode)
.ThenInclude(e => e.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(em => em.Episode)
.ThenInclude(e => e.MediaVersions)
.Include(em => em.Episode)
.ThenInclude(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(sh => sh.ShowMetadata)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(em => em.EpisodeId)
.Select(g => g.OrderBy(em => em.Id).First())
.Map(em => new LibraryBrowseItemResponseModel(
em.EpisodeId,
LibraryBrowseMediaType.Episode,
em.Title ?? string.Empty,
em.Episode.LibraryPath.LibraryId,
em.Episode.LibraryPath.Library.Name,
ArtworkWithFallback(em, ArtworkKind.Thumbnail, ArtworkKind.Poster),
BestDuration(em.Episode.MediaVersions),
1,
null,
CollectionType.Episode,
null,
null,
null,
null,
em.EpisodeId,
null,
EpisodeSubtitle(em))).ToList());
}
public static async Task<List<LibraryBrowseItemResponseModel>> GetMusicVideos(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
return await dbContext.MusicVideoMetadata
.AsNoTracking()
.Where(mvm => ids.Contains(mvm.MusicVideoId))
.Include(mvm => mvm.Artwork)
.Include(mvm => mvm.MusicVideo)
.ThenInclude(mv => mv.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(mvm => mvm.MusicVideo)
.ThenInclude(mv => mv.MediaVersions)
.Include(mvm => mvm.MusicVideo)
.ThenInclude(mv => mv.Artist)
.ThenInclude(a => a.ArtistMetadata)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(mvm => mvm.MusicVideoId)
.Select(g => g.OrderBy(mvm => mvm.Id).First())
.Map(mvm => new LibraryBrowseItemResponseModel(
mvm.MusicVideoId,
LibraryBrowseMediaType.MusicVideo,
mvm.Title ?? string.Empty,
mvm.MusicVideo.LibraryPath.LibraryId,
mvm.MusicVideo.LibraryPath.Library.Name,
ArtworkWithFallback(mvm, ArtworkKind.Thumbnail, ArtworkKind.Poster),
BestDuration(mvm.MusicVideo.MediaVersions),
1,
null,
CollectionType.MusicVideo,
null,
null,
null,
null,
mvm.MusicVideoId,
null,
MusicVideoSubtitle(mvm))).ToList());
}
public static async Task<List<LibraryBrowseItemResponseModel>> GetSongs(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
return await dbContext.SongMetadata
.AsNoTracking()
.Where(sm => ids.Contains(sm.SongId))
.Include(sm => sm.Artwork)
.Include(sm => sm.Song)
.ThenInclude(s => s.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(sm => sm.Song)
.ThenInclude(s => s.MediaVersions)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(sm => sm.SongId)
.Select(g => g.OrderBy(sm => sm.Id).First())
.Map(sm => new LibraryBrowseItemResponseModel(
sm.SongId,
LibraryBrowseMediaType.Song,
sm.Title ?? string.Empty,
sm.Song.LibraryPath.LibraryId,
sm.Song.LibraryPath.Library.Name,
ArtworkWithFallback(sm, ArtworkKind.Thumbnail, ArtworkKind.Poster),
BestDuration(sm.Song.MediaVersions),
1,
null,
CollectionType.Song,
null,
null,
null,
null,
sm.SongId,
null,
SongSubtitle(sm))).ToList());
}
public static async Task<List<LibraryBrowseItemResponseModel>> GetOtherVideos(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
return await dbContext.OtherVideoMetadata
.AsNoTracking()
.Where(ovm => ids.Contains(ovm.OtherVideoId))
.Include(ovm => ovm.Artwork)
.Include(ovm => ovm.OtherVideo)
.ThenInclude(ov => ov.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(ovm => ovm.OtherVideo)
.ThenInclude(ov => ov.MediaVersions)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(ovm => ovm.OtherVideoId)
.Select(g => g.OrderBy(ovm => ovm.Id).First())
.Map(ovm => new LibraryBrowseItemResponseModel(
ovm.OtherVideoId,
LibraryBrowseMediaType.OtherVideo,
ovm.Title ?? string.Empty,
ovm.OtherVideo.LibraryPath.LibraryId,
ovm.OtherVideo.LibraryPath.Library.Name,
ArtworkWithFallback(ovm, ArtworkKind.Thumbnail, ArtworkKind.Poster),
BestDuration(ovm.OtherVideo.MediaVersions),
1,
null,
CollectionType.OtherVideo,
null,
null,
null,
null,
ovm.OtherVideoId,
null,
string.IsNullOrWhiteSpace(ovm.OriginalTitle) ? null : ovm.OriginalTitle)).ToList());
}
public static async Task<List<LibraryBrowseItemResponseModel>> GetImages(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
return await dbContext.ImageMetadata
.AsNoTracking()
.Where(im => ids.Contains(im.ImageId))
.Include(im => im.Artwork)
.Include(im => im.Image)
.ThenInclude(i => i.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(im => im.Image)
.ThenInclude(i => i.MediaVersions)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(im => im.ImageId)
.Select(g => g.OrderBy(im => im.Id).First())
.Map(im => new LibraryBrowseItemResponseModel(
im.ImageId,
LibraryBrowseMediaType.Image,
im.Title ?? string.Empty,
im.Image.LibraryPath.LibraryId,
im.Image.LibraryPath.Library.Name,
ArtworkWithFallback(im, ArtworkKind.Poster, ArtworkKind.Thumbnail),
BestDuration(im.Image.MediaVersions),
1,
null,
CollectionType.Image,
null,
null,
null,
null,
im.ImageId,
null,
string.IsNullOrWhiteSpace(im.OriginalTitle) ? null : im.OriginalTitle)).ToList());
}
public static async Task<List<LibraryBrowseItemResponseModel>> GetRemoteStreams(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
return await dbContext.RemoteStreamMetadata
.AsNoTracking()
.Where(rsm => ids.Contains(rsm.RemoteStreamId))
.Include(rsm => rsm.Artwork)
.Include(rsm => rsm.RemoteStream)
.ThenInclude(rs => rs.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(rsm => rsm.RemoteStream)
.ThenInclude(rs => rs.MediaVersions)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(rsm => rsm.RemoteStreamId)
.Select(g => g.OrderBy(rsm => rsm.Id).First())
.Map(rsm => new LibraryBrowseItemResponseModel(
rsm.RemoteStreamId,
LibraryBrowseMediaType.RemoteStream,
rsm.Title ?? string.Empty,
rsm.RemoteStream.LibraryPath.LibraryId,
rsm.RemoteStream.LibraryPath.Library.Name,
ArtworkWithFallback(rsm, ArtworkKind.Thumbnail, ArtworkKind.Poster),
BestDuration(rsm.RemoteStream.MediaVersions),
1,
null,
CollectionType.RemoteStream,
null,
null,
null,
null,
rsm.RemoteStreamId,
null,
string.IsNullOrWhiteSpace(rsm.OriginalTitle) ? null : rsm.OriginalTitle)).ToList());
}
public 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;
}
// Returns a rooted, directly-usable artwork URL for the SPA's <img src>. Blazor pages rely on
// GetPosterUrl to prefix "artwork/posters/" and resolve relative to <base href="/">, but the SPA
// renders the value raw from under /app/, so the API must root the URL itself (issue #180).
public static string Artwork(Metadata metadata, ArtworkKind artworkKind)
{
string artwork = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
.Match(a => a.Path, string.Empty);
if (string.IsNullOrWhiteSpace(artwork))
{
return string.Empty;
}
// Absolute URLs are already usable as-is (matches Blazor's GetPosterUrl guard).
if (artwork.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
artwork.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
return artwork;
}
string folder = artworkKind is ArtworkKind.Thumbnail ? "thumbnails" : "posters";
if (artwork.StartsWith("jellyfin://", StringComparison.OrdinalIgnoreCase))
{
Url url = JellyfinUrl.RelativeProxyForArtwork(artwork);
if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail)
{
url.SetQueryParam("fillHeight", 440);
}
return $"/artwork/{folder}/{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 $"/artwork/{folder}/{url}";
}
return $"/artwork/{folder}/{artwork}";
}
private static string ArtworkWithFallback(Metadata metadata, ArtworkKind primary, ArtworkKind fallback)
{
string artwork = Artwork(metadata, primary);
return string.IsNullOrWhiteSpace(artwork) ? Artwork(metadata, fallback) : artwork;
}
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}";
}
// Seasons often have no poster of their own; fall back to the parent show's poster (issue #180).
private static string SeasonArtwork(SeasonMetadata metadata)
{
string artwork = Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(artwork))
{
return artwork;
}
return metadata.Season.Show.ShowMetadata.HeadOrNone()
.Match(sm => Artwork(sm, ArtworkKind.Poster), string.Empty);
}
private static string EpisodeSubtitle(EpisodeMetadata metadata)
{
string showTitle = metadata.Episode.Season.Show.ShowMetadata.HeadOrNone()
.Map(sm => sm.Title ?? string.Empty)
.IfNone(string.Empty);
int seasonNumber = metadata.Episode.Season.SeasonNumber;
string suffix = $"S{seasonNumber}E{metadata.EpisodeNumber}";
return string.IsNullOrWhiteSpace(showTitle) ? suffix : $"{showTitle} - {suffix}";
}
private static string MusicVideoSubtitle(MusicVideoMetadata metadata)
{
string artist = metadata.MusicVideo.Artist.ArtistMetadata.HeadOrNone()
.Map(am => am.Title ?? string.Empty)
.IfNone(string.Empty);
string album = string.IsNullOrWhiteSpace(metadata.Album) ? string.Empty : metadata.Album;
if (!string.IsNullOrWhiteSpace(artist) && !string.IsNullOrWhiteSpace(album))
{
return $"{artist} - {album}";
}
return string.IsNullOrWhiteSpace(artist) ? album : artist;
}
private static string SongSubtitle(SongMetadata metadata)
{
string artists = string.Join(", ", metadata.Artists ?? []);
string album = string.IsNullOrWhiteSpace(metadata.Album) ? string.Empty : metadata.Album;
if (!string.IsNullOrWhiteSpace(artists) && !string.IsNullOrWhiteSpace(album))
{
return $"{artists} - {album}";
}
return string.IsNullOrWhiteSpace(artists) ? album : artists;
}
}
@@ -1,12 +1,9 @@
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;
@@ -167,53 +164,62 @@ public class GetLibraryBrowseItemsHandler(
Dictionary<(string Type, int Id), LibraryBrowseItemResponseModel> hydrated = [];
foreach (LibraryBrowseItemResponseModel item in await GetMovies(dbContext, movieIds, cancellationToken))
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetMovies(dbContext, movieIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.MovieType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in await GetShows(dbContext, showIds, cancellationToken))
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetShows(dbContext, showIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.ShowType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in await GetSeasons(dbContext, seasonIds, cancellationToken))
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetSeasons(dbContext, seasonIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.SeasonType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in await GetArtists(dbContext, artistIds, cancellationToken))
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetArtists(dbContext, artistIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.ArtistType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in await GetEpisodes(dbContext, episodeIds, cancellationToken))
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetEpisodes(dbContext, episodeIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.EpisodeType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in await GetMusicVideos(dbContext, musicVideoIds, cancellationToken))
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetMusicVideos(dbContext, musicVideoIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.MusicVideoType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in await GetSongs(dbContext, songIds, cancellationToken))
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetSongs(dbContext, songIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.SongType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in await GetOtherVideos(dbContext, otherVideoIds, cancellationToken))
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetOtherVideos(dbContext, otherVideoIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.OtherVideoType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in await GetImages(dbContext, imageIds, cancellationToken))
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetImages(dbContext, imageIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.ImageType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in
await GetRemoteStreams(dbContext, remoteStreamIds, cancellationToken))
await LibraryBrowseItemMapper.GetRemoteStreams(dbContext, remoteStreamIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.RemoteStreamType, item.Id)] = item;
}
@@ -224,95 +230,6 @@ public class GetLibraryBrowseItemsHandler(
.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<PagedLibraryBrowseItemsResponseModel> BrowseSeasonsForShow(
TvContext dbContext,
GetLibraryBrowseItems request,
@@ -331,7 +248,8 @@ public class GetLibraryBrowseItemsHandler(
.Take(request.PageSize)
.ToList();
List<LibraryBrowseItemResponseModel> seasons = await GetSeasons(dbContext, pageIds, cancellationToken);
List<LibraryBrowseItemResponseModel> seasons =
await LibraryBrowseItemMapper.GetSeasons(dbContext, pageIds, cancellationToken);
// GetSeasons groups by season id, so restore the requested season-number order.
Dictionary<int, LibraryBrowseItemResponseModel> byId = seasons.ToDictionary(s => s.Id);
@@ -366,7 +284,8 @@ public class GetLibraryBrowseItemsHandler(
.Take(request.PageSize)
.ToList();
List<LibraryBrowseItemResponseModel> episodes = await GetEpisodes(dbContext, pageIds, cancellationToken);
List<LibraryBrowseItemResponseModel> episodes =
await LibraryBrowseItemMapper.GetEpisodes(dbContext, pageIds, cancellationToken);
Dictionary<int, LibraryBrowseItemResponseModel> byId = episodes.ToDictionary(e => e.Id);
List<LibraryBrowseItemResponseModel> ordered = pageIds
@@ -401,7 +320,8 @@ public class GetLibraryBrowseItemsHandler(
.Take(request.PageSize)
.ToList();
List<LibraryBrowseItemResponseModel> musicVideos = await GetMusicVideos(dbContext, pageIds, cancellationToken);
List<LibraryBrowseItemResponseModel> musicVideos =
await LibraryBrowseItemMapper.GetMusicVideos(dbContext, pageIds, cancellationToken);
Dictionary<int, LibraryBrowseItemResponseModel> byId = musicVideos.ToDictionary(mv => mv.Id);
List<LibraryBrowseItemResponseModel> ordered = pageIds
@@ -412,405 +332,6 @@ public class GetLibraryBrowseItemsHandler(
return new PagedLibraryBrowseItemsResponseModel(total, ordered);
}
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)
.ThenInclude(shm => shm.Artwork)
.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,
SeasonArtwork(sm),
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<List<LibraryBrowseItemResponseModel>> GetEpisodes(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
return await dbContext.EpisodeMetadata
.AsNoTracking()
.Where(em => ids.Contains(em.EpisodeId))
.Include(em => em.Artwork)
.Include(em => em.Episode)
.ThenInclude(e => e.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(em => em.Episode)
.ThenInclude(e => e.MediaVersions)
.Include(em => em.Episode)
.ThenInclude(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(sh => sh.ShowMetadata)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(em => em.EpisodeId)
.Select(g => g.OrderBy(em => em.Id).First())
.Map(em => new LibraryBrowseItemResponseModel(
em.EpisodeId,
LibraryBrowseMediaType.Episode,
em.Title ?? string.Empty,
em.Episode.LibraryPath.LibraryId,
em.Episode.LibraryPath.Library.Name,
ArtworkWithFallback(em, ArtworkKind.Thumbnail, ArtworkKind.Poster),
BestDuration(em.Episode.MediaVersions),
1,
null,
CollectionType.Episode,
null,
null,
null,
null,
em.EpisodeId,
null,
EpisodeSubtitle(em))).ToList());
}
private static async Task<List<LibraryBrowseItemResponseModel>> GetMusicVideos(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
return await dbContext.MusicVideoMetadata
.AsNoTracking()
.Where(mvm => ids.Contains(mvm.MusicVideoId))
.Include(mvm => mvm.Artwork)
.Include(mvm => mvm.MusicVideo)
.ThenInclude(mv => mv.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(mvm => mvm.MusicVideo)
.ThenInclude(mv => mv.MediaVersions)
.Include(mvm => mvm.MusicVideo)
.ThenInclude(mv => mv.Artist)
.ThenInclude(a => a.ArtistMetadata)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(mvm => mvm.MusicVideoId)
.Select(g => g.OrderBy(mvm => mvm.Id).First())
.Map(mvm => new LibraryBrowseItemResponseModel(
mvm.MusicVideoId,
LibraryBrowseMediaType.MusicVideo,
mvm.Title ?? string.Empty,
mvm.MusicVideo.LibraryPath.LibraryId,
mvm.MusicVideo.LibraryPath.Library.Name,
ArtworkWithFallback(mvm, ArtworkKind.Thumbnail, ArtworkKind.Poster),
BestDuration(mvm.MusicVideo.MediaVersions),
1,
null,
CollectionType.MusicVideo,
null,
null,
null,
null,
mvm.MusicVideoId,
null,
MusicVideoSubtitle(mvm))).ToList());
}
private static async Task<List<LibraryBrowseItemResponseModel>> GetSongs(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
return await dbContext.SongMetadata
.AsNoTracking()
.Where(sm => ids.Contains(sm.SongId))
.Include(sm => sm.Artwork)
.Include(sm => sm.Song)
.ThenInclude(s => s.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(sm => sm.Song)
.ThenInclude(s => s.MediaVersions)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(sm => sm.SongId)
.Select(g => g.OrderBy(sm => sm.Id).First())
.Map(sm => new LibraryBrowseItemResponseModel(
sm.SongId,
LibraryBrowseMediaType.Song,
sm.Title ?? string.Empty,
sm.Song.LibraryPath.LibraryId,
sm.Song.LibraryPath.Library.Name,
ArtworkWithFallback(sm, ArtworkKind.Thumbnail, ArtworkKind.Poster),
BestDuration(sm.Song.MediaVersions),
1,
null,
CollectionType.Song,
null,
null,
null,
null,
sm.SongId,
null,
SongSubtitle(sm))).ToList());
}
private static async Task<List<LibraryBrowseItemResponseModel>> GetOtherVideos(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
return await dbContext.OtherVideoMetadata
.AsNoTracking()
.Where(ovm => ids.Contains(ovm.OtherVideoId))
.Include(ovm => ovm.Artwork)
.Include(ovm => ovm.OtherVideo)
.ThenInclude(ov => ov.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(ovm => ovm.OtherVideo)
.ThenInclude(ov => ov.MediaVersions)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(ovm => ovm.OtherVideoId)
.Select(g => g.OrderBy(ovm => ovm.Id).First())
.Map(ovm => new LibraryBrowseItemResponseModel(
ovm.OtherVideoId,
LibraryBrowseMediaType.OtherVideo,
ovm.Title ?? string.Empty,
ovm.OtherVideo.LibraryPath.LibraryId,
ovm.OtherVideo.LibraryPath.Library.Name,
ArtworkWithFallback(ovm, ArtworkKind.Thumbnail, ArtworkKind.Poster),
BestDuration(ovm.OtherVideo.MediaVersions),
1,
null,
CollectionType.OtherVideo,
null,
null,
null,
null,
ovm.OtherVideoId,
null,
string.IsNullOrWhiteSpace(ovm.OriginalTitle) ? null : ovm.OriginalTitle)).ToList());
}
private static async Task<List<LibraryBrowseItemResponseModel>> GetImages(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
return await dbContext.ImageMetadata
.AsNoTracking()
.Where(im => ids.Contains(im.ImageId))
.Include(im => im.Artwork)
.Include(im => im.Image)
.ThenInclude(i => i.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(im => im.Image)
.ThenInclude(i => i.MediaVersions)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(im => im.ImageId)
.Select(g => g.OrderBy(im => im.Id).First())
.Map(im => new LibraryBrowseItemResponseModel(
im.ImageId,
LibraryBrowseMediaType.Image,
im.Title ?? string.Empty,
im.Image.LibraryPath.LibraryId,
im.Image.LibraryPath.Library.Name,
ArtworkWithFallback(im, ArtworkKind.Poster, ArtworkKind.Thumbnail),
BestDuration(im.Image.MediaVersions),
1,
null,
CollectionType.Image,
null,
null,
null,
null,
im.ImageId,
null,
string.IsNullOrWhiteSpace(im.OriginalTitle) ? null : im.OriginalTitle)).ToList());
}
private static async Task<List<LibraryBrowseItemResponseModel>> GetRemoteStreams(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
return await dbContext.RemoteStreamMetadata
.AsNoTracking()
.Where(rsm => ids.Contains(rsm.RemoteStreamId))
.Include(rsm => rsm.Artwork)
.Include(rsm => rsm.RemoteStream)
.ThenInclude(rs => rs.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(rsm => rsm.RemoteStream)
.ThenInclude(rs => rs.MediaVersions)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(rsm => rsm.RemoteStreamId)
.Select(g => g.OrderBy(rsm => rsm.Id).First())
.Map(rsm => new LibraryBrowseItemResponseModel(
rsm.RemoteStreamId,
LibraryBrowseMediaType.RemoteStream,
rsm.Title ?? string.Empty,
rsm.RemoteStream.LibraryPath.LibraryId,
rsm.RemoteStream.LibraryPath.Library.Name,
ArtworkWithFallback(rsm, ArtworkKind.Thumbnail, ArtworkKind.Poster),
BestDuration(rsm.RemoteStream.MediaVersions),
1,
null,
CollectionType.RemoteStream,
null,
null,
null,
null,
rsm.RemoteStreamId,
null,
string.IsNullOrWhiteSpace(rsm.OriginalTitle) ? null : rsm.OriginalTitle)).ToList());
}
private static string EpisodeSubtitle(EpisodeMetadata metadata)
{
string showTitle = metadata.Episode.Season.Show.ShowMetadata.HeadOrNone()
.Map(sm => sm.Title ?? string.Empty)
.IfNone(string.Empty);
int seasonNumber = metadata.Episode.Season.SeasonNumber;
string suffix = $"S{seasonNumber}E{metadata.EpisodeNumber}";
return string.IsNullOrWhiteSpace(showTitle) ? suffix : $"{showTitle} - {suffix}";
}
private static string MusicVideoSubtitle(MusicVideoMetadata metadata)
{
string artist = metadata.MusicVideo.Artist.ArtistMetadata.HeadOrNone()
.Map(am => am.Title ?? string.Empty)
.IfNone(string.Empty);
string album = string.IsNullOrWhiteSpace(metadata.Album) ? string.Empty : metadata.Album;
if (!string.IsNullOrWhiteSpace(artist) && !string.IsNullOrWhiteSpace(album))
{
return $"{artist} - {album}";
}
return string.IsNullOrWhiteSpace(artist) ? album : artist;
}
private static string SongSubtitle(SongMetadata metadata)
{
string artists = string.Join(", ", metadata.Artists ?? []);
string album = string.IsNullOrWhiteSpace(metadata.Album) ? string.Empty : metadata.Album;
if (!string.IsNullOrWhiteSpace(artists) && !string.IsNullOrWhiteSpace(album))
{
return $"{artists} - {album}";
}
return string.IsNullOrWhiteSpace(artists) ? album : artists;
}
private static async Task<int> CountCollections(
TvContext dbContext,
GetLibraryBrowseItems request,
@@ -1140,16 +661,6 @@ public class GetLibraryBrowseItemsHandler(
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,
@@ -1165,7 +676,7 @@ public class GetLibraryBrowseItemsHandler(
.Include(m => m.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = BestDuration(movie.MediaVersions);
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(movie.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[movie.Id] = duration.Value;
@@ -1178,7 +689,7 @@ public class GetLibraryBrowseItemsHandler(
.Include(e => e.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = BestDuration(episode.MediaVersions);
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(episode.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[episode.Id] = duration.Value;
@@ -1191,7 +702,7 @@ public class GetLibraryBrowseItemsHandler(
.Include(mv => mv.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = BestDuration(musicVideo.MediaVersions);
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(musicVideo.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[musicVideo.Id] = duration.Value;
@@ -1204,7 +715,7 @@ public class GetLibraryBrowseItemsHandler(
.Include(ov => ov.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = BestDuration(otherVideo.MediaVersions);
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(otherVideo.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[otherVideo.Id] = duration.Value;
@@ -1217,7 +728,7 @@ public class GetLibraryBrowseItemsHandler(
.Include(s => s.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = BestDuration(song.MediaVersions);
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(song.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[song.Id] = duration.Value;
@@ -1230,7 +741,7 @@ public class GetLibraryBrowseItemsHandler(
.Include(i => i.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = BestDuration(image.MediaVersions);
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(image.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[image.Id] = duration.Value;
@@ -1243,7 +754,7 @@ public class GetLibraryBrowseItemsHandler(
.Include(rs => rs.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = BestDuration(remoteStream.MediaVersions);
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(remoteStream.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[remoteStream.Id] = duration.Value;
@@ -1274,7 +785,7 @@ public class GetLibraryBrowseItemsHandler(
.OrderBy(mm => mm.Id)
.ToListAsync(cancellationToken))
{
string poster = Artwork(metadata, ArtworkKind.Poster);
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.MovieId))
{
mediaItemArtwork[metadata.MovieId] = poster;
@@ -1288,7 +799,7 @@ public class GetLibraryBrowseItemsHandler(
.OrderBy(sm => sm.Id)
.ToListAsync(cancellationToken))
{
string poster = Artwork(metadata, ArtworkKind.Poster);
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.ShowId))
{
mediaItemArtwork[metadata.ShowId] = poster;
@@ -1302,7 +813,7 @@ public class GetLibraryBrowseItemsHandler(
.OrderBy(sm => sm.Id)
.ToListAsync(cancellationToken))
{
string poster = Artwork(metadata, ArtworkKind.Poster);
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.SeasonId))
{
mediaItemArtwork[metadata.SeasonId] = poster;
@@ -1316,7 +827,7 @@ public class GetLibraryBrowseItemsHandler(
.OrderBy(ovm => ovm.Id)
.ToListAsync(cancellationToken))
{
string poster = Artwork(metadata, ArtworkKind.Poster);
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.OtherVideoId))
{
mediaItemArtwork[metadata.OtherVideoId] = poster;
@@ -1330,7 +841,7 @@ public class GetLibraryBrowseItemsHandler(
.OrderBy(sm => sm.Id)
.ToListAsync(cancellationToken))
{
string poster = Artwork(metadata, ArtworkKind.Poster);
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.SongId))
{
mediaItemArtwork[metadata.SongId] = poster;
@@ -1344,7 +855,7 @@ public class GetLibraryBrowseItemsHandler(
.OrderBy(im => im.Id)
.ToListAsync(cancellationToken))
{
string poster = Artwork(metadata, ArtworkKind.Poster);
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.ImageId))
{
mediaItemArtwork[metadata.ImageId] = poster;
@@ -1358,7 +869,7 @@ public class GetLibraryBrowseItemsHandler(
.OrderBy(rsm => rsm.Id)
.ToListAsync(cancellationToken))
{
string poster = Artwork(metadata, ArtworkKind.Poster);
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.RemoteStreamId))
{
mediaItemArtwork[metadata.RemoteStreamId] = poster;
@@ -1383,83 +894,6 @@ public class GetLibraryBrowseItemsHandler(
.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}";
}
// Seasons often have no poster of their own; fall back to the parent show's poster (issue #180).
private static string SeasonArtwork(SeasonMetadata metadata)
{
string artwork = Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(artwork))
{
return artwork;
}
return metadata.Season.Show.ShowMetadata.HeadOrNone()
.Match(sm => Artwork(sm, ArtworkKind.Poster), string.Empty);
}
private static string ArtworkWithFallback(Metadata metadata, ArtworkKind primary, ArtworkKind fallback)
{
string artwork = Artwork(metadata, primary);
return string.IsNullOrWhiteSpace(artwork) ? Artwork(metadata, fallback) : artwork;
}
// Returns a rooted, directly-usable artwork URL for the SPA's <img src>. Blazor pages rely on
// GetPosterUrl to prefix "artwork/posters/" and resolve relative to <base href="/">, but the SPA
// renders the value raw from under /app/, so the API must root the URL itself (issue #180).
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 (string.IsNullOrWhiteSpace(artwork))
{
return string.Empty;
}
// Absolute URLs are already usable as-is (matches Blazor's GetPosterUrl guard).
if (artwork.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
artwork.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
return artwork;
}
string folder = artworkKind is ArtworkKind.Thumbnail ? "thumbnails" : "posters";
if (artwork.StartsWith("jellyfin://", StringComparison.OrdinalIgnoreCase))
{
Url url = JellyfinUrl.RelativeProxyForArtwork(artwork);
if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail)
{
url.SetQueryParam("fillHeight", 440);
}
return $"/artwork/{folder}/{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 $"/artwork/{folder}/{url}";
}
return $"/artwork/{folder}/{artwork}";
}
private static string EscapeLike(string searchQuery) =>
searchQuery
.Replace("\\", "\\\\", StringComparison.Ordinal)
@@ -0,0 +1,7 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.LibraryBrowse;
namespace ErsatzTV.Application.MediaCollections;
public record GetCollectionItems(int Id, int PageNum, int PageSize)
: IRequest<Either<BaseError, PagedLibraryBrowseItemsResponseModel>>;
@@ -0,0 +1,56 @@
using ErsatzTV.Application.LibraryBrowse;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections;
public class GetCollectionItemsHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetCollectionItems, Either<BaseError, PagedLibraryBrowseItemsResponseModel>>
{
public async Task<Either<BaseError, PagedLibraryBrowseItemsResponseModel>> Handle(
GetCollectionItems request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
bool exists = await dbContext.Collections
.AsNoTracking()
.AnyAsync(c => c.Id == request.Id, cancellationToken);
if (!exists)
{
return new NotFoundError($"Collection {request.Id} does not exist.");
}
// The collection graph is bounded, so load every member id and hydrate them in one shared
// pass (LibraryBrowseItemMapper), then order + page in-memory. Mixed media kinds are supported
// because MediaItem ids are globally unique across kinds.
List<int> mediaItemIds = await dbContext.CollectionItems
.AsNoTracking()
.Where(ci => ci.CollectionId == request.Id)
.Select(ci => ci.MediaItemId)
.ToListAsync(cancellationToken);
List<LibraryBrowseItemResponseModel> all =
await LibraryBrowseItemMapper.HydrateMediaItemsByIds(dbContext, mediaItemIds, cancellationToken);
// Stable title ordering mirrors the library-browse handler (which orders its rows by name),
// giving the SPA a deterministic, browsable list independent of collection insertion order.
List<LibraryBrowseItemResponseModel> ordered = all
.OrderBy(i => i.Title, StringComparer.OrdinalIgnoreCase)
.ThenBy(i => i.Id)
.ToList();
int pageNum = Math.Max(0, request.PageNum);
int pageSize = Math.Clamp(request.PageSize, 1, 100);
List<LibraryBrowseItemResponseModel> page = ordered
.Skip(pageNum * pageSize)
.Take(pageSize)
.ToList();
return new PagedLibraryBrowseItemsResponseModel(ordered.Count, page);
}
}
@@ -2,8 +2,11 @@ using ErsatzTV.Application;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.Search;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using ErsatzTV.Tests.Support;
using NSubstitute;
@@ -130,6 +133,93 @@ public class CollectionHandlerTests : MediaCollectionHandlerTestBase
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
[Test]
public async Task GetItems_Should_Return_NotFoundError_When_Collection_Missing()
{
var handler = new GetCollectionItemsHandler(Db.Factory);
Either<BaseError, PagedLibraryBrowseItemsResponseModel> result =
await handler.Handle(new GetCollectionItems(999, 0, 100), CancellationToken.None);
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
[Test]
public async Task GetItems_Should_Return_Members_With_Rooted_Artwork()
{
await SeedCollectionWithMovie(collectionId: 1, movieId: 10, title: "Fake Movie", poster: "movie.jpg");
var handler = new GetCollectionItemsHandler(Db.Factory);
Either<BaseError, PagedLibraryBrowseItemsResponseModel> result =
await handler.Handle(new GetCollectionItems(1, 0, 100), CancellationToken.None);
PagedLibraryBrowseItemsResponseModel page = result.Match(
Left: _ => throw new AssertionException("Expected a Right result"),
Right: value => value);
page.TotalCount.ShouldBe(1);
page.Page.Count.ShouldBe(1);
page.Page[0].Title.ShouldBe("Fake Movie");
page.Page[0].MediaItemId.ShouldBe(10);
page.Page[0].MediaType.ShouldBe(LibraryBrowseMediaType.Movie);
page.Page[0].Artwork.ShouldBe("/artwork/posters/movie.jpg");
}
private async Task SeedCollectionWithMovie(int collectionId, int movieId, string title, string poster)
{
await using TvContext context = Db.CreateContext();
var library = new LocalLibrary
{
Id = collectionId,
Name = "Local",
MediaKind = LibraryMediaKind.Movies,
Paths = []
};
var path = new LibraryPath
{
Id = collectionId,
Path = "/media",
Library = library,
LibraryFolders = [],
MediaItems = []
};
library.Paths.Add(path);
var collection = new Collection { Id = collectionId, Name = "Collection", MediaItems = [] };
var movie = new Movie
{
Id = movieId,
LibraryPath = path,
Collections = [collection],
CollectionItems = [],
TraktListItems = [],
MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(42) }],
MovieMetadata =
[
new MovieMetadata
{
Title = title,
SortTitle = title,
Artwork = [new Artwork { Path = poster, ArtworkKind = ArtworkKind.Poster }],
Genres = [],
Tags = [],
Studios = [],
Actors = [],
Guids = [],
Subtitles = [],
Directors = [],
Writers = []
}
]
};
collection.MediaItems.Add(movie);
context.LocalLibraries.Add(library);
context.Collections.Add(collection);
context.Movies.Add(movie);
await context.SaveChangesAsync();
}
private static AddItemsToCollection MakeAddItems(int collectionId, List<int>? movieIds = null) =>
new(
collectionId,
@@ -3,6 +3,7 @@ using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using LanguageExt;
@@ -35,6 +36,7 @@ public class CollectionControllerTests
{
ShouldHaveActionRoute(nameof(CollectionController.GetAll), "GET", "/api/collections");
ShouldHaveActionRoute(nameof(CollectionController.GetById), "GET", "/api/collections/{id:int}");
ShouldHaveActionRoute(nameof(CollectionController.GetItems), "GET", "/api/collections/{id:int}/items");
ShouldHaveActionRoute(nameof(CollectionController.Create), "POST", "/api/collections");
ShouldHaveActionRoute(nameof(CollectionController.Update), "PUT", "/api/collections/{id:int}");
ShouldHaveActionRoute(nameof(CollectionController.Delete), "DELETE", "/api/collections/{id:int}");
@@ -238,6 +240,33 @@ public class CollectionControllerTests
problemDetails.Title.ShouldBe("Resource not found");
}
[Test]
public async Task GetItems_Should_Return_200_With_Page_And_Clamp_Paging()
{
var page = new PagedLibraryBrowseItemsResponseModel(0, []);
_mediator.Send(Arg.Any<GetCollectionItems>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, PagedLibraryBrowseItemsResponseModel>(page));
IActionResult result = await _controller.GetItems(7, -5, 999, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(page);
await _mediator.Received(1).Send(
Arg.Is<GetCollectionItems>(q => q.Id == 7 && q.PageNum == 0 && q.PageSize == 100),
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetItems_Should_Return_404_For_NotFoundError()
{
_mediator.Send(Arg.Any<GetCollectionItems>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, PagedLibraryBrowseItemsResponseModel>(new NotFoundError("missing")));
IActionResult result = await _controller.GetItems(99, 0, 100, CancellationToken.None);
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(404);
}
private static MediaCollectionViewModel MakeVm(int id, string name) =>
new(CollectionType.Collection, id, name, false, MediaItemState.Normal);
@@ -126,6 +126,7 @@ public class OpenApiErrorResponseContractTests
[TestCase("/api/channel-templates/{id}", "delete", "404")]
[TestCase("/api/channel-templates/{id}", "delete", "422")]
[TestCase("/api/collections/{id}", "get", "404")]
[TestCase("/api/collections/{id}/items", "get", "404")]
[TestCase("/api/collections", "post", "404")]
[TestCase("/api/collections", "post", "422")]
[TestCase("/api/collections/{id}", "put", "404")]
@@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
@@ -32,6 +33,28 @@ public class CollectionController(IMediator mediator) : ControllerBase
return result.ToGetResult();
}
[HttpGet("/api/collections/{id:int}/items", Name = "GetCollectionItems")]
[Tags("Collections")]
[EndpointSummary("Get the items in a manual collection")]
[EndpointDescription("Returns a manual collection's full contents (all media kinds), paged.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(PagedLibraryBrowseItemsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetItems(
int id,
[FromQuery] int pageNum = 0,
[FromQuery] int pageSize = 100,
CancellationToken cancellationToken = default)
{
int clampedPageNum = Math.Max(0, pageNum);
int clampedPageSize = Math.Clamp(pageSize, 1, 100);
Either<BaseError, PagedLibraryBrowseItemsResponseModel> result = await mediator.Send(
new GetCollectionItems(id, clampedPageNum, clampedPageSize),
cancellationToken);
return result.ToUpdatedResult();
}
[HttpPost("/api/collections")]
[Tags("Collections")]
[EndpointSummary("Create a collection")]
+79
View File
@@ -2626,6 +2626,85 @@
}
},
"/api/collections/{id}/items": {
"get": {
"tags": [
"Collections"
],
"summary": "Get the items in a manual collection",
"description": "Returns a manual collection's full contents (all media kinds), paged.",
"operationId": "GetCollectionItems",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
},
{
"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"
}
}
}
},
"404": {
"description": "Not Found",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
},
"post": {
"tags": [
"Collections"
+9 -5
View File
@@ -16,11 +16,14 @@ Read in this order at session start:
5. **`docs/spa-conventions.md`** — playbook for adding a screen to the ChicoryTV React SPA.
6. **`docs/e2e-local.md`** (+ `scripts/e2e-local.sh`) — how to run a live local instance for manual
or Playwright-MCP verification.
7. **`docs/blazor-route-parity.md`** — the #91 phase (b) tracker: which Blazor routes are
7. **`docs/testing.md`** — testing map: what each `*.Tests` project / `web` suite covers,
golden-file nets, the timezone-independence rule, how to run subsets, the per-PR verification
gate.
8. **`docs/blazor-route-parity.md`** — the #91 phase (b) tracker: which Blazor routes are
redirected, SPA-ready-but-not-redirected, or still Blazor-only (and which issue blocks each).
8. **`docs/decisions.md`** — append-only "why" log. Check here before challenging an existing
9. **`docs/decisions.md`** — append-only "why" log. Check here before challenging an existing
convention.
9. **`docs/ci-cd.md`** — build/test/release pipeline, versioning, dependency management.
10. **`docs/ci-cd.md`** — build/test/release pipeline, versioning, dependency management.
Also present in `docs/`:
@@ -30,8 +33,9 @@ Also present in `docs/`:
- **`docs/m3u-xmltv.md`** — M3U/XMLTV generation overview (`ChannelPlaylist`, `GetChannelGuideHandler`).
- **`docs/fork-strategy.md`** — divergence policy vs upstream ErsatzTV.
- **`docs/design-sync.md`** — Claude Design ↔ repo screen workflow (#92).
- **`docs/endpoint-index.md`** — generated REST endpoint index (method/path/operationId/summary per
OpenAPI tag). Do not edit by hand; regenerated by `scripts/generate-endpoint-index.py` /
`scripts/update-openapi.sh`.
- **`docs/handoffs/chicorytv-issue-queue.md`** — living session-to-session handoff: current queue
state, what's next. Check this for what's actively in flight before starting new work.
- **`docs/handoffs/rest-api.md`** — original handoff prompt for kicking off the REST API work (#2).
Still to come (tracked under #185): a testing map and a generated-endpoint index.
+8 -2
View File
@@ -100,13 +100,19 @@ been added to the redirect map yet.
## Section 3 — BLAZOR-ONLY (blocking issues)
### Multi/rerun collections & playlist variants — API gaps #151/#152/#153/#155
### Multi/rerun collections & playlist variants — API gaps #151/#152/#153
| Blazor route | File | Blocking issue |
|---|---|---|
| `/media/multi-collections`(`/add`, `/{Id}/edit`) | `MultiCollections.razor`, `MultiCollectionEditor.razor` | #151 (multi-collection management API) |
| `/media/rerun-collections`(`/add`, `/{Id}/edit`) | `RerunCollections.razor`, `RerunCollectionEditor.razor` | #152 (rerun-collection management API) |
| `/media/playlists`(`/{Id}`) editing depth beyond what `/app/collections` covers | `Playlists.razor`, `PlaylistEditor.razor` | #153/#155 (playlist variant management API + collection-items editing depth) |
| `/media/playlists`(`/{Id}`) editing depth beyond what `/app/collections` covers | `Playlists.razor`, `PlaylistEditor.razor` | #153 (playlist variant management API) |
**#155 RESOLVED** (collection-items enumeration): `GET /api/collections/{id}/items` (paged) now returns a
manual collection's full contents across all media kinds (reusing `LibraryBrowseItemResponseModel`), so the
SPA `/app/collections` items view lists real members instead of the old lossy Lucene `collection:"name"`
search preview. The `POST /api/collections/{id}/items` bogus-id case already returns 422 (guarded by
`AddItemsToCollectionHandler.ValidateMediaItems`), not 500.
### Playback troubleshooting — #145
+5 -12
View File
@@ -111,18 +111,11 @@ exceptions for control flow.
## 8. Testing
- **NUnit** (`[TestFixture]`/`[Test]`/`[TestCase]`) + **Shouldly** (`.ShouldBe(...)`) + **NSubstitute**
+ Testably.Abstractions for a fake filesystem. **xUnit is not used.** Tests live in `*.Tests`
projects mirroring the source.
- Established kinds: **FFmpeg command-string assertions** (build a pipeline → assert the exact arg
string — `ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs`); **golden-file tests** for
Jellyfin-facing output (`ErsatzTV.Core.Tests/Iptv/ChannelPlaylistGoldenTests.cs`, regen via
`ETV_UPDATE_GOLDENS=1`, ersatztv#11); **architecture tests** (§1, ersatztv#12).
- The suite is **timezone-independent** — the previously timezone-sensitive `DateTimeOffset`
filler-scheduling tests were fixed (ersatztv#24) by giving fixtures realistic times instead of the
default `DateTime.MinValue`, which underflowed `DateTimeOffset.MinValue` under a non-UTC offset.
When constructing test `PlayoutItem`s, set a real `Start` (e.g. `startState.CurrentTime.UtcDateTime`),
not the default. CI runs UTC and uses `dotnet test … --blame-hang-timeout 2m`.
**NUnit** (`[TestFixture]`/`[Test]`/`[TestCase]`) + **Shouldly** + **NSubstitute**; xUnit is not
used. Golden-file tests guard the M3U/XMLTV output formats and a hard fail on a missing/changed
golden means broken code, not a stale fixture — never regenerate via `ETV_UPDATE_GOLDENS=1` in CI
or from an agent. Full testing map (per-project coverage, golden-file details, timezone notes,
how to run subsets, the per-PR gate): **`docs/testing.md`**.
## 9. Build / CI
+351
View File
@@ -0,0 +1,351 @@
# API endpoint index
*Generated by `scripts/generate-endpoint-index.py` from `ErsatzTV/wwwroot/openapi/v1.json`. Do not edit by hand -- regenerated by `scripts/update-openapi.sh`.*
114 endpoints, 176 operations.
## Artists
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/artists/{id}` | GetArtistById | Get an artist by id |
## Artwork
| Method | Path | Operation | Summary |
|---|---|---|---|
| POST | `/api/artwork/uploads` | UploadArtwork | Upload channel logo or watermark artwork |
## Blocks
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/blocks` | | Get all blocks |
| POST | `/api/blocks` | | Create a block |
| GET | `/api/blocks/groups` | GetBlockGroups | Get all block groups |
| POST | `/api/blocks/groups` | | Create a block group |
| DELETE | `/api/blocks/groups/{id}` | | Delete a block group |
| DELETE | `/api/blocks/{id}` | | Delete a block |
| GET | `/api/blocks/{id}` | GetBlockById | Get a block by id |
| PUT | `/api/blocks/{id}` | | Replace a block and its items |
| POST | `/api/blocks/{id}/copy` | | Copy a block |
| GET | `/api/blocks/{id}/items` | | Get block items |
| POST | `/api/blocks/{id}/preview` | | Preview a block playout |
## Channel
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/channels` | | |
## Channel Templates
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/channel-templates` | GetChannelTemplates | Get all channel templates |
| POST | `/api/channel-templates` | CreateChannelTemplate | Create a channel template |
| GET | `/api/channel-templates/default` | GetDefaultChannelTemplate | Get the default channel template |
| PUT | `/api/channel-templates/default/{id}` | SetDefaultChannelTemplate | Set the default channel template |
| DELETE | `/api/channel-templates/{id}` | DeleteChannelTemplate | Delete a channel template |
| GET | `/api/channel-templates/{id}` | GetChannelTemplateById | Get a channel template by id |
| PUT | `/api/channel-templates/{id}` | UpdateChannelTemplate | Update a channel template |
## Channels
| Method | Path | Operation | Summary |
|---|---|---|---|
| POST | `/api/channels` | | Create a channel |
| POST | `/api/channels/bulk/delete` | | Delete channels |
| POST | `/api/channels/bulk/group` | | Move channels to a group |
| POST | `/api/channels/bulk/renumber` | | Renumber channels |
| POST | `/api/channels/from-lineup` | CreateChannelFromLineup | Create a channel from a library lineup |
| GET | `/api/channels/state` | | Get channel runtime state |
| POST | `/api/channels/{channelNumber}/playout/reset` | | Reset channel playout |
| DELETE | `/api/channels/{id}` | | Delete a channel |
| GET | `/api/channels/{id}` | GetChannelById | Get a channel by id |
| PUT | `/api/channels/{id}` | | Update a channel |
| GET | `/api/guide` | | Get the JSON channel guide (EPG) |
## Collections
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/collections` | | Get all collections |
| POST | `/api/collections` | | Create a collection |
| DELETE | `/api/collections/{id}` | | Delete a collection |
| GET | `/api/collections/{id}` | GetCollectionById | Get a collection by id |
| PUT | `/api/collections/{id}` | | Update a collection |
| GET | `/api/collections/{id}/items` | GetCollectionItems | Get the items in a manual collection |
| POST | `/api/collections/{id}/items` | | Add items to a collection |
| DELETE | `/api/collections/{id}/items/{mediaItemId}` | | Remove an item from a collection |
## DecoTemplates
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/deco-templates` | | Get all deco templates |
| POST | `/api/deco-templates` | | Create a deco template |
| GET | `/api/deco-templates/groups` | GetDecoTemplateGroups | Get all deco template groups |
| POST | `/api/deco-templates/groups` | | Create a deco template group |
| DELETE | `/api/deco-templates/groups/{id}` | | Delete a deco template group |
| DELETE | `/api/deco-templates/{id}` | | Delete a deco template |
| GET | `/api/deco-templates/{id}` | GetDecoTemplateById | Get a deco template by id |
| PUT | `/api/deco-templates/{id}` | | Replace a deco template and its items |
| GET | `/api/deco-templates/{id}/items` | | Get deco template items |
## Decos
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/decos` | | Get all decos |
| POST | `/api/decos` | | Create a deco |
| GET | `/api/decos/groups` | GetDecoGroups | Get all deco groups |
| POST | `/api/decos/groups` | | Create a deco group |
| DELETE | `/api/decos/groups/{id}` | | Delete a deco group |
| DELETE | `/api/decos/{id}` | | Delete a deco |
| GET | `/api/decos/{id}` | GetDecoById | Get a deco by id |
| PUT | `/api/decos/{id}` | | Replace a deco |
## FFmpeg Profiles
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/ffmpeg/hardware-acceleration-kinds` | GetSupportedHardwareAccelerationKinds | Get supported hardware acceleration kinds |
| GET | `/api/ffmpeg/profiles` | GetFFmpegProfiles | Get all FFmpeg profiles |
| POST | `/api/ffmpeg/profiles` | CreateFFmpegProfile | Create an FFmpeg profile |
| DELETE | `/api/ffmpeg/profiles/{id}` | DeleteFFmpegProfile | Delete an FFmpeg profile |
| GET | `/api/ffmpeg/profiles/{id}` | GetFFmpegProfileById | Get an FFmpeg profile by id |
| PUT | `/api/ffmpeg/profiles/{id}` | UpdateFFmpegProfile | Update an FFmpeg profile |
## Filler Presets
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/filler-presets` | GetFillerPresets | Get all filler presets |
| POST | `/api/filler-presets` | CreateFillerPreset | Create a filler preset |
| DELETE | `/api/filler-presets/{id}` | DeleteFillerPreset | Delete a filler preset |
| GET | `/api/filler-presets/{id}` | GetFillerPresetById | Get a filler preset by id |
| PUT | `/api/filler-presets/{id}` | UpdateFillerPreset | Update a filler preset |
## Graphics Elements
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/graphics-elements` | GetGraphicsElements | Get all graphics elements |
## Health
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/health` | GetHealthChecks | Get health check results |
## Images
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/images/folders` | GetImageFolders | List image library folders |
| PUT | `/api/images/folders/{id}/duration` | UpdateImageFolderDuration | Set or clear an image folder's playout duration |
## Libraries
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/libraries/scan-status` | GetLibraryScanStatus | Get active library scan status |
| POST | `/api/libraries/{id}/scan` | | Scan library |
| POST | `/api/libraries/{id}/scan-show` | | Scan show |
| GET | `/api/library/browse` | BrowseLibrary | Browse and search library items |
## Logs
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/logs` | GetLogs | Get recent log entries |
## Maintenance
| Method | Path | Operation | Summary |
|---|---|---|---|
| POST | `/api/maintenance/clean_artwork` | | Clean artwork cache |
| POST | `/api/maintenance/empty_trash` | | Empty trash |
| GET | `/api/maintenance/gc` | | Garbage collect |
## Media Items
| Method | Path | Operation | Summary |
|---|---|---|---|
| DELETE | `/api/media-items` | DeleteMediaItems | Delete media items from the database |
| GET | `/api/media-items/{id}/info` | GetMediaItemInfo | Get technical media info for a media item |
## Media Sources
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/media-sources` | GetMediaSources | Get all media sources with their libraries |
## Movies
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/movies/{id}` | GetMovieById | Get a movie by id |
## Playlists
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/playlists` | GetPlaylists | Get playlists in a playlist group |
| GET | `/api/playlists/groups` | GetPlaylistGroups | Get all playlist groups |
## Playouts
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/playouts` | GetPlayouts | List playouts |
| POST | `/api/playouts` | | Create a playout |
| GET | `/api/playouts/history/{id}` | GetPlayoutHistoryDetails | Decode a playout history row |
| POST | `/api/playouts/reset-all` | ResetAllPlayouts | Reset all playouts |
| GET | `/api/playouts/warnings/count` | GetPlayoutWarningsCount | Count playouts with a failed build |
| DELETE | `/api/playouts/{id}` | | Delete a playout |
| GET | `/api/playouts/{id}` | GetPlayoutById | Get a playout by id |
| PUT | `/api/playouts/{id}` | | Update playout scheduling details |
| GET | `/api/playouts/{id}/alternate-schedules` | GetPlayoutAlternateSchedules | Get a classic playout's alternate schedules |
| PUT | `/api/playouts/{id}/alternate-schedules` | | Replace a classic playout's alternate schedules |
| GET | `/api/playouts/{id}/blocks` | GetPlayoutBlocks | Get the blocks scheduled by a block playout |
| GET | `/api/playouts/{id}/blocks/{blockId}/history` | GetPlayoutBlockHistory | Get a block's playout history |
| PUT | `/api/playouts/{id}/deco` | | Set (or clear) a playout's default deco |
| GET | `/api/playouts/{id}/items` | GetPlayoutItems | Get upcoming items (and unscheduled gaps) for a playout |
| GET | `/api/playouts/{id}/templates` | GetPlayoutTemplates | Get a block playout's templates |
| PUT | `/api/playouts/{id}/templates` | | Replace a block playout's templates |
## Resolution
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/ffmpeg/resolution/by-name/{name}` | GetResolutionByName | |
## Schedules
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/schedules` | | Get all schedules |
| POST | `/api/schedules` | | Create a schedule |
| DELETE | `/api/schedules/{id}` | | Delete a schedule |
| GET | `/api/schedules/{id}` | GetScheduleById | Get a schedule by id |
| PUT | `/api/schedules/{id}` | | Update a schedule |
| GET | `/api/schedules/{id}/items` | | Get schedule items |
| POST | `/api/schedules/{id}/items` | | Add a schedule item |
| PUT | `/api/schedules/{id}/items` | | Replace schedule items |
| DELETE | `/api/schedules/{id}/items/{itemId}` | | Delete a schedule item |
## Search
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/search` | Search | Search library items across all media kinds |
| GET | `/api/search/artists` | SearchArtists | Search artists by name |
| GET | `/api/search/collections` | SearchCollections | Search collections by name |
| GET | `/api/search/multi-collections` | SearchMultiCollections | Search multi collections by name |
| GET | `/api/search/smart-collections` | SearchSmartCollections | Search smart collections by name |
| GET | `/api/search/television-seasons` | SearchTelevisionSeasons | Search television seasons by name |
| GET | `/api/search/television-shows` | SearchTelevisionShows | Search television shows by name |
## Sessions
| Method | Path | Operation | Summary |
|---|---|---|---|
| DELETE | `/api/session/{channelNumber}` | | Stop session |
| GET | `/api/sessions` | | Get sessions |
## Settings
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/settings/ffmpeg` | GetFfmpegSettings | Get global FFmpeg settings |
| PUT | `/api/settings/ffmpeg` | UpdateFfmpegSettings | Update global FFmpeg settings |
| GET | `/api/settings/hdhr` | GetHdhrSettings | Get HDHomeRun emulation settings |
| PUT | `/api/settings/hdhr` | UpdateHdhrSettings | Update HDHomeRun emulation settings |
| GET | `/api/settings/logging` | GetLoggingSettings | Get per-area minimum log levels |
| PUT | `/api/settings/logging` | UpdateLoggingSettings | Update per-area minimum log levels |
| GET | `/api/settings/playout` | GetPlayoutSettings | Get global playout settings |
| PUT | `/api/settings/playout` | UpdatePlayoutSettings | Update global playout settings |
| GET | `/api/settings/resolutions` | GetResolutions | Get all resolutions, including custom resolutions |
| POST | `/api/settings/resolutions` | CreateResolution | Create a custom resolution |
| DELETE | `/api/settings/resolutions/{id}` | DeleteResolution | Delete a custom resolution |
| GET | `/api/settings/scanner` | GetScannerSettings | Get library scan cadence |
| PUT | `/api/settings/scanner` | UpdateScannerSettings | Update library scan cadence |
| GET | `/api/settings/ui` | GetUiSettings | Get UI preferences |
| PUT | `/api/settings/ui` | UpdateUiSettings | Update UI preferences |
| GET | `/api/settings/xmltv` | GetXmltvSettings | Get global XMLTV settings |
| PUT | `/api/settings/xmltv` | UpdateXmltvSettings | Update global XMLTV settings |
## Smart Collections
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/smart-collections` | | Get all smart collections |
| POST | `/api/smart-collections` | | Create a smart collection |
| DELETE | `/api/smart-collections/{id}` | | Delete a smart collection |
| GET | `/api/smart-collections/{id}` | GetSmartCollectionById | Get a smart collection by id |
| PUT | `/api/smart-collections/{id}` | | Update a smart collection |
## Television
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/seasons/{id}` | GetSeasonById | Get a television season by id |
| GET | `/api/shows/{id}` | GetShowById | Get a television show by id |
## Templates
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/templates` | | Get all templates |
| POST | `/api/templates` | | Create a template |
| GET | `/api/templates/groups` | GetTemplateGroups | Get all template groups |
| POST | `/api/templates/groups` | | Create a template group |
| DELETE | `/api/templates/groups/{id}` | | Delete a template group |
| DELETE | `/api/templates/{id}` | | Delete a template |
| GET | `/api/templates/{id}` | GetTemplateById | Get a template by id |
| PUT | `/api/templates/{id}` | | Replace a template and its items |
| POST | `/api/templates/{id}/copy` | | Copy a template |
| GET | `/api/templates/{id}/items` | | Get template items |
## Trakt
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/trakt/lists` | GetTraktLists | Get paged Trakt lists |
| POST | `/api/trakt/lists` | | Add a Trakt list by URL |
| DELETE | `/api/trakt/lists/{id}` | | Delete a Trakt list |
| GET | `/api/trakt/lists/{id}` | GetTraktListById | Get a Trakt list by id |
| PUT | `/api/trakt/lists/{id}` | | Update a Trakt list's settings |
| POST | `/api/trakt/lists/{id}/match` | | Match a Trakt list's items |
| GET | `/api/trakt/status` | GetTraktStatus | Get Trakt background operation status |
## Troubleshooting
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/troubleshoot/info` | GetTroubleshootingInfo | Get troubleshooting diagnostic info |
| GET | `/api/troubleshoot/playback.m3u8` | | Start a troubleshooting playback session |
| HEAD | `/api/troubleshoot/playback.m3u8` | | Start a troubleshooting playback session |
| GET | `/api/troubleshoot/playback/archive` | | Download the last troubleshooting playback session archive |
| HEAD | `/api/troubleshoot/playback/archive` | | Download the last troubleshooting playback session archive |
| GET | `/api/troubleshoot/playback/sample/{mediaItemId}` | | Download a media sample archive for troubleshooting |
| HEAD | `/api/troubleshoot/playback/sample/{mediaItemId}` | | Download a media sample archive for troubleshooting |
| POST | `/api/troubleshoot/validate-schedule` | ValidateSequentialSchedule | Validate a sequential schedule YAML document |
## Version
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/version` | GetVersion | Get version |
## Watermarks
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/watermarks` | GetWatermarks | Get all watermarks |
| POST | `/api/watermarks` | CreateWatermark | Create a watermark |
| DELETE | `/api/watermarks/{id}` | DeleteWatermark | Delete a watermark |
| GET | `/api/watermarks/{id}` | GetWatermarkById | Get a watermark by id |
| PUT | `/api/watermarks/{id}` | UpdateWatermark | Update a watermark |
+79
View File
@@ -0,0 +1,79 @@
# Testing map
Purpose: authoritative map of what each test project/suite covers and how to run it. Read this
before adding tests, not just `docs/contributing.md` §8 (which now just points here).
## Test projects
| Project | Covers | Notes |
|---|---|---|
| `ErsatzTV.Tests` | API controllers + MediatR handlers | In-memory SQLite fixture: a shared `SqliteConnection("Data Source=:memory:;Foreign Keys=False")` kept open + `EnsureCreatedAsync()` (**not** full migration replay) + `PRAGMA foreign_keys=OFF`, then seed; a tiny `IDbContextFactory` wraps `new TvContext(...)`. 828 tests currently. |
| `ErsatzTV.Core.Tests` | Domain logic, scheduling, IPTV/XMLTV generation | References `ErsatzTV.Application` directly — there is no separate `Application.Tests` project. 493 tests + 1 skipped. |
| `ErsatzTV.Architecture.Tests` | Layering rules via NetArchTest.eNhancedEdition | Core↛Infra/App/EF; FFmpeg↛all; App↛concrete providers. 5 tests. See `docs/contributing.md` §1. |
| `ErsatzTV.FFmpeg.Tests` | FFmpeg command construction | Build a pipeline, assert the exact rendered arg string (`PipelineBuilderBaseTests.cs`). |
| `web/` (vitest) | React SPA unit tests | 330 tests; run alongside typecheck + build (see below). |
## Golden-file nets
Two golden-file suites guard the Jellyfin-facing output formats:
- **M3U**: `ErsatzTV.Core.Tests/Iptv/ChannelPlaylistGoldenTests.cs` (ersatztv#11)
- **XMLTV**: `ChannelGuideGoldenTests` (ersatztv#28)
Both locate their golden files via `[CallerFilePath]`. A missing golden is a hard fail, not a
skip. Regenerate via `ETV_UPDATE_GOLDENS=1 dotnet test ...`.
**Never set `ETV_UPDATE_GOLDENS` in CI or from an agent.** A golden diff during normal test runs
means the code broke the output format — regenerating to make the diff go away hides the bug
instead of fixing it. Only a human who has confirmed the format change is intentional should
regenerate.
## Timezone independence
The suite is timezone-independent (ersatztv#24). When constructing test `PlayoutItem`s, always
set a real `Start` (e.g. `startState.CurrentTime.UtcDateTime`) — never rely on the default
`DateTime.MinValue`, which underflows `DateTimeOffset.MinValue` once a non-UTC local offset is
applied (`StartOffset` calls `ToLocalTime()`). CI runs in UTC; local runs may not.
## Running tests
Full .NET gate:
```bash
dotnet build ErsatzTV.sln
TZ=UTC dotnet test
```
CI adds `--blame-hang-timeout 2m` to catch hangs.
Fast subsets:
```bash
# single project
dotnet test ErsatzTV.Tests
# filtered
dotnet test ErsatzTV.Core.Tests --filter FullyQualifiedName~ChannelPlaylistGoldenTests
```
Web (`web/`):
```bash
npm test # vitest
npm run typecheck # tsc -b --pretty false
npm run lint # eslint .
npm run build # tsc -b && vite build
```
## Per-PR verification gate
Before opening a PR: build the solution, run both .NET test projects (plus
`ErsatzTV.Architecture.Tests` and `ErsatzTV.FFmpeg.Tests` if touched), and run the web test/lint/
typecheck/build steps above. All must be green. A golden-file diff or an architecture-test
failure is a hard stop — fix the code, don't regenerate/relax the test.
## See also
- `docs/ci-cd.md` — CI pipeline (test → migrations → build), versioning, dependency management.
- `docs/contributing.md` §1 — layering rules enforced by `ErsatzTV.Architecture.Tests`.
- `docs/contributing.md` §8 — short pointer back to this doc.
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Generate docs/endpoint-index.md from the OpenAPI spec.
Do not edit docs/endpoint-index.md by hand -- it is generated by this script
from ErsatzTV/wwwroot/openapi/v1.json. Regenerated automatically as part of
scripts/update-openapi.sh.
"""
import json
from pathlib import Path
HTTP_METHODS = ("get", "put", "post", "delete", "options", "head", "patch", "trace")
REPO_ROOT = Path(__file__).resolve().parent.parent
SPEC_PATH = REPO_ROOT / "ErsatzTV" / "wwwroot" / "openapi" / "v1.json"
OUTPUT_PATH = REPO_ROOT / "docs" / "endpoint-index.md"
UNTAGGED = "(untagged)"
def load_operations(spec: dict) -> list[dict]:
"""Flatten the OpenAPI paths object into a list of per-operation records."""
operations = []
for path, path_item in spec.get("paths", {}).items():
for method, operation in path_item.items():
if method.lower() not in HTTP_METHODS:
continue
operations.append(
{
"path": path,
"method": method.upper(),
"operationId": operation.get("operationId", ""),
"summary": operation.get("summary", "") or "",
"tags": operation.get("tags") or [UNTAGGED],
}
)
return operations
def group_by_tag(operations: list[dict]) -> dict[str, list[dict]]:
grouped: dict[str, list[dict]] = {}
for op in operations:
for tag in op["tags"]:
grouped.setdefault(tag, []).append(op)
return grouped
def render(spec: dict) -> str:
operations = load_operations(spec)
grouped = group_by_tag(operations)
tags = sorted(t for t in grouped if t != UNTAGGED)
if UNTAGGED in grouped:
tags.append(UNTAGGED)
lines = []
lines.append("# API endpoint index")
lines.append("")
lines.append(
"*Generated by `scripts/generate-endpoint-index.py` from "
"`ErsatzTV/wwwroot/openapi/v1.json`. Do not edit by hand -- regenerated by "
"`scripts/update-openapi.sh`.*"
)
lines.append("")
lines.append(
f"{len(spec.get('paths', {}))} endpoints, {len(operations)} operations."
)
lines.append("")
for tag in tags:
lines.append(f"## {tag}")
lines.append("")
lines.append("| Method | Path | Operation | Summary |")
lines.append("|---|---|---|---|")
for op in sorted(grouped[tag], key=lambda o: (o["path"], o["method"])):
lines.append(
f"| {op['method']} | `{op['path']}` | {op['operationId']} | {op['summary']} |"
)
lines.append("")
return "\n".join(lines).rstrip("\n") + "\n"
def main() -> None:
spec = json.loads(SPEC_PATH.read_text())
OUTPUT_PATH.write_text(render(spec))
operations = load_operations(spec)
print(
f"Wrote {OUTPUT_PATH.relative_to(REPO_ROOT)}: "
f"{len(spec.get('paths', {}))} endpoints, {len(operations)} operations."
)
if __name__ == "__main__":
main()
+8 -1
View File
@@ -1,4 +1,11 @@
#! /usr/bin/env bash
cd "$(git rev-parse --show-toplevel)" || exit
cd ErsatzTV && dotnet build -t:GenerateOpenApiDocuments
REPO_ROOT="$(pwd)"
# Only regenerate the endpoint index if the spec build succeeded, so a failed
# build can't render docs/endpoint-index.md from a stale/partial v1.json.
(cd ErsatzTV && dotnet build -t:GenerateOpenApiDocuments) || exit
cd "$REPO_ROOT" || exit
python3 scripts/generate-endpoint-index.py
+20 -12
View File
@@ -6,7 +6,7 @@ import {
deleteCollection,
deleteSmartCollection,
emptyAddItemsRequest,
getCollectionItemsPreview,
getCollectionItems,
getCollections,
getSmartCollections,
removeItemFromCollection,
@@ -158,23 +158,31 @@ describe('collections api client', () => {
expect(JSON.parse(String(createCall?.[1]?.body))).toEqual({ name: 'Sci-Fi', query: 'genre:scifi' });
});
it('getCollectionItemsPreview issues a quoted collection: Lucene query', async () => {
it('getCollectionItems requests the paged collection-items endpoint', async () => {
const fetchMock = vi
.spyOn(window, 'fetch')
.mockResolvedValue(jsonResponse({ totalCount: 1, page: [browseItem(5, 'Movie')] }));
const result = await getCollectionItems(7, 2, 50);
const url = new URL(String(fetchMock.mock.calls[0][0]), 'http://localhost');
expect(url.pathname).toBe('/api/collections/7/items');
expect(url.searchParams.get('pageNum')).toBe('2');
expect(url.searchParams.get('pageSize')).toBe('50');
expect(result.totalCount).toBe(1);
});
it('getCollectionItems defaults to the first page of 100', async () => {
const fetchMock = vi
.spyOn(window, 'fetch')
.mockResolvedValue(jsonResponse({ totalCount: 0, page: [] }));
await getCollectionItemsPreview('The Office');
await getCollectionItems(9);
const url = new URL(String(fetchMock.mock.calls[0][0]), 'http://localhost');
expect(url.pathname).toBe('/api/library/browse');
expect(url.searchParams.get('query')).toBe('collection:"The Office"');
});
it('getCollectionItemsPreview returns [] for a blank name without calling the API', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ totalCount: 0, page: [] }));
await expect(getCollectionItemsPreview(' ')).resolves.toEqual([]);
expect(fetchMock).not.toHaveBeenCalled();
expect(url.pathname).toBe('/api/collections/9/items');
expect(url.searchParams.get('pageNum')).toBe('0');
expect(url.searchParams.get('pageSize')).toBe('100');
});
});
+15 -17
View File
@@ -1,6 +1,6 @@
import { ApiError, request } from './client';
import type { components } from './generated/v1';
import { getLibraryBrowseItems, type LibraryBrowseItem } from './libraryBrowse';
import type { LibraryBrowseItem, PagedLibraryBrowseItems } from './libraryBrowse';
export type MediaCollection = components['schemas']['MediaCollectionViewModel'];
export type SmartCollection = components['schemas']['SmartCollectionViewModel'];
@@ -40,6 +40,20 @@ export function removeItemFromCollection(id: number, mediaItemId: number): Promi
return request<void>(`/api/collections/${id}/items/${mediaItemId}`, { method: 'DELETE' });
}
// Lists a manual collection's full contents (all media kinds), paged. Backed by
// GET /api/collections/{id}/items (#155), which reuses the library-browse item shape.
export function getCollectionItems(
id: number,
pageNum = 0,
pageSize = 100
): Promise<PagedLibraryBrowseItems> {
const params = new URLSearchParams({
pageNum: String(pageNum),
pageSize: String(pageSize)
});
return request<PagedLibraryBrowseItems>(`/api/collections/${id}/items?${params.toString()}`);
}
/* ---------- smart collections ---------- */
export function getSmartCollections(): Promise<SmartCollection[]> {
@@ -116,22 +130,6 @@ export function toAddItemsRequest(items: LibraryBrowseItem[]): AddItemsToCollect
return requestBody;
}
// Best-effort partial listing of a manual collection's members. No API returns a manual
// collection's items by id; the only path is the Lucene `collection:"name"` search field
// via library-browse, which covers Movie / Show / Season / Artist only. Callers must treat
// this as an incomplete preview, never as the authoritative contents.
export async function getCollectionItemsPreview(name: string): Promise<LibraryBrowseItem[]> {
const trimmed = name.trim();
if (!trimmed) {
return [];
}
const escaped = trimmed.replace(/"/g, '\\"');
const result = await getLibraryBrowseItems({ pageSize: 100, query: `collection:"${escaped}"` });
return result.page ?? [];
}
export function messageFromCollectionError(error: unknown, fallback = 'Unable to load collections'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
+20 -14
View File
@@ -51,6 +51,10 @@ function mockApi(options: MockOptions = {}) {
return Promise.resolve(jsonResponse({ page: [], totalCount: 0 }));
}
if (/^\/api\/collections\/\d+\/items/.test(url) && method === 'GET') {
return Promise.resolve(jsonResponse({ page: [], totalCount: 0 }));
}
return Promise.resolve(new Response(null, { status: 204 }));
});
}
@@ -237,15 +241,14 @@ describe('CollectionsScreen', () => {
});
});
it('opens a manual collection and previews its items via a collection: query', async () => {
it('opens a manual collection and lists its items via the collection-items endpoint', async () => {
const fetchMock = mockApi({
onRequest: (url) => {
if (url.startsWith('/api/library/browse')) {
const q = new URL(url, 'http://localhost').searchParams.get('query') ?? '';
if (q.startsWith('collection:')) {
return jsonResponse({ page: [{ id: 5, mediaItemId: 5, mediaType: 'Movie', title: 'Inception' }], totalCount: 1 });
}
onRequest: (url, method) => {
if (/^\/api\/collections\/1\/items/.test(url) && method === 'GET') {
return jsonResponse({
page: [{ id: 5, mediaItemId: 5, mediaType: 'Movie', title: 'Inception' }],
totalCount: 1
});
}
return null;
@@ -258,12 +261,15 @@ describe('CollectionsScreen', () => {
fireEvent.click(screen.getByText('Favorites'));
expect(await screen.findByText('Inception')).toBeInTheDocument();
expect(screen.getByText(/best-effort search preview/)).toBeInTheDocument();
// The lossy best-effort search-preview banner is gone now that a real endpoint exists.
expect(screen.queryByText(/best-effort search preview/)).not.toBeInTheDocument();
const browseCall = fetchMock.mock.calls.find(([u]) =>
u.toString().includes('collection%3A')
);
expect(new URL(String(browseCall?.[0]), 'http://localhost').searchParams.get('query')).toBe('collection:"Favorites"');
const itemsCall = fetchMock.mock.calls.find(([u]) => /\/api\/collections\/1\/items/.test(u.toString()));
expect(itemsCall).toBeDefined();
const itemsUrl = new URL(String(itemsCall?.[0]), 'http://localhost');
expect(itemsUrl.pathname).toBe('/api/collections/1/items');
expect(itemsUrl.searchParams.get('pageNum')).toBe('0');
expect(itemsUrl.searchParams.get('pageSize')).toBe('100');
});
const addItemsByType: Record<string, { id: number; mediaType: string; title: string }> = {
@@ -301,7 +307,7 @@ describe('CollectionsScreen', () => {
await screen.findByText('Favorites');
fireEvent.click(screen.getByText('Favorites'));
await screen.findByText(/best-effort search preview/);
await screen.findByRole('button', { name: 'Add items' });
fireEvent.click(screen.getByRole('button', { name: 'Add items' }));
return screen.getByRole('dialog');
+38 -16
View File
@@ -29,7 +29,7 @@ import {
createSmartCollection,
deleteCollection,
deleteSmartCollection,
getCollectionItemsPreview,
getCollectionItems,
getCollections,
getLibraryBrowseItems,
getSmartCollections,
@@ -482,20 +482,24 @@ function ManualItemsView({
collection: MediaCollection;
onBack: () => void;
}) {
const PAGE_SIZE = 100;
const [items, setItems] = useState<LibraryBrowseItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [removing, setRemoving] = useState<number | null>(null);
const activeRef = useRef(true);
// No synchronous setState here: `loading` starts true and flips false in `finally`, so
// this is safe to call from an effect. Reloads (after add/remove) keep the list visible.
// this is safe to call from an effect. Reloads (after add/remove) reset to the first page.
const load = useCallback(() => {
getCollectionItemsPreview(collection.name ?? '')
.then((preview) => {
getCollectionItems(collection.id, 0, PAGE_SIZE)
.then((result) => {
if (activeRef.current) {
setItems(preview);
setItems(result.page ?? []);
setTotal(result.totalCount ?? 0);
setError(null);
}
})
@@ -509,7 +513,23 @@ function ManualItemsView({
setLoading(false);
}
});
}, [collection.name]);
}, [collection.id]);
const loadMore = async () => {
const nextPage = Math.floor(items.length / PAGE_SIZE);
setLoadingMore(true);
try {
const result = await getCollectionItems(collection.id, nextPage, PAGE_SIZE);
if (activeRef.current) {
setItems((prev) => [...prev, ...(result.page ?? [])]);
setTotal(result.totalCount ?? 0);
}
} catch (moreError) {
setError(messageFromCollectionError(moreError, 'Unable to load more items'));
} finally {
setLoadingMore(false);
}
};
useEffect(() => {
activeRef.current = true;
@@ -547,15 +567,6 @@ function ManualItemsView({
</Button>
</div>
<div className="ctv-settings-warn-callout" role="note">
<Info aria-hidden="true" color="var(--status-warn)" size={14} />
<span>
The API has no endpoint to list a manual collection's items. This is a best-effort search preview covering
movies, shows, seasons and artists only — other kinds in this collection won't appear. Adding items works for
all shown kinds.
</span>
</div>
{error && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
@@ -570,7 +581,7 @@ function ManualItemsView({
<span>Loading items</span>
</div>
) : items.length === 0 ? (
<div className="ctv-collections-empty">No previewable items in this collection.</div>
<div className="ctv-collections-empty">No items in this collection.</div>
) : (
items.map((item, index) => (
<div
@@ -598,6 +609,17 @@ function ManualItemsView({
)}
</Card>
{!loading && items.length < total && (
<div className="ctv-collections-loadmore">
<span className="ctv-collections-count">
Showing {items.length} of {total}
</span>
<Button disabled={loadingMore} onClick={() => void loadMore()} size="sm" variant="ghost">
{loadingMore ? <Spinner size={14} /> : 'Load more'}
</Button>
</div>
)}
<AddItemsDialog
collection={collection}
key={`add-${pickerOpen}`}