feat(385): per-channel overrides in auto-tune bulk-create #428
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()` | — |
|
||||
|
||||
Vendored
+3
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user