Repository: web-scrobbler/web-scrobbler
Pull request #4408 (2024-01-07 23:32:02)

Add jam.coop connector
New connector: jam.coop
https://github.com/web-scrobbler/web-scrobbler/discussions/4396



Patch:
diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json
index 1b8a6da9..b64ea179 100644
--- a/src/_locales/en/messages.json
+++ b/src/_locales/en/messages.json
@@ -255,6 +255,18 @@
         "message": "$1 regex/bulk edits",
         "description": "'Regex/Bulk edits' header"
     },
+    "optionsBlockedSources": {
+        "message": "Blocked sources",
+        "description": "'Edited tracks' header"
+    },
+    "optionsBlockedSourcesDesc": {
+        "message": "You can block scrobbling from specific sources such as channels on this website.",
+        "description": "Description of blocked sources"
+    },
+    "optionsBlockedSourcesPopupTitle": {
+        "message": "$1 blocked sources",
+        "description": "'Blocked sources' header"
+    },
     "optionsViewEdited": {
         "message": "View",
         "description": "Button to view edited tracks"
@@ -831,6 +843,14 @@
         "message": "Disable $1 connector until tab is closed",
         "description": "Context menu item"
     },
+    "menuDisableChannel": {
+        "message": "Disable scrobbling from $1",
+        "description": "Context menu item"
+    },
+    "menuEnableChannel": {
+        "message": "Enable scrobbling from $1",
+        "description": "Context menu item"
+    },
     "notificationConnectAccounts": {
         "message": "Connect your accounts",
         "description": "Notification title"
diff --git a/src/connectors/jam.coop.ts b/src/connectors/jam.coop.ts
new file mode 100644
index 00000000..31eccb41
--- /dev/null
+++ b/src/connectors/jam.coop.ts
@@ -0,0 +1,94 @@
+export {};
+
+const trackListSelector = '[data-player-target="track"]';
+const progressSelector = '[data-player-target="progress"]';
+
+/**
+ * Finds the currently played track (as HTMLElement) in the track list.
+ * Contains track URL and duration.
+ */
+const getCurrentTrackListElement = () => {
+	const playingTrackName = Connector.getTrack();
+	if (!playingTrackName) {
+		return null;
+	}
+	const trackListElements = Util.queryElements(trackListSelector);
+	if (!trackListElements) {
+		return null;
+	}
+	for (const trackListElem of trackListElements) {
+		const trackListElemTitle = trackListElem.querySelector(
+			'span.cursor-pointer',
+		);
+		if (trackListElemTitle?.textContent === playingTrackName) {
+			return trackListElem;
+		}
+	}
+	return null;
+};
+
+/**
+ * While playing, a progress bar is updated frequently.
+ * Its width CSS attribute tells us the current duration percentage.
+ * @returns value between 0 and 100
+ */
+const getProgressPercentage = (): number => {
+	const progressElements = Util.queryElements(progressSelector);
+	if (!progressElements || !progressElements.length) {
+		return 0;
+	}
+	const widthCss = progressElements[0].style.width;
+	return parseFloat(widthCss);
+};
+
+Connector.playerSelector = '[data-controller="player"]';
+
+Connector.playButtonSelector = '[data-action="player#play"]';
+Connector.pauseButtonSelector = '[data-action="player#pause"]';
+
+Connector.artistSelector = `${Connector.playerSelector} h2`;
+
+Connector.trackSelector = `${Connector.playerSelector} h3`;
+
+Connector.albumSelector = `${Connector.playerSelector} h1`;
+
+Connector.trackArtSelector = `${Connector.playerSelector} img`;
+
+Connector.isPlaying = () => {
+	const elements = Util.queryElements(Connector.pauseButtonSelector);
+	if (!elements || !elements.length) {
+		return false;
+	}
+	return !elements[0].classList.contains('hidden');
+};
+
+Connector.getUniqueID = () => {
+	const currentTrackListElem = getCurrentTrackListElement();
+	if (!currentTrackListElem) {
+		return null;
+	}
+	return currentTrackListElem.getAttribute('data-track-url');
+};
+
+Connector.getDuration = () => {
+	const currentTrackListElem = getCurrentTrackListElement();
+	if (!currentTrackListElem) {
+		return null;
+	}
+	const durationElem =
+		currentTrackListElem.querySelector('span:nth-child(2)');
+	if (!durationElem) {
+		return null;
+	}
+	return Util.stringToSeconds(durationElem.textContent);
+};
+
+Connector.getCurrentTime = () => {
+	const duration = Connector.getDuration() ?? 0;
+	const percentage = getProgressPercentage();
+	return (duration * percentage) / 100;
+};
+
+// The player (dis)appears dynamically.
+// A viable approach to detect this is to call Connector.onStateChanged() from time to time
+setInterval(Connector.onStateChanged, 1000);
diff --git a/src/connectors/youtube.ts b/src/connectors/youtube.ts
index e66a4ab5..f96d815b 100644
--- a/src/connectors/youtube.ts
+++ b/src/connectors/youtube.ts
@@ -93,6 +93,15 @@ Connector.scrobbleInfoStyle = {
 	fontWeight: '700',
 };
 
+Connector.getChannelId = () =>
+	new URL(
+		(
+			document.querySelector(
+				'#upload-info .ytd-channel-name .yt-simple-endpoint',
+			) as HTMLAnchorElement
+		)?.href ?? 'https://youtube.com/',
+	).pathname.slice(2);
+
 Connector.getTrackInfo = () => {
 	const trackInfo: TrackInfoWithAlbum = {};
 
diff --git a/src/core/background/action.ts b/src/core/background/action.ts
index fdda163b..632327e1 100644
--- a/src/core/background/action.ts
+++ b/src/core/background/action.ts
@@ -1,5 +1,10 @@
 import browser from 'webextension-polyfill';
-import { contextMenus, getBrowserPreferredTheme } from './util';
+import {
+	contextMenus,
+	getBrowserPreferredTheme,
+	getChannelDetails,
+	isChannelBlocklisted,
+} from './util';
 import { sendPopupMessage } from '@/util/communication';
 import { ManagerTab } from '@/core/storage/wrapper';
 import * as ControllerMode from '@/core/object/controller/controller-mode';
@@ -40,6 +45,12 @@ async function updateMenus(tab: ManagerTab): Promise<void> {
 		browser.contextMenus?.update(contextMenus.DISABLE_UNTIL_CLOSED, {
 			visible: false,
 		});
+		browser.contextMenus?.update(contextMenus.DISABLE_CHANNEL, {
+			visible: false,
+		});
+		browser.contextMenus?.update(contextMenus.ENABLE_CHANNEL, {
+			visible: false,
+		});
 		return;
 	}
 
@@ -56,6 +67,12 @@ async function updateMenus(tab: ManagerTab): Promise<void> {
 		browser.contextMenus?.update(contextMenus.DISABLE_UNTIL_CLOSED, {
 			visible: false,
 		});
+		browser.contextMenus?.update(contextMenus.DISABLE_CHANNEL, {
+			visible: false,
+		});
+		browser.contextMenus?.update(contextMenus.ENABLE_CHANNEL, {
+			visible: false,
+		});
 		return;
 	}
 
@@ -70,6 +87,41 @@ async function updateMenus(tab: ManagerTab): Promise<void> {
 		visible: true,
 		title: t('menuDisableUntilTabClosed', connector?.label),
 	});
+
+	const channelDetails = await getChannelDetails(tab.tabId);
+	if (
+		!channelDetails ||
+		!channelDetails.channelId ||
+		!channelDetails.connector
+	) {
+		browser.contextMenus?.update(contextMenus.DISABLE_CHANNEL, {
+			visible: false,
+		});
+		browser.contextMenus?.update(contextMenus.ENABLE_CHANNEL, {
+			visible: false,
+		});
+	} else if (
+		await isChannelBlocklisted(
+			channelDetails.channelId,
+			channelDetails.connector,
+		)
+	) {
+		browser.contextMenus?.update(contextMenus.DISABLE_CHANNEL, {
+			visible: false,
+		});
+		browser.contextMenus?.update(contextMenus.ENABLE_CHANNEL, {
+			visible: true,
+			title: t('menuEnableChannel', channelDetails.channelId),
+		});
+	} else {
+		browser.contextMenus?.update(contextMenus.DISABLE_CHANNEL, {
+			visible: true,
+			title: t('menuDisableChannel', channelDetails.channelId),
+		});
+		browser.contextMenus?.update(contextMenus.ENABLE_CHANNEL, {
+			visible: false,
+		});
+	}
 }
 
 /**
diff --git a/src/core/background/main.ts b/src/core/background/main.ts
index b0d21d0c..553eb786 100644
--- a/src/core/background/main.ts
+++ b/src/core/background/main.ts
@@ -21,6 +21,8 @@ import {
 	getCurrentTab,
 	getActiveTabDetails,
 	getCurrentTabId,
+	addToBlocklist,
+	removeFromBlocklist,
 } from './util';
 import {
 	ControllerModeStr,
@@ -529,6 +531,20 @@ function onStartup() {
 		contexts: ['action'],
 		title: 'Error: You should not be seeing this',
 	});
+
+	browser.contextMenus.create({
+		id: contextMenus.ENABLE_CHANNEL,
+		visible: false,
+		contexts: ['action'],
+		title: 'Error: You should not be seeing this',
+	});
+
+	browser.contextMenus.create({
+		id: contextMenus.DISABLE_CHANNEL,
+		visible: false,
+		contexts: ['action'],
+		title: 'Error: You should not be seeing this',
+	});
 }
 
 /**
@@ -561,5 +577,13 @@ async function contextMenuHandler(info: browser.Menus.OnClickData) {
 			disableUntilClosed(tab.tabId);
 			break;
 		}
+		case contextMenus.ENABLE_CHANNEL: {
+			removeFromBlocklist(tab.tabId);
+			break;
+		}
+		case contextMenus.DISABLE_CHANNEL: {
+			addToBlocklist(tab.tabId);
+			break;
+		}
 	}
 }
diff --git a/src/core/background/util.ts b/src/core/background/util.ts
index e4ccfcf0..5de15fe5 100644
--- a/src/core/background/util.ts
+++ b/src/core/background/util.ts
@@ -6,13 +6,17 @@ import * as BrowserStorage from '@/core/storage/browser-storage';
 import { isPrioritizedMode } from '@/core/object/controller/controller';
 import { performUpdateAction } from './action';
 import { sendBackgroundMessage } from '@/util/communication';
+import { ConnectorMeta } from '../connectors';
 
 const state = BrowserStorage.getStorage(BrowserStorage.STATE_MANAGEMENT);
+const blocklistStorage = BrowserStorage.getStorage(BrowserStorage.BLOCKLISTS);
 
 export const contextMenus = {
 	ENABLE_CONNECTOR: 'enableConnector',
 	DISABLE_CONNECTOR: 'disableConnector',
 	DISABLE_UNTIL_CLOSED: 'disableUntilClosed',
+	ENABLE_CHANNEL: 'enableChannel',
+	DISABLE_CHANNEL: 'disableChannel',
 };
 
 /**
@@ -61,6 +65,32 @@ export async function filterInactiveTabs(activeTabs: ManagerTab[]) {
 	});
 }
 
+/**
+ * @param tabId - tab to get song from
+ * @returns the details about the channel of the currently playing song in tab
+ */
+export async function getChannelDetails(tabId: number) {
+	return sendBackgroundMessage(tabId, {
+		type: 'getChannelDetails',
+		payload: undefined,
+	});
+}
+
+/**
+ * Is current channel blocklisted
+ *
+ * @param channelId - ID of the channel to check
+ * @param connector - Details about the connector to check
+ * @returns true if current channel is blocklisted; false otherwise
+ */
+export async function isChannelBlocklisted(
+	channelId: string,
+	connector: ConnectorMeta,
+): Promise<boolean> {
+	const blocklist = await blocklistStorage.get();
+	return blocklist?.[connector.id]?.[channelId] ?? false;
+}
+
 /**
  * Unlock state management storage.
  */
