use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use crate::player::state::QueueItemId;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DownloadState {
Queued,
Running,
Done,
Failed(String),
}
impl DownloadState {
pub fn is_settled(&self) -> bool {
matches!(self, Self::Done | Self::Failed(_))
}
}
#[derive(Debug, Clone)]
pub struct Download {
pub id: QueueItemId,
pub track_id: i64,
pub title: String,
pub artist: String,
pub source: PathBuf,
pub dest: PathBuf,
pub total: u64,
pub written: Arc<ByteFeed>,
pub state: DownloadState,
pub bytes_per_second: u64,
}
impl Download {
pub fn fraction(&self) -> Option<f64> {
(self.total > 0)
.then(|| self.written.load(Ordering::Relaxed) as f64 / self.total as f64)
.map(|f| f.clamp(0.0, 1.0))
}
pub fn bytes_written(&self) -> u64 {
self.written.load(Ordering::Relaxed)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Phase {
pub state: PhaseKind,
pub written: u64,
pub total: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PhaseKind {
Queued,
Running,
Done,
Failed,
}
impl Phase {
pub fn is_running(&self) -> bool {
matches!(self.state, PhaseKind::Queued | PhaseKind::Running)
}
}
#[derive(Debug, Default)]
pub struct ByteFeed {
written: AtomicU64,
at: parking_lot::Mutex<()>,
more: parking_lot::Condvar,
}
impl ByteFeed {
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
}
pub fn load(&self, order: Ordering) -> u64 {
self.written.load(order)
}
pub fn set(&self, bytes: u64) {
let _at = self.at.lock();
self.written.store(bytes, Ordering::Release);
self.more.notify_all();
}
pub fn advance(&self, bytes: u64) {
let _at = self.at.lock();
self.written.fetch_add(bytes, Ordering::Release);
self.more.notify_all();
}
pub fn done(&self) {
let _at = self.at.lock();
self.more.notify_all();
}
pub fn wait_past(&self, seen: u64, deadline: Instant) -> u64 {
let mut at = self.at.lock();
let written = self.written.load(Ordering::Acquire);
if written > seen {
return written;
}
let Some(left) = deadline.checked_duration_since(Instant::now()) else {
return written;
};
self.more.wait_for(&mut at, left);
self.written.load(Ordering::Acquire)
}
}
#[derive(Debug, Default)]
pub struct DownloadStore {
entries: parking_lot::RwLock<Vec<Download>>,
version: AtomicU64,
samples: parking_lot::Mutex<HashMap<QueueItemId, Sample>>,
last_sample: parking_lot::Mutex<Option<Instant>>,
figures: AtomicU64,
settled_limit: usize,
}
impl DownloadStore {
pub fn new() -> Arc<Self> {
Arc::new(Self {
entries: parking_lot::RwLock::new(Vec::new()),
version: AtomicU64::new(0),
samples: parking_lot::Mutex::new(HashMap::new()),
last_sample: parking_lot::Mutex::new(None),
figures: AtomicU64::new(0),
settled_limit: 50,
})
}
pub fn version(&self) -> u64 {
self.version.load(Ordering::Acquire)
}
pub fn figures(&self) -> u64 {
self.figures.load(Ordering::Acquire)
}
pub fn all(&self) -> Vec<Download> {
self.entries.read().clone()
}
pub fn get(&self, id: QueueItemId) -> Option<Download> {
self.entries.read().iter().find(|d| d.id == id).cloned()
}
pub fn phase_of(&self, id: QueueItemId) -> Option<Phase> {
self.entries
.read()
.iter()
.find(|d| d.id == id)
.map(|d| Phase {
state: match &d.state {
DownloadState::Queued => PhaseKind::Queued,
DownloadState::Running => PhaseKind::Running,
DownloadState::Done => PhaseKind::Done,
DownloadState::Failed(_) => PhaseKind::Failed,
},
written: d.bytes_written(),
total: d.total,
})
}
pub fn active(&self) -> usize {
self.entries
.read()
.iter()
.filter(|d| !d.state.is_settled())
.count()
}
pub fn queued(&self, download: Download) {
let mut entries = self.entries.write();
entries.retain(|d| d.id != download.id);
entries.insert(0, download);
drop(entries);
self.settle();
}
pub fn started(&self, id: QueueItemId, total: u64, written: Arc<ByteFeed>) {
let mut entries = self.entries.write();
if let Some(entry) = entries.iter_mut().find(|d| d.id == id) {
entry.total = total;
entry.written = written;
entry.state = DownloadState::Running;
}
drop(entries);
self.bump();
}
pub fn finished(&self, id: QueueItemId) {
self.settle_one(id, DownloadState::Done);
}
pub fn failed(&self, id: QueueItemId, reason: String) {
self.settle_one(id, DownloadState::Failed(reason));
}
pub fn clear_settled(&self) {
let mut entries = self.entries.write();
let before = entries.len();
entries.retain(|d| !d.state.is_settled());
let changed = entries.len() != before;
drop(entries);
if changed {
self.bump();
}
}
fn settle_one(&self, id: QueueItemId, state: DownloadState) {
let mut entries = self.entries.write();
if let Some(entry) = entries.iter_mut().find(|d| d.id == id) {
entry.state = state;
entry.bytes_per_second = 0;
}
drop(entries);
self.samples.lock().remove(&id);
self.figures.fetch_add(1, Ordering::Release);
self.settle();
}
fn settle(&self) {
let mut entries = self.entries.write();
let (mut running, settled): (Vec<_>, Vec<_>) =
entries.drain(..).partition(|d| !d.state.is_settled());
running.extend(settled.into_iter().take(self.settled_limit));
*entries = running;
drop(entries);
self.bump();
}
fn bump(&self) {
self.version.fetch_add(1, Ordering::Release);
crate::signal::engine_changed().bump();
}
}
#[derive(Debug)]
struct Sample {
at: Instant,
bytes: u64,
bps: f64,
}
const RATE_SMOOTHING: f64 = 0.3;
const MIN_SAMPLE_GAP: Duration = Duration::from_millis(250);
impl DownloadStore {
pub fn progressed(&self) {
let now = Instant::now();
{
let mut last = self.last_sample.lock();
if last.is_some_and(|at| now.saturating_duration_since(at) < MIN_SAMPLE_GAP) {
return;
}
*last = Some(now);
}
self.sample_rates_at(now);
}
fn sample_rates_at(&self, now: Instant) {
let mut entries = self.entries.write();
let mut samples = self.samples.lock();
for entry in entries.iter_mut() {
if entry.state.is_settled() {
entry.bytes_per_second = 0;
samples.remove(&entry.id);
continue;
}
let bytes = entry.written.load(Ordering::Relaxed);
match samples.get_mut(&entry.id) {
Some(previous) => {
let elapsed = now.saturating_duration_since(previous.at);
if elapsed < MIN_SAMPLE_GAP {
entry.bytes_per_second = previous.bps as u64;
continue;
}
let moved = bytes.saturating_sub(previous.bytes) as f64;
let instant = moved / elapsed.as_secs_f64();
previous.bps = previous.bps * (1.0 - RATE_SMOOTHING) + instant * RATE_SMOOTHING;
previous.at = now;
previous.bytes = bytes;
entry.bytes_per_second = previous.bps as u64;
}
None => {
samples.insert(
entry.id,
Sample {
at: now,
bytes,
bps: 0.0,
},
);
entry.bytes_per_second = 0;
}
}
}
let live: std::collections::HashSet<QueueItemId> = entries.iter().map(|e| e.id).collect();
samples.retain(|id, _| live.contains(id));
self.figures.fetch_add(1, Ordering::Release);
crate::signal::engine_changed().bump();
}
}
pub fn store() -> &'static Arc<DownloadStore> {
static STORE: std::sync::OnceLock<Arc<DownloadStore>> = std::sync::OnceLock::new();
STORE.get_or_init(DownloadStore::new)
}
#[cfg(test)]
mod tests {
use super::*;
fn download(title: &str) -> Download {
Download {
id: QueueItemId::new(),
track_id: 1,
title: title.into(),
artist: "Artist".into(),
source: PathBuf::from(format!("/cache/{title}.opus.part")),
dest: PathBuf::from(format!("/cache/{title}.opus")),
total: 0,
written: ByteFeed::new(),
state: DownloadState::Queued,
bytes_per_second: 0,
}
}
#[test]
fn a_transfer_runs_then_settles() {
let store = DownloadStore::new();
let entry = download("train");
let id = entry.id;
store.queued(entry);
assert_eq!(store.active(), 1);
let written = ByteFeed::new();
store.started(id, 400, written.clone());
written.set(100);
assert_eq!(store.all()[0].fraction(), Some(0.25));
store.finished(id);
assert_eq!(store.active(), 0);
assert_eq!(store.all()[0].state, DownloadState::Done);
}
#[test]
fn progress_does_not_move_the_version() {
let store = DownloadStore::new();
let entry = download("train");
let id = entry.id;
store.queued(entry);
let written = ByteFeed::new();
store.started(id, 1000, written.clone());
let before = store.version();
written.set(500);
assert_eq!(store.version(), before);
assert_eq!(store.all()[0].bytes_written(), 500);
}
#[test]
fn no_content_length_means_no_fraction() {
let store = DownloadStore::new();
let entry = download("chunked");
let id = entry.id;
store.queued(entry);
store.started(id, 0, {
let feed = ByteFeed::new();
feed.set(9000);
feed
});
assert_eq!(store.all()[0].fraction(), None);
assert_eq!(store.all()[0].bytes_written(), 9000);
}
#[test]
fn fetching_the_same_item_again_restarts_its_row() {
let store = DownloadStore::new();
let first = download("train");
let id = first.id;
store.queued(first);
store.finished(id);
let mut again = download("train");
again.id = id;
store.queued(again);
assert_eq!(store.all().len(), 1);
assert_eq!(store.all()[0].state, DownloadState::Queued);
}
#[test]
fn running_transfers_sort_above_settled_ones() {
let store = DownloadStore::new();
let done = download("done");
let done_id = done.id;
store.queued(done);
let running = download("running");
store.queued(running);
store.finished(done_id);
let all = store.all();
assert_eq!(all[0].title, "running");
assert_eq!(all[1].title, "done");
}
#[test]
fn a_failure_keeps_its_reason() {
let store = DownloadStore::new();
let entry = download("gone");
let id = entry.id;
store.queued(entry);
store.failed(id, "server returned 404".into());
assert_eq!(
store.all()[0].state,
DownloadState::Failed("server returned 404".into())
);
}
#[test]
fn a_rate_needs_two_readings_and_a_gap_between_them() {
let store = DownloadStore::new();
let entry = download("train");
let (id, written) = (entry.id, entry.written.clone());
store.queued(entry);
store.started(id, 1_000_000, written.clone());
let start = Instant::now();
store.sample_rates_at(start);
assert_eq!(
store.all()[0].bytes_per_second,
0,
"one reading is not a rate"
);
written.set(100_000);
store.sample_rates_at(start + Duration::from_millis(50));
assert_eq!(store.all()[0].bytes_per_second, 0);
store.sample_rates_at(start + Duration::from_secs(1));
let bps = store.all()[0].bytes_per_second;
assert!(bps > 0, "a rate should have been worked out, got {bps}");
assert!(bps < 100_000, "and smoothed rather than taken whole: {bps}");
}
#[test]
fn a_settled_transfer_has_no_rate() {
let store = DownloadStore::new();
let entry = download("train");
let (id, written) = (entry.id, entry.written.clone());
store.queued(entry);
store.started(id, 1000, written.clone());
let start = Instant::now();
store.sample_rates_at(start);
written.set(500);
store.sample_rates_at(start + Duration::from_secs(1));
assert!(store.all()[0].bytes_per_second > 0);
store.finished(id);
store.sample_rates_at(start + Duration::from_secs(2));
assert_eq!(store.all()[0].bytes_per_second, 0);
}
#[test]
fn a_transfer_can_be_found_by_its_queue_item() {
let store = DownloadStore::new();
let entry = download("train");
let (id, written) = (entry.id, entry.written.clone());
store.queued(entry);
store.started(id, 400, written.clone());
written.set(100);
let phase = store.phase_of(id).expect("the transfer is there");
assert_eq!(phase.state, PhaseKind::Running);
assert_eq!(phase.written, 100);
assert_eq!(phase.total, 400);
assert!(phase.is_running());
assert!(
store.phase_of(QueueItemId::new()).is_none(),
"and only that one"
);
}
#[test]
fn clearing_settled_leaves_the_running_alone() {
let store = DownloadStore::new();
let done = download("done");
let done_id = done.id;
store.queued(done);
store.queued(download("running"));
store.finished(done_id);
store.clear_settled();
let all = store.all();
assert_eq!(all.len(), 1);
assert_eq!(all[0].title, "running");
}
}