1use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::path::PathBuf;
6use std::time::Duration;
7use utoipa::ToSchema;
8
9use crate::config::{DuplicateMethod, PostProcess};
10
11#[derive(
13 Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, ToSchema,
14)]
15#[serde(transparent)]
16pub struct DownloadId(pub i64);
17
18impl DownloadId {
19 pub fn new(id: i64) -> Self {
21 Self(id)
22 }
23
24 pub fn get(&self) -> i64 {
26 self.0
27 }
28}
29
30impl From<i64> for DownloadId {
31 fn from(id: i64) -> Self {
32 Self(id)
33 }
34}
35
36impl From<DownloadId> for i64 {
37 fn from(id: DownloadId) -> Self {
38 id.0
39 }
40}
41
42impl PartialEq<i64> for DownloadId {
43 fn eq(&self, other: &i64) -> bool {
44 self.0 == *other
45 }
46}
47
48impl PartialEq<DownloadId> for i64 {
49 fn eq(&self, other: &DownloadId) -> bool {
50 *self == other.0
51 }
52}
53
54impl std::fmt::Display for DownloadId {
55 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56 write!(f, "{}", self.0)
57 }
58}
59
60impl std::str::FromStr for DownloadId {
61 type Err = std::num::ParseIntError;
62
63 fn from_str(s: &str) -> Result<Self, Self::Err> {
64 Ok(Self(s.parse()?))
65 }
66}
67
68impl sqlx::Type<sqlx::Sqlite> for DownloadId {
70 fn type_info() -> sqlx::sqlite::SqliteTypeInfo {
71 <i64 as sqlx::Type<sqlx::Sqlite>>::type_info()
72 }
73
74 fn compatible(ty: &sqlx::sqlite::SqliteTypeInfo) -> bool {
75 <i64 as sqlx::Type<sqlx::Sqlite>>::compatible(ty)
76 }
77}
78
79impl<'q> sqlx::Encode<'q, sqlx::Sqlite> for DownloadId {
80 fn encode_by_ref(
81 &self,
82 buf: &mut Vec<sqlx::sqlite::SqliteArgumentValue<'q>>,
83 ) -> Result<sqlx::encode::IsNull, Box<dyn std::error::Error + Send + Sync>> {
84 sqlx::Encode::<sqlx::Sqlite>::encode_by_ref(&self.0, buf)
85 }
86}
87
88impl<'r> sqlx::Decode<'r, sqlx::Sqlite> for DownloadId {
89 fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result<Self, sqlx::error::BoxDynError> {
90 let id = <i64 as sqlx::Decode<sqlx::Sqlite>>::decode(value)?;
91 Ok(Self(id))
92 }
93}
94
95#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
97#[serde(rename_all = "lowercase")]
98pub enum Status {
99 Queued,
101 Downloading,
103 Paused,
105 Processing,
107 Complete,
109 Failed,
111}
112
113impl Status {
114 pub fn from_i32(status: i32) -> Self {
116 match status {
117 0 => Status::Queued,
118 1 => Status::Downloading,
119 2 => Status::Paused,
120 3 => Status::Processing,
121 4 => Status::Complete,
122 5 => Status::Failed,
123 _ => Status::Failed, }
125 }
126
127 pub fn to_i32(&self) -> i32 {
129 match self {
130 Status::Queued => 0,
131 Status::Downloading => 1,
132 Status::Paused => 2,
133 Status::Processing => 3,
134 Status::Complete => 4,
135 Status::Failed => 5,
136 }
137 }
138}
139
140#[derive(
142 Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, ToSchema,
143)]
144#[serde(rename_all = "lowercase")]
145pub enum Priority {
146 Low = -1,
148 #[default]
150 Normal = 0,
151 High = 1,
153 Force = 2,
155}
156
157impl Priority {
158 pub fn from_i32(priority: i32) -> Self {
160 match priority {
161 -1 => Priority::Low,
162 0 => Priority::Normal,
163 1 => Priority::High,
164 2 => Priority::Force,
165 _ => Priority::Normal, }
167 }
168}
169
170#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
172#[serde(rename_all = "lowercase")]
173pub enum Stage {
174 Download,
176 Verify,
178 Repair,
180 Extract,
182 Move,
184 Cleanup,
186 DirectUnpack,
188}
189
190#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
192#[serde(rename_all = "lowercase")]
193pub enum ArchiveType {
194 Rar,
196 SevenZip,
198 Zip,
200}
201
202#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
204#[serde(tag = "type", rename_all = "snake_case")]
205pub enum Event {
206 Queued {
208 id: DownloadId,
210 name: String,
212 },
213
214 Removed {
216 id: DownloadId,
218 },
219
220 Downloading {
222 id: DownloadId,
224 percent: f32,
226 speed_bps: u64,
228 #[serde(skip_serializing_if = "Option::is_none")]
230 failed_articles: Option<u64>,
231 #[serde(skip_serializing_if = "Option::is_none")]
233 total_articles: Option<u64>,
234 #[serde(skip_serializing_if = "Option::is_none")]
236 health_percent: Option<f32>,
237 },
238
239 DownloadComplete {
241 id: DownloadId,
243 #[serde(skip_serializing_if = "Option::is_none")]
245 articles_failed: Option<u64>,
246 #[serde(skip_serializing_if = "Option::is_none")]
248 articles_total: Option<u64>,
249 },
250
251 DownloadFailed {
253 id: DownloadId,
255 error: String,
257 #[serde(skip_serializing_if = "Option::is_none")]
259 articles_succeeded: Option<u64>,
260 #[serde(skip_serializing_if = "Option::is_none")]
262 articles_failed: Option<u64>,
263 #[serde(skip_serializing_if = "Option::is_none")]
265 articles_total: Option<u64>,
266 },
267
268 Verifying {
270 id: DownloadId,
272 },
273
274 VerifyComplete {
276 id: DownloadId,
278 damaged: bool,
280 },
281
282 Repairing {
284 id: DownloadId,
286 blocks_needed: u32,
288 blocks_available: u32,
290 },
291
292 RepairComplete {
294 id: DownloadId,
296 success: bool,
298 },
299
300 RepairSkipped {
302 id: DownloadId,
304 reason: String,
306 },
307
308 Extracting {
310 id: DownloadId,
312 archive: String,
314 percent: f32,
316 },
317
318 ExtractComplete {
320 id: DownloadId,
322 },
323
324 Moving {
326 id: DownloadId,
328 destination: PathBuf,
330 },
331
332 Cleaning {
334 id: DownloadId,
336 },
337
338 Complete {
340 id: DownloadId,
342 path: PathBuf,
344 },
345
346 Failed {
348 id: DownloadId,
350 stage: Stage,
352 error: String,
354 files_kept: bool,
356 },
357
358 SpeedLimitChanged {
360 limit_bps: Option<u64>,
362 },
363
364 QueuePaused,
366
367 QueueResumed,
369
370 WebhookFailed {
372 url: String,
374 error: String,
376 },
377
378 ScriptFailed {
380 script: PathBuf,
382 exit_code: Option<i32>,
384 },
385
386 DuplicateDetected {
388 id: DownloadId,
390 name: String,
392 method: DuplicateMethod,
394 existing_name: String,
396 },
397
398 DirectUnpackStarted {
400 id: DownloadId,
402 },
403
404 FileCompleted {
406 id: DownloadId,
408 file_index: i32,
410 filename: String,
412 },
413
414 DirectUnpackExtracting {
416 id: DownloadId,
418 filename: String,
420 },
421
422 DirectUnpackExtracted {
424 id: DownloadId,
426 filename: String,
428 extracted_files: Vec<String>,
430 },
431
432 DirectUnpackCancelled {
434 id: DownloadId,
436 reason: String,
438 },
439
440 DirectUnpackComplete {
442 id: DownloadId,
444 },
445
446 DirectRenamed {
448 id: DownloadId,
450 old_name: String,
452 new_name: String,
454 },
455
456 Shutdown,
458}
459
460#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
462pub struct DownloadInfo {
463 pub id: DownloadId,
465
466 pub name: String,
468
469 pub category: Option<String>,
471
472 pub status: Status,
474
475 pub progress: f32,
477
478 pub speed_bps: u64,
480
481 pub size_bytes: u64,
483
484 pub downloaded_bytes: u64,
486
487 pub eta_seconds: Option<u64>,
489
490 pub priority: Priority,
492
493 pub created_at: DateTime<Utc>,
495
496 pub started_at: Option<DateTime<Utc>>,
498}
499
500#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
502pub struct DownloadOptions {
503 #[serde(default)]
505 pub category: Option<String>,
506
507 #[serde(default)]
509 pub destination: Option<PathBuf>,
510
511 #[serde(default)]
513 pub post_process: Option<PostProcess>,
514
515 #[serde(default)]
517 pub priority: Priority,
518
519 #[serde(default)]
521 pub password: Option<String>,
522}
523
524#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
526pub struct HistoryEntry {
527 pub id: i64,
529
530 pub name: String,
532
533 pub category: Option<String>,
535
536 pub destination: Option<PathBuf>,
538
539 pub status: Status,
541
542 pub size_bytes: u64,
544
545 pub download_time: Duration,
547
548 pub completed_at: DateTime<Utc>,
550}
551
552#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
554pub struct QueueStats {
555 pub total: usize,
557
558 pub queued: usize,
560
561 pub downloading: usize,
563
564 pub paused: usize,
566
567 pub processing: usize,
569
570 pub total_speed_bps: u64,
572
573 pub total_size_bytes: u64,
575
576 pub downloaded_bytes: u64,
578
579 pub overall_progress: f32,
581
582 pub speed_limit_bps: Option<u64>,
584
585 pub accepting_new: bool,
587}
588
589#[derive(Clone, Debug)]
591pub struct DuplicateInfo {
592 pub method: crate::config::DuplicateMethod,
594
595 pub existing_id: DownloadId,
597
598 pub existing_name: String,
600}
601
602#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
604pub struct WebhookPayload {
605 pub event: String,
607
608 pub download_id: DownloadId,
610
611 pub name: String,
613
614 #[serde(skip_serializing_if = "Option::is_none")]
616 pub category: Option<String>,
617
618 pub status: String,
620
621 #[serde(skip_serializing_if = "Option::is_none")]
623 pub destination: Option<PathBuf>,
624
625 #[serde(skip_serializing_if = "Option::is_none")]
627 pub error: Option<String>,
628
629 pub timestamp: i64,
631}
632
633#[allow(clippy::unwrap_used, clippy::expect_used)]
635#[cfg(test)]
636mod tests {
637 use super::*;
638 use std::str::FromStr;
639
640 #[test]
643 fn status_round_trips_through_i32_for_all_variants() {
644 let cases = [
645 (Status::Queued, 0),
646 (Status::Downloading, 1),
647 (Status::Paused, 2),
648 (Status::Processing, 3),
649 (Status::Complete, 4),
650 (Status::Failed, 5),
651 ];
652
653 for (variant, expected_int) in cases {
654 assert_eq!(
655 variant.to_i32(),
656 expected_int,
657 "{variant:?} should encode to {expected_int}"
658 );
659 assert_eq!(
660 Status::from_i32(expected_int),
661 variant,
662 "{expected_int} should decode to {variant:?}"
663 );
664 }
665 }
666
667 #[test]
668 fn status_from_unknown_positive_integer_defaults_to_failed() {
669 assert_eq!(
670 Status::from_i32(99),
671 Status::Failed,
672 "unknown status 99 must fall back to Failed so corrupted DB rows surface visibly"
673 );
674 }
675
676 #[test]
677 fn status_from_negative_integer_defaults_to_failed() {
678 assert_eq!(
679 Status::from_i32(-1),
680 Status::Failed,
681 "negative status must fall back to Failed — not silently become Queued"
682 );
683 }
684
685 #[test]
688 fn priority_round_trips_through_i32_for_all_variants() {
689 let cases = [
690 (Priority::Low, -1),
691 (Priority::Normal, 0),
692 (Priority::High, 1),
693 (Priority::Force, 2),
694 ];
695
696 for (variant, expected_int) in cases {
697 assert_eq!(
699 Priority::from_i32(expected_int),
700 variant,
701 "{expected_int} should decode to {variant:?}"
702 );
703 assert_eq!(
705 variant as i32, expected_int,
706 "{variant:?} discriminant should be {expected_int}"
707 );
708 }
709 }
710
711 #[test]
712 fn priority_from_unknown_integer_defaults_to_normal() {
713 assert_eq!(
714 Priority::from_i32(99),
715 Priority::Normal,
716 "unknown priority must default to Normal, not High or Force"
717 );
718 assert_eq!(
719 Priority::from_i32(-100),
720 Priority::Normal,
721 "large negative priority must default to Normal"
722 );
723 }
724
725 #[test]
728 fn download_id_from_i64_and_back() {
729 let id = DownloadId::from(42_i64);
730 let raw: i64 = id.into();
731 assert_eq!(
732 raw, 42,
733 "round-trip through From<i64>/Into<i64> must preserve value"
734 );
735 }
736
737 #[test]
738 fn download_id_from_str_parses_valid_integer() {
739 let id = DownloadId::from_str("123").unwrap();
740 assert_eq!(id.get(), 123);
741 }
742
743 #[test]
744 fn download_id_from_str_parses_negative_integer() {
745 let id = DownloadId::from_str("-7").unwrap();
746 assert_eq!(
747 id.get(),
748 -7,
749 "DownloadId wraps i64 and must accept negatives"
750 );
751 }
752
753 #[test]
754 fn download_id_from_str_rejects_non_numeric() {
755 let result = DownloadId::from_str("abc");
756 assert!(result.is_err(), "non-numeric string must fail to parse");
757 let err = result.unwrap_err();
759 let msg = err.to_string();
761 assert!(
762 !msg.is_empty(),
763 "ParseIntError should have a descriptive message, got empty"
764 );
765 }
766
767 #[test]
768 fn download_id_from_str_rejects_empty_string() {
769 assert!(
770 DownloadId::from_str("").is_err(),
771 "empty string must not parse to a DownloadId"
772 );
773 }
774
775 #[test]
776 fn download_id_from_str_rejects_float() {
777 assert!(
778 DownloadId::from_str("3.14").is_err(),
779 "float string must not parse as DownloadId"
780 );
781 }
782
783 #[test]
784 fn download_id_display_matches_inner_value() {
785 let id = DownloadId::new(999);
786 assert_eq!(
787 id.to_string(),
788 "999",
789 "Display should produce the raw i64 value"
790 );
791 }
792
793 #[test]
794 fn download_id_display_for_negative() {
795 let id = DownloadId::new(-42);
796 assert_eq!(
797 id.to_string(),
798 "-42",
799 "Display must include the minus sign for negatives"
800 );
801 }
802
803 #[test]
804 fn download_id_partial_eq_with_i64() {
805 let id = DownloadId::new(10);
806 assert!(id == 10_i64, "DownloadId should equal matching i64");
807 assert!(
808 10_i64 == id,
809 "i64 should equal matching DownloadId (symmetric)"
810 );
811 assert!(id != 11_i64, "DownloadId should not equal different i64");
812 }
813
814 #[test]
817 fn download_id_from_str_rejects_whitespace_padded_input() {
818 assert!(
820 DownloadId::from_str(" 123 ").is_err(),
821 "whitespace-padded string must not parse — API callers must trim before parsing"
822 );
823 assert!(
824 DownloadId::from_str(" 123").is_err(),
825 "leading whitespace must be rejected"
826 );
827 assert!(
828 DownloadId::from_str("123 ").is_err(),
829 "trailing whitespace must be rejected"
830 );
831 }
832
833 #[test]
834 fn download_id_from_str_parses_leading_zeros_as_decimal() {
835 let id = DownloadId::from_str("0000123").unwrap();
837 assert_eq!(
838 id.get(),
839 123,
840 "leading zeros should parse as decimal 123, not be rejected or treated as octal"
841 );
842 }
843
844 #[test]
845 fn download_id_from_str_rejects_i64_overflow_without_panic() {
846 let result = DownloadId::from_str("9223372036854775808");
848 assert!(
849 result.is_err(),
850 "i64::MAX + 1 must produce an error, not wrap or panic"
851 );
852 let err = result.unwrap_err();
854 let msg = err.to_string();
855 assert!(
856 msg.contains("too large") || msg.contains("overflow") || msg.contains("number"),
857 "error message should indicate overflow, got: {msg}"
858 );
859 }
860
861 #[test]
862 fn download_id_from_str_rejects_negative_overflow_without_panic() {
863 let result = DownloadId::from_str("-9223372036854775809");
865 assert!(
866 result.is_err(),
867 "i64::MIN - 1 must produce an error, not wrap or panic"
868 );
869 }
870}
871
872#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
874pub struct ServerTestResult {
875 pub success: bool,
877
878 #[serde(skip_serializing_if = "Option::is_none")]
880 pub latency: Option<Duration>,
881
882 #[serde(skip_serializing_if = "Option::is_none")]
884 pub error: Option<String>,
885
886 #[serde(skip_serializing_if = "Option::is_none")]
888 pub capabilities: Option<ServerCapabilities>,
889}
890
891#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
893pub struct ServerCapabilities {
894 pub posting_allowed: bool,
896
897 #[serde(skip_serializing_if = "Option::is_none")]
899 pub max_connections: Option<u32>,
900
901 pub compression: bool,
903}
904
905#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
910pub struct Capabilities {
911 pub parity: ParityCapabilitiesInfo,
913}
914
915#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
920pub struct ParityCapabilitiesInfo {
921 pub can_verify: bool,
923
924 pub can_repair: bool,
926
927 pub handler: String,
929}