Repository: web-scrobbler/web-scrobbler
Pull request #4067 (2023-08-29 09:35:35)

Silence console logs from content scripts by default
## Changes

Adds a new "Enable debug logging" option to the extension's global options (default: `false`). All logging from content scripts to the browser console is silenced unless the user manually enables the option.

## Context

Web Scrobbler can sometimes print a _lot_ of messages to the user's browser console, even on websites that don't have a connector. For example:

<img width="1029" alt="image" src="https://github.com/web-scrobbler/web-scrobbler/assets/1564277/4f7de57c-f516-421b-8999-ae58e3282755">

This can be very annoying for users who frequently use their browser console, since it can make it hard to find other messages printed there, and there's currently no way to silence console messages from Web Scrobbler other than disabling/uninstalling the extension. There are a few existing issues about this: #3828, #3110

This PR adds a new "Enable debug logging" option to Web Scrobbler's global options, sets it to `false` by default, and only allows the extension to print messages to the browser console if the user has switched it to `true`.

This doesn't affect any of the logging to the extension's service worker console or the console on the extension's options pages, just logs from content scripts to the user's main browser console when they're on a regular web page.

## Screenshots

New "Enable debug logging" option added to the global options UI:

<img width="890" alt="image" src="https://github.com/web-scrobbler/web-scrobbler/assets/1564277/53cb508f-aba9-4a0b-bb9c-91fb9167b7d8">

## Considerations

[The instructions for collecting logs from content scripts](https://github.com/web-scrobbler/web-scrobbler/wiki/Debug-the-extension#logs-of-content-scripts) would need to be updated once this change is released, since users will now need to switch on "Enable debug logging" before any logs will appear.

There are a few aspects of this PR that could use some improvement, I'd love any help making these better since they're a bit beyond my skills with TypeScript:

- ~~I've added a new `getGlobalOption()` function that has a return type of `Promise<unknown>`. The Promise should only ever resolve to one of the value types of [the `GlobalOptions` interface](https://github.com/web-scrobbler/web-scrobbler/blob/1f455fd/src/core/storage/options.ts#L23), but I couldn't work out the syntax for that so I've left it as `unknown`.~~ Now resolved as of a1101ef.
- ~~The new `getGlobalOption()` function duplicates a lot of code from the existing `getOption()` function above it, so they could probably be combined or refactored to share some code. I couldn't reuse `getOption()` in its current state because it calls `debugLog()`, which could lead to an infinite loop if it's called from within `debugLog()`. If maintainers would be okay with removing that `debugLog()` call, then the functions would become easier to refactor.~~ Now resolved as of a1101ef.
- I haven't added any tests for this new behaviour because there are no existing tests for `debugLog()` or `getOption()` for me to extend, and I'm not familiar enough with TypeScript or vitest to confidently add new tests.


Patch:
diff --git a/package-lock.json b/package-lock.json
index 9b6afaa5..382b76f5 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",
@@ -23,24 +23,24 @@
         "@types/imagemin": "8.0.1",
         "@types/imagemin-jpegtran": "5.0.1",
         "@types/node": "20.5.6",
-        "@types/react": "18.2.20",
-        "@types/webextension-polyfill": "0.10.1",
-        "@typescript-eslint/eslint-plugin": "6.4.1",
+        "@types/react": "18.2.21",
+        "@types/webextension-polyfill": "0.10.2",
+        "@typescript-eslint/eslint-plugin": "6.6.0",
         "archiver": "6.0.0",
         "canvas": "2.11.2",
         "chokidar": "3.5.3",
         "esbuild": "0.19.2",
-        "eslint": "8.47.0",
+        "eslint": "8.48.0",
         "eslint-config-web-scrobbler": "2.1.1",
         "eslint-plugin-react": "7.33.2",
-        "eslint-plugin-solid": "0.12.1",
+        "eslint-plugin-solid": "0.13.0",
         "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.3",
         "prettier-config-web-scrobbler": "0.1.0",
         "remark": "14.0.3",
         "remark-cli": "11.0.0",
@@ -49,11 +49,11 @@
         "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",
-        "vitest": "0.34.2"
+        "vitest": "0.34.3"
       },
       "engines": {
         "node": ">=18.0.0"
@@ -1472,9 +1472,9 @@
       }
     },
     "node_modules/@eslint/js": {
-      "version": "8.47.0",
-      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.47.0.tgz",
-      "integrity": "sha512-P6omY1zv5MItm93kLM8s2vr1HICJH8v0dvddDhysbIuZ+vcjOHg5Zbkf1mTkcmi2JA9oBG2anOkRnW8WJTS8Og==",
+      "version": "8.48.0",
+      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.48.0.tgz",
+      "integrity": "sha512-ZSjtmelB7IJfWD2Fvb7+Z+ChTIKWq6kjda95fLcQKNS5aheVHn4IkfgRQE3sIIzTcSLwLcLZUD9UBt+V7+h+Pw==",
       "dev": true,
       "engines": {
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@@ -1514,9 +1514,9 @@
       "dev": true
     },
     "node_modules/@jest/schemas": {
-      "version": "29.6.0",
-      "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.0.tgz",
-      "integrity": "sha512-rxLjXyJBTL4LQeJW3aKo0M/+GkCOXsO+8i9Iu7eDb6KwtP65ayoDsitrdPBtujxQ88k4wI2FNYfa6TOGwSn6cQ==",
+      "version": "29.6.3",
+      "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
+      "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
       "dev": true,
       "dependencies": {
         "@sinclair/typebox": "^0.27.8"
@@ -2369,9 +2369,9 @@
       "dev": true
     },
     "node_modules/@types/react": {
-      "version": "18.2.20",
-      "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.20.tgz",
-      "integrity": "sha512-WKNtmsLWJM/3D5mG4U84cysVY31ivmyw85dE84fOCk5Hx78wezB/XEjVPWl2JTZ5FkEeaTJf+VgUAUn3PE7Isw==",
+      "version": "18.2.21",
+      "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.21.tgz",
+      "integrity": "sha512-neFKG/sBAwGxHgXiIxnbm3/AAVQ/cMRS93hvBpg8xYRbeQSPVABp9U2bRnPf0iI4+Ucdv3plSxKK+3CW2ENJxA==",
       "dev": true,
       "dependencies": {
         "@types/prop-types": "*",
@@ -2418,22 +2418,22 @@
       "integrity": "sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ=="
     },
     "node_modules/@types/webextension-polyfill": {
-      "version": "0.10.1",
-      "resolved": "https://registry.npmjs.org/@types/webextension-polyfill/-/webextension-polyfill-0.10.1.tgz",
-      "integrity": "sha512-Sdg+E2F5JUbhkE1qX15QUxpyhfMFKRGJqND9nb1C0gNN4NR7kCV31/1GvNbg6Xe+m/JElJ9/lG5kepMzjGPuQw==",
+      "version": "0.10.2",
+      "resolved": "https://registry.npmjs.org/@types/webextension-polyfill/-/webextension-polyfill-0.10.2.tgz",
+      "integrity": "sha512-L+T72DVTi/1azY6gw7PRmL49kvobLKQQWfOmHDBRSCkAvaCV4wk222J3ZODYA9Gf/UqUvtItu8FPJgWX5ktO9g==",
       "dev": true
     },
     "node_modules/@typescript-eslint/eslint-plugin": {
-      "version": "6.4.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.4.1.tgz",
-      "integrity": "sha512-3F5PtBzUW0dYlq77Lcqo13fv+58KDwUib3BddilE8ajPJT+faGgxmI9Sw+I8ZS22BYwoir9ZhNXcLi+S+I2bkw==",
+      "version": "6.6.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.6.0.tgz",
+      "integrity": "sha512-CW9YDGTQnNYMIo5lMeuiIG08p4E0cXrXTbcZ2saT/ETE7dWUrNxlijsQeU04qAAKkILiLzdQz+cGFxCJjaZUmA==",
       "dev": true,
       "dependencies": {
         "@eslint-community/regexpp": "^4.5.1",
-        "@typescript-eslint/scope-manager": "6.4.1",
-        "@typescript-eslint/type-utils": "6.4.1",
-        "@typescript-eslint/utils": "6.4.1",
-        "@typescript-eslint/visitor-keys": "6.4.1",
+        "@typescript-eslint/scope-manager": "6.6.0",
+        "@typescript-eslint/type-utils": "6.6.0",
+        "@typescript-eslint/utils": "6.6.0",
+        "@typescript-eslint/visitor-keys": "6.6.0",
         "debug": "^4.3.4",
         "graphemer": "^1.4.0",
         "ignore": "^5.2.4",
@@ -2458,105 +2458,6 @@
         }
       }
     },
-    "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": {
-      "version": "6.4.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.4.1.tgz",
-      "integrity": "sha512-p/OavqOQfm4/Hdrr7kvacOSFjwQ2rrDVJRPxt/o0TOWdFnjJptnjnZ+sYDR7fi4OimvIuKp+2LCkc+rt9fIW+A==",
-      "dev": true,
-      "dependencies": {
-        "@typescript-eslint/types": "6.4.1",
-        "@typescript-eslint/visitor-keys": "6.4.1"
-      },
-      "engines": {
-        "node": "^16.0.0 || >=18.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/typescript-eslint"
-      }
-    },
-    "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": {
-      "version": "6.4.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.4.1.tgz",
-      "integrity": "sha512-zAAopbNuYu++ijY1GV2ylCsQsi3B8QvfPHVqhGdDcbx/NK5lkqMnCGU53amAjccSpk+LfeONxwzUhDzArSfZJg==",
-      "dev": true,
-      "engines": {
-        "node": "^16.0.0 || >=18.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/typescript-eslint"
-      }
-    },
-    "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": {
-      "version": "6.4.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.4.1.tgz",
-      "integrity": "sha512-xF6Y7SatVE/OyV93h1xGgfOkHr2iXuo8ip0gbfzaKeGGuKiAnzS+HtVhSPx8Www243bwlW8IF7X0/B62SzFftg==",
-      "dev": true,
-      "dependencies": {
-        "@typescript-eslint/types": "6.4.1",
-        "@typescript-eslint/visitor-keys": "6.4.1",
-        "debug": "^4.3.4",
-        "globby": "^11.1.0",
-        "is-glob": "^4.0.3",
-        "semver": "^7.5.4",
-        "ts-api-utils": "^1.0.1"
-      },
-      "engines": {
-        "node": "^16.0.0 || >=18.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/typescript-eslint"
-      },
-      "peerDependenciesMeta": {
-        "typescript": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": {
-      "version": "6.4.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.4.1.tgz",
-      "integrity": "sha512-F/6r2RieNeorU0zhqZNv89s9bDZSovv3bZQpUNOmmQK1L80/cV4KEu95YUJWi75u5PhboFoKUJBnZ4FQcoqhDw==",
-      "dev": true,
-      "dependencies": {
-        "@eslint-community/eslint-utils": "^4.4.0",
-        "@types/json-schema": "^7.0.12",
-        "@types/semver": "^7.5.0",
-        "@typescript-eslint/scope-manager": "6.4.1",
-        "@typescript-eslint/types": "6.4.1",
-        "@typescript-eslint/typescript-estree": "6.4.1",
-        "semver": "^7.5.4"
-      },
-      "engines": {
-        "node": "^16.0.0 || >=18.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/typescript-eslint"
-      },
-      "peerDependencies": {
-        "eslint": "^7.0.0 || ^8.0.0"
-      }
-    },
-    "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": {
-      "version": "6.4.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.4.1.tgz",
-      "integrity": "sha512-y/TyRJsbZPkJIZQXrHfdnxVnxyKegnpEvnRGNam7s3TRR2ykGefEWOhaef00/UUN3IZxizS7BTO3svd3lCOJRQ==",
-      "dev": true,
-      "dependencies": {
-        "@typescript-eslint/types": "6.4.1",
-        "eslint-visitor-keys": "^3.4.1"
-      },
-      "engines": {
-        "node": "^16.0.0 || >=18.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/typescript-eslint"
-      }
-    },
     "node_modules/@typescript-eslint/parser": {
       "version": "6.3.0",
       "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.3.0.tgz",
@@ -2623,16 +2524,16 @@
       }
     },
     "node_modules/@typescript-eslint/scope-manager": {
-      "version": "5.60.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.60.1.tgz",
-      "integrity": "sha512-Dn/LnN7fEoRD+KspEOV0xDMynEmR3iSHdgNsarlXNLGGtcUok8L4N71dxUgt3YvlO8si7E+BJ5Fe3wb5yUw7DQ==",
+      "version": "6.6.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.6.0.tgz",
+      "integrity": "sha512-pT08u5W/GT4KjPUmEtc2kSYvrH8x89cVzkA0Sy2aaOUIw6YxOIjA8ilwLr/1fLjOedX1QAuBpG9XggWqIIfERw==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "5.60.1",
-        "@typescript-eslint/visitor-keys": "5.60.1"
+        "@typescript-eslint/types": "6.6.0",
+        "@typescript-eslint/visitor-keys": "6.6.0"
       },
       "engines": {
-        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+        "node": "^16.0.0 || >=18.0.0"
       },
       "funding": {
         "type": "opencollective",
@@ -2640,12 +2541,12 @@
       }
     },
     "node_modules/@typescript-eslint/scope-manager/node_modules/@typescript-eslint/types": {
-      "version": "5.60.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.60.1.tgz",
-      "integrity": "sha512-zDcDx5fccU8BA0IDZc71bAtYIcG9PowaOwaD8rjYbqwK7dpe/UMQl3inJ4UtUK42nOCT41jTSCwg76E62JpMcg==",
+      "version": "6.6.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.6.0.tgz",
+      "integrity": "sha512-CB6QpJQ6BAHlJXdwUmiaXDBmTqIE2bzGTDLADgvqtHWuhfNP3rAOK7kAgRMAET5rDRr9Utt+qAzRBdu3AhR3sg==",
       "dev": true,
       "engines": {
-        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+        "node": "^16.0.0 || >=18.0.0"
       },
       "funding": {
         "type": "opencollective",
@@ -2653,13 +2554,13 @@
       }
     },
     "node_modules/@typescript-eslint/type-utils": {
-      "version": "6.4.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.4.1.tgz",
-      "integrity": "sha512-7ON8M8NXh73SGZ5XvIqWHjgX2f+vvaOarNliGhjrJnv1vdjG0LVIz+ToYfPirOoBi56jxAKLfsLm40+RvxVVXA==",
+      "version": "6.6.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.6.0.tgz",
+      "integrity": "sha512-8m16fwAcEnQc69IpeDyokNO+D5spo0w1jepWWY2Q6y5ZKNuj5EhVQXjtVAeDDqvW6Yg7dhclbsz6rTtOvcwpHg==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/typescript-estree": "6.4.1",
-        "@typescript-eslint/utils": "6.4.1",
+        "@typescript-eslint/typescript-estree": "6.6.0",
+        "@typescript-eslint/utils": "6.6.0",
         "debug": "^4.3.4",
         "ts-api-utils": "^1.0.1"
       },
@@ -2679,27 +2580,10 @@
         }
       }
     },
