use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
pub const VIDEO_EXTENSIONS: &[&str] = &[
"mkv", "mp4", "avi", "mov", "wmv", "flv", "webm", "m4v", "ts", "m2ts",
];
#[derive(Debug, Clone, Serialize)]
pub enum MediaEntry {
Movie(Movie),
Show(Show),
}
impl MediaEntry {
pub fn title(&self) -> &str {
match self {
MediaEntry::Movie(m) => &m.title,
MediaEntry::Show(s) => &s.title,
}
}
pub fn base_dir(&self) -> &PathBuf {
match self {
MediaEntry::Movie(m) => &m.base_dir,
MediaEntry::Show(s) => &s.base_dir,
}
}
pub fn latest_video_mtime(&self) -> Option<DateTime<Utc>> {
match self {
MediaEntry::Movie(m) => m.video_mtime,
MediaEntry::Show(s) => s
.seasons
.iter()
.flat_map(|se| se.episodes.iter())
.filter_map(|ep| ep.video_mtime)
.max(),
}
}
pub fn metadata(&self) -> &MediaMetadata {
match self {
MediaEntry::Movie(m) => &m.metadata,
MediaEntry::Show(s) => &s.metadata,
}
}
pub fn poster_cache_path(&self) -> &PathBuf {
match self {
MediaEntry::Movie(m) => &m.poster_path,
MediaEntry::Show(s) => &s.poster_path,
}
}
pub fn has_subtitles(&self) -> bool {
match self {
MediaEntry::Movie(m) => !m.subtitles.is_empty() || !m.external_subs.is_empty(),
MediaEntry::Show(s) => s
.all_episodes()
.any(|ep| !ep.subtitles.is_empty() || !ep.external_subs.is_empty()),
}
}
pub fn comments_path(&self) -> std::path::PathBuf {
match self {
MediaEntry::Movie(m) => {
let stem = m
.video_path
.file_stem()
.unwrap_or_default()
.to_string_lossy();
let dir = m.video_path.parent().unwrap_or(&m.base_dir);
dir.join(format!("{stem}.media.comments.md"))
}
MediaEntry::Show(s) => s.base_dir.join("media.comments.md"),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Movie {
pub title: String,
pub base_dir: PathBuf,
pub video_path: PathBuf,
pub video_mtime: Option<DateTime<Utc>>,
pub state: MovieState,
pub poster_path: PathBuf,
pub metadata: MediaMetadata,
pub subtitles: Vec<SubtitleTrack>,
pub external_subs: Vec<ExternalSubtitle>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MovieState {
pub watched: bool,
#[serde(default)]
pub watch_history: Vec<WatchEvent>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WatchEvent {
pub watched_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Show {
pub title: String,
pub base_dir: PathBuf,
pub seasons: Vec<Season>,
pub bookmarks: ShowBookmarks,
pub poster_path: PathBuf,
pub metadata: MediaMetadata,
}
impl Show {
pub fn all_episodes(&self) -> impl Iterator<Item = &Episode> {
self.seasons.iter().flat_map(|s| s.episodes.iter())
}
pub fn episode_count(&self) -> usize {
self.seasons.iter().map(|s| s.episodes.len()).sum()
}
pub fn watched_count(&self) -> usize {
self.all_episodes()
.filter(|ep| self.bookmarks.is_watched(&ep.relative_path))
.count()
}
pub fn is_fully_watched(&self) -> bool {
self.watched_count() == self.episode_count()
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Season {
pub label: String,
pub episodes: Vec<Episode>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Episode {
pub title: String,
pub season_num: u32,
pub episode_num: u32,
pub episode_title: Option<String>,
pub video_path: PathBuf,
pub video_mtime: Option<DateTime<Utc>>,
pub relative_path: String,
pub subtitles: Vec<SubtitleTrack>,
pub external_subs: Vec<ExternalSubtitle>,
}
impl Episode {
pub fn display_label(&self) -> String {
if self.episode_num > 0 {
let code = format!("S{:02}E{:02}", self.season_num, self.episode_num);
match &self.episode_title {
Some(t) if !t.is_empty() => format!("{code} {t}"),
_ => code,
}
} else {
self.title.clone()
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ShowBookmarks {
#[serde(default)]
pub watched_episodes: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_up: Option<String>,
}
impl ShowBookmarks {
pub fn is_watched(&self, relative_path: &str) -> bool {
self.watched_episodes.iter().any(|p| p == relative_path)
}
pub fn mark_watched(&mut self, relative_path: &str, following: Option<&str>) {
if !self.is_watched(relative_path) {
self.watched_episodes.push(relative_path.to_string());
}
let should_advance = self
.next_up
.as_deref()
.map(|n| n == relative_path)
.unwrap_or(true);
if should_advance {
self.next_up = following.map(str::to_string);
}
}
pub fn mark_unwatched(&mut self, relative_path: &str) {
self.watched_episodes.retain(|p| p != relative_path);
}
}
#[derive(Debug, Clone, Serialize)]
pub struct SubtitleTrack {
pub track_number: u64,
pub language: Option<String>,
pub codec_id: String,
pub name: Option<String>,
pub default: bool,
pub forced: bool,
}
impl SubtitleTrack {
pub fn display_label(&self) -> String {
let lang = self
.language
.as_deref()
.unwrap_or("und")
.to_uppercase();
match &self.name {
Some(n) if !n.is_empty() => {
let mut label = format!("{lang} — {n}");
if self.forced {
label.push_str(" [forced]");
}
label
}
_ => {
let codec_short = self
.codec_id
.strip_prefix("S_TEXT/")
.or_else(|| self.codec_id.strip_prefix("S_HDMV/"))
.or_else(|| self.codec_id.strip_prefix("S_"))
.unwrap_or(&self.codec_id);
let mut label = format!("{lang} ({codec_short})");
if self.forced {
label.push_str(" [forced]");
}
label
}
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ExternalSubtitle {
pub filename: String,
pub language: Option<String>,
pub format: String,
}
impl ExternalSubtitle {
pub fn display_label(&self) -> String {
match &self.language {
Some(lang) => format!("{} ({})", lang.to_uppercase(), self.format),
None => self.format.to_uppercase(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct Comments {
pub markdown: String,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct MediaMetadata {
pub clean_title: String,
pub year: Option<u32>,
pub resolution: Option<String>,
pub source: Option<String>,
pub hdr: Option<String>,
pub codec: Option<String>,
pub season: Option<(u32, String)>,
}
impl MediaMetadata {
pub fn tags(&self) -> Vec<String> {
let mut tags = Vec::new();
if let Some((_n, label)) = &self.season {
tags.push(label.clone());
}
if let Some(y) = self.year {
tags.push(y.to_string());
}
if let Some(r) = &self.resolution {
tags.push(r.clone());
}
if let Some(s) = &self.source {
tags.push(s.clone());
}
if let Some(h) = &self.hdr {
tags.push(h.clone());
}
if let Some(c) = &self.codec {
tags.push(c.clone());
}
tags
}
}