Files
tuliprox/backend/src/api/model/download.rs
T
euzuandGitHub 9ce3e7a313 Feature/download (#700)
- Download manager
- Stream history fixes
- Stream history docs updated
2026-04-03 16:23:15 +02:00

1694 lines
61 KiB
Rust

use crate::{model::VideoDownloadConfig, utils::file_exists_async};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use shared::model::{FileDownloadDto, TaskKindDto, TaskPriorityDto, TransferStatusDto};
use shared::utils::{deunicode_string, CONSTANTS, FILENAME_TRIM_PATTERNS};
use std::{
collections::VecDeque,
ffi::OsStr,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
sync::{atomic::{AtomicU64, Ordering}, Arc},
};
use tokio::{fs, sync::{Mutex, Notify, RwLock}};
const RECORDING_WINDOW_EXPIRED_ERR: &str = "Recording window already expired";
static DOWNLOAD_TASK_ID_COUNTER: AtomicU64 = AtomicU64::new(1);
/// File-Download information.
#[derive(Clone, Debug)]
pub struct FileDownload {
/// uuid of the download for identification.
pub uuid: String,
/// `file_dir` is the directory where the download should be placed.
pub file_dir: PathBuf,
/// `file_path` is the complete path including the filename.
pub file_path: PathBuf,
/// filename is the filename.
pub filename: String,
/// url is the download url.
pub url: reqwest::Url,
/// finished is true, if download is finished, otherweise false
pub finished: bool,
/// the filesize.
pub size: u64,
/// total size in bytes (from Content-Length header)
pub total_size: Option<u64>,
/// paused state
pub paused: bool,
/// Optional error if something goes wrong during downloading.
pub error: Option<String>,
/// Download state
pub state: DownloadState,
/// Scheduled recording start timestamp.
pub start_at: Option<i64>,
/// Scheduled recording duration in seconds.
pub duration_secs: Option<u64>,
/// Distinguishes plain downloads from scheduled recordings.
pub kind: DownloadKind,
/// The input source name used to acquire a provider connection.
pub input_name: Option<Arc<str>>,
/// Priority for provider connection preemption (lower = higher priority).
pub priority: i8,
/// Consecutive retry attempts for transient failures.
pub retry_attempts: u8,
/// Unix timestamp of the next retry attempt while waiting.
pub next_retry_at: Option<i64>,
}
#[derive(Clone, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub enum DownloadKind {
#[default]
Download,
Recording,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct PersistedFileDownload {
uuid: String,
file_dir: PathBuf,
file_path: PathBuf,
filename: String,
url: String,
finished: bool,
size: u64,
total_size: Option<u64>,
paused: bool,
error: Option<String>,
state: DownloadState,
start_at: Option<i64>,
duration_secs: Option<u64>,
kind: DownloadKind,
#[serde(default)]
input_name: Option<String>,
#[serde(default)]
priority: i8,
#[serde(default)]
retry_attempts: u8,
#[serde(default)]
next_retry_at: Option<i64>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct PersistedDownloadQueue {
queue: Vec<PersistedFileDownload>,
scheduled: Vec<PersistedFileDownload>,
active: Option<PersistedFileDownload>,
finished: Vec<PersistedFileDownload>,
}
#[derive(Clone, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub enum DownloadState {
#[default]
Queued,
Scheduled,
WaitingForCapacity,
RetryWaiting,
Downloading,
Paused,
Completed,
Failed,
Cancelled,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum DownloadControl {
#[default]
None,
Pause,
Cancel,
Restart,
}
/// Returns the directory for th file download.
/// if option `organize_into_directories` is set, the root directory is determined.
/// - For series, the episode pattern is used to determine the sub directory for the series.
/// - For vod files, the title is used to determine the sub directory.
///
/// # Arguments
/// * `download_cfg` the download configuration
/// * `filestem` the prepared filestem to use as sub directory
///
fn get_download_directory(download_cfg: &VideoDownloadConfig, filestem: &str) -> PathBuf {
if download_cfg.organize_into_directories {
let mut stem = filestem;
if let Some(re) = &download_cfg.episode_pattern {
if let Some(captures) = re.captures(stem) {
if let Some(episode) = captures.name("episode") {
if !episode.as_str().is_empty() {
stem = &stem[..episode.start()];
}
}
}
}
let dir_name = CONSTANTS.re_remove_filename_ending.replace(stem, "");
let file_dir: PathBuf = [download_cfg.directory.as_str(), dir_name.as_ref()].iter().collect();
file_dir
} else {
PathBuf::from(download_cfg.directory.as_str())
}
}
fn generate_download_task_id() -> String {
let now_nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos());
let counter = DOWNLOAD_TASK_ID_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("{now_nanos:032x}{counter:016x}")
}
impl FileDownload {
// TODO read header size info and restart support
// "content-type" => ".../..."
// "content-length" => "1975828544"
// "accept-ranges" => "0-1975828544"
// "content-range" => "bytes 0-1975828543/1975828544"
pub fn new(req_url: &str, req_filename: &str, download_cfg: &VideoDownloadConfig, input_name: Option<Arc<str>>, priority: i8) -> Option<Self> {
match reqwest::Url::parse(req_url) {
Ok(url) => {
let tmp_filename = CONSTANTS
.re_filename
.replace_all(&deunicode_string(req_filename).replace(' ', "_"), "")
.replace("__", "_")
.replace("_-_", "-");
let filename_path = Path::new(&tmp_filename);
let file_stem = filename_path
.file_stem()
.and_then(OsStr::to_str)
.unwrap_or("")
.trim_matches(FILENAME_TRIM_PATTERNS);
let file_ext = filename_path.extension().and_then(OsStr::to_str).unwrap_or("");
let mut filename = format!("{file_stem}.{file_ext}");
let file_dir = get_download_directory(download_cfg, file_stem);
let mut file_path: PathBuf = file_dir.clone();
file_path.push(&filename);
let mut x: usize = 1;
while file_path.is_file() {
filename = format!("{file_stem}_{x}.{file_ext}");
file_path.clone_from(&file_dir);
file_path.push(&filename);
x += 1;
}
file_path.to_str()?;
Some(Self {
uuid: generate_download_task_id(),
file_dir,
file_path,
filename,
url,
finished: false,
size: 0,
total_size: None,
paused: false,
error: None,
state: DownloadState::Queued,
start_at: None,
duration_secs: None,
kind: DownloadKind::Download,
input_name,
priority,
retry_attempts: 0,
next_retry_at: None,
})
}
Err(_) => None,
}
}
pub fn new_recording(
req_url: &str,
req_filename: &str,
download_cfg: &VideoDownloadConfig,
start_at: i64,
duration_secs: u64,
input_name: Option<Arc<str>>,
priority: i8,
) -> Option<Self> {
let mut recording = Self::new(req_url, req_filename, download_cfg, input_name, priority)?;
recording.state = DownloadState::Scheduled;
recording.start_at = Some(start_at);
recording.duration_secs = Some(duration_secs);
recording.kind = DownloadKind::Recording;
Some(recording)
}
}
impl FileDownload {
fn matches_existing_task(&self, other: &Self) -> bool {
if self.kind != other.kind {
return false;
}
match self.kind {
DownloadKind::Download => self.url == other.url || self.file_path == other.file_path,
DownloadKind::Recording => {
(self.url == other.url && self.start_at == other.start_at && self.duration_secs == other.duration_secs)
|| self.file_path == other.file_path
}
}
}
}
impl From<&FileDownload> for FileDownloadDto {
fn from(value: &FileDownload) -> Self {
Self {
id: value.uuid.clone(),
title: value.filename.clone(),
kind: match value.kind {
DownloadKind::Download => TaskKindDto::Download,
DownloadKind::Recording => TaskKindDto::Recording,
},
priority: match value.priority.cmp(&0) {
std::cmp::Ordering::Less => TaskPriorityDto::High,
std::cmp::Ordering::Equal => TaskPriorityDto::Normal,
std::cmp::Ordering::Greater => TaskPriorityDto::Background,
},
status: match value.state {
DownloadState::Queued => TransferStatusDto::Queued,
DownloadState::Scheduled => TransferStatusDto::Scheduled,
DownloadState::WaitingForCapacity => TransferStatusDto::WaitingForCapacity,
DownloadState::RetryWaiting => TransferStatusDto::RetryWaiting,
DownloadState::Downloading => TransferStatusDto::Running,
DownloadState::Paused => TransferStatusDto::Paused,
DownloadState::Completed => TransferStatusDto::Completed,
DownloadState::Failed => TransferStatusDto::Failed,
DownloadState::Cancelled => TransferStatusDto::Cancelled,
},
downloaded_bytes: value.size,
retry_attempts: value.retry_attempts,
total_bytes: value.total_size,
next_retry_at: value.next_retry_at,
scheduled_start_at: value.start_at,
duration_secs: value.duration_secs,
error: value.error.clone(),
}
}
}
impl From<FileDownload> for FileDownloadDto {
fn from(value: FileDownload) -> Self { Self::from(&value) }
}
/// Priority-aware wait queue for download connection slots.
/// When the provider is at capacity, download tasks register here and are
/// woken one-at-a-time in descending priority order (lowest i8 = highest priority).
struct DownloadWaiter {
id: u64,
input_name: Option<Arc<str>>,
priority: i8,
notify: Arc<Notify>,
}
type DownloadWaiters = Arc<Mutex<Vec<DownloadWaiter>>>;
#[derive(Clone)]
pub struct DownloadWaiterSnapshot {
pub id: u64,
pub input_name: Option<Arc<str>>,
pub priority: i8,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DownloadWaitOutcome {
Signalled,
Paused,
Cancelled,
Restarted,
}
pub struct DownloadSlotWaitQueue {
waiters: DownloadWaiters,
next_waiter_id: AtomicU64,
}
impl DownloadSlotWaitQueue {
pub fn new() -> Self {
Self {
waiters: Arc::new(Mutex::new(Vec::new())),
next_waiter_id: AtomicU64::new(1),
}
}
async fn remove_waiter(&self, waiter_id: u64) {
self.waiters.lock().await.retain(|waiter| waiter.id != waiter_id);
}
/// Register and block until this task is signalled or control flow requests pause/cancel.
pub async fn wait(
&self,
input_name: Option<Arc<str>>,
priority: i8,
control_signal: &RwLock<DownloadControl>,
control_notify: &Notify,
) -> DownloadWaitOutcome {
let waiter_id = self.next_waiter_id.fetch_add(1, Ordering::Relaxed);
let notify = Arc::new(Notify::new());
self.waiters.lock().await.push(DownloadWaiter {
id: waiter_id,
input_name,
priority,
notify: Arc::clone(&notify),
});
match *control_signal.read().await {
DownloadControl::Pause => {
self.remove_waiter(waiter_id).await;
return DownloadWaitOutcome::Paused;
}
DownloadControl::Cancel => {
self.remove_waiter(waiter_id).await;
return DownloadWaitOutcome::Cancelled;
}
DownloadControl::Restart => {
self.remove_waiter(waiter_id).await;
return DownloadWaitOutcome::Restarted;
}
DownloadControl::None => {}
}
loop {
tokio::select! {
() = notify.notified() => return DownloadWaitOutcome::Signalled,
() = control_notify.notified() => {
match *control_signal.read().await {
DownloadControl::Pause => {
self.remove_waiter(waiter_id).await;
return DownloadWaitOutcome::Paused;
}
DownloadControl::Cancel => {
self.remove_waiter(waiter_id).await;
return DownloadWaitOutcome::Cancelled;
}
DownloadControl::Restart => {
self.remove_waiter(waiter_id).await;
return DownloadWaitOutcome::Restarted;
}
DownloadControl::None => {}
}
}
}
}
}
pub async fn snapshots(&self) -> Vec<DownloadWaiterSnapshot> {
self.waiters
.lock()
.await
.iter()
.map(|waiter| DownloadWaiterSnapshot {
id: waiter.id,
input_name: waiter.input_name.clone(),
priority: waiter.priority,
})
.collect()
}
/// Wake a specific waiter by id.
pub async fn signal_waiter(&self, waiter_id: u64) -> bool {
let mut waiters = self.waiters.lock().await;
if let Some(idx) = waiters.iter().position(|waiter| waiter.id == waiter_id) {
let notify = Arc::clone(&waiters[idx].notify);
waiters.remove(idx);
notify.notify_one();
true
} else {
false
}
}
}
pub struct DownloadQueue {
pub queue: Arc<Mutex<VecDeque<FileDownload>>>,
pub scheduled: Arc<RwLock<Vec<FileDownload>>>,
pub active: Arc<RwLock<Option<FileDownload>>>,
pub finished: Arc<RwLock<Vec<FileDownload>>>,
pub control_signal: Arc<RwLock<DownloadControl>>,
pub control_notify: Arc<Notify>,
pub worker_running: Arc<RwLock<bool>>,
pub state_file: Option<PathBuf>,
/// Priority-aware waiter queue for provider connection slots.
pub slot_waiters: Arc<DownloadSlotWaitQueue>,
}
impl Default for DownloadQueue {
fn default() -> Self { Self::new() }
}
impl DownloadQueue {
fn finalize_missed_recording(mut download: FileDownload) -> FileDownload {
download.finished = true;
download.paused = false;
download.state = DownloadState::Failed;
download.error = Some(RECORDING_WINDOW_EXPIRED_ERR.to_string());
download
}
fn recording_start_missed_window(download: &FileDownload, now_ts: i64) -> bool {
download.kind == DownloadKind::Recording
&& download
.start_at
.zip(download.duration_secs)
.is_some_and(|(start_at, duration_secs)| now_ts >= start_at.saturating_add(i64::try_from(duration_secs).unwrap_or(i64::MAX)))
}
pub fn new() -> Self {
Self::new_with_state_file(None)
}
pub fn new_with_state_file(state_file: Option<PathBuf>) -> Self {
Self {
queue: Arc::from(Mutex::new(VecDeque::new())),
scheduled: Arc::from(RwLock::new(Vec::new())),
active: Arc::from(RwLock::new(None)),
finished: Arc::from(RwLock::new(Vec::new())),
control_signal: Arc::from(RwLock::new(DownloadControl::None)),
control_notify: Arc::new(Notify::new()),
worker_running: Arc::from(RwLock::new(false)),
state_file,
slot_waiters: Arc::new(DownloadSlotWaitQueue::new()),
}
}
fn to_persisted(download: &FileDownload) -> PersistedFileDownload {
PersistedFileDownload {
uuid: download.uuid.clone(),
file_dir: download.file_dir.clone(),
file_path: download.file_path.clone(),
filename: download.filename.clone(),
url: download.url.to_string(),
finished: download.finished,
size: download.size,
total_size: download.total_size,
paused: download.paused,
error: download.error.clone(),
state: download.state.clone(),
start_at: download.start_at,
duration_secs: download.duration_secs,
kind: download.kind.clone(),
input_name: download.input_name.as_ref().map(std::string::ToString::to_string),
priority: download.priority,
retry_attempts: download.retry_attempts,
next_retry_at: download.next_retry_at,
}
}
fn from_persisted(download: PersistedFileDownload) -> Option<FileDownload> {
Some(FileDownload {
uuid: download.uuid,
file_dir: download.file_dir,
file_path: download.file_path,
filename: download.filename,
url: reqwest::Url::parse(&download.url).ok()?,
finished: download.finished,
size: download.size,
total_size: download.total_size,
paused: download.paused,
error: download.error,
state: download.state,
start_at: download.start_at,
duration_secs: download.duration_secs,
kind: download.kind,
input_name: download.input_name.map(|s| Arc::from(s.as_str())),
priority: download.priority,
retry_attempts: download.retry_attempts,
next_retry_at: download.next_retry_at,
})
}
pub async fn persist_to_disk(&self) -> std::io::Result<()> {
let Some(state_file) = self.state_file.as_ref() else {
return Ok(());
};
let queue = self.queue.lock().await.iter().map(Self::to_persisted).collect::<Vec<_>>();
let scheduled = self.scheduled.read().await.iter().map(Self::to_persisted).collect::<Vec<_>>();
let active = self.active.read().await.as_ref().map(Self::to_persisted);
let finished = self.finished.read().await.iter().map(Self::to_persisted).collect::<Vec<_>>();
let payload = PersistedDownloadQueue { queue, scheduled, active, finished };
let content = serde_json::to_vec_pretty(&payload).map_err(std::io::Error::other)?;
if let Some(parent) = state_file.parent() {
fs::create_dir_all(parent).await?;
}
let tmp_file = state_file.with_extension("json.tmp");
fs::write(&tmp_file, content).await?;
fs::rename(&tmp_file, state_file).await
}
pub async fn load_from_disk(&self) -> std::io::Result<()> {
let Some(state_file) = self.state_file.as_ref() else {
return Ok(());
};
if !file_exists_async(state_file).await {
return Ok(());
}
let content = fs::read_to_string(state_file).await?;
let persisted: PersistedDownloadQueue =
serde_json::from_str(&content).map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?;
let queue = persisted
.queue
.into_iter()
.filter_map(Self::from_persisted)
.map(Self::recover_loaded_download)
.collect::<VecDeque<_>>();
let now_ts = Utc::now().timestamp();
let scheduled_loaded = persisted
.scheduled
.into_iter()
.filter_map(Self::from_persisted)
.map(Self::recover_loaded_download)
.collect::<Vec<_>>();
let (scheduled, missed_scheduled): (Vec<_>, Vec<_>) = scheduled_loaded
.into_iter()
.partition(|download| !Self::recording_start_missed_window(download, now_ts));
let active = persisted.active.and_then(Self::from_persisted).map(Self::recover_loaded_download);
let mut finished =
persisted.finished.into_iter().filter_map(Self::from_persisted).collect::<Vec<_>>();
finished.extend(missed_scheduled.into_iter().map(Self::finalize_missed_recording));
*self.queue.lock().await = queue;
*self.scheduled.write().await = scheduled;
*self.finished.write().await = finished;
if let Some(active) = active {
if active.paused || active.state == DownloadState::Paused {
*self.active.write().await = Some(active);
} else if !active.finished && active.state != DownloadState::Cancelled {
self.queue.lock().await.push_front(active);
*self.active.write().await = None;
} else {
self.finished.write().await.push(active);
*self.active.write().await = None;
}
} else {
*self.active.write().await = None;
}
*self.control_signal.write().await = DownloadControl::None;
*self.worker_running.write().await = false;
Ok(())
}
fn recover_loaded_download(mut download: FileDownload) -> FileDownload {
if download.paused || download.state == DownloadState::Paused {
download.paused = true;
download.finished = false;
download.state = DownloadState::Paused;
return download;
}
if download.state == DownloadState::Scheduled {
download.paused = false;
download.finished = false;
return download;
}
if !download.finished {
download.paused = false;
download.state = DownloadState::Queued;
download.error = None;
download.retry_attempts = 0;
download.next_retry_at = None;
}
download
}
pub async fn find_duplicate(&self, candidate: &FileDownload) -> Option<FileDownload> {
if let Some(active) = self.active.read().await.as_ref() {
if active.matches_existing_task(candidate) {
return Some(active.clone());
}
}
if let Some(queued) = self
.queue
.lock()
.await
.iter()
.find(|download| download.matches_existing_task(candidate))
.cloned()
{
return Some(queued);
}
if let Some(scheduled) = self
.scheduled
.read()
.await
.iter()
.find(|download| download.matches_existing_task(candidate))
.cloned()
{
return Some(scheduled);
}
self.finished
.read()
.await
.iter()
.find(|download| download.matches_existing_task(candidate))
.cloned()
}
pub async fn pause_active(&self) {
*self.control_signal.write().await = DownloadControl::Pause;
self.control_notify.notify_waiters();
if let Some(download) = self.active.write().await.as_mut() {
download.paused = true;
download.state = DownloadState::Paused;
download.next_retry_at = None;
}
let _ = self.persist_to_disk().await;
}
pub async fn resume_active(&self) {
*self.control_signal.write().await = DownloadControl::None;
self.control_notify.notify_waiters();
if let Some(download) = self.active.write().await.as_mut() {
download.paused = false;
download.state = DownloadState::Downloading;
download.next_retry_at = None;
}
let _ = self.persist_to_disk().await;
}
pub async fn cancel_active(&self) {
*self.control_signal.write().await = DownloadControl::Cancel;
self.control_notify.notify_waiters();
if let Some(download) = self.active.write().await.as_mut() {
download.state = DownloadState::Cancelled;
download.error = Some("Cancelled by user".to_string());
download.next_retry_at = None;
}
let _ = self.persist_to_disk().await;
}
pub fn request_worker_restart(&self) {
if let Ok(mut control) = self.control_signal.try_write() {
*control = DownloadControl::Restart;
self.control_notify.notify_waiters();
return;
}
let control_signal = Arc::clone(&self.control_signal);
let control_notify = Arc::clone(&self.control_notify);
tokio::spawn(async move {
*control_signal.write().await = DownloadControl::Restart;
control_notify.notify_waiters();
});
}
pub async fn remove_from_queue(&self, uuid: &str) -> bool {
let mut queue = self.queue.lock().await;
let initial_len = queue.len();
queue.retain(|d| d.uuid != uuid);
let removed = queue.len() < initial_len;
drop(queue);
if !removed {
let mut scheduled = self.scheduled.write().await;
let initial_len = scheduled.len();
scheduled.retain(|d| d.uuid != uuid);
let scheduled_removed = scheduled.len() < initial_len;
drop(scheduled);
if scheduled_removed {
let _ = self.persist_to_disk().await;
return true;
}
}
if removed {
let _ = self.persist_to_disk().await;
}
removed
}
pub async fn remove_finished(&self, uuid: &str) -> bool {
let mut finished = self.finished.write().await;
let initial_len = finished.len();
finished.retain(|d| d.uuid != uuid);
let removed = finished.len() < initial_len;
drop(finished);
if removed {
let _ = self.persist_to_disk().await;
}
removed
}
pub async fn retry_finished(&self, uuid: &str) -> bool {
let mut finished = self.finished.write().await;
if let Some(pos) = finished.iter().position(|d| d.uuid == uuid) {
let mut download = finished.remove(pos);
if download.kind == DownloadKind::Recording {
finished.insert(pos, download);
return false;
}
download.finished = false;
download.size = 0;
download.paused = false;
download.error = None;
download.state = DownloadState::Queued;
download.retry_attempts = 0;
download.next_retry_at = None;
drop(finished);
self.queue.lock().await.push_back(download);
let _ = self.persist_to_disk().await;
true
} else {
false
}
}
pub async fn promote_due_scheduled(&self, now_ts: i64) -> usize {
let mut scheduled = self.scheduled.write().await;
if scheduled.is_empty() {
return 0;
}
let mut due_downloads = Vec::new();
let mut missed_recordings = Vec::new();
scheduled.retain(|download| {
let is_missed = Self::recording_start_missed_window(download, now_ts);
if is_missed {
missed_recordings.push(Self::finalize_missed_recording(download.clone()));
return false;
}
let is_due = download.start_at.is_some_and(|start_at| start_at <= now_ts);
if is_due {
let mut queued = download.clone();
queued.state = DownloadState::Queued;
queued.paused = false;
queued.finished = false;
queued.error = None;
queued.size = 0;
queued.total_size = None;
queued.retry_attempts = 0;
queued.next_retry_at = None;
due_downloads.push(queued);
}
!is_due
});
drop(scheduled);
let had_missed_recordings = !missed_recordings.is_empty();
let missed_count = missed_recordings.len();
if had_missed_recordings {
self.finished.write().await.extend(missed_recordings);
}
if due_downloads.is_empty() {
if had_missed_recordings {
let _ = self.persist_to_disk().await;
return missed_count;
}
return 0;
}
let due_count = due_downloads.len();
let mut queue = self.queue.lock().await;
for download in due_downloads.into_iter().rev() {
queue.push_front(download);
}
drop(queue);
let _ = self.persist_to_disk().await;
due_count
}
pub async fn promote_due_scheduled_now(&self) -> usize { self.promote_due_scheduled(Utc::now().timestamp()).await }
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct FileDownloadRequest {
pub url: String,
pub filename: String,
#[serde(default)]
pub input_name: Option<String>,
#[serde(default)]
pub priority: Option<i8>,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct FileRecordingRequest {
pub url: String,
pub filename: String,
pub start_at: i64,
pub duration_secs: u64,
#[serde(default)]
pub input_name: Option<String>,
#[serde(default)]
pub priority: Option<i8>,
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::time::{timeout, Duration};
fn temp_state_file(name: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos();
std::env::temp_dir().join(format!("tuliprox_{name}_{nanos}.json"))
}
#[tokio::test]
async fn pause_and_resume_keep_active_download_resumable() {
let queue = DownloadQueue::new();
let active = FileDownload {
uuid: "id".to_string(),
file_dir: PathBuf::from("/tmp"),
file_path: PathBuf::from("/tmp/file.mp4"),
filename: "file.mp4".to_string(),
url: reqwest::Url::parse("https://example.com/file.mp4").expect("valid url"),
finished: false,
size: 42,
total_size: Some(100),
paused: false,
error: None,
state: DownloadState::Downloading,
start_at: None,
duration_secs: None,
kind: DownloadKind::Download,
input_name: None,
priority: 0,
retry_attempts: 0,
next_retry_at: None,
};
*queue.active.write().await = Some(active);
queue.pause_active().await;
let paused = queue.active.read().await.clone().expect("active download");
assert_eq!(paused.state, DownloadState::Paused);
assert!(paused.paused);
assert!(!paused.finished);
queue.resume_active().await;
let resumed = queue.active.read().await.clone().expect("active download");
assert_eq!(resumed.state, DownloadState::Downloading);
assert!(!resumed.paused);
assert!(!resumed.finished);
}
#[tokio::test]
async fn cancel_marks_active_download_cancelled_without_finishing_immediately() {
let queue = DownloadQueue::new();
let active = FileDownload {
uuid: "id".to_string(),
file_dir: PathBuf::from("/tmp"),
file_path: PathBuf::from("/tmp/file.mp4"),
filename: "file.mp4".to_string(),
url: reqwest::Url::parse("https://example.com/file.mp4").expect("valid url"),
finished: false,
size: 0,
total_size: None,
paused: false,
error: None,
state: DownloadState::Downloading,
start_at: None,
duration_secs: None,
kind: DownloadKind::Download,
input_name: None,
priority: 0,
retry_attempts: 0,
next_retry_at: None,
};
*queue.active.write().await = Some(active);
queue.cancel_active().await;
let cancelled = queue.active.read().await.clone().expect("active download");
assert_eq!(cancelled.state, DownloadState::Cancelled);
assert!(!cancelled.finished);
assert_eq!(cancelled.error.as_deref(), Some("Cancelled by user"));
assert!(queue.finished.read().await.is_empty());
}
#[tokio::test]
async fn persisted_queue_round_trips_and_requeues_running_downloads() {
let state_file = temp_state_file("download_state");
let queue = DownloadQueue::new_with_state_file(Some(state_file.clone()));
let queued = FileDownload {
uuid: "queued".to_string(),
file_dir: PathBuf::from("/tmp"),
file_path: PathBuf::from("/tmp/queued.mp4"),
filename: "queued.mp4".to_string(),
url: reqwest::Url::parse("https://example.com/queued.mp4").expect("valid url"),
finished: false,
size: 10,
total_size: Some(100),
paused: false,
error: None,
state: DownloadState::Queued,
start_at: None,
duration_secs: None,
kind: DownloadKind::Download,
input_name: None,
priority: 0,
retry_attempts: 0,
next_retry_at: None,
};
let active = FileDownload {
uuid: "active".to_string(),
file_dir: PathBuf::from("/tmp"),
file_path: PathBuf::from("/tmp/active.mp4"),
filename: "active.mp4".to_string(),
url: reqwest::Url::parse("https://example.com/active.mp4").expect("valid url"),
finished: false,
size: 20,
total_size: Some(200),
paused: false,
error: None,
state: DownloadState::Downloading,
start_at: None,
duration_secs: None,
kind: DownloadKind::Download,
input_name: None,
priority: 0,
retry_attempts: 0,
next_retry_at: None,
};
let paused = FileDownload {
uuid: "paused".to_string(),
file_dir: PathBuf::from("/tmp"),
file_path: PathBuf::from("/tmp/paused.mp4"),
filename: "paused.mp4".to_string(),
url: reqwest::Url::parse("https://example.com/paused.mp4").expect("valid url"),
finished: false,
size: 30,
total_size: Some(300),
paused: true,
error: None,
state: DownloadState::Paused,
start_at: None,
duration_secs: None,
kind: DownloadKind::Download,
input_name: None,
priority: 0,
retry_attempts: 0,
next_retry_at: None,
};
queue.queue.lock().await.push_back(queued);
*queue.active.write().await = Some(active);
queue.finished.write().await.push(paused.clone());
queue.persist_to_disk().await.expect("persist state");
let restored = DownloadQueue::new_with_state_file(Some(state_file.clone()));
restored.load_from_disk().await.expect("load state");
assert_eq!(restored.queue.lock().await.len(), 2);
let restored_active = restored.active.read().await.clone();
assert!(restored_active.is_none());
let restored_finished = restored.finished.read().await.clone();
assert_eq!(restored_finished.len(), 1);
assert_eq!(restored_finished[0].uuid, paused.uuid);
let queued_items = restored.queue.lock().await.iter().map(|d| d.uuid.clone()).collect::<Vec<_>>();
assert!(queued_items.iter().any(|id| id == "queued"));
assert!(queued_items.iter().any(|id| id == "active"));
let _ = std::fs::remove_file(state_file);
}
#[tokio::test]
async fn persisted_scheduled_recordings_round_trip_without_becoming_active() {
let state_file = temp_state_file("record_state");
let queue = DownloadQueue::new_with_state_file(Some(state_file.clone()));
let future_start = Utc::now().timestamp().saturating_add(3_600);
let scheduled = FileDownload {
uuid: "recording".to_string(),
file_dir: PathBuf::from("/tmp"),
file_path: PathBuf::from("/tmp/recording.ts"),
filename: "recording.ts".to_string(),
url: reqwest::Url::parse("https://example.com/live/1").expect("valid url"),
finished: false,
size: 0,
total_size: None,
paused: false,
error: None,
state: DownloadState::Scheduled,
start_at: Some(future_start),
duration_secs: Some(5400),
kind: DownloadKind::Recording,
input_name: None,
priority: 0,
retry_attempts: 0,
next_retry_at: None,
};
queue.scheduled.write().await.push(scheduled.clone());
queue.persist_to_disk().await.expect("persist state");
let restored = DownloadQueue::new_with_state_file(Some(state_file.clone()));
restored.load_from_disk().await.expect("load state");
assert!(restored.active.read().await.is_none());
assert_eq!(restored.queue.lock().await.len(), 0);
let restored_scheduled = restored.scheduled.read().await.clone();
assert_eq!(restored_scheduled.len(), 1);
assert_eq!(restored_scheduled[0].uuid, scheduled.uuid);
assert_eq!(restored_scheduled[0].state, DownloadState::Scheduled);
assert_eq!(restored_scheduled[0].start_at, Some(future_start));
assert_eq!(restored_scheduled[0].duration_secs, Some(5400));
assert_eq!(restored_scheduled[0].kind, DownloadKind::Recording);
let _ = std::fs::remove_file(state_file);
}
#[test]
fn recover_loaded_download_requeues_waiting_states() {
let waiting_for_capacity = FileDownload {
uuid: "capacity".to_string(),
file_dir: PathBuf::from("/tmp"),
file_path: PathBuf::from("/tmp/capacity.ts"),
filename: "capacity.ts".to_string(),
url: reqwest::Url::parse("https://example.com/capacity.ts").expect("valid url"),
finished: false,
size: 77,
total_size: Some(99),
paused: false,
error: Some("old error".to_string()),
state: DownloadState::WaitingForCapacity,
start_at: None,
duration_secs: None,
kind: DownloadKind::Download,
input_name: None,
priority: 0,
retry_attempts: 0,
next_retry_at: None,
};
let retry_waiting = FileDownload {
state: DownloadState::RetryWaiting,
..waiting_for_capacity.clone()
};
let restored_waiting_for_capacity = DownloadQueue::recover_loaded_download(waiting_for_capacity);
let restored_retry_waiting = DownloadQueue::recover_loaded_download(retry_waiting);
assert_eq!(restored_waiting_for_capacity.state, DownloadState::Queued);
assert!(!restored_waiting_for_capacity.paused);
assert!(restored_waiting_for_capacity.error.is_none());
assert_eq!(restored_retry_waiting.state, DownloadState::Queued);
assert!(!restored_retry_waiting.paused);
assert!(restored_retry_waiting.error.is_none());
}
#[test]
fn recover_loaded_download_clears_pending_retry_timestamp() {
let retry_waiting = FileDownload {
uuid: "retry".to_string(),
file_dir: PathBuf::from("/tmp"),
file_path: PathBuf::from("/tmp/retry.ts"),
filename: "retry.ts".to_string(),
url: reqwest::Url::parse("https://example.com/retry.ts").expect("valid url"),
finished: false,
size: 12,
total_size: Some(20),
paused: false,
error: Some("retrying".to_string()),
state: DownloadState::RetryWaiting,
start_at: None,
duration_secs: None,
kind: DownloadKind::Download,
input_name: None,
priority: 0,
retry_attempts: 2,
next_retry_at: Some(1_700_000_000),
};
let restored = DownloadQueue::recover_loaded_download(retry_waiting);
assert_eq!(restored.state, DownloadState::Queued);
assert_eq!(restored.retry_attempts, 0);
assert!(restored.next_retry_at.is_none());
}
#[tokio::test]
async fn retry_finished_clears_retry_metadata() {
let queue = DownloadQueue::new();
queue.finished.write().await.push(FileDownload {
uuid: "done".to_string(),
file_dir: PathBuf::from("/tmp"),
file_path: PathBuf::from("/tmp/done.ts"),
filename: "done.ts".to_string(),
url: reqwest::Url::parse("https://example.com/done.ts").expect("valid url"),
finished: true,
size: 0,
total_size: None,
paused: false,
error: Some("Retry limit reached".to_string()),
state: DownloadState::Failed,
start_at: None,
duration_secs: None,
kind: DownloadKind::Download,
input_name: None,
priority: 0,
retry_attempts: 5,
next_retry_at: Some(1_700_000_000),
});
assert!(queue.retry_finished("done").await);
let queued = queue.queue.lock().await.front().cloned().expect("queued download");
assert_eq!(queued.state, DownloadState::Queued);
assert_eq!(queued.retry_attempts, 0);
assert!(queued.next_retry_at.is_none());
assert!(queued.error.is_none());
}
#[tokio::test]
async fn retry_finished_rejects_recordings() {
let queue = DownloadQueue::new();
queue.finished.write().await.push(FileDownload {
uuid: "recording".to_string(),
file_dir: PathBuf::from("/tmp"),
file_path: PathBuf::from("/tmp/recording.ts"),
filename: "recording.ts".to_string(),
url: reqwest::Url::parse("https://example.com/live/recording.ts").expect("valid url"),
finished: true,
size: 0,
total_size: None,
paused: false,
error: Some("Cancelled by user".to_string()),
state: DownloadState::Cancelled,
start_at: Some(1_700_000_000),
duration_secs: Some(300),
kind: DownloadKind::Recording,
input_name: None,
priority: 0,
retry_attempts: 0,
next_retry_at: None,
});
assert!(!queue.retry_finished("recording").await);
assert!(queue.queue.lock().await.is_empty());
assert_eq!(queue.finished.read().await.len(), 1);
}
#[tokio::test]
async fn promote_due_scheduled_moves_only_ready_recordings_to_queue() {
let queue = DownloadQueue::new();
let due = FileDownload {
uuid: "due".to_string(),
file_dir: PathBuf::from("/tmp"),
file_path: PathBuf::from("/tmp/due.ts"),
filename: "due.ts".to_string(),
url: reqwest::Url::parse("https://example.com/live/due").expect("valid url"),
finished: false,
size: 123,
total_size: Some(999),
paused: false,
error: Some("old error".to_string()),
state: DownloadState::Scheduled,
start_at: Some(100),
duration_secs: Some(60),
kind: DownloadKind::Recording,
input_name: None,
priority: 0,
retry_attempts: 0,
next_retry_at: None,
};
let future = FileDownload {
uuid: "future".to_string(),
file_dir: PathBuf::from("/tmp"),
file_path: PathBuf::from("/tmp/future.ts"),
filename: "future.ts".to_string(),
url: reqwest::Url::parse("https://example.com/live/future").expect("valid url"),
finished: false,
size: 0,
total_size: None,
paused: false,
error: None,
state: DownloadState::Scheduled,
start_at: Some(200),
duration_secs: Some(60),
kind: DownloadKind::Recording,
input_name: None,
priority: 0,
retry_attempts: 0,
next_retry_at: None,
};
queue.scheduled.write().await.extend([due, future]);
let promoted = queue.promote_due_scheduled(150).await;
assert_eq!(promoted, 1);
let queued_items = queue.queue.lock().await.iter().cloned().collect::<Vec<_>>();
assert_eq!(queued_items.len(), 1);
assert_eq!(queued_items[0].uuid, "due");
assert_eq!(queued_items[0].state, DownloadState::Queued);
assert_eq!(queued_items[0].size, 0);
assert!(queued_items[0].error.is_none());
let scheduled_items = queue.scheduled.read().await.clone();
assert_eq!(scheduled_items.len(), 1);
assert_eq!(scheduled_items[0].uuid, "future");
}
#[tokio::test]
async fn promote_due_scheduled_marks_expired_recordings_failed() {
let queue = DownloadQueue::new();
let expired = FileDownload {
uuid: "expired".to_string(),
file_dir: PathBuf::from("/tmp"),
file_path: PathBuf::from("/tmp/expired.ts"),
filename: "expired.ts".to_string(),
url: reqwest::Url::parse("https://example.com/live/expired").expect("valid url"),
finished: false,
size: 0,
total_size: None,
paused: false,
error: None,
state: DownloadState::Scheduled,
start_at: Some(100),
duration_secs: Some(60),
kind: DownloadKind::Recording,
input_name: None,
priority: 0,
retry_attempts: 0,
next_retry_at: None,
};
queue.scheduled.write().await.push(expired);
let promoted = queue.promote_due_scheduled(200).await;
assert_eq!(promoted, 1);
assert!(queue.queue.lock().await.is_empty());
let finished = queue.finished.read().await.clone();
assert_eq!(finished.len(), 1);
assert_eq!(finished[0].uuid, "expired");
assert_eq!(finished[0].state, DownloadState::Failed);
assert!(finished[0].finished);
assert_eq!(finished[0].error.as_deref(), Some("Recording window already expired"));
}
#[tokio::test]
async fn load_from_disk_moves_expired_scheduled_recordings_to_finished() {
let state_file = temp_state_file("expired_record_state");
let queue = DownloadQueue::new_with_state_file(Some(state_file.clone()));
let expired = FileDownload {
uuid: "expired".to_string(),
file_dir: PathBuf::from("/tmp"),
file_path: PathBuf::from("/tmp/expired.ts"),
filename: "expired.ts".to_string(),
url: reqwest::Url::parse("https://example.com/live/expired").expect("valid url"),
finished: false,
size: 0,
total_size: None,
paused: false,
error: None,
state: DownloadState::Scheduled,
start_at: Some(100),
duration_secs: Some(60),
kind: DownloadKind::Recording,
input_name: None,
priority: 0,
retry_attempts: 0,
next_retry_at: None,
};
queue.scheduled.write().await.push(expired);
queue.persist_to_disk().await.expect("persist state");
let restored = DownloadQueue::new_with_state_file(Some(state_file.clone()));
restored.load_from_disk().await.expect("load state");
assert!(restored.scheduled.read().await.is_empty());
let finished = restored.finished.read().await.clone();
assert_eq!(finished.len(), 1);
assert_eq!(finished[0].uuid, "expired");
assert_eq!(finished[0].state, DownloadState::Failed);
assert_eq!(finished[0].error.as_deref(), Some("Recording window already expired"));
let _ = std::fs::remove_file(state_file);
}
#[test]
fn recording_uuid_differs_for_same_url_with_different_start_times() {
let download_cfg = VideoDownloadConfig {
directory: "/tmp".to_string(),
organize_into_directories: false,
episode_pattern: None,
headers: std::collections::HashMap::new(),
download_priority: 0,
recording_priority: 0,
reserve_slots_for_users: 0,
max_background_per_provider: 0,
retry_backoff_initial_secs: 3,
retry_backoff_multiplier: 3.0,
retry_backoff_max_secs: 30,
retry_backoff_jitter_percent: 0,
retry_max_attempts: 5,
};
let first = FileDownload::new_recording(
"https://example.com/live/1",
"recording_1.ts",
&download_cfg,
1_700_000_000,
5400,
None,
0,
)
.expect("first recording");
let second = FileDownload::new_recording(
"https://example.com/live/1",
"recording_2.ts",
&download_cfg,
1_700_005_400,
5400,
None,
0,
)
.expect("second recording");
assert_ne!(first.uuid, second.uuid);
}
#[test]
fn download_uuid_differs_for_same_url_with_different_filenames() {
let download_cfg = VideoDownloadConfig {
directory: "/tmp".to_string(),
organize_into_directories: false,
episode_pattern: None,
headers: std::collections::HashMap::new(),
download_priority: 0,
recording_priority: 0,
reserve_slots_for_users: 0,
max_background_per_provider: 0,
retry_backoff_initial_secs: 3,
retry_backoff_multiplier: 3.0,
retry_backoff_max_secs: 30,
retry_backoff_jitter_percent: 0,
retry_max_attempts: 5,
};
let first = FileDownload::new("https://example.com/video.mp4", "first.mp4", &download_cfg, None, 0)
.expect("first download");
let second = FileDownload::new("https://example.com/video.mp4", "second.mp4", &download_cfg, None, 0)
.expect("second download");
assert_ne!(first.uuid, second.uuid);
}
#[tokio::test]
async fn promote_due_scheduled_places_due_recordings_ahead_of_existing_queue_items() {
let queue = DownloadQueue::new();
queue.queue.lock().await.push_back(FileDownload {
uuid: "existing".to_string(),
file_dir: PathBuf::from("/tmp"),
file_path: PathBuf::from("/tmp/existing.ts"),
filename: "existing.ts".to_string(),
url: reqwest::Url::parse("https://example.com/live/existing").expect("valid url"),
finished: false,
size: 0,
total_size: None,
paused: false,
error: None,
state: DownloadState::Queued,
start_at: None,
duration_secs: None,
kind: DownloadKind::Download,
input_name: None,
priority: 0,
retry_attempts: 0,
next_retry_at: None,
});
queue.scheduled.write().await.extend([
FileDownload {
uuid: "due-first".to_string(),
file_dir: PathBuf::from("/tmp"),
file_path: PathBuf::from("/tmp/due-first.ts"),
filename: "due-first.ts".to_string(),
url: reqwest::Url::parse("https://example.com/live/due-first").expect("valid url"),
finished: false,
size: 0,
total_size: None,
paused: false,
error: None,
state: DownloadState::Scheduled,
start_at: Some(100),
duration_secs: Some(60),
kind: DownloadKind::Recording,
input_name: None,
priority: 0,
retry_attempts: 0,
next_retry_at: None,
},
FileDownload {
uuid: "due-second".to_string(),
file_dir: PathBuf::from("/tmp"),
file_path: PathBuf::from("/tmp/due-second.ts"),
filename: "due-second.ts".to_string(),
url: reqwest::Url::parse("https://example.com/live/due-second").expect("valid url"),
finished: false,
size: 0,
total_size: None,
paused: false,
error: None,
state: DownloadState::Scheduled,
start_at: Some(110),
duration_secs: Some(60),
kind: DownloadKind::Recording,
input_name: None,
priority: 0,
retry_attempts: 0,
next_retry_at: None,
},
]);
let promoted = queue.promote_due_scheduled(150).await;
assert_eq!(promoted, 2);
let queued = queue.queue.lock().await.iter().map(|download| download.uuid.clone()).collect::<Vec<_>>();
assert_eq!(queued, vec!["due-first", "due-second", "existing"]);
}
#[tokio::test]
async fn download_slot_wait_queue_signals_matching_waiter_by_id() {
let queue = Arc::new(DownloadSlotWaitQueue::new());
let control_signal = Arc::new(RwLock::new(DownloadControl::None));
let control_notify = Arc::new(Notify::new());
let queue_for_a = Arc::clone(&queue);
let control_signal_for_a = Arc::clone(&control_signal);
let control_notify_for_a = Arc::clone(&control_notify);
let waiter_a = tokio::spawn(async move {
queue_for_a
.wait(
Some(Arc::from("input-a")),
1,
control_signal_for_a.as_ref(),
control_notify_for_a.as_ref(),
)
.await
});
let queue_for_b = Arc::clone(&queue);
let control_signal_for_b = Arc::clone(&control_signal);
let control_notify_for_b = Arc::clone(&control_notify);
let waiter_b = tokio::spawn(async move {
queue_for_b
.wait(
Some(Arc::from("input-b")),
0,
control_signal_for_b.as_ref(),
control_notify_for_b.as_ref(),
)
.await
});
let waiter_b_id = loop {
let snapshots = queue.snapshots().await;
if snapshots.len() == 2 {
break snapshots
.into_iter()
.find(|waiter| waiter.input_name.as_deref() == Some("input-b"))
.map(|waiter| waiter.id)
.expect("waiter id for input-b");
}
tokio::task::yield_now().await;
};
assert!(queue.signal_waiter(waiter_b_id).await);
assert_eq!(
timeout(Duration::from_millis(100), waiter_b).await.expect("waiter_b finished").expect("join ok"),
DownloadWaitOutcome::Signalled
);
*control_signal.write().await = DownloadControl::Cancel;
control_notify.notify_waiters();
assert_eq!(
timeout(Duration::from_millis(100), waiter_a).await.expect("waiter_a finished").expect("join ok"),
DownloadWaitOutcome::Cancelled
);
}
#[tokio::test]
async fn find_duplicate_matches_active_queue_scheduled_and_finished_downloads() {
let queue = DownloadQueue::new();
let candidate = FileDownload {
uuid: "candidate".to_string(),
file_dir: PathBuf::from("/tmp"),
file_path: PathBuf::from("/tmp/movie.mp4"),
filename: "movie.mp4".to_string(),
url: reqwest::Url::parse("https://example.com/movie.mp4").expect("valid url"),
finished: false,
size: 0,
total_size: None,
paused: false,
error: None,
state: DownloadState::Queued,
start_at: None,
duration_secs: None,
kind: DownloadKind::Download,
input_name: None,
priority: 0,
retry_attempts: 0,
next_retry_at: None,
};
*queue.active.write().await = Some(FileDownload {
uuid: "active".to_string(),
..candidate.clone()
});
assert_eq!(queue.find_duplicate(&candidate).await.map(|download| download.uuid), Some("active".to_string()));
*queue.active.write().await = None;
queue.queue.lock().await.push_back(FileDownload {
uuid: "queued".to_string(),
..candidate.clone()
});
assert_eq!(queue.find_duplicate(&candidate).await.map(|download| download.uuid), Some("queued".to_string()));
queue.queue.lock().await.clear();
queue.scheduled.write().await.push(FileDownload {
uuid: "scheduled".to_string(),
state: DownloadState::Scheduled,
kind: DownloadKind::Recording,
start_at: Some(100),
duration_secs: Some(60),
url: reqwest::Url::parse("https://example.com/live/1").expect("valid url"),
file_path: PathBuf::from("/tmp/recording.ts"),
filename: "recording.ts".to_string(),
..candidate.clone()
});
let recording_candidate = FileDownload {
uuid: "recording-candidate".to_string(),
state: DownloadState::Scheduled,
kind: DownloadKind::Recording,
start_at: Some(100),
duration_secs: Some(60),
url: reqwest::Url::parse("https://example.com/live/1").expect("valid url"),
file_path: PathBuf::from("/tmp/recording.ts"),
filename: "recording.ts".to_string(),
..candidate.clone()
};
assert_eq!(
queue.find_duplicate(&recording_candidate).await.map(|download| download.uuid),
Some("scheduled".to_string())
);
queue.scheduled.write().await.clear();
queue.finished.write().await.push(FileDownload {
uuid: "finished".to_string(),
finished: true,
state: DownloadState::Completed,
..candidate.clone()
});
assert_eq!(
queue.find_duplicate(&candidate).await.map(|download| download.uuid),
Some("finished".to_string())
);
}
#[tokio::test]
async fn find_duplicate_allows_distinct_recording_windows() {
let queue = DownloadQueue::new();
queue.scheduled.write().await.push(FileDownload {
uuid: "scheduled".to_string(),
file_dir: PathBuf::from("/tmp"),
file_path: PathBuf::from("/tmp/recording_1.ts"),
filename: "recording_1.ts".to_string(),
url: reqwest::Url::parse("https://example.com/live/1").expect("valid url"),
finished: false,
size: 0,
total_size: None,
paused: false,
error: None,
state: DownloadState::Scheduled,
start_at: Some(100),
duration_secs: Some(60),
kind: DownloadKind::Recording,
input_name: None,
priority: 0,
retry_attempts: 0,
next_retry_at: None,
});
let different_window = FileDownload {
uuid: "candidate".to_string(),
file_dir: PathBuf::from("/tmp"),
file_path: PathBuf::from("/tmp/recording_2.ts"),
filename: "recording_2.ts".to_string(),
url: reqwest::Url::parse("https://example.com/live/1").expect("valid url"),
finished: false,
size: 0,
total_size: None,
paused: false,
error: None,
state: DownloadState::Scheduled,
start_at: Some(200),
duration_secs: Some(60),
kind: DownloadKind::Recording,
input_name: None,
priority: 0,
retry_attempts: 0,
next_retry_at: None,
};
assert!(queue.find_duplicate(&different_window).await.is_none());
}
#[tokio::test]
async fn request_worker_restart_sets_restart_control_and_notifies_waiters() {
let queue = DownloadQueue::new();
let waiter_queue = Arc::clone(&queue.slot_waiters);
let control_signal = Arc::clone(&queue.control_signal);
let control_notify = Arc::clone(&queue.control_notify);
let waiter = tokio::spawn(async move {
waiter_queue
.wait(None, 0, control_signal.as_ref(), control_notify.as_ref())
.await
});
tokio::task::yield_now().await;
queue.request_worker_restart();
assert_eq!(
timeout(Duration::from_millis(100), waiter).await.expect("waiter finished").expect("join ok"),
DownloadWaitOutcome::Restarted
);
assert_eq!(*queue.control_signal.read().await, DownloadControl::Restart);
}
#[tokio::test]
async fn wait_observes_preexisting_control_before_selecting() {
let queue = DownloadQueue::new();
*queue.control_signal.write().await = DownloadControl::Pause;
let outcome = queue
.slot_waiters
.wait(None, 0, queue.control_signal.as_ref(), queue.control_notify.as_ref())
.await;
assert_eq!(outcome, DownloadWaitOutcome::Paused);
assert!(queue.slot_waiters.snapshots().await.is_empty());
}
}