1use 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
29pub const MAX_FILE: u64 = 32 << 20;
32pub const MAX_ACCOUNT: u64 = 256 << 20;
34pub const MAX_DOC: usize = 512 << 10;
36pub const MAX_FILES: usize = 64;
38pub const MAX_PROFILES: i64 = 1024;
40pub const MAX_DISMISSED: i64 = 1024;
42
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47pub struct SyncDoc {
48 pub profile: DspProfile,
49 pub files: Vec<SyncFile>,
50 #[serde(skip)]
53 pub unknown: serde_json::Map<String, serde_json::Value>,
54}
55
56#[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 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 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 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 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 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
203pub 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 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
276static 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
291pub 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 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 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
424pub 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#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
457pub struct DspSync {
458 pub applied: usize,
460 pub sent: usize,
462}
463
464impl DspSync {
465 pub fn changed(&self) -> bool {
467 self.applied > 0
468 }
469}
470
471static ONE_AT_A_TIME: Mutex<()> = Mutex::new(());
473
474thread_local! {
475 static APPLYING: Cell<bool> = const { Cell::new(false) };
478}
479
480pub 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
490static DEFERRED: AtomicBool = AtomicBool::new(false);
494static PENDING: AtomicBool = AtomicBool::new(false);
495
496pub fn defer() {
499 DEFERRED.store(true, Ordering::Relaxed);
500}
501
502pub 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
518pub 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
530pub 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
549static QUEUED: AtomicBool = AtomicBool::new(false);
552
553pub 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 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
588pub 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
601pub 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
624struct 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 locals = observe(db, url)?;
645 dismissed = pull(db, remote, url, &mut locals, &mut out)?;
646 }
647 let _ = dismissed;
648 Ok(out)
649}
650
651fn 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
686fn 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
771fn 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 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 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 } 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
917fn 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 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 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 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 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 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 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
1063type 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 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 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 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(¤t.name, &name).map_err(io_failed)?;
1137 }
1138
1139 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 c.dsp.removed.retain(|u| u != uid);
1186 }
1187 }
1188 })?;
1189 Ok(renamed)
1190}
1191
1192fn 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
1203fn 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 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
1231fn 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 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 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 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 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 #[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 #[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 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 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 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 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 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 #[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 #[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 #[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 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 #[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 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 #[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 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 #[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 #[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 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 assert!(rows::local_edits(&a.db.conn).unwrap().is_empty());
1860 assert!(rows::local_edits(&b.db.conn).unwrap().is_empty());
1861 }
1862
1863 #[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 #[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.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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 #[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 #[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 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}