use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::Duration;
use utoipa::ToSchema;
use crate::config::{DuplicateMethod, PostProcess};
#[derive(
Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, ToSchema,
)]
#[serde(transparent)]
pub struct DownloadId(pub i64);
impl DownloadId {
pub fn new(id: i64) -> Self {
Self(id)
}
pub fn get(&self) -> i64 {
self.0
}
}
impl From<i64> for DownloadId {
fn from(id: i64) -> Self {
Self(id)
}
}
impl From<DownloadId> for i64 {
fn from(id: DownloadId) -> Self {
id.0
}
}
impl PartialEq<i64> for DownloadId {
fn eq(&self, other: &i64) -> bool {
self.0 == *other
}
}
impl PartialEq<DownloadId> for i64 {
fn eq(&self, other: &DownloadId) -> bool {
*self == other.0
}
}
impl std::fmt::Display for DownloadId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::str::FromStr for DownloadId {
type Err = std::num::ParseIntError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self(s.parse()?))
}
}
impl sqlx::Type<sqlx::Sqlite> for DownloadId {
fn type_info() -> sqlx::sqlite::SqliteTypeInfo {
<i64 as sqlx::Type<sqlx::Sqlite>>::type_info()
}
fn compatible(ty: &sqlx::sqlite::SqliteTypeInfo) -> bool {
<i64 as sqlx::Type<sqlx::Sqlite>>::compatible(ty)
}
}
impl<'q> sqlx::Encode<'q, sqlx::Sqlite> for DownloadId {
fn encode_by_ref(
&self,
buf: &mut Vec<sqlx::sqlite::SqliteArgumentValue<'q>>,
) -> Result<sqlx::encode::IsNull, Box<dyn std::error::Error + Send + Sync>> {
sqlx::Encode::<sqlx::Sqlite>::encode_by_ref(&self.0, buf)
}
}
impl<'r> sqlx::Decode<'r, sqlx::Sqlite> for DownloadId {
fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result<Self, sqlx::error::BoxDynError> {
let id = <i64 as sqlx::Decode<sqlx::Sqlite>>::decode(value)?;
Ok(Self(id))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum Status {
Queued,
Downloading,
Paused,
Processing,
Complete,
Failed,
}
impl Status {
pub fn from_i32(status: i32) -> Self {
match status {
0 => Status::Queued,
1 => Status::Downloading,
2 => Status::Paused,
3 => Status::Processing,
4 => Status::Complete,
5 => Status::Failed,
_ => Status::Failed, }
}
pub fn to_i32(&self) -> i32 {
match self {
Status::Queued => 0,
Status::Downloading => 1,
Status::Paused => 2,
Status::Processing => 3,
Status::Complete => 4,
Status::Failed => 5,
}
}
}
#[derive(
Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, ToSchema,
)]
#[serde(rename_all = "lowercase")]
pub enum Priority {
Low = -1,
#[default]
Normal = 0,
High = 1,
Force = 2,
}
impl Priority {
pub fn from_i32(priority: i32) -> Self {
match priority {
-1 => Priority::Low,
0 => Priority::Normal,
1 => Priority::High,
2 => Priority::Force,
_ => Priority::Normal, }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum Stage {
Download,
Verify,
Repair,
Extract,
Move,
Cleanup,
DirectUnpack,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum ArchiveType {
Rar,
SevenZip,
Zip,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Event {
Queued {
id: DownloadId,
name: String,
},
Removed {
id: DownloadId,
},
Downloading {
id: DownloadId,
percent: f32,
speed_bps: u64,
#[serde(skip_serializing_if = "Option::is_none")]
failed_articles: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
total_articles: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
health_percent: Option<f32>,
},
DownloadComplete {
id: DownloadId,
#[serde(skip_serializing_if = "Option::is_none")]
articles_failed: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
articles_total: Option<u64>,
},
DownloadFailed {
id: DownloadId,
error: String,
#[serde(skip_serializing_if = "Option::is_none")]
articles_succeeded: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
articles_failed: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
articles_total: Option<u64>,
},
Verifying {
id: DownloadId,
},
VerifyComplete {
id: DownloadId,
damaged: bool,
},
Repairing {
id: DownloadId,
blocks_needed: u32,
blocks_available: u32,
},
RepairComplete {
id: DownloadId,
success: bool,
},
RepairSkipped {
id: DownloadId,
reason: String,
},
Extracting {
id: DownloadId,
archive: String,
percent: f32,
},
ExtractComplete {
id: DownloadId,
},
Moving {
id: DownloadId,
destination: PathBuf,
},
Cleaning {
id: DownloadId,
},
Complete {
id: DownloadId,
path: PathBuf,
},
Failed {
id: DownloadId,
stage: Stage,
error: String,
files_kept: bool,
},
SpeedLimitChanged {
limit_bps: Option<u64>,
},
QueuePaused,
QueueResumed,
WebhookFailed {
url: String,
error: String,
},
ScriptFailed {
script: PathBuf,
exit_code: Option<i32>,
},
DuplicateDetected {
id: DownloadId,
name: String,
method: DuplicateMethod,
existing_name: String,
},
DirectUnpackStarted {
id: DownloadId,
},
FileCompleted {
id: DownloadId,
file_index: i32,
filename: String,
},
DirectUnpackExtracting {
id: DownloadId,
filename: String,
},
DirectUnpackExtracted {
id: DownloadId,
filename: String,
extracted_files: Vec<String>,
},
DirectUnpackCancelled {
id: DownloadId,
reason: String,
},
DirectUnpackComplete {
id: DownloadId,
},
DirectRenamed {
id: DownloadId,
old_name: String,
new_name: String,
},
Shutdown,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct DownloadInfo {
pub id: DownloadId,
pub name: String,
pub category: Option<String>,
pub status: Status,
pub progress: f32,
pub speed_bps: u64,
pub size_bytes: u64,
pub downloaded_bytes: u64,
pub eta_seconds: Option<u64>,
pub priority: Priority,
pub created_at: DateTime<Utc>,
pub started_at: Option<DateTime<Utc>>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
pub struct DownloadOptions {
#[serde(default)]
pub category: Option<String>,
#[serde(default)]
pub destination: Option<PathBuf>,
#[serde(default)]
pub post_process: Option<PostProcess>,
#[serde(default)]
pub priority: Priority,
#[serde(default)]
pub password: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct HistoryEntry {
pub id: i64,
pub name: String,
pub category: Option<String>,
pub destination: Option<PathBuf>,
pub status: Status,
pub size_bytes: u64,
pub download_time: Duration,
pub completed_at: DateTime<Utc>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct QueueStats {
pub total: usize,
pub queued: usize,
pub downloading: usize,
pub paused: usize,
pub processing: usize,
pub total_speed_bps: u64,
pub total_size_bytes: u64,
pub downloaded_bytes: u64,
pub overall_progress: f32,
pub speed_limit_bps: Option<u64>,
pub accepting_new: bool,
}
#[derive(Clone, Debug)]
pub struct DuplicateInfo {
pub method: crate::config::DuplicateMethod,
pub existing_id: DownloadId,
pub existing_name: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct WebhookPayload {
pub event: String,
pub download_id: DownloadId,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub destination: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
pub timestamp: i64,
}
#[allow(clippy::unwrap_used, clippy::expect_used)]
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
#[test]
fn status_round_trips_through_i32_for_all_variants() {
let cases = [
(Status::Queued, 0),
(Status::Downloading, 1),
(Status::Paused, 2),
(Status::Processing, 3),
(Status::Complete, 4),
(Status::Failed, 5),
];
for (variant, expected_int) in cases {
assert_eq!(
variant.to_i32(),
expected_int,
"{variant:?} should encode to {expected_int}"
);
assert_eq!(
Status::from_i32(expected_int),
variant,
"{expected_int} should decode to {variant:?}"
);
}
}
#[test]
fn status_from_unknown_positive_integer_defaults_to_failed() {
assert_eq!(
Status::from_i32(99),
Status::Failed,
"unknown status 99 must fall back to Failed so corrupted DB rows surface visibly"
);
}
#[test]
fn status_from_negative_integer_defaults_to_failed() {
assert_eq!(
Status::from_i32(-1),
Status::Failed,
"negative status must fall back to Failed — not silently become Queued"
);
}
#[test]
fn priority_round_trips_through_i32_for_all_variants() {
let cases = [
(Priority::Low, -1),
(Priority::Normal, 0),
(Priority::High, 1),
(Priority::Force, 2),
];
for (variant, expected_int) in cases {
assert_eq!(
Priority::from_i32(expected_int),
variant,
"{expected_int} should decode to {variant:?}"
);
assert_eq!(
variant as i32, expected_int,
"{variant:?} discriminant should be {expected_int}"
);
}
}
#[test]
fn priority_from_unknown_integer_defaults_to_normal() {
assert_eq!(
Priority::from_i32(99),
Priority::Normal,
"unknown priority must default to Normal, not High or Force"
);
assert_eq!(
Priority::from_i32(-100),
Priority::Normal,
"large negative priority must default to Normal"
);
}
#[test]
fn download_id_from_i64_and_back() {
let id = DownloadId::from(42_i64);
let raw: i64 = id.into();
assert_eq!(
raw, 42,
"round-trip through From<i64>/Into<i64> must preserve value"
);
}
#[test]
fn download_id_from_str_parses_valid_integer() {
let id = DownloadId::from_str("123").unwrap();
assert_eq!(id.get(), 123);
}
#[test]
fn download_id_from_str_parses_negative_integer() {
let id = DownloadId::from_str("-7").unwrap();
assert_eq!(
id.get(),
-7,
"DownloadId wraps i64 and must accept negatives"
);
}
#[test]
fn download_id_from_str_rejects_non_numeric() {
let result = DownloadId::from_str("abc");
assert!(result.is_err(), "non-numeric string must fail to parse");
let err = result.unwrap_err();
let msg = err.to_string();
assert!(
!msg.is_empty(),
"ParseIntError should have a descriptive message, got empty"
);
}
#[test]
fn download_id_from_str_rejects_empty_string() {
assert!(
DownloadId::from_str("").is_err(),
"empty string must not parse to a DownloadId"
);
}
#[test]
fn download_id_from_str_rejects_float() {
assert!(
DownloadId::from_str("3.14").is_err(),
"float string must not parse as DownloadId"
);
}
#[test]
fn download_id_display_matches_inner_value() {
let id = DownloadId::new(999);
assert_eq!(
id.to_string(),
"999",
"Display should produce the raw i64 value"
);
}
#[test]
fn download_id_display_for_negative() {
let id = DownloadId::new(-42);
assert_eq!(
id.to_string(),
"-42",
"Display must include the minus sign for negatives"
);
}
#[test]
fn download_id_partial_eq_with_i64() {
let id = DownloadId::new(10);
assert!(id == 10_i64, "DownloadId should equal matching i64");
assert!(
10_i64 == id,
"i64 should equal matching DownloadId (symmetric)"
);
assert!(id != 11_i64, "DownloadId should not equal different i64");
}
#[test]
fn download_id_from_str_rejects_whitespace_padded_input() {
assert!(
DownloadId::from_str(" 123 ").is_err(),
"whitespace-padded string must not parse — API callers must trim before parsing"
);
assert!(
DownloadId::from_str(" 123").is_err(),
"leading whitespace must be rejected"
);
assert!(
DownloadId::from_str("123 ").is_err(),
"trailing whitespace must be rejected"
);
}
#[test]
fn download_id_from_str_parses_leading_zeros_as_decimal() {
let id = DownloadId::from_str("0000123").unwrap();
assert_eq!(
id.get(),
123,
"leading zeros should parse as decimal 123, not be rejected or treated as octal"
);
}
#[test]
fn download_id_from_str_rejects_i64_overflow_without_panic() {
let result = DownloadId::from_str("9223372036854775808");
assert!(
result.is_err(),
"i64::MAX + 1 must produce an error, not wrap or panic"
);
let err = result.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("too large") || msg.contains("overflow") || msg.contains("number"),
"error message should indicate overflow, got: {msg}"
);
}
#[test]
fn download_id_from_str_rejects_negative_overflow_without_panic() {
let result = DownloadId::from_str("-9223372036854775809");
assert!(
result.is_err(),
"i64::MIN - 1 must produce an error, not wrap or panic"
);
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ServerTestResult {
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub latency: Option<Duration>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub capabilities: Option<ServerCapabilities>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ServerCapabilities {
pub posting_allowed: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_connections: Option<u32>,
pub compression: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct Capabilities {
pub parity: ParityCapabilitiesInfo,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ParityCapabilitiesInfo {
pub can_verify: bool,
pub can_repair: bool,
pub handler: String,
}