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 = 5_000;
const MAX_LINES: usize = 300;
pub const MAX_CHAPTER: usize = 120;
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,
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 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,
}
}
pub fn needs_details(&self) -> bool {
self.title == placeholder_title(&self.id)
}
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 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()
}
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 unit = if n == 1 { "view" } else { "views" };
format!("{} {unit}", short_number(n))
}
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"),
}
}
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");
}
#[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);
}
}