feat(metadata): expand the local tag editor

Promote metadata editing to a routed full-page workflow with tabular per-track fields, ordered artist credits, provider identifiers, and compilation handling.

Add bounded artwork selection and retrieval through MusicBrainz, Discogs, and Last.fm. Direct and sidecar saves preflight immutable rows, verify rich tag writes, cache covers by content, and update exact database ids transactionally.

cx
This commit is contained in:
vicrodh committed 2026-08-24 19:46:59 -06:00
1 parent ed8d11e5c8
commit 75a668ed12
16 files changed
+2726 -195

No files matched your search

@@ -432,6 +432,47 @@ impl DiscogsClient {
Ok(cache_path.to_string_lossy().to_string())
}
/// Download one previously returned Discogs image without trusting its
/// size or exposing the proxy URL to a frontend. The editor stages these
/// bytes and validates the decoded image before any tag/file mutation.
pub async fn download_artwork_bytes(
&self,
image_url: &str,
max_bytes: usize,
) -> Result<Vec<u8>, String> {
let proxy_url = format!(
"{}/image?url={}",
DISCOGS_PROXY_URL,
urlencoding::encode(image_url)
);
let mut response = self
.client
.get(&proxy_url)
.send()
.await
.map_err(|error| format!("Failed to download Discogs image: {error}"))?
.error_for_status()
.map_err(|error| format!("Discogs image request failed: {error}"))?;
if response
.content_length()
.is_some_and(|length| length > max_bytes as u64)
{
return Err("Discogs image is larger than the editor limit".to_string());
}
let mut bytes = Vec::new();
while let Some(chunk) = response
.chunk()
.await
.map_err(|error| format!("Failed to read Discogs image: {error}"))?
{
if bytes.len().saturating_add(chunk.len()) > max_bytes {
return Err("Discogs image is larger than the editor limit".to_string());
}
bytes.extend_from_slice(&chunk);
}
Ok(bytes)
}
/// Search for artists and return search results
pub async fn search_artist(&self, query: &str) -> Result<SearchResponse, String> {
let url = format!(
+63 -8
View File
@@ -791,6 +791,64 @@ impl LastFmClient {
Ok(albums)
}
/// Public `album.getInfo` lookup used by the local metadata editor for a
/// conservative artwork candidate. No Last.fm user session is required;
/// the existing proxy supplies the application key.
pub async fn get_album_info(
&self,
artist: &str,
album: &str,
) -> IntegrationResult<LastFmAlbum> {
let url = format!("{}/album.getInfo", LASTFM_PROXY_URL);
let response = self
.client
.post(&url)
.json(&json!({
"artist": artist,
"album": album,
"autocorrect": 1,
}))
.send()
.await?;
if !response.status().is_success() {
let text = response.text().await.unwrap_or_default();
return Err(IntegrationError::internal(format!(
"Last.fm album.getInfo failed: {text}"
)));
}
let data: serde_json::Value = response.json().await?;
if let Some(error) = data.get("error") {
return Err(IntegrationError::api(
error.as_u64().unwrap_or(0) as u32,
data.get("message")
.and_then(|message| message.as_str())
.unwrap_or("Unknown error")
.to_string(),
));
}
let value = data.get("album").ok_or_else(|| {
IntegrationError::internal("Last.fm returned no album object".to_string())
})?;
let name = value
.get("name")
.and_then(|name| name.as_str())
.unwrap_or(album)
.to_string();
let artist = value
.get("artist")
.and_then(|artist| artist.as_str())
.unwrap_or(artist)
.to_string();
Ok(LastFmAlbum {
name,
artist,
artist_mbid: None,
mbid: extract_mbid(value),
image: extract_image(value),
playcount: parse_u64(value.get("playcount")),
})
}
/// user.getTopAlbums — the USER's scrobbled albums (their playcount).
///
/// The "already heard" exclusion set for Recommended Albums. Public read
@@ -900,14 +958,11 @@ fn extract_mbid(value: &serde_json::Value) -> Option<String> {
/// Extract a Unix timestamp from a Last.fm `date.uts` field (string or number).
fn extract_uts(value: &serde_json::Value) -> Option<i64> {
value
.get("date")
.and_then(|d| d.get("uts"))
.and_then(|u| {
u.as_str()
.and_then(|s| s.parse::<i64>().ok())
.or_else(|| u.as_i64())
})
value.get("date").and_then(|d| d.get("uts")).and_then(|u| {
u.as_str()
.and_then(|s| s.parse::<i64>().ok())
.or_else(|| u.as_i64())
})
}
/// Parse a `u64` that Last.fm may return as a JSON string or number; defaults to 0.
+1 -1
View File
@@ -42,5 +42,5 @@ pub use musicbrainz::{MusicBrainzClient, MusicBrainzConfig};
pub use remote_metadata::{
discogs_extended_to_search_result, discogs_full_to_metadata,
musicbrainz_full_to_metadata, musicbrainz_release_to_search_result, RemoteAlbumMetadata,
RemoteAlbumSearchResult, RemoteProvider, RemoteTrackMetadata,
RemoteAlbumSearchResult, RemoteArtistCredit, RemoteProvider, RemoteTrackMetadata,
};
@@ -359,6 +359,8 @@ pub struct Medium {
/// A track on a medium
#[derive(Debug, Deserialize)]
pub struct MediumTrack {
#[serde(default)]
pub id: Option<String>,
pub position: Option<u8>,
pub number: Option<String>,
pub title: Option<String>,
@@ -8,10 +8,35 @@
mod models;
pub use models::{
RemoteAlbumMetadata, RemoteAlbumSearchResult, RemoteMetadataError, RemoteProvider,
RemoteSearchRequest, RemoteSearchResponse, RemoteTrackMetadata,
RemoteAlbumMetadata, RemoteAlbumSearchResult, RemoteArtistCredit, RemoteMetadataError,
RemoteProvider, RemoteSearchRequest, RemoteSearchResponse, RemoteTrackMetadata,
};
fn musicbrainz_credits(
credits: Option<&Vec<crate::musicbrainz::ArtistCredit>>,
) -> Vec<RemoteArtistCredit> {
credits
.into_iter()
.flatten()
.map(|credit| RemoteArtistCredit {
name: credit
.name
.as_deref()
.unwrap_or(&credit.artist.name)
.to_string(),
join_phrase: credit.joinphrase.clone().unwrap_or_default(),
provider_id: Some(credit.artist.id.clone()),
})
.collect()
}
fn display_credit(credits: &[RemoteArtistCredit]) -> String {
credits
.iter()
.map(|credit| format!("{}{}", credit.name, credit.join_phrase))
.collect()
}
pub fn musicbrainz_release_to_search_result(
release: &crate::musicbrainz::ReleaseResult,
) -> RemoteAlbumSearchResult {
@@ -206,24 +231,8 @@ pub fn discogs_extended_to_search_result(
pub fn musicbrainz_full_to_metadata(
release: &crate::musicbrainz::ReleaseFullResponse,
) -> RemoteAlbumMetadata {
// Extract artist from artist-credit
let artist = release
.artist_credit
.as_ref()
.map(|credits| {
credits
.iter()
.map(|c| {
format!(
"{}{}",
c.name.as_deref().unwrap_or(&c.artist.name),
c.joinphrase.as_deref().unwrap_or("")
)
})
.collect::<Vec<_>>()
.join("")
})
.unwrap_or_default();
let artist_credits = musicbrainz_credits(release.artist_credit.as_ref());
let artist = display_credit(&artist_credits);
// Extract year from date
let year = release
@@ -267,6 +276,12 @@ pub fn musicbrainz_full_to_metadata(
for medium in media {
if let Some(tracks) = &medium.tracks {
for track in tracks {
let track_credits = track
.recording
.as_ref()
.map(|recording| musicbrainz_credits(recording.artist_credit.as_ref()))
.filter(|credits| !credits.is_empty())
.unwrap_or_else(|| artist_credits.clone());
all_tracks.push(RemoteTrackMetadata {
disc_number: medium.position.unwrap_or(1),
track_number: track.position.unwrap_or(1),
@@ -281,6 +296,13 @@ pub fn musicbrainz_full_to_metadata(
.as_ref()
.and_then(|r| r.length.map(|l| l as u32))
}),
artist_credit: display_credit(&track_credits),
artist_credits: track_credits,
recording_id: track
.recording
.as_ref()
.map(|recording| recording.id.clone()),
track_id: track.id.clone(),
});
}
}
@@ -294,6 +316,7 @@ pub fn musicbrainz_full_to_metadata(
provider_id: release.id.clone(),
title: release.title.clone(),
artist,
artist_credits,
year,
genres,
label,
@@ -303,6 +326,7 @@ pub fn musicbrainz_full_to_metadata(
tracks,
disc_count,
source_url: Some(format!("https://musicbrainz.org/release/{}", release.id)),
release_group_id: release.release_group.as_ref().map(|group| group.id.clone()),
}
}
@@ -310,18 +334,21 @@ pub fn musicbrainz_full_to_metadata(
pub fn discogs_full_to_metadata(
release: &crate::discogs::DiscogsReleaseMetadata,
) -> RemoteAlbumMetadata {
// Combine artists with join phrases
let artist = release
let artist_credits = release
.artists
.as_ref()
.map(|artists| {
artists
.iter()
.map(|a| format!("{}{}", a.name.clone(), a.join.as_deref().unwrap_or("")))
.map(|artist| RemoteArtistCredit {
name: artist.name.clone(),
join_phrase: artist.join.clone().unwrap_or_default(),
provider_id: artist.id.map(|id| id.to_string()),
})
.collect::<Vec<_>>()
.join("")
})
.unwrap_or_default();
let artist = display_credit(&artist_credits);
// Combine genres and styles
let genres: Vec<String> = {
@@ -361,6 +388,10 @@ pub fn discogs_full_to_metadata(
track_number,
title: t.title.clone(),
duration_ms: t.duration.as_ref().and_then(|d| parse_discogs_duration(d)),
artist_credit: artist.clone(),
artist_credits: artist_credits.clone(),
recording_id: None,
track_id: None,
}
})
.collect()
@@ -375,6 +406,7 @@ pub fn discogs_full_to_metadata(
provider_id: release.id.to_string(),
title: release.title.clone(),
artist,
artist_credits,
year: release.year.map(|y| y as u16),
genres,
label,
@@ -384,6 +416,7 @@ pub fn discogs_full_to_metadata(
tracks,
disc_count,
source_url: release.uri.clone(),
release_group_id: None,
}
}
@@ -537,7 +570,10 @@ mod orchestration_tests {
#[test]
fn the_artist_is_taken_off_the_front_of_the_query_once() {
assert_eq!(strip_prefix_ci("Tool Fear Inoculum", "Tool"), "Fear Inoculum");
assert_eq!(
strip_prefix_ci("Tool Fear Inoculum", "Tool"),
"Fear Inoculum"
);
assert_eq!(
strip_prefix_ci("Black Sabbath Black Sabbath", "Black Sabbath"),
"Black Sabbath",
@@ -564,4 +600,53 @@ mod orchestration_tests {
"the bare word `limit` is not a 429"
);
}
#[test]
fn musicbrainz_converter_keeps_display_credits_components_and_ids_separate() {
let release: crate::musicbrainz::ReleaseFullResponse = serde_json::from_value(
serde_json::json!({
"id": "release-id",
"title": "Collaborations",
"artist-credit": [
{"name": "Alpha", "joinphrase": " feat. ", "artist": {"id": "alpha-id", "name": "Alpha"}},
{"name": "Beta", "joinphrase": "", "artist": {"id": "beta-id", "name": "Beta"}}
],
"release-group": {"id": "group-id", "primary-type": "Album"},
"media": [{
"position": 1,
"tracks": [{
"id": "track-id",
"position": 1,
"title": "A Song",
"recording": {
"id": "recording-id",
"title": "A Song",
"artist-credit": [
{"name": "Gamma", "joinphrase": "", "artist": {"id": "gamma-id", "name": "Gamma"}}
]
}
}]
}]
}),
)
.unwrap();
let metadata = musicbrainz_full_to_metadata(&release);
assert_eq!(metadata.artist, "Alpha feat. Beta");
assert_eq!(metadata.release_group_id.as_deref(), Some("group-id"));
assert_eq!(
metadata.artist_credits[0].provider_id.as_deref(),
Some("alpha-id")
);
assert_eq!(metadata.tracks[0].artist_credit, "Gamma");
assert_eq!(
metadata.tracks[0].recording_id.as_deref(),
Some("recording-id")
);
assert_eq!(metadata.tracks[0].track_id.as_deref(), Some("track-id"));
assert_eq!(
metadata.tracks[0].artist_credits[0].provider_id.as_deref(),
Some("gamma-id")
);
}
}
@@ -72,6 +72,11 @@ pub struct RemoteAlbumMetadata {
pub title: String,
/// Album artist
pub artist: String,
/// Ordered artist-credit components. `artist` remains the display credit
/// including join phrases; these rows preserve identity without guessing
/// how a human-readable credit should be split.
#[serde(default)]
pub artist_credits: Vec<RemoteArtistCredit>,
/// Release year
pub year: Option<u16>,
/// Genres/styles/tags
@@ -90,6 +95,19 @@ pub struct RemoteAlbumMetadata {
pub disc_count: u8,
/// URL to view on provider website
pub source_url: Option<String>,
/// MusicBrainz release-group identity when the provider exposes it.
#[serde(default)]
pub release_group_id: Option<String>,
}
/// One component of an album or track artist credit.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RemoteArtistCredit {
pub name: String,
#[serde(default)]
pub join_phrase: String,
#[serde(default)]
pub provider_id: Option<String>,
}
/// Single track metadata
@@ -103,6 +121,16 @@ pub struct RemoteTrackMetadata {
pub title: String,
/// Duration in milliseconds (if available)
pub duration_ms: Option<u32>,
/// Display credit, including intentional join phrases.
#[serde(default)]
pub artist_credit: String,
/// Ordered, typed credit components.
#[serde(default)]
pub artist_credits: Vec<RemoteArtistCredit>,
#[serde(default)]
pub recording_id: Option<String>,
#[serde(default)]
pub track_id: Option<String>,
}
/// Search request parameters
+227
View File
@@ -3194,6 +3194,140 @@ impl LibraryDatabase {
Ok(())
}
/// Commit one metadata-editor draft and its optional front cover as a
/// single exact-row transaction. A stale/deleted row aborts the entire
/// draft instead of leaving tags, index metadata and artwork out of sync.
pub fn update_tracks_metadata_and_artwork_by_id(
&mut self,
updates: &[TrackMetadataUpdateFull],
artwork_path: Option<&str>,
) -> Result<(), LibraryError> {
if updates.is_empty() {
return Err(LibraryError::Database(
"Metadata update requires at least one track".to_string(),
));
}
if artwork_path.is_some_and(|path| path.trim().is_empty()) {
return Err(LibraryError::Database(
"Artwork path cannot be blank".to_string(),
));
}
let unique = updates
.iter()
.map(|update| update.id)
.collect::<std::collections::HashSet<_>>();
if unique.len() != updates.len() {
return Err(LibraryError::Database(
"Metadata update contains duplicate track ids".to_string(),
));
}
let tx = self
.conn
.transaction()
.map_err(|error| LibraryError::Database(error.to_string()))?;
{
let mut statement = tx
.prepare(
r#"
UPDATE local_tracks
SET
title = ?1,
artist = ?2,
album = ?3,
album_artist = ?4,
album_group_title = ?5,
track_number = ?6,
disc_number = ?7,
year = ?8,
genre = ?9,
catalog_number = ?10,
artwork_path = COALESCE(?11, artwork_path)
WHERE id = ?12
"#,
)
.map_err(|error| LibraryError::Database(error.to_string()))?;
for update in updates {
let changed = statement
.execute(params![
update.title.trim(),
update.artist.trim(),
update.album.trim(),
update
.album_artist
.as_ref()
.map(|value| value.trim().to_string()),
update.album_group_title.trim(),
update.track_number,
update.disc_number,
update.year,
update.genre.as_ref().map(|value| value.trim().to_string()),
update
.catalog_number
.as_ref()
.map(|value| value.trim().to_string()),
artwork_path,
update.id,
])
.map_err(|error| LibraryError::Database(error.to_string()))?;
if changed != 1 {
return Err(LibraryError::Database(format!(
"Metadata target track {} no longer exists",
update.id
)));
}
}
}
tx.commit()
.map_err(|error| LibraryError::Database(error.to_string()))?;
Ok(())
}
/// Apply an editor-selected cover only to the snapshotted physical rows.
/// Album-group-wide artwork updates are intentionally avoided because a
/// multi-disc collection may carry a different cover on every disc.
pub fn update_tracks_artwork_by_id(
&mut self,
ids: &[i64],
artwork_path: &str,
) -> Result<(), LibraryError> {
if ids.is_empty() || artwork_path.trim().is_empty() {
return Err(LibraryError::Database(
"Artwork update requires track ids and a path".to_string(),
));
}
let unique = ids
.iter()
.copied()
.collect::<std::collections::HashSet<_>>();
if unique.len() != ids.len() {
return Err(LibraryError::Database(
"Artwork update contains duplicate track ids".to_string(),
));
}
let tx = self
.conn
.transaction()
.map_err(|error| LibraryError::Database(error.to_string()))?;
{
let mut statement = tx
.prepare("UPDATE local_tracks SET artwork_path = ?1 WHERE id = ?2")
.map_err(|error| LibraryError::Database(error.to_string()))?;
for id in ids {
let changed = statement
.execute(params![artwork_path, id])
.map_err(|error| LibraryError::Database(error.to_string()))?;
if changed != 1 {
return Err(LibraryError::Database(format!(
"Artwork target track {id} no longer exists"
)));
}
}
}
tx.commit()
.map_err(|error| LibraryError::Database(error.to_string()))?;
Ok(())
}
pub fn find_album_group_key(
&self,
album: &str,
@@ -7671,3 +7805,96 @@ mod audio_format_roundtrip_tests {
assert_eq!(LibraryDatabase::parse_format("Dsd"), AudioFormat::Dsd);
}
}
#[cfg(test)]
mod metadata_editor_transaction_tests {
use super::*;
fn update(id: i64, title: &str) -> TrackMetadataUpdateFull {
TrackMetadataUpdateFull {
id,
title: title.to_string(),
artist: "Artist".to_string(),
album: "Album".to_string(),
album_artist: Some("Artist".to_string()),
album_group_title: "Album".to_string(),
track_number: Some(1),
disc_number: Some(1),
year: Some(2026),
genre: Some("Rock".to_string()),
catalog_number: Some("CAT-1".to_string()),
}
}
#[test]
fn metadata_and_artwork_update_only_exact_rows_and_rollback_on_stale_ids() {
let tmp = tempfile::tempdir().unwrap();
let mut db = LibraryDatabase::open(&tmp.path().join("library.db")).unwrap();
let first = db
.insert_track(&LocalTrack {
file_path: "/music/album/01.flac".to_string(),
title: "One".to_string(),
artist: "Artist".to_string(),
album: "Album".to_string(),
album_group_key: "/music/album".to_string(),
album_group_title: "Album".to_string(),
artwork_path: Some("/art/disc-one.jpg".to_string()),
..LocalTrack::default()
})
.unwrap();
let second = db
.insert_track(&LocalTrack {
file_path: "/music/album/02.flac".to_string(),
title: "Two".to_string(),
artist: "Artist".to_string(),
album: "Album".to_string(),
album_group_key: "/music/album".to_string(),
album_group_title: "Album".to_string(),
artwork_path: Some("/art/disc-two.jpg".to_string()),
..LocalTrack::default()
})
.unwrap();
db.update_tracks_metadata_and_artwork_by_id(
&[update(first, "Edited")],
Some("/art/new.jpg"),
)
.unwrap();
let first_row: (String, String) = db
.conn
.query_row(
"SELECT title, artwork_path FROM local_tracks WHERE id = ?1",
params![first],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.unwrap();
let second_art: String = db
.conn
.query_row(
"SELECT artwork_path FROM local_tracks WHERE id = ?1",
params![second],
|row| row.get(0),
)
.unwrap();
assert_eq!(
first_row,
("Edited".to_string(), "/art/new.jpg".to_string())
);
assert_eq!(second_art, "/art/disc-two.jpg");
let error = db.update_tracks_metadata_and_artwork_by_id(
&[update(first, "Must Roll Back"), update(i64::MAX, "Missing")],
Some("/art/should-not-land.jpg"),
);
assert!(error.is_err());
let after: (String, String) = db
.conn
.query_row(
"SELECT title, artwork_path FROM local_tracks WHERE id = ?1",
params![first],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.unwrap();
assert_eq!(after, first_row);
}
}
+4 -2
View File
@@ -61,9 +61,11 @@ pub use reachability::{probe, probe_default, Reach, DEFAULT_PROBE};
pub use scan::{scan_with_progress, ScanEvent};
pub use sacd::{SacdImageImport, SacdImportResult};
pub use tag_writer::{
compute_track_artist_match, inspect_album_tag_layers, write_album_tags_to_files,
compute_track_artist_match, inspect_album_tag_layers, read_editor_tag_snapshots,
write_album_tags_to_files, write_album_tags_to_files_extended, write_folder_front_cover,
write_album_tags_to_files_with_options, write_purchase_tags, AlbumTagInspection,
AlbumTagWrite, DirectTagWriteOptions, Id3v2WriteVersion, PurchaseTagWrite,
AlbumTagWrite, DirectTagWriteOptions, EditorTrackTagSnapshot, ExtendedAlbumTagWrite,
ExtendedTrackTagWrite, FrontCoverWrite, Id3v2WriteVersion, PurchaseTagWrite,
TagLayerInspection, TrackTagWrite,
};
pub use scanner::{
+20
View File
@@ -957,6 +957,26 @@ impl MetadataExtractor {
}
}
/// Generate a stable thumbnail for artwork bytes whose source may be a
/// temporary editor staging file. The content hash is part of the cache
/// key, so replacing an album cover cannot reuse the thumbnail generated
/// for the previous image at the same audio or folder path.
pub fn cache_artwork_bytes(bytes: &[u8]) -> Option<String> {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
bytes.hash(&mut hasher);
let cache_key = format!("metadata-editor-artwork-{:016x}", hasher.finish());
match generate_thumbnail_from_bytes(bytes, &cache_key) {
Ok(thumbnail_path) => Some(thumbnail_path.to_string_lossy().to_string()),
Err(error) => {
log::warn!("Failed to generate edited-art thumbnail: {}", error);
None
}
}
}
/// Look for folder artwork by file name heuristics
/// A cover living in THIS directory, ignoring the album root.
///
+125
View File
@@ -32,6 +32,42 @@ pub struct TrackMetadataOverride {
pub track_number: Option<u32>,
}
/// Lossless editor fields which the flat local-library index does not yet
/// project. Keeping them in sidecars prevents a local-only user from losing
/// ordered credits or provider identifiers merely because they chose the
/// non-destructive persistence mode.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct AlbumExtendedMetadataOverride {
#[serde(default)]
pub album_artists: Vec<String>,
pub compilation: Option<bool>,
pub musicbrainz_release_id: Option<String>,
pub musicbrainz_release_group_id: Option<String>,
#[serde(default)]
pub musicbrainz_album_artist_ids: Vec<String>,
pub discogs_release_id: Option<String>,
pub artwork_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct TrackExtendedMetadataOverride {
pub file_path: String,
pub cue_start_secs: Option<f64>,
pub artist_credit: String,
#[serde(default)]
pub artists: Vec<String>,
#[serde(default)]
pub composers: Vec<String>,
#[serde(default)]
pub performers: Vec<String>,
pub musicbrainz_recording_id: Option<String>,
pub musicbrainz_track_id: Option<String>,
#[serde(default)]
pub musicbrainz_artist_ids: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AlbumTagSidecar {
@@ -39,6 +75,10 @@ pub struct AlbumTagSidecar {
pub updated_at: i64,
pub album: AlbumMetadataOverride,
pub tracks: Vec<TrackMetadataOverride>,
#[serde(default)]
pub extended_album: Option<AlbumExtendedMetadataOverride>,
#[serde(default)]
pub extended_tracks: Vec<TrackExtendedMetadataOverride>,
}
impl AlbumTagSidecar {
@@ -53,8 +93,21 @@ impl AlbumTagSidecar {
updated_at: now,
album,
tracks,
extended_album: None,
extended_tracks: Vec::new(),
}
}
pub fn with_extended(
mut self,
album: AlbumExtendedMetadataOverride,
tracks: Vec<TrackExtendedMetadataOverride>,
) -> Self {
self.version = 2;
self.extended_album = Some(album);
self.extended_tracks = tracks;
self
}
}
pub fn sidecar_path(album_dir: &Path) -> PathBuf {
@@ -125,6 +178,16 @@ pub fn apply_sidecar_to_track(track: &mut LocalTrack, sidecar: &AlbumTagSidecar)
track.catalog_number = normalize(catalog_number);
}
if let Some(extended) = sidecar.extended_album.as_ref() {
if let Some(path) = extended
.artwork_path
.as_ref()
.and_then(|path| normalize(path))
{
track.artwork_path = Some(path);
}
}
if let Some(entry) = sidecar.tracks.iter().find(|t| {
t.file_path == track.file_path
&& match (t.cue_start_secs, track.cue_start_secs) {
@@ -143,6 +206,19 @@ pub fn apply_sidecar_to_track(track: &mut LocalTrack, sidecar: &AlbumTagSidecar)
track.track_number = (no != 0).then_some(no);
}
}
if let Some(entry) = sidecar.extended_tracks.iter().find(|entry| {
entry.file_path == track.file_path
&& match (entry.cue_start_secs, track.cue_start_secs) {
(Some(a), Some(b)) => (a - b).abs() < 0.001,
(None, None) => true,
_ => false,
}
}) {
if let Some(artist) = normalize(&entry.artist_credit) {
track.artist = artist;
}
}
}
fn normalize(value: &str) -> Option<String> {
@@ -230,4 +306,53 @@ mod tests {
assert_eq!(track.disc_number, Some(2));
assert_eq!(track.track_number, Some(1));
}
#[test]
fn legacy_v1_json_deserializes_without_extended_fields() {
let json = r#"{
"version": 1,
"updatedAt": 42,
"album": {},
"tracks": []
}"#;
let sidecar: AlbumTagSidecar = serde_json::from_str(json).unwrap();
assert!(sidecar.extended_album.is_none());
assert!(sidecar.extended_tracks.is_empty());
}
#[test]
fn v2_roundtrip_preserves_ordered_credits_and_provider_ids() {
let sidecar = AlbumTagSidecar::new(AlbumMetadataOverride::default(), Vec::new())
.with_extended(
AlbumExtendedMetadataOverride {
album_artists: vec!["Alpha".into(), "Beta".into()],
compilation: Some(true),
musicbrainz_release_id: Some("release".into()),
musicbrainz_release_group_id: Some("group".into()),
musicbrainz_album_artist_ids: vec!["a".into(), "b".into()],
discogs_release_id: Some("123".into()),
artwork_path: Some("/cache/cover.jpg".into()),
},
vec![TrackExtendedMetadataOverride {
file_path: "/music/01.flac".into(),
cue_start_secs: None,
artist_credit: "Alpha feat. Beta".into(),
artists: vec!["Alpha".into(), "Beta".into()],
composers: vec!["Composer".into()],
performers: vec!["Player (guitar)".into()],
musicbrainz_recording_id: Some("recording".into()),
musicbrainz_track_id: Some("track".into()),
musicbrainz_artist_ids: vec!["a".into(), "b".into()],
}],
);
let json = serde_json::to_string(&sidecar).unwrap();
let decoded: AlbumTagSidecar = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.version, 2);
assert_eq!(
decoded.extended_album.unwrap().album_artists,
["Alpha", "Beta"]
);
assert_eq!(decoded.extended_tracks[0].artist_credit, "Alpha feat. Beta");
}
}
+538 -15
View File
@@ -4,7 +4,7 @@
//! reported through an `on_progress` closure (no Tauri event bus); the caller
//! orchestrates the DB update + sidecar removal.
use std::collections::{BTreeMap, HashSet};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fs::OpenOptions;
use std::path::Path;
@@ -79,10 +79,7 @@ fn tag_has_editor_conflict(tagged_file: &lofty::file::TaggedFile) -> bool {
use lofty::prelude::*;
use lofty::tag::ItemKey;
fn distinct_text(
tagged_file: &lofty::file::TaggedFile,
key: ItemKey,
) -> bool {
fn distinct_text(tagged_file: &lofty::file::TaggedFile, key: ItemKey) -> bool {
let mut values = Vec::<String>::new();
for tag in tagged_file.tags() {
if let Some(value) = tag
@@ -214,6 +211,7 @@ pub fn inspect_album_tag_layers(paths: &[String]) -> Result<AlbumTagInspection,
/// Album-level fields written into every file's embedded tags. A `None`
/// (or blank) field REMOVES that tag (direct write is destructive, unlike the
/// override-only sidecar).
#[derive(Debug, Clone)]
pub struct AlbumTagWrite {
pub album_title: String,
pub album_artist: String, // "" => remove the AlbumArtist tag
@@ -223,6 +221,7 @@ pub struct AlbumTagWrite {
}
/// One file's per-track fields.
#[derive(Debug, Clone)]
pub struct TrackTagWrite {
pub file_path: String,
pub title: String,
@@ -230,6 +229,171 @@ pub struct TrackTagWrite {
pub disc_number: Option<u32>,
}
/// Standards-oriented album fields owned by the expanded Qt editor.
///
/// `album_artist` in [`AlbumTagWrite`] remains the human-readable credit.
/// `album_artists` stores the ordered, lossless components in the dedicated
/// ALBUMARTISTS key where the target tag format supports it.
#[derive(Debug, Clone, Default)]
pub struct ExtendedAlbumTagWrite {
pub album_artists: Vec<String>,
pub compilation: Option<bool>,
pub musicbrainz_release_id: Option<String>,
pub musicbrainz_release_group_id: Option<String>,
pub musicbrainz_album_artist_ids: Vec<String>,
/// Provenance retained by the sidecar. There is no interoperable Discogs
/// key in Lofty's generic tag map, so it is deliberately not embedded.
pub discogs_release_id: Option<String>,
}
/// Standards-oriented fields for one physical track.
#[derive(Debug, Clone, Default)]
pub struct ExtendedTrackTagWrite {
pub file_path: String,
/// Display credit, including intentional join phrases such as "A feat. B".
pub artist_credit: String,
/// Ordered components written to ARTISTS independently from ARTIST.
pub artists: Vec<String>,
pub composers: Vec<String>,
pub performers: Vec<String>,
pub musicbrainz_recording_id: Option<String>,
pub musicbrainz_track_id: Option<String>,
pub musicbrainz_artist_ids: Vec<String>,
}
/// A validated front-cover candidate. Callers stage bytes before save; the
/// writer never follows a QML-provided path or URL while mutating audio files.
#[derive(Debug, Clone)]
pub struct FrontCoverWrite {
pub bytes: Vec<u8>,
}
/// Decode and atomically install a conventional folder front cover.
/// The old file is restored if the final rename fails.
pub fn write_folder_front_cover(album_dir: &Path, bytes: &[u8]) -> Result<String, LibraryError> {
if bytes.is_empty() || bytes.len() > 25 * 1024 * 1024 {
return Err(LibraryError::Metadata(
"The selected cover is empty or larger than 25 MiB.".to_string(),
));
}
let image = image::load_from_memory(bytes).map_err(|error| {
LibraryError::Metadata(format!("The selected cover could not be decoded: {error}"))
})?;
if image.width() == 0
|| image.height() == 0
|| image.width() > 12_000
|| image.height() > 12_000
{
return Err(LibraryError::Metadata(
"The selected cover has unsupported dimensions.".to_string(),
));
}
let target = album_dir.join("cover.jpg");
let temporary = album_dir.join(".cover.jpg.qbz-tmp");
let backup = album_dir.join(".cover.jpg.qbz-backup");
image
.save_with_format(&temporary, image::ImageFormat::Jpeg)
.map_err(|error| LibraryError::Metadata(format!("Cover encode failed: {error}")))?;
let had_target = target.is_file();
if had_target {
if backup.exists() {
std::fs::remove_file(&backup).map_err(LibraryError::Io)?;
}
std::fs::rename(&target, &backup).map_err(LibraryError::Io)?;
}
if let Err(error) = std::fs::rename(&temporary, &target) {
if had_target {
let _ = std::fs::rename(&backup, &target);
}
return Err(LibraryError::Io(error));
}
if had_target && backup.exists() {
std::fs::remove_file(&backup).map_err(LibraryError::Io)?;
}
Ok(target.to_string_lossy().to_string())
}
/// Rich values read from one file's canonical tag for editor seeding.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EditorTrackTagSnapshot {
pub file_path: String,
pub artist_credit: String,
pub artists: Vec<String>,
pub album_artists: Vec<String>,
pub composers: Vec<String>,
pub performers: Vec<String>,
pub compilation: Option<bool>,
pub musicbrainz_release_id: Option<String>,
pub musicbrainz_release_group_id: Option<String>,
pub musicbrainz_recording_id: Option<String>,
pub musicbrainz_track_id: Option<String>,
pub musicbrainz_artist_ids: Vec<String>,
pub musicbrainz_album_artist_ids: Vec<String>,
}
fn normalized_values(tag: &lofty::tag::Tag, key: lofty::tag::ItemKey) -> Vec<String> {
let mut seen = HashSet::<String>::new();
tag.get_strings(key)
.map(str::trim)
.filter(|value| !value.is_empty())
.filter_map(|value| {
let folded = value.to_lowercase();
seen.insert(folded).then(|| value.to_string())
})
.collect()
}
fn normalized_optional(tag: &lofty::tag::Tag, key: lofty::tag::ItemKey) -> Option<String> {
normalized_values(tag, key).into_iter().next()
}
/// Read the canonical rich-tag projection used by the Qt editor.
/// Unreadable rows return an empty snapshot rather than borrowing values from
/// a neighbouring file; the ordinary editor preflight reports writability.
pub fn read_editor_tag_snapshots(paths: &[String]) -> Vec<EditorTrackTagSnapshot> {
use lofty::prelude::*;
use lofty::tag::ItemKey;
paths
.iter()
.map(|path| {
let mut snapshot = EditorTrackTagSnapshot {
file_path: path.clone(),
..EditorTrackTagSnapshot::default()
};
let Ok(file) = lofty::read_from_path(Path::new(path)) else {
return snapshot;
};
let Some(tag) = file.primary_tag().or_else(|| file.first_tag()) else {
return snapshot;
};
snapshot.artist_credit =
normalized_optional(tag, ItemKey::TrackArtist).unwrap_or_default();
snapshot.artists = normalized_values(tag, ItemKey::TrackArtists);
if snapshot.artists.is_empty() && !snapshot.artist_credit.is_empty() {
snapshot.artists.push(snapshot.artist_credit.clone());
}
snapshot.album_artists = normalized_values(tag, ItemKey::AlbumArtists);
snapshot.composers = normalized_values(tag, ItemKey::Composer);
snapshot.performers = normalized_values(tag, ItemKey::Performer);
snapshot.compilation = normalized_optional(tag, ItemKey::FlagCompilation)
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes"));
snapshot.musicbrainz_release_id =
normalized_optional(tag, ItemKey::MusicBrainzReleaseId);
snapshot.musicbrainz_release_group_id =
normalized_optional(tag, ItemKey::MusicBrainzReleaseGroupId);
snapshot.musicbrainz_recording_id =
normalized_optional(tag, ItemKey::MusicBrainzRecordingId);
snapshot.musicbrainz_track_id = normalized_optional(tag, ItemKey::MusicBrainzTrackId);
snapshot.musicbrainz_artist_ids = normalized_values(tag, ItemKey::MusicBrainzArtistId);
snapshot.musicbrainz_album_artist_ids =
normalized_values(tag, ItemKey::MusicBrainzReleaseArtistId);
snapshot
})
.collect()
}
/// Should this file's per-track ARTIST be rewritten to the album artist?
///
/// ARTIST is TRACK scope — a release with guest performers legitimately holds a
@@ -351,6 +515,88 @@ fn apply_editor_fields(
}
}
fn replace_text_values(tag: &mut lofty::tag::Tag, key: lofty::tag::ItemKey, values: &[String]) {
use lofty::tag::{ItemValue, TagItem};
tag.remove_key(key);
for value in values
.iter()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
{
tag.push(TagItem::new(key, ItemValue::Text(value.to_string())));
}
}
fn replace_optional_text(tag: &mut lofty::tag::Tag, key: lofty::tag::ItemKey, value: Option<&str>) {
let values = value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| vec![value.to_string()])
.unwrap_or_default();
replace_text_values(tag, key, &values);
}
fn apply_extended_editor_fields(
tag: &mut lofty::tag::Tag,
album: &ExtendedAlbumTagWrite,
track: &ExtendedTrackTagWrite,
artwork: Option<&lofty::picture::Picture>,
) {
use lofty::picture::PictureType;
use lofty::prelude::*;
use lofty::tag::ItemKey;
if !track.artist_credit.trim().is_empty() {
tag.set_artist(track.artist_credit.trim().to_string());
} else {
tag.remove_key(ItemKey::TrackArtist);
}
replace_text_values(tag, ItemKey::TrackArtists, &track.artists);
replace_text_values(tag, ItemKey::AlbumArtists, &album.album_artists);
replace_text_values(tag, ItemKey::Composer, &track.composers);
replace_text_values(tag, ItemKey::Performer, &track.performers);
match album.compilation {
Some(true) => replace_optional_text(tag, ItemKey::FlagCompilation, Some("1")),
Some(false) => tag.remove_key(ItemKey::FlagCompilation),
None => {}
}
replace_optional_text(
tag,
ItemKey::MusicBrainzReleaseId,
album.musicbrainz_release_id.as_deref(),
);
replace_optional_text(
tag,
ItemKey::MusicBrainzReleaseGroupId,
album.musicbrainz_release_group_id.as_deref(),
);
replace_text_values(
tag,
ItemKey::MusicBrainzReleaseArtistId,
&album.musicbrainz_album_artist_ids,
);
replace_optional_text(
tag,
ItemKey::MusicBrainzRecordingId,
track.musicbrainz_recording_id.as_deref(),
);
replace_optional_text(
tag,
ItemKey::MusicBrainzTrackId,
track.musicbrainz_track_id.as_deref(),
);
replace_text_values(
tag,
ItemKey::MusicBrainzArtistId,
&track.musicbrainz_artist_ids,
);
if let Some(picture) = artwork {
tag.remove_picture_type(PictureType::CoverFront);
tag.push_picture(picture.clone());
}
}
fn normalized_tag_text(tag: &lofty::tag::Tag, key: lofty::tag::ItemKey) -> Option<String> {
tag.get_string(key)
.map(str::trim)
@@ -363,6 +609,9 @@ fn verify_editor_write(
album: &AlbumTagWrite,
track: &TrackTagWrite,
expected_artist: Option<&str>,
extended_album: Option<&ExtendedAlbumTagWrite>,
extended_track: Option<&ExtendedTrackTagWrite>,
expected_artwork: Option<&[u8]>,
) -> Result<(), LibraryError> {
use lofty::prelude::*;
use lofty::tag::ItemKey;
@@ -393,10 +642,10 @@ fn verify_editor_write(
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let expected_album_artist = (!album.album_artist.trim().is_empty())
.then(|| album.album_artist.trim().to_string());
let expected_album_artist =
(!album.album_artist.trim().is_empty()).then(|| album.album_artist.trim().to_string());
let matches = tag.title().as_deref().map(str::trim) == Some(track.title.trim())
let mut matches = tag.title().as_deref().map(str::trim) == Some(track.title.trim())
&& tag.album().as_deref().map(str::trim) == Some(album.album_title.trim())
&& tag.track() == track.track_number
&& tag.disk() == track.disc_number
@@ -407,6 +656,62 @@ fn verify_editor_write(
&& expected_artist.map_or(true, |artist| {
tag.artist().as_deref().map(str::trim) == Some(artist)
});
if let (Some(album), Some(track)) = (extended_album, extended_track) {
let supports = |key: ItemKey| key.map_key(tag.tag_type()).is_some();
let values_match = |key: ItemKey, expected: &[String]| {
!supports(key) || normalized_values(tag, key) == expected
};
let option_match = |key: ItemKey, expected: Option<&str>| {
!supports(key)
|| normalized_optional(tag, key).as_deref()
== expected.map(str::trim).filter(|value| !value.is_empty())
};
matches = matches
&& tag.artist().as_deref().map(str::trim) == Some(track.artist_credit.trim())
&& values_match(ItemKey::TrackArtists, &track.artists)
&& values_match(ItemKey::AlbumArtists, &album.album_artists)
&& values_match(ItemKey::Composer, &track.composers)
&& values_match(ItemKey::Performer, &track.performers)
&& option_match(
ItemKey::MusicBrainzReleaseId,
album.musicbrainz_release_id.as_deref(),
)
&& option_match(
ItemKey::MusicBrainzReleaseGroupId,
album.musicbrainz_release_group_id.as_deref(),
)
&& values_match(
ItemKey::MusicBrainzReleaseArtistId,
&album.musicbrainz_album_artist_ids,
)
&& option_match(
ItemKey::MusicBrainzRecordingId,
track.musicbrainz_recording_id.as_deref(),
)
&& option_match(
ItemKey::MusicBrainzTrackId,
track.musicbrainz_track_id.as_deref(),
)
&& values_match(ItemKey::MusicBrainzArtistId, &track.musicbrainz_artist_ids);
if let Some(compilation) = album.compilation {
matches = matches
&& (!supports(ItemKey::FlagCompilation)
|| normalized_optional(tag, ItemKey::FlagCompilation)
.map(|value| {
matches!(value.to_ascii_lowercase().as_str(), "1" | "true" | "yes")
})
.unwrap_or(false)
== compilation);
}
}
if let Some(bytes) = expected_artwork {
use lofty::picture::PictureType;
matches = matches
&& tag
.get_picture_type(PictureType::CoverFront)
.map(|picture| picture.data())
== Some(bytes);
}
if matches {
Ok(())
} else {
@@ -430,9 +735,13 @@ fn verify_editor_write(
/// `synchronize_secondary_tags`, existing writable modern layers are updated
/// too; ID3v1 stays untouched because its 30-byte/Latin-1 limits would silently
/// truncate or corrupt values that are valid in the canonical tag.
pub fn write_album_tags_to_files_with_options(
fn write_album_tags_impl(
album: &AlbumTagWrite,
extended_album: &ExtendedAlbumTagWrite,
tracks: &[TrackTagWrite],
extended_tracks: &[ExtendedTrackTagWrite],
front_cover: Option<&FrontCoverWrite>,
apply_extended: bool,
options: DirectTagWriteOptions,
mut on_progress: impl FnMut(usize, usize),
) -> Result<(), LibraryError> {
@@ -440,6 +749,32 @@ pub fn write_album_tags_to_files_with_options(
use lofty::prelude::*;
use lofty::tag::{Tag, TagType};
let extended_by_path = extended_tracks
.iter()
.map(|track| (track.file_path.as_str(), track))
.collect::<HashMap<_, _>>();
if apply_extended
&& tracks
.iter()
.any(|track| !extended_by_path.contains_key(track.file_path.as_str()))
{
return Err(LibraryError::Metadata(
"The extended tag rows do not match the audio rows.".to_string(),
));
}
let artwork = front_cover
.map(|cover| {
let mut picture = lofty::picture::Picture::from_reader(&mut cover.bytes.as_slice())
.map_err(|error| {
LibraryError::Metadata(format!(
"The selected cover is not a supported image: {error}"
))
})?;
picture.set_pic_type(lofty::picture::PictureType::CoverFront);
Ok::<_, LibraryError>(picture)
})
.transpose()?;
let mut seen = HashSet::new();
let unique: Vec<&TrackTagWrite> = tracks
.iter()
@@ -539,6 +874,12 @@ pub fn write_album_tags_to_files_with_options(
))
})?;
apply_editor_fields(tag, album, track, previous_artist.as_deref());
if apply_extended {
let extended_track = extended_by_path
.get(track.file_path.as_str())
.expect("validated extended editor row");
apply_extended_editor_fields(tag, extended_album, extended_track, artwork.as_ref());
}
}
prepared.push((track, tagged_file, expected_artist));
@@ -561,13 +902,77 @@ pub fn write_album_tags_to_files_with_options(
path.display()
))
})?;
verify_editor_write(path, album, track, expected_artist.as_deref())?;
let extended_track = apply_extended.then(|| {
extended_by_path
.get(track.file_path.as_str())
.copied()
.expect("validated extended editor row")
});
verify_editor_write(
path,
album,
track,
expected_artist.as_deref(),
apply_extended.then_some(extended_album),
extended_track,
apply_extended
.then(|| front_cover.map(|cover| cover.bytes.as_slice()))
.flatten(),
)?;
on_progress(index + 1, total);
}
Ok(())
}
/// Write the editor's canonical display fields, ordered artist components,
/// credits, standard MusicBrainz identifiers and an optional front cover.
pub fn write_album_tags_to_files_extended(
album: &AlbumTagWrite,
extended_album: &ExtendedAlbumTagWrite,
tracks: &[TrackTagWrite],
extended_tracks: &[ExtendedTrackTagWrite],
front_cover: Option<&FrontCoverWrite>,
options: DirectTagWriteOptions,
on_progress: impl FnMut(usize, usize),
) -> Result<(), LibraryError> {
write_album_tags_impl(
album,
extended_album,
tracks,
extended_tracks,
front_cover,
true,
options,
on_progress,
)
}
pub fn write_album_tags_to_files_with_options(
album: &AlbumTagWrite,
tracks: &[TrackTagWrite],
options: DirectTagWriteOptions,
on_progress: impl FnMut(usize, usize),
) -> Result<(), LibraryError> {
let extended_tracks = tracks
.iter()
.map(|track| ExtendedTrackTagWrite {
file_path: track.file_path.clone(),
..ExtendedTrackTagWrite::default()
})
.collect::<Vec<_>>();
write_album_tags_impl(
album,
&ExtendedAlbumTagWrite::default(),
tracks,
&extended_tracks,
None,
false,
options,
on_progress,
)
}
/// Backwards-compatible default used by the frozen Slint frontend: canonical
/// tag, ID3v2.4, secondary layers preserved.
pub fn write_album_tags_to_files(
@@ -734,7 +1139,12 @@ pub fn write_purchase_tags(
..Default::default()
});
}
if let Some(genre) = meta.genre.as_deref().map(str::trim).filter(|g| !g.is_empty()) {
if let Some(genre) = meta
.genre
.as_deref()
.map(str::trim)
.filter(|g| !g.is_empty())
{
tag.set_genre(genre.to_string());
}
@@ -972,6 +1382,103 @@ mod editor_write_target_tests {
assert_eq!(tag.disk(), None);
assert_eq!(progress, vec![(1, 1)]);
}
#[test]
fn rich_writer_preserves_display_credit_components_ids_and_front_cover() {
use lofty::picture::PictureType;
use lofty::prelude::*;
use std::io::Cursor;
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("credits.flac");
std::fs::write(&file, flac_with_leading_id3v2()).unwrap();
let mut cover = Vec::new();
image::DynamicImage::new_rgb8(2, 2)
.write_to(&mut Cursor::new(&mut cover), image::ImageFormat::Png)
.unwrap();
let album = AlbumTagWrite {
album_title: "Compilation".to_string(),
album_artist: "Various Artists".to_string(),
year: Some(2026),
genre: Some("Soundtrack".to_string()),
catalog_number: Some("CAT-42".to_string()),
};
let extended_album = ExtendedAlbumTagWrite {
album_artists: vec!["Various Artists".to_string()],
compilation: Some(true),
musicbrainz_release_id: Some("release-id".to_string()),
musicbrainz_release_group_id: Some("group-id".to_string()),
musicbrainz_album_artist_ids: vec!["va-id".to_string()],
discogs_release_id: Some("123".to_string()),
};
let tracks = vec![TrackTagWrite {
file_path: file.to_string_lossy().to_string(),
title: "Collaboration".to_string(),
track_number: Some(1),
disc_number: Some(1),
}];
let rich_tracks = vec![ExtendedTrackTagWrite {
file_path: tracks[0].file_path.clone(),
artist_credit: "Alpha feat. Beta".to_string(),
artists: vec!["Alpha".to_string(), "Beta".to_string()],
composers: vec!["Composer One".to_string()],
performers: vec!["Player One (guitar)".to_string()],
musicbrainz_recording_id: Some("recording-id".to_string()),
musicbrainz_track_id: Some("track-id".to_string()),
musicbrainz_artist_ids: vec!["alpha-id".to_string(), "beta-id".to_string()],
}];
write_album_tags_to_files_extended(
&album,
&extended_album,
&tracks,
&rich_tracks,
Some(&FrontCoverWrite {
bytes: cover.clone(),
}),
DirectTagWriteOptions::default(),
|_, _| {},
)
.unwrap();
let snapshot = read_editor_tag_snapshots(
&tracks
.iter()
.map(|t| t.file_path.clone())
.collect::<Vec<_>>(),
);
assert_eq!(snapshot[0].artist_credit, "Alpha feat. Beta");
assert_eq!(snapshot[0].artists, ["Alpha", "Beta"]);
assert_eq!(snapshot[0].album_artists, ["Various Artists"]);
assert_eq!(snapshot[0].composers, ["Composer One"]);
assert_eq!(
snapshot[0].musicbrainz_release_id.as_deref(),
Some("release-id")
);
assert_eq!(
snapshot[0].musicbrainz_recording_id.as_deref(),
Some("recording-id")
);
assert_eq!(snapshot[0].musicbrainz_artist_ids, ["alpha-id", "beta-id"]);
let after = lofty::read_from_path(&file).unwrap();
assert_eq!(
after
.primary_tag()
.and_then(|tag| tag.get_picture_type(PictureType::CoverFront))
.map(|picture| picture.data()),
Some(cover.as_slice())
);
}
#[test]
fn folder_cover_rejects_invalid_bytes_without_replacing_existing_art() {
let tmp = tempfile::tempdir().unwrap();
let existing = tmp.path().join("cover.jpg");
std::fs::write(&existing, b"existing cover").unwrap();
assert!(write_folder_front_cover(tmp.path(), b"not an image").is_err());
assert_eq!(std::fs::read(existing).unwrap(), b"existing cover");
}
}
#[cfg(test)]
@@ -1001,13 +1508,29 @@ mod tests {
// is the data loss this guard exists to stop.
assert!(!should_rename_artist(Some("MAKE-UP"), None, ""));
assert!(!should_rename_artist(Some("MAKE-UP"), Some("MAKE-UP"), ""));
assert!(!should_rename_artist(Some("MAKE-UP"), Some("MAKE-UP"), " "));
assert!(!should_rename_artist(
Some("MAKE-UP"),
Some("MAKE-UP"),
" "
));
}
#[test]
fn no_uniform_prior_artist_means_nothing_is_renamed() {
assert!(!should_rename_artist(Some("MAKE-UP"), None, "Various Artists"));
assert!(!should_rename_artist(Some("MAKE-UP"), Some(""), "Various Artists"));
assert!(!should_rename_artist(None, Some("MAKE-UP"), "Yokoyama Seiji"));
assert!(!should_rename_artist(
Some("MAKE-UP"),
None,
"Various Artists"
));
assert!(!should_rename_artist(
Some("MAKE-UP"),
Some(""),
"Various Artists"
));
assert!(!should_rename_artist(
None,
Some("MAKE-UP"),
"Yokoyama Seiji"
));
}
}
+571 -54
View File
@@ -1,6 +1,7 @@
// Local album metadata editor. The selected physical version is immutable for
// the lifetime of this modal: QML edits row ids and values only; Rust owns the
// file paths, preflights the whole batch and verifies direct writes.
// Full-page local album metadata editor. The historical filename is retained
// so existing qrc registrations stay stable; ContentRouter is its only mount.
// The selected physical version is immutable for the lifetime of this view:
// QML edits row ids and values only; Rust owns paths and verifies direct writes.
import QtQuick
import com.blitzfc.qbz
@@ -8,12 +9,13 @@ import "../theme"
Item {
id: root
visible: QbzTagEditor.editorOpen
enabled: visible
z: 3150
visible: true
enabled: true
property string albumTitle: ""
property string albumArtist: ""
property string albumArtistsText: ""
property bool compilation: false
property string year: ""
property string genre: ""
property string catalogNumber: ""
@@ -28,11 +30,27 @@ Item {
property var remoteResults: []
property var remoteMetadata: null
property string remoteProvider: "musicbrainz"
property string musicbrainzReleaseId: ""
property string musicbrainzReleaseGroupId: ""
property string musicbrainzAlbumArtistIdsText: ""
property string discogsReleaseId: ""
property var artwork: ({})
property var artworkResults: []
property string artworkProvider: "musicbrainz"
property int selectedTrackIndex: -1
function tr(s) { return QbzSession.tr(s, QbzSession.trRev) }
function parse(value) {
try { return JSON.parse(value) } catch (e) { return ({}) }
}
function listText(values) {
return (values || []).join("; ")
}
function splitList(value) {
return String(value || "").split(/[;\n]/).map(function(part) {
return part.trim()
}).filter(function(part) { return part.length > 0 })
}
function cloneRows(rows) {
var copy = []
for (var i = 0; i < rows.length; ++i) {
@@ -42,6 +60,13 @@ Item {
"title": rows[i].title || "",
"trackNumber": rows[i].trackNumber || "",
"discNumber": rows[i].discNumber || "",
"artistCredit": rows[i].artistCredit || "",
"artists": (rows[i].artists || []).slice(),
"composers": (rows[i].composers || []).slice(),
"performers": (rows[i].performers || []).slice(),
"musicbrainzRecordingId": rows[i].musicbrainzRecordingId || "",
"musicbrainzTrackId": rows[i].musicbrainzTrackId || "",
"musicbrainzArtistIds": (rows[i].musicbrainzArtistIds || []).slice(),
"cueBased": rows[i].cueBased === true
})
}
@@ -60,9 +85,17 @@ Item {
return
albumTitle = doc.albumTitle || ""
albumArtist = doc.albumArtist || ""
albumArtistsText = listText(doc.albumArtists)
compilation = doc.compilation === true
year = doc.year || ""
genre = doc.genre || ""
catalogNumber = doc.catalogNumber || ""
musicbrainzReleaseId = doc.musicbrainzReleaseId || ""
musicbrainzReleaseGroupId = doc.musicbrainzReleaseGroupId || ""
musicbrainzAlbumArtistIdsText = listText(doc.musicbrainzAlbumArtistIds)
discogsReleaseId = doc.discogsReleaseId || ""
artwork = doc.artwork || ({})
artworkResults = []
tracks = cloneRows(doc.tracks)
inspection = doc.inspection || ({})
canDirectWrite = doc.canDirectWrite === true
@@ -72,6 +105,7 @@ Item {
syncSecondary = false
remoteResults = []
remoteMetadata = null
selectedTrackIndex = tracks.length > 0 ? 0 : -1
seeded = true
keyScope.forceActiveFocus()
}
@@ -81,15 +115,29 @@ Item {
"id": row.id,
"title": row.title,
"trackNumber": row.trackNumber,
"discNumber": row.discNumber
"discNumber": row.discNumber,
"artistCredit": row.artistCredit,
"artists": row.artists || [],
"composers": row.composers || [],
"performers": row.performers || [],
"musicbrainzRecordingId": row.musicbrainzRecordingId || "",
"musicbrainzTrackId": row.musicbrainzTrackId || "",
"musicbrainzArtistIds": row.musicbrainzArtistIds || []
}
})
return JSON.stringify({
"albumTitle": albumTitle,
"albumArtist": albumArtist,
"albumArtists": splitList(albumArtistsText),
"compilation": compilation,
"year": year,
"genre": genre,
"catalogNumber": catalogNumber,
"musicbrainzReleaseId": musicbrainzReleaseId,
"musicbrainzReleaseGroupId": musicbrainzReleaseGroupId,
"musicbrainzAlbumArtistIds": splitList(musicbrainzAlbumArtistIdsText),
"discogsReleaseId": discogsReleaseId,
"artworkToken": artwork.token || "",
"persistence": persistence,
"id3v2Version": id3Version,
"synchronizeSecondaryTags": syncSecondary,
@@ -110,9 +158,20 @@ Item {
return
if (m.title) albumTitle = m.title
if (m.artist) albumArtist = m.artist
if (m.artist_credits && m.artist_credits.length)
albumArtistsText = listText(m.artist_credits.map(function(credit) { return credit.name }))
year = m.year ? String(m.year) : ""
genre = m.genres && m.genres.length ? m.genres.join("; ") : ""
catalogNumber = m.catalog_number || ""
if (String(m.provider) === "musicbrainz") {
musicbrainzReleaseId = m.provider_id || ""
musicbrainzReleaseGroupId = m.release_group_id || ""
musicbrainzAlbumArtistIdsText = listText((m.artist_credits || []).map(function(credit) {
return credit.provider_id || ""
}).filter(function(value) { return value.length > 0 }))
} else if (String(m.provider) === "discogs") {
discogsReleaseId = m.provider_id || ""
}
var byPosition = ({})
var remoteTracks = m.tracks || []
@@ -131,6 +190,15 @@ Item {
next[i].title = match.title || next[i].title
next[i].discNumber = String(match.disc_number || next[i].discNumber)
next[i].trackNumber = String(match.track_number || next[i].trackNumber)
next[i].artistCredit = match.artist_credit || next[i].artistCredit
if (match.artist_credits && match.artist_credits.length) {
next[i].artists = match.artist_credits.map(function(credit) { return credit.name })
next[i].musicbrainzArtistIds = match.artist_credits.map(function(credit) {
return credit.provider_id || ""
}).filter(function(value) { return value.length > 0 })
}
next[i].musicbrainzRecordingId = match.recording_id || next[i].musicbrainzRecordingId
next[i].musicbrainzTrackId = match.track_id || next[i].musicbrainzTrackId
}
}
tracks = next
@@ -150,19 +218,19 @@ Item {
}
}
onVisibleChanged: {
if (visible && !QbzTagEditor.editorLoading)
Component.onCompleted: {
if (!QbzTagEditor.editorLoading)
seed(parse(QbzTagEditor.editorJson))
if (!visible) {
seeded = false
restoreShellFocus()
}
}
Component.onDestruction: {
QbzTagEditor.leave()
restoreShellFocus()
}
Connections {
target: QbzTagEditor
function onEditorJsonChanged() {
if (root.visible && !QbzTagEditor.editorLoading)
if (!QbzTagEditor.editorLoading)
root.seed(root.parse(QbzTagEditor.editorJson))
}
// Rust publishes the immutable document before it clears loading so
@@ -170,7 +238,7 @@ Item {
// document on that second signal as well; otherwise the JSON change is
// deliberately ignored while loading and the modal spins forever.
function onEditorLoadingChanged() {
if (root.visible && !QbzTagEditor.editorLoading)
if (!QbzTagEditor.editorLoading)
root.seed(root.parse(QbzTagEditor.editorJson))
}
function onRemoteSeqChanged() {
@@ -180,6 +248,10 @@ Item {
root.remoteMetadata = null
} else if (event.kind === "metadata") {
root.remoteMetadata = event.value || null
} else if (event.kind === "artwork-results") {
root.artworkResults = event.value || []
} else if (event.kind === "artwork-selected") {
root.artwork = event.value || ({})
}
}
}
@@ -195,23 +267,12 @@ Item {
}
}
Rectangle {
anchors.fill: parent
color: "#bf000000"
MouseArea { anchors.fill: parent; onClicked: root.closeEditor() }
}
Rectangle {
id: panel
anchors.centerIn: parent
width: Math.min(parent.width - 64, 1040)
height: Math.min(parent.height - 48, 820)
radius: theme.radiusLg
anchors.fill: parent
radius: 0
color: theme.surfaceMain
border.width: 1
border.color: theme.borderSubtle
clip: true
MouseArea { anchors.fill: parent }
Item {
id: header
@@ -286,6 +347,171 @@ Item {
font.pixelSize: theme.fontSection
font.weight: theme.weightSemibold
}
Row {
width: parent.width
height: 154
spacing: 16
Rectangle {
width: 154
height: 154
radius: theme.radiusMd
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
clip: true
Image {
id: albumArtwork
anchors.fill: parent
source: root.artwork.previewPath || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: false
visible: source !== ""
}
QbzIcon {
anchors.centerIn: parent
name: "disc-3"
width: 42
height: 42
tintName: "muted"
visible: !albumArtwork.visible
}
QbzSpinner {
anchors.centerIn: parent
size: 26
visible: QbzTagEditor.artworkLoading
}
}
Column {
width: parent.width - 170
spacing: 8
Row {
id: artworkActions
width: parent.width
spacing: 8
SettingsButton {
text: root.artwork.previewPath ? root.tr("Choose another file") : root.tr("Choose file")
iconName: "image-plus"
minWidth: 0
enabled: !QbzTagEditor.artworkLoading
onClicked: QbzTagEditor.chooseArtwork()
}
SettingsButton {
visible: (root.artwork.token || "") !== ""
text: root.tr("Revert selection")
iconName: "rotate-ccw"
minWidth: 0
enabled: !QbzTagEditor.artworkLoading
onClicked: QbzTagEditor.clearArtwork()
}
Text {
anchors.verticalCenter: parent.verticalCenter
width: Math.max(0, artworkActions.width
- artworkActions.children[0].width
- artworkActions.children[1].width
- artworkActions.spacing * 2)
text: (root.artwork.source || "")
+ (root.artwork.width ? " · " + root.artwork.width + " × " + root.artwork.height : "")
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
}
Row {
spacing: 8
QbzSelect {
width: 150
menuWidth: 150
options: ["MusicBrainz", "Discogs", "Last.fm"]
currentIndex: root.artworkProvider === "discogs" ? 1
: root.artworkProvider === "lastfm" ? 2 : 0
enabled: !QbzTagEditor.artworkSearching && !QbzTagEditor.artworkLoading
onSelected: function(index) {
root.artworkProvider = index === 1 ? "discogs"
: index === 2 ? "lastfm" : "musicbrainz"
}
}
SettingsButton {
text: QbzTagEditor.artworkSearching ? root.tr("Searching…") : root.tr("Find artwork")
iconName: "search"
minWidth: 0
enabled: !QbzTagEditor.artworkSearching && !QbzTagEditor.artworkLoading
onClicked: QbzTagEditor.searchArtwork(
root.artworkProvider,
root.albumTitle,
root.albumArtist,
root.catalogNumber)
}
}
ListView {
id: artworkList
width: parent.width
height: 92
orientation: ListView.Horizontal
spacing: 8
clip: true
model: root.artworkResults
delegate: Rectangle {
required property var modelData
width: 196
height: 88
radius: theme.radiusSm
color: artMouse.containsMouse ? theme.surfaceHover : theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Image {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.margins: 4
width: 80
source: modelData.previewUrl || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
}
Column {
anchors.left: parent.left
anchors.leftMargin: 92
anchors.right: parent.right
anchors.rightMargin: 7
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
width: parent.width
text: modelData.title || ""
color: theme.textPrimary
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
Text {
width: parent.width
text: modelData.source || ""
color: theme.textSecondary
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
Text {
width: parent.width
text: modelData.detail || ""
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
}
MouseArea {
id: artMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: !QbzTagEditor.artworkLoading
onClicked: QbzTagEditor.selectArtwork(parent.modelData.id || "")
}
}
}
}
}
Grid {
width: parent.width
columns: 2
@@ -303,6 +529,32 @@ Item {
onCommitted: function(value) { root.albumTitle = value }
}
}
Column {
width: (content.width - 16) / 2
spacing: 5
Text { text: root.tr("Album artists (ordered; separate with semicolons)"); color: theme.textSecondary; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: root.albumArtistsText
onEdited: function(value) { root.albumArtistsText = value }
onCommitted: function(value) { root.albumArtistsText = value }
}
}
Row {
width: (content.width - 16) / 2
spacing: 8
QbzToggle {
anchors.verticalCenter: parent.verticalCenter
checked: root.compilation
onToggled: function(value) { root.compilation = value }
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.tr("Compilation / Various Artists")
color: theme.textSecondary
font.pixelSize: theme.fontBody
}
}
Column {
width: (content.width - 16) / 2
spacing: 5
@@ -351,6 +603,50 @@ Item {
}
}
}
Column {
width: (content.width - 16) / 2
spacing: 5
Text { text: root.tr("MusicBrainz release ID"); color: theme.textSecondary; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: root.musicbrainzReleaseId
onEdited: function(value) { root.musicbrainzReleaseId = value }
onCommitted: function(value) { root.musicbrainzReleaseId = value }
}
}
Column {
width: (content.width - 16) / 2
spacing: 5
Text { text: root.tr("MusicBrainz release-group ID"); color: theme.textSecondary; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: root.musicbrainzReleaseGroupId
onEdited: function(value) { root.musicbrainzReleaseGroupId = value }
onCommitted: function(value) { root.musicbrainzReleaseGroupId = value }
}
}
Column {
width: (content.width - 16) / 2
spacing: 5
Text { text: root.tr("MusicBrainz album artist IDs"); color: theme.textSecondary; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: root.musicbrainzAlbumArtistIdsText
onEdited: function(value) { root.musicbrainzAlbumArtistIdsText = value }
onCommitted: function(value) { root.musicbrainzAlbumArtistIdsText = value }
}
}
Column {
width: (content.width - 16) / 2
spacing: 5
Text { text: root.tr("Discogs release ID"); color: theme.textSecondary; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: root.discogsReleaseId
onEdited: function(value) { root.discogsReleaseId = value }
onCommitted: function(value) { root.discogsReleaseId = value }
}
}
}
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
@@ -515,7 +811,7 @@ Item {
Row {
width: parent.width
Text {
text: root.tr("Tracks") + " · " + root.tr("Disc") + " / " + root.tr("Track")
text: root.tr("Tracks")
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightSemibold
@@ -529,52 +825,273 @@ Item {
elide: Text.ElideRight
}
}
Rectangle {
width: parent.width
height: 32
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Row {
anchors.fill: parent
Text {
width: 64
height: parent.height
leftPadding: 10
text: root.tr("Disc")
color: theme.textMuted
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
}
Rectangle { width: 1; height: parent.height; color: theme.borderSubtle }
Text {
width: 72
height: parent.height
leftPadding: 10
text: root.tr("Track")
color: theme.textMuted
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
}
Rectangle { width: 1; height: parent.height; color: theme.borderSubtle }
Text {
width: Math.max(160, parent.width - 64 - 72 - 220 - 240 - 4)
height: parent.height
leftPadding: 10
text: root.tr("Title")
color: theme.textMuted
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
}
Rectangle { width: 1; height: parent.height; color: theme.borderSubtle }
Text {
width: 220
height: parent.height
leftPadding: 10
text: root.tr("Artist credit")
color: theme.textMuted
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
}
Rectangle { width: 1; height: parent.height; color: theme.borderSubtle }
Text {
width: 240
height: parent.height
leftPadding: 10
text: root.tr("File")
color: theme.textMuted
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
}
}
}
ListView {
id: trackList
width: parent.width
height: Math.min(280, Math.max(108, content.height > scroll.height ? 180 : 280))
height: Math.min(360, Math.max(112, root.tracks.length * 36))
clip: true
spacing: 4
spacing: 0
model: root.tracks
delegate: Rectangle {
required property var modelData
width: trackList.width
height: 44
radius: theme.radiusSm
color: index % 2 ? theme.surfaceElevated : theme.surfaceCard
height: 36
color: index % 2 ? theme.surfaceElevated : "transparent"
border.width: 1
border.color: theme.borderSubtle
Row {
anchors.fill: parent
anchors.margins: 5
spacing: 8
QbzLineEdit {
width: 52
text: modelData.discNumber
placeholder: root.tr("Disc")
onEdited: function(value) { modelData.discNumber = value }
onCommitted: function(value) { modelData.discNumber = value }
Rectangle {
width: 64
height: parent.height
color: "transparent"
TextInput {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 8
text: modelData.discNumber
color: theme.textPrimary
font.pixelSize: theme.fontBody
verticalAlignment: TextInput.AlignVCenter
selectByMouse: true
inputMethodHints: Qt.ImhDigitsOnly
onTextEdited: modelData.discNumber = text
onActiveFocusChanged: if (activeFocus) root.selectedTrackIndex = index
}
}
QbzLineEdit {
width: 58
text: modelData.trackNumber
placeholder: root.tr("Track")
onEdited: function(value) { modelData.trackNumber = value }
onCommitted: function(value) { modelData.trackNumber = value }
Rectangle { width: 1; height: parent.height; color: theme.borderSubtle }
Rectangle {
width: 72
height: parent.height
color: "transparent"
TextInput {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 8
text: modelData.trackNumber
color: theme.textPrimary
font.pixelSize: theme.fontBody
verticalAlignment: TextInput.AlignVCenter
selectByMouse: true
inputMethodHints: Qt.ImhDigitsOnly
onTextEdited: modelData.trackNumber = text
onActiveFocusChanged: if (activeFocus) root.selectedTrackIndex = index
}
}
QbzLineEdit {
width: parent.width - 52 - 58 - fileName.width - 24
text: modelData.title
onEdited: function(value) { modelData.title = value }
onCommitted: function(value) { modelData.title = value }
Rectangle { width: 1; height: parent.height; color: theme.borderSubtle }
Rectangle {
width: Math.max(160, parent.width - 64 - 72 - 220 - 240 - 4)
height: parent.height
color: "transparent"
TextInput {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 8
text: modelData.title
color: theme.textPrimary
font.pixelSize: theme.fontBody
verticalAlignment: TextInput.AlignVCenter
selectByMouse: true
clip: true
onTextEdited: modelData.title = text
onActiveFocusChanged: if (activeFocus) root.selectedTrackIndex = index
}
}
Rectangle { width: 1; height: parent.height; color: theme.borderSubtle }
Rectangle {
width: 220
height: parent.height
color: "transparent"
TextInput {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 8
text: modelData.artistCredit
color: theme.textPrimary
font.pixelSize: theme.fontBody
verticalAlignment: TextInput.AlignVCenter
selectByMouse: true
clip: true
onTextEdited: modelData.artistCredit = text
onActiveFocusChanged: if (activeFocus) root.selectedTrackIndex = index
}
}
Rectangle { width: 1; height: parent.height; color: theme.borderSubtle }
Text {
id: fileName
width: Math.min(220, implicitWidth)
width: 240
height: parent.height
leftPadding: 10
rightPadding: 8
text: modelData.fileName
color: theme.textMuted
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
elide: Text.ElideMiddle
MouseArea {
anchors.fill: parent
onClicked: root.selectedTrackIndex = index
}
}
}
}
}
Rectangle {
id: trackDetails
property var rowData: root.selectedTrackIndex >= 0
&& root.selectedTrackIndex < root.tracks.length
? root.tracks[root.selectedTrackIndex] : null
visible: rowData !== null
width: parent.width
height: visible ? 190 : 0
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
radius: theme.radiusSm
Column {
anchors.fill: parent
anchors.margins: 10
spacing: 8
Row {
width: parent.width
Text {
text: root.tr("Selected track details")
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightSemibold
}
Text {
width: parent.width - parent.children[0].implicitWidth
horizontalAlignment: Text.AlignRight
text: trackDetails.rowData ? trackDetails.rowData.fileName : ""
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideMiddle
}
}
Grid {
width: parent.width
columns: 2
columnSpacing: 12
rowSpacing: 7
Column {
width: (trackDetails.width - 32) / 2
spacing: 3
Text { text: root.tr("Artists (ordered; semicolon separated)"); color: theme.textMuted; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: trackDetails.rowData ? root.listText(trackDetails.rowData.artists) : ""
onEdited: function(value) { if (trackDetails.rowData) trackDetails.rowData.artists = root.splitList(value) }
}
}
Column {
width: (trackDetails.width - 32) / 2
spacing: 3
Text { text: root.tr("Composers"); color: theme.textMuted; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: trackDetails.rowData ? root.listText(trackDetails.rowData.composers) : ""
onEdited: function(value) { if (trackDetails.rowData) trackDetails.rowData.composers = root.splitList(value) }
}
}
Column {
width: (trackDetails.width - 32) / 2
spacing: 3
Text { text: root.tr("Performers"); color: theme.textMuted; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: trackDetails.rowData ? root.listText(trackDetails.rowData.performers) : ""
onEdited: function(value) { if (trackDetails.rowData) trackDetails.rowData.performers = root.splitList(value) }
}
}
Column {
width: (trackDetails.width - 32) / 2
spacing: 3
Text { text: root.tr("MusicBrainz artist IDs"); color: theme.textMuted; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: trackDetails.rowData ? root.listText(trackDetails.rowData.musicbrainzArtistIds) : ""
onEdited: function(value) { if (trackDetails.rowData) trackDetails.rowData.musicbrainzArtistIds = root.splitList(value) }
}
}
Column {
width: (trackDetails.width - 32) / 2
spacing: 3
Text { text: root.tr("MusicBrainz recording ID"); color: theme.textMuted; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: trackDetails.rowData ? trackDetails.rowData.musicbrainzRecordingId : ""
onEdited: function(value) { if (trackDetails.rowData) trackDetails.rowData.musicbrainzRecordingId = value }
}
}
Column {
width: (trackDetails.width - 32) / 2
spacing: 3
Text { text: root.tr("MusicBrainz track ID"); color: theme.textMuted; font.pixelSize: theme.fontLegal }
QbzLineEdit {
width: parent.width
text: trackDetails.rowData ? trackDetails.rowData.musicbrainzTrackId : ""
onEdited: function(value) { if (trackDetails.rowData) trackDetails.rowData.musicbrainzTrackId = value }
}
}
}
}
-5
View File
@@ -736,11 +736,6 @@ Rectangle {
DiscMetaModal {
anchors.fill: parent
}
// Selected-version metadata editor. Saving republishes the album beneath
// it, so it shares the app-wide lifetime of the disc and rip modals.
TagEditorModal {
anchors.fill: parent
}
RipWizardModal {
anchors.fill: parent
}
@@ -174,6 +174,7 @@ Item {
: v === "local"
? (root.kiosk ? "../kiosk/KioskLocalLibrary.qml" : "../views/LocalLibraryView.qml")
: v === "localalbum" ? "../views/LocalAlbumView.qml"
: v === "metadataeditor" ? "../controls/TagEditorModal.qml"
: v === "album"
? (root.kiosk ? "../kiosk/KioskAlbum.qml" : "../views/AlbumView.qml")
: v === "artist"
+62 -11
View File
@@ -34,6 +34,8 @@ pub mod qbz_tag_editor_bridge {
#[qproperty(QString, remote_json)]
#[qproperty(bool, remote_searching)]
#[qproperty(bool, remote_loading)]
#[qproperty(bool, artwork_searching)]
#[qproperty(bool, artwork_loading)]
/// Bumps for every remote publish, including two byte-identical ones.
#[qproperty(i32, remote_seq)]
type QbzTagEditor = super::QbzTagEditorRust;
@@ -44,6 +46,11 @@ pub mod qbz_tag_editor_bridge {
#[qinvokable]
fn close(self: Pin<&mut QbzTagEditor>);
/// Drop the editor session because navigation already left its view.
/// Unlike `close`, this must not mutate history a second time.
#[qinvokable]
fn leave(self: Pin<&mut QbzTagEditor>);
/// The entire validated draft, encoded as JSON. File paths never come
/// back from QML; Rust resolves row ids against the open session.
#[qinvokable]
@@ -58,14 +65,28 @@ pub mod qbz_tag_editor_bridge {
);
#[qinvokable]
fn load_remote(
self: Pin<&mut QbzTagEditor>,
provider: QString,
provider_id: QString,
);
fn load_remote(self: Pin<&mut QbzTagEditor>, provider: QString, provider_id: QString);
#[qinvokable]
fn open_remote(self: Pin<&mut QbzTagEditor>, provider: QString, provider_id: QString);
#[qinvokable]
fn choose_artwork(self: Pin<&mut QbzTagEditor>);
#[qinvokable]
fn search_artwork(
self: Pin<&mut QbzTagEditor>,
provider: QString,
title: QString,
artist: QString,
catalog_number: QString,
);
#[qinvokable]
fn select_artwork(self: Pin<&mut QbzTagEditor>, candidate_id: QString);
#[qinvokable]
fn clear_artwork(self: Pin<&mut QbzTagEditor>);
}
impl cxx_qt::Threading for QbzTagEditor {}
@@ -83,6 +104,8 @@ pub struct QbzTagEditorRust {
remote_json: QString,
remote_searching: bool,
remote_loading: bool,
artwork_searching: bool,
artwork_loading: bool,
remote_seq: i32,
}
@@ -98,6 +121,8 @@ impl Default for QbzTagEditorRust {
remote_json: QString::from("{}"),
remote_searching: false,
remote_loading: false,
artwork_searching: false,
artwork_loading: false,
remote_seq: 0,
}
}
@@ -124,16 +149,15 @@ impl qbz_tag_editor_bridge::QbzTagEditor {
crate::tag_editor_qt::close();
}
pub fn leave(self: Pin<&mut Self>) {
crate::tag_editor_qt::leave();
}
pub fn save(self: Pin<&mut Self>, draft_json: QString) {
crate::tag_editor_qt::save(&draft_json.to_string());
}
pub fn search_remote(
self: Pin<&mut Self>,
provider: QString,
title: QString,
artist: QString,
) {
pub fn search_remote(self: Pin<&mut Self>, provider: QString, title: QString, artist: QString) {
crate::tag_editor_qt::search_remote(
&provider.to_string(),
&title.to_string(),
@@ -148,4 +172,31 @@ impl qbz_tag_editor_bridge::QbzTagEditor {
pub fn open_remote(self: Pin<&mut Self>, provider: QString, provider_id: QString) {
crate::tag_editor_qt::open_remote(&provider.to_string(), &provider_id.to_string());
}
pub fn choose_artwork(self: Pin<&mut Self>) {
crate::tag_editor_qt::choose_artwork();
}
pub fn search_artwork(
self: Pin<&mut Self>,
provider: QString,
title: QString,
artist: QString,
catalog_number: QString,
) {
crate::tag_editor_qt::search_artwork(
&provider.to_string(),
&title.to_string(),
&artist.to_string(),
&catalog_number.to_string(),
);
}
pub fn select_artwork(self: Pin<&mut Self>, candidate_id: QString) {
crate::tag_editor_qt::select_artwork(&candidate_id.to_string());
}
pub fn clear_artwork(self: Pin<&mut Self>) {
crate::tag_editor_qt::clear_artwork();
}
}
File diff suppressed because it is too large. Load diff