Skip to main content

nzb_core/
models.rs

1use std::path::PathBuf;
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6// ---------------------------------------------------------------------------
7// Job status lifecycle
8// ---------------------------------------------------------------------------
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum JobStatus {
13    Queued,
14    Downloading,
15    Paused,
16    Verifying,
17    Repairing,
18    Extracting,
19    PostProcessing,
20    Completed,
21    Failed,
22}
23
24impl std::fmt::Display for JobStatus {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        match self {
27            Self::Queued => write!(f, "Queued"),
28            Self::Downloading => write!(f, "Downloading"),
29            Self::Paused => write!(f, "Paused"),
30            Self::Verifying => write!(f, "Verifying"),
31            Self::Repairing => write!(f, "Repairing"),
32            Self::Extracting => write!(f, "Extracting"),
33            Self::PostProcessing => write!(f, "PostProcessing"),
34            Self::Completed => write!(f, "Completed"),
35            Self::Failed => write!(f, "Failed"),
36        }
37    }
38}
39
40// ---------------------------------------------------------------------------
41// Priority
42// ---------------------------------------------------------------------------
43
44#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum Priority {
47    Low = 0,
48    #[default]
49    Normal = 1,
50    High = 2,
51    Force = 3,
52}
53
54// ---------------------------------------------------------------------------
55// NZB data model
56// ---------------------------------------------------------------------------
57
58/// Per-server article download statistics for a job.
59#[derive(Debug, Clone, Default, Serialize, Deserialize)]
60pub struct ServerArticleStats {
61    pub server_id: String,
62    pub server_name: String,
63    pub articles_downloaded: usize,
64    pub articles_failed: usize,
65    pub bytes_downloaded: u64,
66}
67
68/// A complete download job (parsed from one NZB file).
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct NzbJob {
71    /// Unique job identifier
72    pub id: String,
73    /// Human-readable name (from NZB filename or metadata)
74    pub name: String,
75    /// Category for this download
76    pub category: String,
77    /// Current status
78    pub status: JobStatus,
79    /// Download priority
80    pub priority: Priority,
81    /// Total size in bytes (sum of all articles)
82    pub total_bytes: u64,
83    /// Bytes downloaded so far
84    pub downloaded_bytes: u64,
85    /// Number of files in this job
86    pub file_count: usize,
87    /// Number of files completed
88    pub files_completed: usize,
89    /// Number of articles total
90    pub article_count: usize,
91    /// Number of articles downloaded
92    pub articles_downloaded: usize,
93    /// Number of articles failed
94    pub articles_failed: usize,
95    /// When the job was added
96    pub added_at: DateTime<Utc>,
97    /// When the job completed (if applicable)
98    pub completed_at: Option<DateTime<Utc>>,
99    /// Working directory for this job (incomplete)
100    pub work_dir: PathBuf,
101    /// Final output directory
102    pub output_dir: PathBuf,
103    /// Optional password for extraction
104    pub password: Option<String>,
105    /// Error message if failed
106    pub error_message: Option<String>,
107    /// Current download speed for this job (bytes/sec)
108    #[serde(default)]
109    pub speed_bps: u64,
110    /// Per-server download statistics
111    #[serde(default)]
112    pub server_stats: Vec<ServerArticleStats>,
113    /// Files in this job
114    #[serde(skip)]
115    pub files: Vec<NzbFile>,
116}
117
118/// A single file within an NZB job (collection of NNTP articles).
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct NzbFile {
121    /// Unique file identifier
122    pub id: String,
123    /// Filename (from yEnc header or NZB subject)
124    pub filename: String,
125    /// Total size in bytes
126    pub bytes: u64,
127    /// Bytes downloaded
128    pub bytes_downloaded: u64,
129    /// Is this a par2 file?
130    pub is_par2: bool,
131    /// Par2 set name (if par2)
132    pub par2_setname: Option<String>,
133    /// Par2 volume number (if par2)
134    pub par2_vol: Option<u32>,
135    /// Par2 block count (if par2)
136    pub par2_blocks: Option<u32>,
137    /// File assembly complete
138    pub assembled: bool,
139    /// Newsgroup(s) this file was posted to
140    pub groups: Vec<String>,
141    /// Article segments
142    #[serde(skip)]
143    pub articles: Vec<Article>,
144}
145
146/// A single NNTP article (segment of a file) — re-exported from the `nzb-nntp` crate.
147pub use nzb_nntp::Article;
148
149// ---------------------------------------------------------------------------
150// History record (for completed/failed jobs)
151// ---------------------------------------------------------------------------
152
153/// A history entry for a completed or failed job.
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct HistoryEntry {
156    pub id: String,
157    pub name: String,
158    pub category: String,
159    pub status: JobStatus,
160    pub total_bytes: u64,
161    pub downloaded_bytes: u64,
162    pub added_at: DateTime<Utc>,
163    pub completed_at: DateTime<Utc>,
164    pub output_dir: PathBuf,
165    /// Post-processing stages with results
166    pub stages: Vec<StageResult>,
167    pub error_message: Option<String>,
168    /// Per-server download statistics
169    #[serde(default)]
170    pub server_stats: Vec<ServerArticleStats>,
171    /// Raw NZB XML data (for retry)
172    #[serde(skip_serializing)]
173    pub nzb_data: Option<Vec<u8>>,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct StageResult {
178    pub name: String,
179    pub status: StageStatus,
180    pub message: Option<String>,
181    pub duration_secs: f64,
182}
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
185#[serde(rename_all = "snake_case")]
186pub enum StageStatus {
187    Success,
188    Failed,
189    Skipped,
190}
191
192// ---------------------------------------------------------------------------
193// RSS feed items and download rules
194// ---------------------------------------------------------------------------
195
196/// A discovered item from an RSS feed, persisted in the database.
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct RssItem {
199    /// Feed entry ID (from the RSS feed)
200    pub id: String,
201    /// Name of the feed this came from
202    pub feed_name: String,
203    /// Title of the entry
204    pub title: String,
205    /// NZB download URL
206    pub url: Option<String>,
207    /// When the entry was published (from feed)
208    pub published_at: Option<DateTime<Utc>>,
209    /// When we first saw this item
210    pub first_seen_at: DateTime<Utc>,
211    /// Whether this item has been downloaded
212    pub downloaded: bool,
213    /// When it was downloaded (if applicable)
214    pub downloaded_at: Option<DateTime<Utc>>,
215    /// Category used when downloaded
216    pub category: Option<String>,
217    /// Size in bytes (if available from feed)
218    pub size_bytes: u64,
219}
220
221/// A download rule that automatically enqueues matching RSS feed items.
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct RssRule {
224    /// Unique rule identifier
225    pub id: String,
226    /// Human-readable name for the rule
227    pub name: String,
228    /// Which feed(s) this rule applies to (one or more feed names)
229    pub feed_names: Vec<String>,
230    /// Category to assign to downloaded NZBs
231    pub category: Option<String>,
232    /// Download priority (0=low, 1=normal, 2=high, 3=force)
233    pub priority: i32,
234    /// Regex to match against feed item titles (applied to pre-filtered items)
235    pub match_regex: String,
236    /// Whether this rule is active
237    pub enabled: bool,
238}
239
240// ---------------------------------------------------------------------------
241// Newsgroup browsing
242// ---------------------------------------------------------------------------
243
244#[cfg(feature = "groups-db")]
245#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct GroupRow {
247    pub id: i64,
248    pub name: String,
249    pub description: Option<String>,
250    pub subscribed: bool,
251    pub article_count: i64,
252    pub first_article: i64,
253    pub last_article: i64,
254    pub last_scanned: i64,
255    pub last_updated: Option<String>,
256    pub created_at: String,
257    #[serde(default)]
258    pub unread_count: i64,
259}
260
261#[cfg(feature = "groups-db")]
262#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct HeaderRow {
264    pub id: i64,
265    pub group_id: i64,
266    pub article_num: i64,
267    pub subject: String,
268    pub author: String,
269    pub date: String,
270    pub message_id: String,
271    pub references_: String,
272    pub bytes: i64,
273    pub lines: i64,
274    pub read: bool,
275    pub downloaded_at: String,
276}
277
278#[cfg(feature = "groups-db")]
279#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct ThreadSummary {
281    pub root_message_id: String,
282    pub subject: String,
283    pub author: String,
284    pub date: String,
285    pub last_reply_date: String,
286    pub reply_count: i64,
287    pub unread_count: i64,
288}
289
290#[cfg(feature = "groups-db")]
291#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct ThreadArticle {
293    #[serde(flatten)]
294    pub header: HeaderRow,
295    pub depth: i32,
296}
297
298#[cfg(feature = "groups-db")]
299#[derive(Debug, Clone, Deserialize)]
300pub struct MarkReadInput {
301    pub header_ids: Vec<i64>,
302}
303
304#[cfg(feature = "groups-db")]
305#[derive(Debug, Clone, Deserialize)]
306pub struct DownloadSelectedInput {
307    pub message_ids: Vec<String>,
308    pub name: Option<String>,
309    pub category: Option<String>,
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    fn test_job_status_display() {
318        assert_eq!(JobStatus::Queued.to_string(), "Queued");
319        assert_eq!(JobStatus::Downloading.to_string(), "Downloading");
320        assert_eq!(JobStatus::Paused.to_string(), "Paused");
321        assert_eq!(JobStatus::Verifying.to_string(), "Verifying");
322        assert_eq!(JobStatus::Repairing.to_string(), "Repairing");
323        assert_eq!(JobStatus::Extracting.to_string(), "Extracting");
324        assert_eq!(JobStatus::PostProcessing.to_string(), "PostProcessing");
325        assert_eq!(JobStatus::Completed.to_string(), "Completed");
326        assert_eq!(JobStatus::Failed.to_string(), "Failed");
327    }
328
329    #[test]
330    fn test_job_status_serde_roundtrip() {
331        let statuses = [
332            JobStatus::Queued,
333            JobStatus::Downloading,
334            JobStatus::Paused,
335            JobStatus::Completed,
336            JobStatus::Failed,
337        ];
338
339        for status in &statuses {
340            let json = serde_json::to_string(status).unwrap();
341            let restored: JobStatus = serde_json::from_str(&json).unwrap();
342            assert_eq!(*status, restored);
343        }
344    }
345
346    #[test]
347    fn test_job_status_serde_snake_case() {
348        let json = serde_json::to_string(&JobStatus::PostProcessing).unwrap();
349        assert_eq!(json, "\"post_processing\"");
350
351        let restored: JobStatus = serde_json::from_str("\"post_processing\"").unwrap();
352        assert_eq!(restored, JobStatus::PostProcessing);
353    }
354
355    #[test]
356    fn test_priority_default() {
357        let p = Priority::default();
358        assert_eq!(p, Priority::Normal);
359    }
360
361    #[test]
362    fn test_priority_ordering() {
363        assert!(Priority::Low < Priority::Normal);
364        assert!(Priority::Normal < Priority::High);
365        assert!(Priority::High < Priority::Force);
366    }
367
368    #[test]
369    fn test_priority_values() {
370        assert_eq!(Priority::Low as i32, 0);
371        assert_eq!(Priority::Normal as i32, 1);
372        assert_eq!(Priority::High as i32, 2);
373        assert_eq!(Priority::Force as i32, 3);
374    }
375
376    #[test]
377    fn test_priority_serde_roundtrip() {
378        for p in [
379            Priority::Low,
380            Priority::Normal,
381            Priority::High,
382            Priority::Force,
383        ] {
384            let json = serde_json::to_string(&p).unwrap();
385            let restored: Priority = serde_json::from_str(&json).unwrap();
386            assert_eq!(p, restored);
387        }
388    }
389
390    #[test]
391    fn test_stage_status_serde() {
392        let statuses = [
393            StageStatus::Success,
394            StageStatus::Failed,
395            StageStatus::Skipped,
396        ];
397        for s in &statuses {
398            let json = serde_json::to_string(s).unwrap();
399            let restored: StageStatus = serde_json::from_str(&json).unwrap();
400            assert_eq!(*s, restored);
401        }
402    }
403
404    #[test]
405    fn test_stage_status_snake_case() {
406        assert_eq!(
407            serde_json::to_string(&StageStatus::Success).unwrap(),
408            "\"success\""
409        );
410        assert_eq!(
411            serde_json::to_string(&StageStatus::Failed).unwrap(),
412            "\"failed\""
413        );
414        assert_eq!(
415            serde_json::to_string(&StageStatus::Skipped).unwrap(),
416            "\"skipped\""
417        );
418    }
419
420    #[test]
421    fn test_stage_result_serde() {
422        let sr = StageResult {
423            name: "Verify".into(),
424            status: StageStatus::Success,
425            message: Some("OK".into()),
426            duration_secs: 2.5,
427        };
428        let json = serde_json::to_string(&sr).unwrap();
429        let restored: StageResult = serde_json::from_str(&json).unwrap();
430        assert_eq!(restored.name, "Verify");
431        assert_eq!(restored.status, StageStatus::Success);
432        assert_eq!(restored.message.as_deref(), Some("OK"));
433        assert!((restored.duration_secs - 2.5).abs() < 0.001);
434    }
435
436    #[test]
437    fn test_server_article_stats_default() {
438        let stats = ServerArticleStats::default();
439        assert!(stats.server_id.is_empty());
440        assert_eq!(stats.articles_downloaded, 0);
441        assert_eq!(stats.articles_failed, 0);
442        assert_eq!(stats.bytes_downloaded, 0);
443    }
444
445    #[test]
446    fn test_server_article_stats_serde() {
447        let stats = ServerArticleStats {
448            server_id: "srv-1".into(),
449            server_name: "Provider".into(),
450            articles_downloaded: 100,
451            articles_failed: 5,
452            bytes_downloaded: 75_000_000,
453        };
454        let json = serde_json::to_string(&stats).unwrap();
455        let restored: ServerArticleStats = serde_json::from_str(&json).unwrap();
456        assert_eq!(restored.server_id, "srv-1");
457        assert_eq!(restored.articles_downloaded, 100);
458        assert_eq!(restored.articles_failed, 5);
459        assert_eq!(restored.bytes_downloaded, 75_000_000);
460    }
461}