Extend DHT scheme with content_id

This commit is contained in:
Ultradesu
2026-07-20 18:04:54 +03:00
parent a897737978
commit 8ee1db9cf8
8 changed files with 232 additions and 25 deletions
+81 -17
View File
@@ -19,13 +19,13 @@ use crate::node::{Node, record_supersedes};
use crate::normalization::{normalize_name, tokenize};
use crate::record::{
DhtKey, ItemId, ItemKind, LibraryItem, MAX_ARTISTS_PER_ITEM, MAX_ITEM_NAME_BYTES,
MAX_TOKENS_PER_ITEM, PeerId, StoredRecord, now_ms, validate_name,
MAX_TOKENS_PER_ITEM, PeerId, StoredRecord, normalize_content_id, now_ms, validate_name,
};
use crate::routing::{NodeContact, NodeId};
/// Fixed schema of the music-dht protocol; peers with a different schema are
/// rejected by `federation-net` during the handshake.
pub const SCHEMA_NAME: &str = "music-dht-poc-v2";
pub const SCHEMA_NAME: &str = "music-dht-poc-v3";
/// Capacity of the application event channel.
const EVENT_CHANNEL_CAPACITY: usize = 256;
@@ -108,6 +108,8 @@ pub struct ItemSpec {
pub disc_number: Option<i32>,
/// Track duration in seconds for tracks.
pub duration_seconds: Option<f64>,
/// Stable audio content id for tracks (`b3:<64 lowercase hex chars>`).
pub content_id: Option<String>,
}
fn sanitize_artist_names(
@@ -184,6 +186,19 @@ fn same_content(a: &LibraryItem, b: &LibraryItem) -> bool {
&& a.track_number == b.track_number
&& a.disc_number == b.disc_number
&& a.duration_seconds == b.duration_seconds
&& a.content_id == b.content_id
}
fn merge_records(merged: &mut HashMap<(ItemId, PeerId), StoredRecord>, records: Vec<StoredRecord>) {
for record in records {
let key = (record.item.id, record.item.owner);
match merged.get(&key) {
Some(existing) if !record_supersedes(&record, existing) => {}
_ => {
merged.insert(key, record);
}
}
}
}
/// Result of a combined local + network search.
@@ -427,6 +442,11 @@ impl MusicDhtService {
&mut seen_artists,
MAX_ARTISTS_PER_ITEM.saturating_sub(artist_names.len()),
);
let content_id = if spec.kind == ItemKind::Track {
spec.content_id.as_deref().and_then(normalize_content_id)
} else {
None
};
let mut item = LibraryItem {
id,
owner,
@@ -441,6 +461,7 @@ impl MusicDhtService {
track_number: positive_index(spec.track_number),
disc_number: positive_index(spec.disc_number),
duration_seconds: spec.duration_seconds.filter(|duration| *duration > 0.0),
content_id,
revision: 1,
deleted: false,
updated_at_ms: now_ms(),
@@ -535,21 +556,10 @@ impl MusicDhtService {
let mut discovered_nodes = 0usize;
// (item id, owner) -> best record seen so far.
let mut merged: HashMap<(ItemId, PeerId), StoredRecord> = HashMap::new();
fn merge(merged: &mut HashMap<(ItemId, PeerId), StoredRecord>, records: Vec<StoredRecord>) {
for record in records {
let key = (record.item.id, record.item.owner);
match merged.get(&key) {
Some(existing) if !record_supersedes(&record, existing) => {}
_ => {
merged.insert(key, record);
}
}
}
}
// Step 1: the exact key — local replicas, then the network.
let exact_key = DhtKey::exact(&network_id, &normalized);
merge(
merge_records(
&mut merged,
self.node.db.dht_records_by_key(exact_key, now_ms()).await?,
);
@@ -559,21 +569,21 @@ impl MusicDhtService {
.await;
queried_nodes += outcome.queried;
discovered_nodes = discovered_nodes.max(outcome.discovered);
merge(&mut merged, outcome.records);
merge_records(&mut merged, outcome.records);
// Step 2: token keys — always, not only as a fallback. The exact key
// of "massive attack" carries the artist record only; the artist's
// releases and tracks live under the token keys.
for token in &tokens {
let key = DhtKey::token(&network_id, token);
merge(
merge_records(
&mut merged,
self.node.db.dht_records_by_key(key, now_ms()).await?,
);
let outcome = self.node.lookup(*key.as_bytes(), Some(key)).await;
queried_nodes += outcome.queried;
discovered_nodes = discovered_nodes.max(outcome.discovered);
merge(&mut merged, outcome.records);
merge_records(&mut merged, outcome.records);
}
// Drop tombstones and expired records, then rank: full-token matches
@@ -605,6 +615,60 @@ impl MusicDhtService {
})
}
/// Searches locally and across the DHT for the exact same audio content.
///
/// `content_id` must be the canonical `b3:<64 hex>` identifier generated
/// from the audio file bytes. This lookup is intended for playback
/// fallback: if the originally liked peer is offline, the application can
/// find another peer that published the same track bytes.
pub async fn search_content_id(&self, content_id: &str) -> Result<SearchOutcome> {
self.node.ensure_running()?;
let started = Instant::now();
let content_id = normalize_content_id(content_id).ok_or(MusicDhtError::InvalidItemName)?;
let network_id = self.node.config.network_id;
let local_results: Vec<LibraryItem> = self
.node
.db
.list_local_items(false)
.await?
.into_iter()
.filter(|item| item.content_id.as_deref() == Some(content_id.as_str()))
.collect();
let key = DhtKey::content(&network_id, &content_id);
let mut merged: HashMap<(ItemId, PeerId), StoredRecord> = HashMap::new();
merge_records(
&mut merged,
self.node.db.dht_records_by_key(key, now_ms()).await?,
);
let outcome = self.node.lookup(*key.as_bytes(), Some(key)).await;
let queried_nodes = outcome.queried;
let discovered_nodes = outcome.discovered;
merge_records(&mut merged, outcome.records);
let now = now_ms();
let mut network_results: Vec<LibraryItem> = merged
.into_values()
.filter(|record| !record.item.deleted && record.expires_at_ms > now)
.filter(|record| record.item.content_id.as_deref() == Some(content_id.as_str()))
.map(|record| record.item)
.collect();
network_results.sort_by(|a, b| {
a.normalized_name
.cmp(&b.normalized_name)
.then_with(|| a.owner.to_string().cmp(&b.owner.to_string()))
});
Ok(SearchOutcome {
local_results,
network_results,
queried_nodes,
discovered_nodes,
duration: started.elapsed(),
})
}
/// Pings a known peer: verifies liveness and DHT identity, returns the
/// round-trip time and refreshes the contact.
pub async fn ping(&self, peer: EndpointId) -> Result<Duration> {