tuitube 0.12.0

YouTube in your terminal: a vim-style client with local subscriptions, built on yt-dlp and mpv
//! A video as the grid shows it, from a feed, a search or a channel's list,
//! and how its numbers are written.

use crate::ids::{ChannelId, VideoId, image_url_allowed};
use crate::text;

/// Longest title kept, in characters. YouTube allows 100.
pub const MAX_TITLE: usize = 200;
/// Longest channel name kept.
pub const MAX_CHANNEL: usize = 100;
/// Longest description kept, in characters: YouTube allows 5,000. Cards
/// show its start, `i` all of it.
pub const MAX_DESCRIPTION: usize = 5_000;
/// Most lines of a description kept.
const MAX_LINES: usize = 300;
/// Longest chapter title kept.
pub const MAX_CHAPTER: usize = 120;

/// The title of a video known only by its id: `youtu.be/<id>`.
pub fn placeholder_title(id: &VideoId) -> String {
    format!("youtu.be/{id}")
}

#[derive(Clone, Debug, PartialEq)]
pub struct Video {
    pub id: VideoId,
    pub title: String,
    pub channel_id: Option<ChannelId>,
    pub channel: String,
    /// The start of the description, on one line.
    pub description: String,
    /// When it was published, in Unix seconds.
    pub published: Option<i64>,
    pub views: Option<u64>,
    /// Length in seconds; feeds don't say.
    pub duration: Option<u32>,
    pub short: bool,
    pub live: bool,
    /// A live stream or premiere that hasn't started.
    pub upcoming: bool,
    /// A thumbnail URL on one of YouTube's image hosts; else the one every
    /// video has is used ([`VideoId::thumbnail_url`]).
    pub thumbnail: Option<String>,
}

impl Video {
    /// A video known only by its id, from a link or a Takeout list: its
    /// title is its short link until YouTube says more.
    pub fn placeholder(id: VideoId, short: bool) -> Self {
        Self {
            title: placeholder_title(&id),
            id,
            channel_id: None,
            channel: String::new(),
            description: String::new(),
            published: None,
            views: None,
            duration: None,
            short,
            live: false,
            upcoming: false,
            thumbnail: None,
        }
    }

    /// Only its id is known ([`Video::placeholder`]).
    pub fn needs_details(&self) -> bool {
        self.title == placeholder_title(&self.id)
    }

    /// Where its thumbnail can be fetched, best first: the 1280×720 WebP
    /// most videos have, the one a search named, then the 480×360 every
    /// video has.
    pub fn thumbnail_urls(&self) -> Vec<String> {
        let mut urls = vec![self.id.large_thumbnail_url()];
        urls.extend(self.thumbnail.clone());
        urls.push(self.id.thumbnail_url());
        urls.dedup();
        urls
    }
}

/// Remote text made safe for one line: hidden characters gone, every run of
/// whitespace (line breaks too) one space, at most `max` characters.
pub fn one_line(s: &str, max: usize) -> String {
    let cleaned = text::clean(s);
    let joined = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
    text::first_chars(&joined, max).to_string()
}

/// Remote text shown on lines of its own (a description, a comment): as
/// [`one_line`] cleans a title, but each line on its own, a blank line
/// kept between paragraphs (never two), at most `max` characters and
/// `MAX_LINES` lines.
pub fn lines(s: &str, max: usize) -> String {
    let cleaned = text::clean(text::first_chars(s, max.saturating_mul(2)));
    let mut kept: Vec<String> = Vec::new();
    for line in cleaned.lines() {
        let line = line.split_whitespace().collect::<Vec<_>>().join(" ");
        let blank = line.is_empty();
        if blank && kept.last().is_none_or(String::is_empty) {
            continue;
        }
        kept.push(line);
        if kept.len() >= MAX_LINES {
            break;
        }
    }
    while kept.last().is_some_and(String::is_empty) {
        kept.pop();
    }
    text::first_chars(&kept.join("\n"), max).to_string()
}

/// A thumbnail URL from a feed or yt-dlp, kept only if it's on YouTube's
/// image hosts.
pub fn checked_image_url(url: Option<&str>) -> Option<String> {
    url.filter(|u| image_url_allowed(u)).map(str::to_string)
}

