//! Holdings — "who has this, and get it from them". //! //! Each member of a Space may publish what they HOLD: the albums of their //! library as room-salted keys (`persona_state::holdings::album_key` over //! `overlap::norm`), one quality word each, and their Soulseek name — sealed //! to the room's epoch key in a private Space, signed by the key they wear //! there, one contract per (room, member). Never files, paths, sizes or //! bitrates: the same rule the overlap shelf lives by, and the switch's //! disclosure says exactly what a member can learn. //! //! Reading a room caches each member's list on disk (`holdings.json`, the //! overlap ledger's pattern), so the ASK is local and instant and works with //! the holders offline: hash the album per room, look it up, name the //! members. "Get it" is the hub's album grab aimed at that member's share — //! `find_folder` walks their browse tree with overlap's `pair_of` and hands //! the files back for the frontend to queue; a holder who is offline goes on //! a local wait list the frontend drains when their presence flips. //! //! Nothing is ever pushed to a holder. The only new network object is the //! list, and only the room's members can read it. use std::collections::{BTreeMap, BTreeSet}; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; use freenet_adapter::FreenetAdapter; use persona_state::holdings as hd; use serde::{Deserialize, Serialize}; use tauri::{Emitter, Manager}; use crate::pipeline::LibraryState; use crate::state::AppState; use crate::{ledger, overlap, persona, slsk_link, spaces}; const HOLDINGS_WASM: &[u8] = include_bytes!("../assets/holdings_contract.wasm"); /// The ledger changed (a publish, a read, a wait): the page re-reads. pub const EVENT: &str = "community:holdings"; const LEDGER_FILE: &str = "holdings.json"; /// A member's cached list is re-read after this long, or on Refresh. const FRESH_SECS: u64 = 10 * 60; /// A member with no list is asked again after this long: a GET for a /// contract nobody put is a ring search that costs seconds. const ABSENT_SECS: u64 = 60 * 60; const LOSSLESS: &[&str] = &["flac", "alac", "wav", "aiff", "aif", "ape", "wv", "dsf", "dff", "tta", "shn"]; const LOSSY: &[&str] = &["mp3", "ogg", "oga", "opus", "m4a", "aac", "wma", "mpc"]; // ---------------------------------------------------------------- ledger /// What this device last published into one room. #[derive(Debug, Default, Clone, Serialize, Deserialize)] pub struct Mine { pub sequence: u64, pub epoch: u64, pub published_at: u64, pub count: u32, /// blake3 over the sorted keys, so a library that has not changed does /// not republish. pub digest: String, #[serde(default)] pub withdrawn: bool, } /// One member's list as this device last read it. #[derive(Debug, Default, Clone, Serialize, Deserialize)] struct Cached { sequence: u64, epoch: u64, read_at: u64, /// No contract under that address when last asked. absent: bool, /// Sealed to an epoch this device has no key for. unreadable: bool, withdrawn: bool, slsk_username: Option, /// (key b58, mbid key b58, tier) — the same three the wire carries. entries: Vec<(String, Option, u8)>, } /// An album to fetch from someone the moment they come online. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Wait { pub username: String, pub artist: String, pub album: String, pub since: u64, /// The room the holder was found in, for the row's provenance. #[serde(default)] pub via: Option, } #[derive(Default, Serialize, Deserialize)] struct Ledger { #[serde(default)] mine: BTreeMap, /// room record id → member key (b58) → the list. #[serde(default)] cache: BTreeMap>, #[serde(default)] waits: Vec, } fn path(app: &tauri::AppHandle) -> Result { Ok(crate::paths::app_data(app)?.join("freenet").join(LEDGER_FILE)) } fn load(app: &tauri::AppHandle) -> Ledger { path(app) .ok() .and_then(|p| std::fs::read(p).ok()) .and_then(|b| serde_json::from_slice(&b).ok()) .unwrap_or_default() } fn save(app: &tauri::AppHandle, l: &Ledger) -> Result<(), String> { let p = path(app)?; if let Some(dir) = p.parent() { std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; } std::fs::write(&p, serde_json::to_vec(l).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?; let _ = app.emit(EVENT, ()); Ok(()) } fn now_secs() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) } fn b58(bytes: &[u8]) -> String { bs58::encode(bytes).into_string() } // ---------------------------------------------------------------- my list /// The tier a dominant extension speaks for. fn tier_of(ext: &str) -> u8 { let e = ext.to_lowercase(); if LOSSLESS.contains(&e.as_str()) { hd::TIER_LOSSLESS } else if LOSSY.contains(&e.as_str()) { hd::TIER_LOSSY } else { hd::TIER_UNKNOWN } } /// The library as one room would hold it: keyed, tiered, sorted. Reads the /// database, so it runs off the async runtime. fn my_list(lib: &library::Library, space_id: &[u8; 16]) -> Result<(Vec, u32), String> { let albums = lib.list_albums().map_err(|e| e.to_string())?; let mbids = lib.album_mbids().unwrap_or_default(); let tiers: BTreeMap = lib .album_stats(0) .map_err(|e| e.to_string())? .into_iter() .map(|s| (s.album_id, s.formats.first().map(|f| tier_of(f)).unwrap_or(hd::TIER_UNKNOWN))) .collect(); let total = albums.len() as u32; // Newest first is the library's own order, so a list over the cap keeps // what was added last. let entries: Vec = albums .iter() .filter_map(|a| { let (na, nt) = (overlap::norm(&a.artist), overlap::norm(&a.title)); if na.is_empty() || nt.is_empty() { return None; } Some(hd::Holding { key: hd::album_key(space_id, &na, &nt), mbid: mbids.get(&a.id).map(|m| hd::mbid_key(space_id, m)), tier: tiers.get(&a.id).copied().unwrap_or(hd::TIER_UNKNOWN), }) }) .take(hd::MAX_ENTRIES) .collect(); Ok((entries, total)) } fn digest_of(entries: &[hd::Holding]) -> String { let mut buf = Vec::with_capacity(entries.len() * 33); for e in entries { buf.extend_from_slice(&e.key); buf.push(e.tier); } b58(&persona_state::content_hash(&buf)) } // ---------------------------------------------------------------- network fn adapter(app: &tauri::AppHandle) -> FreenetAdapter { app.state::().freenet.clone() } /// The list at one address, verified. None when nothing was ever put there. async fn fetch(app: &tauri::AppHandle, space_id: &[u8; 16], member: &[u8; 32]) -> Result, String> { let ids = FreenetAdapter::contract_ids(HOLDINGS_WASM.to_vec(), &hd::make_params(space_id, member)); match adapter(app).contract_get(&ids.instance, false).await { Ok(bytes) if bytes.is_empty() => Ok(None), Ok(bytes) => hd::validate(space_id, member, &bytes) .map(Some) .map_err(|e| format!("holdings failed verification: {e}")), Err(e) if e.to_string().contains("not found") => Ok(None), Err(e) => Err(e.to_string()), } } async fn offer(app: &tauri::AppHandle, space_id: &[u8; 16], member: &[u8; 32], state: &hd::HoldingsState, first: bool) -> Result<(), String> { let bytes = bincode::serialize(state).map_err(|e| e.to_string())?; let params = hd::make_params(space_id, member); let a = adapter(app); if first { a.contract_put(HOLDINGS_WASM.to_vec(), ¶ms, bytes, false).await.map(|_| ()) } else { a.get_then_update(HOLDINGS_WASM.to_vec(), ¶ms, bytes).await.map(|_| ()) } .map_err(|e| e.to_string()) } /// Sign as the key worn in the room: the room secret for a member, the /// delegate for an owner (their persona is their key there). async fn sign(app: &tauri::AppHandle, me: &[u8; 32], worn: &[u8; 32], room_secret: Option<[u8; 32]>, s: &hd::HoldingsState) -> Result, String> { match room_secret { Some(secret) if worn != me => Ok(hd::sign_with(&secret, s)), _ => persona::sign(&adapter(app), hd::SIGN_DOMAIN, hd::sign_payload(s)).await, } } /// Everything a publish or a withdrawal needs to know about where it goes. struct Seat { me: [u8; 32], worn: [u8; 32], room_secret: Option<[u8; 32]>, space_id: [u8; 16], epoch: u64, epoch_key: Option<[u8; 32]>, } async fn seat(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result { let me = spaces::my_key(app).await?; let owner = spaces::key_of(owner_b58)?; let space_id = spaces::id_of(space_b58)?; spaces::room_facts(app, owner_b58, space_b58).ok_or("not a Space this device knows")?; let (worn, room_secret) = spaces::worn_identity(app, owner_b58, space_b58, &me); let (state, _) = spaces::fetch_state(app, &owner, &space_id).await?; let c = state.checkpoint.ok_or("the Space has not reached this node yet")?; if !c.members.iter().any(|m| m.key == worn) { return Err("you are not in this Space's roster".into()); } let epoch_key = if c.private { Some(spaces::epoch_key_for(app, owner_b58, space_b58, c.epoch).ok_or("no key for this epoch yet — the owner has to hand it to you")?) } else { None }; Ok(Seat { me, worn, room_secret, space_id, epoch: c.epoch, epoch_key }) } /// Publish this device's list into one room. Read-and-bump: the sequence /// follows whatever is on the network, so a device that lost its ledger /// cannot publish under a sequence the room has already seen. pub async fn publish(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result { let st = seat(app, owner_b58, space_b58).await?; let lib = app.state::().library.clone(); let sid = st.space_id; let (entries, total) = tauri::async_runtime::spawn_blocking(move || my_list(&lib, &sid)) .await .map_err(|e| e.to_string())??; let count = entries.len() as u32; let digest = digest_of(&entries); let body = hd::encode_body(hd::HoldingsBody { schema_version: hd::SCHEMA_VERSION, slsk_username: slsk_link::own_linked_username(app), entries, }) .map_err(|e| e.to_string())?; let rid = spaces::record_id(owner_b58, space_b58); let on_net = fetch(app, &st.space_id, &st.worn).await?; let known = load(app).mine.get(&rid).map(|m| m.sequence).unwrap_or(0); let sequence = on_net.as_ref().map(|s| s.sequence).unwrap_or(0).max(known) + 1; let mut s = hd::HoldingsState { schema_version: hd::SCHEMA_VERSION, space_id: st.space_id, member: st.worn, sequence, epoch: st.epoch, sealed: st.epoch_key.is_some(), published_at: now_secs(), withdrawn: false, body, sig: vec![], }; if let Some(k) = &st.epoch_key { s.body = hd::seal(k, &s); } s.sig = sign(app, &st.me, &st.worn, st.room_secret, &s).await?; offer(app, &st.space_id, &st.worn, &s, on_net.is_none()).await?; let mine = Mine { sequence, epoch: st.epoch, published_at: s.published_at, count, digest, withdrawn: false }; { let _guard = ledger::hold(LEDGER_FILE).await; let mut l = load(app); l.mine.insert(rid, mine.clone()); save(app, &l)?; } log::info!("holdings: published {count} of {total} albums into {space_b58} as sequence {sequence} (epoch {})", st.epoch); Ok(mine) } /// Take the list out of current state. Copies members already read stay /// with them — never claim erasure. pub async fn withdraw(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result<(), String> { let st = seat(app, owner_b58, space_b58).await?; let rid = spaces::record_id(owner_b58, space_b58); let on_net = fetch(app, &st.space_id, &st.worn).await?; let Some(current) = on_net else { // Nothing there to withdraw; forget what the ledger thought. let _guard = ledger::hold(LEDGER_FILE).await; let mut l = load(app); l.mine.remove(&rid); return save(app, &l); }; if current.withdrawn { return Ok(()); } let sequence = current.sequence.max(load(app).mine.get(&rid).map(|m| m.sequence).unwrap_or(0)) + 1; let mut s = hd::HoldingsState { schema_version: hd::SCHEMA_VERSION, space_id: st.space_id, member: st.worn, sequence, epoch: st.epoch, sealed: false, published_at: now_secs(), withdrawn: true, body: vec![], sig: vec![], }; s.sig = sign(app, &st.me, &st.worn, st.room_secret, &s).await?; offer(app, &st.space_id, &st.worn, &s, false).await?; { let _guard = ledger::hold(LEDGER_FILE).await; let mut l = load(app); l.mine.insert(rid, Mine { sequence, epoch: st.epoch, published_at: s.published_at, count: 0, digest: String::new(), withdrawn: true }); save(app, &l)?; } log::info!("holdings: withdrawn from {space_b58} as sequence {sequence}"); Ok(()) } /// The switch. On: publish now. Off: withdraw. Either way the flag is set /// first, so a publish that fails on the network is retried by the next /// library change rather than forgotten. pub async fn set_share(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, on: bool) -> Result<(), String> { spaces::set_share_holdings_flag(app, owner_b58, space_b58, on).await?; if on { publish(app, owner_b58, space_b58).await.map(|_| ()) } else { withdraw(app, owner_b58, space_b58).await } } /// The library changed: every room with the switch on gets the new list, /// unless the list is byte-for-byte what that room already has. Quiet per /// room — a failed republish is logged and tried again next change. pub async fn republish_all(app: &tauri::AppHandle) -> Result { let rooms = spaces::rooms_sharing_holdings(app); let mut n = 0; for (owner, space) in rooms { let stale = { let lib = app.state::().library.clone(); let sid = spaces::id_of(&space)?; let (entries, _) = tauri::async_runtime::spawn_blocking(move || my_list(&lib, &sid)) .await .map_err(|e| e.to_string())??; let l = load(app); l.mine.get(&spaces::record_id(&owner, &space)).map(|m| m.withdrawn || m.digest != digest_of(&entries)).unwrap_or(true) }; if !stale { continue; } match publish(app, &owner, &space).await { Ok(_) => n += 1, Err(e) => log::info!("holdings: could not republish into {space}: {e}"), } } Ok(n) } // ---------------------------------------------------------------- reading a room #[derive(Debug, Serialize)] pub struct MemberHoldings { pub key: String, pub username: Option, pub is_me: bool, /// "shared" | "none" | "sealed" (no key for its epoch) | "withdrawn" | "unread" pub state: String, pub count: u32, pub sequence: u64, pub read_at: u64, } #[derive(Debug, Serialize)] pub struct RoomHoldings { pub owner: String, pub space_id: String, pub name: String, pub share_holdings: bool, pub mine: Option, pub members: Vec, pub sharing: u32, } /// The room's lists as this device knows them, reading the network for /// members whose cached copy is stale (or all of them, on `refresh`). The /// reads are sequential ring searches — seconds each for a member who /// never published — so this runs from a spawned task and the page hears /// the result by event; it never blocks the Space opening. pub async fn read_room(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, refresh: bool) -> Result { let me = spaces::my_key(app).await?; let owner = spaces::key_of(owner_b58)?; let space_id = spaces::id_of(space_b58)?; let facts = spaces::room_facts(app, owner_b58, space_b58).ok_or("not a Space this device knows")?; let (worn, _) = spaces::worn_identity(app, owner_b58, space_b58, &me); let (state, _) = spaces::fetch_state(app, &owner, &space_id).await?; let c = state.checkpoint.ok_or("the Space has not reached this node yet")?; let rid = spaces::record_id(owner_b58, space_b58); let now = now_secs(); let members: Vec<[u8; 32]> = c.members.iter().map(|m| m.key).filter(|k| *k != worn).collect(); for member in members { let mb58 = b58(&member); let cached = load(app).cache.get(&rid).and_then(|m| m.get(&mb58)).cloned(); let fresh = cached.as_ref().is_some_and(|c| { let age = now.saturating_sub(c.read_at); if c.absent { age < ABSENT_SECS } else { age < FRESH_SECS } }); if fresh && !refresh { continue; } let entry = match fetch(app, &space_id, &member).await { Ok(None) => Cached { read_at: now, absent: true, ..Default::default() }, Ok(Some(s)) if s.withdrawn => Cached { sequence: s.sequence, epoch: s.epoch, read_at: now, withdrawn: true, ..Default::default() }, Ok(Some(s)) => { let plain = if s.sealed { match spaces::epoch_key_for(app, owner_b58, space_b58, s.epoch) { Some(k) => hd::open(&k, &s), None => None, } } else { Some(s.body.clone()) }; match plain.as_deref().and_then(hd::decode_body) { Some(b) => Cached { sequence: s.sequence, epoch: s.epoch, read_at: now, slsk_username: b.slsk_username, entries: b.entries.iter().map(|h| (b58(&h.key), h.mbid.map(|m| b58(&m)), h.tier)).collect(), ..Default::default() }, None => Cached { sequence: s.sequence, epoch: s.epoch, read_at: now, unreadable: true, ..Default::default() }, } } Err(e) => { log::info!("holdings: could not read {mb58} in {space_b58}: {e}"); continue; } }; let _guard = ledger::hold(LEDGER_FILE).await; let mut l = load(app); l.cache.entry(rid.clone()).or_default().insert(mb58, entry); save(app, &l)?; } Ok(room_view(app, owner_b58, space_b58, &facts, &c, &worn)) } /// The room as the cache has it — no network. pub fn room_now(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result { let facts = spaces::room_facts(app, owner_b58, space_b58).ok_or("not a Space this device knows")?; let l = load(app); let rid = spaces::record_id(owner_b58, space_b58); let mine = l.mine.get(&rid).cloned(); let cache = l.cache.get(&rid).cloned().unwrap_or_default(); let members = cache .iter() .map(|(k, c)| MemberHoldings { key: k.clone(), username: c.slsk_username.clone().or_else(|| spaces::username_behind(app, owner_b58, space_b58, k)), is_me: false, state: state_word(c).into(), count: c.entries.len() as u32, sequence: c.sequence, read_at: c.read_at, }) .collect::>(); let sharing = members.iter().filter(|m| m.state == "shared").count() as u32 + u32::from(mine.as_ref().is_some_and(|m| !m.withdrawn)); Ok(RoomHoldings { owner: owner_b58.into(), space_id: space_b58.into(), name: facts.name, share_holdings: facts.share_holdings, mine, members, sharing }) } fn state_word(c: &Cached) -> &'static str { if c.absent { "none" } else if c.withdrawn { "withdrawn" } else if c.unreadable { "sealed" } else { "shared" } } fn room_view(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, facts: &spaces::RoomFacts, c: &persona_state::space::Checkpoint, worn: &[u8; 32]) -> RoomHoldings { let l = load(app); let rid = spaces::record_id(owner_b58, space_b58); let mine = l.mine.get(&rid).cloned(); let cache = l.cache.get(&rid).cloned().unwrap_or_default(); let members: Vec = c .members .iter() .map(|m| { let k = b58(&m.key); let is_me = &m.key == worn; let cached = cache.get(&k); MemberHoldings { username: cached.and_then(|c| c.slsk_username.clone()).or_else(|| spaces::username_behind(app, owner_b58, space_b58, &k)), is_me, state: if is_me { match &mine { Some(m) if !m.withdrawn => "shared", _ => "none", } } else { cached.map(state_word).unwrap_or("unread") } .into(), count: if is_me { mine.as_ref().map(|m| m.count).unwrap_or(0) } else { cached.map(|c| c.entries.len() as u32).unwrap_or(0) }, sequence: if is_me { mine.as_ref().map(|m| m.sequence).unwrap_or(0) } else { cached.map(|c| c.sequence).unwrap_or(0) }, read_at: cached.map(|c| c.read_at).unwrap_or(0), key: k, } }) .collect(); let sharing = members.iter().filter(|m| m.state == "shared").count() as u32; RoomHoldings { owner: owner_b58.into(), space_id: space_b58.into(), name: facts.name.clone(), share_holdings: facts.share_holdings, mine, members, sharing } } // ---------------------------------------------------------------- the ask #[derive(Debug, Deserialize)] pub struct AskItem { pub artist: String, pub album: String, #[serde(default)] pub mbid: Option, } #[derive(Debug, Clone, Serialize)] pub struct Holder { pub owner: String, pub space_id: String, pub space_name: String, pub member: String, pub username: Option, pub tier: u8, /// How the holding matched: "name" | "mbid". pub matched_by: &'static str, } #[derive(Debug, Serialize)] pub struct Answer { pub artist: String, pub album: String, pub holders: Vec, } /// Who holds each album, across every room this device is in — from the /// cache, no network. A person in two rooms is one holder per room; the /// frontend folds by username when it wants to. pub fn ask(app: &tauri::AppHandle, items: &[AskItem]) -> Vec { let l = load(app); let rooms = spaces::rooms(app); // Per room: member → (key set, mbid set, username, tier by key). struct RoomIndex { owner: String, space_id: String, name: String, sid: [u8; 16], members: Vec<(String, Option, BTreeMap, BTreeSet)>, } let index: Vec = rooms .into_iter() .filter_map(|(owner, space, name)| { let sid = spaces::id_of(&space).ok()?; let cache = l.cache.get(&spaces::record_id(&owner, &space))?; let members = cache .iter() .filter(|(_, c)| !c.absent && !c.withdrawn && !c.unreadable && !c.entries.is_empty()) .map(|(k, c)| { let mut keys = BTreeMap::new(); let mut mbids = BTreeSet::new(); for (key, mbid, tier) in &c.entries { keys.insert(key.clone(), *tier); if let Some(m) = mbid { mbids.insert(m.clone()); } } (k.clone(), c.slsk_username.clone().or_else(|| spaces::username_behind(app, &owner, &space, k)), keys, mbids) }) .collect(); Some(RoomIndex { owner, space_id: space, name, sid, members }) }) .collect(); items .iter() .map(|it| { let (na, nt) = (overlap::norm(&it.artist), overlap::norm(&it.album)); let mut holders = Vec::new(); if !na.is_empty() && !nt.is_empty() { for r in &index { let key = b58(&hd::album_key(&r.sid, &na, &nt)); let mk = it.mbid.as_deref().filter(|m| !m.trim().is_empty()).map(|m| b58(&hd::mbid_key(&r.sid, m))); for (member, username, keys, mbids) in &r.members { let by_name = keys.get(&key).copied(); let by_mbid = mk.as_ref().filter(|m| mbids.contains(*m)).map(|_| hd::TIER_UNKNOWN); if let Some((tier, how)) = by_name.map(|t| (t, "name")).or(by_mbid.map(|t| (t, "mbid"))) { holders.push(Holder { owner: r.owner.clone(), space_id: r.space_id.clone(), space_name: r.name.clone(), member: member.clone(), username: username.clone(), tier, matched_by: how, }); } } } } // Lossless first, then those who can actually be asked. holders.sort_by(|a, b| b.tier.cmp(&a.tier).then(b.username.is_some().cmp(&a.username.is_some()))); Answer { artist: it.artist.clone(), album: it.album.clone(), holders } }) .collect() } // ---------------------------------------------------------------- getting it #[derive(Debug, Serialize)] pub struct FileRef { pub path: String, pub name: String, pub size: u64, } #[derive(Debug, Serialize)] pub struct FolderMatch { pub username: String, pub folder: String, pub files: Vec, pub tier: u8, } /// Browse a holder's share and find the folder that names this album — the /// overlap walk, so what the room said they hold is what gets grabbed. Disc /// subfolders come along. None when their share does not name it (they /// moved it, or the room's word was a near-name). pub async fn find_folder(app: &tauri::AppHandle, username: &str, artist: &str, album: &str) -> Result, String> { let (na, nt) = (overlap::norm(artist), overlap::norm(album)); if na.is_empty() || nt.is_empty() { return Err("that album has no name to look for".into()); } let fut = { let state = app.state::(); let guard = state.client.lock().await; let client = guard.as_ref().ok_or("not connected to Soulseek")?; client.browse(username) }; let dirs = fut.await.map_err(|e| e.to_string())?; overlap::note_browse(app, username, &dirs); let mut files = Vec::new(); let mut folder: Option = None; for d in &dirs { let Some((a, b)) = overlap::pair_of(&d.name) else { continue }; let (da, db) = (overlap::norm(&a), overlap::norm(&b)); let hit = da == na && (db == nt || (nt.len() >= 5 && db.len() >= 5 && (db.contains(nt.as_str()) || nt.contains(db.as_str())))); if !hit { continue; } let audio: Vec<&soulseek_proto::types::File> = d.files.iter().filter(|f| overlap::is_audio(f)).collect(); if audio.is_empty() { continue; } if folder.is_none() { folder = Some(d.name.clone()); } for f in audio { files.push(FileRef { path: format!("{}\\{}", d.name, f.filename), name: f.filename.clone(), size: f.size }); } } let Some(folder) = folder else { return Ok(None) }; let tier = files .iter() .map(|f| tier_of(f.name.rsplit('.').next().unwrap_or_default())) .max() .unwrap_or(hd::TIER_UNKNOWN); Ok(Some(FolderMatch { username: username.to_string(), folder, files, tier })) } // ---------------------------------------------------------------- waits pub async fn wait_add(app: &tauri::AppHandle, w: Wait) -> Result, String> { let _guard = ledger::hold(LEDGER_FILE).await; let mut l = load(app); let same = |x: &Wait| x.username.eq_ignore_ascii_case(&w.username) && overlap::norm(&x.artist) == overlap::norm(&w.artist) && overlap::norm(&x.album) == overlap::norm(&w.album); if !l.waits.iter().any(same) { l.waits.push(Wait { since: now_secs(), ..w }); save(app, &l)?; } Ok(l.waits) } pub async fn wait_remove(app: &tauri::AppHandle, username: &str, artist: &str, album: &str) -> Result, String> { let _guard = ledger::hold(LEDGER_FILE).await; let mut l = load(app); let (na, nt) = (overlap::norm(artist), overlap::norm(album)); l.waits.retain(|x| !(x.username.eq_ignore_ascii_case(username) && overlap::norm(&x.artist) == na && overlap::norm(&x.album) == nt)); save(app, &l)?; Ok(l.waits) } // ---------------------------------------------------------------- commands #[tauri::command] pub async fn holdings_set_share(app: tauri::AppHandle, owner: String, space: String, on: bool) -> Result { set_share(&app, &owner, &space, on).await?; room_now(&app, &owner, &space) } /// The room's lists. `refresh` re-reads every member; otherwise only stale /// ones, and only from a spawned task so the answer is immediate — the /// page hears the read finish by event. #[tauri::command] pub async fn holdings_room(app: tauri::AppHandle, owner: String, space: String, refresh: bool) -> Result { let now = room_now(&app, &owner, &space)?; let app2 = app.clone(); tauri::async_runtime::spawn(async move { if let Err(e) = read_room(&app2, &owner, &space, refresh).await { log::info!("holdings: reading {space}: {e}"); } }); Ok(now) } #[tauri::command] pub fn holdings_ask(app: tauri::AppHandle, items: Vec) -> Vec { ask(&app, &items) } #[tauri::command] pub async fn holdings_find_folder(app: tauri::AppHandle, username: String, artist: String, album: String) -> Result, String> { find_folder(&app, &username, &artist, &album).await } #[tauri::command] pub async fn holdings_republish(app: tauri::AppHandle) -> Result { republish_all(&app).await } #[tauri::command] pub fn holdings_waits(app: tauri::AppHandle) -> Vec { load(&app).waits } #[tauri::command] pub async fn holdings_wait_add(app: tauri::AppHandle, username: String, artist: String, album: String, via: Option) -> Result, String> { wait_add(&app, Wait { username, artist, album, since: 0, via }).await } #[tauri::command] pub async fn holdings_wait_remove(app: tauri::AppHandle, username: String, artist: String, album: String) -> Result, String> { wait_remove(&app, &username, &artist, &album).await } #[cfg(test)] mod tests { use super::*; /// The bundled contract is a commitment: a changed wasm re-keys every /// list on the network. #[test] fn contract_generation_is_pinned() { let ids = FreenetAdapter::contract_ids(HOLDINGS_WASM.to_vec(), &hd::make_params(&[1u8; 16], &[7u8; 32])); assert_eq!(ids.code_hash, "3roNW8VMniLcQVWSzrAZ9Vum8KvFY7DD2dFkrXy43h9Q"); } #[test] fn a_dominant_format_speaks_for_the_tier() { assert_eq!(tier_of("flac"), hd::TIER_LOSSLESS); assert_eq!(tier_of("FLAC"), hd::TIER_LOSSLESS); assert_eq!(tier_of("mp3"), hd::TIER_LOSSY); assert_eq!(tier_of("opus"), hd::TIER_LOSSY); assert_eq!(tier_of("bin"), hd::TIER_UNKNOWN); } /// An older ledger loads, and a wait is one per (person, album) however /// the names are spelled. #[test] fn the_ledger_reads_older_files_and_waits_fold_by_name() { let l: Ledger = serde_json::from_str("{}").unwrap(); assert!(l.mine.is_empty() && l.cache.is_empty() && l.waits.is_empty()); let w = Wait { username: "Kraven".into(), artist: "Stars of the Lid".into(), album: "And Their Refinement of the Decline".into(), since: 1, via: None }; let same = Wait { username: "kraven".into(), artist: "stars of the lid".into(), album: "And Their Refinement Of The Decline (2007) [FLAC]".into(), since: 2, via: None }; assert_eq!(overlap::norm(&w.album), overlap::norm(&same.album)); assert!(w.username.eq_ignore_ascii_case(&same.username)); } }