use crate::ids::{ChannelId, VideoId, image_url_allowed};
use crate::text;
pub const MAX_TITLE: usize = 200;
pub const MAX_CHANNEL: usize = 100;
pub const MAX_DESCRIPTION: usize = 500;
#[derive(Clone, Debug, PartialEq)]
pub struct Video {
pub id: VideoId,
pub title: String,
pub channel_id: Option<ChannelId>,
pub channel: String,
pub description: String,
pub published: Option<i64>,
pub views: Option<u64>,
pub duration: Option<u32>,
pub short: bool,
pub live: bool,
pub upcoming: bool,
pub thumbnail: Option<String>,
}
impl Video {
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
}
}
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()
}
pub fn checked_image_url(url: Option<&str>) -> Option<String> {
url.filter(|u| image_url_allowed(u)).map(str::to_string)
}
pub fn views(n: u64) -> String {
let short = 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"),
};
let unit = if n == 1 { "view" } else { "views" };
format!("{short} {unit}")
}
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())
}
}
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")
}
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");
}
}