Repository: web-scrobbler/web-scrobbler
Pull request #4458 (2024-01-27 16:23:26)

Bump actions/upload-artifact from 3 to 4
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 3 to 4.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a href="https://github.com/actions/upload-artifact/releases">actions/upload-artifact's releases</a>.</em></p>
<blockquote>
<h2>v4.0.0</h2>
<h2>What's Changed</h2>
<p>The release of upload-artifact@v4 and download-artifact@v4 are major changes to the backend architecture of Artifacts. They have numerous performance and behavioral improvements.</p>
<p>ℹ️ However, this is a major update that includes breaking changes. Artifacts created with versions v3 and below are not compatible with the v4 actions. Uploads and downloads <em>must</em> use the same major actions versions. There are also key differences from previous versions that may require updates to your workflows.</p>
<p>For more information, please see:</p>
<ol>
<li>The <a href="https://github.blog/changelog/2023-12-14-github-actions-artifacts-v4-is-now-generally-available/">changelog</a> post.</li>
<li>The <a href="https://github.com/actions/upload-artifact/blob/main/README.md">README</a>.</li>
<li>The <a href="https://github.com/actions/upload-artifact/blob/main/docs/MIGRATION.md">migration documentation</a>.</li>
<li>As well as the underlying npm package, <a href="https://github.com/actions/toolkit/tree/main/packages/artifact"><code>@​actions/artifact</code></a> documentation.</li>
</ol>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/vmjoseph"><code>@​vmjoseph</code></a> made their first contribution in <a href="https://redirect.github.com/actions/upload-artifact/pull/464">actions/upload-artifact#464</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a href="https://github.com/actions/upload-artifact/compare/v3...v4.0.0">https://github.com/actions/upload-artifact/compare/v3...v4.0.0</a></p>
<h2>v3.1.3</h2>
<h2>What's Changed</h2>
<ul>
<li>chore(github): remove trailing whitespaces by <a href="https://github.com/ljmf00"><code>@​ljmf00</code></a> in <a href="https://redirect.github.com/actions/upload-artifact/pull/313">actions/upload-artifact#313</a></li>
<li>Bump <code>@​actions/artifact</code> version to v1.1.2 by <a href="https://github.com/bethanyj28"><code>@​bethanyj28</code></a> in <a href="https://redirect.github.com/actions/upload-artifact/pull/436">actions/upload-artifact#436</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a href="https://github.com/actions/upload-artifact/compare/v3...v3.1.3">https://github.com/actions/upload-artifact/compare/v3...v3.1.3</a></p>
<h2>v3.1.2</h2>
<ul>
<li>Update all <code>@actions/*</code> NPM packages to their latest versions- <a href="https://redirect.github.com/actions/upload-artifact/issues/374">#374</a></li>
<li>Update all dev dependencies to their most recent versions - <a href="https://redirect.github.com/actions/upload-artifact/issues/375">#375</a></li>
</ul>
<h2>v3.1.1</h2>
<ul>
<li>Update actions/core package to latest version to remove <code>set-output</code> deprecation warning <a href="https://redirect.github.com/actions/upload-artifact/issues/351">#351</a></li>
</ul>
<h2>v3.1.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Bump <code>@​actions/artifact</code> to v1.1.0 (<a href="https://redirect.github.com/actions/upload-artifact/pull/327">actions/upload-artifact#327</a>)
<ul>
<li>Adds checksum headers on artifact upload (<a href="https://redirect.github.com/actions/toolkit/pull/1095">actions/toolkit#1095</a>) (<a href="https://redirect.github.com/actions/toolkit/pull/1063">actions/toolkit#1063</a>)</li>
</ul>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/actions/upload-artifact/commit/26f96dfa697d77e81fd5907df203aa23a56210a8"><code>26f96df</code></a> Merge pull request <a href="https://redirect.github.com/actions/upload-artifact/issues/505">#505</a> from actions/robherley/merge-artifacts</li>
<li><a href="https://github.com/actions/upload-artifact/commit/530ed2c9b8e7b46db7acf98b7e543836a040586e"><code>530ed2c</code></a> use correct artifact name in test</li>
<li><a href="https://github.com/actions/upload-artifact/commit/53ef6987b3562a24090358abb83a07f2c2bfec26"><code>53ef698</code></a> add additional integration test for merge all behavior</li>
<li><a href="https://github.com/actions/upload-artifact/commit/90b0f8eed8fd7a9e62025ff26858a0f618d12b53"><code>90b0f8e</code></a> add workflow to test merging</li>
<li><a href="https://github.com/actions/upload-artifact/commit/199a58f54fa418c391917374e9d11f5a5e618ae9"><code>199a58f</code></a> more docs</li>
<li><a href="https://github.com/actions/upload-artifact/commit/3f353f9d9ee7e6a8313bfb227f44aa283e6832c9"><code>3f353f9</code></a> ncc</li>
<li><a href="https://github.com/actions/upload-artifact/commit/997fffa3558d08f16f0c12036708e1ce7c0a7430"><code>997fffa</code></a> add merge artifact sub-action</li>
<li><a href="https://github.com/actions/upload-artifact/commit/52899c8c02604e1c6c9ea56b52ead4671321bd05"><code>52899c8</code></a> Merge pull request <a href="https://redirect.github.com/actions/upload-artifact/issues/504">#504</a> from actions/robherley/reorganize</li>
<li><a href="https://github.com/actions/upload-artifact/commit/da58a3f7b2ea3d2db018d82fe856b2d4b297de35"><code>da58a3f</code></a> ncc</li>
<li><a href="https://github.com/actions/upload-artifact/commit/1f64adb8530b493d492e8084db08a55f3856e94e"><code>1f64adb</code></a> fix import</li>
<li>Additional commits viewable in <a href="https://github.com/actions/upload-artifact/compare/v3...v4">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/upload-artifact&package-manager=github_actions&previous-version=3&new-version=4)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

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

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

---

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

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


</details>


Patch:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 8d311ae3..f45b7b33 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -27,7 +27,7 @@ jobs:
 
       - name: Upload the extension as an artifact
         if: github.ref == 'refs/heads/master'
-        uses: actions/upload-artifact@v3
+        uses: actions/upload-artifact@v4
         with:
           name: web-scrobbler-chrome
           path: build/chrome
@@ -58,7 +58,7 @@ jobs:
 
       - name: Upload the extension as an artifact
         if: github.ref == 'refs/heads/master'
-        uses: actions/upload-artifact@v3
+        uses: actions/upload-artifact@v4
         with:
           name: web-scrobbler-firefox
           path: build/firefox
@@ -85,7 +85,7 @@ jobs:
 
       - name: Upload the extension as an artifact
         if: github.ref == 'refs/heads/master'
-        uses: actions/upload-artifact@v3
+        uses: actions/upload-artifact@v4
         with:
           name: web-scrobbler-safari
           path: build/safari
diff --git a/package-lock.json b/package-lock.json
index a9bff8a0..233d1661 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -22,9 +22,9 @@
         "@types/imagemin": "8.0.5",
         "@types/imagemin-jpegtran": "5.0.4",
         "@types/node": "20.11.5",
-        "@types/react": "18.2.48",
+        "@types/react": "18.2.52",
         "@types/webextension-polyfill": "0.10.7",
-        "@typescript-eslint/eslint-plugin": "6.19.1",
+        "@typescript-eslint/eslint-plugin": "6.20.0",
         "@web-scrobbler/eslint-config": "3.0.0",
         "@web-scrobbler/stylelint-config": "1.0.1",
         "archiver": "6.0.1",
@@ -1774,9 +1774,9 @@
       "dev": true
     },
     "node_modules/@types/react": {
-      "version": "18.2.48",
-      "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.48.tgz",
-      "integrity": "sha512-qboRCl6Ie70DQQG9hhNREz81jqC1cs9EVNcjQ1AU+jH6NFfSAhVVbrrY/+nSF+Bsk4AOwm9Qa61InvMCyV+H3w==",
+      "version": "18.2.52",
+      "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.52.tgz",
+      "integrity": "sha512-E/YjWh3tH+qsLKaUzgpZb5AY0ChVa+ZJzF7ogehVILrFpdQk6nC/WXOv0bfFEABbXbgNxLBGU7IIZByPKb6eBw==",
       "dev": true,
       "dependencies": {
         "@types/prop-types": "*",
@@ -1830,16 +1830,16 @@
       "dev": true
     },
     "node_modules/@typescript-eslint/eslint-plugin": {
-      "version": "6.19.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.19.1.tgz",
-      "integrity": "sha512-roQScUGFruWod9CEyoV5KlCYrubC/fvG8/1zXuT0WTcxX87GnMMmnksMwSg99lo1xiKrBzw2icsJPMAw1OtKxg==",
+      "version": "6.20.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.20.0.tgz",
+      "integrity": "sha512-fTwGQUnjhoYHeSF6m5pWNkzmDDdsKELYrOBxhjMrofPqCkoC2k3B2wvGHFxa1CTIqkEn88nlW1HVMztjo2K8Hg==",
       "dev": true,
       "dependencies": {
         "@eslint-community/regexpp": "^4.5.1",
-        "@typescript-eslint/scope-manager": "6.19.1",
-        "@typescript-eslint/type-utils": "6.19.1",
-        "@typescript-eslint/utils": "6.19.1",
-        "@typescript-eslint/visitor-keys": "6.19.1",
+        "@typescript-eslint/scope-manager": "6.20.0",
+        "@typescript-eslint/type-utils": "6.20.0",
+        "@typescript-eslint/utils": "6.20.0",
+        "@typescript-eslint/visitor-keys": "6.20.0",
         "debug": "^4.3.4",
         "graphemer": "^1.4.0",
         "ignore": "^5.2.4",
@@ -1865,13 +1865,13 @@
       }
     },
     "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": {
-      "version": "6.19.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.19.1.tgz",
-      "integrity": "sha512-4CdXYjKf6/6aKNMSly/BP4iCSOpvMmqtDzRtqFyyAae3z5kkqEjKndR5vDHL8rSuMIIWP8u4Mw4VxLyxZW6D5w==",
+      "version": "6.20.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.20.0.tgz",
+      "integrity": "sha512-p4rvHQRDTI1tGGMDFQm+GtxP1ZHyAh64WANVoyEcNMpaTFn3ox/3CcgtIlELnRfKzSs/DwYlDccJEtr3O6qBvA==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "6.19.1",
-        "@typescript-eslint/visitor-keys": "6.19.1"
+        "@typescript-eslint/types": "6.20.0",
+        "@typescript-eslint/visitor-keys": "6.20.0"
       },
       "engines": {
         "node": "^16.0.0 || >=18.0.0"
@@ -1882,9 +1882,9 @@
       }
     },
     "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": {
-      "version": "6.19.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.19.1.tgz",
-      "integrity": "sha512-6+bk6FEtBhvfYvpHsDgAL3uo4BfvnTnoge5LrrCj2eJN8g3IJdLTD4B/jK3Q6vo4Ql/Hoip9I8aB6fF+6RfDqg==",
+      "version": "6.20.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.20.0.tgz",
+      "integrity": "sha512-MM9mfZMAhiN4cOEcUOEx+0HmuaW3WBfukBZPCfwSqFnQy0grXYtngKCqpQN339X3RrwtzspWJrpbrupKYUSBXQ==",
       "dev": true,
       "engines": {
         "node": "^16.0.0 || >=18.0.0"
@@ -1895,12 +1895,12 @@
       }
     },
     "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": {
-      "version": "6.19.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.19.1.tgz",
-      "integrity": "sha512-gkdtIO+xSO/SmI0W68DBg4u1KElmIUo3vXzgHyGPs6cxgB0sa3TlptRAAE0hUY1hM6FcDKEv7aIwiTGm76cXfQ==",
+      "version": "6.20.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.20.0.tgz",
+      "integrity": "sha512-E8Cp98kRe4gKHjJD4NExXKz/zOJ1A2hhZc+IMVD6i7w4yjIvh6VyuRI0gRtxAsXtoC35uGMaQ9rjI2zJaXDEAw==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "6.19.1",
+        "@typescript-eslint/types": "6.20.0",
         "eslint-visitor-keys": "^3.4.1"
       },
       "engines": {
@@ -1959,13 +1959,13 @@
       }
     },
     "node_modules/@typescript-eslint/type-utils": {
-      "version": "6.19.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.19.1.tgz",
-      "integrity": "sha512-0vdyld3ecfxJuddDjACUvlAeYNrHP/pDeQk2pWBR2ESeEzQhg52DF53AbI9QCBkYE23lgkhLCZNkHn2hEXXYIg==",
+      "version": "6.20.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.20.0.tgz",
+      "integrity": "sha512-qnSobiJQb1F5JjN0YDRPHruQTrX7ICsmltXhkV536mp4idGAYrIyr47zF/JmkJtEcAVnIz4gUYJ7gOZa6SmN4g==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/typescript-estree": "6.19.1",
-        "@typescript-eslint/utils": "6.19.1",
+        "@typescript-eslint/typescript-estree": "6.20.0",
+        "@typescript-eslint/utils": "6.20.0",
         "debug": "^4.3.4",
         "ts-api-utils": "^1.0.1"
       },
@@ -1986,9 +1986,9 @@
       }
     },
     "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": {
-      "version": "6.19.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.19.1.tgz",
-      "integrity": "sha512-6+bk6FEtBhvfYvpHsDgAL3uo4BfvnTnoge5LrrCj2eJN8g3IJdLTD4B/jK3Q6vo4Ql/Hoip9I8aB6fF+6RfDqg==",
+      "version": "6.20.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.20.0.tgz",
+      "integrity": "sha512-MM9mfZMAhiN4cOEcUOEx+0HmuaW3WBfukBZPCfwSqFnQy0grXYtngKCqpQN339X3RrwtzspWJrpbrupKYUSBXQ==",
       "dev": true,
       "engines": {
         "node": "^16.0.0 || >=18.0.0"
@@ -1999,13 +1999,13 @@
       }
     },
     "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": {
-      "version": "6.19.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.19.1.tgz",
-      "integrity": "sha512-aFdAxuhzBFRWhy+H20nYu19+Km+gFfwNO4TEqyszkMcgBDYQjmPJ61erHxuT2ESJXhlhrO7I5EFIlZ+qGR8oVA==",
+      "version": "6.20.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.20.0.tgz",
+      "integrity": "sha512-RnRya9q5m6YYSpBN7IzKu9FmLcYtErkDkc8/dKv81I9QiLLtVBHrjz+Ev/crAqgMNW2FCsoZF4g2QUylMnJz+g==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "6.19.1",
-        "@typescript-eslint/visitor-keys": "6.19.1",
+        "@typescript-eslint/types": "6.20.0",
+        "@typescript-eslint/visitor-keys": "6.20.0",
         "debug": "^4.3.4",
         "globby": "^11.1.0",
         "is-glob": "^4.0.3",
@@ -2027,12 +2027,12 @@
       }
     },
     "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": {
-      "version": "6.19.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.19.1.tgz",
-      "integrity": "sha512-gkdtIO+xSO/SmI0W68DBg4u1KElmIUo3vXzgHyGPs6cxgB0sa3TlptRAAE0hUY1hM6FcDKEv7aIwiTGm76cXfQ==",
+      "version": "6.20.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.20.0.tgz",
+      "integrity": "sha512-E8Cp98kRe4gKHjJD4NExXKz/zOJ1A2hhZc+IMVD6i7w4yjIvh6VyuRI0gRtxAsXtoC35uGMaQ9rjI2zJaXDEAw==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "6.19.1",
+        "@typescript-eslint/types": "6.20.0",
         "eslint-visitor-keys": "^3.4.1"
       },
       "engines": {
@@ -2110,17 +2110,17 @@
       }
     },
     "node_modules/@typescript-eslint/utils": {
-      "version": "6.19.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.19.1.tgz",
-      "integrity": "sha512-JvjfEZuP5WoMqwh9SPAPDSHSg9FBHHGhjPugSRxu5jMfjvBpq5/sGTD+9M9aQ5sh6iJ8AY/Kk/oUYVEMAPwi7w==",
+      "version": "6.20.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.20.0.tgz",
+      "integrity": "sha512-/EKuw+kRu2vAqCoDwDCBtDRU6CTKbUmwwI7SH7AashZ+W+7o8eiyy6V2cdOqN49KsTcASWsC5QeghYuRDTyOOg==",
       "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.19.1",
-        "@typescript-eslint/types": "6.19.1",
-        "@typescript-eslint/typescript-estree": "6.19.1",
+        "@typescript-eslint/scope-manager": "6.20.0",
+        "@typescript-eslint/types": "6.20.0",
+        "@typescript-eslint/typescript-estree": "6.20.0",
         "semver": "^7.5.4"
       },
       "engines": {
@@ -2135,13 +2135,13 @@
       }
     },
     "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": {
-      "version": "6.19.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.19.1.tgz",
-      "integrity": "sha512-4CdXYjKf6/6aKNMSly/BP4iCSOpvMmqtDzRtqFyyAae3z5kkqEjKndR5vDHL8rSuMIIWP8u4Mw4VxLyxZW6D5w==",
+      "version": "6.20.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.20.0.tgz",
+      "integrity": "sha512-p4rvHQRDTI1tGGMDFQm+GtxP1ZHyAh64WANVoyEcNMpaTFn3ox/3CcgtIlELnRfKzSs/DwYlDccJEtr3O6qBvA==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "6.19.1",
-        "@typescript-eslint/visitor-keys": "6.19.1"
+        "@typescript-eslint/types": "6.20.0",
+        "@typescript-eslint/visitor-keys": "6.20.0"
       },
       "engines": {
         "node": "^16.0.0 || >=18.0.0"
@@ -2152,9 +2152,9 @@
       }
     },
     "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": {
-      "version": "6.19.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.19.1.tgz",
-      "integrity": "sha512-6+bk6FEtBhvfYvpHsDgAL3uo4BfvnTnoge5LrrCj2eJN8g3IJdLTD4B/jK3Q6vo4Ql/Hoip9I8aB6fF+6RfDqg==",
+      "version": "6.20.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.20.0.tgz",
+      "integrity": "sha512-MM9mfZMAhiN4cOEcUOEx+0HmuaW3WBfukBZPCfwSqFnQy0grXYtngKCqpQN339X3RrwtzspWJrpbrupKYUSBXQ==",
       "dev": true,
       "engines": {
         "node": "^16.0.0 || >=18.0.0"
@@ -2165,13 +2165,13 @@
       }
     },
     "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": {
-      "version": "6.19.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.19.1.tgz",
-      "integrity": "sha512-aFdAxuhzBFRWhy+H20nYu19+Km+gFfwNO4TEqyszkMcgBDYQjmPJ61erHxuT2ESJXhlhrO7I5EFIlZ+qGR8oVA==",
+      "version": "6.20.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.20.0.tgz",
+      "integrity": "sha512-RnRya9q5m6YYSpBN7IzKu9FmLcYtErkDkc8/dKv81I9QiLLtVBHrjz+Ev/crAqgMNW2FCsoZF4g2QUylMnJz+g==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "6.19.1",
-        "@typescript-eslint/visitor-keys": "6.19.1",
+        "@typescript-eslint/types": "6.20.0",
+        "@typescript-eslint/visitor-keys": "6.20.0",
         "debug": "^4.3.4",
         "globby": "^11.1.0",
         "is-glob": "^4.0.3",
@@ -2193,12 +2193,12 @@
       }
     },
     "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": {
-      "version": "6.19.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.19.1.tgz",
-      "integrity": "sha512-gkdtIO+xSO/SmI0W68DBg4u1KElmIUo3vXzgHyGPs6cxgB0sa3TlptRAAE0hUY1hM6FcDKEv7aIwiTGm76cXfQ==",
+      "version": "6.20.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.20.0.tgz",
+      "integrity": "sha512-E8Cp98kRe4gKHjJD4NExXKz/zOJ1A2hhZc+IMVD6i7w4yjIvh6VyuRI0gRtxAsXtoC35uGMaQ9rjI2zJaXDEAw==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "6.19.1",
+        "@typescript-eslint/types": "6.20.0",
         "eslint-visitor-keys": "^3.4.1"
       },
       "engines": {
diff --git a/package.json b/package.json
index fe54b322..dfda1df3 100644
--- a/package.json
+++ b/package.json
@@ -41,9 +41,9 @@
     "@types/imagemin": "8.0.5",
     "@types/imagemin-jpegtran": "5.0.4",
     "@types/node": "20.11.5",
-    "@types/react": "18.2.48",
+    "@types/react": "18.2.52",
     "@types/webextension-polyfill": "0.10.7",
-    "@typescript-eslint/eslint-plugin": "6.19.1",
+    "@typescript-eslint/eslint-plugin": "6.20.0",
     "@web-scrobbler/eslint-config": "3.0.0",
     "@web-scrobbler/stylelint-config": "1.0.1",
     "archiver": "6.0.1",
diff --git a/scripts/compile-connectors.ts b/scripts/compile-connectors.ts
index f583cf40..397324a0 100644
--- a/scripts/compile-connectors.ts
+++ b/scripts/compile-connectors.ts
@@ -8,12 +8,12 @@ import chokidar from 'chokidar';
 /**
  * Sends the actual command to typescript to compile connectors, and places them where they should be.
  *
- * @returns A promise that resolves when the conenctors are compiled
+ * @returns A promise that resolves when the connectors are compiled
  */
 function generateConnectors() {
 	return new Promise<void>((resolve, reject) => {
 		exec(
-			'tsc --project tsconfig.connectors.json',
+			'esbuild src/connectors/*.ts --bundle --outdir=build/connectorraw --tsconfig=tsconfig.connectors.json',
 			(err, stdout, stderr) => {
 				if (err) {
 					colorLog(err, 'error');
@@ -27,7 +27,7 @@ function generateConnectors() {
 				try {
 					fs.removeSync(`build/${getBrowser()}/connectors`);
 					fs.moveSync(
-						'build/connectorraw/connectors',
+						'build/connectorraw',
 						`build/${getBrowser()}/connectors`,
 					);
 					fs.removeSync('build/connectorraw');
diff --git a/scripts/generate-icons.ts b/scripts/generate-icons.ts
index 59e40205..1073cff2 100644
--- a/scripts/generate-icons.ts
+++ b/scripts/generate-icons.ts
@@ -75,6 +75,10 @@ const monochromeColors = {
 		light: '#3b3b3b',
 		dark: '#f7f7f7',
 	},
+	'action_paused.svg': {
+		light: '#7f7f7f',
+		dark: '#9f9f9f',
+	},
 	'action_playing.svg': {
 		light: '#66a858',
 		dark: '#66a858',
diff --git a/scripts/minify-images.ts b/scripts/minify-images.ts
index 2bd02a5b..eac16f38 100644
--- a/scripts/minify-images.ts
+++ b/scripts/minify-images.ts
@@ -25,10 +25,17 @@ async function performMinification() {
 /**
  * Vite plugin that minifies images then move them to the right place
  */
-export default function minifyImages(): PluginOption {
+export default function minifyImages(options: {
+	isDev: boolean;
+}): PluginOption {
 	return {
-		name: 'compile-connectors',
+		name: 'minify-images',
 		buildEnd: async () => {
+			if (options.isDev) {
+				colorLog('Skipping image minification for dev build', 'info');
+				return;
+			}
+
 			await performMinification();
 			colorLog('Finished image minification', 'success');
 		},
diff --git a/src/.eslintrc.yml b/src/.eslintrc.yml
deleted file mode 100644
index a8013b10..00000000
--- a/src/.eslintrc.yml
+++ /dev/null
@@ -1,5 +0,0 @@
-env:
-  browser: true
-globals:
-  chrome: readonly
-  define: readonly
diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json
index fac5dd29..08342c34 100644
--- a/src/_locales/en/messages.json
+++ b/src/_locales/en/messages.json
@@ -47,6 +47,14 @@
         "message": "Scrobble podcast episodes",
         "description": "Option title"
     },
+    "optionAutoToggleLove": {
+        "message": "Love/unlove tracks through players",
+        "description": "Option label"
+    },
+    "optionAutoToggleLoveTitle": {
+        "message": "When you like or dislike a track on a website, the track will also be loved/unloved in your scrobbler",
+        "description": "Option label"
+    },
     "optionDebugLoggingEnabled": {
         "message": "Enable debug logging",
         "description": "Option label"
@@ -243,6 +251,54 @@
         "message": "Options",
         "description": "'Options' section"
     },
+    "optionsScrobbleCache": {
+        "message": "Cache",
+        "description": "'Scrobble Cache' section"
+    },
+    "optionsScrobbleCacheSelectAll": {
+        "message": "Select All",
+        "description": "Select All button label"
+    },
+    "optionsScrobbleCacheUnselectAll": {
+        "message": "Unselect All",
+        "description": "Unselect All button label"
+    },
+    "optionsScrobbleCacheSelectScrobbles": {
+        "message": "Select Scrobbles",
+        "description": "Select Scrobbles button label"
+    },
+    "optionsScrobbleCacheScrobbleSelected": {
+        "message": "Scrobble Selected",
+        "description": "Scrobble Selected button label"
+    },
+    "optionsScrobbleCacheEditAll": {
+        "message": "Edit and scrobble all unscrobbled instances of this song",
+        "description": "Label for checkbox to apply cache edit to all unscrobbled instances of the same track in cache."
+    },
+    "optionsScrobbleCacheLoading": {
+        "message": "Loading scrobble cache...",
+        "description": "'Scrobble Cache' section"
+    },
+    "optionsScrobbleCacheSuccess": {
+        "message": "Successful Scrobbles",
+        "description": "'Scrobble Cache' successful scrobbles section header"
+    },
+    "optionsScrobbleCacheError": {
+        "message": "Errored Scrobbles",
+        "description": "'Scrobble Cache' errored scrobbles section header"
+    },
+    "optionsScrobbleCacheIgnored": {
+        "message": "Ignored Scrobbles",
+        "description": "'Scrobble Cache' ignored scrobbles section header"
+    },
+    "optionsScrobbleCacheDisallowed": {
+        "message": "Disallowed Scrobbles",
+        "description": "'Scrobble Cache' disallowed scrobbles section header"
+    },
+    "optionsScrobbleCacheUnrecognized": {
+        "message": "Unrecognized Scrobbles",
+        "description": "'Scrobble Cache' unrecognized scrobbles section header"
+    },
     "optionsEdits": {
         "message": "Edits",
         "description": "'Edits' section"
@@ -271,6 +327,18 @@
         "message": "$1 regex/bulk edits",
         "description": "'Regex/Bulk edits' header"
     },
+    "optionsBlockedTags": {
+        "message": "Blocked tags",
+        "description": "'Blocked tags' header"
+    },
+    "optionsBlockedTagsDesc": {
+        "message": "This extension stores blocked artist/album/track tags.",
+        "description": "Description of blocked tags"
+    },
+    "optionsBlockedTagsPopupTitle": {
+        "message": "$1 blocked tags",
+        "description": "'Blocked tags' header"
+    },
     "optionsBlockedSources": {
         "message": "Blocked sources",
         "description": "'Edited tracks' header"
@@ -479,10 +547,6 @@
         "message": "Show some love",
         "description": "'Show some love' section"
     },
-    "showSomeLoveFundingCampaign": {
-        "message": "Over time a significant backlog of issues has been built up in the Web Scrobbler repository. From mid-August 2023 until mid-March 2024 one of our main developers will be available to work full-time on the project at a reduced rate. Roughly speaking, for every $$1500 we raise, we can pay for one month of full-time work, up to around $$9-10k. If you want to support this campaign and help us run through our backlog and make Web Scrobbler even better than it has ever been, you can use the button below. We remain committed to keeping Web Scrobbler and all of its features completely free forever. Please help spread the word.",
-        "description": "Temporary funding campaign"
-    },
     "showSomeLoveText1": {
         "message": "Unlike other extensions, this one does not contain any flashing banners or sneaky ads.",
         "description": ""
@@ -511,6 +575,10 @@
         "message": "From \"$1\" album",
         "description": "Tooltip text"
     },
+    "buttonScrobble": {
+        "message": "Scrobble",
+        "description": "Scrobble button label"
+    },
     "buttonOk": {
         "message": "OK",
         "description": "OK button label"
@@ -527,6 +595,10 @@
         "message": "Clear",
         "description": "Clear button label"
     },
+    "buttonRemove": {
+        "message": "Remove",
+        "description": "Remove button label, for removing a single scrobble from the cache"
+    },
     "noItemsInCache": {
         "message": "No items in cache.",
         "description": "Popup hint"
@@ -573,43 +645,51 @@
     },
     "disallowedDesc1": {
         "message": "Web Scrobbler has detected the song \"$2\" by \"$1\", but will not scrobble it.",
-        "description": "The description of 'disallowed' popup on all sites including youtube. $1 is the artist detected. $2 is the track detected"
+        "description": "The description of 'disallowed' popup. $1 is the artist detected. $2 is the track detected"
+    },
+    "disallowedDescIsAd": {
+        "message": "The reason is that Web Scrobbler has detected this as being an ad.",
+        "description": "The description of 'disallowed' popup."
     },
-    "disallowedDescYoutube": {
-        "message": "On YouTube, the most common causes include:",
-        "description": "The description of 'disallowed' popup on youtube."
+    "disallowedDescForbiddenYouTubeCategory": {
+      "message": "You have disabled scrobbling youtube video categories other than music and/or entertainment, and this video is marked by the uploader as a different category. You can choose to scrobble any video category by disabling both \"Scrobble videos from \"Music\" category\" and \"Scrobble videos from \"Entertainment\" category\" in Web Scrobbler settings.",
+      "description": "The description of 'disallowed' popup."
     },
-    "disallowedDescYoutubePoint1": {
-        "message": "Having \"Scrobble videos from \"Music/Entertainment\" category\" enabled and playing a video with a different category.",
-        "description": "Point 1 in example list on youtube"
+    "disallowedDescForbiddenTag": {
+      "message": "You have disabled scrobbling this artist, album, and/or track. You can re-enable tags in settings, or by hitting \"Scrobble anyway\" and re-enabling the tags in regex editing.",
+      "description": "The description of 'disallowed' popup."
     },
-    "disallowedDescYoutubePoint2": {
-        "message": "Having \"Scrobble only videos that are recognized by YouTube Music as music\" enabled and playing a song not recognized by YouTube Music as music.",
-        "description": "Point 2 in example list on youtube"
+    "disallowedDescForbiddenChannel": {
+      "message": "You have disabled scrobbling this channel. You can re-enable it in connector settings, or by right clicking the extension icon and clicking \"Enable scrobbling from <ID>\".",
+      "description": "The description of 'disallowed' popup."
     },
-    "disallowedDescYoutubePoint3": {
-        "message": "Ads (even ads being blocked may temporarily show this.)",
-        "description": "Point 3 in example list on youtube"
+    "disallowedDescNotOnYouTubeMusic": {
+      "message": "YouTube Music does not recognize this video as music, and you have enabled the option \"Scrobble only videos that are recognized by YouTube Music as music\" in settings.",
+      "description": "The description of 'disallowed' popup."
     },
-    "disallowedDesc2": {
-        "message": "This is because the website connector has deemed this not a song to scrobble. Some potential reasons include:",
-        "description": "The description of 'disallowed' popup on sites except youtube"
+    "disallowedDescIsPlayingElsewhere": {
+      "message": "The reason is that Web Scrobbler thinks this track is playing elsewhere and should not be scrobbled.",
+      "description": "The description of 'disallowed' popup."
     },
-    "disallowedDesc2Point1": {
-        "message": "Web Scrobbler might be interpreting this as an ad.",
-        "description": "Point 1 in example list on sites except youtube"
+    "disallowedDescElementMissing": {
+      "message": "The reason is that some element Web Scrobbler relies on is not in the page. This probably means that either you are not playing music, or there is a bug in the connector.",
+      "description": "The description of 'disallowed' popup."
     },
-    "disallowedDesc2Point2": {
-        "message": "Web Scrobbler might be identifying this song as being played on a different device.",
-        "description": "Point 2 in example list on sites except youtube"
+    "disallowedDescFilteredTag": {
+      "message": "Either the artist, album, or track has been filtered by the connector as something that should not be scrobbled. This is not due to any setting you have set in your settings.",
+      "description": "The description of 'disallowed' popup."
     },
-    "disallowedDesc2Point3": {
-        "message": "Web Scrobbler, for some other reason, believes this is not something that should be scrobbled.",
-        "description": "Point 3 in example list on sites except youtube"
+    "disallowedDescIsLoading": {
+      "message": "Something Web Scrobbler relies on for finding the track details is loading. Please wait a few seconds. If this message persists, please report it, as something is wrong.",
+      "description": "The description of 'disallowed' popup."
+    },
+    "disallowedDescOther": {
+      "message": "For some unknown reason, Web Scrobbler has determined this should not scrobble. This is not related to any setting you have set. If you believe this is an error, please report it.",
+      "description": "The description of 'disallowed' popup."
     },
     "disallowedDesc3": {
-        "message": "Web Scrobbler should still scrobble music from elsewhere on this website.",
-        "description": "The description of 'disallowed' popup on all sites including youtube"
+      "message": "Web Scrobbler should still scrobble music from elsewhere on this website.",
+      "description": "The description of 'disallowed' popup on all sites including youtube"
     },
     "disallowedButton": {
         "message": "Scrobble anyway",
@@ -751,6 +831,30 @@
       "message": "Skipped",
       "description": "Title of unskip button, should be short for mobile button label"
     },
+    "infoBlockArtist": {
+      "message": "Never scrobble this artist",
+      "description": "Title of button to block scrobbling artist"
+    },
+    "infoUnblockArtist": {
+      "message": "Scrobble this artist",
+      "description": "Title of button to unblock scrobbling artist"
+    },
+    "infoBlockAlbum": {
+      "message": "Never scrobble this album",
+      "description": "Title of button to block scrobbling album"
+    },
+    "infoUnblockAlbum": {
+      "message": "Scrobble this album",
+      "description": "Title of button to unblock scrobbling album"
+    },
+    "infoBlockTrack": {
+      "message": "Never scrobble this track",
+      "description": "Title of button to block scrobbling track"
+    },
+    "infoUnblockTrack": {
+      "message": "Scrobble this track",
+      "description": "Title of button to unblock scrobbling track"
+    },
     "infoOpenAlbumArt": {
         "message": "Open album art in new tab",
         "description": "Title of album image"
@@ -779,6 +883,10 @@
         "message": "Replace",
         "description": "Label of regex edit replace input"
     },
+    "infoDeleteLabel": {
+        "message": "Delete",
+        "description": "Label of regex edit delete button"
+    },
     "infoPreviewLabel": {
         "message": "Preview",
         "description": "Label of regex edit preview output"
@@ -799,6 +907,18 @@
         "message": "Album artist",
         "description": "Label of album artist input row"
     },
+    "infoUseRegex": {
+        "message": "Use regex",
+        "description": "Title of checkbox to toggle bulk edit flag"
+    },
+    "infoMatchCase": {
+        "message": "Match case",
+        "description": "Title of checkbox to toggle bulk edit flag"
+    },
+    "infoMatchWholeTag": {
+        "message": "Match entire tag",
+        "description": "Title of checkbox to toggle bulk edit flag"
+    },
     "infoViewArtistPage": {
         "message": "View artist page: $1",
         "description": "Placeholder of album input"
@@ -827,6 +947,10 @@
         "message": "Now playing $1",
         "description": "`playing` page action title, also used for infobox"
     },
+    "pageActionPaused": {
+        "message": "Paused $1",
+        "description": "`paused` page action title, also used for infobox"
+    },
     "pageActionScrobbled": {
         "message": "Scrobbled $1",
         "description": "`scrobbled` page action title, also used for infobox"
diff --git a/src/connectors/1001tracklists.ts b/src/connectors/1001tracklists.ts
index 65c3d577..b0e9e719 100644
--- a/src/connectors/1001tracklists.ts
+++ b/src/connectors/1001tracklists.ts
@@ -45,7 +45,7 @@ Connector.getDuration = () => {
 Connector.isPlaying = () =>
 	Util.hasElementClass('#playerWidgetPause', 'fa-pause');
 
-Connector.isScrobblingAllowed = () => {
+Connector.scrobblingDisallowedReason = () => {
 	nextCue = parseInt(
 		Util.getAttrFromSelectors('.cPlay ~ .tlpTog input', 'value') ?? '',
 	);
@@ -57,5 +57,5 @@ Connector.isScrobblingAllowed = () => {
 		'mashupTrack',
 	);
 
-	return noIDs <= 0 && (cue > 0 || nextCue > 0) && !mashup;
+	return noIDs <= 0 && (cue > 0 || nextCue > 0) && !mashup ? null : 'Other';
 };
diff --git a/src/connectors/allclassical.org.ts b/src/connectors/allclassical.org.ts
index 060f03d5..52590229 100644
--- a/src/connectors/allclassical.org.ts
+++ b/src/connectors/allclassical.org.ts
@@ -8,5 +8,7 @@ Connector.artistSelector = '#composer';
 
 Connector.playButtonSelector = '#live-play';
 
-Connector.isScrobblingAllowed = () =>
-	Util.getTextFromSelectors('.nowplaying-label') === 'Now Playing';
+Connector.scrobblingDisallowedReason = () =>
+	Util.getTextFromSelectors('.nowplaying-label') === 'Now Playing'
+		? null
+		: 'Other';
diff --git a/src/connectors/amazon.ts b/src/connectors/amazon.ts
index c6315212..40697e43 100644
--- a/src/connectors/amazon.ts
+++ b/src/connectors/amazon.ts
@@ -39,7 +39,7 @@ function setupPropertiesForNewPlayer() {
 		return { artist, track, trackArt };
 	};
 
-	Connector.isScrobblingAllowed = () => {
+	Connector.scrobblingDisallowedReason = () => {
 		const trackLink = Util.getAttrFromSelectors(
 			trackContainerSelector,
 			'primary-href',
@@ -47,7 +47,7 @@ function setupPropertiesForNewPlayer() {
 
 		// NOTE Regular tracks have no link
 		// Check this condition first if the connector does not work
-		return trackLink === '#';
+		return trackLink === '#' ? null : 'IsAd';
 	};
 
 	Connector.pauseButtonSelector = `${playerBarSelector} music-button[icon-name=pause]`;
diff --git a/src/connectors/audacy.ts b/src/connectors/audacy.ts
index d93321e9..bbbdf015 100644
--- a/src/connectors/audacy.ts
+++ b/src/connectors/audacy.ts
@@ -62,14 +62,18 @@ Connector.isPlaying = () => {
 	return buttonPlaying === 'Pause' || buttonPlaying === 'Stop';
 };
 
-Connector.isScrobblingAllowed = () => {
+Connector.scrobblingDisallowedReason = () => {
 	const artist = Connector.getArtist();
 	const track = Connector.getTrack();
-	return Boolean(
-		artist &&
-			!artist.includes('Audacy') &&
-			track &&
-			!track.startsWith('Advertisement') &&
-			artist !== track,
-	);
+
+	if (!artist || !track) {
+		return 'Other';
+	}
+	if (artist.includes('Audacy') || artist === track) {
+		return 'FilteredTag';
+	}
+	if (track.startsWith('Advertisement')) {
+		return 'IsAd';
+	}
+	return null;
 };
diff --git a/src/connectors/bilibili.ts b/src/connectors/bilibili.ts
index afbde312..dc1497d1 100644
--- a/src/connectors/bilibili.ts
+++ b/src/connectors/bilibili.ts
@@ -197,20 +197,20 @@ Connector.getChannelId = () =>
 
 Connector.channelLabelSelector = '.up-name';
 
-Connector.isScrobblingAllowed = () => {
+Connector.scrobblingDisallowedReason = () => {
 	const tags = videoInfo.tags;
 
 	if (onlyScrobbleContainsMusicTag) {
 		if (!tags.includes('音乐')) {
-			return false;
+			return 'FilteredTag';
 		}
 	}
 	if (useScrobbleTagFilter) {
 		if (tags.some((tag) => isIncludeElems(tag, tagFilterKeyWords))) {
-			return false;
+			return 'FilteredTag';
 		}
 	}
-	return true;
+	return null;
 };
 
 Connector.getArtistTrack = () => {
diff --git a/src/connectors/cpr-indie.ts b/src/connectors/cpr-indie.ts
index 6da113ce..a2eebc74 100644
--- a/src/connectors/cpr-indie.ts
+++ b/src/connectors/cpr-indie.ts
@@ -8,10 +8,10 @@ Connector.trackSelector = '.qtmplayer__songdata .qtmplayer__title';
 
 Connector.isPlaying = () => Util.hasElementClass('#qtmplayerNotif', 'playing');
 
-Connector.isScrobblingAllowed = () => {
-	return (
-		!Util.getTextFromSelectors('.qtmplayer__songdata')?.includes(
-			'Indie 102.3',
-		) && !Connector.getArtist()?.startsWith('with ')
-	);
+Connector.scrobblingDisallowedReason = () => {
+	return Util.getTextFromSelectors('.qtmplayer__songdata')?.includes(
+		'Indie 102.3',
+	) || Connector.getArtist()?.startsWith('with ')
+		? 'FilteredTag'
+		: null;
 };
diff --git a/src/connectors/cpr.ts b/src/connectors/cpr.ts
index b0e6a51f..1df5d770 100644
--- a/src/connectors/cpr.ts
+++ b/src/connectors/cpr.ts
@@ -26,7 +26,8 @@ Connector.getAlbum = () => {
 
 Connector.pauseButtonSelector = `${Connector.playerSelector} [class^=playPauseButton__icon-][class*=playPauseButton__pause-]`;
 
-Connector.isScrobblingAllowed = () => Boolean(Connector.getArtist());
+Connector.scrobblingDisallowedReason = () =>
+	Connector.getArtist() ? null : 'Other';
 
 Connector.isStateChangeAllowed = () => Boolean(Connector.getTrack());
 
diff --git a/src/connectors/dandelionradio.ts b/src/connectors/dandelionradio.ts
index 7f93e47b..e2362eb4 100644
--- a/src/connectors/dandelionradio.ts
+++ b/src/connectors/dandelionradio.ts
@@ -22,4 +22,5 @@ Connector.trackArtSelector = '#bio .img-left-text img';
 Connector.isPlaying = () =>
 	Util.hasElementClass('#jp_container_1', 'jp-state-playing');
 
-Connector.isScrobblingAllowed = () => Boolean(Connector.getTrackInfo());
+Connector.scrobblingDisallowedReason = () =>
+	Connector.getTrackInfo() ? null : 'Other';
diff --git a/src/connectors/decayfm.ts b/src/connectors/decayfm.ts
index 3b3c46e5..a95ae075 100644
--- a/src/connectors/decayfm.ts
+++ b/src/connectors/decayfm.ts
@@ -11,7 +11,13 @@ Connector.trackArtSelector = '.qtmplayer__covercontainer img';
 Connector.isPlaying = () =>
 	Util.getTextFromSelectors('#qtmplayerPlay .material-icons') === 'pause';
 
-Connector.isScrobblingAllowed = () => {
+Connector.scrobblingDisallowedReason = () => {
 	const artist = Connector.getArtist();
-	return Boolean(artist && !artist.match(/^DKFM|^ID\/PSA|^Advert:/));
+	if (!artist) {
+		return 'Other';
+	}
+	if (artist.match(/^DKFM|^ID\/PSA|^Advert:/)) {
+		return 'IsAd';
+	}
+	return null;
 };
diff --git a/src/connectors/gimmeradio.ts b/src/connectors/gimmeradio.ts
index de9e533e..dab19ffd 100644
--- a/src/connectors/gimmeradio.ts
+++ b/src/connectors/gimmeradio.ts
@@ -8,10 +8,10 @@ Connector.trackSelector = '.fZIbtd';
 
 Connector.onReady = Connector.onStateChanged;
 
-Connector.isScrobblingAllowed = () => {
-	return !(
-		Connector.getArtist()?.includes('Gimme Country') ||
+Connector.scrobblingDisallowedReason = () => {
+	return Connector.getArtist()?.includes('Gimme Country') ||
 		Connector.getArtist()?.includes('Gimme Radio') ||
 		Connector.getArtist()?.includes('Gimme Metal')
-	);
+		? 'FilteredTag'
+		: null;
 };
diff --git a/src/connectors/globalplayer.ts b/src/connectors/globalplayer.ts
index 016013c7..fa602b7a 100644
--- a/src/connectors/globalplayer.ts
+++ b/src/connectors/globalplayer.ts
@@ -12,9 +12,8 @@ Connector.playButtonSelector =
 	'button[data-testid="play-pause-button"][aria-pressed="false"]';
 Connector.trackArtSelector = 'div[class^="style_showInfoCard"] img.current-img';
 
-Connector.isScrobblingAllowed = () => {
-	return Connector.getTrack() !== getShowName();
-};
+Connector.scrobblingDisallowedReason = () =>
+	Connector.getTrack() === getShowName() ? 'FilteredTag' : null;
 
 function getShowName() {
 	return Util.getTextFromSelectors(showNameSelector);
diff --git a/src/connectors/idagio.ts b/src/connectors/idagio.ts
index 6f6a539b..71ea1916 100644
--- a/src/connectors/idagio.ts
+++ b/src/connectors/idagio.ts
@@ -25,9 +25,11 @@ Connector.durationSelector = '.player-PlayerProgress__timeTotal--3aHlj span';
 Connector.isPlaying = () =>
 	Util.getTextFromSelectors(pauseButtonSelector)?.toUpperCase() === 'PAUSE';
 
-Connector.isScrobblingAllowed = () =>
-	Util.getTextFromSelectors('.player-PlayerInfo__recordingInfo--15VMv') !==
-	'Sponsor message';
+Connector.scrobblingDisallowedReason = () =>
+	Util.getTextFromSelectors('.player-PlayerInfo__recordingInfo--15VMv') ===
+	'Sponsor message'
+		? 'IsAd'
+		: null;
 
 function getCurrentTrack() {
 	/*
diff --git a/src/connectors/iheart.ts b/src/connectors/iheart.ts
index 18d01f54..d6dc0557 100644
--- a/src/connectors/iheart.ts
+++ b/src/connectors/iheart.ts
@@ -11,7 +11,9 @@ Connector.artistSelector = `${playerBar} [data-test=line-text]:nth-child(3)`;
 
 Connector.trackSelector = `${playerBar} [data-test=line-text]:nth-child(2)`;
 
-Connector.isScrobblingAllowed = () => {
+Connector.scrobblingDisallowedReason = () => {
 	const track = Connector.getTrack();
-	return Boolean(track && !track.startsWith('Thanks for listening'));
+	return track && !track.startsWith('Thanks for listening')
+		? null
+		: 'FilteredTag';
 };
diff --git a/src/connectors/jamstash.ts b/src/connectors/jamstash.ts
index 9ea2bb70..30840854 100644
--- a/src/connectors/jamstash.ts
+++ b/src/connectors/jamstash.ts
@@ -20,6 +20,5 @@ Connector.currentTimeSelector = '#played';
 
 Connector.durationSelector = '#duration';
 
-Connector.isScrobblingAllowed = () => {
-	return Connector.getDuration() !== 0;
-};
+Connector.scrobblingDisallowedReason = () =>
+	Connector.getDuration() === 0 ? 'Other' : null;
diff --git a/src/connectors/jazzgroove.ts b/src/connectors/jazzgroove.ts
index 8306bf8e..22591fa0 100644
--- a/src/connectors/jazzgroove.ts
+++ b/src/connectors/jazzgroove.ts
@@ -22,7 +22,8 @@ Connector.isTrackArtDefault = () => {
 };
 
 // station bumpers and other spoken messages play with default images
-Connector.isScrobblingAllowed = () => !Connector.isTrackArtDefault();
+Connector.scrobblingDisallowedReason = () =>
+	Connector.isTrackArtDefault() ? 'Other' : null;
 
 Connector.isPlaying = () =>
 	!Util.hasElementClass(Connector.playerSelector, 'jg-player--paused');
diff --git a/src/connectors/kinknl.ts b/src/connectors/kinknl.ts
index 3328e2e8..98954358 100644
--- a/src/connectors/kinknl.ts
+++ b/src/connectors/kinknl.ts
@@ -16,5 +16,7 @@ Connector.isPlaying = () => {
 	);
 };
 
-Connector.isScrobblingAllowed = () =>
-	!Connector.getArtistTrack()?.artist?.startsWith('KINK');
+Connector.scrobblingDisallowedReason = () =>
+	Connector.getArtistTrack()?.artist?.startsWith('KINK')
+		? 'FilteredTag'
+		: null;
diff --git a/src/connectors/kyivstar.ts b/src/connectors/kyivstar.ts
index 79e15491..52c76284 100644
--- a/src/connectors/kyivstar.ts
+++ b/src/connectors/kyivstar.ts
@@ -27,4 +27,5 @@ Connector.isTrackArtDefault = (trackArtUrl) => {
 	return false;
 };
 
-Connector.isScrobblingAllowed = () => Connector.getArtist() !== 'Kyivstar';
+Connector.scrobblingDisallowedReason = () =>
+	Connector.getArtist() === 'Kyivstar' ? 'FilteredTag' : null;
diff --git a/src/connectors/live365.ts b/src/connectors/live365.ts
index 5bfe3b2f..49cd818c 100644
--- a/src/connectors/live365.ts
+++ b/src/connectors/live365.ts
@@ -37,15 +37,19 @@ const LIVE_365_ARTIST = 'Live365';
 const ADWTAG = 'Advert:';
 const ADBREAK = 'Ad Break';
 
-Connector.isScrobblingAllowed = () => {
+Connector.scrobblingDisallowedReason = () => {
 	const artist = Connector.getArtistTrack()?.artist;
-	return (
-		artist !== null &&
-		artist !== LIVE_365_ARTIST &&
-		artist !== 'undefined' &&
-		!artist?.includes(ADWTAG) &&
-		!artist?.includes(ADBREAK)
-	);
+	if (
+		artist === null ||
+		artist === LIVE_365_ARTIST ||
+		artist === 'undefined'
+	) {
+		return 'FilteredTag';
+	}
+	if (artist?.includes(ADWTAG) || artist?.includes(ADBREAK)) {
+		return 'IsAd';
+	}
+	return null;
 };
 
 /*
diff --git a/src/connectors/liveone.ts b/src/connectors/liveone.ts
index e8f6f103..56c74a99 100644
--- a/src/connectors/liveone.ts
+++ b/src/connectors/liveone.ts
@@ -16,6 +16,5 @@ Connector.durationSelector = '.vjs-duration-display';
 
 Connector.trackArtSelector = '.current-track-imgContainer img';
 
-Connector.isScrobblingAllowed = () => {
-	return Connector.getArtist() !== 'LiveOne';
-};
+Connector.scrobblingDisallowedReason = () =>
+	Connector.getArtist() === 'LiveOne' ? 'FilteredTag' : null;
diff --git a/src/connectors/mixcloud.ts b/src/connectors/mixcloud.ts
index 8c1e231b..18a8f44b 100644
--- a/src/connectors/mixcloud.ts
+++ b/src/connectors/mixcloud.ts
@@ -70,7 +70,8 @@ Connector.isStateChangeAllowed = () => {
 	);
 };
 
-Connector.isScrobblingAllowed = () => Connector.isStateChangeAllowed();
+Connector.scrobblingDisallowedReason = () =>
+	Connector.isStateChangeAllowed() ? null : 'ElementMissing';
 
 Connector.applyFilter(filter);
 
diff --git a/src/connectors/nts-live.ts b/src/connectors/nts-live.ts
new file mode 100644
index 00000000..3cf9e967
--- /dev/null
+++ b/src/connectors/nts-live.ts
@@ -0,0 +1,16 @@
+export {};
+
+Connector.playerSelector = '.page-live-tracks__body';
+
+Connector.artistSelector =
+	'.page-live-tracks__list li:first-child .page-live-tracks__artist-title';
+
+Connector.trackSelector =
+	'.page-live-tracks__list li:first-child .page-live-tracks__song-title';
+
+Connector.scrobbleInfoLocationSelector = '.page-live-tracks__title';
+Connector.scrobbleInfoStyle = {
+	...Connector.scrobbleInfoStyle,
+	fontSize: '0.7em',
+	marginTop: '0.7em',
+};
diff --git a/src/connectors/nts-shows.ts b/src/connectors/nts-shows.ts
new file mode 100644
index 00000000..9cc29ce6
--- /dev/null
+++ b/src/connectors/nts-shows.ts
@@ -0,0 +1,28 @@
+export {};
+
+Connector.playerSelector = '.episode-player-wrapper';
+
+Connector.isPlaying = () => {
+	const title = document.querySelector('.soundcloud-player__play-icon title');
+	if (title && title.textContent === 'Play') {
+		return false;
+	}
+	return true;
+};
+
+Connector.getTrack = () =>
+	document.querySelector(
+		'.episode-player-tracklist__track--is-playing .episode-player-tracklist__title',
+	)?.textContent;
+
+Connector.getArtist = () =>
+	document.querySelector(
+		'.episode-player-tracklist__track--is-playing .episode-player-tracklist__artist',
+	)?.textContent;
+
+Connector.scrobbleInfoLocationSelector = '.soundcloud-player__content';
+Connector.scrobbleInfoStyle = {
+	...Connector.scrobbleInfoStyle,
+	fontSize: '0.5em',
+	marginLeft: '2em',
+};
diff --git a/src/connectors/pandora.ts b/src/connectors/pandora.ts
index 309dbc2c..43b1b24a 100644
--- a/src/connectors/pandora.ts
+++ b/src/connectors/pandora.ts
@@ -24,6 +24,8 @@ Connector.getTrackArt = () => {
 
 Connector.pauseButtonSelector = '[data-qa="pause_button"]';
 
-Connector.isScrobblingAllowed = () => {
-	return !document.querySelector('.Tuner__Audio__TrackDetail__title--ad');
+Connector.scrobblingDisallowedReason = () => {
+	return document.querySelector('.Tuner__Audio__TrackDetail__title--ad')
+		? 'IsAd'
+		: null;
 };
diff --git a/src/connectors/poolsuite.ts b/src/connectors/poolsuite.ts
index 5444e797..832df77e 100644
--- a/src/connectors/poolsuite.ts
+++ b/src/connectors/poolsuite.ts
@@ -7,4 +7,5 @@ Connector.artistSelector = '.current-track h2';
 Connector.currentTimeSelector = '.timer span:nth-child(1)';
 Connector.durationSelector = '.timer span:nth-child(2)';
 
-Connector.isScrobblingAllowed = () => Connector.getTrack() !== 'Loading...';
+Connector.scrobblingDisallowedReason = () =>
+	Connector.getTrack() === 'Loading...' ? 'IsLoading' : null;
diff --git a/src/connectors/radio357.ts b/src/connectors/radio357.ts
index 46236efa..dee1fbc8 100644
--- a/src/connectors/radio357.ts
+++ b/src/connectors/radio357.ts
@@ -6,5 +6,7 @@ Connector.artistTrackSelector = '#web_player .r357p_song';
 
 Connector.isPlaying = () => Util.hasElementClass('#web_player', 'playing');
 
-Connector.isScrobblingAllowed = () =>
-	!Connector.getArtistTrack()?.artist?.includes('Radio 357');
+Connector.scrobblingDisallowedReason = () =>
+	Connector.getArtistTrack()?.artist?.includes('Radio 357')
+		? 'FilteredTag'
+		: null;
diff --git a/src/connectors/radio7.ts b/src/connectors/radio7.ts
index 4bb3e422..5932f553 100644
--- a/src/connectors/radio7.ts
+++ b/src/connectors/radio7.ts
@@ -6,6 +6,5 @@ Connector.artistTrackSelector = '#dziesma';
 
 Connector.pauseButtonSelector = '.amazingaudioplayer-pause';
 
-Connector.isScrobblingAllowed = () => {
-	return Connector.getArtist() !== 'Radio7';
-};
+Connector.scrobblingDisallowedReason = () =>
+	Connector.getArtist() === 'Radio7' ? 'FilteredTag' : null;
diff --git a/src/connectors/radioparadise.ts b/src/connectors/radioparadise.ts
index e8df64b4..0a3a1c6d 100644
--- a/src/connectors/radioparadise.ts
+++ b/src/connectors/radioparadise.ts
@@ -15,10 +15,10 @@ Connector.isPlaying = () => {
 
 Connector.trackArtSelector = '.player-bar img.player-cover';
 
-Connector.isScrobblingAllowed = () => {
-	return (
-		!Util.getTextFromSelectors('.player-bar .player-title')?.includes(
-			'Listener-supported',
-		) && !Connector.getArtist()?.startsWith('Commercial-Free')
-	);
+Connector.scrobblingDisallowedReason = () => {
+	return Util.getTextFromSelectors('.player-bar .player-title')?.includes(
+		'Listener-supported',
+	) || Connector.getArtist()?.startsWith('Commercial-Free')
+		? 'FilteredTag'
+		: null;
 };
diff --git a/src/connectors/redbull.ts b/src/connectors/redbull.ts
index 0832f868..eb0f002f 100644
--- a/src/connectors/redbull.ts
+++ b/src/connectors/redbull.ts
@@ -49,7 +49,7 @@ Connector.getArtistTrack = () => {
 
 Connector.playButtonSelector = 'rbPlyr-playPause:not(.rbPlyr-pause)';
 
-Connector.isScrobblingAllowed = () => {
+Connector.scrobblingDisallowedReason = () => {
 	const pageType = Util.getAttrFromSelectors(
 		'meta[property="og:type"]',
 		'content',
@@ -57,12 +57,12 @@ Connector.isScrobblingAllowed = () => {
 
 	const categoryLink = Util.getAttrFromSelectors('a[href*="/tags/"]', 'href');
 
-	return Boolean(
-		pageType &&
-			ALLOWED_TYPES.includes(pageType) &&
-			categoryLink &&
-			'music' === categoryLink.split('/').pop(),
-	);
+	return pageType &&
+		ALLOWED_TYPES.includes(pageType) &&
+		categoryLink &&
+		'music' === categoryLink.split('/').pop()
+		? null
+		: 'Other';
 };
 
 function getArtistTrackFrom(getter: string) {
diff --git a/src/connectors/refnet.ts b/src/connectors/refnet.ts
index bb203f15..ec3128b8 100644
--- a/src/connectors/refnet.ts
+++ b/src/connectors/refnet.ts
@@ -20,6 +20,5 @@ Connector.isPlaying = () => {
 	return Util.hasElementClass(Connector.playerSelector, 'playing');
 };
 
-Connector.isScrobblingAllowed = () => {
-	return Util.hasElementClass('#slots > .live', 'music');
-};
+Connector.scrobblingDisallowedReason = () =>
+	Util.hasElementClass('#slots > .live', 'music') ? null : 'Other';
diff --git a/src/connectors/rockandpopcl.ts b/src/connectors/rockandpopcl.ts
index be49b57c..5a88bb48 100644
--- a/src/connectors/rockandpopcl.ts
+++ b/src/connectors/rockandpopcl.ts
@@ -12,14 +12,14 @@ Connector.trackSelector = '.player-info-title .js-player-title';
 
 Connector.trackArtSelector = '.player-data .image-player';
 
-Connector.isScrobblingAllowed = () => {
-	return (
-		!Connector.getArtist()?.includes('24/7') &&
-		!Connector.getTrack()?.includes('24/7') &&
-		!Util.extractImageUrlFromSelectors(
-			Connector.trackArtSelector,
-		)?.includes('/fotos/original/')
-	);
+Connector.scrobblingDisallowedReason = () => {
+	return Connector.getArtist()?.includes('24/7') ||
+		Connector.getTrack()?.includes('24/7') ||
+		Util.extractImageUrlFromSelectors(Connector.trackArtSelector)?.includes(
+			'/fotos/original/',
+		)
+		? 'FilteredTag'
+		: null;
 };
 
 Connector.applyFilter(filter);
diff --git a/src/connectors/rockantenne.ts b/src/connectors/rockantenne.ts
index 4491de7f..ad621e72 100644
--- a/src/connectors/rockantenne.ts
+++ b/src/connectors/rockantenne.ts
@@ -10,6 +10,8 @@ Connector.isPlaying = () => {
 	return Util.hasElementClass('.c-player', 'is-playing');
 };
 
-Connector.isScrobblingAllowed = () => {
-	return !Connector.getArtist()?.includes('ROCK ANTENNE');
+Connector.scrobblingDisallowedReason = () => {
+	return Connector.getArtist()?.includes('ROCK ANTENNE')
+		? 'FilteredTag'
+		: null;
 };
diff --git a/src/connectors/siriusxm-player.ts b/src/connectors/siriusxm-player.ts
index 7948ef44..764c0d08 100644
--- a/src/connectors/siriusxm-player.ts
+++ b/src/connectors/siriusxm-player.ts
@@ -29,7 +29,7 @@ Connector.playButtonSelector =
 Connector.pauseButtonSelector =
 	"*[class^='_playbackControls_'] button[aria-label='Pause']";
 
-Connector.isScrobblingAllowed = () => {
+Connector.scrobblingDisallowedReason = () => {
 	const artist = Connector.getArtist()?.toLowerCase();
 	const track = Connector.getTrack()?.toLowerCase();
 	const filteredTerms = [
@@ -50,9 +50,11 @@ Connector.isScrobblingAllowed = () => {
 		'bdcast',
 	];
 
-	return !filteredTerms.some(
+	return filteredTerms.some(
 		(term) => artist?.includes(term) || track?.includes(term),
-	);
+	)
+		? 'FilteredTag'
+		: null;
 };
 
 Connector.applyFilter(filter);
diff --git a/src/connectors/spotify.ts b/src/connectors/spotify.ts
index bd0de627..91f9ff9a 100644
--- a/src/connectors/spotify.ts
+++ b/src/connectors/spotify.ts
@@ -37,7 +37,8 @@ Connector.pauseButtonSelector = `${oldPauseButtonSelector}, ${newPauseButtonSele
 
 Connector.applyFilter(MetadataFilter.createSpotifyFilter());
 
-Connector.isScrobblingAllowed = () => Boolean(isMusicPlaying() && isMainTab());
+Connector.scrobblingDisallowedReason = () =>
+	isMusicPlaying() && isMainTab() ? null : 'IsPlayingElsewhere';
 
 Connector.isPodcast = () => isPodcastPlaying();
 
@@ -45,6 +46,8 @@ Connector.getUniqueID = () => getTrackUri();
 
 Connector.getOriginUrl = () => getTrackUrl();
 
+Connector.loveButtonSelector = `${playerBar} button[data-testid="add-button"][aria-checked="false"]`;
+
 function isMusicPlaying() {
 	return artistUrlIncludes('/artist/', '/show/') || isLocalFilePlaying();
 }
diff --git a/src/connectors/subfire.ts b/src/connectors/subfire.ts
index 97634118..28107034 100644
--- a/src/connectors/subfire.ts
+++ b/src/connectors/subfire.ts
@@ -29,5 +29,5 @@ Connector.getUniqueID = () => {
 	return (src && src.match(/id=([^&]*)/i)?.[1]) || null;
 };
 
-Connector.isScrobblingAllowed = () =>
-	Util.isElementVisible(Connector.playerSelector);
+Connector.scrobblingDisallowedReason = () =>
+	Util.isElementVisible(Connector.playerSelector) ? null : 'ElementMissing';
diff --git a/src/connectors/telegram-a.ts b/src/connectors/telegram-a.ts
new file mode 100644
index 00000000..fe53a920
--- /dev/null
+++ b/src/connectors/telegram-a.ts
@@ -0,0 +1,33 @@
+export {};
+
+Connector.playerSelector = '.header-tools';
+
+Connector.playButtonSelector = '.player-button.play';
+
+Connector.artistSelector = '.AudioPlayer-content .subtitle';
+
+Connector.getArtistTrack = () => {
+	const artist = Connector.getArtist();
+	const track = Util.getTextFromSelectors('.AudioPlayer-content .title');
+	if (!artist) {
+		return Util.splitArtistTrack(track);
+	}
+	return { artist, track };
+};
+
+Connector.isStateChangeAllowed = () =>
+	Connector.getArtist() !== 'Voice message';
+
+const filter = MetadataFilter.createFilter({
+	track: trimTrackSuffix,
+});
+
+Connector.applyFilter(filter);
+
+function trimTrackSuffix(track: string): string {
+	const index = track.lastIndexOf('.');
+	if (index === -1) {
+		return track;
+	}
+	return track.substring(0, index);
+}
diff --git a/src/connectors/telegram-k-dom-inject.ts b/src/connectors/telegram-k-dom-inject.ts
new file mode 100644
index 00000000..8c277fe7
--- /dev/null
+++ b/src/connectors/telegram-k-dom-inject.ts
@@ -0,0 +1,94 @@
+type AttributeAudio = {
+	title?: string;
+	performer?: string;
+};
+
+type AttributeFilename = {
+	file_name: string;
+};
+
+type Attribute = AttributeAudio | AttributeFilename;
+
+interface Document {
+	id: string;
+	duration: number;
+	type: 'audio' | 'round' | 'voice';
+	attributes: Attribute[];
+}
+
+interface Details {
+	doc: Document;
+	media: HTMLAudioElement | HTMLVideoElement;
+}
+
+interface MediaController {
+	getPlayingDetails: () => Details;
+	addEventListener: (_: string, __: () => void) => void;
+	removeEventListener: (_: string, __: () => void) => void;
+}
+
+interface Window {
+	appMediaPlaybackController: MediaController;
+}
+
+if ('cleanup' in window && typeof window.cleanup === 'function') {
+	window.cleanup();
+}
+
+(window as unknown as { cleanup: () => void }).cleanup = (() => {
+	const W = window as unknown as Window;
+
+	function extractAttributes(attributes: Attribute[]) {
+		const data: { [key: string]: string | undefined } = {};
+		for (const attribute of attributes) {
+			for (const key in attribute) {
+				data[key] = attribute[key as keyof Attribute];
+			}
+		}
+		return data;
+	}
+
+	function listener() {
+		const details = W.appMediaPlaybackController.getPlayingDetails();
+		const attribute = extractAttributes(details.doc.attributes);
+		const { file_name: fileName } = attribute;
+		window.postMessage(
+			{
+				sender: 'web-scrobbler',
+				type: 'TELEGRAM_K_STATE',
+				state: {
+					_track: attribute.title || fileName,
+					_artist: attribute.performer || fileName,
+					getUniqueID: details.doc.id,
+					getDuration: details.doc.duration,
+					isPlaying: details.media.paused === false,
+					isStateChangeAllowed: details.doc.type === 'audio',
+				},
+			},
+			'*',
+		);
+	}
+
+	function toggleEventListeners(name: string) {
+		const api = W.appMediaPlaybackController;
+		const method = `${name}EventListener` as keyof MediaController;
+		const events = ['play', 'pause'];
+		for (const event of events) {
+			api[method](event, listener);
+		}
+	}
+
+	const observer = new MutationObserver(() => {
+		if ('appMediaPlaybackController' in W) {
+			observer.disconnect();
+			toggleEventListeners('add');
+		}
+	});
+
+	observer.observe(document.body, { childList: true });
+
+	return () => {
+		observer.disconnect();
+		toggleEventListeners('remove');
+	};
+})();
diff --git a/src/connectors/telegram-k.ts b/src/connectors/telegram-k.ts
new file mode 100644
index 00000000..6a192d43
--- /dev/null
+++ b/src/connectors/telegram-k.ts
@@ -0,0 +1,65 @@
+export {};
+
+interface State {
+	_track: string | null;
+	_artist: string | null;
+	getUniqueID: string | null;
+	getDuration: number | undefined;
+	isPlaying: boolean;
+	isStateChangeAllowed: boolean;
+}
+
+const state: State = {
+	_track: null,
+	_artist: null,
+	getUniqueID: null,
+	getDuration: undefined,
+	isPlaying: false,
+	isStateChangeAllowed: true,
+};
+
+for (const prop in state) {
+	if (prop.startsWith('_')) {
+		continue;
+	}
+	Object.defineProperty(Connector, prop, {
+		value: () => state[prop as keyof State],
+	});
+}
+
+Connector.onScriptEvent = (event) => {
+	if (event.data.type === 'TELEGRAM_K_STATE') {
+		const newState = event.data.state as State;
+		for (const name in state) {
+			Object.defineProperty(state, name, {
+				value: newState[name as keyof State],
+			});
+		}
+		Connector.onStateChanged();
+	}
+};
+
+Connector.getArtistTrack = () => {
+	const artist = state._artist;
+	const track = state._track;
+	if (artist === track) {
+		return Util.splitArtistTrack(artist);
+	}
+	return { artist, track };
+};
+
+Connector.injectScript('connectors/telegram-k-dom-inject.js');
+
+const filter = MetadataFilter.createFilter({
+	track: trimSuffix,
+});
+
+Connector.applyFilter(filter);
+
+function trimSuffix(track: string): string {
+	const index = track.lastIndexOf('.');
+	if (index === -1) {
+		return track;
+	}
+	return track.substring(0, index);
+}
diff --git a/src/connectors/tidal.ts b/src/connectors/tidal.ts
index a86561cb..ca39fcb5 100644
--- a/src/connectors/tidal.ts
+++ b/src/connectors/tidal.ts
@@ -6,8 +6,8 @@ Connector.playButtonSelector = `${Connector.playerSelector} button[data-test="pl
 
 Connector.pauseButtonSelector = `${Connector.playerSelector} button[data-test="pause"]`;
 
-Connector.isScrobblingAllowed = () =>
-	!!Util.queryElements(Connector.playButtonSelector);
+Connector.scrobblingDisallowedReason = () =>
+	Util.queryElements(Connector.playButtonSelector) ? null : 'ElementMissing';
 
 Connector.trackSelector = [
 	'#nowPlaying div.react-tabs__tab-panel--selected > div > div:nth-child(1) > div:nth-child(1) > wave-text:nth-child(2)',
diff --git a/src/connectors/wdr.ts b/src/connectors/wdr.ts
index c1ef2dc5..1c9af703 100644
--- a/src/connectors/wdr.ts
+++ b/src/connectors/wdr.ts
@@ -12,7 +12,7 @@ Connector.artistTrackSelector = '.wdrrCurrentShowTitleTitle';
 
 Connector.pauseButtonSelector = '#playCtrl.playing';
 
-Connector.isScrobblingAllowed = () => {
+Connector.scrobblingDisallowedReason = () => {
 	const artistTrack = Util.getTextFromSelectors(
 		Connector.artistTrackSelector,
 	);
@@ -33,7 +33,7 @@ Connector.isScrobblingAllowed = () => {
 		);
 
 	// Scrobble only if none of the disallowed strings are included
-	return !containsDisallowedString;
+	return containsDisallowedString ? 'FilteredTag' : null;
 };
 
 function cleanupArtist(artist: string) {
diff --git a/src/connectors/xrayfm.ts b/src/connectors/xrayfm.ts
index 2f6e7136..bf5c4755 100644
--- a/src/connectors/xrayfm.ts
+++ b/src/connectors/xrayfm.ts
@@ -16,8 +16,8 @@ Connector.getArtistTrack = () => {
 	return null;
 };
 
-Connector.isScrobblingAllowed = () => {
-	return Boolean(
-		Connector.getArtistTrack() && Util.isElementVisible('#current-track'),
-	);
+Connector.scrobblingDisallowedReason = () => {
+	return Connector.getArtistTrack() && Util.isElementVisible('#current-track')
+		? 'ElementMissing'
+		: null;
 };
diff --git a/src/connectors/youtube-music.ts b/src/connectors/youtube-music.ts
index a42496a4..f7d763fc 100644
--- a/src/connectors/youtube-music.ts
+++ b/src/connectors/youtube-music.ts
@@ -114,7 +114,8 @@ Connector.getUniqueID = () => {
 	return Util.getYtVideoIdFromUrl(videoUrl);
 };
 
-Connector.isScrobblingAllowed = () => !Util.isElementVisible(adSelector);
+Connector.scrobblingDisallowedReason = () =>
+	Util.isElementVisible(adSelector) ? 'IsAd' : null;
 
 function getArtists() {
 	// FIXME Use Array.from after jQuery support will be removed
diff --git a/src/connectors/youtube.ts b/src/connectors/youtube.ts
index 6d0731c4..0af8fd32 100644
--- a/src/connectors/youtube.ts
+++ b/src/connectors/youtube.ts
@@ -1,4 +1,4 @@
-import type { TrackInfoWithAlbum } from '@/core/types';
+import type { ArtistTrackInfo, TrackInfoWithAlbum } from '@/core/types';
 
 export {};
 
@@ -30,6 +30,7 @@ const channelNameSelector = [
 	'.slim-owner-channel-name .yt-core-attributed-string',
 ];
 const videoDescriptionSelector = [
+	'#description.ytd-expandable-video-description-body-renderer',
 	'#meta-contents #description',
 	'.crawler-full-description',
 ];
@@ -74,7 +75,12 @@ const getTrackInfoFromYoutubeMusicCache: {
 	};
 } = {};
 
-const trackInfoGetters = [
+const trackInfoGetters: (() =>
+	| ArtistTrackInfo
+	| null
+	| undefined
+	| Record<string, never>
+	| TrackInfoWithAlbum)[] = [
 	getTrackInfoFromChapters,
 	getTrackInfoFromYoutubeMusic,
 	getTrackInfoFromDescription,
@@ -93,6 +99,9 @@ Connector.scrobbleInfoStyle = {
 	fontWeight: '700',
 };
 
+Connector.loveButtonSelector =
+	'ytd-watch-metadata like-button-view-model button[aria-pressed="false"]';
+
 Connector.getChannelId = () =>
 	new URL(
 		(
@@ -136,6 +145,10 @@ Connector.getTrackInfo = () => {
 			trackInfo.track = currentTrackInfo.track;
 		}
 
+		if (!trackInfo.album && 'album' in currentTrackInfo) {
+			trackInfo.album = currentTrackInfo.album;
+		}
+
 		if (!Util.isArtistTrackEmpty(trackInfo)) {
 			break;
 		}
@@ -178,14 +191,14 @@ Connector.getUniqueID = () => {
 	return getVideoId();
 };
 
-Connector.isScrobblingAllowed = () => {
+Connector.scrobblingDisallowedReason = () => {
 	if (document.querySelector('.ad-showing')) {
-		return false;
+		return 'IsAd';
 	}
 
 	// Workaround to prevent scrobbling the video opened in a background tab.
 	if (!isVideoStartedPlaying()) {
-		return false;
+		return 'Other';
 	}
 
 	if (scrobbleMusicRecognisedOnly) {
@@ -195,21 +208,21 @@ Connector.isScrobblingAllowed = () => {
 		if (!ytMusicCache) {
 			// start loading getTrackInfoFromYoutubeMusic
 			getTrackInfoFromYoutubeMusic();
-			return false;
+			return 'IsLoading';
 		}
 
 		if (!ytMusicCache.done) {
 			// not done loading yet
-			return false;
+			return 'IsLoading';
 		}
 
 		if (!ytMusicCache.recognisedByYtMusic) {
 			// not recognised!
-			return false;
+			return 'NotOnYouTubeMusic';
 		}
 	}
 
-	return isVideoCategoryAllowed();
+	return isVideoCategoryAllowed() ? null : 'ForbiddenYouTubeCategory';
 };
 
 Connector.applyFilter(
@@ -354,7 +367,7 @@ async function readConnectorOptions() {
 }
 
 function getVideoDescription() {
-	return Util.getTextFromSelectors(videoDescriptionSelector);
+	return Util.getTextFromSelectors(videoDescriptionSelector)?.trim() ?? null;
 }
 
 function getTrackInfoFromDescription() {
@@ -369,7 +382,10 @@ function getTrackInfoFromDescription() {
 	return artistTrackFromDescription;
 }
 
-function getTrackInfoFromYoutubeMusic() {
+function getTrackInfoFromYoutubeMusic():
+	| ArtistTrackInfo
+	| Record<string, never>
+	| undefined {
 	// if neither getTrackInfoFromYtMusicEnabled nor scrobbleMusicRecognisedOnly
 	// are enabled, there is no need to run this getter
 	if (!getTrackInfoFromYtMusicEnabled && !scrobbleMusicRecognisedOnly) {
@@ -482,7 +498,7 @@ function getTrackInfoFromChapters() {
 	return artistTrack;
 }
 
-function getTrackInfoFromTitle() {
+function getTrackInfoFromTitle(): ArtistTrackInfo {
 	let { artist, track } = Util.processYtVideoTitle(
 		Util.getTextFromSelectors(videoTitleSelector),
 	);
diff --git a/src/core/background/main.ts b/src/core/background/main.ts
index 553eb786..8c456c75 100644
--- a/src/core/background/main.ts
+++ b/src/core/background/main.ts
@@ -384,7 +384,12 @@ setupBackgroundListeners(
 	backgroundListener({
 		type: 'scrobble',
 		fn: (payload, sender) => {
-			return scrobble(new ClonedSong(payload.song, sender.tab?.id ?? -1));
+			return scrobble(
+				payload.songs.map(
+					(song) => new ClonedSong(song, sender.tab?.id ?? -1),
+				),
+				payload.currentlyPlaying,
+			);
 		},
 	}),
 
@@ -462,6 +467,26 @@ setupBackgroundListeners(
 			});
 		},
 	}),
+
+	/**
+	 * Listener called by a content script to fetch through background script.
+	 */
+	backgroundListener({
+		type: 'fetch',
+		fn: async ({ url, init }) => {
+			const res = await fetch(url, init);
+			if (!res.ok) {
+				return {
+					ok: false,
+					content: '',
+				};
+			}
+			return {
+				ok: true,
+				content: await res.text(),
+			};
+		},
+	}),
 );
 
 /**
diff --git a/src/core/background/scrobble.ts b/src/core/background/scrobble.ts
index 244efc21..33efb4b6 100644
--- a/src/core/background/scrobble.ts
+++ b/src/core/background/scrobble.ts
@@ -25,9 +25,12 @@ export async function sendResumedPlaying(
 	return scrobbleService.sendResumedPlaying(song);
 }
 
-export async function scrobble(song: BaseSong): Promise<ServiceCallResult[]> {
+export async function scrobble(
+	songs: BaseSong[],
+	currentlyPlaying: boolean,
+): Promise<ServiceCallResult[][]> {
 	await scrobbleService.bindAllScrobblers();
-	return scrobbleService.scrobble(song);
+	return scrobbleService.scrobble(songs, currentlyPlaying);
 }
 
 export async function getSongInfo(
diff --git a/src/core/connectors.ts b/src/core/connectors.ts
index 843248fa..ea012a2d 100644
--- a/src/core/connectors.ts
+++ b/src/core/connectors.ts
@@ -2220,6 +2220,18 @@ export default <ConnectorMeta[]>[
 		js: 'technobase.fm.js',
 		id: 'technobase.fm',
 	},
+	{
+		label: 'Telegram A',
+		matches: ['*://web.telegram.org/a/*'],
+		js: 'telegram-a.js',
+		id: 'telegram-a',
+	},
+	{
+		label: 'Telegram K',
+		matches: ['*://web.telegram.org/k/*'],
+		js: 'telegram-k.js',
+		id: 'telegram-k',
+	},
 	{
 		label: 'TuneGenieEmbed',
 		matches: ['*://b3.tunegenie.com/*'],
@@ -2354,4 +2366,29 @@ export default <ConnectorMeta[]>[
 		js: 'zingmp3.js',
 		id: 'zingmp3',
 	},
+	{
+		label: 'NTS Live',
+		matches: ['*://*nts.live/live-tracklist/*'],
+		js: 'nts-live.js',
+		id: 'nts-live',
+	},
+	{
+		label: 'NTS Shows',
+		matches: [
+			'*://*nts.live/',
+			'*://*nts.live/latest*',
+			'*://*nts.live/shows*',
+			'*://*nts.live/explore*',
+			'*://*nts.live/infinite-mixtapes*',
+			'*://*nts.live/radio*',
+			'*://*nts.live/supporters*',
+			'*://*nts.live/my-nts*',
+			'*://*nts.live/events*',
+			'*://*nts.live/videos*',
+			'*://*nts.live/incoming*',
+			'*://*nts.live/about*',
+		],
+		js: 'nts-shows.js',
+		id: 'nts-shows',
+	},
 ];
diff --git a/src/core/content/connector.ts b/src/core/content/connector.ts
index 84b74b34..6da85ff0 100644
--- a/src/core/content/connector.ts
+++ b/src/core/content/connector.ts
@@ -3,6 +3,7 @@ import browser from 'webextension-polyfill';
 import { ArtistTrackInfo, BaseState, State, TimeInfo } from '@/core/types';
 import * as Util from '@/core/content/util';
 import { ConnectorMeta } from '../connectors';
+import type { DisallowedReason } from '../object/disallowed-reason';
 
 export default class BaseConnector {
 	/**
@@ -372,6 +373,35 @@ export default class BaseConnector {
 		trackArtUrl?: string | null | undefined,
 	) => boolean | null | undefined = () => false;
 
+	/**
+	 * Button to love/like a song on listening service.
+	 */
+	public loveButtonSelector: string | string[] | null = null;
+
+	/**
+	 * Button to unlove/unlike a song on listening service.
+	 */
+	public unloveButtonSelector: string | string[] | null = null;
+
+	/**
+	 * A check to see if song is loved or not.
+	 * If this changes from false to true or vice-versa the song
+	 * will be loved/unloved on scrobbling services.
+	 *
+	 * @returns True if song is liked; false otherwise
+	 */
+	public isLoved: () => boolean | null | undefined = () => {
+		if (this.loveButtonSelector) {
+			return !Util.isElementVisible(this.loveButtonSelector);
+		}
+
+		if (this.unloveButtonSelector) {
+			return Util.isElementVisible(this.unloveButtonSelector);
+		}
+
+		return null;
+	};
+
 	/**
 	 * Default implementation of a check to see if a state change is allowed.
 	 * MutationObserver will ignore mutations while this function returns false.
@@ -384,14 +414,18 @@ export default class BaseConnector {
 	public isStateChangeAllowed: () => boolean | null | undefined = () => true;
 
 	/**
-	 * Default implementation of a check to see if a scrobbling is allowed.
-	 * The connector resets current state if this function returns falsy result.
+	 * Default implementation of a check to see if scrobbling is allowed.
+	 * The connector resets current state if this function returns non-falsy state.
+	 * The string content of non-falsy state determines the reason to show user for non-scrobbling.
 	 *
 	 * Override this method to allow certain states to be reset.
 	 *
-	 * @returns True if state change is allowed; false otherwise
+	 * @returns null/undefined if state change is allowed; {@link DisallowedReason} otherwise
 	 */
-	public isScrobblingAllowed: () => boolean | null | undefined = () => true;
+	public scrobblingDisallowedReason: () =>
+		| DisallowedReason
+		| null
+		| undefined = () => null;
 
 	/**
 	 * Function that will be called when the connector is injected and
@@ -556,7 +590,7 @@ export default class BaseConnector {
 		trackArt: null,
 		isPodcast: false,
 		originUrl: null,
-		isScrobblingAllowed: true,
+		scrobblingDisallowedReason: null,
 	};
 
 	// #v-ifdef VITE_DEV
@@ -614,6 +648,29 @@ export default class BaseConnector {
 		this._controllerCallback = callback;
 	}
 
+	/**
+	 * Callback set by the controller to listen on state changes of this connector.
+	 */
+	private _isLovedCallback:
+		| ((isLoved: boolean | null) => Promise<void>)
+		| null = null;
+
+	/**
+	 * Callback set by the controller to listen on state changes of this connector.
+	 */
+	public get isLovedCallback():
+		| ((isLoved: boolean | null) => Promise<void>)
+		| null {
+		return this._isLovedCallback;
+	}
+
+	public set isLovedCallback(
+		callback: (isLoved: boolean | null) => Promise<void>,
+	) {
+		callback(this.isLoved() ?? null);
+		this._isLovedCallback = callback;
+	}
+
 	/**
 	 * Function for all the hard work around detecting and updating state.
 	 */
@@ -779,6 +836,12 @@ export default class BaseConnector {
 				}
 				// #v-endif
 			}
+
+			/**
+			 * Finally, handle state change to isLoved,
+			 * which is completely independent of other state.
+			 */
+			this.isLovedCallback?.(this.isLoved() ?? null);
 		};
 
 		this.getCurrentState = () => {
@@ -790,7 +853,7 @@ export default class BaseConnector {
 				isPlaying: this.isPlaying(),
 				isPodcast: this.isPodcast(),
 				originUrl: this.getOriginUrl(),
-				isScrobblingAllowed: this.isScrobblingAllowed(),
+				scrobblingDisallowedReason: this.scrobblingDisallowedReason(),
 			};
 
 			let mediaSessionInfo = null;
diff --git a/src/core/content/starter.ts b/src/core/content/starter.ts
index f03897c4..9621ac9f 100644
--- a/src/core/content/starter.ts
+++ b/src/core/content/starter.ts
@@ -93,6 +93,7 @@ async function setupStateListening(): Promise<void> {
 function createController(isEnabled: boolean) {
 	const controller = new Controller(Connector, isEnabled);
 	Connector.controllerCallback = controller.onStateChanged.bind(controller);
+	Connector.isLovedCallback = controller.onLoveChanged.bind(controller);
 }
 
 /**
diff --git a/src/core/content/util.ts b/src/core/content/util.ts
index 27560766..1f4d30c2 100644
--- a/src/core/content/util.ts
+++ b/src/core/content/util.ts
@@ -14,6 +14,7 @@ import type { DebugLogType } from '@/util/util';
 import { t } from '@/util/i18n';
 import * as ControllerMode from '@/core/object/controller/controller-mode';
 import Song from '../object/song';
+import { sendContentMessage } from '@/util/communication';
 
 const BrowserStorage = (async () => {
 	return import('@/core/storage/browser-storage');
@@ -1072,3 +1073,77 @@ export interface ChannelInfo {
 	id: string;
 	label: string;
 }
+
+type BackgroundResponse<T> =
+	| {
+			ok: true;
+			content: T;
+	  }
+	| {
+			ok: false;
+			content: null;
+	  };
+
+/**
+ *
+ * @param url - url of page to fetch
+ * @param type - type of document to fetch, json/text/html
+ * @param init - init object identical to regular fetch API
+ *
+ * @returns a simplified response object with response.
+ */
+export function fetchFromServiceWorker(
+	url: string,
+	type: 'text',
+	init?: RequestInit | undefined,
+): Promise<BackgroundResponse<string>>;
+export function fetchFromServiceWorker(
+	url: string,
+	type: 'json',
+	init?: RequestInit | undefined,
+): Promise<BackgroundResponse<unknown>>;
+export function fetchFromServiceWorker(
+	url: string,
+	type: 'html',
+	init?: RequestInit | undefined,
+): Promise<BackgroundResponse<Document>>;
+export async function fetchFromServiceWorker(
+	url: string,
+	type: 'text' | 'json' | 'html',
+	init?: RequestInit | undefined,
+) {
+	const res = await sendContentMessage({
+		type: 'fetch',
+		payload: {
+			url,
+			init,
+		},
+	});
+	if (!res.ok) {
+		return {
+			ok: false,
+			content: null,
+		};
+	}
+
+	switch (type) {
+		case 'text':
+			return {
+				ok: true,
+				content: res.content,
+			};
+		case 'json':
+			return {
+				ok: true,
+				content: JSON.parse(res.content) as unknown,
+			};
+		case 'html':
+			return {
+				ok: true,
+				content: new DOMParser().parseFromString(
+					res.content,
+					'text/html',
+				),
+			};
+	}
+}
diff --git a/src/core/object/controller/controller-mode.ts b/src/core/object/controller/controller-mode.ts
index aca926e8..3bc752f9 100644
--- a/src/core/object/controller/controller-mode.ts
+++ b/src/core/object/controller/controller-mode.ts
@@ -33,6 +33,11 @@ export const Loading = 'Loading';
  */
 export const Playing = 'Playing';
 
+/**
+ * A song is paused.
+ */
+export const Paused = 'Paused';
+
 /**
  * A song is scrobbled.
  */
diff --git a/src/core/object/controller/controller.ts b/src/core/object/controller/controller.ts
index 256efdf3..36f71365 100644
--- a/src/core/object/controller/controller.ts
+++ b/src/core/object/controller/controller.ts
@@ -23,6 +23,8 @@ import {
 import EventEmitter from '@/util/emitter';
 import * as BrowserStorage from '@/core/storage/browser-storage';
 import { debugLog } from '@/core/content/util';
+import scrobbleCache from '@/core/storage/scrobble-cache';
+import { ScrobbleStatus } from '@/core/storage/wrapper';
 import browser from 'webextension-polyfill';
 import BaseConnector from '@/core/content/connector';
 import Blocklist from '@/core/storage/blocklist';
@@ -45,6 +47,7 @@ export type ControllerModeStr =
 export const isPrioritizedMode: Partial<Record<ControllerModeStr, true>> = {
 	[ControllerMode.Disallowed]: true,
 	[ControllerMode.Playing]: true,
+	[ControllerMode.Paused]: true,
 	[ControllerMode.Scrobbled]: true,
 	[ControllerMode.Loading]: true,
 	[ControllerMode.Unknown]: true,
@@ -73,7 +76,9 @@ export default class Controller {
 	private currentSong: Song | null = null;
 	private isReplayingSong = false;
 	private shouldScrobblePodcasts = true;
+	private scrobbleCacheId: number | null = null;
 	private blocklist: Blocklist;
+	private isPaused = false;
 
 	private forceScrobble = false;
 	private shouldHaveScrobbled = false;
@@ -89,18 +94,39 @@ export default class Controller {
 	});
 
 	/**
+	 * Mutates this.currentSong to sync disallowed reason, and returns whether song should scrobble
+	 *
 	 * @returns true if song should scrobble; false if disallowed.
 	 */
-	private async shouldScrobble() {
+	private async shouldScrobble(): Promise<boolean> {
 		if (this.forceScrobble) {
 			return true;
 		}
-		return (
-			this.currentSong?.parsed.isScrobblingAllowed &&
-			(await this.blocklist.shouldScrobbleChannel(
-				this.connector.getChannelInfo?.()?.id,
+
+		if (!this.currentSong) {
+			return false;
+		}
+
+		if (this.currentSong.parsed.scrobblingDisallowedReason) {
+			return false;
+		}
+
+		if (
+			!(await this.blocklist.shouldScrobbleChannel(
+				this.connector.getChannelId?.(),
 			))
-		);
+		) {
+			this.currentSong.parsed.scrobblingDisallowedReason =
+				'ForbiddenChannel';
+			return false;
+		}
+
+		if (this.currentSong?.flags.hasBlockedTag) {
+			this.currentSong.parsed.scrobblingDisallowedReason = 'ForbiddenTag';
+			return false;
+		}
+
+		return true;
 	}
 
 	/**
@@ -206,7 +232,7 @@ export default class Controller {
 		this.blocklist = new Blocklist(this.connector.meta.id);
 		this.isEnabled = isEnabled;
 		this.mode = isEnabled ? ControllerMode.Base : ControllerMode.Disabled;
-		this.setMode(this.mode);
+		this.onModeChanged();
 		Options.getOption(Options.SCROBBLE_PODCASTS, connector.meta.id)
 			.then((shouldScrobblePodcasts) => {
 				if (typeof shouldScrobblePodcasts !== 'boolean') {
@@ -282,7 +308,7 @@ export default class Controller {
 			contentListener({
 				type: 'getConnectorDetails',
 				fn: () => ({
-					mode: this.mode,
+					mode: this.getMode(),
 					song: this.currentSong?.getCloneableData() ?? null,
 				}),
 			}),
@@ -349,7 +375,7 @@ export default class Controller {
 		this.updateInfoBox();
 		sendContentMessage({
 			type: 'controllerModeChange',
-			payload: this.mode,
+			payload: this.getMode(),
 		});
 	}
 
@@ -365,7 +391,8 @@ export default class Controller {
 				if (
 					!song ||
 					song.flags.isReplaying ||
-					!(await this.shouldScrobble())
+					!(await this.shouldScrobble()) ||
+					!this.currentSong?.isValid()
 				) {
 					break;
 				}
@@ -471,6 +498,8 @@ export default class Controller {
 		this.setMode(ControllerMode.Skipped);
 
 		this.currentSong.flags.isSkipped = true;
+		this.shouldHaveScrobbled = false;
+		this.forceScrobble = false;
 
 		this.playbackTimer.reset();
 		this.replayDetectionTimer.reset();
@@ -499,6 +528,14 @@ export default class Controller {
 	 * @returns Controller mode
 	 */
 	getMode(): (typeof ControllerMode)[keyof typeof ControllerMode] {
+		const pausableModes = [
+			ControllerMode.Playing,
+			ControllerMode.Scrobbled,
+		];
+		if (pausableModes.includes(this.mode) && this.isPaused) {
+			return ControllerMode.Paused;
+		}
+
 		return this.mode;
 	}
 
@@ -567,6 +604,60 @@ export default class Controller {
 		this.onSongUpdated();
 	}
 
+	/**
+	 * React on love/unlove.
+	 * @param isLoved - Whether song is now liked or unliked
+	 */
+	async onLoveChanged(isLoved: boolean | null): Promise<void> {
+		if (!this.currentSong) {
+			return;
+		}
+
+		/**
+		 * If there has not been definitive state before,
+		 * just change state without sending anything to service.
+		 * We dont want the extension to randomly unlove songs
+		 * on scrobbling service because user didnt do it on
+		 * streaming service.
+		 */
+		if (this.currentSong.flags.isLovedInService === null) {
+			this.currentSong.flags.isLovedInService = isLoved;
+			return;
+		}
+
+		/**
+		 * If suddenly we are not receiving definitive state anymore
+		 * be safe and reset the isloved state
+		 */
+		if (isLoved === null) {
+			this.currentSong.flags.isLovedInService = null;
+			return;
+		}
+
+		/**
+		 * State did not change, don't do anything.
+		 */
+		if (this.currentSong.flags.isLovedInService === isLoved) {
+			return;
+		}
+
+		/**
+		 * Song already had fetched a definitive loved state,
+		 * and this one is different.
+		 * This means user has actively changed it.
+		 * Change if option suggests so.
+		 */
+		this.currentSong.flags.isLovedInService = isLoved;
+		if (
+			await Options.getOption(
+				Options.AUTO_TOGGLE_LOVE,
+				this.connector.meta.id,
+			)
+		) {
+			this.toggleLove(isLoved);
+		}
+	}
+
 	/**
 	 * React on state change.
 	 * @param newState - State of connector
@@ -707,7 +798,7 @@ export default class Controller {
 			isPlaying,
 			trackArt,
 			duration,
-			isScrobblingAllowed,
+			scrobblingDisallowedReason,
 		} = newState;
 		const isPlayingStateChanged =
 			this.currentSong.parsed.isPlaying !== isPlaying;
@@ -715,7 +806,8 @@ export default class Controller {
 		this.currentSong.parsed.currentTime = currentTime;
 		this.currentSong.parsed.isPlaying = isPlaying;
 		this.currentSong.parsed.trackArt = trackArt;
-		this.currentSong.parsed.isScrobblingAllowed = isScrobblingAllowed;
+		this.currentSong.parsed.scrobblingDisallowedReason =
+			scrobblingDisallowedReason;
 
 		if (this.isNeedToUpdateDuration(newState) && duration) {
 			this.updateSongDuration(duration);
@@ -742,6 +834,8 @@ export default class Controller {
 
 		this.playbackTimer.reset();
 		this.replayDetectionTimer.reset();
+		this.shouldHaveScrobbled = false;
+		this.forceScrobble = false;
 
 		this.currentSong = null;
 	}
@@ -753,6 +847,8 @@ export default class Controller {
 		if (!assertSongNotNull(this.currentSong)) {
 			return;
 		}
+		this.shouldHaveScrobbled = false;
+		this.forceScrobble = false;
 
 		if (await this.shouldScrobble()) {
 			this.setMode(ControllerMode.Loading);
@@ -773,12 +869,20 @@ export default class Controller {
 			`Song finished processing: ${this.currentSong.toString()}`,
 		);
 
-		if (this.currentSong.isValid()) {
-			// Processing cleans this flag
-			this.currentSong.flags.isMarkedAsPlaying = false;
+		// Processing cleans this flag
+		this.currentSong.flags.isMarkedAsPlaying = false;
 
-			await this.updateTimers(this.currentSong.getDuration());
+		await this.updateTimers(this.currentSong.getDuration());
 
+		/*
+		 * If the song is playing, mark it immediately;
+		 * otherwise will be flagged in isPlaying binding.
+		 */
+		if (!this.currentSong.isValid()) {
+			this.setSongNotRecognized();
+		} else if (!(await this.shouldScrobble())) {
+			this.setMode(ControllerMode.Disallowed);
+		} else if (this.currentSong.parsed.isPlaying) {
 			/*
 			 * If the song is playing, mark it immediately;
 			 * otherwise will be flagged in isPlaying binding.
@@ -821,6 +925,9 @@ export default class Controller {
 
 		this.currentSong.resetData();
 
+		this.shouldHaveScrobbled = false;
+		this.forceScrobble = false;
+
 		this.playbackTimer.update(null);
 		this.replayDetectionTimer.update(null);
 	}
@@ -848,7 +955,7 @@ export default class Controller {
 				void this.setSongNowPlaying();
 			} else {
 				// Resend current mode
-				this.setMode(this.mode);
+				this.onModeChanged();
 			}
 		} else {
 			this.setPaused();
@@ -905,9 +1012,7 @@ export default class Controller {
 
 		this.currentSong.parsed.duration = duration;
 
-		if (this.currentSong.isValid()) {
-			void this.updateTimers(duration);
-		}
+		void this.updateTimers(duration);
 	}
 
 	/**
@@ -953,10 +1058,24 @@ export default class Controller {
 	private async setSongNowPlaying(): Promise<void> {
 		if (
 			!assertSongNotNull(this.currentSong) ||
+			!this.currentSong.isValid() ||
 			!(await this.shouldScrobble())
 		) {
 			return;
 		}
+
+		/**
+		 * Sometimes a song may change state before processing is done.
+		 * This can cause race condition especially with the blocked tags pipeline
+		 * which decides whether a song should be allowed to be played asynchronously.
+		 * For this reason we must check again here if the song is loading.
+		 */
+		if (!this.currentSong.flags.finishedProcessing) {
+			this.debugLog('Song set as loading');
+			this.setMode(ControllerMode.Loading);
+			return;
+		}
+
 		this.currentSong.flags.isMarkedAsPlaying = true;
 
 		const results = await sendContentMessage({
@@ -980,10 +1099,14 @@ export default class Controller {
 	private async setPaused(): Promise<void> {
 		if (
 			!assertSongNotNull(this.currentSong) ||
+			!this.currentSong.isValid() ||
 			!(await this.shouldScrobble())
 		) {
 			return;
 		}
+
+		this.isPaused = true;
+		this.onModeChanged();
 		await sendContentMessage({
 			type: 'setPaused',
 			payload: {
@@ -995,10 +1118,14 @@ export default class Controller {
 	private async setResumedPlaying(): Promise<void> {
 		if (
 			!assertSongNotNull(this.currentSong) ||
+			!this.currentSong.isValid() ||
 			!(await this.shouldScrobble())
 		) {
 			return;
 		}
+
+		this.isPaused = false;
+		this.onModeChanged();
 		await sendContentMessage({
 			type: 'setResumedPlaying',
 			payload: {
@@ -1041,6 +1168,35 @@ export default class Controller {
 		this.setEnabled(false);
 	}
 
+	/**
+	 * Tries to save failed scrobble due to disallowed/unrecognized.
+	 *
+	 * @returns true if scrobble is failed; false if should scrobble
+	 */
+	private async saveFailedScrobble(): Promise<boolean> {
+		if (!assertSongNotNull(this.currentSong)) {
+			return true;
+		}
+
+		if (!(await this.shouldScrobble())) {
+			this.scrobbleCacheId = await scrobbleCache.pushScrobble({
+				song: this.currentSong.getCloneableData(),
+				status: ScrobbleStatus.DISALLOWED,
+			});
+			this.shouldHaveScrobbled = true;
+			return true;
+		}
+		if (!this.currentSong.isValid()) {
+			this.scrobbleCacheId = await scrobbleCache.pushScrobble({
+				song: this.currentSong.getCloneableData(),
+				status: ScrobbleStatus.INVALID,
+			});
+			this.shouldHaveScrobbled = true;
+			return true;
+		}
+		return false;
+	}
+
 	/**
 	 * Called when scrobble timer triggers.
 	 * The time should be set only after the song is validated and ready
@@ -1051,10 +1207,13 @@ export default class Controller {
 			return;
 		}
 
-		if (!(await this.shouldScrobble())) {
-			this.shouldHaveScrobbled = true;
+		if (await this.saveFailedScrobble()) {
 			return;
 		}
+		if (this.scrobbleCacheId) {
+			scrobbleCache.deleteScrobbles([this.scrobbleCacheId]);
+			this.scrobbleCacheId = null;
+		}
 
 		// dont scrobble until user stopped editing.
 		if (this.isEditing) {
@@ -1072,18 +1231,19 @@ export default class Controller {
 		const results = await sendContentMessage({
 			type: 'scrobble',
 			payload: {
-				song: this.currentSong.getCloneableData(),
+				songs: [this.currentSong.getCloneableData()],
+				currentlyPlaying: true,
 			},
 		});
 
-		if (isAnyResult(results, ServiceCallResult.RESULT_OK)) {
+		if (isAnyResult(results[0], ServiceCallResult.RESULT_OK)) {
 			this.debugLog('Scrobbled successfully');
 
 			this.currentSong.flags.isScrobbled = true;
 			this.setMode(ControllerMode.Scrobbled);
 
 			this.onSongUpdated();
-		} else if (areAllResults(results, ServiceCallResult.RESULT_IGNORE)) {
+		} else if (areAllResults(results[0], ServiceCallResult.RESULT_IGNORE)) {
 			this.debugLog('Song is ignored by service');
 			this.setMode(ControllerMode.Ignored);
 		} else {
diff --git a/src/core/object/disallowed-reason.ts b/src/core/object/disallowed-reason.ts
new file mode 100644
index 00000000..22574132
--- /dev/null
+++ b/src/core/object/disallowed-reason.ts
@@ -0,0 +1,56 @@
+/**
+ * Reasons why a scrobble could be disallowed
+ */
+export type DisallowedReason =
+	/**
+	 * Current track is an ad
+	 */
+	| 'IsAd'
+
+	/**
+	 * YouTube category **the user** has disabled
+	 */
+	| 'ForbiddenYouTubeCategory'
+
+	/**
+	 * Artist/album/track tag **the user** has disabled
+	 * For tags that are always disabled in the connector, use 'FilteredTag'
+	 */
+	| 'ForbiddenTag'
+
+	/**
+	 * Playing from a channel **the user** has disabled
+	 */
+	| 'ForbiddenChannel'
+
+	/**
+	 * Not recognized as music by YTM despite user setting demanding it
+	 */
+	| 'NotOnYouTubeMusic'
+
+	/**
+	 * Web scrobbler thinks this song is playing on a different device
+	 */
+	| 'IsPlayingElsewhere'
+
+	/**
+	 * Some element web scrobbler relies on is missing
+	 */
+	| 'ElementMissing'
+
+	/**
+	 * Tags contain some term that is filtered **on website**
+	 * For tags disabled by the user, use 'ForbiddenTag'
+	 */
+	| 'FilteredTag'
+
+	/**
+	 * Something crucial to detection is still loading
+	 * This error will normally be temporary, usually pretty quickly resolved
+	 */
+	| 'IsLoading'
+
+	/**
+	 * Any other reason
+	 */
+	| 'Other';
diff --git a/src/core/object/pipeline/blocked-tags.ts b/src/core/object/pipeline/blocked-tags.ts
new file mode 100644
index 00000000..1d8ea8d9
--- /dev/null
+++ b/src/core/object/pipeline/blocked-tags.ts
@@ -0,0 +1,21 @@
+/**
+ * This pipeline stage sets a flag to block scrobbling if any of the
+ * tags have been blocklisted by the user.
+ */
+
+import Song from '@/core/object/song';
+import { debugLog } from '@/core/content/util';
+import BlockedTags from '@/core/storage/blocked-tags';
+
+/**
+ * Fill song info by user defined values.
+ * @param song - Song instance
+ */
+export async function process(song: Song): Promise<void> {
+	const blockedTags = new BlockedTags();
+	try {
+		song.flags.hasBlockedTag = await blockedTags.hasBlockedTag(song);
+	} catch (err) {
+		debugLog(err, 'error');
+	}
+}
diff --git a/src/core/object/pipeline/pipeline.ts b/src/core/object/pipeline/pipeline.ts
index 897bab93..fc7b1b2f 100644
--- a/src/core/object/pipeline/pipeline.ts
+++ b/src/core/object/pipeline/pipeline.ts
@@ -7,6 +7,7 @@ import * as Metadata from '@/core/object/pipeline/metadata';
 import * as Normalize from '@/core/object/pipeline/normalize';
 import * as RegexEdits from '@/core/object/pipeline/regex-edits';
 import * as CoverArtArchive from '@/core/object/pipeline/coverartarchive/coverartarchive';
+import * as BlockedTags from '@/core/object/pipeline/blocked-tags';
 import Song from '@/core/object/song';
 import { ConnectorMeta } from '@/core/connectors';
 
@@ -19,6 +20,7 @@ export default class Pipeline {
 		RegexEdits,
 		Metadata,
 		RegexEdits, // Run regex edits again, as the regex edit might have caused an album to be found.
+		BlockedTags,
 		CoverArtArchive,
 	];
 	constructor() {
@@ -28,9 +30,17 @@ export default class Pipeline {
 	async process(song: Song, connector: ConnectorMeta): Promise<boolean> {
 		// FIXME: Use another lock way
 		this.song = song;
+		this.song.flags.finishedProcessing = false;
 
-		for (const processor of this.processors) {
-			await processor.process(song, connector);
+		try {
+			for (const processor of this.processors) {
+				await processor.process(song, connector);
+			}
+			this.song.flags.finishedProcessing = true;
+		} catch (err) {
+			// mark as done even though it errored.
+			this.song.flags.finishedProcessing = true;
+			throw err;
 		}
 
 		// Return false if this call is not relevant, e.g. when
diff --git a/src/core/object/scrobble-service.ts b/src/core/object/scrobble-service.ts
index d4d35fe1..7d14a0ff 100644
--- a/src/core/object/scrobble-service.ts
+++ b/src/core/object/scrobble-service.ts
@@ -10,6 +10,8 @@ import { BaseSong } from '@/core/object/song';
 import { ScrobblerSongInfo } from '@/core/scrobbler/base-scrobbler';
 import ClonedSong from './cloned-song';
 import { debugLog } from '../content/util';
+import scrobbleCache from '../storage/scrobble-cache';
+import { getScrobbleStatus } from '../storage/wrapper';
 
 /**
  * Service to handle all scrobbling behavior.
@@ -225,24 +227,36 @@ class ScrobbleService {
 	 * @param song - Song instance
 	 * @returns Promise that will be resolved then the task will complete
 	 */
-	scrobble(song: BaseSong): Promise<ServiceCallResult[]> {
+	async scrobble(
+		songs: BaseSong[],
+		currentlyPlaying: boolean,
+	): Promise<ServiceCallResult[][]> {
 		debugLog(`Send "scrobble" request: ${this.boundScrobblers.length}`);
 
-		return Promise.all(
+		const res = await Promise.all(
 			this.boundScrobblers.map(async (scrobbler) => {
 				// Forward result (including errors) to caller
 				try {
 					return await scrobbler.scrobble(
-						scrobbler.applyFilter(song),
+						songs.map(scrobbler.applyFilter),
+						currentlyPlaying,
 					);
 				} catch (result) {
-					return this.processErrorResult(
+					return this.processScrobbleErrorResult(
 						scrobbler,
-						result as ServiceCallResult,
+						result as ServiceCallResult[],
 					);
 				}
 			}),
 		);
+		for (let i = 0; i < res[0].length; i++) {
+			await scrobbleCache.pushScrobble({
+				song: songs[i].getCloneableData(),
+				status: getScrobbleStatus(res, i),
+			});
+		}
+
+		return res;
 	}
 
 	/**
@@ -327,6 +341,36 @@ class ScrobbleService {
 		// Forward result
 		return result;
 	}
+
+	/**
+	 * Process result received from scrobbler when scrobbling.
+	 * Scrobbling has array result so logic is slightly different
+	 * @param scrobbler - Scrobbler instance
+	 * @param result - API call result
+	 * @returns Promise resolved with result object
+	 */
+	async processScrobbleErrorResult(
+		scrobbler: Scrobbler,
+		result: ServiceCallResult[],
+	): Promise<ServiceCallResult[]> {
+		const isOtherError = result[0] === ServiceCallResult.ERROR_OTHER;
+		const isAuthError = result[0] === ServiceCallResult.ERROR_AUTH;
+
+		if (!(isOtherError || isAuthError)) {
+			throw new Error(`Invalid result: ${result[0]}`);
+		}
+
+		if (isAuthError) {
+			// Don't unbind scrobblers which have tokens
+			const isReady = await scrobbler.isReadyForGrantAccess();
+			if (!isReady) {
+				this.unbindScrobbler(scrobbler);
+			}
+		}
+
+		// Forward result
+		return result;
+	}
 }
 
 export default new ScrobbleService();
diff --git a/src/core/object/song.ts b/src/core/object/song.ts
index 8a36cb81..3aca90be 100644
--- a/src/core/object/song.ts
+++ b/src/core/object/song.ts
@@ -1,5 +1,6 @@
 import type { ConnectorMeta } from '@/core/connectors';
 import { State } from '@/core/types';
+import type { DisallowedReason } from './disallowed-reason';
 
 export interface ProcessedSongData {
 	artist?: string | null;
@@ -16,7 +17,7 @@ export interface ParsedSongData extends ProcessedSongData {
 	isPodcast?: boolean | null;
 	isPlaying?: boolean | null;
 	currentTime?: number | null;
-	isScrobblingAllowed?: boolean | null;
+	scrobblingDisallowedReason?: DisallowedReason | null;
 }
 
 export type Flags =
@@ -34,6 +35,9 @@ export type Flags =
 			isMarkedAsPlaying: boolean;
 			isSkipped: boolean;
 			isReplaying: boolean;
+			hasBlockedTag: boolean;
+			isLovedInService: boolean | null;
+			finishedProcessing: boolean;
 	  }
 	| Record<string, never>;
 
@@ -432,6 +436,25 @@ export default class Song extends BaseSong {
 			 * Flag means song is replaying again.
 			 */
 			isReplaying: false,
+
+			/**
+			 * Flag means song has blocked tag
+			 */
+			hasBlockedTag: false,
+
+			/**
+			 * Flag means song has been liked/loved in the scrobbling service.
+			 * Is null until value has been read from the service page.
+			 * This is because we do not want to do anything when first setting from page,
+			 * but we do want to do something if the value changes afterwards.
+			 */
+			isLovedInService: null,
+
+			/**
+			 * finishedProcessing is set to true after done processing in pipelines.
+			 * While false don't set the song as playing while we wait for pipelines to finish.
+			 */
+			finishedProcessing: false,
 		};
 	}
 
diff --git a/src/core/scrobbler/audio-scrobbler/audio-scrobbler.ts b/src/core/scrobbler/audio-scrobbler/audio-scrobbler.ts
index 9cd9dba6..d2c4034a 100644
--- a/src/core/scrobbler/audio-scrobbler/audio-scrobbler.ts
+++ b/src/core/scrobbler/audio-scrobbler/audio-scrobbler.ts
@@ -194,22 +194,25 @@ export default abstract class AudioScrobbler extends BaseScrobbler<'LastFM'> {
 	}
 
 	/** @override */
-	async scrobble(song: BaseSong): Promise<ServiceCallResult> {
+	async scrobble(songs: BaseSong[]): Promise<ServiceCallResult[]> {
 		const { sessionID } = await this.getSession();
 		const params: AudioScrobblerScrobbleParams = {
 			method: 'track.scrobble',
-			'timestamp[0]': song.metadata.startTimestamp.toString(),
-			'track[0]': song.getTrack(),
-			'artist[0]': song.getArtist(),
 			sk: sessionID,
 		};
+		for (const [index, song] of songs.slice(0, 50).entries()) {
+			params[`timestamp[${index}]`] =
+				song.metadata.startTimestamp.toString();
+			params[`track[${index}]`] = song.getTrack();
+			params[`artist[${index}]`] = song.getArtist();
+
+			if (song.getAlbum()) {
+				params[`album[${index}]`] = song.getAlbum();
+			}
 
-		if (song.getAlbum()) {
-			params['album[0]'] = song.getAlbum();
-		}
-
-		if (song.getAlbumArtist()) {
-			params['albumArtist[0]'] = song.getAlbumArtist();
+			if (song.getAlbumArtist()) {
+				params[`albumArtist[${index}]`] = song.getAlbumArtist();
+			}
 		}
 
 		const response =
@@ -223,17 +226,44 @@ export default abstract class AudioScrobbler extends BaseScrobbler<'LastFM'> {
 			const scrobbles = response.scrobbles;
 
 			if (scrobbles) {
-				const acceptedCount = scrobbles['@attr'].accepted;
+				const ignoredCount = scrobbles['@attr'].ignored;
 
-				if (acceptedCount === '0') {
-					return ServiceCallResult.RESULT_IGNORE;
+				if (Number(ignoredCount) === 0) {
+					return new Array<ServiceCallResult>(
+						Math.min(songs.length, 50),
+					).fill(ServiceCallResult.RESULT_OK);
+				}
+
+				if (!Array.isArray(scrobbles.scrobble)) {
+					return new Array<ServiceCallResult>(
+						Math.min(songs.length, 50),
+					).fill(ServiceCallResult.RESULT_IGNORE);
+				}
+
+				const timestampMap: Record<string, number> = {};
+				for (const [index, song] of songs.entries()) {
+					timestampMap[song.metadata.startTimestamp.toString()] =
+						index;
+				}
+
+				const results: ServiceCallResult[] =
+					new Array<ServiceCallResult>(Math.min(songs.length, 50));
+				for (const scrobble of scrobbles.scrobble) {
+					results[timestampMap[scrobble.timestamp]] =
+						scrobble.ignoredMessage.code === '0'
+							? ServiceCallResult.RESULT_OK
+							: ServiceCallResult.RESULT_IGNORE;
 				}
 			} else {
-				return ServiceCallResult.ERROR_OTHER;
+				return new Array<ServiceCallResult>(
+					Math.min(songs.length, 50),
+				).fill(ServiceCallResult.ERROR_OTHER);
 			}
 		}
 
-		return result;
+		return new Array<ServiceCallResult>(Math.min(songs.length, 50)).fill(
+			result,
+		);
 	}
 
 	/** @override */
diff --git a/src/core/scrobbler/audio-scrobbler/audio-scrobbler.types.ts b/src/core/scrobbler/audio-scrobbler/audio-scrobbler.types.ts
index 773fe735..c8747640 100644
--- a/src/core/scrobbler/audio-scrobbler/audio-scrobbler.types.ts
+++ b/src/core/scrobbler/audio-scrobbler/audio-scrobbler.types.ts
@@ -4,26 +4,26 @@ interface AudioScrobblerScrobbleEntry {
 	'#text'?: string;
 }
 
+interface AudioScrobblerScrobble {
+	artist: AudioScrobblerScrobbleEntry;
+	ignoredMessage: {
+		code: string;
+		message: string;
+	};
+	albumArtist: AudioScrobblerScrobbleEntry;
+	timestamp: string;
+	album: AudioScrobblerScrobbleEntry;
+	track: AudioScrobblerScrobbleEntry;
+}
+
 export interface AudioScrobblerTrackScrobbleResponse
 	extends Record<string, unknown> {
 	scrobbles: {
 		'@attr': {
-			accepted: string;
-			ignored: string;
-		};
-		scrobble: {
-			scrobbles: {
-				artist: AudioScrobblerScrobbleEntry;
-				ignoredMessage: {
-					code: string;
-					message: string;
-				};
-				albumArtist: AudioScrobblerScrobbleEntry;
-				timestamp: string;
-				album: AudioScrobblerScrobbleEntry;
-				track: AudioScrobblerScrobbleEntry;
-			}[];
+			accepted: number | string;
+			ignored: number | string;
 		};
+		scrobble: AudioScrobblerScrobble | AudioScrobblerScrobble[];
 	};
 }
 
diff --git a/src/core/scrobbler/base-scrobbler.ts b/src/core/scrobbler/base-scrobbler.ts
index 725ecb10..6c319936 100644
--- a/src/core/scrobbler/base-scrobbler.ts
+++ b/src/core/scrobbler/base-scrobbler.ts
@@ -331,13 +331,16 @@ export default abstract class BaseScrobbler<K extends keyof ScrobblerModels> {
 	public abstract sendPaused(song: BaseSong): Promise<ServiceCallResult>;
 
 	/**
-	 * Send song to API to scrobble.
+	 * Send songs to API to scrobble.
 	 * Implementation must return ServiceCallResult constant.
 	 *
-	 * @param song - Song instance
+	 * @param song - Song instances
 	 */
 	// eslint-disable-next-line no-unused-vars
-	public abstract scrobble(song: BaseSong): Promise<ServiceCallResult>;
+	public abstract scrobble(
+		song: BaseSong[],
+		currentlyPlaying: boolean,
+	): Promise<ServiceCallResult[]>;
 
 	/**
 	 * Love or unlove given song.
diff --git a/src/core/scrobbler/listenbrainz/listenbrainz-scrobbler.ts b/src/core/scrobbler/listenbrainz/listenbrainz-scrobbler.ts
index 6d876db5..b843ac9c 100644
--- a/src/core/scrobbler/listenbrainz/listenbrainz-scrobbler.ts
+++ b/src/core/scrobbler/listenbrainz/listenbrainz-scrobbler.ts
@@ -6,6 +6,7 @@ import * as Util from '@/util/util';
 import { getExtensionVersion } from '@/util/util-browser';
 import BaseScrobbler, { SessionData } from '@/core/scrobbler/base-scrobbler';
 import {
+	ListenBrainzHTMLReactProps,
 	ListenBrainzParams,
 	ListenBrainzTrackMeta,
 	MetadataLookup,
@@ -16,7 +17,7 @@ import { sendContentMessage } from '@/util/communication';
  * Module for all communication with LB
  */
 
-const listenBrainzTokenPage = 'https://listenbrainz.org/profile/';
+const listenBrainzTokenPage = 'https://listenbrainz.org/settings/';
 const baseUrl = 'https://api.listenbrainz.org/1';
 const apiUrl = `${baseUrl}/submit-listens`;
 export default class ListenBrainzScrobbler extends BaseScrobbler<'ListenBrainz'> {
@@ -46,7 +47,7 @@ export default class ListenBrainzScrobbler extends BaseScrobbler<'ListenBrainz'>
 			await this.storage.set({ isAuthStarted: true });
 		}
 
-		return 'https://listenbrainz.org/login/musicbrainz?next=%2Fprofile%2F';
+		return 'https://listenbrainz.org/login/musicbrainz?next=%2Fsettings%2F';
 	}
 
 	/** @override */
@@ -181,19 +182,23 @@ export default class ListenBrainzScrobbler extends BaseScrobbler<'ListenBrainz'>
 	}
 
 	/** @override */
-	public async scrobble(song: BaseSong): Promise<ServiceCallResult> {
+	public async scrobble(
+		songs: BaseSong[],
+		currentlyPlaying: boolean,
+	): Promise<ServiceCallResult[]> {
 		const { sessionID } = await this.getSession();
 
 		const params = {
-			listen_type: 'single',
-			payload: [
-				{
-					listened_at: song.metadata.startTimestamp,
-					track_metadata: this.makeTrackMetadata(song),
-				},
-			],
+			listen_type: currentlyPlaying ? 'single' : 'import',
+			payload: songs.slice(0, 50).map((song) => ({
+				listened_at: song.metadata.startTimestamp,
+				track_metadata: this.makeTrackMetadata(song),
+			})),
 		} as ListenBrainzParams;
-		return this.sendScrobbleRequest(params, sessionID);
+		const res = await this.sendScrobbleRequest(params, sessionID);
+		return new Array<ServiceCallResult>(Math.min(songs.length, 50)).fill(
+			res,
+		);
 	}
 
 	/** @override */
@@ -360,22 +365,21 @@ export default class ListenBrainzScrobbler extends BaseScrobbler<'ListenBrainz'>
 		const rawHtml = await Util.timeoutPromise(timeout, promise);
 
 		if (rawHtml !== null) {
-			const parser = new DOMParser();
-
-			const doc = parser.parseFromString(rawHtml, 'text/html');
+			let globalReactPropsJSON: ListenBrainzHTMLReactProps | null = null;
 
-			let sessionName = null;
-			let sessionID = null;
-			const sessionNameEl = doc.querySelector('.page-title');
-			const sessionIdEl = doc.querySelector('#auth-token');
+			const globalReactPropsHTML = rawHtml.match(
+				/<script id="global-react-props" type="application\/json">(.*?)<\/script>/,
+			)?.[1];
 
-			if (sessionNameEl) {
-				sessionName = sessionNameEl.textContent;
-			}
-			if (sessionIdEl) {
-				sessionID = sessionIdEl.getAttribute('value');
+			if (globalReactPropsHTML) {
+				globalReactPropsJSON = JSON.parse(
+					globalReactPropsHTML,
+				) as ListenBrainzHTMLReactProps;
 			}
 
+			const sessionName = globalReactPropsJSON?.current_user.name;
+			const sessionID = globalReactPropsJSON?.current_user.auth_token;
+
 			if (sessionID && sessionName) {
 				return { sessionID, sessionName };
 			}
diff --git a/src/core/scrobbler/listenbrainz/listenbrainz.types.ts b/src/core/scrobbler/listenbrainz/listenbrainz.types.ts
index aa1a5b92..6e09c238 100644
--- a/src/core/scrobbler/listenbrainz/listenbrainz.types.ts
+++ b/src/core/scrobbler/listenbrainz/listenbrainz.types.ts
@@ -26,7 +26,7 @@ export type ListenBrainzParams =
 			];
 	  }
 	| {
-			listen_type: 'single';
+			listen_type: 'single' | 'import';
 			payload: [
 				{
 					listened_at: number;
@@ -42,4 +42,11 @@ export type ListenBrainzParams =
 export type MetadataLookup = {
 	recording_mbid?: string;
 };
+
+export interface ListenBrainzHTMLReactProps {
+	current_user: {
+		name: string;
+		auth_token: string;
+	};
+}
 /* eslint-enable camelcase */
diff --git a/src/core/scrobbler/maloja/maloja-scrobbler.ts b/src/core/scrobbler/maloja/maloja-scrobbler.ts
index e54288b4..38942563 100644
--- a/src/core/scrobbler/maloja/maloja-scrobbler.ts
+++ b/src/core/scrobbler/maloja/maloja-scrobbler.ts
@@ -88,10 +88,13 @@ export default class MalojaScrobbler extends BaseScrobbler<'Maloja'> {
 	}
 
 	/** @override */
-	public async scrobble(song: BaseSong): Promise<ServiceCallResult> {
-		const songData = this.makeTrackMetadata(song);
-
-		return this.sendRequest(songData, this.userToken);
+	public async scrobble(songs: BaseSong[]): Promise<ServiceCallResult[]> {
+		const resultArray: Promise<ServiceCallResult>[] = [];
+		for (const song of songs.slice(0, 50)) {
+			const songData = this.makeTrackMetadata(song);
+			resultArray.push(this.sendRequest(songData, this.userToken));
+		}
+		return Promise.all(resultArray);
 	}
 
 	/** Private methods */
diff --git a/src/core/scrobbler/pleroma/pleroma-scrobbler.ts b/src/core/scrobbler/pleroma/pleroma-scrobbler.ts
index fc3801d6..c39f1c88 100644
--- a/src/core/scrobbler/pleroma/pleroma-scrobbler.ts
+++ b/src/core/scrobbler/pleroma/pleroma-scrobbler.ts
@@ -16,7 +16,7 @@ import {
 export default class PleromaScrobbler extends BaseScrobbler<'Pleroma'> {
 	public userToken!: string;
 	public userApiUrl!: string;
-	public isLocalOnly = false;
+	public isLocalOnly = true;
 
 	/** @override */
 	protected getStorageName(): 'Pleroma' {
@@ -115,10 +115,13 @@ export default class PleromaScrobbler extends BaseScrobbler<'Pleroma'> {
 	}
 
 	/** @override */
-	public async scrobble(song: BaseSong): Promise<ServiceCallResult> {
-		const songData = this.makeTrackMetadata(song);
-
-		return this.sendRequest(songData, this.userToken);
+	public async scrobble(songs: BaseSong[]): Promise<ServiceCallResult[]> {
+		const resultArray: Promise<ServiceCallResult>[] = [];
+		for (const song of songs.slice(0, 50)) {
+			const songData = this.makeTrackMetadata(song);
+			resultArray.push(this.sendRequest(songData, this.userToken));
+		}
+		return Promise.all(resultArray);
 	}
 
 	/** Private methods */
diff --git a/src/core/scrobbler/webhook-scrobbler.ts b/src/core/scrobbler/webhook-scrobbler.ts
index 1952a4b2..48e2fc30 100644
--- a/src/core/scrobbler/webhook-scrobbler.ts
+++ b/src/core/scrobbler/webhook-scrobbler.ts
@@ -12,7 +12,9 @@ type WebhookRequest = {
 	time: number;
 	data: {
 		song: BaseSong;
+		songs?: BaseSong[];
 		isLoved?: boolean;
+		currentlyPlaying?: boolean;
 	};
 };
 
@@ -146,12 +148,21 @@ export default class WebhookScrobbler extends BaseScrobbler<'Webhook'> {
 	}
 
 	/** @override */
-	public async scrobble(song: BaseSong): Promise<ServiceCallResult> {
-		return this.sendRequest({
+	public async scrobble(
+		songs: BaseSong[],
+		currentlyPlaying: boolean,
+	): Promise<ServiceCallResult[]> {
+		const res = await this.sendRequest({
 			eventName: 'scrobble',
 			time: Date.now(),
-			data: { song },
+			// send the first song as a separate argument to avoid breaking older implementations
+			data: {
+				song: songs[0],
+				songs,
+				currentlyPlaying,
+			},
 		});
+		return new Array<ServiceCallResult>(songs.length).fill(res);
 	}
 
 	/** @override */
diff --git a/src/core/storage/blocked-tags.ts b/src/core/storage/blocked-tags.ts
new file mode 100644
index 00000000..1ea09f9b
--- /dev/null
+++ b/src/core/storage/blocked-tags.ts
@@ -0,0 +1,183 @@
+import * as BrowserStorage from '@/core/storage/browser-storage';
+import { BaseSong } from '../object/song';
+import { BlockedTagType } from './wrapper';
+
+export default class BlockedTags {
+	private storage = BrowserStorage.getStorage(BrowserStorage.BLOCKED_TAGS);
+	private isReady: Promise<true>;
+
+	constructor() {
+		this.isReady = this.init();
+	}
+
+	/**
+	 * Sets default values to blocklist if necessary
+	 */
+	private async setupDefaultBlocklist() {
+		let data = await this.storage.getLocking();
+		if (!data) {
+			data = {};
+		}
+		await this.storage.setLocking(data);
+	}
+
+	/**
+	 * Initializes the blocklist with default values
+	 */
+	private async init(): Promise<true> {
+		await this.setupDefaultBlocklist();
+		return true;
+	}
+
+	/**
+	 * Adds a set of tags to blocklist
+	 *
+	 * @param type - type of tag to block
+	 * @param song - song to add
+	 */
+	public async addToBlocklist(
+		type: BlockedTagType,
+		song: BaseSong | undefined,
+	): Promise<void> {
+		if (!song) {
+			return;
+		}
+		await this.isReady;
+		const data = await this.storage.getLocking();
+		const artist = song.getArtist();
+		const track = song.getTrack();
+		if (!data || !artist || !track) {
+			this.storage.unlock();
+			return;
+		}
+		if (!data[artist]) {
+			data[artist] = {
+				tracks: {},
+				albums: {},
+			};
+		}
+		switch (type) {
+			case 'artist': {
+				data[artist].disabled = true;
+				break;
+			}
+			case 'album': {
+				const album = song.getAlbum();
+				const albumArtist = song.getAlbumArtist() || artist;
+				if (!album) {
+					this.storage.unlock();
+					return;
+				}
+				if (!data[albumArtist]) {
+					data[albumArtist] = {
+						tracks: {},
+						albums: {},
+					};
+				}
+				data[albumArtist].albums[album] = true;
+				break;
+			}
+			case 'track': {
+				data[artist].tracks[track] = true;
+				break;
+			}
+		}
+		await this.storage.setLocking(data);
+	}
+
+	/**
+	 * Removes a set of tags from blocklist
+	 *
+	 * @param tags - Tags to remove
+	 */
+	async removeFromBlocklist(
+		type: BlockedTagType,
+		song: BaseSong | undefined,
+	): Promise<void> {
+		if (!song) {
+			return;
+		}
+		const artist = song.getArtist();
+		const track = song.getTrack();
+		if (!artist || !track) {
+			return;
+		}
+		const data = await this.storage.getLocking();
+		if (!data) {
+			this.storage.unlock();
+			return;
+		}
+		switch (type) {
+			case 'artist': {
+				delete data[artist]?.disabled;
+				break;
+			}
+			case 'album': {
+				const albumArtist = song.getAlbumArtist() || artist;
+				const album = song.getAlbum();
+				if (!album) {
+					return;
+				}
+				delete data[albumArtist]?.albums[album];
+				break;
+			}
+			case 'track': {
+				delete data[artist]?.tracks[track];
+				break;
+			}
+		}
+
+		await this.storage.setLocking(data);
+	}
+
+	/**
+	 * @param song - song to check
+	 * @returns object of booleans saying whether each tag type is blocked for the song.
+	 */
+	public async getBlockedTypes(song: BaseSong | undefined): Promise<{
+		artist: boolean;
+		album: boolean;
+		track: boolean;
+	}> {
+		const result = {
+			artist: false,
+			album: false,
+			track: false,
+		};
+		if (!song) {
+			return result;
+		}
+
+		await this.isReady;
+		const data = await this.storage.get();
+		const artist = song.getArtist();
+		const track = song.getTrack();
+
+		if (!data || !artist || !data[artist] || !track) {
+			return result;
+		}
+		if (data[artist].disabled) {
+			result.artist = true;
+		}
+		if (data[artist].tracks[track]) {
+			result.track = true;
+		}
+		const albumArtist = song.getAlbumArtist() || artist;
+		const album = song.getAlbum();
+		if (!album || !data[albumArtist]?.albums[album]) {
+			return result;
+		}
+		result.album = true;
+		return result;
+	}
+
+	/**
+	 * @param song - song to check
+	 *
+	 * @returns true if song is blocklisted; false if it isn't
+	 */
+	public async hasBlockedTag(song: BaseSong): Promise<boolean> {
+		const res = await this.getBlockedTypes(song);
+		return res.artist || res.album || res.track;
+	}
+}
diff --git a/src/core/storage/browser-storage.ts b/src/core/storage/browser-storage.ts
index e879064b..c062288e 100644
--- a/src/core/storage/browser-storage.ts
+++ b/src/core/storage/browser-storage.ts
@@ -1,5 +1,6 @@
 import browser from 'webextension-polyfill';
 import StorageWrapper, { DataModels } from '@/core/storage/wrapper';
+import type CloneableSong from '../object/song';
 
 const LOCAL = 0;
 const SYNC = 1;
@@ -48,6 +49,24 @@ export const CUSTOM_PATTERNS = 'customPatterns';
  */
 export const NOTIFICATIONS = 'Notifications';
 
+/**
+ * This storage contains the tags blocked for scrobbling by the user.
+ * This can be artists, albums, or tracks.
+ * The format of the storage data is as follows:
+ * \{
+ *     artist_name: \{
+ *         disabled: true, // true if artist blocked
+ *         albums: \{
+ *             album_name: true, // true if album blocked
+ *         \},
+ *         tracks: \{
+ *             track_name: true, // true if track blocked
+ *         \},
+ *     \}
+ * \}
+ */
+export const BLOCKED_TAGS = 'BlockedTags';
+
 /**
  * This storage contains the song data saved by an user.
  * The format of storage data is following:
@@ -118,6 +137,17 @@ export const DISABLED_TABS = 'DisabledTabs';
  */
 export const REGEX_EDITS = 'RegexEdits';
 
+/**
+ * This storage contains the scrobble cache for the user to interact with.
+ * The format of the storage data is as follows:
+ * \{
+ *     song: {@link CloneableSong},
+ *     status: 'Status of the scrobble',
+ *     id: auto-increment unique ID.
+ * \}
+ */
+export const SCROBBLE_CACHE = 'ScrobbleCache';
+
 /**
  * This storage contains the data saved and used by the extension core.
  * The format of storage data is following:
@@ -145,6 +175,8 @@ const storageTypeMap = {
 	[BLOCKLISTS]: LOCAL,
 	[LOCAL_CACHE]: LOCAL,
 	[REGEX_EDITS]: LOCAL,
+	[SCROBBLE_CACHE]: LOCAL,
+	[BLOCKED_TAGS]: LOCAL,
 	[CORE]: LOCAL,
 	[STATE_MANAGEMENT]: LOCAL,
 	[DISABLED_TABS]: LOCAL,
diff --git a/src/core/storage/options.ts b/src/core/storage/options.ts
index 7b86005e..0593c444 100644
--- a/src/core/storage/options.ts
+++ b/src/core/storage/options.ts
@@ -16,6 +16,7 @@ export const USE_UNRECOGNIZED_SONG_NOTIFICATIONS =
 	'useUnrecognizedSongNotifications';
 export const USE_INFOBOX = 'showInfobox';
 export const SCROBBLE_PODCASTS = 'scrobblePodcasts';
+export const AUTO_TOGGLE_LOVE = 'autoToggleLove';
 export const FORCE_RECOGNIZE = 'forceRecognize';
 export const SCROBBLE_RECOGNIZED_TRACKS = 'scrobbleRecognizedTracks';
 export const SCROBBLE_EDITED_TRACKS_ONLY = 'scrobbleEditedTracksOnly';
@@ -75,6 +76,11 @@ export interface GlobalOptions {
 	 * Allow debug messages to be logged to the browser console.
 	 */
 	[DEBUG_LOGGING_ENABLED]: boolean;
+
+	/**
+	 * Automatically toggle love on scrobbling service when doing so on website.
+	 */
+	[AUTO_TOGGLE_LOVE]: boolean;
 }
 
 /**
@@ -90,6 +96,7 @@ const DEFAULT_OPTIONS: GlobalOptions = {
 	[DEBUG_LOGGING_ENABLED]: false,
 	[SCROBBLE_PERCENT]: DEFAULT_SCROBBLE_PERCENT,
 	[USE_INFOBOX]: true,
+	[AUTO_TOGGLE_LOVE]: true,
 	[DISABLED_CONNECTORS]: {},
 };
 
@@ -101,6 +108,7 @@ const OVERRIDE_CONTENT = {
 	[USE_NOTIFICATIONS]: true,
 	[USE_UNRECOGNIZED_SONG_NOTIFICATIONS]: false,
 	[USE_INFOBOX]: true,
+	[AUTO_TOGGLE_LOVE]: true,
 };
 
 export interface ConnectorOptions {
@@ -129,6 +137,7 @@ export interface ConnectorsOverrideOptionValues {
 	[USE_NOTIFICATIONS]?: boolean;
 	[USE_INFOBOX]?: boolean;
 	[SCROBBLE_PODCASTS]?: boolean;
+	[AUTO_TOGGLE_LOVE]?: boolean;
 	[USE_UNRECOGNIZED_SONG_NOTIFICATIONS]?: boolean;
 	[SCROBBLE_RECOGNIZED_TRACKS]?: boolean;
 	[SCROBBLE_EDITED_TRACKS_ONLY]?: boolean;
diff --git a/src/core/storage/regex-edits.model.ts b/src/core/storage/regex-edits.model.ts
index 9cd476d0..76e2433b 100644
--- a/src/core/storage/regex-edits.model.ts
+++ b/src/core/storage/regex-edits.model.ts
@@ -43,23 +43,14 @@ export default abstract class RegexEditsModel extends CustomStorage<K> {
 	 * @param search - Search to save
 	 * @param replace - Replace to save
 	 */
-	async saveRegexEdit(
-		search: RegexFields,
-		replace: RegexFields,
-	): Promise<void> {
+	async saveRegexEdit(newEdit: RegexEdit): Promise<void> {
 		const storageData = await this.getRegexEditStorage();
 		if (storageData === null) {
-			await this.saveRegexEditToStorage([{ search, replace }]);
+			await this.saveRegexEditToStorage([newEdit]);
 			return;
 		}
 
-		const newStorageData = [
-			...storageData,
-			{
-				search,
-				replace,
-			},
-		];
+		const newStorageData = [...storageData, newEdit];
 
 		await this.saveRegexEditToStorage(newStorageData);
 	}
diff --git a/src/core/storage/scrobble-cache.model.ts b/src/core/storage/scrobble-cache.model.ts
new file mode 100644
index 00000000..902bfc6f
--- /dev/null
+++ b/src/core/storage/scrobble-cache.model.ts
@@ -0,0 +1,92 @@
+import { SCROBBLE_CACHE } from './browser-storage';
+import { CustomStorage } from './custom-storage';
+import { CacheScrobble, CacheScrobbleData, DataModels } from './wrapper';
+
+type K = typeof SCROBBLE_CACHE;
+type V = DataModels[K];
+type T = Record<K, V>;
+
+export default abstract class ScrobbleCacheModel extends CustomStorage<K> {
+	public abstract getScrobbleCacheStorage(): Promise<CacheScrobble[] | null>;
+	public abstract getScrobbleCacheStorageLocking(): Promise<
+		CacheScrobble[] | null
+	>;
+	public abstract saveScrobbleCacheToStorage(data: T[K]): Promise<void>;
+	public abstract saveScrobbleCacheToStorageLocking(
+		data: T[K],
+	): Promise<void>;
+	private MAX_SCROBBLE_CACHE_SIZE = 1000;
+
+	/**
+	 * Replace the data for a scrobble in the scrobble cache
+	 *
+	 * @param id - ID of the scrobble to modify
+	 * @param scrobble - data to replace it with
+	 */
+	async replaceScrobble(
+		id: number,
+		scrobble: CacheScrobbleData,
+	): Promise<void> {
+		const storageData = await this.getScrobbleCacheStorageLocking();
+		if (!storageData) {
+			return;
+		}
+
+		for (let i = 0; i < storageData.length; i++) {
+			if (storageData[i].id === id) {
+				storageData[i] = {
+					...scrobble,
+					id,
+				};
+			}
+		}
+		await this.saveScrobbleCacheToStorageLocking(storageData);
+	}
+
+	/**
+	 * Save scrobble to the storage.
+	 *
+	 * @param scrobble - {@link CacheScrobbleData} to save
+	 *
+	 * @returns the id of the new scrobble
+	 */
+	async pushScrobble(scrobble: CacheScrobbleData): Promise<number> {
+		const storageData = await this.getScrobbleCacheStorageLocking();
+		if (storageData === null) {
+			await this.saveScrobbleCacheToStorageLocking([
+				{
+					...scrobble,
+					id: 1,
+				},
+			]);
+			return 1;
+		}
+
+		const id = (storageData.at(-1)?.id ?? 0) + 1;
+		storageData.push({
+			...scrobble,
+			id,
+		});
+
+		// limit how much we store
+		await this.saveScrobbleCacheToStorageLocking(
+			storageData.slice(-this.MAX_SCROBBLE_CACHE_SIZE),
+		);
+		return id;
+	}
+
+	async deleteScrobbles(ids: number[]): Promise<void> {
+		const storageData = await this.getScrobbleCacheStorageLocking();
+		if (!storageData) {
+			return;
+		}
+		const scrobbleIdMap = new Map<number, boolean>();
+		for (const id of ids) {
+			scrobbleIdMap.set(id, true);
+		}
+
+		await this.saveScrobbleCacheToStorageLocking(
+			storageData.filter((e) => scrobbleIdMap.get(e.id) !== true),
+		);
+	}
+}
diff --git a/src/core/storage/scrobble-cache.ts b/src/core/storage/scrobble-cache.ts
new file mode 100644
index 00000000..b87a8c05
--- /dev/null
+++ b/src/core/storage/scrobble-cache.ts
@@ -0,0 +1,49 @@
+import { SCROBBLE_CACHE } from './browser-storage';
+import StorageWrapper, { CacheScrobble, DataModels } from './wrapper';
+import * as BrowserStorage from './browser-storage';
+import ScrobbleCacheModel from './scrobble-cache.model';
+
+type K = typeof SCROBBLE_CACHE;
+type V = DataModels[K];
+type T = Record<K, V>;
+
+class ScrobbleCacheImpl extends ScrobbleCacheModel {
+	private scrobbleCacheStorage = this.getStorage();
+
+	public init() {
+		this._init(
+			BrowserStorage.getLocalStorage(BrowserStorage.SCROBBLE_CACHE),
+		);
+
+		// #v-ifdef VITE_DEV
+		void this.scrobbleCacheStorage.debugLog();
+		// #v-endif
+	}
+
+	/** @override */
+	async getScrobbleCacheStorage(): Promise<CacheScrobble[] | null> {
+		return this.scrobbleCacheStorage.get();
+	}
+
+	/** @override */
+	async getScrobbleCacheStorageLocking(): Promise<CacheScrobble[] | null> {
+		return this.scrobbleCacheStorage.getLocking();
+	}
+
+	/** @override */
+	async saveScrobbleCacheToStorage(data: T[K]): Promise<void> {
+		return await this.scrobbleCacheStorage.set(data);
+	}
+
+	/** @override */
+	async saveScrobbleCacheToStorageLocking(data: T[K]): Promise<void> {
+		return await this.scrobbleCacheStorage.setLocking(data);
+	}
+
+	/** @override */
+	getStorage(): StorageWrapper<K> {
+		return BrowserStorage.getStorage(SCROBBLE_CACHE);
+	}
+}
+
+export default new ScrobbleCacheImpl();
diff --git a/src/core/storage/wrapper.ts b/src/core/storage/wrapper.ts
index fccd33a8..0680b593 100644
--- a/src/core/storage/wrapper.ts
+++ b/src/core/storage/wrapper.ts
@@ -6,10 +6,12 @@ import {
 	CORE,
 	CUSTOM_PATTERNS,
 	DISABLED_TABS,
+	BLOCKED_TAGS,
 	LOCAL_CACHE,
 	NOTIFICATIONS,
 	OPTIONS,
 	REGEX_EDITS,
+	SCROBBLE_CACHE,
 	STATE_MANAGEMENT,
 	StorageNamespace,
 	BLOCKLISTS,
@@ -27,6 +29,7 @@ import EventEmitter from '@/util/emitter';
 import connectors from '@/core/connectors';
 import { RegexEdit } from '@/util/regex';
 import { debugLog } from '../content/util';
+import { ServiceCallResult } from '../object/service-call-result';
 
 export interface CustomPatterns {
 	[connectorId: string]: string[];
@@ -81,11 +84,71 @@ export interface ManagerTab {
 	song: CloneableSong | null;
 }
 
+export type BlockedTagType = 'artist' | 'album' | 'track';
+
+export type BlockedTagsArtist = {
+	disabled?: true;
+	albums: Record<string, true>;
+	tracks: Record<string, true>;
+};
+
+export type BlockedTags = Record<string, BlockedTagsArtist>;
+
+export type BlockedTagsReference =
+	| {
+			type: 'artist';
+			artist: string;
+	  }
+	| {
+			type: 'album';
+			artist: string;
+			album: string;
+	  }
+	| {
+			type: 'track';
+			artist: string;
+			track: string;
+	  };
+
 export interface StateManagement {
 	activeTabs: ManagerTab[];
 	browserPreferredTheme: 'light' | 'dark';
 }
 
+export enum ScrobbleStatus {
+	SUCCESSFUL = 'success',
+	IGNORED = 'ignored',
+	ERROR = 'error',
+	DISALLOWED = 'disallowed',
+	INVALID = 'invalid',
+}
+
+export function getScrobbleStatus(
+	resultArr: ServiceCallResult[][],
+	index: number,
+): ScrobbleStatus {
+	// RESULT_IGNORE definitely requires action - prioritize it.
+	for (const res of resultArr) {
+		if (res[index] === ServiceCallResult.RESULT_IGNORE) {
+			return ScrobbleStatus.IGNORED;
+		}
+	}
+	for (const res of resultArr) {
+		if (res[index] !== ServiceCallResult.RESULT_OK) {
+			return ScrobbleStatus.ERROR;
+		}
+	}
+	return ScrobbleStatus.SUCCESSFUL;
+}
+
+export interface CacheScrobbleData {
+	song: CloneableSong;
+	status: ScrobbleStatus;
+}
+
+export interface CacheScrobble extends CacheScrobbleData {
+	id: number;
+}
 export type Blocklists = Record<string, Blocklist>;
 
 export type Blocklist = Record<string, string>;
@@ -102,6 +165,8 @@ export interface DataModels extends ScrobblerModels {
 	[CORE]: { appVersion: string };
 	[LOCAL_CACHE]: { [key: string]: SavedEdit };
 	[REGEX_EDITS]: RegexEdit[];
+	[SCROBBLE_CACHE]: CacheScrobble[];
+	[BLOCKED_TAGS]: BlockedTags;
 	[BLOCKLISTS]: Blocklists;
 
 	/* state management */
diff --git a/src/core/types.ts b/src/core/types.ts
index ad00cd4a..33968cbe 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -1,3 +1,5 @@
+import type { DisallowedReason } from './object/disallowed-reason';
+
 export interface TimeInfo {
 	/**
 	 * Currentime in seconds
@@ -75,5 +77,5 @@ export interface State extends BaseState {
 	/**
 	 * Is scrobbling allowed
 	 */
-	isScrobblingAllowed?: boolean | null;
+	scrobblingDisallowedReason?: DisallowedReason | null;
 }
diff --git a/src/icons/monochrome/action_paused.svg b/src/icons/monochrome/action_paused.svg
new file mode 100644
index 00000000..f2cda38a
--- /dev/null
+++ b/src/icons/monochrome/action_paused.svg
@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium css-dhaba5"
+   focusable="false"
+   aria-hidden="true"
+   viewBox="0 0 24 24"
+   data-testid="PauseIcon"
+   version="1.1"
+   id="svg134"
+   sodipodi:docname="action_paused.svg"
+   inkscape:version="1.2.2 (b0a84865, 2022-12-01)"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg">
+  <defs
+     id="defs138" />
+  <sodipodi:namedview
+     id="namedview136"
+     pagecolor="#ffffff"
+     bordercolor="#000000"
+     borderopacity="0.25"
+     inkscape:showpageshadow="2"
+     inkscape:pageopacity="0.0"
+     inkscape:pagecheckerboard="0"
+     inkscape:deskcolor="#d1d1d1"
+     showgrid="false"
+     inkscape:zoom="9.8333333"
+     inkscape:cx="12"
+     inkscape:cy="12"
+     inkscape:window-width="1309"
+     inkscape:window-height="456"
+     inkscape:window-x="0"
+     inkscape:window-y="25"
+     inkscape:window-maximized="0"
+     inkscape:current-layer="svg134" />
+  <path
+     d="M 1.7145,24 H 8.571643 V 0 H 1.7145 Z M 15.428786,0 v 24 h 6.857143 V 0 Z"
+     id="path132"
+     style="stroke-width:1.71429" />
+</svg>
diff --git a/src/theme/themes.scss b/src/theme/themes.scss
index 0e2b1bbf..cadd168e 100644
--- a/src/theme/themes.scss
+++ b/src/theme/themes.scss
@@ -10,6 +10,8 @@ $themes: (
 		context-bg: rgb(250 235 235 / 95%),
 		modal-bg: rgb(255 255 255 / 95%),
 		last-fm-bg: #faebeb,
+		last-fm-bg-light: #faebeb,
+		last-fm-bg-weak: rgb(250 235 235 / 50%),
 		muted-text: #555,
 		active-text: #fff,
 		text: #222,
@@ -19,6 +21,11 @@ $themes: (
 		indeterminate: #8e8e8e,
 		site-image: url('../img/main/site-light.jpg'),
 		invert-filter: invert(0%),
+		interactable-border-color: transparent,
+		neuomorphic-bottom: 10px 10px 30px #bbb,
+		neuomorphic-top: -10px -10px 30px #fff9f9,
+		neuomorphic-small-bottom: 5px 5px 15px #bbb,
+		neuomorphic-small-top: -5px -5px 15px #fff9f9,
 		interactable-border: none,
 	),
 	dark: (
@@ -32,6 +39,8 @@ $themes: (
 		context-bg: rgb(194 57 55 / 95%),
 		modal-bg: rgb(40 40 40 / 95%),
 		last-fm-bg: #c23937,
+		last-fm-bg-light: #faebeb,
+		last-fm-bg-weak: rgb(194 57 55 / 10%),
 		muted-text: #bbb,
 		active-text: #fff,
 		text: #eee,
@@ -41,6 +50,11 @@ $themes: (
 		indeterminate: #8e8e8e,
 		site-image: url('../img/main/site-dark.jpg'),
 		invert-filter: invert(100%),
+		interactable-border-color: transparent,
+		neuomorphic-bottom: 10px 10px 30px #111,
+		neuomorphic-top: -10px -10px 30px #333,
+		neuomorphic-small-bottom: 5px 5px 15px #111,
+		neuomorphic-small-top: -5px -5px 15px #333,
 		interactable-border: none,
 	),
 	high-contrast-dark: (
@@ -63,7 +77,12 @@ $themes: (
 		indeterminate: #8e8e8e,
 		site-image: black,
 		invert-filter: invert(100%),
-		interactable-border: 1px solid yellow,
+		interactable-border-color: yellow,
+		interactable-border: 1px solid var(--interactable-border-color),
+		neuomorphic-bottom: none,
+		neuomorphic-top: none,
+		neuomorphic-small-bottom: none,
+		neuomorphic-small-top: none,
 	),
 	high-contrast-light: (
 		always-light: black,
@@ -85,7 +104,12 @@ $themes: (
 		indeterminate: #8e8e8e,
 		site-image: seashell,
 		invert-filter: invert(0%),
-		interactable-border: 1px solid black,
+		interactable-border-color: black,
+		interactable-border: 1px solid var(--interactable-border-color),
+		neuomorphic-bottom: none,
+		neuomorphic-top: none,
+		neuomorphic-small-bottom: none,
+		neuomorphic-small-top: none,
 	),
 );
 
diff --git a/src/ui/components/_util.scss b/src/ui/components/_util.scss
new file mode 100644
index 00000000..76e1cdbd
--- /dev/null
+++ b/src/ui/components/_util.scss
@@ -0,0 +1,1 @@
+$mobile-supports: '(-webkit-touch-callout: none)';
diff --git a/src/ui/components/context-menu/context-menu.module.scss b/src/ui/components/context-menu/context-menu.module.scss
index 2aa9e1fe..8289cb21 100644
--- a/src/ui/components/context-menu/context-menu.module.scss
+++ b/src/ui/components/context-menu/context-menu.module.scss
@@ -1,3 +1,5 @@
+@use '../util';
+
 .contextMenu {
 	background-color: var(--last-fm-bg);
 	color: var(--text);
@@ -9,7 +11,7 @@
 		padding: 0.5em 0;
 		position: relative;
 
-		@supports (-webkit-touch-callout: none) {
+		@supports #{util.$mobile-supports} {
 			&.activeDialogButton {
 				background-color: var(--always-last-fm-hover);
 				color: var(--active-text);
@@ -60,7 +62,7 @@
 			margin: 0 auto;
 		}
 
-		@supports not (-webkit-touch-callout: none) {
+		@supports not (#{util.$mobile-supports}) {
 			&:hover {
 				background-color: var(--always-last-fm-hover);
 				color: var(--active-text);
diff --git a/src/ui/components/icons.tsx b/src/ui/components/icons.tsx
index ba295331..6e1815a2 100644
--- a/src/ui/components/icons.tsx
+++ b/src/ui/components/icons.tsx
@@ -1,8 +1,10 @@
-import { JSXElement, createMemo } from 'solid-js';
+import { JSXElement, Show, createMemo } from 'solid-js';
 import styles from './icons.module.scss';
+import popupStyles from '@/ui/popup/popup.module.scss';
+import componentStyles from '@/ui/options/components/components.module.scss';
 
 function createMaterialIcon(Path: () => JSXElement) {
-	const Component = (props: { class?: string }) => {
+	const Component = (props: { class?: string; title?: string }) => {
 		const className = createMemo(() =>
 			props.class
 				? `${styles.materialIcon} ${props.class}`
@@ -19,6 +21,9 @@ function createMaterialIcon(Path: () => JSXElement) {
 				{...props}
 				class={className()}
 			>
+				<Show when={props.title}>
+					<title>{props.title}</title>
+				</Show>
 				<Path />
 			</svg>
 		);
@@ -30,6 +35,81 @@ export const AddOutlined = createMaterialIcon(() => (
 	<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
 ));
 
+export const Album = createMaterialIcon(() => (
+	<>
+		<path d="M0 0h24v24H0z" fill="none" />
+		<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 14.5c-2.49 0-4.5-2.01-4.5-4.5S9.51 7.5 12 7.5s4.5 2.01 4.5 4.5-2.01 4.5-4.5 4.5zm0-5.5c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1z" />
+	</>
+));
+
+export const AlbumOff = createMaterialIcon(() => (
+	<>
+		<defs id="defs138">
+			<mask
+				maskUnits="userSpaceOnUse"
+				id="mask-powermask-path-effect3728"
+			>
+				<path
+					id="mask-powermask-path-effect3728_box"
+					style={{ fill: '#ffffff', 'fill-opacity': '1' }}
+					d="M 1,1 H 23 V 23 H 1 Z"
+				/>
+				<g id="g3726">
+					<rect
+						class={`${popupStyles.applyFill} ${componentStyles.applyFill}`}
+						style={{
+							'fill-opacity': '1',
+							stroke: 'none',
+						}}
+						id="rect3722"
+						width="27.407948"
+						height="1.5774385"
+						x="-12.412281"
+						y="-0.70559257"
+						ry="0"
+						transform="rotate(41.317859)"
+					/>
+					<rect
+						class={`${popupStyles.applyFill} ${componentStyles.applyFill}`}
+						style={{
+							'fill-opacity': '1',
+							stroke: 'none',
+						}}
+						id="rect3724"
+						width="27.407948"
+						height="1.5774385"
+						x="18.354237"
+						y="-0.67120558"
+						ry="0"
+						transform="rotate(41.317859)"
+					/>
+				</g>
+			</mask>
+		</defs>
+		<path
+			d="M 12,2 C 6.48,2 2,6.48 2,12 2,17.52 6.48,22 12,22 17.52,22 22,17.52 22,12 22,6.48 17.52,2 12,2 Z m 0,14.5 C 9.51,16.5 7.5,14.49 7.5,12 7.5,9.51 9.51,7.5 12,7.5 c 2.49,0 4.5,2.01 4.5,4.5 0,2.49 -2.01,4.5 -4.5,4.5 z M 12,11 c -0.55,0 -1,0.45 -1,1 0,0.55 0.45,1 1,1 0.55,0 1,-0.45 1,-1 0,-0.55 -0.45,-1 -1,-1 z"
+			id="path132"
+			class={`${popupStyles.applyFill} ${componentStyles.applyFill}`}
+			style={{ display: 'inline' }}
+			mask="url(#mask-powermask-path-effect3728)"
+		/>
+		<rect
+			class={`${popupStyles.applyFill} ${componentStyles.applyFill}`}
+			style={{
+				'fill-opacity': '1',
+				stroke: 'none',
+			}}
+			id="rect413"
+			width="27.407948"
+			height="1.5774385"
+			x="3.194612"
+			y="0.87523758"
+			ry="0"
+			transform="rotate(41.317859)"
+		/>
+	</>
+));
+
 export const AutorenewOutlined = createMaterialIcon(() => (
 	<path d="M12 6v3l4-4-4-4v3c-4.42 0-8 3.58-8 8 0 1.57.46 3.03 1.24 4.26L6.7 14.8c-.45-.83-.7-1.79-.7-2.8 0-3.31 2.69-6 6-6zm6.76 1.74L17.3 9.2c.44.84.7 1.79.7 2.8 0 3.31-2.69 6-6 6v-3l-4 4 4 4v-3c4.42 0 8-3.58 8-8 0-1.57-.46-3.03-1.24-4.26z" />
 ));
@@ -47,6 +127,10 @@ export const CancelScheduleSendOutlined = createMaterialIcon(() => (
 	</g>
 ));
 
+export const CaseSensitiveOutlined = createMaterialIcon(() => (
+	<path d="M20.06 18a3.99 3.99 0 0 1-.2-.89c-.67.7-1.48 1.05-2.41 1.05c-.83 0-1.52-.24-2.05-.71c-.53-.45-.8-1.06-.8-1.79c0-.88.33-1.56 1-2.05c.67-.49 1.61-.73 2.83-.73h1.4v-.64c0-.49-.15-.88-.45-1.17c-.3-.29-.75-.43-1.33-.43c-.52 0-.95.12-1.3.36c-.35.25-.52.54-.52.89h-1.46c0-.43.15-.84.45-1.24c.28-.4.71-.71 1.22-.94c.51-.21 1.06-.35 1.69-.35c.98 0 1.74.24 2.29.73s.84 1.16.86 2.02V16c0 .8.1 1.42.3 1.88V18zm-2.4-1.12c.45 0 .88-.11 1.29-.32c.4-.21.7-.49.88-.83v-1.57H18.7c-1.77 0-2.66.47-2.66 1.41c0 .43.15.73.46.96c.3.23.68.35 1.16.35m-12.2-3.17h4.07L7.5 8.29zM6.64 6h1.72l4.71 12h-1.93l-.97-2.57H4.82L3.86 18H1.93z" />
+));
+
 export const CheckOutlined = createMaterialIcon(() => (
 	<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" />
 ));
@@ -55,10 +139,6 @@ export const CloseOutlined = createMaterialIcon(() => (
 	<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z" />
 ));
 
-export const CodeOutlined = createMaterialIcon(() => (
-	<path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z" />
-));
-
 export const ContactsOutlined = createMaterialIcon(() => (
 	<path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 14H4V6h16v12zM4 0h16v2H4zm0 22h16v2H4zm8-10c1.38 0 2.5-1.12 2.5-2.5S13.38 7 12 7 9.5 8.12 9.5 9.5 10.62 12 12 12zm0-3.5c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm5 7.49C17 13.9 13.69 13 12 13s-5 .9-5 2.99V17h10v-1.01zm-8.19-.49c.61-.52 2.03-1 3.19-1 1.17 0 2.59.48 3.2 1H8.81z" />
 ));
@@ -67,6 +147,10 @@ export const DeleteOutlined = createMaterialIcon(() => (
 	<path d="M16 9v10H8V9h8m-1.5-6h-5l-1 1H5v2h14V4h-3.5l-1-1zM18 7H6v12c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7z" />
 ));
 
+export const DeleteForeverOutlined = createMaterialIcon(() => (
+	<path d="M14.12 10.47L12 12.59l-2.13-2.12-1.41 1.41L10.59 14l-2.12 2.12 1.41 1.41L12 15.41l2.12 2.12 1.41-1.41L13.41 14l2.12-2.12zM15.5 4l-1-1h-5l-1 1H5v2h14V4zM6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM8 9h8v10H8V9z" />
+));
+
 export const DownloadOutlined = createMaterialIcon(() => (
 	<path d="M19 9h-4V3H9v6H5l7 7 7-7zm-8 2V5h2v6h1.17L12 13.17 9.83 11H11zm-6 7h14v2H5z" />
 ));
@@ -75,6 +159,10 @@ export const EditOutlined = createMaterialIcon(() => (
 	<path d="M14.06 9.02l.92.92L5.92 19H5v-.92l9.06-9.06M17.66 3c-.25 0-.51.1-.7.29l-1.83 1.83 3.75 3.75 1.83-1.83c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29zm-3.6 3.19L3 17.25V21h3.75L17.81 9.94l-3.75-3.75z" />
 ));
 
+export const EditNoteOutlined = createMaterialIcon(() => (
+	<path d="M3 10h11v2H3zm0-2h11V6H3zm0 8h7v-2H3zm15.01-3.13.71-.71c.39-.39 1.02-.39 1.41 0l.71.71c.39.39.39 1.02 0 1.41l-.71.71zm-.71.71-5.3 5.3V21h2.12l5.3-5.3z" />
+));
+
 export const ErrorOutlined = createMaterialIcon(() => (
 	<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
 ));
@@ -140,14 +228,44 @@ export const ManageAccountsOutlined = createMaterialIcon(() => (
 	</>
 ));
 
+export const MusicNote = createMaterialIcon(() => (
+	<>
+		<path d="M0 0h24v24H0z" fill="none" />
+		<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
+	</>
+));
+
 export const MusicNoteOutlined = createMaterialIcon(() => (
 	<path d="M12 3l.01 10.55c-.59-.34-1.27-.55-2-.55C7.79 13 6 14.79 6 17s1.79 4 4.01 4S14 19.21 14 17V7h4V3h-6zm-1.99 16c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z" />
 ));
 
+export const MusicOff = createMaterialIcon(() => (
+	<>
+		<path d="M0 0h24v24H0z" fill="none" />
+		<path d="M4.27 3L3 4.27l9 9v.28c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4v-1.73L19.73 21 21 19.73 4.27 3zM14 7h4V3h-6v5.18l2 2z" />
+	</>
+));
+
 export const MusicOffOutlined = createMaterialIcon(() => (
 	<path d="M14 7h4V3h-6v4.61l2 2zm-2 3.44L4.41 2.86 3 4.27l9 9v.28c-.94-.54-2.1-.75-3.33-.32-1.34.48-2.37 1.67-2.61 3.07-.46 2.74 1.86 5.08 4.59 4.65 1.96-.31 3.35-2.11 3.35-4.1v-1.58L19.73 21l1.41-1.41L12 10.44zM10 19c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z" />
 ));
 
+export const Person = createMaterialIcon(() => (
+	<>
+		<path d="M0 0h24v24H0z" fill="none" />
+		<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
+	</>
+));
+
+export const PersonOff = createMaterialIcon(() => (
+	<>
+		<rect fill="none" height="24" width="24" />
+		<g>
+			<path d="M8.65,5.82C9.36,4.72,10.6,4,12,4c2.21,0,4,1.79,4,4c0,1.4-0.72,2.64-1.82,3.35L8.65,5.82z M20,17.17 c-0.02-1.1-0.63-2.11-1.61-2.62c-0.54-0.28-1.13-0.54-1.77-0.76L20,17.17z M21.19,21.19L2.81,2.81L1.39,4.22l8.89,8.89 c-1.81,0.23-3.39,0.79-4.67,1.45C4.61,15.07,4,16.1,4,17.22V20h13.17l2.61,2.61L21.19,21.19z" />
+		</g>
+	</>
+));
+
 export const PersonOffOutlined = createMaterialIcon(() => (
 	<>
 		<rect fill="none" height="24" width="24" />
@@ -166,6 +284,16 @@ export const PublishedWithChangesOutlined = createMaterialIcon(() => (
 	</>
 ));
 
+export const RegexOutlined = createMaterialIcon(() => (
+	<path d="M16 16.92c-.33.05-.66.08-1 .08c-.34 0-.67-.03-1-.08v-3.51l-2.5 2.48c-.5-.39-1-.89-1.39-1.39l2.48-2.5H9.08c-.05-.33-.08-.66-.08-1c0-.34.03-.67.08-1h3.51l-2.48-2.5c.19-.25.39-.5.65-.74c.24-.26.49-.46.74-.65L14 8.59V5.08c.33-.05.66-.08 1-.08c.34 0 .67.03 1 .08v3.51l2.5-2.48c.5.39 1 .89 1.39 1.39L17.41 10h3.51c.05.33.08.66.08 1c0 .34-.03.67-.08 1h-3.51l2.48 2.5c-.19.25-.39.5-.65.74c-.24.26-.49.46-.74.65L16 13.41zM5 19a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2" />
+));
+
+export const QueueMusicOutlined = createMaterialIcon(() => (
+	<g>
+		<path d="M22,6h-5v8.18C16.69,14.07,16.35,14,16,14c-1.66,0-3,1.34-3,3s1.34,3,3,3s3-1.34,3-3V8h3V6z M15,6H3v2h12V6z M15,10H3v2h12 V10z M11,14H3v2h8V14z" />
+	</g>
+));
+
 export const RestartAltOutlined = createMaterialIcon(() => (
 	<>
 		<g>
@@ -247,3 +375,7 @@ export const VolunteerActivismOutlined = createMaterialIcon(() => (
 		</g>
 	</g>
 ));
+
+export const WholeWordOutlined = createMaterialIcon(() => (
+	<path d="M11.14 4L6.43 16h1.93l.96-2.57h5.35l.97 2.57h1.93L12.86 4M12 6.29l2.03 5.42H9.96M20 14v4H4v-3H2v5h20v-6Z" />
+));
diff --git a/src/ui/options/components/components.module.scss b/src/ui/options/components/components.module.scss
index 08b000ac..30d4280d 100644
--- a/src/ui/options/components/components.module.scss
+++ b/src/ui/options/components/components.module.scss
@@ -90,11 +90,12 @@
 }
 
 .button {
+	$icon-color: var(--always-light);
 	align-items: center;
 	background-color: var(--always-last-fm);
 	border: var(--interactable-border);
 	border-radius: 3px;
-	color: var(--always-light);
+	color: $icon-color;
 	cursor: pointer;
 	display: flex;
 	font-size: 1.2em;
@@ -102,6 +103,10 @@
 	white-space: nowrap;
 	width: min-content;
 
+	.applyFill {
+		fill: $icon-color;
+	}
+
 	svg {
 		margin-right: 0.2em;
 	}
@@ -115,6 +120,12 @@
 		white-space: nowrap;
 	}
 
+	&:disabled {
+		background-color: var(--last-fm-bg);
+		color: var(--muted-text);
+		cursor: default;
+	}
+
 	&.noRadius {
 		border-radius: 0;
 	}
@@ -228,6 +239,65 @@
 	}
 }
 
+.editTable {
+	margin-bottom: 1rem;
+
+	label {
+		padding-right: 1rem;
+	}
+
+	label,
+	input {
+		font-size: 1rem;
+	}
+
+	input {
+		appearance: none;
+		background-color: var(--last-fm-bg-weak);
+		border: none;
+		border-radius: 0.5rem;
+		color: var(--text);
+		padding: 0.25rem 0.5rem;
+		width: 30ch;
+	}
+}
+
+.saveIcon {
+	transform: rotate(0deg);
+
+	&.savingSaveIcon {
+		transform: rotate(360deg);
+		transform-origin: 41% 42.5%;
+		transition: transform 600ms ease-in-out;
+	}
+}
+
+.scrobbleButtonsWrapper {
+	display: flex;
+	flex-wrap: wrap;
+	gap: 1rem;
+}
+
+summary {
+	border: var(--interactable-border);
+	list-style: none;
+
+	&::-webkit-details-marker {
+		display: none;
+	}
+}
+
+.scrobbleCacheList {
+	summary {
+		padding: 0.5rem 0;
+	}
+
+	.summarySpan {
+		font-size: 1.5em;
+		font-weight: bold;
+	}
+}
+
 .checkboxOption {
 	$tick-size: 1.05em;
 	$checkbox-size: 1.4em;
@@ -310,15 +380,6 @@
 	max-width: 50vw;
 }
 
-summary {
-	border: var(--interactable-border);
-	list-style: none;
-
-	&::-webkit-details-marker {
-		display: none;
-	}
-}
-
 .radioButtons {
 	.radioButton {
 		margin-bottom: 0.75em;
@@ -352,7 +413,8 @@ summary {
 	}
 }
 
-.connectorOptionsList {
+.connectorOptionsList,
+.scrobbleCacheList {
 	$animation-duration: 0.2s;
 
 	summary,
@@ -369,6 +431,10 @@ summary {
 
 	summary {
 		cursor: pointer;
+
+		&::-webkit-details-marker {
+			display: none;
+		}
 	}
 
 	.expandVector {
@@ -439,18 +505,6 @@ summary {
 	}
 }
 
-.attentionWindow {
-	background-color: var(--sidebar-bg);
-	border: var(--interactable-border);
-	color: var(--text);
-	font-size: 1.2em;
-	line-height: 1.25;
-	margin: 0 auto;
-	padding: 1em;
-	text-align: center;
-	width: 80%;
-}
-
 .arrayPropWrapper {
 	margin-bottom: 1rem;
 	margin-top: 0.5rem;
@@ -479,14 +533,26 @@ summary {
 		'track tracksearch trackreplace' 2em
 		'artist artistsearch artistreplace' 2em
 		'album albumsearch albumreplace' 2em
-		'albumartist albumartistsearch albumartistreplace' 2em / auto 1fr 1fr;
-	margin: 0.75em 0;
+		'albumartist albumartistsearch albumartistreplace' 2em
+		'flags flags flags' 2em / auto 1fr 1fr;
+	margin-bottom: 1.5em;
+	margin-top: 0.75em;
 	padding: 0.25em 1em;
 	width: 100%;
 
+	.regexEditEntry {
+		border: var(--interactable-border);
+	}
+
 	/**
 	 * Set grid areas
 	 */
+	.regexDeleteFlags {
+		display: flex;
+		gap: 0.5em;
+		grid-area: flags;
+	}
+
 	.regexDeleteSearchLabel {
 		grid-area: search;
 	}
@@ -544,13 +610,137 @@ summary {
 	}
 }
 
-.saveIcon {
-	transform: rotate(0deg);
+.scrobble {
+	align-items: center;
+	background-color: var(--last-fm-bg-weak);
+	border: var(--interactable-border);
+	border-radius: 1em;
+	box-shadow: var(--neuomorphic-small-bottom), var(--neuomorphic-small-top);
+	display: flex;
+	gap: 0.5em;
+	height: 7em;
+	margin-bottom: 1em;
+	overflow: hidden;
+	padding-right: 1em;
 
-	&.savingSaveIcon {
-		transform: rotate(360deg);
-		transform-origin: 41% 42.5%;
-		transition: transform 600ms ease-in-out;
+	.scrobbleCheckbox {
+		border: none;
+	}
+
+	.coverArt {
+		height: 7em;
+		object-fit: cover;
+		width: 7em;
+	}
+
+	.scrobbleDetails {
+		display: flex;
+		flex-direction: column;
+		flex-grow: 1;
+		flex-shrink: 1;
+		justify-content: space-between;
+		min-height: 6em;
+		overflow: hidden;
+
+		.scrobbleMetadata {
+			display: flex;
+			flex-direction: column;
+
+			span {
+				white-space: nowrap;
+			}
+
+			.trackName {
+				font-size: 1rem;
+				font-weight: bold;
+			}
+
+			.artistName,
+			.albumName,
+			.albumArtistName {
+				font-size: 0.7rem;
+			}
+		}
+
+		.scrobbleActions {
+			display: flex;
+			gap: 0.5rem;
+
+			.scrobbleActionButton {
+				background-color: var(--always-last-fm);
+				border: var(--interactable-border);
+				border-radius: 3px;
+				color: var(--always-light);
+				cursor: pointer;
+
+				&:hover {
+					background-color: var(--always-last-fm-hover);
+					color: var(--active-text);
+				}
+
+				&:disabled {
+					background-color: var(--last-fm-bg);
+					color: var(--text);
+				}
+			}
+		}
+	}
+
+	&:has(:checked) {
+		background-color: var(--always-last-fm);
+		color: var(--active-text);
+
+		.scrobbleDetails .scrobbleActions .scrobbleActionButton {
+			background-color: var(--last-fm-bg-light);
+			color: var(--always-last-fm);
+		}
+	}
+
+	&.selectingScrobble {
+		cursor: pointer;
+
+		&:hover:not(:has(:checked)) {
+			background-color: var(--last-fm-bg);
+			color: var(--text);
+
+			.scrobbleDetails .scrobbleActions .scrobbleActionButton {
+				background-color: var(--always-last-fm);
+				color: var(--active-text);
+			}
+		}
+	}
+
+	@media only screen and (width <= 500px) {
+		.scrobbleDetails .scrobbleMetadata {
+			.trackName {
+				font-size: 0.8rem;
+			}
+
+			.artistName,
+			.albumName,
+			.albumArtistName {
+				font-size: 0.6rem;
+			}
+		}
+
+		.scrobbleDate {
+			font-size: 0.8rem;
+		}
+	}
+
+	@media only screen and (width <= 400px) {
+		border-radius: 0;
+		box-sizing: border-box;
+		margin-left: -1rem;
+		width: 100vw;
+	}
+
+	@media only screen and (width <= 300px) {
+		padding-right: 0.5rem;
+
+		.scrobbleDate {
+			font-size: 0.7rem;
+		}
 	}
 }
 
diff --git a/src/ui/options/components/connector-override.tsx b/src/ui/options/components/connector-override.tsx
index 11cb04f9..754e2598 100644
--- a/src/ui/options/components/connector-override.tsx
+++ b/src/ui/options/components/connector-override.tsx
@@ -252,6 +252,16 @@ function ConnectorOverrideOptionDetails(props: {
 					setOverrideOptions={setOverrideOptions}
 					connectorOverrideOptions={connectorOverrideOptions}
 				/>
+				<ConnectorTripleCheckbox
+					title={t('optionAutoToggleLoveTitle')}
+					label={t('optionAutoToggleLove')}
+					connector={props.connector}
+					option={Options.AUTO_TOGGLE_LOVE}
+					overrideOptions={overrideOptions}
+					setOverrideOptions={setOverrideOptions}
+					connectorOverrideOptions={connectorOverrideOptions}
+				/>
+
 				<h3 id={`${props.connector.id}-scrobble-behavior`}>
 					{t('optionsScrobbleBehavior')}
 				</h3>
diff --git a/src/ui/options/components/edit-options/blocked-tags.tsx b/src/ui/options/components/edit-options/blocked-tags.tsx
new file mode 100644
index 00000000..174a7fbc
--- /dev/null
+++ b/src/ui/options/components/edit-options/blocked-tags.tsx
@@ -0,0 +1,155 @@
+import { t } from '@/util/i18n';
+import { For, Setter, createMemo, createResource } from 'solid-js';
+import * as BrowserStorage from '@/core/storage/browser-storage';
+import styles from '../components.module.scss';
+import { DeleteOutlined } from '@/ui/components/icons';
+import { ExportEdits, ImportEdits, ViewEdits } from './util';
+import { ModalType } from '../navigator';
+import { BlockedTags, BlockedTagsReference } from '@/core/storage/wrapper';
+import { Dynamic } from 'solid-js/web';
+
+const blocklist = BrowserStorage.getStorage(BrowserStorage.BLOCKED_TAGS);
+
+/**
+ * Component that allows the user to see, import, and export track metadata edits.
+ */
+export default function BlockedTagsElement(props: {
+	setActiveModal: Setter<ModalType>;
+	modal: HTMLDialogElement | undefined;
+}) {
+	return (
+		<>
+			<h2>{t('optionsBlockedTags')}</h2>
+			<p>{t('optionsBlockedTagsDesc')}</p>
+			<div class={styles.buttonContainer}>
+				<ViewEdits
+					setActiveModal={props.setActiveModal}
+					modal={props.modal}
+					type={'blockedTags'}
+				/>
+				<ExportEdits
+					editWrapper={blocklist}
+					filename="blocked-tags.json"
+				/>
+				<ImportEdits editWrapper={blocklist} />
+			</div>
+		</>
+	);
+}
+
+/**
+ * Component that shows all the currently blocked tags and allows the user to unblock them.
+ * To be displayed in a modal.
+ */
+export function BlockedTagsModal() {
+	const [edits, { mutate }] = createResource(blocklist.get.bind(blocklist));
+	const formattedEdits = createMemo<BlockedTagsReference[]>(() => {
+		const rawEdits = edits();
+		const res: BlockedTagsReference[] = [];
+		if (!rawEdits) {
+			return res;
+		}
+		for (const [artist, value] of Object.entries(rawEdits)) {
+			if (value.disabled) {
+				res.push({
+					type: 'artist',
+					artist,
+				});
+			}
+			for (const album of Object.keys(value.albums)) {
+				res.push({
+					type: 'album',
+					artist,
+					album,
+				});
+			}
+			for (const track of Object.keys(value.tracks)) {
+				res.push({
+					type: 'track',
+					artist,
+					track,
+				});
+			}
+		}
+		return res;
+	});
+	return (
+		<>
+			<h1>
+				{t(
+					'optionsBlockedTagsPopupTitle',
+					formattedEdits().length.toString(),
+				)}
+			</h1>
+			<ul class={styles.optionList}>
+				<For each={formattedEdits()}>
+					{(blockedTag) => (
+						<BlockedTagInfo tag={blockedTag} mutate={mutate} />
+					)}
+				</For>
+			</ul>
+		</>
+	);
+}
+
+function getTagSpan(tag: BlockedTagsReference) {
+	switch (tag.type) {
+		case 'artist': {
+			return (
+				<span>
+					{t('infoArtistLabel')}: {tag.artist}
+				</span>
+			);
+		}
+		case 'album': {
+			return (
+				<span>
+					{t('infoAlbumLabel')}: {tag.artist} - {tag.album}
+				</span>
+			);
+		}
+		case 'track': {
+			return (
+				<span>
+					{t('infoTrackLabel')}: {tag.artist} - {tag.track}
+				</span>
+			);
+		}
+	}
+}
+
+function BlockedTagInfo(props: {
+	tag: BlockedTagsReference;
+	mutate: Setter<BlockedTags | null | undefined>;
+}) {
+	return (
+		<li class={styles.deleteListing}>
+			<button
+				class={`${styles.button} ${styles.small} ${styles.marginRight}`}
+				onClick={(event) => {
+					event.stopPropagation();
+					const tag = props.tag;
+					props.mutate((e) => {
+						if (!e) {
+							return e;
+						}
+						if (tag.type === 'artist') {
+							delete e[tag.artist].disabled;
+						} else if (tag.type === 'album') {
+							delete e[tag.artist].albums[tag.album];
+						} else {
+							delete e[tag.artist].tracks[tag.track];
+						}
+						blocklist.set(e);
+						return {
+							...e,
+						};
+					});
+				}}
+			>
+				<DeleteOutlined />
+			</button>
+			<Dynamic component={() => getTagSpan(props.tag)} />
+		</li>
+	);
+}
diff --git a/src/ui/options/components/edit-options/edit-options.tsx b/src/ui/options/components/edit-options/edit-options.tsx
index ca4e0807..2724cc83 100644
--- a/src/ui/options/components/edit-options/edit-options.tsx
+++ b/src/ui/options/components/edit-options/edit-options.tsx
@@ -3,6 +3,7 @@ import EditedTracks from './edited-tracks';
 import RegexEdits from './regex-edits';
 import { t } from '@/util/i18n';
 import { ModalType } from '../navigator';
+import BlockedTagsElement from './blocked-tags';
 
 export default function EditOptions(props: {
 	setActiveModal: Setter<ModalType>;
@@ -19,6 +20,10 @@ export default function EditOptions(props: {
 				setActiveModal={props.setActiveModal}
 				modal={props.modal}
 			/>
+			<BlockedTagsElement
+				setActiveModal={props.setActiveModal}
+				modal={props.modal}
+			/>
 		</>
 	);
 }
diff --git a/src/ui/options/components/edit-options/edited-tracks.tsx b/src/ui/options/components/edit-options/edited-tracks.tsx
index 43e18b77..2940171d 100644
--- a/src/ui/options/components/edit-options/edited-tracks.tsx
+++ b/src/ui/options/components/edit-options/edited-tracks.tsx
@@ -1,14 +1,13 @@
 import { t } from '@/util/i18n';
 import { For, Setter, createMemo, createResource } from 'solid-js';
 import * as Options from '@/core/storage/options';
-import * as BrowserStorage from '@/core/storage/browser-storage';
 import styles from '../components.module.scss';
 import { DeleteOutlined } from '@/ui/components/icons';
 import { ExportEdits, ImportEdits, ViewEdits } from './util';
 import { ModalType } from '../navigator';
+import * as BrowserStorage from '@/core/storage/browser-storage';
 
 const localCache = BrowserStorage.getStorage(BrowserStorage.LOCAL_CACHE);
-const [edits, { mutate }] = createResource(localCache.get.bind(localCache));
 
 /**
  * Component that allows the user to see, import, and export track metadata edits.
@@ -35,7 +34,7 @@ export default function EditedTracks(props: {
 					editWrapper={localCache}
 					filename="local-cache.json"
 				/>
-				<ImportEdits editWrapper={localCache} mutate={mutate} />
+				<ImportEdits editWrapper={localCache} />
 			</div>
 		</>
 	);
@@ -46,6 +45,7 @@ export default function EditedTracks(props: {
  * To be displayed in a modal.
  */
 export function EditsModal() {
+	const [edits, { mutate }] = createResource(localCache.get.bind(localCache));
 	return (
 		<>
 			<h1>
diff --git a/src/ui/options/components/edit-options/regex-edits.tsx b/src/ui/options/components/edit-options/regex-edits.tsx
index 8e700b97..f85652de 100644
--- a/src/ui/options/components/edit-options/regex-edits.tsx
+++ b/src/ui/options/components/edit-options/regex-edits.tsx
@@ -1,14 +1,18 @@
 import { t } from '@/util/i18n';
-import { For, Setter, createMemo, createResource } from 'solid-js';
+import { For, Setter, Show, createMemo, createResource } from 'solid-js';
 import * as BrowserStorage from '@/core/storage/browser-storage';
 import styles from '../components.module.scss';
-import { DeleteOutlined } from '@/ui/components/icons';
+import {
+	CaseSensitiveOutlined,
+	DeleteOutlined,
+	RegexOutlined,
+	WholeWordOutlined,
+} from '@/ui/components/icons';
 import { FieldType, RegexEdit, pascalCaseField } from '@/util/regex';
 import { ExportEdits, ImportEdits, ViewEdits } from './util';
 import { ModalType } from '../navigator';
 
 const regexEdits = BrowserStorage.getStorage(BrowserStorage.REGEX_EDITS);
-const [edits, { mutate }] = createResource(regexEdits.get.bind(regexEdits));
 
 /**
  * Component that allows the user to see, import, and export track metadata edits.
@@ -35,7 +39,7 @@ export default function RegexEdits(props: {
 					editWrapper={regexEdits}
 					filename="regex-edits.json"
 				/>
-				<ImportEdits editWrapper={regexEdits} mutate={mutate} />
+				<ImportEdits editWrapper={regexEdits} />
 			</div>
 		</>
 	);
@@ -46,6 +50,7 @@ export default function RegexEdits(props: {
  * To be displayed in a modal.
  */
 export function RegexEditsModal() {
+	const [edits, { mutate }] = createResource(regexEdits.get.bind(regexEdits));
 	return (
 		<>
 			<h1>
@@ -108,11 +113,40 @@ function EditInfo(props: {
 				<Entry edit={props.edit} type={'artist'} />
 				<Entry edit={props.edit} type={'album'} />
 				<Entry edit={props.edit} type={'albumArtist'} />
+				<Flags edit={props.edit} />
 			</div>
 		</li>
 	);
 }
 
+/**
+ * Component that returns the grid entry for regex flags
+ */
+function Flags(props: { edit: RegexEdit }) {
+	return (
+		<div class={styles.regexDeleteFlags}>
+			<Show when={!props.edit.isRegexDisabled}>
+				<RegexOutlined
+					title={t('infoUseRegex')}
+					class={styles.regexEditEntry}
+				/>
+			</Show>
+			<Show when={!props.edit.isCaseInsensitive}>
+				<CaseSensitiveOutlined
+					title={t('infoMatchCase')}
+					class={styles.regexEditEntry}
+				/>
+			</Show>
+			<Show when={!props.edit.isGlobal}>
+				<WholeWordOutlined
+					title={t('infoMatchWholeTag')}
+					class={styles.regexEditEntry}
+				/>
+			</Show>
+		</div>
+	);
+}
+
 /**
  * Component that returns the grid entries for a single field of regex search/replace
  */
@@ -120,21 +154,23 @@ function Entry(props: { edit: RegexEdit; type: FieldType }) {
 	return (
 		<>
 			<span
-				class={styles[`regexDelete${pascalCaseField(props.type)}Label`]}
+				class={`${
+					styles[`regexDelete${pascalCaseField(props.type)}Label`]
+				} ${styles.regexEditEntry}`}
 			>
 				{t(`info${pascalCaseField(props.type)}Label`)}
 			</span>
 			<span
-				class={
+				class={`${
 					styles[`regexDelete${pascalCaseField(props.type)}Search`]
-				}
+				} ${styles.regexEditEntry}`}
 			>
 				{props.edit.search[props.type]}
 			</span>
 			<span
-				class={
+				class={`${
 					styles[`regexDelete${pascalCaseField(props.type)}Replace`]
-				}
+				} ${styles.regexEditEntry}`}
 			>
 				{props.edit.replace[props.type]}
 			</span>
diff --git a/src/ui/options/components/edit-options/util.tsx b/src/ui/options/components/edit-options/util.tsx
index 4d55525e..81e88eba 100644
--- a/src/ui/options/components/edit-options/util.tsx
+++ b/src/ui/options/components/edit-options/util.tsx
@@ -1,4 +1,7 @@
-import StorageWrapper, { Blocklists } from '@/core/storage/wrapper';
+import StorageWrapper, {
+	BlockedTags,
+	Blocklists,
+} from '@/core/storage/wrapper';
 import * as Options from '@/core/storage/options';
 import * as BrowserStorage from '@/core/storage/browser-storage';
 import {
@@ -14,13 +17,16 @@ import { ModalType } from '../navigator';
 import { ConnectorMeta } from '@/core/connectors';
 
 type EditWrapper = StorageWrapper<
-	typeof BrowserStorage.REGEX_EDITS | typeof BrowserStorage.LOCAL_CACHE
+	| typeof BrowserStorage.REGEX_EDITS
+	| typeof BrowserStorage.LOCAL_CACHE
+	| typeof BrowserStorage.BLOCKED_TAGS
 >;
 type EditSetter = Setter<
 	| {
 			[key: string]: Options.SavedEdit;
 	  }
 	| RegexEdit[]
+	| BlockedTags
 	| null
 	| undefined
 >;
@@ -191,10 +197,7 @@ export function ImportBlocklist(props: {
 /**
  * Button that allows the user to upload a .json file and get edits from it.
  */
-export function ImportEdits(props: {
-	editWrapper: EditWrapper;
-	mutate: EditSetter;
-}) {
+export function ImportEdits(props: { editWrapper: EditWrapper }) {
 	const [ref, setRef] = createSignal<HTMLInputElement>();
 	return (
 		<button
@@ -208,7 +211,7 @@ export function ImportEdits(props: {
 				ref={setRef}
 				type="file"
 				accept=".json"
-				onChange={(e) => pushEdits(e, props.editWrapper, props.mutate)}
+				onChange={(e) => pushEdits(e, props.editWrapper)}
 			/>
 		</button>
 	);
@@ -223,7 +226,6 @@ function pushEdits(
 		target: Element;
 	},
 	editWrapper: EditWrapper,
-	mutate: EditSetter,
 ) {
 	const file = e.currentTarget.files?.[0];
 	if (!file) {
@@ -234,24 +236,82 @@ function pushEdits(
 		'load',
 		(arg) =>
 			void (async (e) => {
-				const edits = JSON.parse(
-					e.target?.result as string,
-				) as RegexEdit[];
+				const edits = JSON.parse(e.target?.result as string) as
+					| RegexEdit[]
+					| BlockedTags
+					| Record<string, Options.SavedEdit>;
+
 				const oldEdits = await editWrapper.get();
-				const newEdits =
-					oldEdits instanceof Array
-						? [...(oldEdits ?? []), ...edits]
-						: {
-								...oldEdits,
-								...edits,
-							};
-				editWrapper.set(newEdits);
-				mutate(newEdits);
+
+				if (oldEdits instanceof Array && edits instanceof Array) {
+					const newEdits = [...(oldEdits ?? []), ...edits];
+					editWrapper.set(newEdits);
+				} else if (isSavedEdits(oldEdits) && isSavedEdits(edits)) {
+					const newEdits = { ...(oldEdits || {}), ...edits };
+					editWrapper.set(newEdits);
+				} else if (isBlockedTags(oldEdits) && isBlockedTags(edits)) {
+					const newEdits = { ...oldEdits };
+					for (const [artist, value] of Object.entries(edits)) {
+						if (!(artist in newEdits)) {
+							newEdits[artist] = value;
+							continue;
+						}
+						newEdits[artist].albums = {
+							...newEdits[artist].albums,
+							...edits[artist].albums,
+						};
+						newEdits[artist].tracks = {
+							...newEdits[artist].tracks,
+							...edits[artist].tracks,
+						};
+						if (
+							newEdits[artist].disabled ||
+							edits[artist].disabled
+						) {
+							newEdits[artist].disabled = true;
+						}
+					}
+					editWrapper.set(newEdits);
+				}
 			})(arg),
 	);
 	reader.readAsText(file);
 }
 
+function isSavedEdits(
+	edits: BlockedTags | Record<string, Options.SavedEdit> | RegexEdit[] | null,
+): edits is Record<string, Options.SavedEdit> {
+	if (edits === null || edits instanceof Array) {
+		return false;
+	}
+	const keys = Object.keys(edits);
+	// if no keys we cannot know which it is but it also doesn't matter, just allow it to be both, it won't error
+	if (keys.length === 0) {
+		return true;
+	}
+	if ('artist' in edits[keys[0]]) {
+		return true;
+	}
+	return false;
+}
+
+function isBlockedTags(
+	edits: BlockedTags | Record<string, Options.SavedEdit> | RegexEdit[] | null,
+): edits is BlockedTags {
+	if (edits === null || edits instanceof Array) {
+		return false;
+	}
+	const keys = Object.keys(edits);
+	// if no keys we cannot know which it is but it also doesn't matter, just allow it to be both, it won't error
+	if (keys.length === 0) {
+		return true;
+	}
+	if ('albums' in edits[keys[0]]) {
+		return true;
+	}
+	return false;
+}
+
 /**
  * Reads an imported .json file and import the blocklist to storage
  */
diff --git a/src/ui/options/components/navigator.ts b/src/ui/options/components/navigator.ts
index 4be79002..31c91fbb 100644
--- a/src/ui/options/components/navigator.ts
+++ b/src/ui/options/components/navigator.ts
@@ -14,10 +14,11 @@ import {
 	FavoriteOutlined,
 	PersonOutlined,
 	PersonOffOutlined,
+	QueueMusicOutlined,
 } from '@/ui/components/icons';
 
 import InfoComponent from '@/ui/options/components/info';
-import ShowSomeLove from '@/ui/options/components/showSomeLove';
+import ShowSomeLove from '@/ui/options/components/show-some-love';
 import FAQ from '@/ui/options/components/faq';
 import ContactComponent from '@/ui/options/components/contact';
 import OptionsComponent from '@/ui/options/components/options/options';
@@ -36,12 +37,19 @@ import {
 	removeFromBlocklist,
 } from '@/core/background/util';
 import * as ControllerMode from '@/core/object/controller/controller-mode';
+import ScrobbleCache from './scrobble-cache';
 import AdvancedOptionsComponent from './advanced-settings';
 
 /**
  * Type indicating possible states for modal
  */
-export type ModalType = 'savedEdits' | 'regexEdits' | 'blocklist' | '';
+export type ModalType =
+	| 'savedEdits'
+	| 'regexEdits'
+	| 'cacheEdit'
+	| 'blockedTags'
+	| 'blocklist'
+	| '';
 
 /**
  * Mutual base button
@@ -95,6 +103,12 @@ export const optionsItem: NavigatorNavigationButton = {
 	element: OptionsComponent,
 };
 
+export const scrobbleCache: NavigatorNavigationButton = {
+	namei18n: 'optionsScrobbleCache',
+	icon: QueueMusicOutlined,
+	element: ScrobbleCache,
+};
+
 export const editOptionsItem: NavigatorNavigationButton = {
 	namei18n: 'optionsEdits',
 	icon: EditOutlined,
@@ -118,6 +132,7 @@ export const optionsGroup: NavigatorButtonGroup = {
 	icon: SettingsOutlined,
 	group: [
 		optionsItem,
+		scrobbleCache,
 		editOptionsItem,
 		connectorOverrideOptionsItem,
 		advancedOptionsItem,
diff --git a/src/ui/options/components/options/global-options.tsx b/src/ui/options/components/options/global-options.tsx
index c7ac84b4..bc6ce00d 100644
--- a/src/ui/options/components/options/global-options.tsx
+++ b/src/ui/options/components/options/global-options.tsx
@@ -65,6 +65,14 @@ export default function GlobalOptionsList(props: {
 					i18nlabel="optionScrobblePodcasts"
 					key={Options.SCROBBLE_PODCASTS}
 				/>
+				<GlobalOptionEntry
+					options={props.options}
+					setOptions={props.setOptions}
+					globalOptions={globalOptions}
+					i18ntitle="optionAutoToggleLoveTitle"
+					i18nlabel="optionAutoToggleLove"
+					key={Options.AUTO_TOGGLE_LOVE}
+				/>
 			</ul>
 		</>
 	);
diff --git a/src/ui/options/components/scrobble-cache.tsx b/src/ui/options/components/scrobble-cache.tsx
new file mode 100644
index 00000000..ad616a77
--- /dev/null
+++ b/src/ui/options/components/scrobble-cache.tsx
@@ -0,0 +1,661 @@
+import scrobbleCache from '@/core/storage/scrobble-cache';
+import { CacheScrobble, ScrobbleStatus } from '@/core/storage/wrapper';
+import { t } from '@/util/i18n';
+import {
+	For,
+	Setter,
+	Show,
+	Suspense,
+	createMemo,
+	createResource,
+	createSignal,
+	onCleanup,
+	onMount,
+} from 'solid-js';
+import styles from './components.module.scss';
+import { ExpandMoreOutlined } from '@/ui/components/icons';
+import ClonedSong from '@/core/object/cloned-song';
+import browser from 'webextension-polyfill';
+import { sendContentMessage } from '@/util/communication';
+import { ModalType } from './navigator';
+import savedEdits from '@/core/storage/saved-edits';
+import { CloneableSong } from '@/core/object/song';
+import { debugLog } from '@/core/content/util';
+import SavedEditsModel from '@/core/storage/saved-edits.model';
+
+const [scrobbles, setScrobbles] = createResource(
+	scrobbleCache.getScrobbleCacheStorage.bind(scrobbleCache),
+);
+
+const [songToEdit, setSongToEdit] = createSignal<ClonedSong>();
+const [scrobbleId, setScrobbleId] = createSignal<number>();
+const [isSelectingScrobbles, setSelectingScrobbles] = createSignal(false);
+const [selectedScrobbles, setSelectedScrobbles] = createSignal<CacheScrobble[]>(
+	[],
+);
+const [isScrobblingMultiple, setScrobblingMultiple] = createSignal(false);
+
+export default function ScrobbleCache(props: {
+	setActiveModal: Setter<ModalType>;
+	modal: HTMLDialogElement | undefined;
+}) {
+	return (
+		<>
+			<h1>{t('optionsScrobbleCache')}</h1>
+			<div class={styles.scrobbleButtonsWrapper}>
+				<button
+					class={styles.button}
+					onClick={() => {
+						const buttons = document.querySelectorAll(
+							`.${styles.scrobbleCheckbox}`,
+						);
+						if (selectedScrobbles().length) {
+							buttons.forEach((checkbox) => {
+								(checkbox as HTMLInputElement).checked = false;
+							});
+							setSelectedScrobbles([]);
+						} else {
+							setSelectingScrobbles(true);
+							buttons.forEach((checkbox) => {
+								(checkbox as HTMLInputElement).checked = true;
+							});
+							setSelectedScrobbles(
+								(scrobbles() ?? []).filter(
+									(scrobble) =>
+										scrobble.status !==
+										ScrobbleStatus.SUCCESSFUL,
+								),
+							);
+						}
+					}}
+				>
+					{selectedScrobbles().length
+						? t('optionsScrobbleCacheUnselectAll')
+						: t('optionsScrobbleCacheSelectAll')}
+				</button>
+				<button
+					class={styles.button}
+					onClick={() =>
+						setSelectingScrobbles((prev) => {
+							const isSelecting = !prev;
+							if (!isSelecting) {
+								document
+									.querySelectorAll(
+										`.${styles.scrobbleCheckbox}`,
+									)
+									.forEach((checkbox) => {
+										(checkbox as HTMLInputElement).checked =
+											false;
+									});
+								setSelectedScrobbles([]);
+							}
+							return isSelecting;
+						})
+					}
+				>
+					{t('optionsScrobbleCacheSelectScrobbles')}
+				</button>
+				<Show when={isSelectingScrobbles()}>
+					<button
+						class={styles.button}
+						disabled={
+							isScrobblingMultiple() ||
+							selectedScrobbles().length === 0
+						}
+						onClick={() => {
+							setScrobblingMultiple(true);
+							void scrobbleMultipleFromCache(
+								selectedScrobbles(),
+							).then(() => {
+								setScrobbles.refetch();
+								setSelectedScrobbles([]);
+								setSelectingScrobbles(false);
+								setScrobblingMultiple(false);
+							});
+						}}
+					>
+						{t('optionsScrobbleCacheScrobbleSelected')}
+					</button>
+				</Show>
+			</div>
+			<div class={styles.scrobbleCacheList}>
+				<Suspense fallback={t('optionsScrobbleCacheLoading')}>
+					<ScrobbleList
+						title="optionsScrobbleCacheUnrecognized"
+						type={ScrobbleStatus.INVALID}
+						setActiveModal={props.setActiveModal}
+						modal={props.modal}
+					/>
+					<ScrobbleList
+						title="optionsScrobbleCacheDisallowed"
+						type={ScrobbleStatus.DISALLOWED}
+						setActiveModal={props.setActiveModal}
+						modal={props.modal}
+					/>
+					<ScrobbleList
+						title="optionsScrobbleCacheIgnored"
+						type={ScrobbleStatus.IGNORED}
+						setActiveModal={props.setActiveModal}
+						modal={props.modal}
+					/>
+					<ScrobbleList
+						title="optionsScrobbleCacheError"
+						type={ScrobbleStatus.ERROR}
+						setActiveModal={props.setActiveModal}
+						modal={props.modal}
+					/>
+					<ScrobbleList
+						title="optionsScrobbleCacheSuccess"
+						type={ScrobbleStatus.SUCCESSFUL}
+						setActiveModal={props.setActiveModal}
+						modal={props.modal}
+					/>
+				</Suspense>
+			</div>
+		</>
+	);
+}
+
+export function CacheEditModal() {
+	const [track, setTrack] = createSignal(songToEdit()?.getTrack() ?? '');
+	const [artist, setArtist] = createSignal(songToEdit()?.getArtist() ?? '');
+	const [album, setAlbum] = createSignal(songToEdit()?.getAlbum() ?? '');
+	const [albumArtist, setAlbumArtist] = createSignal(
+		songToEdit()?.getAlbumArtist() ?? '',
+	);
+	const [isLoading, setLoading] = createSignal(false);
+
+	return (
+		<>
+			<h1>{t('infoEditTitle')}</h1>
+			<table class={styles.editTable}>
+				<tbody>
+					<tr>
+						<td>
+							<label for="scrobble-cache-edit-track">
+								{t('infoTrackPlaceholder')}
+							</label>
+						</td>
+						<td>
+							<input
+								type="text"
+								value={track()}
+								onInput={(e) => setTrack(e.currentTarget.value)}
+								id="scrobble-cache-edit-track"
+								onKeyDown={(e) => {
+									saveEditsWithKeyboard(
+										e,
+										artist(),
+										track(),
+										album(),
+										albumArtist(),
+										setLoading,
+									);
+								}}
+							/>
+						</td>
+					</tr>
+					<tr>
+						<td>
+							<label for="scrobble-cache-edit-artist">
+								{t('infoArtistPlaceholder')}
+							</label>
+						</td>
+						<td>
+							<input
+								type="text"
+								value={artist()}
+								onInput={(e) =>
+									setArtist(e.currentTarget.value)
+								}
+								id="scrobble-cache-edit-artist"
+								onKeyDown={(e) => {
+									saveEditsWithKeyboard(
+										e,
+										artist(),
+										track(),
+										album(),
+										albumArtist(),
+										setLoading,
+									);
+								}}
+							/>
+						</td>
+					</tr>
+					<tr>
+						<td>
+							<label for="scrobble-cache-edit-album">
+								{t('infoAlbumPlaceholder')}
+							</label>
+						</td>
+						<td>
+							<input
+								type="text"
+								value={album()}
+								onInput={(e) => setAlbum(e.currentTarget.value)}
+								id="scrobble-cache-edit-album"
+								onKeyDown={(e) => {
+									saveEditsWithKeyboard(
+										e,
+										artist(),
+										track(),
+										album(),
+										albumArtist(),
+										setLoading,
+									);
+								}}
+							/>
+						</td>
+					</tr>
+					<tr>
+						<td>
+							<label for="scrobble-cache-edit-album-artist">
+								{t('infoAlbumArtistPlaceholder')}
+							</label>
+						</td>
+						<td>
+							<input
+								type="text"
+								value={albumArtist()}
+								onInput={(e) =>
+									setAlbumArtist(e.currentTarget.value)
+								}
+								id="scrobble-cache-edit-album-artist"
+								onKeyDown={(e) => {
+									saveEditsWithKeyboard(
+										e,
+										artist(),
+										track(),
+										album(),
+										albumArtist(),
+										setLoading,
+									);
+								}}
+							/>
+						</td>
+					</tr>
+				</tbody>
+			</table>
+			<input id="should-edit-multiple" type="checkbox" checked={true} />
+			<label for="should-edit-multiple">
+				{t('optionsScrobbleCacheEditAll')}
+			</label>
+			<button
+				disabled={!track() || !artist() || isLoading()}
+				class={styles.button}
+				onClick={(e) => {
+					saveEdits(
+						e,
+						artist(),
+						track(),
+						album(),
+						albumArtist(),
+						setLoading,
+					);
+				}}
+			>
+				{t('buttonScrobble')}
+			</button>
+		</>
+	);
+}
+
+function timeSince(time: number, isShort: boolean) {
+	const seconds = (time - Date.now()) / 1000;
+	let interval = seconds / 31_536_000;
+	const formatter = new Intl.RelativeTimeFormat(
+		Intl.DateTimeFormat().resolvedOptions().locale,
+		{ style: isShort ? 'short' : 'long' },
+	);
+	if (Math.abs(interval) > 1) {
+		return formatter.format(Math.floor(interval), 'year');
+	}
+	interval = seconds / 2_629_744;
+	if (Math.abs(interval) > 1) {
+		return formatter.format(Math.floor(interval), 'month');
+	}
+	interval = seconds / 604_800;
+	if (Math.abs(interval) > 1) {
+		return formatter.format(Math.floor(interval), 'week');
+	}
+	interval = seconds / 86400;
+	if (Math.abs(interval) > 1) {
+		return formatter.format(Math.floor(interval), 'day');
+	}
+	interval = seconds / 3600;
+	if (Math.abs(interval) > 1) {
+		return formatter.format(Math.floor(interval), 'hour');
+	}
+	interval = seconds / 60;
+	if (Math.abs(interval) > 1) {
+		return formatter.format(Math.floor(interval), 'minute');
+	}
+	return formatter.format(Math.floor(seconds), 'second');
+}
+
+function ScrobbleList(props: {
+	title: string;
+	type: ScrobbleStatus;
+	setActiveModal: Setter<ModalType>;
+	modal: HTMLDialogElement | undefined;
+}) {
+	const filteredScrobbles = createMemo(
+		() =>
+			scrobbles()
+				?.slice()
+				.reverse()
+				.filter((e) => e.status === props.type),
+	);
+
+	return (
+		<Show when={filteredScrobbles()?.length}>
+			<details>
+				<summary>
+					<ExpandMoreOutlined class={styles.expandVector} />
+					<span class={styles.summarySpan}>{t(props.title)}</span>
+				</summary>
+				<For each={filteredScrobbles()}>
+					{(scrobble) => {
+						return (
+							<ScrobbleDetailsWrapper
+								scrobble={scrobble}
+								type={props.type}
+								title={props.title}
+								setActiveModal={props.setActiveModal}
+								modal={props.modal}
+							/>
+						);
+					}}
+				</For>
+			</details>
+		</Show>
+	);
+}
+
+function ScrobbleDetailsWrapper(props: {
+	scrobble: CacheScrobble;
+	type: ScrobbleStatus;
+	title: string;
+	setActiveModal: Setter<ModalType>;
+	modal: HTMLDialogElement | undefined;
+}) {
+	return (
+		<Show
+			when={props.type === ScrobbleStatus.SUCCESSFUL}
+			fallback={
+				<label
+					class={`${styles.scrobble}${
+						isSelectingScrobbles()
+							? ` ${styles.selectingScrobble}`
+							: ''
+					}`}
+					onClick={(e) => {
+						if (!isSelectingScrobbles()) {
+							e.preventDefault();
+						}
+					}}
+				>
+					<ScrobbleDetails
+						scrobble={props.scrobble}
+						type={props.type}
+						title={props.title}
+						setActiveModal={props.setActiveModal}
+						modal={props.modal}
+					/>
+				</label>
+			}
+		>
+			<div class={styles.scrobble}>
+				<ScrobbleDetails
+					scrobble={props.scrobble}
+					type={props.type}
+					title={props.title}
+					setActiveModal={props.setActiveModal}
+					modal={props.modal}
+				/>
+			</div>
+		</Show>
+	);
+}
+
+function ScrobbleDetails(props: {
+	scrobble: CacheScrobble;
+	type: ScrobbleStatus;
+	title: string;
+	setActiveModal: Setter<ModalType>;
+	modal: HTMLDialogElement | undefined;
+}) {
+	const song = createMemo(() => new ClonedSong(props.scrobble.song, -1));
+	const dateString = createMemo(() =>
+		new Date(song().metadata.startTimestamp * 1000).toLocaleString(),
+	);
+
+	const [useShortMeta, setUseShortMeta] = createSignal(
+		window.innerWidth < 600,
+	);
+	const updateShortMeta = () => {
+		setUseShortMeta(window.innerWidth < 600);
+	};
+	const relativeString = createMemo(() =>
+		timeSince(song().metadata.startTimestamp * 1000, useShortMeta()),
+	);
+
+	onMount(() => {
+		window.addEventListener('resize', updateShortMeta);
+	});
+
+	onCleanup(() => {
+		window.removeEventListener('resize', updateShortMeta);
+	});
+
+	return (
+		<>
+			<Show when={props.scrobble.status !== ScrobbleStatus.SUCCESSFUL}>
+				<input
+					type="checkbox"
+					class={styles.scrobbleCheckbox}
+					hidden={
+						props.type === ScrobbleStatus.SUCCESSFUL ||
+						!isSelectingScrobbles()
+					}
+					onChange={(e) => {
+						if (e.currentTarget.checked) {
+							setSelectedScrobbles((prev) => [
+								...prev,
+								props.scrobble,
+							]);
+						} else {
+							setSelectedScrobbles((prev) =>
+								prev.filter((e) => e.id !== props.scrobble.id),
+							);
+						}
+					}}
+				/>
+			</Show>
+			<img
+				class={styles.coverArt}
+				src={
+					song().getTrackArt() ??
+					browser.runtime.getURL('img/cover_art_default.png')
+				}
+			/>
+			<div class={styles.scrobbleDetails}>
+				<div class={styles.scrobbleMetadata}>
+					<span class={styles.trackName}>{song().getTrack()}</span>
+					<span class={styles.artistName}>{song().getArtist()}</span>
+					<span class={styles.albumName}>{song().getAlbum()}</span>
+					<span class={styles.albumArtistName}>
+						{song().getAlbumArtist()}
+					</span>
+				</div>
+				<ScrobbleActions
+					song={song()}
+					id={props.scrobble.id}
+					status={props.scrobble.status}
+					setActiveModal={props.setActiveModal}
+					modal={props.modal}
+				/>
+			</div>
+			<span class={styles.scrobbleDate} title={dateString()}>
+				{relativeString()}
+			</span>
+		</>
+	);
+}
+
+function ScrobbleActions(props: {
+	song: ClonedSong;
+	id: number;
+	status: ScrobbleStatus;
+	setActiveModal: Setter<ModalType>;
+	modal: HTMLDialogElement | undefined;
+}) {
+	return (
+		<div class={styles.scrobbleActions}>
+			<Show when={props.status !== ScrobbleStatus.SUCCESSFUL}>
+				<button
+					class={styles.scrobbleActionButton}
+					onClick={(e) => {
+						e.stopImmediatePropagation();
+						setSongToEdit(props.song);
+						setScrobbleId(props.id);
+						props.setActiveModal('cacheEdit');
+						props.modal?.showModal();
+					}}
+				>
+					{t('infoEditTitleShort')}
+				</button>
+			</Show>
+			<button
+				class={styles.scrobbleActionButton}
+				onClick={() => {
+					void scrobbleCache.deleteScrobbles([props.id]).then(() => {
+						setScrobbles.refetch();
+					});
+				}}
+			>
+				{t('buttonRemove')}
+			</button>
+		</div>
+	);
+}
+
+function saveEditsWithKeyboard(
+	e: KeyboardEvent & {
+		currentTarget: HTMLInputElement;
+		target: Element;
+	},
+	artist: string,
+	track: string,
+	album: string,
+	albumArtist: string,
+	setLoading: Setter<boolean>,
+) {
+	if (e.key === 'Enter' && !e.isComposing && artist && track) {
+		saveEdits(e, artist, track, album, albumArtist, setLoading);
+	}
+}
+
+function saveEdits(
+	e: {
+		currentTarget: HTMLInputElement | HTMLButtonElement;
+		target: Element;
+	},
+	artist: string,
+	track: string,
+	album: string,
+	albumArtist: string,
+	setLoading: Setter<boolean>,
+) {
+	setLoading(true);
+	const shouldEditAll = (
+		document.getElementById('should-edit-multiple') as HTMLInputElement
+	).checked;
+	void editCacheScrobbles(
+		songToEdit(),
+		artist,
+		track,
+		album,
+		albumArtist,
+		shouldEditAll,
+	).then(() => {
+		setLoading(false);
+		setScrobbles.refetch();
+		e.target.closest('dialog')?.close();
+	});
+}
+
+async function editCacheScrobbles(
+	song: ClonedSong | undefined,
+	artist: string,
+	track: string,
+	album: string,
+	albumArtist: string,
+	shouldEditAll: boolean,
+) {
+	if (!artist || !track || !song) {
+		return;
+	}
+	const id = scrobbleId() ?? -1;
+	if (shouldEditAll) {
+		savedEdits.saveSongInfo(song, {
+			artist,
+			track,
+			album: album || null,
+			albumArtist: albumArtist || null,
+		});
+	}
+
+	const songId = SavedEditsModel.getSongId(song);
+	const filteredScrobbles = shouldEditAll
+		? scrobbles()?.filter(
+				(scrobble) =>
+					scrobble.status !== ScrobbleStatus.SUCCESSFUL &&
+					SavedEditsModel.getSongId(
+						new ClonedSong(scrobble.song, -1),
+					) === songId,
+			)
+		: scrobbles()?.filter((scrobble) => scrobble.id === id);
+	const editedScrobbles = filteredScrobbles?.map((scrobble) => {
+		scrobble.song.processed.track = track;
+		scrobble.song.processed.artist = artist;
+		scrobble.song.processed.album = album;
+		scrobble.song.processed.albumArtist = albumArtist;
+		return scrobble;
+	});
+
+	await scrobbleMultipleFromCache(editedScrobbles ?? []);
+}
+
+async function scrobbleMultipleFromCache(scrobbles: CacheScrobble[]) {
+	const scrobblesLimited = scrobbles.slice(0, 50);
+	await scrobbleCache.deleteScrobbles(
+		scrobblesLimited.map((scrobble) => scrobble.id),
+	);
+	try {
+		await sendContentMessage({
+			type: 'scrobble',
+			payload: {
+				songs: scrobblesLimited.map((scrobble) => scrobble.song),
+				currentlyPlaying: false,
+			},
+		});
+	} catch (err) {
+		debugLog(err, 'error');
+	}
+}
+
+async function scrobbleFromCache(song: CloneableSong, id: number) {
+	await scrobbleCache.deleteScrobbles([id]);
+	try {
+		await sendContentMessage({
+			type: 'scrobble',
+			payload: {
+				songs: [song],
+				currentlyPlaying: false,
+			},
+		});
+	} catch (err) {
+		debugLog(err, 'error');
+	}
+}
diff --git a/src/ui/options/components/showSomeLove.tsx b/src/ui/options/components/show-some-love.tsx
similarity index 86%
rename from src/ui/options/components/showSomeLove.tsx
rename to src/ui/options/components/show-some-love.tsx
index 13e50dd9..e39587e3 100644
--- a/src/ui/options/components/showSomeLove.tsx
+++ b/src/ui/options/components/show-some-love.tsx
@@ -9,9 +9,6 @@ export default function ShowSomeLove() {
 	return (
 		<>
 			<h1>{t('showSomeLoveTitle')}</h1>
-			<div class={styles.attentionWindow}>
-				<strong>{t('showSomeLoveFundingCampaign')}</strong>
-			</div>
 			<p>{t('showSomeLoveText1')}</p>
 			<p>{t('showSomeLoveText2')}</p>
 			<a
diff --git a/src/ui/options/main.tsx b/src/ui/options/main.tsx
index 819af045..2b6c90b5 100644
--- a/src/ui/options/main.tsx
+++ b/src/ui/options/main.tsx
@@ -18,6 +18,8 @@ import {
 	showSomeLoveItem,
 } from './components/navigator';
 import ContextMenu from '../components/context-menu/context-menu';
+import { CacheEditModal } from './components/scrobble-cache';
+import { BlockedTagsModal } from './components/edit-options/blocked-tags';
 import { BlocklistModal } from './components/edit-options/blocked-channels';
 
 /**
@@ -50,10 +52,12 @@ function getDefaultSetting(): NavigatorNavigationButton {
 
 const defaultSetting = getDefaultSetting();
 
-const modalMap = {
+const modals = {
 	savedEdits: EditsModal,
 	regexEdits: RegexEditsModal,
 	blocklist: BlocklistModal,
+	cacheEdit: CacheEditModal,
+	blockedTags: BlockedTagsModal,
 	'': () => <div>Loading...</div>,
 };
 
@@ -119,7 +123,7 @@ function Options() {
 				onClose={() => setActiveModal('')}
 			>
 				<div class={styles.modalContent}>
-					<Dynamic component={modalMap[activeModal()]} />
+					<Dynamic component={modals[activeModal()]} />
 				</div>
 				<button
 					class={styles.modalClose}
diff --git a/src/ui/options/settings.module.scss b/src/ui/options/settings.module.scss
index bc4cbeb2..c9d295d7 100644
--- a/src/ui/options/settings.module.scss
+++ b/src/ui/options/settings.module.scss
@@ -1,3 +1,5 @@
+@use '../components/util';
+
 body {
 	background-color: var(--background);
 	color: var(--text);
@@ -42,23 +44,23 @@ a {
 	}
 }
 
+.settingsContentWrapper {
+	flex: 1 1 auto;
+	overflow-y: auto;
+	padding: 0 1.5rem;
+	padding-bottom: 1.5rem;
+}
+
 @media only screen and (width <= 700px) {
 	.settings {
 		flex-direction: column-reverse;
 	}
 
 	.settingsContentWrapper {
-		padding: 0 1em;
+		padding: 0 1rem;
 	}
 }
 
-.settingsContentWrapper {
-	flex: 1 1 auto;
-	overflow-y: auto;
-	padding: 0 1.5em;
-	padding-bottom: 1.5em;
-}
-
 .settingsContent {
 	h1 {
 		font-size: 2.5em;
@@ -95,7 +97,7 @@ a {
 	position: relative;
 	width: 80vw;
 
-	@supports (-webkit-touch-callout: none) {
+	@supports #{util.$mobile-supports} {
 		border-radius: 0;
 		// stylelint-disable-next-line unit-no-unknown
 		height: 100dvh;
@@ -127,12 +129,13 @@ a {
 		right: 0;
 		top: 0;
 
-		@supports (-webkit-touch-callout: none) {
+		@supports #{util.$mobile-supports} {
 			padding: 0.75em 1em 0.5em;
 		}
 
 		&:hover {
 			background-color: var(--always-last-fm-hover);
+			color: var(--active-text);
 		}
 	}
 }
diff --git a/src/ui/popup/disallowed.tsx b/src/ui/popup/disallowed.tsx
index 8d5d672d..799d36b7 100644
--- a/src/ui/popup/disallowed.tsx
+++ b/src/ui/popup/disallowed.tsx
@@ -32,26 +32,12 @@ export default function Disallowed(props: { tab: Resource<ManagerTab> }) {
 						song()?.getTrack() ?? '???',
 					])}
 				</p>
-				<Show
-					when={song()?.metadata.label === 'YouTube'}
-					fallback={
-						<>
-							<p>{t('disallowedDesc2')}</p>
-							<ul>
-								<li>{t('disallowedDesc2Point1')}</li>
-								<li>{t('disallowedDesc2Point2')}</li>
-								<li>{t('disallowedDesc2Point3')}</li>
-							</ul>
-						</>
-					}
-				>
-					<p>{t('disallowedDescYoutube')}</p>
-					<ul>
-						<li>{t('disallowedDescYoutubePoint1')}</li>
-						<li>{t('disallowedDescYoutubePoint2')}</li>
-						<li>{t('disallowedDescYoutubePoint3')}</li>
-					</ul>
-				</Show>
+				<p>
+					{t(
+						`disallowedDesc${song()?.parsed
+							.scrobblingDisallowedReason}`,
+					)}
+				</p>
 				<p>{t('disallowedDesc3')}</p>
 				<button
 					class={`${optionComponentStyles.button} ${optionComponentStyles.centered}`}
diff --git a/src/ui/popup/edit.tsx b/src/ui/popup/edit.tsx
index b2259282..0d4f3019 100644
--- a/src/ui/popup/edit.tsx
+++ b/src/ui/popup/edit.tsx
@@ -18,7 +18,7 @@ import browser from 'webextension-polyfill';
 import ClonedSong from '@/core/object/cloned-song';
 import {
 	CheckOutlined,
-	CodeOutlined,
+	EditNoteOutlined,
 	PublishedWithChangesOutlined,
 } from '@/ui/components/icons';
 import { sendBackgroundMessage } from '@/util/communication';
@@ -286,7 +286,7 @@ export default function Edit(props: { tab: Resource<ManagerTab> }) {
 											setIsRegex(true);
 										}}
 									>
-										<CodeOutlined />
+										<EditNoteOutlined />
 									</button>
 								</div>
 							</Show>
@@ -340,7 +340,7 @@ function EditContextMenu(props: {
 			},
 			{
 				namei18n: 'infoRegexTitleShort',
-				icon: CodeOutlined,
+				icon: EditNoteOutlined,
 				action: () => props.setIsRegex(true),
 			},
 		];
diff --git a/src/ui/popup/main.tsx b/src/ui/popup/main.tsx
index 904c21c5..e57cb09c 100644
--- a/src/ui/popup/main.tsx
+++ b/src/ui/popup/main.tsx
@@ -47,6 +47,7 @@ const settingModes = [
 const contextMenuModes = [
 	ControllerMode.Playing,
 	ControllerMode.Skipped,
+	ControllerMode.Paused,
 	ControllerMode.Scrobbled,
 	ControllerMode.Loading,
 	ControllerMode.Unknown,
@@ -90,6 +91,7 @@ function Popup() {
 		[ControllerMode.Disabled]: () => <Disabled />,
 		[ControllerMode.Err]: () => <Err />,
 		[ControllerMode.Playing]: () => <NowPlaying tab={tab} />,
+		[ControllerMode.Paused]: () => <NowPlaying tab={tab} />,
 		[ControllerMode.Skipped]: () => <NowPlaying tab={tab} />,
 		[ControllerMode.Scrobbled]: () => <NowPlaying tab={tab} />,
 		[ControllerMode.Disallowed]: () => <Disallowed tab={tab} />,
diff --git a/src/ui/popup/popup.module.scss b/src/ui/popup/popup.module.scss
index 7cb9218a..2e3cd888 100644
--- a/src/ui/popup/popup.module.scss
+++ b/src/ui/popup/popup.module.scss
@@ -1,3 +1,5 @@
+@use '../components/util';
+
 @mixin ios-text-input {
 	appearance: none;
 	background-color: var(--last-fm-bg);
@@ -56,6 +58,10 @@ a {
 		display: flex;
 		gap: 0.5em;
 		width: 100%;
+
+		&::-webkit-details-marker {
+			display: none;
+		}
 	}
 
 	.expandVector {
@@ -144,15 +150,20 @@ a {
 		margin-top: 0.5em;
 
 		.controlButton {
+			--icon-color: var(--text);
 			background-color: transparent;
 			border: var(--interactable-border);
-			color: var(--text);
+			color: var(--icon-color);
 			cursor: pointer;
 			padding: 0.25em 0.5em;
 
+			.applyFill {
+				fill: var(--icon-color);
+			}
+
 			&:hover,
 			&.active {
-				color: var(--last-fm);
+				--icon-color: var(--last-fm);
 			}
 
 			&:not(:hover) .hover {
@@ -167,7 +178,7 @@ a {
 				cursor: not-allowed;
 
 				&:not(.hiddenDisabled) {
-					color: var(--checkbox-inactive);
+					--icon-color: var(--checkbox-inactive);
 				}
 			}
 		}
@@ -214,16 +225,16 @@ a {
 		white-space: nowrap;
 	}
 
-	.searchWrapper {
+	.regexFieldWrapper {
 		align-self: center;
 		position: relative;
 
-		.searchField {
+		.regexFieldInput {
 			box-sizing: border-box;
 			width: 100%;
 		}
 
-		.regexTest {
+		.regexFieldItem {
 			position: absolute;
 			right: 0.1em;
 			top: 50%;
@@ -251,11 +262,91 @@ a {
 
 	.regexFooter {
 		display: flex;
+		flex-direction: column;
 		width: 100%;
+
+		.regexFooterButtons {
+			display: flex;
+			width: 100%;
+		}
+	}
+
+	.regexFlagsWrapper {
+		align-items: center;
+		display: flex;
+		margin: 0.5em 0;
+
+		span {
+			font-size: 1.1em;
+			margin-right: 0.5em;
+		}
+	}
+
+	.regexToggleWrapper {
+		align-items: center;
+		border: 2px solid var(--interactable-border-color);
+		cursor: pointer;
+		display: flex;
+		padding: 0.25em;
+		width: max-content;
+
+		&:has(.regexToggleInput:checked) {
+			background-color: var(--last-fm);
+			color: var(--active-text);
+		}
+
+		/**
+		 * Check to make sure we are on desktop.
+		 * If we aren't, we don't want any highlighting,
+		 * as this will be confusing to the user.
+		 */
+		@supports not (#{util.$mobile-supports}) {
+			&:hover,
+			&:has(.regexToggleInput:focus-visible) {
+				background-color: var(--last-fm);
+				color: var(--active-text);
+			}
+
+			&:hover:has(.regexToggleInput:checked),
+			&:has(.regexToggleInput:focus-visible:checked) {
+				background-color: var(--background);
+				border: 2px solid var(--last-fm);
+				color: var(--text);
+			}
+		}
+
+		.regexToggleInput {
+			-webkit-appearance: none;
+			appearance: none;
+			border: none;
+			margin: 0;
+			padding: 0;
+			width: 0;
+		}
+	}
+
+	.button {
+		align-items: center;
+		background-color: var(--always-last-fm);
+		border: var(--interactable-border);
+		border-radius: 3px;
+		color: var(--always-light);
+		cursor: pointer;
+		display: flex;
+		font-size: 1em;
+		margin-right: -0.1em;
+		padding: 1px 7px 2px;
+		white-space: nowrap;
+		width: min-content;
+
+		&:hover {
+			background-color: var(--always-last-fm-hover);
+			color: var(--active-text);
+		}
 	}
 }
 
-@supports (-webkit-touch-callout: none) {
+@supports #{util.$mobile-supports} {
 	.root {
 		display: flex;
 		flex-direction: column-reverse;
@@ -265,7 +356,8 @@ a {
 	}
 
 	.nowPlayingPopup,
-	.alertPopup {
+	.alertPopup,
+	.widePopup {
 		align-items: center;
 		box-sizing: border-box;
 		flex: 1 1 auto;
@@ -370,15 +462,29 @@ a {
 			}
 		}
 
-		.searchWrapper {
+		.regexFieldWrapper {
 			align-items: center;
 			display: flex;
 			flex-direction: column;
+			padding: 0.25em 0.5em;
 			width: 100%;
 
-			.regexTest {
+			.regexFieldItem {
 				bottom: -0.3em; // I have no idea why this works - and I honestly don't want to know either
 				top: auto;
+
+				&.button {
+					bottom: -0.6em;
+				}
+			}
+		}
+
+		.regexFlagsWrapper {
+			flex-direction: column;
+
+			.regexToggleWrapper {
+				gap: 0.25em;
+				width: 100%;
 			}
 		}
 	}
diff --git a/src/ui/popup/regex.tsx b/src/ui/popup/regex.tsx
index a0759dc0..099ebcb5 100644
--- a/src/ui/popup/regex.tsx
+++ b/src/ui/popup/regex.tsx
@@ -1,6 +1,20 @@
 import ClonedSong from '@/core/object/cloned-song';
 import styles from './popup.module.scss';
-import { CheckOutlined, CloseOutlined } from '@/ui/components/icons';
+import {
+	Album,
+	AlbumOff,
+	CaseSensitiveOutlined,
+	CheckOutlined,
+	CloseOutlined,
+	DeleteForeverOutlined,
+	DeleteOutlined,
+	MusicOff,
+	MusicNote,
+	PersonOff,
+	Person,
+	RegexOutlined,
+	WholeWordOutlined,
+} from '@/ui/components/icons';
 
 import { t } from '@/util/i18n';
 import {
@@ -16,17 +30,20 @@ import {
 	createSignal,
 	onMount,
 } from 'solid-js';
+import { createStore } from 'solid-js/store';
 import {
 	EditedFields,
 	FieldType,
 	RegexFields,
+	RegexFlags,
 	getProcessedFieldsNoRegex,
 	getSongFieldNoRegex,
 	pascalCaseField,
+	processRegexFlags,
 	replaceFields,
 	searchMatches,
 } from '@/util/regex';
-import { ManagerTab } from '@/core/storage/wrapper';
+import { BlockedTagType, ManagerTab } from '@/core/storage/wrapper';
 import regexEdits from '@/core/storage/regex-edits';
 import { sendBackgroundMessage } from '@/util/communication';
 import ContextMenu from '../components/context-menu/context-menu';
@@ -35,25 +52,33 @@ import {
 	getMobileNavigatorGroup,
 } from '../options/components/navigator';
 import { isIos } from '../components/util';
+import BlockedTags from '@/core/storage/blocked-tags';
+import componentStyles from '@/ui/options/components/components.module.scss';
 
-const [searches, setSearches] = createSignal({
+const [searches, setSearches] = createStore<RegexFields>({
 	track: null,
 	artist: null,
 	album: null,
 	albumArtist: null,
-} as RegexFields);
-const [replaces, setReplaces] = createSignal({
+});
+const [replaces, setReplaces] = createStore<RegexFields>({
 	track: null,
 	artist: null,
 	album: null,
 	albumArtist: null,
-} as RegexFields);
-const [previews, setPreviews] = createSignal({
+});
+const [previews, setPreviews] = createStore<EditedFields>({
 	track: '',
 	artist: '',
 	album: '',
 	albumArtist: '',
-} as EditedFields);
+});
+const [flags, setFlags] = createStore<Record<keyof RegexFlags, boolean>>({
+	isRegexDisabled: true,
+	isCaseInsensitive: true,
+	isGlobal: false,
+});
+const blockedTags = new BlockedTags();
 
 /**
  * Regex editing popup
@@ -62,6 +87,10 @@ export default function Regex(props: {
 	clonedSong: ClonedSong | undefined;
 	tab: Resource<ManagerTab>;
 }) {
+	const song = createMemo(() => props.clonedSong);
+	const [blockedTypes] = createResource(() =>
+		blockedTags.getBlockedTypes(song()),
+	);
 	const [maxCellWidth, setMaxCellWidth] = createSignal(0);
 	const [regexContainer, setRegexContainer] = createSignal<HTMLDivElement>();
 	createEffect(() => {
@@ -81,6 +110,9 @@ export default function Regex(props: {
 			}}
 			ref={setRegexContainer}
 		>
+			<Show when={isIos()}>
+				<Flags />
+			</Show>
 			<Show when={!isIos()}>
 				<div class={styles.entryWrapper}>
 					<span />
@@ -96,26 +128,76 @@ export default function Regex(props: {
 				</div>
 			</Show>
 			<Entry
-				clonedSong={props.clonedSong}
+				blockedTypes={blockedTypes}
+				tabId={props.tab()?.tabId}
 				setMaxCellWidth={setMaxCellWidth}
+				clonedSong={props.clonedSong}
 				type="track"
+				UnblockIcon={() => <MusicNote />}
+				BlockIcon={() => <MusicOff />}
 			/>
 			<Entry
-				clonedSong={props.clonedSong}
+				blockedTypes={blockedTypes}
+				tabId={props.tab()?.tabId}
 				setMaxCellWidth={setMaxCellWidth}
+				clonedSong={props.clonedSong}
 				type="artist"
+				UnblockIcon={() => <Person />}
+				BlockIcon={() => <PersonOff />}
 			/>
 			<Entry
-				clonedSong={props.clonedSong}
+				blockedTypes={blockedTypes}
+				tabId={props.tab()?.tabId}
 				setMaxCellWidth={setMaxCellWidth}
+				clonedSong={props.clonedSong}
 				type="album"
+				UnblockIcon={() => <Album />}
+				BlockIcon={() => <AlbumOff />}
 			/>
 			<Entry
-				clonedSong={props.clonedSong}
+				blockedTypes={blockedTypes}
+				tabId={props.tab()?.tabId}
 				setMaxCellWidth={setMaxCellWidth}
+				clonedSong={props.clonedSong}
 				type="albumArtist"
+				UnblockIcon={() => <Person />}
+				BlockIcon={() => <PersonOff />}
+			/>
+			<Footer
+				blockedTypes={blockedTypes}
+				tab={props.tab}
+				clonedSong={props.clonedSong}
+			/>
+		</div>
+	);
+}
+
+function Flags() {
+	return (
+		<div
+			class={styles.regexFlagsWrapper}
+			role="group"
+			aria-label={t('optionsOptions')}
+		>
+			<span>{t('optionsOptions')}</span>
+			<RegexFlagCheckbox
+				i18nTitle="infoUseRegex"
+				icon={RegexOutlined}
+				isInverted={true}
+				flag="isRegexDisabled"
+			/>
+			<RegexFlagCheckbox
+				i18nTitle="infoMatchCase"
+				icon={CaseSensitiveOutlined}
+				isInverted={true}
+				flag="isCaseInsensitive"
+			/>
+			<RegexFlagCheckbox
+				i18nTitle="infoMatchWholeTag"
+				icon={WholeWordOutlined}
+				isInverted={true}
+				flag="isGlobal"
 			/>
-			<Footer tab={props.tab} clonedSong={props.clonedSong} />
 		</div>
 	);
 }
@@ -124,24 +206,31 @@ export default function Regex(props: {
  * Label, inputs, and preview for a single song field.
  */
 function Entry(props: {
+	blockedTypes: Resource<{
+		artist: boolean;
+		album: boolean;
+		track: boolean;
+	}>;
+	tabId: number | undefined;
 	clonedSong: ClonedSong | undefined;
+	UnblockIcon: () => JSXElement;
+	BlockIcon: () => JSXElement;
 	type: FieldType;
 	setMaxCellWidth: Setter<number>;
 }) {
 	onMount(() => {
-		const type = props.type;
-		const clonedSong = props.clonedSong;
-		setPreviews((prev) => ({
-			...prev,
-			[type]: getSongFieldNoRegex(clonedSong, type),
-		}));
+		setPreviews({
+			[props.type]: getSongFieldNoRegex(props.clonedSong, props.type),
+		});
 	});
-
 	createEffect(() => {
 		setPreviews(() =>
 			replaceFields(
-				searches(),
-				replaces(),
+				{
+					search: searches,
+					replace: replaces,
+					...processRegexFlags(flags),
+				},
 				getProcessedFieldsNoRegex(props.clonedSong),
 			),
 		);
@@ -154,7 +243,14 @@ function Entry(props: {
 	});
 
 	return (
-		<EntryWrapper type={props.type}>
+		<EntryWrapper
+			blockedTypes={props.blockedTypes}
+			tabId={props.tabId}
+			clonedSong={props.clonedSong}
+			UnblockIcon={props.UnblockIcon}
+			BlockIcon={props.BlockIcon}
+			type={props.type}
+		>
 			<Show when={!isIos()}>
 				<span class={styles.entryLabel} ref={setPcLabel}>
 					{t(`info${pascalCaseField(props.type)}Label`)}
@@ -167,10 +263,99 @@ function Entry(props: {
 	);
 }
 
+function BlockTagButtonIOS(props: {
+	blockedTypes: Resource<{
+		artist: boolean;
+		album: boolean;
+		track: boolean;
+	}>;
+	type: FieldType;
+	tabId: number | undefined;
+	clonedSong: ClonedSong | undefined;
+	UnblockIcon: () => JSXElement;
+	BlockIcon: () => JSXElement;
+}) {
+	const transformedType = createMemo(() => {
+		if (props.type === 'albumArtist') {
+			return 'artist';
+		}
+		return props.type;
+	});
+	return (
+		<Show when={props.blockedTypes()}>
+			{(loadedBlockedTypes) => (
+				<button
+					class={componentStyles.button}
+					title={t(
+						loadedBlockedTypes()[transformedType()]
+							? `infoUnblock${pascalCaseField(transformedType())}`
+							: `infoBlock${pascalCaseField(transformedType())}`,
+					)}
+					onClick={() => {
+						const tabId = props.tabId;
+						loadedBlockedTypes()[transformedType()]
+							? blockedTags
+									.removeFromBlocklist(
+										transformedType(),
+										props.clonedSong,
+									)
+									.then(() =>
+										sendBackgroundMessage(tabId ?? -1, {
+											type: 'reprocessSong',
+											payload: undefined,
+										}),
+									)
+							: blockedTags
+									.addToBlocklist(
+										transformedType(),
+										props.clonedSong,
+									)
+									.then(() =>
+										sendBackgroundMessage(tabId ?? -1, {
+											type: 'reprocessSong',
+											payload: undefined,
+										}),
+									);
+					}}
+				>
+					<Show
+						when={loadedBlockedTypes()[transformedType()]}
+						fallback={
+							<>
+								<props.BlockIcon />
+								{t(
+									`infoBlock${pascalCaseField(
+										transformedType(),
+									)}`,
+								)}
+							</>
+						}
+					>
+						<props.UnblockIcon />
+						{t(`infoUnblock${pascalCaseField(transformedType())}`)}
+					</Show>
+				</button>
+			)}
+		</Show>
+	);
+}
+
 /**
  * Wrapper for an entry that shows a fieldset on iOS and just the inputs on other platforms.
  */
-function EntryWrapper(props: { type: FieldType; children: JSXElement }) {
+function EntryWrapper(props: {
+	blockedTypes: Resource<{
+		artist: boolean;
+		album: boolean;
+		track: boolean;
+	}>;
+	tabId: number | undefined;
+	clonedSong: ClonedSong | undefined;
+	UnblockIcon: () => JSXElement;
+	BlockIcon: () => JSXElement;
+	type: FieldType;
+	children: JSXElement;
+}) {
 	return (
 		<Show
 			when={isIos()}
@@ -189,6 +374,14 @@ function EntryWrapper(props: { type: FieldType; children: JSXElement }) {
 					{t(`info${pascalCaseField(props.type)}Label`)}
 				</legend>
 				{props.children}
+				<BlockTagButtonIOS
+					blockedTypes={props.blockedTypes}
+					tabId={props.tabId}
+					clonedSong={props.clonedSong}
+					UnblockIcon={props.UnblockIcon}
+					BlockIcon={props.BlockIcon}
+					type={props.type}
+				/>
 			</fieldset>
 		</Show>
 	);
@@ -202,7 +395,7 @@ function SearchField(props: {
 	clonedSong: ClonedSong | undefined;
 }) {
 	return (
-		<div class={styles.searchWrapper}>
+		<div class={styles.regexFieldWrapper}>
 			<Show when={isIos()}>
 				<label
 					for={`${props.type}SearchInput`}
@@ -214,33 +407,33 @@ function SearchField(props: {
 			<input
 				id={`${props.type}SearchInput`}
 				type="text"
-				class={styles.searchField}
+				class={styles.regexFieldInput}
 				onInput={(e) =>
-					setSearches((prev) => ({
-						...prev,
+					setSearches({
 						[props.type]: e.currentTarget.value || null,
-					}))
+					})
 				}
 				title={t('infoSearchLabel')}
 			/>
 			<Switch
 				fallback={
 					<CloseOutlined
-						class={`${styles.regexTest} ${styles.regexFailure}`}
+						class={`${styles.regexFieldItem} ${styles.regexFailure}`}
 					/>
 				}
 			>
-				<Match when={!searches()[props.type]}>
+				<Match when={!searches[props.type]}>
 					<></>
 				</Match>
 				<Match
 					when={searchMatches(
-						searches()[props.type],
+						searches[props.type],
 						getSongFieldNoRegex(props.clonedSong, props.type),
+						processRegexFlags(flags),
 					)}
 				>
 					<CheckOutlined
-						class={`${styles.regexTest} ${styles.regexSuccess}`}
+						class={`${styles.regexFieldItem} ${styles.regexSuccess}`}
 					/>
 				</Match>
 			</Switch>
@@ -252,28 +445,53 @@ function SearchField(props: {
  * A single replace input for a single song property.
  */
 function ReplaceField(props: { type: FieldType }) {
+	const [shouldDelete, setShouldReplace] = createSignal(false);
+	const [curReplace, setCurReplace] = createSignal('');
+
+	createEffect(() => {
+		setReplaces({
+			[props.type]: shouldDelete() ? '' : curReplace() || null,
+		});
+	});
+
 	return (
-		<>
-			<Show when={isIos()}>
-				<label
-					for={`${props.type}ReplaceInput`}
-					class={styles.iosReplaceLabel}
-				>
-					{t('infoReplaceLabel')}
-				</label>
-			</Show>
-			<input
-				id={`${props.type}ReplaceInput`}
-				type="text"
-				onInput={(e) =>
-					setReplaces((prev) => ({
-						...prev,
-						[props.type]: e.currentTarget.value || null,
-					}))
+		<div class={styles.regexFieldWrapper}>
+			<Switch>
+				<Match when={shouldDelete()}>
+					<span>{t('infoDeleteLabel')}</span>
+				</Match>
+				<Match when={!shouldDelete()}>
+					<Show when={isIos()}>
+						<label
+							for={`${props.type}ReplaceInput`}
+							class={styles.iosReplaceLabel}
+						>
+							{t('infoReplaceLabel')}
+						</label>
+					</Show>
+					<input
+						id={`${props.type}ReplaceInput`}
+						type="text"
+						onInput={(e) => setCurReplace(e.currentTarget.value)}
+						class={styles.regexFieldInput}
+						title={t('infoReplaceLabel')}
+					/>
+				</Match>
+			</Switch>
+			<button
+				class={`${styles.regexFieldItem} ${styles.button}`}
+				title={
+					shouldDelete()
+						? t('infoReplaceLabel')
+						: t('infoDeleteLabel')
 				}
-				title={t('infoReplaceLabel')}
-			/>
-		</>
+				onClick={() => setShouldReplace((cur) => !cur)}
+			>
+				<Show when={shouldDelete()} fallback={<DeleteOutlined />}>
+					<DeleteForeverOutlined />
+				</Show>
+			</button>
+		</div>
 	);
 }
 
@@ -295,7 +513,7 @@ function PreviewOutput(props: { type: FieldType }) {
 				id={`${props.type}PreviewOutput`}
 				title={t('infoPreviewLabel')}
 			>
-				{previews()[props.type]}
+				{previews[props.type]}
 			</output>
 		</>
 	);
@@ -307,6 +525,11 @@ function PreviewOutput(props: { type: FieldType }) {
 function Footer(props: {
 	tab: Resource<ManagerTab>;
 	clonedSong: ClonedSong | undefined;
+	blockedTypes: Resource<{
+		artist: boolean;
+		album: boolean;
+		track: boolean;
+	}>;
 }) {
 	return (
 		<div class={styles.regexFooter}>
@@ -314,22 +537,101 @@ function Footer(props: {
 				<span class={styles.editWarning}>{t('infoEditedWarning')}</span>
 			</Show>
 			<Show when={!isIos()}>
-				<div class={styles.controlButtons}>
-					<button
-						class={styles.controlButton}
-						title={t('infoSubmitTitle')}
-						onClick={() => {
-							saveEdit(props.tab);
-						}}
-					>
-						<CheckOutlined />
-					</button>
+				<Flags />
+				<div class={styles.regexFooterButtons}>
+					<div class={styles.controlButtons}>
+						<button
+							class={styles.controlButton}
+							title={t('infoSubmitTitle')}
+							onClick={() => void saveEdit(props.tab)}
+						>
+							<CheckOutlined />
+						</button>
+						<Show when={props.blockedTypes()}>
+							{(blockedTypesLoaded) => (
+								<>
+									<BlockTagButton
+										isBlocked={blockedTypesLoaded().artist}
+										type="artist"
+										tabId={props.tab()?.tabId}
+										clonedSong={props.clonedSong}
+										UnblockIcon={() => <Person />}
+										BlockIcon={() => <PersonOff />}
+									/>
+									<Show when={props.clonedSong?.getAlbum()}>
+										<BlockTagButton
+											isBlocked={
+												blockedTypesLoaded().album
+											}
+											type="album"
+											tabId={props.tab()?.tabId}
+											clonedSong={props.clonedSong}
+											UnblockIcon={() => <Album />}
+											BlockIcon={() => <AlbumOff />}
+										/>
+									</Show>
+									<BlockTagButton
+										isBlocked={blockedTypesLoaded().track}
+										type="track"
+										tabId={props.tab()?.tabId}
+										clonedSong={props.clonedSong}
+										UnblockIcon={() => <MusicNote />}
+										BlockIcon={() => <MusicOff />}
+									/>
+								</>
+							)}
+						</Show>
+					</div>
 				</div>
 			</Show>
 		</div>
 	);
 }
 
+function BlockTagButton(props: {
+	isBlocked: boolean;
+	type: BlockedTagType;
+	tabId: number | undefined;
+	clonedSong: ClonedSong | undefined;
+	UnblockIcon: () => JSXElement;
+	BlockIcon: () => JSXElement;
+}) {
+	return (
+		<button
+			class={styles.controlButton}
+			title={t(
+				props.isBlocked
+					? `infoUnblock${pascalCaseField(props.type)}`
+					: `infoBlock${pascalCaseField(props.type)}`,
+			)}
+			onClick={() => {
+				const tabId = props.tabId;
+				props.isBlocked
+					? blockedTags
+							.removeFromBlocklist(props.type, props.clonedSong)
+							.then(() =>
+								sendBackgroundMessage(tabId ?? -1, {
+									type: 'reprocessSong',
+									payload: undefined,
+								}),
+							)
+					: blockedTags
+							.addToBlocklist(props.type, props.clonedSong)
+							.then(() =>
+								sendBackgroundMessage(tabId ?? -1, {
+									type: 'reprocessSong',
+									payload: undefined,
+								}),
+							);
+			}}
+		>
+			<Show when={props.isBlocked} fallback={<props.BlockIcon />}>
+				<props.UnblockIcon />
+			</Show>
+		</button>
+	);
+}
+
 /**
  * Component for the context menu of regex edit
  */
@@ -362,9 +664,46 @@ export function RegexEditContextMenu(props: { tab: Resource<ManagerTab> }) {
  * @param data - the new song data to edit to
  */
 async function saveEdit(tab: Resource<ManagerTab>) {
-	await regexEdits.saveRegexEdit(searches(), replaces());
+	await regexEdits.saveRegexEdit({
+		search: searches,
+		replace: replaces,
+		...processRegexFlags(flags),
+	});
 	sendBackgroundMessage(tab()?.tabId ?? -1, {
 		type: 'reprocessSong',
 		payload: undefined,
 	});
 }
+
+/**
+ * Regex flag checkbox
+ */
+function RegexFlagCheckbox(props: {
+	i18nTitle: string;
+	icon: typeof RegexOutlined;
+	isInverted?: true;
+	flag: keyof RegexFlags;
+}) {
+	return (
+		<label title={t(props.i18nTitle)} class={styles.regexToggleWrapper}>
+			<input
+				type="checkbox"
+				checked={
+					props.isInverted ? !flags[props.flag] : flags[props.flag]
+				}
+				class={styles.regexToggleInput}
+				onChange={() =>
+					setFlags((f) => ({
+						[props.flag]: !f[props.flag],
+					}))
+				}
+			/>
+			<props.icon />
+			<Show when={isIos()}>
+				<span class={styles.regexToggleLabel}>
+					{t(props.i18nTitle)}
+				</span>
+			</Show>
+		</label>
+	);
+}
diff --git a/src/util/communication.ts b/src/util/communication.ts
index 9cd8e05f..8ad15f1d 100644
--- a/src/util/communication.ts
+++ b/src/util/communication.ts
@@ -76,9 +76,10 @@ interface ContentCommunications {
 	};
 	scrobble: {
 		payload: {
-			song: CloneableSong;
+			songs: CloneableSong[];
+			currentlyPlaying: boolean;
 		};
-		response: Promise<ServiceCallResult[]>;
+		response: Promise<ServiceCallResult[][]>;
 	};
 	getSongInfo: {
 		payload: {
@@ -103,6 +104,16 @@ interface ContentCommunications {
 		payload: undefined;
 		response: void;
 	};
+	fetch: {
+		payload: {
+			url: string;
+			init?: RequestInit | undefined;
+		};
+		response: Promise<{
+			ok: boolean;
+			content: string;
+		}>;
+	};
 }
 
 interface BackgroundCommunications {
diff --git a/src/util/regex.ts b/src/util/regex.ts
index bc613e65..82a16d82 100644
--- a/src/util/regex.ts
+++ b/src/util/regex.ts
@@ -25,13 +25,90 @@ export type EditedFields = {
 	[field in FieldType]: string;
 };
 
+/**
+ * Regex flags.
+ * If a new regex flag is added, it must also be added to {@link isValidFlag}
+ */
+export interface RegexFlags {
+	isRegexDisabled?: true;
+	isCaseInsensitive?: true;
+	isGlobal?: true;
+}
+
+/**
+ * Checks is string is a valid regex flag.
+ * If a new regex flag is added to {@link RegexFlags}, it must be added here too.
+ *
+ * @param flag - flag to check if is valid
+ * @returns true if valid, coercing argument into a key of {@link RegexFlags}
+ */
+function isValidFlag(flag: string): flag is keyof RegexFlags {
+	for (const f of ['isRegexDisabled', 'isCaseInsensitive', 'isGlobal']) {
+		if (f === flag) {
+			return true;
+		}
+	}
+	return false;
+}
+
 /**
  * Regex edit object.
  */
-export type RegexEdit = {
+export interface RegexEdit extends RegexFlags {
 	search: RegexFields;
 	replace: RegexFields;
-};
+}
+
+/**
+ * Get flags of an edit.
+ *
+ * @param edit - edit to get flags of
+ * @returns flags for the edit supplied
+ */
+const getRegexEditFlags = (edit: RegexEdit): RegexFlags => ({
+	isGlobal: edit.isGlobal,
+	isCaseInsensitive: edit.isCaseInsensitive,
+	isRegexDisabled: edit.isRegexDisabled,
+});
+
+/**
+ * @param str - string to escape regex in
+ * @returns string with regex escaped
+ */
+const escapeRegex = (str: string) =>
+	str.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&');
+
+/**
+ * @param flags - flags to stringify
+ * @returns stringified regex flags
+ */
+function stringifyFlags(flags: RegexFlags): string {
+	let res = '';
+	if (flags.isGlobal) {
+		res += 'g';
+	}
+	if (flags.isCaseInsensitive) {
+		res += 'i';
+	}
+	return res;
+}
+
+/**
+ * Takes a boolean based regex flag state from UI,
+ * and turns it into a true regex flag object
+ */
+export function processRegexFlags(
+	flags: Record<keyof RegexFlags, boolean>,
+): RegexFlags {
+	const processedFlags: RegexFlags = {};
+	for (const [key, value] of Object.entries(flags)) {
+		if (!isValidFlag(key) || !value) {
+			continue;
+		}
+		processedFlags[key] = true;
+	}
+	return processedFlags;
+}
 
 /**
  * Checks if a regex edit has already been applied to any of the fields this edit would apply to.
@@ -66,10 +143,12 @@ export function shouldApplyEdit(edit: RegexEdit, song: Song): boolean {
 		return false;
 	}
 
+	const flags = getRegexEditFlags(edit);
+
 	for (const [_key, field] of Object.entries(edit.search)) {
 		const key = _key as FieldType;
 		const songField = getSongField(song, key);
-		if (!searchMatches(field, songField)) {
+		if (!searchMatches(field, songField, flags)) {
 			return false;
 		}
 	}
@@ -81,14 +160,26 @@ export function shouldApplyEdit(edit: RegexEdit, song: Song): boolean {
  *
  * @param search - Search regex to check
  * @param text - Text to check
+ * @param flags - Flags of the regex edit
  * @returns True if the edit should be applied; false otherwise
  */
-export function searchMatches(search: string | null, text: string): boolean {
+export function searchMatches(
+	search: string | null,
+	text: string,
+	flags: RegexFlags,
+): boolean {
 	if (search === null) {
 		return true;
 	}
+
+	const processedSearch = flags.isRegexDisabled
+		? escapeRegex(search)
+		: search;
 	try {
-		const regex = new RegExp(`^${search}$`);
+		const regex = new RegExp(
+			flags.isGlobal ? processedSearch : `^${processedSearch}$`,
+			stringifyFlags(flags),
+		);
 		return regex.test(text);
 	} catch (err) {
 		return false;
@@ -107,12 +198,20 @@ function replaceField(
 	search: string | null,
 	replace: string | null,
 	text: string,
+	flags: RegexFlags,
 ): string {
 	if (search === null || replace === null) {
 		return text;
 	}
+
+	const processedSearch = flags.isRegexDisabled
+		? escapeRegex(search)
+		: search;
 	try {
-		const regex = new RegExp(`^${search}$`);
+		const regex = new RegExp(
+			flags.isGlobal ? processedSearch : `^${processedSearch}$`,
+			stringifyFlags(flags),
+		);
 		return text.replace(regex, replace);
 	} catch (err) {
 		return text;
@@ -128,22 +227,24 @@ function replaceField(
  * @returns Edited fields
  */
 export function replaceFields(
-	search: RegexFields,
-	replace: RegexFields,
+	edit: RegexEdit,
 	fields: EditedFields,
 ): EditedFields {
+	const flags = getRegexEditFlags(edit);
+
 	for (const _field of Object.keys(fields)) {
 		const field = _field as FieldType;
-		if (!searchMatches(search[field], fields[field])) {
+		if (!searchMatches(edit.search[field], fields[field], flags)) {
 			return fields;
 		}
 	}
 	for (const _field of Object.keys(fields)) {
 		const field = _field as FieldType;
 		fields[field] = replaceField(
-			search[field],
-			replace[field],
+			edit.search[field],
+			edit.replace[field],
 			fields[field],
+			getRegexEditFlags(edit),
 		);
 	}
 	return fields;
@@ -189,15 +290,11 @@ export function getProcessedFieldsNoRegex(
  * @param song - Song to apply regex to
  */
 export function editSong(edit: RegexEdit, song: Song) {
-	const fields = replaceFields(
-		edit.search,
-		edit.replace,
-		getProcessedFields(song),
-	);
+	const fields = replaceFields(edit, getProcessedFields(song));
 
 	for (const [_key, field] of Object.entries(fields)) {
 		const key = _key as FieldType;
-		if (edit.search[key] && edit.replace[key]) {
+		if (edit.search[key] && typeof edit.replace[key] === 'string') {
 			song.flags.isRegexEditedByUser[key] = true;
 			song.processed[key] = field;
 		}
diff --git a/src/util/util.ts b/src/util/util.ts
index 278a8766..67f1960a 100644
--- a/src/util/util.ts
+++ b/src/util/util.ts
@@ -10,6 +10,8 @@ import {
 	WebhookModel,
 	Properties,
 	StateManagement,
+	CacheScrobble,
+	BlockedTags,
 	Blocklists,
 } from '@/core/storage/wrapper';
 import { RegexEdit } from './regex';
@@ -139,6 +141,8 @@ export function hideObjectValue(
 		| WebhookModel
 		| StateManagement
 		| RegexEdit[]
+		| CacheScrobble[]
+		| BlockedTags[]
 		| Blocklists,
 ): string {
 	if (!keyValue) {
diff --git a/vite.configs.ts b/vite.configs.ts
index 9da3c06d..2e273345 100644
--- a/vite.configs.ts
+++ b/vite.configs.ts
@@ -112,6 +112,6 @@ export const buildStart: UserConfig = {
 			isDev: isDev(),
 			watchDirectory: 'src/connectors/*',
 		}),
-		minifyImages(),
+		minifyImages({ isDev: isDev() }),
 	],
 };


Test patch:
diff --git a/tests/background/regex-edits.test.ts b/tests/background/regex-edits.test.ts
index 51e609b7..bccbecbc 100644
--- a/tests/background/regex-edits.test.ts
+++ b/tests/background/regex-edits.test.ts
@@ -1,4 +1,5 @@
 import webextensionPolyfill from '#/mocks/webextension-polyfill';
+import fetchPolyfill from '#/mocks/fetch';
 import Pipeline from '@/core/object/pipeline/pipeline';
 import Song from '@/core/object/song';
 import { SavedEdit } from '@/core/storage/options';
@@ -14,6 +15,8 @@ import { getConnectorById } from '@/util/util-connector';
 import { randomBytes } from 'crypto';
 import { beforeEach, describe, expect, it } from 'vitest';
 
+// ensure polyfill is loaded;
+fetchPolyfill;
 const pipeline = new Pipeline();
 
 describe('Should edit Regex', () => {
@@ -24,11 +27,14 @@ describe('Should edit Regex', () => {
 	});
 
 	it('Should add edits to storage', async () => {
-		await regexEdits.saveRegexEdit(epRemover.search, epRemover.replace);
-		await regexEdits.saveRegexEdit(
-			singleRemover.search,
-			singleRemover.replace,
-		);
+		await regexEdits.saveRegexEdit({
+			search: epRemover.search,
+			replace: epRemover.replace,
+		});
+		await regexEdits.saveRegexEdit({
+			search: singleRemover.search,
+			replace: singleRemover.replace,
+		});
 		expect(await regexEdits.getData()).to.deep.equal([
 			epRemover,
 			singleRemover,
@@ -36,15 +42,18 @@ describe('Should edit Regex', () => {
 	});
 
 	it('Should delete edits from storage', async () => {
-		await regexEdits.saveRegexEdit(epRemover.search, epRemover.replace);
-		await regexEdits.saveRegexEdit(
-			singleRemover.search,
-			singleRemover.replace,
-		);
-		await regexEdits.saveRegexEdit(
-			fuminnikkiFixer.search,
-			fuminnikkiFixer.replace,
-		);
+		await regexEdits.saveRegexEdit({
+			search: epRemover.search,
+			replace: epRemover.replace,
+		});
+		await regexEdits.saveRegexEdit({
+			search: singleRemover.search,
+			replace: singleRemover.replace,
+		});
+		await regexEdits.saveRegexEdit({
+			search: fuminnikkiFixer.search,
+			replace: fuminnikkiFixer.replace,
+		});
 		await regexEdits.deleteRegexEdit(1);
 		expect(await regexEdits.getData()).to.deep.equal([
 			epRemover,
@@ -53,10 +62,10 @@ describe('Should edit Regex', () => {
 	});
 
 	it("Should apply edit to song's artist", async () => {
-		await regexEdits.saveRegexEdit(
-			fuminnikkiFixer.search,
-			fuminnikkiFixer.replace,
-		);
+		await regexEdits.saveRegexEdit({
+			search: fuminnikkiFixer.search,
+			replace: fuminnikkiFixer.replace,
+		});
 		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
 		await pipeline.process(song, youtubeConnector);
 		expect(getProcessedFields(song)).to.deep.equal(
@@ -65,32 +74,38 @@ describe('Should edit Regex', () => {
 	});
 
 	it("Should apply edit to song's album", async () => {
-		await regexEdits.saveRegexEdit(epRemover.search, epRemover.replace);
+		await regexEdits.saveRegexEdit({
+			search: epRemover.search,
+			replace: epRemover.replace,
+		});
 		const song = new Song(correctFuminnikkiSong, youtubeConnector);
 		await pipeline.process(song, youtubeConnector);
 		expect(getProcessedFields(song)).to.deep.equal(processedFuminnikkiSong);
 	});
 
 	it("Should apply edit to song's artist and album", async () => {
-		await regexEdits.saveRegexEdit(
-			fuminnikkiFixer.search,
-			fuminnikkiFixer.replace,
-		);
-		await regexEdits.saveRegexEdit(epRemover.search, epRemover.replace);
+		await regexEdits.saveRegexEdit({
+			search: fuminnikkiFixer.search,
+			replace: fuminnikkiFixer.replace,
+		});
+		await regexEdits.saveRegexEdit({
+			search: epRemover.search,
+			replace: epRemover.replace,
+		});
 		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
 		await pipeline.process(song, youtubeConnector);
 		expect(getProcessedFields(song)).to.deep.equal(processedFuminnikkiSong);
 	});
 
 	it("Should apply only the most recent edit to song's artist only once", async () => {
-		await regexEdits.saveRegexEdit(
-			artistNumberSuffixer(1).search,
-			artistNumberSuffixer(1).replace,
-		);
-		await regexEdits.saveRegexEdit(
-			artistNumberSuffixer(2).search,
-			artistNumberSuffixer(2).replace,
-		);
+		await regexEdits.saveRegexEdit({
+			search: artistNumberSuffixer(1).search,
+			replace: artistNumberSuffixer(1).replace,
+		});
+		await regexEdits.saveRegexEdit({
+			search: artistNumberSuffixer(2).search,
+			replace: artistNumberSuffixer(2).replace,
+		});
 
 		const song = new Song(correctFuminnikkiSong, youtubeConnector);
 		await pipeline.process(song, youtubeConnector);
@@ -101,10 +116,10 @@ describe('Should edit Regex', () => {
 	});
 
 	it('Should apply edit to track that does not exist', async () => {
-		await regexEdits.saveRegexEdit(
-			artistNumberSuffixer(1).search,
-			artistNumberSuffixer(1).replace,
-		);
+		await regexEdits.saveRegexEdit({
+			search: artistNumberSuffixer(1).search,
+			replace: artistNumberSuffixer(1).replace,
+		});
 
 		const artist = randomBytes(32).toString('hex');
 		const track = randomBytes(32).toString('hex');
@@ -117,11 +132,14 @@ describe('Should edit Regex', () => {
 	});
 
 	it('Should have non-regex edits saved for preview', async () => {
-		await regexEdits.saveRegexEdit(
-			fuminnikkiFixer.search,
-			fuminnikkiFixer.replace,
-		);
-		await regexEdits.saveRegexEdit(epRemover.search, epRemover.replace);
+		await regexEdits.saveRegexEdit({
+			search: fuminnikkiFixer.search,
+			replace: fuminnikkiFixer.replace,
+		});
+		await regexEdits.saveRegexEdit({
+			search: epRemover.search,
+			replace: epRemover.replace,
+		});
 
 		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
 		await pipeline.process(song, youtubeConnector);
@@ -135,10 +153,10 @@ describe('Should edit Regex', () => {
 	});
 
 	it('Should not apply regex edit if there is a song edit', async () => {
-		await regexEdits.saveRegexEdit(
-			artistNumberSuffixer(1).search,
-			artistNumberSuffixer(1).replace,
-		);
+		await regexEdits.saveRegexEdit({
+			search: artistNumberSuffixer(1).search,
+			replace: artistNumberSuffixer(1).replace,
+		});
 
 		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
 		savedEdits.saveSongInfo(song, processedFuminnikkiSong as SavedEdit);
@@ -148,6 +166,228 @@ describe('Should edit Regex', () => {
 	});
 });
 
+describe('Should handle bulk edit flags', () => {
+	beforeEach(() => {
+		webextensionPolyfill.reset();
+		webextensionPolyfill.setUser();
+		regexEdits.init();
+	});
+
+	it('Should not apply regex edit with wrong casing if edit is not case insensitive', async () => {
+		await regexEdits.saveRegexEdit({
+			search: fuminnikkiFixerInsensitive.search,
+			replace: fuminnikkiFixerInsensitive.replace,
+		});
+
+		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
+		await pipeline.process(song, youtubeConnector);
+		expect(getProcessedFields(song)).to.deep.equal(
+			wrongProcessedFuminnikkiSong,
+		);
+	});
+
+	it('Should apply regex edit with wrong casing if edit is case insensitive', async () => {
+		await regexEdits.saveRegexEdit({
+			search: fuminnikkiFixerInsensitive.search,
+			replace: fuminnikkiFixerInsensitive.replace,
+			isCaseInsensitive: true,
+		});
+
+		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
+		await pipeline.process(song, youtubeConnector);
+		expect(getProcessedFields(song)).to.deep.equal(
+			processedFuminnikkiSongWithEP,
+		);
+	});
+
+	it('Should not apply non-regex edit with wrong casing if edit is not case insensitive', async () => {
+		await regexEdits.saveRegexEdit({
+			search: fuminnikkiFixerInsensitive.search,
+			replace: fuminnikkiFixerInsensitive.replace,
+			isRegexDisabled: true,
+		});
+
+		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
+		await pipeline.process(song, youtubeConnector);
+		expect(getProcessedFields(song)).to.deep.equal(
+			wrongProcessedFuminnikkiSong,
+		);
+	});
+
+	it('Should apply non-regex edit with wrong casing if edit is case insensitive', async () => {
+		await regexEdits.saveRegexEdit({
+			search: fuminnikkiFixerInsensitive.search,
+			replace: fuminnikkiFixerInsensitive.replace,
+			isCaseInsensitive: true,
+			isRegexDisabled: true,
+		});
+
+		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
+		await pipeline.process(song, youtubeConnector);
+		expect(getProcessedFields(song)).to.deep.equal(
+			processedFuminnikkiSongWithEP,
+		);
+	});
+
+	it('Should apply global regex edit when it covers entire string', async () => {
+		await regexEdits.saveRegexEdit({
+			search: fuminnikkiFixer.search,
+			replace: fuminnikkiFixer.replace,
+			isGlobal: true,
+		});
+
+		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
+		await pipeline.process(song, youtubeConnector);
+		expect(getProcessedFields(song)).to.deep.equal(
+			processedFuminnikkiSongWithEP,
+		);
+	});
+
+	it('Should apply global non-regex edit when it covers entire string', async () => {
+		await regexEdits.saveRegexEdit({
+			search: fuminnikkiFixer.search,
+			replace: fuminnikkiFixer.replace,
+			isGlobal: true,
+			isRegexDisabled: true,
+		});
+
+		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
+		await pipeline.process(song, youtubeConnector);
+		expect(getProcessedFields(song)).to.deep.equal(
+			processedFuminnikkiSongWithEP,
+		);
+	});
+
+	it('Should not apply global-reliant regex edit when global is not enabled', async () => {
+		await regexEdits.saveRegexEdit({
+			search: fuminGlobal.search,
+			replace: fuminGlobal.replace,
+		});
+
+		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
+		await pipeline.process(song, youtubeConnector);
+		expect(getProcessedFields(song)).to.deep.equal(
+			wrongProcessedFuminnikkiSong,
+		);
+	});
+
+	it('Should not apply global-reliant non-regex edit when global is not enabled', async () => {
+		await regexEdits.saveRegexEdit({
+			search: fuminGlobal.search,
+			replace: fuminGlobal.replace,
+			isRegexDisabled: true,
+		});
+
+		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
+		await pipeline.process(song, youtubeConnector);
+		expect(getProcessedFields(song)).to.deep.equal(
+			wrongProcessedFuminnikkiSong,
+		);
+	});
+
+	it('Should apply global-reliant regex edit when global is enabled', async () => {
+		await regexEdits.saveRegexEdit({
+			search: fuminGlobal.search,
+			replace: fuminGlobal.replace,
+			isGlobal: true,
+		});
+
+		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
+		await pipeline.process(song, youtubeConnector);
+		expect(getProcessedFields(song)).to.deep.equal(
+			processedPartialFuminnikkiSong,
+		);
+	});
+
+	it('Should apply global-reliant non-regex edit when global is enabled', async () => {
+		await regexEdits.saveRegexEdit({
+			search: fuminGlobal.search,
+			replace: fuminGlobal.replace,
+			isGlobal: true,
+			isRegexDisabled: true,
+		});
+
+		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
+		await pipeline.process(song, youtubeConnector);
+		expect(getProcessedFields(song)).to.deep.equal(
+			processedPartialFuminnikkiSong,
+		);
+	});
+
+	it('Should not apply global regex edit with wrong casing if case insensitive is off', async () => {
+		await regexEdits.saveRegexEdit({
+			search: fuminGlobalInsensitive.search,
+			replace: fuminGlobalInsensitive.replace,
+			isGlobal: true,
+		});
+
+		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
+		await pipeline.process(song, youtubeConnector);
+		expect(getProcessedFields(song)).to.deep.equal(
+			wrongProcessedFuminnikkiSong,
+		);
+	});
+
+	it('Should not apply global non-regex edit with wrong casing if case insensitive is off', async () => {
+		await regexEdits.saveRegexEdit({
+			search: fuminGlobalInsensitive.search,
+			replace: fuminGlobalInsensitive.replace,
+			isGlobal: true,
+			isRegexDisabled: true,
+		});
+
+		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
+		await pipeline.process(song, youtubeConnector);
+		expect(getProcessedFields(song)).to.deep.equal(
+			wrongProcessedFuminnikkiSong,
+		);
+	});
+
+	it('Should apply global regex edit with wrong casing if case insensitive is on', async () => {
+		await regexEdits.saveRegexEdit({
+			search: fuminGlobalInsensitive.search,
+			replace: fuminGlobalInsensitive.replace,
+			isGlobal: true,
+			isCaseInsensitive: true,
+		});
+
+		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
+		await pipeline.process(song, youtubeConnector);
+		expect(getProcessedFields(song)).to.deep.equal(
+			processedPartialFuminnikkiSong,
+		);
+	});
+
+	it('Should apply global non-regex edit with wrong casing if case insensitive is on', async () => {
+		await regexEdits.saveRegexEdit({
+			search: fuminGlobalInsensitive.search,
+			replace: fuminGlobalInsensitive.replace,
+			isGlobal: true,
+			isCaseInsensitive: true,
+			isRegexDisabled: true,
+		});
+
+		const song = new Song(wrongFuminnikkiSong, youtubeConnector);
+		await pipeline.process(song, youtubeConnector);
+		expect(getProcessedFields(song)).to.deep.equal(
+			processedPartialFuminnikkiSong,
+		);
+	});
+
+	it('Should not apply regex if regex is disabled', async () => {
+		await regexEdits.saveRegexEdit({
+			search: epRemover.search,
+			replace: epRemover.replace,
+			isRegexDisabled: true,
+		});
+		const song = new Song(correctFuminnikkiSong, youtubeConnector);
+		await pipeline.process(song, youtubeConnector);
+		expect(getProcessedFields(song)).to.deep.equal(
+			processedFuminnikkiSongWithEP,
+		);
+	});
+});
+
 const epRemover: RegexEdit = {
 	search: {
 		track: null,
@@ -193,6 +433,51 @@ const fuminnikkiFixer: RegexEdit = {
 	},
 };
 
+const fuminnikkiFixerInsensitive: RegexEdit = {
+	search: {
+		track: null,
+		artist: 'fUmInnIkkI',
+		album: null,
+		albumArtist: null,
+	},
+	replace: {
+		track: null,
+		artist: 'フミンニッキ',
+		album: null,
+		albumArtist: null,
+	},
+};
+
+const fuminGlobal: RegexEdit = {
+	search: {
+		track: null,
+		artist: 'Fumin',
+		album: null,
+		albumArtist: null,
+	},
+	replace: {
+		track: null,
+		artist: 'フミン',
+		album: null,
+		albumArtist: null,
+	},
+};
+
+const fuminGlobalInsensitive: RegexEdit = {
+	search: {
+		track: null,
+		artist: 'fUmIn',
+		album: null,
+		albumArtist: null,
+	},
+	replace: {
+		track: null,
+		artist: 'フミン',
+		album: null,
+		albumArtist: null,
+	},
+};
+
 const artistNumberSuffixer = (suffix: number): RegexEdit => ({
 	search: {
 		track: null,
@@ -230,4 +515,18 @@ const processedFuminnikkiSongWithEP: State = {
 	album: 'Re:start - EP',
 };
 
+const wrongProcessedFuminnikkiSong: State = {
+	track: 'Re:start',
+	artist: 'Fuminnikki',
+	album: '',
+	albumArtist: '',
+};
+
+const processedPartialFuminnikkiSong: State = {
+	track: 'Re:start',
+	artist: 'フミンnikki',
+	album: '',
+	albumArtist: '',
+};
+
 const youtubeConnector = getConnectorById('youtube')!;
diff --git a/tests/background/saved-edits.test.ts b/tests/background/saved-edits.test.ts
index 544fcb0b..d09b9451 100644
--- a/tests/background/saved-edits.test.ts
+++ b/tests/background/saved-edits.test.ts
@@ -3,7 +3,6 @@ import { expect, describe, it, beforeAll } from 'vitest';
 import webextensionPolyfill from '#/mocks/webextension-polyfill';
 
 import Song from '@/core/object/song';
-import { getConnectorById } from '@/util/util-connector';
 import { SavedEdit } from '@/core/storage/options';
 import savedEdits from '@/core/storage/saved-edits';
 import SavedEditsModel from '@/core/storage/saved-edits.model';
@@ -15,7 +14,13 @@ const editedInfo = {
 	albumArtist: null,
 };
 
-const connectorStub = getConnectorById('youtube')!;
+const connectorStub = {
+	label: 'YouTube',
+	matches: ['*://www.youtube.com/*', '*://m.youtube.com/*'],
+	js: 'youtube.js',
+	id: 'youtube',
+};
+
 /**
  * Run all tests.
  */
diff --git a/tests/mocks/fetch.ts b/tests/mocks/fetch.ts
new file mode 100644
index 00000000..c09dcaa7
--- /dev/null
+++ b/tests/mocks/fetch.ts
@@ -0,0 +1,53 @@
+import { vi } from 'vitest';
+
+const createFetchResponse = (data: unknown) => ({
+	json: () => Promise.resolve(data),
+	ok: Boolean(data),
+});
+
+const responses = {
+	'https://musicbrainz.org/ws/2/release?fmt=json&query=title:+"Re:start"^3 Re:start artistname:+"フミンニッキ"^4フミンニッキ':
+		{
+			count: 14693,
+			releases: [{ id: '3e3d2a0f-3fce-4cdb-b6a1-b8c070ce9580' }],
+		},
+
+	'https://coverartarchive.org/release/3e3d2a0f-3fce-4cdb-b6a1-b8c070ce9580/front-500':
+		true,
+	'https://coverartarchive.org/release/674ccc28-5a18-4154-b0f7-e984420b0fc6/front-500':
+		false,
+	'https://musicbrainz.org/ws/2/release-group?fmt=json&query=title:+"Re:start"^3 Re:start artistname:+"フミンニッキ2"^4フミンニッキ2':
+		{
+			count: 10000,
+			releases: [{ id: '32e00a5f-7542-4be0-ad65-a18f8b3df58d' }],
+		},
+	'https://coverartarchive.org/release/32e00a5f-7542-4be0-ad65-a18f8b3df58d/front-500':
+		false,
+	'https://musicbrainz.org/ws/2/release?fmt=json&query=title:+"8a27256b6e2dfb99569bc035c7c0de159d36d6bb4e6a4b866f4bd90c95aebf0f"^3 8a27256b6e2dfb99569bc035c7c0de159d36d6bb4e6a4b866f4bd90c95aebf0f artistname:+"d21308b23b135a46b72024f1233f4942d8e294e11c1f4ba05f718bb80b50a44f1"^4d21308b23b135a46b72024f1233f4942d8e294e11c1f4ba05f718bb80b50a44f1':
+		{
+			count: 4127,
+			releases: [{ id: '84e4f664-5113-453e-a333-a4fba8022db8' }],
+		},
+	'https://coverartarchive.org/release/84e4f664-5113-453e-a333-a4fba8022db8/front-500':
+		true,
+	'https://musicbrainz.org/ws/2/release?fmt=json&query=title:+"Re:start"^3 Re:start artistname:+"Fuminnikki"^4Fuminnikki':
+		{
+			count: 14690,
+			releases: [{ id: '674ccc28-5a18-4154-b0f7-e984420b0fc6' }],
+		},
+};
+
+class FetchMocker {
+	constructor() {
+		(global.fetch as unknown) = vi.fn((input: string) => {
+			if (!(input in responses)) {
+				throw `${input} not mocked`;
+			}
+			return Promise.resolve(
+				createFetchResponse(responses[input as keyof typeof responses]),
+			);
+		});
+	}
+}
+
+export default new FetchMocker();
diff --git a/tests/setup/handle-rejections.ts b/tests/setup/handle-rejections.ts
new file mode 100644
index 00000000..e83a0f7c
--- /dev/null
+++ b/tests/setup/handle-rejections.ts
@@ -0,0 +1,6 @@
+// FAIL LOUDLY on unhandled promise rejections / errors
+process.on('unhandledRejection', (reason) => {
+	console.log(reason);
+	console.trace('FAILED TO HANDLE PROMISE REJECTION');
+	process.exit(1);
+});
diff --git a/vitest.config.ts b/vitest.config.ts
index 977024a1..7fd70f82 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -11,5 +11,6 @@ export default {
 	plugins: [ConditionalCompile()],
 	test: {
 		testTimeout: 10000,
+		setupFiles: ['./tests/setup/handle-rejections.ts'],
 	},
 };