Repository: web-scrobbler/web-scrobbler
Pull request #4477 (2024-01-31 16:49:32)

Add NTS Radio connector
**Describe the changes you made**
Adding a connector for NTS radio (requires paid membership). This is a simple approach that works but requires the user to manually select the channel they are listening to in the nts.live/live-tracklist window they have open (the connector is only associated with this URL). There is no play / pause functionality as a result, meaning a user must manually close the live tracklist when they are done.

NOTE: I have included some commented code which I thought should stop tracks being repeatedly scrobbled if a user changes from and then back to a channel with the same song still playing. In practice this behaved a bit strangely and sometimes the initial channel then wouldn't scrobble at all. Is there any need for this anyway (I can't tell if the extension already has some way of stopping this from happening, but refreshing a page etc seems to cause repeats no matter what).

**Additional context**
I have had a long conversation with @yayuyokitano about this on the discord. I believe there will be a more complex way that potentially works better than this, but for now this works reasonably well and with the caveats above I think this is of interest to users of web scrobbler.


HarrisonWilde: Yes @yayuyokitano it does work, just checked.
yayuyokitano: Ah that one is probably my bad, could you run `npm run prettierfix` and it should fix up the linter error
HarrisonWilde: Oops will fix lint in a min

Patch:
diff --git a/src/.eslintrc.yml b/src/.eslintrc.yml
deleted file mode 100644
index a8013b10..00000000
--- a/src/.eslintrc.yml
+++ /dev/null
@@ -1,5 +0,0 @@
-env:
-  browser: true
-globals:
-  chrome: readonly
-  define: readonly
diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json
index 48f99967..03d8d006 100644
--- a/src/_locales/en/messages.json
+++ b/src/_locales/en/messages.json
@@ -251,6 +251,54 @@
         "message": "Options",
         "description": "'Options' section"
     },
+    "optionsScrobbleCache": {
+        "message": "Cache",
+        "description": "'Scrobble Cache' section"
+    },
+    "optionsScrobbleCacheSelectAll": {
+        "message": "Select All",
+        "description": "Select All button label"
+    },
+    "optionsScrobbleCacheUnselectAll": {
+        "message": "Unselect All",
+        "description": "Unselect All button label"
+    },
+    "optionsScrobbleCacheSelectScrobbles": {
+        "message": "Select Scrobbles",
+        "description": "Select Scrobbles button label"
+    },
+    "optionsScrobbleCacheScrobbleSelected": {
+        "message": "Scrobble Selected",
+        "description": "Scrobble Selected button label"
+    },
+    "optionsScrobbleCacheEditAll": {
+        "message": "Edit and scrobble all unscrobbled instances of this song",
+        "description": "Label for checkbox to apply cache edit to all unscrobbled instances of the same track in cache."
+    },
+    "optionsScrobbleCacheLoading": {
+        "message": "Loading scrobble cache...",
+        "description": "'Scrobble Cache' section"
+    },
+    "optionsScrobbleCacheSuccess": {
+        "message": "Successful Scrobbles",
+        "description": "'Scrobble Cache' successful scrobbles section header"
+    },
+    "optionsScrobbleCacheError": {
+        "message": "Errored Scrobbles",
+        "description": "'Scrobble Cache' errored scrobbles section header"
+    },
+    "optionsScrobbleCacheIgnored": {
+        "message": "Ignored Scrobbles",
+        "description": "'Scrobble Cache' ignored scrobbles section header"
+    },
+    "optionsScrobbleCacheDisallowed": {
+        "message": "Disallowed Scrobbles",
+        "description": "'Scrobble Cache' disallowed scrobbles section header"
+    },
+    "optionsScrobbleCacheUnrecognized": {
+        "message": "Unrecognized Scrobbles",
+        "description": "'Scrobble Cache' unrecognized scrobbles section header"
+    },
     "optionsEdits": {
         "message": "Edits",
         "description": "'Edits' section"
@@ -531,6 +579,10 @@
         "message": "From \"$1\" album",
         "description": "Tooltip text"
     },
+    "buttonScrobble": {
+        "message": "Scrobble",
+        "description": "Scrobble button label"
+    },
     "buttonOk": {
         "message": "OK",
         "description": "OK button label"
@@ -547,6 +599,10 @@
         "message": "Clear",
         "description": "Clear button label"
     },
+    "buttonRemove": {
+        "message": "Remove",
+        "description": "Remove button label, for removing a single scrobble from the cache"
+    },
     "noItemsInCache": {
         "message": "No items in cache.",
         "description": "Popup hint"
@@ -593,43 +649,51 @@
     },
     "disallowedDesc1": {
         "message": "Web Scrobbler has detected the song \"$2\" by \"$1\", but will not scrobble it.",
-        "description": "The description of 'disallowed' popup on all sites including youtube. $1 is the artist detected. $2 is the track detected"
+        "description": "The description of 'disallowed' popup. $1 is the artist detected. $2 is the track detected"
+    },
+    "disallowedDescIsAd": {
+        "message": "The reason is that Web Scrobbler has detected this as being an ad.",
+        "description": "The description of 'disallowed' popup."
+    },
+    "disallowedDescForbiddenYouTubeCategory": {
+      "message": "You have disabled scrobbling youtube video categories other than music and/or entertainment, and this video is marked by the uploader as a different category. You can choose to scrobble any video category by disabling both \"Scrobble videos from \"Music\" category\" and \"Scrobble videos from \"Entertainment\" category\" in Web Scrobbler settings.",
+      "description": "The description of 'disallowed' popup."
     },