@@ -236,6 +266,30 @@ export function enableConnector(tabId: number) {
 	});
 }
 
+/**
+ * Disables scrobbling current channel for a tab
+ *
+ * @param tabId - tab id of tab to disable scrobbling channel for
+ */
+export function addToBlocklist(tabId: number) {
+	sendBackgroundMessage(tabId, {
+		type: 'addToBlocklist',
+		payload: undefined,
+	});
+}
+
+/**
+ * Enables scrobbling current channel for a tab
+ *
+ * @param tabId - tab id of tab to disable scrobbling channel for
+ */
+export function removeFromBlocklist(tabId: number) {
+	sendBackgroundMessage(tabId, {
+		type: 'removeFromBlocklist',
+		payload: undefined,
+	});
+}
+
 /**
  * Disables connector of a tab until the tab is closed
  *
diff --git a/src/core/connectors.ts b/src/core/connectors.ts
index bbae475f..b3982a00 100644
--- a/src/core/connectors.ts
+++ b/src/core/connectors.ts
@@ -4,6 +4,11 @@ export interface ConnectorMeta {
 	js: string;
 	id: string;
 	allFrames?: true;
+
+	/**
+	 * true if connector uses blocklist. Connector must implement {@link Connector.getChannelId}
+	 */
+	usesBlocklist?: true;
 }
 
 export default <ConnectorMeta[]>[
@@ -12,6 +17,7 @@ export default <ConnectorMeta[]>[
 		matches: ['*://www.youtube.com/*', '*://m.youtube.com/*'],
 		js: 'youtube.js',
 		id: 'youtube',
+		usesBlocklist: true,
 	},
 	{
 		label: 'MySpace',
@@ -2323,4 +2329,10 @@ export default <ConnectorMeta[]>[
 		js: 'radiostudent.js',
 		id: 'radiostudent',
 	},
+	{
+		label: 'jam.coop',
+		matches: ['*://jam.coop/*'],
+		js: 'jam.coop.js',
+		id: 'jam.coop',
+	},
 ];
diff --git a/src/core/content/connector.ts b/src/core/content/connector.ts
index 710f9d83..a70d7f8c 100644
--- a/src/core/content/connector.ts
+++ b/src/core/content/connector.ts
@@ -144,6 +144,14 @@ export default class BaseConnector {
 		alignItems: 'center',
 	};
 
+	/**
+	 * Function that gets a unique ID for channel/user blocklist.
+	 *
+	 * Has to be specified if usesBlocklist is set to true in connectors.ts.
+	 * If connectors.ts does not have usesBlocklist set to true, this should be null.
+	 */
+	public getChannelId: (() => string | null | undefined) | null = null;
+
 	/**
 	 * Selector of element contains a track art of now playing song.
 	 * Default implementation looks for track art URL in `src` attribute or
diff --git a/src/core/object/cloned-song.ts b/src/core/object/cloned-song.ts
index 0baf97bb..70e7581a 100644
--- a/src/core/object/cloned-song.ts
+++ b/src/core/object/cloned-song.ts
@@ -18,6 +18,7 @@ export default class ClonedSong extends BaseSong {
 	public flags: Flags;
 	public metadata: Metadata;
 	public connectorLabel: string;
+	public connectorId: string;
 	public controllerTabId: number;
 
 	constructor(song: CloneableSong, controllerTabId: number) {
@@ -28,6 +29,7 @@ export default class ClonedSong extends BaseSong {
 		this.flags = song.flags;
 		this.metadata = song.metadata;
 		this.connectorLabel = song.connectorLabel;
+		this.connectorId = song.connectorId;
 		this.controllerTabId = controllerTabId;
 	}
 
diff --git a/src/core/object/controller/controller.ts b/src/core/object/controller/controller.ts
index 3382c562..e67380b4 100644
--- a/src/core/object/controller/controller.ts
+++ b/src/core/object/controller/controller.ts
@@ -25,6 +25,7 @@ import * as BrowserStorage from '@/core/storage/browser-storage';
 import { debugLog } from '@/core/content/util';
 import browser from 'webextension-polyfill';
 import BaseConnector from '@/core/content/connector';
+import Blocklist from '@/core/storage/blocklist';
 
 /**
  * List of song fields used to check if song is changed. If any of
@@ -72,15 +73,10 @@ export default class Controller {
 	private currentSong: Song | null = null;
 	private isReplayingSong = false;
 	private shouldScrobblePodcasts = true;
+	private blocklist: Blocklist;
 
 	private forceScrobble = false;
 	private shouldHaveScrobbled = false;
-	private shouldScrobble = () => {
-		if (this.forceScrobble) {
-			return true;
-		}
-		return this.currentSong?.parsed.isScrobblingAllowed;
-	};
 
 	private isEditing = false;
 	private setNotEditingTimeout = setTimeout(() => {
@@ -92,6 +88,21 @@ export default class Controller {
 		payload: undefined,
 	});
 
+	/**
+	 * @returns true if song should scrobble; false if disallowed.
+	 */
+	private async shouldScrobble() {
+		if (this.forceScrobble) {
+			return true;
+		}
+		return (
+			this.currentSong?.parsed.isScrobblingAllowed &&
+			(await this.blocklist.shouldScrobbleChannel(
+				this.connector.getChannelId?.(),
+			))
+		);
+	}
+
 	/**
 	 * Function that handles updating the scrobble info box
 	 */
@@ -192,6 +203,7 @@ export default class Controller {
 	 */
 	constructor(connector: BaseConnector, isEnabled: boolean) {
 		this.connector = connector;
+		this.blocklist = new Blocklist(this.connector.meta.id);
 		this.isEnabled = isEnabled;
 		this.mode = isEnabled ? ControllerMode.Base : ControllerMode.Disabled;
 		this.setMode(this.mode);
@@ -285,6 +297,35 @@ export default class Controller {
 					}
 				},
 			}),
