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        let saved = remote.delete(uid, now_ms())?;
959        rows::forget_synced(&db.conn, url, uid)?;
960        sent.insert(uid.clone());
961        if !saved.stored {
962            // The server holds an edit made later than this deletion, by a
963            // clock that ran ahead of ours or by an edit made since. The later
964            // edit wins: the cursor goes back so the pull takes it again, and
965            // the deletion is dropped from the config with the rest sent.
966            log::info!("dsp sync: {uid} was edited later elsewhere; keeping it");
967            rows::set_sync_cursor(&db.conn, url, 0)?;
968            restore = true;
969            continue;
970        }
971        rows::forget_local(&db.conn, uid)?;
972        out.sent += 1;
973    }
974    // A deletion stays listed until it is sent: one made while this sync ran
975    // is sent by the next, its profile included where this pass sends it
976    // first. One neither synced nor being sent has nothing to send.
977    if cfg
978        .dsp
979        .removed
980        .iter()
981        .any(|u| sent.contains(u) || !(synced.contains_key(u) || locals.contains_key(u)))
982    {
983        Config::persist(|c| {
984            c.dsp
985                .removed
986                .retain(|u| !sent.contains(u) && (synced.contains_key(u) || locals.contains_key(u)))
987        })?;
988    }
989    // What was kept everywhere and no longer is, and was never sent or has
990    // now been deleted: nothing is left to track. One kept everywhere that
991    // cannot be sent is not in `locals`, and keeps its refusal.
992    let cfg = Config::cached();
993    let kept: HashSet<&str> = cfg
994        .dsp
995        .profiles
996        .iter()
997        .filter(|p| {
998            crate::audio::dsp::profiles::scope(p, &cfg.dsp.profiles) == DspScope::Everywhere
999        })
1000        .filter_map(|p| p.uid.as_deref())
1001        .collect();
1002    for uid in edits
1003        .keys()
1004        .filter(|u| !kept.contains(u.as_str()) && !synced.contains_key(*u))
1005    {
1006        rows::forget_local(&db.conn, uid)?;
1007    }
1008    let mut kept_later = restore;
1009    let mut uids: Vec<&String> = locals.keys().collect();
1010    uids.sort();
1011    for uid in uids {
1012        let local = &locals[uid];
1013        if synced.get(uid).is_some_and(|(_, h)| *h == local.hash) {
1014            continue;
1015        }
1016        // Never synced from here, or synced and then deleted on the server
1017        // when it was kept here alone: sending it now is the edit.
1018        let edited_at = match synced.get(uid) {
1019            Some(_) => edits.get(uid).map_or_else(now_ms, |e| e.edited_at),
1020            None => now_ms(),
1021        };
1022        let saved = match remote.save(uid, edited_at, &local.doc.json()) {
1023            Ok(saved) => saved,
1024            Err(SubsonicError::Api { message, .. }) => {
1025                rows::set_refused(&db.conn, uid, Some(&message))?;
1026                continue;
1027            }
1028            Err(e) => return Err(e.into()),
1029        };
1030        if !saved.stored {
1031            kept_later = true;
1032            continue;
1033        }
1034        for m in &saved.missing {
1035            let Some(path) = local.paths.get(&m.sha256) else {
1036                continue;
1037            };
1038            let bytes = std::fs::read(path)?;
1039            remote.upload(&m.sha256, bytes)?;
1040        }
1041        rows::set_synced(&db.conn, url, uid, saved.rev, &local.hash)?;
1042        rows::set_refused(&db.conn, uid, None)?;
1043        out.sent += 1;
1044    }
1045    let cfg = Config::cached();
1046    for output in cfg
1047        .dsp
1048        .autoeq_dismissed
1049        .iter()
1050        .filter(|d| !dismissed.contains(*d))
1051    {
1052        remote.dismiss(output)?;
1053    }
1054    Ok(kept_later)
1055}
1056
1057impl From<std::io::Error> for Failed {
1058    fn from(e: std::io::Error) -> Self {
1059        Self::Remote(SubsonicError::Io(e))
1060    }
1061}
1062
1063/// Take `doc` as profile `uid`: update the one here with that uid, or add
1064/// it. Another profile here holding its name, not yet synced, gives the name
1065/// up and is renamed. Its folder is made to hold exactly the doc's files:
1066/// `fetched` by content, the rest from `reused`.
1067/// Renamed profiles, each by uid where it has one: its old name and new.
1068type Renamed = Vec<(Option<String>, String, String)>;
1069
1070fn adopt(
1071    uid: &str,
1072    doc: &SyncDoc,
1073    fetched: Vec<(String, Vec<u8>)>,
1074    reused: &HashMap<String, PathBuf>,
1075    synced: &HashMap<String, (i64, String)>,
1076) -> Result<Renamed, Failed> {
1077    use crate::audio::dsp::profiles;
1078    let cfg = Config::cached();
1079    let name = doc.profile.name.clone();
1080    let current = cfg
1081        .dsp
1082        .profiles
1083        .iter()
1084        .find(|p| p.uid.as_deref() == Some(uid));
1085    // Another profile whose folder this one's name maps to gives the name
1086    // up, so its files are never written over: folders are keyed by the
1087    // name's slug, not the name.
1088    let mut name = name;
1089    let mut renamed = Vec::new();
1090    let folder = profiles::dir(&name);
1091    if let Some(holder) = cfg
1092        .dsp
1093        .profiles
1094        .iter()
1095        .find(|p| profiles::dir(&p.name) == folder && p.uid.as_deref() != Some(uid))
1096        && holder.uid.as_ref().is_some_and(|u| synced.contains_key(u))
1097    {
1098        // Already synced: a later row on this page renames or deletes it, as
1099        // with a rename chain or a profile deleted and made again. Until then
1100        // it steps aside.
1101        let free = free_name(&cfg.dsp.profiles, &holder.name);
1102        profiles::rename(&holder.name, &free).map_err(io_failed)?;
1103    } else if let Some(holder) = cfg
1104        .dsp
1105        .profiles
1106        .iter()
1107        .find(|p| profiles::dir(&p.name) == folder && p.uid.as_deref() != Some(uid))
1108    {
1109        // Two different profiles of one name, made apart before either
1110        // synced: each is named for the device it came from, so neither
1111        // looks like the other.
1112        let here = this_device();
1113        let mine = by_device(&cfg.dsp.profiles, &holder.name, &here);
1114        profiles::rename(&holder.name, &mine).map_err(io_failed)?;
1115        renamed.push((holder.uid.clone(), holder.name.clone(), mine));
1116        let theirs = match doc.profile.origin.as_deref() {
1117            Some(origin) if origin != here => format!("{name} ({origin})"),
1118            _ => free_name(&Config::cached().dsp.profiles, &name),
1119        };
1120        let theirs = if Config::cached()
1121            .dsp
1122            .profiles
1123            .iter()
1124            .any(|p| p.name == theirs)
1125        {
1126            free_name(&Config::cached().dsp.profiles, &theirs)
1127        } else {
1128            theirs
1129        };
1130        renamed.push((Some(uid.to_owned()), name.clone(), theirs.clone()));
1131        name = theirs;
1132    }
1133    if let Some(current) = current
1134        && current.name != name
1135    {
1136        profiles::rename(&current.name, &name).map_err(io_failed)?;
1137    }
1138
1139    // The folder: every file the doc names, nothing else.
1140    let dir = profiles::dir(&name);
1141    let staged = dir.with_extension("incoming");
1142    let _ = std::fs::remove_dir_all(&staged);
1143    std::fs::create_dir_all(&staged)?;
1144    let fetched: HashMap<String, Vec<u8>> = fetched.into_iter().collect();
1145    for f in &doc.files {
1146        let target = staged.join(&f.name);
1147        match fetched.get(&f.sha256) {
1148            Some(bytes) => std::fs::write(&target, bytes)?,
1149            None => {
1150                let from = reused
1151                    .get(&f.sha256)
1152                    .ok_or_else(|| io_failed(format!("{} has not arrived", f.name)))?;
1153                std::fs::copy(from, &target)?;
1154            }
1155        }
1156    }
1157    let _ = std::fs::remove_dir_all(&dir);
1158    if doc.files.is_empty() {
1159        let _ = std::fs::remove_dir_all(&staged);
1160    } else {
1161        std::fs::rename(&staged, &dir)?;
1162    }
1163
1164    let relative = Path::new("dsp").join(dir.file_name().expect("a profile's folder has a name"));
1165    Config::persist(|c| {
1166        let mut incoming = doc.profile.clone();
1167        incoming.name = name.clone();
1168        incoming.impulses = incoming.impulses.iter().map(|f| relative.join(f)).collect();
1169        incoming.uid = Some(uid.to_owned());
1170        incoming.scope = Some(DspScope::Everywhere);
1171        match c
1172            .dsp
1173            .profiles
1174            .iter_mut()
1175            .find(|p| p.uid.as_deref() == Some(uid))
1176        {
1177            Some(p) => {
1178                incoming.devices = std::mem::take(&mut p.devices);
1179                *p = incoming;
1180            }
1181            None => {
1182                c.dsp.profiles.push(incoming);
1183                // Back after being deleted here, as a restore brings it: the
1184                // deletion is no longer one to send.
1185                c.dsp.removed.retain(|u| u != uid);
1186            }
1187        }
1188    })?;
1189    Ok(renamed)
1190}
1191
1192/// `name` with `device` after it, as a profile here may be called: numbered
1193/// as well if even that is taken.
1194fn by_device(profiles: &[DspProfile], name: &str, device: &str) -> String {
1195    let wanted = format!("{name} ({device})");
1196    if profiles.iter().any(|p| p.name == wanted) {
1197        free_name(profiles, &wanted)
1198    } else {
1199        wanted
1200    }
1201}
1202
1203/// Delete the profile here with `uid`, and its folder. A stack that played
1204/// it says so, as for any missing layer.
1205fn drop_profile(uid: &str) -> Result<(), Failed> {
1206    let cfg = Config::cached();
1207    let Some(profile) = cfg
1208        .dsp
1209        .profiles
1210        .iter()
1211        .find(|p| p.uid.as_deref() == Some(uid))
1212    else {
1213        return Ok(());
1214    };
1215    if crate::audio::dsp::profiles::scope(profile, &cfg.dsp.profiles) == DspScope::Device {
1216        // Kept here by choice since: the deletion was this device's own.
1217        return Ok(());
1218    }
1219    let dir = crate::audio::dsp::profiles::dir(&profile.name);
1220    let shared =
1221        cfg.dsp.profiles.iter().any(|p| {
1222            p.uid.as_deref() != Some(uid) && crate::audio::dsp::profiles::dir(&p.name) == dir
1223        });
1224    Config::persist(|c| c.dsp.profiles.retain(|p| p.uid.as_deref() != Some(uid)))?;
1225    if !shared {
1226        let _ = std::fs::remove_dir_all(dir);
1227    }
1228    Ok(())
1229}
1230
1231/// `name` numbered, until neither the name nor its folder is taken.
1232fn free_name(profiles: &[DspProfile], name: &str) -> String {
1233    use crate::audio::dsp::profiles::dir;
1234    (2..)
1235        .map(|n| format!("{name} {n}"))
1236        .find(|candidate| {
1237            profiles
1238                .iter()
1239                .all(|p| &p.name != candidate && dir(&p.name) != dir(candidate))
1240        })
1241        .expect("some number is free")
1242}
1243
1244fn io_failed(e: impl ToString) -> Failed {
1245    Failed::Remote(SubsonicError::Io(std::io::Error::other(e.to_string())))
1246}
1247
1248#[cfg(test)]
1249mod tests {
1250    use std::cell::RefCell;
1251    use std::path::Path;
1252
1253    use super::*;
1254    use crate::config::{DspFilter, DspTarget, EqFilter, EqFilterKind};
1255    use crate::remote::client::{KoanDspDismissed, KoanDspMissing, KoanDspProfile};
1256
1257    /// The server's side, over the same queries a kōan server runs.
1258    struct Server {
1259        conn: rusqlite::Connection,
1260        uploads: RefCell<usize>,
1261    }
1262
1263    const USER: i64 = 1;
1264
1265    impl Server {
1266        fn new() -> Self {
1267            let conn = rusqlite::Connection::open_in_memory().unwrap();
1268            crate::db::schema::create_tables(&conn).unwrap();
1269            conn.execute(
1270                "INSERT INTO users (id, username, password_hash, role) VALUES (1, 'mate', 'x', 'user')",
1271                [],
1272            )
1273            .unwrap();
1274            Self {
1275                conn,
1276                uploads: RefCell::new(0),
1277            }
1278        }
1279
1280        fn named(&self) -> HashMap<String, u64> {
1281            rows::live_docs(&self.conn, USER)
1282                .unwrap()
1283                .into_iter()
1284                .flat_map(|(_, json)| SyncDoc::parse(&json).unwrap().files)
1285                .map(|f| (f.sha256, f.size))
1286                .collect()
1287        }
1288    }
1289
1290    fn api(message: &str) -> SubsonicError {
1291        SubsonicError::Api {
1292            code: 0,
1293            message: message.into(),
1294        }
1295    }
1296
1297    impl Remote for Server {
1298        fn changes(&self, since: i64) -> Result<KoanDspProfiles, SubsonicError> {
1299            let (rows, cursor) = rows::changes(&self.conn, USER, since).unwrap();
1300            Ok(KoanDspProfiles {
1301                cursor,
1302                profile: rows
1303                    .into_iter()
1304                    .map(|r| KoanDspProfile {
1305                        uid: r.uid,
1306                        rev: r.rev,
1307                        edited_at: r.edited_at,
1308                        doc: r.doc,
1309                    })
1310                    .collect(),
1311                dismissed: rows::dismissed(&self.conn, USER)
1312                    .unwrap()
1313                    .into_iter()
1314                    .map(|output| KoanDspDismissed { output })
1315                    .collect(),
1316            })
1317        }
1318        fn save(
1319            &self,
1320            uid: &str,
1321            edited_at: i64,
1322            doc: &str,
1323        ) -> Result<KoanDspSaved, SubsonicError> {
1324            let parsed = SyncDoc::parse(doc).map_err(|e| api(&e))?;
1325            let saved = rows::save(&self.conn, USER, uid, edited_at, Some(doc)).unwrap();
1326            let held = rows::files(&self.conn, USER).unwrap();
1327            Ok(KoanDspSaved {
1328                rev: saved.rev,
1329                stored: saved.stored,
1330                missing: parsed
1331                    .files
1332                    .iter()
1333                    .filter(|f| saved.stored && !held.contains_key(&f.sha256))
1334                    .map(|f| KoanDspMissing {
1335                        sha256: f.sha256.clone(),
1336                    })
1337                    .collect(),
1338            })
1339        }
1340        fn delete(&self, uid: &str, edited_at: i64) -> Result<KoanDspSaved, SubsonicError> {
1341            let saved = rows::save(&self.conn, USER, uid, edited_at, None).unwrap();
1342            Ok(KoanDspSaved {
1343                rev: saved.rev,
1344                stored: saved.stored,
1345                missing: vec![],
1346            })
1347        }
1348        fn dismiss(&self, output: &str) -> Result<(), SubsonicError> {
1349            rows::dismiss(&self.conn, USER, output).unwrap();
1350            Ok(())
1351        }
1352        fn file(&self, sha256: &str) -> Result<Vec<u8>, SubsonicError> {
1353            rows::file(&self.conn, USER, sha256)
1354                .unwrap()
1355                .ok_or_else(|| api("File not found"))
1356        }
1357        fn upload(&self, sha256: &str, data: Vec<u8>) -> Result<(), SubsonicError> {
1358            assert_eq!(sha256_hex(&data), sha256);
1359            assert_eq!(self.named().get(sha256), Some(&(data.len() as u64)));
1360            rows::store_file(&self.conn, USER, sha256, &data).unwrap();
1361            *self.uploads.borrow_mut() += 1;
1362            Ok(())
1363        }
1364    }
1365
1366    /// A device: its own config directory and database.
1367    struct Device {
1368        dir: tempfile::TempDir,
1369        db: Database,
1370        name: &'static str,
1371    }
1372
1373    impl Device {
1374        fn new() -> Self {
1375            Self::named("Device")
1376        }
1377
1378        fn named(name: &'static str) -> Self {
1379            let conn = rusqlite::Connection::open_in_memory().unwrap();
1380            crate::db::schema::create_tables(&conn).unwrap();
1381            Self {
1382                dir: tempfile::tempdir().unwrap(),
1383                db: Database { conn },
1384                name,
1385            }
1386        }
1387
1388        /// Make this the device the config belongs to.
1389        fn on(&self) -> &Self {
1390            config::set_config_dir(self.dir.path());
1391            set_device_name(Some(self.name.to_owned()));
1392            self
1393        }
1394
1395        fn sync(&self, server: &Server) -> DspSync {
1396            self.on();
1397            // Edits made in the same millisecond would tie.
1398            std::thread::sleep(std::time::Duration::from_millis(3));
1399            run(&self.db, server, "https://music.example")
1400        }
1401
1402        fn profiles(&self) -> Vec<DspProfile> {
1403            self.on();
1404            Config::cached().dsp.profiles.clone()
1405        }
1406
1407        fn profile(&self, name: &str) -> Option<DspProfile> {
1408            self.profiles().into_iter().find(|p| p.name == name)
1409        }
1410    }
1411
1412    fn band(gain_db: f64) -> DspFilter {
1413        DspFilter::Band(EqFilter {
1414            kind: EqFilterKind::Peaking,
1415            freq: 1000.0,
1416            gain_db,
1417            q: 1.0,
1418            channels: vec![],
1419        })
1420    }
1421
1422    fn headphone() -> DspProfile {
1423        DspProfile {
1424            name: "HD 650 (AutoEQ, oratory1990)".into(),
1425            devices: vec!["Topping E30".into()],
1426            filters: vec![band(3.0)],
1427            target: Some(DspTarget {
1428                made_for: "harman-over-ear-2018".into(),
1429                chosen: None,
1430            }),
1431            ..Default::default()
1432        }
1433    }
1434
1435    /// Deferred, an edit waits for `flush` and starts no thread a CLI would
1436    /// exit before; with no server signed in to, `flush` takes it and
1437    /// sends nothing.
1438    #[test]
1439    fn deferred_edits_wait_for_flush() {
1440        let _guard = lock();
1441        let here = Device::new();
1442        here.on();
1443        defer();
1444        changed();
1445        assert!(PENDING.load(Ordering::Relaxed));
1446        assert!(flush(&here.db).is_none());
1447        assert!(!PENDING.load(Ordering::Relaxed));
1448        assert!(flush(&here.db).is_none());
1449        DEFERRED.store(false, Ordering::Relaxed);
1450    }
1451
1452    fn lock() -> std::sync::MutexGuard<'static, ()> {
1453        crate::config::tests::PERSIST_LOCK
1454            .lock()
1455            .unwrap_or_else(|e| e.into_inner())
1456    }
1457
1458    /// A headphone correction goes everywhere, without the output it was set
1459    /// for; what a device had of its own before stays on it; an edit made
1460    /// later wins; a deletion reaches every device.
1461    #[test]
1462    fn profiles_follow_the_account() {
1463        let _guard = lock();
1464        let server = Server::new();
1465        let (a, b) = (Device::new(), Device::new());
1466
1467        a.on();
1468        Config::persist(|c| {
1469            c.dsp.profiles.push(headphone());
1470            c.dsp.profiles.push(DspProfile {
1471                name: "Desk speakers".into(),
1472                filters: vec![band(-2.0)],
1473                ..Default::default()
1474            });
1475        })
1476        .unwrap();
1477        assert_eq!(a.sync(&server).sent, 1);
1478        assert_eq!(
1479            a.profile("Desk speakers").unwrap().scope,
1480            Some(DspScope::Device),
1481            "kept here: it was here before syncing"
1482        );
1483
1484        b.sync(&server);
1485        let theirs = b.profile("HD 650 (AutoEQ, oratory1990)").unwrap();
1486        assert_eq!(theirs.filters, vec![band(3.0)]);
1487        assert!(
1488            theirs.devices.is_empty(),
1489            "which output uses it stays per device"
1490        );
1491        assert_eq!(
1492            theirs.uid,
1493            a.profile("HD 650 (AutoEQ, oratory1990)").unwrap().uid
1494        );
1495        assert!(b.profile("Desk speakers").is_none());
1496
1497        // Edited on A (by hand: the apps leave a correction as made), then
1498        // renamed on B, offline: B's later edit wins.
1499        a.on();
1500        Config::persist(|c| {
1501            let hd = c
1502                .dsp
1503                .profiles
1504                .iter_mut()
1505                .find(|p| p.name == "HD 650 (AutoEQ, oratory1990)")
1506                .unwrap();
1507            hd.filters[0] = band(5.0);
1508        })
1509        .unwrap();
1510        a.sync(&server);
1511        std::thread::sleep(std::time::Duration::from_millis(3));
1512        b.on();
1513        crate::audio::dsp::profiles::rename("HD 650 (AutoEQ, oratory1990)", "HD 650").unwrap();
1514        b.sync(&server);
1515        a.sync(&server);
1516        let a_now = a.profile("HD 650").expect("renamed on A too");
1517        assert_eq!(a_now.filters, vec![band(3.0)], "B's copy was edited later");
1518        assert_eq!(
1519            a_now.devices,
1520            vec!["Topping E30".to_string()],
1521            "A's output kept"
1522        );
1523        assert_eq!(b.profile("HD 650").unwrap().filters, vec![band(3.0)]);
1524
1525        // Deleted on B: gone from A.
1526        b.on();
1527        crate::audio::dsp::profiles::delete("HD 650").unwrap();
1528        b.sync(&server);
1529        a.sync(&server);
1530        assert!(a.profile("HD 650").is_none());
1531        assert!(a.profile("Desk speakers").is_some());
1532    }
1533
1534    /// EQ bands made by hand: kept everywhere only while nothing says
1535    /// otherwise, unlike an AutoEQ correction, whose target keeps it so.
1536    fn bass() -> DspProfile {
1537        DspProfile {
1538            name: "Bass".into(),
1539            filters: vec![band(4.0)],
1540            ..Default::default()
1541        }
1542    }
1543
1544    fn edit(name: &str, f: impl FnOnce(&mut DspProfile)) {
1545        Config::persist(|c| f(c.dsp.profiles.iter_mut().find(|p| p.name == name).unwrap()))
1546            .unwrap();
1547    }
1548
1549    fn removed() -> Vec<String> {
1550        Config::cached().dsp.removed.clone()
1551    }
1552
1553    /// A and B on one server, A past its first sync, with `made` synced to
1554    /// both.
1555    fn shared(made: Vec<DspProfile>) -> (Server, Device, Device) {
1556        let server = Server::new();
1557        let (a, b) = (Device::new(), Device::new());
1558        a.on();
1559        Config::persist(|c| c.dsp.profiles.push(headphone())).unwrap();
1560        a.sync(&server);
1561        a.on();
1562        Config::persist(|c| c.dsp.profiles.extend(made)).unwrap();
1563        a.sync(&server);
1564        b.sync(&server);
1565        (server, a, b)
1566    }
1567
1568    /// Sync `made` to B, change it on A with `drift`, a change that once
1569    /// made it read as kept on A alone, and sync again: `name` stays on the
1570    /// server and on B, and A keeps it everywhere. Returns A's last sync.
1571    fn stays_shared(made: Vec<DspProfile>, name: &str, drift: impl FnOnce()) -> DspSync {
1572        let (server, a, b) = shared(made);
1573        assert!(b.profile(name).is_some(), "{name} reached B");
1574
1575        a.on();
1576        drift();
1577        let synced = a.sync(&server);
1578        b.sync(&server);
1579
1580        let uid = a.profile(name).unwrap().uid.unwrap();
1581        assert!(
1582            rows::live_docs(&server.conn, USER)
1583                .unwrap()
1584                .iter()
1585                .any(|(u, _)| *u == uid),
1586            "{name} was deleted from the server"
1587        );
1588        assert!(b.profile(name).is_some(), "{name} was deleted from B");
1589        assert_eq!(a.profile(name).unwrap().scope, Some(DspScope::Everywhere));
1590        synced
1591    }
1592
1593    #[test]
1594    fn a_shared_profile_set_for_a_built_in_output_stays_shared() {
1595        let _guard = lock();
1596        stays_shared(vec![bass()], "Bass", || {
1597            edit("Bass", |p| p.devices.push("Speaker".into()));
1598        });
1599    }
1600
1601    #[test]
1602    fn a_shared_profile_given_an_impulse_response_stays_shared() {
1603        let _guard = lock();
1604        stays_shared(vec![bass()], "Bass", || {
1605            let dir = crate::audio::dsp::profiles::dir("Bass");
1606            std::fs::create_dir_all(&dir).unwrap();
1607            std::fs::write(dir.join("48000.wav"), b"RIFF not really").unwrap();
1608            let rel = Path::new("dsp")
1609                .join(dir.file_name().unwrap())
1610                .join("48000.wav");
1611            edit("Bass", |p| p.impulses.push(rel));
1612        });
1613    }
1614
1615    fn local_layer() -> DspProfile {
1616        DspProfile {
1617            name: "Desk".into(),
1618            filters: vec![band(-2.0)],
1619            scope: Some(DspScope::Device),
1620            ..Default::default()
1621        }
1622    }
1623
1624    fn stack(group: bool) -> DspProfile {
1625        DspProfile {
1626            name: "Stack".into(),
1627            layers: vec![crate::config::DspLayer {
1628                profile: "Bass".into(),
1629                on: true,
1630            }],
1631            group,
1632            ..Default::default()
1633        }
1634    }
1635
1636    fn add_local_layer(on: bool) {
1637        Config::persist(|c| c.dsp.profiles.push(local_layer())).unwrap();
1638        edit("Stack", |s| {
1639            s.layers.push(crate::config::DspLayer {
1640                profile: "Desk".into(),
1641                on,
1642            })
1643        });
1644    }
1645
1646    #[test]
1647    fn a_shared_stack_given_a_layer_kept_here_stays_shared() {
1648        let _guard = lock();
1649        stays_shared(vec![bass(), stack(false)], "Stack", || {
1650            add_local_layer(true)
1651        });
1652    }
1653
1654    #[test]
1655    fn a_shared_group_given_a_member_kept_here_stays_shared() {
1656        let _guard = lock();
1657        stays_shared(vec![bass(), stack(true)], "Stack", || {
1658            add_local_layer(false)
1659        });
1660    }
1661
1662    /// A profile synced by a build that left its scope to be worked out, and
1663    /// whose worked-out scope changed before this sync, is set back to
1664    /// everywhere as it is: nothing is deleted, and nothing taken again.
1665    #[test]
1666    fn a_synced_profile_that_drifted_before_this_build_stays_shared() {
1667        let _guard = lock();
1668        let synced = stays_shared(vec![bass()], "Bass", || {
1669            edit("Bass", |p| {
1670                p.scope = None;
1671                p.devices.push("Speaker".into());
1672            });
1673        });
1674        assert_eq!(synced.applied, 0, "kept as it was, not restored");
1675    }
1676
1677    /// A profile missing here without having been deleted, as after a
1678    /// config that did not load, is not deleted from the server or the
1679    /// other devices: the server's copy comes back.
1680    #[test]
1681    fn a_profile_missing_without_a_deletion_is_restored_not_deleted() {
1682        let _guard = lock();
1683        let (server, a, b) = shared(vec![bass()]);
1684        a.on();
1685        Config::persist(|c| c.dsp.profiles.retain(|p| p.name != "Bass")).unwrap();
1686        a.sync(&server);
1687        b.sync(&server);
1688
1689        assert!(b.profile("Bass").is_some(), "still on B");
1690        assert_eq!(
1691            a.profile("Bass").unwrap().filters,
1692            vec![band(4.0)],
1693            "back on A"
1694        );
1695    }
1696
1697    /// A deletion the server refuses, because it holds an edit stamped later
1698    /// (a device whose clock ran ahead), is not forgotten here: the profile
1699    /// comes back from the server instead of the two disagreeing for good.
1700    #[test]
1701    fn a_deletion_the_server_refuses_brings_the_profile_back() {
1702        let _guard = lock();
1703        let (server, a, _b) = shared(vec![bass()]);
1704        a.on();
1705        let uid = a.profile("Bass").unwrap().uid.unwrap();
1706        Config::persist(|c| {
1707            c.dsp.removed.push(uid.clone());
1708            c.dsp.profiles.retain(|p| p.name != "Bass");
1709        })
1710        .unwrap();
1711        let url = "https://music.example";
1712        let mut locals = observe(&a.db, url).unwrap();
1713
1714        // Another device's edit lands after this one looked, stamped ahead.
1715        let doc = rows::live_docs(&server.conn, USER)
1716            .unwrap()
1717            .into_iter()
1718            .find(|(u, _)| *u == uid)
1719            .unwrap()
1720            .1;
1721        server.save(&uid, now_ms() + 30_000, &doc).unwrap();
1722
1723        let mut out = DspSync::default();
1724        let again = push(&a.db, &server, url, &locals, &HashSet::new(), &mut out).unwrap();
1725        assert!(again, "the server's copy is taken again");
1726        assert_eq!(out.sent, 0);
1727        assert!(
1728            rows::live_docs(&server.conn, USER)
1729                .unwrap()
1730                .iter()
1731                .any(|(u, _)| *u == uid),
1732            "still live on the server"
1733        );
1734
1735        locals = observe(&a.db, url).unwrap();
1736        pull(&a.db, &server, url, &mut locals, &mut out).unwrap();
1737        assert!(a.profile("Bass").is_some(), "back on A");
1738        assert!(removed().is_empty(), "the deletion is not left pending");
1739    }
1740
1741    /// A deletion recorded after this sync looked at the profiles, as one
1742    /// made while it runs, is kept until a sync sends it.
1743    #[test]
1744    fn a_deletion_made_while_syncing_is_sent_by_the_next_sync() {
1745        let _guard = lock();
1746        let (server, a, b) = shared(vec![bass()]);
1747        a.on();
1748        let uid = a.profile("Bass").unwrap().uid.unwrap();
1749        // Recorded, with the profile still there when the sync looked.
1750        Config::persist(|c| c.dsp.removed.push(uid.clone())).unwrap();
1751        a.sync(&server);
1752        a.on();
1753        assert_eq!(removed(), vec![uid.clone()], "not sent yet, still listed");
1754
1755        Config::persist(|c| c.dsp.profiles.retain(|p| p.name != "Bass")).unwrap();
1756        a.sync(&server);
1757        b.sync(&server);
1758        assert!(b.profile("Bass").is_none(), "deleted on B");
1759        assert!(a.profile("Bass").is_none(), "not restored on A");
1760        a.on();
1761        assert!(removed().is_empty());
1762    }
1763
1764    /// A profile deleted after this sync looked at the profiles and before it
1765    /// was ever synced, as a new EQ deleted at once, stays deleted: the
1766    /// deletion is kept while the profile is still being sent, and sent next.
1767    #[test]
1768    fn a_profile_deleted_before_its_first_sync_stays_deleted() {
1769        let _guard = lock();
1770        let (server, a, b) = shared(vec![]);
1771        a.on();
1772        let uid = uuid::Uuid::now_v7().to_string();
1773        Config::persist(|c| {
1774            let mut new = bass();
1775            new.uid = Some(uid.clone());
1776            new.scope = Some(DspScope::Everywhere);
1777            c.dsp.profiles.push(new);
1778            // Deleted after the sync observed it.
1779            c.dsp.removed.push(uid.clone());
1780        })
1781        .unwrap();
1782        a.sync(&server);
1783        a.on();
1784        assert_eq!(removed(), vec![uid.clone()], "kept while it is sent");
1785
1786        Config::persist(|c| c.dsp.profiles.retain(|p| p.name != "Bass")).unwrap();
1787        a.sync(&server);
1788        b.sync(&server);
1789        assert!(a.profile("Bass").is_none(), "not restored on A");
1790        assert!(b.profile("Bass").is_none(), "never on B");
1791        a.on();
1792        assert!(removed().is_empty());
1793    }
1794
1795    /// Deleting a profile whose last edit the server refused still deletes
1796    /// it everywhere, and it does not come back.
1797    #[test]
1798    fn deleting_a_profile_whose_edit_was_refused_deletes_it() {
1799        let _guard = lock();
1800        let (server, a, b) = shared(vec![bass()]);
1801        a.on();
1802        let dir = crate::audio::dsp::profiles::dir("Bass");
1803        std::fs::create_dir_all(&dir).unwrap();
1804        std::fs::File::create(dir.join("48000.wav"))
1805            .unwrap()
1806            .set_len(MAX_FILE + 1)
1807            .unwrap();
1808        a.sync(&server);
1809        a.on();
1810        assert!(refusal(&a.db, "Bass").is_some());
1811
1812        crate::audio::dsp::profiles::delete("Bass").unwrap();
1813        a.sync(&server);
1814        a.sync(&server);
1815        b.sync(&server);
1816        assert!(a.profile("Bass").is_none(), "not restored on A");
1817        assert!(b.profile("Bass").is_none(), "deleted on B");
1818    }
1819
1820    /// Files travel by content: an impulse response arrives byte for byte,
1821    /// under the profile's folder on the other device.
1822    #[test]
1823    fn files_travel_with_their_profile() {
1824        let _guard = lock();
1825        let server = Server::new();
1826        let (a, b) = (Device::new(), Device::new());
1827        a.on();
1828        let dir = crate::audio::dsp::profiles::dir("Room");
1829        std::fs::create_dir_all(&dir).unwrap();
1830        std::fs::write(dir.join("48000.wav"), b"RIFF not really").unwrap();
1831        Config::persist(|c| {
1832            c.dsp.profiles.push(DspProfile {
1833                name: "Room".into(),
1834                impulses: vec![Path::new("dsp/room/48000.wav").into()],
1835                scope: Some(DspScope::Everywhere),
1836                ..Default::default()
1837            })
1838        })
1839        .unwrap();
1840        a.sync(&server);
1841        assert_eq!(*server.uploads.borrow(), 1);
1842        b.sync(&server);
1843        b.on();
1844        let room = b.profile("Room").unwrap();
1845        assert_eq!(room.impulses, vec![PathBuf::from("dsp/room/48000.wav")]);
1846        assert_eq!(
1847            std::fs::read(config::config_dir().join("dsp/room/48000.wav")).unwrap(),
1848            b"RIFF not really"
1849        );
1850
1851        // Kept on A alone now: gone from B, still on A.
1852        a.on();
1853        crate::audio::dsp::profiles::set_scope("Room", DspScope::Device).unwrap();
1854        a.sync(&server);
1855        b.sync(&server);
1856        assert!(b.profile("Room").is_none());
1857        assert!(a.profile("Room").is_some());
1858        // Neither device tracks it as kept everywhere any more.
1859        assert!(rows::local_edits(&a.db.conn).unwrap().is_empty());
1860        assert!(rows::local_edits(&b.db.conn).unwrap().is_empty());
1861    }
1862
1863    /// A profile kept everywhere that cannot be sent keeps its refusal, and
1864    /// its record, across syncs.
1865    #[test]
1866    fn an_unsendable_profile_keeps_its_refusal() {
1867        let _guard = lock();
1868        let server = Server::new();
1869        let a = Device::new();
1870        a.on();
1871        let dir = crate::audio::dsp::profiles::dir("Huge");
1872        std::fs::create_dir_all(&dir).unwrap();
1873        std::fs::File::create(dir.join("48000.wav"))
1874            .unwrap()
1875            .set_len(MAX_FILE + 1)
1876            .unwrap();
1877        Config::persist(|c| {
1878            c.dsp.profiles.push(DspProfile {
1879                name: "Huge".into(),
1880                impulses: vec![Path::new("dsp/huge/48000.wav").into()],
1881                scope: Some(DspScope::Everywhere),
1882                ..Default::default()
1883            })
1884        })
1885        .unwrap();
1886        a.sync(&server);
1887        a.sync(&server);
1888        a.on();
1889        assert!(refusal(&a.db, "Huge").is_some());
1890        assert_eq!(rows::local_edits(&a.db.conn).unwrap().len(), 1);
1891    }
1892
1893    /// A correction built from a measurement arrives as made: its role, its
1894    /// measurement and target, and the measurement's file.
1895    #[test]
1896    fn a_measured_correction_travels_whole() {
1897        use crate::audio::dsp::profiles;
1898        use crate::config::{DspEar, DspRole};
1899        let _guard = lock();
1900        let server = Server::new();
1901        let (a, b) = (Device::new(), Device::new());
1902        a.on();
1903        let mut text = String::from("frequency,raw\n");
1904        for hz in crate::audio::dsp::targets::grid() {
1905            text.push_str(&format!("{hz:.2},{:.1}\n", 90.0 + (hz / 1000.0).log2()));
1906        }
1907        profiles::save_measured("IEM", &text, DspEar::In, "diffuse-field-iso-11904-1").unwrap();
1908        let sent = a.profile("IEM").unwrap();
1909        let (doc, paths) = doc_of(&sent).unwrap();
1910        let back = SyncDoc::parse(&doc.json()).unwrap();
1911        assert_eq!(back.profile.fitted, sent.fitted);
1912        assert!(sent.fitted.is_some() && back.profile.filters == sent.filters);
1913        assert!(back.files.iter().any(|f| f.name == "measurement.csv"));
1914        assert!(paths.values().any(|p| p.ends_with("measurement.csv")));
1915
1916        a.sync(&server);
1917        b.sync(&server);
1918        b.on();
1919        let got = b.profile("IEM").unwrap();
1920        assert_eq!(got.fitted, sent.fitted);
1921        assert_eq!(profiles::role(&got), DspRole::Correction);
1922        a.on();
1923        let file = std::fs::read(profiles::dir("IEM").join("measurement.csv")).unwrap();
1924        b.on();
1925        assert_eq!(
1926            std::fs::read(profiles::dir("IEM").join("measurement.csv")).unwrap(),
1927            file
1928        );
1929
1930        // A role said on one device is said on the other.
1931        a.on();
1932        profiles::set_role("IEM", DspRole::Baked).unwrap();
1933        a.sync(&server);
1934        b.sync(&server);
1935        assert_eq!(b.profile("IEM").unwrap().role, Some(DspRole::Baked));
1936    }
1937
1938    /// The same headphone installed on two devices before either synced is
1939    /// one profile; another holding a synced profile's name gives it up.
1940    /// Two devices with a profile of one name: one profile where they would
1941    /// sound the same, bands in another order and all; each renamed for its
1942    /// device where they would not, with a note saying why.
1943    #[test]
1944    fn a_name_is_shared_once() {
1945        let _guard = lock();
1946        let server = Server::new();
1947        let (a, b) = (Device::named("Mac Studio"), Device::named("iPhone"));
1948        for (d, gain) in [(&a, 1.0), (&b, 2.0)] {
1949            d.on();
1950            let mut hp = headphone();
1951            hp.filters = vec![
1952                band(3.0),
1953                DspFilter::Band(EqFilter {
1954                    kind: EqFilterKind::LowShelf,
1955                    freq: 100.0,
1956                    gain_db: 2.0 + if d.name == "iPhone" { 0.04 } else { 0.0 },
1957                    q: 0.7,
1958                    channels: vec![],
1959                }),
1960            ];
1961            if d.name == "iPhone" {
1962                hp.filters.reverse();
1963            }
1964            Config::persist(|c| {
1965                c.dsp.profiles.push(hp);
1966                c.dsp.profiles.push(DspProfile {
1967                    name: "Lush".into(),
1968                    filters: vec![band(gain)],
1969                    scope: Some(DspScope::Everywhere),
1970                    ..Default::default()
1971                });
1972            })
1973            .unwrap();
1974        }
1975        a.sync(&server);
1976        b.sync(&server);
1977        a.sync(&server);
1978        let names = |d: &Device| -> Vec<String> {
1979            let mut n: Vec<String> = d.profiles().into_iter().map(|p| p.name).collect();
1980            n.sort();
1981            n
1982        };
1983        let all = vec![
1984            "HD 650 (AutoEQ, oratory1990)",
1985            "Lush (Mac Studio)",
1986            "Lush (iPhone)",
1987        ];
1988        assert_eq!(names(&a), all);
1989        assert_eq!(names(&b), all);
1990        assert_eq!(
1991            a.profile("HD 650 (AutoEQ, oratory1990)").unwrap().uid,
1992            b.profile("HD 650 (AutoEQ, oratory1990)").unwrap().uid,
1993            "reordered bands, 0.04 dB apart: the same profile"
1994        );
1995        assert_eq!(b.profile("Lush (iPhone)").unwrap().filters, vec![band(2.0)]);
1996        assert_eq!(
1997            a.profile("Lush (Mac Studio)").unwrap().filters,
1998            vec![band(1.0)]
1999        );
2000        b.on();
2001        let note = note(&b.db, "Lush (iPhone)").unwrap();
2002        assert!(note.starts_with("Renamed from “Lush”"), "{note}");
2003    }
2004
2005    /// A profile deleted and made again under its name before the next sync,
2006    /// as reinstalling an AutoEQ headphone does, keeps the name everywhere.
2007    #[test]
2008    fn a_profile_made_again_keeps_its_name() {
2009        use crate::audio::dsp::profiles;
2010        let _guard = lock();
2011        let server = Server::new();
2012        let (a, b) = (Device::named("Mac Studio"), Device::named("iPhone"));
2013        a.on();
2014        Config::persist(|c| {
2015            c.dsp.profiles.push(headphone());
2016            c.dsp.profiles.push(DspProfile {
2017                name: "Lush".into(),
2018                filters: vec![band(1.0)],
2019                scope: Some(DspScope::Everywhere),
2020                ..Default::default()
2021            });
2022        })
2023        .unwrap();
2024        a.sync(&server);
2025        b.sync(&server);
2026        a.on();
2027        profiles::delete("Lush").unwrap();
2028        profiles::delete("HD 650 (AutoEQ, oratory1990)").unwrap();
2029        let mut hp = headphone();
2030        hp.filters = vec![band(4.0)];
2031        Config::persist(|c| {
2032            c.dsp.profiles.push(hp);
2033            c.dsp.profiles.push(DspProfile {
2034                name: "Lush".into(),
2035                filters: vec![band(2.0)],
2036                scope: Some(DspScope::Everywhere),
2037                ..Default::default()
2038            });
2039        })
2040        .unwrap();
2041        a.sync(&server);
2042        b.sync(&server);
2043        a.sync(&server);
2044        for d in [&a, &b] {
2045            let mut names: Vec<String> = d.profiles().into_iter().map(|p| p.name).collect();
2046            names.sort();
2047            assert_eq!(
2048                names,
2049                ["HD 650 (AutoEQ, oratory1990)", "Lush"],
2050                "{}",
2051                d.name
2052            );
2053            assert_eq!(d.profile("Lush").unwrap().filters, vec![band(2.0)]);
2054            assert_eq!(
2055                d.profile("HD 650 (AutoEQ, oratory1990)").unwrap().filters,
2056                vec![band(4.0)]
2057            );
2058            d.on();
2059            assert_eq!(note(&d.db, "Lush"), None, "{}", d.name);
2060        }
2061    }
2062
2063    /// Renames made in a chain, B to C and then A to B, arrive as made: the
2064    /// profile still called B here steps aside until its own rename lands.
2065    #[test]
2066    fn a_rename_chain_arrives_as_made() {
2067        use crate::audio::dsp::profiles;
2068        let _guard = lock();
2069        let server = Server::new();
2070        let (a, b) = (Device::named("Mac Studio"), Device::named("iPhone"));
2071        a.on();
2072        Config::persist(|c| {
2073            for (name, gain) in [("A", 1.0), ("B", 2.0)] {
2074                c.dsp.profiles.push(DspProfile {
2075                    name: name.into(),
2076                    filters: vec![band(gain)],
2077                    scope: Some(DspScope::Everywhere),
2078                    ..Default::default()
2079                });
2080            }
2081        })
2082        .unwrap();
2083        a.sync(&server);
2084        b.sync(&server);
2085        a.on();
2086        profiles::rename("B", "C").unwrap();
2087        profiles::rename("A", "B").unwrap();
2088        a.sync(&server);
2089        b.sync(&server);
2090        a.sync(&server);
2091        for d in [&a, &b] {
2092            let mut names: Vec<String> = d.profiles().into_iter().map(|p| p.name).collect();
2093            names.sort();
2094            assert_eq!(names, ["B", "C"], "{}", d.name);
2095            assert_eq!(d.profile("B").unwrap().filters, vec![band(1.0)]);
2096            assert_eq!(d.profile("C").unwrap().filters, vec![band(2.0)]);
2097        }
2098    }
2099
2100    fn doc(filters: Vec<DspFilter>, files: Vec<SyncFile>) -> SyncDoc {
2101        SyncDoc {
2102            profile: DspProfile {
2103                name: "X".into(),
2104                filters,
2105                ..Default::default()
2106            },
2107            files,
2108            unknown: Default::default(),
2109        }
2110    }
2111
2112    /// A field a newer kōan wrote is kept through a parse and written back,
2113    /// so a server or device in between loses none.
2114    #[test]
2115    fn a_newer_field_is_passed_on() {
2116        let json = r#"{"profile":{"name":"Warm","filters":[],"impulses":[],"devices":[],"source":[],"layers":[],"from_the_future":{"a":1}},"files":[]}"#;
2117        let doc = SyncDoc::parse(json).unwrap();
2118        assert_eq!(doc.profile.name, "Warm");
2119        assert_eq!(
2120            doc.unknown.get("from_the_future"),
2121            Some(&serde_json::json!({"a": 1}))
2122        );
2123        let again = SyncDoc::parse(&doc.json()).unwrap();
2124        assert_eq!(again, doc);
2125        assert!(doc.json().contains("from_the_future"));
2126    }
2127
2128    /// A copy taken from the server with a field from a newer kōan is not
2129    /// an edit here, so it is never sent back without that field.
2130    #[test]
2131    fn a_newer_field_is_not_an_edit() {
2132        let _guard = lock();
2133        let server = Server::new();
2134        let (a, b) = (Device::new(), Device::new());
2135        a.on();
2136        Config::persist(|c| c.dsp.profiles.push(headphone())).unwrap();
2137        a.sync(&server);
2138        let (uid, json) = rows::live_docs(&server.conn, USER).unwrap().pop().unwrap();
2139        let mut newer: serde_json::Value = serde_json::from_str(&json).unwrap();
2140        newer["profile"]["from_the_future"] = serde_json::json!({"a": 1});
2141        server.save(&uid, now_ms() + 1, &newer.to_string()).unwrap();
2142        for device in [&a, &b, &a, &b] {
2143            let synced = device.sync(&server);
2144            assert_eq!(synced.sent, 0, "{synced:?}");
2145        }
2146        let (_, kept) = rows::live_docs(&server.conn, USER).unwrap().pop().unwrap();
2147        assert!(kept.contains("from_the_future"), "{kept}");
2148        assert_eq!(
2149            b.profile("HD 650 (AutoEQ, oratory1990)").unwrap().filters,
2150            vec![band(3.0)]
2151        );
2152    }
2153
2154    /// What two profiles play, not how they are written down.
2155    #[test]
2156    fn profiles_that_would_sound_the_same_are_the_same() {
2157        let none = |_: &str| None;
2158        let peak = |hz: f64, db: f64| {
2159            DspFilter::Band(EqFilter {
2160                kind: EqFilterKind::Peaking,
2161                freq: hz,
2162                gain_db: db,
2163                q: 1.0,
2164                channels: vec![],
2165            })
2166        };
2167        let a = doc(vec![peak(100.0, 3.0), peak(1000.0, -2.0)], vec![]);
2168        let reordered = doc(vec![peak(1000.0, -2.0), peak(100.0, 3.0)], vec![]);
2169        assert!(sounds_same(&a, &reordered, &none), "bands commute");
2170        let close = doc(vec![peak(100.0, 3.04), peak(1000.0, -2.0)], vec![]);
2171        assert!(sounds_same(&a, &close, &none), "0.04 dB is nothing heard");
2172        let extra = doc(
2173            vec![peak(100.0, 3.0), peak(1000.0, -2.0), peak(5000.0, 1.0)],
2174            vec![],
2175        );
2176        assert!(!sounds_same(&a, &extra, &none), "one band more");
2177        let measured = |target: &str| {
2178            let mut d = a.clone();
2179            d.profile.measurement = Some(crate::config::DspMeasurement {
2180                ear: crate::config::DspEar::In,
2181                target: target.into(),
2182            });
2183            d
2184        };
2185        assert!(
2186            !sounds_same(
2187                &measured("harman-in-ear-2019"),
2188                &measured("diffuse-field-iso-11904-1"),
2189                &none
2190            ),
2191            "one measurement corrected to two targets"
2192        );
2193        let tuned = |target: &str| {
2194            let mut d = a.clone();
2195            d.profile.tuned_for = Some(target.into());
2196            d
2197        };
2198        assert!(
2199            !sounds_same(
2200                &tuned("harman-over-ear-2018"),
2201                &tuned("diffuse-field-gras-kemar"),
2202                &none
2203            ),
2204            "one tuning made against two targets"
2205        );
2206        let mut grouped = a.clone();
2207        grouped.profile.group = true;
2208        assert!(
2209            !sounds_same(&a, &grouped, &none),
2210            "one at a time is not together"
2211        );
2212        let mut renamed = a.clone();
2213        renamed.profile.name = "Y".into();
2214        renamed.profile.origin = Some("iPhone".into());
2215        assert!(sounds_same(&a, &renamed, &none), "names and origins aside");
2216
2217        // Around a delay, order counts.
2218        let delay = DspFilter::Delay(crate::config::Delay {
2219            ms: 1.0,
2220            ..Default::default()
2221        });
2222        let before = doc(
2223            vec![peak(100.0, 3.0), delay.clone(), peak(1000.0, -2.0)],
2224            vec![],
2225        );
2226        let after = doc(vec![peak(1000.0, -2.0), delay, peak(100.0, 3.0)], vec![]);
2227        assert!(!sounds_same(&before, &after, &none));
2228
2229        // Impulse responses by content.
2230        let ir = |sha: char| {
2231            let mut d = doc(
2232                vec![],
2233                vec![SyncFile {
2234                    name: "48000.wav".into(),
2235                    sha256: sha.to_string().repeat(64),
2236                    size: 10,
2237                }],
2238            );
2239            d.profile.impulses = vec!["48000.wav".into()];
2240            d
2241        };
2242        assert!(sounds_same(&ir('a'), &ir('a'), &none));
2243        assert!(
2244            !sounds_same(&ir('a'), &ir('b'), &none),
2245            "a different response"
2246        );
2247    }
2248
2249    #[test]
2250    fn a_doc_cannot_name_a_file_outside_its_folder() {
2251        let doc = |name: &str| SyncDoc {
2252            profile: DspProfile {
2253                name: "Room".into(),
2254                ..Default::default()
2255            },
2256            files: vec![SyncFile {
2257                name: name.into(),
2258                sha256: "a".repeat(64),
2259                size: 1,
2260            }],
2261            unknown: Default::default(),
2262        };
2263        for bad in ["../config.toml", ".hidden", "a/b", ""] {
2264            assert!(SyncDoc::parse(&doc(bad).json()).is_err(), "{bad:?}");
2265        }
2266        let mut huge = doc("x.wav");
2267        huge.files[0].size = MAX_FILE + 1;
2268        assert!(SyncDoc::parse(&huge.json()).is_err());
2269        assert!(SyncDoc::parse(&doc("x.wav").json()).is_ok());
2270    }
2271
2272    /// A profile another device or the server sends out of bounds arrives
2273    /// within them, assigned to no output here, and plays.
2274    #[test]
2275    fn a_profile_out_of_bounds_arrives_within_them() {
2276        let _guard = lock();
2277        let server = Server::new();
2278        let b = Device::new();
2279        let hostile = r#"{"profile":{"name":"Loud","devices":["Built-in Output"],
2280            "filters":[{"type":"delay","ms":1e12},{"type":"peaking","freq":1000.0,"gain_db":200.0,"q":1.0}],
2281            "impulses":[]},"files":[]}"#;
2282        rows::save(
2283            &server.conn,
2284            USER,
2285            "0199b5a2-6c1e-7cc3-9d2a-3f5b1e0c4d21",
2286            1,
2287            Some(hostile),
2288        )
2289        .unwrap();
2290        b.sync(&server);
2291        let loud = b.profile("Loud").expect("applied");
2292        assert!(loud.devices.is_empty(), "assigned to nothing here");
2293        assert_eq!(
2294            loud.filters[0],
2295            DspFilter::Delay(crate::config::Delay {
2296                ms: 2000.0,
2297                ..Default::default()
2298            })
2299        );
2300        b.on();
2301        let cfg = Config::cached();
2302        let setup = crate::audio::dsp::Setup::load(&loud, &cfg.dsp.profiles, &config::config_dir());
2303        assert!(setup.is_ok(), "it plays");
2304    }
2305
2306    /// Folders go by the slug of a name: a profile from elsewhere whose name
2307    /// maps to a folder of one kept here is named for its device, and the
2308    /// one here for this device, with its files: never overwritten or sent.
2309    #[test]
2310    fn a_folder_is_never_shared() {
2311        let _guard = lock();
2312        let server = Server::new();
2313        let (a, b) = (Device::named("Mac"), Device::named("iPhone"));
2314        b.on();
2315        let room = crate::audio::dsp::profiles::dir("Room");
2316        std::fs::create_dir_all(&room).unwrap();
2317        std::fs::write(room.join("48000.wav"), b"kept here").unwrap();
2318        Config::persist(|c| {
2319            c.dsp.profiles.push(DspProfile {
2320                name: "Room".into(),
2321                impulses: vec![Path::new("dsp/room/48000.wav").into()],
2322                scope: Some(DspScope::Device),
2323                ..Default::default()
2324            })
2325        })
2326        .unwrap();
2327        b.sync(&server);
2328        a.on();
2329        Config::persist(|c| {
2330            c.dsp.profiles.push(DspProfile {
2331                name: "ROOM".into(),
2332                filters: vec![band(1.0)],
2333                scope: Some(DspScope::Everywhere),
2334                ..Default::default()
2335            })
2336        })
2337        .unwrap();
2338        a.sync(&server);
2339        b.sync(&server);
2340        let renamed = b.profile("Room (iPhone)").expect("renamed, files and all");
2341        assert_eq!(renamed.scope, Some(DspScope::Device));
2342        b.on();
2343        assert_eq!(
2344            std::fs::read(config::config_dir().join(&renamed.impulses[0])).unwrap(),
2345            b"kept here"
2346        );
2347        assert!(b.profile("ROOM (Mac)").is_some());
2348
2349        // Two profiles here whose names share a folder: neither is sent.
2350        Config::persist(|c| {
2351            for name in ["Desk", "desk!"] {
2352                c.dsp.profiles.push(DspProfile {
2353                    name: name.into(),
2354                    filters: vec![band(1.0)],
2355                    scope: Some(DspScope::Everywhere),
2356                    ..Default::default()
2357                });
2358            }
2359        })
2360        .unwrap();
2361        let cfg = Config::cached();
2362        let shared = cfg.dsp.profiles.iter().find(|p| p.name == "desk!").unwrap();
2363        assert!(doc_of(shared).unwrap_err().contains("shares its folder"));
2364    }
2365}