-    "disallowedDescYoutube": {
-        "message": "On YouTube, the most common causes include:",
-        "description": "The description of 'disallowed' popup on youtube."
+    "disallowedDescForbiddenTag": {
+      "message": "You have disabled scrobbling this artist, album, and/or track. You can re-enable tags in settings, or by hitting \"Scrobble anyway\" and re-enabling the tags in regex editing.",
+      "description": "The description of 'disallowed' popup."
     },
-    "disallowedDescYoutubePoint1": {
-        "message": "Having \"Scrobble videos from \"Music/Entertainment\" category\" enabled and playing a video with a different category.",
-        "description": "Point 1 in example list on youtube"
+    "disallowedDescForbiddenChannel": {
+      "message": "You have disabled scrobbling this channel. You can re-enable it in connector settings, or by right clicking the extension icon and clicking \"Enable scrobbling from <ID>\".",
+      "description": "The description of 'disallowed' popup."
     },
-    "disallowedDescYoutubePoint2": {
-        "message": "Having \"Scrobble only videos that are recognized by YouTube Music as music\" enabled and playing a song not recognized by YouTube Music as music.",
-        "description": "Point 2 in example list on youtube"
+    "disallowedDescNotOnYouTubeMusic": {
+      "message": "YouTube Music does not recognize this video as music, and you have enabled the option \"Scrobble only videos that are recognized by YouTube Music as music\" in settings.",
+      "description": "The description of 'disallowed' popup."
     },
-    "disallowedDescYoutubePoint3": {
-        "message": "Ads (even ads being blocked may temporarily show this.)",
-        "description": "Point 3 in example list on youtube"
+    "disallowedDescIsPlayingElsewhere": {
+      "message": "The reason is that Web Scrobbler thinks this track is playing elsewhere and should not be scrobbled.",
+      "description": "The description of 'disallowed' popup."
     },
-    "disallowedDesc2": {
-        "message": "This is because the website connector has deemed this not a song to scrobble. Some potential reasons include:",
-        "description": "The description of 'disallowed' popup on sites except youtube"
+    "disallowedDescElementMissing": {
+      "message": "The reason is that some element Web Scrobbler relies on is not in the page. This probably means that either you are not playing music, or there is a bug in the connector.",
+      "description": "The description of 'disallowed' popup."
     },
-    "disallowedDesc2Point1": {
-        "message": "Web Scrobbler might be interpreting this as an ad.",
-        "description": "Point 1 in example list on sites except youtube"
+    "disallowedDescFilteredTag": {
+      "message": "Either the artist, album, or track has been filtered by the connector as something that should not be scrobbled. This is not due to any setting you have set in your settings.",
+      "description": "The description of 'disallowed' popup."
     },
-    "disallowedDesc2Point2": {
-        "message": "Web Scrobbler might be identifying this song as being played on a different device.",
-        "description": "Point 2 in example list on sites except youtube"
+    "disallowedDescIsLoading": {
+      "message": "Something Web Scrobbler relies on for finding the track details is loading. Please wait a few seconds. If this message persists, please report it, as something is wrong.",
+      "description": "The description of 'disallowed' popup."
     },
-    "disallowedDesc2Point3": {
-        "message": "Web Scrobbler, for some other reason, believes this is not something that should be scrobbled.",
-        "description": "Point 3 in example list on sites except youtube"
+    "disallowedDescOther": {
+      "message": "For some unknown reason, Web Scrobbler has determined this should not scrobble. This is not related to any setting you have set. If you believe this is an error, please report it.",
+      "description": "The description of 'disallowed' popup."
     },
     "disallowedDesc3": {
-        "message": "Web Scrobbler should still scrobble music from elsewhere on this website.",
-        "description": "The description of 'disallowed' popup on all sites including youtube"
+      "message": "Web Scrobbler should still scrobble music from elsewhere on this website.",
+      "description": "The description of 'disallowed' popup on all sites including youtube"
     },
     "disallowedButton": {
         "message": "Scrobble anyway",
diff --git a/src/connectors/1001tracklists.ts b/src/connectors/1001tracklists.ts
index 65c3d577..b0e9e719 100644
--- a/src/connectors/1001tracklists.ts
+++ b/src/connectors/1001tracklists.ts
@@ -45,7 +45,7 @@ Connector.getDuration = () => {
 Connector.isPlaying = () =>
 	Util.hasElementClass('#playerWidgetPause', 'fa-pause');
 
-Connector.isScrobblingAllowed = () => {
+Connector.scrobblingDisallowedReason = () => {
 	nextCue = parseInt(
 		Util.getAttrFromSelectors('.cPlay ~ .tlpTog input', 'value') ?? '',
 	);
@@ -57,5 +57,5 @@ Connector.isScrobblingAllowed = () => {
 		'mashupTrack',
 	);
 
-	return noIDs <= 0 && (cue > 0 || nextCue > 0) && !mashup;
+	return noIDs <= 0 && (cue > 0 || nextCue > 0) && !mashup ? null : 'Other';
 };
diff --git a/src/connectors/allclassical.org.ts b/src/connectors/allclassical.org.ts
index 060f03d5..52590229 100644
--- a/src/connectors/allclassical.org.ts
+++ b/src/connectors/allclassical.org.ts
@@ -8,5 +8,7 @@ Connector.artistSelector = '#composer';
 
 Connector.playButtonSelector = '#live-play';
 
-Connector.isScrobblingAllowed = () =>
-	Util.getTextFromSelectors('.nowplaying-label') === 'Now Playing';
+Connector.scrobblingDisallowedReason = () =>
+	Util.getTextFromSelectors('.nowplaying-label') === 'Now Playing'
+		? null
+		: 'Other';
diff --git a/src/connectors/amazon.ts b/src/connectors/amazon.ts
index c6315212..40697e43 100644
--- a/src/connectors/amazon.ts
+++ b/src/connectors/amazon.ts
@@ -39,7 +39,7 @@ function setupPropertiesForNewPlayer() {
 		return { artist, track, trackArt };
 	};
 
-	Connector.isScrobblingAllowed = () => {
+	Connector.scrobblingDisallowedReason = () => {
 		const trackLink = Util.getAttrFromSelectors(
 			trackContainerSelector,
 			'primary-href',
@@ -47,7 +47,7 @@ function setupPropertiesForNewPlayer() {
 
 		// NOTE Regular tracks have no link
 		// Check this condition first if the connector does not work
-		return trackLink === '#';
+		return trackLink === '#' ? null : 'IsAd';
 	};
 
 	Connector.pauseButtonSelector = `${playerBarSelector} music-button[icon-name=pause]`;
diff --git a/src/connectors/audacy.ts b/src/connectors/audacy.ts
index d93321e9..bbbdf015 100644
--- a/src/connectors/audacy.ts
+++ b/src/connectors/audacy.ts
@@ -62,14 +62,18 @@ Connector.isPlaying = () => {
 	return buttonPlaying === 'Pause' || buttonPlaying === 'Stop';
 };
 
-Connector.isScrobblingAllowed = () => {
+Connector.scrobblingDisallowedReason = () => {
 	const artist = Connector.getArtist();
 	const track = Connector.getTrack();
-	return Boolean(
-		artist &&
-			!artist.includes('Audacy') &&
-			track &&
-			!track.startsWith('Advertisement') &&
-			artist !== track,
-	);
+
+	if (!artist || !track) {
+		return 'Other';
+	}
+	if (artist.includes('Audacy') || artist === track) {
+		return 'FilteredTag';
+	}
+	if (track.startsWith('Advertisement')) {
+		return 'IsAd';
+	}
+	return null;
 };
diff --git a/src/connectors/bilibili.ts b/src/connectors/bilibili.ts
index afbde312..dc1497d1 100644
--- a/src/connectors/bilibili.ts
+++ b/src/connectors/bilibili.ts
@@ -197,20 +197,20 @@ Connector.getChannelId = () =>
 
 Connector.channelLabelSelector = '.up-name';
 
-Connector.isScrobblingAllowed = () => {
+Connector.scrobblingDisallowedReason = () => {
 	const tags = videoInfo.tags;
 
 	if (onlyScrobbleContainsMusicTag) {
 		if (!tags.includes('音乐')) {
-			return false;
+			return 'FilteredTag';
 		}
 	}
 	if (useScrobbleTagFilter) {
 		if (tags.some((tag) => isIncludeElems(tag, tagFilterKeyWords))) {
-			return false;
+			return 'FilteredTag';
 		}
 	}
-	return true;
+	return null;
 };
 
 Connector.getArtistTrack = () => {
diff --git a/src/connectors/cpr-indie.ts b/src/connectors/cpr-indie.ts
index 6da113ce..a2eebc74 100644
--- a/src/connectors/cpr-indie.ts
+++ b/src/connectors/cpr-indie.ts
@@ -8,10 +8,10 @@ Connector.trackSelector = '.qtmplayer__songdata .qtmplayer__title';
 
 Connector.isPlaying = () => Util.hasElementClass('#qtmplayerNotif', 'playing');
 
-Connector.isScrobblingAllowed = () => {
-	return (
-		!Util.getTextFromSelectors('.qtmplayer__songdata')?.includes(
-			'Indie 102.3',
-		) && !Connector.getArtist()?.startsWith('with ')
-	);
+Connector.scrobblingDisallowedReason = () => {
+	return Util.getTextFromSelectors('.qtmplayer__songdata')?.includes(
+		'Indie 102.3',
+	) || Connector.getArtist()?.startsWith('with ')
+		? 'FilteredTag'
+		: null;
 };
diff --git a/src/connectors/cpr.ts b/src/connectors/cpr.ts
index b0e6a51f..1df5d770 100644
--- a/src/connectors/cpr.ts
+++ b/src/connectors/cpr.ts
@@ -26,7 +26,8 @@ Connector.getAlbum = () => {
 
 Connector.pauseButtonSelector = `${Connector.playerSelector} [class^=playPauseButton__icon-][class*=playPauseButton__pause-]`;
 
-Connector.isScrobblingAllowed = () => Boolean(Connector.getArtist());
+Connector.scrobblingDisallowedReason = () =>
+	Connector.getArtist() ? null : 'Other';
 
 Connector.isStateChangeAllowed = () => Boolean(Connector.getTrack());
 
diff --git a/src/connectors/dandelionradio.ts b/src/connectors/dandelionradio.ts
index 7f93e47b..e2362eb4 100644
--- a/src/connectors/dandelionradio.ts
+++ b/src/connectors/dandelionradio.ts
@@ -22,4 +22,5 @@ Connector.trackArtSelector = '#bio .img-left-text img';
 Connector.isPlaying = () =>
 	Util.hasElementClass('#jp_container_1', 'jp-state-playing');
 
-Connector.isScrobblingAllowed = () => Boolean(Connector.getTrackInfo());
+Connector.scrobblingDisallowedReason = () =>
+	Connector.getTrackInfo() ? null : 'Other';
diff --git a/src/connectors/decayfm.ts b/src/connectors/decayfm.ts
index 3b3c46e5..a95ae075 100644
--- a/src/connectors/decayfm.ts
+++ b/src/connectors/decayfm.ts
@@ -11,7 +11,13 @@ Connector.trackArtSelector = '.qtmplayer__covercontainer img';
 Connector.isPlaying = () =>
 	Util.getTextFromSelectors('#qtmplayerPlay .material-icons') === 'pause';
 
-Connector.isScrobblingAllowed = () => {
+Connector.scrobblingDisallowedReason = () => {
 	const artist = Connector.getArtist();
-	return Boolean(artist && !artist.match(/^DKFM|^ID\/PSA|^Advert:/));
+	if (!artist) {
+		return 'Other';
+	}
+	if (artist.match(/^DKFM|^ID\/PSA|^Advert:/)) {
+		return 'IsAd';
+	}
+	return null;
 };
diff --git a/src/connectors/gimmeradio.ts b/src/connectors/gimmeradio.ts
index de9e533e..dab19ffd 100644
--- a/src/connectors/gimmeradio.ts
+++ b/src/connectors/gimmeradio.ts
@@ -8,10 +8,10 @@ Connector.trackSelector = '.fZIbtd';
 
 Connector.onReady = Connector.onStateChanged;
 
-Connector.isScrobblingAllowed = () => {
-	return !(
-		Connector.getArtist()?.includes('Gimme Country') ||
+Connector.scrobblingDisallowedReason = () => {
+	return Connector.getArtist()?.includes('Gimme Country') ||
 		Connector.getArtist()?.includes('Gimme Radio') ||
 		Connector.getArtist()?.includes('Gimme Metal')
-	);
+		? 'FilteredTag'
+		: null;
 };
diff --git a/src/connectors/globalplayer.ts b/src/connectors/globalplayer.ts
index 016013c7..fa602b7a 100644
--- a/src/connectors/globalplayer.ts
+++ b/src/connectors/globalplayer.ts
@@ -12,9 +12,8 @@ Connector.playButtonSelector =
 	'button[data-testid="play-pause-button"][aria-pressed="false"]';
 Connector.trackArtSelector = 'div[class^="style_showInfoCard"] img.current-img';
 
-Connector.isScrobblingAllowed = () => {
-	return Connector.getTrack() !== getShowName();
-};
+Connector.scrobblingDisallowedReason = () =>
+	Connector.getTrack() === getShowName() ? 'FilteredTag' : null;
 
 function getShowName() {
 	return Util.getTextFromSelectors(showNameSelector);
diff --git a/src/connectors/idagio.ts b/src/connectors/idagio.ts
index 6f6a539b..71ea1916 100644
--- a/src/connectors/idagio.ts
+++ b/src/connectors/idagio.ts
@@ -25,9 +25,11 @@ Connector.durationSelector = '.player-PlayerProgress__timeTotal--3aHlj span';
 Connector.isPlaying = () =>
 	Util.getTextFromSelectors(pauseButtonSelector)?.toUpperCase() === 'PAUSE';
 
-Connector.isScrobblingAllowed = () =>
-	Util.getTextFromSelectors('.player-PlayerInfo__recordingInfo--15VMv') !==
-	'Sponsor message';
+Connector.scrobblingDisallowedReason = () =>
+	Util.getTextFromSelectors('.player-PlayerInfo__recordingInfo--15VMv') ===
+	'Sponsor message'
+		? 'IsAd'
+		: null;
 
 function getCurrentTrack() {
 	/*
diff --git a/src/connectors/iheart.ts b/src/connectors/iheart.ts
index 18d01f54..d6dc0557 100644
--- a/src/connectors/iheart.ts
+++ b/src/connectors/iheart.ts
@@ -11,7 +11,9 @@ Connector.artistSelector = `${playerBar} [data-test=line-text]:nth-child(3)`;
 
 Connector.trackSelector = `${playerBar} [data-test=line-text]:nth-child(2)`;
 
-Connector.isScrobblingAllowed = () => {
+Connector.scrobblingDisallowedReason = () => {
 	const track = Connector.getTrack();
-	return Boolean(track && !track.startsWith('Thanks for listening'));
+	return track && !track.startsWith('Thanks for listening')
+		? null
+		: 'FilteredTag';
 };
diff --git a/src/connectors/jamstash.ts b/src/connectors/jamstash.ts
index 9ea2bb70..30840854 100644
--- a/src/connectors/jamstash.ts
+++ b/src/connectors/jamstash.ts
@@ -20,6 +20,5 @@ Connector.currentTimeSelector = '#played';
 
 Connector.durationSelector = '#duration';
 
-Connector.isScrobblingAllowed = () => {
-	return Connector.getDuration() !== 0;
-};
+Connector.scrobblingDisallowedReason = () =>
+	Connector.getDuration() === 0 ? 'Other' : null;
diff --git a/src/connectors/jazzgroove.ts b/src/connectors/jazzgroove.ts
index 8306bf8e..22591fa0 100644
--- a/src/connectors/jazzgroove.ts
+++ b/src/connectors/jazzgroove.ts
@@ -22,7 +22,8 @@ Connector.isTrackArtDefault = () => {
 };
 
 // station bumpers and other spoken messages play with default images
-Connector.isScrobblingAllowed = () => !Connector.isTrackArtDefault();
+Connector.scrobblingDisallowedReason = () =>
+	Connector.isTrackArtDefault() ? 'Other' : null;
 
 Connector.isPlaying = () =>
 	!Util.hasElementClass(Connector.playerSelector, 'jg-player--paused');
diff --git a/src/connectors/kinknl.ts b/src/connectors/kinknl.ts
index 3328e2e8..98954358 100644
--- a/src/connectors/kinknl.ts
+++ b/src/connectors/kinknl.ts
@@ -16,5 +16,7 @@ Connector.isPlaying = () => {
 	);
 };
 
-Connector.isScrobblingAllowed = () =>
-	!Connector.getArtistTrack()?.artist?.startsWith('KINK');
+Connector.scrobblingDisallowedReason = () =>
+	Connector.getArtistTrack()?.artist?.startsWith('KINK')
+		? 'FilteredTag'
+		: null;
diff --git a/src/connectors/kyivstar.ts b/src/connectors/kyivstar.ts
index 79e15491..52c76284 100644
--- a/src/connectors/kyivstar.ts
+++ b/src/connectors/kyivstar.ts
@@ -27,4 +27,5 @@ Connector.isTrackArtDefault = (trackArtUrl) => {
 	return false;
 };
 
-Connector.isScrobblingAllowed = () => Connector.getArtist() !== 'Kyivstar';
+Connector.scrobblingDisallowedReason = () =>
+	Connector.getArtist() === 'Kyivstar' ? 'FilteredTag' : null;
diff --git a/src/connectors/live365.ts b/src/connectors/live365.ts
index 5bfe3b2f..49cd818c 100644
--- a/src/connectors/live365.ts
+++ b/src/connectors/live365.ts
@@ -37,15 +37,19 @@ const LIVE_365_ARTIST = 'Live365';
 const ADWTAG = 'Advert:';
 const ADBREAK = 'Ad Break';
 
-Connector.isScrobblingAllowed = () => {
+Connector.scrobblingDisallowedReason = () => {
 	const artist = Connector.getArtistTrack()?.artist;
-	return (
-		artist !== null &&
-		artist !== LIVE_365_ARTIST &&
-		artist !== 'undefined' &&
-		!artist?.includes(ADWTAG) &&
-		!artist?.includes(ADBREAK)
-	);
+	if (
+		artist === null ||
+		artist === LIVE_365_ARTIST ||
+		artist === 'undefined'
+	) {
+		return 'FilteredTag';
+	}
+	if (artist?.includes(ADWTAG) || artist?.includes(ADBREAK)) {
+		return 'IsAd';
+	}
+	return null;
 };
 
 /*
diff --git a/src/connectors/liveone.ts b/src/connectors/liveone.ts
index e8f6f103..56c74a99 100644
--- a/src/connectors/liveone.ts
+++ b/src/connectors/liveone.ts
@@ -16,6 +16,5 @@ Connector.durationSelector = '.vjs-duration-display';
 
 Connector.trackArtSelector = '.current-track-imgContainer img';
 
-Connector.isScrobblingAllowed = () => {
-	return Connector.getArtist() !== 'LiveOne';
-};
+Connector.scrobblingDisallowedReason = () =>
+	Connector.getArtist() === 'LiveOne' ? 'FilteredTag' : null;
diff --git a/src/connectors/mixcloud.ts b/src/connectors/mixcloud.ts
index 8c1e231b..18a8f44b 100644
--- a/src/connectors/mixcloud.ts
+++ b/src/connectors/mixcloud.ts
@@ -70,7 +70,8 @@ Connector.isStateChangeAllowed = () => {
 	);
 };
 
-Connector.isScrobblingAllowed = () => Connector.isStateChangeAllowed();
+Connector.scrobblingDisallowedReason = () =>
+	Connector.isStateChangeAllowed() ? null : 'ElementMissing';
 
 Connector.applyFilter(filter);
 
diff --git a/src/connectors/nts.ts b/src/connectors/nts.ts
new file mode 100644
index 00000000..3cf9e967
--- /dev/null
+++ b/src/connectors/nts.ts
@@ -0,0 +1,16 @@
+export {};
+
+Connector.playerSelector = '.page-live-tracks__body';
+
+Connector.artistSelector =
+	'.page-live-tracks__list li:first-child .page-live-tracks__artist-title';
+
+Connector.trackSelector =
+	'.page-live-tracks__list li:first-child .page-live-tracks__song-title';
+
+Connector.scrobbleInfoLocationSelector = '.page-live-tracks__title';
+Connector.scrobbleInfoStyle = {
+	...Connector.scrobbleInfoStyle,
+	fontSize: '0.7em',
+	marginTop: '0.7em',
+};
diff --git a/src/connectors/pandora.ts b/src/connectors/pandora.ts
index 309dbc2c..43b1b24a 100644
--- a/src/connectors/pandora.ts
+++ b/src/connectors/pandora.ts
@@ -24,6 +24,8 @@ Connector.getTrackArt = () => {
 
 Connector.pauseButtonSelector = '[data-qa="pause_button"]';
 
-Connector.isScrobblingAllowed = () => {
-	return !document.querySelector('.Tuner__Audio__TrackDetail__title--ad');
+Connector.scrobblingDisallowedReason = () => {
+	return document.querySelector('.Tuner__Audio__TrackDetail__title--ad')
+		? 'IsAd'
+		: null;
 };
diff --git a/src/connectors/poolsuite.ts b/src/connectors/poolsuite.ts
index 5444e797..832df77e 100644
--- a/src/connectors/poolsuite.ts
+++ b/src/connectors/poolsuite.ts
@@ -7,4 +7,5 @@ Connector.artistSelector = '.current-track h2';
 Connector.currentTimeSelector = '.timer span:nth-child(1)';
 Connector.durationSelector = '.timer span:nth-child(2)';
 
-Connector.isScrobblingAllowed = () => Connector.getTrack() !== 'Loading...';
+Connector.scrobblingDisallowedReason = () =>
+	Connector.getTrack() === 'Loading...' ? 'IsLoading' : null;
diff --git a/src/connectors/radio357.ts b/src/connectors/radio357.ts
index 46236efa..dee1fbc8 100644
--- a/src/connectors/radio357.ts
+++ b/src/connectors/radio357.ts
@@ -6,5 +6,7 @@ Connector.artistTrackSelector = '#web_player .r357p_song';
 
 Connector.isPlaying = () => Util.hasElementClass('#web_player', 'playing');
 
-Connector.isScrobblingAllowed = () =>
-	!Connector.getArtistTrack()?.artist?.includes('Radio 357');
+Connector.scrobblingDisallowedReason = () =>
+	Connector.getArtistTrack()?.artist?.includes('Radio 357')
+		? 'FilteredTag'
+		: null;
diff --git a/src/connectors/radio7.ts b/src/connectors/radio7.ts
index 4bb3e422..5932f553 100644
--- a/src/connectors/radio7.ts
+++ b/src/connectors/radio7.ts
@@ -6,6 +6,5 @@ Connector.artistTrackSelector = '#dziesma';
 
 Connector.pauseButtonSelector = '.amazingaudioplayer-pause';
 
-Connector.isScrobblingAllowed = () => {
-	return Connector.getArtist() !== 'Radio7';
-};
+Connector.scrobblingDisallowedReason = () =>
+	Connector.getArtist() === 'Radio7' ? 'FilteredTag' : null;
diff --git a/src/connectors/radioparadise.ts b/src/connectors/radioparadise.ts
index e8df64b4..0a3a1c6d 100644
--- a/src/connectors/radioparadise.ts
+++ b/src/connectors/radioparadise.ts
@@ -15,10 +15,10 @@ Connector.isPlaying = () => {
 
 Connector.trackArtSelector = '.player-bar img.player-cover';
 
-Connector.isScrobblingAllowed = () => {
-	return (
-		!Util.getTextFromSelectors('.player-bar .player-title')?.includes(
-			'Listener-supported',
-		) && !Connector.getArtist()?.startsWith('Commercial-Free')
-	);
+Connector.scrobblingDisallowedReason = () => {
+	return Util.getTextFromSelectors('.player-bar .player-title')?.includes(
+		'Listener-supported',
+	) || Connector.getArtist()?.startsWith('Commercial-Free')
+		? 'FilteredTag'
+		: null;
 };
diff --git a/src/connectors/redbull.ts b/src/connectors/redbull.ts
index 0832f868..eb0f002f 100644
--- a/src/connectors/redbull.ts
+++ b/src/connectors/redbull.ts
@@ -49,7 +49,7 @@ Connector.getArtistTrack = () => {
 
 Connector.playButtonSelector = 'rbPlyr-playPause:not(.rbPlyr-pause)';
 
-Connector.isScrobblingAllowed = () => {
+Connector.scrobblingDisallowedReason = () => {
 	const pageType = Util.getAttrFromSelectors(
 		'meta[property="og:type"]',
 		'content',
@@ -57,12 +57,12 @@ Connector.isScrobblingAllowed = () => {
 
 	const categoryLink = Util.getAttrFromSelectors('a[href*="/tags/"]', 'href');
 
-	return Boolean(
-		pageType &&
-			ALLOWED_TYPES.includes(pageType) &&
-			categoryLink &&
-			'music' === categoryLink.split('/').pop(),
-	);
+	return pageType &&
+		ALLOWED_TYPES.includes(pageType) &&
+		categoryLink &&
+		'music' === categoryLink.split('/').pop()
+		? null
+		: 'Other';
 };
 
 function getArtistTrackFrom(getter: string) {
diff --git a/src/connectors/refnet.ts b/src/connectors/refnet.ts
index bb203f15..ec3128b8 100644
--- a/src/connectors/refnet.ts
+++ b/src/connectors/refnet.ts
@@ -20,6 +20,5 @@ Connector.isPlaying = () => {
 	return Util.hasElementClass(Connector.playerSelector, 'playing');
 };
 
-Connector.isScrobblingAllowed = () => {
-	return Util.hasElementClass('#slots > .live', 'music');
-};
+Connector.scrobblingDisallowedReason = () =>
+	Util.hasElementClass('#slots > .live', 'music') ? null : 'Other';
diff --git a/src/connectors/rockandpopcl.ts b/src/connectors/rockandpopcl.ts
index be49b57c..5a88bb48 100644
--- a/src/connectors/rockandpopcl.ts
+++ b/src/connectors/rockandpopcl.ts
@@ -12,14 +12,14 @@ Connector.trackSelector = '.player-info-title .js-player-title';
 
 Connector.trackArtSelector = '.player-data .image-player';
 
-Connector.isScrobblingAllowed = () => {
-	return (
-		!Connector.getArtist()?.includes('24/7') &&
-		!Connector.getTrack()?.includes('24/7') &&
-		!Util.extractImageUrlFromSelectors(
-			Connector.trackArtSelector,
-		)?.includes('/fotos/original/')
-	);
+Connector.scrobblingDisallowedReason = () => {
+	return Connector.getArtist()?.includes('24/7') ||
+		Connector.getTrack()?.includes('24/7') ||
+		Util.extractImageUrlFromSelectors(Connector.trackArtSelector)?.includes(
+			'/fotos/original/',
+		)
+		? 'FilteredTag'
+		: null;
 };
 
 Connector.applyFilter(filter);
diff --git a/src/connectors/rockantenne.ts b/src/connectors/rockantenne.ts
index 4491de7f..ad621e72 100644
--- a/src/connectors/rockantenne.ts
+++ b/src/connectors/rockantenne.ts
@@ -10,6 +10,8 @@ Connector.isPlaying = () => {
 	return Util.hasElementClass('.c-player', 'is-playing');
 };
 
-Connector.isScrobblingAllowed = () => {
-	return !Connector.getArtist()?.includes('ROCK ANTENNE');
+Connector.scrobblingDisallowedReason = () => {
+	return Connector.getArtist()?.includes('ROCK ANTENNE')
+		? 'FilteredTag'
+		: null;
 };
diff --git a/src/connectors/siriusxm-player.ts b/src/connectors/siriusxm-player.ts
index 7948ef44..764c0d08 100644
--- a/src/connectors/siriusxm-player.ts
+++ b/src/connectors/siriusxm-player.ts
@@ -29,7 +29,7 @@ Connector.playButtonSelector =
 Connector.pauseButtonSelector =
 	"*[class^='_playbackControls_'] button[aria-label='Pause']";
 
-Connector.isScrobblingAllowed = () => {
+Connector.scrobblingDisallowedReason = () => {
 	const artist = Connector.getArtist()?.toLowerCase();
 	const track = Connector.getTrack()?.toLowerCase();
 	const filteredTerms = [
@@ -50,9 +50,11 @@ Connector.isScrobblingAllowed = () => {
 		'bdcast',
 	];
 
-	return !filteredTerms.some(
+	return filteredTerms.some(
 		(term) => artist?.includes(term) || track?.includes(term),
-	);
+	)
+		? 'FilteredTag'
+		: null;
 };
 
 Connector.applyFilter(filter);
diff --git a/src/connectors/spotify.ts b/src/connectors/spotify.ts
index d493c6d3..91f9ff9a 100644
--- a/src/connectors/spotify.ts
+++ b/src/connectors/spotify.ts
@@ -37,7 +37,8 @@ Connector.pauseButtonSelector = `${oldPauseButtonSelector}, ${newPauseButtonSele
 
 Connector.applyFilter(MetadataFilter.createSpotifyFilter());
 
-Connector.isScrobblingAllowed = () => Boolean(isMusicPlaying() && isMainTab());
+Connector.scrobblingDisallowedReason = () =>
+	isMusicPlaying() && isMainTab() ? null : 'IsPlayingElsewhere';
 
 Connector.isPodcast = () => isPodcastPlaying();
 
diff --git a/src/connectors/subfire.ts b/src/connectors/subfire.ts
index 97634118..28107034 100644
--- a/src/connectors/subfire.ts
+++ b/src/connectors/subfire.ts
@@ -29,5 +29,5 @@ Connector.getUniqueID = () => {
 	return (src && src.match(/id=([^&]*)/i)?.[1]) || null;
 };
 
-Connector.isScrobblingAllowed = () =>
-	Util.isElementVisible(Connector.playerSelector);
+Connector.scrobblingDisallowedReason = () =>
+	Util.isElementVisible(Connector.playerSelector) ? null : 'ElementMissing';
diff --git a/src/connectors/tidal.ts b/src/connectors/tidal.ts
index a86561cb..ca39fcb5 100644
--- a/src/connectors/tidal.ts
+++ b/src/connectors/tidal.ts
@@ -6,8 +6,8 @@ Connector.playButtonSelector = `${Connector.playerSelector} button[data-test="pl
 
 Connector.pauseButtonSelector = `${Connector.playerSelector} button[data-test="pause"]`;
 
-Connector.isScrobblingAllowed = () =>
-	!!Util.queryElements(Connector.playButtonSelector);
+Connector.scrobblingDisallowedReason = () =>
+	Util.queryElements(Connector.playButtonSelector) ? null : 'ElementMissing';
 
 Connector.trackSelector = [
 	'#nowPlaying div.react-tabs__tab-panel--selected > div > div:nth-child(1) > div:nth-child(1) > wave-text:nth-child(2)',
diff --git a/src/connectors/wdr.ts b/src/connectors/wdr.ts
index c1ef2dc5..1c9af703 100644
--- a/src/connectors/wdr.ts
+++ b/src/connectors/wdr.ts
@@ -12,7 +12,7 @@ Connector.artistTrackSelector = '.wdrrCurrentShowTitleTitle';
 
 Connector.pauseButtonSelector = '#playCtrl.playing';
 
-Connector.isScrobblingAllowed = () => {
+Connector.scrobblingDisallowedReason = () => {
 	const artistTrack = Util.getTextFromSelectors(
 		Connector.artistTrackSelector,
 	);
@@ -33,7 +33,7 @@ Connector.isScrobblingAllowed = () => {
 		);
 
 	// Scrobble only if none of the disallowed strings are included
-	return !containsDisallowedString;
+	return containsDisallowedString ? 'FilteredTag' : null;
 };
 
 function cleanupArtist(artist: string) {
diff --git a/src/connectors/xrayfm.ts b/src/connectors/xrayfm.ts
index 2f6e7136..bf5c4755 100644
--- a/src/connectors/xrayfm.ts
+++ b/src/connectors/xrayfm.ts
@@ -16,8 +16,8 @@ Connector.getArtistTrack = () => {
 	return null;
 };
 
-Connector.isScrobblingAllowed = () => {
-	return Boolean(
-		Connector.getArtistTrack() && Util.isElementVisible('#current-track'),
-	);
+Connector.scrobblingDisallowedReason = () => {
+	return Connector.getArtistTrack() && Util.isElementVisible('#current-track')
+		? 'ElementMissing'
+		: null;
 };
diff --git a/src/connectors/youtube-music.ts b/src/connectors/youtube-music.ts
index a42496a4..f7d763fc 100644
--- a/src/connectors/youtube-music.ts
+++ b/src/connectors/youtube-music.ts
@@ -114,7 +114,8 @@ Connector.getUniqueID = () => {
 	return Util.getYtVideoIdFromUrl(videoUrl);
 };
 
-Connector.isScrobblingAllowed = () => !Util.isElementVisible(adSelector);
+Connector.scrobblingDisallowedReason = () =>
+	Util.isElementVisible(adSelector) ? 'IsAd' : null;
 
 function getArtists() {
 	// FIXME Use Array.from after jQuery support will be removed
diff --git a/src/connectors/youtube.ts b/src/connectors/youtube.ts
index 0ff15237..43b2d650 100644
--- a/src/connectors/youtube.ts
+++ b/src/connectors/youtube.ts
@@ -181,14 +181,14 @@ Connector.getUniqueID = () => {
 	return getVideoId();
 };
 
-Connector.isScrobblingAllowed = () => {
+Connector.scrobblingDisallowedReason = () => {
 	if (document.querySelector('.ad-showing')) {
-		return false;
+		return 'IsAd';
 	}
 
 	// Workaround to prevent scrobbling the video opened in a background tab.
 	if (!isVideoStartedPlaying()) {
-		return false;
+		return 'Other';
 	}
 
 	if (scrobbleMusicRecognisedOnly) {
@@ -198,21 +198,21 @@ Connector.isScrobblingAllowed = () => {
 		if (!ytMusicCache) {
 			// start loading getTrackInfoFromYoutubeMusic
 			getTrackInfoFromYoutubeMusic();
-			return false;
+			return 'IsLoading';
 		}
 
 		if (!ytMusicCache.done) {
 			// not done loading yet
-			return false;
+			return 'IsLoading';
 		}
 
 		if (!ytMusicCache.recognisedByYtMusic) {
 			// not recognised!
-			return false;
+			return 'NotOnYouTubeMusic';
 		}
 	}
 
-	return isVideoCategoryAllowed();
+	return isVideoCategoryAllowed() ? null : 'ForbiddenYouTubeCategory';
 };
 
 Connector.applyFilter(
diff --git a/src/core/background/main.ts b/src/core/background/main.ts
index 553eb786..78279466 100644
--- a/src/core/background/main.ts
+++ b/src/core/background/main.ts
@@ -384,7 +384,12 @@ setupBackgroundListeners(
 	backgroundListener({
 		type: 'scrobble',
 		fn: (payload, sender) => {
-			return scrobble(new ClonedSong(payload.song, sender.tab?.id ?? -1));
+			return scrobble(
+				payload.songs.map(
+					(song) => new ClonedSong(song, sender.tab?.id ?? -1),
+				),
+				payload.currentlyPlaying,
+			);
 		},
 	}),
 
diff --git a/src/core/background/scrobble.ts b/src/core/background/scrobble.ts
index 244efc21..33efb4b6 100644
--- a/src/core/background/scrobble.ts
+++ b/src/core/background/scrobble.ts
@@ -25,9 +25,12 @@ export async function sendResumedPlaying(
 	return scrobbleService.sendResumedPlaying(song);
 }
 
-export async function scrobble(song: BaseSong): Promise<ServiceCallResult[]> {
+export async function scrobble(
+	songs: BaseSong[],
+	currentlyPlaying: boolean,
+): Promise<ServiceCallResult[][]> {
 	await scrobbleService.bindAllScrobblers();
-	return scrobbleService.scrobble(song);
+	return scrobbleService.scrobble(songs, currentlyPlaying);
 }
 
 export async function getSongInfo(
diff --git a/src/core/connectors.ts b/src/core/connectors.ts
index 7b2d5f12..124f4bff 100644
--- a/src/core/connectors.ts
+++ b/src/core/connectors.ts
@@ -2366,4 +2366,10 @@ export default <ConnectorMeta[]>[
 		js: 'zingmp3.js',
 		id: 'zingmp3',
 	},
+	{
+		label: 'NTS',
+		matches: ['*://*nts.live/live-tracklist/*'],
+		js: 'nts.js',
+		id: 'nts',
+	},
 ];
diff --git a/src/core/content/connector.ts b/src/core/content/connector.ts
index 1a2d5e50..6da85ff0 100644
--- a/src/core/content/connector.ts
+++ b/src/core/content/connector.ts
@@ -3,6 +3,7 @@ import browser from 'webextension-polyfill';
 import { ArtistTrackInfo, BaseState, State, TimeInfo } from '@/core/types';
 import * as Util from '@/core/content/util';
 import { ConnectorMeta } from '../connectors';
+import type { DisallowedReason } from '../object/disallowed-reason';
 
 export default class BaseConnector {
 	/**
@@ -413,14 +414,18 @@ export default class BaseConnector {
 	public isStateChangeAllowed: () => boolean | null | undefined = () => true;
 
 	/**
-	 * Default implementation of a check to see if a scrobbling is allowed.
-	 * The connector resets current state if this function returns falsy result.
+	 * Default implementation of a check to see if scrobbling is allowed.
+	 * The connector resets current state if this function returns non-falsy state.
+	 * The string content of non-falsy state determines the reason to show user for non-scrobbling.
 	 *
 	 * Override this method to allow certain states to be reset.
 	 *
-	 * @returns True if state change is allowed; false otherwise
+	 * @returns null/undefined if state change is allowed; {@link DisallowedReason} otherwise
 	 */
-	public isScrobblingAllowed: () => boolean | null | undefined = () => true;
+	public scrobblingDisallowedReason: () =>
+		| DisallowedReason
+		| null
+		| undefined = () => null;
 
 	/**
 	 * Function that will be called when the connector is injected and
@@ -585,7 +590,7 @@ export default class BaseConnector {
 		trackArt: null,
 		isPodcast: false,
 		originUrl: null,
-		isScrobblingAllowed: true,
+		scrobblingDisallowedReason: null,
 	};
 
 	// #v-ifdef VITE_DEV
@@ -848,7 +853,7 @@ export default class BaseConnector {
 				isPlaying: this.isPlaying(),
 				isPodcast: this.isPodcast(),
 				originUrl: this.getOriginUrl(),
-				isScrobblingAllowed: this.isScrobblingAllowed(),
+				scrobblingDisallowedReason: this.scrobblingDisallowedReason(),
 			};
 
 			let mediaSessionInfo = null;
diff --git a/src/core/object/controller/controller.ts b/src/core/object/controller/controller.ts
index 2cff79cd..b88f5e04 100644
--- a/src/core/object/controller/controller.ts
+++ b/src/core/object/controller/controller.ts
@@ -23,9 +23,12 @@ import {
 import EventEmitter from '@/util/emitter';
 import * as BrowserStorage from '@/core/storage/browser-storage';
 import { debugLog } from '@/core/content/util';
+import scrobbleCache from '@/core/storage/scrobble-cache';
+import { ScrobbleStatus } from '@/core/storage/wrapper';
 import browser from 'webextension-polyfill';
 import BaseConnector from '@/core/content/connector';
 import Blocklist from '@/core/storage/blocklist';
+import { DisallowedReason } from '../disallowed-reason';
 
 /**
  * List of song fields used to check if song is changed. If any of
@@ -73,6 +76,7 @@ export default class Controller {
 	private currentSong: Song | null = null;
 	private isReplayingSong = false;
 	private shouldScrobblePodcasts = true;
+	private scrobbleCacheId: number | null = null;
 	private blocklist: Blocklist;
 
 	private forceScrobble = false;
@@ -89,22 +93,39 @@ export default class Controller {
 	});
 
 	/**
+	 * Mutates this.currentSong to sync disallowed reason, and returns whether song should scrobble
+	 *
 	 * @returns true if song should scrobble; false if disallowed.
 	 */
-	private async shouldScrobble() {
+	private async shouldScrobble(): Promise<boolean> {
 		if (this.forceScrobble) {
 			return true;
 		}
-		if (this.currentSong?.flags.hasBlockedTag) {
+
+		if (!this.currentSong) {
 			return false;
 		}
 
-		return (
-			this.currentSong?.parsed.isScrobblingAllowed &&
-			(await this.blocklist.shouldScrobbleChannel(
-				this.connector.getChannelInfo?.()?.id,
+		if (this.currentSong.parsed.scrobblingDisallowedReason) {
+			return false;
+		}
+
+		if (
+			!(await this.blocklist.shouldScrobbleChannel(
+				this.connector.getChannelId?.(),
 			))
-		);
+		) {
+			this.currentSong.parsed.scrobblingDisallowedReason =
+				'ForbiddenChannel';
+			return false;
+		}
+
+		if (this.currentSong?.flags.hasBlockedTag) {
+			this.currentSong.parsed.scrobblingDisallowedReason = 'ForbiddenTag';
+			return false;
+		}
+
+		return true;
 	}
 
 	/**
@@ -369,7 +390,8 @@ export default class Controller {
 				if (
 					!song ||
 					song.flags.isReplaying ||
-					!(await this.shouldScrobble())
+					!(await this.shouldScrobble()) ||
+					!this.currentSong?.isValid()
 				) {
 					break;
 				}
@@ -767,7 +789,7 @@ export default class Controller {
 			isPlaying,
 			trackArt,
 			duration,
-			isScrobblingAllowed,
+			scrobblingDisallowedReason,
 		} = newState;
 		const isPlayingStateChanged =
 			this.currentSong.parsed.isPlaying !== isPlaying;
@@ -775,7 +797,8 @@ export default class Controller {
 		this.currentSong.parsed.currentTime = currentTime;
 		this.currentSong.parsed.isPlaying = isPlaying;
 		this.currentSong.parsed.trackArt = trackArt;
-		this.currentSong.parsed.isScrobblingAllowed = isScrobblingAllowed;
+		this.currentSong.parsed.scrobblingDisallowedReason =
+			scrobblingDisallowedReason;
 
 		if (this.isNeedToUpdateDuration(newState) && duration) {
 			this.updateSongDuration(duration);
@@ -837,12 +860,20 @@ export default class Controller {
 			`Song finished processing: ${this.currentSong.toString()}`,
 		);
 
-		if (this.currentSong.isValid()) {
-			// Processing cleans this flag
-			this.currentSong.flags.isMarkedAsPlaying = false;
+		// Processing cleans this flag
+		this.currentSong.flags.isMarkedAsPlaying = false;
 
-			await this.updateTimers(this.currentSong.getDuration());
+		await this.updateTimers(this.currentSong.getDuration());
 
+		/*
+		 * If the song is playing, mark it immediately;
+		 * otherwise will be flagged in isPlaying binding.
+		 */
+		if (!this.currentSong.isValid()) {
+			this.setSongNotRecognized();
+		} else if (!(await this.shouldScrobble())) {
+			this.setMode(ControllerMode.Disallowed);
+		} else if (this.currentSong.parsed.isPlaying) {
 			/*
 			 * If the song is playing, mark it immediately;
 			 * otherwise will be flagged in isPlaying binding.
@@ -972,9 +1003,7 @@ export default class Controller {
 
 		this.currentSong.parsed.duration = duration;
 
-		if (this.currentSong.isValid()) {
-			void this.updateTimers(duration);
-		}
+		void this.updateTimers(duration);
 	}
 
 	/**
@@ -1020,6 +1049,7 @@ export default class Controller {
 	private async setSongNowPlaying(): Promise<void> {
 		if (
 			!assertSongNotNull(this.currentSong) ||
+			!this.currentSong.isValid() ||
 			!(await this.shouldScrobble())
 		) {
 			return;
@@ -1060,6 +1090,7 @@ export default class Controller {
 	private async setPaused(): Promise<void> {
 		if (
 			!assertSongNotNull(this.currentSong) ||
+			!this.currentSong.isValid() ||
 			!(await this.shouldScrobble())
 		) {
 			return;
@@ -1075,6 +1106,7 @@ export default class Controller {
 	private async setResumedPlaying(): Promise<void> {
 		if (
 			!assertSongNotNull(this.currentSong) ||
+			!this.currentSong.isValid() ||
 			!(await this.shouldScrobble())
 		) {
 			return;
@@ -1121,6 +1153,35 @@ export default class Controller {
 		this.setEnabled(false);
 	}
 
+	/**
+	 * Tries to save failed scrobble due to disallowed/unrecognized.
+	 *
+	 * @returns true if scrobble is failed; false if should scrobble
+	 */
+	private async saveFailedScrobble(): Promise<boolean> {
+		if (!assertSongNotNull(this.currentSong)) {
+			return true;
+		}
+
+		if (!(await this.shouldScrobble())) {
+			this.scrobbleCacheId = await scrobbleCache.pushScrobble({
+				song: this.currentSong.getCloneableData(),
+				status: ScrobbleStatus.DISALLOWED,
+			});
+			this.shouldHaveScrobbled = true;
+			return true;
+		}
+		if (!this.currentSong.isValid()) {
+			this.scrobbleCacheId = await scrobbleCache.pushScrobble({
+				song: this.currentSong.getCloneableData(),
+				status: ScrobbleStatus.INVALID,
+			});
+			this.shouldHaveScrobbled = true;
+			return true;
+		}
+		return false;
+	}
+
 	/**
 	 * Called when scrobble timer triggers.
 	 * The time should be set only after the song is validated and ready
@@ -1131,10 +1192,13 @@ export default class Controller {
 			return;
 		}
 
-		if (!(await this.shouldScrobble())) {
-			this.shouldHaveScrobbled = true;
+		if (await this.saveFailedScrobble()) {
 			return;
 		}
+		if (this.scrobbleCacheId) {
+			scrobbleCache.deleteScrobbles([this.scrobbleCacheId]);
+			this.scrobbleCacheId = null;
+		}
 
 		// dont scrobble until user stopped editing.
 		if (this.isEditing) {
@@ -1152,18 +1216,19 @@ export default class Controller {
 		const results = await sendContentMessage({
 			type: 'scrobble',
 			payload: {
-				song: this.currentSong.getCloneableData(),
+				songs: [this.currentSong.getCloneableData()],
+				currentlyPlaying: true,
 			},
 		});
 
-		if (isAnyResult(results, ServiceCallResult.RESULT_OK)) {
+		if (isAnyResult(results[0], ServiceCallResult.RESULT_OK)) {
 			this.debugLog('Scrobbled successfully');
 
 			this.currentSong.flags.isScrobbled = true;
 			this.setMode(ControllerMode.Scrobbled);
 
 			this.onSongUpdated();
-		} else if (areAllResults(results, ServiceCallResult.RESULT_IGNORE)) {
+		} else if (areAllResults(results[0], ServiceCallResult.RESULT_IGNORE)) {
 			this.debugLog('Song is ignored by service');
 			this.setMode(ControllerMode.Ignored);
 		} else {
diff --git a/src/core/object/disallowed-reason.ts b/src/core/object/disallowed-reason.ts
new file mode 100644
index 00000000..22574132
--- /dev/null
+++ b/src/core/object/disallowed-reason.ts
@@ -0,0 +1,56 @@
+/**
+ * Reasons why a scrobble could be disallowed
+ */
+export type DisallowedReason =
+	/**
+	 * Current track is an ad
+	 */
+	| 'IsAd'
+
+	/**
+	 * YouTube category **the user** has disabled
+	 */
+	| 'ForbiddenYouTubeCategory'
+
+	/**
+	 * Artist/album/track tag **the user** has disabled
+	 * For tags that are always disabled in the connector, use 'FilteredTag'
+	 */
+	| 'ForbiddenTag'
+
+	/**
+	 * Playing from a channel **the user** has disabled
+	 */
+	| 'ForbiddenChannel'
+
+	/**
+	 * Not recognized as music by YTM despite user setting demanding it
+	 */
+	| 'NotOnYouTubeMusic'
+
+	/**
+	 * Web scrobbler thinks this song is playing on a different device
+	 */
+	| 'IsPlayingElsewhere'
+
+	/**
+	 * Some element web scrobbler relies on is missing
+	 */
+	| 'ElementMissing'
+
+	/**
+	 * Tags contain some term that is filtered **on website**
+	 * For tags disabled by the user, use 'ForbiddenTag'
+	 */
+	| 'FilteredTag'
+
+	/**
+	 * Something crucial to detection is still loading
+	 * This error will normally be temporary, usually pretty quickly resolved
+	 */
+	| 'IsLoading'
+
+	/**
+	 * Any other reason
+	 */
+	| 'Other';
diff --git a/src/core/object/scrobble-service.ts b/src/core/object/scrobble-service.ts
index d4d35fe1..7d14a0ff 100644
--- a/src/core/object/scrobble-service.ts
+++ b/src/core/object/scrobble-service.ts
@@ -10,6 +10,8 @@ import { BaseSong } from '@/core/object/song';
 import { ScrobblerSongInfo } from '@/core/scrobbler/base-scrobbler';
 import ClonedSong from './cloned-song';
 import { debugLog } from '../content/util';
+import scrobbleCache from '../storage/scrobble-cache';
+import { getScrobbleStatus } from '../storage/wrapper';
 
 /**
  * Service to handle all scrobbling behavior.
@@ -225,24 +227,36 @@ class ScrobbleService {
 	 * @param song - Song instance
 	 * @returns Promise that will be resolved then the task will complete
 	 */
-	scrobble(song: BaseSong): Promise<ServiceCallResult[]> {
+	async scrobble(
+		songs: BaseSong[],
+		currentlyPlaying: boolean,
+	): Promise<ServiceCallResult[][]> {
 		debugLog(`Send "scrobble" request: ${this.boundScrobblers.length}`);
 
-		return Promise.all(
+		const res = await Promise.all(
 			this.boundScrobblers.map(async (scrobbler) => {
 				// Forward result (including errors) to caller
 				try {
 					return await scrobbler.scrobble(
-						scrobbler.applyFilter(song),
+						songs.map(scrobbler.applyFilter),
+						currentlyPlaying,
 					);
 				} catch (result) {
-					return this.processErrorResult(
+					return this.processScrobbleErrorResult(
 						scrobbler,
-						result as ServiceCallResult,
+						result as ServiceCallResult[],
 					);
 				}
 			}),
 		);
+		for (let i = 0; i < res[0].length; i++) {
+			await scrobbleCache.pushScrobble({
+				song: songs[i].getCloneableData(),
+				status: getScrobbleStatus(res, i),
+			});
+		}
+
+		return res;
 	}
 
 	/**
@@ -327,6 +341,36 @@ class ScrobbleService {
 		// Forward result
 		return result;
 	}
+
+	/**
+	 * Process result received from scrobbler when scrobbling.
+	 * Scrobbling has array result so logic is slightly different
+	 * @param scrobbler - Scrobbler instance
+	 * @param result - API call result
+	 * @returns Promise resolved with result object
+	 */
+	async processScrobbleErrorResult(
+		scrobbler: Scrobbler,
+		result: ServiceCallResult[],
+	): Promise<ServiceCallResult[]> {
+		const isOtherError = result[0] === ServiceCallResult.ERROR_OTHER;
+		const isAuthError = result[0] === ServiceCallResult.ERROR_AUTH;
+
+		if (!(isOtherError || isAuthError)) {
+			throw new Error(`Invalid result: ${result[0]}`);
+		}
+
+		if (isAuthError) {
+			// Don't unbind scrobblers which have tokens
+			const isReady = await scrobbler.isReadyForGrantAccess();
+			if (!isReady) {
+				this.unbindScrobbler(scrobbler);
+			}
+		}
+
+		// Forward result
+		return result;
+	}
 }
 
 export default new ScrobbleService();
diff --git a/src/core/object/song.ts b/src/core/object/song.ts
index eb270aad..3aca90be 100644
--- a/src/core/object/song.ts
+++ b/src/core/object/song.ts
@@ -1,5 +1,6 @@
 import type { ConnectorMeta } from '@/core/connectors';
 import { State } from '@/core/types';
+import type { DisallowedReason } from './disallowed-reason';
 
 export interface ProcessedSongData {
 	artist?: string | null;
@@ -16,7 +17,7 @@ export interface ParsedSongData extends ProcessedSongData {
 	isPodcast?: boolean | null;
 	isPlaying?: boolean | null;
 	currentTime?: number | null;
-	isScrobblingAllowed?: boolean | null;
+	scrobblingDisallowedReason?: DisallowedReason | null;
 }
 
 export type Flags =
diff --git a/src/core/scrobbler/audio-scrobbler/audio-scrobbler.ts b/src/core/scrobbler/audio-scrobbler/audio-scrobbler.ts
index 9cd9dba6..d2c4034a 100644
--- a/src/core/scrobbler/audio-scrobbler/audio-scrobbler.ts
+++ b/src/core/scrobbler/audio-scrobbler/audio-scrobbler.ts
@@ -194,22 +194,25 @@ export default abstract class AudioScrobbler extends BaseScrobbler<'LastFM'> {
 	}
 
 	/** @override */
-	async scrobble(song: BaseSong): Promise<ServiceCallResult> {
+	async scrobble(songs: BaseSong[]): Promise<ServiceCallResult[]> {
 		const { sessionID } = await this.getSession();
 		const params: AudioScrobblerScrobbleParams = {
 			method: 'track.scrobble',
-			'timestamp[0]': song.metadata.startTimestamp.toString(),
-			'track[0]': song.getTrack(),
-			'artist[0]': song.getArtist(),
 			sk: sessionID,
 		};
+		for (const [index, song] of songs.slice(0, 50).entries()) {
+			params[`timestamp[${index}]`] =
+				song.metadata.startTimestamp.toString();
+			params[`track[${index}]`] = song.getTrack();
+			params[`artist[${index}]`] = song.getArtist();
+
+			if (song.getAlbum()) {
+				params[`album[${index}]`] = song.getAlbum();
+			}
 
-		if (song.getAlbum()) {
-			params['album[0]'] = song.getAlbum();
-		}
-
-		if (song.getAlbumArtist()) {
-			params['albumArtist[0]'] = song.getAlbumArtist();
+			if (song.getAlbumArtist()) {
+				params[`albumArtist[${index}]`] = song.getAlbumArtist();
+			}
 		}
 
 		const response =
@@ -223,17 +226,44 @@ export default abstract class AudioScrobbler extends BaseScrobbler<'LastFM'> {
 			const scrobbles = response.scrobbles;
 
 			if (scrobbles) {
-				const acceptedCount = scrobbles['@attr'].accepted;
+				const ignoredCount = scrobbles['@attr'].ignored;
 
-				if (acceptedCount === '0') {
-					return ServiceCallResult.RESULT_IGNORE;
+				if (Number(ignoredCount) === 0) {
+					return new Array<ServiceCallResult>(
+						Math.min(songs.length, 50),
+					).fill(ServiceCallResult.RESULT_OK);
+				}
+
+				if (!Array.isArray(scrobbles.scrobble)) {
+					return new Array<ServiceCallResult>(
+						Math.min(songs.length, 50),
+					).fill(ServiceCallResult.RESULT_IGNORE);
+				}
+
+				const timestampMap: Record<string, number> = {};
+				for (const [index, song] of songs.entries()) {
+					timestampMap[song.metadata.startTimestamp.toString()] =
+						index;
+				}
+
+				const results: ServiceCallResult[] =
+					new Array<ServiceCallResult>(Math.min(songs.length, 50));
+				for (const scrobble of scrobbles.scrobble) {
+					results[timestampMap[scrobble.timestamp]] =
+						scrobble.ignoredMessage.code === '0'
+							? ServiceCallResult.RESULT_OK
+							: ServiceCallResult.RESULT_IGNORE;
 				}
 			} else {
-				return ServiceCallResult.ERROR_OTHER;
+				return new Array<ServiceCallResult>(
+					Math.min(songs.length, 50),
+				).fill(ServiceCallResult.ERROR_OTHER);
 			}
 		}
 
-		return result;
+		return new Array<ServiceCallResult>(Math.min(songs.length, 50)).fill(
+			result,
+		);
 	}
 
 	/** @override */
diff --git a/src/core/scrobbler/audio-scrobbler/audio-scrobbler.types.ts b/src/core/scrobbler/audio-scrobbler/audio-scrobbler.types.ts
index 773fe735..c8747640 100644
--- a/src/core/scrobbler/audio-scrobbler/audio-scrobbler.types.ts
+++ b/src/core/scrobbler/audio-scrobbler/audio-scrobbler.types.ts
@@ -4,26 +4,26 @@ interface AudioScrobblerScrobbleEntry {
 	'#text'?: string;
 }
 
+interface AudioScrobblerScrobble {
+	artist: AudioScrobblerScrobbleEntry;
+	ignoredMessage: {
+		code: string;
+		message: string;
+	};
+	albumArtist: AudioScrobblerScrobbleEntry;
+	timestamp: string;
+	album: AudioScrobblerScrobbleEntry;
+	track: AudioScrobblerScrobbleEntry;
+}
+
 export interface AudioScrobblerTrackScrobbleResponse
 	extends Record<string, unknown> {
 	scrobbles: {
 		'@attr': {
-			accepted: string;
-			ignored: string;
-		};
-		scrobble: {
-			scrobbles: {
-				artist: AudioScrobblerScrobbleEntry;
-				ignoredMessage: {
-					code: string;
-					message: string;
-				};
-				albumArtist: AudioScrobblerScrobbleEntry;
-				timestamp: string;
-				album: AudioScrobblerScrobbleEntry;
-				track: AudioScrobblerScrobbleEntry;
-			}[];
+			accepted: number | string;
+			ignored: number | string;
 		};
+		scrobble: AudioScrobblerScrobble | AudioScrobblerScrobble[];
 	};
 }
 
diff --git a/src/core/scrobbler/base-scrobbler.ts b/src/core/scrobbler/base-scrobbler.ts
index 725ecb10..6c319936 100644
--- a/src/core/scrobbler/base-scrobbler.ts
+++ b/src/core/scrobbler/base-scrobbler.ts
@@ -331,13 +331,16 @@ export default abstract class BaseScrobbler<K extends keyof ScrobblerModels> {
 	public abstract sendPaused(song: BaseSong): Promise<ServiceCallResult>;
 
 	/**
-	 * Send song to API to scrobble.
+	 * Send songs to API to scrobble.
 	 * Implementation must return ServiceCallResult constant.
 	 *
-	 * @param song - Song instance
+	 * @param song - Song instances
 	 */
 	// eslint-disable-next-line no-unused-vars
-	public abstract scrobble(song: BaseSong): Promise<ServiceCallResult>;
+	public abstract scrobble(
+		song: BaseSong[],
+		currentlyPlaying: boolean,
+	): Promise<ServiceCallResult[]>;
 
 	/**
 	 * Love or unlove given song.
diff --git a/src/core/scrobbler/listenbrainz/listenbrainz-scrobbler.ts b/src/core/scrobbler/listenbrainz/listenbrainz-scrobbler.ts
index 6d876db5..0ef1fda9 100644
--- a/src/core/scrobbler/listenbrainz/listenbrainz-scrobbler.ts
+++ b/src/core/scrobbler/listenbrainz/listenbrainz-scrobbler.ts
@@ -181,19 +181,23 @@ export default class ListenBrainzScrobbler extends BaseScrobbler<'ListenBrainz'>
 	}
 
 	/** @override */
-	public async scrobble(song: BaseSong): Promise<ServiceCallResult> {
+	public async scrobble(
+		songs: BaseSong[],
+		currentlyPlaying: boolean,
+	): Promise<ServiceCallResult[]> {
 		const { sessionID } = await this.getSession();
 
 		const params = {
-			listen_type: 'single',
-			payload: [
-				{
-					listened_at: song.metadata.startTimestamp,
-					track_metadata: this.makeTrackMetadata(song),
-				},
-			],
+			listen_type: currentlyPlaying ? 'single' : 'import',
+			payload: songs.slice(0, 50).map((song) => ({
+				listened_at: song.metadata.startTimestamp,
+				track_metadata: this.makeTrackMetadata(song),
+			})),
 		} as ListenBrainzParams;
-		return this.sendScrobbleRequest(params, sessionID);
+		const res = await this.sendScrobbleRequest(params, sessionID);
+		return new Array<ServiceCallResult>(Math.min(songs.length, 50)).fill(
+			res,
+		);
 	}
 
 	/** @override */
diff --git a/src/core/scrobbler/listenbrainz/listenbrainz.types.ts b/src/core/scrobbler/listenbrainz/listenbrainz.types.ts
index aa1a5b92..f9b475f0 100644
--- a/src/core/scrobbler/listenbrainz/listenbrainz.types.ts
+++ b/src/core/scrobbler/listenbrainz/listenbrainz.types.ts
@@ -26,7 +26,7 @@ export type ListenBrainzParams =
 			];
 	  }
 	| {
-			listen_type: 'single';
+			listen_type: 'single' | 'import';
 			payload: [
 				{
 					listened_at: number;
diff --git a/src/core/scrobbler/maloja/maloja-scrobbler.ts b/src/core/scrobbler/maloja/maloja-scrobbler.ts
index e54288b4..38942563 100644
--- a/src/core/scrobbler/maloja/maloja-scrobbler.ts
+++ b/src/core/scrobbler/maloja/maloja-scrobbler.ts
@@ -88,10 +88,13 @@ export default class MalojaScrobbler extends BaseScrobbler<'Maloja'> {
 	}
 
 	/** @override */
-	public async scrobble(song: BaseSong): Promise<ServiceCallResult> {
-		const songData = this.makeTrackMetadata(song);
-
-		return this.sendRequest(songData, this.userToken);
+	public async scrobble(songs: BaseSong[]): Promise<ServiceCallResult[]> {
+		const resultArray: Promise<ServiceCallResult>[] = [];
+		for (const song of songs.slice(0, 50)) {
+			const songData = this.makeTrackMetadata(song);
+			resultArray.push(this.sendRequest(songData, this.userToken));
+		}
+		return Promise.all(resultArray);
 	}
 
 	/** Private methods */
diff --git a/src/core/scrobbler/pleroma/pleroma-scrobbler.ts b/src/core/scrobbler/pleroma/pleroma-scrobbler.ts
index fc3801d6..3b970ced 100644
--- a/src/core/scrobbler/pleroma/pleroma-scrobbler.ts
+++ b/src/core/scrobbler/pleroma/pleroma-scrobbler.ts
@@ -115,10 +115,13 @@ export default class PleromaScrobbler extends BaseScrobbler<'Pleroma'> {
 	}
 
 	/** @override */
-	public async scrobble(song: BaseSong): Promise<ServiceCallResult> {
-		const songData = this.makeTrackMetadata(song);
-
-		return this.sendRequest(songData, this.userToken);
+	public async scrobble(songs: BaseSong[]): Promise<ServiceCallResult[]> {
+		const resultArray: Promise<ServiceCallResult>[] = [];
+		for (const song of songs.slice(0, 50)) {
+			const songData = this.makeTrackMetadata(song);
+			resultArray.push(this.sendRequest(songData, this.userToken));
+		}
+		return Promise.all(resultArray);
 	}
 
 	/** Private methods */
diff --git a/src/core/scrobbler/webhook-scrobbler.ts b/src/core/scrobbler/webhook-scrobbler.ts
index 1952a4b2..48e2fc30 100644
--- a/src/core/scrobbler/webhook-scrobbler.ts
+++ b/src/core/scrobbler/webhook-scrobbler.ts
@@ -12,7 +12,9 @@ type WebhookRequest = {
 	time: number;
 	data: {
 		song: BaseSong;
+		songs?: BaseSong[];
 		isLoved?: boolean;
+		currentlyPlaying?: boolean;
 	};
 };
 
@@ -146,12 +148,21 @@ export default class WebhookScrobbler extends BaseScrobbler<'Webhook'> {
 	}
 
 	/** @override */
-	public async scrobble(song: BaseSong): Promise<ServiceCallResult> {
-		return this.sendRequest({
+	public async scrobble(
+		songs: BaseSong[],
+		currentlyPlaying: boolean,
+	): Promise<ServiceCallResult[]> {
+		const res = await this.sendRequest({
 			eventName: 'scrobble',
 			time: Date.now(),
-			data: { song },
+			// send the first song as a separate argument to avoid breaking older implementations
+			data: {
+				song: songs[0],
+				songs,
+				currentlyPlaying,
+			},
 		});
+		return new Array<ServiceCallResult>(songs.length).fill(res);
 	}
 
 	/** @override */
diff --git a/src/core/storage/browser-storage.ts b/src/core/storage/browser-storage.ts
index 896ed090..c062288e 100644
--- a/src/core/storage/browser-storage.ts
+++ b/src/core/storage/browser-storage.ts
@@ -1,5 +1,6 @@
 import browser from 'webextension-polyfill';
 import StorageWrapper, { DataModels } from '@/core/storage/wrapper';
+import type CloneableSong from '../object/song';
 
 const LOCAL = 0;
 const SYNC = 1;
@@ -136,6 +137,17 @@ export const DISABLED_TABS = 'DisabledTabs';
  */
 export const REGEX_EDITS = 'RegexEdits';
 
+/**
+ * This storage contains the scrobble cache for the user to interact with.
+ * The format of the storage data is as follows:
+ * \{
+ *     song: {@link CloneableSong},
+ *     status: 'Status of the scrobble',
+ *     id: auto-increment unique ID.
+ * \}
+ */
+export const SCROBBLE_CACHE = 'ScrobbleCache';
+
 /**
  * This storage contains the data saved and used by the extension core.
  * The format of storage data is following:
@@ -163,6 +175,7 @@ const storageTypeMap = {
 	[BLOCKLISTS]: LOCAL,
 	[LOCAL_CACHE]: LOCAL,
 	[REGEX_EDITS]: LOCAL,
+	[SCROBBLE_CACHE]: LOCAL,
 	[BLOCKED_TAGS]: LOCAL,
 	[CORE]: LOCAL,
 	[STATE_MANAGEMENT]: LOCAL,
diff --git a/src/core/storage/scrobble-cache.model.ts b/src/core/storage/scrobble-cache.model.ts
new file mode 100644
index 00000000..902bfc6f
--- /dev/null
+++ b/src/core/storage/scrobble-cache.model.ts
@@ -0,0 +1,92 @@
+import { SCROBBLE_CACHE } from './browser-storage';
+import { CustomStorage } from './custom-storage';
+import { CacheScrobble, CacheScrobbleData, DataModels } from './wrapper';
+
+type K = typeof SCROBBLE_CACHE;
+type V = DataModels[K];
+type T = Record<K, V>;
+
+export default abstract class ScrobbleCacheModel extends CustomStorage<K> {
+	public abstract getScrobbleCacheStorage(): Promise<CacheScrobble[] | null>;
+	public abstract getScrobbleCacheStorageLocking(): Promise<
+		CacheScrobble[] | null
+	>;
+	public abstract saveScrobbleCacheToStorage(data: T[K]): Promise<void>;
+	public abstract saveScrobbleCacheToStorageLocking(
+		data: T[K],
+	): Promise<void>;
+	private MAX_SCROBBLE_CACHE_SIZE = 1000;
+
+	/**
+	 * Replace the data for a scrobble in the scrobble cache
+	 *
+	 * @param id - ID of the scrobble to modify
+	 * @param scrobble - data to replace it with
+	 */
+	async replaceScrobble(
+		id: number,
+		scrobble: CacheScrobbleData,
+	): Promise<void> {
+		const storageData = await this.getScrobbleCacheStorageLocking();
+		if (!storageData) {
+			return;
+		}
+
+		for (let i = 0; i < storageData.length; i++) {
+			if (storageData[i].id === id) {
+				storageData[i] = {
+					...scrobble,
+					id,
+				};
+			}
+		}
+		await this.saveScrobbleCacheToStorageLocking(storageData);
+	}
+
+	/**
+	 * Save scrobble to the storage.
+	 *
+	 * @param scrobble - {@link CacheScrobbleData} to save
+	 *
+	 * @returns the id of the new scrobble
+	 */
+	async pushScrobble(scrobble: CacheScrobbleData): Promise<number> {
+		const storageData = await this.getScrobbleCacheStorageLocking();
+		if (storageData === null) {
+			await this.saveScrobbleCacheToStorageLocking([
+				{
+					...scrobble,
+					id: 1,
+				},
+			]);
+			return 1;
+		}
+
+		const id = (storageData.at(-1)?.id ?? 0) + 1;
+		storageData.push({
+			...scrobble,
+			id,
+		});
+
+		// limit how much we store
+		await this.saveScrobbleCacheToStorageLocking(
+			storageData.slice(-this.MAX_SCROBBLE_CACHE_SIZE),
+		);
+		return id;
+	}
+
+	async deleteScrobbles(ids: number[]): Promise<void> {
+		const storageData = await this.getScrobbleCacheStorageLocking();
+		if (!storageData) {
+			return;
+		}
+		const scrobbleIdMap = new Map<number, boolean>();
+		for (const id of ids) {
+			scrobbleIdMap.set(id, true);
+		}
+
+		await this.saveScrobbleCacheToStorageLocking(
+			storageData.filter((e) => scrobbleIdMap.get(e.id) !== true),
+		);
+	}
+}
diff --git a/src/core/storage/scrobble-cache.ts b/src/core/storage/scrobble-cache.ts
new file mode 100644
index 00000000..b87a8c05
--- /dev/null
+++ b/src/core/storage/scrobble-cache.ts
@@ -0,0 +1,49 @@
+import { SCROBBLE_CACHE } from './browser-storage';
+import StorageWrapper, { CacheScrobble, DataModels } from './wrapper';
+import * as BrowserStorage from './browser-storage';
+import ScrobbleCacheModel from './scrobble-cache.model';
+
+type K = typeof SCROBBLE_CACHE;
+type V = DataModels[K];
+type T = Record<K, V>;
+
+class ScrobbleCacheImpl extends ScrobbleCacheModel {
+	private scrobbleCacheStorage = this.getStorage();
+
+	public init() {
+		this._init(
+			BrowserStorage.getLocalStorage(BrowserStorage.SCROBBLE_CACHE),
+		);
+
+		// #v-ifdef VITE_DEV
+		void this.scrobbleCacheStorage.debugLog();
+		// #v-endif
+	}
+
+	/** @override */
+	async getScrobbleCacheStorage(): Promise<CacheScrobble[] | null> {
+		return this.scrobbleCacheStorage.get();
+	}
+
+	/** @override */
+	async getScrobbleCacheStorageLocking(): Promise<CacheScrobble[] | null> {
+		return this.scrobbleCacheStorage.getLocking();
+	}
+
+	/** @override */
+	async saveScrobbleCacheToStorage(data: T[K]): Promise<void> {
+		return await this.scrobbleCacheStorage.set(data);
+	}
+
+	/** @override */
+	async saveScrobbleCacheToStorageLocking(data: T[K]): Promise<void> {
+		return await this.scrobbleCacheStorage.setLocking(data);
+	}
+
+	/** @override */
+	getStorage(): StorageWrapper<K> {
+		return BrowserStorage.getStorage(SCROBBLE_CACHE);
+	}
+}
+
+export default new ScrobbleCacheImpl();
diff --git a/src/core/storage/wrapper.ts b/src/core/storage/wrapper.ts
index 605e476a..0680b593 100644
--- a/src/core/storage/wrapper.ts
+++ b/src/core/storage/wrapper.ts
@@ -11,6 +11,7 @@ import {
 	NOTIFICATIONS,
 	OPTIONS,
 	REGEX_EDITS,
+	SCROBBLE_CACHE,
 	STATE_MANAGEMENT,
 	StorageNamespace,
 	BLOCKLISTS,
@@ -28,6 +29,7 @@ import EventEmitter from '@/util/emitter';
 import connectors from '@/core/connectors';
 import { RegexEdit } from '@/util/regex';
 import { debugLog } from '../content/util';
+import { ServiceCallResult } from '../object/service-call-result';
 
 export interface CustomPatterns {
 	[connectorId: string]: string[];
@@ -113,6 +115,40 @@ export interface StateManagement {
 	browserPreferredTheme: 'light' | 'dark';
 }
 
+export enum ScrobbleStatus {
+	SUCCESSFUL = 'success',
+	IGNORED = 'ignored',
+	ERROR = 'error',
+	DISALLOWED = 'disallowed',
+	INVALID = 'invalid',
+}
+
+export function getScrobbleStatus(
+	resultArr: ServiceCallResult[][],
+	index: number,
+): ScrobbleStatus {
+	// RESULT_IGNORE definitely requires action - prioritize it.
+	for (const res of resultArr) {
+		if (res[index] === ServiceCallResult.RESULT_IGNORE) {
+			return ScrobbleStatus.IGNORED;
+		}
+	}
+	for (const res of resultArr) {
+		if (res[index] !== ServiceCallResult.RESULT_OK) {
+			return ScrobbleStatus.ERROR;
+		}
+	}
+	return ScrobbleStatus.SUCCESSFUL;
+}
+
+export interface CacheScrobbleData {
+	song: CloneableSong;
+	status: ScrobbleStatus;
+}
+
+export interface CacheScrobble extends CacheScrobbleData {
+	id: number;
+}
 export type Blocklists = Record<string, Blocklist>;
 
 export type Blocklist = Record<string, string>;
@@ -129,6 +165,7 @@ export interface DataModels extends ScrobblerModels {
 	[CORE]: { appVersion: string };
 	[LOCAL_CACHE]: { [key: string]: SavedEdit };
 	[REGEX_EDITS]: RegexEdit[];
+	[SCROBBLE_CACHE]: CacheScrobble[];
 	[BLOCKED_TAGS]: BlockedTags;
 	[BLOCKLISTS]: Blocklists;
 
diff --git a/src/core/types.ts b/src/core/types.ts
index ad00cd4a..33968cbe 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -1,3 +1,5 @@
+import type { DisallowedReason } from './object/disallowed-reason';
+
 export interface TimeInfo {
 	/**
 	 * Currentime in seconds
@@ -75,5 +77,5 @@ export interface State extends BaseState {
 	/**
 	 * Is scrobbling allowed
 	 */
-	isScrobblingAllowed?: boolean | null;
+	scrobblingDisallowedReason?: DisallowedReason | null;
 }
diff --git a/src/theme/themes.scss b/src/theme/themes.scss
index 0e2b1bbf..da788679 100644
--- a/src/theme/themes.scss
+++ b/src/theme/themes.scss
@@ -10,6 +10,8 @@ $themes: (
 		context-bg: rgb(250 235 235 / 95%),
 		modal-bg: rgb(255 255 255 / 95%),
 		last-fm-bg: #faebeb,
+		last-fm-bg-light: #faebeb,
+		last-fm-bg-weak: rgb(250 235 235 / 50%),
 		muted-text: #555,
 		active-text: #fff,
 		text: #222,
@@ -19,6 +21,10 @@ $themes: (
 		indeterminate: #8e8e8e,
 		site-image: url('../img/main/site-light.jpg'),
 		invert-filter: invert(0%),
+		neuomorphic-bottom: 10px 10px 30px #bbb,
+		neuomorphic-top: -10px -10px 30px #fff9f9,
+		neuomorphic-small-bottom: 5px 5px 15px #bbb,
+		neuomorphic-small-top: -5px -5px 15px #fff9f9,
 		interactable-border: none,
 	),
 	dark: (
@@ -32,6 +38,8 @@ $themes: (
 		context-bg: rgb(194 57 55 / 95%),
 		modal-bg: rgb(40 40 40 / 95%),
 		last-fm-bg: #c23937,
+		last-fm-bg-light: #faebeb,
+		last-fm-bg-weak: rgb(194 57 55 / 10%),
 		muted-text: #bbb,
 		active-text: #fff,
 		text: #eee,
@@ -41,6 +49,10 @@ $themes: (
 		indeterminate: #8e8e8e,
 		site-image: url('../img/main/site-dark.jpg'),
 		invert-filter: invert(100%),
+		neuomorphic-bottom: 10px 10px 30px #111,
+		neuomorphic-top: -10px -10px 30px #333,
+		neuomorphic-small-bottom: 5px 5px 15px #111,
+		neuomorphic-small-top: -5px -5px 15px #333,
 		interactable-border: none,
 	),
 	high-contrast-dark: (
@@ -63,6 +75,10 @@ $themes: (
 		indeterminate: #8e8e8e,
 		site-image: black,
 		invert-filter: invert(100%),
+		neuomorphic-bottom: 10px 10px 30px #111,
+		neuomorphic-top: -10px -10px 30px #333,
+		neuomorphic-small-bottom: 5px 5px 15px #111,
+		neuomorphic-small-top: -5px -5px 15px #333,
 		interactable-border: 1px solid yellow,
 	),
 	high-contrast-light: (
@@ -85,6 +101,10 @@ $themes: (
 		indeterminate: #8e8e8e,
 		site-image: seashell,
 		invert-filter: invert(0%),
+		neuomorphic-bottom: 10px 10px 30px #bbb,
+		neuomorphic-top: -10px -10px 30px #fff9f9,
+		neuomorphic-small-bottom: 5px 5px 15px #bbb,
+		neuomorphic-small-top: -5px -5px 15px #fff9f9,
 		interactable-border: 1px solid black,
 	),
 );
diff --git a/src/ui/components/icons.tsx b/src/ui/components/icons.tsx
index 2a1c4206..63703830 100644
--- a/src/ui/components/icons.tsx
+++ b/src/ui/components/icons.tsx
@@ -273,6 +273,12 @@ export const PublishedWithChangesOutlined = createMaterialIcon(() => (
 	</>
 ));
 
+export const QueueMusicOutlined = createMaterialIcon(() => (
+	<g>
+		<path d="M22,6h-5v8.18C16.69,14.07,16.35,14,16,14c-1.66,0-3,1.34-3,3s1.34,3,3,3s3-1.34,3-3V8h3V6z M15,6H3v2h12V6z M15,10H3v2h12 V10z M11,14H3v2h8V14z" />
+	</g>
+));
+
 export const RestartAltOutlined = createMaterialIcon(() => (
 	<>
 		<g>
diff --git a/src/ui/options/components/components.module.scss b/src/ui/options/components/components.module.scss
index db64c3be..ff3c8f21 100644
--- a/src/ui/options/components/components.module.scss
+++ b/src/ui/options/components/components.module.scss
@@ -120,6 +120,12 @@
 		white-space: nowrap;
 	}
 
+	&:disabled {
+		background-color: var(--last-fm-bg);
+		color: var(--muted-text);
+		cursor: default;
+	}
+
 	&.noRadius {
 		border-radius: 0;
 	}
@@ -233,6 +239,65 @@
 	}
 }
 
+.editTable {
+	margin-bottom: 1rem;
+
+	label {
+		padding-right: 1rem;
+	}
+
+	label,
+	input {
+		font-size: 1rem;
+	}
+
+	input {
+		appearance: none;
+		background-color: var(--last-fm-bg-weak);
+		border: none;
+		border-radius: 0.5rem;
+		color: var(--text);
+		padding: 0.25rem 0.5rem;
+		width: 30ch;
+	}
+}
+
+.saveIcon {
+	transform: rotate(0deg);
+
+	&.savingSaveIcon {
+		transform: rotate(360deg);
+		transform-origin: 41% 42.5%;
+		transition: transform 600ms ease-in-out;
+	}
+}
+
+.scrobbleButtonsWrapper {
+	display: flex;
+	flex-wrap: wrap;
+	gap: 1rem;
+}
+
+summary {
+	border: var(--interactable-border);
+	list-style: none;
+
+	&::-webkit-details-marker {
+		display: none;
+	}
+}
+
+.scrobbleCacheList {
+	summary {
+		padding: 0.5rem 0;
+	}
+
+	.summarySpan {
+		font-size: 1.5em;
+		font-weight: bold;
+	}
+}
+
 .checkboxOption {
 	$tick-size: 1.05em;
 	$checkbox-size: 1.4em;
@@ -315,15 +380,6 @@
 	max-width: 50vw;
 }
 
-summary {
-	border: var(--interactable-border);
-	list-style: none;
-
-	&::-webkit-details-marker {
-		display: none;
-	}
-}
-
 .radioButtons {
 	.radioButton {
 		margin-bottom: 0.75em;
@@ -357,7 +413,8 @@ summary {
 	}
 }
 
-.connectorOptionsList {
+.connectorOptionsList,
+.scrobbleCacheList {
 	$animation-duration: 0.2s;
 
 	summary,
@@ -374,6 +431,10 @@ summary {
 
 	summary {
 		cursor: pointer;
+
+		&::-webkit-details-marker {
+			display: none;
+		}
 	}
 
 	.expandVector {
@@ -549,13 +610,135 @@ summary {
 	}
 }
 
-.saveIcon {
-	transform: rotate(0deg);
+.scrobble {
+	align-items: center;
+	background-color: var(--last-fm-bg-weak);
+	border-radius: 1em;
+	box-shadow: var(--neuomorphic-small-bottom), var(--neuomorphic-small-top);
+	display: flex;
+	gap: 0.5em;
+	height: 7em;
+	margin-bottom: 1em;
+	overflow: hidden;
+	padding-right: 1em;
 
-	&.savingSaveIcon {
-		transform: rotate(360deg);
-		transform-origin: 41% 42.5%;
-		transition: transform 600ms ease-in-out;
+	.scrobbleCheckbox {
+		border: none;
+	}
+
+	.coverArt {
+		height: 7em;
+		object-fit: cover;
+		width: 7em;
+	}
+
+	.scrobbleDetails {
+		display: flex;
+		flex-direction: column;
+		flex-grow: 1;
+		flex-shrink: 1;
+		justify-content: space-between;
+		min-height: 6em;
+		overflow: hidden;
+
+		.scrobbleMetadata {
+			display: flex;
+			flex-direction: column;
+
+			span {
+				white-space: nowrap;
+			}
+
+			.trackName {
+				font-size: 1rem;
+				font-weight: bold;
+			}
+
+			.artistName,
+			.albumName,
+			.albumArtistName {
+				font-size: 0.7rem;
+			}
+		}
+
+		.scrobbleActions {
+			display: flex;
+			gap: 0.5rem;
+
+			.scrobbleActionButton {
+				background-color: var(--always-last-fm);
+				border: none;
+				border-radius: 3px;
+				color: var(--always-light);
+				cursor: pointer;
+
+				&:hover {
+					background-color: var(--always-last-fm-hover);
+				}
+
+				&:disabled {
+					background-color: var(--last-fm-bg);
+					color: var(--text);
+				}
+			}
+		}
+	}
+
+	&:has(:checked) {
+		background-color: var(--always-last-fm);
+		color: var(--always-light);
+
+		.scrobbleDetails .scrobbleActions .scrobbleActionButton {
+			background-color: var(--last-fm-bg-light);
+			color: var(--always-last-fm);
+		}
+	}
+
+	&.selectingScrobble {
+		cursor: pointer;
+
+		&:hover:not(:has(:checked)) {
+			background-color: var(--last-fm-bg);
+			color: var(--text);
+
+			.scrobbleDetails .scrobbleActions .scrobbleActionButton {
+				background-color: var(--always-last-fm);
+				color: var(--always-light);
+			}
+		}
+	}
+
+	@media only screen and (width <= 500px) {
+		.scrobbleDetails .scrobbleMetadata {
+			.trackName {
+				font-size: 0.8rem;
+			}
+
+			.artistName,
+			.albumName,
+			.albumArtistName {
+				font-size: 0.6rem;
+			}
+		}
+
+		.scrobbleDate {
+			font-size: 0.8rem;
+		}
+	}
+
+	@media only screen and (width <= 400px) {
+		border-radius: 0;
+		box-sizing: border-box;
+		margin-left: -1rem;
+		width: 100vw;
+	}
+
+	@media only screen and (width <= 300px) {
+		padding-right: 0.5rem;
+
+		.scrobbleDate {
+			font-size: 0.7rem;
+		}
 	}
 }
 
diff --git a/src/ui/options/components/edit-options/blocked-tags.tsx b/src/ui/options/components/edit-options/blocked-tags.tsx
index a5847fc3..174a7fbc 100644
--- a/src/ui/options/components/edit-options/blocked-tags.tsx
+++ b/src/ui/options/components/edit-options/blocked-tags.tsx
@@ -5,11 +5,10 @@ import styles from '../components.module.scss';
 import { DeleteOutlined } from '@/ui/components/icons';
 import { ExportEdits, ImportEdits, ViewEdits } from './util';
 import { ModalType } from '../navigator';
-import { BlockedTagsReference } from '@/core/storage/wrapper';
+import { BlockedTags, BlockedTagsReference } from '@/core/storage/wrapper';
 import { Dynamic } from 'solid-js/web';
 
 const blocklist = BrowserStorage.getStorage(BrowserStorage.BLOCKED_TAGS);
-const [edits, { mutate }] = createResource(blocklist.get.bind(blocklist));
 
 /**
  * Component that allows the user to see, import, and export track metadata edits.
@@ -30,9 +29,9 @@ export default function BlockedTagsElement(props: {
 				/>
 				<ExportEdits
 					editWrapper={blocklist}
-					filename="regex-edits.json"
+					filename="blocked-tags.json"
 				/>
-				<ImportEdits editWrapper={blocklist} mutate={mutate} />
+				<ImportEdits editWrapper={blocklist} />
 			</div>
 		</>
 	);
@@ -43,6 +42,7 @@ export default function BlockedTagsElement(props: {
  * To be displayed in a modal.
  */
 export function BlockedTagsModal() {
+	const [edits, { mutate }] = createResource(blocklist.get.bind(blocklist));
 	const formattedEdits = createMemo<BlockedTagsReference[]>(() => {
 		const rawEdits = edits();
 		const res: BlockedTagsReference[] = [];
@@ -83,7 +83,9 @@ export function BlockedTagsModal() {
 			</h1>
 			<ul class={styles.optionList}>
 				<For each={formattedEdits()}>
-					{(blockedTag) => <BlockedTagInfo tag={blockedTag} />}
+					{(blockedTag) => (
+						<BlockedTagInfo tag={blockedTag} mutate={mutate} />
+					)}
 				</For>
 			</ul>
 		</>
@@ -116,7 +118,10 @@ function getTagSpan(tag: BlockedTagsReference) {
 	}
 }
 
-function BlockedTagInfo(props: { tag: BlockedTagsReference }) {
+function BlockedTagInfo(props: {
+	tag: BlockedTagsReference;
+	mutate: Setter<BlockedTags | null | undefined>;
+}) {
 	return (
 		<li class={styles.deleteListing}>
 			<button
@@ -124,7 +129,7 @@ function BlockedTagInfo(props: { tag: BlockedTagsReference }) {
 				onClick={(event) => {
 					event.stopPropagation();
 					const tag = props.tag;
-					mutate((e) => {
+					props.mutate((e) => {
 						if (!e) {
 							return e;
 						}
diff --git a/src/ui/options/components/edit-options/edited-tracks.tsx b/src/ui/options/components/edit-options/edited-tracks.tsx
index 43e18b77..2940171d 100644
--- a/src/ui/options/components/edit-options/edited-tracks.tsx
+++ b/src/ui/options/components/edit-options/edited-tracks.tsx
@@ -1,14 +1,13 @@
 import { t } from '@/util/i18n';
 import { For, Setter, createMemo, createResource } from 'solid-js';
 import * as Options from '@/core/storage/options';
-import * as BrowserStorage from '@/core/storage/browser-storage';
 import styles from '../components.module.scss';
 import { DeleteOutlined } from '@/ui/components/icons';
 import { ExportEdits, ImportEdits, ViewEdits } from './util';
 import { ModalType } from '../navigator';
+import * as BrowserStorage from '@/core/storage/browser-storage';
 
 const localCache = BrowserStorage.getStorage(BrowserStorage.LOCAL_CACHE);
-const [edits, { mutate }] = createResource(localCache.get.bind(localCache));
 
 /**
  * Component that allows the user to see, import, and export track metadata edits.
@@ -35,7 +34,7 @@ export default function EditedTracks(props: {
 					editWrapper={localCache}
 					filename="local-cache.json"
 				/>
-				<ImportEdits editWrapper={localCache} mutate={mutate} />
+				<ImportEdits editWrapper={localCache} />
 			</div>
 		</>
 	);
@@ -46,6 +45,7 @@ export default function EditedTracks(props: {
  * To be displayed in a modal.
  */
 export function EditsModal() {
+	const [edits, { mutate }] = createResource(localCache.get.bind(localCache));
 	return (
 		<>
 			<h1>
diff --git a/src/ui/options/components/edit-options/regex-edits.tsx b/src/ui/options/components/edit-options/regex-edits.tsx
index 8e700b97..4fdf4fe7 100644
--- a/src/ui/options/components/edit-options/regex-edits.tsx
+++ b/src/ui/options/components/edit-options/regex-edits.tsx
@@ -8,7 +8,6 @@ import { ExportEdits, ImportEdits, ViewEdits } from './util';
 import { ModalType } from '../navigator';
 
 const regexEdits = BrowserStorage.getStorage(BrowserStorage.REGEX_EDITS);
-const [edits, { mutate }] = createResource(regexEdits.get.bind(regexEdits));
 
 /**
  * Component that allows the user to see, import, and export track metadata edits.
@@ -35,7 +34,7 @@ export default function RegexEdits(props: {
 					editWrapper={regexEdits}
 					filename="regex-edits.json"
 				/>
-				<ImportEdits editWrapper={regexEdits} mutate={mutate} />
+				<ImportEdits editWrapper={regexEdits} />
 			</div>
 		</>
 	);
@@ -46,6 +45,7 @@ export default function RegexEdits(props: {
  * To be displayed in a modal.
  */
 export function RegexEditsModal() {
+	const [edits, { mutate }] = createResource(regexEdits.get.bind(regexEdits));
 	return (
 		<>
 			<h1>
diff --git a/src/ui/options/components/edit-options/util.tsx b/src/ui/options/components/edit-options/util.tsx
index 48a101fc..81e88eba 100644
--- a/src/ui/options/components/edit-options/util.tsx
+++ b/src/ui/options/components/edit-options/util.tsx
@@ -197,10 +197,7 @@ export function ImportBlocklist(props: {
 /**
  * Button that allows the user to upload a .json file and get edits from it.
  */
-export function ImportEdits(props: {
-	editWrapper: EditWrapper;
-	mutate: EditSetter;
-}) {
+export function ImportEdits(props: { editWrapper: EditWrapper }) {
 	const [ref, setRef] = createSignal<HTMLInputElement>();
 	return (
 		<button
@@ -214,7 +211,7 @@ export function ImportEdits(props: {
 				ref={setRef}
 				type="file"
 				accept=".json"
-				onChange={(e) => pushEdits(e, props.editWrapper, props.mutate)}
+				onChange={(e) => pushEdits(e, props.editWrapper)}
 			/>
 		</button>
 	);
@@ -229,7 +226,6 @@ function pushEdits(
 		target: Element;
 	},
 	editWrapper: EditWrapper,
-	mutate: EditSetter,
 ) {
 	const file = e.currentTarget.files?.[0];
 	if (!file) {
@@ -250,11 +246,9 @@ function pushEdits(
 				if (oldEdits instanceof Array && edits instanceof Array) {
 					const newEdits = [...(oldEdits ?? []), ...edits];
 					editWrapper.set(newEdits);
-					mutate(newEdits);
 				} else if (isSavedEdits(oldEdits) && isSavedEdits(edits)) {
 					const newEdits = { ...(oldEdits || {}), ...edits };
 					editWrapper.set(newEdits);
-					mutate(newEdits);
 				} else if (isBlockedTags(oldEdits) && isBlockedTags(edits)) {
 					const newEdits = { ...oldEdits };
 					for (const [artist, value] of Object.entries(edits)) {
@@ -278,7 +272,6 @@ function pushEdits(
 						}
 					}
 					editWrapper.set(newEdits);
-					mutate(newEdits);
 				}
 			})(arg),
 	);
diff --git a/src/ui/options/components/navigator.ts b/src/ui/options/components/navigator.ts
index 9d96489e..31c91fbb 100644
--- a/src/ui/options/components/navigator.ts
+++ b/src/ui/options/components/navigator.ts
@@ -14,10 +14,11 @@ import {
 	FavoriteOutlined,
 	PersonOutlined,
 	PersonOffOutlined,
+	QueueMusicOutlined,
 } from '@/ui/components/icons';
 
 import InfoComponent from '@/ui/options/components/info';
-import ShowSomeLove from '@/ui/options/components/showSomeLove';
+import ShowSomeLove from '@/ui/options/components/show-some-love';
 import FAQ from '@/ui/options/components/faq';
 import ContactComponent from '@/ui/options/components/contact';
 import OptionsComponent from '@/ui/options/components/options/options';
@@ -36,6 +37,7 @@ import {
 	removeFromBlocklist,
 } from '@/core/background/util';
 import * as ControllerMode from '@/core/object/controller/controller-mode';
+import ScrobbleCache from './scrobble-cache';
 import AdvancedOptionsComponent from './advanced-settings';
 
 /**
@@ -44,6 +46,7 @@ import AdvancedOptionsComponent from './advanced-settings';
 export type ModalType =
 	| 'savedEdits'
 	| 'regexEdits'
+	| 'cacheEdit'
 	| 'blockedTags'
 	| 'blocklist'
 	| '';
@@ -100,6 +103,12 @@ export const optionsItem: NavigatorNavigationButton = {
 	element: OptionsComponent,
 };
 
+export const scrobbleCache: NavigatorNavigationButton = {
+	namei18n: 'optionsScrobbleCache',
+	icon: QueueMusicOutlined,
+	element: ScrobbleCache,
+};
+
 export const editOptionsItem: NavigatorNavigationButton = {
 	namei18n: 'optionsEdits',
 	icon: EditOutlined,
@@ -123,6 +132,7 @@ export const optionsGroup: NavigatorButtonGroup = {
 	icon: SettingsOutlined,
 	group: [
 		optionsItem,
+		scrobbleCache,
 		editOptionsItem,
 		connectorOverrideOptionsItem,
 		advancedOptionsItem,
diff --git a/src/ui/options/components/scrobble-cache.tsx b/src/ui/options/components/scrobble-cache.tsx
new file mode 100644
index 00000000..ad616a77
--- /dev/null
+++ b/src/ui/options/components/scrobble-cache.tsx
@@ -0,0 +1,661 @@
+import scrobbleCache from '@/core/storage/scrobble-cache';
+import { CacheScrobble, ScrobbleStatus } from '@/core/storage/wrapper';
+import { t } from '@/util/i18n';
+import {
+	For,
+	Setter,
+	Show,
+	Suspense,
+	createMemo,
+	createResource,
+	createSignal,
+	onCleanup,
+	onMount,
+} from 'solid-js';
+import styles from './components.module.scss';
+import { ExpandMoreOutlined } from '@/ui/components/icons';
+import ClonedSong from '@/core/object/cloned-song';
+import browser from 'webextension-polyfill';
+import { sendContentMessage } from '@/util/communication';
+import { ModalType } from './navigator';
+import savedEdits from '@/core/storage/saved-edits';
+import { CloneableSong } from '@/core/object/song';
+import { debugLog } from '@/core/content/util';
+import SavedEditsModel from '@/core/storage/saved-edits.model';
+
+const [scrobbles, setScrobbles] = createResource(
+	scrobbleCache.getScrobbleCacheStorage.bind(scrobbleCache),
+);
+
+const [songToEdit, setSongToEdit] = createSignal<ClonedSong>();
+const [scrobbleId, setScrobbleId] = createSignal<number>();
+const [isSelectingScrobbles, setSelectingScrobbles] = createSignal(false);
+const [selectedScrobbles, setSelectedScrobbles] = createSignal<CacheScrobble[]>(
+	[],
+);
+const [isScrobblingMultiple, setScrobblingMultiple] = createSignal(false);
+
+export default function ScrobbleCache(props: {
+	setActiveModal: Setter<ModalType>;
+	modal: HTMLDialogElement | undefined;
+}) {
+	return (
+		<>
+			<h1>{t('optionsScrobbleCache')}</h1>
+			<div class={styles.scrobbleButtonsWrapper}>
+				<button
+					class={styles.button}
+					onClick={() => {
+						const buttons = document.querySelectorAll(
+							`.${styles.scrobbleCheckbox}`,
+						);
+						if (selectedScrobbles().length) {
+							buttons.forEach((checkbox) => {
+								(checkbox as HTMLInputElement).checked = false;
+							});
+							setSelectedScrobbles([]);
+						} else {
+							setSelectingScrobbles(true);
+							buttons.forEach((checkbox) => {
+								(checkbox as HTMLInputElement).checked = true;
+							});
+							setSelectedScrobbles(
+								(scrobbles() ?? []).filter(
+									(scrobble) =>
+										scrobble.status !==
+										ScrobbleStatus.SUCCESSFUL,
+								),
+							);
+						}
+					}}
+				>
+					{selectedScrobbles().length
+						? t('optionsScrobbleCacheUnselectAll')
+						: t('optionsScrobbleCacheSelectAll')}
+				</button>
+				<button
+					class={styles.button}
+					onClick={() =>
+						setSelectingScrobbles((prev) => {
+							const isSelecting = !prev;
+							if (!isSelecting) {
+								document
+									.querySelectorAll(
+										`.${styles.scrobbleCheckbox}`,
+									)
+									.forEach((checkbox) => {
+										(checkbox as HTMLInputElement).checked =
+											false;
+									});
+								setSelectedScrobbles([]);
+							}
+							return isSelecting;
+						})
+					}
+				>
+					{t('optionsScrobbleCacheSelectScrobbles')}
+				</button>
+				<Show when={isSelectingScrobbles()}>
+					<button
+						class={styles.button}
+						disabled={
+							isScrobblingMultiple() ||
+							selectedScrobbles().length === 0
+						}
+						onClick={() => {
+							setScrobblingMultiple(true);
+							void scrobbleMultipleFromCache(
+								selectedScrobbles(),
+							).then(() => {
+								setScrobbles.refetch();
+								setSelectedScrobbles([]);
+								setSelectingScrobbles(false);
+								setScrobblingMultiple(false);
+							});
+						}}
+					>
+						{t('optionsScrobbleCacheScrobbleSelected')}
+					</button>
+				</Show>
+			</div>
+			<div class={styles.scrobbleCacheList}>
+				<Suspense fallback={t('optionsScrobbleCacheLoading')}>
+					<ScrobbleList
+						title="optionsScrobbleCacheUnrecognized"
+						type={ScrobbleStatus.INVALID}
+						setActiveModal={props.setActiveModal}
+						modal={props.modal}
+					/>
+					<ScrobbleList
+						title="optionsScrobbleCacheDisallowed"
+						type={ScrobbleStatus.DISALLOWED}
+						setActiveModal={props.setActiveModal}
+						modal={props.modal}
+					/>
+					<ScrobbleList
+						title="optionsScrobbleCacheIgnored"
+						type={ScrobbleStatus.IGNORED}
+						setActiveModal={props.setActiveModal}
+						modal={props.modal}
+					/>
+					<ScrobbleList
+						title="optionsScrobbleCacheError"
+						type={ScrobbleStatus.ERROR}
+						setActiveModal={props.setActiveModal}
+						modal={props.modal}
+					/>
+					<ScrobbleList
+						title="optionsScrobbleCacheSuccess"
+						type={ScrobbleStatus.SUCCESSFUL}
+						setActiveModal={props.setActiveModal}
+						modal={props.modal}
+					/>
+				</Suspense>
+			</div>
+		</>
+	);
+}
+
+export function CacheEditModal() {
+	const [track, setTrack] = createSignal(songToEdit()?.getTrack() ?? '');
+	const [artist, setArtist] = createSignal(songToEdit()?.getArtist() ?? '');
+	const [album, setAlbum] = createSignal(songToEdit()?.getAlbum() ?? '');
+	const [albumArtist, setAlbumArtist] = createSignal(
+		songToEdit()?.getAlbumArtist() ?? '',
+	);
+	const [isLoading, setLoading] = createSignal(false);
+
+	return (
+		<>
+			<h1>{t('infoEditTitle')}</h1>
+			<table class={styles.editTable}>
+				<tbody>
+					<tr>
+						<td>
+							<label for="scrobble-cache-edit-track">
+								{t('infoTrackPlaceholder')}
+							</label>
+						</td>
+						<td>
+							<input
+								type="text"
+								value={track()}
+								onInput={(e) => setTrack(e.currentTarget.value)}
+								id="scrobble-cache-edit-track"
+								onKeyDown={(e) => {
+									saveEditsWithKeyboard(
+										e,
+										artist(),
+										track(),
+										album(),
+										albumArtist(),
+										setLoading,
+									);
+								}}
+							/>
+						</td>
+					</tr>
+					<tr>
+						<td>
+							<label for="scrobble-cache-edit-artist">
+								{t('infoArtistPlaceholder')}
+							</label>
+						</td>
+						<td>
+							<input
+								type="text"
+								value={artist()}
+								onInput={(e) =>
+									setArtist(e.currentTarget.value)
+								}
+								id="scrobble-cache-edit-artist"
+								onKeyDown={(e) => {
+									saveEditsWithKeyboard(
+										e,
+										artist(),
+										track(),
+										album(),
+										albumArtist(),
+										setLoading,
+									);
+								}}
+							/>
+						</td>
+					</tr>
+					<tr>
+						<td>
+							<label for="scrobble-cache-edit-album">
+								{t('infoAlbumPlaceholder')}
+							</label>
+						</td>
+						<td>
+							<input
+								type="text"
+								value={album()}
+								onInput={(e) => setAlbum(e.currentTarget.value)}
+								id="scrobble-cache-edit-album"
+								onKeyDown={(e) => {
+									saveEditsWithKeyboard(
+										e,
+										artist(),
+										track(),
+										album(),
+										albumArtist(),
+										setLoading,
+									);
+								}}
+							/>
+						</td>
+					</tr>
+					<tr>
+						<td>
+							<label for="scrobble-cache-edit-album-artist">
+								{t('infoAlbumArtistPlaceholder')}
+							</label>
+						</td>
+						<td>
+							<input
+								type="text"
+								value={albumArtist()}
+								onInput={(e) =>
+									setAlbumArtist(e.currentTarget.value)
+								}
+								id="scrobble-cache-edit-album-artist"
+								onKeyDown={(e) => {
+									saveEditsWithKeyboard(
+										e,
+										artist(),
+										track(),
+										album(),
+										albumArtist(),
+										setLoading,
+									);
+								}}
+							/>
+						</td>
+					</tr>
+				</tbody>
+			</table>
+			<input id="should-edit-multiple" type="checkbox" checked={true} />
+			<label for="should-edit-multiple">
+				{t('optionsScrobbleCacheEditAll')}
+			</label>
+			<button
+				disabled={!track() || !artist() || isLoading()}
+				class={styles.button}
+				onClick={(e) => {
+					saveEdits(
+						e,
+						artist(),
+						track(),
+						album(),
+						albumArtist(),
+						setLoading,
+					);
+				}}
+			>
+				{t('buttonScrobble')}
+			</button>
+		</>
+	);
+}
+
+function timeSince(time: number, isShort: boolean) {
+	const seconds = (time - Date.now()) / 1000;
+	let interval = seconds / 31_536_000;
+	const formatter = new Intl.RelativeTimeFormat(
+		Intl.DateTimeFormat().resolvedOptions().locale,
+		{ style: isShort ? 'short' : 'long' },
+	);
+	if (Math.abs(interval) > 1) {
+		return formatter.format(Math.floor(interval), 'year');
+	}
+	interval = seconds / 2_629_744;
+	if (Math.abs(interval) > 1) {
+		return formatter.format(Math.floor(interval), 'month');
+	}
+	interval = seconds / 604_800;
+	if (Math.abs(interval) > 1) {
+		return formatter.format(Math.floor(interval), 'week');
+	}
+	interval = seconds / 86400;
+	if (Math.abs(interval) > 1) {
+		return formatter.format(Math.floor(interval), 'day');
+	}
+	interval = seconds / 3600;
+	if (Math.abs(interval) > 1) {
+		return formatter.format(Math.floor(interval), 'hour');
+	}
+	interval = seconds / 60;
+	if (Math.abs(interval) > 1) {
+		return formatter.format(Math.floor(interval), 'minute');
+	}
+	return formatter.format(Math.floor(seconds), 'second');
+}
+
+function ScrobbleList(props: {
+	title: string;
+	type: ScrobbleStatus;
+	setActiveModal: Setter<ModalType>;
+	modal: HTMLDialogElement | undefined;
+}) {
+	const filteredScrobbles = createMemo(
+		() =>
+			scrobbles()
+				?.slice()
+				.reverse()
+				.filter((e) => e.status === props.type),
+	);
+
+	return (
+		<Show when={filteredScrobbles()?.length}>
+			<details>
+				<summary>
+					<ExpandMoreOutlined class={styles.expandVector} />
+					<span class={styles.summarySpan}>{t(props.title)}</span>
+				</summary>
+				<For each={filteredScrobbles()}>
+					{(scrobble) => {
+						return (
+							<ScrobbleDetailsWrapper
+								scrobble={scrobble}
+								type={props.type}
+								title={props.title}
+								setActiveModal={props.setActiveModal}
+								modal={props.modal}
+							/>
+						);
+					}}
+				</For>
+			</details>
+		</Show>
+	);
+}
+
+function ScrobbleDetailsWrapper(props: {
+	scrobble: CacheScrobble;
+	type: ScrobbleStatus;
+	title: string;
+	setActiveModal: Setter<ModalType>;
+	modal: HTMLDialogElement | undefined;
+}) {
+	return (
+		<Show
+			when={props.type === ScrobbleStatus.SUCCESSFUL}
+			fallback={
+				<label
+					class={`${styles.scrobble}${
+						isSelectingScrobbles()
+							? ` ${styles.selectingScrobble}`
+							: ''
+					}`}
+					onClick={(e) => {
+						if (!isSelectingScrobbles()) {
+							e.preventDefault();
+						}
+					}}
+				>
+					<ScrobbleDetails
+						scrobble={props.scrobble}
+						type={props.type}
+						title={props.title}
+						setActiveModal={props.setActiveModal}
+						modal={props.modal}
+					/>
+				</label>
+			}
+		>
+			<div class={styles.scrobble}>
+				<ScrobbleDetails
+					scrobble={props.scrobble}
+					type={props.type}
+					title={props.title}
+					setActiveModal={props.setActiveModal}
+					modal={props.modal}
+				/>
+			</div>
+		</Show>
+	);
+}
+
+function ScrobbleDetails(props: {
+	scrobble: CacheScrobble;
+	type: ScrobbleStatus;
+	title: string;
+	setActiveModal: Setter<ModalType>;
+	modal: HTMLDialogElement | undefined;
+}) {
+	const song = createMemo(() => new ClonedSong(props.scrobble.song, -1));
+	const dateString = createMemo(() =>
+		new Date(song().metadata.startTimestamp * 1000).toLocaleString(),
+	);
+
+	const [useShortMeta, setUseShortMeta] = createSignal(
+		window.innerWidth < 600,
+	);
+	const updateShortMeta = () => {
+		setUseShortMeta(window.innerWidth < 600);
+	};
+	const relativeString = createMemo(() =>
+		timeSince(song().metadata.startTimestamp * 1000, useShortMeta()),
+	);
+
+	onMount(() => {
+		window.addEventListener('resize', updateShortMeta);
+	});
+
+	onCleanup(() => {
+		window.removeEventListener('resize', updateShortMeta);
+	});
+
+	return (
+		<>
+			<Show when={props.scrobble.status !== ScrobbleStatus.SUCCESSFUL}>
+				<input
+					type="checkbox"
+					class={styles.scrobbleCheckbox}
+					hidden={
+						props.type === ScrobbleStatus.SUCCESSFUL ||
+						!isSelectingScrobbles()
+					}
+					onChange={(e) => {
+						if (e.currentTarget.checked) {
+							setSelectedScrobbles((prev) => [
+								...prev,
+								props.scrobble,
+							]);
+						} else {
+							setSelectedScrobbles((prev) =>
+								prev.filter((e) => e.id !== props.scrobble.id),
+							);
+						}
+					}}
+				/>
+			</Show>
+			<img
+				class={styles.coverArt}
+				src={
+					song().getTrackArt() ??
+					browser.runtime.getURL('img/cover_art_default.png')
+				}
+			/>
+			<div class={styles.scrobbleDetails}>
+				<div class={styles.scrobbleMetadata}>
+					<span class={styles.trackName}>{song().getTrack()}</span>
+					<span class={styles.artistName}>{song().getArtist()}</span>
+					<span class={styles.albumName}>{song().getAlbum()}</span>
+					<span class={styles.albumArtistName}>
+						{song().getAlbumArtist()}
+					</span>
+				</div>
+				<ScrobbleActions
+					song={song()}
+					id={props.scrobble.id}
+					status={props.scrobble.status}
+					setActiveModal={props.setActiveModal}
+					modal={props.modal}
+				/>
+			</div>
+			<span class={styles.scrobbleDate} title={dateString()}>
+				{relativeString()}
+			</span>
+		</>
+	);
+}
+
+function ScrobbleActions(props: {
+	song: ClonedSong;
+	id: number;
+	status: ScrobbleStatus;
+	setActiveModal: Setter<ModalType>;
+	modal: HTMLDialogElement | undefined;
+}) {
+	return (
+		<div class={styles.scrobbleActions}>
+			<Show when={props.status !== ScrobbleStatus.SUCCESSFUL}>
+				<button
+					class={styles.scrobbleActionButton}
+					onClick={(e) => {
+						e.stopImmediatePropagation();
+						setSongToEdit(props.song);
+						setScrobbleId(props.id);
+						props.setActiveModal('cacheEdit');
+						props.modal?.showModal();
+					}}
+				>
+					{t('infoEditTitleShort')}
+				</button>
+			</Show>
+			<button
+				class={styles.scrobbleActionButton}
+				onClick={() => {
+					void scrobbleCache.deleteScrobbles([props.id]).then(() => {
+						setScrobbles.refetch();
+					});
+				}}
+			>
+				{t('buttonRemove')}
+			</button>
+		</div>
+	);
+}
+
+function saveEditsWithKeyboard(
+	e: KeyboardEvent & {
+		currentTarget: HTMLInputElement;
+		target: Element;
+	},
+	artist: string,
+	track: string,
+	album: string,
+	albumArtist: string,
+	setLoading: Setter<boolean>,
+) {
+	if (e.key === 'Enter' && !e.isComposing && artist && track) {
+		saveEdits(e, artist, track, album, albumArtist, setLoading);
+	}
+}
+
+function saveEdits(
+	e: {
+		currentTarget: HTMLInputElement | HTMLButtonElement;
+		target: Element;
+	},
+	artist: string,
+	track: string,
+	album: string,
+	albumArtist: string,
+	setLoading: Setter<boolean>,
+) {
+	setLoading(true);
+	const shouldEditAll = (
+		document.getElementById('should-edit-multiple') as HTMLInputElement
+	).checked;
+	void editCacheScrobbles(
+		songToEdit(),
+		artist,
+		track,
+		album,
+		albumArtist,
+		shouldEditAll,
+	).then(() => {
+		setLoading(false);
+		setScrobbles.refetch();
+		e.target.closest('dialog')?.close();
+	});
+}
+
+async function editCacheScrobbles(
+	song: ClonedSong | undefined,
+	artist: string,
+	track: string,
+	album: string,
+	albumArtist: string,
+	shouldEditAll: boolean,
+) {
+	if (!artist || !track || !song) {
+		return;
+	}
+	const id = scrobbleId() ?? -1;
+	if (shouldEditAll) {
+		savedEdits.saveSongInfo(song, {
+			artist,
+			track,
+			album: album || null,
+			albumArtist: albumArtist || null,
+		});
+	}
+
+	const songId = SavedEditsModel.getSongId(song);
+	const filteredScrobbles = shouldEditAll
+		? scrobbles()?.filter(
+				(scrobble) =>
+					scrobble.status !== ScrobbleStatus.SUCCESSFUL &&
+					SavedEditsModel.getSongId(
+						new ClonedSong(scrobble.song, -1),
+					) === songId,
+			)
+		: scrobbles()?.filter((scrobble) => scrobble.id === id);
+	const editedScrobbles = filteredScrobbles?.map((scrobble) => {
+		scrobble.song.processed.track = track;
+		scrobble.song.processed.artist = artist;
+		scrobble.song.processed.album = album;
+		scrobble.song.processed.albumArtist = albumArtist;
+		return scrobble;
+	});
+
+	await scrobbleMultipleFromCache(editedScrobbles ?? []);
+}
+
+async function scrobbleMultipleFromCache(scrobbles: CacheScrobble[]) {
+	const scrobblesLimited = scrobbles.slice(0, 50);
+	await scrobbleCache.deleteScrobbles(
+		scrobblesLimited.map((scrobble) => scrobble.id),
+	);
+	try {
+		await sendContentMessage({
+			type: 'scrobble',
+			payload: {
+				songs: scrobblesLimited.map((scrobble) => scrobble.song),
+				currentlyPlaying: false,
+			},
+		});
+	} catch (err) {
+		debugLog(err, 'error');
+	}
+}
+
+async function scrobbleFromCache(song: CloneableSong, id: number) {
+	await scrobbleCache.deleteScrobbles([id]);
+	try {
+		await sendContentMessage({
+			type: 'scrobble',
+			payload: {
+				songs: [song],
+				currentlyPlaying: false,
+			},
+		});
+	} catch (err) {
+		debugLog(err, 'error');
+	}
+}
diff --git a/src/ui/options/components/showSomeLove.tsx b/src/ui/options/components/show-some-love.tsx
similarity index 100%
rename from src/ui/options/components/showSomeLove.tsx
rename to src/ui/options/components/show-some-love.tsx
diff --git a/src/ui/options/main.tsx b/src/ui/options/main.tsx
index 46c09a8f..2b6c90b5 100644
--- a/src/ui/options/main.tsx
+++ b/src/ui/options/main.tsx
@@ -18,6 +18,7 @@ import {
 	showSomeLoveItem,
 } from './components/navigator';
 import ContextMenu from '../components/context-menu/context-menu';
+import { CacheEditModal } from './components/scrobble-cache';
 import { BlockedTagsModal } from './components/edit-options/blocked-tags';
 import { BlocklistModal } from './components/edit-options/blocked-channels';
 
@@ -51,10 +52,11 @@ function getDefaultSetting(): NavigatorNavigationButton {
 
 const defaultSetting = getDefaultSetting();
 
-const modalMap = {
+const modals = {
 	savedEdits: EditsModal,
 	regexEdits: RegexEditsModal,
 	blocklist: BlocklistModal,
+	cacheEdit: CacheEditModal,
 	blockedTags: BlockedTagsModal,
 	'': () => <div>Loading...</div>,
 };
@@ -121,7 +123,7 @@ function Options() {
 				onClose={() => setActiveModal('')}
 			>
 				<div class={styles.modalContent}>
-					<Dynamic component={modalMap[activeModal()]} />
+					<Dynamic component={modals[activeModal()]} />
 				</div>
 				<button
 					class={styles.modalClose}
diff --git a/src/ui/options/settings.module.scss b/src/ui/options/settings.module.scss
index bc4cbeb2..5c866ea8 100644
--- a/src/ui/options/settings.module.scss
+++ b/src/ui/options/settings.module.scss
@@ -42,23 +42,23 @@ a {
 	}
 }
 
+.settingsContentWrapper {
+	flex: 1 1 auto;
+	overflow-y: auto;
+	padding: 0 1.5rem;
+	padding-bottom: 1.5rem;
+}
+
 @media only screen and (width <= 700px) {
 	.settings {
 		flex-direction: column-reverse;
 	}
 
 	.settingsContentWrapper {
-		padding: 0 1em;
+		padding: 0 1rem;
 	}
 }
 
-.settingsContentWrapper {
-	flex: 1 1 auto;
-	overflow-y: auto;
-	padding: 0 1.5em;
-	padding-bottom: 1.5em;
-}
-
 .settingsContent {
 	h1 {
 		font-size: 2.5em;
diff --git a/src/ui/popup/disallowed.tsx b/src/ui/popup/disallowed.tsx
index 8d5d672d..799d36b7 100644
--- a/src/ui/popup/disallowed.tsx
+++ b/src/ui/popup/disallowed.tsx
@@ -32,26 +32,12 @@ export default function Disallowed(props: { tab: Resource<ManagerTab> }) {
 						song()?.getTrack() ?? '???',
 					])}
 				</p>
-				<Show
-					when={song()?.metadata.label === 'YouTube'}
-					fallback={
-						<>
-							<p>{t('disallowedDesc2')}</p>
-							<ul>
-								<li>{t('disallowedDesc2Point1')}</li>
-								<li>{t('disallowedDesc2Point2')}</li>
-								<li>{t('disallowedDesc2Point3')}</li>
-							</ul>
-						</>
-					}
-				>
-					<p>{t('disallowedDescYoutube')}</p>
-					<ul>
-						<li>{t('disallowedDescYoutubePoint1')}</li>
-						<li>{t('disallowedDescYoutubePoint2')}</li>
-						<li>{t('disallowedDescYoutubePoint3')}</li>
-					</ul>
-				</Show>
+				<p>
+					{t(
+						`disallowedDesc${song()?.parsed
+							.scrobblingDisallowedReason}`,
+					)}
+				</p>
 				<p>{t('disallowedDesc3')}</p>
 				<button
 					class={`${optionComponentStyles.button} ${optionComponentStyles.centered}`}
diff --git a/src/ui/popup/popup.module.scss b/src/ui/popup/popup.module.scss
index 4827a545..535a3ab3 100644
--- a/src/ui/popup/popup.module.scss
+++ b/src/ui/popup/popup.module.scss
@@ -56,6 +56,10 @@ a {
 		display: flex;
 		gap: 0.5em;
 		width: 100%;
+
+		&::-webkit-details-marker {
+			display: none;
+		}
 	}
 
 	.expandVector {
@@ -256,7 +260,13 @@ a {
 
 	.regexFooter {
 		display: flex;
+		flex-direction: column;
 		width: 100%;
+
+		.regexFooterButtons {
+			display: flex;
+			width: 100%;
+		}
 	}
 }
 
@@ -270,7 +280,8 @@ a {
 	}
 
 	.nowPlayingPopup,
-	.alertPopup {
+	.alertPopup,
+	.widePopup {
 		align-items: center;
 		box-sizing: border-box;
 		flex: 1 1 auto;
diff --git a/src/ui/popup/regex.tsx b/src/ui/popup/regex.tsx
index 39628920..ef5b96b7 100644
--- a/src/ui/popup/regex.tsx
+++ b/src/ui/popup/regex.tsx
@@ -466,51 +466,55 @@ function Footer(props: {
 			<Show when={props.clonedSong?.flags.isCorrectedByUser}>
 				<span class={styles.editWarning}>{t('infoEditedWarning')}</span>
 			</Show>
-			<Show when={!isIos()}>
-				<div class={styles.controlButtons}>
-					<button
-						class={styles.controlButton}
-						title={t('infoSubmitTitle')}
-						onClick={() => {
-							saveEdit(props.tab);
-						}}
-					>
-						<CheckOutlined />
-					</button>
-					<Show when={props.blockedTypes()}>
-						{(blockedTypesLoaded) => (
-							<>
-								<BlockTagButton
-									isBlocked={blockedTypesLoaded().artist}
-									type="artist"
-									tabId={props.tab()?.tabId}
-									clonedSong={props.clonedSong}
-									UnblockIcon={() => <Person />}
-									BlockIcon={() => <PersonOff />}
-								/>
-								<Show when={props.clonedSong?.getAlbum()}>
+			<div class={styles.regexFooterButtons}>
+				<Show when={!isIos()}>
+					<div class={styles.controlButtons}>
+						<button
+							class={styles.controlButton}
+							title={t('infoSubmitTitle')}
+							onClick={() => {
+								saveEdit(props.tab);
+							}}
+						>
+							<CheckOutlined />
+						</button>
+						<Show when={props.blockedTypes()}>
+							{(blockedTypesLoaded) => (
+								<>
 									<BlockTagButton
-										isBlocked={blockedTypesLoaded().album}
-										type="album"
+										isBlocked={blockedTypesLoaded().artist}
+										type="artist"
 										tabId={props.tab()?.tabId}
 										clonedSong={props.clonedSong}
-										UnblockIcon={() => <Album />}
-										BlockIcon={() => <AlbumOff />}
+										UnblockIcon={() => <Person />}
+										BlockIcon={() => <PersonOff />}
 									/>
-								</Show>
-								<BlockTagButton
-									isBlocked={blockedTypesLoaded().track}
-									type="track"
-									tabId={props.tab()?.tabId}
-									clonedSong={props.clonedSong}
-									UnblockIcon={() => <MusicNote />}
-									BlockIcon={() => <MusicOff />}
-								/>
-							</>
-						)}
-					</Show>
-				</div>
-			</Show>
+									<Show when={props.clonedSong?.getAlbum()}>
+										<BlockTagButton
+											isBlocked={
+												blockedTypesLoaded().album
+											}
+											type="album"
+											tabId={props.tab()?.tabId}
+											clonedSong={props.clonedSong}
+											UnblockIcon={() => <Album />}
+											BlockIcon={() => <AlbumOff />}
+										/>
+									</Show>
+									<BlockTagButton
+										isBlocked={blockedTypesLoaded().track}
+										type="track"
+										tabId={props.tab()?.tabId}
+										clonedSong={props.clonedSong}
+										UnblockIcon={() => <MusicNote />}
+										BlockIcon={() => <MusicOff />}
+									/>
+								</>
+							)}
+						</Show>
+					</div>
+				</Show>
+			</div>
 		</div>
 	);
 }
diff --git a/src/util/communication.ts b/src/util/communication.ts
index 9cd8e05f..daf7acfe 100644
--- a/src/util/communication.ts
+++ b/src/util/communication.ts
@@ -76,9 +76,10 @@ interface ContentCommunications {
 	};
 	scrobble: {
 		payload: {
-			song: CloneableSong;
+			songs: CloneableSong[];
+			currentlyPlaying: boolean;
 		};
-		response: Promise<ServiceCallResult[]>;
+		response: Promise<ServiceCallResult[][]>;
 	};
 	getSongInfo: {
 		payload: {
diff --git a/src/util/util.ts b/src/util/util.ts
index 6496c64b..67f1960a 100644
--- a/src/util/util.ts
+++ b/src/util/util.ts
@@ -10,6 +10,7 @@ import {
 	WebhookModel,
 	Properties,
 	StateManagement,
+	CacheScrobble,
 	BlockedTags,
 	Blocklists,
 } from '@/core/storage/wrapper';
@@ -140,6 +141,7 @@ export function hideObjectValue(
 		| WebhookModel
 		| StateManagement
 		| RegexEdit[]
+		| CacheScrobble[]
 		| BlockedTags[]
 		| Blocklists,
 ): string {


Test patch:
diff --git a/tests/background/saved-edits.test.ts b/tests/background/saved-edits.test.ts
index 544fcb0b..d09b9451 100644
--- a/tests/background/saved-edits.test.ts
+++ b/tests/background/saved-edits.test.ts
@@ -3,7 +3,6 @@ import { expect, describe, it, beforeAll } from 'vitest';
 import webextensionPolyfill from '#/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';
@@ -15,7 +14,13 @@ const editedInfo = {
 	albumArtist: null,
 };
 
-const connectorStub = getConnectorById('youtube')!;
+const connectorStub = {
+	label: 'YouTube',
+	matches: ['*://www.youtube.com/*', '*://m.youtube.com/*'],
+	js: 'youtube.js',
+	id: 'youtube',
+};
+
 /**
  * Run all tests.
  */