diff --git a/.gitignore b/.gitignore
index b6aaed4..8ea7b59 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,9 +11,6 @@ test-output
 TestConfig.toml
 
 # Runtime artifacts
-*.sqlite
-**/*.sqlite-shm
-**/*.sqlite-wal
 auth.secret
 collection.index
 polaris.log
diff --git a/src/app.rs b/src/app.rs
index 8500ed4..23a9824 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1,7 +1,5 @@
-use std::cmp::min;
 use std::fs;
 use std::path::{Path, PathBuf};
-use std::time::Duration;
 
 use log::info;
 use rand::rngs::OsRng;
@@ -109,6 +107,8 @@ pub enum Error {
 	InvalidDirectory(String),
 	#[error("The following virtual path could not be mapped to a real path: `{0}`")]
 	CouldNotMapToRealPath(PathBuf),
+	#[error("The following real path could not be mapped to a virtual path: `{0}`")]
+	CouldNotMapToVirtualPath(PathBuf),
 	#[error("User not found")]
 	UserNotFound,
 	#[error("Directory not found: {0}")]
@@ -275,27 +275,14 @@ impl App {
 		self.config_manager.apply_config(config).await?;
 		self.config_manager.save_config().await?;
 
-		self.scanner.try_trigger_scan();
-		let mut wait_seconds = 1;
-		loop {
-			tokio::time::sleep(Duration::from_secs(wait_seconds)).await;
-			if matches!(
-				self.scanner.get_status().await.state,
-				scanner::State::UpToDate
-			) {
-				break;
-			} else {
-				info!("Migration is waiting for collection scan to finish");
-				wait_seconds = min(2 * wait_seconds, 30);
-			}
-		}
-
 		info!("Migrating playlists");
 		for (name, owner, songs) in read_legacy_playlists(
 			db_file_path,
-			self.config_manager.clone(),
 			self.index_manager.clone(),
-		)? {
+			self.scanner.clone(),
+		)
+		.await?
+		{
 			self.playlist_manager
 				.save_playlist(&name, &owner, songs)
 				.await?;
diff --git a/src/app/legacy.rs b/src/app/legacy.rs
index ae06b58..a07fd25 100644
--- a/src/app/legacy.rs
+++ b/src/app/legacy.rs
@@ -1,8 +1,12 @@
-use std::{path::PathBuf, str::FromStr};
+use std::{
+	collections::HashMap,
+	path::{Path, PathBuf},
+	str::FromStr,
+};
 
 use rusqlite::Connection;
 
-use crate::app::{config, index, Error};
+use crate::app::{config, index, scanner, Error};
 
 pub fn read_legacy_auth_secret(db_file_path: &PathBuf) -> Result<[u8; 32], Error> {
 	let connection = Connection::open(db_file_path)?;
@@ -18,14 +22,26 @@ pub fn read_legacy_config(
 ) -> Result<Option<config::storage::Config>, Error> {
 	let connection = Connection::open(db_file_path)?;
 
-	// Album art pattern
 	let album_art_pattern: String = connection.query_row(
 		"SELECT index_album_art_pattern FROM misc_settings",
 		[],
 		|row| row.get(0),
 	)?;
 
-	// Mount directories
+	let mount_dirs = read_mount_dirs(db_file_path)?;
+	let users = read_users(db_file_path)?;
+
+	Ok(Some(config::storage::Config {
+		album_art_pattern: Some(album_art_pattern),
+		mount_dirs,
+		ddns_update_url: None,
+		users: users.into_values().collect(),
+	}))
+}
+
+fn read_mount_dirs(db_file_path: &PathBuf) -> Result<Vec<config::storage::MountDir>, Error> {
+	let connection = Connection::open(db_file_path)?;
+
 	let mut mount_dirs_statement = connection.prepare("SELECT source, name FROM mount_points")?;
 	let mount_dirs_rows = mount_dirs_statement.query_and_then([], |row| {
 		let source_string = row.get::<_, String>(0)?;
@@ -37,40 +53,108 @@ pub fn read_legacy_config(
 			name: row.get::<_, String>(1)?,
 		})
 	})?;
+
 	let mut mount_dirs = vec![];
 	for mount_dir_result in mount_dirs_rows {
 		mount_dirs.push(mount_dir_result?);
 	}
 
-	// Users
-	let mut users_statement = connection.prepare("SELECT name, password_hash, admin FROM users")?;
-	let users_rows = users_statement.query_map([], |row| {
-		Ok(config::storage::User {
-			name: row.get(0)?,
-			admin: row.get(2)?,
-			initial_password: None,
-			hashed_password: row.get(1)?,
-		})
-	})?;
-	let mut users = vec![];
-	for user_result in users_rows {
-		users.push(user_result?);
-	}
-
-	Ok(Some(config::storage::Config {
-		album_art_pattern: Some(album_art_pattern),
-		mount_dirs,
-		ddns_update_url: None,
-		users,
-	}))
+	Ok(mount_dirs)
 }
 
-pub fn read_legacy_playlists(
+fn read_users(db_file_path: &PathBuf) -> Result<HashMap<u32, config::storage::User>, Error> {
+	let connection = Connection::open(db_file_path)?;
+	let mut users_statement =
+		connection.prepare("SELECT id, name, password_hash, admin FROM users")?;
+	let users_rows = users_statement.query_map([], |row| {
+		Ok((
+			row.get(0)?,
+			config::storage::User {
+				name: row.get(1)?,
+				admin: row.get(3)?,
+				initial_password: None,
+				hashed_password: row.get(2)?,
+			},
+		))
+	})?;
+
+	let mut users = HashMap::new();
+	for users_row in users_rows {
+		let (id, user) = users_row?;
+		users.insert(id, user);
+	}
+
+	Ok(users)
+}
+
+fn virtualize_path(
+	real_path: &PathBuf,
+	mount_dirs: &Vec<config::storage::MountDir>,
+) -> Result<PathBuf, Error> {
+	for mount_dir in mount_dirs {
+		if let Ok(tail) = real_path.strip_prefix(&mount_dir.source) {
+			return Ok(Path::new(&mount_dir.name).join(tail));
+		}
+	}
+	Err(Error::CouldNotMapToVirtualPath(real_path.clone()))
+}
+
+pub async fn read_legacy_playlists(
 	db_file_path: &PathBuf,
-	config_manager: config::Manager,
 	index_manager: index::Manager,
+	scanner: scanner::Scanner,
 ) -> Result<Vec<(String, String, Vec<index::Song>)>, Error> {
-	Ok(vec![])
+	scanner.run_scan().await?;
+
+	let users = read_users(db_file_path)?;
+	let mount_dirs = read_mount_dirs(db_file_path)?;
+	let connection = Connection::open(db_file_path)?;
+
+	let mut playlists_statement = connection.prepare("SELECT id, owner, name FROM playlists")?;
+	let playlists_rows =
+		playlists_statement.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?;
+	let mut playlists = HashMap::new();
+	for playlists_row in playlists_rows {
+		let (id, owner, name): (u32, u32, String) = playlists_row?;
+		playlists.insert(id, (users.get(&owner).ok_or(Error::UserNotFound)?, name));
+	}
+
+	let mut playlists_by_user: HashMap<String, HashMap<String, Vec<index::Song>>> = HashMap::new();
+	let mut songs_statement =
+		connection.prepare("SELECT playlist, path FROM playlist_songs ORDER BY ordering")?;
+	let mut songs_rows = songs_statement.query([])?;
+	while let Some(row) = songs_rows.next()? {
+		let playlist = playlists.get(&row.get(0)?).ok_or(Error::PlaylistNotFound)?;
+		let user = playlist.0.name.clone();
+		let name = playlist.1.clone();
+		let real_path = PathBuf::from(row.get::<_, String>(1)?);
+		let Ok(virtual_path) = virtualize_path(&real_path, &mount_dirs) else {
+			continue;
+		};
+		let Ok(song) = index_manager
+			.get_songs(vec![virtual_path])
+			.await
+			.pop()
+			.unwrap()
+		else {
+			continue;
+		};
+		playlists_by_user
+			.entry(user)
+			.or_default()
+			.entry(name)
+			.or_default()
+			.push(song);
+	}
+
+	let mut results = vec![];
+	for (user, playlists) in playlists_by_user {
+		for (playlist_name, songs) in playlists {
+			results.push((playlist_name.clone(), user.clone(), songs));
+		}
+	}
+
+	Ok(results)
 }
 
 pub async fn delete_legacy_db(db_file_path: &PathBuf) -> Result<(), Error> {
@@ -85,7 +169,10 @@ mod test {
 	use std::path::PathBuf;
 
 	use super::*;
-	use crate::app::config;
+	use crate::{
+		app::{config, test},
+		test_name,
+	};
 
 	#[test]
 	fn can_read_auth_secret() {
@@ -145,4 +232,73 @@ mod test {
 
 		assert_eq!(actual, expected);
 	}
+
+	#[tokio::test]
+	async fn can_read_blank_playlists() {
+		let ctx = test::ContextBuilder::new(test_name!()).build().await;
+
+		let actual = read_legacy_playlists(
+			&PathBuf::from_iter(["test-data", "legacy_db_blank.sqlite"]),
+			ctx.index_manager,
+			ctx.scanner,
+		)
+		.await
+		.unwrap();
+
+		let expected = vec![];
+
+		assert_eq!(actual, expected);
+	}
+
+	#[tokio::test]
+	async fn can_read_populated_playlists() {
+		let ctx = test::ContextBuilder::new(test_name!()).build().await;
+		let db_file_path = PathBuf::from_iter(["test-data", "legacy_db_populated.sqlite"]);
+
+		let config = read_legacy_config(&db_file_path).unwrap().unwrap();
+		ctx.config_manager.apply_config(config).await.unwrap();
+
+		let actual = read_legacy_playlists(
+			&db_file_path,
+			ctx.index_manager.clone(),
+			ctx.scanner.clone(),
+		)
+		.await
+		.unwrap();
+
+		#[rustfmt::skip]
+		let song_paths = vec![
+			PathBuf::from_iter(["root", "Omodaka","2011 - Cantata No.147", "01 - Otemoyan.mp3"]),
+			PathBuf::from_iter(["root", "Omodaka","2011 - Cantata No.147", "02 - Asadoya Yunta.mp3"]),
+			PathBuf::from_iter(["root", "Omodaka","2011 - Cantata No.147", "03 - Fortunate 1mark (A-4 Mix).mp3"]),
+			PathBuf::from_iter(["root", "Omodaka","2011 - Cantata No.147", "04 - Hanagasa Ondo.mp3"]),
+			PathBuf::from_iter(["root", "Omodaka","2011 - Cantata No.147", "05 - Cantata No.147 (Video Mix).mp3"]),
+			PathBuf::from_iter(["root", "Omodaka","2011 - Cantata No.147", "06 - Kokirikobushi (Video Mix).mp3"]),
+			PathBuf::from_iter(["root", "Omodaka","2011 - Cantata No.147", "07 - Monkey Turn (Mahoroba Mix).mp3"]),
+			PathBuf::from_iter(["root", "Omodaka","2011 - Cantata No.147", "08 - Otemoyan (Inst).mp3"]),
+			PathBuf::from_iter(["root", "Omodaka","2011 - Cantata No.147", "09 - Asadoya Yunta (Inst).mp3"]),
+			PathBuf::from_iter(["root", "Omodaka","2011 - Cantata No.147", "10 - Hanagasa Ondo (Inst).mp3"]),
+			PathBuf::from_iter(["root", "Holy Konni","2013 - Fetushouse", "01 - Kate Moss Magic.mp3"]),
+			PathBuf::from_iter(["root", "Holy Konni","2013 - Fetushouse", "02 - Self Portrait 19.mp3"]),
+			PathBuf::from_iter(["root", "Holy Konni","2013 - Fetushouse", "03 - Vlieg Berg-Stop Nie.mp3"]),
+			PathBuf::from_iter(["root", "Holy Konni","2013 - Fetushouse", "04 - DREAM CRY BREAST LIFE.mp3"]),
+			PathBuf::from_iter(["root", "Holy Konni","2013 - Fetushouse", "05 - Natalie Reborn.mp3"]),
+		];
+
+		let songs: Vec<index::Song> = ctx
+			.index_manager
+			.get_songs(song_paths)
+			.await
+			.into_iter()
+			.map(|s| s.unwrap())
+			.collect();
+
+		let expected = vec![(
+			"Example Playlist".to_owned(),
+			"example_user".to_owned(),
+			songs,
+		)];
+
+		assert_eq!(actual, expected);
+	}
 }
diff --git a/src/main.rs b/src/main.rs
index 0331921..d9d2268 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -128,7 +128,7 @@ fn main() -> Result<(), Error> {
 	info!("Cache files location is {:#?}", paths.cache_dir_path);
 	info!("Data files location is {:#?}", paths.data_dir_path);
 	info!("Config file location is {:#?}", paths.config_file_path);
-	info!("Database file location is {:#?}", paths.db_file_path);
+	info!("Legacy database file location is {:#?}", paths.db_file_path);
 	info!("Log file location is {:#?}", paths.log_file_path);
 	#[cfg(unix)]
 	if !cli_options.foreground {
diff --git a/src/server/error.rs b/src/server/error.rs
index 11245f4..16bc717 100644
--- a/src/server/error.rs
+++ b/src/server/error.rs
@@ -132,6 +132,7 @@ impl From<app::Error> for APIError {
 			app::Error::IndexSerializationError => APIError::Internal,
 
 			app::Error::CouldNotMapToRealPath(_) => APIError::VFSPathNotFound,
+			app::Error::CouldNotMapToVirtualPath(_) => APIError::Internal,
 			app::Error::UserNotFound => APIError::UserNotFound,
 			app::Error::DirectoryNotFound(d) => APIError::DirectoryNotFound(d),
 			app::Error::ArtistNotFound => APIError::ArtistNotFound,
diff --git a/test-data/legacy_db_blank.sqlite b/test-data/legacy_db_blank.sqlite
new file mode 100644
index 0000000..73a36dd
Binary files /dev/null and b/test-data/legacy_db_blank.sqlite differ
diff --git a/test-data/legacy_db_populated.sqlite b/test-data/legacy_db_populated.sqlite
new file mode 100644
index 0000000..4bc3b27
Binary files /dev/null and b/test-data/legacy_db_populated.sqlite differ