Compare commits

..
Author SHA1 Message Date
timothy 630119f382 fix(api): address library browse review findings
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m3s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m36s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
refs #65

refs PR #130
2026-07-06 07:45:39 +02:00
timothy 127a130737 feat(api): add library browse endpoint
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m10s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m47s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
refs #65
2026-07-06 00:01:06 +02:00
timothyandClaude Fable 5 a5de775480 docs: advance ChicoryTV issue queue past #85 (PR #129); next prompt = #65 library browse API (Codex)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m39s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m14s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m32s
Renovate / Renovate (push) Successful in 1m18s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 23:22:10 +02:00
timothy 8a62f22208 Merge pull request 'feat(web): Guide / EPG screen (#85)' (#129) from feat/85-epg into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m22s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m51s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m39s
2026-07-05 21:12:25 +00:00
timothy a8d4979b0c fix(web): address Guide review feedback refs #85
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m48s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m32s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-05 23:06:08 +02:00
timothy 7d93688841 feat(web): add Guide EPG screen refs #85
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m3s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-05 22:31:30 +02:00
timothyandClaude Fable 5 d9ffbde79c docs: advance ChicoryTV issue queue past #88 (PR #128); next prompt = #85 EPG (Codex)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m54s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m28s
Records the #128 review outcome (percent-fraction + poll-start-race fixes,
bee686c8), main d63719ec, baselines (368/493+1skip/75 web), and new lessons
(numeric wire-field units, trigger≠started grace windows, no ref mutation in
updaters).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:08:53 +02:00
timothy d63719ece9 Merge pull request 'feat(web): Libraries screen (#88)' (#128) from feat/88-libraries into main
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
2026-07-05 17:06:50 +00:00
timothyandClaude Fable 5 bee686c846 fix(web): libraries scan-flow review fixes for #88
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m3s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
- Normalize scan-status percent at the API boundary: the wire value is a
  0-1 fraction despite the field name; the bar previously showed ~0% for
  the whole scan (fixtures now use fractional wire values incl. 0/1)
- Fix the poll-start race: a triggered library enters a pending set that
  survives status pruning until seen active or a 3-tick grace window
  expires; the poll runs while pending ∪ active is nonempty (previously
  the queue-to-start lag could mean polling never started and the button
  re-enabled mid-scan); grace ticks also drain on persistent status-fetch
  errors so the UI can't stick
- Keep setState updaters pure: pending/grace bookkeeping hoisted into
  pruneGraceExpiredPending() outside the updater (StrictMode-safe)
- Wire the prototype's per-source Scan button (one POST per library,
  disabled while any of its libraries is pending/active)
- Honest bare-500 media-sources failure test (backend has no exception
  middleware); loadScanStatuses returns its promise; poll effect depends
  on derived hasActiveScans; synchronous ref double-submit guard;
  --radius-xs fallback 3px; StatusDot label on source cards (75 tests)

