Repository: web-scrobbler/web-scrobbler
Pull request #4063 (2023-08-26 16:56:16)

Bump typescript from 5.1.6 to 5.2.2
Bumps [typescript](https://github.com/Microsoft/TypeScript) from 5.1.6 to 5.2.2.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a href="https://github.com/Microsoft/TypeScript/releases">typescript's releases</a>.</em></p>
<blockquote>
<h2>TypeScript 5.2</h2>
<p>For release notes, check out the <a href="https://devblogs.microsoft.com/typescript/announcing-typescript-5-2/">release announcement</a>.</p>
<p>For the complete list of fixed issues, check out the</p>
<ul>
<li><a href="https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&amp;q=milestone%3A%22TypeScript+5.2.0%22+is%3Aclosed+">fixed issues query for Typescript 5.2.0 (Beta)</a>.</li>
<li><a href="https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&amp;q=milestone%3A%22TypeScript+5.2.1%22+is%3Aclosed+">fixed issues query for Typescript 5.2.1 (RC)</a>.</li>
<li><a href="https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&amp;q=milestone%3A%22TypeScript+5.2.2%22+is%3Aclosed+">fixed issues query for Typescript 5.2.2 (Stable)</a>.</li>
</ul>
<p>Downloads are available on:</p>
<ul>
<li><a href="https://www.nuget.org/packages/Microsoft.TypeScript.MSBuild">NuGet package</a></li>
</ul>
<h2>TypeScript 5.2 RC</h2>
<p>For release notes, check out the <a href="https://devblogs.microsoft.com/typescript/announcing-typescript-5-2-rc/">release announcement</a>.</p>
<p>For the complete list of fixed issues, check out the</p>
<ul>
<li><a href="https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&amp;q=milestone%3A%22TypeScript+5.2.0%22+is%3Aclosed+">fixed issues query for Typescript 5.2.0 (Beta)</a>.</li>
<li><a href="https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&amp;q=milestone%3A%22TypeScript+5.2.1%22+is%3Aclosed+">fixed issues query for Typescript 5.2.1 (RC)</a>.</li>
</ul>
<p>Downloads are available on:</p>
<ul>
<li><a href="https://www.nuget.org/packages/Microsoft.TypeScript.MSBuild">NuGet package</a></li>
</ul>
<h2>TypeScript 5.2 Beta</h2>
<p>For release notes, check out the <a href="https://devblogs.microsoft.com/typescript/announcing-typescript-5-2-beta/">release announcement</a>.</p>
<p>For the complete list of fixed issues, check out the</p>
<ul>
<li><a href="https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&amp;q=milestone%3A%22TypeScript+5.2.0%22+is%3Aclosed+">fixed issues query for Typescript v5.2.0 (Beta)</a>.</li>
</ul>
<p>Downloads are available on <a href="https://www.npmjs.com/package/typescript">npm</a>.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/microsoft/TypeScript/commit/9684ba6b0d73c37546ada901e5d0a5324de7fc1d"><code>9684ba6</code></a> Cherry-pick fix for cross-file inlay hints (<a href="https://redirect.github.com/Microsoft/TypeScript/issues/55476">#55476</a>) to <code>release-5.2</code> and LKG ...</li>
<li><a href="https://github.com/microsoft/TypeScript/commit/555ef99d037547da48b8f7e4c629f571b706d1d5"><code>555ef99</code></a> Bump version to 5.2.2 and LKG</li>
<li><a href="https://github.com/microsoft/TypeScript/commit/6074b9d12b70757fe68ab2b4da059ea363c4df04"><code>6074b9d</code></a> Update LKG for 5.2.1 RC.</li>
<li><a href="https://github.com/microsoft/TypeScript/commit/b778ed1d8fb9ad3c23c191b78be4835437173de2"><code>b778ed1</code></a> Merge commit 'e936eb13d2900f21d79553c32a704307c7ad03dd' into release-5.2</li>
<li><a href="https://github.com/microsoft/TypeScript/commit/10b9962915de5136de972188046a429b02bfed55"><code>10b9962</code></a> Bump version to 5.2.1-rc and LKG</li>
<li><a href="https://github.com/microsoft/TypeScript/commit/e936eb13d2900f21d79553c32a704307c7ad03dd"><code>e936eb1</code></a> Update package-lock.json</li>
<li><a href="https://github.com/microsoft/TypeScript/commit/e36cd5768aa46ed2ce9487cce768222d8ee05a4d"><code>e36cd57</code></a> Update package-lock.json</li>
<li><a href="https://github.com/microsoft/TypeScript/commit/581fba1f6f85116116a5838e44521b4a99de6bad"><code>581fba1</code></a> Update package-lock.json</li>
<li><a href="https://github.com/microsoft/TypeScript/commit/8fc8c95830fa826eae2441aaa1e83bd2e06c1705"><code>8fc8c95</code></a> Decorators normative updates (<a href="https://redirect.github.com/Microsoft/TypeScript/issues/55276">#55276</a>)</li>
<li><a href="https://github.com/microsoft/TypeScript/commit/b1c4dc475cc0419747c6bec43d12f8e6f64e714c"><code>b1c4dc4</code></a> Fix class name references (<a href="https://redirect.github.com/Microsoft/TypeScript/issues/55262">#55262</a>)</li>
<li>Additional commits viewable in <a href="https://github.com/Microsoft/TypeScript/compare/v5.1.6...v5.2.2">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=typescript&package-manager=npm_and_yarn&previous-version=5.1.6&new-version=5.2.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)


</details>


Patch:
diff --git a/package-lock.json b/package-lock.json
index 9b6afaa5..4435bba8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "web-scrobbler",
-  "version": "3.0.15",
+  "version": "3.1.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "web-scrobbler",
-      "version": "3.0.15",
+      "version": "3.1.0",
       "license": "MIT",
       "dependencies": {
         "@suid/icons-material": "0.6.9",
@@ -36,11 +36,11 @@
         "eslint-plugin-solid": "0.12.1",
         "eslint-plugin-tsdoc": "0.2.17",
         "fs-extra": "11.1.1",
-        "glob": "10.3.3",
+        "glob": "10.3.4",
         "imagemin": "8.0.1",
         "imagemin-jpegtran": "7.0.0",
         "imagemin-pngquant": "9.0.2",
-        "prettier": "3.0.1",
+        "prettier": "3.0.2",
         "prettier-config-web-scrobbler": "0.1.0",
         "remark": "14.0.3",
         "remark-cli": "11.0.0",
@@ -49,7 +49,7 @@
         "solid-js": "1.7.11",
         "stylelint-config-web-scrobbler": "0.2.0",
         "tsx": "3.12.7",
-        "typescript": "5.1.6",
+        "typescript": "5.2.2",
         "vite-plugin-conditional-compiler": "0.1.1",
         "vite-plugin-solid": "2.7.0",
         "vite-plugin-static-copy": "0.17.0",
@@ -6201,9 +6201,9 @@
       }
     },
     "node_modules/glob": {
-      "version": "10.3.3",
-      "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.3.tgz",
-      "integrity": "sha512-92vPiMb/iqpmEgsOoIDvTjc50wf9CCCvMzsi6W0JLPeUKE8TWP1a73PgqSrqy7iAZxaSD1YdzU7QZR5LF51MJw==",
+      "version": "10.3.4",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.4.tgz",
+      "integrity": "sha512-6LFElP3A+i/Q8XQKEvZjkEWEOTgAIALR9AO2rwT8bgPhDd1anmqDJDZ6lLddI4ehxxxR1S5RIqKe1uapMQfYaQ==",
       "dev": true,
       "dependencies": {
         "foreground-child": "^3.1.0",
@@ -9763,9 +9763,9 @@
       }
     },
     "node_modules/prettier": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.1.tgz",
-      "integrity": "sha512-fcOWSnnpCrovBsmFZIGIy9UqK2FaI7Hqax+DIO0A9UxeVoY4iweyaFjS5TavZN97Hfehph0nhsZnjlVKzEQSrQ==",
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.2.tgz",
+      "integrity": "sha512-o2YR9qtniXvwEZlOKbveKfDQVyqxbEIWn48Z8m3ZJjBjcCmUy3xZGIv+7AkaeuaTr6yPXJjwv07ZWlsWbEy1rQ==",
       "dev": true,
       "bin": {
         "prettier": "bin/prettier.cjs"
@@ -12254,9 +12254,9 @@
       }
     },
     "node_modules/typescript": {
-      "version": "5.1.6",
-      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz",
-      "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==",
+      "version": "5.2.2",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
+      "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==",
       "dev": true,
       "bin": {
         "tsc": "bin/tsc",
diff --git a/package.json b/package.json
index 54dae77e..5391a1b9 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "web-scrobbler",
-  "version": "3.0.15",
+  "version": "3.1.0",
   "author": "David Šabata <posta@davidsabata.cz>",
   "contributors": [
     "@mountainash (https://mountainash.id.au/)"
@@ -53,11 +53,11 @@
     "eslint-plugin-solid": "0.12.1",
     "eslint-plugin-tsdoc": "0.2.17",
     "fs-extra": "11.1.1",
-    "glob": "10.3.3",
+    "glob": "10.3.4",
     "imagemin": "8.0.1",
     "imagemin-jpegtran": "7.0.0",
     "imagemin-pngquant": "9.0.2",
-    "prettier": "3.0.1",
+    "prettier": "3.0.2",
     "prettier-config-web-scrobbler": "0.1.0",
     "remark": "14.0.3",
     "remark-cli": "11.0.0",
@@ -66,7 +66,7 @@
     "solid-js": "1.7.11",
     "stylelint-config-web-scrobbler": "0.2.0",
     "tsx": "3.12.7",
-    "typescript": "5.1.6",
+    "typescript": "5.2.2",
     "vite-plugin-conditional-compiler": "0.1.1",
     "vite-plugin-solid": "2.7.0",
     "vite-plugin-static-copy": "0.17.0",
diff --git a/src/connectors/gensokyoradio-dom-inject.ts b/src/connectors/gensokyoradio-dom-inject.ts
new file mode 100644
index 00000000..838d2840
--- /dev/null
+++ b/src/connectors/gensokyoradio-dom-inject.ts
@@ -0,0 +1,31 @@
+/**
+ * This script runs in non-isolated environments (gensokyoradio.net) so that we
+ * have access to the global `audio` player.
+ */
+
+interface GensokyoWindow {
+	audio: HTMLAudioElement;
+}
+
+gensokyoSetupEventListener();
+
+function gensokyoSendUpdateEvent(type: string) {
+	window.postMessage(
+		{
+			sender: 'web-scrobbler',
+			type,
+			isPlaying: (window as unknown as GensokyoWindow).audio.paused
+				? false
+				: true,
+		},
+		'*',
+	);
+}
+
+function gensokyoSetupEventListener() {
+	const audio = (window as unknown as GensokyoWindow).audio;
+
+	for (const e of ['play', 'pause', 'ended', 'emptied', 'suspend']) {
+		audio.addEventListener(e, gensokyoSendUpdateEvent.bind(null, e));
+	}
+}
diff --git a/src/connectors/gensokyoradio.ts b/src/connectors/gensokyoradio.ts
new file mode 100644
index 00000000..fe6b1fa0
--- /dev/null
+++ b/src/connectors/gensokyoradio.ts
@@ -0,0 +1,34 @@
+export {};
+
+let isPlaying = false;
+
+Connector.playerSelector = '#heroMain';
+
+Connector.artistSelector = '#playerArtist';
+
+Connector.trackSelector = '#playerTitle';
+
+Connector.albumSelector = '#playerAlbum';
+
+Connector.albumArtistSelector = '#playerCircle';
+
+Connector.trackArtSelector = '#playerArt';
+
+Connector.timeInfoSelector = '#playerCounter';
+
+Connector.isPlaying = () => isPlaying;
+
+Connector.isTrackArtDefault = (trackArtUrl) => {
+	return (
+		!!trackArtUrl &&
+		/(gr-logo-placeholder|no-albumart)\.png$/i.test(trackArtUrl)
+	);
+};
+
+Connector.onScriptEvent = (event) => {
+	isPlaying = !!event.data.isPlaying;
+
+	Connector.onStateChanged();
+};
+
+Connector.injectScript('connectors/gensokyoradio-dom-inject.js');
diff --git a/src/connectors/stationhead.ts b/src/connectors/stationhead.ts
new file mode 100644
index 00000000..3542b33d
--- /dev/null
+++ b/src/connectors/stationhead.ts
@@ -0,0 +1,10 @@
+export {};
+
+const playerSelector = 
+	'#root>div>div:nth-child(2)>div>div>div>div>div:nth-child(2)>div:nth-child(2)>div';
+
+Connector.useMediaSessionApi();
+
+Connector.playerSelector = playerSelector;
+Connector.trackSelector = `${playerSelector} p`;
+Connector.artistSelector = `${playerSelector}>div>div>div p`;
diff --git a/src/core/background/main.ts b/src/core/background/main.ts
index 8ac71b50..04d61c32 100644
--- a/src/core/background/main.ts
+++ b/src/core/background/main.ts
@@ -29,6 +29,7 @@ import {
 import { CloneableSong } from '@/core/object/song';
 import {
 	clearNowPlaying,
+	showAuthNotification,
 	showNowPlaying,
 	showSongNotRecognized,
 } from '@/util/notifications';
@@ -36,6 +37,7 @@ import ClonedSong from '@/core/object/cloned-song';
 import { openTab } from '@/util/util-browser';
 import { setRegexDefaults } from '@/util/regex';
 import { getSongInfo, scrobble, sendNowPlaying, toggleLove } from './scrobble';
+import scrobbleService from '../object/scrobble-service';
 
 const disabledTabs = BrowserStorage.getStorage(BrowserStorage.DISABLED_TABS);
 
@@ -118,7 +120,7 @@ async function onTabRemoved(tabId: number) {
  * @param activeInfo - Information about the switch of tabs
  */
 async function onTabActivated(
-	activeInfo: browser.Tabs.OnActivatedActiveInfoType
+	activeInfo: browser.Tabs.OnActivatedActiveInfoType,
 ) {
 	await updateTabList(activeInfo.tabId, true);
 }
@@ -134,7 +136,7 @@ async function onTabActivated(
 async function onTabUpdated(
 	tabId: number,
 	changeInfo: browser.Tabs.OnUpdatedChangeInfoType,
-	tab?: browser.Tabs.Tab
+	tab?: browser.Tabs.Tab,
 ) {
 	if (tab?.active && changeInfo.status === 'complete') {
 		await updateTabList(tabId, false);
@@ -161,7 +163,7 @@ async function updateTabList(tabId: number, activated: boolean) {
 			newTabs = [curTab, ...newTabs];
 		} else {
 			newTabs = newTabs.map((active) =>
-				active.tabId === tabId ? curTab : active
+				active.tabId === tabId ? curTab : active,
 			);
 		}
 	}
@@ -182,7 +184,7 @@ async function updateTabList(tabId: number, activated: boolean) {
  */
 async function updateTab(
 	tabId: number | undefined,
-	fn: (tab: ManagerTab) => ManagerTab
+	fn: (tab: ManagerTab) => ManagerTab,
 ): Promise<void> {
 	if (!tabId) {
 		throw new Error('No tabid given');
@@ -250,7 +252,7 @@ async function updateMode(tabId: number | undefined, mode: ControllerModeStr) {
  */
 async function updateState(
 	tabId: number | undefined,
-	song: CloneableSong | null
+	song: CloneableSong | null,
 ) {
 	await updateTab(tabId, (oldTab) => ({
 		tabId: oldTab.tabId,
@@ -308,7 +310,7 @@ setupBackgroundListeners(
 				payload.connector,
 				() => {
 					openTab(sender.tab?.id ?? -1);
-				}
+				},
 			);
 		},
 	}),
@@ -320,7 +322,7 @@ setupBackgroundListeners(
 		type: 'setNowPlaying',
 		fn: (payload, sender) => {
 			return sendNowPlaying(
-				new ClonedSong(payload.song, sender.tab?.id ?? -1)
+				new ClonedSong(payload.song, sender.tab?.id ?? -1),
 			);
 		},
 	}),
@@ -342,7 +344,7 @@ setupBackgroundListeners(
 		type: 'getSongInfo',
 		fn: (payload, sender) => {
 			return getSongInfo(
-				new ClonedSong(payload.song, sender.tab?.id ?? -1)
+				new ClonedSong(payload.song, sender.tab?.id ?? -1),
 			);
 		},
 	}),
@@ -355,7 +357,7 @@ setupBackgroundListeners(
 		fn: (payload, sender) => {
 			return toggleLove(
 				new ClonedSong(payload.song, sender.tab?.id ?? -1),
-				payload.isLoved
+				payload.isLoved,
 			);
 		},
 	}),
@@ -381,7 +383,7 @@ setupBackgroundListeners(
 				payload.connector,
 				() => {
 					openTab(sender.tab?.id ?? -1);
-				}
+				},
 			);
 		},
 	}),
@@ -398,9 +400,38 @@ setupBackgroundListeners(
 				browserPreferredTheme: payload,
 			});
 		},
-	})
+	}),
 );
 
