diff --git a/README.md b/README.md
index 9c4c63c..0d1cb0b 100644
--- a/README.md
+++ b/README.md
@@ -27,7 +27,7 @@ No neural-networks/machine-learning is used by this project. All image analysis
 The bot needs the following environment variables to be set in order to run:
 
 ```
-SHIMMERING_DISCORD_ID=yourtoken
+SHIMMERING_DISCORD_TOKEN=yourtoken
 SHIMMERING_DATA_DIR=shimmering/data
 SHIMMERING_ASSET_DIR=shimmering/assets
 SHIMMERING_CONFIG_DIR=shimmering/config
diff --git a/migrations/04-auto-delete-scores/up.sql b/migrations/04-auto-delete-scores/up.sql
new file mode 100644
index 0000000..d284d63
--- /dev/null
+++ b/migrations/04-auto-delete-scores/up.sql
@@ -0,0 +1,7 @@
+-- Automatically delete all associated scores
+-- every time a play is deleted.
+CREATE TRIGGER auto_delete_scores AFTER DELETE ON plays
+BEGIN
+  DELETE FROM scores
+  WHERE play_id = OLD.id;
+END;
diff --git a/src/arcaea/play.rs b/src/arcaea/play.rs
index 64103be..8db4438 100644
--- a/src/arcaea/play.rs
+++ b/src/arcaea/play.rs
@@ -83,7 +83,12 @@ impl CreatePlay {
 					self.max_recall,
 					self.far_notes,
 				),
-				|row| Ok((row.get("id")?, row.get("created_at")?)),
+				|row| {
+					Ok((
+						row.get("id")?,
+						default_while_testing(row.get("created_at")?),
+					))
+				},
 			)
 			.with_context(|| {
 				format!(
@@ -131,7 +136,7 @@ impl CreatePlay {
 }
 // }}}
 // {{{ Score data
-#[derive(Debug, Clone, Copy)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub struct ScoreCollection([Score; ScoringSystem::SCORING_SYSTEMS.len()]);
 
 impl ScoreCollection {
@@ -143,7 +148,7 @@ impl ScoreCollection {
 }
 // }}}
 // {{{ Play
-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, PartialEq, Eq)]
 pub struct Play {
 	pub id: u32,
 	#[allow(unused)]
@@ -157,6 +162,18 @@ pub struct Play {
 	pub scores: ScoreCollection,
 }
 
+/// Timestamps and other similar values break golden testing.
+/// This function can be used to replace such values with [Default::default]
+/// while testing.
+#[inline]
+fn default_while_testing<D: Default>(v: D) -> D {
+	if cfg!(test) {
+		D::default()
+	} else {
+		v
+	}
+}
+
 impl Play {
 	// {{{ Row parsing
 	#[inline]
@@ -165,10 +182,10 @@ impl Play {
 			id: row.get("id")?,
 			chart_id: row.get("chart_id")?,
 			user_id: row.get("user_id")?,
-			created_at: row.get("created_at")?,
 			max_recall: row.get("max_recall")?,
 			far_notes: row.get("far_notes")?,
 			scores: ScoreCollection::from_standard_score(Score(row.get("score")?), chart),
+			created_at: default_while_testing(row.get("created_at")?),
 		})
 	}
 	// }}}
diff --git a/src/assets.rs b/src/assets.rs
index 379113f..7896ec9 100644
--- a/src/assets.rs
+++ b/src/assets.rs
@@ -42,11 +42,9 @@ pub fn get_asset_dir() -> PathBuf {
 // {{{ Font helpers
 #[inline]
 fn get_font(name: &str) -> RefCell<Face> {
-	let face = timed!(format!("load font \"{name}\""), {
-		FREETYPE_LIB.with(|lib| {
-			lib.new_face(get_asset_dir().join("fonts").join(name), 0)
-				.expect(&format!("Could not load {} font", name))
-		})
+	let face = FREETYPE_LIB.with(|lib| {
+		lib.new_face(get_asset_dir().join("fonts").join(name), 0)
+			.expect(&format!("Could not load {} font", name))
 	});
 	RefCell::new(face)
 }
diff --git a/src/cli/analyse.rs b/src/cli/analyse.rs
new file mode 100644
index 0000000..ab0bd94
--- /dev/null
+++ b/src/cli/analyse.rs
@@ -0,0 +1,18 @@
+use std::path::PathBuf;
+
+use crate::{
+	cli::context::CliContext,
+	commands::score::magic_impl,
+	context::{Error, UserContext},
+};
+
+#[derive(clap::Args)]
+pub struct Args {
+	files: Vec<PathBuf>,
+}
+
+pub async fn run(args: Args) -> Result<(), Error> {
+	let mut ctx = CliContext::new(UserContext::new().await?);
+	magic_impl(&mut ctx, &args.files).await?;
+	Ok(())
+}
diff --git a/src/cli/context.rs b/src/cli/context.rs
new file mode 100644
index 0000000..d81b255
--- /dev/null
+++ b/src/cli/context.rs
@@ -0,0 +1,81 @@
+use std::num::NonZeroU64;
+use std::path::PathBuf;
+use std::str::FromStr;
+
+use poise::serenity_prelude::{CreateAttachment, CreateMessage};
+
+use crate::assets::get_var;
+use crate::context::Error;
+use crate::{commands::discord::MessageContext, context::UserContext};
+
+pub struct CliContext {
+	pub user_id: u64,
+	pub data: UserContext,
+}
+
+impl CliContext {
+	pub fn new(data: UserContext) -> Self {
+		Self {
+			data,
+			user_id: get_var("SHIMMERING_DISCORD_USER_ID")
+				.parse()
+				.expect("invalid user id"),
+		}
+	}
+}
+
+impl MessageContext for CliContext {
+	fn author_id(&self) -> u64 {
+		self.user_id
+	}
+
+	async fn fetch_user(&self, discord_id: &str) -> Result<poise::serenity_prelude::User, Error> {
+		let mut user = poise::serenity_prelude::User::default();
+		user.id = poise::serenity_prelude::UserId::from_str(discord_id)?;
+		user.name = "shimmeringuser".to_string();
+		Ok(user)
+	}
+
+	fn data(&self) -> &UserContext {
+		&self.data
+	}
+
+	async fn reply(&mut self, text: &str) -> Result<(), Error> {
+		println!("[Reply] {text}");
+		Ok(())
+	}
+
+	async fn send_files(
+		&mut self,
+		_attachments: impl IntoIterator<Item = CreateAttachment>,
+		message: CreateMessage,
+	) -> Result<(), Error> {
+		let all = toml::to_string(&message).unwrap();
+		println!("\n========== Message ==========");
+		println!("{all}");
+		Ok(())
+	}
+
+	// {{{ Input attachments
+	type Attachment = PathBuf;
+
+	fn filename(attachment: &Self::Attachment) -> &str {
+		attachment.file_name().unwrap().to_str().unwrap()
+	}
+
+	// This is a dumb implementation, but it works for testing...
+	fn is_image(attachment: &Self::Attachment) -> bool {
+		let ext = attachment.extension().unwrap();
+		ext == "png" || ext == "jpg" || ext == "webp"
+	}
+
+	fn attachment_id(_attachment: &Self::Attachment) -> NonZeroU64 {
+		NonZeroU64::new(666).unwrap()
+	}
+
+	async fn download(&self, attachment: &Self::Attachment) -> Result<Vec<u8>, Error> {
+		let res = tokio::fs::read(attachment).await?;
+		Ok(res)
+	}
+	// }}}
+}
diff --git a/src/cli/mod.rs b/src/cli/mod.rs
index a4090bd..db9d9e4 100644
--- a/src/cli/mod.rs
+++ b/src/cli/mod.rs
@@ -1,3 +1,5 @@
+pub mod analyse;
+pub mod context;
 pub mod prepare_jackets;
 
 #[derive(clap::Parser)]
@@ -12,4 +14,5 @@ pub enum Command {
 	/// Start the discord bot
 	Discord {},
 	PrepareJackets {},
+	Analyse(analyse::Args),
 }
diff --git a/src/cli/prepare_jackets.rs b/src/cli/prepare_jackets.rs
index 427c5ab..9e0302f 100644
--- a/src/cli/prepare_jackets.rs
+++ b/src/cli/prepare_jackets.rs
@@ -16,7 +16,12 @@ use crate::{
 	recognition::fuzzy_song_name::guess_chart_name,
 };
 
-pub fn prepare_jackets() -> Result<(), Error> {
+#[inline]
+fn clear_line() {
+	print!("\r                                       \r");
+}
+
+pub fn run() -> Result<(), Error> {
 	let db = connect_db(&get_data_dir());
 	let song_cache = SongCache::new(&db)?;
 
@@ -41,11 +46,12 @@ pub fn prepare_jackets() -> Result<(), Error> {
 		let dir_name = raw_dir_name.to_str().unwrap();
 
 		// {{{ Update progress live
-		print!(
-			"{}/{}: {dir_name}                          \r",
-			i,
-			entries.len()
-		);
+		if i != 0 {
+			clear_line();
+		}
+
+		print!("{}/{}: {dir_name}", i, entries.len());
+
 		if i % 5 == 0 {
 			stdout().flush()?;
 		}
@@ -132,6 +138,8 @@ pub fn prepare_jackets() -> Result<(), Error> {
 		}
 	}
 
+	clear_line();
+
 	// NOTE: this is N^2, but it's a one-off warning thing, so it's fine
 	for chart in song_cache.charts() {
 		if jacket_vectors.iter().all(|(i, _)| chart.song_id != *i) {
diff --git a/src/commands/discord.rs b/src/commands/discord.rs
index 3384681..98a021c 100644
--- a/src/commands/discord.rs
+++ b/src/commands/discord.rs
@@ -1,8 +1,9 @@
-use std::num::NonZeroU64;
+use std::{num::NonZeroU64, str::FromStr};
 
 use poise::serenity_prelude::{futures::future::join_all, CreateAttachment, CreateMessage};
 
 use crate::{
+	arcaea::play::Play,
 	context::{Error, UserContext},
 	timed,
 };
@@ -13,6 +14,9 @@ pub trait MessageContext {
 	fn data(&self) -> &UserContext;
 	fn author_id(&self) -> u64;
 
+	/// Fetch info about a user given it's id.
+	async fn fetch_user(&self, discord_id: &str) -> Result<poise::serenity_prelude::User, Error>;
+
 	/// Reply to the current message
 	async fn reply(&mut self, text: &str) -> Result<(), Error>;
 
@@ -23,6 +27,11 @@ pub trait MessageContext {
 		message: CreateMessage,
 	) -> Result<(), Error>;
 
+	/// Deliver a message
+	async fn send(&mut self, message: CreateMessage) -> Result<(), Error> {
+		self.send_files([], message).await
+	}
+
 	// {{{ Input attachments
 	type Attachment;
 
@@ -36,7 +45,7 @@ pub trait MessageContext {
 	/// Downloads every image
 	async fn download_images<'a>(
 		&self,
-		attachments: &'a Vec<Self::Attachment>,
+		attachments: &'a [Self::Attachment],
 	) -> Result<Vec<(&'a Self::Attachment, Vec<u8>)>, Error> {
 		let download_tasks = attachments
 			.iter()
@@ -64,6 +73,13 @@ impl<'a> MessageContext for poise::Context<'a, UserContext, Error> {
 		self.author().id.get()
 	}
 
+	async fn fetch_user(&self, discord_id: &str) -> Result<poise::serenity_prelude::User, Error> {
+		poise::serenity_prelude::UserId::from_str(discord_id)?
+			.to_user(self.http())
+			.await
+			.map_err(|e| e.into())
+	}
+
 	async fn reply(&mut self, text: &str) -> Result<(), Error> {
 		Self::reply(*self, text).await?;
 		Ok(())
@@ -106,6 +122,9 @@ pub mod mock {
 
 	use super::*;
 
+	/// A mock context usable for testing. Messages and attachments are
+	/// accumulated inside a vec, and can be used for golden testing
+	/// (see [MockContext::golden])
 	pub struct MockContext {
 		pub user_id: u64,
 		pub data: UserContext,
@@ -121,7 +140,16 @@ pub mod mock {
 			}
 		}
 
-		pub fn write_to(&self, path: &PathBuf) -> Result<(), Error> {
+		// {{{ golden
+		/// This function implements the logic for "golden testing". We essentially
+		/// make sure a command's output doesn't change, by writing it to disk,
+		/// and comparing new outputs to the "golden" copy.
+		///
+		/// 1. This will attempt to write the data to disk (at the given path)
+		/// 2. If the data already exists on disk, the two copies will be
+		///    compared. A panic will occur on disagreements.
+		/// 3. `SHIMMERING_TEST_REGEN=1` can be passed to overwrite disagreements.
+		pub fn golden(&self, path: &PathBuf) -> Result<(), Error> {
 			if env::var("SHIMMERING_TEST_REGEN").unwrap_or_default() == "1" {
 				fs::remove_dir_all(path)?;
 			}
@@ -152,10 +180,21 @@ pub mod mock {
 						fs::write(&path, &attachment.data)?;
 					}
 				}
+
+				// Ensure there's no extra attachments on disk
+				let file_count = fs::read_dir(dir)?.count();
+				if file_count != attachments.len() + 1 {
+					panic!(
+						"Only {} attachments found instead of {}",
+						attachments.len(),
+						file_count - 1
+					);
+				}
 			}
 
 			Ok(())
 		}
+		// }}}
 	}
 
 	impl MessageContext for MockContext {
@@ -163,6 +202,16 @@ pub mod mock {
 			self.user_id
 		}
 
+		async fn fetch_user(
+			&self,
+			discord_id: &str,
+		) -> Result<poise::serenity_prelude::User, Error> {
+			let mut user = poise::serenity_prelude::User::default();
+			user.id = poise::serenity_prelude::UserId::from_str(discord_id)?;
+			user.name = "testinguser".to_string();
+			Ok(user)
+		}
+
 		fn data(&self) -> &UserContext {
 			&self.data
 		}
@@ -208,3 +257,10 @@ pub mod mock {
 	}
 }
 // }}}
+// {{{ Helpers
+#[inline]
+#[allow(dead_code)] // Currently only used for testing
+pub fn play_song_title<'a>(ctx: &'a impl MessageContext, play: &'a Play) -> Result<&'a str, Error> {
+	Ok(&ctx.data().song_cache.lookup_chart(play.chart_id)?.0.title)
+}
+// }}}
diff --git a/src/commands/score.rs b/src/commands/score.rs
index 0a3f1d1..8f1b937 100644
--- a/src/commands/score.rs
+++ b/src/commands/score.rs
@@ -2,7 +2,7 @@ use crate::arcaea::play::{CreatePlay, Play};
 use crate::arcaea::score::Score;
 use crate::context::{Context, Error};
 use crate::recognition::recognize::{ImageAnalyzer, ScoreKind};
-use crate::user::{discord_id_to_discord_user, User};
+use crate::user::User;
 use crate::{get_user, timed};
 use anyhow::anyhow;
 use image::DynamicImage;
@@ -25,9 +25,9 @@ pub async fn score(_ctx: Context<'_>) -> Result<(), Error> {
 // }}}
 // {{{ Score magic
 // {{{ Implementation
-async fn magic_impl<C: MessageContext>(
+pub async fn magic_impl<C: MessageContext>(
 	ctx: &mut C,
-	files: Vec<C::Attachment>,
+	files: &[C::Attachment],
 ) -> Result<Vec<Play>, Error> {
 	let user = get_user!(ctx);
 	let files = ctx.download_images(&files).await?;
@@ -44,36 +44,30 @@ async fn magic_impl<C: MessageContext>(
 
 	for (i, (attachment, bytes)) in files.into_iter().enumerate() {
 		// {{{ Preapare image
-		let mut image = timed!("decode image", { image::load_from_memory(&bytes)? });
-		let mut grayscale_image = timed!("grayscale image", {
-			DynamicImage::ImageLuma8(image.to_luma8())
-		});
-		// image = image.resize(1024, 1024, FilterType::Nearest);
+		let mut image = image::load_from_memory(&bytes)?;
+		let mut grayscale_image = DynamicImage::ImageLuma8(image.to_luma8());
 		// }}}
 
 		let result: Result<(), Error> = try {
 			// {{{ Detection
 
-			// edit_reply!(ctx, handle, "Image {}: reading kind", i + 1).await?;
 			let kind = timed!("read_score_kind", {
 				analyzer.read_score_kind(ctx.data(), &grayscale_image)?
 			});
 
-			// edit_reply!(ctx, handle, "Image {}: reading difficulty", i + 1).await?;
 			// Do not use `ocr_image` because this reads the colors
 			let difficulty = timed!("read_difficulty", {
 				analyzer.read_difficulty(ctx.data(), &image, &grayscale_image, kind)?
 			});
 
-			// edit_reply!(ctx, handle, "Image {}: reading jacket", i + 1).await?;
 			let (song, chart) = timed!("read_jacket", {
 				analyzer.read_jacket(ctx.data(), &mut image, kind, difficulty)?
 			});
 
 			let max_recall = match kind {
 				ScoreKind::ScoreScreen => {
-					// edit_reply!(ctx, handle, "Image {}: reading max recall", i + 1).await?;
-					Some(analyzer.read_max_recall(ctx.data(), &grayscale_image)?)
+					// NOTE: are we ok with discarding errors like that?
+					analyzer.read_max_recall(ctx.data(), &grayscale_image).ok()
 				}
 				ScoreKind::SongSelect => None,
 			};
@@ -81,13 +75,11 @@ async fn magic_impl<C: MessageContext>(
 			grayscale_image.invert();
 			let note_distribution = match kind {
 				ScoreKind::ScoreScreen => {
-					// edit_reply!(ctx, handle, "Image {}: reading distribution", i + 1).await?;
 					Some(analyzer.read_distribution(ctx.data(), &grayscale_image)?)
 				}
 				ScoreKind::SongSelect => None,
 			};
 
-			// edit_reply!(ctx, handle, "Image {}: reading score", i + 1).await?;
 			let score = timed!("read_score", {
 				analyzer
 					.read_score(ctx.data(), Some(chart.note_count), &grayscale_image, kind)
@@ -145,19 +137,17 @@ mod magic_tests {
 	use std::path::PathBuf;
 
 	use crate::{
-		arcaea::score::ScoringSystem, commands::discord::mock::MockContext, with_test_ctx,
+		arcaea::score::ScoringSystem,
+		commands::discord::{mock::MockContext, play_song_title},
+		with_test_ctx,
 	};
 
 	use super::*;
 
-	fn play_song_title<'a>(ctx: &'a MockContext, play: &'a Play) -> Result<&'a str, Error> {
-		Ok(&ctx.data().song_cache.lookup_chart(play.chart_id)?.0.title)
-	}
-
 	#[tokio::test]
 	async fn no_pics() -> Result<(), Error> {
 		with_test_ctx!("test/commands/score/magic/no_pics", async |ctx| {
-			magic_impl(ctx, vec![]).await?;
+			magic_impl(ctx, &[]).await?;
 			Ok(())
 		})
 	}
@@ -167,11 +157,9 @@ mod magic_tests {
 		with_test_ctx!(
 			"test/commands/score/magic/single_pic",
 			async |ctx: &mut MockContext| {
-				let plays = magic_impl(
-					ctx,
-					vec![PathBuf::from_str("test/screenshots/alter_ego.jpg")?],
-				)
-				.await?;
+				let plays =
+					magic_impl(ctx, &[PathBuf::from_str("test/screenshots/alter_ego.jpg")?])
+						.await?;
 				assert_eq!(plays.len(), 1);
 				assert_eq!(plays[0].score(ScoringSystem::Standard).0, 9926250);
 				assert_eq!(play_song_title(ctx, &plays[0])?, "ALTER EGO");
@@ -187,7 +175,7 @@ mod magic_tests {
 			async |ctx: &mut MockContext| {
 				let plays = magic_impl(
 					ctx,
-					vec![
+					&[
 						PathBuf::from_str("test/screenshots/antithese_74_kerning.jpg")?,
 						PathBuf::from_str("test/screenshots/genocider_24_kerning.jpg")?,
 					],
@@ -206,74 +194,33 @@ mod magic_tests {
 	}
 }
 // }}}
-
+// {{{ Discord wrapper
 /// Identify scores from attached images.
 #[poise::command(prefix_command, slash_command)]
 pub async fn magic(
 	mut ctx: Context<'_>,
 	#[description = "Images containing scores"] files: Vec<serenity::Attachment>,
 ) -> Result<(), Error> {
-	magic_impl(&mut ctx, files).await?;
+	magic_impl(&mut ctx, &files).await?;
 
 	Ok(())
 }
 // }}}
-// {{{ Score delete
-/// Delete scores, given their IDs.
-#[poise::command(prefix_command, slash_command)]
-pub async fn delete(
-	mut ctx: Context<'_>,
-	#[description = "Id of score to delete"] ids: Vec<u32>,
-) -> Result<(), Error> {
-	let user = get_user!(&mut ctx);
-
-	if ids.len() == 0 {
-		ctx.reply("Empty ID list provided").await?;
-		return Ok(());
-	}
-
-	let mut count = 0;
-
-	for id in ids {
-		let res = ctx
-			.data()
-			.db
-			.get()?
-			.prepare_cached("DELETE FROM plays WHERE id=? AND user_id=?")?
-			.execute((id, user.id))?;
-
-		if res == 0 {
-			ctx.reply(format!("No play with id {} found", id)).await?;
-		} else {
-			count += 1;
-		}
-	}
-
-	if count > 0 {
-		ctx.reply(format!("Deleted {} play(s) successfully!", count))
-			.await?;
-	}
-
-	Ok(())
-}
 // }}}
 // {{{ Score show
-/// Show scores given their ides
-#[poise::command(prefix_command, slash_command)]
-pub async fn show(
-	ctx: Context<'_>,
-	#[description = "Ids of score to show"] ids: Vec<u32>,
-) -> Result<(), Error> {
+// {{{ Implementation
+pub async fn show_impl<C: MessageContext>(ctx: &mut C, ids: &[u32]) -> Result<Vec<Play>, Error> {
 	if ids.len() == 0 {
 		ctx.reply("Empty ID list provided").await?;
-		return Ok(());
+		return Ok(vec![]);
 	}
 
 	let mut embeds = Vec::with_capacity(ids.len());
 	let mut attachments = Vec::with_capacity(ids.len());
+	let mut plays = Vec::with_capacity(ids.len());
 	let conn = ctx.data().db.get()?;
 	for (i, id) in ids.iter().enumerate() {
-		let (song, chart, play, discord_id) = conn
+		let result = conn
 			.prepare_cached(
 				"
           SELECT 
@@ -292,13 +239,24 @@ pub async fn show(
 			.query_and_then([id], |row| -> Result<_, Error> {
 				let (song, chart) = ctx.data().song_cache.lookup_chart(row.get("chart_id")?)?;
 				let play = Play::from_sql(chart, row)?;
+
 				let discord_id = row.get::<_, String>("discord_id")?;
 				Ok((song, chart, play, discord_id))
 			})?
-			.next()
-			.ok_or_else(|| anyhow!("Could not find play with id {}", id))??;
+			.next();
 
-		let author = discord_id_to_discord_user(&ctx, &discord_id).await?;
+		let (song, chart, play, discord_id) = match result {
+			None => {
+				ctx.send(
+					CreateMessage::new().content(format!("Could not find play with id {}", id)),
+				)
+				.await?;
+				continue;
+			}
+			Some(result) => result?,
+		};
+
+		let author = ctx.fetch_user(&discord_id).await?;
 		let user = User::by_id(ctx.data(), play.user_id)?;
 
 		let (embed, attachment) =
@@ -306,12 +264,215 @@ pub async fn show(
 
 		embeds.push(embed);
 		attachments.extend(attachment);
+		plays.push(play);
 	}
 
-	ctx.channel_id()
-		.send_files(ctx.http(), attachments, CreateMessage::new().embeds(embeds))
-		.await?;
+	if embeds.len() > 0 {
+		ctx.send_files(attachments, CreateMessage::new().embeds(embeds))
+			.await?;
+	}
+
+	Ok(plays)
+}
+/// }}}
+// {{{ Tests
+#[cfg(test)]
+mod show_tests {
+	use super::*;
+	use crate::{commands::discord::mock::MockContext, with_test_ctx};
+	use std::path::PathBuf;
+
+	#[tokio::test]
+	async fn no_ids() -> Result<(), Error> {
+		with_test_ctx!("test/commands/score/show/no_ids", async |ctx| {
+			show_impl(ctx, &[]).await?;
+			Ok(())
+		})
+	}
+
+	#[tokio::test]
+	async fn nonexistent_id() -> Result<(), Error> {
+		with_test_ctx!("test/commands/score/show/nonexistent_id", async |ctx| {
+			show_impl(ctx, &[666]).await?;
+			Ok(())
+		})
+	}
+
+	#[tokio::test]
+	async fn agrees_with_magic() -> Result<(), Error> {
+		with_test_ctx!(
+			"test/commands/score/show/agrees_with_magic",
+			async |ctx: &mut MockContext| {
+				let created_plays = magic_impl(
+					ctx,
+					&[
+						PathBuf::from_str("test/screenshots/alter_ego.jpg")?,
+						PathBuf::from_str("test/screenshots/antithese_74_kerning.jpg")?,
+						PathBuf::from_str("test/screenshots/genocider_24_kerning.jpg")?,
+					],
+				)
+				.await?;
+
+				let ids = created_plays.iter().map(|p| p.id).collect::<Vec<_>>();
+				let plays = show_impl(ctx, &ids).await?;
+
+				assert_eq!(plays.len(), 3);
+				assert_eq!(created_plays, plays);
+				Ok(())
+			}
+		)
+	}
+}
+// }}}
+// {{{ Discord wrapper
+/// Show scores given their ides
+#[poise::command(prefix_command, slash_command)]
+pub async fn show(
+	mut ctx: Context<'_>,
+	#[description = "Ids of score to show"] ids: Vec<u32>,
+) -> Result<(), Error> {
+	show_impl(&mut ctx, &ids).await?;
 
 	Ok(())
 }
 // }}}
+// }}}
+// {{{ Score delete
+// {{{ Implementation
+pub async fn delete_impl<C: MessageContext>(ctx: &mut C, ids: &[u32]) -> Result<(), Error> {
+	let user = get_user!(ctx);
+
+	if ids.len() == 0 {
+		ctx.reply("Empty ID list provided").await?;
+		return Ok(());
+	}
+
+	let mut count = 0;
+
+	for id in ids {
+		let res = ctx
+			.data()
+			.db
+			.get()?
+			.prepare_cached("DELETE FROM plays WHERE id=? AND user_id=?")?
+			.execute((id, user.id))?;
+
+		if res == 0 {
+			ctx.reply(&format!("No play with id {} found", id)).await?;
+		} else {
+			count += 1;
+		}
+	}
+
+	if count > 0 {
+		ctx.reply(&format!("Deleted {} play(s) successfully!", count))
+			.await?;
+	}
+
+	Ok(())
+}
+/// }}}
+// {{{ Tests
+#[cfg(test)]
+mod delete_tests {
+	use super::*;
+	use crate::{
+		commands::discord::{mock::MockContext, play_song_title},
+		with_test_ctx,
+	};
+	use std::path::PathBuf;
+
+	#[tokio::test]
+	async fn no_ids() -> Result<(), Error> {
+		with_test_ctx!("test/commands/score/delete/no_ids", async |ctx| {
+			delete_impl(ctx, &[]).await?;
+			Ok(())
+		})
+	}
+
+	#[tokio::test]
+	async fn nonexistent_id() -> Result<(), Error> {
+		with_test_ctx!("test/commands/score/delete/nonexistent_id", async |ctx| {
+			delete_impl(ctx, &[666]).await?;
+			Ok(())
+		})
+	}
+
+	#[tokio::test]
+	async fn delete_twice() -> Result<(), Error> {
+		with_test_ctx!(
+			"test/commands/score/delete/delete_twice",
+			async |ctx: &mut MockContext| {
+				let plays =
+					magic_impl(ctx, &[PathBuf::from_str("test/screenshots/alter_ego.jpg")?])
+						.await?;
+
+				let id = plays[0].id;
+				delete_impl(ctx, &[id, id]).await?;
+				Ok(())
+			}
+		)
+	}
+
+	#[tokio::test]
+	async fn no_show_after_delete() -> Result<(), Error> {
+		with_test_ctx!(
+			"test/commands/score/delete/no_show_after_delete",
+			async |ctx: &mut MockContext| {
+				let plays =
+					magic_impl(ctx, &[PathBuf::from_str("test/screenshots/alter_ego.jpg")?])
+						.await?;
+
+				// Showcase proper usage
+				let ids = [plays[0].id];
+				delete_impl(ctx, &ids).await?;
+
+				// This will tell the user the play doesn't exist
+				let shown_plays = show_impl(ctx, &ids).await?;
+				assert_eq!(shown_plays.len(), 0);
+
+				Ok(())
+			}
+		)
+	}
+
+	#[tokio::test]
+	async fn delete_multiple() -> Result<(), Error> {
+		with_test_ctx!(
+			"test/commands/score/delete/delete_multiple",
+			async |ctx: &mut MockContext| {
+				let plays = magic_impl(
+					ctx,
+					&[
+						PathBuf::from_str("test/screenshots/antithese_74_kerning.jpg")?,
+						PathBuf::from_str("test/screenshots/alter_ego.jpg")?,
+						PathBuf::from_str("test/screenshots/genocider_24_kerning.jpg")?,
+					],
+				)
+				.await?;
+
+				delete_impl(ctx, &[plays[0].id, plays[2].id]).await?;
+
+				// Ensure the second play still exists
+				let shown_plays = show_impl(ctx, &[plays[1].id]).await?;
+				assert_eq!(play_song_title(ctx, &shown_plays[0])?, "ALTER EGO");
+
+				Ok(())
+			}
+		)
+	}
+}
+// }}}
+// {{{ Discord wrapper
+/// Delete scores, given their IDs.
+#[poise::command(prefix_command, slash_command)]
+pub async fn delete(
+	mut ctx: Context<'_>,
+	#[description = "Id of score to delete"] ids: Vec<u32>,
+) -> Result<(), Error> {
+	delete_impl(&mut ctx, &ids).await?;
+
+	Ok(())
+}
+// }}}
+// }}}
diff --git a/src/context.rs b/src/context.rs
index 4539558..f0d8c43 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -35,24 +35,22 @@ pub struct UserContext {
 }
 
 pub fn connect_db(data_dir: &Path) -> DbConnection {
-	timed!("create_sqlite_pool", {
-		fs::create_dir_all(data_dir).expect("Could not create $SHIMMERING_DATA_DIR");
+	fs::create_dir_all(data_dir).expect("Could not create $SHIMMERING_DATA_DIR");
 
-		let data_dir = data_dir.to_str().unwrap().to_owned();
+	let data_dir = data_dir.to_str().unwrap().to_owned();
 
-		let db_path = format!("{}/db.sqlite", data_dir);
-		let mut conn = rusqlite::Connection::open(&db_path).unwrap();
-		static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/migrations");
-		static MIGRATIONS: LazyLock<Migrations> = LazyLock::new(|| {
-			Migrations::from_directory(&MIGRATIONS_DIR).expect("Could not load migrations")
-		});
+	let db_path = format!("{}/db.sqlite", data_dir);
+	let mut conn = rusqlite::Connection::open(&db_path).unwrap();
+	static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/migrations");
+	static MIGRATIONS: LazyLock<Migrations> = LazyLock::new(|| {
+		Migrations::from_directory(&MIGRATIONS_DIR).expect("Could not load migrations")
+	});
 
-		MIGRATIONS
-			.to_latest(&mut conn)
-			.expect("Could not run migrations");
+	MIGRATIONS
+		.to_latest(&mut conn)
+		.expect("Could not run migrations");
 
-		Pool::new(SqliteConnectionManager::file(&db_path)).expect("Could not open sqlite database.")
-	})
+	Pool::new(SqliteConnectionManager::file(&db_path)).expect("Could not open sqlite database.")
 }
 
 impl UserContext {
@@ -61,9 +59,9 @@ impl UserContext {
 		timed!("create_context", {
 			let db = connect_db(&get_data_dir());
 
-			let mut song_cache = timed!("make_song_cache", { SongCache::new(&db)? });
+			let mut song_cache = SongCache::new(&db)?;
+			let ui_measurements = UIMeasurements::read()?;
 			let jacket_cache = timed!("make_jacket_cache", { JacketCache::new(&mut song_cache)? });
-			let ui_measurements = timed!("read_ui_measurements", { UIMeasurements::read()? });
 
 			// {{{ Font measurements
 			static WHITELIST: &str = "0123456789'abcdefghklmnopqrstuvwxyzABCDEFGHIJKLMNOPRSTUVWXYZ";
@@ -134,7 +132,7 @@ pub mod testing {
 			let res: Result<(), Error> = $f(&mut ctx).await;
 			res?;
 
-			ctx.write_to(&std::path::PathBuf::from_str($test_path)?)?;
+			ctx.golden(&std::path::PathBuf::from_str($test_path)?)?;
 			Ok(())
 		}};
 	}
diff --git a/src/main.rs b/src/main.rs
index 66eb916..86696fc 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -24,7 +24,7 @@ mod user;
 
 use arcaea::play::generate_missing_scores;
 use clap::Parser;
-use cli::{prepare_jackets::prepare_jackets, Cli, Command};
+use cli::{Cli, Command};
 use context::{Error, UserContext};
 use poise::serenity_prelude::{self as serenity};
 use std::{env::var, sync::Arc, time::Duration};
@@ -115,7 +115,12 @@ async fn main() {
 			// }}}
 		}
 		Command::PrepareJackets {} => {
-			prepare_jackets().expect("Could not prepare jackets");
+			cli::prepare_jackets::run().expect("Could not prepare jackets");
+		}
+		Command::Analyse(args) => {
+			cli::analyse::run(args)
+				.await
+				.expect("Could not analyse screenshot");
 		}
 	}
 }
diff --git a/src/recognition/hyperglass.rs b/src/recognition/hyperglass.rs
index cb1646f..ff3681f 100644
--- a/src/recognition/hyperglass.rs
+++ b/src/recognition/hyperglass.rs
@@ -34,7 +34,6 @@ use crate::{
 	bitmap::{Align, BitmapCanvas, Color, TextStyle},
 	context::Error,
 	logs::{debug_image_buffer_log, debug_image_log},
-	timed,
 };
 
 // {{{ ConponentVec
@@ -232,69 +231,66 @@ pub struct CharMeasurements {
 impl CharMeasurements {
 	// {{{ Creation
 	pub fn from_text(face: &mut Face, string: &str, weight: Option<u32>) -> Result<Self, Error> {
-		timed!("measure_chars", {
-			// These are bad estimates lol
-			let style = TextStyle {
-				stroke: None,
-				drop_shadow: None,
-				align: (Align::Start, Align::Start),
-				size: 60,
-				color: Color::BLACK,
-				// TODO: do we want to use the weight hint for resilience?
-				weight,
-			};
-			let padding = (5, 5);
-			let planned = BitmapCanvas::plan_text_rendering(padding, &mut [face], style, &string)?;
+		// These are bad estimates lol
+		let style = TextStyle {
+			stroke: None,
+			drop_shadow: None,
+			align: (Align::Start, Align::Start),
+			size: 60,
+			color: Color::BLACK,
+			// TODO: do we want to use the weight hint for resilience?
+			weight,
+		};
+		let padding = (5, 5);
+		let planned = BitmapCanvas::plan_text_rendering(padding, &mut [face], style, &string)?;
 
-			let mut canvas = BitmapCanvas::new(
-				(planned.0 .0) as u32 + planned.1.width + 2 * padding.0 as u32,
-				(planned.0 .1) as u32 + planned.1.height + 2 * padding.0 as u32,
-			);
+		let mut canvas = BitmapCanvas::new(
+			(planned.0 .0) as u32 + planned.1.width + 2 * padding.0 as u32,
+			(planned.0 .1) as u32 + planned.1.height + 2 * padding.0 as u32,
+		);
 
-			canvas.text(padding, &mut [face], style, &string)?;
-			let buffer =
-				ImageBuffer::from_raw(canvas.width, canvas.height(), canvas.buffer.to_vec())
-					.ok_or_else(|| anyhow!("Failed to turn buffer into canvas"))?;
-			let image = DynamicImage::ImageRgb8(buffer);
+		canvas.text(padding, &mut [face], style, &string)?;
+		let buffer = ImageBuffer::from_raw(canvas.width, canvas.height(), canvas.buffer.to_vec())
+			.ok_or_else(|| anyhow!("Failed to turn buffer into canvas"))?;
+		let image = DynamicImage::ImageRgb8(buffer);
 
-			debug_image_log(&image);
+		debug_image_log(&image);
 
-			let components = ComponentsWithBounds::from_image(&image, 100)?;
+		let components = ComponentsWithBounds::from_image(&image, 100)?;
 
-			// {{{ Compute max width/height
-			let max_width = components
-				.bounds
-				.iter()
-				.filter_map(|o| o.as_ref())
-				.map(|b| b.x_max - b.x_min)
-				.max()
-				.ok_or_else(|| anyhow!("No connected components found"))?;
-			let max_height = components
-				.bounds
-				.iter()
-				.filter_map(|o| o.as_ref())
-				.map(|b| b.y_max - b.y_min)
-				.max()
-				.ok_or_else(|| anyhow!("No connected components found"))?;
-			// }}}
+		// {{{ Compute max width/height
+		let max_width = components
+			.bounds
+			.iter()
+			.filter_map(|o| o.as_ref())
+			.map(|b| b.x_max - b.x_min)
+			.max()
+			.ok_or_else(|| anyhow!("No connected components found"))?;
+		let max_height = components
+			.bounds
+			.iter()
+			.filter_map(|o| o.as_ref())
+			.map(|b| b.y_max - b.y_min)
+			.max()
+			.ok_or_else(|| anyhow!("No connected components found"))?;
+		// }}}
 
-			let mut chars = Vec::with_capacity(string.len());
-			for (i, char) in string.chars().enumerate() {
-				chars.push((
-					char,
-					ComponentVec::from_component(
-						&components,
-						(max_width, max_height),
-						components.bounds_by_position[i] as u32 + 1,
-					)?,
-				))
-			}
+		let mut chars = Vec::with_capacity(string.len());
+		for (i, char) in string.chars().enumerate() {
+			chars.push((
+				char,
+				ComponentVec::from_component(
+					&components,
+					(max_width, max_height),
+					components.bounds_by_position[i] as u32 + 1,
+				)?,
+			))
+		}
 
-			Ok(Self {
-				chars,
-				max_width,
-				max_height,
-			})
+		Ok(Self {
+			chars,
+			max_width,
+			max_height,
 		})
 	}
 	// }}}
@@ -305,9 +301,8 @@ impl CharMeasurements {
 		whitelist: &str,
 		binarisation_threshold: Option<u8>,
 	) -> Result<String, Error> {
-		let components = timed!("from_image", {
-			ComponentsWithBounds::from_image(image, binarisation_threshold.unwrap_or(100))?
-		});
+		let components =
+			ComponentsWithBounds::from_image(image, binarisation_threshold.unwrap_or(100))?;
 		let mut result = String::with_capacity(components.bounds.len());
 
 		let max_height = components
diff --git a/src/recognition/recognize.rs b/src/recognition/recognize.rs
index bfecf0d..4e27810 100644
--- a/src/recognition/recognize.rs
+++ b/src/recognition/recognize.rs
@@ -19,7 +19,6 @@ use crate::recognition::fuzzy_song_name::guess_chart_name;
 use crate::recognition::ui::{
 	ScoreScreenRect, SongSelectRect, UIMeasurementRect, UIMeasurementRect::*,
 };
-use crate::timed;
 use crate::transform::rotate;
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -133,32 +132,28 @@ impl ImageAnalyzer {
 		image: &DynamicImage,
 		kind: ScoreKind,
 	) -> Result<Score, Error> {
-		let image = timed!("interp_crop_resize", {
-			self.interp_crop(
-				ctx,
-				image,
-				match kind {
-					ScoreKind::SongSelect => SongSelect(SongSelectRect::Score),
-					ScoreKind::ScoreScreen => ScoreScreen(ScoreScreenRect::Score),
-				},
-			)?
-		});
+		let image = self.interp_crop(
+			ctx,
+			image,
+			match kind {
+				ScoreKind::SongSelect => SongSelect(SongSelectRect::Score),
+				ScoreKind::ScoreScreen => ScoreScreen(ScoreScreenRect::Score),
+			},
+		)?;
 
 		let measurements = match kind {
 			ScoreKind::SongSelect => &ctx.exo_measurements,
 			ScoreKind::ScoreScreen => &ctx.geosans_measurements,
 		};
 
-		let result = timed!("full recognition", {
-			Score(
-				measurements
-					.recognise(&image, "0123456789'", None)?
-					.chars()
-					.filter(|c| *c != '\'')
-					.collect::<String>()
-					.parse()?,
-			)
-		});
+		let result = Score(
+			measurements
+				.recognise(&image, "0123456789'", None)?
+				.chars()
+				.filter(|c| *c != '\'')
+				.collect::<String>()
+				.parse()?,
+		);
 
 		// Discard scores if it's impossible
 		if result.0 <= 10_010_000