feat(385): per-channel overrides in auto-tune bulk-create #428

Merged
timothy merged 2 commits from feat/385-autotune-overrides into main 2026-07-18 00:03:53 +02:00
11 changed files with 302 additions and 8 deletions
@@ -1,3 +1,4 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core.Domain;
using MediatR;
@@ -8,11 +9,17 @@ public record CreateAutoTunedChannels(
string Group,
List<AutoTuneChannelSelection> Channels) : IRequest<AutoTuneResult>;
// 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<AutoTuneChannelOutcome> Results)
{
@@ -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<BaseError, SmartCollectionViewModel> 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,
@@ -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<CancellationToken>());
}
[Test]
public async Task Per_Channel_Overrides_Are_Threaded_Into_The_Create_Command()
{
_mediator.Send(Arg.Any<CreateChannelFromLineup>(), Arg.Any<CancellationToken>())
.Returns((Either<BaseError, CreateChannelFromLineupResponseModel>)
new CreateChannelFromLineupResponseModel(88, null, 1, 2));
var result = await Handle(new CreateAutoTunedChannels(
TemplateId: 3, Group: "Auto-Tuned",
new List<AutoTuneChannelSelection>
{
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<CreateChannelFromLineup>(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<CancellationToken>());
}
[Test]
public async Task Advanced_Without_Playback_Order_Keeps_The_Axis_Default()
{
_mediator.Send(Arg.Any<CreateChannelFromLineup>(), Arg.Any<CancellationToken>())
.Returns((Either<BaseError, CreateChannelFromLineupResponseModel>)
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<AutoTuneChannelSelection>
{
new(
AutoTuneAxis.TvShow, "The Office", "The Office", "500",
Advanced: new CreateChannelFromLineupAdvancedOptions(WatermarkId: 5))
}));
result.CreatedCount.ShouldBe(1);
await _mediator.Received().Send(
Arg.Is<CreateChannelFromLineup>(c =>
c.TemplateId == 3 &&
c.Advanced.PlaybackOrder == PlaybackOrder.SeasonEpisode &&
c.Advanced.WatermarkId == 5),
Arg.Any<CancellationToken>());
}
[Test]
public async Task No_Overrides_Uses_Batch_Template_Axis_Order_And_No_Logo()
{
_mediator.Send(Arg.Any<CreateChannelFromLineup>(), Arg.Any<CancellationToken>())
.Returns((Either<BaseError, CreateChannelFromLineupResponseModel>)
new CreateChannelFromLineupResponseModel(88, null, 1, 2));
await Handle(new CreateAutoTunedChannels(
TemplateId: 3, Group: "Auto-Tuned",
new List<AutoTuneChannelSelection>
{
new(AutoTuneAxis.TvGenre, "Comedy", "Comedy", "500")
}));
await _mediator.Received().Send(
Arg.Is<CreateChannelFromLineup>(c =>
c.TemplateId == 3 &&
c.Advanced.PlaybackOrder == PlaybackOrder.Shuffle &&
c.Advanced.FFmpegProfileId == null &&
c.Logo.Path == string.Empty),
Arg.Any<CancellationToken>());
}
[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<CreateChannelFromLineup>(c => c.Number == "500"),
Arg.Any<CancellationToken>())
.Returns((Either<BaseError, CreateChannelFromLineupResponseModel>)
BaseError.New("FFmpegProfile 999 does not exist."));
_mediator.Send(
Arg.Is<CreateChannelFromLineup>(c => c.Number == "501"),
Arg.Any<CancellationToken>())
.Returns((Either<BaseError, CreateChannelFromLineupResponseModel>)
new CreateChannelFromLineupResponseModel(88, null, 1, 2));
var result = await Handle(new CreateAutoTunedChannels(
TemplateId: 3, Group: "Auto-Tuned",
new List<AutoTuneChannelSelection>
{
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()
{
@@ -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);
}
}
@@ -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<IActionResult> CreateAutoTuned(
@@ -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());
}
+14
View File
@@ -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"
}
}
},
+10
View File
@@ -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
+46
View File
@@ -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 <sha>:<file>`, 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 `<icon>` 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.
+1 -1
View File
@@ -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()` | — |
+3
View File
@@ -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;