fixed metadata import

This commit is contained in:
Ultradesu
2026-07-16 21:16:15 +03:00
parent 691abe599b
commit b027db8a09
4 changed files with 164 additions and 41 deletions
+73 -19
View File
@@ -39,6 +39,34 @@ struct AudioResponseHeader {
total_size: u64, total_size: u64,
#[serde(default)] #[serde(default)]
offset: u64, offset: u64,
/// Full track metadata from the owner's database — richer and more
/// authoritative than whatever tags the file itself carries. Absent
/// when the peer predates the field (the header is extensible).
#[serde(default, skip_serializing_if = "Option::is_none")]
metadata: Option<TrackMetadata>,
}
/// Track metadata exchanged alongside the audio bytes.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TrackMetadata {
#[serde(default)]
pub title: String,
#[serde(default)]
pub artists: Vec<String>,
#[serde(default)]
pub featured_artists: Vec<String>,
#[serde(default)]
pub album_artists: Vec<String>,
#[serde(default)]
pub release_title: String,
#[serde(default)]
pub release_type: Option<String>,
#[serde(default)]
pub year: Option<i32>,
#[serde(default)]
pub track_number: Option<i32>,
#[serde(default)]
pub disc_number: Option<i32>,
} }
pub fn hex_encode(bytes: &[u8]) -> String { pub fn hex_encode(bytes: &[u8]) -> String {
@@ -122,15 +150,16 @@ async fn write_line<W: AsyncWriteExt + Unpin>(writer: &mut W, value: &impl Seria
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Downloads a whole track from `owner` into `dir/<stem>.<ext>`; returns /// Downloads a whole track from `owner` into `dir/<stem>.<ext>`; returns
/// the file path and the mime type the peer reported. An already complete /// the file path, the mime type and the track metadata the peer reported.
/// cached file is reused. /// An already complete cached file is reused (the metadata still comes
/// fresh from the header).
pub async fn download_track( pub async fn download_track(
service: &MusicDhtService, service: &MusicDhtService,
owner: EndpointId, owner: EndpointId,
item_id_hex: &str, item_id_hex: &str,
dir: &Path, dir: &Path,
stem: &str, stem: &str,
) -> Result<(PathBuf, String)> { ) -> Result<(PathBuf, String, Option<TrackMetadata>)> {
let mut stream = service let mut stream = service
.open_stream(owner, AUDIO_ALPN) .open_stream(owner, AUDIO_ALPN)
.await .await
@@ -160,7 +189,7 @@ pub async fn download_track(
&& header.total_size > 0 && header.total_size > 0
{ {
// Already fully downloaded earlier; no need to fetch again. // Already fully downloaded earlier; no need to fetch again.
return Ok((path, header.mime_type)); return Ok((path, header.mime_type, header.metadata));
} }
let temp_path = dir.join(format!(".{stem}.{extension}.part")); let temp_path = dir.join(format!(".{stem}.{extension}.part"));
@@ -182,7 +211,7 @@ pub async fn download_track(
); );
} }
tokio::fs::rename(&temp_path, &path).await?; tokio::fs::rename(&temp_path, &path).await?;
Ok((path, header.mime_type)) Ok((path, header.mime_type, header.metadata))
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -205,19 +234,42 @@ pub fn resolve_local_track_id(
Ok(None) Ok(None)
} }
fn resolve_local_file( /// Resolves the item to (file path, full metadata from the database).
fn resolve_for_serving(
library: &Library, library: &Library,
own: EndpointId, own: EndpointId,
item_id: ItemId, item_id: ItemId,
) -> Result<Option<String>> { ) -> Result<Option<(String, TrackMetadata)>> {
let export = library.federation_export()?; let Some(track_id) = resolve_local_track_id(library, own, item_id)? else {
for track in export.tracks { return Ok(None);
let derived = ItemId::derive(&own, ItemKind::Track, &format!("track:{}", track.id)); };
if derived == item_id { let Some(track) = library.tracks_by_ids(&[track_id])?.into_iter().next() else {
return Ok(Some(track.file_path)); return Ok(None);
} };
} // Release type and album artists live on the release row.
Ok(None) let (release_type, album_artists) = match library.release(track.release_id) {
Ok(detail) => (
Some(detail.release_type),
detail.artists.iter().map(|a| a.name.clone()).collect(),
),
Err(_) => (None, Vec::new()),
};
let metadata = TrackMetadata {
title: track.title.clone(),
artists: track.artists.iter().map(|a| a.name.clone()).collect(),
featured_artists: track
.featured_artists
.iter()
.map(|a| a.name.clone())
.collect(),
album_artists,
release_title: track.release_title.clone(),
release_type,
year: track.release_year,
track_number: track.track_number,
disc_number: track.disc_number,
};
Ok(Some((track.file_path, metadata)))
} }
/// Runs the accept loop of the audio protocol until the acceptor closes. /// Runs the accept loop of the audio protocol until the acceptor closes.
@@ -247,10 +299,10 @@ async fn serve_one(mut stream: ByteStream, library: Arc<Library>, own: EndpointI
let resolved = match hex_decode_item_id(&request.item_id) { let resolved = match hex_decode_item_id(&request.item_id) {
Some(item_id) => { Some(item_id) => {
let library = Arc::clone(&library); let library = Arc::clone(&library);
match tokio::task::spawn_blocking(move || resolve_local_file(&library, own, item_id)) match tokio::task::spawn_blocking(move || resolve_for_serving(&library, own, item_id))
.await .await
{ {
Ok(Ok(Some(path))) => Ok(path), Ok(Ok(Some(found))) => Ok(found),
Ok(Ok(None)) => Err("track not found in the library".to_string()), Ok(Ok(None)) => Err("track not found in the library".to_string()),
Ok(Err(err)) => Err(format!("library lookup failed: {err:#}")), Ok(Err(err)) => Err(format!("library lookup failed: {err:#}")),
Err(err) => Err(format!("lookup task failed: {err}")), Err(err) => Err(format!("lookup task failed: {err}")),
@@ -258,8 +310,8 @@ async fn serve_one(mut stream: ByteStream, library: Arc<Library>, own: EndpointI
} }
None => Err("malformed item_id".to_string()), None => Err("malformed item_id".to_string()),
}; };
let file_path = match resolved { let (file_path, metadata) = match resolved {
Ok(path) => path, Ok(found) => found,
Err(message) => return refuse(stream, message).await, Err(message) => return refuse(stream, message).await,
}; };
@@ -281,6 +333,7 @@ async fn serve_one(mut stream: ByteStream, library: Arc<Library>, own: EndpointI
mime_type: guess_mime(&path).to_string(), mime_type: guess_mime(&path).to_string(),
total_size, total_size,
offset, offset,
metadata: Some(metadata),
}, },
) )
.await?; .await?;
@@ -302,6 +355,7 @@ async fn refuse(mut stream: ByteStream, message: String) -> Result<()> {
mime_type: String::new(), mime_type: String::new(),
total_size: 0, total_size: 0,
offset: 0, offset: 0,
metadata: None,
}, },
) )
.await?; .await?;
+79 -17
View File
@@ -30,7 +30,7 @@ use serde::{Deserialize, Serialize};
use crate::library::Library; use crate::library::Library;
use crate::library::models::{ArtistRef, TrackItem}; use crate::library::models::{ArtistRef, TrackItem};
pub use audio::AUDIO_ALPN; pub use audio::{AUDIO_ALPN, TrackMetadata};
/// How often the published library is re-synchronized with the local index. /// How often the published library is re-synchronized with the local index.
const SYNC_INTERVAL: Duration = Duration::from_secs(60); const SYNC_INTERVAL: Duration = Duration::from_secs(60);
@@ -481,15 +481,21 @@ impl Federation {
let dir = if save { &self.media_dir } else { &self.cache_dir }; let dir = if save { &self.media_dir } else { &self.cache_dir };
tokio::fs::create_dir_all(dir).await?; tokio::fs::create_dir_all(dir).await?;
let (path, mime) = let (path, mime, metadata) =
audio::download_track(&service, owner, &fed.item_id, dir, &download_stem(fed)).await?; audio::download_track(&service, owner, &fed.item_id, dir, &download_stem(fed)).await?;
tracing::info!(path = %path.display(), %mime, "federated track downloaded"); tracing::info!(path = %path.display(), %mime, "federated track downloaded");
if save { if save {
let library = Arc::clone(&self.library); let library = Arc::clone(&self.library);
let import_path = path.clone(); let import_path = path.clone();
let import_metadata = metadata.clone();
let imported = tokio::task::spawn_blocking(move || -> Result<Option<TrackItem>> { let imported = tokio::task::spawn_blocking(move || -> Result<Option<TrackItem>> {
let import = crate::library::import::read_file(&import_path)?; 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);
}
let (track_id, _) = crate::library::import::upsert_track(&library, &import)?; let (track_id, _) = crate::library::import::upsert_track(&library, &import)?;
Ok(library.tracks_by_ids(&[track_id])?.into_iter().next()) Ok(library.tracks_by_ids(&[track_id])?.into_iter().next())
}) })
@@ -509,12 +515,48 @@ impl Federation {
} }
Ok(FedPlayable { Ok(FedPlayable {
track: ephemeral_track(fed, &path), track: ephemeral_track(fed, metadata.as_ref(), &path),
imported: false, imported: false,
}) })
} }
} }
/// 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;
}
}
async fn stop_running(running: Option<Running>) { async fn stop_running(running: Option<Running>) {
let Some(running) = running else { return }; let Some(running) = running else { return };
for task in &running.tasks { for task in &running.tasks {
@@ -594,27 +636,47 @@ fn download_stem(fed: &FedTrack) -> String {
} }
/// A playable TrackItem for a downloaded-but-not-imported federated track. /// A playable TrackItem for a downloaded-but-not-imported federated track.
fn ephemeral_track(fed: &FedTrack, path: &std::path::Path) -> TrackItem { fn ephemeral_track(
fed: &FedTrack,
metadata: Option<&TrackMetadata>,
path: &std::path::Path,
) -> TrackItem {
let id = NEXT_EPHEMERAL_ID.fetch_sub(1, Ordering::Relaxed); 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 file_size = std::fs::metadata(path).map(|m| m.len() as i64).ok();
TrackItem { let refs = |names: &[String]| -> Vec<ArtistRef> {
id, names
title: fed.title.clone(),
track_number: None,
disc_number: None,
duration_seconds: fed.duration_seconds.unwrap_or(0) as f64,
artists: fed
.artist_names
.iter() .iter()
.map(|name| ArtistRef { .map(|name| ArtistRef {
id: -1, id: -1,
name: name.clone(), name: name.clone(),
}) })
.collect(), .collect()
featured_artists: Vec::new(), };
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 release_title = metadata
.map(|m| m.release_title.trim())
.filter(|t| !t.is_empty())
.map(|t| t.to_string())
.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: fed.duration_seconds.unwrap_or(0) as f64,
artists,
featured_artists: metadata.map(|m| refs(&m.featured_artists)).unwrap_or_default(),
release_id: -1, release_id: -1,
release_title: format!("federation · {}", fed.owner_short()), release_title,
release_year: fed.year, release_year: metadata.and_then(|m| m.year).or(fed.year),
file_path: path.to_string_lossy().into_owned(), file_path: path.to_string_lossy().into_owned(),
cover_path: None, cover_path: None,
audio_format: path audio_format: path
+10 -2
View File
@@ -24,6 +24,9 @@ pub struct TrackImport {
pub featured_artists: Vec<String>, pub featured_artists: Vec<String>,
pub album_artists: Vec<String>, pub album_artists: Vec<String>,
pub release_title: String, pub release_title: String,
/// Release type ("album", "single", ...) when known from a richer
/// source than file tags (e.g. federation metadata); None = "album".
pub release_type: Option<String>,
pub year: Option<i32>, pub year: Option<i32>,
pub track_number: Option<i32>, pub track_number: Option<i32>,
pub disc_number: Option<i32>, pub disc_number: Option<i32>,
@@ -186,6 +189,7 @@ pub fn read_file(path: &Path) -> Result<TrackImport> {
featured_artists: featured, featured_artists: featured,
album_artists, album_artists,
release_title: album.unwrap_or_else(|| "Unknown Album".to_string()), release_title: album.unwrap_or_else(|| "Unknown Album".to_string()),
release_type: None,
year, year,
track_number, track_number,
disc_number, disc_number,
@@ -243,8 +247,12 @@ pub fn upsert_track(library: &Library, import: &TrackImport) -> Result<(i64, boo
} }
None => { None => {
tx.execute( tx.execute(
"INSERT INTO releases (title, release_type, year) VALUES (?1, 'album', ?2)", "INSERT INTO releases (title, release_type, year) VALUES (?1, ?2, ?3)",
params![import.release_title, import.year], params![
import.release_title,
import.release_type.as_deref().unwrap_or("album"),
import.year,
],
)?; )?;
let id = tx.last_insert_rowid(); let id = tx.last_insert_rowid();
for (position, name) in import.album_artists.iter().enumerate() { for (position, name) in import.album_artists.iter().enumerate() {
+2 -3
View File
@@ -130,7 +130,6 @@ pub struct ExportTrack {
pub title: String, pub title: String,
pub year: Option<i32>, pub year: Option<i32>,
pub duration_seconds: f64, pub duration_seconds: f64,
pub file_path: String,
pub artist_names: Vec<String>, pub artist_names: Vec<String>,
} }
@@ -434,7 +433,7 @@ impl Library {
track_artists.entry(id).or_default().push(name); track_artists.entry(id).or_default().push(name);
} }
let mut statement = conn.prepare( let mut statement = conn.prepare(
"SELECT t.id, t.title, r.year, t.duration_seconds, t.file_path "SELECT t.id, t.title, r.year, t.duration_seconds
FROM tracks t JOIN releases r ON r.id = t.release_id", FROM tracks t JOIN releases r ON r.id = t.release_id",
)?; )?;
let tracks = statement let tracks = statement
@@ -444,7 +443,6 @@ impl Library {
title: row.get(1)?, title: row.get(1)?,
year: row.get(2)?, year: row.get(2)?,
duration_seconds: row.get(3)?, duration_seconds: row.get(3)?,
file_path: row.get(4)?,
artist_names: Vec::new(), artist_names: Vec::new(),
}) })
})? })?
@@ -883,6 +881,7 @@ mod tests {
fn add_track(lib: &Library, title: &str, artist: &str, album: &str) -> i64 { fn add_track(lib: &Library, title: &str, artist: &str, album: &str) -> i64 {
let import = import::TrackImport { let import = import::TrackImport {
release_type: None,
file_path: format!("/music/{artist}/{album}/{title}.mp3"), file_path: format!("/music/{artist}/{album}/{title}.mp3"),
title: title.to_string(), title: title.to_string(),
artists: vec![artist.to_string()], artists: vec![artist.to_string()],