/// `1.2M views`, as YouTube writes them.
pub fn views(n: u64) -> String {
    let unit = if n == 1 { "view" } else { "views" };
    format!("{} {unit}", short_number(n))
}

/// `1.2M`, as YouTube writes its numbers.
pub fn short_number(n: u64) -> String {
    match n {
        0..1_000 => n.to_string(),
        1_000..1_000_000 => compact(n as f64 / 1e3, "K"),
        1_000_000..1_000_000_000 => compact(n as f64 / 1e6, "M"),
        _ => compact(n as f64 / 1e9, "B"),
    }
}

/// One decimal under 10 (`1.2K`), none above (`12K`), and never `1.0K`.
fn compact(x: f64, suffix: &str) -> String {
    if x < 10.0 {
        let rounded = (x * 10.0).floor() / 10.0;
        if rounded.fract() == 0.0 {
            format!("{rounded:.0}{suffix}")
        } else {
            format!("{rounded:.1}{suffix}")
        }
    } else {
        format!("{:.0}{suffix}", x.floor())
    }
}

/// `3 days ago`, from Unix seconds `then` and `now`.
pub fn age(then: i64, now: i64) -> String {
    let seconds = now.saturating_sub(then).max(0);
    let (n, unit) = match seconds {
        0..60 => return "just now".into(),
        60..3_600 => (seconds / 60, "minute"),
        3_600..86_400 => (seconds / 3_600, "hour"),
        86_400..604_800 => (seconds / 86_400, "day"),
        604_800..2_629_800 => (seconds / 604_800, "week"),
        2_629_800..31_557_600 => (seconds / 2_629_800, "month"),
        _ => (seconds / 31_557_600, "year"),
    };
    let s = if n == 1 { "" } else { "s" };
    format!("{n} {unit}{s} ago")
}

/// `4:05`, or `1:02:03` for an hour or more.
pub fn duration(seconds: u32) -> String {
    let (h, m, s) = (seconds / 3600, seconds / 60 % 60, seconds % 60);
    if h > 0 {
        format!("{h}:{m:02}:{s:02}")
    } else {
        format!("{m}:{s:02}")
    }
}

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

    #[test]
    fn views_are_written_as_youtube_does() {
        assert_eq!(views(1), "1 view");
        assert_eq!(views(999), "999 views");
        assert_eq!(views(1_000), "1K views");
        assert_eq!(views(1_250), "1.2K views");
        assert_eq!(views(45_900), "45K views");
        assert_eq!(views(638_955), "638K views");
        assert_eq!(views(1_999_999), "1.9M views");
        assert_eq!(views(3_100_000_000), "3.1B views");
    }

    #[test]
    fn ages_round_down_to_the_largest_unit() {
        let now = 1_800_000_000;
        assert_eq!(age(now - 30, now), "just now");
        assert_eq!(age(now - 60, now), "1 minute ago");
        assert_eq!(age(now - 7_200, now), "2 hours ago");
        assert_eq!(age(now - 3 * 86_400, now), "3 days ago");
        assert_eq!(age(now - 15 * 86_400, now), "2 weeks ago");
        assert_eq!(age(now - 400 * 86_400, now), "1 year ago");
        assert_eq!(age(now + 100, now), "just now", "clock skew");
        assert!(age(i64::MIN, now).ends_with("years ago"), "no overflow");
    }

    #[test]
    fn durations_show_hours_only_when_needed() {
        assert_eq!(duration(5), "0:05");
        assert_eq!(duration(245), "4:05");
        assert_eq!(duration(11_104), "3:05:04");
    }

    #[test]
    fn remote_text_becomes_one_clean_line() {
        assert_eq!(one_line("a\n\nb\u{1b}[2J\tc\u{202E}d", 100), "a b[2J cd");
        assert_eq!(one_line("héllo world", 3), "hél");
    }

    #[test]
    fn a_description_keeps_its_lines_each_cleaned() {
        let text = "\n\nIntro\u{1b}[2J  line\r\n\n\n\n0:00 Start\u{202E}\tnow\u{2028}x\n\n";
        assert_eq!(lines(text, 100), "Intro[2J line\n\n0:00 Start nowx");
        assert_eq!(lines("one\ntwo", 5), "one\nt");
        let many = "a\n".repeat(1_000);
        assert_eq!(lines(&many, 10_000).lines().count(), MAX_LINES);
    }
}