Skip to main content

koan_core/remote/
dsp_sync.rs

1//! EQ profiles kept everywhere: synced through the account's kōan server to
2//! every device signed in to it, gated on `koanDspProfiles`.
3//!
4//! A profile travels whole as a [`SyncDoc`], everything but which outputs use
5//! it, with the files in its folder (impulse responses, a routing `.cfg`,
6//! AutoEQ's measurement) named by content. The last edit wins per profile,
7//! by when it was made, so an edit made offline still counts from when it
8//! was made. A profile kept on this device alone is never sent, and making
9//! one so deletes it from the account's other devices.
10//!
11//! A device reads what changed after its cursor on linking, on signing in,
12//! and when the server says the profiles moved; it sends what changed here
13//! after each edit. Nothing polls.
14
15use std::cell::Cell;
16use std::collections::{HashMap, HashSet};
17use std::path::{Path, PathBuf};
18use std::sync::atomic::{AtomicBool, Ordering};
19
20use parking_lot::Mutex;
21use serde::{Deserialize, Serialize};
22use sha2::{Digest, Sha256};
23
24use crate::config::{self, Config, DspProfile, DspScope};
25use crate::db::connection::Database;
26use crate::db::queries::dsp as rows;
27use crate::remote::client::{KoanDspProfiles, KoanDspSaved, SubsonicClient, SubsonicError};
28
29/// The most one file may hold: a stereo impulse response at 384 kHz runs to
30/// a few MB.
31pub const MAX_FILE: u64 = 32 << 20;
32/// The most an account's files may hold together.
33pub const MAX_ACCOUNT: u64 = 256 << 20;
34/// The most a profile's document may hold, files aside.
35pub const MAX_DOC: usize = 512 << 10;
36/// The most files one profile may name.
37pub const MAX_FILES: usize = 64;
38/// The most profiles a server keeps for an account, deleted ones included.
39pub const MAX_PROFILES: i64 = 1024;
40/// The most outputs an account may turn AutoEQ down for.
41pub const MAX_DISMISSED: i64 = 1024;
42
43/// A profile as it travels: as configured, without the outputs that use it
44/// or where it is kept, with its impulse responses named by file and every
45/// file in its folder by content.
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47pub struct SyncDoc {
48    pub profile: DspProfile,
49    pub files: Vec<SyncFile>,
50    /// The profile's fields a newer kōan wrote that this one does not know:
51    /// kept and written back, so a server or a device in between loses none.
52    #[serde(skip)]
53    pub unknown: serde_json::Map<String, serde_json::Value>,
54}
55
56/// A document as written, its profile's unknown fields set apart.
57#[derive(Deserialize)]
58struct Wire {
59    profile: WireProfile,
60    files: Vec<SyncFile>,
61}
62
63#[derive(Deserialize)]
64struct WireProfile {
65    #[serde(flatten)]
66    known: DspProfile,
67    #[serde(flatten)]
68    unknown: serde_json::Map<String, serde_json::Value>,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct SyncFile {
73    pub name: String,
74    pub sha256: String,
75    pub size: u64,
76}
77
78impl SyncDoc {
79    /// Read and check a document from another device or the server.
80    /// The profile is brought within `config::dsp_bounds`, and stripped of
81    /// what is each device's own: which outputs use it, where it is kept,
82    /// its uid, what it was imported from. A server keeps it so, and a
83    /// device applies it so.
84    pub fn parse(json: &str) -> Result<Self, String> {
85        if json.len() > MAX_DOC {
86            return Err(format!("An EQ may hold at most {} KB", MAX_DOC >> 10));
87        }
88        let wire: Wire = serde_json::from_str(json).map_err(|e| format!("Not an EQ: {e}"))?;
89        let mut doc = Self {
90            profile: wire.profile.known,
91            files: wire.files,
92            unknown: wire.profile.unknown,
93        };
94        doc.profile.sanitize();
95        doc.profile.devices.clear();
96        doc.profile.scope = None;
97        doc.profile.uid = None;
98        doc.profile.source.clear();
99        doc.check()?;
100        Ok(doc)
101    }
102
103    /// What a server and a device both refuse: files with names that could
104    /// leave the profile's folder, sizes past the caps, impulse responses
105    /// that are not among the files.
106    pub fn check(&self) -> Result<(), String> {
107        if self.profile.name.trim().is_empty() {
108            return Err("It needs a name".into());
109        }
110        if self.files.len() > MAX_FILES {
111            return Err(format!("An EQ may hold at most {MAX_FILES} files"));
112        }
113        let mut names = HashSet::new();
114        for f in &self.files {
115            if !safe_name(&f.name) {
116                return Err(format!("{:?} is not a file name an EQ may hold", f.name));
117            }
118            if !names.insert(f.name.as_str()) {
119                return Err(format!("{} is named twice", f.name));
120            }
121            if !is_sha256(&f.sha256) {
122                return Err(format!("{} has no SHA-256", f.name));
123            }
124            if f.size > MAX_FILE {
125                return Err(too_large(&f.name));
126            }
127        }
128        if self.size() > MAX_ACCOUNT {
129            return Err(format!(
130                "Its files come to more than the {} MB an account keeps",
131                MAX_ACCOUNT >> 20
132            ));
133        }
134        for ir in &self.profile.impulses {
135            let named = ir.to_str().is_some_and(|n| names.contains(n));
136            if !named {
137                return Err(format!("{} is not among its files", ir.display()));
138            }
139        }
140        Ok(())
141    }
142
143    /// Its files' bytes together, each content counted once.
144    pub fn size(&self) -> u64 {
145        let mut seen = HashSet::new();
146        self.files
147            .iter()
148            .filter(|f| seen.insert(&f.sha256))
149            .map(|f| f.size)
150            .sum()
151    }
152
153    /// As it travels: what this kōan knows, with the fields a newer one
154    /// wrote, laid out the same way whether there are any.
155    pub fn json(&self) -> String {
156        let mut value = serde_json::to_value(self).expect("a profile serialises");
157        if let Some(profile) = value.get_mut("profile").and_then(|p| p.as_object_mut()) {
158            for (key, field) in &self.unknown {
159                profile.entry(key.clone()).or_insert_with(|| field.clone());
160            }
161        }
162        value.to_string()
163    }
164
165    /// What tells two versions apart: the fields this kōan knows, which are
166    /// all a device keeps. A field from a newer kōan is never kept here, so
167    /// counting it would make every copy taken read as edited here, and be
168    /// sent back without it.
169    pub fn hash(&self) -> String {
170        sha256_hex(
171            serde_json::to_string(self)
172                .expect("a profile serialises")
173                .as_bytes(),
174        )
175    }
176}
177
178fn too_large(name: &str) -> String {
179    format!(
180        "{name} is larger than the {} MB a file may be",
181        MAX_FILE >> 20
182    )
183}
184
185fn safe_name(name: &str) -> bool {
186    !name.is_empty()
187        && name.len() <= 128
188        && !name.starts_with('.')
189        && !name.contains(['/', '\\', '\0'])
190}
191
192pub fn is_sha256(s: &str) -> bool {
193    s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
194}
195
196pub fn sha256_hex(bytes: &[u8]) -> String {
197    Sha256::digest(bytes)
198        .iter()
199        .map(|b| format!("{b:02x}"))
200        .collect()
201}
202
203/// `profile` as it travels, and where each of its files is here. Refused,
204/// with the reason, for one that cannot travel: a response kept outside its
205/// folder, or files past the caps.
206pub fn doc_of(profile: &DspProfile) -> Result<(SyncDoc, HashMap<String, PathBuf>), String> {
207    let base = config::config_dir();
208    let dir = crate::audio::dsp::profiles::dir(&profile.name);
209    // A folder two profiles' names map to holds both their files; sending
210    // it would send the other's, which may be kept here alone.
211    if let Some(other) = Config::cached()
212        .dsp
213        .profiles
214        .iter()
215        .find(|p| p.name != profile.name && crate::audio::dsp::profiles::dir(&p.name) == dir)
216    {
217        return Err(format!(
218            "{} shares its folder with {}; rename one of them to sync it",
219            profile.name, other.name
220        ));
221    }
222    let mut files = Vec::new();
223    let mut paths = HashMap::new();
224    if let Ok(entries) = std::fs::read_dir(&dir) {
225        let mut entries: Vec<_> = entries.flatten().collect();
226        entries.sort_by_key(|e| e.file_name());
227        for entry in entries {
228            let name = entry.file_name().to_string_lossy().into_owned();
229            if !safe_name(&name) || !entry.file_type().is_ok_and(|t| t.is_file()) {
230                continue;
231            }
232            let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
233            if size > MAX_FILE {
234                return Err(too_large(&name));
235            }
236            let bytes = std::fs::read(entry.path()).map_err(|e| format!("{name}: {e}"))?;
237            let sha256 = sha256_hex(&bytes);
238            paths.insert(sha256.clone(), entry.path());
239            files.push(SyncFile { name, sha256, size });
240        }
241    }
242    let mut travelling = profile.clone();
243    travelling.devices.clear();
244    travelling.scope = None;
245    travelling.uid = None;
246    travelling.source.clear();
247    travelling.origin = profile.origin.clone().or_else(|| Some(this_device()));
248    travelling.impulses = profile
249        .impulses
250        .iter()
251        .map(|path| {
252            let full = if path.is_absolute() {
253                path.clone()
254            } else {
255                base.join(path)
256            };
257            match (full.parent(), full.file_name()) {
258                (Some(parent), Some(name)) if parent == dir => Ok(PathBuf::from(name)),
259                _ => Err(format!(
260                    "{} is kept outside {}'s folder, so it cannot be sent",
261                    path.display(),
262                    profile.name
263                )),
264            }
265        })
266        .collect::<Result<_, _>>()?;
267    let doc = SyncDoc {
268        profile: travelling,
269        files,
270        unknown: Default::default(),
271    };
272    doc.check()?;
273    Ok((doc, paths))
274}
275
276/// This device's name, as the apps give it when the engine starts, for
277/// telling apart two profiles of one name that came from different devices.
278static DEVICE_NAME: parking_lot::RwLock<Option<String>> = parking_lot::RwLock::new(None);
279
280pub fn set_device_name(name: Option<String>) {
281    *DEVICE_NAME.write() = name.filter(|n| !n.trim().is_empty());
282}
283
284fn this_device() -> String {
285    DEVICE_NAME
286        .read()
287        .clone()
288        .unwrap_or_else(|| crate::remote::link::LinkIdentity::this_device(None).name)
289}
290
291/// Whether two profiles would sound the same, whatever else differs: their
292/// names, where they are kept, which device made them, when. Gains, the
293/// preamp and Q match within what no one hears (±0.05 dB, ±0.5%), and
294/// frequencies within 0.1%. Parametric bands on the same channels commute,
295/// so their order between delays, mixes and graphic curves does not count;
296/// those, and the order around them, do. Files match by content, impulse
297/// responses by the content of the file each names. Layers match when the
298/// profiles they name, found by `member`, sound the same in turn.
299pub fn sounds_same(a: &SyncDoc, b: &SyncDoc, member: &dyn Fn(&str) -> Option<SyncDoc>) -> bool {
300    same(a, b, member, 0)
301}
302
303fn same(a: &SyncDoc, b: &SyncDoc, member: &dyn Fn(&str) -> Option<SyncDoc>, depth: usize) -> bool {
304    use crate::config::DspFilter;
305    let (pa, pb) = (&a.profile, &b.profile);
306    let close =
307        |x: f64, y: f64, abs: f64, rel: f64| (x - y).abs() <= abs.max(rel * x.abs().max(y.abs()));
308    let db = |x: f64, y: f64| close(x, y, 0.05, 0.0);
309    let preamp = match (pa.preamp_db, pb.preamp_db) {
310        (Some(x), Some(y)) => db(x, y),
311        (None, None) => true,
312        _ => false,
313    };
314    // What the correction aims at, and whether its layers play together or
315    // one at a time, change the sound as much as any band.
316    if !preamp
317        || pa.target != pb.target
318        || pa.measurement != pb.measurement
319        || pa.fitted != pb.fitted
320        || pa.group != pb.group
321        || pa.tuned_for != pb.tuned_for
322    {
323        return false;
324    }
325    let files = |d: &SyncDoc| {
326        let mut h: Vec<String> = d.files.iter().map(|f| f.sha256.clone()).collect();
327        h.sort_unstable();
328        h
329    };
330    if files(a) != files(b) {
331        return false;
332    }
333    let named = |d: &SyncDoc| -> Vec<String> {
334        d.profile
335            .impulses
336            .iter()
337            .filter_map(|ir| {
338                d.files
339                    .iter()
340                    .find(|f| Some(f.name.as_str()) == ir.to_str())
341            })
342            .map(|f| f.sha256.clone())
343            .collect()
344    };
345    if named(a) != named(b) {
346        return false;
347    }
348    // Filters in runs between those whose order counts; each run's bands
349    // sorted, since bands on the same channels commute.
350    let runs = |filters: &[DspFilter]| -> Vec<Vec<DspFilter>> {
351        let mut out = vec![Vec::new()];
352        for f in filters {
353            if matches!(f, DspFilter::Band(_)) {
354                out.last_mut().expect("one at least").push(f.clone());
355            } else {
356                out.push(vec![f.clone()]);
357                out.push(Vec::new());
358            }
359        }
360        for run in &mut out {
361            run.sort_by(|x, y| match (x, y) {
362                (DspFilter::Band(x), DspFilter::Band(y)) => (&x.channels, x.freq)
363                    .partial_cmp(&(&y.channels, y.freq))
364                    .unwrap_or(std::cmp::Ordering::Equal),
365                _ => std::cmp::Ordering::Equal,
366            });
367        }
368        out.retain(|r| !r.is_empty());
369        out
370    };
371    let alike = |x: &DspFilter, y: &DspFilter| match (x, y) {
372        (DspFilter::Band(x), DspFilter::Band(y)) => {
373            x.kind == y.kind
374                && x.channels == y.channels
375                && close(x.freq, y.freq, 0.0, 0.001)
376                && db(x.gain_db, y.gain_db)
377                && close(x.q, y.q, 0.0, 0.005)
378        }
379        (DspFilter::Delay(x), DspFilter::Delay(y)) => {
380            x.channels == y.channels
381                && close(x.ms, y.ms, 0.001, 0.001)
382                && close(x.samples, y.samples, 0.5, 0.0)
383                && x.subsample == y.subsample
384        }
385        (DspFilter::Mix(x), DspFilter::Mix(y)) => {
386            x.outputs.len() == y.outputs.len()
387                && x.outputs.iter().zip(&y.outputs).all(|(p, q)| {
388                    p.len() == q.len()
389                        && p.iter()
390                            .zip(q)
391                            .all(|((c, g), (d, h))| c == d && close(*g, *h, 0.001, 0.005))
392                })
393        }
394        (DspFilter::Graphic(x), DspFilter::Graphic(y)) => {
395            x.channels == y.channels
396                && x.points.len() == y.points.len()
397                && x.points
398                    .iter()
399                    .zip(&y.points)
400                    .all(|((f, g), (e, h))| close(*f, *e, 0.0, 0.001) && db(*g, *h))
401        }
402        _ => false,
403    };
404    let (ra, rb) = (runs(&pa.filters), runs(&pb.filters));
405    let filters = ra.len() == rb.len()
406        && ra
407            .iter()
408            .zip(&rb)
409            .all(|(x, y)| x.len() == y.len() && x.iter().zip(y).all(|(f, g)| alike(f, g)));
410    if !filters || pa.layers.len() != pb.layers.len() {
411        return false;
412    }
413    pa.layers.iter().zip(&pb.layers).all(|(x, y)| {
414        x.on == y.on
415            && (x.profile == y.profile
416                || depth < 8
417                    && match (member(&x.profile), member(&y.profile)) {
418                        (Some(m), Some(n)) => same(&m, &n, member, depth + 1),
419                        _ => false,
420                    })
421    })
422}
423
424/// The server, as syncing uses it: what tests stand in for.
425pub trait Remote {
426    fn changes(&self, since: i64) -> Result<KoanDspProfiles, SubsonicError>;
427    fn save(&self, uid: &str, edited_at: i64, doc: &str) -> Result<KoanDspSaved, SubsonicError>;
428    fn delete(&self, uid: &str, edited_at: i64) -> Result<KoanDspSaved, SubsonicError>;
429    fn dismiss(&self, output: &str) -> Result<(), SubsonicError>;
430    fn file(&self, sha256: &str) -> Result<Vec<u8>, SubsonicError>;
431    fn upload(&self, sha256: &str, data: Vec<u8>) -> Result<(), SubsonicError>;
432}
433
434impl Remote for SubsonicClient {
435    fn changes(&self, since: i64) -> Result<KoanDspProfiles, SubsonicError> {
436        self.koan_dsp_profiles(since)
437    }
438    fn save(&self, uid: &str, edited_at: i64, doc: &str) -> Result<KoanDspSaved, SubsonicError> {
439        self.koan_dsp_profile_save(uid, edited_at, doc)
440    }
441    fn delete(&self, uid: &str, edited_at: i64) -> Result<KoanDspSaved, SubsonicError> {
442        self.koan_dsp_profile_delete(uid, edited_at)
443    }
444    fn dismiss(&self, output: &str) -> Result<(), SubsonicError> {
445        self.koan_dsp_dismiss(output)
446    }
447    fn file(&self, sha256: &str) -> Result<Vec<u8>, SubsonicError> {
448        self.koan_dsp_file(sha256)
449    }
450    fn upload(&self, sha256: &str, data: Vec<u8>) -> Result<(), SubsonicError> {
451        self.koan_dsp_upload(sha256, data)
452    }
453}
454
455/// What a sync did.
456#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
457pub struct DspSync {
458    /// Profiles taken from other devices, deletions included.
459    pub applied: usize,
460    /// Profiles sent, deletions included.
461    pub sent: usize,
462}
463
464impl DspSync {
465    /// Whether this device's profiles moved, so the player reloads them.
466    pub fn changed(&self) -> bool {
467        self.applied > 0
468    }
469}
470
471/// One sync at a time: two reading the same changes would each apply them.
472static ONE_AT_A_TIME: Mutex<()> = Mutex::new(());
473
474thread_local! {
475    /// Set while a sync writes the profiles, so its own writes are not taken
476    /// for edits to send.
477    static APPLYING: Cell<bool> = const { Cell::new(false) };
478}
479
480/// Sync with the server signed in to, as a device does when told the
481/// profiles moved.
482pub fn sync(db: &Database) -> DspSync {
483    let cfg = Config::load().unwrap_or_default();
484    let Some(client) = crate::helpers::subsonic_client(&cfg) else {
485        return DspSync::default();
486    };
487    reconcile(db, &client, &cfg.remote.url)
488}
489
490/// Set by a process that would exit before a background sync ran, such as
491/// the CLI: [`changed`] notes an edit in `PENDING` instead, and [`flush`]
492/// sends it.
493static DEFERRED: AtomicBool = AtomicBool::new(false);
494static PENDING: AtomicBool = AtomicBool::new(false);
495
496/// From now on, edits wait for [`flush`] rather than syncing in the
497/// background.
498pub fn defer() {
499    DEFERRED.store(true, Ordering::Relaxed);
500}
501
502/// Sync now if an edit was made since [`defer`]. None where nothing was
503/// edited; an error where the server could not be reached or refused.
504pub fn flush(db: &Database) -> Option<Result<DspSync, String>> {
505    if !PENDING.swap(false, Ordering::Relaxed) {
506        return None;
507    }
508    let cfg = Config::load().unwrap_or_default();
509    let client = crate::helpers::subsonic_client(&cfg)?;
510    let offers = crate::remote::profile::for_auth(client.auth())
511        .is_some_and(|p| p.offers(crate::remote::profile::DSP_PROFILES));
512    if !offers {
513        return Some(Ok(DspSync::default()));
514    }
515    Some(try_run(db, client.as_ref(), &cfg.remote.url).map_err(|e| e.to_string()))
516}
517
518/// Part of every sync with the server at `url`: read what changed there,
519/// then send what changed here. Nothing, for a server that does not keep
520/// profiles.
521pub fn reconcile(db: &Database, client: &SubsonicClient, url: &str) -> DspSync {
522    let offers = crate::remote::profile::for_auth(client.auth())
523        .is_some_and(|p| p.offers(crate::remote::profile::DSP_PROFILES));
524    if !offers {
525        return DspSync::default();
526    }
527    run(db, client, url)
528}
529
530/// [`reconcile`], given the server.
531pub fn run(db: &Database, remote: &dyn Remote, url: &str) -> DspSync {
532    try_run(db, remote, url).unwrap_or_else(|e| {
533        log::warn!("dsp sync: {e}");
534        DspSync::default()
535    })
536}
537
538fn try_run(db: &Database, remote: &dyn Remote, url: &str) -> Result<DspSync, Failed> {
539    let _one = ONE_AT_A_TIME.lock();
540    APPLYING.with(|a| a.set(true));
541    let out = sync_with(db, remote, url);
542    APPLYING.with(|a| a.set(false));
543    if out.as_ref().is_ok_and(DspSync::changed) {
544        crate::signal::engine_changed().bump();
545    }
546    out
547}
548
549/// A sync is waiting for the one under way, and will send every edit made
550/// before it starts.
551static QUEUED: AtomicBool = AtomicBool::new(false);
552
553/// After an edit here: send it, in the background, where there is a server
554/// to send it to. Edits in quick succession, a drag on the graph saving
555/// several times a second, share one sync: at most one runs and one waits.
556pub fn changed() {
557    if APPLYING.with(Cell::get) {
558        return;
559    }
560    if DEFERRED.load(Ordering::Relaxed) {
561        PENDING.store(true, Ordering::Relaxed);
562        return;
563    }
564    let cfg = Config::cached();
565    if crate::helpers::subsonic_auth(&cfg).is_none() {
566        return;
567    }
568    if QUEUED.swap(true, Ordering::AcqRel) {
569        return;
570    }
571    let spawned = std::thread::Builder::new()
572        .name("koan-dsp-sync".into())
573        .spawn(|| {
574            // Wait out the sync under way; edits from here on are this one's.
575            drop(ONE_AT_A_TIME.lock());
576            QUEUED.store(false, Ordering::Release);
577            let Ok(db) = crate::db::pool::shared().get() else {
578                return;
579            };
580            sync(&db);
581        });
582    if let Err(e) = spawned {
583        QUEUED.store(false, Ordering::Release);
584        log::warn!("dsp sync: could not start: {e}");
585    }
586}
587
588/// What syncing did to `name` that its page says, if anything: a rename.
589pub fn note(db: &Database, name: &str) -> Option<String> {
590    let cfg = Config::cached();
591    let uid = cfg
592        .dsp
593        .profiles
594        .iter()
595        .find(|p| p.name == name)?
596        .uid
597        .clone()?;
598    rows::local_edits(&db.conn).ok()?.remove(&uid)?.note
599}
600
601/// Why the server refused to keep `name`, if it did.
602pub fn refusal(db: &Database, name: &str) -> Option<String> {
603    let cfg = Config::cached();
604    let uid = cfg
605        .dsp
606        .profiles
607        .iter()
608        .find(|p| p.name == name)?
609        .uid
610        .clone()?;
611    rows::local_edits(&db.conn).ok()?.remove(&uid)?.refused
612}
613
614#[derive(Debug, thiserror::Error)]
615enum Failed {
616    #[error("{0}")]
617    Db(#[from] crate::db::connection::DbError),
618    #[error("{0}")]
619    Remote(#[from] SubsonicError),
620    #[error("{0}")]
621    Config(#[from] crate::config::ConfigError),
622}
623
624/// A profile kept everywhere, as it stands here.
625struct Local {
626    doc: SyncDoc,
627    hash: String,
628    paths: HashMap<String, PathBuf>,
629}
630
631fn now_ms() -> i64 {
632    std::time::SystemTime::now()
633        .duration_since(std::time::UNIX_EPOCH)
634        .map_or(0, |d| d.as_millis() as i64)
635}
636
637fn sync_with(db: &Database, remote: &dyn Remote, url: &str) -> Result<DspSync, Failed> {
638    let mut out = DspSync::default();
639    first_sync(db)?;
640    let mut locals = observe(db, url)?;
641    let mut dismissed = pull(db, remote, url, &mut locals, &mut out)?;
642    if push(db, remote, url, &locals, &dismissed, &mut out)? {
643        // The server kept a copy edited later than one sent: take it.
644        locals = observe(db, url)?;
645        dismissed = pull(db, remote, url, &mut locals, &mut out)?;
646    }
647    let _ = dismissed;
648    Ok(out)
649}
650
651/// The first time this device syncs, what it already had stays on it unless
652/// it came from AutoEQ: nothing leaves a device unless it was made to travel
653/// or chosen to.
654fn first_sync(db: &Database) -> Result<(), Failed> {
655    let first = rows::local_edits(&db.conn)?.is_empty()
656        && db
657            .conn
658            .query_row("SELECT COUNT(*) FROM dsp_sync_cursor", [], |r| {
659                r.get::<_, i64>(0)
660            })
661            .map_err(crate::db::connection::DbError::from)?
662            == 0;
663    if !first {
664        return Ok(());
665    }
666    let cfg = Config::cached();
667    let pin: Vec<String> = cfg
668        .dsp
669        .profiles
670        .iter()
671        .filter(|p| {
672            p.scope.is_none() && p.target.is_none() && p.measured().is_none() && p.layers.is_empty()
673        })
674        .map(|p| p.name.clone())
675        .collect();
676    if !pin.is_empty() {
677        Config::persist(|c| {
678            for p in c.dsp.profiles.iter_mut().filter(|p| pin.contains(&p.name)) {
679                p.scope = Some(DspScope::Device);
680            }
681        })?;
682    }
683    Ok(())
684}
685
686/// Every profile kept everywhere, each given a uid if it lacks one, with an
687/// edit recorded for each whose content moved since it was last seen.
688///
689/// A profile that travels has its scope set to everywhere, so only a choice
690/// made later keeps it on this device. Left to be worked out, it would
691/// change on its own: set for a built-in output, given an impulse response,
692/// or a stack given a layer kept here, it would read as kept here alone, and
693/// `push` would delete it from the server and every other device. One synced
694/// already whose worked-out scope has since changed is set back, for the
695/// same reason.
696fn observe(db: &Database, url: &str) -> Result<HashMap<String, Local>, Failed> {
697    use crate::audio::dsp::profiles::scope;
698    let cfg = Config::cached();
699    let all = cfg.dsp.profiles.clone();
700    let synced = rows::synced(&db.conn, url)?;
701    let unpinned: Vec<String> = all
702        .iter()
703        .filter(|p| p.scope.is_none())
704        .filter(|p| {
705            scope(p, &all) == DspScope::Everywhere
706                || p.uid.as_ref().is_some_and(|u| synced.contains_key(u))
707        })
708        .map(|p| p.name.clone())
709        .collect();
710    if !unpinned.is_empty() {
711        Config::persist(|c| {
712            for p in c
713                .dsp
714                .profiles
715                .iter_mut()
716                .filter(|p| unpinned.contains(&p.name))
717            {
718                p.scope = Some(DspScope::Everywhere);
719            }
720        })?;
721        return observe(db, url);
722    }
723    let everywhere: Vec<&DspProfile> = all
724        .iter()
725        .filter(|p| crate::audio::dsp::profiles::scope(p, &all) == DspScope::Everywhere)
726        .collect();
727    let unnamed: Vec<String> = everywhere
728        .iter()
729        .filter(|p| p.uid.is_none())
730        .map(|p| p.name.clone())
731        .collect();
732    if !unnamed.is_empty() {
733        Config::persist(|c| {
734            for p in c
735                .dsp
736                .profiles
737                .iter_mut()
738                .filter(|p| unnamed.contains(&p.name))
739            {
740                p.uid = Some(uuid::Uuid::now_v7().to_string());
741            }
742        })?;
743        return observe(db, url);
744    }
745    let seen = rows::local_edits(&db.conn)?;
746    let mut locals = HashMap::new();
747    for p in everywhere {
748        let uid = p.uid.clone().expect("given one above");
749        match doc_of(p) {
750            Ok((doc, paths)) => {
751                let hash = doc.hash();
752                if seen.get(&uid).is_none_or(|s| s.hash != hash) {
753                    rows::set_local_edit(&db.conn, &uid, &hash, now_ms())?;
754                }
755                locals.insert(uid, Local { doc, hash, paths });
756            }
757            Err(why) => {
758                if seen
759                    .get(&uid)
760                    .is_none_or(|s| s.refused.as_deref() != Some(&why))
761                {
762                    rows::set_local_edit(&db.conn, &uid, "", now_ms())?;
763                    rows::set_refused(&db.conn, &uid, Some(&why))?;
764                }
765            }
766        }
767    }
768    Ok(locals)
769}
770
771/// Take what changed on the server after this device's cursor. Stops before
772/// a profile whose files cannot be fetched yet, to read it again next time.
773/// Returns the outputs the account dismissed AutoEQ for.
774fn pull(
775    db: &Database,
776    remote: &dyn Remote,
777    url: &str,
778    locals: &mut HashMap<String, Local>,
779    out: &mut DspSync,
780) -> Result<HashSet<String>, Failed> {
781    let cursor = rows::sync_cursor(&db.conn, url)?;
782    let page = remote.changes(cursor)?;
783    let mut synced = rows::synced(&db.conn, url)?;
784    let edits = rows::local_edits(&db.conn)?;
785    let mut held = false;
786    for row in &page.profile {
787        // This device's own change, sent after the cursor was last moved:
788        // already here, or deleted here since, which is sent next.
789        if synced.get(&row.uid).is_some_and(|(rev, _)| *rev >= row.rev) {
790            rows::set_sync_cursor(&db.conn, url, row.rev)?;
791            continue;
792        }
793        // The same profile made here before this device first synced, such
794        // as one headphone installed from AutoEQ on two devices: one profile.
795        if let Some(doc) = row.doc.as_deref().and_then(|j| SyncDoc::parse(j).ok()) {
796            let folder = crate::audio::dsp::profiles::dir(&doc.profile.name);
797            let member = |name: &str| {
798                let cfg = Config::cached();
799                cfg.dsp
800                    .profiles
801                    .iter()
802                    .find(|p| p.name == name)
803                    .and_then(|p| doc_of(p).ok())
804                    .map(|(d, _)| d)
805            };
806            let twin = locals
807                .iter()
808                .find(|(uid, l)| {
809                    **uid != row.uid
810                        && !synced.contains_key(*uid)
811                        && crate::audio::dsp::profiles::dir(&l.doc.profile.name) == folder
812                        && sounds_same(&l.doc, &doc, &member)
813                })
814                .map(|(uid, _)| uid.clone());
815            if let Some(twin) = twin {
816                Config::persist(|c| {
817                    for p in c
818                        .dsp
819                        .profiles
820                        .iter_mut()
821                        .filter(|p| p.uid.as_deref() == Some(&twin))
822                    {
823                        p.uid = Some(row.uid.clone());
824                    }
825                })?;
826                rows::forget_local(&db.conn, &twin)?;
827                let l = locals.remove(&twin).expect("found above");
828                locals.insert(row.uid.clone(), l);
829            }
830        }
831        let local = locals.get(&row.uid);
832        let dirty = local.is_some_and(|l| synced.get(&row.uid).is_none_or(|(_, h)| *h != l.hash));
833        let edited_here = edits.get(&row.uid).map_or(0, |e| e.edited_at);
834        if dirty && edited_here > row.edited_at {
835            // Edited here since: this device's copy is sent instead.
836        } else if let Some(json) = &row.doc {
837            let doc = match SyncDoc::parse(json) {
838                Ok(doc) => doc,
839                Err(e) => {
840                    log::warn!("dsp sync: skipping {}: {e}", row.uid);
841                    rows::set_sync_cursor(&db.conn, url, row.rev)?;
842                    continue;
843                }
844            };
845            let have: HashSet<&str> = local
846                .map(|l| l.paths.keys().map(String::as_str).collect())
847                .unwrap_or_default();
848            let mut fetched = Vec::new();
849            for f in doc
850                .files
851                .iter()
852                .filter(|f| !have.contains(f.sha256.as_str()))
853            {
854                match remote.file(&f.sha256) {
855                    Ok(bytes) if sha256_hex(&bytes) == f.sha256 => {
856                        fetched.push((f.sha256.clone(), bytes))
857                    }
858                    other => {
859                        log::info!(
860                            "dsp sync: {} not fetched yet ({:?}); holding the cursor",
861                            f.name,
862                            other.err()
863                        );
864                        held = true;
865                        break;
866                    }
867                }
868            }
869            if held {
870                break;
871            }
872            let reused: HashMap<String, PathBuf> =
873                local.map(|l| l.paths.clone()).unwrap_or_default();
874            for (renamed, old, new) in adopt(&row.uid, &doc, fetched, &reused, &synced)? {
875                let why = format!(
876                    "Renamed from “{old}”: it met a different EQ of that name from another device, so each is named for where it came from"
877                );
878                log::info!("dsp sync: {old} is now {new}");
879                if let Some(uid) = renamed {
880                    rows::set_note(&db.conn, &uid, &why)?;
881                }
882            }
883            let hash = doc.hash();
884            rows::set_synced(&db.conn, url, &row.uid, row.rev, &hash)?;
885            rows::set_local_edit(&db.conn, &row.uid, &hash, row.edited_at)?;
886            synced.insert(row.uid.clone(), (row.rev, hash));
887            out.applied += 1;
888        } else {
889            drop_profile(&row.uid)?;
890            rows::forget_synced(&db.conn, url, &row.uid)?;
891            rows::forget_local(&db.conn, &row.uid)?;
892            synced.remove(&row.uid);
893            locals.remove(&row.uid);
894            out.applied += 1;
895        }
896        rows::set_sync_cursor(&db.conn, url, row.rev)?;
897    }
898    if !held {
899        rows::set_sync_cursor(&db.conn, url, page.cursor)?;
900    }
901    let dismissed: HashSet<String> = page.dismissed.into_iter().map(|d| d.output).collect();
902    let cfg = Config::cached();
903    let missing: Vec<String> = dismissed
904        .iter()
905        .filter(|d| !cfg.dsp.autoeq_dismissed.contains(d))
906        .cloned()
907        .collect();
908    if !missing.is_empty() {
909        Config::persist(|c| c.dsp.autoeq_dismissed.extend(missing))?;
910    }
911    if out.applied > 0 {
912        *locals = observe(db, url)?;
913    }
914    Ok(dismissed)
915}
916
917/// Send each profile kept everywhere that changed here since it was last
918/// synced, and delete on the server those that went from here. Whether the
919/// server kept a later copy of any, which a pull then takes.
920fn push(
921    db: &Database,
922    remote: &dyn Remote,
923    url: &str,
924    locals: &HashMap<String, Local>,
925    dismissed: &HashSet<String>,
926    out: &mut DspSync,
927) -> Result<bool, Failed> {
928    let synced = rows::synced(&db.conn, url)?;
929    let edits = rows::local_edits(&db.conn)?;
930    let cfg = Config::cached();
931    // Deleted here, or kept here alone by choice: gone everywhere else too.
932    // Deletions go first, so a profile deleted and made again under its
933    // name reaches other devices after the one it replaces has left.
934    let mut sent = HashSet::new();
935    let mut restore = false;
936    for uid in synced.keys().filter(|u| !locals.contains_key(*u)) {
937        let deleted = cfg.dsp.removed.contains(uid);
938        let kept_here = cfg
939            .dsp
940            .profiles
941            .iter()
942            .any(|p| p.uid.as_deref() == Some(uid) && p.scope == Some(DspScope::Device));
943        if !deleted && !kept_here {
944            if edits.get(uid).is_some_and(|e| e.refused.is_some()) {
945                continue;
946            }
947            // Missing without having been deleted: a config that did not
948            // load, or one edited by hand. Never a reason to delete it from
949            // every device; the server's copy is taken again instead. The
950            // cursor goes back first, so the copy is found whatever happens
951            // after.
952            log::warn!("dsp sync: {uid} is missing here but was not deleted; restoring it");
953            rows::set_sync_cursor(&db.conn, url, 0)?;
954            rows::forget_synced(&db.conn, url, uid)?;
955            restore = true;
956            continue;
957        }
958        remote.delete(uid, now_ms())?;
959        rows::forget_synced(&db.conn, url, uid)?;
960        rows::forget_local(&db.conn, uid)?;
961        sent.insert(uid.clone());
962        out.sent += 1;
963    }
964    // A deletion stays listed until it is sent: one made while this sync ran
965    // is sent by the next, its profile included where this pass sends it
966    // first. One neither synced nor being sent has nothing to send.
967    if cfg
968        .dsp
969        .removed
970        .iter()
971        .any(|u| sent.contains(u) || !(synced.contains_key(u) || locals.contains_key(u)))
972    {
973        Config::persist(|c| {
974            c.dsp
975                .removed
976                .retain(|u| !sent.contains(u) && (synced.contains_key(u) || locals.contains_key(u)))
977        })?;
978    }
979    // What was kept everywhere and no longer is, and was never sent or has
980    // now been deleted: nothing is left to track. One kept everywhere that
981    // cannot be sent is not in `locals`, and keeps its refusal.
982    let cfg = Config::cached();
983    let kept: HashSet<&str> = cfg
984        .dsp
985        .profiles
986        .iter()
987        .filter(|p| {
988            crate::audio::dsp::profiles::scope(p, &cfg.dsp.profiles) == DspScope::Everywhere
989        })
990        .filter_map(|p| p.uid.as_deref())
991        .collect();
992    for uid in edits
993        .keys()
994        .filter(|u| !kept.contains(u.as_str()) && !synced.contains_key(*u))
995    {
996        rows::forget_local(&db.conn, uid)?;
997    }
998    let mut kept_later = restore;
999    let mut uids: Vec<&String> = locals.keys().collect();
1000    uids.sort();
1001    for uid in uids {
1002        let local = &locals[uid];
1003        if synced.get(uid).is_some_and(|(_, h)| *h == local.hash) {
1004            continue;
1005        }
1006        // Never synced from here, or synced and then deleted on the server
1007        // when it was kept here alone: sending it now is the edit.
1008        let edited_at = match synced.get(uid) {
1009            Some(_) => edits.get(uid).map_or_else(now_ms, |e| e.edited_at),
1010            None => now_ms(),
1011        };
1012        let saved = match remote.save(uid, edited_at, &local.doc.json()) {
1013            Ok(saved) => saved,
1014            Err(SubsonicError::Api { message, .. }) => {
1015                rows::set_refused(&db.conn, uid, Some(&message))?;
1016                continue;
1017            }
1018            Err(e) => return Err(e.into()),
1019        };
1020        if !saved.stored {
1021            kept_later = true;
1022            continue;
1023        }
1024        for m in &saved.missing {
1025            let Some(path) = local.paths.get(&m.sha256) else {
1026                continue;
1027            };
1028            let bytes = std::fs::read(path)?;
1029            remote.upload(&m.sha256, bytes)?;
1030        }
1031        rows::set_synced(&db.conn, url, uid, saved.rev, &local.hash)?;
1032        rows::set_refused(&db.conn, uid, None)?;
1033        out.sent += 1;
1034    }
1035    let cfg = Config::cached();
1036    for output in cfg
1037        .dsp
1038        .autoeq_dismissed
1039        .iter()
1040        .filter(|d| !dismissed.contains(*d))
1041    {
1042        remote.dismiss(output)?;
1043    }
1044    Ok(kept_later)
1045}
1046
1047impl From<std::io::Error> for Failed {
1048    fn from(e: std::io::Error) -> Self {
1049        Self::Remote(SubsonicError::Io(e))
1050    }
1051}
1052
1053/// Take `doc` as profile `uid`: update the one here with that uid, or add
1054/// it. Another profile here holding its name, not yet synced, gives the name
1055/// up and is renamed. Its folder is made to hold exactly the doc's files:
1056/// `fetched` by content, the rest from `reused`.
1057/// Renamed profiles, each by uid where it has one: its old name and new.
1058type Renamed = Vec<(Option<String>, String, String)>;
1059
1060fn adopt(
1061    uid: &str,
1062    doc: &SyncDoc,
1063    fetched: Vec<(String, Vec<u8>)>,
1064    reused: &HashMap<String, PathBuf>,
1065    synced: &HashMap<String, (i64, String)>,
1066) -> Result<Renamed, Failed> {
1067    use crate::audio::dsp::profiles;
1068    let cfg = Config::cached();
1069    let name = doc.profile.name.clone();
1070    let current = cfg
1071        .dsp
1072        .profiles
1073        .iter()
1074        .find(|p| p.uid.as_deref() == Some(uid));
1075    // Another profile whose folder this one's name maps to gives the name
1076    // up, so its files are never written over: folders are keyed by the
1077    // name's slug, not the name.
1078    let mut name = name;
1079    let mut renamed = Vec::new();
1080    let folder = profiles::dir(&name);
1081    if let Some(holder) = cfg
1082        .dsp
1083        .profiles
1084        .iter()
1085        .find(|p| profiles::dir(&p.name) == folder && p.uid.as_deref() != Some(uid))
1086        && holder.uid.as_ref().is_some_and(|u| synced.contains_key(u))
1087    {
1088        // Already synced: a later row on this page renames or deletes it, as
1089        // with a rename chain or a profile deleted and made again. Until then
1090        // it steps aside.
1091        let free = free_name(&cfg.dsp.profiles, &holder.name);
1092        profiles::rename(&holder.name, &free).map_err(io_failed)?;
1093    } else if let Some(holder) = cfg
1094        .dsp
1095        .profiles
1096        .iter()
1097        .find(|p| profiles::dir(&p.name) == folder && p.uid.as_deref() != Some(uid))
1098    {
1099        // Two different profiles of one name, made apart before either
1100        // synced: each is named for the device it came from, so neither
1101        // looks like the other.
1102        let here = this_device();
1103        let mine = by_device(&cfg.dsp.profiles, &holder.name, &here);
1104        profiles::rename(&holder.name, &mine).map_err(io_failed)?;
1105        renamed.push((holder.uid.clone(), holder.name.clone(), mine));
1106        let theirs = match doc.profile.origin.as_deref() {
1107            Some(origin) if origin != here => format!("{name} ({origin})"),
1108            _ => free_name(&Config::cached().dsp.profiles, &name),
1109        };
1110        let theirs = if Config::cached()
1111            .dsp
1112            .profiles
1113            .iter()
1114            .any(|p| p.name == theirs)
1115        {
1116            free_name(&Config::cached().dsp.profiles, &theirs)
1117        } else {
1118            theirs
1119        };
1120        renamed.push((Some(uid.to_owned()), name.clone(), theirs.clone()));
1121        name = theirs;
1122    }
1123    if let Some(current) = current
1124        && current.name != name
1125    {
1126        profiles::rename(&current.name, &name).map_err(io_failed)?;
1127    }
1128
1129    // The folder: every file the doc names, nothing else.
1130    let dir = profiles::dir(&name);
1131    let staged = dir.with_extension("incoming");
1132    let _ = std::fs::remove_dir_all(&staged);
1133    std::fs::create_dir_all(&staged)?;
1134    let fetched: HashMap<String, Vec<u8>> = fetched.into_iter().collect();
1135    for f in &doc.files {
1136        let target = staged.join(&f.name);
1137        match fetched.get(&f.sha256) {
1138            Some(bytes) => std::fs::write(&target, bytes)?,
1139            None => {
1140                let from = reused
1141                    .get(&f.sha256)
1142                    .ok_or_else(|| io_failed(format!("{} has not arrived", f.name)))?;
1143                std::fs::copy(from, &target)?;
1144            }
1145        }
1146    }
1147    let _ = std::fs::remove_dir_all(&dir);
1148    if doc.files.is_empty() {
1149        let _ = std::fs::remove_dir_all(&staged);
1150    } else {
1151        std::fs::rename(&staged, &dir)?;
1152    }
1153
1154    let relative = Path::new("dsp").join(dir.file_name().expect("a profile's folder has a name"));
1155    Config::persist(|c| {
1156        let mut incoming = doc.profile.clone();
1157        incoming.name = name.clone();
1158        incoming.impulses = incoming.impulses.iter().map(|f| relative.join(f)).collect();
1159        incoming.uid = Some(uid.to_owned());
1160        incoming.scope = Some(DspScope::Everywhere);
1161        match c
1162            .dsp
1163            .profiles
1164            .iter_mut()
1165            .find(|p| p.uid.as_deref() == Some(uid))
1166        {
1167            Some(p) => {
1168                incoming.devices = std::mem::take(&mut p.devices);
1169                *p = incoming;
1170            }
1171            None => {
1172                c.dsp.profiles.push(incoming);
1173                // Back after being deleted here, as a restore brings it: the
1174                // deletion is no longer one to send.
1175                c.dsp.removed.retain(|u| u != uid);
1176            }
1177        }
1178    })?;
1179    Ok(renamed)
1180}
1181
1182/// `name` with `device` after it, as a profile here may be called: numbered
1183/// as well if even that is taken.
1184fn by_device(profiles: &[DspProfile], name: &str, device: &str) -> String {
1185    let wanted = format!("{name} ({device})");
1186    if profiles.iter().any(|p| p.name == wanted) {
1187        free_name(profiles, &wanted)
1188    } else {
1189        wanted
1190    }
1191}
1192
1193/// Delete the profile here with `uid`, and its folder. A stack that played
1194/// it says so, as for any missing layer.
1195fn drop_profile(uid: &str) -> Result<(), Failed> {
1196    let cfg = Config::cached();
1197    let Some(profile) = cfg
1198        .dsp
1199        .profiles
1200        .iter()
1201        .find(|p| p.uid.as_deref() == Some(uid))
1202    else {
1203        return Ok(());
1204    };
1205    if crate::audio::dsp::profiles::scope(profile, &cfg.dsp.profiles) == DspScope::Device {
1206        // Kept here by choice since: the deletion was this device's own.
1207        return Ok(());
1208    }
1209    let dir = crate::audio::dsp::profiles::dir(&profile.name);
1210    let shared =
1211        cfg.dsp.profiles.iter().any(|p| {
1212            p.uid.as_deref() != Some(uid) && crate::audio::dsp::profiles::dir(&p.name) == dir
1213        });
1214    Config::persist(|c| c.dsp.profiles.retain(|p| p.uid.as_deref() != Some(uid)))?;
1215    if !shared {
1216        let _ = std::fs::remove_dir_all(dir);
1217    }
1218    Ok(())
1219}
1220
1221/// `name` numbered, until neither the name nor its folder is taken.
1222fn free_name(profiles: &[DspProfile], name: &str) -> String {
1223    use crate::audio::dsp::profiles::dir;
1224    (2..)
1225        .map(|n| format!("{name} {n}"))
1226        .find(|candidate| {
1227            profiles
1228                .iter()
1229                .all(|p| &p.name != candidate && dir(&p.name) != dir(candidate))
1230        })
1231        .expect("some number is free")
1232}
1233
1234fn io_failed(e: impl ToString) -> Failed {
1235    Failed::Remote(SubsonicError::Io(std::io::Error::other(e.to_string())))
1236}
1237
1238#[cfg(test)]
1239mod tests {
1240    use std::cell::RefCell;
1241    use std::path::Path;
1242
1243    use super::*;
1244    use crate::config::{DspFilter, DspTarget, EqFilter, EqFilterKind};
1245    use crate::remote::client::{KoanDspDismissed, KoanDspMissing, KoanDspProfile};
1246
1247    /// The server's side, over the same queries a kōan server runs.
1248    struct Server {
1249        conn: rusqlite::Connection,
1250        uploads: RefCell<usize>,
1251    }
1252
1253    const USER: i64 = 1;
1254
1255    impl Server {
1256        fn new() -> Self {
1257            let conn = rusqlite::Connection::open_in_memory().unwrap();
1258            crate::db::schema::create_tables(&conn).unwrap();
1259            conn.execute(
1260                "INSERT INTO users (id, username, password_hash, role) VALUES (1, 'mate', 'x', 'user')",
1261                [],
1262            )
1263            .unwrap();
1264            Self {
1265                conn,
1266                uploads: RefCell::new(0),
1267            }
1268        }
1269
1270        fn named(&self) -> HashMap<String, u64> {
1271            rows::live_docs(&self.conn, USER)
1272                .unwrap()
1273                .into_iter()
1274                .flat_map(|(_, json)| SyncDoc::parse(&json).unwrap().files)
1275                .map(|f| (f.sha256, f.size))
1276                .collect()
1277        }
1278    }
1279
1280    fn api(message: &str) -> SubsonicError {
1281        SubsonicError::Api {
1282            code: 0,
1283            message: message.into(),
1284        }
1285    }
1286
1287    impl Remote for Server {
1288        fn changes(&self, since: i64) -> Result<KoanDspProfiles, SubsonicError> {
1289            let (rows, cursor) = rows::changes(&self.conn, USER, since).unwrap();
1290            Ok(KoanDspProfiles {
1291                cursor,
1292                profile: rows
1293                    .into_iter()
1294                    .map(|r| KoanDspProfile {
1295                        uid: r.uid,
1296                        rev: r.rev,
1297                        edited_at: r.edited_at,
1298                        doc: r.doc,
1299                    })
1300                    .collect(),
1301                dismissed: rows::dismissed(&self.conn, USER)
1302                    .unwrap()
1303                    .into_iter()
1304                    .map(|output| KoanDspDismissed { output })
1305                    .collect(),
1306            })
1307        }
1308        fn save(
1309            &self,
1310            uid: &str,
1311            edited_at: i64,
1312            doc: &str,
1313        ) -> Result<KoanDspSaved, SubsonicError> {
1314            let parsed = SyncDoc::parse(doc).map_err(|e| api(&e))?;
1315            let saved = rows::save(&self.conn, USER, uid, edited_at, Some(doc)).unwrap();
1316            let held = rows::files(&self.conn, USER).unwrap();
1317            Ok(KoanDspSaved {
1318                rev: saved.rev,
1319                stored: saved.stored,
1320                missing: parsed
1321                    .files
1322                    .iter()
1323                    .filter(|f| saved.stored && !held.contains_key(&f.sha256))
1324                    .map(|f| KoanDspMissing {
1325                        sha256: f.sha256.clone(),
1326                    })
1327                    .collect(),
1328            })
1329        }
1330        fn delete(&self, uid: &str, edited_at: i64) -> Result<KoanDspSaved, SubsonicError> {
1331            let saved = rows::save(&self.conn, USER, uid, edited_at, None).unwrap();
1332            Ok(KoanDspSaved {
1333                rev: saved.rev,
1334                stored: saved.stored,
1335                missing: vec![],
1336            })
1337        }
1338        fn dismiss(&self, output: &str) -> Result<(), SubsonicError> {
1339            rows::dismiss(&self.conn, USER, output).unwrap();
1340            Ok(())
1341        }
1342        fn file(&self, sha256: &str) -> Result<Vec<u8>, SubsonicError> {
1343            rows::file(&self.conn, USER, sha256)
1344                .unwrap()
1345                .ok_or_else(|| api("File not found"))
1346        }
1347        fn upload(&self, sha256: &str, data: Vec<u8>) -> Result<(), SubsonicError> {
1348            assert_eq!(sha256_hex(&data), sha256);
1349            assert_eq!(self.named().get(sha256), Some(&(data.len() as u64)));
1350            rows::store_file(&self.conn, USER, sha256, &data).unwrap();
1351            *self.uploads.borrow_mut() += 1;
1352            Ok(())
1353        }
1354    }
1355
1356    /// A device: its own config directory and database.
1357    struct Device {
1358        dir: tempfile::TempDir,
1359        db: Database,
1360        name: &'static str,
1361    }
1362
1363    impl Device {
1364        fn new() -> Self {
1365            Self::named("Device")
1366        }
1367
1368        fn named(name: &'static str) -> Self {
1369            let conn = rusqlite::Connection::open_in_memory().unwrap();
1370            crate::db::schema::create_tables(&conn).unwrap();
1371            Self {
1372                dir: tempfile::tempdir().unwrap(),
1373                db: Database { conn },
1374                name,
1375            }
1376        }
1377
1378        /// Make this the device the config belongs to.
1379        fn on(&self) -> &Self {
1380            config::set_config_dir(self.dir.path());
1381            set_device_name(Some(self.name.to_owned()));
1382            self
1383        }
1384
1385        fn sync(&self, server: &Server) -> DspSync {
1386            self.on();
1387            // Edits made in the same millisecond would tie.
1388            std::thread::sleep(std::time::Duration::from_millis(3));
1389            run(&self.db, server, "https://music.example")
1390        }
1391
1392        fn profiles(&self) -> Vec<DspProfile> {
1393            self.on();
1394            Config::cached().dsp.profiles.clone()
1395        }
1396
1397        fn profile(&self, name: &str) -> Option<DspProfile> {
1398            self.profiles().into_iter().find(|p| p.name == name)
1399        }
1400    }
1401
1402    fn band(gain_db: f64) -> DspFilter {
1403        DspFilter::Band(EqFilter {
1404            kind: EqFilterKind::Peaking,
1405            freq: 1000.0,
1406            gain_db,
1407            q: 1.0,
1408            channels: vec![],
1409        })
1410    }
1411
1412    fn headphone() -> DspProfile {
1413        DspProfile {
1414            name: "HD 650 (AutoEQ, oratory1990)".into(),
1415            devices: vec!["Topping E30".into()],
1416            filters: vec![band(3.0)],
1417            target: Some(DspTarget {
1418                made_for: "harman-over-ear-2018".into(),
1419                chosen: None,
1420            }),
1421            ..Default::default()
1422        }
1423    }
1424
1425    /// Deferred, an edit waits for `flush` and starts no thread a CLI would
1426    /// exit before; with no server signed in to, `flush` takes it and
1427    /// sends nothing.
1428    #[test]
1429    fn deferred_edits_wait_for_flush() {
1430        let _guard = lock();
1431        let here = Device::new();
1432        here.on();
1433        defer();
1434        changed();
1435        assert!(PENDING.load(Ordering::Relaxed));
1436        assert!(flush(&here.db).is_none());
1437        assert!(!PENDING.load(Ordering::Relaxed));
1438        assert!(flush(&here.db).is_none());
1439        DEFERRED.store(false, Ordering::Relaxed);
1440    }
1441
1442    fn lock() -> std::sync::MutexGuard<'static, ()> {
1443        crate::config::tests::PERSIST_LOCK
1444            .lock()
1445            .unwrap_or_else(|e| e.into_inner())
1446    }
1447
1448    /// A headphone correction goes everywhere, without the output it was set
1449    /// for; what a device had of its own before stays on it; an edit made
1450    /// later wins; a deletion reaches every device.
1451    #[test]
1452    fn profiles_follow_the_account() {
1453        let _guard = lock();
1454        let server = Server::new();
1455        let (a, b) = (Device::new(), Device::new());
1456
1457        a.on();
1458        Config::persist(|c| {
1459            c.dsp.profiles.push(headphone());
1460            c.dsp.profiles.push(DspProfile {
1461                name: "Desk speakers".into(),
1462                filters: vec![band(-2.0)],
1463                ..Default::default()
1464            });
1465        })
1466        .unwrap();
1467        assert_eq!(a.sync(&server).sent, 1);
1468        assert_eq!(
1469            a.profile("Desk speakers").unwrap().scope,
1470            Some(DspScope::Device),
1471            "kept here: it was here before syncing"
1472        );
1473
1474        b.sync(&server);
1475        let theirs = b.profile("HD 650 (AutoEQ, oratory1990)").unwrap();
1476        assert_eq!(theirs.filters, vec![band(3.0)]);
1477        assert!(
1478            theirs.devices.is_empty(),
1479            "which output uses it stays per device"
1480        );
1481        assert_eq!(
1482            theirs.uid,
1483            a.profile("HD 650 (AutoEQ, oratory1990)").unwrap().uid
1484        );
1485        assert!(b.profile("Desk speakers").is_none());
1486
1487        // Edited on A (by hand: the apps leave a correction as made), then
1488        // renamed on B, offline: B's later edit wins.
1489        a.on();
1490        Config::persist(|c| {
1491            let hd = c
1492                .dsp
1493                .profiles
1494                .iter_mut()
1495                .find(|p| p.name == "HD 650 (AutoEQ, oratory1990)")
1496                .unwrap();
1497            hd.filters[0] = band(5.0);
1498        })
1499        .unwrap();
1500        a.sync(&server);
1501        std::thread::sleep(std::time::Duration::from_millis(3));
1502        b.on();
1503        crate::audio::dsp::profiles::rename("HD 650 (AutoEQ, oratory1990)", "HD 650").unwrap();
1504        b.sync(&server);
1505        a.sync(&server);
1506        let a_now = a.profile("HD 650").expect("renamed on A too");
1507        assert_eq!(a_now.filters, vec![band(3.0)], "B's copy was edited later");
1508        assert_eq!(
1509            a_now.devices,
1510            vec!["Topping E30".to_string()],
1511            "A's output kept"
1512        );
1513        assert_eq!(b.profile("HD 650").unwrap().filters, vec![band(3.0)]);
1514
1515        // Deleted on B: gone from A.
1516        b.on();
1517        crate::audio::dsp::profiles::delete("HD 650").unwrap();
1518        b.sync(&server);
1519        a.sync(&server);
1520        assert!(a.profile("HD 650").is_none());
1521        assert!(a.profile("Desk speakers").is_some());
1522    }
1523
1524    /// EQ bands made by hand: kept everywhere only while nothing says
1525    /// otherwise, unlike an AutoEQ correction, whose target keeps it so.
1526    fn bass() -> DspProfile {
1527        DspProfile {
1528            name: "Bass".into(),
1529            filters: vec![band(4.0)],
1530            ..Default::default()
1531        }
1532    }
1533
1534    fn edit(name: &str, f: impl FnOnce(&mut DspProfile)) {
1535        Config::persist(|c| f(c.dsp.profiles.iter_mut().find(|p| p.name == name).unwrap()))
1536            .unwrap();
1537    }
1538
1539    fn removed() -> Vec<String> {
1540        Config::cached().dsp.removed.clone()
1541    }
1542
1543    /// A and B on one server, A past its first sync, with `made` synced to
1544    /// both.
1545    fn shared(made: Vec<DspProfile>) -> (Server, Device, Device) {
1546        let server = Server::new();
1547        let (a, b) = (Device::new(), Device::new());
1548        a.on();
1549        Config::persist(|c| c.dsp.profiles.push(headphone())).unwrap();
1550        a.sync(&server);
1551        a.on();
1552        Config::persist(|c| c.dsp.profiles.extend(made)).unwrap();
1553        a.sync(&server);
1554        b.sync(&server);
1555        (server, a, b)
1556    }
1557
1558    /// Sync `made` to B, change it on A with `drift`, a change that once
1559    /// made it read as kept on A alone, and sync again: `name` stays on the
1560    /// server and on B, and A keeps it everywhere. Returns A's last sync.
1561    fn stays_shared(made: Vec<DspProfile>, name: &str, drift: impl FnOnce()) -> DspSync {
1562        let (server, a, b) = shared(made);
1563        assert!(b.profile(name).is_some(), "{name} reached B");
1564
1565        a.on();
1566        drift();
1567        let synced = a.sync(&server);
1568        b.sync(&server);
1569
1570        let uid = a.profile(name).unwrap().uid.unwrap();
1571        assert!(
1572            rows::live_docs(&server.conn, USER)
1573                .unwrap()
1574                .iter()
1575                .any(|(u, _)| *u == uid),
1576            "{name} was deleted from the server"
1577        );
1578        assert!(b.profile(name).is_some(), "{name} was deleted from B");
1579        assert_eq!(a.profile(name).unwrap().scope, Some(DspScope::Everywhere));
1580        synced
1581    }
1582
1583    #[test]
1584    fn a_shared_profile_set_for_a_built_in_output_stays_shared() {
1585        let _guard = lock();
1586        stays_shared(vec![bass()], "Bass", || {
1587            edit("Bass", |p| p.devices.push("Speaker".into()));
1588        });
1589    }
1590
1591    #[test]
1592    fn a_shared_profile_given_an_impulse_response_stays_shared() {
1593        let _guard = lock();
1594        stays_shared(vec![bass()], "Bass", || {
1595            let dir = crate::audio::dsp::profiles::dir("Bass");
1596            std::fs::create_dir_all(&dir).unwrap();
1597            std::fs::write(dir.join("48000.wav"), b"RIFF not really").unwrap();
1598            let rel = Path::new("dsp")
1599                .join(dir.file_name().unwrap())
1600                .join("48000.wav");
1601            edit("Bass", |p| p.impulses.push(rel));
1602        });
1603    }
1604
1605    fn local_layer() -> DspProfile {
1606        DspProfile {
1607            name: "Desk".into(),
1608            filters: vec![band(-2.0)],
1609            scope: Some(DspScope::Device),
1610            ..Default::default()
1611        }
1612    }
1613
1614    fn stack(group: bool) -> DspProfile {
1615        DspProfile {
1616            name: "Stack".into(),
1617            layers: vec![crate::config::DspLayer {
1618                profile: "Bass".into(),
1619                on: true,
1620            }],
1621            group,
1622            ..Default::default()
1623        }
1624    }
1625
1626    fn add_local_layer(on: bool) {
1627        Config::persist(|c| c.dsp.profiles.push(local_layer())).unwrap();
1628        edit("Stack", |s| {
1629            s.layers.push(crate::config::DspLayer {
1630                profile: "Desk".into(),
1631                on,
1632            })
1633        });
1634    }
1635
1636    #[test]
1637    fn a_shared_stack_given_a_layer_kept_here_stays_shared() {
1638        let _guard = lock();
1639        stays_shared(vec![bass(), stack(false)], "Stack", || {
1640            add_local_layer(true)
1641        });
1642    }
1643
1644    #[test]
1645    fn a_shared_group_given_a_member_kept_here_stays_shared() {
1646        let _guard = lock();
1647        stays_shared(vec![bass(), stack(true)], "Stack", || {
1648            add_local_layer(false)
1649        });
1650    }
1651
1652    /// A profile synced by a build that left its scope to be worked out, and
1653    /// whose worked-out scope changed before this sync, is set back to
1654    /// everywhere as it is: nothing is deleted, and nothing taken again.
1655    #[test]
1656    fn a_synced_profile_that_drifted_before_this_build_stays_shared() {
1657        let _guard = lock();
1658        let synced = stays_shared(vec![bass()], "Bass", || {
1659            edit("Bass", |p| {
1660                p.scope = None;
1661                p.devices.push("Speaker".into());
1662            });
1663        });
1664        assert_eq!(synced.applied, 0, "kept as it was, not restored");
1665    }
1666
1667    /// A profile missing here without having been deleted, as after a
1668    /// config that did not load, is not deleted from the server or the
1669    /// other devices: the server's copy comes back.
1670    #[test]
1671    fn a_profile_missing_without_a_deletion_is_restored_not_deleted() {
1672        let _guard = lock();
1673        let (server, a, b) = shared(vec![bass()]);
1674        a.on();
1675        Config::persist(|c| c.dsp.profiles.retain(|p| p.name != "Bass")).unwrap();
1676        a.sync(&server);
1677        b.sync(&server);
1678
1679        assert!(b.profile("Bass").is_some(), "still on B");
1680        assert_eq!(
1681            a.profile("Bass").unwrap().filters,
1682            vec![band(4.0)],
1683            "back on A"
1684        );
1685    }
1686
1687    /// A deletion recorded after this sync looked at the profiles, as one
1688    /// made while it runs, is kept until a sync sends it.
1689    #[test]
1690    fn a_deletion_made_while_syncing_is_sent_by_the_next_sync() {
1691        let _guard = lock();
1692        let (server, a, b) = shared(vec![bass()]);
1693        a.on();
1694        let uid = a.profile("Bass").unwrap().uid.unwrap();
1695        // Recorded, with the profile still there when the sync looked.
1696        Config::persist(|c| c.dsp.removed.push(uid.clone())).unwrap();
1697        a.sync(&server);
1698        a.on();
1699        assert_eq!(removed(), vec![uid.clone()], "not sent yet, still listed");
1700
1701        Config::persist(|c| c.dsp.profiles.retain(|p| p.name != "Bass")).unwrap();
1702        a.sync(&server);
1703        b.sync(&server);
1704        assert!(b.profile("Bass").is_none(), "deleted on B");
1705        assert!(a.profile("Bass").is_none(), "not restored on A");
1706        a.on();
1707        assert!(removed().is_empty());
1708    }
1709
1710    /// A profile deleted after this sync looked at the profiles and before it
1711    /// was ever synced, as a new EQ deleted at once, stays deleted: the
1712    /// deletion is kept while the profile is still being sent, and sent next.
1713    #[test]
1714    fn a_profile_deleted_before_its_first_sync_stays_deleted() {
1715        let _guard = lock();
1716        let (server, a, b) = shared(vec![]);
1717        a.on();
1718        let uid = uuid::Uuid::now_v7().to_string();
1719        Config::persist(|c| {
1720            let mut new = bass();
1721            new.uid = Some(uid.clone());
1722            new.scope = Some(DspScope::Everywhere);
1723            c.dsp.profiles.push(new);
1724            // Deleted after the sync observed it.
1725            c.dsp.removed.push(uid.clone());
1726        })
1727        .unwrap();
1728        a.sync(&server);
1729        a.on();
1730        assert_eq!(removed(), vec![uid.clone()], "kept while it is sent");
1731
1732        Config::persist(|c| c.dsp.profiles.retain(|p| p.name != "Bass")).unwrap();
1733        a.sync(&server);
1734        b.sync(&server);
1735        assert!(a.profile("Bass").is_none(), "not restored on A");
1736        assert!(b.profile("Bass").is_none(), "never on B");
1737        a.on();
1738        assert!(removed().is_empty());
1739    }
1740
1741    /// Deleting a profile whose last edit the server refused still deletes
1742    /// it everywhere, and it does not come back.
1743    #[test]
1744    fn deleting_a_profile_whose_edit_was_refused_deletes_it() {
1745        let _guard = lock();
1746        let (server, a, b) = shared(vec![bass()]);
1747        a.on();
1748        let dir = crate::audio::dsp::profiles::dir("Bass");
1749        std::fs::create_dir_all(&dir).unwrap();
1750        std::fs::File::create(dir.join("48000.wav"))
1751            .unwrap()
1752            .set_len(MAX_FILE + 1)
1753            .unwrap();
1754        a.sync(&server);
1755        a.on();
1756        assert!(refusal(&a.db, "Bass").is_some());
1757
1758        crate::audio::dsp::profiles::delete("Bass").unwrap();
1759        a.sync(&server);
1760        a.sync(&server);
1761        b.sync(&server);
1762        assert!(a.profile("Bass").is_none(), "not restored on A");
1763        assert!(b.profile("Bass").is_none(), "deleted on B");
1764    }
1765
1766    /// Files travel by content: an impulse response arrives byte for byte,
1767    /// under the profile's folder on the other device.
1768    #[test]
1769    fn files_travel_with_their_profile() {
1770        let _guard = lock();
1771        let server = Server::new();
1772        let (a, b) = (Device::new(), Device::new());
1773        a.on();
1774        let dir = crate::audio::dsp::profiles::dir("Room");
1775        std::fs::create_dir_all(&dir).unwrap();
1776        std::fs::write(dir.join("48000.wav"), b"RIFF not really").unwrap();
1777        Config::persist(|c| {
1778            c.dsp.profiles.push(DspProfile {
1779                name: "Room".into(),
1780                impulses: vec![Path::new("dsp/room/48000.wav").into()],
1781                scope: Some(DspScope::Everywhere),
1782                ..Default::default()
1783            })
1784        })
1785        .unwrap();
1786        a.sync(&server);
1787        assert_eq!(*server.uploads.borrow(), 1);
1788        b.sync(&server);
1789        b.on();
1790        let room = b.profile("Room").unwrap();
1791        assert_eq!(room.impulses, vec![PathBuf::from("dsp/room/48000.wav")]);
1792        assert_eq!(
1793            std::fs::read(config::config_dir().join("dsp/room/48000.wav")).unwrap(),
1794            b"RIFF not really"
1795        );
1796
1797        // Kept on A alone now: gone from B, still on A.
1798        a.on();
1799        crate::audio::dsp::profiles::set_scope("Room", DspScope::Device).unwrap();
1800        a.sync(&server);
1801        b.sync(&server);
1802        assert!(b.profile("Room").is_none());
1803        assert!(a.profile("Room").is_some());
1804        // Neither device tracks it as kept everywhere any more.
1805        assert!(rows::local_edits(&a.db.conn).unwrap().is_empty());
1806        assert!(rows::local_edits(&b.db.conn).unwrap().is_empty());
1807    }
1808
1809    /// A profile kept everywhere that cannot be sent keeps its refusal, and
1810    /// its record, across syncs.
1811    #[test]
1812    fn an_unsendable_profile_keeps_its_refusal() {
1813        let _guard = lock();
1814        let server = Server::new();
1815        let a = Device::new();
1816        a.on();
1817        let dir = crate::audio::dsp::profiles::dir("Huge");
1818        std::fs::create_dir_all(&dir).unwrap();
1819        std::fs::File::create(dir.join("48000.wav"))
1820            .unwrap()
1821            .set_len(MAX_FILE + 1)
1822            .unwrap();
1823        Config::persist(|c| {
1824            c.dsp.profiles.push(DspProfile {
1825                name: "Huge".into(),
1826                impulses: vec![Path::new("dsp/huge/48000.wav").into()],
1827                scope: Some(DspScope::Everywhere),
1828                ..Default::default()
1829            })
1830        })
1831        .unwrap();
1832        a.sync(&server);
1833        a.sync(&server);
1834        a.on();
1835        assert!(refusal(&a.db, "Huge").is_some());
1836        assert_eq!(rows::local_edits(&a.db.conn).unwrap().len(), 1);
1837    }
1838
1839    /// A correction built from a measurement arrives as made: its role, its
1840    /// measurement and target, and the measurement's file.
1841    #[test]
1842    fn a_measured_correction_travels_whole() {
1843        use crate::audio::dsp::profiles;
1844        use crate::config::{DspEar, DspRole};
1845        let _guard = lock();
1846        let server = Server::new();
1847        let (a, b) = (Device::new(), Device::new());
1848        a.on();
1849        let mut text = String::from("frequency,raw\n");
1850        for hz in crate::audio::dsp::targets::grid() {
1851            text.push_str(&format!("{hz:.2},{:.1}\n", 90.0 + (hz / 1000.0).log2()));
1852        }
1853        profiles::save_measured("IEM", &text, DspEar::In, "diffuse-field-iso-11904-1").unwrap();
1854        let sent = a.profile("IEM").unwrap();
1855        let (doc, paths) = doc_of(&sent).unwrap();
1856        let back = SyncDoc::parse(&doc.json()).unwrap();
1857        assert_eq!(back.profile.fitted, sent.fitted);
1858        assert!(sent.fitted.is_some() && back.profile.filters == sent.filters);
1859        assert!(back.files.iter().any(|f| f.name == "measurement.csv"));
1860        assert!(paths.values().any(|p| p.ends_with("measurement.csv")));
1861
1862        a.sync(&server);
1863        b.sync(&server);
1864        b.on();
1865        let got = b.profile("IEM").unwrap();
1866        assert_eq!(got.fitted, sent.fitted);
1867        assert_eq!(profiles::role(&got), DspRole::Correction);
1868        a.on();
1869        let file = std::fs::read(profiles::dir("IEM").join("measurement.csv")).unwrap();
1870        b.on();
1871        assert_eq!(
1872            std::fs::read(profiles::dir("IEM").join("measurement.csv")).unwrap(),
1873            file
1874        );
1875
1876        // A role said on one device is said on the other.
1877        a.on();
1878        profiles::set_role("IEM", DspRole::Baked).unwrap();
1879        a.sync(&server);
1880        b.sync(&server);
1881        assert_eq!(b.profile("IEM").unwrap().role, Some(DspRole::Baked));
1882    }
1883
1884    /// The same headphone installed on two devices before either synced is
1885    /// one profile; another holding a synced profile's name gives it up.
1886    /// Two devices with a profile of one name: one profile where they would
1887    /// sound the same, bands in another order and all; each renamed for its
1888    /// device where they would not, with a note saying why.
1889    #[test]
1890    fn a_name_is_shared_once() {
1891        let _guard = lock();
1892        let server = Server::new();
1893        let (a, b) = (Device::named("Mac Studio"), Device::named("iPhone"));
1894        for (d, gain) in [(&a, 1.0), (&b, 2.0)] {
1895            d.on();
1896            let mut hp = headphone();
1897            hp.filters = vec![
1898                band(3.0),
1899                DspFilter::Band(EqFilter {
1900                    kind: EqFilterKind::LowShelf,
1901                    freq: 100.0,
1902                    gain_db: 2.0 + if d.name == "iPhone" { 0.04 } else { 0.0 },
1903                    q: 0.7,
1904                    channels: vec![],
1905                }),
1906            ];
1907            if d.name == "iPhone" {
1908                hp.filters.reverse();
1909            }
1910            Config::persist(|c| {
1911                c.dsp.profiles.push(hp);
1912                c.dsp.profiles.push(DspProfile {
1913                    name: "Lush".into(),
1914                    filters: vec![band(gain)],
1915                    scope: Some(DspScope::Everywhere),
1916                    ..Default::default()
1917                });
1918            })
1919            .unwrap();
1920        }
1921        a.sync(&server);
1922        b.sync(&server);
1923        a.sync(&server);
1924        let names = |d: &Device| -> Vec<String> {
1925            let mut n: Vec<String> = d.profiles().into_iter().map(|p| p.name).collect();
1926            n.sort();
1927            n
1928        };
1929        let all = vec![
1930            "HD 650 (AutoEQ, oratory1990)",
1931            "Lush (Mac Studio)",
1932            "Lush (iPhone)",
1933        ];
1934        assert_eq!(names(&a), all);
1935        assert_eq!(names(&b), all);
1936        assert_eq!(
1937            a.profile("HD 650 (AutoEQ, oratory1990)").unwrap().uid,
1938            b.profile("HD 650 (AutoEQ, oratory1990)").unwrap().uid,
1939            "reordered bands, 0.04 dB apart: the same profile"
1940        );
1941        assert_eq!(b.profile("Lush (iPhone)").unwrap().filters, vec![band(2.0)]);
1942        assert_eq!(
1943            a.profile("Lush (Mac Studio)").unwrap().filters,
1944            vec![band(1.0)]
1945        );
1946        b.on();
1947        let note = note(&b.db, "Lush (iPhone)").unwrap();
1948        assert!(note.starts_with("Renamed from “Lush”"), "{note}");
1949    }
1950
1951    /// A profile deleted and made again under its name before the next sync,
1952    /// as reinstalling an AutoEQ headphone does, keeps the name everywhere.
1953    #[test]
1954    fn a_profile_made_again_keeps_its_name() {
1955        use crate::audio::dsp::profiles;
1956        let _guard = lock();
1957        let server = Server::new();
1958        let (a, b) = (Device::named("Mac Studio"), Device::named("iPhone"));
1959        a.on();
1960        Config::persist(|c| {
1961            c.dsp.profiles.push(headphone());
1962            c.dsp.profiles.push(DspProfile {
1963                name: "Lush".into(),
1964                filters: vec![band(1.0)],
1965                scope: Some(DspScope::Everywhere),
1966                ..Default::default()
1967            });
1968        })
1969        .unwrap();
1970        a.sync(&server);
1971        b.sync(&server);
1972        a.on();
1973        profiles::delete("Lush").unwrap();
1974        profiles::delete("HD 650 (AutoEQ, oratory1990)").unwrap();
1975        let mut hp = headphone();
1976        hp.filters = vec![band(4.0)];
1977        Config::persist(|c| {
1978            c.dsp.profiles.push(hp);
1979            c.dsp.profiles.push(DspProfile {
1980                name: "Lush".into(),
1981                filters: vec![band(2.0)],
1982                scope: Some(DspScope::Everywhere),
1983                ..Default::default()
1984            });
1985        })
1986        .unwrap();
1987        a.sync(&server);
1988        b.sync(&server);
1989        a.sync(&server);
1990        for d in [&a, &b] {
1991            let mut names: Vec<String> = d.profiles().into_iter().map(|p| p.name).collect();
1992            names.sort();
1993            assert_eq!(
1994                names,
1995                ["HD 650 (AutoEQ, oratory1990)", "Lush"],
1996                "{}",
1997                d.name
1998            );
1999            assert_eq!(d.profile("Lush").unwrap().filters, vec![band(2.0)]);
2000            assert_eq!(
2001                d.profile("HD 650 (AutoEQ, oratory1990)").unwrap().filters,
2002                vec![band(4.0)]
2003            );
2004            d.on();
2005            assert_eq!(note(&d.db, "Lush"), None, "{}", d.name);
2006        }
2007    }
2008
2009    /// Renames made in a chain, B to C and then A to B, arrive as made: the
2010    /// profile still called B here steps aside until its own rename lands.
2011    #[test]
2012    fn a_rename_chain_arrives_as_made() {
2013        use crate::audio::dsp::profiles;
2014        let _guard = lock();
2015        let server = Server::new();
2016        let (a, b) = (Device::named("Mac Studio"), Device::named("iPhone"));
2017        a.on();
2018        Config::persist(|c| {
2019            for (name, gain) in [("A", 1.0), ("B", 2.0)] {
2020                c.dsp.profiles.push(DspProfile {
2021                    name: name.into(),
2022                    filters: vec![band(gain)],
2023                    scope: Some(DspScope::Everywhere),
2024                    ..Default::default()
2025                });
2026            }
2027        })
2028        .unwrap();
2029        a.sync(&server);
2030        b.sync(&server);
2031        a.on();
2032        profiles::rename("B", "C").unwrap();
2033        profiles::rename("A", "B").unwrap();
2034        a.sync(&server);
2035        b.sync(&server);
2036        a.sync(&server);
2037        for d in [&a, &b] {
2038            let mut names: Vec<String> = d.profiles().into_iter().map(|p| p.name).collect();
2039            names.sort();
2040            assert_eq!(names, ["B", "C"], "{}", d.name);
2041            assert_eq!(d.profile("B").unwrap().filters, vec![band(1.0)]);
2042            assert_eq!(d.profile("C").unwrap().filters, vec![band(2.0)]);
2043        }
2044    }
2045
2046    fn doc(filters: Vec<DspFilter>, files: Vec<SyncFile>) -> SyncDoc {
2047        SyncDoc {
2048            profile: DspProfile {
2049                name: "X".into(),
2050                filters,
2051                ..Default::default()
2052            },
2053            files,
2054            unknown: Default::default(),
2055        }
2056    }
2057
2058    /// A field a newer kōan wrote is kept through a parse and written back,
2059    /// so a server or device in between loses none.
2060    #[test]
2061    fn a_newer_field_is_passed_on() {
2062        let json = r#"{"profile":{"name":"Warm","filters":[],"impulses":[],"devices":[],"source":[],"layers":[],"from_the_future":{"a":1}},"files":[]}"#;
2063        let doc = SyncDoc::parse(json).unwrap();
2064        assert_eq!(doc.profile.name, "Warm");
2065        assert_eq!(
2066            doc.unknown.get("from_the_future"),
2067            Some(&serde_json::json!({"a": 1}))
2068        );
2069        let again = SyncDoc::parse(&doc.json()).unwrap();
2070        assert_eq!(again, doc);
2071        assert!(doc.json().contains("from_the_future"));
2072    }
2073
2074    /// A copy taken from the server with a field from a newer kōan is not
2075    /// an edit here, so it is never sent back without that field.
2076    #[test]
2077    fn a_newer_field_is_not_an_edit() {
2078        let _guard = lock();
2079        let server = Server::new();
2080        let (a, b) = (Device::new(), Device::new());
2081        a.on();
2082        Config::persist(|c| c.dsp.profiles.push(headphone())).unwrap();
2083        a.sync(&server);
2084        let (uid, json) = rows::live_docs(&server.conn, USER).unwrap().pop().unwrap();
2085        let mut newer: serde_json::Value = serde_json::from_str(&json).unwrap();
2086        newer["profile"]["from_the_future"] = serde_json::json!({"a": 1});
2087        server.save(&uid, now_ms() + 1, &newer.to_string()).unwrap();
2088        for device in [&a, &b, &a, &b] {
2089            let synced = device.sync(&server);
2090            assert_eq!(synced.sent, 0, "{synced:?}");
2091        }
2092        let (_, kept) = rows::live_docs(&server.conn, USER).unwrap().pop().unwrap();
2093        assert!(kept.contains("from_the_future"), "{kept}");
2094        assert_eq!(
2095            b.profile("HD 650 (AutoEQ, oratory1990)").unwrap().filters,
2096            vec![band(3.0)]
2097        );
2098    }
2099
2100    /// What two profiles play, not how they are written down.
2101    #[test]
2102    fn profiles_that_would_sound_the_same_are_the_same() {
2103        let none = |_: &str| None;
2104        let peak = |hz: f64, db: f64| {
2105            DspFilter::Band(EqFilter {
2106                kind: EqFilterKind::Peaking,
2107                freq: hz,
2108                gain_db: db,
2109                q: 1.0,
2110                channels: vec![],
2111            })
2112        };
2113        let a = doc(vec![peak(100.0, 3.0), peak(1000.0, -2.0)], vec![]);
2114        let reordered = doc(vec![peak(1000.0, -2.0), peak(100.0, 3.0)], vec![]);
2115        assert!(sounds_same(&a, &reordered, &none), "bands commute");
2116        let close = doc(vec![peak(100.0, 3.04), peak(1000.0, -2.0)], vec![]);
2117        assert!(sounds_same(&a, &close, &none), "0.04 dB is nothing heard");
2118        let extra = doc(
2119            vec![peak(100.0, 3.0), peak(1000.0, -2.0), peak(5000.0, 1.0)],
2120            vec![],
2121        );
2122        assert!(!sounds_same(&a, &extra, &none), "one band more");
2123        let measured = |target: &str| {
2124            let mut d = a.clone();
2125            d.profile.measurement = Some(crate::config::DspMeasurement {
2126                ear: crate::config::DspEar::In,
2127                target: target.into(),
2128            });
2129            d
2130        };
2131        assert!(
2132            !sounds_same(
2133                &measured("harman-in-ear-2019"),
2134                &measured("diffuse-field-iso-11904-1"),
2135                &none
2136            ),
2137            "one measurement corrected to two targets"
2138        );
2139        let tuned = |target: &str| {
2140            let mut d = a.clone();
2141            d.profile.tuned_for = Some(target.into());
2142            d
2143        };
2144        assert!(
2145            !sounds_same(
2146                &tuned("harman-over-ear-2018"),
2147                &tuned("diffuse-field-gras-kemar"),
2148                &none
2149            ),
2150            "one tuning made against two targets"
2151        );
2152        let mut grouped = a.clone();
2153        grouped.profile.group = true;
2154        assert!(
2155            !sounds_same(&a, &grouped, &none),
2156            "one at a time is not together"
2157        );
2158        let mut renamed = a.clone();
2159        renamed.profile.name = "Y".into();
2160        renamed.profile.origin = Some("iPhone".into());
2161        assert!(sounds_same(&a, &renamed, &none), "names and origins aside");
2162
2163        // Around a delay, order counts.
2164        let delay = DspFilter::Delay(crate::config::Delay {
2165            ms: 1.0,
2166            ..Default::default()
2167        });
2168        let before = doc(
2169            vec![peak(100.0, 3.0), delay.clone(), peak(1000.0, -2.0)],
2170            vec![],
2171        );
2172        let after = doc(vec![peak(1000.0, -2.0), delay, peak(100.0, 3.0)], vec![]);
2173        assert!(!sounds_same(&before, &after, &none));
2174
2175        // Impulse responses by content.
2176        let ir = |sha: char| {
2177            let mut d = doc(
2178                vec![],
2179                vec![SyncFile {
2180                    name: "48000.wav".into(),
2181                    sha256: sha.to_string().repeat(64),
2182                    size: 10,
2183                }],
2184            );
2185            d.profile.impulses = vec!["48000.wav".into()];
2186            d
2187        };
2188        assert!(sounds_same(&ir('a'), &ir('a'), &none));
2189        assert!(
2190            !sounds_same(&ir('a'), &ir('b'), &none),
2191            "a different response"
2192        );
2193    }
2194
2195    #[test]
2196    fn a_doc_cannot_name_a_file_outside_its_folder() {
2197        let doc = |name: &str| SyncDoc {
2198            profile: DspProfile {
2199                name: "Room".into(),
2200                ..Default::default()
2201            },
2202            files: vec![SyncFile {
2203                name: name.into(),
2204                sha256: "a".repeat(64),
2205                size: 1,
2206            }],
2207            unknown: Default::default(),
2208        };
2209        for bad in ["../config.toml", ".hidden", "a/b", ""] {
2210            assert!(SyncDoc::parse(&doc(bad).json()).is_err(), "{bad:?}");
2211        }
2212        let mut huge = doc("x.wav");
2213        huge.files[0].size = MAX_FILE + 1;
2214        assert!(SyncDoc::parse(&huge.json()).is_err());
2215        assert!(SyncDoc::parse(&doc("x.wav").json()).is_ok());
2216    }
2217
2218    /// A profile another device or the server sends out of bounds arrives
2219    /// within them, assigned to no output here, and plays.
2220    #[test]
2221    fn a_profile_out_of_bounds_arrives_within_them() {
2222        let _guard = lock();
2223        let server = Server::new();
2224        let b = Device::new();
2225        let hostile = r#"{"profile":{"name":"Loud","devices":["Built-in Output"],
2226            "filters":[{"type":"delay","ms":1e12},{"type":"peaking","freq":1000.0,"gain_db":200.0,"q":1.0}],
2227            "impulses":[]},"files":[]}"#;
2228        rows::save(
2229            &server.conn,
2230            USER,
2231            "0199b5a2-6c1e-7cc3-9d2a-3f5b1e0c4d21",
2232            1,
2233            Some(hostile),
2234        )
2235        .unwrap();
2236        b.sync(&server);
2237        let loud = b.profile("Loud").expect("applied");
2238        assert!(loud.devices.is_empty(), "assigned to nothing here");
2239        assert_eq!(
2240            loud.filters[0],
2241            DspFilter::Delay(crate::config::Delay {
2242                ms: 2000.0,
2243                ..Default::default()
2244            })
2245        );
2246        b.on();
2247        let cfg = Config::cached();
2248        let setup = crate::audio::dsp::Setup::load(&loud, &cfg.dsp.profiles, &config::config_dir());
2249        assert!(setup.is_ok(), "it plays");
2250    }
2251
2252    /// Folders go by the slug of a name: a profile from elsewhere whose name
2253    /// maps to a folder of one kept here is named for its device, and the
2254    /// one here for this device, with its files: never overwritten or sent.
2255    #[test]
2256    fn a_folder_is_never_shared() {
2257        let _guard = lock();
2258        let server = Server::new();
2259        let (a, b) = (Device::named("Mac"), Device::named("iPhone"));
2260        b.on();
2261        let room = crate::audio::dsp::profiles::dir("Room");
2262        std::fs::create_dir_all(&room).unwrap();
2263        std::fs::write(room.join("48000.wav"), b"kept here").unwrap();
2264        Config::persist(|c| {
2265            c.dsp.profiles.push(DspProfile {
2266                name: "Room".into(),
2267                impulses: vec![Path::new("dsp/room/48000.wav").into()],
2268                scope: Some(DspScope::Device),
2269                ..Default::default()
2270            })
2271        })
2272        .unwrap();
2273        b.sync(&server);
2274        a.on();
2275        Config::persist(|c| {
2276            c.dsp.profiles.push(DspProfile {
2277                name: "ROOM".into(),
2278                filters: vec![band(1.0)],
2279                scope: Some(DspScope::Everywhere),
2280                ..Default::default()
2281            })
2282        })
2283        .unwrap();
2284        a.sync(&server);
2285        b.sync(&server);
2286        let renamed = b.profile("Room (iPhone)").expect("renamed, files and all");
2287        assert_eq!(renamed.scope, Some(DspScope::Device));
2288        b.on();
2289        assert_eq!(
2290            std::fs::read(config::config_dir().join(&renamed.impulses[0])).unwrap(),
2291            b"kept here"
2292        );
2293        assert!(b.profile("ROOM (Mac)").is_some());
2294
2295        // Two profiles here whose names share a folder: neither is sent.
2296        Config::persist(|c| {
2297            for name in ["Desk", "desk!"] {
2298                c.dsp.profiles.push(DspProfile {
2299                    name: name.into(),
2300                    filters: vec![band(1.0)],
2301                    scope: Some(DspScope::Everywhere),
2302                    ..Default::default()
2303                });
2304            }
2305        })
2306        .unwrap();
2307        let cfg = Config::cached();
2308        let shared = cfg.dsp.profiles.iter().find(|p| p.name == "desk!").unwrap();
2309        assert!(doc_of(shared).unwrap_err().contains("shares its folder"));
2310    }
2311}