diff --git a/.github/workflows/wai-catalogue.yml b/.github/workflows/wai-catalogue.yml index c79832c9b..5113c0fc6 100644 --- a/.github/workflows/wai-catalogue.yml +++ b/.github/workflows/wai-catalogue.yml @@ -30,6 +30,7 @@ on: - 'wai/live-conformance/fleet/**' # tests/cli_ops.rs builds the revenue share's --dry-run sample from this corpus. - 'wai/live-conformance/share/**' + - 'wai/live-conformance/channels/**' - 'wai/REGISTRY.md' - 'wai/LICENSE' - 'wai/wai-quantum/**' @@ -52,6 +53,7 @@ on: - 'wai/live-conformance/fleet/**' # tests/cli_ops.rs builds the revenue share's --dry-run sample from this corpus. - 'wai/live-conformance/share/**' + - 'wai/live-conformance/channels/**' - 'wai/REGISTRY.md' - 'wai/LICENSE' - 'wai/wai-quantum/**' diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml index 61b0b8f7c..7cf3f2623 100644 --- a/.github/workflows/wai-conformance.yml +++ b/.github/workflows/wai-conformance.yml @@ -142,6 +142,10 @@ on: # reference adds, stitched_presentation_verify.py reads that table, and the registry's # wire identifiers test reads its sections. - 'wai/extensions/stitched-presentation.md' + # tests/channel_lineup.rs requires this extension's §10 to list the codes the reference + # adds and runs its §9's commands as the text writes them, channel_lineup_verify.py reads + # its §10, and the registry's wire identifiers test reads its sections. + - 'wai/extensions/channel-lineup.md' # tests/live_provenance.rs requires this extension's §12 to list the codes and the C2PA # codes the reference reports, c2pa_live_verify.py reads that table, and the registry's # wire identifiers test reads its sections. @@ -1164,7 +1168,7 @@ jobs: name: independent verifiers · claims, JWP, key discovery and integer payloads (Python) runs-on: linux-arm64 env: - WAI_FEATURES: key_discovery,http,video_manifest,stage_measure,live_events,live_room,capture_take,live_compose,live_record,timed_text,access_services,live_metering,energy_steering,live_moderation,dred_exact,live_ads,live_provenance,synthesis,synthesis_mark,neural_int,exact_upscale,robustness,ai_usage,contribution,settlement,browser_sink,receipt_claims,takedown,influence,direction,co_performance,age_assurance,bearer_receipts,exploration,co_watch,telemetry_aggregate,exact_tonemap,splat_exact,live_watermark,derive_edge,sink_energy,key_release,stitched_presentation,prior_wire,scene_map,avatar_face,receipt_ledger,stream_audit,fleet,native_sink,platform_encode,remote_desktop,stream_termination,live_world,format_probe,live_messaging,conform_result,revenue_share,opportunity_report + WAI_FEATURES: key_discovery,http,video_manifest,stage_measure,live_events,live_room,capture_take,live_compose,live_record,timed_text,access_services,live_metering,energy_steering,live_moderation,dred_exact,live_ads,live_provenance,synthesis,synthesis_mark,neural_int,exact_upscale,robustness,ai_usage,contribution,settlement,browser_sink,receipt_claims,takedown,influence,direction,co_performance,age_assurance,bearer_receipts,exploration,co_watch,telemetry_aggregate,exact_tonemap,splat_exact,live_watermark,derive_edge,sink_energy,key_release,stitched_presentation,prior_wire,scene_map,avatar_face,receipt_ledger,stream_audit,fleet,native_sink,platform_encode,remote_desktop,stream_termination,live_world,format_probe,live_messaging,conform_result,revenue_share,opportunity_report,channel_lineup WAI_PQ_FEATURES: synthesis,pq_signatures WAI_RELEASE_FEATURES: dred_exact,synthesis_mark,robustness,avatar_face steps: @@ -1234,11 +1238,13 @@ jobs: --bin wai_http_vectors --bin wai_stage_measure --bin wai_browser_sink_vectors \ --bin wai_claim_vectors --bin wai_jwp_vectors --bin wai_signer_vectors \ --bin wai_sink_energy_vectors --bin wai_stitched_presentation \ - --bin wai_stream_termination_vectors --bin wai_format_probe --bin wai_conform_result_vectors + --bin wai_stream_termination_vectors --bin wai_format_probe --bin wai_conform_result_vectors \ + --bin wai_channel_lineup_vectors for b in wai_http_vectors wai_stage_measure wai_browser_sink_vectors \ wai_claim_vectors wai_jwp_vectors wai_signer_vectors \ wai_sink_energy_vectors wai_stitched_presentation \ - wai_stream_termination_vectors wai_format_probe wai_conform_result_vectors; do + wai_stream_termination_vectors wai_format_probe wai_conform_result_vectors \ + wai_channel_lineup_vectors; do cp "${CARGO_TARGET_DIR:-target}/debug/$b" "$RUNNER_TEMP/wai-bins/union/" done # Copied before this build replaces target/debug's copies of the three it shares. @@ -1259,7 +1265,7 @@ jobs: --test stitched_presentation --test prior_wire --test scene_map --test receipt_ledger \ --test stream_audit --test fleet --test native_sink --test platform_encode --test remote_desktop \ --test stream_termination --test live_world --test format_probe --test live_messaging \ - --test conform_result --test revenue_share --test opportunity_report + --test conform_result --test revenue_share --test opportunity_report --test channel_lineup cargo test --release --no-run --no-default-features --features "$WAI_RELEASE_FEATURES" \ --test dred_exact --test dred_exact_measure --test synthesis_mark --test robustness \ --test avatar_face @@ -1720,6 +1726,25 @@ jobs: (cd wai-rs && "$RUNNER_TEMP/wai-bins/union/wai_stitched_presentation" vectors verify ../live-conformance/stitched) python3 -I live-conformance/stitched_presentation_verify.py live-conformance/stitched node --test ../joule-code/joule-code-rs/crates/joule-moq-web/web/ads/stitched.test.mjs + # wai.video.lineup (extensions/channel-lineup.md): the reference module's unit tests (times, + # the document type declaration, the reasons) and tests/channel_lineup.rs (every file of the + # committed corpus regenerates byte for byte from its fixed seeds, and no other file is + # there; wai-rs gives every import and every verification its outcome; §10 lists exactly + # the reference's codes; §9's commands, run as the text writes them through `wai`, yield + # the corpus's receipt, lineup, cues and guides, and a refusal is its reason with exit 1); + # then the corpus's second implementation, channel_lineup_verify.py, written from the + # extension's text and sharing no code with wai-rs, which reimports every vector, requires + # every outcome and fact, and requires every mutated copy of the corpus to fail and every + # check it drops to be caught. + - name: channel lineup · wai-rs and channel_lineup_verify.py + working-directory: wai + shell: bash + run: | + set -euo pipefail + (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib channel_lineup::) + (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test channel_lineup) + (cd wai-rs && "$RUNNER_TEMP/wai-bins/union/wai_channel_lineup_vectors" verify ../live-conformance/channels) + python3 -I live-conformance/channel_lineup_verify.py live-conformance/channels # wai.live.provenance (extensions/live-provenance.md): the reference module's unit tests # (boxes, chunks, emsg and the BMFF hash; the JUMBF store; COSE_Sign1 and the OKP key; # certificates written and read, their profile and chain; identifiers, track renderings, @@ -2719,7 +2744,7 @@ jobs: synthesis,pq_signatures sink_energy key_release stitched_presentation prior_wire \ prior_wire,neural_int scene_map avatar_face receipt_ledger stream_audit fleet native_sink \ platform_encode remote_desktop stream_termination live_world format_probe live_messaging \ - conform_result revenue_share opportunity_report; do + conform_result revenue_share opportunity_report channel_lineup; do echo "::group::$f" cargo check --no-default-features --features "$f" --tests echo "::endgroup::" diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 09483e960..b3939a7ba 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -63,6 +63,33 @@ wire format may change and such changes are recorded here. verifies each ledger document with `receipt_ledger_verify.py` and recomputes every attribution, line, division and citation itself. +### Channel lineup and guide: a linear channel's feeds, signed, and its guide checkable + +- **`wai.video.lineup`** (`extensions/channel-lineup.md`): a linear channel's signed lineup, + `WLU1`, of one `wai.video.channel` schedule, bound to it by the schedule root: each programme + asset's title, description, categories, rating, artwork by SHA-256, where it may be shown and its + rights holder, and where each programme of the guide begins. +- **The import**: an XMLTV schedule, Media RSS catalogues (one owner's items alone play back to + back from a start), or a JSON form of both, with the SCTE-35 date ranges of the channel's HLS + Media Playlist as its breaks, into the schedule's receipt, the lineup and a live-events `cue` + for each break (its `cue_id` a `splice_insert()`'s `splice_event_id`). Bounded reading, an XML + subset with one document type declaration that defines nothing, and reasons for a gap, an + overlap, a programme with no stop, a time zone named by letters, a break outside its programme, + a window outside the schedule, an unknown asset and the rest of §10. +- **The guide**: an XMLTV document and a now/next document (RFC 8785), rendered from the receipt + and the lineup and naming both; a verification recomputes it, refuses one that places a + programme the schedule does not or states what the lineup does not, and says of each programme + whether the receipt's program-as-run says it aired. +- **The receipt's JSON carriage** is revenue-share §4.1's, `ChannelReceipt::to_json` and + `from_json`, the one JSON form of a channel receipt: the import writes it and a verification + reads it; `wai.video.channel` receipts keep their signed bytes. +- **Operations** `channel.import`, `channel.guide` and `channel.verify` (`wai channel ...`), with + the agent rules; `quickstart`, the shipped `wai`'s build, holds the feature `channel_lineup`. +- **Conformance**: `live-conformance/channels/`, a sample channel of three owners with a control + of each reason, written from fixed seeds by `wai_channel_lineup_vectors`; its second + implementation, `channel_lineup_verify.py`, from the text alone, agrees on every vector, and a + CI step runs both. + ### wai-quantum for JavaScript: the engine as an npm package - **`@transaction-science/wai-quantum` 0.1.0** (`wai/wai-quantum-js/`): the job service's engine for WebAssembly diff --git a/wai/REGISTRY.md b/wai/REGISTRY.md index 1b3774104..43b72515a 100644 --- a/wai/REGISTRY.md +++ b/wai/REGISTRY.md @@ -313,6 +313,7 @@ and a role are fixed once registered. | `wai.video.jxs` | payload | registered | video | standalone | patent-pool | standard-defined | — | §5 Immersive audio, cinema, volumetric, haptics, HDR | | `wai.video.keys` | record | registered | — | — | — | — | — | §5 Neural capabilities | | `wai.video.lcevc` | companion | registered | video | enhancement | patent-pool | standard-unclassified | — | §5 Broadcast and streaming codecs | +| `wai.video.lineup` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content | | `wai.video.manifest` | record | registered | — | — | — | — | — | §5 Neural capabilities | | `wai.video.mvhevc` | payload | registered | video | standalone | patent-pool | standard-defined | — | §5 Broadcast and streaming codecs | | `wai.video.package` | record | registered | — | — | — | — | — | §5 Neural capabilities | @@ -406,6 +407,7 @@ and a role are fixed once registered. | `WKT1` | magic | defined | key-release §9.3 | | `WLI1` | magic | defined | receipt-ledger §6.1 | | `WLT1` | magic | defined | receipt-ledger §4.1 | +| `WLU1` | magic | defined | channel-lineup §6.1 | | `WMA1` | magic | defined | live-messaging §8 | | `WMC1` | magic | defined | live-messaging §3.1 | | `WME1` | magic | defined | live-messaging §7.3 | @@ -501,11 +503,13 @@ and a role are fixed once registered. | `urn:wai:asset:` | scheme | defined | scene-carriage §2.2 | | `urn:wai:event:1` | scheme | defined | live-events §8.4 | | `urn:wai:event:1` | catalog | defined | live-events §8.1 | +| `urn:wai:guide:` | scheme | defined | channel-lineup §7.2 | | `wai` | catalog | defined | moq-streaming-format §3 | | `wai-access-completeness` | document-kind | defined | access-services §7.1 | | `wai-age-admissions` | document-kind | defined | age-assurance §7.1 | | `wai-age-policy` | document-kind | defined | age-assurance §4.1 | | `wai-archive` | document-kind | defined | exploration §5.3 | +| `wai-asset-id` | scheme | defined | channel-lineup §7.2 | | `wai-attack:` | scheme | defined | robustness-evidence §4 | | `wai-attribution` | document-kind | defined | influence §5.3 | | `wai-authorship-map` | document-kind | defined | contribution §7.3 | @@ -514,6 +518,7 @@ and a role are fixed once registered. | `wai-caption-reference` | document-kind | defined | access-services §4.3 | | `wai-caption-sources` | document-kind | defined | access-services §4.2 | | `wai-catalogue` | document-kind | defined | influence §3.3 | +| `wai-channel-feed` | document-kind | defined | channel-lineup §4.4 | | `wai-collection` | document-kind | defined | exploration §7.1 | | `wai-conditioning` | document-kind | defined | influence §4.1 | | `wai-contribution` | document-kind | defined | contribution §6 | @@ -535,6 +540,7 @@ and a role are fixed once registered. | `wai-membership` | document-kind | defined | contribution §3.2 | | `wai-module-grant` | document-kind | defined | jwp-receipts §7.9 | | `wai-module-grants` | document-kind | defined | http-delivery §8.4 | +| `wai-now-next` | document-kind | defined | channel-lineup §7.3 | | `wai-origin` | document-kind | defined | synthesis §3.1 | | `wai-pick` | document-kind | defined | exploration §10.2 | | `wai-player` | document-kind | defined | synthesis §10.3 | diff --git a/wai/SPEC.md b/wai/SPEC.md index c8eb47ca1..5beb4edc5 100644 --- a/wai/SPEC.md +++ b/wai/SPEC.md @@ -1302,6 +1302,21 @@ spend that counts, by currency; an ad whose segments were fetched ahead of its b presented has no claim and counts nothing: [`extensions/stitched-presentation.md`](extensions/stitched-presentation.md). +And for a **linear channel's lineup and guide** — `wai.video.lineup`: the channel operator's +signed lineup, `WLU1`, of one `wai.video.channel` schedule, bound to it by the schedule root: each +programme asset's title, description, categories, rating, artwork by content hash, the countries +it may be shown in and its rights holder, and where each programme of the guide begins. A channel +as operators exchange it (an XMLTV schedule, Media RSS catalogues or a JSON form of both, with the +SCTE-35 date ranges of its HLS Media Playlist as its ad breaks) is imported into the schedule's +receipt, whose signed bytes are unchanged, the lineup, and a live-events `cue` for each break. The +import reads bounded documents over an XML subset that defines no entity, and refuses, each with +its reason, a gap, an overlap, a programme with no stop, a time zone named by letters, a break +outside its programme and a window outside the schedule. The guide, an XMLTV document or a +now/next document for a player's guide row, is rendered from the receipt and the lineup and names +both, so a verifier recomputes it and compares, and states of each of its programmes whether the +receipt's program-as-run says it aired: +[`extensions/channel-lineup.md`](extensions/channel-lineup.md). + And for **watching together** in a room — `wai.live.cowatch`: people in different places watching one source in sync, with the sync measured. The room authority signs a co-watch policy, `WXP1`, naming the source (a WAI package, a sequence of on-demand objects, or another diff --git a/wai/catalogue/ops.json b/wai/catalogue/ops.json index 2c17f0756..ab724ed11 100644 --- a/wai/catalogue/ops.json +++ b/wai/catalogue/ops.json @@ -3301,6 +3301,396 @@ "sdk": "wai.report.export" } }, + { + "id": "channel.import", + "summary": "A linear channel's feeds (an XMLTV schedule, Media RSS catalogues or the JSON form, and a Media Playlist's SCTE-35 date ranges) imported into its signed receipt, its signed lineup and its breaks' cues", + "cli": "wai channel import", + "aliases": [], + "usage": [ + "wai channel import --channel ID (--xmltv F [--mrss F]... | --mrss F... | --feed F) [--playlist F] --key K", + " [--mrss-owner O]... [--owner O] [--signer-id S] [--id-system N] [--xmltv-channel X] [--start-ms T] [--artwork DIR]", + " [--window-start T --window-end T] [--parent HEX] [--out-dir D] [--yes] the receipt (channel.json), the lineup", + " (lineup.wlu) and the cues (cues.json), written to D (.); a refusal's code is its reason; replacing a file waits", + " for --yes (--confirm, deprecated, is the same)" + ], + "placement": "local", + "effect": "writes-project", + "dry_run": true, + "confirm": "when-replacing-a-file", + "route": null, + "doors": { + "cli": true, + "mcp": false, + "http": false + }, + "toolset": "core", + "stability": "preview", + "input": { + "type": "object", + "properties": { + "channel": { + "type": "string", + "description": "The channel id.", + "x-cli": { + "flag": "--channel" + } + }, + "xmltv": { + "type": "string", + "format": "path", + "description": "An XMLTV document: the schedule.", + "x-cli": { + "flag": "--xmltv" + } + }, + "mrss": { + "type": "array", + "items": { + "type": "string" + }, + "description": "A Media RSS document: a catalogue. Repeated.", + "x-cli": { + "flag": "--mrss" + } + }, + "mrss_owner": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The rights holder of each --mrss document's items, in their order: none, or one each.", + "x-cli": { + "flag": "--mrss-owner" + } + }, + "feed": { + "type": "string", + "format": "path", + "description": "The JSON form of a schedule, its catalogue and its breaks.", + "x-cli": { + "flag": "--feed" + } + }, + "playlist": { + "type": "string", + "format": "path", + "description": "The channel's HLS Media Playlist, whose SCTE-35 date ranges are its breaks.", + "x-cli": { + "flag": "--playlist" + } + }, + "key": { + "type": "string", + "format": "path", + "description": "The operator's key file: 32 bytes, or 64 hexadecimal digits.", + "x-cli": { + "flag": "--key" + } + }, + "owner": { + "type": "string", + "description": "The rights holder of every asset no other input gives one.", + "x-cli": { + "flag": "--owner" + } + }, + "signer_id": { + "type": "string", + "description": "The receipt's signer id (the operator key's did:key).", + "x-cli": { + "flag": "--signer-id" + } + }, + "id_system": { + "type": "string", + "description": "The XMLTV episode-num system that names a programme's asset (wai-asset-id).", + "x-cli": { + "flag": "--id-system" + } + }, + "xmltv_channel": { + "type": "string", + "description": "The XMLTV channel id whose programmes are read (the channel id).", + "x-cli": { + "flag": "--xmltv-channel" + } + }, + "start_ms": { + "type": "integer", + "description": "Where a schedule of Media RSS items alone begins, milliseconds since 1970.", + "x-cli": { + "flag": "--start-ms" + } + }, + "artwork": { + "type": "string", + "format": "path", + "description": "The directory holding the artwork files the feeds' icons and thumbnails name.", + "x-cli": { + "flag": "--artwork" + } + }, + "window_start": { + "type": "integer", + "description": "The receipt's window's start (the schedule's first start).", + "x-cli": { + "flag": "--window-start" + } + }, + "window_end": { + "type": "integer", + "description": "The receipt's window's end (the schedule's last end).", + "x-cli": { + "flag": "--window-end" + } + }, + "parent": { + "type": "string", + "description": "The receipt hash of the window before this one, 64 hexadecimal digits.", + "x-cli": { + "flag": "--parent" + } + }, + "out_dir": { + "type": "string", + "format": "path", + "description": "Where channel.json, lineup.wlu and cues.json are written (.).", + "x-cli": { + "flag": "--out-dir" + } + } + }, + "required": [ + "channel", + "key" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "required": [ + "channel", + "receipt", + "receipt_hash", + "schedule_root", + "lineup", + "lineup_digest", + "cues" + ], + "description": "The files written (the receipt, the lineup, the cues), the receipt's identity and schedule root, the lineup's digest, and their counts" + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": true, + "needs": { + "all": [ + "channel_lineup" + ] + }, + "names": { + "cli": "wai channel import", + "mcp": null, + "c": "wai_channel_import", + "rust": "wai::channel::import", + "sdk": "wai.channel.import" + } + }, + { + "id": "channel.guide", + "summary": "A channel's guide, an XMLTV document or a now/next document, rendered from its receipt and its lineup and naming both", + "cli": "wai channel guide", + "aliases": [], + "usage": [ + "wai channel guide --receipt R --lineup L [--from T --to T | --now-next --at T] [--out F] [--yes] the XMLTV guide", + " of a range (the whole schedule), or the now/next document of an instant, written to F (guide.xml, now-next.json);", + " replacing a file waits for --yes (--confirm, deprecated, is the same)" + ], + "placement": "local", + "effect": "writes-project", + "dry_run": true, + "confirm": "when-replacing-a-file", + "route": null, + "doors": { + "cli": true, + "mcp": false, + "http": false + }, + "toolset": "core", + "stability": "preview", + "input": { + "type": "object", + "properties": { + "receipt": { + "type": "string", + "format": "path", + "description": "The channel receipt, in its JSON carriage.", + "x-cli": { + "flag": "--receipt" + } + }, + "lineup": { + "type": "string", + "format": "path", + "description": "The lineup's file.", + "x-cli": { + "flag": "--lineup" + } + }, + "from": { + "type": "integer", + "description": "The range's start, milliseconds since 1970 (the schedule's first start).", + "x-cli": { + "flag": "--from" + } + }, + "to": { + "type": "integer", + "description": "The range's end (the schedule's last end).", + "x-cli": { + "flag": "--to" + } + }, + "now_next": { + "type": "boolean", + "description": "The now/next document of --at instead.", + "x-cli": { + "flag": "--now-next" + } + }, + "at": { + "type": "integer", + "description": "The instant of a now/next document, milliseconds since 1970.", + "x-cli": { + "flag": "--at" + } + }, + "out": { + "type": "string", + "format": "path", + "description": "The guide's file (guide.xml, or now-next.json).", + "x-cli": { + "flag": "--out" + } + } + }, + "required": [ + "receipt", + "lineup" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "required": [ + "file", + "format", + "schedule_root", + "lineup" + ], + "description": "The guide's file and format (xmltv or now-next), the sources it names, and its range or instant" + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": { + "all": [ + "channel_lineup" + ] + }, + "names": { + "cli": "wai channel guide", + "mcp": null, + "c": "wai_channel_guide", + "rust": "wai::channel::guide", + "sdk": "wai.channel.guide" + } + }, + { + "id": "channel.verify", + "summary": "A channel's receipt, its lineup and a guide verified together: the guide recomputed from the two and compared, each programme with whether it aired", + "cli": "wai channel verify", + "aliases": [], + "usage": [ + "wai channel verify --receipt R --lineup L [--guide G] the receipt, the lineup and the guide checked together;", + " exit 1 with the reason when one does not verify" + ], + "placement": "local", + "effect": "reads", + "dry_run": false, + "confirm": "never", + "route": null, + "doors": { + "cli": true, + "mcp": false, + "http": false + }, + "toolset": "core", + "stability": "preview", + "input": { + "type": "object", + "properties": { + "receipt": { + "type": "string", + "format": "path", + "description": "The channel receipt, in its JSON carriage.", + "x-cli": { + "flag": "--receipt" + } + }, + "lineup": { + "type": "string", + "format": "path", + "description": "The lineup's file.", + "x-cli": { + "flag": "--lineup" + } + }, + "guide": { + "type": "string", + "format": "path", + "description": "An XMLTV guide or a now/next document.", + "x-cli": { + "flag": "--guide" + } + } + }, + "required": [ + "receipt", + "lineup" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "required": [ + "verdict", + "part", + "receipt", + "lineup", + "guide", + "entry" + ], + "description": "What extensions/channel-lineup.md §8.3 states: the verdict and the part it is about, the receipt's identity, the lineup's digest, and the guide's programmes, each with whether it aired" + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": { + "all": [ + "channel_lineup" + ] + }, + "names": { + "cli": "wai channel verify", + "mcp": null, + "c": "wai_channel_verify", + "rust": "wai::channel::verify", + "sdk": "wai.channel.verify" + } + }, { "id": "mcp.start", "summary": "WAI's operations as tools for a coding agent: a Model Context Protocol server on standard input and output, in toolsets", diff --git a/wai/extensions/channel-lineup.md b/wai/extensions/channel-lineup.md new file mode 100644 index 000000000..b3f99731a --- /dev/null +++ b/wai/extensions/channel-lineup.md @@ -0,0 +1,764 @@ +# WAI Extension: Channel Lineup and Guide + +> Status: Draft. Registers the record `wai.video.lineup`: a linear channel's signed **lineup**, +> `WLU1`, which describes each programme asset of one channel schedule (its title, description, +> categories, rating, artwork by content hash, where it may be shown, and its rights holder) and +> where each programme of the guide begins, and which binds that schedule by its root. It +> defines how a channel as operators exchange it (an XMLTV schedule, Media RSS catalogues, a +> JSON form of both, and the SCTE-35 date ranges of the channel's HLS Media Playlist) is +> **imported** into a `wai.video.channel` receipt and a lineup; and the channel's **guide**, an +> XMLTV document or a now/next document for a player's guide row, rendered from a receipt and its +> lineup, naming both, so that anyone holding them recomputes the guide and compares. The +> `wai.video.channel` receipt keeps its signed bytes and travels in revenue-share's JSON carriage +> of it. No new transport. The reference implementation is the `channel_lineup` module of `wai-rs` (feature +> `channel_lineup`); the corpus is [`live-conformance/channels/`](../live-conformance/channels/), +> and its second implementation is `live-conformance/channel_lineup_verify.py`. +> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174. +> +> Texts read for this revision, each at its source: +> +> - the XMLTV DTD, `xmltv.dtd`: its date and time format, and the elements `tv`, `channel`, +> `display-name`, `programme`, `title`, `desc`, `category`, `icon`, `episode-num`, `rating` +> and `value`, with their attributes and the order the DTD gives a programme's children; +> - *Media RSS Specification* 1.5.1, December 2009: §4.1 (`media:group`), §4.2 +> (`media:content`), §5 (where its optional elements appear), §5.2 (`media:rating`), §5.3 +> (`media:title`), §5.4 (`media:description`), §5.6 (`media:thumbnails`), §5.7 +> (`media:category`) and §5.13 (`media:restriction`); and the RSS 2.0 elements it extends +> (`rss`, `channel`, `item`, `title`, `description`, `category`, `guid`); +> - `draft-pantos-hls-rfc8216bis-22`: §4.2 (attribute lists, decimal-floating-point and +> hexadecimal-sequence values), §4.4.5.1 (`EXT-X-DATERANGE`) and §4.4.5.1.1 (SCTE-35 in a +> date range); +> - ANSI/SCTE 35-1 2023r2: §9.6 (Table 5, `splice_info_section()`) and §9.7.3 (Table 10, +> `splice_insert()`); +> - RFC 8259 (JSON), RFC 8785 (the JSON Canonicalization Scheme), and XML 1.0 for the subset of +> §3.3. +> +> A section number of one of these texts is that revision's. + +## 1. Scope + +In scope: the bounds and value forms an import reads (§3); the import of a channel's schedule, +catalogue and ad breaks into a schedule, its assets and its cues (§4); the JSON carriage a +`wai.video.channel` receipt travels in, which is revenue-share's (§5); the lineup, its rules, its signature and how it binds its +schedule (§6); the guide, in XMLTV and as a now/next document (§7); verification of a receipt, a +lineup and a guide together (§8); the operations that do each, with a worked example (§9). + +Out of scope: a transport; how feeds travel between operators; playout, which turns a schedule +into segments; the ad decision of a break, which [live-ads](live-ads.md) and `wai.video.ssai` +make; the revenue an asset's rights holder is owed; the HLS cue tags that the HLS text does not +define, and interstitial date ranges, which [stitched-presentation](stitched-presentation.md) +reads; SCTE 224 audience and blackout rules (a lineup states where an asset may be shown, and +says nothing about when); and the elements of the XMLTV DTD and of Media RSS that §4 does not +name, which an import does not read. + +Relationship to the core spec: `wai.video.lineup` is a **record** ([SPEC](../SPEC.md) §5): a +signed record a WAI tool emits and verifies. It names no payload format and is not dispatchable. +A guide is not a record: it is a rendering of two records that anyone recomputes, so it carries +no signature of its own. + +Relationship to `wai.video.channel` (SPEC §5): an import seals a channel receipt with +`ChannelReceipt::seal`, whose signed bytes are unchanged here, and carries it in the JSON form +[revenue-share](revenue-share.md) §4.1 defines for it; a lineup binds the receipt's +schedule by its `schedule_root`. A receipt's program-as-run (`aired`) is what a verification +sets beside a guide (§8.3). + +Relationship to [live-events](live-events.md): each ad break an import reads becomes a `cue` +payload of live-events §4.3, unchanged, which a room's principal signs as a `cue` event when the +channel is delivered as a room. Relationship to [cue-injection](cue-injection.md): its injection +record (§7 there) binds a cue to the SCTE 104 message it was translated from; a cue an import +yields comes from a playlist's SCTE-35 date range, and an import makes no injection record. + +## 2. Terms + +- **Channel**: a linear channel, named by a **channel id**. +- **Schedule**: the entries of a `wai.video.channel` receipt, gapless and non-overlapping, each + an asset id, a start, a duration and a kind: program, ad or filler. +- **Programme**: a span of the schedule the guide lists as one item: an episode or a film, with + the ad breaks inside it. A programme's **asset** is the asset id of its program entries. +- **Asset**: what an asset id names; the lineup describes each programme asset (§6.2). +- **Catalogue**: the assets a channel's content owners describe, in Media RSS or the JSON form. +- **Break**: a span of a programme that ads fill, from a date range or the JSON form (§4.5). +- **Operator**: the party that runs the channel and signs its receipt and its lineup. +- **Rights holder**: the party an asset's lineup entry names as holding its rights. +- **Feed**: a document an import reads: an XMLTV document, a Media RSS document, the JSON form, + or a Media Playlist. +- **Guide**: an XMLTV document (§7.2) or a now/next document (§7.3) rendered from a receipt and a + lineup. + +An instant is a count of milliseconds since 1970-01-01T00:00:00Z, leap seconds not counted. An +instant or a duration an import reads, a lineup states or a guide writes is at most 2^53 − 1, +the integers JSON carries exactly (RFC 8785 §3.2.2.3). A span from `a` to `b` holds the instants from `a`, included, to `b`, +excluded. Integers of the lineup are little-endian. A digest is SHA-256 (FIPS 180-4), 32 bytes. +Ed25519 is RFC 8032's, verified strictly ([SPEC](../SPEC.md) §7 *Signatures*): a key that fails +step 1 or 2 is `bad_key`, and a signature that fails a later step is `bad_signature`. Hexadecimal +written by this text is lowercase. + +## 3. Reading + +### 3.1 Bounds + +An import refuses `import_oversized` a feed or an artwork file of more than 16 777 216 bytes, and +a feed that holds more than 10 000 programmes of the channel (XMLTV, the JSON form), more than +10 000 items (Media RSS, all its documents together), more than 10 000 assets (the JSON form) or +more than 10 000 breaks (the JSON form and the playlist together). The size of each feed is +checked before it is read, and each count as soon as its document has been read as XML, JSON or +lines, before any of its elements is read further. An XML document is read to a depth of 64 +elements at most (§3.3). An import therefore reads a bounded amount for any input. + +### 3.2 Text values + +A **text** is a sequence of Unicode scalar values in UTF-8 with no character below U+0020 other +than U+0009 (tab) and U+000A (line feed). A **short text** is a text of 1 to 255 bytes; a +**long text** a text of 0 to 65 535 bytes. A value an import reads that must be one of these and +is not is refused `import_field_invalid`. Text is taken as the feed writes it; nothing is +normalised. + +### 3.3 The XML subset + +An XMLTV or Media RSS document is read over the XML subset of +[stitched-presentation](stitched-presentation.md) §3.5, with its local names and its text of an +element, and with one addition: before the root element, where that subset admits a comment, the +document **MAY** hold one document type declaration with no internal subset, which is skipped. +Such a declaration is ``. A literal is `"`, bytes other than `"`, and `"`; or `'`, +bytes other than `'`, and `'`. White space is one or more of space, tab, CR and LF. A +declaration of any other form (one with an internal subset among them), or a second one, is +outside the subset. A document outside the subset, or whose root element is not the one §4.2 or +§4.3 names, is refused `import_xml_malformed`. + +An element's **first** `x` child is its first child element of local name `x`. Elements are +named by local name only, so a Media RSS element and an RSS 2.0 element of one local name +(`media:title` and `title`, for one) are both `title` children; §4.3 says which one an import +takes. + +### 3.4 Times and durations + +An **XMLTV time** is 14 ASCII digits `YYYYMMDDhhmmss`, optionally followed by one space and a +numeric zone: `+` or `-` and four digits `HHMM` with `HH` at most 23 and `MM` at most 59. With no +zone the time is UTC, as the DTD says. The instant is the UTC instant of the date and time, less +the zone's offset. A value is refused: + +- `import_timezone_unknown` when its first 14 bytes are digits and what follows them is a space + and anything other than a numeric zone (a zone named by letters, for one); +- `import_time_malformed` otherwise when it is not of that form, when its year is below 1970, its + month not 1 to 12, its day not a day of that month in the Gregorian calendar, its hour above 23, + its minute above 59 or its second above 59, or when its instant is before 1970-01-01T00:00:00Z. + +An **HLS date-time** is `YYYY-MM-DDThh:mm:ss`, optionally `.` and one or more digits, then `Z` or +a zone `+hh:mm` or `-hh:mm` with `hh` at most 23 and `mm` at most 59, the fields as above. Its +fraction is read to the millisecond, and every digit after the third **MUST** be 0. A value of +this form without a zone is refused `import_timezone_unknown`; any other value that is not of +this form, or breaks a rule above, is refused `import_time_malformed`. + +A **duration** is a decimal-floating-point value (`draft-pantos-hls-rfc8216bis-22` §4.2): one or +more digits, optionally `.` and one or more digits, a number of seconds. It is read to the +millisecond, every fractional digit after the third **MUST** be 0, and the count of milliseconds +**MUST** be at most 2^53 − 1; otherwise it is refused `import_time_malformed`. + +## 4. Import + +### 4.1 Inputs + +An import is given: + +- **channel**: the channel id, a short text; +- **feeds**, one of these sets, each optionally with one Media Playlist: + - an XMLTV document, and zero or more Media RSS documents; + - one or more Media RSS documents, with no XMLTV document; + - one JSON form (§4.4), and no XMLTV or Media RSS document; +- for each Media RSS document, optionally its **owner**, the rights holder of its items: a short + text; +- **owner**: optionally, the rights holder of every asset that no other input gives one: a short + text; +- **id_system**: the XMLTV `episode-num` system that names a programme's asset (§4.2); when not + given, `wai-asset-id`, the system a guide writes (§7.2); +- **xmltv_channel**: the XMLTV channel id whose programmes are read; when not given, `channel`; +- **start**: an instant, which a schedule made of Media RSS items alone requires (§4.6); +- **artwork**: a set of files, each named by a file name (§4.6); +- **window**: optionally, the receipt's window, a span from `window_start` to `window_end` (§4.9); +- the operator's Ed25519 key and the receipt's `signer_id` (a short text), and optionally the + receipt hash of the window before this one, its `parent_receipt_hash`. + +An input set that is none of the sets above, a Media RSS owner given for some of the Media RSS +documents and not for the others, a value above that is not of its form, or a schedule of Media +RSS items alone with no start, is refused `import_inputs_invalid`. + +### 4.2 An XMLTV schedule + +The document's root element is `tv`. Its **channel name** is the text of the first +`display-name` child of the first `channel` child of `tv` whose `id` attribute is +`xmltv_channel`, when that text is not empty; it **MUST** then be a short text. The import's +**programmes** are the `programme` children of `tv` whose `channel` attribute is +`xmltv_channel`, in document order; when there are none, the import is refused `import_empty`. +Each one is read, in document order, by these steps, and refused at the first that fails: + +1. Its `start` attribute is an XMLTV time (§3.4); an absent `start` is `import_time_malformed`. +2. It has a `stop` attribute: otherwise `import_no_stop`. That attribute is an XMLTV time (§3.4), + and its instant is after the start's: otherwise `import_time_malformed`. +3. **Its asset.** It has an `episode-num` child whose `system` attribute is `id_system` (an + absent `system` is `onscreen`, the DTD's default), and the text of the first such child is a + short text: otherwise `import_asset_unknown`. That text is the programme's asset id. +4. **Its description**, each value as §3.2 says (`import_field_invalid`): its **title**, the text + of its first `title` child, a long text of 1 byte or more; its **description**, the text of + its first `desc` child, or empty when it has none, a long text; its **categories**, the text + of each `category` child, in order, each a short text, at most 16 of them; its **artwork**, + the `src` attribute of each `icon` child, in order, each a long text of 1 byte or more, at + most 8 of them (an `icon` with no `src` is refused); and its **rating**, when it has a + `rating` child: the first one's `system` attribute, or empty when it has none, which when + not empty is a short text; and the text of that child's first `value` child, a short text + (a `rating` with no `value`, or an empty one, is refused). + +Every instant an XMLTV document gives is a whole second. + +### 4.3 A Media RSS catalogue + +The document's root element is `rss`, with a `channel` child; the first `channel` child is +read. Its **channel name** is the text of that channel's first `title` child, when not empty; it +**MUST** then be a short text. Its **items** are that channel's `item` children, in document +order. Each one is read, in document order, by these steps, and refused at the first that fails: + +1. **Its asset id** is the text of its first `guid` child (RSS 2.0's `guid`), a short text: + otherwise `import_asset_unknown`. An asset id that an item read before it has, in this + document or in an earlier one, is refused `import_asset_duplicate`. +2. **Its content** is its first `content` child (`media:content`, §4.2 there), or, when it has + none, the first `content` child of its first `group` child (`media:group`, §4.1 there). Its + **content URI** is the content's `url` attribute, a long text, empty when absent or when it + has no content. Its **duration** is the content's `duration` attribute read as a duration + (§3.4; `import_time_malformed`), which **MUST** be at least 1 millisecond + (`import_time_malformed`); with no such attribute it is unknown. +3. **Its description**, each value as §3.2 says (`import_field_invalid`): its title, the text of + its first `title` child, a long text of 1 byte or more; its description, the text of its + first `description` child, or empty, a long text; its categories, the text of each `category` + child, each a short text, at most 16; its artwork, the `url` attribute of each `thumbnail` + child (`media:thumbnail`, §5.6 there), each a long text of 1 byte or more, at most 8 (a + `thumbnail` with no `url` is refused); its rating, when it has a `rating` child + (`media:rating`, §5.2 there): the first one's `scheme` attribute, or `urn:simple` when absent + (§5.2's default), which when not empty is a short text; and its text, a short text. +4. **Where it may be shown**, from its first `restriction` child whose `type` attribute is + `country` (`media:restriction`, §5.13 there): its `relationship` attribute is `allow` or + `deny`, and its text, split at white space (space, tab, CR, LF) into non-empty tokens, is + either the one token `all` or the one token `none`, or one or more tokens of two ASCII + letters each: otherwise `import_field_invalid`. Two letters name the ISO 3166-1 alpha-2 code + they spell in upper case, and the codes are taken in increasing order, each once. `allow` with `all`, `deny` with `none`, or no such child: shown + everywhere; `allow` with `none`, or `deny` with `all`: shown nowhere; `allow` with codes: + shown only in those countries; `deny` with codes: shown everywhere but those. + +The item's title is RSS 2.0's `title` or Media RSS's `media:title`, whichever the item writes +first, and so for `description` and `category`. Elements that a `media:group` or a +`media:content` holds, other than the content itself, are not read. + +### 4.4 The JSON form + +The JSON form carries a channel's schedule, its catalogue and its breaks in one document. It is +one JSON text (RFC 8259) in UTF-8, nested at most 64 deep, whose objects each name a member once. +Its value is an object with exactly these members: + +| member | value | +|---|---| +| `kind` | the string `wai-channel-feed` | +| `channel_name` | a string | +| `programmes` | an array of objects, each with exactly `start_ms` and `stop_ms` (integers) and `asset_id` (a string) | +| `assets` | an array of objects, each with exactly the members of the next table | +| `breaks` | an array of objects, each with exactly `id` (a string), `start_ms` and `duration_ms` (integers) and `scte35` (a string, or `null`) | + +| asset member | value | +|---|---| +| `asset_id`, `title`, `description`, `content_uri` | strings | +| `categories` | an array of strings | +| `rating` | `null`, or an object with exactly `system` and `value`, strings | +| `artwork` | an array of objects, each with exactly `uri` and `sha256`, strings | +| `geo` | `null` (shown everywhere), or an object with exactly one member, `allow` (shown only in) or `deny` (shown everywhere but), an array of strings | +| `rights_holder` | a string, or `null` | +| `duration_ms` | an integer: the asset's duration, 0 when unknown | + +An integer is written as decimal digits with no sign, fraction, exponent or leading zero, at most +2^53 − 1. A document that is not so is refused `import_json_malformed`. Then, in document order, +each value is read as §3.2 and this paragraph say (`import_field_invalid` unless said otherwise): +`channel_name` a short text; each programme's `asset_id` a short text, with `stop_ms` after +`start_ms` (otherwise `import_time_malformed`); each asset's `asset_id` a short text (one that an +asset before it has is `import_asset_duplicate`), its `title` a long text of 1 byte or more, its +`description` and `content_uri` long texts, its categories as in §4.2, its `rating`'s `system` a +short text or empty and its `value` a short text, its artwork at most 8, each `uri` a long text +of 1 byte or more and each `sha256` 64 lowercase hexadecimal digits, its `geo` codes each two +upper-case ASCII letters in strictly increasing order (a `deny` with none is refused), and its +`rights_holder` a short text when not `null`; each break as §4.5 says of a break. + +### 4.5 Breaks from a Media Playlist + +A Media Playlist is read as stitched-presentation §3.2 reads one: UTF-8, split at each line feed +with a CR before it dropped, its first line `#EXTM3U` (otherwise `import_playlist_malformed`). +Each line that begins `#EXT-X-DATERANGE:` is a date range tag, and the rest of the line its +attribute list, read by the rules of stitched-presentation §3.2; a tag whose list does not read, +or whose `ID` is not a quoted string, is refused `import_playlist_malformed`. + +Tags are grouped by `ID`, each group in the order its first tag appears. Two tags of one group +that carry one attribute **MUST** carry the same value, a quoted string or not as both write it +(`draft-pantos-hls-rfc8216bis-22` §4.4.5.1): otherwise `import_playlist_malformed`. A group is a +**break** when one of its tags carries `SCTE35-OUT`; the other groups are not read. A break's +attributes are those of its tags together, and it is read by these steps, each refused at the +first that fails: + +1. Its **id** is its `ID`, a short text (`import_field_invalid`). +2. Its `START-DATE` is a quoted string, an HLS date-time (§3.4): an absent one, or one that is + not quoted, is `import_playlist_malformed`. Its instant is the break's **start**. +3. Its `SCTE35-OUT` (and its `SCTE35-IN`, when it has one) is an unquoted hexadecimal-sequence + (§4.2 there: `0x` or `0X`, then hexadecimal digits, read here in either case) of an even + count of digits, whose bytes are a `splice_info_section()` as live-events §4.3 reads form 1: + `table_id` 0xFC, and `section_length` (the low 12 bits of its bytes 1 and 2) plus 3 its + length. Otherwise `import_scte35_malformed`. The bytes of `SCTE35-OUT` are the break's + **section**. +4. Its **duration** is its `DURATION`, unquoted, when it has one; otherwise its `END-DATE`, a + quoted HLS date-time, less its start, when it has one; otherwise its `PLANNED-DURATION`, + unquoted. A duration attribute that is quoted is `import_playlist_malformed`, and one that + does not read is refused as §3.4 says. A break with none of the three, or whose duration is 0 + or above 2^32 − 1 milliseconds, or an `END-DATE` before its start, is refused + `import_break_duration`. + +A break of the JSON form is its `id` (step 1); its `start_ms`; its `scte35`, when not `null`, +an even count of hexadecimal digits (either case, no `0x`) read as step 3 reads a section +(`import_scte35_malformed`); and its `duration_ms`, refused as step 4 refuses one when 0 or above +2^32 − 1. A break of the JSON form with a `null` `scte35` has no section. + +### 4.6 The programmes and their assets + +The programmes are, by the feeds: + +- **XMLTV**: those of §4.2; +- **the JSON form**: its `programmes`, each the span from `start_ms` to `stop_ms`, with its `asset_id`; none is + `import_empty`; +- **Media RSS alone**: the items of the documents in the order given, each document's in + document order, one programme an item, back to back from `start`: each programme begins where + the one before it ends and lasts its item's duration. An item whose duration is unknown is + refused `import_duration_unknown`, and an instant above 2^53 − 1 `import_time_malformed`. No + item is `import_empty`. + +The programmes are ordered by start (programmes of one start keep their order), and each next to +the one after it: a programme that ends before the next begins is refused `import_gap`, and one +that ends after the next begins `import_overlap`. Every programme's start, and the last one's +end, **MUST** be a whole second, a multiple of 1000 (`import_time_malformed`): a guide's XMLTV +times have no finer unit (§7.2). + +Each distinct asset id of the programmes, in the order of the first programme that names it, gets +its lineup entry (§6.2) by these steps, refused at the first that fails: + +1. **Its description.** With the JSON form, its `assets` entry, which **MUST** exist + (`import_asset_unknown`). With XMLTV, the item of that asset id when a Media RSS document has + one, otherwise the first programme that names it. With Media RSS alone, its item. The + description is title, description, categories, artwork, rating, where it may be shown, + content URI and duration; a programme gives no content URI, a duration of 0 and shown + everywhere. +2. **Its rights holder**: the JSON form's `rights_holder` when not `null`; otherwise the owner of + the Media RSS document its item came from, when given; otherwise the import's owner, when + given. None is refused `import_owner_unknown`. +3. **Its artwork.** Each artwork URI of a description that came from XMLTV or Media RSS is bound + by its content hash: its **name** is the part of the URI after its last `/`, once the first + `?` or `#` and everything after it are removed. The name **MUST** be 1 to 255 bytes of ASCII + letters, digits, `.`, `_` and `-`, other than `.` and `..`, and the artwork set **MUST** hold a + file of that name; otherwise `import_artwork_missing`. A file of more than 16 777 216 bytes is + `import_oversized`. The artwork's `sha256` is the SHA-256 of that file's bytes. The JSON form + gives its artwork's `sha256` itself. + +Programmes and assets are read once each: an asset's later programmes give it nothing, and +catalogue items no programme names are read (§4.3) but enter no lineup. + +### 4.7 The schedule + +The breaks are those of the JSON form, then those of the playlist, ordered by start (breaks of +one start keep their order). A break whose end (its start and its duration) is after the next +break's start is refused `import_overlap`; a break that does not lie within one programme, a +programme whose start is at most its start and whose end is at least its end, is refused +`import_break_outside`. + +The **schedule** is, for each programme in order: a program entry from the programme's start, or +from the end of its previous break, to the start of its next break or to its end, of the +programme's asset id, wherever that span is not empty; and an ad entry for each of its breaks, +from its start to its start plus its duration, whose asset id is the break's id. The schedule is therefore gapless +and non-overlapping, and every entry of a programme lies within it. + +### 4.8 The cues + +Each break, in the order of §4.7, yields one `cue` payload of live-events §4.3, unchanged: +`type` 4 (break: out and back, its duration known), `duration_ms` the break's duration, and +`form` 1 with the break's section verbatim, or `form` 0 for a break with no section. Its +`cue_id` is the section's `splice_event_id` when the section's `encrypted_packet` bit (the high +bit of byte 4) is 0, its `splice_command_type` (byte 13) is 0x05 (`splice_insert()`, SCTE 35-1 +§9.7.3) and it is 18 bytes or longer: the big-endian integer of bytes 14 to 17 (SCTE 35-1 §9.6, +Table 5, and §9.7.3, Table 10). Otherwise the `cue_id` is the big-endian integer of the first 4 +bytes of SHA-256 over `wai:channel-cue-id` (ASCII), 0x01 and the break's id. A cue says, as its +message does, that the break is out of the program and back. + +### 4.9 What an import yields + +- **The receipt**: `ChannelReceipt::seal` (SPEC §5, `wai.video.channel`) by the operator's key, + with the import's `signer_id`, `channel_id` the channel, the schedule of §4.7, the window + (when not given, from the schedule's first start to its last end), `joules_micro` 0 and no + class (the import meters nothing; energy-measurement §2), the grant reference with no ceilings + (`grant_hash` 32 zero bytes, `capability` `video.channel`, `joule_ceiling_micro` 2^64 − 1, no + `funds_ceiling`), and `parent_receipt_hash` as given. A window that is empty, or that does not + lie within the schedule (its start before the schedule's first start, or its end after its + last end), is refused `import_window_outside`. +- **The lineup** (§6): the channel id, the channel name (the first of these that gives one: the + XMLTV document, the first Media RSS document, the JSON form; when none does, the channel id), the receipt's + `schedule_root`, the schedule's first start and last end, each programme's start, and the + assets of §4.6 in the order of their asset ids' bytes, signed by the operator's key. +- **The cues** of §4.8, each with its break's id and start. + +### 4.10 The order of refusal + +An import is refused for the first step that fails, in this order: §4.1; then each feed, the +XMLTV document, the Media RSS documents in the order given, the JSON form and then the playlist, +each first by its size (§3.1), then as XML, JSON or lines, then by its counts (§3.1), then by +its own steps (§4.2 to §4.5), element by element in document order; then §4.6, §4.7 and §4.9. +An import that is refused yields nothing. + +## 5. The channel receipt's JSON carriage + +A `wai.video.channel` receipt keeps its signed bytes, and this text defines no form of its own for +it: an import writes a receipt, and a verifier reads one, in the JSON carriage of +[revenue-share](revenue-share.md) §4.1, the one JSON form of a channel receipt. A file that holds +a receipt holds one JSON text (RFC 8259) in UTF-8 of that carriage. A receipt **reads** when it +is such a text and reads as revenue-share §4.1 says; its entries' leaves, its schedule root, its +signing payload, its signature and its **identity** (its receipt hash) are revenue-share §4.2's; +its **rules** are rules 1 to 5 of revenue-share §4.3, and its signature that section's rule 6. A +receipt **verifies** when its signature verifies and its rules hold. + +## 6. The lineup + +### 6.1 Layout + +| size | field | meaning | +|---|---|---| +| 4 | `magic` | `WLU1` (ASCII) | +| 1 | `version` | 1 in this revision | +| short | `channel_id` | the channel: a short text | +| short | `channel_name` | its name, as a guide shows it: a short text | +| 32 | `schedule_root` | the root of the schedule it describes (§5) | +| 8 | `schedule_start_ms` | u64: the schedule's first start | +| 8 | `schedule_end_ms` | u64: the schedule's last end | +| 4 | `p` | u32: the programmes' count, 1 to 10 000 | +| 8 × `p` | `programmes` | each programme's start (u64), in order | +| 4 | `a` | u32: the assets' count, 1 to 10 000 | +| … | `assets` | `a` assets, §6.2, in the order of their asset ids' bytes | +| 32 | `signer` | the operator's Ed25519 key | +| 64 | `sig` | §6.3 | + +A **short** field is a u8 length and that many bytes; a **long** field a u16 length and that +many bytes. Programme *k* runs from its start to the start of programme *k* + 1, the last one to +`schedule_end_ms`. + +### 6.2 An asset + +| size | field | meaning | +|---|---|---| +| short | `asset_id` | the schedule's asset id: a short text | +| long | `title` | a long text of 1 byte or more | +| long | `description` | a long text | +| 1 | `c` | the categories' count, 0 to 16 | +| short × `c` | `categories` | each a short text | +| short | `rating_system` | the rating's system, or no bytes | +| short | `rating_value` | the rating, or no bytes for none | +| 1 | `k` | the artworks' count, 0 to 8 | +| … | `artwork` | `k` artworks, each 32 bytes (the SHA-256 of the image's bytes) and a long field (its URI, a long text of 1 byte or more) | +| 1 | `geo` | where it may be shown: 0 everywhere; 1 only in the countries listed; 2 everywhere but them | +| 1 | `g` | the countries' count | +| 2 × `g` | `countries` | each an ISO 3166-1 alpha-2 code, two of `A`–`Z`, in strictly increasing order | +| short | `rights_holder` | the party that holds its rights, as the operator names it: a short text | +| long | `content_uri` | the asset's media, or no bytes | +| 8 | `duration_ms` | u64: the asset's duration, 0 when unknown | + +A rights holder is a name, not a key: a contract between the operator and the rights holder +(a share contract of the revenue it earns, for one) names its parties itself, and a lineup states +what the operator published. + +### 6.3 Digest and signature + +The **digest** of a lineup is SHA-256 over its bytes from `magic` through `signer`. The +**signature** is Ed25519 by `signer` over `wai:channel-lineup` (ASCII) and 0x01, followed by the +bytes the digest covers. + +### 6.4 Reading and rules + +A reader reads exactly the bytes it is given as one lineup, in the order of §6.1, and refuses it +`lineup_malformed` when the bytes end before a field the layout requires or go on after `sig`, +or when: + +1. its magic is not `WLU1` or its version not 1; +2. `channel_id` or `channel_name` is not a short text; +3. `schedule_start_ms` is not below `schedule_end_ms`, either is not a multiple of 1000, or + `schedule_end_ms` is above 2^53 − 1; +4. `p` is 0 or above 10 000; or the programmes' starts are not strictly increasing, the first is + not `schedule_start_ms`, the last is not below `schedule_end_ms`, or one is not a multiple of + 1000; +5. `a` is 0 or above 10 000, or the asset ids are not in strictly increasing order of their + bytes; +6. an asset breaks §6.2: an `asset_id`, a category or `rights_holder` that is not a short text; + a `title` empty or not a text; a `description`, an artwork's URI or `content_uri` not a text; + an empty artwork URI; `c` above 16 or `k` above 8; a `rating_system` that is not empty or a + short text, or one that is not empty beside an empty `rating_value`; a `rating_value` that is + not empty or a short text; `geo` above 2, `geo` 0 with `g` above 0, `geo` 2 with `g` 0, or + countries not two of `A`–`Z` each in strictly increasing order; or `duration_ms` above + 2^53 − 1. + +### 6.5 A lineup and its schedule + +A lineup **describes** a receipt's schedule when: + +1. its `channel_id` is the receipt's; +2. its `schedule_root` is the receipt's, and its `schedule_start_ms` and `schedule_end_ms` are + the start of the receipt's first entry and the end of its last; +3. each programme's start is the start of an entry; +4. each programme holds at least one program entry, and every program entry it holds (every + entry that starts in its span) has one asset id, the programme's asset; +5. the asset ids of its assets are exactly the programmes' assets. + +A programme's ad and filler entries are its breaks; a guide lists the programme over them. + +## 7. The guide + +### 7.1 The programmes of a range + +A guide is rendered from a receipt and a lineup that describes its schedule. The programmes of a +range from `from` to `to`, with `from` below `to`, are the lineup's programmes whose span meets it (a +programme that begins before `to` and ends after `from`), in order, each with its start, its +end, its asset's id and its asset's entry (§6.2). + +### 7.2 The XMLTV guide + +The XMLTV guide of a range is these bytes, in UTF-8, each line ending with a line feed: + +``` + + + + NAME + + + TITLE + DESCRIPTION + CATEGORY + + ASSET + + VALUE + + + +``` + +- `ROOT` is the receipt's `schedule_root` and `LINEUP` the lineup's digest, each 64 lowercase + hexadecimal digits, and `FROM` and `TO` the range's ends in decimal, with no leading zero. + `urn:wai:guide:` names a guide's sources; a guide names them in its `source-data-url`, an + attribute the DTD gives `tv` for where the data came from. +- `CHANNEL` is the lineup's `channel_id` and `NAME` its `channel_name`. +- There is one `programme` element for each programme of the range, in order. `START` and `STOP` + are its start and its end as XMLTV times (§3.4) in UTC with the zone written: `YYYYMMDDhhmmss + +0000`. +- Of a programme's children, in the order the DTD gives them: one `title`; one `desc` only when + the description is not empty; one `category` for each category, in order; one `icon` for each + artwork, in order, its `src` the URI; one `episode-num`, whose text is the asset id; and one + `rating` only when `rating_value` is not empty, with a `system` attribute only when + `rating_system` is not empty. +- In text, `&`, `<` and `>` are written `&`, `<` and `>`; in an attribute value, + those and `"`, tab and line feed are written `&`, `<`, `>`, `"`, ` ` and + ` `. Every other character is written as itself. + +The guide's `episode-num` system is `wai-asset-id`, the import's default `id_system` (§4.1), so +a guide imports back into the programmes it lists. + +### 7.3 The now/next document + +The now/next document of an instant `at` is the JCS serialisation (RFC 8785) of this object, +followed by one line feed: + +| member | value | +|---|---| +| `kind` | `wai-now-next` | +| `channel`, `channel_name` | the lineup's `channel_id` and `channel_name` | +| `schedule_root` | the receipt's, 64 lowercase hexadecimal digits | +| `lineup` | the lineup's digest, 64 lowercase hexadecimal digits | +| `at_ms` | `at` | +| `now` | the programme whose span holds `at`, or `null` | +| `next` | when `now` is a programme, the one after it, or `null` for the last; when `at` is before the schedule's first start, the first programme; otherwise `null` | + +A programme is an object with the members `start_ms`, `stop_ms` and `asset_id`; `title`, +`description` and `content_uri`; `categories`, an array of strings; `rating`, `null` or +`{"system", "value"}`; `artwork`, an array of `{"sha256", "uri"}`; `geo`, `null` for everywhere, +`{"allow": [...]}` or `{"deny": [...]}`; `rights_holder`; and `duration_ms`. + +## 8. Verification + +### 8.1 Inputs + +A verifier is given a receipt in its JSON carriage (§5), a lineup's bytes, and optionally a +guide's bytes: an XMLTV guide when its first byte is `<`, a now/next document when it is `{`. + +### 8.2 Steps + +A verification gets one verdict, **verified** or the reason of the first step it fails: + +1. **The receipt** reads (§5): `channel_receipt_malformed`. +2. Its signature verifies under `signer_pubkey` (§5): `bad_key`, `bad_signature`. +3. Its rules hold (§5): `channel_receipt_invalid`. +4. **The lineup** reads (§6.4): `lineup_malformed`. +5. Its signature verifies under `signer` (§6.3): `bad_key`, `bad_signature`. +6. Its `signer` is the receipt's `signer_pubkey`: one operator signs both. Otherwise + `lineup_signer_mismatch`. +7. It describes the receipt's schedule (§6.5): `lineup_schedule_mismatch`. +8. **The guide**, when one is given, reads: `guide_malformed`. An XMLTV guide reads when it is in + the subset of §3.3 with the root `tv`; its `source-data-url` is `urn:wai:guide:`, two runs of + 64 lowercase hexadecimal digits and two decimal integers with no leading zero, the first below + the second, separated by `:`; and each of its `programme` children has `start` and `stop` + attributes that are XMLTV times (§3.4) and an `episode-num` child of system `wai-asset-id`, + the text of the first of which is its asset id. A now/next document reads when it is one JSON + object whose members are those of §7.3, with `at_ms` an integer of at most 2^53 − 1, + `schedule_root` and `lineup` strings, and `now` and `next` each `null` or an object whose `start_ms` and `stop_ms` are + integers and whose `asset_id` is a string. +9. It names the receipt's `schedule_root` and the lineup's digest: `guide_source_mismatch`. +10. Each of its programmes is the lineup's: for an XMLTV guide, each programme has the start, the + end and the asset of one of the programmes of the guide's range (§7.1); for a now/next + document, `now` and `next` have those of the programmes §7.3 names for its `at_ms`, and are + `null` where those are. Otherwise `guide_entry_unscheduled`, at the first programme, in the + guide's order, that is not (its index among the guide's `programme` elements; for a now/next + document, `now` 0 and `next` 1). +11. Its bytes are the guide of §7.2 for its range, or the document of §7.3 for its `at_ms`: + `guide_mismatch`. + +Step 10 catches a guide that places a programme where the schedule does not; step 11 every other +difference: a title, a rating or a picture the lineup does not state, a programme left out. + +### 8.3 What a verification states + +- its verdict, and the **part** it is about: `receipt` for steps 1 to 3, `lineup` for steps 4 to + 7, `guide` for steps 8 to 11, none when verified; +- the receipt's identity, when it reads; and the lineup's digest, when it reads; +- for a guide that reads: its format (`xmltv` or `now-next`), its range or its `at_ms`, and its + programmes, each with its start, its end, its asset id and whether it **aired**: whether an + entry the receipt's `aired` lists meets its span. A guide shows what the schedule placed; the + receipt states which of it was on air in its window; +- for `guide_entry_unscheduled`, the index of step 10. + +## 9. Operations and a worked example + +Three operations of the WAI command line carry this text (the operation catalogue, +`wai/catalogue/ops.json`), each with the toolchain's agent rules: `--json` for the envelope, +`--dry-run` on an operation that writes, and `--yes` before a file is replaced. + +- `channel.import` (`wai channel import`): feeds in; the receipt (`channel.json`, its JSON + carriage), the lineup (`lineup.wlu`) and the cues (`cues.json`) out, into a directory. A + refusal's code is the error's code, and its exit status 1. +- `channel.guide` (`wai channel guide`): a receipt and a lineup in; an XMLTV guide of a range + (the whole schedule unless `--from` and `--to` say otherwise), or with `--now-next --at` a + now/next document, out. +- `channel.verify` (`wai channel verify`): a receipt, a lineup and optionally a guide in; what + §8.3 states out, exit status 0 when verified and 1 otherwise. + +The corpus's sample channel (§11) is imported, guided and verified so: + +``` +wai channel import --channel sample-1 --xmltv sample.xmltv.xml \ + --mrss sample-a.mrss.xml --mrss-owner owner-a --mrss sample-b.mrss.xml --mrss-owner owner-b \ + --owner owner-c --playlist sample.m3u8 --artwork . --key operator.key --signer-id sample-operator \ + --window-start 1793296800000 --window-end 1793300400000 --out-dir out +wai channel guide --receipt out/channel.json --lineup out/lineup.wlu --out guide.xml +wai channel guide --receipt out/channel.json --lineup out/lineup.wlu --now-next --at 1793299545000 --out now-next.json +wai channel verify --receipt out/channel.json --lineup out/lineup.wlu --guide guide.xml +``` + +The test `tests/channel_lineup.rs` runs these commands on the corpus's files and requires the +corpus's receipt, lineup, cues and guides from them, byte for byte. + +## 10. Reasons + +The codes this extension adds, each with where it is raised: + +| reason | raised at | +|---|---| +| `import_inputs_invalid` | §4.1 | +| `import_oversized` | §3.1, §4.6 step 3 | +| `import_xml_malformed` | §3.3 | +| `import_json_malformed` | §4.4 | +| `import_playlist_malformed` | §4.5 | +| `import_time_malformed` | §3.4, §4.2, §4.3, §4.4, §4.6 | +| `import_timezone_unknown` | §3.4 | +| `import_no_stop` | §4.2 step 2 | +| `import_empty` | §4.2, §4.6 | +| `import_asset_unknown` | §4.2 step 3, §4.3 step 1, §4.6 step 1 | +| `import_asset_duplicate` | §4.3 step 1, §4.4 | +| `import_field_invalid` | §3.2, §4.2 to §4.5 | +| `import_scte35_malformed` | §4.5 step 3 | +| `import_break_duration` | §4.5 step 4 | +| `import_duration_unknown` | §4.6 | +| `import_gap` | §4.6 | +| `import_overlap` | §4.6, §4.7 | +| `import_owner_unknown` | §4.6 step 2 | +| `import_artwork_missing` | §4.6 step 3 | +| `import_break_outside` | §4.7 | +| `import_window_outside` | §4.9 | +| `channel_receipt_malformed` | §8.2 step 1 | +| `channel_receipt_invalid` | §8.2 step 3 | +| `lineup_malformed` | §6.4, §8.2 step 4 | +| `lineup_signer_mismatch` | §8.2 step 6 | +| `lineup_schedule_mismatch` | §8.2 step 7 | +| `guide_malformed` | §8.2 step 8 | +| `guide_source_mismatch` | §8.2 step 9 | +| `guide_entry_unscheduled` | §8.2 step 10 | +| `guide_mismatch` | §8.2 step 11 | + +A verification also gives live-events' `bad_key` and `bad_signature` (§8.2 steps 2 and 5). + +## 11. Conformance + +[`live-conformance/channels/`](../live-conformance/channels/) holds the corpus, written from fixed +seeds by `wai_channel_lineup_vectors gen`: `vectors.json`, which holds the operator's key and +each vector's inputs and outcome; the feeds of a sample channel (an XMLTV document with a second +channel's programmes beside it, two Media RSS documents of two content owners, a Media Playlist +with SCTE-35 date ranges, and the JSON form of the same channel); the artwork files; the receipt +and the guides the sample yields. Every document is a fixture written for its case, of +programmes, owners and pictures made for it. The vectors: + +- **imports**: the sample from XMLTV, Media RSS and the playlist; the same channel from the JSON + form, which yields the same receipt and lineup byte for byte; a channel made of one owner's + Media RSS items alone, back to back; and a control of each refusal of §4, each the sample with + one change (an edit the vector states, which a verifier applies to its copy); +- **verifications**: the sample's receipt and lineup alone, with its XMLTV guide, with a guide of + part of the schedule, and with now/next documents before, inside and after the schedule; and a + control of each step of §8.2 (a receipt that does not read, a flipped signature, a key of small + order, a program-as-run the window does not give, a lineup that does not read, one another key + signed, one for each rule of §6.5 that it alone breaks, a guide that does not read, one naming + another schedule, one that places a programme the schedule does not, and one whose title the + lineup does not state). + +A conforming implementation gives every import its outcome (the receipt, the lineup and the cues, +or the refusal) and every verification its verdict and facts, as the corpus states. +`wai_channel_lineup_vectors verify` and the test `tests/channel_lineup.rs` check that every file +regenerates byte for byte, that `wai-rs` gives every vector its outcome, that §10 lists exactly +the codes the reference adds, and that the corpus gives every reason of §10 and both of +live-events'. `live-conformance/channel_lineup_verify.py` is the second implementation, written +from this text: it shares no code with `wai-rs`; it verifies Ed25519 with WAI's strict rule +(`claim-conformance/_ed25519.py`), BLAKE3 with `claim-conformance/_blake3.py`, and reads a channel +receipt's carriage, its signed bytes, its identity and its rules as the revenue-share corpus's +second implementation reads them (§5); it reads XML, JSON and playlists itself; and it mutates copies of the corpus and requires +every copy to fail, and drops each of its own checks in turn and requires the corpus to catch the +weakened verifier. + +## 12. Security and privacy considerations + +- **What a lineup proves.** That the operator stated this description of each programme asset of + one schedule, and where its guide's programmes begin. It does not prove that a rights holder + agreed: a rights holder's name in a lineup is the operator's statement, and a party paid by it + checks a contract it signed, not the lineup. +- **What a guide proves.** Nothing by itself: it is recomputed from a receipt and a lineup and + compared, so a guide that verifies says exactly what the two signed records say, and a guide a + third party altered is refused. A guide is a schedule published ahead of air; which of it was on + air in a window is the receipt's program-as-run (§8.3), and a guide covering a span the receipt's + window does not is checked against the schedule alone. +- **Artwork.** A lineup binds each picture by its SHA-256, so a player that fetches the URI and + checks the hash shows the picture the operator stated, wherever it was fetched from. +- **Bounded reading.** An import reads bounded documents (§3.1), XML to a bounded depth with no + document type declaration that defines anything (§3.3), so no entity is ever expanded, and + integers it reads are checked against their bounds before any arithmetic. +- **Availability.** Where an asset may be shown is the operator's statement of the catalogue's + restriction; a player enforces it, or a delivery that a `wai.video.manifest` describes does. + A lineup holds no viewer's data. diff --git a/wai/live-conformance/channel_lineup_verify.py b/wai/live-conformance/channel_lineup_verify.py new file mode 100644 index 000000000..70c2f1511 --- /dev/null +++ b/wai/live-conformance/channel_lineup_verify.py @@ -0,0 +1,1390 @@ +#!/usr/bin/env python3 +"""Independent verifier of the channel lineup corpus (extensions/channel-lineup.md). + +Written from the extension's text. It shares no code with wai-rs, which generates the corpus, +and never runs it. Ed25519 signatures are checked with ../claim-conformance/_ed25519.py, WAI's +strict rule (SPEC §7 *Signatures*), and BLAKE3 with ../claim-conformance/_blake3.py. From the +corpora's other second implementations it takes, by reference as the extension does: a channel +receipt's JSON carriage, its leaves and schedule root, its signed bytes, its identity and its +rules (revenue-share §4, the extension's §5) from revenue_share_verify.py; and the XML subset, the +HLS attribute lists and the decimal reading of stitched-presentation §3.2 and §3.5 from +stitched_presentation_verify.py. Everything else is here, from the standard library: + +- text values, XMLTV and HLS times, durations, and the document type declaration (§3); +- the import of an XMLTV schedule, Media RSS catalogues, the JSON form and a Media Playlist's + breaks into a schedule, its assets and its cues (§4); +- the lineup, its rules, digest and signature, and whether it describes a schedule (§6); +- the XMLTV guide and the now/next document (§7), and verification (§8). + +For every import it requires the outcome the corpus states (the refusal, or a receipt, lineup and +cues it recomputes and whose signatures it verifies); for every verification, the facts. Of the +corpus, that every reason of §10 and both of live-events' appear, and that no other file is +there. Then it mutates copies of the corpus, one defect each, and requires every copy to fail; +and it drops each of its own checks in turn and requires the corpus to catch the weakened +verifier. + + python3 -I channel_lineup_verify.py + +Exit status 0 only if every vector verifies, every mutated copy fails and every weakened verifier +is caught. Python 3.8 or later; standard library only. +""" + +import copy +import datetime +import hashlib +import json +import os +import struct +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +sys.path.insert(0, os.path.join(HERE, "..", "claim-conformance")) +import _blake3 as B3 # noqa: E402 +import _ed25519 as ED # noqa: E402 +import revenue_share_verify as RSV # noqa: E402 +import stitched_presentation_verify as SPV # noqa: E402 + +EXTENSION = os.path.join(HERE, "..", "extensions", "channel-lineup.md") + +MAGIC, VERSION = b"WLU1", 1 # §6.1 +LINEUP_DOMAIN = b"wai:channel-lineup\x01" # §6.3 +CUE_ID_DOMAIN = b"wai:channel-cue-id\x01" # §4.8 +ID_SYSTEM = "wai-asset-id" # §7.2 +GUIDE_URN = "urn:wai:guide:" +NOW_NEXT_KIND, FEED_KIND = "wai-now-next", "wai-channel-feed" +MAX_DOC, MAX_COUNT, MAX_INSTANT = 16777216, 10000, 2 ** 53 - 1 # §2, §3.1 +U32, U64 = 2 ** 32 - 1, 2 ** 64 - 1 +EVERYWHERE, ONLY, EXCEPT = 0, 1, 2 # §6.2 geo +DIGITS = "0123456789" +WS = " \t\r\n" + +# The checks a weakened verifier may drop (main(), the last part). +SKIP = set() + + +class Refuse(Exception): + def __init__(self, reason): + super().__init__(reason) + self.reason = reason + + +def check(name, ok, reason): + """A rule: refuse `reason` when it fails, unless this run drops the check `name`.""" + if not ok and name not in SKIP: + raise Refuse(reason) + + +def sha(*parts): + h = hashlib.sha256() + for p in parts: + h.update(p) + return h.digest() + + +def reasons(): + """§10's table of the codes this extension adds, and the codes it names after it.""" + with open(EXTENSION, encoding="utf-8") as f: + text = f.read() + sec = text[text.index("\n## 10. Reasons"):] + sec = sec[:sec.index("\n## ", 1)] + table, after = sec.split("\nA verification also", 1) + added = [ln.split("`")[1] for ln in table.splitlines() if ln.startswith("| `")] + reused = [w.strip("`") for w in after.split() if w.startswith("`") and w.rstrip(",.").endswith("`")] + return added, [r.rstrip("`") for r in reused] + + +# ---------------------------------------------------------------- §3.2 text values + +def is_text(s): + return isinstance(s, str) and all(c >= " " or c in "\t\n" for c in s) and not any("\ud800" <= c <= "\udfff" for c in s) + + +def blen(s): + return len(s.encode("utf-8")) + + +def short_text(s): + return is_text(s) and 1 <= blen(s) <= 255 + + +def long_text(s): + return is_text(s) and blen(s) <= 65535 + + +def field(ok): + check("fields", ok, "import_field_invalid") + + +# ---------------------------------------------------------------- §3.4 times + +def instant(y, mo, d, h, mi, s, ms, offset_min): + if y < 1970 or not 1 <= mo <= 12 or h > 23 or mi > 59 or s > 59: + return None + try: + days = (datetime.date(y, mo, d) - datetime.date(1970, 1, 1)).days + except ValueError: + return None + t = (days * 86400 + h * 3600 + mi * 60 + s - offset_min * 60) * 1000 + ms + return t if t >= 0 else None + + +def digits(s): + return len(s) > 0 and all(c in DIGITS for c in s) + + +def xmltv_time(v): + """§3.4: an XMLTV time's instant, or Refuse.""" + if len(v) < 14 or not digits(v[:14]): + raise Refuse("import_time_malformed") + rest = v[14:] + if rest == "": + off = 0 + elif rest.startswith(" "): + z = rest[1:] + ok = len(z) == 5 and z[0] in "+-" and digits(z[1:]) and int(z[1:3]) <= 23 and int(z[3:5]) <= 59 + check("timezone", ok, "import_timezone_unknown") + if not ok: + off = 0 + else: + off = (int(z[1:3]) * 60 + int(z[3:5])) * (-1 if z[0] == "-" else 1) + else: + raise Refuse("import_time_malformed") + t = instant(int(v[0:4]), int(v[4:6]), int(v[6:8]), int(v[8:10]), int(v[10:12]), int(v[12:14]), 0, off) + if t is None: + raise Refuse("import_time_malformed") + return t + + +def xmltv_format(ms): + d = datetime.datetime(1970, 1, 1) + datetime.timedelta(milliseconds=ms) + return d.strftime("%Y%m%d%H%M%S") + " +0000" + + +def hls_date_time(v): + """§3.4: an HLS date-time's instant, or Refuse.""" + bad = Refuse("import_time_malformed") + shape = "dddd-dd-ddTdd:dd:dd" + if len(v) < 19 or any((c not in DIGITS) if p == "d" else (c != p) for p, c in zip(shape, v[:19])): + raise bad + p, ms = 19, 0 + if p < len(v) and v[p] == ".": + q = p + 1 + while q < len(v) and v[q] in DIGITS: + q += 1 + frac = v[p + 1:q] + if not frac or frac[3:].strip("0"): + raise bad + ms = int((frac[:3] + "000")[:3]) + p = q + z = v[p:] + if z == "": + raise Refuse("import_timezone_unknown") + if z == "Z": + off = 0 + elif len(z) == 6 and z[0] in "+-" and z[3] == ":" and digits(z[1:3]) and digits(z[4:6]): + hh, mm = int(z[1:3]), int(z[4:6]) + if hh > 23 or mm > 59: + raise bad + off = (hh * 60 + mm) * (-1 if z[0] == "-" else 1) + else: + raise bad + t = instant(int(v[0:4]), int(v[5:7]), int(v[8:10]), int(v[11:13]), int(v[14:16]), int(v[17:19]), ms, off) + if t is None or t > MAX_INSTANT: + raise bad + return t + + +def duration(v): + """§3.4: a duration in milliseconds, or Refuse.""" + d = SPV.decimal_scaled(v, 3) + if d is None or d > MAX_INSTANT: + raise Refuse("import_time_malformed") + return d + + +# ---------------------------------------------------------------- §3.3 the XML subset + +def doctype_end(b, at): + """The end of a document type declaration of §3.3 at `at`, or None.""" + def skip(p): + while p < len(b) and b[p] in b" \t\r\n": + p += 1 + return p + + def literal(p): + if p >= len(b) or b[p] not in b"\"'": + return None + q = b.find(b[p:p + 1], p + 1) + return None if q < 0 else q + 1 + + p = at + len(b"= len(b) or not (chr(b[p]).isascii() and (chr(b[p]).isalpha() or b[p] == ord("_"))): + return None + p += 1 + while p < len(b) and (chr(b[p]).isascii() and (chr(b[p]).isalnum() or chr(b[p]) in "_-.:")): + p += 1 + a = skip(p) + if a > p: + if b.startswith(b"SYSTEM", a): + q = skip(a + 6) + if q == a + 6: + return None + p = literal(q) + elif b.startswith(b"PUBLIC", a): + q = skip(a + 6) + if q == a + 6: + return None + r = literal(q) + if r is None: + return None + s = skip(r) + if s == r: + return None + p = literal(s) + else: + p = a + if p is None: + return None + p = skip(p) + return p + 1 if p < len(b) and b[p] == ord(">") else None + + +def read_feed_xml(b): + """§3.3: the root element, or None.""" + b = bytearray(b) + p = 0 + while True: + while p < len(b) and b[p] in b" \t\r\n": + p += 1 + if b.startswith(b"", p + 4) + if q < 0: + return None + p = q + 3 + elif b.startswith(b"", p + 2) + if q < 0: + return None + p = q + 2 + elif b.startswith(b" 0 + if inp["json"] is not None: + sets = inp["xmltv"] is None and not inp["mrss"] + else: + sets = inp["xmltv"] is not None or bool(inp["mrss"]) + owners = [m["owner"] for m in inp["mrss"]] + ok = (short_text(inp["channel"]) and sets + and (all(o is not None for o in owners) or all(o is None for o in owners)) + and all(o is None or short_text(o) for o in owners) + and all(inp[k] is None or short_text(inp[k]) for k in ("owner", "id_system", "xmltv_channel")) + and short_text(inp["signer_id"]) + and not (alone and inp["start_ms"] is None) + and (inp["start_ms"] is None or inp["start_ms"] <= MAX_INSTANT)) + check("inputs", ok, "import_inputs_invalid") + + +def sized(b): + check("size", len(b) <= MAX_DOC, "import_oversized") + + +def description(e, desc_name, art, scheme_attr, scheme_default, rating_text): + """§4.2 step 4, §4.3 step 3.""" + title = text_of(first(e, "title")) + field(title != "" and long_text(title)) + desc = text_of(first(e, desc_name)) + field(long_text(desc)) + cats = [SPV.text(c) for c in SPV.kids(e, "category")] + field(len(cats) <= 16 and all(short_text(c) for c in cats)) + uris = [i["attrs"].get(art[1]) for i in SPV.kids(e, art[0])] + field(len(uris) <= 8 and all(u is not None and u != "" and long_text(u) for u in uris)) + rating = ("", "") + r = first(e, "rating") + if r is not None: + system = r["attrs"].get(scheme_attr, scheme_default) + field(system == "" or short_text(system)) + value = rating_text(r) + field(short_text(value)) + rating = (system, value) + return {"title": title, "description": desc, "categories": cats, "rating": rating, "art": ("uris", uris), + "geo": (EVERYWHERE, []), "content_uri": "", "duration_ms": 0} + + +def read_xmltv(b, xch, id_system): + """§4.2: (channel name or None, [(programme, description)]).""" + sized(b) + root = read_feed_xml(b) + if root is None or root["name"] != "tv": + raise Refuse("import_xml_malformed") + progs = [p for p in SPV.kids(root, "programme") if p["attrs"].get("channel") == xch] + check("counts", len(progs) <= MAX_COUNT, "import_oversized") + name = None + for c in SPV.kids(root, "channel"): + if c["attrs"].get("id") == xch: + name = text_of(first(c, "display-name")) or None + break + if name is not None: + field(short_text(name)) + if not progs: + raise Refuse("import_empty") + out = [] + for p in progs: + if "start" not in p["attrs"]: + raise Refuse("import_time_malformed") + start = xmltv_time(p["attrs"]["start"]) + if "stop" not in p["attrs"]: + check("no_stop", False, "import_no_stop") + stop = start + 1000 + else: + stop = xmltv_time(p["attrs"]["stop"]) + if stop <= start: + raise Refuse("import_time_malformed") + ids = [SPV.text(e) for e in SPV.kids(p, "episode-num") if e["attrs"].get("system", "onscreen") == id_system] + check("asset", bool(ids) and short_text(ids[0]), "import_asset_unknown") + asset = ids[0] if ids else "?" + d = description(p, "desc", ("icon", "src"), "system", "", lambda r: text_of(first(r, "value"))) + out.append(({"start": start, "stop": stop, "asset": asset}, d)) + return name, out + + +def restriction(item): + """§4.3 step 4.""" + r = None + for x in SPV.kids(item, "restriction"): + if x["attrs"].get("type") == "country": + r = x + break + if r is None: + return (EVERYWHERE, []) + rel = r["attrs"].get("relationship") + field(rel in ("allow", "deny")) + allow = rel == "allow" + toks = [t for t in SPV.text(r).replace("\t", " ").replace("\r", " ").replace("\n", " ").split(" ") if t] + if toks in (["all"], ["none"]): + return (EVERYWHERE, []) if (toks == ["all"]) == allow else (ONLY, []) + ok = bool(toks) and all(len(t) == 2 and t.isascii() and t.isalpha() for t in toks) + field(ok) + codes = sorted(set(t.upper() for t in toks)) + return (ONLY if allow else EXCEPT, codes) + + +def read_mrss(b, owner, seen, count): + """§4.3: (channel name or None, [item]).""" + sized(b) + root = read_feed_xml(b) + ch = first(root, "channel") if root is not None and root["name"] == "rss" else None + if ch is None: + raise Refuse("import_xml_malformed") + items = SPV.kids(ch, "item") + count[0] += len(items) + check("counts", count[0] <= MAX_COUNT, "import_oversized") + name = text_of(first(ch, "title")) or None + if name is not None: + field(short_text(name)) + out = [] + for it in items: + g = first(it, "guid") + asset = SPV.text(g) if g is not None else "" + check("asset", short_text(asset), "import_asset_unknown") + check("duplicate", asset not in seen, "import_asset_duplicate") + seen.add(asset) + content = first(it, "content") + if content is None: + grp = first(it, "group") + content = first(grp, "content") if grp is not None else None + uri = content["attrs"].get("url", "") if content is not None else "" + field(long_text(uri)) + dur = None + if content is not None and "duration" in content["attrs"]: + dur = duration(content["attrs"]["duration"]) + if dur == 0: + raise Refuse("import_time_malformed") + d = description(it, "description", ("thumbnail", "url"), "scheme", "urn:simple", SPV.text) + d["geo"] = restriction(it) + d["content_uri"], d["duration_ms"] = uri, dur or 0 + out.append({"asset": asset, "desc": d, "duration": dur, "owner": owner}) + return name, out + + +def strict_json(b): + """§4.4's reading of JSON: a value, or None (a member named twice, a number that is not an + integer of the form, an unpaired surrogate, more than 64 levels).""" + class Bad(Exception): + pass + + def pairs(ps): + names = [n for n, _ in ps] + if len(set(names)) != len(names): + raise Bad() + return dict(ps) + + def bad_number(s): + raise Bad() + + def depth(v, d): + if d > 64: + raise Bad() + if isinstance(v, dict): + for k, x in v.items(): + if not is_text_or_any(k): + raise Bad() + depth(x, d + 1) + elif isinstance(v, list): + for x in v: + depth(x, d + 1) + elif isinstance(v, str) and not is_text_or_any(v): + raise Bad() + + def is_text_or_any(s): + return not any("\ud800" <= c <= "\udfff" for c in s) + + try: + s = b.decode("utf-8") + v = json.loads(s, object_pairs_hook=pairs, parse_float=bad_number, parse_constant=bad_number) + depth(v, 1) + return v + except (UnicodeDecodeError, ValueError, Bad, RecursionError): + return None + + +def jint(v): + return isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= MAX_INSTANT + + +def exactly(o, names): + return isinstance(o, dict) and set(o) == set(names) and len(o) == len(names) + + +def json_structure(v): + """§4.4's table: True when the form reads.""" + if not exactly(v, ["kind", "channel_name", "programmes", "assets", "breaks"]) or v["kind"] != FEED_KIND: + return False + if not isinstance(v["channel_name"], str) or not all(isinstance(v[k], list) for k in ("programmes", "assets", "breaks")): + return False + for p in v["programmes"]: + if not (exactly(p, ["start_ms", "stop_ms", "asset_id"]) and jint(p["start_ms"]) and jint(p["stop_ms"]) + and isinstance(p["asset_id"], str)): + return False + for a in v["assets"]: + if not exactly(a, ["asset_id", "title", "description", "content_uri", "categories", "rating", "artwork", "geo", + "rights_holder", "duration_ms"]): + return False + if not all(isinstance(a[k], str) for k in ("asset_id", "title", "description", "content_uri")): + return False + if not (isinstance(a["categories"], list) and all(isinstance(c, str) for c in a["categories"])): + return False + r = a["rating"] + if r is not None and not (exactly(r, ["system", "value"]) and isinstance(r["system"], str) and isinstance(r["value"], str)): + return False + if not isinstance(a["artwork"], list) or not all(exactly(w, ["uri", "sha256"]) and isinstance(w["uri"], str) + and isinstance(w["sha256"], str) for w in a["artwork"]): + return False + g = a["geo"] + if g is not None and not (isinstance(g, dict) and len(g) == 1 and list(g)[0] in ("allow", "deny") + and isinstance(list(g.values())[0], list) + and all(isinstance(c, str) for c in list(g.values())[0])): + return False + if not (a["rights_holder"] is None or isinstance(a["rights_holder"], str)) or not jint(a["duration_ms"]): + return False + for k in v["breaks"]: + if not (exactly(k, ["id", "start_ms", "duration_ms", "scte35"]) and isinstance(k["id"], str) and jint(k["start_ms"]) + and jint(k["duration_ms"]) and (k["scte35"] is None or isinstance(k["scte35"], str))): + return False + return True + + +def hex_any(s): + if len(s) % 2 or any(c not in "0123456789abcdefABCDEF" for c in s): + return None + return bytes.fromhex(s) + + +def section_reads(b): + """live-events §4.3's reading of form 1 (§4.5 step 3).""" + return len(b) >= 3 and b[0] == 0xFC and (((b[1] << 8) | b[2]) & 0x0FFF) + 3 == len(b) + + +def break_duration(d): + check("break_duration", 0 < d <= U32, "import_break_duration") + return d + + +def scte35(b): + check("scte35", b is not None and section_reads(b), "import_scte35_malformed") + return b + + +def read_json(b, nbreaks): + """§4.4: (channel name, programmes, {asset id: (description, rights holder)}, breaks).""" + sized(b) + v = strict_json(b) + if v is None or not json_structure(v): + raise Refuse("import_json_malformed") + nbreaks[0] += len(v["breaks"]) + check("counts", len(v["programmes"]) <= MAX_COUNT and len(v["assets"]) <= MAX_COUNT and nbreaks[0] <= MAX_COUNT, + "import_oversized") + field(short_text(v["channel_name"])) + progs = [] + for p in v["programmes"]: + field(short_text(p["asset_id"])) + if p["stop_ms"] <= p["start_ms"]: + raise Refuse("import_time_malformed") + progs.append({"start": p["start_ms"], "stop": p["stop_ms"], "asset": p["asset_id"]}) + assets = {} + for a in v["assets"]: + field(short_text(a["asset_id"])) + check("duplicate", a["asset_id"] not in assets, "import_asset_duplicate") + field(a["title"] != "" and long_text(a["title"])) + field(long_text(a["description"]) and long_text(a["content_uri"])) + field(len(a["categories"]) <= 16 and all(short_text(c) for c in a["categories"])) + rating = ("", "") + if a["rating"] is not None: + s, val = a["rating"]["system"], a["rating"]["value"] + field((s == "" or short_text(s)) and short_text(val)) + rating = (s, val) + field(len(a["artwork"]) <= 8 and all(w["uri"] != "" and long_text(w["uri"]) and len(w["sha256"]) == 64 + and all(c in "0123456789abcdef" for c in w["sha256"]) for w in a["artwork"])) + geo = (EVERYWHERE, []) + if a["geo"] is not None: + (rel, codes), = a["geo"].items() + ok = (all(len(c) == 2 and c.isascii() and c.isalpha() and c.isupper() for c in codes) + and all(codes[i] < codes[i + 1] for i in range(len(codes) - 1)) and (rel == "allow" or codes)) + field(ok) + geo = (ONLY if rel == "allow" else EXCEPT, list(codes)) + if a["rights_holder"] is not None: + field(short_text(a["rights_holder"])) + d = {"title": a["title"], "description": a["description"], "categories": a["categories"], "rating": rating, + "art": ("hashed", [(bytes.fromhex(w["sha256"]), w["uri"]) for w in a["artwork"]]), "geo": geo, + "content_uri": a["content_uri"], "duration_ms": a["duration_ms"]} + assets[a["asset_id"]] = (d, a["rights_holder"]) + breaks = [] + for k in v["breaks"]: + field(short_text(k["id"])) + sec = scte35(hex_any(k["scte35"])) if k["scte35"] is not None else None + breaks.append({"id": k["id"], "start": k["start_ms"], "duration": break_duration(k["duration_ms"]), "section": sec}) + return v["channel_name"], progs, assets, breaks + + +def hex_sequence(v): + """§4.5 step 3: an unquoted hexadecimal-sequence's bytes, or None.""" + value, quoted = v + if quoted or value[:2] not in ("0x", "0X"): + return None + return hex_any(value[2:]) + + +def read_playlist(b, nbreaks): + """§4.5: the breaks.""" + sized(b) + bad = Refuse("import_playlist_malformed") + try: + s = b.decode("utf-8") + except UnicodeDecodeError: + raise bad + lines = [ln[:-1] if ln.endswith("\r") else ln for ln in s.split("\n")] + if lines[0] != "#EXTM3U": + raise bad + groups = [] # [(id, {name: (value, quoted)})] + for ln in lines: + if not ln.startswith("#EXT-X-DATERANGE:"): + continue + a = SPV.attribute_list(ln[len("#EXT-X-DATERANGE:"):]) + if a is None: + raise bad + d = {n: (v, q) for n, v, q in a} + if "ID" not in d or not d["ID"][1]: + raise bad + gid = d["ID"][0] + g = next((x for x in groups if x[0] == gid), None) + if g is None: + g = (gid, {}) + groups.append(g) + for n, v in d.items(): + if n in g[1] and g[1][n] != v and "conflict" not in SKIP: + raise bad + g[1].setdefault(n, v) + brks = [g for g in groups if "SCTE35-OUT" in g[1]] + nbreaks[0] += len(brks) + check("counts", nbreaks[0] <= MAX_COUNT, "import_oversized") + out = [] + for gid, a in brks: + field(short_text(gid)) + sd = a.get("START-DATE") + if sd is None or not sd[1]: + raise bad + start = hls_date_time(sd[0]) + sec = scte35(hex_sequence(a["SCTE35-OUT"])) + if "SCTE35-IN" in a: + scte35(hex_sequence(a["SCTE35-IN"])) + + def plain(v): + if v[1]: + raise bad + return duration(v[0]) + if "DURATION" in a: + d = plain(a["DURATION"]) + elif "END-DATE" in a: + if not a["END-DATE"][1]: + raise bad + end = hls_date_time(a["END-DATE"][0]) + check("break_duration", end >= start, "import_break_duration") + d = end - start + elif "PLANNED-DURATION" in a: + d = plain(a["PLANNED-DURATION"]) + else: + raise Refuse("import_break_duration") + out.append({"id": gid, "start": start, "duration": break_duration(d), "section": sec}) + return out + + +def artwork_name(uri): + u = uri + for c in "?#": + u = u.split(c, 1)[0] + name = u.rsplit("/", 1)[-1] + ok = 1 <= len(name) <= 255 and name not in (".", "..") and all(c.isascii() and (c.isalnum() or c in "._-") for c in name) + return name if ok else None + + +def cue_id(bid, sec): + if sec is not None and len(sec) >= 18 and sec[4] & 0x80 == 0 and sec[13] == 0x05: + return int.from_bytes(sec[14:18], "big") + return int.from_bytes(sha(CUE_ID_DOMAIN, bid.encode())[:4], "big") + + +def cue_payload(k): + """§4.8: live-events §4.3's cue, little-endian.""" + out = bytes([4]) + struct.pack(" MAX_INSTANT: + raise Refuse("import_time_malformed") + progs.append({"start": at, "stop": stop, "asset": it["asset"]}) + at = stop + progs = sorted(progs, key=lambda p: p["start"]) + for p, q in zip(progs, progs[1:]): + check("gap", p["stop"] >= q["start"], "import_gap") + check("overlap", p["stop"] <= q["start"], "import_overlap") + check("whole_seconds", all(p["start"] % 1000 == 0 for p in progs) and progs[-1]["stop"] % 1000 == 0, + "import_time_malformed") + + # §4.6 their assets + item_of = {it["asset"]: it for it in items} + order = [] + for p in progs: + if p["asset"] not in order: + order.append(p["asset"]) + assets = [] + for aid in order: + if jf is not None: + check("asset", aid in jf[1], "import_asset_unknown") + d, holder = jf[1].get(aid, (None, None)) + if d is None: + continue + elif aid in item_of: + d, holder = item_of[aid]["desc"], item_of[aid]["owner"] + else: + d, holder = next(x for p, x in xmltv if p["asset"] == aid), None + holder = holder if holder is not None else inp["owner"] + check("owner", holder is not None, "import_owner_unknown") + kind, art = d["art"] + if kind == "uris": + hashed = [] + for u in art: + n = artwork_name(u) + ok = n is not None and n in inp["artwork_files"] + check("artwork", ok, "import_artwork_missing") + body = inp["artwork_files"].get(n, b"") if n else b"" + sized(body) + hashed.append((hashlib.sha256(body).digest(), u)) + art = hashed + assets.append({"asset_id": aid, "title": d["title"], "description": d["description"], "categories": d["categories"], + "rating_system": d["rating"][0], "rating_value": d["rating"][1], "artwork": art, "geo": d["geo"][0], + "countries": d["geo"][1], "rights_holder": holder or "", "content_uri": d["content_uri"], + "duration_ms": d["duration_ms"]}) + + # §4.7 the schedule + breaks = sorted(breaks, key=lambda k: k["start"]) + for k, n in zip(breaks, breaks[1:]): + check("break_overlap", k["start"] + k["duration"] <= n["start"], "import_overlap") + within = [[] for _ in progs] + for k in breaks: + at = next((i for i, p in enumerate(progs) if p["start"] <= k["start"] < p["stop"]), None) + ok = at is not None and k["start"] + k["duration"] <= progs[at]["stop"] + check("break_outside", ok, "import_break_outside") + if at is not None: + within[at].append(k) + entries = [] + for p, ks in zip(progs, within): + at = p["start"] + for k in ks: + if k["start"] > at: + entries.append({"asset_id": p["asset"], "start_ms": at, "duration_ms": k["start"] - at, "kind": "program"}) + entries.append({"asset_id": k["id"], "start_ms": k["start"], "duration_ms": k["duration"], "kind": "ad"}) + at = k["start"] + k["duration"] + if p["stop"] > at: + entries.append({"asset_id": p["asset"], "start_ms": at, "duration_ms": p["stop"] - at, "kind": "program"}) + + # §4.9 + first_start, last_end = progs[0]["start"], progs[-1]["stop"] + ws, we = inp["window"] if inp["window"] is not None else (first_start, last_end) + check("window", ws < we and ws >= first_start and we <= last_end, "import_window_outside") + name = next((n for n in names if n is not None), inp["channel"]) + lineup = {"channel_id": inp["channel"], "channel_name": name, "schedule_root": schedule_root(entries), + "schedule_start_ms": first_start, "schedule_end_ms": last_end, "programmes": [p["start"] for p in progs], + "assets": sorted(assets, key=lambda a: a["asset_id"].encode())} + cues = [{"break": k["id"], "start_ms": k["start"], "payload": cue_payload(k).hex()} for k in breaks] + return {"entries": entries, "window": (ws, we), "lineup": lineup, "cues": cues} + + +# ---------------------------------------------------------------- §5 the receipt + +def schedule_root(entries): + """revenue-share §4.2: the Merkle root of the entries' leaves.""" + return RSV.merkle([RSV.leaf(e) for e in entries]) + + +def aired_of(entries, ws, we): + return [i for i, e in enumerate(entries) if e["start_ms"] < we and e["start_ms"] + e["duration_ms"] > ws] + + +def u64(v): + return isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= U64 + + +def hexn(v, n): + if not isinstance(v, str) or len(v) != 2 * n or any(c not in "0123456789abcdef" for c in v): + return None + return bytes.fromhex(v) + + +def read_receipt(b): + """§5: one JSON text of revenue-share §4.1's carriage, read as revenue-share's second + implementation reads it; the run, or None.""" + try: + r = RSV.read_run(json.loads(b.decode("utf-8"))) + RSV.run_payload(r) + return r + except (UnicodeError, ValueError, RSV.NoRead, KeyError, TypeError): + return None + + +def receipt_payload(r): + return RSV.run_payload(r) + + +def receipt_identity(r): + return RSV.run_identity(r) + + +def receipt_rules(r): + """§5: revenue-share §4.3's rules 1 to 5 (rule 6, the signature, is §8.2 step 2's).""" + return "receipt_rules" in SKIP or RSV.run_verifies(r) + + +# ---------------------------------------------------------------- §6 the lineup + +def lshort(s): + b = s.encode() + return bytes([len(b)]) + b + + +def llong(s): + b = s.encode() + return struct.pack(" len(self.b): + raise ValueError("short") + out = self.b[self.p:self.p + n] + self.p += n + return out + + def u(self, fmt): + return struct.unpack(fmt, self.take(struct.calcsize(fmt)))[0] + + def short(self): + return self.take(self.u("= e or any(ps[i] >= ps[i + 1] for i in range(p - 1)) or any(x % 1000 for x in ps): + return None + a = c.u(" 16: + return None + x["categories"] = [c.short() for _ in range(n)] + x["rating_system"], x["rating_value"] = c.short(), c.short() + k = c.u(" 8: + return None + x["artwork"] = [(c.take(32), c.long()) for _ in range(k)] + x["geo"] = c.u("= x["asset_id"].encode()): + return None + l["assets"].append(x) + l["signer"], l["sig"] = c.take(32), c.take(64) + return l if c.p == len(b) else None + except (ValueError, UnicodeDecodeError, struct.error): + return None + + +def lineup_digest(l): + return sha(lineup_body(l, l["signer"])) + + +def spans(l): + ps = l["programmes"] + return [(s, ps[k + 1] if k + 1 < len(ps) else l["schedule_end_ms"]) for k, s in enumerate(ps)] + + +def programme_assets(l, sched): + """§6.5 rule 4: each programme's asset, or None.""" + out = [] + for s, e in spans(l): + ids = [x["asset_id"] for x in sched if x["kind"] == "program" and s <= x["start_ms"] < e] + if not ids or any(i != ids[0] for i in ids): + return None + out.append(ids[0]) + return out + + +def describes(l, r): + """§6.5, each rule a check of its own.""" + s = r["schedule"] + if not s: + return False + end = s[-1]["start_ms"] + s[-1]["duration_ms"] + if "describes_root" not in SKIP and (l["channel_id"] != r["channel_id"] or l["schedule_root"] != r["schedule_root"] + or l["schedule_start_ms"] != s[0]["start_ms"] or l["schedule_end_ms"] != end): + return False + starts = {x["start_ms"] for x in s} + if "describes_starts" not in SKIP and any(p not in starts for p in l["programmes"]): + return False + if "describes_assets" not in SKIP and programme_assets(l, s) is None: + return False + # Rule 5 over the programmes' assets: every program entry's asset in a programme's span. + held = {x["asset_id"] for a, b in spans(l) for x in s if x["kind"] == "program" and a <= x["start_ms"] < b} + return "describes_set" in SKIP or sorted(held, key=str.encode) == [a["asset_id"] for a in l["assets"]] + + +# ---------------------------------------------------------------- §7 the guide + +def all_programmes(r, l): + ids = programme_assets(l, r["schedule"]) + by = {a["asset_id"]: a for a in l["assets"]} + return [(s, e, by[i]) for (s, e), i in zip(spans(l), ids)] + + +def esc_text(s): + return s.replace("&", "&").replace("<", "<").replace(">", ">") + + +def esc_attr(s): + return esc_text(s).replace('"', """).replace("\t", " ").replace("\n", " ") + + +def xmltv_guide(r, l, frm, to): + """§7.2.""" + ch = esc_attr(l["channel_id"]) + o = ['\n', + '\n' % (GUIDE_URN, r["schedule_root"].hex(), lineup_digest(l).hex(), frm, to), + ' \n %s\n \n' % (ch, esc_text(l["channel_name"]))] + for s, e, a in all_programmes(r, l): + if not (s < to and e > frm): + continue + o.append(' \n' % (xmltv_format(s), xmltv_format(e), ch)) + o.append(" %s\n" % esc_text(a["title"])) + if a["description"]: + o.append(" %s\n" % esc_text(a["description"])) + o += [" %s\n" % esc_text(c) for c in a["categories"]] + o += [' \n' % esc_attr(u) for _, u in a["artwork"]] + o.append(' %s\n' % (ID_SYSTEM, esc_text(a["asset_id"]))) + if a["rating_value"]: + o.append(' \n' % esc_attr(a["rating_system"]) if a["rating_system"] else " \n") + o.append(" %s\n \n" % esc_text(a["rating_value"])) + o.append(" \n") + o.append("\n") + return "".join(o).encode("utf-8") + + +def now_and_next(progs, at): + for k, p in enumerate(progs): + if p[0] <= at < p[1]: + return p, progs[k + 1] if k + 1 < len(progs) else None + if progs and at < progs[0][0]: + return None, progs[0] + return None, None + + +def programme_doc(p): + s, e, a = p + geo = None + if a["geo"] == ONLY: + geo = {"allow": a["countries"]} + elif a["geo"] == EXCEPT: + geo = {"deny": a["countries"]} + return {"start_ms": s, "stop_ms": e, "asset_id": a["asset_id"], "title": a["title"], "description": a["description"], + "content_uri": a["content_uri"], "categories": a["categories"], + "rating": {"system": a["rating_system"], "value": a["rating_value"]} if a["rating_value"] else None, + "artwork": [{"sha256": h.hex(), "uri": u} for h, u in a["artwork"]], "geo": geo, + "rights_holder": a["rights_holder"], "duration_ms": a["duration_ms"]} + + +def jcs(v): + """RFC 8785 over this document's values: sorted members (ASCII names), no space, strings as + JSON escapes them.""" + return json.dumps(v, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + + +def now_next_doc(r, l, at): + """§7.3.""" + now, nxt = now_and_next(all_programmes(r, l), at) + v = {"kind": NOW_NEXT_KIND, "channel": l["channel_id"], "channel_name": l["channel_name"], + "schedule_root": r["schedule_root"].hex(), "lineup": lineup_digest(l).hex(), "at_ms": at, + "now": programme_doc(now) if now else None, "next": programme_doc(nxt) if nxt else None} + return (jcs(v) + "\n").encode("utf-8") + + +def plain_uint(s): + return digits(s) and (s == "0" or s[0] != "0") + + +def read_guide(b): + """§8.2 step 8: the guide as read, or None.""" + if b[:1] == b"<": + root = read_feed_xml(b) + if root is None or root["name"] != "tv": + return None + url = root["attrs"].get("source-data-url", "") + if not url.startswith(GUIDE_URN): + return None + parts = url[len(GUIDE_URN):].split(":") + if len(parts) != 4 or not all(hexn(x, 32) is not None for x in parts[:2]) or not all(plain_uint(x) for x in parts[2:]): + return None + frm, to = int(parts[2]), int(parts[3]) + if frm >= to: + return None + progs = [] + for p in SPV.kids(root, "programme"): + try: + s, e = xmltv_time(p["attrs"].get("start", "")), xmltv_time(p["attrs"].get("stop", "")) + except Refuse: + return None + ids = [SPV.text(x) for x in SPV.kids(p, "episode-num") if x["attrs"].get("system", "onscreen") == ID_SYSTEM] + if not ids: + return None + progs.append((s, e, ids[0])) + return {"format": "xmltv", "root": parts[0], "lineup": parts[1], "from": frm, "to": to, "programmes": progs} + if b[:1] == b"{": + v = strict_json(b) + if not exactly(v, ["kind", "channel", "channel_name", "schedule_root", "lineup", "at_ms", "now", "next"]): + return None + if not jint(v["at_ms"]) or not isinstance(v["schedule_root"], str) or not isinstance(v["lineup"], str): + return None + out = [] + for k in ("now", "next"): + p = v[k] + if p is None: + out.append(None) + elif isinstance(p, dict) and u64(p.get("start_ms")) and u64(p.get("stop_ms")) and isinstance(p.get("asset_id"), str): + out.append((p["start_ms"], p["stop_ms"], p["asset_id"])) + else: + return None + return {"format": "now-next", "root": v["schedule_root"], "lineup": v["lineup"], "at": v["at_ms"], "now": out[0], + "next": out[1]} + return None + + +# ---------------------------------------------------------------- §8 verification + +def verify(receipt, lineup, guide): + """§8.2 and §8.3: the facts.""" + f = {"verdict": "verified", "part": None, "receipt": None, "lineup": None, "guide": None, "entry": None} + + def fail(part, reason): + f["verdict"], f["part"] = reason, part + return f + + r = read_receipt(receipt) + if r is None: + return fail("receipt", "channel_receipt_malformed") + f["receipt"] = receipt_identity(r).hex() + bad = ED.verify(r["signer_pubkey"], receipt_payload(r), r["sig"]) if "receipt_sig" not in SKIP else None + if bad: + return fail("receipt", bad) + if not receipt_rules(r): + return fail("receipt", "channel_receipt_invalid") + l = read_lineup(lineup) + if l is None: + return fail("lineup", "lineup_malformed") + f["lineup"] = lineup_digest(l).hex() + bad = ED.verify(l["signer"], LINEUP_DOMAIN + lineup_body(l, l["signer"]), l["sig"]) if "lineup_sig" not in SKIP else None + if bad: + return fail("lineup", bad) + if l["signer"] != r["signer_pubkey"] and "signer" not in SKIP: + return fail("lineup", "lineup_signer_mismatch") + if not describes(l, r): + return fail("lineup", "lineup_schedule_mismatch") + if guide is None: + return f + g = read_guide(guide) + if g is None: + return fail("guide", "guide_malformed") + sched = r["schedule"] + + def aired(s, e): + return any(sched[i]["start_ms"] < e and sched[i]["start_ms"] + sched[i]["duration_ms"] > s + for i in r["aired"] if i < len(sched)) + + progs = all_programmes(r, l) + if g["format"] == "xmltv": + shown = g["programmes"] + f["guide"] = {"format": "xmltv", "from_ms": g["from"], "to_ms": g["to"], "at_ms": None} + else: + shown = [p for p in (g["now"], g["next"]) if p is not None] + f["guide"] = {"format": "now-next", "from_ms": None, "to_ms": None, "at_ms": g["at"]} + f["guide"]["programmes"] = [{"start_ms": s, "stop_ms": e, "asset_id": i, "aired": aired(s, e)} for s, e, i in shown] + if (g["root"] != r["schedule_root"].hex() or g["lineup"] != f["lineup"]) and "source" not in SKIP: + return fail("guide", "guide_source_mismatch") + key = (lambda p: (p[0], p[1], p[2]["asset_id"])) + if g["format"] == "xmltv": + want = [key(p) for p in progs if p[0] < g["to"] and p[1] > g["from"]] + for i, p in enumerate(g["programmes"]): + if p not in want and "entries" not in SKIP: + f["entry"] = i + return fail("guide", "guide_entry_unscheduled") + expect = xmltv_guide(r, l, g["from"], g["to"]) + else: + wn, wx = now_and_next(progs, g["at"]) + for i, (got, w) in enumerate(((g["now"], wn), (g["next"], wx))): + if got != (key(w) if w else None) and "entries" not in SKIP: + f["entry"] = i + return fail("guide", "guide_entry_unscheduled") + expect = now_next_doc(r, l, g["at"]) + if guide != expect and "bytes" not in SKIP: + return fail("guide", "guide_mismatch") + return f + + +# ---------------------------------------------------------------- the corpus + +def apply_edits(files, edits): + out = dict(files) + for e in edits: + b = out[e["file"]] + if "pad" in e: + out[e["file"]] = b + b" " * e["pad"] + continue + s = b.decode("utf-8") + if s.count(e["find"]) != 1: + raise ValueError("%s: an edit's find does not occur once" % e["file"]) + with_ = e["find"] + e["replace"] * e["repeat"] if "repeat" in e else e["replace"] + out[e["file"]] = s.replace(e["find"], with_, 1).encode("utf-8") + return out + + +def import_inputs(v, files): + inp = dict(v["inputs"]) + inp["artwork_files"] = {n: files[n] for n in inp["artwork"]} + if inp["window"] is not None: + inp["window"] = tuple(inp["window"]) + return inp + + +def check_import(doc, v, files, op_key): + """None when the import gets the outcome the corpus states, else why not.""" + files = apply_edits(files, v["edits"]) + o = v["outcome"] + try: + got = do_import(import_inputs(v, files), files) + except Refuse as e: + return None if o.get("refused") == e.reason else "refused %s, the corpus states %s" % (e.reason, o) + if "refused" in o: + return "imported, the corpus states %s" % o + r = read_receipt(files[o["receipt"]]) + if r is None: + return "the receipt does not read" + inp = v["inputs"] + want_grant = {"grant_hash": bytes(32), "capability": "video.channel", "joule_ceiling_micro": U64, "funds_ceiling": None} + ws, we = got["window"] + same = (r["channel_id"] == inp["channel"] and r["schedule"] == got["entries"] and r["schedule_root"] == schedule_root(got["entries"]) + and (r["window_start_ms"], r["window_end_ms"]) == (ws, we) and r["aired"] == aired_of(got["entries"], ws, we) + and r["work_schedule_ops"] == len(got["entries"]) and r["joules_micro"] == 0 and r["class"] is None + and r["grant"] == want_grant and r["parent"] == (bytes.fromhex(inp["parent_receipt_hash"]) if inp["parent_receipt_hash"] else None) + and r["signer_pubkey"] == op_key and r["signer_id"] == inp["signer_id"]) + if not same: + return "the receipt is not the import's" + if ED.verify(r["signer_pubkey"], receipt_payload(r), r["sig"]) or receipt_identity(r).hex() != o["receipt_hash"]: + return "the receipt's signature or identity" + lb = bytes.fromhex(o["lineup"]) + body = lineup_body(got["lineup"], op_key) + if lb[:-64] != body or ED.verify(op_key, LINEUP_DOMAIN + body, lb[-64:]) or sha(body).hex() != o["lineup_digest"]: + return "the lineup is not the import's, or its signature does not verify" + if got["cues"] != o["cues"]: + return "the cues differ" + return None + + +def check_verification(v, files): + files = apply_edits(files, v["edits"]) + g = files[v["guide"]] if v["guide"] else None + facts = verify(files[v["receipt"]], bytes.fromhex(v["lineup"]), g) + return None if facts == v["facts"] else "states %s, the corpus %s" % (facts, v["facts"]) + + +def corpus_files(doc): + names = {"vectors.json", "README.md"} + for v in doc["imports"]: + i = v["inputs"] + names |= {x for x in (i["xmltv"], i["json"], i["playlist"]) if x} | {m["file"] for m in i["mrss"]} | set(i["artwork"]) + if "receipt" in v["outcome"]: + names.add(v["outcome"]["receipt"]) + for v in doc["verifications"]: + names |= {x for x in (v["receipt"], v["guide"]) if x} + return names + + +def check_all(doc, files, listing, quiet=False): + """The failures of every vector and of the corpus as a whole.""" + fails = [] + op_key = bytes.fromhex(doc["operator"]["key"]) + for v in doc["imports"]: + try: + why = check_import(doc, v, files, op_key) + except (KeyError, ValueError, TypeError) as e: + why = "broken: %r" % (e,) + if why: + fails.append("import %s: %s" % (v["name"], why)) + for v in doc["verifications"]: + try: + why = check_verification(v, files) + except (KeyError, ValueError, TypeError) as e: + why = "broken: %r" % (e,) + if why: + fails.append("verification %s: %s" % (v["name"], why)) + added, reused = reasons() + seen = {v["outcome"]["refused"] for v in doc["imports"] if "refused" in v["outcome"]} + seen |= {v["facts"]["verdict"] for v in doc["verifications"]} + for r in added + reused: + if r not in seen: + fails.append("the corpus gives no %s" % r) + for r in seen - set(added) - set(reused) - {"verified"}: + fails.append("%s is not a reason of §10" % r) + if listing != corpus_files(doc): + fails.append("the files are not the corpus's: %s" % sorted(listing ^ corpus_files(doc))) + if not quiet: + for f in fails: + print("FAIL", f) + return fails + + +def load(d): + listing = set(os.listdir(d)) + files = {} + for n in listing: + with open(os.path.join(d, n), "rb") as f: + files[n] = f.read() + return json.loads(files["vectors.json"]), files, listing + + +def flip(h, at): + c = "0" if h[at] != "0" else "1" + return h[:at] + c + h[at + 1:] + + +def mutations(doc, files, listing): + """Copies of the corpus with one defect each: every one must fail.""" + out = [] + + def m(name, f): + d, fs, ls = copy.deepcopy(doc), dict(files), set(listing) + f(d, fs, ls) + out.append((name, d, fs, ls)) + + imp = lambda d, n: next(v for v in d["imports"] if v["name"] == n) # noqa: E731 + ver = lambda d, n: next(v for v in d["verifications"] if v["name"] == n) # noqa: E731 + m("a lineup byte of the sample's outcome", lambda d, fs, ls: imp(d, "sample")["outcome"].__setitem__("lineup", flip(imp(d, "sample")["outcome"]["lineup"], 120))) + m("the sample's receipt hash", lambda d, fs, ls: imp(d, "sample")["outcome"].__setitem__("receipt_hash", flip(imp(d, "sample")["outcome"]["receipt_hash"], 0))) + m("a cue's payload", lambda d, fs, ls: imp(d, "sample")["outcome"]["cues"][0].__setitem__("payload", flip(imp(d, "sample")["outcome"]["cues"][0]["payload"], 4))) + m("a cue's start", lambda d, fs, ls: imp(d, "sample")["outcome"]["cues"][1].__setitem__("start_ms", 1)) + m("a refusal's code", lambda d, fs, ls: imp(d, "gap")["outcome"].__setitem__("refused", "import_overlap")) + m("an edit dropped", lambda d, fs, ls: imp(d, "no_stop").__setitem__("edits", [])) + m("a verdict", lambda d, fs, ls: ver(d, "guide_mismatch")["facts"].__setitem__("verdict", "verified")) + m("an aired flag", lambda d, fs, ls: ver(d, "xmltv_guide")["facts"]["guide"]["programmes"][2].__setitem__("aired", True)) + m("the unscheduled entry's index", lambda d, fs, ls: ver(d, "guide_entry_unscheduled")["facts"].__setitem__("entry", 1)) + m("a guide's title", lambda d, fs, ls: fs.__setitem__("sample.guide.xml", fs["sample.guide.xml"].replace(b"Tidepool Kitchen", b"Tidepool Kitchens"))) + m("a now/next title", lambda d, fs, ls: fs.__setitem__("sample.now-next.json", fs["sample.now-next.json"].replace(b"Tidepool Kitchen", b"Tidepool Kitchens"))) + m("an artwork file", lambda d, fs, ls: fs.__setitem__("art-tk-7.svg", fs["art-tk-7.svg"] + b" ")) + m("a catalogue's duration", lambda d, fs, ls: fs.__setitem__("sample-a.mrss.xml", fs["sample-a.mrss.xml"].replace(b'duration="1620"/>', b'duration="1621"/>', 1))) + m("a break's start", lambda d, fs, ls: fs.__setitem__("sample.m3u8", fs["sample.m3u8"].replace(b"18:45:30.500Z", b"18:45:31.500Z"))) + m("an SCTE-35 section", lambda d, fs, ls: fs.__setitem__("sample.m3u8", fs["sample.m3u8"].replace(b"03E97FEFFE", b"03EA7FEFFE", 1))) + m("the receipt's window", lambda d, fs, ls: fs.__setitem__("sample.channel.json", fs["sample.channel.json"].replace(b'"window_end_ms": 1793300400000', b'"window_end_ms": 1793300401000'))) + m("a file added", lambda d, fs, ls: (fs.__setitem__("extra.txt", b"x"), ls.add("extra.txt"))) + m("a vector of a reason dropped", lambda d, fs, ls: d.__setitem__("imports", [v for v in d["imports"] if v["name"] != "window_outside"])) + return out + + +def main(corpus_dir): + global SKIP + doc, files, listing = load(corpus_dir) + if check_all(doc, files, listing): + return 1 + print("channel lineup corpus: %d imports and %d verifications hold" % (len(doc["imports"]), len(doc["verifications"]))) + bad = 0 + for name, d, fs, ls in mutations(doc, files, listing): + if not check_all(d, fs, ls, quiet=True): + print("FAIL mutation not caught:", name) + bad += 1 + print("mutations: %d copies, %d not caught" % (len(mutations(doc, files, listing)), bad)) + checks = ["inputs", "size", "fields", "timezone", "no_stop", "asset", "duplicate", "counts", "doctype", "conflict", + "scte35", "break_duration", "duration_unknown", "gap", "overlap", "whole_seconds", "owner", "artwork", + "break_overlap", "break_outside", "window", "receipt_sig", "receipt_rules", "lineup_sig", "signer", + "describes_root", "describes_starts", "describes_assets", "describes_set", "source", "entries", "bytes"] + weak = 0 + for c in checks: + SKIP = {c} + try: + caught = bool(check_all(doc, files, listing, quiet=True)) + except Exception: + caught = True + if not caught: + print("FAIL weakened verifier not caught: without", c) + weak += 1 + SKIP = set() + print("weakened verifiers: %d checks dropped one at a time, %d not caught" % (len(checks), weak)) + return 1 if bad or weak else 0 + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print(__doc__) + sys.exit(2) + sys.exit(main(sys.argv[1])) diff --git a/wai/live-conformance/channels/README.md b/wai/live-conformance/channels/README.md new file mode 100644 index 000000000..59d200331 --- /dev/null +++ b/wai/live-conformance/channels/README.md @@ -0,0 +1,46 @@ +# live-conformance/channels — channel lineup and guide + +The corpus of [`extensions/channel-lineup.md`](../../extensions/channel-lineup.md) §11: a sample +linear channel as operators exchange it, imported into its `wai.video.channel` receipt and its +`WLU1` lineup, its guides, and what a verifier states of each. Every file but this one is written +from fixed seeds by `wai_channel_lineup_vectors gen` (feature `channel_lineup`), and +`wai_channel_lineup_vectors verify ` and `tests/channel_lineup.rs` require them byte for byte. +The second implementation is [`../channel_lineup_verify.py`](../channel_lineup_verify.py), run as +`python3 -I channel_lineup_verify.py channels`. + +The sample channel, `sample-1`, is two hours on 2026-10-29 from 18:00 UTC, four programmes of +three content owners, with four ad breaks: + +| file | what | +|---|---| +| `sample.xmltv.xml` | the schedule: XMLTV, with a document type declaration, a second channel's programme beside it, and times in three zone forms | +| `sample-a.mrss.xml`, `sample-b.mrss.xml` | two owners' Media RSS catalogues: content, durations, titles, categories, thumbnails, ratings and country restrictions, one item under `media:group`, one item no programme names | +| `sample.m3u8` | the channel's Media Playlist: SCTE-35 date ranges with real `splice_insert()` and `time_signal()` sections and their CRC, an out/in pair split across two tags of one `ID`, one duration from `END-DATE`, and a date range that is not a break | +| `sample.json` | the same channel in the JSON form | +| `art-*.svg` | the artwork the icons and thumbnails name | +| `sample.channel.json` | the receipt the sample yields, its first hour the window | +| `sample.guide.xml`, `sample.part.guide.xml` | XMLTV guides of the whole schedule and of a range | +| `sample.now-next.json`, `sample.before.now-next.json`, `sample.after.now-next.json` | now/next documents inside a break, before the schedule and after it | +| `media_rss_loop.channel.json` | the receipt of one owner's items alone, back to back | +| `aired-misreported.channel.json` | a receipt, signed, whose program-as-run its window does not give | +| `vectors.json` | the operator's key, and every vector | + +`vectors.json` holds: + +- `operator`: the key that signs every receipt and lineup, and its seed (a published seed: a test + key that attests nothing); +- `imports`: each with its `inputs` (§4.1: the files it reads, owners, `id_system`, + `xmltv_channel`, `start_ms`, the artwork set by file name, the window, `signer_id` and + `parent_receipt_hash`), its `edits`, and its `outcome`: `{"refused": reason}`, or the receipt's + file and identity, the lineup's bytes and digest in hexadecimal, and the cues (each break's id, + its start and its live-events `cue` payload in hexadecimal); +- `verifications`: each with its `receipt` file, its `lineup` bytes in hexadecimal, its `guide` + file or `null`, its `edits`, and its `facts`, what §8.3 states: `verdict`, `part`, `receipt`, + `lineup`, `guide` (`format`, `from_ms`, `to_ms`, `at_ms`, and its `programmes`, each + `start_ms`, `stop_ms`, `asset_id` and `aired`), and `entry`. + +An edit is applied to a copy of the file it names before the vector is run: `{"file", "find", +"replace"}` replaces the one occurrence of `find` (an edit whose `find` does not occur exactly +once is a broken vector); with `"repeat": n` it keeps `find` and writes `replace` n times after +it; and `{"file", "pad"}` appends that many space bytes. Each control is +the sample with one such change, or with one input changed. diff --git a/wai/live-conformance/channels/aired-misreported.channel.json b/wai/live-conformance/channels/aired-misreported.channel.json new file mode 100644 index 000000000..0f42e293f --- /dev/null +++ b/wai/live-conformance/channels/aired-misreported.channel.json @@ -0,0 +1,90 @@ +{ + "channel_id": "sample-1", + "schedule_root": "d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b83", + "schedule": [ + { + "asset_id": "hl-101", + "start_ms": 1793296800000, + "duration_ms": 720000, + "kind": "program" + }, + { + "asset_id": "brk-1", + "start_ms": 1793297520000, + "duration_ms": 90000, + "kind": "ad" + }, + { + "asset_id": "hl-101", + "start_ms": 1793297610000, + "duration_ms": 870000, + "kind": "program" + }, + { + "asset_id": "brk-2", + "start_ms": 1793298480000, + "duration_ms": 120000, + "kind": "ad" + }, + { + "asset_id": "tk-7", + "start_ms": 1793298600000, + "duration_ms": 930500, + "kind": "program" + }, + { + "asset_id": "brk-3", + "start_ms": 1793299530500, + "duration_ms": 60000, + "kind": "ad" + }, + { + "asset_id": "tk-7", + "start_ms": 1793299590500, + "duration_ms": 809500, + "kind": "program" + }, + { + "asset_id": "hl-102", + "start_ms": 1793300400000, + "duration_ms": 840000, + "kind": "program" + }, + { + "asset_id": "brk-4", + "start_ms": 1793301240000, + "duration_ms": 90000, + "kind": "ad" + }, + { + "asset_id": "hl-102", + "start_ms": 1793301330000, + "duration_ms": 870000, + "kind": "program" + }, + { + "asset_id": "nf-1", + "start_ms": 1793302200000, + "duration_ms": 1800000, + "kind": "program" + } + ], + "window_start_ms": 1793296800000, + "window_end_ms": 1793300400000, + "aired": [ + 0 + ], + "work_schedule_ops": 11, + "joules_micro": 0, + "energy_provenance": null, + "grant": { + "grant_hash": "0000000000000000000000000000000000000000000000000000000000000000", + "capability": "video.channel", + "joule_ceiling_micro": 18446744073709551615, + "funds_ceiling": null + }, + "parent_receipt_hash": null, + "signer_pubkey": "da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a", + "signer_id": "sample-operator", + "sig": "1e2c1bce261bbc3ff14ff2c6ca29ee2b25e8140307683e70d3041f58f504498248c17a22101b09e33cd12348c570d34bb8fea00352d6c1884647f564dda7bc0a" +} diff --git a/wai/live-conformance/channels/art-hl-101.svg b/wai/live-conformance/channels/art-hl-101.svg new file mode 100644 index 000000000..4cf6d8771 --- /dev/null +++ b/wai/live-conformance/channels/art-hl-101.svg @@ -0,0 +1 @@ +HL 101 diff --git a/wai/live-conformance/channels/art-hl-102.svg b/wai/live-conformance/channels/art-hl-102.svg new file mode 100644 index 000000000..4c7defb1f --- /dev/null +++ b/wai/live-conformance/channels/art-hl-102.svg @@ -0,0 +1 @@ +HL 102 diff --git a/wai/live-conformance/channels/art-nf-1.svg b/wai/live-conformance/channels/art-nf-1.svg new file mode 100644 index 000000000..6252d9f64 --- /dev/null +++ b/wai/live-conformance/channels/art-nf-1.svg @@ -0,0 +1 @@ +NF 1 diff --git a/wai/live-conformance/channels/art-tk-7.svg b/wai/live-conformance/channels/art-tk-7.svg new file mode 100644 index 000000000..796361bd6 --- /dev/null +++ b/wai/live-conformance/channels/art-tk-7.svg @@ -0,0 +1 @@ +TK 7 diff --git a/wai/live-conformance/channels/media_rss_loop.channel.json b/wai/live-conformance/channels/media_rss_loop.channel.json new file mode 100644 index 000000000..31f9e104c --- /dev/null +++ b/wai/live-conformance/channels/media_rss_loop.channel.json @@ -0,0 +1,44 @@ +{ + "channel_id": "sample-1", + "schedule_root": "2fccd317b7809fcf7439a7eefcae244d273b7430c665404d0c5d4d94b42674b0", + "schedule": [ + { + "asset_id": "hl-101", + "start_ms": 1793318400000, + "duration_ms": 1620000, + "kind": "program" + }, + { + "asset_id": "hl-102", + "start_ms": 1793320020000, + "duration_ms": 1620000, + "kind": "program" + }, + { + "asset_id": "hl-103", + "start_ms": 1793321640000, + "duration_ms": 1620000, + "kind": "program" + } + ], + "window_start_ms": 1793318400000, + "window_end_ms": 1793323260000, + "aired": [ + 0, + 1, + 2 + ], + "work_schedule_ops": 3, + "joules_micro": 0, + "energy_provenance": null, + "grant": { + "grant_hash": "0000000000000000000000000000000000000000000000000000000000000000", + "capability": "video.channel", + "joule_ceiling_micro": 18446744073709551615, + "funds_ceiling": null + }, + "parent_receipt_hash": null, + "signer_pubkey": "da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a", + "signer_id": "sample-operator", + "sig": "c9c7e26c43dc42f6939c67efd86cc1424940cc802694f6cbefdf8cee377a80cf32f350055005ef002bff4bbd544edd1069595cd58778c11ceb411816dc95880c" +} diff --git a/wai/live-conformance/channels/sample-a.mrss.xml b/wai/live-conformance/channels/sample-a.mrss.xml new file mode 100644 index 000000000..6b567b3b9 --- /dev/null +++ b/wai/live-conformance/channels/sample-a.mrss.xml @@ -0,0 +1,37 @@ + + + + Owner A titles + https://owner-a.example/ + Owner A's catalogue for linear channels. + + Harbour Lights + hl-101 + + Harbour Lights + A lighthouse keeper's first winter. + Drama + + tv-pg + us ca gb + + + hl-102 + + + + + Harbour Lights + The supply boat is late. + Drama + + tv-pg + de + + + hl-103 + + Harbour Lights + + + diff --git a/wai/live-conformance/channels/sample-b.mrss.xml b/wai/live-conformance/channels/sample-b.mrss.xml new file mode 100644 index 000000000..4a5dd104a --- /dev/null +++ b/wai/live-conformance/channels/sample-b.mrss.xml @@ -0,0 +1,16 @@ + + + + Owner B kitchen + + tk-7 + + Tidepool Kitchen + Kelp, crab & lemon, on a beach stove. + Food + Family + + none + + + diff --git a/wai/live-conformance/channels/sample.after.now-next.json b/wai/live-conformance/channels/sample.after.now-next.json new file mode 100644 index 000000000..2f70d9a2c --- /dev/null +++ b/wai/live-conformance/channels/sample.after.now-next.json @@ -0,0 +1 @@ +{"at_ms":1793307600000,"channel":"sample-1","channel_name":"Sample One","kind":"wai-now-next","lineup":"6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5","next":null,"now":null,"schedule_root":"d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b83"} diff --git a/wai/live-conformance/channels/sample.before.now-next.json b/wai/live-conformance/channels/sample.before.now-next.json new file mode 100644 index 000000000..2f8005a35 --- /dev/null +++ b/wai/live-conformance/channels/sample.before.now-next.json @@ -0,0 +1 @@ +{"at_ms":1793293200000,"channel":"sample-1","channel_name":"Sample One","kind":"wai-now-next","lineup":"6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5","next":{"artwork":[{"sha256":"f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc","uri":"https://art.example/sample/art-hl-101.svg?w=320"}],"asset_id":"hl-101","categories":["Drama"],"content_uri":"https://media.example/a/hl-101/index.m3u8","description":"A lighthouse keeper's first winter.","duration_ms":1620000,"geo":{"allow":["CA","GB","US"]},"rating":{"system":"urn:v-chip","value":"tv-pg"},"rights_holder":"owner-a","start_ms":1793296800000,"stop_ms":1793298600000,"title":"Harbour Lights"},"now":null,"schedule_root":"d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b83"} diff --git a/wai/live-conformance/channels/sample.channel.json b/wai/live-conformance/channels/sample.channel.json new file mode 100644 index 000000000..d902a7a34 --- /dev/null +++ b/wai/live-conformance/channels/sample.channel.json @@ -0,0 +1,96 @@ +{ + "channel_id": "sample-1", + "schedule_root": "d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b83", + "schedule": [ + { + "asset_id": "hl-101", + "start_ms": 1793296800000, + "duration_ms": 720000, + "kind": "program" + }, + { + "asset_id": "brk-1", + "start_ms": 1793297520000, + "duration_ms": 90000, + "kind": "ad" + }, + { + "asset_id": "hl-101", + "start_ms": 1793297610000, + "duration_ms": 870000, + "kind": "program" + }, + { + "asset_id": "brk-2", + "start_ms": 1793298480000, + "duration_ms": 120000, + "kind": "ad" + }, + { + "asset_id": "tk-7", + "start_ms": 1793298600000, + "duration_ms": 930500, + "kind": "program" + }, + { + "asset_id": "brk-3", + "start_ms": 1793299530500, + "duration_ms": 60000, + "kind": "ad" + }, + { + "asset_id": "tk-7", + "start_ms": 1793299590500, + "duration_ms": 809500, + "kind": "program" + }, + { + "asset_id": "hl-102", + "start_ms": 1793300400000, + "duration_ms": 840000, + "kind": "program" + }, + { + "asset_id": "brk-4", + "start_ms": 1793301240000, + "duration_ms": 90000, + "kind": "ad" + }, + { + "asset_id": "hl-102", + "start_ms": 1793301330000, + "duration_ms": 870000, + "kind": "program" + }, + { + "asset_id": "nf-1", + "start_ms": 1793302200000, + "duration_ms": 1800000, + "kind": "program" + } + ], + "window_start_ms": 1793296800000, + "window_end_ms": 1793300400000, + "aired": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6 + ], + "work_schedule_ops": 11, + "joules_micro": 0, + "energy_provenance": null, + "grant": { + "grant_hash": "0000000000000000000000000000000000000000000000000000000000000000", + "capability": "video.channel", + "joule_ceiling_micro": 18446744073709551615, + "funds_ceiling": null + }, + "parent_receipt_hash": null, + "signer_pubkey": "da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a", + "signer_id": "sample-operator", + "sig": "70267928c5af2dc768215e23ca3fe4e47d106658261b40cc27a1fff1fb72906f84fe11bab8f3b95bc2763b86e95d7eaaeeb28a02b847f48f25e7107fb218d50a" +} diff --git a/wai/live-conformance/channels/sample.guide.xml b/wai/live-conformance/channels/sample.guide.xml new file mode 100644 index 000000000..272f0672e --- /dev/null +++ b/wai/live-conformance/channels/sample.guide.xml @@ -0,0 +1,44 @@ + + + + Sample One + + + Harbour Lights + A lighthouse keeper's first winter. + Drama + + hl-101 + + tv-pg + + + + Tidepool Kitchen + Kelp, crab & lemon, on a beach stove. + Food + Family + + tk-7 + + + Harbour Lights + The supply boat is late. + Drama + + hl-102 + + tv-pg + + + + Night Freight + Overnight on the coast line & the yards it feeds. + Documentary + + nf-1 + + TV-14 + + + diff --git a/wai/live-conformance/channels/sample.json b/wai/live-conformance/channels/sample.json new file mode 100644 index 000000000..a1f10e837 --- /dev/null +++ b/wai/live-conformance/channels/sample.json @@ -0,0 +1,150 @@ +{ + "kind": "wai-channel-feed", + "channel_name": "Sample One", + "programmes": [ + { + "start_ms": 1793296800000, + "stop_ms": 1793298600000, + "asset_id": "hl-101" + }, + { + "start_ms": 1793298600000, + "stop_ms": 1793300400000, + "asset_id": "tk-7" + }, + { + "start_ms": 1793300400000, + "stop_ms": 1793302200000, + "asset_id": "hl-102" + }, + { + "start_ms": 1793302200000, + "stop_ms": 1793304000000, + "asset_id": "nf-1" + } + ], + "assets": [ + { + "asset_id": "hl-101", + "title": "Harbour Lights", + "description": "A lighthouse keeper's first winter.", + "content_uri": "https://media.example/a/hl-101/index.m3u8", + "categories": [ + "Drama" + ], + "rating": { + "system": "urn:v-chip", + "value": "tv-pg" + }, + "artwork": [ + { + "uri": "https://art.example/sample/art-hl-101.svg?w=320", + "sha256": "f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc" + } + ], + "geo": { + "allow": [ + "CA", + "GB", + "US" + ] + }, + "rights_holder": "owner-a", + "duration_ms": 1620000 + }, + { + "asset_id": "hl-102", + "title": "Harbour Lights", + "description": "The supply boat is late.", + "content_uri": "https://media.example/a/hl-102/index.m3u8", + "categories": [ + "Drama" + ], + "rating": { + "system": "urn:v-chip", + "value": "tv-pg" + }, + "artwork": [ + { + "uri": "https://art.example/sample/art-hl-102.svg?w=320", + "sha256": "6a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d3" + } + ], + "geo": { + "deny": [ + "DE" + ] + }, + "rights_holder": "owner-a", + "duration_ms": 1620000 + }, + { + "asset_id": "nf-1", + "title": "Night Freight", + "description": "Overnight on the coast line & the yards it feeds.", + "content_uri": "", + "categories": [ + "Documentary" + ], + "rating": { + "system": "VCHIP", + "value": "TV-14" + }, + "artwork": [ + { + "uri": "https://art.example/sample/art-nf-1.svg?w=320", + "sha256": "2eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b1" + } + ], + "geo": null, + "rights_holder": "owner-c", + "duration_ms": 0 + }, + { + "asset_id": "tk-7", + "title": "Tidepool Kitchen", + "description": "Kelp, crab & lemon, on a beach stove.", + "content_uri": "https://media.example/b/tk-7.m3u8", + "categories": [ + "Food", + "Family" + ], + "rating": null, + "artwork": [ + { + "uri": "https://art.example/sample/art-tk-7.svg?w=320", + "sha256": "1cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b860" + } + ], + "geo": null, + "rights_holder": "owner-b", + "duration_ms": 1650000 + } + ], + "breaks": [ + { + "id": "brk-1", + "start_ms": 1793297520000, + "duration_ms": 90000, + "scte35": "fc3025000000000000fffff01405000003e97feffe03dcc500fe007b98a00001000000008f389a15" + }, + { + "id": "brk-2", + "start_ms": 1793298480000, + "duration_ms": 120000, + "scte35": "fc3016000000000000fffff00506fe090321000000f83beb5f" + }, + { + "id": "brk-3", + "start_ms": 1793299530500, + "duration_ms": 60000, + "scte35": "fc3025000000000000fffff01405000003eb7feffe0ea5c568fe005265c000010000000045b18e8a" + }, + { + "id": "brk-4", + "start_ms": 1793301240000, + "duration_ms": 90000, + "scte35": "fc3025000000000000fffff01405000003ec7feffe17d16980fe007b98a0000100000000959f6b5f" + } + ] +} diff --git a/wai/live-conformance/channels/sample.m3u8 b/wai/live-conformance/channels/sample.m3u8 new file mode 100644 index 000000000..1fa9836e9 --- /dev/null +++ b/wai/live-conformance/channels/sample.m3u8 @@ -0,0 +1,17 @@ +#EXTM3U +#EXT-X-VERSION:6 +#EXT-X-TARGETDURATION:6 +#EXT-X-MEDIA-SEQUENCE:120 +#EXT-X-PROGRAM-DATE-TIME:2026-10-29T18:11:54.000Z +#EXTINF:6.000, +seg-120.ts +#EXT-X-DATERANGE:ID="brk-1",START-DATE="2026-10-29T18:12:00.000Z",PLANNED-DURATION=90.000,SCTE35-OUT=0xFC3025000000000000FFFFF01405000003E97FEFFE03DCC500FE007B98A00001000000008F389A15 +#EXTINF:6.000, +seg-121.ts +#EXT-X-DATERANGE:ID="brk-1",START-DATE="2026-10-29T18:12:00.000Z",DURATION=90.000,SCTE35-IN=0xFC3020000000000000FFFFF00F05000003E97F4FFE04585DA0000100000000AE39FE70 +#EXT-X-DATERANGE:ID="chapter-2",CLASS="sample.chapter",START-DATE="2026-10-29T18:20:00.000Z" +#EXT-X-DATERANGE:ID="brk-2",START-DATE="2026-10-29T18:28:00.000Z",DURATION=120.000,SCTE35-OUT=0xFC3016000000000000FFFFF00506FE090321000000F83BEB5F +#EXT-X-DATERANGE:ID="brk-3",START-DATE="2026-10-29T18:45:30.500Z",PLANNED-DURATION=60,SCTE35-OUT=0xfc3025000000000000fffff01405000003eb7feffe0ea5c568fe005265c000010000000045b18e8a +#EXT-X-DATERANGE:ID="brk-4",START-DATE="2026-10-29T20:14:00+01:00",END-DATE="2026-10-29T19:15:30.000Z",SCTE35-OUT=0XFC3025000000000000FFFFF01405000003EC7FEFFE17D16980FE007B98A0000100000000959F6B5F +#EXTINF:6.000, +seg-122.ts diff --git a/wai/live-conformance/channels/sample.now-next.json b/wai/live-conformance/channels/sample.now-next.json new file mode 100644 index 000000000..5797e3449 --- /dev/null +++ b/wai/live-conformance/channels/sample.now-next.json @@ -0,0 +1 @@ +{"at_ms":1793299545000,"channel":"sample-1","channel_name":"Sample One","kind":"wai-now-next","lineup":"6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5","next":{"artwork":[{"sha256":"6a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d3","uri":"https://art.example/sample/art-hl-102.svg?w=320"}],"asset_id":"hl-102","categories":["Drama"],"content_uri":"https://media.example/a/hl-102/index.m3u8","description":"The supply boat is late.","duration_ms":1620000,"geo":{"deny":["DE"]},"rating":{"system":"urn:v-chip","value":"tv-pg"},"rights_holder":"owner-a","start_ms":1793300400000,"stop_ms":1793302200000,"title":"Harbour Lights"},"now":{"artwork":[{"sha256":"1cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b860","uri":"https://art.example/sample/art-tk-7.svg?w=320"}],"asset_id":"tk-7","categories":["Food","Family"],"content_uri":"https://media.example/b/tk-7.m3u8","description":"Kelp, crab & lemon, on a beach stove.","duration_ms":1650000,"geo":null,"rating":null,"rights_holder":"owner-b","start_ms":1793298600000,"stop_ms":1793300400000,"title":"Tidepool Kitchen"},"schedule_root":"d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b83"} diff --git a/wai/live-conformance/channels/sample.part.guide.xml b/wai/live-conformance/channels/sample.part.guide.xml new file mode 100644 index 000000000..aaaf062f9 --- /dev/null +++ b/wai/live-conformance/channels/sample.part.guide.xml @@ -0,0 +1,24 @@ + + + + Sample One + + + Tidepool Kitchen + Kelp, crab & lemon, on a beach stove. + Food + Family + + tk-7 + + + Harbour Lights + The supply boat is late. + Drama + + hl-102 + + tv-pg + + + diff --git a/wai/live-conformance/channels/sample.xmltv.xml b/wai/live-conformance/channels/sample.xmltv.xml new file mode 100644 index 000000000..37afdef81 --- /dev/null +++ b/wai/live-conformance/channels/sample.xmltv.xml @@ -0,0 +1,40 @@ + + + + + Sample One + + + Sample Two + + + Harbour Lights + A lighthouse keeper's first winter. + Drama + + 0.0. + hl-101 + + + Tidepool Kitchen + tk-7 + + + Harbour Lights + hl-102 + + + Night Freight + Overnight on the coast line & the yards it feeds. + Documentary + + nf-1 + + TV-14 + + + + Another Channel's Hour + other-1 + + diff --git a/wai/live-conformance/channels/vectors.json b/wai/live-conformance/channels/vectors.json new file mode 100644 index 000000000..13c3ab6cd --- /dev/null +++ b/wai/live-conformance/channels/vectors.json @@ -0,0 +1,1787 @@ +{ + "about": "extensions/channel-lineup.md §11: a sample channel's feeds (XMLTV, two owners' Media RSS, a Media Playlist with SCTE-35 date ranges, and the JSON form), each import's inputs, the edit that makes it a control and its outcome; each verification of a receipt, a lineup and a guide, and what it states", + "operator": { + "key": "da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a", + "seed": "aca23cde2418010624ea01fe74a44132163bed9ca0511ef11638290fe650ef83" + }, + "imports": [ + { + "name": "sample", + "about": "the sample channel from its XMLTV schedule (beside another channel's), two owners' Media RSS catalogues and the playlist's SCTE-35 date ranges, its first hour the receipt's window", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [], + "outcome": { + "receipt": "sample.channel.json", + "receipt_hash": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05", + "lineup_digest": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5", + "cues": [ + { + "break": "brk-1", + "start_ms": 1793297520000, + "payload": "04e9030000905f0100012800fc3025000000000000fffff01405000003e97feffe03dcc500fe007b98a00001000000008f389a15" + }, + { + "break": "brk-2", + "start_ms": 1793298480000, + "payload": "041733d0f9c0d40100011900fc3016000000000000fffff00506fe090321000000f83beb5f" + }, + { + "break": "brk-3", + "start_ms": 1793299530500, + "payload": "04eb03000060ea0000012800fc3025000000000000fffff01405000003eb7feffe0ea5c568fe005265c000010000000045b18e8a" + }, + { + "break": "brk-4", + "start_ms": 1793301240000, + "payload": "04ec030000905f0100012800fc3025000000000000fffff01405000003ec7feffe17d16980fe007b98a0000100000000959f6b5f" + } + ] + } + }, + { + "name": "sample_json", + "about": "the same channel from the JSON form alone: the same receipt and lineup, byte for byte", + "inputs": { + "channel": "sample-1", + "xmltv": null, + "mrss": [], + "json": "sample.json", + "playlist": null, + "owner": null, + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [], + "outcome": { + "receipt": "sample.channel.json", + "receipt_hash": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05", + "lineup_digest": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5", + "cues": [ + { + "break": "brk-1", + "start_ms": 1793297520000, + "payload": "04e9030000905f0100012800fc3025000000000000fffff01405000003e97feffe03dcc500fe007b98a00001000000008f389a15" + }, + { + "break": "brk-2", + "start_ms": 1793298480000, + "payload": "041733d0f9c0d40100011900fc3016000000000000fffff00506fe090321000000f83beb5f" + }, + { + "break": "brk-3", + "start_ms": 1793299530500, + "payload": "04eb03000060ea0000012800fc3025000000000000fffff01405000003eb7feffe0ea5c568fe005265c000010000000045b18e8a" + }, + { + "break": "brk-4", + "start_ms": 1793301240000, + "payload": "04ec030000905f0100012800fc3025000000000000fffff01405000003ec7feffe17d16980fe007b98a0000100000000959f6b5f" + } + ] + } + }, + { + "name": "media_rss_loop", + "about": "one owner's Media RSS items alone, back to back from a start, with no break", + "inputs": { + "channel": "sample-1", + "xmltv": null, + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + } + ], + "json": null, + "playlist": null, + "owner": null, + "id_system": null, + "xmltv_channel": null, + "start_ms": 1793318400000, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": null, + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [], + "outcome": { + "receipt": "media_rss_loop.channel.json", + "receipt_hash": "a2cc6ae4d6de83aa9b0c1dd3e63878a605a9ccaf813d074f37c2a1cad2ed0c99", + "lineup": "574c5531010873616d706c652d310e4f776e65722041207469746c65732fccd317b7809fcf7439a7eefcae244d273b7430c665404d0c5d4d94b42674b000301b8aa10100006058658aa10100000300000000301b8aa101000020e8338aa101000040a04c8aa10100000300000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b818000000000006686c2d3130330e00486172626f7572204c69676874730000000000000000076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130332f696e6465782e6d33753820b8180000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a0dc1ce7aa1b514b5f1433fd99901df6d1dd586a16482465fd2693cb484c759ec9188c13db65a5a5bce116be12c86efb5ddcbd10472fb7925dd8918632f797c0b", + "lineup_digest": "81a6b92a2b83c431728c15f58e68a1f03773708a8644b472c513aadc077f4eb2", + "cues": [] + } + }, + { + "name": "oversized_count", + "about": "the JSON form with more than 10 000 breaks", + "inputs": { + "channel": "sample-1", + "xmltv": null, + "mrss": [], + "json": "sample.json", + "playlist": null, + "owner": null, + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample.json", + "find": "\"breaks\": [", + "replace": "{\"id\": \"x\", \"start_ms\": 0, \"duration_ms\": 1, \"scte35\": null}, ", + "repeat": 10000 + } + ], + "outcome": { + "refused": "import_oversized" + } + }, + { + "name": "not_whole_second", + "about": "a programme boundary half a second past a whole second, which an XMLTV guide cannot state", + "inputs": { + "channel": "sample-1", + "xmltv": null, + "mrss": [], + "json": "sample.json", + "playlist": null, + "owner": null, + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample.json", + "find": "\"stop_ms\": 1793298600000", + "replace": "\"stop_ms\": 1793298600500" + }, + { + "file": "sample.json", + "find": "\"start_ms\": 1793298600000", + "replace": "\"start_ms\": 1793298600500" + } + ], + "outcome": { + "refused": "import_time_malformed" + } + }, + { + "name": "break_overlap", + "about": "a break that begins before the one before it ends", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample.m3u8", + "find": "ID=\"brk-2\",START-DATE=\"2026-10-29T18:28:00.000Z\"", + "replace": "ID=\"brk-2\",START-DATE=\"2026-10-29T18:13:00.000Z\"" + } + ], + "outcome": { + "refused": "import_overlap" + } + }, + { + "name": "inputs_invalid", + "about": "an XMLTV document beside the JSON form", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": "sample.json", + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [], + "outcome": { + "refused": "import_inputs_invalid" + } + }, + { + "name": "oversized", + "about": "an XMLTV document of more than 16 777 216 bytes", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample.xmltv.xml", + "pad": 16777216 + } + ], + "outcome": { + "refused": "import_oversized" + } + }, + { + "name": "xml_malformed", + "about": "a document type declaration that defines an entity", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample.xmltv.xml", + "find": "", + "replace": "]>" + } + ], + "outcome": { + "refused": "import_xml_malformed" + } + }, + { + "name": "json_malformed", + "about": "the JSON form naming a member twice", + "inputs": { + "channel": "sample-1", + "xmltv": null, + "mrss": [], + "json": "sample.json", + "playlist": null, + "owner": null, + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample.json", + "find": "\"kind\": \"wai-channel-feed\",", + "replace": "\"kind\": \"wai-channel-feed\",\n \"kind\": \"wai-channel-feed\"," + } + ], + "outcome": { + "refused": "import_json_malformed" + } + }, + { + "name": "playlist_malformed", + "about": "two tags of one date range that give it two starts", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample.m3u8", + "find": "START-DATE=\"2026-10-29T18:12:00.000Z\",DURATION", + "replace": "START-DATE=\"2026-10-29T18:12:01.000Z\",DURATION" + } + ], + "outcome": { + "refused": "import_playlist_malformed" + } + }, + { + "name": "time_malformed", + "about": "a programme starting in a thirteenth month", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample.xmltv.xml", + "find": "start=\"20261029183000 +0000\"", + "replace": "start=\"20261329183000 +0000\"" + } + ], + "outcome": { + "refused": "import_time_malformed" + } + }, + { + "name": "timezone_unknown", + "about": "a programme's time in a zone named by letters", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample.xmltv.xml", + "find": "start=\"20261029183000 +0000\"", + "replace": "start=\"20261029193000 BST\"" + } + ], + "outcome": { + "refused": "import_timezone_unknown" + } + }, + { + "name": "no_stop", + "about": "a programme with no stop", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample.xmltv.xml", + "find": " stop=\"20261029190000 +0000\" channel=\"sample-1\"", + "replace": " channel=\"sample-1\"" + } + ], + "outcome": { + "refused": "import_no_stop" + } + }, + { + "name": "empty", + "about": "an XMLTV channel id the document has no programme of", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": "sample-9", + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [], + "outcome": { + "refused": "import_empty" + } + }, + { + "name": "asset_unknown", + "about": "a programme whose asset is named only in another episode-num system", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample.xmltv.xml", + "find": "tk-7", + "replace": "tk-7" + } + ], + "outcome": { + "refused": "import_asset_unknown" + } + }, + { + "name": "asset_duplicate", + "about": "two catalogue items with one guid", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample-a.mrss.xml", + "find": "hl-103", + "replace": "hl-101" + } + ], + "outcome": { + "refused": "import_asset_duplicate" + } + }, + { + "name": "field_invalid", + "about": "a restriction whose relationship is neither allow nor deny", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample-b.mrss.xml", + "find": "relationship=\"deny\"", + "replace": "relationship=\"maybe\"" + } + ], + "outcome": { + "refused": "import_field_invalid" + } + }, + { + "name": "scte35_malformed", + "about": "a section whose section_length is not its length", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample.m3u8", + "find": "DURATION=120.000,SCTE35-OUT=0x", + "replace": "DURATION=120.000,SCTE35-OUT=0xFC3001,X-WAS=0x" + } + ], + "outcome": { + "refused": "import_scte35_malformed" + } + }, + { + "name": "break_duration", + "about": "a break of no duration", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample.m3u8", + "find": "DURATION=120.000", + "replace": "DURATION=0.000" + } + ], + "outcome": { + "refused": "import_break_duration" + } + }, + { + "name": "duration_unknown", + "about": "a Media RSS item with no duration, in a channel of Media RSS items alone", + "inputs": { + "channel": "sample-1", + "xmltv": null, + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + } + ], + "json": null, + "playlist": null, + "owner": null, + "id_system": null, + "xmltv_channel": null, + "start_ms": 1793318400000, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": null, + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample-a.mrss.xml", + "find": "", + "replace": "" + } + ], + "outcome": { + "refused": "import_duration_unknown" + } + }, + { + "name": "gap", + "about": "a minute of dead air before the last programme", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample.xmltv.xml", + "find": "start=\"20261029193000\"", + "replace": "start=\"20261029193100\"" + } + ], + "outcome": { + "refused": "import_gap" + } + }, + { + "name": "overlap", + "about": "a programme that ends after the next begins", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample.xmltv.xml", + "find": "stop=\"20261029203000 +0100\"", + "replace": "stop=\"20261029203100 +0100\"" + } + ], + "outcome": { + "refused": "import_overlap" + } + }, + { + "name": "owner_unknown", + "about": "an asset of the XMLTV document alone, and no owner given", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": null, + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [], + "outcome": { + "refused": "import_owner_unknown" + } + }, + { + "name": "artwork_missing", + "about": "an icon whose file the artwork set does not hold", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [], + "outcome": { + "refused": "import_artwork_missing" + } + }, + { + "name": "break_outside", + "about": "a break that runs past the end of its programme", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample.m3u8", + "find": "ID=\"brk-2\",START-DATE=\"2026-10-29T18:28:00.000Z\"", + "replace": "ID=\"brk-2\",START-DATE=\"2026-10-29T18:29:00.000Z\"" + } + ], + "outcome": { + "refused": "import_break_outside" + } + }, + { + "name": "window_outside", + "about": "a window that begins before the schedule", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793293200000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [], + "outcome": { + "refused": "import_window_outside" + } + } + ], + "verifications": [ + { + "name": "receipt_and_lineup", + "about": "the sample's receipt and lineup", + "receipt": "sample.channel.json", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05", + "guide": null, + "edits": [], + "facts": { + "verdict": "verified", + "part": null, + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5", + "guide": null, + "entry": null + } + }, + { + "name": "xmltv_guide", + "about": "the XMLTV guide of the whole schedule: two programmes aired in the receipt's window, two after it", + "receipt": "sample.channel.json", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05", + "guide": "sample.guide.xml", + "edits": [], + "facts": { + "verdict": "verified", + "part": null, + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5", + "guide": { + "format": "xmltv", + "from_ms": 1793296800000, + "to_ms": 1793304000000, + "at_ms": null, + "programmes": [ + { + "start_ms": 1793296800000, + "stop_ms": 1793298600000, + "asset_id": "hl-101", + "aired": true + }, + { + "start_ms": 1793298600000, + "stop_ms": 1793300400000, + "asset_id": "tk-7", + "aired": true + }, + { + "start_ms": 1793300400000, + "stop_ms": 1793302200000, + "asset_id": "hl-102", + "aired": false + }, + { + "start_ms": 1793302200000, + "stop_ms": 1793304000000, + "asset_id": "nf-1", + "aired": false + } + ] + }, + "entry": null + } + }, + { + "name": "part_guide", + "about": "the XMLTV guide of a range across the window's end", + "receipt": "sample.channel.json", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05", + "guide": "sample.part.guide.xml", + "edits": [], + "facts": { + "verdict": "verified", + "part": null, + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5", + "guide": { + "format": "xmltv", + "from_ms": 1793299500000, + "to_ms": 1793301300000, + "at_ms": null, + "programmes": [ + { + "start_ms": 1793298600000, + "stop_ms": 1793300400000, + "asset_id": "tk-7", + "aired": true + }, + { + "start_ms": 1793300400000, + "stop_ms": 1793302200000, + "asset_id": "hl-102", + "aired": false + } + ] + }, + "entry": null + } + }, + { + "name": "now_next", + "about": "the now/next document inside a programme's break", + "receipt": "sample.channel.json", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05", + "guide": "sample.now-next.json", + "edits": [], + "facts": { + "verdict": "verified", + "part": null, + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5", + "guide": { + "format": "now-next", + "from_ms": null, + "to_ms": null, + "at_ms": 1793299545000, + "programmes": [ + { + "start_ms": 1793298600000, + "stop_ms": 1793300400000, + "asset_id": "tk-7", + "aired": true + }, + { + "start_ms": 1793300400000, + "stop_ms": 1793302200000, + "asset_id": "hl-102", + "aired": false + } + ] + }, + "entry": null + } + }, + { + "name": "now_next_before", + "about": "the now/next document before the schedule begins", + "receipt": "sample.channel.json", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05", + "guide": "sample.before.now-next.json", + "edits": [], + "facts": { + "verdict": "verified", + "part": null, + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5", + "guide": { + "format": "now-next", + "from_ms": null, + "to_ms": null, + "at_ms": 1793293200000, + "programmes": [ + { + "start_ms": 1793296800000, + "stop_ms": 1793298600000, + "asset_id": "hl-101", + "aired": true + } + ] + }, + "entry": null + } + }, + { + "name": "now_next_after", + "about": "the now/next document after the schedule ends", + "receipt": "sample.channel.json", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05", + "guide": "sample.after.now-next.json", + "edits": [], + "facts": { + "verdict": "verified", + "part": null, + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5", + "guide": { + "format": "now-next", + "from_ms": null, + "to_ms": null, + "at_ms": 1793307600000, + "programmes": [] + }, + "entry": null + } + }, + { + "name": "receipt_malformed", + "about": "a receipt missing a member", + "receipt": "sample.channel.json", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05", + "guide": null, + "edits": [ + { + "file": "sample.channel.json", + "find": "\"signer_id\": \"sample-operator\"", + "replace": "\"signer\": \"sample-operator\"" + } + ], + "facts": { + "verdict": "channel_receipt_malformed", + "part": "receipt", + "receipt": null, + "lineup": null, + "guide": null, + "entry": null + } + }, + { + "name": "receipt_bad_signature", + "about": "a receipt whose signature was altered", + "receipt": "sample.channel.json", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05", + "guide": null, + "edits": [ + { + "file": "sample.channel.json", + "find": "70267928c5af2dc768215e23ca3fe4e47d106658261b40cc27a1fff1fb72906f84fe11bab8f3b95bc2763b86e95d7eaaeeb28a02b847f48f25e7107fb218d50a", + "replace": "00267928c5af2dc768215e23ca3fe4e47d106658261b40cc27a1fff1fb72906f84fe11bab8f3b95bc2763b86e95d7eaaeeb28a02b847f48f25e7107fb218d50a" + } + ], + "facts": { + "verdict": "bad_signature", + "part": "receipt", + "receipt": "6eacddf5e17d1aa344ee867d7b4d04df3555fb051e3118b11a3775edf24f2c6a", + "lineup": null, + "guide": null, + "entry": null + } + }, + { + "name": "receipt_invalid", + "about": "a receipt, signed, whose program-as-run is not what its window gives", + "receipt": "aired-misreported.channel.json", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05", + "guide": null, + "edits": [], + "facts": { + "verdict": "channel_receipt_invalid", + "part": "receipt", + "receipt": "edd53f42022f7846ee7aa67c7ad0dc04685e00926ba18b37acdef22b09fcf711", + "lineup": null, + "guide": null, + "entry": null + } + }, + { + "name": "lineup_malformed", + "about": "a lineup with a byte after its signature", + "receipt": "sample.channel.json", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b0500", + "guide": null, + "edits": [], + "facts": { + "verdict": "lineup_malformed", + "part": "lineup", + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": null, + "guide": null, + "entry": null + } + }, + { + "name": "lineup_bad_signature", + "about": "a lineup whose signature was altered", + "receipt": "sample.channel.json", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b04", + "guide": null, + "edits": [], + "facts": { + "verdict": "bad_signature", + "part": "lineup", + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5", + "guide": null, + "entry": null + } + }, + { + "name": "lineup_bad_key", + "about": "the identity point as the key, with (R, S) = (B, 1): the equation holds for every message, and the key is of small order", + "receipt": "sample.channel.json", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000010000000000000000000000000000000000000000000000000000000000000058666666666666666666666666666666666666666666666666666666666666660100000000000000000000000000000000000000000000000000000000000000", + "guide": null, + "edits": [], + "facts": { + "verdict": "bad_key", + "part": "lineup", + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "73865f2fe1d8342db7defeac36cca4aff3a378ca2112459fd2c8d89b2caeb0fd", + "guide": null, + "entry": null + } + }, + { + "name": "lineup_signer_mismatch", + "about": "a lineup another key signed", + "receipt": "sample.channel.json", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000a84e8579cbcc4c8793feab96571297a22458d3bd3d47671107083829f7ba6b1ec42835241c90d0b06b4886ee02270b40d1c10bf99619844d02149cefa2d6aacdea4fdf338c804876f42c05272da289e76a27cd86c53139b0b7ae14c1d3c88709", + "guide": null, + "edits": [], + "facts": { + "verdict": "lineup_signer_mismatch", + "part": "lineup", + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "5e7a2c0872c91871abd3df191db1d3e77f41caa76f984366395186fd6f42c0b9", + "guide": null, + "entry": null + } + }, + { + "name": "lineup_other_root", + "about": "a lineup, signed, naming another schedule root (§6.5 rule 2)", + "receipt": "sample.channel.json", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e6500000000000000000000000000000000000000000000000000000000000000000099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a91f6f497fac376fd5473b61b99c97fcb262c4412aebdbc8364dd531e6dfd9096272792853ec0ffaf94bd641629a8c46846d0453eb8f3b01ab3586135eca5b20b", + "guide": null, + "edits": [], + "facts": { + "verdict": "lineup_schedule_mismatch", + "part": "lineup", + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "07454d8e14e984a80580c774f0840f9d553b83dcbc1aa35b691d7dc2c4c0c506", + "guide": null, + "entry": null + } + }, + { + "name": "lineup_programme_mid_entry", + "about": "a lineup, signed, with a programme that begins inside an entry (§6.5 rule 3)", + "receipt": "sample.channel.json", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000050000000099d188a1010000e02cd688a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a43552d34a753e436ff992d91eedae90a128720e12546ea0a2f5f81fff976ac11dcd9af87800a6b79327f4947e8829203135186c85ea6d627a37cb5f4dce72e04", + "guide": null, + "edits": [], + "facts": { + "verdict": "lineup_schedule_mismatch", + "part": "lineup", + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "fb102745262a9c9cca09a2751a333b9d8e035325d01f92955d02cd2bd131afb4", + "guide": null, + "entry": null + } + }, + { + "name": "lineup_programme_two_assets", + "about": "a lineup, signed, whose first programme holds two assets' program entries (§6.5 rule 4)", + "receipt": "sample.channel.json", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000030000000099d188a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a20fcdb45c1078dc80ffc7de408c62217bcafc9b8cbde1805ba9ebbeb6d43933932c663a5e6c1471c72699c0dc39032b858d51f72803dbe7806cbe9274ade9e0f", + "guide": null, + "edits": [], + "facts": { + "verdict": "lineup_schedule_mismatch", + "part": "lineup", + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "6501ee75a48b77d14d91fa2c4224b052218ad2b8787eed9bd114ce86988f9725", + "guide": null, + "entry": null + } + }, + { + "name": "lineup_asset_unused", + "about": "a lineup, signed, describing an asset no programme airs (§6.5 rule 5)", + "receipt": "sample.channel.json", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000500000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000097a7a2d756e757365640e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b8180000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a0760f271a3b6302c64dc7e88682fabd2749275805b519d39f951f6ad120839983c30dbe9372a1bbe28dea63c1f976b52e5fb07e467dfb142ab853be510186a00", + "guide": null, + "edits": [], + "facts": { + "verdict": "lineup_schedule_mismatch", + "part": "lineup", + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "485d82cbb2c278cece8ae2d4b326ab62bb13ebc90c714746a83b690febdedccd", + "guide": null, + "entry": null + } + }, + { + "name": "guide_malformed", + "about": "a guide cut short", + "receipt": "sample.channel.json", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05", + "guide": "sample.guide.xml", + "edits": [ + { + "file": "sample.guide.xml", + "find": "\n", + "replace": "" + } + ], + "facts": { + "verdict": "guide_malformed", + "part": "guide", + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5", + "guide": null, + "entry": null + } + }, + { + "name": "guide_source_mismatch", + "about": "a guide naming another schedule", + "receipt": "sample.channel.json", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05", + "guide": "sample.guide.xml", + "edits": [ + { + "file": "sample.guide.xml", + "find": "d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b83", + "replace": "0000000000000000000000000000000000000000000000000000000000000000" + } + ], + "facts": { + "verdict": "guide_source_mismatch", + "part": "guide", + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5", + "guide": { + "format": "xmltv", + "from_ms": 1793296800000, + "to_ms": 1793304000000, + "at_ms": null, + "programmes": [ + { + "start_ms": 1793296800000, + "stop_ms": 1793298600000, + "asset_id": "hl-101", + "aired": true + }, + { + "start_ms": 1793298600000, + "stop_ms": 1793300400000, + "asset_id": "tk-7", + "aired": true + }, + { + "start_ms": 1793300400000, + "stop_ms": 1793302200000, + "asset_id": "hl-102", + "aired": false + }, + { + "start_ms": 1793302200000, + "stop_ms": 1793304000000, + "asset_id": "nf-1", + "aired": false + } + ] + }, + "entry": null + } + }, + { + "name": "guide_entry_unscheduled", + "about": "a guide that places Night Freight where the schedule places Harbour Lights", + "receipt": "sample.channel.json", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05", + "guide": "sample.guide.xml", + "edits": [ + { + "file": "sample.guide.xml", + "find": ">hl-102", + "replace": ">nf-1" + } + ], + "facts": { + "verdict": "guide_entry_unscheduled", + "part": "guide", + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5", + "guide": { + "format": "xmltv", + "from_ms": 1793296800000, + "to_ms": 1793304000000, + "at_ms": null, + "programmes": [ + { + "start_ms": 1793296800000, + "stop_ms": 1793298600000, + "asset_id": "hl-101", + "aired": true + }, + { + "start_ms": 1793298600000, + "stop_ms": 1793300400000, + "asset_id": "tk-7", + "aired": true + }, + { + "start_ms": 1793300400000, + "stop_ms": 1793302200000, + "asset_id": "nf-1", + "aired": false + }, + { + "start_ms": 1793302200000, + "stop_ms": 1793304000000, + "asset_id": "nf-1", + "aired": false + } + ] + }, + "entry": 2 + } + }, + { + "name": "guide_mismatch", + "about": "a guide whose title the lineup does not state", + "receipt": "sample.channel.json", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05", + "guide": "sample.guide.xml", + "edits": [ + { + "file": "sample.guide.xml", + "find": "Night Freight", + "replace": "Night Freight Live" + } + ], + "facts": { + "verdict": "guide_mismatch", + "part": "guide", + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5", + "guide": { + "format": "xmltv", + "from_ms": 1793296800000, + "to_ms": 1793304000000, + "at_ms": null, + "programmes": [ + { + "start_ms": 1793296800000, + "stop_ms": 1793298600000, + "asset_id": "hl-101", + "aired": true + }, + { + "start_ms": 1793298600000, + "stop_ms": 1793300400000, + "asset_id": "tk-7", + "aired": true + }, + { + "start_ms": 1793300400000, + "stop_ms": 1793302200000, + "asset_id": "hl-102", + "aired": false + }, + { + "start_ms": 1793302200000, + "stop_ms": 1793304000000, + "asset_id": "nf-1", + "aired": false + } + ] + }, + "entry": null + } + }, + { + "name": "now_next_unscheduled", + "about": "a now/next document naming another programme on air", + "receipt": "sample.channel.json", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05", + "guide": "sample.now-next.json", + "edits": [ + { + "file": "sample.now-next.json", + "find": "\"asset_id\":\"tk-7\"", + "replace": "\"asset_id\":\"hl-101\"" + } + ], + "facts": { + "verdict": "guide_entry_unscheduled", + "part": "guide", + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5", + "guide": { + "format": "now-next", + "from_ms": null, + "to_ms": null, + "at_ms": 1793299545000, + "programmes": [ + { + "start_ms": 1793298600000, + "stop_ms": 1793300400000, + "asset_id": "hl-101", + "aired": true + }, + { + "start_ms": 1793300400000, + "stop_ms": 1793302200000, + "asset_id": "hl-102", + "aired": false + } + ] + }, + "entry": 0 + } + } + ] +} diff --git a/wai/registry/capabilities.json b/wai/registry/capabilities.json index 149f97aa6..22dacb01c 100644 --- a/wai/registry/capabilities.json +++ b/wai/registry/capabilities.json @@ -2295,6 +2295,20 @@ "edition": null, "registered": "2026-09-28" }, + { + "capability": "wai.video.lineup", + "kind": "record", + "status": "registered", + "media": null, + "role": null, + "tier": null, + "licence": null, + "replicate": null, + "sets": [], + "spec_section": "§5 Interactive, temporal & computed content", + "edition": null, + "registered": "2026-10-29" + }, { "capability": "wai.video.manifest", "kind": "record", @@ -2947,6 +2961,13 @@ "section": "§4.1", "status": "defined" }, + { + "name": "WLU1", + "kind": "magic", + "document": "extensions/channel-lineup.md", + "section": "§6.1", + "status": "defined" + }, { "name": "WMA1", "kind": "magic", @@ -3612,6 +3633,13 @@ "section": "§8.1", "status": "defined" }, + { + "name": "urn:wai:guide:", + "kind": "scheme", + "document": "extensions/channel-lineup.md", + "section": "§7.2", + "status": "defined" + }, { "name": "wai", "kind": "catalog", @@ -3647,6 +3675,13 @@ "section": "§5.3", "status": "defined" }, + { + "name": "wai-asset-id", + "kind": "scheme", + "document": "extensions/channel-lineup.md", + "section": "§7.2", + "status": "defined" + }, { "name": "wai-attack:", "kind": "scheme", @@ -3703,6 +3738,13 @@ "section": "§3.3", "status": "defined" }, + { + "name": "wai-channel-feed", + "kind": "document-kind", + "document": "extensions/channel-lineup.md", + "section": "§4.4", + "status": "defined" + }, { "name": "wai-collection", "kind": "document-kind", @@ -3850,6 +3892,13 @@ "section": "§8.4", "status": "defined" }, + { + "name": "wai-now-next", + "kind": "document-kind", + "document": "extensions/channel-lineup.md", + "section": "§7.3", + "status": "defined" + }, { "name": "wai-origin", "kind": "document-kind", diff --git a/wai/vocab/v2.ttl b/wai/vocab/v2.ttl index 734786dc6..7f7733152 100644 --- a/wai/vocab/v2.ttl +++ b/wai/vocab/v2.ttl @@ -1649,6 +1649,13 @@ wai:wai.video.lcevc a wai:Capability ; wai:specSection "§5 Broadcast and streaming codecs" ; wai:registeredOn "2026-09-28"^^xsd:date . +wai:wai.video.lineup a wai:Capability ; + rdfs:label "wai.video.lineup" ; + wai:kind wai:kind-record ; + wai:status wai:status-registered ; + wai:specSection "§5 Interactive, temporal & computed content" ; + wai:registeredOn "2026-10-29"^^xsd:date . + wai:wai.video.manifest a wai:Capability ; rdfs:label "wai.video.manifest" ; wai:kind wai:kind-record ; diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index a4db01b41..5687a37a2 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -159,7 +159,7 @@ floor = ["dep:image", "dep:claxon", "dep:zstd"] # extensions/fleet.md §7 verifies it), and `wai mcp`, the server # `wai setup-agent` registers. Pure Rust and the floor's zstd: no system library. `wai create`, # `wai dev` and `wai setup-agent` need no feature. -quickstart = ["floor", "http", "receipt_claims", "conform_result", "mcp", "fleet", "dep:getrandom"] +quickstart = ["floor", "http", "receipt_claims", "conform_result", "mcp", "fleet", "dep:getrandom", "channel_lineup"] # extensions/conformance-results.md: the record `conform-result/1`, its signature and verification. conform_result = ["ed25519"] # Pulls in the ONNX runtime + zstd unpack so the native sink can @@ -366,6 +366,14 @@ live_ads = ["live_room", "video_ssai", "dep:num-bigint"] # verify. ad-presentation's spans, viewability, quartile and attestation; live-compose's layout; # a reader of HLS attribute lists, asset lists and an XML subset of its own. No new dependency. stitched_presentation = ["live_ads", "live_compose"] +# Channel lineup and guide (extensions/channel-lineup): `wai.video.lineup` — a linear channel's +# signed lineup (WLU1) of each programme asset's description, artwork by content hash, geo +# availability and rights holder, bound to its `wai.video.channel` schedule by the schedule root; +# the import of an XMLTV schedule, Media RSS catalogues, a JSON form and a Media Playlist's SCTE-35 +# date ranges into that receipt, the lineup and the breaks' live-events cues; and the guide (XMLTV, +# and a now/next document) rendered from the receipt and the lineup, checkable by recomputation. +# Reads XML by stitched-presentation's subset and attribute lists by its reader. No new dependency. +channel_lineup = ["video_channel", "stitched_presentation"] # Live provenance (extensions/live-provenance): `wai.live.provenance` — the live video provenance # of C2PA 2.4 §19 for a room's CMAF tracks and a device's CMAF take, written and verified with no # C2PA library: ISO BMFF segments and the BMFF hash over whole-box exclusions, the JUMBF manifest @@ -1296,6 +1304,13 @@ name = "wai_stitched_presentation" path = "src/bin/wai_stitched_presentation.rs" required-features = ["stitched_presentation"] +# The corpus of extensions/channel-lineup.md §11 (`live-conformance/channels/`): `gen` writes it +# from its fixed seeds, `verify` requires byte-identical regeneration and every vector's outcome. +[[bin]] +name = "wai_channel_lineup_vectors" +path = "src/bin/wai_channel_lineup_vectors.rs" +required-features = ["channel_lineup"] + [[bin]] name = "wai_live_provenance_vectors" path = "src/bin/wai_live_provenance_vectors.rs" @@ -2312,6 +2327,11 @@ name = "stitched_presentation" path = "tests/stitched_presentation.rs" required-features = ["stitched_presentation"] +[[test]] +name = "channel_lineup" +path = "tests/channel_lineup.rs" +required-features = ["channel_lineup"] + [[test]] name = "live_provenance" path = "tests/live_provenance.rs" diff --git a/wai/wai-rs/src/bin/wai_channel_lineup_vectors.rs b/wai/wai-rs/src/bin/wai_channel_lineup_vectors.rs new file mode 100644 index 000000000..c097f74ef --- /dev/null +++ b/wai/wai-rs/src/bin/wai_channel_lineup_vectors.rs @@ -0,0 +1,89 @@ +//! The corpus of extensions/channel-lineup.md §11 (`live-conformance/channels/`). +//! +//! wai_channel_lineup_vectors gen write every file from the fixed seeds +//! wai_channel_lineup_vectors verify require byte-identical regeneration, no other +//! file but the README, and every vector's outcome +//! +//! Exit status 0 when it holds, 1 with one line a finding otherwise, 2 on a usage error. + +use std::collections::BTreeMap; +use std::path::Path; +use std::process::ExitCode; + +use wai::channel_lineup::corpus; + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + let (Some(mode), Some(dir)) = (args.first(), args.get(1)) else { + eprintln!("usage: wai_channel_lineup_vectors gen|verify "); + return ExitCode::from(2); + }; + let dir = Path::new(dir); + let generated = corpus::generate(); + match mode.as_str() { + "gen" => { + if let Err(e) = std::fs::create_dir_all(dir) { + eprintln!("{}: {e}", dir.display()); + return ExitCode::from(1); + } + for (name, body) in &generated { + if let Err(e) = std::fs::write(dir.join(name), body) { + eprintln!("{name}: {e}"); + return ExitCode::from(1); + } + } + println!("wrote {} files to {}", generated.len(), dir.display()); + ExitCode::SUCCESS + } + "verify" => { + let mut committed = BTreeMap::new(); + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(e) => { + eprintln!("{}: {e}", dir.display()); + return ExitCode::from(1); + } + }; + for e in entries.flatten() { + let name = e.file_name().to_string_lossy().into_owned(); + if name == "README.md" { + continue; + } + match std::fs::read_to_string(e.path()) { + Ok(s) => { + committed.insert(name, s); + } + Err(err) => { + eprintln!("{name}: {err}"); + return ExitCode::from(1); + } + } + } + let mut fails: Vec = Vec::new(); + for (name, body) in &generated { + match committed.get(name) { + None => fails.push(format!("{name}: missing")), + Some(c) if c != body => fails.push(format!("{name}: does not regenerate byte for byte")), + Some(_) => {} + } + } + for name in committed.keys().filter(|n| !generated.iter().any(|(g, _)| g == *n)) { + fails.push(format!("{name}: not a file of the corpus")); + } + fails.extend(corpus::verify(&committed)); + for f in &fails { + println!("{f}"); + } + if fails.is_empty() { + println!("channel lineup corpus: {} files regenerate and every vector holds", generated.len()); + ExitCode::SUCCESS + } else { + ExitCode::from(1) + } + } + _ => { + eprintln!("usage: wai_channel_lineup_vectors gen|verify "); + ExitCode::from(2) + } + } +} diff --git a/wai/wai-rs/src/bin/wai_cli/dx/channel.rs b/wai/wai-rs/src/bin/wai_cli/dx/channel.rs new file mode 100644 index 000000000..1ac107454 --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/dx/channel.rs @@ -0,0 +1,293 @@ +//! A linear channel's operations (extensions/channel-lineup.md §9): `wai channel import` +//! (`channel.import`), `wai channel guide` (`channel.guide`) and `wai channel verify` +//! (`channel.verify`). +//! +//! They keep the agent rules of [`super::envelope`], as every operation of the catalogue does: the +//! exit codes, the JSON envelope with `--json`, `--dry-run` (every file through `out.rs`, nothing +//! written), and `--yes` before a file that exists is replaced. An import's or a verification's +//! refusal has the text's reason as its code (`import_gap`, `guide_mismatch`, ...) and exit 1. + +#[cfg(feature = "channel_lineup")] +use super::envelope::EXIT_FAILED; +use super::envelope::{self as agent, EXIT_INTERNAL, EXIT_NOT_FOUND, EXIT_PERMISSION, OpError}; +use crate::out; +use serde_json::Value; +#[cfg(feature = "channel_lineup")] +use serde_json::json; + +type R = Result; + +/// Print what an operation did: its envelope with `--json`, its text otherwise; in a dry run of an +/// operation that writes, with the plan of what it would have done. +#[cfg_attr(not(feature = "channel_lineup"), allow(dead_code))] +fn done(op: &str, writes: bool, data: Value, text: &str) -> R { + let plan = (writes && out::dry_run()).then(out::plan); + if agent::ctx().json { + agent::print(&agent::envelope_with(op, true, data, plan)); + } else if plan.is_some() { + println!("{text}"); + println!("dry run: nothing was written. Without --dry-run, wai would:"); + for l in out::plan_lines() { + println!(" {l}"); + } + } else { + println!("{text}"); + } + Ok(true) +} + +#[cfg_attr(not(feature = "channel_lineup"), allow(dead_code))] +fn io_fail(path: &str, e: &std::io::Error, hint: &str) -> OpError { + match e.kind() { + std::io::ErrorKind::NotFound => OpError::new(EXIT_NOT_FOUND, "not-found", format!("{path}: {e}"), hint), + std::io::ErrorKind::PermissionDenied => OpError::new(EXIT_PERMISSION, "permission-denied", format!("{path}: {e}"), "the file's owner runs this"), + _ => OpError::new(EXIT_INTERNAL, "io-error", format!("{path}: {e}"), hint), + } +} + +#[cfg_attr(not(feature = "channel_lineup"), allow(dead_code))] +fn read(path: &str, what: &str) -> Result, OpError> { + std::fs::read(path).map_err(|e| io_fail(path, &e, &format!("give {what}"))) +} + +/// Every value of a flag that may be repeated, in order. +#[cfg_attr(not(feature = "channel_lineup"), allow(dead_code))] +fn all<'a>(args: &'a [String], flag: &str) -> Vec<&'a str> { + args.windows(2).filter(|w| w[0] == flag).map(|w| w[1].as_str()).collect() +} + +#[cfg_attr(not(feature = "channel_lineup"), allow(dead_code))] +fn number(args: &[String], flag: &str, usage: &str) -> Result, OpError> { + match super::opt(args, flag) { + None => Ok(None), + Some(v) => v.parse().map(Some).map_err(|_| OpError::usage(format!("{flag} {v}: milliseconds since 1970, an integer"), usage)), + } +} + +#[cfg_attr(not(feature = "channel_lineup"), allow(dead_code))] +fn unhex(s: &str) -> Option> { + if !s.len().is_multiple_of(2) { + return None; + } + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok()).collect() +} + +#[cfg_attr(not(feature = "channel_lineup"), allow(dead_code))] +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +/// A refusal of the text, as the toolchain's failure (exit 1, the reason as its code). +#[cfg(feature = "channel_lineup")] +fn refused(r: wai::channel_lineup::Reason, what: &str) -> OpError { + OpError::new(EXIT_FAILED, r.code(), format!("{what}: {r}"), "extensions/channel-lineup.md §10 names where each reason is raised") +} + +#[cfg(not(feature = "channel_lineup"))] +pub fn import(_: &[String]) -> R { + Err(crate::door::not_built(wai::catalogue::op("channel.import").expect("listed"))) +} + +#[cfg(not(feature = "channel_lineup"))] +pub fn guide(_: &[String]) -> R { + Err(crate::door::not_built(wai::catalogue::op("channel.guide").expect("listed"))) +} + +#[cfg(not(feature = "channel_lineup"))] +pub fn verify(_: &[String]) -> R { + Err(crate::door::not_built(wai::catalogue::op("channel.verify").expect("listed"))) +} + +/// Write `files`, each `(path, bytes)`; replacing one that exists waits for `--yes`. +#[cfg(feature = "channel_lineup")] +fn write_all(files: &[(String, Vec)]) -> Result<(), OpError> { + if !out::dry_run() { + let replaces: Vec<&String> = files.iter().map(|(p, _)| p).filter(|p| std::path::Path::new(p).exists()).collect(); + if !replaces.is_empty() { + let names: Vec<&str> = replaces.iter().map(|s| s.as_str()).collect(); + agent::confirm(&format!("{} exist(s), and would be replaced", names.join(", ")), replaces.iter().map(|p| json!({"write": p, "replaces": true})).collect())?; + } + } + for (p, b) in files { + out::write(p, b).map_err(|e| io_fail(p, &e, "check the directory"))?; + } + Ok(()) +} + +/// `wai channel import ...`: a channel's feeds into its receipt, lineup and cues (§4). +#[cfg(feature = "channel_lineup")] +pub fn import(args: &[String]) -> R { + use std::collections::BTreeMap; + use wai::channel_lineup::{import::{cues_json, import, Feeds}, receipt_text, MAX_DOCUMENT}; + let usage = super::usage("channel.import"); + let with_value = [ + "--channel", "--xmltv", "--mrss", "--mrss-owner", "--feed", "--playlist", "--key", "--signer-id", "--owner", "--id-system", "--xmltv-channel", + "--start-ms", "--artwork", "--window-start", "--window-end", "--parent", "--out-dir", + ]; + agent::check_args(args, &with_value, &with_value, 0, &usage)?; + let channel = super::opt(args, "--channel").ok_or_else(|| OpError::usage("--channel: the channel id", &usage))?; + let key_path = super::opt(args, "--key").ok_or_else(|| OpError::usage("--key: the operator's key file (32 bytes, or 64 hexadecimal digits)", &usage))?; + let raw = read(key_path, "the operator's key file")?; + let seed: [u8; 32] = if raw.len() == 32 { + raw.try_into().expect("32") + } else { + std::str::from_utf8(&raw).ok().and_then(|s| unhex(s.trim())).and_then(|b| b.try_into().ok()).ok_or_else(|| OpError::usage(format!("{key_path}: 32 bytes, or 64 hexadecimal digits"), "the operator key's seed"))? + }; + let key = ed25519_dalek::SigningKey::from_bytes(&seed); + let mrss = all(args, "--mrss"); + let owners = all(args, "--mrss-owner"); + if !owners.is_empty() && owners.len() != mrss.len() { + return Err(OpError::usage("--mrss-owner: none, or one for each --mrss, in their order", &usage)); + } + let mut feeds = Feeds { channel: channel.to_string(), ..Feeds::default() }; + feeds.xmltv = super::opt(args, "--xmltv").map(|p| read(p, "the XMLTV document")).transpose()?; + for (k, p) in mrss.iter().enumerate() { + feeds.mrss.push((read(p, "the Media RSS document")?, owners.get(k).map(|o| o.to_string()))); + } + feeds.json = super::opt(args, "--feed").map(|p| read(p, "the JSON form")).transpose()?; + feeds.playlist = super::opt(args, "--playlist").map(|p| read(p, "the Media Playlist")).transpose()?; + feeds.owner = super::opt(args, "--owner").map(str::to_string); + feeds.id_system = super::opt(args, "--id-system").map(str::to_string); + feeds.xmltv_channel = super::opt(args, "--xmltv-channel").map(str::to_string); + feeds.start_ms = number(args, "--start-ms", &usage)?; + feeds.window = match (number(args, "--window-start", &usage)?, number(args, "--window-end", &usage)?) { + (Some(s), Some(e)) => Some((s, e)), + (None, None) => None, + _ => return Err(OpError::usage("--window-start and --window-end: both, or neither", &usage)), + }; + if let Some(dir) = super::opt(args, "--artwork") { + let mut art = BTreeMap::new(); + for e in std::fs::read_dir(dir).map_err(|e| io_fail(dir, &e, "give the directory that holds the artwork"))?.flatten() { + if e.file_type().is_ok_and(|t| t.is_file()) { + use std::io::Read; + let mut b = Vec::new(); + let p = e.path(); + std::fs::File::open(&p).and_then(|f| f.take(MAX_DOCUMENT as u64 + 1).read_to_end(&mut b)).map_err(|x| io_fail(&p.display().to_string(), &x, "check the artwork"))?; + art.insert(e.file_name().to_string_lossy().into_owned(), b); + } + } + feeds.artwork = art; + } + let parent = match super::opt(args, "--parent") { + None => None, + Some(h) => Some(unhex(h).and_then(|b| <[u8; 32]>::try_from(b).ok()).ok_or_else(|| OpError::usage("--parent: a receipt hash, 64 hexadecimal digits", &usage))?), + }; + let did = wai::signer_id::did_key(&key.verifying_key().to_bytes()); + let signer_id = super::opt(args, "--signer-id").unwrap_or(&did); + let got = import(&feeds, &key, signer_id, parent).map_err(|r| refused(r, "the feeds are refused"))?; + let dir = std::path::Path::new(super::opt(args, "--out-dir").unwrap_or(".")); + let path = |n: &str| dir.join(n).display().to_string(); + let cues = serde_json::to_string_pretty(&cues_json(&got.cues)).expect("JSON") + "\n"; + let files = vec![ + (path("channel.json"), receipt_text(&got.receipt).into_bytes()), + (path("lineup.wlu"), got.lineup.to_bytes()), + (path("cues.json"), cues.into_bytes()), + ]; + if !dir.as_os_str().is_empty() { + out::create_dir_all(dir).map_err(|e| io_fail(&dir.display().to_string(), &e, "check the directory"))?; + } + write_all(&files)?; + let (r, l) = (&got.receipt, &got.lineup); + let data = json!({ + "channel": channel, + "receipt": files[0].0, "receipt_hash": hex(&r.receipt_hash()), "schedule_root": hex(&r.schedule_root), + "entries": r.schedule.len(), "window": [r.window_start_ms, r.window_end_ms], "aired": r.aired.len(), + "lineup": files[1].0, "lineup_digest": hex(&l.digest()), "programmes": l.programmes.len(), "assets": l.assets.len(), + "cues": files[2].0, "breaks": got.cues.len(), "signer_id": r.signer_id, + }); + let text = format!( + "imported {channel}: {} entries, {} programmes of {} assets, {} breaks; schedule root {}…, lineup {}…\n receipt {}\n lineup {}\n cues {}", + r.schedule.len(), + l.programmes.len(), + l.assets.len(), + got.cues.len(), + &hex(&r.schedule_root)[..16], + &hex(&l.digest())[..16], + files[0].0, + files[1].0, + files[2].0 + ); + done("channel.import", true, data, &text) +} + +/// The receipt and lineup a guide is made from, checked first (§8.2 steps 1 to 7). +#[cfg(feature = "channel_lineup")] +fn sources(args: &[String], usage: &str) -> Result<(Vec, Vec), OpError> { + let r = super::opt(args, "--receipt").ok_or_else(|| OpError::usage("--receipt: a channel receipt in its JSON carriage", usage))?; + let l = super::opt(args, "--lineup").ok_or_else(|| OpError::usage("--lineup: a lineup's file", usage))?; + Ok((read(r, "the receipt")?, read(l, "the lineup")?)) +} + +/// `wai channel guide --receipt R --lineup L ...`: the guide (§7). +#[cfg(feature = "channel_lineup")] +pub fn guide(args: &[String]) -> R { + use wai::channel_lineup::{now_next, read_receipt, verify, xmltv_guide, Lineup}; + let usage = super::usage("channel.guide"); + let with_value = ["--receipt", "--lineup", "--from", "--to", "--at", "--out"]; + let mut known = with_value.to_vec(); + known.push("--now-next"); + agent::check_args(args, &known, &with_value, 0, &usage)?; + let (rb, lb) = sources(args, &usage)?; + let v = verify(&rb, &lb, None); + if let Err(r) = v.verdict { + return Err(refused(r, "the receipt and the lineup do not verify")); + } + let r = read_receipt(&rb).expect("it verified"); + let l = Lineup::parse(&lb).expect("it verified"); + let now = args.iter().any(|a| a == "--now-next"); + let (body, format, data) = if now { + let at = number(args, "--at", &usage)?.ok_or_else(|| OpError::usage("--now-next takes --at, an instant in milliseconds since 1970", &usage))?; + let b = now_next(&r, &l, at).ok_or_else(|| OpError::usage("--at: at most 2^53 - 1", &usage))?; + (b, "now-next", json!({"at_ms": at})) + } else { + let from = number(args, "--from", &usage)?.unwrap_or(l.schedule_start_ms); + let to = number(args, "--to", &usage)?.unwrap_or(l.schedule_end_ms); + let b = xmltv_guide(&r, &l, from, to).ok_or_else(|| OpError::usage("--from and --to: a range, from below to, to at most 2^53 - 1", &usage))?; + (b, "xmltv", json!({"from_ms": from, "to_ms": to})) + }; + let file = super::opt(args, "--out").unwrap_or(if now { "now-next.json" } else { "guide.xml" }).to_string(); + write_all(&[(file.clone(), body.clone().into_bytes())])?; + let mut d = json!({"file": file, "format": format, "schedule_root": hex(&r.schedule_root), "lineup": hex(&l.digest()), "bytes": body.len()}); + for (k, x) in data.as_object().expect("an object") { + d[k] = x.clone(); + } + done("channel.guide", true, d, &format!("wrote the {format} guide of {} to {file}", l.channel_id)) +} + +/// `wai channel verify --receipt R --lineup L [--guide G]`: what §8.3 states. +#[cfg(feature = "channel_lineup")] +pub fn verify(args: &[String]) -> R { + use wai::channel_lineup::{verification_json, verify}; + let usage = super::usage("channel.verify"); + let with_value = ["--receipt", "--lineup", "--guide"]; + agent::check_args(args, &with_value, &with_value, 0, &usage)?; + let (rb, lb) = sources(args, &usage)?; + let g = super::opt(args, "--guide").map(|p| read(p, "the guide")).transpose()?; + let v = verify(&rb, &lb, g.as_deref()); + let facts = verification_json(&v); + let mut text = facts["verdict"].as_str().unwrap_or("?").to_string(); + if let Some(p) = facts["part"].as_str() { + text.push_str(&format!(" (the {p})")); + } + if let Some(h) = facts["receipt"].as_str() { + text.push_str(&format!("\n receipt {h}")); + } + if let Some(h) = facts["lineup"].as_str() { + text.push_str(&format!("\n lineup {h}")); + } + for p in facts["guide"]["programmes"].as_array().into_iter().flatten() { + text.push_str(&format!("\n {} to {} {}{}", p["start_ms"], p["stop_ms"], p["asset_id"].as_str().unwrap_or(""), if p["aired"] == true { " (aired in the window)" } else { "" })); + } + match v.verdict { + Ok(()) => done("channel.verify", false, facts, &text), + Err(r) => { + if agent::ctx().json { + agent::print(&agent::error_envelope_with(Some("channel.verify"), &refused(r, "the channel does not verify"), Some(facts))); + Err(OpError::reported(EXIT_FAILED)) + } else { + println!("{text}"); + Ok(false) + } + } + } +} diff --git a/wai/wai-rs/src/bin/wai_cli/dx/mod.rs b/wai/wai-rs/src/bin/wai_cli/dx/mod.rs index 12e35aa36..b17c6dff9 100644 --- a/wai/wai-rs/src/bin/wai_cli/dx/mod.rs +++ b/wai/wai-rs/src/bin/wai_cli/dx/mod.rs @@ -21,6 +21,9 @@ //! - `enrolment.create`, `enrolment.list` (`wai enrolment create|list`) and `report.create`, //! `report.export` (`wai report create|export`): a fleet's nodes enrolled and its reports, //! against a fleet service (extensions/fleet.md §9; `fleet.rs`); +//! - `channel.import`, `channel.guide` and `channel.verify` (`wai channel import|guide|verify`): +//! a linear channel's feeds imported into its signed receipt and lineup, its guide, and the +//! three checked together (extensions/channel-lineup.md §9; `channel.rs`). //! - `contract.create`, `contract.update` (`wai contract create|accept`) and `statement.create`, //! `statement.verify` (`wai statement create|verify`): a channel's share contract and the //! revenue-share statements recomputed from the ledger's invoices and the channel's @@ -42,6 +45,7 @@ pub mod project; mod app; mod breaking; +mod channel; #[cfg(feature = "quickstart")] pub(crate) mod conform; mod create; @@ -83,6 +87,9 @@ pub const VERBS: &[Impl] = &[ Impl { id: "enrolment.list", run: fleet::enrolment_list, json: Json::Native }, Impl { id: "report.create", run: fleet::report_create, json: Json::Native }, Impl { id: "report.export", run: fleet::report_export, json: Json::Native }, + Impl { id: "channel.import", run: channel::import, json: Json::Native }, + Impl { id: "channel.guide", run: channel::guide, json: Json::Native }, + Impl { id: "channel.verify", run: channel::verify, json: Json::Native }, // The revenue share (extensions/revenue-share.md §10). Impl { id: "contract.create", run: share::contract_create, json: Json::Native }, Impl { id: "contract.update", run: share::contract_update, json: Json::Native }, diff --git a/wai/wai-rs/src/catalogue.rs b/wai/wai-rs/src/catalogue.rs index 7ffb3b7ce..ebfdae9cb 100644 --- a/wai/wai-rs/src/catalogue.rs +++ b/wai/wai-rs/src/catalogue.rs @@ -309,6 +309,10 @@ const S_ENROLMENT: &str = r#"{"type":"object","required":["node","role","organis const S_ENROLMENTS: &str = r#"{"type":"object","required":["enrolments"],"description":"Each node the fleet service holds enrolled: its key, role, label and records"}"#; const S_REPORT: &str = r#"{"type":"object","required":["report","outcome"],"description":"The report's digest and its outcome as extensions/fleet.md §7.3 states it"}"#; const S_REPORT_EXPORT: &str = r#"{"type":"object","required":["file","outcome"],"description":"The bundle's file and its outcome, recomputed here"}"#; +// The channel operations' outputs (extensions/channel-lineup.md §9). +const S_CHANNEL_IMPORT: &str = r#"{"type":"object","required":["channel","receipt","receipt_hash","schedule_root","lineup","lineup_digest","cues"],"description":"The files written (the receipt, the lineup, the cues), the receipt's identity and schedule root, the lineup's digest, and their counts"}"#; +const S_CHANNEL_GUIDE: &str = r#"{"type":"object","required":["file","format","schedule_root","lineup"],"description":"The guide's file and format (xmltv or now-next), the sources it names, and its range or instant"}"#; +const S_CHANNEL_VERIFY: &str = r#"{"type":"object","required":["verdict","part","receipt","lineup","guide","entry"],"description":"What extensions/channel-lineup.md §8.3 states: the verdict and the part it is about, the receipt's identity, the lineup's digest, and the guide's programmes, each with whether it aired"}"#; const S_STAGE_APPLY: &str = r#"{"description":"The trace of every stage reached, as `wai stage apply --json` prints it"}"#; /// Every operation, in the order help lists them: the developer's path first. @@ -914,6 +918,82 @@ http vectors diff|fuzz --seed S --count N --out F", confirm: Some(Confirm::AnyFile), ..OP }, + // ─── linear channels (extensions/channel-lineup.md §9) ─── + Op { + id: "channel.import", + summary: "A linear channel's feeds (an XMLTV schedule, Media RSS catalogues or the JSON form, and a Media Playlist's SCTE-35 date ranges) imported into its signed receipt, its signed lineup and its breaks' cues", + cli: Some("channel import"), + usage: "channel import --channel ID (--xmltv F [--mrss F]... | --mrss F... | --feed F) [--playlist F] --key K + [--mrss-owner O]... [--owner O] [--signer-id S] [--id-system N] [--xmltv-channel X] [--start-ms T] [--artwork DIR] + [--window-start T --window-end T] [--parent HEX] [--out-dir D] [--yes] the receipt (channel.json), the lineup + (lineup.wlu) and the cues (cues.json), written to D (.); a refusal's code is its reason; replacing a file waits + for --yes (--confirm, deprecated, is the same)", + effect: Effect::WritesProject, + stability: Stability::Preview, + input: &[ + flag("channel", "--channel", Ty::String, true, "The channel id."), + flag("xmltv", "--xmltv", Ty::Path, false, "An XMLTV document: the schedule."), + flag("mrss", "--mrss", Ty::Strings, false, "A Media RSS document: a catalogue. Repeated."), + flag("mrss_owner", "--mrss-owner", Ty::Strings, false, "The rights holder of each --mrss document's items, in their order: none, or one each."), + flag("feed", "--feed", Ty::Path, false, "The JSON form of a schedule, its catalogue and its breaks."), + flag("playlist", "--playlist", Ty::Path, false, "The channel's HLS Media Playlist, whose SCTE-35 date ranges are its breaks."), + flag("key", "--key", Ty::Path, true, "The operator's key file: 32 bytes, or 64 hexadecimal digits."), + flag("owner", "--owner", Ty::String, false, "The rights holder of every asset no other input gives one."), + flag("signer_id", "--signer-id", Ty::String, false, "The receipt's signer id (the operator key's did:key)."), + flag("id_system", "--id-system", Ty::String, false, "The XMLTV episode-num system that names a programme's asset (wai-asset-id)."), + flag("xmltv_channel", "--xmltv-channel", Ty::String, false, "The XMLTV channel id whose programmes are read (the channel id)."), + flag("start_ms", "--start-ms", Ty::Integer, false, "Where a schedule of Media RSS items alone begins, milliseconds since 1970."), + flag("artwork", "--artwork", Ty::Path, false, "The directory holding the artwork files the feeds' icons and thumbnails name."), + flag("window_start", "--window-start", Ty::Integer, false, "The receipt's window's start (the schedule's first start)."), + flag("window_end", "--window-end", Ty::Integer, false, "The receipt's window's end (the schedule's last end)."), + flag("parent", "--parent", Ty::String, false, "The receipt hash of the window before this one, 64 hexadecimal digits."), + flag("out_dir", "--out-dir", Ty::Path, false, "Where channel.json, lineup.wlu and cues.json are written (.)."), + ], + output: Output::Json(S_CHANNEL_IMPORT), + receipt: true, + needs: Needs::All(&["channel_lineup"]), + confirm: Some(Confirm::AnyFile), + ..OP + }, + Op { + id: "channel.guide", + summary: "A channel's guide, an XMLTV document or a now/next document, rendered from its receipt and its lineup and naming both", + cli: Some("channel guide"), + usage: "channel guide --receipt R --lineup L [--from T --to T | --now-next --at T] [--out F] [--yes] the XMLTV guide + of a range (the whole schedule), or the now/next document of an instant, written to F (guide.xml, now-next.json); + replacing a file waits for --yes (--confirm, deprecated, is the same)", + effect: Effect::WritesProject, + stability: Stability::Preview, + input: &[ + flag("receipt", "--receipt", Ty::Path, true, "The channel receipt, in its JSON carriage."), + flag("lineup", "--lineup", Ty::Path, true, "The lineup's file."), + flag("from", "--from", Ty::Integer, false, "The range's start, milliseconds since 1970 (the schedule's first start)."), + flag("to", "--to", Ty::Integer, false, "The range's end (the schedule's last end)."), + switch("now_next", "--now-next", "The now/next document of --at instead."), + flag("at", "--at", Ty::Integer, false, "The instant of a now/next document, milliseconds since 1970."), + flag("out", "--out", Ty::Path, false, "The guide's file (guide.xml, or now-next.json)."), + ], + output: Output::Json(S_CHANNEL_GUIDE), + needs: Needs::All(&["channel_lineup"]), + confirm: Some(Confirm::AnyFile), + ..OP + }, + Op { + id: "channel.verify", + summary: "A channel's receipt, its lineup and a guide verified together: the guide recomputed from the two and compared, each programme with whether it aired", + cli: Some("channel verify"), + usage: "channel verify --receipt R --lineup L [--guide G] the receipt, the lineup and the guide checked together; + exit 1 with the reason when one does not verify", + stability: Stability::Preview, + input: &[ + flag("receipt", "--receipt", Ty::Path, true, "The channel receipt, in its JSON carriage."), + flag("lineup", "--lineup", Ty::Path, true, "The lineup's file."), + flag("guide", "--guide", Ty::Path, false, "An XMLTV guide or a now/next document."), + ], + output: Output::Json(S_CHANNEL_VERIFY), + needs: Needs::All(&["channel_lineup"]), + ..OP + }, // ─── the agent door ─── Op { id: "mcp.start", @@ -1695,6 +1775,7 @@ pub fn feature_on(feature: &str) -> Option { "app" => cfg!(feature = "app"), "fleet" => cfg!(feature = "fleet"), "revenue_share" => cfg!(feature = "revenue_share"), + "channel_lineup" => cfg!(feature = "channel_lineup"), "opportunity_report" => cfg!(feature = "opportunity_report"), _ => return None, }) diff --git a/wai/wai-rs/src/channel_lineup.rs b/wai/wai-rs/src/channel_lineup.rs new file mode 100644 index 000000000..795c07d05 --- /dev/null +++ b/wai/wai-rs/src/channel_lineup.rs @@ -0,0 +1,1122 @@ +//! Channel lineup and guide — `wai.video.lineup` (extensions/channel-lineup.md). +//! +//! A FAST channel's schedule and catalogue, as operators exchange them (an XMLTV schedule, +//! Media RSS catalogues, a JSON form of both, the SCTE-35 date ranges of the channel's HLS Media +//! Playlist), are imported ([`import`]) into a `wai.video.channel` receipt, whose signed bytes +//! do not change, and a signed **lineup**, `WLU1` ([`Lineup`]): each programme asset's +//! description, artwork by content hash, where it may be shown and its rights holder, and where +//! each programme of the guide begins, bound to the schedule by its root. The **guide** — an +//! XMLTV document ([`xmltv_guide`]) or a now/next document ([`now_next`]) — is rendered from the +//! receipt and the lineup and names both, so [`verify`] recomputes it and compares. +//! +//! Every function here is a pure function of the bytes it is given: no I/O, no clock and no +//! floating point. The corpus of §11 is [`corpus`]. + +pub mod corpus; +pub mod import; + +use ed25519_dalek::{Signer, SigningKey}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; + +use crate::stitched_presentation::markers::{read_xml, Element}; +use crate::strict_json::{self, Json}; +use crate::wai_video_channel::{ChannelReceipt, EntryKind, ScheduleEntry}; + +/// The record this module registers (SPEC §5). +pub const CAPABILITY: &str = "wai.video.lineup"; +/// The lineup's magic (§6.1). +pub const MAGIC: &[u8; 4] = b"WLU1"; +/// The lineup's version (§6.1). +pub const VERSION: u8 = 1; +/// The lineup's signing domain (§6.3). +const SIG_DOMAIN: &[u8] = b"wai:channel-lineup\x01"; +/// The XMLTV `episode-num` system a guide writes, and the import's default `id_system` (§7.2). +pub const ID_SYSTEM: &str = "wai-asset-id"; +/// The prefix of a guide's `source-data-url` (§7.2). +pub const GUIDE_URN: &str = "urn:wai:guide:"; +/// The `kind` of a now/next document (§7.3). +pub const NOW_NEXT_KIND: &str = "wai-now-next"; +/// The `kind` of the JSON form (§4.4). +pub const FEED_KIND: &str = "wai-channel-feed"; +/// The most bytes of a feed or an artwork file (§3.1). +pub const MAX_DOCUMENT: usize = 16_777_216; +/// The most programmes, items, assets or breaks (§3.1), and the lineup's counts (§6.1). +pub const MAX_COUNT: usize = 10_000; +/// The most categories and artworks of an asset (§6.2). +pub const MAX_CATEGORIES: usize = 16; +pub const MAX_ARTWORK: usize = 8; +/// The largest instant or duration this text reads or writes (§2): what JSON carries exactly. +pub const MAX_INSTANT: u64 = (1 << 53) - 1; + +// --------------------------------------------------------------------------------------- +// §10 Reasons +// --------------------------------------------------------------------------------------- + +/// A reason of §10, or one of live-events' this text reuses. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Reason { + ImportInputsInvalid, + ImportOversized, + ImportXmlMalformed, + ImportJsonMalformed, + ImportPlaylistMalformed, + ImportTimeMalformed, + ImportTimezoneUnknown, + ImportNoStop, + ImportEmpty, + ImportAssetUnknown, + ImportAssetDuplicate, + ImportFieldInvalid, + ImportScte35Malformed, + ImportBreakDuration, + ImportDurationUnknown, + ImportGap, + ImportOverlap, + ImportOwnerUnknown, + ImportArtworkMissing, + ImportBreakOutside, + ImportWindowOutside, + ChannelReceiptMalformed, + ChannelReceiptInvalid, + LineupMalformed, + LineupSignerMismatch, + LineupScheduleMismatch, + GuideMalformed, + GuideSourceMismatch, + GuideEntryUnscheduled, + GuideMismatch, + BadKey, + BadSignature, +} + +impl Reason { + /// The codes this text adds, in §10's order. + pub const ADDED: &'static [Reason] = &[ + Reason::ImportInputsInvalid, + Reason::ImportOversized, + Reason::ImportXmlMalformed, + Reason::ImportJsonMalformed, + Reason::ImportPlaylistMalformed, + Reason::ImportTimeMalformed, + Reason::ImportTimezoneUnknown, + Reason::ImportNoStop, + Reason::ImportEmpty, + Reason::ImportAssetUnknown, + Reason::ImportAssetDuplicate, + Reason::ImportFieldInvalid, + Reason::ImportScte35Malformed, + Reason::ImportBreakDuration, + Reason::ImportDurationUnknown, + Reason::ImportGap, + Reason::ImportOverlap, + Reason::ImportOwnerUnknown, + Reason::ImportArtworkMissing, + Reason::ImportBreakOutside, + Reason::ImportWindowOutside, + Reason::ChannelReceiptMalformed, + Reason::ChannelReceiptInvalid, + Reason::LineupMalformed, + Reason::LineupSignerMismatch, + Reason::LineupScheduleMismatch, + Reason::GuideMalformed, + Reason::GuideSourceMismatch, + Reason::GuideEntryUnscheduled, + Reason::GuideMismatch, + ]; + /// live-events' codes a verification also gives (§10). + pub const REUSED: &'static [Reason] = &[Reason::BadKey, Reason::BadSignature]; + + /// The stable code. + pub fn code(self) -> &'static str { + match self { + Reason::ImportInputsInvalid => "import_inputs_invalid", + Reason::ImportOversized => "import_oversized", + Reason::ImportXmlMalformed => "import_xml_malformed", + Reason::ImportJsonMalformed => "import_json_malformed", + Reason::ImportPlaylistMalformed => "import_playlist_malformed", + Reason::ImportTimeMalformed => "import_time_malformed", + Reason::ImportTimezoneUnknown => "import_timezone_unknown", + Reason::ImportNoStop => "import_no_stop", + Reason::ImportEmpty => "import_empty", + Reason::ImportAssetUnknown => "import_asset_unknown", + Reason::ImportAssetDuplicate => "import_asset_duplicate", + Reason::ImportFieldInvalid => "import_field_invalid", + Reason::ImportScte35Malformed => "import_scte35_malformed", + Reason::ImportBreakDuration => "import_break_duration", + Reason::ImportDurationUnknown => "import_duration_unknown", + Reason::ImportGap => "import_gap", + Reason::ImportOverlap => "import_overlap", + Reason::ImportOwnerUnknown => "import_owner_unknown", + Reason::ImportArtworkMissing => "import_artwork_missing", + Reason::ImportBreakOutside => "import_break_outside", + Reason::ImportWindowOutside => "import_window_outside", + Reason::ChannelReceiptMalformed => "channel_receipt_malformed", + Reason::ChannelReceiptInvalid => "channel_receipt_invalid", + Reason::LineupMalformed => "lineup_malformed", + Reason::LineupSignerMismatch => "lineup_signer_mismatch", + Reason::LineupScheduleMismatch => "lineup_schedule_mismatch", + Reason::GuideMalformed => "guide_malformed", + Reason::GuideSourceMismatch => "guide_source_mismatch", + Reason::GuideEntryUnscheduled => "guide_entry_unscheduled", + Reason::GuideMismatch => "guide_mismatch", + Reason::BadKey => "bad_key", + Reason::BadSignature => "bad_signature", + } + } + + /// The reason of a code, among those this module gives. + pub fn from_code(code: &str) -> Option { + Reason::ADDED.iter().chain(Reason::REUSED).copied().find(|r| r.code() == code) + } +} + +impl std::fmt::Display for Reason { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.code()) + } +} + +impl std::error::Error for Reason {} + +fn sig_reason(e: crate::sig::SigError) -> Reason { + match e { + crate::sig::SigError::BadKey => Reason::BadKey, + _ => Reason::BadSignature, + } +} + +// --------------------------------------------------------------------------------------- +// §3.2 Text values, §3.4 times +// --------------------------------------------------------------------------------------- + +/// A text (§3.2): no character below U+0020 but tab and line feed. +pub fn is_text(s: &str) -> bool { + s.chars().all(|c| c >= ' ' || c == '\t' || c == '\n') +} + +/// A short text (§3.2): a text of 1 to 255 bytes. +pub fn is_short(s: &str) -> bool { + (1..=255).contains(&s.len()) && is_text(s) +} + +/// A long text (§3.2): a text of 0 to 65 535 bytes. +pub fn is_long(s: &str) -> bool { + s.len() <= 65_535 && is_text(s) +} + +/// Days since 1970-01-01 of a Gregorian date. +fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { + let y = if m <= 2 { y - 1 } else { y }; + let era = y.div_euclid(400); + let yoe = y - era * 400; + let mp = (m + 9) % 12; + let doy = (153 * mp + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe - 719_468 +} + +fn days_in_month(y: i64, m: i64) -> i64 { + match m { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + _ if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29, + _ => 28, + } +} + +fn digits(s: &str) -> Option { + (!s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())).then(|| s.parse().ok()).flatten() +} + +/// The instant of a date and time (year, month, day, hour, minute, second, millisecond) with +/// an offset in minutes east of UTC (§3.4), or `None` when a field is out of range or the instant +/// is before 1970. +fn instant([y, mo, d, h, mi, s, ms]: [i64; 7], offset_min: i64) -> Option { + if y < 1970 || !(1..=12).contains(&mo) || d < 1 || d > days_in_month(y, mo) || h > 23 || mi > 59 || s > 59 { + return None; + } + let secs = days_from_civil(y, mo, d) * 86_400 + h * 3600 + mi * 60 + s - offset_min * 60; + let total = secs.checked_mul(1000)?.checked_add(ms)?; + u64::try_from(total).ok() +} + +/// An XMLTV time (§3.4), as an instant. +pub fn xmltv_time(v: &str) -> Result { + let b = v.as_bytes(); + if b.len() < 14 || !b[..14].iter().all(u8::is_ascii_digit) { + return Err(Reason::ImportTimeMalformed); + } + let rest = &v[14..]; + let offset = if rest.is_empty() { + 0 + } else if let Some(z) = rest.strip_prefix(' ') { + let zb = z.as_bytes(); + let numeric = zb.len() == 5 && (zb[0] == b'+' || zb[0] == b'-') && zb[1..].iter().all(u8::is_ascii_digit); + let (hh, mm) = if numeric { (digits(&z[1..3]).unwrap_or(99), digits(&z[3..5]).unwrap_or(99)) } else { (99, 99) }; + if !numeric || hh > 23 || mm > 59 { + return Err(Reason::ImportTimezoneUnknown); + } + let m = hh * 60 + mm; + if zb[0] == b'-' { -m } else { m } + } else { + return Err(Reason::ImportTimeMalformed); + }; + let f = |a: usize, b: usize| digits(&v[a..b]).expect("digits"); + instant([f(0, 4), f(4, 6), f(6, 8), f(8, 10), f(10, 12), f(12, 14), 0], offset).ok_or(Reason::ImportTimeMalformed) +} + +/// An instant as an XMLTV time in UTC with its zone written (§7.2): `YYYYMMDDhhmmss +0000`. +pub fn xmltv_format(ms: u64) -> String { + let secs = (ms / 1000) as i64; + let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); + let (y, m, d) = civil_from_days(days); + format!("{y:04}{m:02}{d:02}{:02}{:02}{:02} +0000", rem / 3600, rem % 3600 / 60, rem % 60) +} + +fn civil_from_days(z: i64) -> (i64, i64, i64) { + let z = z + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + (if m <= 2 { y + 1 } else { y }, m, d) +} + +/// An HLS date-time (§3.4), as an instant. +pub fn hls_date_time(v: &str) -> Result { + let b = v.as_bytes(); + let malformed = Err(Reason::ImportTimeMalformed); + if b.len() < 19 || !b.is_ascii() { + return malformed; + } + let shape = |i: usize, c: u8| b[i] == c; + let dig = |r: std::ops::Range| b[r].iter().all(u8::is_ascii_digit); + if !(dig(0..4) && shape(4, b'-') && dig(5..7) && shape(7, b'-') && dig(8..10) && shape(10, b'T') && dig(11..13) && shape(13, b':') && dig(14..16) && shape(16, b':') && dig(17..19)) { + return malformed; + } + let mut p = 19; + let mut ms = 0i64; + if p < b.len() && b[p] == b'.' { + let start = p + 1; + let mut end = start; + while end < b.len() && b[end].is_ascii_digit() { + end += 1; + } + if end == start || b[start + 3.min(end - start)..end].iter().any(|&c| c != b'0') { + return malformed; + } + let kept = &v[start..end.min(start + 3)]; + ms = digits(&format!("{kept:0<3}")).expect("digits"); + p = end; + } + let offset = match &v[p..] { + "" => return Err(Reason::ImportTimezoneUnknown), + "Z" => 0, + z if z.len() == 6 && (z.starts_with('+') || z.starts_with('-')) && z.as_bytes()[3] == b':' && z[1..3].bytes().all(|c| c.is_ascii_digit()) && z[4..6].bytes().all(|c| c.is_ascii_digit()) => { + let (hh, mm) = (digits(&z[1..3]).expect("digits"), digits(&z[4..6]).expect("digits")); + if hh > 23 || mm > 59 { + return malformed; + } + if z.starts_with('-') { -(hh * 60 + mm) } else { hh * 60 + mm } + } + _ => return malformed, + }; + let f = |a: usize, b: usize| digits(&v[a..b]).expect("digits"); + match instant([f(0, 4), f(5, 7), f(8, 10), f(11, 13), f(14, 16), f(17, 19), ms], offset) { + Some(t) if t <= MAX_INSTANT => Ok(t), + _ => malformed, + } +} + +/// A duration (§3.4): decimal-floating-point seconds, as milliseconds. +pub fn duration_ms(v: &str) -> Result { + match crate::stitched_presentation::markers::decimal_scaled(v, 3) { + Some(ms) if ms <= MAX_INSTANT => Ok(ms), + _ => Err(Reason::ImportTimeMalformed), + } +} + +// --------------------------------------------------------------------------------------- +// §3.3 The XML subset, with a document type declaration skipped +// --------------------------------------------------------------------------------------- + +/// The end of a document type declaration of §3.3 that begins at `at`, or `None` when the +/// bytes there are not one. +fn doctype_end(b: &[u8], at: usize) -> Option { + let ws = |c: u8| matches!(c, b' ' | b'\t' | b'\r' | b'\n'); + let skip = |mut p: usize| { + while p < b.len() && ws(b[p]) { + p += 1; + } + p + }; + let literal = |p: usize| -> Option { + let q = *b.get(p)?; + if q != b'"' && q != b'\'' { + return None; + } + let close = b[p + 1..].iter().position(|&c| c == q)?; + Some(p + 1 + close + 1) + }; + let mut p = at + b" p { + if b[after..].starts_with(b"SYSTEM") { + let q = skip(after + 6); + if q == after + 6 { + return None; + } + p = literal(q)?; + } else if b[after..].starts_with(b"PUBLIC") { + let q = skip(after + 6); + if q == after + 6 { + return None; + } + let r = literal(q)?; + let s = skip(r); + if s == r { + return None; + } + p = literal(s)?; + } else { + p = after; + } + } + p = skip(p); + (b.get(p) == Some(&b'>')).then_some(p + 1) +} + +/// Read an XML document over the subset of §3.3: stitched-presentation §3.5's, with one +/// document type declaration of no internal subset skipped before the root. +pub fn read_feed_xml(b: &[u8]) -> Option { + let mut b = b.to_vec(); + let mut p = 0; + loop { + while p < b.len() && matches!(b[p], b' ' | b'\t' | b'\r' | b'\n') { + p += 1; + } + let rest = &b[p..]; + if rest.starts_with(b"")? + 3; + } else if rest.starts_with(b"")? + 2; + } else if rest.starts_with(b"(e: &'a Element, name: &'a str) -> Option<&'a Element> { + e.children_named(name).next() +} + +// --------------------------------------------------------------------------------------- +// §5 The channel receipt's JSON carriage: revenue-share §4, `ChannelReceipt::to_json` and +// `from_json` +// --------------------------------------------------------------------------------------- + +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +/// Lowercase hexadecimal of an even count of digits, as bytes. +pub(crate) fn hex_bytes(s: &str) -> Option> { + if !s.len().is_multiple_of(2) || !s.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) { + return None; + } + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()).collect() +} + +fn unhex(s: &str) -> Option<[u8; N]> { + if s.len() != 2 * N || !s.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) { + return None; + } + let mut out = [0u8; N]; + for (i, o) in out.iter_mut().enumerate() { + *o = u8::from_str_radix(&s[2 * i..2 * i + 2], 16).ok()?; + } + Some(out) +} + +/// A receipt's JSON carriage as a file holds it (§5): `ChannelReceipt::to_json`, revenue-share +/// §4.1's one JSON form of a channel receipt, indented, one line feed after it. +pub fn receipt_text(r: &ChannelReceipt) -> String { + serde_json::to_string_pretty(&r.to_json()).expect("JSON") + "\n" +} + +/// Read a receipt's bytes in its JSON carriage (§5): one JSON text, read by +/// `ChannelReceipt::from_json`, revenue-share §4.1's reading. +pub fn read_receipt(bytes: &[u8]) -> Option { + serde_json::from_slice::(bytes).ok().and_then(|v| ChannelReceipt::from_json(&v)) +} + +/// An object whose members are exactly `want`, each once. +fn exactly<'a>(j: &'a Json, want: &[&str]) -> Option<&'a [(String, Json)]> { + let m = j.as_obj()?; + (m.len() == want.len() && want.iter().all(|k| m.iter().any(|(n, _)| n == k))).then_some(m) +} + +/// A receipt's verdict (§8.2 steps 2 and 3): its signature, then its rules. +pub fn receipt_verdict(r: &ChannelReceipt) -> Result<(), Reason> { + crate::sig::verify_strict(&r.signer_pubkey, &r.payload(), &r.sig).map_err(sig_reason)?; + if r.verify() { Ok(()) } else { Err(Reason::ChannelReceiptInvalid) } +} + +// --------------------------------------------------------------------------------------- +// §6 The lineup +// --------------------------------------------------------------------------------------- + +/// Where an asset may be shown (§6.2 `geo`). +pub mod geo { + pub const EVERYWHERE: u8 = 0; + pub const ONLY: u8 = 1; + pub const EXCEPT: u8 = 2; +} + +/// An asset of a lineup (§6.2). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Asset { + pub asset_id: String, + pub title: String, + pub description: String, + pub categories: Vec, + pub rating_system: String, + pub rating_value: String, + /// Each artwork: the SHA-256 of its bytes, and its URI. + pub artwork: Vec<([u8; 32], String)>, + pub geo: u8, + pub countries: Vec<[u8; 2]>, + pub rights_holder: String, + pub content_uri: String, + pub duration_ms: u64, +} + +/// A lineup, `WLU1` (§6.1). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Lineup { + pub channel_id: String, + pub channel_name: String, + pub schedule_root: [u8; 32], + pub schedule_start_ms: u64, + pub schedule_end_ms: u64, + /// Each programme's start, in order. + pub programmes: Vec, + /// In the order of their asset ids' bytes. + pub assets: Vec, + pub signer: [u8; 32], + pub sig: [u8; 64], +} + +fn short(o: &mut Vec, s: &str) { + o.push(u8::try_from(s.len()).expect("a short field")); + o.extend_from_slice(s.as_bytes()); +} + +fn long(o: &mut Vec, s: &str) { + o.extend_from_slice(&u16::try_from(s.len()).expect("a long field").to_le_bytes()); + o.extend_from_slice(s.as_bytes()); +} + +impl Lineup { + /// The bytes from `magic` through `signer` (§6.3). + fn body(&self) -> Vec { + let mut o = MAGIC.to_vec(); + o.push(VERSION); + short(&mut o, &self.channel_id); + short(&mut o, &self.channel_name); + o.extend_from_slice(&self.schedule_root); + o.extend_from_slice(&self.schedule_start_ms.to_le_bytes()); + o.extend_from_slice(&self.schedule_end_ms.to_le_bytes()); + o.extend_from_slice(&(self.programmes.len() as u32).to_le_bytes()); + for p in &self.programmes { + o.extend_from_slice(&p.to_le_bytes()); + } + o.extend_from_slice(&(self.assets.len() as u32).to_le_bytes()); + for a in &self.assets { + short(&mut o, &a.asset_id); + long(&mut o, &a.title); + long(&mut o, &a.description); + o.push(a.categories.len() as u8); + for c in &a.categories { + short(&mut o, c); + } + o.push(a.rating_system.len() as u8); + o.extend_from_slice(a.rating_system.as_bytes()); + o.push(a.rating_value.len() as u8); + o.extend_from_slice(a.rating_value.as_bytes()); + o.push(a.artwork.len() as u8); + for (h, uri) in &a.artwork { + o.extend_from_slice(h); + long(&mut o, uri); + } + o.push(a.geo); + o.push(a.countries.len() as u8); + for c in &a.countries { + o.extend_from_slice(c); + } + short(&mut o, &a.rights_holder); + long(&mut o, &a.content_uri); + o.extend_from_slice(&a.duration_ms.to_le_bytes()); + } + o.extend_from_slice(&self.signer); + o + } + + /// The lineup's bytes (§6.1). + pub fn to_bytes(&self) -> Vec { + let mut o = self.body(); + o.extend_from_slice(&self.sig); + o + } + + /// Its digest (§6.3). + pub fn digest(&self) -> [u8; 32] { + Sha256::digest(self.body()).into() + } + + /// Sign it with `key` (§6.3), which becomes its `signer`. + pub fn sign(mut self, key: &SigningKey) -> Lineup { + self.signer = key.verifying_key().to_bytes(); + self.sig = key.sign(&self.signing_input()).to_bytes(); + self + } + + /// What the signature covers (§6.3). + pub fn signing_input(&self) -> Vec { + let mut m = SIG_DOMAIN.to_vec(); + m.extend_from_slice(&self.body()); + m + } + + /// Its signature, verified strictly (§6.3). + pub fn verify_signature(&self) -> Result<(), Reason> { + crate::sig::verify_strict(&self.signer, &self.signing_input(), &self.sig).map_err(sig_reason) + } + + /// Read a lineup (§6.4): exactly these bytes, by its rules. + pub fn parse(b: &[u8]) -> Result { + read_lineup(b).ok_or(Reason::LineupMalformed) + } + + /// The asset of an asset id. + pub fn asset(&self, id: &str) -> Option<&Asset> { + self.assets.binary_search_by(|a| a.asset_id.as_bytes().cmp(id.as_bytes())).ok().map(|i| &self.assets[i]) + } + + /// Each programme's span (§6.1). + pub fn spans(&self) -> Vec<(u64, u64)> { + self.programmes.iter().enumerate().map(|(k, &s)| (s, self.programmes.get(k + 1).copied().unwrap_or(self.schedule_end_ms))).collect() + } +} + +struct Cursor<'a> { + b: &'a [u8], + p: usize, +} + +impl<'a> Cursor<'a> { + fn take(&mut self, n: usize) -> Option<&'a [u8]> { + let s = self.b.get(self.p..self.p.checked_add(n)?)?; + self.p += n; + Some(s) + } + fn u8(&mut self) -> Option { + Some(self.take(1)?[0]) + } + fn u16(&mut self) -> Option { + Some(u16::from_le_bytes(self.take(2)?.try_into().ok()?)) + } + fn u32(&mut self) -> Option { + Some(u32::from_le_bytes(self.take(4)?.try_into().ok()?)) + } + fn u64(&mut self) -> Option { + Some(u64::from_le_bytes(self.take(8)?.try_into().ok()?)) + } + fn arr(&mut self) -> Option<[u8; N]> { + self.take(N)?.try_into().ok() + } + fn short(&mut self) -> Option { + let n = self.u8()? as usize; + String::from_utf8(self.take(n)?.to_vec()).ok() + } + fn long(&mut self) -> Option { + let n = self.u16()? as usize; + String::from_utf8(self.take(n)?.to_vec()).ok() + } +} + +fn read_lineup(b: &[u8]) -> Option { + let mut c = Cursor { b, p: 0 }; + if c.take(4)? != MAGIC || c.u8()? != VERSION { + return None; + } + let channel_id = c.short()?; + let channel_name = c.short()?; + if !is_short(&channel_id) || !is_short(&channel_name) { + return None; + } + let schedule_root = c.arr::<32>()?; + let (schedule_start_ms, schedule_end_ms) = (c.u64()?, c.u64()?); + if schedule_start_ms >= schedule_end_ms || !schedule_start_ms.is_multiple_of(1000) || !schedule_end_ms.is_multiple_of(1000) || schedule_end_ms > MAX_INSTANT { + return None; + } + let p = c.u32()? as usize; + if p == 0 || p > MAX_COUNT { + return None; + } + let programmes = (0..p).map(|_| c.u64()).collect::>>()?; + if programmes[0] != schedule_start_ms + || *programmes.last()? >= schedule_end_ms + || !programmes.windows(2).all(|w| w[0] < w[1]) + || !programmes.iter().all(|s| s.is_multiple_of(1000)) + { + return None; + } + let a = c.u32()? as usize; + if a == 0 || a > MAX_COUNT { + return None; + } + let mut assets: Vec = Vec::with_capacity(a); + for _ in 0..a { + let asset_id = c.short()?; + let title = c.long()?; + let description = c.long()?; + let n = c.u8()? as usize; + if n > MAX_CATEGORIES { + return None; + } + let categories = (0..n).map(|_| c.short()).collect::>>()?; + let rating_system = c.short()?; + let rating_value = c.short()?; + let k = c.u8()? as usize; + if k > MAX_ARTWORK { + return None; + } + let artwork = (0..k).map(|_| Some((c.arr::<32>()?, c.long()?))).collect::>>()?; + let g = c.u8()?; + let n = c.u8()? as usize; + let countries = (0..n).map(|_| c.arr::<2>()).collect::>>()?; + let rights_holder = c.short()?; + let content_uri = c.long()?; + let duration_ms = c.u64()?; + let ok = is_short(&asset_id) + && !title.is_empty() + && is_long(&title) + && is_long(&description) + && categories.iter().all(|x| is_short(x)) + && (rating_system.is_empty() || is_short(&rating_system)) + && (rating_value.is_empty() || is_short(&rating_value)) + && !(rating_value.is_empty() && !rating_system.is_empty()) + && artwork.iter().all(|(_, u)| !u.is_empty() && is_long(u)) + && g <= geo::EXCEPT + && !(g == geo::EVERYWHERE && !countries.is_empty()) + && !(g == geo::EXCEPT && countries.is_empty()) + && countries.iter().all(|x| x.iter().all(u8::is_ascii_uppercase)) + && countries.windows(2).all(|w| w[0] < w[1]) + && is_short(&rights_holder) + && is_long(&content_uri) + && duration_ms <= MAX_INSTANT; + if !ok || assets.last().is_some_and(|l| l.asset_id.as_bytes() >= asset_id.as_bytes()) { + return None; + } + assets.push(Asset { asset_id, title, description, categories, rating_system, rating_value, artwork, geo: g, countries, rights_holder, content_uri, duration_ms }); + } + let signer = c.arr::<32>()?; + let sig = c.arr::<64>()?; + (c.p == b.len()).then_some(Lineup { channel_id, channel_name, schedule_root, schedule_start_ms, schedule_end_ms, programmes, assets, signer, sig }) +} + +/// Each programme's asset (§6.5 rule 4): the one asset id of the program entries that start +/// in its span; `None` when a programme holds none, or two. +pub fn programme_assets(l: &Lineup, schedule: &[ScheduleEntry]) -> Option> { + l.spans() + .iter() + .map(|&(s, e)| { + let mut ids = schedule.iter().filter(|x| x.kind == EntryKind::Program && x.start_ms >= s && x.start_ms < e).map(|x| x.asset_id.as_str()); + let id = ids.next()?; + ids.all(|o| o == id).then(|| id.to_string()) + }) + .collect() +} + +/// Whether a lineup describes a receipt's schedule (§6.5). +pub fn describes(l: &Lineup, r: &ChannelReceipt) -> bool { + let (Some(first), Some(last)) = (r.schedule.first(), r.schedule.last()) else { return false }; + if l.channel_id != r.channel_id || l.schedule_root != r.schedule_root || l.schedule_start_ms != first.start_ms || l.schedule_end_ms != last.end_ms() { + return false; + } + if !l.programmes.iter().all(|p| r.schedule.iter().any(|e| e.start_ms == *p)) { + return false; + } + let Some(ids) = programme_assets(l, &r.schedule) else { return false }; + let mut want: Vec<&str> = ids.iter().map(String::as_str).collect(); + want.sort_unstable_by(|a, b| a.as_bytes().cmp(b.as_bytes())); + want.dedup(); + let have: Vec<&str> = l.assets.iter().map(|a| a.asset_id.as_str()).collect(); + want == have +} + +// --------------------------------------------------------------------------------------- +// §7 The guide +// --------------------------------------------------------------------------------------- + +/// A programme of a guide (§7.1): its span and its asset. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Programme<'a> { + pub start_ms: u64, + pub stop_ms: u64, + pub asset: &'a Asset, +} + +/// Every programme of a lineup that describes `r`, with its asset; `None` when it does not. +pub fn programmes<'a>(r: &ChannelReceipt, l: &'a Lineup) -> Option>> { + if !describes(l, r) { + return None; + } + let ids = programme_assets(l, &r.schedule)?; + l.spans().into_iter().zip(ids).map(|((s, e), id)| Some(Programme { start_ms: s, stop_ms: e, asset: l.asset(&id)? })).collect() +} + +/// The programmes of a range (§7.1). +pub fn programmes_of<'a>(all: &[Programme<'a>], from: u64, to: u64) -> Vec> { + all.iter().filter(|p| p.start_ms < to && p.stop_ms > from).cloned().collect() +} + +fn esc_text(s: &str) -> String { + let mut o = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => o.push_str("&"), + '<' => o.push_str("<"), + '>' => o.push_str(">"), + _ => o.push(c), + } + } + o +} + +fn esc_attr(s: &str) -> String { + let mut o = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => o.push_str("&"), + '<' => o.push_str("<"), + '>' => o.push_str(">"), + '"' => o.push_str("""), + '\t' => o.push_str(" "), + '\n' => o.push_str(" "), + _ => o.push(c), + } + } + o +} + +/// The XMLTV guide of `[from, to)` (§7.2), or `None` when the lineup does not describe the +/// receipt's schedule, or the range is empty or ends past what a guide writes. +pub fn xmltv_guide(r: &ChannelReceipt, l: &Lineup, from: u64, to: u64) -> Option { + if from >= to || to > MAX_INSTANT { + return None; + } + let all = programmes(r, l)?; + let ch = esc_attr(&l.channel_id); + let mut o = String::new(); + o.push_str("\n"); + o.push_str(&format!("\n", hex(&r.schedule_root), hex(&l.digest()))); + o.push_str(&format!(" \n {}\n \n", esc_text(&l.channel_name))); + for p in programmes_of(&all, from, to) { + let a = p.asset; + o.push_str(&format!(" \n", xmltv_format(p.start_ms), xmltv_format(p.stop_ms))); + o.push_str(&format!(" {}\n", esc_text(&a.title))); + if !a.description.is_empty() { + o.push_str(&format!(" {}\n", esc_text(&a.description))); + } + for c in &a.categories { + o.push_str(&format!(" {}\n", esc_text(c))); + } + for (_, uri) in &a.artwork { + o.push_str(&format!(" \n", esc_attr(uri))); + } + o.push_str(&format!(" {}\n", esc_text(&a.asset_id))); + if !a.rating_value.is_empty() { + if a.rating_system.is_empty() { + o.push_str(" \n"); + } else { + o.push_str(&format!(" \n", esc_attr(&a.rating_system))); + } + o.push_str(&format!(" {}\n \n", esc_text(&a.rating_value))); + } + o.push_str(" \n"); + } + o.push_str("\n"); + Some(o) +} + +/// A programme as the now/next document writes it (§7.3). +fn programme_json(p: &Programme<'_>) -> Value { + let a = p.asset; + let codes = || Value::Array(a.countries.iter().map(|c| json!(String::from_utf8_lossy(c))).collect()); + json!({ + "start_ms": p.start_ms, + "stop_ms": p.stop_ms, + "asset_id": a.asset_id, + "title": a.title, + "description": a.description, + "content_uri": a.content_uri, + "categories": a.categories, + "rating": if a.rating_value.is_empty() { Value::Null } else { json!({"system": a.rating_system, "value": a.rating_value}) }, + "artwork": a.artwork.iter().map(|(h, u)| json!({"sha256": hex(h), "uri": u})).collect::>(), + "geo": match a.geo { geo::ONLY => json!({"allow": codes()}), geo::EXCEPT => json!({"deny": codes()}), _ => Value::Null }, + "rights_holder": a.rights_holder, + "duration_ms": a.duration_ms, + }) +} + +/// The programmes on air at `at` and next (§7.3). +pub fn now_and_next<'a>(all: &[Programme<'a>], at: u64) -> (Option>, Option>) { + match all.iter().position(|p| p.start_ms <= at && at < p.stop_ms) { + Some(k) => (Some(all[k].clone()), all.get(k + 1).cloned()), + None if all.first().is_some_and(|p| at < p.start_ms) => (None, Some(all[0].clone())), + None => (None, None), + } +} + +/// The now/next document of `at` (§7.3), or `None` when the lineup does not describe the +/// receipt's schedule or `at` is past what a guide writes. +pub fn now_next(r: &ChannelReceipt, l: &Lineup, at: u64) -> Option { + if at > MAX_INSTANT { + return None; + } + let all = programmes(r, l)?; + let (now, next) = now_and_next(&all, at); + let v = json!({ + "kind": NOW_NEXT_KIND, + "channel": l.channel_id, + "channel_name": l.channel_name, + "schedule_root": hex(&r.schedule_root), + "lineup": hex(&l.digest()), + "at_ms": at, + "now": now.as_ref().map_or(Value::Null, programme_json), + "next": next.as_ref().map_or(Value::Null, programme_json), + }); + let mut s = crate::jcs::jcs(&v).ok()?; + s.push('\n'); + Some(s) +} + +// --------------------------------------------------------------------------------------- +// §8 Verification +// --------------------------------------------------------------------------------------- + +/// A guide as §8.2 step 8 reads it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Guide { + /// An XMLTV guide: its sources, its range, and each programme's start, end and asset id. + Xmltv { root: String, lineup: String, from: u64, to: u64, programmes: Vec<(u64, u64, String)> }, + /// A now/next document: its sources, its instant, and `now` and `next`. + NowNext { root: String, lineup: String, at: u64, now: Option<(u64, u64, String)>, next: Option<(u64, u64, String)> }, +} + +fn plain_uint(s: &str) -> Option { + (!s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) && (s == "0" || !s.starts_with('0'))).then(|| s.parse().ok()).flatten() +} + +fn is_hex64(s: &str) -> bool { + s.len() == 64 && s.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) +} + +/// Read a guide (§8.2 step 8). +pub fn read_guide(b: &[u8]) -> Option { + match b.first()? { + b'<' => { + let root = read_feed_xml(b)?; + if root.name != "tv" { + return None; + } + let url = root.attr("source-data-url")?.strip_prefix(GUIDE_URN)?; + let parts: Vec<&str> = url.split(':').collect(); + let [r, l, f, t] = parts.as_slice() else { return None }; + let (from, to) = (plain_uint(f)?, plain_uint(t)?); + if !is_hex64(r) || !is_hex64(l) || from >= to { + return None; + } + let mut programmes = Vec::new(); + for p in root.children_named("programme") { + let start = xmltv_time(p.attr("start")?).ok()?; + let stop = xmltv_time(p.attr("stop")?).ok()?; + let id = p.children_named("episode-num").find(|e| e.attr("system").unwrap_or("onscreen") == ID_SYSTEM)?.text(); + programmes.push((start, stop, id)); + } + Some(Guide::Xmltv { root: r.to_string(), lineup: l.to_string(), from, to, programmes }) + } + b'{' => { + let j = strict_json::read(b).ok()?; + exactly(&j, &["kind", "channel", "channel_name", "schedule_root", "lineup", "at_ms", "now", "next"])?; + let at = j.get("at_ms")?.as_u64().filter(|&a| a <= MAX_INSTANT)?; + let entry = |k: &str| -> Option> { + match j.get(k)? { + Json::Null => Some(None), + o @ Json::Obj(_) => Some(Some((o.get("start_ms")?.as_u64()?, o.get("stop_ms")?.as_u64()?, o.get("asset_id")?.as_str()?.to_string()))), + _ => None, + } + }; + Some(Guide::NowNext { + root: j.get("schedule_root")?.as_str()?.to_string(), + lineup: j.get("lineup")?.as_str()?.to_string(), + at, + now: entry("now")?, + next: entry("next")?, + }) + } + _ => None, + } +} + +/// What a verification states (§8.3). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Verification { + /// `Ok` when verified, otherwise the reason. + pub verdict: Result<(), Reason>, + /// The part the verdict is about: `receipt`, `lineup` or `guide`. + pub part: Option<&'static str>, + /// The receipt's identity, when it reads. + pub receipt: Option<[u8; 32]>, + /// The lineup's digest, when it reads. + pub lineup: Option<[u8; 32]>, + /// For a guide that reads: its format, its range or instant, and its programmes, each + /// `(start, end, asset id, aired)`. + pub guide: Option, + /// For `guide_entry_unscheduled`, the index of step 10. + pub entry: Option, +} + +/// A guide's facts (§8.3). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GuideFacts { + pub format: &'static str, + pub range: Option<(u64, u64)>, + pub at: Option, + pub programmes: Vec<(u64, u64, String, bool)>, +} + +/// Verify a receipt, a lineup and optionally a guide (§8.2). +pub fn verify(receipt: &[u8], lineup: &[u8], guide: Option<&[u8]>) -> Verification { + let mut v = Verification { verdict: Ok(()), part: None, receipt: None, lineup: None, guide: None, entry: None }; + let fail = |mut v: Verification, part: &'static str, r: Reason| { + v.verdict = Err(r); + v.part = Some(part); + v + }; + let Some(r) = read_receipt(receipt) else { return fail(v, "receipt", Reason::ChannelReceiptMalformed) }; + v.receipt = Some(r.receipt_hash()); + if let Err(e) = receipt_verdict(&r) { + return fail(v, "receipt", e); + } + let l = match Lineup::parse(lineup) { + Ok(l) => l, + Err(e) => return fail(v, "lineup", e), + }; + v.lineup = Some(l.digest()); + if let Err(e) = l.verify_signature() { + return fail(v, "lineup", e); + } + if l.signer != r.signer_pubkey { + return fail(v, "lineup", Reason::LineupSignerMismatch); + } + if !describes(&l, &r) { + return fail(v, "lineup", Reason::LineupScheduleMismatch); + } + let Some(g) = guide else { return v }; + let Some(read) = read_guide(g) else { return fail(v, "guide", Reason::GuideMalformed) }; + let aired = |s: u64, e: u64| r.aired.iter().filter_map(|&i| r.schedule.get(i as usize)).any(|x| x.start_ms < e && x.end_ms() > s); + let all = programmes(&r, &l).expect("the lineup describes the schedule"); + let key = |p: &Programme<'_>| (p.start_ms, p.stop_ms, p.asset.asset_id.clone()); + let (root, ldig) = (hex(&r.schedule_root), hex(&l.digest())); + match &read { + Guide::Xmltv { root: gr, lineup: gl, from, to, programmes: ps } => { + v.guide = Some(GuideFacts { + format: "xmltv", + range: Some((*from, *to)), + at: None, + programmes: ps.iter().map(|(s, e, id)| (*s, *e, id.clone(), aired(*s, *e))).collect(), + }); + if *gr != root || *gl != ldig { + return fail(v, "guide", Reason::GuideSourceMismatch); + } + let want: Vec<(u64, u64, String)> = programmes_of(&all, *from, *to).iter().map(key).collect(); + if let Some(i) = ps.iter().position(|p| !want.contains(p)) { + v.entry = Some(i); + return fail(v, "guide", Reason::GuideEntryUnscheduled); + } + if xmltv_guide(&r, &l, *from, *to).as_deref().map(str::as_bytes) != Some(g) { + return fail(v, "guide", Reason::GuideMismatch); + } + } + Guide::NowNext { root: gr, lineup: gl, at, now, next } => { + v.guide = Some(GuideFacts { + format: "now-next", + range: None, + at: Some(*at), + programmes: [now, next].into_iter().flatten().map(|(s, e, id)| (*s, *e, id.clone(), aired(*s, *e))).collect(), + }); + if *gr != root || *gl != ldig { + return fail(v, "guide", Reason::GuideSourceMismatch); + } + let (wn, wx) = now_and_next(&all, *at); + for (i, (got, want)) in [(now, wn.as_ref().map(key)), (next, wx.as_ref().map(key))].into_iter().enumerate() { + if *got != want { + v.entry = Some(i); + return fail(v, "guide", Reason::GuideEntryUnscheduled); + } + } + if now_next(&r, &l, *at).as_deref().map(str::as_bytes) != Some(g) { + return fail(v, "guide", Reason::GuideMismatch); + } + } + } + v +} + +/// A verification as JSON (§8.3; the corpus's `facts`). +pub fn verification_json(v: &Verification) -> Value { + json!({ + "verdict": v.verdict.err().map_or("verified", Reason::code), + "part": v.part, + "receipt": v.receipt.map(|h| hex(&h)), + "lineup": v.lineup.map(|h| hex(&h)), + "guide": v.guide.as_ref().map(|g| json!({ + "format": g.format, + "from_ms": g.range.map(|r| r.0), + "to_ms": g.range.map(|r| r.1), + "at_ms": g.at, + "programmes": g.programmes.iter().map(|(s, e, id, a)| json!({"start_ms": s, "stop_ms": e, "asset_id": id, "aired": a})).collect::>(), + })), + "entry": v.entry, + }) +} + +#[cfg(test)] +mod tests; diff --git a/wai/wai-rs/src/channel_lineup/corpus.rs b/wai/wai-rs/src/channel_lineup/corpus.rs new file mode 100644 index 000000000..44399dbe2 --- /dev/null +++ b/wai/wai-rs/src/channel_lineup/corpus.rs @@ -0,0 +1,689 @@ +//! The corpus of extensions/channel-lineup.md §11 (`live-conformance/channels/`). +//! +//! [`generate`] writes every file from fixed seeds: a sample channel's feeds (an XMLTV document +//! with a second channel beside it, two content owners' Media RSS documents, a Media Playlist +//! with SCTE-35 date ranges whose sections carry their CRC, and the JSON form of the same +//! channel), its artwork, the receipt and guides it yields, and `vectors.json`: each import's +//! inputs, the edit that makes it a control, and its outcome; and each verification's inputs and +//! facts. It asserts that every vector gets the outcome it was written for and that the corpus +//! gives every reason of §10 and both of live-events'. [`verify`] requires every vector's +//! outcome from committed files. + +use std::collections::{BTreeMap, BTreeSet}; + +use ed25519_dalek::{Signer, SigningKey}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; + +use super::import::{import, Feeds, Imported}; +use super::{hex, now_next, receipt_text, verification_json, verify as verify_all, xmltv_guide, Lineup, Reason}; + +/// 2026-10-29T18:00:00Z, the sample's first programme. +const T0: u64 = 1_793_296_800_000; +const MIN: u64 = 60_000; +/// 2026-10-30T00:00:00Z, where the Media-RSS-only channel begins. +const LOOP_START: u64 = 1_793_318_400_000; + +fn seed(label: &str) -> SigningKey { + SigningKey::from_bytes(&Sha256::digest(format!("wai channel-lineup corpus: {label}").as_bytes()).into()) +} + +/// The operator, whose key signs the receipts and lineups. +pub fn operator() -> SigningKey { + seed("operator") +} + +// --------------------------------------------------------------------------------------- +// SCTE-35 sections, with their CRC (SCTE 35-1 §9.6, Table 5) +// --------------------------------------------------------------------------------------- + +fn crc32_mpeg2(b: &[u8]) -> u32 { + let mut c = 0xFFFF_FFFFu32; + for &x in b { + c ^= u32::from(x) << 24; + for _ in 0..8 { + c = if c & 0x8000_0000 != 0 { (c << 1) ^ 0x04C1_1DB7 } else { c << 1 }; + } + } + c +} + +/// A `splice_info_section()` around a command: `protocol_version` 0; no encryption and +/// `pts_adjustment` 0 (the 40 bits after it); `cw_index` 0xFF; `tier` 0xFFF; no descriptor; +/// and its CRC_32. Its `splice_command_type` is byte 13, as in SCTE 35-1 §14's samples. +fn section(command_type: u8, command: &[u8]) -> Vec { + let mut body = vec![0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF]; + let len = command.len() as u32; + body.extend_from_slice(&[0xFF, (0xF0 | (len >> 8) as u8 & 0x0F), len as u8, command_type]); + body.extend_from_slice(command); + body.extend_from_slice(&[0x00, 0x00]); + let section_length = (body.len() + 4) as u16; + let mut s = vec![0xFC, 0x30 | (section_length >> 8) as u8 & 0x0F, section_length as u8]; + s.extend_from_slice(&body); + let crc = crc32_mpeg2(&s); + s.extend_from_slice(&crc.to_be_bytes()); + s +} + +fn pts(flag_bits: u8, v: u64) -> [u8; 5] { + [flag_bits | ((v >> 32) as u8 & 1), (v >> 24) as u8, (v >> 16) as u8, (v >> 8) as u8, v as u8] +} + +/// A `splice_insert()` (SCTE 35-1 §9.7.3): out of the network with a break duration, or back in. +fn splice_insert(event_id: u32, out: bool, duration_ms: u64, at_ms: u64) -> Vec { + let mut c = event_id.to_be_bytes().to_vec(); + c.push(0x7F); + c.push(if out { 0xE0 } else { 0x40 } | 0x08 | 0x07); + c.extend_from_slice(&pts(0xFE, at_ms * 90 % (1 << 33))); + if out { + c.extend_from_slice(&pts(0xFE, duration_ms * 90)); + } + c.extend_from_slice(&[0x00, 0x01, 0x00, 0x00]); + section(0x05, &c) +} + +/// A `time_signal()` (SCTE 35-1 §9.7.4). +fn time_signal(at_ms: u64) -> Vec { + section(0x06, &pts(0xFE, at_ms * 90 % (1 << 33))) +} + +fn hexu(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02X}")).collect() +} + +// --------------------------------------------------------------------------------------- +// The sample channel +// --------------------------------------------------------------------------------------- + +fn art(name: &str) -> String { + format!("https://art.example/sample/{name}?w=320") +} + +fn svg(label: &str, fill: &str) -> String { + format!( + "{label}\n" + ) +} + +/// The artwork files. +fn artwork() -> Vec<(String, String)> { + vec![ + ("art-hl-101.svg".into(), svg("HL 101", "#1d4e89")), + ("art-hl-102.svg".into(), svg("HL 102", "#1d4e89")), + ("art-nf-1.svg".into(), svg("NF 1", "#3b3b3b")), + ("art-tk-7.svg".into(), svg("TK 7", "#2f7d4a")), + ] +} + +fn xmltv() -> String { + format!( + r#" + + + + Sample One + + + Sample Two + + + Harbour Lights + A lighthouse keeper's first winter. + Drama + + 0.0. + hl-101 + + + Tidepool Kitchen + tk-7 + + + Harbour Lights + hl-102 + + + Night Freight + Overnight on the coast line & the yards it feeds. + Documentary + + nf-1 + + TV-14 + + + + Another Channel's Hour + other-1 + + +"#, + a1 = art("art-hl-101.svg").replace('&', "&"), + a4 = art("art-nf-1.svg").replace('&', "&"), + ) +} + +fn mrss_a() -> String { + format!( + r#" + + + Owner A titles + https://owner-a.example/ + Owner A's catalogue for linear channels. + + Harbour Lights + hl-101 + + Harbour Lights + A lighthouse keeper's first winter. + Drama + + tv-pg + us ca gb + + + hl-102 + + + + + Harbour Lights + The supply boat is late. + Drama + + tv-pg + de + + + hl-103 + + Harbour Lights + + + +"#, + a1 = art("art-hl-101.svg").replace('&', "&"), + a2 = art("art-hl-102.svg").replace('&', "&"), + ) +} + +fn mrss_b() -> String { + format!( + r#" + + + Owner B kitchen + + tk-7 + + Tidepool Kitchen + Kelp, crab & lemon, on a beach stove. + Food + Family + + none + + + +"#, + a3 = art("art-tk-7.svg").replace('&', "&"), + ) +} + +/// The breaks: id, start, duration, the out section, the in section. +fn breaks() -> Vec<(&'static str, u64, u64, Vec)> { + vec![ + ("brk-1", T0 + 12 * MIN, 90_000, splice_insert(1001, true, 90_000, 12 * MIN)), + ("brk-2", T0 + 28 * MIN, 120_000, time_signal(28 * MIN)), + ("brk-3", T0 + 45 * MIN + 30_500, 60_000, splice_insert(1003, true, 60_000, 45 * MIN + 30_500)), + ("brk-4", T0 + 74 * MIN, 90_000, splice_insert(1004, true, 90_000, 74 * MIN)), + ] +} + +fn playlist() -> String { + let b = breaks(); + let date = |ms: u64| { + let s = super::xmltv_format(ms); + format!("{}-{}-{}T{}:{}:{}.{:03}Z", &s[0..4], &s[4..6], &s[6..8], &s[8..10], &s[10..12], &s[12..14], ms % 1000) + }; + let mut o = String::from("#EXTM3U\n#EXT-X-VERSION:6\n#EXT-X-TARGETDURATION:6\n#EXT-X-MEDIA-SEQUENCE:120\n"); + o.push_str(&format!("#EXT-X-PROGRAM-DATE-TIME:{}\n#EXTINF:6.000,\nseg-120.ts\n", date(T0 + 12 * MIN - 6000))); + o.push_str(&format!("#EXT-X-DATERANGE:ID=\"brk-1\",START-DATE=\"{}\",PLANNED-DURATION=90.000,SCTE35-OUT=0x{}\n", date(b[0].1), hexu(&b[0].3))); + o.push_str("#EXTINF:6.000,\nseg-121.ts\n"); + o.push_str(&format!( + "#EXT-X-DATERANGE:ID=\"brk-1\",START-DATE=\"{}\",DURATION=90.000,SCTE35-IN=0x{}\n", + date(b[0].1), + hexu(&splice_insert(1001, false, 0, 12 * MIN + 90_000)) + )); + o.push_str(&format!("#EXT-X-DATERANGE:ID=\"chapter-2\",CLASS=\"sample.chapter\",START-DATE=\"{}\"\n", date(T0 + 20 * MIN))); + o.push_str(&format!("#EXT-X-DATERANGE:ID=\"brk-2\",START-DATE=\"{}\",DURATION=120.000,SCTE35-OUT=0x{}\n", date(b[1].1), hexu(&b[1].3))); + o.push_str(&format!("#EXT-X-DATERANGE:ID=\"brk-3\",START-DATE=\"{}\",PLANNED-DURATION=60,SCTE35-OUT=0x{}\n", date(b[2].1), hexu(&b[2].3).to_lowercase())); + o.push_str(&format!( + "#EXT-X-DATERANGE:ID=\"brk-4\",START-DATE=\"2026-10-29T20:14:00+01:00\",END-DATE=\"{}\",SCTE35-OUT=0X{}\n", + date(b[3].1 + b[3].2), + hexu(&b[3].3) + )); + o.push_str("#EXTINF:6.000,\nseg-122.ts\n"); + o +} + +/// The JSON form of the same channel: what the XMLTV document, the Media RSS documents and +/// the playlist say, in one document, with the artwork's hashes. +fn json_form(l: &Lineup) -> String { + let progs = [(0, 30, "hl-101"), (30, 60, "tk-7"), (60, 90, "hl-102"), (90, 120, "nf-1")]; + let assets: Vec = l + .assets + .iter() + .map(|a| { + json!({ + "asset_id": a.asset_id, + "title": a.title, + "description": a.description, + "content_uri": a.content_uri, + "categories": a.categories, + "rating": if a.rating_value.is_empty() { Value::Null } else { json!({"system": a.rating_system, "value": a.rating_value}) }, + "artwork": a.artwork.iter().map(|(h, u)| json!({"uri": u, "sha256": hex(h)})).collect::>(), + "geo": match a.geo { + super::geo::ONLY => json!({"allow": a.countries.iter().map(|c| String::from_utf8_lossy(c).into_owned()).collect::>()}), + super::geo::EXCEPT => json!({"deny": a.countries.iter().map(|c| String::from_utf8_lossy(c).into_owned()).collect::>()}), + _ => Value::Null, + }, + "rights_holder": a.rights_holder, + "duration_ms": a.duration_ms, + }) + }) + .collect(); + let doc = json!({ + "kind": super::FEED_KIND, + "channel_name": l.channel_name, + "programmes": progs.iter().map(|(s, e, id)| json!({"start_ms": T0 + s * MIN, "stop_ms": T0 + e * MIN, "asset_id": id})).collect::>(), + "assets": assets, + "breaks": breaks().iter().map(|(id, s, d, sec)| json!({"id": id, "start_ms": s, "duration_ms": d, "scte35": hex(sec)})).collect::>(), + }); + serde_json::to_string_pretty(&doc).expect("JSON") + "\n" +} + +// --------------------------------------------------------------------------------------- +// Inputs, edits and outcomes +// --------------------------------------------------------------------------------------- + +/// Apply a vector's edits to a copy of the files it reads: each `{"file", "find", "replace"}` +/// replaces the one occurrence of `find`; `{"file", "find", "replace", "repeat": n}` writes +/// `replace` n times after it instead; and each `{"file", "pad"}` appends that many spaces. +pub fn apply_edits(files: &BTreeMap>, edits: &Value) -> Result>, String> { + let mut out = files.clone(); + for e in edits.as_array().ok_or("edits: an array")? { + let f = e["file"].as_str().ok_or("an edit's file")?; + let b = out.get_mut(f).ok_or_else(|| format!("{f}: not in the corpus"))?; + if let Some(n) = e["pad"].as_u64() { + b.extend(std::iter::repeat_n(b' ', n as usize)); + continue; + } + let (find, replace) = (e["find"].as_str().ok_or("find")?, e["replace"].as_str().ok_or("replace")?); + let s = String::from_utf8(b.clone()).map_err(|_| format!("{f}: not UTF-8"))?; + if s.matches(find).count() != 1 { + return Err(format!("{f}: {find:?} does not occur once")); + } + let with = match e["repeat"].as_u64() { + Some(n) => format!("{find}{}", replace.repeat(n as usize)), + None => replace.to_string(), + }; + *b = s.replacen(find, &with, 1).into_bytes(); + } + Ok(out) +} + +fn opt_str(v: &Value) -> Option { + v.as_str().map(str::to_string) +} + +/// The feeds a vector's inputs name, from the files given. +pub fn feeds_of(inputs: &Value, files: &BTreeMap>) -> Result { + let file = |v: &Value| -> Result>, String> { + match v.as_str() { + None => Ok(None), + Some(n) => files.get(n).cloned().map(Some).ok_or_else(|| format!("{n}: not in the corpus")), + } + }; + let mut mrss = Vec::new(); + for m in inputs["mrss"].as_array().ok_or("mrss: an array")? { + mrss.push((file(&m["file"])?.ok_or("an mrss file")?, opt_str(&m["owner"]))); + } + let mut artwork = BTreeMap::new(); + for n in inputs["artwork"].as_array().ok_or("artwork: an array")? { + let n = n.as_str().ok_or("an artwork name")?; + artwork.insert(n.to_string(), files.get(n).cloned().ok_or_else(|| format!("{n}: not in the corpus"))?); + } + let window = match &inputs["window"] { + Value::Array(w) if w.len() == 2 => Some((w[0].as_u64().ok_or("window")?, w[1].as_u64().ok_or("window")?)), + Value::Null => None, + _ => return Err("window: null or two instants".into()), + }; + Ok(Feeds { + channel: inputs["channel"].as_str().ok_or("channel")?.to_string(), + xmltv: file(&inputs["xmltv"])?, + mrss, + json: file(&inputs["json"])?, + playlist: file(&inputs["playlist"])?, + owner: opt_str(&inputs["owner"]), + id_system: opt_str(&inputs["id_system"]), + xmltv_channel: opt_str(&inputs["xmltv_channel"]), + start_ms: inputs["start_ms"].as_u64(), + artwork, + window, + }) +} + +/// Run a vector's import over the files given. +pub fn run_import(v: &Value, files: &BTreeMap>) -> Result, String> { + let files = apply_edits(files, &v["edits"])?; + let inputs = &v["inputs"]; + let feeds = feeds_of(inputs, &files)?; + let parent = match inputs["parent_receipt_hash"].as_str() { + None => None, + Some(h) => Some(super::unhex::<32>(h).ok_or("parent_receipt_hash")?), + }; + Ok(import(&feeds, &operator(), inputs["signer_id"].as_str().ok_or("signer_id")?, parent)) +} + +fn cues_json(i: &Imported) -> Value { + super::import::cues_json(&i.cues) +} + +fn inputs(over: Value) -> Value { + let mut base = json!({ + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [{"file": "sample-a.mrss.xml", "owner": "owner-a"}, {"file": "sample-b.mrss.xml", "owner": "owner-b"}], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": artwork().iter().map(|(n, _)| n.clone()).collect::>(), + "window": [T0, T0 + 60 * MIN], + "signer_id": "sample-operator", + "parent_receipt_hash": null, + }); + for (k, v) in over.as_object().expect("an object") { + base[k] = v.clone(); + } + base +} + +fn edit(file: &str, find: &str, replace: &str) -> Value { + json!({"file": file, "find": find, "replace": replace}) +} + +/// Each import vector: its name, what it shows, its inputs, its edits, and the refusal it was +/// written for (none for an import that yields). +fn import_vectors() -> Vec { + use Reason::*; + let (x, a, b, p, j) = ("sample.xmltv.xml", "sample-a.mrss.xml", "sample-b.mrss.xml", "sample.m3u8", "sample.json"); + let loop_inputs = || inputs(json!({"xmltv": null, "mrss": [{"file": a, "owner": "owner-a"}], "playlist": null, "owner": null, "start_ms": LOOP_START, "window": null})); + vec![ + ("sample", "the sample channel from its XMLTV schedule (beside another channel's), two owners' Media RSS catalogues and the playlist's SCTE-35 date ranges, its first hour the receipt's window", inputs(json!({})), json!([]), None), + ("sample_json", "the same channel from the JSON form alone: the same receipt and lineup, byte for byte", inputs(json!({"xmltv": null, "mrss": [], "json": j, "playlist": null, "owner": null, "artwork": []})), json!([]), None), + ("media_rss_loop", "one owner's Media RSS items alone, back to back from a start, with no break", loop_inputs(), json!([]), None), + ("oversized_count", "the JSON form with more than 10 000 breaks", inputs(json!({"xmltv": null, "mrss": [], "json": j, "playlist": null, "owner": null, "artwork": []})), json!([{"file": j, "find": "\"breaks\": [", "replace": "{\"id\": \"x\", \"start_ms\": 0, \"duration_ms\": 1, \"scte35\": null}, ", "repeat": 10_000}]), Some(ImportOversized)), + ("not_whole_second", "a programme boundary half a second past a whole second, which an XMLTV guide cannot state", inputs(json!({"xmltv": null, "mrss": [], "json": j, "playlist": null, "owner": null, "artwork": []})), json!([edit(j, "\"stop_ms\": 1793298600000", "\"stop_ms\": 1793298600500"), edit(j, "\"start_ms\": 1793298600000", "\"start_ms\": 1793298600500")]), Some(ImportTimeMalformed)), + ("break_overlap", "a break that begins before the one before it ends", inputs(json!({})), json!([edit(p, "ID=\"brk-2\",START-DATE=\"2026-10-29T18:28:00.000Z\"", "ID=\"brk-2\",START-DATE=\"2026-10-29T18:13:00.000Z\"")]), Some(ImportOverlap)), + ("inputs_invalid", "an XMLTV document beside the JSON form", inputs(json!({"json": j})), json!([]), Some(ImportInputsInvalid)), + ("oversized", "an XMLTV document of more than 16 777 216 bytes", inputs(json!({})), json!([{"file": x, "pad": 16_777_216}]), Some(ImportOversized)), + ("xml_malformed", "a document type declaration that defines an entity", inputs(json!({})), json!([edit(x, "", "]>")]), Some(ImportXmlMalformed)), + ("json_malformed", "the JSON form naming a member twice", inputs(json!({"xmltv": null, "mrss": [], "json": j, "playlist": null, "owner": null, "artwork": []})), json!([edit(j, "\"kind\": \"wai-channel-feed\",", "\"kind\": \"wai-channel-feed\",\n \"kind\": \"wai-channel-feed\",")]), Some(ImportJsonMalformed)), + ("playlist_malformed", "two tags of one date range that give it two starts", inputs(json!({})), json!([edit(p, "START-DATE=\"2026-10-29T18:12:00.000Z\",DURATION", "START-DATE=\"2026-10-29T18:12:01.000Z\",DURATION")]), Some(ImportPlaylistMalformed)), + ("time_malformed", "a programme starting in a thirteenth month", inputs(json!({})), json!([edit(x, "start=\"20261029183000 +0000\"", "start=\"20261329183000 +0000\"")]), Some(ImportTimeMalformed)), + ("timezone_unknown", "a programme's time in a zone named by letters", inputs(json!({})), json!([edit(x, "start=\"20261029183000 +0000\"", "start=\"20261029193000 BST\"")]), Some(ImportTimezoneUnknown)), + ("no_stop", "a programme with no stop", inputs(json!({})), json!([edit(x, " stop=\"20261029190000 +0000\" channel=\"sample-1\"", " channel=\"sample-1\"")]), Some(ImportNoStop)), + ("empty", "an XMLTV channel id the document has no programme of", inputs(json!({"xmltv_channel": "sample-9"})), json!([]), Some(ImportEmpty)), + ("asset_unknown", "a programme whose asset is named only in another episode-num system", inputs(json!({})), json!([edit(x, "tk-7", "tk-7")]), Some(ImportAssetUnknown)), + ("asset_duplicate", "two catalogue items with one guid", inputs(json!({})), json!([edit(a, "hl-103", "hl-101")]), Some(ImportAssetDuplicate)), + ("field_invalid", "a restriction whose relationship is neither allow nor deny", inputs(json!({})), json!([edit(b, "relationship=\"deny\"", "relationship=\"maybe\"")]), Some(ImportFieldInvalid)), + ("scte35_malformed", "a section whose section_length is not its length", inputs(json!({})), json!([edit(p, "DURATION=120.000,SCTE35-OUT=0x", "DURATION=120.000,SCTE35-OUT=0xFC3001,X-WAS=0x")]), Some(ImportScte35Malformed)), + ("break_duration", "a break of no duration", inputs(json!({})), json!([edit(p, "DURATION=120.000", "DURATION=0.000")]), Some(ImportBreakDuration)), + ("duration_unknown", "a Media RSS item with no duration, in a channel of Media RSS items alone", loop_inputs(), json!([edit(a, "", "")]), Some(ImportDurationUnknown)), + ("gap", "a minute of dead air before the last programme", inputs(json!({})), json!([edit(x, "start=\"20261029193000\"", "start=\"20261029193100\"")]), Some(ImportGap)), + ("overlap", "a programme that ends after the next begins", inputs(json!({})), json!([edit(x, "stop=\"20261029203000 +0100\"", "stop=\"20261029203100 +0100\"")]), Some(ImportOverlap)), + ("owner_unknown", "an asset of the XMLTV document alone, and no owner given", inputs(json!({"owner": null})), json!([]), Some(ImportOwnerUnknown)), + ("artwork_missing", "an icon whose file the artwork set does not hold", inputs(json!({"artwork": ["art-hl-101.svg", "art-hl-102.svg", "art-tk-7.svg"]})), json!([]), Some(ImportArtworkMissing)), + ("break_outside", "a break that runs past the end of its programme", inputs(json!({})), json!([edit(p, "ID=\"brk-2\",START-DATE=\"2026-10-29T18:28:00.000Z\"", "ID=\"brk-2\",START-DATE=\"2026-10-29T18:29:00.000Z\"")]), Some(ImportBreakOutside)), + ("window_outside", "a window that begins before the schedule", inputs(json!({"window": [T0 - 60 * MIN, T0 + 60 * MIN]})), json!([]), Some(ImportWindowOutside)), + ] +} + +/// A lineup's hexadecimal bytes. +fn lineup_hex(l: &Lineup) -> String { + hex(&l.to_bytes()) +} + +/// A verification vector: its name, what it shows, the receipt file, the lineup's bytes, the +/// guide file, its edits. +type VerificationVector = (&'static str, &'static str, &'static str, String, Option<&'static str>, Value); + +/// An import vector: its name, what it shows, its inputs, its edits, and the refusal it was +/// written for. +type ImportVector = (&'static str, &'static str, Value, Value, Option); + +/// Each verification vector. +fn verification_vectors(sample: &Imported) -> Vec { + let (rc, g, nn) = ("sample.channel.json", "sample.guide.xml", "sample.now-next.json"); + let l = &sample.lineup; + let good = lineup_hex(l); + let key = operator(); + let sig = hex(&sample.receipt.sig); + let flipped_receipt_sig = format!("{}{}", if sig.starts_with('0') { "1" } else { "0" }, &sig[1..]); + let mut bad_sig = l.to_bytes(); + *bad_sig.last_mut().expect("a signature") ^= 0x01; + let mut extra = l.to_bytes(); + extra.push(0); + let forged = crate::sig::forgeries(|k| { + let mut f = l.clone(); + f.signer = *k; + f.signing_input() + }); + let small = forged.iter().find(|f| f.refusal == crate::sig::SigError::BadKey).expect("a key of small order"); + let mut small_key = l.clone(); + small_key.signer = small.key; + small_key.sig = small.sig; + let other = l.clone().sign(&seed("another operator")); + // One lineup, signed, for each rule of §6.5 that it alone breaks. + let mut rooted = l.clone(); + rooted.schedule_root = [0; 32]; + let rooted = rooted.sign(&key); + let mut mid = l.clone(); + mid.programmes.insert(1, T0 + 5 * MIN); + let mid = mid.sign(&key); + let mut merged = l.clone(); + merged.programmes.remove(1); + let merged = merged.sign(&key); + let mut unused = l.clone(); + let mut spare = l.assets[0].clone(); + spare.asset_id = "zz-unused".into(); + unused.assets.push(spare); + let unused = unused.sign(&key); + let urn_root = hex(&sample.receipt.schedule_root); + vec![ + ("receipt_and_lineup", "the sample's receipt and lineup", rc, good.clone(), None, json!([])), + ("xmltv_guide", "the XMLTV guide of the whole schedule: two programmes aired in the receipt's window, two after it", rc, good.clone(), Some(g), json!([])), + ("part_guide", "the XMLTV guide of a range across the window's end", rc, good.clone(), Some("sample.part.guide.xml"), json!([])), + ("now_next", "the now/next document inside a programme's break", rc, good.clone(), Some(nn), json!([])), + ("now_next_before", "the now/next document before the schedule begins", rc, good.clone(), Some("sample.before.now-next.json"), json!([])), + ("now_next_after", "the now/next document after the schedule ends", rc, good.clone(), Some("sample.after.now-next.json"), json!([])), + ("receipt_malformed", "a receipt missing a member", rc, good.clone(), None, json!([edit(rc, "\"signer_id\": \"sample-operator\"", "\"signer\": \"sample-operator\"")])), + ("receipt_bad_signature", "a receipt whose signature was altered", rc, good.clone(), None, json!([edit(rc, &sig, &flipped_receipt_sig)])), + ("receipt_invalid", "a receipt, signed, whose program-as-run is not what its window gives", "aired-misreported.channel.json", good.clone(), None, json!([])), + ("lineup_malformed", "a lineup with a byte after its signature", rc, hex(&extra), None, json!([])), + ("lineup_bad_signature", "a lineup whose signature was altered", rc, hex(&bad_sig), None, json!([])), + ("lineup_bad_key", small.about, rc, lineup_hex(&small_key), None, json!([])), + ("lineup_signer_mismatch", "a lineup another key signed", rc, lineup_hex(&other), None, json!([])), + ("lineup_other_root", "a lineup, signed, naming another schedule root (§6.5 rule 2)", rc, lineup_hex(&rooted), None, json!([])), + ("lineup_programme_mid_entry", "a lineup, signed, with a programme that begins inside an entry (§6.5 rule 3)", rc, lineup_hex(&mid), None, json!([])), + ("lineup_programme_two_assets", "a lineup, signed, whose first programme holds two assets' program entries (§6.5 rule 4)", rc, lineup_hex(&merged), None, json!([])), + ("lineup_asset_unused", "a lineup, signed, describing an asset no programme airs (§6.5 rule 5)", rc, lineup_hex(&unused), None, json!([])), + ("guide_malformed", "a guide cut short", rc, good.clone(), Some(g), json!([edit(g, "\n", "")])), + ("guide_source_mismatch", "a guide naming another schedule", rc, good.clone(), Some(g), json!([edit(g, &urn_root, &"0".repeat(64))])), + ("guide_entry_unscheduled", "a guide that places Night Freight where the schedule places Harbour Lights", rc, good.clone(), Some(g), json!([edit(g, ">hl-102", ">nf-1")])), + ("guide_mismatch", "a guide whose title the lineup does not state", rc, good.clone(), Some(g), json!([edit(g, "Night Freight", "Night Freight Live")])), + ("now_next_unscheduled", "a now/next document naming another programme on air", rc, good, Some(nn), json!([edit(nn, "\"asset_id\":\"tk-7\"", "\"asset_id\":\"hl-101\"")])), + ] +} + +/// The files of a verification vector, with its edits applied, and its facts. +pub fn run_verification(v: &Value, files: &BTreeMap>) -> Result { + let files = apply_edits(files, &v["edits"])?; + let receipt = files.get(v["receipt"].as_str().ok_or("receipt")?).ok_or("the receipt file")?; + let lineup = super::hex_bytes(v["lineup"].as_str().ok_or("lineup")?).ok_or("the lineup's hexadecimal")?; + let guide = match v["guide"].as_str() { + None => None, + Some(g) => Some(files.get(g).ok_or("the guide file")?.as_slice()), + }; + Ok(verification_json(&verify_all(receipt, &lineup, guide))) +} + +// --------------------------------------------------------------------------------------- +// generate, verify +// --------------------------------------------------------------------------------------- + +fn bytes_of(files: &[(String, String)]) -> BTreeMap> { + files.iter().map(|(n, s)| (n.clone(), s.clone().into_bytes())).collect() +} + +/// Every file of the corpus but its README, by name, from the fixed seeds. +pub fn generate() -> Vec<(String, String)> { + let mut files: Vec<(String, String)> = artwork(); + files.push(("sample.xmltv.xml".into(), xmltv())); + files.push(("sample-a.mrss.xml".into(), mrss_a())); + files.push(("sample-b.mrss.xml".into(), mrss_b())); + files.push(("sample.m3u8".into(), playlist())); + + let ivs = import_vectors(); + let sample = run_import(&json!({"inputs": ivs[0].2, "edits": []}), &bytes_of(&files)).expect("the sample's files").expect("the sample imports"); + files.push(("sample.json".into(), json_form(&sample.lineup))); + files.push(("sample.channel.json".into(), receipt_text(&sample.receipt))); + let (r, l) = (&sample.receipt, &sample.lineup); + let (start, end) = (l.schedule_start_ms, l.schedule_end_ms); + files.push(("sample.guide.xml".into(), xmltv_guide(r, l, start, end).expect("a guide"))); + files.push(("sample.part.guide.xml".into(), xmltv_guide(r, l, T0 + 45 * MIN, T0 + 75 * MIN).expect("a guide"))); + files.push(("sample.now-next.json".into(), now_next(r, l, T0 + 45 * MIN + 45_000).expect("a document"))); + files.push(("sample.before.now-next.json".into(), now_next(r, l, T0 - 60 * MIN).expect("a document"))); + files.push(("sample.after.now-next.json".into(), now_next(r, l, end + 60 * MIN).expect("a document"))); + let mut misreported = r.clone(); + misreported.aired = vec![0]; + misreported.sig = operator().sign(&misreported.payload()).to_bytes(); + files.push(("aired-misreported.channel.json".into(), receipt_text(&misreported))); + + let held = bytes_of(&files); + let mut seen: BTreeSet<&'static str> = BTreeSet::new(); + let mut imports = Vec::new(); + for (name, about, inp, edits, want) in &ivs { + let v = json!({"name": name, "about": about, "inputs": inp, "edits": edits}); + let got = run_import(&v, &held).unwrap_or_else(|e| panic!("{name}: {e}")); + let outcome = match (&got, want) { + (Err(r), Some(w)) => { + assert_eq!(r, w, "{name}: refused {r}, written for {w}"); + seen.insert(r.code()); + json!({"refused": r.code()}) + } + (Ok(i), None) => { + let file = match *name { + "sample" | "sample_json" => { + assert_eq!(receipt_text(&i.receipt), receipt_text(&sample.receipt), "{name}: the sample's receipt"); + assert_eq!(i.lineup, sample.lineup, "{name}: the sample's lineup"); + "sample.channel.json".to_string() + } + _ => { + let f = format!("{name}.channel.json"); + files.push((f.clone(), receipt_text(&i.receipt))); + f + } + }; + json!({"receipt": file, "receipt_hash": hex(&i.receipt.receipt_hash()), "lineup": lineup_hex(&i.lineup), "lineup_digest": hex(&i.lineup.digest()), "cues": cues_json(i)}) + } + (Err(r), None) => panic!("{name}: refused {r}"), + (Ok(_), Some(w)) => panic!("{name}: imported, written for {w}"), + }; + let mut v = v; + v["outcome"] = outcome; + imports.push(v); + } + + let held = bytes_of(&files); + let mut verifications = Vec::new(); + for (name, about, receipt, lineup, guide, edits) in verification_vectors(&sample) { + let mut v = json!({"name": name, "about": about, "receipt": receipt, "lineup": lineup, "guide": guide, "edits": edits}); + let facts = run_verification(&v, &held).unwrap_or_else(|e| panic!("{name}: {e}")); + let verdict = facts["verdict"].as_str().expect("a verdict").to_string(); + let expect_verified = ["receipt_and_lineup", "xmltv_guide", "part_guide", "now_next", "now_next_before", "now_next_after"].contains(&name); + assert_eq!(verdict == "verified", expect_verified, "{name}: {verdict}"); + if let Some(r) = Reason::from_code(&verdict) { + seen.insert(r.code()); + } + v["facts"] = facts; + verifications.push(v); + } + for r in Reason::ADDED.iter().chain(Reason::REUSED) { + assert!(seen.contains(r.code()), "the corpus gives no {r}"); + } + + let doc = json!({ + "about": "extensions/channel-lineup.md §11: a sample channel's feeds (XMLTV, two owners' Media RSS, a Media Playlist with SCTE-35 date ranges, and the JSON form), each import's inputs, the edit that makes it a control and its outcome; each verification of a receipt, a lineup and a guide, and what it states", + "operator": {"key": hex(&operator().verifying_key().to_bytes()), "seed": hex(&operator().to_bytes())}, + "imports": imports, + "verifications": verifications, + }); + files.push(("vectors.json".into(), serde_json::to_string_pretty(&doc).expect("JSON") + "\n")); + files.sort(); + files +} + +/// Check committed files: every import gets its outcome and every verification its facts. +/// Each failure is one line. +pub fn verify(files: &BTreeMap) -> Vec { + let mut fails = Vec::new(); + let Some(doc) = files.get("vectors.json").and_then(|s| serde_json::from_str::(s).ok()) else { + return vec!["vectors.json: missing or not JSON".into()]; + }; + let held: BTreeMap> = files.iter().map(|(n, s)| (n.clone(), s.clone().into_bytes())).collect(); + for v in doc["imports"].as_array().into_iter().flatten() { + let name = v["name"].as_str().unwrap_or("?"); + let got = match run_import(v, &held) { + Ok(g) => g, + Err(e) => { + fails.push(format!("{name}: {e}")); + continue; + } + }; + let o = &v["outcome"]; + match got { + Err(r) => { + if o["refused"] != json!(r.code()) { + fails.push(format!("{name}: refused {r}, the corpus states {o}")); + } + } + Ok(i) => { + let receipt = o["receipt"].as_str().and_then(|f| files.get(f)); + if receipt != Some(&receipt_text(&i.receipt)) { + fails.push(format!("{name}: the receipt differs from {}", o["receipt"])); + } + if o["lineup"] != json!(lineup_hex(&i.lineup)) || o["lineup_digest"] != json!(hex(&i.lineup.digest())) || o["receipt_hash"] != json!(hex(&i.receipt.receipt_hash())) { + fails.push(format!("{name}: the lineup or the receipt hash differs")); + } + if o["cues"] != cues_json(&i) { + fails.push(format!("{name}: the cues differ")); + } + } + } + } + for v in doc["verifications"].as_array().into_iter().flatten() { + let name = v["name"].as_str().unwrap_or("?"); + match run_verification(v, &held) { + Ok(f) if f == v["facts"] => {} + Ok(f) => fails.push(format!("{name}: states {f}, the corpus {}", v["facts"])), + Err(e) => fails.push(format!("{name}: {e}")), + } + } + fails +} diff --git a/wai/wai-rs/src/channel_lineup/import.rs b/wai/wai-rs/src/channel_lineup/import.rs new file mode 100644 index 000000000..d69d787d0 --- /dev/null +++ b/wai/wai-rs/src/channel_lineup/import.rs @@ -0,0 +1,783 @@ +//! The import of extensions/channel-lineup.md §4: an XMLTV schedule, Media RSS catalogues, the +//! JSON form and a Media Playlist's SCTE-35 date ranges, into a `wai.video.channel` receipt, a +//! lineup and the breaks' cues. Every step refuses with the reason its section names, in the +//! order of §4.10. + +use std::collections::{BTreeMap, HashMap, HashSet}; + +use ed25519_dalek::SigningKey; +use sha2::{Digest, Sha256}; + +use super::{ + duration_ms, first, geo, hls_date_time, is_long, is_short, read_feed_xml, xmltv_time, Asset, Lineup, Reason, FEED_KIND, ID_SYSTEM, + MAX_ARTWORK, MAX_CATEGORIES, MAX_COUNT, MAX_DOCUMENT, MAX_INSTANT, +}; +use crate::quantum_ops::GrantRef; +use crate::stitched_presentation::markers::{attribute_list, Element, Value as Attr}; +use crate::strict_json::{self, Json}; +use crate::wai_video_channel::{ChannelReceipt, EntryKind, ScheduleEntry}; + +/// The cue id domain (§4.8). +const CUE_ID_DOMAIN: &[u8] = b"wai:channel-cue-id\x01"; + +/// An import's inputs (§4.1), but the operator's key and the receipt's signer id and parent. +#[derive(Clone, Debug, Default)] +pub struct Feeds { + pub channel: String, + pub xmltv: Option>, + /// Each Media RSS document, with its owner when one is given. + pub mrss: Vec<(Vec, Option)>, + pub json: Option>, + pub playlist: Option>, + pub owner: Option, + pub id_system: Option, + pub xmltv_channel: Option, + pub start_ms: Option, + /// The artwork set: each file's bytes by its name. + pub artwork: BTreeMap>, + pub window: Option<(u64, u64)>, +} + +/// A break's cue (§4.8): its break's id and start, and the live-events §4.3 `cue` payload. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Cue { + pub break_id: String, + pub start_ms: u64, + pub payload: Vec, +} + +/// The cues as `cues.json` holds them: each break's id, its start and its payload in +/// hexadecimal. +pub fn cues_json(cues: &[Cue]) -> serde_json::Value { + serde_json::Value::Array(cues.iter().map(|c| serde_json::json!({"break": c.break_id, "start_ms": c.start_ms, "payload": super::hex(&c.payload)})).collect()) +} + +/// What an import yields (§4.9). +#[derive(Clone, Debug, PartialEq)] +pub struct Imported { + pub receipt: ChannelReceipt, + pub lineup: Lineup, + pub cues: Vec, +} + +type R = Result; + +/// An asset's artwork as a feed gives it: by URI (XMLTV, Media RSS) or with its hash (the +/// JSON form). +#[derive(Clone, Debug)] +enum Art { + Uris(Vec), + Hashed(Vec<([u8; 32], String)>), +} + +/// An asset's description as a feed gives it (§4.6 step 1). +#[derive(Clone, Debug)] +struct Desc { + title: String, + description: String, + categories: Vec, + rating: (String, String), + art: Art, + geo: (u8, Vec<[u8; 2]>), + content_uri: String, + duration_ms: u64, +} + +/// A programme: its span and its asset id. +#[derive(Clone, Debug)] +struct Prog { + start: u64, + stop: u64, + asset: String, +} + +/// A Media RSS item (§4.3). +#[derive(Clone, Debug)] +struct Item { + asset: String, + desc: Desc, + duration: Option, + owner: Option, +} + +/// The programmes of an XMLTV document, each with its description (§4.2). +type Described = Vec<(Prog, Desc)>; + +/// The JSON form's assets, each its id, its description and its rights holder (§4.4). +type Catalogue = Vec<(String, Desc, Option)>; + +/// The JSON form as §4.4 reads it. +struct JsonRead { + channel_name: String, + programmes: Vec, + assets: Catalogue, + breaks: Vec, +} + +/// A break (§4.5). +#[derive(Clone, Debug)] +struct Break { + id: String, + start: u64, + duration: u64, + section: Option>, +} + +fn field(ok: bool) -> R<()> { + if ok { Ok(()) } else { Err(Reason::ImportFieldInvalid) } +} + +fn sized(b: &[u8]) -> R<()> { + if b.len() > MAX_DOCUMENT { Err(Reason::ImportOversized) } else { Ok(()) } +} + +// --------------------------------------------------------------------------------------- +// §4.1 Inputs +// --------------------------------------------------------------------------------------- + +fn check_inputs(f: &Feeds, signer_id: &str) -> R<()> { + let bad = Err(Reason::ImportInputsInvalid); + let set = if f.json.is_some() { f.xmltv.is_none() && f.mrss.is_empty() } else { f.xmltv.is_some() || !f.mrss.is_empty() }; + let owners = f.mrss.iter().all(|(_, o)| o.is_some()) || f.mrss.iter().all(|(_, o)| o.is_none()); + let short_opt = |o: &Option| o.as_deref().is_none_or(is_short); + let alone = f.json.is_none() && f.xmltv.is_none() && !f.mrss.is_empty(); + if !is_short(&f.channel) + || !set + || !owners + || !f.mrss.iter().all(|(_, o)| short_opt(o)) + || !short_opt(&f.owner) + || !short_opt(&f.id_system) + || !short_opt(&f.xmltv_channel) + || !is_short(signer_id) + || (alone && f.start_ms.is_none()) + || f.start_ms.is_some_and(|s| s > MAX_INSTANT) + { + return bad; + } + Ok(()) +} + +// --------------------------------------------------------------------------------------- +// §4.2 XMLTV, §4.3 Media RSS +// --------------------------------------------------------------------------------------- + +/// The categories, artwork URIs and rating of an XMLTV programme or a Media RSS item (§4.2 +/// step 4, §4.3 step 3): `art` the child that names an artwork and `attr` its URI attribute, +/// `scheme` the rating's system attribute and its default. +fn description(e: &Element, desc_name: &str, art: (&str, &str), scheme: (&str, &str)) -> R { + let title = first(e, "title").map(Element::text).unwrap_or_default(); + field(!title.is_empty() && is_long(&title))?; + let description = first(e, desc_name).map(Element::text).unwrap_or_default(); + field(is_long(&description))?; + let categories: Vec = e.children_named("category").map(Element::text).collect(); + field(categories.len() <= MAX_CATEGORIES && categories.iter().all(|c| is_short(c)))?; + let uris: Vec> = e.children_named(art.0).map(|i| i.attr(art.1)).collect(); + field(uris.len() <= MAX_ARTWORK && uris.iter().all(|u| u.is_some_and(|u| !u.is_empty() && is_long(u))))?; + let rating = match first(e, "rating") { + None => (String::new(), String::new()), + Some(r) => { + let system = r.attr(scheme.0).unwrap_or(scheme.1).to_string(); + field(system.is_empty() || is_short(&system))?; + let value = if desc_name == "desc" { first(r, "value").map(Element::text).unwrap_or_default() } else { r.text() }; + field(is_short(&value))?; + (system, value) + } + }; + Ok(Desc { + title, + description, + categories, + rating, + art: Art::Uris(uris.into_iter().map(|u| u.expect("checked").to_string()).collect()), + geo: (geo::EVERYWHERE, Vec::new()), + content_uri: String::new(), + duration_ms: 0, + }) +} + +/// §4.2: the channel name, the programmes and each one's description. +fn read_xmltv(b: &[u8], xch: &str, id_system: &str) -> R<(Option, Described)> { + sized(b)?; + let root = read_feed_xml(b).ok_or(Reason::ImportXmlMalformed)?; + if root.name != "tv" { + return Err(Reason::ImportXmlMalformed); + } + let progs: Vec<&Element> = root.children_named("programme").filter(|p| p.attr("channel") == Some(xch)).collect(); + if progs.len() > MAX_COUNT { + return Err(Reason::ImportOversized); + } + let name = root.children_named("channel").find(|c| c.attr("id") == Some(xch)).and_then(|c| first(c, "display-name")).map(Element::text).filter(|n| !n.is_empty()); + if let Some(n) = &name { + field(is_short(n))?; + } + if progs.is_empty() { + return Err(Reason::ImportEmpty); + } + let mut out = Vec::with_capacity(progs.len()); + for p in progs { + let start = xmltv_time(p.attr("start").ok_or(Reason::ImportTimeMalformed)?)?; + let stop = xmltv_time(p.attr("stop").ok_or(Reason::ImportNoStop)?)?; + if stop <= start { + return Err(Reason::ImportTimeMalformed); + } + let asset = p.children_named("episode-num").find(|e| e.attr("system").unwrap_or("onscreen") == id_system).map(Element::text).filter(|a| is_short(a)).ok_or(Reason::ImportAssetUnknown)?; + let desc = description(p, "desc", ("icon", "src"), ("system", ""))?; + out.push((Prog { start, stop, asset }, desc)); + } + Ok((name, out)) +} + +/// §4.3 step 4: where an item may be shown. +fn restriction(item: &Element) -> R<(u8, Vec<[u8; 2]>)> { + let Some(r) = item.children_named("restriction").find(|r| r.attr("type") == Some("country")) else { + return Ok((geo::EVERYWHERE, Vec::new())); + }; + let allow = match r.attr("relationship") { + Some("allow") => true, + Some("deny") => false, + _ => return Err(Reason::ImportFieldInvalid), + }; + let text = r.text(); + let tokens: Vec<&str> = text.split([' ', '\t', '\r', '\n']).filter(|t| !t.is_empty()).collect(); + match (tokens.as_slice(), allow) { + (["all"], true) | (["none"], false) => Ok((geo::EVERYWHERE, Vec::new())), + (["none"], true) | (["all"], false) => Ok((geo::ONLY, Vec::new())), + ([], _) => Err(Reason::ImportFieldInvalid), + (t, _) if t.iter().all(|c| c.len() == 2 && c.bytes().all(|x| x.is_ascii_alphabetic())) => { + let mut codes: Vec<[u8; 2]> = t.iter().map(|c| [c.as_bytes()[0].to_ascii_uppercase(), c.as_bytes()[1].to_ascii_uppercase()]).collect(); + codes.sort_unstable(); + codes.dedup(); + Ok((if allow { geo::ONLY } else { geo::EXCEPT }, codes)) + } + _ => Err(Reason::ImportFieldInvalid), + } +} + +/// §4.3: the channel name and the items of one document. +fn read_mrss(b: &[u8], owner: &Option, seen: &mut HashSet, count: &mut usize) -> R<(Option, Vec)> { + sized(b)?; + let root = read_feed_xml(b).ok_or(Reason::ImportXmlMalformed)?; + let channel = (root.name == "rss").then(|| first(&root, "channel")).flatten().ok_or(Reason::ImportXmlMalformed)?; + let items: Vec<&Element> = channel.children_named("item").collect(); + *count += items.len(); + if *count > MAX_COUNT { + return Err(Reason::ImportOversized); + } + let name = first(channel, "title").map(Element::text).filter(|n| !n.is_empty()); + if let Some(n) = &name { + field(is_short(n))?; + } + let mut out = Vec::with_capacity(items.len()); + for item in items { + let asset = first(item, "guid").map(Element::text).filter(|a| is_short(a)).ok_or(Reason::ImportAssetUnknown)?; + if !seen.insert(asset.clone()) { + return Err(Reason::ImportAssetDuplicate); + } + let content = first(item, "content").or_else(|| first(item, "group").and_then(|g| first(g, "content"))); + let content_uri = content.and_then(|c| c.attr("url")).unwrap_or("").to_string(); + field(is_long(&content_uri))?; + let duration = match content.and_then(|c| c.attr("duration")) { + None => None, + Some(d) => Some(duration_ms(d).and_then(|d| if d == 0 { Err(Reason::ImportTimeMalformed) } else { Ok(d) })?), + }; + let mut desc = description(item, "description", ("thumbnail", "url"), ("scheme", "urn:simple"))?; + desc.geo = restriction(item)?; + desc.content_uri = content_uri; + desc.duration_ms = duration.unwrap_or(0); + out.push(Item { asset, desc, duration, owner: owner.clone() }); + } + Ok((name, out)) +} + +// --------------------------------------------------------------------------------------- +// §4.4 The JSON form +// --------------------------------------------------------------------------------------- + +fn exactly<'a>(j: &'a Json, want: &[&str]) -> Option<&'a Json> { + let m = j.as_obj()?; + (m.len() == want.len() && want.iter().all(|k| m.iter().any(|(n, _)| n == k))).then_some(j) +} + +fn int(j: Option<&Json>) -> Option { + j?.as_u64().filter(|&v| v <= MAX_INSTANT) +} + +fn strs(j: Option<&Json>) -> Option> { + j?.as_arr()?.iter().map(|s| s.as_str().map(str::to_string)).collect() +} + +/// An asset of the JSON form, as its structure reads (§4.4's table). +struct JsonAsset { + asset_id: String, + title: String, + description: String, + content_uri: String, + categories: Vec, + rating: Option<(String, String)>, + artwork: Vec<(String, String)>, + geo: Option<(bool, Vec)>, + rights_holder: Option, + duration_ms: u64, +} + +struct JsonBreak { + id: String, + start_ms: u64, + duration_ms: u64, + scte35: Option, +} + +struct JsonForm { + channel_name: String, + programmes: Vec<(u64, u64, String)>, + assets: Vec, + breaks: Vec, +} + +/// The JSON form's structure (§4.4): `None` when it does not read so. +fn json_structure(b: &[u8]) -> Option { + let j = strict_json::read(b).ok()?; + exactly(&j, &["kind", "channel_name", "programmes", "assets", "breaks"])?; + if j.get("kind")?.as_str()? != FEED_KIND { + return None; + } + let channel_name = j.get("channel_name")?.as_str()?.to_string(); + let mut programmes = Vec::new(); + for p in j.get("programmes")?.as_arr()? { + exactly(p, &["start_ms", "stop_ms", "asset_id"])?; + programmes.push((int(p.get("start_ms"))?, int(p.get("stop_ms"))?, p.get("asset_id")?.as_str()?.to_string())); + } + let mut assets = Vec::new(); + for a in j.get("assets")?.as_arr()? { + exactly(a, &["asset_id", "title", "description", "content_uri", "categories", "rating", "artwork", "geo", "rights_holder", "duration_ms"])?; + let s = |k: &str| a.get(k)?.as_str().map(str::to_string); + let rating = match a.get("rating")? { + Json::Null => None, + r => { + exactly(r, &["system", "value"])?; + Some((r.get("system")?.as_str()?.to_string(), r.get("value")?.as_str()?.to_string())) + } + }; + let mut artwork = Vec::new(); + for w in a.get("artwork")?.as_arr()? { + exactly(w, &["uri", "sha256"])?; + artwork.push((w.get("uri")?.as_str()?.to_string(), w.get("sha256")?.as_str()?.to_string())); + } + let geo = match a.get("geo")? { + Json::Null => None, + g @ Json::Obj(m) if m.len() == 1 => { + let allow = m[0].0 == "allow"; + if !allow && m[0].0 != "deny" { + return None; + } + Some((allow, strs(g.get(&m[0].0))?)) + } + _ => return None, + }; + let rights_holder = match a.get("rights_holder")? { + Json::Null => None, + Json::Str(r) => Some(r.clone()), + _ => return None, + }; + assets.push(JsonAsset { + asset_id: s("asset_id")?, + title: s("title")?, + description: s("description")?, + content_uri: s("content_uri")?, + categories: strs(a.get("categories"))?, + rating, + artwork, + geo, + rights_holder, + duration_ms: int(a.get("duration_ms"))?, + }); + } + let mut breaks = Vec::new(); + for k in j.get("breaks")?.as_arr()? { + exactly(k, &["id", "start_ms", "duration_ms", "scte35"])?; + let scte35 = match k.get("scte35")? { + Json::Null => None, + Json::Str(s) => Some(s.clone()), + _ => return None, + }; + breaks.push(JsonBreak { id: k.get("id")?.as_str()?.to_string(), start_ms: int(k.get("start_ms"))?, duration_ms: int(k.get("duration_ms"))?, scte35 }); + } + Some(JsonForm { channel_name, programmes, assets, breaks }) +} + +fn is_lower_hex64(s: &str) -> bool { + s.len() == 64 && s.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) +} + +/// Hexadecimal digits of either case, an even count, as bytes. +fn hex_any(s: &str) -> Option> { + if !s.len().is_multiple_of(2) || !s.bytes().all(|c| c.is_ascii_hexdigit()) { + return None; + } + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()).collect() +} + +/// A `splice_info_section()` as live-events §4.3 reads form 1 (§4.5 step 3). +fn section_reads(b: &[u8]) -> bool { + b.len() >= 3 && b[0] == 0xFC && (u16::from_be_bytes([b[1], b[2]]) & 0x0FFF) as usize + 3 == b.len() +} + +/// A break's duration, as §4.5 step 4 bounds it. +fn break_duration(d: u64) -> R { + if d == 0 || d > u64::from(u32::MAX) { Err(Reason::ImportBreakDuration) } else { Ok(d) } +} + +/// §4.4: the JSON form, its values read in document order. +fn read_json(b: &[u8], breaks_count: &mut usize) -> R { + sized(b)?; + let f = json_structure(b).ok_or(Reason::ImportJsonMalformed)?; + *breaks_count += f.breaks.len(); + if f.programmes.len() > MAX_COUNT || f.assets.len() > MAX_COUNT || *breaks_count > MAX_COUNT { + return Err(Reason::ImportOversized); + } + field(is_short(&f.channel_name))?; + let mut progs = Vec::with_capacity(f.programmes.len()); + for (start, stop, asset) in f.programmes { + field(is_short(&asset))?; + if stop <= start { + return Err(Reason::ImportTimeMalformed); + } + progs.push(Prog { start, stop, asset }); + } + let mut seen = HashSet::new(); + let mut assets = Vec::with_capacity(f.assets.len()); + for a in f.assets { + field(is_short(&a.asset_id))?; + if !seen.insert(a.asset_id.clone()) { + return Err(Reason::ImportAssetDuplicate); + } + field(!a.title.is_empty() && is_long(&a.title))?; + field(is_long(&a.description) && is_long(&a.content_uri))?; + field(a.categories.len() <= MAX_CATEGORIES && a.categories.iter().all(|c| is_short(c)))?; + let rating = match a.rating { + None => (String::new(), String::new()), + Some((s, v)) => { + field((s.is_empty() || is_short(&s)) && is_short(&v))?; + (s, v) + } + }; + field(a.artwork.len() <= MAX_ARTWORK && a.artwork.iter().all(|(u, h)| !u.is_empty() && is_long(u) && is_lower_hex64(h)))?; + let art = a.artwork.into_iter().map(|(u, h)| (super::unhex::<32>(&h).expect("checked"), u)).collect(); + let geo = match a.geo { + None => (geo::EVERYWHERE, Vec::new()), + Some((allow, codes)) => { + let ok = codes.iter().all(|c| c.len() == 2 && c.bytes().all(|x| x.is_ascii_uppercase())) && codes.windows(2).all(|w| w[0] < w[1]) && (allow || !codes.is_empty()); + field(ok)?; + (if allow { geo::ONLY } else { geo::EXCEPT }, codes.iter().map(|c| [c.as_bytes()[0], c.as_bytes()[1]]).collect()) + } + }; + if let Some(r) = &a.rights_holder { + field(is_short(r))?; + } + let desc = Desc { title: a.title, description: a.description, categories: a.categories, rating, art: Art::Hashed(art), geo, content_uri: a.content_uri, duration_ms: a.duration_ms }; + assets.push((a.asset_id, desc, a.rights_holder)); + } + let mut breaks = Vec::with_capacity(f.breaks.len()); + for k in f.breaks { + field(is_short(&k.id))?; + let section = match &k.scte35 { + None => None, + Some(h) => Some(hex_any(h).filter(|s| section_reads(s)).ok_or(Reason::ImportScte35Malformed)?), + }; + breaks.push(Break { id: k.id, start: k.start_ms, duration: break_duration(k.duration_ms)?, section }); + } + Ok(JsonRead { channel_name: f.channel_name, programmes: progs, assets, breaks }) +} + +// --------------------------------------------------------------------------------------- +// §4.5 Breaks from a Media Playlist +// --------------------------------------------------------------------------------------- + +fn hexadecimal_sequence(v: &Attr) -> Option> { + match v { + Attr::Plain(s) => hex_any(s.strip_prefix("0x").or_else(|| s.strip_prefix("0X"))?).filter(|b| section_reads(b)), + Attr::Quoted(_) => None, + } +} + +/// §4.5: the breaks of a Media Playlist. +fn read_playlist(b: &[u8], breaks_count: &mut usize) -> R> { + sized(b)?; + let malformed = Reason::ImportPlaylistMalformed; + let s = std::str::from_utf8(b).map_err(|_| malformed)?; + let lines: Vec<&str> = s.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l)).collect(); + if lines.first() != Some(&"#EXTM3U") { + return Err(malformed); + } + let mut groups: Vec<(String, Vec<(String, Attr)>)> = Vec::new(); + for l in lines.iter().filter_map(|l| l.strip_prefix("#EXT-X-DATERANGE:")) { + let attrs = attribute_list(l).ok_or(malformed)?; + let id = match attrs.iter().find(|(n, _)| n == "ID") { + Some((_, Attr::Quoted(id))) => id.clone(), + _ => return Err(malformed), + }; + let at = match groups.iter().position(|(g, _)| *g == id) { + Some(i) => i, + None => { + groups.push((id, Vec::new())); + groups.len() - 1 + } + }; + let g = &mut groups[at].1; + for (n, v) in attrs { + match g.iter().find(|(m, _)| *m == n) { + Some((_, w)) if *w != v => return Err(malformed), + Some(_) => {} + None => g.push((n, v)), + } + } + } + let breaks: Vec<(String, Vec<(String, Attr)>)> = groups.into_iter().filter(|(_, a)| a.iter().any(|(n, _)| n == "SCTE35-OUT")).collect(); + *breaks_count += breaks.len(); + if *breaks_count > MAX_COUNT { + return Err(Reason::ImportOversized); + } + let mut out = Vec::with_capacity(breaks.len()); + for (id, a) in breaks { + let get = |n: &str| a.iter().find(|(m, _)| m == n).map(|(_, v)| v); + field(is_short(&id))?; + let start = match get("START-DATE") { + Some(Attr::Quoted(d)) => hls_date_time(d)?, + _ => return Err(malformed), + }; + let section = hexadecimal_sequence(get("SCTE35-OUT").expect("a break")).ok_or(Reason::ImportScte35Malformed)?; + if let Some(i) = get("SCTE35-IN") { + hexadecimal_sequence(i).ok_or(Reason::ImportScte35Malformed)?; + } + let plain = |v: &Attr| match v { + Attr::Plain(d) => duration_ms(d), + Attr::Quoted(_) => Err(malformed), + }; + let d = if let Some(v) = get("DURATION") { + plain(v)? + } else if let Some(v) = get("END-DATE") { + let end = match v { + Attr::Quoted(e) => hls_date_time(e)?, + Attr::Plain(_) => return Err(malformed), + }; + end.checked_sub(start).ok_or(Reason::ImportBreakDuration)? + } else if let Some(v) = get("PLANNED-DURATION") { + plain(v)? + } else { + return Err(Reason::ImportBreakDuration); + }; + out.push(Break { id, start, duration: break_duration(d)?, section: Some(section) }); + } + Ok(out) +} + +// --------------------------------------------------------------------------------------- +// §4.6 The programmes and their assets, §4.7 the schedule, §4.8 the cues +// --------------------------------------------------------------------------------------- + +/// An artwork URI's name (§4.6 step 3). +fn artwork_name(uri: &str) -> Option<&str> { + let u = uri.split(['?', '#']).next().unwrap_or(""); + let name = u.rsplit('/').next().unwrap_or(""); + let ok = (1..=255).contains(&name.len()) && name != "." && name != ".." && name.bytes().all(|c| c.is_ascii_alphanumeric() || matches!(c, b'.' | b'_' | b'-')); + ok.then_some(name) +} + +/// A break's cue id (§4.8). +pub fn cue_id(break_id: &str, section: Option<&[u8]>) -> u32 { + match section { + Some(s) if s.len() >= 18 && s[4] & 0x80 == 0 && s[13] == 0x05 => u32::from_be_bytes(s[14..18].try_into().expect("4 bytes")), + _ => { + let d = Sha256::new().chain_update(CUE_ID_DOMAIN).chain_update(break_id.as_bytes()).finalize(); + u32::from_be_bytes(d[..4].try_into().expect("4 bytes")) + } + } +} + +/// A break's `cue` payload of live-events §4.3 (§4.8). +fn cue_payload(k: &Break) -> Vec { + let mut o = vec![4u8]; + o.extend_from_slice(&cue_id(&k.id, k.section.as_deref()).to_le_bytes()); + o.extend_from_slice(&(k.duration as u32).to_le_bytes()); + match &k.section { + None => o.push(0), + Some(s) => { + o.push(1); + o.extend_from_slice(&(s.len() as u16).to_le_bytes()); + o.extend_from_slice(s); + } + } + o +} + +/// Import a channel (§4), the receipt and the lineup signed by `key`. +pub fn import(f: &Feeds, key: &SigningKey, signer_id: &str, parent: Option<[u8; 32]>) -> R { + check_inputs(f, signer_id)?; + let xch = f.xmltv_channel.as_deref().unwrap_or(&f.channel); + let id_system = f.id_system.as_deref().unwrap_or(ID_SYSTEM); + + // The feeds, in the order of §4.10. + let mut names: Vec> = Vec::new(); + let mut xmltv: Option = None; + if let Some(b) = &f.xmltv { + let (name, progs) = read_xmltv(b, xch, id_system)?; + names.push(name); + xmltv = Some(progs); + } + let (mut seen, mut count) = (HashSet::new(), 0usize); + let mut items: Vec = Vec::new(); + for (k, (b, owner)) in f.mrss.iter().enumerate() { + let (name, mut its) = read_mrss(b, owner, &mut seen, &mut count)?; + if k == 0 { + names.push(name); + } + items.append(&mut its); + } + let mut breaks_count = 0usize; + let mut breaks: Vec = Vec::new(); + let mut json: Option<(Vec, Catalogue)> = None; + if let Some(b) = &f.json { + let mut j = read_json(b, &mut breaks_count)?; + names.push(Some(j.channel_name)); + breaks.append(&mut j.breaks); + json = Some((j.programmes, j.assets)); + } + if let Some(b) = &f.playlist { + breaks.append(&mut read_playlist(b, &mut breaks_count)?); + } + + // §4.6 The programmes. + let mut progs: Vec = if let Some(x) = &xmltv { + x.iter().map(|(p, _)| p.clone()).collect() + } else if let Some((p, _)) = &json { + if p.is_empty() { + return Err(Reason::ImportEmpty); + } + p.clone() + } else { + if items.is_empty() { + return Err(Reason::ImportEmpty); + } + let mut at = f.start_ms.expect("checked in §4.1"); + let mut out = Vec::with_capacity(items.len()); + for it in &items { + let d = it.duration.ok_or(Reason::ImportDurationUnknown)?; + let stop = at.checked_add(d).filter(|&s| s <= MAX_INSTANT).ok_or(Reason::ImportTimeMalformed)?; + out.push(Prog { start: at, stop, asset: it.asset.clone() }); + at = stop; + } + out + }; + progs.sort_by_key(|p| p.start); + for w in progs.windows(2) { + if w[0].stop < w[1].start { + return Err(Reason::ImportGap); + } + if w[0].stop > w[1].start { + return Err(Reason::ImportOverlap); + } + } + let last_stop = progs.last().expect("not empty").stop; + if !progs.iter().all(|p| p.start.is_multiple_of(1000)) || !last_stop.is_multiple_of(1000) { + return Err(Reason::ImportTimeMalformed); + } + + // §4.6 Their assets. + let item_of: HashMap<&str, &Item> = items.iter().map(|i| (i.asset.as_str(), i)).collect(); + let mut order: Vec<&str> = Vec::new(); + for p in &progs { + if !order.contains(&p.asset.as_str()) { + order.push(&p.asset); + } + } + let mut assets: Vec = Vec::with_capacity(order.len()); + for id in order { + let (desc, holder): (Desc, Option) = if let Some((_, js)) = &json { + let (_, d, r) = js.iter().find(|(a, _, _)| a == id).ok_or(Reason::ImportAssetUnknown)?; + (d.clone(), r.clone()) + } else if let Some(it) = item_of.get(id) { + (it.desc.clone(), it.owner.clone()) + } else { + let (_, d) = xmltv.as_ref().expect("an XMLTV programme").iter().find(|(p, _)| p.asset == id).expect("a programme of it"); + (d.clone(), None) + }; + let rights_holder = holder.or_else(|| f.owner.clone()).ok_or(Reason::ImportOwnerUnknown)?; + let artwork = match desc.art { + Art::Hashed(h) => h, + Art::Uris(uris) => { + let mut out = Vec::with_capacity(uris.len()); + for u in uris { + let bytes = artwork_name(&u).and_then(|n| f.artwork.get(n)).ok_or(Reason::ImportArtworkMissing)?; + sized(bytes)?; + out.push((Sha256::digest(bytes).into(), u)); + } + out + } + }; + assets.push(Asset { + asset_id: id.to_string(), + title: desc.title, + description: desc.description, + categories: desc.categories, + rating_system: desc.rating.0, + rating_value: desc.rating.1, + artwork, + geo: desc.geo.0, + countries: desc.geo.1, + rights_holder, + content_uri: desc.content_uri, + duration_ms: desc.duration_ms, + }); + } + + // §4.7 The schedule. + breaks.sort_by_key(|k| k.start); + for w in breaks.windows(2) { + if w[0].start + w[0].duration > w[1].start { + return Err(Reason::ImportOverlap); + } + } + let mut within: Vec> = vec![Vec::new(); progs.len()]; + for k in &breaks { + let at = progs.iter().position(|p| p.start <= k.start && k.start < p.stop).filter(|&i| k.start + k.duration <= progs[i].stop).ok_or(Reason::ImportBreakOutside)?; + within[at].push(k); + } + let mut schedule: Vec = Vec::new(); + for (p, bks) in progs.iter().zip(&within) { + let mut at = p.start; + for k in bks { + if k.start > at { + schedule.push(ScheduleEntry { asset_id: p.asset.clone(), start_ms: at, duration_ms: k.start - at, kind: EntryKind::Program }); + } + schedule.push(ScheduleEntry { asset_id: k.id.clone(), start_ms: k.start, duration_ms: k.duration, kind: EntryKind::Ad }); + at = k.start + k.duration; + } + if p.stop > at { + schedule.push(ScheduleEntry { asset_id: p.asset.clone(), start_ms: at, duration_ms: p.stop - at, kind: EntryKind::Program }); + } + } + + // §4.9 What it yields. + let (first_start, last_end) = (progs[0].start, last_stop); + let (ws, we) = f.window.unwrap_or((first_start, last_end)); + if ws >= we || ws < first_start || we > last_end { + return Err(Reason::ImportWindowOutside); + } + let receipt = ChannelReceipt::seal(key, signer_id, f.channel.clone(), schedule, ws, we, 0, None, GrantRef::unbounded("video.channel"), parent); + assets.sort_by(|a, b| a.asset_id.as_bytes().cmp(b.asset_id.as_bytes())); + let channel_name = names.into_iter().flatten().next().unwrap_or_else(|| f.channel.clone()); + let lineup = Lineup { + channel_id: f.channel.clone(), + channel_name, + schedule_root: receipt.schedule_root, + schedule_start_ms: first_start, + schedule_end_ms: last_end, + programmes: progs.iter().map(|p| p.start).collect(), + assets, + signer: [0; 32], + sig: [0; 64], + } + .sign(key); + let cues = breaks.iter().map(|k| Cue { break_id: k.id.clone(), start_ms: k.start, payload: cue_payload(k) }).collect(); + Ok(Imported { receipt, lineup, cues }) +} diff --git a/wai/wai-rs/src/channel_lineup/tests.rs b/wai/wai-rs/src/channel_lineup/tests.rs new file mode 100644 index 000000000..12634dda7 --- /dev/null +++ b/wai/wai-rs/src/channel_lineup/tests.rs @@ -0,0 +1,73 @@ +use super::*; + +#[test] +fn xmltv_times_and_their_refusals() { + assert_eq!(xmltv_time("20261029180000"), Ok(1_793_296_800_000)); + assert_eq!(xmltv_time("20261029200000 +0200"), Ok(1_793_296_800_000)); + assert_eq!(xmltv_time("20261029160000 -0200"), Ok(1_793_296_800_000)); + assert_eq!(xmltv_time("20261029180000 BST"), Err(Reason::ImportTimezoneUnknown)); + assert_eq!(xmltv_time("20261029180000 +2400"), Err(Reason::ImportTimezoneUnknown)); + assert_eq!(xmltv_time("202610291800"), Err(Reason::ImportTimeMalformed)); + assert_eq!(xmltv_time("20260230180000"), Err(Reason::ImportTimeMalformed)); + assert_eq!(xmltv_time("20280229180000").map(|_| ()), Ok(())); + assert_eq!(xmltv_time("19700101000000 +0100"), Err(Reason::ImportTimeMalformed)); + assert_eq!(xmltv_time("20261029180000+0000"), Err(Reason::ImportTimeMalformed)); + assert_eq!(xmltv_format(1_793_296_800_000), "20261029180000 +0000"); +} + +#[test] +fn hls_date_times_and_durations() { + assert_eq!(hls_date_time("2026-10-29T18:00:00Z"), Ok(1_793_296_800_000)); + assert_eq!(hls_date_time("2026-10-29T18:00:00.5Z"), Ok(1_793_296_800_500)); + assert_eq!(hls_date_time("2026-10-29T18:00:00.250000Z"), Ok(1_793_296_800_250)); + assert_eq!(hls_date_time("2026-10-29T18:00:00.2501Z"), Err(Reason::ImportTimeMalformed)); + assert_eq!(hls_date_time("2026-10-29T20:00:00+02:00"), Ok(1_793_296_800_000)); + assert_eq!(hls_date_time("2026-10-29T18:00:00"), Err(Reason::ImportTimezoneUnknown)); + assert_eq!(hls_date_time("2026-10-29 18:00:00Z"), Err(Reason::ImportTimeMalformed)); + assert_eq!(duration_ms("90.000"), Ok(90_000)); + assert_eq!(duration_ms("1.0005"), Err(Reason::ImportTimeMalformed)); + assert_eq!(duration_ms("-1"), Err(Reason::ImportTimeMalformed)); +} + +#[test] +fn a_document_type_declaration_is_skipped_once() { + let ok = b"\n\n"; + assert_eq!(read_feed_xml(ok).map(|e| e.name), Some("tv".to_string())); + assert!(read_feed_xml(b"").is_some()); + assert!(read_feed_xml(b"").is_some()); + assert!(read_feed_xml(b"]>").is_none(), "an internal subset is outside the subset"); + assert!(read_feed_xml(b"").is_none(), "a second declaration is outside it"); + assert!(read_feed_xml(b"").is_none()); +} + +#[test] +fn every_reason_has_its_code() { + for r in Reason::ADDED.iter().chain(Reason::REUSED) { + assert_eq!(Reason::from_code(r.code()), Some(*r)); + } +} + +#[test] +fn the_capability_is_the_registrys() { + assert_eq!(CAPABILITY, crate::codecs::registry::CAP_VIDEO_LINEUP); +} + +fn unhex_any(s: &str) -> Vec { + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect() +} + +/// The sample messages of ANSI/SCTE 35-1 2023r2 §14.2 (a `splice_insert()`) and §14.1 (a +/// `time_signal()`): each reads as live-events §4.3 reads form 1, the first's cue id is its +/// `splice_event_id` (0x4800008F), and the second's, a command with none, is the break id's. +#[test] +fn the_cue_id_is_a_splice_inserts_event_id() { + let insert = unhex_any("FC302F000000000000FFFFF014054800008F7FEFFE7369C02EFE0052CCF500000000000A0008435545490000013562DBA30A"); + let signal = unhex_any("FC3034000000000000FFFFF00506FE72BD0050001E021C435545494800008E7FCF0001A599B00808000000002CA0A18A3402009AC9D17E"); + assert_eq!(insert.len(), 50); + assert_eq!(import::cue_id("brk", Some(&insert)), 0x4800_008F); + assert_ne!(import::cue_id("brk", Some(&signal)), 0x4800_008E); + assert_eq!(import::cue_id("brk", Some(&signal)), import::cue_id("brk", None)); + let mut encrypted = insert.clone(); + encrypted[4] |= 0x80; + assert_eq!(import::cue_id("brk", Some(&encrypted)), import::cue_id("brk", None), "an encrypted command is not read"); +} diff --git a/wai/wai-rs/src/codecs/registry.rs b/wai/wai-rs/src/codecs/registry.rs index 267bcf298..3082a0967 100644 --- a/wai/wai-rs/src/codecs/registry.rs +++ b/wai/wai-rs/src/codecs/registry.rs @@ -226,6 +226,7 @@ pub const CAP_LIVE_WATERMARK: &str = "wai.live.watermark"; pub const CAP_LIVE_ADS: &str = "wai.live.ads"; pub const CAP_LIVE_AD_PRESENTATION: &str = "wai.live.ad_presentation"; pub const CAP_VIDEO_STITCHED_PRESENTATION: &str = "wai.video.stitched_presentation"; +pub const CAP_VIDEO_LINEUP: &str = "wai.video.lineup"; pub const CAP_LIVE_COWATCH: &str = "wai.live.cowatch"; pub const CAP_LIVE_REMOTE: &str = "wai.live.remote"; pub const CAP_LIVE_WORLD: &str = "wai.live.world"; @@ -356,6 +357,7 @@ const ROWS: &[(&str, &str, Option<&str>, &str)] = &[ (CAP_LIVE_ADS, S_INTERACTIVE, None, "2026-10-20"), (CAP_LIVE_AD_PRESENTATION, S_INTERACTIVE, None, "2026-10-21"), (CAP_VIDEO_STITCHED_PRESENTATION, S_INTERACTIVE, None, "2026-10-25"), + (CAP_VIDEO_LINEUP, S_INTERACTIVE, None, "2026-10-29"), (CAP_LIVE_COWATCH, S_INTERACTIVE, None, "2026-10-22"), (CAP_LIVE_REMOTE, S_INTERACTIVE, None, "2026-10-25"), (CAP_LIVE_PROVENANCE, S_INTERACTIVE, None, "2026-10-21"), @@ -450,6 +452,7 @@ const RECORDS: &[&str] = &[ CAP_LIVE_ADS, CAP_LIVE_AD_PRESENTATION, CAP_VIDEO_STITCHED_PRESENTATION, + CAP_VIDEO_LINEUP, CAP_LIVE_COWATCH, CAP_LIVE_REMOTE, CAP_LIVE_PROVENANCE, @@ -652,6 +655,7 @@ const CO_PERFORMANCE: &str = "extensions/co-performance.md"; const LIVE_ADS: &str = "extensions/live-ads.md"; const AD_PRESENTATION: &str = "extensions/ad-presentation.md"; const STITCHED_PRESENTATION: &str = "extensions/stitched-presentation.md"; +const CHANNEL_LINEUP: &str = "extensions/channel-lineup.md"; const CO_WATCH: &str = "extensions/co-watch.md"; const REMOTE_DESKTOP: &str = "extensions/remote-desktop.md"; const LIVE_PROVENANCE: &str = "extensions/live-provenance.md"; @@ -791,6 +795,11 @@ pub const WIRE_IDENTIFIERS: &[WireIdentifier] = &[ wire("WPC1", WireKind::Magic, AD_PRESENTATION, "§5.1", WireStatus::Defined), wire("WSI1", WireKind::Magic, STITCHED_PRESENTATION, "§4.1", WireStatus::Defined), wire("WSC1", WireKind::Magic, STITCHED_PRESENTATION, "§5.1", WireStatus::Defined), + wire("WLU1", WireKind::Magic, CHANNEL_LINEUP, "§6.1", WireStatus::Defined), + wire("wai-channel-feed", WireKind::DocumentKind, CHANNEL_LINEUP, "§4.4", WireStatus::Defined), + wire("urn:wai:guide:", WireKind::Scheme, CHANNEL_LINEUP, "§7.2", WireStatus::Defined), + wire("wai-asset-id", WireKind::Scheme, CHANNEL_LINEUP, "§7.2", WireStatus::Defined), + wire("wai-now-next", WireKind::DocumentKind, CHANNEL_LINEUP, "§7.3", WireStatus::Defined), wire("WXP1", WireKind::Magic, CO_WATCH, "§3.1", WireStatus::Defined), wire("WXC1", WireKind::Magic, CO_WATCH, "§5.1", WireStatus::Defined), wire("WXS1", WireKind::Magic, CO_WATCH, "§7.1", WireStatus::Defined), diff --git a/wai/wai-rs/src/lib.rs b/wai/wai-rs/src/lib.rs index c045749ec..50e0d8312 100644 --- a/wai/wai-rs/src/lib.rs +++ b/wai/wai-rs/src/lib.rs @@ -682,6 +682,14 @@ pub mod live_ads; /// cbindgen:ignore #[cfg(feature = "stitched_presentation")] pub mod stitched_presentation; +// Channel lineup and guide (extensions/channel-lineup.md): `wai.video.lineup` — a linear channel's +// signed lineup (WLU1), bound to its wai.video.channel schedule by the schedule root; the import of +// an XMLTV schedule, Media RSS catalogues, a JSON form and a Media Playlist's SCTE-35 date ranges +// into the receipt, the lineup and the breaks' cues; and the guide, XMLTV or now/next, rendered +// from both and checked by recomputation. Rust-only, like the live modules. +/// cbindgen:ignore +#[cfg(feature = "channel_lineup")] +pub mod channel_lineup; // Live provenance (extensions/live-provenance.md): `wai.live.provenance` — the live video // provenance of C2PA 2.4 §19 for a room's CMAF tracks and a device's CMAF take: per-segment // manifests chained by manifest identifier, Verifiable Segment Info under rotating session keys, @@ -816,7 +824,7 @@ pub mod scene_map; // The strict JSON reader the two mappings above share, and the fleet's reader of a relay's session // summaries. Rust-only. /// cbindgen:ignore -#[cfg(any(feature = "prior_wire", feature = "scene_map", feature = "conform_result", feature = "fleet"))] +#[cfg(any(feature = "prior_wire", feature = "scene_map", feature = "conform_result", feature = "fleet", feature = "channel_lineup"))] pub(crate) mod strict_json; // Conformance results (extensions/conformance-results.md): `conform-result/1`, the signed outcome // of a conformance run, which `wai conform` writes and `wai verify` checks; and its corpus. diff --git a/wai/wai-rs/tests/channel_lineup.rs b/wai/wai-rs/tests/channel_lineup.rs new file mode 100644 index 000000000..8695a9641 --- /dev/null +++ b/wai/wai-rs/tests/channel_lineup.rs @@ -0,0 +1,150 @@ +//! `wai.video.lineup` (extensions/channel-lineup.md): the committed corpus +//! `live-conformance/channels/` regenerates byte for byte from its fixed seeds and holds no other +//! file, every vector gets the outcome it states, §10 lists exactly the reasons the reference adds, +//! and the worked example of §9 — its commands as the text writes them — imports, guides and +//! verifies the sample channel through the `wai` command line, yielding the corpus's files. +#![cfg(feature = "channel_lineup")] + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde_json::Value; +use wai::channel_lineup::{corpus, Reason}; + +fn wai_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..") +} + +fn corpus_dir() -> PathBuf { + wai_dir().join("live-conformance/channels") +} + +fn committed() -> BTreeMap { + std::fs::read_dir(corpus_dir()) + .expect("the committed corpus") + .flatten() + .map(|e| (e.file_name().to_string_lossy().into_owned(), e.path())) + .filter(|(n, _)| n != "README.md") + .map(|(n, p)| (n, std::fs::read_to_string(p).expect("a text file"))) + .collect() +} + +#[test] +fn the_committed_corpus_regenerates_from_its_seeds() { + let files = committed(); + let generated: BTreeMap = corpus::generate().into_iter().collect(); + let extra: Vec<&String> = files.keys().filter(|k| !generated.contains_key(*k)).collect(); + let missing: Vec<&String> = generated.keys().filter(|k| !files.contains_key(*k)).collect(); + let differ: Vec<&String> = generated.iter().filter(|(k, v)| files.get(*k).is_some_and(|f| f != *v)).map(|(k, _)| k).collect(); + assert!(extra.is_empty() && missing.is_empty() && differ.is_empty(), "extra {extra:?}, missing {missing:?}, differ {differ:?}: run `wai_channel_lineup_vectors gen`"); +} + +#[test] +fn every_vector_gets_its_stated_outcome() { + let fails = corpus::verify(&committed()); + assert!(fails.is_empty(), "{}", fails.join("\n")); +} + +/// §10 of the extension lists exactly the codes `Reason::ADDED` has, each once, in order, and +/// then names live-events' two. +#[test] +fn the_extension_lists_the_reasons() { + let text = std::fs::read_to_string(wai_dir().join("extensions/channel-lineup.md")).expect("the extension"); + let at = text.find("\n## 10. Reasons").expect("a §10"); + let end = text[at + 1..].find("\n## ").map_or(text.len(), |e| at + 1 + e); + let section = &text[at..end]; + let (table, after) = section.split_once("\nA verification also").expect("the table, then the reused codes"); + let listed: Vec = table.lines().filter(|l| l.starts_with("| `")).filter_map(|l| l.split('`').nth(1).map(String::from)).collect(); + let want: Vec = Reason::ADDED.iter().map(|r| r.code().to_string()).collect(); + assert_eq!(listed, want, "§10's table and Reason::ADDED differ"); + for r in Reason::REUSED { + assert!(after.contains(&format!("`{}`", r.code())), "§10 names {r}"); + } +} + +/// The commands of §9's worked example, as the text writes them, each a list of words. +fn worked_example() -> Vec> { + let text = std::fs::read_to_string(wai_dir().join("extensions/channel-lineup.md")).expect("the extension"); + let at = text.find("\n## 9. ").expect("a §9"); + let block = text[at..].split("```").nth(1).expect("a code block in §9"); + let joined = block.replace("\\\n", " "); + joined.lines().map(str::trim).filter(|l| l.starts_with("wai ")).map(|l| l.split_whitespace().skip(1).map(String::from).collect()).collect() +} + +fn run(dir: &Path, args: &[String]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_wai")).args(args).current_dir(dir).output().expect("wai runs") +} + +fn scratch(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("wai-channel-lineup-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).expect("a scratch directory"); + for (n, body) in committed() { + std::fs::write(d.join(n), body).expect("a corpus file"); + } + std::fs::write(d.join("operator.key"), hex(&corpus::operator().to_bytes())).expect("the key file"); + d +} + +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +fn vectors() -> Value { + serde_json::from_str(&committed()["vectors.json"]).expect("vectors.json") +} + +/// §9: the text's commands, run as written over the corpus's files, yield the corpus's receipt, +/// lineup, cues and guides, and the guide verifies. +#[test] +fn the_worked_example_runs_as_the_text_writes_it() { + let d = scratch("example"); + let cmds = worked_example(); + assert_eq!(cmds.len(), 4, "§9 shows an import, two guides and a verification: {cmds:?}"); + for c in &cmds { + let o = run(&d, c); + assert!(o.status.success(), "wai {}: {}{}", c.join(" "), String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)); + } + let files = committed(); + let v = vectors(); + let sample = v["imports"].as_array().unwrap().iter().find(|x| x["name"] == "sample").unwrap(); + assert_eq!(std::fs::read_to_string(d.join("out/channel.json")).unwrap(), files["sample.channel.json"], "the receipt"); + assert_eq!(hex(&std::fs::read(d.join("out/lineup.wlu")).unwrap()), sample["outcome"]["lineup"].as_str().unwrap(), "the lineup"); + let cues: Value = serde_json::from_slice(&std::fs::read(d.join("out/cues.json")).unwrap()).unwrap(); + assert_eq!(cues, sample["outcome"]["cues"], "the cues"); + assert_eq!(std::fs::read_to_string(d.join("guide.xml")).unwrap(), files["sample.guide.xml"], "the XMLTV guide"); + assert_eq!(std::fs::read_to_string(d.join("now-next.json")).unwrap(), files["sample.now-next.json"], "the now/next document"); + let _ = std::fs::remove_dir_all(&d); +} + +/// A refusal is exit 1 with the text's reason as the envelope's code, and nothing is written; a +/// guide that a third party changed does not verify, and says where. +#[test] +fn a_refusal_is_its_reason_and_exit_1() { + let d = scratch("refusal"); + let s = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::>(); + let x = std::fs::read_to_string(d.join("sample.xmltv.xml")).unwrap().replace("start=\"20261029193000\"", "start=\"20261029193100\""); + std::fs::write(d.join("gap.xmltv.xml"), x).unwrap(); + let o = run(&d, &s(&["channel", "import", "--channel", "sample-1", "--xmltv", "gap.xmltv.xml", "--owner", "owner-c", "--artwork", ".", "--key", "operator.key", "--out-dir", "gap", "--json"])); + assert_eq!(o.status.code(), Some(1), "{}", String::from_utf8_lossy(&o.stdout)); + let e: Value = serde_json::from_slice(&o.stdout).expect("an envelope"); + assert_eq!((e["ok"].as_bool(), e["error"]["code"].as_str()), (Some(false), Some("import_gap")), "{e}"); + assert!(!d.join("gap").exists(), "a refused import writes nothing"); + + let g = std::fs::read_to_string(d.join("sample.guide.xml")).unwrap().replace(">hl-102", ">nf-1"); + std::fs::write(d.join("changed.guide.xml"), g).unwrap(); + let lineup = vectors()["imports"][0]["outcome"]["lineup"].as_str().unwrap().to_string(); + let bytes: Vec = (0..lineup.len()).step_by(2).map(|i| u8::from_str_radix(&lineup[i..i + 2], 16).unwrap()).collect(); + std::fs::write(d.join("lineup.wlu"), bytes).unwrap(); + let o = run(&d, &s(&["channel", "verify", "--receipt", "sample.channel.json", "--lineup", "lineup.wlu", "--guide", "changed.guide.xml", "--json"])); + assert_eq!(o.status.code(), Some(1)); + let e: Value = serde_json::from_slice(&o.stdout).expect("an envelope"); + assert_eq!((e["error"]["code"].as_str(), e["data"]["entry"].as_u64(), e["data"]["part"].as_str()), (Some("guide_entry_unscheduled"), Some(2), Some("guide")), "{e}"); + let o = run(&d, &s(&["channel", "verify", "--receipt", "sample.channel.json", "--lineup", "lineup.wlu", "--guide", "sample.guide.xml", "--json"])); + assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stdout)); + let e: Value = serde_json::from_slice(&o.stdout).expect("an envelope"); + let aired: Vec = e["data"]["guide"]["programmes"].as_array().unwrap().iter().map(|p| p["aired"].as_bool().unwrap()).collect(); + assert_eq!(aired, [true, true, false, false], "the first hour aired in the receipt's window: {e}"); + let _ = std::fs::remove_dir_all(&d); +} diff --git a/wai/wai-rs/tests/cli_ops.rs b/wai/wai-rs/tests/cli_ops.rs index 898521b54..2e0f656e3 100644 --- a/wai/wai-rs/tests/cli_ops.rs +++ b/wai/wai-rs/tests/cli_ops.rs @@ -412,6 +412,25 @@ fn samples(d: &Path, origin_port: u16) -> Vec<(&'static str, Vec, bool)> more.push(("report.export", s(&["report", "export", &digest, "--target", &url, "--out", "week.wai-fleet.json"]), true)); more.push(("report.create", s(&["report", "create", "--target", "http://127.0.0.1:9", "--start", "0", "--end", "1"]), false)); } + #[cfg(feature = "channel_lineup")] + { + // The sample channel of extensions/channel-lineup.md §11, imported here; and the guide of + // its committed receipt and lineup. + let c = wai_dir().join("live-conformance/channels"); + let f = |n: &str| c.join(n).to_str().unwrap().to_string(); + let corpus: Value = serde_json::from_slice(&std::fs::read(c.join("vectors.json")).unwrap()).unwrap(); + std::fs::write(d.join("channel-operator.key"), corpus["operator"]["seed"].as_str().unwrap()).unwrap(); + let l = corpus["imports"][0]["outcome"]["lineup"].as_str().unwrap(); + let lineup: Vec = (0..l.len()).step_by(2).map(|i| u8::from_str_radix(&l[i..i + 2], 16).unwrap()).collect(); + std::fs::write(d.join("sample.wlu"), lineup).unwrap(); + let (x, a, b, p, art) = (f("sample.xmltv.xml"), f("sample-a.mrss.xml"), f("sample-b.mrss.xml"), f("sample.m3u8"), f("")); + more.push(( + "channel.import", + s(&["channel", "import", "--channel", "sample-1", "--xmltv", &x, "--mrss", &a, "--mrss-owner", "owner-a", "--mrss", &b, "--mrss-owner", "owner-b", "--owner", "owner-c", "--playlist", &p, "--artwork", &art, "--key", "channel-operator.key", "--out-dir", "channel-out"]), + true, + )); + more.push(("channel.guide", s(&["channel", "guide", "--receipt", &f("sample.channel.json"), "--lineup", "sample.wlu", "--out", "guide.xml"]), true)); + } #[cfg(feature = "revenue_share")] { // The revenue share: a contract written from its terms, an owner's countersignature added diff --git a/wai/wai-studio/src/actions.generated.js b/wai/wai-studio/src/actions.generated.js index 0fe72f926..f4a3f8db1 100644 --- a/wai/wai-studio/src/actions.generated.js +++ b/wai/wai-studio/src/actions.generated.js @@ -1,7 +1,7 @@ // GENERATED by scripts/actions.mjs from the WAI operation catalogue. Do not edit: change the // catalogue (wai-rs/src/catalogue.rs, exported by `wai ops export`), then run // `node scripts/actions.mjs`. `node scripts/actions.mjs --check` fails when this file differs. -export const CATALOGUE = {"format":"wai.ops/1","toolchain":"0.4.0","sha256":"ab16d7ceb6b7dfd8f50d469bc6efbb7451fee513da067a4408aeec078c4ffa10"}; +export const CATALOGUE = {"format":"wai.ops/1","toolchain":"0.4.0","sha256":"f63a5d9f34e021915cabe55a4fed30fbb0eea967d5beede72bc2b6aa9ab84efd"}; export const ACTIONS = [ {"id":"toolchain.test","noun":"toolchain","verb":"test","menu":"Toolchain","item":"Test","palette":"toolchain test","summary":"WAI's four steps (name, dispatch, rebuild, receipt) on a picture made here, its receipt checked offline","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai hello","mcp":null,"route":null,"call":"toolchain_test"}, {"id":"project.create","noun":"project","verb":"create","menu":"Project","item":"Create","palette":"project create","summary":"A new project from one of the examples, written into a new or empty directory; --list names the templates","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai create","mcp":null,"route":null,"call":"project_create"}, @@ -43,6 +43,9 @@ export const ACTIONS = [ {"id":"enrolment.list","noun":"enrolment","verb":"list","menu":"Enrolment","item":"List","palette":"enrolment list","summary":"The nodes a fleet service holds enrolled, each with its role, label and how many records it gave","placement":"client","effect":"reads","stability":"preview","toolset":"core","cli":"wai enrolment list","mcp":null,"route":null,"call":"enrolment_list"}, {"id":"report.create","noun":"report","verb":"create","menu":"Report","item":"Create","palette":"report create","summary":"The fleet report of a period, signed by a fleet service over the enrolments and records it holds","placement":"client","effect":"writes-target","stability":"preview","toolset":"core","cli":"wai report create","mcp":null,"route":null,"call":"report_create"}, {"id":"report.export","noun":"report","verb":"export","menu":"Report","item":"Export","palette":"report export","summary":"A fleet report's bundle fetched from a fleet service, its report recomputed here, then written to a file","placement":"client","effect":"writes-project","stability":"preview","toolset":"core","cli":"wai report export","mcp":null,"route":null,"call":"report_export"}, + {"id":"channel.import","noun":"channel","verb":"import","menu":"Channel","item":"Import","palette":"channel import","summary":"A linear channel's feeds (an XMLTV schedule, Media RSS catalogues or the JSON form, and a Media Playlist's SCTE-35 date ranges) imported into its signed receipt, its signed lineup and its breaks' cues","placement":"local","effect":"writes-project","stability":"preview","toolset":"core","cli":"wai channel import","mcp":null,"route":null,"call":"channel_import"}, + {"id":"channel.guide","noun":"channel","verb":"guide","menu":"Channel","item":"Guide","palette":"channel guide","summary":"A channel's guide, an XMLTV document or a now/next document, rendered from its receipt and its lineup and naming both","placement":"local","effect":"writes-project","stability":"preview","toolset":"core","cli":"wai channel guide","mcp":null,"route":null,"call":"channel_guide"}, + {"id":"channel.verify","noun":"channel","verb":"verify","menu":"Channel","item":"Verify","palette":"channel verify","summary":"A channel's receipt, its lineup and a guide verified together: the guide recomputed from the two and compared, each programme with whether it aired","placement":"local","effect":"reads","stability":"preview","toolset":"core","cli":"wai channel verify","mcp":null,"route":null,"call":"channel_verify"}, {"id":"mcp.start","noun":"mcp","verb":"start","menu":"Mcp","item":"Start","palette":"mcp start","summary":"WAI's operations as tools for a coding agent: a Model Context Protocol server on standard input and output, in toolsets","placement":"local","effect":"runs","stability":"preview","toolset":"core","cli":"wai mcp","mcp":null,"route":null,"call":"mcp_start"}, {"id":"agent.setup","noun":"agent","verb":"setup","menu":"Agent","item":"Setup","palette":"agent setup","summary":"A project made ready for coding agents: AGENTS.md, an import of it in a CLAUDE.md already there, and wai mcp registered in its MCP configuration","placement":"local","effect":"writes-project","stability":"preview","toolset":"core","cli":"wai setup-agent","mcp":null,"route":null,"call":"agent_setup"}, {"id":"app.guide","noun":"app","verb":"guide","menu":"App","item":"Guide","palette":"app guide","summary":"The line language and every WAI widget, as a model writing a WAI app is told them","placement":"local","effect":"reads","stability":"preview","toolset":"app","cli":"wai app guide","mcp":"app_guide","route":null,"call":"app_guide"},