youtube-legend-cli 0.4.0

Non-interactive Rust CLI that downloads YouTube subtitles through third-party providers, using a native Unix stdin/stdout interface.
//! Per-provider health that survives the process.
//!
//! # Why this exists
//!
//! `AppError::retryable` classifies by TYPE, and by type
//! `ProviderUnavailable` is retryable: a provider that is unavailable
//! now is, in the general case, available later. That answer is right
//! over the population of future occurrences and wrong for the one
//! upstream this project has measured broken across its entire history,
//! which returns an empty list even for videos with manual tracks.
//!
//! Flipping the match arm would not fix it. It would move the same
//! defect to the other side of the population and start lying about
//! every upstream that is merely having a bad afternoon. The
//! information the type does not carry is TIME, so the remedy is state
//! rather than another arm.
//!
//! # Why not `EscalationPolicy`
//!
//! `crate::net::waf::EscalationPolicy` also counts failures, and
//! `src/retry.rs` records that a second in-memory counter beside it
//! would have been drift rather than a fix. That reasoning holds and
//! does not reach here. `EscalationPolicy` counts consecutive failures
//! against the WAF vendor *currently being fought*, holds one vendor at
//! a time, lives entirely inside one invocation, and is not
//! serialisable. This records a different fact — which provider has
//! been failing, for how long, across separate runs of a one-shot CLI —
//! and a fact that cannot be expressed in the other type is not a
//! duplicate of it.
//!
//! # The two conditions
//!
//! A provider is called persistently broken only when it has failed at
//! least `budget` times in a row AND the run of failures has lasted
//! longer than `window`. Count alone would condemn an upstream for one
//! bad minute; duration alone would condemn one that failed once a
//! month ago. Requiring both is what separates a broken service from an
//! unlucky one.

use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};

/// Consecutive failures before a provider may be called broken.
const DEFAULT_FAILURE_BUDGET: u64 = 3;

/// Seconds a run of failures must span before it counts as persistent.
///
/// A day, because that is long enough to outlast a deploy, a rate-limit
/// window and a bad afternoon, all of which are exactly the transient
/// conditions `retryable: true` exists to describe.
const DEFAULT_PERSISTENCE_WINDOW_SECS: u64 = 86_400;

/// One provider's failure run.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
struct Record {
    consecutive_failures: u64,
    first_failure_unix: u64,
    last_failure_unix: u64,
}

/// Consecutive failures before a provider may be called broken.
///
/// Resolves `net.health.failure_budget`.
fn failure_budget() -> u64 {
    crate::config::tuning_u64_in_range(
        "net.health.failure_budget",
        DEFAULT_FAILURE_BUDGET,
        1,
        1_000,
    )
}

/// Seconds a failure run must span before it counts as persistent.
///
/// Resolves `net.health.persistence_window_secs`.
fn persistence_window_secs() -> u64 {
    crate::config::tuning_u64_in_range(
        "net.health.persistence_window_secs",
        DEFAULT_PERSISTENCE_WINDOW_SECS,
        60,
        31_536_000,
    )
}

/// Absolute path of the health ledger, when a state directory exists.
fn ledger_path() -> Option<PathBuf> {
    crate::config::state_dir()
        .ok()
        .map(|d| d.join("provider-health.tsv"))
}

/// Seconds since the Unix epoch, or `0` if the clock is before it.
fn now_unix() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_or(0, |d| d.as_secs())
}

/// Read the ledger.
///
/// A malformed line is skipped rather than fatal. This file is an
/// optimisation, and a CLI that refuses to download subtitles because
/// its bookkeeping is corrupt has turned a convenience into an outage.
fn load() -> Vec<(String, Record)> {
    let Some(path) = ledger_path() else {
        return Vec::new();
    };
    let Ok(text) = std::fs::read_to_string(path) else {
        return Vec::new();
    };
    text.lines()
        .filter_map(|line| {
            let mut parts = line.split('\t');
            let name = parts.next()?.to_owned();
            let record = Record {
                consecutive_failures: parts.next()?.parse().ok()?,
                first_failure_unix: parts.next()?.parse().ok()?,
                last_failure_unix: parts.next()?.parse().ok()?,
            };
            (!name.is_empty()).then_some((name, record))
        })
        .collect()
}

/// Write the ledger, replacing it wholesale.
///
/// Every failure is swallowed, for the reason [`load`] gives.
fn store(entries: &[(String, Record)]) {
    let Some(path) = ledger_path() else {
        return;
    };
    if let Some(parent) = path.parent() {
        let _ = std::fs::create_dir_all(parent);
    }
    let mut body = String::new();
    for (name, r) in entries {
        body.push_str(&format!(
            "{name}\t{}\t{}\t{}\n",
            r.consecutive_failures, r.first_failure_unix, r.last_failure_unix
        ));
    }
    let _ = std::fs::write(path, body);
}

