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