wai: channel lineup and guide — a linear channel's feeds imported, signed (wai.video.lineup), and its guide checkable #540

Merged
dcharlot merged 15 commits from wai/fast-channel-guide into main 2026-10-30 02:17:33 -04:00
43 changed files with 8427 additions and 8 deletions

View file

@ -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/**'

View file

@ -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::"

View file

@ -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

View file

@ -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 |

View file

@ -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

View file

@ -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",

View file

@ -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 `<!DOCTYPE`, white space, a name (an ASCII letter or `_`, then ASCII
letters, digits, `_`, `-`, `.` and `:`), and then optionally white space and either `SYSTEM`,
white space and a literal, or `PUBLIC`, white space, a literal, white space and a literal;
then optional white space and `>`. 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:
```
<?xml version="1.0" encoding="UTF-8"?>
<tv source-data-url="urn:wai:guide:ROOT:LINEUP:FROM:TO">
<channel id="CHANNEL">
<display-name>NAME</display-name>
</channel>
<programme start="START" stop="STOP" channel="CHANNEL">
<title>TITLE</title>
<desc>DESCRIPTION</desc>
<category>CATEGORY</category>
<icon src="URI"/>
<episode-num system="wai-asset-id">ASSET</episode-num>
<rating system="SYSTEM">
<value>VALUE</value>
</rating>
</programme>
</tv>
```
- `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 `&amp;`, `&lt;` and `&gt;`; in an attribute value,
those and `"`, tab and line feed are written `&amp;`, `&lt;`, `&gt;`, `&quot;`, `&#9;` and
`&#10;`. 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.

File diff suppressed because it is too large Load diff

View file

@ -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 <dir>` 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.

View file

@ -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"
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180" viewBox="0 0 16 9"><rect width="16" height="9" fill="#1d4e89"/><text x="1" y="6" font-size="4" fill="#ffffff">HL 101</text></svg>

After

Width:  |  Height:  |  Size: 195 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180" viewBox="0 0 16 9"><rect width="16" height="9" fill="#1d4e89"/><text x="1" y="6" font-size="4" fill="#ffffff">HL 102</text></svg>

After

Width:  |  Height:  |  Size: 195 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180" viewBox="0 0 16 9"><rect width="16" height="9" fill="#3b3b3b"/><text x="1" y="6" font-size="4" fill="#ffffff">NF 1</text></svg>

After

Width:  |  Height:  |  Size: 193 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180" viewBox="0 0 16 9"><rect width="16" height="9" fill="#2f7d4a"/><text x="1" y="6" font-size="4" fill="#ffffff">TK 7</text></svg>

After

Width:  |  Height:  |  Size: 193 B

View file

@ -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"
}

View file

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:media="http://search.yahoo.com/mrss/"><!-- allow-name: the Media RSS namespace URI, a protocol identifier -->
<channel>
<title>Owner A titles</title>
<link>https://owner-a.example/</link>
<description>Owner A's catalogue for linear channels.</description>
<item>
<title>Harbour Lights</title>
<guid isPermaLink="false">hl-101</guid>
<media:content url="https://media.example/a/hl-101/index.m3u8" type="application/vnd.apple.mpegurl" duration="1620"/>
<media:title>Harbour Lights</media:title>
<media:description>A lighthouse keeper's first winter.</media:description>
<media:category>Drama</media:category>
<media:thumbnail url="https://art.example/sample/art-hl-101.svg?w=320" width="320" height="180"/>
<media:rating scheme="urn:v-chip">tv-pg</media:rating>
<media:restriction relationship="allow" type="country">us ca gb</media:restriction>
</item>
<item>
<guid isPermaLink="false">hl-102</guid>
<media:group>
<media:content url="https://media.example/a/hl-102/index.m3u8" duration="1620"/>
<media:content url="https://media.example/a/hl-102/low.m3u8" duration="1620"/>
</media:group>
<media:title>Harbour Lights</media:title>
<media:description>The supply boat is late.</media:description>
<media:category>Drama</media:category>
<media:thumbnail url="https://art.example/sample/art-hl-102.svg?w=320"/>
<media:rating scheme="urn:v-chip">tv-pg</media:rating>
<media:restriction relationship="deny" type="country">de</media:restriction>
</item>
<item>
<guid isPermaLink="false">hl-103</guid>
<media:content url="https://media.example/a/hl-103/index.m3u8" duration="1620"/>
<media:title>Harbour Lights</media:title>
</item>
</channel>
</rss>

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:media="http://search.yahoo.com/mrss/"><!-- allow-name: the Media RSS namespace URI, a protocol identifier -->
<channel>
<title>Owner B kitchen</title>
<item>
<guid>tk-7</guid>
<media:content url="https://media.example/b/tk-7.m3u8" duration="1650.000"/>
<media:title>Tidepool Kitchen</media:title>
<media:description>Kelp, crab &amp; lemon, on a beach stove.</media:description>
<media:category>Food</media:category>
<media:category>Family</media:category>
<media:thumbnail url="https://art.example/sample/art-tk-7.svg?w=320"/>
<media:restriction relationship="deny" type="country">none</media:restriction>
</item>
</channel>
</rss>

View file

@ -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"}

View file

@ -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"}

View file

@ -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"
}

View file

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<tv source-data-url="urn:wai:guide:d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b83:6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5:1793296800000:1793304000000">
<channel id="sample-1">
<display-name>Sample One</display-name>
</channel>
<programme start="20261029180000 +0000" stop="20261029183000 +0000" channel="sample-1">
<title>Harbour Lights</title>
<desc>A lighthouse keeper's first winter.</desc>
<category>Drama</category>
<icon src="https://art.example/sample/art-hl-101.svg?w=320"/>
<episode-num system="wai-asset-id">hl-101</episode-num>
<rating system="urn:v-chip">
<value>tv-pg</value>
</rating>
</programme>
<programme start="20261029183000 +0000" stop="20261029190000 +0000" channel="sample-1">
<title>Tidepool Kitchen</title>
<desc>Kelp, crab &amp; lemon, on a beach stove.</desc>
<category>Food</category>
<category>Family</category>
<icon src="https://art.example/sample/art-tk-7.svg?w=320"/>
<episode-num system="wai-asset-id">tk-7</episode-num>
</programme>
<programme start="20261029190000 +0000" stop="20261029193000 +0000" channel="sample-1">
<title>Harbour Lights</title>
<desc>The supply boat is late.</desc>
<category>Drama</category>
<icon src="https://art.example/sample/art-hl-102.svg?w=320"/>
<episode-num system="wai-asset-id">hl-102</episode-num>
<rating system="urn:v-chip">
<value>tv-pg</value>
</rating>
</programme>
<programme start="20261029193000 +0000" stop="20261029200000 +0000" channel="sample-1">
<title>Night Freight</title>
<desc>Overnight on the coast line &amp; the yards it feeds.</desc>
<category>Documentary</category>
<icon src="https://art.example/sample/art-nf-1.svg?w=320"/>
<episode-num system="wai-asset-id">nf-1</episode-num>
<rating system="VCHIP">
<value>TV-14</value>
</rating>
</programme>
</tv>

View file

@ -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"
}
]
}

View file

@ -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

View file

@ -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"}

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<tv source-data-url="urn:wai:guide:d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b83:6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5:1793299500000:1793301300000">
<channel id="sample-1">
<display-name>Sample One</display-name>
</channel>
<programme start="20261029183000 +0000" stop="20261029190000 +0000" channel="sample-1">
<title>Tidepool Kitchen</title>
<desc>Kelp, crab &amp; lemon, on a beach stove.</desc>
<category>Food</category>
<category>Family</category>
<icon src="https://art.example/sample/art-tk-7.svg?w=320"/>
<episode-num system="wai-asset-id">tk-7</episode-num>
</programme>
<programme start="20261029190000 +0000" stop="20261029193000 +0000" channel="sample-1">
<title>Harbour Lights</title>
<desc>The supply boat is late.</desc>
<category>Drama</category>
<icon src="https://art.example/sample/art-hl-102.svg?w=320"/>
<episode-num system="wai-asset-id">hl-102</episode-num>
<rating system="urn:v-chip">
<value>tv-pg</value>
</rating>
</programme>
</tv>

View file

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE tv SYSTEM "xmltv.dtd">
<tv source-info-name="sample feed" generator-info-name="sample">
<channel id="sample-1">
<display-name>Sample One</display-name>
</channel>
<channel id="sample-2">
<display-name>Sample Two</display-name>
</channel>
<programme start="20261029180000 +0000" stop="20261029183000 +0000" channel="sample-1">
<title lang="en">Harbour Lights</title>
<desc lang="en">A lighthouse keeper's first winter.</desc>
<category lang="en">Drama</category>
<icon src="https://art.example/sample/art-hl-101.svg?w=320"/>
<episode-num system="xmltv_ns">0.0.</episode-num>
<episode-num system="wai-asset-id">hl-101</episode-num>
</programme>
<programme start="20261029183000 +0000" stop="20261029190000 +0000" channel="sample-1">
<title lang="en">Tidepool Kitchen</title>
<episode-num system="wai-asset-id">tk-7</episode-num>
</programme>
<programme start="20261029200000 +0100" stop="20261029203000 +0100" channel="sample-1">
<title lang="en">Harbour Lights</title>
<episode-num system="wai-asset-id">hl-102</episode-num>
</programme>
<programme start="20261029193000" stop="20261029200000" channel="sample-1">
<title lang="en">Night Freight</title>
<desc lang="en">Overnight on the coast line &amp; the yards it feeds.</desc>
<category lang="en">Documentary</category>
<icon src="https://art.example/sample/art-nf-1.svg?w=320"/>
<episode-num system="wai-asset-id">nf-1</episode-num>
<rating system="VCHIP">
<value>TV-14</value>
</rating>
</programme>
<programme start="20261029180000 +0000" stop="20261029190000 +0000" channel="sample-2">
<title lang="en">Another Channel's Hour</title>
<episode-num system="wai-asset-id">other-1</episode-num>
</programme>
</tv>

File diff suppressed because it is too large Load diff

View file

@ -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",

View file

@ -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 ;

View file

@ -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"

View file

@ -0,0 +1,89 @@
//! The corpus of extensions/channel-lineup.md §11 (`live-conformance/channels/`).
//!
//! wai_channel_lineup_vectors gen <dir> write every file from the fixed seeds
//! wai_channel_lineup_vectors verify <dir> 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<String> = 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 <dir>");
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<String> = 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 <dir>");
ExitCode::from(2)
}
}
}

View file

@ -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<bool, OpError>;
/// 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<Vec<u8>, 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<Option<u64>, 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<Vec<u8>> {
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<u8>)]) -> 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<u8>, Vec<u8>), 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)
}
}
}
}

View file

@ -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 },

View file

@ -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 <dir> --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<bool> {
"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,
})

File diff suppressed because it is too large Load diff

View file

@ -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<u8> {
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<u8> {
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<u8> {
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!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"320\" height=\"180\" viewBox=\"0 0 16 9\"><rect width=\"16\" height=\"9\" fill=\"{fill}\"/><text x=\"1\" y=\"6\" font-size=\"4\" fill=\"#ffffff\">{label}</text></svg>\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#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE tv SYSTEM "xmltv.dtd">
<tv source-info-name="sample feed" generator-info-name="sample">
<channel id="sample-1">
<display-name>Sample One</display-name>
</channel>
<channel id="sample-2">
<display-name>Sample Two</display-name>
</channel>
<programme start="20261029180000 +0000" stop="20261029183000 +0000" channel="sample-1">
<title lang="en">Harbour Lights</title>
<desc lang="en">A lighthouse keeper's first winter.</desc>
<category lang="en">Drama</category>
<icon src="{a1}"/>
<episode-num system="xmltv_ns">0.0.</episode-num>
<episode-num system="wai-asset-id">hl-101</episode-num>
</programme>
<programme start="20261029183000 +0000" stop="20261029190000 +0000" channel="sample-1">
<title lang="en">Tidepool Kitchen</title>
<episode-num system="wai-asset-id">tk-7</episode-num>
</programme>
<programme start="20261029200000 +0100" stop="20261029203000 +0100" channel="sample-1">
<title lang="en">Harbour Lights</title>
<episode-num system="wai-asset-id">hl-102</episode-num>
</programme>
<programme start="20261029193000" stop="20261029200000" channel="sample-1">
<title lang="en">Night Freight</title>
<desc lang="en">Overnight on the coast line &amp; the yards it feeds.</desc>
<category lang="en">Documentary</category>
<icon src="{a4}"/>
<episode-num system="wai-asset-id">nf-1</episode-num>
<rating system="VCHIP">
<value>TV-14</value>
</rating>
</programme>
<programme start="20261029180000 +0000" stop="20261029190000 +0000" channel="sample-2">
<title lang="en">Another Channel's Hour</title>
<episode-num system="wai-asset-id">other-1</episode-num>
</programme>
</tv>
"#,
a1 = art("art-hl-101.svg").replace('&', "&amp;"),
a4 = art("art-nf-1.svg").replace('&', "&amp;"),
)
}
fn mrss_a() -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:media="http://search.yahoo.com/mrss/"><!-- allow-name: the Media RSS namespace URI, a protocol identifier -->
<channel>
<title>Owner A titles</title>
<link>https://owner-a.example/</link>
<description>Owner A's catalogue for linear channels.</description>
<item>
<title>Harbour Lights</title>
<guid isPermaLink="false">hl-101</guid>
<media:content url="https://media.example/a/hl-101/index.m3u8" type="application/vnd.apple.mpegurl" duration="1620"/>
<media:title>Harbour Lights</media:title>
<media:description>A lighthouse keeper's first winter.</media:description>
<media:category>Drama</media:category>
<media:thumbnail url="{a1}" width="320" height="180"/>
<media:rating scheme="urn:v-chip">tv-pg</media:rating>
<media:restriction relationship="allow" type="country">us ca gb</media:restriction>
</item>
<item>
<guid isPermaLink="false">hl-102</guid>
<media:group>
<media:content url="https://media.example/a/hl-102/index.m3u8" duration="1620"/>
<media:content url="https://media.example/a/hl-102/low.m3u8" duration="1620"/>
</media:group>
<media:title>Harbour Lights</media:title>
<media:description>The supply boat is late.</media:description>
<media:category>Drama</media:category>
<media:thumbnail url="{a2}"/>
<media:rating scheme="urn:v-chip">tv-pg</media:rating>
<media:restriction relationship="deny" type="country">de</media:restriction>
</item>
<item>
<guid isPermaLink="false">hl-103</guid>
<media:content url="https://media.example/a/hl-103/index.m3u8" duration="1620"/>
<media:title>Harbour Lights</media:title>
</item>
</channel>
</rss>
"#,
a1 = art("art-hl-101.svg").replace('&', "&amp;"),
a2 = art("art-hl-102.svg").replace('&', "&amp;"),
)
}
fn mrss_b() -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:media="http://search.yahoo.com/mrss/"><!-- allow-name: the Media RSS namespace URI, a protocol identifier -->
<channel>
<title>Owner B kitchen</title>
<item>
<guid>tk-7</guid>
<media:content url="https://media.example/b/tk-7.m3u8" duration="1650.000"/>
<media:title>Tidepool Kitchen</media:title>
<media:description>Kelp, crab &amp; lemon, on a beach stove.</media:description>
<media:category>Food</media:category>
<media:category>Family</media:category>
<media:thumbnail url="{a3}"/>
<media:restriction relationship="deny" type="country">none</media:restriction>
</item>
</channel>
</rss>
"#,
a3 = art("art-tk-7.svg").replace('&', "&amp;"),
)
}
/// The breaks: id, start, duration, the out section, the in section.
fn breaks() -> Vec<(&'static str, u64, u64, Vec<u8>)> {
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<Value> = 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::<Vec<_>>(),
"geo": match a.geo {
super::geo::ONLY => json!({"allow": a.countries.iter().map(|c| String::from_utf8_lossy(c).into_owned()).collect::<Vec<_>>()}),
super::geo::EXCEPT => json!({"deny": a.countries.iter().map(|c| String::from_utf8_lossy(c).into_owned()).collect::<Vec<_>>()}),
_ => 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::<Vec<_>>(),
"assets": assets,
"breaks": breaks().iter().map(|(id, s, d, sec)| json!({"id": id, "start_ms": s, "duration_ms": d, "scte35": hex(sec)})).collect::<Vec<_>>(),
});
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<String, Vec<u8>>, edits: &Value) -> Result<BTreeMap<String, Vec<u8>>, 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<String> {
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<String, Vec<u8>>) -> Result<Feeds, String> {
let file = |v: &Value| -> Result<Option<Vec<u8>>, 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<String, Vec<u8>>) -> Result<Result<Imported, Reason>, 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::<Vec<_>>(),
"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<ImportVector> {
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, "<!DOCTYPE tv SYSTEM \"xmltv.dtd\">", "<!DOCTYPE tv SYSTEM \"xmltv.dtd\" [<!ENTITY more \"more\">]>")]), 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, "<episode-num system=\"wai-asset-id\">tk-7</episode-num>", "<episode-num system=\"onscreen\">tk-7</episode-num>")]), Some(ImportAssetUnknown)),
("asset_duplicate", "two catalogue items with one guid", inputs(json!({})), json!([edit(a, "<guid isPermaLink=\"false\">hl-103</guid>", "<guid isPermaLink=\"false\">hl-101</guid>")]), 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, "<media:content url=\"https://media.example/a/hl-103/index.m3u8\" duration=\"1620\"/>", "<media:content url=\"https://media.example/a/hl-103/index.m3u8\"/>")]), 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<Reason>);
/// Each verification vector.
fn verification_vectors(sample: &Imported) -> Vec<VerificationVector> {
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, "</tv>\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</episode-num>", ">nf-1</episode-num>")])),
("guide_mismatch", "a guide whose title the lineup does not state", rc, good.clone(), Some(g), json!([edit(g, "<title>Night Freight</title>", "<title>Night Freight Live</title>")])),
("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<String, Vec<u8>>) -> Result<Value, String> {
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<String, Vec<u8>> {
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<String, String>) -> Vec<String> {
let mut fails = Vec::new();
let Some(doc) = files.get("vectors.json").and_then(|s| serde_json::from_str::<Value>(s).ok()) else {
return vec!["vectors.json: missing or not JSON".into()];
};
let held: BTreeMap<String, Vec<u8>> = 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
}

View file

@ -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<Vec<u8>>,
/// Each Media RSS document, with its owner when one is given.
pub mrss: Vec<(Vec<u8>, Option<String>)>,
pub json: Option<Vec<u8>>,
pub playlist: Option<Vec<u8>>,
pub owner: Option<String>,
pub id_system: Option<String>,
pub xmltv_channel: Option<String>,
pub start_ms: Option<u64>,
/// The artwork set: each file's bytes by its name.
pub artwork: BTreeMap<String, Vec<u8>>,
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<u8>,
}
/// 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<Cue>,
}
type R<T> = Result<T, Reason>;
/// 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<String>),
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<String>,
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<u64>,
owner: Option<String>,
}
/// 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<String>)>;
/// The JSON form as §4.4 reads it.
struct JsonRead {
channel_name: String,
programmes: Vec<Prog>,
assets: Catalogue,
breaks: Vec<Break>,
}
/// A break (§4.5).
#[derive(Clone, Debug)]
struct Break {
id: String,
start: u64,
duration: u64,
section: Option<Vec<u8>>,
}
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<String>| 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<Desc> {
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<String> = e.children_named("category").map(Element::text).collect();
field(categories.len() <= MAX_CATEGORIES && categories.iter().all(|c| is_short(c)))?;
let uris: Vec<Option<&str>> = 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<String>, 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<String>, seen: &mut HashSet<String>, count: &mut usize) -> R<(Option<String>, Vec<Item>)> {
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<u64> {
j?.as_u64().filter(|&v| v <= MAX_INSTANT)
}
fn strs(j: Option<&Json>) -> Option<Vec<String>> {
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<String>,
rating: Option<(String, String)>,
artwork: Vec<(String, String)>,
geo: Option<(bool, Vec<String>)>,
rights_holder: Option<String>,
duration_ms: u64,
}
struct JsonBreak {
id: String,
start_ms: u64,
duration_ms: u64,
scte35: Option<String>,
}
struct JsonForm {
channel_name: String,
programmes: Vec<(u64, u64, String)>,
assets: Vec<JsonAsset>,
breaks: Vec<JsonBreak>,
}
/// The JSON form's structure (§4.4): `None` when it does not read so.
fn json_structure(b: &[u8]) -> Option<JsonForm> {
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<Vec<u8>> {
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<u64> {
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<JsonRead> {
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<Vec<u8>> {
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<Vec<Break>> {
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<u8> {
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<Imported> {
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<Option<String>> = Vec::new();
let mut xmltv: Option<Described> = 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<Item> = 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<Break> = Vec::new();
let mut json: Option<(Vec<Prog>, 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<Prog> = 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<Asset> = Vec::with_capacity(order.len());
for id in order {
let (desc, holder): (Desc, Option<String>) = 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<&Break>> = 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<ScheduleEntry> = 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 })
}

View file

@ -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"<?xml version=\"1.0\"?>\n<!-- c --><!DOCTYPE tv SYSTEM \"xmltv.dtd\">\n<tv/>";
assert_eq!(read_feed_xml(ok).map(|e| e.name), Some("tv".to_string()));
assert!(read_feed_xml(b"<!DOCTYPE tv PUBLIC 'a' \"b\"><tv/>").is_some());
assert!(read_feed_xml(b"<!DOCTYPE tv><tv/>").is_some());
assert!(read_feed_xml(b"<!DOCTYPE tv [<!ENTITY x \"y\">]><tv/>").is_none(), "an internal subset is outside the subset");
assert!(read_feed_xml(b"<!DOCTYPE tv><!DOCTYPE tv><tv/>").is_none(), "a second declaration is outside it");
assert!(read_feed_xml(b"<!DOCTYPEtv><tv/>").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<u8> {
(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");
}

View file

@ -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),

View file

@ -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.

View file

@ -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<String, String> {
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<String, String> = 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<String> = table.lines().filter(|l| l.starts_with("| `")).filter_map(|l| l.split('`').nth(1).map(String::from)).collect();
let want: Vec<String> = 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<Vec<String>> {
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::<Vec<_>>();
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</episode-num>", ">nf-1</episode-num>");
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<u8> = (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<bool> = 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);
}

View file

@ -412,6 +412,25 @@ fn samples(d: &Path, origin_port: u16) -> Vec<(&'static str, Vec<String>, 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<u8> = (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

View file

@ -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"},