use std::sync::Arc;
use std::time::Instant;
use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use tokio::sync::{broadcast, RwLock};
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
use crate::commands::Platform;
use crate::config::Config;
const MAX_FINISHED_SESSIONS: usize = 50;
pub(crate) struct AppState {
pub(crate) config: Arc<RwLock<Config>>,
pub(crate) sessions: Arc<DashMap<Uuid, DownloadSession>>,
pub(crate) event_tx: broadcast::Sender<ServerEvent>,
}
impl AppState {
pub(crate) fn new(config: Config) -> Self {
let (event_tx, _rx) = broadcast::channel(256);
Self {
config: Arc::new(RwLock::new(config)),
sessions: Arc::new(DashMap::new()),
event_tx,
}
}
}
pub(crate) struct DownloadSession {
pub(crate) id: Uuid,
pub(crate) platform: Platform,
pub(crate) url: String,
pub(crate) status: SessionStatus,
pub(crate) progress: f32,
pub(crate) error_message: Option<String>,
pub(crate) abort: CancellationToken,
pub(crate) created_at: Instant,
}
#[derive(Debug, Clone)]
pub(crate) enum SessionStatus {
Pending,
FetchingMetadata,
Downloading,
Merging,
Complete,
Error,
Cancelled,
}
impl SessionStatus {
pub(crate) fn as_str(&self) -> &'static str {
match self {
SessionStatus::Pending => "pending",
SessionStatus::FetchingMetadata => "fetching_metadata",
SessionStatus::Downloading => "downloading",
SessionStatus::Merging => "merging",
SessionStatus::Complete => "complete",
SessionStatus::Error => "error",
SessionStatus::Cancelled => "cancelled",
}
}
pub(crate) fn is_finished(&self) -> bool {
matches!(
self,
SessionStatus::Complete | SessionStatus::Error | SessionStatus::Cancelled
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum ServerEvent {
Progress {
session_id: Uuid,
percent: f32,
speed: String,
eta: String,
size: String,
},
Status {
session_id: Uuid,
status: String,
#[serde(skip_serializing_if = "Option::is_none")]
message: Option<String>,
},
Metadata {
session_id: Uuid,
title: String,
uploader: Option<String>,
thumbnail: Option<String>,
duration: Option<i64>,
},
Complete {
session_id: Uuid,
output_path: String,
},
Error {
session_id: Uuid,
message: String,
},
}
impl ServerEvent {
pub(crate) fn session_id(&self) -> Uuid {
match self {
ServerEvent::Progress { session_id, .. }
| ServerEvent::Status { session_id, .. }
| ServerEvent::Metadata { session_id, .. }
| ServerEvent::Complete { session_id, .. }
| ServerEvent::Error { session_id, .. } => *session_id,
}
}
}
pub(crate) fn evict_finished_sessions(sessions: &DashMap<Uuid, DownloadSession>) {
let mut finished: Vec<(Uuid, Instant)> = sessions
.iter()
.filter(|entry| entry.value().status.is_finished())
.map(|entry| (*entry.key(), entry.value().created_at))
.collect();
if finished.len() <= MAX_FINISHED_SESSIONS {
return;
}
finished.sort_by_key(|(_, created_at)| *created_at);
let overflow = finished.len() - MAX_FINISHED_SESSIONS;
for (id, _) in finished.into_iter().take(overflow) {
sessions.remove(&id);
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
fn session_at(status: SessionStatus, offset_secs: u64, base: Instant) -> DownloadSession {
DownloadSession {
id: Uuid::new_v4(),
platform: Platform::YouTube,
url: "https://example.com/watch?v=test".to_string(),
status,
progress: 0.0,
error_message: None,
abort: CancellationToken::new(),
created_at: base + Duration::from_secs(offset_secs),
}
}
#[test]
fn eviction_keeps_only_the_newest_finished_sessions() {
let sessions: DashMap<Uuid, DownloadSession> = DashMap::new();
let base = Instant::now();
for offset in 0..60u64 {
let session = session_at(SessionStatus::Complete, offset, base);
sessions.insert(session.id, session);
}
evict_finished_sessions(&sessions);
assert_eq!(sessions.len(), MAX_FINISHED_SESSIONS);
let oldest_remaining = sessions
.iter()
.map(|entry| entry.value().created_at)
.min()
.expect("sessions should not be empty");
assert_eq!(oldest_remaining, base + Duration::from_secs(10));
}
#[test]
fn eviction_never_removes_active_sessions() {
let sessions: DashMap<Uuid, DownloadSession> = DashMap::new();
let base = Instant::now();
for offset in 0..60u64 {
sessions.insert(
Uuid::new_v4(),
session_at(SessionStatus::Downloading, offset, base),
);
}
evict_finished_sessions(&sessions);
assert_eq!(sessions.len(), 60);
}
#[test]
fn server_event_serializes_with_tagged_shape() {
let session_id = Uuid::new_v4();
let event = ServerEvent::Status {
session_id,
status: "downloading".to_string(),
message: None,
};
let json = serde_json::to_value(&event).expect("event should serialize");
assert_eq!(json["type"], "status");
assert_eq!(json["session_id"], session_id.to_string());
assert_eq!(json["status"], "downloading");
assert!(json.get("message").is_none());
}
}