-    "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/scope-manager": {
-      "version": "6.4.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.4.1.tgz",
-      "integrity": "sha512-p/OavqOQfm4/Hdrr7kvacOSFjwQ2rrDVJRPxt/o0TOWdFnjJptnjnZ+sYDR7fi4OimvIuKp+2LCkc+rt9fIW+A==",
-      "dev": true,
-      "dependencies": {
-        "@typescript-eslint/types": "6.4.1",
-        "@typescript-eslint/visitor-keys": "6.4.1"
-      },
-      "engines": {
-        "node": "^16.0.0 || >=18.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/typescript-eslint"
-      }
-    },
     "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": {
-      "version": "6.4.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.4.1.tgz",
-      "integrity": "sha512-zAAopbNuYu++ijY1GV2ylCsQsi3B8QvfPHVqhGdDcbx/NK5lkqMnCGU53amAjccSpk+LfeONxwzUhDzArSfZJg==",
+      "version": "6.6.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.6.0.tgz",
+      "integrity": "sha512-CB6QpJQ6BAHlJXdwUmiaXDBmTqIE2bzGTDLADgvqtHWuhfNP3rAOK7kAgRMAET5rDRr9Utt+qAzRBdu3AhR3sg==",
       "dev": true,
       "engines": {
         "node": "^16.0.0 || >=18.0.0"
@@ -2710,13 +2594,13 @@
       }
     },
     "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": {
-      "version": "6.4.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.4.1.tgz",
-      "integrity": "sha512-xF6Y7SatVE/OyV93h1xGgfOkHr2iXuo8ip0gbfzaKeGGuKiAnzS+HtVhSPx8Www243bwlW8IF7X0/B62SzFftg==",
+      "version": "6.6.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.6.0.tgz",
+      "integrity": "sha512-hMcTQ6Al8MP2E6JKBAaSxSVw5bDhdmbCEhGW/V8QXkb9oNsFkA4SBuOMYVPxD3jbtQ4R/vSODBsr76R6fP3tbA==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "6.4.1",
-        "@typescript-eslint/visitor-keys": "6.4.1",
+        "@typescript-eslint/types": "6.6.0",
+        "@typescript-eslint/visitor-keys": "6.6.0",
         "debug": "^4.3.4",
         "globby": "^11.1.0",
         "is-glob": "^4.0.3",
@@ -2736,48 +2620,6 @@
         }
       }
     },
