Repository: web-scrobbler/web-scrobbler
Pull request #3692 (2023-05-02 20:26:59)

dont output "expected a number or a string"
**Describe the changes you made**
<!-- A clear and concise description of changes proposed in this pull request. -->

Seems to work just fine without this when testing  - should we even be logging this? Feels like it's causing a bit of noise in the console of each page? 

**Additional context**
<!-- Add any other context or screenshots here. -->


yayuyokitano: Yep, I think I talked about wanting to remove this.

I’ll have to have a closer look at the v2 code before approving, as I think this is an important function to get right

Patch:
diff --git a/.nvmrc b/.nvmrc
index 8b3ed1b2..7eae4e2e 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1,1 +1,1 @@
-16.19.1
+18.0.0
diff --git a/.prettierignore b/.prettierignore
index 79c05994..db636f41 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -2,7 +2,6 @@ build/**
 coverage/**
 node_modules/**
 src/_locales/**
-src/vendor/**
 
 package.json
 package-lock.json
diff --git a/package-lock.json b/package-lock.json
index ef7938e0..be23324c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -55,7 +55,7 @@
         "vitest": "0.30.1"
       },
       "engines": {
-        "node": ">=16.14.2"
+        "node": ">=18.0.0"
       }
     },
     "node_modules/@ampproject/remapping": {
diff --git a/package.json b/package.json
index 708f417a..57b2e63d 100644
--- a/package.json
+++ b/package.json
@@ -72,7 +72,7 @@
     "vitest": "0.30.1"
   },
   "engines": {
-    "node": ">=16.14.2"
+    "node": ">=18.0.0"
   },
   "prettier": "prettier-config-web-scrobbler",
   "remark": {
diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json
index a8a1dc0e..cf690ed6 100644
--- a/src/_locales/en/messages.json
+++ b/src/_locales/en/messages.json
@@ -203,6 +203,18 @@
         "message": "$1 edited tracks",
         "description": "'Edited tracks' header"
     },
+    "optionsRegexEdits": {
+        "message": "Regex/Bulk edits",
+        "description": "'Regex/Bulk edits' header"
+    },
+    "optionsRegexEditsDesc": {
+        "message": "This extension stores regex/bulk edits.",
+        "description": "Description of regex/bulk edits"
+    },
+    "optionsRegexEditsPopupTitle": {
+        "message": "$1 regex/bulk edits",
+        "description": "'Regex/Bulk edits' header"
+    },
     "optionsViewEdited": {
         "message": "View",
         "description": "Button to view edited tracks"
@@ -375,6 +387,10 @@
         "message": "Show some love",
         "description": "'Show some love' section"
     },
+    "showSomeLoveFundingCampaign": {
+        "message": "Over time a significant backlog of issues has been built up in the Web Scrobbler repository. From mid-August 2023 until mid-March 2024 one of our main developers will be available to work full-time on the project at a reduced rate. Roughly speaking, for every $$1500 we raise, we can pay for one month of full-time work, up to around $$9-10k. If you want to support this campaign and help us run through our backlog and make Web Scrobbler even better than it has ever been, you can use the button below. We remain committed to keeping Web Scrobbler and all of its features completely free forever. Please help spread the word.",
+        "description": "Temporary funding campaign"
+    },
     "showSomeLoveText1": {
         "message": "Unlike other extensions, this one does not contain any flashing banners or sneaky ads.",
         "description": ""
@@ -495,6 +511,14 @@
         "message": "You cannot swap empty track info",
         "description": "Title of swap button"
     },
+    "infoRegexTitle": {
+        "message": "Regex/Bulk edit mode",
+        "description": "Title of regex/bulk edit button"
+    },
+    "infoEditedWarning": {
+        "message": "This track has been edited. Regex edits are not applied to edited tracks, and the preview you see here may be inaccurate.",
+        "description": "Warning message to user if bulk editing on a previously edited track"
+    },
     "infoSubmitTitle": {
         "message": "Submit changes",
         "description": "Title of submit button"
@@ -543,6 +567,34 @@
         "message": "Album artist (optional)",
         "description": "Placeholder of album artist input"
     },
+    "infoSearchLabel": {
+        "message": "Search",
+        "description": "Label of regex edit search input"
+    },
+    "infoReplaceLabel": {
+        "message": "Replace",
+        "description": "Label of regex edit replace input"
+    },
+    "infoPreviewLabel": {
+        "message": "Preview",
+        "description": "Label of regex edit preview output"
+    },
+    "infoArtistLabel": {
+      "message": "Artist",
+      "description": "Label of artist input row"
+    },
+    "infoTrackLabel": {
+        "message": "Track",
+        "description": "Label of track input row"
+    },
+    "infoAlbumLabel": {
+        "message": "Album",
+        "description": "Label of album input row"
+    },
+    "infoAlbumArtistLabel": {
+        "message": "Album artist",
+        "description": "Label of album artist input row"
+    },
     "infoViewArtistPage": {
         "message": "View artist page: $1",
         "description": "Placeholder of album input"
diff --git a/src/core/background/main.ts b/src/core/background/main.ts
index ead5030b..385536f8 100644
--- a/src/core/background/main.ts
+++ b/src/core/background/main.ts
@@ -28,6 +28,7 @@ import {
 import ClonedSong from '@/core/object/cloned-song';
 import { openTab } from '@/util/util-browser';
 import { updateAction } from './action';
+import { setRegexDefaults } from '@/util/regex';
 
 const disabledTabs = BrowserStorage.getStorage(BrowserStorage.DISABLED_TABS);
 
@@ -296,6 +297,8 @@ function startupFunc() {
 	state.set(DEFAULT_STATE);
 	disabledTabs.set({});
 
+	setRegexDefaults();
+
 	browser.contextMenus.create({
 		id: contextMenus.ENABLE_CONNECTOR,
 		visible: false,
diff --git a/src/core/object/cloned-song.ts b/src/core/object/cloned-song.ts
index 53d75f15..0baf97bb 100644
--- a/src/core/object/cloned-song.ts
+++ b/src/core/object/cloned-song.ts
@@ -14,6 +14,7 @@ import {
 export default class ClonedSong extends BaseSong {
 	public parsed: ParsedSongData;
 	public processed: ProcessedSongData;
+	public noRegex: ProcessedSongData;
 	public flags: Flags;
 	public metadata: Metadata;
 	public connectorLabel: string;
@@ -23,6 +24,7 @@ export default class ClonedSong extends BaseSong {
 		super();
 		this.parsed = song.parsed;
 		this.processed = song.processed;
+		this.noRegex = song.noRegex;
 		this.flags = song.flags;
 		this.metadata = song.metadata;
 		this.connectorLabel = song.connectorLabel;
diff --git a/src/core/object/pipeline/copy.ts b/src/core/object/pipeline/copy.ts
new file mode 100644
index 00000000..63b8af1c
--- /dev/null
+++ b/src/core/object/pipeline/copy.ts
@@ -0,0 +1,15 @@
+/**
+ * This pipeline stage copies song info from parsed to processed for further processing.
+ */
+import Song from '@/core/object/song';
+
+/**
+ * Copy data from parsed to processed fields.
+ * @param song - Song object
+ */
+export function process(song: Song): void {
+	for (const field of Song.PROCESSED_FIELDS) {
+		// eslint-disable-next-line @typescript-eslint/no-explicit-any -- We know we are always setting to the same field even though typescript doesnt
+		(song.processed[field] as any) = song.parsed[field];
+	}
+}
diff --git a/src/core/object/pipeline/metadata.ts b/src/core/object/pipeline/metadata.ts
index fa7a4bb7..c1ece50c 100644
--- a/src/core/object/pipeline/metadata.ts
+++ b/src/core/object/pipeline/metadata.ts
@@ -47,12 +47,17 @@ export async function process(
 	if (isSongValid) {
 		if (!song.flags.isCorrectedByUser) {
 			for (const field of INFO_TO_COPY) {
+				if (!songInfo[field]) {
+					continue;
+				}
 				// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Set regardless of previous state
 				(song.processed[field] as any) = songInfo[field];
 			}
 
-			if (!song.getAlbum()) {
+			if (!song.getAlbum() || song.flags.isAlbumFetched) {
 				song.processed.album = songInfo.album;
+				song.noRegex.album = songInfo.album;
+				song.flags.isAlbumFetched = true;
 			}
 		}
 
diff --git a/src/core/object/pipeline/normalize.ts b/src/core/object/pipeline/normalize.ts
index 36565c1f..0b47f6a6 100644
--- a/src/core/object/pipeline/normalize.ts
+++ b/src/core/object/pipeline/normalize.ts
@@ -1,5 +1,3 @@
-'use strict';
-
 /**
  * This pipeline stage normalizes track info fields.
  */
@@ -14,6 +12,7 @@ export function process(song: Song): void {
 		const fieldValue = song.processed[field];
 		if (typeof fieldValue === 'string' && fieldValue) {
 			song.processed[field] = fieldValue.normalize();
+			song.noRegex[field] = fieldValue.normalize();
 		}
 	}
 }
diff --git a/src/core/object/pipeline/pipeline.ts b/src/core/object/pipeline/pipeline.ts
index 29f31d79..897bab93 100644
--- a/src/core/object/pipeline/pipeline.ts
+++ b/src/core/object/pipeline/pipeline.ts
@@ -2,15 +2,25 @@
  * The module applies functions provided by pipeline stages to given song.
  */
 import * as UserInput from '@/core/object/pipeline/user-input';
+import * as Copy from '@/core/object/pipeline/copy';
 import * as Metadata from '@/core/object/pipeline/metadata';
 import * as Normalize from '@/core/object/pipeline/normalize';
+import * as RegexEdits from '@/core/object/pipeline/regex-edits';
 import * as CoverArtArchive from '@/core/object/pipeline/coverartarchive/coverartarchive';
 import Song from '@/core/object/song';
 import { ConnectorMeta } from '@/core/connectors';
 
 export default class Pipeline {
 	private song: Song | null = null;
-	private processors = [Normalize, UserInput, Metadata, CoverArtArchive];
+	private processors = [
+		Copy,
+		Normalize,
+		UserInput,
+		RegexEdits,
+		Metadata,
+		RegexEdits, // Run regex edits again, as the regex edit might have caused an album to be found.
+		CoverArtArchive,
+	];
 	constructor() {
 		this.song = null;
 	}
diff --git a/src/core/object/pipeline/regex-edits.ts b/src/core/object/pipeline/regex-edits.ts
new file mode 100644
index 00000000..2257ed41
--- /dev/null
+++ b/src/core/object/pipeline/regex-edits.ts
@@ -0,0 +1,18 @@
+import Song from '@/core/object/song';
+import RegexEdits from '@/core/storage/regex-edits';
+
+/**
+ * Fill song info by user defined regex edits.
+ * @param song - Song instance
+ */
+export async function process(song: Song): Promise<void> {
+	// If song info was already corrected by user, skip this stage
+	if (song.flags.isCorrectedByUser) {
+		return;
+	}
+	try {
+		await RegexEdits.loadSongInfo(song);
+	} catch (e) {
+		// Do nothing
+	}
+}
diff --git a/src/core/object/song.ts b/src/core/object/song.ts
index 7fa9d1b6..14137b0c 100644
--- a/src/core/object/song.ts
+++ b/src/core/object/song.ts
@@ -22,6 +22,13 @@ export type Flags =
 	| {
 			isScrobbled: boolean;
 			isCorrectedByUser: boolean;
+			isRegexEditedByUser: {
+				track: boolean;
+				artist: boolean;
+				album: boolean;
+				albumArtist: boolean;
+			};
+			isAlbumFetched: boolean;
 			isValid: boolean;
 			isMarkedAsPlaying: boolean;
 			isSkipped: boolean;
@@ -48,6 +55,7 @@ export type Metadata =
 export interface CloneableSong {
 	parsed: ParsedSongData;
 	processed: ProcessedSongData;
+	noRegex: ProcessedSongData;
 	flags: Flags;
 	metadata: Metadata;
 	connectorLabel: string;
@@ -56,6 +64,7 @@ export interface CloneableSong {
 export abstract class BaseSong {
 	public abstract parsed: ParsedSongData;
 	public abstract processed: ProcessedSongData;
+	public abstract noRegex: ProcessedSongData;
 	public abstract flags: Flags;
 	public abstract metadata: Metadata;
 	public abstract connectorLabel: string;
@@ -211,6 +220,7 @@ export abstract class BaseSong {
 	getCloneableData(): CloneableSong {
 		return {
 			parsed: this.parsed,
+			noRegex: this.noRegex,
 			processed: this.processed,
 			metadata: this.metadata,
 			flags: this.flags,
@@ -266,6 +276,19 @@ export abstract class BaseSong {
 	static get BASE_FIELDS(): ['artist', 'track', 'album', 'albumArtist'] {
 		return ['artist', 'track', 'album', 'albumArtist'];
 	}
+
+	/**
+	 * Fields in a processed song.
+	 */
+	static get PROCESSED_FIELDS(): [
+		'track',
+		'album',
+		'artist',
+		'albumArtist',
+		'duration'
+	] {
+		return ['track', 'album', 'artist', 'albumArtist', 'duration'];
+	}
 }
 
 /**
@@ -274,6 +297,7 @@ export abstract class BaseSong {
 export default class Song extends BaseSong {
 	public parsed: ParsedSongData;
 	public processed: ProcessedSongData;
+	public noRegex: ProcessedSongData;
 	public flags: Flags;
 	public metadata: Metadata;
 	public connectorLabel: string;
@@ -311,6 +335,20 @@ export default class Song extends BaseSong {
 			duration: null,
 		};
 
+		/**
+		 * Post-processed song data, excluding regex-based changes.
+		 * Initially filled with parsed data and optionally changed
+		 * as the object is processed in pipeline. Should not be modified outside pipeline.
+		 * Used for regex edit preview for user convenience.
+		 */
+		this.noRegex = {
+			track: null,
+			artist: null,
+			albumArtist: null,
+			album: null,
+			duration: null,
+		};
+
 		/**
 		 * Song flags. Can be modified.
 		 */
@@ -361,6 +399,21 @@ export default class Song extends BaseSong {
 			 */
 			isCorrectedByUser: false,
 
+			/**
+			 * Flag indicating song info has been affected by a user regex/bulk edit
+			 */
+			isRegexEditedByUser: {
+				track: false,
+				artist: false,
+				album: false,
+				albumArtist: false,
+			},
+
+			/**
+			 * Flag indicating that the album of the current track was fetched from the Last.fm API
+			 */
+			isAlbumFetched: false,
+
 			/**
 			 * Flag indicated song is known by scrobbling service.
 			 */
@@ -400,10 +453,9 @@ export default class Song extends BaseSong {
 	}
 
 	private initProcessedData(): void {
-		const fields: ['track', 'album', 'artist', 'albumArtist', 'duration'] =
-			['track', 'album', 'artist', 'albumArtist', 'duration'];
-		for (const field of fields) {
+		for (const field of Song.PROCESSED_FIELDS) {
 			this.processed[field] = null;
+			this.noRegex[field] = null;
 		}
 	}
 }
diff --git a/src/core/storage/browser-storage.ts b/src/core/storage/browser-storage.ts
index 8be70cea..416b6092 100644
--- a/src/core/storage/browser-storage.ts
+++ b/src/core/storage/browser-storage.ts
@@ -78,6 +78,34 @@ export const STATE_MANAGEMENT = 'StateManagement';
  */
 export const DISABLED_TABS = 'DisabledTabs';
 
+/**
+ * This storage contains the regex/bulk edits saved by the user.
+ * The format of storage data is following:
+ * [
+ *     ...,
+ *     \{
+ *         search: \{
+ * 		       track: 'Track search regex or null',
+ * 		       artist: 'Artist search regex or null',
+ * 		       album: 'Album search regex or null',
+ * 		       albumArtist: 'Album Artist search regex or null',
+ * 	       \},
+ * 	       replace: \{
+ * 		       track: 'Track replace regex or null',
+ * 		       artist: 'Artist replace regex or null',
+ * 		       album: 'Album replace regex or null',
+ * 		       albumArtist: 'Album Artist replace regex or null',
+ * 	       \},
+ *     \},
+ *     ...
+ * ]
+ *
+ * All non-null search properties must be true for the edit to be applied.
+ * It will then apply all non-null replace properties where search is non-null.
+ * The latest regex edit to match will be applied, assuming no local cache edits exist.
+ */
+export const REGEX_EDITS = 'RegexEdits';
+
 /**
  * This storage contains the data saved and used by the extension core.
  * The format of storage data is following:
@@ -100,6 +128,7 @@ const storageTypeMap = {
 	Maloja: LOCAL,
 
 	[LOCAL_CACHE]: LOCAL,
+	[REGEX_EDITS]: LOCAL,
 	[CORE]: LOCAL,
 	[STATE_MANAGEMENT]: LOCAL,
 	[DISABLED_TABS]: LOCAL,
diff --git a/src/core/storage/regex-edits.model.ts b/src/core/storage/regex-edits.model.ts
new file mode 100644
index 00000000..134593cd
--- /dev/null
+++ b/src/core/storage/regex-edits.model.ts
@@ -0,0 +1,76 @@
+import { REGEX_EDITS } from './browser-storage';
+import { CustomStorage } from './custom-storage';
+import { DataModels } from './wrapper';
+import {
+	RegexEdit,
+	RegexFields,
+	editSong,
+	shouldApplyEdit,
+} from '@/util/regex';
+import Song from '../object/song';
+
+type K = typeof REGEX_EDITS;
+type V = DataModels[K];
+type T = Record<K, V>;
+
+export default abstract class RegexEditsModel extends CustomStorage<K> {
+	public abstract getRegexEditStorage(): Promise<RegexEdit[] | null>;
+	public abstract saveRegexEditToStorage(data: T[K]): Promise<void>;
+
+	/**
+	 * Apply regex edits to a given song object.
+	 *
+	 * @param song - Song instance
+	 */
+	async loadSongInfo(song: Song): Promise<void> {
+		const storageData = await this.getRegexEditStorage();
+
+		if (!storageData) {
+			return;
+		}
+
+		for (let i = storageData.length - 1; i >= 0; i--) {
+			if (!shouldApplyEdit(storageData[i], song)) {
+				continue;
+			}
+			editSong(storageData[i], song);
+		}
+	}
+
+	/**
+	 * Save custom regex edit to the storage.
+	 *
+	 * @param search - Search to save
+	 * @param replace - Replace to save
+	 */
+	async saveRegexEdit(
+		search: RegexFields,
+		replace: RegexFields
+	): Promise<void> {
+		const storageData = await this.getRegexEditStorage();
+		if (storageData === null) {
+			await this.saveRegexEditToStorage([{ search, replace }]);
+			return;
+		}
+
+		const newStorageData = [
+			...storageData,
+			{
+				search,
+				replace,
+			},
+		];
+
+		await this.saveRegexEditToStorage(newStorageData);
+	}
+
+	async deleteRegexEdit(index: number): Promise<void> {
+		const storageData = await this.getRegexEditStorage();
+		if (storageData === null) {
+			return;
+		}
+		storageData.splice(index, 1);
+
+		await this.saveRegexEditToStorage(storageData);
+	}
+}
diff --git a/src/core/storage/regex-edits.ts b/src/core/storage/regex-edits.ts
new file mode 100644
index 00000000..a9de4047
--- /dev/null
+++ b/src/core/storage/regex-edits.ts
@@ -0,0 +1,38 @@
+import { REGEX_EDITS } from './browser-storage';
+import StorageWrapper, { DataModels } from './wrapper';
+import * as BrowserStorage from './browser-storage';
+import { RegexEdit } from '@/util/regex';
+import RegexEditsModel from './regex-edits.model';
+
+type K = typeof REGEX_EDITS;
+type V = DataModels[K];
+type T = Record<K, V>;
+
+class RegexEditsImpl extends RegexEditsModel {
+	private regexEditStorage = this.getStorage();
+
+	constructor() {
+		super(BrowserStorage.getLocalStorage(BrowserStorage.REGEX_EDITS));
+
+		/* @ifdef DEBUG */
+		void this.regexEditStorage.debugLog();
+		/* @endif */
+	}
+
+	/** @override */
+	async getRegexEditStorage(): Promise<RegexEdit[] | null> {
+		return this.regexEditStorage.get();
+	}
+
+	/** @override */
+	async saveRegexEditToStorage(data: T[K]): Promise<void> {
+		return await this.regexEditStorage.set(data);
+	}
+
+	/** @override */
+	getStorage(): StorageWrapper<K> {
+		return BrowserStorage.getStorage(BrowserStorage.REGEX_EDITS);
+	}
+}
+
+export default new RegexEditsImpl();
diff --git a/src/core/storage/wrapper.ts b/src/core/storage/wrapper.ts
index b8e11977..d164ba6a 100644
--- a/src/core/storage/wrapper.ts
+++ b/src/core/storage/wrapper.ts
@@ -9,6 +9,7 @@ import {
 	LOCAL_CACHE,
 	NOTIFICATIONS,
 	OPTIONS,
+	REGEX_EDITS,
 	STATE_MANAGEMENT,
 	StorageNamespace,
 } from '@/core/storage/browser-storage';
@@ -22,6 +23,7 @@ import { ControllerModeStr } from '@/core/object/controller/controller';
 import { CloneableSong } from '@/core/object/song';
 import EventEmitter from '@/util/emitter';
 import connectors from '@/core/connectors';
+import { RegexEdit } from '@/util/regex';
 
 export interface CustomPatterns {
 	[connectorId: string]: string[];
@@ -76,6 +78,7 @@ export interface DataModels extends ScrobblerModels {
 	/* local options */
 	[CORE]: { appVersion: string };
 	[LOCAL_CACHE]: { [key: string]: SavedEdit };
+	[REGEX_EDITS]: RegexEdit[];
 
 	/* state management */
 	[STATE_MANAGEMENT]: StateManagement;
@@ -227,9 +230,6 @@ export default class StorageWrapper<K extends keyof DataModels> {
 			if (hiddenKeys.includes(key)) {
 				return hideObjectValue(value);
 			}
-			if (typeof value !== 'string' && typeof value !== 'number') {
-				return 'expected a number or a string';
-			}
 
 			return value;
 		};
diff --git a/src/ui/options/components/components.module.scss b/src/ui/options/components/components.module.scss
index b9a642fc..cfcd35e6 100644
--- a/src/ui/options/components/components.module.scss
+++ b/src/ui/options/components/components.module.scss
@@ -401,3 +401,79 @@ summary {
 		padding: 1em 15vw;
 	}
 }
+
+.attentionWindow {
+	background-color: var(--sidebar-bg);
+	color: var(--text);
+	font-size: 1.2em;
+	line-height: 1.25;
+	margin: 0 auto;
+	padding: 1em;
+	text-align: center;
+	width: 80%;
+}
+
+.regexDeleteContent {
+	align-items: center;
+	background: repeating-linear-gradient(var(--last-fm-bg) 0 2.5em,transparent 2.5em 5em);
+	display: grid;
+	gap: 0.5em 2em;
+	grid-template: '. search replace' 2em
+		'track tracksearch trackreplace' 2em
+		'artist artistsearch artistreplace' 2em
+		'album albumsearch albumreplace' 2em
+		'albumartist albumartistsearch albumartistreplace' 2em / auto 1fr 1fr;
+	margin: 0.75em 0;
+	padding: 0.25em 1em;
+	width: 100%;
+
+	/**
+	 * Set grid areas
+	 */
+	.regexDeleteSearchLabel {
+		grid-area: search;
+	}
+	.regexDeleteReplaceLabel {
+		grid-area: replace;
+	}
+
+	.regexDeleteTrackLabel {
+		grid-area: track;
+	}
+	.regexDeleteTrackSearch {
+		grid-area: tracksearch;
+	}
+	.regexDeleteTrackReplace {
+		grid-area: trackreplace;
+	}
+
+	.regexDeleteArtistLabel {
+		grid-area: artist;
+	}
+	.regexDeleteArtistSearch {
+		grid-area: artistsearch;
+	}
+	.regexDeleteArtistReplace {
+		grid-area: artistreplace;
+	}
+
+	.regexDeleteAlbumLabel {
+		grid-area: album;
+	}
+	.regexDeleteAlbumSearch {
+		grid-area: albumsearch;
+	}
+	.regexDeleteAlbumReplace {
+		grid-area: albumreplace;
+	}
+
+	.regexDeleteAlbumArtistLabel {
+		grid-area: albumartist;
+	}
+	.regexDeleteAlbumArtistSearch {
+		grid-area: albumartistsearch;
+	}
+	.regexDeleteAlbumArtistReplace {
+		grid-area: albumartistreplace;
+	}
+}
diff --git a/src/ui/options/components/options/options.tsx b/src/ui/options/components/options/options.tsx
index 292adf74..9a2b9b31 100644
--- a/src/ui/options/components/options/options.tsx
+++ b/src/ui/options/components/options/options.tsx
@@ -6,6 +6,7 @@ import ConnectorOptionsList from './connector-options';
 import ScrobbleBehavior from './scrobble-behavior';
 import EditedTracks from './edited-tracks';
 import ConnectorOverrideOptions from './connector-override';
+import RegexEdits from './regex-edits';
 
 const globalOptions = BrowserStorage.getStorage(BrowserStorage.OPTIONS);
 
@@ -30,6 +31,10 @@ export default function OptionsComponent(props: {
 				setActiveModal={props.setActiveModal}
 				modal={props.modal}
 			/>
+			<RegexEdits
+				setActiveModal={props.setActiveModal}
+				modal={props.modal}
+			/>
 			<ConnectorOverrideOptions />
 		</>
 	);
diff --git a/src/ui/options/components/options/regex-edits.tsx b/src/ui/options/components/options/regex-edits.tsx
new file mode 100644
index 00000000..8393fa6b
--- /dev/null
+++ b/src/ui/options/components/options/regex-edits.tsx
@@ -0,0 +1,224 @@
+import { t } from '@/util/i18n';
+import { For, Setter, createResource } from 'solid-js';
+import * as BrowserStorage from '@/core/storage/browser-storage';
+import styles from '../components.module.scss';
+import Download from '@suid/icons-material/DownloadOutlined';
+import Upload from '@suid/icons-material/UploadOutlined';
+import Visibility from '@suid/icons-material/VisibilityOutlined';
+import Delete from '@suid/icons-material/DeleteOutlined';
+import { FieldType, RegexEdit, pascalCaseField } from '@/util/regex';
+
+const regexEdits = BrowserStorage.getStorage(BrowserStorage.REGEX_EDITS);
+
+/**
+ * Component that allows the user to see, import, and export track metadata edits.
+ */
+export default function RegexEdits(props: {
+	setActiveModal: Setter<string>;
+	modal: HTMLDialogElement | undefined;
+}) {
+	return (
+		<>
+			<h2>{t('optionsRegexEdits')}</h2>
+			<p>{t('optionsRegexEditsDesc')}</p>
+			<div class={styles.buttonContainer}>
+				<ViewEdits
+					setActiveModal={props.setActiveModal}
+					modal={props.modal}
+				/>
+				<ExportEdits />
+				<ImportEdits />
+			</div>
+		</>
+	);
+}
+
+const [edits, { mutate }] = createResource(regexEdits.get.bind(regexEdits));
+
+/**
+ * Component that shows all the currently registered track edits and allows the user to delete them.
+ * To be displayed in a modal.
+ */
+export function RegexEditsModal() {
+	return (
+		<>
+			<h1>
+				{t(
+					'optionsRegexEditsPopupTitle',
+					(edits() ?? []).length.toString()
+				)}
+			</h1>
+			<ul>
+				<For each={[...(edits() ?? []).entries()]}>
+					{([index, edit]) => (
+						<EditInfo index={index} edit={edit} mutate={mutate} />
+					)}
+				</For>
+			</ul>
+		</>
+	);
+}
+
+/**
+ * Component that shows a single regex edit and allows the user to delete it.
+ */
+function EditInfo(props: {
+	index: number;
+	edit: RegexEdit;
+	mutate: Setter<RegexEdit[] | null | undefined>;
+}) {
+	return (
+		<li class={styles.deleteListing}>
+			<button
+				class={styles.deleteEditButton}
+				onClick={() => {
+					props.mutate((e) => {
+						if (!e) return e;
+						const o = e.filter((_, i) => i !== props.index);
+						regexEdits.set(o);
+						return o;
+					});
+				}}
+			>
+				<Delete />
+			</button>
+			<div class={styles.regexDeleteContent}>
+				<span class={styles.regexDeleteSearchLabel}>
+					{t('infoSearchLabel')}
+				</span>
+				<span class={styles.regexDeleteReplaceLabel}>
+					{t('infoReplaceLabel')}
+				</span>
+				<Entry edit={props.edit} type={'track'} />
+				<Entry edit={props.edit} type={'artist'} />
+				<Entry edit={props.edit} type={'album'} />
+				<Entry edit={props.edit} type={'albumArtist'} />
+			</div>
+		</li>
+	);
+}
+
+/**
+ * Component that returns the grid entries for a single field of regex search/replace
+ */
+function Entry(props: { edit: RegexEdit; type: FieldType }) {
+	return (
+		<>
+			<span
+				class={styles[`regexDelete${pascalCaseField(props.type)}Label`]}
+			>
+				{t(`info${pascalCaseField(props.type)}Label`)}
+			</span>
+			<span
+				class={
+					styles[`regexDelete${pascalCaseField(props.type)}Search`]
+				}
+			>
+				{props.edit.search[props.type]}
+			</span>
+			<span
+				class={
+					styles[`regexDelete${pascalCaseField(props.type)}Replace`]
+				}
+			>
+				{props.edit.replace[props.type]}
+			</span>
+		</>
+	);
+}
+
+/**
+ * Button that allows the user to open the modal that shows them their track edits
+ */
+function ViewEdits(props: {
+	setActiveModal: Setter<string>;
+	modal: HTMLDialogElement | undefined;
+}) {
+	return (
+		<button
+			class={styles.editButton}
+			onClick={(e) => {
+				e.stopImmediatePropagation();
+				props.setActiveModal('regexEdits');
+				props.modal?.showModal();
+			}}
+		>
+			<Visibility />
+			{t('optionsViewEdited')}
+		</button>
+	);
+}
+
+/**
+ * Button that exports edits for the user
+ */
+function ExportEdits() {
+	return (
+		<button class={styles.editButton} onClick={downloadEdits}>
+			<Upload />
+			{t('optionsExportEdited')}
+		</button>
+	);
+}
+
+/**
+ * Compiles all the users track edits from storage and downloads them
+ */
+async function downloadEdits() {
+	const edits = await regexEdits.get();
+	if (!edits) return;
+	const blob = new Blob([JSON.stringify(edits)], {
+		type: 'application/json',
+	});
+	const url = URL.createObjectURL(blob);
+	const a = document.createElement('a');
+	a.href = url;
+	a.download = 'local-cache.json';
+	a.click();
+}
+
+/**
+ * Button that allows the user to upload a .json file and get edits from it.
+ */
+function ImportEdits() {
+	return (
+		<button
+			class={styles.editButton}
+			onClick={() =>
+				(
+					document.querySelector('#import-edits') as HTMLInputElement
+				)?.click()
+			}
+		>
+			<Download />
+			{t('optionsImportEdited')}
+			<input
+				hidden={true}
+				type="file"
+				accept=".json"
+				id="import-edits"
+				onChange={pushEdits}
+			/>
+		</button>
+	);
+}
+
+/**
+ * Reads an imported .json file and import the edits to storage
+ */
+function pushEdits(
+	e: Event & {
+		currentTarget: HTMLInputElement;
+		target: Element;
+	}
+) {
+	const file = e.currentTarget.files?.[0];
+	if (!file) return;
+	const reader = new FileReader();
+	reader.addEventListener('load', async (e) => {
+		const edits = JSON.parse(e.target?.result as string);
+		const oldEdits = await regexEdits.get();
+		regexEdits.set([...(oldEdits ?? []), ...edits]);
+	});
+	reader.readAsText(file);
+}
diff --git a/src/ui/options/components/showSomeLove.tsx b/src/ui/options/components/showSomeLove.tsx
index ce303963..046420e4 100644
--- a/src/ui/options/components/showSomeLove.tsx
+++ b/src/ui/options/components/showSomeLove.tsx
@@ -9,6 +9,9 @@ export default function ShowSomeLove() {
 	return (
 		<>
 			<h1>{t('showSomeLoveTitle')}</h1>
+			<div class={styles.attentionWindow}>
+				<strong>{t('showSomeLoveFundingCampaign')}</strong>
+			</div>
 			<p>{t('showSomeLoveText1')}</p>
 			<p>{t('showSomeLoveText2')}</p>
 			<a
diff --git a/src/ui/options/main.tsx b/src/ui/options/main.tsx
index f955b618..09338230 100644
--- a/src/ui/options/main.tsx
+++ b/src/ui/options/main.tsx
@@ -19,6 +19,7 @@ import OptionsComponent from './components/options/options';
 import Accounts from './components/accounts';
 import { EditsModal } from './components/options/edited-tracks';
 import Permissions from './components/permissions';
+import { RegexEditsModal } from './components/options/regex-edits';
 
 /**
  * All the different options pages, their sidebar labels, and icons.
@@ -117,6 +118,9 @@ function Options() {
 						<Match when={activeModal() === 'savedEdits'}>
 							<EditsModal />
 						</Match>
+						<Match when={activeModal() === 'regexEdits'}>
+							<RegexEditsModal />
+						</Match>
 					</Switch>
 				</div>
 				<button
diff --git a/src/ui/popup/edit.tsx b/src/ui/popup/edit.tsx
index d862159b..128aa40f 100644
--- a/src/ui/popup/edit.tsx
+++ b/src/ui/popup/edit.tsx
@@ -1,13 +1,22 @@
 import { t } from '@/util/i18n';
 import styles from './popup.module.scss';
-import { Resource, createSignal, onCleanup, onMount } from 'solid-js';
+import {
+	Match,
+	Resource,
+	Switch,
+	createSignal,
+	onCleanup,
+	onMount,
+} from 'solid-js';
 import { ManagerTab } from '@/core/storage/wrapper';
 import browser from 'webextension-polyfill';
 import ClonedSong from '@/core/object/cloned-song';
 import Check from '@suid/icons-material/CheckOutlined';
+import Code from '@suid/icons-material/CodeOutlined';
 import PublishedWithChanges from '@suid/icons-material/PublishedWithChangesOutlined';
 import { sendBackgroundMessage } from '@/util/communication';
 import savedEdits from '@/core/storage/saved-edits';
+import Regex from './regex';
 
 /**
  * Component that allows the user to edit the currently playing track
@@ -27,6 +36,7 @@ export default function Edit(props: { tab: Resource<ManagerTab> }) {
 	const [albumArtist, setAlbumArtist] = createSignal(
 		clonedSong.getAlbumArtist() ?? ''
 	);
+	const [isRegex, setIsRegex] = createSignal(false);
 
 	sendBackgroundMessage(tab()?.tabId ?? -1, {
 		type: 'setEditState',
@@ -65,106 +75,122 @@ export default function Edit(props: { tab: Resource<ManagerTab> }) {
 
 	return (
 		<div class={styles.nowPlayingPopup} ref={nowplaying}>
-			<a
-				class={styles.coverArtWrapper}
-				href={
-					clonedSong.getTrackArt() ??
-					browser.runtime.getURL('img/cover_art_default.png')
-				}
-				target="_blank"
-				rel="noopener noreferrer"
-				title={t('infoOpenAlbumArt')}
-			>
-				<img
-					class={styles.coverArt}
-					src={
-						clonedSong.getTrackArt() ??
-						browser.runtime.getURL('img/cover_art_default.png')
-					}
-				/>
-			</a>
-			<div class={styles.songDetails}>
-				<input
-					class={styles.editField}
-					type="text"
-					value={clonedSong.getTrack() ?? ''}
-					title={t('infoTrackPlaceholder')}
-					placeholder={t('infoTrackPlaceholder')}
-					onInput={(e) => {
-						setTrack(e.currentTarget.value);
-					}}
-				/>
-				<input
-					class={styles.editField}
-					type="text"
-					value={clonedSong.getArtist() ?? ''}
-					title={t('infoArtistPlaceholder')}
-					placeholder={t('infoArtistPlaceholder')}
-					onInput={(e) => {
-						setArtist(e.currentTarget.value);
-					}}
-				/>
-				<input
-					class={styles.editField}
-					type="text"
-					value={clonedSong.getAlbum() ?? ''}
-					title={t('infoAlbumPlaceholder')}
-					placeholder={t('infoAlbumPlaceholder')}
-					onInput={(e) => {
-						setAlbum(e.currentTarget.value);
-					}}
-				/>
-				<input
-					class={styles.editField}
-					type="text"
-					value={clonedSong.getAlbumArtist() ?? ''}
-					title={t('infoAlbumArtistPlaceholder')}
-					placeholder={t('infoAlbumArtistPlaceholder')}
-					onInput={(e) => {
-						setAlbumArtist(e.currentTarget.value);
-					}}
-				/>
-				<div class={styles.controlButtons}>
-					<button
-						class={styles.controlButton}
-						disabled={!track() || !artist()}
-						title={
-							!track() || !artist()
-								? t('infoSubmitUnableTitle')
-								: t('infoSubmitTitle')
+			<Switch>
+				<Match when={isRegex()}>
+					<Regex clonedSong={clonedSong} tab={tab} />
+				</Match>
+				<Match when={!isRegex()}>
+					<a
+						class={styles.coverArtWrapper}
+						href={
+							clonedSong.getTrackArt() ??
+							browser.runtime.getURL('img/cover_art_default.png')
 						}
-						onClick={() => {
-							saveEdit(tab, clonedSong, {
-								artist: artist(),
-								track: track(),
-								album: album() || null,
-								albumArtist: albumArtist() || null,
-							});
-						}}
+						target="_blank"
+						rel="noopener noreferrer"
+						title={t('infoOpenAlbumArt')}
 					>
-						<Check />
-					</button>
-					<button
-						class={styles.controlButton}
-						disabled={!track() || !artist()}
-						title={
-							!track() || !artist()
-								? t('infoSwapUnableTitle')
-								: t('infoSwapTitle')
-						}
-						onClick={() => {
-							saveEdit(tab, clonedSong, {
-								artist: track(),
-								track: artist(),
-								album: album() || null,
-								albumArtist: albumArtist() || null,
-							});
-						}}
-					>
-						<PublishedWithChanges />
-					</button>
-				</div>
-			</div>
+						<img
+							class={styles.coverArt}
+							src={
+								clonedSong.getTrackArt() ??
+								browser.runtime.getURL(
+									'img/cover_art_default.png'
+								)
+							}
+						/>
+					</a>
+					<div class={styles.songDetails}>
+						<input
+							class={styles.editField}
+							type="text"
+							value={clonedSong.getTrack() ?? ''}
+							title={t('infoTrackPlaceholder')}
+							placeholder={t('infoTrackPlaceholder')}
+							onInput={(e) => {
+								setTrack(e.currentTarget.value);
+							}}
+						/>
+						<input
+							class={styles.editField}
+							type="text"
+							value={clonedSong.getArtist() ?? ''}
+							title={t('infoArtistPlaceholder')}
+							placeholder={t('infoArtistPlaceholder')}
+							onInput={(e) => {
+								setArtist(e.currentTarget.value);
+							}}
+						/>
+						<input
+							class={styles.editField}
+							type="text"
+							value={clonedSong.getAlbum() ?? ''}
+							title={t('infoAlbumPlaceholder')}
+							placeholder={t('infoAlbumPlaceholder')}
+							onInput={(e) => {
+								setAlbum(e.currentTarget.value);
+							}}
+						/>
+						<input
+							class={styles.editField}
+							type="text"
+							value={clonedSong.getAlbumArtist() ?? ''}
+							title={t('infoAlbumArtistPlaceholder')}
+							placeholder={t('infoAlbumArtistPlaceholder')}
+							onInput={(e) => {
+								setAlbumArtist(e.currentTarget.value);
+							}}
+						/>
+						<div class={styles.controlButtons}>
+							<button
+								class={styles.controlButton}
+								disabled={!track() || !artist()}
+								title={
+									!track() || !artist()
+										? t('infoSubmitUnableTitle')
+										: t('infoSubmitTitle')
+								}
+								onClick={() => {
+									saveEdit(tab, clonedSong, {
+										artist: artist(),
+										track: track(),
+										album: album() || null,
+										albumArtist: albumArtist() || null,
+									});
+								}}
+							>
+								<Check />
+							</button>
+							<button
+								class={styles.controlButton}
+								disabled={!track() || !artist()}
+								title={
+									!track() || !artist()
+										? t('infoSwapUnableTitle')
+										: t('infoSwapTitle')
+								}
+								onClick={() => {
+									saveEdit(tab, clonedSong, {
+										artist: track(),
+										track: artist(),
+										album: album() || null,
+										albumArtist: albumArtist() || null,
+									});
+								}}
+							>
+								<PublishedWithChanges />
+							</button>
+							<button
+								class={styles.controlButton}
+								title={t('infoRegexTitle')}
+								onClick={() => setIsRegex(true)}
+							>
+								<Code />
+							</button>
+						</div>
+					</div>
+				</Match>
+			</Switch>
 		</div>
 	);
 }
diff --git a/src/ui/popup/popup.module.scss b/src/ui/popup/popup.module.scss
index e95e38df..b59810a8 100644
--- a/src/ui/popup/popup.module.scss
+++ b/src/ui/popup/popup.module.scss
@@ -147,3 +147,126 @@ a {
 		color: var(--last-fm);
 	}
 }
+
+.regexContainer {
+	align-items: center;
+	box-sizing: border-box;
+	display: grid;
+	gap: 0.5em 1em;
+	grid-template: '. search replace preview' 2em
+		'track tracksearch trackreplace trackpreview' 2em
+		'artist artistsearch artistreplace artistpreview' 2em
+		'album albumsearch albumreplace albumpreview' 2em
+		'albumartist albumartistsearch albumartistreplace albumartistpreview' 2em
+		'footer footer footer footer' auto / auto 1fr 1fr 1fr;
+	padding: 1em;
+	width: 800px;
+
+	.entryLabel {
+		justify-self: right;
+	}
+
+	.searchWrapper {
+		align-self: center;
+		position: relative;
+
+		.searchField {
+			box-sizing: border-box;
+			width: 100%;
+		}
+
+		.regexTest {
+			position: absolute;
+			right: 0.1em;
+			top: 50%;
+			transform: translateY(-50%);
+
+			&.regexSuccess {
+				color: var(--confirm);
+			}
+
+			&.regexFailure {
+				color: var(--last-fm);
+			}
+		}
+	}
+
+	.editWarning {
+		color: var(--last-fm);
+	}
+
+	input {
+		padding: 0.25em 0.5em;
+	}
+
+	.regexFooter {
+		display: flex;
+		flex-direction: column;
+		grid-area: footer;
+	}
+
+	/**
+	 * assign grid areas
+	 */
+
+	.searchLabel {
+		grid-area: search;
+	}
+	.replaceLabel {
+		grid-area: replace;
+	}
+	.previewLabel {
+		grid-area: preview;
+	}
+	.trackLabel {
+		grid-area: track;
+	}
+	.artistLabel {
+		grid-area: artist;
+	}
+	.albumLabel {
+		grid-area: album;
+	}
+	.albumArtistLabel {
+		grid-area: albumartist;
+	}
+
+	.trackSearch {
+		grid-area: tracksearch;
+	}
+	.artistSearch {
+		grid-area: artistsearch;
+	}
+	.albumSearch {
+		grid-area: albumsearch;
+	}
+	.albumArtistSearch {
+		grid-area: albumartistsearch;
+	}
+
+	.trackReplace {
+		grid-area: trackreplace;
+	}
+	.artistReplace {
+		grid-area: artistreplace;
+	}
+	.albumReplace {
+		grid-area: albumreplace;
+	}
+	.albumArtistReplace {
+		grid-area: albumartistreplace;
+	}
+
+	.trackPreview {
+		grid-area: trackpreview;
+	}
+	.artistPreview {
+		grid-area: artistpreview;
+	}
+	.albumPreview {
+		grid-area: albumpreview;
+	}
+	.albumArtistPreview {
+		grid-area: albumartistpreview;
+	}
+}
diff --git a/src/ui/popup/regex.tsx b/src/ui/popup/regex.tsx
new file mode 100644
index 00000000..a92470e6
--- /dev/null
+++ b/src/ui/popup/regex.tsx
@@ -0,0 +1,188 @@
+import ClonedSong from '@/core/object/cloned-song';
+import styles from './popup.module.scss';
+import Check from '@suid/icons-material/CheckOutlined';
+import Close from '@suid/icons-material/CloseOutlined';
+
+import { t } from '@/util/i18n';
+import {
+	Match,
+	Resource,
+	Show,
+	Switch,
+	createEffect,
+	createSignal,
+} from 'solid-js';
+import {
+	EditedFields,
+	FieldType,
+	RegexFields,
+	getProcessedFieldsNoRegex,
+	getSongFieldNoRegex,
+	pascalCaseField,
+	replaceFields,
+	searchMatches,
+} from '@/util/regex';
+import { ManagerTab } from '@/core/storage/wrapper';
+import regexEdits from '@/core/storage/regex-edits';
+import { sendBackgroundMessage } from '@/util/communication';
+
+const [searches, setSearches] = createSignal({
+	track: null,
+	artist: null,
+	album: null,
+	albumArtist: null,
+} as RegexFields);
+const [replaces, setReplaces] = createSignal({
+	track: null,
+	artist: null,
+	album: null,
+	albumArtist: null,
+} as RegexFields);
+const [previews, setPreviews] = createSignal({
+	track: '',
+	artist: '',
+	album: '',
+	albumArtist: '',
+} as EditedFields);
+
+/**
+ * Regex editing popup
+ */
+export default function Regex(props: {
+	clonedSong: ClonedSong;
+	tab: Resource<ManagerTab>;
+}) {
+	return (
+		<div class={styles.regexContainer}>
+			<span class={styles.searchLabel}>{t('infoSearchLabel')}</span>
+			<span class={styles.replaceLabel}>{t('infoReplaceLabel')}</span>
+			<span class={styles.previewLabel}>{t('infoPreviewLabel')}</span>
+			<Entry clonedSong={props.clonedSong} type="track" />
+			<Entry clonedSong={props.clonedSong} type="artist" />
+			<Entry clonedSong={props.clonedSong} type="album" />
+			<Entry clonedSong={props.clonedSong} type="albumArtist" />
+			<Footer tab={props.tab} clonedSong={props.clonedSong} />
+		</div>
+	);
+}
+
+/**
+ * Label, inputs, and preview for a single song field.
+ */
+function Entry(props: { clonedSong: ClonedSong; type: FieldType }) {
+	const { clonedSong, type } = props;
+	setPreviews((prev) => ({
+		...prev,
+		[type]: getSongFieldNoRegex(clonedSong, type),
+	}));
+
+	createEffect(() => {
+		setPreviews(() =>
+			replaceFields(
+				searches(),
+				replaces(),
+				getProcessedFieldsNoRegex(clonedSong)
+			)
+		);
+	});
+
+	return (
+		<>
+			<span class={`${styles[`${type}Label`]} ${styles.entryLabel}`}>
+				{t(`info${pascalCaseField(type)}Label`)}
+			</span>
+			<SearchField type={type} clonedSong={clonedSong} />
+			<input
+				type="text"
+				class={styles[`${type}Replace`]}
+				onInput={(e) =>
+					setReplaces((prev) => ({
+						...prev,
+						[type]: e.currentTarget.value || null,
+					}))
+				}
+			/>
+			<span class={styles[`${type}Preview`]}>{previews()[type]}</span>
+		</>
+	);
+}
+
+/**
+ * A single search input for a single song property.
+ */
+function SearchField(props: { type: FieldType; clonedSong: ClonedSong }) {
+	const { type, clonedSong } = props;
+	return (
+		<div class={`${styles[`${type}Search`]} ${styles.searchWrapper}`}>
+			<input
+				type="text"
+				class={styles.searchField}
+				onInput={(e) =>
+					setSearches((prev) => ({
+						...prev,
+						[type]: e.currentTarget.value || null,
+					}))
+				}
+			/>
+			<Switch
+				fallback={
+					<Close
+						class={`${styles.regexTest} ${styles.regexFailure}`}
+					/>
+				}
+			>
+				<Match when={!searches()[type]}>
+					<></>
+				</Match>
+				<Match
+					when={searchMatches(
+						searches()[type],
+						getSongFieldNoRegex(clonedSong, type)
+					)}
+				>
+					<Check
+						class={`${styles.regexTest} ${styles.regexSuccess}`}
+					/>
+				</Match>
+			</Switch>
+		</div>
+	);
+}
+
+/**
+ * Footer of regex edit popup, allows the user to submit, and displays a warning if edit exists already.
+ */
+function Footer(props: { tab: Resource<ManagerTab>; clonedSong: ClonedSong }) {
+	return (
+		<div class={styles.regexFooter}>
+			<Show when={props.clonedSong.flags.isCorrectedByUser}>
+				<span class={styles.editWarning}>{t('infoEditedWarning')}</span>
+			</Show>
+			<div class={styles.controlButtons}>
+				<button
+					class={styles.controlButton}
+					title={t('infoSubmitTitle')}
+					onClick={() => {
+						saveEdit(props.tab);
+					}}
+				>
+					<Check />
+				</button>
+			</div>
+		</div>
+	);
+}
+
+/**
+ * Save edit to storage
+ * @param tab - tab data of the tab whose data is being edited
+ * @param clonedSong - cloned song from the tab
+ * @param data - the new song data to edit to
+ */
+async function saveEdit(tab: Resource<ManagerTab>) {
+	await regexEdits.saveRegexEdit(searches(), replaces());
+	sendBackgroundMessage(tab()?.tabId ?? -1, {
+		type: 'reprocessSong',
+		payload: undefined,
+	});
+}
diff --git a/src/util/regex.ts b/src/util/regex.ts
new file mode 100644
index 00000000..8f3544a1
--- /dev/null
+++ b/src/util/regex.ts
@@ -0,0 +1,285 @@
+import ClonedSong from '@/core/object/cloned-song';
+import Song, { BaseSong } from '@/core/object/song';
+import * as BrowserStorage from '@/core/storage/browser-storage';
+
+/**
+ * Editable fields.
+ */
+export type FieldType = 'track' | 'artist' | 'album' | 'albumArtist';
+
+/**
+ * Editable fields with pascal case for function calls and i18n
+ */
+export type PascalCaseFieldType = 'Track' | 'Artist' | 'Album' | 'AlbumArtist';
+
+/**
+ * Edit fields with nullability; used for storage. Null indicates the field should be ignored.
+ */
+export type RegexFields = {
+	[field in FieldType]: string | null;
+};
+
+/**
+ * Edit fields without nullability; used for previews.
+ */
+export type EditedFields = {
+	[field in FieldType]: string;
+};
+
+/**
+ * Regex edit object.
+ */
+export type RegexEdit = {
+	search: RegexFields;
+	replace: RegexFields;
+};
+
+/**
+ * Checks if a regex edit has already been applied to any of the fields this edit would apply to.
+ * This is used to prevent multiple edits from being applied to the same field.
+ *
+ * @param replace - Replace regex fields
+ * @param song - Song to check
+ * @returns True if an edit has already been applied to the fields this edit would apply to; false otherwise
+ */
+function alreadyApplied(replace: RegexFields, song: Song) {
+	for (const [_key, field] of Object.entries(replace)) {
+		const key = _key as FieldType;
+		if (field === null) {
+			continue;
+		}
+		if (song.flags.isRegexEditedByUser[key]) {
+			return true;
+		}
+	}
+	return false;
+}
+
+/**
+ * Checks a set of search fields for a regex edit and sees if the edit should be applied to the song.
+ *
+ * @param search - Search fields to check
+ * @param song - Song to check
+ * @returns True if the edit should be applied; false otherwise
+ */
+export function shouldApplyEdit(edit: RegexEdit, song: Song): boolean {
+	if (alreadyApplied(edit.replace, song)) {
+		return false;
+	}
+
+	for (const [_key, field] of Object.entries(edit.search)) {
+		const key = _key as FieldType;
+		const songField = getSongField(song, key);
+		if (!searchMatches(field, songField)) {
+			return false;
+		}
+	}
+	return true;
+}
+
+/**
+ * Checks a single search field from a regex edit and sees if the edit should be applied to a specific text.
+ *
+ * @param search - Search regex to check
+ * @param text - Text to check
+ * @returns True if the edit should be applied; false otherwise
+ */
+export function searchMatches(search: string | null, text: string): boolean {
+	if (search === null) {
+		return true;
+	}
+	try {
+		const regex = new RegExp(`^${search}$`);
+		return regex.test(text);
+	} catch (err) {
+		return false;
+	}
+}
+
+/**
+ * Replaces a single field based on a regex edit.
+ *
+ * @param search - Search regex
+ * @param replace - Replace regex
+ * @param text - Text to replace
+ * @returns Text with regex applied
+ */
+function replaceField(
+	search: string | null,
+	replace: string | null,
+	text: string
+): string {
+	if (search === null || replace === null) {
+		return text;
+	}
+	try {
+		const regex = new RegExp(`^${search}$`);
+		return text.replace(regex, replace);
+	} catch (err) {
+		return text;
+	}
+}
+
+/**
+ * Apply regex edit to all fields of a song.
+ *
+ * @param search - Search regex fields
+ * @param replace - Replace regex fields
+ * @param fields - Fields to apply regex to
+ * @returns Edited fields
+ */
+export function replaceFields(
+	search: RegexFields,
+	replace: RegexFields,
+	fields: EditedFields
+): EditedFields {
+	for (const _field of Object.keys(fields)) {
+		const field = _field as FieldType;
+		if (!searchMatches(search[field], fields[field])) {
+			return fields;
+		}
+	}
+	for (const _field of Object.keys(fields)) {
+		const field = _field as FieldType;
+		fields[field] = replaceField(
+			search[field],
+			replace[field],
+			fields[field]
+		);
+	}
+	return fields;
+}
+
+/**
+ * Gets processed fields from song.
+ *
+ * @param song - Song to get processed fields from
+ * @returns Processed fields
+ */
+export function getProcessedFields(song: BaseSong): EditedFields {
+	return {
+		track: getSongField(song, 'track'),
+		artist: getSongField(song, 'artist'),
+		album: getSongField(song, 'album'),
+		albumArtist: getSongField(song, 'albumArtist'),
+	};
+}
+
+/**
+ * Gets non-regex processed fields from song.
+ * This is used for previews.
+ *
+ * @param song - Song to get processed fields from
+ * @returns Processed fields without regex
+ */
+export function getProcessedFieldsNoRegex(song: BaseSong): EditedFields {
+	return {
+		track: getSongFieldNoRegex(song, 'track'),
+		artist: getSongFieldNoRegex(song, 'artist'),
+		album: getSongFieldNoRegex(song, 'album'),
+		albumArtist: getSongFieldNoRegex(song, 'albumArtist'),
+	};
+}
+
+/**
+ * Applies a regex edit to a song.
+ *
+ * @param edit - Regex edit to apply
+ * @param song - Song to apply regex to
+ */
+export function editSong(edit: RegexEdit, song: Song) {
+	const fields = replaceFields(
+		edit.search,
+		edit.replace,
+		getProcessedFields(song)
+	);
+
+	for (const [_key, field] of Object.entries(fields)) {
+		const key = _key as FieldType;
+		if (field !== getSongField(song, key)) {
+			song.flags.isRegexEditedByUser[key] = true;
+			song.processed[key] = field;
+		}
+	}
+}
+
+/**
+ * Converts a field to pascal case.
+ *
+ * @param text - Field to convert
+ * @returns Field in pascal case
+ */
+export function pascalCaseField(text: FieldType): PascalCaseFieldType {
+	return (text.charAt(0).toUpperCase() +
+		text.slice(1)) as PascalCaseFieldType;
+}
+
+/**
+ * Gets a field from a song.
+ *
+ * @param clonedSong - Song to get field from
+ * @param type - Field to get
+ * @returns Content of field from song
+ */
+export function getSongField(clonedSong: BaseSong, type: FieldType): string {
+	return clonedSong[`get${pascalCaseField(type)}`]() ?? '';
+}
+
+/**
+ * Gets a field from a song ignoring regex edits.
+ *
+ * @param clonedSong - Song to get field from
+ * @param type - Field to get
+ * @returns Content of field from song ignoring regex edits
+ */
+export function getSongFieldNoRegex(
+	clonedSong: BaseSong,
+	type: FieldType
+): string {
+	return (clonedSong.noRegex[type] || clonedSong.parsed[type]) ?? '';
+}
+
+/**
+ * Default regexes to be applied on first load
+ */
+const DEFAULT_REGEXES = [
+	{
+		search: {
+			track: null,
+			artist: null,
+			album: '(.*) - Single',
+			albumArtist: null,
+		},
+		replace: {
+			track: null,
+			artist: null,
+			album: '$1',
+			albumArtist: null,
+		},
+	},
+	{
+		search: {
+			track: null,
+			artist: null,
+			album: '(.*) - EP',
+			albumArtist: null,
+		},
+		replace: {
+			track: null,
+			artist: null,
+			album: '$1',
+			albumArtist: null,
+		},
+	},
+];
+
+/**
+ * Sets default regexes if none exist.
+ */
+export async function setRegexDefaults() {
+	const regexEdits = BrowserStorage.getStorage(BrowserStorage.REGEX_EDITS);
+	const regexes = await regexEdits.get();
+	if (!regexes) {
+		regexEdits.set(DEFAULT_REGEXES);
+	}
+}
diff --git a/src/util/util.ts b/src/util/util.ts
index 0c212cd2..21f76714 100644
--- a/src/util/util.ts
+++ b/src/util/util.ts
@@ -10,6 +10,7 @@ import {
 	Properties,
 	StateManagement,
 } from '@/core/storage/wrapper';
+import { RegexEdit } from './regex';
 
 /**
  * Module that contains some useful helper functions for background scripts.
@@ -112,6 +113,7 @@ export function hideObjectValue(
 		| Properties
 		| ListenBrainzModel
 		| StateManagement
+		| RegexEdit[]
 ): string {
 	if (!keyValue) {
 		if (keyValue === null) {


Test patch:
diff --git a/tests/background/regex.test.ts b/tests/background/regex.test.ts
new file mode 100644
index 00000000..2fbab5fc
--- /dev/null
+++ b/tests/background/regex.test.ts
@@ -0,0 +1,233 @@
+import WebextensionPolyfillMocker from '#/mocks/webextension-polyfill';
+import Pipeline from '@/core/object/pipeline/pipeline';
+import Song from '@/core/object/song';
+import { SavedEdit } from '@/core/storage/options';
+import regexEdits from '@/core/storage/regex-edits';
+import savedEdits from '@/core/storage/saved-edits';
+import { State } from '@/core/types';
+import {
+	RegexEdit,
+	getProcessedFields,
+	getProcessedFieldsNoRegex,
+} from '@/util/regex';
+import { getConnectorById } from '@/util/util-connector';
+import { randomBytes } from 'crypto';
+import { beforeEach, describe, expect, it } from 'vitest';
+
+const mocker = new WebextensionPolyfillMocker();
+const pipeline = new Pipeline();
+
+describe('Should edit Regex', () => {
+	beforeEach(() => {
+		mocker.reset();
+		mocker.setUser();
+	});
+
+	it('Should add edits to storage', async () => {
+		await regexEdits.saveRegexEdit(epRemover.search, epRemover.replace);
+		await regexEdits.saveRegexEdit(
+			singleRemover.search,
+			singleRemover.replace
+		);
+		expect(await regexEdits.getData()).to.deep.equal([
+			epRemover,
+			singleRemover,
+		]);
+	});
+
+	it('Should delete edits from storage', async () => {
+		await regexEdits.saveRegexEdit(epRemover.search, epRemover.replace);
+		await regexEdits.saveRegexEdit(
+			singleRemover.search,
+			singleRemover.replace
+		);
+		await regexEdits.saveRegexEdit(
+			fuminnikkiFixer.search,
+			fuminnikkiFixer.replace
+		);
+		await regexEdits.deleteRegexEdit(1);
+		expect(await regexEdits.getData()).to.deep.equal([
+			epRemover,
+			fuminnikkiFixer,
+		]);
+	});
+
+	it("Should apply edit to song's artist", async () => {
+		await regexEdits.saveRegexEdit(
+			fuminnikkiFixer.search,
+			fuminnikkiFixer.replace
+		);
+		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
+		await pipeline.process(song, youtubeConnector);
+		expect(getProcessedFields(song)).to.deep.equal(
+			processedFuminnikkiSongWithEP
+		);
+	});
+
+	it("Should apply edit to song's album", async () => {
+		await regexEdits.saveRegexEdit(epRemover.search, epRemover.replace);
+		const song = new Song(correctFuminnikkiSong, youtubeConnector);
+		await pipeline.process(song, youtubeConnector);
+		expect(getProcessedFields(song)).to.deep.equal(processedFuminnikkiSong);
+	});
+
+	it("Should apply edit to song's artist and album", async () => {
+		await regexEdits.saveRegexEdit(
+			fuminnikkiFixer.search,
+			fuminnikkiFixer.replace
+		);
+		await regexEdits.saveRegexEdit(epRemover.search, epRemover.replace);
+		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
+		await pipeline.process(song, youtubeConnector);
+		expect(getProcessedFields(song)).to.deep.equal(processedFuminnikkiSong);
+	});
+
+	it("Should apply only the most recent edit to song's artist only once", async () => {
+		await regexEdits.saveRegexEdit(
+			artistNumberSuffixer(1).search,
+			artistNumberSuffixer(1).replace
+		);
+		await regexEdits.saveRegexEdit(
+			artistNumberSuffixer(2).search,
+			artistNumberSuffixer(2).replace
+		);
+
+		const song = new Song(correctFuminnikkiSong, youtubeConnector);
+		await pipeline.process(song, youtubeConnector);
+		expect(song.getTrack()).to.equal('Re:start');
+		expect(song.getArtist()).to.equal('フミンニッキ2');
+		expect(song.getAlbum()).to.equal(null);
+		expect(song.getAlbumArtist()).to.equal(null);
+	});
+
+	it('Should apply edit to track that does not exist', async () => {
+		await regexEdits.saveRegexEdit(
+			artistNumberSuffixer(1).search,
+			artistNumberSuffixer(1).replace
+		);
+
+		const artist = randomBytes(32).toString('hex');
+		const track = randomBytes(32).toString('hex');
+		const song = new Song({ artist, track }, youtubeConnector);
+		await pipeline.process(song, youtubeConnector);
+		expect(song.getTrack()).to.equal(track);
+		expect(song.getArtist()).to.equal(`${artist}1`);
+		expect(song.getAlbum()).to.equal(null);
+		expect(song.getAlbumArtist()).to.equal(null);
+	});
+
+	it('Should have non-regex edits saved for preview', async () => {
+		await regexEdits.saveRegexEdit(
+			fuminnikkiFixer.search,
+			fuminnikkiFixer.replace
+		);
+		await regexEdits.saveRegexEdit(epRemover.search, epRemover.replace);
+
+		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
+		await pipeline.process(song, youtubeConnector);
+		expect(getProcessedFields(song)).to.deep.equal(processedFuminnikkiSong);
+		expect(getProcessedFieldsNoRegex(song)).to.deep.equal({
+			track: 'Re:start',
+			artist: 'Fuminnikki',
+			album: 'Re:start - EP',
+			albumArtist: '',
+		});
+	});
+
+	it('Should not apply regex edit if there is a song edit', async () => {
+		await regexEdits.saveRegexEdit(
+			artistNumberSuffixer(1).search,
+			artistNumberSuffixer(1).replace
+		);
+
+		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
+		savedEdits.saveSongInfo(song, processedFuminnikkiSong as SavedEdit);
+		await pipeline.process(song, youtubeConnector);
+
+		expect(getProcessedFields(song)).to.deep.equal(processedFuminnikkiSong);
+	});
+});
+
+const epRemover: RegexEdit = {
+	search: {
+		track: null,
+		artist: null,
+		album: '(.*) - EP',
+		albumArtist: null,
+	},
+	replace: {
+		track: null,
+		artist: null,
+		album: '$1',
+		albumArtist: null,
+	},
+};
+
+const singleRemover: RegexEdit = {
+	search: {
+		track: null,
+		artist: null,
+		album: '(.*) - Single',
+		albumArtist: null,
+	},
+	replace: {
+		track: null,
+		artist: null,
+		album: '$1',
+		albumArtist: null,
+	},
+};
+
+const fuminnikkiFixer: RegexEdit = {
+	search: {
+		track: null,
+		artist: 'Fuminnikki',
+		album: null,
+		albumArtist: null,
+	},
+	replace: {
+		track: null,
+		artist: 'フミンニッキ',
+		album: null,
+		albumArtist: null,
+	},
+};
+
+const artistNumberSuffixer = (suffix: number): RegexEdit => ({
+	search: {
+		track: null,
+		artist: '(.*)',
+		album: null,
+		albumArtist: null,
+	},
+	replace: {
+		track: null,
+		artist: `$1${suffix}`,
+		album: null,
+		albumArtist: null,
+	},
+});
+
+const wrongFuminnikkiSong: State = {
+	track: 'Re:start',
+	artist: 'Fuminnikki',
+};
+
+const correctFuminnikkiSong: State = {
+	track: 'Re:start',
+	artist: 'フミンニッキ',
+};
+
+const processedFuminnikkiSong: State = {
+	track: 'Re:start',
+	artist: 'フミンニッキ',
+	album: 'Re:start',
+	albumArtist: '',
+};
+
+const processedFuminnikkiSongWithEP: State = {
+	...processedFuminnikkiSong,
+	album: 'Re:start - EP',
+};
+
+const youtubeConnector = getConnectorById('youtube')!;
diff --git a/tests/mocks/webextension-polyfill.ts b/tests/mocks/webextension-polyfill.ts
new file mode 100644
index 00000000..9a9c874d
--- /dev/null
+++ b/tests/mocks/webextension-polyfill.ts
@@ -0,0 +1,59 @@
+import { vi } from 'vitest';
+
+/**
+ * StorageArea object stub.
+ */
+class StorageAreaStub {
+	private data: Record<string, unknown>;
+	constructor() {
+		this.data = {};
+	}
+
+	get() {
+		return this.data;
+	}
+
+	set(data: Record<string, unknown>) {
+		this.data = Object.assign(this.data, data);
+	}
+
+	remove(key: string) {
+		delete this.data[key];
+	}
+
+	clear() {
+		this.data = {};
+	}
+}
+
+/**
+ * Browser object stub.
+ */
+const browser = {
+	storage: {
+		local: new StorageAreaStub(),
+		sync: new StorageAreaStub(),
+	},
+};
+
+export default class WebextensionPolyfillMocker {
+	constructor() {
+		vi.mock('webextension-polyfill', () => ({
+			default: browser,
+		}));
+	}
+
+	public setUser(): void {
+		browser.storage.local.set({
+			LastFM: {
+				sessionID: 'no',
+				sessionName: 'Enyachann',
+			},
+		});
+	}
+
+	public reset(): void {
+		browser.storage.local.clear();
+		browser.storage.sync.clear();
+	}
+}
diff --git a/tests/stubs/webextension-polyfill.ts b/tests/stubs/webextension-polyfill.ts
deleted file mode 100644
index fafe0a0b..00000000
--- a/tests/stubs/webextension-polyfill.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-/**
- * StorageArea object stub.
- */
-export class StorageAreaStub {
-	private data: Record<string, unknown>;
-	constructor() {
-		this.data = {};
-	}
-
-	get() {
-		return this.data;
-	}
-
-	set(data: Record<string, unknown>) {
-		this.data = Object.assign(this.data, data);
-	}
-
-	remove(key: string) {
-		delete this.data[key];
-	}
-}
-
-/**
- * Browser object stub.
- */
-export const browser = {
-	storage: {
-		local: new StorageAreaStub(),
-		sync: new StorageAreaStub(),
-	},
-};
diff --git a/vitest.config.ts b/vitest.config.ts
index af986b74..b422c9bd 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -3,6 +3,7 @@ export default {
 	resolve: {
 		alias: {
 			'@': path.resolve(__dirname, './src'),
+			'#': path.resolve(__dirname, './tests'),
 		},
 	},
 };