+/**
+ * Replace the extension version stored in local storage by current one.
+ */
+async function updateVersionInStorage() {
+	const storage = BrowserStorage.getStorage(BrowserStorage.CORE);
+	let data = await storage.get();
+	if (!data) {
+		data = {
+			appVersion: '',
+		};
+	}
+
+	data.appVersion = browser.runtime.getManifest().version;
+	await storage.set(data);
+
+	storage.debugLog();
+}
+
+/**
+ * Ask a scrobble service to bind scrobblers.
+ *
+ * @returns true if at least one scrobbler is registered;
+ *          false if no scrobblers are registered
+ */
+async function bindScrobblers() {
+	const boundScrobblers = await scrobbleService.bindAllScrobblers();
+	return boundScrobblers.length > 0;
+}
+
 /**
  * Sets up the starting state of the extension on browser startup/extension install.
  * Storage is used instead of variables, as with Manifest V3 service workers, script state cannot be guaranteed.
@@ -411,6 +442,13 @@ function startupFunc() {
 	disabledTabs.set({});
 
 	setRegexDefaults();
+	updateVersionInStorage();
+	bindScrobblers().then((bound) => {
+		if (!bound) {
+			console.warn('No scrobblers are bound');
+			showAuthNotification();
+		}
+	});
 
 	browser.contextMenus?.create({
 		id: contextMenus.ENABLE_CONNECTOR,
diff --git a/src/core/connectors.ts b/src/core/connectors.ts
index 41f919ae..b90bb4bf 100644
--- a/src/core/connectors.ts
+++ b/src/core/connectors.ts
@@ -1926,7 +1926,7 @@ export default <ConnectorMeta[]>[
 	},
 	{
 		label: 'Beatbump',
-		matches: ['*://beatbump.ml/*'],
+		matches: ['*://beatbump.io/*'],
 		js: 'beatbump.js',
 		id: 'beatbump',
 	},
@@ -2112,4 +2112,16 @@ export default <ConnectorMeta[]>[
 		js: 'trackerhub.js',
 		id: 'trackerhub',
 	},
+	{
+		label: 'Gensokyo Radio',
+		matches: ['*://gensokyoradio.net/*'],
+		js: 'gensokyoradio.js',
+		id: 'gensokyoradio',
+	},
+	{
+		label: 'Stationhead',
+		matches: ['*://*stationhead.com/*'],
+		js: 'stationhead.js',
+		id: 'stationhead',
+	},
 ];
diff --git a/src/ui/options/main.tsx b/src/ui/options/main.tsx
index 9b477304..9c0d4eee 100644
--- a/src/ui/options/main.tsx
+++ b/src/ui/options/main.tsx
@@ -12,6 +12,8 @@ import {
 	ModalType,
 	NavigatorNavigationButton,
 	aboutItem,
+	accountItem,
+	connectorOverrideOptionsItem,
 	settings,
 	showSomeLoveItem,
 } from './components/navigator';
@@ -26,11 +28,26 @@ function contextMenuQuery() {
 	return window.matchMedia('(max-width: 700px)').matches;
 }
 
-// Show some love as default except safari, where we don't want it shown because apple
-let defaultSetting = aboutItem;
-// #v-ifndef VITE_SAFARI
-defaultSetting = showSomeLoveItem;
-// #v-endif
+function getDefaultSetting(): NavigatorNavigationButton {
+	// if a page override is selected, return that page
+	const pageTitle = new URLSearchParams(window.location.search).get('p');
+	switch (pageTitle) {
+		case 'accounts':
+			return accountItem;
+		case 'connectors':
+			return connectorOverrideOptionsItem;
+	}
+
+	// in non-safari, go to show some love page
+	// #v-ifndef VITE_SAFARI
+	return showSomeLoveItem;
+	// #v-endif
+
+	// We actually reach this in safari, return about page
+	return aboutItem;
+}
+
+const defaultSetting = getDefaultSetting();
 
 /**
  * Preferences component, with a sidebar and several different options and info pages
@@ -59,7 +76,7 @@ function Options() {
 	onCleanup(() => document.removeEventListener('click', onclick));
 
 	const [shouldShowContextMenu, setShouldShowContextMenu] = createSignal(
-		contextMenuQuery()
+		contextMenuQuery(),
 	);
 	const resizeListener = () => setShouldShowContextMenu(contextMenuQuery());
 	window.addEventListener('resize', resizeListener);
diff --git a/src/ui/popup/disabled.tsx b/src/ui/popup/disabled.tsx
index 6a03ad8a..81ff9513 100644
--- a/src/ui/popup/disabled.tsx
+++ b/src/ui/popup/disabled.tsx
@@ -16,7 +16,9 @@ export default function Disabled() {
 			<h1>{t('disabledSiteHeader')}</h1>
 			<p>{t('disabledSiteDesc')}</p>
 			<PopupAnchor
-				href={browser.runtime.getURL('src/ui/options/index.html')}
+				href={browser.runtime.getURL(
+					'src/ui/options/index.html?p=connectors',
+				)}
 				class={`${optionComponentStyles.linkButton} ${styles.centered}`}
 			>
 				<Settings />
diff --git a/src/ui/popup/err.tsx b/src/ui/popup/err.tsx
index 21418525..c4695105 100644
--- a/src/ui/popup/err.tsx
+++ b/src/ui/popup/err.tsx
@@ -18,7 +18,9 @@ export default function Err() {
 				<TPopupAnchor messageName="serviceErrorDesc" />
 			</p>
 			<PopupAnchor
-				href={browser.runtime.getURL('src/ui/options/index.html')}
+				href={browser.runtime.getURL(
+					'src/ui/options/index.html?p=accounts',
+				)}
 				class={`${optionComponentStyles.linkButton} ${styles.centered}`}
 			>
 				<Settings />
diff --git a/src/util/notifications.ts b/src/util/notifications.ts
index 48b8fd28..1e521b78 100644
--- a/src/util/notifications.ts
+++ b/src/util/notifications.ts
@@ -6,6 +6,7 @@ import * as Options from '@/core/storage/options';
 import { ConnectorMeta } from '@/core/connectors';
 import { Scrobbler } from '@/core/object/scrobble-service';
 import { debugLog } from '@/core/content/util';
+import * as BrowserStorage from '@/core/storage/browser-storage';
 
 /**
  * Notification service.
@@ -43,6 +44,9 @@ let notificationTimeoutId: NodeJS.Timeout | null = null;
  * @returns Check result
  */
 async function isAvailable() {
+	if (!browser?.notifications) {
+		return false;
+	}
 	// #v-ifdef VITE_CHROME
 	const platform = await getPlatformName();
 	if (platform === 'mac') {
@@ -74,7 +78,7 @@ async function isAllowed(connector: ConnectorMeta) {
  * @param notificationId - Notification ID
  * @param callback - Function that will be called on notification click
  */
-function addOnClickedListener(notificationId: number, callback: () => void) {
+function addOnClickedListener(notificationId: string, callback: () => void) {
 	clickListeners[notificationId] = callback;
 }
 
@@ -112,7 +116,7 @@ interface ProcessedNotificationOptions extends BaseNotificationOptions {
  */
 async function showNotification(
 	options: BaseNotificationOptions,
-	onClick: (() => void) | null
+	onClick: (() => void) | null,
 ) {
 	if (!(await isAvailable())) {
 		throw new Error('Notifications are not available');
@@ -131,7 +135,7 @@ async function showNotification(
 	try {
 		notificationId = await browser.notifications?.create(
 			'',
-			processedOptions
+			processedOptions,
 		);
 	} catch (err) {
 		// Use default track art and try again
@@ -142,12 +146,12 @@ async function showNotification(
 		processedOptions.iconUrl = defaultTrackArtUrl;
 		notificationId = await browser.notifications?.create(
 			'',
-			processedOptions
+			processedOptions,
 		);
 	}
 
 	if (typeof onClick === 'function') {
-		addOnClickedListener(parseInt(notificationId), onClick);
+		addOnClickedListener(notificationId, onClick);
 	}
 
 	return notificationId;
@@ -162,7 +166,7 @@ async function showNotification(
 export async function showNowPlaying(
 	song: BaseSong,
 	connector: ConnectorMeta,
-	onClick: () => void
+	onClick: () => void,
 ): Promise<void> {
 	if (!(await isAllowed(connector))) {
 		return;
@@ -186,7 +190,7 @@ export async function showNowPlaying(
 	if (userPlayCount) {
 		const userPlayCountStr = browser.i18n.getMessage(
 			'infoYourScrobbles',
-			userPlayCount.toString()
+			userPlayCount.toString(),
 		);
 		message = `${message ?? 'null'}\n${userPlayCountStr}`;
 	}
@@ -235,7 +239,7 @@ export function clearNowPlaying(song: BaseSong): void {
  */
 export function showError(
 	message: string,
-	onClick: (() => void) | null = null
+	onClick: (() => void) | null = null,
 ): void {
 	const title = browser.i18n.getMessage('notificationAuthError');
 	const options = { title, message };
@@ -249,11 +253,11 @@ export function showError(
  */
 export function showSignInError(
 	scrobbler: Scrobbler,
-	onClick: () => void
+	onClick: () => void,
 ): void {
 	const errorMessage = browser.i18n.getMessage(
 		'notificationUnableSignIn',
-		scrobbler.getLabel()
+		scrobbler.getLabel(),
 	);
 	showError(errorMessage, onClick);
 }
@@ -267,12 +271,12 @@ export function showSignInError(
 export async function showSongNotRecognized(
 	song: BaseSong,
 	connector: ConnectorMeta,
-	onClick: () => void
+	onClick: () => void,
 ): Promise<void> {
 	if (
 		!(await Options.getOption(
 			Options.USE_UNRECOGNIZED_SONG_NOTIFICATIONS,
-			connector.id
+			connector.id,
 		))
 	) {
 		return;
@@ -284,21 +288,81 @@ export async function showSongNotRecognized(
 		message: browser.i18n.getMessage('notificationNotRecognizedText'),
 	};
 
-	const notificationId = await showNotification(options, onClick);
-	song.metadata.notificationId = notificationId;
+	try {
+		const notificationId = await showNotification(options, onClick);
+		song.metadata.notificationId = notificationId;
+	} catch (err) {
+		debugLog('Unable to show song not recognized notification: ', 'warn');
+		debugLog(err, 'warn');
+	}
+}
+
+/**
+ * How many times to show auth notification.
+ */
+const authNotificationDisplayCount = 3;
+
+/**
+ * Storage for auth notification display count.
+ */
+const notificationStorage = BrowserStorage.getStorage(
+	BrowserStorage.NOTIFICATIONS,
+);
+
+/**
+ * Check if auth notification is allowed.
+ * @returns Check result
+ */
+async function isAuthNotificationAllowed(): Promise<boolean> {
+	const displayCount =
+		(await notificationStorage.get())?.authDisplayCount || 0;
+	return displayCount < authNotificationDisplayCount;
+}
+
+/**
+ * Update internal counter of displayed auth notifications.
+ */
+async function updateAuthDisplayCount() {
+	let data = await notificationStorage.get();
+	if (!data) {
+		data = {
+			authDisplayCount: 0,
+		};
+	}
+
+	data.authDisplayCount = data.authDisplayCount + 1;
+	await notificationStorage.set(data);
 }
 
 /**
  * Show auth notification.
- * @param onClick - Function that will be called on notification click
  */
-export async function showAuthNotification(onClick: () => void): Promise<void> {
+export async function showAuthNotification(): Promise<void> {
+	if (!(await isAuthNotificationAllowed())) {
+		return;
+	}
+
 	const options = {
 		title: browser.i18n.getMessage('notificationConnectAccounts'),
 		message: browser.i18n.getMessage('notificationConnectAccountsText'),
 	};
 
-	await showNotification(options, onClick);
+	try {
+		await showNotification(options, () => {
+			browser.tabs.create({
+				url: browser.runtime.getURL(
+					'src/ui/options/index.html?p=accounts',
+				),
+			});
+		});
+	} catch (err) {
+		debugLog('Unable to show auth notification: ', 'warn');
+		debugLog(err, 'warn');
+		browser.tabs.create({
+			url: browser.runtime.getURL('src/ui/options/index.html?p=accounts'),
+		});
+	}
+	void updateAuthDisplayCount();
 }
 
 /**


Test patch:
diff --git a/vitest.config.ts b/vitest.config.ts
index 1d9c25e3..977024a1 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -9,4 +9,7 @@ export default {
 		},
 	},
 	plugins: [ConditionalCompile()],
+	test: {
+		testTimeout: 10000,
+	},
 };