Repository: ROpdebee/mb-userscripts
Pull request #478 (2022-06-13 11:12:52)

feat(ecau): file size and type on a-tisket images
WIP title, not sure what's going to go into this PR.

Wish list:
- [x] Retry loading dimensions if failed on a-tisket (proper fix for #438).
- [x] Image information caching on a-tisket.
- [x] Retry loading dimensions if failed for Bandcamp non-square check
- [x] Grab dimensions for maximised images in a-tisket (possibly with fix for #273, depends on how expensive it is. Another reason to do this is that the file type of Apple Music images could differ between the displayed version and the maximised one).
- [x] File size information on a-tisket pages? (via HEAD request and a response header, maybe?)
- [x] Improved file type information on a-tisket pages? (also HEAD requests, content-type header)

codecov[bot]: # [Codecov](https://codecov.io/gh/ROpdebee/mb-userscripts/pull/478?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Ruben+Opdebeeck) Report
> Merging [#478](https://codecov.io/gh/ROpdebee/mb-userscripts/pull/478?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Ruben+Opdebeeck) (10a2df9) into [main](https://codecov.io/gh/ROpdebee/mb-userscripts/commit/6ab873af0870deb58ecfa2af637c53399a8ff3a4?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Ruben+Opdebeeck) (6ab873a) will **not change** coverage.
> The diff coverage is `100.00%`.

```diff
@@            Coverage Diff            @@
##              main      #478   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           53        54    +1     
  Lines         1207      1235   +28     
  Branches       192       195    +3     
=========================================
+ Hits          1207      1235   +28     
```


| [Impacted Files](https://codecov.io/gh/ROpdebee/mb-userscripts/pull/478?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Ruben+Opdebeeck) | Coverage Δ | |
|---|---|---|
| [...ed\_cover\_art\_uploads/seeding/atisket/dimensions.ts](https://codecov.io/gh/ROpdebee/mb-userscripts/pull/478/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Ruben+Opdebeeck#diff-c3JjL21iX2VuaGFuY2VkX2NvdmVyX2FydF91cGxvYWRzL3NlZWRpbmcvYXRpc2tldC9kaW1lbnNpb25zLnRz) | `100.00% <100.00%> (ø)` | |
| [src/lib/util/dom.ts](https://codecov.io/gh/ROpdebee/mb-userscripts/pull/478/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Ruben+Opdebeeck#diff-c3JjL2xpYi91dGlsL2RvbS50cw==) | `100.00% <0.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/ROpdebee/mb-userscripts/pull/478?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Ruben+Opdebeeck).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Ruben+Opdebeeck)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/ROpdebee/mb-userscripts/pull/478?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Ruben+Opdebeeck). Last update [6ab873a...10a2df9](https://codecov.io/gh/ROpdebee/mb-userscripts/pull/478?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Ruben+Opdebeeck). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Ruben+Opdebeeck).

ROpdebee: /deploy-preview to check impact on built scripts
ROpdebee: /deploy-preview again because the bot yelled at me for not adhering to my own guidelines about PR formatting 😢 
github-actions[bot]: This PR changes 1 built userscript(s):
* `mb_enhanced_cover_art_uploads` ([install preview](https://raw.github.com/ROpdebee/mb-userscripts/dist-preview-478/mb_enhanced_cover_art_uploads.user.js), changes: 51e4d05620543aecdcf40d44cb885a10002fd86d)

[See all changes](https://github.com/ROpdebee/mb-userscripts/compare/dist...dist-preview-478)

Patch:
diff --git a/build/changelog.ts b/build/changelog.ts
index 58aa531d..b4c53885 100644
--- a/build/changelog.ts
+++ b/build/changelog.ts
@@ -48,9 +48,14 @@ export function parsePullRequestTitle(prInfo: PullRequestInfo): Promise<ChangeIn
 
 // Exported for tests
 export async function generateChangelogEntry(scriptVersion: string, prInfo: PullRequestInfo): Promise<string> {
-    const changeInfo = await parsePullRequestTitle(prInfo);
-    const changelogTitle = CC_TYPE_TO_TITLE[changeInfo.type] ?? 'Internal changes';
-    return `- **${scriptVersion}**: ${changelogTitle}: ${changeInfo.subject} ([#${prInfo.number}](${prInfo.url}))`;
+    try {
+        const changeInfo = await parsePullRequestTitle(prInfo);
+        const changelogTitle = CC_TYPE_TO_TITLE[changeInfo.type] ?? 'Internal changes';
+        return `- **${scriptVersion}**: ${changelogTitle}: ${changeInfo.subject} ([#${prInfo.number}](${prInfo.url}))`;
+    } catch (err) {
+        console.error(err);
+        return `- **${scriptVersion}**: UNKNOWN CHANGE TYPE: ${prInfo.title} ([#${prInfo.number}](${prInfo.url}))`;
+    }
 }
 
 export async function updateChangelog(scriptName: string, version: string, distRepo: string, prInfo: PullRequestInfo): Promise<void> {
diff --git a/mb_qol_inline_recording_tracks.user.js b/mb_qol_inline_recording_tracks.user.js
index a86319f1..2cc44cc6 100644
--- a/mb_qol_inline_recording_tracks.user.js
+++ b/mb_qol_inline_recording_tracks.user.js
@@ -1,6 +1,6 @@
 // ==UserScript==
 // @name         MB: QoL: Inline all recording's tracks on releases
-// @version      2021.12.18
+// @version      2022.6.13
 // @description  Display all tracks and releases on which a recording appears from the release page.
 // @author       ROpdebee
 // @license      MIT; https://opensource.org/licenses/MIT
@@ -10,13 +10,10 @@
 // @match        *://*.musicbrainz.org/release/*
 // @exclude      *://*.musicbrainz.org/release/add
 // @exclude      *://*.musicbrainz.org/release/*/edit*
-// @require      https://code.jquery.com/jquery-3.6.0.min.js
 // @run-at       document-end
 // @grant        none
 // ==/UserScript==
 
-let $ = this.$ = this.jQuery = jQuery.noConflict(true);
-
 function splitChunks(arr, chunkSize) {
     let chunks = [];
     for (let i = 0; i < arr.length; i += chunkSize) {
@@ -31,6 +28,7 @@ const queuedFetch = (() => {
 
     // This may make multiple concurrent requests if an old one is still pending
     // while the new one is queued.
+    // FIXME: This runs continuously even though no fetches are queued.
     setInterval(async () => {
         let url, resolve;
         try {
@@ -49,7 +47,7 @@ const queuedFetch = (() => {
     }, 500);
 
     return function(url) {
-        return new Promise((resolve, reject) => fetchQueue.push([url, resolve]));
+        return new Promise((resolve) => fetchQueue.push([url, resolve]));
     }
 })();
 
@@ -86,24 +84,33 @@ function insertRows(recordingTd, recordingInfo) {
         .map(formatRow)
         .map(row => '<dl class="ars"><dt>appears on:</dt><dd>' + row + '</dd></dl>')
         .join('\n');
-    $(recordingTd).find('div.ars:first')
-        .before('<div class="ars ROpdebee_inline_tracks">' + rowElements + '</div>');
+    recordingTd.querySelector('div.ars')
+        .insertAdjacentHTML('beforebegin', '<div class="ars ROpdebee_inline_tracks">' + rowElements + '</div>');
 }
 
 function loadAndInsert() {
-    let recAnchors = $('table.medium td > a[href^="/recording/"]:first-child, table.medium td > span:first-child > a[href^="/recording/"]:first-child');
+    let recAnchors = document.querySelectorAll('table.medium td > a[href^="/recording/"]:first-child, table.medium td > span:first-child > a[href^="/recording/"]:first-child');
     let todo = [...recAnchors]
         .map((a) => [a.closest('td'), a.href.split('/recording/')[1]])
-        .filter(([td, recId]) => !td.querySelector('div.ars.ROpdebee_inline_tracks'));
+        .filter(([td]) => !td.querySelector('div.ars.ROpdebee_inline_tracks'));
     let chunks = splitChunks(todo, 20);
 
     chunks.forEach(async (chunk) => {
-        let recInfo = await loadRecordingInfo(chunk.map(([td, recId]) => recId));
+        let recInfo = await loadRecordingInfo(chunk.map(([, recId]) => recId));
         chunk.forEach(([td, recId]) => insertRows(td, recInfo[recId]));
     });
 }
 
-$(document).ready(() => {
-    $('span#medium-toolbox').prepend('<button id="ROpdebee_display_inline_tracks" class="btn-link" type="button">Display track info for recordings</button> | ')
-    $('#ROpdebee_display_inline_tracks').click(loadAndInsert);
+// FIXME: onload workaround for hydration errors, not ideal, will cause script
+// to postpone initialising until cover art is loaded.
+window.addEventListener('load', () => {
+    const button = document.createElement('button');
+    button.classList.add('btn-link');
+    button.type = 'button';
+    button.textContent = 'Display track info for recordings';
+    button.addEventListener('click', loadAndInsert);
+
+    document.querySelector('span#medium-toolbox')
+        .firstChild.before(button, ' | ');
+
 });
diff --git a/mb_qol_seed_recording_disambiguation.user.js b/mb_qol_seed_recording_disambiguation.user.js
index 5c013144..d8d6fde5 100644
--- a/mb_qol_seed_recording_disambiguation.user.js
+++ b/mb_qol_seed_recording_disambiguation.user.js
@@ -1,6 +1,6 @@
 // ==UserScript==
 // @name         MB: QoL: Seed the batch recording comments script
-// @version      2021.12.23
+// @version      2022.6.13
 // @description  Seed the recording comments for the batch recording comments userscripts with live and DJ-mix data.
 // @author       ROpdebee
 // @license      MIT; https://opensource.org/licenses/MIT
@@ -29,7 +29,9 @@ function unicodeToAscii(s) {
 }
 
 function getReleaseTitle() {
-    return $('.releaseheader > h1:first-child bdi').text();
+    // Make sure we're only taking the first <bdi> element. There could be a
+    // second if the release has a disambiguation comment itself.
+    return document.querySelector('.releaseheader > h1 bdi').textContent;
 }
 
 function getDJMixComment() {
diff --git a/package-lock.json b/package-lock.json
index 6a804448..96b955bc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,9 +15,9 @@
         "ts-custom-error": "3.2.0"
       },
       "devDependencies": {
-        "@babel/core": "7.18.2",
+        "@babel/core": "7.18.5",
         "@babel/plugin-syntax-jsx": "7.17.12",
-        "@babel/plugin-transform-runtime": "7.18.2",
+        "@babel/plugin-transform-runtime": "7.18.5",
         "@babel/preset-env": "7.18.2",
         "@babel/preset-typescript": "7.17.12",
         "@delagen/eslint-plugin-deprecation": "1.3.2",
@@ -43,12 +43,12 @@
         "@types/rollup__plugin-virtual": "2.0.1",
         "@types/rollup-plugin-progress": "1.1.1",
         "@types/setup-polly-jest": "0.5.1",
-        "@typescript-eslint/eslint-plugin": "5.27.1",
-        "@typescript-eslint/parser": "5.27.1",
+        "@typescript-eslint/eslint-plugin": "5.28.0",
+        "@typescript-eslint/parser": "5.28.0",
         "babel-plugin-transform-async-to-promises": "0.8.16",
         "confusing-browser-globals": "1.0.11",
         "conventional-commits-parser": "^3.2.4",
-        "core-js": "3.22.8",
+        "core-js": "3.23.1",
         "eslint": "8.17.0",
         "eslint-config-adjunct": "^4.11.1",
         "eslint-config-prettier": "^8.5.0",
@@ -93,10 +93,10 @@
         "setup-polly-jest": "0.11.0",
         "simple-git": "3.7.1",
         "terser": "5.14.1",
-        "ts-jest": "28.0.4",
+        "ts-jest": "28.0.5",
         "ts-node": "10.8.1",
         "tsconfig-paths": "4.0.0",
-        "type-fest": "2.13.0",
+        "type-fest": "2.13.1",
         "typescript": "4.7.3",
         "warcio": "1.5.1"
       }
@@ -136,9 +136,9 @@
       }
     },
     "node_modules/@babel/core": {
-      "version": "7.18.2",
-      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.18.2.tgz",
-      "integrity": "sha512-A8pri1YJiC5UnkdrWcmfZTJTV85b4UXTAfImGmCfYmax4TR9Cw8sDS0MOk++Gp2mE/BefVJ5nwy5yzqNJbP/DQ==",
+      "version": "7.18.5",
+      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.18.5.tgz",
+      "integrity": "sha512-MGY8vg3DxMnctw0LdvSEojOsumc70g0t18gNyUdAZqB1Rpd1Bqo/svHGvt+UJ6JcGX+DIekGFDxxIWofBxLCnQ==",
       "dev": true,
       "dependencies": {
         "@ampproject/remapping": "^2.1.0",
@@ -147,10 +147,10 @@
         "@babel/helper-compilation-targets": "^7.18.2",
         "@babel/helper-module-transforms": "^7.18.0",
         "@babel/helpers": "^7.18.2",
-        "@babel/parser": "^7.18.0",
+        "@babel/parser": "^7.18.5",
         "@babel/template": "^7.16.7",
-        "@babel/traverse": "^7.18.2",
-        "@babel/types": "^7.18.2",
+        "@babel/traverse": "^7.18.5",
+        "@babel/types": "^7.18.4",
         "convert-source-map": "^1.7.0",
         "debug": "^4.1.0",
         "gensync": "^1.0.0-beta.2",
@@ -530,9 +530,9 @@
       }
     },
     "node_modules/@babel/parser": {
-      "version": "7.18.4",
-      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.18.4.tgz",
-      "integrity": "sha512-FDge0dFazETFcxGw/EXzOkN8uJp0PC7Qbm+Pe9T+av2zlBpOgunFHkQPPn+eRuClU73JF+98D531UgayY89tow==",
+      "version": "7.18.5",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.18.5.tgz",
+      "integrity": "sha512-YZWVaglMiplo7v8f1oMQ5ZPQr0vn7HPeZXxXWsxXJRjGVrzUFn9OxFQl1sb5wzfootjA/yChhW84BV+383FSOw==",
       "dev": true,
       "bin": {
         "parser": "bin/babel-parser.js"
@@ -1469,9 +1469,9 @@
       }
     },
     "node_modules/@babel/plugin-transform-runtime": {
-      "version": "7.18.2",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.18.2.tgz",
-      "integrity": "sha512-mr1ufuRMfS52ttq+1G1PD8OJNqgcTFjq3hwn8SZ5n1x1pBhi0E36rYMdTK0TsKtApJ4lDEdfXJwtGobQMHSMPg==",
+      "version": "7.18.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.18.5.tgz",
+      "integrity": "sha512-Q17hHxXr2fplrE+5BSC1j1Fo5cOA8YeP8XW3/1paI8MzF/faZGh0MaH1KC4jLAvqLPamQWHB5/B7KqSLY1kuHA==",
       "dev": true,
       "dependencies": {
         "@babel/helper-module-imports": "^7.16.7",
@@ -1761,9 +1761,9 @@
       }
     },
     "node_modules/@babel/traverse": {
-      "version": "7.18.2",
-      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.18.2.tgz",
-      "integrity": "sha512-9eNwoeovJ6KH9zcCNnENY7DMFwTU9JdGCFtqNLfUAqtUHRCOsTOqWoffosP8vKmNYeSBUv3yVJXjfd8ucwOjUA==",
+      "version": "7.18.5",
+      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.18.5.tgz",
+      "integrity": "sha512-aKXj1KT66sBj0vVzk6rEeAO6Z9aiiQ68wfDgge3nHhA/my6xMM/7HGQUNumKZaoa2qUPQ5whJG9aAifsxUKfLA==",
       "dev": true,
       "dependencies": {
         "@babel/code-frame": "^7.16.7",
@@ -1772,8 +1772,8 @@
         "@babel/helper-function-name": "^7.17.9",
         "@babel/helper-hoist-variables": "^7.16.7",
         "@babel/helper-split-export-declaration": "^7.16.7",
-        "@babel/parser": "^7.18.0",
-        "@babel/types": "^7.18.2",
+        "@babel/parser": "^7.18.5",
+        "@babel/types": "^7.18.4",
         "debug": "^4.1.0",
         "globals": "^11.1.0"
       },
@@ -3824,14 +3824,14 @@
       "dev": true
     },
     "node_modules/@typescript-eslint/eslint-plugin": {
-      "version": "5.27.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.27.1.tgz",
-      "integrity": "sha512-6dM5NKT57ZduNnJfpY81Phe9nc9wolnMCnknb1im6brWi1RYv84nbMS3olJa27B6+irUVV1X/Wb+Am0FjJdGFw==",
+      "version": "5.28.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.28.0.tgz",
+      "integrity": "sha512-DXVU6Cg29H2M6EybqSg2A+x8DgO9TCUBRp4QEXQHJceLS7ogVDP0g3Lkg/SZCqcvkAP/RruuQqK0gdlkgmhSUA==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/scope-manager": "5.27.1",
-        "@typescript-eslint/type-utils": "5.27.1",
-        "@typescript-eslint/utils": "5.27.1",
+        "@typescript-eslint/scope-manager": "5.28.0",
+        "@typescript-eslint/type-utils": "5.28.0",
+        "@typescript-eslint/utils": "5.28.0",
         "debug": "^4.3.4",
         "functional-red-black-tree": "^1.0.1",
         "ignore": "^5.2.0",
@@ -3930,14 +3930,14 @@
       }
     },
     "node_modules/@typescript-eslint/parser": {
-      "version": "5.27.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.27.1.tgz",
-      "integrity": "sha512-7Va2ZOkHi5NP+AZwb5ReLgNF6nWLGTeUJfxdkVUAPPSaAdbWNnFZzLZ4EGGmmiCTg+AwlbE1KyUYTBglosSLHQ==",
+      "version": "5.28.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.28.0.tgz",
+      "integrity": "sha512-ekqoNRNK1lAcKhZESN/PdpVsWbP9jtiNqzFWkp/yAUdZvJalw2heCYuqRmM5eUJSIYEkgq5sGOjq+ZqsLMjtRA==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/scope-manager": "5.27.1",
-        "@typescript-eslint/types": "5.27.1",
-        "@typescript-eslint/typescript-estree": "5.27.1",
+        "@typescript-eslint/scope-manager": "5.28.0",
+        "@typescript-eslint/types": "5.28.0",
+        "@typescript-eslint/typescript-estree": "5.28.0",
         "debug": "^4.3.4"
       },
       "engines": {
@@ -3957,9 +3957,9 @@
       }
     },
     "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": {
-      "version": "5.27.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.27.1.tgz",
-      "integrity": "sha512-LgogNVkBhCTZU/m8XgEYIWICD6m4dmEDbKXESCbqOXfKZxRKeqpiJXQIErv66sdopRKZPo5l32ymNqibYEH/xg==",
+      "version": "5.28.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.28.0.tgz",
+      "integrity": "sha512-2OOm8ZTOQxqkPbf+DAo8oc16sDlVR5owgJfKheBkxBKg1vAfw2JsSofH9+16VPlN9PWtv8Wzhklkqw3k/zCVxA==",
       "dev": true,
       "engines": {
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@@ -3970,13 +3970,13 @@
       }
     },
     "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": {
-      "version": "5.27.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.27.1.tgz",
-      "integrity": "sha512-DnZvvq3TAJ5ke+hk0LklvxwYsnXpRdqUY5gaVS0D4raKtbznPz71UJGnPTHEFo0GDxqLOLdMkkmVZjSpET1hFw==",
+      "version": "5.28.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.28.0.tgz",
+      "integrity": "sha512-9GX+GfpV+F4hdTtYc6OV9ZkyYilGXPmQpm6AThInpBmKJEyRSIjORJd1G9+bknb7OTFYL+Vd4FBJAO6T78OVqA==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "5.27.1",
-        "@typescript-eslint/visitor-keys": "5.27.1",
+        "@typescript-eslint/types": "5.28.0",
+        "@typescript-eslint/visitor-keys": "5.28.0",
         "debug": "^4.3.4",
         "globby": "^11.1.0",
         "is-glob": "^4.0.3",
@@ -4012,13 +4012,13 @@
       }
     },
     "node_modules/@typescript-eslint/scope-manager": {
-      "version": "5.27.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.27.1.tgz",
-      "integrity": "sha512-fQEOSa/QroWE6fAEg+bJxtRZJTH8NTskggybogHt4H9Da8zd4cJji76gA5SBlR0MgtwF7rebxTbDKB49YUCpAg==",
+      "version": "5.28.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.28.0.tgz",
+      "integrity": "sha512-LeBLTqF/he1Z+boRhSqnso6YrzcKMTQ8bO/YKEe+6+O/JGof9M0g3IJlIsqfrK/6K03MlFIlycbf1uQR1IjE+w==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "5.27.1",
-        "@typescript-eslint/visitor-keys": "5.27.1"
+        "@typescript-eslint/types": "5.28.0",
+        "@typescript-eslint/visitor-keys": "5.28.0"
       },
       "engines": {
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@@ -4029,9 +4029,9 @@
       }
     },
     "node_modules/@typescript-eslint/scope-manager/node_modules/@typescript-eslint/types": {
-      "version": "5.27.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.27.1.tgz",
-      "integrity": "sha512-LgogNVkBhCTZU/m8XgEYIWICD6m4dmEDbKXESCbqOXfKZxRKeqpiJXQIErv66sdopRKZPo5l32ymNqibYEH/xg==",
+      "version": "5.28.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.28.0.tgz",
+      "integrity": "sha512-2OOm8ZTOQxqkPbf+DAo8oc16sDlVR5owgJfKheBkxBKg1vAfw2JsSofH9+16VPlN9PWtv8Wzhklkqw3k/zCVxA==",
       "dev": true,
       "engines": {
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@@ -4042,12 +4042,12 @@
       }
     },
     "node_modules/@typescript-eslint/type-utils": {
-      "version": "5.27.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.27.1.tgz",
-      "integrity": "sha512-+UC1vVUWaDHRnC2cQrCJ4QtVjpjjCgjNFpg8b03nERmkHv9JV9X5M19D7UFMd+/G7T/sgFwX2pGmWK38rqyvXw==",
+      "version": "5.28.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.28.0.tgz",
+      "integrity": "sha512-SyKjKh4CXPglueyC6ceAFytjYWMoPHMswPQae236zqe1YbhvCVQyIawesYywGiu98L9DwrxsBN69vGIVxJ4mQQ==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/utils": "5.27.1",
+        "@typescript-eslint/utils": "5.28.0",
         "debug": "^4.3.4",
         "tsutils": "^3.21.0"
       },
@@ -4140,15 +4140,15 @@
       }
     },
     "node_modules/@typescript-eslint/utils": {
-      "version": "5.27.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.27.1.tgz",
-      "integrity": "sha512-mZ9WEn1ZLDaVrhRaYgzbkXBkTPghPFsup8zDbbsYTxC5OmqrFE7skkKS/sraVsLP3TcT3Ki5CSyEFBRkLH/H/w==",
+      "version": "5.28.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.28.0.tgz",
+      "integrity": "sha512-E60N5L0fjv7iPJV3UGc4EC+A3Lcj4jle9zzR0gW7vXhflO7/J29kwiTGITA2RlrmPokKiZbBy2DgaclCaEUs6g==",
       "dev": true,
       "dependencies": {
         "@types/json-schema": "^7.0.9",
-        "@typescript-eslint/scope-manager": "5.27.1",
-        "@typescript-eslint/types": "5.27.1",
-        "@typescript-eslint/typescript-estree": "5.27.1",
+        "@typescript-eslint/scope-manager": "5.28.0",
+        "@typescript-eslint/types": "5.28.0",
+        "@typescript-eslint/typescript-estree": "5.28.0",
         "eslint-scope": "^5.1.1",
         "eslint-utils": "^3.0.0"
       },
@@ -4164,9 +4164,9 @@
       }
     },
     "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": {
-      "version": "5.27.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.27.1.tgz",
-      "integrity": "sha512-LgogNVkBhCTZU/m8XgEYIWICD6m4dmEDbKXESCbqOXfKZxRKeqpiJXQIErv66sdopRKZPo5l32ymNqibYEH/xg==",
+      "version": "5.28.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.28.0.tgz",
+      "integrity": "sha512-2OOm8ZTOQxqkPbf+DAo8oc16sDlVR5owgJfKheBkxBKg1vAfw2JsSofH9+16VPlN9PWtv8Wzhklkqw3k/zCVxA==",
       "dev": true,
       "engines": {
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@@ -4177,13 +4177,13 @@
       }
     },
     "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": {
-      "version": "5.27.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.27.1.tgz",
-      "integrity": "sha512-DnZvvq3TAJ5ke+hk0LklvxwYsnXpRdqUY5gaVS0D4raKtbznPz71UJGnPTHEFo0GDxqLOLdMkkmVZjSpET1hFw==",
+      "version": "5.28.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.28.0.tgz",
+      "integrity": "sha512-9GX+GfpV+F4hdTtYc6OV9ZkyYilGXPmQpm6AThInpBmKJEyRSIjORJd1G9+bknb7OTFYL+Vd4FBJAO6T78OVqA==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "5.27.1",
-        "@typescript-eslint/visitor-keys": "5.27.1",
+        "@typescript-eslint/types": "5.28.0",
+        "@typescript-eslint/visitor-keys": "5.28.0",
         "debug": "^4.3.4",
         "globby": "^11.1.0",
         "is-glob": "^4.0.3",
@@ -4219,12 +4219,12 @@
       }
     },
     "node_modules/@typescript-eslint/visitor-keys": {
-      "version": "5.27.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.27.1.tgz",
-      "integrity": "sha512-xYs6ffo01nhdJgPieyk7HAOpjhTsx7r/oB9LWEhwAXgwn33tkr+W8DI2ChboqhZlC4q3TC6geDYPoiX8ROqyOQ==",
+      "version": "5.28.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.28.0.tgz",
+      "integrity": "sha512-BtfP1vCor8cWacovzzPFOoeW4kBQxzmhxGoOpt0v1SFvG+nJ0cWaVdJk7cky1ArTcFHHKNIxyo2LLr3oNkSuXA==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "5.27.1",
+        "@typescript-eslint/types": "5.28.0",
         "eslint-visitor-keys": "^3.3.0"
       },
       "engines": {
@@ -4236,9 +4236,9 @@
       }
     },
     "node_modules/@typescript-eslint/visitor-keys/node_modules/@typescript-eslint/types": {
-      "version": "5.27.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.27.1.tgz",
-      "integrity": "sha512-LgogNVkBhCTZU/m8XgEYIWICD6m4dmEDbKXESCbqOXfKZxRKeqpiJXQIErv66sdopRKZPo5l32ymNqibYEH/xg==",
+      "version": "5.28.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.28.0.tgz",
+      "integrity": "sha512-2OOm8ZTOQxqkPbf+DAo8oc16sDlVR5owgJfKheBkxBKg1vAfw2JsSofH9+16VPlN9PWtv8Wzhklkqw3k/zCVxA==",
       "dev": true,
       "engines": {
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@@ -5209,9 +5209,9 @@
       }
     },
     "node_modules/core-js": {
-      "version": "3.22.8",
-      "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.22.8.tgz",
-      "integrity": "sha512-UoGQ/cfzGYIuiq6Z7vWL1HfkE9U9IZ4Ub+0XSiJTCzvbZzgPA69oDF2f+lgJ6dFFLEdjW5O6svvoKzXX23xFkA==",
+      "version": "3.23.1",
+      "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.23.1.tgz",
+      "integrity": "sha512-wfMYHWi1WQjpgZNC9kAlN4ut04TM9fUTdi7CqIoTVM7yaiOUQTklOzfb+oWH3r9edQcT3F887swuVmxrV+CC8w==",
       "dev": true,
       "hasInstallScript": true,
       "funding": {
@@ -13750,9 +13750,9 @@
       }
     },
     "node_modules/ts-jest": {
-      "version": "28.0.4",
-      "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-28.0.4.tgz",
-      "integrity": "sha512-S6uRDDdCJBvnZqyGjB4VCnwbQrbgdL8WPeP4jevVSpYsBaeGRQAIS08o3Svav2Ex+oXwLgJ/m7F24TNq62kA1A==",
+      "version": "28.0.5",
+      "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-28.0.5.tgz",
+      "integrity": "sha512-Sx9FyP9pCY7pUzQpy4FgRZf2bhHY3za576HMKJFs+OnQ9jS96Du5vNsDKkyedQkik+sEabbKAnCliv9BEsHZgQ==",
       "dev": true,
       "dependencies": {
         "bs-logger": "0.x",
@@ -13762,7 +13762,7 @@
         "lodash.memoize": "4.x",
         "make-error": "1.x",
         "semver": "7.x",
-        "yargs-parser": "^20.x"
+        "yargs-parser": "^21.0.1"
       },
       "bin": {
         "ts-jest": "cli.js"
@@ -13803,6 +13803,15 @@
         "node": ">=10"
       }
     },
+    "node_modules/ts-jest/node_modules/yargs-parser": {
+      "version": "21.0.1",
+      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.0.1.tgz",
+      "integrity": "sha512-9BK1jFpLzJROCI5TzwZL/TU4gqjK5xiHV/RfWLOahrjAko/e4DJkRDZQXfvqAsiZzzYhgAzbgz6lg48jcm4GLg==",
+      "dev": true,
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "node_modules/ts-node": {
       "version": "10.8.1",
       "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.8.1.tgz",
@@ -13924,9 +13933,9 @@
       }
     },
     "node_modules/type-fest": {
-      "version": "2.13.0",
-      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.13.0.tgz",
-      "integrity": "sha512-lPfAm42MxE4/456+QyIaaVBAwgpJb6xZ8PRu09utnhPdWwcyj9vgy6Sq0Z5yNbJ21EdxB5dRU/Qg8bsyAMtlcw==",
+      "version": "2.13.1",
+      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.13.1.tgz",
+      "integrity": "sha512-hXYyrPFwETT2swFLHeoKtJrvSF/ftG/sA15/8nGaLuaDGfVAaq8DYFpu4yOyV4tzp082WqnTEoMsm3flKMI2FQ==",
       "dev": true,
       "engines": {
         "node": ">=12.20"
@@ -14673,9 +14682,9 @@
       "dev": true
     },
     "@babel/core": {
-      "version": "7.18.2",
-      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.18.2.tgz",
-      "integrity": "sha512-A8pri1YJiC5UnkdrWcmfZTJTV85b4UXTAfImGmCfYmax4TR9Cw8sDS0MOk++Gp2mE/BefVJ5nwy5yzqNJbP/DQ==",
+      "version": "7.18.5",
+      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.18.5.tgz",
+      "integrity": "sha512-MGY8vg3DxMnctw0LdvSEojOsumc70g0t18gNyUdAZqB1Rpd1Bqo/svHGvt+UJ6JcGX+DIekGFDxxIWofBxLCnQ==",
       "dev": true,
       "requires": {
         "@ampproject/remapping": "^2.1.0",
@@ -14684,10 +14693,10 @@
         "@babel/helper-compilation-targets": "^7.18.2",
         "@babel/helper-module-transforms": "^7.18.0",
         "@babel/helpers": "^7.18.2",
-        "@babel/parser": "^7.18.0",
+        "@babel/parser": "^7.18.5",
         "@babel/template": "^7.16.7",
-        "@babel/traverse": "^7.18.2",
-        "@babel/types": "^7.18.2",
+        "@babel/traverse": "^7.18.5",
+        "@babel/types": "^7.18.4",
         "convert-source-map": "^1.7.0",
         "debug": "^4.1.0",
         "gensync": "^1.0.0-beta.2",
@@ -14972,9 +14981,9 @@
       }
     },
     "@babel/parser": {
-      "version": "7.18.4",
-      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.18.4.tgz",
-      "integrity": "sha512-FDge0dFazETFcxGw/EXzOkN8uJp0PC7Qbm+Pe9T+av2zlBpOgunFHkQPPn+eRuClU73JF+98D531UgayY89tow==",
+      "version": "7.18.5",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.18.5.tgz",
+      "integrity": "sha512-YZWVaglMiplo7v8f1oMQ5ZPQr0vn7HPeZXxXWsxXJRjGVrzUFn9OxFQl1sb5wzfootjA/yChhW84BV+383FSOw==",
       "dev": true
     },
     "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": {
@@ -15578,9 +15587,9 @@
       }
     },
     "@babel/plugin-transform-runtime": {
-      "version": "7.18.2",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.18.2.tgz",
-      "integrity": "sha512-mr1ufuRMfS52ttq+1G1PD8OJNqgcTFjq3hwn8SZ5n1x1pBhi0E36rYMdTK0TsKtApJ4lDEdfXJwtGobQMHSMPg==",
+      "version": "7.18.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.18.5.tgz",
+      "integrity": "sha512-Q17hHxXr2fplrE+5BSC1j1Fo5cOA8YeP8XW3/1paI8MzF/faZGh0MaH1KC4jLAvqLPamQWHB5/B7KqSLY1kuHA==",
       "dev": true,
       "requires": {
         "@babel/helper-module-imports": "^7.16.7",
@@ -15795,9 +15804,9 @@
       }
     },
     "@babel/traverse": {
-      "version": "7.18.2",
-      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.18.2.tgz",
-      "integrity": "sha512-9eNwoeovJ6KH9zcCNnENY7DMFwTU9JdGCFtqNLfUAqtUHRCOsTOqWoffosP8vKmNYeSBUv3yVJXjfd8ucwOjUA==",
+      "version": "7.18.5",
+      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.18.5.tgz",
+      "integrity": "sha512-aKXj1KT66sBj0vVzk6rEeAO6Z9aiiQ68wfDgge3nHhA/my6xMM/7HGQUNumKZaoa2qUPQ5whJG9aAifsxUKfLA==",
       "dev": true,
       "requires": {
         "@babel/code-frame": "^7.16.7",
@@ -15806,8 +15815,8 @@
         "@babel/helper-function-name": "^7.17.9",
         "@babel/helper-hoist-variables": "^7.16.7",
         "@babel/helper-split-export-declaration": "^7.16.7",
-        "@babel/parser": "^7.18.0",
-        "@babel/types": "^7.18.2",
+        "@babel/parser": "^7.18.5",
+        "@babel/types": "^7.18.4",
         "debug": "^4.1.0",
         "globals": "^11.1.0"
       }
@@ -17434,14 +17443,14 @@
       "dev": true
     },
     "@typescript-eslint/eslint-plugin": {
-      "version": "5.27.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.27.1.tgz",
-      "integrity": "sha512-6dM5NKT57ZduNnJfpY81Phe9nc9wolnMCnknb1im6brWi1RYv84nbMS3olJa27B6+irUVV1X/Wb+Am0FjJdGFw==",
+      "version": "5.28.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.28.0.tgz",
+      "integrity": "sha512-DXVU6Cg29H2M6EybqSg2A+x8DgO9TCUBRp4QEXQHJceLS7ogVDP0g3Lkg/SZCqcvkAP/RruuQqK0gdlkgmhSUA==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/scope-manager": "5.27.1",
-        "@typescript-eslint/type-utils": "5.27.1",
-        "@typescript-eslint/utils": "5.27.1",
+        "@typescript-eslint/scope-manager": "5.28.0",
+        "@typescript-eslint/type-utils": "5.28.0",
+        "@typescript-eslint/utils": "5.28.0",
         "debug": "^4.3.4",
         "functional-red-black-tree": "^1.0.1",
         "ignore": "^5.2.0",
@@ -17498,31 +17507,31 @@
       }
     },
     "@typescript-eslint/parser": {
-      "version": "5.27.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.27.1.tgz",
-      "integrity": "sha512-7Va2ZOkHi5NP+AZwb5ReLgNF6nWLGTeUJfxdkVUAPPSaAdbWNnFZzLZ4EGGmmiCTg+AwlbE1KyUYTBglosSLHQ==",
+      "version": "5.28.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.28.0.tgz",
+      "integrity": "sha512-ekqoNRNK1lAcKhZESN/PdpVsWbP9jtiNqzFWkp/yAUdZvJalw2heCYuqRmM5eUJSIYEkgq5sGOjq+ZqsLMjtRA==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/scope-manager": "5.27.1",
-        "@typescript-eslint/types": "5.27.1",
-        "@typescript-eslint/typescript-estree": "5.27.1",
+        "@typescript-eslint/scope-manager": "5.28.0",
+        "@typescript-eslint/types": "5.28.0",
+        "@typescript-eslint/typescript-estree": "5.28.0",
         "debug": "^4.3.4"
       },
       "dependencies": {
         "@typescript-eslint/types": {
-          "version": "5.27.1",
-          "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.27.1.tgz",
-          "integrity": "sha512-LgogNVkBhCTZU/m8XgEYIWICD6m4dmEDbKXESCbqOXfKZxRKeqpiJXQIErv66sdopRKZPo5l32ymNqibYEH/xg==",
+          "version": "5.28.0",
+          "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.28.0.tgz",
+          "integrity": "sha512-2OOm8ZTOQxqkPbf+DAo8oc16sDlVR5owgJfKheBkxBKg1vAfw2JsSofH9+16VPlN9PWtv8Wzhklkqw3k/zCVxA==",
           "dev": true
         },
         "@typescript-eslint/typescript-estree": {
-          "version": "5.27.1",
-          "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.27.1.tgz",
-          "integrity": "sha512-DnZvvq3TAJ5ke+hk0LklvxwYsnXpRdqUY5gaVS0D4raKtbznPz71UJGnPTHEFo0GDxqLOLdMkkmVZjSpET1hFw==",
+          "version": "5.28.0",
+          "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.28.0.tgz",
+          "integrity": "sha512-9GX+GfpV+F4hdTtYc6OV9ZkyYilGXPmQpm6AThInpBmKJEyRSIjORJd1G9+bknb7OTFYL+Vd4FBJAO6T78OVqA==",
           "dev": true,
           "requires": {
-            "@typescript-eslint/types": "5.27.1",
-            "@typescript-eslint/visitor-keys": "5.27.1",
+            "@typescript-eslint/types": "5.28.0",
+            "@typescript-eslint/visitor-keys": "5.28.0",
             "debug": "^4.3.4",
             "globby": "^11.1.0",
             "is-glob": "^4.0.3",
@@ -17542,30 +17551,30 @@
       }
     },
     "@typescript-eslint/scope-manager": {
-      "version": "5.27.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.27.1.tgz",
-      "integrity": "sha512-fQEOSa/QroWE6fAEg+bJxtRZJTH8NTskggybogHt4H9Da8zd4cJji76gA5SBlR0MgtwF7rebxTbDKB49YUCpAg==",
+      "version": "5.28.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.28.0.tgz",
+      "integrity": "sha512-LeBLTqF/he1Z+boRhSqnso6YrzcKMTQ8bO/YKEe+6+O/JGof9M0g3IJlIsqfrK/6K03MlFIlycbf1uQR1IjE+w==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/types": "5.27.1",
-        "@typescript-eslint/visitor-keys": "5.27.1"
+        "@typescript-eslint/types": "5.28.0",
+        "@typescript-eslint/visitor-keys": "5.28.0"
       },
       "dependencies": {
         "@typescript-eslint/types": {
-          "version": "5.27.1",
-          "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.27.1.tgz",
-          "integrity": "sha512-LgogNVkBhCTZU/m8XgEYIWICD6m4dmEDbKXESCbqOXfKZxRKeqpiJXQIErv66sdopRKZPo5l32ymNqibYEH/xg==",
+          "version": "5.28.0",
+          "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.28.0.tgz",
+          "integrity": "sha512-2OOm8ZTOQxqkPbf+DAo8oc16sDlVR5owgJfKheBkxBKg1vAfw2JsSofH9+16VPlN9PWtv8Wzhklkqw3k/zCVxA==",
           "dev": true
         }
       }
     },
     "@typescript-eslint/type-utils": {
-      "version": "5.27.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.27.1.tgz",
-      "integrity": "sha512-+UC1vVUWaDHRnC2cQrCJ4QtVjpjjCgjNFpg8b03nERmkHv9JV9X5M19D7UFMd+/G7T/sgFwX2pGmWK38rqyvXw==",
+      "version": "5.28.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.28.0.tgz",
+      "integrity": "sha512-SyKjKh4CXPglueyC6ceAFytjYWMoPHMswPQae236zqe1YbhvCVQyIawesYywGiu98L9DwrxsBN69vGIVxJ4mQQ==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/utils": "5.27.1",
+        "@typescript-eslint/utils": "5.28.0",
         "debug": "^4.3.4",
         "tsutils": "^3.21.0"
       }
@@ -17613,33 +17622,33 @@
       }
     },
     "@typescript-eslint/utils": {
-      "version": "5.27.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.27.1.tgz",
-      "integrity": "sha512-mZ9WEn1ZLDaVrhRaYgzbkXBkTPghPFsup8zDbbsYTxC5OmqrFE7skkKS/sraVsLP3TcT3Ki5CSyEFBRkLH/H/w==",
+      "version": "5.28.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.28.0.tgz",
+      "integrity": "sha512-E60N5L0fjv7iPJV3UGc4EC+A3Lcj4jle9zzR0gW7vXhflO7/J29kwiTGITA2RlrmPokKiZbBy2DgaclCaEUs6g==",
       "dev": true,
       "requires": {
         "@types/json-schema": "^7.0.9",
-        "@typescript-eslint/scope-manager": "5.27.1",
-        "@typescript-eslint/types": "5.27.1",
-        "@typescript-eslint/typescript-estree": "5.27.1",
+        "@typescript-eslint/scope-manager": "5.28.0",
+        "@typescript-eslint/types": "5.28.0",
+        "@typescript-eslint/typescript-estree": "5.28.0",
         "eslint-scope": "^5.1.1",
         "eslint-utils": "^3.0.0"
       },
       "dependencies": {
         "@typescript-eslint/types": {
-          "version": "5.27.1",
-          "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.27.1.tgz",
-          "integrity": "sha512-LgogNVkBhCTZU/m8XgEYIWICD6m4dmEDbKXESCbqOXfKZxRKeqpiJXQIErv66sdopRKZPo5l32ymNqibYEH/xg==",
+          "version": "5.28.0",
+          "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.28.0.tgz",
+          "integrity": "sha512-2OOm8ZTOQxqkPbf+DAo8oc16sDlVR5owgJfKheBkxBKg1vAfw2JsSofH9+16VPlN9PWtv8Wzhklkqw3k/zCVxA==",
           "dev": true
         },
         "@typescript-eslint/typescript-estree": {
-          "version": "5.27.1",
-          "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.27.1.tgz",
-          "integrity": "sha512-DnZvvq3TAJ5ke+hk0LklvxwYsnXpRdqUY5gaVS0D4raKtbznPz71UJGnPTHEFo0GDxqLOLdMkkmVZjSpET1hFw==",
+          "version": "5.28.0",
+          "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.28.0.tgz",
+          "integrity": "sha512-9GX+GfpV+F4hdTtYc6OV9ZkyYilGXPmQpm6AThInpBmKJEyRSIjORJd1G9+bknb7OTFYL+Vd4FBJAO6T78OVqA==",
           "dev": true,
           "requires": {
-            "@typescript-eslint/types": "5.27.1",
-            "@typescript-eslint/visitor-keys": "5.27.1",
+            "@typescript-eslint/types": "5.28.0",
+            "@typescript-eslint/visitor-keys": "5.28.0",
             "debug": "^4.3.4",
             "globby": "^11.1.0",
             "is-glob": "^4.0.3",
@@ -17659,19 +17668,19 @@
       }
     },
     "@typescript-eslint/visitor-keys": {
-      "version": "5.27.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.27.1.tgz",
-      "integrity": "sha512-xYs6ffo01nhdJgPieyk7HAOpjhTsx7r/oB9LWEhwAXgwn33tkr+W8DI2ChboqhZlC4q3TC6geDYPoiX8ROqyOQ==",
+      "version": "5.28.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.28.0.tgz",
+      "integrity": "sha512-BtfP1vCor8cWacovzzPFOoeW4kBQxzmhxGoOpt0v1SFvG+nJ0cWaVdJk7cky1ArTcFHHKNIxyo2LLr3oNkSuXA==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/types": "5.27.1",
+        "@typescript-eslint/types": "5.28.0",
         "eslint-visitor-keys": "^3.3.0"
       },
       "dependencies": {
         "@typescript-eslint/types": {
-          "version": "5.27.1",
-          "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.27.1.tgz",
-          "integrity": "sha512-LgogNVkBhCTZU/m8XgEYIWICD6m4dmEDbKXESCbqOXfKZxRKeqpiJXQIErv66sdopRKZPo5l32ymNqibYEH/xg==",
+          "version": "5.28.0",
+          "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.28.0.tgz",
+          "integrity": "sha512-2OOm8ZTOQxqkPbf+DAo8oc16sDlVR5owgJfKheBkxBKg1vAfw2JsSofH9+16VPlN9PWtv8Wzhklkqw3k/zCVxA==",
           "dev": true
         }
       }
@@ -18397,9 +18406,9 @@
       }
     },
     "core-js": {
-      "version": "3.22.8",
-      "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.22.8.tgz",
-      "integrity": "sha512-UoGQ/cfzGYIuiq6Z7vWL1HfkE9U9IZ4Ub+0XSiJTCzvbZzgPA69oDF2f+lgJ6dFFLEdjW5O6svvoKzXX23xFkA==",
+      "version": "3.23.1",
+      "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.23.1.tgz",
+      "integrity": "sha512-wfMYHWi1WQjpgZNC9kAlN4ut04TM9fUTdi7CqIoTVM7yaiOUQTklOzfb+oWH3r9edQcT3F887swuVmxrV+CC8w==",
       "dev": true
     },
     "core-js-compat": {
@@ -24632,9 +24641,9 @@
       "integrity": "sha512-cBvC2QjtvJ9JfWLvstVnI45Y46Y5dMxIaG1TDMGAD/R87hpvqFL+7LhvUDhnRCfOnx/xitollFWWvUKKKhbN0A=="
     },
     "ts-jest": {
-      "version": "28.0.4",
-      "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-28.0.4.tgz",
-      "integrity": "sha512-S6uRDDdCJBvnZqyGjB4VCnwbQrbgdL8WPeP4jevVSpYsBaeGRQAIS08o3Svav2Ex+oXwLgJ/m7F24TNq62kA1A==",
+      "version": "28.0.5",
+      "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-28.0.5.tgz",
+      "integrity": "sha512-Sx9FyP9pCY7pUzQpy4FgRZf2bhHY3za576HMKJFs+OnQ9jS96Du5vNsDKkyedQkik+sEabbKAnCliv9BEsHZgQ==",
       "dev": true,
       "requires": {
         "bs-logger": "0.x",
@@ -24644,7 +24653,7 @@
         "lodash.memoize": "4.x",
         "make-error": "1.x",
         "semver": "7.x",
-        "yargs-parser": "^20.x"
+        "yargs-parser": "^21.0.1"
       },
       "dependencies": {
         "semver": {
@@ -24655,6 +24664,12 @@
           "requires": {
             "lru-cache": "^6.0.0"
           }
+        },
+        "yargs-parser": {
+          "version": "21.0.1",
+          "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.0.1.tgz",
+          "integrity": "sha512-9BK1jFpLzJROCI5TzwZL/TU4gqjK5xiHV/RfWLOahrjAko/e4DJkRDZQXfvqAsiZzzYhgAzbgz6lg48jcm4GLg==",
+          "dev": true
         }
       }
     },
@@ -24745,9 +24760,9 @@
       "dev": true
     },
     "type-fest": {
-      "version": "2.13.0",
-      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.13.0.tgz",
-      "integrity": "sha512-lPfAm42MxE4/456+QyIaaVBAwgpJb6xZ8PRu09utnhPdWwcyj9vgy6Sq0Z5yNbJ21EdxB5dRU/Qg8bsyAMtlcw==",
+      "version": "2.13.1",
+      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.13.1.tgz",
+      "integrity": "sha512-hXYyrPFwETT2swFLHeoKtJrvSF/ftG/sA15/8nGaLuaDGfVAaq8DYFpu4yOyV4tzp082WqnTEoMsm3flKMI2FQ==",
       "dev": true
     },
     "typescript": {
diff --git a/package.json b/package.json
index e2eccaa4..b6910f8c 100644
--- a/package.json
+++ b/package.json
@@ -22,9 +22,9 @@
   "homepage": "https://github.com/ROpdebee/mb-userscripts#readme",
   "private": true,
   "devDependencies": {
-    "@babel/core": "7.18.2",
+    "@babel/core": "7.18.5",
     "@babel/plugin-syntax-jsx": "7.17.12",
-    "@babel/plugin-transform-runtime": "7.18.2",
+    "@babel/plugin-transform-runtime": "7.18.5",
     "@babel/preset-env": "7.18.2",
     "@babel/preset-typescript": "7.17.12",
     "@delagen/eslint-plugin-deprecation": "1.3.2",
@@ -50,12 +50,12 @@
     "@types/rollup__plugin-virtual": "2.0.1",
     "@types/rollup-plugin-progress": "1.1.1",
     "@types/setup-polly-jest": "0.5.1",
-    "@typescript-eslint/eslint-plugin": "5.27.1",
-    "@typescript-eslint/parser": "5.27.1",
+    "@typescript-eslint/eslint-plugin": "5.28.0",
+    "@typescript-eslint/parser": "5.28.0",
     "babel-plugin-transform-async-to-promises": "0.8.16",
     "confusing-browser-globals": "1.0.11",
     "conventional-commits-parser": "^3.2.4",
-    "core-js": "3.22.8",
+    "core-js": "3.23.1",
     "eslint": "8.17.0",
     "eslint-config-adjunct": "^4.11.1",
     "eslint-config-prettier": "^8.5.0",
@@ -100,10 +100,10 @@
     "setup-polly-jest": "0.11.0",
     "simple-git": "3.7.1",
     "terser": "5.14.1",
-    "ts-jest": "28.0.4",
+    "ts-jest": "28.0.5",
     "ts-node": "10.8.1",
     "tsconfig-paths": "4.0.0",
-    "type-fest": "2.13.0",
+    "type-fest": "2.13.1",
     "typescript": "4.7.3",
     "warcio": "1.5.1"
   },
diff --git a/src/mb_enhanced_cover_art_uploads/image_dimensions.ts b/src/mb_enhanced_cover_art_uploads/image_dimensions.ts
deleted file mode 100644
index 46415eb8..00000000
--- a/src/mb_enhanced_cover_art_uploads/image_dimensions.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-interface ImageDimensions {
-    height: number;
-    width: number;
-}
-
-// TODO: This originates from mb_caa_dimensions but is also used here. Not sure
-// where to put it. It might make sense to put it in the mb_caa_dimensions source
-// tree later on, and import it in this source tree where necessary.
-export function getImageDimensions(url: string): Promise<ImageDimensions> {
-    return new Promise((resolve, reject) => {
-        let done = false;
-
-        function dimensionsLoaded(dimensions: ImageDimensions): void {
-            // Make sure we don't poll again, it's not necessary.
-            clearInterval(interval);
-            if (!done) {  // Prevent resolving twice.
-                resolve(dimensions);
-                done = true;
-                img.src = '';  // Cancel loading the image
-            }
-        }
-
-        function dimensionsFailed(): void {
-            clearInterval(interval);
-            if (!done) {
-                done = true;
-                reject();
-            }
-        }
-
-        const img = document.createElement('img');
-        img.addEventListener('load', () => {
-            dimensionsLoaded({
-                height: img.naturalHeight,
-                width: img.naturalWidth,
-            });
-        });
-        img.addEventListener('error', dimensionsFailed);
-
-        // onload and onerror are asynchronous, so this interval should have
-        // already been set before they are called.
-        const interval = window.setInterval(() => {
-            if (img.naturalHeight) {
-                // naturalHeight will be non-zero as soon as enough of the image
-                // is loaded to determine its dimensions.
-                dimensionsLoaded({
-                    height: img.naturalHeight,
-                    width: img.naturalWidth,
-                });
-            }
-        }, 50);
-
-        // Start loading the image
-        img.src = url;
-    });
-}
diff --git a/src/mb_enhanced_cover_art_uploads/providers/bandcamp.ts b/src/mb_enhanced_cover_art_uploads/providers/bandcamp.ts
index 6ec67189..37b128bd 100644
--- a/src/mb_enhanced_cover_art_uploads/providers/bandcamp.ts
+++ b/src/mb_enhanced_cover_art_uploads/providers/bandcamp.ts
@@ -4,9 +4,9 @@ import { LOGGER } from '@lib/logging/logger';
 import { ArtworkTypeIDs } from '@lib/MB/CoverArt';
 import { filterNonNull } from '@lib/util/array';
 import { parseDOM, qs, qsa, qsMaybe } from '@lib/util/dom';
+import { getImageDimensions } from '@src/mb_caa_dimensions/dimensions';
 
 import type { CoverArt, ParsedTrackImage } from './base';
-import { getImageDimensions } from '../image_dimensions';
 import { ProviderWithTrackImages } from './base';
 
 export class BandcampProvider extends ProviderWithTrackImages {
diff --git a/src/mb_enhanced_cover_art_uploads/seeding/atisket/dimensions.ts b/src/mb_enhanced_cover_art_uploads/seeding/atisket/dimensions.ts
new file mode 100644
index 00000000..bb62e64b
--- /dev/null
+++ b/src/mb_enhanced_cover_art_uploads/seeding/atisket/dimensions.ts
@@ -0,0 +1,125 @@
+import pRetry from 'p-retry';
+
+import type { Dimensions, FileInfo } from '@src/mb_caa_dimensions/ImageInfo';
+import { LOGGER } from '@lib/logging/logger';
+import { safeParseJSON } from '@lib/util/json';
+import { gmxhr, HTTPResponseError } from '@lib/util/xhr';
+import { BaseImage } from '@src/mb_caa_dimensions/Image';
+
+// Use a multiple of 3, most a-tisket releases have 3 images.
+// Currently set to 30, should allow 10 releases open in parallel.
+export const MAX_CACHED_IMAGES = 30;
+
+export const CACHE_LOCALSTORAGE_KEY = 'ROpdebee_dimensions_cache';
+
+export interface CacheEntry {
+    dimensions?: Dimensions;
+    fileInfo?: FileInfo;
+    addedDatetime: number;
+}
+export type CacheStore = Record<string, CacheEntry>;
+
+// Simple local storage-backed info cache. We want to cache a image information
+// for a limited number of images to prevent reloading information that we
+// already loaded. E.g. we loaded image information on the main page, and now
+// the images are displayed again on the post-submit screen. However, we don't
+// need as large of a cache as CAA Dimensions, and we don't want to reuse the
+// IndexedDB implementation since it adds quite a few dependencies.
+// We cannot use session storage since that's not shared between tabs and the
+// submission button opens a new tab.
+export /* for tests */ const localStorageCache = {
+    getStore: function(): CacheStore {
+        const store = safeParseJSON<CacheStore>(localStorage.getItem(CACHE_LOCALSTORAGE_KEY) ?? '{}');
+        if (!store) {
+            LOGGER.warn('Cache was malformed, resetting');
+            this.putStore({});
+            return {};
+        }
+        return store;
+    },
+
+    putStore: function(store: CacheStore): void {
+        localStorage.setItem(CACHE_LOCALSTORAGE_KEY, JSON.stringify(store));
+    },
+
+    getInfo: function(imageUrl: string): CacheEntry | undefined {
+        return this.getStore()[imageUrl];
+    },
+
+    putInfo: function(imageUrl: string, cacheEntry: Omit<CacheEntry, 'addedDatetime'>): void {
+        const prevStore = this.getStore();
+        if (Object.keys(prevStore).length >= MAX_CACHED_IMAGES) {
+            const entries = Object.entries(prevStore);
+            entries.sort(([, info1], [, info2]) => info2.addedDatetime - info1.addedDatetime);
+            // Cannot use Object.fromEntries, it's not available in all browser versions that we support and a-tisket doesn't polyfill it.
+            // So we can't just create a new object and assign it. Instead, just delete the entries we need to delete.
+            for (const [url] of entries.slice(MAX_CACHED_IMAGES - 1)) {
+                delete prevStore[url];
+            }
+        }
+
+        this.putStore({
+            ...prevStore,
+            [imageUrl]: {
+                ...cacheEntry,
+                addedDatetime: Date.now(),
+            },
+        });
+    },
+
+    getDimensions: function(imageUrl: string): Promise<Dimensions | undefined> {
+        return Promise.resolve(this.getInfo(imageUrl)?.dimensions);
+    },
+
+    getFileInfo: function(imageUrl: string): Promise<FileInfo | undefined> {
+        return Promise.resolve(this.getInfo(imageUrl)?.fileInfo);
+    },
+
+    putDimensions: function(imageUrl: string, dimensions: Dimensions): Promise<void> {
+        const prevEntry = this.getInfo(imageUrl);
+        this.putInfo(imageUrl, {
+            ...prevEntry,
+            dimensions,
+        });
+        return Promise.resolve();
+    },
+
+    putFileInfo: function(imageUrl: string, fileInfo: FileInfo): Promise<void> {
+        const prevEntry = this.getInfo(imageUrl);
+        this.putInfo(imageUrl, {
+            ...prevEntry,
+            fileInfo,
+        });
+        return Promise.resolve();
+    },
+};
+
+export class AtisketImage extends BaseImage {
+    public constructor(imgUrl: string) {
+        super(imgUrl, localStorageCache);
+    }
+
+    protected async loadFileInfo(): Promise<FileInfo> {
+        const resp = await pRetry(() => gmxhr(this.imgUrl, {
+            method: 'HEAD',
+        }), {
+            retries: 5,
+            onFailedAttempt: (err) => {
+                // Don't retry on 4xx status codes except for 429. Anything below 400 doesn't throw a HTTPResponseError.
+                if (err instanceof HTTPResponseError && err.statusCode < 500 && err.statusCode !== 429) {
+                    throw err;
+                }
+
+                LOGGER.warn(`Failed to retrieve image file info: ${err.message}. Retrying…`);
+            },
+        });
+
+        const fileSize = resp.responseHeaders.match(/content-length: (\d+)/i)?.[1];
+        const fileType = resp.responseHeaders.match(/content-type: \w+\/(\w+)/i)?.[1];
+
+        return {
+            fileType: fileType?.toUpperCase(),
+            size: fileSize ? parseInt(fileSize) : /* istanbul ignore next: Probably won't happen */ undefined,
+        };
+    }
+}
diff --git a/src/mb_enhanced_cover_art_uploads/seeding/atisket.tsx b/src/mb_enhanced_cover_art_uploads/seeding/atisket/index.tsx
similarity index 58%
rename from src/mb_enhanced_cover_art_uploads/seeding/atisket.tsx
rename to src/mb_enhanced_cover_art_uploads/seeding/atisket/index.tsx
index c4227ae6..e702600e 100644
--- a/src/mb_enhanced_cover_art_uploads/seeding/atisket.tsx
+++ b/src/mb_enhanced_cover_art_uploads/seeding/atisket/index.tsx
@@ -1,9 +1,13 @@
+import type { ImageInfo } from '@src/mb_caa_dimensions/ImageInfo';
 import { LOGGER } from '@lib/logging/logger';
+import { logFailure } from '@lib/util/async';
 import { qs, qsa, qsMaybe } from '@lib/util/dom';
+import { formatFileSize } from '@lib/util/format';
+import { getMaximisedCandidates } from '@src/mb_enhanced_cover_art_uploads/maximise';
 
-import type { Seeder } from './base';
-import { getImageDimensions } from '../image_dimensions';
-import { SeedParameters } from './parameters';
+import type { Seeder } from '../base';
+import { SeedParameters } from '../parameters';
+import { AtisketImage } from './dimensions';
 
 // For main page after search but before adding
 export const AtisketSeeder: Seeder = {
@@ -11,6 +15,8 @@ export const AtisketSeeder: Seeder = {
     supportedRegexes: [/(?:\.uk|\.info\/atisket)\/\?.+/],
 
     insertSeedLinks(): void {
+        addDimensionsToCovers();
+
         const alreadyInMB = qsMaybe('.already-in-mb-item');
         if (alreadyInMB === null) {
             return;
@@ -32,6 +38,8 @@ export const AtasketSeeder: Seeder = {
     supportedRegexes: [/(?:\.uk|\.info\/atisket)\/atasket\.php\?/],
 
     insertSeedLinks(): void {
+        addDimensionsToCovers();
+
         const urlParams = new URLSearchParams(document.location.search);
         const mbid = urlParams.get('release_mbid');
         const selfId = urlParams.get('self_id');
@@ -49,10 +57,16 @@ export const AtasketSeeder: Seeder = {
 
 function addSeedLinkToCovers(mbid: string, origin: string): void {
     const covers = qsa<HTMLElement>('figure.cover');
-    Promise.all(covers.map((fig) => addSeedLinkToCover(fig, mbid, origin)))
-        .catch((err) => {
-            LOGGER.error('Failed to add seed links to some cover art', err);
-        });
+    for (const fig of covers) {
+        addSeedLinkToCover(fig, mbid, origin);
+    }
+}
+
+function addDimensionsToCovers(): void {
+    const covers = qsa<HTMLElement>('figure.cover');
+    for (const fig of covers) {
+        logFailure(addDimensions(fig), 'Failed to insert image information');
+    }
 }
 
 function tryExtractReleaseUrl(fig: HTMLElement): string | undefined {
@@ -69,12 +83,9 @@ function tryExtractReleaseUrl(fig: HTMLElement): string | undefined {
     return RELEASE_URL_CONSTRUCTORS[vendorCode](vendorId, countryCode);
 }
 
-async function addSeedLinkToCover(fig: HTMLElement, mbid: string, origin: string): Promise<void> {
+function addSeedLinkToCover(fig: HTMLElement, mbid: string, origin: string): void {
     const imageUrl = qs<HTMLAnchorElement>('a.icon', fig).href;
 
-    // Already start loading the image dimensions.
-    const dimensionsPromise = addDimensions(fig, imageUrl);
-
     // On atj's mirror, we'll seed the release URLs, instead of the images
     // directly. This will allow us to e.g. extract additional images for the
     // release, or handle some maximisations exceptions (e.g. Apple Music).
@@ -95,24 +106,63 @@ async function addSeedLinkToCover(fig: HTMLElement, mbid: string, origin: string
     // below should lead to a consistent ordering of elements.
     qs<HTMLElement>('figcaption', fig)
         .insertAdjacentElement('beforeend', seedLink);
-
-    return dimensionsPromise
-        // TODO: Use `logFailure`
-        .catch((err) => {
-            LOGGER.warn('Failed to insert dimensions', err);
-        });
 }
 
-async function addDimensions(fig: HTMLElement, imageUrl: string): Promise<void> {
-    const imageDimensions = await getImageDimensions(imageUrl);
-    // Not using .split('.') here because Spotify images do not have an extension.
-    const ext = imageUrl.match(/\.(\w+)$/)?.[1];
-    const dimensionStr = `${imageDimensions.width}x${imageDimensions.height}`;
+async function addDimensions(fig: HTMLElement): Promise<void> {
+    const imageUrl = qs<HTMLAnchorElement>('a.icon', fig).href;
     const dimSpan = <span style={{ display: 'block' }}>
-        {dimensionStr + (ext ? ` ${ext.toUpperCase()}` : '')}
+        loading…
     </span>;
-
     qs<HTMLElement>('figcaption > a', fig).insertAdjacentElement('afterend', dimSpan);
+
+    const imageInfo = await getImageInfo(imageUrl);
+
+    const infoStringParts = [
+        imageInfo.dimensions ? `${imageInfo.dimensions.width}x${imageInfo.dimensions.height}` : '',
+        typeof imageInfo.size !== 'undefined' ? formatFileSize(imageInfo.size) : '',
+        imageInfo.fileType,
+    ];
+    const infoString = infoStringParts.filter(Boolean).join(', ');
+
+    if (infoString) {
+        dimSpan.textContent = infoString;
+    } else {
+        dimSpan.remove();
+    }
+}
+
+async function getImageInfo(imageUrl: string): Promise<ImageInfo> {
+    // Try maximising the image
+    for await (const maxCandidate of getMaximisedCandidates(new URL(imageUrl))) {
+        // Skip likely broken images. Happens on Apple Music images a lot as the
+        // first candidate.
+        if (maxCandidate.likely_broken) continue;
+
+        LOGGER.debug(`Trying to get image information for maximised candidate ${maxCandidate.url}`);
+        const atisketImage = new AtisketImage(maxCandidate.url.toString());
+        // Query dimensions and file info separately, don't use `Image#getImageInfo`
+        // since it resolves even if both queries fail. We're dealing with images
+        // that may not exist, so instead we'll call both parts separately and
+        // check their output.
+
+        // Load file info first, and skip loading dimensions if file info failed.
+        // File info does a HEAD request and fails immediately on 404, dimensions
+        // will retry on 404, which would be wasteful.
+        const fileInfo = await atisketImage.getFileInfo();
+        const dimensions = fileInfo && await atisketImage.getDimensions();
+        if (!dimensions) {
+            LOGGER.warn(`Failed to load dimensions for maximised candidate ${maxCandidate.url}`);
+            continue;
+        }
+
+        return {
+            dimensions,
+            ...fileInfo,
+        };
+    }
+
+    // Fall back on original URL. Using `Image#getImageInfo` is fine here.
+    return new AtisketImage(imageUrl).getImageInfo();
 }
 
 const RELEASE_URL_CONSTRUCTORS: Record<string, (id: string, country: string) => string> = {
diff --git a/src/mb_enhanced_cover_art_uploads/tsconfig.json b/src/mb_enhanced_cover_art_uploads/tsconfig.json
index 00f4bccd..a0204421 100644
--- a/src/mb_enhanced_cover_art_uploads/tsconfig.json
+++ b/src/mb_enhanced_cover_art_uploads/tsconfig.json
@@ -2,7 +2,8 @@
     "extends": "../../configs/tsconfig.base-web.json",
     "include": ["**/*"],
     "references": [
-        { "path": "../lib/" }
+        { "path": "../lib/" },
+        { "path": "../mb_caa_dimensions/" }
     ],
     "compilerOptions": {
         "types": ["nativejsx/types/jsx", "greasemonkey"]


Test patch:
diff --git a/tests/test-data/__recordings__/a-tisket-images_2315790463/loads-file-info-for-Apple-Music-PNG-images_3342677255.warc b/tests/test-data/__recordings__/a-tisket-images_2315790463/loads-file-info-for-Apple-Music-PNG-images_3342677255.warc
new file mode 100644
index 00000000..f56d78f9
--- /dev/null
+++ b/tests/test-data/__recordings__/a-tisket-images_2315790463/loads-file-info-for-Apple-Music-PNG-images_3342677255.warc
@@ -0,0 +1,94 @@
+WARC/1.1
+WARC-Filename: a-tisket images/loads file info for Apple Music PNG images
+WARC-Date: 2022-06-13T14:57:07.684Z
+WARC-Type: warcinfo
+WARC-Record-ID: <urn:uuid:16e81a2e-5386-43dc-9001-174d8819c1b8>
+Content-Type: application/warc-fields
+Content-Length: 119
+
+software: warcio.js
+harVersion: 1.2
+harCreator: {"name":"Polly.JS","version":"6.0.5","comment":"persister:fs-warc"}
+
+
+WARC/1.1
+WARC-Concurrent-To: <urn:uuid:64874ec8-9b9b-40db-8b1b-1aedf2c17aca>
+WARC-Target-URI: https://a1.mzstatic.com/us/r1000/063/Music126/v4/48/4f/49/484f49a5-fb52-37b3-f3c6-244e20f74b7c/5052075509815.png
+WARC-Date: 2022-06-13T14:57:07.685Z
+WARC-Type: request
+WARC-Record-ID: <urn:uuid:a2ee2983-a591-4804-9967-5a7784f4476e>
+Content-Type: application/http; msgtype=request
+WARC-Payload-Digest: sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
+WARC-Block-Digest: sha256:2b12599f4397620162f07ed09c6b5a5a8d67a84d9d5774ac3e7ce0253331cf7b
+Content-Length: 107
+
+HEAD /us/r1000/063/Music126/v4/48/4f/49/484f49a5-fb52-37b3-f3c6-244e20f74b7c/5052075509815.png HTTP/1.1
+
+
+
+WARC/1.1
+WARC-Concurrent-To: <urn:uuid:64874ec8-9b9b-40db-8b1b-1aedf2c17aca>
+WARC-Target-URI: https://a1.mzstatic.com/us/r1000/063/Music126/v4/48/4f/49/484f49a5-fb52-37b3-f3c6-244e20f74b7c/5052075509815.png
+WARC-Date: 2022-06-13T14:57:07.685Z
+WARC-Type: metadata
+WARC-Record-ID: <urn:uuid:9b96464f-4e37-483c-9659-bb31f7f17772>
+Content-Type: application/warc-fields
+WARC-Payload-Digest: sha256:87bd74abdf240c366bc58fe770985c10cf6559df201a42f74f7448d1c0f15dcf
+WARC-Block-Digest: sha256:87bd74abdf240c366bc58fe770985c10cf6559df201a42f74f7448d1c0f15dcf
+Content-Length: 352
+
+harEntryId: 834ded94b78fb45509a7c0cda35af2af
+harEntryOrder: 0
+cache: {}
+startedDateTime: 2022-06-13T14:57:06.451Z
+time: 1232
+timings: {"blocked":-1,"dns":-1,"connect":-1,"send":0,"wait":1232,"receive":0,"ssl":-1}
+warcRequestHeadersSize: 132
+warcRequestCookies: []
+warcResponseHeadersSize: 1374
+warcResponseCookies: []
+responseDecoded: false
+
+
+WARC/1.1
+WARC-Target-URI: https://a1.mzstatic.com/us/r1000/063/Music126/v4/48/4f/49/484f49a5-fb52-37b3-f3c6-244e20f74b7c/5052075509815.png
+WARC-Date: 2022-06-13T14:57:07.684Z
+WARC-Type: response
+WARC-Record-ID: <urn:uuid:64874ec8-9b9b-40db-8b1b-1aedf2c17aca>
+Content-Type: application/http; msgtype=response
+WARC-Payload-Digest: sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
+WARC-Block-Digest: sha256:d64f4b3c88299ad859fcca8582ee11268db0375365aa5f678e077aea1d695501
+Content-Length: 1391
+
+HTTP/1.1 200 OK
+accept-ranges: bytes
+access-control-allow-credentials: false
+access-control-allow-headers: range
+access-control-allow-methods: HEAD, GET, PUT
+access-control-allow-origin: *
+access-control-expose-headers: *
+access-control-max-age: 3000
+cache-control: public, max-age=2587115
+cdnuuid: 22f1b6ed-ee50-4829-a4c0-779d49b3a016-1075891399
+connection: close
+content-length: 23803429
+content-type: image/png
+date: Mon, 13 Jun 2022 14:57:07 GMT
+etag: "B0CD8FB48A94ECC97712FD77D05AB377"
+last-modified: Fri, 12 Nov 2021 02:20:35 GMT
+server: dlb/1.0.2
+x-amz-storage-class: STANDARD
+x-apple-ms-content-length: 23803429
+x-apple-request-uuid: 5f9e3faa-dbb2-4c07-b14d-2f2b3fb5dc89, 5f9e3faa-dbb2-4c07-b14d-2f2b3fb5dc89
+x-apple-store-id: 026
+x-cache: TCP_MISS from a104-126-37-174.deploy.akamaitechnologies.com (AkamaiGHost/10.8.2-41758951) (-)
+x-cache-remote: TCP_MISS from a95-100-97-76.deploy.akamaitechnologies.com (AkamaiGHost/10.8.2-41841244) (-)
+x-dlb-upstream: 10.117.38.197:8081
+x-icloud-availability: [DL, L, B]
+x-icloud-content-length: 23803429
+x-icloud-versionid: 1e941ca0-435f-11ec-8272-248a0755c7bc
+x-responding-server: massilia_protocol_026:426004802:pv49p01if-zteh12154001.pv.if.apple.com:8081:22H218:a76f9edcdd82
+x-pollyjs-finalurl: https://a1.mzstatic.com/us/r1000/063/Music126/v4/48/4f/49/484f49a5-fb52-37b3-f3c6-244e20f74b7c/5052075509815.png
+
+
+
diff --git a/tests/test-data/__recordings__/a-tisket-images_2315790463/loads-file-info-for-Apple-Music-images_2990659670.warc b/tests/test-data/__recordings__/a-tisket-images_2315790463/loads-file-info-for-Apple-Music-images_2990659670.warc
new file mode 100644
index 00000000..36a8553a
--- /dev/null
+++ b/tests/test-data/__recordings__/a-tisket-images_2315790463/loads-file-info-for-Apple-Music-images_2990659670.warc
@@ -0,0 +1,90 @@
+WARC/1.1
+WARC-Filename: a-tisket images/loads file info for Apple Music images
+WARC-Date: 2022-06-13T14:57:06.442Z
+WARC-Type: warcinfo
+WARC-Record-ID: <urn:uuid:07db5a2b-82b5-4642-a26c-df10f22aee45>
+Content-Type: application/warc-fields
+Content-Length: 119
+
+software: warcio.js
+harVersion: 1.2
+harCreator: {"name":"Polly.JS","version":"6.0.5","comment":"persister:fs-warc"}
+
+
+WARC/1.1
+WARC-Concurrent-To: <urn:uuid:72414f2d-63cf-487a-8b3a-a3022c817e97>
+WARC-Target-URI: https://is2-ssl.mzstatic.com/image/thumb/Music/v4/05/f3/b2/05f3b216-755e-6472-e998-f72a3b487dc0/884501818353.jpg/9999x9999-100.jpg
+WARC-Date: 2022-06-13T14:57:06.443Z
+WARC-Type: request
+WARC-Record-ID: <urn:uuid:9c53b375-962a-4dda-b18c-cd32efb14963>
+Content-Type: application/http; msgtype=request
+WARC-Payload-Digest: sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
+WARC-Block-Digest: sha256:92a9aac19b35e75639c4604720fbdc00fb0b402b9f9ca4aac1791068a8481a91
+Content-Length: 120
+
+HEAD /image/thumb/Music/v4/05/f3/b2/05f3b216-755e-6472-e998-f72a3b487dc0/884501818353.jpg/9999x9999-100.jpg HTTP/1.1
+
+
+
+WARC/1.1
+WARC-Concurrent-To: <urn:uuid:72414f2d-63cf-487a-8b3a-a3022c817e97>
+WARC-Target-URI: https://is2-ssl.mzstatic.com/image/thumb/Music/v4/05/f3/b2/05f3b216-755e-6472-e998-f72a3b487dc0/884501818353.jpg/9999x9999-100.jpg
+WARC-Date: 2022-06-13T14:57:06.444Z
+WARC-Type: metadata
+WARC-Record-ID: <urn:uuid:f8eb7c14-a396-4eaf-a5c6-76e82288e42c>
+Content-Type: application/warc-fields
+WARC-Payload-Digest: sha256:c01562fed521e54e795a955394b67f7d5043add7a0f1293b0b982901dbf438fd
+WARC-Block-Digest: sha256:c01562fed521e54e795a955394b67f7d5043add7a0f1293b0b982901dbf438fd
+Content-Length: 350
+
+harEntryId: 3b2fc26069c8b0517f1f82a8bd9f4d21
+harEntryOrder: 0
+cache: {}
+startedDateTime: 2022-06-13T14:57:06.225Z
+time: 212
+timings: {"blocked":-1,"dns":-1,"connect":-1,"send":0,"wait":212,"receive":0,"ssl":-1}
+warcRequestHeadersSize: 150
+warcRequestCookies: []
+warcResponseHeadersSize: 1349
+warcResponseCookies: []
+responseDecoded: false
+
+
+WARC/1.1
+WARC-Target-URI: https://is2-ssl.mzstatic.com/image/thumb/Music/v4/05/f3/b2/05f3b216-755e-6472-e998-f72a3b487dc0/884501818353.jpg/9999x9999-100.jpg
+WARC-Date: 2022-06-13T14:57:06.442Z
+WARC-Type: response
+WARC-Record-ID: <urn:uuid:72414f2d-63cf-487a-8b3a-a3022c817e97>
+Content-Type: application/http; msgtype=response
+WARC-Payload-Digest: sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
+WARC-Block-Digest: sha256:874b0ad7467be14fd48a48ce650e6d88e08c9ca7a89617b978d96f6708fd4149
+Content-Length: 1366
+
+HTTP/1.1 200 OK
+access-control-allow-origin: *
+access-control-expose-headers: Content-Length,Content-Type,ETag,Cache-Control,Expires,Last-Modified
+apple-originating-system: UnknownOriginatingSystem
+apple-seq: 0.0
+apple-tk: false
+b3: 8a26894cd12e045a5be796db4861a04d-35ad78ebf433fc59
+cache-control: no-transform, max-age=13235202
+cdnuuid: c8a5e60f-aad7-4a50-b11a-eb9edb257fc0-1949909649
+connection: close
+content-length: 1826850
+content-type: image/jpeg
+date: Mon, 13 Jun 2022 14:57:06 GMT
+etag: "MSwxLjI3LTIyRyxWZXJzaW9uIDExLjMuMSAoQnVpbGQgMjBFMjQxKSwxNjU0MDgyNzAzNjAwLGlzQnVpbGRWZXJzaW9uTm90U2V0LDUwMDQ1LG5vRWZmZWN0"
+last-modified: Wed, 01 Jun 2022 11:25:03 GMT
+server: daiquiri/3.0.0
+strict-transport-security: max-age=31536000; includeSubDomains
+timing-allow-origin: *
+x-apple-jingle-correlation-key: RITISTGRFYCFUW7HS3NUQYNAJU
+x-apple-request-uuid: 8a26894c-d12e-045a-5be7-96db4861a04d
+x-b3-spanid: 35ad78ebf433fc59
+x-b3-traceid: 8a26894cd12e045a5be796db4861a04d
+x-cache: TCP_HIT from a2-19-194-101.deploy.akamaitechnologies.com (AkamaiGHost/10.8.2-41758951) (-)
+x-daiquiri-instance: daiquiri:13624002:mr85p00it-hyhk03094901:7987:22RELEASE72:daiquiri-amp-processing-shared-int-001-mr
+x-pollyjs-finalurl: https://is2-ssl.mzstatic.com/image/thumb/Music/v4/05/f3/b2/05f3b216-755e-6472-e998-f72a3b487dc0/884501818353.jpg/9999x9999-100.jpg
+
+
+
diff --git a/tests/test-data/__recordings__/a-tisket-images_2315790463/loads-file-info-for-Deezer-images_3609701700.warc b/tests/test-data/__recordings__/a-tisket-images_2315790463/loads-file-info-for-Deezer-images_3609701700.warc
new file mode 100644
index 00000000..0e9c1af0
--- /dev/null
+++ b/tests/test-data/__recordings__/a-tisket-images_2315790463/loads-file-info-for-Deezer-images_3609701700.warc
@@ -0,0 +1,87 @@
+WARC/1.1
+WARC-Filename: a-tisket images/loads file info for Deezer images
+WARC-Date: 2022-06-13T14:57:08.078Z
+WARC-Type: warcinfo
+WARC-Record-ID: <urn:uuid:18c3ff31-3a87-4bad-a76d-7a0c5cfe155d>
+Content-Type: application/warc-fields
+Content-Length: 119
+
+software: warcio.js
+harVersion: 1.2
+harCreator: {"name":"Polly.JS","version":"6.0.5","comment":"persister:fs-warc"}
+
+
+WARC/1.1
+WARC-Concurrent-To: <urn:uuid:07bec5fb-737c-4691-a54e-a352ea7d0d12>
+WARC-Target-URI: https://e-cdns-images.dzcdn.net/images/cover/2d8c720d7fee9506e40c5f16760c3640/1200x0-000000-100-0-0.jpg
+WARC-Date: 2022-06-13T14:57:08.079Z
+WARC-Type: request
+WARC-Record-ID: <urn:uuid:8f155e26-9d40-4045-aec4-e001890634b0>
+Content-Type: application/http; msgtype=request
+WARC-Payload-Digest: sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
+WARC-Block-Digest: sha256:457216300c0deb4b1eedd78c7ef7bddc78beb20d9fba61d61db372ac553fc231
+Content-Length: 90
+
+HEAD /images/cover/2d8c720d7fee9506e40c5f16760c3640/1200x0-000000-100-0-0.jpg HTTP/1.1
+
+
+
+WARC/1.1
+WARC-Concurrent-To: <urn:uuid:07bec5fb-737c-4691-a54e-a352ea7d0d12>
+WARC-Target-URI: https://e-cdns-images.dzcdn.net/images/cover/2d8c720d7fee9506e40c5f16760c3640/1200x0-000000-100-0-0.jpg
+WARC-Date: 2022-06-13T14:57:08.079Z
+WARC-Type: metadata
+WARC-Record-ID: <urn:uuid:1860c9ef-db23-4db6-9f85-f6e2ee4d30f9>
+Content-Type: application/warc-fields
+WARC-Payload-Digest: sha256:dc6a7f3ea7ad39bbd36dc7060f2a7578fb061fd5e9f9ff6c28abb48b7b14ed81
+WARC-Block-Digest: sha256:dc6a7f3ea7ad39bbd36dc7060f2a7578fb061fd5e9f9ff6c28abb48b7b14ed81
+Content-Length: 349
+
+harEntryId: e2999d54c7a0124f04f7b51ff2b16768
+harEntryOrder: 0
+cache: {}
+startedDateTime: 2022-06-13T14:57:07.886Z
+time: 190
+timings: {"blocked":-1,"dns":-1,"connect":-1,"send":0,"wait":190,"receive":0,"ssl":-1}
+warcRequestHeadersSize: 123
+warcRequestCookies: []
+warcResponseHeadersSize: 780
+warcResponseCookies: []
+responseDecoded: false
+
+
+WARC/1.1
+WARC-Target-URI: https://e-cdns-images.dzcdn.net/images/cover/2d8c720d7fee9506e40c5f16760c3640/1200x0-000000-100-0-0.jpg
+WARC-Date: 2022-06-13T14:57:08.078Z
+WARC-Type: response
+WARC-Record-ID: <urn:uuid:07bec5fb-737c-4691-a54e-a352ea7d0d12>
+Content-Type: application/http; msgtype=response
+WARC-Payload-Digest: sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
+WARC-Block-Digest: sha256:4cfdce07f484feeb4d49073408eac9ac48cd1e2e20cc6c0040645c6ba1cb3167
+Content-Length: 797
+
+HTTP/1.1 200 OK
+accept-ranges: bytes
+access-control-allow-origin: *
+access-control-expose-headers: x-deezer-client-ip, content-length, content-range
+age: 3951
+cache-control: public
+connection: close
+content-length: 365960
+content-type: image/jpeg
+date: Mon, 13 Jun 2022 14:57:08 GMT
+expires: Thu, 10 Nov 2022 14:57:08 GMT
+last-modified: Sat, 25 Apr 2020 14:39:07 GMT
+p3p: policyref="/w3c/p3p.xml" CP="IDC DSP COR CURa ADMa OUR IND PHY ONL COM STA"
+pragma: 
+server: ECS (pab/6FAF)
+strict-transport-security: max-age=60;
+x-cache: HIT
+x-deezer-cache: MISS
+x-deezer-client-ip: 92.223.88.228
+x-host: blm-static-06
+x-md5: 2d8c720d7fee9506e40c5f16760c3640
+x-pollyjs-finalurl: https://e-cdns-images.dzcdn.net/images/cover/2d8c720d7fee9506e40c5f16760c3640/1200x0-000000-100-0-0.jpg
+
+
+
diff --git a/tests/test-data/__recordings__/a-tisket-images_2315790463/loads-file-info-for-Spotify-images_1432023381.warc b/tests/test-data/__recordings__/a-tisket-images_2315790463/loads-file-info-for-Spotify-images_1432023381.warc
new file mode 100644
index 00000000..bc14d0c1
--- /dev/null
+++ b/tests/test-data/__recordings__/a-tisket-images_2315790463/loads-file-info-for-Spotify-images_1432023381.warc
@@ -0,0 +1,81 @@
+WARC/1.1
+WARC-Filename: a-tisket images/loads file info for Spotify images
+WARC-Date: 2022-06-13T14:57:07.878Z
+WARC-Type: warcinfo
+WARC-Record-ID: <urn:uuid:41cd4ec5-2191-4ade-bcd5-74de046ca7e3>
+Content-Type: application/warc-fields
+Content-Length: 119
+
+software: warcio.js
+harVersion: 1.2
+harCreator: {"name":"Polly.JS","version":"6.0.5","comment":"persister:fs-warc"}
+
+
+WARC/1.1
+WARC-Concurrent-To: <urn:uuid:38e32789-bfdd-4beb-9a69-76b3f8ab2054>
+WARC-Target-URI: https://i.scdn.co/image/ab67616d0000b273843b6bc2dc1517b7f7f0f424
+WARC-Date: 2022-06-13T14:57:07.879Z
+WARC-Type: request
+WARC-Record-ID: <urn:uuid:d71b4d73-5d8e-4a2c-87f2-67a61c21de04>
+Content-Type: application/http; msgtype=request
+WARC-Payload-Digest: sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
+WARC-Block-Digest: sha256:d7eefdc4f9db1b4f6ebefe0e418139d9e4474fd93f4b7c07bf501c922a951200
+Content-Length: 65
+
+HEAD /image/ab67616d0000b273843b6bc2dc1517b7f7f0f424 HTTP/1.1
+
+
+
+WARC/1.1
+WARC-Concurrent-To: <urn:uuid:38e32789-bfdd-4beb-9a69-76b3f8ab2054>
+WARC-Target-URI: https://i.scdn.co/image/ab67616d0000b273843b6bc2dc1517b7f7f0f424
+WARC-Date: 2022-06-13T14:57:07.879Z
+WARC-Type: metadata
+WARC-Record-ID: <urn:uuid:8273aa11-4e8b-411c-9365-b0e8cbf8c545>
+Content-Type: application/warc-fields
+WARC-Payload-Digest: sha256:7e9260d0fef477b8df5d6d6cca1d047e973ae3fb2815f4c5082b3a7e0252a0cc
+WARC-Block-Digest: sha256:7e9260d0fef477b8df5d6d6cca1d047e973ae3fb2815f4c5082b3a7e0252a0cc
+Content-Length: 348
+
+harEntryId: ecda0e76255d71853d10386e83dc78ec
+harEntryOrder: 0
+cache: {}
+startedDateTime: 2022-06-13T14:57:07.689Z
+time: 188
+timings: {"blocked":-1,"dns":-1,"connect":-1,"send":0,"wait":188,"receive":0,"ssl":-1}
+warcRequestHeadersSize: 84
+warcRequestCookies: []
+warcResponseHeadersSize: 533
+warcResponseCookies: []
+responseDecoded: false
+
+
+WARC/1.1
+WARC-Target-URI: https://i.scdn.co/image/ab67616d0000b273843b6bc2dc1517b7f7f0f424
+WARC-Date: 2022-06-13T14:57:07.879Z
+WARC-Type: response
+WARC-Record-ID: <urn:uuid:38e32789-bfdd-4beb-9a69-76b3f8ab2054>
+Content-Type: application/http; msgtype=response
+WARC-Payload-Digest: sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
+WARC-Block-Digest: sha256:0fa1c7f61bc8447b987e467254eee84b39ac1fc93d65538933c35c83fa2833f3
+Content-Length: 550
+
+HTTP/1.1 200 OK
+accept-ranges: bytes
+access-control-allow-origin: *
+age: 316534
+cache-control: max-age=315360000
+connection: close
+content-length: 102281
+content-type: image/jpeg
+date: Mon, 13 Jun 2022 14:57:07 GMT
+etag: "b8b46268b1a9fffea6ab2d8db3ee4dd1"
+last-modified: Tue, 03 Sep 2019 12:39:51 GMT
+timing-allow-origin: *
+x-cache: MISS, HIT, HIT
+x-cache-hits: 0, 1, 1
+x-served-by: cache-ord1733-ORD, cache-chi-kigq8000034-CHI, cache-lhr7354-LHR
+x-pollyjs-finalurl: https://i.scdn.co/image/ab67616d0000b273843b6bc2dc1517b7f7f0f424
+
+
+
diff --git a/tests/unit/mb_enhanced_cover_art_uploads/image_dimensions.test.ts b/tests/unit/mb_enhanced_cover_art_uploads/image_dimensions.test.ts
deleted file mode 100644
index 23c6f6e1..00000000
--- a/tests/unit/mb_enhanced_cover_art_uploads/image_dimensions.test.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import { getImageDimensions } from '@src/mb_enhanced_cover_art_uploads/image_dimensions';
-
-describe('retrieving image dimensions', () => {
-    // jsdom doesn't actually load the images, so we need to mock that behaviour
-    const createElementSpy = jest.spyOn(document, 'createElement');
-
-    beforeEach(() => {
-        createElementSpy.mockClear();
-        jest.useFakeTimers();
-    });
-
-    function mockImageDimensions(img: HTMLImageElement, height: number, width: number): void {
-        jest.spyOn(img, 'naturalHeight', 'get').mockReturnValue(height);
-        jest.spyOn(img, 'naturalWidth', 'get').mockReturnValue(width);
-    }
-
-    it('retrieves dimensions for image which loads immediately', async () => {
-        const dimsProm = getImageDimensions('https://example.com/img');
-        const imgElem = createElementSpy.mock.results[0].value as HTMLImageElement;
-        mockImageDimensions(imgElem, 1200, 345);
-        imgElem.dispatchEvent(new Event('load'));
-
-        await expect(dimsProm)
-            .resolves.toMatchObject({
-                height: 1200,
-                width: 345,
-            });
-    });
-
-    it('retrieves dimensions for image which does not load immediately', async () => {
-        const dimsProm = getImageDimensions('https://example.com/img');
-        const imgElem = createElementSpy.mock.results[0].value as HTMLImageElement;
-        mockImageDimensions(imgElem, 1200, 345);
-        jest.advanceTimersByTime(100);
-
-        await expect(dimsProm)
-            .resolves.toMatchObject({
-                height: 1200,
-                width: 345,
-            });
-    });
-
-    it('retrieves dimensions for image which does loads slowly', async () => {
-        const dimsProm = getImageDimensions('https://example.com/img');
-        const imgElem = createElementSpy.mock.results[0].value as HTMLImageElement;
-        // Let the interval run a couple of times, shouldn't resolve yet.
-        jest.advanceTimersByTime(100);
-        mockImageDimensions(imgElem, 1200, 345);
-        jest.advanceTimersByTime(100);
-
-        await expect(dimsProm)
-            .resolves.toMatchObject({
-                height: 1200,
-                width: 345,
-            });
-    });
-
-    it('does not resolve the promise twice', async () => {
-        const dimsProm = getImageDimensions('https://example.com/img');
-        const imgElem = createElementSpy.mock.results[0].value as HTMLImageElement;
-        mockImageDimensions(imgElem, 1200, 345);
-        // Let the interval run so it can pick up on the dimensions
-        jest.advanceTimersByTime(100);
-        // Simulate the image loading. This should not resolve a second time.
-        // There's no easy way to detect if the promise is resolved multiple
-        // times, so we'll change its dimensions instead. If it uses these
-        // dimensions, it's doing something wrong
-        mockImageDimensions(imgElem, 100, 200);
-        imgElem.dispatchEvent(new Event('load'));
-
-
-        await expect(dimsProm)
-            .resolves.toMatchObject({
-                height: 1200,
-                width: 345,
-            });
-    });
-
-    it('rejects if the image fails to load', async () => {
-        const dimsProm = getImageDimensions('https://example.com/img');
-        const imgElem = createElementSpy.mock.results[0].value as HTMLImageElement;
-        imgElem.dispatchEvent(new Event('error'));
-
-        await expect(dimsProm).toReject();
-    });
-
-    it('does not reject if image dimensions are known before error', async () => {
-        const dimsProm = getImageDimensions('https://example.com/img');
-        const imgElem = createElementSpy.mock.results[0].value as HTMLImageElement;
-        mockImageDimensions(imgElem, 1200, 345);
-        jest.advanceTimersByTime(100);
-        imgElem.dispatchEvent(new Event('error'));
-
-        await expect(dimsProm)
-            .resolves.toMatchObject({
-                height: 1200,
-                width: 345,
-            });
-    });
-});
diff --git a/tests/unit/mb_enhanced_cover_art_uploads/providers/bandcamp.test.ts b/tests/unit/mb_enhanced_cover_art_uploads/providers/bandcamp.test.ts
index c18df896..fada6938 100644
--- a/tests/unit/mb_enhanced_cover_art_uploads/providers/bandcamp.test.ts
+++ b/tests/unit/mb_enhanced_cover_art_uploads/providers/bandcamp.test.ts
@@ -1,5 +1,5 @@
 import { ArtworkTypeIDs } from '@lib/MB/CoverArt';
-import { getImageDimensions } from '@src/mb_enhanced_cover_art_uploads/image_dimensions';
+import { getImageDimensions } from '@src/mb_caa_dimensions/dimensions';
 import { BandcampProvider } from '@src/mb_enhanced_cover_art_uploads/providers/bandcamp';
 import { itBehavesLike } from '@test-utils/shared_behaviour';
 
@@ -7,8 +7,8 @@ import { findImagesSpec } from './find_images_spec';
 import { urlMatchingSpec } from './url_matching_spec';
 
 // We need to mock getImageDimensions since jsdom doesn't actually load images.
-// See also tests/mb_enhanced_cover_art_uploads/image_dimensions.test.ts
-jest.mock('@src/mb_enhanced_cover_art_uploads/image_dimensions');
+// See also tests/mb_caa_dimensions/dimensions.test.ts
+jest.mock('@src/mb_caa_dimensions/dimensions');
 const mockGetImageDimensions = getImageDimensions as jest.MockedFunction<typeof getImageDimensions>;
 
 describe('bandcamp provider', () => {
diff --git a/tests/unit/mb_enhanced_cover_art_uploads/seeding/atisket/dimensions.test.ts b/tests/unit/mb_enhanced_cover_art_uploads/seeding/atisket/dimensions.test.ts
new file mode 100644
index 00000000..f93897f9
--- /dev/null
+++ b/tests/unit/mb_enhanced_cover_art_uploads/seeding/atisket/dimensions.test.ts
@@ -0,0 +1,330 @@
+import retry from 'retry';
+
+import type { CacheStore} from '@src/mb_enhanced_cover_art_uploads/seeding/atisket/dimensions';
+import * as xhr from '@lib/util/xhr';
+import { AtisketImage, CACHE_LOCALSTORAGE_KEY, localStorageCache, MAX_CACHED_IMAGES } from '@src/mb_enhanced_cover_art_uploads/seeding/atisket/dimensions';
+import { setupPolly } from '@test-utils/pollyjs';
+
+describe('local storage cache', () => {
+    const dummyDimensions = {
+        width: 100,
+        height: 100,
+    };
+    const dummyNewDimensions = {
+        width: 200,
+        height: 150,
+    };
+    const dummyFileInfo = {
+        fileType: 'JPEG',
+        size: 1234,
+    };
+    const dummyNewFileInfo = {
+        fileType: 'PNG',
+        size: 567,
+    };
+
+    const lsSetItemSpy = jest.spyOn(window.Storage.prototype, 'setItem');
+    const lsGetItemSpy = jest.spyOn(window.Storage.prototype, 'getItem');
+
+    function mockLocalStorage(fakeLocalStorage: Record<string, string | undefined>): void {
+        lsGetItemSpy.mockImplementation((key) => {
+            return fakeLocalStorage[key] ?? null;
+        });
+        lsSetItemSpy.mockImplementation((key, value) => {
+            fakeLocalStorage[key] = value;
+        });
+    }
+
+    afterAll(() => {
+        lsSetItemSpy.mockRestore();
+        lsGetItemSpy.mockRestore();
+    });
+
+    function runTests(): void {
+        it('returns no dimensions on cache miss', async () => {
+            await expect(localStorageCache.getDimensions('https://example.com/notInCache'))
+                .resolves.toBeUndefined();
+        });
+
+        it('returns no file info on cache miss', async () => {
+            await expect(localStorageCache.getFileInfo('https://example.com/notInCache'))
+                .resolves.toBeUndefined();
+        });
+
+        it('returns dimensions on cache hit', async () => {
+            await localStorageCache.putDimensions('https://example.com/notYetInCache', dummyDimensions);
+
+            await expect(localStorageCache.getDimensions('https://example.com/notYetInCache'))
+                .resolves.toStrictEqual(dummyDimensions);
+        });
+
+        it('returns file info on cache hit', async () => {
+            await localStorageCache.putFileInfo('https://example.com/notYetInCache', dummyFileInfo);
+
+            await expect(localStorageCache.getFileInfo('https://example.com/notYetInCache'))
+                .resolves.toStrictEqual(dummyFileInfo);
+        });
+
+        it('returns no dimensions if only file info is known', async () => {
+            await localStorageCache.putFileInfo('https://example.com/notYetInCache', dummyFileInfo);
+
+            await expect(localStorageCache.getDimensions('https://example.com/notYetInCache'))
+                .resolves.toBeUndefined();
+        });
+
+        it('returns no file info if only dimensions are known', async () => {
+            await localStorageCache.putDimensions('https://example.com/notYetInCache', dummyDimensions);
+
+            await expect(localStorageCache.getFileInfo('https://example.com/notYetInCache'))
+                .resolves.toBeUndefined();
+        });
+
+        it('sets file info when dimensions already in cache', async () => {
+            await localStorageCache.putDimensions('https://example.com/notYetInCache', dummyDimensions);
+            await localStorageCache.putFileInfo('https://example.com/notYetInCache', dummyFileInfo);
+
+            await expect(localStorageCache.getFileInfo('https://example.com/notYetInCache'))
+                .resolves.toStrictEqual(dummyFileInfo);
+        });
+
+        it('retains previous dimensions info when setting file info', async () => {
+            await localStorageCache.putDimensions('https://example.com/notYetInCache', dummyDimensions);
+            await localStorageCache.putFileInfo('https://example.com/notYetInCache', dummyFileInfo);
+
+            await expect(localStorageCache.getDimensions('https://example.com/notYetInCache'))
+                .resolves.toStrictEqual(dummyDimensions);
+        });
+
+        it('sets dimensions when file info already in cache', async () => {
+            await localStorageCache.putFileInfo('https://example.com/notYetInCache', dummyFileInfo);
+            await localStorageCache.putDimensions('https://example.com/notYetInCache', dummyDimensions);
+
+            await expect(localStorageCache.getDimensions('https://example.com/notYetInCache'))
+                .resolves.toStrictEqual(dummyDimensions);
+        });
+
+        it('retains previous file info info when setting dimensions', async () => {
+            await localStorageCache.putFileInfo('https://example.com/notYetInCache', dummyFileInfo);
+            await localStorageCache.putDimensions('https://example.com/notYetInCache', dummyDimensions);
+
+            await expect(localStorageCache.getFileInfo('https://example.com/notYetInCache'))
+                .resolves.toStrictEqual(dummyFileInfo);
+        });
+
+        it('overwrites dimensions and keeps file info when entry already in cache', async () => {
+            await localStorageCache.putFileInfo('https://example.com/notYetInCache', dummyFileInfo);
+            await localStorageCache.putDimensions('https://example.com/notYetInCache', dummyDimensions);
+            await localStorageCache.putDimensions('https://example.com/notYetInCache', dummyNewDimensions);
+
+            await expect(localStorageCache.getDimensions('https://example.com/notYetInCache'))
+                .resolves.toStrictEqual(dummyNewDimensions);
+        });
+
+        it('overwrites file info and keeps dimensions when entry already in cache', async () => {
+            await localStorageCache.putFileInfo('https://example.com/notYetInCache', dummyFileInfo);
+            await localStorageCache.putDimensions('https://example.com/notYetInCache', dummyDimensions);
+            await localStorageCache.putFileInfo('https://example.com/notYetInCache', dummyNewFileInfo);
+
+            await expect(localStorageCache.getFileInfo('https://example.com/notYetInCache'))
+                .resolves.toStrictEqual(dummyNewFileInfo);
+        });
+    }
+
+    describe('with empty cache', () => {
+        beforeEach(() => {
+            mockLocalStorage({
+                [CACHE_LOCALSTORAGE_KEY]: '{}',
+            });
+        });
+
+        // eslint-disable-next-line jest/require-hook
+        runTests();
+    });
+
+    describe('with non-existent cache', () => {
+        // Simulate first run
+        beforeEach(() => {
+            mockLocalStorage({});
+        });
+
+        // eslint-disable-next-line jest/require-hook
+        runTests();
+    });
+
+    describe('with broken cache', () => {
+        beforeEach(() => {
+            mockLocalStorage({
+                [CACHE_LOCALSTORAGE_KEY]: '{{{},,,}not json!',
+            });
+        });
+
+        // eslint-disable-next-line jest/require-hook
+        runTests();
+    });
+
+    describe('with non-empty cache', () => {
+        const store = {
+            'https://example.com/1': {
+                dimensions: dummyDimensions,
+                fileInfo: dummyFileInfo,
+                addedDatetime: 123,
+            },
+            'https://example.com/2': {
+                dimensions: dummyDimensions,
+                fileInfo: dummyFileInfo,
+                addedDatetime: 456,
+            },
+        };
+
+        beforeEach(() => {
+            mockLocalStorage({
+                [CACHE_LOCALSTORAGE_KEY]: JSON.stringify(store),
+            });
+        });
+
+        // eslint-disable-next-line jest/require-hook
+        runTests();
+
+        it('retains previous entries when inserting new entry', async () => {
+            await localStorageCache.putDimensions('https://example.com/3', dummyNewDimensions);
+
+            await expect(localStorageCache.getDimensions('https://example.com/1'))
+                .resolves.toStrictEqual(dummyDimensions);
+            await expect(localStorageCache.getDimensions('https://example.com/2'))
+                .resolves.toStrictEqual(dummyDimensions);
+            await expect(localStorageCache.getFileInfo('https://example.com/1'))
+                .resolves.toStrictEqual(dummyFileInfo);
+            await expect(localStorageCache.getFileInfo('https://example.com/2'))
+                .resolves.toStrictEqual(dummyFileInfo);
+        });
+    });
+
+    describe('with full cache', () => {
+        beforeEach(() => {
+            const store: CacheStore = {};
+            let i = MAX_CACHED_IMAGES;
+            while (i--) {
+                store[`https://example.com/${i}`] = {
+                    dimensions: dummyDimensions,
+                    fileInfo: dummyFileInfo,
+                    addedDatetime: i,
+                };
+            }
+
+            mockLocalStorage({
+                [CACHE_LOCALSTORAGE_KEY]: JSON.stringify(store),
+            });
+        });
+
+        // eslint-disable-next-line jest/require-hook
+        runTests();
+
+        it('removes oldest entry when inserting new entry', async () => {
+            await localStorageCache.putDimensions('https://example.com/abc', dummyNewDimensions);
+
+            await expect(localStorageCache.getDimensions('https://example.com/0'))
+                .resolves.toBeUndefined();
+            await expect(localStorageCache.getFileInfo('https://example.com/0'))
+                .resolves.toBeUndefined();
+        });
+
+        it('keeps second-oldest entry when inserting new entry', async () => {
+            await localStorageCache.putDimensions('https://example.com/abc', dummyNewDimensions);
+
+            await expect(localStorageCache.getDimensions('https://example.com/1'))
+                .resolves.toBeDefined();
+            await expect(localStorageCache.getFileInfo('https://example.com/1'))
+                .resolves.toBeDefined();
+        });
+    });
+});
+
+describe('a-tisket images', () => {
+    const pollyContext = setupPolly();
+
+    it('loads file info for Apple Music images', async () => {
+        const image = new AtisketImage('https://is2-ssl.mzstatic.com/image/thumb/Music/v4/05/f3/b2/05f3b216-755e-6472-e998-f72a3b487dc0/884501818353.jpg/9999x9999-100.jpg');
+
+        await expect(image.getFileInfo()).resolves.toStrictEqual({
+            size: 1_826_850,
+            fileType: 'JPEG',
+        });
+    });
+
+    it('loads file info for Apple Music PNG images', async () => {
+        const image = new AtisketImage('https://a1.mzstatic.com/us/r1000/063/Music126/v4/48/4f/49/484f49a5-fb52-37b3-f3c6-244e20f74b7c/5052075509815.png');
+
+        await expect(image.getFileInfo()).resolves.toStrictEqual({
+            size: 23_803_429,  // I'm glad we're just getting headers, this is huge!
+            fileType: 'PNG',
+        });
+    });
+
+    it('loads file info for Spotify images', async () => {
+        const image = new AtisketImage('https://i.scdn.co/image/ab67616d0000b273843b6bc2dc1517b7f7f0f424');
+
+        await expect(image.getFileInfo()).resolves.toStrictEqual({
+            fileType: 'JPEG',
+            size: 102_281,
+        });
+    });
+
+    it('loads file info for Deezer images', async () => {
+        const image = new AtisketImage('https://e-cdns-images.dzcdn.net/images/cover/2d8c720d7fee9506e40c5f16760c3640/1200x0-000000-100-0-0.jpg');
+
+        await expect(image.getFileInfo()).resolves.toStrictEqual({
+            fileType: 'JPEG',
+            size: 365_960,
+        });
+    });
+
+    describe('retrying', () => {
+        // We won't mock out the p-retry module, since replicating its behaviour
+        // will be tricky. Instead, we'll mock out the function that creates the
+        // underlying timeouts, so retries are done immediately and the tests don't
+        // time out.
+        const timeoutsSpy = jest.spyOn(retry, 'timeouts');
+        const xhrSpy = jest.spyOn(xhr, 'gmxhr');
+
+        beforeEach(() => {
+            timeoutsSpy.mockReturnValue([0, 0, 0, 0, 0]);
+            xhrSpy.mockClear();
+        });
+
+        it('retries on 429 errors', async () => {
+            pollyContext.polly.server
+                .any()
+                .intercept((_req, res) => {
+                    res.sendStatus(429);
+                });
+            const image = new AtisketImage('https://example.com/test');
+
+            await expect(image.getFileInfo()).resolves.toBeUndefined();
+            expect(xhrSpy).toHaveBeenCalledTimes(6); // First try + 5 retries
+        });
+
+        it('does not retry on 404 errors', async () => {
+            pollyContext.polly.server
+                .any()
+                .intercept((_req, res) => {
+                    res.sendStatus(404);
+                });
+            const image = new AtisketImage('https://example.com/test');
+
+            await expect(image.getFileInfo()).resolves.toBeUndefined();
+            expect(xhrSpy).toHaveBeenCalledTimes(1);
+        });
+
+        it('retries on 503 errors', async () => {
+            pollyContext.polly.server
+                .any()
+                .intercept((_req, res) => {
+                    res.sendStatus(503);
+                });
+            const image = new AtisketImage('https://example.com/test');
+
+            await expect(image.getFileInfo()).resolves.toBeUndefined();
+            expect(xhrSpy).toHaveBeenCalledTimes(6);
+        });
+    });
+});