diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index d2c0b31..0738051 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -82,17 +82,63 @@ 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 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. +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. +Cross-channel grouping requires an explicit `CanonicalEventId` in projection +input; the generated item identity also includes the normalized title, subtitle, +episode number, and schedule. Without that explicit ID, rows are scoped to a +source/channel and sanitized source-row evidence, including descriptions, +categories (recognized or not), and XMLTV new/live/premiere flags. Conflicting +evidence remains separate; exact equivalent rows share an item. The fixed +category registry still omits unrecognized categories from output. Accepted +XMLTV does not preserve `CanonicalEventId` or provider attribution per +programme. The HTTP projection labels its source `Accepted guide`. It reports +`Kind` as `Programme` unless input explicitly carries a recognized kind. +Sport, league, participant, promotion, entitlement, and launch metadata are +reported only when actually supplied; title text is never used to invent them. +Source, channel, and promotion identifiers are opaque domain-separated hashes. + +The server keeps one bounded in-memory projection for the current accepted +generation per repository root, keyed by its pointer and manifest identity. Catalogue construction +precomputes parsed UTC interval ticks and a direct item-ID lookup. Warm list +requests validate the current pointer, scan the cached records, and clone only +the returned page (maximum 100); Details uses the ID lookup. Warm requests do +not reread or reparse accepted XMLTV. A new generation replaces the entry. The +cache is derived and disposable; no database or second source of truth is +introduced. + +List routes use `limit` and `offset` for bounded enumeration (maximum 100 +items); each item reports the number of offerings and whether its 16-offering +summary is truncated. + +The read API exposes a bounded safe projection, not the accepted generation's +raw M3U/XMLTV bytes, hashes, credentials, private paths, or stream URLs. The +current server remains loopback-only; authenticated LAN access is not provided. The primary deployment modes are: @@ -110,8 +156,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 f17702b..48b35cd 100644 --- a/docs/developer/DEVELOPER_GUIDE.md +++ b/docs/developer/DEVELOPER_GUIDE.md @@ -449,7 +449,49 @@ 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`. +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 +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 +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: @@ -499,22 +541,31 @@ 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 | + +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. + +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 9ddf00e..3c46622 100644 --- a/docs/user/CURRENT_LIMITATIONS.md +++ b/docs/user/CURRENT_LIMITATIONS.md @@ -18,18 +18,55 @@ 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, 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. 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. +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`, +`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 +count returns an empty page. + +The accepted XMLTV path does not preserve a cross-channel `CanonicalEventId` or +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. ![Accepted browser Guided Setup review](assets/guided-browser-accepted.png) @@ -76,7 +113,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 +137,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..58ce3be --- /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 of accepted XMLTV. Cross-channel grouping, source provenance, and programme Kind are exposed only when preserved by input evidence.", + "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": "^cf-[a-f0-9]{64}$" + }, + "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": "^cf-[a-f0-9]{64}$" + }, + "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": "^cf-[a-f0-9]{64}$" + } + } + }, + "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 05db5ac..e53337b 100644 --- a/src/ChannelForge/ChannelForge.psd1 +++ b/src/ChannelForge/ChannelForge.psd1 @@ -113,6 +113,7 @@ FunctionsToExport = @( 'Get-ChannelForgeEnrolledSourceInput' 'Set-ChannelForgeSourceEnrollmentRefreshState' 'Get-ChannelForgeGuideReadiness' + 'Get-ChannelForgeOneGuide' 'Get-ChannelForgeWebStatus' 'Read-ChannelForgeEpgSource' 'Read-ChannelForgeProvider' 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 new file mode 100644 index 0000000..7317257 --- /dev/null +++ b/src/ChannelForge/Private/Get-ChannelForgeWebOneGuideResponse.ps1 @@ -0,0 +1,149 @@ +$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 + 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' + 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, + [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 + throw 'Accepted guide is unavailable because the accepted generation could not be loaded.' + } + $snapshotPointer = $snapshot.Pointer.Object + $snapshotKey = @( + $repositoryRootKey, + [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.' } + + if ($null -eq $snapshot.XMLTV) { + throw 'Accepted guide is unavailable because accepted XMLTV was not generated.' + } + $programmes = @() + $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 = '') + + 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 + } + $catalogue = Get-ChannelForgeWebOneGuideCatalogue -RepositoryRoot $RepositoryRoot + $arguments = @{ + Query = $Query + 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 + 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..9fd7a54 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,35 @@ 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() + $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 + } + $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..9bdb064 --- /dev/null +++ b/src/ChannelForge/Public/Get-ChannelForgeOneGuide.ps1 @@ -0,0 +1,390 @@ +function Get-ChannelForgeOneGuide { + [CmdletBinding()] + param( + [Parameter(Mandatory)][ValidateSet('LiveNow', 'StartingSoon', 'Category', 'Details')][string]$Query, + [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Programmes, + [Parameter(Mandatory)][datetimeoffset]$EvaluationTimeUtc, + [string]$CategoryKey, + [ValidatePattern('^[a-f0-9]{64}$')][string]$ItemId, + [ValidateRange(1, 100)][int]$MaximumItems = 100, + [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) + $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]) } + else { $orderedItems = @() } + } + else { $orderedItems = @($Catalogue) } + } + else { + $itemGroups = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::Ordinal) + $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) + 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-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 '' } + 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) + $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 + $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-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 } + } + } + + $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+', ' ') } + $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() } + $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 { '' } + 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() } + 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] } + else { + $sourceId = Get-OpaqueIdentifier $rawSourceId 'one-guide-source/v1' 'accepted-guide' + $sourceReferences[$rawSourceId] = $sourceId + } + 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 + 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]@{ + ItemId = $itemId + Kind = $null + HasExplicitKind = $false + KindConflict = $false + Title = $title + Subtitle = $subtitle + Description = $description + 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 + League = $league + HomeParticipant = $homeParticipant + AwayParticipant = $awayParticipant + Promotion = $promotion + Offerings = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::Ordinal) + } + } + $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 + } + 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) } + + 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)" } + 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 = [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) } + } + $finalKind = if ($group.HasExplicitKind -and -not $group.KindConflict) { $group.Kind } else { 'Programme' } + $itemProjection = [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 = $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) { 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.' } + + $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' { $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) { + if ([int64]$totalCount -ge [int64]$Offset -and [int64]$totalCount -lt $pageEnd) { [void]$selected.Add($item) } + $totalCount++ + } + } + $returned = [System.Collections.Generic.List[object]]::new() + 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) + 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/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 new file mode 100644 index 0000000..24396ed --- /dev/null +++ b/tests/fixtures/one-guide/programmes.json @@ -0,0 +1,77 @@ +[ + { + "ChannelId": "channel-wwe-a", + "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", + "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", + "CanonicalEventId": "sample-event:championship-main-event-2026", + "Kind": "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/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 new file mode 100644 index 0000000..eea88e1 --- /dev/null +++ b/tests/unit/OneGuide.Tests.ps1 @@ -0,0 +1,257 @@ +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', 'CanonicalEventId', 'Kind')) { + $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 '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 + $result.Query | Should -Be 'Category' + $result.CategoryKey | Should -Be $key + $result.TotalCount | Should -BeGreaterThan 0 + } + + { 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' + @($wrestling.Items[0].CategoryKeys | Where-Object { $_ -notin $expectedTaxonomy }) | Should -BeNullOrEmpty + } + + It 'groups offerings only when an explicit canonical event identity is present' { + $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] + + $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 + $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 -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 '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.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' { + $programmes = @( + for ($index = 0; $index -lt 17; $index++) { + $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 + + $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 + $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 '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' + $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 6a99a5c..e363382 100644 --- a/tests/unit/WebServer.Tests.ps1 +++ b/tests/unit/WebServer.Tests.ps1 @@ -276,18 +276,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 { @@ -499,6 +501,157 @@ 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 + + $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 -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++ + 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' + $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) + } + 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 = { + 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' { $root = Join-Path $TestDrive 'state-boundary' @@ -825,7 +978,13 @@ 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 = "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 @@ -838,6 +997,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), '