Status: Living document
Describe how ChannelForge is structured today, and how that structure enforces the clean layered architecture decided in ADR 0003.
Developers, maintainers, and technical users who need to know where logic lives before changing it.
ChannelForge is organized as a PowerShell module (src/ChannelForge) with three conceptual layers.
Plain data objects with no knowledge of providers, file formats, or output targets.
Channel— a single channel: identity fields (name, TVG ID, logo, group), classification flags (IsLocal,IsAdult,IsRegionalSports,IsDuplicate), and aConfidencescore with accumulatedWarnings.BuildContext— the state of one build run: imported providers, playlists, channels, guide sources, programmes, plus warnings, errors, informational messages, statistics, and output metadata.
Public functions that turn raw input into domain objects, or transform domain objects deterministically. These live in src/ChannelForge/Public:
Read-ChannelForgeProvider— loadsprovider.jsonand returns one record per configured source.Read-ChannelForgeEpgSource— loadsepg_sources.jsonand returns sources sorted by priority.Import-ChannelForgeM3UPlaylist— parses a local M3U playlist intoChannelobjects, including the stream URL, through the shared streaming parser used by configured remote M3U acquisition.Import-ChannelForgeConfiguredM3USource— acquires one configured remote provider M3U through the v5 bounded HTTPS/443 path, optionally uses the disposable 24-hour fetch cache, and returns the sameChannelobjects as the local parser without exposing a raw URL ingestion command.Resolve-ChannelForgeAlias— deterministic, exact-match alias lookup; falls back to the input name when no alias is known.Set-ChannelForgeChannelNumber— assignsAssignedNumberfromnumbering_blocks.jsonby exact (case-insensitive) match against a channel'sGroup; leaves a channel unassigned with a warning rather than guessing a category mapping.Merge-ChannelForgeLineup— the lineup pipeline: parses local playlists or accepts already-parsed configured remote channels, normalizes, alias-resolves, deduplicates by tvg-id (or display name), numbers, and returns a deterministically ordered channel set plus duplicate/warning counts.Export-ChannelForgeM3UPlaylist— renders aChannel[]to M3U text (#EXTM3U/#EXTINFwithtvg-id/tvg-name/tvg-logo/tvg-chno/group-title) and writes it with no BOM and a fixed line ending, so the same channel set always produces the same bytes.Export-ChannelForgeXmltv— writes deterministic XMLTV from merged Programme records;Build-Lineup.ps1wires configured local and bounded remote XMLTV sources into this path.New-ChannelForgeChannel/New-ChannelForgeBuildContext— domain object constructors.ConvertTo-ChannelForgeNormalizedChannel— applies normalization to a channel.
Private helpers in src/ChannelForge/Private support the application layer without being part of the public surface, e.g. Normalize-ChannelForgeName, which strips quality tags and backup/alternate markers from playlist names.
Stage A guide intelligence adds read-only domain contracts for volatile event-guide evidence. GuideEvidenceRecord carries sanitized provider display text, M3U metadata, XMLTV, AED-derived, schedule, and accepted-knowledge evidence with source relationship, freshness, confidence state, and event metadata. GuideReadinessRecord and Get-ChannelForgeGuideReadiness project those records without changing accepted state or publishing output; every result remains CandidateOnly and requires the existing explicit acceptance boundary.
New-ChannelForgeGuideEvidence is the constructor for this Stage A evidence surface. It accepts logical source identifiers and structured event fields, normalizes ISO-8601 instants to UTC, rejects invalid identifiers, downgrades low-confidence confirmations, and excludes URLs, stream URLs, credentials, tokens, private paths, parser errors, candidate hashes, and generation IDs from the record.
Stage B native event-pattern inference adds GuidePatternExample, GuidePatternFieldCandidate, GuideEventPatternRule, and GuidePatternInferenceResult contracts. Invoke-ChannelForgeGuidePatternInference accepts representative provider-name examples or Stage A GuideEvidenceRecord objects and returns a deterministic, read-only candidate. The candidate stores semantic grammar segments, field candidates, per-example extraction preview, confidence, reason codes, and safe provenance fingerprints; it does not expose an opaque regex rule.
Timezone interpretation is explicit: callers provide safe abbreviation mappings or a default timezone, and yearless dates use an injected reference instant. Duplicate timezone clauses and Stage A timestamps are normalized to canonical UTC and marked as agreement or contradiction. Cross-source assessment counts only independent source relationships; mirrors remain provenance and never create independent confirmation. A supplied existing rule is compared by deterministic rule identity, and drift remains NeedsReview with adoption NotApplied.
Inference is a read-only analysis boundary. It does not write provider state, downstream configuration, guide output, accepted state, or a generation. Every result is CandidateOnly, has CanPublish = false, and retains the existing explicit acceptance/promotion boundary for any later consumer.
Stage C adds the beginner review projection: GuidePatternReviewReport and Get-ChannelForgeGuidePatternReview. It consumes only the Stage B inference result and returns an object, deterministic JSON, or beginner-readable Markdown. The projection translates semantic candidates and review reason codes into plain-language fields and next actions without exposing regex, raw examples, or sensitive source data. CandidateOnly, CanPublish = false, and AcceptedStateMutation = None are fixed safety invariants; the command has no provider, downstream, filesystem, guide-publication, or accepted-state mutation path.
Stage C is therefore a coaching/readability boundary, not an adoption boundary. Confirmed output still requires the existing explicit acceptance/promotion contract, while contradiction, stale, unavailable, unresolved, and drift states remain visible and blocked from automatic adoption.
Stage D wires this contract into the beginner workflow at scripts/Build-My-Lineup.ps1. -EventPatternPreview accepts representative event-channel display names or Stage A evidence, invokes the Stage B inference and Stage C review boundaries, and writes deterministic redacted JSON, Markdown, and text reports under output/reports/. Interactive examples are pipe-separated when the parameter is omitted; callers can provide event type/group, timezone mappings or a default offset, reference instant, date order, minimum example count, and an existing rule/candidate for deterministic drift comparison without writing a regex.
The workflow rejects -EventPatternPreview -Accept before candidate staging. The preview report is always CandidateOnly, CanPublish = false, requires ExplicitAcceptance, and records AcceptedStateMutation = None; it has no guide-publication, provider, downstream, or accepted-state mutation path. Confirmed and safe-candidate reviews are informational only, while missing, contradictory, stale, unavailable, or unresolved evidence remains blocked.
The first slice is ephemeral: rule audit fields (CreatedUtc, ValidatedUtc, and LastSuccessfulValidationUtc) remain empty because no learned-rule store is introduced. Deterministic rule identity and extraction output do not depend on wall-clock time.
Stage E adds Get-ChannelForgeGuidePatternAcceptancePlan, a deterministic read-only projection over Stage B inference or Stage C/D review output. It explains detected patterns, matched examples, confidence, provenance, freshness, drift, proposed future rule identity when Stage B is supplied directly, eligibility, blocked reasons, and the safety boundary. Stable title/time/pattern identity is explicitly separate from volatile enrichment. Volatile facts are eligible for current presentation only when source identity/type, fetched and source-data timestamps, freshness TTL, season/competition context, subject identity, confidence, and non-contradictory provenance prove eligibility; otherwise they are omitted or marked for review. This is an evaluator boundary, not a volatile-fact producer: the current Stage A/B path does not synthesize statistics, rosters, standings, or schedule descriptions, and absent optional safe metadata produces NotProvided. Stage C/D-only input explicitly reports that the opaque identity is unavailable because those review formats omit candidate hashes. It returns an object, compact JSON, distinct beginner-readable Markdown, or plain text. It never accepts, adopts, publishes, writes files, mutates provider/downstream state, or changes accepted state; PlanOnly, CandidateOnly, CanPublish = false, CanAcceptNow = false, and Adoption = NotApplied are fixed invariants.
File-format, transport, and output-target integrations: M3U, XMLTV, JSON, CSV, IPTVBoss, Dispatcharr, the file system, and HTTP. M3U/XMLTV local parsing and writing are joined by the approved bounded HTTPS/443 acquisition path for configured remote provider M3U and XMLTV sources; the IPTVBoss/Dispatcharr/Plex output writers described in the Roadmap remain future work — see "Known limitations" below.
Per ADR 0003, domain objects must not reference infrastructure-specific concepts.
Channel and BuildContext have no IPTVBoss-, Dispatcharr-, or Plex-specific
fields; provider- and playlist-specific values are stored as plain strings
supplied by the infrastructure layer.
ChannelForge's primary UI architecture is a browser-based local web UI served by the ChannelForge engine. The browser talks to the engine's documented HTTP/API boundary; the engine remains responsible for configuration, source acquisition, candidate generation, review, immutable acceptance, reports, and generated outputs.
The local server provides a beginner status shell and safe health/status JSON.
The landing surface consumes GET /api/status. The browser also provides a
read-only Live Now view over /api/one-guide/live-now, follows bounded API
pages, and opens a bounded item-details view through the item route. Both views
project only validated same-origin API data; neither reads guide files or
creates a second state authority. The server also has explicit guided-setup
proposal/acceptance and source-refresh routes; those state-changing operations
remain engine-owned and are not part of the read-only guide contract.
The loopback listener serves only its index.html and allowlisted static asset
types when gui/dist contains a Vite build. Otherwise / falls back to the
safe placeholder shell. Static requests are confined to the configured
gui/dist subtree, do not list directories, and do not expose private files.
The One Guide API (GET/HEAD /api/one-guide/...) returns the versioned
one-guide/v1 projection defined by
one-guide-projection.schema.json.
It reads the verified immutable accepted XMLTV artifact through a disposable
derived in-memory catalogue; it does not run recovery, enumerate configured
sources, fetch the network, or mutate accepted/source/evidence state. Live Now
and Starting Soon first pages are evaluated in UTC at request time; later pages
must carry the first page's at instant and opaque generation token, so a
paged listing is one coherent snapshot (a changed accepted generation returns
HTTP 409). Category routes optionally accept window=active to omit past items
before pagination at the same evaluation instant; other routes reject it. The
public query function takes an explicit evaluation instant.
If no accepted generation exists, or accepted XMLTV is unavailable/not
generated, these routes return HTTP 503 with one-guide-unavailable. An
accepted guide that has no programmes matching a query remains a successful
HTTP 200 response with an empty result; unavailable source state is never
represented as a valid empty guide.
The built-in category registry is centralized and data-driven. It defines
live-now, starting-soon, football, baseball, basketball, hockey, soccer,
wrestling, motorsports, boxing, MMA, tennis, golf, rugby, cricket, lacrosse,
other-sports, movies, news, kids, entertainment, documentary, and comedy.
Deterministic aliases map recognized XMLTV labels; generic Sports maps to
other-sports only when no specific sport category is also present. Unknown
categories remain omitted.
Cross-channel grouping requires an explicit CanonicalEventId in projection
input; the generated item identity also includes the normalized title, subtitle,
episode number, and schedule. Without that explicit ID, rows are scoped to a
source/channel and sanitized source-row evidence, including descriptions,
categories (recognized or not), and XMLTV new/live/premiere flags. Conflicting
evidence remains separate; exact equivalent rows share an item. The fixed
category registry still omits unrecognized categories from output. Accepted
XMLTV does not preserve CanonicalEventId or provider attribution per
programme. The HTTP projection labels its source Accepted guide. It reports
Kind as Programme unless input explicitly carries a recognized kind.
Sport, league, participant, promotion, entitlement, and launch metadata are
reported only when actually supplied; title text is never used to invent them.
Source, channel, and promotion identifiers are opaque domain-separated hashes.
The server keeps one bounded in-memory projection for the current accepted generation per repository root, keyed by its pointer and manifest identity. Catalogue construction precomputes parsed UTC interval ticks and a direct item-ID lookup. Warm list requests validate the current pointer, scan the cached records, and clone only the returned page (maximum 100); Details uses the ID lookup. Warm requests do not reread or reparse accepted XMLTV. A new generation replaces the entry. The cache is derived and disposable; no database or second source of truth is introduced.
List routes use limit and offset for bounded enumeration (maximum 100
items); each item reports the number of offerings and whether its 16-offering
summary is truncated.
The read API exposes a bounded safe projection, not the accepted generation's raw M3U/XMLTV bytes, hashes, credentials, private paths, or stream URLs. The current server remains loopback-only; authenticated LAN access is not provided.
The primary deployment modes are:
- Docker container — the engine serves the web UI and API from one container, with operator-provided persistent mounts for configuration, disposable source cache, immutable generations, accepted pointers, reports/logs, and generated M3U/XMLTV outputs.
- Windows server/service install — the installed engine serves the same web UI and API from a local server/service process with persistent storage for the same state, reports, and outputs.
Native/local development may serve the same UI/API without Docker. Docker, Windows server/service, and native/local modes must share the same engine commands, accepted-generation contract, and safety boundaries. A native wrapper is optional future packaging only; it is not a second product UI or a second state authority.
Docker, Windows service, authenticated LAN access, appearance modes, and the distributed client contract remain future work. The local server does not change those deployment claims.
The existing gui/ React/TypeScript/Tauri work is reusable UI work, not disposable architecture. Preserve or adapt its visual layout direction, design tokens, accessible component patterns, Guided Setup / Beginner Workflow structure, playlist and guide selection UX, validation and review surfaces, saved-lineup concepts, and beginner-facing copy and terminology when building the web UI.
Before web UI implementation, re-evaluate every Tauri-specific shell command, native file-picker assumption, direct filesystem access assumption, local process invocation, and state-changing behavior that currently sits outside an engine HTTP/API boundary. The web UI must not reproduce native-only behavior by granting the browser direct filesystem or process authority. State-changing operations must route through documented engine commands and the immutable acceptance boundary.
The current Tauri implementation remains preserved and is useful as a design and behavior reference. Its native bridge is not the deployment contract for the web UI.
scripts/Build-Lineup.ps1 and scripts/Backup-IPTVBoss.ps1 sit outside the module (they're standalone entry points, not part of the public API) but import it to call Assert-ChannelForgeWritePath and Assert-ChannelForgePathExists before any filesystem write, copy, or archive operation. This keeps the "what's allowed to write where" decision in one place rather than re-implemented per script. The same principle covers reads of operator-supplied configuration paths: Assert-ChannelForgeReadPath confines a resolved local_playlist value to data/playlists/ before Build-Lineup.ps1 ever opens it. See the Developer Guide for the current write/read sites and what each guardrail checks.
provider.json (enabled + local_playlist or supported remote URL)
|
v
[enabled + local_playlist] ----> Import-ChannelForgeM3UPlaylist
|
v
[enabled + no local + remote URL] -> Import-ChannelForgeConfiguredM3USource
|
v
shared M3U parser -> normalize -> alias -> dedup -> number
|
v
Export-ChannelForgeM3UPlaylist ---> output/merged.m3u
epg_sources.json (enabled local XMLTV path or supported remote URL entries)
|
v
[enabled + supported local or remote XMLTV]
|
v
Import-ChannelForgeConfiguredXmltvSource
|
v
Merge-ChannelForgeXmltvProgrammes -> guarded staging -> safe promotion
|
v
Export-ChannelForgeXmltv ---> output/merged.xml
event-channel examples or Stage A guide evidence
|
v
Invoke-ChannelForgeGuidePatternInference
|
v
Get-ChannelForgeGuidePatternReview
|
v
plain-language object / JSON / Markdown review
|
+--> review/confidence/provenance/drift only
+--> no guide publication, provider mutation, downstream mutation,
filesystem write, or accepted-state mutation
This is the build pipeline scripts/Build-Lineup.ps1 runs via Merge-ChannelForgeLineup for local or remote M3U and the configured XMLTV import/merge/export commands for guide output. The branches remain separate: XMLTV is source-scoped and is not bound to M3U Channel.TvgId values. BuildContext exists as a domain object but this pipeline does not populate it yet; downstream output writers and Plex guide binding remain future work.
- Remote provider M3U/XMLTV acquisition is bounded and fail-closed. Only HTTPS on port 443 is accepted; redirects, proxies, credentials, retries, remote ZIP, stale/offline success, and live-network CI are outside this slice. Unsupported content, encodings, malformed input, bounds failures, conflicts,
NeedsReview, or export/promotion failures produce a failed source/build status. - Remote fetch caching is disposable, not authoritative. Provider M3U uses a separate 24-hour cache namespace and XMLTV uses its fixed policy; invalid or stale entries require successful validation or a full refetch and never substitute stale data.
- Plex EPG/guide binding remains deferred. The build can produce
output/merged.xml, but downstream channel-to-guide binding and automatic Plex refresh are separate work. - Category-to-numbering-block matching is intentionally simple. A channel is numbered only if its M3U
group-titleexactly matches (case-insensitive) anumbering_blocks.jsoncategory. Smarter category inference is Confidence Engine territory (Roadmap Milestone 2), not this pipeline. - Native event-pattern inference and review are read-only and bounded. Stage B analyzes supplied examples or Stage A evidence; Stage C projects that result as beginner-readable object, JSON, or Markdown output. Schedule-source adapters, documented IPTVBoss AED JSON import, persistent learned-rule storage, expert regex override, unattended event refresh, automatic relearning/adoption, and guide publication remain future work.
scripts/Invoke-ChannelForgeScheduledRefreshRun.ps1 is a manual foreground entry point by default. The same wrapper has a scheduler-owned -ScheduledInvocation mode that invokes the report-only planner with TriggerKind=Scheduled, accepts only a fresh READY_SCHEDULED plan, and invokes scripts/Invoke-ChannelForgeSourceRefresh.ps1 at most once. A deterministic jitter delay is one bounded foreground wait, not a worker or retry loop.
The opt-in Windows registration is owned by the four scripts/*ScheduledRefresh*.ps1 lifecycle/status commands. It registers one daily CalendarTrigger under \ChannelForge\ with an explicit UTC StartBoundary, an absolute PowerShell Core 7.6+ executable, least-privilege interactive principal, and MultipleInstancesPolicy=IgnoreNew. Registration and history evidence under output/operations/ contain digests and bounded statuses only.
The lock handle, not the marker file, establishes ownership. Marker fields are diagnostic crash evidence and contain only an owner-token hash, process timing, plan digest, and bounded state. A failed lock acquisition never deletes or adopts a stale marker. A successful acquisition may record a prior Running marker as abandoned before starting the new run.
The run reports under output/reports/ are operational evidence. The wrapper itself performs no network request and writes no cache directly. The existing source-refresh executor may update disposable source cache according to its established contract. Accepted state, generations, pointers, published M3U/XMLTV output, provider state, and downstream state remain outside this boundary. ChannelForge does not provide a daemon, service, worker, autonomous retry loop, or cross-platform scheduler backend.
Provider sources, EPG sources, and rules live in data/ as declarative JSON/CSV files (see ADR 0001). Generated outputs under output/ are disposable artifacts, not source data.
Every tracked JSON file under data/ has a structural contract in schemas/, validated with PowerShell's built-in Test-Json -SchemaFile (run them all via scripts/Validate-ConfigSchemas.ps1). Schemas check shape — required/optional fields and types — not business content; they do not enumerate today's provider names, EPG roles, or categories, and they do not check URL trust-boundary rules, which remain a runtime concern in Test-ChannelForgeSourceUrl. See the Developer Guide for the full file-to-schema mapping and how the two validation layers relate.
state/source-enrollment.json is the single server-owned durable source-set authority for guided local sources. Its current schema is source-enrollment/v2; it stores ordered playlist records, guide records, and guide-to-playlist decisions in one canonical document. A playlist may have multiple guides, a guide may serve one or many playlists, and AppliesToAll is explicit rather than inferred from enumeration order. With one playlist, an omitted decision defaults each guide to that playlist; an ExplicitlyUnbound: true decision with no playlist IDs preserves a user-cleared binding instead. With multiple playlists, omitted bindings leave guides unbound and actionable. Binding revisions and canonical binding identities are part of the source-set hash; the optional explicit-unbound marker is included in enrollment integrity.
Each record has a stable server-derived source ID independent of content bytes and enumeration order. Managed files are retained below state/managed-sources/; supported public sources retain their non-tokenized HTTPS URL plus a bounded validated last-known-good snapshot in that same managed root. Safe status projections omit URLs, managed paths, content hashes, and accepted-state hashes. Tokenized or credential-bearing URLs are unsupported.
The legacy source-enrollment/v1 document remains readable through a deterministic in-memory compatibility projection; it is not a second authority and is not silently discarded. Existing refresh planning consumes this compatibility input projection, enumerates every enabled playlist and guide record, reuses unchanged managed bytes, and refreshes public HTTPS records through the existing bounded transport. A changed source creates review-only candidate evidence; a failed source preserves the prior snapshot and accepted state. The source-set authority is separate from accepted lineup state, candidate generations, and disposable fetch caches, so refresh review never mutates accepted state.
POST /api/guided-setup/proposal retains the exact v1 browser envelope for
legacy callers and accepts the current schemaVersion: 2 source-set envelope.
V2 stages 1–8 playlists and 0–8 guides, validates selected, explicit-all, and
explicitly-unbound guide decisions, and produces one candidate through the
same candidate-only path. Request limits, source identities, and binding
identities are derived by the server. Omitted decisions auto-bind each guide
only when exactly one playlist exists; with multiple playlists, omitted guides
remain enrolled and actionable but are not applied to accepted guide output.
The explicit-unbound descriptor is distinct from omission and suppresses the
single-playlist default. The response never exposes source URLs, filenames,
paths, hashes, or raw content.
Managed uploads and one-time acquired public HTTPS bytes are staged under server-derived source IDs. Acceptance promotes validated source bytes and declarative public descriptors into the durable source-set authority. The browser renders the v2 multi-row source contract; refresh remains manual and review-only, with no unattended acquisition or accepted-state mutation.