Cold adversarial review (no blockers, 3 real Mediums):
- Query&size "Order" row ignored the Shuffle toggle (dead ?? fallback showing
the static axis order); now reflects shuffleOn, consistent with the subtitle.
- Clearing Name/Number to '' flagged the row Edited + armed the unsaved-changes
guard, but the payload reverted to the proposal default — the two "edited"
derivations disagreed. overrideEdited now ignores an empty (inherited) value.
- runPreview did not clear per-channel overrides, so edits (incl. pinned numbers)
silently carried into a re-generated batch → collision risk. Fresh preview now
resets overrides/detailKey/logo cache.
Tests: empty-name-not-Edited + re-preview-clears-overrides.
Live-E2E (local instance, seeded TV library): preview → Configure → toggle
Shuffle → Create verified end-to-end; the created channel's schedule shows
PlaybackOrder=Shuffle (overridden) vs SeasonEpisode (axis default), confirming
the per-channel override flows UI → SPA → backend → playout.
Refs #386
Adds a right-hand "Configure" slide-over to each Auto-Tune Preview row, making
a proposed channel editable before bulk-create — against the shipped #384/#385
backend only, so no control lacks a wire target.
- New reusable SlideOver primitive (components/overlay.tsx), sharing a
useOverlayBehavior hook (focus/scroll-lock/Escape/scrim) with Dialog.
- Extract the Channel Builder's advanced-options model to builder/advancedOptions.tsx
(enum catalogs, ADVANCED_KEYS, effectiveValue, INHERIT/omit useAdvancedOverrides
hook); ChannelBuilder imports it unchanged (its tests pass byte-for-byte). The
DetailPanel writes its own field JSX over the same hook — shared logic, per-screen
layout.
- Panes: identity (name/number + logo upload), Playback (Shuffle/Always-playing →
advanced.playbackOrder/playoutMode), per-channel template picker, Advanced
disclosure, lean read-only Query&size, read-only Content-sources via GET /members.
- getAutoTuneChannelMembers API client (#384 read endpoint) + tests.
- Screen-scoped §8 unsaved-changes guard + "Edited" row badge.
- Dropped as backend-less decoration: MiniEpg, bug-initials generator, query text.
Deferred to #425 with an in-pane hint: per-source weight steppers + corrections.
- Docs: spa-conventions §11 (SlideOver + shared advanced-options), decisions.md.
Refs #386
Make the ~14 health checks actionable: each check that has a fix now declares
where to go, and the SPA acts on it.
Backend:
- Widen domain HealthCheckLink (string Link) -> (string Target, HealthCheckLinkKind
Kind) with ExternalDoc|AppRoute + factories; only the 4 link-building checks and
the API mapper touched .Link.
- Evolve HealthCheckResponseModel additively (/api/v1 frozen-additive): keep
deprecated string? Link (still populated), add Brief (the BriefMessage the mapper
was silently dropping) and nested Remediation {Kind, Target}. Kind is a mapped
string, not a wire enum.
- Make Mapper.GetStatus total: NotApplicable no longer throws (defensive; handler
still filters it). InternalsVisibleTo(ErsatzTV.Tests) added to unit-test totality.
- Fix 2 stale Blazor route links (media/trash -> /app/trash, search?query ->
/app/search); add AppRoute remediation to actionable checks that had none
(libraries / schedules / ffmpeg-profiles / settings).
SPA:
- DashboardScreen health panel renders remediation: AppRoute -> client-side nav
button, ExternalDoc -> new-tab anchor; detail text truncates with title-hover.
- Remove the dead "Open Classic UI" -> /system/health row from SettingsScreen
(a #91b leftover that just 302'd to /app); update its regression test.
Docs: decisions.md (#164), api-conventions.md (deprecate-in-place DTO evolution),
blazor-route-parity.md (Section 4 correction); v1.json/v1.d.ts/endpoint-index
regenerated.
fixes#164
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Auto-Tune DetailPanel backend (#385), additive half. The create request
`AutoTunedChannelRequest` gains three optional per-channel fields, all
backward-compatible (omit = PR1 behavior):
- `templateId` — overrides the batch template per channel
- `advanced` — reuses the manual Channel Builder's
`CreateChannelFromLineupAdvancedOptionsRequest` verbatim (24-field override
set, `advanced.X ?? template.X` stamp contract). Axis default fills
`PlaybackOrder` only when the caller leaves it null.
- `logo` — uploaded channel image, `Sanitized()` at the request boundary
(#283 stored-XSS defense), forwarded to `CreateChannelFromLineup.Logo`
Resolved per channel inside `CreateAutoTunedChannelsHandler.CreateOne`, so one
channel's bad override still yields a per-channel Failed/Skipped without
aborting the batch.
Per-source rotation weights + query corrections are split out to #425 (they
need a MultiCollection-of-per-source-SmartCollections redesign — #70's
WeightedShuffle reads weights only off MultiCollection join rows, and an
auto-tuned channel is one SmartCollection). Bug-initials/colour generated logo
also deferred (needs persisted Channel state + FFmpeg-pipeline wiring).
Tests: handler override-threading (per-channel wins, axis default preserved,
no-override baseline) + request `ToCommand()` logo sanitization. OpenAPI trio
regenerated. Docs: decisions.md, api-conventions.md, domain-model.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Without this the weight is only reachable by editing the database, so the
enumerator has nothing to distribute by.
Weight is threaded through create and update (all four handler branches: add and
update, plain and smart) and defaults to 1, so it is optional on the wire and
/api/v1 stays additive under the freeze.
It is returned on the read path too, which is load-bearing rather than symmetry:
the update replaces the item list, so a client that GETs, edits a name, and PUTs
back would silently reset every weight to the default if the GET didn't carry it.
Weight edits ride the existing MultiCollection Version token, so If-Match/412
concurrency needs no new design.
Regenerated v1.json + v1.d.ts + endpoint-index via update-openapi.sh and
generate:api (never hand-edited). The spec picks up weight on both request and
response models and WeightedShuffle in the PlaybackOrder enum; weight is emitted
optional.
Refs #70
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adversarial review, Medium: the "Problems" filter over-promised. The badge is
honestly named ("No playout"), but the filter claimed a taxonomy that does not
exist — decisions.md enumerates three fault classes this deliberately does NOT
compute (empty schedule behind a playout, broken source, origin). A user whose
Classic schedule is empty would read "Problems 0" as "lineup healthy" and ship
a dead channel. A false all-clear is worse than no affordance.
Renamed the filter to "No playout" so badge and filter both name exactly the
one fault the API can prove, leaving "Problems" free for when the taxonomy
behind it actually exists. Rationale recorded at the predicate so the next
person doesn't "improve" the label back.
Also from the review:
- The zero-playout test's comment claimed include coverage it does not provide
(it passes with or without the include — 0 == 0). Re-stated as what it is: a
mapper boundary check. Its two siblings are the include coverage.
- UpdateChannelHandler:179 is a fourth call site the "shared by all three"
framing excluded. Harmless (the controller discards the view model and
re-projects through GetChannelByIdForApi), but a trap: its query lacks the
MirrorSourceChannel include, so swapping in the shared helper would report 0
for a working mirror. Documented that the fix there is a QUERY change.
- Mirrored the rename into the design-system prototype.
The rename collided the badge and filter labels, so the screen tests now scope
the badge assertion to the table (spa-conventions §6). This also fixed a real
weakness: the mirror test's unscoped queryByText would have matched the filter
button and asserted nothing.
Review note: the reviewer's strongest hypothesis — that PUT and GET could
disagree on a mirror channel's count — was investigated and does NOT hold.
Refs #72
Surfaces the per-channel playout count the previous commit put on the API as
the fault state #72 calls "the important one": a channel with no playout can
never play, and until now the lineup gave no sign of it.
- "No playout" renders as a Badge with visible text rather than another
single-letter marker like D/H. D/H flag states the user chose deliberately;
this is a fault they need to notice and act on, and a tooltip-only glyph is
invisible on touch and easy to miss in a long lineup.
- A "Problems" segmented filter answers the issue's actual goal — seeing the
health of a LARGE lineup at a glance — by collapsing it to just the faults.
- `willNeverPlay` is one predicate shared by the badge, the count and the
filter, so the three cannot disagree.
Mirror channels relay their source's playouts and the server folds those into
playoutCount, so a working mirror is not false-flagged (covered by a test).
Deliberately not approximated here, per the design pass: an empty schedule
behind an existing playout (EmptyScheduleHealthCheck only understands Classic
ProgramSchedule playouts — a badge driven by it would silently lie on Block,
Sequential, Scripted and ExternalJson channels) and broken/missing sources
(FileNotFound/Unavailable are server-wide counts with no channel attribution).
Both stay open on #72 rather than shipping a signal that is wrong for four of
the five schedule kinds.
The test fixture now sends playoutCount, matching the API, so the fault state
is opt-in per test instead of depending on an absent field.
Refs #72
The channels API could not answer "will this channel play?", which #72 needs
to flag a broken channel in the lineup at a glance.
Two defects, one root cause each:
1. `ChannelRepository.GetChannel` never included `Playouts`. The read is
AsNoTracking with no lazy-loading proxies, so the navigation came back
empty and `GetChannelByIdForApiHandler`'s `channel.Playouts?.Count ?? 0`
could only ever evaluate to 0 — `GET /api/v1/channels/{id}` reported
`playoutCount: 0` for every channel on the system. That silently disabled
the channel editor's playout-source guard (ChannelEditScreen:820, gated on
`playoutCount > 0`), so the "Cannot be changed once a generated channel has
a playout" control was always live. The server still enforces the invariant
(UpdateChannelHandler coerces Mirror back to Generated), so nothing was
corrupted — but the user's change was silently discarded. That silent
coercion is filed separately as #401.
2. The detail path counted only the channel's own playouts, never the mirror
source's, so a working Mirror channel would read as "no playout" even once
the include landed.
Both call sites now share `Mapper.GetPlayoutsCount` (previously private to
GetAllChannelsHandler), which handles the Mirror case. `ChannelResponseModel`
gains `PlayoutCount` so the list — #72's actual surface — can render it; the
count is free there, since `GetAll` already includes `Playouts` and
`MirrorSourceChannel.Playouts` and simply discarded them.
Tests run the real repository against a real context on purpose: a handler
test with a substituted IChannelRepository populates `Playouts` itself, so it
passes whether or not the query includes them. Proven non-vacuous — removing
the include again turns the 2-playout and mirror cases red (0 CS errors, so
no stale-dll false pass).
Refs #72
Addresses the cold-review findings on PR #389:
- Medium: selection was keyed by proposal name, coupling two same-named
proposals (e.g. a show titled "Comedy" and the "Comedy" genre) so toggling
one flipped both. Now keyed by axis+value (unique); adds a regression test.
- Low: zero channel templates now shows a hint on Configure and the Create
tooltip explains the missing template instead of a misleading positive label.
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>
Reset only reseeds Playout.Seed for Classic playouts (PlayoutBuilder); Block/
Sequential/Scripted rebuild deterministically from the existing seed, so
reshuffle was a silent no-op for 3 of the 4 supported kinds (C1). Fix:
ReshufflePlayoutHandler now sends ErasePlayoutHistory (reseed + clear anchors/
history, the only primitive that reseeds all four kinds) before enqueueing
BuildPlayout(Reset).
Also: correct docs/decisions.md's false "Reset already reseeds..." claim,
fix the SPA reshuffle test mock to return 202 (matches the real endpoint),
and gate the seed-help text to the resettable kinds (was showing even for
ExternalJson/None where no Reshuffle button exists).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two missing-base-reset defects in the ChicoryTV SPA shell, both confirmed by
rendering the real app (not source-reading):
- #373: the browser-default `body { margin: 8px }` was never reset, so it framed
every full-viewport layout — the app shell and the shell-less `.ctv-auth-page`
boot pages (login/setup/checking/error), both `min-height:100vh` — with a light
border on all four edges. Fix: `html, body { margin: 0 }` + paint the app surface
on `body` so any residual gap/overscroll stays dark (matches the design-system
reset in forms.card.html / chicorytv-admin templates). Verified body margin 0 and
no edge border on the boot page and the shell, warm theme, 1280px and 900px.
- #377: the SPA ships no global `box-sizing` reset (default content-box), so
`.ctv-nav-item { width:100%; padding:0 10px }` overflowed `.ctv-nav`
(`overflow:auto`) by 20px → a horizontal scrollbar in the sidebar. Fix: scope
`box-sizing: border-box` to `.ctv-nav-item`. Verified nav scrollWidth==clientWidth
(216==216) and no horizontal scroll after the fix; the intended vertical nav scroll
is unaffected.
Docs: spa-conventions.md §1 records the base reset and the deliberate absence of a
global box-sizing reset (set border-box locally when combining width:100% + padding).
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>
Adversarial review of the initial fix found that authorizedNoServers =
isAuthorized && !hasServers ignored isLocked. Because boot.state updates on
every poll observation, the "no eligible servers — sign out and retry"
subtitle + summary rendered during the normal `finalizing` window (locked,
authorized, no servers yet) alongside the "discovering your Plex servers…"
status — and during `budget-exhausted` (large first sync still holding the
lock, polling stopped, Sign out enabled) it urged the user to sign out
mid-sync. That reintroduced the contradictory-state class the PR fixes.
Gate the empty-servers presentation on !isLocked so it only appears at the
genuine released-lock terminal. Add regression coverage: the pin-flow test
now passes through the finalizing cell asserting the empty-servers messaging
is absent while locked, plus a budget-exhausted-while-locked case. Both new
guards were confirmed to fail on the ungated code (negative control).
Refs #345
A real Plex pin flow reached isAuthorized:true, isLocked:false, servers:[]
(a Plex account that owns/has been granted no eligible servers). The SPA
then showed contradictory state: the pin-flow status claimed "Connected to
Plex." while the Connection card said "Not signed in.", offered Sign in, and
hid Sign out — so the stored authorization could not be removed through the UI.
Root cause: PlexSourceScreen conflated authorization (a stored token,
isAuthorized) with connection (a discovered server, servers.length > 0) by
deriving everything from `connected = servers.length > 0`, and evaluatePinFlow
returned `success` ("Connected to Plex.") for any released-lock authorized
observation regardless of server count.
Fix (pure SPA; sign-out endpoint already works with empty servers):
- pinFlowPoll: add a distinct `authorized-no-servers` terminal status and a
`hasServers` observation field; the released-lock branch now yields success
only when a server was discovered, else authorized-no-servers with an honest
message that distinguishes authorization from discovery.
- PlexSourceScreen: gate Sign in vs Sign out on `hasPlexAccount`
(isAuthorized || hasServers) instead of the server count, so an authorized
account can always sign out; add a subtitle + explanatory summary for the
authorized/empty state; keep the Servers card gated on actual servers.
- Regression tests: the authorized/unlocked/empty transition (pin-flow and
fresh-reload paths) and sign-out from that state.
fixes#345
Move Guide rendering, timer and clipping helpers, and detailed behavior tests into a screen-owned module while retaining only shell composition coverage in App.
Refs #246
Co-Authored-By: OpenAI Codex <codex@openai.com>
Move Dashboard rendering, health and on-air helpers, and detailed behavior tests into a screen-owned module while retaining only shell composition coverage in App.
Refs #246
Co-Authored-By: OpenAI Codex <codex@openai.com>
Exercise default-deco persistence and errors plus the complete action, add-dialog, and edit-field kind matrices identified by cold review.
Refs #245
Co-Authored-By: OpenAI Codex <codex@openai.com>
Disable and gate remote connection draft inputs while a save owns the current revision. Exercise the real App-owned route transition after a successful save so the regression proves the editor unmounts without a dirty prompt.
Refs #344
Co-Authored-By: OpenAI Codex <codex@openai.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>
Cold-review follow-ups (both non-blocking):
- Low: the TopBar "Add Trakt List" button was a silent no-op on the
/app/trakt-lists/{id} detail sub-route (setAddOpen state isn't rendered by
the editor branch, and the screen is keyed by pathname so the state
wouldn't survive a navigate). Guard on editingId: route back to the list
from the detail view, open the dialog from the list.
- Nit: the invariant test only spot-checked 2 screens. Replaced with a
data-driven it.each over the 4 URL-navigating create screens (channels,
filler, ffmpeg, watermarks) asserting each banner actually navigates — a
typo'd route id now fails red. Docs wording corrected to match.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The shell TopBar rendered a primary-action button (Plus icon) for every
screen, but only SchedulesScreen subscribed to its ctv:primary-action event
— so every other screen's button was dead (a labelled no-op, or a bare "+"
for the ~10 routes whose primaryAction was '').
Resolution (issue #238): the Plus-icon button is a "create new item"
affordance. Keep + wire it only on the 8 list screens with a single create
flow (channels, schedules, multi/rerun collections, trakt lists, filler
presets, ffmpeg profiles, watermarks) via a shared usePrimaryAction hook
(web/src/primaryAction.ts); drop it (primaryAction: '') everywhere else —
where the action isn't a create (Save/Refresh/Play/Validate/Reset/Scan, all
of which have correct in-body controls), is ambiguous (collections tabs), a
silent no-op (builder, playlists), or misplaced (dashboard, libraries). The
TopBar now renders the button only when primaryAction is non-empty.
Also relabels the apiKey route's stale post-#295 "Save key"/description.
Docs: spa-conventions.md §10 + decisions.md 2026-07-12.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The server returns MachineKeyResponse(string ApiKey) -> JSON { apiKey }, but the
hand-written SPA MachineKey type declared { key } and ApiKeyScreen read result.key,
which would be undefined at runtime (blank key + empty copy). Mocked unit tests
passed against the wrong shape. Align the type, the screen, and both test mocks to
the real { apiKey } contract.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the API-key SPA model with the session-cookie auth from PR1:
- client.ts: stop sending X-Api-Key; attach X-Csrf on mutating verbs (POST/PUT/
PATCH/DELETE); add suppressUnauthorizedSignal to skip the global 401 signal on
expected wrong-credentials 401s (login / change-password).
- api/auth.ts: hand-written wire types (AuthConfig/AuthSession/MachineKey — the
auth surface is IgnoreApi, deliberately not in generated types) + endpoint fns
(getAuthConfig/getAuthSession/login/setup/logout/changePassword/getMachineKey);
keep the notifyUnauthorized/subscribeUnauthorized 401 signal; add
clearLegacyStoredApiKey. Legacy get/set/clearStoredApiKey retained ONLY so the
still-shipping ApiKeyScreen (machine-key slice) compiles without a cross-slice
conflict — the client no longer reads them.
- AuthGate.tsx: boot gate wrapping <App/> outside the shell (mints no URL, deep
links survive login). checking -> setup | login | ready | error, with a safe
default AuthContext so App.test.tsx renders without a provider; config-fetch
failure lands on an explicit error+Retry, never a blank screen.
- LoginScreen / SetupScreen: shell-less centered cards; inline 401 / 409 handling;
SSO button + local-form gating from AuthConfig.
- UnauthorizedBanner: rewritten to prompt re-login (passive; consults the
unsaved-changes guard before flipping the gate).
- UserMenu: TopBar sign-out (guard -> logout -> signOut), mounted next to
ConnectMenu.
- main.tsx: wrap <AuthGate><App/></AuthGate> inside StrictMode.
Tests: client/auth/AuthGate/LoginScreen/SetupScreen/UnauthorizedBanner/UserMenu
(723 pass). Lint + build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cold-review nit fixes on PR #298:
- useCollectionsScan.scan() now guards on the whole family being busy (active OR
any pending key of that family), not just the exact key — a sibling source of a
family with a scan in flight no longer fires a redundant (benign-409) POST.
- LibrariesScreen ExternalCollectionsSection disables every row of a family that
has a pending or active scan (derives pendingFamilies from pendingKeys), matching
Blazor's instant all-rows-disabled behavior instead of waiting a poll RTT.
- Rewrite the promote test to actually observe the optimistic-pending window via a
deferred POST (was only asserting the promoted end state), and add a test proving
a sibling-source click fires no second POST while the family is pending.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add GET /api/media-sources/collections-scan-status (MediaSourcesController →
GetCollectionsScanStatus handler) reporting which media-source families
(plex/jellyfin/emby) currently hold their external-collections scan lock,
reading IEntityLocker.Are{X}CollectionsLocked(). The lock is family-global
(no source id) and boolean (no percent), so the DTO carries just {family} and
returns only active families — the counterpart to GET /api/libraries/scan-status.
SPA: useCollectionsScan now polls this endpoint and reconciles optimistic
pending against the active-family set (seeding on mount so an in-progress scan
disables buttons immediately), using the same grace-tick helper as library
scans (now generic over the pending key type). Drops COLLECTIONS_PENDING_TIMEOUT_MS
— a long deep scan no longer re-enables the button early, and a fast scan no
longer wedges it disabled for the full timeout. A row shows Scanning when its
family is active or it has an in-grace optimistic pending key.
Tests: handler (3), controller route+delegation (2), SPA api fn + hook reconcile
(mount-seed / 202-promote / 409-keeps-disabled / 404-error). OpenAPI + TS types
regenerated. Docs: api-conventions §3b, blazor-route-parity §5, decisions.md.
Unblocks #91b (arc item 4): Libraries.razor's collections-scan affordance now
has full authoritative parity.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mint ChannelDetailResponseModel (faithful detail DTO exposing the raw editable
field set the channel editor reads: raw FFmpegProfileId/WatermarkId/FallbackFillerId
ids, the mode enums, logo, playoutCount, id) and route GetById/Create/Update through
it, replacing the lean list ChannelResponseModel that resolved the profile to a name
and dropped the editable ids (a functional regression for draftFromChannel). The lean
ChannelResponseModel stays unchanged for GET /api/channels. webEncodedName dropped
(SPA never reads it). Logo is mirrored as a Core ChannelLogoResponseModel since the
Application ArtworkContentTypeModel can't be referenced from Core.
Repoint the hand-written SPA client aliases now that the VMs are gone from the schema:
Channel -> ChannelDetailResponseModel, MediaCollection/SmartCollection -> *ResponseModel,
ProgramSchedule -> ProgramScheduleResponseModel. Fix#288 honest-nullability test fallout
in search.test.ts (null -> [] for now-non-null id arrays). Include the already-on-disk
playouts.ts WithDayNames removal and regenerate v1.json + v1.d.ts + endpoint-index.md
(authoritative final regen; the reset endpoint's {channelNumber}->{id} re-key surfaces
in the generated docs and the OpenApi error-contract test).
Refs #288#197
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GetById/Create/Update return ChannelResponseModel via new GetChannelByIdForApi
read-side query; POST /api/channels/{id:int}/playout/reset (new
GetPlayoutIdByChannelId; by-number kept for HlsSessionWorker broadcast).
Refs #288#197
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
S4 stored-XSS + S9 upload-size DoS from the #197 cold API review.
The artwork path trusted client-supplied content types at both ends: upload
validated only the declared multipart Content-Type (never decoded the bytes),
and serving reflected a client `?contentType=` straight into the response
Content-Type on unauthenticated GET sinks (/iptv/logos, /artwork/watermarks).
Chain: upload <script> bytes as image/png -> GET ...?contentType=text/html
serves them as HTML in-origin. nosniff (#279) does not help because the server
explicitly declares text/html.
- Upload: derive the content type from the bytes via SkiaSharp SKCodec
(header-only, no decode -> no decompression-bomb path); reject non-images 422.
New ErsatzTV.Core/Images/ImageContentTypes as the single allow-list source.
Dropped the untrusted declared Content-Type from the UploadArtwork command.
- Serve: removed the ?contentType= reflection structurally -- dropped ContentType
from GetCachedImagePath and the [FromQuery] binding on GetImage/GetWatermark;
the handler always sniffs the file, defaulting application/octet-stream.
ArtworkContentTypeModel.UrlWithContentType is now the bare path; SPA previews
no longer append the query.
- Defense-in-depth: channel-logo / watermark {path, contentType} DTOs run through
ArtworkContentTypeModel.Sanitized(), blanking non-allow-listed types on write.
- S9: Kestrel MaxRequestBodySize from ETV_MAXIMUM_UPLOAD_MB rejects oversized
bodies during read (controller file.Length check kept as friendly-error backstop).
Both serve sinks are IgnoreApi, so no OpenAPI change. Tests: byte-sniff accept/
reject, Sanitized() allow-list, Location no longer carries ?contentType=.
Docs: api-conventions §4a + decisions.md 2026-07-12.
Refs #283#197#66
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>