2691 lines
96 KiB
Rust
2691 lines
96 KiB
Rust
//! P2P federation for the TUI player.
|
||
//!
|
||
//! The player stays fully local, but can join a federated network of
|
||
//! furumi instances (TUI or furumi-fd): it publishes its library index
|
||
//! (names and small metadata, never files) into a shared DHT, searches the
|
||
//! other participants' libraries and streams their audio over the same
|
||
//! `furumi-fd/audio/1` protocol furumi-fd speaks — the two are wire
|
||
//! compatible.
|
||
//!
|
||
//! Federated tracks are downloaded before playback: into a cache file, or —
|
||
//! with "save on listen" enabled — straight into the local library (the
|
||
//! file is imported like any local file, so this peer then serves it to
|
||
//! the network too).
|
||
|
||
mod audio;
|
||
mod capabilities;
|
||
pub mod catalog;
|
||
|
||
use std::collections::{HashMap, VecDeque};
|
||
use std::path::{Path, PathBuf};
|
||
use std::str::FromStr;
|
||
use std::sync::Arc;
|
||
use std::sync::atomic::{AtomicI64, Ordering};
|
||
use std::time::Duration;
|
||
|
||
use anyhow::{Context, Result};
|
||
use music_dht::{
|
||
ByteStream, ByteStreamConnectionStats, EndpointId, ItemKind, ItemSpec, LibraryItem,
|
||
MusicDhtConfig, MusicDhtService, NetworkId, PeerTicket, PublishStats, RendezvousConfig,
|
||
SyncStats,
|
||
};
|
||
use rusqlite::{Connection, OpenFlags, params};
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use crate::library::Library;
|
||
use crate::library::NetworkArtistPreview;
|
||
use crate::library::models::{ArtistRef, TrackItem};
|
||
|
||
pub use audio::{AUDIO_ALPN, DownloadProgress, StreamingStart, TrackMetadata};
|
||
pub use capabilities::ProtocolVersions;
|
||
pub use catalog::{CATALOG_ALPN, FedAppearsOn, FedArtistCard, FedCardTrack, FedRelease};
|
||
|
||
/// How often the published library is re-synchronized with the local index.
|
||
const SYNC_INTERVAL: Duration = Duration::from_secs(60);
|
||
|
||
/// How many times a share-link content lookup is retried before the label
|
||
/// fallback kicks in.
|
||
const CONTENT_LOOKUP_ATTEMPTS: usize = 3;
|
||
/// Pause between share-link content lookup attempts.
|
||
const CONTENT_LOOKUP_RETRY_DELAY: Duration = Duration::from_secs(2);
|
||
const TRANSPORT_SAMPLE_LIMIT: usize = 16;
|
||
|
||
/// Ephemeral (not-in-library) tracks get negative ids so the rest of the
|
||
/// app can tell them apart from library rows (history, likes and release
|
||
/// navigation skip them).
|
||
static NEXT_EPHEMERAL_ID: AtomicI64 = AtomicI64::new(-1);
|
||
|
||
#[derive(Debug, Clone, Default)]
|
||
pub struct TransportSample {
|
||
pub at: String,
|
||
pub protocol: &'static str,
|
||
pub direction: &'static str,
|
||
pub phase: &'static str,
|
||
pub peer_id: String,
|
||
pub selected_path: String,
|
||
pub open_paths: usize,
|
||
pub direct_paths: usize,
|
||
pub relay_paths: usize,
|
||
pub custom_paths: usize,
|
||
pub selected_rtt_ms: Option<u64>,
|
||
pub selected_tx_bytes: u64,
|
||
pub selected_rx_bytes: u64,
|
||
pub total_tx_bytes: u64,
|
||
pub total_rx_bytes: u64,
|
||
pub lost_packets: u64,
|
||
pub lost_bytes: u64,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, Default)]
|
||
struct TransportTrafficCursor {
|
||
tx_bytes: u64,
|
||
rx_bytes: u64,
|
||
lost_packets: u64,
|
||
lost_bytes: u64,
|
||
}
|
||
|
||
impl TransportSample {
|
||
fn from_stats(
|
||
protocol: &'static str,
|
||
direction: &'static str,
|
||
phase: &'static str,
|
||
stats: ByteStreamConnectionStats,
|
||
) -> Self {
|
||
Self {
|
||
at: now_label(),
|
||
protocol,
|
||
direction,
|
||
phase,
|
||
peer_id: stats.peer_id.to_string(),
|
||
selected_path: stats.selected_path.as_str().to_string(),
|
||
open_paths: stats.open_paths,
|
||
direct_paths: stats.direct_paths,
|
||
relay_paths: stats.relay_paths,
|
||
custom_paths: stats.custom_paths,
|
||
selected_rtt_ms: stats
|
||
.selected_rtt
|
||
.map(|duration| duration.as_millis() as u64),
|
||
selected_tx_bytes: stats.selected_tx_bytes,
|
||
selected_rx_bytes: stats.selected_rx_bytes,
|
||
total_tx_bytes: stats.total_tx_bytes,
|
||
total_rx_bytes: stats.total_rx_bytes,
|
||
lost_packets: stats.lost_packets,
|
||
lost_bytes: stats.lost_bytes,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone, Default)]
|
||
pub struct TransportStatsSnapshot {
|
||
pub total_samples: u64,
|
||
pub direct_samples: u64,
|
||
pub relay_samples: u64,
|
||
pub custom_samples: u64,
|
||
pub unknown_samples: u64,
|
||
pub audio_samples: u64,
|
||
pub catalog_samples: u64,
|
||
pub sync_samples: u64,
|
||
pub runtime_tx_bytes: u64,
|
||
pub runtime_rx_bytes: u64,
|
||
pub runtime_lost_packets: u64,
|
||
pub runtime_lost_bytes: u64,
|
||
pub active_streams: usize,
|
||
pub last: Vec<TransportSample>,
|
||
}
|
||
|
||
#[derive(Debug, Default)]
|
||
struct TransportStatsState {
|
||
total_samples: u64,
|
||
direct_samples: u64,
|
||
relay_samples: u64,
|
||
custom_samples: u64,
|
||
unknown_samples: u64,
|
||
audio_samples: u64,
|
||
catalog_samples: u64,
|
||
sync_samples: u64,
|
||
runtime_tx_bytes: u64,
|
||
runtime_rx_bytes: u64,
|
||
runtime_lost_packets: u64,
|
||
runtime_lost_bytes: u64,
|
||
traffic_cursors: HashMap<u64, TransportTrafficCursor>,
|
||
last: VecDeque<TransportSample>,
|
||
}
|
||
|
||
#[derive(Debug, Default)]
|
||
pub struct TransportStats {
|
||
inner: std::sync::Mutex<TransportStatsState>,
|
||
}
|
||
|
||
impl TransportStats {
|
||
fn reset(&self) {
|
||
*lock(&self.inner) = TransportStatsState::default();
|
||
}
|
||
|
||
pub fn record(
|
||
&self,
|
||
stream_key: u64,
|
||
protocol: &'static str,
|
||
direction: &'static str,
|
||
phase: &'static str,
|
||
stats: ByteStreamConnectionStats,
|
||
) {
|
||
let sample = TransportSample::from_stats(protocol, direction, phase, stats);
|
||
let mut state = lock(&self.inner);
|
||
state.total_samples += 1;
|
||
let previous = if transport_phase_is_open(phase) {
|
||
TransportTrafficCursor::default()
|
||
} else {
|
||
state
|
||
.traffic_cursors
|
||
.get(&stream_key)
|
||
.copied()
|
||
.unwrap_or_default()
|
||
};
|
||
state.runtime_tx_bytes = state
|
||
.runtime_tx_bytes
|
||
.saturating_add(sample.total_tx_bytes.saturating_sub(previous.tx_bytes));
|
||
state.runtime_rx_bytes = state
|
||
.runtime_rx_bytes
|
||
.saturating_add(sample.total_rx_bytes.saturating_sub(previous.rx_bytes));
|
||
state.runtime_lost_packets = state
|
||
.runtime_lost_packets
|
||
.saturating_add(sample.lost_packets.saturating_sub(previous.lost_packets));
|
||
state.runtime_lost_bytes = state
|
||
.runtime_lost_bytes
|
||
.saturating_add(sample.lost_bytes.saturating_sub(previous.lost_bytes));
|
||
state.traffic_cursors.insert(
|
||
stream_key,
|
||
TransportTrafficCursor {
|
||
tx_bytes: sample.total_tx_bytes,
|
||
rx_bytes: sample.total_rx_bytes,
|
||
lost_packets: sample.lost_packets,
|
||
lost_bytes: sample.lost_bytes,
|
||
},
|
||
);
|
||
if transport_phase_is_terminal(phase) {
|
||
state.traffic_cursors.remove(&stream_key);
|
||
}
|
||
match sample.selected_path.as_str() {
|
||
"direct" => state.direct_samples += 1,
|
||
"relay" => state.relay_samples += 1,
|
||
"custom" => state.custom_samples += 1,
|
||
_ => state.unknown_samples += 1,
|
||
}
|
||
match protocol {
|
||
"audio" => state.audio_samples += 1,
|
||
"catalog" => state.catalog_samples += 1,
|
||
"device-sync" => state.sync_samples += 1,
|
||
_ => {}
|
||
}
|
||
state.last.push_front(sample);
|
||
while state.last.len() > TRANSPORT_SAMPLE_LIMIT {
|
||
state.last.pop_back();
|
||
}
|
||
}
|
||
|
||
fn snapshot(&self) -> TransportStatsSnapshot {
|
||
let state = lock(&self.inner);
|
||
TransportStatsSnapshot {
|
||
total_samples: state.total_samples,
|
||
direct_samples: state.direct_samples,
|
||
relay_samples: state.relay_samples,
|
||
custom_samples: state.custom_samples,
|
||
unknown_samples: state.unknown_samples,
|
||
audio_samples: state.audio_samples,
|
||
catalog_samples: state.catalog_samples,
|
||
sync_samples: state.sync_samples,
|
||
runtime_tx_bytes: state.runtime_tx_bytes,
|
||
runtime_rx_bytes: state.runtime_rx_bytes,
|
||
runtime_lost_packets: state.runtime_lost_packets,
|
||
runtime_lost_bytes: state.runtime_lost_bytes,
|
||
active_streams: state.traffic_cursors.len(),
|
||
last: state.last.iter().cloned().collect(),
|
||
}
|
||
}
|
||
}
|
||
|
||
fn transport_phase_is_terminal(phase: &str) -> bool {
|
||
phase == "done" || phase.ends_with("-done")
|
||
}
|
||
|
||
fn transport_phase_is_open(phase: &str) -> bool {
|
||
phase == "open" || phase.ends_with("-open")
|
||
}
|
||
|
||
pub(crate) fn stream_transport_key(stream: &ByteStream) -> u64 {
|
||
stream as *const ByteStream as usize as u64
|
||
}
|
||
|
||
pub fn record_stream_transport(
|
||
stats: &Arc<TransportStats>,
|
||
protocol: &'static str,
|
||
direction: &'static str,
|
||
phase: &'static str,
|
||
stream: &ByteStream,
|
||
) {
|
||
stats.record(
|
||
stream_transport_key(stream),
|
||
protocol,
|
||
direction,
|
||
phase,
|
||
stream.connection_stats(),
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Settings (persisted in <config dir>/federation.toml)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||
pub struct FedSettings {
|
||
pub enabled: bool,
|
||
#[serde(default)]
|
||
pub network_id: String,
|
||
/// Downloaded-for-playback federated tracks are imported into the local
|
||
/// library (replication) instead of a throwaway cache.
|
||
#[serde(default)]
|
||
pub save_on_listen: bool,
|
||
}
|
||
|
||
fn settings_path() -> Option<PathBuf> {
|
||
crate::config::project_dirs().map(|dirs| dirs.config_dir().join("federation.toml"))
|
||
}
|
||
|
||
pub fn load_settings() -> FedSettings {
|
||
let Some(path) = settings_path() else {
|
||
return FedSettings::default();
|
||
};
|
||
match std::fs::read_to_string(&path) {
|
||
Ok(text) => toml::from_str(&text).unwrap_or_else(|err| {
|
||
tracing::warn!(%err, "federation.toml is malformed; using defaults");
|
||
FedSettings::default()
|
||
}),
|
||
Err(_) => FedSettings::default(),
|
||
}
|
||
}
|
||
|
||
fn save_settings(settings: &FedSettings) -> Result<()> {
|
||
let path = settings_path().context("cannot determine the config directory")?;
|
||
if let Some(parent) = path.parent() {
|
||
std::fs::create_dir_all(parent)?;
|
||
}
|
||
std::fs::write(&path, toml::to_string_pretty(settings)?)?;
|
||
Ok(())
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Data shapes for the UI
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// An artist surfaced by federated search — either an artist record, or
|
||
/// derived from the artist names of matching tracks/releases (so searching
|
||
/// a track title still leads to the artist's card).
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct FedArtistHit {
|
||
pub name: String,
|
||
/// Distinct peers (other than this instance) holding the artist.
|
||
pub peers: usize,
|
||
}
|
||
|
||
/// Federated search results for the UI.
|
||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||
pub struct FedSearchResults {
|
||
pub artists: Vec<FedArtistHit>,
|
||
pub tracks: Vec<FedTrack>,
|
||
}
|
||
|
||
/// A track found through federated search.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct FedTrack {
|
||
/// Hex item id in the DHT (the key audio is requested by).
|
||
pub item_id: String,
|
||
/// Hex endpoint id of the owning peer.
|
||
pub owner: String,
|
||
/// The item is published by this very instance.
|
||
pub own: bool,
|
||
pub title: String,
|
||
pub artist_names: Vec<String>,
|
||
pub featured_artist_names: Vec<String>,
|
||
pub year: Option<i32>,
|
||
pub duration_seconds: Option<i64>,
|
||
/// Stable audio content id (`b3:<64 hex>`) when the owner published it.
|
||
pub content_id: Option<String>,
|
||
/// Release context, known when the track came from an artist card.
|
||
pub release_title: Option<String>,
|
||
pub track_number: Option<i32>,
|
||
pub disc_number: Option<i32>,
|
||
}
|
||
|
||
impl FedTrack {
|
||
pub fn artist_line(&self) -> String {
|
||
artist_line(&self.artist_names, &self.featured_artist_names)
|
||
}
|
||
|
||
pub fn owner_short(&self) -> String {
|
||
self.owner.chars().take(10).collect()
|
||
}
|
||
|
||
pub fn duration_label(&self) -> String {
|
||
match self.duration_seconds {
|
||
Some(total) => format!("{}:{:02}", total / 60, total % 60),
|
||
None => String::new(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Live status snapshot rendered on the Federation tab.
|
||
#[derive(Debug, Clone, Default)]
|
||
pub struct FedStatus {
|
||
pub running: bool,
|
||
pub network: String,
|
||
pub endpoint_id: String,
|
||
pub dht_node_id: String,
|
||
pub connected_peers: Vec<String>,
|
||
pub known_contacts: usize,
|
||
pub stored_dht_records: Option<usize>,
|
||
pub stored_dht_bytes: Option<u64>,
|
||
pub published_items: usize,
|
||
pub last_sync: Option<String>,
|
||
pub last_error: Option<String>,
|
||
pub transport: TransportStatsSnapshot,
|
||
pub protocols: ProtocolVersions,
|
||
}
|
||
|
||
/// Outcome of preparing a federated track for playback.
|
||
#[derive(Debug)]
|
||
pub struct FedPlayable {
|
||
pub track: TrackItem,
|
||
/// The file was imported into the local library (save-on-listen).
|
||
pub imported: bool,
|
||
}
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||
pub struct NetworkLibrarySource {
|
||
pub endpoint_id: String,
|
||
pub kind: &'static str,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// The federation manager (lives in Runtime, not AppState)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
struct Running {
|
||
service: Arc<MusicDhtService>,
|
||
network_name: String,
|
||
network_id: NetworkId,
|
||
tasks: Vec<tokio::task::JoinHandle<()>>,
|
||
}
|
||
|
||
pub struct Federation {
|
||
library: Arc<Library>,
|
||
devices: Arc<crate::devices::DeviceSync>,
|
||
jam: Arc<crate::jam::JamManager>,
|
||
data_dir: PathBuf,
|
||
cache_dir: PathBuf,
|
||
media_dir: PathBuf,
|
||
metadata_cache: std::sync::Mutex<std::collections::HashMap<String, CachedTrackMetadata>>,
|
||
settings: std::sync::Mutex<FedSettings>,
|
||
running: tokio::sync::Mutex<Option<Running>>,
|
||
last_sync: std::sync::Mutex<Option<String>>,
|
||
last_error: std::sync::Mutex<Option<String>>,
|
||
transport_stats: Arc<TransportStats>,
|
||
observed_protocols: Arc<capabilities::ObservedVersions>,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
struct CachedTrackMetadata {
|
||
fed: FedTrack,
|
||
title: String,
|
||
artists: Vec<String>,
|
||
featured_artists: Vec<String>,
|
||
release_title: Option<String>,
|
||
release_type: Option<String>,
|
||
year: Option<i32>,
|
||
duration_seconds: Option<f64>,
|
||
track_number: Option<i32>,
|
||
disc_number: Option<i32>,
|
||
}
|
||
|
||
impl CachedTrackMetadata {
|
||
fn to_fed_track(&self) -> FedTrack {
|
||
FedTrack {
|
||
item_id: self.fed.item_id.clone(),
|
||
owner: self.fed.owner.clone(),
|
||
own: self.fed.own,
|
||
title: self.title.clone(),
|
||
artist_names: self.artists.clone(),
|
||
featured_artist_names: self.featured_artists.clone(),
|
||
year: self.year,
|
||
duration_seconds: self
|
||
.duration_seconds
|
||
.map(|duration| duration.round() as i64),
|
||
content_id: self.fed.content_id.clone(),
|
||
release_title: self.release_title.clone(),
|
||
track_number: self.track_number,
|
||
disc_number: self.disc_number,
|
||
}
|
||
}
|
||
}
|
||
|
||
fn lock<T>(mutex: &std::sync::Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||
mutex
|
||
.lock()
|
||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||
}
|
||
|
||
fn now_label() -> String {
|
||
// Seconds since start of the day are enough for a status line without
|
||
// pulling in a date-time crate.
|
||
let secs = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map(|d| d.as_secs())
|
||
.unwrap_or(0);
|
||
format!(
|
||
"{:02}:{:02}:{:02} UTC",
|
||
secs / 3600 % 24,
|
||
secs / 60 % 60,
|
||
secs % 60
|
||
)
|
||
}
|
||
|
||
fn unix_time_ms() -> u64 {
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map(|d| d.as_millis() as u64)
|
||
.unwrap_or(0)
|
||
}
|
||
|
||
async fn dht_record_payload_bytes(data_dir: PathBuf, now_ms: u64) -> Result<u64> {
|
||
tokio::task::spawn_blocking(move || -> Result<u64> {
|
||
let path = data_dir.join("state.sqlite3");
|
||
if !path.exists() {
|
||
return Ok(0);
|
||
}
|
||
let conn = Connection::open_with_flags(
|
||
&path,
|
||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||
)
|
||
.with_context(|| format!("opening {}", path.display()))?;
|
||
let bytes: i64 = conn.query_row(
|
||
"SELECT COALESCE(SUM(length(payload)), 0)
|
||
FROM dht_records
|
||
WHERE expires_at_ms > ?1",
|
||
params![now_ms as i64],
|
||
|row| row.get(0),
|
||
)?;
|
||
Ok(bytes.max(0) as u64)
|
||
})
|
||
.await
|
||
.context("DHT size query task failed")?
|
||
}
|
||
|
||
impl Federation {
|
||
pub fn new(
|
||
library: Arc<Library>,
|
||
devices: Arc<crate::devices::DeviceSync>,
|
||
jam: Arc<crate::jam::JamManager>,
|
||
) -> Arc<Self> {
|
||
let dirs = crate::config::project_dirs();
|
||
let data_dir = dirs
|
||
.as_ref()
|
||
.map(|d| d.data_dir().join("federation"))
|
||
.unwrap_or_else(|| PathBuf::from("federation"));
|
||
let cache_dir = dirs
|
||
.as_ref()
|
||
.map(|d| d.cache_dir().join("fedcache"))
|
||
.unwrap_or_else(|| PathBuf::from("fedcache"));
|
||
let media_dir = dirs
|
||
.as_ref()
|
||
.map(|d| d.data_dir().join("federation-media"))
|
||
.unwrap_or_else(|| PathBuf::from("federation-media"));
|
||
let initial_error = [&data_dir, &cache_dir, &media_dir]
|
||
.into_iter()
|
||
.find_map(|dir| {
|
||
std::fs::create_dir_all(dir)
|
||
.err()
|
||
.map(|err| format!("cannot create {}: {err}", dir.display()))
|
||
});
|
||
Arc::new(Self {
|
||
library,
|
||
devices,
|
||
jam,
|
||
data_dir,
|
||
cache_dir,
|
||
media_dir,
|
||
metadata_cache: std::sync::Mutex::new(Default::default()),
|
||
settings: std::sync::Mutex::new(load_settings()),
|
||
running: tokio::sync::Mutex::new(None),
|
||
last_sync: std::sync::Mutex::new(None),
|
||
last_error: std::sync::Mutex::new(initial_error),
|
||
transport_stats: Arc::new(TransportStats::default()),
|
||
observed_protocols: Arc::new(capabilities::ObservedVersions::default()),
|
||
})
|
||
}
|
||
|
||
pub fn settings(&self) -> FedSettings {
|
||
lock(&self.settings).clone()
|
||
}
|
||
|
||
pub async fn create_jam(&self) -> Result<String> {
|
||
let service = {
|
||
let running = self.running.lock().await;
|
||
Arc::clone(
|
||
&running
|
||
.as_ref()
|
||
.context("enable federation before creating a Jam")?
|
||
.service,
|
||
)
|
||
};
|
||
let (device_id, device_name) = self.devices.identity_summary()?;
|
||
self.jam
|
||
.create_or_regenerate(service, device_id, device_name)
|
||
.await
|
||
}
|
||
|
||
pub fn join_jam(&self, invite: &str) -> Result<()> {
|
||
let (device_id, device_name) = self.devices.identity_summary()?;
|
||
self.jam.join(invite, device_id, device_name)
|
||
}
|
||
|
||
fn cached_metadata_snapshot(&self) -> Vec<CachedTrackMetadata> {
|
||
lock(&self.metadata_cache).values().cloned().collect()
|
||
}
|
||
|
||
fn set_error(&self, message: Option<String>) {
|
||
*lock(&self.last_error) = message;
|
||
}
|
||
|
||
/// Persists new settings and starts/stops/restarts the node to match.
|
||
pub async fn apply_settings(self: &Arc<Self>, settings: FedSettings) -> Result<()> {
|
||
anyhow::ensure!(
|
||
!settings.enabled || !settings.network_id.trim().is_empty(),
|
||
"set a network id before enabling federation"
|
||
);
|
||
if let Err(err) = save_settings(&settings) {
|
||
tracing::warn!(%err, "saving federation settings failed");
|
||
}
|
||
*lock(&self.settings) = settings.clone();
|
||
if settings.enabled {
|
||
self.start(settings.network_id.trim().to_string()).await?;
|
||
self.spawn_sync_soon().await;
|
||
} else {
|
||
self.stop().await;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub async fn start_if_enabled(self: &Arc<Self>) {
|
||
let settings = self.settings();
|
||
if settings.enabled && !settings.network_id.trim().is_empty() {
|
||
if let Err(err) = self.start(settings.network_id.trim().to_string()).await {
|
||
tracing::error!("federation autostart failed: {err:#}");
|
||
self.set_error(Some(format!("autostart failed: {err}")));
|
||
} else {
|
||
self.spawn_sync_soon().await;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Starts the DHT node. Idempotent per network name.
|
||
async fn start(self: &Arc<Self>, network_name: String) -> Result<()> {
|
||
self.start_with_network_id(NetworkId::from_name(&network_name), network_name)
|
||
.await
|
||
}
|
||
|
||
async fn start_with_network_id(
|
||
self: &Arc<Self>,
|
||
network_id: NetworkId,
|
||
network_name: String,
|
||
) -> Result<()> {
|
||
let mut guard = self.running.lock().await;
|
||
if let Some(running) = guard.as_ref() {
|
||
if running.network_id == network_id {
|
||
return Ok(());
|
||
}
|
||
stop_running(guard.take()).await;
|
||
}
|
||
std::fs::create_dir_all(&self.data_dir)
|
||
.with_context(|| format!("creating {}", self.data_dir.display()))?;
|
||
self.transport_stats.reset();
|
||
|
||
let config = MusicDhtConfig::builder()
|
||
.data_dir(&self.data_dir)
|
||
.network_id(network_id)
|
||
// Peers of the network find each other knowing only its name.
|
||
.rendezvous(RendezvousConfig::default())
|
||
// Peers stream each other's audio over this protocol.
|
||
.stream_protocol(AUDIO_ALPN)
|
||
// ...and browse each other's per-artist catalogs over this one.
|
||
.stream_protocol(CATALOG_ALPN)
|
||
// Personal-device sync (likes, playlists, trusted devices).
|
||
.stream_protocol(crate::devices::SYNC_ALPN)
|
||
// Capability-scoped shared playback control.
|
||
.stream_protocol(crate::jam::JAM_ALPN)
|
||
// Informational application/protocol versions.
|
||
.schema_independent_stream_protocol(capabilities::CAPABILITIES_ALPN)
|
||
.build()
|
||
.map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?;
|
||
let (service, mut events) = MusicDhtService::start(config)
|
||
.await
|
||
.map_err(|err| anyhow::anyhow!("failed to start the DHT node: {err}"))?;
|
||
let service = Arc::new(service);
|
||
tracing::info!(
|
||
endpoint_id = %service.endpoint_id(),
|
||
network = %network_name,
|
||
"federation started"
|
||
);
|
||
|
||
// Drain DHT events into the log; the channel is bounded.
|
||
let event_task = tokio::spawn(async move {
|
||
while let Some(event) = events.recv().await {
|
||
tracing::debug!("federation event: {event:?}");
|
||
}
|
||
});
|
||
// Keep the published library in sync with the local index.
|
||
let sync_self = Arc::clone(self);
|
||
let sync_service = Arc::clone(&service);
|
||
let sync_task = tokio::spawn(async move {
|
||
let mut interval = tokio::time::interval(SYNC_INTERVAL);
|
||
loop {
|
||
interval.tick().await;
|
||
let _ = sync_self.sync_once(&sync_service).await;
|
||
}
|
||
});
|
||
// Serve audio requests from other peers of the network.
|
||
let audio_acceptor = service
|
||
.stream_acceptor(AUDIO_ALPN)
|
||
.map_err(|err| anyhow::anyhow!("failed to take the audio acceptor: {err}"))?;
|
||
let audio_task = tokio::spawn(audio::serve_peers(
|
||
audio_acceptor,
|
||
Arc::clone(&self.library),
|
||
service.endpoint_id(),
|
||
Arc::clone(&self.transport_stats),
|
||
));
|
||
// Serve per-artist catalog requests (the federated artist card).
|
||
let catalog_acceptor = service
|
||
.stream_acceptor(CATALOG_ALPN)
|
||
.map_err(|err| anyhow::anyhow!("failed to take the catalog acceptor: {err}"))?;
|
||
let catalog_task = tokio::spawn(catalog::serve_peers(
|
||
catalog_acceptor,
|
||
Arc::clone(&self.library),
|
||
service.endpoint_id(),
|
||
Arc::clone(&self.transport_stats),
|
||
));
|
||
let sync_acceptor = service
|
||
.stream_acceptor(crate::devices::SYNC_ALPN)
|
||
.map_err(|err| anyhow::anyhow!("failed to take the device-sync acceptor: {err}"))?;
|
||
let device_sync_task = tokio::spawn(crate::devices::serve_peers(
|
||
sync_acceptor,
|
||
Arc::clone(&self.devices),
|
||
Arc::clone(&service),
|
||
Arc::clone(&self.transport_stats),
|
||
));
|
||
let device_sync = Arc::clone(&self.devices);
|
||
let device_service = Arc::clone(&service);
|
||
let device_transport = Arc::clone(&self.transport_stats);
|
||
let device_tick_task = tokio::spawn(async move {
|
||
crate::devices::sync_loop(device_sync, device_service, device_transport).await;
|
||
});
|
||
let jam_acceptor = service
|
||
.stream_acceptor(crate::jam::JAM_ALPN)
|
||
.map_err(|err| anyhow::anyhow!("failed to take the Jam acceptor: {err}"))?;
|
||
let jam_serve_task =
|
||
tokio::spawn(crate::jam::serve_peers(jam_acceptor, Arc::clone(&self.jam)));
|
||
let jam_poll_task = tokio::spawn(crate::jam::poll_loop(
|
||
Arc::clone(&self.jam),
|
||
Arc::clone(&service),
|
||
));
|
||
let capabilities_acceptor = service
|
||
.stream_acceptor(capabilities::CAPABILITIES_ALPN)
|
||
.map_err(|err| anyhow::anyhow!("failed to take capabilities acceptor: {err}"))?;
|
||
let capabilities_serve_task = tokio::spawn(capabilities::serve(capabilities_acceptor));
|
||
let capabilities_probe_task = tokio::spawn(capabilities::probe_loop(
|
||
Arc::clone(&service),
|
||
Arc::clone(&self.observed_protocols),
|
||
));
|
||
|
||
*guard = Some(Running {
|
||
service,
|
||
network_name,
|
||
network_id,
|
||
tasks: vec![
|
||
event_task,
|
||
sync_task,
|
||
audio_task,
|
||
catalog_task,
|
||
device_sync_task,
|
||
device_tick_task,
|
||
jam_serve_task,
|
||
jam_poll_task,
|
||
capabilities_serve_task,
|
||
capabilities_probe_task,
|
||
],
|
||
});
|
||
self.set_error(None);
|
||
Ok(())
|
||
}
|
||
|
||
async fn stop(&self) {
|
||
let mut guard = self.running.lock().await;
|
||
stop_running(guard.take()).await;
|
||
}
|
||
|
||
pub async fn shutdown(&self) {
|
||
self.stop().await;
|
||
}
|
||
|
||
async fn service(&self) -> Result<Arc<MusicDhtService>> {
|
||
self.running
|
||
.lock()
|
||
.await
|
||
.as_ref()
|
||
.map(|running| Arc::clone(&running.service))
|
||
.context("federation is not running")
|
||
}
|
||
|
||
fn ensure_connected_devices_enabled(&self) -> Result<()> {
|
||
let settings = self.settings();
|
||
anyhow::ensure!(
|
||
settings.enabled && !settings.network_id.trim().is_empty(),
|
||
"enable federation before using connected devices"
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
async fn running_service_and_network(&self) -> Result<(Arc<MusicDhtService>, NetworkId)> {
|
||
self.running
|
||
.lock()
|
||
.await
|
||
.as_ref()
|
||
.map(|running| (Arc::clone(&running.service), running.network_id))
|
||
.context("federation is not running")
|
||
}
|
||
|
||
/// Publishes the library immediately (used right after start/settings).
|
||
async fn spawn_sync_soon(self: &Arc<Self>) {
|
||
if let Ok(service) = self.service().await {
|
||
let fed = Arc::clone(self);
|
||
tokio::spawn(async move {
|
||
let _ = fed.sync_once(&service).await;
|
||
});
|
||
}
|
||
}
|
||
|
||
pub async fn sync_now(self: &Arc<Self>) -> Result<()> {
|
||
let service = self.service().await?;
|
||
let sync_stats = self.sync_once(&service).await?;
|
||
let publish_stats = match service.republish().await {
|
||
Ok(stats) => stats,
|
||
Err(err) => {
|
||
tracing::warn!("federation republish failed: {err}");
|
||
self.set_error(Some(format!("republish failed: {err}")));
|
||
anyhow::bail!("republish failed: {err}");
|
||
}
|
||
};
|
||
self.record_publish_success(sync_stats, publish_stats);
|
||
Ok(())
|
||
}
|
||
|
||
async fn sync_once(&self, service: &MusicDhtService) -> Result<SyncStats> {
|
||
let library = Arc::clone(&self.library);
|
||
let specs = tokio::task::spawn_blocking(move || collect_specs(&library)).await;
|
||
let specs = match specs {
|
||
Ok(Ok(specs)) => specs,
|
||
Ok(Err(err)) => {
|
||
tracing::warn!("federation sync: library read failed: {err:#}");
|
||
self.set_error(Some(format!("library read failed: {err}")));
|
||
anyhow::bail!("library read failed: {err}");
|
||
}
|
||
Err(err) => {
|
||
tracing::warn!("federation sync task failed: {err}");
|
||
anyhow::bail!("sync task failed: {err}");
|
||
}
|
||
};
|
||
match service.sync_library(specs).await {
|
||
Ok(stats) => {
|
||
self.record_sync_success(stats);
|
||
if stats.failed > 0 {
|
||
self.set_error(Some(format!(
|
||
"{} item(s) failed to publish in the last sync",
|
||
stats.failed
|
||
)));
|
||
} else {
|
||
self.set_error(None);
|
||
}
|
||
Ok(stats)
|
||
}
|
||
Err(err) => {
|
||
tracing::warn!("federation sync failed: {err}");
|
||
self.set_error(Some(format!("sync failed: {err}")));
|
||
Err(anyhow::anyhow!("sync failed: {err}"))
|
||
}
|
||
}
|
||
}
|
||
|
||
fn record_sync_success(&self, stats: SyncStats) {
|
||
*lock(&self.last_sync) = Some(format!(
|
||
"{} (+{} ~{} −{}, unchanged {}, failed {})",
|
||
now_label(),
|
||
stats.added,
|
||
stats.updated,
|
||
stats.removed,
|
||
stats.unchanged,
|
||
stats.failed
|
||
));
|
||
}
|
||
|
||
fn record_publish_success(&self, sync_stats: SyncStats, publish_stats: PublishStats) {
|
||
*lock(&self.last_sync) = Some(format!(
|
||
"{} (+{} ~{} −{}, unchanged {}, failed {}; republished {} records, {} keys, remote nodes {})",
|
||
now_label(),
|
||
sync_stats.added,
|
||
sync_stats.updated,
|
||
sync_stats.removed,
|
||
sync_stats.unchanged,
|
||
sync_stats.failed,
|
||
publish_stats.records,
|
||
publish_stats.keys,
|
||
publish_stats.remote_nodes,
|
||
));
|
||
self.set_error(None);
|
||
}
|
||
|
||
pub async fn status(&self) -> FedStatus {
|
||
let settings = self.settings();
|
||
let guard = self.running.lock().await;
|
||
let mut status = FedStatus {
|
||
network: settings.network_id,
|
||
last_sync: lock(&self.last_sync).clone(),
|
||
last_error: lock(&self.last_error).clone(),
|
||
protocols: ProtocolVersions::snapshot(&self.observed_protocols),
|
||
..FedStatus::default()
|
||
};
|
||
if let Some(running) = guard.as_ref() {
|
||
let service = &running.service;
|
||
status.running = true;
|
||
status.network = running.network_name.clone();
|
||
status.endpoint_id = service.endpoint_id().to_string();
|
||
status.dht_node_id = service.node_id().to_string();
|
||
status.connected_peers = service
|
||
.connected_peers()
|
||
.iter()
|
||
.map(|p| p.to_string())
|
||
.collect();
|
||
status.known_contacts = service.known_peers().len();
|
||
status.stored_dht_records = service.dht_record_count().await.ok();
|
||
status.stored_dht_bytes =
|
||
dht_record_payload_bytes(self.data_dir.clone(), unix_time_ms())
|
||
.await
|
||
.ok();
|
||
status.published_items = service
|
||
.list_local_items()
|
||
.await
|
||
.map(|items| items.len())
|
||
.unwrap_or(0);
|
||
}
|
||
status.transport = self.transport_stats.snapshot();
|
||
status
|
||
}
|
||
|
||
pub async fn network_library_sources(
|
||
&self,
|
||
mode: crate::config::settings::LibrarySourceMode,
|
||
) -> Result<Vec<NetworkLibrarySource>> {
|
||
if !mode.includes_network() {
|
||
return Ok(Vec::new());
|
||
}
|
||
let service = self.service().await?;
|
||
let own = service.endpoint_id().to_string();
|
||
let mut seen = std::collections::HashSet::new();
|
||
seen.insert(own);
|
||
let mut sources = Vec::new();
|
||
for endpoint_id in self.devices.active_remote_endpoint_ids()? {
|
||
if seen.insert(endpoint_id.clone()) {
|
||
sources.push(NetworkLibrarySource {
|
||
endpoint_id,
|
||
kind: "personal",
|
||
});
|
||
}
|
||
}
|
||
if mode.includes_global_peers() {
|
||
for endpoint in service
|
||
.connected_peers()
|
||
.into_iter()
|
||
.map(|peer| peer.to_string())
|
||
.chain(
|
||
service
|
||
.known_peers()
|
||
.into_iter()
|
||
.map(|peer| peer.peer_id.to_string()),
|
||
)
|
||
{
|
||
if seen.insert(endpoint.clone()) {
|
||
sources.push(NetworkLibrarySource {
|
||
endpoint_id: endpoint,
|
||
kind: "federation",
|
||
});
|
||
}
|
||
if sources.len() >= 32 {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
Ok(sources)
|
||
}
|
||
|
||
pub async fn cache_artist_slice_from_source(
|
||
&self,
|
||
source: NetworkLibrarySource,
|
||
cursor: Option<String>,
|
||
limit: usize,
|
||
) -> Result<(usize, Option<String>)> {
|
||
let service = self.service().await?;
|
||
let owner = EndpointId::from_str(&source.endpoint_id)
|
||
.map_err(|_| anyhow::anyhow!("malformed endpoint id '{}'", source.endpoint_id))?;
|
||
let replace_source = cursor.is_none();
|
||
let (artists, next_cursor) = catalog::fetch_artist_slice(
|
||
&service,
|
||
owner,
|
||
cursor.as_deref(),
|
||
limit,
|
||
&self.transport_stats,
|
||
)
|
||
.await?;
|
||
let artists: Vec<NetworkArtistPreview> = artists
|
||
.into_iter()
|
||
.map(|artist| NetworkArtistPreview {
|
||
artist_key: if artist.artist_key.trim().is_empty() {
|
||
music_dht::normalize_name(&artist.name)
|
||
} else {
|
||
artist.artist_key
|
||
},
|
||
name: artist.name,
|
||
image_path: artist.image_path,
|
||
release_count: artist.release_count,
|
||
track_count: artist.track_count,
|
||
})
|
||
.collect();
|
||
let count = self.library.replace_network_artist_cache(
|
||
&source.endpoint_id,
|
||
source.kind,
|
||
&artists,
|
||
replace_source,
|
||
)?;
|
||
Ok((count, next_cursor))
|
||
}
|
||
|
||
/// Searches the federated network: matching tracks plus the artists a
|
||
/// card can be assembled for (from artist records and from the artist
|
||
/// names of matching tracks/releases).
|
||
pub async fn search(&self, query: &str) -> Result<FedSearchResults> {
|
||
let service = self.service().await?;
|
||
let normalized = music_dht::normalize_name(query);
|
||
let outcome = service
|
||
.search_network(query)
|
||
.await
|
||
.map_err(|err| anyhow::anyhow!("federated search failed: {err}"))?;
|
||
let own = service.endpoint_id();
|
||
let mut tracks: Vec<FedTrack> = outcome
|
||
.network_results
|
||
.iter()
|
||
.filter(|item| item.kind == ItemKind::Track)
|
||
.map(|item| FedTrack {
|
||
item_id: audio::hex_encode(item.id.as_bytes()),
|
||
owner: item.owner.to_string(),
|
||
own: item.owner == own,
|
||
title: item.name.clone(),
|
||
artist_names: item.artist_names.clone(),
|
||
featured_artist_names: item.featured_artist_names.clone(),
|
||
year: item.year,
|
||
duration_seconds: item.duration_seconds.map(|d| d.round() as i64),
|
||
content_id: item.content_id.clone(),
|
||
release_title: item.release_title.clone(),
|
||
track_number: item.track_number,
|
||
disc_number: item.disc_number,
|
||
})
|
||
.collect();
|
||
let mut seen_tracks: std::collections::HashSet<(String, String)> = tracks
|
||
.iter()
|
||
.map(|track| (track.owner.clone(), track.item_id.clone()))
|
||
.collect();
|
||
|
||
// Artists: normalized name -> (display name, distinct non-own peers).
|
||
let mut artists: std::collections::HashMap<
|
||
String,
|
||
(String, std::collections::HashSet<String>),
|
||
> = Default::default();
|
||
for item in &outcome.network_results {
|
||
if item.owner == own {
|
||
continue;
|
||
}
|
||
let mut note = |name: &str| {
|
||
let key = music_dht::normalize_name(name);
|
||
if key.is_empty() {
|
||
return;
|
||
}
|
||
let entry = artists
|
||
.entry(key)
|
||
.or_insert_with(|| (name.to_string(), Default::default()));
|
||
entry.1.insert(item.owner.to_string());
|
||
};
|
||
if item.kind == ItemKind::Artist {
|
||
note(&item.name);
|
||
}
|
||
for artist in &item.artist_names {
|
||
note(artist);
|
||
}
|
||
for artist in &item.featured_artist_names {
|
||
note(artist);
|
||
}
|
||
}
|
||
for cached in self.cached_metadata_snapshot() {
|
||
if !cached_matches_query(&cached, &normalized) {
|
||
continue;
|
||
}
|
||
let fed = cached.to_fed_track();
|
||
if seen_tracks.insert((fed.owner.clone(), fed.item_id.clone())) {
|
||
tracks.push(fed);
|
||
}
|
||
if !cached.fed.own {
|
||
for artist in &cached.featured_artists {
|
||
let key = music_dht::normalize_name(artist);
|
||
if key.is_empty() {
|
||
continue;
|
||
}
|
||
let entry = artists
|
||
.entry(key)
|
||
.or_insert_with(|| (artist.clone(), Default::default()));
|
||
entry.1.insert(cached.fed.owner.clone());
|
||
}
|
||
}
|
||
}
|
||
let mut artists: Vec<FedArtistHit> = artists
|
||
.into_values()
|
||
.map(|(name, owners)| FedArtistHit {
|
||
name,
|
||
peers: owners.len(),
|
||
})
|
||
.collect();
|
||
artists.sort_by(|a, b| b.peers.cmp(&a.peers).then_with(|| a.name.cmp(&b.name)));
|
||
rank_fed_search_results(&mut artists, &mut tracks, &normalized);
|
||
|
||
Ok(FedSearchResults { artists, tracks })
|
||
}
|
||
|
||
/// Resolves a share-link content id to one playable federated track.
|
||
///
|
||
/// Resolution order: the in-session metadata cache, the DHT content key,
|
||
/// then an early name search by the link label while content lookup keeps
|
||
/// retrying. The DHT is eventually consistent, so a single lookup can
|
||
/// transiently come up short. Records under the name keys carry content
|
||
/// ids too, and failing an exact match, a track whose artists and title
|
||
/// all match the label is the same song from another owner.
|
||
pub async fn track_by_content_id(
|
||
&self,
|
||
content_id: &str,
|
||
label: Option<&str>,
|
||
) -> Result<FedTrack> {
|
||
let content_id =
|
||
music_dht::normalize_content_id(content_id).context("invalid content id")?;
|
||
let service = self.service().await?;
|
||
let own = service.endpoint_id();
|
||
|
||
for cached in self.cached_metadata_snapshot() {
|
||
if cached.fed.content_id.as_deref() == Some(content_id.as_str()) {
|
||
return Ok(cached.to_fed_track());
|
||
}
|
||
}
|
||
|
||
let mut queried_nodes = 0usize;
|
||
let mut tried_label_fallback = false;
|
||
for attempt in 0..CONTENT_LOOKUP_ATTEMPTS {
|
||
if attempt > 0 {
|
||
tokio::time::sleep(CONTENT_LOOKUP_RETRY_DELAY).await;
|
||
}
|
||
let outcome = service
|
||
.search_content_id(&content_id)
|
||
.await
|
||
.map_err(|err| anyhow::anyhow!("federated content lookup failed: {err}"))?;
|
||
queried_nodes = queried_nodes.max(outcome.queried_nodes);
|
||
if let Some(item) = outcome
|
||
.local_results
|
||
.into_iter()
|
||
.chain(outcome.network_results)
|
||
.find(|item| item.kind == ItemKind::Track)
|
||
{
|
||
return Ok(fed_track_from_item(item, own));
|
||
}
|
||
if !tried_label_fallback {
|
||
tried_label_fallback = true;
|
||
if let Some(item) = self
|
||
.track_by_content_label(&service, own, &content_id, label, &mut queried_nodes)
|
||
.await?
|
||
{
|
||
return Ok(item);
|
||
}
|
||
}
|
||
}
|
||
|
||
if queried_nodes == 0 {
|
||
anyhow::bail!("no federation peers reachable yet — check the Federation tab and retry");
|
||
}
|
||
anyhow::bail!("no peers currently publish this shared track")
|
||
}
|
||
|
||
async fn track_by_content_label(
|
||
&self,
|
||
service: &MusicDhtService,
|
||
own: EndpointId,
|
||
content_id: &str,
|
||
label: Option<&str>,
|
||
queried_nodes: &mut usize,
|
||
) -> Result<Option<FedTrack>> {
|
||
let Some(label) = label else {
|
||
return Ok(None);
|
||
};
|
||
let normalized = music_dht::normalize_name(label);
|
||
if normalized.is_empty() {
|
||
return Ok(None);
|
||
}
|
||
let outcome = service
|
||
.search_network(label)
|
||
.await
|
||
.map_err(|err| anyhow::anyhow!("federated search failed: {err}"))?;
|
||
*queried_nodes = (*queried_nodes).max(outcome.queried_nodes);
|
||
let candidates: Vec<music_dht::LibraryItem> = outcome
|
||
.local_results
|
||
.into_iter()
|
||
.chain(outcome.network_results)
|
||
.filter(|item| item.kind == ItemKind::Track)
|
||
.collect();
|
||
if let Some(item) = candidates
|
||
.iter()
|
||
.find(|item| item.content_id.as_deref() == Some(content_id))
|
||
{
|
||
return Ok(Some(fed_track_from_item(item.clone(), own)));
|
||
}
|
||
// "feat" is an artifact of the label format ("A feat. B-Title"),
|
||
// not a token of any track record.
|
||
let tokens: Vec<String> = music_dht::tokenize(&normalized)
|
||
.into_iter()
|
||
.filter(|token| token != "feat")
|
||
.collect();
|
||
if !tokens.is_empty()
|
||
&& let Some(item) = candidates.into_iter().find(|item| {
|
||
let item_tokens = item.search_tokens();
|
||
tokens.iter().all(|token| item_tokens.contains(token))
|
||
})
|
||
{
|
||
return Ok(Some(fed_track_from_item(item, own)));
|
||
}
|
||
Ok(None)
|
||
}
|
||
|
||
async fn source_by_content_id_for_playback(
|
||
&self,
|
||
fed: &FedTrack,
|
||
reason: String,
|
||
) -> Result<FedTrack> {
|
||
let content_id = fed
|
||
.content_id
|
||
.as_deref()
|
||
.and_then(music_dht::normalize_content_id)
|
||
.with_context(|| format!("{reason}; no content id fallback is available"))?;
|
||
let label = format!("{} {}", fed.artist_names.join(" "), fed.title);
|
||
let resolved = self.track_by_content_id(&content_id, Some(&label)).await?;
|
||
if resolved.owner == fed.owner && resolved.item_id == fed.item_id {
|
||
anyhow::bail!("{reason}");
|
||
}
|
||
Ok(resolved)
|
||
}
|
||
|
||
async fn local_track_by_content_id_for_playback(
|
||
self: &Arc<Self>,
|
||
content_id: &str,
|
||
) -> Result<Option<TrackItem>> {
|
||
let Some(content_id) = music_dht::normalize_content_id(content_id) else {
|
||
return Ok(None);
|
||
};
|
||
let library = Arc::clone(&self.library);
|
||
tokio::task::spawn_blocking(move || -> Result<Option<TrackItem>> {
|
||
Ok(library
|
||
.track_by_content_id(&content_id)?
|
||
.filter(|track| Path::new(&track.file_path).is_file()))
|
||
})
|
||
.await?
|
||
}
|
||
|
||
/// Assembles the federated artist card: finds the peers holding the
|
||
/// artist through the DHT, asks each for its catalog slice directly and
|
||
/// merges the answers (missing/slow peers are skipped). Role-aware DHT
|
||
/// track records are also folded in, so featured appearances can be shown
|
||
/// even before a peer's catalog response arrives.
|
||
pub async fn artist_card(&self, name: &str) -> Result<FedArtistCard> {
|
||
let service = self.service().await?;
|
||
let own = service.endpoint_id();
|
||
let own_hex = own.to_string();
|
||
let normalized = music_dht::normalize_name(name);
|
||
let outcome = service
|
||
.search_network(name)
|
||
.await
|
||
.map_err(|err| anyhow::anyhow!("federated search failed: {err}"))?;
|
||
let cached_metadata = self.cached_metadata_snapshot();
|
||
let has_cached_artist = cached_metadata
|
||
.iter()
|
||
.any(|cached| cached_has_artist(cached, &normalized));
|
||
let owners: std::collections::HashSet<EndpointId> = outcome
|
||
.network_results
|
||
.iter()
|
||
.filter(|item| {
|
||
(item.kind == ItemKind::Artist && item.normalized_name == normalized)
|
||
|| item
|
||
.artist_names
|
||
.iter()
|
||
.any(|artist| music_dht::normalize_name(artist) == normalized)
|
||
|| item
|
||
.featured_artist_names
|
||
.iter()
|
||
.any(|artist| music_dht::normalize_name(artist) == normalized)
|
||
})
|
||
.map(|item| item.owner)
|
||
.filter(|owner| *owner != own)
|
||
.collect();
|
||
|
||
let local_library = Arc::clone(&self.library);
|
||
let local_name = name.to_string();
|
||
let mut catalogs = match tokio::task::spawn_blocking(move || {
|
||
catalog::build_catalog_artist(&local_library, own, &local_name)
|
||
})
|
||
.await
|
||
{
|
||
Ok(Ok(Some(catalog))) => vec![(own_hex.clone(), catalog)],
|
||
Ok(Ok(None)) => Vec::new(),
|
||
Ok(Err(err)) => {
|
||
tracing::warn!("local catalog lookup failed: {err:#}");
|
||
Vec::new()
|
||
}
|
||
Err(err) => {
|
||
tracing::warn!("local catalog task failed: {err}");
|
||
Vec::new()
|
||
}
|
||
};
|
||
anyhow::ensure!(
|
||
!owners.is_empty() || !catalogs.is_empty() || has_cached_artist,
|
||
"no peers hold artist \"{name}\""
|
||
);
|
||
|
||
let mut requests = Vec::new();
|
||
for owner in owners {
|
||
let service = Arc::clone(&service);
|
||
let transport_stats = Arc::clone(&self.transport_stats);
|
||
let name = name.to_string();
|
||
requests.push(tokio::spawn(async move {
|
||
let result = tokio::time::timeout(
|
||
Duration::from_secs(5),
|
||
catalog::fetch_catalog(&service, owner, &name, &transport_stats),
|
||
)
|
||
.await;
|
||
match result {
|
||
Ok(Ok(catalog)) => Some((owner.to_string(), catalog)),
|
||
Ok(Err(err)) => {
|
||
tracing::warn!(peer = %owner, "catalog fetch failed: {err:#}");
|
||
None
|
||
}
|
||
Err(_) => {
|
||
tracing::warn!(peer = %owner, "catalog fetch timed out");
|
||
None
|
||
}
|
||
}
|
||
}));
|
||
}
|
||
for request in requests {
|
||
if let Ok(Some(catalog)) = request.await {
|
||
catalogs.push(catalog);
|
||
}
|
||
}
|
||
let mut card = catalog::merge_catalogs(name, catalogs);
|
||
add_dht_appearance_hits(&mut card, &outcome.network_results, &normalized, name);
|
||
add_cached_appearance_hits(&mut card, &cached_metadata, &normalized, name);
|
||
anyhow::ensure!(
|
||
!card.releases.is_empty() || !card.appears_on.is_empty(),
|
||
"none of the peers returned releases or appearances"
|
||
);
|
||
card.own_owner = Some(own_hex);
|
||
Ok(card)
|
||
}
|
||
|
||
pub async fn ticket(&self) -> Result<String> {
|
||
let service = self.service().await?;
|
||
let ticket = service
|
||
.ticket()
|
||
.await
|
||
.map_err(|err| anyhow::anyhow!("cannot create a ticket: {err}"))?;
|
||
Ok(ticket.to_string())
|
||
}
|
||
|
||
pub async fn device_invite(self: &Arc<Self>) -> Result<String> {
|
||
self.ensure_connected_devices_enabled()?;
|
||
let service = self.service().await?;
|
||
self.devices.create_invite(service).await
|
||
}
|
||
|
||
pub async fn device_connect(self: &Arc<Self>, invite: &str) -> Result<String> {
|
||
self.ensure_connected_devices_enabled()?;
|
||
let network_id = crate::devices::invite_network_id(invite)?;
|
||
let (service, running_network_id) = self.running_service_and_network().await?;
|
||
anyhow::ensure!(
|
||
running_network_id == network_id,
|
||
"device invite belongs to a different federation network"
|
||
);
|
||
self.devices
|
||
.connect_invite(service, invite, Arc::clone(&self.transport_stats))
|
||
.await
|
||
}
|
||
|
||
pub async fn device_sync_now(self: &Arc<Self>) -> Result<()> {
|
||
self.ensure_connected_devices_enabled()?;
|
||
let service = self.service().await?;
|
||
self.devices
|
||
.sync_once(service, Arc::clone(&self.transport_stats))
|
||
.await
|
||
}
|
||
|
||
pub async fn connect(&self, ticket: &str) -> Result<String> {
|
||
let service = self.service().await?;
|
||
let ticket: PeerTicket = ticket
|
||
.trim()
|
||
.parse()
|
||
.map_err(|err| anyhow::anyhow!("malformed ticket: {err}"))?;
|
||
let peer = service
|
||
.connect(ticket)
|
||
.await
|
||
.map_err(|err| anyhow::anyhow!("connect failed: {err}"))?;
|
||
Ok(peer.to_string())
|
||
}
|
||
|
||
/// Directory for streamed (never library-imported) card artwork.
|
||
fn art_cache_dir(&self) -> PathBuf {
|
||
self.cache_dir.join("art")
|
||
}
|
||
|
||
/// Returns a cached-or-streamed image for the card: the artist image
|
||
/// (`release: None`) or a release cover. Peers are tried in order until
|
||
/// one answers with an image; the result lands in the art cache and its
|
||
/// local path is returned.
|
||
pub async fn card_image(
|
||
&self,
|
||
owners: &[String],
|
||
artist: &str,
|
||
release: Option<&str>,
|
||
) -> Option<String> {
|
||
let dir = self.art_cache_dir();
|
||
let stem = match release {
|
||
Some(release) => format!(
|
||
"cover-{}-{}",
|
||
sanitize_file_stem(artist),
|
||
sanitize_file_stem(release)
|
||
),
|
||
None => format!("artist-{}", sanitize_file_stem(artist)),
|
||
};
|
||
// Reuse a previously streamed copy of any known image type.
|
||
for extension in ["jpg", "png", "webp", "gif", "bmp"] {
|
||
let path = dir.join(format!("{stem}.{extension}"));
|
||
if path.is_file() {
|
||
return Some(path.to_string_lossy().into_owned());
|
||
}
|
||
}
|
||
let service = self.service().await.ok()?;
|
||
tokio::fs::create_dir_all(&dir).await.ok()?;
|
||
for owner in owners {
|
||
let Ok(owner) = EndpointId::from_str(owner) else {
|
||
continue;
|
||
};
|
||
let fetched = tokio::time::timeout(
|
||
Duration::from_secs(5),
|
||
catalog::fetch_image(&service, owner, artist, release, &self.transport_stats),
|
||
)
|
||
.await;
|
||
match fetched {
|
||
Ok(Ok(Some((bytes, extension)))) => {
|
||
let path = dir.join(format!("{stem}.{extension}"));
|
||
if tokio::fs::write(&path, &bytes).await.is_ok() {
|
||
return Some(path.to_string_lossy().into_owned());
|
||
}
|
||
}
|
||
Ok(Ok(None)) => continue,
|
||
Ok(Err(err)) => tracing::debug!(peer = %owner, "image fetch failed: {err:#}"),
|
||
Err(_) => tracing::debug!(peer = %owner, "image fetch timed out"),
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Downloads a federated track straight into the local library
|
||
/// (regardless of the save-on-listen setting) and returns the imported
|
||
/// track. Own/already-local tracks resolve without downloading.
|
||
pub async fn download_to_library_with_progress<F>(
|
||
self: &Arc<Self>,
|
||
fed: &FedTrack,
|
||
progress: F,
|
||
) -> Result<TrackItem>
|
||
where
|
||
F: FnMut(DownloadProgress) + Send,
|
||
{
|
||
let playable = self
|
||
.fetch_playable_with_progress(fed, true, true, progress, None)
|
||
.await?;
|
||
Ok(playable.track)
|
||
}
|
||
|
||
/// Prepares a federated track for playback: local tracks resolve
|
||
/// straight to the library; remote tracks are downloaded — into the
|
||
/// library when save-on-listen is enabled, into the cache otherwise.
|
||
pub async fn prepare_playback_with_progress<F>(
|
||
self: &Arc<Self>,
|
||
fed: &FedTrack,
|
||
progress: F,
|
||
) -> Result<FedPlayable>
|
||
where
|
||
F: FnMut(DownloadProgress) + Send,
|
||
{
|
||
let save = self.settings().save_on_listen;
|
||
self.fetch_playable_with_progress(fed, save, false, progress, None)
|
||
.await
|
||
}
|
||
|
||
pub async fn prepare_playback_streaming_with_progress<F, S>(
|
||
self: &Arc<Self>,
|
||
fed: &FedTrack,
|
||
progress: F,
|
||
mut stream_start: S,
|
||
) -> Result<FedPlayable>
|
||
where
|
||
F: FnMut(DownloadProgress) + Send,
|
||
S: FnMut(StreamingStart) + Send,
|
||
{
|
||
let save = self.settings().save_on_listen;
|
||
self.fetch_playable_with_progress(fed, save, false, progress, Some(&mut stream_start))
|
||
.await
|
||
}
|
||
|
||
/// Fetches rich metadata for a federated track without downloading the
|
||
/// audio bytes, for the track-info popup.
|
||
pub async fn track_info(&self, track: TrackItem) -> Result<TrackItem> {
|
||
let Some(fed) = track.fed.clone() else {
|
||
return Ok(track);
|
||
};
|
||
let service = self.service().await?;
|
||
let item_id =
|
||
audio::hex_decode_item_id(&fed.item_id).context("malformed item id in the result")?;
|
||
|
||
if fed.own {
|
||
let library = Arc::clone(&self.library);
|
||
let own_id = service.endpoint_id();
|
||
return tokio::task::spawn_blocking(move || -> Result<TrackItem> {
|
||
let Some(track_id) = audio::resolve_local_track_id(&library, own_id, item_id)?
|
||
else {
|
||
anyhow::bail!("this track is no longer in the local library");
|
||
};
|
||
library
|
||
.tracks_by_ids(&[track_id])?
|
||
.into_iter()
|
||
.next()
|
||
.context("this track is no longer in the local library")
|
||
})
|
||
.await?;
|
||
}
|
||
|
||
let owner = EndpointId::from_str(&fed.owner)
|
||
.map_err(|_| anyhow::anyhow!("malformed owner id '{}'", fed.owner))?;
|
||
let fetched = self
|
||
.fetch_metadata_with_fallback(&service, owner, &fed)
|
||
.await?;
|
||
let enriched = metadata_preview_track(&track, &fed, &fetched);
|
||
if let Some(cached) = cached_track_metadata(&enriched, &fed, &fetched) {
|
||
lock(&self.metadata_cache).insert(cached_cache_key(&fed), cached);
|
||
}
|
||
Ok(enriched)
|
||
}
|
||
|
||
async fn fetch_playable_with_progress<F>(
|
||
self: &Arc<Self>,
|
||
fed: &FedTrack,
|
||
save: bool,
|
||
want_images: bool,
|
||
mut progress: F,
|
||
mut stream_start: Option<&mut (dyn FnMut(StreamingStart) + Send)>,
|
||
) -> Result<FedPlayable>
|
||
where
|
||
F: FnMut(DownloadProgress) + Send,
|
||
{
|
||
let mut fed = fed.clone();
|
||
let mut tried_content_lookup = false;
|
||
|
||
loop {
|
||
if let Some(content_id) = fed
|
||
.content_id
|
||
.as_deref()
|
||
.and_then(music_dht::normalize_content_id)
|
||
&& let Some(track) = self
|
||
.local_track_by_content_id_for_playback(&content_id)
|
||
.await?
|
||
{
|
||
return Ok(FedPlayable {
|
||
track,
|
||
imported: false,
|
||
});
|
||
}
|
||
|
||
let service = self.service().await?;
|
||
let item_id = match audio::hex_decode_item_id(&fed.item_id) {
|
||
Some(item_id) => item_id,
|
||
None if !tried_content_lookup => {
|
||
fed = self
|
||
.source_by_content_id_for_playback(
|
||
&fed,
|
||
"malformed item id in the result".to_string(),
|
||
)
|
||
.await?;
|
||
tried_content_lookup = true;
|
||
continue;
|
||
}
|
||
None => anyhow::bail!("malformed item id in the result"),
|
||
};
|
||
|
||
if fed.own {
|
||
let library = Arc::clone(&self.library);
|
||
let own_id = service.endpoint_id();
|
||
let track = tokio::task::spawn_blocking(move || -> Result<Option<TrackItem>> {
|
||
let Some(track_id) = audio::resolve_local_track_id(&library, own_id, item_id)?
|
||
else {
|
||
return Ok(None);
|
||
};
|
||
Ok(library.tracks_by_ids(&[track_id])?.into_iter().next())
|
||
})
|
||
.await??;
|
||
if let Some(track) = track.filter(|track| Path::new(&track.file_path).is_file()) {
|
||
return Ok(FedPlayable {
|
||
track,
|
||
imported: false,
|
||
});
|
||
}
|
||
if !tried_content_lookup {
|
||
fed = self
|
||
.source_by_content_id_for_playback(
|
||
&fed,
|
||
"this track is no longer in the local library".to_string(),
|
||
)
|
||
.await?;
|
||
tried_content_lookup = true;
|
||
continue;
|
||
}
|
||
anyhow::bail!("this track is no longer in the local library");
|
||
}
|
||
|
||
let owner = match EndpointId::from_str(&fed.owner) {
|
||
Ok(owner) => owner,
|
||
Err(_) if !tried_content_lookup => {
|
||
fed = self
|
||
.source_by_content_id_for_playback(
|
||
&fed,
|
||
format!("malformed owner id '{}'", fed.owner),
|
||
)
|
||
.await?;
|
||
tried_content_lookup = true;
|
||
continue;
|
||
}
|
||
Err(_) => anyhow::bail!("malformed owner id '{}'", fed.owner),
|
||
};
|
||
let dir = if save {
|
||
&self.media_dir
|
||
} else {
|
||
&self.cache_dir
|
||
};
|
||
tokio::fs::create_dir_all(dir).await?;
|
||
|
||
let downloaded = match self
|
||
.download_track_with_fallback(
|
||
&service,
|
||
owner,
|
||
&fed,
|
||
dir,
|
||
want_images,
|
||
&mut progress,
|
||
stream_start
|
||
.as_mut()
|
||
.map(|callback| &mut **callback as &mut (dyn FnMut(StreamingStart) + Send)),
|
||
)
|
||
.await
|
||
{
|
||
Ok(downloaded) => downloaded,
|
||
Err(err) if !tried_content_lookup && fed.content_id.is_some() => {
|
||
tracing::warn!(
|
||
owner = %fed.owner,
|
||
item_id = %fed.item_id,
|
||
"federated source failed; resolving by content id: {err:#}"
|
||
);
|
||
fed = self
|
||
.source_by_content_id_for_playback(
|
||
&fed,
|
||
"federated source failed".to_string(),
|
||
)
|
||
.await?;
|
||
tried_content_lookup = true;
|
||
continue;
|
||
}
|
||
Err(err) => return Err(err),
|
||
};
|
||
tracing::info!(
|
||
path = %downloaded.path.display(),
|
||
mime = %downloaded.mime_type,
|
||
cover = downloaded.cover.is_some(),
|
||
"federated track downloaded"
|
||
);
|
||
|
||
if save {
|
||
let library = Arc::clone(&self.library);
|
||
let import_path = downloaded.path.clone();
|
||
let import_metadata = downloaded.metadata.clone();
|
||
let import_cover = downloaded.cover.clone();
|
||
let artist_image = downloaded.artist_image.clone();
|
||
let fed_item_id = fed.item_id.clone();
|
||
let imported = tokio::task::spawn_blocking(move || -> Result<Option<TrackItem>> {
|
||
let mut import = crate::library::import::read_file(&import_path)?;
|
||
// The owner's database is more authoritative than whatever
|
||
// tags the file happens to carry (often none at all).
|
||
if let Some(meta) = &import_metadata {
|
||
apply_remote_metadata(&mut import, meta);
|
||
}
|
||
// Same for the cover: the peer's library cover wins over an
|
||
// embedded picture; embedded art stays as the fallback.
|
||
if import_cover.is_some() {
|
||
import.cover = import_cover;
|
||
}
|
||
let (track_id, _) = crate::library::import::upsert_track(&library, &import)?;
|
||
// A like that referenced the federated track moves onto the
|
||
// freshly imported local row.
|
||
if let Err(err) = library.transfer_fed_like(&fed_item_id, track_id) {
|
||
tracing::warn!(%err, "federated like transfer failed");
|
||
}
|
||
// The owner's artist image fills the gap for a freshly
|
||
// created (or still image-less) main artist.
|
||
if let (Some((bytes, extension)), Some(artist_name)) =
|
||
(&artist_image, import.artists.first())
|
||
&& let Err(err) = save_artist_image(&library, artist_name, bytes, extension)
|
||
{
|
||
tracing::warn!(%err, "saving the artist image failed");
|
||
}
|
||
Ok(library.tracks_by_ids(&[track_id])?.into_iter().next())
|
||
})
|
||
.await?;
|
||
match imported {
|
||
Ok(Some(track)) => {
|
||
return Ok(FedPlayable {
|
||
track,
|
||
imported: true,
|
||
});
|
||
}
|
||
Ok(None) => {}
|
||
Err(err) => {
|
||
tracing::warn!(
|
||
"importing the downloaded track failed: {err:#}; playing from the file"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Ephemeral playback: put the cover next to the cached audio so
|
||
// the views can show it.
|
||
let cover_path = match &downloaded.cover {
|
||
Some((bytes, extension)) => {
|
||
let path = downloaded.path.with_extension(format!("cover.{extension}"));
|
||
match tokio::fs::write(&path, bytes).await {
|
||
Ok(()) => Some(path.to_string_lossy().into_owned()),
|
||
Err(err) => {
|
||
tracing::warn!(%err, "saving the cover failed");
|
||
None
|
||
}
|
||
}
|
||
}
|
||
None => None,
|
||
};
|
||
let mut track = ephemeral_track(&fed, downloaded.metadata.as_ref(), &downloaded.path);
|
||
track.cover_path = cover_path;
|
||
return Ok(FedPlayable {
|
||
track,
|
||
imported: false,
|
||
});
|
||
}
|
||
}
|
||
|
||
async fn download_track_with_fallback<F>(
|
||
&self,
|
||
service: &MusicDhtService,
|
||
owner: EndpointId,
|
||
fed: &FedTrack,
|
||
dir: &Path,
|
||
want_images: bool,
|
||
progress: &mut F,
|
||
mut stream_start: Option<&mut (dyn FnMut(StreamingStart) + Send)>,
|
||
) -> Result<audio::Downloaded>
|
||
where
|
||
F: FnMut(DownloadProgress) + Send,
|
||
{
|
||
let stem = download_stem(fed);
|
||
let primary = {
|
||
let primary_stream_start = stream_start
|
||
.as_mut()
|
||
.map(|callback| &mut **callback as &mut (dyn FnMut(StreamingStart) + Send));
|
||
audio::download_track_with_streaming(
|
||
service,
|
||
owner,
|
||
&fed.item_id,
|
||
dir,
|
||
&stem,
|
||
want_images,
|
||
|event| {
|
||
if let Some(stats) = event.transport {
|
||
self.transport_stats.record(
|
||
event.stream_key,
|
||
"audio",
|
||
"outbound",
|
||
event.transport_phase,
|
||
stats,
|
||
);
|
||
}
|
||
progress(event);
|
||
},
|
||
primary_stream_start,
|
||
)
|
||
.await
|
||
};
|
||
match primary {
|
||
Ok(downloaded) => return Ok(downloaded),
|
||
Err(primary_err) => {
|
||
let Some(content_id) = fed.content_id.as_deref() else {
|
||
return Err(primary_err);
|
||
};
|
||
tracing::warn!(
|
||
owner = %fed.owner,
|
||
item_id = %fed.item_id,
|
||
content_id,
|
||
"primary federated source failed; searching content-id fallbacks: {primary_err:#}"
|
||
);
|
||
let outcome = match service.search_content_id(content_id).await {
|
||
Ok(outcome) => outcome,
|
||
Err(err) => {
|
||
tracing::warn!(content_id, "content-id fallback lookup failed: {err}");
|
||
return Err(primary_err);
|
||
}
|
||
};
|
||
for item in outcome.network_results {
|
||
if item.kind != ItemKind::Track {
|
||
continue;
|
||
}
|
||
let candidate_item_id = audio::hex_encode(item.id.as_bytes());
|
||
if item.owner == owner && candidate_item_id == fed.item_id {
|
||
continue;
|
||
}
|
||
let candidate_owner = item.owner;
|
||
let fallback = {
|
||
let fallback_stream_start = stream_start.as_mut().map(|callback| {
|
||
&mut **callback as &mut (dyn FnMut(StreamingStart) + Send)
|
||
});
|
||
audio::download_track_with_streaming(
|
||
service,
|
||
candidate_owner,
|
||
&candidate_item_id,
|
||
dir,
|
||
&stem,
|
||
want_images,
|
||
|event| {
|
||
if let Some(stats) = event.transport {
|
||
self.transport_stats.record(
|
||
event.stream_key,
|
||
"audio",
|
||
"outbound",
|
||
event.transport_phase,
|
||
stats,
|
||
);
|
||
}
|
||
progress(event);
|
||
},
|
||
fallback_stream_start,
|
||
)
|
||
.await
|
||
};
|
||
match fallback {
|
||
Ok(downloaded) => {
|
||
tracing::info!(
|
||
owner = %candidate_owner,
|
||
item_id = %candidate_item_id,
|
||
content_id,
|
||
"federated track downloaded from content-id fallback"
|
||
);
|
||
return Ok(downloaded);
|
||
}
|
||
Err(err) => {
|
||
tracing::debug!(
|
||
owner = %candidate_owner,
|
||
item_id = %candidate_item_id,
|
||
content_id,
|
||
"content-id fallback source failed: {err:#}"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
Err(primary_err)
|
||
}
|
||
}
|
||
}
|
||
|
||
async fn fetch_metadata_with_fallback(
|
||
&self,
|
||
service: &MusicDhtService,
|
||
owner: EndpointId,
|
||
fed: &FedTrack,
|
||
) -> Result<audio::FetchedMetadata> {
|
||
match audio::fetch_metadata(service, owner, &fed.item_id).await {
|
||
Ok(metadata) => return Ok(metadata),
|
||
Err(primary_err) => {
|
||
let Some(content_id) = fed.content_id.as_deref() else {
|
||
return Err(primary_err);
|
||
};
|
||
tracing::warn!(
|
||
owner = %fed.owner,
|
||
item_id = %fed.item_id,
|
||
content_id,
|
||
"primary metadata source failed; searching content-id fallbacks: {primary_err:#}"
|
||
);
|
||
let outcome = match service.search_content_id(content_id).await {
|
||
Ok(outcome) => outcome,
|
||
Err(err) => {
|
||
tracing::warn!(content_id, "metadata fallback lookup failed: {err}");
|
||
return Err(primary_err);
|
||
}
|
||
};
|
||
for item in outcome.network_results {
|
||
if item.kind != ItemKind::Track {
|
||
continue;
|
||
}
|
||
let candidate_item_id = audio::hex_encode(item.id.as_bytes());
|
||
if item.owner == owner && candidate_item_id == fed.item_id {
|
||
continue;
|
||
}
|
||
match audio::fetch_metadata(service, item.owner, &candidate_item_id).await {
|
||
Ok(metadata) => {
|
||
tracing::info!(
|
||
owner = %item.owner,
|
||
item_id = %candidate_item_id,
|
||
content_id,
|
||
"federated metadata fetched from content-id fallback"
|
||
);
|
||
return Ok(metadata);
|
||
}
|
||
Err(err) => {
|
||
tracing::debug!(
|
||
owner = %item.owner,
|
||
item_id = %candidate_item_id,
|
||
content_id,
|
||
"metadata fallback source failed: {err:#}"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
Err(primary_err)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Writes a received artist image into the covers directory and attaches it
|
||
/// to the artist unless one is already set.
|
||
fn save_artist_image(
|
||
library: &Library,
|
||
artist_name: &str,
|
||
bytes: &[u8],
|
||
extension: &str,
|
||
) -> Result<()> {
|
||
let covers_dir = library.covers_dir();
|
||
std::fs::create_dir_all(covers_dir)?;
|
||
let path = covers_dir.join(format!(
|
||
"artist-{}.{extension}",
|
||
sanitize_file_stem(artist_name)
|
||
));
|
||
// Write only if the artist actually lacks an image, to avoid litter.
|
||
if library.artist_image_missing(artist_name)? {
|
||
std::fs::write(&path, bytes)?;
|
||
library.set_artist_image_if_missing(artist_name, &path.to_string_lossy())?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Overlays the peer-supplied metadata onto tag-derived import data. Every
|
||
/// non-empty peer field wins; file tags only fill the gaps.
|
||
fn apply_remote_metadata(import: &mut crate::library::import::TrackImport, meta: &TrackMetadata) {
|
||
let title = meta.title.trim();
|
||
if !title.is_empty() {
|
||
import.title = title.to_string();
|
||
}
|
||
if !meta.artists.is_empty() {
|
||
import.artists = meta.artists.clone();
|
||
}
|
||
if !meta.featured_artists.is_empty() {
|
||
import.featured_artists = meta.featured_artists.clone();
|
||
}
|
||
if !meta.album_artists.is_empty() {
|
||
import.album_artists = meta.album_artists.clone();
|
||
} else if !meta.artists.is_empty() {
|
||
import.album_artists = meta.artists.clone();
|
||
}
|
||
let release_title = meta.release_title.trim();
|
||
if !release_title.is_empty() {
|
||
import.release_title = release_title.to_string();
|
||
}
|
||
if meta.release_type.is_some() {
|
||
import.release_type = meta.release_type.clone();
|
||
}
|
||
if meta.year.is_some() {
|
||
import.year = meta.year;
|
||
}
|
||
if meta.track_number.is_some() {
|
||
import.track_number = meta.track_number;
|
||
}
|
||
if meta.disc_number.is_some() {
|
||
import.disc_number = meta.disc_number;
|
||
}
|
||
if let Some(duration) = meta.duration_seconds {
|
||
import.duration_seconds = duration;
|
||
}
|
||
if meta.audio_format.is_some() {
|
||
import.audio_format = meta.audio_format.clone();
|
||
}
|
||
if meta.audio_bitrate.is_some() {
|
||
import.audio_bitrate = meta.audio_bitrate;
|
||
}
|
||
if meta.audio_sample_rate.is_some() {
|
||
import.audio_sample_rate = meta.audio_sample_rate;
|
||
}
|
||
if meta.audio_bit_depth.is_some() {
|
||
import.audio_bit_depth = meta.audio_bit_depth;
|
||
}
|
||
}
|
||
|
||
fn metadata_preview_track(
|
||
base: &TrackItem,
|
||
fed: &FedTrack,
|
||
fetched: &audio::FetchedMetadata,
|
||
) -> TrackItem {
|
||
let refs = |names: &[String]| -> Vec<ArtistRef> {
|
||
names
|
||
.iter()
|
||
.map(|name| ArtistRef {
|
||
id: -1,
|
||
name: name.clone(),
|
||
})
|
||
.collect()
|
||
};
|
||
let metadata = fetched.metadata.as_ref();
|
||
let title = metadata
|
||
.map(|meta| meta.title.trim())
|
||
.filter(|title| !title.is_empty())
|
||
.unwrap_or(&base.title)
|
||
.to_string();
|
||
let artists = match metadata {
|
||
Some(meta) if !meta.artists.is_empty() => refs(&meta.artists),
|
||
_ => base.artists.clone(),
|
||
};
|
||
let featured_artists = match metadata {
|
||
Some(meta) if !meta.featured_artists.is_empty() => refs(&meta.featured_artists),
|
||
_ => base.featured_artists.clone(),
|
||
};
|
||
let release_title = metadata
|
||
.map(|meta| meta.release_title.trim())
|
||
.filter(|title| !title.is_empty())
|
||
.map(str::to_string)
|
||
.unwrap_or_else(|| base.release_title.clone());
|
||
TrackItem {
|
||
id: base.id,
|
||
title,
|
||
track_number: metadata
|
||
.and_then(|meta| meta.track_number)
|
||
.or(base.track_number),
|
||
disc_number: metadata
|
||
.and_then(|meta| meta.disc_number)
|
||
.or(base.disc_number),
|
||
duration_seconds: metadata
|
||
.and_then(|meta| meta.duration_seconds)
|
||
.or_else(|| fed.duration_seconds.map(|duration| duration as f64))
|
||
.unwrap_or(base.duration_seconds),
|
||
artists,
|
||
featured_artists,
|
||
release_id: base.release_id,
|
||
release_title,
|
||
release_year: metadata
|
||
.and_then(|meta| meta.year)
|
||
.or(base.release_year)
|
||
.or(fed.year),
|
||
file_path: String::new(),
|
||
content_id: fed.content_id.clone().or_else(|| base.content_id.clone()),
|
||
cover_path: base.cover_path.clone(),
|
||
audio_format: metadata
|
||
.and_then(|meta| meta.audio_format.clone())
|
||
.or_else(|| audio::format_for_mime(&fetched.mime_type))
|
||
.or_else(|| base.audio_format.clone()),
|
||
audio_bitrate: metadata
|
||
.and_then(|meta| meta.audio_bitrate)
|
||
.or(base.audio_bitrate),
|
||
audio_sample_rate: metadata
|
||
.and_then(|meta| meta.audio_sample_rate)
|
||
.or(base.audio_sample_rate),
|
||
audio_bit_depth: metadata
|
||
.and_then(|meta| meta.audio_bit_depth)
|
||
.or(base.audio_bit_depth),
|
||
file_size_bytes: (fetched.total_size > 0)
|
||
.then_some(fetched.total_size as i64)
|
||
.or(base.file_size_bytes),
|
||
play_count: base.play_count,
|
||
fed: Some(fed.clone()),
|
||
}
|
||
}
|
||
|
||
fn cached_track_metadata(
|
||
track: &TrackItem,
|
||
fed: &FedTrack,
|
||
fetched: &audio::FetchedMetadata,
|
||
) -> Option<CachedTrackMetadata> {
|
||
let artist_names = |items: &[ArtistRef]| {
|
||
items
|
||
.iter()
|
||
.map(|artist| artist.name.clone())
|
||
.collect::<Vec<_>>()
|
||
};
|
||
Some(CachedTrackMetadata {
|
||
fed: fed.clone(),
|
||
title: track.title.clone(),
|
||
artists: artist_names(&track.artists),
|
||
featured_artists: artist_names(&track.featured_artists),
|
||
release_title: (!track.release_title.trim().is_empty())
|
||
.then(|| track.release_title.clone()),
|
||
release_type: fetched
|
||
.metadata
|
||
.as_ref()
|
||
.and_then(|metadata| metadata.release_type.clone()),
|
||
year: track.release_year.or(fed.year),
|
||
duration_seconds: (track.duration_seconds > 0.0).then_some(track.duration_seconds),
|
||
track_number: track.track_number.or(fed.track_number),
|
||
disc_number: track.disc_number.or(fed.disc_number),
|
||
})
|
||
}
|
||
|
||
fn cached_cache_key(fed: &FedTrack) -> String {
|
||
format!("{}:{}", fed.owner, fed.item_id)
|
||
}
|
||
|
||
fn cached_matches_query(cached: &CachedTrackMetadata, normalized_query: &str) -> bool {
|
||
if normalized_query.is_empty() {
|
||
return false;
|
||
}
|
||
if music_dht::normalize_name(&cached.title) == normalized_query {
|
||
return true;
|
||
}
|
||
if cached
|
||
.release_title
|
||
.as_deref()
|
||
.is_some_and(|title| music_dht::normalize_name(title) == normalized_query)
|
||
{
|
||
return true;
|
||
}
|
||
let query_tokens = music_dht::tokenize(normalized_query);
|
||
if query_tokens.is_empty() {
|
||
return false;
|
||
}
|
||
let item_tokens = cached_search_tokens(cached);
|
||
query_tokens
|
||
.iter()
|
||
.all(|token| item_tokens.iter().any(|candidate| candidate == token))
|
||
}
|
||
|
||
fn cached_search_tokens(cached: &CachedTrackMetadata) -> Vec<String> {
|
||
let mut tokens = music_dht::tokenize(&music_dht::normalize_name(&cached.title));
|
||
for value in cached
|
||
.artists
|
||
.iter()
|
||
.chain(cached.featured_artists.iter())
|
||
.chain(cached.release_title.iter())
|
||
{
|
||
tokens.extend(music_dht::tokenize(&music_dht::normalize_name(value)));
|
||
}
|
||
tokens
|
||
}
|
||
|
||
fn cached_has_artist(cached: &CachedTrackMetadata, normalized_artist: &str) -> bool {
|
||
cached
|
||
.featured_artists
|
||
.iter()
|
||
.any(|artist| music_dht::normalize_name(artist) == normalized_artist)
|
||
}
|
||
|
||
fn rank_fed_search_results(
|
||
artists: &mut [FedArtistHit],
|
||
tracks: &mut [FedTrack],
|
||
normalized_query: &str,
|
||
) {
|
||
artists.sort_by(|a, b| {
|
||
exact_match_rank(&a.name, normalized_query)
|
||
.cmp(&exact_match_rank(&b.name, normalized_query))
|
||
.then_with(|| b.peers.cmp(&a.peers))
|
||
.then_with(|| a.name.cmp(&b.name))
|
||
});
|
||
tracks.sort_by_key(|track| fed_track_match_rank(track, normalized_query));
|
||
}
|
||
|
||
fn exact_match_rank(value: &str, normalized_query: &str) -> u8 {
|
||
if music_dht::normalize_name(value) == normalized_query {
|
||
0
|
||
} else {
|
||
1
|
||
}
|
||
}
|
||
|
||
fn fed_track_match_rank(track: &FedTrack, normalized_query: &str) -> u8 {
|
||
if music_dht::normalize_name(&track.title) == normalized_query {
|
||
return 0;
|
||
}
|
||
if track
|
||
.release_title
|
||
.as_deref()
|
||
.is_some_and(|title| music_dht::normalize_name(title) == normalized_query)
|
||
{
|
||
return 1;
|
||
}
|
||
if track
|
||
.artist_names
|
||
.iter()
|
||
.chain(track.featured_artist_names.iter())
|
||
.any(|artist| music_dht::normalize_name(artist) == normalized_query)
|
||
{
|
||
return 2;
|
||
}
|
||
3
|
||
}
|
||
|
||
fn add_dht_appearance_hits(
|
||
card: &mut FedArtistCard,
|
||
hits: &[LibraryItem],
|
||
normalized_artist: &str,
|
||
display_artist: &str,
|
||
) {
|
||
let mut changed = false;
|
||
for item in hits {
|
||
let Some(appearance) = dht_appearance_hit(item, normalized_artist, display_artist) else {
|
||
continue;
|
||
};
|
||
if add_appearance_to_card(card, appearance) {
|
||
changed = true;
|
||
}
|
||
}
|
||
if changed {
|
||
card.peers = card.owners.len();
|
||
sort_fed_appearances(&mut card.appears_on);
|
||
}
|
||
}
|
||
|
||
fn add_cached_appearance_hits(
|
||
card: &mut FedArtistCard,
|
||
cached: &[CachedTrackMetadata],
|
||
normalized_artist: &str,
|
||
display_artist: &str,
|
||
) {
|
||
let mut changed = false;
|
||
for track in cached {
|
||
let Some(appearance) = cached_appearance_hit(track, normalized_artist, display_artist)
|
||
else {
|
||
continue;
|
||
};
|
||
if add_appearance_to_card(card, appearance) {
|
||
changed = true;
|
||
}
|
||
}
|
||
if changed {
|
||
card.peers = card.owners.len();
|
||
sort_fed_appearances(&mut card.appears_on);
|
||
}
|
||
}
|
||
|
||
fn dht_appearance_hit(
|
||
item: &LibraryItem,
|
||
normalized_artist: &str,
|
||
display_artist: &str,
|
||
) -> Option<FedAppearsOn> {
|
||
if item.kind != ItemKind::Track {
|
||
return None;
|
||
}
|
||
let appears_as_featured = item
|
||
.featured_artist_names
|
||
.iter()
|
||
.any(|artist| music_dht::normalize_name(artist) == normalized_artist);
|
||
if !appears_as_featured {
|
||
return None;
|
||
}
|
||
let mut artists = Vec::new();
|
||
for artist in &item.artist_names {
|
||
push_artist_once(&mut artists, artist);
|
||
}
|
||
let mut featured_artists = Vec::new();
|
||
for artist in &item.featured_artist_names {
|
||
push_artist_once(&mut featured_artists, artist);
|
||
}
|
||
if featured_artists.is_empty() {
|
||
push_artist_once(&mut featured_artists, display_artist);
|
||
}
|
||
Some(FedAppearsOn {
|
||
release_title: item.release_title.clone().unwrap_or_default(),
|
||
release_type: item.release_type.clone().unwrap_or_default(),
|
||
year: item.year,
|
||
track: FedCardTrack {
|
||
title: item.name.clone(),
|
||
artists,
|
||
featured_artists,
|
||
track_number: item.track_number,
|
||
disc_number: item.disc_number,
|
||
duration_seconds: item.duration_seconds,
|
||
content_id: item.content_id.clone(),
|
||
sources: vec![(
|
||
item.owner.to_string(),
|
||
audio::hex_encode(item.id.as_bytes()),
|
||
)],
|
||
},
|
||
})
|
||
}
|
||
|
||
/// Converts a raw DHT record into the UI-facing federated track shape.
|
||
fn fed_track_from_item(item: music_dht::LibraryItem, own: EndpointId) -> FedTrack {
|
||
FedTrack {
|
||
item_id: audio::hex_encode(item.id.as_bytes()),
|
||
owner: item.owner.to_string(),
|
||
own: item.owner == own,
|
||
title: item.name,
|
||
artist_names: item.artist_names,
|
||
featured_artist_names: item.featured_artist_names,
|
||
year: item.year,
|
||
duration_seconds: item.duration_seconds.map(|d| d.round() as i64),
|
||
content_id: item.content_id,
|
||
release_title: item.release_title,
|
||
track_number: item.track_number,
|
||
disc_number: item.disc_number,
|
||
}
|
||
}
|
||
|
||
fn cached_appearance_hit(
|
||
cached: &CachedTrackMetadata,
|
||
normalized_artist: &str,
|
||
display_artist: &str,
|
||
) -> Option<FedAppearsOn> {
|
||
if !cached_has_artist(cached, normalized_artist) {
|
||
return None;
|
||
}
|
||
let mut featured_artists = cached.featured_artists.clone();
|
||
if featured_artists.is_empty() {
|
||
push_artist_once(&mut featured_artists, display_artist);
|
||
}
|
||
Some(FedAppearsOn {
|
||
release_title: cached.release_title.clone().unwrap_or_default(),
|
||
release_type: cached.release_type.clone().unwrap_or_default(),
|
||
year: cached.year,
|
||
track: FedCardTrack {
|
||
title: cached.title.clone(),
|
||
artists: cached.artists.clone(),
|
||
featured_artists,
|
||
track_number: cached.track_number,
|
||
disc_number: cached.disc_number,
|
||
duration_seconds: cached.duration_seconds,
|
||
content_id: cached.fed.content_id.clone(),
|
||
sources: vec![(cached.fed.owner.clone(), cached.fed.item_id.clone())],
|
||
},
|
||
})
|
||
}
|
||
|
||
fn add_appearance_to_card(card: &mut FedArtistCard, appearance: FedAppearsOn) -> bool {
|
||
let Some((owner, item_id)) = appearance.track.sources.first().cloned() else {
|
||
return false;
|
||
};
|
||
if fed_releases_have_source(card, &owner, &item_id) {
|
||
return false;
|
||
}
|
||
if let Some(existing) = card.appears_on.iter_mut().find(|existing| {
|
||
existing
|
||
.track
|
||
.sources
|
||
.iter()
|
||
.any(|(source_owner, source_id)| source_owner == &owner && source_id == &item_id)
|
||
}) {
|
||
merge_dht_appearance(existing, appearance);
|
||
return true;
|
||
}
|
||
if !card.owners.contains(&owner) {
|
||
card.owners.push(owner);
|
||
}
|
||
if let Some(slot) = card
|
||
.appears_on
|
||
.iter_mut()
|
||
.find(|existing| same_appearance(existing, &appearance))
|
||
{
|
||
merge_dht_appearance(slot, appearance);
|
||
} else {
|
||
card.appears_on.push(appearance);
|
||
}
|
||
true
|
||
}
|
||
|
||
fn same_appearance(left: &FedAppearsOn, right: &FedAppearsOn) -> bool {
|
||
let left_release = music_dht::normalize_name(&left.release_title);
|
||
let right_release = music_dht::normalize_name(&right.release_title);
|
||
(left_release == right_release || left_release.is_empty() || right_release.is_empty())
|
||
&& music_dht::normalize_name(&left.track.title)
|
||
== music_dht::normalize_name(&right.track.title)
|
||
&& (left.track.track_number == right.track.track_number
|
||
|| left.track.track_number.is_none()
|
||
|| right.track.track_number.is_none())
|
||
&& (left.year == right.year || left.year.is_none() || right.year.is_none())
|
||
}
|
||
|
||
fn fed_releases_have_source(card: &FedArtistCard, owner: &str, item_id: &str) -> bool {
|
||
card.releases
|
||
.iter()
|
||
.flat_map(|release| release.tracks.iter())
|
||
.any(|track| {
|
||
track
|
||
.sources
|
||
.iter()
|
||
.any(|(source_owner, source_id)| source_owner == owner && source_id == item_id)
|
||
})
|
||
}
|
||
|
||
fn merge_dht_appearance(target: &mut FedAppearsOn, appearance: FedAppearsOn) {
|
||
if target.release_title.is_empty() {
|
||
target.release_title = appearance.release_title;
|
||
}
|
||
if target.release_type.is_empty() {
|
||
target.release_type = appearance.release_type;
|
||
}
|
||
if target.year.is_none() {
|
||
target.year = appearance.year;
|
||
}
|
||
if target.track.duration_seconds.is_none() {
|
||
target.track.duration_seconds = appearance.track.duration_seconds;
|
||
}
|
||
if target.track.content_id.is_none() {
|
||
target.track.content_id = appearance.track.content_id;
|
||
}
|
||
for artist in appearance.track.artists {
|
||
push_artist_once(&mut target.track.artists, &artist);
|
||
}
|
||
for artist in appearance.track.featured_artists {
|
||
push_artist_once(&mut target.track.featured_artists, &artist);
|
||
}
|
||
for source in appearance.track.sources {
|
||
if !target.track.sources.contains(&source) {
|
||
target.track.sources.push(source);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn push_artist_once(names: &mut Vec<String>, name: &str) {
|
||
if names
|
||
.iter()
|
||
.any(|existing| music_dht::normalize_name(existing) == music_dht::normalize_name(name))
|
||
{
|
||
return;
|
||
}
|
||
names.push(name.to_string());
|
||
}
|
||
|
||
fn artist_line(artists: &[String], featured_artists: &[String]) -> String {
|
||
let mut main = Vec::new();
|
||
for artist in artists {
|
||
push_artist_once(&mut main, artist);
|
||
}
|
||
let mut featured = Vec::new();
|
||
for artist in featured_artists {
|
||
if !main
|
||
.iter()
|
||
.any(|name| music_dht::normalize_name(name) == music_dht::normalize_name(artist))
|
||
{
|
||
push_artist_once(&mut featured, artist);
|
||
}
|
||
}
|
||
match (main.is_empty(), featured.is_empty()) {
|
||
(false, false) => format!("{} feat. {}", main.join(", "), featured.join(", ")),
|
||
(false, true) => main.join(", "),
|
||
(true, false) => format!("feat. {}", featured.join(", ")),
|
||
(true, true) => String::new(),
|
||
}
|
||
}
|
||
|
||
fn sort_fed_appearances(appearances: &mut [FedAppearsOn]) {
|
||
appearances.sort_by(|a, b| {
|
||
b.year
|
||
.unwrap_or(i32::MIN)
|
||
.cmp(&a.year.unwrap_or(i32::MIN))
|
||
.then_with(|| a.release_title.cmp(&b.release_title))
|
||
.then_with(|| {
|
||
a.track
|
||
.track_number
|
||
.unwrap_or(i32::MAX)
|
||
.cmp(&b.track.track_number.unwrap_or(i32::MAX))
|
||
})
|
||
.then_with(|| a.track.title.cmp(&b.track.title))
|
||
});
|
||
}
|
||
|
||
async fn stop_running(running: Option<Running>) {
|
||
let Some(running) = running else { return };
|
||
for task in &running.tasks {
|
||
task.abort();
|
||
}
|
||
if let Err(err) = running.service.shutdown().await {
|
||
tracing::warn!("federation node shutdown reported an error: {err}");
|
||
}
|
||
tracing::info!("federation stopped");
|
||
}
|
||
|
||
/// Reads the local library and converts it into DHT item specs. Only names
|
||
/// and small metadata are shared — never file paths or the files themselves.
|
||
fn collect_specs(library: &Library) -> Result<Vec<ItemSpec>> {
|
||
let export = library.federation_export()?;
|
||
let mut specs = Vec::new();
|
||
for (id, name) in export.artists {
|
||
specs.push(ItemSpec {
|
||
local_key: format!("artist:{id}"),
|
||
kind: ItemKind::Artist,
|
||
name,
|
||
artist_names: Vec::new(),
|
||
featured_artist_names: Vec::new(),
|
||
year: None,
|
||
release_type: None,
|
||
release_title: None,
|
||
track_number: None,
|
||
disc_number: None,
|
||
duration_seconds: None,
|
||
content_id: None,
|
||
});
|
||
}
|
||
for release in export.releases {
|
||
specs.push(ItemSpec {
|
||
local_key: format!("release:{}", release.id),
|
||
kind: ItemKind::Release,
|
||
name: release.title,
|
||
artist_names: release.artist_names,
|
||
featured_artist_names: Vec::new(),
|
||
year: release.year,
|
||
release_type: Some(release.release_type),
|
||
release_title: None,
|
||
track_number: None,
|
||
disc_number: None,
|
||
duration_seconds: None,
|
||
content_id: None,
|
||
});
|
||
}
|
||
for track in export.tracks {
|
||
specs.push(ItemSpec {
|
||
local_key: format!("track:{}", track.id),
|
||
kind: ItemKind::Track,
|
||
name: track.title,
|
||
artist_names: track.artist_names,
|
||
featured_artist_names: track.featured_artist_names,
|
||
year: track.year,
|
||
release_type: Some(track.release_type),
|
||
release_title: Some(track.release_title),
|
||
track_number: track.track_number,
|
||
disc_number: track.disc_number,
|
||
duration_seconds: (track.duration_seconds > 0.0).then_some(track.duration_seconds),
|
||
content_id: track.content_id,
|
||
});
|
||
}
|
||
Ok(specs)
|
||
}
|
||
|
||
fn sanitize_file_stem(value: &str) -> String {
|
||
let cleaned: String = value
|
||
.chars()
|
||
.map(|c| match c {
|
||
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||
c if c.is_control() => '_',
|
||
c => c,
|
||
})
|
||
.collect();
|
||
let trimmed = cleaned.trim().trim_matches('.');
|
||
let mut stem: String = trimmed.chars().take(120).collect();
|
||
if stem.is_empty() {
|
||
stem.push_str("track");
|
||
}
|
||
stem
|
||
}
|
||
|
||
fn download_stem(fed: &FedTrack) -> String {
|
||
let artists = fed.artist_line();
|
||
if artists.is_empty() {
|
||
sanitize_file_stem(&fed.title)
|
||
} else {
|
||
sanitize_file_stem(&format!("{artists} - {}", fed.title))
|
||
}
|
||
}
|
||
|
||
/// A queueable placeholder for a federated track that has not been
|
||
/// downloaded yet: it behaves like a regular track everywhere (queue, info,
|
||
/// selection) and resolves to a local file when playback reaches it.
|
||
pub fn pending_track(fed: &FedTrack) -> TrackItem {
|
||
let id = NEXT_EPHEMERAL_ID.fetch_sub(1, Ordering::Relaxed);
|
||
let refs = |names: &[String]| -> Vec<ArtistRef> {
|
||
names
|
||
.iter()
|
||
.map(|name| ArtistRef {
|
||
id: -1,
|
||
name: name.clone(),
|
||
})
|
||
.collect()
|
||
};
|
||
TrackItem {
|
||
id,
|
||
title: fed.title.clone(),
|
||
track_number: fed.track_number,
|
||
disc_number: fed.disc_number,
|
||
duration_seconds: fed.duration_seconds.unwrap_or(0) as f64,
|
||
artists: refs(&fed.artist_names),
|
||
featured_artists: refs(&fed.featured_artist_names),
|
||
release_id: -1,
|
||
release_title: fed
|
||
.release_title
|
||
.clone()
|
||
.unwrap_or_else(|| format!("federation · {}", fed.owner_short())),
|
||
release_year: fed.year,
|
||
file_path: String::new(),
|
||
content_id: fed.content_id.clone(),
|
||
cover_path: None,
|
||
audio_format: None,
|
||
audio_bitrate: None,
|
||
audio_sample_rate: None,
|
||
audio_bit_depth: None,
|
||
file_size_bytes: None,
|
||
play_count: 0,
|
||
fed: Some(fed.clone()),
|
||
}
|
||
}
|
||
|
||
/// A playable TrackItem for a downloaded-but-not-imported federated track.
|
||
fn ephemeral_track(
|
||
fed: &FedTrack,
|
||
metadata: Option<&TrackMetadata>,
|
||
path: &std::path::Path,
|
||
) -> TrackItem {
|
||
let id = NEXT_EPHEMERAL_ID.fetch_sub(1, Ordering::Relaxed);
|
||
let file_size = std::fs::metadata(path).map(|m| m.len() as i64).ok();
|
||
let refs = |names: &[String]| -> Vec<ArtistRef> {
|
||
names
|
||
.iter()
|
||
.map(|name| ArtistRef {
|
||
id: -1,
|
||
name: name.clone(),
|
||
})
|
||
.collect()
|
||
};
|
||
let title = metadata
|
||
.map(|m| m.title.trim())
|
||
.filter(|t| !t.is_empty())
|
||
.unwrap_or(&fed.title)
|
||
.to_string();
|
||
let artists = match metadata {
|
||
Some(meta) if !meta.artists.is_empty() => refs(&meta.artists),
|
||
_ => refs(&fed.artist_names),
|
||
};
|
||
let featured_artists = match metadata {
|
||
Some(meta) => refs(&meta.featured_artists),
|
||
None => refs(&fed.featured_artist_names),
|
||
};
|
||
let release_title = metadata
|
||
.map(|m| m.release_title.trim())
|
||
.filter(|t| !t.is_empty())
|
||
.map(|t| t.to_string())
|
||
.or_else(|| fed.release_title.clone())
|
||
.unwrap_or_else(|| format!("federation · {}", fed.owner_short()));
|
||
TrackItem {
|
||
id,
|
||
title,
|
||
track_number: metadata.and_then(|m| m.track_number),
|
||
disc_number: metadata.and_then(|m| m.disc_number),
|
||
duration_seconds: metadata
|
||
.and_then(|m| m.duration_seconds)
|
||
.or_else(|| fed.duration_seconds.map(|duration| duration as f64))
|
||
.unwrap_or(0.0),
|
||
artists,
|
||
featured_artists,
|
||
release_id: -1,
|
||
release_title,
|
||
release_year: metadata.and_then(|m| m.year).or(fed.year),
|
||
file_path: path.to_string_lossy().into_owned(),
|
||
content_id: fed
|
||
.content_id
|
||
.clone()
|
||
.or_else(|| crate::library::audio_content_id(&path.to_string_lossy())),
|
||
cover_path: None,
|
||
audio_format: metadata.and_then(|m| m.audio_format.clone()).or_else(|| {
|
||
path.extension()
|
||
.and_then(|e| e.to_str())
|
||
.map(str::to_string)
|
||
}),
|
||
audio_bitrate: metadata.and_then(|m| m.audio_bitrate),
|
||
audio_sample_rate: metadata.and_then(|m| m.audio_sample_rate),
|
||
audio_bit_depth: metadata.and_then(|m| m.audio_bit_depth),
|
||
file_size_bytes: file_size,
|
||
play_count: 0,
|
||
// Keep the federation reference: the cached track can still be
|
||
// liked, re-downloaded and marked as federated in the lists.
|
||
fed: Some(fed.clone()),
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
#[path = "tests.rs"]
|
||
mod tests;
|