diff --git a/ErsatzTV.Application/Channels/CreateAutoTunedChannels.cs b/ErsatzTV.Application/Channels/CreateAutoTunedChannels.cs index 509a5cae3..57e88c735 100644 --- a/ErsatzTV.Application/Channels/CreateAutoTunedChannels.cs +++ b/ErsatzTV.Application/Channels/CreateAutoTunedChannels.cs @@ -1,3 +1,4 @@ +using ErsatzTV.Application.Artworks; using ErsatzTV.Core.Domain; using MediatR; @@ -8,11 +9,17 @@ public record CreateAutoTunedChannels( string Group, List Channels) : IRequest; +// The batch-level TemplateId is the default; any per-channel field set here overrides it for that one +// channel. Advanced/Logo/TemplateId are all optional so the older positional {axis, value, name, number} +// form (and every existing caller/test) keeps compiling and behaving identically. public record AutoTuneChannelSelection( AutoTuneAxis Axis, string Value, string Name, - string Number); + string Number, + int? TemplateId = null, + ArtworkContentTypeModel Logo = null, + CreateChannelFromLineupAdvancedOptions Advanced = null); public record AutoTuneResult(List Results) { diff --git a/ErsatzTV.Application/Channels/CreateAutoTunedChannelsHandler.cs b/ErsatzTV.Application/Channels/CreateAutoTunedChannelsHandler.cs index e523b558a..3a0b0543c 100644 --- a/ErsatzTV.Application/Channels/CreateAutoTunedChannelsHandler.cs +++ b/ErsatzTV.Application/Channels/CreateAutoTunedChannelsHandler.cs @@ -43,7 +43,23 @@ public class CreateAutoTunedChannelsHandler(ISender mediator) } string query = AutoTuneAxisMap.GenerateQuery(selection.Axis, selection.Value); - PlaybackOrder order = AutoTuneAxisMap.PlaybackOrderFor(selection.Axis); + + // The axis default (SeasonEpisode for a single show, Shuffle for a genre) is the playback order + // unless the DetailPanel set an explicit per-channel override. Any other Advanced field the caller + // set is layered on top of the template by CreateChannelFromLineup's `advanced.X ?? template.X` + // stamp-at-create contract, so we only have to fill in the axis-derived PlaybackOrder default here. + PlaybackOrder axisOrder = AutoTuneAxisMap.PlaybackOrderFor(selection.Axis); + CreateChannelFromLineupAdvancedOptions advanced = + (selection.Advanced ?? new CreateChannelFromLineupAdvancedOptions()) with + { + PlaybackOrder = selection.Advanced?.PlaybackOrder ?? axisOrder + }; + + // Per-channel template override falls back to the batch template. + int effectiveTemplateId = selection.TemplateId ?? templateId; + + // Per-channel uploaded channel image; None = generate the on-the-fly fallback logo at serve time. + ArtworkContentTypeModel logo = selection.Logo ?? ArtworkContentTypeModel.None; // 1. Create the smart collection that drives this channel. Either scResult = @@ -67,11 +83,11 @@ public class CreateAutoTunedChannelsHandler(ISender mediator) selection.Number, group, string.Empty, - ArtworkContentTypeModel.None, + logo, IsEnabled: true, ShowInEpg: true, - templateId, - new CreateChannelFromLineupAdvancedOptions(PlaybackOrder: order), + effectiveTemplateId, + advanced, [ new CreateChannelFromLineupItem( LibraryBrowseMediaType.SmartCollection, diff --git a/ErsatzTV.Tests/Application/Channels/CreateAutoTunedChannelsHandlerTests.cs b/ErsatzTV.Tests/Application/Channels/CreateAutoTunedChannelsHandlerTests.cs index af076b063..855bb876e 100644 --- a/ErsatzTV.Tests/Application/Channels/CreateAutoTunedChannelsHandlerTests.cs +++ b/ErsatzTV.Tests/Application/Channels/CreateAutoTunedChannelsHandlerTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using ErsatzTV.Application.Artworks; using ErsatzTV.Application.Channels; using ErsatzTV.Application.MediaCollections; using ErsatzTV.Core; @@ -71,6 +72,128 @@ public class CreateAutoTunedChannelsHandlerTests Arg.Any()); } + [Test] + public async Task Per_Channel_Overrides_Are_Threaded_Into_The_Create_Command() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns((Either) + new CreateChannelFromLineupResponseModel(88, null, 1, 2)); + + var result = await Handle(new CreateAutoTunedChannels( + TemplateId: 3, Group: "Auto-Tuned", + new List + { + new( + AutoTuneAxis.TvGenre, "Comedy", "Comedy", "500", + TemplateId: 11, + Logo: new ArtworkContentTypeModel("iptv/logos/comedy.png", "image/png"), + Advanced: new CreateChannelFromLineupAdvancedOptions( + PlaybackOrder: PlaybackOrder.Chronological, + FFmpegProfileId: 42, + ShuffleScheduleItems: true)) + })); + + result.CreatedCount.ShouldBe(1); + + await _mediator.Received().Send( + Arg.Is(c => + // per-channel template wins over the batch template + c.TemplateId == 11 && + // explicit per-channel playback order wins over the axis default (Shuffle) + c.Advanced.PlaybackOrder == PlaybackOrder.Chronological && + c.Advanced.FFmpegProfileId == 42 && + c.Advanced.ShuffleScheduleItems == true && + // uploaded logo threaded through + c.Logo.Path == "iptv/logos/comedy.png" && + c.Logo.ContentType == "image/png"), + Arg.Any()); + } + + [Test] + public async Task Advanced_Without_Playback_Order_Keeps_The_Axis_Default() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns((Either) + new CreateChannelFromLineupResponseModel(88, null, 1, 2)); + + // Advanced is set but leaves PlaybackOrder null: the axis default (SeasonEpisode for a single + // show) must still be filled in, not silently dropped to null. + var result = await Handle(new CreateAutoTunedChannels( + TemplateId: 3, Group: "Auto-Tuned", + new List + { + new( + AutoTuneAxis.TvShow, "The Office", "The Office", "500", + Advanced: new CreateChannelFromLineupAdvancedOptions(WatermarkId: 5)) + })); + + result.CreatedCount.ShouldBe(1); + + await _mediator.Received().Send( + Arg.Is(c => + c.TemplateId == 3 && + c.Advanced.PlaybackOrder == PlaybackOrder.SeasonEpisode && + c.Advanced.WatermarkId == 5), + Arg.Any()); + } + + [Test] + public async Task No_Overrides_Uses_Batch_Template_Axis_Order_And_No_Logo() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns((Either) + new CreateChannelFromLineupResponseModel(88, null, 1, 2)); + + await Handle(new CreateAutoTunedChannels( + TemplateId: 3, Group: "Auto-Tuned", + new List + { + new(AutoTuneAxis.TvGenre, "Comedy", "Comedy", "500") + })); + + await _mediator.Received().Send( + Arg.Is(c => + c.TemplateId == 3 && + c.Advanced.PlaybackOrder == PlaybackOrder.Shuffle && + c.Advanced.FFmpegProfileId == null && + c.Logo.Path == string.Empty), + Arg.Any()); + } + + [Test] + public async Task A_Bad_Per_Channel_Override_Fails_Only_That_Channel_Not_The_Batch() + { + // First channel's override is rejected downstream (e.g. a non-existent FFmpegProfileId); + // the second, override-free channel must still be Created. Keyed on channel number so the + // two CreateChannelFromLineup sends resolve to different outcomes. + _mediator.Send( + Arg.Is(c => c.Number == "500"), + Arg.Any()) + .Returns((Either) + BaseError.New("FFmpegProfile 999 does not exist.")); + _mediator.Send( + Arg.Is(c => c.Number == "501"), + Arg.Any()) + .Returns((Either) + new CreateChannelFromLineupResponseModel(88, null, 1, 2)); + + var result = await Handle(new CreateAutoTunedChannels( + TemplateId: 3, Group: "Auto-Tuned", + new List + { + new( + AutoTuneAxis.TvGenre, "Comedy", "Bad", "500", + Advanced: new CreateChannelFromLineupAdvancedOptions(FFmpegProfileId: 999)), + new(AutoTuneAxis.TvGenre, "Drama", "Good", "501") + })); + + result.FailedCount.ShouldBe(1); + result.CreatedCount.ShouldBe(1); + result.Results[0].Status.ShouldBe(AutoTuneOutcomeStatus.Failed); + result.Results[1].Status.ShouldBe(AutoTuneOutcomeStatus.Created); + result.Results[1].ChannelId.ShouldBe(88); + } + [Test] public async Task Number_Collision_Is_Skipped_Not_Failed() { diff --git a/ErsatzTV.Tests/Controllers/AutoTuneRequestsTests.cs b/ErsatzTV.Tests/Controllers/AutoTuneRequestsTests.cs new file mode 100644 index 000000000..54bf0dd44 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/AutoTuneRequestsTests.cs @@ -0,0 +1,52 @@ +using ErsatzTV.Application.Artworks; +using ErsatzTV.Application.Channels; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Scheduling; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Controllers; + +[TestFixture] +public class AutoTuneRequestsTests +{ + [Test] + public void ToCommand_Maps_Overrides_And_Sanitizes_A_Bad_Logo_Content_Type() + { + var request = new AutoTunedChannelRequest( + AutoTuneAxis.TvGenre, + "Comedy", + "Comedy", + "500", + TemplateId: 11, + // A hostile content type must not survive to the command (stored-XSS defense, #283) — + // the request mirrors CreateChannelFromLineupRequest's Sanitized() call. + Logo: new ArtworkContentTypeModel("iptv/logos/comedy.png", "text/html"), + Advanced: new CreateChannelFromLineupAdvancedOptionsRequest( + PlaybackOrder: PlaybackOrder.Chronological, + FFmpegProfileId: 42)); + + AutoTuneChannelSelection command = request.ToCommand(); + + command.TemplateId.ShouldBe(11); + command.Logo.Path.ShouldBe("iptv/logos/comedy.png"); + command.Logo.ContentType.ShouldBe(string.Empty); // blanked + command.Advanced.ShouldNotBeNull(); + command.Advanced.PlaybackOrder.ShouldBe(PlaybackOrder.Chronological); + command.Advanced.FFmpegProfileId.ShouldBe(42); + } + + [Test] + public void ToCommand_Without_Overrides_Leaves_Command_Fields_At_Defaults() + { + var request = new AutoTunedChannelRequest(AutoTuneAxis.TvShow, "The Office", "The Office", "500"); + + AutoTuneChannelSelection command = request.ToCommand(); + + command.TemplateId.ShouldBeNull(); + command.Advanced.ShouldBeNull(); + // An omitted logo maps to None (empty path), not null. + command.Logo.ShouldBe(ArtworkContentTypeModel.None); + } +} diff --git a/ErsatzTV/Controllers/Api/ChannelController.cs b/ErsatzTV/Controllers/Api/ChannelController.cs index 6a982c231..5fe037530 100644 --- a/ErsatzTV/Controllers/Api/ChannelController.cs +++ b/ErsatzTV/Controllers/Api/ChannelController.cs @@ -235,6 +235,12 @@ public class ChannelController( [HttpPost("/api/v1/channels/auto-tune", Name = "CreateAutoTunedChannels")] [Tags("Channels")] [EndpointSummary("Create auto-tuned channels")] + [EndpointDescription( + "Bulk-creates the selected auto-tuned channels. Each channel is independent — one failure never " + + "aborts the batch (see the per-channel Created/Skipped/Failed outcomes). Per channel, the optional " + + "templateId, advanced overrides (same set as the manual Channel Builder; advanced values win over " + + "the template) and an uploaded logo image override the batch defaults; an omitted field keeps the " + + "batch template, the axis-derived playback order and the on-the-fly fallback logo.")] [EndpointGroupName("general")] [ProducesResponseType(typeof(AutoTuneResultResponseModel), StatusCodes.Status200OK)] public async Task CreateAutoTuned( diff --git a/ErsatzTV/Controllers/Api/Requests/AutoTuneRequests.cs b/ErsatzTV/Controllers/Api/Requests/AutoTuneRequests.cs index 9f216b728..17d5cd6ea 100644 --- a/ErsatzTV/Controllers/Api/Requests/AutoTuneRequests.cs +++ b/ErsatzTV/Controllers/Api/Requests/AutoTuneRequests.cs @@ -1,3 +1,4 @@ +using ErsatzTV.Application.Artworks; using ErsatzTV.Application.Channels; using ErsatzTV.Core.Domain; @@ -20,11 +21,27 @@ public record CreateAutoTunedChannelsRequest( new(TemplateId, Group, (Channels ?? []).Select(c => c.ToCommand()).ToList()); } +// Optional per-channel overrides (DetailPanel, #385): TemplateId overrides the batch template; Advanced +// carries the same override set as the manual Channel Builder (reuses CreateChannelFromLineupAdvancedOptionsRequest); +// Logo is an uploaded channel image ({path, contentType}). All optional — an omitted field keeps the PR1 +// behavior (batch template, axis-derived playback order, no logo). Note: no #nullable pragma here (matches the +// rest of this file), so the new reference-type params carry no `?` annotation; they default to null. public record AutoTunedChannelRequest( AutoTuneAxis Axis, string Value, string Name, - string Number) + string Number, + int? TemplateId = null, + ArtworkContentTypeModel Logo = null, + CreateChannelFromLineupAdvancedOptionsRequest Advanced = null) { - public AutoTuneChannelSelection ToCommand() => new(Axis, Value, Name, Number); + public AutoTuneChannelSelection ToCommand() => + new( + Axis, + Value, + Name, + Number, + TemplateId, + (Logo ?? ArtworkContentTypeModel.None).Sanitized(), + Advanced?.ToCommand()); } diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 25bb52dd5..267c5d288 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -2478,6 +2478,7 @@ "Channels" ], "summary": "Create auto-tuned channels", + "description": "Bulk-creates the selected auto-tuned channels. Each channel is independent — one failure never aborts the batch (see the per-channel Created/Skipped/Failed outcomes). Per channel, the optional templateId, advanced overrides (same set as the manual Channel Builder; advanced values win over the template) and an uploaded logo image override the batch defaults; an omitted field keeps the batch template, the axis-derived playback order and the on-the-fly fallback logo.", "operationId": "CreateAutoTunedChannels", "requestBody": { "content": { @@ -22985,6 +22986,19 @@ "null", "string" ] + }, + "templateId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "logo": { + "$ref": "#/components/schemas/ArtworkContentTypeModel" + }, + "advanced": { + "$ref": "#/components/schemas/CreateChannelFromLineupAdvancedOptionsRequest" } } }, diff --git a/docs/api-conventions.md b/docs/api-conventions.md index fdba3a13f..7d02931b9 100644 --- a/docs/api-conventions.md +++ b/docs/api-conventions.md @@ -331,6 +331,16 @@ the TV axes (`ItemCount` = query-matching episodes, **not** the show's total), m axis. Paging is clamped (`pageNum` floored at 0; `pageSize` defaults to 100, clamped 1–200) per the §1 Logs precedent. See `docs/decisions.md` 2026-07-17 (#384) for the search-index-vs-EF-enumeration rationale. +**DTO expansion (#385, Auto-Tune DetailPanel per-channel overrides)**: no new endpoint — the existing +`POST /api/v1/channels/auto-tune` request (`AutoTunedChannelRequest`) gained three **optional** per-channel +fields: `templateId` (overrides the batch template), `advanced` (reuses the manual Channel Builder's +`CreateChannelFromLineupAdvancedOptionsRequest` verbatim — same 24-field override set + `ToCommand()`), and +`logo` (an uploaded `{path, contentType}` image, `Sanitized()` at the request boundary per §4a). Omitting each +preserves PR1 behavior exactly (batch template, axis-derived playback order, on-the-fly fallback logo). This is +a second caller of the `from-lineup` advanced-options wire contract — do **not** mint a parallel DTO. Per-source +rotation weights are **not** here (deferred to #425; they need a MultiCollection redesign — see `docs/decisions.md` +2026-07-17 #385). Regenerated the OpenAPI trio (v1.json/v1.d.ts/endpoint-index) even though only schemas changed. + **Resolved wart (#287)**: `DayOfWeek` previously serialized as an integer in the OpenAPI schema while the runtime JSON payload is the enum's **name string** ("Sunday".."Saturday"). It is now added to `Startup.UseStringEnumSchemas`'s hand-list, so the "v1" schema emits it as a **string enum** matching diff --git a/docs/decisions.md b/docs/decisions.md index 1c6d8eb99..1ece387a1 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -90,6 +90,7 @@ in-file entries. - [2026-07-17 — No persistent compiler servers in CI; every `services:` container gets an explicit cap; #390's small-lane move reversed (#406)](#2026-07-17--no-persistent-compiler-servers-in-ci-every-services-container-gets-an-explicit-cap-390s-small-lane-move-reversed-406) - [2026-07-17 — Channel health on the API = the raw `PlayoutCount` fact on the list DTO, not a derived status enum (#72)](#2026-07-17--channel-health-on-the-api--the-raw-playoutcount-fact-on-the-list-dto-not-a-derived-status-enum-72) - [2026-07-17 — Weighted / fair-share distribution is a new `WeightedShuffle` order; `ShuffleInOrder` is anti-clumping, not fair-share (#70)](#2026-07-17--weighted--fair-share-distribution-is-a-new-weightedshuffle-order-shuffleinorder-is-anti-clumping-not-fair-share-70) +- [2026-07-17 — Auto-Tune per-channel overrides reuse the Channel Builder advanced-options DTO; weights + bug-colour logo split out to #425 (#385)](#2026-07-17--auto-tune-per-channel-overrides-reuse-the-channel-builder-advanced-options-dto-weights--bug-colour-logo-split-out-to-425-385) --- @@ -1488,6 +1489,7 @@ and "no single show dominates" — as **one** order, because fair-share is the e SmartCollection, so its "sources" are members of a single collection, and a `PlaylistItem` cannot even reference a search query. Nothing here forecloses it — the enumerator reads weights off `CollectionWithItems` source-agnostically. + ## 2026-07-17 — Docs-only CI skip gates STEPS in always-running required jobs, never `if:`-skips them (#416) A change touching only `docs/**` or `*.md` ran the entire `docker-build.yml` matrix (`test`, @@ -1557,3 +1559,47 @@ the review-process half (point reviewers at `git show :`, never the b guidance. Same hook family as H11 (rebase-not-merge) / H12 (issue-qualification), same "#303 make the rule a hook, not prose to remember" throughline. Verified: dirty PR-file → block; clean tree → allow; dirty non-PR file → allow; escape hatch → allow. + +## 2026-07-17 — Auto-Tune per-channel overrides reuse the Channel Builder advanced-options DTO; weights + bug-colour logo split out to #425 (#385) + +The Auto-Tune DetailPanel (#383) makes each proposed channel individually editable before bulk-create. +The backend for that (#385) split cleanly along a "structural cost" line, and only the additive half +shipped here; the rest is deliberately deferred rather than forced. + +- **Shipped now (additive, over the frozen `/api/v1`):** `POST /api/v1/channels/auto-tune`'s + `AutoTunedChannelRequest` gained three optional per-channel fields — `templateId`, `advanced`, and an + uploaded `logo` image. Omitting each keeps PR1 behavior exactly (batch template, axis-derived playback + order, on-the-fly fallback logo), so the change is backward-compatible and the older positional + `{axis, value, name, number}` form still binds. + - **`advanced` reuses the manual Channel Builder's `CreateChannelFromLineupAdvancedOptionsRequest` + verbatim** — the same 24-field override set, the same `ToCommand()`, and the same + `advanced.X ?? template.X` stamp-at-create contract that `POST /api/v1/channels/from-lineup` already + honors. Auto-tune is now just a second caller of that wire contract; we did **not** mint a parallel + 24-field DTO. The one auto-tune-specific rule: the axis default (`SeasonEpisode` for a single show, + `Shuffle` for a genre) fills `PlaybackOrder` only when the caller left it null, so an explicit + per-channel order wins but an unset one is never dropped. + - **`logo` is the uploaded channel image**, forwarded to `CreateChannelFromLineup.Logo` (persisted as an + `ArtworkKind.Logo` artwork) and run through `ArtworkContentTypeModel.Sanitized()` at the request + boundary — the same stored-XSS defense (#283) the Channel Builder uses; a hostile content type never + reaches the command. + - Per-channel independence is preserved: `templateId`/`advanced`/`logo` are resolved per channel inside + `CreateAutoTunedChannelsHandler.CreateOne`, so one channel's bad override still yields a per-channel + `Failed`/`Skipped` outcome without aborting the batch. + +- **Deferred to #425 — per-source rotation weights + query corrections (exclude / add-untagged).** This is + a structural redesign, not effort: #70's `WeightedShuffle` reads weights only off + `MultiCollectionItem`/`MultiCollectionSmartItem` rows, but an auto-tuned channel is backed by a **single + SmartCollection** (→ `GetFakeMultiCollectionCollections`, every fake group forced to `Weight = 1`), which + gives fair-share but **cannot express differing per-source weights**. Honoring them requires auto-tune to + build a **MultiCollection of per-source SmartCollections** (one query per weighted source), which + contradicts the documented "one live query per channel" design and lands new structure in the scheduling + subsystem. That is the `[PLAN-MODE]` design call the #385 body flagged; it belongs in its own issue with + a recorded design decision, so it moved to **#425** (consumed by the SPA #386). + +- **Deferred — bug initials + fallback colour generated logo.** The generated logo (`/iptv/logos/gen`) is a + stateless on-the-fly render used by both the XMLTV `` and the FFmpeg `WatermarkSelector` bug, with + **no persisted bug-initials/colour state on `Channel`**. Making it configurable needs either new `Channel` + columns (a dual-provider migration) wired through the watermark pipeline, or create-time artwork-file + materialization — neither is the additive plumbing this slice was scoped to, and it is a channel-wide + capability rather than an auto-tune concern. Left for its own issue / the SPA branding work; the uploaded + `logo` above already covers the on-screen bug for channels that supply an image. diff --git a/docs/domain-model.md b/docs/domain-model.md index 7ad86c651..79b819ac8 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -79,7 +79,7 @@ Channel (1) ──< Playout (0..N per channel; ChannelPlayoutSource distinguishe | **MediaItemState** | Health flag on a media item: Normal/FileNotFound/Unavailable/RemoteOnly. Drives the Trash screen. | `MediaItemState` | `/app/trash` | | **PlayoutItem** | One materialized, built entry in a playout's timeline (the actual thing that will play at a given time). | `PlayoutItem` | (generated, not directly edited) | | **PlayoutHistory** | Rotation/rerun bookkeeping per block (`BlockId`) + collection `Key`/`ChildKey`, used by block-playout schedulers to avoid repeats; inspectable via Troubleshooting. | `PlayoutHistory` | `/app/troubleshooting/blocks` | -| **Channel concepts** | `Number` (validated by `Channel.NumberValidator` regex), `Group`, `PlayoutSource` (Generated/Mirror; Mirror channels relay another channel via `MirrorSourceChannelId`+`PlayoutOffset`), `PlayoutMode` (Continuous/OnDemand), `TranscodeMode` (OnDemand only, today), `IdleBehavior` (StopOnDisconnect/KeepRunning), `StreamingMode` (TransportStream/HttpLiveStreamingDirect/HttpLiveStreamingSegmenter/TransportStreamHybrid). | `Channel` | `/app/channels`, `/app/edit-channel/{id}`, `/app/new-channel`, `/app/auto-tune` (bulk-generate from library metadata, #69; per-channel DetailPanel content-source members read via `GET /api/v1/channels/auto-tune/members`, #384) | +| **Channel concepts** | `Number` (validated by `Channel.NumberValidator` regex), `Group`, `PlayoutSource` (Generated/Mirror; Mirror channels relay another channel via `MirrorSourceChannelId`+`PlayoutOffset`), `PlayoutMode` (Continuous/OnDemand), `TranscodeMode` (OnDemand only, today), `IdleBehavior` (StopOnDisconnect/KeepRunning), `StreamingMode` (TransportStream/HttpLiveStreamingDirect/HttpLiveStreamingSegmenter/TransportStreamHybrid). | `Channel` | `/app/channels`, `/app/edit-channel/{id}`, `/app/new-channel`, `/app/auto-tune` (bulk-generate from library metadata, #69; per-channel DetailPanel content-source members read via `GET /api/v1/channels/auto-tune/members`, #384; per-channel `templateId`/`advanced`/`logo` overrides accepted by `POST /api/v1/channels/auto-tune`, #385 — per-source rotation weights deferred to #425) | | **Channel health / `PlayoutCount`** (#72) | Whether a channel can play at all. `PlayoutCount` (channel's own playouts, **plus the mirror source's** when `PlayoutSource is Mirror` — computed by `Mapper.GetPlayoutsCount`) rides on both `ChannelResponseModel` (list) and `ChannelDetailResponseModel`; `0` ⇒ the channel can never play, rendered as a "No playout" badge + a matching **No playout** filter on the channels list (both name only the one fault the API can prove — a broader "Problems" label would read as a false all-clear to a user whose *other* fault classes below are uncomputed). It is a **raw fact, not a status enum** — see `decisions.md` 2026-07-17. Distinct from `/api/v1/channels/state`'s `OnAir`, which is runtime liveness ("someone is streaming right now"), not "would play if tuned". Empty-schedule, broken-source and auto-tuned-vs-user origin are deliberately **not** computed (see that decision entry for why each is unsafe today). | `Channel.Playouts` | `/app/channels` (read-only signal) | | **Guide / EPG (XMLTV)** | Per-channel programme guide generated from playout items; channels with `ShowInEpg=false` are excluded. | `GetChannelGuideHandler` | `/app/guide` (viewer); settings at `/app/settings/xmltv` | | **M3U** | The channel lineup playlist Jellyfin/Dispatcharr consume. | `ChannelPlaylist.ToM3U()` | — | diff --git a/web/src/api/generated/v1.d.ts b/web/src/api/generated/v1.d.ts index b098b2c76..9fd3bffd6 100644 --- a/web/src/api/generated/v1.d.ts +++ b/web/src/api/generated/v1.d.ts @@ -71,6 +71,9 @@ export interface components { "value": null | string; "name": null | string; "number": null | string; + "templateId"?: null | number; + "logo"?: components["schemas"]["ArtworkContentTypeModel"]; + "advanced"?: components["schemas"]["CreateChannelFromLineupAdvancedOptionsRequest"]; }; "AutoTuneProposalResponseModel": { "axis": string;