feat(414): stamp immutable Channel.Origin (auto-tuned vs user-created) and surface it (#575)
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m23s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 14m21s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 19m9s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 12m1s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m23s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 14m21s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 19m9s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 12m1s
Co-authored-by: Timothy <timothy.look@gmail.com> Co-committed-by: Timothy <timothy.look@gmail.com>
This commit was merged in pull request #575.
This commit is contained in:
@@ -450,7 +450,8 @@ public class CreateChannelFromLineupHandler(
|
||||
TranscodeMode = advanced.TranscodeMode ?? template.TranscodeMode,
|
||||
IdleBehavior = advanced.IdleBehavior ?? template.IdleBehavior,
|
||||
IsEnabled = request.IsEnabled,
|
||||
ShowInEpg = request.IsEnabled && request.ShowInEpg
|
||||
ShowInEpg = request.IsEnabled && request.ShowInEpg,
|
||||
Origin = ChannelOrigin.AutoTuned
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -152,7 +152,8 @@ public class CreateChannelHandler(
|
||||
TranscodeMode = request.TranscodeMode,
|
||||
IdleBehavior = request.IdleBehavior,
|
||||
IsEnabled = request.IsEnabled,
|
||||
ShowInEpg = request.IsEnabled && request.ShowInEpg
|
||||
ShowInEpg = request.IsEnabled && request.ShowInEpg,
|
||||
Origin = ChannelOrigin.UserCreated
|
||||
};
|
||||
|
||||
if (channel.PlayoutSource is ChannelPlayoutSource.Mirror)
|
||||
|
||||
@@ -114,7 +114,8 @@ internal static class Mapper
|
||||
channel.ShowInEpg,
|
||||
playoutCount,
|
||||
GetLogoUrl(channel),
|
||||
GetPreview(channel.StreamingMode, channel.Number, channel.IsEnabled, playoutCount));
|
||||
GetPreview(channel.StreamingMode, channel.Number, channel.IsEnabled, playoutCount),
|
||||
channel.Origin);
|
||||
|
||||
internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) =>
|
||||
new(resolution.Height, resolution.Width);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#nullable enable
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace ErsatzTV.Core.Api.Channels;
|
||||
@@ -21,4 +22,7 @@ public record ChannelResponseModel(
|
||||
// (SPA then renders the generated initials fallback). See ErsatzTV.Application Channels.Mapper.GetLogoUrl.
|
||||
string? Logo,
|
||||
// Server-declared browser-preview capability; see ChannelPreviewResponseModel.
|
||||
ChannelPreviewResponseModel Preview);
|
||||
ChannelPreviewResponseModel Preview,
|
||||
// Immutable creation-provenance (auto-tuned vs user-created). Raw fact; the SPA decides how to render it.
|
||||
// Unknown for rows created before the origin column existed (never back-filled).
|
||||
ChannelOrigin Origin);
|
||||
|
||||
@@ -45,5 +45,8 @@ public class Channel
|
||||
public ChannelIdleBehavior IdleBehavior { get; set; }
|
||||
public bool IsEnabled { get; set; }
|
||||
public bool ShowInEpg { get; set; }
|
||||
|
||||
// Immutable creation-provenance (auto-tuned vs user-created); stamped once at insert, never on edit.
|
||||
public ChannelOrigin Origin { get; set; }
|
||||
public string WebEncodedName => WebUtility.UrlEncode(Name);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
// How a Channel row came to exist. This is immutable creation-provenance: it records how the channel
|
||||
// was born and a later user edit never changes it. Unknown is the honest default for rows that predate
|
||||
// this column — provenance was never recorded for them and is deliberately not back-filled (inferring it
|
||||
// from the "Channel Lineups" system playlist group is the mislabeling heuristic #414 rejected).
|
||||
public enum ChannelOrigin
|
||||
{
|
||||
Unknown = 0,
|
||||
UserCreated = 1,
|
||||
AutoTuned = 2
|
||||
}
|
||||
+7309
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Add_Channel_Origin : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Origin",
|
||||
table: "Channel",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Origin",
|
||||
table: "Channel");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -334,6 +334,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
b.Property<string>("Number")
|
||||
.HasColumnType("varchar(255)");
|
||||
|
||||
b.Property<int>("Origin")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("PlayoutMode")
|
||||
.HasColumnType("int");
|
||||
|
||||
|
||||
+7134
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Add_Channel_Origin : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Origin",
|
||||
table: "Channel",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Origin",
|
||||
table: "Channel");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -321,6 +321,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
b.Property<string>("Number")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Origin")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PlayoutMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ public class CreateChannelFromLineupHandlerTests
|
||||
channel.FallbackFillerId.ShouldBe(5);
|
||||
channel.StreamingMode.ShouldBe(StreamingMode.HttpLiveStreamingSegmenter);
|
||||
channel.ShowInEpg.ShouldBeTrue();
|
||||
channel.Origin.ShouldBe(ChannelOrigin.AutoTuned);
|
||||
|
||||
ProgramSchedule schedule = await context.ProgramSchedules.Include(ps => ps.Items).SingleAsync();
|
||||
schedule.Name.ShouldBe("12 Movies Schedule");
|
||||
|
||||
@@ -29,8 +29,8 @@ public class CreateChannelHandlerTests : ChannelHandlerTestBase
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
bool exists = await context.Channels.AnyAsync(c => c.Number == "7" && c.Name == "News");
|
||||
exists.ShouldBeTrue();
|
||||
Channel channel = await context.Channels.SingleAsync(c => c.Number == "7" && c.Name == "News");
|
||||
channel.Origin.ShouldBe(ChannelOrigin.UserCreated);
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -157,7 +157,8 @@ public class OpenApiSerializerContractTests
|
||||
true,
|
||||
2,
|
||||
"/iptv/logos/logo.png",
|
||||
Mapper.GetPreview(StreamingMode.HttpLiveStreamingSegmenter, "1", true, 2));
|
||||
Mapper.GetPreview(StreamingMode.HttpLiveStreamingSegmenter, "1", true, 2),
|
||||
ChannelOrigin.AutoTuned);
|
||||
|
||||
private static string FindOpenApiDocument()
|
||||
{
|
||||
|
||||
@@ -23889,6 +23889,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ChannelOrigin": {
|
||||
"enum": [
|
||||
"Unknown",
|
||||
"UserCreated",
|
||||
"AutoTuned"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ChannelPlayoutMode": {
|
||||
"enum": [
|
||||
"Continuous",
|
||||
@@ -23943,7 +23951,8 @@
|
||||
"showInEpg",
|
||||
"playoutCount",
|
||||
"logo",
|
||||
"preview"
|
||||
"preview",
|
||||
"origin"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -23994,6 +24003,9 @@
|
||||
},
|
||||
"preview": {
|
||||
"$ref": "#/components/schemas/ChannelPreviewResponseModel"
|
||||
},
|
||||
"origin": {
|
||||
"$ref": "#/components/schemas/ChannelOrigin"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -85,6 +85,8 @@
|
||||
{!c.enabled && <Marker title="Disabled">D</Marker>}
|
||||
{!c.epg && <Marker title="Hidden from EPG">H</Marker>}
|
||||
{c.playouts === 0 && <Badge tone="error">No playout</Badge>}
|
||||
{/* Immutable creation-provenance: only AutoTuned is badged; user-created/legacy are unmarked. */}
|
||||
{c.origin === "AutoTuned" && <span title="Created by Auto-Tune from a channel lineup"><Badge tone="neutral">Auto-tuned</Badge></span>}
|
||||
</div>
|
||||
<div style={{ marginTop: 3, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>{c.lang}</div>
|
||||
</div>
|
||||
|
||||
@@ -7,8 +7,8 @@ window.CTV_DATA = {
|
||||
{ num: "1.2", name: "Anime Block", group: "Cartoons", lang: "Japanese",mode: "HLS Segmenter", profile: "1080p H.264", live: false, enabled: true, epg: true, playouts: 1 },
|
||||
{ num: "2.1", name: "News 24", group: "News", lang: "English", mode: "HLS Segmenter", profile: "720p H.264", live: true, enabled: true, epg: true, playouts: 1 },
|
||||
{ num: "3.1", name: "Late Night Movies",group: "Movies", lang: "English", mode: "MPEG-TS", profile: "1080p HEVC", live: false, enabled: true, epg: true, playouts: 1 },
|
||||
{ num: "7.3", name: "Sci-Fi Marathon", group: "Movies", lang: "English", mode: "MPEG-TS", profile: "1080p H.264", live: false, enabled: true, epg: false, playouts: 1 },
|
||||
{ num: "4.2", name: "Music Videos", group: "Music", lang: "English", mode: "HLS Segmenter", profile: "720p H.264", live: true, enabled: true, epg: true, playouts: 1 },
|
||||
{ num: "7.3", name: "Sci-Fi Marathon", group: "Movies", lang: "English", mode: "MPEG-TS", profile: "1080p H.264", live: false, enabled: true, epg: false, playouts: 1, origin: "AutoTuned" },
|
||||
{ num: "4.2", name: "Music Videos", group: "Music", lang: "English", mode: "HLS Segmenter", profile: "720p H.264", live: true, enabled: true, epg: true, playouts: 1, origin: "AutoTuned" },
|
||||
{ num: "5.1", name: "Nature Docs", group: "Docs", lang: "Spanish", mode: "HLS Direct", profile: "—", live: false, enabled: true, epg: true, playouts: 0 },
|
||||
{ num: "9.1", name: "Kids Block", group: "Kids", lang: "French", mode: "HLS Segmenter", profile: "720p H.264", live: true, enabled: true, epg: true, playouts: 1 },
|
||||
{ num: "12.1", name: "Test Pattern", group: "System", lang: "English", mode: "MPEG-TS", profile: "480p H.264", live: false, enabled: false, epg: false, playouts: 1 },
|
||||
|
||||
@@ -3586,3 +3586,16 @@ record's per-item Pad-preset behavior is unchanged. Determinism needs no new anc
|
||||
is a pure function of offsets). Coverage is per-scheduler-mode (One/Flood/Duration/Multiple) via golden and
|
||||
invariant tests across midnight crossings. The SPA schedule editor exposes it as a 5/10/15/30/60 minute
|
||||
picker; TZ-independence holds only for divisors of 60. See #77 (prior art) and #392.
|
||||
|
||||
## 2026-07-23 — Channel origin is immutable creation-provenance, stamped at insert, not a health signal (#414)
|
||||
|
||||
`key: channel.origin-marker` · `status: active` · `since: 2026-07-23` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** A new `Channel.Origin` (`ChannelOrigin` enum — `Unknown`/`UserCreated`/`AutoTuned`) records how a channel row was created and is stamped exactly once at insert (`AutoTuned` in `CreateChannelFromLineupHandler`, `UserCreated` in `CreateChannelHandler`), and is never mutated on a later edit. It is surfaced as a raw `origin` field on `ChannelResponseModel`; the SPA badges only `AutoTuned`. Rows predating the column read `Unknown` — provenance is **not** back-filled.
|
||||
**Signals:** channel origin, auto-tuned vs user-created channel, ChannelOrigin enum, immutable creation provenance, Origin column stamp at insert, do not back-fill origin, Channel Lineups playlist-group heuristic rejected, auto-generated then user-edited stays AutoTuned · paths: `ErsatzTV.Core/Domain/ChannelOrigin.cs`, `Channel.Origin`, `CreateChannelFromLineupHandler.BuildChannel`, `CreateChannelHandler`, `ChannelResponseModel`, `web/src/screens/ChannelsScreen.tsx`, `/app/channels` · issues: #414, #415, #72
|
||||
**Mechanics:** `CreateChannelHandlerTests` (UserCreated stamp), `CreateChannelFromLineupHandlerTests` (AutoTuned stamp), `ChannelsScreen.test.tsx` (badge only on AutoTuned); dual-provider migration `Add_Channel_Origin`
|
||||
|
||||
This is #72 scope item (a), deferred in `api.channel-health-signal` because "no honest signal exists": `ChannelPlayoutSource.Generated` is a *playout-strategy* value that SPA-created blank channels also carry, so it would mislabel them, and a join through the `"Channel Lineups"` system playlist group was rejected as a heuristic that breaks the moment a user edits the channel. The fix is a dedicated `Origin` column — a fact, not a derivation.
|
||||
|
||||
**Immutable provenance, not a mutable "still managed" flag.** `Origin` records how the row was *born* and a later user edit never changes it, so "auto-generated then user-edited" stays `AutoTuned`. This deliberately avoids reviving the fragile "detect when it's been edited away" heuristic the issue rejected. A future "has diverged from its auto-tune template" signal, if wanted, is a *separate* concern owned by the #383/#384 auto-tune arc (which knows the template), not this column — mirroring the `api.channel-health-signal` reasoning that kept health a raw fact rather than freezing a policy enum.
|
||||
|
||||
**`Unknown = 0` is the honest legacy default.** A new non-null int column defaults existing rows to `0`; making that `Unknown` (rather than `UserCreated`) means pre-migration rows say "we never recorded this" instead of asserting a provenance we cannot know. The SPA badges only `AutoTuned`, so `Unknown` and `UserCreated` both render unbadged. Enum (not `bool IsAutoTuned`) so a future origin (e.g. `Imported`) is additive without a wire-contract break. Stamped in `CreateChannelFromLineupHandler.BuildChannel`, which is the single channel-construction primitive `CreateAutoTunedChannelsHandler` delegates to, so both the lineup endpoint and bulk auto-tune are covered by one stamp site. Empty-schedule and broken-source fault detection remain deferred to #415.
|
||||
|
||||
@@ -31,6 +31,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
|
||||
| `api.versioning-v1` | The entire `/api` surface is versioned to `/api/v1` uniformly (no unversioned corner); legacy unversioned callers are rewritten in-pipeline (not redirected) with Deprecation/Link/Sunset headers, and post-freeze `/api/v1` is additive-only — a breaking change requires `/api/v2`. | 2026-07-13 | [link](../decisions.md#2026-07-13--api-versioning-the-whole-api-surface-is-mounted-at-apiv1-additive-only-after-freeze-286) |
|
||||
| `blazor.rollback-tag` | The commit immediately preceding the Blazor-removal merge is tagged `blazor-final` (not a `v*` tag, so it doesn't trigger a prod release build) as the documented rollback/restore path. | 2026-07-11 | [link](../decisions.md#2026-07-11--pre-removal-blazor-rollback-tag-blazor-final-205) |
|
||||
| `blazor.ui-removed` | The legacy Blazor Server UI (`Pages/`, `Shared/`, `ViewModels/`, `Validators/`, MudBlazor + 8 other packages, Blazor Startup wiring) is fully deleted now that the SPA has parity; the legacy `MapWhen` branch is kept only for controllers/docs/OpenAPI/`LegacyUiRedirects`, and the catch-all fallback 302s any unmatched non-api/artwork/docs/openapi path to `/app`. | 2026-07-11 | [link](../decisions.md#2026-07-11--blazor-server-ui-removed-91-phase-b) |
|
||||
| `channel.origin-marker` | A new `Channel.Origin` (`ChannelOrigin` enum — `Unknown`/`UserCreated`/`AutoTuned`) records how a channel row was created and is stamped exactly once at insert (`AutoTuned` in `CreateChannelFromLineupHandler`, `UserCreated` in `CreateChannelHandler`), and is never mutated on a later edit. It is surfaced as a raw `origin` field on `ChannelResponseModel`; the SPA badges only `AutoTuned`. Rows predating the column read `Unknown` — provenance is **not** back-filled. | 2026-07-23 | [link](../decisions.md#2026-07-23--channel-origin-is-immutable-creation-provenance-stamped-at-insert-not-a-health-signal-414) |
|
||||
| `ci.batch-pushes-no-cancel-route` | Hold review fixes, doc corrections and format fixes locally and push **once** — a superseded run cannot be cancelled from the agent side and holds a runner slot until it finishes. | 2026-07-21 | [link](workflow-process.md#2026-07-21--batch-your-pushes-there-is-no-agent-side-cancel-route-on-gitea-1254-542) |
|
||||
| `ci.build-once-rejected` | CI build-once (a shared compile artifact across jobs) was implemented, measured, and rejected for a 40-85% wall-clock regression; keep the #420 cross-run tree-identity skip instead. | 2026-07-18 | [link](../decisions.md#2026-07-18--ci-build-once-was-measured-and-rejected-keep-the-420-tree-skip) |
|
||||
| `ci.cancelled-is-not-a-verdict` | Treat a `cancelled` conclusion as "no verdict" — never as pass or fail — and report FAILED and CANCELLED counts separately in any CI monitor. | 2026-07-21 | [link](workflow-process.md#2026-07-21--cancelled-is-not-failure-a-cancelled-run-is-no-verdict-542) |
|
||||
|
||||
@@ -81,7 +81,8 @@ Channel (1) ──< Playout (0..N per channel; ChannelPlayoutSource distinguishe
|
||||
| **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 — OnDemand is the per-channel "resume where I left off" freeze/thaw mode, #68; see `channels.md` → On-demand resume), `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 + query corrections via an optional `sources: [{sourceId, weight, excluded}]` on that same request, #425 — a customized channel is backed by a system-owned `MultiCollection` of per-source `SmartCollection`s with `PlaybackOrder.WeightedShuffle`, `OwnedByChannelId`-tagged so it's hidden from collection lists and cleaned up on channel delete) |
|
||||
| **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) |
|
||||
| **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 and broken-source faults are still deliberately **not** computed (#415 — unsafe today; see that decision entry). Auto-tuned-vs-user **origin** *is* now computed, but as immutable creation-provenance rather than a health signal — see the **Channel origin** row below. | `Channel.Playouts` | `/app/channels` (read-only signal) |
|
||||
| **Channel origin** (#414) | Immutable creation-provenance: `Channel.Origin` (`ChannelOrigin` enum — `Unknown`/`UserCreated`/`AutoTuned`) records **how the row was born** and is stamped once at insert (`AutoTuned` in `CreateChannelFromLineupHandler`, the single primitive bulk auto-tune delegates to; `UserCreated` in `CreateChannelHandler`). A later user edit never changes it (so "auto-generated then user-edited" stays `AutoTuned`; any future "diverged from its auto-tune template" signal belongs to the auto-tune arc, not this column). `Unknown` is the honest default for rows predating the column — provenance is **not** back-filled (inferring it from the `"Channel Lineups"` playlist group is the heuristic #414 rejected). Surfaced as a raw `origin` on `ChannelResponseModel` (the SPA badges only `AutoTuned`; absence ⇒ user-created or legacy). | `Channel.Origin` | `/app/channels` (read-only badge) |
|
||||
| **Guide / EPG (XMLTV)** | Per-channel programme guide generated from playout items; channels with `ShowInEpg=false` are excluded. The SPA's JSON guide grid is built by the sibling `GetChannelGuideDataHandler`. Both the JSON guide (`ChannelGuideChannelResponseModel`) and the channels-list DTO (`ChannelResponseModel`) expose a rooted, directly-usable `Logo` URL (#464) via `Mapper.GetLogoUrl` — `/iptv/logos/{file}` for an uploaded logo, the absolute URL passed through for an external one, `null` when unset (SPA then draws the generated initials "bug"). | `GetChannelGuideHandler`, `GetChannelGuideDataHandler` | `/app/guide` (viewer); settings at `/app/settings/xmltv` |
|
||||
| **M3U** | The channel lineup playlist Jellyfin/Dispatcharr consume. | `ChannelPlaylist.ToM3U()` | — |
|
||||
| **IPTV base URL** | Optional advertised base URL for the IPTV surface (#340). Stored as a single `ConfigElement` (`ConfigElementKey.IptvBaseUrl`, key `iptv.base_url`, no EF migration); when set, `GetChannelPlaylistHandler` (M3U) and `GetChannelGuideHandler` (XMLTV) pin their absolute URLs to its scheme/host/base instead of the request `Host` (blank/invalid → request-derived). Not applied to HDHomeRun; distinct from `ETV_BASE_URL`. Parsed by `ErsatzTV.Core/Iptv/AdvertisedBaseUrl.cs`. | `ConfigElementKey.IptvBaseUrl` | `/app/settings` → IPTV (`GET`/`PUT /api/v1/settings/iptv`) |
|
||||
|
||||
Vendored
+2
@@ -236,6 +236,7 @@ export interface components {
|
||||
"startUtc": string;
|
||||
"finishUtc": string;
|
||||
};
|
||||
"ChannelOrigin": "Unknown" | "UserCreated" | "AutoTuned";
|
||||
"ChannelPlayoutMode": "Continuous" | "OnDemand";
|
||||
"ChannelPlayoutSource": "Generated" | "Mirror";
|
||||
"ChannelPreviewResponseModel": {
|
||||
@@ -258,6 +259,7 @@ export interface components {
|
||||
"playoutCount": number;
|
||||
"logo": null | string;
|
||||
"preview": components["schemas"]["ChannelPreviewResponseModel"];
|
||||
"origin": components["schemas"]["ChannelOrigin"];
|
||||
};
|
||||
"ChannelSongVideoMode": "Default" | "WithProgress";
|
||||
"ChannelStateResponseModel": {
|
||||
|
||||
@@ -262,6 +262,24 @@ describe('ChannelsScreen — load + render', () => {
|
||||
expect(screen.getByRole('button', { name: 'No playout 0' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('badges an auto-tuned channel and leaves user-created and legacy rows unmarked', async () => {
|
||||
// origin is immutable creation-provenance: AutoTuned is badged, UserCreated is not, and a legacy
|
||||
// row (origin omitted / 'Unknown') is deliberately left unmarked rather than asserted user-created.
|
||||
mockApi({
|
||||
channels: [
|
||||
channelRow({ id: 1, name: 'Generated One', number: '1', origin: 'AutoTuned' }),
|
||||
channelRow({ id: 2, name: 'Hand Made', number: '2', origin: 'UserCreated' }),
|
||||
channelRow({ id: 3, name: 'Legacy', number: '3', origin: 'Unknown' })
|
||||
]
|
||||
});
|
||||
|
||||
render(<ChannelsScreen />);
|
||||
|
||||
const table = await screen.findByRole('table', { name: 'Channels lineup' });
|
||||
// Exactly one Auto-tuned badge, on the generated row only.
|
||||
expect(within(table).getAllByText('Auto-tuned')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('polls only channel state after the initial load', async () => {
|
||||
const intervalHandlers: Array<() => void> = [];
|
||||
vi.spyOn(window, 'setInterval').mockImplementation((handler) => {
|
||||
|
||||
@@ -614,6 +614,16 @@ function ChannelTableRow({
|
||||
A tooltip-only glyph would be invisible on touch and easy to miss in a long lineup.
|
||||
*/}
|
||||
{willNeverPlay(channel) && <Badge tone="error">No playout</Badge>}
|
||||
{/*
|
||||
Creation-provenance marker. Rendered only for AutoTuned: its absence means user-created
|
||||
(or a legacy row predating the origin column, which reads as 'Unknown' — we never
|
||||
back-fill provenance, so we don't assert 'user-created' about those).
|
||||
*/}
|
||||
{channel.origin === 'AutoTuned' && (
|
||||
<span title="Created by Auto-Tune from a channel lineup">
|
||||
<Badge tone="neutral">Auto-tuned</Badge>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<small>{channel.language || 'Language unset'}</small>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user