From 1cb7f1b2fa65aaa1d7f1e0d023a9af2d001aabc5 Mon Sep 17 00:00:00 2001 From: "naps62-yolo (agent)" Date: Sat, 22 Aug 2026 20:52:09 +0100 Subject: [PATCH] feat(probe): ffprobe wrapper (#59) --- .gitea/workflows/ci.yml | 5 +- Cargo.lock | 8 + crates/arr-probe/Cargo.toml | 9 + crates/arr-probe/src/error.rs | 118 ++++++++ crates/arr-probe/src/ffprobe.rs | 88 ++++++ crates/arr-probe/src/language.rs | 145 ++++++++++ crates/arr-probe/src/lib.rs | 252 ++++++++++++++++- crates/arr-probe/src/model.rs | 260 ++++++++++++++++++ .../tests/fixtures/audio-with-cover.m4a | Bin 0 -> 5207 bytes .../arr-probe/tests/fixtures/dv-profile5.mp4 | Bin 0 -> 5532 bytes .../tests/fixtures/dv-profile8-1.mp4 | Bin 0 -> 5532 bytes .../arr-probe/tests/fixtures/extras-360p.mkv | Bin 0 -> 69097 bytes .../arr-probe/tests/fixtures/hdr10-2160p.mkv | Bin 0 -> 10862 bytes .../arr-probe/tests/fixtures/movie-1080p.mkv | Bin 0 -> 29346 bytes crates/arr-probe/tests/fixtures/notes.nfo | 1 + crates/arr-probe/tests/probe.rs | 243 ++++++++++++++++ scripts/make-probe-fixtures.sh | 159 +++++++++++ 17 files changed, 1286 insertions(+), 2 deletions(-) create mode 100644 crates/arr-probe/src/error.rs create mode 100644 crates/arr-probe/src/ffprobe.rs create mode 100644 crates/arr-probe/src/language.rs create mode 100644 crates/arr-probe/src/model.rs create mode 100644 crates/arr-probe/tests/fixtures/audio-with-cover.m4a create mode 100644 crates/arr-probe/tests/fixtures/dv-profile5.mp4 create mode 100644 crates/arr-probe/tests/fixtures/dv-profile8-1.mp4 create mode 100644 crates/arr-probe/tests/fixtures/extras-360p.mkv create mode 100644 crates/arr-probe/tests/fixtures/hdr10-2160p.mkv create mode 100644 crates/arr-probe/tests/fixtures/movie-1080p.mkv create mode 100644 crates/arr-probe/tests/fixtures/notes.nfo create mode 100644 crates/arr-probe/tests/probe.rs create mode 100755 scripts/make-probe-fixtures.sh diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 8f8a202..ec5f3b6 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -19,9 +19,12 @@ jobs: - name: Build deps run: | + # ffmpeg is here for ffprobe: arr-probe's tests run it against the + # committed fixture clips (DESIGN.md §12). apt-get update apt-get install -y --no-install-recommends \ - git curl ca-certificates build-essential pkg-config + git curl ca-certificates build-essential pkg-config \ + ffmpeg - name: Cache cargo uses: actions/cache@v4 diff --git a/Cargo.lock b/Cargo.lock index 6b60ad5..4591012 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -143,6 +143,14 @@ dependencies = [ [[package]] name = "arr-probe" version = "0.1.0" +dependencies = [ + "arr-core", + "serde", + "serde_json", + "thiserror", + "tokio", + "tracing", +] [[package]] name = "assert-json-diff" diff --git a/crates/arr-probe/Cargo.toml b/crates/arr-probe/Cargo.toml index d1e1a75..c06fbfb 100644 --- a/crates/arr-probe/Cargo.toml +++ b/crates/arr-probe/Cargo.toml @@ -7,6 +7,15 @@ repository.workspace = true publish = false [dependencies] +arr-core.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio.workspace = true +tracing.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } [lints] workspace = true diff --git a/crates/arr-probe/src/error.rs b/crates/arr-probe/src/error.rs new file mode 100644 index 0000000..d1bc4c8 --- /dev/null +++ b/crates/arr-probe/src/error.rs @@ -0,0 +1,118 @@ +//! Errors the `ffprobe` wrapper can produce. + +use std::path::PathBuf; + +/// Result alias for every fallible operation in this crate. +pub type Result = std::result::Result; + +/// Everything that can go wrong probing a file. +/// +/// Nothing here has a default. A file `ffprobe` cannot read is an error, never +/// a `ProbedFile` full of zeroes — importing on top of a default-valued struct +/// would silently record a 0-byte SDR file with no audio and pass every policy +/// rule that inspects a field it does not have (DESIGN.md §5.6, §5.7). +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// `ffprobe` could not be started at all: not installed, or not on `PATH`. + #[error("could not run {binary}")] + Spawn { + /// The binary that was attempted. + binary: String, + /// The underlying spawn failure. + #[source] + source: std::io::Error, + }, + + /// `ffprobe` started but exited non-zero — an unreadable or corrupt file. + #[error("ffprobe rejected {path}: {stderr}")] + Rejected { + /// The file that was probed. + path: PathBuf, + /// Exit status, absent when the process was killed by a signal. + status: Option, + /// `ffprobe`'s own diagnostics, truncated to something loggable. + stderr: String, + }, + + /// `ffprobe` did not finish within the configured timeout. + #[error("ffprobe timed out probing {path}")] + Timeout { + /// The file that was probed. + path: PathBuf, + }, + + /// Reading the child process failed after it was spawned. + #[error("reading ffprobe output for {path} failed")] + Io { + /// The file that was probed. + path: PathBuf, + /// The underlying IO failure. + #[source] + source: std::io::Error, + }, + + /// `ffprobe` printed something that is not the JSON shape expected. + #[error("ffprobe output for {path} did not parse")] + Decode { + /// The file that was probed. + path: PathBuf, + /// The deserialisation failure. + #[source] + source: serde_json::Error, + }, + + /// `ffprobe` read the file, but as a still image or as text. + /// + /// ffmpeg's `tty` demuxer parses any text file at all as ANSI art video, + /// and the image demuxers turn a poster into a one-frame film. Both would + /// otherwise pass for importable media. + #[error("{path} is not a video file: ffprobe read it as {format}")] + NotVideo { + /// The file that was probed. + path: PathBuf, + /// The container format `ffprobe` matched. + format: String, + }, + + /// `ffprobe` read the file but it carries no video stream. + #[error("{path} has no video stream")] + NoVideoStream { + /// The file that was probed. + path: PathBuf, + }, + + /// A container that reports neither a duration nor a stream long enough to + /// derive one. The runtime sanity check has nothing to work with. + #[error("{path} reports no duration")] + NoDuration { + /// The file that was probed. + path: PathBuf, + }, + + /// Feature selection was handed a set of paths with no readable video file + /// among them. + #[error("no video file among the candidates")] + NoCandidates, +} + +impl Error { + /// Whether this failure is a fact about one file rather than about the + /// prober. + /// + /// Feature selection walks a torrent full of `.nfo`, `.jpg` and `.txt` + /// files, so a file that is not importable media is expected and gets + /// skipped. A missing binary, a timeout or unparseable output is not about + /// the file at all, and swallowing it would report "no video file" for a + /// torrent that has one. + #[must_use] + pub const fn is_about_the_file(&self) -> bool { + matches!( + self, + Self::Rejected { .. } + | Self::NotVideo { .. } + | Self::NoVideoStream { .. } + | Self::NoDuration { .. } + ) + } +} diff --git a/crates/arr-probe/src/ffprobe.rs b/crates/arr-probe/src/ffprobe.rs new file mode 100644 index 0000000..8de0b46 --- /dev/null +++ b/crates/arr-probe/src/ffprobe.rs @@ -0,0 +1,88 @@ +//! The slice of `ffprobe`'s JSON output this crate reads. +//! +//! Everything is optional because `ffprobe` omits fields rather than nulling +//! them, and which ones it omits depends on the container. + +use std::collections::BTreeMap; + +use serde::Deserialize; + +/// One `ffprobe -print_format json -show_format -show_streams` document. +#[derive(Debug, Deserialize)] +pub(crate) struct Output { + #[serde(default)] + pub format: Format, + #[serde(default)] + pub streams: Vec, +} + +/// Container-level facts. +#[derive(Debug, Default, Deserialize)] +pub(crate) struct Format { + #[serde(rename = "format_name")] + pub name: Option, + pub duration: Option, + pub size: Option, +} + +/// One stream of any kind. `codec_type` says which. +#[derive(Debug, Deserialize)] +pub(crate) struct Stream { + pub codec_type: Option, + pub codec_name: Option, + pub width: Option, + pub height: Option, + pub color_transfer: Option, + pub duration: Option, + #[serde(default)] + pub disposition: Disposition, + #[serde(default)] + pub tags: Tags, + #[serde(default)] + pub side_data_list: Vec, +} + +impl Stream { + pub fn is_kind(&self, kind: &str) -> bool { + self.codec_type.as_deref() == Some(kind) + } + + /// A real video stream, not the cover art bolted onto an audio file. + pub fn is_video(&self) -> bool { + self.is_kind("video") && self.disposition.attached_pic == 0 + } +} + +/// Stream flags. Only `attached_pic` is read: an MP3 or FLAC with embedded +/// artwork reports it as a video stream, and taking that for video would make +/// an audio file look importable. +#[derive(Debug, Default, Deserialize)] +pub(crate) struct Disposition { + #[serde(default)] + pub attached_pic: u8, +} + +/// Stream metadata. Matroska writes `HANDLER_NAME`, MP4 writes `handler_name`, +/// so every lookup here is case-insensitive. +#[derive(Debug, Default, Deserialize)] +pub(crate) struct Tags(BTreeMap); + +impl Tags { + pub fn get(&self, key: &str) -> Option<&str> { + self.0 + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case(key)) + .map(|(_, value)| value.as_str()) + .filter(|value| !value.is_empty()) + } +} + +/// A stream side-data block. Only the Dolby Vision configuration record and +/// the HDR10+ marker are read; the rest are ignored. +#[derive(Debug, Deserialize)] +pub(crate) struct SideData { + #[serde(rename = "side_data_type")] + pub kind: Option, + pub dv_profile: Option, + pub dv_bl_signal_compatibility_id: Option, +} diff --git a/crates/arr-probe/src/language.rs b/crates/arr-probe/src/language.rs new file mode 100644 index 0000000..fa00bd7 --- /dev/null +++ b/crates/arr-probe/src/language.rs @@ -0,0 +1,145 @@ +//! Turning a track's language tag into an [`arr_core::Language`]. +//! +//! The Portuguese case is not decided here. `arr-core::lang` owns the §5.2 +//! signal order, and this module's job is to hand it the two signals a file +//! carries — the stream `title` and `handler_name` strings, and the +//! container's BCP-47 tag. The third signal, release-name markers, belongs to +//! the release and is deliberately not consulted from a file probe. + +use arr_core::{ + lang::{resolve_portuguese, PortugueseEvidence}, + Language, +}; + +/// ISO-639-2 to ISO-639-1, for the codes `ffprobe` actually emits. +/// +/// TMDB reports a title's original language as ISO-639-1 (`en`), `ffprobe` +/// reports a track's as ISO-639-2/B (`eng`). The §5.2 rule compares the two, +/// so they have to be spelled the same way by the time they meet. +const ISO_639_2_TO_1: [(&str, &str); 40] = [ + ("ara", "ar"), + ("bul", "bg"), + ("cat", "ca"), + ("ces", "cs"), + ("chi", "zh"), + ("cze", "cs"), + ("dan", "da"), + ("deu", "de"), + ("dut", "nl"), + ("ell", "el"), + ("eng", "en"), + ("fin", "fi"), + ("fra", "fr"), + ("fre", "fr"), + ("ger", "de"), + ("gle", "ga"), + ("heb", "he"), + ("hin", "hi"), + ("hun", "hu"), + ("ind", "id"), + ("isl", "is"), + ("ita", "it"), + ("jpn", "ja"), + ("kor", "ko"), + ("nld", "nl"), + ("nor", "no"), + ("pol", "pl"), + ("ron", "ro"), + ("rum", "ro"), + ("rus", "ru"), + ("slk", "sk"), + ("slo", "sk"), + ("spa", "es"), + ("swe", "sv"), + ("tha", "th"), + ("tur", "tr"), + ("ukr", "uk"), + ("und", "und"), + ("vie", "vi"), + ("zho", "zh"), +]; + +/// Resolve one track's language from its tag and its self-description. +pub(crate) fn resolve(tag: Option<&str>, title: Option<&str>, handler: Option<&str>) -> Language { + let normalised = tag.unwrap_or("und").trim().to_ascii_lowercase(); + let primary = normalised + .split(['-', '_']) + .next() + .unwrap_or(normalised.as_str()); + + if matches!(primary, "pt" | "por") { + return resolve_portuguese(PortugueseEvidence { + // Signal 1 is a property of the release, not of the file. + name_markers: &[], + stream_title: title, + handler_name: handler, + container_tag: tag, + }); + } + + let code = ISO_639_2_TO_1 + .iter() + .find(|(long, _)| *long == primary) + .map_or(primary, |(_, short)| *short); + Language::Other(code.to_owned()) +} + +#[cfg(test)] +mod tests { + use arr_core::Language; + + use super::resolve; + + #[test] + fn iso_639_2_becomes_iso_639_1() { + assert_eq!( + resolve(Some("eng"), None, None), + Language::Other("en".to_owned()) + ); + assert_eq!( + resolve(Some("ger"), None, None), + Language::Other("de".to_owned()) + ); + } + + #[test] + fn an_untagged_track_is_undetermined_not_portuguese() { + assert_eq!(resolve(None, None, None), Language::Other("und".to_owned())); + } + + #[test] + fn bcp_47_region_decides_on_its_own() { + assert_eq!( + resolve(Some("pt-BR"), None, None), + Language::PortugueseBrazil + ); + assert_eq!( + resolve(Some("pt-PT"), None, None), + Language::PortuguesePortugal + ); + } + + #[test] + fn the_track_title_decides_when_the_code_cannot() { + assert_eq!( + resolve(Some("por"), Some("Portuguese (Brazil)"), None), + Language::PortugueseBrazil + ); + assert_eq!( + resolve(Some("por"), None, Some("Português (Portugal)")), + Language::PortuguesePortugal + ); + } + + #[test] + fn plain_portuguese_stays_unverified() { + assert_eq!( + resolve(Some("por"), Some("Português"), Some("SoundHandler")), + Language::PortugueseUnverified + ); + assert_eq!( + resolve(Some("pt"), None, None), + Language::PortugueseUnverified + ); + } +} diff --git a/crates/arr-probe/src/lib.rs b/crates/arr-probe/src/lib.rs index cd2adf6..42cd143 100644 --- a/crates/arr-probe/src/lib.rs +++ b/crates/arr-probe/src/lib.rs @@ -1 +1,251 @@ -//! arr-probe — see DESIGN.md. +//! `ffprobe` wrapper — the second phase of truth, DESIGN.md §5.6. +//! +//! Release names lie or omit; files do not. Everything here reads a file that +//! is already on disk and reports what is actually in it: resolution, video +//! codec, HDR format with the Dolby Vision profile as a number, per-track audio +//! languages with the strings the tracks call themselves, subtitle tracks, +//! duration and size. +//! +//! Two things this crate deliberately does not do. It never reports a source +//! type — no file knows whether it came off a disc or a streaming service, so +//! that stays a claim of the release name. And it never returns a +//! default-valued [`ProbedFile`]: a file `ffprobe` cannot read is an +//! [`Error`], because a zeroed struct would sail through every policy rule +//! that inspects a field the file never had. + +use std::{ + ffi::OsString, + path::{Path, PathBuf}, + process::Stdio, + time::Duration, +}; + +use tokio::process::Command; + +pub mod error; +mod ffprobe; +mod language; +mod model; + +pub use error::{Error, Result}; +pub use model::{FeatureSelection, ProbedFile, RuntimeMatch}; + +/// The binary invoked when nothing else is configured. +pub const DEFAULT_BINARY: &str = "ffprobe"; + +/// How long one probe may take. Generous: `ffprobe` on a 60 GB remux over a +/// network mount is slow, and killing it early would fail an import that was +/// about to succeed. +pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); + +/// How far a candidate's playing time may sit from the expected runtime and +/// still be the feature. Wide enough for an extended cut, narrow enough that a +/// 90-minute extras reel does not pass for a 120-minute film. +pub const RUNTIME_TOLERANCE: f64 = 0.15; + +/// How much of `ffprobe`'s stderr is kept in an error. +const STDERR_LIMIT: usize = 512; + +/// Runs `ffprobe` and turns its output into domain types. +#[derive(Clone, Debug)] +pub struct Prober { + binary: OsString, + timeout: Duration, +} + +impl Default for Prober { + fn default() -> Self { + Self { + binary: DEFAULT_BINARY.into(), + timeout: DEFAULT_TIMEOUT, + } + } +} + +impl Prober { + /// A prober that runs `ffprobe` from `PATH`. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Run a specific binary instead of whatever `PATH` resolves to. + #[must_use] + pub fn with_binary(mut self, binary: impl Into) -> Self { + self.binary = binary.into(); + self + } + + /// Change how long one probe may take. + #[must_use] + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + /// Probe one file. + /// + /// # Errors + /// + /// [`Error::Spawn`] when `ffprobe` is not installed, [`Error::Rejected`] + /// when `ffprobe` cannot read the file, [`Error::Timeout`], + /// [`Error::Decode`] on unexpected output, and + /// [`Error::NoVideoStream`] / [`Error::NoDuration`] when the file is + /// readable but is not a video file this project can import. + pub async fn probe(&self, path: impl Into) -> Result { + let path = path.into(); + let output = self.run(&path).await?; + let document = serde_json::from_slice(&output).map_err(|source| Error::Decode { + path: path.clone(), + source, + })?; + let mut file = ProbedFile::from_output(path, &document)?; + if file.size == 0 { + // Every container ffprobe knows reports a size, but a zero would + // score as a 0-byte release downstream, so never trust it. + file.size = tokio::fs::metadata(&file.path) + .await + .map_err(|source| Error::Io { + path: file.path.clone(), + source, + })? + .len(); + } + Ok(file) + } + + /// Decide which file in a multi-file torrent is the feature. + /// + /// The largest video file wins, but only after a runtime sanity check + /// against `expected_runtime` — usually TMDB's — so that a bulky extras + /// reel does not get imported as the film. When no candidate is within + /// [`RUNTIME_TOLERANCE`], the largest is still returned and the result is + /// marked [`RuntimeMatch::Mismatched`]; deciding what to do about that is + /// the import's call, not this crate's. + /// + /// Paths that are not importable media are skipped rather than failing the + /// selection — a torrent is full of `.nfo` and `.jpg` files. A failure of + /// the prober itself is not skipped: see [`Error::is_about_the_file`]. + /// + /// # Errors + /// + /// [`Error::NoCandidates`] when no path in the set is a readable video + /// file, and anything [`Prober::probe`] raises that is about the prober + /// rather than about one file. + pub async fn select_feature( + &self, + paths: impl IntoIterator>, + expected_runtime: Option, + ) -> Result { + let mut candidates: Vec = Vec::new(); + for path in paths { + let path = path.into(); + match self.probe(path.clone()).await { + Ok(file) => candidates.push(file), + Err(error) if error.is_about_the_file() => { + tracing::debug!(path = %path.display(), %error, "not a video file, skipping"); + } + Err(error) => return Err(error), + } + } + + candidates.sort_by_key(|candidate| std::cmp::Reverse(candidate.size)); + + let matched = expected_runtime.and_then(|expected| { + candidates + .iter() + .position(|file| within_tolerance(file.duration, expected)) + }); + + if candidates.is_empty() { + return Err(Error::NoCandidates); + } + + let feature = candidates.remove(matched.unwrap_or(0)); + let runtime = match (expected_runtime, matched) { + (None, _) => RuntimeMatch::Unchecked, + (Some(_), Some(_)) => RuntimeMatch::Matched, + (Some(expected), None) => RuntimeMatch::Mismatched { + expected, + actual: feature.duration, + }, + }; + + Ok(FeatureSelection { + feature, + runtime, + others: candidates, + }) + } + + /// Spawn `ffprobe` and collect its stdout. + async fn run(&self, path: &Path) -> Result> { + let mut command = Command::new(&self.binary); + command + .args([ + "-v", + "error", + "-hide_banner", + "-print_format", + "json", + "-show_format", + "-show_streams", + "-i", + ]) + .arg(path) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + let child = command.spawn().map_err(|source| Error::Spawn { + binary: self.binary.to_string_lossy().into_owned(), + source, + })?; + + let output = match tokio::time::timeout(self.timeout, child.wait_with_output()).await { + Ok(output) => output.map_err(|source| Error::Io { + path: path.to_path_buf(), + source, + })?, + Err(_) => { + return Err(Error::Timeout { + path: path.to_path_buf(), + }) + } + }; + + if output.status.success() { + return Ok(output.stdout); + } + + let stderr = String::from_utf8_lossy(&output.stderr) + .trim() + .chars() + .take(STDERR_LIMIT) + .collect(); + Err(Error::Rejected { + path: path.to_path_buf(), + status: output.status.code(), + stderr, + }) + } +} + +fn within_tolerance(actual: Duration, expected: Duration) -> bool { + actual.abs_diff(expected) <= expected.mul_f64(RUNTIME_TOLERANCE) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::within_tolerance; + + #[test] + fn an_extras_reel_is_not_the_feature() { + let feature = Duration::from_mins(120); + assert!(within_tolerance(Duration::from_mins(118), feature)); + assert!(!within_tolerance(Duration::from_mins(90), feature)); + } +} diff --git a/crates/arr-probe/src/model.rs b/crates/arr-probe/src/model.rs new file mode 100644 index 0000000..ad6097b --- /dev/null +++ b/crates/arr-probe/src/model.rs @@ -0,0 +1,260 @@ +//! What a probe returns. + +use std::{path::PathBuf, time::Duration}; + +use arr_core::{AudioTrack, DolbyVisionProfile, HdrFormat, ProbedMedia, Resolution, SubtitleTrack}; + +use crate::{ + error::{Error, Result}, + ffprobe::{Output, Stream}, + language, +}; + +/// The facts `ffprobe` reports about one file. +/// +/// [`ProbedFile::media`] is the part the policy engine consumes post-download +/// (DESIGN.md §5.6). The rest — size, duration, codec — is what naming, +/// feature selection and the UI need. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProbedFile { + /// The file that was probed. + pub path: PathBuf, + /// Size in bytes, as the container reports it. + pub size: u64, + /// Playing time. + pub duration: Duration, + /// `ffprobe`'s codec name for the video stream, e.g. `hevc` or `h264`. + pub video_codec: String, + /// Everything the policy engine looks at. + pub media: ProbedMedia, +} + +impl ProbedFile { + /// Build one from an `ffprobe` document. + /// + /// # Errors + /// + /// [`Error::NoVideoStream`] when the file carries no video, and + /// [`Error::NoDuration`] when neither the container nor any stream reports + /// a playing time. + pub(crate) fn from_output(path: PathBuf, output: &Output) -> Result { + if let Some(format) = output.format.name.as_deref() { + if let Some(name) = format.split(',').find(|name| is_still_or_text(name)) { + return Err(Error::NotVideo { + path, + format: name.to_owned(), + }); + } + } + + let video = output + .streams + .iter() + .find(|stream| stream.is_video()) + .ok_or_else(|| Error::NoVideoStream { path: path.clone() })?; + + let duration = duration(output).ok_or_else(|| Error::NoDuration { path: path.clone() })?; + + let size = output + .format + .size + .as_deref() + .and_then(|size| size.parse().ok()) + .unwrap_or_default(); + + let media = ProbedMedia { + resolution: resolution(video), + // A file cannot tell you where it came from. Source stays a claim + // of the release name (DESIGN.md §5.6). + source: None, + hdr: hdr(video), + audio_tracks: output + .streams + .iter() + .filter(|stream| stream.is_kind("audio")) + .map(audio_track) + .collect(), + subtitle_tracks: output + .streams + .iter() + .filter(|stream| stream.is_kind("subtitle")) + .map(subtitle_track) + .collect(), + }; + + Ok(Self { + path, + size, + duration, + video_codec: video.codec_name.clone().unwrap_or_default(), + media, + }) + } +} + +/// How a candidate's playing time compared to the runtime metadata claims. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RuntimeMatch { + /// Within tolerance of the expected runtime. + Matched, + /// No expected runtime was supplied, so nothing was checked. + Unchecked, + /// Every candidate was outside tolerance; the largest was taken anyway. + /// An extended cut looks like this, and so does an extras reel. + Mismatched { + /// What was expected, typically TMDB's runtime. + expected: Duration, + /// What the chosen file actually runs for. + actual: Duration, + }, +} + +/// Which file in a multi-file torrent is the feature. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FeatureSelection { + /// The chosen file. + pub feature: ProbedFile, + /// Whether the runtime sanity check agreed with the choice. + pub runtime: RuntimeMatch, + /// The other readable video files, largest first. Samples, extras and the + /// occasional second feature end up here. + pub others: Vec, +} + +/// Containers that are not video however hard `ffprobe` tries. +/// +/// The `tty` demuxer will read a `.nfo` as ANSI art and report a video stream +/// with a codec, a resolution and a duration, so "has a video stream" is not +/// on its own enough to call something importable. +fn is_still_or_text(format: &str) -> bool { + matches!(format, "tty" | "image2" | "gif" | "srt" | "ass" | "webvtt") + || format.ends_with("_pipe") +} + +fn audio_track(stream: &Stream) -> AudioTrack { + let title = stream.tags.get("title"); + let handler_name = stream.tags.get("handler_name"); + AudioTrack { + language: language::resolve(stream.tags.get("language"), title, handler_name), + title: title.map(str::to_owned), + handler_name: handler_name.map(str::to_owned), + } +} + +fn subtitle_track(stream: &Stream) -> SubtitleTrack { + SubtitleTrack { + language: language::resolve( + stream.tags.get("language"), + stream.tags.get("title"), + stream.tags.get("handler_name"), + ), + } +} + +/// Frame size to a resolution bucket. +/// +/// Width leads: a 2.39:1 scope transfer of a 2160p film is 3840x1600, and +/// bucketing that by height would call it 1080p. +fn resolution(video: &Stream) -> Resolution { + let width = video.width.unwrap_or_default(); + let height = video.height.unwrap_or_default(); + match () { + () if width >= 3000 || height >= 1700 => Resolution::R2160p, + () if width >= 1800 || height >= 900 => Resolution::R1080p, + () if width >= 1200 || height >= 620 => Resolution::R720p, + () => Resolution::Other(u16::try_from(height).unwrap_or(u16::MAX)), + } +} + +/// HDR format, Dolby Vision profile included. +/// +/// The profile is the whole point: DESIGN.md §5.3 accepts 8.1 and rejects 5 +/// and 7, and no release name can be trusted for it. +fn hdr(video: &Stream) -> HdrFormat { + if let Some(side_data) = video + .side_data_list + .iter() + .find(|side_data| side_data.dv_profile.is_some()) + { + if let Some(profile) = side_data.dv_profile { + return HdrFormat::DolbyVision(DolbyVisionProfile { + profile, + compatibility_id: side_data.dv_bl_signal_compatibility_id, + }); + } + } + + if video.side_data_list.iter().any(|side_data| { + side_data + .kind + .as_deref() + .is_some_and(|kind| kind.contains("2094")) + }) { + return HdrFormat::Hdr10Plus; + } + + match video.color_transfer.as_deref() { + Some("smpte2084") => HdrFormat::Hdr10, + Some("arib-std-b67") => HdrFormat::Hlg, + _ => HdrFormat::Sdr, + } +} + +/// Container duration first, then the longest stream, then Matroska's +/// per-stream `DURATION` tag, which is all a stream-copied remux carries. +fn duration(output: &Output) -> Option { + if let Some(duration) = output.format.duration.as_deref().and_then(seconds) { + return Some(duration); + } + + output + .streams + .iter() + .filter_map(|stream| { + stream + .duration + .as_deref() + .and_then(seconds) + .or_else(|| stream.tags.get("duration").and_then(timecode)) + }) + .max() +} + +/// `"1.023000"` — seconds as a float. +fn seconds(value: &str) -> Option { + let seconds: f64 = value.trim().parse().ok()?; + if seconds.is_finite() && seconds > 0.0 { + Duration::try_from_secs_f64(seconds).ok() + } else { + None + } +} + +/// `"00:00:01.023000000"` — Matroska's tag spelling. +fn timecode(value: &str) -> Option { + let mut parts = value.trim().split(':'); + let hours: u64 = parts.next()?.parse().ok()?; + let minutes: u64 = parts.next()?.parse().ok()?; + let seconds: f64 = parts.next()?.parse().ok()?; + if parts.next().is_some() || !seconds.is_finite() || seconds < 0.0 { + return None; + } + let whole = Duration::from_secs(hours * 3600 + minutes * 60); + Some(whole + Duration::try_from_secs_f64(seconds).ok()?) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::timecode; + + #[test] + fn matroska_timecodes_parse() { + assert_eq!( + timecode("00:01:30.500000000"), + Some(Duration::from_millis(90_500)) + ); + assert_eq!(timecode("nonsense"), None); + } +} diff --git a/crates/arr-probe/tests/fixtures/audio-with-cover.m4a b/crates/arr-probe/tests/fixtures/audio-with-cover.m4a new file mode 100644 index 0000000000000000000000000000000000000000..8c032801d0ba003547634e753f056a9cca105d6d GIT binary patch literal 5207 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 K&pzSMuKx#$WQoxL literal 0 HcmV?d00001 diff --git a/crates/arr-probe/tests/fixtures/dv-profile5.mp4 b/crates/arr-probe/tests/fixtures/dv-profile5.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..ed2cfa20dbcdd577629da500263453608e077041 GIT binary patch literal 5532 zcmc&&UuYc18Q;@cv82edOaxZJ4-=3ZJJFu+PJb-BT%uYIQG$~OgIu?+z;nBEx<$J? ztDU)%&J9%k6PmoaC5F7!F^!4cJd}WGY5L%rQ2f%Ohmt}I#TEqo&<8&>P(l*-_s#C! zon05I%Fsc3^Zl9coA2LzJ1fhw_SJMN@{|lLtH+`lPdSJtOO|Ez)nXwmYv>xSppD4~-y+7)}d!r8gwXXHoI$CpoXfMhCJ_C1-WYDg+ zGidMK4BF2!=*Za&`o?Sq1=lm^$Lkq%r440PT0`@eWj))I+j#QDHwNEnJbe}8msb6H zz9+xgJNDT4&e!^G4$M40Gg#j>KQuG+&)sXg?+!=9=Z81;6!*Nk=lq_Hoqta>+&X^Y z?E1~UCrQIEtY=tmA$PmykNHbGKI;9)&XK+Y{qGIj9PHaQv+K^#+q>Tw9)^y)(2;3) z&dS{y`{LZ#W3T?^!UD$Wt*dwzDr(nmZN~@%V)myC)jV5#iTrqf>yeo4$=$lKf8jJM zzy2puarOGv9hrqAmp}dUrCIm!D-?@`^{wlmcy8L?{6WW+EvE#oY@GdI;S?+PX0B|x zFref1vHd4c^$mVME3U2EVdZXrmBJym6^`3g3deygvs*U<;aGnJ;kaSK(LQTiw*%pL z1l4i-5md*;?T2GCsg7-hWAhPI$L2Px<2h8vuYa|0y3>9Bm$R~o;#k{G>%g(!y!`2x zZ|2Sy-lfiQYRjEt=x`uq6FuDznspa**T~<&tK^QA!|Sz+dOrfjeN4-m?OyxuaF1o} zs_K|8gY_M~Z1%+asXbC{wD$w!fqfpfZ!bi8HjLbuaJ2%oPxuNK1T&z4^zKM)9{fw- zdK?@UxDWbX1i5G}d}QS8y6eXV!cWt4@#61{Q^4gs4_#l-n!(otFRTF=X$D4Vx7lNE z!rSM@g4WO={$L~aSpp9}uXNS7tbvx&DoY5~40vv{PB$MK-8v1Sm)xe8J3TaVBPV#% znVu+=3Z-I^`CfIcJUx~0lHbvL_bjXbgL>1M%k?nO&KvsTUF-8NKKsnFcJH`|eMV<< zgSmV6XhEJIWKk-Vg_g*#%q@kns{IGSZ$5)lud|4bQEV zFp3jLSS{v(uys8a+*Oqc7B&KIM^gH#QYy05C}N?sqX0G}Kr*Q?CR8aeV7^?kebE$t zrAR)wBGPq|^}?v3ZAV3w601e3Ql30iN{O+6_VP?|?QogaX%YL}5s=qXbKf>LDWFEJ z?YR!%I$XE4Ct|2;)Lpykf`}3X3QM@}%T+s+q3y`9CgXsIjxjYBk>J{{!#xoz#=}so zRgFZ$rAbJG38@IH$1d!tNml2oPWFf(k}*v3c?;s8$Vi0tl8hLRh`lUYupXYV44Nu3 zFl}GTWlnoSqMEuh!KyZQd8CPEBbPkz+>~oqJx=LG1Wd{)@!8DI$xz^|H*oxt`0Chm&~BrC`wXDvgy1p)a)x?n6yKNx4B2 z`A%W9MH_j7G1rGLL+jNn2_H)HYn7CcQVX9Gwd`}^Uy6y$vj6}9 literal 0 HcmV?d00001 diff --git a/crates/arr-probe/tests/fixtures/dv-profile8-1.mp4 b/crates/arr-probe/tests/fixtures/dv-profile8-1.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..94fb8e35014ee371de1a155a36eb3295e5ee42dd GIT binary patch literal 5532 zcmc&&UuYc18Q;@cv82edOaxZJ4-=3ZJJFu+PJb-BT%uYIQG$~OgIu?+z;nBEx<$J? ztDU)%&J9%k6PmoaC5F7!F^!4cJd}WGY5L%rQ2f%Ohmt}I#TEqo&<8&>P(l*-_s#C! zon05I%Fsc3^L^jU_s#czXJuK|zM5`Do|1uO^;k6HDF@MH$+E1zS}cTR?fiP+a&1|; zrw-)a|H`pXet7!h((<=Ies}-DmrlNQdGu$WytdFdVOe?er>`IU?$xsy_LKFe^KE2k zZJcEbdF#XV4r~qF>_RFRJ4g=aCxY`9Y!SxLK@p=YbX+xQo*3i6VSR412d1$4Ayte56ukybNAZryTj4&`QeQ{#XYa?IlpIP=id_vw~k*p zyMA-;Ns{mj>lv0?$ldPwWB$^Pk9z;HbENM;|9b;B2m5x-?7B1b_U<=^hauxGWMmSa zvvRk_zBo7b*sH&}uz+!T>mr_oh}yMV+ff3JnEmNOHP04bB0JvSx+i9Pa<^{mUpURm zum6ceT)n<^LuO&fC2QY)KCj)p`+y`whf?Tu~J~DiE-SuMw;iu`jc=30}Dd2LRhpsPZ&*1BU7uEobGy@~FTkSD7 zS=;Bvg7(m$^}$B$vjiS|Ug@fDSpzMlRTdEJ8Sva@oo+rfx@8(dFPTj*cY0{#Mo#di zGd)o#6-vb-^S$a?d3q{YOMXZ1-LtIz59&>4F4x0AJ8$TVcdgIA`0O*w+P&i<&KaG} z4d(9MqYZi5VXhL37vAcB2aAPcIm6nua! zeDur-FKnzWo_S$zj+Leg6NTd9ndwPZ_4Fbu6)|Dod;z?N=AS<`#~kSj2c$U}wPJ6n zu35QQny~RR!wxtH*~<-Idyb67Dv!lDdoiTVb*-bLhYpbj1sN}aA|vg|P#tAX)bQL& z38Of1gwZFedA{j#^pSQpcii|{PFUg3ZMC@hJg7&bCWzbZS zVbS)bT;_BpII5{R6Rc`;mq(gtHe$&G&rMhDs>exs>~yhC!L_LvTlt;?r%f{Dh)_5U z5<>cVO4}KU#oVPJfUY9iPZ0ErxeY(T=XK`jMrERGbaxuI^3I7$X_~gh!qGJI0G9DJ zTc@ko4A6wgP9vKVAT=ZSUf^l4kO8h-X=j7m3SpyT$8bXd6-><Jl1(+LW(ETf!7_EY6Yro2b!3u>w%agBJutei3MxWFNx&qXLhiTD30onAk>@Dp zimET2U zp{h|snoj5ehZ1X8aq6)QIKmGO*Fzk5%U<A4NJ|G)3s6+k!+DXTVaiJV6b$ zY!fMxXa}lVnPJU_hu#OZDdo|g2uH63( zh8=OGY_kgtSszK8QK2|R{-agom@9nEA(}`gI`lRnr0&LO_8CCi3BhFq@)W2ICE5+6O;#Mm@E_KE$tgv<`4BQ+pduVCsbeJh3+Et0_o1erq^m&_ z@lIj1MH_JfH(3Q1Sdg>}DqC75ucvgmYP{y6VW#YDwei26BuSzcH31wzVdM>Jw3&KT zC*+{4H@0g{A-b@F+(X_JO$Ux9P&JK9Yo5S80>L#e5h|R}PSO%0(aq8qgU#t4fJjw} zU9ZA4{cNgXp%pV12^dtBTFU?c?ieVz*yJ4wk<7r+s0=s)vFM}=kpoBR8Ufso>YVNj zcGDB9c&W3rVv5S4=Hr0rAzB9iw|K@wGveY%%rq!2<9K+()g1G;0%2x=)|1^|G5GR{c@*J8qcg_6 a*=2v-h=soNJ$dXI;rkRjsOL)q2*- zs=J=LZ->aA+uP|g3ZBd~8=z*zb0I%F&(o?8Qz@SBLY_?T&Xo> zoszGZ4vN9pqi_E)2LdI1v--bx`_`YEKT5ohH(m35)F-x}QxgO=t2x{Af|mRG`}&i~ z>uCR7wydN6#|?bc$*Q3xMV0P^rieF<5&J8t2dMva>)Z8E8-J8IG;Huc^Ix5oJ`#e< zMk-q@61H^Q=>%GZAlLp@@wSqjN+C#kAZ=3uGcuau7n{7+kCw&|3&D$L#S-9i{e=FI zN(2#H7J<1fLP)Agl6px9PJJ%~{l^&)5GcE?3ryG8{%ru+Z#Ml-K?Fa(&_sYhP!RBK z#|~p%7Jxx8?mci5@tbq0!XG8P?&BxQ%D&Im2sYn18rd$3T*Fu!5t$Sr`|mHa>|wU1 zI{W|X_U0Rk|9j;IlZ&$d<04{FeilCkCk zKffJ2cKB}3&&e$<;*|K7Zr$wn&8{!Eytv3zr<7NoUs_TYO3dbDak9h7#I3n`;r_&& z+^nL~?80!c%tEy)cJ@b~ujC+2a=$~W=K3iElUM*n4C3vW|tUS3)5w?^gMt)>QL;ijrZ zrKN=&t|_jDMD`)4X z?9yT$=bs*#oKwD)lV4&oVXC;5^Q}(aR!(v5w~p_~&EL!|=b2K4xjRjV!vg~UliXCC zUlQ&gKrGA7Ey>N^_RZEm?Y}u?Yi=3Wl;4^ip1n1XSe#|@#5Z4>GU0(H9}>5GJAF9W zH^`LaeY5k=FNYrxj2MEa(uQJa^pi@;inBX#8POPqoVffZ30s21yK`sWp+9i;u8JivY;uLPeL{zHTWe);L6%iy)beZL$+Hlsl-jT^N7MzpQzq z6|c=1ng@zShypNJaFkh(7`m78^;A_(A_1D`=@4Mdh;`Kh%e!rl*tY8NX6Z5Zb{S!| z@UQSsr-6aZ_hxyJMy4XYomb$NvI9e;s z#PX@@syJz!OeOBbr@!; zm~aMsH4eBsh$tG6_#^jUWjX+rGbM7|C_Zz4M?)k^IjSUhC@mm|6- zAhM2k**MI#dOYBWe9vZQ(hxaqO9)+tb&}@J!uSL=(MGu;ldLA&_+;la#?1NR-i>DB zkcaE~7vg$!j*dKg22zV^8ydhxE`Iv54Z&{gH;Nn#7u%S7_|G8)8bP2TfWCu_sPsV_LrL81al2uf(d7Eq?c z)(9@*T7G|Rf8}`d*Yb?xl3lM<%K_G;Gh$a?p5o7eWxm+P1L6sHzI~JD;Rxp&J}!JV z1$!2epM^=s-mVxGOjvpne438;_gL|KIIy-`bgTV#oV$E)(g5g;J=@7yp&w+#SB*eu zRJs0tSwepXeY5ThT{91)$!r+TFzrx-nv8h zw{n!=R!3-Kp_!Lc0Xc5tyVMk2oQ8uahp2X+stADmxzcu;xAvNqY&Zd|3OJUj(L2~* z;(lS=aWIo#zZMUTx_h>5N#7q0fHK4u;HEgPk=*mNKRIOYA%IY=;-m2lYW)Rxk5T&- z!&`o?%dU9+o|%DR>q*3BIml+xxyA(+T5^ZGJp+5>UW)U@>IPrsQYNwB*+k8_h`N2<~ul zC{q7$jfm2oA?vCfX2ZtE~gL?#!qaa_-Rttgiy;u zqG)ljK|99@(H~e(cb8ST2g@2t;n`Gu`Y;4E)u$4MjhjVPY&iyCAv^1(GpHpOUV|-E zKEmc{r$}4jdh*DL3)GVjMg!@H5@IL4`Ra1{8zFeV{L`%#9WS-I`kLp>5~h5fI)wvB zr(&WcTkRH5$oGN^D9OD_%|X40TJC>vHSX@T3vax~yZJESfMktN+}~u7fhJaY328!s zYV54VJsnXJ&eEJ1;J!I)EB62fLN(1c+`c-O;kik2+EclbgCRwIC(0WVgC1b*{%sB5 zNeUJ&y5ULs3YBH=`ub>UP)U-0!}W|tagmoU?waM2O;BZEvu4h<5m8D}rK_jvzmDPQ z#SS|jO3bG7Nj`icJG{Eo8mss!S!s#cyfX9UOaX1Nvtvx#P`NAWQef_odac6Qx>B7& znr=Gz=Z2BtF7`!kiy*&1jo4Xiu4xD-rohybSLmMbNk&Gf=g&Led!iSQ<@GaVo!Qeo;&kSoM_oOCh7osHQcZS_$mp4fPQ4z@v`Kk za>%3&{JL!wxDM}qX!wOeBcbg7>M`jEBiN`ZF$d`U0X>h4M_n+VEXP)oj$4Y+H#Bpr z39F|ITFeen3Zq9}df!OkxUmOF7HrMyox|da_UO3=@#^lv*ym|xf^}vf?dZ@Qb0AOv z@r=8??yjrvDXH%yluh+UhqNHK`N2|>3<37E7Y^wLY>b+{MIq=VP*RXw%F-pnYtipR zwjcEx0&|>5hbuk9q69Mzm^a#O5GQ2Wc0@en^=3=8Pl9F=jVKsJl@Iu$c#lN%o)Gca z&~HCf+fx-|AMV%B=WC>zi>8ZZK)-djRI5Jk}X83*R&5dbS z+#P;jBh}~~H^zmuI|=%H@b5dps!<4X3i}1>927!&0XMr&`zsNzzbM6k2Z^Zp`En%+ z3zfUq_4WRMdsKls$WAAuiSp3QgG2N3rLwsb)-3FNyKKuR0z(`pUAU3VY1OJB8C+)s zEb=VzthF}+l@gt52A{YC1kN}cs^*8m{oaoE&Zk1~+Ar~yQ)D)BcNzGs4Wa}`DBj3- z=L!}kt&APUs@O+r9E>&A&KIa`aZ@00=JJx%8!hXPQk&@uXVhZSERxxaQvaX??UL)%)@ zrAJZxK&{ZQ;{Y}u3_}z^KU%{nMFGabA;@5Iez(D=1V}l{*BSj@$mUBOv(nTJJOo zOw`q9WFUGr2T+~)NBl!d-YXJVy!|IcfbZ!QxU-Oa5wOYFWvVcKI-Q|8L z(Z)vq@TkQ1FC6RZj9)Gf`0!QHwR$C9xgtOCnbrq|A$p!Zj2J)g4T zZ=?R0UnThL>!KJ<)JRo&qxi?Dp*Prr*{!~Vi?+%HRp~p)z~&-(N+^cET(S)U*9%$U zK@t1yayegxib}pJmYw?}Vg&q$*+cP|r15}UnyGslRG$WQeAn#~Oly-qI8io9OsT47 z+~ly*P(sWC4#pf~6We5eDl?-rOu;ekm#|5iBbnQb2Fo|*G#fQ_KklbZ28zBAE z;~f-tYpzgYOn{S7@#*WJBHax}Oa<@{=$e=fLXy`9cs{~1gQOV+0>dzshCMz+PSJ$~ z`5R(-Z~n?PhePskZiwwga8dCQ=iC9ym@$^)kE6 zPbT`Ak6d)Jo(B}h2h<|W$w#UXxU?jFmE~vn0^!SkHurJ=Ld22pwh9`}Aevf~IA3>k zgh82Pq=As2SRbcUPoP2-xLInU%N8DQjJZ;9`xpEu;e>`Ex0!V6`48YS|6c;GOaEU0 zu3+PR<3w{tn1f(V$D(Jbs&r3%JOsQJn_h3kHiXG#t@BFGod3Lm9k0cUQ0JaMF#ED= z5h3EeKRe(IfIg|A((P~k|A6a%E8rqhZU0{*t}sw->$}~@_ICj_<;AYmNL{7p^NA+Z z*eXf+8rLzVY-{GR`BFRcPSMl00&r)yj`Tvk62e>%GHoAZy+bG+IauD@X*9lVOQqa` zN~&)J9ofqd^sz9WSQJ(HvKov2s8+R#rWf*$K0PQZSvGQVG%FW#P3d~f4Wu-xH6>QmwMqt? zi2f+t0r!O1QGJBeqAgrG!H{&*dsU_rf)Vq5Tdh!)D2!)BR4y#7t}}xRnWx$BWYkHK z{TP8fSYs~HO74z!$#*V}Qt!#kx+8gLm4+7KK4HvHwT6VGQ}*qTq1-yDJ`u_0!+1Q5 zQ<1ku&)|W^8_UnOL~LA(*{FhR1T5Pv6sy;nC}rnRPJs9prGLo5o=b4zvc00Pct|=H zd6WEL=#M(*lW|4?D;kkAgF0X-50Q#PjT@;lNXAo?e{CCOdX(=`cZ8+$NL?s9eCV)irw~)*Zcb*aNhRuI*=vwBi6$J#^jXge zWG({nb<_hEmw4fpwVDdZXYue^4?con2?8BJ`yTM1;E3tNi4V9o&sEt%5*7Ux#soy|AemEb+f?yxopts`hFG871y0-8qcaVR`)U zZxxRY4n&Ze=d(S9dRAkb4i>cnoR#kNG!DwC0j$-IMtEouk8ulQQ78V|=A|vg5TJZ2 zes<;+)5gJ!n&6zhVGrpn3h$~>lY4S{I?iUsBAuVjx*DzuRIv;Iw;4oGK=mqLk<5k};U`Q3(qD3}F9!7+Xhun|-i&xE! zAF(gcPV2vnExku~6raW6L2q?6a3i&AM-$x3Tc@nGNths^##GW65yaT*?j#S)@QA9^ zhr%@_;^!JohVu>6+-_iLBoJy2?f2Xmie$T{I})Xi(3fW%trxtRL%17$_X6S4Pc;OV ztLCHSF`K{1t}Ty)2kfrmU z=KTU;7+dEGOL83xONDsl79k+|8J8!c6DXs!2bvKMzR7@Ls`M>Fq2P=i%3}jzqxdE% z=hDXkGTj-H%n|7vV|-Hva8YpJru1zqwqFQOTeAr~e%xez@8Z!xWx-Q!Nc;hP1JHn7 zZb`v~xuT-Mk|3!v5+Q%)pPqLPFUHe=q-8%Z_RCaZ#+h!AQ~>uZOQ)4LT_I=iwqDV8 zd3ZRc%B^h{SFRe-ujMlmyr3h53c>4Vk<^FkC~hK8$?E#$*%5g9&fAip`iw??$xbz5 z`+F;KX62!%=xRG{%ZijB|7C;jXA5z3-iC|rblDNQU z=m)GcrXWhPD@W5L-mX5n^))07nrV-RF}l*3BuyzU<&@X?XnOs|;p*jmJEW}#s0E?imhDJQ6y?RcGA$1A8Z5ImH+F)0oOypk_1bfe) zI-YV@vF<3Rm2}h~x$v8G90=Pvy?+L#5$>`y@Ip@=lc4lcq3K3K5ji2g*g+gQ-EfqW zQi_2k-#r1S&%dE;$u7J;z+hWfFGE$A7oJ6^yGR)3?zN!SiD{mjIbb&YNa6M-PS>K!fG5A z97Y(lkzRZzsdybJ_QEcI7cMlupdHfQ!g|5xb?JbDI zD@fFeB#%RFj{pj@w0m>Gn`1q zM+`x;J3{;Zikjxe9X1C-Yb@+4V6(#gfc(nK8H%Z@(BYIuBdJ%aA$GuQh z`^Q5*#i_%fzgr!sjdD`XZQYrUfK?9Jd)*T_P315xwV?J2#T?P2Il!15fu2NU;RGImGw3vM zYnW%SukGN`&=yjAq$LYM8oV!%GOb!MY-#2v5$J-2!}8%77E-svY>s~;3@1GZ#|;(= zCc=~3{zPMu-|^X7_~mo%_1vHqLD0cX`nn?-c+mXrd@?NqHC1c?sgKr$wSB$=4&@aJ zmkr}&&E+MefzYu=c_@@jj9ye~7DBX@Ks3}bW-yHDO*$zWm_H(dd~}AU@42_18`+CF z<9iYqAl#4i;u7)KX;{`I3yU%ueUD*uZ5<(=ayQ}$&roDyIOwqg8{_KW(RaYX_GUofQCrpiHaMtx3nudh6LF31kaJ8?e^7fjY><_T2&Df-Ne`^ZW(LL`T{OM^HV4Zk zK&huCVTimsT*%GT4JaHn9bt8G3&RPKhXHc&#iiBsQY-lh%XF+Qls~JzWxw=H(FKt$ z1}7E5-h&&cX=1J5gdcFf!HuNl5tTLV!g`{&3+|?sftx)zR^?%~P6SeQU&n$^(seX& z%Ay@oSmg;%+|y@7kAv|Yv#)Wm%x*KW#!a!4>gP_Esh&B}dw4jf$N`4H4SEy1;A^o9 zJ704o?!gf2W<}4%ubeyX(o0}Ws!3-3&^KM!2h-{L8+T|2E zbJH@6OhOb9-n3I^uTLW2Kv=$loGBep=}K`Kn(FOimV!p7MaA#RM zxCcNQz{FI2Q+=AVBv7D!;e%jx3NE5FYYCo_%fSQ)PnZ@aM~MWH0tRzwM+E8wv54EB z;Mo(P3yqJotTP{xS#;Ea;8^Kf#b_k+tQ$Lj0yow6hTnpDE)9tMJM6~!_ z=vcpD4@np3`!*hO8Q@eG!)mTj3ut=3tszAc$?~*Cu%Vkv>pDWrQmW{2h9r$(T&GDV zpgz13f_eaKVWgQPAZ?}+Eji(m?#Hrb6H!aPY7`+U%~KjNVxI$x!OMS*JNpMXY$bF{ zF2s`iasf+_Ck}Cq`qY#L+|3SK2{cw;J;SDP8ihXmba@d*iGhP&@X4wY98T(vgF(hc zFx-3@L;*jTb4ms%H2%8j6f*YOL%2r95G2R&U3@GBT))?Li2}z z^Zn~aXq;rqO88YLn4OXYLsNf}zrs}PuS)5*>fsFbS{dxe;v}`E(7e4^i=k8#Q|h8Y zQ+X6*Q3f#_92RH>)-7SQ_ZLkvrg26W)7f!Or^yau)(}3`7U3lXN<89sPAtAdYA2e; ze3v+)PZxHMG`LU*Pi4(n;OGR#IYfb2kQh0;KIDTj)E$t*Zznu}|6Va!x zT_0JFTusQ_Ws4gJ9|p(=Ps(bUK_|pPhD#U>1;r61zN>fSV!tO#AKFm(EVI7Xsc-|_|8-l0hOq>v46IJa)x30e^c0uGi+2_)h2}8)| z*)+1@uiX&rG8O?(D5F;~8i#MX=rbU+HS5zZHDbz)vioMI;qJK@pqWAQg z;e~toT$}ngla1WPgR`_Z*Cu{XUnjnx7f`J9Vs)%sBLj4arw^nV>l9F3v$b$AVJK>n z)@b+VS}Zx$d6vy5sYJ@i)lG5Z_ht*hp5pw4`50VAMKOXEJ0dTHl8zyDq+^`DVcTY5 z>^nlwmyl)kcOOzOdq9$~r=?0~+Fk8fq9n{?zQWBl1G~OpKHWi;A`kp^57guQBW9?d zi-6-SxOhfx(*%odyKbobj39q7tLtK+xb9*^34nHO#NJeVGFPvsiq!tmc&S<9Xl60< z)W@?tqV#tP<%d8{;ElQLd%psy+WT^UL;3hzxhF=!mJZ?yz=`bAOsPEPm&@}(GEcdu z5+UfHpPR8?t;wKa_55Z65_&cu>tWh;O4=)R`rKv&`X)1OSVQ4pSs>5$5pbA$SBos~ zssao1yf->>7$~5|S4<7nklIO!!{za!4}H(KOam_ z^V=v8j@x`hb`G9~$Y_!BYo;La-vjMtvyG;xymR}>7~|nldZ zY_T7nHn4d(YR>162w=~SIRq32nk&@ug;ir8a1#>9pd|{v1MOw^TDuJPYk$`1NQ@z! zypWO{(0=c|vNZ_}DrIwp`JYUM&iPWb+jzQoSQavpp( z|K$X>`V~16b3im=fW{*VK2e!@)jWi(Llcc?0mAV{ul*CwinG!4Bhe@|nlT(g4%q~=Sz zZ6nWsq;z{MmFt7Z7@BLU#21r8;fHp*ISo0&$HiG*td4CT-`@P*Io()F9k|1E)h?X7 zGO&$v96TnWAlf|aU9+bmA2(a2o68e0-uk*EsUWug$RuM44UlR+1gFDyk2`{Ok&Xp% zfPSPi-y7ZnL2Rb*;ypDbcTP7adjHw7ig`$3u6A`HJ~$bQATVNRyOMkdY!~0M*(A(c z^nJ@4+jrw&FZf1nBydLXi~&WI1^qayqHT;$c-gvYUZfLj1P<;T)7PL=0+?3QuJlmc z8{M||LbJbkXzN47j2ZA4It*-K=T%zu% zyVY{T53ficOKM1U)qNd}iMQ9T2^qfCN?r|s=Nux^(+*sHhYf2NfwxNnp)|);Q6 zqs<76SMSjR_cdTm2!xzXZNOBcKh+F~2F|B9s8YPG5G5qu2=5}x1*!|2K?|4&HVMlx ziwMHuX0vp6aL)+SP>&Cqtr<&u_xWUvjnOURaQDQ*&QI;YL3WI3mzfY@6jFlD%C{%Z^+LH&uO*CZWaORgqQspq~sr%$v1jn z^-{wr3_cbyw)2yIj8ti$YKFR%0lCq9F!e83OTfeh9#)Nf*^CA8L4o|Ml zS!<1OW7#-BvD|USTVG1lxnqiaTOC1NPSgR9LELRdq?QYji$YP=GS~SFJx;6=D2wp< z&~=D%FIM|%`~|1$r5Z=Vw#T+{-hZ`5^mW6AdaKl@MBuh0d&2pT{NDs__i!?m;7k0= zPl87^k1Yv^O#evF1>>iSi0vp(a|?RAJz8ikJf8#GO5Tib)O zu86JeL6BvJPSff**FzdNXFdD_Z%!jgUvU*0k&r8|m1uh}N3W=-G56vv!P9u_h6gt-z=0(_*JmSWpEyJpa zE3DapZSH_k;Eh`cK}<7`{MH@a%t!(u^jhJ~2)J^u^#6g@ip~7IoM=1wrVv3Q^>NCw z3E9ew)e6qb8n3Ak%;HmTLx_RK1bQcW8-|u1wPe;)s~@Bnj^?>F-JT;v3*xjDS{86> ze&F(qry^9j@TK|7NzO7c`X~C(3Nw8c(^?2~;PNNbd6*~C=VY>OnG_j)fm$pN+B0Oo z8v^P<6bZXxH!u?1l!xZ#R_Ufo%+*t)-K(jS86-W#EV1$A+9CJ4ueRYe-2V}&Ny~9& zAz7V@3mPH6z2WS(E^$^M@X#jRhiijj+YC9tX44I9!D(y^2QPPr{7lGN5T#EJ0FxM{ zyQ8tKw*>#nuxh4Uo@v@nP?0s+I$Pw59`i8g7?L6w4XZheg&cC;lPx^>i-Sd```KIf z2c0hhV0jbZ+?D_bR&42rEqP|irT)x#%5ot%UYlN0EU{1g*EQnTtT?X%8Y ziDh{*9X|g6Zdm_D>1=m^N|stBPdk3ctI{hMm^|zOp4fuOf88iJDdLi)dTJj^)1v#s ztgu#571Bcvtqb_ApyIpU-9o_sZKch|e#xW7f~jL#Jg;1d;cpBKbw-IjSc?aV?))yK z;)~N9AE>?hudxEBZb7%)74qo%X&~ng>>BPmL*pKtY=UVrF1s3kx!Cry{Sf3sij~TU zB`~5v$ZNK;6V@sBWN@8S)VMi2>!v7>=r;6@6NQ_Ap&|Aec8v$zh ziCMq5$&6<=3z8*)ZM?_gJ&TkcuW{Hk7#e0PwjP(q|9hrpQC$k@dz^zK_|oDbCQ@np zKZ3O3)d>J8BA$UEF$Rg z$4aN_--azQMgh2S$&FSTaQAG+E+EhwlZikd8S`M=-m6-R^LrM#QgB{Ug&_^HJ91}~ z!I&@Hr?4Nnf&+DbfkAA!GnVg%^G{zzcTfq|iAdCYhTN>bSZ>6tpL$ZcidL^Em#SuL zZGRF%fgNcr5$I(nP;qvs%iYJt7cc)=H$WPL=684;G`*8Cvc8ZDo|G#TbI^2g*p#Ag zuRB6<(8wAj=5w%A48E<-O*rp5yKKDo4JhxYd>-?pa zekpY>$#*7N1A<(w>2JhNhXk!Pq>sk601TbvYb#TXX}D9HJnx{IMS6{7xt(9ujeZU8 z27{!;!W8J{jaKuJ-jin3`)il9n`TK<^+l0|Hg!K-J-U4MbS8Gr&b|s@tva%9hk4I( zc_>*bw26c+S-m^v%5q3NZ5*knXtlh>)FA_`o*;56iS?5%QE#X}?Gm#?5$(mX)K-GJ z*G=$%O2h0+)Ow!=UkNzFi8%j(`#U{O5~{1@i`O*I5jj{A>{ZNVJ?nr+;yzgOzI}x~ z8!j?|9{U|I8>`FEofr)1&eY#^TG%${;1=E=qi8y~+$!>yXc5_md6OE?+mD!IoTWYN z{xG9^HrImGA$H|6sjpdBTj{f7(4PSNEo7;+@eldquQ~orYaoBYc~yeraxp!pIp!zn zA04uZ%VcS&@93e_XrJ(qSqo2|mW(r#=Ue8dullT}e5gafrf}m}py+q|E7)EE`EBS( z#6!f7^Msb?AHnHh-CD}H!dW4d>yblvK{uPqTzmgS@p~KoA7HgL4f0#hcx;Zh6UXAz zfG_B1h#5$a(AGkj0p5O70W+wXy9G&FrYeRsP%(@jddlWoP#;zLf~h2dBoO0_o1`*o zs~KblKbwbXzYV_0JJJ88zdG6>7!S6PK5+K`!InA!mIN#k^to^QwHYF4CB-y}1Y?nL z+twCZ7FkUml!Q@rMp%Lt4$@EIBQZBF-ceKt<#93qLDIQDtes`LWi|474zbq%4lCGC z-M9sDBP2Y($6p_K`iO9(Iq)9T2PXaLM6p4$l53t~1Qiu$#;lg*6-bD?J~ zae=;mE^Hs6k2!vhx#d8sBrx0bUi5lt{r=buv19O|NhJ5!-GTx7sXg*j2U%zmc6bfJ z^N?lRZ*P|}-@cNe%d})y_D{ot@k4&^{G9Ii!rDmYCqmw2v8UmL_}4~_4nLe>F^;_E zOS2LN;pPR9984*KMbO1dLYIYvk!>&Z-I=q3?b9Z^Ny1~6dpV>*htsyq3{_wMO?VIE z4fyr|9jr?z%IesxpAhB9HPW6;iLOp4EAu@~2NzPTF zua4peUAKml?{bNB$Ed>*!%r97EwfY56&vvRYXN9PM}Yt4*Ym%C;#)};9I&zJ?mO^V z`*W^j++bE!>MO&$65Lyx6epL8r~o^NGj#C}!FzRIaS#42JQ^M5_MFi%v+0IrJ>fP6}cqFxa$e#H7tcgTsTH7fBY`<3| zhS|jUP`bUnhP55@M5&r(*W8OQqz7o<^tqjUAp`<9{}M!gMk?r~Mi1ODEsmq(dL8$a zR?{FWAt|;E71dBEb&be0wV+e0+-|DKIO%{36wrtUah}#OgZEY;%*kV%NYPU(XSyT( zvG!TyJ~<70?@vj6bEn}ELZ*9$7fRBhJD}>EMNu5FSVH5NZ2Hf);|gf0#KDSePwSSb@~) zOI-bSM406D(}N?bQ$|1T__=603ofopB|tF3xICRb+hT2zmif0TP5ND7)LgzNxi^iy5R@~9f& zEk_qJ+rgk3?%mpEL&I8H4jp=|74mYa(;s5(5=y+*1Yxd^M$Q}$#gHbcZd$7Q7{8mH z-LsYHigW8ecWMsz_oYjAWdbIq$O$K<;E2W-Y37u|8s7c057cZ)$)~8zb^2c{+4(hj zCWfpWgpzpzSWT16iRgoY3#RNcy`8(C5tRFSu&`@G_E_V0Az9;iaBIF`ps@xosFBN) z5+9nF4$I)IP$(iVBZuhlq#;xW<{7q&msv=MWk=w4Jx44<&4rTXQz1n!54CAM5qS0= zX|`h6oheVpSNvk7u3U%i_}e=

XFaj#NasrIN-*}Xg#sa=NY**=h(`qJ!C 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�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 LF4faU8E$XtY(oF;FpO;; zmFP(sPbc*2QN1~;v*v}f!|g|Ze>2=$S3pgnU%$&oa>zXa9itte{cA~4hA31_qoe;^ z$;r&2Y{Cjm7l+FUa--V!X6$IHxv|(cT}){$^p{_q>+9jQL5|7FKTJjsL-gOw!XZ{w zqNxYy-=*~?9tVo5E*Vf!U0Jiyp=5CIRly~crc3g}K7Fxk$*yHWH@k}-qXCVnot=Me zZ&xVxAI1CLRIg_oJ9{p>fv@#(C=K7av$IqASBH<>wQzXYvFPd*zxhq^^>WNKs$fwM z^N*!epb@U3cVSSG{JM6P{tCQM&tOoCVV;j*&as2%BUeRn1HRA)Qe?4f-qMnImU~#f zkUeV!0bSe|9zRmI52t;lI=KMP57O`^lXK`M!Ie$S#Y%UDNHDg=(ih5NFG^kIo7xfN zg#VaaBhJo}Ji*3w5z>vWd#KV_PW2XCky*dT?x~ z0tXtseze2OE+T<@l=5xhD@Wc%XKc`8YG02r*M7F!^+&=zIqdDh^W3WM$dqie&~|fC zbsoKXlJJI+73k})X8#p5Vp!XJmB4XKbdycv;CiL3!eU!icj;-^C)xtvxTI|OFm1*; z%LtP7(6noO%e=#thD_-k(LtDtjVQc#CVa;N(CkENA=ixI-y+gOkD6Wwq~xRRw83d3 z^1@OpDX{EF5W3=I^U3RsNio!c3WcH7M&p9fxYPRex&Fk2C+TyC?V&K1uW+&N{ssK$ zx0)&1A8}Q(x&w<-Pbr{8s~(=^MOo`1i2Dcay$QMVLpcM2AcS@_4Ae!#+>m z*3wPZodU1@cM8Dgz9Ue}6K9)PcJH#5nK z6z$Zy72s%iUX8anmyWrwLwsm}y$v;??S=uF6O%StVsPE=A$nGL_J>eZWJvKnRlN0J zhC}%KcG24$Tik_|lv(A6EhQ0dSg4C2l5~1Nu`wAp%Y>>R!yRXQBq8Fed+JcGJ?jp{ zJ-Xq(E*BU6NG$xpV@g1a8=o|L?*mP_f|l({7y2yNpg$_dl zS4qy>G_{!p)7^D3Rurc@S{jXblK_oQ5H+Dw5>_S<@`;bQtl`qkxY;N0u^b0Qo*c7R z^mX6rms?M7>NB$5y*>R6gt!p+A;(j@^luri@1P34X`1xv!hPD)Cj^wcudM2U(=A6T z$&SKFchP!~mxDuxKj7zHOWGGj@O;u(nDz(JJk?n(9j$kY#c@9MyEaZ)jNQWe%F#da z;7g@f0cV476qfFH@p+!mLa`2XD$#`WN(cxI#WB<_z8Rzh3IJ!fiHhVB>q=(+0{N*& zXwjPdL9BHvFKev-KF#ySU=<6lUuN|Kz9lyTDwEaSWEpq3rz$4{QdeM*sjNV)Tf*tk zC=_==yxayYNLgLA#3zcz24?s*S!^tbLC+cQKP5^h!42BXv?`e-aTBg)-orw<%pmwV z$L@Wf2ODJvkb&;%Rq()>Cq()&E=-JS zUL$I+J_!yq`8ngZ0Pvo7VzvL!;U9q4==sJHU95HZ2^(tRkjnM~^esp;~IYcBer zG;>tuc>w*qo1dS;1#`Eo4iu#V&+WIaeeD}D38E}HYCc+Cp1oirVkz2nGkJ{n$e4>0 z-A&Ua8P^MEe6Sm$eF8diEz-`3g;@aXy}wmbRI1TB!u9K{mLAD%&3E{O-6Ll>=>Z5a zO3}GIMfMm$gquzzi-HQIJ|28Vvyq3H;w|qX*2t|`XFW|^CRw(TTq6%X1acNP?)Bbg zQP>lzdvhXg$?L87Qb-V57$|d9evy4X6YpIE)x5-M#8KNw8rMc?T2!nz%ltq;3; zE~1U?isC{APBa6$VUF{8GE*&HW{jogO7od37-zQKy717gI#unj(dOsymM2SwrM;tO^xDK+Dvwp*g^UC zdCB+IhS0)qU$QCzT`mDR{goFphH6S^>*+A5C-Fzh*ShE$&jEhE*g=jNrUZQWk zqCi5t(RFBnQYG>s!1c1NB~L4Q*e+6IVVAvU0$*)VPKDt}z+Y>(HM@k9Wi0e{jj+EF` zr4+`LBNPwFlOOtwy%_dVI352^Inlms^|T~&I5qx9XgU7Ft!^Ls{UgwTf15J>Dskqe zV5oE;Z(`$P-SDyH7WgU`ZF@;`cdN(E^!4=%|$VIHz78w*2mWL%hsjFeDu)0BdDx8+E;iv}(wL zq!o6I@lqQzR!tCo*K5bYx0CZnoDvXpu{h@W8U?%lkid*R)ivYJuR@a{V?5I_su(K3 zB2+oDVG0qRKIc%X&WIX;%L;p?(Q75F6BQJI;X+;ggeeV+@J;6P@Fiwho?cz=TCgC%bF#G?L@3jVwaVJ zn>skQS@#SYich(nZjY(k}ijvO)gPp@>$_1dSI}# z2LE2wnD$J&0HfKmvsvL;EjELwaaq~m?0lwwxK7AkEvlssNNjCrPCm2L)S>M5j#Dy8 z0r%*7YD*&hF~1|>ur5bnsij4_C?^QKHTSBGw1jpBYO8RQ8(aY6!?z=!56XbI>}EMK zMf^O5#>%_m(4L~&Vzu&qU-9G3m$urQu0cAf&(hrk>I4`9c!-EVxSW>w1on^gWUkMF*3zqH8( z)!!+YA62d`I$ z7*bc(h45276Bd>;#45$!P7NanK9dg0jq0`0Iw|q?k&Mn@cSVu1SnCIFSURcD)7z*bR!m*=){2w9~;Prnc9%b_7caei##V*DN&ddgCh?$ zpb~3qYfFo^*>lCf{f$iQ;^#vjIg$CyP+ET`+$;vvZ1+lPC1u^Mb=${6Zk}#_5Lr@A z8S1^fKX%Ke!^lZ+EF?H?JBpUdPrW~B=d5XE4LgX=YS-Ymhad7>Sy4fsSzMZngDOdE zyusd7Yy8AQoXIDDYTxS1*?8(OIFMMbrKAY> z^T?6V)n1Vmv>uPITUU_#D)udYjc)b$nI+pBH!u3xxBvEfGj3J({cEz;yTY=Eyh;7; z6tPH43d`>OejU1_@m3*Fw1g=l_oAFdiNzD7y8&#veC)LA{>XI&%y zCOzH9@g{kh5Oh`dtWV?-|305xZx;}9boL696T7q3U%7IwRjf-Qul8MfdYMh;ufu1+ zvA&v6piE7nB>cKxno1rQh=d;^G$_1*{FWWHJ*QlsG&j^6 zdaoa)LqqH3CyZY^px&;V((Gb6c!anx3c!nxqYg`xR9e=Xm8Xk>upNOqtjbotae zgj&H5Kg8m`W%%yr3^C~_8BHIwSC||o)A}(82JNr z{a-Qn?Vo9pF6*E})&Drs43@BqHB`6%To+!plJTh+%V2e!VB`m3HEa$6c5aFpv{w~a zr?03qt{enyWQUqR|{zJwY~ABpad`3{LL1@@W9# zG3b|2uN>-$dfW-qWk)lcv+MQY<0oZDI+40`Tdl9qU~4gPBK)o<7n>>;`Y8#<6>&~_ zqcfU1zsD}e=JkVk5I~fQ z+S;E=Czx^LgMpWqUI9OdLVAyJJ+d62`rYS&ygnLf3cE+PI+XUL((Q1`P4aE9*yL{_ zzcY~n%M6g<2Qa)X=Fd2VqyE;ieWG7j221eI5ftyCyKxXFl`)$_B7X|s*Er*WQk}d6 z5EpsZi-&3P!ZED>SSGxoB8`rk7Z&$PEabxIor%(;QVM9zj>B|_dFxTE8t2iyglsgX;Zi# zzdMjNXZb%*?{)1_6bSHlfHQ5fI&!tXsIo0T%&=(l)y|PhifMLx=E9A!h$i*i?Q#XS)L}j@R&i zw!Am_?H#Q0{Z))8=fbIQ6Y0`3&hj$(pKHel)~;~F8EkujaMSW%wR@`T*y1peAJgrJ zg^m#{r1g_3A^E?dG@qR7J}CPtdPQRszweb5ktG_WjXx>Je341;rF=tm=)>^KHEYJ8 zI&05;Bt$JhG+}iC-8`5xXMkovsJ#Yi?cx6E&~b7qjKqhXLqpGWsqI#uFlnp;Sk1^Yw?w33QKvvLB1(HgkH zB|CpOA*C z2X!kJfkvuQbPDX6&gE{10Q@4rNh8%;f1CiRgZ5mUn(zN}e+_yhScy~DlV5pEryT-@aD$JI{4$s zd7o$O<53qmU+tsIj&^|-y~)()d!!ZDqq4L$_`8WX)Dw5bNJt;1nUOOV#JpMAk%@^8 z&-Z-^;w_lEx?rqfUc61!Ny|}`tNx}oVoo@;p=qV8&Nbc`gAx()parsrY)-90yxMFR zS_+!0{7uA)1NtD`@+T^;txrE6FnfsZ41Ry0;M&)t)8OA7blRLK(-cvE9MIDI0Om>J z4d^JN09}R(z(Ig1k?ZtQmLI1t0@lBSe7uEGT6C6BfW~!V0<>f=>NfU>@ccVw_%tE( z5WeS3WZ#i{%4ve=io+o{@Z757S2IA`}LMF1Ya5U>W!+4c*uq+l?{PxNGoJBw<<^Tnu8x z`IcgyEHhRwAQ(Kx0|y#|Xyd4HXV)Mb-a_|y8K#`Ypgq~G3kHfqgbBn?V(b?jwDwh2 zBD73|L-1_?|A~t2xU_13Hf(Do-*XV1=N*lFQ!}r|P3!t%Bc6}#*Bv~wiXn`E4UaDI zc%LDmW{d38!5<{XP4?phDT=%v?4Yh&o-7W$(9rZzm`PD+Q3^~D6&5Y)bgp5DzY;g5 zPE(QBCHFjb-d6W(>&;jZcaiGiL40`LNJq`bJKtjqP@Lrsw0blP^VI$Hodyw;X1D|| zKHTxc+%%NsCI9W>f)k-YkfuY^aSIgp;qx3ttpDui+m`tz&mvBG47#2`Q?>chk%hD; zm_zWw+jEYVMW@)a&Nt(tb)V|*yH0f!==0rVYAz*jY?1Qv(|3jbB{RM5dM|q%x=BVV zmDG)f?~eXwN>q8_+#NQvqP)h%XPJ=z0&ThLQ z3VZ04R`&JlbK~R}8NPJfEZwd%DWQHnoQV4V=01I?QtJx}fJWFKL_zw+q5qjtzJJ1- zRrUnCbQny#Bl$mU_CHUN@9d~GWff`$U0Ichl`=#A*Tp+`qSG<@ki4W}h0O5V4F^t; zJ8h^li8L-qnwxU^5?#OB<+nBROK*eH^57d+=3Mi#)!dKvNlAbG75K`^^x@vU0ds7j zuh7kw!zqz7i;<;D;{i9NFoo7%#VzT15fOKYFuFRhQ(r()7@TgR_#(y5_1(!Y3okb( z(Y~>%HyslBaev|@NYqbIih+BuZ(tPR$z9nF=opI|oVr^`Ucz3|SaUo!7GL%*^6yUT zV#det-RW=mX*OY_jZKlJlQn2(!9pu2da>9rFvaDWyb5g8!u&nD#HA5A=zqrW(n6Di z@9`ubay%PQ`|8w#!!=E@pd8Skb-xwBKh~&D=EHPMopbD~B++KzF}i&&hqMLLl}o8; z@gRS>R(&ahk^F|u#8;H#uU~@qe0@HWu#>+9ukdO5?I^{)f_wmPbe*T=;15~z>%uU3 z{nlUWFN@yPRBLB}HDg;Vv3vM`7$_hOc>VH;(cf@|Bw80KlYq@QRHWN`&KX~F zXu;o|S3qOddd{e~7KyC9*28Gmj`N<#&MjLMN4@dqD&u116wa8#@!{0@2sl2dV^8cl z@1ASZ^|%^fby}e4k$WXh%){*Z5#m_q6RUlN?y3_3u71HNd)NPuCbV#@N%_|u`4vmG zYfz%2qv9j6CzfXB2nw`DgOBa3^@y4QV)HLB9s%JZ&K6D=z2+0OZPPPw#-e?wbG~%N zkb??!gP5$BG3*6h#2X!2wI8th1Ih%gHWPJuGpN0LJ36MHtbb{NeW<`7Qjidt(2NSt zb7Ga<7784s=!v}yZ3MCLy{I;oV;h%7&LFXaapG62p;ppk5tsJ)35A5TaPchyVo5$3 zMRQ9tC8h!ZaiAfwMzqqq){}7yeC-e0OSA7qKY*FoK0nT=l5ofM?oQ zt33be&?@qdTUbqiY>OQ z3t3GDZ=#dUd&_ye65W1p(1g_?+*t)b@L_z4uFe7d(!03oEMaUPZ(nF20SM0%4$^1v zcY@}y|LP;n8qewT1oK;Hme49FP#Aq5s&6fr_rQd1X)`2$ed#H6X%4laj7BXo2Zwu;!b#sVqA&(%!^GeCW z>i8H27gv#8pDj0S@iU*R9;O@WJfO44{_LyBzoV}DzCP7CFtixz3IfOdAXzYQ@1<2P z$i#j5dXqG|B;p-t!uR#Sq2@vKrd1aQK_ zT6A-(ssl05aLYsjT5ox8dW_Lw9jb4(vIcuWu3MR|yI5;0-vkWN1`Zi4>q>gAI<)vB zRl$wRAnmHx=)s9W?-$lbJ+b-2Ae88nOAu>)iYg5!tx#tw+b|~u0Z*LGgacKUn*pxdjo?t7lvcPD6on`JuB%M6o_}E z0P7=zvitY#7M{{c4WX%MUt?F~UG`;qWqju330CuAS*u#$(3@zs|Ae0}3~dIU-%iKy>%%fmT)(l~Irkt^G zqh!d@BT^=?8Vfa!vTci7LhsT*^tihv_)a`dN{G!H=+tO3hAptZC?4B^AQl}b{>Tc* zKr|srpR6eylxsZ=&e%pckQeI`aR|MVf+>wUfd9Cb@pTVl?YM{=>jE4_cXJN0kG}OZ zCD8!{#hM0b!>4(GO^gZLgZ{D^5Gq6H|JXF7?%-wkD z5BMelB&VZ)lT%mj*`BA9EveHSnfR0T^O8*I!*Nd zZ&sard+2Z%D2`Jgl$jvl&ZQbwm2)5&)vXS=I*GQLX)iqk+tps>2eBs5yTJq4oov`q z|EF%9>zwA&RNv%?jE>a-ncJwEx*H86r3`z00bmYtwsVfP^KrK0friM?$K;(czJIMv z$0TmBs^gY6K?Wh@_7)A>PvuPm-iHoOtNkygPyn`>Vhf-Pc@n#5BSwN|_){@zy8!`Y z-sB|@Q6PTC6zlX{2XkrIG=*4_(0&003qdYP&d({*?Po*#RNC@-Sj*hN#PV+lfxm?KwR8&S|DP*|&e^ z{IA68e^_}CoIYa?(US(zW)RuaO*yPDuuch&?oFI?=JTT`6YFw{D9TTAj1)1EOHw(4 zIxbG!QS{V^Dti$f>^(AL8XBplb<$6Y9aDuGW?>pebu033nHJ zG&$%ygeBkG)Nf?3_zKwt`O#fjKRiTeh9DjR?VWun)P1W0O@uzxHR+(uX8&4mV4NqO z;RkLjci6i1PksJ|^nQ=j}~_Z3%(?D>D!4cf#s%1BZb#XIQAr|g(2nud3bdV%T4JQG9k;>@gp_I7ET;8nV0u&(Xaq^(huXRw z9R+hDjhfBn1@nMJpBORC&^;Cs(SZIQP>Z66-Ik#2de7l|s{0*()IRXsEY8OwlxBW) zn&T|BR2k{aIjTrJs!+bq{S;vzs(f&TQlNR?)~{2$)mCL+KLFoEPEKB<|HnXRERd`h zf)4}J25G&g%BrT8iYBk^()-&S>+@I6dUeb1EDOJ3Fc-L0(e$Ch8-^^#(rY(TRok@+ zbVD&;cx_wUYEN2+83@o-wn_BJBn6M}xup93*u#FyEga%hK&ms!i1jNe#ov6>VfY6eyF7n@YHaC7dxwz4 zbB^3X>cO$CfkC^;C9sOX3v(TdR4d*DH|8YNN0uinz_ft2&&%IO z4$|g(&gp)0S2=MO4(wPbp{;*({l6cDxBgDY)|8fg7I6xeo;C)WEJYcv25ok>L{jpn zSMqlH;2CW$_QU=4tE_3Tt2(H3SIaKc%S&_P2L0;{HFWQ19mV0yxUP)G#bd#&bn^43 zK4EUkoFKPnn!3{lb`RKJ-Z=+T#-EZA#b;k(k)^N z4i|T(ruCN?zXM+=qHM1pl(lY7c@;Itb8lMZDF@8|&H$YF$MouCXd7e7J$%y~gqeme z%1PG1G}QIT#A)YFIu1L+?HY72fZELmj&zP~kr09g@TpG#X(UBpSI2NnettY_;xuv~ z!Py>IxrzL;X*i_6r{12bFu1*MFW_?R09LB+pE5~X)y(gwP^$iPv~16cA1IV`vT-hd z${2^52c@J8@|QJ5^RV-_*&FeSUyh!!*+@4M9-aA?rOz0^_(rd`5-y)`yzr7xH=}Kc zJn>v6WrDI-&IZR<7P!AdW|bdbj3^onU13l{{Xfmnb-CJ3Fyrn!ZSVi{@3y&ww`)$mG%SSp2V#Bul=bIc<60$F`Yx#c}+B z`zil9EHk){Spx3~vk!lV&ZrkaJj>ix$qZ%JrX6v#SgCCUAPPk{I_8*( z120I>%1E9?i720AKZWiugVYw+MZuCs22j4*45E5<82s+uJ~k>~B;lar+Qolf#ilf& zp7d}3pN`HwuEqZU+A;LR+`v(c@Te*O=0wi#2i2H|T6rXQE*vq4g)C@CSH8y>iRQsd*SLvO89 z&9OaXw_X?U-`BmJEa?QU(X{=eS$94^KgdFzPsK{RTeaRn$QzQ~@;o8d0BEBp@K~>? zBpa|SZfd%s^wbjl>57egJUd)+65o{&gqG}5HQwUO zzr1FC6L3uVBb6Koc=dM*7w)?{!WRci)*voW&EMIk*?zK7r5`mQf%#^;@+s&laXiAB z{T`o-wZ|FbDiTqE;+On(r|2oNdPM+H^d%b5(D3zqA8k~bNVZiNXU^u@V(k`G7tSsr z+!PX}%Z)1<1R?WS@@<`D@g{_B4K$7gdXn!OvnF4Dt|V*g6(h;78jx9gI*Ue=C<7;B zUDF9JrftiMq8_qBjz%;~EY2(zTUYsOwJW2~MIIxIcWtoecwWTmvwJOll>>I*Xj`9p zR5nJ^J8f`FP8^8pb(>t_XQ0Ep`b12M>O10Y(hgG&aOU(2hfTi7ts#4)e>_pN_l0r0 z)8h+aERHzwV8s==W59%#(3kd?7!XlF7t4%%t2Qs};r6kfOH!k~nbKOGebtmuEw1uZ zLRc_SWe}TsXuwa{7|hW_5(amR(I0a#C@|nto!6FRIR*V-D4cals8|mm6apYBMKsPr;`oge z%uYEQTa+r2<^FaJ6QQHaftOs;XSpRs6vwOq$0byF2Rbru9$!yCk&=btChJ2?{!yQx z;N+YdsNxuhAUb#-{pDnGpLQw`{Tm=xPCl`vYOUuD@HzCU9VL3|VOZ7Q_2}VR`bFd! z_+zsMuTB+&QnWbg-tXcd;F?N6)%(QFj#A%%QA%pzTuEf@cg*gTW9B2Tf;f(1{nomN z)hiM5SX?yX>a%i8t|rfwjUB?I9!J(LmlP68Di{06{m|Ezyg(d3_F?nkfr;_@InH67 zHWhG-2jbS6g%-dgFBsr^rWYPxAOM?D{8rVqX>27$Omm<=HEmnw0@-^I+X^O;ChELJ zXsSmn6TM`$>x0T8i$_CM!-*!(o2L*o@&ue-Rg%8gK#)#gS2vxY$J&Ey%Uq|f>ku5! zj=c3TB0nXwLhMoJ_(s&0qw~rjqqnH{JR(2_OU$duiqv2ahw-pTPETm2#%r z9?+X9Cwm?|i!l)P;zAqglSsryhj|;K`CUa0%XvA<%bQY6$qS4B)^f1o3|d_)I}ZC0 z4niM8EGd)jsCS|FD(ao)(KUV3qwh{9zb66fiRk!xnB(aZve#Go<=@_-$)fZf3iqkd zN0>sydRUf==FC-1@#<9B&`}gtl*c=5t0WQb$y1<_(0!*SkN9omnJYrVzUu;;!V5~{ z*%^!ktSVcJ&lJ7Dl7Nr!XrUyu&(fcTke`>&49^E;#dr|jUSw)>3>FI4m__hsqsNu2 z8QutIn$`uoL}M`mz1#K*bSEIL$w{s=inFXea2m&c#FxhkdA+wQNGQ*3-+?K#wkkqw znMS%T66dT`22#ZtOLD+_5Tva742b%B?=I_t>k1?5?cI2*oXwJjVYPDv4A5}^r`wU~ z8ba7&Yecu@Y6Ea-ZbO8&Po)D-h)dwoP(DJiJjX$WC5bcA zw6^hdy@kMl{PkH?*7Mi6qFUPgd_8b*Hd0+Eju%n@x=e%L2OhAmT&2#YRM5E_zmaT- zDTi8}gnBV?iDLl+Kl;i35voutI{fh!Kt8oWFX40lYK%goNzi9D|yh z;`U7(V!GD|IYlU*-Emo4$0(Y2WH|A?5oKhj%f2}${_=5bLv&Z~A;zk6HoSLjovZl( z)+}A!md5?W_c0Sz6Smw$7xY7zIX^jWe)3vayfs^we7&gr?52T$3R3&Ljn}v*CT>^u z=WwyZw0GJs&DqPN%-d}NEW~$~8z)1zs_FKD|CR^TO#cnNUCC!+G$GXoUwtfA=Vp8OPn)K7(f*Jhx$xZrf5WWb9{U{OIf~4W? zFvKq*N+V^Xk-+FJfdgn33_oC-YjiN**L+L!ujy@)Zdek$U6zO#bDT{$!Z# zimIhii(!{OP@Qt>eZPA}F^CLW3iyTA*z(g5U4BRreuEev_naTQaf3Y|mm>vh2d;&K z+uM%(W^weAw%34s(}0PHc&^i-k0#~RN%hjbePs8(yYQT-V$HWnTps}VsPn=Goi)@J z60{m)QANXtZav7YdJnyI;Pzd_iyCVz7XJR|7HWlb{PF?!e=r+OTJG!^MI)l0kvdIL zNt=#56^Ay^TMs-%_!#hsY2kw4q|-$8*#7Z7o@$b?OgFP#N<36+-Q4E9c0=@%|-xE{>l)* zmv5YB%vacPzGJfN7B1ab!+$SNEDGS#)DPr9u<8pIcNhvu(Axum8uEGSpvrNF8wEZ2 z`wa`Zvw$NP{r(<6j5Zo_xNsVw3hW>GftsuPA2u|jzoebgwtS#njGlP&Dn)WfoM!HD z{_3V4`7_9@ihh*UekbNoiG`uT>B!@5k{%K@kg`!VGg~QNHCjDX@@z!H?M{)rx(%j8-ZYZ`F3sG`;)w!R_N?Gc4Hph_ie|-=g?~L( zFzR)(EVL?9wM=fcYF`S&eF~X8#7K2PZg+m=&#Q;QkeaYS*MKKIAG0kiJ*~>!?nT#d z@TIh(Ox3!g&^xZj5{J}z7TiSu87Knf25af%4pyt1vB<9BUT6HhC{;F$>EXGYMCL1kJGi)cc{BJyS}?3}&>MmXh?r>$zN>as zfR$N5&&_=rJFqL;Px__^L`H*nM3<*Hq4vX`iLuT#A+(P3XOshDiQ$0U%Jx5S}W zjt0H`@CUuTUzD)JQgb^Sd`jeci{#TKdkmxI=xr171+Ld=+zotgWZm}X1hH=wS$zw# zXcg1u3*w1wycL)blfKF@QQJz2d`PY0Y^qN>i2z(Gqz9C}U0X+H_7Z;qbH zCS{SH>!3r_L$UY+0W4bGZRwzX74c^Y31w#wu zR}6kPt}H`@(Y?0>tm2c2`}>BX#Pd@v6b90RB^GmBY=c}mA*k26fFj|A(`Z8mg0~gb zp;Bpdg`yOIVipl4i}i~rie2lv;#Y01-6(1~9}TBd#g;Dc75=jMa8@C5u}LN4D?reG zpTpXw%;5)G84C;k7$TGYDy=b_My9MZFwrqpqz8x0Z8MIRAz3^k=v67SgGM1;4RxWK zjJ{J;MK^eyiuiZmJs?nDP&J~fD>_trh>NP+pA!FGTx8G3ffyZ^onta_}$M@V?YWVa(LWu`3!hI`@GvM31Zg&(67yU<#np)=;)*+ z;w8Bzw-Q1hjekiCg;8Mvs}kge^95N*x9q{7JB{w;QeO)<34D`+YyOV@A$q@o3xKfU z)8M6~T)1q%A`e*)zz^#eo9G4>oq_=qnY+OtBbXd-W<4JcZVMHi09;z7^*k*%j%o)^ zWu!5SYi~ymt^hK>zqdHu{hpnMvYOUo34EV(F_m-03{EVyXdoch2`oe#A;*^PHCN4@ z1ffy3J)~aGL4Q%koX&!Fv|?EpY(UA$yRIy(#Ds{ijXNk45VW#fOg36Pq<0p-(ozrZ z$+1IBGFQ>Knz@G(&hx&` zc&L8^T|>yU^~-QI%eO+|g~t(zb{0t*0tM%`QgD(MCt>j2h8HOPiKVB2sZcu;xrMe) z-E#zV{org#(>86V)vQ6=lr}ADWc1dL%t02yk~EOnh1(2&Fj}fU@wiR4LkTFYkYEuf zBb0{1;gUQAogXU7M>_v=d;9xz)>o+^M09LXmQ}}v4RchKO5kXL>WtV@}ZZ*{xN(TKE{y{GPbZQcF@# zzT8^lnvaR26fssguaY~yQb+j{TKaU4Ln%W*#cE4Gq-;Tk15oE$Q>@zF^s$X5%~w-| z3}!Y!CYsrMMxZfEuh`?PR?&KXO=C)EjB9~pD^UCUXuhLD&5uDYV4OoA7}h|qiBYB* zJcmP$BL)~DMVThLJZK-F&ck(lm2M5rqsUAci#jPa_=vWKuu~)Y94wvAq==!gf} z%KUTt9o~j^sPm2N_3X2OKKC)|_y#RME;TU?7&J*)0i-Bf@pl$W0Bz zKciunKX+=s9l6e0cMSQxBtwgbMQary$PAo7DSR^8N$sy46)g+jbtHNXLTjW zNJJVx+T!Xzy5+0F^G3~?-Uw!@C3vr_Wv{9WLtN$W5?>Pgy46{SuFmy%>3RI3vW|La zGAbJf3+by?$5-!nHcfb`=jN+x!rxB835ZtJMqSpz^`4)twsmiIZ;eaX9BS2F)_Wr8 zC8?vCe=d3naQ!?T4lWNE(+RD#xA*;G(`mQ4q~mnBlYXYP?M*`to|Qt!MN#N%e~$33 z>7&`!KGN}y2Pyjm6hqn6lD#oWbDCx@B~__&1|KiRy(rh}8=I`Q#VtZ!D)R*Ua8Z{c zAnZ?|HmP%bJ}>3Fw{OYst1e+SL2uH?6Bvz!e4*3qp8NJSr=;V4{2-^k80UjhrVsv2 zN!E-SSlc_b;O-iA-cr^$Mza@};`P5lGkZ5~`f-tlb3=RD=_u!>+O&ndEu0lvb!zEVG4eQ=d(AGgjw5BO zoTC@%%vRvCw5tKC>22(*=#3V%+@mAvyK+x!V$8j@5;B__zsh4D^^mu;Lb7OyI2@FW z2xGUhcjx>2J1lSH(&~ae4cp7Ju&Sq?h*^v$&S_(i&P+gyL%G3@&zZa|NAY)Rw;A)!SjFa%L1EY~eE`R7x`!hd@+xSvB&kO22a>Cd<{8lTWZv$~+NOG$QY8 zU>{3qjp|gG9O1hn!(jlVu|{-xgAzcVJt9HoBd;q_3!0W#rbETSk$L+N4P@cl6X%l=0)i;4Zv_48s{zT9Kx`w=*p1dGv}5e;unUSPod#+ZnWH9 zA08xugQ!HfEub#O9xd6OqMmi?%EF@E(E@d0iRuJ8b-9`Rm3HUGqD|6>|F{7(LRp9C zuh+=Vvt>_lBlwLx$2R#6DPouE)kenFx@5p(qYN1cgSK=zHB?Q5$7R|8YkT`0Z_Q;< zBENs&E4t&Hk#&|?=3kOWT1grL{*Uv`^6wS>VKlC8F8>mO7N^5kg1rU#?6wS|=u#eJ zQ&48P>bFf|%hgCDoA^x}oMlB|w4gSGp8-Q@H_a1pw>Ii5l4LZm)CEH{E__=g^Kb05 zjhSpizx9uVKC3Fh`F9Fc41gKYrDO0izB|H1Vc4!L;t=O?9ivlPQ zY;ID0y2lxq>55Ut*^P|#cZr(M+>q--HfGwF}jYbuvmE5_zp< ze6FM9CczE?>Xl}kK%)BpnO(eX^hwjHofhGnnB?KxCKHnrIVXx(!9%p&@26~bNphw6 zQz*ccf8=@w(i5Kpal)eN#|ty%*{!0K-xtAaVqL%zx1Z01%x_HCYqLq+64A41FrQM) zUkjTMAt)bENM{)%1pOqWr{Ya*S?osigC+;MtQcK(2ys=w>r*;PwDqG;cK}SYE~_q+3ar3H%SQOov z>jcz$LrBT~k=vDd`IVLPzCGE=dMr-~6=^^Zm1(;f+1373xRQzrhtp^L{af^ek*uQ@ zJAb$-#0u8ngpvjB6K(}Zq5+}Wb>s60c&h1=QzmI7!p&m{;UJin`93`LL0!<%NRX;3 zwl!F6MCg5&NxwuGV%Y>xnl(3|4M4f1(@iul(OHyKb+qXe3uFck?a2m4PL~FJxk)AX ze5&go4~IYm5Own&TTo>=6NOfUpq|5l2Edld*gMeW{zCJU@_Yw1!6U>FB-H?&^d;UP($>Ro`NazNVk* za%gc0>3STWZI&SdxB(J!$KTC*sQB@Iz(vrrADRKDXg5nYynZ_QP58k!lbu?S$#?M< z)d9e6<4W>LIY^*dRL??aU0XsVNrF%SNqNWIx|~;}kOx^~lKe@dw=fa)0f>TQiwBKJ zR+wVRaTouM@dTtY%cu7C0>Szu0i&13l-k>@Y=nvlR-k{Uum8DMDdzW~Ts1Zj5D>x^ z+mw^Y4}>=BD#VPlsst1q2;!sT$p}lv3}5wKw>c6(-wAqmBRp4SW~0h@A!^W$dNXG( z+zBr}H92mp&Lf_{U4xG)c&&&2o~OHH1wn6KqjxSltI}9|;8k%X4Ry!HOVWvo!qSsj zxXgCOc2Y-pbBk2ez=04eZv`QecCte2!9(tfe$wH_p${p6w72LcV(?@#f8~c7&kvnI zu9Nf<`r_I2gHnB>s3rxC>=$}E(zt13_zzcg*^rnKOW%$Ep!<#QBde1nZ;|_B7M?^K z!}zU)?HBdF#k;kE1=IZBtjU^^`noRh>eYkSufnog(MRw8;V?x{S2u5NmrF#Eh17tq z9mZWW>kfy-uh>cI{5Wr|Y(s4fX}#c zkC(*?4BU9ViVl!`w@AeH-b|At&^yprVm+Ix)g6pa#ZLnSk-dEu_QbxL7a;1o@NE`b zzp;~eaxy$ujB|j3W;|1YxgQn6_9P^*T10u67FFb1<*_yRWSVUo@5D3-L7T^b8TH9q zK!4K03;xrjz4%>)AP2?9-UGd?LF#;TmnWqh32#*9+qsV5#{&9YXUH8N?W&4MBw(U~*~X*fWD;Uc6gc$ z1AhDfcJOz!frIn1g~YDKFvs6iOuSEbDR97kb^kV1d5EpY^6Q&?6v8ZP_j~8guFF^f zpd+!nS<`(X=+B?HKST!udrAwQd=!LEw!J{Y3Z=i}z5Q(=Y`dg<>QjkE$j*aC!*p43 zn7gTNQzX107CYpE3g=Jb+-Sk{niD4^DH3^HFEr2!!mWt;Bg^I~g`o!yS=KE;Jf1>; zpPD{@ZulRka0+Sy8KDn*osAj2w%mpO-MX(pj41EPokPVLb0w#YYp90+Bwd3KnMqSH$w6nFmP`UN3q zc5Fi+wF{TxoESe>bo<|uE}P${_vdL%>ivMNG|@w?)%qDe46%qYO`ViSD~C_=K<)KB$meA zuFM4RhECdWlDYsoTe$7ER*$>1q>c7$rYV=u^&Q<7vvTp3h|RBl-X92Zzg@+AHFWP& zUC42FKgY&V-HTao$$;290O^Gg5YdhHGmT+wz=z}C+%RX%f0Z{jZ^P9v_q z94*$Ub6(uD$-0S{wJ2(D`O{~l_TLWUuIU=e+G;ifJkWQOKR%r*aQm3Uy{eV%*z@b( z=-RZ|mW%p;>l;XJ>WdKscP}?Zi9Ct1Y$iB)RIPoo_CML-{cmhcZ931ix=Y8g$omFJ zGPy1eZ*00}Hq<&;#T>D`9N6>iwux0}ss6;FS+_~+@x%9B@ADIp zAG4zK=yyo>JZxvU(a1#c4MB#)%Zq+;^G9HT2NxU|P`9LI{zb21ctMl=;tO_)=9I1~ z&(hlmZPY(Hdwh48(gEBk2-zH1t2t)FIqEAqwREKz`R}i(eJShsELojYM<~u+qRTep z>$S79(6uPopQ6#apqeyq&B>Uzwr&o9h3$&WI556PV~t(;gm<~)P^=43DPz(7*8vXJ zaeZ`>!1ZC|06DFop49c%jUUrXc$XzxGz&Ipv#I-sLwCe6v+Z2KMsvC7kwrEL4iQZL zRC0_=LOH;^WRo=0tHU{2P(jWNniY579zg5q=i%?I{3Ns{K5EASCc(5H8gYIxvvQ-c z(4?27u?w76#o*bf;bT{ISpMbo#TQ!Zjz$s6Eds%d6?@Jm;EO9-)By3=Y36P&AaGuM zlVC&!2&T-WUt!rM(S;)T_)kbb6v6WUvqy{yC=zj8$RD%~6uZ+Rt-t5QmDd{n{X0o>=emOOQCryF+9#e8eT^lIdRL#f#sZtZV2qRI%q?vjQWQv`j~ zq^7*8W{D;Uopg^^ikm6PCsv$533k1kCH*dm&MZ-B5)WlC zfFd4uguiyw+f{TfhVMceOtna-FwMqIz34jNovS#Yv!;3m)0NhUa&%k+GRcphH>)h3 zfVw*3`&d3RG94HeXU8v5e#P6oHgBSAiaxUrl(XB zidZ~5(+66B@8`nWjC}Nws|?sqeZv+`C$^o7RdK{gQilMowWub6ATAm1QknJSmEyi?K&xd%JH>>HI}&jgYa^DtyTC! zZiOq_X&*isgPRrJ#qPC}A;oy z_-CT+XWI-7l(VEEqsAgkUIs12g32P4EGec)Kl|dz}RHTKYfYb@au zxV!O{%ybPK^B|Et8JXq{y;GK1U4s)vAXk=C!?3kjdnCbq(xR3xi|P@F_Nv(F%xpG& z)}80>7Inq1Gw=1k9D=4aTF_kcP}v~F;q_P8s>5=N?(ApMe^|l0=X{ZVdvlXISM=QH z5&1#C`Rek@1ehESL243FteyNYpBDUuxv^XUJep%Kb z;irVH{WAfmufY%W%V@0{Lb=#SPLJ-~RPb8(O&r#japn@S&8H@cWh$=|>!|i9F8?fr zW%-BAy>tTF=!vYxn3P$Xr+bTq8MBJBsV8oq%Y3+G9j|`e6pze&CNUQ784hj)w|~tz zwpbZyP9Wx5(n5&p|8>fzH!Eo5dtTTD(9c%OMKuX1`5Gp2ICx)yLcFZ4${rNR zWn=kQz9Z!6=$6?>Yh1uJJ_Z@Xj?DnInwZ;2+A4w~k48$LcS?Kp5{)~QBG!RnDg3S= zxGV?-baA^`Ig!w}`(sPxjcmwu2B`O;StPGS-JDyZI;yN|!HefVznP6u)`?o}e_Ps{ z28{eQH3aXonBJs610}_~;q2Z6FvkPaug*gVvHn)~y}OPhXxCXW@itP)?hjd6=+X>?C0)L50LSu^mD!;-ZQU2D1~-e&QPMAn zZ6i}SXmx(eh}|e~<6tFe8mj|GO)3n}%kb%_pH=VE9iJ@ce-hQjL|G&+BY?O4AQP{9qkq)SEzdc@bZSFEqtSZs?gMK{~&{+S9-U_f0lgits?zHJ6;4VJR{mwLcu>z>`6w-+U^dsOXD{Mciu z1p*UMW#BHxGz}=bNyeEkhY5fr?2wLapMZX3h(_;`dkayT8DJ|S+F_~7T>0;f1-VJt zyCEGRAN{EOxIJ!WGam+}Z&ux;?sv$_iq`aJrGk@-i=*3DmF!~BYha%B`XqhlMR7M7 zG=G6>{_4Qy{YXN6fSATj76=2aaeNnl(qKaL)A4uLdW&b!2#jMrG4*`FsLun%5`V)k5h9hE#-*a8R$fx(bC@g9Bs5MNj?A;O zwy6a0>fzWd$I`@qT1i^_Xr`!@O(;@+6~qUK>Jk?zpISqQ>=K5`LX~yNJ+22C9y1bA zQC%j(VFJ1J_7XgvFpZ$4FI<=r3gEh)Htq5#$K-$y2-_miF|kvA#&V_&++c5Hk_P1= z2p);}uq8_Q_0bC+*SFeuJU}9Gb4_EZ0<~KX=P~|w0&(Yyz+G|+3G1_JGR`cjWwrQ& zlcmUXwb-Q4N%(ZuB~|(Ftqr))5vz`gL{x-2cg`kN?rXj(o`Bu&+_yI+SK=hAfhR*x}B|21PJ!*DYj= zdT_OKbHoPd^|qf)+Fm2K&@1xl)%;)9hB3=@8#e2ZWm@4!bxt;0=3R0O6;+yl@Keoy zyt?%){i4;#mqsQhU6D?X`^*o--v5{#55q}lW_QvXr!un1#7h_ zm%32CZm-FxRFm$^v{Zz4{N1xqzu|NTpf$AD{Du&&x<%g*p-|_z+fqK,HJw0c>?Dz_BDJSP&aZbVEihhMo)&Ki%p9UBo_2@3bAv=$` zvn)HP)LYw5-?uw`-+^A`5AIxoIH##;pS``Et3R)<(fpKtv4Iv!p@nMMwM;g^}(?Chl9 z`+P#X@LT*UIPRd|exwEP_=4BM%6etJ+i?iduLS+FUP6Mn<;d-4yS{>S$#x0P?2hPn zxm~P=+tdwR4LtnH=CSYD7EpvL0mg~2rdzWKtSnT0E&V#2FORd6x1c(`B)Q0)DUTUZ z_fv}DY*TRW4-xl5>D{?yJ^%WWgJ5 zvmk^7A#8Y#?=&3rOA*Hx4Z6<0FwMRw=K@c3M{gM0?U*dI9X!}4f1kJDSdqo>dZo*S zd;F}!4^*JbFECUB#tf~d!OB6EMM^wP8kw)`wEk$w58V`AC^;o}C1o%?a8<0TT&XcT zJBV7OG_J<)0ab#S{#FTikB^z6SRoj#b(s4DEj>bcg}=C_1H$0G4b{?>FUrxP8g_`L z)jrOw>vzoo(s^~xu@V%2>B%Mp*LSk)-30)t44v-~_h)1Yke>;K5#}jGh|q+3&@dvrShc`jjVgEkHe* zDpMrO+fl#hgPvYnSUlwXZ;T6aW2*&-rnNq(i$e)?n25e5BrVgOoXs)b4HbD!-a!6Q z;3X9;t?dY%I!my8Q1a%liSWzl9*)T)5LTYf)Fzpa6 z`!CdaspZ0%k2Fq}q>pubY5DfkH9{^k{`wthxo^>4botHaioS_Z(o@Q5CpepM z!3Enu0yBj@O5-i6tS!Xxg)7kC2{Vl7NYjHFk5JV*ZjkwZ12>^DuLnK0@qb@d{jb&_ zopQ`}^?wjxeok%EvrqBK)7Qak`_h1h=I z3pvfu6e*obs6>mWt;#=#fIX_IdcPZHqzcCh%p-MDI!#b+i58+Di)%V_aj41=d+>t~ z4a`9XEy)h2yQcO3y^FskHF>lM`mvAkN0(l=5sMz&L9$*hsih}Ndou-ld1*4ALYyKz6fj6=}PKKK^}S^Uq&!*Tlh7401!JsG3i+- zA3{~f>{e~ZkRchToNq+8_mS{DeJDoTe+_7@cFDuu>kn%;W+h0t4x z?d&+@Nr{QFK47xL0+i@MVx=pA(!>gFISP;iyH0dHx@pIz*WSMnv_rPR2zkF*lLJL(R-K1rq zU4Y*hGoDom(3%E^O`%173Yyoqr}l59ui)Em^;P&p8c{Bt_4Gl?K-PW8cwkLhrwm+scg1nA{;9k^kIc zGpw-=U81l;>XcOrhCBc6J1>CII6+d)SMZ3L77khuY`~H z^VfH8g94#9Nh*!`sJ~SaH4(MENFB})Kg_dSsRO!~@F0zZ3=axKFhYJW6a=eMY7Ceg zkK+mGineZ&27@pn04AQ-~(#jFGHjW`+weE>8S zl07SGL$IQ{2g!%6+o9nHcfq-e_q06z&qth|v!jCGi`u?j%kSNmE-1?3I$EAxjgrqj z?wsJfx<0c|{lAQXb;n4UppUyh`#$Qvach;-ILC+)1log~=iksUHK*1x;H??B2TF^; z#HVpH zvl|6>NbUZ{2Xp$inOT{c*Hx->x@)pkt={F8_MX%OYWwVgbD1wm{n5t8#?~OmSsp4a zb|;oM(xZ5&E=-Vnn2rSmQ^8+mqn@|X1r{m3b(9&Icgigy)OX)0TCV*+TjaKmQp9(U zit(5e5wnf3sFVBS(2m*z2S3$OUC#Aqp+#qs;%J&zn&II2efk-mHcgSg0QlU%Lnzak z{8tc0?p^T!49@?1_qTjqcUPsoYj89G_#I{vRW;fTArXqcnDCq}mvoDD*>RjkWgTuR zP0l{(BWySbu<^yaMEXWzN0hC$-L~n#-sbAECQmZ`m={$l;9lYw7K9Q{Eq)THrE*b0y3J(BI-p_US{{uC#j3@R4TrVvUH9U9is234%ReK`d zIdJ*c_<1uY8woFO%)No!4TrY3-fpqu)jQ#A@|H=yi)jPn=n10Cu}Yk!Nzf2}TAB!B2V(DmIr$e|kSz~@z|)#$u2e74X= zZ}&w*mhkGEBa*JA@T1?_wiF)$LAsa6^U!@o7z9(*XtoT4e-n|}mT%Ggd|{p|$w(As z3<)%I8}#24!Ix?Lc!E{u$vOD;r8Ck$v&R>(08{=OS4O{(6dgGMdJ8$NW; zbmN9=0#ltci_azN=%WsWNUnxt)=3&G7j>JmbEdml+BmA1#|5xkf8=+k+T5y1#mLdP zG3bX_hi(rSq@WR(PM{~2rnK#bL#_*gd)8>RgrW|y{6DPZcnw{7)US3_OgnPAO`Elg zMxSh7ms|z8)>vdX;e~!jZi4YzyT)k^;#@PUlcbxMD$=eiN4V(eEpN@C*zM$qzlJuf z7X-I;Ohvi@pn>zyQ|ae1ycr{c(eD;p@Q^`vDNrG|p7jl7Iw{yrQR09u}PJJe=lPm0(d9nwJCp+o^9>#LU6Cpx?}NfuVP7>Y7$m@ z%=FDA%Xqtu!@M}~lJ{7V2%-=sHPZyKUO0u8&Jhfne#e64A|Y1wH!GwF z4mk^9l=kRRQ#^CVc#6Rp5q#E78)x8PRh2c{wpi}Uuw$MZ1&GBf&-q4B@tIsTMV=-U z`-un`1HKpcsiN>uj*dQT@)+xwt@s8}KTBhuIHX`cZ;)qBU~e z+{!foUwHWHTGkgq@DpWFb6+m*+v;g(T1XMqESs#bw6u`db<{`hlh>G@=aJA~K-lG$ zI|_XLPOLUCcd7FNbHh=Z)RiYUTZ4i?Tz4oexluPzD5mXu$e%T39|n?v=d;lg?OZW^ zwmDsS>1VB%_#-Ry2!Q5>FPCun0g>O)UuvE=e@r_2QtZ!fvm^ZwEo-8Y|GB|R38T+# zO|z=Xq&x$pk>04xEBtJ`&_~-h=WyT4-O&v=Jt;|&2OcEdjAU+7Hqy2btmJ`7wSvgs z54|N^R__Lz^mgQ(r~2Trw~&r{BHr3e<9*1tuHFu1U0y+-ctCP2j=}A zCCXV9)}Pv8%}(ck$wGpAfro`(PBXHsm|YI>27{jq$B@jvPq5y^nNf~n}r;70I#SI&DZoP z9%2KVsLs(W;`EBZO3>wDYR6+};=rr9H&{r~e>ED7KjkNDPA$!U0oForZ&XTGHUg`P zb|%Kw{uJ%fKK(Q#HN3Ty2OPBtL4^xwBy*8yzHCZka>-@k-cfHvSVaJ18%5 zQ6j>;y2@; zh-HBJaF}mJj7#BJv!q^%9u3wsj-n0jwbOYNC;63@zhU8yJ#W zxjgH+J)?bd7Io7&MK^-VPGd#vRJ;K)%6}^TwE^7AA`tzL&^9n*|MtIjNov5CvV64% zm)p=I1f1c}wv9XG6wq!h{VZZ%4~Tw%88S7Z!6D33Yesv*eB^U7t>5kQA;at^_kOc$OI-G%g7DETtw zdwFcOzm{tsrm;8eylM6K;*Y0)l>ogEIDA znm73HLPQt7p4b~if_^;g{>r+N|yuAGYddO>i2%Q-z06KsZRQxJFr;9Ts!L%A2Gb=AZHiNE?cL9lP-KE#*I9%XX68&L289%8X`x)Ck86ypoP z4FeGy#d9=v`x(*4cZH-Y4Y1j0)GyB)?kvkB32jeY_r# z6|l-QzWzk0D!m3U_w|j6;7!KvQrOS3>=$G0r|I$Kwe)HvF0#wyR#|1)A$u1l&tc`Q e%}4G^2ZyVV+&XYoY;wKqs~GWFg0%W1hW-z15IoKR literal 0 HcmV?d00001 diff --git a/crates/arr-probe/tests/fixtures/hdr10-2160p.mkv b/crates/arr-probe/tests/fixtures/hdr10-2160p.mkv new file mode 100644 index 0000000000000000000000000000000000000000..275a8fdd5b37283729590d7a5a25dcb130c0594f GIT binary patch literal 10862 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 literal 0 HcmV?d00001 diff --git a/crates/arr-probe/tests/fixtures/movie-1080p.mkv b/crates/arr-probe/tests/fixtures/movie-1080p.mkv new file mode 100644 index 0000000000000000000000000000000000000000..95297db1f4728a18c341b62fdf90fa9af8e5a334 GIT binary patch literal 29346 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

@Eq2ckR}gBjX0-nWw(wDFJTX8C#w9m^~(YZm3j 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 literal 0 HcmV?d00001 diff --git a/crates/arr-probe/tests/fixtures/notes.nfo b/crates/arr-probe/tests/fixtures/notes.nfo new file mode 100644 index 0000000..6d94e12 --- /dev/null +++ b/crates/arr-probe/tests/fixtures/notes.nfo @@ -0,0 +1 @@ +Release notes. Not a video file. diff --git a/crates/arr-probe/tests/probe.rs b/crates/arr-probe/tests/probe.rs new file mode 100644 index 0000000..3d09732 --- /dev/null +++ b/crates/arr-probe/tests/probe.rs @@ -0,0 +1,243 @@ +//! Real `ffprobe` runs against the committed fixture clips (DESIGN.md §12). +//! +//! The clips are generated by `scripts/make-probe-fixtures.sh`. Every +//! assertion here is about output `ffprobe` actually produced, not a recorded +//! JSON document, because the shape of that output is exactly what breaks +//! between ffmpeg releases. + +// Same per-target quirk as elsewhere in the workspace: an integration test +// links the library's dependencies without using them directly. +use {serde as _, serde_json as _, thiserror as _, tracing as _}; + +use std::{path::PathBuf, time::Duration}; + +use arr_core::{DolbyVisionProfile, HdrFormat, Language, Resolution}; +use arr_probe::{Error, Prober, RuntimeMatch}; + +fn fixture(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures") + .join(name) +} + +#[tokio::test] +async fn a_feature_reports_every_track_it_carries() { + let file = Prober::new() + .probe(fixture("movie-1080p.mkv")) + .await + .expect("the fixture is readable"); + + assert_eq!(file.media.resolution, Resolution::R1080p); + assert_eq!(file.video_codec, "h264"); + assert_eq!(file.media.hdr, HdrFormat::Sdr); + assert!(file.size > 0); + assert!(file.duration >= Duration::from_secs(3)); + + // A file never claims a source; that stays a claim of the release name. + assert_eq!(file.media.source, None); + + let languages: Vec<&Language> = file + .media + .audio_tracks + .iter() + .map(|track| &track.language) + .collect(); + assert_eq!( + languages, + vec![ + &Language::Other("en".to_owned()), + &Language::PortugueseBrazil, + &Language::PortuguesePortugal, + ] + ); + + let brazilian = &file.media.audio_tracks[1]; + assert_eq!(brazilian.title.as_deref(), Some("Português (Brasil)")); + assert_eq!( + brazilian.handler_name.as_deref(), + Some("Portuguese (Brazil)") + ); + + let subtitles: Vec<&Language> = file + .media + .subtitle_tracks + .iter() + .map(|track| &track.language) + .collect(); + assert_eq!( + subtitles, + vec![ + &Language::Other("en".to_owned()), + // `por` with nothing else to go on. Not guessed either way. + &Language::PortugueseUnverified, + ] + ); +} + +#[tokio::test] +async fn hdr10_comes_from_the_transfer_function() { + let file = Prober::new() + .probe(fixture("hdr10-2160p.mkv")) + .await + .expect("the fixture is readable"); + + assert_eq!(file.media.resolution, Resolution::R2160p); + assert_eq!(file.video_codec, "hevc"); + assert_eq!(file.media.hdr, HdrFormat::Hdr10); +} + +#[tokio::test] +async fn dolby_vision_profile_5_is_reported_as_a_number() { + let file = Prober::new() + .probe(fixture("dv-profile5.mp4")) + .await + .expect("the fixture is readable"); + + assert_eq!( + file.media.hdr, + HdrFormat::DolbyVision(DolbyVisionProfile { + profile: 5, + compatibility_id: Some(0), + }) + ); +} + +#[tokio::test] +async fn dolby_vision_profile_8_1_keeps_its_compatibility_id() { + let file = Prober::new() + .probe(fixture("dv-profile8-1.mp4")) + .await + .expect("the fixture is readable"); + + assert_eq!( + file.media.hdr, + HdrFormat::DolbyVision(DolbyVisionProfile { + profile: 8, + compatibility_id: Some(1), + }) + ); +} + +#[tokio::test] +async fn a_text_file_is_not_media() { + let error = Prober::new() + .probe(fixture("notes.nfo")) + .await + .expect_err("a text file is not media"); + + assert!(matches!(error, Error::NotVideo { .. }), "{error:?}"); +} + +/// Cover art is a video stream as far as ffprobe is concerned. +#[tokio::test] +async fn an_audio_file_with_artwork_is_not_video() { + let error = Prober::new() + .probe(fixture("audio-with-cover.m4a")) + .await + .expect_err("an attached picture is not a video stream"); + + assert!(matches!(error, Error::NoVideoStream { .. }), "{error:?}"); +} + +#[tokio::test] +async fn a_missing_file_is_an_error() { + let error = Prober::new() + .probe(fixture("does-not-exist.mkv")) + .await + .expect_err("nothing to probe"); + + assert!(matches!(error, Error::Rejected { .. }), "{error:?}"); +} + +#[tokio::test] +async fn a_missing_ffprobe_is_an_error() { + let error = Prober::new() + .with_binary("ffprobe-that-does-not-exist") + .probe(fixture("movie-1080p.mkv")) + .await + .expect_err("no binary to run"); + + assert!(matches!(error, Error::Spawn { .. }), "{error:?}"); +} + +/// The extras reel is the larger file on disk and the shorter one on the +/// clock. Size alone picks it; the runtime check does not. +#[tokio::test] +async fn runtime_beats_size_when_picking_the_feature() { + let selection = Prober::new() + .select_feature( + [ + fixture("extras-360p.mkv"), + fixture("movie-1080p.mkv"), + fixture("notes.nfo"), + ], + Some(Duration::from_secs(3)), + ) + .await + .expect("one of them is a video file"); + + assert_eq!(selection.feature.path, fixture("movie-1080p.mkv")); + assert_eq!(selection.runtime, RuntimeMatch::Matched); + assert_eq!(selection.others.len(), 1); + assert_eq!(selection.others[0].path, fixture("extras-360p.mkv")); + assert!(selection.others[0].size > selection.feature.size); +} + +#[tokio::test] +async fn without_an_expected_runtime_the_largest_file_wins() { + let selection = Prober::new() + .select_feature( + [fixture("movie-1080p.mkv"), fixture("extras-360p.mkv")], + None, + ) + .await + .expect("both are video files"); + + assert_eq!(selection.feature.path, fixture("extras-360p.mkv")); + assert_eq!(selection.runtime, RuntimeMatch::Unchecked); +} + +#[tokio::test] +async fn nothing_within_tolerance_still_returns_the_largest_file() { + let expected = Duration::from_hours(2); + let selection = Prober::new() + .select_feature( + [fixture("movie-1080p.mkv"), fixture("extras-360p.mkv")], + Some(expected), + ) + .await + .expect("both are video files"); + + assert_eq!(selection.feature.path, fixture("extras-360p.mkv")); + assert!( + matches!( + selection.runtime, + RuntimeMatch::Mismatched { expected: e, .. } if e == expected + ), + "{:?}", + selection.runtime + ); +} + +/// A prober that cannot run is not the same fact as a torrent without video, +/// and must not be reported as one. +#[tokio::test] +async fn a_broken_prober_fails_the_selection() { + let error = Prober::new() + .with_binary("ffprobe-that-does-not-exist") + .select_feature([fixture("movie-1080p.mkv")], None) + .await + .expect_err("nothing could be probed"); + + assert!(matches!(error, Error::Spawn { .. }), "{error:?}"); +} + +#[tokio::test] +async fn a_torrent_with_no_video_file_selects_nothing() { + let error = Prober::new() + .select_feature([fixture("notes.nfo")], None) + .await + .expect_err("no video among the candidates"); + + assert!(matches!(error, Error::NoCandidates), "{error:?}"); +} diff --git a/scripts/make-probe-fixtures.sh b/scripts/make-probe-fixtures.sh new file mode 100755 index 0000000..08af9e3 --- /dev/null +++ b/scripts/make-probe-fixtures.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# Regenerate the ffprobe fixture clips in crates/arr-probe/tests/fixtures. +# +# The clips are committed (a few KB each, DESIGN.md §12) so the test suite +# never needs ffmpeg. This script exists to document how they were made and to +# rebuild them if the set ever needs to change. +# +# The two Dolby Vision clips cannot be encoded directly: x265 refuses to write +# a DV stream without an RPU file. Instead a real HEVC clip is produced and the +# `dvcC`/`dvvC` configuration box is spliced into its sample entry, which is +# exactly what ffprobe reads to report `dv_profile`. + +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +out="$root/crates/arr-probe/tests/fixtures" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +mkdir -p "$out" + +# 1080p feature: three audio tracks covering all three §5.2 signals, plus two +# subtitle tracks. Three seconds, so the runtime sanity check has something to +# compare against. +printf '1\n00:00:00,000 --> 00:00:01,000\nhello\n\n' > "$work/en.srt" +printf '1\n00:00:00,000 --> 00:00:01,000\nolá\n\n' > "$work/pt.srt" + +ffmpeg -hide_banner -loglevel error -y \ + -f lavfi -i "color=c=black:s=1920x1080:r=24:d=3" \ + -f lavfi -i "sine=frequency=440:duration=3" \ + -f lavfi -i "sine=frequency=660:duration=3" \ + -f lavfi -i "sine=frequency=880:duration=3" \ + -i "$work/en.srt" -i "$work/pt.srt" \ + -map 0:v -map 1:a -map 2:a -map 3:a -map 4:s -map 5:s \ + -c:v libx264 -preset ultrafast -crf 51 -pix_fmt yuv420p \ + -c:a aac -b:a 8k -c:s srt \ + -metadata:s:a:0 language=eng -metadata:s:a:0 title="English" \ + -metadata:s:a:1 language=por -metadata:s:a:1 title="Português (Brasil)" \ + -metadata:s:a:1 handler_name="Portuguese (Brazil)" \ + -metadata:s:a:2 language=pt-PT \ + -metadata:s:s:0 language=eng -metadata:s:s:1 language=por \ + "$out/movie-1080p.mkv" + +# The extras reel trap: larger on disk than the feature (noise compresses +# badly) but a third of its runtime. +ffmpeg -hide_banner -loglevel error -y \ + -f lavfi -i "nullsrc=s=640x360:r=12:d=1,geq=random(1)*255:128:128" \ + -an -c:v libx264 -preset ultrafast -crf 51 -pix_fmt yuv420p \ + "$out/extras-360p.mkv" + +# HDR10: bt2020 primaries and the PQ transfer, which is all ffprobe reports. +ffmpeg -hide_banner -loglevel error -y \ + -f lavfi -i "color=c=black:s=3840x2160:r=24:d=1" -an \ + -vf "format=yuv420p10le,setparams=color_primaries=bt2020:color_trc=smpte2084:colorspace=bt2020nc" \ + -c:v libx265 -preset ultrafast -crf 51 \ + "$out/hdr10-2160p.mkv" + +# HEVC base layer for the two Dolby Vision clips. +ffmpeg -hide_banner -loglevel error -y \ + -f lavfi -i "color=c=black:s=1280x720:r=24:d=1" -an \ + -vf "format=yuv420p10le,setparams=color_primaries=bt2020:color_trc=smpte2084:colorspace=bt2020nc" \ + -c:v libx265 -preset ultrafast -crf 51 -tag:v hvc1 \ + "$work/base.mp4" + +cat > "$work/dovi.py" <<'PY' +"""Splice a DolbyVisionConfigurationBox into an MP4 HEVC sample entry.""" + +import struct +import sys + +CONTAINERS = {"moov", "trak", "mdia", "minf", "stbl", "stsd"} + + +def children(buf, start, end): + boxes = [] + off = start + while off + 8 <= end: + size = struct.unpack(">I", buf[off : off + 4])[0] + if size == 0: + size = end - off + boxes.append((off, size, buf[off + 4 : off + 8].decode("latin1"))) + off += size + return boxes + + +def first_child_offset(off, kind): + # stsd carries a version/flags word and an entry count before its children. + return off + (16 if kind == "stsd" else 8) + + +def find(buf, start, end, path): + for off, size, kind in children(buf, start, end): + if kind != path[0]: + continue + if len(path) == 1: + return off, size + return find(buf, first_child_offset(off, kind), off + size, path[1:]) + return None + + +def configuration(profile, compatibility_id, level=6): + bits = (profile & 0x7F) << 41 + bits |= (level & 0x3F) << 35 + bits |= 1 << 34 # rpu_present_flag + bits |= 1 << 32 # bl_present_flag + bits |= (compatibility_id & 0xF) << 28 + return bytes([1, 0]) + bits.to_bytes(6, "big") + bytes(16) + + +def grow_ancestors(buf, target, growth): + def walk(start, end): + for off, size, kind in children(buf, start, end): + if not off <= target < off + size: + continue + struct.pack_into(">I", buf, off, size + growth) + if kind in CONTAINERS: + walk(first_child_offset(off, kind), off + size) + return + + walk(0, len(buf)) + + +def main(source, destination, profile, compatibility_id): + buf = bytearray(open(source, "rb").read()) + entry = find(buf, 0, len(buf), ["moov", "trak", "mdia", "minf", "stbl", "stsd", "hvc1"]) + if entry is None: + sys.exit("no hvc1 sample entry") + off, size = entry + + payload = configuration(profile, compatibility_id) + kind = b"dvvC" if profile == 5 else b"dvcC" + box = struct.pack(">I", 8 + len(payload)) + kind + payload + + buf[off + 4 : off + 8] = b"dvh1" + buf[off + size : off + size] = box + grow_ancestors(buf, off, len(box)) + open(destination, "wb").write(bytes(buf)) + + +if __name__ == "__main__": + main(sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4])) +PY + +python3 "$work/dovi.py" "$work/base.mp4" "$out/dv-profile5.mp4" 5 0 +python3 "$work/dovi.py" "$work/base.mp4" "$out/dv-profile8-1.mp4" 8 1 + +# An audio file with embedded artwork. ffprobe reports the cover as a video +# stream, so this is what stops a soundtrack passing for a feature. +ffmpeg -hide_banner -loglevel error -y \ + -f lavfi -i "color=c=red:s=64x64:d=1" -frames:v 1 "$work/cover.jpg" +ffmpeg -hide_banner -loglevel error -y \ + -f lavfi -i "sine=frequency=440:duration=2" -i "$work/cover.jpg" \ + -map 0:a -map 1:v -c:a aac -b:a 8k -c:v mjpeg -disposition:v attached_pic \ + "$out/audio-with-cover.m4a" + +# Not media at all: the thing a multi-file torrent is full of. +printf 'Release notes. Not a video file.\n' > "$out/notes.nfo" + +ls -l "$out"