diff --git a/src/app/index/metadata.rs b/src/app/index/metadata.rs index 943b00d..c8ef03c 100644 --- a/src/app/index/metadata.rs +++ b/src/app/index/metadata.rs @@ -1,7 +1,6 @@ use id3::TagLike; use lewton::inside_ogg::OggStreamReader; use log::error; -use regex::Regex; use std::fs; use std::path::{Path, PathBuf}; @@ -28,69 +27,31 @@ pub enum Error { VorbisCommentNotFoundInFlacFile, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SongTags { +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct SongMetadata { pub disc_number: Option<u32>, pub track_number: Option<u32>, pub title: Option<String>, pub duration: Option<u32>, - pub artist: Option<String>, - pub album_artist: Option<String>, + pub artists: Vec<String>, + pub album_artists: Vec<String>, pub album: Option<String>, pub year: Option<i32>, pub has_artwork: bool, - pub lyricist: Option<String>, - pub composer: Option<String>, - pub genre: Option<String>, - pub label: Option<String>, + pub lyricists: Vec<String>, + pub composers: Vec<String>, + pub genres: Vec<String>, + pub labels: Vec<String>, } -impl From<id3::Tag> for SongTags { - fn from(tag: id3::Tag) -> Self { - let artist = tag.artist().map(|s| s.to_string()); - let album_artist = tag.album_artist().map(|s| s.to_string()); - let album = tag.album().map(|s| s.to_string()); - let title = tag.title().map(|s| s.to_string()); - let duration = tag.duration(); - let disc_number = tag.disc(); - let track_number = tag.track(); - let year = tag - .year() - .or_else(|| tag.date_released().map(|d| d.year)) - .or_else(|| tag.original_date_released().map(|d| d.year)) - .or_else(|| tag.date_recorded().map(|d| d.year)); - let has_artwork = tag.pictures().count() > 0; - let lyricist = tag.get_text("TEXT"); - let composer = tag.get_text("TCOM"); - let genre = tag.genre().map(|s| s.to_string()); - let label = tag.get_text("TPUB"); - - SongTags { - disc_number, - track_number, - title, - duration, - artist, - album_artist, - album, - year, - has_artwork, - lyricist, - composer, - genre, - label, - } - } -} - -pub fn read(path: &Path) -> Option<SongTags> { +pub fn read(path: &Path) -> Option<SongMetadata> { let data = match utils::get_audio_format(path) { - Some(AudioFormat::AIFF) => read_aiff(path), + Some(AudioFormat::AIFF) => read_id3(path), Some(AudioFormat::FLAC) => read_flac(path), Some(AudioFormat::MP3) => read_mp3(path), Some(AudioFormat::OGG) => read_vorbis(path), Some(AudioFormat::OPUS) => read_opus(path), - Some(AudioFormat::WAVE) => read_wave(path), + Some(AudioFormat::WAVE) => read_id3(path), Some(AudioFormat::APE) | Some(AudioFormat::MPC) => read_ape(path), Some(AudioFormat::MP4) | Some(AudioFormat::M4B) => read_mp4(path), None => return None, @@ -104,23 +65,20 @@ pub fn read(path: &Path) -> Option<SongTags> { } } -trait FrameContent { - /// Returns the value stored, if any, in the Frame. - /// Say "TCOM" returns composer field. - fn get_text(&self, key: &str) -> Option<String>; +trait ID3Ext { + fn get_text_values(&self, frame_name: &str) -> Vec<String>; } -impl FrameContent for id3::Tag { - fn get_text(&self, key: &str) -> Option<String> { - let frame = self.get(key)?; - match frame.content() { - id3::Content::Text(value) => Some(value.to_string()), - _ => None, - } +impl ID3Ext for id3::Tag { + fn get_text_values(&self, frame_name: &str) -> Vec<String> { + self.get(frame_name) + .and_then(|f| f.content().text_values()) + .map(|i| i.map(str::to_string).collect()) + .unwrap_or_default() } } -fn read_mp3(path: &Path) -> Result<SongTags, Error> { +fn read_id3(path: &Path) -> Result<SongMetadata, Error> { let tag = id3::Tag::read_from_path(path).or_else(|error| { if let Some(tag) = error.partial_tag { Ok(tag) @@ -129,83 +87,102 @@ fn read_mp3(path: &Path) -> Result<SongTags, Error> { } })?; + let artists = tag.get_text_values("TPE1"); + let album_artists = tag.get_text_values("TPE2"); + let album = tag.album().map(|s| s.to_string()); + let title = tag.title().map(|s| s.to_string()); + let duration = tag.duration(); + let disc_number = tag.disc(); + let track_number = tag.track(); + let year = tag + .year() + .or_else(|| tag.date_released().map(|d| d.year)) + .or_else(|| tag.original_date_released().map(|d| d.year)) + .or_else(|| tag.date_recorded().map(|d| d.year)); + let has_artwork = tag.pictures().count() > 0; + let lyricists = tag.get_text_values("TEXT"); + let composers = tag.get_text_values("TCOM"); + let genres = tag.get_text_values("TCON"); + let labels = tag.get_text_values("TPUB"); + + Ok(SongMetadata { + disc_number, + track_number, + title, + duration, + artists, + album_artists, + album, + year, + has_artwork, + lyricists, + composers, + genres, + labels, + }) +} + +fn read_mp3(path: &Path) -> Result<SongMetadata, Error> { + let mut metadata = read_id3(path)?; let duration = { mp3_duration::from_path(path) .map(|d| d.as_secs() as u32) .ok() }; - - let mut song_tags: SongTags = tag.into(); - song_tags.duration = duration; // Use duration from mp3_duration instead of from tags. - Ok(song_tags) + metadata.duration = duration; + Ok(metadata) } -fn read_aiff(path: &Path) -> Result<SongTags, Error> { - let tag = id3::Tag::read_from_path(path).or_else(|error| { - if let Some(tag) = error.partial_tag { - Ok(tag) - } else { - Err(error) +mod ape_ext { + pub fn read_string(item: &ape::Item) -> Option<String> { + match item.value { + ape::ItemValue::Text(ref s) => Some(s.clone()), + _ => None, } - })?; - Ok(tag.into()) -} + } -fn read_wave(path: &Path) -> Result<SongTags, Error> { - let tag = id3::Tag::read_from_path(path).or_else(|error| { - if let Some(tag) = error.partial_tag { - Ok(tag) - } else { - Err(error) + pub fn read_strings(items: Vec<&ape::Item>) -> Vec<String> { + items.iter().filter_map(|i| read_string(i)).collect() + } + + pub fn read_i32(item: &ape::Item) -> Option<i32> { + match item.value { + ape::ItemValue::Text(ref s) => s.parse::<i32>().ok(), + _ => None, } - })?; - Ok(tag.into()) -} - -fn read_ape_string(item: &ape::Item) -> Option<String> { - match item.value { - ape::ItemValue::Text(ref s) => Some(s.clone()), - _ => None, } -} -fn read_ape_i32(item: &ape::Item) -> Option<i32> { - match item.value { - ape::ItemValue::Text(ref s) => s.parse::<i32>().ok(), - _ => None, - } -} - -fn read_ape_x_of_y(item: &ape::Item) -> Option<u32> { - match item.value { - ape::ItemValue::Text(ref s) => { - let format = Regex::new(r#"^\d+"#).unwrap(); - if let Some(m) = format.find(s) { - s[m.start()..m.end()].parse().ok() - } else { - None + pub fn read_x_of_y(item: &ape::Item) -> Option<u32> { + match item.value { + ape::ItemValue::Text(ref s) => { + let format = regex::Regex::new(r#"^\d+"#).unwrap(); + if let Some(m) = format.find(s) { + s[m.start()..m.end()].parse().ok() + } else { + None + } } + _ => None, } - _ => None, } } -fn read_ape(path: &Path) -> Result<SongTags, Error> { +fn read_ape(path: &Path) -> Result<SongMetadata, Error> { let tag = ape::read_from_path(path)?; - let artist = tag.item("Artist").and_then(read_ape_string); - let album = tag.item("Album").and_then(read_ape_string); - let album_artist = tag.item("Album artist").and_then(read_ape_string); - let title = tag.item("Title").and_then(read_ape_string); - let year = tag.item("Year").and_then(read_ape_i32); - let disc_number = tag.item("Disc").and_then(read_ape_x_of_y); - let track_number = tag.item("Track").and_then(read_ape_x_of_y); - let lyricist = tag.item("LYRICIST").and_then(read_ape_string); - let composer = tag.item("COMPOSER").and_then(read_ape_string); - let genre = tag.item("GENRE").and_then(read_ape_string); - let label = tag.item("PUBLISHER").and_then(read_ape_string); - Ok(SongTags { - artist, - album_artist, + let artists = ape_ext::read_strings(tag.items("Artist")); + let album = tag.item("Album").and_then(ape_ext::read_string); + let album_artists = ape_ext::read_strings(tag.items("Album artist")); + let title = tag.item("Title").and_then(ape_ext::read_string); + let year = tag.item("Year").and_then(ape_ext::read_i32); + let disc_number = tag.item("Disc").and_then(ape_ext::read_x_of_y); + let track_number = tag.item("Track").and_then(ape_ext::read_x_of_y); + let lyricists = ape_ext::read_strings(tag.items("LYRICIST")); + let composers = ape_ext::read_strings(tag.items("COMPOSER")); + let genres = ape_ext::read_strings(tag.items("GENRE")); + let labels = ape_ext::read_strings(tag.items("PUBLISHER")); + Ok(SongMetadata { + artists, + album_artists, album, title, duration: None, @@ -213,97 +190,67 @@ fn read_ape(path: &Path) -> Result<SongTags, Error> { track_number, year, has_artwork: false, - lyricist, - composer, - genre, - label, + lyricists, + composers, + genres, + labels, }) } -fn read_vorbis(path: &Path) -> Result<SongTags, Error> { +fn read_vorbis(path: &Path) -> Result<SongMetadata, Error> { let file = fs::File::open(path).map_err(|e| Error::Io(path.to_owned(), e))?; let source = OggStreamReader::new(file)?; - let mut tags = SongTags { - artist: None, - album_artist: None, - album: None, - title: None, - duration: None, - disc_number: None, - track_number: None, - year: None, - has_artwork: false, - lyricist: None, - composer: None, - genre: None, - label: None, - }; - + let mut metadata = SongMetadata::default(); for (key, value) in source.comment_hdr.comment_list { utils::match_ignore_case! { match key { - "TITLE" => tags.title = Some(value), - "ALBUM" => tags.album = Some(value), - "ARTIST" => tags.artist = Some(value), - "ALBUMARTIST" => tags.album_artist = Some(value), - "TRACKNUMBER" => tags.track_number = value.parse::<u32>().ok(), - "DISCNUMBER" => tags.disc_number = value.parse::<u32>().ok(), - "DATE" => tags.year = value.parse::<i32>().ok(), - "LYRICIST" => tags.lyricist = Some(value), - "COMPOSER" => tags.composer = Some(value), - "GENRE" => tags.genre = Some(value), - "PUBLISHER" => tags.label = Some(value), + "TITLE" => metadata.title = Some(value), + "ALBUM" => metadata.album = Some(value), + "ARTIST" => metadata.artists.push(value), + "ALBUMARTIST" => metadata.album_artists.push(value), + "TRACKNUMBER" => metadata.track_number = value.parse::<u32>().ok(), + "DISCNUMBER" => metadata.disc_number = value.parse::<u32>().ok(), + "DATE" => metadata.year = value.parse::<i32>().ok(), + "LYRICIST" => metadata.lyricists.push(value), + "COMPOSER" => metadata.composers.push(value), + "GENRE" => metadata.genres.push(value), + "PUBLISHER" => metadata.labels.push(value), _ => (), } } } - Ok(tags) + Ok(metadata) } -fn read_opus(path: &Path) -> Result<SongTags, Error> { +fn read_opus(path: &Path) -> Result<SongMetadata, Error> { let headers = opus_headers::parse_from_path(path)?; - let mut tags = SongTags { - artist: None, - album_artist: None, - album: None, - title: None, - duration: None, - disc_number: None, - track_number: None, - year: None, - has_artwork: false, - lyricist: None, - composer: None, - genre: None, - label: None, - }; - + let mut metadata = SongMetadata::default(); for (key, value) in headers.comments.user_comments { utils::match_ignore_case! { match key { - "TITLE" => tags.title = Some(value), - "ALBUM" => tags.album = Some(value), - "ARTIST" => tags.artist = Some(value), - "ALBUMARTIST" => tags.album_artist = Some(value), - "TRACKNUMBER" => tags.track_number = value.parse::<u32>().ok(), - "DISCNUMBER" => tags.disc_number = value.parse::<u32>().ok(), - "DATE" => tags.year = value.parse::<i32>().ok(), - "LYRICIST" => tags.lyricist = Some(value), - "COMPOSER" => tags.composer = Some(value), - "GENRE" => tags.genre = Some(value), - "PUBLISHER" => tags.label = Some(value), + "TITLE" => metadata.title = Some(value), + "ALBUM" => metadata.album = Some(value), + "ARTIST" => metadata.artists.push(value), + "ALBUMARTIST" => metadata.album_artists.push(value), + "TRACKNUMBER" => metadata.track_number = value.parse::<u32>().ok(), + "DISCNUMBER" => metadata.disc_number = value.parse::<u32>().ok(), + "DATE" => metadata.year = value.parse::<i32>().ok(), + "LYRICIST" => metadata.lyricists.push(value), + "COMPOSER" => metadata.composers.push(value), + "GENRE" => metadata.genres.push(value), + "PUBLISHER" => metadata.labels.push(value), _ => (), } } } - Ok(tags) + Ok(metadata) } -fn read_flac(path: &Path) -> Result<SongTags, Error> { +fn read_flac(path: &Path) -> Result<SongMetadata, Error> { let tag = metaflac::Tag::read_from_path(path)?; let vorbis = tag .vorbis_comments() @@ -319,9 +266,11 @@ fn read_flac(path: &Path) -> Result<SongTags, Error> { }; let has_artwork = tag.pictures().count() > 0; - Ok(SongTags { - artist: vorbis.artist().map(|v| v[0].clone()), - album_artist: vorbis.album_artist().map(|v| v[0].clone()), + let multivalue = |o: Option<&Vec<String>>| o.cloned().unwrap_or_default(); + + Ok(SongMetadata { + artists: multivalue(vorbis.artist()), + album_artists: multivalue(vorbis.album_artist()), album: vorbis.album().map(|v| v[0].clone()), title: vorbis.title().map(|v| v[0].clone()), duration, @@ -329,20 +278,20 @@ fn read_flac(path: &Path) -> Result<SongTags, Error> { track_number: vorbis.track(), year, has_artwork, - lyricist: vorbis.get("LYRICIST").map(|v| v[0].clone()), - composer: vorbis.get("COMPOSER").map(|v| v[0].clone()), - genre: vorbis.get("GENRE").map(|v| v[0].clone()), - label: vorbis.get("PUBLISHER").map(|v| v[0].clone()), + lyricists: multivalue(vorbis.get("LYRICIST")), + composers: multivalue(vorbis.get("COMPOSER")), + genres: multivalue(vorbis.get("GENRE")), + labels: multivalue(vorbis.get("PUBLISHER")), }) } -fn read_mp4(path: &Path) -> Result<SongTags, Error> { +fn read_mp4(path: &Path) -> Result<SongMetadata, Error> { let mut tag = mp4ameta::Tag::read_from_path(path)?; let label_ident = mp4ameta::FreeformIdent::new("com.apple.iTunes", "Label"); - Ok(SongTags { - artist: tag.take_artist(), - album_artist: tag.take_album_artist(), + Ok(SongMetadata { + artists: tag.take_artists().collect(), + album_artists: tag.take_album_artists().collect(), album: tag.take_album(), title: tag.take_title(), duration: tag.duration().map(|v| v.as_secs() as u32), @@ -350,39 +299,39 @@ fn read_mp4(path: &Path) -> Result<SongTags, Error> { track_number: tag.track_number().map(|d| d as u32), year: tag.year().and_then(|v| v.parse::<i32>().ok()), has_artwork: tag.artwork().is_some(), - lyricist: tag.take_lyricist(), - composer: tag.take_composer(), - genre: tag.take_genre(), - label: tag.take_strings_of(&label_ident).next(), + lyricists: tag.take_lyricists().collect(), + composers: tag.take_composers().collect(), + genres: tag.take_genres().collect(), + labels: tag.take_strings_of(&label_ident).collect(), }) } #[test] fn reads_file_metadata() { - let sample_tags = SongTags { + let sample_tags = SongMetadata { disc_number: Some(3), track_number: Some(1), title: Some("TEST TITLE".into()), - artist: Some("TEST ARTIST".into()), - album_artist: Some("TEST ALBUM ARTIST".into()), + artists: vec!["TEST ARTIST".into()], + album_artists: vec!["TEST ALBUM ARTIST".into()], album: Some("TEST ALBUM".into()), duration: None, year: Some(2016), has_artwork: false, - lyricist: Some("TEST LYRICIST".into()), - composer: Some("TEST COMPOSER".into()), - genre: Some("TEST GENRE".into()), - label: Some("TEST LABEL".into()), + lyricists: vec!["TEST LYRICIST".into()], + composers: vec!["TEST COMPOSER".into()], + genres: vec!["TEST GENRE".into()], + labels: vec!["TEST LABEL".into()], }; - let flac_sample_tag = SongTags { + let flac_sample_tag = SongMetadata { duration: Some(0), ..sample_tags.clone() }; - let mp3_sample_tag = SongTags { + let mp3_sample_tag = SongMetadata { duration: Some(0), ..sample_tags.clone() }; - let m4a_sample_tag = SongTags { + let m4a_sample_tag = SongMetadata { duration: Some(0), ..sample_tags.clone() }; diff --git a/src/app/index/query.rs b/src/app/index/query.rs index ad921d0..95a5ff4 100644 --- a/src/app/index/query.rs +++ b/src/app/index/query.rs @@ -167,8 +167,8 @@ impl Index { WHERE ( path LIKE $1 OR title LIKE $1 OR album LIKE $1 - OR artist LIKE $1 - OR album_artist LIKE $1 + OR artists LIKE $1 + OR album_artists LIKE $1 ) AND parent NOT LIKE $1 "#, diff --git a/src/app/index/test.rs b/src/app/index/test.rs index a03b1b8..bf2dcfc 100644 --- a/src/app/index/test.rs +++ b/src/app/index/test.rs @@ -203,8 +203,8 @@ async fn can_get_a_song() { assert_eq!(song.track_number, Some(5)); assert_eq!(song.disc_number, None); assert_eq!(song.title, Some("シャーベット (Sherbet)".to_owned())); - assert_eq!(song.artist, Some("Tobokegao".to_owned())); - assert_eq!(song.album_artist, None); + assert_eq!(song.artists, MultiString(vec!["Tobokegao".to_owned()])); + assert_eq!(song.album_artists, MultiString(vec![])); assert_eq!(song.album, Some("Picnic".to_owned())); assert_eq!(song.year, Some(2016)); assert_eq!( diff --git a/src/app/index/types.rs b/src/app/index/types.rs index f0bf7c8..0c19dcb 100644 --- a/src/app/index/types.rs +++ b/src/app/index/types.rs @@ -1,34 +1,34 @@ -use serde::{Deserialize, Serialize}; use std::path::Path; use crate::app::vfs::VFS; -#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Eq)] +pub struct MultiString(pub Vec<String>); + +#[derive(Debug, PartialEq, Eq)] pub enum CollectionFile { Directory(Directory), Song(Song), } -#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Eq)] pub struct Song { - #[serde(skip_serializing, skip_deserializing)] pub id: i64, pub path: String, - #[serde(skip_serializing, skip_deserializing)] pub parent: String, pub track_number: Option<i64>, pub disc_number: Option<i64>, pub title: Option<String>, - pub artist: Option<String>, - pub album_artist: Option<String>, + pub artists: MultiString, + pub album_artists: MultiString, pub year: Option<i64>, pub album: Option<String>, pub artwork: Option<String>, pub duration: Option<i64>, - pub lyricist: Option<String>, - pub composer: Option<String>, - pub genre: Option<String>, - pub label: Option<String>, + pub lyricists: MultiString, + pub composers: MultiString, + pub genres: MultiString, + pub labels: MultiString, } impl Song { @@ -47,14 +47,12 @@ impl Song { } } -#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Eq)] pub struct Directory { - #[serde(skip_serializing, skip_deserializing)] pub id: i64, pub path: String, - #[serde(skip_serializing, skip_deserializing)] pub parent: Option<String>, - pub artist: Option<String>, + pub artists: MultiString, pub year: Option<i64>, pub album: Option<String>, pub artwork: Option<String>, diff --git a/src/app/index/update/collector.rs b/src/app/index/update/collector.rs index 89af45d..1acf7d5 100644 --- a/src/app/index/update/collector.rs +++ b/src/app/index/update/collector.rs @@ -1,6 +1,8 @@ use log::error; use regex::Regex; +use crate::app::index::MultiString; + use super::*; pub struct Collector { @@ -31,7 +33,7 @@ impl Collector { fn collect_directory(&self, directory: traverser::Directory) { let mut directory_album = None; let mut directory_year = None; - let mut directory_artist = None; + let mut directory_artists = None; let mut inconsistent_directory_album = false; let mut inconsistent_directory_year = false; let mut inconsistent_directory_artist = false; @@ -56,14 +58,13 @@ impl Collector { directory_album = tags.album.as_ref().cloned(); } - if tags.album_artist.is_some() { + if !tags.album_artists.is_empty() { inconsistent_directory_artist |= - directory_artist.is_some() && directory_artist != tags.album_artist; - directory_artist = tags.album_artist.as_ref().cloned(); - } else if tags.artist.is_some() { - inconsistent_directory_artist |= - directory_artist.is_some() && directory_artist != tags.artist; - directory_artist = tags.artist.as_ref().cloned(); + directory_artists.as_ref() != Some(&tags.album_artists); + directory_artists = Some(tags.album_artists.clone()); + } else if !tags.artists.is_empty() { + inconsistent_directory_artist |= directory_artists.as_ref() != Some(&tags.artists); + directory_artists = Some(tags.artists.clone()); } let artwork_path = if tags.has_artwork { @@ -79,15 +80,15 @@ impl Collector { track_number: tags.track_number.map(|n| n as i32), title: tags.title, duration: tags.duration.map(|n| n as i32), - artist: tags.artist, - album_artist: tags.album_artist, + artists: MultiString(tags.artists), + album_artists: MultiString(tags.album_artists), album: tags.album, year: tags.year, artwork: artwork_path, - lyricist: tags.lyricist, - composer: tags.composer, - genre: tags.genre, - label: tags.label, + lyricists: MultiString(tags.lyricists), + composers: MultiString(tags.composers), + genres: MultiString(tags.genres), + labels: MultiString(tags.labels), })) { error!("Error while sending song from collector: {}", e); } @@ -100,7 +101,7 @@ impl Collector { directory_album = None; } if inconsistent_directory_artist { - directory_artist = None; + directory_artists = None; } if let Err(e) = self @@ -110,7 +111,7 @@ impl Collector { parent: directory_parent_string, artwork: directory_artwork, album: directory_album, - artist: directory_artist, + artists: MultiString(directory_artists.unwrap_or_default()), year: directory_year, date_added: directory.created, })) { diff --git a/src/app/index/update/inserter.rs b/src/app/index/update/inserter.rs index 63de282..09f3517 100644 --- a/src/app/index/update/inserter.rs +++ b/src/app/index/update/inserter.rs @@ -1,8 +1,14 @@ +use std::borrow::Cow; + use log::error; -use sqlx::{QueryBuilder, Sqlite}; +use sqlx::{ + encode::IsNull, + sqlite::{SqliteArgumentValue, SqliteTypeInfo}, + QueryBuilder, Sqlite, +}; use tokio::sync::mpsc::UnboundedReceiver; -use crate::db::DB; +use crate::{app::index::MultiString, db::DB}; const INDEX_BUILDING_INSERT_BUFFER_SIZE: usize = 1000; // Insertions in each transaction @@ -12,22 +18,22 @@ pub struct Song { pub track_number: Option<i32>, pub disc_number: Option<i32>, pub title: Option<String>, - pub artist: Option<String>, - pub album_artist: Option<String>, + pub artists: MultiString, + pub album_artists: MultiString, pub year: Option<i32>, pub album: Option<String>, pub artwork: Option<String>, pub duration: Option<i32>, - pub lyricist: Option<String>, - pub composer: Option<String>, - pub genre: Option<String>, - pub label: Option<String>, + pub lyricists: MultiString, + pub composers: MultiString, + pub genres: MultiString, + pub labels: MultiString, } pub struct Directory { pub path: String, pub parent: Option<String>, - pub artist: Option<String>, + pub artists: MultiString, pub year: Option<i32>, pub album: Option<String>, pub artwork: Option<String>, @@ -46,6 +52,39 @@ pub struct Inserter { db: DB, } +static MULTI_STRING_SEPARATOR: &str = "\u{000C}"; + +impl<'q> sqlx::Encode<'q, Sqlite> for MultiString { + fn encode_by_ref(&self, args: &mut Vec<SqliteArgumentValue<'q>>) -> IsNull { + if self.0.is_empty() { + IsNull::Yes + } else { + let joined = self.0.join(MULTI_STRING_SEPARATOR); + args.push(SqliteArgumentValue::Text(Cow::Owned(joined))); + IsNull::No + } + } +} + +impl From<Option<String>> for MultiString { + fn from(value: Option<String>) -> Self { + match value { + None => MultiString(Vec::new()), + Some(s) => MultiString( + s.split(MULTI_STRING_SEPARATOR) + .map(|s| s.to_string()) + .collect(), + ), + } + } +} + +impl sqlx::Type<Sqlite> for MultiString { + fn type_info() -> SqliteTypeInfo { + <&str as sqlx::Type<Sqlite>>::type_info() + } +} + impl Inserter { pub fn new(db: DB, receiver: UnboundedReceiver<Item>) -> Self { let new_directories = Vec::with_capacity(INDEX_BUILDING_INSERT_BUFFER_SIZE); @@ -90,12 +129,12 @@ impl Inserter { }; let result = QueryBuilder::<Sqlite>::new( - "INSERT INTO directories(path, parent, artist, year, album, artwork, date_added) ", + "INSERT INTO directories(path, parent, artists, year, album, artwork, date_added) ", ) .push_values(&self.new_directories, |mut b, directory| { b.push_bind(&directory.path) .push_bind(&directory.parent) - .push_bind(&directory.artist) + .push_bind(&directory.artists) .push_bind(directory.year) .push_bind(&directory.album) .push_bind(&directory.artwork) @@ -117,23 +156,23 @@ impl Inserter { return; }; - let result = QueryBuilder::<Sqlite>::new("INSERT INTO songs(path, parent, track_number, disc_number, title, artist, album_artist, year, album, artwork, duration, lyricist, composer, genre, label) ") + let result = QueryBuilder::<Sqlite>::new("INSERT INTO songs(path, parent, track_number, disc_number, title, artists, album_artists, year, album, artwork, duration, lyricists, composers, genres, labels) ") .push_values(&self.new_songs, |mut b, song| { b.push_bind(&song.path) .push_bind(&song.parent) .push_bind(song.track_number) .push_bind(song.disc_number) .push_bind(&song.title) - .push_bind(&song.artist) - .push_bind(&song.album_artist) + .push_bind(&song.artists) + .push_bind(&song.album_artists) .push_bind(song.year) .push_bind(&song.album) .push_bind(&song.artwork) .push_bind(song.duration) - .push_bind(&song.lyricist) - .push_bind(&song.composer) - .push_bind(&song.genre) - .push_bind(&song.label); + .push_bind(&song.lyricists) + .push_bind(&song.composers) + .push_bind(&song.genres) + .push_bind(&song.labels); }) .build() .execute(connection.as_mut()) diff --git a/src/app/index/update/traverser.rs b/src/app/index/update/traverser.rs index be12e4b..9084fe4 100644 --- a/src/app/index/update/traverser.rs +++ b/src/app/index/update/traverser.rs @@ -9,12 +9,12 @@ use std::sync::Arc; use std::thread; use std::time::Duration; -use crate::app::index::metadata::{self, SongTags}; +use crate::app::index::metadata::{self, SongMetadata}; #[derive(Debug)] pub struct Song { pub path: PathBuf, - pub metadata: SongTags, + pub metadata: SongMetadata, } #[derive(Debug)] diff --git a/src/app/lastfm.rs b/src/app/lastfm.rs index 0ace649..f20a311 100644 --- a/src/app/lastfm.rs +++ b/src/app/lastfm.rs @@ -86,7 +86,7 @@ impl Manager { async fn scrobble_from_path(&self, track: &Path) -> Result<Scrobble, Error> { let song = self.index.get_song(track).await?; Ok(Scrobble::new( - song.artist.as_deref().unwrap_or(""), + song.artists.0.first().map(|s| s.as_str()).unwrap_or(""), song.title.as_deref().unwrap_or(""), song.album.as_deref().unwrap_or(""), )) diff --git a/src/db/20240711080449_init.sql b/src/db/20240711080449_init.sql index d4b9c60..f47c71a 100644 --- a/src/db/20240711080449_init.sql +++ b/src/db/20240711080449_init.sql @@ -49,7 +49,7 @@ CREATE TABLE directories ( id INTEGER PRIMARY KEY NOT NULL, path TEXT NOT NULL, parent TEXT, - artist TEXT, + artists TEXT, year INTEGER, album TEXT, artwork TEXT, @@ -64,16 +64,16 @@ CREATE TABLE songs ( track_number INTEGER, disc_number INTEGER, title TEXT, - artist TEXT, - album_artist TEXT, + artists TEXT, + album_artists TEXT, year INTEGER, album TEXT, artwork TEXT, duration INTEGER, - lyricist TEXT, - composer TEXT, - genre TEXT, - label TEXT, + lyricists TEXT, + composers TEXT, + genres TEXT, + labels TEXT, UNIQUE(path) ON CONFLICT REPLACE ); diff --git a/src/server/axum/api.rs b/src/server/axum/api.rs index a35eb04..b6d203c 100644 --- a/src/server/axum/api.rs +++ b/src/server/axum/api.rs @@ -255,70 +255,70 @@ async fn post_trigger_index( async fn get_browse_root( _auth: Auth, State(index): State<index::Index>, -) -> Result<Json<Vec<index::CollectionFile>>, APIError> { +) -> Result<Json<Vec<dto::CollectionFile>>, APIError> { let result = index.browse(std::path::Path::new("")).await?; - Ok(Json(result)) + Ok(Json(result.into_iter().map(|f| f.into()).collect())) } async fn get_browse( _auth: Auth, State(index): State<index::Index>, Path(path): Path<String>, -) -> Result<Json<Vec<index::CollectionFile>>, APIError> { +) -> Result<Json<Vec<dto::CollectionFile>>, APIError> { let path = percent_decode_str(&path).decode_utf8_lossy(); let result = index.browse(std::path::Path::new(path.as_ref())).await?; - Ok(Json(result)) + Ok(Json(result.into_iter().map(|f| f.into()).collect())) } async fn get_flatten_root( _auth: Auth, State(index): State<index::Index>, -) -> Result<Json<Vec<index::Song>>, APIError> { +) -> Result<Json<Vec<dto::Song>>, APIError> { let songs = index.flatten(std::path::Path::new("")).await?; - Ok(Json(songs)) + Ok(Json(songs.into_iter().map(|f| f.into()).collect())) } async fn get_flatten( _auth: Auth, State(index): State<index::Index>, Path(path): Path<String>, -) -> Result<Json<Vec<index::Song>>, APIError> { +) -> Result<Json<Vec<dto::Song>>, APIError> { let path = percent_decode_str(&path).decode_utf8_lossy(); let songs = index.flatten(std::path::Path::new(path.as_ref())).await?; - Ok(Json(songs)) + Ok(Json(songs.into_iter().map(|f| f.into()).collect())) } async fn get_random( _auth: Auth, State(index): State<index::Index>, -) -> Result<Json<Vec<index::Directory>>, APIError> { +) -> Result<Json<Vec<dto::Directory>>, APIError> { let result = index.get_random_albums(20).await?; - Ok(Json(result)) + Ok(Json(result.into_iter().map(|f| f.into()).collect())) } async fn get_recent( _auth: Auth, State(index): State<index::Index>, -) -> Result<Json<Vec<index::Directory>>, APIError> { +) -> Result<Json<Vec<dto::Directory>>, APIError> { let result = index.get_recent_albums(20).await?; - Ok(Json(result)) + Ok(Json(result.into_iter().map(|f| f.into()).collect())) } async fn get_search_root( _auth: Auth, State(index): State<index::Index>, -) -> Result<Json<Vec<index::CollectionFile>>, APIError> { +) -> Result<Json<Vec<dto::CollectionFile>>, APIError> { let result = index.search("").await?; - Ok(Json(result)) + Ok(Json(result.into_iter().map(|f| f.into()).collect())) } async fn get_search( _auth: Auth, State(index): State<index::Index>, Path(query): Path<String>, -) -> Result<Json<Vec<index::CollectionFile>>, APIError> { +) -> Result<Json<Vec<dto::CollectionFile>>, APIError> { let result = index.search(&query).await?; - Ok(Json(result)) + Ok(Json(result.into_iter().map(|f| f.into()).collect())) } async fn get_playlists( @@ -350,11 +350,11 @@ async fn get_playlist( auth: Auth, State(playlist_manager): State<playlist::Manager>, Path(name): Path<String>, -) -> Result<Json<Vec<index::Song>>, APIError> { +) -> Result<Json<Vec<dto::Song>>, APIError> { let songs = playlist_manager .read_playlist(&name, auth.get_username()) .await?; - Ok(Json(songs)) + Ok(Json(songs.into_iter().map(|f| f.into()).collect())) } async fn delete_playlist( diff --git a/src/server/dto.rs b/src/server/dto.rs index 161fb3b..566be2f 100644 --- a/src/server/dto.rs +++ b/src/server/dto.rs @@ -1,9 +1,9 @@ use serde::{Deserialize, Serialize}; -use crate::app::{config, ddns, settings, thumbnail, user, vfs}; +use crate::app::{config, ddns, index, settings, thumbnail, user, vfs}; use std::convert::From; -pub const API_MAJOR_VERSION: i32 = 7; +pub const API_MAJOR_VERSION: i32 = 8; pub const API_MINOR_VERSION: i32 = 0; #[derive(PartialEq, Eq, Debug, Serialize, Deserialize)] @@ -231,5 +231,99 @@ impl From<settings::Settings> for Settings { } } -// TODO: Preferences, CollectionFile, Song and Directory should have dto types +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum CollectionFile { + Directory(Directory), + Song(Song), +} + +impl From<index::CollectionFile> for CollectionFile { + fn from(f: index::CollectionFile) -> Self { + match f { + index::CollectionFile::Directory(d) => Self::Directory(d.into()), + index::CollectionFile::Song(s) => Self::Song(s.into()), + } + } +} + +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Song { + pub path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub track_number: Option<i64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disc_number: Option<i64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub artists: Vec<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub album_artists: Vec<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub year: Option<i64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub album: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub artwork: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration: Option<i64>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub lyricists: Vec<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub composers: Vec<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub genres: Vec<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub labels: Vec<String>, +} + +impl From<index::Song> for Song { + fn from(s: index::Song) -> Self { + Self { + path: s.path, + track_number: s.track_number, + disc_number: s.disc_number, + title: s.title, + artists: s.artists.0, + album_artists: s.album_artists.0, + year: s.year, + album: s.album, + artwork: s.artwork, + duration: s.duration, + lyricists: s.lyricists.0, + composers: s.composers.0, + genres: s.genres.0, + labels: s.labels.0, + } + } +} + +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Directory { + pub path: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub artists: Vec<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub year: Option<i64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub album: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub artwork: Option<String>, + pub date_added: i64, +} + +impl From<index::Directory> for Directory { + fn from(d: index::Directory) -> Self { + Self { + path: d.path, + artists: d.artists.0, + year: d.year, + album: d.album, + artwork: d.artwork, + date_added: d.date_added, + } + } +} + +// TODO: Preferences, CollectionFile should have dto types // TODO Song dto type should skip `None` values when serializing, to lower payload sizes by a lot diff --git a/src/server/test.rs b/src/server/test.rs index 6e9fb88..9c483a9 100644 --- a/src/server/test.rs +++ b/src/server/test.rs @@ -22,7 +22,6 @@ mod swagger; mod user; mod web; -use crate::app::index; use crate::server::dto; use crate::server::test::constants::*; @@ -119,7 +118,7 @@ pub trait TestService { loop { let browse_request = protocol::browse(Path::new("")); let response = self - .fetch_json::<(), Vec<index::CollectionFile>>(&browse_request) + .fetch_json::<(), Vec<dto::CollectionFile>>(&browse_request) .await; let entries = response.body(); if !entries.is_empty() { @@ -130,9 +129,7 @@ pub trait TestService { loop { let flatten_request = protocol::flatten(Path::new("")); - let response = self - .fetch_json::<_, Vec<index::Song>>(&flatten_request) - .await; + let response = self.fetch_json::<_, Vec<dto::Song>>(&flatten_request).await; let entries = response.body(); if !entries.is_empty() { break; diff --git a/src/server/test/admin.rs b/src/server/test/admin.rs index f37165a..0cefc85 100644 --- a/src/server/test/admin.rs +++ b/src/server/test/admin.rs @@ -1,6 +1,5 @@ use http::StatusCode; -use crate::app::index; use crate::server::dto; use crate::server::test::{protocol, ServiceType, TestService}; use crate::test_name; @@ -50,17 +49,13 @@ async fn trigger_index_golden_path() { let request = protocol::random(); - let response = service - .fetch_json::<_, Vec<index::Directory>>(&request) - .await; + let response = service.fetch_json::<_, Vec<dto::Directory>>(&request).await; let entries = response.body(); assert_eq!(entries.len(), 0); service.index().await; - let response = service - .fetch_json::<_, Vec<index::Directory>>(&request) - .await; + let response = service.fetch_json::<_, Vec<dto::Directory>>(&request).await; let entries = response.body(); assert_eq!(entries.len(), 3); } diff --git a/src/server/test/collection.rs b/src/server/test/collection.rs index eb96230..e236724 100644 --- a/src/server/test/collection.rs +++ b/src/server/test/collection.rs @@ -1,7 +1,7 @@ use http::StatusCode; use std::path::{Path, PathBuf}; -use crate::app::index; +use crate::server::dto; use crate::server::test::{add_trailing_slash, constants::*, protocol, ServiceType, TestService}; use crate::test_name; @@ -23,7 +23,7 @@ async fn browse_root() { let request = protocol::browse(&PathBuf::new()); let response = service - .fetch_json::<_, Vec<index::CollectionFile>>(&request) + .fetch_json::<_, Vec<dto::CollectionFile>>(&request) .await; assert_eq!(response.status(), StatusCode::OK); let entries = response.body(); @@ -41,7 +41,7 @@ async fn browse_directory() { let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted"].iter().collect(); let request = protocol::browse(&path); let response = service - .fetch_json::<_, Vec<index::CollectionFile>>(&request) + .fetch_json::<_, Vec<dto::CollectionFile>>(&request) .await; assert_eq!(response.status(), StatusCode::OK); let entries = response.body(); @@ -77,7 +77,7 @@ async fn flatten_root() { service.login().await; let request = protocol::flatten(&PathBuf::new()); - let response = service.fetch_json::<_, Vec<index::Song>>(&request).await; + let response = service.fetch_json::<_, Vec<dto::Song>>(&request).await; assert_eq!(response.status(), StatusCode::OK); let entries = response.body(); assert_eq!(entries.len(), 13); @@ -92,7 +92,7 @@ async fn flatten_directory() { service.login().await; let request = protocol::flatten(Path::new(TEST_MOUNT_NAME)); - let response = service.fetch_json::<_, Vec<index::Song>>(&request).await; + let response = service.fetch_json::<_, Vec<dto::Song>>(&request).await; assert_eq!(response.status(), StatusCode::OK); let entries = response.body(); assert_eq!(entries.len(), 13); @@ -127,9 +127,7 @@ async fn random_golden_path() { service.login().await; let request = protocol::random(); - let response = service - .fetch_json::<_, Vec<index::Directory>>(&request) - .await; + let response = service.fetch_json::<_, Vec<dto::Directory>>(&request).await; assert_eq!(response.status(), StatusCode::OK); let entries = response.body(); assert_eq!(entries.len(), 3); @@ -145,9 +143,7 @@ async fn random_with_trailing_slash() { let mut request = protocol::random(); add_trailing_slash(&mut request); - let response = service - .fetch_json::<_, Vec<index::Directory>>(&request) - .await; + let response = service.fetch_json::<_, Vec<dto::Directory>>(&request).await; assert_eq!(response.status(), StatusCode::OK); let entries = response.body(); assert_eq!(entries.len(), 3); @@ -170,9 +166,7 @@ async fn recent_golden_path() { service.login().await; let request = protocol::recent(); - let response = service - .fetch_json::<_, Vec<index::Directory>>(&request) - .await; + let response = service.fetch_json::<_, Vec<dto::Directory>>(&request).await; assert_eq!(response.status(), StatusCode::OK); let entries = response.body(); assert_eq!(entries.len(), 3); @@ -188,9 +182,7 @@ async fn recent_with_trailing_slash() { let mut request = protocol::recent(); add_trailing_slash(&mut request); - let response = service - .fetch_json::<_, Vec<index::Directory>>(&request) - .await; + let response = service.fetch_json::<_, Vec<dto::Directory>>(&request).await; assert_eq!(response.status(), StatusCode::OK); let entries = response.body(); assert_eq!(entries.len(), 3); @@ -212,7 +204,7 @@ async fn search_without_query() { let request = protocol::search(""); let response = service - .fetch_json::<_, Vec<index::CollectionFile>>(&request) + .fetch_json::<_, Vec<dto::CollectionFile>>(&request) .await; assert_eq!(response.status(), StatusCode::OK); } @@ -227,12 +219,12 @@ async fn search_with_query() { let request = protocol::search("door"); let response = service - .fetch_json::<_, Vec<index::CollectionFile>>(&request) + .fetch_json::<_, Vec<dto::CollectionFile>>(&request) .await; let results = response.body(); assert_eq!(results.len(), 1); match results[0] { - index::CollectionFile::Song(ref s) => { + dto::CollectionFile::Song(ref s) => { assert_eq!(s.title, Some("Beyond The Door".into())) } _ => panic!(), diff --git a/src/server/test/playlist.rs b/src/server/test/playlist.rs index cc792f1..4ba8205 100644 --- a/src/server/test/playlist.rs +++ b/src/server/test/playlist.rs @@ -1,6 +1,5 @@ use http::StatusCode; -use crate::app::index; use crate::server::dto; use crate::server::test::{constants::*, protocol, ServiceType, TestService}; use crate::test_name; @@ -83,7 +82,7 @@ async fn get_playlist_golden_path() { } let request = protocol::read_playlist(TEST_PLAYLIST_NAME); - let response = service.fetch_json::<_, Vec<index::Song>>(&request).await; + let response = service.fetch_json::<_, Vec<dto::Song>>(&request).await; assert_eq!(response.status(), StatusCode::OK); }