-    "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/utils": {
-      "version": "6.4.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.4.1.tgz",
-      "integrity": "sha512-F/6r2RieNeorU0zhqZNv89s9bDZSovv3bZQpUNOmmQK1L80/cV4KEu95YUJWi75u5PhboFoKUJBnZ4FQcoqhDw==",
-      "dev": true,
-      "dependencies": {
-        "@eslint-community/eslint-utils": "^4.4.0",
-        "@types/json-schema": "^7.0.12",
-        "@types/semver": "^7.5.0",
-        "@typescript-eslint/scope-manager": "6.4.1",
-        "@typescript-eslint/types": "6.4.1",
-        "@typescript-eslint/typescript-estree": "6.4.1",
-        "semver": "^7.5.4"
-      },
-      "engines": {
-        "node": "^16.0.0 || >=18.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/typescript-eslint"
-      },
-      "peerDependencies": {
-        "eslint": "^7.0.0 || ^8.0.0"
-      }
-    },
-    "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": {
-      "version": "6.4.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.4.1.tgz",
-      "integrity": "sha512-y/TyRJsbZPkJIZQXrHfdnxVnxyKegnpEvnRGNam7s3TRR2ykGefEWOhaef00/UUN3IZxizS7BTO3svd3lCOJRQ==",
-      "dev": true,
-      "dependencies": {
-        "@typescript-eslint/types": "6.4.1",
-        "eslint-visitor-keys": "^3.4.1"
-      },
-      "engines": {
-        "node": "^16.0.0 || >=18.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/typescript-eslint"
-      }
-    },
     "node_modules/@typescript-eslint/types": {
       "version": "6.3.0",
       "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.3.0.tgz",
@@ -2839,38 +2681,37 @@
       }
     },
     "node_modules/@typescript-eslint/utils": {
-      "version": "5.60.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.60.1.tgz",
-      "integrity": "sha512-tiJ7FFdFQOWssFa3gqb94Ilexyw0JVxj6vBzaSpfN/8IhoKkDuSAenUKvsSHw2A/TMpJb26izIszTXaqygkvpQ==",
+      "version": "6.6.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.6.0.tgz",
+      "integrity": "sha512-mPHFoNa2bPIWWglWYdR0QfY9GN0CfvvXX1Sv6DlSTive3jlMTUy+an67//Gysc+0Me9pjitrq0LJp0nGtLgftw==",
       "dev": true,
       "dependencies": {
-        "@eslint-community/eslint-utils": "^4.2.0",
-        "@types/json-schema": "^7.0.9",
-        "@types/semver": "^7.3.12",
-        "@typescript-eslint/scope-manager": "5.60.1",
-        "@typescript-eslint/types": "5.60.1",
-        "@typescript-eslint/typescript-estree": "5.60.1",
-        "eslint-scope": "^5.1.1",
-        "semver": "^7.3.7"
+        "@eslint-community/eslint-utils": "^4.4.0",
+        "@types/json-schema": "^7.0.12",
+        "@types/semver": "^7.5.0",
+        "@typescript-eslint/scope-manager": "6.6.0",
+        "@typescript-eslint/types": "6.6.0",
+        "@typescript-eslint/typescript-estree": "6.6.0",
+        "semver": "^7.5.4"
       },
       "engines": {
-        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+        "node": "^16.0.0 || >=18.0.0"
       },
       "funding": {
         "type": "opencollective",
         "url": "https://opencollective.com/typescript-eslint"
       },
       "peerDependencies": {
-        "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0"
+        "eslint": "^7.0.0 || ^8.0.0"
       }
     },
     "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": {
-      "version": "5.60.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.60.1.tgz",
-      "integrity": "sha512-zDcDx5fccU8BA0IDZc71bAtYIcG9PowaOwaD8rjYbqwK7dpe/UMQl3inJ4UtUK42nOCT41jTSCwg76E62JpMcg==",
+      "version": "6.6.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.6.0.tgz",
+      "integrity": "sha512-CB6QpJQ6BAHlJXdwUmiaXDBmTqIE2bzGTDLADgvqtHWuhfNP3rAOK7kAgRMAET5rDRr9Utt+qAzRBdu3AhR3sg==",
       "dev": true,
       "engines": {
-        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+        "node": "^16.0.0 || >=18.0.0"
       },
       "funding": {
         "type": "opencollective",
@@ -2878,21 +2719,21 @@
       }
     },
     "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": {
-      "version": "5.60.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.60.1.tgz",
-      "integrity": "sha512-hkX70J9+2M2ZT6fhti5Q2FoU9zb+GeZK2SLP1WZlvUDqdMbEKhexZODD1WodNRyO8eS+4nScvT0dts8IdaBzfw==",
+      "version": "6.6.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.6.0.tgz",
+      "integrity": "sha512-hMcTQ6Al8MP2E6JKBAaSxSVw5bDhdmbCEhGW/V8QXkb9oNsFkA4SBuOMYVPxD3jbtQ4R/vSODBsr76R6fP3tbA==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "5.60.1",
-        "@typescript-eslint/visitor-keys": "5.60.1",
+        "@typescript-eslint/types": "6.6.0",
+        "@typescript-eslint/visitor-keys": "6.6.0",
         "debug": "^4.3.4",
         "globby": "^11.1.0",
         "is-glob": "^4.0.3",
-        "semver": "^7.3.7",
-        "tsutils": "^3.21.0"
+        "semver": "^7.5.4",
+        "ts-api-utils": "^1.0.1"
       },
       "engines": {
-        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+        "node": "^16.0.0 || >=18.0.0"
       },
       "funding": {
         "type": "opencollective",
@@ -2905,16 +2746,16 @@
       }
     },
     "node_modules/@typescript-eslint/visitor-keys": {
-      "version": "5.60.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.60.1.tgz",
-      "integrity": "sha512-xEYIxKcultP6E/RMKqube11pGjXH1DCo60mQoWhVYyKfLkwbIVVjYxmOenNMxILx0TjCujPTjjnTIVzm09TXIw==",
+      "version": "6.6.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.6.0.tgz",
+      "integrity": "sha512-L61uJT26cMOfFQ+lMZKoJNbAEckLe539VhTxiGHrWl5XSKQgA0RTBZJW2HFPy5T0ZvPVSD93QsrTKDkfNwJGyQ==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "5.60.1",
-        "eslint-visitor-keys": "^3.3.0"
+        "@typescript-eslint/types": "6.6.0",
+        "eslint-visitor-keys": "^3.4.1"
       },
       "engines": {
-        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+        "node": "^16.0.0 || >=18.0.0"
       },
       "funding": {
         "type": "opencollective",
@@ -2922,12 +2763,12 @@
       }
     },
     "node_modules/@typescript-eslint/visitor-keys/node_modules/@typescript-eslint/types": {
-      "version": "5.60.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.60.1.tgz",
-      "integrity": "sha512-zDcDx5fccU8BA0IDZc71bAtYIcG9PowaOwaD8rjYbqwK7dpe/UMQl3inJ4UtUK42nOCT41jTSCwg76E62JpMcg==",
+      "version": "6.6.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.6.0.tgz",
+      "integrity": "sha512-CB6QpJQ6BAHlJXdwUmiaXDBmTqIE2bzGTDLADgvqtHWuhfNP3rAOK7kAgRMAET5rDRr9Utt+qAzRBdu3AhR3sg==",
       "dev": true,
       "engines": {
-        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+        "node": "^16.0.0 || >=18.0.0"
       },
       "funding": {
         "type": "opencollective",
@@ -2935,13 +2776,13 @@
       }
     },
     "node_modules/@vitest/expect": {
-      "version": "0.34.2",
-      "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.34.2.tgz",
-      "integrity": "sha512-EZm2dMNlLyIfDMha17QHSQcg2KjeAZaXd65fpPzXY5bvnfx10Lcaz3N55uEe8PhF+w4pw+hmrlHLLlRn9vkBJg==",
+      "version": "0.34.3",
+      "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.34.3.tgz",
+      "integrity": "sha512-F8MTXZUYRBVsYL1uoIft1HHWhwDbSzwAU9Zgh8S6WFC3YgVb4AnFV2GXO3P5Em8FjEYaZtTnQYoNwwBrlOMXgg==",
       "dev": true,
       "dependencies": {
-        "@vitest/spy": "0.34.2",
-        "@vitest/utils": "0.34.2",
+        "@vitest/spy": "0.34.3",
+        "@vitest/utils": "0.34.3",
         "chai": "^4.3.7"
       },
       "funding": {
@@ -2949,12 +2790,12 @@
       }
     },
     "node_modules/@vitest/runner": {
-      "version": "0.34.2",
-      "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-0.34.2.tgz",
-      "integrity": "sha512-8ydGPACVX5tK3Dl0SUwxfdg02h+togDNeQX3iXVFYgzF5odxvaou7HnquALFZkyVuYskoaHUOqOyOLpOEj5XTA==",
+      "version": "0.34.3",
+      "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-0.34.3.tgz",
+      "integrity": "sha512-lYNq7N3vR57VMKMPLVvmJoiN4bqwzZ1euTW+XXYH5kzr3W/+xQG3b41xJn9ChJ3AhYOSoweu974S1V3qDcFESA==",
       "dev": true,
       "dependencies": {
-        "@vitest/utils": "0.34.2",
+        "@vitest/utils": "0.34.3",
         "p-limit": "^4.0.0",
         "pathe": "^1.1.1"
       },
@@ -2990,9 +2831,9 @@
       }
     },
     "node_modules/@vitest/snapshot": {
-      "version": "0.34.2",
-      "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-0.34.2.tgz",
-      "integrity": "sha512-qhQ+xy3u4mwwLxltS4Pd4SR+XHv4EajiTPNY3jkIBLUApE6/ce72neJPSUQZ7bL3EBuKI+NhvzhGj3n5baRQUQ==",
+      "version": "0.34.3",
+      "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-0.34.3.tgz",
+      "integrity": "sha512-QyPaE15DQwbnIBp/yNJ8lbvXTZxS00kRly0kfFgAD5EYmCbYcA+1EEyRalc93M0gosL/xHeg3lKAClIXYpmUiQ==",
       "dev": true,
       "dependencies": {
         "magic-string": "^0.30.1",
@@ -3004,9 +2845,9 @@
       }
     },
     "node_modules/@vitest/spy": {
-      "version": "0.34.2",
-      "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-0.34.2.tgz",
-      "integrity": "sha512-yd4L9OhfH6l0Av7iK3sPb3MykhtcRN5c5K5vm1nTbuN7gYn+yvUVVsyvzpHrjqS7EWqn9WsPJb7+0c3iuY60tA==",
+      "version": "0.34.3",
+      "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-0.34.3.tgz",
+      "integrity": "sha512-N1V0RFQ6AI7CPgzBq9kzjRdPIgThC340DGjdKdPSE8r86aUSmeliTUgkTqLSgtEwWWsGfBQ+UetZWhK0BgJmkQ==",
       "dev": true,
       "dependencies": {
         "tinyspy": "^2.1.1"
@@ -3016,9 +2857,9 @@
       }
     },
     "node_modules/@vitest/utils": {
-      "version": "0.34.2",
-      "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-0.34.2.tgz",
-      "integrity": "sha512-Lzw+kAsTPubhoQDp1uVAOP6DhNia1GMDsI9jgB0yMn+/nDaPieYQ88lKqz/gGjSHL4zwOItvpehec9OY+rS73w==",
+      "version": "0.34.3",
+      "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-0.34.3.tgz",
+      "integrity": "sha512-kiSnzLG6m/tiT0XEl4U2H8JDBjFtwVlaE8I3QfGiMFR0QvnRDfYfdP3YvTBWM/6iJDAyaPY6yVQiCTUc7ZzTHA==",
       "dev": true,
       "dependencies": {
         "diff-sequences": "^29.4.3",
@@ -3047,9 +2888,9 @@
       }
     },
     "node_modules/acorn": {
-      "version": "8.9.0",
-      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.9.0.tgz",
-      "integrity": "sha512-jaVNAFBHNLXspO543WnNNPZFRtavh3skAkITqD0/2aeMkKZTN+254PyhwxFYrk3vQ1xfY+2wbesJMs/JC8/PwQ==",
+      "version": "8.10.0",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz",
+      "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==",
       "dev": true,
       "bin": {
         "acorn": "bin/acorn"
@@ -4169,9 +4010,9 @@
       }
     },
     "node_modules/chai": {
-      "version": "4.3.7",
-      "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.7.tgz",
-      "integrity": "sha512-HLnAzZ2iupm25PlN0xFreAlBA5zaBSv3og0DdeGA4Ar6h6rJ3A0rolRUKJhSF2V10GZKDgWF/VmAEsNWjCRB+A==",
+      "version": "4.3.8",
+      "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.8.tgz",
+      "integrity": "sha512-vX4YvVVtxlfSZ2VecZgFUTU5qPCYsobVI2O9FmwEXBhDigYGQA6jRXCycIs1yJnnWbZ6/+a2zNIF5DfVCcJBFQ==",
       "dev": true,
       "dependencies": {
         "assertion-error": "^1.1.0",
@@ -4881,9 +4722,9 @@
       }
     },
     "node_modules/diff-sequences": {
-      "version": "29.4.3",
-      "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.4.3.tgz",
-      "integrity": "sha512-ofrBgwpPhCD85kMKtE9RYFFq6OC1A89oW2vvgWZNCwxrUpRUILopY7lsYyMDSjc8g6U6aiO0Qubg6r4Wgt5ZnA==",
+      "version": "29.6.3",
+      "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
+      "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==",
       "dev": true,
       "engines": {
         "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
@@ -5210,15 +5051,15 @@
       }
     },
     "node_modules/eslint": {
-      "version": "8.47.0",
-      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.47.0.tgz",
-      "integrity": "sha512-spUQWrdPt+pRVP1TTJLmfRNJJHHZryFmptzcafwSvHsceV81djHOdnEeDmkdotZyLNjDhrOasNK8nikkoG1O8Q==",
+      "version": "8.48.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.48.0.tgz",
+      "integrity": "sha512-sb6DLeIuRXxeM1YljSe1KEx9/YYeZFQWcV8Rq9HfigmdDEugjLEVEa1ozDjL6YDjBpQHPJxJzze+alxi4T3OLg==",
       "dev": true,
       "dependencies": {
         "@eslint-community/eslint-utils": "^4.2.0",
         "@eslint-community/regexpp": "^4.6.1",
         "@eslint/eslintrc": "^2.1.2",
-        "@eslint/js": "^8.47.0",
+        "@eslint/js": "8.48.0",
         "@humanwhocodes/config-array": "^0.11.10",
         "@humanwhocodes/module-importer": "^1.0.1",
         "@nodelib/fs.walk": "^1.2.8",
@@ -5354,12 +5195,12 @@
       }
     },
     "node_modules/eslint-plugin-solid": {
-      "version": "0.12.1",
-      "resolved": "https://registry.npmjs.org/eslint-plugin-solid/-/eslint-plugin-solid-0.12.1.tgz",
-      "integrity": "sha512-fM0sEg9PcS1mcNbWklwc+W/lOv1/XyEwXf53HmFFy4GOA8E3u41h8JW+hc+Vv1m3kh01umKoTalOTET08zKdAQ==",
+      "version": "0.13.0",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-solid/-/eslint-plugin-solid-0.13.0.tgz",
+      "integrity": "sha512-Sutd+DxEGu9+Z9ITtHKXRAClxVe1a6C1XQZSuN8iBsMy0IAVEc6Tca1UYgc7tD2ZrRRjZKB9mohBOaZl5NJLgg==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/utils": "^5.55.0",
+        "@typescript-eslint/utils": "^6.4.0",
         "is-html": "^2.0.0",
         "jsx-ast-utils": "^3.3.3",
         "kebab-case": "^1.0.2",
@@ -5405,28 +5246,6 @@
         "eslint": "^6.2.0 || ^7.0.0 || ^8.0.0"
       }
     },
