From 67dfefb689f096f718bab2e65e26df22b471daa1 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 29 Oct 2026 17:12:37 -0400 Subject: [PATCH 01/11] wai: channel lineup and guide, the text (extensions/channel-lineup.md) The import of a linear channel's feeds (XMLTV, Media RSS, a JSON form, a Media Playlist's SCTE-35 date ranges) into a wai.video.channel receipt and a signed lineup (WCL1); the receipt's JSON carriage, its signed bytes unchanged; the guide, XMLTV or now/next, rendered from both and verified by recomputation; the operations channel.import, channel.guide and channel.verify; the reasons. Co-Authored-By: Claude Opus 5.5 --- wai/extensions/channel-lineup.md | 797 +++++++++++++++++++++++++++++++ 1 file changed, 797 insertions(+) create mode 100644 wai/extensions/channel-lineup.md diff --git a/wai/extensions/channel-lineup.md b/wai/extensions/channel-lineup.md new file mode 100644 index 000000000..da5f9aea5 --- /dev/null +++ b/wai/extensions/channel-lineup.md @@ -0,0 +1,797 @@ +# WAI Extension: Channel Lineup and Guide + +> Status: Draft. Registers the record `wai.video.lineup`: a linear channel's signed **lineup**, +> `WCL1`, 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; this text gives it a JSON carriage. 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 of a +`wai.video.channel` receipt (§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; 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 `[a, b)` holds the instants from `a`, included, to `b`, +excluded. Integers of the lineup are little-endian. A digest is SHA-256 (FIPS 180-4), 32 bytes. +Ed25519 is RFC 8032's, verified strictly ([SPEC](../SPEC.md) §7 *Signatures*): a key that fails +step 1 or 2 is `bad_key`, and a signature that fails a later step is `bad_signature`. Hexadecimal +written by this text is lowercase. + +## 3. Reading + +### 3.1 Bounds + +An import refuses `import_oversized` a feed or an artwork file of more than 16 777 216 bytes, and +a feed that holds more than 10 000 programmes of the channel (XMLTV, the JSON form), more than +10 000 items (Media RSS, all its documents together), more than 10 000 assets (the JSON form) or +more than 10 000 breaks (the JSON form and the playlist together). The size of each feed is +checked before it is read, and each count as soon as its document has been read as XML, JSON or +lines, before any of its elements is read further. An XML document is read to a depth of 64 +elements at most (§3.3). An import therefore reads a bounded amount for any input. + +### 3.2 Text values + +A **text** is a sequence of Unicode scalar values in UTF-8 with no character below U+0020 other +than U+0009 (tab) and U+000A (line feed). A **short text** is a text of 1 to 255 bytes; a +**long text** a text of 0 to 65 535 bytes. A value an import reads that must be one of these and +is not is refused `import_field_invalid`. Text is taken as the feed writes it; nothing is +normalised. + +### 3.3 The XML subset + +An XMLTV or Media RSS document is read over the XML subset of +[stitched-presentation](stitched-presentation.md) §3.5, with its local names and its text of an +element, and with one addition: before the root element, where that subset admits a comment, the +document **MAY** hold one document type declaration with no internal subset, which is skipped. +Such a declaration is ``. A literal is `"`, bytes other than `"`, and `"`; or `'`, +bytes other than `'`, and `'`. White space is one or more of space, tab, CR and LF. A +declaration of any other form (one with an internal subset among them), or a second one, is +outside the subset. A document outside the subset, or whose root element is not the one §4.2 or +§4.3 names, is refused `import_xml_malformed`. + +An element's **first** `x` child is its first child element of local name `x`. Elements are +named by local name only, so a Media RSS element and an RSS 2.0 element of one local name +(`media:title` and `title`, for one) are both `title` children; §4.3 says which one an import +takes. + +### 3.4 Times and durations + +An **XMLTV time** is 14 ASCII digits `YYYYMMDDhhmmss`, optionally followed by one space and a +numeric zone: `+` or `-` and four digits `HHMM` with `HH` at most 23 and `MM` at most 59. With no +zone the time is UTC, as the DTD says. The instant is the UTC instant of the date and time, less +the zone's offset. A value is refused: + +- `import_timezone_unknown` when its first 14 bytes are digits and what follows them is a space + and anything other than a numeric zone (a zone named by letters, for one); +- `import_time_malformed` otherwise when it is not of that form, when its year is below 1970, its + month not 1 to 12, its day not a day of that month in the Gregorian calendar, its hour above 23, + its minute above 59 or its second above 59, or when its instant is before 1970-01-01T00:00:00Z. + +An **HLS date-time** is `YYYY-MM-DDThh:mm:ss`, optionally `.` and one or more digits, then `Z` or +a zone `+hh:mm` or `-hh:mm` with `hh` at most 23 and `mm` at most 59, the fields as above. Its +fraction is read to the millisecond, and every digit after the third **MUST** be 0. A value of +this form without a zone is refused `import_timezone_unknown`; any other value that is not of +this form, or breaks a rule above, is refused `import_time_malformed`. + +A **duration** is a decimal-floating-point value (`draft-pantos-hls-rfc8216bis-22` §4.2): one or +more digits, optionally `.` and one or more digits, a number of seconds. It is read to the +millisecond, every fractional digit after the third **MUST** be 0, and the count of milliseconds +**MUST** be at most 2^53 − 1; otherwise it is refused `import_time_malformed`. + +## 4. Import + +### 4.1 Inputs + +An import is given: + +- **channel**: the channel id, a short text; +- **feeds**, one of these sets, each optionally with one Media Playlist: + - an XMLTV document, and zero or more Media RSS documents; + - one or more Media RSS documents, with no XMLTV document; + - one JSON form (§4.4), and no XMLTV or Media RSS document; +- for each Media RSS document, optionally its **owner**, the rights holder of its items: a short + text; +- **owner**: optionally, the rights holder of every asset that no other input gives one: a short + text; +- **id_system**: the XMLTV `episode-num` system that names a programme's asset (§4.2); when not + given, `wai-asset-id`, the system a guide writes (§7.2); +- **xmltv_channel**: the XMLTV channel id whose programmes are read; when not given, `channel`; +- **start**: an instant, which a schedule made of Media RSS items alone requires (§4.6); +- **artwork**: a set of files, each named by a file name (§4.6); +- **window**: optionally, a span `[window_start, window_end)` of the receipt (§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 `[start_ms, 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, +`[start, start + 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. Its **JSON carriage**, in which an import +writes it and a verifier reads it, is one JSON object with exactly these members, a byte field +lowercase hexadecimal of its exact length: + +| member | value | +|---|---| +| `channel_id` | a string | +| `schedule_root` | 32 bytes | +| `schedule` | an array of entries, each `{"asset_id", "start_ms", "duration_ms", "kind"}`: a string, two integers, and `program`, `ad` or `filler` | +| `window_start_ms`, `window_end_ms` | integers | +| `aired` | an array of integers: the indices of the entries on air in the window | +| `work_schedule_ops`, `joules_micro` | integers | +| `energy_provenance` | its class's label, or `null` unmetered; and the members a class with evidence carries, as live-ads §8.2 has them. The legacy label `HwShunt` does not read in this carriage | +| `grant` | `{"grant_hash", "capability", "joule_ceiling_micro", "funds_ceiling"}`, `funds_ceiling` `null` for none | +| `parent_receipt_hash` | 32 bytes, or `null` | +| `signer_pubkey` | 32 bytes | +| `signer_id` | a string | +| `sig` | 64 bytes | + +Its integers are written as §4.4 writes them, but up to 2^64 − 1, and an entry whose start and +duration sum above 2^64 − 1 does not read. The receipt's integers below are big-endian. An entry's **leaf** is +BLAKE3 over `wai:video-channel-entry` (ASCII) and 0x01, the length (u32) and bytes of its +`asset_id`, its `start_ms` and `duration_ms` (u64 each), and its kind (1 program, 2 ad, 3 filler, +one byte). Its **signing payload** is the concatenation of `wai:video-channel-receipt` (ASCII) +and 0x01; the length (u32) and bytes of `channel_id`; `schedule_root`; `window_start_ms`, +`window_end_ms`, `work_schedule_ops` and `joules_micro` (u64 each); the class in its signed +encoding (energy-measurement §2.7), the tag byte 0 for none; `grant_hash`, the length (u32) and +bytes of `capability`, `joule_ceiling_micro` (u64), and 1 and `funds_ceiling` (u64), or 0; 1 and +`parent_receipt_hash`, or 0; the count (u64) of `aired` and each index (u64); the count (u64) of +the entries and each one's leaf; and `signer_pubkey`, then the length (u32) and bytes of +`signer_id`. `sig` is Ed25519 by `signer_pubkey` over the payload, and the receipt's **identity** +(its receipt hash) is BLAKE3 over `wai:video-channel-receipt-id` (ASCII) and 0x01, the payload +and `sig`. + +A receipt's **rules** hold when `schedule_root` is the Merkle root of its entries' leaves +(jwp-receipts §2.2, each leaf of its first form; 32 zero bytes for no entry); `work_schedule_ops` +is the count of its entries; its window is not empty; each entry begins where the one before it +ends; `aired` is exactly the indices, in increasing order, of the entries whose span meets the +window (an entry that begins before the window's end and ends after its start); and its figure +and class cohere, as live-ads §8.5 rule 5 has them: `joules_micro` 0 with no class, or above 0 +with a class, a hardware class's declared uncertainty with `relative_ppm` and `window_us` above 0 +and at the resolution floor (`joules_micro` × `relative_ppm` at least 288 676), and a calibrated +instrument's reference holding a character that is not Unicode White_Space. A receipt **verifies** when its signature verifies and its rules hold. + +## 6. The lineup + +### 6.1 Layout + +| size | field | meaning | +|---|---|---| +| 4 | `magic` | `WCL1` (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 `WCL1` 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, to)`, with `from` below `to`, are the lineup's programmes whose span meets it (a +programme that begins before `to` and ends after `from`), in order, each with its start, its +end, its asset's id and its asset's entry (§6.2). + +### 7.2 The XMLTV guide + +The XMLTV guide of a range is these bytes, in UTF-8, each line ending with a line feed: + +``` + + + + NAME + + + TITLE + DESCRIPTION + CATEGORY + + ASSET + + VALUE + + + +``` + +- `ROOT` is the receipt's `schedule_root` and `LINEUP` the lineup's digest, each 64 lowercase + hexadecimal digits, and `FROM` and `TO` the range's ends in decimal, with no leading zero. + `urn:wai:guide:` names a guide's sources; a guide names them in its `source-data-url`, an + attribute the DTD gives `tv` for where the data came from. +- `CHANNEL` is the lineup's `channel_id` and `NAME` its `channel_name`. +- There is one `programme` element for each programme of the range, in order. `START` and `STOP` + are its start and its end as XMLTV times (§3.4) in UTC with the zone written: `YYYYMMDDhhmmss + +0000`. +- Of a programme's children, in the order the DTD gives them: one `title`; one `desc` only when + the description is not empty; one `category` for each category, in order; one `icon` for each + artwork, in order, its `src` the URI; one `episode-num`, whose text is the asset id; and one + `rating` only when `rating_value` is not empty, with a `system` attribute only when + `rating_system` is not empty. +- In text, `&`, `<` and `>` are written `&`, `<` and `>`; in an attribute value, + those and `"`, tab and line feed are written `&`, `<`, `>`, `"`, ` ` and + ` `. Every other character is written as itself. + +The guide's `episode-num` system is `wai-asset-id`, the import's default `id_system` (§4.1), so +a guide imports back into the programmes it lists. + +### 7.3 The now/next document + +The now/next document of an instant `at` is the JCS serialisation (RFC 8785) of this object, +followed by one line feed: + +| member | value | +|---|---| +| `kind` | `wai-now-next` | +| `channel`, `channel_name` | the lineup's `channel_id` and `channel_name` | +| `schedule_root` | the receipt's, 64 lowercase hexadecimal digits | +| `lineup` | the lineup's digest, 64 lowercase hexadecimal digits | +| `at_ms` | `at` | +| `now` | the programme whose span holds `at`, or `null` | +| `next` | when `now` is a programme, the one after it, or `null` for the last; when `at` is before the schedule's first start, the first programme; otherwise `null` | + +A programme is an object with the members `start_ms`, `stop_ms` and `asset_id`; `title`, +`description` and `content_uri`; `categories`, an array of strings; `rating`, `null` or +`{"system", "value"}`; `artwork`, an array of `{"sha256", "uri"}`; `geo`, `null` for everywhere, +`{"allow": [...]}` or `{"deny": [...]}`; `rights_holder`; and `duration_ms`. + +## 8. Verification + +### 8.1 Inputs + +A verifier is given a receipt in its JSON carriage (§5), a lineup's bytes, and optionally a +guide's bytes: an XMLTV guide when its first byte is `<`, a now/next document when it is `{`. + +### 8.2 Steps + +A verification gets one verdict, **verified** or the reason of the first step it fails: + +1. **The receipt** reads (§5): `channel_receipt_malformed`. +2. Its signature verifies under `signer_pubkey`: `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.wcl`) 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.wcl --out guide.xml +wai channel guide --receipt out/channel.json --lineup out/lineup.wcl --now-next --at 1793299545000 --out now-next.json +wai channel verify --receipt out/channel.json --lineup out/lineup.wcl --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 of another schedule, 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 the Merkle root +and an energy class's signed encoding as the live-ads corpus's second implementation computes +them; 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. -- 2.54.0 (Apple Git-157) From 845d98976c8fd89f2aa4ac1770b7b510261bcf00 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 29 Oct 2026 17:12:37 -0400 Subject: [PATCH 02/11] wai-rs: channel_lineup, the import, the lineup and the guide, with its corpus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The feature channel_lineup (video_channel, stitched_presentation; no new dependency): the import of §4, the lineup record WCL1, the receipt's JSON carriage, the XMLTV and now/next guides and the verification of §8. The corpus live-conformance/channels/ is written from fixed seeds by wai_channel_lineup_vectors; tests/channel_lineup.rs requires it byte for byte, every vector's outcome, §10's table, and the worked example of §9 run as the text writes it. ChannelReceipt's signing payload becomes visible to the crate; its bytes do not change. Co-Authored-By: Claude Opus 5.5 --- wai/live-conformance/channels/README.md | 45 + .../channels/aired-misreported.channel.json | 90 + wai/live-conformance/channels/art-hl-101.svg | 1 + wai/live-conformance/channels/art-hl-102.svg | 1 + wai/live-conformance/channels/art-nf-1.svg | 1 + wai/live-conformance/channels/art-tk-7.svg | 1 + .../channels/media_rss_loop.channel.json | 44 + .../channels/sample-a.mrss.xml | 37 + .../channels/sample-b.mrss.xml | 16 + .../channels/sample.after.now-next.json | 1 + .../channels/sample.before.now-next.json | 1 + .../channels/sample.channel.json | 96 + .../channels/sample.guide.xml | 44 + wai/live-conformance/channels/sample.json | 150 ++ wai/live-conformance/channels/sample.m3u8 | 17 + .../channels/sample.now-next.json | 1 + .../channels/sample.part.guide.xml | 24 + .../channels/sample.xmltv.xml | 40 + wai/live-conformance/channels/vectors.json | 1623 +++++++++++++++++ wai/wai-rs/Cargo.toml | 22 +- .../src/bin/wai_channel_lineup_vectors.rs | 89 + wai/wai-rs/src/channel_lineup.rs | 1269 +++++++++++++ wai/wai-rs/src/channel_lineup/corpus.rs | 658 +++++++ wai/wai-rs/src/channel_lineup/import.rs | 769 ++++++++ wai/wai-rs/src/channel_lineup/tests.rs | 48 + wai/wai-rs/src/lib.rs | 10 +- wai/wai-rs/src/wai_video_channel.rs | 5 +- wai/wai-rs/tests/channel_lineup.rs | 150 ++ 28 files changed, 5250 insertions(+), 3 deletions(-) create mode 100644 wai/live-conformance/channels/README.md create mode 100644 wai/live-conformance/channels/aired-misreported.channel.json create mode 100644 wai/live-conformance/channels/art-hl-101.svg create mode 100644 wai/live-conformance/channels/art-hl-102.svg create mode 100644 wai/live-conformance/channels/art-nf-1.svg create mode 100644 wai/live-conformance/channels/art-tk-7.svg create mode 100644 wai/live-conformance/channels/media_rss_loop.channel.json create mode 100644 wai/live-conformance/channels/sample-a.mrss.xml create mode 100644 wai/live-conformance/channels/sample-b.mrss.xml create mode 100644 wai/live-conformance/channels/sample.after.now-next.json create mode 100644 wai/live-conformance/channels/sample.before.now-next.json create mode 100644 wai/live-conformance/channels/sample.channel.json create mode 100644 wai/live-conformance/channels/sample.guide.xml create mode 100644 wai/live-conformance/channels/sample.json create mode 100644 wai/live-conformance/channels/sample.m3u8 create mode 100644 wai/live-conformance/channels/sample.now-next.json create mode 100644 wai/live-conformance/channels/sample.part.guide.xml create mode 100644 wai/live-conformance/channels/sample.xmltv.xml create mode 100644 wai/live-conformance/channels/vectors.json create mode 100644 wai/wai-rs/src/bin/wai_channel_lineup_vectors.rs create mode 100644 wai/wai-rs/src/channel_lineup.rs create mode 100644 wai/wai-rs/src/channel_lineup/corpus.rs create mode 100644 wai/wai-rs/src/channel_lineup/import.rs create mode 100644 wai/wai-rs/src/channel_lineup/tests.rs create mode 100644 wai/wai-rs/tests/channel_lineup.rs diff --git a/wai/live-conformance/channels/README.md b/wai/live-conformance/channels/README.md new file mode 100644 index 000000000..756b24a96 --- /dev/null +++ b/wai/live-conformance/channels/README.md @@ -0,0 +1,45 @@ +# 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 +`WCL1` lineup, its guides, and what a verifier states of each. Every file but this one is written +from fixed seeds by `wai_channel_lineup_vectors gen` (feature `channel_lineup`), and +`wai_channel_lineup_vectors verify ` and `tests/channel_lineup.rs` require them byte for byte. +The second implementation is [`../channel_lineup_verify.py`](../channel_lineup_verify.py), run as +`python3 -I channel_lineup_verify.py channels`. + +The sample channel, `sample-1`, is two hours on 2026-10-29 from 18:00 UTC, four programmes of +three content owners, with four ad breaks: + +| file | what | +|---|---| +| `sample.xmltv.xml` | the schedule: XMLTV, with a document type declaration, a second channel's programme beside it, and times in three zone forms | +| `sample-a.mrss.xml`, `sample-b.mrss.xml` | two owners' Media RSS catalogues: content, durations, titles, categories, thumbnails, ratings and country restrictions, one item under `media:group`, one item no programme names | +| `sample.m3u8` | the channel's Media Playlist: SCTE-35 date ranges with real `splice_insert()` and `time_signal()` sections and their CRC, an out/in pair split across two tags of one `ID`, one duration from `END-DATE`, and a date range that is not a break | +| `sample.json` | the same channel in the JSON form | +| `art-*.svg` | the artwork the icons and thumbnails name | +| `sample.channel.json` | the receipt the sample yields, its first hour the window | +| `sample.guide.xml`, `sample.part.guide.xml` | XMLTV guides of the whole schedule and of a range | +| `sample.now-next.json`, `sample.before.now-next.json`, `sample.after.now-next.json` | now/next documents inside a break, before the schedule and after it | +| `media_rss_loop.channel.json` | the receipt of one owner's items alone, back to back | +| `aired-misreported.channel.json` | a receipt, signed, whose program-as-run its window does not give | +| `vectors.json` | the operator's key, and every vector | + +`vectors.json` holds: + +- `operator`: the key that signs every receipt and lineup, and its seed (a published seed: a test + key that attests nothing); +- `imports`: each with its `inputs` (§4.1: the files it reads, owners, `id_system`, + `xmltv_channel`, `start_ms`, the artwork set by file name, the window, `signer_id` and + `parent_receipt_hash`), its `edits`, and its `outcome`: `{"refused": reason}`, or the receipt's + file and identity, the lineup's bytes and digest in hexadecimal, and the cues (each break's id, + its start and its live-events `cue` payload in hexadecimal); +- `verifications`: each with its `receipt` file, its `lineup` bytes in hexadecimal, its `guide` + file or `null`, its `edits`, and its `facts`, what §8.3 states: `verdict`, `part`, `receipt`, + `lineup`, `guide` (`format`, `from_ms`, `to_ms`, `at_ms`, and its `programmes`, each + `start_ms`, `stop_ms`, `asset_id` and `aired`), and `entry`. + +An edit is applied to a copy of the file it names before the vector is run: `{"file", "find", +"replace"}` replaces the one occurrence of `find` (an edit whose `find` does not occur exactly +once is a broken vector), and `{"file", "pad"}` appends that many space bytes. Each control is +the sample with one such change, or with one input changed. diff --git a/wai/live-conformance/channels/aired-misreported.channel.json b/wai/live-conformance/channels/aired-misreported.channel.json new file mode 100644 index 000000000..0f42e293f --- /dev/null +++ b/wai/live-conformance/channels/aired-misreported.channel.json @@ -0,0 +1,90 @@ +{ + "channel_id": "sample-1", + "schedule_root": "d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b83", + "schedule": [ + { + "asset_id": "hl-101", + "start_ms": 1793296800000, + "duration_ms": 720000, + "kind": "program" + }, + { + "asset_id": "brk-1", + "start_ms": 1793297520000, + "duration_ms": 90000, + "kind": "ad" + }, + { + "asset_id": "hl-101", + "start_ms": 1793297610000, + "duration_ms": 870000, + "kind": "program" + }, + { + "asset_id": "brk-2", + "start_ms": 1793298480000, + "duration_ms": 120000, + "kind": "ad" + }, + { + "asset_id": "tk-7", + "start_ms": 1793298600000, + "duration_ms": 930500, + "kind": "program" + }, + { + "asset_id": "brk-3", + "start_ms": 1793299530500, + "duration_ms": 60000, + "kind": "ad" + }, + { + "asset_id": "tk-7", + "start_ms": 1793299590500, + "duration_ms": 809500, + "kind": "program" + }, + { + "asset_id": "hl-102", + "start_ms": 1793300400000, + "duration_ms": 840000, + "kind": "program" + }, + { + "asset_id": "brk-4", + "start_ms": 1793301240000, + "duration_ms": 90000, + "kind": "ad" + }, + { + "asset_id": "hl-102", + "start_ms": 1793301330000, + "duration_ms": 870000, + "kind": "program" + }, + { + "asset_id": "nf-1", + "start_ms": 1793302200000, + "duration_ms": 1800000, + "kind": "program" + } + ], + "window_start_ms": 1793296800000, + "window_end_ms": 1793300400000, + "aired": [ + 0 + ], + "work_schedule_ops": 11, + "joules_micro": 0, + "energy_provenance": null, + "grant": { + "grant_hash": "0000000000000000000000000000000000000000000000000000000000000000", + "capability": "video.channel", + "joule_ceiling_micro": 18446744073709551615, + "funds_ceiling": null + }, + "parent_receipt_hash": null, + "signer_pubkey": "da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a", + "signer_id": "sample-operator", + "sig": "1e2c1bce261bbc3ff14ff2c6ca29ee2b25e8140307683e70d3041f58f504498248c17a22101b09e33cd12348c570d34bb8fea00352d6c1884647f564dda7bc0a" +} diff --git a/wai/live-conformance/channels/art-hl-101.svg b/wai/live-conformance/channels/art-hl-101.svg new file mode 100644 index 000000000..4cf6d8771 --- /dev/null +++ b/wai/live-conformance/channels/art-hl-101.svg @@ -0,0 +1 @@ +HL 101 diff --git a/wai/live-conformance/channels/art-hl-102.svg b/wai/live-conformance/channels/art-hl-102.svg new file mode 100644 index 000000000..4c7defb1f --- /dev/null +++ b/wai/live-conformance/channels/art-hl-102.svg @@ -0,0 +1 @@ +HL 102 diff --git a/wai/live-conformance/channels/art-nf-1.svg b/wai/live-conformance/channels/art-nf-1.svg new file mode 100644 index 000000000..6252d9f64 --- /dev/null +++ b/wai/live-conformance/channels/art-nf-1.svg @@ -0,0 +1 @@ +NF 1 diff --git a/wai/live-conformance/channels/art-tk-7.svg b/wai/live-conformance/channels/art-tk-7.svg new file mode 100644 index 000000000..796361bd6 --- /dev/null +++ b/wai/live-conformance/channels/art-tk-7.svg @@ -0,0 +1 @@ +TK 7 diff --git a/wai/live-conformance/channels/media_rss_loop.channel.json b/wai/live-conformance/channels/media_rss_loop.channel.json new file mode 100644 index 000000000..31f9e104c --- /dev/null +++ b/wai/live-conformance/channels/media_rss_loop.channel.json @@ -0,0 +1,44 @@ +{ + "channel_id": "sample-1", + "schedule_root": "2fccd317b7809fcf7439a7eefcae244d273b7430c665404d0c5d4d94b42674b0", + "schedule": [ + { + "asset_id": "hl-101", + "start_ms": 1793318400000, + "duration_ms": 1620000, + "kind": "program" + }, + { + "asset_id": "hl-102", + "start_ms": 1793320020000, + "duration_ms": 1620000, + "kind": "program" + }, + { + "asset_id": "hl-103", + "start_ms": 1793321640000, + "duration_ms": 1620000, + "kind": "program" + } + ], + "window_start_ms": 1793318400000, + "window_end_ms": 1793323260000, + "aired": [ + 0, + 1, + 2 + ], + "work_schedule_ops": 3, + "joules_micro": 0, + "energy_provenance": null, + "grant": { + "grant_hash": "0000000000000000000000000000000000000000000000000000000000000000", + "capability": "video.channel", + "joule_ceiling_micro": 18446744073709551615, + "funds_ceiling": null + }, + "parent_receipt_hash": null, + "signer_pubkey": "da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a", + "signer_id": "sample-operator", + "sig": "c9c7e26c43dc42f6939c67efd86cc1424940cc802694f6cbefdf8cee377a80cf32f350055005ef002bff4bbd544edd1069595cd58778c11ceb411816dc95880c" +} diff --git a/wai/live-conformance/channels/sample-a.mrss.xml b/wai/live-conformance/channels/sample-a.mrss.xml new file mode 100644 index 000000000..6b567b3b9 --- /dev/null +++ b/wai/live-conformance/channels/sample-a.mrss.xml @@ -0,0 +1,37 @@ + + + + Owner A titles + https://owner-a.example/ + Owner A's catalogue for linear channels. + + Harbour Lights + hl-101 + + Harbour Lights + A lighthouse keeper's first winter. + Drama + + tv-pg + us ca gb + + + hl-102 + + + + + Harbour Lights + The supply boat is late. + Drama + + tv-pg + de + + + hl-103 + + Harbour Lights + + + diff --git a/wai/live-conformance/channels/sample-b.mrss.xml b/wai/live-conformance/channels/sample-b.mrss.xml new file mode 100644 index 000000000..4a5dd104a --- /dev/null +++ b/wai/live-conformance/channels/sample-b.mrss.xml @@ -0,0 +1,16 @@ + + + + Owner B kitchen + + tk-7 + + Tidepool Kitchen + Kelp, crab & lemon, on a beach stove. + Food + Family + + none + + + diff --git a/wai/live-conformance/channels/sample.after.now-next.json b/wai/live-conformance/channels/sample.after.now-next.json new file mode 100644 index 000000000..545a20159 --- /dev/null +++ b/wai/live-conformance/channels/sample.after.now-next.json @@ -0,0 +1 @@ +{"at_ms":1793307600000,"channel":"sample-1","channel_name":"Sample One","kind":"wai-now-next","lineup":"72ad84006fc7b22799708d78b736407742b35ea2ab32918e9068d263290481c7","next":null,"now":null,"schedule_root":"d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b83"} diff --git a/wai/live-conformance/channels/sample.before.now-next.json b/wai/live-conformance/channels/sample.before.now-next.json new file mode 100644 index 000000000..f126f42fe --- /dev/null +++ b/wai/live-conformance/channels/sample.before.now-next.json @@ -0,0 +1 @@ +{"at_ms":1793293200000,"channel":"sample-1","channel_name":"Sample One","kind":"wai-now-next","lineup":"72ad84006fc7b22799708d78b736407742b35ea2ab32918e9068d263290481c7","next":{"artwork":[{"sha256":"f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc","uri":"https://art.example/sample/art-hl-101.svg?w=320"}],"asset_id":"hl-101","categories":["Drama"],"content_uri":"https://media.example/a/hl-101/index.m3u8","description":"A lighthouse keeper's first winter.","duration_ms":1620000,"geo":{"allow":["CA","GB","US"]},"rating":{"system":"urn:v-chip","value":"tv-pg"},"rights_holder":"owner-a","start_ms":1793296800000,"stop_ms":1793298600000,"title":"Harbour Lights"},"now":null,"schedule_root":"d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b83"} diff --git a/wai/live-conformance/channels/sample.channel.json b/wai/live-conformance/channels/sample.channel.json new file mode 100644 index 000000000..d902a7a34 --- /dev/null +++ b/wai/live-conformance/channels/sample.channel.json @@ -0,0 +1,96 @@ +{ + "channel_id": "sample-1", + "schedule_root": "d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b83", + "schedule": [ + { + "asset_id": "hl-101", + "start_ms": 1793296800000, + "duration_ms": 720000, + "kind": "program" + }, + { + "asset_id": "brk-1", + "start_ms": 1793297520000, + "duration_ms": 90000, + "kind": "ad" + }, + { + "asset_id": "hl-101", + "start_ms": 1793297610000, + "duration_ms": 870000, + "kind": "program" + }, + { + "asset_id": "brk-2", + "start_ms": 1793298480000, + "duration_ms": 120000, + "kind": "ad" + }, + { + "asset_id": "tk-7", + "start_ms": 1793298600000, + "duration_ms": 930500, + "kind": "program" + }, + { + "asset_id": "brk-3", + "start_ms": 1793299530500, + "duration_ms": 60000, + "kind": "ad" + }, + { + "asset_id": "tk-7", + "start_ms": 1793299590500, + "duration_ms": 809500, + "kind": "program" + }, + { + "asset_id": "hl-102", + "start_ms": 1793300400000, + "duration_ms": 840000, + "kind": "program" + }, + { + "asset_id": "brk-4", + "start_ms": 1793301240000, + "duration_ms": 90000, + "kind": "ad" + }, + { + "asset_id": "hl-102", + "start_ms": 1793301330000, + "duration_ms": 870000, + "kind": "program" + }, + { + "asset_id": "nf-1", + "start_ms": 1793302200000, + "duration_ms": 1800000, + "kind": "program" + } + ], + "window_start_ms": 1793296800000, + "window_end_ms": 1793300400000, + "aired": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6 + ], + "work_schedule_ops": 11, + "joules_micro": 0, + "energy_provenance": null, + "grant": { + "grant_hash": "0000000000000000000000000000000000000000000000000000000000000000", + "capability": "video.channel", + "joule_ceiling_micro": 18446744073709551615, + "funds_ceiling": null + }, + "parent_receipt_hash": null, + "signer_pubkey": "da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a", + "signer_id": "sample-operator", + "sig": "70267928c5af2dc768215e23ca3fe4e47d106658261b40cc27a1fff1fb72906f84fe11bab8f3b95bc2763b86e95d7eaaeeb28a02b847f48f25e7107fb218d50a" +} diff --git a/wai/live-conformance/channels/sample.guide.xml b/wai/live-conformance/channels/sample.guide.xml new file mode 100644 index 000000000..b2861ea02 --- /dev/null +++ b/wai/live-conformance/channels/sample.guide.xml @@ -0,0 +1,44 @@ + + + + Sample One + + + Harbour Lights + A lighthouse keeper's first winter. + Drama + + hl-101 + + tv-pg + + + + Tidepool Kitchen + Kelp, crab & lemon, on a beach stove. + Food + Family + + tk-7 + + + Harbour Lights + The supply boat is late. + Drama + + hl-102 + + tv-pg + + + + Night Freight + Overnight on the coast line & the yards it feeds. + Documentary + + nf-1 + + TV-14 + + + diff --git a/wai/live-conformance/channels/sample.json b/wai/live-conformance/channels/sample.json new file mode 100644 index 000000000..587f5520b --- /dev/null +++ b/wai/live-conformance/channels/sample.json @@ -0,0 +1,150 @@ +{ + "kind": "wai-channel-feed", + "channel_name": "Sample One", + "programmes": [ + { + "start_ms": 1793296800000, + "stop_ms": 1793298600000, + "asset_id": "hl-101" + }, + { + "start_ms": 1793298600000, + "stop_ms": 1793300400000, + "asset_id": "tk-7" + }, + { + "start_ms": 1793300400000, + "stop_ms": 1793302200000, + "asset_id": "hl-102" + }, + { + "start_ms": 1793302200000, + "stop_ms": 1793304000000, + "asset_id": "nf-1" + } + ], + "assets": [ + { + "asset_id": "hl-101", + "title": "Harbour Lights", + "description": "A lighthouse keeper's first winter.", + "content_uri": "https://media.example/a/hl-101/index.m3u8", + "categories": [ + "Drama" + ], + "rating": { + "system": "urn:v-chip", + "value": "tv-pg" + }, + "artwork": [ + { + "uri": "https://art.example/sample/art-hl-101.svg?w=320", + "sha256": "f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc" + } + ], + "geo": { + "allow": [ + "CA", + "GB", + "US" + ] + }, + "rights_holder": "owner-a", + "duration_ms": 1620000 + }, + { + "asset_id": "hl-102", + "title": "Harbour Lights", + "description": "The supply boat is late.", + "content_uri": "https://media.example/a/hl-102/index.m3u8", + "categories": [ + "Drama" + ], + "rating": { + "system": "urn:v-chip", + "value": "tv-pg" + }, + "artwork": [ + { + "uri": "https://art.example/sample/art-hl-102.svg?w=320", + "sha256": "6a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d3" + } + ], + "geo": { + "deny": [ + "DE" + ] + }, + "rights_holder": "owner-a", + "duration_ms": 1620000 + }, + { + "asset_id": "nf-1", + "title": "Night Freight", + "description": "Overnight on the coast line & the yards it feeds.", + "content_uri": "", + "categories": [ + "Documentary" + ], + "rating": { + "system": "VCHIP", + "value": "TV-14" + }, + "artwork": [ + { + "uri": "https://art.example/sample/art-nf-1.svg?w=320", + "sha256": "2eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b1" + } + ], + "geo": null, + "rights_holder": "owner-c", + "duration_ms": 0 + }, + { + "asset_id": "tk-7", + "title": "Tidepool Kitchen", + "description": "Kelp, crab & lemon, on a beach stove.", + "content_uri": "https://media.example/b/tk-7.m3u8", + "categories": [ + "Food", + "Family" + ], + "rating": null, + "artwork": [ + { + "uri": "https://art.example/sample/art-tk-7.svg?w=320", + "sha256": "1cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b860" + } + ], + "geo": null, + "rights_holder": "owner-b", + "duration_ms": 1650000 + } + ], + "breaks": [ + { + "id": "brk-1", + "start_ms": 1793297520000, + "duration_ms": 90000, + "scte35": "fc30240000000000fffff01405000003e97feffe03dcc500fe007b98a00001000000004c30d1ad" + }, + { + "id": "brk-2", + "start_ms": 1793298480000, + "duration_ms": 120000, + "scte35": "fc30150000000000fffff00506fe09032100000021d72ce8" + }, + { + "id": "brk-3", + "start_ms": 1793299530500, + "duration_ms": 60000, + "scte35": "fc30240000000000fffff01405000003eb7feffe0ea5c568fe005265c000010000000086b9c532" + }, + { + "id": "brk-4", + "start_ms": 1793301240000, + "duration_ms": 90000, + "scte35": "fc30240000000000fffff01405000003ec7feffe17d16980fe007b98a0000100000000569720e7" + } + ] +} diff --git a/wai/live-conformance/channels/sample.m3u8 b/wai/live-conformance/channels/sample.m3u8 new file mode 100644 index 000000000..90eef9ed0 --- /dev/null +++ b/wai/live-conformance/channels/sample.m3u8 @@ -0,0 +1,17 @@ +#EXTM3U +#EXT-X-VERSION:6 +#EXT-X-TARGETDURATION:6 +#EXT-X-MEDIA-SEQUENCE:120 +#EXT-X-PROGRAM-DATE-TIME:2026-10-29T18:11:54.000Z +#EXTINF:6.000, +seg-120.ts +#EXT-X-DATERANGE:ID="brk-1",START-DATE="2026-10-29T18:12:00.000Z",PLANNED-DURATION=90.000,SCTE35-OUT=0xFC30240000000000FFFFF01405000003E97FEFFE03DCC500FE007B98A00001000000004C30D1AD +#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=0xFC301F0000000000FFFFF00F05000003E97F4FFE04585DA00001000000005A2DF1BC +#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=0xFC30150000000000FFFFF00506FE09032100000021D72CE8 +#EXT-X-DATERANGE:ID="brk-3",START-DATE="2026-10-29T18:45:30.500Z",PLANNED-DURATION=60,SCTE35-OUT=0xfc30240000000000fffff01405000003eb7feffe0ea5c568fe005265c000010000000086b9c532 +#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=0XFC30240000000000FFFFF01405000003EC7FEFFE17D16980FE007B98A0000100000000569720E7 +#EXTINF:6.000, +seg-122.ts diff --git a/wai/live-conformance/channels/sample.now-next.json b/wai/live-conformance/channels/sample.now-next.json new file mode 100644 index 000000000..48f6c6a44 --- /dev/null +++ b/wai/live-conformance/channels/sample.now-next.json @@ -0,0 +1 @@ +{"at_ms":1793299545000,"channel":"sample-1","channel_name":"Sample One","kind":"wai-now-next","lineup":"72ad84006fc7b22799708d78b736407742b35ea2ab32918e9068d263290481c7","next":{"artwork":[{"sha256":"6a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d3","uri":"https://art.example/sample/art-hl-102.svg?w=320"}],"asset_id":"hl-102","categories":["Drama"],"content_uri":"https://media.example/a/hl-102/index.m3u8","description":"The supply boat is late.","duration_ms":1620000,"geo":{"deny":["DE"]},"rating":{"system":"urn:v-chip","value":"tv-pg"},"rights_holder":"owner-a","start_ms":1793300400000,"stop_ms":1793302200000,"title":"Harbour Lights"},"now":{"artwork":[{"sha256":"1cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b860","uri":"https://art.example/sample/art-tk-7.svg?w=320"}],"asset_id":"tk-7","categories":["Food","Family"],"content_uri":"https://media.example/b/tk-7.m3u8","description":"Kelp, crab & lemon, on a beach stove.","duration_ms":1650000,"geo":null,"rating":null,"rights_holder":"owner-b","start_ms":1793298600000,"stop_ms":1793300400000,"title":"Tidepool Kitchen"},"schedule_root":"d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b83"} diff --git a/wai/live-conformance/channels/sample.part.guide.xml b/wai/live-conformance/channels/sample.part.guide.xml new file mode 100644 index 000000000..fede4da64 --- /dev/null +++ b/wai/live-conformance/channels/sample.part.guide.xml @@ -0,0 +1,24 @@ + + + + Sample One + + + Tidepool Kitchen + Kelp, crab & lemon, on a beach stove. + Food + Family + + tk-7 + + + Harbour Lights + The supply boat is late. + Drama + + hl-102 + + tv-pg + + + diff --git a/wai/live-conformance/channels/sample.xmltv.xml b/wai/live-conformance/channels/sample.xmltv.xml new file mode 100644 index 000000000..37afdef81 --- /dev/null +++ b/wai/live-conformance/channels/sample.xmltv.xml @@ -0,0 +1,40 @@ + + + + + Sample One + + + Sample Two + + + Harbour Lights + A lighthouse keeper's first winter. + Drama + + 0.0. + hl-101 + + + Tidepool Kitchen + tk-7 + + + Harbour Lights + hl-102 + + + Night Freight + Overnight on the coast line & the yards it feeds. + Documentary + + nf-1 + + TV-14 + + + + Another Channel's Hour + other-1 + + diff --git a/wai/live-conformance/channels/vectors.json b/wai/live-conformance/channels/vectors.json new file mode 100644 index 000000000..cf186a69d --- /dev/null +++ b/wai/live-conformance/channels/vectors.json @@ -0,0 +1,1623 @@ +{ + "about": "extensions/channel-lineup.md §11: a sample channel's feeds (XMLTV, two owners' Media RSS, a Media Playlist with SCTE-35 date ranges, and the JSON form), each import's inputs, the edit that makes it a control and its outcome; each verification of a receipt, a lineup and a guide, and what it states", + "operator": { + "key": "da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a", + "seed": "aca23cde2418010624ea01fe74a44132163bed9ca0511ef11638290fe650ef83" + }, + "imports": [ + { + "name": "sample", + "about": "the sample channel from its XMLTV schedule (beside another channel's), two owners' Media RSS catalogues and the playlist's SCTE-35 date ranges, its first hour the receipt's window", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [], + "outcome": { + "receipt": "sample.channel.json", + "receipt_hash": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a227663ed8f17511d1ca8b78c5d3ba3854546651420ef97d39208883f9bf987b1d9d58577208bfb33aeeea3d94b973ebd816ce9cfc4e5a6762e777a39a8bbe806", + "lineup_digest": "72ad84006fc7b22799708d78b736407742b35ea2ab32918e9068d263290481c7", + "cues": [ + { + "break": "brk-1", + "start_ms": 1793297520000, + "payload": "04cf47598c905f0100012700fc30240000000000fffff01405000003e97feffe03dcc500fe007b98a00001000000004c30d1ad" + }, + { + "break": "brk-2", + "start_ms": 1793298480000, + "payload": "041733d0f9c0d40100011800fc30150000000000fffff00506fe09032100000021d72ce8" + }, + { + "break": "brk-3", + "start_ms": 1793299530500, + "payload": "048e32f8b160ea0000012700fc30240000000000fffff01405000003eb7feffe0ea5c568fe005265c000010000000086b9c532" + }, + { + "break": "brk-4", + "start_ms": 1793301240000, + "payload": "04d2ba2307905f0100012700fc30240000000000fffff01405000003ec7feffe17d16980fe007b98a0000100000000569720e7" + } + ] + } + }, + { + "name": "sample_json", + "about": "the same channel from the JSON form alone: the same receipt and lineup, byte for byte", + "inputs": { + "channel": "sample-1", + "xmltv": null, + "mrss": [], + "json": "sample.json", + "playlist": null, + "owner": null, + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [], + "outcome": { + "receipt": "sample.channel.json", + "receipt_hash": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a227663ed8f17511d1ca8b78c5d3ba3854546651420ef97d39208883f9bf987b1d9d58577208bfb33aeeea3d94b973ebd816ce9cfc4e5a6762e777a39a8bbe806", + "lineup_digest": "72ad84006fc7b22799708d78b736407742b35ea2ab32918e9068d263290481c7", + "cues": [ + { + "break": "brk-1", + "start_ms": 1793297520000, + "payload": "04cf47598c905f0100012700fc30240000000000fffff01405000003e97feffe03dcc500fe007b98a00001000000004c30d1ad" + }, + { + "break": "brk-2", + "start_ms": 1793298480000, + "payload": "041733d0f9c0d40100011800fc30150000000000fffff00506fe09032100000021d72ce8" + }, + { + "break": "brk-3", + "start_ms": 1793299530500, + "payload": "048e32f8b160ea0000012700fc30240000000000fffff01405000003eb7feffe0ea5c568fe005265c000010000000086b9c532" + }, + { + "break": "brk-4", + "start_ms": 1793301240000, + "payload": "04d2ba2307905f0100012700fc30240000000000fffff01405000003ec7feffe17d16980fe007b98a0000100000000569720e7" + } + ] + } + }, + { + "name": "media_rss_loop", + "about": "one owner's Media RSS items alone, back to back from a start, with no break", + "inputs": { + "channel": "sample-1", + "xmltv": null, + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + } + ], + "json": null, + "playlist": null, + "owner": null, + "id_system": null, + "xmltv_channel": null, + "start_ms": 1793318400000, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": null, + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [], + "outcome": { + "receipt": "media_rss_loop.channel.json", + "receipt_hash": "a2cc6ae4d6de83aa9b0c1dd3e63878a605a9ccaf813d074f37c2a1cad2ed0c99", + "lineup": "57434c31010873616d706c652d310e4f776e65722041207469746c65732fccd317b7809fcf7439a7eefcae244d273b7430c665404d0c5d4d94b42674b000301b8aa10100006058658aa10100000300000000301b8aa101000020e8338aa101000040a04c8aa10100000300000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b818000000000006686c2d3130330e00486172626f7572204c69676874730000000000000000076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130332f696e6465782e6d33753820b8180000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16ae0b4831075f0903309e6b6b7b364909f7db6068a40e02ba1b5fe5fb8c632944a0c317f7342697822dfa5aca73794d6680c9118a8b1e7db31adcdda0598b59204", + "lineup_digest": "d653cbd60cd62e5b90c6d0f17c656e7f4a1cf66843f92e89b9a9bf088fd01ddd", + "cues": [] + } + }, + { + "name": "inputs_invalid", + "about": "an XMLTV document beside the JSON form", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": "sample.json", + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [], + "outcome": { + "refused": "import_inputs_invalid" + } + }, + { + "name": "oversized", + "about": "an XMLTV document of more than 16 777 216 bytes", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample.xmltv.xml", + "pad": 16777216 + } + ], + "outcome": { + "refused": "import_oversized" + } + }, + { + "name": "xml_malformed", + "about": "a document type declaration that defines an entity", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample.xmltv.xml", + "find": "", + "replace": "]>" + } + ], + "outcome": { + "refused": "import_xml_malformed" + } + }, + { + "name": "json_malformed", + "about": "the JSON form naming a member twice", + "inputs": { + "channel": "sample-1", + "xmltv": null, + "mrss": [], + "json": "sample.json", + "playlist": null, + "owner": null, + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample.json", + "find": "\"kind\": \"wai-channel-feed\",", + "replace": "\"kind\": \"wai-channel-feed\",\n \"kind\": \"wai-channel-feed\"," + } + ], + "outcome": { + "refused": "import_json_malformed" + } + }, + { + "name": "playlist_malformed", + "about": "two tags of one date range that give it two starts", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample.m3u8", + "find": "START-DATE=\"2026-10-29T18:12:00.000Z\",DURATION", + "replace": "START-DATE=\"2026-10-29T18:12:01.000Z\",DURATION" + } + ], + "outcome": { + "refused": "import_playlist_malformed" + } + }, + { + "name": "time_malformed", + "about": "a programme starting in a thirteenth month", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample.xmltv.xml", + "find": "start=\"20261029183000 +0000\"", + "replace": "start=\"20261329183000 +0000\"" + } + ], + "outcome": { + "refused": "import_time_malformed" + } + }, + { + "name": "timezone_unknown", + "about": "a programme's time in a zone named by letters", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample.xmltv.xml", + "find": "start=\"20261029183000 +0000\"", + "replace": "start=\"20261029193000 BST\"" + } + ], + "outcome": { + "refused": "import_timezone_unknown" + } + }, + { + "name": "no_stop", + "about": "a programme with no stop", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample.xmltv.xml", + "find": " stop=\"20261029190000 +0000\" channel=\"sample-1\"", + "replace": " channel=\"sample-1\"" + } + ], + "outcome": { + "refused": "import_no_stop" + } + }, + { + "name": "empty", + "about": "an XMLTV channel id the document has no programme of", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": "sample-9", + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [], + "outcome": { + "refused": "import_empty" + } + }, + { + "name": "asset_unknown", + "about": "a programme whose asset is named only in another episode-num system", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample.xmltv.xml", + "find": "tk-7", + "replace": "tk-7" + } + ], + "outcome": { + "refused": "import_asset_unknown" + } + }, + { + "name": "asset_duplicate", + "about": "two catalogue items with one guid", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample-a.mrss.xml", + "find": "hl-103", + "replace": "hl-101" + } + ], + "outcome": { + "refused": "import_asset_duplicate" + } + }, + { + "name": "field_invalid", + "about": "a restriction whose relationship is neither allow nor deny", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample-b.mrss.xml", + "find": "relationship=\"deny\"", + "replace": "relationship=\"maybe\"" + } + ], + "outcome": { + "refused": "import_field_invalid" + } + }, + { + "name": "scte35_malformed", + "about": "a section whose section_length is not its length", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample.m3u8", + "find": "DURATION=120.000,SCTE35-OUT=0x", + "replace": "DURATION=120.000,SCTE35-OUT=0xFC3001,X-WAS=0x" + } + ], + "outcome": { + "refused": "import_scte35_malformed" + } + }, + { + "name": "break_duration", + "about": "a break of no duration", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample.m3u8", + "find": "DURATION=120.000", + "replace": "DURATION=0.000" + } + ], + "outcome": { + "refused": "import_break_duration" + } + }, + { + "name": "duration_unknown", + "about": "a Media RSS item with no duration, in a channel of Media RSS items alone", + "inputs": { + "channel": "sample-1", + "xmltv": null, + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + } + ], + "json": null, + "playlist": null, + "owner": null, + "id_system": null, + "xmltv_channel": null, + "start_ms": 1793318400000, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": null, + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample-a.mrss.xml", + "find": "", + "replace": "" + } + ], + "outcome": { + "refused": "import_duration_unknown" + } + }, + { + "name": "gap", + "about": "a minute of dead air before the last programme", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample.xmltv.xml", + "find": "start=\"20261029193000\"", + "replace": "start=\"20261029193100\"" + } + ], + "outcome": { + "refused": "import_gap" + } + }, + { + "name": "overlap", + "about": "a programme that ends after the next begins", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample.xmltv.xml", + "find": "stop=\"20261029203000 +0100\"", + "replace": "stop=\"20261029203100 +0100\"" + } + ], + "outcome": { + "refused": "import_overlap" + } + }, + { + "name": "owner_unknown", + "about": "an asset of the XMLTV document alone, and no owner given", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": null, + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [], + "outcome": { + "refused": "import_owner_unknown" + } + }, + { + "name": "artwork_missing", + "about": "an icon whose file the artwork set does not hold", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [], + "outcome": { + "refused": "import_artwork_missing" + } + }, + { + "name": "break_outside", + "about": "a break that runs past the end of its programme", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample.m3u8", + "find": "ID=\"brk-2\",START-DATE=\"2026-10-29T18:28:00.000Z\"", + "replace": "ID=\"brk-2\",START-DATE=\"2026-10-29T18:29:00.000Z\"" + } + ], + "outcome": { + "refused": "import_break_outside" + } + }, + { + "name": "window_outside", + "about": "a window that begins before the schedule", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793293200000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [], + "outcome": { + "refused": "import_window_outside" + } + } + ], + "verifications": [ + { + "name": "receipt_and_lineup", + "about": "the sample's receipt and lineup", + "receipt": "sample.channel.json", + "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a227663ed8f17511d1ca8b78c5d3ba3854546651420ef97d39208883f9bf987b1d9d58577208bfb33aeeea3d94b973ebd816ce9cfc4e5a6762e777a39a8bbe806", + "guide": null, + "edits": [], + "facts": { + "verdict": "verified", + "part": null, + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "72ad84006fc7b22799708d78b736407742b35ea2ab32918e9068d263290481c7", + "guide": null, + "entry": null + } + }, + { + "name": "xmltv_guide", + "about": "the XMLTV guide of the whole schedule: two programmes aired in the receipt's window, two after it", + "receipt": "sample.channel.json", + "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a227663ed8f17511d1ca8b78c5d3ba3854546651420ef97d39208883f9bf987b1d9d58577208bfb33aeeea3d94b973ebd816ce9cfc4e5a6762e777a39a8bbe806", + "guide": "sample.guide.xml", + "edits": [], + "facts": { + "verdict": "verified", + "part": null, + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "72ad84006fc7b22799708d78b736407742b35ea2ab32918e9068d263290481c7", + "guide": { + "format": "xmltv", + "from_ms": 1793296800000, + "to_ms": 1793304000000, + "at_ms": null, + "programmes": [ + { + "start_ms": 1793296800000, + "stop_ms": 1793298600000, + "asset_id": "hl-101", + "aired": true + }, + { + "start_ms": 1793298600000, + "stop_ms": 1793300400000, + "asset_id": "tk-7", + "aired": true + }, + { + "start_ms": 1793300400000, + "stop_ms": 1793302200000, + "asset_id": "hl-102", + "aired": false + }, + { + "start_ms": 1793302200000, + "stop_ms": 1793304000000, + "asset_id": "nf-1", + "aired": false + } + ] + }, + "entry": null + } + }, + { + "name": "part_guide", + "about": "the XMLTV guide of a range across the window's end", + "receipt": "sample.channel.json", + "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a227663ed8f17511d1ca8b78c5d3ba3854546651420ef97d39208883f9bf987b1d9d58577208bfb33aeeea3d94b973ebd816ce9cfc4e5a6762e777a39a8bbe806", + "guide": "sample.part.guide.xml", + "edits": [], + "facts": { + "verdict": "verified", + "part": null, + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "72ad84006fc7b22799708d78b736407742b35ea2ab32918e9068d263290481c7", + "guide": { + "format": "xmltv", + "from_ms": 1793299500000, + "to_ms": 1793301300000, + "at_ms": null, + "programmes": [ + { + "start_ms": 1793298600000, + "stop_ms": 1793300400000, + "asset_id": "tk-7", + "aired": true + }, + { + "start_ms": 1793300400000, + "stop_ms": 1793302200000, + "asset_id": "hl-102", + "aired": false + } + ] + }, + "entry": null + } + }, + { + "name": "now_next", + "about": "the now/next document inside a programme's break", + "receipt": "sample.channel.json", + "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a227663ed8f17511d1ca8b78c5d3ba3854546651420ef97d39208883f9bf987b1d9d58577208bfb33aeeea3d94b973ebd816ce9cfc4e5a6762e777a39a8bbe806", + "guide": "sample.now-next.json", + "edits": [], + "facts": { + "verdict": "verified", + "part": null, + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "72ad84006fc7b22799708d78b736407742b35ea2ab32918e9068d263290481c7", + "guide": { + "format": "now-next", + "from_ms": null, + "to_ms": null, + "at_ms": 1793299545000, + "programmes": [ + { + "start_ms": 1793298600000, + "stop_ms": 1793300400000, + "asset_id": "tk-7", + "aired": true + }, + { + "start_ms": 1793300400000, + "stop_ms": 1793302200000, + "asset_id": "hl-102", + "aired": false + } + ] + }, + "entry": null + } + }, + { + "name": "now_next_before", + "about": "the now/next document before the schedule begins", + "receipt": "sample.channel.json", + "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a227663ed8f17511d1ca8b78c5d3ba3854546651420ef97d39208883f9bf987b1d9d58577208bfb33aeeea3d94b973ebd816ce9cfc4e5a6762e777a39a8bbe806", + "guide": "sample.before.now-next.json", + "edits": [], + "facts": { + "verdict": "verified", + "part": null, + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "72ad84006fc7b22799708d78b736407742b35ea2ab32918e9068d263290481c7", + "guide": { + "format": "now-next", + "from_ms": null, + "to_ms": null, + "at_ms": 1793293200000, + "programmes": [ + { + "start_ms": 1793296800000, + "stop_ms": 1793298600000, + "asset_id": "hl-101", + "aired": true + } + ] + }, + "entry": null + } + }, + { + "name": "now_next_after", + "about": "the now/next document after the schedule ends", + "receipt": "sample.channel.json", + "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a227663ed8f17511d1ca8b78c5d3ba3854546651420ef97d39208883f9bf987b1d9d58577208bfb33aeeea3d94b973ebd816ce9cfc4e5a6762e777a39a8bbe806", + "guide": "sample.after.now-next.json", + "edits": [], + "facts": { + "verdict": "verified", + "part": null, + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "72ad84006fc7b22799708d78b736407742b35ea2ab32918e9068d263290481c7", + "guide": { + "format": "now-next", + "from_ms": null, + "to_ms": null, + "at_ms": 1793307600000, + "programmes": [] + }, + "entry": null + } + }, + { + "name": "receipt_malformed", + "about": "a receipt missing a member", + "receipt": "sample.channel.json", + "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a227663ed8f17511d1ca8b78c5d3ba3854546651420ef97d39208883f9bf987b1d9d58577208bfb33aeeea3d94b973ebd816ce9cfc4e5a6762e777a39a8bbe806", + "guide": null, + "edits": [ + { + "file": "sample.channel.json", + "find": "\"signer_id\": \"sample-operator\"", + "replace": "\"signer\": \"sample-operator\"" + } + ], + "facts": { + "verdict": "channel_receipt_malformed", + "part": "receipt", + "receipt": null, + "lineup": null, + "guide": null, + "entry": null + } + }, + { + "name": "receipt_bad_signature", + "about": "a receipt whose signature was altered", + "receipt": "sample.channel.json", + "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a227663ed8f17511d1ca8b78c5d3ba3854546651420ef97d39208883f9bf987b1d9d58577208bfb33aeeea3d94b973ebd816ce9cfc4e5a6762e777a39a8bbe806", + "guide": null, + "edits": [ + { + "file": "sample.channel.json", + "find": "70267928c5af2dc768215e23ca3fe4e47d106658261b40cc27a1fff1fb72906f84fe11bab8f3b95bc2763b86e95d7eaaeeb28a02b847f48f25e7107fb218d50a", + "replace": "00267928c5af2dc768215e23ca3fe4e47d106658261b40cc27a1fff1fb72906f84fe11bab8f3b95bc2763b86e95d7eaaeeb28a02b847f48f25e7107fb218d50a" + } + ], + "facts": { + "verdict": "bad_signature", + "part": "receipt", + "receipt": "6eacddf5e17d1aa344ee867d7b4d04df3555fb051e3118b11a3775edf24f2c6a", + "lineup": null, + "guide": null, + "entry": null + } + }, + { + "name": "receipt_invalid", + "about": "a receipt, signed, whose program-as-run is not what its window gives", + "receipt": "aired-misreported.channel.json", + "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a227663ed8f17511d1ca8b78c5d3ba3854546651420ef97d39208883f9bf987b1d9d58577208bfb33aeeea3d94b973ebd816ce9cfc4e5a6762e777a39a8bbe806", + "guide": null, + "edits": [], + "facts": { + "verdict": "channel_receipt_invalid", + "part": "receipt", + "receipt": "edd53f42022f7846ee7aa67c7ad0dc04685e00926ba18b37acdef22b09fcf711", + "lineup": null, + "guide": null, + "entry": null + } + }, + { + "name": "lineup_malformed", + "about": "a lineup with a byte after its signature", + "receipt": "sample.channel.json", + "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a227663ed8f17511d1ca8b78c5d3ba3854546651420ef97d39208883f9bf987b1d9d58577208bfb33aeeea3d94b973ebd816ce9cfc4e5a6762e777a39a8bbe80600", + "guide": null, + "edits": [], + "facts": { + "verdict": "lineup_malformed", + "part": "lineup", + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": null, + "guide": null, + "entry": null + } + }, + { + "name": "lineup_bad_signature", + "about": "a lineup whose signature was altered", + "receipt": "sample.channel.json", + "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a227663ed8f17511d1ca8b78c5d3ba3854546651420ef97d39208883f9bf987b1d9d58577208bfb33aeeea3d94b973ebd816ce9cfc4e5a6762e777a39a8bbe807", + "guide": null, + "edits": [], + "facts": { + "verdict": "bad_signature", + "part": "lineup", + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "72ad84006fc7b22799708d78b736407742b35ea2ab32918e9068d263290481c7", + "guide": null, + "entry": null + } + }, + { + "name": "lineup_bad_key", + "about": "the identity point as the key, with (R, S) = (B, 1): the equation holds for every message, and the key is of small order", + "receipt": "sample.channel.json", + "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000010000000000000000000000000000000000000000000000000000000000000058666666666666666666666666666666666666666666666666666666666666660100000000000000000000000000000000000000000000000000000000000000", + "guide": null, + "edits": [], + "facts": { + "verdict": "bad_key", + "part": "lineup", + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "1577f6220ffcb24ce7e7a73f3e43ef9a341cb791107ba276ced3a56ec68f4faa", + "guide": null, + "entry": null + } + }, + { + "name": "lineup_signer_mismatch", + "about": "a lineup another key signed", + "receipt": "sample.channel.json", + "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000a84e8579cbcc4c8793feab96571297a22458d3bd3d47671107083829f7ba6b1e676042cab0030f756201a8b9e03d6486f5a122f26ddc2ed0347043503bdd071fe18a5ebb4d31a26a225881cc59643217338a6e35408904e8d0dd63d326dff808", + "guide": null, + "edits": [], + "facts": { + "verdict": "lineup_signer_mismatch", + "part": "lineup", + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "b9806fd698e5c0aaed9d169d22b9099cd0e496ba5441913dbd70006c230236d6", + "guide": null, + "entry": null + } + }, + { + "name": "lineup_schedule_mismatch", + "about": "a lineup, signed, whose second programme begins a minute late, where no entry does", + "receipt": "sample.channel.json", + "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a1010000a0faed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a033bf88417ca7e6f1405ec8114a52bc5d7dc08c944470c72ea9d13503862f10966acf5eae95697b9682a43d99f8ec2246812ef4b9cf356fd257ff96a94bd1a0e", + "guide": null, + "edits": [], + "facts": { + "verdict": "lineup_schedule_mismatch", + "part": "lineup", + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "1ef0f6c2abf972eff6b8b4bba59680b38a27361e648d6d40b38cc5caf86c6edb", + "guide": null, + "entry": null + } + }, + { + "name": "guide_malformed", + "about": "a guide cut short", + "receipt": "sample.channel.json", + "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a227663ed8f17511d1ca8b78c5d3ba3854546651420ef97d39208883f9bf987b1d9d58577208bfb33aeeea3d94b973ebd816ce9cfc4e5a6762e777a39a8bbe806", + "guide": "sample.guide.xml", + "edits": [ + { + "file": "sample.guide.xml", + "find": "\n", + "replace": "" + } + ], + "facts": { + "verdict": "guide_malformed", + "part": "guide", + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "72ad84006fc7b22799708d78b736407742b35ea2ab32918e9068d263290481c7", + "guide": null, + "entry": null + } + }, + { + "name": "guide_source_mismatch", + "about": "a guide naming another schedule", + "receipt": "sample.channel.json", + "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a227663ed8f17511d1ca8b78c5d3ba3854546651420ef97d39208883f9bf987b1d9d58577208bfb33aeeea3d94b973ebd816ce9cfc4e5a6762e777a39a8bbe806", + "guide": "sample.guide.xml", + "edits": [ + { + "file": "sample.guide.xml", + "find": "d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b83", + "replace": "0000000000000000000000000000000000000000000000000000000000000000" + } + ], + "facts": { + "verdict": "guide_source_mismatch", + "part": "guide", + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "72ad84006fc7b22799708d78b736407742b35ea2ab32918e9068d263290481c7", + "guide": { + "format": "xmltv", + "from_ms": 1793296800000, + "to_ms": 1793304000000, + "at_ms": null, + "programmes": [ + { + "start_ms": 1793296800000, + "stop_ms": 1793298600000, + "asset_id": "hl-101", + "aired": true + }, + { + "start_ms": 1793298600000, + "stop_ms": 1793300400000, + "asset_id": "tk-7", + "aired": true + }, + { + "start_ms": 1793300400000, + "stop_ms": 1793302200000, + "asset_id": "hl-102", + "aired": false + }, + { + "start_ms": 1793302200000, + "stop_ms": 1793304000000, + "asset_id": "nf-1", + "aired": false + } + ] + }, + "entry": null + } + }, + { + "name": "guide_entry_unscheduled", + "about": "a guide that places Night Freight where the schedule places Harbour Lights", + "receipt": "sample.channel.json", + "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a227663ed8f17511d1ca8b78c5d3ba3854546651420ef97d39208883f9bf987b1d9d58577208bfb33aeeea3d94b973ebd816ce9cfc4e5a6762e777a39a8bbe806", + "guide": "sample.guide.xml", + "edits": [ + { + "file": "sample.guide.xml", + "find": ">hl-102", + "replace": ">nf-1" + } + ], + "facts": { + "verdict": "guide_entry_unscheduled", + "part": "guide", + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "72ad84006fc7b22799708d78b736407742b35ea2ab32918e9068d263290481c7", + "guide": { + "format": "xmltv", + "from_ms": 1793296800000, + "to_ms": 1793304000000, + "at_ms": null, + "programmes": [ + { + "start_ms": 1793296800000, + "stop_ms": 1793298600000, + "asset_id": "hl-101", + "aired": true + }, + { + "start_ms": 1793298600000, + "stop_ms": 1793300400000, + "asset_id": "tk-7", + "aired": true + }, + { + "start_ms": 1793300400000, + "stop_ms": 1793302200000, + "asset_id": "nf-1", + "aired": false + }, + { + "start_ms": 1793302200000, + "stop_ms": 1793304000000, + "asset_id": "nf-1", + "aired": false + } + ] + }, + "entry": 2 + } + }, + { + "name": "guide_mismatch", + "about": "a guide whose title the lineup does not state", + "receipt": "sample.channel.json", + "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a227663ed8f17511d1ca8b78c5d3ba3854546651420ef97d39208883f9bf987b1d9d58577208bfb33aeeea3d94b973ebd816ce9cfc4e5a6762e777a39a8bbe806", + "guide": "sample.guide.xml", + "edits": [ + { + "file": "sample.guide.xml", + "find": "Night Freight", + "replace": "Night Freight Live" + } + ], + "facts": { + "verdict": "guide_mismatch", + "part": "guide", + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "72ad84006fc7b22799708d78b736407742b35ea2ab32918e9068d263290481c7", + "guide": { + "format": "xmltv", + "from_ms": 1793296800000, + "to_ms": 1793304000000, + "at_ms": null, + "programmes": [ + { + "start_ms": 1793296800000, + "stop_ms": 1793298600000, + "asset_id": "hl-101", + "aired": true + }, + { + "start_ms": 1793298600000, + "stop_ms": 1793300400000, + "asset_id": "tk-7", + "aired": true + }, + { + "start_ms": 1793300400000, + "stop_ms": 1793302200000, + "asset_id": "hl-102", + "aired": false + }, + { + "start_ms": 1793302200000, + "stop_ms": 1793304000000, + "asset_id": "nf-1", + "aired": false + } + ] + }, + "entry": null + } + }, + { + "name": "now_next_unscheduled", + "about": "a now/next document naming another programme on air", + "receipt": "sample.channel.json", + "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a227663ed8f17511d1ca8b78c5d3ba3854546651420ef97d39208883f9bf987b1d9d58577208bfb33aeeea3d94b973ebd816ce9cfc4e5a6762e777a39a8bbe806", + "guide": "sample.now-next.json", + "edits": [ + { + "file": "sample.now-next.json", + "find": "\"asset_id\":\"tk-7\"", + "replace": "\"asset_id\":\"hl-101\"" + } + ], + "facts": { + "verdict": "guide_entry_unscheduled", + "part": "guide", + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "72ad84006fc7b22799708d78b736407742b35ea2ab32918e9068d263290481c7", + "guide": { + "format": "now-next", + "from_ms": null, + "to_ms": null, + "at_ms": 1793299545000, + "programmes": [ + { + "start_ms": 1793298600000, + "stop_ms": 1793300400000, + "asset_id": "hl-101", + "aired": true + }, + { + "start_ms": 1793300400000, + "stop_ms": 1793302200000, + "asset_id": "hl-102", + "aired": false + } + ] + }, + "entry": 0 + } + } + ] +} diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index 17e92ef77..81ab4571c 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -159,7 +159,7 @@ floor = ["dep:image", "dep:claxon", "dep:zstd"] # extensions/fleet.md §7 verifies it), and `wai mcp`, the server # `wai setup-agent` registers. Pure Rust and the floor's zstd: no system library. `wai create`, # `wai dev` and `wai setup-agent` need no feature. -quickstart = ["floor", "http", "receipt_claims", "conform_result", "mcp", "fleet", "dep:getrandom"] +quickstart = ["floor", "http", "receipt_claims", "conform_result", "mcp", "fleet", "dep:getrandom", "channel_lineup"] # extensions/conformance-results.md: the record `conform-result/1`, its signature and verification. conform_result = ["ed25519"] # Pulls in the ONNX runtime + zstd unpack so the native sink can @@ -366,6 +366,14 @@ live_ads = ["live_room", "video_ssai", "dep:num-bigint"] # verify. ad-presentation's spans, viewability, quartile and attestation; live-compose's layout; # a reader of HLS attribute lists, asset lists and an XML subset of its own. No new dependency. stitched_presentation = ["live_ads", "live_compose"] +# Channel lineup and guide (extensions/channel-lineup): `wai.video.lineup` — a linear channel's +# signed lineup (WCL1) 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 @@ -1280,6 +1288,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" @@ -2286,6 +2301,11 @@ name = "stitched_presentation" path = "tests/stitched_presentation.rs" required-features = ["stitched_presentation"] +[[test]] +name = "channel_lineup" +path = "tests/channel_lineup.rs" +required-features = ["channel_lineup"] + [[test]] name = "live_provenance" path = "tests/live_provenance.rs" diff --git a/wai/wai-rs/src/bin/wai_channel_lineup_vectors.rs b/wai/wai-rs/src/bin/wai_channel_lineup_vectors.rs new file mode 100644 index 000000000..c097f74ef --- /dev/null +++ b/wai/wai-rs/src/bin/wai_channel_lineup_vectors.rs @@ -0,0 +1,89 @@ +//! The corpus of extensions/channel-lineup.md §11 (`live-conformance/channels/`). +//! +//! wai_channel_lineup_vectors gen write every file from the fixed seeds +//! wai_channel_lineup_vectors verify require byte-identical regeneration, no other +//! file but the README, and every vector's outcome +//! +//! Exit status 0 when it holds, 1 with one line a finding otherwise, 2 on a usage error. + +use std::collections::BTreeMap; +use std::path::Path; +use std::process::ExitCode; + +use wai::channel_lineup::corpus; + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + let (Some(mode), Some(dir)) = (args.first(), args.get(1)) else { + eprintln!("usage: wai_channel_lineup_vectors gen|verify "); + return ExitCode::from(2); + }; + let dir = Path::new(dir); + let generated = corpus::generate(); + match mode.as_str() { + "gen" => { + if let Err(e) = std::fs::create_dir_all(dir) { + eprintln!("{}: {e}", dir.display()); + return ExitCode::from(1); + } + for (name, body) in &generated { + if let Err(e) = std::fs::write(dir.join(name), body) { + eprintln!("{name}: {e}"); + return ExitCode::from(1); + } + } + println!("wrote {} files to {}", generated.len(), dir.display()); + ExitCode::SUCCESS + } + "verify" => { + let mut committed = BTreeMap::new(); + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(e) => { + eprintln!("{}: {e}", dir.display()); + return ExitCode::from(1); + } + }; + for e in entries.flatten() { + let name = e.file_name().to_string_lossy().into_owned(); + if name == "README.md" { + continue; + } + match std::fs::read_to_string(e.path()) { + Ok(s) => { + committed.insert(name, s); + } + Err(err) => { + eprintln!("{name}: {err}"); + return ExitCode::from(1); + } + } + } + let mut fails: Vec = Vec::new(); + for (name, body) in &generated { + match committed.get(name) { + None => fails.push(format!("{name}: missing")), + Some(c) if c != body => fails.push(format!("{name}: does not regenerate byte for byte")), + Some(_) => {} + } + } + for name in committed.keys().filter(|n| !generated.iter().any(|(g, _)| g == *n)) { + fails.push(format!("{name}: not a file of the corpus")); + } + fails.extend(corpus::verify(&committed)); + for f in &fails { + println!("{f}"); + } + if fails.is_empty() { + println!("channel lineup corpus: {} files regenerate and every vector holds", generated.len()); + ExitCode::SUCCESS + } else { + ExitCode::from(1) + } + } + _ => { + eprintln!("usage: wai_channel_lineup_vectors gen|verify "); + ExitCode::from(2) + } + } +} diff --git a/wai/wai-rs/src/channel_lineup.rs b/wai/wai-rs/src/channel_lineup.rs new file mode 100644 index 000000000..f16126d84 --- /dev/null +++ b/wai/wai-rs/src/channel_lineup.rs @@ -0,0 +1,1269 @@ +//! Channel lineup and guide — `wai.video.lineup` (extensions/channel-lineup.md). +//! +//! A FAST channel's schedule and catalogue, as operators exchange them (an XMLTV schedule, +//! Media RSS catalogues, a JSON form of both, the SCTE-35 date ranges of the channel's HLS Media +//! Playlist), are imported ([`import`]) into a `wai.video.channel` receipt, whose signed bytes +//! do not change, and a signed **lineup**, `WCL1` ([`Lineup`]): each programme asset's +//! description, artwork by content hash, where it may be shown and its rights holder, and where +//! each programme of the guide begins, bound to the schedule by its root. The **guide** — an +//! XMLTV document ([`xmltv_guide`]) or a now/next document ([`now_next`]) — is rendered from the +//! receipt and the lineup and names both, so [`verify`] recomputes it and compares. +//! +//! Every function here is a pure function of the bytes it is given: no I/O, no clock and no +//! floating point. The corpus of §11 is [`corpus`]. + +pub mod corpus; +pub mod import; + +use ed25519_dalek::{Signer, SigningKey}; +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; + +use crate::energy_class::{CounterFiltering, Uncertainty}; +use crate::quantum_ops::GrantRef; +use crate::stitched_presentation::markers::{read_xml, Element}; +use crate::strict_json::{self, Json}; +use crate::wai_video_channel::{ChannelReceipt, EntryKind, ScheduleEntry}; +use crate::wai_video_manifest::EnergyProvenance; + +/// The record this module registers (SPEC §5). +pub const CAPABILITY: &str = "wai.video.lineup"; +/// The lineup's magic (§6.1). +pub const MAGIC: &[u8; 4] = b"WCL1"; +/// The lineup's version (§6.1). +pub const VERSION: u8 = 1; +/// The lineup's signing domain (§6.3). +const SIG_DOMAIN: &[u8] = b"wai:channel-lineup\x01"; +/// The XMLTV `episode-num` system a guide writes, and the import's default `id_system` (§7.2). +pub const ID_SYSTEM: &str = "wai-asset-id"; +/// The prefix of a guide's `source-data-url` (§7.2). +pub const GUIDE_URN: &str = "urn:wai:guide:"; +/// The `kind` of a now/next document (§7.3). +pub const NOW_NEXT_KIND: &str = "wai-now-next"; +/// The `kind` of the JSON form (§4.4). +pub const FEED_KIND: &str = "wai-channel-feed"; +/// The most bytes of a feed or an artwork file (§3.1). +pub const MAX_DOCUMENT: usize = 16_777_216; +/// The most programmes, items, assets or breaks (§3.1), and the lineup's counts (§6.1). +pub const MAX_COUNT: usize = 10_000; +/// The most categories and artworks of an asset (§6.2). +pub const MAX_CATEGORIES: usize = 16; +pub const MAX_ARTWORK: usize = 8; +/// The largest instant or duration this text reads or writes (§2): what JSON carries exactly. +pub const MAX_INSTANT: u64 = (1 << 53) - 1; + +// --------------------------------------------------------------------------------------- +// §10 Reasons +// --------------------------------------------------------------------------------------- + +/// A reason of §10, or one of live-events' this text reuses. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Reason { + ImportInputsInvalid, + ImportOversized, + ImportXmlMalformed, + ImportJsonMalformed, + ImportPlaylistMalformed, + ImportTimeMalformed, + ImportTimezoneUnknown, + ImportNoStop, + ImportEmpty, + ImportAssetUnknown, + ImportAssetDuplicate, + ImportFieldInvalid, + ImportScte35Malformed, + ImportBreakDuration, + ImportDurationUnknown, + ImportGap, + ImportOverlap, + ImportOwnerUnknown, + ImportArtworkMissing, + ImportBreakOutside, + ImportWindowOutside, + ChannelReceiptMalformed, + ChannelReceiptInvalid, + LineupMalformed, + LineupSignerMismatch, + LineupScheduleMismatch, + GuideMalformed, + GuideSourceMismatch, + GuideEntryUnscheduled, + GuideMismatch, + BadKey, + BadSignature, +} + +impl Reason { + /// The codes this text adds, in §10's order. + pub const ADDED: &'static [Reason] = &[ + Reason::ImportInputsInvalid, + Reason::ImportOversized, + Reason::ImportXmlMalformed, + Reason::ImportJsonMalformed, + Reason::ImportPlaylistMalformed, + Reason::ImportTimeMalformed, + Reason::ImportTimezoneUnknown, + Reason::ImportNoStop, + Reason::ImportEmpty, + Reason::ImportAssetUnknown, + Reason::ImportAssetDuplicate, + Reason::ImportFieldInvalid, + Reason::ImportScte35Malformed, + Reason::ImportBreakDuration, + Reason::ImportDurationUnknown, + Reason::ImportGap, + Reason::ImportOverlap, + Reason::ImportOwnerUnknown, + Reason::ImportArtworkMissing, + Reason::ImportBreakOutside, + Reason::ImportWindowOutside, + Reason::ChannelReceiptMalformed, + Reason::ChannelReceiptInvalid, + Reason::LineupMalformed, + Reason::LineupSignerMismatch, + Reason::LineupScheduleMismatch, + Reason::GuideMalformed, + Reason::GuideSourceMismatch, + Reason::GuideEntryUnscheduled, + Reason::GuideMismatch, + ]; + /// live-events' codes a verification also gives (§10). + pub const REUSED: &'static [Reason] = &[Reason::BadKey, Reason::BadSignature]; + + /// The stable code. + pub fn code(self) -> &'static str { + match self { + Reason::ImportInputsInvalid => "import_inputs_invalid", + Reason::ImportOversized => "import_oversized", + Reason::ImportXmlMalformed => "import_xml_malformed", + Reason::ImportJsonMalformed => "import_json_malformed", + Reason::ImportPlaylistMalformed => "import_playlist_malformed", + Reason::ImportTimeMalformed => "import_time_malformed", + Reason::ImportTimezoneUnknown => "import_timezone_unknown", + Reason::ImportNoStop => "import_no_stop", + Reason::ImportEmpty => "import_empty", + Reason::ImportAssetUnknown => "import_asset_unknown", + Reason::ImportAssetDuplicate => "import_asset_duplicate", + Reason::ImportFieldInvalid => "import_field_invalid", + Reason::ImportScte35Malformed => "import_scte35_malformed", + Reason::ImportBreakDuration => "import_break_duration", + Reason::ImportDurationUnknown => "import_duration_unknown", + Reason::ImportGap => "import_gap", + Reason::ImportOverlap => "import_overlap", + Reason::ImportOwnerUnknown => "import_owner_unknown", + Reason::ImportArtworkMissing => "import_artwork_missing", + Reason::ImportBreakOutside => "import_break_outside", + Reason::ImportWindowOutside => "import_window_outside", + Reason::ChannelReceiptMalformed => "channel_receipt_malformed", + Reason::ChannelReceiptInvalid => "channel_receipt_invalid", + Reason::LineupMalformed => "lineup_malformed", + Reason::LineupSignerMismatch => "lineup_signer_mismatch", + Reason::LineupScheduleMismatch => "lineup_schedule_mismatch", + Reason::GuideMalformed => "guide_malformed", + Reason::GuideSourceMismatch => "guide_source_mismatch", + Reason::GuideEntryUnscheduled => "guide_entry_unscheduled", + Reason::GuideMismatch => "guide_mismatch", + Reason::BadKey => "bad_key", + Reason::BadSignature => "bad_signature", + } + } + + /// The reason of a code, among those this module gives. + pub fn from_code(code: &str) -> Option { + Reason::ADDED.iter().chain(Reason::REUSED).copied().find(|r| r.code() == code) + } +} + +impl std::fmt::Display for Reason { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.code()) + } +} + +impl std::error::Error for Reason {} + +fn sig_reason(e: crate::sig::SigError) -> Reason { + match e { + crate::sig::SigError::BadKey => Reason::BadKey, + _ => Reason::BadSignature, + } +} + +// --------------------------------------------------------------------------------------- +// §3.2 Text values, §3.4 times +// --------------------------------------------------------------------------------------- + +/// A text (§3.2): no character below U+0020 but tab and line feed. +pub fn is_text(s: &str) -> bool { + s.chars().all(|c| c >= ' ' || c == '\t' || c == '\n') +} + +/// A short text (§3.2): a text of 1 to 255 bytes. +pub fn is_short(s: &str) -> bool { + (1..=255).contains(&s.len()) && is_text(s) +} + +/// A long text (§3.2): a text of 0 to 65 535 bytes. +pub fn is_long(s: &str) -> bool { + s.len() <= 65_535 && is_text(s) +} + +/// Days since 1970-01-01 of a Gregorian date. +fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { + let y = if m <= 2 { y - 1 } else { y }; + let era = y.div_euclid(400); + let yoe = y - era * 400; + let mp = (m + 9) % 12; + let doy = (153 * mp + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe - 719_468 +} + +fn days_in_month(y: i64, m: i64) -> i64 { + match m { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + _ if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29, + _ => 28, + } +} + +fn digits(s: &str) -> Option { + (!s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())).then(|| s.parse().ok()).flatten() +} + +/// The instant of a date and time in UTC with an offset in minutes east of UTC (§3.4), or +/// `None` when a field is out of range or the instant is before 1970. +fn instant(y: i64, mo: i64, d: i64, h: i64, mi: i64, s: i64, ms: i64, offset_min: i64) -> Option { + if y < 1970 || !(1..=12).contains(&mo) || d < 1 || d > days_in_month(y, mo) || h > 23 || mi > 59 || s > 59 { + return None; + } + let secs = days_from_civil(y, mo, d) * 86_400 + h * 3600 + mi * 60 + s - offset_min * 60; + let total = secs.checked_mul(1000)?.checked_add(ms)?; + u64::try_from(total).ok() +} + +/// An XMLTV time (§3.4), as an instant. +pub fn xmltv_time(v: &str) -> Result { + let b = v.as_bytes(); + if b.len() < 14 || !b[..14].iter().all(u8::is_ascii_digit) { + return Err(Reason::ImportTimeMalformed); + } + let rest = &v[14..]; + let offset = if rest.is_empty() { + 0 + } else if let Some(z) = rest.strip_prefix(' ') { + let zb = z.as_bytes(); + let numeric = zb.len() == 5 && (zb[0] == b'+' || zb[0] == b'-') && zb[1..].iter().all(u8::is_ascii_digit); + let (hh, mm) = if numeric { (digits(&z[1..3]).unwrap_or(99), digits(&z[3..5]).unwrap_or(99)) } else { (99, 99) }; + if !numeric || hh > 23 || mm > 59 { + return Err(Reason::ImportTimezoneUnknown); + } + let m = hh * 60 + mm; + if zb[0] == b'-' { -m } else { m } + } else { + return Err(Reason::ImportTimeMalformed); + }; + let f = |a: usize, b: usize| digits(&v[a..b]).expect("digits"); + instant(f(0, 4), f(4, 6), f(6, 8), f(8, 10), f(10, 12), f(12, 14), 0, offset).ok_or(Reason::ImportTimeMalformed) +} + +/// An instant as an XMLTV time in UTC with its zone written (§7.2): `YYYYMMDDhhmmss +0000`. +pub fn xmltv_format(ms: u64) -> String { + let secs = (ms / 1000) as i64; + let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); + let (y, m, d) = civil_from_days(days); + format!("{y:04}{m:02}{d:02}{:02}{:02}{:02} +0000", rem / 3600, rem % 3600 / 60, rem % 60) +} + +fn civil_from_days(z: i64) -> (i64, i64, i64) { + let z = z + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + (if m <= 2 { y + 1 } else { y }, m, d) +} + +/// An HLS date-time (§3.4), as an instant. +pub fn hls_date_time(v: &str) -> Result { + let b = v.as_bytes(); + let malformed = Err(Reason::ImportTimeMalformed); + if b.len() < 19 || !b.is_ascii() { + return malformed; + } + let shape = |i: usize, c: u8| b[i] == c; + let dig = |r: std::ops::Range| b[r].iter().all(u8::is_ascii_digit); + if !(dig(0..4) && shape(4, b'-') && dig(5..7) && shape(7, b'-') && dig(8..10) && shape(10, b'T') && dig(11..13) && shape(13, b':') && dig(14..16) && shape(16, b':') && dig(17..19)) { + return malformed; + } + let mut p = 19; + let mut ms = 0i64; + if p < b.len() && b[p] == b'.' { + let start = p + 1; + let mut end = start; + while end < b.len() && b[end].is_ascii_digit() { + end += 1; + } + if end == start || b[start + 3.min(end - start)..end].iter().any(|&c| c != b'0') { + return malformed; + } + let kept = &v[start..end.min(start + 3)]; + ms = digits(&format!("{kept:0<3}")).expect("digits"); + p = end; + } + let offset = match &v[p..] { + "" => return Err(Reason::ImportTimezoneUnknown), + "Z" => 0, + z if z.len() == 6 && (z.starts_with('+') || z.starts_with('-')) && z.as_bytes()[3] == b':' && z[1..3].bytes().all(|c| c.is_ascii_digit()) && z[4..6].bytes().all(|c| c.is_ascii_digit()) => { + let (hh, mm) = (digits(&z[1..3]).expect("digits"), digits(&z[4..6]).expect("digits")); + if hh > 23 || mm > 59 { + return malformed; + } + if z.starts_with('-') { -(hh * 60 + mm) } else { hh * 60 + mm } + } + _ => return malformed, + }; + let f = |a: usize, b: usize| digits(&v[a..b]).expect("digits"); + match instant(f(0, 4), f(5, 7), f(8, 10), f(11, 13), f(14, 16), f(17, 19), ms, offset) { + Some(t) if t <= MAX_INSTANT => Ok(t), + _ => malformed, + } +} + +/// A duration (§3.4): decimal-floating-point seconds, as milliseconds. +pub fn duration_ms(v: &str) -> Result { + match crate::stitched_presentation::markers::decimal_scaled(v, 3) { + Some(ms) if ms <= MAX_INSTANT => Ok(ms), + _ => Err(Reason::ImportTimeMalformed), + } +} + +// --------------------------------------------------------------------------------------- +// §3.3 The XML subset, with a document type declaration skipped +// --------------------------------------------------------------------------------------- + +/// The end of a document type declaration of §3.3 that begins at `at`, or `None` when the +/// bytes there are not one. +fn doctype_end(b: &[u8], at: usize) -> Option { + let ws = |c: u8| matches!(c, b' ' | b'\t' | b'\r' | b'\n'); + let skip = |mut p: usize| { + while p < b.len() && ws(b[p]) { + p += 1; + } + p + }; + let literal = |p: usize| -> Option { + let q = *b.get(p)?; + if q != b'"' && q != b'\'' { + return None; + } + let close = b[p + 1..].iter().position(|&c| c == q)?; + Some(p + 1 + close + 1) + }; + let mut p = at + b" p { + if b[after..].starts_with(b"SYSTEM") { + let q = skip(after + 6); + if q == after + 6 { + return None; + } + p = literal(q)?; + } else if b[after..].starts_with(b"PUBLIC") { + let q = skip(after + 6); + if q == after + 6 { + return None; + } + let r = literal(q)?; + let s = skip(r); + if s == r { + return None; + } + p = literal(s)?; + } else { + p = after; + } + } + p = skip(p); + (b.get(p) == Some(&b'>')).then_some(p + 1) +} + +/// Read an XML document over the subset of §3.3: stitched-presentation §3.5's, with one +/// document type declaration of no internal subset skipped before the root. +pub fn read_feed_xml(b: &[u8]) -> Option { + let mut b = b.to_vec(); + let mut p = 0; + loop { + while p < b.len() && matches!(b[p], b' ' | b'\t' | b'\r' | b'\n') { + p += 1; + } + let rest = &b[p..]; + if rest.starts_with(b"")? + 3; + } else if rest.starts_with(b"")? + 2; + } else if rest.starts_with(b"(e: &'a Element, name: &'a str) -> Option<&'a Element> { + e.children_named(name).next() +} + +// --------------------------------------------------------------------------------------- +// §5 The channel receipt's JSON carriage +// --------------------------------------------------------------------------------------- + +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +/// Lowercase hexadecimal of an even count of digits, as bytes. +pub(crate) fn hex_bytes(s: &str) -> Option> { + if !s.len().is_multiple_of(2) || !s.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) { + return None; + } + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()).collect() +} + +fn unhex(s: &str) -> Option<[u8; N]> { + if s.len() != 2 * N || !s.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) { + return None; + } + let mut out = [0u8; N]; + for (i, o) in out.iter_mut().enumerate() { + *o = u8::from_str_radix(&s[2 * i..2 * i + 2], 16).ok()?; + } + Some(out) +} + +fn kind_label(k: EntryKind) -> &'static str { + match k { + EntryKind::Program => "program", + EntryKind::Ad => "ad", + EntryKind::Filler => "filler", + } +} + +/// A receipt in its JSON carriage (§5), its members in the table's order. +pub fn receipt_to_json(r: &ChannelReceipt) -> Value { + let mut m = Map::new(); + m.insert("channel_id".into(), json!(r.channel_id)); + m.insert("schedule_root".into(), json!(hex(&r.schedule_root))); + let schedule: Vec = r + .schedule + .iter() + .map(|e| json!({"asset_id": e.asset_id, "start_ms": e.start_ms, "duration_ms": e.duration_ms, "kind": kind_label(e.kind)})) + .collect(); + m.insert("schedule".into(), Value::Array(schedule)); + m.insert("window_start_ms".into(), json!(r.window_start_ms)); + m.insert("window_end_ms".into(), json!(r.window_end_ms)); + m.insert("aired".into(), json!(r.aired)); + m.insert("work_schedule_ops".into(), json!(r.work_schedule_ops)); + m.insert("joules_micro".into(), json!(r.joules_micro)); + m.insert("energy_provenance".into(), r.energy_provenance.as_ref().map_or(Value::Null, |p| json!(p.label()))); + match &r.energy_provenance { + Some(EnergyProvenance::OnChipCounter { uncertainty, filtering }) => { + m.insert("energy_uncertainty".into(), json!({"relative_ppm": uncertainty.relative_ppm, "window_us": uncertainty.window_us})); + m.insert("energy_counter_filtering".into(), json!(filtering.label())); + } + Some(EnergyProvenance::CalibratedInstrument { uncertainty, calibration_ref }) => { + m.insert("energy_uncertainty".into(), json!({"relative_ppm": uncertainty.relative_ppm, "window_us": uncertainty.window_us})); + m.insert("energy_calibration_ref".into(), json!(calibration_ref)); + } + _ => {} + } + m.insert( + "grant".into(), + json!({ + "grant_hash": hex(&r.grant.grant_hash), + "capability": r.grant.capability, + "joule_ceiling_micro": r.grant.joule_ceiling_micro, + "funds_ceiling": r.grant.funds_ceiling, + }), + ); + m.insert("parent_receipt_hash".into(), r.parent_receipt_hash.map_or(Value::Null, |h| json!(hex(&h)))); + m.insert("signer_pubkey".into(), json!(hex(&r.signer_pubkey))); + m.insert("signer_id".into(), json!(r.signer_id)); + m.insert("sig".into(), json!(hex(&r.sig))); + Value::Object(m) +} + +/// A receipt's JSON carriage as a file holds it: indented, one line feed after it. +pub fn receipt_text(r: &ChannelReceipt) -> String { + serde_json::to_string_pretty(&receipt_to_json(r)).expect("JSON") + "\n" +} + +/// The members of a receipt's JSON carriage (§5), but a class's evidence. +const RECEIPT_MEMBERS: &[&str] = &[ + "channel_id", + "schedule_root", + "schedule", + "window_start_ms", + "window_end_ms", + "aired", + "work_schedule_ops", + "joules_micro", + "energy_provenance", + "grant", + "parent_receipt_hash", + "signer_pubkey", + "signer_id", + "sig", +]; + +/// An object whose members are exactly `want`, each once. +fn exactly<'a>(j: &'a Json, want: &[&str]) -> Option<&'a [(String, Json)]> { + let m = j.as_obj()?; + (m.len() == want.len() && want.iter().all(|k| m.iter().any(|(n, _)| n == k))).then_some(m) +} + +/// Read a receipt's JSON carriage (§5). Whether it verifies is [`receipt_verdict`]'s. +pub fn receipt_from_json(bytes: &[u8]) -> Option { + let j = strict_json::read(bytes).ok()?; + let label = match j.get("energy_provenance")? { + Json::Null => None, + Json::Str(s) => Some(s.as_str()), + _ => return None, + }; + let mut want: Vec<&str> = RECEIPT_MEMBERS.to_vec(); + match label { + Some("OnChipCounter") => want.extend(["energy_uncertainty", "energy_counter_filtering"]), + Some("CalibratedInstrument") => want.extend(["energy_uncertainty", "energy_calibration_ref"]), + _ => {} + } + exactly(&j, &want)?; + let u = |k: &str| j.get(k)?.as_u64(); + let s = |k: &str| j.get(k)?.as_str().map(str::to_string); + let uncertainty = || -> Option { + let o = j.get("energy_uncertainty")?; + exactly(o, &["relative_ppm", "window_us"])?; + Some(Uncertainty { relative_ppm: u32::try_from(o.get("relative_ppm")?.as_u64()?).ok()?, window_us: o.get("window_us")?.as_u64()? }) + }; + let energy_provenance = match label { + None => None, + Some("ModelBased") => Some(EnergyProvenance::ModelBased), + Some("Estimator") => Some(EnergyProvenance::Estimator), + Some("OnChipCounter") => Some(EnergyProvenance::OnChipCounter { + uncertainty: uncertainty()?, + filtering: CounterFiltering::from_label(j.get("energy_counter_filtering")?.as_str()?)?, + }), + Some("CalibratedInstrument") => { + Some(EnergyProvenance::CalibratedInstrument { uncertainty: uncertainty()?, calibration_ref: s("energy_calibration_ref")? }) + } + Some(_) => return None, + }; + let g = j.get("grant")?; + exactly(g, &["grant_hash", "capability", "joule_ceiling_micro", "funds_ceiling"])?; + let grant = GrantRef { + grant_hash: unhex(g.get("grant_hash")?.as_str()?)?, + capability: g.get("capability")?.as_str()?.to_string(), + joule_ceiling_micro: g.get("joule_ceiling_micro")?.as_u64()?, + funds_ceiling: match g.get("funds_ceiling")? { + Json::Null => None, + f => Some(f.as_u64()?), + }, + }; + let mut schedule = Vec::new(); + for e in j.get("schedule")?.as_arr()? { + exactly(e, &["asset_id", "start_ms", "duration_ms", "kind"])?; + let kind = match e.get("kind")?.as_str()? { + "program" => EntryKind::Program, + "ad" => EntryKind::Ad, + "filler" => EntryKind::Filler, + _ => return None, + }; + let (start_ms, duration_ms) = (e.get("start_ms")?.as_u64()?, e.get("duration_ms")?.as_u64()?); + start_ms.checked_add(duration_ms)?; + schedule.push(ScheduleEntry { asset_id: e.get("asset_id")?.as_str()?.to_string(), start_ms, duration_ms, kind }); + } + let aired = j.get("aired")?.as_arr()?.iter().map(Json::as_u64).collect::>>()?; + let parent_receipt_hash = match j.get("parent_receipt_hash")? { + Json::Null => None, + Json::Str(h) => Some(unhex(h)?), + _ => return None, + }; + Some(ChannelReceipt { + channel_id: s("channel_id")?, + schedule_root: unhex(j.get("schedule_root")?.as_str()?)?, + schedule, + window_start_ms: u("window_start_ms")?, + window_end_ms: u("window_end_ms")?, + aired, + work_schedule_ops: u("work_schedule_ops")?, + joules_micro: u("joules_micro")?, + energy_provenance, + grant, + parent_receipt_hash, + signer_pubkey: unhex(j.get("signer_pubkey")?.as_str()?)?, + signer_id: s("signer_id")?, + sig: unhex(j.get("sig")?.as_str()?)?, + }) +} + +/// A receipt's verdict (§8.2 steps 2 and 3): its signature, then its rules. +pub fn receipt_verdict(r: &ChannelReceipt) -> Result<(), Reason> { + crate::sig::verify_strict(&r.signer_pubkey, &r.signing_payload(), &r.sig).map_err(sig_reason)?; + if r.verify() { Ok(()) } else { Err(Reason::ChannelReceiptInvalid) } +} + +// --------------------------------------------------------------------------------------- +// §6 The lineup +// --------------------------------------------------------------------------------------- + +/// Where an asset may be shown (§6.2 `geo`). +pub mod geo { + pub const EVERYWHERE: u8 = 0; + pub const ONLY: u8 = 1; + pub const EXCEPT: u8 = 2; +} + +/// An asset of a lineup (§6.2). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Asset { + pub asset_id: String, + pub title: String, + pub description: String, + pub categories: Vec, + pub rating_system: String, + pub rating_value: String, + /// Each artwork: the SHA-256 of its bytes, and its URI. + pub artwork: Vec<([u8; 32], String)>, + pub geo: u8, + pub countries: Vec<[u8; 2]>, + pub rights_holder: String, + pub content_uri: String, + pub duration_ms: u64, +} + +/// A lineup, `WCL1` (§6.1). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Lineup { + pub channel_id: String, + pub channel_name: String, + pub schedule_root: [u8; 32], + pub schedule_start_ms: u64, + pub schedule_end_ms: u64, + /// Each programme's start, in order. + pub programmes: Vec, + /// In the order of their asset ids' bytes. + pub assets: Vec, + pub signer: [u8; 32], + pub sig: [u8; 64], +} + +fn short(o: &mut Vec, s: &str) { + o.push(u8::try_from(s.len()).expect("a short field")); + o.extend_from_slice(s.as_bytes()); +} + +fn long(o: &mut Vec, s: &str) { + o.extend_from_slice(&u16::try_from(s.len()).expect("a long field").to_le_bytes()); + o.extend_from_slice(s.as_bytes()); +} + +impl Lineup { + /// The bytes from `magic` through `signer` (§6.3). + fn body(&self) -> Vec { + let mut o = MAGIC.to_vec(); + o.push(VERSION); + short(&mut o, &self.channel_id); + short(&mut o, &self.channel_name); + o.extend_from_slice(&self.schedule_root); + o.extend_from_slice(&self.schedule_start_ms.to_le_bytes()); + o.extend_from_slice(&self.schedule_end_ms.to_le_bytes()); + o.extend_from_slice(&(self.programmes.len() as u32).to_le_bytes()); + for p in &self.programmes { + o.extend_from_slice(&p.to_le_bytes()); + } + o.extend_from_slice(&(self.assets.len() as u32).to_le_bytes()); + for a in &self.assets { + short(&mut o, &a.asset_id); + long(&mut o, &a.title); + long(&mut o, &a.description); + o.push(a.categories.len() as u8); + for c in &a.categories { + short(&mut o, c); + } + o.push(a.rating_system.len() as u8); + o.extend_from_slice(a.rating_system.as_bytes()); + o.push(a.rating_value.len() as u8); + o.extend_from_slice(a.rating_value.as_bytes()); + o.push(a.artwork.len() as u8); + for (h, uri) in &a.artwork { + o.extend_from_slice(h); + long(&mut o, uri); + } + o.push(a.geo); + o.push(a.countries.len() as u8); + for c in &a.countries { + o.extend_from_slice(c); + } + short(&mut o, &a.rights_holder); + long(&mut o, &a.content_uri); + o.extend_from_slice(&a.duration_ms.to_le_bytes()); + } + o.extend_from_slice(&self.signer); + o + } + + /// The lineup's bytes (§6.1). + pub fn to_bytes(&self) -> Vec { + let mut o = self.body(); + o.extend_from_slice(&self.sig); + o + } + + /// Its digest (§6.3). + pub fn digest(&self) -> [u8; 32] { + Sha256::digest(self.body()).into() + } + + /// Sign it with `key` (§6.3), which becomes its `signer`. + pub fn sign(mut self, key: &SigningKey) -> Lineup { + self.signer = key.verifying_key().to_bytes(); + self.sig = key.sign(&self.signing_input()).to_bytes(); + self + } + + /// What the signature covers (§6.3). + pub fn signing_input(&self) -> Vec { + let mut m = SIG_DOMAIN.to_vec(); + m.extend_from_slice(&self.body()); + m + } + + /// Its signature, verified strictly (§6.3). + pub fn verify_signature(&self) -> Result<(), Reason> { + crate::sig::verify_strict(&self.signer, &self.signing_input(), &self.sig).map_err(sig_reason) + } + + /// Read a lineup (§6.4): exactly these bytes, by its rules. + pub fn parse(b: &[u8]) -> Result { + read_lineup(b).ok_or(Reason::LineupMalformed) + } + + /// The asset of an asset id. + pub fn asset(&self, id: &str) -> Option<&Asset> { + self.assets.binary_search_by(|a| a.asset_id.as_bytes().cmp(id.as_bytes())).ok().map(|i| &self.assets[i]) + } + + /// Each programme's span (§6.1). + pub fn spans(&self) -> Vec<(u64, u64)> { + self.programmes.iter().enumerate().map(|(k, &s)| (s, self.programmes.get(k + 1).copied().unwrap_or(self.schedule_end_ms))).collect() + } +} + +struct Cursor<'a> { + b: &'a [u8], + p: usize, +} + +impl<'a> Cursor<'a> { + fn take(&mut self, n: usize) -> Option<&'a [u8]> { + let s = self.b.get(self.p..self.p.checked_add(n)?)?; + self.p += n; + Some(s) + } + fn u8(&mut self) -> Option { + Some(self.take(1)?[0]) + } + fn u16(&mut self) -> Option { + Some(u16::from_le_bytes(self.take(2)?.try_into().ok()?)) + } + fn u32(&mut self) -> Option { + Some(u32::from_le_bytes(self.take(4)?.try_into().ok()?)) + } + fn u64(&mut self) -> Option { + Some(u64::from_le_bytes(self.take(8)?.try_into().ok()?)) + } + fn arr(&mut self) -> Option<[u8; N]> { + self.take(N)?.try_into().ok() + } + fn short(&mut self) -> Option { + let n = self.u8()? as usize; + String::from_utf8(self.take(n)?.to_vec()).ok() + } + fn long(&mut self) -> Option { + let n = self.u16()? as usize; + String::from_utf8(self.take(n)?.to_vec()).ok() + } +} + +fn read_lineup(b: &[u8]) -> Option { + let mut c = Cursor { b, p: 0 }; + if c.take(4)? != MAGIC || c.u8()? != VERSION { + return None; + } + let channel_id = c.short()?; + let channel_name = c.short()?; + if !is_short(&channel_id) || !is_short(&channel_name) { + return None; + } + let schedule_root = c.arr::<32>()?; + let (schedule_start_ms, schedule_end_ms) = (c.u64()?, c.u64()?); + if schedule_start_ms >= schedule_end_ms || !schedule_start_ms.is_multiple_of(1000) || !schedule_end_ms.is_multiple_of(1000) || schedule_end_ms > MAX_INSTANT { + return None; + } + let p = c.u32()? as usize; + if p == 0 || p > MAX_COUNT { + return None; + } + let programmes = (0..p).map(|_| c.u64()).collect::>>()?; + if programmes[0] != schedule_start_ms + || *programmes.last()? >= schedule_end_ms + || !programmes.windows(2).all(|w| w[0] < w[1]) + || !programmes.iter().all(|s| s.is_multiple_of(1000)) + { + return None; + } + let a = c.u32()? as usize; + if a == 0 || a > MAX_COUNT { + return None; + } + let mut assets: Vec = Vec::with_capacity(a); + for _ in 0..a { + let asset_id = c.short()?; + let title = c.long()?; + let description = c.long()?; + let n = c.u8()? as usize; + if n > MAX_CATEGORIES { + return None; + } + let categories = (0..n).map(|_| c.short()).collect::>>()?; + let rating_system = c.short()?; + let rating_value = c.short()?; + let k = c.u8()? as usize; + if k > MAX_ARTWORK { + return None; + } + let artwork = (0..k).map(|_| Some((c.arr::<32>()?, c.long()?))).collect::>>()?; + let g = c.u8()?; + let n = c.u8()? as usize; + let countries = (0..n).map(|_| c.arr::<2>()).collect::>>()?; + let rights_holder = c.short()?; + let content_uri = c.long()?; + let duration_ms = c.u64()?; + let ok = is_short(&asset_id) + && !title.is_empty() + && is_long(&title) + && is_long(&description) + && categories.iter().all(|x| is_short(x)) + && (rating_system.is_empty() || is_short(&rating_system)) + && (rating_value.is_empty() || is_short(&rating_value)) + && !(rating_value.is_empty() && !rating_system.is_empty()) + && artwork.iter().all(|(_, u)| !u.is_empty() && is_long(u)) + && g <= geo::EXCEPT + && !(g == geo::EVERYWHERE && !countries.is_empty()) + && !(g == geo::EXCEPT && countries.is_empty()) + && countries.iter().all(|x| x.iter().all(u8::is_ascii_uppercase)) + && countries.windows(2).all(|w| w[0] < w[1]) + && is_short(&rights_holder) + && is_long(&content_uri) + && duration_ms <= MAX_INSTANT; + if !ok || assets.last().is_some_and(|l| l.asset_id.as_bytes() >= asset_id.as_bytes()) { + return None; + } + assets.push(Asset { asset_id, title, description, categories, rating_system, rating_value, artwork, geo: g, countries, rights_holder, content_uri, duration_ms }); + } + let signer = c.arr::<32>()?; + let sig = c.arr::<64>()?; + (c.p == b.len()).then_some(Lineup { channel_id, channel_name, schedule_root, schedule_start_ms, schedule_end_ms, programmes, assets, signer, sig }) +} + +/// Each programme's asset (§6.5 rule 4): the one asset id of the program entries that start +/// in its span; `None` when a programme holds none, or two. +pub fn programme_assets(l: &Lineup, schedule: &[ScheduleEntry]) -> Option> { + l.spans() + .iter() + .map(|&(s, e)| { + let mut ids = schedule.iter().filter(|x| x.kind == EntryKind::Program && x.start_ms >= s && x.start_ms < e).map(|x| x.asset_id.as_str()); + let id = ids.next()?; + ids.all(|o| o == id).then(|| id.to_string()) + }) + .collect() +} + +/// Whether a lineup describes a receipt's schedule (§6.5). +pub fn describes(l: &Lineup, r: &ChannelReceipt) -> bool { + let (Some(first), Some(last)) = (r.schedule.first(), r.schedule.last()) else { return false }; + if l.channel_id != r.channel_id || l.schedule_root != r.schedule_root || l.schedule_start_ms != first.start_ms || l.schedule_end_ms != last.end_ms() { + return false; + } + if !l.programmes.iter().all(|p| r.schedule.iter().any(|e| e.start_ms == *p)) { + return false; + } + let Some(ids) = programme_assets(l, &r.schedule) else { return false }; + let mut want: Vec<&str> = ids.iter().map(String::as_str).collect(); + want.sort_unstable_by(|a, b| a.as_bytes().cmp(b.as_bytes())); + want.dedup(); + let have: Vec<&str> = l.assets.iter().map(|a| a.asset_id.as_str()).collect(); + want == have +} + +// --------------------------------------------------------------------------------------- +// §7 The guide +// --------------------------------------------------------------------------------------- + +/// A programme of a guide (§7.1): its span and its asset. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Programme<'a> { + pub start_ms: u64, + pub stop_ms: u64, + pub asset: &'a Asset, +} + +/// Every programme of a lineup that describes `r`, with its asset; `None` when it does not. +pub fn programmes<'a>(r: &ChannelReceipt, l: &'a Lineup) -> Option>> { + if !describes(l, r) { + return None; + } + let ids = programme_assets(l, &r.schedule)?; + l.spans().into_iter().zip(ids).map(|((s, e), id)| Some(Programme { start_ms: s, stop_ms: e, asset: l.asset(&id)? })).collect() +} + +/// The programmes of a range (§7.1). +pub fn programmes_of<'a>(all: &[Programme<'a>], from: u64, to: u64) -> Vec> { + all.iter().filter(|p| p.start_ms < to && p.stop_ms > from).cloned().collect() +} + +fn esc_text(s: &str) -> String { + let mut o = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => o.push_str("&"), + '<' => o.push_str("<"), + '>' => o.push_str(">"), + _ => o.push(c), + } + } + o +} + +fn esc_attr(s: &str) -> String { + let mut o = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => o.push_str("&"), + '<' => o.push_str("<"), + '>' => o.push_str(">"), + '"' => o.push_str("""), + '\t' => o.push_str(" "), + '\n' => o.push_str(" "), + _ => o.push(c), + } + } + o +} + +/// The XMLTV guide of `[from, to)` (§7.2), or `None` when the lineup does not describe the +/// receipt's schedule, or the range is empty or ends past what a guide writes. +pub fn xmltv_guide(r: &ChannelReceipt, l: &Lineup, from: u64, to: u64) -> Option { + if from >= to || to > MAX_INSTANT { + return None; + } + let all = programmes(r, l)?; + let ch = esc_attr(&l.channel_id); + let mut o = String::new(); + o.push_str("\n"); + o.push_str(&format!("\n", hex(&r.schedule_root), hex(&l.digest()))); + o.push_str(&format!(" \n {}\n \n", esc_text(&l.channel_name))); + for p in programmes_of(&all, from, to) { + let a = p.asset; + o.push_str(&format!(" \n", xmltv_format(p.start_ms), xmltv_format(p.stop_ms))); + o.push_str(&format!(" {}\n", esc_text(&a.title))); + if !a.description.is_empty() { + o.push_str(&format!(" {}\n", esc_text(&a.description))); + } + for c in &a.categories { + o.push_str(&format!(" {}\n", esc_text(c))); + } + for (_, uri) in &a.artwork { + o.push_str(&format!(" \n", esc_attr(uri))); + } + o.push_str(&format!(" {}\n", esc_text(&a.asset_id))); + if !a.rating_value.is_empty() { + if a.rating_system.is_empty() { + o.push_str(" \n"); + } else { + o.push_str(&format!(" \n", esc_attr(&a.rating_system))); + } + o.push_str(&format!(" {}\n \n", esc_text(&a.rating_value))); + } + o.push_str(" \n"); + } + o.push_str("\n"); + Some(o) +} + +/// A programme as the now/next document writes it (§7.3). +fn programme_json(p: &Programme<'_>) -> Value { + let a = p.asset; + let codes = || Value::Array(a.countries.iter().map(|c| json!(String::from_utf8_lossy(c))).collect()); + json!({ + "start_ms": p.start_ms, + "stop_ms": p.stop_ms, + "asset_id": a.asset_id, + "title": a.title, + "description": a.description, + "content_uri": a.content_uri, + "categories": a.categories, + "rating": if a.rating_value.is_empty() { Value::Null } else { json!({"system": a.rating_system, "value": a.rating_value}) }, + "artwork": a.artwork.iter().map(|(h, u)| json!({"sha256": hex(h), "uri": u})).collect::>(), + "geo": match a.geo { geo::ONLY => json!({"allow": codes()}), geo::EXCEPT => json!({"deny": codes()}), _ => Value::Null }, + "rights_holder": a.rights_holder, + "duration_ms": a.duration_ms, + }) +} + +/// The programmes on air at `at` and next (§7.3). +pub fn now_and_next<'a>(all: &[Programme<'a>], at: u64) -> (Option>, Option>) { + match all.iter().position(|p| p.start_ms <= at && at < p.stop_ms) { + Some(k) => (Some(all[k].clone()), all.get(k + 1).cloned()), + None if all.first().is_some_and(|p| at < p.start_ms) => (None, Some(all[0].clone())), + None => (None, None), + } +} + +/// The now/next document of `at` (§7.3), or `None` when the lineup does not describe the +/// receipt's schedule or `at` is past what a guide writes. +pub fn now_next(r: &ChannelReceipt, l: &Lineup, at: u64) -> Option { + if at > MAX_INSTANT { + return None; + } + let all = programmes(r, l)?; + let (now, next) = now_and_next(&all, at); + let v = json!({ + "kind": NOW_NEXT_KIND, + "channel": l.channel_id, + "channel_name": l.channel_name, + "schedule_root": hex(&r.schedule_root), + "lineup": hex(&l.digest()), + "at_ms": at, + "now": now.as_ref().map_or(Value::Null, programme_json), + "next": next.as_ref().map_or(Value::Null, programme_json), + }); + let mut s = crate::jcs::jcs(&v).ok()?; + s.push('\n'); + Some(s) +} + +// --------------------------------------------------------------------------------------- +// §8 Verification +// --------------------------------------------------------------------------------------- + +/// A guide as §8.2 step 8 reads it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Guide { + /// An XMLTV guide: its sources, its range, and each programme's start, end and asset id. + Xmltv { root: String, lineup: String, from: u64, to: u64, programmes: Vec<(u64, u64, String)> }, + /// A now/next document: its sources, its instant, and `now` and `next`. + NowNext { root: String, lineup: String, at: u64, now: Option<(u64, u64, String)>, next: Option<(u64, u64, String)> }, +} + +fn plain_uint(s: &str) -> Option { + (!s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) && (s == "0" || !s.starts_with('0'))).then(|| s.parse().ok()).flatten() +} + +fn is_hex64(s: &str) -> bool { + s.len() == 64 && s.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) +} + +/// Read a guide (§8.2 step 8). +pub fn read_guide(b: &[u8]) -> Option { + match b.first()? { + b'<' => { + let root = read_feed_xml(b)?; + if root.name != "tv" { + return None; + } + let url = root.attr("source-data-url")?.strip_prefix(GUIDE_URN)?; + let parts: Vec<&str> = url.split(':').collect(); + let [r, l, f, t] = parts.as_slice() else { return None }; + let (from, to) = (plain_uint(f)?, plain_uint(t)?); + if !is_hex64(r) || !is_hex64(l) || from >= to { + return None; + } + let mut programmes = Vec::new(); + for p in root.children_named("programme") { + let start = xmltv_time(p.attr("start")?).ok()?; + let stop = xmltv_time(p.attr("stop")?).ok()?; + let id = p.children_named("episode-num").find(|e| e.attr("system").unwrap_or("onscreen") == ID_SYSTEM)?.text(); + programmes.push((start, stop, id)); + } + Some(Guide::Xmltv { root: r.to_string(), lineup: l.to_string(), from, to, programmes }) + } + b'{' => { + let j = strict_json::read(b).ok()?; + exactly(&j, &["kind", "channel", "channel_name", "schedule_root", "lineup", "at_ms", "now", "next"])?; + let at = j.get("at_ms")?.as_u64().filter(|&a| a <= MAX_INSTANT)?; + let entry = |k: &str| -> Option> { + match j.get(k)? { + Json::Null => Some(None), + o @ Json::Obj(_) => Some(Some((o.get("start_ms")?.as_u64()?, o.get("stop_ms")?.as_u64()?, o.get("asset_id")?.as_str()?.to_string()))), + _ => None, + } + }; + Some(Guide::NowNext { + root: j.get("schedule_root")?.as_str()?.to_string(), + lineup: j.get("lineup")?.as_str()?.to_string(), + at, + now: entry("now")?, + next: entry("next")?, + }) + } + _ => None, + } +} + +/// What a verification states (§8.3). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Verification { + /// `Ok` when verified, otherwise the reason. + pub verdict: Result<(), Reason>, + /// The part the verdict is about: `receipt`, `lineup` or `guide`. + pub part: Option<&'static str>, + /// The receipt's identity, when it reads. + pub receipt: Option<[u8; 32]>, + /// The lineup's digest, when it reads. + pub lineup: Option<[u8; 32]>, + /// For a guide that reads: its format, its range or instant, and its programmes, each + /// `(start, end, asset id, aired)`. + pub guide: Option, + /// For `guide_entry_unscheduled`, the index of step 10. + pub entry: Option, +} + +/// A guide's facts (§8.3). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GuideFacts { + pub format: &'static str, + pub range: Option<(u64, u64)>, + pub at: Option, + pub programmes: Vec<(u64, u64, String, bool)>, +} + +/// Verify a receipt, a lineup and optionally a guide (§8.2). +pub fn verify(receipt: &[u8], lineup: &[u8], guide: Option<&[u8]>) -> Verification { + let mut v = Verification { verdict: Ok(()), part: None, receipt: None, lineup: None, guide: None, entry: None }; + let fail = |mut v: Verification, part: &'static str, r: Reason| { + v.verdict = Err(r); + v.part = Some(part); + v + }; + let Some(r) = receipt_from_json(receipt) else { return fail(v, "receipt", Reason::ChannelReceiptMalformed) }; + v.receipt = Some(r.receipt_hash()); + if let Err(e) = receipt_verdict(&r) { + return fail(v, "receipt", e); + } + let l = match Lineup::parse(lineup) { + Ok(l) => l, + Err(e) => return fail(v, "lineup", e), + }; + v.lineup = Some(l.digest()); + if let Err(e) = l.verify_signature() { + return fail(v, "lineup", e); + } + if l.signer != r.signer_pubkey { + return fail(v, "lineup", Reason::LineupSignerMismatch); + } + if !describes(&l, &r) { + return fail(v, "lineup", Reason::LineupScheduleMismatch); + } + let Some(g) = guide else { return v }; + let Some(read) = read_guide(g) else { return fail(v, "guide", Reason::GuideMalformed) }; + let aired = |s: u64, e: u64| r.aired.iter().filter_map(|&i| r.schedule.get(i as usize)).any(|x| x.start_ms < e && x.end_ms() > s); + let all = programmes(&r, &l).expect("the lineup describes the schedule"); + let key = |p: &Programme<'_>| (p.start_ms, p.stop_ms, p.asset.asset_id.clone()); + let (root, ldig) = (hex(&r.schedule_root), hex(&l.digest())); + match &read { + Guide::Xmltv { root: gr, lineup: gl, from, to, programmes: ps } => { + v.guide = Some(GuideFacts { + format: "xmltv", + range: Some((*from, *to)), + at: None, + programmes: ps.iter().map(|(s, e, id)| (*s, *e, id.clone(), aired(*s, *e))).collect(), + }); + if *gr != root || *gl != ldig { + return fail(v, "guide", Reason::GuideSourceMismatch); + } + let want: Vec<(u64, u64, String)> = programmes_of(&all, *from, *to).iter().map(key).collect(); + if let Some(i) = ps.iter().position(|p| !want.contains(p)) { + v.entry = Some(i); + return fail(v, "guide", Reason::GuideEntryUnscheduled); + } + if xmltv_guide(&r, &l, *from, *to).as_deref().map(str::as_bytes) != Some(g) { + return fail(v, "guide", Reason::GuideMismatch); + } + } + Guide::NowNext { root: gr, lineup: gl, at, now, next } => { + v.guide = Some(GuideFacts { + format: "now-next", + range: None, + at: Some(*at), + programmes: [now, next].into_iter().flatten().map(|(s, e, id)| (*s, *e, id.clone(), aired(*s, *e))).collect(), + }); + if *gr != root || *gl != ldig { + return fail(v, "guide", Reason::GuideSourceMismatch); + } + let (wn, wx) = now_and_next(&all, *at); + for (i, (got, want)) in [(now, wn.as_ref().map(key)), (next, wx.as_ref().map(key))].into_iter().enumerate() { + if *got != want { + v.entry = Some(i); + return fail(v, "guide", Reason::GuideEntryUnscheduled); + } + } + if now_next(&r, &l, *at).as_deref().map(str::as_bytes) != Some(g) { + return fail(v, "guide", Reason::GuideMismatch); + } + } + } + v +} + +/// A verification as JSON (§8.3; the corpus's `facts`). +pub fn verification_json(v: &Verification) -> Value { + json!({ + "verdict": v.verdict.err().map_or("verified", Reason::code), + "part": v.part, + "receipt": v.receipt.map(|h| hex(&h)), + "lineup": v.lineup.map(|h| hex(&h)), + "guide": v.guide.as_ref().map(|g| json!({ + "format": g.format, + "from_ms": g.range.map(|r| r.0), + "to_ms": g.range.map(|r| r.1), + "at_ms": g.at, + "programmes": g.programmes.iter().map(|(s, e, id, a)| json!({"start_ms": s, "stop_ms": e, "asset_id": id, "aired": a})).collect::>(), + })), + "entry": v.entry, + }) +} + +#[cfg(test)] +mod tests; diff --git a/wai/wai-rs/src/channel_lineup/corpus.rs b/wai/wai-rs/src/channel_lineup/corpus.rs new file mode 100644 index 000000000..ec378e1a0 --- /dev/null +++ b/wai/wai-rs/src/channel_lineup/corpus.rs @@ -0,0 +1,658 @@ +//! 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: no encryption, `pts_adjustment` 0, `tier` +/// 0xFFF, no descriptor, and its CRC_32. +fn section(command_type: u8, command: &[u8]) -> Vec { + let mut body = vec![0x00, 0x00, 0x00, 0x00, 0x00, 0xFF]; + let len = command.len() as u32; + body.extend_from_slice(&[0xFF, (0xF0 | (len >> 8) as u8 & 0x0F), len as u8, command_type]); + body.extend_from_slice(command); + body.extend_from_slice(&[0x00, 0x00]); + let section_length = (body.len() + 4) as u16; + let mut s = vec![0xFC, 0x30 | (section_length >> 8) as u8 & 0x0F, section_length as u8]; + s.extend_from_slice(&body); + let crc = crc32_mpeg2(&s); + s.extend_from_slice(&crc.to_be_bytes()); + s +} + +fn pts(flag_bits: u8, v: u64) -> [u8; 5] { + [flag_bits | ((v >> 32) as u8 & 1), (v >> 24) as u8, (v >> 16) as u8, (v >> 8) as u8, v as u8] +} + +/// A `splice_insert()` (SCTE 35-1 §9.7.3): out of the network with a break duration, or back in. +fn splice_insert(event_id: u32, out: bool, duration_ms: u64, at_ms: u64) -> Vec { + let mut c = event_id.to_be_bytes().to_vec(); + c.push(0x7F); + c.push(if out { 0xE0 } else { 0x40 } | 0x08 | 0x07); + c.extend_from_slice(&pts(0xFE, at_ms * 90 % (1 << 33))); + if out { + c.extend_from_slice(&pts(0xFE, duration_ms * 90)); + } + c.extend_from_slice(&[0x00, 0x01, 0x00, 0x00]); + section(0x05, &c) +} + +/// A `time_signal()` (SCTE 35-1 §9.7.4). +fn time_signal(at_ms: u64) -> Vec { + section(0x06, &pts(0xFE, at_ms * 90 % (1 << 33))) +} + +fn hexu(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02X}")).collect() +} + +// --------------------------------------------------------------------------------------- +// The sample channel +// --------------------------------------------------------------------------------------- + +fn art(name: &str) -> String { + format!("https://art.example/sample/{name}?w=320") +} + +fn svg(label: &str, fill: &str) -> String { + format!( + "{label}\n" + ) +} + +/// The artwork files. +fn artwork() -> Vec<(String, String)> { + vec![ + ("art-hl-101.svg".into(), svg("HL 101", "#1d4e89")), + ("art-hl-102.svg".into(), svg("HL 102", "#1d4e89")), + ("art-nf-1.svg".into(), svg("NF 1", "#3b3b3b")), + ("art-tk-7.svg".into(), svg("TK 7", "#2f7d4a")), + ] +} + +fn xmltv() -> String { + format!( + r#" + + + + Sample One + + + Sample Two + + + Harbour Lights + A lighthouse keeper's first winter. + Drama + + 0.0. + hl-101 + + + Tidepool Kitchen + tk-7 + + + Harbour Lights + hl-102 + + + Night Freight + Overnight on the coast line & the yards it feeds. + Documentary + + nf-1 + + TV-14 + + + + Another Channel's Hour + other-1 + + +"#, + a1 = art("art-hl-101.svg").replace('&', "&"), + a4 = art("art-nf-1.svg").replace('&', "&"), + ) +} + +fn mrss_a() -> String { + format!( + r#" + + + Owner A titles + https://owner-a.example/ + Owner A's catalogue for linear channels. + + Harbour Lights + hl-101 + + Harbour Lights + A lighthouse keeper's first winter. + Drama + + tv-pg + us ca gb + + + hl-102 + + + + + Harbour Lights + The supply boat is late. + Drama + + tv-pg + de + + + hl-103 + + Harbour Lights + + + +"#, + a1 = art("art-hl-101.svg").replace('&', "&"), + a2 = art("art-hl-102.svg").replace('&', "&"), + ) +} + +fn mrss_b() -> String { + format!( + r#" + + + Owner B kitchen + + tk-7 + + Tidepool Kitchen + Kelp, crab & lemon, on a beach stove. + Food + Family + + none + + + +"#, + a3 = art("art-tk-7.svg").replace('&', "&"), + ) +} + +/// The breaks: id, start, duration, the out section, the in section. +fn breaks() -> Vec<(&'static str, u64, u64, Vec)> { + vec![ + ("brk-1", T0 + 12 * MIN, 90_000, splice_insert(1001, true, 90_000, 12 * MIN)), + ("brk-2", T0 + 28 * MIN, 120_000, time_signal(28 * MIN)), + ("brk-3", T0 + 45 * MIN + 30_500, 60_000, splice_insert(1003, true, 60_000, 45 * MIN + 30_500)), + ("brk-4", T0 + 74 * MIN, 90_000, splice_insert(1004, true, 90_000, 74 * MIN)), + ] +} + +fn playlist() -> String { + let b = breaks(); + let date = |ms: u64| { + let s = super::xmltv_format(ms); + format!("{}-{}-{}T{}:{}:{}.{:03}Z", &s[0..4], &s[4..6], &s[6..8], &s[8..10], &s[10..12], &s[12..14], ms % 1000) + }; + let mut o = String::from("#EXTM3U\n#EXT-X-VERSION:6\n#EXT-X-TARGETDURATION:6\n#EXT-X-MEDIA-SEQUENCE:120\n"); + o.push_str(&format!("#EXT-X-PROGRAM-DATE-TIME:{}\n#EXTINF:6.000,\nseg-120.ts\n", date(T0 + 12 * MIN - 6000))); + o.push_str(&format!("#EXT-X-DATERANGE:ID=\"brk-1\",START-DATE=\"{}\",PLANNED-DURATION=90.000,SCTE35-OUT=0x{}\n", date(b[0].1), hexu(&b[0].3))); + o.push_str("#EXTINF:6.000,\nseg-121.ts\n"); + o.push_str(&format!( + "#EXT-X-DATERANGE:ID=\"brk-1\",START-DATE=\"{}\",DURATION=90.000,SCTE35-IN=0x{}\n", + date(b[0].1), + hexu(&splice_insert(1001, false, 0, 12 * MIN + 90_000)) + )); + o.push_str(&format!("#EXT-X-DATERANGE:ID=\"chapter-2\",CLASS=\"sample.chapter\",START-DATE=\"{}\"\n", date(T0 + 20 * MIN))); + o.push_str(&format!("#EXT-X-DATERANGE:ID=\"brk-2\",START-DATE=\"{}\",DURATION=120.000,SCTE35-OUT=0x{}\n", date(b[1].1), hexu(&b[1].3))); + o.push_str(&format!("#EXT-X-DATERANGE:ID=\"brk-3\",START-DATE=\"{}\",PLANNED-DURATION=60,SCTE35-OUT=0x{}\n", date(b[2].1), hexu(&b[2].3).to_lowercase())); + o.push_str(&format!( + "#EXT-X-DATERANGE:ID=\"brk-4\",START-DATE=\"2026-10-29T20:14:00+01:00\",END-DATE=\"{}\",SCTE35-OUT=0X{}\n", + date(b[3].1 + b[3].2), + hexu(&b[3].3) + )); + o.push_str("#EXTINF:6.000,\nseg-122.ts\n"); + o +} + +/// The JSON form of the same channel: what the XMLTV document, the Media RSS documents and +/// the playlist say, in one document, with the artwork's hashes. +fn json_form(l: &Lineup) -> String { + let progs = [(0, 30, "hl-101"), (30, 60, "tk-7"), (60, 90, "hl-102"), (90, 120, "nf-1")]; + let assets: Vec = l + .assets + .iter() + .map(|a| { + json!({ + "asset_id": a.asset_id, + "title": a.title, + "description": a.description, + "content_uri": a.content_uri, + "categories": a.categories, + "rating": if a.rating_value.is_empty() { Value::Null } else { json!({"system": a.rating_system, "value": a.rating_value}) }, + "artwork": a.artwork.iter().map(|(h, u)| json!({"uri": u, "sha256": hex(h)})).collect::>(), + "geo": match a.geo { + super::geo::ONLY => json!({"allow": a.countries.iter().map(|c| String::from_utf8_lossy(c).into_owned()).collect::>()}), + super::geo::EXCEPT => json!({"deny": a.countries.iter().map(|c| String::from_utf8_lossy(c).into_owned()).collect::>()}), + _ => Value::Null, + }, + "rights_holder": a.rights_holder, + "duration_ms": a.duration_ms, + }) + }) + .collect(); + let doc = json!({ + "kind": super::FEED_KIND, + "channel_name": l.channel_name, + "programmes": progs.iter().map(|(s, e, id)| json!({"start_ms": T0 + s * MIN, "stop_ms": T0 + e * MIN, "asset_id": id})).collect::>(), + "assets": assets, + "breaks": breaks().iter().map(|(id, s, d, sec)| json!({"id": id, "start_ms": s, "duration_ms": d, "scte35": hex(sec)})).collect::>(), + }); + serde_json::to_string_pretty(&doc).expect("JSON") + "\n" +} + +// --------------------------------------------------------------------------------------- +// Inputs, edits and outcomes +// --------------------------------------------------------------------------------------- + +/// Apply a vector's edits to a copy of the files it reads: each `{"file", "find", "replace"}` +/// replaces the one occurrence of `find`, and each `{"file", "pad"}` appends that many spaces. +pub fn apply_edits(files: &BTreeMap>, edits: &Value) -> Result>, String> { + let mut out = files.clone(); + for e in edits.as_array().ok_or("edits: an array")? { + let f = e["file"].as_str().ok_or("an edit's file")?; + let b = out.get_mut(f).ok_or_else(|| format!("{f}: not in the corpus"))?; + if let Some(n) = e["pad"].as_u64() { + b.extend(std::iter::repeat_n(b' ', n as usize)); + continue; + } + let (find, replace) = (e["find"].as_str().ok_or("find")?, e["replace"].as_str().ok_or("replace")?); + let s = String::from_utf8(b.clone()).map_err(|_| format!("{f}: not UTF-8"))?; + if s.matches(find).count() != 1 { + return Err(format!("{f}: {find:?} does not occur once")); + } + *b = s.replacen(find, replace, 1).into_bytes(); + } + Ok(out) +} + +fn opt_str(v: &Value) -> Option { + v.as_str().map(str::to_string) +} + +/// The feeds a vector's inputs name, from the files given. +pub fn feeds_of(inputs: &Value, files: &BTreeMap>) -> Result { + let file = |v: &Value| -> Result>, String> { + match v.as_str() { + None => Ok(None), + Some(n) => files.get(n).cloned().map(Some).ok_or_else(|| format!("{n}: not in the corpus")), + } + }; + let mut mrss = Vec::new(); + for m in inputs["mrss"].as_array().ok_or("mrss: an array")? { + mrss.push((file(&m["file"])?.ok_or("an mrss file")?, opt_str(&m["owner"]))); + } + let mut artwork = BTreeMap::new(); + for n in inputs["artwork"].as_array().ok_or("artwork: an array")? { + let n = n.as_str().ok_or("an artwork name")?; + artwork.insert(n.to_string(), files.get(n).cloned().ok_or_else(|| format!("{n}: not in the corpus"))?); + } + let window = match &inputs["window"] { + Value::Array(w) if w.len() == 2 => Some((w[0].as_u64().ok_or("window")?, w[1].as_u64().ok_or("window")?)), + Value::Null => None, + _ => return Err("window: null or two instants".into()), + }; + Ok(Feeds { + channel: inputs["channel"].as_str().ok_or("channel")?.to_string(), + xmltv: file(&inputs["xmltv"])?, + mrss, + json: file(&inputs["json"])?, + playlist: file(&inputs["playlist"])?, + owner: opt_str(&inputs["owner"]), + id_system: opt_str(&inputs["id_system"]), + xmltv_channel: opt_str(&inputs["xmltv_channel"]), + start_ms: inputs["start_ms"].as_u64(), + artwork, + window, + }) +} + +/// Run a vector's import over the files given. +pub fn run_import(v: &Value, files: &BTreeMap>) -> Result, String> { + let files = apply_edits(files, &v["edits"])?; + let inputs = &v["inputs"]; + let feeds = feeds_of(inputs, &files)?; + let parent = match inputs["parent_receipt_hash"].as_str() { + None => None, + Some(h) => Some(super::unhex::<32>(h).ok_or("parent_receipt_hash")?), + }; + Ok(import(&feeds, &operator(), inputs["signer_id"].as_str().ok_or("signer_id")?, parent)) +} + +fn cues_json(i: &Imported) -> Value { + super::import::cues_json(&i.cues) +} + +fn inputs(over: Value) -> Value { + let mut base = json!({ + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [{"file": "sample-a.mrss.xml", "owner": "owner-a"}, {"file": "sample-b.mrss.xml", "owner": "owner-b"}], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": artwork().iter().map(|(n, _)| n.clone()).collect::>(), + "window": [T0, T0 + 60 * MIN], + "signer_id": "sample-operator", + "parent_receipt_hash": null, + }); + for (k, v) in over.as_object().expect("an object") { + base[k] = v.clone(); + } + base +} + +fn edit(file: &str, find: &str, replace: &str) -> Value { + json!({"file": file, "find": find, "replace": replace}) +} + +/// Each import vector: its name, what it shows, its inputs, its edits, and the refusal it was +/// written for (none for an import that yields). +fn import_vectors() -> Vec<(&'static str, &'static str, Value, Value, Option)> { + 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), + ("inputs_invalid", "an XMLTV document beside the JSON form", inputs(json!({"json": j})), json!([]), Some(ImportInputsInvalid)), + ("oversized", "an XMLTV document of more than 16 777 216 bytes", inputs(json!({})), json!([{"file": x, "pad": 16_777_216}]), Some(ImportOversized)), + ("xml_malformed", "a document type declaration that defines an entity", inputs(json!({})), json!([edit(x, "", "]>")]), Some(ImportXmlMalformed)), + ("json_malformed", "the JSON form naming a member twice", inputs(json!({"xmltv": null, "mrss": [], "json": j, "playlist": null, "owner": null, "artwork": []})), json!([edit(j, "\"kind\": \"wai-channel-feed\",", "\"kind\": \"wai-channel-feed\",\n \"kind\": \"wai-channel-feed\",")]), Some(ImportJsonMalformed)), + ("playlist_malformed", "two tags of one date range that give it two starts", inputs(json!({})), json!([edit(p, "START-DATE=\"2026-10-29T18:12:00.000Z\",DURATION", "START-DATE=\"2026-10-29T18:12:01.000Z\",DURATION")]), Some(ImportPlaylistMalformed)), + ("time_malformed", "a programme starting in a thirteenth month", inputs(json!({})), json!([edit(x, "start=\"20261029183000 +0000\"", "start=\"20261329183000 +0000\"")]), Some(ImportTimeMalformed)), + ("timezone_unknown", "a programme's time in a zone named by letters", inputs(json!({})), json!([edit(x, "start=\"20261029183000 +0000\"", "start=\"20261029193000 BST\"")]), Some(ImportTimezoneUnknown)), + ("no_stop", "a programme with no stop", inputs(json!({})), json!([edit(x, " stop=\"20261029190000 +0000\" channel=\"sample-1\"", " channel=\"sample-1\"")]), Some(ImportNoStop)), + ("empty", "an XMLTV channel id the document has no programme of", inputs(json!({"xmltv_channel": "sample-9"})), json!([]), Some(ImportEmpty)), + ("asset_unknown", "a programme whose asset is named only in another episode-num system", inputs(json!({})), json!([edit(x, "tk-7", "tk-7")]), Some(ImportAssetUnknown)), + ("asset_duplicate", "two catalogue items with one guid", inputs(json!({})), json!([edit(a, "hl-103", "hl-101")]), Some(ImportAssetDuplicate)), + ("field_invalid", "a restriction whose relationship is neither allow nor deny", inputs(json!({})), json!([edit(b, "relationship=\"deny\"", "relationship=\"maybe\"")]), Some(ImportFieldInvalid)), + ("scte35_malformed", "a section whose section_length is not its length", inputs(json!({})), json!([edit(p, "DURATION=120.000,SCTE35-OUT=0x", "DURATION=120.000,SCTE35-OUT=0xFC3001,X-WAS=0x")]), Some(ImportScte35Malformed)), + ("break_duration", "a break of no duration", inputs(json!({})), json!([edit(p, "DURATION=120.000", "DURATION=0.000")]), Some(ImportBreakDuration)), + ("duration_unknown", "a Media RSS item with no duration, in a channel of Media RSS items alone", loop_inputs(), json!([edit(a, "", "")]), Some(ImportDurationUnknown)), + ("gap", "a minute of dead air before the last programme", inputs(json!({})), json!([edit(x, "start=\"20261029193000\"", "start=\"20261029193100\"")]), Some(ImportGap)), + ("overlap", "a programme that ends after the next begins", inputs(json!({})), json!([edit(x, "stop=\"20261029203000 +0100\"", "stop=\"20261029203100 +0100\"")]), Some(ImportOverlap)), + ("owner_unknown", "an asset of the XMLTV document alone, and no owner given", inputs(json!({"owner": null})), json!([]), Some(ImportOwnerUnknown)), + ("artwork_missing", "an icon whose file the artwork set does not hold", inputs(json!({"artwork": ["art-hl-101.svg", "art-hl-102.svg", "art-tk-7.svg"]})), json!([]), Some(ImportArtworkMissing)), + ("break_outside", "a break that runs past the end of its programme", inputs(json!({})), json!([edit(p, "ID=\"brk-2\",START-DATE=\"2026-10-29T18:28:00.000Z\"", "ID=\"brk-2\",START-DATE=\"2026-10-29T18:29:00.000Z\"")]), Some(ImportBreakOutside)), + ("window_outside", "a window that begins before the schedule", inputs(json!({"window": [T0 - 60 * MIN, T0 + 60 * MIN]})), json!([]), Some(ImportWindowOutside)), + ] +} + +/// A lineup's hexadecimal bytes. +fn lineup_hex(l: &Lineup) -> String { + hex(&l.to_bytes()) +} + +/// Each verification vector: its name, what it shows, the receipt file, the lineup's bytes, the +/// guide file, its edits. +fn verification_vectors(sample: &Imported) -> Vec<(&'static str, &'static str, &'static str, String, Option<&'static str>, Value)> { + 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")); + let mut moved = l.clone(); + moved.programmes[1] += MIN; + let moved = moved.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_schedule_mismatch", "a lineup, signed, whose second programme begins a minute late, where no entry does", rc, lineup_hex(&moved), None, json!([])), + ("guide_malformed", "a guide cut short", rc, good.clone(), Some(g), json!([edit(g, "\n", "")])), + ("guide_source_mismatch", "a guide naming another schedule", rc, good.clone(), Some(g), json!([edit(g, &urn_root, &"0".repeat(64))])), + ("guide_entry_unscheduled", "a guide that places Night Freight where the schedule places Harbour Lights", rc, good.clone(), Some(g), json!([edit(g, ">hl-102", ">nf-1")])), + ("guide_mismatch", "a guide whose title the lineup does not state", rc, good.clone(), Some(g), json!([edit(g, "Night Freight", "Night Freight Live")])), + ("now_next_unscheduled", "a now/next document naming another programme on air", rc, good, Some(nn), json!([edit(nn, "\"asset_id\":\"tk-7\"", "\"asset_id\":\"hl-101\"")])), + ] +} + +/// The files of a verification vector, with its edits applied, and its facts. +pub fn run_verification(v: &Value, files: &BTreeMap>) -> Result { + let files = apply_edits(files, &v["edits"])?; + let receipt = files.get(v["receipt"].as_str().ok_or("receipt")?).ok_or("the receipt file")?; + let lineup = super::hex_bytes(v["lineup"].as_str().ok_or("lineup")?).ok_or("the lineup's hexadecimal")?; + let guide = match v["guide"].as_str() { + None => None, + Some(g) => Some(files.get(g).ok_or("the guide file")?.as_slice()), + }; + Ok(verification_json(&verify_all(receipt, &lineup, guide))) +} + +// --------------------------------------------------------------------------------------- +// generate, verify +// --------------------------------------------------------------------------------------- + +fn bytes_of(files: &[(String, String)]) -> BTreeMap> { + files.iter().map(|(n, s)| (n.clone(), s.clone().into_bytes())).collect() +} + +/// Every file of the corpus but its README, by name, from the fixed seeds. +pub fn generate() -> Vec<(String, String)> { + let mut files: Vec<(String, String)> = artwork(); + files.push(("sample.xmltv.xml".into(), xmltv())); + files.push(("sample-a.mrss.xml".into(), mrss_a())); + files.push(("sample-b.mrss.xml".into(), mrss_b())); + files.push(("sample.m3u8".into(), playlist())); + + let ivs = import_vectors(); + let sample = run_import(&json!({"inputs": ivs[0].2, "edits": []}), &bytes_of(&files)).expect("the sample's files").expect("the sample imports"); + files.push(("sample.json".into(), json_form(&sample.lineup))); + files.push(("sample.channel.json".into(), receipt_text(&sample.receipt))); + let (r, l) = (&sample.receipt, &sample.lineup); + let (start, end) = (l.schedule_start_ms, l.schedule_end_ms); + files.push(("sample.guide.xml".into(), xmltv_guide(r, l, start, end).expect("a guide"))); + files.push(("sample.part.guide.xml".into(), xmltv_guide(r, l, T0 + 45 * MIN, T0 + 75 * MIN).expect("a guide"))); + files.push(("sample.now-next.json".into(), now_next(r, l, T0 + 45 * MIN + 45_000).expect("a document"))); + files.push(("sample.before.now-next.json".into(), now_next(r, l, T0 - 60 * MIN).expect("a document"))); + files.push(("sample.after.now-next.json".into(), now_next(r, l, end + 60 * MIN).expect("a document"))); + let mut misreported = r.clone(); + misreported.aired = vec![0]; + misreported.sig = operator().sign(&misreported.signing_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 = !name.contains("malformed") && !name.contains("mismatch") && !name.contains("bad_") && !name.contains("invalid") && !name.contains("unscheduled"); + assert_eq!(verdict == "verified", expect_verified, "{name}: {verdict}"); + if let Some(r) = Reason::from_code(&verdict) { + seen.insert(r.code()); + } + v["facts"] = facts; + verifications.push(v); + } + for r in Reason::ADDED.iter().chain(Reason::REUSED) { + assert!(seen.contains(r.code()), "the corpus gives no {r}"); + } + + let doc = json!({ + "about": "extensions/channel-lineup.md §11: a sample channel's feeds (XMLTV, two owners' Media RSS, a Media Playlist with SCTE-35 date ranges, and the JSON form), each import's inputs, the edit that makes it a control and its outcome; each verification of a receipt, a lineup and a guide, and what it states", + "operator": {"key": hex(&operator().verifying_key().to_bytes()), "seed": hex(&operator().to_bytes())}, + "imports": imports, + "verifications": verifications, + }); + files.push(("vectors.json".into(), serde_json::to_string_pretty(&doc).expect("JSON") + "\n")); + files.sort(); + files +} + +/// Check committed files: every import gets its outcome and every verification its facts. +/// Each failure is one line. +pub fn verify(files: &BTreeMap) -> Vec { + let mut fails = Vec::new(); + let Some(doc) = files.get("vectors.json").and_then(|s| serde_json::from_str::(s).ok()) else { + return vec!["vectors.json: missing or not JSON".into()]; + }; + let held: BTreeMap> = files.iter().map(|(n, s)| (n.clone(), s.clone().into_bytes())).collect(); + for v in doc["imports"].as_array().into_iter().flatten() { + let name = v["name"].as_str().unwrap_or("?"); + let got = match run_import(v, &held) { + Ok(g) => g, + Err(e) => { + fails.push(format!("{name}: {e}")); + continue; + } + }; + let o = &v["outcome"]; + match got { + Err(r) => { + if o["refused"] != json!(r.code()) { + fails.push(format!("{name}: refused {r}, the corpus states {o}")); + } + } + Ok(i) => { + let receipt = o["receipt"].as_str().and_then(|f| files.get(f)); + if receipt != Some(&receipt_text(&i.receipt)) { + fails.push(format!("{name}: the receipt differs from {}", o["receipt"])); + } + if o["lineup"] != json!(lineup_hex(&i.lineup)) || o["lineup_digest"] != json!(hex(&i.lineup.digest())) || o["receipt_hash"] != json!(hex(&i.receipt.receipt_hash())) { + fails.push(format!("{name}: the lineup or the receipt hash differs")); + } + if o["cues"] != cues_json(&i) { + fails.push(format!("{name}: the cues differ")); + } + } + } + } + for v in doc["verifications"].as_array().into_iter().flatten() { + let name = v["name"].as_str().unwrap_or("?"); + match run_verification(v, &held) { + Ok(f) if f == v["facts"] => {} + Ok(f) => fails.push(format!("{name}: states {f}, the corpus {}", v["facts"])), + Err(e) => fails.push(format!("{name}: {e}")), + } + } + fails +} diff --git a/wai/wai-rs/src/channel_lineup/import.rs b/wai/wai-rs/src/channel_lineup/import.rs new file mode 100644 index 000000000..6a433dd9a --- /dev/null +++ b/wai/wai-rs/src/channel_lineup/import.rs @@ -0,0 +1,769 @@ +//! The import of extensions/channel-lineup.md §4: an XMLTV schedule, Media RSS catalogues, the +//! JSON form and a Media Playlist's SCTE-35 date ranges, into a `wai.video.channel` receipt, a +//! lineup and the breaks' cues. Every step refuses with the reason its section names, in the +//! order of §4.10. + +use std::collections::{BTreeMap, HashMap, HashSet}; + +use ed25519_dalek::SigningKey; +use sha2::{Digest, Sha256}; + +use super::{ + duration_ms, first, geo, hls_date_time, is_long, is_short, read_feed_xml, xmltv_time, Asset, Lineup, Reason, FEED_KIND, ID_SYSTEM, + MAX_ARTWORK, MAX_CATEGORIES, MAX_COUNT, MAX_DOCUMENT, MAX_INSTANT, +}; +use crate::quantum_ops::GrantRef; +use crate::stitched_presentation::markers::{attribute_list, Element, Value as Attr}; +use crate::strict_json::{self, Json}; +use crate::wai_video_channel::{ChannelReceipt, EntryKind, ScheduleEntry}; + +/// The cue id domain (§4.8). +const CUE_ID_DOMAIN: &[u8] = b"wai:channel-cue-id\x01"; + +/// An import's inputs (§4.1), but the operator's key and the receipt's signer id and parent. +#[derive(Clone, Debug, Default)] +pub struct Feeds { + pub channel: String, + pub xmltv: Option>, + /// Each Media RSS document, with its owner when one is given. + pub mrss: Vec<(Vec, Option)>, + pub json: Option>, + pub playlist: Option>, + pub owner: Option, + pub id_system: Option, + pub xmltv_channel: Option, + pub start_ms: Option, + /// The artwork set: each file's bytes by its name. + pub artwork: BTreeMap>, + pub window: Option<(u64, u64)>, +} + +/// A break's cue (§4.8): its break's id and start, and the live-events §4.3 `cue` payload. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Cue { + pub break_id: String, + pub start_ms: u64, + pub payload: Vec, +} + +/// The cues as `cues.json` holds them: each break's id, its start and its payload in +/// hexadecimal. +pub fn cues_json(cues: &[Cue]) -> serde_json::Value { + serde_json::Value::Array(cues.iter().map(|c| serde_json::json!({"break": c.break_id, "start_ms": c.start_ms, "payload": super::hex(&c.payload)})).collect()) +} + +/// What an import yields (§4.9). +#[derive(Clone, Debug, PartialEq)] +pub struct Imported { + pub receipt: ChannelReceipt, + pub lineup: Lineup, + pub cues: Vec, +} + +type R = Result; + +/// An asset's artwork as a feed gives it: by URI (XMLTV, Media RSS) or with its hash (the +/// JSON form). +#[derive(Clone, Debug)] +enum Art { + Uris(Vec), + Hashed(Vec<([u8; 32], String)>), +} + +/// An asset's description as a feed gives it (§4.6 step 1). +#[derive(Clone, Debug)] +struct Desc { + title: String, + description: String, + categories: Vec, + rating: (String, String), + art: Art, + geo: (u8, Vec<[u8; 2]>), + content_uri: String, + duration_ms: u64, +} + +/// A programme: its span and its asset id. +#[derive(Clone, Debug)] +struct Prog { + start: u64, + stop: u64, + asset: String, +} + +/// A Media RSS item (§4.3). +#[derive(Clone, Debug)] +struct Item { + asset: String, + desc: Desc, + duration: Option, + owner: Option, +} + +/// A break (§4.5). +#[derive(Clone, Debug)] +struct Break { + id: String, + start: u64, + duration: u64, + section: Option>, +} + +fn field(ok: bool) -> R<()> { + if ok { Ok(()) } else { Err(Reason::ImportFieldInvalid) } +} + +fn sized(b: &[u8]) -> R<()> { + if b.len() > MAX_DOCUMENT { Err(Reason::ImportOversized) } else { Ok(()) } +} + +// --------------------------------------------------------------------------------------- +// §4.1 Inputs +// --------------------------------------------------------------------------------------- + +fn check_inputs(f: &Feeds, signer_id: &str) -> R<()> { + let bad = Err(Reason::ImportInputsInvalid); + let set = if f.json.is_some() { f.xmltv.is_none() && f.mrss.is_empty() } else { f.xmltv.is_some() || !f.mrss.is_empty() }; + let owners = f.mrss.iter().all(|(_, o)| o.is_some()) || f.mrss.iter().all(|(_, o)| o.is_none()); + let short_opt = |o: &Option| o.as_deref().is_none_or(is_short); + let alone = f.json.is_none() && f.xmltv.is_none() && !f.mrss.is_empty(); + if !is_short(&f.channel) + || !set + || !owners + || !f.mrss.iter().all(|(_, o)| short_opt(o)) + || !short_opt(&f.owner) + || !short_opt(&f.id_system) + || !short_opt(&f.xmltv_channel) + || !is_short(signer_id) + || (alone && f.start_ms.is_none()) + || f.start_ms.is_some_and(|s| s > MAX_INSTANT) + { + return bad; + } + Ok(()) +} + +// --------------------------------------------------------------------------------------- +// §4.2 XMLTV, §4.3 Media RSS +// --------------------------------------------------------------------------------------- + +/// The categories, artwork URIs and rating of an XMLTV programme or a Media RSS item (§4.2 +/// step 4, §4.3 step 3): `art` the child that names an artwork and `attr` its URI attribute, +/// `scheme` the rating's system attribute and its default. +fn description(e: &Element, desc_name: &str, art: (&str, &str), scheme: (&str, &str)) -> R { + let title = first(e, "title").map(Element::text).unwrap_or_default(); + field(!title.is_empty() && is_long(&title))?; + let description = first(e, desc_name).map(Element::text).unwrap_or_default(); + field(is_long(&description))?; + let categories: Vec = e.children_named("category").map(Element::text).collect(); + field(categories.len() <= MAX_CATEGORIES && categories.iter().all(|c| is_short(c)))?; + let uris: Vec> = e.children_named(art.0).map(|i| i.attr(art.1)).collect(); + field(uris.len() <= MAX_ARTWORK && uris.iter().all(|u| u.is_some_and(|u| !u.is_empty() && is_long(u))))?; + let rating = match first(e, "rating") { + None => (String::new(), String::new()), + Some(r) => { + let system = r.attr(scheme.0).unwrap_or(scheme.1).to_string(); + field(system.is_empty() || is_short(&system))?; + let value = if desc_name == "desc" { first(r, "value").map(Element::text).unwrap_or_default() } else { r.text() }; + field(is_short(&value))?; + (system, value) + } + }; + Ok(Desc { + title, + description, + categories, + rating, + art: Art::Uris(uris.into_iter().map(|u| u.expect("checked").to_string()).collect()), + geo: (geo::EVERYWHERE, Vec::new()), + content_uri: String::new(), + duration_ms: 0, + }) +} + +/// §4.2: the channel name, the programmes and each one's description. +fn read_xmltv(b: &[u8], xch: &str, id_system: &str) -> R<(Option, Vec<(Prog, Desc)>)> { + sized(b)?; + let root = read_feed_xml(b).ok_or(Reason::ImportXmlMalformed)?; + if root.name != "tv" { + return Err(Reason::ImportXmlMalformed); + } + let progs: Vec<&Element> = root.children_named("programme").filter(|p| p.attr("channel") == Some(xch)).collect(); + if progs.len() > MAX_COUNT { + return Err(Reason::ImportOversized); + } + let name = root.children_named("channel").find(|c| c.attr("id") == Some(xch)).and_then(|c| first(c, "display-name")).map(Element::text).filter(|n| !n.is_empty()); + if let Some(n) = &name { + field(is_short(n))?; + } + if progs.is_empty() { + return Err(Reason::ImportEmpty); + } + let mut out = Vec::with_capacity(progs.len()); + for p in progs { + let start = xmltv_time(p.attr("start").ok_or(Reason::ImportTimeMalformed)?)?; + let stop = xmltv_time(p.attr("stop").ok_or(Reason::ImportNoStop)?)?; + if stop <= start { + return Err(Reason::ImportTimeMalformed); + } + let asset = p.children_named("episode-num").find(|e| e.attr("system").unwrap_or("onscreen") == id_system).map(Element::text).filter(|a| is_short(a)).ok_or(Reason::ImportAssetUnknown)?; + let desc = description(p, "desc", ("icon", "src"), ("system", ""))?; + out.push((Prog { start, stop, asset }, desc)); + } + Ok((name, out)) +} + +/// §4.3 step 4: where an item may be shown. +fn restriction(item: &Element) -> R<(u8, Vec<[u8; 2]>)> { + let Some(r) = item.children_named("restriction").find(|r| r.attr("type") == Some("country")) else { + return Ok((geo::EVERYWHERE, Vec::new())); + }; + let allow = match r.attr("relationship") { + Some("allow") => true, + Some("deny") => false, + _ => return Err(Reason::ImportFieldInvalid), + }; + let text = r.text(); + let tokens: Vec<&str> = text.split([' ', '\t', '\r', '\n']).filter(|t| !t.is_empty()).collect(); + match (tokens.as_slice(), allow) { + (["all"], true) | (["none"], false) => Ok((geo::EVERYWHERE, Vec::new())), + (["none"], true) | (["all"], false) => Ok((geo::ONLY, Vec::new())), + ([], _) => Err(Reason::ImportFieldInvalid), + (t, _) if t.iter().all(|c| c.len() == 2 && c.bytes().all(|x| x.is_ascii_alphabetic())) => { + let mut codes: Vec<[u8; 2]> = t.iter().map(|c| [c.as_bytes()[0].to_ascii_uppercase(), c.as_bytes()[1].to_ascii_uppercase()]).collect(); + codes.sort_unstable(); + codes.dedup(); + Ok((if allow { geo::ONLY } else { geo::EXCEPT }, codes)) + } + _ => Err(Reason::ImportFieldInvalid), + } +} + +/// §4.3: the channel name and the items of one document. +fn read_mrss(b: &[u8], owner: &Option, seen: &mut HashSet, count: &mut usize) -> R<(Option, Vec)> { + sized(b)?; + let root = read_feed_xml(b).ok_or(Reason::ImportXmlMalformed)?; + let channel = (root.name == "rss").then(|| first(&root, "channel")).flatten().ok_or(Reason::ImportXmlMalformed)?; + let items: Vec<&Element> = channel.children_named("item").collect(); + *count += items.len(); + if *count > MAX_COUNT { + return Err(Reason::ImportOversized); + } + let name = first(channel, "title").map(Element::text).filter(|n| !n.is_empty()); + if let Some(n) = &name { + field(is_short(n))?; + } + let mut out = Vec::with_capacity(items.len()); + for item in items { + let asset = first(item, "guid").map(Element::text).filter(|a| is_short(a)).ok_or(Reason::ImportAssetUnknown)?; + if !seen.insert(asset.clone()) { + return Err(Reason::ImportAssetDuplicate); + } + let content = first(item, "content").or_else(|| first(item, "group").and_then(|g| first(g, "content"))); + let content_uri = content.and_then(|c| c.attr("url")).unwrap_or("").to_string(); + field(is_long(&content_uri))?; + let duration = match content.and_then(|c| c.attr("duration")) { + None => None, + Some(d) => Some(duration_ms(d).and_then(|d| if d == 0 { Err(Reason::ImportTimeMalformed) } else { Ok(d) })?), + }; + let mut desc = description(item, "description", ("thumbnail", "url"), ("scheme", "urn:simple"))?; + desc.geo = restriction(item)?; + desc.content_uri = content_uri; + desc.duration_ms = duration.unwrap_or(0); + out.push(Item { asset, desc, duration, owner: owner.clone() }); + } + Ok((name, out)) +} + +// --------------------------------------------------------------------------------------- +// §4.4 The JSON form +// --------------------------------------------------------------------------------------- + +fn exactly<'a>(j: &'a Json, want: &[&str]) -> Option<&'a Json> { + let m = j.as_obj()?; + (m.len() == want.len() && want.iter().all(|k| m.iter().any(|(n, _)| n == k))).then_some(j) +} + +fn int(j: Option<&Json>) -> Option { + j?.as_u64().filter(|&v| v <= MAX_INSTANT) +} + +fn strs(j: Option<&Json>) -> Option> { + j?.as_arr()?.iter().map(|s| s.as_str().map(str::to_string)).collect() +} + +/// An asset of the JSON form, as its structure reads (§4.4's table). +struct JsonAsset { + asset_id: String, + title: String, + description: String, + content_uri: String, + categories: Vec, + rating: Option<(String, String)>, + artwork: Vec<(String, String)>, + geo: Option<(bool, Vec)>, + rights_holder: Option, + duration_ms: u64, +} + +struct JsonBreak { + id: String, + start_ms: u64, + duration_ms: u64, + scte35: Option, +} + +struct JsonForm { + channel_name: String, + programmes: Vec<(u64, u64, String)>, + assets: Vec, + breaks: Vec, +} + +/// The JSON form's structure (§4.4): `None` when it does not read so. +fn json_structure(b: &[u8]) -> Option { + let j = strict_json::read(b).ok()?; + exactly(&j, &["kind", "channel_name", "programmes", "assets", "breaks"])?; + if j.get("kind")?.as_str()? != FEED_KIND { + return None; + } + let channel_name = j.get("channel_name")?.as_str()?.to_string(); + let mut programmes = Vec::new(); + for p in j.get("programmes")?.as_arr()? { + exactly(p, &["start_ms", "stop_ms", "asset_id"])?; + programmes.push((int(p.get("start_ms"))?, int(p.get("stop_ms"))?, p.get("asset_id")?.as_str()?.to_string())); + } + let mut assets = Vec::new(); + for a in j.get("assets")?.as_arr()? { + exactly(a, &["asset_id", "title", "description", "content_uri", "categories", "rating", "artwork", "geo", "rights_holder", "duration_ms"])?; + let s = |k: &str| a.get(k)?.as_str().map(str::to_string); + let rating = match a.get("rating")? { + Json::Null => None, + r => { + exactly(r, &["system", "value"])?; + Some((r.get("system")?.as_str()?.to_string(), r.get("value")?.as_str()?.to_string())) + } + }; + let mut artwork = Vec::new(); + for w in a.get("artwork")?.as_arr()? { + exactly(w, &["uri", "sha256"])?; + artwork.push((w.get("uri")?.as_str()?.to_string(), w.get("sha256")?.as_str()?.to_string())); + } + let geo = match a.get("geo")? { + Json::Null => None, + g @ Json::Obj(m) if m.len() == 1 => { + let allow = m[0].0 == "allow"; + if !allow && m[0].0 != "deny" { + return None; + } + Some((allow, strs(g.get(&m[0].0))?)) + } + _ => return None, + }; + let rights_holder = match a.get("rights_holder")? { + Json::Null => None, + Json::Str(r) => Some(r.clone()), + _ => return None, + }; + assets.push(JsonAsset { + asset_id: s("asset_id")?, + title: s("title")?, + description: s("description")?, + content_uri: s("content_uri")?, + categories: strs(a.get("categories"))?, + rating, + artwork, + geo, + rights_holder, + duration_ms: int(a.get("duration_ms"))?, + }); + } + let mut breaks = Vec::new(); + for k in j.get("breaks")?.as_arr()? { + exactly(k, &["id", "start_ms", "duration_ms", "scte35"])?; + let scte35 = match k.get("scte35")? { + Json::Null => None, + Json::Str(s) => Some(s.clone()), + _ => return None, + }; + breaks.push(JsonBreak { id: k.get("id")?.as_str()?.to_string(), start_ms: int(k.get("start_ms"))?, duration_ms: int(k.get("duration_ms"))?, scte35 }); + } + Some(JsonForm { channel_name, programmes, assets, breaks }) +} + +fn is_lower_hex64(s: &str) -> bool { + s.len() == 64 && s.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) +} + +/// Hexadecimal digits of either case, an even count, as bytes. +fn hex_any(s: &str) -> Option> { + if !s.len().is_multiple_of(2) || !s.bytes().all(|c| c.is_ascii_hexdigit()) { + return None; + } + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()).collect() +} + +/// A `splice_info_section()` as live-events §4.3 reads form 1 (§4.5 step 3). +fn section_reads(b: &[u8]) -> bool { + b.len() >= 3 && b[0] == 0xFC && (u16::from_be_bytes([b[1], b[2]]) & 0x0FFF) as usize + 3 == b.len() +} + +/// A break's duration, as §4.5 step 4 bounds it. +fn break_duration(d: u64) -> R { + if d == 0 || d > u64::from(u32::MAX) { Err(Reason::ImportBreakDuration) } else { Ok(d) } +} + +/// §4.4: the JSON form, its values read in document order. +fn read_json(b: &[u8], breaks_count: &mut usize) -> R<(String, Vec, Vec<(String, Desc, Option)>, Vec)> { + 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((f.channel_name, progs, assets, breaks)) +} + +// --------------------------------------------------------------------------------------- +// §4.5 Breaks from a Media Playlist +// --------------------------------------------------------------------------------------- + +fn hexadecimal_sequence(v: &Attr) -> Option> { + match v { + Attr::Plain(s) => hex_any(s.strip_prefix("0x").or_else(|| s.strip_prefix("0X"))?).filter(|b| section_reads(b)), + Attr::Quoted(_) => None, + } +} + +/// §4.5: the breaks of a Media Playlist. +fn read_playlist(b: &[u8], breaks_count: &mut usize) -> R> { + sized(b)?; + let malformed = Reason::ImportPlaylistMalformed; + let s = std::str::from_utf8(b).map_err(|_| malformed)?; + let lines: Vec<&str> = s.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l)).collect(); + if lines.first() != Some(&"#EXTM3U") { + return Err(malformed); + } + let mut groups: Vec<(String, Vec<(String, Attr)>)> = Vec::new(); + for l in lines.iter().filter_map(|l| l.strip_prefix("#EXT-X-DATERANGE:")) { + let attrs = attribute_list(l).ok_or(malformed)?; + let id = match attrs.iter().find(|(n, _)| n == "ID") { + Some((_, Attr::Quoted(id))) => id.clone(), + _ => return Err(malformed), + }; + let at = match groups.iter().position(|(g, _)| *g == id) { + Some(i) => i, + None => { + groups.push((id, Vec::new())); + groups.len() - 1 + } + }; + let g = &mut groups[at].1; + for (n, v) in attrs { + match g.iter().find(|(m, _)| *m == n) { + Some((_, w)) if *w != v => return Err(malformed), + Some(_) => {} + None => g.push((n, v)), + } + } + } + let breaks: Vec<(String, Vec<(String, Attr)>)> = groups.into_iter().filter(|(_, a)| a.iter().any(|(n, _)| n == "SCTE35-OUT")).collect(); + *breaks_count += breaks.len(); + if *breaks_count > MAX_COUNT { + return Err(Reason::ImportOversized); + } + let mut out = Vec::with_capacity(breaks.len()); + for (id, a) in breaks { + let get = |n: &str| a.iter().find(|(m, _)| m == n).map(|(_, v)| v); + field(is_short(&id))?; + let start = match get("START-DATE") { + Some(Attr::Quoted(d)) => hls_date_time(d)?, + _ => return Err(malformed), + }; + let section = hexadecimal_sequence(get("SCTE35-OUT").expect("a break")).ok_or(Reason::ImportScte35Malformed)?; + if let Some(i) = get("SCTE35-IN") { + hexadecimal_sequence(i).ok_or(Reason::ImportScte35Malformed)?; + } + let plain = |v: &Attr| match v { + Attr::Plain(d) => duration_ms(d), + Attr::Quoted(_) => Err(malformed), + }; + let d = if let Some(v) = get("DURATION") { + plain(v)? + } else if let Some(v) = get("END-DATE") { + let end = match v { + Attr::Quoted(e) => hls_date_time(e)?, + Attr::Plain(_) => return Err(malformed), + }; + end.checked_sub(start).ok_or(Reason::ImportBreakDuration)? + } else if let Some(v) = get("PLANNED-DURATION") { + plain(v)? + } else { + return Err(Reason::ImportBreakDuration); + }; + out.push(Break { id, start, duration: break_duration(d)?, section: Some(section) }); + } + Ok(out) +} + +// --------------------------------------------------------------------------------------- +// §4.6 The programmes and their assets, §4.7 the schedule, §4.8 the cues +// --------------------------------------------------------------------------------------- + +/// An artwork URI's name (§4.6 step 3). +fn artwork_name(uri: &str) -> Option<&str> { + let u = uri.split(['?', '#']).next().unwrap_or(""); + let name = u.rsplit('/').next().unwrap_or(""); + let ok = (1..=255).contains(&name.len()) && name != "." && name != ".." && name.bytes().all(|c| c.is_ascii_alphanumeric() || matches!(c, b'.' | b'_' | b'-')); + ok.then_some(name) +} + +/// A break's cue id (§4.8). +pub fn cue_id(break_id: &str, section: Option<&[u8]>) -> u32 { + match section { + Some(s) if s.len() >= 18 && s[4] & 0x80 == 0 && s[13] == 0x05 => u32::from_be_bytes(s[14..18].try_into().expect("4 bytes")), + _ => { + let d = Sha256::new().chain_update(CUE_ID_DOMAIN).chain_update(break_id.as_bytes()).finalize(); + u32::from_be_bytes(d[..4].try_into().expect("4 bytes")) + } + } +} + +/// A break's `cue` payload of live-events §4.3 (§4.8). +fn cue_payload(k: &Break) -> Vec { + let mut o = vec![4u8]; + o.extend_from_slice(&cue_id(&k.id, k.section.as_deref()).to_le_bytes()); + o.extend_from_slice(&(k.duration as u32).to_le_bytes()); + match &k.section { + None => o.push(0), + Some(s) => { + o.push(1); + o.extend_from_slice(&(s.len() as u16).to_le_bytes()); + o.extend_from_slice(s); + } + } + o +} + +/// Import a channel (§4), the receipt and the lineup signed by `key`. +pub fn import(f: &Feeds, key: &SigningKey, signer_id: &str, parent: Option<[u8; 32]>) -> R { + check_inputs(f, signer_id)?; + let xch = f.xmltv_channel.as_deref().unwrap_or(&f.channel); + let id_system = f.id_system.as_deref().unwrap_or(ID_SYSTEM); + + // The feeds, in the order of §4.10. + let mut names: Vec> = Vec::new(); + let mut xmltv: Option> = None; + if let Some(b) = &f.xmltv { + let (name, progs) = read_xmltv(b, xch, id_system)?; + names.push(name); + xmltv = Some(progs); + } + let (mut seen, mut count) = (HashSet::new(), 0usize); + let mut items: Vec = Vec::new(); + for (k, (b, owner)) in f.mrss.iter().enumerate() { + let (name, mut its) = read_mrss(b, owner, &mut seen, &mut count)?; + if k == 0 { + names.push(name); + } + items.append(&mut its); + } + let mut breaks_count = 0usize; + let mut breaks: Vec = Vec::new(); + let mut json: Option<(Vec, Vec<(String, Desc, Option)>)> = None; + if let Some(b) = &f.json { + let (name, progs, assets, mut bks) = read_json(b, &mut breaks_count)?; + names.push(Some(name)); + json = Some((progs, assets)); + breaks.append(&mut bks); + } + if let Some(b) = &f.playlist { + breaks.append(&mut read_playlist(b, &mut breaks_count)?); + } + + // §4.6 The programmes. + let mut progs: Vec = if let Some(x) = &xmltv { + x.iter().map(|(p, _)| p.clone()).collect() + } else if let Some((p, _)) = &json { + if p.is_empty() { + return Err(Reason::ImportEmpty); + } + p.clone() + } else { + if items.is_empty() { + return Err(Reason::ImportEmpty); + } + let mut at = f.start_ms.expect("checked in §4.1"); + let mut out = Vec::with_capacity(items.len()); + for it in &items { + let d = it.duration.ok_or(Reason::ImportDurationUnknown)?; + let stop = at.checked_add(d).filter(|&s| s <= MAX_INSTANT).ok_or(Reason::ImportTimeMalformed)?; + out.push(Prog { start: at, stop, asset: it.asset.clone() }); + at = stop; + } + out + }; + progs.sort_by_key(|p| p.start); + for w in progs.windows(2) { + if w[0].stop < w[1].start { + return Err(Reason::ImportGap); + } + if w[0].stop > w[1].start { + return Err(Reason::ImportOverlap); + } + } + let last_stop = progs.last().expect("not empty").stop; + if !progs.iter().all(|p| p.start.is_multiple_of(1000)) || !last_stop.is_multiple_of(1000) { + return Err(Reason::ImportTimeMalformed); + } + + // §4.6 Their assets. + let item_of: HashMap<&str, &Item> = items.iter().map(|i| (i.asset.as_str(), i)).collect(); + let mut order: Vec<&str> = Vec::new(); + for p in &progs { + if !order.contains(&p.asset.as_str()) { + order.push(&p.asset); + } + } + let mut assets: Vec = Vec::with_capacity(order.len()); + for id in order { + let (desc, holder): (Desc, Option) = if let Some((_, js)) = &json { + let (_, d, r) = js.iter().find(|(a, _, _)| a == id).ok_or(Reason::ImportAssetUnknown)?; + (d.clone(), r.clone()) + } else if let Some(it) = item_of.get(id) { + (it.desc.clone(), it.owner.clone()) + } else { + let (_, d) = xmltv.as_ref().expect("an XMLTV programme").iter().find(|(p, _)| p.asset == id).expect("a programme of it"); + (d.clone(), None) + }; + let rights_holder = holder.or_else(|| f.owner.clone()).ok_or(Reason::ImportOwnerUnknown)?; + let artwork = match desc.art { + Art::Hashed(h) => h, + Art::Uris(uris) => { + let mut out = Vec::with_capacity(uris.len()); + for u in uris { + let bytes = artwork_name(&u).and_then(|n| f.artwork.get(n)).ok_or(Reason::ImportArtworkMissing)?; + sized(bytes)?; + out.push((Sha256::digest(bytes).into(), u)); + } + out + } + }; + assets.push(Asset { + asset_id: id.to_string(), + title: desc.title, + description: desc.description, + categories: desc.categories, + rating_system: desc.rating.0, + rating_value: desc.rating.1, + artwork, + geo: desc.geo.0, + countries: desc.geo.1, + rights_holder, + content_uri: desc.content_uri, + duration_ms: desc.duration_ms, + }); + } + + // §4.7 The schedule. + breaks.sort_by_key(|k| k.start); + for w in breaks.windows(2) { + if w[0].start + w[0].duration > w[1].start { + return Err(Reason::ImportOverlap); + } + } + let mut within: Vec> = vec![Vec::new(); progs.len()]; + for k in &breaks { + let at = progs.iter().position(|p| p.start <= k.start && k.start < p.stop).filter(|&i| k.start + k.duration <= progs[i].stop).ok_or(Reason::ImportBreakOutside)?; + within[at].push(k); + } + let mut schedule: Vec = Vec::new(); + for (p, bks) in progs.iter().zip(&within) { + let mut at = p.start; + for k in bks { + if k.start > at { + schedule.push(ScheduleEntry { asset_id: p.asset.clone(), start_ms: at, duration_ms: k.start - at, kind: EntryKind::Program }); + } + schedule.push(ScheduleEntry { asset_id: k.id.clone(), start_ms: k.start, duration_ms: k.duration, kind: EntryKind::Ad }); + at = k.start + k.duration; + } + if p.stop > at { + schedule.push(ScheduleEntry { asset_id: p.asset.clone(), start_ms: at, duration_ms: p.stop - at, kind: EntryKind::Program }); + } + } + + // §4.9 What it yields. + let (first_start, last_end) = (progs[0].start, last_stop); + let (ws, we) = f.window.unwrap_or((first_start, last_end)); + if ws >= we || ws < first_start || we > last_end { + return Err(Reason::ImportWindowOutside); + } + let receipt = ChannelReceipt::seal(key, signer_id, f.channel.clone(), schedule, ws, we, 0, None, GrantRef::unbounded("video.channel"), parent); + assets.sort_by(|a, b| a.asset_id.as_bytes().cmp(b.asset_id.as_bytes())); + let channel_name = names.into_iter().flatten().next().unwrap_or_else(|| f.channel.clone()); + let lineup = Lineup { + channel_id: f.channel.clone(), + channel_name, + schedule_root: receipt.schedule_root, + schedule_start_ms: first_start, + schedule_end_ms: last_end, + programmes: progs.iter().map(|p| p.start).collect(), + assets, + signer: [0; 32], + sig: [0; 64], + } + .sign(key); + let cues = breaks.iter().map(|k| Cue { break_id: k.id.clone(), start_ms: k.start, payload: cue_payload(k) }).collect(); + Ok(Imported { receipt, lineup, cues }) +} diff --git a/wai/wai-rs/src/channel_lineup/tests.rs b/wai/wai-rs/src/channel_lineup/tests.rs new file mode 100644 index 000000000..9aa3afb2f --- /dev/null +++ b/wai/wai-rs/src/channel_lineup/tests.rs @@ -0,0 +1,48 @@ +use super::*; + +#[test] +fn xmltv_times_and_their_refusals() { + assert_eq!(xmltv_time("20261029180000"), Ok(1_793_296_800_000)); + assert_eq!(xmltv_time("20261029200000 +0200"), Ok(1_793_296_800_000)); + assert_eq!(xmltv_time("20261029160000 -0200"), Ok(1_793_296_800_000)); + assert_eq!(xmltv_time("20261029180000 BST"), Err(Reason::ImportTimezoneUnknown)); + assert_eq!(xmltv_time("20261029180000 +2400"), Err(Reason::ImportTimezoneUnknown)); + assert_eq!(xmltv_time("202610291800"), Err(Reason::ImportTimeMalformed)); + assert_eq!(xmltv_time("20260230180000"), Err(Reason::ImportTimeMalformed)); + assert_eq!(xmltv_time("20280229180000").map(|_| ()), Ok(())); + assert_eq!(xmltv_time("19700101000000 +0100"), Err(Reason::ImportTimeMalformed)); + assert_eq!(xmltv_time("20261029180000+0000"), Err(Reason::ImportTimeMalformed)); + assert_eq!(xmltv_format(1_793_296_800_000), "20261029180000 +0000"); +} + +#[test] +fn hls_date_times_and_durations() { + assert_eq!(hls_date_time("2026-10-29T18:00:00Z"), Ok(1_793_296_800_000)); + assert_eq!(hls_date_time("2026-10-29T18:00:00.5Z"), Ok(1_793_296_800_500)); + assert_eq!(hls_date_time("2026-10-29T18:00:00.250000Z"), Ok(1_793_296_800_250)); + assert_eq!(hls_date_time("2026-10-29T18:00:00.2501Z"), Err(Reason::ImportTimeMalformed)); + assert_eq!(hls_date_time("2026-10-29T20:00:00+02:00"), Ok(1_793_296_800_000)); + assert_eq!(hls_date_time("2026-10-29T18:00:00"), Err(Reason::ImportTimezoneUnknown)); + assert_eq!(hls_date_time("2026-10-29 18:00:00Z"), Err(Reason::ImportTimeMalformed)); + assert_eq!(duration_ms("90.000"), Ok(90_000)); + assert_eq!(duration_ms("1.0005"), Err(Reason::ImportTimeMalformed)); + assert_eq!(duration_ms("-1"), Err(Reason::ImportTimeMalformed)); +} + +#[test] +fn a_document_type_declaration_is_skipped_once() { + let ok = b"\n\n"; + assert_eq!(read_feed_xml(ok).map(|e| e.name), Some("tv".to_string())); + assert!(read_feed_xml(b"").is_some()); + assert!(read_feed_xml(b"").is_some()); + assert!(read_feed_xml(b"]>").is_none(), "an internal subset is outside the subset"); + assert!(read_feed_xml(b"").is_none(), "a second declaration is outside it"); + assert!(read_feed_xml(b"").is_none()); +} + +#[test] +fn every_reason_has_its_code() { + for r in Reason::ADDED.iter().chain(Reason::REUSED) { + assert_eq!(Reason::from_code(r.code()), Some(*r)); + } +} diff --git a/wai/wai-rs/src/lib.rs b/wai/wai-rs/src/lib.rs index 6b5f622dc..ac2afff98 100644 --- a/wai/wai-rs/src/lib.rs +++ b/wai/wai-rs/src/lib.rs @@ -682,6 +682,14 @@ pub mod live_ads; /// cbindgen:ignore #[cfg(feature = "stitched_presentation")] pub mod stitched_presentation; +// Channel lineup and guide (extensions/channel-lineup.md): `wai.video.lineup` — a linear channel's +// signed lineup (WCL1), 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, @@ -799,7 +807,7 @@ pub mod scene_map; // The strict JSON reader the two mappings above share, and the fleet's reader of a relay's session // summaries. Rust-only. /// cbindgen:ignore -#[cfg(any(feature = "prior_wire", feature = "scene_map", feature = "conform_result", feature = "fleet"))] +#[cfg(any(feature = "prior_wire", feature = "scene_map", feature = "conform_result", feature = "fleet", feature = "channel_lineup"))] pub(crate) mod strict_json; // Conformance results (extensions/conformance-results.md): `conform-result/1`, the signed outcome // of a conformance run, which `wai conform` writes and `wai verify` checks; and its corpus. diff --git a/wai/wai-rs/src/wai_video_channel.rs b/wai/wai-rs/src/wai_video_channel.rs index 315c4b6d9..4b7dc748b 100644 --- a/wai/wai-rs/src/wai_video_channel.rs +++ b/wai/wai-rs/src/wai_video_channel.rs @@ -129,7 +129,10 @@ pub struct ChannelReceipt { } impl ChannelReceipt { - fn signing_payload(&self) -> Vec { + /// The bytes the signature covers. Visible to the crate so that a verifier + /// (extensions/channel-lineup.md §8.2) can tell a signature that fails from a rule that + /// does; the bytes are unchanged. + pub(crate) fn signing_payload(&self) -> Vec { let mut o = DOMAIN_RECEIPT.to_vec(); o.extend_from_slice(&(self.channel_id.len() as u32).to_be_bytes()); o.extend_from_slice(self.channel_id.as_bytes()); diff --git a/wai/wai-rs/tests/channel_lineup.rs b/wai/wai-rs/tests/channel_lineup.rs new file mode 100644 index 000000000..55ad86577 --- /dev/null +++ b/wai/wai-rs/tests/channel_lineup.rs @@ -0,0 +1,150 @@ +//! `wai.video.lineup` (extensions/channel-lineup.md): the committed corpus +//! `live-conformance/channels/` regenerates byte for byte from its fixed seeds and holds no other +//! file, every vector gets the outcome it states, §10 lists exactly the reasons the reference adds, +//! and the worked example of §9 — its commands as the text writes them — imports, guides and +//! verifies the sample channel through the `wai` command line, yielding the corpus's files. +#![cfg(feature = "channel_lineup")] + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde_json::Value; +use wai::channel_lineup::{corpus, Reason}; + +fn wai_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..") +} + +fn corpus_dir() -> PathBuf { + wai_dir().join("live-conformance/channels") +} + +fn committed() -> BTreeMap { + std::fs::read_dir(corpus_dir()) + .expect("the committed corpus") + .flatten() + .map(|e| (e.file_name().to_string_lossy().into_owned(), e.path())) + .filter(|(n, _)| n != "README.md") + .map(|(n, p)| (n, std::fs::read_to_string(p).expect("a text file"))) + .collect() +} + +#[test] +fn the_committed_corpus_regenerates_from_its_seeds() { + let files = committed(); + let generated: BTreeMap = corpus::generate().into_iter().collect(); + let extra: Vec<&String> = files.keys().filter(|k| !generated.contains_key(*k)).collect(); + let missing: Vec<&String> = generated.keys().filter(|k| !files.contains_key(*k)).collect(); + let differ: Vec<&String> = generated.iter().filter(|(k, v)| files.get(*k).is_some_and(|f| f != *v)).map(|(k, _)| k).collect(); + assert!(extra.is_empty() && missing.is_empty() && differ.is_empty(), "extra {extra:?}, missing {missing:?}, differ {differ:?}: run `wai_channel_lineup_vectors gen`"); +} + +#[test] +fn every_vector_gets_its_stated_outcome() { + let fails = corpus::verify(&committed()); + assert!(fails.is_empty(), "{}", fails.join("\n")); +} + +/// §10 of the extension lists exactly the codes `Reason::ADDED` has, each once, in order, and +/// then names live-events' two. +#[test] +fn the_extension_lists_the_reasons() { + let text = std::fs::read_to_string(wai_dir().join("extensions/channel-lineup.md")).expect("the extension"); + let at = text.find("\n## 10. Reasons").expect("a §10"); + let end = text[at + 1..].find("\n## ").map_or(text.len(), |e| at + 1 + e); + let section = &text[at..end]; + let (table, after) = section.split_once("\nA verification also").expect("the table, then the reused codes"); + let listed: Vec = table.lines().filter(|l| l.starts_with("| `")).filter_map(|l| l.split('`').nth(1).map(String::from)).collect(); + let want: Vec = Reason::ADDED.iter().map(|r| r.code().to_string()).collect(); + assert_eq!(listed, want, "§10's table and Reason::ADDED differ"); + for r in Reason::REUSED { + assert!(after.contains(&format!("`{}`", r.code())), "§10 names {r}"); + } +} + +/// The commands of §9's worked example, as the text writes them, each a list of words. +fn worked_example() -> Vec> { + let text = std::fs::read_to_string(wai_dir().join("extensions/channel-lineup.md")).expect("the extension"); + let at = text.find("\n## 9. ").expect("a §9"); + let block = text[at..].split("```").nth(1).expect("a code block in §9"); + let joined = block.replace("\\\n", " "); + joined.lines().map(str::trim).filter(|l| l.starts_with("wai ")).map(|l| l.split_whitespace().skip(1).map(String::from).collect()).collect() +} + +fn run(dir: &Path, args: &[String]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_wai")).args(args).current_dir(dir).output().expect("wai runs") +} + +fn scratch(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("wai-channel-lineup-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).expect("a scratch directory"); + for (n, body) in committed() { + std::fs::write(d.join(n), body).expect("a corpus file"); + } + std::fs::write(d.join("operator.key"), hex(&corpus::operator().to_bytes())).expect("the key file"); + d +} + +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +fn vectors() -> Value { + serde_json::from_str(&committed()["vectors.json"]).expect("vectors.json") +} + +/// §9: the text's commands, run as written over the corpus's files, yield the corpus's receipt, +/// lineup, cues and guides, and the guide verifies. +#[test] +fn the_worked_example_runs_as_the_text_writes_it() { + let d = scratch("example"); + let cmds = worked_example(); + assert_eq!(cmds.len(), 4, "§9 shows an import, two guides and a verification: {cmds:?}"); + for c in &cmds { + let o = run(&d, c); + assert!(o.status.success(), "wai {}: {}{}", c.join(" "), String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)); + } + let files = committed(); + let v = vectors(); + let sample = v["imports"].as_array().unwrap().iter().find(|x| x["name"] == "sample").unwrap(); + assert_eq!(std::fs::read_to_string(d.join("out/channel.json")).unwrap(), files["sample.channel.json"], "the receipt"); + assert_eq!(hex(&std::fs::read(d.join("out/lineup.wcl")).unwrap()), sample["outcome"]["lineup"].as_str().unwrap(), "the lineup"); + let cues: Value = serde_json::from_slice(&std::fs::read(d.join("out/cues.json")).unwrap()).unwrap(); + assert_eq!(cues, sample["outcome"]["cues"], "the cues"); + assert_eq!(std::fs::read_to_string(d.join("guide.xml")).unwrap(), files["sample.guide.xml"], "the XMLTV guide"); + assert_eq!(std::fs::read_to_string(d.join("now-next.json")).unwrap(), files["sample.now-next.json"], "the now/next document"); + let _ = std::fs::remove_dir_all(&d); +} + +/// A refusal is exit 1 with the text's reason as the envelope's code, and nothing is written; a +/// guide that a third party changed does not verify, and says where. +#[test] +fn a_refusal_is_its_reason_and_exit_1() { + let d = scratch("refusal"); + let s = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::>(); + let x = std::fs::read_to_string(d.join("sample.xmltv.xml")).unwrap().replace("start=\"20261029193000\"", "start=\"20261029193100\""); + std::fs::write(d.join("gap.xmltv.xml"), x).unwrap(); + let o = run(&d, &s(&["channel", "import", "--channel", "sample-1", "--xmltv", "gap.xmltv.xml", "--owner", "owner-c", "--artwork", ".", "--key", "operator.key", "--out-dir", "gap", "--json"])); + assert_eq!(o.status.code(), Some(1), "{}", String::from_utf8_lossy(&o.stdout)); + let e: Value = serde_json::from_slice(&o.stdout).expect("an envelope"); + assert_eq!((e["ok"].as_bool(), e["error"]["code"].as_str()), (Some(false), Some("import_gap")), "{e}"); + assert!(!d.join("gap").exists(), "a refused import writes nothing"); + + let g = std::fs::read_to_string(d.join("sample.guide.xml")).unwrap().replace(">hl-102", ">nf-1"); + std::fs::write(d.join("changed.guide.xml"), g).unwrap(); + let lineup = vectors()["imports"][0]["outcome"]["lineup"].as_str().unwrap().to_string(); + let bytes: Vec = (0..lineup.len()).step_by(2).map(|i| u8::from_str_radix(&lineup[i..i + 2], 16).unwrap()).collect(); + std::fs::write(d.join("lineup.wcl"), bytes).unwrap(); + let o = run(&d, &s(&["channel", "verify", "--receipt", "sample.channel.json", "--lineup", "lineup.wcl", "--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.wcl", "--guide", "sample.guide.xml", "--json"])); + assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stdout)); + let e: Value = serde_json::from_slice(&o.stdout).expect("an envelope"); + let aired: Vec = e["data"]["guide"]["programmes"].as_array().unwrap().iter().map(|p| p["aired"].as_bool().unwrap()).collect(); + assert_eq!(aired, [true, true, false, false], "the first hour aired in the receipt's window: {e}"); + let _ = std::fs::remove_dir_all(&d); +} -- 2.54.0 (Apple Git-157) From b3ca7cf4d6327b370323609295921f3a5d35b45e Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 29 Oct 2026 17:12:37 -0400 Subject: [PATCH 03/11] wai: the operations channel.import, channel.guide and channel.verify In the catalogue (and its export), with the agent rules: --json, --dry-run on the two that write, --yes before a file is replaced, and a refusal's reason as the error's code with exit 1. quickstart, the shipped wai's build, holds channel_lineup. tests/cli_ops.rs gains the two writes' dry-run samples. Co-Authored-By: Claude Opus 5.5 --- wai/catalogue/ops.json | 390 +++++++++++++++++++++++ wai/wai-rs/src/bin/wai_cli/dx/channel.rs | 288 +++++++++++++++++ wai/wai-rs/src/bin/wai_cli/dx/mod.rs | 9 +- wai/wai-rs/src/catalogue.rs | 81 +++++ wai/wai-rs/tests/cli_ops.rs | 19 ++ 5 files changed, 786 insertions(+), 1 deletion(-) create mode 100644 wai/wai-rs/src/bin/wai_cli/dx/channel.rs diff --git a/wai/catalogue/ops.json b/wai/catalogue/ops.json index ed818a8db..1b384c18f 100644 --- a/wai/catalogue/ops.json +++ b/wai/catalogue/ops.json @@ -3301,6 +3301,396 @@ "sdk": "wai.report.export" } }, + { + "id": "channel.import", + "summary": "A linear channel's feeds (an XMLTV schedule, Media RSS catalogues or the JSON form, and a Media Playlist's SCTE-35 date ranges) imported into its signed receipt, its signed lineup and its breaks' cues", + "cli": "wai channel import", + "aliases": [], + "usage": [ + "wai channel import --channel ID (--xmltv F [--mrss F]... | --mrss F... | --feed F) [--playlist F] --key K", + " [--mrss-owner O]... [--owner O] [--signer-id S] [--id-system N] [--xmltv-channel X] [--start-ms T] [--artwork DIR]", + " [--window-start T --window-end T] [--parent HEX] [--out-dir D] [--yes] the receipt (channel.json), the lineup", + " (lineup.wcl) 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.wcl and cues.json are written (.).", + "x-cli": { + "flag": "--out-dir" + } + } + }, + "required": [ + "channel", + "key" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "required": [ + "channel", + "receipt", + "receipt_hash", + "schedule_root", + "lineup", + "lineup_digest", + "cues" + ], + "description": "The files written (the receipt, the lineup, the cues), the receipt's identity and schedule root, the lineup's digest, and their counts" + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": true, + "needs": { + "all": [ + "channel_lineup" + ] + }, + "names": { + "cli": "wai channel import", + "mcp": null, + "c": "wai_channel_import", + "rust": "wai::channel::import", + "sdk": "wai.channel.import" + } + }, + { + "id": "channel.guide", + "summary": "A channel's guide, an XMLTV document or a now/next document, rendered from its receipt and its lineup and naming both", + "cli": "wai channel guide", + "aliases": [], + "usage": [ + "wai channel guide --receipt R --lineup L [--from T --to T | --now-next --at T] [--out F] [--yes] the XMLTV guide", + " of a range (the whole schedule), or the now/next document of an instant, written to F (guide.xml, now-next.json);", + " replacing a file waits for --yes (--confirm, deprecated, is the same)" + ], + "placement": "local", + "effect": "writes-project", + "dry_run": true, + "confirm": "when-replacing-a-file", + "route": null, + "doors": { + "cli": true, + "mcp": false, + "http": false + }, + "toolset": "core", + "stability": "preview", + "input": { + "type": "object", + "properties": { + "receipt": { + "type": "string", + "format": "path", + "description": "The channel receipt, in its JSON carriage.", + "x-cli": { + "flag": "--receipt" + } + }, + "lineup": { + "type": "string", + "format": "path", + "description": "The lineup's file.", + "x-cli": { + "flag": "--lineup" + } + }, + "from": { + "type": "integer", + "description": "The range's start, milliseconds since 1970 (the schedule's first start).", + "x-cli": { + "flag": "--from" + } + }, + "to": { + "type": "integer", + "description": "The range's end (the schedule's last end).", + "x-cli": { + "flag": "--to" + } + }, + "now_next": { + "type": "boolean", + "description": "The now/next document of --at instead.", + "x-cli": { + "flag": "--now-next" + } + }, + "at": { + "type": "integer", + "description": "The instant of a now/next document, milliseconds since 1970.", + "x-cli": { + "flag": "--at" + } + }, + "out": { + "type": "string", + "format": "path", + "description": "The guide's file (guide.xml, or now-next.json).", + "x-cli": { + "flag": "--out" + } + } + }, + "required": [ + "receipt", + "lineup" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "required": [ + "file", + "format", + "schedule_root", + "lineup" + ], + "description": "The guide's file and format (xmltv or now-next), the sources it names, and its range or instant" + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": { + "all": [ + "channel_lineup" + ] + }, + "names": { + "cli": "wai channel guide", + "mcp": null, + "c": "wai_channel_guide", + "rust": "wai::channel::guide", + "sdk": "wai.channel.guide" + } + }, + { + "id": "channel.verify", + "summary": "A channel's receipt, its lineup and a guide verified together: the guide recomputed from the two and compared, each programme with whether it aired", + "cli": "wai channel verify", + "aliases": [], + "usage": [ + "wai channel verify --receipt R --lineup L [--guide G] the receipt, the lineup and the guide checked together;", + " exit 1 with the reason when one does not verify" + ], + "placement": "local", + "effect": "reads", + "dry_run": false, + "confirm": "never", + "route": null, + "doors": { + "cli": true, + "mcp": false, + "http": false + }, + "toolset": "core", + "stability": "preview", + "input": { + "type": "object", + "properties": { + "receipt": { + "type": "string", + "format": "path", + "description": "The channel receipt, in its JSON carriage.", + "x-cli": { + "flag": "--receipt" + } + }, + "lineup": { + "type": "string", + "format": "path", + "description": "The lineup's file.", + "x-cli": { + "flag": "--lineup" + } + }, + "guide": { + "type": "string", + "format": "path", + "description": "An XMLTV guide or a now/next document.", + "x-cli": { + "flag": "--guide" + } + } + }, + "required": [ + "receipt", + "lineup" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "required": [ + "verdict", + "part", + "receipt", + "lineup", + "guide", + "entry" + ], + "description": "What extensions/channel-lineup.md §8.3 states: the verdict and the part it is about, the receipt's identity, the lineup's digest, and the guide's programmes, each with whether it aired" + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": { + "all": [ + "channel_lineup" + ] + }, + "names": { + "cli": "wai channel verify", + "mcp": null, + "c": "wai_channel_verify", + "rust": "wai::channel::verify", + "sdk": "wai.channel.verify" + } + }, { "id": "mcp.start", "summary": "WAI's operations as tools for a coding agent: a Model Context Protocol server on standard input and output, in toolsets", diff --git a/wai/wai-rs/src/bin/wai_cli/dx/channel.rs b/wai/wai-rs/src/bin/wai_cli/dx/channel.rs new file mode 100644 index 000000000..625c10601 --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/dx/channel.rs @@ -0,0 +1,288 @@ +//! 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. + +use super::envelope::{self as agent, EXIT_FAILED, EXIT_INTERNAL, EXIT_NOT_FOUND, EXIT_PERMISSION, OpError}; +use crate::out; +use serde_json::{Value, json}; + +type R = Result; + +/// Print what an operation did: its envelope with `--json`, its text otherwise; in a dry run of an +/// operation that writes, with the plan of what it would have done. +fn done(op: &str, writes: bool, data: Value, text: &str) -> R { + let plan = (writes && out::dry_run()).then(out::plan); + if agent::ctx().json { + agent::print(&agent::envelope_with(op, true, data, plan)); + } else if plan.is_some() { + println!("{text}"); + println!("dry run: nothing was written. Without --dry-run, wai would:"); + for l in out::plan_lines() { + println!(" {l}"); + } + } else { + println!("{text}"); + } + Ok(true) +} + +#[cfg_attr(not(feature = "channel_lineup"), allow(dead_code))] +fn io_fail(path: &str, e: &std::io::Error, hint: &str) -> OpError { + match e.kind() { + std::io::ErrorKind::NotFound => OpError::new(EXIT_NOT_FOUND, "not-found", format!("{path}: {e}"), hint), + std::io::ErrorKind::PermissionDenied => OpError::new(EXIT_PERMISSION, "permission-denied", format!("{path}: {e}"), "the file's owner runs this"), + _ => OpError::new(EXIT_INTERNAL, "io-error", format!("{path}: {e}"), hint), + } +} + +#[cfg_attr(not(feature = "channel_lineup"), allow(dead_code))] +fn read(path: &str, what: &str) -> Result, OpError> { + std::fs::read(path).map_err(|e| io_fail(path, &e, &format!("give {what}"))) +} + +/// Every value of a flag that may be repeated, in order. +#[cfg_attr(not(feature = "channel_lineup"), allow(dead_code))] +fn all<'a>(args: &'a [String], flag: &str) -> Vec<&'a str> { + args.windows(2).filter(|w| w[0] == flag).map(|w| w[1].as_str()).collect() +} + +#[cfg_attr(not(feature = "channel_lineup"), allow(dead_code))] +fn number(args: &[String], flag: &str, usage: &str) -> Result, OpError> { + match super::opt(args, flag) { + None => Ok(None), + Some(v) => v.parse().map(Some).map_err(|_| OpError::usage(format!("{flag} {v}: milliseconds since 1970, an integer"), usage)), + } +} + +#[cfg_attr(not(feature = "channel_lineup"), allow(dead_code))] +fn unhex(s: &str) -> Option> { + if !s.len().is_multiple_of(2) { + return None; + } + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok()).collect() +} + +#[cfg_attr(not(feature = "channel_lineup"), allow(dead_code))] +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +/// A refusal of the text, as the toolchain's failure (exit 1, the reason as its code). +#[cfg(feature = "channel_lineup")] +fn refused(r: wai::channel_lineup::Reason, what: &str) -> OpError { + OpError::new(EXIT_FAILED, r.code(), format!("{what}: {r}"), "extensions/channel-lineup.md §10 names where each reason is raised") +} + +#[cfg(not(feature = "channel_lineup"))] +pub fn import(_: &[String]) -> R { + Err(crate::door::not_built(wai::catalogue::op("channel.import").expect("listed"))) +} + +#[cfg(not(feature = "channel_lineup"))] +pub fn guide(_: &[String]) -> R { + Err(crate::door::not_built(wai::catalogue::op("channel.guide").expect("listed"))) +} + +#[cfg(not(feature = "channel_lineup"))] +pub fn verify(_: &[String]) -> R { + Err(crate::door::not_built(wai::catalogue::op("channel.verify").expect("listed"))) +} + +/// Write `files`, each `(path, bytes)`; replacing one that exists waits for `--yes`. +#[cfg(feature = "channel_lineup")] +fn write_all(files: &[(String, Vec)]) -> Result<(), OpError> { + if !out::dry_run() { + let replaces: Vec<&String> = files.iter().map(|(p, _)| p).filter(|p| std::path::Path::new(p).exists()).collect(); + if !replaces.is_empty() { + let names: Vec<&str> = replaces.iter().map(|s| s.as_str()).collect(); + agent::confirm(&format!("{} exist(s), and would be replaced", names.join(", ")), replaces.iter().map(|p| json!({"write": p, "replaces": true})).collect())?; + } + } + for (p, b) in files { + out::write(p, b).map_err(|e| io_fail(p, &e, "check the directory"))?; + } + Ok(()) +} + +/// `wai channel import ...`: a channel's feeds into its receipt, lineup and cues (§4). +#[cfg(feature = "channel_lineup")] +pub fn import(args: &[String]) -> R { + use std::collections::BTreeMap; + use wai::channel_lineup::{import::{cues_json, import, Feeds}, receipt_text, MAX_DOCUMENT}; + let usage = super::usage("channel.import"); + let with_value = [ + "--channel", "--xmltv", "--mrss", "--mrss-owner", "--feed", "--playlist", "--key", "--signer-id", "--owner", "--id-system", "--xmltv-channel", + "--start-ms", "--artwork", "--window-start", "--window-end", "--parent", "--out-dir", + ]; + agent::check_args(args, &with_value, &with_value, 0, &usage)?; + let channel = super::opt(args, "--channel").ok_or_else(|| OpError::usage("--channel: the channel id", &usage))?; + let key_path = super::opt(args, "--key").ok_or_else(|| OpError::usage("--key: the operator's key file (32 bytes, or 64 hexadecimal digits)", &usage))?; + let raw = read(key_path, "the operator's key file")?; + let seed: [u8; 32] = if raw.len() == 32 { + raw.try_into().expect("32") + } else { + std::str::from_utf8(&raw).ok().and_then(|s| unhex(s.trim())).and_then(|b| b.try_into().ok()).ok_or_else(|| OpError::usage(format!("{key_path}: 32 bytes, or 64 hexadecimal digits"), "the operator key's seed"))? + }; + let key = ed25519_dalek::SigningKey::from_bytes(&seed); + let mrss = all(args, "--mrss"); + let owners = all(args, "--mrss-owner"); + if !owners.is_empty() && owners.len() != mrss.len() { + return Err(OpError::usage("--mrss-owner: none, or one for each --mrss, in their order", &usage)); + } + let mut feeds = Feeds { channel: channel.to_string(), ..Feeds::default() }; + feeds.xmltv = super::opt(args, "--xmltv").map(|p| read(p, "the XMLTV document")).transpose()?; + for (k, p) in mrss.iter().enumerate() { + feeds.mrss.push((read(p, "the Media RSS document")?, owners.get(k).map(|o| o.to_string()))); + } + feeds.json = super::opt(args, "--feed").map(|p| read(p, "the JSON form")).transpose()?; + feeds.playlist = super::opt(args, "--playlist").map(|p| read(p, "the Media Playlist")).transpose()?; + feeds.owner = super::opt(args, "--owner").map(str::to_string); + feeds.id_system = super::opt(args, "--id-system").map(str::to_string); + feeds.xmltv_channel = super::opt(args, "--xmltv-channel").map(str::to_string); + feeds.start_ms = number(args, "--start-ms", &usage)?; + feeds.window = match (number(args, "--window-start", &usage)?, number(args, "--window-end", &usage)?) { + (Some(s), Some(e)) => Some((s, e)), + (None, None) => None, + _ => return Err(OpError::usage("--window-start and --window-end: both, or neither", &usage)), + }; + if let Some(dir) = super::opt(args, "--artwork") { + let mut art = BTreeMap::new(); + for e in std::fs::read_dir(dir).map_err(|e| io_fail(dir, &e, "give the directory that holds the artwork"))?.flatten() { + if e.file_type().is_ok_and(|t| t.is_file()) { + use std::io::Read; + let mut b = Vec::new(); + let p = e.path(); + std::fs::File::open(&p).and_then(|f| f.take(MAX_DOCUMENT as u64 + 1).read_to_end(&mut b)).map_err(|x| io_fail(&p.display().to_string(), &x, "check the artwork"))?; + art.insert(e.file_name().to_string_lossy().into_owned(), b); + } + } + feeds.artwork = art; + } + let parent = match super::opt(args, "--parent") { + None => None, + Some(h) => Some(unhex(h).and_then(|b| <[u8; 32]>::try_from(b).ok()).ok_or_else(|| OpError::usage("--parent: a receipt hash, 64 hexadecimal digits", &usage))?), + }; + let did = wai::signer_id::did_key(&key.verifying_key().to_bytes()); + let signer_id = super::opt(args, "--signer-id").unwrap_or(&did); + let got = import(&feeds, &key, signer_id, parent).map_err(|r| refused(r, "the feeds are refused"))?; + let dir = std::path::Path::new(super::opt(args, "--out-dir").unwrap_or(".")); + let path = |n: &str| dir.join(n).display().to_string(); + let cues = serde_json::to_string_pretty(&cues_json(&got.cues)).expect("JSON") + "\n"; + let files = vec![ + (path("channel.json"), receipt_text(&got.receipt).into_bytes()), + (path("lineup.wcl"), got.lineup.to_bytes()), + (path("cues.json"), cues.into_bytes()), + ]; + if !dir.as_os_str().is_empty() { + out::create_dir_all(dir).map_err(|e| io_fail(&dir.display().to_string(), &e, "check the directory"))?; + } + write_all(&files)?; + let (r, l) = (&got.receipt, &got.lineup); + let data = json!({ + "channel": channel, + "receipt": files[0].0, "receipt_hash": hex(&r.receipt_hash()), "schedule_root": hex(&r.schedule_root), + "entries": r.schedule.len(), "window": [r.window_start_ms, r.window_end_ms], "aired": r.aired.len(), + "lineup": files[1].0, "lineup_digest": hex(&l.digest()), "programmes": l.programmes.len(), "assets": l.assets.len(), + "cues": files[2].0, "breaks": got.cues.len(), "signer_id": r.signer_id, + }); + let text = format!( + "imported {channel}: {} entries, {} programmes of {} assets, {} breaks; schedule root {}…, lineup {}…\n receipt {}\n lineup {}\n cues {}", + r.schedule.len(), + l.programmes.len(), + l.assets.len(), + got.cues.len(), + &hex(&r.schedule_root)[..16], + &hex(&l.digest())[..16], + files[0].0, + files[1].0, + files[2].0 + ); + done("channel.import", true, data, &text) +} + +/// The receipt and lineup a guide is made from, checked first (§8.2 steps 1 to 7). +#[cfg(feature = "channel_lineup")] +fn sources(args: &[String], usage: &str) -> Result<(Vec, Vec), OpError> { + let r = super::opt(args, "--receipt").ok_or_else(|| OpError::usage("--receipt: a channel receipt in its JSON carriage", usage))?; + let l = super::opt(args, "--lineup").ok_or_else(|| OpError::usage("--lineup: a lineup's file", usage))?; + Ok((read(r, "the receipt")?, read(l, "the lineup")?)) +} + +/// `wai channel guide --receipt R --lineup L ...`: the guide (§7). +#[cfg(feature = "channel_lineup")] +pub fn guide(args: &[String]) -> R { + use wai::channel_lineup::{now_next, receipt_from_json, 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 = receipt_from_json(&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 = format!("{}", facts["verdict"].as_str().unwrap_or("?")); + if let Some(p) = facts["part"].as_str() { + text.push_str(&format!(" (the {p})")); + } + if let Some(h) = facts["receipt"].as_str() { + text.push_str(&format!("\n receipt {h}")); + } + if let Some(h) = facts["lineup"].as_str() { + text.push_str(&format!("\n lineup {h}")); + } + for p in facts["guide"]["programmes"].as_array().into_iter().flatten() { + text.push_str(&format!("\n {} to {} {}{}", p["start_ms"], p["stop_ms"], p["asset_id"].as_str().unwrap_or(""), if p["aired"] == true { " (aired in the window)" } else { "" })); + } + match v.verdict { + Ok(()) => done("channel.verify", false, facts, &text), + Err(r) => { + if agent::ctx().json { + agent::print(&agent::error_envelope_with(Some("channel.verify"), &refused(r, "the channel does not verify"), Some(facts))); + Err(OpError::reported(EXIT_FAILED)) + } else { + println!("{text}"); + Ok(false) + } + } + } +} diff --git a/wai/wai-rs/src/bin/wai_cli/dx/mod.rs b/wai/wai-rs/src/bin/wai_cli/dx/mod.rs index 0d3be1f07..755820d80 100644 --- a/wai/wai-rs/src/bin/wai_cli/dx/mod.rs +++ b/wai/wai-rs/src/bin/wai_cli/dx/mod.rs @@ -20,7 +20,10 @@ //! (`../setup_agent.rs`); //! - `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`). +//! 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`). //! //! [`envelope`] holds the agent rules every command keeps: the exit codes, the JSON envelope, //! `--dry-run`, `--yes`. @@ -34,6 +37,7 @@ pub mod project; mod app; mod breaking; +mod channel; #[cfg(feature = "quickstart")] pub(crate) mod conform; mod create; @@ -73,6 +77,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 }, ]; /// An operation's synopsis, from the catalogue, for a usage error. diff --git a/wai/wai-rs/src/catalogue.rs b/wai/wai-rs/src/catalogue.rs index 8e78adca8..872e0eaab 100644 --- a/wai/wai-rs/src/catalogue.rs +++ b/wai/wai-rs/src/catalogue.rs @@ -301,6 +301,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. @@ -906,6 +910,82 @@ http vectors diff|fuzz --seed S --count N --out F", confirm: Some(Confirm::AnyFile), ..OP }, + // ─── linear channels (extensions/channel-lineup.md §9) ─── + Op { + id: "channel.import", + summary: "A linear channel's feeds (an XMLTV schedule, Media RSS catalogues or the JSON form, and a Media Playlist's SCTE-35 date ranges) imported into its signed receipt, its signed lineup and its breaks' cues", + cli: Some("channel import"), + usage: "channel import --channel ID (--xmltv F [--mrss F]... | --mrss F... | --feed F) [--playlist F] --key K + [--mrss-owner O]... [--owner O] [--signer-id S] [--id-system N] [--xmltv-channel X] [--start-ms T] [--artwork DIR] + [--window-start T --window-end T] [--parent HEX] [--out-dir D] [--yes] the receipt (channel.json), the lineup + (lineup.wcl) 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.wcl 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", @@ -1547,6 +1627,7 @@ pub fn feature_on(feature: &str) -> Option { "mcp" => cfg!(feature = "mcp"), "app" => cfg!(feature = "app"), "fleet" => cfg!(feature = "fleet"), + "channel_lineup" => cfg!(feature = "channel_lineup"), _ => return None, }) } diff --git a/wai/wai-rs/tests/cli_ops.rs b/wai/wai-rs/tests/cli_ops.rs index f3e9db6c6..667a45f47 100644 --- a/wai/wai-rs/tests/cli_ops.rs +++ b/wai/wai-rs/tests/cli_ops.rs @@ -412,6 +412,25 @@ fn samples(d: &Path, origin_port: u16) -> Vec<(&'static str, Vec, bool)> more.push(("report.export", s(&["report", "export", &digest, "--target", &url, "--out", "week.wai-fleet.json"]), true)); more.push(("report.create", s(&["report", "create", "--target", "http://127.0.0.1:9", "--start", "0", "--end", "1"]), false)); } + #[cfg(feature = "channel_lineup")] + { + // The sample channel of extensions/channel-lineup.md §11, imported here; and the guide of + // its committed receipt and lineup. + let c = wai_dir().join("live-conformance/channels"); + let f = |n: &str| c.join(n).to_str().unwrap().to_string(); + let corpus: Value = serde_json::from_slice(&std::fs::read(c.join("vectors.json")).unwrap()).unwrap(); + std::fs::write(d.join("operator.key"), corpus["operator"]["seed"].as_str().unwrap()).unwrap(); + let l = corpus["imports"][0]["outcome"]["lineup"].as_str().unwrap(); + let lineup: Vec = (0..l.len()).step_by(2).map(|i| u8::from_str_radix(&l[i..i + 2], 16).unwrap()).collect(); + std::fs::write(d.join("sample.wcl"), 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", "operator.key", "--out-dir", "channel-out"]), + true, + )); + more.push(("channel.guide", s(&["channel", "guide", "--receipt", &f("sample.channel.json"), "--lineup", "sample.wcl", "--out", "guide.xml"]), true)); + } #[cfg(feature = "generate")] { let model = wai_dir().join("wai-rs/tests/vectors/generate/generate_spectral_model.json"); -- 2.54.0 (Apple Git-157) From 0842b4eb927924045855729035dfd28303a2846f Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 29 Oct 2026 17:19:36 -0400 Subject: [PATCH 04/11] wai: channel_lineup_verify.py, the corpus's second implementation Written from extensions/channel-lineup.md alone; shares no code with wai-rs. It reimports every vector's feeds and requires its outcome (the refusal, or the receipt, the lineup and the cues, signatures verified), recomputes every verification's facts, mutates copies of the corpus and requires each to fail, and drops each of its checks in turn and requires the corpus to catch it. The corpus gains three controls that only such a weakened verifier showed were missing: the count bound, a boundary off a whole second, and two breaks that overlap; and an edit form that repeats its text. Co-Authored-By: Claude Opus 5.5 --- wai/live-conformance/channel_lineup_verify.py | 1482 +++++++++++++++++ wai/live-conformance/channels/README.md | 3 +- wai/live-conformance/channels/vectors.json | 116 ++ wai/wai-rs/src/channel_lineup/corpus.rs | 12 +- 4 files changed, 1610 insertions(+), 3 deletions(-) create mode 100644 wai/live-conformance/channel_lineup_verify.py diff --git a/wai/live-conformance/channel_lineup_verify.py b/wai/live-conformance/channel_lineup_verify.py new file mode 100644 index 000000000..2f2f4aab3 --- /dev/null +++ b/wai/live-conformance/channel_lineup_verify.py @@ -0,0 +1,1482 @@ +#!/usr/bin/env python3 +"""Independent verifier of the channel lineup corpus (extensions/channel-lineup.md). + +Written from the extension's text. It shares no code with wai-rs, which generates the corpus, +and never runs it. Ed25519 signatures are checked with ../claim-conformance/_ed25519.py, WAI's +strict rule (SPEC §7 *Signatures*), and BLAKE3 with ../claim-conformance/_blake3.py. From the +corpora's other second implementations it takes, by reference as the extension does: the Merkle +root of jwp-receipts §2.2 and an energy class's signed encoding from live_ads_verify.py; and the +XML subset, the HLS attribute lists and the decimal reading of stitched-presentation §3.2 and +§3.5 from stitched_presentation_verify.py. Everything else is here, from the standard library: + +- text values, XMLTV and HLS times, durations, and the document type declaration (§3); +- the import of an XMLTV schedule, Media RSS catalogues, the JSON form and a Media Playlist's + breaks into a schedule, its assets and its cues (§4); +- the channel receipt's JSON carriage, its leaves, payload, identity and rules (§5); +- the lineup, its rules, digest and signature, and whether it describes a schedule (§6); +- the XMLTV guide and the now/next document (§7), and verification (§8). + +For every import it requires the outcome the corpus states (the refusal, or a receipt, lineup and +cues it recomputes and whose signatures it verifies); for every verification, the facts. Of the +corpus, that every reason of §10 and both of live-events' appear, and that no other file is +there. Then it mutates copies of the corpus, one defect each, and requires every copy to fail; +and it drops each of its own checks in turn and requires the corpus to catch the weakened +verifier. + + python3 -I channel_lineup_verify.py + +Exit status 0 only if every vector verifies, every mutated copy fails and every weakened verifier +is caught. Python 3.8 or later; standard library only. +""" + +import copy +import datetime +import hashlib +import json +import os +import struct +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +sys.path.insert(0, os.path.join(HERE, "..", "claim-conformance")) +import _blake3 as B3 # noqa: E402 +import _ed25519 as ED # noqa: E402 +import live_ads_verify as LAV # noqa: E402 +import stitched_presentation_verify as SPV # noqa: E402 + +EXTENSION = os.path.join(HERE, "..", "extensions", "channel-lineup.md") + +MAGIC, VERSION = b"WCL1", 1 # §6.1 +LINEUP_DOMAIN = b"wai:channel-lineup\x01" # §6.3 +ENTRY_DOMAIN = b"wai:video-channel-entry\x01" # §5 +RECEIPT_DOMAIN = b"wai:video-channel-receipt\x01" +RECEIPT_ID_DOMAIN = b"wai:video-channel-receipt-id\x01" +CUE_ID_DOMAIN = b"wai:channel-cue-id\x01" # §4.8 +ID_SYSTEM = "wai-asset-id" # §7.2 +GUIDE_URN = "urn:wai:guide:" +NOW_NEXT_KIND, FEED_KIND = "wai-now-next", "wai-channel-feed" +MAX_DOC, MAX_COUNT, MAX_INSTANT = 16777216, 10000, 2 ** 53 - 1 # §2, §3.1 +U32, U64 = 2 ** 32 - 1, 2 ** 64 - 1 +KINDS = {"program": 1, "ad": 2, "filler": 3} +EVERYWHERE, ONLY, EXCEPT = 0, 1, 2 # §6.2 geo +DIGITS = "0123456789" +WS = " \t\r\n" + +# The checks a weakened verifier may drop (main(), the last part). +SKIP = set() + + +class Refuse(Exception): + def __init__(self, reason): + super().__init__(reason) + self.reason = reason + + +def check(name, ok, reason): + """A rule: refuse `reason` when it fails, unless this run drops the check `name`.""" + if not ok and name not in SKIP: + raise Refuse(reason) + + +def sha(*parts): + h = hashlib.sha256() + for p in parts: + h.update(p) + return h.digest() + + +def reasons(): + """§10's table of the codes this extension adds, and the codes it names after it.""" + with open(EXTENSION, encoding="utf-8") as f: + text = f.read() + sec = text[text.index("\n## 10. Reasons"):] + sec = sec[:sec.index("\n## ", 1)] + table, after = sec.split("\nA verification also", 1) + added = [ln.split("`")[1] for ln in table.splitlines() if ln.startswith("| `")] + reused = [w.strip("`") for w in after.split() if w.startswith("`") and w.rstrip(",.").endswith("`")] + return added, [r.rstrip("`") for r in reused] + + +# ---------------------------------------------------------------- §3.2 text values + +def is_text(s): + return isinstance(s, str) and all(c >= " " or c in "\t\n" for c in s) and not any("\ud800" <= c <= "\udfff" for c in s) + + +def blen(s): + return len(s.encode("utf-8")) + + +def short_text(s): + return is_text(s) and 1 <= blen(s) <= 255 + + +def long_text(s): + return is_text(s) and blen(s) <= 65535 + + +def field(ok): + check("fields", ok, "import_field_invalid") + + +# ---------------------------------------------------------------- §3.4 times + +def instant(y, mo, d, h, mi, s, ms, offset_min): + if y < 1970 or not 1 <= mo <= 12 or h > 23 or mi > 59 or s > 59: + return None + try: + days = (datetime.date(y, mo, d) - datetime.date(1970, 1, 1)).days + except ValueError: + return None + t = (days * 86400 + h * 3600 + mi * 60 + s - offset_min * 60) * 1000 + ms + return t if t >= 0 else None + + +def digits(s): + return len(s) > 0 and all(c in DIGITS for c in s) + + +def xmltv_time(v): + """§3.4: an XMLTV time's instant, or Refuse.""" + if len(v) < 14 or not digits(v[:14]): + raise Refuse("import_time_malformed") + rest = v[14:] + if rest == "": + off = 0 + elif rest.startswith(" "): + z = rest[1:] + ok = len(z) == 5 and z[0] in "+-" and digits(z[1:]) and int(z[1:3]) <= 23 and int(z[3:5]) <= 59 + check("timezone", ok, "import_timezone_unknown") + if not ok: + off = 0 + else: + off = (int(z[1:3]) * 60 + int(z[3:5])) * (-1 if z[0] == "-" else 1) + else: + raise Refuse("import_time_malformed") + t = instant(int(v[0:4]), int(v[4:6]), int(v[6:8]), int(v[8:10]), int(v[10:12]), int(v[12:14]), 0, off) + if t is None: + raise Refuse("import_time_malformed") + return t + + +def xmltv_format(ms): + d = datetime.datetime(1970, 1, 1) + datetime.timedelta(milliseconds=ms) + return d.strftime("%Y%m%d%H%M%S") + " +0000" + + +def hls_date_time(v): + """§3.4: an HLS date-time's instant, or Refuse.""" + bad = Refuse("import_time_malformed") + shape = "dddd-dd-ddTdd:dd:dd" + if len(v) < 19 or any((c not in DIGITS) if p == "d" else (c != p) for p, c in zip(shape, v[:19])): + raise bad + p, ms = 19, 0 + if p < len(v) and v[p] == ".": + q = p + 1 + while q < len(v) and v[q] in DIGITS: + q += 1 + frac = v[p + 1:q] + if not frac or frac[3:].strip("0"): + raise bad + ms = int((frac[:3] + "000")[:3]) + p = q + z = v[p:] + if z == "": + raise Refuse("import_timezone_unknown") + if z == "Z": + off = 0 + elif len(z) == 6 and z[0] in "+-" and z[3] == ":" and digits(z[1:3]) and digits(z[4:6]): + hh, mm = int(z[1:3]), int(z[4:6]) + if hh > 23 or mm > 59: + raise bad + off = (hh * 60 + mm) * (-1 if z[0] == "-" else 1) + else: + raise bad + t = instant(int(v[0:4]), int(v[5:7]), int(v[8:10]), int(v[11:13]), int(v[14:16]), int(v[17:19]), ms, off) + if t is None or t > MAX_INSTANT: + raise bad + return t + + +def duration(v): + """§3.4: a duration in milliseconds, or Refuse.""" + d = SPV.decimal_scaled(v, 3) + if d is None or d > MAX_INSTANT: + raise Refuse("import_time_malformed") + return d + + +# ---------------------------------------------------------------- §3.3 the XML subset + +def doctype_end(b, at): + """The end of a document type declaration of §3.3 at `at`, or None.""" + def skip(p): + while p < len(b) and b[p] in b" \t\r\n": + p += 1 + return p + + def literal(p): + if p >= len(b) or b[p] not in b"\"'": + return None + q = b.find(b[p:p + 1], p + 1) + return None if q < 0 else q + 1 + + p = at + len(b"= len(b) or not (chr(b[p]).isascii() and (chr(b[p]).isalpha() or b[p] == ord("_"))): + return None + p += 1 + while p < len(b) and (chr(b[p]).isascii() and (chr(b[p]).isalnum() or chr(b[p]) in "_-.:")): + p += 1 + a = skip(p) + if a > p: + if b.startswith(b"SYSTEM", a): + q = skip(a + 6) + if q == a + 6: + return None + p = literal(q) + elif b.startswith(b"PUBLIC", a): + q = skip(a + 6) + if q == a + 6: + return None + r = literal(q) + if r is None: + return None + s = skip(r) + if s == r: + return None + p = literal(s) + else: + p = a + if p is None: + return None + p = skip(p) + return p + 1 if p < len(b) and b[p] == ord(">") else None + + +def read_feed_xml(b): + """§3.3: the root element, or None.""" + b = bytearray(b) + p = 0 + while True: + while p < len(b) and b[p] in b" \t\r\n": + p += 1 + if b.startswith(b"", p + 4) + if q < 0: + return None + p = q + 3 + elif b.startswith(b"", p + 2) + if q < 0: + return None + p = q + 2 + elif b.startswith(b" 0 + if inp["json"] is not None: + sets = inp["xmltv"] is None and not inp["mrss"] + else: + sets = inp["xmltv"] is not None or bool(inp["mrss"]) + owners = [m["owner"] for m in inp["mrss"]] + ok = (short_text(inp["channel"]) and sets + and (all(o is not None for o in owners) or all(o is None for o in owners)) + and all(o is None or short_text(o) for o in owners) + and all(inp[k] is None or short_text(inp[k]) for k in ("owner", "id_system", "xmltv_channel")) + and short_text(inp["signer_id"]) + and not (alone and inp["start_ms"] is None) + and (inp["start_ms"] is None or inp["start_ms"] <= MAX_INSTANT)) + check("inputs", ok, "import_inputs_invalid") + + +def sized(b): + check("size", len(b) <= MAX_DOC, "import_oversized") + + +def description(e, desc_name, art, scheme_attr, scheme_default, rating_text): + """§4.2 step 4, §4.3 step 3.""" + title = text_of(first(e, "title")) + field(title != "" and long_text(title)) + desc = text_of(first(e, desc_name)) + field(long_text(desc)) + cats = [SPV.text(c) for c in SPV.kids(e, "category")] + field(len(cats) <= 16 and all(short_text(c) for c in cats)) + uris = [i["attrs"].get(art[1]) for i in SPV.kids(e, art[0])] + field(len(uris) <= 8 and all(u is not None and u != "" and long_text(u) for u in uris)) + rating = ("", "") + r = first(e, "rating") + if r is not None: + system = r["attrs"].get(scheme_attr, scheme_default) + field(system == "" or short_text(system)) + value = rating_text(r) + field(short_text(value)) + rating = (system, value) + return {"title": title, "description": desc, "categories": cats, "rating": rating, "art": ("uris", uris), + "geo": (EVERYWHERE, []), "content_uri": "", "duration_ms": 0} + + +def read_xmltv(b, xch, id_system): + """§4.2: (channel name or None, [(programme, description)]).""" + sized(b) + root = read_feed_xml(b) + if root is None or root["name"] != "tv": + raise Refuse("import_xml_malformed") + progs = [p for p in SPV.kids(root, "programme") if p["attrs"].get("channel") == xch] + check("counts", len(progs) <= MAX_COUNT, "import_oversized") + name = None + for c in SPV.kids(root, "channel"): + if c["attrs"].get("id") == xch: + name = text_of(first(c, "display-name")) or None + break + if name is not None: + field(short_text(name)) + if not progs: + raise Refuse("import_empty") + out = [] + for p in progs: + if "start" not in p["attrs"]: + raise Refuse("import_time_malformed") + start = xmltv_time(p["attrs"]["start"]) + if "stop" not in p["attrs"]: + check("no_stop", False, "import_no_stop") + stop = start + 1000 + else: + stop = xmltv_time(p["attrs"]["stop"]) + if stop <= start: + raise Refuse("import_time_malformed") + ids = [SPV.text(e) for e in SPV.kids(p, "episode-num") if e["attrs"].get("system", "onscreen") == id_system] + check("asset", bool(ids) and short_text(ids[0]), "import_asset_unknown") + asset = ids[0] if ids else "?" + d = description(p, "desc", ("icon", "src"), "system", "", lambda r: text_of(first(r, "value"))) + out.append(({"start": start, "stop": stop, "asset": asset}, d)) + return name, out + + +def restriction(item): + """§4.3 step 4.""" + r = None + for x in SPV.kids(item, "restriction"): + if x["attrs"].get("type") == "country": + r = x + break + if r is None: + return (EVERYWHERE, []) + rel = r["attrs"].get("relationship") + field(rel in ("allow", "deny")) + allow = rel == "allow" + toks = [t for t in SPV.text(r).replace("\t", " ").replace("\r", " ").replace("\n", " ").split(" ") if t] + if toks in (["all"], ["none"]): + return (EVERYWHERE, []) if (toks == ["all"]) == allow else (ONLY, []) + ok = bool(toks) and all(len(t) == 2 and t.isascii() and t.isalpha() for t in toks) + field(ok) + codes = sorted(set(t.upper() for t in toks)) + return (ONLY if allow else EXCEPT, codes) + + +def read_mrss(b, owner, seen, count): + """§4.3: (channel name or None, [item]).""" + sized(b) + root = read_feed_xml(b) + ch = first(root, "channel") if root is not None and root["name"] == "rss" else None + if ch is None: + raise Refuse("import_xml_malformed") + items = SPV.kids(ch, "item") + count[0] += len(items) + check("counts", count[0] <= MAX_COUNT, "import_oversized") + name = text_of(first(ch, "title")) or None + if name is not None: + field(short_text(name)) + out = [] + for it in items: + g = first(it, "guid") + asset = SPV.text(g) if g is not None else "" + check("asset", short_text(asset), "import_asset_unknown") + check("duplicate", asset not in seen, "import_asset_duplicate") + seen.add(asset) + content = first(it, "content") + if content is None: + grp = first(it, "group") + content = first(grp, "content") if grp is not None else None + uri = content["attrs"].get("url", "") if content is not None else "" + field(long_text(uri)) + dur = None + if content is not None and "duration" in content["attrs"]: + dur = duration(content["attrs"]["duration"]) + if dur == 0: + raise Refuse("import_time_malformed") + d = description(it, "description", ("thumbnail", "url"), "scheme", "urn:simple", SPV.text) + d["geo"] = restriction(it) + d["content_uri"], d["duration_ms"] = uri, dur or 0 + out.append({"asset": asset, "desc": d, "duration": dur, "owner": owner}) + return name, out + + +def strict_json(b): + """§4.4's reading of JSON: a value, or None (a member named twice, a number that is not an + integer of the form, an unpaired surrogate, more than 64 levels).""" + class Bad(Exception): + pass + + def pairs(ps): + names = [n for n, _ in ps] + if len(set(names)) != len(names): + raise Bad() + return dict(ps) + + def bad_number(s): + raise Bad() + + def depth(v, d): + if d > 64: + raise Bad() + if isinstance(v, dict): + for k, x in v.items(): + if not is_text_or_any(k): + raise Bad() + depth(x, d + 1) + elif isinstance(v, list): + for x in v: + depth(x, d + 1) + elif isinstance(v, str) and not is_text_or_any(v): + raise Bad() + + def is_text_or_any(s): + return not any("\ud800" <= c <= "\udfff" for c in s) + + try: + s = b.decode("utf-8") + v = json.loads(s, object_pairs_hook=pairs, parse_float=bad_number, parse_constant=bad_number) + depth(v, 1) + return v + except (UnicodeDecodeError, ValueError, Bad, RecursionError): + return None + + +def jint(v): + return isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= MAX_INSTANT + + +def exactly(o, names): + return isinstance(o, dict) and set(o) == set(names) and len(o) == len(names) + + +def json_structure(v): + """§4.4's table: True when the form reads.""" + if not exactly(v, ["kind", "channel_name", "programmes", "assets", "breaks"]) or v["kind"] != FEED_KIND: + return False + if not isinstance(v["channel_name"], str) or not all(isinstance(v[k], list) for k in ("programmes", "assets", "breaks")): + return False + for p in v["programmes"]: + if not (exactly(p, ["start_ms", "stop_ms", "asset_id"]) and jint(p["start_ms"]) and jint(p["stop_ms"]) + and isinstance(p["asset_id"], str)): + return False + for a in v["assets"]: + if not exactly(a, ["asset_id", "title", "description", "content_uri", "categories", "rating", "artwork", "geo", + "rights_holder", "duration_ms"]): + return False + if not all(isinstance(a[k], str) for k in ("asset_id", "title", "description", "content_uri")): + return False + if not (isinstance(a["categories"], list) and all(isinstance(c, str) for c in a["categories"])): + return False + r = a["rating"] + if r is not None and not (exactly(r, ["system", "value"]) and isinstance(r["system"], str) and isinstance(r["value"], str)): + return False + if not isinstance(a["artwork"], list) or not all(exactly(w, ["uri", "sha256"]) and isinstance(w["uri"], str) + and isinstance(w["sha256"], str) for w in a["artwork"]): + return False + g = a["geo"] + if g is not None and not (isinstance(g, dict) and len(g) == 1 and list(g)[0] in ("allow", "deny") + and isinstance(list(g.values())[0], list) + and all(isinstance(c, str) for c in list(g.values())[0])): + return False + if not (a["rights_holder"] is None or isinstance(a["rights_holder"], str)) or not jint(a["duration_ms"]): + return False + for k in v["breaks"]: + if not (exactly(k, ["id", "start_ms", "duration_ms", "scte35"]) and isinstance(k["id"], str) and jint(k["start_ms"]) + and jint(k["duration_ms"]) and (k["scte35"] is None or isinstance(k["scte35"], str))): + return False + return True + + +def hex_any(s): + if len(s) % 2 or any(c not in "0123456789abcdefABCDEF" for c in s): + return None + return bytes.fromhex(s) + + +def section_reads(b): + """live-events §4.3's reading of form 1 (§4.5 step 3).""" + return len(b) >= 3 and b[0] == 0xFC and (((b[1] << 8) | b[2]) & 0x0FFF) + 3 == len(b) + + +def break_duration(d): + check("break_duration", 0 < d <= U32, "import_break_duration") + return d + + +def scte35(b): + check("scte35", b is not None and section_reads(b), "import_scte35_malformed") + return b + + +def read_json(b, nbreaks): + """§4.4: (channel name, programmes, {asset id: (description, rights holder)}, breaks).""" + sized(b) + v = strict_json(b) + if v is None or not json_structure(v): + raise Refuse("import_json_malformed") + nbreaks[0] += len(v["breaks"]) + check("counts", len(v["programmes"]) <= MAX_COUNT and len(v["assets"]) <= MAX_COUNT and nbreaks[0] <= MAX_COUNT, + "import_oversized") + field(short_text(v["channel_name"])) + progs = [] + for p in v["programmes"]: + field(short_text(p["asset_id"])) + if p["stop_ms"] <= p["start_ms"]: + raise Refuse("import_time_malformed") + progs.append({"start": p["start_ms"], "stop": p["stop_ms"], "asset": p["asset_id"]}) + assets = {} + for a in v["assets"]: + field(short_text(a["asset_id"])) + check("duplicate", a["asset_id"] not in assets, "import_asset_duplicate") + field(a["title"] != "" and long_text(a["title"])) + field(long_text(a["description"]) and long_text(a["content_uri"])) + field(len(a["categories"]) <= 16 and all(short_text(c) for c in a["categories"])) + rating = ("", "") + if a["rating"] is not None: + s, val = a["rating"]["system"], a["rating"]["value"] + field((s == "" or short_text(s)) and short_text(val)) + rating = (s, val) + field(len(a["artwork"]) <= 8 and all(w["uri"] != "" and long_text(w["uri"]) and len(w["sha256"]) == 64 + and all(c in "0123456789abcdef" for c in w["sha256"]) for w in a["artwork"])) + geo = (EVERYWHERE, []) + if a["geo"] is not None: + (rel, codes), = a["geo"].items() + ok = (all(len(c) == 2 and c.isascii() and c.isalpha() and c.isupper() for c in codes) + and all(codes[i] < codes[i + 1] for i in range(len(codes) - 1)) and (rel == "allow" or codes)) + field(ok) + geo = (ONLY if rel == "allow" else EXCEPT, list(codes)) + if a["rights_holder"] is not None: + field(short_text(a["rights_holder"])) + d = {"title": a["title"], "description": a["description"], "categories": a["categories"], "rating": rating, + "art": ("hashed", [(bytes.fromhex(w["sha256"]), w["uri"]) for w in a["artwork"]]), "geo": geo, + "content_uri": a["content_uri"], "duration_ms": a["duration_ms"]} + assets[a["asset_id"]] = (d, a["rights_holder"]) + breaks = [] + for k in v["breaks"]: + field(short_text(k["id"])) + sec = scte35(hex_any(k["scte35"])) if k["scte35"] is not None else None + breaks.append({"id": k["id"], "start": k["start_ms"], "duration": break_duration(k["duration_ms"]), "section": sec}) + return v["channel_name"], progs, assets, breaks + + +def hex_sequence(v): + """§4.5 step 3: an unquoted hexadecimal-sequence's bytes, or None.""" + value, quoted = v + if quoted or value[:2] not in ("0x", "0X"): + return None + return hex_any(value[2:]) + + +def read_playlist(b, nbreaks): + """§4.5: the breaks.""" + sized(b) + bad = Refuse("import_playlist_malformed") + try: + s = b.decode("utf-8") + except UnicodeDecodeError: + raise bad + lines = [ln[:-1] if ln.endswith("\r") else ln for ln in s.split("\n")] + if lines[0] != "#EXTM3U": + raise bad + groups = [] # [(id, {name: (value, quoted)})] + for ln in lines: + if not ln.startswith("#EXT-X-DATERANGE:"): + continue + a = SPV.attribute_list(ln[len("#EXT-X-DATERANGE:"):]) + if a is None: + raise bad + d = {n: (v, q) for n, v, q in a} + if "ID" not in d or not d["ID"][1]: + raise bad + gid = d["ID"][0] + g = next((x for x in groups if x[0] == gid), None) + if g is None: + g = (gid, {}) + groups.append(g) + for n, v in d.items(): + if n in g[1] and g[1][n] != v and "conflict" not in SKIP: + raise bad + g[1].setdefault(n, v) + brks = [g for g in groups if "SCTE35-OUT" in g[1]] + nbreaks[0] += len(brks) + check("counts", nbreaks[0] <= MAX_COUNT, "import_oversized") + out = [] + for gid, a in brks: + field(short_text(gid)) + sd = a.get("START-DATE") + if sd is None or not sd[1]: + raise bad + start = hls_date_time(sd[0]) + sec = scte35(hex_sequence(a["SCTE35-OUT"])) + if "SCTE35-IN" in a: + scte35(hex_sequence(a["SCTE35-IN"])) + + def plain(v): + if v[1]: + raise bad + return duration(v[0]) + if "DURATION" in a: + d = plain(a["DURATION"]) + elif "END-DATE" in a: + if not a["END-DATE"][1]: + raise bad + end = hls_date_time(a["END-DATE"][0]) + check("break_duration", end >= start, "import_break_duration") + d = end - start + elif "PLANNED-DURATION" in a: + d = plain(a["PLANNED-DURATION"]) + else: + raise Refuse("import_break_duration") + out.append({"id": gid, "start": start, "duration": break_duration(d), "section": sec}) + return out + + +def artwork_name(uri): + u = uri + for c in "?#": + u = u.split(c, 1)[0] + name = u.rsplit("/", 1)[-1] + ok = 1 <= len(name) <= 255 and name not in (".", "..") and all(c.isascii() and (c.isalnum() or c in "._-") for c in name) + return name if ok else None + + +def cue_id(bid, sec): + if sec is not None and len(sec) >= 18 and sec[4] & 0x80 == 0 and sec[13] == 0x05: + return int.from_bytes(sec[14:18], "big") + return int.from_bytes(sha(CUE_ID_DOMAIN, bid.encode())[:4], "big") + + +def cue_payload(k): + """§4.8: live-events §4.3's cue, little-endian.""" + out = bytes([4]) + struct.pack(" MAX_INSTANT: + raise Refuse("import_time_malformed") + progs.append({"start": at, "stop": stop, "asset": it["asset"]}) + at = stop + progs = sorted(progs, key=lambda p: p["start"]) + for p, q in zip(progs, progs[1:]): + check("gap", p["stop"] >= q["start"], "import_gap") + check("overlap", p["stop"] <= q["start"], "import_overlap") + check("whole_seconds", all(p["start"] % 1000 == 0 for p in progs) and progs[-1]["stop"] % 1000 == 0, + "import_time_malformed") + + # §4.6 their assets + item_of = {it["asset"]: it for it in items} + order = [] + for p in progs: + if p["asset"] not in order: + order.append(p["asset"]) + assets = [] + for aid in order: + if jf is not None: + check("asset", aid in jf[1], "import_asset_unknown") + d, holder = jf[1].get(aid, (None, None)) + if d is None: + continue + elif aid in item_of: + d, holder = item_of[aid]["desc"], item_of[aid]["owner"] + else: + d, holder = next(x for p, x in xmltv if p["asset"] == aid), None + holder = holder if holder is not None else inp["owner"] + check("owner", holder is not None, "import_owner_unknown") + kind, art = d["art"] + if kind == "uris": + hashed = [] + for u in art: + n = artwork_name(u) + ok = n is not None and n in inp["artwork_files"] + check("artwork", ok, "import_artwork_missing") + body = inp["artwork_files"].get(n, b"") if n else b"" + sized(body) + hashed.append((hashlib.sha256(body).digest(), u)) + art = hashed + assets.append({"asset_id": aid, "title": d["title"], "description": d["description"], "categories": d["categories"], + "rating_system": d["rating"][0], "rating_value": d["rating"][1], "artwork": art, "geo": d["geo"][0], + "countries": d["geo"][1], "rights_holder": holder or "", "content_uri": d["content_uri"], + "duration_ms": d["duration_ms"]}) + + # §4.7 the schedule + breaks = sorted(breaks, key=lambda k: k["start"]) + for k, n in zip(breaks, breaks[1:]): + check("break_overlap", k["start"] + k["duration"] <= n["start"], "import_overlap") + within = [[] for _ in progs] + for k in breaks: + at = next((i for i, p in enumerate(progs) if p["start"] <= k["start"] < p["stop"]), None) + ok = at is not None and k["start"] + k["duration"] <= progs[at]["stop"] + check("break_outside", ok, "import_break_outside") + if at is not None: + within[at].append(k) + entries = [] + for p, ks in zip(progs, within): + at = p["start"] + for k in ks: + if k["start"] > at: + entries.append({"asset_id": p["asset"], "start_ms": at, "duration_ms": k["start"] - at, "kind": "program"}) + entries.append({"asset_id": k["id"], "start_ms": k["start"], "duration_ms": k["duration"], "kind": "ad"}) + at = k["start"] + k["duration"] + if p["stop"] > at: + entries.append({"asset_id": p["asset"], "start_ms": at, "duration_ms": p["stop"] - at, "kind": "program"}) + + # §4.9 + first_start, last_end = progs[0]["start"], progs[-1]["stop"] + ws, we = inp["window"] if inp["window"] is not None else (first_start, last_end) + check("window", ws < we and ws >= first_start and we <= last_end, "import_window_outside") + name = next((n for n in names if n is not None), inp["channel"]) + lineup = {"channel_id": inp["channel"], "channel_name": name, "schedule_root": schedule_root(entries), + "schedule_start_ms": first_start, "schedule_end_ms": last_end, "programmes": [p["start"] for p in progs], + "assets": sorted(assets, key=lambda a: a["asset_id"].encode())} + cues = [{"break": k["id"], "start_ms": k["start"], "payload": cue_payload(k).hex()} for k in breaks] + return {"entries": entries, "window": (ws, we), "lineup": lineup, "cues": cues} + + +# ---------------------------------------------------------------- §5 the receipt + +def leaf(e): + a = e["asset_id"].encode() + return B3.blake3(ENTRY_DOMAIN + struct.pack(">I", len(a)) + a + struct.pack(">QQ", e["start_ms"], e["duration_ms"]) + + bytes([KINDS[e["kind"]]])) + + +def schedule_root(entries): + return LAV.merkle([leaf(e) for e in entries]) + + +def aired_of(entries, ws, we): + return [i for i, e in enumerate(entries) if e["start_ms"] < we and e["start_ms"] + e["duration_ms"] > ws] + + +RECEIPT_MEMBERS = ["channel_id", "schedule_root", "schedule", "window_start_ms", "window_end_ms", "aired", + "work_schedule_ops", "joules_micro", "energy_provenance", "grant", "parent_receipt_hash", "signer_pubkey", + "signer_id", "sig"] + + +def u64(v): + return isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= U64 + + +def hexn(v, n): + if not isinstance(v, str) or len(v) != 2 * n or any(c not in "0123456789abcdef" for c in v): + return None + return bytes.fromhex(v) + + +def read_receipt(b): + """§5: the receipt, or None.""" + v = strict_json(b) + if not isinstance(v, dict): + return None + label = v.get("energy_provenance", 0) + if label is not None and label not in LAV.EVIDENCE: + return None + if not exactly(v, RECEIPT_MEMBERS + LAV.EVIDENCE.get(label, [])): + return None + r = {k: v[k] for k in ("channel_id", "signer_id")} + if not all(isinstance(r[k], str) for k in r): + return None + for k, n in (("schedule_root", 32), ("signer_pubkey", 32), ("sig", 64)): + r[k] = hexn(v[k], n) + if r[k] is None: + return None + for k in ("window_start_ms", "window_end_ms", "work_schedule_ops", "joules_micro"): + if not u64(v[k]): + return None + r[k] = v[k] + if not isinstance(v["aired"], list) or not all(u64(i) for i in v["aired"]): + return None + r["aired"] = v["aired"] + if not isinstance(v["schedule"], list): + return None + r["schedule"] = [] + for e in v["schedule"]: + if not (exactly(e, ["asset_id", "start_ms", "duration_ms", "kind"]) and isinstance(e["asset_id"], str) + and u64(e["start_ms"]) and u64(e["duration_ms"]) and e["kind"] in KINDS + and e["start_ms"] + e["duration_ms"] <= U64): + return None + r["schedule"].append(e) + g = v["grant"] + if not (exactly(g, ["grant_hash", "capability", "joule_ceiling_micro", "funds_ceiling"]) and isinstance(g["capability"], str) + and u64(g["joule_ceiling_micro"]) and (g["funds_ceiling"] is None or u64(g["funds_ceiling"])) + and hexn(g["grant_hash"], 32) is not None): + return None + r["grant"] = dict(g, grant_hash=hexn(g["grant_hash"], 32)) + p = v["parent_receipt_hash"] + r["parent"] = None if p is None else hexn(p, 32) + if p is not None and r["parent"] is None: + return None + r["class"] = None + if label is not None: + c = {"label": label} + if label in ("OnChipCounter", "CalibratedInstrument"): + unc = v["energy_uncertainty"] + if not (exactly(unc, ["relative_ppm", "window_us"]) and u64(unc["relative_ppm"]) and unc["relative_ppm"] <= U32 + and u64(unc["window_us"])): + return None + c["ppm"], c["window"] = unc["relative_ppm"], unc["window_us"] + if label == "OnChipCounter": + if v["energy_counter_filtering"] not in LAV.FILTERING: + return None + c["filtering"] = LAV.FILTERING[v["energy_counter_filtering"]] + if label == "CalibratedInstrument": + if not isinstance(v["energy_calibration_ref"], str): + return None + c["ref"] = v["energy_calibration_ref"] + r["class"] = c + return r + + +def receipt_payload(r): + cid, sid, cap = r["channel_id"].encode(), r["signer_id"].encode(), r["grant"]["capability"].encode() + g = r["grant"] + out = [RECEIPT_DOMAIN, struct.pack(">I", len(cid)), cid, r["schedule_root"], + struct.pack(">QQQQ", r["window_start_ms"], r["window_end_ms"], r["work_schedule_ops"], r["joules_micro"]), + LAV.class_bytes(r["class"]), g["grant_hash"], struct.pack(">I", len(cap)), cap, struct.pack(">Q", g["joule_ceiling_micro"]), + b"\x00" if g["funds_ceiling"] is None else b"\x01" + struct.pack(">Q", g["funds_ceiling"]), + b"\x00" if r["parent"] is None else b"\x01" + r["parent"], + struct.pack(">Q", len(r["aired"]))] + [struct.pack(">Q", i) for i in r["aired"]] + out += [struct.pack(">Q", len(r["schedule"]))] + [leaf(e) for e in r["schedule"]] + out += [r["signer_pubkey"], struct.pack(">I", len(sid)), sid] + return b"".join(out) + + +def receipt_identity(r): + return B3.blake3(RECEIPT_ID_DOMAIN + receipt_payload(r) + r["sig"]) + + +def receipt_rules(r): + """§5's rules: True when they hold.""" + s = r["schedule"] + if "receipt_rules" in SKIP: + return True + if schedule_root(s) != r["schedule_root"] or r["work_schedule_ops"] != len(s): + return False + if r["window_start_ms"] >= r["window_end_ms"]: + return False + if any(s[i]["start_ms"] + s[i]["duration_ms"] != s[i + 1]["start_ms"] for i in range(len(s) - 1)): + return False + if aired_of(s, r["window_start_ms"], r["window_end_ms"]) != r["aired"]: + return False + c, j = r["class"], r["joules_micro"] + if c is None: + return j == 0 + if j == 0: + return False + if c["label"] in ("OnChipCounter", "CalibratedInstrument") and (c["ppm"] == 0 or c["window"] == 0 or j * c["ppm"] < LAV.FLOOR): + return False + return not (c["label"] == "CalibratedInstrument" and all(ch in LAV.WHITE_SPACE for ch in c["ref"])) + + +# ---------------------------------------------------------------- §6 the lineup + +def lshort(s): + b = s.encode() + return bytes([len(b)]) + b + + +def llong(s): + b = s.encode() + return struct.pack(" len(self.b): + raise ValueError("short") + out = self.b[self.p:self.p + n] + self.p += n + return out + + def u(self, fmt): + return struct.unpack(fmt, self.take(struct.calcsize(fmt)))[0] + + def short(self): + return self.take(self.u("= e or any(ps[i] >= ps[i + 1] for i in range(p - 1)) or any(x % 1000 for x in ps): + return None + a = c.u(" 16: + return None + x["categories"] = [c.short() for _ in range(n)] + x["rating_system"], x["rating_value"] = c.short(), c.short() + k = c.u(" 8: + return None + x["artwork"] = [(c.take(32), c.long()) for _ in range(k)] + x["geo"] = c.u("= x["asset_id"].encode()): + return None + l["assets"].append(x) + l["signer"], l["sig"] = c.take(32), c.take(64) + return l if c.p == len(b) else None + except (ValueError, UnicodeDecodeError, struct.error): + return None + + +def lineup_digest(l): + return sha(lineup_body(l, l["signer"])) + + +def spans(l): + ps = l["programmes"] + return [(s, ps[k + 1] if k + 1 < len(ps) else l["schedule_end_ms"]) for k, s in enumerate(ps)] + + +def programme_assets(l, sched): + """§6.5 rule 4: each programme's asset, or None.""" + out = [] + for s, e in spans(l): + ids = [x["asset_id"] for x in sched if x["kind"] == "program" and s <= x["start_ms"] < e] + if not ids or any(i != ids[0] for i in ids): + return None + out.append(ids[0]) + return out + + +def describes(l, r): + """§6.5.""" + if "describes" in SKIP: + return True + s = r["schedule"] + if not s or l["channel_id"] != r["channel_id"] or l["schedule_root"] != r["schedule_root"]: + return False + if l["schedule_start_ms"] != s[0]["start_ms"] or l["schedule_end_ms"] != s[-1]["start_ms"] + s[-1]["duration_ms"]: + return False + starts = {x["start_ms"] for x in s} + if any(p not in starts for p in l["programmes"]): + return False + ids = programme_assets(l, s) + return ids is not None and sorted(set(ids), key=str.encode) == [a["asset_id"] for a in l["assets"]] + + +# ---------------------------------------------------------------- §7 the guide + +def all_programmes(r, l): + ids = programme_assets(l, r["schedule"]) + by = {a["asset_id"]: a for a in l["assets"]} + return [(s, e, by[i]) for (s, e), i in zip(spans(l), ids)] + + +def esc_text(s): + return s.replace("&", "&").replace("<", "<").replace(">", ">") + + +def esc_attr(s): + return esc_text(s).replace('"', """).replace("\t", " ").replace("\n", " ") + + +def xmltv_guide(r, l, frm, to): + """§7.2.""" + ch = esc_attr(l["channel_id"]) + o = ['\n', + '\n' % (GUIDE_URN, r["schedule_root"].hex(), lineup_digest(l).hex(), frm, to), + ' \n %s\n \n' % (ch, esc_text(l["channel_name"]))] + for s, e, a in all_programmes(r, l): + if not (s < to and e > frm): + continue + o.append(' \n' % (xmltv_format(s), xmltv_format(e), ch)) + o.append(" %s\n" % esc_text(a["title"])) + if a["description"]: + o.append(" %s\n" % esc_text(a["description"])) + o += [" %s\n" % esc_text(c) for c in a["categories"]] + o += [' \n' % esc_attr(u) for _, u in a["artwork"]] + o.append(' %s\n' % (ID_SYSTEM, esc_text(a["asset_id"]))) + if a["rating_value"]: + o.append(' \n' % esc_attr(a["rating_system"]) if a["rating_system"] else " \n") + o.append(" %s\n \n" % esc_text(a["rating_value"])) + o.append(" \n") + o.append("\n") + return "".join(o).encode("utf-8") + + +def now_and_next(progs, at): + for k, p in enumerate(progs): + if p[0] <= at < p[1]: + return p, progs[k + 1] if k + 1 < len(progs) else None + if progs and at < progs[0][0]: + return None, progs[0] + return None, None + + +def programme_doc(p): + s, e, a = p + geo = None + if a["geo"] == ONLY: + geo = {"allow": a["countries"]} + elif a["geo"] == EXCEPT: + geo = {"deny": a["countries"]} + return {"start_ms": s, "stop_ms": e, "asset_id": a["asset_id"], "title": a["title"], "description": a["description"], + "content_uri": a["content_uri"], "categories": a["categories"], + "rating": {"system": a["rating_system"], "value": a["rating_value"]} if a["rating_value"] else None, + "artwork": [{"sha256": h.hex(), "uri": u} for h, u in a["artwork"]], "geo": geo, + "rights_holder": a["rights_holder"], "duration_ms": a["duration_ms"]} + + +def jcs(v): + """RFC 8785 over this document's values: sorted members (ASCII names), no space, strings as + JSON escapes them.""" + return json.dumps(v, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + + +def now_next_doc(r, l, at): + """§7.3.""" + now, nxt = now_and_next(all_programmes(r, l), at) + v = {"kind": NOW_NEXT_KIND, "channel": l["channel_id"], "channel_name": l["channel_name"], + "schedule_root": r["schedule_root"].hex(), "lineup": lineup_digest(l).hex(), "at_ms": at, + "now": programme_doc(now) if now else None, "next": programme_doc(nxt) if nxt else None} + return (jcs(v) + "\n").encode("utf-8") + + +def plain_uint(s): + return digits(s) and (s == "0" or s[0] != "0") + + +def read_guide(b): + """§8.2 step 8: the guide as read, or None.""" + if b[:1] == b"<": + root = read_feed_xml(b) + if root is None or root["name"] != "tv": + return None + url = root["attrs"].get("source-data-url", "") + if not url.startswith(GUIDE_URN): + return None + parts = url[len(GUIDE_URN):].split(":") + if len(parts) != 4 or not all(hexn(x, 32) is not None for x in parts[:2]) or not all(plain_uint(x) for x in parts[2:]): + return None + frm, to = int(parts[2]), int(parts[3]) + if frm >= to: + return None + progs = [] + for p in SPV.kids(root, "programme"): + try: + s, e = xmltv_time(p["attrs"].get("start", "")), xmltv_time(p["attrs"].get("stop", "")) + except Refuse: + return None + ids = [SPV.text(x) for x in SPV.kids(p, "episode-num") if x["attrs"].get("system", "onscreen") == ID_SYSTEM] + if not ids: + return None + progs.append((s, e, ids[0])) + return {"format": "xmltv", "root": parts[0], "lineup": parts[1], "from": frm, "to": to, "programmes": progs} + if b[:1] == b"{": + v = strict_json(b) + if not exactly(v, ["kind", "channel", "channel_name", "schedule_root", "lineup", "at_ms", "now", "next"]): + return None + if not jint(v["at_ms"]) or not isinstance(v["schedule_root"], str) or not isinstance(v["lineup"], str): + return None + out = [] + for k in ("now", "next"): + p = v[k] + if p is None: + out.append(None) + elif isinstance(p, dict) and u64(p.get("start_ms")) and u64(p.get("stop_ms")) and isinstance(p.get("asset_id"), str): + out.append((p["start_ms"], p["stop_ms"], p["asset_id"])) + else: + return None + return {"format": "now-next", "root": v["schedule_root"], "lineup": v["lineup"], "at": v["at_ms"], "now": out[0], + "next": out[1]} + return None + + +# ---------------------------------------------------------------- §8 verification + +def verify(receipt, lineup, guide): + """§8.2 and §8.3: the facts.""" + f = {"verdict": "verified", "part": None, "receipt": None, "lineup": None, "guide": None, "entry": None} + + def fail(part, reason): + f["verdict"], f["part"] = reason, part + return f + + r = read_receipt(receipt) + if r is None: + return fail("receipt", "channel_receipt_malformed") + f["receipt"] = receipt_identity(r).hex() + bad = ED.verify(r["signer_pubkey"], receipt_payload(r), r["sig"]) if "receipt_sig" not in SKIP else None + if bad: + return fail("receipt", bad) + if not receipt_rules(r): + return fail("receipt", "channel_receipt_invalid") + l = read_lineup(lineup) + if l is None: + return fail("lineup", "lineup_malformed") + f["lineup"] = lineup_digest(l).hex() + bad = ED.verify(l["signer"], LINEUP_DOMAIN + lineup_body(l, l["signer"]), l["sig"]) if "lineup_sig" not in SKIP else None + if bad: + return fail("lineup", bad) + if l["signer"] != r["signer_pubkey"] and "signer" not in SKIP: + return fail("lineup", "lineup_signer_mismatch") + if not describes(l, r): + return fail("lineup", "lineup_schedule_mismatch") + if guide is None: + return f + g = read_guide(guide) + if g is None: + return fail("guide", "guide_malformed") + sched = r["schedule"] + + def aired(s, e): + return any(sched[i]["start_ms"] < e and sched[i]["start_ms"] + sched[i]["duration_ms"] > s + for i in r["aired"] if i < len(sched)) + + progs = all_programmes(r, l) + if g["format"] == "xmltv": + shown = g["programmes"] + f["guide"] = {"format": "xmltv", "from_ms": g["from"], "to_ms": g["to"], "at_ms": None} + else: + shown = [p for p in (g["now"], g["next"]) if p is not None] + f["guide"] = {"format": "now-next", "from_ms": None, "to_ms": None, "at_ms": g["at"]} + f["guide"]["programmes"] = [{"start_ms": s, "stop_ms": e, "asset_id": i, "aired": aired(s, e)} for s, e, i in shown] + if (g["root"] != r["schedule_root"].hex() or g["lineup"] != f["lineup"]) and "source" not in SKIP: + return fail("guide", "guide_source_mismatch") + key = (lambda p: (p[0], p[1], p[2]["asset_id"])) + if g["format"] == "xmltv": + want = [key(p) for p in progs if p[0] < g["to"] and p[1] > g["from"]] + for i, p in enumerate(g["programmes"]): + if p not in want and "entries" not in SKIP: + f["entry"] = i + return fail("guide", "guide_entry_unscheduled") + expect = xmltv_guide(r, l, g["from"], g["to"]) + else: + wn, wx = now_and_next(progs, g["at"]) + for i, (got, w) in enumerate(((g["now"], wn), (g["next"], wx))): + if got != (key(w) if w else None) and "entries" not in SKIP: + f["entry"] = i + return fail("guide", "guide_entry_unscheduled") + expect = now_next_doc(r, l, g["at"]) + if guide != expect and "bytes" not in SKIP: + return fail("guide", "guide_mismatch") + return f + + +# ---------------------------------------------------------------- the corpus + +def apply_edits(files, edits): + out = dict(files) + for e in edits: + b = out[e["file"]] + if "pad" in e: + out[e["file"]] = b + b" " * e["pad"] + continue + s = b.decode("utf-8") + if s.count(e["find"]) != 1: + raise ValueError("%s: an edit's find does not occur once" % e["file"]) + with_ = e["find"] + e["replace"] * e["repeat"] if "repeat" in e else e["replace"] + out[e["file"]] = s.replace(e["find"], with_, 1).encode("utf-8") + return out + + +def import_inputs(v, files): + inp = dict(v["inputs"]) + inp["artwork_files"] = {n: files[n] for n in inp["artwork"]} + if inp["window"] is not None: + inp["window"] = tuple(inp["window"]) + return inp + + +def check_import(doc, v, files, op_key): + """None when the import gets the outcome the corpus states, else why not.""" + files = apply_edits(files, v["edits"]) + o = v["outcome"] + try: + got = do_import(import_inputs(v, files), files) + except Refuse as e: + return None if o.get("refused") == e.reason else "refused %s, the corpus states %s" % (e.reason, o) + if "refused" in o: + return "imported, the corpus states %s" % o + r = read_receipt(files[o["receipt"]]) + if r is None: + return "the receipt does not read" + inp = v["inputs"] + want_grant = {"grant_hash": bytes(32), "capability": "video.channel", "joule_ceiling_micro": U64, "funds_ceiling": None} + ws, we = got["window"] + same = (r["channel_id"] == inp["channel"] and r["schedule"] == got["entries"] and r["schedule_root"] == schedule_root(got["entries"]) + and (r["window_start_ms"], r["window_end_ms"]) == (ws, we) and r["aired"] == aired_of(got["entries"], ws, we) + and r["work_schedule_ops"] == len(got["entries"]) and r["joules_micro"] == 0 and r["class"] is None + and r["grant"] == want_grant and r["parent"] == (bytes.fromhex(inp["parent_receipt_hash"]) if inp["parent_receipt_hash"] else None) + and r["signer_pubkey"] == op_key and r["signer_id"] == inp["signer_id"]) + if not same: + return "the receipt is not the import's" + if ED.verify(r["signer_pubkey"], receipt_payload(r), r["sig"]) or receipt_identity(r).hex() != o["receipt_hash"]: + return "the receipt's signature or identity" + lb = bytes.fromhex(o["lineup"]) + body = lineup_body(got["lineup"], op_key) + if lb[:-64] != body or ED.verify(op_key, LINEUP_DOMAIN + body, lb[-64:]) or sha(body).hex() != o["lineup_digest"]: + return "the lineup is not the import's, or its signature does not verify" + if got["cues"] != o["cues"]: + return "the cues differ" + return None + + +def check_verification(v, files): + files = apply_edits(files, v["edits"]) + g = files[v["guide"]] if v["guide"] else None + facts = verify(files[v["receipt"]], bytes.fromhex(v["lineup"]), g) + return None if facts == v["facts"] else "states %s, the corpus %s" % (facts, v["facts"]) + + +def corpus_files(doc): + names = {"vectors.json", "README.md"} + for v in doc["imports"]: + i = v["inputs"] + names |= {x for x in (i["xmltv"], i["json"], i["playlist"]) if x} | {m["file"] for m in i["mrss"]} | set(i["artwork"]) + if "receipt" in v["outcome"]: + names.add(v["outcome"]["receipt"]) + for v in doc["verifications"]: + names |= {x for x in (v["receipt"], v["guide"]) if x} + return names + + +def check_all(doc, files, listing, quiet=False): + """The failures of every vector and of the corpus as a whole.""" + fails = [] + op_key = bytes.fromhex(doc["operator"]["key"]) + for v in doc["imports"]: + try: + why = check_import(doc, v, files, op_key) + except (KeyError, ValueError, TypeError) as e: + why = "broken: %r" % (e,) + if why: + fails.append("import %s: %s" % (v["name"], why)) + for v in doc["verifications"]: + try: + why = check_verification(v, files) + except (KeyError, ValueError, TypeError) as e: + why = "broken: %r" % (e,) + if why: + fails.append("verification %s: %s" % (v["name"], why)) + added, reused = reasons() + seen = {v["outcome"]["refused"] for v in doc["imports"] if "refused" in v["outcome"]} + seen |= {v["facts"]["verdict"] for v in doc["verifications"]} + for r in added + reused: + if r not in seen: + fails.append("the corpus gives no %s" % r) + for r in seen - set(added) - set(reused) - {"verified"}: + fails.append("%s is not a reason of §10" % r) + if listing != corpus_files(doc): + fails.append("the files are not the corpus's: %s" % sorted(listing ^ corpus_files(doc))) + if not quiet: + for f in fails: + print("FAIL", f) + return fails + + +def load(d): + listing = set(os.listdir(d)) + files = {} + for n in listing: + with open(os.path.join(d, n), "rb") as f: + files[n] = f.read() + return json.loads(files["vectors.json"]), files, listing + + +def flip(h, at): + c = "0" if h[at] != "0" else "1" + return h[:at] + c + h[at + 1:] + + +def mutations(doc, files, listing): + """Copies of the corpus with one defect each: every one must fail.""" + out = [] + + def m(name, f): + d, fs, ls = copy.deepcopy(doc), dict(files), set(listing) + f(d, fs, ls) + out.append((name, d, fs, ls)) + + imp = lambda d, n: next(v for v in d["imports"] if v["name"] == n) # noqa: E731 + ver = lambda d, n: next(v for v in d["verifications"] if v["name"] == n) # noqa: E731 + m("a lineup byte of the sample's outcome", lambda d, fs, ls: imp(d, "sample")["outcome"].__setitem__("lineup", flip(imp(d, "sample")["outcome"]["lineup"], 120))) + m("the sample's receipt hash", lambda d, fs, ls: imp(d, "sample")["outcome"].__setitem__("receipt_hash", flip(imp(d, "sample")["outcome"]["receipt_hash"], 0))) + m("a cue's payload", lambda d, fs, ls: imp(d, "sample")["outcome"]["cues"][0].__setitem__("payload", flip(imp(d, "sample")["outcome"]["cues"][0]["payload"], 4))) + m("a cue's start", lambda d, fs, ls: imp(d, "sample")["outcome"]["cues"][1].__setitem__("start_ms", 1)) + m("a refusal's code", lambda d, fs, ls: imp(d, "gap")["outcome"].__setitem__("refused", "import_overlap")) + m("an edit dropped", lambda d, fs, ls: imp(d, "no_stop").__setitem__("edits", [])) + m("a verdict", lambda d, fs, ls: ver(d, "guide_mismatch")["facts"].__setitem__("verdict", "verified")) + m("an aired flag", lambda d, fs, ls: ver(d, "xmltv_guide")["facts"]["guide"]["programmes"][2].__setitem__("aired", True)) + m("the unscheduled entry's index", lambda d, fs, ls: ver(d, "guide_entry_unscheduled")["facts"].__setitem__("entry", 1)) + m("a guide's title", lambda d, fs, ls: fs.__setitem__("sample.guide.xml", fs["sample.guide.xml"].replace(b"Tidepool Kitchen", b"Tidepool Kitchens"))) + m("a now/next title", lambda d, fs, ls: fs.__setitem__("sample.now-next.json", fs["sample.now-next.json"].replace(b"Tidepool Kitchen", b"Tidepool Kitchens"))) + m("an artwork file", lambda d, fs, ls: fs.__setitem__("art-tk-7.svg", fs["art-tk-7.svg"] + b" ")) + m("a catalogue's duration", lambda d, fs, ls: fs.__setitem__("sample-a.mrss.xml", fs["sample-a.mrss.xml"].replace(b'duration="1620"/>', b'duration="1621"/>', 1))) + m("a break's start", lambda d, fs, ls: fs.__setitem__("sample.m3u8", fs["sample.m3u8"].replace(b"18:45:30.500Z", b"18:45:31.500Z"))) + m("an SCTE-35 section", lambda d, fs, ls: fs.__setitem__("sample.m3u8", fs["sample.m3u8"].replace(b"03E97FEFFE", b"03EA7FEFFE", 1))) + m("the receipt's window", lambda d, fs, ls: fs.__setitem__("sample.channel.json", fs["sample.channel.json"].replace(b'"window_end_ms": 1793300400000', b'"window_end_ms": 1793300401000'))) + m("a file added", lambda d, fs, ls: (fs.__setitem__("extra.txt", b"x"), ls.add("extra.txt"))) + m("a vector of a reason dropped", lambda d, fs, ls: d.__setitem__("imports", [v for v in d["imports"] if v["name"] != "window_outside"])) + return out + + +def main(corpus_dir): + global SKIP + doc, files, listing = load(corpus_dir) + if check_all(doc, files, listing): + return 1 + print("channel lineup corpus: %d imports and %d verifications hold" % (len(doc["imports"]), len(doc["verifications"]))) + bad = 0 + for name, d, fs, ls in mutations(doc, files, listing): + if not check_all(d, fs, ls, quiet=True): + print("FAIL mutation not caught:", name) + bad += 1 + print("mutations: %d copies, %d not caught" % (len(mutations(doc, files, listing)), bad)) + checks = ["inputs", "size", "fields", "timezone", "no_stop", "asset", "duplicate", "counts", "doctype", "conflict", + "scte35", "break_duration", "duration_unknown", "gap", "overlap", "whole_seconds", "owner", "artwork", + "break_overlap", "break_outside", "window", "receipt_sig", "receipt_rules", "lineup_sig", "signer", + "describes", "source", "entries", "bytes"] + weak = 0 + for c in checks: + SKIP = {c} + try: + caught = bool(check_all(doc, files, listing, quiet=True)) + except Exception: + caught = True + if not caught: + print("FAIL weakened verifier not caught: without", c) + weak += 1 + SKIP = set() + print("weakened verifiers: %d checks dropped one at a time, %d not caught" % (len(checks), weak)) + return 1 if bad or weak else 0 + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print(__doc__) + sys.exit(2) + sys.exit(main(sys.argv[1])) diff --git a/wai/live-conformance/channels/README.md b/wai/live-conformance/channels/README.md index 756b24a96..e1093d230 100644 --- a/wai/live-conformance/channels/README.md +++ b/wai/live-conformance/channels/README.md @@ -41,5 +41,6 @@ three content owners, with four ad breaks: 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), and `{"file", "pad"}` appends that many space bytes. Each control is +once is a broken vector); with `"repeat": n` it keeps `find` and writes `replace` n times after +it; and `{"file", "pad"}` appends that many space bytes. Each control is the sample with one such change, or with one input changed. diff --git a/wai/live-conformance/channels/vectors.json b/wai/live-conformance/channels/vectors.json index cf186a69d..c91f6bbd3 100644 --- a/wai/live-conformance/channels/vectors.json +++ b/wai/live-conformance/channels/vectors.json @@ -158,6 +158,122 @@ "cues": [] } }, + { + "name": "oversized_count", + "about": "the JSON form with more than 10 000 breaks", + "inputs": { + "channel": "sample-1", + "xmltv": null, + "mrss": [], + "json": "sample.json", + "playlist": null, + "owner": null, + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample.json", + "find": "\"breaks\": [", + "replace": "{\"id\": \"x\", \"start_ms\": 0, \"duration_ms\": 1, \"scte35\": null}, ", + "repeat": 10000 + } + ], + "outcome": { + "refused": "import_oversized" + } + }, + { + "name": "not_whole_second", + "about": "a programme boundary half a second past a whole second, which an XMLTV guide cannot state", + "inputs": { + "channel": "sample-1", + "xmltv": null, + "mrss": [], + "json": "sample.json", + "playlist": null, + "owner": null, + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample.json", + "find": "\"stop_ms\": 1793298600000", + "replace": "\"stop_ms\": 1793298600500" + }, + { + "file": "sample.json", + "find": "\"start_ms\": 1793298600000", + "replace": "\"start_ms\": 1793298600500" + } + ], + "outcome": { + "refused": "import_time_malformed" + } + }, + { + "name": "break_overlap", + "about": "a break that begins before the one before it ends", + "inputs": { + "channel": "sample-1", + "xmltv": "sample.xmltv.xml", + "mrss": [ + { + "file": "sample-a.mrss.xml", + "owner": "owner-a" + }, + { + "file": "sample-b.mrss.xml", + "owner": "owner-b" + } + ], + "json": null, + "playlist": "sample.m3u8", + "owner": "owner-c", + "id_system": null, + "xmltv_channel": null, + "start_ms": null, + "artwork": [ + "art-hl-101.svg", + "art-hl-102.svg", + "art-nf-1.svg", + "art-tk-7.svg" + ], + "window": [ + 1793296800000, + 1793300400000 + ], + "signer_id": "sample-operator", + "parent_receipt_hash": null + }, + "edits": [ + { + "file": "sample.m3u8", + "find": "ID=\"brk-2\",START-DATE=\"2026-10-29T18:28:00.000Z\"", + "replace": "ID=\"brk-2\",START-DATE=\"2026-10-29T18:13:00.000Z\"" + } + ], + "outcome": { + "refused": "import_overlap" + } + }, { "name": "inputs_invalid", "about": "an XMLTV document beside the JSON form", diff --git a/wai/wai-rs/src/channel_lineup/corpus.rs b/wai/wai-rs/src/channel_lineup/corpus.rs index ec378e1a0..adf4972ce 100644 --- a/wai/wai-rs/src/channel_lineup/corpus.rs +++ b/wai/wai-rs/src/channel_lineup/corpus.rs @@ -308,7 +308,8 @@ fn json_form(l: &Lineup) -> String { // --------------------------------------------------------------------------------------- /// Apply a vector's edits to a copy of the files it reads: each `{"file", "find", "replace"}` -/// replaces the one occurrence of `find`, and each `{"file", "pad"}` appends that many spaces. +/// replaces the one occurrence of `find`; `{"file", "find", "replace", "repeat": n}` writes +/// `replace` n times after it instead; and each `{"file", "pad"}` appends that many spaces. pub fn apply_edits(files: &BTreeMap>, edits: &Value) -> Result>, String> { let mut out = files.clone(); for e in edits.as_array().ok_or("edits: an array")? { @@ -323,7 +324,11 @@ pub fn apply_edits(files: &BTreeMap>, edits: &Value) -> Result format!("{find}{}", replace.repeat(n as usize)), + None => replace.to_string(), + }; + *b = s.replacen(find, &with, 1).into_bytes(); } Ok(out) } @@ -421,6 +426,9 @@ fn import_vectors() -> Vec<(&'static str, &'static str, Value, Value, Option", "]>")]), Some(ImportXmlMalformed)), -- 2.54.0 (Apple Git-157) From 01053b3874c5495543eb07e85d0c2c0ff43a75e9 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 29 Oct 2026 17:23:18 -0400 Subject: [PATCH 05/11] wai: register wai.video.lineup, its magic WLU1, and the CI step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The record in the registry's table, with its wire identifiers (WLU1, wai-channel-feed, urn:wai:guide:, wai-asset-id, wai-now-next) and the export regenerated; SPEC §5's paragraph and the CHANGELOG entry. The lineup's magic is WLU1: WCL1 is live-recording's link record, which the registry's test of wire identifiers caught. The conformance workflow runs the module's tests, tests/channel_lineup.rs, the corpus tool's verify and channel_lineup_verify.py, and builds the feature alone; the catalogue workflow fires on the corpus, which tests/cli_ops.rs reads. Studio's generated actions follow the catalogue. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-catalogue.yml | 2 + .github/workflows/wai-conformance.yml | 35 ++++++-- wai/CHANGELOG.md | 26 ++++++ wai/REGISTRY.md | 6 ++ wai/SPEC.md | 15 ++++ wai/catalogue/ops.json | 4 +- wai/extensions/channel-lineup.md | 14 ++-- wai/live-conformance/channel_lineup_verify.py | 2 +- wai/live-conformance/channels/README.md | 2 +- .../channels/sample.after.now-next.json | 2 +- .../channels/sample.before.now-next.json | 2 +- .../channels/sample.guide.xml | 2 +- .../channels/sample.now-next.json | 2 +- .../channels/sample.part.guide.xml | 2 +- wai/live-conformance/channels/vectors.json | 80 +++++++++---------- wai/registry/capabilities.json | 49 ++++++++++++ wai/vocab/v2.ttl | 7 ++ wai/wai-rs/Cargo.toml | 2 +- wai/wai-rs/src/bin/wai_cli/dx/channel.rs | 2 +- wai/wai-rs/src/catalogue.rs | 4 +- wai/wai-rs/src/channel_lineup.rs | 6 +- wai/wai-rs/src/channel_lineup/tests.rs | 5 ++ wai/wai-rs/src/codecs/registry.rs | 9 +++ wai/wai-rs/src/lib.rs | 2 +- wai/wai-rs/tests/channel_lineup.rs | 8 +- wai/wai-rs/tests/cli_ops.rs | 4 +- wai/wai-studio/src/actions.generated.js | 5 +- 27 files changed, 223 insertions(+), 76 deletions(-) diff --git a/.github/workflows/wai-catalogue.yml b/.github/workflows/wai-catalogue.yml index ecda70c91..c951fe05c 100644 --- a/.github/workflows/wai-catalogue.yml +++ b/.github/workflows/wai-catalogue.yml @@ -28,6 +28,7 @@ on: - 'wai/http-conformance/**' - 'wai/score-conformance/**' - 'wai/live-conformance/fleet/**' + - 'wai/live-conformance/channels/**' - 'wai/REGISTRY.md' - 'wai/LICENSE' - 'wai/wai-quantum/**' @@ -48,6 +49,7 @@ on: - 'wai/http-conformance/**' - 'wai/score-conformance/**' - 'wai/live-conformance/fleet/**' + - 'wai/live-conformance/channels/**' - 'wai/REGISTRY.md' - 'wai/LICENSE' - 'wai/wai-quantum/**' diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml index 255a565cf..4c533a393 100644 --- a/.github/workflows/wai-conformance.yml +++ b/.github/workflows/wai-conformance.yml @@ -142,6 +142,10 @@ on: # reference adds, stitched_presentation_verify.py reads that table, and the registry's # wire identifiers test reads its sections. - 'wai/extensions/stitched-presentation.md' + # tests/channel_lineup.rs requires this extension's §10 to list the codes the reference + # adds and runs its §9's commands as the text writes them, channel_lineup_verify.py reads + # its §10, and the registry's wire identifiers test reads its sections. + - 'wai/extensions/channel-lineup.md' # tests/live_provenance.rs requires this extension's §12 to list the codes and the C2PA # codes the reference reports, c2pa_live_verify.py reads that table, and the registry's # wire identifiers test reads its sections. @@ -1155,7 +1159,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 + 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,channel_lineup WAI_PQ_FEATURES: synthesis,pq_signatures WAI_RELEASE_FEATURES: dred_exact,synthesis_mark,robustness,avatar_face steps: @@ -1225,11 +1229,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. @@ -1250,7 +1256,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 conform_result --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 @@ -1711,6 +1717,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, @@ -2664,7 +2689,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; do + conform_result channel_lineup; do echo "::group::$f" cargo check --no-default-features --features "$f" --tests echo "::endgroup::" diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 470e412cc..a09d6faa3 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,6 +6,32 @@ wire format may change and such changes are recorded here. ## Unreleased +### 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**: `wai.video.channel` receipts keep their signed bytes; the text + gives them a JSON carriage, their payload and identity, which a second implementation reads. +- **Operations** `channel.import`, `channel.guide` and `channel.verify` (`wai channel ...`), with + the agent rules; `quickstart`, the shipped `wai`'s build, holds the feature `channel_lineup`. +- **Conformance**: `live-conformance/channels/`, a sample channel of three owners with a control + of each reason, written from fixed seeds by `wai_channel_lineup_vectors`; its second + implementation, `channel_lineup_verify.py`, from the text alone, agrees on every vector, and a + CI step runs both. + ### wai-quantum for JavaScript: the engine as an npm package - **`@transaction-science/wai-quantum` 0.1.0** (`wai/wai-quantum-js/`): the job service's engine for WebAssembly diff --git a/wai/REGISTRY.md b/wai/REGISTRY.md index b56e889e5..800fd301a 100644 --- a/wai/REGISTRY.md +++ b/wai/REGISTRY.md @@ -311,6 +311,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 | @@ -403,6 +404,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 | @@ -495,11 +497,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 | @@ -508,6 +512,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 | @@ -529,6 +534,7 @@ and a role are fixed once registered. | `wai-membership` | document-kind | defined | contribution §3.2 | | `wai-module-grant` | document-kind | defined | jwp-receipts §7.9 | | `wai-module-grants` | document-kind | defined | http-delivery §8.4 | +| `wai-now-next` | document-kind | defined | channel-lineup §7.3 | | `wai-origin` | document-kind | defined | synthesis §3.1 | | `wai-pick` | document-kind | defined | exploration §10.2 | | `wai-player` | document-kind | defined | synthesis §10.3 | diff --git a/wai/SPEC.md b/wai/SPEC.md index e9d290e44..6733c00e0 100644 --- a/wai/SPEC.md +++ b/wai/SPEC.md @@ -1302,6 +1302,21 @@ spend that counts, by currency; an ad whose segments were fetched ahead of its b presented has no claim and counts nothing: [`extensions/stitched-presentation.md`](extensions/stitched-presentation.md). +And for a **linear channel's lineup and guide** — `wai.video.lineup`: the channel operator's +signed lineup, `WLU1`, of one `wai.video.channel` schedule, bound to it by the schedule root: each +programme asset's title, description, categories, rating, artwork by content hash, the countries +it may be shown in and its rights holder, and where each programme of the guide begins. A channel +as operators exchange it (an XMLTV schedule, Media RSS catalogues or a JSON form of both, with the +SCTE-35 date ranges of its HLS Media Playlist as its ad breaks) is imported into the schedule's +receipt, whose signed bytes are unchanged, the lineup, and a live-events `cue` for each break. The +import reads bounded documents over an XML subset that defines no entity, and refuses, each with +its reason, a gap, an overlap, a programme with no stop, a time zone named by letters, a break +outside its programme and a window outside the schedule. The guide, an XMLTV document or a +now/next document for a player's guide row, is rendered from the receipt and the lineup and names +both, so a verifier recomputes it and compares, and states of each of its programmes whether the +receipt's program-as-run says it aired: +[`extensions/channel-lineup.md`](extensions/channel-lineup.md). + And for **watching together** in a room — `wai.live.cowatch`: people in different places watching one source in sync, with the sync measured. The room authority signs a co-watch policy, `WXP1`, naming the source (a WAI package, a sequence of on-demand objects, or another diff --git a/wai/catalogue/ops.json b/wai/catalogue/ops.json index 1b384c18f..751156715 100644 --- a/wai/catalogue/ops.json +++ b/wai/catalogue/ops.json @@ -3310,7 +3310,7 @@ "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.wcl) and the cues (cues.json), written to D (.); a refusal's code is its reason; replacing a file waits", + " (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", @@ -3454,7 +3454,7 @@ "out_dir": { "type": "string", "format": "path", - "description": "Where channel.json, lineup.wcl and cues.json are written (.).", + "description": "Where channel.json, lineup.wlu and cues.json are written (.).", "x-cli": { "flag": "--out-dir" } diff --git a/wai/extensions/channel-lineup.md b/wai/extensions/channel-lineup.md index da5f9aea5..f781e8665 100644 --- a/wai/extensions/channel-lineup.md +++ b/wai/extensions/channel-lineup.md @@ -1,7 +1,7 @@ # WAI Extension: Channel Lineup and Guide > Status: Draft. Registers the record `wai.video.lineup`: a linear channel's signed **lineup**, -> `WCL1`, which describes each programme asset of one channel schedule (its title, description, +> `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 @@ -464,7 +464,7 @@ instrument's reference holding a character that is not Unicode White_Space. A re | size | field | meaning | |---|---|---| -| 4 | `magic` | `WCL1` (ASCII) | +| 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 | @@ -518,7 +518,7 @@ A reader reads exactly the bytes it is given as one lineup, in the order of §6. `lineup_malformed` when the bytes end before a field the layout requires or go on after `sig`, or when: -1. its magic is not `WCL1` or its version not 1; +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; @@ -680,7 +680,7 @@ Three operations of the WAI command line carry this text (the operation catalogu `--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.wcl`) and the cues (`cues.json`) out, into a directory. A + 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 @@ -695,9 +695,9 @@ 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.wcl --out guide.xml -wai channel guide --receipt out/channel.json --lineup out/lineup.wcl --now-next --at 1793299545000 --out now-next.json -wai channel verify --receipt out/channel.json --lineup out/lineup.wcl --guide guide.xml +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 diff --git a/wai/live-conformance/channel_lineup_verify.py b/wai/live-conformance/channel_lineup_verify.py index 2f2f4aab3..579785dbd 100644 --- a/wai/live-conformance/channel_lineup_verify.py +++ b/wai/live-conformance/channel_lineup_verify.py @@ -47,7 +47,7 @@ import stitched_presentation_verify as SPV # noqa: E402 EXTENSION = os.path.join(HERE, "..", "extensions", "channel-lineup.md") -MAGIC, VERSION = b"WCL1", 1 # §6.1 +MAGIC, VERSION = b"WLU1", 1 # §6.1 LINEUP_DOMAIN = b"wai:channel-lineup\x01" # §6.3 ENTRY_DOMAIN = b"wai:video-channel-entry\x01" # §5 RECEIPT_DOMAIN = b"wai:video-channel-receipt\x01" diff --git a/wai/live-conformance/channels/README.md b/wai/live-conformance/channels/README.md index e1093d230..59d200331 100644 --- a/wai/live-conformance/channels/README.md +++ b/wai/live-conformance/channels/README.md @@ -2,7 +2,7 @@ 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 -`WCL1` lineup, its guides, and what a verifier states of each. Every file but this one is written +`WLU1` lineup, its guides, and what a verifier states of each. Every file but this one is written from fixed seeds by `wai_channel_lineup_vectors gen` (feature `channel_lineup`), and `wai_channel_lineup_vectors verify ` and `tests/channel_lineup.rs` require them byte for byte. The second implementation is [`../channel_lineup_verify.py`](../channel_lineup_verify.py), run as diff --git a/wai/live-conformance/channels/sample.after.now-next.json b/wai/live-conformance/channels/sample.after.now-next.json index 545a20159..2f70d9a2c 100644 --- a/wai/live-conformance/channels/sample.after.now-next.json +++ b/wai/live-conformance/channels/sample.after.now-next.json @@ -1 +1 @@ -{"at_ms":1793307600000,"channel":"sample-1","channel_name":"Sample One","kind":"wai-now-next","lineup":"72ad84006fc7b22799708d78b736407742b35ea2ab32918e9068d263290481c7","next":null,"now":null,"schedule_root":"d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b83"} +{"at_ms":1793307600000,"channel":"sample-1","channel_name":"Sample One","kind":"wai-now-next","lineup":"6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5","next":null,"now":null,"schedule_root":"d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b83"} diff --git a/wai/live-conformance/channels/sample.before.now-next.json b/wai/live-conformance/channels/sample.before.now-next.json index f126f42fe..2f8005a35 100644 --- a/wai/live-conformance/channels/sample.before.now-next.json +++ b/wai/live-conformance/channels/sample.before.now-next.json @@ -1 +1 @@ -{"at_ms":1793293200000,"channel":"sample-1","channel_name":"Sample One","kind":"wai-now-next","lineup":"72ad84006fc7b22799708d78b736407742b35ea2ab32918e9068d263290481c7","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"} +{"at_ms":1793293200000,"channel":"sample-1","channel_name":"Sample One","kind":"wai-now-next","lineup":"6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5","next":{"artwork":[{"sha256":"f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc","uri":"https://art.example/sample/art-hl-101.svg?w=320"}],"asset_id":"hl-101","categories":["Drama"],"content_uri":"https://media.example/a/hl-101/index.m3u8","description":"A lighthouse keeper's first winter.","duration_ms":1620000,"geo":{"allow":["CA","GB","US"]},"rating":{"system":"urn:v-chip","value":"tv-pg"},"rights_holder":"owner-a","start_ms":1793296800000,"stop_ms":1793298600000,"title":"Harbour Lights"},"now":null,"schedule_root":"d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b83"} diff --git a/wai/live-conformance/channels/sample.guide.xml b/wai/live-conformance/channels/sample.guide.xml index b2861ea02..272f0672e 100644 --- a/wai/live-conformance/channels/sample.guide.xml +++ b/wai/live-conformance/channels/sample.guide.xml @@ -1,5 +1,5 @@ - + Sample One diff --git a/wai/live-conformance/channels/sample.now-next.json b/wai/live-conformance/channels/sample.now-next.json index 48f6c6a44..5797e3449 100644 --- a/wai/live-conformance/channels/sample.now-next.json +++ b/wai/live-conformance/channels/sample.now-next.json @@ -1 +1 @@ -{"at_ms":1793299545000,"channel":"sample-1","channel_name":"Sample One","kind":"wai-now-next","lineup":"72ad84006fc7b22799708d78b736407742b35ea2ab32918e9068d263290481c7","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"} +{"at_ms":1793299545000,"channel":"sample-1","channel_name":"Sample One","kind":"wai-now-next","lineup":"6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5","next":{"artwork":[{"sha256":"6a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d3","uri":"https://art.example/sample/art-hl-102.svg?w=320"}],"asset_id":"hl-102","categories":["Drama"],"content_uri":"https://media.example/a/hl-102/index.m3u8","description":"The supply boat is late.","duration_ms":1620000,"geo":{"deny":["DE"]},"rating":{"system":"urn:v-chip","value":"tv-pg"},"rights_holder":"owner-a","start_ms":1793300400000,"stop_ms":1793302200000,"title":"Harbour Lights"},"now":{"artwork":[{"sha256":"1cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b860","uri":"https://art.example/sample/art-tk-7.svg?w=320"}],"asset_id":"tk-7","categories":["Food","Family"],"content_uri":"https://media.example/b/tk-7.m3u8","description":"Kelp, crab & lemon, on a beach stove.","duration_ms":1650000,"geo":null,"rating":null,"rights_holder":"owner-b","start_ms":1793298600000,"stop_ms":1793300400000,"title":"Tidepool Kitchen"},"schedule_root":"d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b83"} diff --git a/wai/live-conformance/channels/sample.part.guide.xml b/wai/live-conformance/channels/sample.part.guide.xml index fede4da64..aaaf062f9 100644 --- a/wai/live-conformance/channels/sample.part.guide.xml +++ b/wai/live-conformance/channels/sample.part.guide.xml @@ -1,5 +1,5 @@ - + Sample One diff --git a/wai/live-conformance/channels/vectors.json b/wai/live-conformance/channels/vectors.json index c91f6bbd3..971f27b15 100644 --- a/wai/live-conformance/channels/vectors.json +++ b/wai/live-conformance/channels/vectors.json @@ -44,8 +44,8 @@ "outcome": { "receipt": "sample.channel.json", "receipt_hash": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", - "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a227663ed8f17511d1ca8b78c5d3ba3854546651420ef97d39208883f9bf987b1d9d58577208bfb33aeeea3d94b973ebd816ce9cfc4e5a6762e777a39a8bbe806", - "lineup_digest": "72ad84006fc7b22799708d78b736407742b35ea2ab32918e9068d263290481c7", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05", + "lineup_digest": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5", "cues": [ { "break": "brk-1", @@ -95,8 +95,8 @@ "outcome": { "receipt": "sample.channel.json", "receipt_hash": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", - "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a227663ed8f17511d1ca8b78c5d3ba3854546651420ef97d39208883f9bf987b1d9d58577208bfb33aeeea3d94b973ebd816ce9cfc4e5a6762e777a39a8bbe806", - "lineup_digest": "72ad84006fc7b22799708d78b736407742b35ea2ab32918e9068d263290481c7", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05", + "lineup_digest": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5", "cues": [ { "break": "brk-1", @@ -153,8 +153,8 @@ "outcome": { "receipt": "media_rss_loop.channel.json", "receipt_hash": "a2cc6ae4d6de83aa9b0c1dd3e63878a605a9ccaf813d074f37c2a1cad2ed0c99", - "lineup": "57434c31010873616d706c652d310e4f776e65722041207469746c65732fccd317b7809fcf7439a7eefcae244d273b7430c665404d0c5d4d94b42674b000301b8aa10100006058658aa10100000300000000301b8aa101000020e8338aa101000040a04c8aa10100000300000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b818000000000006686c2d3130330e00486172626f7572204c69676874730000000000000000076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130332f696e6465782e6d33753820b8180000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16ae0b4831075f0903309e6b6b7b364909f7db6068a40e02ba1b5fe5fb8c632944a0c317f7342697822dfa5aca73794d6680c9118a8b1e7db31adcdda0598b59204", - "lineup_digest": "d653cbd60cd62e5b90c6d0f17c656e7f4a1cf66843f92e89b9a9bf088fd01ddd", + "lineup": "574c5531010873616d706c652d310e4f776e65722041207469746c65732fccd317b7809fcf7439a7eefcae244d273b7430c665404d0c5d4d94b42674b000301b8aa10100006058658aa10100000300000000301b8aa101000020e8338aa101000040a04c8aa10100000300000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b818000000000006686c2d3130330e00486172626f7572204c69676874730000000000000000076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130332f696e6465782e6d33753820b8180000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a0dc1ce7aa1b514b5f1433fd99901df6d1dd586a16482465fd2693cb484c759ec9188c13db65a5a5bce116be12c86efb5ddcbd10472fb7925dd8918632f797c0b", + "lineup_digest": "81a6b92a2b83c431728c15f58e68a1f03773708a8644b472c513aadc077f4eb2", "cues": [] } }, @@ -1193,14 +1193,14 @@ "name": "receipt_and_lineup", "about": "the sample's receipt and lineup", "receipt": "sample.channel.json", - "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a227663ed8f17511d1ca8b78c5d3ba3854546651420ef97d39208883f9bf987b1d9d58577208bfb33aeeea3d94b973ebd816ce9cfc4e5a6762e777a39a8bbe806", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05", "guide": null, "edits": [], "facts": { "verdict": "verified", "part": null, "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", - "lineup": "72ad84006fc7b22799708d78b736407742b35ea2ab32918e9068d263290481c7", + "lineup": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5", "guide": null, "entry": null } @@ -1209,14 +1209,14 @@ "name": "xmltv_guide", "about": "the XMLTV guide of the whole schedule: two programmes aired in the receipt's window, two after it", "receipt": "sample.channel.json", - "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a227663ed8f17511d1ca8b78c5d3ba3854546651420ef97d39208883f9bf987b1d9d58577208bfb33aeeea3d94b973ebd816ce9cfc4e5a6762e777a39a8bbe806", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05", "guide": "sample.guide.xml", "edits": [], "facts": { "verdict": "verified", "part": null, "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", - "lineup": "72ad84006fc7b22799708d78b736407742b35ea2ab32918e9068d263290481c7", + "lineup": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5", "guide": { "format": "xmltv", "from_ms": 1793296800000, @@ -1256,14 +1256,14 @@ "name": "part_guide", "about": "the XMLTV guide of a range across the window's end", "receipt": "sample.channel.json", - "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a227663ed8f17511d1ca8b78c5d3ba3854546651420ef97d39208883f9bf987b1d9d58577208bfb33aeeea3d94b973ebd816ce9cfc4e5a6762e777a39a8bbe806", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05", "guide": "sample.part.guide.xml", "edits": [], "facts": { "verdict": "verified", "part": null, "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", - "lineup": "72ad84006fc7b22799708d78b736407742b35ea2ab32918e9068d263290481c7", + "lineup": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5", "guide": { "format": "xmltv", "from_ms": 1793299500000, @@ -1291,14 +1291,14 @@ "name": "now_next", "about": "the now/next document inside a programme's break", "receipt": "sample.channel.json", - "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a227663ed8f17511d1ca8b78c5d3ba3854546651420ef97d39208883f9bf987b1d9d58577208bfb33aeeea3d94b973ebd816ce9cfc4e5a6762e777a39a8bbe806", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05", "guide": "sample.now-next.json", "edits": [], "facts": { "verdict": "verified", "part": null, "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", - "lineup": "72ad84006fc7b22799708d78b736407742b35ea2ab32918e9068d263290481c7", + "lineup": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5", "guide": { "format": "now-next", "from_ms": null, @@ -1326,14 +1326,14 @@ "name": "now_next_before", "about": "the now/next document before the schedule begins", "receipt": "sample.channel.json", - "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a227663ed8f17511d1ca8b78c5d3ba3854546651420ef97d39208883f9bf987b1d9d58577208bfb33aeeea3d94b973ebd816ce9cfc4e5a6762e777a39a8bbe806", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05", "guide": "sample.before.now-next.json", "edits": [], "facts": { "verdict": "verified", "part": null, "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", - "lineup": "72ad84006fc7b22799708d78b736407742b35ea2ab32918e9068d263290481c7", + "lineup": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5", "guide": { "format": "now-next", "from_ms": null, @@ -1355,14 +1355,14 @@ "name": "now_next_after", "about": "the now/next document after the schedule ends", "receipt": "sample.channel.json", - "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a227663ed8f17511d1ca8b78c5d3ba3854546651420ef97d39208883f9bf987b1d9d58577208bfb33aeeea3d94b973ebd816ce9cfc4e5a6762e777a39a8bbe806", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05", "guide": "sample.after.now-next.json", "edits": [], "facts": { "verdict": "verified", "part": null, "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", - "lineup": "72ad84006fc7b22799708d78b736407742b35ea2ab32918e9068d263290481c7", + "lineup": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5", "guide": { "format": "now-next", "from_ms": null, @@ -1377,7 +1377,7 @@ "name": "receipt_malformed", "about": "a receipt missing a member", "receipt": "sample.channel.json", - "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a227663ed8f17511d1ca8b78c5d3ba3854546651420ef97d39208883f9bf987b1d9d58577208bfb33aeeea3d94b973ebd816ce9cfc4e5a6762e777a39a8bbe806", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05", "guide": null, "edits": [ { @@ -1399,7 +1399,7 @@ "name": "receipt_bad_signature", "about": "a receipt whose signature was altered", "receipt": "sample.channel.json", - "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a227663ed8f17511d1ca8b78c5d3ba3854546651420ef97d39208883f9bf987b1d9d58577208bfb33aeeea3d94b973ebd816ce9cfc4e5a6762e777a39a8bbe806", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05", "guide": null, "edits": [ { @@ -1421,7 +1421,7 @@ "name": "receipt_invalid", "about": "a receipt, signed, whose program-as-run is not what its window gives", "receipt": "aired-misreported.channel.json", - "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a227663ed8f17511d1ca8b78c5d3ba3854546651420ef97d39208883f9bf987b1d9d58577208bfb33aeeea3d94b973ebd816ce9cfc4e5a6762e777a39a8bbe806", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05", "guide": null, "edits": [], "facts": { @@ -1437,7 +1437,7 @@ "name": "lineup_malformed", "about": "a lineup with a byte after its signature", "receipt": "sample.channel.json", - "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a227663ed8f17511d1ca8b78c5d3ba3854546651420ef97d39208883f9bf987b1d9d58577208bfb33aeeea3d94b973ebd816ce9cfc4e5a6762e777a39a8bbe80600", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b0500", "guide": null, "edits": [], "facts": { @@ -1453,14 +1453,14 @@ "name": "lineup_bad_signature", "about": "a lineup whose signature was altered", "receipt": "sample.channel.json", - "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a227663ed8f17511d1ca8b78c5d3ba3854546651420ef97d39208883f9bf987b1d9d58577208bfb33aeeea3d94b973ebd816ce9cfc4e5a6762e777a39a8bbe807", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b04", "guide": null, "edits": [], "facts": { "verdict": "bad_signature", "part": "lineup", "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", - "lineup": "72ad84006fc7b22799708d78b736407742b35ea2ab32918e9068d263290481c7", + "lineup": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5", "guide": null, "entry": null } @@ -1469,14 +1469,14 @@ "name": "lineup_bad_key", "about": "the identity point as the key, with (R, S) = (B, 1): the equation holds for every message, and the key is of small order", "receipt": "sample.channel.json", - "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000010000000000000000000000000000000000000000000000000000000000000058666666666666666666666666666666666666666666666666666666666666660100000000000000000000000000000000000000000000000000000000000000", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000010000000000000000000000000000000000000000000000000000000000000058666666666666666666666666666666666666666666666666666666666666660100000000000000000000000000000000000000000000000000000000000000", "guide": null, "edits": [], "facts": { "verdict": "bad_key", "part": "lineup", "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", - "lineup": "1577f6220ffcb24ce7e7a73f3e43ef9a341cb791107ba276ced3a56ec68f4faa", + "lineup": "73865f2fe1d8342db7defeac36cca4aff3a378ca2112459fd2c8d89b2caeb0fd", "guide": null, "entry": null } @@ -1485,14 +1485,14 @@ "name": "lineup_signer_mismatch", "about": "a lineup another key signed", "receipt": "sample.channel.json", - "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000a84e8579cbcc4c8793feab96571297a22458d3bd3d47671107083829f7ba6b1e676042cab0030f756201a8b9e03d6486f5a122f26ddc2ed0347043503bdd071fe18a5ebb4d31a26a225881cc59643217338a6e35408904e8d0dd63d326dff808", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000a84e8579cbcc4c8793feab96571297a22458d3bd3d47671107083829f7ba6b1ec42835241c90d0b06b4886ee02270b40d1c10bf99619844d02149cefa2d6aacdea4fdf338c804876f42c05272da289e76a27cd86c53139b0b7ae14c1d3c88709", "guide": null, "edits": [], "facts": { "verdict": "lineup_signer_mismatch", "part": "lineup", "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", - "lineup": "b9806fd698e5c0aaed9d169d22b9099cd0e496ba5441913dbd70006c230236d6", + "lineup": "5e7a2c0872c91871abd3df191db1d3e77f41caa76f984366395186fd6f42c0b9", "guide": null, "entry": null } @@ -1501,14 +1501,14 @@ "name": "lineup_schedule_mismatch", "about": "a lineup, signed, whose second programme begins a minute late, where no entry does", "receipt": "sample.channel.json", - "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a1010000a0faed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a033bf88417ca7e6f1405ec8114a52bc5d7dc08c944470c72ea9d13503862f10966acf5eae95697b9682a43d99f8ec2246812ef4b9cf356fd257ff96a94bd1a0e", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a1010000a0faed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a68009f893de9bb6f63b46b0a813a8aacc4b4ed59182313cbbccb9558a41ab1c6680c1cee7a4e30274065e7fe18fcf76cac02685ae0820b74c6a373a3fbbf6608", "guide": null, "edits": [], "facts": { "verdict": "lineup_schedule_mismatch", "part": "lineup", "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", - "lineup": "1ef0f6c2abf972eff6b8b4bba59680b38a27361e648d6d40b38cc5caf86c6edb", + "lineup": "4c653fb1f6f1824e22723d750307c2f75181908d60a775885b25bb000f12d59c", "guide": null, "entry": null } @@ -1517,7 +1517,7 @@ "name": "guide_malformed", "about": "a guide cut short", "receipt": "sample.channel.json", - "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a227663ed8f17511d1ca8b78c5d3ba3854546651420ef97d39208883f9bf987b1d9d58577208bfb33aeeea3d94b973ebd816ce9cfc4e5a6762e777a39a8bbe806", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05", "guide": "sample.guide.xml", "edits": [ { @@ -1530,7 +1530,7 @@ "verdict": "guide_malformed", "part": "guide", "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", - "lineup": "72ad84006fc7b22799708d78b736407742b35ea2ab32918e9068d263290481c7", + "lineup": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5", "guide": null, "entry": null } @@ -1539,7 +1539,7 @@ "name": "guide_source_mismatch", "about": "a guide naming another schedule", "receipt": "sample.channel.json", - "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a227663ed8f17511d1ca8b78c5d3ba3854546651420ef97d39208883f9bf987b1d9d58577208bfb33aeeea3d94b973ebd816ce9cfc4e5a6762e777a39a8bbe806", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05", "guide": "sample.guide.xml", "edits": [ { @@ -1552,7 +1552,7 @@ "verdict": "guide_source_mismatch", "part": "guide", "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", - "lineup": "72ad84006fc7b22799708d78b736407742b35ea2ab32918e9068d263290481c7", + "lineup": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5", "guide": { "format": "xmltv", "from_ms": 1793296800000, @@ -1592,7 +1592,7 @@ "name": "guide_entry_unscheduled", "about": "a guide that places Night Freight where the schedule places Harbour Lights", "receipt": "sample.channel.json", - "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a227663ed8f17511d1ca8b78c5d3ba3854546651420ef97d39208883f9bf987b1d9d58577208bfb33aeeea3d94b973ebd816ce9cfc4e5a6762e777a39a8bbe806", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05", "guide": "sample.guide.xml", "edits": [ { @@ -1605,7 +1605,7 @@ "verdict": "guide_entry_unscheduled", "part": "guide", "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", - "lineup": "72ad84006fc7b22799708d78b736407742b35ea2ab32918e9068d263290481c7", + "lineup": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5", "guide": { "format": "xmltv", "from_ms": 1793296800000, @@ -1645,7 +1645,7 @@ "name": "guide_mismatch", "about": "a guide whose title the lineup does not state", "receipt": "sample.channel.json", - "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a227663ed8f17511d1ca8b78c5d3ba3854546651420ef97d39208883f9bf987b1d9d58577208bfb33aeeea3d94b973ebd816ce9cfc4e5a6762e777a39a8bbe806", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05", "guide": "sample.guide.xml", "edits": [ { @@ -1658,7 +1658,7 @@ "verdict": "guide_mismatch", "part": "guide", "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", - "lineup": "72ad84006fc7b22799708d78b736407742b35ea2ab32918e9068d263290481c7", + "lineup": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5", "guide": { "format": "xmltv", "from_ms": 1793296800000, @@ -1698,7 +1698,7 @@ "name": "now_next_unscheduled", "about": "a now/next document naming another programme on air", "receipt": "sample.channel.json", - "lineup": "57434c31010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a227663ed8f17511d1ca8b78c5d3ba3854546651420ef97d39208883f9bf987b1d9d58577208bfb33aeeea3d94b973ebd816ce9cfc4e5a6762e777a39a8bbe806", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a96865a21b49260106e649ca0abaa99bec642543b588c106cc74445a51cf01ccb949546bc1891ffd0dedaa4668a4349ca19ab66e2dda0a325667695eadbe02b05", "guide": "sample.now-next.json", "edits": [ { @@ -1711,7 +1711,7 @@ "verdict": "guide_entry_unscheduled", "part": "guide", "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", - "lineup": "72ad84006fc7b22799708d78b736407742b35ea2ab32918e9068d263290481c7", + "lineup": "6012ecf6dc14da1068767881f92084cca677d6cf9cf3f0732b309ddcc9146aa5", "guide": { "format": "now-next", "from_ms": null, diff --git a/wai/registry/capabilities.json b/wai/registry/capabilities.json index 7598e0868..b4b23b5f7 100644 --- a/wai/registry/capabilities.json +++ b/wai/registry/capabilities.json @@ -2267,6 +2267,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", @@ -2912,6 +2926,13 @@ "section": "§4.1", "status": "defined" }, + { + "name": "WLU1", + "kind": "magic", + "document": "extensions/channel-lineup.md", + "section": "§6.1", + "status": "defined" + }, { "name": "WMA1", "kind": "magic", @@ -3556,6 +3577,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", @@ -3591,6 +3619,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", @@ -3647,6 +3682,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", @@ -3794,6 +3836,13 @@ "section": "§8.4", "status": "defined" }, + { + "name": "wai-now-next", + "kind": "document-kind", + "document": "extensions/channel-lineup.md", + "section": "§7.3", + "status": "defined" + }, { "name": "wai-origin", "kind": "document-kind", diff --git a/wai/vocab/v2.ttl b/wai/vocab/v2.ttl index 93f610d10..d9e7c69fd 100644 --- a/wai/vocab/v2.ttl +++ b/wai/vocab/v2.ttl @@ -1635,6 +1635,13 @@ wai:wai.video.lcevc a wai:Capability ; wai:specSection "§5 Broadcast and streaming codecs" ; wai:registeredOn "2026-09-28"^^xsd:date . +wai:wai.video.lineup a wai:Capability ; + rdfs:label "wai.video.lineup" ; + wai:kind wai:kind-record ; + wai:status wai:status-registered ; + wai:specSection "§5 Interactive, temporal & computed content" ; + wai:registeredOn "2026-10-29"^^xsd:date . + wai:wai.video.manifest a wai:Capability ; rdfs:label "wai.video.manifest" ; wai:kind wai:kind-record ; diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index 81ab4571c..6d3d63632 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -367,7 +367,7 @@ live_ads = ["live_room", "video_ssai", "dep:num-bigint"] # 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 (WCL1) of each programme asset's description, artwork by content hash, geo +# 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, diff --git a/wai/wai-rs/src/bin/wai_cli/dx/channel.rs b/wai/wai-rs/src/bin/wai_cli/dx/channel.rs index 625c10601..f1a945023 100644 --- a/wai/wai-rs/src/bin/wai_cli/dx/channel.rs +++ b/wai/wai-rs/src/bin/wai_cli/dx/channel.rs @@ -175,7 +175,7 @@ pub fn import(args: &[String]) -> R { 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.wcl"), got.lineup.to_bytes()), + (path("lineup.wlu"), got.lineup.to_bytes()), (path("cues.json"), cues.into_bytes()), ]; if !dir.as_os_str().is_empty() { diff --git a/wai/wai-rs/src/catalogue.rs b/wai/wai-rs/src/catalogue.rs index 872e0eaab..622ec129e 100644 --- a/wai/wai-rs/src/catalogue.rs +++ b/wai/wai-rs/src/catalogue.rs @@ -918,7 +918,7 @@ http vectors diff|fuzz --seed S --count N --out F", 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.wcl) and the cues (cues.json), written to D (.); a refusal's code is its reason; replacing a file waits + (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, @@ -939,7 +939,7 @@ http vectors diff|fuzz --seed S --count N --out F", 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.wcl and cues.json are written (.)."), + 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, diff --git a/wai/wai-rs/src/channel_lineup.rs b/wai/wai-rs/src/channel_lineup.rs index f16126d84..d06ebde91 100644 --- a/wai/wai-rs/src/channel_lineup.rs +++ b/wai/wai-rs/src/channel_lineup.rs @@ -3,7 +3,7 @@ //! A FAST channel's schedule and catalogue, as operators exchange them (an XMLTV schedule, //! Media RSS catalogues, a JSON form of both, the SCTE-35 date ranges of the channel's HLS Media //! Playlist), are imported ([`import`]) into a `wai.video.channel` receipt, whose signed bytes -//! do not change, and a signed **lineup**, `WCL1` ([`Lineup`]): each programme asset's +//! do not change, and a signed **lineup**, `WLU1` ([`Lineup`]): each programme asset's //! description, artwork by content hash, where it may be shown and its rights holder, and where //! each programme of the guide begins, bound to the schedule by its root. The **guide** — an //! XMLTV document ([`xmltv_guide`]) or a now/next document ([`now_next`]) — is rendered from the @@ -29,7 +29,7 @@ use crate::wai_video_manifest::EnergyProvenance; /// The record this module registers (SPEC §5). pub const CAPABILITY: &str = "wai.video.lineup"; /// The lineup's magic (§6.1). -pub const MAGIC: &[u8; 4] = b"WCL1"; +pub const MAGIC: &[u8; 4] = b"WLU1"; /// The lineup's version (§6.1). pub const VERSION: u8 = 1; /// The lineup's signing domain (§6.3). @@ -662,7 +662,7 @@ pub struct Asset { pub duration_ms: u64, } -/// A lineup, `WCL1` (§6.1). +/// A lineup, `WLU1` (§6.1). #[derive(Clone, Debug, PartialEq, Eq)] pub struct Lineup { pub channel_id: String, diff --git a/wai/wai-rs/src/channel_lineup/tests.rs b/wai/wai-rs/src/channel_lineup/tests.rs index 9aa3afb2f..bd641955f 100644 --- a/wai/wai-rs/src/channel_lineup/tests.rs +++ b/wai/wai-rs/src/channel_lineup/tests.rs @@ -46,3 +46,8 @@ fn every_reason_has_its_code() { 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); +} diff --git a/wai/wai-rs/src/codecs/registry.rs b/wai/wai-rs/src/codecs/registry.rs index 58bccd732..e31d0e49f 100644 --- a/wai/wai-rs/src/codecs/registry.rs +++ b/wai/wai-rs/src/codecs/registry.rs @@ -226,6 +226,7 @@ pub const CAP_LIVE_WATERMARK: &str = "wai.live.watermark"; pub const CAP_LIVE_ADS: &str = "wai.live.ads"; pub const CAP_LIVE_AD_PRESENTATION: &str = "wai.live.ad_presentation"; pub const CAP_VIDEO_STITCHED_PRESENTATION: &str = "wai.video.stitched_presentation"; +pub const CAP_VIDEO_LINEUP: &str = "wai.video.lineup"; pub const CAP_LIVE_COWATCH: &str = "wai.live.cowatch"; pub const CAP_LIVE_REMOTE: &str = "wai.live.remote"; pub const CAP_LIVE_WORLD: &str = "wai.live.world"; @@ -354,6 +355,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"), @@ -446,6 +448,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, @@ -646,6 +649,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"; @@ -783,6 +787,11 @@ pub const WIRE_IDENTIFIERS: &[WireIdentifier] = &[ wire("WPC1", WireKind::Magic, AD_PRESENTATION, "§5.1", WireStatus::Defined), wire("WSI1", WireKind::Magic, STITCHED_PRESENTATION, "§4.1", WireStatus::Defined), wire("WSC1", WireKind::Magic, STITCHED_PRESENTATION, "§5.1", WireStatus::Defined), + wire("WLU1", WireKind::Magic, CHANNEL_LINEUP, "§6.1", WireStatus::Defined), + wire("wai-channel-feed", WireKind::DocumentKind, CHANNEL_LINEUP, "§4.4", WireStatus::Defined), + wire("urn:wai:guide:", WireKind::Scheme, CHANNEL_LINEUP, "§7.2", WireStatus::Defined), + wire("wai-asset-id", WireKind::Scheme, CHANNEL_LINEUP, "§7.2", WireStatus::Defined), + wire("wai-now-next", WireKind::DocumentKind, CHANNEL_LINEUP, "§7.3", WireStatus::Defined), wire("WXP1", WireKind::Magic, CO_WATCH, "§3.1", WireStatus::Defined), wire("WXC1", WireKind::Magic, CO_WATCH, "§5.1", WireStatus::Defined), wire("WXS1", WireKind::Magic, CO_WATCH, "§7.1", WireStatus::Defined), diff --git a/wai/wai-rs/src/lib.rs b/wai/wai-rs/src/lib.rs index ac2afff98..27e29f503 100644 --- a/wai/wai-rs/src/lib.rs +++ b/wai/wai-rs/src/lib.rs @@ -683,7 +683,7 @@ pub mod live_ads; #[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 (WCL1), bound to its wai.video.channel schedule by the schedule root; the import of +// 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. diff --git a/wai/wai-rs/tests/channel_lineup.rs b/wai/wai-rs/tests/channel_lineup.rs index 55ad86577..8695a9641 100644 --- a/wai/wai-rs/tests/channel_lineup.rs +++ b/wai/wai-rs/tests/channel_lineup.rs @@ -110,7 +110,7 @@ fn the_worked_example_runs_as_the_text_writes_it() { 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.wcl")).unwrap()), sample["outcome"]["lineup"].as_str().unwrap(), "the lineup"); + 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"); @@ -136,12 +136,12 @@ fn a_refusal_is_its_reason_and_exit_1() { std::fs::write(d.join("changed.guide.xml"), g).unwrap(); let lineup = vectors()["imports"][0]["outcome"]["lineup"].as_str().unwrap().to_string(); let bytes: Vec = (0..lineup.len()).step_by(2).map(|i| u8::from_str_radix(&lineup[i..i + 2], 16).unwrap()).collect(); - std::fs::write(d.join("lineup.wcl"), bytes).unwrap(); - let o = run(&d, &s(&["channel", "verify", "--receipt", "sample.channel.json", "--lineup", "lineup.wcl", "--guide", "changed.guide.xml", "--json"])); + 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.wcl", "--guide", "sample.guide.xml", "--json"])); + let o = run(&d, &s(&["channel", "verify", "--receipt", "sample.channel.json", "--lineup", "lineup.wlu", "--guide", "sample.guide.xml", "--json"])); assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stdout)); let e: Value = serde_json::from_slice(&o.stdout).expect("an envelope"); let aired: Vec = e["data"]["guide"]["programmes"].as_array().unwrap().iter().map(|p| p["aired"].as_bool().unwrap()).collect(); diff --git a/wai/wai-rs/tests/cli_ops.rs b/wai/wai-rs/tests/cli_ops.rs index 667a45f47..b751a9787 100644 --- a/wai/wai-rs/tests/cli_ops.rs +++ b/wai/wai-rs/tests/cli_ops.rs @@ -422,14 +422,14 @@ fn samples(d: &Path, origin_port: u16) -> Vec<(&'static str, Vec, bool)> std::fs::write(d.join("operator.key"), corpus["operator"]["seed"].as_str().unwrap()).unwrap(); let l = corpus["imports"][0]["outcome"]["lineup"].as_str().unwrap(); let lineup: Vec = (0..l.len()).step_by(2).map(|i| u8::from_str_radix(&l[i..i + 2], 16).unwrap()).collect(); - std::fs::write(d.join("sample.wcl"), lineup).unwrap(); + 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", "operator.key", "--out-dir", "channel-out"]), true, )); - more.push(("channel.guide", s(&["channel", "guide", "--receipt", &f("sample.channel.json"), "--lineup", "sample.wcl", "--out", "guide.xml"]), true)); + more.push(("channel.guide", s(&["channel", "guide", "--receipt", &f("sample.channel.json"), "--lineup", "sample.wlu", "--out", "guide.xml"]), true)); } #[cfg(feature = "generate")] { diff --git a/wai/wai-studio/src/actions.generated.js b/wai/wai-studio/src/actions.generated.js index afdca18b1..f061387bf 100644 --- a/wai/wai-studio/src/actions.generated.js +++ b/wai/wai-studio/src/actions.generated.js @@ -1,7 +1,7 @@ // GENERATED by scripts/actions.mjs from the WAI operation catalogue. Do not edit: change the // catalogue (wai-rs/src/catalogue.rs, exported by `wai ops export`), then run // `node scripts/actions.mjs`. `node scripts/actions.mjs --check` fails when this file differs. -export const CATALOGUE = {"format":"wai.ops/1","toolchain":"0.4.0","sha256":"327f96eaf75e0d852d2db58e9833f0fb6ea85dbafc6dfc1d27ea41614fdcd5b2"}; +export const CATALOGUE = {"format":"wai.ops/1","toolchain":"0.4.0","sha256":"eb9777b6948ba82171987b82f5814ea8bb441a7b82d2b179accd5369dbaecfa8"}; 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"}, -- 2.54.0 (Apple Git-157) From cef9fba9c6b6eb3c5c1802d17c1af6bde072d158 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 29 Oct 2026 17:28:59 -0400 Subject: [PATCH 06/11] wai-rs: channel_lineup, clippy's notes Type names for the import's intermediate forms, the date fields of an instant as one array, and a plain to_string. No change of behaviour. Co-Authored-By: Claude Opus 5.5 --- wai/wai-rs/src/bin/wai_cli/dx/channel.rs | 2 +- wai/wai-rs/src/channel_lineup.rs | 11 ++++---- wai/wai-rs/src/channel_lineup/corpus.rs | 13 +++++++--- wai/wai-rs/src/channel_lineup/import.rs | 32 +++++++++++++++++------- 4 files changed, 40 insertions(+), 18 deletions(-) diff --git a/wai/wai-rs/src/bin/wai_cli/dx/channel.rs b/wai/wai-rs/src/bin/wai_cli/dx/channel.rs index f1a945023..b57957a44 100644 --- a/wai/wai-rs/src/bin/wai_cli/dx/channel.rs +++ b/wai/wai-rs/src/bin/wai_cli/dx/channel.rs @@ -260,7 +260,7 @@ pub fn verify(args: &[String]) -> R { 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 = format!("{}", facts["verdict"].as_str().unwrap_or("?")); + 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})")); } diff --git a/wai/wai-rs/src/channel_lineup.rs b/wai/wai-rs/src/channel_lineup.rs index d06ebde91..b0f5cbf8b 100644 --- a/wai/wai-rs/src/channel_lineup.rs +++ b/wai/wai-rs/src/channel_lineup.rs @@ -232,9 +232,10 @@ fn digits(s: &str) -> Option { (!s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())).then(|| s.parse().ok()).flatten() } -/// The instant of a date and time in UTC with an offset in minutes east of UTC (§3.4), or -/// `None` when a field is out of range or the instant is before 1970. -fn instant(y: i64, mo: i64, d: i64, h: i64, mi: i64, s: i64, ms: i64, offset_min: i64) -> Option { +/// The instant of a date and time (year, month, day, hour, minute, second, millisecond) with +/// an offset in minutes east of UTC (§3.4), or `None` when a field is out of range or the instant +/// is before 1970. +fn instant([y, mo, d, h, mi, s, ms]: [i64; 7], offset_min: i64) -> Option { if y < 1970 || !(1..=12).contains(&mo) || d < 1 || d > days_in_month(y, mo) || h > 23 || mi > 59 || s > 59 { return None; } @@ -265,7 +266,7 @@ pub fn xmltv_time(v: &str) -> Result { return Err(Reason::ImportTimeMalformed); }; let f = |a: usize, b: usize| digits(&v[a..b]).expect("digits"); - instant(f(0, 4), f(4, 6), f(6, 8), f(8, 10), f(10, 12), f(12, 14), 0, offset).ok_or(Reason::ImportTimeMalformed) + instant([f(0, 4), f(4, 6), f(6, 8), f(8, 10), f(10, 12), f(12, 14), 0], offset).ok_or(Reason::ImportTimeMalformed) } /// An instant as an XMLTV time in UTC with its zone written (§7.2): `YYYYMMDDhhmmss +0000`. @@ -329,7 +330,7 @@ pub fn hls_date_time(v: &str) -> Result { _ => return malformed, }; let f = |a: usize, b: usize| digits(&v[a..b]).expect("digits"); - match instant(f(0, 4), f(5, 7), f(8, 10), f(11, 13), f(14, 16), f(17, 19), ms, offset) { + match instant([f(0, 4), f(5, 7), f(8, 10), f(11, 13), f(14, 16), f(17, 19), ms], offset) { Some(t) if t <= MAX_INSTANT => Ok(t), _ => malformed, } diff --git a/wai/wai-rs/src/channel_lineup/corpus.rs b/wai/wai-rs/src/channel_lineup/corpus.rs index adf4972ce..a0b50bea2 100644 --- a/wai/wai-rs/src/channel_lineup/corpus.rs +++ b/wai/wai-rs/src/channel_lineup/corpus.rs @@ -418,7 +418,7 @@ fn edit(file: &str, find: &str, replace: &str) -> Value { /// 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<(&'static str, &'static str, Value, Value, Option)> { +fn import_vectors() -> Vec { use Reason::*; let (x, a, b, p, j) = ("sample.xmltv.xml", "sample-a.mrss.xml", "sample-b.mrss.xml", "sample.m3u8", "sample.json"); let loop_inputs = || inputs(json!({"xmltv": null, "mrss": [{"file": a, "owner": "owner-a"}], "playlist": null, "owner": null, "start_ms": LOOP_START, "window": null})); @@ -458,9 +458,16 @@ fn lineup_hex(l: &Lineup) -> String { hex(&l.to_bytes()) } -/// Each verification vector: its name, what it shows, the receipt file, the lineup's bytes, the +/// A verification vector: its name, what it shows, the receipt file, the lineup's bytes, the /// guide file, its edits. -fn verification_vectors(sample: &Imported) -> Vec<(&'static str, &'static str, &'static str, String, Option<&'static str>, Value)> { +type VerificationVector = (&'static str, &'static str, &'static str, String, Option<&'static str>, Value); + +/// An import vector: its name, what it shows, its inputs, its edits, and the refusal it was +/// written for. +type ImportVector = (&'static str, &'static str, Value, Value, Option); + +/// Each verification vector. +fn verification_vectors(sample: &Imported) -> Vec { let (rc, g, nn) = ("sample.channel.json", "sample.guide.xml", "sample.now-next.json"); let l = &sample.lineup; let good = lineup_hex(l); diff --git a/wai/wai-rs/src/channel_lineup/import.rs b/wai/wai-rs/src/channel_lineup/import.rs index 6a433dd9a..d69d787d0 100644 --- a/wai/wai-rs/src/channel_lineup/import.rs +++ b/wai/wai-rs/src/channel_lineup/import.rs @@ -100,6 +100,20 @@ struct Item { owner: Option, } +/// The programmes of an XMLTV document, each with its description (§4.2). +type Described = Vec<(Prog, Desc)>; + +/// The JSON form's assets, each its id, its description and its rights holder (§4.4). +type Catalogue = Vec<(String, Desc, Option)>; + +/// The JSON form as §4.4 reads it. +struct JsonRead { + channel_name: String, + programmes: Vec, + assets: Catalogue, + breaks: Vec, +} + /// A break (§4.5). #[derive(Clone, Debug)] struct Break { @@ -182,7 +196,7 @@ fn description(e: &Element, desc_name: &str, art: (&str, &str), scheme: (&str, & } /// §4.2: the channel name, the programmes and each one's description. -fn read_xmltv(b: &[u8], xch: &str, id_system: &str) -> R<(Option, Vec<(Prog, Desc)>)> { +fn read_xmltv(b: &[u8], xch: &str, id_system: &str) -> R<(Option, Described)> { sized(b)?; let root = read_feed_xml(b).ok_or(Reason::ImportXmlMalformed)?; if root.name != "tv" { @@ -414,7 +428,7 @@ fn break_duration(d: u64) -> R { } /// §4.4: the JSON form, its values read in document order. -fn read_json(b: &[u8], breaks_count: &mut usize) -> R<(String, Vec, Vec<(String, Desc, Option)>, Vec)> { +fn read_json(b: &[u8], breaks_count: &mut usize) -> R { sized(b)?; let f = json_structure(b).ok_or(Reason::ImportJsonMalformed)?; *breaks_count += f.breaks.len(); @@ -472,7 +486,7 @@ fn read_json(b: &[u8], breaks_count: &mut usize) -> R<(String, Vec, Vec<(S }; breaks.push(Break { id: k.id, start: k.start_ms, duration: break_duration(k.duration_ms)?, section }); } - Ok((f.channel_name, progs, assets, breaks)) + Ok(JsonRead { channel_name: f.channel_name, programmes: progs, assets, breaks }) } // --------------------------------------------------------------------------------------- @@ -604,7 +618,7 @@ pub fn import(f: &Feeds, key: &SigningKey, signer_id: &str, parent: Option<[u8; // The feeds, in the order of §4.10. let mut names: Vec> = Vec::new(); - let mut xmltv: Option> = None; + let mut xmltv: Option = None; if let Some(b) = &f.xmltv { let (name, progs) = read_xmltv(b, xch, id_system)?; names.push(name); @@ -621,12 +635,12 @@ pub fn import(f: &Feeds, key: &SigningKey, signer_id: &str, parent: Option<[u8; } let mut breaks_count = 0usize; let mut breaks: Vec = Vec::new(); - let mut json: Option<(Vec, Vec<(String, Desc, Option)>)> = None; + let mut json: Option<(Vec, Catalogue)> = None; if let Some(b) = &f.json { - let (name, progs, assets, mut bks) = read_json(b, &mut breaks_count)?; - names.push(Some(name)); - json = Some((progs, assets)); - breaks.append(&mut bks); + 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)?); -- 2.54.0 (Apple Git-157) From e5e31ec9caa9c9c055320f410b96bcf9348c2e30 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 29 Oct 2026 17:31:14 -0400 Subject: [PATCH 07/11] =?UTF-8?q?wai:=20a=20control=20for=20each=20rule=20?= =?UTF-8?q?of=20channel-lineup=20=C2=A76.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one lineup control moved a programme's start, which rule 4 refuses as well as rule 3, so it did not show that rule 3 holds on its own. Four signed lineups now each break one rule alone: another schedule root, a programme that begins inside an entry, a programme of two assets, an asset no programme airs. The second implementation drops each rule as a check of its own, and the corpus catches each. Co-Authored-By: Claude Opus 5.5 --- wai/extensions/channel-lineup.md | 5 +- wai/live-conformance/channel_lineup_verify.py | 21 ++++--- wai/live-conformance/channels/vectors.json | 56 +++++++++++++++++-- wai/wai-rs/src/channel_lineup/corpus.rs | 25 +++++++-- 4 files changed, 87 insertions(+), 20 deletions(-) diff --git a/wai/extensions/channel-lineup.md b/wai/extensions/channel-lineup.md index f781e8665..c97228407 100644 --- a/wai/extensions/channel-lineup.md +++ b/wai/extensions/channel-lineup.md @@ -760,8 +760,9 @@ programmes, owners and pictures made for it. The vectors: 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 of another schedule, 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). + 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. diff --git a/wai/live-conformance/channel_lineup_verify.py b/wai/live-conformance/channel_lineup_verify.py index 579785dbd..4f4c7f1c3 100644 --- a/wai/live-conformance/channel_lineup_verify.py +++ b/wai/live-conformance/channel_lineup_verify.py @@ -1072,19 +1072,22 @@ def programme_assets(l, sched): def describes(l, r): - """§6.5.""" - if "describes" in SKIP: - return True + """§6.5, each rule a check of its own.""" s = r["schedule"] - if not s or l["channel_id"] != r["channel_id"] or l["schedule_root"] != r["schedule_root"]: + if not s: return False - if l["schedule_start_ms"] != s[0]["start_ms"] or l["schedule_end_ms"] != s[-1]["start_ms"] + s[-1]["duration_ms"]: + end = s[-1]["start_ms"] + s[-1]["duration_ms"] + if "describes_root" not in SKIP and (l["channel_id"] != r["channel_id"] or l["schedule_root"] != r["schedule_root"] + or l["schedule_start_ms"] != s[0]["start_ms"] or l["schedule_end_ms"] != end): return False starts = {x["start_ms"] for x in s} - if any(p not in starts for p in l["programmes"]): + if "describes_starts" not in SKIP and any(p not in starts for p in l["programmes"]): return False - ids = programme_assets(l, s) - return ids is not None and sorted(set(ids), key=str.encode) == [a["asset_id"] for a in l["assets"]] + if "describes_assets" not in SKIP and programme_assets(l, s) is None: + return False + # Rule 5 over the programmes' assets: every program entry's asset in a programme's span. + held = {x["asset_id"] for a, b in spans(l) for x in s if x["kind"] == "program" and a <= x["start_ms"] < b} + return "describes_set" in SKIP or sorted(held, key=str.encode) == [a["asset_id"] for a in l["assets"]] # ---------------------------------------------------------------- §7 the guide @@ -1459,7 +1462,7 @@ def main(corpus_dir): checks = ["inputs", "size", "fields", "timezone", "no_stop", "asset", "duplicate", "counts", "doctype", "conflict", "scte35", "break_duration", "duration_unknown", "gap", "overlap", "whole_seconds", "owner", "artwork", "break_overlap", "break_outside", "window", "receipt_sig", "receipt_rules", "lineup_sig", "signer", - "describes", "source", "entries", "bytes"] + "describes_root", "describes_starts", "describes_assets", "describes_set", "source", "entries", "bytes"] weak = 0 for c in checks: SKIP = {c} diff --git a/wai/live-conformance/channels/vectors.json b/wai/live-conformance/channels/vectors.json index 971f27b15..b2423cdd7 100644 --- a/wai/live-conformance/channels/vectors.json +++ b/wai/live-conformance/channels/vectors.json @@ -1498,17 +1498,65 @@ } }, { - "name": "lineup_schedule_mismatch", - "about": "a lineup, signed, whose second programme begins a minute late, where no entry does", + "name": "lineup_other_root", + "about": "a lineup, signed, naming another schedule root (§6.5 rule 2)", "receipt": "sample.channel.json", - "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a1010000a0faed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a68009f893de9bb6f63b46b0a813a8aacc4b4ed59182313cbbccb9558a41ab1c6680c1cee7a4e30274065e7fe18fcf76cac02685ae0820b74c6a373a3fbbf6608", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e6500000000000000000000000000000000000000000000000000000000000000000099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a91f6f497fac376fd5473b61b99c97fcb262c4412aebdbc8364dd531e6dfd9096272792853ec0ffaf94bd641629a8c46846d0453eb8f3b01ab3586135eca5b20b", "guide": null, "edits": [], "facts": { "verdict": "lineup_schedule_mismatch", "part": "lineup", "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", - "lineup": "4c653fb1f6f1824e22723d750307c2f75181908d60a775885b25bb000f12d59c", + "lineup": "07454d8e14e984a80580c774f0840f9d553b83dcbc1aa35b691d7dc2c4c0c506", + "guide": null, + "entry": null + } + }, + { + "name": "lineup_programme_mid_entry", + "about": "a lineup, signed, with a programme that begins inside an entry (§6.5 rule 3)", + "receipt": "sample.channel.json", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000050000000099d188a1010000e02cd688a10100004010ed88a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a43552d34a753e436ff992d91eedae90a128720e12546ea0a2f5f81fff976ac11dcd9af87800a6b79327f4947e8829203135186c85ea6d627a37cb5f4dce72e04", + "guide": null, + "edits": [], + "facts": { + "verdict": "lineup_schedule_mismatch", + "part": "lineup", + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "fb102745262a9c9cca09a2751a333b9d8e035325d01f92955d02cd2bd131afb4", + "guide": null, + "entry": null + } + }, + { + "name": "lineup_programme_two_assets", + "about": "a lineup, signed, whose first programme holds two assets' program entries (§6.5 rule 4)", + "receipt": "sample.channel.json", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000030000000099d188a101000080870889a1010000c0fe2389a10100000400000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a20fcdb45c1078dc80ffc7de408c62217bcafc9b8cbde1805ba9ebbeb6d43933932c663a5e6c1471c72699c0dc39032b858d51f72803dbe7806cbe9274ade9e0f", + "guide": null, + "edits": [], + "facts": { + "verdict": "lineup_schedule_mismatch", + "part": "lineup", + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "6501ee75a48b77d14d91fa2c4224b052218ad2b8787eed9bd114ce86988f9725", + "guide": null, + "entry": null + } + }, + { + "name": "lineup_asset_unused", + "about": "a lineup, signed, describing an asset no programme airs (§6.5 rule 5)", + "receipt": "sample.channel.json", + "lineup": "574c5531010873616d706c652d310a53616d706c65204f6e65d3e3cd0db705a43fba441941b0a3d6bfa9b76e3ee2d8533a0abfa9db39f73b830099d188a101000000763f89a1010000040000000099d188a10100004010ed88a101000080870889a1010000c0fe2389a10100000500000006686c2d3130310e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b818000000000006686c2d3130320e00486172626f7572204c6967687473180054686520737570706c7920626f6174206973206c6174652e01054472616d610a75726e3a762d636869700574762d7067016a8f8b10ac737d9ebb3dd37def2a86241457f6e9bc65543ec5b442c7c4ec48d32f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130322e7376673f773d33323002014445076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130322f696e6465782e6d33753820b8180000000000046e662d310d004e69676874204672656967687431004f7665726e69676874206f6e2074686520636f617374206c696e652026207468652079617264732069742066656564732e010b446f63756d656e746172790556434849500554562d3134012eb2a3fb7d3b281f82df4c1e5986671ab612fa8f81b4e96a559febf0f93688b12d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d6e662d312e7376673f773d3332300000076f776e65722d630000000000000000000004746b2d37100054696465706f6f6c204b69746368656e25004b656c702c20637261622026206c656d6f6e2c206f6e20612062656163682073746f76652e0204466f6f640646616d696c790000011cd8560940d1840e86cf8658e29df1f317d6eaafedef8c9d6aa8fa1a9ce2b8602d0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d746b2d372e7376673f773d3332300000076f776e65722d62210068747470733a2f2f6d656469612e6578616d706c652f622f746b2d372e6d337538502d190000000000097a7a2d756e757365640e00486172626f7572204c6967687473230041206c69676874686f757365206b656570657227732066697273742077696e7465722e01054472616d610a75726e3a762d636869700574762d706701f4d849a415c7b8468dac7066456dc1566fc320fcf7ced5c976362ce08945dcdc2f0068747470733a2f2f6172742e6578616d706c652f73616d706c652f6172742d686c2d3130312e7376673f773d3332300103434147425553076f776e65722d61290068747470733a2f2f6d656469612e6578616d706c652f612f686c2d3130312f696e6465782e6d33753820b8180000000000da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a0760f271a3b6302c64dc7e88682fabd2749275805b519d39f951f6ad120839983c30dbe9372a1bbe28dea63c1f976b52e5fb07e467dfb142ab853be510186a00", + "guide": null, + "edits": [], + "facts": { + "verdict": "lineup_schedule_mismatch", + "part": "lineup", + "receipt": "c60c5ab6fbb1748b3cb0957f8576a5e1f634502398637bb75159c331bdbd78da", + "lineup": "485d82cbb2c278cece8ae2d4b326ab62bb13ebc90c714746a83b690febdedccd", "guide": null, "entry": null } diff --git a/wai/wai-rs/src/channel_lineup/corpus.rs b/wai/wai-rs/src/channel_lineup/corpus.rs index a0b50bea2..35211b81b 100644 --- a/wai/wai-rs/src/channel_lineup/corpus.rs +++ b/wai/wai-rs/src/channel_lineup/corpus.rs @@ -488,9 +488,21 @@ fn verification_vectors(sample: &Imported) -> Vec { small_key.signer = small.key; small_key.sig = small.sig; let other = l.clone().sign(&seed("another operator")); - let mut moved = l.clone(); - moved.programmes[1] += MIN; - let moved = moved.sign(&key); + // 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!([])), @@ -506,7 +518,10 @@ fn verification_vectors(sample: &Imported) -> Vec { ("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_schedule_mismatch", "a lineup, signed, whose second programme begins a minute late, where no entry does", rc, lineup_hex(&moved), None, json!([])), + ("lineup_other_root", "a lineup, signed, naming another schedule root (§6.5 rule 2)", rc, lineup_hex(&rooted), None, json!([])), + ("lineup_programme_mid_entry", "a lineup, signed, with a programme that begins inside an entry (§6.5 rule 3)", rc, lineup_hex(&mid), None, json!([])), + ("lineup_programme_two_assets", "a lineup, signed, whose first programme holds two assets' program entries (§6.5 rule 4)", rc, lineup_hex(&merged), None, json!([])), + ("lineup_asset_unused", "a lineup, signed, describing an asset no programme airs (§6.5 rule 5)", rc, lineup_hex(&unused), None, json!([])), ("guide_malformed", "a guide cut short", rc, good.clone(), Some(g), json!([edit(g, "\n", "")])), ("guide_source_mismatch", "a guide naming another schedule", rc, good.clone(), Some(g), json!([edit(g, &urn_root, &"0".repeat(64))])), ("guide_entry_unscheduled", "a guide that places Night Freight where the schedule places Harbour Lights", rc, good.clone(), Some(g), json!([edit(g, ">hl-102", ">nf-1")])), @@ -600,7 +615,7 @@ pub fn generate() -> Vec<(String, String)> { 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 = !name.contains("malformed") && !name.contains("mismatch") && !name.contains("bad_") && !name.contains("invalid") && !name.contains("unscheduled"); + 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()); -- 2.54.0 (Apple Git-157) From b929c31aec39cdd9cb3225342f7d437ea82365b7 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 29 Oct 2026 17:35:12 -0400 Subject: [PATCH 08/11] wai: the corpus's SCTE-35 sections, five bytes of pts_adjustment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generator wrote four bytes where splice_info_section() has five (the encryption bits and the 33-bit pts_adjustment), so splice_command_type fell on byte 12 and every cue took the hashed cue_id: the splice_event_id path of §4.8 was never reached, and both implementations agreed on the wrong bytes. A falsification that broke that path showed it. The sections now have the layout of SCTE 35-1 §9.6, and a unit test reads §14.2's splice_insert() and §14.1's time_signal() samples: 0x4800008F, and the break id's hash. Co-Authored-By: Claude Opus 5.5 --- wai/live-conformance/channels/sample.json | 8 ++++---- wai/live-conformance/channels/sample.m3u8 | 10 +++++----- wai/live-conformance/channels/vectors.json | 16 ++++++++-------- wai/wai-rs/src/channel_lineup/corpus.rs | 7 ++++--- wai/wai-rs/src/channel_lineup/tests.rs | 20 ++++++++++++++++++++ 5 files changed, 41 insertions(+), 20 deletions(-) diff --git a/wai/live-conformance/channels/sample.json b/wai/live-conformance/channels/sample.json index 587f5520b..a1f10e837 100644 --- a/wai/live-conformance/channels/sample.json +++ b/wai/live-conformance/channels/sample.json @@ -126,25 +126,25 @@ "id": "brk-1", "start_ms": 1793297520000, "duration_ms": 90000, - "scte35": "fc30240000000000fffff01405000003e97feffe03dcc500fe007b98a00001000000004c30d1ad" + "scte35": "fc3025000000000000fffff01405000003e97feffe03dcc500fe007b98a00001000000008f389a15" }, { "id": "brk-2", "start_ms": 1793298480000, "duration_ms": 120000, - "scte35": "fc30150000000000fffff00506fe09032100000021d72ce8" + "scte35": "fc3016000000000000fffff00506fe090321000000f83beb5f" }, { "id": "brk-3", "start_ms": 1793299530500, "duration_ms": 60000, - "scte35": "fc30240000000000fffff01405000003eb7feffe0ea5c568fe005265c000010000000086b9c532" + "scte35": "fc3025000000000000fffff01405000003eb7feffe0ea5c568fe005265c000010000000045b18e8a" }, { "id": "brk-4", "start_ms": 1793301240000, "duration_ms": 90000, - "scte35": "fc30240000000000fffff01405000003ec7feffe17d16980fe007b98a0000100000000569720e7" + "scte35": "fc3025000000000000fffff01405000003ec7feffe17d16980fe007b98a0000100000000959f6b5f" } ] } diff --git a/wai/live-conformance/channels/sample.m3u8 b/wai/live-conformance/channels/sample.m3u8 index 90eef9ed0..1fa9836e9 100644 --- a/wai/live-conformance/channels/sample.m3u8 +++ b/wai/live-conformance/channels/sample.m3u8 @@ -5,13 +5,13 @@ #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=0xFC30240000000000FFFFF01405000003E97FEFFE03DCC500FE007B98A00001000000004C30D1AD +#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=0xFC301F0000000000FFFFF00F05000003E97F4FFE04585DA00001000000005A2DF1BC +#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=0xFC30150000000000FFFFF00506FE09032100000021D72CE8 -#EXT-X-DATERANGE:ID="brk-3",START-DATE="2026-10-29T18:45:30.500Z",PLANNED-DURATION=60,SCTE35-OUT=0xfc30240000000000fffff01405000003eb7feffe0ea5c568fe005265c000010000000086b9c532 -#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=0XFC30240000000000FFFFF01405000003EC7FEFFE17D16980FE007B98A0000100000000569720E7 +#EXT-X-DATERANGE:ID="brk-2",START-DATE="2026-10-29T18:28:00.000Z",DURATION=120.000,SCTE35-OUT=0xFC3016000000000000FFFFF00506FE090321000000F83BEB5F +#EXT-X-DATERANGE:ID="brk-3",START-DATE="2026-10-29T18:45:30.500Z",PLANNED-DURATION=60,SCTE35-OUT=0xfc3025000000000000fffff01405000003eb7feffe0ea5c568fe005265c000010000000045b18e8a +#EXT-X-DATERANGE:ID="brk-4",START-DATE="2026-10-29T20:14:00+01:00",END-DATE="2026-10-29T19:15:30.000Z",SCTE35-OUT=0XFC3025000000000000FFFFF01405000003EC7FEFFE17D16980FE007B98A0000100000000959F6B5F #EXTINF:6.000, seg-122.ts diff --git a/wai/live-conformance/channels/vectors.json b/wai/live-conformance/channels/vectors.json index b2423cdd7..13c3ab6cd 100644 --- a/wai/live-conformance/channels/vectors.json +++ b/wai/live-conformance/channels/vectors.json @@ -50,22 +50,22 @@ { "break": "brk-1", "start_ms": 1793297520000, - "payload": "04cf47598c905f0100012700fc30240000000000fffff01405000003e97feffe03dcc500fe007b98a00001000000004c30d1ad" + "payload": "04e9030000905f0100012800fc3025000000000000fffff01405000003e97feffe03dcc500fe007b98a00001000000008f389a15" }, { "break": "brk-2", "start_ms": 1793298480000, - "payload": "041733d0f9c0d40100011800fc30150000000000fffff00506fe09032100000021d72ce8" + "payload": "041733d0f9c0d40100011900fc3016000000000000fffff00506fe090321000000f83beb5f" }, { "break": "brk-3", "start_ms": 1793299530500, - "payload": "048e32f8b160ea0000012700fc30240000000000fffff01405000003eb7feffe0ea5c568fe005265c000010000000086b9c532" + "payload": "04eb03000060ea0000012800fc3025000000000000fffff01405000003eb7feffe0ea5c568fe005265c000010000000045b18e8a" }, { "break": "brk-4", "start_ms": 1793301240000, - "payload": "04d2ba2307905f0100012700fc30240000000000fffff01405000003ec7feffe17d16980fe007b98a0000100000000569720e7" + "payload": "04ec030000905f0100012800fc3025000000000000fffff01405000003ec7feffe17d16980fe007b98a0000100000000959f6b5f" } ] } @@ -101,22 +101,22 @@ { "break": "brk-1", "start_ms": 1793297520000, - "payload": "04cf47598c905f0100012700fc30240000000000fffff01405000003e97feffe03dcc500fe007b98a00001000000004c30d1ad" + "payload": "04e9030000905f0100012800fc3025000000000000fffff01405000003e97feffe03dcc500fe007b98a00001000000008f389a15" }, { "break": "brk-2", "start_ms": 1793298480000, - "payload": "041733d0f9c0d40100011800fc30150000000000fffff00506fe09032100000021d72ce8" + "payload": "041733d0f9c0d40100011900fc3016000000000000fffff00506fe090321000000f83beb5f" }, { "break": "brk-3", "start_ms": 1793299530500, - "payload": "048e32f8b160ea0000012700fc30240000000000fffff01405000003eb7feffe0ea5c568fe005265c000010000000086b9c532" + "payload": "04eb03000060ea0000012800fc3025000000000000fffff01405000003eb7feffe0ea5c568fe005265c000010000000045b18e8a" }, { "break": "brk-4", "start_ms": 1793301240000, - "payload": "04d2ba2307905f0100012700fc30240000000000fffff01405000003ec7feffe17d16980fe007b98a0000100000000569720e7" + "payload": "04ec030000905f0100012800fc3025000000000000fffff01405000003ec7feffe17d16980fe007b98a0000100000000959f6b5f" } ] } diff --git a/wai/wai-rs/src/channel_lineup/corpus.rs b/wai/wai-rs/src/channel_lineup/corpus.rs index 35211b81b..396955c32 100644 --- a/wai/wai-rs/src/channel_lineup/corpus.rs +++ b/wai/wai-rs/src/channel_lineup/corpus.rs @@ -48,10 +48,11 @@ fn crc32_mpeg2(b: &[u8]) -> u32 { c } -/// A `splice_info_section()` around a command: no encryption, `pts_adjustment` 0, `tier` -/// 0xFFF, no descriptor, and its CRC_32. +/// A `splice_info_section()` around a command: `protocol_version` 0; no encryption and +/// `pts_adjustment` 0 (the 40 bits after it); `cw_index` 0xFF; `tier` 0xFFF; no descriptor; +/// and its CRC_32. Its `splice_command_type` is byte 13, as in SCTE 35-1 §14's samples. fn section(command_type: u8, command: &[u8]) -> Vec { - let mut body = vec![0x00, 0x00, 0x00, 0x00, 0x00, 0xFF]; + 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); diff --git a/wai/wai-rs/src/channel_lineup/tests.rs b/wai/wai-rs/src/channel_lineup/tests.rs index bd641955f..12634dda7 100644 --- a/wai/wai-rs/src/channel_lineup/tests.rs +++ b/wai/wai-rs/src/channel_lineup/tests.rs @@ -51,3 +51,23 @@ fn every_reason_has_its_code() { fn the_capability_is_the_registrys() { assert_eq!(CAPABILITY, crate::codecs::registry::CAP_VIDEO_LINEUP); } + +fn unhex_any(s: &str) -> Vec { + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect() +} + +/// The sample messages of ANSI/SCTE 35-1 2023r2 §14.2 (a `splice_insert()`) and §14.1 (a +/// `time_signal()`): each reads as live-events §4.3 reads form 1, the first's cue id is its +/// `splice_event_id` (0x4800008F), and the second's, a command with none, is the break id's. +#[test] +fn the_cue_id_is_a_splice_inserts_event_id() { + let insert = unhex_any("FC302F000000000000FFFFF014054800008F7FEFFE7369C02EFE0052CCF500000000000A0008435545490000013562DBA30A"); + let signal = unhex_any("FC3034000000000000FFFFF00506FE72BD0050001E021C435545494800008E7FCF0001A599B00808000000002CA0A18A3402009AC9D17E"); + assert_eq!(insert.len(), 50); + assert_eq!(import::cue_id("brk", Some(&insert)), 0x4800_008F); + assert_ne!(import::cue_id("brk", Some(&signal)), 0x4800_008E); + assert_eq!(import::cue_id("brk", Some(&signal)), import::cue_id("brk", None)); + let mut encrypted = insert.clone(); + encrypted[4] |= 0x80; + assert_eq!(import::cue_id("brk", Some(&encrypted)), import::cue_id("brk", None), "an encrypted command is not read"); +} -- 2.54.0 (Apple Git-157) From 3320d5b59c995bcc7f93cc3fed8d343c4c05db73 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 29 Oct 2026 19:49:28 -0400 Subject: [PATCH 09/11] =?UTF-8?q?wai:=20one=20JSON=20carriage=20for=20the?= =?UTF-8?q?=20channel=20receipt,=20revenue-share=20=C2=A74.1's?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit channel_lineup's own receipt_to_json and receipt_from_json are gone: the import writes a receipt with ChannelReceipt::to_json and a verification reads one with ChannelReceipt::from_json, the one JSON form of a channel receipt. channel-lineup §5 keeps its number and is now a normative reference to revenue-share §4.1 (the carriage), §4.2 (leaves, root, signed bytes, identity) and §4.3 (rules 1 to 5, the signature rule 6). channel_lineup_verify.py reads receipts with revenue_share_verify.py's read_run, run_payload, run_identity and run_verifies, and no longer takes anything from live_ads_verify.py. Before the pair was removed, a test (not kept) ran both readers and both writers over every corpus receipt: the three receipt files, the two receipts verification vectors edit, and the sample sealed with each energy class: 9 receipts, 8 read and 1 refused by both, every reading, every JSON value and every file identical. After it, the corpus regenerates byte for byte. Co-Authored-By: Claude Opus 5.5 --- wai/CHANGELOG.md | 5 +- wai/extensions/channel-lineup.md | 70 ++----- wai/live-conformance/channel_lineup_verify.py | 133 ++------------ wai/wai-rs/src/bin/wai_cli/dx/channel.rs | 4 +- wai/wai-rs/src/channel_lineup.rs | 172 ++---------------- 5 files changed, 54 insertions(+), 330 deletions(-) diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 4482ef491..8a5c89fbe 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -51,8 +51,9 @@ wire format may change and such changes are recorded here. 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**: `wai.video.channel` receipts keep their signed bytes; the text - gives them a JSON carriage, their payload and identity, which a second implementation reads. +- **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 diff --git a/wai/extensions/channel-lineup.md b/wai/extensions/channel-lineup.md index c97228407..681d904b6 100644 --- a/wai/extensions/channel-lineup.md +++ b/wai/extensions/channel-lineup.md @@ -9,8 +9,8 @@ > **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; this text gives it a JSON carriage. No new -> transport. The reference implementation is the `channel_lineup` module of `wai-rs` (feature +> `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. @@ -38,8 +38,8 @@ ## 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 of a -`wai.video.channel` receipt (§5); the lineup, its rules, its signature and how it binds its +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). @@ -57,7 +57,8 @@ A guide is not a record: it is a rendering of two records that anyone recomputes 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; a lineup binds the receipt's +`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). @@ -414,49 +415,14 @@ An import that is refused yields nothing. ## 5. The channel receipt's JSON carriage -A `wai.video.channel` receipt keeps its signed bytes. Its **JSON carriage**, in which an import -writes it and a verifier reads it, is one JSON object with exactly these members, a byte field -lowercase hexadecimal of its exact length: - -| member | value | -|---|---| -| `channel_id` | a string | -| `schedule_root` | 32 bytes | -| `schedule` | an array of entries, each `{"asset_id", "start_ms", "duration_ms", "kind"}`: a string, two integers, and `program`, `ad` or `filler` | -| `window_start_ms`, `window_end_ms` | integers | -| `aired` | an array of integers: the indices of the entries on air in the window | -| `work_schedule_ops`, `joules_micro` | integers | -| `energy_provenance` | its class's label, or `null` unmetered; and the members a class with evidence carries, as live-ads §8.2 has them. The legacy label `HwShunt` does not read in this carriage | -| `grant` | `{"grant_hash", "capability", "joule_ceiling_micro", "funds_ceiling"}`, `funds_ceiling` `null` for none | -| `parent_receipt_hash` | 32 bytes, or `null` | -| `signer_pubkey` | 32 bytes | -| `signer_id` | a string | -| `sig` | 64 bytes | - -Its integers are written as §4.4 writes them, but up to 2^64 − 1, and an entry whose start and -duration sum above 2^64 − 1 does not read. The receipt's integers below are big-endian. An entry's **leaf** is -BLAKE3 over `wai:video-channel-entry` (ASCII) and 0x01, the length (u32) and bytes of its -`asset_id`, its `start_ms` and `duration_ms` (u64 each), and its kind (1 program, 2 ad, 3 filler, -one byte). Its **signing payload** is the concatenation of `wai:video-channel-receipt` (ASCII) -and 0x01; the length (u32) and bytes of `channel_id`; `schedule_root`; `window_start_ms`, -`window_end_ms`, `work_schedule_ops` and `joules_micro` (u64 each); the class in its signed -encoding (energy-measurement §2.7), the tag byte 0 for none; `grant_hash`, the length (u32) and -bytes of `capability`, `joule_ceiling_micro` (u64), and 1 and `funds_ceiling` (u64), or 0; 1 and -`parent_receipt_hash`, or 0; the count (u64) of `aired` and each index (u64); the count (u64) of -the entries and each one's leaf; and `signer_pubkey`, then the length (u32) and bytes of -`signer_id`. `sig` is Ed25519 by `signer_pubkey` over the payload, and the receipt's **identity** -(its receipt hash) is BLAKE3 over `wai:video-channel-receipt-id` (ASCII) and 0x01, the payload -and `sig`. - -A receipt's **rules** hold when `schedule_root` is the Merkle root of its entries' leaves -(jwp-receipts §2.2, each leaf of its first form; 32 zero bytes for no entry); `work_schedule_ops` -is the count of its entries; its window is not empty; each entry begins where the one before it -ends; `aired` is exactly the indices, in increasing order, of the entries whose span meets the -window (an entry that begins before the window's end and ends after its start); and its figure -and class cohere, as live-ads §8.5 rule 5 has them: `joules_micro` 0 with no class, or above 0 -with a class, a hardware class's declared uncertainty with `relative_ppm` and `window_us` above 0 -and at the resolution floor (`joules_micro` × `relative_ppm` at least 288 676), and a calibrated -instrument's reference holding a character that is not Unicode White_Space. A receipt **verifies** when its signature verifies and its rules hold. +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 @@ -633,7 +599,7 @@ guide's bytes: an XMLTV guide when its first byte is `<`, a now/next document wh 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`: `bad_key`, `bad_signature`. +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`. @@ -771,9 +737,9 @@ regenerates byte for byte, that `wai-rs` gives every vector its outcome, that § 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 the Merkle root -and an energy class's signed encoding as the live-ads corpus's second implementation computes -them; it reads XML, JSON and playlists itself; and it mutates copies of the corpus and requires +(`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. diff --git a/wai/live-conformance/channel_lineup_verify.py b/wai/live-conformance/channel_lineup_verify.py index 4f4c7f1c3..70c2f1511 100644 --- a/wai/live-conformance/channel_lineup_verify.py +++ b/wai/live-conformance/channel_lineup_verify.py @@ -4,15 +4,15 @@ Written from the extension's text. It shares no code with wai-rs, which generates the corpus, and never runs it. Ed25519 signatures are checked with ../claim-conformance/_ed25519.py, WAI's strict rule (SPEC §7 *Signatures*), and BLAKE3 with ../claim-conformance/_blake3.py. From the -corpora's other second implementations it takes, by reference as the extension does: the Merkle -root of jwp-receipts §2.2 and an energy class's signed encoding from live_ads_verify.py; and the -XML subset, the HLS attribute lists and the decimal reading of stitched-presentation §3.2 and -§3.5 from stitched_presentation_verify.py. Everything else is here, from the standard library: +corpora's other second implementations it takes, by reference as the extension does: a channel +receipt's JSON carriage, its leaves and schedule root, its signed bytes, its identity and its +rules (revenue-share §4, the extension's §5) from revenue_share_verify.py; and the XML subset, the +HLS attribute lists and the decimal reading of stitched-presentation §3.2 and §3.5 from +stitched_presentation_verify.py. Everything else is here, from the standard library: - text values, XMLTV and HLS times, durations, and the document type declaration (§3); - the import of an XMLTV schedule, Media RSS catalogues, the JSON form and a Media Playlist's breaks into a schedule, its assets and its cues (§4); -- the channel receipt's JSON carriage, its leaves, payload, identity and rules (§5); - the lineup, its rules, digest and signature, and whether it describes a schedule (§6); - the XMLTV guide and the now/next document (§7), and verification (§8). @@ -42,23 +42,19 @@ sys.path.insert(0, HERE) sys.path.insert(0, os.path.join(HERE, "..", "claim-conformance")) import _blake3 as B3 # noqa: E402 import _ed25519 as ED # noqa: E402 -import live_ads_verify as LAV # noqa: E402 +import revenue_share_verify as RSV # noqa: E402 import stitched_presentation_verify as SPV # noqa: E402 EXTENSION = os.path.join(HERE, "..", "extensions", "channel-lineup.md") MAGIC, VERSION = b"WLU1", 1 # §6.1 LINEUP_DOMAIN = b"wai:channel-lineup\x01" # §6.3 -ENTRY_DOMAIN = b"wai:video-channel-entry\x01" # §5 -RECEIPT_DOMAIN = b"wai:video-channel-receipt\x01" -RECEIPT_ID_DOMAIN = b"wai:video-channel-receipt-id\x01" CUE_ID_DOMAIN = b"wai:channel-cue-id\x01" # §4.8 ID_SYSTEM = "wai-asset-id" # §7.2 GUIDE_URN = "urn:wai:guide:" NOW_NEXT_KIND, FEED_KIND = "wai-now-next", "wai-channel-feed" MAX_DOC, MAX_COUNT, MAX_INSTANT = 16777216, 10000, 2 ** 53 - 1 # §2, §3.1 U32, U64 = 2 ** 32 - 1, 2 ** 64 - 1 -KINDS = {"program": 1, "ad": 2, "filler": 3} EVERYWHERE, ONLY, EXCEPT = 0, 1, 2 # §6.2 geo DIGITS = "0123456789" WS = " \t\r\n" @@ -809,25 +805,15 @@ def do_import(inp, files): # ---------------------------------------------------------------- §5 the receipt -def leaf(e): - a = e["asset_id"].encode() - return B3.blake3(ENTRY_DOMAIN + struct.pack(">I", len(a)) + a + struct.pack(">QQ", e["start_ms"], e["duration_ms"]) - + bytes([KINDS[e["kind"]]])) - - def schedule_root(entries): - return LAV.merkle([leaf(e) for e in entries]) + """revenue-share §4.2: the Merkle root of the entries' leaves.""" + return RSV.merkle([RSV.leaf(e) for e in entries]) def aired_of(entries, ws, we): return [i for i, e in enumerate(entries) if e["start_ms"] < we and e["start_ms"] + e["duration_ms"] > ws] -RECEIPT_MEMBERS = ["channel_id", "schedule_root", "schedule", "window_start_ms", "window_end_ms", "aired", - "work_schedule_ops", "joules_micro", "energy_provenance", "grant", "parent_receipt_hash", "signer_pubkey", - "signer_id", "sig"] - - def u64(v): return isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= U64 @@ -839,108 +825,27 @@ def hexn(v, n): def read_receipt(b): - """§5: the receipt, or None.""" - v = strict_json(b) - if not isinstance(v, dict): + """§5: one JSON text of revenue-share §4.1's carriage, read as revenue-share's second + implementation reads it; the run, or None.""" + try: + r = RSV.read_run(json.loads(b.decode("utf-8"))) + RSV.run_payload(r) + return r + except (UnicodeError, ValueError, RSV.NoRead, KeyError, TypeError): return None - label = v.get("energy_provenance", 0) - if label is not None and label not in LAV.EVIDENCE: - return None - if not exactly(v, RECEIPT_MEMBERS + LAV.EVIDENCE.get(label, [])): - return None - r = {k: v[k] for k in ("channel_id", "signer_id")} - if not all(isinstance(r[k], str) for k in r): - return None - for k, n in (("schedule_root", 32), ("signer_pubkey", 32), ("sig", 64)): - r[k] = hexn(v[k], n) - if r[k] is None: - return None - for k in ("window_start_ms", "window_end_ms", "work_schedule_ops", "joules_micro"): - if not u64(v[k]): - return None - r[k] = v[k] - if not isinstance(v["aired"], list) or not all(u64(i) for i in v["aired"]): - return None - r["aired"] = v["aired"] - if not isinstance(v["schedule"], list): - return None - r["schedule"] = [] - for e in v["schedule"]: - if not (exactly(e, ["asset_id", "start_ms", "duration_ms", "kind"]) and isinstance(e["asset_id"], str) - and u64(e["start_ms"]) and u64(e["duration_ms"]) and e["kind"] in KINDS - and e["start_ms"] + e["duration_ms"] <= U64): - return None - r["schedule"].append(e) - g = v["grant"] - if not (exactly(g, ["grant_hash", "capability", "joule_ceiling_micro", "funds_ceiling"]) and isinstance(g["capability"], str) - and u64(g["joule_ceiling_micro"]) and (g["funds_ceiling"] is None or u64(g["funds_ceiling"])) - and hexn(g["grant_hash"], 32) is not None): - return None - r["grant"] = dict(g, grant_hash=hexn(g["grant_hash"], 32)) - p = v["parent_receipt_hash"] - r["parent"] = None if p is None else hexn(p, 32) - if p is not None and r["parent"] is None: - return None - r["class"] = None - if label is not None: - c = {"label": label} - if label in ("OnChipCounter", "CalibratedInstrument"): - unc = v["energy_uncertainty"] - if not (exactly(unc, ["relative_ppm", "window_us"]) and u64(unc["relative_ppm"]) and unc["relative_ppm"] <= U32 - and u64(unc["window_us"])): - return None - c["ppm"], c["window"] = unc["relative_ppm"], unc["window_us"] - if label == "OnChipCounter": - if v["energy_counter_filtering"] not in LAV.FILTERING: - return None - c["filtering"] = LAV.FILTERING[v["energy_counter_filtering"]] - if label == "CalibratedInstrument": - if not isinstance(v["energy_calibration_ref"], str): - return None - c["ref"] = v["energy_calibration_ref"] - r["class"] = c - return r def receipt_payload(r): - cid, sid, cap = r["channel_id"].encode(), r["signer_id"].encode(), r["grant"]["capability"].encode() - g = r["grant"] - out = [RECEIPT_DOMAIN, struct.pack(">I", len(cid)), cid, r["schedule_root"], - struct.pack(">QQQQ", r["window_start_ms"], r["window_end_ms"], r["work_schedule_ops"], r["joules_micro"]), - LAV.class_bytes(r["class"]), g["grant_hash"], struct.pack(">I", len(cap)), cap, struct.pack(">Q", g["joule_ceiling_micro"]), - b"\x00" if g["funds_ceiling"] is None else b"\x01" + struct.pack(">Q", g["funds_ceiling"]), - b"\x00" if r["parent"] is None else b"\x01" + r["parent"], - struct.pack(">Q", len(r["aired"]))] + [struct.pack(">Q", i) for i in r["aired"]] - out += [struct.pack(">Q", len(r["schedule"]))] + [leaf(e) for e in r["schedule"]] - out += [r["signer_pubkey"], struct.pack(">I", len(sid)), sid] - return b"".join(out) + return RSV.run_payload(r) def receipt_identity(r): - return B3.blake3(RECEIPT_ID_DOMAIN + receipt_payload(r) + r["sig"]) + return RSV.run_identity(r) def receipt_rules(r): - """§5's rules: True when they hold.""" - s = r["schedule"] - if "receipt_rules" in SKIP: - return True - if schedule_root(s) != r["schedule_root"] or r["work_schedule_ops"] != len(s): - return False - if r["window_start_ms"] >= r["window_end_ms"]: - return False - if any(s[i]["start_ms"] + s[i]["duration_ms"] != s[i + 1]["start_ms"] for i in range(len(s) - 1)): - return False - if aired_of(s, r["window_start_ms"], r["window_end_ms"]) != r["aired"]: - return False - c, j = r["class"], r["joules_micro"] - if c is None: - return j == 0 - if j == 0: - return False - if c["label"] in ("OnChipCounter", "CalibratedInstrument") and (c["ppm"] == 0 or c["window"] == 0 or j * c["ppm"] < LAV.FLOOR): - return False - return not (c["label"] == "CalibratedInstrument" and all(ch in LAV.WHITE_SPACE for ch in c["ref"])) + """§5: revenue-share §4.3's rules 1 to 5 (rule 6, the signature, is §8.2 step 2's).""" + return "receipt_rules" in SKIP or RSV.run_verifies(r) # ---------------------------------------------------------------- §6 the lineup diff --git a/wai/wai-rs/src/bin/wai_cli/dx/channel.rs b/wai/wai-rs/src/bin/wai_cli/dx/channel.rs index b57957a44..1f4c3c326 100644 --- a/wai/wai-rs/src/bin/wai_cli/dx/channel.rs +++ b/wai/wai-rs/src/bin/wai_cli/dx/channel.rs @@ -216,7 +216,7 @@ fn sources(args: &[String], usage: &str) -> Result<(Vec, Vec), OpError> /// `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, receipt_from_json, verify, xmltv_guide, Lineup}; + 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(); @@ -227,7 +227,7 @@ pub fn guide(args: &[String]) -> R { if let Err(r) = v.verdict { return Err(refused(r, "the receipt and the lineup do not verify")); } - let r = receipt_from_json(&rb).expect("it verified"); + 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 { diff --git a/wai/wai-rs/src/channel_lineup.rs b/wai/wai-rs/src/channel_lineup.rs index 24fc74a90..795c07d05 100644 --- a/wai/wai-rs/src/channel_lineup.rs +++ b/wai/wai-rs/src/channel_lineup.rs @@ -16,15 +16,12 @@ pub mod corpus; pub mod import; use ed25519_dalek::{Signer, SigningKey}; -use serde_json::{json, Map, Value}; +use serde_json::{json, Value}; use sha2::{Digest, Sha256}; -use crate::energy_class::{CounterFiltering, Uncertainty}; -use crate::quantum_ops::GrantRef; use crate::stitched_presentation::markers::{read_xml, Element}; use crate::strict_json::{self, Json}; use crate::wai_video_channel::{ChannelReceipt, EntryKind, ScheduleEntry}; -use crate::wai_video_manifest::EnergyProvenance; /// The record this module registers (SPEC §5). pub const CAPABILITY: &str = "wai.video.lineup"; @@ -438,7 +435,8 @@ pub(crate) fn first<'a>(e: &'a Element, name: &'a str) -> Option<&'a Element> { } // --------------------------------------------------------------------------------------- -// §5 The channel receipt's JSON carriage +// §5 The channel receipt's JSON carriage: revenue-share §4, `ChannelReceipt::to_json` and +// `from_json` // --------------------------------------------------------------------------------------- fn hex(b: &[u8]) -> String { @@ -464,80 +462,17 @@ fn unhex(s: &str) -> Option<[u8; N]> { Some(out) } -fn kind_label(k: EntryKind) -> &'static str { - match k { - EntryKind::Program => "program", - EntryKind::Ad => "ad", - EntryKind::Filler => "filler", - } -} - -/// A receipt in its JSON carriage (§5), its members in the table's order. -pub fn receipt_to_json(r: &ChannelReceipt) -> Value { - let mut m = Map::new(); - m.insert("channel_id".into(), json!(r.channel_id)); - m.insert("schedule_root".into(), json!(hex(&r.schedule_root))); - let schedule: Vec = r - .schedule - .iter() - .map(|e| json!({"asset_id": e.asset_id, "start_ms": e.start_ms, "duration_ms": e.duration_ms, "kind": kind_label(e.kind)})) - .collect(); - m.insert("schedule".into(), Value::Array(schedule)); - m.insert("window_start_ms".into(), json!(r.window_start_ms)); - m.insert("window_end_ms".into(), json!(r.window_end_ms)); - m.insert("aired".into(), json!(r.aired)); - m.insert("work_schedule_ops".into(), json!(r.work_schedule_ops)); - m.insert("joules_micro".into(), json!(r.joules_micro)); - m.insert("energy_provenance".into(), r.energy_provenance.as_ref().map_or(Value::Null, |p| json!(p.label()))); - match &r.energy_provenance { - Some(EnergyProvenance::OnChipCounter { uncertainty, filtering }) => { - m.insert("energy_uncertainty".into(), json!({"relative_ppm": uncertainty.relative_ppm, "window_us": uncertainty.window_us})); - m.insert("energy_counter_filtering".into(), json!(filtering.label())); - } - Some(EnergyProvenance::CalibratedInstrument { uncertainty, calibration_ref }) => { - m.insert("energy_uncertainty".into(), json!({"relative_ppm": uncertainty.relative_ppm, "window_us": uncertainty.window_us})); - m.insert("energy_calibration_ref".into(), json!(calibration_ref)); - } - _ => {} - } - m.insert( - "grant".into(), - json!({ - "grant_hash": hex(&r.grant.grant_hash), - "capability": r.grant.capability, - "joule_ceiling_micro": r.grant.joule_ceiling_micro, - "funds_ceiling": r.grant.funds_ceiling, - }), - ); - m.insert("parent_receipt_hash".into(), r.parent_receipt_hash.map_or(Value::Null, |h| json!(hex(&h)))); - m.insert("signer_pubkey".into(), json!(hex(&r.signer_pubkey))); - m.insert("signer_id".into(), json!(r.signer_id)); - m.insert("sig".into(), json!(hex(&r.sig))); - Value::Object(m) -} - -/// A receipt's JSON carriage as a file holds it: indented, one line feed after it. +/// A receipt's JSON carriage as a file holds it (§5): `ChannelReceipt::to_json`, revenue-share +/// §4.1's one JSON form of a channel receipt, indented, one line feed after it. pub fn receipt_text(r: &ChannelReceipt) -> String { - serde_json::to_string_pretty(&receipt_to_json(r)).expect("JSON") + "\n" + serde_json::to_string_pretty(&r.to_json()).expect("JSON") + "\n" } -/// The members of a receipt's JSON carriage (§5), but a class's evidence. -const RECEIPT_MEMBERS: &[&str] = &[ - "channel_id", - "schedule_root", - "schedule", - "window_start_ms", - "window_end_ms", - "aired", - "work_schedule_ops", - "joules_micro", - "energy_provenance", - "grant", - "parent_receipt_hash", - "signer_pubkey", - "signer_id", - "sig", -]; +/// Read a receipt's bytes in its JSON carriage (§5): one JSON text, read by +/// `ChannelReceipt::from_json`, revenue-share §4.1's reading. +pub fn read_receipt(bytes: &[u8]) -> Option { + serde_json::from_slice::(bytes).ok().and_then(|v| ChannelReceipt::from_json(&v)) +} /// An object whose members are exactly `want`, each once. fn exactly<'a>(j: &'a Json, want: &[&str]) -> Option<&'a [(String, Json)]> { @@ -545,89 +480,6 @@ fn exactly<'a>(j: &'a Json, want: &[&str]) -> Option<&'a [(String, Json)]> { (m.len() == want.len() && want.iter().all(|k| m.iter().any(|(n, _)| n == k))).then_some(m) } -/// Read a receipt's JSON carriage (§5). Whether it verifies is [`receipt_verdict`]'s. -pub fn receipt_from_json(bytes: &[u8]) -> Option { - let j = strict_json::read(bytes).ok()?; - let label = match j.get("energy_provenance")? { - Json::Null => None, - Json::Str(s) => Some(s.as_str()), - _ => return None, - }; - let mut want: Vec<&str> = RECEIPT_MEMBERS.to_vec(); - match label { - Some("OnChipCounter") => want.extend(["energy_uncertainty", "energy_counter_filtering"]), - Some("CalibratedInstrument") => want.extend(["energy_uncertainty", "energy_calibration_ref"]), - _ => {} - } - exactly(&j, &want)?; - let u = |k: &str| j.get(k)?.as_u64(); - let s = |k: &str| j.get(k)?.as_str().map(str::to_string); - let uncertainty = || -> Option { - let o = j.get("energy_uncertainty")?; - exactly(o, &["relative_ppm", "window_us"])?; - Some(Uncertainty { relative_ppm: u32::try_from(o.get("relative_ppm")?.as_u64()?).ok()?, window_us: o.get("window_us")?.as_u64()? }) - }; - let energy_provenance = match label { - None => None, - Some("ModelBased") => Some(EnergyProvenance::ModelBased), - Some("Estimator") => Some(EnergyProvenance::Estimator), - Some("OnChipCounter") => Some(EnergyProvenance::OnChipCounter { - uncertainty: uncertainty()?, - filtering: CounterFiltering::from_label(j.get("energy_counter_filtering")?.as_str()?)?, - }), - Some("CalibratedInstrument") => { - Some(EnergyProvenance::CalibratedInstrument { uncertainty: uncertainty()?, calibration_ref: s("energy_calibration_ref")? }) - } - Some(_) => return None, - }; - let g = j.get("grant")?; - exactly(g, &["grant_hash", "capability", "joule_ceiling_micro", "funds_ceiling"])?; - let grant = GrantRef { - grant_hash: unhex(g.get("grant_hash")?.as_str()?)?, - capability: g.get("capability")?.as_str()?.to_string(), - joule_ceiling_micro: g.get("joule_ceiling_micro")?.as_u64()?, - funds_ceiling: match g.get("funds_ceiling")? { - Json::Null => None, - f => Some(f.as_u64()?), - }, - }; - let mut schedule = Vec::new(); - for e in j.get("schedule")?.as_arr()? { - exactly(e, &["asset_id", "start_ms", "duration_ms", "kind"])?; - let kind = match e.get("kind")?.as_str()? { - "program" => EntryKind::Program, - "ad" => EntryKind::Ad, - "filler" => EntryKind::Filler, - _ => return None, - }; - let (start_ms, duration_ms) = (e.get("start_ms")?.as_u64()?, e.get("duration_ms")?.as_u64()?); - start_ms.checked_add(duration_ms)?; - schedule.push(ScheduleEntry { asset_id: e.get("asset_id")?.as_str()?.to_string(), start_ms, duration_ms, kind }); - } - let aired = j.get("aired")?.as_arr()?.iter().map(Json::as_u64).collect::>>()?; - let parent_receipt_hash = match j.get("parent_receipt_hash")? { - Json::Null => None, - Json::Str(h) => Some(unhex(h)?), - _ => return None, - }; - Some(ChannelReceipt { - channel_id: s("channel_id")?, - schedule_root: unhex(j.get("schedule_root")?.as_str()?)?, - schedule, - window_start_ms: u("window_start_ms")?, - window_end_ms: u("window_end_ms")?, - aired, - work_schedule_ops: u("work_schedule_ops")?, - joules_micro: u("joules_micro")?, - energy_provenance, - grant, - parent_receipt_hash, - signer_pubkey: unhex(j.get("signer_pubkey")?.as_str()?)?, - signer_id: s("signer_id")?, - sig: unhex(j.get("sig")?.as_str()?)?, - }) -} - /// A receipt's verdict (§8.2 steps 2 and 3): its signature, then its rules. pub fn receipt_verdict(r: &ChannelReceipt) -> Result<(), Reason> { crate::sig::verify_strict(&r.signer_pubkey, &r.payload(), &r.sig).map_err(sig_reason)?; @@ -1178,7 +1030,7 @@ pub fn verify(receipt: &[u8], lineup: &[u8], guide: Option<&[u8]>) -> Verificati v.part = Some(part); v }; - let Some(r) = receipt_from_json(receipt) else { return fail(v, "receipt", Reason::ChannelReceiptMalformed) }; + let Some(r) = read_receipt(receipt) else { return fail(v, "receipt", Reason::ChannelReceiptMalformed) }; v.receipt = Some(r.receipt_hash()); if let Err(e) = receipt_verdict(&r) { return fail(v, "receipt", e); -- 2.54.0 (Apple Git-157) From 88deef46757283850b6843a425966ff948c3a530 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 29 Oct 2026 19:58:39 -0400 Subject: [PATCH 10/11] wai: channel-lineup, spans written in words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The section-reference check pairs a bare [ with the next link, and §5 now links to revenue-share, so an interval written [a, b) before it read as a link's text. Each span is written from its start to its end instead; the meaning is unchanged. Co-Authored-By: Claude Opus 5.5 --- wai/extensions/channel-lineup.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/wai/extensions/channel-lineup.md b/wai/extensions/channel-lineup.md index 681d904b6..b3f99731a 100644 --- a/wai/extensions/channel-lineup.md +++ b/wai/extensions/channel-lineup.md @@ -87,7 +87,7 @@ yields comes from a playlist's SCTE-35 date range, and an import makes no inject 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 `[a, b)` holds the instants from `a`, included, to `b`, +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 @@ -177,7 +177,7 @@ An import is given: - **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, a span `[window_start, window_end)` of the receipt (§4.9); +- **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`. @@ -326,7 +326,7 @@ an even count of hexadecimal digits (either case, no `0x`) read as step 3 reads The programmes are, by the feeds: - **XMLTV**: those of §4.2; -- **the JSON form**: its `programmes`, each `[start_ms, stop_ms)` with its `asset_id`; none is +- **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 @@ -374,7 +374,7 @@ programme whose start is at most its start and whose end is at least its end, is 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, -`[start, start + duration)`, whose asset id is the break's id. The schedule is therefore gapless +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 @@ -520,7 +520,7 @@ A programme's ad and filler entries are its breaks; a guide lists the programme ### 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, to)`, with `from` below `to`, are the lineup's programmes whose span meets it (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). -- 2.54.0 (Apple Git-157) From cc2c7ead70f83e66a731679c704b867ee0e460cc Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 29 Oct 2026 22:22:04 -0400 Subject: [PATCH 11/11] wai channel: no dead code or unused import in a build without channel_lineup done() is called only by the feature's operations, as io_fail and the other helpers are, and EXIT_FAILED and json! only by them: the helper takes the same allow as its neighbours, and the two imports come in with the feature, as in the opportunity report's door. Co-Authored-By: Claude Opus 5.5 --- wai/wai-rs/src/bin/wai_cli/dx/channel.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/wai/wai-rs/src/bin/wai_cli/dx/channel.rs b/wai/wai-rs/src/bin/wai_cli/dx/channel.rs index 1f4c3c326..1ac107454 100644 --- a/wai/wai-rs/src/bin/wai_cli/dx/channel.rs +++ b/wai/wai-rs/src/bin/wai_cli/dx/channel.rs @@ -7,14 +7,19 @@ //! 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. -use super::envelope::{self as agent, EXIT_FAILED, EXIT_INTERNAL, EXIT_NOT_FOUND, EXIT_PERMISSION, OpError}; +#[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, json}; +use serde_json::Value; +#[cfg(feature = "channel_lineup")] +use serde_json::json; type R = Result; /// Print what an operation did: its envelope with `--json`, its text otherwise; in a dry run of an /// operation that writes, with the plan of what it would have done. +#[cfg_attr(not(feature = "channel_lineup"), allow(dead_code))] fn done(op: &str, writes: bool, data: Value, text: &str) -> R { let plan = (writes && out::dry_run()).then(out::plan); if agent::ctx().json { -- 2.54.0 (Apple Git-157)