Review: PR #128 findings

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:01:05 +02:00
timothy 5c4acb9c7b feat(web): add Libraries screen refs #88
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m18s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-05 18:26:06 +02:00
timothyandClaude Fable 5 223ef70912 docs: advance ChicoryTV issue queue past #87 (PR #127); next prompt = #88 Libraries (Codex)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m22s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m0s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m1s
Records the #127 review outcome (filler blocker + undefined token), pre-merge
fixes (c4190e3a), main a8dd88ef, baselines (368/493+1skip/63 web), and new
lessons (fixture-vs-actual-query fidelity, pure setState updaters, responsive
collapse, visible deferrals, honest tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 17:53:07 +02:00
timothy a8dd88ef73 Merge pull request 'feat(web): Playouts screen (#87)' (#127) from feat/87-playouts into main
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
2026-07-05 15:50:57 +00:00
timothyandClaude Fable 5 c4190e3a54 fix(web): playouts monitor review fixes for #87
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m28s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m9s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
- Filler unit: treat fillerKind 'None'/null as non-filler (real items no
  longer render the Filler badge); add the prototype's Show filler toggle
  wired to ?showFiller=true (the client previously never requested filler,
  making the filler UI dead code); fixtures now use real wire values
- Hoist selection fetch out of the setState updater (StrictMode-safe)
  via a baseRef snapshot; merge polled channel states functionally
  instead of clobbering with the captured snapshot
- Fix undefined --ctv-surface-1 token on the hero (→ --surface-card per
  prototype); correct --text-lg fallback; collapse the new playout grids
  in the 980px media block (no page-level horizontal scroll)
- Visible deferrals: disabled Reset / Schedule reset buttons with note
  (no per-playout reset endpoint yet)
- itemsLoading spinners on selection switch; time-window now-matching
  with title fallback; rail disabled while reset-all mutates
- Tests: mount-count delta isolation from the Dashboard prefetch;
  reset-all reframed as transport-failure (mock honors fixture status;
  backend is unconditionally 202); rail filter test; paging honesty
  ('shown of total' label); listPlayout() fixture matches the list DTO
  (63 tests)

Review: PR #127 findings

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 17:45:01 +02:00
timothy 1840ef83b1 feat(web): add Playouts monitor screen refs #87
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m1s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-05 17:10:30 +02:00
timothyandClaude Fable 5 c5021e28da docs: advance ChicoryTV issue queue past #86 (PR #125); next prompt = #87 Playouts (Codex)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m48s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m23s
Records the #125 review outcome (refuted reorder blocker → #126), pre-merge
fixes (f18f2dc0), main 229f7cc1, baselines (368/493+1skip/52 web), and new
lessons (mount call-count assertions, no world-refetch, no list[0]-only UIs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 12:27:06 +02:00
timothy 229f7cc1b0 Merge pull request 'feat(web): Schedule editor screen (#86)' (#125) from feat/86-schedule-editor into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m18s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
2026-07-05 10:22:16 +00:00
timothyandClaude Fable 5 f18f2dc058 fix(web): schedule editor review fixes for #86
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m33s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m16s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
- Add a schedule selector (previously only the first schedule was ever
  shown); switching loads items only, with a pane-level spinner
- Fix duplicated mount effect that fetched the whole screen twice
- Add a collection picker to the add-item flow (was silently using the
  alphabetically-first collection with no way to correct it)
- Reorder now reconciles from the PUT response; add/delete refetch items
  only — pickers are no longer refetched on every mutation
- Guard reorder against double-submit via a ref; remove the inert Save
  button; document the widened item type (OpenAPI gap → #126)
- Inspector fidelity: Tail filler row + Keep multi-part together
- a11y: aria-current for the active item, draggable role description
- CSS: correct --text-md fallbacks; replace nonexistent --text-faint
  with --text-disabled in new blocks
- Tests: single-fetch-on-mount assertion, chosen-collection POST body,
  picker-failure path, collection() fixture with all required fields;
  drop unreachable mock branch (52 tests)

Review: PR #125 findings; refs #126

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 12:16:53 +02:00
timothy 2e58fa1815 feat(web): add schedule editor screen
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m44s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-05 11:43:04 +02:00
timothyandClaude Fable 5 8f792d964e docs: advance ChicoryTV issue queue past #84 (PR #124); next prompt = #86 Schedule editor (Codex)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m23s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m2s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m28s
Records the new Fable-prompts/Fable-reviews process, PR #124 review outcome
and pre-merge fixes (ecf6cf75), main 7ee6e987, baselines (368/493+1skip/39 web).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 11:29:53 +02:00
timothy 7ee6e9870c Merge pull request 'feat(web): Channels screen (#84)' (#124) from feat/84-channels-screen into main
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
2026-07-05 09:26:22 +00:00
timothyandClaude Fable 5 ecf6cf7513 fix(web): scope channel bulk ops to visible rows, review fixes for #84
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m58s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
- Derive selectedChannels from visibleChannels and clear selection on
  filter change so bulk operations can never act on rows hidden by the
  active filter (reachable via the 30s state poll under the on-air filter)
- Disable the per-row delete button while a mutation is in flight
- Correct the --pad-cell-x fallback (token resolves to 16px)
- Add tests: bulk renumber request body + selection clearing, and
  selection reset across filter changes

Review: PR #124 findings 1, 2, 4, 8

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 11:20:32 +02:00
timothy 15c6cdd086 feat(web): add Channels screen
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m26s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m55s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
refs #84
2026-07-05 10:41:29 +02:00
timothyandClaude Fable 5 39d76080de docs: advance ChicoryTV issue queue past #109 (PR #123); next prompt = #84 Channels screen (Codex)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m15s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m56s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m21s
Records: main=4ffd777b, review-fix cycle lesson (fixture values must come from
backend serializers), health no-poll policy, baselines 368/493 + 31 web tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 21:23:24 +02:00
timothy 4ffd777b49 Merge pull request 'feat(web): Dashboard real data sources (#109)' (#123) from feat/109-dashboard-data into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m42s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m21s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m31s
2026-07-04 19:20:38 +00:00
timothyandClaude Fable 5 e6197d63ff fix(web): dashboard review fixes — real health status contract, dedupe version fetch, refresh guard (#109)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m3s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m53s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
- Match the backend's exact health status contract ('pass'|'fail'|'warn'|'info')
  instead of fictional strings; 'info' now renders as a neutral/idle presentation
  and is excluded from failing/warning counts in summarizeHealth.
- Drop the redundant /api/version fetch from getDashboardData/DashboardData;
  SidebarVersion's useDashboardVersionQuery remains the single source.
- Disable "Refresh health" (via Button's loading prop) while a health request
  is in flight to prevent concurrent double-click requests.
- Add the active-flag unmount guard to useDashboardHealthQuery for consistency
  with useDashboardQuery/useChannelsQuery.
- Remove dead .ctv-activity-*/.ctv-release-* CSS left over from the removed
  activity feed/release notes UI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 21:14:44 +02:00
timothy 6a9f1e41a4 fix(web): satisfy dashboard lint checks
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m48s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
refs #109
2026-07-04 20:55:38 +02:00
timothy a6056f73d8 feat(web): wire dashboard to real API data
Build ErsatzTV Image / Build & test (.NET) (pull_request) Failing after 41s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 3m41s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
refs #109
2026-07-04 20:52:32 +02:00
timothy da7636fdad docs: update ChicoryTV queue after PR 121
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m11s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m4s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m46s
2026-07-04 20:37:29 +02:00
timothy cc6ffcb8c4 Merge pull request 'feat(core): track active sessions for direct streaming modes (#99)' (#121) from feat/99-session-tracking into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m21s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m0s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m35s
Merge pull request 'feat(core): track active sessions for direct streaming modes (#99)' (#121) from feat/99-session-tracking into main
2026-07-04 18:24:15 +00:00
timothy f9bb230673 chore: retrigger PR checks
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m47s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m29s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
refs #99
2026-07-04 20:10:26 +02:00
timothy 6350845101 fix(core): harden direct session tracking
Build ErsatzTV Image / Build & test (.NET) (pull_request) Failing after 3s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 3m38s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
refs #99
2026-07-04 20:05:19 +02:00
timothy 9cd107bc8d feat(core): track direct stream sessions
refs #99
2026-07-04 19:57:50 +02:00
timothyandClaude Fable 5 3e8cfa6288 docs: advance ChicoryTV issue queue past the merge pass (PR #120); next prompt = #109 Dashboard
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m46s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m31s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m45s
Records: main=1b6de047, PR #122 hotfix pending consent, PR #121 (#99) FIX-FIRST
review state, new baselines (364/488), worktree cleanup status.

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