Repository: web-scrobbler/web-scrobbler
Pull request #3689 (2023-05-02 08:15:06)

Fix Connector API handling of undefined/null values
Some functions in connector do not handle undefined/null values correctly.
This goes a long way to fixing that, and adjusts connectors accordingly.

I think once these three current PRs are merged, I want to do one last PR changing the sidebar organization in the options (I think there will be a lot of merge conflicts if this is done before current PRs are merged and I can't be bothered) and then I think we might be ready to release!


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..efc4f7d4 100644
--- a/src/_locales/en/messages.json
+++ b/src/_locales/en/messages.json
@@ -191,6 +191,10 @@
         "message": "Options",
         "description": "'Options' section"
     },
+    "optionsEdits": {
+        "message": "Edits",
+        "description": "'Edits' section"
+    },
     "optionsEditedTracks": {
         "message": "Edited tracks",
         "description": "'Edited tracks' header"
@@ -203,6 +207,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"
@@ -215,6 +231,10 @@
         "message": "Import",
         "description": "Button to import edited tracks"
     },
+    "optionsConnectors": {
+        "message": "Connectors",
+        "description": "'Connectors' section"
+    },
     "optionsEnableDisableHint": {
         "message": "Click on the checkbox to enable/disable connectors.",
         "description": "Enable/disable hint"
@@ -375,6 +395,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 +519,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 +575,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/connectors/1001tracklists.ts b/src/connectors/1001tracklists.ts
index 17c4f598..f6e88027 100644
--- a/src/connectors/1001tracklists.ts
+++ b/src/connectors/1001tracklists.ts
@@ -6,7 +6,7 @@ let currentTime: number;
 const mixDuration = Util.stringToSeconds(
 	Util.getTextFromSelectors('.mediaTabItm:not(.hidden) .active')
 		?.split('[')[1]
-		.split(']')[0] ?? ''
+		.split(']')[0]
 );
 
 Connector.playerSelector = '#playerWidgetFields';
diff --git a/src/connectors/163-music.ts b/src/connectors/163-music.ts
index 26a2fb65..ba19445b 100644
--- a/src/connectors/163-music.ts
+++ b/src/connectors/163-music.ts
@@ -6,12 +6,12 @@ Connector.playerSelector = '.m-playbar';
 
 Connector.getTrackArt = () => {
 	const src = Util.getAttrFromSelectors('.head.j-flag img', 'src');
-	return src?.split('?')[0] ?? '';
+	return src?.split('?')[0];
 };
 
 Connector.getTrack = () => {
-	const track = Util.getTextFromSelectors('.fc1') ?? '';
-	return track.replace(trackRegEx, ' ');
+	const track = Util.getTextFromSelectors('.fc1');
+	return track?.replace(trackRegEx, ' ');
 };
 
 Connector.getArtist = () => Util.getAttrFromSelectors('.by span', 'title');
@@ -22,5 +22,5 @@ Connector.timeInfoSelector = '.time';
 
 Connector.getUniqueID = () => {
 	const attr = Util.getAttrFromSelectors('.fc1', 'href');
-	return attr?.split('id=').at(-1) ?? '';
+	return attr?.split('id=').at(-1);
 };
diff --git a/src/connectors/181.fm.ts b/src/connectors/181.fm.ts
index 976cbf2c..1dbecedb 100644
--- a/src/connectors/181.fm.ts
+++ b/src/connectors/181.fm.ts
@@ -28,7 +28,7 @@ Connector.getAlbum = () => {
 Connector.trackArtSelector = '.songimg';
 
 Connector.isTrackArtDefault = (trackArtUrl) => {
-	return trackArtUrl?.endsWith(DEFAULT_TRACK_ART) ?? false;
+	return trackArtUrl?.endsWith(DEFAULT_TRACK_ART);
 };
 
 Connector.pauseButtonSelector = '#playbtn.jp-stopx';
diff --git a/src/connectors/amazingradio.ts b/src/connectors/amazingradio.ts
index 71dfd564..1560cf44 100644
--- a/src/connectors/amazingradio.ts
+++ b/src/connectors/amazingradio.ts
@@ -16,7 +16,7 @@ Connector.trackSelector = [
 
 Connector.trackArtSelector = '#player img.artwork';
 
-Connector.isTrackArtDefault = (url) => url?.includes('/defaults/') ?? false;
+Connector.isTrackArtDefault = (url) => url?.includes('/defaults/');
 
 Connector.isPlaying = () => {
 	const playerControlsIcon = Util.getAttrFromSelectors(
diff --git a/src/connectors/amazon-alexa.ts b/src/connectors/amazon-alexa.ts
index 8821034a..af648c05 100644
--- a/src/connectors/amazon-alexa.ts
+++ b/src/connectors/amazon-alexa.ts
@@ -20,7 +20,7 @@ Connector.getArtistTrack = () => {
 };
 
 Connector.isPlaying = () => {
-	const duration = Connector.getDuration() ?? 0;
+	const duration = Connector.getDuration();
 
 	/*
 	 * The app doesn't update the remaining and elapsed times straight away
@@ -28,7 +28,7 @@ Connector.isPlaying = () => {
 	 * down from an hour. This is a workaround to avoid detecting an incorrect
 	 * duration time.
 	 */
-	if (duration > 3600) {
+	if (!duration || duration > 3600) {
 		return false;
 	}
 
diff --git a/src/connectors/animebits.ts b/src/connectors/animebits.ts
index 06815b3a..8ddbee22 100644
--- a/src/connectors/animebits.ts
+++ b/src/connectors/animebits.ts
@@ -25,7 +25,7 @@ Connector.durationSelector = '#time-container .duration';
 
 Connector.currentTimeSelector = '#time-container .current-time';
 
-Connector.isTrackArtDefault = (url) => url?.includes('no-cover') ?? false;
+Connector.isTrackArtDefault = (url) => url?.includes('no-cover');
 
 Connector.isPlaying = () => Util.hasElementClass('#play-pause', 'playing');
 
diff --git a/src/connectors/apple-music.ts b/src/connectors/apple-music.ts
index 75c579cd..55348983 100644
--- a/src/connectors/apple-music.ts
+++ b/src/connectors/apple-music.ts
@@ -7,11 +7,11 @@ const queryShadowRoot = (selector: string) =>
 
 Connector.getArtist = () =>
 	queryShadowRoot('.lcd-meta__secondary .lcd-meta-line__fragment:first-child')
-		?.textContent ?? '';
+		?.textContent;
 
 Connector.getAlbum = () =>
 	queryShadowRoot('.lcd-meta__secondary .lcd-meta-line__fragment:last-child')
-		?.textContent ?? '';
+		?.textContent;
 
 Connector.getCurrentTime = () =>
 	parseInt(
@@ -25,7 +25,7 @@ Connector.getRemainingTime = () =>
 
 Connector.getTrack = () =>
 	queryShadowRoot('.lcd-meta__primary-wrapper .lcd-meta-line__fragment')
-		?.textContent ?? '';
+		?.textContent;
 
 Connector.isPlaying = () =>
 	Util.getAttrFromSelectors('.playback-play__pause', 'aria-hidden') !==
diff --git a/src/connectors/audacy.ts b/src/connectors/audacy.ts
index 6c55cf5c..24ea017b 100644
--- a/src/connectors/audacy.ts
+++ b/src/connectors/audacy.ts
@@ -37,11 +37,9 @@ Connector.getTrack = () => {
 	}
 
 	if (Util.isElementVisible(fullArtistTrackSelector)) {
-		return (
-			Util.getTextFromSelectors(fullArtistTrackSelector)?.split(
-				' - '
-			)[0] ?? ''
-		);
+		return Util.getTextFromSelectors(fullArtistTrackSelector)?.split(
+			' - '
+		)[0];
 	}
 
 	return null;
@@ -52,10 +50,11 @@ Connector.trackArtSelector = [
 	`${buttonCloseFullscreen} + div img`,
 ];
 
-Connector.isTrackArtDefault = (url) => url?.includes('base64') ?? false;
+Connector.isTrackArtDefault = (url) => url?.includes('base64');
 
 Connector.isPlaying = () => {
-	const buttonSvgTitle = 'button:not([aria-label=Like], [aria-label*=thumbs]) svg title';
+	const buttonSvgTitle =
+		'button:not([aria-label=Like], [aria-label*=thumbs]) svg title';
 	const buttonPlaying = Util.getTextFromSelectors([
 		`${audioPlayer} ${buttonSvgTitle}`,
 		`${buttonCloseFullscreen} + div ${buttonSvgTitle}`,
diff --git a/src/connectors/bandcamp.ts b/src/connectors/bandcamp.ts
index 821aea4c..3a695e32 100644
--- a/src/connectors/bandcamp.ts
+++ b/src/connectors/bandcamp.ts
@@ -72,7 +72,7 @@ function initGenericProperties() {
 			return Util.splitArtistTrack(track, SEPARATORS);
 		}
 		if (trackContainsRecordSide()) {
-			track = Util.removeRecordSide(track ?? '');
+			track = Util.removeRecordSide(track);
 		}
 
 		return { artist, track };
@@ -291,7 +291,7 @@ function isArtistVarious(artist: string | null, track: string | null) {
 	 */
 
 	if (VARIOUS_ARTISTS_REGEXP.test(artist ?? '')) {
-		return Util.findSeparator(track ?? '', SEPARATORS) !== null;
+		return Util.findSeparator(track, SEPARATORS) !== null;
 	}
 
 	return false;
diff --git a/src/connectors/beatport.ts b/src/connectors/beatport.ts
index e05d51b4..edc02fbe 100644
--- a/src/connectors/beatport.ts
+++ b/src/connectors/beatport.ts
@@ -22,7 +22,7 @@ Connector.getUniqueID = () => {
 		`${playerBar} .track-title a`,
 		'href'
 	);
-	return trackUrl?.split('/').at(-1) ?? '';
+	return trackUrl?.split('/').at(-1);
 };
 
 Connector.applyFilter(filter);
diff --git a/src/connectors/clyp.ts b/src/connectors/clyp.ts
index 4e894607..4e665817 100644
--- a/src/connectors/clyp.ts
+++ b/src/connectors/clyp.ts
@@ -29,7 +29,7 @@ function setupCardsPlayer() {
 		const container = [...cards].filter((element) =>
 			element.classList.contains('pause-button')
 		)[0];
-		return container.querySelector('span.name')?.textContent ?? null;
+		return container.querySelector('span.name')?.textContent;
 	};
 
 	Connector.getTrack = () => {
@@ -40,7 +40,7 @@ function setupCardsPlayer() {
 		const container = [...cards].filter((element) =>
 			element.classList.contains('pause-button')
 		)[0];
-		return container.querySelector('.card-title-text')?.textContent ?? null;
+		return container.querySelector('.card-title-text')?.textContent;
 	};
 
 	Connector.isPlaying = () =>
diff --git a/src/connectors/douban.fm.ts b/src/connectors/douban.fm.ts
index 71501ab4..b42a6610 100644
--- a/src/connectors/douban.fm.ts
+++ b/src/connectors/douban.fm.ts
@@ -33,7 +33,7 @@ function setupMobilePlayer() {
 
 	Connector.getArtist = () => {
 		const text = Util.getTextFromSelectors('h3>span');
-		return text?.split('Icon Angle right')[0] ?? null;
+		return text?.split('Icon Angle right')[0];
 	};
 
 	Connector.trackArtSelector =
@@ -41,7 +41,7 @@ function setupMobilePlayer() {
 
 	Connector.getUniqueID = () => {
 		const text = Util.getAttrFromSelectors('h1 > a', 'href');
-		return text?.split('/').at(-2) ?? null;
+		return text?.split('/').at(-2);
 	};
 }
 
diff --git a/src/connectors/earbits.ts b/src/connectors/earbits.ts
index 902835e3..e438b26c 100644
--- a/src/connectors/earbits.ts
+++ b/src/connectors/earbits.ts
@@ -19,5 +19,5 @@ Connector.isPlaying = () => {
 		'#play-pause img',
 		'src'
 	);
-	return playButtonImgFilename?.includes('pause') ?? false;
+	return playButtonImgFilename?.includes('pause');
 };
diff --git a/src/connectors/eggs-dom-inject.ts b/src/connectors/eggs-dom-inject.ts
index f01e207c..80b052a5 100644
--- a/src/connectors/eggs-dom-inject.ts
+++ b/src/connectors/eggs-dom-inject.ts
@@ -10,7 +10,7 @@ let frameID = '';
 let currentTime = 0;
 let duration = 0;
 
-let videoId = '';
+let videoId: string | undefined = '';
 
 if (isArtistPage) {
 	const observer = new MutationObserver(toggleExternalPlayer);
@@ -50,7 +50,7 @@ function replaceYoutubeVideo() {
 	const videoFrame = document.querySelector(
 		'.fancybox-inner iframe'
 	) as HTMLIFrameElement;
-	videoId = videoFrame.src.split('/').pop()?.split('?')[0] ?? '';
+	videoId = videoFrame.src.split('/').pop()?.split('?')[0];
 
 	videoFrame.src += '&enablejsapi=1&widgetid=1';
 	frameID = videoFrame.id;
diff --git a/src/connectors/emby.ts b/src/connectors/emby.ts
index 0f7fe653..37d2604c 100644
--- a/src/connectors/emby.ts
+++ b/src/connectors/emby.ts
@@ -58,7 +58,7 @@ Connector.getUniqueID = () => {
 		Connector.trackArtSelector
 	);
 	if (trackArtUrl) {
-		return /Items\/(\w+)/g.exec(trackArtUrl)?.[1] ?? null;
+		return /Items\/(\w+)/g.exec(trackArtUrl)?.[1];
 	}
 
 	return null;
diff --git a/src/connectors/eulerbeats.ts b/src/connectors/eulerbeats.ts
index d554dc55..46dd8237 100644
--- a/src/connectors/eulerbeats.ts
+++ b/src/connectors/eulerbeats.ts
@@ -5,7 +5,7 @@ Connector.playerSelector = '#root';
 Connector.isPlaying = () =>
 	!!document.querySelector('.eulerbeats-main.eulerbeats-is-playing');
 
-Connector.getUniqueID = () => getSeed() ?? null;
+Connector.getUniqueID = () => getSeed();
 
 Connector.getAlbum = () => 'Euler';
 
diff --git a/src/connectors/grrif.ts b/src/connectors/grrif.ts
index 959ed477..415a1843 100644
--- a/src/connectors/grrif.ts
+++ b/src/connectors/grrif.ts
@@ -10,4 +10,4 @@ Connector.isPlaying = () => Util.hasElementClass('#kast-play', 'kast-playing');
 
 Connector.trackArtSelector = '.pg-history .onair img';
 
-Connector.isTrackArtDefault = (url) => url?.includes('default') ?? false;
+Connector.isTrackArtDefault = (url) => url?.includes('default');
diff --git a/src/connectors/hardtunes.ts b/src/connectors/hardtunes.ts
index 0de6e77f..59311b28 100644
--- a/src/connectors/hardtunes.ts
+++ b/src/connectors/hardtunes.ts
@@ -10,7 +10,7 @@ Connector.trackArtSelector = '#jp_poster_0';
 
 Connector.getUniqueID = () => {
 	const src = Util.getAttrFromSelectors(trackSelector, 'src');
-	return src?.split('/').at(-1)?.split('.')[0] ?? null;
+	return src?.split('/').at(-1)?.split('.')[0];
 };
 
 Connector.getArtistTrack = () => {
diff --git a/src/connectors/hypem.ts b/src/connectors/hypem.ts
index a18abf19..ba1cb9ae 100644
--- a/src/connectors/hypem.ts
+++ b/src/connectors/hypem.ts
@@ -8,7 +8,7 @@ Connector.trackSelector = '#player-nowplaying [href^="/track/"]';
 
 Connector.trackArtSelector = ['.haarp-active .thumb', '.thumb'];
 
-Connector.isTrackArtDefault = (url) => url?.includes('solid_color') ?? false;
+Connector.isTrackArtDefault = (url) => url?.includes('solid_color');
 
 Connector.getUniqueID = () => {
 	const trackUrl = Util.getAttrFromSelectors(
@@ -16,7 +16,7 @@ Connector.getUniqueID = () => {
 		'href'
 	);
 	if (trackUrl) {
-		return trackUrl.split('/').at(-1) ?? null;
+		return trackUrl.split('/').at(-1);
 	}
 	return null;
 };
diff --git a/src/connectors/idagio.ts b/src/connectors/idagio.ts
index 19cfbf0a..df0d5552 100644
--- a/src/connectors/idagio.ts
+++ b/src/connectors/idagio.ts
@@ -55,7 +55,7 @@ function getCurrentTrack() {
 	if (track?.at(-1) === '–') {
 		track = track.slice(0, -1);
 	}
-	return track ?? null;
+	return track;
 }
 
 function getCurrentSymphony() {
diff --git a/src/connectors/indieshuffle.ts b/src/connectors/indieshuffle.ts
index 2f26cb8d..612551ef 100644
--- a/src/connectors/indieshuffle.ts
+++ b/src/connectors/indieshuffle.ts
@@ -3,7 +3,7 @@ export {};
 Connector.playerSelector = '#indieshuffle_player';
 
 Connector.getArtist = () =>
-	Util.getTextFromSelectors('#player-current .artist')?.slice(0, -3) ?? null;
+	Util.getTextFromSelectors('#player-current .artist')?.slice(0, -3);
 
 Connector.trackSelector = '#player-current .title';
 
diff --git a/src/connectors/jazzandrain.ts b/src/connectors/jazzandrain.ts
index ba79d7c8..1cede804 100644
--- a/src/connectors/jazzandrain.ts
+++ b/src/connectors/jazzandrain.ts
@@ -5,9 +5,7 @@ Connector.playerSelector = '#componentWrapper';
 Connector.artistTrackSelector = '.fontMeasure';
 
 Connector.isPlaying = () => {
-	return (
-		Util.getAttrFromSelectors('.controls_toggle img', 'src')?.includes(
-			'pause'
-		) ?? false
+	return Util.getAttrFromSelectors('.controls_toggle img', 'src')?.includes(
+		'pause'
 	);
 };
diff --git a/src/connectors/jazzgroove.ts b/src/connectors/jazzgroove.ts
index fd48b9b6..66fd533c 100644
--- a/src/connectors/jazzgroove.ts
+++ b/src/connectors/jazzgroove.ts
@@ -25,4 +25,4 @@ Connector.isTrackArtDefault = () => {
 Connector.isScrobblingAllowed = () => !Connector.isTrackArtDefault();
 
 Connector.isPlaying = () =>
-	!Util.hasElementClass(Connector.playerSelector ?? '', 'jg-player--paused');
+	!Util.hasElementClass(Connector.playerSelector, 'jg-player--paused');
diff --git a/src/connectors/kexp.ts b/src/connectors/kexp.ts
index 4a738df0..02c9f596 100644
--- a/src/connectors/kexp.ts
+++ b/src/connectors/kexp.ts
@@ -8,7 +8,7 @@ Connector.albumSelector = '.Player-album';
 
 Connector.trackArtSelector = '.Player-coverImage';
 
-Connector.isTrackArtDefault = (url) => url?.includes('default') ?? false;
+Connector.isTrackArtDefault = (url) => url?.includes('default');
 
 Connector.isPlaying = () =>
-	Util.hasElementClass(Connector.playerSelector ?? '', 'Player--playing');
+	Util.hasElementClass(Connector.playerSelector, 'Player--playing');
diff --git a/src/connectors/kodi.ts b/src/connectors/kodi.ts
index 2d05f45f..2f192006 100644
--- a/src/connectors/kodi.ts
+++ b/src/connectors/kodi.ts
@@ -80,9 +80,7 @@ function getCurrentContext() {
  * Use a custom function to extract URL from CSS property only.
  */
 function getTrackArtUrl(selector: string) {
-	return (
-		Util.extractUrlFromCssProperty(
-			Util.getCSSPropertyFromSelectors(selector, 'background-image') ?? ''
-		) ?? null
+	return Util.extractUrlFromCssProperty(
+		Util.getCSSPropertyFromSelectors(selector, 'background-image')
 	);
 }
diff --git a/src/connectors/kugou.ts b/src/connectors/kugou.ts
index 92142171..d06e29dd 100644
--- a/src/connectors/kugou.ts
+++ b/src/connectors/kugou.ts
@@ -18,5 +18,5 @@ Connector.durationSelector = '.all-time';
 
 Connector.getUniqueID = () => {
 	const text = document.location.hash.slice(1);
-	return text.split('&')[0].split('=').at(-1) ?? null;
+	return text.split('&')[0].split('=').at(-1);
 };
diff --git a/src/connectors/lightningstream.ts b/src/connectors/lightningstream.ts
index b0124007..10adc13d 100644
--- a/src/connectors/lightningstream.ts
+++ b/src/connectors/lightningstream.ts
@@ -8,6 +8,6 @@ Connector.artistSelector = '.SN_pw_slot.open .SN_pw_artist';
 
 Connector.trackArtSelector = '.SN_pw_slot.open .SN_pw_cover';
 
-Connector.isTrackArtDefault = (url) => url?.includes('noimage') ?? false;
+Connector.isTrackArtDefault = (url) => url?.includes('noimage');
 
 Connector.isPlaying = () => Util.isElementVisible('#playerStopLink');
diff --git a/src/connectors/listenlive.ts b/src/connectors/listenlive.ts
index c73000a8..70a1ebae 100644
--- a/src/connectors/listenlive.ts
+++ b/src/connectors/listenlive.ts
@@ -28,6 +28,6 @@ Connector.getTrackArt = () => {
 	return null;
 };
 
-Connector.isTrackArtDefault = (url) => url?.includes('default') ?? false;
+Connector.isTrackArtDefault = (url) => url?.includes('default');
 
 Connector.isPlaying = () => Util.hasElementClass('#nowPlaying', 'hasSong');
diff --git a/src/connectors/live365.ts b/src/connectors/live365.ts
index 5b83165d..3c82e081 100644
--- a/src/connectors/live365.ts
+++ b/src/connectors/live365.ts
@@ -13,7 +13,7 @@ const artistAlbumSeparator = ' by ';
 Connector.getArtistTrack = () => {
 	// "Art for Layla by Derek & The Dominos"
 	const artistAndTrackUnparsed = Util.getAttrFromSelectors(
-		Connector.trackArtSelector ?? '',
+		Connector.trackArtSelector,
 		'alt'
 	);
 
diff --git a/src/connectors/magnatune.ts b/src/connectors/magnatune.ts
index d3108491..d0c12e4b 100644
--- a/src/connectors/magnatune.ts
+++ b/src/connectors/magnatune.ts
@@ -28,12 +28,10 @@ Connector.getTrack = () => {
 Connector.getAlbum = () => {
 	const artistElement = document.querySelector(artistSelector);
 
-	return (
-		artistElement
-			?.closest('font')
-			?.textContent?.replace(artistElement?.textContent ?? '', '')
-			.slice(1) ?? null
-	);
+	return artistElement
+		?.closest('font')
+		?.textContent?.replace(artistElement?.textContent ?? '', '')
+		.slice(1);
 };
 
 Connector.isPlaying = () =>
diff --git a/src/connectors/mail.ru.ts b/src/connectors/mail.ru.ts
index b7b709f1..b54da783 100644
--- a/src/connectors/mail.ru.ts
+++ b/src/connectors/mail.ru.ts
@@ -14,7 +14,6 @@ Connector.durationSelector = '.l-music__player__song__time.duration';
 
 Connector.trackArtSelector = '.l-music__player__song__cover';
 
-Connector.isTrackArtDefault = (url) =>
-	url?.endsWith(DEFAULT_TRACK_ART) ?? false;
+Connector.isTrackArtDefault = (url) => url?.endsWith(DEFAULT_TRACK_ART);
 
 Connector.isPlaying = () => Util.hasElementClass('.l-music__player', 'playing');
diff --git a/src/connectors/musicoin.ts b/src/connectors/musicoin.ts
index 3af222c8..6f066c36 100644
--- a/src/connectors/musicoin.ts
+++ b/src/connectors/musicoin.ts
@@ -1,21 +1,20 @@
 export {};
 
 Connector.getArtist = () => {
-	return getIframeElement('#player-artist')?.textContent ?? null;
+	return getIframeElement('#player-artist')?.textContent;
 };
 
 Connector.getTrack = () => {
-	return getIframeElement('#player-title')?.textContent ?? null;
+	return getIframeElement('#player-title')?.textContent;
 };
 
 Connector.getDuration = () => {
-	const duration = getIframeElement('#player-time-left')?.textContent ?? '';
+	const duration = getIframeElement('#player-time-left')?.textContent;
 	return Util.stringToSeconds(duration);
 };
 
 Connector.getCurrentTime = () => {
-	const currentTime =
-		getIframeElement('#player-time-played')?.textContent ?? '';
+	const currentTime = getIframeElement('#player-time-played')?.textContent;
 	return Util.stringToSeconds(currentTime);
 };
 
@@ -38,7 +37,7 @@ Connector.getTrackArt = () => {
 Connector.getUniqueID = () => {
 	const trackUrl = getIframeElement('#player')?.getAttribute('src');
 	if (trackUrl) {
-		return trackUrl.split('/').at(-1) ?? null;
+		return trackUrl.split('/').at(-1);
 	}
 	return null;
 };
diff --git a/src/connectors/oxigenio.fm.ts b/src/connectors/oxigenio.fm.ts
index 3bc2f015..4b0117cf 100644
--- a/src/connectors/oxigenio.fm.ts
+++ b/src/connectors/oxigenio.fm.ts
@@ -11,9 +11,5 @@ Connector.isStateChangeAllowed = () => {
 	const artistTrack = Util.getTextFromSelectors(
 		Connector.artistTrackSelector
 	);
-	return (
-		(artistTrack?.includes('-') &&
-			!artistTrack?.match(/^\s*OXIGENIO - /)) ??
-		false
-	);
+	return artistTrack?.includes('-') && !artistTrack?.match(/^\s*OXIGENIO - /);
 };
diff --git a/src/connectors/patari.ts b/src/connectors/patari.ts
index 4408d6c9..4a1755ea 100644
--- a/src/connectors/patari.ts
+++ b/src/connectors/patari.ts
@@ -9,4 +9,4 @@ Connector.trackSelector = `${Connector.playerSelector} .songName`;
 Connector.timeInfoSelector = '.rightWrapper';
 
 Connector.isPlaying = () =>
-	Util.getAttrFromSelectors('.playerPlay', 'src')?.includes('pause') ?? false;
+	Util.getAttrFromSelectors('.playerPlay', 'src')?.includes('pause');
diff --git a/src/connectors/pcloud.ts b/src/connectors/pcloud.ts
index da4588f4..d98f7493 100644
--- a/src/connectors/pcloud.ts
+++ b/src/connectors/pcloud.ts
@@ -14,4 +14,4 @@ Connector.currentTimeSelector = `${playerPopupSelector} .playinfo .currtime`;
 
 Connector.durationSelector = `${playerPopupSelector} .playinfo .maxtime`;
 
-Connector.isTrackArtDefault = (url) => url?.endsWith('audio.png') ?? false;
+Connector.isTrackArtDefault = (url) => url?.endsWith('audio.png');
diff --git a/src/connectors/pinguinradio.ts b/src/connectors/pinguinradio.ts
index 9f3b2ca1..eb5c9e07 100644
--- a/src/connectors/pinguinradio.ts
+++ b/src/connectors/pinguinradio.ts
@@ -21,7 +21,7 @@ Connector.getArtistTrack = () => {
 Connector.isPlaying = () => {
 	const playButton = document.querySelector(playButtonSelector);
 	if (playButton) {
-		return playButton.textContent?.includes('pause') ?? false;
+		return playButton.textContent?.includes('pause');
 	}
 
 	return true;
diff --git a/src/connectors/playlist-randomizer.ts b/src/connectors/playlist-randomizer.ts
index 8afee29b..4eceaeca 100644
--- a/src/connectors/playlist-randomizer.ts
+++ b/src/connectors/playlist-randomizer.ts
@@ -25,7 +25,7 @@ Connector.getArtistTrack = () => {
 		return null;
 	}
 	let { artist, track } = Util.processYtVideoTitle(
-		currentTrack.firstChild?.nodeValue ?? null
+		currentTrack.firstChild?.nodeValue
 	);
 
 	// Set to some default information that we have so that the user can edit the info in the extension
diff --git a/src/connectors/qq-music.ts b/src/connectors/qq-music.ts
index f117603e..db630159 100644
--- a/src/connectors/qq-music.ts
+++ b/src/connectors/qq-music.ts
@@ -8,7 +8,7 @@ Connector.trackSelector = '#song_name a';
 
 Connector.getUniqueID = () => {
 	const text = Util.getAttrFromSelectors('#song_name a', 'href');
-	return text?.split('/').at(-1)?.split('.html')[0] ?? null;
+	return text?.split('/').at(-1)?.split('.html')[0];
 };
 
 Connector.albumSelector = '#album_name a';
diff --git a/src/connectors/qq-video.ts b/src/connectors/qq-video.ts
index 344638fe..d9d5d6fa 100644
--- a/src/connectors/qq-video.ts
+++ b/src/connectors/qq-video.ts
@@ -8,14 +8,14 @@ function setupCoverPlayer() {
 			'.video_title ._base_title',
 			'title'
 		);
-		return extractArtistTrack(text ?? '');
+		return extractArtistTrack(text);
 	};
 }
 
 function setupPagePlayer() {
 	Connector.getArtistTrack = () => {
 		const text = Util.getAttrFromSelectors('.video_title', 'title');
-		return extractArtistTrack(text ?? '');
+		return extractArtistTrack(text);
 	};
 }
 
@@ -30,7 +30,10 @@ function isPagePlayer() {
 }
 
 // Example: 周华健《朋友》(KTV版)
-function extractArtistTrack(artistTrackStr: string) {
+function extractArtistTrack(artistTrackStr?: string | null) {
+	if (!artistTrackStr) {
+		return null;
+	}
 	const match = artistTrackStr.match(artistTrackRe);
 	if (match) {
 		return { artist: match[1], track: match[2] };
diff --git a/src/connectors/radioco.ts b/src/connectors/radioco.ts
index 8a884676..49cc8020 100644
--- a/src/connectors/radioco.ts
+++ b/src/connectors/radioco.ts
@@ -11,4 +11,4 @@ Connector.trackArtSelector = '.current-artwork';
 Connector.currentTimeSelector = '.streamtime';
 
 Connector.isPlaying = () =>
-	Util.hasElementClass(Connector.playerSelector ?? '', 'playing');
+	Util.hasElementClass(Connector.playerSelector, 'playing');
diff --git a/src/connectors/radiorethink.ts b/src/connectors/radiorethink.ts
index dc85f050..0c95b1b5 100644
--- a/src/connectors/radiorethink.ts
+++ b/src/connectors/radiorethink.ts
@@ -21,5 +21,4 @@ Connector.getTrackArt = () => {
 	return trackArtUrl && trackArtUrl.replace(/\._SL\d+_/, '');
 };
 
-Connector.isTrackArtDefault = (url) =>
-	url?.includes('placeHolderCovers') ?? false;
+Connector.isTrackArtDefault = (url) => url?.includes('placeHolderCovers');
diff --git a/src/connectors/redbull.ts b/src/connectors/redbull.ts
index 412d38a6..3e49ab47 100644
--- a/src/connectors/redbull.ts
+++ b/src/connectors/redbull.ts
@@ -32,7 +32,7 @@ Connector.getUniqueID = () => {
 		'[data-content-id]',
 		'data-content-id'
 	);
-	return (text && text.split(':').at(-2)) ?? null;
+	return text && text.split(':').at(-2);
 };
 
 Connector.getArtistTrack = () => {
diff --git a/src/connectors/redditmusicplayer.ts b/src/connectors/redditmusicplayer.ts
index 3266c0f7..c8566d39 100644
--- a/src/connectors/redditmusicplayer.ts
+++ b/src/connectors/redditmusicplayer.ts
@@ -11,7 +11,7 @@ Connector.getArtistTrack = () => {
 		/ \[.*/,
 		''
 	);
-	return Util.splitArtistTrack(text ?? null);
+	return Util.splitArtistTrack(text);
 };
 
 Connector.isPlaying = () => Util.hasElementClass('.item.play.button', 'active');
diff --git a/src/connectors/refnet.ts b/src/connectors/refnet.ts
index f71aaecf..bb203f15 100644
--- a/src/connectors/refnet.ts
+++ b/src/connectors/refnet.ts
@@ -17,7 +17,7 @@ Connector.getOriginUrl = () => {
 };
 
 Connector.isPlaying = () => {
-	return Util.hasElementClass(Connector.playerSelector ?? '', 'playing');
+	return Util.hasElementClass(Connector.playerSelector, 'playing');
 };
 
 Connector.isScrobblingAllowed = () => {
diff --git a/src/connectors/relisten.ts b/src/connectors/relisten.ts
index f3e9a2c4..63b0af52 100644
--- a/src/connectors/relisten.ts
+++ b/src/connectors/relisten.ts
@@ -15,7 +15,7 @@ Connector.trackSelector = '.player .content .info .song-title';
 
 Connector.getTrackInfo = () => {
 	const artistAlbum = Util.getTextFromSelectors(artistAlbumSelector);
-	return Util.splitArtistAlbum(artistAlbum ?? '', [ARTISTALBUM_SEPARATOR]);
+	return Util.splitArtistAlbum(artistAlbum, [ARTISTALBUM_SEPARATOR]);
 };
 
 Connector.isPlaying = () => {
diff --git a/src/connectors/residentadvisor.ts b/src/connectors/residentadvisor.ts
index 2ff63233..37f03d4a 100644
--- a/src/connectors/residentadvisor.ts
+++ b/src/connectors/residentadvisor.ts
@@ -71,7 +71,7 @@ Connector.getTrackInfo = () => {
 		case PLAYER_TYPES.POPULAR: {
 			const artistTrackStr =
 				trackContainer?.querySelector('div a')?.textContent;
-			({ artist, track } = Util.splitArtistTrack(artistTrackStr ?? ''));
+			({ artist, track } = Util.splitArtistTrack(artistTrackStr));
 			break;
 		}
 		case PLAYER_TYPES.SINGLE: {
diff --git a/src/connectors/soundcloud.ts b/src/connectors/soundcloud.ts
index d18dda59..ecae120b 100644
--- a/src/connectors/soundcloud.ts
+++ b/src/connectors/soundcloud.ts
@@ -12,7 +12,7 @@ Connector.currentTimeSelector =
 
 Connector.getArtistTrack = () => {
 	let { artist, track } = Util.processSoundCloudTrack(
-		Util.getTextFromSelectors(trackSelector) ?? ''
+		Util.getTextFromSelectors(trackSelector)
 	);
 	if (!artist) {
 		artist = Util.getTextFromSelectors(artistSelector);
diff --git a/src/connectors/subfire.ts b/src/connectors/subfire.ts
index 71c1fc8e..97634118 100644
--- a/src/connectors/subfire.ts
+++ b/src/connectors/subfire.ts
@@ -30,4 +30,4 @@ Connector.getUniqueID = () => {
 };
 
 Connector.isScrobblingAllowed = () =>
-	Util.isElementVisible(Connector.playerSelector ?? '');
+	Util.isElementVisible(Connector.playerSelector);
diff --git a/src/connectors/subphonic.ts b/src/connectors/subphonic.ts
index 13497ac3..2d3d872b 100644
--- a/src/connectors/subphonic.ts
+++ b/src/connectors/subphonic.ts
@@ -3,10 +3,7 @@
 Connector.playerSelector = '#playdeck';
 
 Connector.getArtist = () => {
-	return (
-		Util.getTextFromSelectors('#songdetails_artist')?.split(' - ')[0] ??
-		null
-	);
+	return Util.getTextFromSelectors('#songdetails_artist')?.split(' - ')[0];
 };
 
 Connector.trackSelector = '#songdetails_song';
@@ -15,10 +12,7 @@ Connector.isPlaying = () =>
 	!(document.querySelector('#playdeck audio') as HTMLAudioElement).paused;
 
 Connector.getAlbum = () => {
-	return (
-		Util.getTextFromSelectors('#songdetails_artist')?.split(' - ')[1] ??
-		null
-	);
+	return Util.getTextFromSelectors('#songdetails_artist')?.split(' - ')[1];
 };
 
 Connector.trackArtSelector = '#coverartimage img';
diff --git a/src/connectors/synology.ts b/src/connectors/synology.ts
index a58679db..6b34708d 100644
--- a/src/connectors/synology.ts
+++ b/src/connectors/synology.ts
@@ -7,7 +7,7 @@ Connector.trackSelector = '.info-title';
 Connector.getTrackInfo = () => {
 	const albumArtistText =
 		document.querySelector('.info-album-artist')?.textContent;
-	return Util.splitArtistAlbum(albumArtistText ?? '', null, true);
+	return Util.splitArtistAlbum(albumArtistText, null, true);
 };
 
 Connector.currentTimeSelector = '.info-position';
diff --git a/src/connectors/thrilljockey.ts b/src/connectors/thrilljockey.ts
index cdc1cdb3..d7fada18 100644
--- a/src/connectors/thrilljockey.ts
+++ b/src/connectors/thrilljockey.ts
@@ -8,7 +8,7 @@ Connector.trackSelector = `${Connector.playerSelector} .audio-player__title`;
 
 Connector.getTrack = () => {
 	const trackName = Util.getTextFromSelectors(Connector.trackSelector);
-	return trackName?.slice(trackName.indexOf(' ') + 1) ?? null;
+	return trackName?.slice(trackName.indexOf(' ') + 1);
 };
 
 Connector.artistSelector = '.product-info-holder a[href^="/artists/"]';
diff --git a/src/connectors/tidal.ts b/src/connectors/tidal.ts
index a5bc64cd..103f2af2 100644
--- a/src/connectors/tidal.ts
+++ b/src/connectors/tidal.ts
@@ -7,7 +7,7 @@ Connector.playButtonSelector = `${Connector.playerSelector} button[data-test="pl
 Connector.pauseButtonSelector = `${Connector.playerSelector} button[data-test="pause"]`;
 
 Connector.isScrobblingAllowed = () =>
-	!!Util.queryElements(Connector.playButtonSelector ?? '');
+	!!Util.queryElements(Connector.playButtonSelector);
 
 Connector.trackSelector = [
 	'#nowPlaying div.react-tabs__tab-panel--selected > div > div:nth-child(1) > div:nth-child(1) > wave-text:nth-child(2)',
@@ -20,7 +20,7 @@ Connector.getUniqueID = () => {
 		'href'
 	);
 	if (trackUrl) {
-		return trackUrl.split('/').at(-1) ?? null;
+		return trackUrl.split('/').at(-1);
 	}
 	return null;
 };
@@ -33,10 +33,7 @@ Connector.albumSelector = [
 ];
 
 Connector.getAlbumArtist = () => {
-	const albumUrl = Util.getAttrFromSelectors(
-		Connector.albumSelector ?? '',
-		'href'
-	);
+	const albumUrl = Util.getAttrFromSelectors(Connector.albumSelector, 'href');
 	const canonicalUrl = Util.getAttrFromSelectors(
 		'head link[rel="canonical"]',
 		'href'
diff --git a/src/connectors/towerrecordsmusic.ts b/src/connectors/towerrecordsmusic.ts
index 613e2d2d..21e28003 100644
--- a/src/connectors/towerrecordsmusic.ts
+++ b/src/connectors/towerrecordsmusic.ts
@@ -8,7 +8,7 @@ let lastAlbumThumbnail: string | null = null;
 /**
  * Holds album title
  */
-let albumTitle: string | null = null;
+let albumTitle: string | null | undefined = null;
 
 const filter = MetadataFilter.createFilter(
 	MetadataFilter.createFilterSetForFields(
@@ -31,7 +31,7 @@ Connector.getTrackArt = () =>
 		Util.getCSSPropertyFromSelectors(
 			'#jacketImageAudio',
 			'background-image'
-		) ?? ''
+		)
 	);
 
 Connector.currentTimeSelector = '#currentTimeAudio';
@@ -97,7 +97,7 @@ async function fetchAlbumTitle() {
 async function requestAlbum() {
 	if (isNewAlbum()) {
 		try {
-			albumTitle = (await fetchAlbumTitle()) ?? null;
+			albumTitle = await fetchAlbumTitle();
 		} catch (err) {
 			Util.debugLog(`Error: ${err}`, 'error');
 
diff --git a/src/connectors/tunetrack.ts b/src/connectors/tunetrack.ts
index 5a5a66ad..a373c544 100644
--- a/src/connectors/tunetrack.ts
+++ b/src/connectors/tunetrack.ts
@@ -9,7 +9,7 @@ Connector.pauseButtonSelector = '.pause-btn';
 
 Connector.getUniqueID = () => {
 	const text = Util.getAttrFromSelectors(`${trackSelector} a`, 'href');
-	return (text && /\/(\d+)-?/g.exec(text)?.at(-1)) ?? null;
+	return text && /\/(\d+)-?/g.exec(text)?.at(-1);
 };
 
 Connector.trackSelector = trackSelector;
diff --git a/src/connectors/vagalume.ts b/src/connectors/vagalume.ts
index 2a85fa6b..86280a2f 100644
--- a/src/connectors/vagalume.ts
+++ b/src/connectors/vagalume.ts
@@ -27,12 +27,12 @@ function setupConnector() {
 
 		Connector.getCurrentTime = () => {
 			const text = Util.getAttrFromSelectors('.progress-bar', 'current');
-			return Util.stringToSeconds(text ?? '');
+			return Util.stringToSeconds(text);
 		};
 
 		Connector.getDuration = () => {
 			const text = Util.getAttrFromSelectors('.progress-bar', 'duration');
-			return Util.stringToSeconds(text ?? '');
+			return Util.stringToSeconds(text);
 		};
 	}
 	function setupVideoPlayer() {
diff --git a/src/connectors/warp-aphextwin.ts b/src/connectors/warp-aphextwin.ts
index d90eda0b..a6a6e743 100644
--- a/src/connectors/warp-aphextwin.ts
+++ b/src/connectors/warp-aphextwin.ts
@@ -11,16 +11,14 @@ Connector.currentTimeSelector = '.current-time.js-current-time';
 Connector.playButtonSelector = '.track-controls .js-play-track';
 
 Connector.getArtist = () => {
-	return (
-		Util.getTextFromSelectors(
-			'.current-track .js-current-track-artist'
-		)?.replace(' - ', '') ?? null
-	);
+	return Util.getTextFromSelectors(
+		'.current-track .js-current-track-artist'
+	)?.replace(' - ', '');
 };
 
 Connector.getAlbum = () => {
 	const releasePath = Util.getAttrFromSelectors(
-		Connector.trackSelector ?? '',
+		Connector.trackSelector,
 		'href'
 	);
 	return Util.getAttrFromSelectors(
diff --git a/src/connectors/weibo.ts b/src/connectors/weibo.ts
index b1f06e4f..fefa983b 100644
--- a/src/connectors/weibo.ts
+++ b/src/connectors/weibo.ts
@@ -14,5 +14,5 @@ Connector.trackArtSelector = '.music_pic img';
 
 Connector.getUniqueID = () => {
 	const text = Util.getAttrFromSelectors('.current', 'objectid');
-	return (text && text.split('_').at(-1)) ?? null;
+	return text && text.split('_').at(-1);
 };
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/content/connector.ts b/src/core/content/connector.ts
index f79f2314..f24b0746 100644
--- a/src/core/content/connector.ts
+++ b/src/core/content/connector.ts
@@ -153,7 +153,7 @@ export default class BaseConnector {
 	 *
 	 * @returns Song artist
 	 */
-	public getArtist: () => string | null;
+	public getArtist: () => string | null | undefined;
 
 	/**
 	 * Default implementation of track name lookup by selector.
@@ -162,7 +162,7 @@ export default class BaseConnector {
 	 *
 	 * @returns Song title
 	 */
-	public getTrack: () => string | null;
+	public getTrack: () => string | null | undefined;
 
 	/**
 	 * Default implementation of album name lookup by selector.
@@ -171,7 +171,7 @@ export default class BaseConnector {
 	 *
 	 * @returns Song album
 	 */
-	public getAlbum: () => string | null;
+	public getAlbum: () => string | null | undefined;
 
 	/**
 	 * Default implementation of album artist name lookup by selector.
@@ -180,7 +180,7 @@ export default class BaseConnector {
 	 *
 	 * @returns Song album artist
 	 */
-	public getAlbumArtist: () => string | null;
+	public getAlbumArtist: () => string | null | undefined;
 
 	/**
 	 * Default implementation of track duration lookup. If this method returns
@@ -230,7 +230,7 @@ export default class BaseConnector {
 	 *
 	 * @returns Object containing current time and duration info
 	 */
-	public getTimeInfo: () => TimeInfo | null;
+	public getTimeInfo: () => TimeInfo | null | undefined;
 
 	/**
 	 * Default implementation of artist and track name lookup by selector.
@@ -241,7 +241,7 @@ export default class BaseConnector {
 	 *
 	 * @returns Object containing artist and track information
 	 */
-	public getArtistTrack: () => ArtistTrackInfo | null;
+	public getArtistTrack: () => ArtistTrackInfo | null | undefined;
 
 	/**
 	 * Get object contains track info.
@@ -252,7 +252,7 @@ export default class BaseConnector {
 	 *
 	 * @returns Track info
 	 */
-	public getTrackInfo: () => BaseState | null = () => null;
+	public getTrackInfo: () => BaseState | null | undefined = () => null;
 
 	/**
 	 * Returns a unique identifier of current track. The identifier does not
@@ -269,7 +269,7 @@ export default class BaseConnector {
 	 *
 	 * @returns Song unique ID
 	 */
-	public getUniqueID: () => string | null = () => null;
+	public getUniqueID: () => string | null | undefined = () => null;
 
 	/**
 	 * Default implementation of check for active playback by play/pause button
@@ -279,7 +279,7 @@ export default class BaseConnector {
 	 *
 	 * @returns True if song is now playing; false otherwise
 	 */
-	public isPlaying: () => boolean;
+	public isPlaying: () => boolean | null | undefined;
 
 	/**
 	 * Default implementation to check whether a podcast is playing. Only has an
@@ -287,7 +287,7 @@ export default class BaseConnector {
 	 *
 	 * @returns True if the current track is a podcast; false otherwise
 	 */
-	public isPodcast: () => boolean = () => false;
+	public isPodcast: () => boolean | null = () => false;
 
 	/**
 	 * Default implementation used to get the track art URL from the selector.
@@ -296,7 +296,7 @@ export default class BaseConnector {
 	 *
 	 * @returns Track art URL
 	 */
-	public getTrackArt: () => string | null;
+	public getTrackArt: () => string | null | undefined;
 
 	/**
 	 * Default implementation of a check if given track art URL
@@ -307,7 +307,9 @@ export default class BaseConnector {
 	 * @param trackArtUrl - Track art URL
 	 * @returns Check result
 	 */
-	public isTrackArtDefault: (trackArtUrl?: string) => boolean = () => false;
+	public isTrackArtDefault: (
+		trackArtUrl?: string | null | undefined
+	) => boolean | null | undefined = () => false;
 
 	/**
 	 * Default implementation of a check to see if a state change is allowed.
@@ -318,7 +320,7 @@ export default class BaseConnector {
 	 *
 	 * @returns True if state change is allowed; false otherwise
 	 */
-	public isStateChangeAllowed: () => boolean = () => true;
+	public isStateChangeAllowed: () => boolean | null | undefined = () => true;
 
 	/**
 	 * Default implementation of a check to see if a scrobbling is allowed.
@@ -328,7 +330,7 @@ export default class BaseConnector {
 	 *
 	 * @returns True if state change is allowed; false otherwise
 	 */
-	public isScrobblingAllowed: () => boolean = () => true;
+	public isScrobblingAllowed: () => boolean | null | undefined = () => true;
 
 	/**
 	 * Function that will be called when the connector is injected and
@@ -462,7 +464,7 @@ export default class BaseConnector {
 	 *
 	 * @returns The source URL
 	 */
-	public getOriginUrl: () => string | null = () => {
+	public getOriginUrl: () => string | null | undefined = () => {
 		return document.location.href;
 	};
 
@@ -600,7 +602,7 @@ export default class BaseConnector {
 
 		this.isPlaying = () => {
 			if (this.playButtonSelector) {
-				return Util.isElementVisible(this.playButtonSelector);
+				return !Util.isElementVisible(this.playButtonSelector);
 			}
 
 			if (this.pauseButtonSelector) {
diff --git a/src/core/content/util.ts b/src/core/content/util.ts
index f174c7c3..d5683140 100644
--- a/src/core/content/util.ts
+++ b/src/core/content/util.ts
@@ -73,7 +73,7 @@ export const defaultSeparators = [
  * @param str - Time-string in h:m:s format
  * @returns Seconds
  */
-export function stringToSeconds(str: string | null): number {
+export function stringToSeconds(str: string | null | undefined): number {
 	const timeFormatExpression =
 		/^\s*-?((\d{1,2}:\d\d:\d\d)|(\d{1,2}:\d\d)|(\d{1,2}))\s*$/g;
 	if (!str || !timeFormatExpression.test(str)) {
@@ -100,7 +100,7 @@ export function stringToSeconds(str: string | null): number {
  * @returns Object containing position and width of separator
  */
 export function findSeparator(
-	str: string,
+	str: string | null,
 	separators: Separator[] | null = null
 ): { index: number; length: number } | null {
 	if (str === null || str.length === 0) {
@@ -143,11 +143,11 @@ export function joinArtists(artists: Node[]): string | null {
  * @returns Object contains artist and track fields
  */
 export function splitArtistTrack(
-	str: string | null,
+	str: string | null | undefined,
 	separators: Separator[] | null = null,
 	swap = false
 ): ArtistTrackInfo {
-	if (str === null) {
+	if (!str) {
 		return { artist: null, track: null };
 	}
 	const [artist, track] = splitString(str, separators, swap);
@@ -164,7 +164,7 @@ export const RECORD_SIDE_REGEX = /^[A-Z][1-9]\.? /;
  * @param str - String containing track title
  * @returns Track title without record side
  */
-export function removeRecordSide(str: string): string | null {
+export function removeRecordSide(str: string | null): string | null {
 	if (str !== null) {
 		return str.replace(RECORD_SIDE_REGEX, '');
 	}
@@ -179,7 +179,7 @@ export function removeRecordSide(str: string): string | null {
  * @returns Object containing artist and track fields
  */
 export function splitArtistAlbum(
-	str: string | null,
+	str: string | null | undefined,
 	separators: Separator[] | null = null,
 	swap = false
 ): { artist: string | null; album: string | null } {
@@ -220,7 +220,7 @@ export function splitTimeInfo(
  * @returns Array of strings splitted by separator
  */
 export function splitString(
-	str: string | null,
+	str: string | null | undefined,
 	separators: Separator[] | null,
 	swap = false
 ): [string | null, string | null] {
@@ -264,7 +264,12 @@ export function escapeBadTimeValues(time: unknown): number | null {
  * @param cssProperty - CSS property
  * @returns Track art URL
  */
-export function extractUrlFromCssProperty(cssProperty: string): string | null {
+export function extractUrlFromCssProperty(
+	cssProperty: string | null | undefined
+): string | null {
+	if (!cssProperty) {
+		return null;
+	}
 	const match = /url\((["']?)(.*)\1\)/.exec(cssProperty);
 	if (match) {
 		return match[2].trim();
@@ -313,8 +318,8 @@ export function isArtistTrackEmpty(
  */
 export function fillEmptyFields(
 	target: State,
-	source: State | null,
-	fields: (keyof State)[]
+	source: State | null | undefined,
+	fields: (keyof State)[] | undefined
 ): State {
 	if (!source || !Array.isArray(fields)) {
 		return target;
@@ -402,10 +407,13 @@ export function getTextFromSelectors(
  */
 /* istanbul ignore next */
 export function getAttrFromSelectors(
-	selectors: string | string[],
+	selectors: string | string[] | null,
 	attr: string,
 	defaultValue: string | null = null
 ): string | null {
+	if (selectors === null) {
+		return defaultValue;
+	}
 	const elements = queryElements(selectors);
 
 	if (elements) {
@@ -538,9 +546,12 @@ export function getCSSProperty(element: Element, property: string) {
  */
 /* istanbul ignore next */
 export function hasElementClass(
-	selectors: string | string[],
+	selectors: string | string[] | null,
 	cls: string
 ): boolean {
+	if (selectors === null) {
+		return false;
+	}
 	const elements = queryElements(selectors);
 	if (!elements) {
 		return false;
@@ -579,7 +590,12 @@ function visibleFilter(elements: NodeListOf<HTMLElement>) {
  * @returns Check result
  */
 /* istanbul ignore next */
-export function isElementVisible(selectors: string | string[]): boolean {
+export function isElementVisible(
+	selectors: string | string[] | null | undefined
+): boolean {
+	if (!selectors) {
+		return false;
+	}
 	const element = queryElements(selectors);
 	return (element && visibleFilter(element)) ?? false;
 }
@@ -623,7 +639,7 @@ export function getDataFromSelectors(
  */
 /* istanbul ignore next */
 export function queryElements(
-	selectors: string | string[]
+	selectors: string | string[] | null | undefined
 ): NodeListOf<HTMLElement> | null {
 	if (!selectors) {
 		return null;
@@ -771,7 +787,7 @@ export const ytTitleRegExps = [
  * @returns Object containing artist and track fields
  */
 export function processYtVideoTitle(
-	videoTitle: string | null
+	videoTitle: string | null | undefined
 ): ArtistTrackInfo {
 	let artist = null;
 	let track = null;
@@ -915,7 +931,12 @@ export const scArtistTrackRe = /(.+)\s[:\u2013-\u2015-]\s(.+)/;
  * @param track - SoundCloud track title
  * @returns Object contains artist and track fields
  */
-export function processSoundCloudTrack(track: string): ArtistTrackInfo {
+export function processSoundCloudTrack(
+	track: string | null | undefined
+): ArtistTrackInfo {
+	if (!track) {
+		return { artist: null, track: null };
+	}
 	/*
 	 * Sometimes the artist name is in the track title,
 	 * e.g. Tokyo Rose - Zender Overdrive by Aphasia Records.
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/controller/controller.ts b/src/core/object/controller/controller.ts
index 6cc7a956..5dc19fd1 100644
--- a/src/core/object/controller/controller.ts
+++ b/src/core/object/controller/controller.ts
@@ -611,7 +611,7 @@ export default class Controller {
 	 * Called when playing state is changed.
 	 * @param value - New playing state
 	 */
-	private onPlayingStateChanged(value: boolean): void {
+	private onPlayingStateChanged(value: boolean | null): void {
 		this.debugLog(`isPlaying state changed to ${String(value)}`);
 
 		if (value && this.currentSong) {
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..fd801f27
--- /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 (err) {
+		console.error(err);
+	}
+}
diff --git a/src/core/object/pipeline/user-input.ts b/src/core/object/pipeline/user-input.ts
index c842cee4..f1a590e3 100644
--- a/src/core/object/pipeline/user-input.ts
+++ b/src/core/object/pipeline/user-input.ts
@@ -14,8 +14,8 @@ export async function process(song: Song): Promise<void> {
 	let isSongInfoLoaded = false;
 	try {
 		isSongInfoLoaded = await SavedEdits.loadSongInfo(song);
-	} catch (e) {
-		// Do nothing
+	} catch (err) {
+		console.error(err);
 	}
 
 	song.flags.isCorrectedByUser = isSongInfoLoaded;
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/custom-patterns.ts b/src/core/storage/custom-patterns.ts
index 31941a28..270fbd0c 100644
--- a/src/core/storage/custom-patterns.ts
+++ b/src/core/storage/custom-patterns.ts
@@ -23,11 +23,7 @@ export async function setPatterns(
 	connectorId: string,
 	patterns: string[]
 ): Promise<void> {
-	const data = await storage.get();
-	if (!data) {
-		throw new Error('No custom patterns found');
-	}
-
+	const data = (await storage.get()) ?? {};
 	data[connectorId] = patterns;
 	await storage.set(data);
 }
@@ -37,11 +33,7 @@ export async function setPatterns(
  * @param connectorId - Connector ID
  */
 export async function resetPatterns(connectorId: string): Promise<void> {
-	const data = await storage.get();
-	if (!data) {
-		throw new Error('No custom patterns found');
-	}
-
+	const data = (await storage.get()) ?? {};
 	delete data[connectorId];
 	await storage.set(data);
 }
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/saved-edits.model.ts b/src/core/storage/saved-edits.model.ts
index 6d1affae..4b07047c 100644
--- a/src/core/storage/saved-edits.model.ts
+++ b/src/core/storage/saved-edits.model.ts
@@ -11,7 +11,7 @@ import { SavedEdit } from '@/core/storage/options';
 type K = typeof LOCAL_CACHE;
 type V = DataModels[K];
 type T = Record<K, V>;
-type TContent = T[K];
+export type TContent = T[K];
 
 export default abstract class SavedEditsModel extends CustomStorage<K> {
 	/**
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/core/types.ts b/src/core/types.ts
index 758ab440..0bc81721 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -14,61 +14,61 @@ export interface ArtistTrackInfo {
 	/**
 	 * Artist name
 	 */
-	artist?: string|null;
+	artist?: string | null;
 
 	/**
 	 * Track name
 	 */
-	track?: string|null;
+	track?: string | null;
 }
 
 export interface TrackInfoWithAlbum extends ArtistTrackInfo {
 	/**
 	 * Album name
 	 */
-	album?: string|null;
+	album?: string | null;
 }
 
 export interface BaseState extends TrackInfoWithAlbum {
 	/**
 	 * URL to track art image.
 	 */
-	trackArt?: string|null;
+	trackArt?: string | null;
 }
 
 export interface State extends BaseState {
 	/**
 	 * Album artist.
 	 */
-	albumArtist?: string|null,
+	albumArtist?: string | null;
 
 	/**
 	 * Track unique ID.
 	 */
-	uniqueID?: string|null,
+	uniqueID?: string | null;
 
 	/**
 	 * Track duration.
 	 */
-	duration?: number|null,
+	duration?: number | null;
 
 	/**
 	 * Current time.
 	 */
-	currentTime?: number|null,
+	currentTime?: number | null;
 
 	/**
 	 * Playing/pause state.
 	 */
-	isPlaying?: boolean,
+	isPlaying?: boolean | null;
 
 	/**
 	 * Whether the current track is a podcast episode.
 	 */
-	isPodcast?: boolean,
+	isPodcast?: boolean | null;
 
 	/**
 	 * Origin URL.
 	 */
-	originUrl?: string|null,
+	originUrl?: string | null;
 }
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/connector-override.tsx b/src/ui/options/components/connector-override.tsx
similarity index 98%
rename from src/ui/options/components/options/connector-override.tsx
rename to src/ui/options/components/connector-override.tsx
index e67876c3..080617d9 100644
--- a/src/ui/options/components/options/connector-override.tsx
+++ b/src/ui/options/components/connector-override.tsx
@@ -9,7 +9,7 @@ import {
 	createResource,
 	createSignal,
 } from 'solid-js';
-import styles from '../components.module.scss';
+import styles from './components.module.scss';
 import Settings from '@suid/icons-material/SettingsOutlined';
 import ExpandMore from '@suid/icons-material/ExpandMoreOutlined';
 import Delete from '@suid/icons-material/DeleteOutlined';
@@ -20,7 +20,7 @@ import {
 	ConnectorTripleCheckbox,
 	RadioButtons,
 	SummaryCheckbox,
-} from '../inputs';
+} from './inputs';
 import browser from 'webextension-polyfill';
 import { t } from '@/util/i18n';
 
@@ -48,7 +48,7 @@ const [customPatternOptions, setCustomPatternOptions] = createResource(
 export default function ConnectorOverrideOptions() {
 	return (
 		<>
-			<h2>{t('optionsSupportedWebsites')}</h2>
+			<h1>{t('optionsSupportedWebsites')}</h1>
 			<p>{t('optionsEnableDisableHint')}</p>
 			{/* eslint-disable-next-line */}
 			<p innerHTML={t('optionsCustomPatternsHint')} />
diff --git a/src/ui/options/components/edit-options/edit-options.tsx b/src/ui/options/components/edit-options/edit-options.tsx
new file mode 100644
index 00000000..19531a50
--- /dev/null
+++ b/src/ui/options/components/edit-options/edit-options.tsx
@@ -0,0 +1,23 @@
+import { Setter } from 'solid-js';
+import EditedTracks from './edited-tracks';
+import RegexEdits from './regex-edits';
+import { t } from '@/util/i18n';
+
+export default function EditOptions(props: {
+	setActiveModal: Setter<string>;
+	modal: HTMLDialogElement | undefined;
+}) {
+	return (
+		<>
+			<h1>{t('optionsEdits')}</h1>
+			<EditedTracks
+				setActiveModal={props.setActiveModal}
+				modal={props.modal}
+			/>
+			<RegexEdits
+				setActiveModal={props.setActiveModal}
+				modal={props.modal}
+			/>
+		</>
+	);
+}
diff --git a/src/ui/options/components/options/edited-tracks.tsx b/src/ui/options/components/edit-options/edited-tracks.tsx
similarity index 100%
rename from src/ui/options/components/options/edited-tracks.tsx
rename to src/ui/options/components/edit-options/edited-tracks.tsx
diff --git a/src/ui/options/components/edit-options/regex-edits.tsx b/src/ui/options/components/edit-options/regex-edits.tsx
new file mode 100644
index 00000000..8393fa6b
--- /dev/null
+++ b/src/ui/options/components/edit-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/options/options.tsx b/src/ui/options/components/options/options.tsx
index 292adf74..2f318700 100644
--- a/src/ui/options/components/options/options.tsx
+++ b/src/ui/options/components/options/options.tsx
@@ -1,11 +1,9 @@
 import * as BrowserStorage from '@/core/storage/browser-storage';
 import { t } from '@/util/i18n';
-import { Setter, createResource } from 'solid-js';
+import { createResource } from 'solid-js';
 import GlobalOptionsList from './global-options';
 import ConnectorOptionsList from './connector-options';
 import ScrobbleBehavior from './scrobble-behavior';
-import EditedTracks from './edited-tracks';
-import ConnectorOverrideOptions from './connector-override';
 
 const globalOptions = BrowserStorage.getStorage(BrowserStorage.OPTIONS);
 
@@ -16,21 +14,13 @@ const [options, setOptions] = createResource(
 /**
  * Component that shows options to the user
  */
-export default function OptionsComponent(props: {
-	setActiveModal: Setter<string>;
-	modal: HTMLDialogElement | undefined;
-}) {
+export default function OptionsComponent() {
 	return (
 		<>
 			<h1>{t('optionsOptions')}</h1>
 			<GlobalOptionsList options={options} setOptions={setOptions} />
 			<ConnectorOptionsList />
 			<ScrobbleBehavior options={options} setOptions={setOptions} />
-			<EditedTracks
-				setActiveModal={props.setActiveModal}
-				modal={props.modal}
-			/>
-			<ConnectorOverrideOptions />
 		</>
 	);
 }
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..524f82b6 100644
--- a/src/ui/options/main.tsx
+++ b/src/ui/options/main.tsx
@@ -9,6 +9,8 @@ import Info from '@suid/icons-material/InfoOutlined';
 import Help from '@suid/icons-material/HelpOutlined';
 import Contacts from '@suid/icons-material/ContactsOutlined';
 import Settings from '@suid/icons-material/SettingsOutlined';
+import Edit from '@suid/icons-material/EditOutlined';
+import Extension from '@suid/icons-material/ExtensionOutlined';
 import Close from '@suid/icons-material/CloseOutlined';
 import ManageAccounts from '@suid/icons-material/ManageAccountsOutlined';
 import Sidebar from './sidebar/sidebar';
@@ -17,8 +19,11 @@ import FAQ from './components/faq';
 import ContactComponent from './components/contact';
 import OptionsComponent from './components/options/options';
 import Accounts from './components/accounts';
-import { EditsModal } from './components/options/edited-tracks';
+import { EditsModal } from './components/edit-options/edited-tracks';
 import Permissions from './components/permissions';
+import { RegexEditsModal } from './components/edit-options/regex-edits';
+import ConnectorOverrideOptions from './components/connector-override';
+import EditOptions from './components/edit-options/edit-options';
 
 /**
  * All the different options pages, their sidebar labels, and icons.
@@ -34,6 +39,16 @@ export type Settings =
 			icon: typeof Settings;
 			element: typeof OptionsComponent;
 	  }
+	| {
+			namei18n: 'optionsEdits';
+			icon: typeof Edit;
+			element: typeof EditOptions;
+	  }
+	| {
+			namei18n: 'optionsConnectors';
+			icon: typeof Extension;
+			element: typeof ConnectorOverrideOptions;
+	  }
 	| {
 			namei18n: 'contactTitle';
 			icon: typeof Contacts;
@@ -61,6 +76,12 @@ export type Settings =
 const settings: Settings[] = [
 	{ namei18n: 'optionsAccounts', icon: ManageAccounts, element: Accounts },
 	{ namei18n: 'optionsOptions', icon: Settings, element: OptionsComponent },
+	{ namei18n: 'optionsEdits', icon: Edit, element: EditOptions },
+	{
+		namei18n: 'optionsConnectors',
+		icon: Extension,
+		element: ConnectorOverrideOptions,
+	},
 	{ namei18n: 'contactTitle', icon: Contacts, element: ContactComponent },
 	{ namei18n: 'faqTitle', icon: Help, element: FAQ },
 	{ namei18n: 'optionsAbout', icon: Info, element: InfoComponent },
@@ -117,6 +138,9 @@ function Options() {
 						<Match when={activeModal() === 'savedEdits'}>
 							<EditsModal />
 						</Match>
+						<Match when={activeModal() === 'regexEdits'}>
+							<RegexEditsModal />
+						</Match>
 					</Switch>
 				</div>
 				<button
diff --git a/src/ui/options/sidebar/sidebar.module.scss b/src/ui/options/sidebar/sidebar.module.scss
index 749b459c..de9e5f48 100644
--- a/src/ui/options/sidebar/sidebar.module.scss
+++ b/src/ui/options/sidebar/sidebar.module.scss
@@ -1,28 +1,32 @@
-.sidebar {
+.sidebarWrapper {
 	background-color: var(--sidebar-bg);
 	flex-shrink: 0;
+	height: 100vh;
 	margin-right: 1.5em;
+	overflow-y: auto;
 
-	.sidebarButton {
-		align-items: center;
-		background-color: transparent;
-		border: none;
-		color: var(--text);
-		cursor: pointer;
-		display: flex;
-		font-size: 1.2em;
-		padding: 1em 2em;
-		width: 100%;
+	.sidebar {
+		.sidebarButton {
+			align-items: center;
+			background-color: transparent;
+			border: none;
+			color: var(--text);
+			cursor: pointer;
+			display: flex;
+			font-size: 1.2em;
+			padding: 1em 2em;
+			width: 100%;
 
-		svg {
-			margin-bottom: 3px;
-			margin-right: 0.2em;
-		}
+			svg {
+				margin-bottom: 3px;
+				margin-right: 0.2em;
+			}
 
-		&.active,
-		&:hover {
-			background-color: var(--last-fm);
-			color: var(--always-light);
+			&.active,
+			&:hover {
+				background-color: var(--last-fm);
+				color: var(--always-light);
+			}
 		}
 	}
 }
diff --git a/src/ui/options/sidebar/sidebar.tsx b/src/ui/options/sidebar/sidebar.tsx
index 73b765b9..e32946b4 100644
--- a/src/ui/options/sidebar/sidebar.tsx
+++ b/src/ui/options/sidebar/sidebar.tsx
@@ -38,16 +38,18 @@ export default function Sidebar(props: {
 }) {
 	const { items, activeSetting, setActiveSetting } = props;
 	return (
-		<nav class={styles.sidebar}>
-			<For each={items}>
-				{(item) => (
-					<SidebarButton
-						item={item}
-						activeSetting={activeSetting}
-						setActiveSetting={setActiveSetting}
-					/>
-				)}
-			</For>
-		</nav>
+		<div class={styles.sidebarWrapper}>
+			<nav class={styles.sidebar}>
+				<For each={items}>
+					{(item) => (
+						<SidebarButton
+							item={item}
+							activeSetting={activeSetting}
+							setActiveSetting={setActiveSetting}
+						/>
+					)}
+				</For>
+			</nav>
+		</div>
 	);
 }
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/connectors.test.ts b/tests/background/connectors.test.ts
new file mode 100644
index 00000000..aacde5ec
--- /dev/null
+++ b/tests/background/connectors.test.ts
@@ -0,0 +1,99 @@
+import fs from 'fs';
+import path from 'path';
+
+import { expect, assert, describe, it } from 'vitest';
+
+import connectors, { ConnectorMeta } from '@/core/connectors';
+import * as UrlMatch from '@/util/url-match';
+
+const PROP_TYPES: {
+	allFrames: 'boolean';
+	matches: 'array';
+	label: 'string';
+	js: 'string';
+	id: 'string';
+} = {
+	allFrames: 'boolean',
+	matches: 'array',
+	label: 'string',
+	js: 'string',
+	id: 'string',
+};
+const REQUIRED_PROPS: ['label', 'js', 'id'] = ['label', 'js', 'id'];
+
+function testProps(entry: ConnectorMeta) {
+	for (const prop of REQUIRED_PROPS) {
+		assert(entry[prop], `Missing property: ${prop}`);
+	}
+
+	for (const _prop in entry) {
+		const prop = _prop as keyof ConnectorMeta;
+		const type = PROP_TYPES[prop];
+
+		assert(type, `Missing property: ${prop}`);
+		expect(entry[prop]).to.be.a(type);
+	}
+}
+
+function testMatches(entry: ConnectorMeta) {
+	if (!entry.matches) {
+		return;
+	}
+
+	assert(entry.matches.length !== 0, 'Property is empty: matches');
+
+	for (const m of entry.matches) {
+		assert(UrlMatch.createPattern(m), `URL pattern is invalid: ${m}`);
+	}
+}
+
+function testPaths(entry: ConnectorMeta) {
+	if (!entry.js) {
+		return;
+	}
+
+	const jsPath = path.join(
+		__dirname,
+		'../../src/connectors',
+		entry.js.replace('.js', '.ts')
+	);
+	try {
+		fs.statSync(jsPath);
+	} catch (e) {
+		throw new Error(`File is missing: ${entry.js}`);
+	}
+}
+
+function testUniqueness(entry: ConnectorMeta) {
+	for (const connector of connectors) {
+		if (connector.label === entry.label) {
+			continue;
+		}
+
+		assert(entry.id !== connector.id, `Id is not unique: ${entry.label}`);
+	}
+}
+
+function runTests() {
+	for (const entry of connectors) {
+		describe(entry.label, () => {
+			it('should have valid prop types', () => {
+				testProps(entry);
+			});
+
+			it('should have valid URL matches', () => {
+				testMatches(entry);
+			});
+
+			it('should have js files for', () => {
+				testPaths(entry);
+			});
+
+			it('should have unique id', () => {
+				testUniqueness(entry);
+			});
+		});
+	}
+}
+
+runTests();
diff --git a/tests/background/custom-patterns.test.ts b/tests/background/custom-patterns.test.ts
new file mode 100644
index 00000000..c92c1fc9
--- /dev/null
+++ b/tests/background/custom-patterns.test.ts
@@ -0,0 +1,42 @@
+import { expect, describe, it, beforeAll, afterAll } from 'vitest';
+
+import webextensionPolyfill from '#/mocks/webextension-polyfill';
+import * as CustomPatterns from '@/core/storage/custom-patterns';
+
+/**
+ * Run all tests.
+ */
+function runTests() {
+	describe('Test custom patterns', () => {
+		beforeAll(() => {
+			webextensionPolyfill.reset();
+		});
+
+		afterAll(() => {
+			webextensionPolyfill.reset();
+		});
+
+		it('should return null before patterns have been configured', async () => {
+			const data = await CustomPatterns.getAllPatterns();
+			expect(data).to.be.null;
+		});
+
+		it('should set patterns for connector', async () => {
+			const patterns = ['1', '2'];
+			const expectedData = {
+				connector: patterns,
+			};
+			await CustomPatterns.setPatterns('connector', patterns);
+			const data = await CustomPatterns.getAllPatterns();
+			expect(expectedData).to.deep.equal(data);
+		});
+
+		it('should clear custom patterns', async () => {
+			await CustomPatterns.resetPatterns('connector');
+			const data = await CustomPatterns.getAllPatterns();
+			expect(data).to.deep.equal({});
+		});
+	});
+}
+
+runTests();
diff --git a/tests/background/regex-edits.test.ts b/tests/background/regex-edits.test.ts
new file mode 100644
index 00000000..d38b989e
--- /dev/null
+++ b/tests/background/regex-edits.test.ts
@@ -0,0 +1,232 @@
+import webextensionPolyfill 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 pipeline = new Pipeline();
+
+describe('Should edit Regex', () => {
+	beforeEach(() => {
+		webextensionPolyfill.reset();
+		webextensionPolyfill.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/background/saved-edits.test.ts b/tests/background/saved-edits.test.ts
new file mode 100644
index 00000000..b52fb1d5
--- /dev/null
+++ b/tests/background/saved-edits.test.ts
@@ -0,0 +1,237 @@
+import { expect, describe, it, beforeAll } from 'vitest';
+
+import webextensionPolyfill from '#/mocks/webextension-polyfill';
+('#/mocks/webextension-polyfill');
+
+import Song from '@/core/object/song';
+import { getConnectorById } from '@/util/util-connector';
+import { SavedEdit } from '@/core/storage/options';
+import savedEdits from '@/core/storage/saved-edits';
+import SavedEditsModel from '@/core/storage/saved-edits.model';
+
+const editedInfo = {
+	artist: 'ArtistEdited',
+	album: 'AlbumEdited',
+	track: 'TrackEdited',
+	albumArtist: null,
+};
+
+const connectorStub = getConnectorById('youtube')!;
+/**
+ * Run all tests.
+ */
+function runTests() {
+	describe('should throw an error for empty songs', testSaveEmptySong);
+
+	describe('clear storage', testClearStorage);
+	describe('should not load not a song', testLoadNoSavedSong);
+	describe('should remove song from storage', testRemoveSongInfo);
+	describe('should overwrite edited song info', testSaveOverwriteSong);
+	describe(
+		'should save and load a song with unique ID',
+		testSaveLoadSongWithId
+	);
+	describe(
+		'should save and load a song with no unique ID',
+		testSaveLoadSongWithNoId
+	);
+	describe(
+		'should save and load a song (with fallback)',
+		testSaveLoadSongFallback
+	);
+}
+
+function testSaveEmptySong() {
+	emptySavedEdits();
+	const emptySong = makeNonProcessedSong();
+
+	it('should throw an error while loading info of an empty song', async () => {
+		expect(savedEdits.loadSongInfo(emptySong)).rejects.to.deep.equal(
+			new Error('Empty song')
+		);
+	});
+
+	it('should throw an error while saving an empty song', async () => {
+		expect(
+			savedEdits.saveSongInfo(emptySong, editedInfo)
+		).rejects.to.deep.equal(new Error('Empty song'));
+	});
+}
+
+function testClearStorage() {
+	const song = makeNonProcessedSong('Artist', 'Track', 'Album');
+	const savedEdits = makeSavedEdits({
+		song,
+		edit: editedInfo,
+	});
+
+	it('should clear storage', () => {
+		return savedEdits.clear();
+	});
+
+	it('should not load song', async () => {
+		await expectSongInfoNotLoaded(savedEdits, song);
+	});
+}
+
+function testLoadNoSavedSong() {
+	emptySavedEdits();
+
+	const song = makeNonProcessedSong('Artist', 'Track', 'Album');
+
+	it('should not load not saved song', async () => {
+		const songInfo = await savedEdits.loadSongInfo(song);
+		expect(songInfo).to.be.false;
+	});
+}
+
+function testSaveLoadSongWithNoId() {
+	beforeAll(() => {
+		emptySavedEdits();
+	});
+
+	const songWithNoId = makeNonProcessedSong('Artist', 'Track', 'Album');
+
+	it('should return false for song with no unique ID', async () => {
+		await expectSongInfoNotLoaded(savedEdits, songWithNoId);
+	});
+
+	it('should save edited info of song with no unique ID', () => {
+		return savedEdits.saveSongInfo(songWithNoId, editedInfo);
+	});
+
+	it('should load edited info of song with no unique ID', async () => {
+		await expectSongInfoLoaded(savedEdits, songWithNoId, editedInfo);
+	});
+}
+
+function testSaveLoadSongWithId() {
+	beforeAll(() => {
+		emptySavedEdits();
+	});
+	const songWitId = makeNonProcessedSong(
+		'Artist',
+		'Track',
+		'Album',
+		'uniqueId'
+	);
+
+	it('should return false for song with unique ID', async () => {
+		await expectSongInfoNotLoaded(savedEdits, songWitId);
+	});
+
+	it('should save edited info of song with unique ID', () => {
+		return savedEdits.saveSongInfo(songWitId, editedInfo);
+	});
+
+	it('should load edited info of song with unique ID', async () => {
+		await expectSongInfoLoaded(savedEdits, songWitId, editedInfo);
+	});
+}
+
+function testSaveOverwriteSong() {
+	emptySavedEdits();
+
+	const editedInfo1 = {
+		artist: 'ArtistEdited1',
+		album: 'AlbumEdited1',
+		track: 'TrackEdited1',
+		albumArtist: null,
+	};
+	const editedInfo2 = {
+		artist: 'ArtistEdited2',
+		album: 'AlbumEdited2',
+		track: 'TrackEdited2',
+		albumArtist: null,
+	};
+	const song = makeNonProcessedSong('Artist', 'Track', 'Album');
+
+	it('should save edited info of song with no album', async () => {
+		await savedEdits.saveSongInfo(song, editedInfo1);
+		await savedEdits.saveSongInfo(song, editedInfo2);
+
+		await expectSongInfoLoaded(savedEdits, song, editedInfo2);
+	});
+}
+
+function testSaveLoadSongFallback() {
+	emptySavedEdits();
+
+	const songWithNoAlbum = makeNonProcessedSong('Artist', 'Track');
+	const songWithAlbum = makeNonProcessedSong('Artist', 'Track', 'Album');
+
+	it('should save edited info of song with no album', () => {
+		return savedEdits.saveSongInfo(songWithNoAlbum, editedInfo);
+	});
+
+	it('should load edited info of song with no album', async () => {
+		await expectSongInfoLoaded(savedEdits, songWithNoAlbum, editedInfo);
+	});
+
+	it('should load edited info of song with album', async () => {
+		await expectSongInfoLoaded(savedEdits, songWithAlbum, editedInfo);
+	});
+}
+
+function testRemoveSongInfo() {
+	const song = makeNonProcessedSong('Artist', 'Track', 'Album');
+	const savedEdits = makeSavedEdits({
+		song,
+		edit: editedInfo,
+	});
+
+	it('should remove saved song', async () => {
+		await savedEdits.removeSongInfo(song);
+	});
+
+	it('should not load removed song', async () => {
+		await expectSongInfoNotLoaded(savedEdits, song);
+	});
+}
+
+function makeNonProcessedSong(
+	artist?: string,
+	track?: string,
+	album?: string,
+	uniqueID?: string
+) {
+	return new Song({ artist, track, album, uniqueID }, connectorStub);
+}
+
+async function expectSongInfoLoaded(
+	model: SavedEditsModel,
+	song: Song,
+	editedInfo: SavedEdit
+) {
+	const isLoaded = await model.loadSongInfo(song);
+
+	const { artist, album, track, albumArtist } = song.processed;
+	const loadedInfo = { artist, album, track, albumArtist };
+
+	expect(isLoaded).to.be.true;
+	expect(loadedInfo).to.be.deep.equal(editedInfo);
+}
+
+async function expectSongInfoNotLoaded(model: SavedEditsModel, song: Song) {
+	const songInfo = await model.loadSongInfo(song);
+	expect(songInfo).be.false;
+}
+
+function makeSavedEdits(
+	...edits: {
+		song: Song;
+		edit: SavedEdit;
+	}[]
+) {
+	webextensionPolyfill.reset();
+	for (const edit of edits) {
+		savedEdits.saveSongInfo(edit.song, edit.edit);
+	}
+	return savedEdits;
+}
+
+function emptySavedEdits() {
+	webextensionPolyfill.reset();
+}
+
+runTests();
diff --git a/tests/background/storage.test.ts b/tests/background/storage.test.ts
new file mode 100644
index 00000000..fcb9cf3c
--- /dev/null
+++ b/tests/background/storage.test.ts
@@ -0,0 +1,96 @@
+/**
+ * Tests for 'BrowserStorage' module.
+ */
+
+import '#/mocks/webextension-polyfill';
+import * as BrowserStorage from '@/core/storage/browser-storage';
+import StorageWrapper from '@/core/storage/wrapper';
+import { describe, it, expect } from 'vitest';
+
+/**
+ * Test storage object.
+ * @param type - Storage type
+ * @param storage - Storage instance
+ */
+function testStorage<K extends BrowserStorage.StorageNamespace>(
+	type: K,
+	storage: StorageWrapper<K>
+) {
+	describe(`${type} storage`, () => {
+		it('should return empty object', async () => {
+			const data = await storage.get();
+			expect(data).to.be.null;
+		});
+
+		it('should set key value', async () => {
+			const newData = { test: 'ok' };
+			// @ts-expect-error
+			await storage.set(newData);
+
+			const data = await storage.get();
+			expect(newData).to.be.deep.equal(data);
+		});
+
+		it('should update storage', async () => {
+			const newData = { test: 'ok', key: 'value' };
+			const dataToAdd = { key: 'value' };
+			// @ts-expect-error
+			await storage.update(dataToAdd);
+			const data = await storage.get();
+			expect(newData).to.deep.equal(data);
+		});
+
+		it('should clear storage', async () => {
+			await storage.clear();
+			const data = await storage.get();
+			expect(data).to.be.null;
+		});
+	});
+}
+
+/**
+ * Run all tests.
+ */
+function runTests() {
+	const storages = {
+		// @ts-expect-error
+		local: BrowserStorage.getLocalStorage('Local'),
+		// @ts-expect-error
+		sync: BrowserStorage.getSyncStorage('Sync'),
+	};
+
+	for (const type in storages) {
+		// @ts-expect-error
+		const storage = storages[type];
+		// @ts-expect-error
+		testStorage(type, storage);
+	}
+
+	testBrowserStorage();
+}
+
+function testBrowserStorage() {
+	it('should return local storage', () => {
+		const storage = BrowserStorage.getScrobblerStorage('LastFM');
+		expect(storage).to.be.an('object');
+	});
+
+	it('should return local storage', () => {
+		const storage = BrowserStorage.getStorage(BrowserStorage.CORE);
+		expect(storage).to.be.an('object');
+	});
+
+	it('should return sync storage', () => {
+		const storage = BrowserStorage.getStorage(BrowserStorage.OPTIONS);
+		expect(storage).to.be.an('object');
+	});
+
+	it('should throw error for unknown storage', () => {
+		expect(() => {
+			// @ts-expect-error
+			return BrowserStorage.getStorage('unknown-storage123');
+		}).to.throw();
+	});
+}
+
+runTests();
diff --git a/tests/mocks/webextension-polyfill.ts b/tests/mocks/webextension-polyfill.ts
new file mode 100644
index 00000000..db89a018
--- /dev/null
+++ b/tests/mocks/webextension-polyfill.ts
@@ -0,0 +1,61 @@
+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(),
+	},
+};
+
+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();
+	}
+}
+
+export default new WebextensionPolyfillMocker();
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'),
 		},
 	},
 };