-    "node_modules/eslint-scope": {
-      "version": "5.1.1",
-      "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
-      "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
-      "dev": true,
-      "dependencies": {
-        "esrecurse": "^4.3.0",
-        "estraverse": "^4.1.1"
-      },
-      "engines": {
-        "node": ">=8.0.0"
-      }
-    },
-    "node_modules/eslint-scope/node_modules/estraverse": {
-      "version": "4.3.0",
-      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
-      "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
-      "dev": true,
-      "engines": {
-        "node": ">=4.0"
-      }
-    },
     "node_modules/eslint-visitor-keys": {
       "version": "3.4.3",
       "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
@@ -6201,9 +6020,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",
@@ -8750,15 +8569,15 @@
       }
     },
     "node_modules/mlly": {
-      "version": "1.4.0",
-      "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.4.0.tgz",
-      "integrity": "sha512-ua8PAThnTwpprIaU47EPeZ/bPUVp2QYBbWMphUQpVdBI3Lgqzm5KZQ45Agm3YJedHXaIHl6pBGabaLSUPPSptg==",
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.4.1.tgz",
+      "integrity": "sha512-SCDs78Q2o09jiZiE2WziwVBEqXQ02XkGdUy45cbJf+BpYRIjArXRJ1Wbowxkb+NaM9DWvS3UC9GiO/6eqvQ/pg==",
       "dev": true,
       "dependencies": {
-        "acorn": "^8.9.0",
+        "acorn": "^8.10.0",
         "pathe": "^1.1.1",
         "pkg-types": "^1.0.3",
-        "ufo": "^1.1.2"
+        "ufo": "^1.3.0"
       }
     },
     "node_modules/mri": {
@@ -9763,9 +9582,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.3",
+      "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.3.tgz",
+      "integrity": "sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==",
       "dev": true,
       "bin": {
         "prettier": "bin/prettier.cjs"
@@ -9790,12 +9609,12 @@
       }
     },
     "node_modules/pretty-format": {
-      "version": "29.6.2",
-      "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.6.2.tgz",
-      "integrity": "sha512-1q0oC8eRveTg5nnBEWMXAU2qpv65Gnuf2eCQzSjxpWFkPaPARwqZZDGuNE0zPAZfTCHzIk3A8dIjwlQKKLphyg==",
+      "version": "29.6.3",
+      "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.6.3.tgz",
+      "integrity": "sha512-ZsBgjVhFAj5KeK+nHfF1305/By3lechHQSMWCTl8iHSbfOm2TN5nHEtFc/+W7fAyUeCs2n5iow72gld4gW0xDw==",
       "dev": true,
       "dependencies": {
-        "@jest/schemas": "^29.6.0",
+        "@jest/schemas": "^29.6.3",
         "ansi-styles": "^5.0.0",
         "react-is": "^18.0.0"
       },
@@ -12091,27 +11910,6 @@
         "typescript": ">=4.2.0"
       }
     },
-    "node_modules/tslib": {
-      "version": "1.14.1",
-      "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
-      "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
-      "dev": true
-    },
-    "node_modules/tsutils": {
-      "version": "3.21.0",
-      "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz",
-      "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==",
-      "dev": true,
-      "dependencies": {
-        "tslib": "^1.8.1"
-      },
-      "engines": {
-        "node": ">= 6"
-      },
-      "peerDependencies": {
-        "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta"
-      }
-    },
     "node_modules/tsx": {
       "version": "3.12.7",
       "resolved": "https://registry.npmjs.org/tsx/-/tsx-3.12.7.tgz",
@@ -12254,9 +12052,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",
@@ -12267,9 +12065,9 @@
       }
     },
     "node_modules/ufo": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.2.0.tgz",
-      "integrity": "sha512-RsPyTbqORDNDxqAdQPQBpgqhWle1VcTSou/FraClYlHf6TZnQcGslpLcAphNR+sQW4q5lLWLbOsRlh9j24baQg==",
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.3.0.tgz",
+      "integrity": "sha512-bRn3CsoojyNStCZe0BG0Mt4Nr/4KF+rhFlnNXybgqt5pXHNFRlqinSoQaTrGyzE4X8aHplSb+TorH+COin9Yxw==",
       "dev": true
     },
     "node_modules/unbox-primitive": {
@@ -13012,9 +12810,9 @@
       }
     },
     "node_modules/vite-node": {
-      "version": "0.34.2",
-      "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-0.34.2.tgz",
-      "integrity": "sha512-JtW249Zm3FB+F7pQfH56uWSdlltCo1IOkZW5oHBzeQo0iX4jtC7o1t9aILMGd9kVekXBP2lfJBEQt9rBh07ebA==",
+      "version": "0.34.3",
+      "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-0.34.3.tgz",
+      "integrity": "sha512-+0TzJf1g0tYXj6tR2vEyiA42OPq68QkRZCu/ERSo2PtsDJfBpDyEfuKbRvLmZqi/CgC7SCBtyC+WjTGNMRIaig==",
       "dev": true,
       "dependencies": {
         "cac": "^6.7.14",
@@ -13505,19 +13303,19 @@
       }
     },
     "node_modules/vitest": {
-      "version": "0.34.2",
-      "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.34.2.tgz",
-      "integrity": "sha512-WgaIvBbjsSYMq/oiMlXUI7KflELmzM43BEvkdC/8b5CAod4ryAiY2z8uR6Crbi5Pjnu5oOmhKa9sy7uk6paBxQ==",
+      "version": "0.34.3",
+      "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.34.3.tgz",
+      "integrity": "sha512-7+VA5Iw4S3USYk+qwPxHl8plCMhA5rtfwMjgoQXMT7rO5ldWcdsdo3U1QD289JgglGK4WeOzgoLTsGFu6VISyQ==",
       "dev": true,
       "dependencies": {
         "@types/chai": "^4.3.5",
         "@types/chai-subset": "^1.3.3",
         "@types/node": "*",
-        "@vitest/expect": "0.34.2",
-        "@vitest/runner": "0.34.2",
-        "@vitest/snapshot": "0.34.2",
-        "@vitest/spy": "0.34.2",
-        "@vitest/utils": "0.34.2",
+        "@vitest/expect": "0.34.3",
+        "@vitest/runner": "0.34.3",
+        "@vitest/snapshot": "0.34.3",
+        "@vitest/spy": "0.34.3",
+        "@vitest/utils": "0.34.3",
         "acorn": "^8.9.0",
         "acorn-walk": "^8.2.0",
         "cac": "^6.7.14",
@@ -13532,7 +13330,7 @@
         "tinybench": "^2.5.0",
         "tinypool": "^0.7.0",
         "vite": "^3.0.0 || ^4.0.0",
-        "vite-node": "0.34.2",
+        "vite-node": "0.34.3",
         "why-is-node-running": "^2.2.2"
       },
       "bin": {
diff --git a/package.json b/package.json
index 54dae77e..93af7378 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/)"
@@ -40,24 +40,24 @@
     "@types/imagemin": "8.0.1",
     "@types/imagemin-jpegtran": "5.0.1",
     "@types/node": "20.5.6",
-    "@types/react": "18.2.20",
-    "@types/webextension-polyfill": "0.10.1",
-    "@typescript-eslint/eslint-plugin": "6.4.1",
+    "@types/react": "18.2.21",
+    "@types/webextension-polyfill": "0.10.2",
+    "@typescript-eslint/eslint-plugin": "6.6.0",
     "archiver": "6.0.0",
     "canvas": "2.11.2",
     "chokidar": "3.5.3",
     "esbuild": "0.19.2",
-    "eslint": "8.47.0",
+    "eslint": "8.48.0",
     "eslint-config-web-scrobbler": "2.1.1",
     "eslint-plugin-react": "7.33.2",
-    "eslint-plugin-solid": "0.12.1",
+    "eslint-plugin-solid": "0.13.0",
     "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.3",
     "prettier-config-web-scrobbler": "0.1.0",
     "remark": "14.0.3",
     "remark-cli": "11.0.0",
@@ -66,11 +66,11 @@
     "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",
-    "vitest": "0.34.2"
+    "vitest": "0.34.3"
   },
   "engines": {
     "node": ">=18.0.0"
diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json
index 37eb58c5..4ab49291 100644
--- a/src/_locales/en/messages.json
+++ b/src/_locales/en/messages.json
@@ -39,6 +39,14 @@
         "message": "Scrobble podcast episodes",
         "description": "Option title"
     },
