diff --git a/Cargo.lock b/Cargo.lock index cc4f7dd..f6fc99f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3319,6 +3319,7 @@ dependencies = [ "onetagger-shared", "onetagger-tag", "onetagger-tagger", + "rand 0.8.5", "regex", "reqwest", "serde", diff --git a/client/src/components/AutotaggerAdvanced.vue b/client/src/components/AutotaggerAdvanced.vue index bdee597..901635c 100644 --- a/client/src/components/AutotaggerAdvanced.vue +++ b/client/src/components/AutotaggerAdvanced.vue @@ -174,6 +174,26 @@ v-model='$1t.config.value.capitalizeGenres' > + + +
+
How many % of tracks must be from same album to be treated as album:
+ +
+
Match duration
diff --git a/client/src/scripts/autotagger.ts b/client/src/scripts/autotagger.ts index f3fdb1c..e1cc2f5 100644 --- a/client/src/scripts/autotagger.ts +++ b/client/src/scripts/autotagger.ts @@ -80,6 +80,8 @@ class AutotaggerConfig { id3CommLang?: string; removeAllCovers: boolean = false; fetchAllResults: boolean = false; + albumTagging: boolean = false; + albumTaggingRatio: number = 0.5; spotify?: SpotifyConfig; diff --git a/client/src/views/AutotaggerStatus.vue b/client/src/views/AutotaggerStatus.vue index 5790481..8c8d08d 100644 --- a/client/src/views/AutotaggerStatus.vue +++ b/client/src/views/AutotaggerStatus.vue @@ -120,6 +120,7 @@ Accuracy: {{ (i.status.accuracy * 100).toFixed(2) }}% + , Reason: {{ i.status.reason }} | diff --git a/crates/onetagger-autotag/Cargo.toml b/crates/onetagger-autotag/Cargo.toml index 39768c8..8f32677 100644 --- a/crates/onetagger-autotag/Cargo.toml +++ b/crates/onetagger-autotag/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" [dependencies] log = "0.4" +rand = "0.8" regex = "1.10" dunce = "1.0" image = "0.25" diff --git a/crates/onetagger-autotag/src/audiofeatures.rs b/crates/onetagger-autotag/src/audiofeatures.rs index 12def7e..a50b860 100644 --- a/crates/onetagger-autotag/src/audiofeatures.rs +++ b/crates/onetagger-autotag/src/audiofeatures.rs @@ -182,7 +182,7 @@ impl AudioFeatures { let mut status = TaggingStatus { status: TaggingState::Error, path: file.to_owned(), - message: None, accuracy: None, used_shazam: false + message: None, accuracy: None, used_shazam: false, release_id: None, reason: None }; // Load file if let Ok(info) = AudioFileInfo::load_file(&file, None, None) { diff --git a/crates/onetagger-autotag/src/lib.rs b/crates/onetagger-autotag/src/lib.rs index 8c0c8a3..be1ae60 100644 --- a/crates/onetagger-autotag/src/lib.rs +++ b/crates/onetagger-autotag/src/lib.rs @@ -4,6 +4,7 @@ use std::collections::HashMap; use anyhow::Error; +use rand::seq::SliceRandom; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::path::{Path, PathBuf}; @@ -13,7 +14,7 @@ use std::default::Default; use std::io::prelude::*; use chrono::Local; use execute::Execute; -use onetagger_tagger::{FileTaggedStatus, LyricsExt, SupportedTag, MatchingUtils, TrackMatch}; +use onetagger_tagger::{FileTaggedStatus, LyricsExt, MatchReason, MatchingUtils, SupportedTag, TrackMatch}; use regex::Regex; use reqwest::StatusCode; use walkdir::WalkDir; @@ -226,9 +227,9 @@ impl TrackImpl for Track { let t = format!("{}_TRACK_ID", serde_json::to_value(self.platform.clone()).unwrap().as_str().unwrap().to_uppercase()); tag.set_raw(&t, vec![self.track_id.as_ref().unwrap().to_string()], config.overwrite_tag(SupportedTag::TrackId)); } - if config.tag_enabled(SupportedTag::ReleaseId) && !self.release_id.is_empty() { + if config.tag_enabled(SupportedTag::ReleaseId) && self.release_id.is_some() { let t = format!("{}_RELEASE_ID", serde_json::to_value(self.platform.clone()).unwrap().as_str().unwrap().to_uppercase()); - tag.set_raw(&t, vec![self.release_id.to_string()], config.overwrite_tag(SupportedTag::ReleaseId)); + tag.set_raw(&t, vec![self.release_id.as_ref().unwrap().to_string()], config.overwrite_tag(SupportedTag::ReleaseId)); } // Catalog number if config.tag_enabled(SupportedTag::CatalogNumber) && self.catalog_number.is_some() { @@ -449,7 +450,7 @@ impl AudioFileInfoImpl for AudioFileInfo { Ok(AudioFileInfo { format: tag_wrap.format(), title, - artists: artists.ok_or(anyhow!("Missing artist tag!"))?, + artists: artists.unwrap_or_default(), path: path.as_ref().to_owned(), isrc: tag.get_field(Field::ISRC).unwrap_or(vec![]).first().map(String::from), duration: None, @@ -566,6 +567,8 @@ pub struct TaggingStatus { pub message: Option, pub accuracy: Option, pub used_shazam: bool, + pub release_id: Option, + pub reason: Option } // Wrap for sending into UI @@ -593,6 +596,12 @@ impl Tagger { // Returtns progress receiver, and file count pub fn tag_files(cfg: &TaggerConfig, mut files: Vec, finished: Arc>>) -> Receiver { STOP_TAGGING.store(false, Ordering::SeqCst); + + // Shuffle so album tag is more "efficient" + if cfg.album_tagging { + let mut rng = rand::thread_rng(); + files.shuffle(&mut rng); + } // let original_files = files.clone(); let mut succesful_files = vec![]; @@ -634,7 +643,7 @@ impl Tagger { if platform_info.max_threads > 0 && platform_info.max_threads < config.threads { threads = platform_info.max_threads; } - let rx = match Tagger::tag_dir(&files, tagger, &config, threads) { + let rx = match Tagger::tag_batch(&files, tagger, &config, threads) { Some(t) => t, None => { error!("Failed creating platform: {platform:?}, skipping..."); @@ -656,7 +665,9 @@ impl Tagger { } // Fallback if !config.multiplatform { - files.remove(files.iter().position(|f| f == &status.path).unwrap()); + if let Some(index) = files.iter().position(|f| f == &status.path) { + files.remove(index); + } } // Remove from failed if let Some(i) = failed_files.iter().position(|i| i == &status.path) { @@ -771,7 +782,9 @@ impl Tagger { path: path.as_ref().to_owned(), accuracy: None, message: None, - used_shazam: false + used_shazam: false, + release_id: None, + reason: None }; // Filename template @@ -882,6 +895,8 @@ impl Tagger { } // Save + out.release_id = track.track.release_id.clone(); + out.reason = Some(track.reason); match track.track.merge_styles(&config.styles_options).write_to_file(&info.path, &config) { Ok(_) => { out.accuracy = Some(track.accuracy); @@ -897,16 +912,25 @@ impl Tagger { } // Tag all files with threads specified in config - pub fn tag_dir(files: &Vec, tagger: &mut Box, config: &TaggerConfig, threads: u16) -> Option> { + pub fn tag_batch(files: &Vec, tagger: &mut Box, config: &TaggerConfig, threads: u16) -> Option> { info!("Starting tagging: {} files, {} threads!", files.len(), threads); let (tx, rx) = unbounded(); let (file_tx, file_rx): (Sender, Receiver) = unbounded(); + let (finished_tx, finished_rx) = unbounded(); + + // Album tagging + let album_tagging = Arc::new(Mutex::new(AlbumTagContext::new())); + if config.album_tagging { + album_tagging.lock().unwrap().init(files); + } let mut ok_sources = 0; for _ in 0..threads { let tx = tx.clone(); let file_rx = file_rx.clone(); let config = config.clone(); + let finished_tx = finished_tx.clone(); + let album_tagging = album_tagging.clone(); let mut source = match tagger.get_source(&config) { Ok(s) => s, Err(e) => { @@ -922,11 +946,56 @@ impl Tagger { break; } + // Check if not marked for album tagging + if album_tagging.lock().unwrap().is_marked(&f) { + continue; + } + + // Tag let res = Tagger::tag_track(&f, &mut source, &config); + if config.album_tagging { + album_tagging.lock().unwrap().process(&res, &config); + } tx.send(res).ok(); } + finished_tx.send(0u8).ok(); }); } + + // Spawn album tag thread + if config.album_tagging { + let config = config.clone(); + match tagger.get_source(&config) { + Ok(mut source) => { + std::thread::spawn(move || { + // Wait for all threads to finish + for _ in finished_rx.into_iter() {} + + // Check all album statuses + let album_tagging = album_tagging.lock().unwrap(); + for (path, stats) in &album_tagging.folders { + if !stats.marked { + continue; + } + + // Tag + match Self::tag_album(path, &stats.get_album_id().unwrap(), &mut source, &config) { + Ok(statuses) => { + for status in statuses { + tx.send(status).ok(); + } + }, + Err(e) => error!("Album tagging failed: {e}, path: {}", path.display()), + } + } + + }); + }, + Err(e) => error!("Failed to get source for album tagging, album tagging will be disabled! {e}") + } + } + + if ok_sources == 0 { error!("All AT sources failed to create!"); return None; @@ -938,6 +1007,60 @@ impl Tagger { Some(rx) } + /// Tag an album by ID + pub fn tag_album(path: impl AsRef, release_id: &str, source: &mut Box, config: &TaggerConfig) -> Result, Error> { + info!("Album tagging release: {release_id} in {}", path.as_ref().display()); + + // Change strictness since we're working in context of album, and just care about most likely match + let mut config = config.clone(); + config.strictness = 0.0; + config.match_duration = false; + config.match_by_id = true; + config.enable_shazam = false; + config.force_shazam = false; + + // Get album + let album = source.get_album(&release_id, &config)?.ok_or(anyhow!("Album with id: {release_id} not found"))?; + if album.tracks.is_empty() { + return Err(anyhow!("Album {release_id} has no tracks!")) + } + + let mut statuses = vec![]; + + // Load files + let files = std::fs::read_dir(&path)?.filter_map(|e| e.ok()).map(|f| f.path()).collect::>(); + for file in files { + let (info, mut status) = Self::load_track(&file, &config); + let info = match info { + Some(i) => i, + None => { + warn!("Failed to load track info for file: {}", file.display()); + continue; + } + }; + + // Find closest match + let mut tracks = MatchingUtils::match_track(&info, &album.tracks, &config, false); + MatchingUtils::sort_tracks(&mut tracks, &config); + let track = tracks.remove(0); + + // TODO: Extend track if needed (?) + if let Err(e) = track.track.merge_styles(&config.styles_options).write_to_file(&info.path, &config) { + status.status = TaggingState::Error; + error!("Album tag writing tags failed: {e} ({})", file.display()); + } else { + status.status = TaggingState::Ok; + } + + // Save status + status.accuracy = Some(1.0); + status.reason = Some(MatchReason::Album); + statuses.push(status); + } + + Ok(statuses) + } + /// Move file to target dir if enabled fn move_file(source: impl AsRef, target: impl AsRef) -> Result { // Generate path @@ -960,6 +1083,96 @@ impl Tagger { } } +/// For keeping track of per-album tagging +struct AlbumTagContext { + /// path: stats + folders: HashMap +} + +impl AlbumTagContext { + /// Create new instance + pub fn new() -> AlbumTagContext { + AlbumTagContext { + folders: Default::default() + } + } + + /// Initialize internal counters + pub fn init(&mut self, paths: &[PathBuf]) { + for path in paths { + if let Some(path) = path.parent() { + let stats = match self.folders.get_mut(path) { + Some(v) => v, + None => { + self.folders.insert(path.to_owned(), AlbumTagFolderStats::default()); + self.folders.get_mut(path).unwrap() + } + }; + stats.files += 1; + } + } + } + + /// Save info from tagging status data + /// Returns (Path, Release ID) + pub fn process(&mut self, status: &TaggingStatus, config: &TaggerConfig) -> Option<(PathBuf, String)> { + let release_id = status.release_id.as_ref()?; + // Get folder path + let path = status.path.parent()?; + let stats = self.folders.get_mut(path)?; + if stats.marked { + return None; + } + + let count = match stats.albums.get_mut(release_id) { + Some(v) => { + *v = *v + 1; + *v + }, + None => { + stats.albums.insert(release_id.to_string(), 1); + 1 + } + }; + + // Should be considered as + if (count as f32 / stats.files as f32) >= config.album_tagging_ratio { + stats.marked = true; + return Some((path.to_owned(), release_id.to_owned())); + } + None + } + + /// Check if path is marked + pub fn is_marked(&self, path: impl AsRef) -> bool { + if let Some(parent) = path.as_ref().parent() { + if let Some(stats) = self.folders.get(parent) { + return stats.marked; + } + } + false + } + +} + +#[derive(Debug, Clone, Default)] +struct AlbumTagFolderStats { + /// How many files + pub files: usize, + /// album_id: count + pub albums: HashMap, + /// Is already marked as album + pub marked: bool, +} + +impl AlbumTagFolderStats { + /// Get album ID with highest count + pub fn get_album_id(&self) -> Option { + self.albums.iter().max_by_key(|(_, c)| **c).map(|(i, _)| i.to_string()) + } +} + + /// When AT finishes this will contain some extra data #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/onetagger-platforms/src/bandcamp.rs b/crates/onetagger-platforms/src/bandcamp.rs index 49f41c4..9e598ab 100644 --- a/crates/onetagger-platforms/src/bandcamp.rs +++ b/crates/onetagger-platforms/src/bandcamp.rs @@ -138,7 +138,7 @@ impl Into for BandcampSearchResult { title: self.name, artists: vec![self.band_name], album: self.album_name, - release_id: self.album_id.map(|a| a.to_string()).unwrap_or(String::new()), + release_id: self.album_id.map(|a| a.to_string()), url: self.item_url_path, ..Default::default() } @@ -191,7 +191,7 @@ impl Into for BandcampTrack { genres: genre.map(|g| vec![g]).unwrap_or(vec![]), track_id: Some(self.id.clone()), url: self.id, - release_id: self.in_album.id.unwrap_or(String::new()), + release_id: self.in_album.id, track_total: self.in_album.num_tracks, thumbnail: Some(self.image.replace("_10.", "_23.")), art: Some(self.image), diff --git a/crates/onetagger-platforms/src/beatport.rs b/crates/onetagger-platforms/src/beatport.rs index b40088f..42ba265 100644 --- a/crates/onetagger-platforms/src/beatport.rs +++ b/crates/onetagger-platforms/src/beatport.rs @@ -8,7 +8,7 @@ use scraper::{Html, Selector}; use serde::de::DeserializeOwned; use serde::{Serialize, Deserialize}; use onetagger_tag::FrameName; -use onetagger_tagger::{Track, TaggerConfig, AutotaggerSource, AudioFileInfo, MatchingUtils, TrackNumber, AutotaggerSourceBuilder, PlatformInfo, PlatformCustomOptions, PlatformCustomOptionValue, supported_tags, SupportedTag, TrackMatch}; +use onetagger_tagger::{supported_tags, Album, AudioFileInfo, AutotaggerSource, AutotaggerSourceBuilder, MatchingUtils, PlatformCustomOptionValue, PlatformCustomOptions, PlatformInfo, SupportedTag, TaggerConfig, Track, TrackMatch, TrackNumber}; use serde_json::Value; const INVALID_ART: &'static str = "ab2d1d04-233d-4b08-8234-9782b34dcab8"; @@ -102,6 +102,15 @@ impl Beatport { Ok(response) } + /// Get tracks from release + pub fn release_tracks(&self, id: i64) -> Result, Error> { + let token = self.update_token()?; + let response: BeatportPagination = self.client.get(&format!("https://api.beatport.com/v4/catalog/releases/{}/tracks?per_page=200", id)) + .bearer_auth(token) + .send()?.json()?; + Ok(response.results) + } + /// Beatport returns 403 if you have more than single () pair pub fn clear_search_query(query: &str) -> String { @@ -210,6 +219,11 @@ pub struct BeatportRelease { pub artists: Option>, } +#[derive(Debug, Clone, Serialize, Deserialize)] +struct BeatportPagination { + pub results: Vec +} + impl BeatportTrackResult { pub fn to_track(self, include_version: bool) -> Track { Track { @@ -257,7 +271,7 @@ impl BeatportTrack { (FrameName::same("UNIQUEFILEID"), vec![format!("https://beatport.com|{}", &self.id)]) ], track_id: Some(self.id.to_string()), - release_id: self.release.id.to_string(), + release_id: Some(self.release.id.to_string()), duration: Duration::from_millis(self.length_ms.unwrap_or(0)).into(), remixers: self.remixers.into_iter().map(|r| r.name).collect(), track_number: self.number.map(|n| TrackNumber::Number(n as i32)), @@ -397,7 +411,7 @@ impl AutotaggerSource for Beatport { return Ok(()); } - let release = self.release(track.release_id.parse()?)?; + let release = self.release(track.release_id.as_ref().ok_or(anyhow!("Missing release_id"))?.parse()?)?; track.track_total = release.track_count; track.album_artists = match release.artists { Some(a) => a.into_iter().map(|a| a.name).collect(), @@ -406,6 +420,21 @@ impl AutotaggerSource for Beatport { Ok(()) } + fn get_album(&mut self, id: &str, config: &TaggerConfig) -> Result, Error> { + let custom_config: BeatportConfig = config.get_custom("beatport")?; + let id: i64 = id.trim().parse()?; + let release = self.release(id)?; + let tracks = self.release_tracks(id)?; + + let album = Album { + id: id.to_string(), + name: release.name, + tracks: tracks.into_iter().map(|t| t.to_track(custom_config.art_resolution)).collect() + }; + + Ok(Some(album)) + } + } /// For creating Beatport instances @@ -458,4 +487,15 @@ struct BeatportConfig { pub art_resolution: u32, pub max_pages: i32, pub ignore_version: bool +} + +#[test] +fn test_album() { + let mut builder = BeatportBuilder::new(); + let mut config = TaggerConfig::default(); + let custom_config = builder.info().custom_options.get_defaults(); + config.custom.0.insert("beatport".to_string(), custom_config); + + let mut bp = builder.get_source(&config).unwrap(); + bp.get_album("2174307", &config).unwrap(); } \ No newline at end of file diff --git a/crates/onetagger-platforms/src/beatsource.rs b/crates/onetagger-platforms/src/beatsource.rs index b21df5f..8a4f814 100644 --- a/crates/onetagger-platforms/src/beatsource.rs +++ b/crates/onetagger-platforms/src/beatsource.rs @@ -117,7 +117,7 @@ impl BeatsourceTrack { label: Some(self.release.label.name), catalog_number: Some(self.catalog_number), track_id: Some(self.id.to_string()), - release_id: self.release.id.to_string(), + release_id: Some(self.release.id.to_string()), duration: self.length_ms.map(|ms| Duration::from_millis(ms)).unwrap_or(Duration::ZERO).into(), remixers: self.remixers.into_iter().map(|r| r.name).collect(), release_date: NaiveDate::parse_from_str(&self.publish_date, "%Y-%m-%d").ok(), diff --git a/crates/onetagger-platforms/src/deezer.rs b/crates/onetagger-platforms/src/deezer.rs index 8f49882..5273f08 100644 --- a/crates/onetagger-platforms/src/deezer.rs +++ b/crates/onetagger-platforms/src/deezer.rs @@ -98,7 +98,7 @@ impl AutotaggerSource for Deezer { // Extend with album data if config.any_tag_enabled(&supported_tags!(Genre, TrackTotal, Label, AlbumArtist)) { - let id = track.release_id.parse().unwrap(); + let id = track.release_id.as_ref().ok_or(anyhow!("Missing release_id"))?.parse()?; match self.album(id) { Ok(album) => { track.genres = album.genres.data.into_iter().map(|g| g.name).collect(); @@ -183,7 +183,7 @@ impl Into for DeezerTrack { url: self.link, catalog_number: Some(self.id.to_string()), track_id: Some(self.id.to_string()), - release_id: self.album.id.to_string(), + release_id: Some(self.album.id.to_string()), duration: Duration::from_secs(self.duration as u64).into(), explicit: self.explicit_lyrics.or(self.explicit_content_lyrics.map(|i| i == 1)), thumbnail: Some(Deezer::image_url("cover", &self.album.md5_image, 150)), diff --git a/crates/onetagger-platforms/src/discogs.rs b/crates/onetagger-platforms/src/discogs.rs index fd1acba..fe0b3f9 100644 --- a/crates/onetagger-platforms/src/discogs.rs +++ b/crates/onetagger-platforms/src/discogs.rs @@ -431,7 +431,7 @@ impl ReleaseMaster { release_date, catalog_number, track_id: None, - release_id: self.id.to_string(), + release_id: Some(self.id.to_string()), duration: MatchingUtils::parse_duration(&self.tracks[track_index].duration).unwrap_or(Duration::ZERO).into(), track_number: Some(track_number), disc_number, diff --git a/crates/onetagger-platforms/src/itunes.rs b/crates/onetagger-platforms/src/itunes.rs index 652cb5f..59678a1 100644 --- a/crates/onetagger-platforms/src/itunes.rs +++ b/crates/onetagger-platforms/src/itunes.rs @@ -118,7 +118,7 @@ impl SearchResult { album: collection_name.clone(), url: track_view_url.to_string(), track_id: Some(track_id.to_string()), - release_id: collection_id.map(|c| c.to_string()).unwrap_or_default(), + release_id: collection_id.map(|c| c.to_string()), duration: track_time_millis.map(|d| Duration::from_millis(d)).unwrap_or(Duration::ZERO).into(), genres: vec![primary_genre_name.to_string()], release_date: release_date.as_ref().map(|release_date| NaiveDate::parse_from_str(&release_date[0..10], "%Y-%m-%d").ok()).flatten(), diff --git a/crates/onetagger-platforms/src/junodownload.rs b/crates/onetagger-platforms/src/junodownload.rs index 7df1332..0bdae88 100644 --- a/crates/onetagger-platforms/src/junodownload.rs +++ b/crates/onetagger-platforms/src/junodownload.rs @@ -154,7 +154,7 @@ impl JunoDownload { url: format!("https://www.junodownload.com{}", url), catalog_number: catalog_number.clone(), other: vec![], - release_id: release_id.clone(), + release_id: Some(release_id.clone()), duration: duration.into(), track_number: Some(TrackNumber::Number((track_index + 1) as i32)), track_total: Some(track_total), diff --git a/crates/onetagger-platforms/src/musicbrainz.rs b/crates/onetagger-platforms/src/musicbrainz.rs index cb13c90..e126f10 100644 --- a/crates/onetagger-platforms/src/musicbrainz.rs +++ b/crates/onetagger-platforms/src/musicbrainz.rs @@ -68,7 +68,7 @@ impl MusicBrainz { })); } track.album = Some(release.title.to_string()); - track.release_id = release.id.to_string(); + track.release_id = Some(release.id.to_string()); // Label if let Some(label_info) = match &release.label_info { LabelInfoResult::Array(labels) => labels.first(), @@ -163,7 +163,7 @@ impl Into for Recording { album: release.map(|a| a.title.to_string()), url: format!("https://musicbrainz.org/recording/{}", self.id), track_id: Some(self.id), - release_id: release.map(|r| r.id.to_string()).unwrap_or(String::new()), + release_id: release.map(|r| r.id.to_string()), duration: self.length.map(|l| Duration::from_millis(l)).unwrap_or(Duration::ZERO).into(), release_year: self.first_release_date.clone().map(|d| (d.len() >= 4).then(|| d[0..4].parse().ok()).flatten()).flatten(), release_date: self.first_release_date.map(|d| NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok()).flatten(), diff --git a/crates/onetagger-platforms/src/spotify.rs b/crates/onetagger-platforms/src/spotify.rs index 1311b36..d18d1f6 100644 --- a/crates/onetagger-platforms/src/spotify.rs +++ b/crates/onetagger-platforms/src/spotify.rs @@ -152,7 +152,7 @@ impl Spotify { fn extend_track_spotify(&self, track: &mut Track, config: &TaggerConfig) -> Result<(), Error> { // Fetch album if config.tag_enabled(SupportedTag::Label) || config.tag_enabled(SupportedTag::Genre) { - match self.album(&AlbumId::from_id(&track.release_id)?) { + match self.album(&AlbumId::from_id(track.release_id.as_ref().ok_or(anyhow!("Missing release_id"))?)?) { Ok(album) => { track.label = album.label; track.genres.extend(album.genres.into_iter()); @@ -243,7 +243,7 @@ fn full_track_to_track(track: FullTrack) -> Track { art: track.album.images.first().map(|i| i.url.to_string()), url: format!("https://open.spotify.com/track/{}", track.id.as_ref().map(|i| i.id()).unwrap_or("")), track_id: track.id.map(|i| i.id().to_string()), - release_id: track.album.id.map(|i| i.id().to_string()).unwrap_or(String::new()), + release_id: track.album.id.map(|i| i.id().to_string()), duration: track.duration.to_std().unwrap().into(), track_number: Some(TrackNumber::Number(track.track_number as i32)), isrc: track.external_ids.into_iter().find(|(k, _)| k == "isrc").map(|(_, v)| v.to_string()), diff --git a/crates/onetagger-platforms/src/traxsource.rs b/crates/onetagger-platforms/src/traxsource.rs index 18195b3..0c5ccfd 100644 --- a/crates/onetagger-platforms/src/traxsource.rs +++ b/crates/onetagger-platforms/src/traxsource.rs @@ -109,7 +109,7 @@ impl Traxsource { release_date: NaiveDate::parse_from_str(&release_date, "%Y-%m-%d").ok(), genres: genre.map(|g| vec![g]).unwrap_or_default(), track_id: Some(track_id), - release_id: String::new(), + release_id: None, duration: duration.into(), thumbnail: art_url, ..Default::default() @@ -139,7 +139,7 @@ impl Traxsource { // Get release id let release_id = album_url.replace("/title/", ""); - track.release_id = release_id[..release_id.find("/").unwrap()].to_string(); + track.release_id = Some(release_id[..release_id.find("/").unwrap()].to_string()); // Album metadata if !album_meta { diff --git a/crates/onetagger-tagger/src/custom.rs b/crates/onetagger-tagger/src/custom.rs index cfb6ce3..e70f1e0 100644 --- a/crates/onetagger-tagger/src/custom.rs +++ b/crates/onetagger-tagger/src/custom.rs @@ -4,7 +4,7 @@ use log::{Record, Level, RecordBuilder}; use crate::TrackMatch; /// Version of supported custom platform -pub const CUSTOM_PLATFORM_COMPATIBILITY: i32 = 43; +pub const CUSTOM_PLATFORM_COMPATIBILITY: i32 = 44; /// Logging from plugins #[no_mangle] diff --git a/crates/onetagger-tagger/src/lib.rs b/crates/onetagger-tagger/src/lib.rs index fdb1b10..32756c0 100644 --- a/crates/onetagger-tagger/src/lib.rs +++ b/crates/onetagger-tagger/src/lib.rs @@ -82,6 +82,10 @@ pub struct TaggerConfig { /// Fetch all results instead of the most likely ones (used for (future) manual tag) pub fetch_all_results: bool, + pub album_tagging: bool, + /// % of tracks that have to be from one album to be considered as the correct + pub album_tagging_ratio: f32, + /// Platform specific. Format: `{ platform: { custom_option: value }}` pub custom: PlatformTaggerConfig, pub spotify: Option, @@ -160,7 +164,9 @@ impl Default for TaggerConfig { capitalize_genres: false, remove_all_covers: false, id3_comm_lang: None, - fetch_all_results: false + fetch_all_results: false, + album_tagging: false, + album_tagging_ratio: 0.5 } } } @@ -244,7 +250,7 @@ pub struct Track { /// Tag name, Value pub other: Vec<(FrameName, Vec)>, pub track_id: Option, - pub release_id: String, + pub release_id: Option, pub duration: Duration, pub remixers: Vec, pub track_number: Option, @@ -355,7 +361,7 @@ impl PartialOrd for TrackMatch { } /// Why was this track matched -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Ord, PartialOrd)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Ord, PartialOrd, Copy)] #[serde(rename_all = "camelCase")] pub enum MatchReason { Fuzzy, @@ -363,6 +369,7 @@ pub enum MatchReason { ISRC, #[serde(rename = "id")] ID, + Album } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -554,6 +561,18 @@ impl LyricsExt for Lyrics { } +/// Representation of an Album / Release. +/// Very slim, since `Track` should have all the relevant information +#[derive(Debug, Clone, Serialize, Deserialize)] +#[repr(C)] +pub struct Album { + pub id: String, + pub name: String, + + pub tracks: Vec +} + + #[derive(Debug, Clone, Serialize, Deserialize)] #[repr(C)] pub struct AudioFileInfo { @@ -657,8 +676,16 @@ pub trait AutotaggerSourceBuilder: Any + Send + Sync { pub trait AutotaggerSource: Any + Send + Sync { /// Returns (accuracy, track) fn match_track(&mut self, info: &AudioFileInfo, config: &TaggerConfig) -> Result, Error>; + /// Extend track with extra metadata (match track should be as fast as possible) fn extend_track(&mut self, track: &mut Track, config: &TaggerConfig) -> Result<(), Error>; + + /// Get an album by ID (used for album tagging) + #[allow(unused_variables)] + fn get_album(&mut self, id: &str, config: &TaggerConfig) -> Result, Error> { + warn!("Album tagging not supported on this platform!"); + Ok(None) + } } /// Response from Config callback @@ -809,6 +836,15 @@ impl PlatformCustomOptions { self.options.push(PlatformCustomOption::new(id, label, value).tooltip(tooltip)); self } + + /// Get the default config values + pub fn get_defaults(&self) -> Value { + let mut out = HashMap::new(); + for option in &self.options { + out.insert(option.id.to_owned(), option.value.json_value()); + } + serde_json::to_value(&out).unwrap() + } } pub struct MatchingUtils;