From eae60866cf1ec45029e2119928534e7561aaac98 Mon Sep 17 00:00:00 2001 From: Nils Lehnen <30603423+iderex@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:44:42 +0200 Subject: [PATCH] Hold 0111's ladder as values: which source, which streams, and what the handover carries (#111) 0111 fixes that the play call carries the client's own capability description and the answer is read against it, that a source sits on the highest of four rungs the description admits and is judged only on the streams that will be played, that a tie goes to the order the server listed, how the audio and subtitle streams are chosen, what the handover carries and nothing else, and that nothing playable is request-refused rather than an empty answer. The record had landed and nothing in the tree held any of it, which is the state 0111 was written against: the first playback code answers five records at once, and the shortest correct-looking code takes the first source listed. src/playback/handover.rs is the rule. What the server offered arrives as values, each source has its streams chosen first and is judged on those and its container, and the highest rung wins with fewer converted streams winning on the conversion rung and the server's order breaking a tie. The start is 0058's answer applied. A source played as it stands names itself and carries no address, because the server supplies one only for the rungs below and 0010's table has no row for the path the core would build. Every lower rung is built from the address the server supplied, so a rung without one cannot be written. Nothing playable is told apart as the server's refusal or the core's, and both become one kind through a further entrance to 0037's mapping point in src/failure/mod.rs. Eight guards were watched failing, each reddening the case named for it: taking the first source listed, judging every stream rather than the ones to be played, treating a picture of unstated size as covered, ignoring the ceiling, giving a tie to the later source, formatting the address the server put the token into, reading the file's default flag before the server's index, and ignoring the server's error code. The ninth is the address guard, and its one-line removal does not compile. Nothing here sends a byte. #111's four test conditions are each a run against the fake server and every one of them is untouched; the issue stays open on them and on the address for a source played as it stands. Signed-off-by: Nils Lehnen <30603423+iderex@users.noreply.github.com> --- src/failure/mod.rs | 41 ++ src/lib.rs | 14 + src/playback/handover.rs | 1391 ++++++++++++++++++++++++++++++++++++ src/playback/mod.rs | 5 + tests/thread_statements.rs | 23 + 5 files changed, 1474 insertions(+) create mode 100644 src/playback/handover.rs diff --git a/src/failure/mod.rs b/src/failure/mod.rs index 9f9e182..bb7d929 100644 --- a/src/failure/mod.rs +++ b/src/failure/mod.rs @@ -37,6 +37,9 @@ //! [`Failure::internal_fault`] and [`Failure::cancelled`] are mapped from //! nothing and built here anyway, for the reason 0037 gives: a second //! construction site is a second place a sixteenth thing can be invented. +//! [`Failure::nothing_playable`] is 0111's refusal arriving the same way: the +//! module that decides it says which of two conditions it was and builds +//! nothing, and the one kind both become is built here. //! //! # What is not here, said once so a green build is not read as covering it //! @@ -786,6 +789,27 @@ impl Failure { } } + /// Nothing the server offers or will convert is playable on this device. + /// + /// 0111 maps two conditions onto `request-refused`: the server answering + /// that it will serve nothing, and the core refusing every source on the + /// description the client supplied. Both are refusals of a request the + /// server understood, which is 0004's `Means` for the row, and 0111 names + /// the cost of one kind for two: the payload is the server's code and + /// nothing more, so a caller sees an absent code where the core refused and + /// where the server refused without one, and the event under 0100 is what + /// tells them apart. `src/playback/handover.rs` keeps the two apart for + /// that event and cannot name a variant of [`Failure`] in an expression, so + /// this is a further entrance to the one mapping point 0037 fixes rather + /// than a second mapping. + #[must_use] + pub fn nothing_playable(server_code: Option<&str>) -> Self { + Self::RequestRefused { + server_code: server_code.map(str::to_owned), + constructed: Constructed(()), + } + } + /// A store the client supplied answering badly. #[must_use] pub const fn storage_unavailable(store: Store, operation: Operation) -> Self { @@ -1318,6 +1342,23 @@ mod tests { ); } + #[test] + fn nothing_playable_is_request_refused_with_the_servers_code_where_it_gave_one() { + let from_the_server = Failure::nothing_playable(Some("NoCompatibleStream")); + assert_eq!(from_the_server.kind(), Kind::RequestRefused); + let Failure::RequestRefused { server_code, .. } = from_the_server else { + panic!("nothing playable mapped onto something else"); + }; + assert_eq!(server_code.as_deref(), Some("NoCompatibleStream")); + + let from_the_core = Failure::nothing_playable(None); + assert_eq!(from_the_core.kind(), Kind::RequestRefused); + let Failure::RequestRefused { server_code, .. } = from_the_core else { + panic!("nothing playable mapped onto something else"); + }; + assert_eq!(server_code, None); + } + #[test] fn a_401_says_a_token_was_presented_so_that_35_can_tell_the_two_apart() { let Failure::NotAuthenticated { diff --git a/src/lib.rs b/src/lib.rs index 6e51b03..2767ae4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -236,6 +236,20 @@ const _: () = { any_thread::(); any_thread::(); any_thread::(); + any_thread::(); + any_thread::(); + any_thread::(); + any_thread::(); + any_thread::(); + any_thread::(); + any_thread::(); + any_thread::(); + any_thread::(); + any_thread::>(); + any_thread::(); + any_thread::(); + any_thread::(); + any_thread::(); any_thread::>(); any_thread::(); any_thread::(); diff --git a/src/playback/handover.rs b/src/playback/handover.rs new file mode 100644 index 0000000..1262d82 --- /dev/null +++ b/src/playback/handover.rs @@ -0,0 +1,1391 @@ +//! Which source is played, which streams are chosen, and what the handover +//! carries. +//! +//! `docs/decisions/0111-which-source-is-played-and-the-handover.md` is the +//! record and #111 is the issue. The record decides five things: that the +//! description travels with the play call and the answer is read against that +//! one; that a source sits on the highest of four rungs the description admits, +//! judged after its streams are chosen, with a tie going to the order the server +//! listed; how the audio and subtitle streams are chosen; what the handover +//! carries and nothing else; and that nothing playable is `request-refused` +//! from 0004 rather than an empty answer. +//! +//! # What is here, and what is deliberately not +//! +//! What is here is everything of that a comparison over names and counts +//! settles: which of a source's streams will be played, whether the description +//! covers each of them and the container, which rung that puts the source on, +//! which of several sources is taken, and the value the call hands back. None +//! of it reads a clock, a socket or a store. +//! +//! WHAT IS NOT HERE IS THE CALL. The play call is a request carrying the +//! description in its body, 0010 names the path, and the transport is #27, so +//! nothing here sends or receives a byte. [`WhatTheServerOffered`] is the +//! server's answer as values rather than as bytes, and the reading that turns an +//! answer body into one is a reading 0037 places at its mapping point, for a +//! body nothing in this tree parses. #111's four test conditions are each a run +//! against the fake server, and every one of them is untouched by this module. +//! +//! WHAT IS ALSO NOT HERE IS THE ADDRESS OF A SOURCE PLAYED AS IT STANDS. 0111 +//! says the handover carries an address the platform's own player opens. For +//! every rung but the top one the server supplies that address in its answer, +//! and [`WhatThePlayerOpens::TheServersConversion`] carries it as it arrived. +//! For a source played as it stands the server supplies none, the address is a +//! path the core would have to build, and 0010's table carries no row for it. +//! 0010 is the authority for which paths the core may name and says of itself +//! that a path outside its table arrives by a superseding record, so +//! [`WhatThePlayerOpens::TheSourceAsItStands`] names the source and carries no +//! address, and the row is owed to that record rather than invented here. +//! +//! # Why the ladder is here rather than at the call +//! +//! 0111's own sentence: the shortest correct-looking code takes the first +//! source the server listed, it compiles, it works on every machine where every +//! file is already playable, and it ships a stream the first narrower device +//! cannot decode, with the failure arriving from inside the platform's decoder, +//! where 0004 has no kind and the core no visibility. A ladder that was never +//! written does not appear in a diff as a missing ladder. So it is written +//! once, here, and the call asks it. +//! +//! # What the server's flags are, and what they are not +//! +//! Both supported lines compute whether a source may be played as it stands, +//! with its container changed, or converted, against the description the call +//! carried, and set the three flags [`RoutesOffered`] holds from that. The +//! ladder reads the same description and judges the streams that will be +//! played, and the two readings are meant to agree. Where they do not, which +//! 0111 names as a server that ignored the description, the source clears no +//! rung it has an address for and the answer is nothing playable, from the +//! core. That is the reading the record wants: the mismatch arrives here rather +//! than inside a decoder. + +use super::{Ticks, resume::Resume}; +use crate::failure::Failure; +use crate::session::device::{Capabilities, LargestPicture}; + +/// Which kind of stream a stream is, as the server reports it. +/// +/// Thread safety, from 0009: a plain value, safe from any thread. +/// +/// Both supported lines report six kinds, and three of them are ones a player +/// plays. The other three are one variant here because the ladder never chooses +/// among them: an embedded image, a data track and a lyric track ride inside +/// the container and are decoded by nothing 0111 judges. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StreamKind { + /// The picture. + Video, + /// The sound. + Audio, + /// Text or pictures shown over the picture. + Subtitle, + /// Anything else the container carries. + Other, +} + +/// The size of a video stream's picture, as the server states it. +/// +/// Thread safety, from 0009: a plain value, safe from any thread. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Picture { + across: u32, + down: u32, +} + +impl Picture { + /// The two counts of pixels the server stated. + #[must_use] + pub const fn stated(across: u32, down: u32) -> Self { + Self { across, down } + } + + /// Whether this picture is no larger than the largest the client can + /// decode, in both directions. + const fn fits_within(self, largest: LargestPicture) -> bool { + self.across <= largest.across() && self.down <= largest.down() + } +} + +/// One stream of a source, as the server described it. +/// +/// Thread safety, from 0009: a plain value, safe from any thread. +/// +/// What is carried is what the ladder and the stream rule read and nothing +/// else: the index the server names the stream by, its kind, its codec, its +/// language, whether the file marks it as its default, and for a picture its +/// size. The server states a great deal more about a stream and none of it +/// reaches a decision here. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OfferedStream { + index: i32, + kind: StreamKind, + codec: String, + language: Option, + marked_default: bool, + picture: Option, +} + +impl OfferedStream { + /// Takes a stream as the server described it, unchanged. + #[must_use] + pub fn described( + index: i32, + kind: StreamKind, + codec: &str, + language: Option<&str>, + marked_default: bool, + picture: Option, + ) -> Self { + Self { + index, + kind, + codec: codec.to_owned(), + language: language.map(str::to_owned), + marked_default, + picture, + } + } + + /// The index the server names this stream by. + #[must_use] + pub const fn index(&self) -> i32 { + self.index + } + + /// Which kind of stream it is. + #[must_use] + pub const fn kind(&self) -> StreamKind { + self.kind + } + + /// The codec, as the server names it. + #[must_use] + pub fn codec(&self) -> &str { + &self.codec + } + + /// The language, where the server states one. + #[must_use] + pub fn language(&self) -> Option<&str> { + self.language.as_deref() + } + + /// The picture's size, where the server states one. + #[must_use] + pub const fn picture(&self) -> Option { + self.picture + } + + /// Whether the description covers this stream as it stands. + /// + /// The codec has to be one the client said it decodes, and a picture has to + /// be no larger than the largest it said it decodes. A PICTURE WHOSE SIZE + /// THE SERVER DID NOT STATE IS NOT COVERED. The description's second part is + /// "at what sizes", a size nobody stated cannot be inside it, and 0036 names + /// the direction this fails in as the cheap one: a conversion the device did + /// not need, rather than a stream that fails inside a decoder. + fn is_covered_by(&self, description: &Capabilities) -> bool { + if !admits_name(description.codecs(), &self.codec) { + return false; + } + match self.kind { + StreamKind::Video => self + .picture + .is_some_and(|picture| picture.fits_within(description.largest_picture())), + StreamKind::Audio | StreamKind::Subtitle | StreamKind::Other => true, + } + } + + fn speaks(&self, language: &str) -> bool { + self.language + .as_deref() + .is_some_and(|spoken| spoken.eq_ignore_ascii_case(language)) + } +} + +/// Which of the three routes the server said it will serve a source by. +/// +/// Thread safety, from 0009: a plain value, safe from any thread. +/// +/// Both supported lines set the three from the description the call carried, +/// which is why the ladder can read them at all: a flag computed from a stored +/// description would be a flag about a different device. The three are the +/// server's statement about what it will do; whether the description covers +/// what would be played is this module's, and a source is taken only where the +/// two agree. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RoutesOffered { + as_it_stands: bool, + container_changed: bool, + converted: bool, +} + +impl RoutesOffered { + /// The three flags as the server stated them. + #[must_use] + pub const fn stated(as_it_stands: bool, container_changed: bool, converted: bool) -> Self { + Self { + as_it_stands, + container_changed, + converted, + } + } +} + +/// The address the server supplied for a source it will change or convert, as +/// it arrived. +/// +/// Thread safety, from 0009: a plain value, safe from any thread. +/// +/// Both supported lines write the session's token into this address, read at +/// the commits 0010 names, so it is a value 0071 excludes from every event and +/// 0005 keeps out of every log. The hand-written formatting below is what keeps +/// it out of anything that formats a handover, and no accessor here goes +/// through 0071's treatment, so a caller that puts it into a report has moved +/// the leak rather than found one. +#[derive(Clone, PartialEq, Eq)] +pub struct ConversionAddress { + as_sent: String, +} + +/// Written out by hand so that the token the server put into the address is +/// never in a formatted line. +impl core::fmt::Debug for ConversionAddress { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("ConversionAddress").finish_non_exhaustive() + } +} + +impl ConversionAddress { + /// Takes the address as the server sent it. + #[must_use] + pub const fn as_sent(text: String) -> Self { + Self { as_sent: text } + } + + /// The address, for the player that opens it. + #[must_use] + pub fn as_str(&self) -> &str { + &self.as_sent + } +} + +/// One source the server offered for an item. +/// +/// Thread safety, from 0009: a plain value, safe from any thread. +/// +/// The server's order among several is the order they are handed to +/// [`WhatTheServerOffered::answered`] in, and 0111 makes that order the +/// tie-break, so nothing here sorts. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OfferedSource { + id: String, + container: String, + streams: Vec, + routes: RoutesOffered, + conversion_address: Option, + bitrate: Option, + default_audio: Option, + default_subtitle: Option, +} + +impl OfferedSource { + /// A source as the server named it, with the routes it offers and nothing + /// else yet. + #[must_use] + pub fn described(id: &str, container: &str, routes: RoutesOffered) -> Self { + Self { + id: id.to_owned(), + container: container.to_owned(), + streams: Vec::new(), + routes, + conversion_address: None, + bitrate: None, + default_audio: None, + default_subtitle: None, + } + } + + /// The streams the server listed, in the order it listed them. + #[must_use] + pub fn with_streams(mut self, streams: Vec) -> Self { + self.streams = streams; + self + } + + /// The address the server supplied for changing or converting this source. + #[must_use] + pub fn with_conversion_address(mut self, address: ConversionAddress) -> Self { + self.conversion_address = Some(address); + self + } + + /// How much the source carries, in bits per second, where the server states + /// it. + #[must_use] + pub const fn with_bitrate(mut self, bits_per_second: u64) -> Self { + self.bitrate = Some(bits_per_second); + self + } + + /// The audio and subtitle streams the server marks as this source's own + /// defaults, by index. + #[must_use] + pub const fn with_defaults(mut self, audio: Option, subtitle: Option) -> Self { + self.default_audio = audio; + self.default_subtitle = subtitle; + self + } + + /// The identifier the server names this source by. + #[must_use] + pub fn id(&self) -> &str { + &self.id + } + + /// Whether the source carries more than a ceiling allows. + /// + /// A source whose rate the server did not state is not above any ceiling, + /// because the core cannot say that it is, and 0111 has the core apply no + /// ceiling of its own. + fn exceeds(&self, ceiling: Option) -> bool { + match (self.bitrate, ceiling) { + (Some(rate), Some(ceiling)) => rate > ceiling.bits_per_second, + (None, _) | (_, None) => false, + } + } + + /// The streams that will actually be played, chosen before the source is + /// judged. + fn streams_to_play(&self, languages: &[&str]) -> StreamsToPlay<'_> { + StreamsToPlay { + video: self.streams.iter().find(|s| s.kind == StreamKind::Video), + audio: self.chosen_of(StreamKind::Audio, languages, self.default_audio), + subtitle: self.chosen_of(StreamKind::Subtitle, languages, self.default_subtitle), + } + } + + /// 0111's stream rule for one kind: the first stream matching the earliest + /// language the caller named that any stream satisfies, else the one the + /// source marks as its default, else the first listed. + /// + /// Two things can mark a default and both are read, the source's own index + /// first. The index is the server's statement about this source and the + /// flag on a stream is the file's about itself, and where the two disagree + /// the server's is the one that reflects what it will serve. + fn chosen_of( + &self, + kind: StreamKind, + languages: &[&str], + marked_by_index: Option, + ) -> Option<&OfferedStream> { + let of_kind = || self.streams.iter().filter(move |s| s.kind == kind); + languages + .iter() + .find_map(|language| of_kind().find(|s| s.speaks(language))) + .or_else(|| marked_by_index.and_then(|index| of_kind().find(|s| s.index == index))) + .or_else(|| of_kind().find(|s| s.marked_default)) + .or_else(|| of_kind().next()) + } + + /// Which rung, if any, this source sits on for this description. + /// + /// The container and the streams that will be played are judged, and no + /// other stream is, which is 0111's ordering: judging a source before its + /// streams are chosen tests it against a track nobody will hear. A source + /// that describes no stream at all is judged by nothing and clears nothing, + /// because the alternative admits a file on the strength of its container + /// alone. + fn placement<'a>( + &'a self, + description: &Capabilities, + to_play: &StreamsToPlay<'_>, + ) -> Option> { + let judged: Vec<&OfferedStream> = [to_play.video, to_play.audio, to_play.subtitle] + .into_iter() + .flatten() + .collect(); + if judged.is_empty() { + return None; + } + let not_covered = judged + .iter() + .filter(|stream| !stream.is_covered_by(description)) + .count(); + let container_covered = admits_name(description.containers(), &self.container); + + if self.routes.as_it_stands && container_covered && not_covered == 0 { + return Some(Placement { + rung: Rung::PlayedAsItStands, + converted: 0, + opens: Opens::AsItStands, + }); + } + // Every rung below the top one is a route the server carries out, and + // it names that route by an address. No address, no rung: the placement + // is built from the address, so a lower rung without one cannot be + // written here at all. + let address = self.conversion_address.as_ref()?; + let opens = Opens::Conversion(address); + if self.routes.container_changed && not_covered == 0 { + return Some(Placement { + rung: Rung::ContainerChanged, + converted: 0, + opens, + }); + } + if !self.routes.converted { + return None; + } + if not_covered < judged.len() { + return Some(Placement { + rung: Rung::SomeStreamsConverted, + converted: not_covered, + opens, + }); + } + Some(Placement { + rung: Rung::EveryStreamConverted, + converted: not_covered, + opens, + }) + } +} + +/// The streams of one source that will actually be played. +struct StreamsToPlay<'a> { + video: Option<&'a OfferedStream>, + audio: Option<&'a OfferedStream>, + subtitle: Option<&'a OfferedStream>, +} + +/// Where a source landed on the ladder, what that costs, and what the player +/// is handed there. +#[derive(Clone, Copy, PartialEq, Eq)] +struct Placement<'a> { + rung: Rung, + /// How many of the streams that will be played are converted, which is + /// what orders two sources on the conversion rung. + converted: usize, + opens: Opens<'a>, +} + +/// What a placement hands the player, borrowed from the source until one +/// placement has won. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Opens<'a> { + AsItStands, + Conversion(&'a ConversionAddress), +} + +impl Opens<'_> { + fn owned(self) -> WhatThePlayerOpens { + match self { + Self::AsItStands => WhatThePlayerOpens::TheSourceAsItStands, + Self::Conversion(address) => WhatThePlayerOpens::TheServersConversion(address.clone()), + } + } +} + +impl Placement<'_> { + /// Whether this placement beats another, strictly. + /// + /// Strictly, so that a tie leaves the earlier source in place, which is + /// 0111's tie-break by the server's order arriving through the order the + /// sources are walked in rather than through a comparison of its own. + const fn is_higher_than(self, other: Self) -> bool { + if self.rung.rank() != other.rung.rank() { + return self.rung.rank() < other.rung.rank(); + } + self.converted < other.converted + } +} + +/// The rung a source was taken from. +/// +/// Thread safety, from 0009: a plain value, safe from any thread. +/// +/// Highest first, and each rung down costs somebody something the rung above +/// does not: nothing at all, a repackage on the server, picture or sound +/// quality that cannot be recovered, and both of those on every stream. The +/// handover carries this so that a client wanting to tell a person the file is +/// being converted has a way to know that is not inferring it from the shape of +/// the address. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Rung { + /// The bytes the server holds, sent unchanged. + PlayedAsItStands, + /// The same streams, repackaged. + ContainerChanged, + /// The streams the description does not admit are converted, and no + /// others. + SomeStreamsConverted, + /// Every stream is converted. + EveryStreamConverted, +} + +impl Rung { + /// Every rung, highest first, so a caller reads the ladder out of the crate + /// rather than keeping a copy of it. + #[must_use] + pub const fn all() -> &'static [Self] { + &[ + Self::PlayedAsItStands, + Self::ContainerChanged, + Self::SomeStreamsConverted, + Self::EveryStreamConverted, + ] + } + + /// The rung as it is reported: data a client reads rather than the text a + /// debug printing would produce, which 0100 requires of a field. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::PlayedAsItStands => "played-as-it-stands", + Self::ContainerChanged => "container-changed", + Self::SomeStreamsConverted => "some-streams-converted", + Self::EveryStreamConverted => "every-stream-converted", + } + } + + /// Lower is higher on the ladder. + const fn rank(self) -> u8 { + match self { + Self::PlayedAsItStands => 0, + Self::ContainerChanged => 1, + Self::SomeStreamsConverted => 2, + Self::EveryStreamConverted => 3, + } + } +} + +/// A ceiling the caller supplies on how much a source may carry. +/// +/// Thread safety, from 0009: a plain value, safe from any thread. +/// +/// 0111 has the caller supply it and the core carry it, because a client holds +/// what the core does not: whether the device is on a metered connection, what +/// a person set, and what the platform says about the network. The core invents +/// none and applies none where none is supplied. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Ceiling { + bits_per_second: u64, +} + +impl Ceiling { + /// A ceiling in bits per second, which is the unit the server states a + /// source's rate in. + #[must_use] + pub const fn bits_per_second(bits_per_second: u64) -> Self { + Self { bits_per_second } + } + + /// The ceiling, in bits per second. + #[must_use] + pub const fn as_bits_per_second(self) -> u64 { + self.bits_per_second + } +} + +/// What the caller asked for beside the item: the languages it prefers, in the +/// order it prefers them, and a ceiling. +/// +/// Thread safety, from 0009: a plain value, safe from any thread. +/// +/// Both belong to the caller for the reason 0111 gives: what a person wants to +/// hear and read is a setting a client holds in a screen the core does not +/// draw, and 0003 keeps the core out of both. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Preferences<'a> { + languages: &'a [&'a str], + ceiling: Option, +} + +impl<'a> Preferences<'a> { + /// What the caller stated. + #[must_use] + pub const fn stated(languages: &'a [&'a str], ceiling: Option) -> Self { + Self { languages, ceiling } + } + + /// A caller that stated nothing, which is legal and applies no ceiling. + #[must_use] + pub const fn none() -> Self { + Self { + languages: &[], + ceiling: None, + } + } +} + +/// The server's answer to a play call, as values. +/// +/// Thread safety, from 0009: a plain value, safe from any thread. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WhatTheServerOffered { + sources: Vec, + error_code: Option, +} + +impl WhatTheServerOffered { + /// The sources in the order the server listed them, and the error code it + /// supplied where it supplied one. + #[must_use] + pub fn answered(sources: Vec, error_code: Option<&str>) -> Self { + Self { + sources, + error_code: error_code.map(str::to_owned), + } + } + + /// The sources, in the server's order. + #[must_use] + pub fn sources(&self) -> &[OfferedSource] { + &self.sources + } +} + +/// A stream the rule chose, as the handover names it. +/// +/// Thread safety, from 0009: a plain value, safe from any thread. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChosenStream { + index: i32, + codec: String, + language: Option, +} + +impl ChosenStream { + fn of(stream: &OfferedStream) -> Self { + Self { + index: stream.index, + codec: stream.codec.clone(), + language: stream.language.clone(), + } + } + + /// The index the server names the stream by. + #[must_use] + pub const fn index(&self) -> i32 { + self.index + } + + /// The codec, as the server names it. + #[must_use] + pub fn codec(&self) -> &str { + &self.codec + } + + /// The language, where the server states one. + #[must_use] + pub fn language(&self) -> Option<&str> { + self.language.as_deref() + } +} + +/// What the platform's own player is handed. +/// +/// Thread safety, from 0009: a plain value, safe from any thread. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WhatThePlayerOpens { + /// The source itself, unchanged. THE ADDRESS IS NOT HERE: the server + /// supplies none for this rung, the path is one the core would build, and + /// 0010's table carries no row for it, so it arrives with a record that + /// supersedes 0010 rather than with this variant. + TheSourceAsItStands, + /// The address the server supplied for changing or converting the source, + /// as it arrived. + TheServersConversion(ConversionAddress), +} + +/// What a play call hands back, and nothing else. +/// +/// Thread safety, from 0009: a plain value, safe from any thread. +/// +/// Not a player, not a decoder, not a frame, not a duration the client should +/// count against, and nothing beyond the point 0112 places outside. The core +/// opens no player and holds no opinion about what the client does with what +/// it was given. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Handover { + source: String, + rung: Rung, + opens: WhatThePlayerOpens, + starts_at: Ticks, + audio: Option, + subtitle: Option, +} + +impl Handover { + /// 0111's rule applied to what the server offered. + /// + /// A source above the caller's ceiling is not a candidate. Each remaining + /// source has its streams chosen, is judged on those and its container, and + /// lands on a rung or on none. The highest rung wins; on the conversion + /// rung the fewest converted streams wins; a tie goes to the source the + /// server listed first. The start is 0058's answer applied and not + /// re-decided: a position it offers is where playback starts, and an item + /// it calls finished or keeps no position for starts from the beginning. + /// + /// # Errors + /// + /// [`NothingPlayable::TheServerRefused`] where the server supplied an error + /// code or listed no source, carrying the code where there was one. + /// [`NothingPlayable::TheCoreRefused`] where the server offered sources and + /// none of them clears a rung it has a route for. Both map onto + /// `request-refused` through [`NothingPlayable::as_failure`], which is + /// 0111's decision, and the two are told apart here so that the event under + /// 0100 can say which it was. + pub fn chosen( + offered: &WhatTheServerOffered, + description: &Capabilities, + preferences: Preferences<'_>, + start: Resume, + ) -> Result { + if let Some(code) = &offered.error_code { + return Err(NothingPlayable::TheServerRefused { + code: Some(code.clone()), + }); + } + if offered.sources.is_empty() { + return Err(NothingPlayable::TheServerRefused { code: None }); + } + + let mut best: Option<(Placement<'_>, &OfferedSource, StreamsToPlay<'_>)> = None; + for source in &offered.sources { + if source.exceeds(preferences.ceiling) { + continue; + } + let to_play = source.streams_to_play(preferences.languages); + let Some(placement) = source.placement(description, &to_play) else { + continue; + }; + let beats_the_best = best + .as_ref() + .is_none_or(|(held, _, _)| placement.is_higher_than(*held)); + if beats_the_best { + best = Some((placement, source, to_play)); + } + } + + let Some((placement, source, to_play)) = best else { + return Err(NothingPlayable::TheCoreRefused); + }; + Ok(Self { + source: source.id.clone(), + rung: placement.rung, + opens: placement.opens.owned(), + starts_at: match start { + Resume::At(position) => position, + Resume::ItemIsFinished | Resume::NoPositionIsKept => Ticks::ZERO, + }, + audio: to_play.audio.map(ChosenStream::of), + subtitle: to_play.subtitle.map(ChosenStream::of), + }) + } + + /// The source that was taken, as the server names it. + #[must_use] + pub fn source(&self) -> &str { + &self.source + } + + /// The rung it was taken from. + #[must_use] + pub const fn rung(&self) -> Rung { + self.rung + } + + /// What the platform's own player is handed. + #[must_use] + pub const fn opens(&self) -> &WhatThePlayerOpens { + &self.opens + } + + /// Where playback starts, which is 0058's answer applied. + #[must_use] + pub const fn starts_at(&self) -> Ticks { + self.starts_at + } + + /// The audio stream that was chosen, where the source has one. + #[must_use] + pub const fn audio(&self) -> Option<&ChosenStream> { + self.audio.as_ref() + } + + /// The subtitle stream that was chosen, where the source has one. Chosen + /// and not turned on: whether to show it at the start is a client setting + /// the core has no view on. + #[must_use] + pub const fn subtitle(&self) -> Option<&ChosenStream> { + self.subtitle.as_ref() + } +} + +/// Why nothing was playable. +/// +/// Thread safety, from 0009: a plain value, safe from any thread. +/// +/// Two conditions arrive here and 0111 maps both onto one kind, because both +/// are refusals of the same request: one the server made, and one the core made +/// on the description the client supplied. The two are kept apart in this value +/// for the event under 0100, which is where somebody diagnosing this looks, and +/// they are one kind by the time a caller sees them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NothingPlayable { + /// The server answered that it will serve nothing, with the code it gave + /// where it gave one. + TheServerRefused { + /// The server's own error code, opaque, as 0004 carries it. + code: Option, + }, + /// The server offered sources and none of them clears a rung it has a + /// route for, judged on the description the client supplied. + TheCoreRefused, +} + +impl NothingPlayable { + /// The server's code, where the server gave one. + #[must_use] + pub fn server_code(&self) -> Option<&str> { + match self { + Self::TheServerRefused { code } => code.as_deref(), + Self::TheCoreRefused => None, + } + } + + /// The one kind both conditions become, built at 0037's mapping point. + #[must_use] + pub fn as_failure(&self) -> Failure { + Failure::nothing_playable(self.server_code()) + } +} + +/// Whether a list of names the client supplied admits a name the server +/// stated. +/// +/// Compared without regard to letter case, because both are the same platform +/// fact spelled by two parties, and a client writing a container in capitals +/// would otherwise have every file converted with nothing saying why. +fn admits_name(admitted: &[String], name: &str) -> bool { + admitted + .iter() + .any(|entry| entry.eq_ignore_ascii_case(name)) +} + +#[cfg(test)] +mod tests { + use super::{ + Ceiling, ChosenStream, ConversionAddress, Handover, NothingPlayable, OfferedSource, + OfferedStream, Picture, Preferences, RoutesOffered, Rung, StreamKind, WhatThePlayerOpens, + WhatTheServerOffered, + }; + use crate::failure::{Failure, Kind}; + use crate::playback::Ticks; + use crate::playback::resume::Resume; + use crate::session::device::{Capabilities, LargestPicture}; + + /// A device that opens two containers, decodes three codecs, and shows up + /// to a full high-definition picture. + fn description() -> Capabilities { + Capabilities::supplied( + &["mkv", "mp4"], + &["h264", "aac", "subrip"], + LargestPicture::supplied(1920, 1080).expect("a size"), + ) + .expect("a description") + } + + const EVERY_ROUTE: RoutesOffered = RoutesOffered::stated(true, true, true); + const ONLY_AS_IT_STANDS: RoutesOffered = RoutesOffered::stated(true, false, false); + const CHANGED_OR_CONVERTED: RoutesOffered = RoutesOffered::stated(false, true, true); + const ONLY_CONVERTED: RoutesOffered = RoutesOffered::stated(false, false, true); + + const HD: Picture = Picture::stated(1920, 1080); + const START: Resume = Resume::At(Ticks::from_seconds(600)); + + fn video(codec: &str, picture: Option) -> OfferedStream { + OfferedStream::described(0, StreamKind::Video, codec, None, false, picture) + } + + fn audio(index: i32, codec: &str, language: &str, marked_default: bool) -> OfferedStream { + OfferedStream::described( + index, + StreamKind::Audio, + codec, + Some(language), + marked_default, + None, + ) + } + + fn subtitle(index: i32, codec: &str, language: &str) -> OfferedStream { + OfferedStream::described( + index, + StreamKind::Subtitle, + codec, + Some(language), + false, + None, + ) + } + + fn address(id: &str) -> ConversionAddress { + ConversionAddress::as_sent(format!("/videos/{id}/master.m3u8?api_key=the-token")) + } + + /// A source with every route and the server's address for the lower ones. + fn source(id: &str, container: &str, streams: Vec) -> OfferedSource { + OfferedSource::described(id, container, EVERY_ROUTE) + .with_streams(streams) + .with_conversion_address(address(id)) + } + + fn a_playable_file(id: &str) -> OfferedSource { + source( + id, + "mkv", + vec![video("h264", Some(HD)), audio(1, "aac", "eng", true)], + ) + } + + fn chosen(offered: &WhatTheServerOffered) -> Result { + Handover::chosen(offered, &description(), Preferences::none(), START) + } + + #[test] + fn a_source_the_description_covers_is_played_as_it_stands() { + let offered = WhatTheServerOffered::answered(vec![a_playable_file("one")], None); + let handover = chosen(&offered).expect("playable"); + assert_eq!(handover.source(), "one"); + assert_eq!(handover.rung(), Rung::PlayedAsItStands); + assert_eq!(handover.opens(), &WhatThePlayerOpens::TheSourceAsItStands); + assert_eq!(handover.starts_at(), Ticks::from_seconds(600)); + assert_eq!(handover.audio().map(ChosenStream::index), Some(1)); + assert_eq!(handover.subtitle(), None); + } + + /// The failure 0111 was written against: a line taking the first element of + /// a list. The first source needs a conversion and the second does not. + #[test] + fn the_first_source_listed_is_not_taken_when_a_later_one_sits_higher() { + let needs_converting = source( + "first", + "mkv", + vec![video("hevc", Some(HD)), audio(1, "aac", "eng", true)], + ); + let offered = + WhatTheServerOffered::answered(vec![needs_converting, a_playable_file("second")], None); + let handover = chosen(&offered).expect("playable"); + assert_eq!(handover.source(), "second"); + assert_eq!(handover.rung(), Rung::PlayedAsItStands); + } + + #[test] + fn a_tie_on_one_rung_goes_to_the_source_the_server_listed_first() { + let offered = WhatTheServerOffered::answered( + vec![a_playable_file("first"), a_playable_file("second")], + None, + ); + assert_eq!(chosen(&offered).expect("playable").source(), "first"); + + let both_converted = WhatTheServerOffered::answered( + vec![ + source("first", "mkv", vec![video("hevc", Some(HD))]), + source("second", "mkv", vec![video("hevc", Some(HD))]), + ], + None, + ); + let handover = chosen(&both_converted).expect("playable"); + assert_eq!(handover.source(), "first"); + assert_eq!(handover.rung(), Rung::EveryStreamConverted); + } + + #[test] + fn a_container_the_device_cannot_open_changes_the_container_and_converts_nothing() { + let offered = WhatTheServerOffered::answered( + vec![ + OfferedSource::described("one", "avi", CHANGED_OR_CONVERTED) + .with_streams(vec![video("h264", Some(HD)), audio(1, "aac", "eng", true)]) + .with_conversion_address(address("one")), + ], + None, + ); + let handover = chosen(&offered).expect("playable"); + assert_eq!(handover.rung(), Rung::ContainerChanged); + assert_eq!( + handover.opens(), + &WhatThePlayerOpens::TheServersConversion(address("one")) + ); + } + + /// 0111's ordering: the streams are chosen first and only those are judged, + /// so a second audio track nobody will hear does not cost a conversion. + #[test] + fn a_stream_nobody_will_play_is_not_judged() { + let offered = WhatTheServerOffered::answered( + vec![source( + "one", + "mkv", + vec![ + video("h264", Some(HD)), + audio(1, "aac", "eng", true), + audio(2, "dts", "fra", false), + ], + )], + None, + ); + let handover = chosen(&offered).expect("playable"); + assert_eq!(handover.rung(), Rung::PlayedAsItStands); + assert_eq!(handover.audio().map(ChosenStream::codec), Some("aac")); + } + + #[test] + fn a_chosen_stream_the_description_does_not_admit_is_converted_and_no_other() { + let offered = WhatTheServerOffered::answered( + vec![source( + "one", + "mkv", + vec![video("h264", Some(HD)), audio(1, "dts", "eng", true)], + )], + None, + ); + let handover = chosen(&offered).expect("playable"); + assert_eq!(handover.rung(), Rung::SomeStreamsConverted); + assert_eq!( + handover.opens(), + &WhatThePlayerOpens::TheServersConversion(address("one")) + ); + } + + #[test] + fn fewer_converted_streams_wins_between_two_sources_on_the_conversion_rung() { + let two_converted = source( + "first", + "mkv", + vec![ + video("hevc", Some(HD)), + audio(1, "dts", "eng", true), + subtitle(2, "subrip", "eng"), + ], + ); + let one_converted = source( + "second", + "mkv", + vec![ + video("h264", Some(HD)), + audio(1, "dts", "eng", true), + subtitle(2, "subrip", "eng"), + ], + ); + let offered = WhatTheServerOffered::answered(vec![two_converted, one_converted], None); + let handover = chosen(&offered).expect("playable"); + assert_eq!(handover.source(), "second"); + assert_eq!(handover.rung(), Rung::SomeStreamsConverted); + } + + #[test] + fn a_source_admitting_no_chosen_stream_converts_every_stream() { + let offered = WhatTheServerOffered::answered( + vec![source( + "one", + "mkv", + vec![video("hevc", Some(HD)), audio(1, "dts", "eng", true)], + )], + None, + ); + assert_eq!( + chosen(&offered).expect("playable").rung(), + Rung::EveryStreamConverted + ); + } + + #[test] + fn a_picture_larger_than_the_largest_or_of_unstated_size_is_not_played_as_it_stands() { + let too_large = WhatTheServerOffered::answered( + vec![source( + "one", + "mkv", + vec![video("h264", Some(Picture::stated(3840, 2160)))], + )], + None, + ); + assert_eq!( + chosen(&too_large).expect("playable").rung(), + Rung::EveryStreamConverted + ); + + let unstated = WhatTheServerOffered::answered( + vec![source("one", "mkv", vec![video("h264", None)])], + None, + ); + assert_eq!( + chosen(&unstated).expect("playable").rung(), + Rung::EveryStreamConverted + ); + + let exactly_the_largest = WhatTheServerOffered::answered( + vec![source("one", "mkv", vec![video("h264", Some(HD))])], + None, + ); + assert_eq!( + chosen(&exactly_the_largest).expect("playable").rung(), + Rung::PlayedAsItStands + ); + } + + #[test] + fn languages_are_read_in_order_then_the_default_then_the_first_listed() { + let streams = || { + vec![ + video("h264", Some(HD)), + audio(1, "aac", "eng", false), + audio(2, "aac", "deu", true), + audio(3, "aac", "fra", false), + ] + }; + let offered = WhatTheServerOffered::answered(vec![source("one", "mkv", streams())], None); + + let earliest_satisfiable = Preferences::stated(&["jpn", "FRA", "eng"], None); + let handover = Handover::chosen(&offered, &description(), earliest_satisfiable, START) + .expect("playable"); + assert_eq!(handover.audio().map(ChosenStream::index), Some(3)); + + let nothing_matches = Preferences::stated(&["jpn"], None); + let handover = + Handover::chosen(&offered, &description(), nothing_matches, START).expect("playable"); + assert_eq!(handover.audio().map(ChosenStream::index), Some(2)); + + let by_the_sources_index = WhatTheServerOffered::answered( + vec![source("one", "mkv", streams()).with_defaults(Some(3), None)], + None, + ); + let handover = chosen(&by_the_sources_index).expect("playable"); + assert_eq!(handover.audio().map(ChosenStream::index), Some(3)); + + let no_default_at_all = WhatTheServerOffered::answered( + vec![source( + "one", + "mkv", + vec![ + video("h264", Some(HD)), + audio(1, "aac", "eng", false), + audio(2, "aac", "deu", false), + ], + )], + None, + ); + let handover = chosen(&no_default_at_all).expect("playable"); + assert_eq!(handover.audio().map(ChosenStream::index), Some(1)); + } + + #[test] + fn a_server_that_offers_nothing_is_its_refusal_and_its_code_rides_the_kind() { + let with_a_code = WhatTheServerOffered::answered(vec![], Some("NoCompatibleStream")); + let refused = chosen(&with_a_code).expect_err("nothing playable"); + assert_eq!( + refused, + NothingPlayable::TheServerRefused { + code: Some("NoCompatibleStream".to_owned()) + } + ); + let failure = refused.as_failure(); + assert_eq!(failure.kind(), Kind::RequestRefused); + let Failure::RequestRefused { server_code, .. } = failure else { + panic!("nothing playable mapped onto something else"); + }; + assert_eq!(server_code.as_deref(), Some("NoCompatibleStream")); + + let with_no_code = WhatTheServerOffered::answered(vec![], None); + let refused = chosen(&with_no_code).expect_err("nothing playable"); + assert_eq!(refused, NothingPlayable::TheServerRefused { code: None }); + assert_eq!(refused.as_failure().kind(), Kind::RequestRefused); + } + + #[test] + fn a_ladder_nothing_clears_is_the_cores_refusal_on_the_same_kind() { + let no_route_at_all = WhatTheServerOffered::answered( + vec![ + OfferedSource::described("one", "mkv", RoutesOffered::stated(false, false, false)) + .with_streams(vec![video("h264", Some(HD))]), + ], + None, + ); + let refused = chosen(&no_route_at_all).expect_err("nothing playable"); + assert_eq!(refused, NothingPlayable::TheCoreRefused); + assert_eq!(refused.server_code(), None); + assert_eq!(refused.as_failure().kind(), Kind::RequestRefused); + } + + /// The mismatch 0111 names: the server says the source plays as it stands, + /// the description does not cover it, and the server supplied no address + /// for any other route. The answer is nothing playable from the core rather + /// than a stream that fails inside a decoder. + #[test] + fn a_source_the_server_marked_playable_that_the_description_does_not_cover_has_no_route() { + let offered = WhatTheServerOffered::answered( + vec![ + OfferedSource::described("one", "mkv", ONLY_AS_IT_STANDS) + .with_streams(vec![video("hevc", Some(HD))]), + ], + None, + ); + assert_eq!( + chosen(&offered).expect_err("nothing playable"), + NothingPlayable::TheCoreRefused + ); + } + + #[test] + fn a_route_below_the_top_needs_the_address_the_server_supplies_for_it() { + let converted_with_no_address = WhatTheServerOffered::answered( + vec![ + OfferedSource::described("one", "mkv", ONLY_CONVERTED) + .with_streams(vec![video("hevc", Some(HD))]), + ], + None, + ); + assert_eq!( + chosen(&converted_with_no_address).expect_err("nothing playable"), + NothingPlayable::TheCoreRefused + ); + } + + #[test] + fn a_source_above_the_ceiling_is_not_a_candidate_and_one_of_unstated_rate_is() { + let offered = WhatTheServerOffered::answered( + vec![ + a_playable_file("heavy").with_bitrate(40_000_000), + a_playable_file("light").with_bitrate(4_000_000), + a_playable_file("unstated"), + ], + None, + ); + let ceiling = Preferences::stated(&[], Some(Ceiling::bits_per_second(8_000_000))); + let handover = + Handover::chosen(&offered, &description(), ceiling, START).expect("playable"); + assert_eq!(handover.source(), "light"); + + let only_heavy = WhatTheServerOffered::answered( + vec![a_playable_file("heavy").with_bitrate(40_000_000)], + None, + ); + assert_eq!( + Handover::chosen(&only_heavy, &description(), ceiling, START).expect_err("above it"), + NothingPlayable::TheCoreRefused + ); + + let no_ceiling = chosen(&only_heavy).expect("playable"); + assert_eq!(no_ceiling.source(), "heavy"); + assert_eq!(Ceiling::bits_per_second(1).as_bits_per_second(), 1); + } + + #[test] + fn the_start_is_0058s_answer_applied_and_not_re_decided() { + let offered = WhatTheServerOffered::answered(vec![a_playable_file("one")], None); + for (start, expected) in [ + (Resume::At(Ticks::from_seconds(90)), Ticks::from_seconds(90)), + (Resume::ItemIsFinished, Ticks::ZERO), + (Resume::NoPositionIsKept, Ticks::ZERO), + ] { + let handover = Handover::chosen(&offered, &description(), Preferences::none(), start) + .expect("playable"); + assert_eq!(handover.starts_at(), expected, "for {start:?}"); + } + } + + #[test] + fn the_conversion_address_is_written_out_nowhere() { + let offered = WhatTheServerOffered::answered( + vec![source("one", "mkv", vec![video("hevc", Some(HD))])], + None, + ); + let handover = chosen(&offered).expect("playable"); + let formatted = format!("{handover:?}"); + assert!(!formatted.contains("api_key"), "{formatted}"); + assert!(!formatted.contains("the-token"), "{formatted}"); + let WhatThePlayerOpens::TheServersConversion(address) = handover.opens() else { + panic!("a converted source opens the server's address"); + }; + assert!(address.as_str().contains("the-token")); + } + + #[test] + fn a_source_describing_no_stream_clears_no_rung() { + let offered = WhatTheServerOffered::answered(vec![source("one", "mkv", vec![])], None); + assert_eq!( + chosen(&offered).expect_err("nothing to judge"), + NothingPlayable::TheCoreRefused + ); + } + + #[test] + fn a_subtitle_is_chosen_by_the_same_rule_and_counts_when_judged() { + let offered = WhatTheServerOffered::answered( + vec![source( + "one", + "mkv", + vec![ + video("h264", Some(HD)), + audio(1, "aac", "eng", true), + subtitle(2, "pgs", "eng"), + subtitle(3, "subrip", "deu"), + ], + )], + None, + ); + let handover = chosen(&offered).expect("playable"); + assert_eq!(handover.subtitle().map(ChosenStream::index), Some(2)); + assert_eq!(handover.rung(), Rung::SomeStreamsConverted); + + let german = Preferences::stated(&["deu"], None); + let handover = Handover::chosen(&offered, &description(), german, START).expect("playable"); + assert_eq!(handover.subtitle().map(ChosenStream::index), Some(3)); + assert_eq!( + handover.subtitle().map(ChosenStream::language), + Some(Some("deu")) + ); + assert_eq!(handover.rung(), Rung::PlayedAsItStands); + } + + #[test] + fn names_are_compared_without_regard_to_case() { + let offered = WhatTheServerOffered::answered( + vec![source( + "one", + "MKV", + vec![video("H264", Some(HD)), audio(1, "AAC", "ENG", true)], + )], + None, + ); + assert_eq!( + chosen(&offered).expect("playable").rung(), + Rung::PlayedAsItStands + ); + } + + #[test] + fn the_ladder_is_read_out_of_the_crate_highest_first() { + let names: Vec<&str> = Rung::all().iter().map(|rung| rung.as_str()).collect(); + assert_eq!( + names, + [ + "played-as-it-stands", + "container-changed", + "some-streams-converted", + "every-stream-converted" + ] + ); + let one = WhatTheServerOffered::answered(vec![a_playable_file("one")], None); + assert_eq!(one.sources().len(), 1); + assert_eq!(one.sources()[0].id(), "one"); + let stream = video("h264", Some(HD)); + assert_eq!(stream.index(), 0); + assert_eq!(stream.kind(), StreamKind::Video); + assert_eq!(stream.codec(), "h264"); + assert_eq!(stream.language(), None); + assert_eq!(stream.picture(), Some(HD)); + } +} diff --git a/src/playback/mod.rs b/src/playback/mod.rs index 19755fd..02a159c 100644 --- a/src/playback/mod.rs +++ b/src/playback/mod.rs @@ -25,6 +25,10 @@ //! on [`resume`]'s rewind. [`report`] is the report itself: the one act that //! puts a position on the queue in 0047, on each of those five events and when //! the interval says one is due, and the place #57's three conditions are asked. +//! [`handover`] is 0111's ladder: which of the sources a server offered is +//! played and from which rung, which audio and subtitle streams, where playback +//! starts by [`resume`]'s answer, and what the play call hands back. It holds +//! no address for a source played as it stands, for the reason written there. //! //! # Why the type is named for the unit //! @@ -57,6 +61,7 @@ //! sentence. pub mod cadence; +pub mod handover; pub mod report; pub mod resume; pub mod watched; diff --git a/tests/thread_statements.rs b/tests/thread_statements.rs index 006f817..b9389dc 100644 --- a/tests/thread_statements.rs +++ b/tests/thread_statements.rs @@ -61,6 +61,11 @@ use flowfin_core::measurement::{Measurement, MeasurementSink}; use flowfin_core::playback::cadence::{ ReportsWithoutWaiting, TheInterval, WhatItDoesToTheInterval, }; +use flowfin_core::playback::handover::{ + Ceiling, ChosenStream, ConversionAddress, Handover, NothingPlayable, OfferedSource, + OfferedStream, Picture, Preferences, RoutesOffered, Rung, StreamKind, WhatThePlayerOpens, + WhatTheServerOffered, +}; use flowfin_core::playback::report::{PositionReport, ReportedOn, Reporting, WhatObservingDid}; use flowfin_core::server::address::{AddressNotUsable, BaseAddress}; use flowfin_core::server::destinations::{ @@ -754,3 +759,21 @@ fn what_asking_for_a_decode_does_is_safe_from_any_thread() { fn the_decoded_bytes_bookkeeping_is_safe_from_any_thread() { const _: () = any_thread::(); } + +#[test] +fn what_a_play_call_hands_back_is_safe_from_any_thread() { + const _: () = any_thread::(); + const _: () = any_thread::(); + const _: () = any_thread::(); + const _: () = any_thread::(); + const _: () = any_thread::(); + const _: () = any_thread::(); + const _: () = any_thread::(); + const _: () = any_thread::(); + const _: () = any_thread::(); + const _: () = any_thread::>(); + const _: () = any_thread::(); + const _: () = any_thread::(); + const _: () = any_thread::(); + const _: () = any_thread::(); +}