+    "optionDebugLoggingEnabled": {
+        "message": "Enable debug logging",
+        "description": "Option label"
+    },
+    "optionDebugLoggingEnabledTitle": {
+        "message": "Allow the extension to log debug messages to the browser console",
+        "description": "Option title"
+    },
     "optionsScrobbleBehavior": {
         "message": "Scrobble behavior",
         "description": "'Scrobble behavior' section"
@@ -376,7 +384,7 @@
         "description": "'About' section"
     },
     "aboutChangelog": {
-        "message": "The changelog of current version is available <a id='latest-release' target='_blank' href='#'>here</a>. For the full changelog please visit <a target='_blank' href='https://github.com/web-scrobbler/web-scrobbler/releases'>this page</a>.",
+        "message": "The changelog of current version is available <a id='latest-release' target='_blank' href='$1'>here</a>. For the full changelog please visit <a target='_blank' href='https://github.com/web-scrobbler/web-scrobbler/releases'>this page</a>.",
         "description": "'About' section text"
     },
     "aboutExtensionDesc": {
diff --git a/src/connectors/deezer-dom-inject.ts b/src/connectors/deezer-dom-inject.ts
index 071d3cbc..45ee5eaa 100644
--- a/src/connectors/deezer-dom-inject.ts
+++ b/src/connectors/deezer-dom-inject.ts
@@ -1,5 +1,3 @@
-import { State } from '@/core/types';
-
 deezerInitConnector();
 
 function deezerInitConnector() {
@@ -55,6 +53,16 @@ function deezerSendEvent() {
 	);
 }
 
+interface DeezerState {
+	artist?: string;
+	track?: string;
+	album?: string;
+	uniqueID?: string;
+	trackArt?: string;
+	duration?: number | null;
+	currentTime?: number | null;
+}
+
 interface DeezerMedia {
 	__TYPE__: string;
 	EXTERNAL?: unknown;
@@ -89,7 +97,7 @@ function deezerGetCurrentMediaInfo() {
 	const currentTime = player.getPosition();
 	const duration = player.getDuration();
 
-	let trackInfo: State | null = null;
+	let trackInfo: DeezerState | null = null;
 
 	switch (mediaType) {
 		case 'episode': {
diff --git a/src/connectors/deezer.ts b/src/connectors/deezer.ts
index 0d27d774..111736ab 100644
--- a/src/connectors/deezer.ts
+++ b/src/connectors/deezer.ts
@@ -1,4 +1,4 @@
-import { State } from '@/core/types';
+import type { State } from '@/core/types';
 export {};
 
 const deezerFilter = MetadataFilter.createRemasteredFilter();
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/redbull.ts b/src/connectors/redbull.ts
index 3e49ab47..89993451 100644
--- a/src/connectors/redbull.ts
+++ b/src/connectors/redbull.ts
@@ -1,4 +1,4 @@
-import { ArtistTrackInfo } from '@/core/types';
+import type { ArtistTrackInfo } from '@/core/types';
 
 export {};
 
diff --git a/src/connectors/soundcloud.ts b/src/connectors/soundcloud.ts
index 998de6f9..5d0e520c 100644
--- a/src/connectors/soundcloud.ts
+++ b/src/connectors/soundcloud.ts
@@ -1,18 +1,51 @@
 export {};
 
-const artistSelector = '.playbackSoundBadge__titleContextContainer > a';
-const trackSelector = '.playbackSoundBadge__titleLink > span:nth-child(2)';
-const trackArtSelector = '.playControls span.sc-artwork';
-const durationSelector = '.playbackTimeline__duration > span:nth-child(2)';
+const artistSelector = [
+	'.playbackSoundBadge__titleContextContainer > a',
+	'[class*=MiniPlayer_MiniPlayerArtist]',
+];
+const trackSelector = [
+	'.playbackSoundBadge__titleLink > span:nth-child(2)',
+	'[class*=MiniPlayer_MiniPlayerTrack]',
+];
+const trackArtSelector = [
+	'.playControls span.sc-artwork',
+	'[class*=MiniPlayer_MiniPlayerArtworkImage]',
+];
+const desktopDurationSelector =
+	'.playbackTimeline__duration > span:nth-child(2)';
+const desktopCurrentTimeSelector =
+	'.playbackTimeline__timePassed > span:nth-child(2)';
+const mobileProgressSelector = '[class*=MiniPlayer_MiniPlayerProgressBar]';
 
-Connector.playerSelector = '.playControls';
+Connector.playerSelector = ['.playControls', '[class*=MiniPlayer_MiniPlayer]'];
 
-Connector.currentTimeSelector =
-	'.playbackTimeline__timePassed > span:nth-child(2)';
+Connector.getCurrentTime = () => {
+	const desktopCurrentTime = document.querySelector(
+		desktopCurrentTimeSelector,
+	);
+	if (desktopCurrentTime) {
+		return Util.getSecondsFromSelectors(desktopCurrentTimeSelector);
+	}
+
+	return Number(
+		document.querySelector(mobileProgressSelector)?.getAttribute('value'),
+	);
+};
+
+Connector.getDuration = () => {
+	const time = getDurationOrRemainingTime();
+	return time && time > 0 ? time : null;
+};
+
+Connector.getRemainingTime = () => {
+	const time = getDurationOrRemainingTime();
+	return time && time < 0 ? time : null;
+};
 
 Connector.getArtistTrack = () => {
 	let { artist, track } = Util.processSoundCloudTrack(
-		Util.getTextFromSelectors(trackSelector)
+		Util.getTextFromSelectors(trackSelector),
 	);
 	if (!artist) {
 		artist = Util.getTextFromSelectors(artistSelector);
@@ -30,24 +63,23 @@ Connector.getTrackArt = () => {
 	return null;
 };
 
-Connector.getDuration = () => {
-	const time = getDurationOrRemainingTime();
-	return time && time > 0 ? time : null;
-};
+Connector.isPlaying = () =>
+	Util.hasElementClass('.playControl', 'playing') ||
+	document
+		.querySelector('[class*=IconButton_Medium]')
+		?.getAttribute('data-testid') === 'miniplayer-pause';
 
-Connector.getRemainingTime = () => {
-	const time = getDurationOrRemainingTime();
-	return time && time < 0 ? time : null;
-};
+Connector.getUniqueID = () => {
+	const titleLink = document.querySelector(
+		'.playbackSoundBadge__titleLink',
+	) as HTMLAnchorElement | null;
 
-Connector.isPlaying = () => Util.hasElementClass('.playControl', 'playing');
+	// We don't have this on mobile.
+	if (!titleLink) {
+		return null;
+	}
 
-Connector.getUniqueID = () => {
-	const url = new URL((
-		document.querySelector(
-			'.playbackSoundBadge__titleLink'
-		) as HTMLAnchorElement
-	).href);
+	const url = new URL(titleLink.href);
 	return url.origin + url.pathname;
 };
 
@@ -65,7 +97,7 @@ const filterTrackPremiereRules = [{ source: /\[.*Premiere.*\]/i, target: '' }];
 function filterArtistPremiere(text: string) {
 	return MetadataFilter.filterWithFilterRules(
 		text,
-		filterArtistPremiereRules
+		filterArtistPremiereRules,
 	);
 }
 
@@ -77,9 +109,16 @@ Connector.applyFilter(
 	MetadataFilter.createYouTubeFilter().append({
 		artist: filterArtistPremiere,
 		track: filterTrackPremiere,
-	})
+	}),
 );
 
 function getDurationOrRemainingTime() {
-	return Util.getSecondsFromSelectors(durationSelector);
+	const desktopDuration = document.querySelector(desktopDurationSelector);
+	if (desktopDuration) {
+		return Util.getSecondsFromSelectors(desktopDurationSelector);
+	}
+
+	return Number(
+		document.querySelector(mobileProgressSelector)?.getAttribute('max'),
+	);
 }
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/connectors/technobase.fm.ts b/src/connectors/technobase.fm.ts
new file mode 100644
index 00000000..fc453db3
--- /dev/null
+++ b/src/connectors/technobase.fm.ts
@@ -0,0 +1,19 @@
+export {};
+
+const filter = MetadataFilter.createFilter({
+	track: replaceWith,
+});
+Connector.applyFilter(filter);
+
+Connector.isPlaying = () =>
+	Util.hasElementClass('.streamplayer-button', 'playing');
+
+Connector.playerSelector = '.stream-wrapper';
+Connector.artistSelector = '.streamplayer-artist .streamplayer-link';
+Connector.trackSelector = '.streamplayer-title .streamplayer-link';
+
+function replaceWith(text: string) {
+	// some DJs choose to use "Artist A with Artist B"
+	// instead of "Artist A & Artist B"
+	return text.replace(/\swith\s/, ' & ');
+}
diff --git a/src/core/background/main.ts b/src/core/background/main.ts
index 8ac71b50..ff8a8f1b 100644
--- a/src/core/background/main.ts
+++ b/src/core/background/main.ts
@@ -29,13 +29,22 @@ import {
 import { CloneableSong } from '@/core/object/song';
 import {
 	clearNowPlaying,
+	showAuthNotification,
 	showNowPlaying,
 	showSongNotRecognized,
 } from '@/util/notifications';
 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 {
+	getSongInfo,
+	scrobble,
+	sendNowPlaying,
+	sendPaused,
+	sendResumedPlaying,
+	toggleLove,
+} from './scrobble';
+import scrobbleService from '../object/scrobble-service';
 
 const disabledTabs = BrowserStorage.getStorage(BrowserStorage.DISABLED_TABS);
 
@@ -118,7 +127,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 +143,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 +170,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 +191,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 +259,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 +317,7 @@ setupBackgroundListeners(
 				payload.connector,
 				() => {
 					openTab(sender.tab?.id ?? -1);
-				}
+				},
 			);
 		},
 	}),
@@ -320,7 +329,31 @@ setupBackgroundListeners(
 		type: 'setNowPlaying',
 		fn: (payload, sender) => {
 			return sendNowPlaying(
-				new ClonedSong(payload.song, sender.tab?.id ?? -1)
+				new ClonedSong(payload.song, sender.tab?.id ?? -1),
+			);
+		},
+	}),
+
+	/**
+	 * Listener called by a controller to trigger sending paused song on every pause.
+	 */
+	backgroundListener({
+		type: 'setPaused',
+		fn: (payload, sender) => {
+			return sendPaused(
+				new ClonedSong(payload.song, sender.tab?.id ?? -1),
+			);
+		},
+	}),
+
+	/**
+	 * Listener called by a controller to trigger sending playing song on every resumed play.
+	 */
+	backgroundListener({
+		type: 'setResumedPlaying',
+		fn: (payload, sender) => {
+			return sendResumedPlaying(
+				new ClonedSong(payload.song, sender.tab?.id ?? -1),
 			);
 		},
 	}),
@@ -342,7 +375,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 +388,7 @@ setupBackgroundListeners(
 		fn: (payload, sender) => {
 			return toggleLove(
 				new ClonedSong(payload.song, sender.tab?.id ?? -1),
-				payload.isLoved
+				payload.isLoved,
 			);
 		},
 	}),
@@ -381,7 +414,7 @@ setupBackgroundListeners(
 				payload.connector,
 				() => {
 					openTab(sender.tab?.id ?? -1);
-				}
+				},
 			);
 		},
 	}),
@@ -398,9 +431,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 +473,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/background/scrobble.ts b/src/core/background/scrobble.ts
index 7b0d22b8..73703627 100644
--- a/src/core/background/scrobble.ts
+++ b/src/core/background/scrobble.ts
@@ -5,19 +5,31 @@ import { BaseSong } from '../object/song';
 import { ScrobblerSongInfo } from '../scrobbler/base-scrobbler';
 
 export async function sendNowPlaying(
-	song: BaseSong
+	song: BaseSong,
 ): Promise<ServiceCallResult[]> {
 	await scrobbleService.bindAllScrobblers();
 	return scrobbleService.sendNowPlaying(song);
 }
 
