use std::collections::{HashSet, VecDeque};
use std::time::Instant;
use crate::analysis::{CopyOpts, Plan};
use crate::db::{MasterDb, SafetyOpts, rekordbox_running};
use crate::library::{TrackRow, load_rows};
use super::data::{dst_visible, src_visible};
pub const DURATION_TOL_SECS: i64 = 1;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Focus {
Src,
Dst,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Screen {
Transfer,
Shop,
Pending,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ShopFocus {
Tracks,
Offers,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum InputMode {
Normal,
Search(Focus),
Confirm(ConfirmKind),
Help,
ShopSearch,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ConfirmKind {
Transfer,
ImportRows,
ForceApply,
ClearQueue,
}
pub struct ImportBatch {
pub rows: Vec<crate::import::NewContent>,
pub entry_ids: Vec<i64>,
pub scroll: u16,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ShopTrackState {
Untouched,
Queued,
Done(usize),
}
pub enum ShopState {
Idle,
Searching {
since: Instant,
what: String,
done: usize,
total: usize,
specs: Vec<crate::acquire::shop::QuerySpec>,
},
Results {
groups: Box<Vec<crate::acquire::shop::GroupOutcome>>,
cursor: usize,
specs: Vec<crate::acquire::shop::QuerySpec>,
},
Failed(String),
}
#[derive(Default)]
pub enum FetchState {
#[default]
Idle,
Running {
since: Instant,
what: String,
note: Option<String>,
},
Done {
paths: Vec<std::path::PathBuf>,
queued: Option<i64>,
},
Failed(String),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OfferFetch {
None,
Queued,
Running,
Done,
}
#[derive(Debug, PartialEq, Eq)]
struct QueuedFetch {
what: String,
src: Option<String>,
item: crate::acquire::ItemRef,
}
#[derive(Default)]
pub struct FetchQueue(VecDeque<QueuedFetch>);
impl FetchQueue {
fn push(&mut self, q: QueuedFetch) {
self.0.push_back(q);
}
fn abandon_all(&mut self) {
self.0.clear();
}
fn ahead_of(&self, item: &crate::acquire::ItemRef) -> Option<usize> {
self.0.iter().position(|q| &q.item == item)
}
fn finish_front(&mut self) -> Option<QueuedFetch> {
self.0.pop_front()
}
fn label(&self) -> Option<&str> {
self.0.front().map(|q| q.what.as_str())
}
pub fn iter(&self) -> impl Iterator<Item = (&str, bool)> {
self.0
.iter()
.enumerate()
.map(|(i, q)| (q.what.as_str(), i == 0))
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl ShopState {
pub fn len(&self) -> usize {
match self {
Self::Results { groups, .. } => groups.iter().map(|g| g.outcome.offers.len()).sum(),
_ => 0,
}
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn move_cursor(&mut self, delta: isize) {
let n = self.len();
if let Self::Results { cursor, .. } = self {
if n == 0 {
*cursor = 0;
return;
}
*cursor = (*cursor as isize + delta).clamp(0, n as isize - 1) as usize;
}
}
pub fn flattened(
&self,
) -> impl Iterator<
Item = (
&crate::acquire::shop::GroupOutcome,
&crate::acquire::shop::RankedOffer,
),
> {
let groups: &[crate::acquire::shop::GroupOutcome] = match self {
Self::Results { groups, .. } => groups,
_ => &[],
};
groups
.iter()
.flat_map(|g| g.outcome.offers.iter().map(move |o| (g, o)))
}
pub fn selected(&self) -> Option<&crate::acquire::shop::RankedOffer> {
self.selected_with_group().map(|(_, o)| o)
}
pub fn selected_with_group(
&self,
) -> Option<(
&crate::acquire::shop::GroupOutcome,
&crate::acquire::shop::RankedOffer,
)> {
let cursor = match self {
Self::Results { cursor, .. } => *cursor,
_ => return None,
};
self.flattened().nth(cursor)
}
pub fn specs(&self) -> Option<&[crate::acquire::shop::QuerySpec]> {
match self {
Self::Searching { specs, .. } | Self::Results { specs, .. } => Some(specs),
_ => None,
}
}
}
fn hidden_selected(rows: &[TrackRow], col: &ColumnState) -> usize {
let shown = col
.visible
.iter()
.filter_map(|&i| rows.get(i))
.filter(|r| col.selected.contains(&r.id))
.count();
col.selected.len().saturating_sub(shown)
}
fn basket_rows(
rows: &[TrackRow],
selected: &HashSet<String>,
cap: usize,
pending: impl Fn(&TrackRow) -> bool,
) -> (Vec<TrackRow>, usize) {
let all: Vec<TrackRow> = rows
.iter()
.filter(|r| selected.contains(&r.id) && pending(r))
.cloned()
.collect();
let total = all.len();
(all.into_iter().take(cap).collect(), total)
}
fn track_state(shop: &ShopState, queued: &[String], src_id: &str) -> ShopTrackState {
let found = shop
.flattened()
.filter(|(g, _)| g.src_id.as_deref() == Some(src_id))
.count();
if found > 0 {
return ShopTrackState::Done(found);
}
if let ShopState::Results { groups, .. } = shop
&& groups.iter().any(|g| g.src_id.as_deref() == Some(src_id))
{
return ShopTrackState::Done(0);
}
if queued.iter().any(|q| q == src_id) {
return ShopTrackState::Queued;
}
ShopTrackState::Untouched
}
#[derive(Clone, Copy, Default, Debug)]
pub struct DstFilters {
pub auto: bool,
pub fuzzy_from_src: bool,
}
#[derive(Clone, Debug, Default)]
pub struct ColumnState {
pub query: String,
pub visible: Vec<usize>,
pub cursor: usize,
pub selected: HashSet<String>,
}
impl ColumnState {
pub fn clamp_cursor(&mut self) {
if self.visible.is_empty() {
self.cursor = 0;
} else if self.cursor >= self.visible.len() {
self.cursor = self.visible.len() - 1;
}
}
pub fn move_by(&mut self, delta: isize) {
if self.visible.is_empty() {
self.cursor = 0;
return;
}
let n = self.visible.len() as isize;
let mut c = self.cursor as isize + delta;
if c < 0 {
c = 0;
}
if c >= n {
c = n - 1;
}
self.cursor = c as usize;
}
pub fn jump_top(&mut self) {
self.cursor = 0;
}
pub fn jump_bottom(&mut self) {
if !self.visible.is_empty() {
self.cursor = self.visible.len() - 1;
}
}
}
#[derive(Default)]
pub struct StatusLine {
pub text: String,
pub level: StatusLevel,
}
#[derive(Default, Clone, Copy, PartialEq, Eq)]
pub enum StatusLevel {
#[default]
Info,
Warn,
Err,
Ok,
}
impl StatusLine {
pub fn info(&mut self, msg: impl Into<String>) {
self.text = msg.into();
self.level = StatusLevel::Info;
}
pub fn ok(&mut self, msg: impl Into<String>) {
self.text = msg.into();
self.level = StatusLevel::Ok;
}
pub fn warn(&mut self, msg: impl Into<String>) {
self.text = msg.into();
self.level = StatusLevel::Warn;
}
pub fn err(&mut self, msg: impl Into<String>) {
self.text = msg.into();
self.level = StatusLevel::Err;
}
}
pub struct PendingBatch {
pub plans: Vec<Plan>,
pub failures: Vec<(String, String)>, pub scroll: u16,
}
pub struct ForceBatch {
pub entry_id: i64,
pub dst_content_id: String,
pub plan: Box<Plan>,
pub verdict: crate::fingerprint::Verdict,
pub scroll: u16,
}
pub struct App {
pub db: MasterDb,
pub safety: SafetyOpts,
pub rows: Vec<TrackRow>,
pub rb_running: bool,
pub rb_last_polled: Instant,
pub src: ColumnState,
pub dst: ColumnState,
pub focus: Focus,
pub screen: Screen,
queue_return: Screen,
pub shop_focus: ShopFocus,
pub shop_list: ColumnState,
pub mode: InputMode,
pub copy_opts: CopyOpts,
pub dst_filters: DstFilters,
pub status: StatusLine,
pub worker: Option<super::worker::Worker>,
pub shop: ShopState,
pub fetch: FetchState,
pub fetch_queue: FetchQueue,
fetched: HashSet<crate::acquire::ItemRef>,
shop_queued: Vec<String>,
pub cfg: crate::config::Config,
pub pending: Option<PendingBatch>,
pub unresolved_errors: bool,
pub queue: super::queue::QueueState,
pub store: Option<crate::pending::PendingStore>,
pub import_batch: Option<ImportBatch>,
pub force_batch: Option<ForceBatch>,
pub help_scroll: u16,
pub quit_pending: bool,
pub should_quit: bool,
}
impl App {
pub fn new(db: MasterDb, safety: SafetyOpts) -> anyhow::Result<Self> {
let cfg_path = crate::paths::config_path(None)?;
let cfg = crate::config::Config::load(&cfg_path).unwrap_or_default();
let creds = crate::config::Credentials::load(&crate::paths::credentials_path()?)
.unwrap_or_default();
let worker = super::worker::Worker::spawn(&cfg, &creds).ok();
let rows = load_rows(&db)?;
let mut app = App {
db,
safety,
rows,
rb_running: rekordbox_running(),
rb_last_polled: Instant::now(),
src: ColumnState::default(),
dst: ColumnState::default(),
focus: Focus::Src,
screen: Screen::Transfer,
queue_return: Screen::Transfer,
shop_focus: ShopFocus::Tracks,
shop_list: ColumnState::default(),
mode: InputMode::Normal,
copy_opts: CopyOpts::default(),
dst_filters: DstFilters::default(),
status: StatusLine::default(),
worker,
shop: ShopState::Idle,
fetch: FetchState::Idle,
fetch_queue: FetchQueue::default(),
fetched: HashSet::new(),
shop_queued: Vec::new(),
cfg,
pending: None,
unresolved_errors: false,
queue: super::queue::QueueState::default(),
store: crate::pending::PendingStore::open().ok(),
import_batch: None,
force_batch: None,
help_scroll: 0,
quit_pending: false,
should_quit: false,
};
app.recompute_visible();
app.status
.info(format!("Loaded {} tracks.", app.rows.len()));
Ok(app)
}
pub fn recompute_visible(&mut self) {
self.src.visible = src_visible(&self.rows, &self.src.query);
self.src.clamp_cursor();
self.shop_list.visible = src_visible(&self.rows, &self.shop_list.query);
self.shop_list.clamp_cursor();
let src = self
.src
.visible
.get(self.src.cursor)
.and_then(|&i| self.rows.get(i))
.cloned();
self.dst.visible = dst_visible(
&self.rows,
&self.dst.query,
self.dst_filters.auto,
src.as_ref(),
self.dst_filters.fuzzy_from_src,
DURATION_TOL_SECS,
);
self.dst.clamp_cursor();
}
pub fn reload_db(&mut self) -> anyhow::Result<()> {
self.rows = load_rows(&self.db)?;
let existing: HashSet<&str> = self.rows.iter().map(|r| r.id.as_str()).collect();
self.dst
.selected
.retain(|id| existing.contains(id.as_str()));
self.shop_list
.selected
.retain(|id| existing.contains(id.as_str()));
self.recompute_visible();
Ok(())
}
pub fn poll_rekordbox_if_due(&mut self) {
if self.rb_last_polled.elapsed() >= std::time::Duration::from_secs(1) {
self.rb_running = rekordbox_running();
self.rb_last_polled = Instant::now();
}
}
pub fn pump_worker(&mut self) {
let Some(worker) = self.worker.as_mut() else {
return;
};
for update in worker.drain() {
match update {
super::worker::Update::Started => {}
super::worker::Update::Progress {
done: d,
total: t,
label,
} => {
if let ShopState::Searching {
done, total, what, ..
} = &mut self.shop
{
*done = d;
*total = t;
if t > 1 && !label.is_empty() {
*what = format!("{label} ({}/{t})", d + 1);
}
}
}
super::worker::Update::Note(line) => {
if let FetchState::Running { note, .. } = &mut self.fetch {
*note = Some(line);
}
}
super::worker::Update::Finished(new_groups) => {
for g in new_groups.iter() {
if let Some(id) = &g.src_id {
self.shop_queued.retain(|q| q != id);
}
}
let found: usize = new_groups.iter().map(|g| g.outcome.offers.len()).sum();
let mut failed: Vec<String> = Vec::new();
for g in new_groups.iter() {
for r in g.outcome.failures() {
if let Some(e) = &r.error {
let msg = format!("{}: {e}", r.backend);
if !failed.contains(&msg) {
failed.push(msg);
}
}
}
}
let specs = self.shop.specs().unwrap_or(&[]).to_vec();
let mut groups: Vec<crate::acquire::shop::GroupOutcome> =
match std::mem::replace(&mut self.shop, ShopState::Idle) {
ShopState::Results { groups, .. } => *groups,
_ => Vec::new(),
};
let cursor_at = groups.iter().map(|g| g.outcome.offers.len()).sum::<usize>();
groups.extend(*new_groups);
let total: usize = groups.iter().map(|g| g.outcome.offers.len()).sum();
self.shop = ShopState::Results {
groups: Box::new(groups),
cursor: cursor_at.min(total.saturating_sub(1)),
specs,
};
let pending = self.worker.as_ref().map(|w| w.outstanding()).unwrap_or(0);
let tail = if pending > 0 {
format!(" — {pending} still queued")
} else {
String::new()
};
match (found, failed.is_empty()) {
(0, true) => self.status.warn(format!("no offers found{tail}.")),
(0, false) => self
.status
.err(format!("no offers — {}{tail}", failed.join("; "))),
(n, true) => self.status.ok(format!("{n} more offers{tail}.")),
(n, false) => self.status.warn(format!(
"{n} more offers, degraded — {}{tail}",
failed.join("; ")
)),
}
}
super::worker::Update::Fetched(result) => match *result {
Ok(files) => {
let paths: Vec<std::path::PathBuf> =
files.iter().map(|f| f.path.clone()).collect();
let lossy = files.iter().any(|f| !f.format.is_lossless());
let done = self.finish_front_fetch();
if let Some(q) = &done {
self.fetched.insert(q.item.clone());
}
let queued = self.queue_transfer_for(done.and_then(|q| q.src), &paths);
match (queued, lossy) {
(Some(id), _) => self.status.ok(format!(
"downloaded and queued transfer #{id} — import, then `pending --apply`"
)),
(None, true) => self
.status
.warn("downloaded, but it is a lossy transcode".to_string()),
(None, false) => {
self.status.ok(format!("downloaded {} file(s)", paths.len()))
}
}
if self.fetch_queue.is_empty() {
self.fetch = FetchState::Done { paths, queued };
} else {
self.note_downloads_left();
}
}
Err(why) => {
self.finish_front_fetch();
self.status.err(format!("download failed: {why}"));
if self.fetch_queue.is_empty() {
self.fetch = FetchState::Failed(why);
} else {
self.note_downloads_left();
}
}
},
super::worker::Update::Probed(r) => {
let (entry_id, generation, result) = *r;
self.on_probed(entry_id, generation, result);
}
super::worker::Update::Fingerprinted(r) => {
let (entry_id, generation, result) = *r;
self.on_fingerprinted(entry_id, generation, result);
}
super::worker::Update::Failed(why) => {
self.status.err(format!("worker failed: {why}"));
self.queue.abandon_in_flight(&why);
if matches!(self.fetch, FetchState::Running { .. }) {
self.fetch_queue.abandon_all();
self.fetch = FetchState::Failed(why);
} else {
self.shop = ShopState::Failed(why);
}
}
}
}
}
fn spec_for(&self, row: &TrackRow) -> Option<crate::acquire::shop::QuerySpec> {
let title = row.title.trim().to_string();
if title.is_empty() {
return None;
}
let artist = Some(row.artist.trim().to_string()).filter(|a| !a.is_empty());
Some(crate::acquire::shop::QuerySpec {
label: format!("{} — {title}", artist.as_deref().unwrap_or("?")),
src_id: Some(row.id.clone()),
query: crate::acquire::types::SearchQuery {
title,
artist,
duration_secs: row.length,
limit: self.cfg.search.limit,
..Default::default()
},
})
}
pub fn open_shop(&mut self) -> bool {
self.screen = Screen::Shop;
let Some(row) = self.current_src().cloned() else {
self.shop_focus = ShopFocus::Tracks;
self.status
.info("pick a track and press 's' to search for it.");
return false;
};
self.focus_shop_list_on(&row.id);
self.shop_track()
}
pub fn shop_track(&mut self) -> bool {
let Some(row) = self.current_shop_track().cloned() else {
self.status.warn("no track highlighted.");
return false;
};
if let Some(i) = self.first_offer_index_for(&row.id) {
let found = self
.shop
.flattened()
.filter(|(g, _)| g.src_id.as_deref() == Some(row.id.as_str()))
.count();
if let ShopState::Results { cursor, .. } = &mut self.shop {
*cursor = i;
}
self.status.info(format!(
"showing the {found} offer(s) already found for {} — 'r' re-runs it.",
row.title
));
return true;
}
if self.shop_queued.contains(&row.id) {
self.status.info(format!(
"{} is already queued — {} search(es) to go.",
row.title,
self.shop_outstanding()
));
return true;
}
self.enqueue_shop(&[row])
}
fn focus_shop_list_on(&mut self, id: &str) {
if let Some(p) = self.position_in_shop_list(id) {
self.shop_list.cursor = p;
return;
}
self.shop_list.query.clear();
self.recompute_visible();
if let Some(p) = self.position_in_shop_list(id) {
self.shop_list.cursor = p;
}
}
fn position_in_shop_list(&self, id: &str) -> Option<usize> {
self.shop_list
.visible
.iter()
.position(|&i| self.rows.get(i).is_some_and(|r| r.id == id))
}
pub fn toggle_shop_focus(&mut self) {
self.shop_focus = match self.shop_focus {
ShopFocus::Tracks => ShopFocus::Offers,
ShopFocus::Offers => ShopFocus::Tracks,
};
}
pub fn shop_move(&mut self, delta: isize) {
match self.shop_focus {
ShopFocus::Tracks => self.shop_list.move_by(delta),
ShopFocus::Offers => self.shop.move_cursor(delta),
}
}
pub fn shop_jump(&mut self, top: bool) {
match (self.shop_focus, top) {
(ShopFocus::Tracks, true) => self.shop_list.jump_top(),
(ShopFocus::Tracks, false) => self.shop_list.jump_bottom(),
(ShopFocus::Offers, true) => self.shop_move(isize::MIN / 2),
(ShopFocus::Offers, false) => self.shop_move(isize::MAX / 2),
}
}
pub fn toggle_basket(&mut self) {
if self.shop_focus != ShopFocus::Tracks {
self.status
.info("space fills the basket — Tab back to the track list.");
return;
}
let Some(id) = self.current_shop_track().map(|r| r.id.clone()) else {
return;
};
if !self.shop_list.selected.remove(&id) {
self.shop_list.selected.insert(id);
}
}
pub fn shop_track_state(&self, src_id: &str) -> ShopTrackState {
track_state(&self.shop, &self.shop_queued, src_id)
}
pub fn enqueue_shop(&mut self, rows: &[TrackRow]) -> bool {
let specs: Vec<_> = rows
.iter()
.filter(|r| self.is_pending(&r.id))
.filter_map(|r| self.spec_for(r))
.collect();
if specs.is_empty() {
self.status
.info("nothing new to search — those are already done or queued.");
return false;
}
self.submit_shop_queued(specs)
}
fn is_pending(&self, src_id: &str) -> bool {
self.first_offer_index_for(src_id).is_none()
&& !self.shop_queued.iter().any(|q| q == src_id)
}
fn first_offer_index_for(&self, src_id: &str) -> Option<usize> {
self.shop
.flattened()
.position(|(g, _)| g.src_id.as_deref() == Some(src_id))
}
pub fn start_shop(&mut self) -> bool {
let id = match self.shop_focus {
ShopFocus::Offers => self
.shop
.selected_with_group()
.and_then(|(g, _)| g.src_id.clone())
.or_else(|| self.current_shop_track().map(|r| r.id.clone())),
ShopFocus::Tracks => self.current_shop_track().map(|r| r.id.clone()),
};
let Some(row) = id.and_then(|id| self.rows.iter().find(|r| r.id == id).cloned()) else {
self.status.warn("nothing to re-search.");
return false;
};
let Some(spec) = self.spec_for(&row) else {
self.status.warn("that track has no title to search for.");
return false;
};
self.drop_group_for(&row.id);
self.shop_queued.retain(|id| id != &row.id);
self.submit_shop_queued(vec![spec])
}
fn drop_group_for(&mut self, src_id: &str) {
if let ShopState::Results {
groups,
cursor,
specs,
} = &mut self.shop
{
groups.retain(|g| g.src_id.as_deref() != Some(src_id));
specs.retain(|s| s.src_id.as_deref() != Some(src_id));
let n: usize = groups.iter().map(|g| g.outcome.offers.len()).sum();
*cursor = (*cursor).min(n.saturating_sub(1));
}
}
pub fn shop_selected(&mut self) -> bool {
if self.shop_list.selected.is_empty() {
self.status
.warn("the basket is empty — press space on the tracks you want, then 'S'.");
return false;
}
let cap = self.cfg.search.bulk_max.max(1);
let (rows, total) = basket_rows(&self.rows, &self.shop_list.selected, cap, |r| {
self.is_pending(&r.id)
});
let queued = rows.len();
let ok = self.enqueue_shop(&rows);
if ok && total > queued {
let text = self.status.text.clone();
self.status.warn(format!(
"{text} {} left in the basket — press 'S' again for the next {cap}.",
total - queued
));
}
ok
}
pub fn basket_hidden(&self) -> usize {
hidden_selected(&self.rows, &self.shop_list)
}
pub fn dst_hidden(&self) -> usize {
hidden_selected(&self.rows, &self.dst)
}
pub fn shop_offer_src(&self) -> Option<&str> {
self.shop
.selected_with_group()
.and_then(|(g, _)| g.src_id.as_deref())
}
fn submit_shop_queued(&mut self, specs: Vec<crate::acquire::shop::QuerySpec>) -> bool {
let opts = crate::acquire::shop::SearchOpts {
timeout: std::time::Duration::from_secs(self.cfg.search.timeout_secs.max(1)),
enrich_top_n: self.cfg.search.enrich_top_n,
..Default::default()
};
let Some(worker) = self.worker.as_mut() else {
self.status
.err("the search thread is not running; restart the TUI.");
return false;
};
if !worker.submit(super::worker::Job::Shop {
specs: specs.clone(),
opts: Box::new(opts),
}) {
self.status.err("could not start the search.");
return false;
}
let outstanding = worker.outstanding();
for spec in &specs {
if let Some(id) = &spec.src_id {
self.shop_queued.push(id.clone());
}
}
let what = match specs.len() {
1 => specs[0].label.clone(),
n => format!("{n} tracks"),
};
self.status.info(if outstanding > 1 {
format!("queued {what} — {outstanding} search(es) pending.")
} else {
format!("searching for {what} …")
});
if self.shop.is_empty() {
let total = specs.len();
self.shop = ShopState::Searching {
since: Instant::now(),
what,
done: 0,
total,
specs,
};
} else if let ShopState::Results { specs: have, .. } = &mut self.shop {
have.extend(specs);
}
self.screen = Screen::Shop;
true
}
pub fn open_queue(&mut self) {
if self.screen != Screen::Pending {
self.queue_return = self.screen;
}
self.screen = Screen::Pending;
self.reload_queue();
}
pub fn close_queue(&mut self) {
self.screen = self.queue_return;
}
pub fn reload_queue(&mut self) {
let Some(store) = self.store.as_ref() else {
self.status
.err("the pending queue could not be opened — downloads cannot be finished here.");
return;
};
let swept = store.sweep(&self.db);
let listed = self.store.as_ref().map(|s| s.all());
match swept {
Ok(retired) => {
for (id, state, why) in retired {
self.status.warn(format!("#{id} → {state}: {why}"));
}
}
Err(e) => self.status.warn(format!("sweep failed: {e}")),
}
match listed {
Some(Ok(entries)) => {
let present: std::collections::HashSet<i64> = entries
.iter()
.filter(|e| {
crate::pending::find_imported_row(&self.db, &e.acquired_path)
.ok()
.flatten()
.is_some()
})
.map(|e| e.id)
.collect();
self.queue.reload(entries);
self.queue.set_rows_present(present);
}
Some(Err(e)) => self.status.err(format!("could not read the queue: {e}")),
None => {}
}
}
pub fn start_import(&mut self) {
if !self.cfg.import.insert_content_rows {
self.status.err(
"creating rekordbox rows is off — set insert_content_rows = true under [import] \
in your config, or import the files into rekordbox by hand.",
);
return;
}
if self.queue.any_in_flight() {
self.status.info("already working — give it a moment.");
return;
}
let needing: Vec<(i64, std::path::PathBuf)> = self
.queue
.entries
.iter()
.filter(|e| e.state == crate::pending::State::AwaitingImport)
.filter(|e| {
crate::pending::find_imported_row(&self.db, &e.acquired_path)
.ok()
.flatten()
.is_none()
})
.map(|e| (e.id, e.acquired_path.clone()))
.collect();
if needing.is_empty() {
self.status
.info("every queued download already has a rekordbox row.");
return;
}
let generation = self.queue.next_generation();
let mut sent = 0;
for (entry_id, path) in needing {
if !path.exists() {
self.queue.set_work(
entry_id,
super::queue::EntryWork::Failed("file is gone".into()),
);
continue;
}
let job = super::worker::Job::Probe {
entry_id,
generation,
path,
};
if self.worker.as_mut().is_some_and(|w| w.submit(job)) {
self.queue.set_work(
entry_id,
super::queue::EntryWork::Probing {
since: Instant::now(),
generation,
},
);
sent += 1;
}
}
if sent == 0 {
self.status
.err("could not start — the worker is not running.");
} else {
self.status.info(format!("reading {sent} file(s)…"));
}
}
pub fn on_probed(
&mut self,
entry_id: i64,
generation: u64,
result: Result<crate::audio::AudioInfo, String>,
) {
if !self.queue.accepts(entry_id, generation) {
return;
}
let Some(entry) = self
.queue
.entries
.iter()
.find(|e| e.id == entry_id)
.cloned()
else {
return;
};
let path = entry.acquired_path.clone();
let planned = result.and_then(|info| {
let title = info
.tags
.title
.is_none()
.then_some(entry.src_title.as_deref())
.flatten();
let artist = (info.tags.artist.is_none() && info.tags.album_artist.is_none())
.then_some(entry.src_artist.as_deref())
.flatten();
crate::import::plan_insert(&self.db, &path, &info, title, artist)
.map_err(|e| e.to_string())
});
match planned {
Ok(new) => self
.queue
.set_work(entry_id, super::queue::EntryWork::Planned(Box::new(new))),
Err(why) => self
.queue
.set_work(entry_id, super::queue::EntryWork::Failed(why)),
}
if !self.queue.any_in_flight() {
self.open_import_confirm();
}
}
fn open_import_confirm(&mut self) {
let mut rows = Vec::new();
let mut entry_ids = Vec::new();
for entry in &self.queue.entries {
if let Some(super::queue::EntryWork::Planned(new)) = self.queue.work_for(entry.id) {
rows.push((**new).clone());
entry_ids.push(entry.id);
}
}
if rows.is_empty() {
self.status.warn("nothing could be planned — see the rows.");
return;
}
crate::import::dedupe_lookups(&mut rows);
self.import_batch = Some(ImportBatch {
rows,
entry_ids,
scroll: 0,
});
self.mode = InputMode::Confirm(ConfirmKind::ImportRows);
}
pub fn apply_import_batch(&mut self) {
let Some(batch) = self.import_batch.take() else {
self.mode = InputMode::Normal;
return;
};
self.mode = InputMode::Normal;
if let Err(e) = crate::db::safety_preflight(self.safety) {
self.status.err(format!("{e}"));
return;
}
let backup = match self.db.backup() {
Ok(p) => p,
Err(e) => {
self.status
.err(format!("backup failed, nothing written: {e}"));
return;
}
};
let (mut done, mut failed) = (0usize, Vec::new());
for (new, entry_id) in batch.rows.iter().zip(&batch.entry_ids) {
match crate::import::insert(&mut self.db, new) {
Ok(mut note) => {
note.backup = Some(backup.to_string_lossy().into_owned());
let _ = note.write_beside(&backup);
self.queue.clear_work(*entry_id);
done += 1;
}
Err(e) => {
failed.push(e.to_string());
self.queue
.set_work(*entry_id, super::queue::EntryWork::Failed(e.to_string()));
}
}
}
self.reload_queue();
let total = batch.rows.len();
if let Some(first) = failed.first() {
self.unresolved_errors = true;
self.status
.err(format!("imported {done}/{total}. Failed → {first}"));
return;
}
self.status.ok(format!(
"imported {done} row(s) — checking fingerprints now. Backup: {}",
backup.display()
));
if done > 0 {
self.start_apply();
}
}
pub fn start_apply(&mut self) {
if self.queue.any_in_flight() {
self.status.info("already working — give it a moment.");
return;
}
self.queue.next_generation();
if !self.submit_next_gate() {
self.status
.info("nothing to check — import the downloads first with 'i'.");
}
}
fn submit_next_gate(&mut self) -> bool {
let generation = self.queue.current_generation();
let candidates: Vec<crate::pending::Entry> = self
.queue
.entries
.iter()
.filter(|e| e.state == crate::pending::State::AwaitingImport)
.filter(|e| {
!matches!(
self.queue.work_for(e.id),
Some(
super::queue::EntryWork::Ready { .. } | super::queue::EntryWork::Failed(_)
)
)
})
.cloned()
.collect();
for entry in candidates {
let Some(dst_content_id) =
crate::pending::find_imported_row(&self.db, &entry.acquired_path)
.ok()
.flatten()
else {
continue;
};
let Ok(src) = crate::analysis::load_track(&self.db, &entry.src_content_id) else {
self.queue.set_work(
entry.id,
super::queue::EntryWork::Failed("the source track is gone".into()),
);
continue;
};
if src.uuid != entry.src_uuid {
self.queue.set_work(
entry.id,
super::queue::EntryWork::Failed("the source track was replaced".into()),
);
continue;
}
let dst = crate::analysis::load_track(&self.db, &dst_content_id).ok();
let (dst_length, dst_bpm) = match &dst {
Some(d) => (d.length, d.bpm),
None => (None, None),
};
let job = super::worker::Job::Fingerprint {
entry_id: entry.id,
generation,
src: Box::new(src),
dst_path: entry.acquired_path.clone(),
dst_length,
dst_bpm,
};
if self.worker.as_mut().is_some_and(|w| w.submit(job)) {
self.queue.set_work(
entry.id,
super::queue::EntryWork::Fingerprinting {
since: Instant::now(),
generation,
dst_content_id,
},
);
self.status.info(format!(
"fingerprinting {}…",
entry.src_title.as_deref().unwrap_or("the download")
));
return true;
}
}
false
}
pub fn on_fingerprinted(
&mut self,
entry_id: i64,
generation: u64,
result: Result<crate::transfer::GateOutcome, String>,
) {
if !self.queue.accepts(entry_id, generation) {
return;
}
let Some(super::queue::EntryWork::Fingerprinting { dst_content_id, .. }) =
self.queue.work_for(entry_id)
else {
return;
};
let dst_content_id = dst_content_id.clone();
let Some(entry) = self
.queue
.entries
.iter()
.find(|e| e.id == entry_id)
.cloned()
else {
return;
};
match result {
Err(why) => self
.queue
.set_work(entry_id, super::queue::EntryWork::Failed(why)),
Ok(outcome) if !outcome.verdict.is_accept() => {
let why = outcome.verdict.summary();
if let Some(store) = self.store.as_ref() {
let _ = store.set_rejected(entry_id, &why);
}
self.queue
.set_work(entry_id, super::queue::EntryWork::Failed(why));
}
Ok(outcome) => {
let still = crate::pending::find_imported_row(&self.db, &entry.acquired_path)
.ok()
.flatten();
if still.as_deref() != Some(dst_content_id.as_str()) {
self.queue.set_work(
entry_id,
super::queue::EntryWork::Failed(
"the rekordbox row changed while checking — press 'a' again".into(),
),
);
} else {
let opts = CopyOpts {
replace: entry.replace,
lock: entry.lock,
};
match crate::analysis::build_plan(
&self.db,
&entry.src_content_id,
&dst_content_id,
&opts,
) {
Ok(plan) => self.queue.set_work(
entry_id,
super::queue::EntryWork::Ready {
plan: Box::new(plan),
verdict: outcome.verdict,
},
),
Err(e) => self
.queue
.set_work(entry_id, super::queue::EntryWork::Failed(e.to_string())),
}
}
}
}
if !self.submit_next_gate() {
self.apply_ready();
}
}
fn apply_ready(&mut self) {
let ready: Vec<i64> = self
.queue
.entries
.iter()
.map(|e| e.id)
.filter(|id| {
matches!(
self.queue.work_for(*id),
Some(super::queue::EntryWork::Ready { .. })
)
})
.collect();
if ready.is_empty() {
self.status.warn("nothing passed the fingerprint check.");
return;
}
if let Err(e) = crate::db::safety_preflight(self.safety) {
self.status.err(format!("{e}"));
return;
}
let (mut done, mut failed) = (0usize, Vec::new());
for id in &ready {
let Some(super::queue::EntryWork::Ready { plan, verdict }) = self.queue.take_work(*id)
else {
continue;
};
let entry = self.queue.entries.iter().find(|e| e.id == *id).cloned();
match crate::analysis::apply_plan(&mut self.db, &plan) {
Ok(_backup) => {
if let (Some(store), Some(entry)) = (self.store.as_ref(), entry.as_ref()) {
let _ = store.set_matched(*id, &plan.dst.id, &verdict.summary());
let _ = crate::transfer::mark_applied(store, entry);
}
done += 1;
}
Err(e) => {
failed.push(e.to_string());
self.queue
.set_work(*id, super::queue::EntryWork::Failed(e.to_string()));
}
}
}
self.reload_queue();
match failed.first() {
None => self.status.ok(format!("applied {done} transfer(s).")),
Some(first) => {
self.unresolved_errors = true;
self.status
.err(format!("applied {done}/{}. Failed → {first}", ready.len()));
}
}
}
pub fn retry_selected(&mut self) {
let Some(entry) = self.queue.selected().cloned() else {
return;
};
self.queue.clear_work(entry.id);
if entry.state != crate::pending::State::AwaitingImport {
let Some(store) = self.store.as_ref() else {
return;
};
if let Err(e) = store.set_state(entry.id, crate::pending::State::AwaitingImport) {
self.status
.err(format!("could not reset #{}: {e}", entry.id));
return;
}
}
self.reload_queue();
self.status
.info(format!("#{} is back in the queue.", entry.id));
}
pub fn forget_selected(&mut self) {
let Some(entry) = self.queue.selected().cloned() else {
return;
};
let Some(store) = self.store.as_ref() else {
return;
};
match store.remove(entry.id) {
Ok(()) => {
self.queue.clear_work(entry.id);
self.reload_queue();
self.status.info(format!("forgot #{}.", entry.id));
}
Err(e) => self.status.err(format!("could not forget it: {e}")),
}
}
pub fn start_force(&mut self) {
let Some(entry) = self.queue.selected().cloned() else {
return;
};
if self.queue.in_flight(entry.id) {
self.status
.info("still checking that one — give it a moment.");
return;
}
let Some(dst_content_id) =
crate::pending::find_imported_row(&self.db, &entry.acquired_path)
.ok()
.flatten()
else {
self.status
.err("rekordbox has no row for this file yet — press 'i', or drag it in.");
return;
};
let Ok(src) = crate::analysis::load_track(&self.db, &entry.src_content_id) else {
self.status.err("the source track is gone.");
return;
};
if src.uuid != entry.src_uuid {
self.status
.err("the source track was replaced — 'c' forgets this entry.");
return;
}
let opts = CopyOpts {
replace: entry.replace,
lock: entry.lock,
};
match crate::analysis::build_plan(&self.db, &entry.src_content_id, &dst_content_id, &opts) {
Ok(plan) => {
self.force_batch = Some(ForceBatch {
entry_id: entry.id,
dst_content_id,
plan: Box::new(plan),
verdict: crate::transfer::bypass_verdict(&src),
scroll: 0,
});
self.mode = InputMode::Confirm(ConfirmKind::ForceApply);
}
Err(e) => self.status.err(format!("no plan for it: {e}")),
}
}
pub fn apply_force_batch(&mut self) {
self.mode = InputMode::Normal;
let Some(batch) = self.force_batch.take() else {
return;
};
let entry = self
.queue
.entries
.iter()
.find(|e| e.id == batch.entry_id)
.cloned();
let still = entry.as_ref().and_then(|e| {
crate::pending::find_imported_row(&self.db, &e.acquired_path)
.ok()
.flatten()
});
if still.as_deref() != Some(batch.dst_content_id.as_str()) {
self.status
.err("the rekordbox row changed since you were shown the plan — press 'F' again.");
return;
}
if let Err(e) = crate::db::safety_preflight(self.safety) {
self.status.err(format!("{e}"));
return;
}
match crate::analysis::apply_plan(&mut self.db, &batch.plan) {
Ok(_backup) => {
if let (Some(store), Some(entry)) = (self.store.as_ref(), entry.as_ref()) {
let _ = store.set_matched(
batch.entry_id,
&batch.plan.dst.id,
&batch.verdict.summary(),
);
let _ = crate::transfer::mark_applied(store, entry);
}
self.queue.clear_work(batch.entry_id);
self.reload_queue();
self.status.warn(format!(
"applied #{} unverified — check the cues in rekordbox.",
batch.entry_id
));
}
Err(e) => {
self.unresolved_errors = true;
self.status.err(format!("could not apply it: {e}"));
}
}
}
pub fn start_clear_queue(&mut self) {
if self.queue.entries.is_empty() {
self.status.info("the queue is already empty.");
return;
}
self.mode = InputMode::Confirm(ConfirmKind::ClearQueue);
}
pub fn clear_queue(&mut self) {
self.mode = InputMode::Normal;
let Some(store) = self.store.as_ref() else {
return;
};
match store.clear_all() {
Ok(n) => {
self.reload_queue();
self.status.info(format!(
"forgot {n} entr(y/ies). The files are still there."
));
}
Err(e) => self.status.err(format!("could not clear the queue: {e}")),
}
}
pub fn scroll_confirm(&mut self, delta: i32) {
if let Some(batch) = self.import_batch.as_mut() {
batch.scroll = (batch.scroll as i32 + delta).max(0) as u16;
}
if let Some(batch) = self.pending.as_mut() {
batch.scroll = (batch.scroll as i32 + delta).max(0) as u16;
}
if let Some(batch) = self.force_batch.as_mut() {
batch.scroll = (batch.scroll as i32 + delta).max(0) as u16;
}
}
pub fn shop_outstanding(&self) -> usize {
self.outstanding_of(super::worker::JobKind::Search)
}
pub fn outstanding_of(&self, kind: super::worker::JobKind) -> usize {
self.worker
.as_ref()
.map(|w| w.outstanding_of(kind))
.unwrap_or(0)
}
pub fn work_in_flight(&self) -> Vec<(super::worker::JobKind, usize)> {
use super::worker::JobKind::*;
[Search, Fetch, Probe, Fingerprint]
.into_iter()
.map(|k| (k, self.outstanding_of(k)))
.filter(|(_, n)| *n > 0)
.collect()
}
pub fn shop_since(&self) -> Option<Instant> {
match (&self.shop, &self.fetch) {
(_, FetchState::Running { since, .. }) => Some(*since),
(ShopState::Searching { since, .. }, _) => Some(*since),
_ => None,
}
}
pub fn shop_busy(&self) -> bool {
self.worker.as_ref().map(|w| w.is_busy()).unwrap_or(false)
}
pub fn start_fetch(&mut self) -> bool {
let Some((offer, group_src)) = self
.shop
.selected_with_group()
.map(|(g, r)| (r.offer.clone(), g.src_id.clone()))
else {
self.status.warn("no offer selected.");
return false;
};
if offer.requires_purchase() {
self.status.warn(format!(
"you don't own this yet ({}). Press 'o' to open the buy page.",
super::super::acquire::render::price_cell(&offer)
));
return false;
}
let dest = match self.cfg.download_dir() {
Ok(d) => d,
Err(e) => {
self.status.err(format!("no download directory: {e}"));
return false;
}
};
let format_pref = match crate::acquire::format_preference(&self.cfg) {
Ok(p) => p,
Err(e) => {
self.status.err(e.to_string());
return false;
}
};
if let Some(n) = self.fetch_queue.ahead_of(&offer.item_ref) {
self.status.info(match n {
0 => "that one is downloading now.".to_string(),
_ => format!("that one is already queued, {n} download(s) ahead of it."),
});
return false;
}
let Some(worker) = self.worker.as_mut() else {
self.status.err("the worker thread is not running.");
return false;
};
if !worker.submit(super::worker::Job::Fetch {
item: offer.item_ref.clone(),
dest,
format_pref,
overwrite: false,
}) {
self.status.err("could not start the download.");
return false;
}
let what = format!("{} — {}", offer.artist, offer.title);
let src = group_src.or_else(|| self.current_shop_track().map(|r| r.id.clone()));
let running = !self.fetch_queue.is_empty();
self.fetch_queue.push(QueuedFetch {
what: what.clone(),
src,
item: offer.item_ref.clone(),
});
if running {
self.status.info(format!(
"queued {what} — {} download(s) ahead of it.",
self.fetch_queue.len() - 1
));
} else {
self.status.info(format!("downloading {what} …"));
self.fetch = FetchState::Running {
since: Instant::now(),
what,
note: None,
};
}
true
}
fn finish_front_fetch(&mut self) -> Option<QueuedFetch> {
let done = self.fetch_queue.finish_front();
if let Some(what) = self.fetch_queue.label().map(str::to_string) {
self.fetch = FetchState::Running {
since: Instant::now(),
what,
note: None,
};
}
done
}
pub fn offer_fetch(&self, item: &crate::acquire::ItemRef) -> OfferFetch {
match self.fetch_queue.ahead_of(item) {
Some(0) => OfferFetch::Running,
Some(_) => OfferFetch::Queued,
None if self.fetched.contains(item) => OfferFetch::Done,
None => OfferFetch::None,
}
}
fn note_downloads_left(&mut self) {
let text = self.status.text.clone();
self.status
.info(format!("{text} {} still queued.", self.fetch_queue.len()));
}
fn queue_transfer_for(
&mut self,
src_id: Option<String>,
paths: &[std::path::PathBuf],
) -> Option<i64> {
let src_id = src_id?;
let src = crate::analysis::load_track(&self.db, &src_id).ok()?;
let store = crate::pending::PendingStore::open().ok()?;
let first = paths.first()?;
store
.add(
&src,
first,
None,
true,
self.copy_opts.lock,
self.cfg.pending.ttl_days,
)
.ok()
}
pub fn focused_column_mut(&mut self) -> &mut ColumnState {
match self.focus {
Focus::Src => &mut self.src,
Focus::Dst => &mut self.dst,
}
}
pub fn focused_column(&self) -> &ColumnState {
match self.focus {
Focus::Src => &self.src,
Focus::Dst => &self.dst,
}
}
pub fn current_src(&self) -> Option<&TrackRow> {
self.src
.visible
.get(self.src.cursor)
.and_then(|&i| self.rows.get(i))
}
pub fn current_dst(&self) -> Option<&TrackRow> {
self.dst
.visible
.get(self.dst.cursor)
.and_then(|&i| self.rows.get(i))
}
pub fn current_shop_track(&self) -> Option<&TrackRow> {
self.shop_list
.visible
.get(self.shop_list.cursor)
.and_then(|&i| self.rows.get(i))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acquire::shop::{GroupOutcome, QuerySpec, SearchOutcome};
use crate::acquire::types::{BackendId, ItemKind, ItemRef, Offer, SearchQuery};
fn spec(src_id: &str) -> QuerySpec {
QuerySpec {
label: format!("track {src_id}"),
src_id: Some(src_id.to_string()),
query: SearchQuery::from_text("x", 5),
}
}
fn group(src_id: &str, offers: usize) -> GroupOutcome {
let ranked = (0..offers)
.map(|i| crate::acquire::shop::RankedOffer {
offer: Offer::new(
ItemRef::new(BackendId::Bandcamp, format!("t:{src_id}:{i}")),
ItemKind::Track,
"A",
format!("T{i}"),
"https://x/y",
),
row: i + 1,
match_score: 50,
})
.collect();
GroupOutcome {
label: format!("track {src_id}"),
src_id: Some(src_id.to_string()),
outcome: SearchOutcome {
offers: ranked,
per_backend: vec![],
},
}
}
fn results(groups: Vec<GroupOutcome>, specs: Vec<QuerySpec>) -> ShopState {
ShopState::Results {
groups: Box::new(groups),
cursor: 0,
specs,
}
}
#[test]
fn completed_results_remember_what_they_were_for() {
let s = results(vec![group("101", 2)], vec![spec("101")]);
assert_eq!(s.specs().unwrap().len(), 1);
assert_eq!(s.specs().unwrap()[0].src_id.as_deref(), Some("101"));
assert_eq!(s.len(), 2);
}
#[test]
fn the_cursor_walks_across_groups() {
let mut s = results(
vec![group("101", 2), group("202", 3)],
vec![spec("101"), spec("202")],
);
assert_eq!(s.len(), 5);
assert_eq!(
s.selected_with_group().unwrap().0.src_id.as_deref(),
Some("101")
);
s.move_cursor(1);
assert_eq!(
s.selected_with_group().unwrap().0.src_id.as_deref(),
Some("101")
);
s.move_cursor(1);
assert_eq!(
s.selected_with_group().unwrap().0.src_id.as_deref(),
Some("202")
);
s.move_cursor(10);
assert_eq!(
s.selected_with_group().unwrap().0.src_id.as_deref(),
Some("202")
);
assert_eq!(s.selected().unwrap().offer.title, "T2");
}
#[test]
fn the_cursor_clamps_at_both_ends() {
let mut s = results(vec![group("101", 2)], vec![spec("101")]);
s.move_cursor(-5);
assert_eq!(s.selected().unwrap().offer.title, "T0");
s.move_cursor(99);
assert_eq!(s.selected().unwrap().offer.title, "T1");
}
#[test]
fn an_empty_group_contributes_no_rows_but_still_shows() {
let s = results(
vec![group("101", 0), group("202", 2)],
vec![spec("101"), spec("202")],
);
assert_eq!(s.len(), 2);
assert_eq!(
s.selected_with_group().unwrap().0.src_id.as_deref(),
Some("202")
);
}
#[test]
fn nothing_is_selected_when_there_are_no_results() {
assert!(ShopState::Idle.selected().is_none());
assert!(ShopState::Failed("x".into()).selected().is_none());
let empty = results(vec![], vec![]);
assert!(empty.selected().is_none());
assert!(empty.is_empty());
}
fn basket(ids: &[&str]) -> HashSet<String> {
ids.iter().map(|s| s.to_string()).collect()
}
fn library() -> Vec<TrackRow> {
vec![
TrackRow::stub("1", "alpha"),
TrackRow::stub("2", "beta"),
TrackRow::stub("3", "gamma"),
TrackRow::stub("4", "delta"),
]
}
#[test]
fn the_basket_survives_a_filter_that_hides_part_of_it() {
let (rows, total) = basket_rows(&library(), &basket(&["1", "3", "4"]), 25, |_| true);
assert_eq!(total, 3);
assert_eq!(
rows.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
["1", "3", "4"],
"every basket item must be queued regardless of the filter"
);
}
#[test]
fn the_basket_is_queued_in_library_order() {
let picked = basket(&["4", "2", "1"]);
for _ in 0..8 {
let (rows, _) = basket_rows(&library(), &picked, 25, |_| true);
assert_eq!(
rows.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
["1", "2", "4"]
);
}
}
#[test]
fn the_cap_limits_what_is_queued_but_reports_the_whole_basket() {
let (rows, total) = basket_rows(&library(), &basket(&["1", "2", "3", "4"]), 2, |_| true);
assert_eq!(rows.len(), 2);
assert_eq!(total, 4, "the caller needs the real total to warn with");
}
#[test]
fn the_cap_applies_to_what_is_left_to_search_not_the_whole_basket() {
let all = basket(&["1", "2", "3", "4"]);
let done = ["1", "2"];
let (rows, total) = basket_rows(&library(), &all, 2, |r| !done.contains(&r.id.as_str()));
assert_eq!(
rows.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
["3", "4"],
"the second press must reach the items the first one did not"
);
assert_eq!(total, 2, "the total counts what is still to search");
}
fn queued(what: &str, src: Option<&str>) -> QueuedFetch {
QueuedFetch {
what: what.to_string(),
src: src.map(str::to_string),
item: crate::acquire::ItemRef::new(
crate::acquire::BackendId::Bandcamp,
format!("k:{what}"),
),
}
}
#[test]
fn each_finished_download_is_paired_with_its_own_source_track() {
let mut q = FetchQueue::default();
q.push(queued("a", Some("src-a")));
q.push(queued("b", Some("src-b")));
q.push(queued("c", None));
fn src_of(q: Option<QueuedFetch>) -> Option<String> {
q.and_then(|q| q.src)
}
assert_eq!(q.label(), Some("a"), "the front one is the running one");
assert_eq!(src_of(q.finish_front()).as_deref(), Some("src-a"));
assert_eq!(q.label(), Some("b"));
assert_eq!(src_of(q.finish_front()).as_deref(), Some("src-b"));
assert_eq!(
src_of(q.finish_front()),
None,
"'just download it' stays unpaired"
);
assert!(q.is_empty());
assert_eq!(q.label(), None);
}
#[test]
fn the_same_offer_twice_reports_its_place_rather_than_queueing_again() {
let mut q = FetchQueue::default();
q.push(queued("a", None));
q.push(queued("b", None));
assert_eq!(q.ahead_of(&queued("a", None).item), Some(0));
assert_eq!(q.ahead_of(&queued("b", None).item), Some(1));
assert_eq!(q.ahead_of(&queued("c", None).item), None);
assert_eq!(q.len(), 2);
}
#[test]
fn a_dead_worker_abandons_the_whole_queue_not_just_the_running_one() {
let mut q = FetchQueue::default();
q.push(queued("a", None));
q.push(queued("b", None));
q.abandon_all();
assert!(q.is_empty(), "nothing will ever answer these");
}
#[test]
fn an_id_no_longer_in_the_library_is_ignored() {
let (rows, total) = basket_rows(&library(), &basket(&["2", "999"]), 25, |_| true);
assert_eq!(rows.len(), 1);
assert_eq!(total, 1);
assert_eq!(rows[0].id, "2");
}
#[test]
fn a_filter_hiding_a_selection_is_counted_not_lost() {
let rows = library();
let mut col = ColumnState {
selected: basket(&["1", "4"]),
..ColumnState::default()
};
col.visible = vec![0, 1, 2, 3];
assert_eq!(hidden_selected(&rows, &col), 0);
col.visible = vec![0];
assert_eq!(hidden_selected(&rows, &col), 1);
col.visible = vec![1, 2];
assert_eq!(hidden_selected(&rows, &col), 2);
assert_eq!(col.selected.len(), 2, "counting must not mutate anything");
}
#[test]
fn the_track_tag_tells_found_nothing_apart_from_not_searched() {
let s = results(vec![group("101", 3), group("202", 0)], vec![]);
let queued = vec!["303".to_string()];
assert_eq!(track_state(&s, &queued, "101"), ShopTrackState::Done(3));
assert_eq!(track_state(&s, &queued, "202"), ShopTrackState::Done(0));
assert_eq!(track_state(&s, &queued, "303"), ShopTrackState::Queued);
assert_eq!(track_state(&s, &queued, "404"), ShopTrackState::Untouched);
}
#[test]
fn a_queued_track_that_has_answered_reads_as_done() {
let s = results(vec![group("101", 2)], vec![]);
let stale = vec!["101".to_string()];
assert_eq!(track_state(&s, &stale, "101"), ShopTrackState::Done(2));
}
#[test]
fn nothing_is_queued_or_done_before_the_first_search() {
assert_eq!(
track_state(&ShopState::Idle, &[], "101"),
ShopTrackState::Untouched
);
}
#[test]
fn a_search_in_flight_still_reports_its_specs() {
let s = ShopState::Searching {
since: Instant::now(),
what: "track 101".into(),
done: 0,
total: 1,
specs: vec![spec("101")],
};
assert_eq!(s.specs().unwrap()[0].src_id.as_deref(), Some("101"));
assert!(s.selected().is_none());
}
}