From 2d16d68f883f7fa8f0247d7658522b9b7cd35594 Mon Sep 17 00:00:00 2001 From: Jumpstile <29765787+Jumpstile@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:26:56 -0400 Subject: [PATCH 1/6] feat: add read-only One Guide API --- docs/architecture/ARCHITECTURE.md | 50 ++- docs/developer/DEVELOPER_GUIDE.md | 65 +++- docs/user/CURRENT_LIMITATIONS.md | 41 ++- schemas/one-guide-projection.schema.json | 179 +++++++++++ src/ChannelForge/ChannelForge.psd1 | 1 + .../Get-ChannelForgeWebOneGuideResponse.ps1 | 90 ++++++ .../Private/Get-ChannelForgeWebResponse.ps1 | 29 ++ .../Public/Get-ChannelForgeOneGuide.ps1 | 293 ++++++++++++++++++ tests/fixtures/one-guide/programmes.json | 73 +++++ tests/unit/OneGuide.Tests.ps1 | 186 +++++++++++ tests/unit/WebServer.Tests.ps1 | 55 +++- 11 files changed, 1021 insertions(+), 41 deletions(-) create mode 100644 schemas/one-guide-projection.schema.json create mode 100644 src/ChannelForge/Private/Get-ChannelForgeWebOneGuideResponse.ps1 create mode 100644 src/ChannelForge/Public/Get-ChannelForgeOneGuide.ps1 create mode 100644 tests/fixtures/one-guide/programmes.json create mode 100644 tests/unit/OneGuide.Tests.ps1 diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index cf58f23..9e385bc 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -82,17 +82,40 @@ boundary; the engine remains responsible for configuration, source acquisition, candidate generation, review, immutable acceptance, reports, and generated outputs. -The foundation slice provides a read-only loopback HTTP server with a beginner -status shell and safe health/status JSON. When `gui/dist` contains a Vite build, -the server serves only its `index.html` and allowlisted static asset types. -Without that build output, `/` 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 accepted-generation contents or provider files. -The built landing surface consumes `GET /api/status` from the same origin and -projects only its validated running, lineup, next-action, and read-only facts -into beginner copy. The browser retains no raw status payload and has no -state-changing API capability; unavailable, failed, or invalid status responses -render a safe degraded message. +The local server provides a beginner status shell and safe health/status JSON. +The landing surface consumes `GET /api/status` from the same origin and +projects validated running, lineup, next-action, and read-only facts into +beginner copy. It retains no raw status payload. 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`](../../schemas/one-guide-projection.schema.json). +It reads the verified immutable accepted XMLTV artifact directly; it does not +run recovery, enumerate configured sources, fetch the network, or mutate +accepted/source/evidence state. Live Now and Starting Soon are evaluated in UTC +at request time; the public query function takes an explicit evaluation instant. +The fixed category keys are Live Now, Starting Soon, Wrestling, Football, +Baseball, Soccer, Movies, and News. Item identity is content-derived and +independent of source enumeration; distinct channel references remain distinct +safe offerings. The accepted merged XMLTV artifact does not retain provider +per-programme provenance, entitlement, or launch URLs, so this slice reports +the accepted guide as its source and leaves entitlement, freshness, confidence, +DVR, and timeshift unknown. Structured promotion metadata is included only +when explicitly supplied; no promotion is guessed from a title. + +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: @@ -110,8 +133,9 @@ 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, full HTTP/API behavior, and appearance modes remain -future work. The local server foundation does not change those deployment claims. +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. #### Reuse existing GUI work diff --git a/docs/developer/DEVELOPER_GUIDE.md b/docs/developer/DEVELOPER_GUIDE.md index fb3d632..0c43f61 100644 --- a/docs/developer/DEVELOPER_GUIDE.md +++ b/docs/developer/DEVELOPER_GUIDE.md @@ -439,7 +439,30 @@ Follow [CONTRIBUTING.md](../../CONTRIBUTING.md) for the branch, commit, and revi ChannelForge's primary UI is a browser-based local web UI served by the ChannelForge engine. The browser consumes the engine's documented HTTP/API boundary; it does not host a second state authority or receive direct access to provider files, accepted generations, private paths, or local processes. -The first web-server foundation is a read-only loopback server. It provides a beginner-facing placeholder shell and safe health/status responses. Status reads validated accepted-generation metadata only to derive lineup status; it does not read provider data or change any lineup state. +The local server is loopback-only. It provides a beginner-facing shell and safe +health/status responses; status reads validated accepted-generation metadata +only. Guided Setup proposal/acceptance and source-refresh operations are +separate engine-owned routes. Reads never trigger source acquisition. + +The One Guide read API projects the verified accepted XMLTV artifact to the +versioned `one-guide/v1` contract in +[`one-guide-projection.schema.json`](../../schemas/one-guide-projection.schema.json). +The projection does not run accepted-state recovery, enumerate configured +sources, fetch the network, or mutate state. Query functions require an +explicit `EvaluationTimeUtc`; HTTP requests evaluate at request time. Starting +Soon means starts strictly after evaluation and no more than two hours later. +Live intervals are half-open: start is included, stop is excluded. List routes +accept `limit` (1–100) and `offset` (nonnegative decimal integer); responses +include total counts and truncation flags. Each item returns at most 16 +offerings and reports `OfferingCount` and `OfferingsTruncated`. + +The accepted merged XMLTV does not preserve provider-level per-programme +provenance. The initial API therefore labels its origin `Accepted guide` and +uses safe channel references for offerings; it reports availability as +guide-only and leaves entitlement, freshness, confidence, DVR, and timeshift +unknown. Promotion is a structured optional `{ Id, Name }` value and is never +inferred from title text. This is a read contract, not playback, provider +selection, or authenticated LAN access. The primary deployment modes are: @@ -489,22 +512,30 @@ protected, served without directory listings, and unsupported asset types return `404`. There is no SPA fallback: an unknown static path returns `404`. `GET` and `HEAD` return the same status and content metadata. -The read-only endpoints remain: - -| Method | Path | Purpose | -| ------------- | ------------- | ----------------------------------------------- | -| `GET`, `HEAD` | `/` | Built UI or safe placeholder shell | -| `GET`, `HEAD` | `/health` | Safe health/status JSON | -| `GET`, `HEAD` | `/api/status` | Safe status contract consumed by the browser UI | - -The dashboard is presentation-only. It makes one same-origin `GET` request and -never sends `POST`, `PUT`, `PATCH`, or `DELETE`. Setup and review actions remain -future until their engine/API contracts are implemented. Only the engine may -own candidate generation, review, acceptance, reports, and output publication. -The server and browser expose no provider URLs, credentials, private paths, -hashes, generation IDs, accepted-generation contents, or parser details. -Stop the foreground server with `Ctrl+C`. The server has no Docker, Windows -service, packaging, release, deployment, or tester-build behavior. +The read endpoints are: + +| Method | Path | Purpose | +| ------------- | -------------------------------- | ----------------------------------------------- | +| `GET`, `HEAD` | `/` | Built UI or safe placeholder shell | +| `GET`, `HEAD` | `/health` | Safe health/status JSON | +| `GET`, `HEAD` | `/api/status` | Safe status contract consumed by the browser UI | +| `GET`, `HEAD` | `/api/one-guide/live-now` | Accepted programmes live at request evaluation | +| `GET`, `HEAD` | `/api/one-guide/starting-soon` | Accepted programmes starting within two hours | +| `GET`, `HEAD` | `/api/one-guide/category/{key}` | Bounded items in a fixed One Guide category | +| `GET`, `HEAD` | `/api/one-guide/items/{item-id}` | One deterministic item detail | + +Category keys: `live-now`, `starting-soon`, `wrestling`, `football`, `baseball`, +`soccer`, `movies`, `news`. Responses use UTC timestamps and contain no raw +URLs, credentials, private paths, accepted hashes, or parser diagnostics. The +server remains loopback-only; authentication and LAN access are not implemented. + +The dashboard remains presentation-only and currently consumes `/api/status`; +it does not yet render the One Guide routes. Guided Setup and refresh requests +remain engine-owned operations. The browser has no direct filesystem or +provider-file access. The safe One Guide DTO is a deliberate projection of +accepted guide content, not the raw accepted-generation bytes. Stop the +foreground server with `Ctrl+C`. Docker, Windows service, packaging, release, +deployment, and tester-build behavior remain unavailable. ### Reusable existing GUI work diff --git a/docs/user/CURRENT_LIMITATIONS.md b/docs/user/CURRENT_LIMITATIONS.md index 76ea43f..dead697 100644 --- a/docs/user/CURRENT_LIMITATIONS.md +++ b/docs/user/CURRENT_LIMITATIONS.md @@ -18,18 +18,38 @@ ChannelForge is **Early Alpha**. Treat everything below as the honest, current s - Browser Guided Setup remains a one-playlist flow with an optional XMLTV upload through `/api/guided-setup/proposal` and `/api/guided-setup/accept`; it requires explicit acknowledgement, reuses immutable accepted-state publication, and leaves provider, downstream, and scheduler state unchanged. - Durable local source enrollment after browser acceptance, including optional XMLTV, no-guide mode, managed-root integrity checks, restart-safe redacted status, manual review-only refresh, and preservation of accepted lineup state when source refresh fails. - A Stage A/B/C/D read-only guide-intelligence contract for provider display text, M3U metadata, XMLTV, documented AED-derived evidence, schedule evidence, accepted knowledge, native event-pattern candidates, and beginner review reports. Stage D wires the report-only event-pattern preview into `scripts/Build-My-Lineup.ps1`; it writes deterministic redacted JSON/Markdown/text reports and never publishes a guide or mutates provider, downstream, or accepted state. +- A bounded, read-only One Guide API for Live Now, Starting Soon, fixed categories, and item details. It projects only the verified accepted XMLTV guide, makes no network requests, and does not change saved state. Query results use stable item IDs, safe channel references, and a fixed eight-key taxonomy. - Existing GUI work is preserved as a reusable React/TypeScript layout, design-token, Guided Setup, validation/review, and saved-lineup reference. Its Tauri wrapper is optional packaging work, not the primary product UI. - The optional Tauri saved-lineup flow prepares a read-only candidate plan, permits saving only for a native **Checked** match, requires accessible explicit acknowledgement, and navigates to a redacted accepted-state view after native success. The primary UI architecture is a browser-based local web UI served by the -ChannelForge engine. The React/Vite application consumes same-origin -`GET /api/status` and, from Guided Setup, sends browser-selected bytes to -`POST /api/guided-setup/proposal`. The proposal response is candidate-only: -it reports aggregate channel/guide coverage, an opaque review handle, and fixed -safety classifications. It does not expose raw source values or candidate -hashes. A separate acknowledgement request to -`POST /api/guided-setup/accept` commits only the exact server-owned reviewed -candidate through the engine acceptance boundary. +ChannelForge engine. The current landing UI consumes same-origin +`GET /api/status`. Guided Setup sends browser-selected bytes to +`POST /api/guided-setup/proposal` and requires separate explicit acceptance. + +The proposal response is candidate-only: it reports aggregate channel/guide +coverage, an opaque review handle, and fixed safety classifications. It does +not expose raw source values or candidate hashes. A separate acknowledgement +request to `POST /api/guided-setup/accept` commits only the exact server-owned +reviewed candidate through the engine acceptance boundary. +The engine also offers read-only `GET`/`HEAD` routes: +`/api/one-guide/live-now`, `/api/one-guide/starting-soon`, +`/api/one-guide/category/{key}`, and `/api/one-guide/items/{item-id}`. +Responses follow the versioned +[`one-guide-projection.schema.json`](../../schemas/one-guide-projection.schema.json) +contract. The landing UI does not yet show One Guide pages. + +One Guide reads only the verified accepted XMLTV artifact. It does not fetch +providers, enumerate current source configuration, or repair accepted state. +Starting Soon means a programme starts after now and within two hours; Live Now +includes its start but excludes its stop. The eight category keys are +`live-now`, `starting-soon`, `wrestling`, `football`, `baseball`, `soccer`, +`movies`, and `news`. Results are paged with `limit` (1–100) and `offset`; +titles and optional metadata are redacted and bounded. The merged accepted +guide does not preserve source identity per programme, so offerings identify +the accepted guide and a safe channel reference. Entitlement, freshness, +confidence, DVR, timeshift, and playback are not available in this slice. The +local server is loopback-only and does not provide authenticated LAN access. ![Accepted browser Guided Setup review](assets/guided-browser-accepted.png) @@ -76,7 +96,7 @@ The following describes preserved prototype/reference work, not a primary deploy | Area | Status | | ----------------------------------------- | ------------------------------------------------------------------------------------------------- | -| Browser web UI served by engine | Built UI with read-only `/api/status`, durable review sessions, and explicit immutable acceptance | +| Browser web UI served by engine | Status dashboard; engine offers read-only One Guide API; Guided Setup acceptance remains explicit | | Docker deployment | Architecture recorded; implementation pending | | Windows server/service install | Future deployment mode; not implemented | | Optional Tauri Workbench shell/navigation | Works now (prototype) | @@ -100,7 +120,8 @@ The following describes preserved prototype/reference work, not a primary deploy - **No fuzzy or target-specific guide assignment.** The build reports exact, unambiguous M3U `tvg-id` to XMLTV channel-id bindings, plus unbound, ambiguous, and XMLTV-only identities. It does not guess, perform fuzzy matching, or rewrite the separate canonical M3U/XMLTV outputs for a downstream target. - **No automatic Plex target configuration or refresh.** The generated XMLTV file and exact identity-binding report are separate outputs that downstream Plex configuration must consume explicitly. - **No automatic Plex refresh.** Re-running the build regenerates `output/merged.m3u`; pointing Plex at the new file or refreshing its channel list is a manual step. -- **The complete event-guide workflow is not implemented yet.** Stage D provides the beginner workflow's report-only event-pattern preview, and Stage E provides a read-only future-acceptance plan. Stable title/time/pattern identity is separated from volatile statistics, game summaries, standings, and roster/player facts; those details are omitted or marked for review unless freshness, season context, provenance, and contradiction checks prove them current. The current Stage A/B path does not synthesize those volatile facts; Stage E only evaluates optional safe metadata when a producer supplies it. Neither stage accepts or adopts rules, publishes guides, mutates provider/downstream/accepted state, or creates a learned-rule store. Schedule-source adapters, documented AED-definition JSON import, expert regex/date/time overrides, unattended event refresh, automatic relearning/adoption, beginner GUI integration, and guide publication remain future work. +- **The complete event-guide workflow is not implemented yet.** The One Guide read slice provides a safe schedule projection and basic category queries, not a learned event model or full discovery experience. Stage D still provides the report-only event-pattern preview, and Stage E a read-only future-acceptance plan. Volatile statistics, game summaries, standings, and roster/player facts remain omitted or review-only unless freshness, season context, provenance, and contradiction checks prove them current. Neither stage accepts or adopts rules, publishes guides, mutates provider/downstream/accepted state, or creates a learned-rule store. Schedule-source adapters, documented AED-definition JSON import, expert regex/date/time overrides, unattended event refresh, automatic relearning/adoption, beginner GUI integration, and guide publication remain future work. + Stable title/time/pattern identity remains separate from volatile facts. Stage A/B does not synthesize those facts; Stage E evaluates safe metadata only when a producer supplies it. ## Repository and product release status diff --git a/schemas/one-guide-projection.schema.json b/schemas/one-guide-projection.schema.json new file mode 100644 index 0000000..01bf569 --- /dev/null +++ b/schemas/one-guide-projection.schema.json @@ -0,0 +1,179 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://channelforge.invalid/schemas/one-guide-projection.schema.json", + "title": "ChannelForge One Guide read projection", + "description": "Bounded, deterministic, read-only projection for future guide clients.", + "type": "object", + "additionalProperties": false, + "required": [ + "Version", + "EvaluationTimeUtc", + "Query", + "Offset", + "MaximumItems", + "TotalCount", + "ItemsTruncated", + "Items" + ], + "properties": { + "Version": { "const": "one-guide/v1" }, + "EvaluationTimeUtc": { "type": "string", "format": "date-time" }, + "Query": { "enum": ["LiveNow", "StartingSoon", "Category", "Details"] }, + "CategoryKey": { + "enum": [ + "live-now", + "starting-soon", + "wrestling", + "football", + "baseball", + "soccer", + "movies", + "news" + ] + }, + "Offset": { "type": "integer", "minimum": 0 }, + "MaximumItems": { "type": "integer", "minimum": 1, "maximum": 100 }, + "TotalCount": { "type": "integer", "minimum": 0 }, + "ItemsTruncated": { "type": "boolean" }, + "Items": { + "type": "array", + "maxItems": 100, + "items": { "$ref": "#/$defs/item" } + } + }, + "$defs": { + "categoryKey": { + "enum": [ + "live-now", + "starting-soon", + "wrestling", + "football", + "baseball", + "soccer", + "movies", + "news" + ] + }, + "item": { + "type": "object", + "additionalProperties": false, + "required": [ + "ItemId", + "Kind", + "Title", + "StartUtc", + "StopUtc", + "Status", + "CategoryKeys", + "Sport", + "League", + "HomeParticipant", + "AwayParticipant", + "Promotion", + "Offerings", + "OfferingCount", + "OfferingsTruncated", + "FreshnessState", + "ConfidenceState", + "ConfidenceScore" + ], + "properties": { + "ItemId": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "Kind": { + "enum": ["Programme", "Event", "Movie", "SeriesEpisode", "Other"] + }, + "Title": { "type": "string", "minLength": 1, "maxLength": 256 }, + "Subtitle": { "type": ["string", "null"], "maxLength": 256 }, + "Description": { "type": ["string", "null"], "maxLength": 512 }, + "EpisodeNumber": { "type": ["string", "null"], "maxLength": 128 }, + "StartUtc": { "type": "string", "format": "date-time" }, + "StopUtc": { "type": "string", "format": "date-time" }, + "Status": { "enum": ["Live", "StartingSoon", "Upcoming", "Past"] }, + "CategoryKeys": { + "type": "array", + "uniqueItems": true, + "items": { "$ref": "#/$defs/categoryKey" } + }, + "Sport": { "type": ["string", "null"], "maxLength": 128 }, + "League": { "type": ["string", "null"], "maxLength": 128 }, + "HomeParticipant": { "type": ["string", "null"], "maxLength": 128 }, + "AwayParticipant": { "type": ["string", "null"], "maxLength": 128 }, + "Promotion": { + "anyOf": [ + { "type": "null" }, + { + "type": "object", + "additionalProperties": false, + "required": ["Id", "Name"], + "properties": { + "Id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._:-]{0,127}$" + }, + "Name": { "type": "string", "minLength": 1, "maxLength": 128 } + } + } + ] + }, + "OfferingCount": { "type": "integer", "minimum": 1 }, + "OfferingsTruncated": { "type": "boolean" }, + "Offerings": { + "type": "array", + "minItems": 1, + "maxItems": 16, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "OfferingId", + "SourceId", + "SourceLabel", + "Availability", + "Entitlement", + "Launch", + "DvrSupported", + "TimeshiftSupported" + ], + "properties": { + "OfferingId": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "SourceId": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._:-]{0,127}$" + }, + "SourceLabel": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "Availability": { "const": "GuideOnly" }, + "Entitlement": { "const": "Unknown" }, + "Launch": { + "type": "object", + "additionalProperties": false, + "required": ["Kind", "ChannelReference"], + "properties": { + "Kind": { "const": "Channel" }, + "ChannelReference": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._:-]{0,127}$" + } + } + }, + "DvrSupported": { "type": ["boolean", "null"] }, + "TimeshiftSupported": { "type": ["boolean", "null"] } + } + } + }, + "FreshnessState": { + "enum": ["Current", "Stale", "Unavailable", "Unknown"] + }, + "ConfidenceState": { "enum": ["Confirmed", "NeedsReview", "Unknown"] }, + "ConfidenceScore": { + "type": ["integer", "null"], + "minimum": 0, + "maximum": 100 + } + } + } + } +} diff --git a/src/ChannelForge/ChannelForge.psd1 b/src/ChannelForge/ChannelForge.psd1 index bfeca25..2ee85c2 100644 --- a/src/ChannelForge/ChannelForge.psd1 +++ b/src/ChannelForge/ChannelForge.psd1 @@ -111,6 +111,7 @@ FunctionsToExport = @( 'Get-ChannelForgeEnrolledSourceInput' 'Set-ChannelForgeSourceEnrollmentRefreshState' 'Get-ChannelForgeGuideReadiness' + 'Get-ChannelForgeOneGuide' 'Get-ChannelForgeWebStatus' 'Read-ChannelForgeEpgSource' 'Read-ChannelForgeProvider' diff --git a/src/ChannelForge/Private/Get-ChannelForgeWebOneGuideResponse.ps1 b/src/ChannelForge/Private/Get-ChannelForgeWebOneGuideResponse.ps1 new file mode 100644 index 0000000..ef95c94 --- /dev/null +++ b/src/ChannelForge/Private/Get-ChannelForgeWebOneGuideResponse.ps1 @@ -0,0 +1,90 @@ +function Get-ChannelForgeWebOneGuidePaging { + param([AllowEmptyString()][string]$QueryString = '') + + if ($QueryString.Length -gt 128) { throw 'One Guide query is too long.' } + $maximumItems = 100 + $offset = 0 + $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + if (-not [string]::IsNullOrEmpty($QueryString)) { + foreach ($part in $QueryString.Split('&')) { + $pair = $part.Split('=', 2) + if ($pair.Length -ne 2 -or [string]::IsNullOrWhiteSpace($pair[0]) -or -not $seen.Add($pair[0])) { + throw 'One Guide query parameters are invalid.' + } + $value = 0 + if (-not [int]::TryParse($pair[1], [Globalization.NumberStyles]::None, [Globalization.CultureInfo]::InvariantCulture, [ref]$value)) { + throw 'One Guide query values must be decimal integers.' + } + switch ($pair[0].ToLowerInvariant()) { + 'limit' { + if ($value -lt 1 -or $value -gt 100) { throw 'One Guide limit is outside the supported range.' } + $maximumItems = $value + } + 'offset' { $offset = $value } + default { throw 'One Guide query parameters are unsupported.' } + } + } + } + return [pscustomobject][ordered]@{ MaximumItems = $maximumItems; Offset = $offset } +} + +function Get-ChannelForgeWebOneGuideResponse { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$RepositoryRoot, + [Parameter(Mandatory)][System.Collections.IDictionary]$Headers, + [Parameter(Mandatory)][string]$ContentType, + [Parameter(Mandatory)][ValidateSet('LiveNow', 'StartingSoon', 'Category', 'Details')][string]$Query, + [AllowNull()][string]$CategoryKey, + [AllowNull()][string]$ItemId, + [ValidateRange(1, 100)][int]$MaximumItems = 100, + [ValidateRange(0, 2147483647)][int]$Offset = 0 + ) + + try { + if ($Query -eq 'Details' -and $Offset -ne 0) { + return New-ChannelForgeWebResponse -StatusCode 400 -ContentType $ContentType -Body (@{ Error = 'invalid-query'; Message = 'Item detail does not support an offset.' } | ConvertTo-Json -Compress) -Headers $Headers + } + $paths = Get-ChannelForgeGenerationPaths -RepositoryRoot $RepositoryRoot + # Read the validated immutable generation directly. The recovery wrapper + # is intentionally not used because it may repair durable state. + $snapshot = Get-ChannelForgeGenerationCurrentSnapshot -RepositoryRoot $RepositoryRoot -Paths $paths + $programmes = @() + if ($null -ne $snapshot -and $null -ne $snapshot.XMLTV) { + $bytes = [byte[]]$snapshot.XMLTV.Bytes + if ($bytes.Length -gt 268435456) { throw 'Accepted guide exceeds the read projection limit.' } + $stream = [System.IO.MemoryStream]::new($bytes, $false) + try { + $programmes = @(Read-ChannelForgeXmltvDocument ` + -Stream $stream ` + -SourceId 'accepted-guide' ` + -SourcePath $snapshot.XMLTV.Path ` + -SourceKind local ` + -MaxDocumentBytes 268435456 ` + -CandidateContractVersion 'blocker-2-contract/v8') + } + finally { $stream.Dispose() } + } + $arguments = @{ + Query = $Query + Programmes = $programmes + EvaluationTimeUtc = [datetimeoffset]::UtcNow + MaximumItems = $MaximumItems + Offset = $Offset + } + if (-not [string]::IsNullOrWhiteSpace($CategoryKey)) { $arguments.CategoryKey = $CategoryKey } + if (-not [string]::IsNullOrWhiteSpace($ItemId)) { $arguments.ItemId = $ItemId } + $projection = Get-ChannelForgeOneGuide @arguments + if ($Query -eq 'Details' -and $projection.TotalCount -eq 0) { + return New-ChannelForgeWebResponse -StatusCode 404 -ContentType $ContentType -Body (@{ Error = 'not-found'; Message = 'Guide item not found.' } | ConvertTo-Json -Compress) -Headers $Headers + } + $body = ConvertTo-Json -InputObject $projection -Depth 10 -Compress + return New-ChannelForgeWebResponse -StatusCode 200 -ContentType $ContentType -Body $body -Headers $Headers + } + catch { + return New-ChannelForgeWebResponse -StatusCode 503 -ContentType $ContentType -Body (@{ + Error = 'one-guide-unavailable' + Message = 'The accepted guide is temporarily unavailable.' + } | ConvertTo-Json -Compress) -Headers $Headers + } +} diff --git a/src/ChannelForge/Private/Get-ChannelForgeWebResponse.ps1 b/src/ChannelForge/Private/Get-ChannelForgeWebResponse.ps1 index a86c86a..27f8d39 100644 --- a/src/ChannelForge/Private/Get-ChannelForgeWebResponse.ps1 +++ b/src/ChannelForge/Private/Get-ChannelForgeWebResponse.ps1 @@ -198,6 +198,7 @@ function Get-ChannelForgeWebResponse { $contentType = 'application/json; charset=utf-8' $methodName = if ($null -eq $Method) { '' } else { $Method.ToUpperInvariant() } $requestPath = if ([string]::IsNullOrWhiteSpace($Path)) { '/' } else { ($Path -split '\?', 2)[0] } + $requestQuery = if ($Path -match '\?') { [string](($Path -split '\?', 2)[1]) } else { '' } $acceptPath = '/api/guided-setup/accept' if ($requestPath.ToLowerInvariant() -eq $acceptPath) { if ($methodName -ne 'POST') { @@ -255,6 +256,34 @@ function Get-ChannelForgeWebResponse { Message = 'Only read-only GET and HEAD requests are supported.' } | ConvertTo-Json -Compress) -Headers $headers } + $oneGuideRoute = [regex]::Match($requestPath, '^/api/one-guide/(live-now|starting-soon|category/([^/]+)|items/([^/]+))$', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) + if ($oneGuideRoute.Success) { + try { + $paging = Get-ChannelForgeWebOneGuidePaging -QueryString $requestQuery + } + catch { + return New-ChannelForgeWebResponse -StatusCode 400 -ContentType $contentType -Body (@{ Error = 'invalid-query'; Message = 'One Guide query parameters are invalid.' } | ConvertTo-Json -Compress) -Headers $commonHeaders + } + $routeValue = $oneGuideRoute.Groups[1].Value + if ($routeValue -ieq 'live-now') { + return Get-ChannelForgeWebOneGuideResponse -RepositoryRoot $RepositoryRoot -Headers $commonHeaders -ContentType $contentType -Query LiveNow -MaximumItems $paging.MaximumItems -Offset $paging.Offset + } + if ($routeValue -ieq 'starting-soon') { + return Get-ChannelForgeWebOneGuideResponse -RepositoryRoot $RepositoryRoot -Headers $commonHeaders -ContentType $contentType -Query StartingSoon -MaximumItems $paging.MaximumItems -Offset $paging.Offset + } + if ($oneGuideRoute.Groups[2].Success) { + $categoryKey = $oneGuideRoute.Groups[2].Value.ToLowerInvariant() + if ($categoryKey -notin @('live-now', 'starting-soon', 'wrestling', 'football', 'baseball', 'soccer', 'movies', 'news')) { + return New-ChannelForgeWebResponse -StatusCode 400 -ContentType $contentType -Body (@{ Error = 'invalid-category'; Message = 'The guide category is not supported.' } | ConvertTo-Json -Compress) -Headers $commonHeaders + } + return Get-ChannelForgeWebOneGuideResponse -RepositoryRoot $RepositoryRoot -Headers $commonHeaders -ContentType $contentType -Query Category -CategoryKey $categoryKey -MaximumItems $paging.MaximumItems -Offset $paging.Offset + } + $itemId = $oneGuideRoute.Groups[3].Value + if ($itemId -notmatch '^[A-Fa-f0-9]{64}$') { + return New-ChannelForgeWebResponse -StatusCode 400 -ContentType $contentType -Body (@{ Error = 'invalid-item-id'; Message = 'The guide item identifier is invalid.' } | ConvertTo-Json -Compress) -Headers $commonHeaders + } + return Get-ChannelForgeWebOneGuideResponse -RepositoryRoot $RepositoryRoot -Headers $commonHeaders -ContentType $contentType -Query Details -ItemId $itemId.ToLowerInvariant() -MaximumItems $paging.MaximumItems -Offset $paging.Offset + } switch ($requestPath.ToLowerInvariant()) { '/' { diff --git a/src/ChannelForge/Public/Get-ChannelForgeOneGuide.ps1 b/src/ChannelForge/Public/Get-ChannelForgeOneGuide.ps1 new file mode 100644 index 0000000..8925365 --- /dev/null +++ b/src/ChannelForge/Public/Get-ChannelForgeOneGuide.ps1 @@ -0,0 +1,293 @@ +function Get-ChannelForgeOneGuide { + [CmdletBinding()] + param( + [Parameter(Mandatory)][ValidateSet('LiveNow', 'StartingSoon', 'Category', 'Details')][string]$Query, + [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Programmes, + [Parameter(Mandatory)][datetimeoffset]$EvaluationTimeUtc, + [ValidateSet('live-now', 'starting-soon', 'wrestling', 'football', 'baseball', 'soccer', 'movies', 'news')][string]$CategoryKey, + [ValidatePattern('^[a-f0-9]{64}$')][string]$ItemId, + [ValidateRange(1, 100)][int]$MaximumItems = 100, + [ValidateRange(0, 2147483647)][int]$Offset = 0 + ) + + $evaluation = $EvaluationTimeUtc.ToUniversalTime() + $startingSoonEnd = $evaluation.AddHours(2) + $itemGroups = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::Ordinal) + $taxonomyOrder = @('live-now', 'starting-soon', 'wrestling', 'football', 'baseball', 'soccer', 'movies', 'news') + $taxonomy = @{ + wrestling = 'wrestling' + football = 'football' + baseball = 'baseball' + soccer = 'soccer' + movies = 'movies' + movie = 'movies' + news = 'news' + } + + function Get-InputValue { + param([AllowNull()][object]$InputObject, [Parameter(Mandatory)][string]$Name) + if ($null -eq $InputObject) { return $null } + if ($InputObject -is [System.Collections.IDictionary]) { + if ($InputObject.Contains($Name)) { return $InputObject[$Name] } + return $null + } + $property = $InputObject.PSObject.Properties[$Name] + if ($null -ne $property) { return $property.Value } + return $null + } + + function Get-SafeIdentifier { + param([AllowNull()][object]$Value, [Parameter(Mandatory)][string]$Domain, [string]$Fallback = '') + $raw = if ($null -eq $Value) { '' } else { ([string]$Value).Trim() } + if ([string]::IsNullOrWhiteSpace($raw)) { $raw = $Fallback } + if ([string]::IsNullOrWhiteSpace($raw)) { return '' } + $safe = ConvertTo-ChannelForgeGuideSafeText -Value $raw -MaximumLength 128 + if ($safe -match '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$') { return $safe.ToLowerInvariant() } + return ('cf-' + (Get-ChannelForgeDomainHash -Domain $Domain -InputObject $raw)) + } + + function Get-SafeOptionalText { + param([AllowNull()][object]$Value, [int]$MaximumLength = 128) + if ($null -eq $Value -or [string]::IsNullOrWhiteSpace([string]$Value)) { return $null } + $safe = ConvertTo-ChannelForgeGuideSafeText -Value $Value -MaximumLength $MaximumLength + if ([string]::IsNullOrWhiteSpace($safe)) { return $null } + return $safe + } + + foreach ($programme in $Programmes) { + if ($null -eq $programme) { throw 'One Guide source contains a null programme.' } + $title = ConvertTo-ChannelForgeGuideSafeText -Value (Get-InputValue $programme 'Title') -MaximumLength 256 + if ([string]::IsNullOrWhiteSpace($title)) { continue } + + $startValue = Get-InputValue $programme 'Start' + $stopValue = Get-InputValue $programme 'End' + $start = [datetimeoffset]::MinValue + $stop = [datetimeoffset]::MinValue + if ($null -eq $startValue -or $null -eq $stopValue -or + -not [datetimeoffset]::TryParse([string]$startValue, [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::AssumeUniversal -bor [Globalization.DateTimeStyles]::AdjustToUniversal, [ref]$start) -or + -not [datetimeoffset]::TryParse([string]$stopValue, [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::AssumeUniversal -bor [Globalization.DateTimeStyles]::AdjustToUniversal, [ref]$stop) -or + $stop -le $start) { + throw 'One Guide programme has an invalid time interval.' + } + $start = $start.ToUniversalTime() + $stop = $stop.ToUniversalTime() + $startText = $start.ToString('o', [Globalization.CultureInfo]::InvariantCulture) + $stopText = $stop.ToString('o', [Globalization.CultureInfo]::InvariantCulture) + + $subtitle = Get-SafeOptionalText (Get-InputValue $programme 'Subtitle') 256 + $description = Get-SafeOptionalText (Get-InputValue $programme 'Description') 512 + $episodeNumber = Get-SafeOptionalText (Get-InputValue $programme 'EpisodeNumber') 128 + $categories = [System.Collections.Generic.SortedSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($rawCategory in @(Get-InputValue $programme 'Categories')) { + if ($null -eq $rawCategory) { continue } + $categoryText = ConvertTo-ChannelForgeGuideSafeText -Value $rawCategory -MaximumLength 128 + $key = $categoryText.Trim().ToLowerInvariant() + if ($taxonomy.ContainsKey($key)) { [void]$categories.Add([string]$taxonomy[$key]) } + } + $declaredKind = [string](Get-InputValue $programme 'Kind') + if ($declaredKind -notin @('Programme', 'Event', 'Movie', 'SeriesEpisode', 'Other')) { $declaredKind = '' } + $kind = if ($categories.Contains('movies')) { 'Movie' } + elseif (-not [string]::IsNullOrWhiteSpace($episodeNumber)) { 'SeriesEpisode' } + elseif (@($categories | Where-Object { $_ -in @('wrestling', 'football', 'baseball', 'soccer') }).Count -gt 0) { 'Event' } + elseif (-not [string]::IsNullOrWhiteSpace($declaredKind)) { $declaredKind } + else { 'Programme' } + + $sport = Get-SafeOptionalText (Get-InputValue $programme 'Sport') 128 + $league = Get-SafeOptionalText (Get-InputValue $programme 'League') 128 + $homeParticipant = Get-SafeOptionalText (Get-InputValue $programme 'HomeParticipant') 128 + $awayParticipant = Get-SafeOptionalText (Get-InputValue $programme 'AwayParticipant') 128 + $promotion = $null + $promotionInput = Get-InputValue $programme 'Promotion' + if ($categories.Contains('wrestling') -and $null -ne $promotionInput) { + $promotionId = Get-SafeIdentifier (Get-InputValue $promotionInput 'Id') 'one-guide-promotion/v1' + $promotionName = Get-SafeOptionalText (Get-InputValue $promotionInput 'Name') 128 + if (-not [string]::IsNullOrWhiteSpace($promotionId) -and $null -ne $promotionName) { + $promotion = [ordered]@{ Id = $promotionId; Name = $promotionName } + } + } + + $status = if ($start -le $evaluation -and $stop -gt $evaluation) { 'Live' } + elseif ($start -gt $evaluation -and $start -le $startingSoonEnd) { 'StartingSoon' } + elseif ($start -gt $evaluation) { 'Upcoming' } + else { 'Past' } + if ($status -eq 'Live') { [void]$categories.Add('live-now') } + elseif ($status -eq 'StartingSoon') { [void]$categories.Add('starting-soon') } + + $safeTitleKey = [regex]::Replace($title.Trim().ToLowerInvariant(), '\s+', ' ') + $safeSubtitleKey = if ($null -eq $subtitle) { '' } else { [regex]::Replace($subtitle.Trim().ToLowerInvariant(), '\s+', ' ') } + # V1 correlation deliberately excludes classification and provider/channel + # metadata. Corroborating sources may disagree on those fields while still + # describing the same scheduled content. + $identity = [ordered]@{ + Title = $safeTitleKey + Subtitle = $safeSubtitleKey + EpisodeNumber = if ($null -eq $episodeNumber) { '' } else { $episodeNumber.ToLowerInvariant() } + StartUtc = $startText + StopUtc = $stopText + } + $itemId = Get-ChannelForgeDomainHash -Domain 'one-guide-item/v1' -InputObject $identity + + $sourceId = Get-SafeIdentifier (Get-InputValue $programme 'SourceId') 'one-guide-source/v1' 'accepted-guide' + $sourceLabel = Get-SafeOptionalText (Get-InputValue $programme 'SourceLabel') 128 + if ($null -eq $sourceLabel) { $sourceLabel = if ($sourceId -eq 'accepted-guide') { 'Accepted guide' } else { $sourceId } } + $channelReference = Get-SafeIdentifier (Get-InputValue $programme 'ChannelId') 'one-guide-channel/v1' + if ([string]::IsNullOrWhiteSpace($channelReference)) { continue } + $offeringId = Get-ChannelForgeDomainHash -Domain 'one-guide-offering/v1' -InputObject ([ordered]@{ + ItemId = $itemId + SourceId = $sourceId + ChannelReference = $channelReference + }) + $offering = [pscustomobject][ordered]@{ + OfferingId = $offeringId + SourceId = $sourceId + SourceLabel = $sourceLabel + Availability = 'GuideOnly' + Entitlement = 'Unknown' + Launch = [pscustomobject][ordered]@{ Kind = 'Channel'; ChannelReference = $channelReference } + DvrSupported = $null + TimeshiftSupported = $null + } + + if (-not $itemGroups.ContainsKey($itemId)) { + $itemGroups[$itemId] = [pscustomobject][ordered]@{ + Identity = $identity + ItemId = $itemId + Kind = $kind + Title = $title + Subtitle = $subtitle + Description = $description + EpisodeNumber = $episodeNumber + StartUtc = $startText + StopUtc = $stopText + Status = $status + CategoryKeys = [System.Collections.Generic.SortedSet[string]]::new([System.StringComparer]::Ordinal) + Sport = $sport + League = $league + HomeParticipant = $homeParticipant + AwayParticipant = $awayParticipant + Promotion = $promotion + Offerings = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::Ordinal) + } + } + $group = $itemGroups[$itemId] + if (-not $group.Offerings.ContainsKey($offeringId)) { + $group.Offerings[$offeringId] = $offering + } + elseif ([StringComparer]::Ordinal.Compare($sourceLabel, [string]$group.Offerings[$offeringId].SourceLabel) -lt 0) { + $group.Offerings[$offeringId].SourceLabel = $sourceLabel + } + if ([StringComparer]::Ordinal.Compare($title, [string]$group.Title) -lt 0) { $group.Title = $title } + if ($null -ne $description -and ($null -eq $group.Description -or [StringComparer]::Ordinal.Compare($description, [string]$group.Description) -lt 0)) { $group.Description = $description } + if ($null -ne $subtitle -and ($null -eq $group.Subtitle -or [StringComparer]::Ordinal.Compare($subtitle, [string]$group.Subtitle) -lt 0)) { $group.Subtitle = $subtitle } + if ($null -ne $episodeNumber -and ($null -eq $group.EpisodeNumber -or [StringComparer]::Ordinal.Compare($episodeNumber, [string]$group.EpisodeNumber) -lt 0)) { $group.EpisodeNumber = $episodeNumber } + foreach ($category in $categories) { [void]$group.CategoryKeys.Add($category) } + + foreach ($metadata in @( + @{ Name = 'Sport'; Value = $sport }, + @{ Name = 'League'; Value = $league }, + @{ Name = 'HomeParticipant'; Value = $homeParticipant }, + @{ Name = 'AwayParticipant'; Value = $awayParticipant } + )) { + $candidate = $metadata.Value + $current = $group.($metadata.Name) + if ($null -ne $candidate -and ($null -eq $current -or [StringComparer]::Ordinal.Compare([string]$candidate, [string]$current) -lt 0)) { + $group.($metadata.Name) = $candidate + } + } + if ($null -ne $promotion) { + $candidatePromotionKey = "$($promotion.Id)::$($promotion.Name)" + $currentPromotionKey = if ($null -eq $group.Promotion) { $null } else { "$($group.Promotion.Id)::$($group.Promotion.Name)" } + if ($null -eq $currentPromotionKey -or [StringComparer]::Ordinal.Compare($candidatePromotionKey, $currentPromotionKey) -lt 0) { + $group.Promotion = $promotion + } + } + } + + + $offeringComparison = [System.Comparison[object]]{ + param($left, $right) + $comparison = [StringComparer]::Ordinal.Compare([string]$left.SourceId, [string]$right.SourceId) + if ($comparison -eq 0) { $comparison = [StringComparer]::Ordinal.Compare([string]$left.Launch.ChannelReference, [string]$right.Launch.ChannelReference) } + if ($comparison -eq 0) { $comparison = [StringComparer]::Ordinal.Compare([string]$left.OfferingId, [string]$right.OfferingId) } + return $comparison + } + $itemComparison = [System.Comparison[object]]{ + param($left, $right) + $comparison = [StringComparer]::Ordinal.Compare([string]$left.StartUtc, [string]$right.StartUtc) + if ($comparison -eq 0) { $comparison = [StringComparer]::Ordinal.Compare([string]$left.Title, [string]$right.Title) } + if ($comparison -eq 0) { $comparison = [StringComparer]::Ordinal.Compare([string]$left.ItemId, [string]$right.ItemId) } + return $comparison + } + $items = [System.Collections.Generic.List[object]]::new() + foreach ($group in $itemGroups.Values) { + $allOfferings = @($group.Offerings.Values) + [Array]::Sort($allOfferings, $offeringComparison) + $offerings = @($allOfferings | Select-Object -First 16) + $orderedCategories = [System.Collections.Generic.List[string]]::new() + foreach ($taxonomyKey in $taxonomyOrder) { + if (@($group.CategoryKeys) -contains $taxonomyKey) { [void]$orderedCategories.Add($taxonomyKey) } + } + $finalKind = if (@($orderedCategories) -contains 'movies') { 'Movie' } + elseif (-not [string]::IsNullOrWhiteSpace([string]$group.EpisodeNumber)) { 'SeriesEpisode' } + elseif (@($orderedCategories | Where-Object { $_ -in @('wrestling', 'football', 'baseball', 'soccer') }).Count -gt 0) { 'Event' } + elseif (@($orderedCategories) -contains 'news') { 'Programme' } + else { 'Other' } + $items.Add([pscustomobject][ordered]@{ + ItemId = $group.ItemId + Kind = $finalKind + Title = $group.Title + Subtitle = $group.Subtitle + Description = $group.Description + EpisodeNumber = $group.EpisodeNumber + StartUtc = $group.StartUtc + StopUtc = $group.StopUtc + Status = $group.Status + CategoryKeys = @($orderedCategories) + Sport = $group.Sport + League = $group.League + HomeParticipant = $group.HomeParticipant + AwayParticipant = $group.AwayParticipant + Promotion = $group.Promotion + Offerings = $offerings + OfferingCount = $allOfferings.Count + OfferingsTruncated = ($allOfferings.Count -gt $offerings.Count) + FreshnessState = 'Unknown' + ConfidenceState = 'Unknown' + ConfidenceScore = $null + }) + } + $orderedItems = $items.ToArray() + [Array]::Sort($orderedItems, $itemComparison) + + if ($Query -eq 'Category') { + if ([string]::IsNullOrWhiteSpace($CategoryKey)) { throw 'CategoryKey is required for a category query.' } + $CategoryKey = $CategoryKey.ToLowerInvariant() + } + if ($Query -eq 'Details' -and [string]::IsNullOrWhiteSpace($ItemId)) { throw 'ItemId is required for a details query.' } + $selected = [System.Collections.Generic.List[object]]::new() + foreach ($item in $orderedItems) { + $include = switch ($Query) { + 'LiveNow' { $item.Status -ceq 'Live' } + 'StartingSoon' { $item.Status -ceq 'StartingSoon' } + 'Category' { $item.CategoryKeys -ccontains $CategoryKey } + 'Details' { [string]$item.ItemId -ceq $ItemId } + } + if ($include) { [void]$selected.Add($item) } + } + $totalCount = $selected.Count + $upperBound = [Math]::Min([int64]$totalCount, ([int64]$Offset + [int64]$MaximumItems)) + $returned = [System.Collections.Generic.List[object]]::new() + for ($index = $Offset; $index -lt $upperBound; $index++) { [void]$returned.Add($selected[$index]) } + $result = [ordered]@{ + Version = 'one-guide/v1' + EvaluationTimeUtc = $evaluation.ToString('o', [Globalization.CultureInfo]::InvariantCulture) + Query = $Query + Offset = $Offset + MaximumItems = $MaximumItems + TotalCount = $totalCount + ItemsTruncated = ([int64]$Offset + [int64]$returned.Count -lt [int64]$totalCount) + Items = @($returned.ToArray()) + } + if ($Query -eq 'Category') { $result.CategoryKey = $CategoryKey } + return [pscustomobject]$result +} diff --git a/tests/fixtures/one-guide/programmes.json b/tests/fixtures/one-guide/programmes.json new file mode 100644 index 0000000..a28305e --- /dev/null +++ b/tests/fixtures/one-guide/programmes.json @@ -0,0 +1,73 @@ +[ + { + "ChannelId": "channel-wwe-a", + "Start": "2026-10-01T12:30:00Z", + "End": "2026-10-01T14:00:00Z", + "Title": "Championship Main Event", + "Categories": ["Wrestling"], + "SourceId": "provider-a", + "SourceLabel": "Provider A", + "Sport": "Wrestling", + "League": "WWE", + "HomeParticipant": "Rhea Ripley", + "AwayParticipant": "Bianca Belair", + "Promotion": { "Id": "wwe", "Name": "WWE" } + }, + { + "ChannelId": "channel-wwe-b", + "Start": "2026-10-01T12:30:00Z", + "End": "2026-10-01T14:00:00Z", + "Title": "Championship Main Event", + "Categories": [], + "SourceId": "provider-b", + "SourceLabel": "Provider B" + }, + { + "ChannelId": "channel-football", + "Start": "2026-10-01T13:00:00Z", + "End": "2026-10-01T15:00:00Z", + "Title": "Football Match", + "Categories": ["Football"], + "SourceId": "provider-a" + }, + { + "ChannelId": "channel-baseball", + "Start": "2026-10-01T14:00:00Z", + "End": "2026-10-01T17:00:00Z", + "Title": "Baseball Game", + "Categories": ["Baseball"], + "SourceId": "provider-a" + }, + { + "ChannelId": "channel-soccer", + "Start": "2026-10-01T13:15:00Z", + "End": "2026-10-01T15:00:00Z", + "Title": "Soccer Match", + "Categories": ["Soccer"], + "SourceId": "provider-b" + }, + { + "ChannelId": "channel-movie", + "Start": "2026-10-01T15:00:00Z", + "End": "2026-10-01T17:00:00Z", + "Title": "Feature Film", + "Categories": ["Movies"], + "SourceId": "provider-b" + }, + { + "ChannelId": "channel-news-live", + "Start": "2026-10-01T11:30:00Z", + "End": "2026-10-01T12:30:00Z", + "Title": "Live News Bulletin", + "Categories": ["News"], + "SourceId": "provider-a" + }, + { + "ChannelId": "channel-news-soon", + "Start": "2026-10-01T12:15:00Z", + "End": "2026-10-01T13:00:00Z", + "Title": "News Update", + "Categories": ["News"], + "SourceId": "provider-b" + } +] diff --git a/tests/unit/OneGuide.Tests.ps1 b/tests/unit/OneGuide.Tests.ps1 new file mode 100644 index 0000000..6d30552 --- /dev/null +++ b/tests/unit/OneGuide.Tests.ps1 @@ -0,0 +1,186 @@ +BeforeAll { + $script:RepoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) + $script:ModulePath = Join-Path $script:RepoRoot 'src\ChannelForge\ChannelForge.psd1' + $script:SchemaPath = Join-Path $script:RepoRoot 'schemas\one-guide-projection.schema.json' + Import-Module $script:ModulePath -Force + + + $script:FixturePath = Join-Path $script:RepoRoot 'tests\fixtures\one-guide\programmes.json' + + function Get-OneGuideFixtureProgrammes { + $rows = Get-Content -LiteralPath $script:FixturePath -Raw | ConvertFrom-Json -DateKind String + return @( + foreach ($row in $rows) { + $start = [datetimeoffset]::Parse([string]$row.Start, [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::AdjustToUniversal) + $end = [datetimeoffset]::Parse([string]$row.End, [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::AdjustToUniversal) + $programme = New-OneGuideTestProgramme -ChannelId $row.ChannelId -Start $start -End $end -Title $row.Title -Categories $row.Categories -SourceId $row.SourceId + foreach ($name in @('SourceLabel', 'Sport', 'League', 'HomeParticipant', 'AwayParticipant', 'Promotion')) { + $property = $row.PSObject.Properties[$name] + if ($null -ne $property) { $programme | Add-Member -NotePropertyName $name -NotePropertyValue $property.Value } + } + $programme + } + ) + } + function New-OneGuideTestProgramme { + param( + [Parameter(Mandatory)][string]$ChannelId, + [Parameter(Mandatory)][datetimeoffset]$Start, + [Parameter(Mandatory)][datetimeoffset]$End, + [Parameter(Mandatory)][string]$Title, + [string[]]$Categories = @(), + [string]$SourceId = 'provider-a', + [string]$Subtitle = '', + [string]$Description = '', + [string]$EpisodeNumber = '' + ) + return New-ChannelForgeProgramme -ChannelId $ChannelId -Start $Start -End $End -Title $Title -Categories $Categories -SourceId $SourceId -Subtitle $Subtitle -Description $Description -EpisodeNumber $EpisodeNumber + } +} + +Describe 'One Guide read projection' { + BeforeEach { + $script:Evaluation = [datetimeoffset]::Parse('2026-10-01T12:00:00Z') + } + + It 'uses half-open live intervals and an inclusive two-hour Starting Soon boundary' { + $programmes = @( + (New-OneGuideTestProgramme 'starts-now' $Evaluation ($Evaluation.AddHours(1)) 'Starts Now'), + (New-OneGuideTestProgramme 'ends-now' ($Evaluation.AddHours(-1)) $Evaluation 'Ends Now'), + (New-OneGuideTestProgramme 'starts-in-two-hours' ($Evaluation.AddHours(2)) ($Evaluation.AddHours(3)) 'Two Hours'), + (New-OneGuideTestProgramme 'just-late' ($Evaluation.AddHours(2).AddSeconds(1)) ($Evaluation.AddHours(3)) 'Just Late') + ) + + $live = Get-ChannelForgeOneGuide -Query LiveNow -Programmes $programmes -EvaluationTimeUtc $Evaluation + $soon = Get-ChannelForgeOneGuide -Query StartingSoon -Programmes $programmes -EvaluationTimeUtc $Evaluation + + @($live.Items.Title) | Should -Be @('Starts Now') + @($soon.Items.Title) | Should -Be @('Two Hours') + $live.EvaluationTimeUtc | Should -Be $Evaluation.ToString('o') + } + + It 'emits only the fixed taxonomy and keeps wrestling promotion structured' { + $programmes = Get-OneGuideFixtureProgrammes + $expectedTaxonomy = @('live-now', 'starting-soon', 'wrestling', 'football', 'baseball', 'soccer', 'movies', 'news') + + foreach ($key in $expectedTaxonomy) { + $result = Get-ChannelForgeOneGuide -Query Category -CategoryKey $key -Programmes $programmes -EvaluationTimeUtc $Evaluation + $result.Query | Should -Be 'Category' + $result.CategoryKey | Should -Be $key + $result.TotalCount | Should -BeGreaterThan 0 + } + $items = Get-ChannelForgeOneGuide -Query StartingSoon -Programmes $programmes -EvaluationTimeUtc $Evaluation + $wrestling = Get-ChannelForgeOneGuide -Query Category -CategoryKey wrestling -Programmes $programmes -EvaluationTimeUtc $Evaluation + $items.Items[0].Promotion | Should -BeNullOrEmpty + $wrestling.Items[0].Promotion.Id | Should -Be 'wwe' + $wrestling.Items[0].Promotion.Name | Should -Be 'WWE' + @($wrestling.Items[0].CategoryKeys | Where-Object { $_ -notin $expectedTaxonomy }) | Should -BeNullOrEmpty + } + + It 'groups one event across offerings despite classification disagreement with order-independent output' { + $programmes = Get-OneGuideFixtureProgrammes + $reversed = @($programmes) + [array]::Reverse($reversed) + $forward = Get-ChannelForgeOneGuide -Query Category -CategoryKey wrestling -Programmes $programmes -EvaluationTimeUtc $Evaluation + $reverse = Get-ChannelForgeOneGuide -Query Category -CategoryKey wrestling -Programmes $reversed -EvaluationTimeUtc $Evaluation + $item = $forward.Items[0] + + $item.ItemId | Should -Be $reverse.Items[0].ItemId + ($forward | ConvertTo-Json -Depth 10 -Compress) | Should -Be ($reverse | ConvertTo-Json -Depth 10 -Compress) + $item.Offerings.Count | Should -Be 2 + $item.OfferingCount | Should -Be 2 + $item.OfferingsTruncated | Should -BeFalse + $item.Kind | Should -Be 'Event' + $item.CategoryKeys | Should -Contain 'wrestling' + $item.Sport | Should -Be 'Wrestling' + $item.League | Should -Be 'WWE' + $item.Promotion.Id | Should -Be 'wwe' + @($item.Offerings.Launch.ChannelReference | Sort-Object) | Should -Be @('channel-wwe-a', 'channel-wwe-b') + @($item.Offerings.SourceId | Sort-Object) | Should -Be @('provider-a', 'provider-b') + $item.ItemId | Should -Not -Be $item.Offerings[0].Launch.ChannelReference + } + + It 'selects repeated-offering metadata deterministically when source rows disagree' { + $first = New-OneGuideTestProgramme 'shared-channel' ($Evaluation.AddMinutes(20)) ($Evaluation.AddHours(1)) 'Episode Event' @('Football') 'provider-a' -EpisodeNumber 's01e01' -Description 'Zulu description' + $second = New-OneGuideTestProgramme 'shared-channel' ($Evaluation.AddMinutes(20)) ($Evaluation.AddHours(1)) 'Episode Event' @('Football') 'provider-a' -EpisodeNumber 'S01E01' -Description 'Alpha description' + $first | Add-Member -NotePropertyName SourceLabel -NotePropertyValue 'Zulu Source' + $second | Add-Member -NotePropertyName SourceLabel -NotePropertyValue 'Alpha Source' + $forward = Get-ChannelForgeOneGuide -Query StartingSoon -Programmes @($first, $second) -EvaluationTimeUtc $Evaluation + $reverse = Get-ChannelForgeOneGuide -Query StartingSoon -Programmes @($second, $first) -EvaluationTimeUtc $Evaluation + + ($forward | ConvertTo-Json -Depth 10 -Compress) | Should -Be ($reverse | ConvertTo-Json -Depth 10 -Compress) + $forward.Items[0].Description | Should -Be 'Alpha description' + $forward.Items[0].EpisodeNumber | Should -Be 'S01E01' + $forward.Items[0].Offerings.Count | Should -Be 1 + $forward.Items[0].Offerings[0].SourceLabel | Should -Be 'Alpha Source' + } + + It 'bounds large offering sets while reporting their full count' { + $programmes = @( + for ($index = 0; $index -lt 17; $index++) { + New-OneGuideTestProgramme "channel-$index" ($Evaluation.AddMinutes(20)) ($Evaluation.AddHours(1)) 'Multi-channel Event' @('Football') 'provider-a' + } + ) + $result = Get-ChannelForgeOneGuide -Query StartingSoon -Programmes $programmes -EvaluationTimeUtc $Evaluation + + $result.Items[0].OfferingCount | Should -Be 17 + $result.Items[0].Offerings.Count | Should -Be 16 + $result.Items[0].OfferingsTruncated | Should -BeTrue + } + + It 'pages stable query results without hiding additional rows' { + $programmes = Get-OneGuideFixtureProgrammes + $first = Get-ChannelForgeOneGuide -Query Category -CategoryKey news -Programmes $programmes -EvaluationTimeUtc $Evaluation -MaximumItems 1 + $second = Get-ChannelForgeOneGuide -Query Category -CategoryKey news -Programmes $programmes -EvaluationTimeUtc $Evaluation -MaximumItems 1 -Offset 1 + + $first.TotalCount | Should -Be 2 + $first.ItemsTruncated | Should -BeTrue + $first.Items.Count | Should -Be 1 + $second.ItemsTruncated | Should -BeFalse + $second.Items.Count | Should -Be 1 + $second.Items[0].ItemId | Should -Not -Be $first.Items[0].ItemId + } + + It 'returns optional metadata as null and identifies a details item without changing source records' { + $programme = New-OneGuideTestProgramme 'details-channel' ($Evaluation.AddHours(3)) ($Evaluation.AddHours(4)) 'No Metadata' + $before = $programme | ConvertTo-Json -Depth 5 -Compress + $list = Get-ChannelForgeOneGuide -Query Category -CategoryKey football -Programmes @($programme) -EvaluationTimeUtc $Evaluation + $all = Get-ChannelForgeOneGuide -Query StartingSoon -Programmes @($programme) -EvaluationTimeUtc $Evaluation + $futureProgramme = New-OneGuideTestProgramme 'details-channel' ($Evaluation.AddMinutes(5)) ($Evaluation.AddHours(1)) 'Details Event' + $future = Get-ChannelForgeOneGuide -Query StartingSoon -Programmes @($futureProgramme) -EvaluationTimeUtc $Evaluation + $details = Get-ChannelForgeOneGuide -Query Details -ItemId $future.Items[0].ItemId -Programmes @($futureProgramme) -EvaluationTimeUtc $Evaluation + + $list.TotalCount | Should -Be 0 + $future.Items[0].Subtitle | Should -BeNullOrEmpty + $future.Items[0].Description | Should -BeNullOrEmpty + $future.Items[0].EpisodeNumber | Should -BeNullOrEmpty + $future.Items[0].Sport | Should -BeNullOrEmpty + $future.Items[0].ConfidenceScore | Should -BeNullOrEmpty + $details.Items.Count | Should -Be 1 + $details.Items[0].ItemId | Should -Be $future.Items[0].ItemId + ($programme | ConvertTo-Json -Depth 5 -Compress) | Should -Be $before + } + + It 'redacts URLs, secrets, private paths, and unsafe identifiers from emitted data' { + $programme = New-OneGuideTestProgramme 'C:\private\guide.xml' ($Evaluation.AddMinutes(5)) ($Evaluation.AddHours(1)) 'Watch https://user:pass@example.invalid/live secret=abc' @('News') 'https://example.invalid/ACCOUNT_ID/API_TOKEN' -Description 'password=very-secret D:\private\source.xml' + $programme | Add-Member -NotePropertyName SourceLabel -NotePropertyValue 'D:\private\Provider TOKEN=hidden' + $result = Get-ChannelForgeOneGuide -Query StartingSoon -Programmes @($programme) -EvaluationTimeUtc $Evaluation + $json = $result | ConvertTo-Json -Depth 10 -Compress + + $json | Should -Match '\[redacted-url\]' + $json | Should -Match '\[redacted-path\]' + $json | Should -Not -Match 'https?://|ACCOUNT_ID|API_TOKEN|very-secret|hidden|C:\\private|D:\\private' + $result.Items[0].Offerings[0].Launch.PSObject.Properties.Name | Should -Contain 'ChannelReference' + $result.Items[0].Offerings[0].Launch.PSObject.Properties.Name | Should -Not -Contain 'Url' + } + + It 'matches the versioned JSON schema and rejects malformed intervals' { + $programme = New-OneGuideTestProgramme 'schema-channel' ($Evaluation.AddMinutes(5)) ($Evaluation.AddHours(1)) 'Schema Event' @('Movies') -EpisodeNumber 'S01E01' + $result = Get-ChannelForgeOneGuide -Query StartingSoon -Programmes @($programme) -EvaluationTimeUtc $Evaluation + $json = $result | ConvertTo-Json -Depth 10 -Compress + + Test-Json -Json $json -SchemaFile $SchemaPath | Should -BeTrue + $invalid = [pscustomobject]@{ ChannelId = 'bad'; Start = $Evaluation; End = $Evaluation; Title = 'Broken'; Categories = @(); SourceId = 'source' } + { Get-ChannelForgeOneGuide -Query LiveNow -Programmes @($invalid) -EvaluationTimeUtc $Evaluation } | Should -Throw '*invalid time interval*' + } +} diff --git a/tests/unit/WebServer.Tests.ps1 b/tests/unit/WebServer.Tests.ps1 index a36ff8c..79fdcf2 100644 --- a/tests/unit/WebServer.Tests.ps1 +++ b/tests/unit/WebServer.Tests.ps1 @@ -497,6 +497,47 @@ Describe 'ChannelForge web server foundation' { $notFoundResponse.StatusCode | Should -Be 404 } + It 'serves bounded One Guide reads from accepted XMLTV without mutating the store' { + $root = Join-Path $TestDrive 'one-guide-read-api' + $start = [datetimeoffset]::UtcNow.AddMinutes(-10) + $stop = $start.AddHours(1) + $startText = $start.ToString('yyyyMMddHHmmss', [Globalization.CultureInfo]::InvariantCulture) + ' +0000' + $stopText = $stop.ToString('yyyyMMddHHmmss', [Globalization.CultureInfo]::InvariantCulture) + ' +0000' + $guide = "OneAccepted Live NewsNewspassword=private-value" + $proposalBody = New-TestProposalBody -GuideText $guide -WithGuide + $proposal = Get-TestWebResponse -Method POST -Path '/api/guided-setup/proposal' -RepositoryRoot $root -BodyBytes $proposalBody -ContentType 'application/json' -ContentLength $proposalBody.Length + $proposalPayload = $proposal.Body | ConvertFrom-Json + $acceptBody = [Text.Encoding]::UTF8.GetBytes((@{ schemaVersion = 1; proposalId = $proposalPayload.Proposal.ProposalId; acknowledged = $true } | ConvertTo-Json -Compress)) + $accepted = Get-TestWebResponse -Method POST -Path '/api/guided-setup/accept' -RepositoryRoot $root -BodyBytes $acceptBody -ContentType 'application/json' -ContentLength $acceptBody.Length + $accepted.StatusCode | Should -Be 200 + $before = Get-TestTreeSnapshot -Root $root + + $live = Get-TestWebResponse -Method GET -Path '/api/one-guide/live-now' -RepositoryRoot $root + $live.StatusCode | Should -Be 200 + $payload = $live.Body | ConvertFrom-Json + @($payload.Items).Count | Should -BeGreaterThan 0 + $details = Get-TestWebResponse -Method HEAD -Path ("/api/one-guide/items/{0}" -f $payload.Items[0].ItemId) -RepositoryRoot $root + $category = Get-TestWebResponse -Method GET -Path '/api/one-guide/category/news' -RepositoryRoot $root + $invalidCategory = Get-TestWebResponse -Method GET -Path '/api/one-guide/category/unsupported' -RepositoryRoot $root + $post = Get-TestWebResponse -Method POST -Path '/api/one-guide/live-now' -RepositoryRoot $root + $paged = Get-TestWebResponse -Method GET -Path '/api/one-guide/live-now?limit=1&offset=0' -RepositoryRoot $root + $invalidQuery = Get-TestWebResponse -Method GET -Path '/api/one-guide/live-now?limit=101' -RepositoryRoot $root + $after = Get-TestTreeSnapshot -Root $root + + $payload.Version | Should -Be 'one-guide/v1' + $payload.Query | Should -Be 'LiveNow' + $payload.Items[0].Title | Should -Be 'Accepted Live News' + $live.Body | Should -Not -Match 'private-value|https?://' + $details.StatusCode | Should -Be 200 + ($details.Body | ConvertFrom-Json).Query | Should -Be 'Details' + ($category.Body | ConvertFrom-Json).TotalCount | Should -Be 1 + $invalidCategory.StatusCode | Should -Be 400 + $post.StatusCode | Should -Be 405 + ($paged.Body | ConvertFrom-Json).Items.Count | Should -Be 1 + $invalidQuery.StatusCode | Should -Be 400 + $after | ConvertTo-Json -Depth 5 | Should -Be ($before | ConvertTo-Json -Depth 5) + } + # Status may read validated accepted metadata; it must not write any state. It 'does not mutate provider, downstream, guide, or accepted state' { $root = Join-Path $TestDrive 'state-boundary' @@ -823,7 +864,12 @@ Describe 'ChannelForge web server foundation' { } $ready | Should -BeTrue - $body = New-TestProposalBody + $guideStart = [datetimeoffset]::UtcNow.AddMinutes(-1) + $guideStop = $guideStart.AddMinutes(30) + $guideStartText = $guideStart.ToString('yyyyMMddHHmmss', [Globalization.CultureInfo]::InvariantCulture) + ' +0000' + $guideStopText = $guideStop.ToString('yyyyMMddHHmmss', [Globalization.CultureInfo]::InvariantCulture) + ' +0000' + $guide = "OneHTTP Smoke NewsNews" + $body = New-TestProposalBody -GuideText $guide -WithGuide $proposalHeaders = "POST /api/guided-setup/proposal HTTP/1.1`r`nHost: 127.0.0.1`r`nConnection: keep-alive`r`nContent-Type: application/json`r`nContent-Length: $($body.Length)" $proposalResponse = Invoke-TestRawHttpRequest -Port $port -Headers $proposalHeaders -BodyBytes $body $proposalResponse.StatusCode | Should -Be 200 @@ -836,6 +882,13 @@ Describe 'ChannelForge web server foundation' { $acceptResponse.StatusCode | Should -Be 200 ($acceptResponse.Body | ConvertFrom-Json).Status | Should -Be 'ACCEPTED' Test-Path -LiteralPath (Join-Path $root 'state\accepted-lineup.json') | Should -BeTrue + $oneGuideHeaders = "GET /api/one-guide/live-now?limit=1 HTTP/1.1`r`nHost: 127.0.0.1`r`nConnection: keep-alive" + $oneGuideResponse = Invoke-TestRawHttpRequest -Port $port -Headers $oneGuideHeaders + $oneGuideResponse.StatusCode | Should -Be 200 + $oneGuidePayload = $oneGuideResponse.Body | ConvertFrom-Json + $oneGuidePayload.Version | Should -Be 'one-guide/v1' + @($oneGuidePayload.Items).Count | Should -Be 1 + $oneGuidePayload.Items[0].Title | Should -Be 'HTTP Smoke News' $shortHeaders = "POST /api/guided-setup/proposal HTTP/1.1`r`nHost: 127.0.0.1`r`nConnection: close`r`nContent-Type: application/json`r`nContent-Length: 3" $shortResponse = Invoke-TestRawHttpRequest -Port $port -Headers $shortHeaders -BodyBytes ([Text.Encoding]::UTF8.GetBytes('{}')) -ShutdownSend -ResponseTimeoutMilliseconds $malformedResponseTimeoutMilliseconds From c29ffc1bb2114f92b45e596b9375ce272b7bdf93 Mon Sep 17 00:00:00 2001 From: Jumpstile <29765787+Jumpstile@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:20:41 -0400 Subject: [PATCH 2/6] fix(one-guide): address remediation review --- docs/architecture/ARCHITECTURE.md | 37 +++-- docs/developer/DEVELOPER_GUIDE.md | 25 +++- docs/user/CURRENT_LIMITATIONS.md | 19 ++- schemas/one-guide-projection.schema.json | 8 +- .../Get-ChannelForgeWebOneGuideResponse.ps1 | 97 ++++++++++--- .../Public/Get-ChannelForgeOneGuide.ps1 | 134 +++++++++++++----- .../Public/Start-ChannelForgeWebServer.ps1 | 3 +- tests/fixtures/one-guide/programmes.json | 4 + tests/unit/OneGuide.Tests.ps1 | 56 ++++++-- tests/unit/WebServer.Tests.ps1 | 134 +++++++++++++++--- 10 files changed, 408 insertions(+), 109 deletions(-) diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index 9e385bc..5e23ae0 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -96,18 +96,33 @@ safe placeholder shell. Static requests are confined to the configured The One Guide API (`GET`/`HEAD /api/one-guide/...`) returns the versioned `one-guide/v1` projection defined by [`one-guide-projection.schema.json`](../../schemas/one-guide-projection.schema.json). -It reads the verified immutable accepted XMLTV artifact directly; it does not -run recovery, enumerate configured sources, fetch the network, or mutate -accepted/source/evidence state. Live Now and Starting Soon are evaluated in UTC -at request time; the public query function takes an explicit evaluation instant. +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 are evaluated in UTC at request time; the public query +function takes an explicit evaluation instant. The fixed category keys are Live Now, Starting Soon, Wrestling, Football, -Baseball, Soccer, Movies, and News. Item identity is content-derived and -independent of source enumeration; distinct channel references remain distinct -safe offerings. The accepted merged XMLTV artifact does not retain provider -per-programme provenance, entitlement, or launch URLs, so this slice reports -the accepted guide as its source and leaves entitlement, freshness, confidence, -DVR, and timeshift unknown. Structured promotion metadata is included only -when explicitly supplied; no promotion is guessed from a title. +Baseball, Soccer, Movies, and News. 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 +the explicit ID, generated identities are scoped to a source/channel occurrence, +so matching title and time on distinct pairs remain separate. Accepted XMLTV +does not preserve `CanonicalEventId` or provider attribution per programme. The +HTTP projection keeps those channel occurrences separate and labels their +source `Accepted guide`. It reports +`Kind` as `Programme` unless the 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, 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 diff --git a/docs/developer/DEVELOPER_GUIDE.md b/docs/developer/DEVELOPER_GUIDE.md index 0c43f61..88560c6 100644 --- a/docs/developer/DEVELOPER_GUIDE.md +++ b/docs/developer/DEVELOPER_GUIDE.md @@ -456,13 +456,24 @@ accept `limit` (1–100) and `offset` (nonnegative decimal integer); responses include total counts and truncation flags. Each item returns at most 16 offerings and reports `OfferingCount` and `OfferingsTruncated`. -The accepted merged XMLTV does not preserve provider-level per-programme -provenance. The initial API therefore labels its origin `Accepted guide` and -uses safe channel references for offerings; it reports availability as -guide-only and leaves entitlement, freshness, confidence, DVR, and timeshift -unknown. Promotion is a structured optional `{ Id, Name }` value and is never -inferred from title text. This is a read contract, not playback, provider -selection, or authenticated LAN access. +The accepted merged XMLTV does not retain provider attribution or a +`CanonicalEventId` per programme. Cross-channel grouping requires the same +explicit identity and normalized title, subtitle, episode number, and schedule; +matching title and time alone never merge separate source/channel occurrences. +Accepted-guide HTTP responses use the display label `Accepted guide` and opaque +domain-separated source, channel, and promotion identifiers. They report `Kind` +as `Programme` unless the input explicitly supplies a recognized kind. Sport, +promotion, entitlement, and launch metadata are emitted only when the input +actually contains them; the title is never used to infer those fields. + +The server holds a one-entry in-memory projection cache keyed by the current +accepted pointer and manifest identity. Catalogue construction parses and +stores UTC interval ticks once and builds a direct item-ID lookup. Warm list +requests validate the pointer and scan the cached records without rereading or +reparsing XMLTV; they create response objects only for the returned page of at +most 100 items. Details uses the direct lookup. A generation change replaces +the entry; the cache is disposable and introduces no persisted state or second +authority. The primary deployment modes are: diff --git a/docs/user/CURRENT_LIMITATIONS.md b/docs/user/CURRENT_LIMITATIONS.md index dead697..c1d515b 100644 --- a/docs/user/CURRENT_LIMITATIONS.md +++ b/docs/user/CURRENT_LIMITATIONS.md @@ -44,11 +44,20 @@ providers, enumerate current source configuration, or repair accepted state. Starting Soon means a programme starts after now and within two hours; Live Now includes its start but excludes its stop. The eight category keys are `live-now`, `starting-soon`, `wrestling`, `football`, `baseball`, `soccer`, -`movies`, and `news`. Results are paged with `limit` (1–100) and `offset`; -titles and optional metadata are redacted and bounded. The merged accepted -guide does not preserve source identity per programme, so offerings identify -the accepted guide and a safe channel reference. Entitlement, freshness, -confidence, DVR, timeshift, and playback are not available in this slice. The +`movies`, and `news`. +List `GET` and `HEAD` requests support `limit` (1–100) and `offset` +(0–2,147,483,647); item Details does not accept an offset. Malformed or +out-of-range values fail with HTTP 400, while an offset beyond the list result +count returns an empty page. + +The accepted XMLTV path does not preserve a cross-channel `CanonicalEventId` or +provider attribution for each programme. Matching titles and times on separate +source/channel occurrences therefore remain separate items; the API does not +guess that they are one event. The source label is `Accepted guide`, public +source/channel/promotion identifiers are opaque, and `Kind` remains +`Programme` unless the input explicitly supplies a recognized value. Optional +metadata appears only when preserved by the accepted guide; entitlement, +freshness, confidence, DVR, timeshift, and playback remain unavailable. The local server is loopback-only and does not provide authenticated LAN access. ![Accepted browser Guided Setup review](assets/guided-browser-accepted.png) diff --git a/schemas/one-guide-projection.schema.json b/schemas/one-guide-projection.schema.json index 01bf569..58ce3be 100644 --- a/schemas/one-guide-projection.schema.json +++ b/schemas/one-guide-projection.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://channelforge.invalid/schemas/one-guide-projection.schema.json", "title": "ChannelForge One Guide read projection", - "description": "Bounded, deterministic, read-only projection for future guide clients.", + "description": "Bounded, deterministic, read-only projection of accepted XMLTV. Cross-channel grouping, source provenance, and programme Kind are exposed only when preserved by input evidence.", "type": "object", "additionalProperties": false, "required": [ @@ -108,7 +108,7 @@ "properties": { "Id": { "type": "string", - "pattern": "^[a-z0-9][a-z0-9._:-]{0,127}$" + "pattern": "^cf-[a-f0-9]{64}$" }, "Name": { "type": "string", "minLength": 1, "maxLength": 128 } } @@ -138,7 +138,7 @@ "OfferingId": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, "SourceId": { "type": "string", - "pattern": "^[a-z0-9][a-z0-9._:-]{0,127}$" + "pattern": "^cf-[a-f0-9]{64}$" }, "SourceLabel": { "type": "string", @@ -155,7 +155,7 @@ "Kind": { "const": "Channel" }, "ChannelReference": { "type": "string", - "pattern": "^[a-z0-9][a-z0-9._:-]{0,127}$" + "pattern": "^cf-[a-f0-9]{64}$" } } }, diff --git a/src/ChannelForge/Private/Get-ChannelForgeWebOneGuideResponse.ps1 b/src/ChannelForge/Private/Get-ChannelForgeWebOneGuideResponse.ps1 index ef95c94..e36312c 100644 --- a/src/ChannelForge/Private/Get-ChannelForgeWebOneGuideResponse.ps1 +++ b/src/ChannelForge/Private/Get-ChannelForgeWebOneGuideResponse.ps1 @@ -1,3 +1,74 @@ +$script:ChannelForgeOneGuideAcceptedProjectionCache = $null + +function Get-ChannelForgeWebOneGuideCatalogue { + [CmdletBinding()] + param([Parameter(Mandatory)][string]$RepositoryRoot) + + $paths = Get-ChannelForgeGenerationPaths -RepositoryRoot $RepositoryRoot + if (-not [IO.File]::Exists($paths.Current)) { + $script:ChannelForgeOneGuideAcceptedProjectionCache = $null + return @() + } + $pointer = Read-ChannelForgeGenerationDocument -RepositoryRoot $RepositoryRoot -Path $paths.Current -Domain 'pointer/v2' + Assert-ChannelForgeGenerationPropertySequence $pointer.Object @('Version', 'GenerationId', 'GenerationManifestHash', 'AcceptedStateHash', 'AcceptedOutputManifestHash', 'PointerHash') 'Pointer' + Assert-ChannelForgeGenerationHash $pointer.Object.PointerHash 'PointerHash' + Assert-ChannelForgeGenerationId ([string]$pointer.Object.GenerationId) + if (-not (Test-ChannelForgeGenerationProjectionHash $pointer.Object 'pointer/v2' 'PointerHash')) { throw 'FAIL_CLOSED: current pointer hash is invalid.' } + $cacheKey = @( + [string]$pointer.Object.GenerationId, + [string]$pointer.Object.GenerationManifestHash, + [string]$pointer.Object.AcceptedStateHash, + [string]$pointer.Object.AcceptedOutputManifestHash, + [string]$pointer.Object.PointerHash + ) -join '|' + if ($null -ne $script:ChannelForgeOneGuideAcceptedProjectionCache -and + [string]$script:ChannelForgeOneGuideAcceptedProjectionCache.Key -ceq $cacheKey) { + return @($script:ChannelForgeOneGuideAcceptedProjectionCache.Items) + } + + $snapshot = Get-ChannelForgeGenerationCurrentSnapshot -RepositoryRoot $RepositoryRoot -Paths $paths + if ($null -eq $snapshot) { + $script:ChannelForgeOneGuideAcceptedProjectionCache = $null + return @() + } + $snapshotPointer = $snapshot.Pointer.Object + $snapshotKey = @( + [string]$snapshotPointer.GenerationId, + [string]$snapshotPointer.GenerationManifestHash, + [string]$snapshotPointer.AcceptedStateHash, + [string]$snapshotPointer.AcceptedOutputManifestHash, + [string]$snapshotPointer.PointerHash + ) -join '|' + if ($snapshotKey -cne $cacheKey) { throw 'Accepted generation changed while the One Guide projection was loading.' } + + $programmes = @() + if ($null -ne $snapshot.XMLTV) { + $bytes = [byte[]]$snapshot.XMLTV.Bytes + if ($bytes.Length -gt 268435456) { throw 'Accepted guide exceeds the read projection limit.' } + $stream = [System.IO.MemoryStream]::new($bytes, $false) + try { + $programmes = @(Read-ChannelForgeXmltvDocument ` + -Stream $stream ` + -SourceId 'accepted-guide' ` + -SourcePath $snapshot.XMLTV.Path ` + -SourceKind local ` + -MaxDocumentBytes 268435456 ` + -CandidateContractVersion 'blocker-2-contract/v8') + } + finally { $stream.Dispose() } + } + $minimumEvaluationTimeUtc = [datetimeoffset]::MinValue + $catalogue = @(Get-ChannelForgeOneGuide -Query LiveNow -Programmes $programmes -EvaluationTimeUtc $minimumEvaluationTimeUtc -ReturnCatalogue) + $itemsById = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::Ordinal) + foreach ($item in $catalogue) { $itemsById[[string]$item.ItemId] = $item } + $script:ChannelForgeOneGuideAcceptedProjectionCache = [pscustomobject]@{ + Key = $cacheKey + Items = $catalogue + ItemsById = $itemsById + } + return $catalogue +} + function Get-ChannelForgeWebOneGuidePaging { param([AllowEmptyString()][string]$QueryString = '') @@ -45,33 +116,17 @@ function Get-ChannelForgeWebOneGuideResponse { if ($Query -eq 'Details' -and $Offset -ne 0) { return New-ChannelForgeWebResponse -StatusCode 400 -ContentType $ContentType -Body (@{ Error = 'invalid-query'; Message = 'Item detail does not support an offset.' } | ConvertTo-Json -Compress) -Headers $Headers } - $paths = Get-ChannelForgeGenerationPaths -RepositoryRoot $RepositoryRoot - # Read the validated immutable generation directly. The recovery wrapper - # is intentionally not used because it may repair durable state. - $snapshot = Get-ChannelForgeGenerationCurrentSnapshot -RepositoryRoot $RepositoryRoot -Paths $paths - $programmes = @() - if ($null -ne $snapshot -and $null -ne $snapshot.XMLTV) { - $bytes = [byte[]]$snapshot.XMLTV.Bytes - if ($bytes.Length -gt 268435456) { throw 'Accepted guide exceeds the read projection limit.' } - $stream = [System.IO.MemoryStream]::new($bytes, $false) - try { - $programmes = @(Read-ChannelForgeXmltvDocument ` - -Stream $stream ` - -SourceId 'accepted-guide' ` - -SourcePath $snapshot.XMLTV.Path ` - -SourceKind local ` - -MaxDocumentBytes 268435456 ` - -CandidateContractVersion 'blocker-2-contract/v8') - } - finally { $stream.Dispose() } - } + $catalogue = Get-ChannelForgeWebOneGuideCatalogue -RepositoryRoot $RepositoryRoot $arguments = @{ Query = $Query - Programmes = $programmes + Programmes = @() + Catalogue = $catalogue + UseCatalogue = $true EvaluationTimeUtc = [datetimeoffset]::UtcNow MaximumItems = $MaximumItems Offset = $Offset } + if ($Query -eq 'Details') { $arguments.CatalogueById = $script:ChannelForgeOneGuideAcceptedProjectionCache.ItemsById } if (-not [string]::IsNullOrWhiteSpace($CategoryKey)) { $arguments.CategoryKey = $CategoryKey } if (-not [string]::IsNullOrWhiteSpace($ItemId)) { $arguments.ItemId = $ItemId } $projection = Get-ChannelForgeOneGuide @arguments diff --git a/src/ChannelForge/Public/Get-ChannelForgeOneGuide.ps1 b/src/ChannelForge/Public/Get-ChannelForgeOneGuide.ps1 index 8925365..d3519f0 100644 --- a/src/ChannelForge/Public/Get-ChannelForgeOneGuide.ps1 +++ b/src/ChannelForge/Public/Get-ChannelForgeOneGuide.ps1 @@ -7,13 +7,25 @@ function Get-ChannelForgeOneGuide { [ValidateSet('live-now', 'starting-soon', 'wrestling', 'football', 'baseball', 'soccer', 'movies', 'news')][string]$CategoryKey, [ValidatePattern('^[a-f0-9]{64}$')][string]$ItemId, [ValidateRange(1, 100)][int]$MaximumItems = 100, - [ValidateRange(0, 2147483647)][int]$Offset = 0 + [ValidateRange(0, 2147483647)][int]$Offset = 0, + [Parameter(DontShow)][AllowNull()][object]$CatalogueById, + [Parameter(DontShow)][AllowNull()][AllowEmptyCollection()][object[]]$Catalogue = @(), + [Parameter(DontShow)][switch]$UseCatalogue, + [Parameter(DontShow)][switch]$ReturnCatalogue ) $evaluation = $EvaluationTimeUtc.ToUniversalTime() $startingSoonEnd = $evaluation.AddHours(2) - $itemGroups = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::Ordinal) $taxonomyOrder = @('live-now', 'starting-soon', 'wrestling', 'football', 'baseball', 'soccer', 'movies', 'news') + if ($UseCatalogue) { + if ($Query -eq 'Details' -and $null -ne $CatalogueById) { + if ($CatalogueById.ContainsKey($ItemId)) { $orderedItems = @($CatalogueById[$ItemId]) } + else { $orderedItems = @() } + } + else { $orderedItems = @($Catalogue) } + } + else { + $itemGroups = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::Ordinal) $taxonomy = @{ wrestling = 'wrestling' football = 'football' @@ -36,13 +48,11 @@ function Get-ChannelForgeOneGuide { return $null } - function Get-SafeIdentifier { + function Get-OpaqueIdentifier { param([AllowNull()][object]$Value, [Parameter(Mandatory)][string]$Domain, [string]$Fallback = '') $raw = if ($null -eq $Value) { '' } else { ([string]$Value).Trim() } if ([string]::IsNullOrWhiteSpace($raw)) { $raw = $Fallback } if ([string]::IsNullOrWhiteSpace($raw)) { return '' } - $safe = ConvertTo-ChannelForgeGuideSafeText -Value $raw -MaximumLength 128 - if ($safe -match '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$') { return $safe.ToLowerInvariant() } return ('cf-' + (Get-ChannelForgeDomainHash -Domain $Domain -InputObject $raw)) } @@ -85,13 +95,7 @@ function Get-ChannelForgeOneGuide { if ($taxonomy.ContainsKey($key)) { [void]$categories.Add([string]$taxonomy[$key]) } } $declaredKind = [string](Get-InputValue $programme 'Kind') - if ($declaredKind -notin @('Programme', 'Event', 'Movie', 'SeriesEpisode', 'Other')) { $declaredKind = '' } - $kind = if ($categories.Contains('movies')) { 'Movie' } - elseif (-not [string]::IsNullOrWhiteSpace($episodeNumber)) { 'SeriesEpisode' } - elseif (@($categories | Where-Object { $_ -in @('wrestling', 'football', 'baseball', 'soccer') }).Count -gt 0) { 'Event' } - elseif (-not [string]::IsNullOrWhiteSpace($declaredKind)) { $declaredKind } - else { 'Programme' } - + if ($declaredKind -cnotin @('Programme', 'Event', 'Movie', 'SeriesEpisode', 'Other')) { $declaredKind = $null } $sport = Get-SafeOptionalText (Get-InputValue $programme 'Sport') 128 $league = Get-SafeOptionalText (Get-InputValue $programme 'League') 128 $homeParticipant = Get-SafeOptionalText (Get-InputValue $programme 'HomeParticipant') 128 @@ -99,7 +103,7 @@ function Get-ChannelForgeOneGuide { $promotion = $null $promotionInput = Get-InputValue $programme 'Promotion' if ($categories.Contains('wrestling') -and $null -ne $promotionInput) { - $promotionId = Get-SafeIdentifier (Get-InputValue $promotionInput 'Id') 'one-guide-promotion/v1' + $promotionId = Get-OpaqueIdentifier (Get-InputValue $promotionInput 'Id') 'one-guide-promotion/v1' $promotionName = Get-SafeOptionalText (Get-InputValue $promotionInput 'Name') 128 if (-not [string]::IsNullOrWhiteSpace($promotionId) -and $null -ne $promotionName) { $promotion = [ordered]@{ Id = $promotionId; Name = $promotionName } @@ -115,10 +119,16 @@ function Get-ChannelForgeOneGuide { $safeTitleKey = [regex]::Replace($title.Trim().ToLowerInvariant(), '\s+', ' ') $safeSubtitleKey = if ($null -eq $subtitle) { '' } else { [regex]::Replace($subtitle.Trim().ToLowerInvariant(), '\s+', ' ') } - # V1 correlation deliberately excludes classification and provider/channel - # metadata. Corroborating sources may disagree on those fields while still - # describing the same scheduled content. + $rawSourceId = [string](Get-InputValue $programme 'SourceId') + $rawChannelId = [string](Get-InputValue $programme 'ChannelId') + $canonicalEventId = [string](Get-InputValue $programme 'CanonicalEventId') + if ([string]::IsNullOrWhiteSpace($canonicalEventId)) { $canonicalEventId = $null } + else { $canonicalEventId = $canonicalEventId.Trim() } $identity = [ordered]@{ + Correlation = if ($null -ne $canonicalEventId) { 'ExplicitEvent' } else { 'ChannelOccurrence' } + CanonicalEventId = if ($null -ne $canonicalEventId) { $canonicalEventId } else { '' } + SourceId = if ($null -eq $canonicalEventId) { $rawSourceId } else { '' } + ChannelId = if ($null -eq $canonicalEventId) { $rawChannelId } else { '' } Title = $safeTitleKey Subtitle = $safeSubtitleKey EpisodeNumber = if ($null -eq $episodeNumber) { '' } else { $episodeNumber.ToLowerInvariant() } @@ -126,11 +136,10 @@ function Get-ChannelForgeOneGuide { StopUtc = $stopText } $itemId = Get-ChannelForgeDomainHash -Domain 'one-guide-item/v1' -InputObject $identity - - $sourceId = Get-SafeIdentifier (Get-InputValue $programme 'SourceId') 'one-guide-source/v1' 'accepted-guide' + $sourceId = Get-OpaqueIdentifier $rawSourceId 'one-guide-source/v1' 'accepted-guide' $sourceLabel = Get-SafeOptionalText (Get-InputValue $programme 'SourceLabel') 128 - if ($null -eq $sourceLabel) { $sourceLabel = if ($sourceId -eq 'accepted-guide') { 'Accepted guide' } else { $sourceId } } - $channelReference = Get-SafeIdentifier (Get-InputValue $programme 'ChannelId') 'one-guide-channel/v1' + if ($null -eq $sourceLabel) { $sourceLabel = if ($rawSourceId -ceq 'accepted-guide') { 'Accepted guide' } else { 'Source' } } + $channelReference = Get-OpaqueIdentifier $rawChannelId 'one-guide-channel/v1' if ([string]::IsNullOrWhiteSpace($channelReference)) { continue } $offeringId = Get-ChannelForgeDomainHash -Domain 'one-guide-offering/v1' -InputObject ([ordered]@{ ItemId = $itemId @@ -150,9 +159,10 @@ function Get-ChannelForgeOneGuide { if (-not $itemGroups.ContainsKey($itemId)) { $itemGroups[$itemId] = [pscustomobject][ordered]@{ - Identity = $identity ItemId = $itemId - Kind = $kind + Kind = $null + HasExplicitKind = $false + KindConflict = $false Title = $title Subtitle = $subtitle Description = $description @@ -170,6 +180,16 @@ function Get-ChannelForgeOneGuide { } } $group = $itemGroups[$itemId] + if ($null -ne $declaredKind -and -not $group.KindConflict) { + if (-not $group.HasExplicitKind) { + $group.Kind = $declaredKind + $group.HasExplicitKind = $true + } + elseif ($group.Kind -cne $declaredKind) { + $group.Kind = 'Programme' + $group.KindConflict = $true + } + } if (-not $group.Offerings.ContainsKey($offeringId)) { $group.Offerings[$offeringId] = $offering } @@ -227,11 +247,7 @@ function Get-ChannelForgeOneGuide { foreach ($taxonomyKey in $taxonomyOrder) { if (@($group.CategoryKeys) -contains $taxonomyKey) { [void]$orderedCategories.Add($taxonomyKey) } } - $finalKind = if (@($orderedCategories) -contains 'movies') { 'Movie' } - elseif (-not [string]::IsNullOrWhiteSpace([string]$group.EpisodeNumber)) { 'SeriesEpisode' } - elseif (@($orderedCategories | Where-Object { $_ -in @('wrestling', 'football', 'baseball', 'soccer') }).Count -gt 0) { 'Event' } - elseif (@($orderedCategories) -contains 'news') { 'Programme' } - else { 'Other' } + $finalKind = if ($group.HasExplicitKind -and -not $group.KindConflict) { $group.Kind } else { 'Programme' } $items.Add([pscustomobject][ordered]@{ ItemId = $group.ItemId Kind = $finalKind @@ -258,26 +274,74 @@ function Get-ChannelForgeOneGuide { } $orderedItems = $items.ToArray() [Array]::Sort($orderedItems, $itemComparison) - + } + if ($ReturnCatalogue) { + foreach ($item in $orderedItems) { + $item | Add-Member -NotePropertyName StartUtcTicks -NotePropertyValue ([datetimeoffset]::Parse([string]$item.StartUtc, [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::AdjustToUniversal).UtcTicks) + $item | Add-Member -NotePropertyName StopUtcTicks -NotePropertyValue ([datetimeoffset]::Parse([string]$item.StopUtc, [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::AdjustToUniversal).UtcTicks) + } + return $orderedItems + } if ($Query -eq 'Category') { if ([string]::IsNullOrWhiteSpace($CategoryKey)) { throw 'CategoryKey is required for a category query.' } $CategoryKey = $CategoryKey.ToLowerInvariant() } if ($Query -eq 'Details' -and [string]::IsNullOrWhiteSpace($ItemId)) { throw 'ItemId is required for a details query.' } + $selected = [System.Collections.Generic.List[object]]::new() + $totalCount = 0 + $pageEnd = [int64]$Offset + [int64]$MaximumItems foreach ($item in $orderedItems) { + $status = [string]$item.Status + if ($UseCatalogue) { + $startTicks = [long]$item.StartUtcTicks + $stopTicks = [long]$item.StopUtcTicks + $status = if ($startTicks -le $evaluation.UtcTicks -and $stopTicks -gt $evaluation.UtcTicks) { 'Live' } + elseif ($startTicks -gt $evaluation.UtcTicks -and $startTicks -le $startingSoonEnd.UtcTicks) { 'StartingSoon' } + elseif ($startTicks -gt $evaluation.UtcTicks) { 'Upcoming' } + else { 'Past' } + } $include = switch ($Query) { - 'LiveNow' { $item.Status -ceq 'Live' } - 'StartingSoon' { $item.Status -ceq 'StartingSoon' } - 'Category' { $item.CategoryKeys -ccontains $CategoryKey } + 'LiveNow' { $status -ceq 'Live' } + 'StartingSoon' { $status -ceq 'StartingSoon' } + 'Category' { + if ($CategoryKey -ceq 'live-now') { $status -ceq 'Live' } + elseif ($CategoryKey -ceq 'starting-soon') { $status -ceq 'StartingSoon' } + else { $item.CategoryKeys -ccontains $CategoryKey } + } 'Details' { [string]$item.ItemId -ceq $ItemId } } - if ($include) { [void]$selected.Add($item) } + if ($include) { + if ([int64]$totalCount -ge [int64]$Offset -and [int64]$totalCount -lt $pageEnd) { [void]$selected.Add($item) } + $totalCount++ + } } - $totalCount = $selected.Count - $upperBound = [Math]::Min([int64]$totalCount, ([int64]$Offset + [int64]$MaximumItems)) $returned = [System.Collections.Generic.List[object]]::new() - for ($index = $Offset; $index -lt $upperBound; $index++) { [void]$returned.Add($selected[$index]) } + for ($index = 0; $index -lt $selected.Count; $index++) { + $item = $selected[$index] + $itemStatus = [string]$item.Status + if ($UseCatalogue) { + $itemStatus = if ([long]$item.StartUtcTicks -le $evaluation.UtcTicks -and [long]$item.StopUtcTicks -gt $evaluation.UtcTicks) { 'Live' } + elseif ([long]$item.StartUtcTicks -gt $evaluation.UtcTicks -and [long]$item.StartUtcTicks -le $startingSoonEnd.UtcTicks) { 'StartingSoon' } + elseif ([long]$item.StartUtcTicks -gt $evaluation.UtcTicks) { 'Upcoming' } + else { 'Past' } + $categorySet = [System.Collections.Generic.SortedSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($category in $item.CategoryKeys) { + if ($category -notin @('live-now', 'starting-soon')) { [void]$categorySet.Add([string]$category) } + } + if ($itemStatus -eq 'Live') { [void]$categorySet.Add('live-now') } + elseif ($itemStatus -eq 'StartingSoon') { [void]$categorySet.Add('starting-soon') } + $categoriesForItem = @($taxonomyOrder | Where-Object { $categorySet.Contains($_) }) + $copy = [ordered]@{} + foreach ($property in $item.PSObject.Properties) { + if ($property.Name -notin @('StartUtcTicks', 'StopUtcTicks')) { $copy[$property.Name] = $property.Value } + } + $copy.Status = $itemStatus + $copy.CategoryKeys = $categoriesForItem + $item = [pscustomobject]$copy + } + [void]$returned.Add($item) + } $result = [ordered]@{ Version = 'one-guide/v1' EvaluationTimeUtc = $evaluation.ToString('o', [Globalization.CultureInfo]::InvariantCulture) diff --git a/src/ChannelForge/Public/Start-ChannelForgeWebServer.ps1 b/src/ChannelForge/Public/Start-ChannelForgeWebServer.ps1 index af03c21..16e4361 100644 --- a/src/ChannelForge/Public/Start-ChannelForgeWebServer.ps1 +++ b/src/ChannelForge/Public/Start-ChannelForgeWebServer.ps1 @@ -31,6 +31,7 @@ function Start-ChannelForgeWebServer { try { $requestPath = $context.Request.Url.AbsolutePath + $requestPathAndQuery = $context.Request.Url.PathAndQuery $isBodyPost = $context.Request.HttpMethod.ToUpperInvariant() -eq 'POST' -and $requestPath.ToLowerInvariant() -in @('/api/guided-setup/proposal', '/api/guided-setup/accept', '/api/sources/refresh') if ($isBodyPost) { try { @@ -54,7 +55,7 @@ function Start-ChannelForgeWebServer { } } else { - $response = Get-ChannelForgeWebResponse -Method $context.Request.HttpMethod -Path $requestPath -RepositoryRoot $server.RepositoryRoot -StaticRoot $server.StaticRoot + $response = Get-ChannelForgeWebResponse -Method $context.Request.HttpMethod -Path $requestPathAndQuery -RepositoryRoot $server.RepositoryRoot -StaticRoot $server.StaticRoot } Write-ChannelForgeWebResponse -Context $context -Response $response } diff --git a/tests/fixtures/one-guide/programmes.json b/tests/fixtures/one-guide/programmes.json index a28305e..24396ed 100644 --- a/tests/fixtures/one-guide/programmes.json +++ b/tests/fixtures/one-guide/programmes.json @@ -4,6 +4,8 @@ "Start": "2026-10-01T12:30:00Z", "End": "2026-10-01T14:00:00Z", "Title": "Championship Main Event", + "CanonicalEventId": "sample-event:championship-main-event-2026", + "Kind": "Event", "Categories": ["Wrestling"], "SourceId": "provider-a", "SourceLabel": "Provider A", @@ -18,6 +20,8 @@ "Start": "2026-10-01T12:30:00Z", "End": "2026-10-01T14:00:00Z", "Title": "Championship Main Event", + "CanonicalEventId": "sample-event:championship-main-event-2026", + "Kind": "Event", "Categories": [], "SourceId": "provider-b", "SourceLabel": "Provider B" diff --git a/tests/unit/OneGuide.Tests.ps1 b/tests/unit/OneGuide.Tests.ps1 index 6d30552..6ffe1c6 100644 --- a/tests/unit/OneGuide.Tests.ps1 +++ b/tests/unit/OneGuide.Tests.ps1 @@ -14,7 +14,7 @@ BeforeAll { $start = [datetimeoffset]::Parse([string]$row.Start, [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::AdjustToUniversal) $end = [datetimeoffset]::Parse([string]$row.End, [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::AdjustToUniversal) $programme = New-OneGuideTestProgramme -ChannelId $row.ChannelId -Start $start -End $end -Title $row.Title -Categories $row.Categories -SourceId $row.SourceId - foreach ($name in @('SourceLabel', 'Sport', 'League', 'HomeParticipant', 'AwayParticipant', 'Promotion')) { + foreach ($name in @('SourceLabel', 'Sport', 'League', 'HomeParticipant', 'AwayParticipant', 'Promotion', 'CanonicalEventId', 'Kind')) { $property = $row.PSObject.Properties[$name] if ($null -ne $property) { $programme | Add-Member -NotePropertyName $name -NotePropertyValue $property.Value } } @@ -72,12 +72,12 @@ Describe 'One Guide read projection' { $items = Get-ChannelForgeOneGuide -Query StartingSoon -Programmes $programmes -EvaluationTimeUtc $Evaluation $wrestling = Get-ChannelForgeOneGuide -Query Category -CategoryKey wrestling -Programmes $programmes -EvaluationTimeUtc $Evaluation $items.Items[0].Promotion | Should -BeNullOrEmpty - $wrestling.Items[0].Promotion.Id | Should -Be 'wwe' + $wrestling.Items[0].Promotion.Id | Should -Match '^cf-[a-f0-9]{64}$' $wrestling.Items[0].Promotion.Name | Should -Be 'WWE' @($wrestling.Items[0].CategoryKeys | Where-Object { $_ -notin $expectedTaxonomy }) | Should -BeNullOrEmpty } - It 'groups one event across offerings despite classification disagreement with order-independent output' { + It 'groups offerings only when an explicit canonical event identity is present' { $programmes = Get-OneGuideFixtureProgrammes $reversed = @($programmes) [array]::Reverse($reversed) @@ -85,6 +85,7 @@ Describe 'One Guide read projection' { $reverse = Get-ChannelForgeOneGuide -Query Category -CategoryKey wrestling -Programmes $reversed -EvaluationTimeUtc $Evaluation $item = $forward.Items[0] + $forward.TotalCount | Should -Be 1 $item.ItemId | Should -Be $reverse.Items[0].ItemId ($forward | ConvertTo-Json -Depth 10 -Compress) | Should -Be ($reverse | ConvertTo-Json -Depth 10 -Compress) $item.Offerings.Count | Should -Be 2 @@ -94,12 +95,38 @@ Describe 'One Guide read projection' { $item.CategoryKeys | Should -Contain 'wrestling' $item.Sport | Should -Be 'Wrestling' $item.League | Should -Be 'WWE' - $item.Promotion.Id | Should -Be 'wwe' - @($item.Offerings.Launch.ChannelReference | Sort-Object) | Should -Be @('channel-wwe-a', 'channel-wwe-b') - @($item.Offerings.SourceId | Sort-Object) | Should -Be @('provider-a', 'provider-b') + $item.Promotion.Id | Should -Match '^cf-[a-f0-9]{64}$' + $item.Promotion.Id | Should -Not -Be 'wwe' + @($item.Offerings.Launch.ChannelReference | Where-Object { $_ -notmatch '^cf-[a-f0-9]{64}$' }) | Should -BeNullOrEmpty + @($item.Offerings.SourceId | Where-Object { $_ -notmatch '^cf-[a-f0-9]{64}$' }) | Should -BeNullOrEmpty $item.ItemId | Should -Not -Be $item.Offerings[0].Launch.ChannelReference } + It 'keeps matching title and time on separate channels as separate items without explicit identity' { + $first = New-OneGuideTestProgramme 'channel-a' ($Evaluation.AddMinutes(20)) ($Evaluation.AddHours(1)) 'Same Scheduled Title' @('Wrestling') 'provider-a' + $second = New-OneGuideTestProgramme 'channel-b' ($Evaluation.AddMinutes(20)) ($Evaluation.AddHours(1)) 'Same Scheduled Title' @('Wrestling') 'provider-b' + $result = Get-ChannelForgeOneGuide -Query Category -CategoryKey wrestling -Programmes @($first, $second) -EvaluationTimeUtc $Evaluation + + $result.TotalCount | Should -Be 2 + @($result.Items.ItemId | Select-Object -Unique).Count | Should -Be 2 + @($result.Items | Where-Object { $_.OfferingCount -ne 1 }) | Should -BeNullOrEmpty + } + + It 'uses only an exactly recognized explicit Kind value instead of inferring from category or episode metadata' { + $categoryOnly = New-OneGuideTestProgramme 'category-channel' ($Evaluation.AddMinutes(20)) ($Evaluation.AddHours(1)) 'Wrestling Movie' @('Wrestling', 'Movies') 'provider-a' -EpisodeNumber 'S01E01' + $explicit = New-OneGuideTestProgramme 'explicit-channel' ($Evaluation.AddMinutes(20)) ($Evaluation.AddHours(1)) 'Explicit Event' @('Wrestling') 'provider-a' + $lowercase = New-OneGuideTestProgramme 'lowercase-channel' ($Evaluation.AddMinutes(20)) ($Evaluation.AddHours(1)) 'Unrecognized Kind' @('Wrestling') 'provider-a' + $explicit | Add-Member -NotePropertyName Kind -NotePropertyValue Event + $lowercase | Add-Member -NotePropertyName Kind -NotePropertyValue event + $categoryResult = Get-ChannelForgeOneGuide -Query StartingSoon -Programmes @($categoryOnly) -EvaluationTimeUtc $Evaluation + $explicitResult = Get-ChannelForgeOneGuide -Query StartingSoon -Programmes @($explicit) -EvaluationTimeUtc $Evaluation + $lowercaseResult = Get-ChannelForgeOneGuide -Query StartingSoon -Programmes @($lowercase) -EvaluationTimeUtc $Evaluation + + $categoryResult.Items[0].Kind | Should -Be 'Programme' + $explicitResult.Items[0].Kind | Should -Be 'Event' + $lowercaseResult.Items[0].Kind | Should -Be 'Programme' + } + It 'selects repeated-offering metadata deterministically when source rows disagree' { $first = New-OneGuideTestProgramme 'shared-channel' ($Evaluation.AddMinutes(20)) ($Evaluation.AddHours(1)) 'Episode Event' @('Football') 'provider-a' -EpisodeNumber 's01e01' -Description 'Zulu description' $second = New-OneGuideTestProgramme 'shared-channel' ($Evaluation.AddMinutes(20)) ($Evaluation.AddHours(1)) 'Episode Event' @('Football') 'provider-a' -EpisodeNumber 'S01E01' -Description 'Alpha description' @@ -118,7 +145,9 @@ Describe 'One Guide read projection' { It 'bounds large offering sets while reporting their full count' { $programmes = @( for ($index = 0; $index -lt 17; $index++) { - New-OneGuideTestProgramme "channel-$index" ($Evaluation.AddMinutes(20)) ($Evaluation.AddHours(1)) 'Multi-channel Event' @('Football') 'provider-a' + $programme = New-OneGuideTestProgramme "channel-$index" ($Evaluation.AddMinutes(20)) ($Evaluation.AddHours(1)) 'Multi-channel Event' @('Football') 'provider-a' + $programme | Add-Member -NotePropertyName CanonicalEventId -NotePropertyValue 'sample-event:multi-channel-2026' + $programme } ) $result = Get-ChannelForgeOneGuide -Query StartingSoon -Programmes $programmes -EvaluationTimeUtc $Evaluation @@ -145,7 +174,6 @@ Describe 'One Guide read projection' { $programme = New-OneGuideTestProgramme 'details-channel' ($Evaluation.AddHours(3)) ($Evaluation.AddHours(4)) 'No Metadata' $before = $programme | ConvertTo-Json -Depth 5 -Compress $list = Get-ChannelForgeOneGuide -Query Category -CategoryKey football -Programmes @($programme) -EvaluationTimeUtc $Evaluation - $all = Get-ChannelForgeOneGuide -Query StartingSoon -Programmes @($programme) -EvaluationTimeUtc $Evaluation $futureProgramme = New-OneGuideTestProgramme 'details-channel' ($Evaluation.AddMinutes(5)) ($Evaluation.AddHours(1)) 'Details Event' $future = Get-ChannelForgeOneGuide -Query StartingSoon -Programmes @($futureProgramme) -EvaluationTimeUtc $Evaluation $details = Get-ChannelForgeOneGuide -Query Details -ItemId $future.Items[0].ItemId -Programmes @($futureProgramme) -EvaluationTimeUtc $Evaluation @@ -173,6 +201,18 @@ Describe 'One Guide read projection' { $result.Items[0].Offerings[0].Launch.PSObject.Properties.Name | Should -Contain 'ChannelReference' $result.Items[0].Offerings[0].Launch.PSObject.Properties.Name | Should -Not -Contain 'Url' } + It 'hashes source, channel, and promotion identifiers even when they resemble ordinary labels' { + $programme = New-OneGuideTestProgramme 'ChannelPlan42' ($Evaluation.AddMinutes(5)) ($Evaluation.AddHours(1)) 'Visible Programme' @('Wrestling') 'ProviderAccount19' + $programme | Add-Member -NotePropertyName Promotion -NotePropertyValue @{ Id = 'PromoAccess29'; Name = 'WWE' } + $result = Get-ChannelForgeOneGuide -Query StartingSoon -Programmes @($programme) -EvaluationTimeUtc $Evaluation + $json = $result | ConvertTo-Json -Depth 10 -Compress + + $json | Should -Not -Match 'ChannelPlan42|ProviderAccount19|PromoAccess29' + $result.Items[0].Offerings[0].SourceId | Should -Match '^cf-[a-f0-9]{64}$' + $result.Items[0].Offerings[0].Launch.ChannelReference | Should -Match '^cf-[a-f0-9]{64}$' + $result.Items[0].Promotion.Id | Should -Match '^cf-[a-f0-9]{64}$' + $result.Items[0].Offerings[0].SourceLabel | Should -Be 'Source' + } It 'matches the versioned JSON schema and rejects malformed intervals' { $programme = New-OneGuideTestProgramme 'schema-channel' ($Evaluation.AddMinutes(5)) ($Evaluation.AddHours(1)) 'Schema Event' @('Movies') -EpisodeNumber 'S01E01' diff --git a/tests/unit/WebServer.Tests.ps1 b/tests/unit/WebServer.Tests.ps1 index 79fdcf2..6100ec4 100644 --- a/tests/unit/WebServer.Tests.ps1 +++ b/tests/unit/WebServer.Tests.ps1 @@ -274,18 +274,20 @@ BeforeAll { $statusMatch = [regex]::Match($headerText, '^HTTP/\d\.\d\s+(?\d+)', [Text.RegularExpressions.RegexOptions]::Multiline) $lengthMatch = [regex]::Match($headerText, '(?im)^Content-Length:\s*(?\d+)') - if (-not $statusMatch.Success -or -not $lengthMatch.Success) { throw 'The HTTP response omitted a status or Content-Length.' } + $requestMethodMatch = [regex]::Match($Headers, '^(?[A-Z]+)\s') + if (-not $statusMatch.Success -or -not $lengthMatch.Success -or -not $requestMethodMatch.Success) { throw 'The HTTP response omitted a status, Content-Length, or request method.' } $contentLength = [int]$lengthMatch.Groups['Length'].Value - $responseBytes = [byte[]]::new($contentLength) + $responseBytes = [byte[]]::new($(if ($requestMethodMatch.Groups['Method'].Value -eq 'HEAD') { 0 } else { $contentLength })) $offset = 0 - while ($offset -lt $contentLength) { - $read = $stream.Read($responseBytes, $offset, $contentLength - $offset) + while ($offset -lt $contentLength -and $offset -lt $responseBytes.Length) { + $read = $stream.Read($responseBytes, $offset, [Math]::Min($contentLength - $offset, $responseBytes.Length - $offset)) if ($read -le 0) { throw 'The HTTP response ended before its declared body length.' } $offset += $read } return [pscustomobject]@{ - StatusCode = [int]$statusMatch.Groups['Status'].Value - Body = [Text.Encoding]::UTF8.GetString($responseBytes) + StatusCode = [int]$statusMatch.Groups['Status'].Value + ContentLength = $contentLength + Body = [Text.Encoding]::UTF8.GetString($responseBytes) } } finally { @@ -512,16 +514,47 @@ Describe 'ChannelForge web server foundation' { $accepted.StatusCode | Should -Be 200 $before = Get-TestTreeSnapshot -Root $root - $live = Get-TestWebResponse -Method GET -Path '/api/one-guide/live-now' -RepositoryRoot $root - $live.StatusCode | Should -Be 200 + $global:ChannelForgeOneGuideXmltvReadCount = 0 + $global:ChannelForgeOneGuideCacheCheckArmed = $false + $originalGenerationReader = (Get-Module ChannelForge).Invoke({ (Get-Command Read-ChannelForgeGenerationFile).ScriptBlock })[0] + $live = & (Get-Module ChannelForge) { + param($RepositoryRoot) + Get-ChannelForgeWebResponse -Method GET -Path '/api/one-guide/live-now' -RepositoryRoot $RepositoryRoot + } $root $payload = $live.Body | ConvertFrom-Json - @($payload.Items).Count | Should -BeGreaterThan 0 - $details = Get-TestWebResponse -Method HEAD -Path ("/api/one-guide/items/{0}" -f $payload.Items[0].ItemId) -RepositoryRoot $root - $category = Get-TestWebResponse -Method GET -Path '/api/one-guide/category/news' -RepositoryRoot $root - $invalidCategory = Get-TestWebResponse -Method GET -Path '/api/one-guide/category/unsupported' -RepositoryRoot $root - $post = Get-TestWebResponse -Method POST -Path '/api/one-guide/live-now' -RepositoryRoot $root - $paged = Get-TestWebResponse -Method GET -Path '/api/one-guide/live-now?limit=1&offset=0' -RepositoryRoot $root - $invalidQuery = Get-TestWebResponse -Method GET -Path '/api/one-guide/live-now?limit=101' -RepositoryRoot $root + @($payload.Items).Count | Should -Be 1 + $payload.Items[0].ItemId | Should -Match '^[a-f0-9]{64}$' + Mock -CommandName Read-ChannelForgeGenerationFile -ModuleName ChannelForge -MockWith { + if ($global:ChannelForgeOneGuideCacheCheckArmed -and [IO.Path]::GetFileName($Path) -ceq 'merged.xml') { + $global:ChannelForgeOneGuideXmltvReadCount++ + throw 'A warm One Guide request reread accepted XMLTV bytes.' + } + & $originalGenerationReader -RepositoryRoot $RepositoryRoot -Path $Path -Domain $Domain + } + $global:ChannelForgeOneGuideCacheCheckArmed = $true + $responses = & (Get-Module ChannelForge) { + param($RepositoryRoot) + $live = Get-ChannelForgeWebResponse -Method GET -Path '/api/one-guide/live-now' -RepositoryRoot $RepositoryRoot + $payload = $live.Body | ConvertFrom-Json + $details = Get-ChannelForgeWebResponse -Method HEAD -Path ("/api/one-guide/items/{0}" -f $payload.Items[0].ItemId) -RepositoryRoot $RepositoryRoot + $category = Get-ChannelForgeWebResponse -Method GET -Path '/api/one-guide/category/news' -RepositoryRoot $RepositoryRoot + $invalidCategory = Get-ChannelForgeWebResponse -Method GET -Path '/api/one-guide/category/unsupported' -RepositoryRoot $RepositoryRoot + $post = Get-ChannelForgeWebResponse -Method POST -Path '/api/one-guide/live-now' -RepositoryRoot $RepositoryRoot + $paged = Get-ChannelForgeWebResponse -Method GET -Path '/api/one-guide/live-now?limit=1&offset=0' -RepositoryRoot $RepositoryRoot + $invalidQuery = Get-ChannelForgeWebResponse -Method GET -Path '/api/one-guide/live-now?limit=101' -RepositoryRoot $RepositoryRoot + [pscustomobject]@{ Live = $live; Payload = $payload; Details = $details; Category = $category; InvalidCategory = $invalidCategory; Post = $post; Paged = $paged; InvalidQuery = $invalidQuery } + } $root + $global:ChannelForgeOneGuideCacheCheckArmed = $false + $live = $responses.Live + $payload = $responses.Payload + $details = $responses.Details + $category = $responses.Category + $invalidCategory = $responses.InvalidCategory + $post = $responses.Post + $paged = $responses.Paged + $invalidQuery = $responses.InvalidQuery + $global:ChannelForgeOneGuideXmltvReadCount | Should -Be 0 + Remove-Variable -Name ChannelForgeOneGuideXmltvReadCount, ChannelForgeOneGuideCacheCheckArmed -Scope Global -ErrorAction SilentlyContinue $after = Get-TestTreeSnapshot -Root $root $payload.Version | Should -Be 'one-guide/v1' @@ -537,6 +570,40 @@ Describe 'ChannelForge web server foundation' { $invalidQuery.StatusCode | Should -Be 400 $after | ConvertTo-Json -Depth 5 | Should -Be ($before | ConvertTo-Json -Depth 5) } + It 'rebuilds the cached One Guide projection when the accepted generation changes' { + $root = Join-Path $TestDrive 'one-guide-cache-invalidation' + $publishGuide = { + param([string]$Title) + $start = [datetimeoffset]::UtcNow.AddMinutes(-10) + $stop = $start.AddHours(1) + $startText = $start.ToString('yyyyMMddHHmmss', [Globalization.CultureInfo]::InvariantCulture) + ' +0000' + $stopText = $stop.ToString('yyyyMMddHHmmss', [Globalization.CultureInfo]::InvariantCulture) + ' +0000' + $guide = "One$TitleNews" + $proposalBody = New-TestProposalBody -GuideText $guide -WithGuide + $proposal = Get-TestWebResponse -Method POST -Path '/api/guided-setup/proposal' -RepositoryRoot $root -BodyBytes $proposalBody -ContentType 'application/json' -ContentLength $proposalBody.Length + $proposalPayload = $proposal.Body | ConvertFrom-Json + $acceptBody = [Text.Encoding]::UTF8.GetBytes((@{ schemaVersion = 1; proposalId = $proposalPayload.Proposal.ProposalId; acknowledged = $true } | ConvertTo-Json -Compress)) + $accepted = Get-TestWebResponse -Method POST -Path '/api/guided-setup/accept' -RepositoryRoot $root -BodyBytes $acceptBody -ContentType 'application/json' -ContentLength $acceptBody.Length + $accepted.StatusCode | Should -Be 200 + } + + & $publishGuide 'Accepted First News' + $first = Get-TestWebResponse -Method GET -Path '/api/one-guide/live-now' -RepositoryRoot $root + $firstPayload = $first.Body | ConvertFrom-Json + $firstGenerationId = (Get-Content -LiteralPath (Join-Path $root 'state\accepted-lineup.json') -Raw | ConvertFrom-Json).GenerationId + & $publishGuide 'Accepted Replacement News' + $secondGenerationId = (Get-Content -LiteralPath (Join-Path $root 'state\accepted-lineup.json') -Raw | ConvertFrom-Json).GenerationId + $second = Get-TestWebResponse -Method GET -Path '/api/one-guide/live-now' -RepositoryRoot $root + $secondPayload = $second.Body | ConvertFrom-Json + $category = Get-TestWebResponse -Method GET -Path '/api/one-guide/category/news' -RepositoryRoot $root + + $firstGenerationId | Should -Not -Be $secondGenerationId + $firstPayload.Items[0].Title | Should -Be 'Accepted First News' + $second.StatusCode | Should -Be 200 + $secondPayload.Items[0].Title | Should -Be 'Accepted Replacement News' + $secondPayload.Items[0].ItemId | Should -Not -Be $firstPayload.Items[0].ItemId + ($category.Body | ConvertFrom-Json).Items[0].Title | Should -Be 'Accepted Replacement News' + } # Status may read validated accepted metadata; it must not write any state. It 'does not mutate provider, downstream, guide, or accepted state' { @@ -868,8 +935,9 @@ Describe 'ChannelForge web server foundation' { $guideStop = $guideStart.AddMinutes(30) $guideStartText = $guideStart.ToString('yyyyMMddHHmmss', [Globalization.CultureInfo]::InvariantCulture) + ' +0000' $guideStopText = $guideStop.ToString('yyyyMMddHHmmss', [Globalization.CultureInfo]::InvariantCulture) + ' +0000' - $guide = "OneHTTP Smoke NewsNews" - $body = New-TestProposalBody -GuideText $guide -WithGuide + $guide = "OneTwoHTTP Smoke NewsNewsHTTP Smoke NewsNews" + $playlistText = "#EXTM3U`n#EXTINF:-1 tvg-id=one,One`nhttps://example.invalid/one`n#EXTINF:-1 tvg-id=two,Two`nhttps://example.invalid/two`n" + $body = New-TestProposalBody -PlaylistText $playlistText -GuideText $guide -WithGuide $proposalHeaders = "POST /api/guided-setup/proposal HTTP/1.1`r`nHost: 127.0.0.1`r`nConnection: keep-alive`r`nContent-Type: application/json`r`nContent-Length: $($body.Length)" $proposalResponse = Invoke-TestRawHttpRequest -Port $port -Headers $proposalHeaders -BodyBytes $body $proposalResponse.StatusCode | Should -Be 200 @@ -882,13 +950,45 @@ Describe 'ChannelForge web server foundation' { $acceptResponse.StatusCode | Should -Be 200 ($acceptResponse.Body | ConvertFrom-Json).Status | Should -Be 'ACCEPTED' Test-Path -LiteralPath (Join-Path $root 'state\accepted-lineup.json') | Should -BeTrue + $pointerPath = Join-Path $root 'state\accepted-lineup.json' + $pointerHashBefore = (Get-FileHash -LiteralPath $pointerPath -Algorithm SHA256).Hash + $acceptedGenerationId = (Get-Content -LiteralPath $pointerPath -Raw | ConvertFrom-Json).GenerationId + $acceptedGuidePath = Join-Path $root "state\generations\$acceptedGenerationId\merged.xml" + $guideHashBefore = (Get-FileHash -LiteralPath $acceptedGuidePath -Algorithm SHA256).Hash + $internalChannelId = [regex]::Match([IO.File]::ReadAllText($acceptedGuidePath), ' Date: Mon, 28 Sep 2026 23:08:00 -0400 Subject: [PATCH 3/6] perf(one-guide): optimize warm accepted guide navigation --- docs/architecture/ARCHITECTURE.md | 10 ++- docs/developer/DEVELOPER_GUIDE.md | 5 +- docs/user/CURRENT_LIMITATIONS.md | 7 +- ...t-ChannelForgeOneGuideCategoryRegistry.ps1 | 15 ++++ .../Get-ChannelForgeWebOneGuideResponse.ps1 | 3 + .../Private/Get-ChannelForgeWebResponse.ps1 | 3 +- .../Public/Get-ChannelForgeOneGuide.ps1 | 88 ++++++++++--------- tests/unit/GeneratedReportSchemas.Tests.ps1 | 2 + tests/unit/OneGuide.Tests.ps1 | 26 +++++- tests/unit/WebServer.Tests.ps1 | 13 +++ 10 files changed, 118 insertions(+), 54 deletions(-) create mode 100644 src/ChannelForge/Private/Get-ChannelForgeOneGuideCategoryRegistry.ps1 diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index 3e7f138..62a2b1e 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -101,9 +101,11 @@ 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 are evaluated in UTC at request time; the public query function takes an explicit evaluation instant. -The fixed category keys are Live Now, Starting Soon, Wrestling, Football, -Baseball, Soccer, Movies, and News. Cross-channel grouping requires an explicit -`CanonicalEventId` in projection input; the generated item identity also +The Slice 1 built-in category registry contains only Live Now, Starting Soon, +Wrestling, Football, Baseball, Soccer, Movies, and News. It maps only those +recognized XMLTV categories to stable keys; unrecognized categories are 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 the explicit ID, generated identities are scoped to a source/channel occurrence, so matching title and time on distinct pairs remain separate. Accepted XMLTV @@ -116,7 +118,7 @@ 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, keyed by its pointer and manifest identity. Catalogue construction +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 diff --git a/docs/developer/DEVELOPER_GUIDE.md b/docs/developer/DEVELOPER_GUIDE.md index 7c7732f..fdd560e 100644 --- a/docs/developer/DEVELOPER_GUIDE.md +++ b/docs/developer/DEVELOPER_GUIDE.md @@ -545,8 +545,9 @@ The read endpoints are: | `GET`, `HEAD` | `/api/one-guide/category/{key}` | Bounded items in a fixed One Guide category | | `GET`, `HEAD` | `/api/one-guide/items/{item-id}` | One deterministic item detail | -Category keys: `live-now`, `starting-soon`, `wrestling`, `football`, `baseball`, -`soccer`, `movies`, `news`. Responses use UTC timestamps and contain no raw +Slice 1 built-in category keys: `live-now`, `starting-soon`, `wrestling`, +`football`, `baseball`, `soccer`, `movies`, and `news`. Category aliases map +deterministically; other XMLTV categories are omitted. Responses use UTC timestamps and contain no raw URLs, credentials, private paths, accepted hashes, or parser diagnostics. The server remains loopback-only; authentication and LAN access are not implemented. diff --git a/docs/user/CURRENT_LIMITATIONS.md b/docs/user/CURRENT_LIMITATIONS.md index 30c6ab9..7d8149a 100644 --- a/docs/user/CURRENT_LIMITATIONS.md +++ b/docs/user/CURRENT_LIMITATIONS.md @@ -18,7 +18,7 @@ ChannelForge is **Early Alpha**. Treat everything below as the honest, current s - Browser Guided Setup exposes the source-set contract through `/api/guided-setup/proposal` and `/api/guided-setup/accept`: multiple local/public-HTTPS playlists, optional multiple guides, an automatic sole-playlist default when no prior decision exists, persistent explicit unbind, explicit selected/all bindings, and explicit no-guide mode. It requires acknowledgement, reuses immutable accepted-state publication, and leaves provider, downstream, and scheduler state unchanged. - Durable source enrollment after browser acceptance includes local and public HTTPS sources, optional XMLTV, no-guide mode, managed-root integrity checks, restart-safe redacted per-source status, manual review-only refresh, last-known-good preservation, and no automatic acceptance. - A Stage A/B/C/D read-only guide-intelligence contract for provider display text, M3U metadata, XMLTV, documented AED-derived evidence, schedule evidence, accepted knowledge, native event-pattern candidates, and beginner review reports. Stage D wires the report-only event-pattern preview into `scripts/Build-My-Lineup.ps1`; it writes deterministic redacted JSON/Markdown/text reports and never publishes a guide or mutates provider, downstream, or accepted state. -- A bounded, read-only One Guide API for Live Now, Starting Soon, fixed categories, and item details. It projects only the verified accepted XMLTV guide, makes no network requests, and does not change saved state. Query results use stable item IDs, safe channel references, and a fixed eight-key taxonomy. +- A bounded, read-only One Guide API for Live Now, Starting Soon, a versioned built-in category registry, and item details. It projects only the verified accepted XMLTV guide, makes no network requests, and does not change saved state. Query results use stable item IDs, safe channel references, and deterministic category keys. - Existing GUI work is preserved as a reusable React/TypeScript layout, design-token, Guided Setup, validation/review, and saved-lineup reference. Its Tauri wrapper is optional packaging work, not the primary product UI. - The optional Tauri saved-lineup flow prepares a read-only candidate plan, permits saving only for a native **Checked** match, requires accessible explicit acknowledgement, and navigates to a redacted accepted-state view after native success. @@ -42,9 +42,10 @@ contract. The landing UI does not yet show One Guide pages. One Guide reads only the verified accepted XMLTV artifact. It does not fetch providers, enumerate current source configuration, or repair accepted state. Starting Soon means a programme starts after now and within two hours; Live Now -includes its start but excludes its stop. The eight category keys are +includes its start but excludes its stop. The built-in category keys are `live-now`, `starting-soon`, `wrestling`, `football`, `baseball`, `soccer`, -`movies`, and `news`. +`movies`, and `news`. Category aliases are deterministic; other XMLTV +categories are omitted. List `GET` and `HEAD` requests support `limit` (1–100) and `offset` (0–2,147,483,647); item Details does not accept an offset. Malformed or out-of-range values fail with HTTP 400, while an offset beyond the list result diff --git a/src/ChannelForge/Private/Get-ChannelForgeOneGuideCategoryRegistry.ps1 b/src/ChannelForge/Private/Get-ChannelForgeOneGuideCategoryRegistry.ps1 new file mode 100644 index 0000000..9fb18fb --- /dev/null +++ b/src/ChannelForge/Private/Get-ChannelForgeOneGuideCategoryRegistry.ps1 @@ -0,0 +1,15 @@ +function Get-ChannelForgeOneGuideCategoryRegistry { + [CmdletBinding()] + param() + + return @( + [pscustomobject][ordered]@{ Key = 'live-now'; DisplayName = 'Live Now'; Group = 'temporal'; Aliases = @() } + [pscustomobject][ordered]@{ Key = 'starting-soon'; DisplayName = 'Starting Soon'; Group = 'temporal'; Aliases = @() } + [pscustomobject][ordered]@{ Key = 'wrestling'; DisplayName = 'Wrestling'; Group = 'sports'; Aliases = @('wrestling', 'professional wrestling', 'pro wrestling') } + [pscustomobject][ordered]@{ Key = 'football'; DisplayName = 'Football'; Group = 'sports'; Aliases = @('football') } + [pscustomobject][ordered]@{ Key = 'baseball'; DisplayName = 'Baseball'; Group = 'sports'; Aliases = @('baseball') } + [pscustomobject][ordered]@{ Key = 'soccer'; DisplayName = 'Soccer'; Group = 'sports'; Aliases = @('soccer', 'association football') } + [pscustomobject][ordered]@{ Key = 'movies'; DisplayName = 'Movies'; Group = 'content'; Aliases = @('movie', 'movies', 'film', 'films') } + [pscustomobject][ordered]@{ Key = 'news'; DisplayName = 'News'; Group = 'content'; Aliases = @('news', 'current affairs') } + ) +} diff --git a/src/ChannelForge/Private/Get-ChannelForgeWebOneGuideResponse.ps1 b/src/ChannelForge/Private/Get-ChannelForgeWebOneGuideResponse.ps1 index e36312c..2c3ffa5 100644 --- a/src/ChannelForge/Private/Get-ChannelForgeWebOneGuideResponse.ps1 +++ b/src/ChannelForge/Private/Get-ChannelForgeWebOneGuideResponse.ps1 @@ -14,7 +14,9 @@ function Get-ChannelForgeWebOneGuideCatalogue { Assert-ChannelForgeGenerationHash $pointer.Object.PointerHash 'PointerHash' Assert-ChannelForgeGenerationId ([string]$pointer.Object.GenerationId) if (-not (Test-ChannelForgeGenerationProjectionHash $pointer.Object 'pointer/v2' 'PointerHash')) { throw 'FAIL_CLOSED: current pointer hash is invalid.' } + $repositoryRootKey = [IO.Path]::GetFullPath($RepositoryRoot) $cacheKey = @( + $repositoryRootKey, [string]$pointer.Object.GenerationId, [string]$pointer.Object.GenerationManifestHash, [string]$pointer.Object.AcceptedStateHash, @@ -33,6 +35,7 @@ function Get-ChannelForgeWebOneGuideCatalogue { } $snapshotPointer = $snapshot.Pointer.Object $snapshotKey = @( + $repositoryRootKey, [string]$snapshotPointer.GenerationId, [string]$snapshotPointer.GenerationManifestHash, [string]$snapshotPointer.AcceptedStateHash, diff --git a/src/ChannelForge/Private/Get-ChannelForgeWebResponse.ps1 b/src/ChannelForge/Private/Get-ChannelForgeWebResponse.ps1 index 27f8d39..9fd7a54 100644 --- a/src/ChannelForge/Private/Get-ChannelForgeWebResponse.ps1 +++ b/src/ChannelForge/Private/Get-ChannelForgeWebResponse.ps1 @@ -273,7 +273,8 @@ function Get-ChannelForgeWebResponse { } if ($oneGuideRoute.Groups[2].Success) { $categoryKey = $oneGuideRoute.Groups[2].Value.ToLowerInvariant() - if ($categoryKey -notin @('live-now', 'starting-soon', 'wrestling', 'football', 'baseball', 'soccer', 'movies', 'news')) { + $supportedCategoryKeys = @(Get-ChannelForgeOneGuideCategoryRegistry | ForEach-Object { $_.Key }) + if ($supportedCategoryKeys -cnotcontains $categoryKey) { return New-ChannelForgeWebResponse -StatusCode 400 -ContentType $contentType -Body (@{ Error = 'invalid-category'; Message = 'The guide category is not supported.' } | ConvertTo-Json -Compress) -Headers $commonHeaders } return Get-ChannelForgeWebOneGuideResponse -RepositoryRoot $RepositoryRoot -Headers $commonHeaders -ContentType $contentType -Query Category -CategoryKey $categoryKey -MaximumItems $paging.MaximumItems -Offset $paging.Offset diff --git a/src/ChannelForge/Public/Get-ChannelForgeOneGuide.ps1 b/src/ChannelForge/Public/Get-ChannelForgeOneGuide.ps1 index d3519f0..f28b56d 100644 --- a/src/ChannelForge/Public/Get-ChannelForgeOneGuide.ps1 +++ b/src/ChannelForge/Public/Get-ChannelForgeOneGuide.ps1 @@ -4,7 +4,7 @@ function Get-ChannelForgeOneGuide { [Parameter(Mandatory)][ValidateSet('LiveNow', 'StartingSoon', 'Category', 'Details')][string]$Query, [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Programmes, [Parameter(Mandatory)][datetimeoffset]$EvaluationTimeUtc, - [ValidateSet('live-now', 'starting-soon', 'wrestling', 'football', 'baseball', 'soccer', 'movies', 'news')][string]$CategoryKey, + [string]$CategoryKey, [ValidatePattern('^[a-f0-9]{64}$')][string]$ItemId, [ValidateRange(1, 100)][int]$MaximumItems = 100, [ValidateRange(0, 2147483647)][int]$Offset = 0, @@ -16,7 +16,8 @@ function Get-ChannelForgeOneGuide { $evaluation = $EvaluationTimeUtc.ToUniversalTime() $startingSoonEnd = $evaluation.AddHours(2) - $taxonomyOrder = @('live-now', 'starting-soon', 'wrestling', 'football', 'baseball', 'soccer', 'movies', 'news') + $categoryRegistry = @(Get-ChannelForgeOneGuideCategoryRegistry) + $taxonomyOrder = @($categoryRegistry | ForEach-Object { $_.Key }) if ($UseCatalogue) { if ($Query -eq 'Details' -and $null -ne $CatalogueById) { if ($CatalogueById.ContainsKey($ItemId)) { $orderedItems = @($CatalogueById[$ItemId]) } @@ -26,15 +27,14 @@ function Get-ChannelForgeOneGuide { } else { $itemGroups = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::Ordinal) - $taxonomy = @{ - wrestling = 'wrestling' - football = 'football' - baseball = 'baseball' - soccer = 'soccer' - movies = 'movies' - movie = 'movies' - news = 'news' - } + $sourceReferences = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) + $channelReferences = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) + $taxonomy = @{} + foreach ($categoryDefinition in $categoryRegistry) { + foreach ($alias in @($categoryDefinition.Aliases)) { + $taxonomy[[string]$alias.ToLowerInvariant()] = [string]$categoryDefinition.Key + } + } function Get-InputValue { param([AllowNull()][object]$InputObject, [Parameter(Mandatory)][string]$Name) @@ -136,10 +136,18 @@ function Get-ChannelForgeOneGuide { StopUtc = $stopText } $itemId = Get-ChannelForgeDomainHash -Domain 'one-guide-item/v1' -InputObject $identity - $sourceId = Get-OpaqueIdentifier $rawSourceId 'one-guide-source/v1' 'accepted-guide' + if ($sourceReferences.ContainsKey($rawSourceId)) { $sourceId = $sourceReferences[$rawSourceId] } + else { + $sourceId = Get-OpaqueIdentifier $rawSourceId 'one-guide-source/v1' 'accepted-guide' + $sourceReferences[$rawSourceId] = $sourceId + } $sourceLabel = Get-SafeOptionalText (Get-InputValue $programme 'SourceLabel') 128 if ($null -eq $sourceLabel) { $sourceLabel = if ($rawSourceId -ceq 'accepted-guide') { 'Accepted guide' } else { 'Source' } } - $channelReference = Get-OpaqueIdentifier $rawChannelId 'one-guide-channel/v1' + if ($channelReferences.ContainsKey($rawChannelId)) { $channelReference = $channelReferences[$rawChannelId] } + else { + $channelReference = Get-OpaqueIdentifier $rawChannelId 'one-guide-channel/v1' + $channelReferences[$rawChannelId] = $channelReference + } if ([string]::IsNullOrWhiteSpace($channelReference)) { continue } $offeringId = Get-ChannelForgeDomainHash -Domain 'one-guide-offering/v1' -InputObject ([ordered]@{ ItemId = $itemId @@ -169,6 +177,8 @@ function Get-ChannelForgeOneGuide { EpisodeNumber = $episodeNumber StartUtc = $startText StopUtc = $stopText + StartUtcTicks = $start.UtcTicks + StopUtcTicks = $stop.UtcTicks Status = $status CategoryKeys = [System.Collections.Generic.SortedSet[string]]::new([System.StringComparer]::Ordinal) Sport = $sport @@ -202,18 +212,10 @@ function Get-ChannelForgeOneGuide { if ($null -ne $episodeNumber -and ($null -eq $group.EpisodeNumber -or [StringComparer]::Ordinal.Compare($episodeNumber, [string]$group.EpisodeNumber) -lt 0)) { $group.EpisodeNumber = $episodeNumber } foreach ($category in $categories) { [void]$group.CategoryKeys.Add($category) } - foreach ($metadata in @( - @{ Name = 'Sport'; Value = $sport }, - @{ Name = 'League'; Value = $league }, - @{ Name = 'HomeParticipant'; Value = $homeParticipant }, - @{ Name = 'AwayParticipant'; Value = $awayParticipant } - )) { - $candidate = $metadata.Value - $current = $group.($metadata.Name) - if ($null -ne $candidate -and ($null -eq $current -or [StringComparer]::Ordinal.Compare([string]$candidate, [string]$current) -lt 0)) { - $group.($metadata.Name) = $candidate - } - } + if ($null -ne $sport -and ($null -eq $group.Sport -or [StringComparer]::Ordinal.Compare($sport, [string]$group.Sport) -lt 0)) { $group.Sport = $sport } + if ($null -ne $league -and ($null -eq $group.League -or [StringComparer]::Ordinal.Compare($league, [string]$group.League) -lt 0)) { $group.League = $league } + if ($null -ne $homeParticipant -and ($null -eq $group.HomeParticipant -or [StringComparer]::Ordinal.Compare($homeParticipant, [string]$group.HomeParticipant) -lt 0)) { $group.HomeParticipant = $homeParticipant } + if ($null -ne $awayParticipant -and ($null -eq $group.AwayParticipant -or [StringComparer]::Ordinal.Compare($awayParticipant, [string]$group.AwayParticipant) -lt 0)) { $group.AwayParticipant = $awayParticipant } if ($null -ne $promotion) { $candidatePromotionKey = "$($promotion.Id)::$($promotion.Name)" $currentPromotionKey = if ($null -eq $group.Promotion) { $null } else { "$($group.Promotion.Id)::$($group.Promotion.Name)" } @@ -240,15 +242,21 @@ function Get-ChannelForgeOneGuide { } $items = [System.Collections.Generic.List[object]]::new() foreach ($group in $itemGroups.Values) { - $allOfferings = @($group.Offerings.Values) - [Array]::Sort($allOfferings, $offeringComparison) - $offerings = @($allOfferings | Select-Object -First 16) + $allOfferings = [object[]]@($group.Offerings.Values) + if ($allOfferings.Count -gt 1) { [Array]::Sort($allOfferings, $offeringComparison) } + $offeringCount = $allOfferings.Count + $offeringLimit = [Math]::Min($offeringCount, 16) + if ($offeringCount -gt $offeringLimit) { + $offerings = [object[]]::new($offeringLimit) + [Array]::Copy($allOfferings, $offerings, $offeringLimit) + } + else { $offerings = $allOfferings } $orderedCategories = [System.Collections.Generic.List[string]]::new() foreach ($taxonomyKey in $taxonomyOrder) { - if (@($group.CategoryKeys) -contains $taxonomyKey) { [void]$orderedCategories.Add($taxonomyKey) } + if ($group.CategoryKeys.Contains($taxonomyKey)) { [void]$orderedCategories.Add($taxonomyKey) } } $finalKind = if ($group.HasExplicitKind -and -not $group.KindConflict) { $group.Kind } else { 'Programme' } - $items.Add([pscustomobject][ordered]@{ + $itemProjection = [ordered]@{ ItemId = $group.ItemId Kind = $finalKind Title = $group.Title @@ -265,26 +273,26 @@ function Get-ChannelForgeOneGuide { AwayParticipant = $group.AwayParticipant Promotion = $group.Promotion Offerings = $offerings - OfferingCount = $allOfferings.Count - OfferingsTruncated = ($allOfferings.Count -gt $offerings.Count) + OfferingCount = $offeringCount + OfferingsTruncated = ($offeringCount -gt $offeringLimit) FreshnessState = 'Unknown' ConfidenceState = 'Unknown' ConfidenceScore = $null - }) + } + if ($ReturnCatalogue) { + $itemProjection.StartUtcTicks = $group.StartUtcTicks + $itemProjection.StopUtcTicks = $group.StopUtcTicks + } + $items.Add([pscustomobject]$itemProjection) } $orderedItems = $items.ToArray() [Array]::Sort($orderedItems, $itemComparison) } - if ($ReturnCatalogue) { - foreach ($item in $orderedItems) { - $item | Add-Member -NotePropertyName StartUtcTicks -NotePropertyValue ([datetimeoffset]::Parse([string]$item.StartUtc, [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::AdjustToUniversal).UtcTicks) - $item | Add-Member -NotePropertyName StopUtcTicks -NotePropertyValue ([datetimeoffset]::Parse([string]$item.StopUtc, [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::AdjustToUniversal).UtcTicks) - } - return $orderedItems - } + if ($ReturnCatalogue) { return $orderedItems } if ($Query -eq 'Category') { if ([string]::IsNullOrWhiteSpace($CategoryKey)) { throw 'CategoryKey is required for a category query.' } $CategoryKey = $CategoryKey.ToLowerInvariant() + if ($taxonomyOrder -cnotcontains $CategoryKey) { throw "Unsupported One Guide category '$CategoryKey'." } } if ($Query -eq 'Details' -and [string]::IsNullOrWhiteSpace($ItemId)) { throw 'ItemId is required for a details query.' } diff --git a/tests/unit/GeneratedReportSchemas.Tests.ps1 b/tests/unit/GeneratedReportSchemas.Tests.ps1 index 6cb34b8..77f25fd 100644 --- a/tests/unit/GeneratedReportSchemas.Tests.ps1 +++ b/tests/unit/GeneratedReportSchemas.Tests.ps1 @@ -8,6 +8,7 @@ BeforeAll { $script:AcceptancePlanSchema = Join-Path $script:RepoRoot 'schemas\guide-pattern-acceptance-plan.schema.json' $script:WorkflowPath = Join-Path $script:RepoRoot 'scripts\Build-My-Lineup.ps1' $script:PlaylistFixture = Join-Path $script:RepoRoot 'tests\fixtures\identity-binding\playlist.m3u' + $script:GuideFixture = Join-Path $script:RepoRoot 'tests\fixtures\identity-binding\guide.xml' function New-InferenceFixture { param([string[]]$Examples = @( @@ -119,6 +120,7 @@ Describe 'Generated report JSON schemas' { & $script:WorkflowPath ` -Root $root ` -M3UPath $script:PlaylistFixture ` + -XMLTVPath $script:GuideFixture ` -EventPatternPreview ` -EventPatternExamples @( 'UFC 01: Fight Night // UTC Sat 13 Apr 5:00pm' diff --git a/tests/unit/OneGuide.Tests.ps1 b/tests/unit/OneGuide.Tests.ps1 index 6ffe1c6..0f10a27 100644 --- a/tests/unit/OneGuide.Tests.ps1 +++ b/tests/unit/OneGuide.Tests.ps1 @@ -59,9 +59,25 @@ Describe 'One Guide read projection' { $live.EvaluationTimeUtc | Should -Be $Evaluation.ToString('o') } - It 'emits only the fixed taxonomy and keeps wrestling promotion structured' { - $programmes = Get-OneGuideFixtureProgrammes + It 'exposes only the eight Slice 1 categories and keeps wrestling promotion structured' { + $fixtureProgrammes = Get-OneGuideFixtureProgrammes $expectedTaxonomy = @('live-now', 'starting-soon', 'wrestling', 'football', 'baseball', 'soccer', 'movies', 'news') + $registryTaxonomy = @(& (Get-Module ChannelForge) { Get-ChannelForgeOneGuideCategoryRegistry } | ForEach-Object { $_.Key }) + $registryTaxonomy | Should -Be $expectedTaxonomy + $categoryAliases = [ordered]@{ + wrestling = 'Professional Wrestling' + football = 'Football' + baseball = 'Baseball' + soccer = 'Association Football' + movies = 'Film' + news = 'Current Affairs' + } + $categoryProgrammes = @( + foreach ($entry in $categoryAliases.GetEnumerator()) { + New-OneGuideTestProgramme "category-$($entry.Key)" ($Evaluation.AddMinutes(30)) ($Evaluation.AddHours(1)) "Category $($entry.Key)" @([string]$entry.Value) + } + ) + $programmes = @($fixtureProgrammes) + @($categoryProgrammes) foreach ($key in $expectedTaxonomy) { $result = Get-ChannelForgeOneGuide -Query Category -CategoryKey $key -Programmes $programmes -EvaluationTimeUtc $Evaluation @@ -69,8 +85,10 @@ Describe 'One Guide read projection' { $result.CategoryKey | Should -Be $key $result.TotalCount | Should -BeGreaterThan 0 } - $items = Get-ChannelForgeOneGuide -Query StartingSoon -Programmes $programmes -EvaluationTimeUtc $Evaluation - $wrestling = Get-ChannelForgeOneGuide -Query Category -CategoryKey wrestling -Programmes $programmes -EvaluationTimeUtc $Evaluation + + { Get-ChannelForgeOneGuide -Query Category -CategoryKey hockey -Programmes $programmes -EvaluationTimeUtc $Evaluation } | Should -Throw '*Unsupported One Guide category*' + $items = Get-ChannelForgeOneGuide -Query StartingSoon -Programmes $fixtureProgrammes -EvaluationTimeUtc $Evaluation + $wrestling = Get-ChannelForgeOneGuide -Query Category -CategoryKey wrestling -Programmes $fixtureProgrammes -EvaluationTimeUtc $Evaluation $items.Items[0].Promotion | Should -BeNullOrEmpty $wrestling.Items[0].Promotion.Id | Should -Match '^cf-[a-f0-9]{64}$' $wrestling.Items[0].Promotion.Name | Should -Be 'WWE' diff --git a/tests/unit/WebServer.Tests.ps1 b/tests/unit/WebServer.Tests.ps1 index 2ed6737..77c5d29 100644 --- a/tests/unit/WebServer.Tests.ps1 +++ b/tests/unit/WebServer.Tests.ps1 @@ -526,6 +526,19 @@ Describe 'ChannelForge web server foundation' { $payload = $live.Body | ConvertFrom-Json @($payload.Items).Count | Should -Be 1 $payload.Items[0].ItemId | Should -Match '^[a-f0-9]{64}$' + $currentPaths = & (Get-Module ChannelForge) { + param($RepositoryRoot) + Get-ChannelForgeGenerationPaths -RepositoryRoot $RepositoryRoot + } $root + $alternateRoot = Join-Path $TestDrive 'one-guide-unverified-cache-root' + $alternatePaths = & (Get-Module ChannelForge) { + param($RepositoryRoot) + Get-ChannelForgeGenerationPaths -RepositoryRoot $RepositoryRoot + } $alternateRoot + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $alternatePaths.Current) | Out-Null + Copy-Item -LiteralPath $currentPaths.Current -Destination $alternatePaths.Current + $alternateRootResponse = Get-TestWebResponse -Method GET -Path '/api/one-guide/live-now' -RepositoryRoot $alternateRoot + $alternateRootResponse.StatusCode | Should -Be 503 Mock -CommandName Read-ChannelForgeGenerationFile -ModuleName ChannelForge -MockWith { if ($global:ChannelForgeOneGuideCacheCheckArmed -and [IO.Path]::GetFileName($Path) -ceq 'merged.xml') { $global:ChannelForgeOneGuideXmltvReadCount++ From 90954ba92004b5db2bf9e3db58400e7cd58be31e Mon Sep 17 00:00:00 2001 From: Jumpstile <29765787+Jumpstile@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:48:23 -0400 Subject: [PATCH 4/6] Fix deterministic One Guide row identity --- docs/architecture/ARCHITECTURE.md | 17 +++++---- docs/developer/DEVELOPER_GUIDE.md | 18 +++++---- docs/user/CURRENT_LIMITATIONS.md | 15 +++++--- .../Public/Get-ChannelForgeOneGuide.ps1 | 29 ++++++++++++++- tests/unit/OneGuide.Tests.ps1 | 37 +++++++++++++------ 5 files changed, 81 insertions(+), 35 deletions(-) diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index 62a2b1e..33be7ae 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -105,14 +105,15 @@ The Slice 1 built-in category registry contains only Live Now, Starting Soon, Wrestling, Football, Baseball, Soccer, Movies, and News. It maps only those recognized XMLTV categories to stable keys; unrecognized categories are 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 -the explicit ID, generated identities are scoped to a source/channel occurrence, -so matching title and time on distinct pairs remain separate. Accepted XMLTV -does not preserve `CanonicalEventId` or provider attribution per programme. The -HTTP projection keeps those channel occurrences separate and labels their -source `Accepted guide`. It reports -`Kind` as `Programme` unless the input explicitly carries a recognized kind. +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. diff --git a/docs/developer/DEVELOPER_GUIDE.md b/docs/developer/DEVELOPER_GUIDE.md index fdd560e..624b316 100644 --- a/docs/developer/DEVELOPER_GUIDE.md +++ b/docs/developer/DEVELOPER_GUIDE.md @@ -468,13 +468,17 @@ offerings and reports `OfferingCount` and `OfferingsTruncated`. The accepted merged XMLTV does not retain provider attribution or a `CanonicalEventId` per programme. Cross-channel grouping requires the same -explicit identity and normalized title, subtitle, episode number, and schedule; -matching title and time alone never merge separate source/channel occurrences. -Accepted-guide HTTP responses use the display label `Accepted guide` and opaque -domain-separated source, channel, and promotion identifiers. They report `Kind` -as `Programme` unless the input explicitly supplies a recognized kind. Sport, -promotion, entitlement, and launch metadata are emitted only when the input -actually contains them; the title is never used to infer those fields. +explicit identity and normalized title, subtitle, episode number, and schedule. +Without explicit identity, rows on a source/channel correlate only when their +sanitized source-row evidence also matches, including descriptions, recognized +or unrecognized categories, and XMLTV new/live/premiere flags. Conflicts remain +separate; the fixed registry still omits unrecognized categories from output. +Accepted-guide HTTP responses use +the display label `Accepted guide` and opaque domain-separated source, channel, +and promotion identifiers. They report `Kind` as `Programme` unless input +explicitly supplies a recognized kind. Sport, promotion, entitlement, and +launch metadata are emitted only when input actually contains them; title text +is never used to infer those fields. The server holds a one-entry in-memory projection cache keyed by the current accepted pointer and manifest identity. Catalogue construction parses and diff --git a/docs/user/CURRENT_LIMITATIONS.md b/docs/user/CURRENT_LIMITATIONS.md index 7d8149a..74cbc46 100644 --- a/docs/user/CURRENT_LIMITATIONS.md +++ b/docs/user/CURRENT_LIMITATIONS.md @@ -52,12 +52,15 @@ out-of-range values fail with HTTP 400, while an offset beyond the list result count returns an empty page. The accepted XMLTV path does not preserve a cross-channel `CanonicalEventId` or -provider attribution for each programme. Matching titles and times on separate -source/channel occurrences therefore remain separate items; the API does not -guess that they are one event. The source label is `Accepted guide`, public -source/channel/promotion identifiers are opaque, and `Kind` remains -`Programme` unless the input explicitly supplies a recognized value. Optional -metadata appears only when preserved by the accepted guide; entitlement, +provider attribution for each programme. Separate source/channel occurrences +are not grouped by matching title and time alone. Conflicting descriptions, +categories (including categories the API omits), XMLTV new/live/premiere flags, +or other sanitized source-row evidence on the same source/channel remain +separate items; only matching row evidence shares an item. The API does not +infer event identity from titles or categories. The source label is +`Accepted guide`, public source/channel/promotion identifiers are opaque, and +`Kind` remains `Programme` unless input explicitly supplies a recognized value. +Optional metadata appears only when preserved by the accepted guide; entitlement, freshness, confidence, DVR, timeshift, and playback remain unavailable. The local server is loopback-only and does not provide authenticated LAN access. diff --git a/src/ChannelForge/Public/Get-ChannelForgeOneGuide.ps1 b/src/ChannelForge/Public/Get-ChannelForgeOneGuide.ps1 index f28b56d..9bdb064 100644 --- a/src/ChannelForge/Public/Get-ChannelForgeOneGuide.ps1 +++ b/src/ChannelForge/Public/Get-ChannelForgeOneGuide.ps1 @@ -88,12 +88,17 @@ function Get-ChannelForgeOneGuide { $description = Get-SafeOptionalText (Get-InputValue $programme 'Description') 512 $episodeNumber = Get-SafeOptionalText (Get-InputValue $programme 'EpisodeNumber') 128 $categories = [System.Collections.Generic.SortedSet[string]]::new([System.StringComparer]::Ordinal) + $sourceCategories = [System.Collections.Generic.List[string]]::new() foreach ($rawCategory in @(Get-InputValue $programme 'Categories')) { if ($null -eq $rawCategory) { continue } $categoryText = ConvertTo-ChannelForgeGuideSafeText -Value $rawCategory -MaximumLength 128 + [void]$sourceCategories.Add([string]$categoryText) $key = $categoryText.Trim().ToLowerInvariant() if ($taxonomy.ContainsKey($key)) { [void]$categories.Add([string]$taxonomy[$key]) } } + $sourceCategories.Sort([System.StringComparer]::Ordinal) + $sourceCategoryEvidence = $sourceCategories.ToArray() + $sourceCategoryKeys = @($categories) $declaredKind = [string](Get-InputValue $programme 'Kind') if ($declaredKind -cnotin @('Programme', 'Event', 'Movie', 'SeriesEpisode', 'Other')) { $declaredKind = $null } $sport = Get-SafeOptionalText (Get-InputValue $programme 'Sport') 128 @@ -124,6 +129,8 @@ function Get-ChannelForgeOneGuide { $canonicalEventId = [string](Get-InputValue $programme 'CanonicalEventId') if ([string]::IsNullOrWhiteSpace($canonicalEventId)) { $canonicalEventId = $null } else { $canonicalEventId = $canonicalEventId.Trim() } + $sourceLabel = Get-SafeOptionalText (Get-InputValue $programme 'SourceLabel') 128 + if ($null -eq $sourceLabel) { $sourceLabel = if ($rawSourceId -ceq 'accepted-guide') { 'Accepted guide' } else { 'Source' } } $identity = [ordered]@{ Correlation = if ($null -ne $canonicalEventId) { 'ExplicitEvent' } else { 'ChannelOccurrence' } CanonicalEventId = if ($null -ne $canonicalEventId) { $canonicalEventId } else { '' } @@ -134,6 +141,26 @@ function Get-ChannelForgeOneGuide { EpisodeNumber = if ($null -eq $episodeNumber) { '' } else { $episodeNumber.ToLowerInvariant() } StartUtc = $startText StopUtc = $stopText + RowEvidence = if ($null -eq $canonicalEventId) { + [ordered]@{ + Title = $title + Subtitle = $subtitle + EpisodeNumber = $episodeNumber + Description = $description + RecognizedCategoryKeys = $sourceCategoryKeys + SourceCategories = $sourceCategoryEvidence + Kind = $declaredKind + Sport = $sport + League = $league + HomeParticipant = $homeParticipant + AwayParticipant = $awayParticipant + Promotion = $promotion + IsNew = [bool](Get-InputValue $programme 'IsNew') + IsLive = [bool](Get-InputValue $programme 'IsLive') + IsPremiere = [bool](Get-InputValue $programme 'IsPremiere') + SourceLabel = $sourceLabel + } + } else { $null } } $itemId = Get-ChannelForgeDomainHash -Domain 'one-guide-item/v1' -InputObject $identity if ($sourceReferences.ContainsKey($rawSourceId)) { $sourceId = $sourceReferences[$rawSourceId] } @@ -141,8 +168,6 @@ function Get-ChannelForgeOneGuide { $sourceId = Get-OpaqueIdentifier $rawSourceId 'one-guide-source/v1' 'accepted-guide' $sourceReferences[$rawSourceId] = $sourceId } - $sourceLabel = Get-SafeOptionalText (Get-InputValue $programme 'SourceLabel') 128 - if ($null -eq $sourceLabel) { $sourceLabel = if ($rawSourceId -ceq 'accepted-guide') { 'Accepted guide' } else { 'Source' } } if ($channelReferences.ContainsKey($rawChannelId)) { $channelReference = $channelReferences[$rawChannelId] } else { $channelReference = Get-OpaqueIdentifier $rawChannelId 'one-guide-channel/v1' diff --git a/tests/unit/OneGuide.Tests.ps1 b/tests/unit/OneGuide.Tests.ps1 index 0f10a27..eea88e1 100644 --- a/tests/unit/OneGuide.Tests.ps1 +++ b/tests/unit/OneGuide.Tests.ps1 @@ -145,19 +145,32 @@ Describe 'One Guide read projection' { $lowercaseResult.Items[0].Kind | Should -Be 'Programme' } - It 'selects repeated-offering metadata deterministically when source rows disagree' { - $first = New-OneGuideTestProgramme 'shared-channel' ($Evaluation.AddMinutes(20)) ($Evaluation.AddHours(1)) 'Episode Event' @('Football') 'provider-a' -EpisodeNumber 's01e01' -Description 'Zulu description' - $second = New-OneGuideTestProgramme 'shared-channel' ($Evaluation.AddMinutes(20)) ($Evaluation.AddHours(1)) 'Episode Event' @('Football') 'provider-a' -EpisodeNumber 'S01E01' -Description 'Alpha description' - $first | Add-Member -NotePropertyName SourceLabel -NotePropertyValue 'Zulu Source' - $second | Add-Member -NotePropertyName SourceLabel -NotePropertyValue 'Alpha Source' - $forward = Get-ChannelForgeOneGuide -Query StartingSoon -Programmes @($first, $second) -EvaluationTimeUtc $Evaluation - $reverse = Get-ChannelForgeOneGuide -Query StartingSoon -Programmes @($second, $first) -EvaluationTimeUtc $Evaluation - + It 'keeps conflicting same-source programme rows separate and stable under permutation' { + $first = New-OneGuideTestProgramme 'shared-channel' ($Evaluation.AddMinutes(20)) ($Evaluation.AddHours(1)) 'Episode Event' @('Football') 'provider-a' -EpisodeNumber 'S01E01' -Description 'Football description' + $second = New-OneGuideTestProgramme 'shared-channel' ($Evaluation.AddMinutes(20)) ($Evaluation.AddHours(1)) 'Episode Event' @('Wrestling') 'provider-a' -EpisodeNumber 'S01E01' -Description 'Wrestling description' + $third = New-OneGuideTestProgramme 'shared-channel' ($Evaluation.AddMinutes(20)) ($Evaluation.AddHours(1)) 'Episode Event' @('Football') 'provider-a' -EpisodeNumber 'S01E01' -Description 'Football description' + $third | Add-Member -NotePropertyName League -NotePropertyValue 'League B' + $duplicate = New-OneGuideTestProgramme 'shared-channel' ($Evaluation.AddMinutes(20)) ($Evaluation.AddHours(1)) 'Episode Event' @('Football') 'provider-a' -EpisodeNumber 'S01E01' -Description 'Football description' + $newFlag = New-OneGuideTestProgramme 'shared-channel' ($Evaluation.AddMinutes(20)) ($Evaluation.AddHours(1)) 'Episode Event' @('Football') 'provider-a' -EpisodeNumber 'S01E01' -Description 'Football description' + $newFlag.IsNew = $true + $liveFlag = New-OneGuideTestProgramme 'shared-channel' ($Evaluation.AddMinutes(20)) ($Evaluation.AddHours(1)) 'Episode Event' @('Football') 'provider-a' -EpisodeNumber 'S01E01' -Description 'Football description' + $liveFlag.IsLive = $true + $premiereFlag = New-OneGuideTestProgramme 'shared-channel' ($Evaluation.AddMinutes(20)) ($Evaluation.AddHours(1)) 'Episode Event' @('Football') 'provider-a' -EpisodeNumber 'S01E01' -Description 'Football description' + $premiereFlag.IsPremiere = $true + $unknownDrama = New-OneGuideTestProgramme 'shared-channel' ($Evaluation.AddMinutes(20)) ($Evaluation.AddHours(1)) 'Episode Event' @('Drama') 'provider-a' -EpisodeNumber 'S01E01' -Description 'Football description' + $unknownDocumentary = New-OneGuideTestProgramme 'shared-channel' ($Evaluation.AddMinutes(20)) ($Evaluation.AddHours(1)) 'Episode Event' @('Documentary') 'provider-a' -EpisodeNumber 'S01E01' -Description 'Football description' + $allRows = @($first, $second, $third, $duplicate, $newFlag, $liveFlag, $premiereFlag, $unknownDrama, $unknownDocumentary) + $reverseRows = @($unknownDocumentary, $unknownDrama, $premiereFlag, $liveFlag, $newFlag, $duplicate, $third, $second, $first) + $forward = Get-ChannelForgeOneGuide -Query StartingSoon -Programmes $allRows -EvaluationTimeUtc $Evaluation + $reverse = Get-ChannelForgeOneGuide -Query StartingSoon -Programmes $reverseRows -EvaluationTimeUtc $Evaluation + $forward.TotalCount | Should -Be 8 ($forward | ConvertTo-Json -Depth 10 -Compress) | Should -Be ($reverse | ConvertTo-Json -Depth 10 -Compress) - $forward.Items[0].Description | Should -Be 'Alpha description' - $forward.Items[0].EpisodeNumber | Should -Be 'S01E01' - $forward.Items[0].Offerings.Count | Should -Be 1 - $forward.Items[0].Offerings[0].SourceLabel | Should -Be 'Alpha Source' + @($forward.Items.ItemId | Select-Object -Unique).Count | Should -Be 8 + @($forward.Items | Where-Object { $_.CategoryKeys -contains 'football' -and $_.Description -eq 'Wrestling description' }).Count | Should -Be 0 + @($forward.Items | Where-Object { $_.CategoryKeys -contains 'wrestling' -and $_.Description -eq 'Football description' }).Count | Should -Be 0 + @($forward.Items | Where-Object { $_.Description -eq 'Football description' -and $_.League -eq 'League B' }).Count | Should -Be 1 + @($forward.Items | Where-Object { $_.Description -eq 'Football description' -and $null -eq $_.League }).Count | Should -Be 6 + @($forward.Items | Where-Object { $_.OfferingCount -ne 1 }).Count | Should -Be 0 } It 'bounds large offering sets while reporting their full count' { From dfe645d227a3bcdad1ab7496ee46d01dc9f6c637 Mon Sep 17 00:00:00 2001 From: Jumpstile <29765787+Jumpstile@users.noreply.github.com> Date: Wed, 30 Sep 2026 06:44:42 -0400 Subject: [PATCH 5/6] Return unavailable for missing accepted guide --- .../Get-ChannelForgeWebOneGuideResponse.ps1 | 33 ++++++++++--------- tests/unit/WebServer.Tests.ps1 | 32 ++++++++++++++++++ 2 files changed, 49 insertions(+), 16 deletions(-) diff --git a/src/ChannelForge/Private/Get-ChannelForgeWebOneGuideResponse.ps1 b/src/ChannelForge/Private/Get-ChannelForgeWebOneGuideResponse.ps1 index 2c3ffa5..7317257 100644 --- a/src/ChannelForge/Private/Get-ChannelForgeWebOneGuideResponse.ps1 +++ b/src/ChannelForge/Private/Get-ChannelForgeWebOneGuideResponse.ps1 @@ -7,7 +7,7 @@ function Get-ChannelForgeWebOneGuideCatalogue { $paths = Get-ChannelForgeGenerationPaths -RepositoryRoot $RepositoryRoot if (-not [IO.File]::Exists($paths.Current)) { $script:ChannelForgeOneGuideAcceptedProjectionCache = $null - return @() + throw 'Accepted guide is unavailable because no accepted generation exists.' } $pointer = Read-ChannelForgeGenerationDocument -RepositoryRoot $RepositoryRoot -Path $paths.Current -Domain 'pointer/v2' Assert-ChannelForgeGenerationPropertySequence $pointer.Object @('Version', 'GenerationId', 'GenerationManifestHash', 'AcceptedStateHash', 'AcceptedOutputManifestHash', 'PointerHash') 'Pointer' @@ -31,7 +31,7 @@ function Get-ChannelForgeWebOneGuideCatalogue { $snapshot = Get-ChannelForgeGenerationCurrentSnapshot -RepositoryRoot $RepositoryRoot -Paths $paths if ($null -eq $snapshot) { $script:ChannelForgeOneGuideAcceptedProjectionCache = $null - return @() + throw 'Accepted guide is unavailable because the accepted generation could not be loaded.' } $snapshotPointer = $snapshot.Pointer.Object $snapshotKey = @( @@ -44,22 +44,23 @@ function Get-ChannelForgeWebOneGuideCatalogue { ) -join '|' if ($snapshotKey -cne $cacheKey) { throw 'Accepted generation changed while the One Guide projection was loading.' } + if ($null -eq $snapshot.XMLTV) { + throw 'Accepted guide is unavailable because accepted XMLTV was not generated.' + } $programmes = @() - if ($null -ne $snapshot.XMLTV) { - $bytes = [byte[]]$snapshot.XMLTV.Bytes - if ($bytes.Length -gt 268435456) { throw 'Accepted guide exceeds the read projection limit.' } - $stream = [System.IO.MemoryStream]::new($bytes, $false) - try { - $programmes = @(Read-ChannelForgeXmltvDocument ` - -Stream $stream ` - -SourceId 'accepted-guide' ` - -SourcePath $snapshot.XMLTV.Path ` - -SourceKind local ` - -MaxDocumentBytes 268435456 ` - -CandidateContractVersion 'blocker-2-contract/v8') - } - finally { $stream.Dispose() } + $bytes = [byte[]]$snapshot.XMLTV.Bytes + if ($bytes.Length -gt 268435456) { throw 'Accepted guide exceeds the read projection limit.' } + $stream = [System.IO.MemoryStream]::new($bytes, $false) + try { + $programmes = @(Read-ChannelForgeXmltvDocument ` + -Stream $stream ` + -SourceId 'accepted-guide' ` + -SourcePath $snapshot.XMLTV.Path ` + -SourceKind local ` + -MaxDocumentBytes 268435456 ` + -CandidateContractVersion 'blocker-2-contract/v8') } + finally { $stream.Dispose() } $minimumEvaluationTimeUtc = [datetimeoffset]::MinValue $catalogue = @(Get-ChannelForgeOneGuide -Query LiveNow -Programmes $programmes -EvaluationTimeUtc $minimumEvaluationTimeUtc -ReturnCatalogue) $itemsById = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::Ordinal) diff --git a/tests/unit/WebServer.Tests.ps1 b/tests/unit/WebServer.Tests.ps1 index 77c5d29..e363382 100644 --- a/tests/unit/WebServer.Tests.ps1 +++ b/tests/unit/WebServer.Tests.ps1 @@ -585,6 +585,38 @@ Describe 'ChannelForge web server foundation' { $invalidQuery.StatusCode | Should -Be 400 $after | ConvertTo-Json -Depth 5 | Should -Be ($before | ConvertTo-Json -Depth 5) } + It 'returns unavailable for a missing accepted generation and accepted XMLTV that was not generated' { + $missingRoot = Join-Path $TestDrive 'one-guide-missing-accepted-generation' + $missing = Get-TestWebResponse -Method GET -Path '/api/one-guide/live-now' -RepositoryRoot $missingRoot + + $notGeneratedRoot = Join-Path $TestDrive 'one-guide-not-generated' + New-TestAcceptedState -RepositoryRoot $notGeneratedRoot + $notGenerated = Get-TestWebResponse -Method GET -Path '/api/one-guide/live-now' -RepositoryRoot $notGeneratedRoot + + $missing.StatusCode | Should -Be 503 + ($missing.Body | ConvertFrom-Json).Error | Should -Be 'one-guide-unavailable' + $notGenerated.StatusCode | Should -Be 503 + ($notGenerated.Body | ConvertFrom-Json).Error | Should -Be 'one-guide-unavailable' + } + + It 'returns a valid empty result when accepted XMLTV contains no matching programmes' { + $root = Join-Path $TestDrive 'one-guide-valid-empty' + $guide = 'EmptyExpired Movie' + $proposalBody = New-TestProposalBody -GuideText $guide -WithGuide + $proposal = Get-TestWebResponse -Method POST -Path '/api/guided-setup/proposal' -RepositoryRoot $root -BodyBytes $proposalBody -ContentType 'application/json' -ContentLength $proposalBody.Length + $proposalPayload = $proposal.Body | ConvertFrom-Json + $acceptBody = [Text.Encoding]::UTF8.GetBytes((@{ schemaVersion = 1; proposalId = $proposalPayload.Proposal.ProposalId; acknowledged = $true } | ConvertTo-Json -Compress)) + $accepted = Get-TestWebResponse -Method POST -Path '/api/guided-setup/accept' -RepositoryRoot $root -BodyBytes $acceptBody -ContentType 'application/json' -ContentLength $acceptBody.Length + $response = Get-TestWebResponse -Method GET -Path '/api/one-guide/live-now' -RepositoryRoot $root + $payload = $response.Body | ConvertFrom-Json + + $accepted.StatusCode | Should -Be 200 + $response.StatusCode | Should -Be 200 + $payload.Query | Should -Be 'LiveNow' + $payload.TotalCount | Should -Be 0 + @($payload.Items).Count | Should -Be 0 + } + It 'rebuilds the cached One Guide projection when the accepted generation changes' { $root = Join-Path $TestDrive 'one-guide-cache-invalidation' $publishGuide = { From 16e30826c909ea05a1312fab42c2ef6570f2eeea Mon Sep 17 00:00:00 2001 From: Jumpstile <29765787+Jumpstile@users.noreply.github.com> Date: Wed, 30 Sep 2026 06:47:26 -0400 Subject: [PATCH 6/6] Document One Guide unavailable response --- docs/architecture/ARCHITECTURE.md | 5 +++++ docs/developer/DEVELOPER_GUIDE.md | 4 ++++ docs/user/CURRENT_LIMITATIONS.md | 4 ++++ 3 files changed, 13 insertions(+) diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index 33be7ae..0738051 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -101,6 +101,11 @@ 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 are evaluated in UTC at request time; 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 Slice 1 built-in category registry contains only Live Now, Starting Soon, Wrestling, Football, Baseball, Soccer, Movies, and News. It maps only those recognized XMLTV categories to stable keys; unrecognized categories are omitted. diff --git a/docs/developer/DEVELOPER_GUIDE.md b/docs/developer/DEVELOPER_GUIDE.md index 624b316..48b35cd 100644 --- a/docs/developer/DEVELOPER_GUIDE.md +++ b/docs/developer/DEVELOPER_GUIDE.md @@ -465,6 +465,10 @@ Live intervals are half-open: start is included, stop is excluded. List routes accept `limit` (1–100) and `offset` (nonnegative decimal integer); responses include total counts and truncation flags. Each item returns at most 16 offerings and reports `OfferingCount` and `OfferingsTruncated`. +If there is no accepted generation, or accepted XMLTV is unavailable/not +generated, the HTTP route returns 503 `one-guide-unavailable`. A valid guide +with no matching programmes still returns 200 and an empty result; this +distinguishes source unavailability from a genuine empty query. The accepted merged XMLTV does not retain provider attribution or a `CanonicalEventId` per programme. Cross-channel grouping requires the same diff --git a/docs/user/CURRENT_LIMITATIONS.md b/docs/user/CURRENT_LIMITATIONS.md index 74cbc46..3c46622 100644 --- a/docs/user/CURRENT_LIMITATIONS.md +++ b/docs/user/CURRENT_LIMITATIONS.md @@ -41,6 +41,10 @@ contract. The landing UI does not yet show One Guide pages. One Guide reads only the verified accepted XMLTV artifact. It does not fetch providers, enumerate current source configuration, or repair accepted state. +If accepted state is missing or its XMLTV guide was not generated, a One Guide +request reports HTTP 503 `one-guide-unavailable`. This is different from a +valid accepted guide with no programmes matching the requested query, which +returns HTTP 200 with an empty result. Starting Soon means a programme starts after now and within two hours; Live Now includes its start but excludes its stop. The built-in category keys are `live-now`, `starting-soon`, `wrestling`, `football`, `baseball`, `soccer`,