+export async function sendPaused(song: BaseSong): Promise<ServiceCallResult[]> {
+	await scrobbleService.bindAllScrobblers();
+	return scrobbleService.sendPaused(song);
+}
+
+export async function sendResumedPlaying(
+	song: BaseSong,
+): Promise<ServiceCallResult[]> {
+	await scrobbleService.bindAllScrobblers();
+	return scrobbleService.sendResumedPlaying(song);
+}
+
 export async function scrobble(song: BaseSong): Promise<ServiceCallResult[]> {
 	await scrobbleService.bindAllScrobblers();
 	return scrobbleService.scrobble(song);
 }
 
 export async function getSongInfo(
-	song: ClonedSong
+	song: ClonedSong,
 ): Promise<(Record<string, never> | ScrobblerSongInfo | null)[]> {
 	await scrobbleService.bindAllScrobblers();
 	return scrobbleService.getSongInfo(song);
@@ -25,7 +37,7 @@ export async function getSongInfo(
 
 export async function toggleLove(
 	song: BaseSong,
-	isLoved: boolean
+	isLoved: boolean,
 ): Promise<(ServiceCallResult | Record<string, never>)[]> {
 	await scrobbleService.bindAllScrobblers();
 	const res = await scrobbleService.toggleLove(song, isLoved);
diff --git a/src/core/connectors.ts b/src/core/connectors.ts
index 41f919ae..60db57c1 100644
--- a/src/core/connectors.ts
+++ b/src/core/connectors.ts
@@ -59,7 +59,7 @@ export default <ConnectorMeta[]>[
 	},
 	{
 		label: 'SoundCloud',
-		matches: ['*://soundcloud.com/*'],
+		matches: ['*://soundcloud.com/*', '*://m.soundcloud.com/*'],
 		js: 'soundcloud.js',
 		id: 'soundcloud',
 	},
@@ -1926,7 +1926,7 @@ export default <ConnectorMeta[]>[
 	},
 	{
 		label: 'Beatbump',
-		matches: ['*://beatbump.ml/*'],
+		matches: ['*://beatbump.io/*'],
 		js: 'beatbump.js',
 		id: 'beatbump',
 	},
@@ -2112,4 +2112,31 @@ 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',
+	},
+	{
+		label: 'TECHNOBASE.FM',
+		matches: [
+			'*://*technobase.fm/*',
+			'*://*housetime.fm/*',
+			'*://*hardbase.fm/*',
+			'*://*trancebase.fm/*',
+			'*://*coretime.fm/*',
+			'*://*clubtime.fm/*',
+			'*://*teatime.fm/*',
+			'*://*replay.fm/*',
+		],
+		js: 'technobase.fm.js',
+		id: 'technobase.fm',
+	},
 ];
diff --git a/src/core/content/util.ts b/src/core/content/util.ts
index cba5a511..a339c5c2 100644
--- a/src/core/content/util.ts
+++ b/src/core/content/util.ts
@@ -14,6 +14,10 @@ const BrowserStorage = (async () => {
 	return import('@/core/storage/browser-storage');
 })();
 
+const Options = (async () => {
+	return import('@/core/storage/options');
+})();
+
 /**
  * All the separators used by the core and by connectors.
  * This can be expanded just fine, do so if you run into trouble while developing a connector.
@@ -741,6 +745,54 @@ export function injectScriptIntoDocument(scriptUrl: string): void {
 	(document.head || document.documentElement).appendChild(script);
 }
 
+/**
+ * Handle async checks for DEBUG_LOGGING_ENABLED option while ensuring
+ * that logs are still printed in a predictable order.
+ */
+class DebugLogQueue {
+  private queue: {text: unknown, logType: DebugLogType}[] = [];
+  private isActive = false;
+  private shouldPrint = Options.then((awaitedOptions) => awaitedOptions.getOption(awaitedOptions.DEBUG_LOGGING_ENABLED));
+
+  /**
+   * Enqueue a log message to be printed.
+   * @param text - Debug message
+   * @param logType - Log type
+   */
+  public push(text: unknown, logType: DebugLogType): void {
+    this.queue.push({text, logType});
+    this.start();
+  }
+
+  /**
+   * Process the queue to print logs in order.
+   */
+  private async start(): Promise<void> {
+    if (this.isActive) return;
+    this.isActive = true;
+
+    try {
+      for (let i = 0; i < 100 && this.queue.length > 0; i++) {
+        const currentMessage = this.queue.shift();
+        if (currentMessage && await this.shouldPrint) {
+          const logFunc = console[currentMessage.logType];
+
+          if (typeof logFunc !== 'function') {
+            throw new TypeError(`Unknown log type: ${currentMessage.logType}`);
+          }
+
+          const message = `Web Scrobbler: ${currentMessage.text}`;
+          logFunc(message);
+        }
+      }
+      this.isActive = false;
+    } catch(err) {
+      this.isActive = false;
+    }
+  }
+}
+const debugLogQueue = new DebugLogQueue();
+
 /**
  * Print debug message with prefixed "Web Scrobbler" string.
  * @param text - Debug message
@@ -748,14 +800,7 @@ export function injectScriptIntoDocument(scriptUrl: string): void {
  */
 /* istanbul ignore next */
 export function debugLog(text: unknown, logType: DebugLogType = 'log'): void {
-	const logFunc = console[logType];
-
-	if (typeof logFunc !== 'function') {
-		throw new TypeError(`Unknown log type: ${logType}`);
-	}
-
-	const message = `Web Scrobbler: ${text}`;
-	logFunc(message);
+  debugLogQueue.push(text, logType)
 }
 
 /** YouTube section. */
diff --git a/src/core/object/controller/controller.ts b/src/core/object/controller/controller.ts
index 0ec5cf78..9ae041b2 100644
--- a/src/core/object/controller/controller.ts
+++ b/src/core/object/controller/controller.ts
@@ -164,7 +164,7 @@ export default class Controller {
 					mode: this.mode,
 					song: this.currentSong?.getCloneableData() ?? null,
 				}),
-			})
+			}),
 		);
 	}
 
