forked from Keelrleek/SoulStream
holdings: a member of a Space publishes what they hold as room-salted album keys, sealed to the room, so "who has this" is answered while they are offline
This commit is contained in:
1 parent
08ca09586a
commit
a98c750908
12 files changed
+2955
-8
No files matched your search
@@ -0,0 +1,809 @@
|
||||
//! 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<String>,
|
||||
/// (key b58, mbid key b58, tier) — the same three the wire carries.
|
||||
entries: Vec<(String, Option<String>, 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<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
struct Ledger {
|
||||
#[serde(default)]
|
||||
mine: BTreeMap<String, Mine>,
|
||||
/// room record id → member key (b58) → the list.
|
||||
#[serde(default)]
|
||||
cache: BTreeMap<String, BTreeMap<String, Cached>>,
|
||||
#[serde(default)]
|
||||
waits: Vec<Wait>,
|
||||
}
|
||||
|
||||
fn path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
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<hd::Holding>, u32), String> {
|
||||
let albums = lib.list_albums().map_err(|e| e.to_string())?;
|
||||
let mbids = lib.album_mbids().unwrap_or_default();
|
||||
let tiers: BTreeMap<i64, u8> = 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<hd::Holding> = 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::<AppState>().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<Option<hd::HoldingsState>, 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<Vec<u8>, 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<Seat, String> {
|
||||
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<Mine, String> {
|
||||
let st = seat(app, owner_b58, space_b58).await?;
|
||||
let lib = app.state::<LibraryState>().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<u32, String> {
|
||||
let rooms = spaces::rooms_sharing_holdings(app);
|
||||
let mut n = 0;
|
||||
for (owner, space) in rooms {
|
||||
let stale = {
|
||||
let lib = app.state::<LibraryState>().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<String>,
|
||||
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<Mine>,
|
||||
pub members: Vec<MemberHoldings>,
|
||||
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<RoomHoldings, String> {
|
||||
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<RoomHoldings, String> {
|
||||
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::<Vec<_>>();
|
||||
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<MemberHoldings> = 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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Holder {
|
||||
pub owner: String,
|
||||
pub space_id: String,
|
||||
pub space_name: String,
|
||||
pub member: String,
|
||||
pub username: Option<String>,
|
||||
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<Holder>,
|
||||
}
|
||||
|
||||
/// 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<Answer> {
|
||||
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<String>, BTreeMap<String, u8>, BTreeSet<String>)>,
|
||||
}
|
||||
let index: Vec<RoomIndex> = 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<FileRef>,
|
||||
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<Option<FolderMatch>, 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::<AppState>();
|
||||
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<String> = 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<Vec<Wait>, 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<Vec<Wait>, 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<RoomHoldings, String> {
|
||||
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<RoomHoldings, String> {
|
||||
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<AskItem>) -> Vec<Answer> {
|
||||
ask(&app, &items)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn holdings_find_folder(app: tauri::AppHandle, username: String, artist: String, album: String) -> Result<Option<FolderMatch>, String> {
|
||||
find_folder(&app, &username, &artist, &album).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn holdings_republish(app: tauri::AppHandle) -> Result<u32, String> {
|
||||
republish_all(&app).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn holdings_waits(app: tauri::AppHandle) -> Vec<Wait> {
|
||||
load(&app).waits
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn holdings_wait_add(app: tauri::AppHandle, username: String, artist: String, album: String, via: Option<String>) -> Result<Vec<Wait>, 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<Vec<Wait>, 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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user