use std::collections::HashMap;
use std::fmt;
use serde::{Deserialize, Serialize};
use serde_with::{DefaultOnNull, serde_as};
use crate::model::caption::{AutomaticCaption, Subtitle};
use crate::model::chapter::Chapter;
#[cfg(any(feature = "live-recording", feature = "live-streaming"))]
use crate::model::format::Protocol;
use crate::model::format::{Format, FormatType};
use crate::model::heatmap::Heatmap;
use crate::model::thumbnail::Thumbnail;
pub const FORMAT_URL_LIFETIME: i64 = 6 * 3600;
use super::DrmStatus;
#[serde_as]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Video {
pub id: String,
pub title: String,
pub thumbnail: Option<String>,
pub description: Option<String>,
pub availability: Option<String>,
#[serde(rename = "timestamp")]
pub upload_date: Option<i64>,
pub duration: Option<i64>,
pub duration_string: Option<String>,
pub webpage_url: Option<String>,
pub language: Option<String>,
pub media_type: Option<String>,
pub is_live: Option<bool>,
pub was_live: Option<bool>,
pub release_timestamp: Option<i64>,
pub release_year: Option<i64>,
#[cfg(any(feature = "live-recording", feature = "live-streaming"))]
pub concurrent_view_count: Option<i64>,
pub view_count: Option<i64>,
pub like_count: Option<i64>,
pub comment_count: Option<i64>,
pub channel: Option<String>,
pub channel_id: Option<String>,
pub channel_url: Option<String>,
pub channel_follower_count: Option<i64>,
pub uploader: Option<String>,
pub uploader_id: Option<String>,
pub uploader_url: Option<String>,
pub channel_is_verified: Option<bool>,
#[serde(default)]
pub formats: Vec<Format>,
#[serde(default)]
pub thumbnails: Vec<Thumbnail>,
#[serde(default)]
pub automatic_captions: HashMap<String, Vec<AutomaticCaption>>,
#[serde(default)]
pub subtitles: HashMap<String, Vec<Subtitle>>,
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnNull")]
pub chapters: Vec<Chapter>,
#[serde(default)]
pub heatmap: Option<Heatmap>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub categories: Vec<String>,
pub age_limit: i64,
#[serde(rename = "_has_drm")]
pub has_drm: Option<DrmStatus>,
pub live_status: String,
pub playable_in_embed: bool,
#[serde(flatten)]
pub extractor_info: ExtractorInfo,
#[serde(rename = "_version")]
pub version: Version,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ExtractorInfo {
pub extractor: String,
pub extractor_key: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Version {
pub version: String,
pub current_git_head: Option<String>,
pub release_git_head: Option<String>,
pub repository: String,
}
impl Video {
pub fn get_chapters(&self) -> &[Chapter] {
&self.chapters
}
pub fn get_chapter_at_time(&self, timestamp: f64) -> Option<&Chapter> {
self.get_chapters()
.iter()
.find(|chapter| chapter.contains_timestamp(timestamp))
}
pub fn has_chapters(&self) -> bool {
!self.get_chapters().is_empty()
}
pub fn get_heatmap(&self) -> Option<&Heatmap> {
self.heatmap.as_ref()
}
pub fn has_heatmap(&self) -> bool {
self.heatmap.is_some()
}
pub fn formats_available_at(&self) -> Option<i64> {
self.formats
.iter()
.filter(|f| !matches!(f.format_type(), FormatType::Storyboard | FormatType::Manifest))
.filter_map(|f| f.available_at)
.min()
}
pub fn are_format_urls_fresh(&self) -> bool {
let Some(available_at) = self.formats_available_at() else {
return true;
};
let now = crate::utils::current_timestamp();
now < available_at + FORMAT_URL_LIFETIME
}
pub fn best_thumbnail(&self) -> Option<&Thumbnail> {
self.thumbnails
.iter()
.filter(|t| t.width.is_some() && t.height.is_some())
.max_by_key(|t| (t.width.unwrap_or(0) * t.height.unwrap_or(0), t.preference))
.or_else(|| self.thumbnails.iter().max_by_key(|t| t.preference))
}
pub fn worst_thumbnail(&self) -> Option<&Thumbnail> {
self.thumbnails
.iter()
.filter(|t| t.width.is_some() && t.height.is_some())
.min_by_key(|t| (t.width.unwrap_or(0) * t.height.unwrap_or(0), t.preference))
.or_else(|| self.thumbnails.iter().min_by_key(|t| t.preference))
}
pub fn thumbnail_for_size(&self, min_width: u32, min_height: u32) -> Option<&Thumbnail> {
self.thumbnails
.iter()
.filter(|t| {
t.width.is_some_and(|w| w >= min_width as i64) && t.height.is_some_and(|h| h >= min_height as i64)
})
.min_by_key(|t| t.width.unwrap_or(0) * t.height.unwrap_or(0))
}
pub fn best_storyboard_format(&self) -> Option<&Format> {
self.formats
.iter()
.filter(|f| f.format_type() == FormatType::Storyboard)
.max_by(|a, b| {
let a_frags = a.storyboard_info.fragments.as_ref().map_or(0, Vec::len);
let b_frags = b.storyboard_info.fragments.as_ref().map_or(0, Vec::len);
let a_area =
a.video_resolution.width.unwrap_or(0) as u64 * a.video_resolution.height.unwrap_or(0) as u64;
let b_area =
b.video_resolution.width.unwrap_or(0) as u64 * b.video_resolution.height.unwrap_or(0) as u64;
a_frags.cmp(&b_frags).then_with(|| a_area.cmp(&b_area))
})
}
pub fn worst_storyboard_format(&self) -> Option<&Format> {
self.formats
.iter()
.filter(|f| f.format_type() == FormatType::Storyboard)
.min_by(|a, b| {
let a_frags = a.storyboard_info.fragments.as_ref().map_or(0, Vec::len);
let b_frags = b.storyboard_info.fragments.as_ref().map_or(0, Vec::len);
let a_area =
a.video_resolution.width.unwrap_or(0) as u64 * a.video_resolution.height.unwrap_or(0) as u64;
let b_area =
b.video_resolution.width.unwrap_or(0) as u64 * b.video_resolution.height.unwrap_or(0) as u64;
a_frags.cmp(&b_frags).then_with(|| a_area.cmp(&b_area))
})
}
pub fn best_audio_video_format(&self) -> Result<&Format, crate::error::Error> {
self.formats
.iter()
.find(|f| f.format_type().is_audio_and_video())
.ok_or_else(|| crate::error::Error::FormatNotAvailable {
video_id: self.id.clone(),
format_type: FormatType::AudioVideo,
available_formats: self.formats.iter().map(|f| f.format_id.clone()).collect(),
})
}
#[cfg(any(feature = "live-recording", feature = "live-streaming"))]
pub fn is_currently_live(&self) -> bool {
const STATUS: &str = "is_live";
self.is_live == Some(true) || self.live_status == STATUS
}
#[cfg(any(feature = "live-recording", feature = "live-streaming"))]
pub fn is_upcoming(&self) -> bool {
const STATUS: &str = "is_upcoming";
self.live_status == STATUS
}
#[cfg(any(feature = "live-recording", feature = "live-streaming"))]
pub fn live_formats(&self) -> Vec<&Format> {
let mut formats: Vec<&Format> = self
.formats
.iter()
.filter(|f| f.protocol == Protocol::M3U8Native)
.collect();
formats.sort_by(|a, b| {
a.rates_info
.total_rate
.partial_cmp(&b.rates_info.total_rate)
.unwrap_or(std::cmp::Ordering::Equal)
});
formats
}
}
impl fmt::Display for Video {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"Video(id={}, title={:?}, channel={:?}, formats={})",
self.id,
self.title,
self.channel.as_deref().unwrap_or("Unknown"),
self.formats.len()
)
}
}
impl fmt::Display for ExtractorInfo {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"ExtractorInfo(extractor={}, key={})",
self.extractor, self.extractor_key
)
}
}
impl fmt::Display for Version {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Version(version={}, repository={})", self.version, self.repository)
}
}
impl Eq for Video {}
impl Eq for Version {}
impl Eq for ExtractorInfo {}
impl std::hash::Hash for Video {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.id.hash(state);
self.title.hash(state);
self.channel.hash(state);
self.channel_id.hash(state);
}
}
impl std::hash::Hash for Version {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.version.hash(state);
self.repository.hash(state);
}
}
impl std::hash::Hash for ExtractorInfo {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.extractor.hash(state);
self.extractor_key.hash(state);
}
}