/// Record that `provider` failed in a way that looks upstream-side.
pub(crate) fn record_failure(provider: &str) {
    let now = now_unix();
    let mut entries = load();
    match entries.iter_mut().find(|(name, _)| name == provider) {
        Some((_, record)) => {
            record.consecutive_failures = record.consecutive_failures.saturating_add(1);
            record.last_failure_unix = now;
        }
        None => entries.push((
            provider.to_owned(),
            Record {
                consecutive_failures: 1,
                first_failure_unix: now,
                last_failure_unix: now,
            },
        )),
    }
    store(&entries);
}

/// Record that `provider` answered, clearing its failure run.
///
/// One success ends the run outright rather than decrementing it. The
/// question this ledger answers is "has this upstream been down without
/// interruption", and a single answer is the interruption.
pub(crate) fn record_success(provider: &str) {
    let mut entries = load();
    let before = entries.len();
    entries.retain(|(name, _)| name != provider);
    if entries.len() != before {
        store(&entries);
    }
}

/// `true` when `provider` has failed enough times over a long enough
/// stretch that another attempt is not worth the caller's time.
pub(crate) fn is_persistently_broken(provider: &str) -> bool {
    verdict(
        &load(),
        provider,
        failure_budget(),
        persistence_window_secs(),
    )
}

/// The decision of [`is_persistently_broken`], over explicit inputs.
///
/// Separated so tests can state the property without a ledger on disk
/// and without waiting a day for the window to elapse.
fn verdict(entries: &[(String, Record)], provider: &str, budget: u64, window: u64) -> bool {
    entries
        .iter()
        .find(|(name, _)| name == provider)
        .is_some_and(|(_, r)| {
            r.consecutive_failures >= budget
                && r.last_failure_unix.saturating_sub(r.first_failure_unix) >= window
        })
}

#[cfg(test)]
mod tests {
    use super::*;

    fn entry(failures: u64, span: u64) -> Vec<(String, Record)> {
        vec![(
            "provider-x".to_owned(),
            Record {
                consecutive_failures: failures,
                first_failure_unix: 1_000,
                last_failure_unix: 1_000 + span,
            },
        )]
    }

    /// Both conditions are required, and each one alone is refused.
    ///
    /// This is the whole point of the type: count alone condemns an
    /// upstream for one bad minute, and duration alone condemns one
    /// that failed once a month ago.
    #[test]
    fn only_count_and_duration_together_condemn_a_provider() {
        assert!(
            verdict(&entry(5, 90_000), "provider-x", 3, 86_400),
            "five failures over a day is the case this exists for"
        );
        assert!(
            !verdict(&entry(5, 60), "provider-x", 3, 86_400),
            "five failures in a minute is a blip, not a broken upstream"
        );
        assert!(
            !verdict(&entry(1, 90_000), "provider-x", 3, 86_400),
            "one failure a day ago is not a run of failures"
        );
    }

    /// An unknown provider is healthy, because absence of evidence is
    /// not evidence of breakage.
    #[test]
    fn an_unrecorded_provider_is_never_condemned() {
        assert!(!verdict(&entry(9, 999_999), "provider-y", 1, 1));
        assert!(!verdict(&[], "provider-x", 1, 1));
    }

    /// The ledger round-trips through its own line format.
    #[test]
    fn a_record_survives_the_line_format() {
        let entries = entry(4, 90_000);
        let mut body = String::new();
        for (name, r) in &entries {
            body.push_str(&format!(
                "{name}\t{}\t{}\t{}\n",
                r.consecutive_failures, r.first_failure_unix, r.last_failure_unix
            ));
        }
        let parsed: Vec<(String, Record)> = body
            .lines()
            .filter_map(|line| {
                let mut parts = line.split('\t');
                let name = parts.next()?.to_owned();
                Some((
                    name,
                    Record {
                        consecutive_failures: parts.next()?.parse().ok()?,
                        first_failure_unix: parts.next()?.parse().ok()?,
                        last_failure_unix: parts.next()?.parse().ok()?,
                    },
                ))
            })
            .collect();
        assert_eq!(parsed, entries);
    }

    /// A corrupt line is skipped and never panics, because bookkeeping
    /// must not be able to take the download down with it.
    #[test]
    fn a_malformed_line_is_skipped_rather_than_fatal() {
        let text = "provider-x\tnot-a-number\t1\t2\ngood\t1\t2\t3\n";
        let parsed: Vec<String> = text
            .lines()
            .filter_map(|line| {
                let mut parts = line.split('\t');
                let name = parts.next()?.to_owned();
                let _: u64 = parts.next()?.parse().ok()?;
                let _: u64 = parts.next()?.parse().ok()?;
                let _: u64 = parts.next()?.parse().ok()?;
                Some(name)
            })
            .collect();
        assert_eq!(parsed, vec!["good".to_string()]);
    }
}