+			contentListener({
+				type: 'getChannelDetails',
+				fn: () => ({
+					connector: this.connector.meta,
+					channelId: this.connector.getChannelId?.(),
+				}),
+			}),
+			contentListener({
+				type: 'addToBlocklist',
+				fn: async () => {
+					await this.blocklist.addToBlocklist(
+						this.connector.getChannelId?.() ?? '',
+					);
+					this.setMode(ControllerMode.Disallowed);
+				},
+			}),
+			contentListener({
+				type: 'removeFromBlocklist',
+				fn: async () => {
+					await this.blocklist.removeFromBlocklist(
+						this.connector.getChannelId?.() ?? '',
+					);
+					if (this.shouldHaveScrobbled) {
+						void this.scrobbleSong();
+					} else {
+						void this.setSongNowPlaying();
+					}
+				},
+			}),
 		);
 	}
 
@@ -321,7 +362,11 @@ export default class Controller {
 		switch (event) {
 			case ControllerEvents.SongNowPlaying: {
 				const song = this.getCurrentSong();
-				if (!song || song.flags.isReplaying || !this.shouldScrobble()) {
+				if (
+					!song ||
+					song.flags.isReplaying ||
+					!(await this.shouldScrobble())
+				) {
 					break;
 				}
 				const id = await this.tabId;
@@ -352,7 +397,7 @@ export default class Controller {
 			}
 			case ControllerEvents.SongUnrecognized: {
 				const song = this.getCurrentSong();
-				if (!song || !this.shouldScrobble()) {
+				if (!song || !(await this.shouldScrobble())) {
 					break;
 				}
 				const id = await this.tabId;
@@ -649,7 +694,7 @@ export default class Controller {
 	 * Process connector state as current one.
 	 * @param newState - Connector state
 	 */
-	private processCurrentState(newState: State): void {
+	private async processCurrentState(newState: State): Promise<void> {
 		if (!assertSongNotNull(this.currentSong)) {
 			return;
 		}
@@ -680,7 +725,7 @@ export default class Controller {
 			this.onPlayingStateChanged(isPlaying);
 		} else if (
 			this.mode === ControllerMode.Disallowed &&
-			this.shouldScrobble() &&
+			(await this.shouldScrobble()) &&
 			isPlaying
 		) {
 			// we need to unset disallowed whenever needed.
@@ -709,7 +754,7 @@ export default class Controller {
 			return;
 		}
 
-		if (this.shouldScrobble()) {
+		if (await this.shouldScrobble()) {
 			this.setMode(ControllerMode.Loading);
 		} else {
 			this.setMode(ControllerMode.Disallowed);
@@ -738,7 +783,7 @@ export default class Controller {
 			 * If the song is playing, mark it immediately;
 			 * otherwise will be flagged in isPlaying binding.
 			 */
-			if (!this.shouldScrobble()) {
+			if (!(await this.shouldScrobble())) {
 				this.setMode(ControllerMode.Disallowed);
 			} else if (this.currentSong.parsed.isPlaying) {
 				/*
@@ -784,7 +829,7 @@ export default class Controller {
 	 * Called when playing state is changed.
 	 * @param value - New playing state
 	 */
-	private onPlayingStateChanged(value: boolean | null): void {
+	private async onPlayingStateChanged(value: boolean | null): Promise<void> {
 		this.debugLog(`isPlaying state changed to ${String(value)}`);
 
 		if (value && this.currentSong) {
@@ -798,7 +843,7 @@ export default class Controller {
 			if (
 				!isMarkedAsPlaying &&
 				this.currentSong.isValid() &&
-				this.shouldScrobble()
+				(await this.shouldScrobble())
 			) {
 				void this.setSongNowPlaying();
 			} else {
@@ -906,7 +951,10 @@ export default class Controller {
 	 * now playing.
 	 */
 	private async setSongNowPlaying(): Promise<void> {
-		if (!assertSongNotNull(this.currentSong) || !this.shouldScrobble()) {
+		if (
+			!assertSongNotNull(this.currentSong) ||
+			!(await this.shouldScrobble())
+		) {
 			return;
 		}
 		this.currentSong.flags.isMarkedAsPlaying = true;
@@ -930,7 +978,10 @@ export default class Controller {
 	}
 
 	private async setPaused(): Promise<void> {
-		if (!assertSongNotNull(this.currentSong) || !this.shouldScrobble()) {
+		if (
+			!assertSongNotNull(this.currentSong) ||
+			!(await this.shouldScrobble())
+		) {
 			return;
 		}
 		await sendContentMessage({
@@ -942,7 +993,10 @@ export default class Controller {
 	}
 
 	private async setResumedPlaying(): Promise<void> {
-		if (!assertSongNotNull(this.currentSong) || !this.shouldScrobble()) {
+		if (
+			!assertSongNotNull(this.currentSong) ||
+			!(await this.shouldScrobble())
+		) {
 			return;
 		}
 		await sendContentMessage({
@@ -997,7 +1051,7 @@ export default class Controller {
 			return;
 		}
 
-		if (!this.shouldScrobble()) {
+		if (!(await this.shouldScrobble())) {
 			this.shouldHaveScrobbled = true;
 			return;
 		}
diff --git a/src/core/object/song.ts b/src/core/object/song.ts
index a7e94e23..b5d36351 100644
--- a/src/core/object/song.ts
+++ b/src/core/object/song.ts
@@ -60,6 +60,7 @@ export interface CloneableSong {
 	flags: Flags;
 	metadata: Metadata;
 	connectorLabel: string;
+	connectorId: string;
 }
 
 export abstract class BaseSong {
@@ -69,6 +70,7 @@ export abstract class BaseSong {
 	public abstract flags: Flags;
 	public abstract metadata: Metadata;
 	public abstract connectorLabel: string;
+	public abstract connectorId: string;
 
 	/**
 	 * Get song artist.
@@ -226,6 +228,7 @@ export abstract class BaseSong {
 			metadata: this.metadata,
 			flags: this.flags,
 			connectorLabel: this.connectorLabel,
+			connectorId: this.connectorId,
 		};
 	}
 
@@ -302,6 +305,7 @@ export default class Song extends BaseSong {
 	public flags: Flags;
 	public metadata: Metadata;
 	public connectorLabel: string;
+	public connectorId: string;
 	/**
 	 * @param parsedData - Current state received from connector
 	 * @param connector - Connector match object
@@ -365,6 +369,7 @@ export default class Song extends BaseSong {
 		};
 
 		this.connectorLabel = connector.label;
+		this.connectorId = connector.id;
 
 		this.initSongData();
 	}
diff --git a/src/core/storage/blocklist.ts b/src/core/storage/blocklist.ts
new file mode 100644
index 00000000..e7b1cb6d
--- /dev/null
+++ b/src/core/storage/blocklist.ts
@@ -0,0 +1,95 @@
+import * as BrowserStorage from '@/core/storage/browser-storage';
+
+export default class Blocklist {
+	private storage = BrowserStorage.getStorage(BrowserStorage.BLOCKLISTS);
+	private connectorId: string;
+	private isReady: Promise<true>;
+
+	constructor(connectorId: string) {
+		this.connectorId = connectorId;
+		this.isReady = this.init();
+	}
+
+	/**
+	 * Sets default values to blocklist if necessary
+	 */
+	private async setupDefaultBlocklist() {
+		let data = await this.storage.getLocking();
+		if (!data) {
+			data = {};
+		}
+		if (!(this.connectorId in data) || !data[this.connectorId]) {
+			data[this.connectorId] = {};
+		}
+		await this.storage.setLocking(data);
+	}
+
+	/**
+	 * Initializes the blocklist with default values
+	 */
+	private async init(): Promise<true> {
+		await this.setupDefaultBlocklist();
+		return true;
+	}
+
+	/**
+	 * Adds a channel ID to blocklist
+	 *
+	 * @param id - ID of channel to add
+	 */
+	public async addToBlocklist(id: string): Promise<void> {
+		if (!id) {
+			return;
+		}
+		await this.isReady;
+		const data = await this.storage.getLocking();
+		if (!data || !(this.connectorId in data) || !data[this.connectorId]) {
+			this.storage.unlock();
+			return;
+		}
+		data[this.connectorId] = {
+			...data[this.connectorId],
+			[id]: true,
+		};
+		await this.storage.setLocking(data);
+	}
+
+	/**
+	 * Removes a channel ID from blocklist
+	 *
+	 * @param id - ID of channel to remove
+	 */
+	public async removeFromBlocklist(id: string) {
+		if (!id) {
+			return;
+		}
+		await this.isReady;
+		const data = await this.storage.getLocking();
+		if (!data || !(this.connectorId in data) || !data[this.connectorId]) {
+			this.storage.unlock();
+			return;
+		}
+		delete data[this.connectorId][id];
+		this.storage.setLocking(data);
+	}
+
+	/**
+	 * @param id - ID of channel to check
+	 *
+	 * @returns true if channel isn't blocklisted; false if it is
+	 */
+	public async shouldScrobbleChannel(
+		id: string | undefined | null,
+	): Promise<boolean> {
+		if (!id) {
+			return true;
+		}
+		await this.isReady;
+		const data = await this.storage.get();
+		if (!data || !(this.connectorId in data) || !data[this.connectorId]) {
+			return true;
+		}
+
+		return data[this.connectorId][id] !== true;
+	}
+}
diff --git a/src/core/storage/browser-storage.ts b/src/core/storage/browser-storage.ts
index 21de20c1..839f4d53 100644
--- a/src/core/storage/browser-storage.ts
+++ b/src/core/storage/browser-storage.ts
@@ -62,6 +62,12 @@ export const NOTIFICATIONS = 'Notifications';
  */
 export const LOCAL_CACHE = 'LocalCache';
 
+/**
+ * This storage contains the blocklist of each connector.
+ * Each blocklist is an object with channel IDs as keys.
+ */
+export const BLOCKLISTS = 'Blocklists';
+
 /**
  * This storage contains the options values.
  * @see DEFAULT_OPTIONS_MAP object in `config` module.
@@ -128,6 +134,7 @@ const storageTypeMap = {
 	Maloja: LOCAL,
 	Webhook: LOCAL,
 
+	[BLOCKLISTS]: LOCAL,
 	[LOCAL_CACHE]: LOCAL,
 	[REGEX_EDITS]: LOCAL,
 	[CORE]: LOCAL,
diff --git a/src/core/storage/wrapper.ts b/src/core/storage/wrapper.ts
index 44585f3d..d7ffc76e 100644
--- a/src/core/storage/wrapper.ts
+++ b/src/core/storage/wrapper.ts
@@ -12,6 +12,7 @@ import {
 	REGEX_EDITS,
 	STATE_MANAGEMENT,
 	StorageNamespace,
+	BLOCKLISTS,
 } from '@/core/storage/browser-storage';
 import {
 	ConnectorOptions,
@@ -83,6 +84,10 @@ export interface StateManagement {
 	browserPreferredTheme: 'light' | 'dark';
 }
 
+export type Blocklists = Record<string, Blocklist>;
+
+export type Blocklist = Record<string, true>;
+
 export interface DataModels extends ScrobblerModels {
 	/* sync options */
 	[CUSTOM_PATTERNS]: CustomPatterns;
@@ -95,6 +100,7 @@ export interface DataModels extends ScrobblerModels {
 	[CORE]: { appVersion: string };
 	[LOCAL_CACHE]: { [key: string]: SavedEdit };
 	[REGEX_EDITS]: RegexEdit[];
+	[BLOCKLISTS]: Blocklists;
 
 	/* state management */
 	[STATE_MANAGEMENT]: StateManagement;
diff --git a/src/ui/options/components/connector-override.tsx b/src/ui/options/components/connector-override.tsx
index 9abd2ef8..1c3392d9 100644
--- a/src/ui/options/components/connector-override.tsx
+++ b/src/ui/options/components/connector-override.tsx
@@ -3,6 +3,7 @@ import * as Options from '@/core/storage/options';
 import {
 	Accessor,
 	For,
+	Setter,
 	Show,
 	Suspense,
 	createEffect,
@@ -23,6 +24,8 @@ import {
 } from './inputs';
 import browser from 'webextension-polyfill';
 import { t } from '@/util/i18n';
+import BlockedChannels from './edit-options/blocked-channels';
+import { ModalType } from './navigator';
 
 const globalOptions = BrowserStorage.getStorage(BrowserStorage.OPTIONS);
 const connectorOverrideOptions = BrowserStorage.getStorage(
@@ -45,7 +48,10 @@ const [customPatternOptions, setCustomPatternOptions] = createResource(
 /**
  * Component that shows the override options for all connectors
  */
-export default function ConnectorOverrideOptions() {
+export default function ConnectorOverrideOptions(props: {
+	setActiveModal: Setter<ModalType>;
+	modal: HTMLDialogElement | undefined;
+}) {
 	return (
 		<>
 			<h1>{t('optionsSupportedWebsites')}</h1>
@@ -95,7 +101,10 @@ export default function ConnectorOverrideOptions() {
 					/>
 				</li>
 
-				<ConnectorOptions />
+				<ConnectorOptions
+					setActiveModal={props.setActiveModal}
+					modal={props.modal}
+				/>
 			</ul>
 		</>
 	);
@@ -104,11 +113,20 @@ export default function ConnectorOverrideOptions() {
 /**
  * Connector Override Options list
  */
-function ConnectorOptions() {
+function ConnectorOptions(props: {
+	setActiveModal: Setter<ModalType>;
+	modal: HTMLDialogElement | undefined;
+}) {
 	return (
 		<Suspense fallback={<p>{t('optionsLoadingConnectorOptions')}</p>}>
 			<For each={connectors}>
-				{(connector) => <ConnectorOption connector={connector} />}
+				{(connector) => (
+					<ConnectorOption
+						setActiveModal={props.setActiveModal}
+						modal={props.modal}
+						connector={connector}
+					/>
+				)}
 			</For>
 		</Suspense>
 	);
@@ -117,7 +135,11 @@ function ConnectorOptions() {
 /**
  * The connector override options for one connector
  */
-function ConnectorOption(props: { connector: ConnectorMeta }) {
+function ConnectorOption(props: {
+	connector: ConnectorMeta;
+	setActiveModal: Setter<ModalType>;
+	modal: HTMLDialogElement | undefined;
+}) {
 	const [ref, setRef] = createSignal<HTMLDetailsElement>();
 	const [active, setActive] = createSignal(false);
 	createEffect(() => {
@@ -167,6 +189,8 @@ function ConnectorOption(props: { connector: ConnectorMeta }) {
 					/>
 				</summary>
 				<ConnectorOverrideOptionDetails
+					setActiveModal={props.setActiveModal}
+					modal={props.modal}
 					connector={props.connector}
 					active={active}
 				/>
@@ -181,6 +205,8 @@ function ConnectorOption(props: { connector: ConnectorMeta }) {
 function ConnectorOverrideOptionDetails(props: {
 	connector: ConnectorMeta;
 	active: Accessor<boolean>;
+	setActiveModal: Setter<ModalType>;
+	modal: HTMLDialogElement | undefined;
 }) {
 	return (
 		<Show when={props.active()}>
@@ -313,6 +339,13 @@ function ConnectorOverrideOptionDetails(props: {
 			<h3>{t('customPatterns')}</h3>
 			<p>{t('customPatternsHint')}</p>
 			<EditCustomPatterns connector={props.connector} />
+			<Show when={props.connector.usesBlocklist}>
+				<BlockedChannels
+					setActiveModal={props.setActiveModal}
+					modal={props.modal}
+					connector={props.connector}
+				/>
+			</Show>
 		</Show>
 	);
 }
diff --git a/src/ui/options/components/edit-options/blocked-channels.tsx b/src/ui/options/components/edit-options/blocked-channels.tsx
new file mode 100644
index 00000000..4b08b3ca
--- /dev/null
+++ b/src/ui/options/components/edit-options/blocked-channels.tsx
@@ -0,0 +1,127 @@
+import { t } from '@/util/i18n';
+import {
+	For,
+	Setter,
+	createEffect,
+	createResource,
+	createSignal,
+} from 'solid-js';
+import * as BrowserStorage from '@/core/storage/browser-storage';
+import styles from '../components.module.scss';
+import Delete from '@suid/icons-material/DeleteOutlined';
+import { ExportBlocklist, ImportBlocklist, ViewBlocklist } from './util';
+import { ModalType } from '../navigator';
+import { ConnectorMeta } from '@/core/connectors';
+import { Blocklists } from '@/core/storage/wrapper';
+
+const blocklistStorage = BrowserStorage.getStorage(BrowserStorage.BLOCKLISTS);
+const [blocklists, { mutate }] = createResource(
+	blocklistStorage.get.bind(blocklistStorage),
+);
+const [connector, setConnector] = createSignal<ConnectorMeta>();
+
+/**
+ * Component that allows the user to see, import, and export connector blocklists.
+ */
+export default function BlockedChannels(props: {
+	setActiveModal: Setter<ModalType>;
+	modal: HTMLDialogElement | undefined;
+	connector: ConnectorMeta;
+}) {
+	createEffect(() => {
+		setConnector(props.connector);
+	});
+
+	return (
+		<>
+			<h3>{t('optionsBlockedSources')}</h3>
+			<p>{t('optionsBlockedSourcesDesc')}</p>
+			<div class={styles.buttonContainer}>
+				<ViewBlocklist
+					setActiveModal={props.setActiveModal}
+					modal={props.modal}
+					type={'blocklist'}
+				/>
+				<ExportBlocklist
+					blocklistWrapper={blocklistStorage}
+					connector={props.connector}
+					filename={`${props.connector.id}-blocklist.json`}
+				/>
+				<ImportBlocklist
+					blocklistWrapper={blocklistStorage}
+					connector={props.connector}
+					mutate={mutate}
+				/>
+			</div>
+		</>
+	);
+}
+
+/**
+ * Component that shows all the currently registered blocked channels and allows the user to unblock them.
+ * To be displayed in a modal.
+ */
+export function BlocklistModal() {
+	return (
+		<>
+			<h1>
+				{t(
+					'optionsBlockedSourcesPopupTitle',
+					Object.keys(
+						blocklists()?.[connector()?.id ?? ''] ?? {},
+					).length.toString(),
+				)}
+			</h1>
+			<ul class={styles.optionList}>
+				<For
+					each={Object.entries(
+						blocklists()?.[connector()?.id ?? ''] ?? {},
+					)}
+				>
+					{([channel]) => (
+						<ChannelInfo
+							channel={channel}
+							connector={connector()}
+							mutate={mutate}
+						/>
+					)}
+				</For>
+			</ul>
+		</>
+	);
+}
+
+/**
+ * Component that shows a blocked channel and allows the user to delete it.
+ */
+function ChannelInfo(props: {
+	channel: string;
+	connector: ConnectorMeta | undefined;
+	mutate: Setter<Blocklists | null | undefined>;
+}) {
+	return (
+		<li class={styles.deleteListing}>
+			<button
+				class={`${styles.button} ${styles.small} ${styles.marginRight}`}
+				onClick={(event) => {
+					event.stopPropagation();
+					const channel = props.channel;
+					const connector = props.connector;
+					props.mutate((e) => {
+						if (!e || !connector || !e[connector.id]) {
+							return e;
+						}
+						delete e[connector.id][channel];
+						blocklistStorage.set(e);
+						return {
+							...e,
+						};
+					});
+				}}
+			>
+				<Delete />
+			</button>
+			<span>{props.channel}</span>
+		</li>
+	);
+}
diff --git a/src/ui/options/components/edit-options/util.tsx b/src/ui/options/components/edit-options/util.tsx
index 6813d80c..39f230a4 100644
--- a/src/ui/options/components/edit-options/util.tsx
+++ b/src/ui/options/components/edit-options/util.tsx
@@ -1,4 +1,4 @@
-import StorageWrapper from '@/core/storage/wrapper';
+import StorageWrapper, { Blocklists } from '@/core/storage/wrapper';
 import * as Options from '@/core/storage/options';
 import * as BrowserStorage from '@/core/storage/browser-storage';
 import Visibility from '@suid/icons-material/VisibilityOutlined';
@@ -9,6 +9,7 @@ import { t } from '@/util/i18n';
 import { Setter, createSignal } from 'solid-js';
 import { RegexEdit } from '@/util/regex';
 import { ModalType } from '../navigator';
+import { ConnectorMeta } from '@/core/connectors';
 
 type EditWrapper = StorageWrapper<
 	typeof BrowserStorage.REGEX_EDITS | typeof BrowserStorage.LOCAL_CACHE
@@ -45,6 +46,29 @@ export function ViewEdits(props: {
 	);
 }
 
+/**
+ * Button that allows the user to open the modal that shows them their track edits
+ */
+export function ViewBlocklist(props: {
+	setActiveModal: Setter<ModalType>;
+	modal: HTMLDialogElement | undefined;
+	type: ModalType;
+}) {
+	return (
+		<button
+			class={`${styles.button} ${styles.shiftLeft}`}
+			onClick={(e) => {
+				e.stopImmediatePropagation();
+				props.setActiveModal(props.type);
+				props.modal?.showModal();
+			}}
+		>
+			<Visibility />
+			{t('optionsViewEdited')}
+		</button>
+	);
+}
+
 /**
  * Button that exports edits for the user
  */
@@ -65,6 +89,52 @@ export function ExportEdits(props: {
 	);
 }
 
+/**
+ * Button that exports blocklist for the user
+ */
+export function ExportBlocklist(props: {
+	blocklistWrapper: StorageWrapper<typeof BrowserStorage.BLOCKLISTS>;
+	connector: ConnectorMeta;
+	filename: string;
+}) {
+	return (
+		<button
+			class={`${styles.button} ${styles.shiftLeft}`}
+			onClick={() =>
+				void downloadBlocklist(
+					props.blocklistWrapper,
+					props.connector,
+					props.filename,
+				)
+			}
+		>
+			<Upload />
+			{t('optionsExportEdited')}
+		</button>
+	);
+}
+
+/**
+ * Compiles all the users track edits from storage and downloads them
+ */
+async function downloadBlocklist(
+	blocklistWrapper: StorageWrapper<typeof BrowserStorage.BLOCKLISTS>,
+	connector: ConnectorMeta,
+	filename: string,
+) {
+	const blocklists = await blocklistWrapper.get();
+	if (!blocklists || !blocklists[connector.id]) {
+		return;
+	}
+	const data = `data:text/json;charset=UTF-8,${encodeURIComponent(
+		JSON.stringify(blocklists[connector.id]),
+	)}`;
+	const a = document.createElement('a');
+	a.download = filename;
+	a.href = data;
+	a.click();
+}
+
 /**
  * Compiles all the users track edits from storage and downloads them
  */
@@ -82,6 +152,40 @@ async function downloadEdits(editWrapper: EditWrapper, filename: string) {
 	a.click();
 }
 
+/**
+ * Button that allows the user to upload a .json file and get blocklist from it.
+ */
+export function ImportBlocklist(props: {
+	blocklistWrapper: StorageWrapper<typeof BrowserStorage.BLOCKLISTS>;
+	connector: ConnectorMeta;
+	mutate: Setter<Blocklists | null | undefined>;
+}) {
+	const [ref, setRef] = createSignal<HTMLInputElement>();
+	return (
+		<button
+			class={`${styles.button} ${styles.shiftLeft}`}
+			onClick={() => ref()?.click()}
+		>
+			<Download />
+			{t('optionsImportEdited')}
+			<input
+				hidden={true}
+				ref={setRef}
+				type="file"
+				accept=".json"
+				onChange={(e) =>
+					pushBlocklist(
+						e,
+						props.blocklistWrapper,
+						props.connector,
+						props.mutate,
+					)
+				}
+			/>
+		</button>
+	);
+}
+
 /**
  * Button that allows the user to upload a .json file and get edits from it.
  */
@@ -145,3 +249,45 @@ function pushEdits(
 	);
 	reader.readAsText(file);
 }
+
+/**
+ * Reads an imported .json file and import the blocklist to storage
+ */
+function pushBlocklist(
+	e: Event & {
+		currentTarget: HTMLInputElement;
+		target: Element;
+	},
+	blocklistWrapper: StorageWrapper<typeof BrowserStorage.BLOCKLISTS>,
+	connector: ConnectorMeta,
+	mutate: Setter<Blocklists | null | undefined>,
+) {
+	const file = e.currentTarget.files?.[0];
+	if (!file) {
+		return;
+	}
+	const reader = new FileReader();
+	reader.addEventListener(
+		'load',
+		(arg) =>
+			void (async (e) => {
+				const newBlocklist = JSON.parse(
+					e.target?.result as string,
+				) as Record<string, true>;
+				let blocklists = await blocklistWrapper.get();
+				if (!blocklists) {
+					blocklists = {};
+				}
+				if (!blocklists[connector.id]) {
+					blocklists[connector.id] = {};
+				}
+				blocklists[connector.id] = {
+					...blocklists[connector.id],
+					...newBlocklist,
+				};
+				blocklistWrapper.set(blocklists);
+				mutate(blocklists);
+			})(arg),
+	);
+	reader.readAsText(file);
+}
diff --git a/src/ui/options/components/navigator.ts b/src/ui/options/components/navigator.ts
index 83f8924b..64b3af26 100644
--- a/src/ui/options/components/navigator.ts
+++ b/src/ui/options/components/navigator.ts
@@ -32,7 +32,7 @@ import AdvancedOptionsComponent from './advanced-settings';
 /**
  * Type indicating possible states for modal
  */
-export type ModalType = 'savedEdits' | 'regexEdits' | '';
+export type ModalType = 'savedEdits' | 'regexEdits' | 'blocklist' | '';
 
 /**
  * Singular navigator button
diff --git a/src/ui/options/main.tsx b/src/ui/options/main.tsx
index 71bb5a98..e457e70a 100644
--- a/src/ui/options/main.tsx
+++ b/src/ui/options/main.tsx
@@ -1,8 +1,8 @@
-import { render } from 'solid-js/web';
+import { Dynamic, render } from 'solid-js/web';
 import styles from './settings.module.scss';
 import { initializeThemes } from '@/theme/themes';
 import '@/theme/themes.scss';
-import { Match, Show, Switch, createSignal, onCleanup } from 'solid-js';
+import { Show, createSignal, onCleanup } from 'solid-js';
 import Close from '@suid/icons-material/CloseOutlined';
 import Sidebar from './sidebar/sidebar';
 import { EditsModal } from './components/edit-options/edited-tracks';
@@ -18,6 +18,7 @@ import {
 	showSomeLoveItem,
 } from './components/navigator';
 import ContextMenu from '../components/context-menu/context-menu';
+import { BlocklistModal } from './components/edit-options/blocked-channels';
 
 /**
  * Media query for detecting whether to use context menu or sidebar
@@ -49,6 +50,13 @@ function getDefaultSetting(): NavigatorNavigationButton {
 
 const defaultSetting = getDefaultSetting();
 
+const modalMap = {
+	savedEdits: EditsModal,
+	regexEdits: RegexEditsModal,
+	blocklist: BlocklistModal,
+	'': () => <div>Loading...</div>,
+};
+
 /**
  * Preferences component, with a sidebar and several different options and info pages
  */
@@ -111,14 +119,7 @@ function Options() {
 				onClose={() => setActiveModal('')}
 			>
 				<div class={styles.modalContent}>
-					<Switch fallback={<div>Loading...</div>}>
-						<Match when={activeModal() === 'savedEdits'}>
-							<EditsModal />
-						</Match>
-						<Match when={activeModal() === 'regexEdits'}>
-							<RegexEditsModal />
-						</Match>
-					</Switch>
+					<Dynamic component={modalMap[activeModal()]} />
 				</div>
 				<button
 					class={styles.modalClose}
diff --git a/src/util/communication.ts b/src/util/communication.ts
index 9f42945e..b83b661a 100644
--- a/src/util/communication.ts
+++ b/src/util/communication.ts
@@ -156,6 +156,21 @@ interface BackgroundCommunications {
 		payload: undefined;
 		response: void;
 	};
+	addToBlocklist: {
+		payload: undefined;
+		response: Promise<void>;
+	};
+	removeFromBlocklist: {
+		payload: undefined;
+		response: Promise<void>;
+	};
+	getChannelDetails: {
+		payload: undefined;
+		response: {
+			connector: ConnectorMeta;
+			channelId: string | null | undefined;
+		};
+	};
 }
 
 /**
diff --git a/src/util/util.ts b/src/util/util.ts
index 22905884..ad590122 100644
--- a/src/util/util.ts
+++ b/src/util/util.ts
@@ -10,6 +10,7 @@ import {
 	WebhookModel,
 	Properties,
 	StateManagement,
+	Blocklists,
 } from '@/core/storage/wrapper';
 import { RegexEdit } from './regex';
 
@@ -137,7 +138,8 @@ export function hideObjectValue(
 		| ListenBrainzModel
 		| WebhookModel
 		| StateManagement
-		| RegexEdit[],
+		| RegexEdit[]
+		| Blocklists,
 ): string {
 	if (!keyValue) {
 		if (keyValue === null) {


Test patch:
diff --git a/tests/background/connectors.test.ts b/tests/background/connectors.test.ts
index 5b22f4b6..e9374038 100644
--- a/tests/background/connectors.test.ts
+++ b/tests/background/connectors.test.ts
@@ -7,12 +7,14 @@ import connectors, { ConnectorMeta } from '@/core/connectors';
 import * as UrlMatch from '@/util/url-match';
 
 const PROP_TYPES: {
+	usesBlocklist: 'boolean';
 	allFrames: 'boolean';
 	matches: 'array';
 	label: 'string';
 	js: 'string';
 	id: 'string';
 } = {
+	usesBlocklist: 'boolean',
 	allFrames: 'boolean',
 	matches: 'array',
 	label: 'string',