Compare commits

...

1 Commits

Author SHA1 Message Date
Connor Johnstone
5957d69e7d Formatting 2026-03-18 15:36:54 -04:00
7 changed files with 107 additions and 75 deletions

View File

@@ -45,8 +45,8 @@ pub fn normalize(s: &str) -> String {
/// Escape special characters for MusicBrainz Lucene query syntax.
pub fn escape_lucene(s: &str) -> String {
let special = [
'+', '-', '&', '|', '!', '(', ')', '{', '}', '[', ']', '^', '"', '~', '*', '?', ':',
'\\', '/',
'+', '-', '&', '|', '!', '(', ')', '{', '}', '[', ']', '^', '"', '~', '*', '?', ':', '\\',
'/',
];
let mut result = String::with_capacity(s.len());
for c in s.chars() {
@@ -72,7 +72,10 @@ mod tests {
fn test_normalize_strips_official_video() {
assert_eq!(normalize("Time (Official Video)"), "time");
assert_eq!(normalize("Money (Official Music Video)"), "money");
assert_eq!(normalize("Comfortably Numb (Official Audio)"), "comfortably numb");
assert_eq!(
normalize("Comfortably Numb (Official Audio)"),
"comfortably numb"
);
}
#[test]

View File

@@ -31,9 +31,7 @@ pub fn write_tags(
) -> TagResult<()> {
let path = Path::new(file_path);
let tagged_file = Probe::open(path)?
.options(ParseOptions::default())
.read()?;
let tagged_file = Probe::open(path)?.options(ParseOptions::default()).read()?;
// Determine the tag type to use
let tag_type = tagged_file

View File

@@ -7,7 +7,10 @@ use shanty_db::Database;
use shanty_tag::{MusicBrainzClient, TagConfig, run_tagging};
#[derive(Parser)]
#[command(name = "shanty-tag", about = "Fill in missing metadata on music files via MusicBrainz")]
#[command(
name = "shanty-tag",
about = "Fill in missing metadata on music files via MusicBrainz"
)]
struct Cli {
/// Database URL. Defaults to sqlite://<XDG_DATA_HOME>/shanty/shanty.db?mode=rwc
#[arg(long, env = "SHANTY_DATABASE_URL")]

View File

@@ -30,9 +30,7 @@ pub fn build_query(track: &track::Model) -> Option<(String, String)> {
/// Parse "Artist - Title" from a filename, stripping extension and path.
pub fn parse_filename(file_path: &str) -> Option<(String, String)> {
let filename = std::path::Path::new(file_path)
.file_stem()?
.to_str()?;
let filename = std::path::Path::new(file_path).file_stem()?.to_str()?;
// Try common "Artist - Title" pattern
if let Some((artist, title)) = filename.split_once(" - ") {
@@ -55,11 +53,7 @@ pub fn parse_filename(file_path: &str) -> Option<(String, String)> {
/// Score a candidate recording against the track's known metadata.
/// Returns a confidence value from 0.0 to 1.0.
pub fn score_match(track: &track::Model, candidate: &RecordingMatch) -> f64 {
let track_title = track
.title
.as_deref()
.map(normalize)
.unwrap_or_default();
let track_title = track.title.as_deref().map(normalize).unwrap_or_default();
let candidate_title = normalize(&candidate.title);
let track_artist = track

View File

@@ -64,11 +64,7 @@ impl MusicBrainzClient {
}
impl MetadataProvider for MusicBrainzClient {
async fn search_recording(
&self,
artist: &str,
title: &str,
) -> TagResult<Vec<RecordingMatch>> {
async fn search_recording(&self, artist: &str, title: &str) -> TagResult<Vec<RecordingMatch>> {
let query = if artist.is_empty() {
format!("recording:{}", escape_lucene(title))
} else {
@@ -78,7 +74,10 @@ impl MetadataProvider for MusicBrainzClient {
escape_lucene(title)
)
};
let url = format!("{BASE_URL}/recording/?query={}&fmt=json&limit=5", urlencoded(&query));
let url = format!(
"{BASE_URL}/recording/?query={}&fmt=json&limit=5",
urlencoded(&query)
);
let resp: MbRecordingSearchResponse = self.get_json(&url).await?;
Ok(resp
@@ -108,11 +107,7 @@ impl MetadataProvider for MusicBrainzClient {
.collect())
}
async fn search_release(
&self,
artist: &str,
album: &str,
) -> TagResult<Vec<ReleaseMatch>> {
async fn search_release(&self, artist: &str, album: &str) -> TagResult<Vec<ReleaseMatch>> {
let query = if artist.is_empty() {
format!("release:{}", escape_lucene(album))
} else {
@@ -122,7 +117,10 @@ impl MetadataProvider for MusicBrainzClient {
escape_lucene(album)
)
};
let url = format!("{BASE_URL}/release/?query={}&fmt=json&limit=5", urlencoded(&query));
let url = format!(
"{BASE_URL}/release/?query={}&fmt=json&limit=5",
urlencoded(&query)
);
let resp: MbReleaseSearchResponse = self.get_json(&url).await?;
Ok(resp
@@ -144,9 +142,7 @@ impl MetadataProvider for MusicBrainzClient {
}
async fn get_recording(&self, mbid: &str) -> TagResult<RecordingDetails> {
let url = format!(
"{BASE_URL}/recording/{mbid}?inc=artists+releases+genres&fmt=json"
);
let url = format!("{BASE_URL}/recording/{mbid}?inc=artists+releases+genres&fmt=json");
let r: MbRecordingDetail = self.get_json(&url).await?;
let (artist_name, artist_mbid) = extract_artist_credit(&r.artist_credit);
@@ -177,11 +173,7 @@ impl MetadataProvider for MusicBrainzClient {
secondary_artists,
})
}
async fn search_artist(
&self,
query: &str,
limit: u32,
) -> TagResult<Vec<ArtistSearchResult>> {
async fn search_artist(&self, query: &str, limit: u32) -> TagResult<Vec<ArtistSearchResult>> {
let url = format!(
"{BASE_URL}/artist/?query={}&fmt=json&limit={limit}",
urlencoded(&escape_lucene(query))
@@ -207,9 +199,7 @@ impl MetadataProvider for MusicBrainzClient {
artist_mbid: &str,
limit: u32,
) -> TagResult<Vec<DiscographyEntry>> {
let url = format!(
"{BASE_URL}/release/?artist={artist_mbid}&fmt=json&limit={limit}"
);
let url = format!("{BASE_URL}/release/?artist={artist_mbid}&fmt=json&limit={limit}");
let resp: MbReleaseSearchResponse = self.get_json(&url).await?;
Ok(resp
@@ -225,13 +215,8 @@ impl MetadataProvider for MusicBrainzClient {
.collect())
}
async fn get_release_tracks(
&self,
release_mbid: &str,
) -> TagResult<Vec<ReleaseTrack>> {
let url = format!(
"{BASE_URL}/release/{release_mbid}?inc=recordings&fmt=json"
);
async fn get_release_tracks(&self, release_mbid: &str) -> TagResult<Vec<ReleaseTrack>> {
let url = format!("{BASE_URL}/release/{release_mbid}?inc=recordings&fmt=json");
let resp: MbReleaseDetail = self.get_json(&url).await?;
let mut tracks = Vec::new();
@@ -270,7 +255,8 @@ impl MetadataProvider for MusicBrainzClient {
primary_type: rg.primary_type,
secondary_types: rg.secondary_types.unwrap_or_default(),
first_release_date: rg.first_release_date,
first_release_mbid: rg.releases
first_release_mbid: rg
.releases
.and_then(|r| r.into_iter().next().map(|rel| rel.id)),
})
.collect())
@@ -294,7 +280,9 @@ fn extract_artist_credit(credits: &Option<Vec<MbArtistCredit>>) -> (String, Opti
/// Extract non-featuring secondary artists from MusicBrainz artist credits.
/// Returns (name, mbid) pairs for collaborators that aren't "featuring" credits.
fn extract_secondary_artists(credits: &Option<Vec<MbArtistCredit>>) -> Vec<(String, String)> {
let Some(credits) = credits else { return vec![] };
let Some(credits) = credits else {
return vec![];
};
if credits.len() <= 1 {
return vec![];
}

View File

@@ -107,12 +107,16 @@ pub async fn tag_track(
// Upsert primary artist with MusicBrainz ID
let artist_id = match &details.artist_mbid {
Some(mbid) => {
Some(queries::artists::upsert(conn, &details.artist, Some(mbid)).await?.id)
}
None => {
Some(queries::artists::upsert(conn, &details.artist, None).await?.id)
}
Some(mbid) => Some(
queries::artists::upsert(conn, &details.artist, Some(mbid))
.await?
.id,
),
None => Some(
queries::artists::upsert(conn, &details.artist, None)
.await?
.id,
),
};
// Upsert secondary collaborator artists so they exist as separate library entries
@@ -174,16 +178,16 @@ pub async fn tag_track(
queries::tracks::update_metadata(conn, track.id, active).await?;
// Optionally write tags to file
if config.write_tags {
if let Err(e) = file_tags::write_tags(
if config.write_tags
&& let Err(e) = file_tags::write_tags(
&track.file_path,
&details,
best_release.as_ref(),
year,
genre.as_deref(),
) {
tracing::warn!(id = track.id, path = %track.file_path, "failed to write file tags: {e}");
}
)
{
tracing::warn!(id = track.id, path = %track.file_path, "failed to write file tags: {e}");
}
Ok(true)

View File

@@ -2,11 +2,11 @@ use chrono::Utc;
use sea_orm::ActiveValue::Set;
use shanty_db::{Database, queries};
use shanty_tag::error::TagResult;
use shanty_tag::provider::{
ArtistSearchResult, DiscographyEntry, MetadataProvider, RecordingDetails, RecordingMatch,
ReleaseMatch, ReleaseRef,
};
use shanty_tag::error::TagResult;
use shanty_tag::{TagConfig, run_tagging};
/// A mock metadata provider for testing without hitting MusicBrainz.
@@ -64,15 +64,25 @@ impl MetadataProvider for MockProvider {
Ok(vec![])
}
async fn get_artist_releases(&self, _artist_mbid: &str, _limit: u32) -> TagResult<Vec<DiscographyEntry>> {
async fn get_artist_releases(
&self,
_artist_mbid: &str,
_limit: u32,
) -> TagResult<Vec<DiscographyEntry>> {
Ok(vec![])
}
async fn get_release_tracks(&self, _release_mbid: &str) -> TagResult<Vec<shanty_tag::provider::ReleaseTrack>> {
async fn get_release_tracks(
&self,
_release_mbid: &str,
) -> TagResult<Vec<shanty_tag::provider::ReleaseTrack>> {
Ok(vec![])
}
async fn get_artist_release_groups(&self, _artist_mbid: &str) -> TagResult<Vec<shanty_tag::provider::ReleaseGroupEntry>> {
async fn get_artist_release_groups(
&self,
_artist_mbid: &str,
) -> TagResult<Vec<shanty_tag::provider::ReleaseGroupEntry>> {
Ok(vec![])
}
}
@@ -83,7 +93,12 @@ async fn test_db() -> Database {
.expect("failed to create test database")
}
async fn insert_untagged_track(db: &Database, file_path: &str, title: Option<&str>, artist: Option<&str>) -> i32 {
async fn insert_untagged_track(
db: &Database,
file_path: &str,
title: Option<&str>,
artist: Option<&str>,
) -> i32 {
let now = Utc::now().naive_utc();
let active = shanty_db::entities::track::ActiveModel {
file_path: Set(file_path.to_string()),
@@ -103,7 +118,8 @@ async fn test_tag_track_with_match() {
let db = test_db().await;
let provider = MockProvider;
let track_id = insert_untagged_track(&db, "/music/time.mp3", Some("Time"), Some("Pink Floyd")).await;
let track_id =
insert_untagged_track(&db, "/music/time.mp3", Some("Time"), Some("Pink Floyd")).await;
let config = TagConfig {
dry_run: false,
@@ -111,13 +127,17 @@ async fn test_tag_track_with_match() {
confidence: 0.8,
};
let stats = run_tagging(db.conn(), &provider, &config, Some(track_id)).await.unwrap();
let stats = run_tagging(db.conn(), &provider, &config, Some(track_id))
.await
.unwrap();
assert_eq!(stats.tracks_processed, 1);
assert_eq!(stats.tracks_matched, 1);
assert_eq!(stats.tracks_updated, 1);
// Verify the track was updated
let track = queries::tracks::get_by_id(db.conn(), track_id).await.unwrap();
let track = queries::tracks::get_by_id(db.conn(), track_id)
.await
.unwrap();
assert_eq!(track.musicbrainz_id.as_deref(), Some("rec-123"));
assert_eq!(track.title.as_deref(), Some("Time"));
assert_eq!(track.artist.as_deref(), Some("Pink Floyd"));
@@ -126,9 +146,14 @@ async fn test_tag_track_with_match() {
assert_eq!(track.genre.as_deref(), Some("Progressive Rock"));
// Verify artist was created with MusicBrainz ID
let artist = queries::artists::find_by_name(db.conn(), "Pink Floyd").await.unwrap();
let artist = queries::artists::find_by_name(db.conn(), "Pink Floyd")
.await
.unwrap();
assert!(artist.is_some());
assert_eq!(artist.unwrap().musicbrainz_id.as_deref(), Some("artist-456"));
assert_eq!(
artist.unwrap().musicbrainz_id.as_deref(),
Some("artist-456")
);
}
#[tokio::test]
@@ -136,7 +161,13 @@ async fn test_tag_track_no_match() {
let db = test_db().await;
let provider = MockProvider;
let track_id = insert_untagged_track(&db, "/music/unknown.mp3", Some("Unknown Song"), Some("Nobody")).await;
let track_id = insert_untagged_track(
&db,
"/music/unknown.mp3",
Some("Unknown Song"),
Some("Nobody"),
)
.await;
let config = TagConfig {
dry_run: false,
@@ -144,12 +175,16 @@ async fn test_tag_track_no_match() {
confidence: 0.8,
};
let stats = run_tagging(db.conn(), &provider, &config, Some(track_id)).await.unwrap();
let stats = run_tagging(db.conn(), &provider, &config, Some(track_id))
.await
.unwrap();
assert_eq!(stats.tracks_processed, 1);
assert_eq!(stats.tracks_skipped, 1);
// Track should be unchanged
let track = queries::tracks::get_by_id(db.conn(), track_id).await.unwrap();
let track = queries::tracks::get_by_id(db.conn(), track_id)
.await
.unwrap();
assert!(track.musicbrainz_id.is_none());
}
@@ -158,7 +193,8 @@ async fn test_dry_run_does_not_update() {
let db = test_db().await;
let provider = MockProvider;
let track_id = insert_untagged_track(&db, "/music/time.mp3", Some("Time"), Some("Pink Floyd")).await;
let track_id =
insert_untagged_track(&db, "/music/time.mp3", Some("Time"), Some("Pink Floyd")).await;
let config = TagConfig {
dry_run: true,
@@ -166,12 +202,16 @@ async fn test_dry_run_does_not_update() {
confidence: 0.8,
};
let stats = run_tagging(db.conn(), &provider, &config, Some(track_id)).await.unwrap();
let stats = run_tagging(db.conn(), &provider, &config, Some(track_id))
.await
.unwrap();
assert_eq!(stats.tracks_matched, 1);
assert_eq!(stats.tracks_updated, 0); // dry run
// Track should be unchanged
let track = queries::tracks::get_by_id(db.conn(), track_id).await.unwrap();
let track = queries::tracks::get_by_id(db.conn(), track_id)
.await
.unwrap();
assert!(track.musicbrainz_id.is_none());
}
@@ -189,7 +229,9 @@ async fn test_tag_all_untagged() {
confidence: 0.8,
};
let stats = run_tagging(db.conn(), &provider, &config, None).await.unwrap();
let stats = run_tagging(db.conn(), &provider, &config, None)
.await
.unwrap();
assert_eq!(stats.tracks_processed, 2);
assert_eq!(stats.tracks_matched, 1); // only Pink Floyd matched
assert_eq!(stats.tracks_skipped, 1);