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,
#[serde(default)]
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 {
@@ -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
/// the file path and the mime type the peer reported. An already complete
/// cached file is reused.
/// the file path, the mime type and the track metadata the peer reported.
/// An already complete cached file is reused (the metadata still comes
/// fresh from the header).
pub async fn download_track(
service: &MusicDhtService,
owner: EndpointId,
item_id_hex: &str,
dir: &Path,
stem: &str,
) -> Result<(PathBuf, String)> {
) -> Result<(PathBuf, String, Option<TrackMetadata>)> {
let mut stream = service
.open_stream(owner, AUDIO_ALPN)
.await
@@ -160,7 +189,7 @@ pub async fn download_track(
&& header.total_size > 0
{
// 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"));
@@ -182,7 +211,7 @@ pub async fn download_track(
);
}
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)
}
fn resolve_local_file(
/// Resolves the item to (file path, full metadata from the database).
fn resolve_for_serving(
library: &Library,
own: EndpointId,
item_id: ItemId,
) -> Result<Option<String>> {
let export = library.federation_export()?;
for track in export.tracks {
let derived = ItemId::derive(&own, ItemKind::Track, &format!("track:{}", track.id));
if derived == item_id {
return Ok(Some(track.file_path));
}
}
Ok(None)
) -> Result<Option<(String, TrackMetadata)>> {
let Some(track_id) = resolve_local_track_id(library, own, item_id)? else {
return Ok(None);
};
let Some(track) = library.tracks_by_ids(&[track_id])?.into_iter().next() else {
return Ok(None);
};
// Release type and album artists live on the release row.
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.
@@ -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) {
Some(item_id) => {
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
{
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(Err(err)) => Err(format!("library lookup 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()),
};
let file_path = match resolved {
Ok(path) => path,
let (file_path, metadata) = match resolved {
Ok(found) => found,
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(),
total_size,
offset,
metadata: Some(metadata),
},
)
.await?;
@@ -302,6 +355,7 @@ async fn refuse(mut stream: ByteStream, message: String) -> Result<()> {
mime_type: String::new(),
total_size: 0,
offset: 0,
metadata: None,
},
)
.await?;
+79 -17
View File
@@ -30,7 +30,7 @@ use serde::{Deserialize, Serialize};
use crate::library::Library;
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.
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 };
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?;
tracing::info!(path = %path.display(), %mime, "federated track downloaded");
if save {
let library = Arc::clone(&self.library);
let import_path = path.clone();
let import_metadata = metadata.clone();
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)?;
Ok(library.tracks_by_ids(&[track_id])?.into_iter().next())
})
@@ -509,12 +515,48 @@ impl Federation {
}
Ok(FedPlayable {
track: ephemeral_track(fed, &path),
track: ephemeral_track(fed, metadata.as_ref(), &path),
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>) {
let Some(running) = running else { return };
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.
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 file_size = std::fs::metadata(path).map(|m| m.len() as i64).ok();
TrackItem {
id,
title: fed.title.clone(),
track_number: None,
disc_number: None,
duration_seconds: fed.duration_seconds.unwrap_or(0) as f64,
artists: fed
.artist_names
let refs = |names: &[String]| -> Vec<ArtistRef> {
names
.iter()
.map(|name| ArtistRef {
id: -1,
name: name.clone(),
})
.collect(),
featured_artists: Vec::new(),
.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 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_title: format!("federation · {}", fed.owner_short()),
release_year: fed.year,
release_title,
release_year: metadata.and_then(|m| m.year).or(fed.year),
file_path: path.to_string_lossy().into_owned(),
cover_path: None,
audio_format: path
+10 -2
View File
@@ -24,6 +24,9 @@ pub struct TrackImport {
pub featured_artists: Vec<String>,
pub album_artists: Vec<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 track_number: Option<i32>,
pub disc_number: Option<i32>,
@@ -186,6 +189,7 @@ pub fn read_file(path: &Path) -> Result<TrackImport> {
featured_artists: featured,
album_artists,
release_title: album.unwrap_or_else(|| "Unknown Album".to_string()),
release_type: None,
year,
track_number,
disc_number,
@@ -243,8 +247,12 @@ pub fn upsert_track(library: &Library, import: &TrackImport) -> Result<(i64, boo
}
None => {
tx.execute(
"INSERT INTO releases (title, release_type, year) VALUES (?1, 'album', ?2)",
params![import.release_title, import.year],
"INSERT INTO releases (title, release_type, year) VALUES (?1, ?2, ?3)",
params![
import.release_title,
import.release_type.as_deref().unwrap_or("album"),
import.year,
],
)?;
let id = tx.last_insert_rowid();
for (position, name) in import.album_artists.iter().enumerate() {
+2 -3
View File
@@ -130,7 +130,6 @@ pub struct ExportTrack {
pub title: String,
pub year: Option<i32>,
pub duration_seconds: f64,
pub file_path: String,
pub artist_names: Vec<String>,
}
@@ -434,7 +433,7 @@ impl Library {
track_artists.entry(id).or_default().push(name);
}
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",
)?;
let tracks = statement
@@ -444,7 +443,6 @@ impl Library {
title: row.get(1)?,
year: row.get(2)?,
duration_seconds: row.get(3)?,
file_path: row.get(4)?,
artist_names: Vec::new(),
})
})?
@@ -883,6 +881,7 @@ mod tests {
fn add_track(lib: &Library, title: &str, artist: &str, album: &str) -> i64 {
let import = import::TrackImport {
release_type: None,
file_path: format!("/music/{artist}/{album}/{title}.mp3"),
title: title.to_string(),
artists: vec![artist.to_string()],