@@ -273,7 +273,7 @@ export default class Controller {
 	 */
 	public finish(): void {
 		this.debugLog(
-			`Remove controller for ${this.connector.label} connector`
+			`Remove controller for ${this.connector.label} connector`,
 		);
 		this.resetState();
 	}
@@ -422,9 +422,9 @@ export default class Controller {
 			if (newState.isPlaying) {
 				this.debugLog(
 					`State from connector doesn't contain enough information about the playing track: ${toString(
-						newState as Record<string, unknown>
+						newState as Record<string, unknown>,
 					)}`,
-					'warn'
+					'warn',
 				);
 			}
 
@@ -483,7 +483,9 @@ export default class Controller {
 		this.currentSong.flags.isReplaying = this.isReplayingSong;
 
 		this.debugLog(
-			`New song detected: ${toString(newState as Record<string, string>)}`
+			`New song detected: ${toString(
+				newState as Record<string, string>,
+			)}`,
 		);
 
 		if (!this.shouldScrobblePodcasts && newState.isPodcast) {
@@ -575,7 +577,7 @@ export default class Controller {
 		}
 
 		this.debugLog(
-			`Song finished processing: ${this.currentSong.toString()}`
+			`Song finished processing: ${this.currentSong.toString()}`,
 		);
 
 		if (this.currentSong.isValid()) {
@@ -636,6 +638,7 @@ export default class Controller {
 		this.debugLog(`isPlaying state changed to ${String(value)}`);
 
 		if (value && this.currentSong) {
+			this.setResumedPlaying();
 			this.playbackTimer.resume();
 			this.replayDetectionTimer.resume();
 
@@ -649,6 +652,7 @@ export default class Controller {
 				this.setMode(this.mode);
 			}
 		} else {
+			this.setPaused();
 			this.playbackTimer.pause();
 			this.replayDetectionTimer.pause();
 		}
@@ -712,7 +716,7 @@ export default class Controller {
 	 * @param duration - Song duration in seconds
 	 */
 	private async updateTimers(
-		duration: number | null | undefined
+		duration: number | null | undefined,
 	): Promise<void> {
 		if (this.playbackTimer.isExpired()) {
 			this.debugLog('Attempt to update expired timers', 'warn');
@@ -721,7 +725,7 @@ export default class Controller {
 
 		const percent = await Options.getOption(
 			Options.SCROBBLE_PERCENT,
-			this.connector.id
+			this.connector.id,
 		);
 		if (typeof percent !== 'number') {
 			return;
@@ -737,7 +741,7 @@ export default class Controller {
 			this.debugLog(
 				`The song will be scrobbled in ${
 					remainedSeconds ?? -999
-				} seconds`
+				} seconds`,
 			);
 			this.debugLog(`The song will be repeated in ${duration} seconds`);
 		} else {
@@ -773,6 +777,30 @@ export default class Controller {
 		this.dispatchEvent(ControllerEvents.SongNowPlaying);
 	}
 
+	private async setPaused(): Promise<void> {
+		if (!assertSongNotNull(this.currentSong)) {
+			return;
+		}
+		await sendContentMessage({
+			type: 'setPaused',
+			payload: {
+				song: this.currentSong.getCloneableData(),
+			},
+		});
+	}
+
+	private async setResumedPlaying(): Promise<void> {
+		if (!assertSongNotNull(this.currentSong)) {
+			return;
+		}
+		await sendContentMessage({
+			type: 'setResumedPlaying',
+			payload: {
+				song: this.currentSong.getCloneableData(),
+			},
+		});
+	}
+
 	/**
 	 * Notify user that song it not recognized by the extension.
 	 */
diff --git a/src/core/object/scrobble-service.ts b/src/core/object/scrobble-service.ts
index a17256ba..f693301c 100644
--- a/src/core/object/scrobble-service.ts
+++ b/src/core/object/scrobble-service.ts
@@ -3,6 +3,7 @@ import LastFmScrobbler from '@/core/scrobbler/lastfm/lastfm-scrobbler';
 import LibreFmScrobbler from '@/core/scrobbler/librefm-scrobbler';
 import ListenBrainzScrobbler from '@/core/scrobbler/listenbrainz/listenbrainz-scrobbler';
 import MalojaScrobbler from '@/core/scrobbler/maloja/maloja-scrobbler';
+import WebhookScrobbler from '@/core/scrobbler/webhook-scrobbler';
 import { ServiceCallResult } from '@/core/object/service-call-result';
 import { BaseSong } from '@/core/object/song';
 import { ScrobblerSongInfo } from '@/core/scrobbler/base-scrobbler';
@@ -17,7 +18,8 @@ export type Scrobbler =
 	| LastFmScrobbler
 	| LibreFmScrobbler
 	| ListenBrainzScrobbler
-	| MalojaScrobbler;
+	| MalojaScrobbler
+	| WebhookScrobbler;
 
 /**
  * Scrobblers that are registered and that can be bound.
@@ -27,9 +29,15 @@ const registeredScrobblers = [
 	new LibreFmScrobbler(),
 	new ListenBrainzScrobbler(),
 	new MalojaScrobbler(),
+	new WebhookScrobbler(),
 ];
 
-export type ScrobblerLabel = 'Last.fm' | 'ListenBrainz' | 'Libre.fm' | 'Maloja';
+export type ScrobblerLabel =
+	| 'Last.fm'
+	| 'ListenBrainz'
+	| 'Libre.fm'
+	| 'Maloja'
+	| 'Webhook';
 
 /**
  * Check if scrobbler is in given array of scrobblers.
@@ -98,7 +106,7 @@ class ScrobbleService {
 	 * @returns Promise resolved with array of song info objects
 	 */
 	getSongInfo(
-		song: ClonedSong
+		song: ClonedSong,
 	): Promise<(Record<string, never> | ScrobblerSongInfo | null)[]> {
 		const scrobblers = registeredScrobblers.filter((scrobbler) => {
 			return scrobbler.canLoadSongInfo();
@@ -113,11 +121,11 @@ class ScrobbleService {
 				} catch {
 					debugLog(
 						`Unable to get song info from ${scrobbler.getLabel()}`,
-						'warn'
+						'warn',
 					);
 					return null;
 				}
-			})
+			}),
 		);
 	}
 
@@ -134,15 +142,77 @@ class ScrobbleService {
 				// Forward result (including errors) to caller
 				try {
 					return await scrobbler.sendNowPlaying(
-						scrobbler.applyFilter(song)
+						scrobbler.applyFilter(song),
 					);
 				} catch (result) {
 					return this.processErrorResult(
 						scrobbler,
-						result as ServiceCallResult
+						result as ServiceCallResult,
 					);
 				}
-			})
+			}),
+		);
+	}
+
+	/**
+	 * Send now playing notification to each bound scrobbler.
+	 * @param song - Song instance
+	 * @returns Promise that will be resolved then the task will complete
+	 */
+	sendPaused(song: BaseSong): Promise<ServiceCallResult[]> {
+		const supportedBoundScrobblers = this.boundScrobblers.filter(
+			(scrobbler) => typeof scrobbler.sendPaused === 'function',
+		);
+		if (supportedBoundScrobblers.length === 0) {
+			return Promise.resolve([ServiceCallResult.RESULT_OK]);
+		}
+		debugLog(`Send "paused" request: ${supportedBoundScrobblers.length}`);
+
+		return Promise.all(
+			supportedBoundScrobblers.map(async (scrobbler) => {
+				// Forward result (including errors) to caller
+				try {
+					return await scrobbler.sendPaused(
+						scrobbler.applyFilter(song),
+					);
+				} catch (result) {
+					return this.processErrorResult(
+						scrobbler,
+						result as ServiceCallResult,
+					);
+				}
+			}),
+		);
+	}
+
+	/**
+	 * Send now playing notification to each bound scrobbler.
+	 * @param song - Song instance
+	 * @returns Promise that will be resolved then the task will complete
+	 */
+	sendResumedPlaying(song: BaseSong): Promise<ServiceCallResult[]> {
+		const supportedBoundScrobblers = this.boundScrobblers.filter(
+			(scrobbler) => typeof scrobbler.sendResumedPlaying === 'function',
+		);
+		if (supportedBoundScrobblers.length === 0) {
+			return Promise.resolve([ServiceCallResult.RESULT_OK]);
+		}
+		debugLog(`Send "paused" request: ${supportedBoundScrobblers.length}`);
+
+		return Promise.all(
+			supportedBoundScrobblers.map(async (scrobbler) => {
+				// Forward result (including errors) to caller
+				try {
+					return await scrobbler.sendResumedPlaying(
+						scrobbler.applyFilter(song),
+					);
+				} catch (result) {
+					return this.processErrorResult(
+						scrobbler,
+						result as ServiceCallResult,
+					);
+				}
+			}),
 		);
 	}
 
@@ -159,15 +229,15 @@ class ScrobbleService {
 				// Forward result (including errors) to caller
 				try {
 					return await scrobbler.scrobble(
-						scrobbler.applyFilter(song)
+						scrobbler.applyFilter(song),
 					);
 				} catch (result) {
 					return this.processErrorResult(
 						scrobbler,
-						result as ServiceCallResult
+						result as ServiceCallResult,
 					);
 				}
-			})
+			}),
 		);
 	}
 
@@ -179,7 +249,7 @@ class ScrobbleService {
 	 */
 	async toggleLove(
 		song: BaseSong,
-		flag: boolean
+		flag: boolean,
 	): Promise<(ServiceCallResult | Record<string, never>)[]> {
 		const scrobblers = registeredScrobblers.filter((scrobbler) => {
 			return scrobbler.canLoveSong();
@@ -195,10 +265,10 @@ class ScrobbleService {
 				} catch (result) {
 					return this.processErrorResult(
 						scrobbler,
-						result as ServiceCallResult
+						result as ServiceCallResult,
 					);
 				}
-			})
+			}),
 		);
 	}
 
@@ -233,7 +303,7 @@ class ScrobbleService {
 	 */
 	async processErrorResult(
 		scrobbler: Scrobbler,
-		result: ServiceCallResult
+		result: ServiceCallResult,
 	): Promise<ServiceCallResult> {
 		const isOtherError = result === ServiceCallResult.ERROR_OTHER;
 		const isAuthError = result === ServiceCallResult.ERROR_AUTH;
diff --git a/src/core/scrobbler/base-scrobbler.ts b/src/core/scrobbler/base-scrobbler.ts
index eb6a8e98..899f4659 100644
--- a/src/core/scrobbler/base-scrobbler.ts
+++ b/src/core/scrobbler/base-scrobbler.ts
@@ -93,7 +93,7 @@ export default abstract class BaseScrobbler<K extends keyof ScrobblerModels> {
 	 * @param props - Object contains user properties
 	 */
 	public async applyUserProperties(
-		props: Record<string, unknown>
+		props: Record<string, unknown>,
 	): Promise<void> {
 		this.applyProps(props, this.getUsedDefinedProperties());
 
@@ -199,6 +199,26 @@ export default abstract class BaseScrobbler<K extends keyof ScrobblerModels> {
 	// eslint-disable-next-line no-unused-vars
 	public abstract sendNowPlaying(song: BaseSong): Promise<ServiceCallResult>;
 
+	/**
+	 * Send resumed playing status of song to API.
+	 * Implementation must return ServiceCallResult constant.
+	 *
+	 * @param song - Song instance
+	 */
+	// eslint-disable-next-line no-unused-vars
+	public abstract sendResumedPlaying(
+		song: BaseSong,
+	): Promise<ServiceCallResult>;
+
+	/**
+	 * Send paused status of song to API.
+	 * Implementation must return ServiceCallResult constant.
+	 *
+	 * @param song - Song instance
+	 */
+	// eslint-disable-next-line no-unused-vars
+	public abstract sendPaused(song: BaseSong): Promise<ServiceCallResult>;
+
 	/**
 	 * Send song to API to scrobble.
 	 * Implementation must return ServiceCallResult constant.
@@ -218,7 +238,7 @@ export default abstract class BaseScrobbler<K extends keyof ScrobblerModels> {
 	// eslint-disable-next-line no-unused-vars
 	public abstract toggleLove(
 		song: ClonedSong,
-		isLoved: boolean
+		isLoved: boolean,
 	): Promise<ServiceCallResult | Record<string, never>>;
 
 	/**
@@ -229,7 +249,7 @@ export default abstract class BaseScrobbler<K extends keyof ScrobblerModels> {
 	 */
 	// eslint-disable-next-line no-unused-vars
 	public abstract getSongInfo(
-		song: BaseSong
+		song: BaseSong,
 	): Promise<ScrobblerSongInfo | Record<string, never>>;
 
 	/* Getters. */
@@ -246,7 +266,8 @@ export default abstract class BaseScrobbler<K extends keyof ScrobblerModels> {
 		| 'Last.fm'
 		| 'ListenBrainz'
 		| 'Maloja'
-		| 'Libre.fm';
+		| 'Libre.fm'
+		| 'Webhook';
 
 	/**
 	 * Get URL to profile page.
@@ -353,7 +374,7 @@ export default abstract class BaseScrobbler<K extends keyof ScrobblerModels> {
 
 	private applyProps(
 		props: Record<string, unknown>,
-		allowedProps: string[]
+		allowedProps: string[],
 	): void {
 		for (const prop in props) {
 			if (!allowedProps.includes(prop)) {
diff --git a/src/core/scrobbler/webhook-scrobbler.ts b/src/core/scrobbler/webhook-scrobbler.ts
new file mode 100644
index 00000000..5ba5b908
--- /dev/null
+++ b/src/core/scrobbler/webhook-scrobbler.ts
@@ -0,0 +1,163 @@
+'use strict';
+
+import { BaseSong } from '@/core/object/song';
+import BaseScrobbler from '@/core/scrobbler/base-scrobbler';
+import { SessionData } from './base-scrobbler';
+import { timeoutPromise } from '@/util/util';
+import { ServiceCallResult } from '../object/service-call-result';
+import ClonedSong from '../object/cloned-song';
+
+type WebhookRequest = {
+	eventName: string;
+	data: {
+		song: BaseSong;
+		isLoved?: boolean;
+	};
+};
+
+/**
+ * Module for all communication with a custom webhook
+ */
+
+export default class WebhookScrobbler extends BaseScrobbler<'Webhook'> {
+	public userApiUrl!: string;
+
+	/** @override */
+	getApiUrl(): string {
+		return '';
+	}
+
+	/** @override */
+	getApiKey(): string {
+		return '';
+	}
+
+	/** @override */
+	getApiSecret(): string {
+		return '';
+	}
+
+	/** @override */
+	getBaseAuthUrl(): string {
+		return '';
+	}
+
+	/** @override */
+	getBaseProfileUrl(): string {
+		return '';
+	}
+
+	/** @override */
+	getLabel(): 'Webhook' {
+		return 'Webhook';
+	}
+
+	/** @override */
+	getStatusUrl(): string {
+		return '';
+	}
+
+	/** @override */
+	getStorageName(): 'Webhook' {
+		return 'Webhook';
+	}
+
+	/** @override */
+	getSession(): Promise<SessionData> {
+		// Webhook connection doesn't have a session.
+		return Promise.resolve({ sessionID: 'webhook' });
+	}
+
+	/** @override */
+	public getAuthUrl(): Promise<string> {
+		throw new Error('Method not implemented.');
+	}
+
+	/** @override */
+	public isReadyForGrantAccess(): Promise<boolean> {
+		throw new Error('Method not implemented.');
+	}
+
+	/** @override */
+	public getUsedDefinedProperties(): string[] {
+		return ['userApiUrl'];
+	}
+
+	public async getSongInfo(): Promise<Record<string, never>> {
+		return Promise.resolve({});
+	}
+
+	/** @override */
+	async sendRequest(request: WebhookRequest): Promise<ServiceCallResult> {
+		this.debugLog(
+			`Webhook - sendRequest: ${JSON.stringify(request, null, 2)}`,
+		);
+		const requestInfo = {
+			method: 'POST',
+			headers: {
+				'Content-Type': 'application/json',
+			},
+			body: JSON.stringify(request),
+		};
+
+		const promise = fetch(this.userApiUrl, requestInfo);
+
+		const timeout = this.REQUEST_TIMEOUT;
+
+		let response = null;
+		try {
+			response = await timeoutPromise(timeout, promise);
+			if (response.status !== 200) {
+				return ServiceCallResult.ERROR_OTHER;
+			}
+		} catch (e) {
+			this.debugLog('Error while sending request', 'error');
+			return ServiceCallResult.ERROR_OTHER;
+		}
+
+		return ServiceCallResult.RESULT_OK;
+	}
+
+	/** @override */
+	async sendNowPlaying(song: BaseSong): Promise<ServiceCallResult> {
+		return this.sendRequest({
+			eventName: 'nowplaying',
+			data: { song },
+		});
+	}
+
+	/** @override */
+	async sendPaused(song: BaseSong): Promise<ServiceCallResult> {
+		return this.sendRequest({
+			eventName: 'paused',
+			data: { song },
+		});
+	}
+
+	/** @override */
+	async sendResumedPlaying(song: BaseSong): Promise<ServiceCallResult> {
+		return this.sendRequest({
+			eventName: 'resumedplaying',
+			data: { song },
+		});
+	}
+
+	/** @override */
+	public async scrobble(song: BaseSong): Promise<ServiceCallResult> {
+		return this.sendRequest({
+			eventName: 'scrobble',
+			data: { song },
+		});
+	}
+
+	/** @override */
+	public toggleLove(
+		song: ClonedSong,
+		isLoved: boolean,
+	): Promise<ServiceCallResult | Record<string, never>> {
+		return this.sendRequest({
+			eventName: 'loved',
+			data: { song, isLoved },
+		});
+	}
+}
diff --git a/src/core/storage/browser-storage.ts b/src/core/storage/browser-storage.ts
index 416b6092..21de20c1 100644
--- a/src/core/storage/browser-storage.ts
+++ b/src/core/storage/browser-storage.ts
@@ -126,6 +126,7 @@ const storageTypeMap = {
 	LibreFM: LOCAL,
 	ListenBrainz: LOCAL,
 	Maloja: LOCAL,
+	Webhook: LOCAL,
 
 	[LOCAL_CACHE]: LOCAL,
 	[REGEX_EDITS]: LOCAL,
@@ -141,7 +142,7 @@ const storageTypeMap = {
  * @throws if unknown namespace is specified
  */
 export function getStorage<K extends StorageNamespace>(
-	namespace: K
+	namespace: K,
 ): StorageWrapper<K> {
 	const storageType = storageTypeMap[namespace];
 	switch (storageType) {
@@ -160,7 +161,7 @@ export function getStorage<K extends StorageNamespace>(
  * @returns StorageWrapper instance
  */
 export function getScrobblerStorage<K extends keyof DataModels>(
-	namespace: StorageNamespace
+	namespace: StorageNamespace,
 ): StorageWrapper<K> {
 	return getLocalStorage(namespace);
 }
@@ -171,7 +172,7 @@ export function getScrobblerStorage<K extends keyof DataModels>(
  * @returns StorageWrapper instance
  */
 export function getLocalStorage<K extends keyof DataModels>(
-	namespace: StorageNamespace
+	namespace: StorageNamespace,
 ): StorageWrapper<K> {
 	const storageArea = browser.storage.local;
 	return new StorageWrapper(storageArea, namespace);
@@ -184,7 +185,7 @@ export function getLocalStorage<K extends keyof DataModels>(
  * @returns StorageWrapper instance
  */
 export function getSyncStorage<K extends keyof DataModels>(
-	namespace: StorageNamespace
+	namespace: StorageNamespace,
 ): StorageWrapper<K> {
 	const storageArea = browser.storage.sync || browser.storage.local;
 	return new StorageWrapper(storageArea, namespace);
diff --git a/src/core/storage/options.ts b/src/core/storage/options.ts
index 68cb5b23..9be3fe8c 100644
--- a/src/core/storage/options.ts
+++ b/src/core/storage/options.ts
@@ -19,6 +19,7 @@ export const SCROBBLE_RECOGNIZED_TRACKS = 'scrobbleRecognizedTracks';
 export const SCROBBLE_EDITED_TRACKS_ONLY = 'scrobbleEditedTracksOnly';
 export const SCROBBLE_PERCENT = 'scrobblePercent';
 export const DISABLED_CONNECTORS = 'disabledConnectors';
+export const DEBUG_LOGGING_ENABLED = 'debugLoggingEnabled';
 
 export interface GlobalOptions {
 	/**
@@ -62,6 +63,11 @@ export interface GlobalOptions {
 	 * Scrobble podcast episodes.
 	 */
 	[SCROBBLE_PODCASTS]: boolean;
+
+	/**
+	 * Allow debug messages to be logged to the browser console.
+	 */
+	[DEBUG_LOGGING_ENABLED]: boolean;
 }
 
 /**
@@ -74,6 +80,7 @@ const DEFAULT_OPTIONS: GlobalOptions = {
 	[USE_UNRECOGNIZED_SONG_NOTIFICATIONS]: false,
 	[SCROBBLE_RECOGNIZED_TRACKS]: true,
 	[SCROBBLE_EDITED_TRACKS_ONLY]: false,
+	[DEBUG_LOGGING_ENABLED]: false,
 	[SCROBBLE_PERCENT]: 50,
 	[DISABLED_CONNECTORS]: {},
 };
diff --git a/src/core/storage/wrapper.ts b/src/core/storage/wrapper.ts
index 284e9c7b..af7f9866 100644
--- a/src/core/storage/wrapper.ts
+++ b/src/core/storage/wrapper.ts
@@ -50,11 +50,14 @@ export type ListenBrainzModel =
 	| ListenBrainzAuthStarted
 	| ListenBrainzAuthFinished;
 
+export type WebhookModel = { userApiUrl?: string };
+
 export interface ScrobblerModels {
 	LastFM?: { token?: string } | { sessionID?: string; sessionName?: string };
 	LibreFM?: { token?: string } | { sessionID?: string; sessionName?: string };
 	ListenBrainz?: ListenBrainzModel;
 	Maloja?: Properties;
+	Webhook?: WebhookModel;
 }
 
 export interface ManagerTab {
@@ -126,7 +129,7 @@ export default class StorageWrapper<K extends keyof DataModels> {
 		storage:
 			| browser.Storage.SyncStorageAreaSync
 			| browser.Storage.LocalStorageArea,
-		namespace: StorageNamespace
+		namespace: StorageNamespace,
 	) {
 		this.storage = storage;
 		this.namespace = namespace;
diff --git a/src/ui/options/components/accounts.tsx b/src/ui/options/components/accounts.tsx
index adf1415e..7af59162 100644
--- a/src/ui/options/components/accounts.tsx
+++ b/src/ui/options/components/accounts.tsx
@@ -36,6 +36,13 @@ const scrobblerPropertiesMap = {
 			placeholder: 'accountsUserTokenPlaceholder',
 		},
 	},
+	Webhook: {
+		userApiUrl: {
+			type: 'text',
+			title: 'accountsUserApiUrl',
+			placeholder: 'accountsUserApiUrlPlaceholder',
+		},
+	},
 };
 
 /**
@@ -49,6 +56,7 @@ export default function Accounts() {
 			<ScrobblerDisplay label="Libre.fm" />
 			<ScrobblerDisplay label="ListenBrainz" />
 			<ScrobblerDisplay label="Maloja" />
+			<ScrobblerDisplay label="Webhook" />
 		</>
 	);
 }
@@ -61,10 +69,10 @@ function ScrobblerDisplay(props: { label: ScrobblerLabel }) {
 	const rawScrobbler = ScrobbleService.getScrobblerByLabel(label);
 	if (!rawScrobbler) return <></>;
 	const [session, setSession] = createResource(
-		rawScrobbler.getSession.bind(rawScrobbler)
+		rawScrobbler.getSession.bind(rawScrobbler),
 	);
 	const [profileUrl, setProfileUrl] = createResource(
-		rawScrobbler.getProfileUrl.bind(rawScrobbler)
+		rawScrobbler.getProfileUrl.bind(rawScrobbler),
 	);
 
 	const onFocus = async () => {
@@ -77,7 +85,7 @@ function ScrobblerDisplay(props: { label: ScrobblerLabel }) {
 		} catch (err) {
 			debugLog(
 				`${rawScrobbler.getLabel()}: Error while fetching session`,
-				'warn'
+				'warn',
 			);
 			debugLog(err, 'warn');
 		}
@@ -92,7 +100,7 @@ function ScrobblerDisplay(props: { label: ScrobblerLabel }) {
 				<p>
 					{t(
 						'accountsSignedInAs',
-						session()?.sessionName || 'anonymous'
+						session()?.sessionName || 'anonymous',
 					)}
 				</p>
 				<div class={styles.buttonContainer}>
@@ -156,7 +164,7 @@ function Properties(props: { scrobbler: Scrobbler }) {
 	const label = scrobbler.getLabel();
 	if (!labelHasProperties(label)) return <></>;
 	const [properties, setProperties] = createResource(
-		scrobbler.getUserProperties.bind(scrobbler)
+		scrobbler.getUserProperties.bind(scrobbler),
 	);
 	return (
 		<>
@@ -197,7 +205,7 @@ function Properties(props: { scrobbler: Scrobbler }) {
  * @returns true if scrobbler has properties, false if not
  */
 function labelHasProperties(
-	label: ScrobblerLabel
+	label: ScrobblerLabel,
 ): label is keyof typeof scrobblerPropertiesMap {
 	return label in scrobblerPropertiesMap;
 }
diff --git a/src/ui/options/components/options/global-options.tsx b/src/ui/options/components/options/global-options.tsx
index 6ddc1921..2e328517 100644
--- a/src/ui/options/components/options/global-options.tsx
+++ b/src/ui/options/components/options/global-options.tsx
@@ -57,6 +57,14 @@ export default function GlobalOptionsList(props: {
 					i18nlabel="optionScrobblePodcasts"
 					key={Options.SCROBBLE_PODCASTS}
 				/>
+				<GlobalOptionEntry
+					options={props.options}
+					setOptions={props.setOptions}
+					globalOptions={globalOptions}
+					i18ntitle="optionDebugLoggingEnabledTitle"
+					i18nlabel="optionDebugLoggingEnabled"
+					key={Options.DEBUG_LOGGING_ENABLED}
+				/>
 			</ul>
 		</>
 	);
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/communication.ts b/src/util/communication.ts
index 1cf66619..bff7445d 100644
--- a/src/util/communication.ts
+++ b/src/util/communication.ts
@@ -61,6 +61,18 @@ interface ContentCommunications {
 		};
 		response: Promise<ServiceCallResult[]>;
 	};
+	setPaused: {
+		payload: {
+			song: CloneableSong;
+		};
+		response: void;
+	};
+	setResumedPlaying: {
+		payload: {
+			song: CloneableSong;
+		};
+		response: void;
+	};
 	scrobble: {
 		payload: {
 			song: CloneableSong;
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();
 }
 
 /**
diff --git a/src/util/util.ts b/src/util/util.ts
index 407aac0d..7e6458f4 100644
--- a/src/util/util.ts
+++ b/src/util/util.ts
@@ -7,6 +7,7 @@ import {
 } from '@/core/storage/options';
 import {
 	ListenBrainzModel,
+	WebhookModel,
 	Properties,
 	StateManagement,
 } from '@/core/storage/wrapper';
@@ -62,7 +63,7 @@ export function debugLog(text: unknown, logType: DebugLogType = 'log'): void {
  */
 export function getSecondsToScrobble(
 	duration: number | null | undefined,
-	percent: number
+	percent: number,
 ): number {
 	if (isDurationInvalid(duration)) {
 		return DEFAULT_SCROBBLE_TIME;
@@ -112,8 +113,9 @@ export function hideObjectValue(
 		| { token?: string }
 		| Properties
 		| ListenBrainzModel
+		| WebhookModel
 		| StateManagement
-		| RegexEdit[]
+		| RegexEdit[],
 ): string {
 	if (!keyValue) {
 		if (keyValue === null) {
@@ -137,7 +139,7 @@ export function hideObjectValue(
  * @returns Check result
  */
 function isDurationInvalid(
-	duration: number | null | undefined
+	duration: number | null | undefined,
 ): duration is null | undefined {
 	return (
 		!duration ||
@@ -155,7 +157,7 @@ function isDurationInvalid(
  */
 export function timeoutPromise<T>(
 	timeout: number,
-	promise: Promise<T>
+	promise: Promise<T>,
 ): Promise<T> {
 	return new Promise((resolve, reject) => {
 		const timeoutId = setTimeout(() => {
@@ -169,7 +171,7 @@ export function timeoutPromise<T>(
 			(err) => {
 				clearTimeout(timeoutId);
 				reject(err);
-			}
+			},
 		);
 	});
 }
@@ -228,7 +230,7 @@ export function createArtistURL(artist: string | null | undefined): string {
  */
 export function createAlbumURL(
 	artist: string | null | undefined,
-	album: string | null | undefined
+	album: string | null | undefined,
 ): string {
 	if (!album || !artist) {
 		return '';
@@ -244,7 +246,7 @@ export function createAlbumURL(
  */
 export function createTrackURL(
 	artist: string | null | undefined,
-	track?: string | null | undefined
+	track?: string | null | undefined,
 ): string {
 	if (!track || !artist) {
 		return '';
@@ -262,14 +264,14 @@ export function createTrackURL(
 export function createTrackLibraryURL(
 	username: string | null | undefined,
 	artist: string | null | undefined,
-	track: string | null | undefined
+	track: string | null | undefined,
 ): string {
 	if (!track || !artist || !username) {
 		return '';
 	}
 	return `https://www.last.fm/user/${encodeURIComponent(
-		username
+		username,
 	)}/library/music/${encodeURIComponent(artist)}/_/${encodeURIComponent(
-		track
+		track,
 	)}`;
 }


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,
+	},
 };