From 9d8d543494b991a69d05276cfbc101017123249a Mon Sep 17 00:00:00 2001
From: Antoine Gersant <antoine.gersant@lesforges.org>
Date: Mon, 15 Jul 2024 01:29:09 -0700
Subject: [PATCH] Adds multi-value fields (single row)

---
 src/app/index/metadata.rs         | 367 +++++++++++++-----------------
 src/app/index/query.rs            |   4 +-
 src/app/index/test.rs             |   4 +-
 src/app/index/types.rs            |  28 ++-
 src/app/index/update/collector.rs |  33 +--
 src/app/index/update/inserter.rs  |  75 ++++--
 src/app/index/update/traverser.rs |   4 +-
 src/app/lastfm.rs                 |   2 +-
 src/db/20240711080449_init.sql    |  14 +-
 src/server/axum/api.rs            |  36 +--
 src/server/dto.rs                 | 100 +++++++-
 src/server/test.rs                |   7 +-
 src/server/test/admin.rs          |   9 +-
 src/server/test/collection.rs     |  32 +--
 src/server/test/playlist.rs       |   3 +-
 15 files changed, 391 insertions(+), 327 deletions(-)

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);
 }