wai: channel lineup and guide — a linear channel's feeds imported, signed (wai.video.lineup), and its guide checkable #540
43 changed files with 8427 additions and 8 deletions
2
.github/workflows/wai-catalogue.yml
vendored
2
.github/workflows/wai-catalogue.yml
vendored
|
|
@ -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/**'
|
||||
|
|
|
|||
35
.github/workflows/wai-conformance.yml
vendored
35
.github/workflows/wai-conformance.yml
vendored
|
|
@ -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::"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
15
wai/SPEC.md
15
wai/SPEC.md
|
|
@ -1302,6 +1302,21 @@ spend that counts, by currency; an ad whose segments were fetched ahead of its b
|
|||
presented has no claim and counts nothing:
|
||||
[`extensions/stitched-presentation.md`](extensions/stitched-presentation.md).
|
||||
|
||||
And for a **linear channel's lineup and guide** — `wai.video.lineup`: the channel operator's
|
||||
signed lineup, `WLU1`, of one `wai.video.channel` schedule, bound to it by the schedule root: each
|
||||
programme asset's title, description, categories, rating, artwork by content hash, the countries
|
||||
it may be shown in and its rights holder, and where each programme of the guide begins. A channel
|
||||
as operators exchange it (an XMLTV schedule, Media RSS catalogues or a JSON form of both, with the
|
||||
SCTE-35 date ranges of its HLS Media Playlist as its ad breaks) is imported into the schedule's
|
||||
receipt, whose signed bytes are unchanged, the lineup, and a live-events `cue` for each break. The
|
||||
import reads bounded documents over an XML subset that defines no entity, and refuses, each with
|
||||
its reason, a gap, an overlap, a programme with no stop, a time zone named by letters, a break
|
||||
outside its programme and a window outside the schedule. The guide, an XMLTV document or a
|
||||
now/next document for a player's guide row, is rendered from the receipt and the lineup and names
|
||||
both, so a verifier recomputes it and compares, and states of each of its programmes whether the
|
||||
receipt's program-as-run says it aired:
|
||||
[`extensions/channel-lineup.md`](extensions/channel-lineup.md).
|
||||
|
||||
And for **watching together** in a room — `wai.live.cowatch`: people in different places
|
||||
watching one source in sync, with the sync measured. The room authority signs a co-watch
|
||||
policy, `WXP1`, naming the source (a WAI package, a sequence of on-demand objects, or another
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
764
wai/extensions/channel-lineup.md
Normal file
764
wai/extensions/channel-lineup.md
Normal 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 `&`, `<` and `>`; in an attribute value,
|
||||
those and `"`, tab and line feed are written `&`, `<`, `>`, `"`, `	` and
|
||||
` `. Every other character is written as itself.
|
||||
|
||||
The guide's `episode-num` system is `wai-asset-id`, the import's default `id_system` (§4.1), so
|
||||
a guide imports back into the programmes it lists.
|
||||
|
||||
### 7.3 The now/next document
|
||||
|
||||
The now/next document of an instant `at` is the JCS serialisation (RFC 8785) of this object,
|
||||
followed by one line feed:
|
||||
|
||||
| member | value |
|
||||
|---|---|
|
||||
| `kind` | `wai-now-next` |
|
||||
| `channel`, `channel_name` | the lineup's `channel_id` and `channel_name` |
|
||||
| `schedule_root` | the receipt's, 64 lowercase hexadecimal digits |
|
||||
| `lineup` | the lineup's digest, 64 lowercase hexadecimal digits |
|
||||
| `at_ms` | `at` |
|
||||
| `now` | the programme whose span holds `at`, or `null` |
|
||||
| `next` | when `now` is a programme, the one after it, or `null` for the last; when `at` is before the schedule's first start, the first programme; otherwise `null` |
|
||||
|
||||
A programme is an object with the members `start_ms`, `stop_ms` and `asset_id`; `title`,
|
||||
`description` and `content_uri`; `categories`, an array of strings; `rating`, `null` or
|
||||
`{"system", "value"}`; `artwork`, an array of `{"sha256", "uri"}`; `geo`, `null` for everywhere,
|
||||
`{"allow": [...]}` or `{"deny": [...]}`; `rights_holder`; and `duration_ms`.
|
||||
|
||||
## 8. Verification
|
||||
|
||||
### 8.1 Inputs
|
||||
|
||||
A verifier is given a receipt in its JSON carriage (§5), a lineup's bytes, and optionally a
|
||||
guide's bytes: an XMLTV guide when its first byte is `<`, a now/next document when it is `{`.
|
||||
|
||||
### 8.2 Steps
|
||||
|
||||
A verification gets one verdict, **verified** or the reason of the first step it fails:
|
||||
|
||||
1. **The receipt** reads (§5): `channel_receipt_malformed`.
|
||||
2. Its signature verifies under `signer_pubkey` (§5): `bad_key`, `bad_signature`.
|
||||
3. Its rules hold (§5): `channel_receipt_invalid`.
|
||||
4. **The lineup** reads (§6.4): `lineup_malformed`.
|
||||
5. Its signature verifies under `signer` (§6.3): `bad_key`, `bad_signature`.
|
||||
6. Its `signer` is the receipt's `signer_pubkey`: one operator signs both. Otherwise
|
||||
`lineup_signer_mismatch`.
|
||||
7. It describes the receipt's schedule (§6.5): `lineup_schedule_mismatch`.
|
||||
8. **The guide**, when one is given, reads: `guide_malformed`. An XMLTV guide reads when it is in
|
||||
the subset of §3.3 with the root `tv`; its `source-data-url` is `urn:wai:guide:`, two runs of
|
||||
64 lowercase hexadecimal digits and two decimal integers with no leading zero, the first below
|
||||
the second, separated by `:`; and each of its `programme` children has `start` and `stop`
|
||||
attributes that are XMLTV times (§3.4) and an `episode-num` child of system `wai-asset-id`,
|
||||
the text of the first of which is its asset id. A now/next document reads when it is one JSON
|
||||
object whose members are those of §7.3, with `at_ms` an integer of at most 2^53 − 1,
|
||||
`schedule_root` and `lineup` strings, and `now` and `next` each `null` or an object whose `start_ms` and `stop_ms` are
|
||||
integers and whose `asset_id` is a string.
|
||||
9. It names the receipt's `schedule_root` and the lineup's digest: `guide_source_mismatch`.
|
||||
10. Each of its programmes is the lineup's: for an XMLTV guide, each programme has the start, the
|
||||
end and the asset of one of the programmes of the guide's range (§7.1); for a now/next
|
||||
document, `now` and `next` have those of the programmes §7.3 names for its `at_ms`, and are
|
||||
`null` where those are. Otherwise `guide_entry_unscheduled`, at the first programme, in the
|
||||
guide's order, that is not (its index among the guide's `programme` elements; for a now/next
|
||||
document, `now` 0 and `next` 1).
|
||||
11. Its bytes are the guide of §7.2 for its range, or the document of §7.3 for its `at_ms`:
|
||||
`guide_mismatch`.
|
||||
|
||||
Step 10 catches a guide that places a programme where the schedule does not; step 11 every other
|
||||
difference: a title, a rating or a picture the lineup does not state, a programme left out.
|
||||
|
||||
### 8.3 What a verification states
|
||||
|
||||
- its verdict, and the **part** it is about: `receipt` for steps 1 to 3, `lineup` for steps 4 to
|
||||
7, `guide` for steps 8 to 11, none when verified;
|
||||
- the receipt's identity, when it reads; and the lineup's digest, when it reads;
|
||||
- for a guide that reads: its format (`xmltv` or `now-next`), its range or its `at_ms`, and its
|
||||
programmes, each with its start, its end, its asset id and whether it **aired**: whether an
|
||||
entry the receipt's `aired` lists meets its span. A guide shows what the schedule placed; the
|
||||
receipt states which of it was on air in its window;
|
||||
- for `guide_entry_unscheduled`, the index of step 10.
|
||||
|
||||
## 9. Operations and a worked example
|
||||
|
||||
Three operations of the WAI command line carry this text (the operation catalogue,
|
||||
`wai/catalogue/ops.json`), each with the toolchain's agent rules: `--json` for the envelope,
|
||||
`--dry-run` on an operation that writes, and `--yes` before a file is replaced.
|
||||
|
||||
- `channel.import` (`wai channel import`): feeds in; the receipt (`channel.json`, its JSON
|
||||
carriage), the lineup (`lineup.wlu`) and the cues (`cues.json`) out, into a directory. A
|
||||
refusal's code is the error's code, and its exit status 1.
|
||||
- `channel.guide` (`wai channel guide`): a receipt and a lineup in; an XMLTV guide of a range
|
||||
(the whole schedule unless `--from` and `--to` say otherwise), or with `--now-next --at` a
|
||||
now/next document, out.
|
||||
- `channel.verify` (`wai channel verify`): a receipt, a lineup and optionally a guide in; what
|
||||
§8.3 states out, exit status 0 when verified and 1 otherwise.
|
||||
|
||||
The corpus's sample channel (§11) is imported, guided and verified so:
|
||||
|
||||
```
|
||||
wai channel import --channel sample-1 --xmltv sample.xmltv.xml \
|
||||
--mrss sample-a.mrss.xml --mrss-owner owner-a --mrss sample-b.mrss.xml --mrss-owner owner-b \
|
||||
--owner owner-c --playlist sample.m3u8 --artwork . --key operator.key --signer-id sample-operator \
|
||||
--window-start 1793296800000 --window-end 1793300400000 --out-dir out
|
||||
wai channel guide --receipt out/channel.json --lineup out/lineup.wlu --out guide.xml
|
||||
wai channel guide --receipt out/channel.json --lineup out/lineup.wlu --now-next --at 1793299545000 --out now-next.json
|
||||
wai channel verify --receipt out/channel.json --lineup out/lineup.wlu --guide guide.xml
|
||||
```
|
||||
|
||||
The test `tests/channel_lineup.rs` runs these commands on the corpus's files and requires the
|
||||
corpus's receipt, lineup, cues and guides from them, byte for byte.
|
||||
|
||||
## 10. Reasons
|
||||
|
||||
The codes this extension adds, each with where it is raised:
|
||||
|
||||
| reason | raised at |
|
||||
|---|---|
|
||||
| `import_inputs_invalid` | §4.1 |
|
||||
| `import_oversized` | §3.1, §4.6 step 3 |
|
||||
| `import_xml_malformed` | §3.3 |
|
||||
| `import_json_malformed` | §4.4 |
|
||||
| `import_playlist_malformed` | §4.5 |
|
||||
| `import_time_malformed` | §3.4, §4.2, §4.3, §4.4, §4.6 |
|
||||
| `import_timezone_unknown` | §3.4 |
|
||||
| `import_no_stop` | §4.2 step 2 |
|
||||
| `import_empty` | §4.2, §4.6 |
|
||||
| `import_asset_unknown` | §4.2 step 3, §4.3 step 1, §4.6 step 1 |
|
||||
| `import_asset_duplicate` | §4.3 step 1, §4.4 |
|
||||
| `import_field_invalid` | §3.2, §4.2 to §4.5 |
|
||||
| `import_scte35_malformed` | §4.5 step 3 |
|
||||
| `import_break_duration` | §4.5 step 4 |
|
||||
| `import_duration_unknown` | §4.6 |
|
||||
| `import_gap` | §4.6 |
|
||||
| `import_overlap` | §4.6, §4.7 |
|
||||
| `import_owner_unknown` | §4.6 step 2 |
|
||||
| `import_artwork_missing` | §4.6 step 3 |
|
||||
| `import_break_outside` | §4.7 |
|
||||
| `import_window_outside` | §4.9 |
|
||||
| `channel_receipt_malformed` | §8.2 step 1 |
|
||||
| `channel_receipt_invalid` | §8.2 step 3 |
|
||||
| `lineup_malformed` | §6.4, §8.2 step 4 |
|
||||
| `lineup_signer_mismatch` | §8.2 step 6 |
|
||||
| `lineup_schedule_mismatch` | §8.2 step 7 |
|
||||
| `guide_malformed` | §8.2 step 8 |
|
||||
| `guide_source_mismatch` | §8.2 step 9 |
|
||||
| `guide_entry_unscheduled` | §8.2 step 10 |
|
||||
| `guide_mismatch` | §8.2 step 11 |
|
||||
|
||||
A verification also gives live-events' `bad_key` and `bad_signature` (§8.2 steps 2 and 5).
|
||||
|
||||
## 11. Conformance
|
||||
|
||||
[`live-conformance/channels/`](../live-conformance/channels/) holds the corpus, written from fixed
|
||||
seeds by `wai_channel_lineup_vectors gen`: `vectors.json`, which holds the operator's key and
|
||||
each vector's inputs and outcome; the feeds of a sample channel (an XMLTV document with a second
|
||||
channel's programmes beside it, two Media RSS documents of two content owners, a Media Playlist
|
||||
with SCTE-35 date ranges, and the JSON form of the same channel); the artwork files; the receipt
|
||||
and the guides the sample yields. Every document is a fixture written for its case, of
|
||||
programmes, owners and pictures made for it. The vectors:
|
||||
|
||||
- **imports**: the sample from XMLTV, Media RSS and the playlist; the same channel from the JSON
|
||||
form, which yields the same receipt and lineup byte for byte; a channel made of one owner's
|
||||
Media RSS items alone, back to back; and a control of each refusal of §4, each the sample with
|
||||
one change (an edit the vector states, which a verifier applies to its copy);
|
||||
- **verifications**: the sample's receipt and lineup alone, with its XMLTV guide, with a guide of
|
||||
part of the schedule, and with now/next documents before, inside and after the schedule; and a
|
||||
control of each step of §8.2 (a receipt that does not read, a flipped signature, a key of small
|
||||
order, a program-as-run the window does not give, a lineup that does not read, one another key
|
||||
signed, one for each rule of §6.5 that it alone breaks, a guide that does not read, one naming
|
||||
another schedule, one that places a programme the schedule does not, and one whose title the
|
||||
lineup does not state).
|
||||
|
||||
A conforming implementation gives every import its outcome (the receipt, the lineup and the cues,
|
||||
or the refusal) and every verification its verdict and facts, as the corpus states.
|
||||
`wai_channel_lineup_vectors verify` and the test `tests/channel_lineup.rs` check that every file
|
||||
regenerates byte for byte, that `wai-rs` gives every vector its outcome, that §10 lists exactly
|
||||
the codes the reference adds, and that the corpus gives every reason of §10 and both of
|
||||
live-events'. `live-conformance/channel_lineup_verify.py` is the second implementation, written
|
||||
from this text: it shares no code with `wai-rs`; it verifies Ed25519 with WAI's strict rule
|
||||
(`claim-conformance/_ed25519.py`), BLAKE3 with `claim-conformance/_blake3.py`, and reads a channel
|
||||
receipt's carriage, its signed bytes, its identity and its rules as the revenue-share corpus's
|
||||
second implementation reads them (§5); it reads XML, JSON and playlists itself; and it mutates copies of the corpus and requires
|
||||
every copy to fail, and drops each of its own checks in turn and requires the corpus to catch the
|
||||
weakened verifier.
|
||||
|
||||
## 12. Security and privacy considerations
|
||||
|
||||
- **What a lineup proves.** That the operator stated this description of each programme asset of
|
||||
one schedule, and where its guide's programmes begin. It does not prove that a rights holder
|
||||
agreed: a rights holder's name in a lineup is the operator's statement, and a party paid by it
|
||||
checks a contract it signed, not the lineup.
|
||||
- **What a guide proves.** Nothing by itself: it is recomputed from a receipt and a lineup and
|
||||
compared, so a guide that verifies says exactly what the two signed records say, and a guide a
|
||||
third party altered is refused. A guide is a schedule published ahead of air; which of it was on
|
||||
air in a window is the receipt's program-as-run (§8.3), and a guide covering a span the receipt's
|
||||
window does not is checked against the schedule alone.
|
||||
- **Artwork.** A lineup binds each picture by its SHA-256, so a player that fetches the URI and
|
||||
checks the hash shows the picture the operator stated, wherever it was fetched from.
|
||||
- **Bounded reading.** An import reads bounded documents (§3.1), XML to a bounded depth with no
|
||||
document type declaration that defines anything (§3.3), so no entity is ever expanded, and
|
||||
integers it reads are checked against their bounds before any arithmetic.
|
||||
- **Availability.** Where an asset may be shown is the operator's statement of the catalogue's
|
||||
restriction; a player enforces it, or a delivery that a `wai.video.manifest` describes does.
|
||||
A lineup holds no viewer's data.
|
||||
1390
wai/live-conformance/channel_lineup_verify.py
Normal file
1390
wai/live-conformance/channel_lineup_verify.py
Normal file
File diff suppressed because it is too large
Load diff
46
wai/live-conformance/channels/README.md
Normal file
46
wai/live-conformance/channels/README.md
Normal 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.
|
||||
90
wai/live-conformance/channels/aired-misreported.channel.json
Normal file
90
wai/live-conformance/channels/aired-misreported.channel.json
Normal 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"
|
||||
}
|
||||
1
wai/live-conformance/channels/art-hl-101.svg
Normal file
1
wai/live-conformance/channels/art-hl-101.svg
Normal 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 |
1
wai/live-conformance/channels/art-hl-102.svg
Normal file
1
wai/live-conformance/channels/art-hl-102.svg
Normal 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 |
1
wai/live-conformance/channels/art-nf-1.svg
Normal file
1
wai/live-conformance/channels/art-nf-1.svg
Normal 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 |
1
wai/live-conformance/channels/art-tk-7.svg
Normal file
1
wai/live-conformance/channels/art-tk-7.svg
Normal 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 |
44
wai/live-conformance/channels/media_rss_loop.channel.json
Normal file
44
wai/live-conformance/channels/media_rss_loop.channel.json
Normal 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"
|
||||
}
|
||||
37
wai/live-conformance/channels/sample-a.mrss.xml
Normal file
37
wai/live-conformance/channels/sample-a.mrss.xml
Normal 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>
|
||||
16
wai/live-conformance/channels/sample-b.mrss.xml
Normal file
16
wai/live-conformance/channels/sample-b.mrss.xml
Normal 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 & 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>
|
||||
1
wai/live-conformance/channels/sample.after.now-next.json
Normal file
1
wai/live-conformance/channels/sample.after.now-next.json
Normal 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"}
|
||||
|
|
@ -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"}
|
||||
96
wai/live-conformance/channels/sample.channel.json
Normal file
96
wai/live-conformance/channels/sample.channel.json
Normal 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"
|
||||
}
|
||||
44
wai/live-conformance/channels/sample.guide.xml
Normal file
44
wai/live-conformance/channels/sample.guide.xml
Normal 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 & 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 & 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>
|
||||
150
wai/live-conformance/channels/sample.json
Normal file
150
wai/live-conformance/channels/sample.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
17
wai/live-conformance/channels/sample.m3u8
Normal file
17
wai/live-conformance/channels/sample.m3u8
Normal 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
|
||||
1
wai/live-conformance/channels/sample.now-next.json
Normal file
1
wai/live-conformance/channels/sample.now-next.json
Normal 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"}
|
||||
24
wai/live-conformance/channels/sample.part.guide.xml
Normal file
24
wai/live-conformance/channels/sample.part.guide.xml
Normal 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 & 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>
|
||||
40
wai/live-conformance/channels/sample.xmltv.xml
Normal file
40
wai/live-conformance/channels/sample.xmltv.xml
Normal 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 & 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>
|
||||
1787
wai/live-conformance/channels/vectors.json
Normal file
1787
wai/live-conformance/channels/vectors.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 ;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
89
wai/wai-rs/src/bin/wai_channel_lineup_vectors.rs
Normal file
89
wai/wai-rs/src/bin/wai_channel_lineup_vectors.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
293
wai/wai-rs/src/bin/wai_cli/dx/channel.rs
Normal file
293
wai/wai-rs/src/bin/wai_cli/dx/channel.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
1122
wai/wai-rs/src/channel_lineup.rs
Normal file
1122
wai/wai-rs/src/channel_lineup.rs
Normal file
File diff suppressed because it is too large
Load diff
689
wai/wai-rs/src/channel_lineup/corpus.rs
Normal file
689
wai/wai-rs/src/channel_lineup/corpus.rs
Normal 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 & 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('&', "&"),
|
||||
a4 = art("art-nf-1.svg").replace('&', "&"),
|
||||
)
|
||||
}
|
||||
|
||||
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('&', "&"),
|
||||
a2 = art("art-hl-102.svg").replace('&', "&"),
|
||||
)
|
||||
}
|
||||
|
||||
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 & 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('&', "&"),
|
||||
)
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
783
wai/wai-rs/src/channel_lineup/import.rs
Normal file
783
wai/wai-rs/src/channel_lineup/import.rs
Normal 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 })
|
||||
}
|
||||
73
wai/wai-rs/src/channel_lineup/tests.rs
Normal file
73
wai/wai-rs/src/channel_lineup/tests.rs
Normal 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");
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
150
wai/wai-rs/tests/channel_lineup.rs
Normal file
150
wai/wai-rs/tests/channel_lineup.rs
Normal 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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
Loading…
Reference in a new issue