Skip to main content

ferrex_model/
files.rs

1use crate::{
2    MediaID,
3    error::{ModelError as MediaError, Result},
4};
5use std::fmt;
6use std::path::PathBuf;
7use uuid::Uuid;
8
9use super::LibraryId;
10use crate::chrono::{DateTime, Utc};
11
12#[derive(Clone, PartialEq)]
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14#[cfg_attr(
15    feature = "rkyv",
16    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
17)]
18#[cfg_attr(feature = "rkyv", rkyv(derive(Debug, PartialEq, Eq)))]
19pub struct MediaFile {
20    pub id: Uuid,
21    pub media_id: MediaID,
22    #[cfg_attr(feature = "rkyv", rkyv(with = crate::rkyv_wrappers::PathBufWrapper))]
23    pub path: PathBuf,
24    pub filename: String,
25    pub size: u64,
26    #[cfg_attr(feature = "rkyv", rkyv(with = crate::rkyv_wrappers::DateTimeWrapper))]
27    pub discovered_at: DateTime<Utc>,
28    #[cfg_attr(feature = "rkyv", rkyv(with = crate::rkyv_wrappers::DateTimeWrapper))]
29    pub created_at: DateTime<Utc>,
30    pub media_file_metadata: Option<MediaFileMetadata>,
31    pub library_id: LibraryId,
32}
33
34#[derive(Clone, PartialEq)]
35#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
36#[cfg_attr(
37    feature = "rkyv",
38    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
39)]
40#[cfg_attr(feature = "rkyv", rkyv(derive(Debug, PartialEq, Eq)))]
41pub struct MediaFileMetadata {
42    // Technical metadata from FFmpeg
43    pub duration: Option<f64>,
44    pub width: Option<u32>,
45    pub height: Option<u32>,
46    pub video_codec: Option<String>,
47    pub audio_codec: Option<String>,
48    pub bitrate: Option<u64>,
49    pub framerate: Option<f64>,
50    pub file_size: u64,
51
52    // HDR metadata
53    pub color_primaries: Option<String>,
54    pub color_transfer: Option<String>,
55    pub color_space: Option<String>,
56    pub bit_depth: Option<u32>,
57
58    // Parsed from filename
59    pub parsed_info: Option<ParsedMediaInfo>,
60}
61
62impl fmt::Debug for MediaFile {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        f.debug_struct("MediaFile")
65            .field("id", &self.id)
66            .field("filename", &self.filename)
67            .field("path", &self.path)
68            .field("size", &self.size)
69            .field("discovered_at", &self.discovered_at)
70            .field("created_at", &self.created_at)
71            .field("has_metadata", &self.media_file_metadata.is_some())
72            .field("metadata", &self.media_file_metadata.as_ref())
73            .field("library_id", &self.library_id)
74            .finish()
75    }
76}
77
78impl fmt::Debug for MediaFileMetadata {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        let resolution = self.width.zip(self.height);
81        let parsed_kind = self.parsed_info.as_ref().map(|info| match info {
82            ParsedMediaInfo::Movie(_) => "Movie",
83            ParsedMediaInfo::Episode(_) => "Episode",
84        });
85
86        f.debug_struct("MediaFileMetadata")
87            .field("duration", &self.duration)
88            .field("resolution", &resolution)
89            .field("video_codec", &self.video_codec)
90            .field("audio_codec", &self.audio_codec)
91            .field("bitrate", &self.bitrate)
92            .field("framerate", &self.framerate)
93            .field("file_size", &self.file_size)
94            .field(
95                "hdr",
96                &(
97                    &self.color_primaries,
98                    &self.color_transfer,
99                    &self.color_space,
100                    &self.bit_depth,
101                ),
102            )
103            .field("parsed_info_kind", &parsed_kind)
104            .finish()
105    }
106}
107#[derive(Debug, Clone, PartialEq)]
108#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
109#[cfg_attr(
110    feature = "rkyv",
111    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
112)]
113#[cfg_attr(feature = "rkyv", rkyv(derive(Debug, PartialEq, Eq)))]
114pub enum ParsedMediaInfo {
115    Movie(ParsedMovieInfo),
116    Episode(ParsedEpisodeInfo),
117}
118
119#[derive(Debug, Clone, PartialEq)]
120#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
121#[cfg_attr(
122    feature = "rkyv",
123    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
124)]
125#[cfg_attr(feature = "rkyv", rkyv(derive(Debug, PartialEq, Eq)))]
126pub struct ParsedMovieInfo {
127    pub title: String,
128    pub year: Option<u16>,
129    pub resolution: Option<String>,
130    pub source: Option<String>,
131    pub release_group: Option<String>,
132}
133
134#[derive(Debug, Clone, PartialEq)]
135#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
136#[cfg_attr(
137    feature = "rkyv",
138    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
139)]
140#[cfg_attr(feature = "rkyv", rkyv(derive(Debug, PartialEq, Eq)))]
141pub struct ParsedEpisodeInfo {
142    pub show_name: String,
143    pub season: u16,
144    pub episode: u16,
145    pub episode_title: Option<String>,
146    pub year: Option<u16>,
147    pub resolution: Option<String>,
148    pub source: Option<String>,
149    pub release_group: Option<String>,
150}
151
152#[derive(Debug, Clone, PartialEq)]
153#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
154#[cfg_attr(
155    feature = "rkyv",
156    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
157)]
158#[cfg_attr(feature = "serde", serde(rename_all = "PascalCase"))]
159#[cfg_attr(feature = "rkyv", rkyv(derive(Debug, PartialEq, Eq)))]
160pub enum ExtraType {
161    BehindTheScenes,
162    DeletedScenes,
163    Featurette,
164    Interview,
165    Scene,
166    Short,
167    Trailer,
168    Other,
169}
170
171impl std::fmt::Display for ExtraType {
172    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173        match self {
174            ExtraType::BehindTheScenes => write!(f, "Behind the Scenes"),
175            ExtraType::DeletedScenes => write!(f, "Deleted Scenes"),
176            ExtraType::Featurette => write!(f, "Featurette"),
177            ExtraType::Interview => write!(f, "Interview"),
178            ExtraType::Scene => write!(f, "Scene"),
179            ExtraType::Short => write!(f, "Short"),
180            ExtraType::Trailer => write!(f, "Trailer"),
181            ExtraType::Other => write!(f, "Other"),
182        }
183    }
184}
185
186impl MediaFile {
187    pub fn new(
188        media_id: MediaID,
189        path: PathBuf,
190        library_id: LibraryId,
191    ) -> Result<Self> {
192        Self::new_with_policy(media_id, path, library_id, false)
193    }
194
195    pub fn new_with_policy(
196        media_id: MediaID,
197        path: PathBuf,
198        library_id: LibraryId,
199        allow_zero_length: bool,
200    ) -> Result<Self> {
201        let filename = path
202            .file_name()
203            .ok_or_else(|| {
204                MediaError::InvalidMedia("Invalid file path".to_string())
205            })?
206            .to_string_lossy()
207            .to_string();
208
209        let metadata = path.metadata().map_err(MediaError::Io)?;
210
211        // Get actual file creation time from filesystem metadata
212        let created_at = metadata
213            .created()
214            .ok()
215            .and_then(|time| {
216                // Convert SystemTime to chrono DateTime
217                let duration =
218                    time.duration_since(std::time::UNIX_EPOCH).ok()?;
219                DateTime::<Utc>::from_timestamp(
220                    duration.as_secs() as i64,
221                    duration.subsec_nanos(),
222                )
223            })
224            .unwrap_or_else(|| {
225                // Fallback to modified time if creation time is not available
226                metadata
227                    .modified()
228                    .ok()
229                    .and_then(|time| {
230                        let duration =
231                            time.duration_since(std::time::UNIX_EPOCH).ok()?;
232                        DateTime::<Utc>::from_timestamp(
233                            duration.as_secs() as i64,
234                            duration.subsec_nanos(),
235                        )
236                    })
237                    .unwrap_or_else(Utc::now)
238            });
239
240        let size = metadata.len();
241
242        if size == 0 && !allow_zero_length {
243            return Err(MediaError::InvalidMedia(
244                "Zero-length media files are not supported".to_string(),
245            ));
246        }
247
248        Ok(Self {
249            id: Uuid::now_v7(),
250            media_id,
251            path,
252            filename,
253            size,
254            // discovered_at represents when we discovered the file in the library (row creation time)
255            // DB provides a default NOW(); set it here for in-memory consistency
256            discovered_at: Utc::now(),
257            created_at,
258            media_file_metadata: None,
259            library_id,
260        })
261    }
262
263    pub fn is_video_file(&self) -> bool {
264        let video_extensions =
265            ["mp4", "mkv", "avi", "mov", "webm", "flv", "wmv"];
266
267        if let Some(extension) = self.path.extension()
268            && let Some(ext_str) = extension.to_str()
269        {
270            return video_extensions.contains(&ext_str.to_lowercase().as_str());
271        }
272
273        false
274    }
275}