GET /api/v1/channels/auto-tune/members lists the distinct content sources a
proposed auto-tune channel's server-generated SmartCollection resolves to —
parent shows for the TV axes (ItemCount = query-matching episodes), movies for
the movie-genre axis — reusing the existing PagedLibraryBrowseItemsResponseModel
(no new schema). The handler runs the server-owned AutoTuneAxisMap.GenerateQuery
through ISearchIndex (client never sends Lucene, per #69 PR1) and rolls matching
leaf items up to their distinct sources, mirroring GetSmartCollectionItems so the
DetailPanel preview matches what the built playout will contain.
Backend child of #383 (Auto-Tune DetailPanel milestone); read-only, cold review
acceptable. Handler + controller tests (9 new). OpenAPI + endpoint-index
regenerated; d.ts unchanged (reuses existing schema). Docs: api-conventions §5,
decisions.md 2026-07-17, domain-model.
fixes#384
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fixes#73#73's premise ("ErsatzTV has no native date-conditional scheduling today")
is false. The predicate (IAlternateScheduleItem) is shipped on both engines,
evaluated by AlternateScheduleSelector, reachable from the SPA, and already
unit-tested — so the only real gap was discoverability, and it was a docs gap:
the mechanism was documented as a mechanism, never as a task.
- channels.md: task-shaped "Recipe: seasonal / holiday programming" for both
engines, plus the gotchas — chiefly that blank StartYear/EndYear is what
makes a range repeat every year (AlternateScheduleSelector.cs:32-40), that
the override branch needs BOTH years, and that explicit years disable
wrap-around detection.
- domain-model.md: glossary row (mirrors the #77 "already exists" row).
- decisions.md: entry recording the verdict and the rejected asks — notably
that "prioritize collection X during a date range" is #70's weighting
problem, not a second primitive built here while #70 is mid-flight.
No production code changed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#77's core (pad/snap schedules to :00/:15/:30 via filler) already exists —
FillerMode.Pad + PadToNearestMinute (Classic), pad_to_next/pad_until
(Sequential); Block is inherently time-anchored. No production code change;
this locks the behaviour end-to-end and documents that it exists.
- PlayoutBuildGoldenTests.Classic_clock_padded: a PostRoll FillerMode.Pad(15)
preset through the real PlayoutBuilder snaps content to :15 (golden +
explicit quarter-hour assertion). Splits Verify -> CompareGolden for reuse.
- ChannelGuideProjectorClockPadTests: the guide projection coalesces trailing
filler so programmes STOP on the padded boundary (XMLTV half).
- docs: decisions.md (2026-07-17 entry), domain-model.md (clock-boundary row),
testing.md (test map + count 540->542).
Deferred (UI, blocked on #388): one-click per-channel/schedule clock-align
toggle + 60-min increment option.
Refs #77
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New `/app/auto-tune` screen driving the PR1 endpoints: pick metadata axes
(TV Shows / TV Genres / Movie Genres) + defaults, preview proposed channels
grouped by axis with per-row/per-group selection and already-exists dedup,
then bulk-create with a Created/Skipped/Failed summary. Additive/non-destructive.
The per-channel DetailPanel from the design iteration is deferred to #383
(new endpoints backlogged) — this ships the 3-step wizard only.
- web/src/api/autoTune.ts (+test), web/src/screens/AutoTuneScreen.tsx (+test)
- route/nav (routes.tsx, ScreenContent.tsx), api barrel, App nav-smoke test
- docs: domain-model (route), blazor-route-parity (net-new SPA screen)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ErsatzTV built every absolute M3U/XMLTV URL from the incoming request's
Scheme/Host/PathBase, so a client fetching via a host that downstream
consumers can't resolve (e.g. Dispatcharr over Docker DNS → Kodi) baked
that internal host into programme-image/stream URLs.
Add an optional advertised IPTV base URL, backed by the existing
ConfigElement key/value store (key `iptv.base_url`, no EF migration):
- Central pure Core helper `AdvertisedBaseUrl` (TryParse/Resolve):
validates absolute http(s), no credentials/query/fragment, preserves
port + path prefix, normalizes trailing slash. Blank/invalid falls
back to the request-derived values, so unset output is byte-identical.
- Resolved inside `GetChannelPlaylistHandler` (M3U guide/logo/stream) and
`GetChannelGuideHandler` (both XMLTV {RequestBase} sites) — controllers
stay thin, golden tests untouched.
- New `iptv` settings group: GET/PUT /api/v1/settings/iptv (blank clears,
malformed → 422) + a new IPTV section on the SPA Settings screen.
- Scoped to M3U + XMLTV; HDHomeRun deliberately out of scope. Distinct
from ETV_BASE_URL (which only sets ASP.NET PathBase).
Tests: AdvertisedBaseUrl unit tests (override/fallback/port/path/invalid),
handler override tests for both generators, settings controller + handler
tests, SPA client + screen tests. Docs: m3u-xmltv, decisions, domain-model,
regenerated OpenAPI v1.json + v1.d.ts + endpoint-index.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Version every /api route to /api/v1 (251 controller routes + ~24 Location
headers + the scanner callback URL + the Startup request-log literal),
uniform across the machine API, auth, scanner and scripted-build surfaces.
Add ApiVersionRewriteMiddleware: a legacy unversioned /api/* request is
rewritten (NOT redirected) to /api/v1/* in-pipeline — method, body, auth
headers and query survive — carrying RFC 8594 Deprecation/Sunset headers,
so curl / the future MCP server / bookmarks keep working. An already-
versioned path passes through; a future /api/v2 is never forced to v1.
Standardize the route convention (leading-slash absolute route per method,
no class-[Route] — except the two Scanner/Scripted controllers whose ~all
actions share a parametrized {id} prefix), enforced by ApiRouteVersioningTests
(^/api/v\d+/ over the whole Controllers.Api surface; browser-nav
/auth/oidc/login is out of scope).
Regenerate v1.json (160 paths, all /api/v1)/endpoint-index/v1.d.ts; sweep 945
SPA request literals + the test mocks (regex + positional URL parsers). /api/v1
is additive-only after freeze; the legacy-rewrite shim sunsets in ~2 releases
(owner decision) with removal tracked as a Phase-3 follow-up.
Docs: decisions.md 2026-07-13, api-conventions §1/§9, rest-api/spa-conventions/
blazor-route-parity/e2e-local/domain-model.
fixes#286
refs #197
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the shared optimistic-concurrency contract so a stale second tab can no longer
silently overwrite a fresher edit. PR1 lands the infra + the Block reference aggregate;
PRs 2–4 fan the same recipe across the other 8 roots (design: #253#issuecomment-8472).
Contract
- `IVersionedAggregate` (`int Version`) on all 9 replace-all roots (ProgramSchedule,
Block, Template, DecoTemplate, Playlist, Collection, Playout, MultiCollection,
RerunCollection), EF-mapped `.IsConcurrencyToken()`; one dual-provider migration
`AddAggregateVersions` (nullable:false, default 0).
- Strong `ETag` of `Version` on the aggregate GET; `If-Match` on the PUT; mismatch →
412 (distinct from the §3a 409 build-lock guard). Successful PUT returns the new ETag.
- `PreconditionFailedError : BaseError` → 412 in `ApiResults.ToErrorResult`;
`ConcurrencyHeaders.ParseIfMatch/SetETag`; malformed If-Match → 400; `*`/absent =
Phase-1 force-write.
Block reference wiring
- Handler: standalone `Either` via `CheckVersion` AFTER validation (never through
`Apply`, which Join()-flattens the subtype to 422), unconditional `Version++`,
`SaveChangesWithConcurrencyGuard` backstop (DbUpdateConcurrencyException → 412).
- `BlockViewModel.Version` (header-only, not echoed in the body); controller sets the
ETag on GET items and on the successful PUT.
- SPA: `client.requestWithMeta` seam; `blocks.getBlockItemsWithMeta` + `replaceBlock`
If-Match/ETag round-trip; `BlockEditor` holds the ETag, sends If-Match, and on 412
opens a blocking "changed elsewhere — reload" dialog.
Tests
- Handler contract tests: stale-If-Match → 412 (no mutation), matching/absent → success
+ bump, no-op save still bumps, and a two-context racing save → 412; proven
non-vacuous (drop `.IsConcurrencyToken()` → the race test fails).
- Controller tests: malformed If-Match → 400, If-Match threaded to the command, ETag on
GET/PUT, 412 passthrough. SPA: requestWithMeta ETag, replaceBlock If-Match, 412 dialog.
Docs: api-conventions §7a, spa-conventions §4a, domain-model glossary, decisions log.
Refs #253
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a ChicoryTV SPA Playlist editor at /app/playlists over the new
playlist-CRUD REST API: a group tree (add/rename/delete groups,
add/delete playlists) plus a playlist item editor (per-item Collection
Type over the 12 playlist item types, type-conditional playback order,
count, Play All, Show In EPG, reorder/copy/remove) and a draft playout
preview. System groups and playlists are read-only, mirroring the Blazor
Playlists.razor / PlaylistEditor.razor pages.
- web/src/api/playlists.ts: group/playlist/item CRUD + preview client fns
- web/src/screens/PlaylistsScreen.tsx: group tree + editor + preview
- App.tsx: route, ScreenId, nav, dispatch wiring
- docs: blazor-route-parity + domain-model updated to SPA DONE
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add two React SPA editor screens over the existing REST APIs, retiring the
last two "Classic UI only" media-collection editors.
MultiCollectionsScreen (/app/multi-collections, #151): list + in-screen
editor. Name + a list of manual/smart collection items, each with a
Schedule-As-Group toggle; two add-pickers dedupe already-present entries.
Items save with playbackOrder "Chronological" (matches the Blazor editor).
RerunCollectionsScreen (/app/rerun-collections, #152): list + in-screen
editor. Name, a Collection Type select restricted to the REST-supported
selection set (IsSupportedSelectionType) — Collection/MultiCollection/
SmartCollection plus the media-item types TelevisionShow/TelevisionSeason/
Artist/Movie/Episode/MusicVideo/OtherVideo/Song/Image/RemoteStream (every
one maps to a working LibraryBrowseMediaType picker); Playlist is excluded.
A conditional single-selection picker per type, and First/Rerun playback
order selects with the Blazor type-conditional option sets (reset on type
change).
Both screens toggle list<->editor via local state (mirroring
CollectionsScreen), no sub-path routing. Wired into App.tsx (imports,
ScreenId, routes, mediaNavIds, ScreenContent). Added messageFrom*Error
helpers + type re-export to the api clients.
Tests: multiCollections/rerunCollections api-client param+body tests,
both screen tests (list render, create body, rerun type-switch swaps
picker + order options, delete), and an App.tsx nav-registration test.
Docs: blazor-route-parity.md (rows moved to SPA DONE), domain-model.md.
web verify gate: 353 tests pass, eslint clean, tsc -b + vite build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>