diff --git a/src/app.rs b/src/app.rs
index d173b72..bde215f 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -9,6 +9,7 @@ pub mod ddns;
 pub mod index;
 pub mod lastfm;
 pub mod playlist;
+pub mod scanner;
 pub mod settings;
 pub mod thumbnail;
 pub mod user;
@@ -34,6 +35,7 @@ pub struct App {
 	pub port: u16,
 	pub web_dir_path: PathBuf,
 	pub swagger_dir_path: PathBuf,
+	pub scanner: scanner::Scanner,
 	pub index: index::Index,
 	pub config_manager: config::Manager,
 	pub ddns_manager: ddns::Manager,
@@ -62,7 +64,9 @@ impl App {
 		let auth_secret = settings_manager.get_auth_secret().await?;
 		let ddns_manager = ddns::Manager::new(db.clone());
 		let user_manager = user::Manager::new(db.clone(), auth_secret);
-		let index = index::Index::new(db.clone(), vfs_manager.clone(), settings_manager.clone());
+		let scanner =
+			scanner::Scanner::new(db.clone(), vfs_manager.clone(), settings_manager.clone());
+		let index = index::Index::new(db.clone(), vfs_manager.clone());
 		let config_manager = config::Manager::new(
 			settings_manager.clone(),
 			user_manager.clone(),
@@ -82,6 +86,7 @@ impl App {
 			port,
 			web_dir_path: paths.web_dir_path,
 			swagger_dir_path: paths.swagger_dir_path,
+			scanner,
 			index,
 			config_manager,
 			ddns_manager,
diff --git a/src/app/index.rs b/src/app/index.rs
index b0b00cf..028d30a 100644
--- a/src/app/index.rs
+++ b/src/app/index.rs
@@ -1,74 +1,21 @@
-use log::error;
-use std::sync::Arc;
-use std::time::Duration;
-use tokio::sync::Notify;
-
-use crate::app::{settings, vfs};
+use crate::app::vfs;
 use crate::db::DB;
 
-mod metadata;
 mod query;
 #[cfg(test)]
 mod test;
 mod types;
-mod update;
 
-pub use self::query::*;
 pub use self::types::*;
 
 #[derive(Clone)]
 pub struct Index {
 	db: DB,
 	vfs_manager: vfs::Manager,
-	settings_manager: settings::Manager,
-	pending_reindex: Arc<Notify>,
 }
 
 impl Index {
-	pub fn new(db: DB, vfs_manager: vfs::Manager, settings_manager: settings::Manager) -> Self {
-		let index = Self {
-			db,
-			vfs_manager,
-			settings_manager,
-			pending_reindex: Arc::new(Notify::new()),
-		};
-
-		tokio::spawn({
-			let index = index.clone();
-			async move {
-				loop {
-					index.pending_reindex.notified().await;
-					if let Err(e) = index.update().await {
-						error!("Error while updating index: {}", e);
-					}
-				}
-			}
-		});
-
-		index
-	}
-
-	pub fn trigger_reindex(&self) {
-		self.pending_reindex.notify_one();
-	}
-
-	pub fn begin_periodic_updates(&self) {
-		tokio::spawn({
-			let index = self.clone();
-			async move {
-				loop {
-					index.trigger_reindex();
-					let sleep_duration = index
-						.settings_manager
-						.get_index_sleep_duration()
-						.await
-						.unwrap_or_else(|e| {
-							error!("Could not retrieve index sleep duration: {}", e);
-							Duration::from_secs(1800)
-						});
-					tokio::time::sleep(sleep_duration).await;
-				}
-			}
-		});
+	pub fn new(db: DB, vfs_manager: vfs::Manager) -> Self {
+		Self { db, vfs_manager }
 	}
 }
diff --git a/src/app/index/query.rs b/src/app/index/query.rs
index 95a5ff4..900bca2 100644
--- a/src/app/index/query.rs
+++ b/src/app/index/query.rs
@@ -1,22 +1,10 @@
-use std::path::{Path, PathBuf};
+use std::path::Path;
 
 use super::*;
-use crate::db;
-
-#[derive(thiserror::Error, Debug)]
-pub enum QueryError {
-	#[error(transparent)]
-	Database(#[from] sqlx::Error),
-	#[error(transparent)]
-	DatabaseConnection(#[from] db::Error),
-	#[error("Song was not found: `{0}`")]
-	SongNotFound(PathBuf),
-	#[error(transparent)]
-	Vfs(#[from] vfs::Error),
-}
+use crate::app::scanner;
 
 impl Index {
-	pub async fn browse<P>(&self, virtual_path: P) -> Result<Vec<CollectionFile>, QueryError>
+	pub async fn browse<P>(&self, virtual_path: P) -> Result<Vec<CollectionFile>, Error>
 	where
 		P: AsRef<Path>,
 	{
@@ -26,10 +14,12 @@ impl Index {
 
 		if virtual_path.as_ref().components().count() == 0 {
 			// Browse top-level
-			let real_directories =
-				sqlx::query_as!(Directory, "SELECT * FROM directories WHERE parent IS NULL")
-					.fetch_all(connection.as_mut())
-					.await?;
+			let real_directories = sqlx::query_as!(
+				scanner::Directory,
+				"SELECT * FROM directories WHERE parent IS NULL"
+			)
+			.fetch_all(connection.as_mut())
+			.await?;
 			let virtual_directories = real_directories
 				.into_iter()
 				.filter_map(|d| d.virtualize(&vfs));
@@ -40,7 +30,7 @@ impl Index {
 			let real_path_string = real_path.as_path().to_string_lossy().into_owned();
 
 			let real_directories = sqlx::query_as!(
-				Directory,
+				scanner::Directory,
 				"SELECT * FROM directories WHERE parent = $1 ORDER BY path COLLATE NOCASE ASC",
 				real_path_string
 			)
@@ -54,7 +44,7 @@ impl Index {
 			output.extend(virtual_directories.map(CollectionFile::Directory));
 
 			let real_songs = sqlx::query_as!(
-				Song,
+				scanner::Song,
 				"SELECT * FROM songs WHERE parent = $1 ORDER BY path COLLATE NOCASE ASC",
 				real_path_string
 			)
@@ -68,7 +58,7 @@ impl Index {
 		Ok(output)
 	}
 
-	pub async fn flatten<P>(&self, virtual_path: P) -> Result<Vec<Song>, QueryError>
+	pub async fn flatten<P>(&self, virtual_path: P) -> Result<Vec<scanner::Song>, Error>
 	where
 		P: AsRef<Path>,
 	{
@@ -83,28 +73,31 @@ impl Index {
 				path_buf.as_path().to_string_lossy().into_owned()
 			};
 			sqlx::query_as!(
-				Song,
+				scanner::Song,
 				"SELECT * FROM songs WHERE path LIKE $1 ORDER BY path COLLATE NOCASE ASC",
 				song_path_filter
 			)
 			.fetch_all(connection.as_mut())
 			.await?
 		} else {
-			sqlx::query_as!(Song, "SELECT * FROM songs ORDER BY path COLLATE NOCASE ASC")
-				.fetch_all(connection.as_mut())
-				.await?
+			sqlx::query_as!(
+				scanner::Song,
+				"SELECT * FROM songs ORDER BY path COLLATE NOCASE ASC"
+			)
+			.fetch_all(connection.as_mut())
+			.await?
 		};
 
 		let virtual_songs = real_songs.into_iter().filter_map(|s| s.virtualize(&vfs));
 		Ok(virtual_songs.collect::<Vec<_>>())
 	}
 
-	pub async fn get_random_albums(&self, count: i64) -> Result<Vec<Directory>, QueryError> {
+	pub async fn get_random_albums(&self, count: i64) -> Result<Vec<scanner::Directory>, Error> {
 		let vfs = self.vfs_manager.get_vfs().await?;
 		let mut connection = self.db.connect().await?;
 
 		let real_directories = sqlx::query_as!(
-			Directory,
+			scanner::Directory,
 			"SELECT * FROM directories WHERE album IS NOT NULL ORDER BY RANDOM() DESC LIMIT $1",
 			count
 		)
@@ -117,12 +110,12 @@ impl Index {
 		Ok(virtual_directories.collect::<Vec<_>>())
 	}
 
-	pub async fn get_recent_albums(&self, count: i64) -> Result<Vec<Directory>, QueryError> {
+	pub async fn get_recent_albums(&self, count: i64) -> Result<Vec<scanner::Directory>, Error> {
 		let vfs = self.vfs_manager.get_vfs().await?;
 		let mut connection = self.db.connect().await?;
 
 		let real_directories = sqlx::query_as!(
-			Directory,
+			scanner::Directory,
 			"SELECT * FROM directories WHERE album IS NOT NULL ORDER BY date_added DESC LIMIT $1",
 			count
 		)
@@ -135,7 +128,7 @@ impl Index {
 		Ok(virtual_directories.collect::<Vec<_>>())
 	}
 
-	pub async fn search(&self, query: &str) -> Result<Vec<CollectionFile>, QueryError> {
+	pub async fn search(&self, query: &str) -> Result<Vec<CollectionFile>, Error> {
 		let vfs = self.vfs_manager.get_vfs().await?;
 		let mut connection = self.db.connect().await?;
 		let like_test = format!("%{}%", query);
@@ -144,7 +137,7 @@ impl Index {
 		// Find dirs with matching path and parent not matching
 		{
 			let real_directories = sqlx::query_as!(
-				Directory,
+				scanner::Directory,
 				"SELECT * FROM directories WHERE path LIKE $1 AND parent NOT LIKE $1",
 				like_test
 			)
@@ -161,7 +154,7 @@ impl Index {
 		// Find songs with matching title/album/artist and non-matching parent
 		{
 			let real_songs = sqlx::query_as!(
-				Song,
+				scanner::Song,
 				r#"
 				SELECT * FROM songs
 				WHERE	(	path LIKE $1
@@ -185,7 +178,7 @@ impl Index {
 		Ok(output)
 	}
 
-	pub async fn get_song(&self, virtual_path: &Path) -> Result<Song, QueryError> {
+	pub async fn get_song(&self, virtual_path: &Path) -> Result<scanner::Song, Error> {
 		let vfs = self.vfs_manager.get_vfs().await?;
 		let mut connection = self.db.connect().await?;
 
@@ -193,7 +186,7 @@ impl Index {
 		let real_path_string = real_path.as_path().to_string_lossy();
 
 		let real_song = sqlx::query_as!(
-			Song,
+			scanner::Song,
 			"SELECT * FROM songs WHERE path = $1",
 			real_path_string
 		)
@@ -202,7 +195,7 @@ impl Index {
 
 		match real_song.virtualize(&vfs) {
 			Some(s) => Ok(s),
-			None => Err(QueryError::SongNotFound(real_path)),
+			None => Err(Error::SongNotFound(real_path)),
 		}
 	}
 }
diff --git a/src/app/index/test.rs b/src/app/index/test.rs
index bf2dcfc..ad6850f 100644
--- a/src/app/index/test.rs
+++ b/src/app/index/test.rs
@@ -1,96 +1,18 @@
-use std::default::Default;
 use std::path::{Path, PathBuf};
 
 use super::*;
-use crate::app::test;
+use crate::app::{scanner, test};
 use crate::test_name;
 
 const TEST_MOUNT_NAME: &str = "root";
 
-#[tokio::test]
-async fn update_adds_new_content() {
-	let ctx = test::ContextBuilder::new(test_name!())
-		.mount(TEST_MOUNT_NAME, "test-data/small-collection")
-		.build()
-		.await;
-
-	ctx.index.update().await.unwrap();
-	ctx.index.update().await.unwrap(); // Validates that subsequent updates don't run into conflicts
-
-	let mut connection = ctx.db.connect().await.unwrap();
-	let all_directories = sqlx::query_as!(Directory, "SELECT * FROM directories")
-		.fetch_all(connection.as_mut())
-		.await
-		.unwrap();
-	let all_songs = sqlx::query_as!(Song, "SELECT * FROM songs")
-		.fetch_all(connection.as_mut())
-		.await
-		.unwrap();
-	assert_eq!(all_directories.len(), 6);
-	assert_eq!(all_songs.len(), 13);
-}
-
-#[tokio::test]
-async fn update_removes_missing_content() {
-	let builder = test::ContextBuilder::new(test_name!());
-
-	let original_collection_dir: PathBuf = ["test-data", "small-collection"].iter().collect();
-	let test_collection_dir: PathBuf = builder.test_directory.join("small-collection");
-
-	let copy_options = fs_extra::dir::CopyOptions::new();
-	fs_extra::dir::copy(
-		original_collection_dir,
-		&builder.test_directory,
-		&copy_options,
-	)
-	.unwrap();
-
-	let ctx = builder
-		.mount(TEST_MOUNT_NAME, test_collection_dir.to_str().unwrap())
-		.build()
-		.await;
-
-	ctx.index.update().await.unwrap();
-
-	{
-		let mut connection = ctx.db.connect().await.unwrap();
-		let all_directories = sqlx::query_as!(Directory, "SELECT * FROM directories")
-			.fetch_all(connection.as_mut())
-			.await
-			.unwrap();
-		let all_songs = sqlx::query_as!(Song, "SELECT * FROM songs")
-			.fetch_all(connection.as_mut())
-			.await
-			.unwrap();
-		assert_eq!(all_directories.len(), 6);
-		assert_eq!(all_songs.len(), 13);
-	}
-
-	let khemmis_directory = test_collection_dir.join("Khemmis");
-	std::fs::remove_dir_all(khemmis_directory).unwrap();
-	ctx.index.update().await.unwrap();
-	{
-		let mut connection = ctx.db.connect().await.unwrap();
-		let all_directories = sqlx::query_as!(Directory, "SELECT * FROM directories")
-			.fetch_all(connection.as_mut())
-			.await
-			.unwrap();
-		let all_songs = sqlx::query_as!(Song, "SELECT * FROM songs")
-			.fetch_all(connection.as_mut())
-			.await
-			.unwrap();
-		assert_eq!(all_directories.len(), 4);
-		assert_eq!(all_songs.len(), 8);
-	}
-}
-
 #[tokio::test]
 async fn can_browse_top_level() {
 	let ctx = test::ContextBuilder::new(test_name!())
 		.mount(TEST_MOUNT_NAME, "test-data/small-collection")
 		.build()
 		.await;
-	ctx.index.update().await.unwrap();
+	ctx.scanner.scan().await.unwrap();
 
 	let root_path = Path::new(TEST_MOUNT_NAME);
 	let files = ctx.index.browse(Path::new("")).await.unwrap();
@@ -110,7 +32,7 @@ async fn can_browse_directory() {
 		.mount(TEST_MOUNT_NAME, "test-data/small-collection")
 		.build()
 		.await;
-	ctx.index.update().await.unwrap();
+	ctx.scanner.scan().await.unwrap();
 
 	let files = ctx.index.browse(Path::new(TEST_MOUNT_NAME)).await.unwrap();
 
@@ -132,7 +54,7 @@ async fn can_flatten_root() {
 		.mount(TEST_MOUNT_NAME, "test-data/small-collection")
 		.build()
 		.await;
-	ctx.index.update().await.unwrap();
+	ctx.scanner.scan().await.unwrap();
 	let songs = ctx.index.flatten(Path::new(TEST_MOUNT_NAME)).await.unwrap();
 	assert_eq!(songs.len(), 13);
 	assert_eq!(songs[0].title, Some("Above The Water".to_owned()));
@@ -144,7 +66,7 @@ async fn can_flatten_directory() {
 		.mount(TEST_MOUNT_NAME, "test-data/small-collection")
 		.build()
 		.await;
-	ctx.index.update().await.unwrap();
+	ctx.scanner.scan().await.unwrap();
 	let path: PathBuf = [TEST_MOUNT_NAME, "Tobokegao"].iter().collect();
 	let songs = ctx.index.flatten(path).await.unwrap();
 	assert_eq!(songs.len(), 8);
@@ -156,7 +78,7 @@ async fn can_flatten_directory_with_shared_prefix() {
 		.mount(TEST_MOUNT_NAME, "test-data/small-collection")
 		.build()
 		.await;
-	ctx.index.update().await.unwrap();
+	ctx.scanner.scan().await.unwrap();
 	let path: PathBuf = [TEST_MOUNT_NAME, "Tobokegao", "Picnic"].iter().collect(); // Prefix of '(Picnic Remixes)'
 	let songs = ctx.index.flatten(path).await.unwrap();
 	assert_eq!(songs.len(), 7);
@@ -168,7 +90,7 @@ async fn can_get_random_albums() {
 		.mount(TEST_MOUNT_NAME, "test-data/small-collection")
 		.build()
 		.await;
-	ctx.index.update().await.unwrap();
+	ctx.scanner.scan().await.unwrap();
 	let albums = ctx.index.get_random_albums(1).await.unwrap();
 	assert_eq!(albums.len(), 1);
 }
@@ -179,7 +101,7 @@ async fn can_get_recent_albums() {
 		.mount(TEST_MOUNT_NAME, "test-data/small-collection")
 		.build()
 		.await;
-	ctx.index.update().await.unwrap();
+	ctx.scanner.scan().await.unwrap();
 	let albums = ctx.index.get_recent_albums(2).await.unwrap();
 	assert_eq!(albums.len(), 2);
 	assert!(albums[0].date_added >= albums[1].date_added);
@@ -192,7 +114,7 @@ async fn can_get_a_song() {
 		.build()
 		.await;
 
-	ctx.index.update().await.unwrap();
+	ctx.scanner.scan().await.unwrap();
 
 	let picnic_virtual_dir: PathBuf = [TEST_MOUNT_NAME, "Tobokegao", "Picnic"].iter().collect();
 	let song_virtual_path = picnic_virtual_dir.join("05 - シャーベット (Sherbet).mp3");
@@ -203,8 +125,11 @@ 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.artists, MultiString(vec!["Tobokegao".to_owned()]));
-	assert_eq!(song.album_artists, MultiString(vec![]));
+	assert_eq!(
+		song.artists,
+		scanner::MultiString(vec!["Tobokegao".to_owned()])
+	);
+	assert_eq!(song.album_artists, scanner::MultiString(vec![]));
 	assert_eq!(song.album, Some("Picnic".to_owned()));
 	assert_eq!(song.year, Some(2016));
 	assert_eq!(
@@ -212,51 +137,3 @@ async fn can_get_a_song() {
 		Some(artwork_virtual_path.to_string_lossy().into_owned())
 	);
 }
-
-#[tokio::test]
-async fn indexes_embedded_artwork() {
-	let ctx = test::ContextBuilder::new(test_name!())
-		.mount(TEST_MOUNT_NAME, "test-data/small-collection")
-		.build()
-		.await;
-
-	ctx.index.update().await.unwrap();
-
-	let picnic_virtual_dir: PathBuf = [TEST_MOUNT_NAME, "Tobokegao", "Picnic"].iter().collect();
-	let song_virtual_path = picnic_virtual_dir.join("07 - なぜ (Why).mp3");
-
-	let song = ctx.index.get_song(&song_virtual_path).await.unwrap();
-	assert_eq!(
-		song.artwork,
-		Some(song_virtual_path.to_string_lossy().into_owned())
-	);
-}
-
-#[tokio::test]
-async fn album_art_pattern_is_case_insensitive() {
-	let ctx = test::ContextBuilder::new(test_name!())
-		.mount(TEST_MOUNT_NAME, "test-data/small-collection")
-		.build()
-		.await;
-
-	let patterns = vec!["folder", "FOLDER"];
-
-	for pattern in patterns.into_iter() {
-		ctx.settings_manager
-			.amend(&settings::NewSettings {
-				album_art_pattern: Some(pattern.to_owned()),
-				..Default::default()
-			})
-			.await
-			.unwrap();
-		ctx.index.update().await.unwrap();
-
-		let hunted_virtual_dir: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted"].iter().collect();
-		let artwork_virtual_path = hunted_virtual_dir.join("Folder.jpg");
-		let song = &ctx.index.flatten(&hunted_virtual_dir).await.unwrap()[0];
-		assert_eq!(
-			song.artwork,
-			Some(artwork_virtual_path.to_string_lossy().into_owned())
-		);
-	}
-}
diff --git a/src/app/index/types.rs b/src/app/index/types.rs
index 0c19dcb..136ef14 100644
--- a/src/app/index/types.rs
+++ b/src/app/index/types.rs
@@ -1,76 +1,24 @@
-use std::path::Path;
+use std::path::PathBuf;
 
-use crate::app::vfs::VFS;
-
-#[derive(Debug, PartialEq, Eq)]
-pub struct MultiString(pub Vec<String>);
+use crate::{
+	app::{scanner, vfs},
+	db,
+};
 
 #[derive(Debug, PartialEq, Eq)]
 pub enum CollectionFile {
-	Directory(Directory),
-	Song(Song),
+	Directory(scanner::Directory),
+	Song(scanner::Song),
 }
 
-#[derive(Debug, PartialEq, Eq)]
-pub struct Song {
-	pub id: i64,
-	pub path: String,
-	pub parent: String,
-	pub track_number: Option<i64>,
-	pub disc_number: Option<i64>,
-	pub title: 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 lyricists: MultiString,
-	pub composers: MultiString,
-	pub genres: MultiString,
-	pub labels: MultiString,
-}
-
-impl Song {
-	pub fn virtualize(mut self, vfs: &VFS) -> Option<Song> {
-		self.path = match vfs.real_to_virtual(Path::new(&self.path)) {
-			Ok(p) => p.to_string_lossy().into_owned(),
-			_ => return None,
-		};
-		if let Some(artwork_path) = self.artwork {
-			self.artwork = match vfs.real_to_virtual(Path::new(&artwork_path)) {
-				Ok(p) => Some(p.to_string_lossy().into_owned()),
-				_ => None,
-			};
-		}
-		Some(self)
-	}
-}
-
-#[derive(Debug, PartialEq, Eq)]
-pub struct Directory {
-	pub id: i64,
-	pub path: String,
-	pub parent: Option<String>,
-	pub artists: MultiString,
-	pub year: Option<i64>,
-	pub album: Option<String>,
-	pub artwork: Option<String>,
-	pub date_added: i64,
-}
-
-impl Directory {
-	pub fn virtualize(mut self, vfs: &VFS) -> Option<Directory> {
-		self.path = match vfs.real_to_virtual(Path::new(&self.path)) {
-			Ok(p) => p.to_string_lossy().into_owned(),
-			_ => return None,
-		};
-		if let Some(artwork_path) = self.artwork {
-			self.artwork = match vfs.real_to_virtual(Path::new(&artwork_path)) {
-				Ok(p) => Some(p.to_string_lossy().into_owned()),
-				_ => None,
-			};
-		}
-		Some(self)
-	}
+#[derive(thiserror::Error, Debug)]
+pub enum Error {
+	#[error(transparent)]
+	Database(#[from] sqlx::Error),
+	#[error(transparent)]
+	DatabaseConnection(#[from] db::Error),
+	#[error("Song was not found: `{0}`")]
+	SongNotFound(PathBuf),
+	#[error(transparent)]
+	Vfs(#[from] vfs::Error),
 }
diff --git a/src/app/lastfm.rs b/src/app/lastfm.rs
index f20a311..d0d47e2 100644
--- a/src/app/lastfm.rs
+++ b/src/app/lastfm.rs
@@ -3,7 +3,7 @@ use std::path::Path;
 use user::AuthToken;
 
 use crate::app::{
-	index::{Index, QueryError},
+	index::{self, Index},
 	user,
 };
 
@@ -19,7 +19,7 @@ pub enum Error {
 	#[error("Failed to emit last.fm now playing update")]
 	NowPlaying(rustfm_scrobble::ScrobblerError),
 	#[error(transparent)]
-	Query(#[from] QueryError),
+	Query(#[from] index::Error),
 	#[error(transparent)]
 	User(#[from] user::Error),
 }
diff --git a/src/app/playlist.rs b/src/app/playlist.rs
index 17baf6b..0a33cbd 100644
--- a/src/app/playlist.rs
+++ b/src/app/playlist.rs
@@ -1,7 +1,7 @@
 use core::clone::Clone;
 use sqlx::{Acquire, QueryBuilder, Sqlite};
 
-use crate::app::index::Song;
+use crate::app::scanner::Song;
 use crate::app::vfs;
 use crate::db::{self, DB};
 
@@ -237,7 +237,7 @@ mod test {
 			.build()
 			.await;
 
-		ctx.index.update().await.unwrap();
+		ctx.scanner.scan().await.unwrap();
 
 		let playlist_content: Vec<String> = ctx
 			.index
@@ -302,7 +302,7 @@ mod test {
 			.build()
 			.await;
 
-		ctx.index.update().await.unwrap();
+		ctx.scanner.scan().await.unwrap();
 
 		let playlist_content: Vec<String> = ctx
 			.index
diff --git a/src/app/scanner.rs b/src/app/scanner.rs
new file mode 100644
index 0000000..77ca3f6
--- /dev/null
+++ b/src/app/scanner.rs
@@ -0,0 +1,123 @@
+use log::{error, info};
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+use tokio::sync::Notify;
+
+use crate::app::{settings, vfs};
+use crate::db::DB;
+
+mod cleaner;
+mod collector;
+mod inserter;
+mod metadata;
+#[cfg(test)]
+mod test;
+mod traverser;
+mod types;
+
+pub use self::types::*;
+
+#[derive(Clone)]
+pub struct Scanner {
+	db: DB,
+	vfs_manager: vfs::Manager,
+	settings_manager: settings::Manager,
+	pending_scan: Arc<Notify>,
+}
+
+impl Scanner {
+	pub fn new(db: DB, vfs_manager: vfs::Manager, settings_manager: settings::Manager) -> Self {
+		let scanner = Self {
+			db,
+			vfs_manager,
+			settings_manager,
+			pending_scan: Arc::new(Notify::new()),
+		};
+
+		tokio::spawn({
+			let scanner = scanner.clone();
+			async move {
+				loop {
+					scanner.pending_scan.notified().await;
+					if let Err(e) = scanner.scan().await {
+						error!("Error while updating index: {}", e);
+					}
+				}
+			}
+		});
+
+		scanner
+	}
+
+	pub fn trigger_scan(&self) {
+		self.pending_scan.notify_one();
+	}
+
+	pub fn begin_periodic_scans(&self) {
+		tokio::spawn({
+			let index = self.clone();
+			async move {
+				loop {
+					index.trigger_scan();
+					let sleep_duration = index
+						.settings_manager
+						.get_index_sleep_duration()
+						.await
+						.unwrap_or_else(|e| {
+							error!("Could not retrieve index sleep duration: {}", e);
+							Duration::from_secs(1800)
+						});
+					tokio::time::sleep(sleep_duration).await;
+				}
+			}
+		});
+	}
+
+	pub async fn scan(&self) -> Result<(), types::Error> {
+		let start = Instant::now();
+		info!("Beginning library index update");
+
+		let album_art_pattern = self
+			.settings_manager
+			.get_index_album_art_pattern()
+			.await
+			.ok();
+
+		let cleaner = cleaner::Cleaner::new(self.db.clone(), self.vfs_manager.clone());
+		cleaner.clean().await?;
+
+		let (insert_sender, insert_receiver) = tokio::sync::mpsc::unbounded_channel();
+		let insertion = tokio::spawn({
+			let db = self.db.clone();
+			async {
+				let mut inserter = inserter::Inserter::new(db, insert_receiver);
+				inserter.insert().await;
+			}
+		});
+
+		let (collect_sender, collect_receiver) = crossbeam_channel::unbounded();
+		let collection = tokio::task::spawn_blocking(|| {
+			let collector =
+				collector::Collector::new(collect_receiver, insert_sender, album_art_pattern);
+			collector.collect();
+		});
+
+		let vfs = self.vfs_manager.get_vfs().await?;
+		let traversal = tokio::task::spawn_blocking(move || {
+			let mounts = vfs.mounts();
+			let traverser = traverser::Traverser::new(collect_sender);
+			traverser.traverse(mounts.iter().map(|p| p.source.clone()).collect());
+		});
+
+		traversal.await.unwrap();
+		collection.await.unwrap();
+		insertion.await.unwrap();
+
+		info!(
+			"Library index update took {} seconds",
+			start.elapsed().as_millis() as f32 / 1000.0
+		);
+
+		Ok(())
+	}
+}
diff --git a/src/app/index/update/cleaner.rs b/src/app/scanner/cleaner.rs
similarity index 100%
rename from src/app/index/update/cleaner.rs
rename to src/app/scanner/cleaner.rs
diff --git a/src/app/index/update/collector.rs b/src/app/scanner/collector.rs
similarity index 98%
rename from src/app/index/update/collector.rs
rename to src/app/scanner/collector.rs
index 1acf7d5..15d1fd9 100644
--- a/src/app/index/update/collector.rs
+++ b/src/app/scanner/collector.rs
@@ -1,9 +1,10 @@
 use log::error;
 use regex::Regex;
 
-use crate::app::index::MultiString;
+use crate::app::scanner::MultiString;
 
-use super::*;
+use super::inserter;
+use super::traverser;
 
 pub struct Collector {
 	receiver: crossbeam_channel::Receiver<traverser::Directory>,
diff --git a/src/app/index/update/inserter.rs b/src/app/scanner/inserter.rs
similarity index 80%
rename from src/app/index/update/inserter.rs
rename to src/app/scanner/inserter.rs
index 09f3517..8d24c56 100644
--- a/src/app/index/update/inserter.rs
+++ b/src/app/scanner/inserter.rs
@@ -1,14 +1,8 @@
-use std::borrow::Cow;
-
 use log::error;
-use sqlx::{
-	encode::IsNull,
-	sqlite::{SqliteArgumentValue, SqliteTypeInfo},
-	QueryBuilder, Sqlite,
-};
+use sqlx::{QueryBuilder, Sqlite};
 use tokio::sync::mpsc::UnboundedReceiver;
 
-use crate::{app::index::MultiString, db::DB};
+use crate::{app::scanner::MultiString, db::DB};
 
 const INDEX_BUILDING_INSERT_BUFFER_SIZE: usize = 1000; // Insertions in each transaction
 
@@ -52,39 +46,6 @@ 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);
diff --git a/src/app/index/metadata.rs b/src/app/scanner/metadata.rs
similarity index 100%
rename from src/app/index/metadata.rs
rename to src/app/scanner/metadata.rs
diff --git a/src/app/index/update.rs b/src/app/scanner/scan.rs
similarity index 93%
rename from src/app/index/update.rs
rename to src/app/scanner/scan.rs
index 8064227..2e8e4a2 100644
--- a/src/app/index/update.rs
+++ b/src/app/scanner/scan.rs
@@ -1,11 +1,6 @@
 use log::{error, info};
 use std::time;
 
-mod cleaner;
-mod collector;
-mod inserter;
-mod traverser;
-
 use crate::app::index::Index;
 use crate::app::vfs;
 use crate::db;
@@ -27,8 +22,8 @@ pub enum Error {
 	Vfs(#[from] vfs::Error),
 }
 
-impl Index {
-	pub async fn update(&self) -> Result<(), Error> {
+impl Scanner {
+	pub async fn scan(&self) -> Result<(), Error> {
 		let start = time::Instant::now();
 		info!("Beginning library index update");
 
diff --git a/src/app/scanner/test.rs b/src/app/scanner/test.rs
new file mode 100644
index 0000000..925d844
--- /dev/null
+++ b/src/app/scanner/test.rs
@@ -0,0 +1,133 @@
+use std::path::PathBuf;
+
+use crate::{
+	app::{scanner, settings, test},
+	test_name,
+};
+
+const TEST_MOUNT_NAME: &str = "root";
+
+#[tokio::test]
+async fn scan_adds_new_content() {
+	let ctx = test::ContextBuilder::new(test_name!())
+		.mount(TEST_MOUNT_NAME, "test-data/small-collection")
+		.build()
+		.await;
+
+	ctx.scanner.scan().await.unwrap();
+	ctx.scanner.scan().await.unwrap(); // Validates that subsequent updates don't run into conflicts
+
+	let mut connection = ctx.db.connect().await.unwrap();
+	let all_directories = sqlx::query_as!(scanner::Directory, "SELECT * FROM directories")
+		.fetch_all(connection.as_mut())
+		.await
+		.unwrap();
+	let all_songs = sqlx::query_as!(scanner::Song, "SELECT * FROM songs")
+		.fetch_all(connection.as_mut())
+		.await
+		.unwrap();
+	assert_eq!(all_directories.len(), 6);
+	assert_eq!(all_songs.len(), 13);
+}
+
+#[tokio::test]
+async fn scan_removes_missing_content() {
+	let builder = test::ContextBuilder::new(test_name!());
+
+	let original_collection_dir: PathBuf = ["test-data", "small-collection"].iter().collect();
+	let test_collection_dir: PathBuf = builder.test_directory.join("small-collection");
+
+	let copy_options = fs_extra::dir::CopyOptions::new();
+	fs_extra::dir::copy(
+		original_collection_dir,
+		&builder.test_directory,
+		&copy_options,
+	)
+	.unwrap();
+
+	let ctx = builder
+		.mount(TEST_MOUNT_NAME, test_collection_dir.to_str().unwrap())
+		.build()
+		.await;
+
+	ctx.scanner.scan().await.unwrap();
+
+	{
+		let mut connection = ctx.db.connect().await.unwrap();
+		let all_directories = sqlx::query_as!(scanner::Directory, "SELECT * FROM directories")
+			.fetch_all(connection.as_mut())
+			.await
+			.unwrap();
+		let all_songs = sqlx::query_as!(scanner::Song, "SELECT * FROM songs")
+			.fetch_all(connection.as_mut())
+			.await
+			.unwrap();
+		assert_eq!(all_directories.len(), 6);
+		assert_eq!(all_songs.len(), 13);
+	}
+
+	let khemmis_directory = test_collection_dir.join("Khemmis");
+	std::fs::remove_dir_all(khemmis_directory).unwrap();
+	ctx.scanner.scan().await.unwrap();
+	{
+		let mut connection = ctx.db.connect().await.unwrap();
+		let all_directories = sqlx::query_as!(scanner::Directory, "SELECT * FROM directories")
+			.fetch_all(connection.as_mut())
+			.await
+			.unwrap();
+		let all_songs = sqlx::query_as!(scanner::Song, "SELECT * FROM songs")
+			.fetch_all(connection.as_mut())
+			.await
+			.unwrap();
+		assert_eq!(all_directories.len(), 4);
+		assert_eq!(all_songs.len(), 8);
+	}
+}
+
+#[tokio::test]
+async fn finds_embedded_artwork() {
+	let ctx = test::ContextBuilder::new(test_name!())
+		.mount(TEST_MOUNT_NAME, "test-data/small-collection")
+		.build()
+		.await;
+
+	ctx.scanner.scan().await.unwrap();
+
+	let picnic_virtual_dir: PathBuf = [TEST_MOUNT_NAME, "Tobokegao", "Picnic"].iter().collect();
+	let song_virtual_path = picnic_virtual_dir.join("07 - なぜ (Why).mp3");
+
+	let song = ctx.index.get_song(&song_virtual_path).await.unwrap();
+	assert_eq!(
+		song.artwork,
+		Some(song_virtual_path.to_string_lossy().into_owned())
+	);
+}
+
+#[tokio::test]
+async fn album_art_pattern_is_case_insensitive() {
+	let ctx = test::ContextBuilder::new(test_name!())
+		.mount(TEST_MOUNT_NAME, "test-data/small-collection")
+		.build()
+		.await;
+
+	let patterns = vec!["folder", "FOLDER"];
+
+	for pattern in patterns.into_iter() {
+		ctx.settings_manager
+			.amend(&settings::NewSettings {
+				album_art_pattern: Some(pattern.to_owned()),
+				..Default::default()
+			})
+			.await
+			.unwrap();
+		ctx.scanner.scan().await.unwrap();
+
+		let hunted_virtual_dir: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted"].iter().collect();
+		let artwork_virtual_path = hunted_virtual_dir.join("Folder.jpg");
+		let song = &ctx.index.flatten(&hunted_virtual_dir).await.unwrap()[0];
+		assert_eq!(
+			song.artwork,
+			Some(artwork_virtual_path.to_string_lossy().into_owned())
+		);
+	}
+}
diff --git a/src/app/index/update/traverser.rs b/src/app/scanner/traverser.rs
similarity index 98%
rename from src/app/index/update/traverser.rs
rename to src/app/scanner/traverser.rs
index 9084fe4..e79650d 100644
--- a/src/app/index/update/traverser.rs
+++ b/src/app/scanner/traverser.rs
@@ -9,7 +9,7 @@ use std::sync::Arc;
 use std::thread;
 use std::time::Duration;
 
-use crate::app::index::metadata::{self, SongMetadata};
+use crate::app::scanner::metadata::{self, SongMetadata};
 
 #[derive(Debug)]
 pub struct Song {
diff --git a/src/app/scanner/types.rs b/src/app/scanner/types.rs
new file mode 100644
index 0000000..47a7577
--- /dev/null
+++ b/src/app/scanner/types.rs
@@ -0,0 +1,124 @@
+use std::{borrow::Cow, path::Path};
+
+use sqlx::{
+	encode::IsNull,
+	sqlite::{SqliteArgumentValue, SqliteTypeInfo},
+	Sqlite,
+};
+
+use crate::{
+	app::vfs::{self, VFS},
+	db,
+};
+
+#[derive(thiserror::Error, Debug)]
+pub enum Error {
+	#[error(transparent)]
+	IndexClean(#[from] super::cleaner::Error),
+	#[error(transparent)]
+	Database(#[from] sqlx::Error),
+	#[error(transparent)]
+	DatabaseConnection(#[from] db::Error),
+	#[error(transparent)]
+	Vfs(#[from] vfs::Error),
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub struct MultiString(pub Vec<String>);
+
+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()
+	}
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub struct Song {
+	pub id: i64,
+	pub path: String,
+	pub parent: String,
+	pub track_number: Option<i64>,
+	pub disc_number: Option<i64>,
+	pub title: 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 lyricists: MultiString,
+	pub composers: MultiString,
+	pub genres: MultiString,
+	pub labels: MultiString,
+}
+
+impl Song {
+	pub fn virtualize(mut self, vfs: &VFS) -> Option<Song> {
+		self.path = match vfs.real_to_virtual(Path::new(&self.path)) {
+			Ok(p) => p.to_string_lossy().into_owned(),
+			_ => return None,
+		};
+		if let Some(artwork_path) = self.artwork {
+			self.artwork = match vfs.real_to_virtual(Path::new(&artwork_path)) {
+				Ok(p) => Some(p.to_string_lossy().into_owned()),
+				_ => None,
+			};
+		}
+		Some(self)
+	}
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub struct Directory {
+	pub id: i64,
+	pub path: String,
+	pub parent: Option<String>,
+	pub artists: MultiString,
+	pub year: Option<i64>,
+	pub album: Option<String>,
+	pub artwork: Option<String>,
+	pub date_added: i64,
+}
+
+impl Directory {
+	pub fn virtualize(mut self, vfs: &VFS) -> Option<Directory> {
+		self.path = match vfs.real_to_virtual(Path::new(&self.path)) {
+			Ok(p) => p.to_string_lossy().into_owned(),
+			_ => return None,
+		};
+		if let Some(artwork_path) = self.artwork {
+			self.artwork = match vfs.real_to_virtual(Path::new(&artwork_path)) {
+				Ok(p) => Some(p.to_string_lossy().into_owned()),
+				_ => None,
+			};
+		}
+		Some(self)
+	}
+}
diff --git a/src/app/test.rs b/src/app/test.rs
index a00628c..1c25089 100644
--- a/src/app/test.rs
+++ b/src/app/test.rs
@@ -1,11 +1,12 @@
 use std::path::PathBuf;
 
-use crate::app::{config, ddns, index::Index, playlist, settings, user, vfs};
+use crate::app::{config, ddns, index::Index, playlist, scanner::Scanner, settings, user, vfs};
 use crate::db::DB;
 use crate::test::*;
 
 pub struct Context {
 	pub db: DB,
+	pub scanner: Scanner,
 	pub index: Index,
 	pub config_manager: config::Manager,
 	pub ddns_manager: ddns::Manager,
@@ -65,13 +66,15 @@ impl ContextBuilder {
 			vfs_manager.clone(),
 			ddns_manager.clone(),
 		);
-		let index = Index::new(db.clone(), vfs_manager.clone(), settings_manager.clone());
+		let scanner = Scanner::new(db.clone(), vfs_manager.clone(), settings_manager.clone());
+		let index = Index::new(db.clone(), vfs_manager.clone());
 		let playlist_manager = playlist::Manager::new(db.clone(), vfs_manager.clone());
 
 		config_manager.apply(&self.config).await.unwrap();
 
 		Context {
 			db,
+			scanner,
 			index,
 			config_manager,
 			ddns_manager,
diff --git a/src/main.rs b/src/main.rs
index eb8ffe7..ab02616 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -144,7 +144,7 @@ fn main() -> Result<(), Error> {
 async fn async_main(cli_options: CLIOptions, paths: paths::Paths) -> Result<(), Error> {
 	// Create and run app
 	let app = app::App::new(cli_options.port.unwrap_or(5050), paths).await?;
-	app.index.begin_periodic_updates();
+	app.scanner.begin_periodic_scans();
 	app.ddns_manager.begin_periodic_updates();
 
 	// Start server
diff --git a/src/server/axum.rs b/src/server/axum.rs
index 58bd4cd..a0a9352 100644
--- a/src/server/axum.rs
+++ b/src/server/axum.rs
@@ -62,6 +62,12 @@ impl FromRef<App> for app::user::Manager {
 	}
 }
 
+impl FromRef<App> for app::scanner::Scanner {
+	fn from_ref(app: &App) -> Self {
+		app.scanner.clone()
+	}
+}
+
 impl FromRef<App> for app::settings::Manager {
 	fn from_ref(app: &App) -> Self {
 		app.settings_manager.clone()
diff --git a/src/server/axum/api.rs b/src/server/axum/api.rs
index b6d203c..0cdad7c 100644
--- a/src/server/axum/api.rs
+++ b/src/server/axum/api.rs
@@ -11,7 +11,7 @@ use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
 use percent_encoding::percent_decode_str;
 
 use crate::{
-	app::{config, ddns, index, lastfm, playlist, settings, thumbnail, user, vfs, App},
+	app::{config, ddns, index, lastfm, playlist, scanner, settings, thumbnail, user, vfs, App},
 	server::{dto, error::APIError},
 };
 
@@ -246,9 +246,9 @@ async fn put_preferences(
 
 async fn post_trigger_index(
 	_admin_rights: AdminRights,
-	State(index): State<index::Index>,
+	State(scanner): State<scanner::Scanner>,
 ) -> Result<(), APIError> {
-	index.trigger_reindex();
+	scanner.trigger_scan();
 	Ok(())
 }
 
diff --git a/src/server/dto.rs b/src/server/dto.rs
index 566be2f..e052c9c 100644
--- a/src/server/dto.rs
+++ b/src/server/dto.rs
@@ -1,6 +1,6 @@
 use serde::{Deserialize, Serialize};
 
-use crate::app::{config, ddns, index, settings, thumbnail, user, vfs};
+use crate::app::{config, ddns, index, scanner, settings, thumbnail, user, vfs};
 use std::convert::From;
 
 pub const API_MAJOR_VERSION: i32 = 8;
@@ -277,8 +277,8 @@ pub struct Song {
 	pub labels: Vec<String>,
 }
 
-impl From<index::Song> for Song {
-	fn from(s: index::Song) -> Self {
+impl From<scanner::Song> for Song {
+	fn from(s: scanner::Song) -> Self {
 		Self {
 			path: s.path,
 			track_number: s.track_number,
@@ -312,8 +312,8 @@ pub struct Directory {
 	pub date_added: i64,
 }
 
-impl From<index::Directory> for Directory {
-	fn from(d: index::Directory) -> Self {
+impl From<scanner::Directory> for Directory {
+	fn from(d: scanner::Directory) -> Self {
 		Self {
 			path: d.path,
 			artists: d.artists.0,
diff --git a/src/server/error.rs b/src/server/error.rs
index af19618..3c2941c 100644
--- a/src/server/error.rs
+++ b/src/server/error.rs
@@ -1,8 +1,7 @@
 use std::path::PathBuf;
 use thiserror::Error;
 
-use crate::app::index::QueryError;
-use crate::app::{config, ddns, lastfm, playlist, settings, thumbnail, user, vfs};
+use crate::app::{config, ddns, index, lastfm, playlist, settings, thumbnail, user, vfs};
 use crate::db;
 
 #[derive(Error, Debug)]
@@ -102,13 +101,13 @@ impl From<playlist::Error> for APIError {
 	}
 }
 
-impl From<QueryError> for APIError {
-	fn from(error: QueryError) -> APIError {
+impl From<index::Error> for APIError {
+	fn from(error: index::Error) -> APIError {
 		match error {
-			QueryError::Database(e) => APIError::Database(e),
-			QueryError::DatabaseConnection(e) => e.into(),
-			QueryError::SongNotFound(_) => APIError::SongMetadataNotFound,
-			QueryError::Vfs(e) => e.into(),
+			index::Error::Database(e) => APIError::Database(e),
+			index::Error::DatabaseConnection(e) => e.into(),
+			index::Error::SongNotFound(_) => APIError::SongMetadataNotFound,
+			index::Error::Vfs(e) => e.into(),
 		}
 	}
 }