fixed metadata import
This commit is contained in:
+73
-19
@@ -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
@@ -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
@@ -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
@@ -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()],
|
||||
|
||||
Reference in New Issue
Block a user