Repository: ROpdebee/mb-userscripts (ts)

Issue:
VGMdb: Not all images are fetched
https://community.metabrainz.org/t/ropdebees-userscripts-support-thread/551947/31
https://community.metabrainz.org/t/ropdebees-userscripts-support-thread/551947/17

I was under the impression that vgmdb.info provided all the covers, that's evidently not true. There doesn't seem to be a way for us to grab the missing images without somehow logging in to an account, and adding account support it something I'd like to avoid altogether.

It appears the most we can do here is warn if images are missing, but for that, we need to know how many images a given album has in total. That's available through [this table](https://vgmdb.net/db/covers.php), but extracting that information would be inefficient since we need to traverse the table page-by-page. Binary search would work, but still requires a fair few requests in the worst case.

At the very least, the provider support table should be updated to make note of this.

Pull request description:
feat(caa upload): add VGMdb seeder
Closes #62.

We can't extract all images through the VGMdb provider, so we'll add a VGMdb seeder on logged-in pages so that images that require logging in can be extracted from the page itself, and added through seeding parameters.

Patch:
diff --git a/package-lock.json b/package-lock.json
index ed650f79..ea019d33 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10028,7 +10028,7 @@
     },
     "node_modules/nativejsx": {
       "version": "4.3.0",
-      "resolved": "git+ssh://git@github.com/ROpdebee/nativejsx.git#6bd78f05bf858978ad8217372a37594fb2a53e86",
+      "resolved": "git+ssh://git@github.com/ROpdebee/nativejsx.git#c4fb7561cb02f64b8d3cddd20c288b1c93932286",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -23099,7 +23099,7 @@
       "dev": true
     },
     "nativejsx": {
-      "version": "git+ssh://git@github.com/ROpdebee/nativejsx.git#6bd78f05bf858978ad8217372a37594fb2a53e86",
+      "version": "git+ssh://git@github.com/ROpdebee/nativejsx.git#c4fb7561cb02f64b8d3cddd20c288b1c93932286",
       "dev": true,
       "from": "nativejsx@github:ROpdebee/nativejsx",
       "requires": {
diff --git a/src/lib/MB/URLs.ts b/src/lib/MB/URLs.ts
index 34692b3d..656e0270 100644
--- a/src/lib/MB/URLs.ts
+++ b/src/lib/MB/URLs.ts
@@ -28,3 +28,9 @@ export async function getURLsForRelease(releaseId: string, options?: { excludeEn
         }
     });
 }
+
+export async function getReleaseIDsForURL(url: string): Promise<string[]> {
+    const resp = await fetch(`//musicbrainz.org/ws/2/url?resource=${encodeURIComponent(url)}&inc=release-rels&fmt=json`);
+    const metadata = await resp.json();
+    return metadata.relations?.map((rel: URLAdvRel & ReleaseAdvRel) => rel.release.id) ?? [];
+}
diff --git a/src/lib/MB/advanced-relationships.ts b/src/lib/MB/advanced-relationships.ts
index 48cd2aa2..cf04d68a 100644
--- a/src/lib/MB/advanced-relationships.ts
+++ b/src/lib/MB/advanced-relationships.ts
@@ -9,5 +9,8 @@ export interface URLAdvRel extends AdvancedRelationship {
     };
 }
 
-// eslint-disable-next-line @typescript-eslint/no-empty-interface
-export interface ReleaseAdvRel extends AdvancedRelationship {}
+export interface ReleaseAdvRel extends AdvancedRelationship {
+    release: {
+        id: string;
+    };
+}
diff --git a/src/mb_enhanced_cover_art_uploads/docs/supported_providers.md b/src/mb_enhanced_cover_art_uploads/docs/supported_providers.md
index 35e9645b..1292e5c2 100644
--- a/src/mb_enhanced_cover_art_uploads/docs/supported_providers.md
+++ b/src/mb_enhanced_cover_art_uploads/docs/supported_providers.md
@@ -20,4 +20,4 @@ The following table describes the types of links supported by MB: Upload to CAA
 | Soundcloud | ✔️ | ✔️ | Grabs custom track images. |
 | Spotify | ✔️ | ✔️ |
 | Tidal | ✔️ | ✔️ | listen.tidal.com/store.tidal.com are converted to tidal.com prior to fetching |
-| VGMdb | ✔️ | ✔️ | Types are filled on a best-effort basis, make sure to double-check. Some images are not fetched, see issue [#62](https://github.com/ROpdebee/mb-userscripts/issues/62). |
+| VGMdb | ✔️ | ✔️ | Types are filled on a best-effort basis, make sure to double-check. Images which require logging in are not fetched, they can be added by navigating to the album on VGMdb, making sure you're logged in, and using the seeding options displayed in the cover art table in the sidebar. |
diff --git a/src/mb_enhanced_cover_art_uploads/form.ts b/src/mb_enhanced_cover_art_uploads/form.ts
index fe3e99b0..94e30d8a 100644
--- a/src/mb_enhanced_cover_art_uploads/form.ts
+++ b/src/mb_enhanced_cover_art_uploads/form.ts
@@ -63,34 +63,42 @@ function setImageParameters(imageName: string, imageTypes: ArtworkTypeIDs[], ima
     }
 }
 
-export function fillEditNote({ images, containerUrl }: FetchedImages, origin: string, editNote: EditNote): void {
+export function fillEditNote(allFetchedImages: FetchedImages[], origin: string, editNote: EditNote): void {
+    const totalNumImages = allFetchedImages.reduce((acc, fetched) => acc + fetched.images.length, 0);
     // Nothing enqueued => Skip edit note altogether
-    if (!images.length) return;
-
-    let prefix = '';
-    if (containerUrl) {
-        prefix = ' * ';
-        editNote.addExtraInfo(decodeURI(containerUrl.href));
-    }
+    if (!totalNumImages) return;
 
     // Limiting to 3 URLs to reduce noise
-    for (const queuedUrl of images.slice(0, 3)) {
-        // Prevent noise from data: URLs
-        if (queuedUrl.maximisedUrl.protocol === 'data:') {
-            editNote.addExtraInfo(prefix + 'Uploaded from data URL');
-            continue;
-        }
-        editNote.addExtraInfo(prefix + decodeURI(queuedUrl.originalUrl.href));
-        if (queuedUrl.wasMaximised) {
-            editNote.addExtraInfo(' '.repeat(prefix.length) + '→ Maximised to ' + decodeURI(queuedUrl.maximisedUrl.href));
+    let numFilled = 0;
+    for (const { containerUrl, images } of allFetchedImages) {
+        let prefix = '';
+        if (containerUrl) {
+            prefix = ' * ';
+            editNote.addExtraInfo(decodeURI(containerUrl.href));
         }
-        if (queuedUrl.wasRedirected) {
-            editNote.addExtraInfo(' '.repeat(prefix.length) + '→ Redirected to ' + decodeURI(queuedUrl.fetchedUrl.href));
+
+        for (const queuedUrl of images) {
+            numFilled += 1;
+            if (numFilled > 3) break;
+            // Prevent noise from data: URLs
+            if (queuedUrl.maximisedUrl.protocol === 'data:') {
+                editNote.addExtraInfo(prefix + 'Uploaded from data URL');
+                continue;
+            }
+            editNote.addExtraInfo(prefix + decodeURI(queuedUrl.originalUrl.href));
+            if (queuedUrl.wasMaximised) {
+                editNote.addExtraInfo(' '.repeat(prefix.length) + '→ Maximised to ' + decodeURI(queuedUrl.maximisedUrl.href));
+            }
+            if (queuedUrl.wasRedirected) {
+                editNote.addExtraInfo(' '.repeat(prefix.length) + '→ Redirected to ' + decodeURI(queuedUrl.fetchedUrl.href));
+            }
         }
+
+        if (numFilled > 3) break;
     }
 
-    if (images.length > 3) {
-        editNote.addExtraInfo(prefix + `…and ${images.length - 3} additional image(s)`);
+    if (totalNumImages > 3) {
+        editNote.addExtraInfo(`…and ${totalNumImages - 3} additional image(s)`);
     }
 
     if (origin) {
diff --git a/src/mb_enhanced_cover_art_uploads/index.ts b/src/mb_enhanced_cover_art_uploads/index.ts
index 8d257654..a1149803 100644
--- a/src/mb_enhanced_cover_art_uploads/index.ts
+++ b/src/mb_enhanced_cover_art_uploads/index.ts
@@ -7,7 +7,7 @@ import { ConsoleSink } from '@lib/logging/consoleSink';
 import { LogLevel } from '@lib/logging/levels';
 
 import { getProvider } from './providers';
-import type { ArtworkTypeIDs } from './providers/base';
+import type { CoverArt } from './providers/base';
 
 import { StatusBanner } from './ui/status_banner';
 import USERSCRIPT_NAME from 'consts:userscript-name';
@@ -36,7 +36,7 @@ class App {
         this.#ui = new InputForm(banner.htmlElement, this.processURL.bind(this));
     }
 
-    async processURL(url: URL, types: ArtworkTypeIDs[] = [], comment = '', origin = ''): Promise<void> {
+    async processURL(url: URL): Promise<void> {
         // Don't process a URL if we're already doing so
         if (this.#urlsInProgress.has(url.href)) {
             return;
@@ -44,13 +44,13 @@ class App {
 
         try {
             this.#urlsInProgress.add(url.href);
-            await this.#_processURL(url, types, comment, origin);
+            await this.#_processURL(url);
         } finally {
             this.#urlsInProgress.delete(url.href);
         }
     }
 
-    async #_processURL(url: URL, types: ArtworkTypeIDs[] = [], comment = '', origin = ''): Promise<void> {
+    async #_processURL(url: URL): Promise<void> {
         // eslint-disable-next-line init-declarations
         let fetchResult: FetchedImages;
         try {
@@ -61,22 +61,52 @@ class App {
         }
 
         try {
-            await enqueueImages(fetchResult, types, comment);
+            await enqueueImages(fetchResult);
         } catch (err) {
             LOGGER.error('Failed to enqueue images', err);
             return;
         }
 
-        fillEditNote(fetchResult, origin, this.#note);
         this.#ui.clearOldInputValue(url.href);
+
+        fillEditNote([fetchResult], origin, this.#note);
         if (fetchResult.images.length) {
             LOGGER.success(`Successfully added ${fetchResult.images.length} image(s)`);
         }
     }
 
-    processSeedingParameters(): void {
+    async processSeedingParameters(): Promise<void> {
         const params = SeedParameters.decode(new URLSearchParams(document.location.search));
-        params.images.forEach((image) => this.processURL(image.url, image.types, image.comment, params.origin));
+        // Although this is very similar to `processURL`, we may have to fetch
+        // and enqueue multiple images. We want to fetch images in parallel, but
+        // enqueue them sequentially to ensure the order stays consistent.
+
+        // eslint-disable-next-line init-declarations
+        let fetchResults: Array<[FetchedImages, CoverArt]>;
+        try {
+            fetchResults = await Promise.all(params.images
+                .map(async (cover): Promise<[FetchedImages, CoverArt]> => {
+                    return [await this.#fetcher.fetchImages(cover.url), cover];
+                }));
+        } catch (err) {
+            LOGGER.error('Failed to grab images', err);
+            return;
+        }
+
+        // Not using Promise.all to ensure images get added in order.
+        for (const [fetchResult, cover] of fetchResults) {
+            try {
+                await enqueueImages(fetchResult, cover.types, cover.comment);
+            } catch (err) {
+                LOGGER.error('Failed to enqueue some images', err);
+            }
+        }
+
+        fillEditNote(fetchResults.map((pair) => pair[0]), params.origin ?? '', this.#note);
+        const totalNumImages = fetchResults.reduce((acc, pair) => acc + pair[0].images.length, 0);
+        if (totalNumImages) {
+            LOGGER.success(`Successfully added ${totalNumImages} image(s)`);
+        }
     }
 
     async addImportButtons(): Promise<void> {
diff --git a/src/mb_enhanced_cover_art_uploads/meta.ts b/src/mb_enhanced_cover_art_uploads/meta.ts
index e5548b83..0dfaedc2 100644
--- a/src/mb_enhanced_cover_art_uploads/meta.ts
+++ b/src/mb_enhanced_cover_art_uploads/meta.ts
@@ -10,7 +10,8 @@ const metadata: UserscriptMetadata = {
         'release/*/add-cover-art',
         'release/*/add-cover-art?*',
     ].map(transformMBMatchURL).concat([
-        '*://atisket.pulsewidth.org.uk/*'
+        '*://atisket.pulsewidth.org.uk/*',
+        '*://vgmdb.net/album/*',
     ]),
     exclude: ['*://atisket.pulsewidth.org.uk/'],
     grant: [
diff --git a/src/mb_enhanced_cover_art_uploads/providers/vgmdb.ts b/src/mb_enhanced_cover_art_uploads/providers/vgmdb.ts
index e3f7b57b..31243b9b 100644
--- a/src/mb_enhanced_cover_art_uploads/providers/vgmdb.ts
+++ b/src/mb_enhanced_cover_art_uploads/providers/vgmdb.ts
@@ -1,3 +1,4 @@
+import { LOGGER } from '@lib/logging/logger';
 import { assert, assertHasValue } from '@lib/util/assert';
 import { safeParseJSON } from '@lib/util/json';
 import { gmxhr } from '@lib/util/xhr';
@@ -122,6 +123,22 @@ for (const [key, value] of Object.entries(__CAPTION_TYPE_MAPPING)) {
     };
 }
 
+export function convertCaptions(cover: { url: string; caption: string }): CoverArt {
+    const url = new URL(cover.url);
+    if (!cover.caption) {
+        return { url };
+    }
+    const [captionType, ...captionRestParts] = cover.caption.split(' ');
+    const captionRest = captionRestParts.join(' ');
+    const mapper = CAPTION_TYPE_MAPPING[captionType.toLowerCase()];
+
+    if (!mapper) return { url, comment: cover.caption };
+    return {
+        url,
+        ...mapper(captionRest),
+    };
+}
+
 export class VGMdbProvider extends CoverArtProvider {
     supportedDomains = ['vgmdb.net'];
     favicon = 'https://vgmdb.net/favicon.ico';
@@ -138,6 +155,7 @@ export class VGMdbProvider extends CoverArtProvider {
 
         assert(metadata.link === 'album/' + id, `VGMdb.info returned wrong release: Requested album/${id}, got ${metadata.link}`);
 
+        LOGGER.warn('Heads up! VGMdb requires you to be logged in to view all images, some images may have been missed. If you have an account, please go to the album on VGMdb and use the seeding functionality to add the missing images.');
         return this.#extractImages(metadata);
     }
 
@@ -151,22 +169,6 @@ export class VGMdbProvider extends CoverArtProvider {
             covers.unshift({ url: metadata.picture_full, caption: 'Front' });
         }
 
-        return covers.map(this.#convertCaptions.bind(this));
-    }
-
-    #convertCaptions(cover: { url: string; caption: string }): CoverArt {
-        const url = new URL(cover.url);
-        if (!cover.caption) {
-            return { url };
-        }
-        const [captionType, ...captionRestParts] = cover.caption.split(' ');
-        const captionRest = captionRestParts.join(' ');
-        const mapper = CAPTION_TYPE_MAPPING[captionType.toLowerCase()];
-
-        if (!mapper) return { url, comment: cover.caption };
-        return {
-            url,
-            ...mapper(captionRest),
-        };
+        return covers.map(convertCaptions);
     }
 }
diff --git a/src/mb_enhanced_cover_art_uploads/seeding/index.ts b/src/mb_enhanced_cover_art_uploads/seeding/index.ts
index 7f482160..e7e696dc 100644
--- a/src/mb_enhanced_cover_art_uploads/seeding/index.ts
+++ b/src/mb_enhanced_cover_art_uploads/seeding/index.ts
@@ -2,8 +2,10 @@
 
 import { AtasketSeeder, AtisketSeeder } from './atisket';
 import { registerSeeder } from './base';
+import { VGMdbSeeder } from './vgmdb';
 
 registerSeeder(AtisketSeeder);
 registerSeeder(AtasketSeeder);
+registerSeeder(VGMdbSeeder);
 
 export { seederFactory } from './base';
diff --git a/src/mb_enhanced_cover_art_uploads/seeding/vgmdb.tsx b/src/mb_enhanced_cover_art_uploads/seeding/vgmdb.tsx
new file mode 100644
index 00000000..dba078ed
--- /dev/null
+++ b/src/mb_enhanced_cover_art_uploads/seeding/vgmdb.tsx
@@ -0,0 +1,128 @@
+import { LOGGER } from '@lib/logging/logger';
+import { getReleaseIDsForURL } from '@lib/MB/URLs';
+import { assertHasValue } from '@lib/util/assert';
+import { qs, qsa, qsMaybe } from '@lib/util/dom';
+import type { CoverArt } from '../providers/base';
+import { convertCaptions, VGMdbProvider } from '../providers/vgmdb';
+import type { Seeder } from './base';
+import { SeedParameters } from './parameters';
+
+interface VGMdbCovers {
+    allCovers: CoverArt[];
+    privateCovers: CoverArt[];
+}
+
+// VGMdb seeder to enqueue images that are only displayed for logged in users.
+// The VGMdb provider cannot extract those, so the user needs to manually navigate
+// to the release and seed the private images from there.
+export const VGMdbSeeder: Seeder = {
+    supportedDomains: ['vgmdb.net'],
+    supportedRegexes: [/\/album\/(\d+)(?:\/|#|\?|$)/],
+
+    insertSeedLinks(): void {
+        if (!isLoggedIn()) {
+            return;
+        }
+
+        const coverHeading = qsMaybe('#covernav')?.parentElement;
+        if (!coverHeading) {
+            LOGGER.info('No covers in release, not inserting seeding menu');
+            return;
+        }
+
+        const releaseIdsProm = getMBReleases();
+        const coversProm = extractCovers(coverHeading);
+
+        Promise.all([releaseIdsProm, coversProm]).then(([releaseIds, covers]) => {
+            insertSeedButtons(coverHeading, releaseIds, covers);
+        });
+    }
+};
+
+function isLoggedIn(): boolean {
+    return qsMaybe('#navmember') !== null;
+}
+
+function getMBReleases(): Promise<string[]> {
+    // Account for possibility that we're on HTTPS, remove any query params
+    // or hash.
+    const releaseUrl = 'https://vgmdb.net' + document.location.pathname;
+    return getReleaseIDsForURL(releaseUrl);
+}
+
+async function extractCovers(coverHeading: Element): Promise<VGMdbCovers> {
+    const coverDiv = coverHeading.parentElement;
+    assertHasValue(coverDiv);
+    const coverElements = qsa<HTMLAnchorElement>('#cover_gallery a[id*="thumb_"]', coverDiv);
+    const covers = coverElements.map(extractCoverFromAnchor);
+
+    // Split the extracted covers into public and private, to provide the option
+    // to seed only private covers.
+    const publicCoverURLs = new Set((await new VGMdbProvider().findImages(new URL(document.location.href)))
+        .map((cover) => cover.url.href));
+    const result: VGMdbCovers = {
+        allCovers: covers,
+        privateCovers: covers.filter((cover) => !publicCoverURLs.has(cover.url.href)),
+    };
+
+    return result;
+}
+
+function extractCoverFromAnchor(anchor: HTMLAnchorElement): CoverArt {
+    return convertCaptions({
+        url: anchor.href,
+        caption: qs('.label', anchor).textContent ?? '',
+    });
+}
+
+function insertSeedButtons(coverHeading: Element, releaseIds: string[], covers: VGMdbCovers): void {
+    const seedParamsPrivate = new SeedParameters(covers.privateCovers, document.location.href);
+    const seedParamsAll = new SeedParameters(covers.allCovers, document.location.href);
+
+    const relIdToAnchors = new Map(releaseIds.map((relId) => {
+        const a = <a
+            href={ seedParamsPrivate.createSeedURL(relId) }
+            target='_blank'
+            rel='noopener noreferrer'
+            style={{ display: 'block' }}
+        >
+            { 'Seed covers to ' + relId.split('-')[0] }
+        </a> as HTMLAnchorElement;
+        return [relId, a];
+    }));
+    const anchors = [...relIdToAnchors.values()];
+
+    const inclPublicCheckbox = <input
+        type='checkbox'
+        id='ROpdebee_incl_public_checkbox'
+        onChange={(evt): void => {
+            relIdToAnchors.forEach((a, relId) => {
+                if (evt.currentTarget.checked) {
+                    a.href = seedParamsAll.createSeedURL(relId);
+                } else {
+                    a.href = seedParamsPrivate.createSeedURL(relId);
+                }
+            });
+        }}
+    />;
+    const inclPublicLabel = <label
+        htmlFor='ROpdebee_incl_public_checkbox'
+        title='Leave this unchecked to only seed covers which cannot be extracted by the VGMdb provider'
+        style={{ cursor: 'help' }}
+    >Include publicly accessible covers</label>;
+
+    const containedElements = [inclPublicCheckbox, inclPublicLabel].concat(anchors);
+    if (!anchors.length) {
+        containedElements.push(<span style={{ display: 'block'}}>
+            This album is not linked to any MusicBrainz releases!
+        </span>);
+    }
+
+    const container = <div
+        style={{ padding: '8px 8px 0px 8px', fontSize: '8pt' }}
+    >
+        {containedElements}
+    </div>;
+
+    coverHeading.nextElementSibling?.insertAdjacentElement('afterbegin', container);
+}


Test patch:
diff --git a/tests/lib/MB/URLs.test.ts b/tests/lib/MB/URLs.test.ts
index 3443a2ce..76213517 100644
--- a/tests/lib/MB/URLs.test.ts
+++ b/tests/lib/MB/URLs.test.ts
@@ -1,4 +1,4 @@
-import { getURLsForRelease } from '@lib/MB/URLs';
+import { getReleaseIDsForURL, getURLsForRelease } from '@lib/MB/URLs';
 import HttpAdapter from '@pollyjs/adapter-node-http';
 import { mockFetch, setupPolly } from '@test-utils/pollyjs';
 
@@ -7,7 +7,7 @@ import { mockFetch, setupPolly } from '@test-utils/pollyjs';
 // after an edit is made on MB, but on the other hand, if something changes in
 // the API, we'll know it too.
 // eslint-disable-next-line jest/require-hook
-setupPolly({
+const pollyContext = setupPolly({
     adapters: [HttpAdapter],
 });
 
@@ -43,3 +43,24 @@ describe('getting URLs for release', () => {
             .toStrictEqual(['https://bulletproofmessenger.bandcamp.com/track/round-2']);
     });
 });
+
+describe('getting releases for URLs', () => {
+    it('returns empty list when URL does not exist', async () => {
+        pollyContext.polly.configure({
+            recordFailedRequests: true,
+        });
+
+        await expect(getReleaseIDsForURL('https://example.com'))
+            .resolves.toBeEmpty();
+    });
+
+    it('returns empty list when no release is attached to URL', async () => {
+        await expect(getReleaseIDsForURL('https://www.discogs.com/artist/5788865'))
+            .resolves.toBeEmpty();
+    });
+
+    it('returns release ID for URL', async () => {
+        await expect(getReleaseIDsForURL('https://www.discogs.com/release/12620800'))
+            .resolves.toStrictEqual(['06ac3e39-54fb-46eb-b40b-9345b7d2a23d']);
+    });
+});
diff --git a/tests/mb_enhanced_cover_art_uploads/form.test.ts b/tests/mb_enhanced_cover_art_uploads/form.test.ts
index 87f440e3..3ee33a96 100644
--- a/tests/mb_enhanced_cover_art_uploads/form.test.ts
+++ b/tests/mb_enhanced_cover_art_uploads/form.test.ts
@@ -226,7 +226,7 @@ describe('filling edit notes', () => {
                 images: [],
             };
 
-            fillEditNote(fetchedImages, '', editNote);
+            fillEditNote([fetchedImages], '', editNote);
 
             expect(textarea.value).toBe('');
         });
@@ -245,7 +245,7 @@ describe('filling edit notes', () => {
             };
             const expectedLines = [args.prefix + fakeUrl.href];
 
-            fillEditNote(fetchedImages, '', editNote);
+            fillEditNote([fetchedImages], '', editNote);
 
             expect(textarea.value).toBe(createExpectedContent(expectedLines));
         });
@@ -267,7 +267,7 @@ describe('filling edit notes', () => {
                 ' '.repeat(args.prefix.length) + '→ Maximised to https://example.com/max',
             ];
 
-            fillEditNote(fetchedImages, '', editNote);
+            fillEditNote([fetchedImages], '', editNote);
 
             expect(textarea.value).toBe(createExpectedContent(expectedLines));
         });
@@ -289,7 +289,7 @@ describe('filling edit notes', () => {
                 ' '.repeat(args.prefix.length) + '→ Redirected to https://example.com/redirected',
             ];
 
-            fillEditNote(fetchedImages, '', editNote);
+            fillEditNote([fetchedImages], '', editNote);
 
             expect(textarea.value).toBe(createExpectedContent(expectedLines));
         });
@@ -312,7 +312,7 @@ describe('filling edit notes', () => {
                 ' '.repeat(args.prefix.length) + '→ Redirected to https://example.com/redirected',
             ];
 
-            fillEditNote(fetchedImages, '', editNote);
+            fillEditNote([fetchedImages], '', editNote);
 
             expect(textarea.value).toBe(createExpectedContent(expectedLines));
         });
@@ -334,7 +334,7 @@ describe('filling edit notes', () => {
                 args.prefix + 'Uploaded from data URL',
             ];
 
-            fillEditNote(fetchedImages, '', editNote);
+            fillEditNote([fetchedImages], '', editNote);
 
             expect(textarea.value).toBe(createExpectedContent(expectedLines));
         });
@@ -365,7 +365,7 @@ describe('filling edit notes', () => {
                 ' '.repeat(args.prefix.length) + '→ Maximised to https://example.com/max2',
             ];
 
-            fillEditNote(fetchedImages, '', editNote);
+            fillEditNote([fetchedImages], '', editNote);
 
             expect(textarea.value).toBe(createExpectedContent(expectedLines));
         });
@@ -407,7 +407,56 @@ describe('filling edit notes', () => {
                 args.prefix + 'https://example.com/1',
                 args.prefix + 'https://example.com/2',
                 args.prefix + 'https://example.com/3',
-                args.prefix + '…and 1 additional image(s)',
+                '…and 1 additional image(s)',
+            ];
+
+            fillEditNote([fetchedImages], '', editNote);
+
+            expect(textarea.value).toBe(createExpectedContent(expectedLines));
+        });
+
+        it('fills at most 3 URLs with separate fetch results', () => {
+            const images = [{
+                originalUrl: new URL('https://example.com/1'),
+                maximisedUrl: new URL('https://example.com/1'),
+                fetchedUrl: new URL('https://example.com/1'),
+                wasRedirected: false,
+                wasMaximised: false,
+                content: createDummyImage('test.png'),
+            }, {
+                originalUrl: new URL('https://example.com/2'),
+                maximisedUrl: new URL('https://example.com/2'),
+                fetchedUrl: new URL('https://example.com/2'),
+                wasRedirected: false,
+                wasMaximised: false,
+                content: createDummyImage('test.png'),
+            }, {
+                originalUrl: new URL('https://example.com/3'),
+                maximisedUrl: new URL('https://example.com/3'),
+                fetchedUrl: new URL('https://example.com/3'),
+                wasRedirected: false,
+                wasMaximised: false,
+                content: createDummyImage('test.png'),
+            }, {
+                originalUrl: new URL('https://example.com/4'),
+                maximisedUrl: new URL('https://example.com/4'),
+                fetchedUrl: new URL('https://example.com/4'),
+                wasRedirected: false,
+                wasMaximised: false,
+                content: createDummyImage('test.png'),
+            }];
+            const fetchedImages = images.map((img) => {
+                return {
+                    containerUrl: args.containerUrl,
+                    images: [img],
+                };
+            });
+            // The edit note filler removes the duplicate container URL.
+            const expectedLines = [
+                args.prefix + 'https://example.com/1',
+                args.prefix + 'https://example.com/2',
+                args.prefix + 'https://example.com/3',
+                '…and 1 additional image(s)',
             ];
 
             fillEditNote(fetchedImages, '', editNote);
@@ -432,7 +481,7 @@ describe('filling edit notes', () => {
                 'Seeded from seeding-origin',
             ];
 
-            fillEditNote(fetchedImages, 'seeding-origin', editNote);
+            fillEditNote([fetchedImages], 'seeding-origin', editNote);
 
             expect(textarea.value).toBe(createExpectedContent(expectedLines));
         });
diff --git a/tests/test-data/__recordings__/getting-releases-for-URLs_3409569510/returns-empty-list-when-URL-does-not-exist_2275831759/recording.har b/tests/test-data/__recordings__/getting-releases-for-URLs_3409569510/returns-empty-list-when-URL-does-not-exist_2275831759/recording.har
new file mode 100644
index 00000000..827d92f3
--- /dev/null
+++ b/tests/test-data/__recordings__/getting-releases-for-URLs_3409569510/returns-empty-list-when-URL-does-not-exist_2275831759/recording.har
@@ -0,0 +1,130 @@
+{
+  "log": {
+    "_recordingName": "getting releases for URLs/returns empty list when URL does not exist",
+    "creator": {
+      "comment": "persister:fs",
+      "name": "Polly.JS",
+      "version": "5.1.1"
+    },
+    "entries": [
+      {
+        "_id": "1da35b6695a66421e008745ab8704fb2",
+        "_order": 0,
+        "cache": {},
+        "request": {
+          "bodySize": 0,
+          "cookies": [],
+          "headers": [
+            {
+              "name": "accept",
+              "value": "*/*"
+            },
+            {
+              "name": "accept-encoding",
+              "value": "gzip,deflate,br"
+            },
+            {
+              "name": "connection",
+              "value": "close"
+            },
+            {
+              "name": "user-agent",
+              "value": "node-fetch"
+            },
+            {
+              "name": "host",
+              "value": "musicbrainz.org"
+            }
+          ],
+          "headersSize": 223,
+          "httpVersion": "HTTP/1.1",
+          "method": "GET",
+          "queryString": [
+            {
+              "name": "resource",
+              "value": "https://example.com"
+            },
+            {
+              "name": "inc",
+              "value": "release-rels"
+            },
+            {
+              "name": "fmt",
+              "value": "json"
+            }
+          ],
+          "url": "https://musicbrainz.org/ws/2/url?resource=https%3A%2F%2Fexample.com&inc=release-rels&fmt=json"
+        },
+        "response": {
+          "bodySize": 93,
+          "content": {
+            "mimeType": "application/json; charset=utf-8",
+            "size": 93,
+            "text": "{\"error\":\"Not Found\",\"help\":\"For usage, please see: https://musicbrainz.org/development/mmd\"}"
+          },
+          "cookies": [],
+          "headers": [
+            {
+              "name": "date",
+              "value": "Sat, 30 Oct 2021 16:35:10 GMT"
+            },
+            {
+              "name": "content-type",
+              "value": "application/json; charset=utf-8"
+            },
+            {
+              "name": "content-length",
+              "value": "93"
+            },
+            {
+              "name": "connection",
+              "value": "close"
+            },
+            {
+              "name": "x-ratelimit-limit",
+              "value": "1200"
+            },
+            {
+              "name": "x-ratelimit-remaining",
+              "value": "571"
+            },
+            {
+              "name": "x-ratelimit-reset",
+              "value": "1635611711"
+            },
+            {
+              "name": "server",
+              "value": "Plack::Handler::Starlet"
+            },
+            {
+              "name": "etag",
+              "value": "\"74811b6ebfb55bc761c2a7cca11d88d6\""
+            },
+            {
+              "name": "access-control-allow-origin",
+              "value": "*"
+            }
+          ],
+          "headersSize": 316,
+          "httpVersion": "HTTP/1.1",
+          "redirectURL": "",
+          "status": 404,
+          "statusText": "Not Found"
+        },
+        "startedDateTime": "2021-10-30T16:35:10.549Z",
+        "time": 311,
+        "timings": {
+          "blocked": -1,
+          "connect": -1,
+          "dns": -1,
+          "receive": 0,
+          "send": 0,
+          "ssl": -1,
+          "wait": 311
+        }
+      }
+    ],
+    "pages": [],
+    "version": "1.2"
+  }
+}
diff --git a/tests/test-data/__recordings__/getting-releases-for-URLs_3409569510/returns-empty-list-when-no-release-is-attached-to-URL_3987713515/recording.har b/tests/test-data/__recordings__/getting-releases-for-URLs_3409569510/returns-empty-list-when-no-release-is-attached-to-URL_3987713515/recording.har
new file mode 100644
index 00000000..7b101ddb
--- /dev/null
+++ b/tests/test-data/__recordings__/getting-releases-for-URLs_3409569510/returns-empty-list-when-no-release-is-attached-to-URL_3987713515/recording.har
@@ -0,0 +1,134 @@
+{
+  "log": {
+    "_recordingName": "getting releases for URLs/returns empty list when no release is attached to URL",
+    "creator": {
+      "comment": "persister:fs",
+      "name": "Polly.JS",
+      "version": "5.1.1"
+    },
+    "entries": [
+      {
+        "_id": "303e1b8705b5a2e6ab0833e45a1d05cc",
+        "_order": 0,
+        "cache": {},
+        "request": {
+          "bodySize": 0,
+          "cookies": [],
+          "headers": [
+            {
+              "name": "accept",
+              "value": "*/*"
+            },
+            {
+              "name": "accept-encoding",
+              "value": "gzip,deflate,br"
+            },
+            {
+              "name": "connection",
+              "value": "close"
+            },
+            {
+              "name": "user-agent",
+              "value": "node-fetch"
+            },
+            {
+              "name": "host",
+              "value": "musicbrainz.org"
+            }
+          ],
+          "headersSize": 246,
+          "httpVersion": "HTTP/1.1",
+          "method": "GET",
+          "queryString": [
+            {
+              "name": "resource",
+              "value": "https://www.discogs.com/artist/5788865"
+            },
+            {
+              "name": "inc",
+              "value": "release-rels"
+            },
+            {
+              "name": "fmt",
+              "value": "json"
+            }
+          ],
+          "url": "https://musicbrainz.org/ws/2/url?resource=https%3A%2F%2Fwww.discogs.com%2Fartist%2F5788865&inc=release-rels&fmt=json"
+        },
+        "response": {
+          "bodySize": 112,
+          "content": {
+            "mimeType": "application/json; charset=utf-8",
+            "size": 112,
+            "text": "{\"id\":\"b3853e43-d80f-48af-80a7-117021f04e30\",\"relations\":[],\"resource\":\"https://www.discogs.com/artist/5788865\"}"
+          },
+          "cookies": [],
+          "headers": [
+            {
+              "name": "date",
+              "value": "Sat, 30 Oct 2021 16:35:11 GMT"
+            },
+            {
+              "name": "content-type",
+              "value": "application/json; charset=utf-8"
+            },
+            {
+              "name": "content-length",
+              "value": "112"
+            },
+            {
+              "name": "connection",
+              "value": "close"
+            },
+            {
+              "name": "x-ratelimit-limit",
+              "value": "1200"
+            },
+            {
+              "name": "x-ratelimit-remaining",
+              "value": "499"
+            },
+            {
+              "name": "x-ratelimit-reset",
+              "value": "1635611711"
+            },
+            {
+              "name": "server",
+              "value": "Plack::Handler::Starlet"
+            },
+            {
+              "name": "etag",
+              "value": "\"29a521fd16e332d7a8661a2ff5377339\""
+            },
+            {
+              "name": "access-control-allow-origin",
+              "value": "*"
+            },
+            {
+              "name": "x-cache-status",
+              "value": "MISS"
+            }
+          ],
+          "headersSize": 339,
+          "httpVersion": "HTTP/1.1",
+          "redirectURL": "",
+          "status": 200,
+          "statusText": "OK"
+        },
+        "startedDateTime": "2021-10-30T16:35:10.870Z",
+        "time": 196,
+        "timings": {
+          "blocked": -1,
+          "connect": -1,
+          "dns": -1,
+          "receive": 0,
+          "send": 0,
+          "ssl": -1,
+          "wait": 196
+        }
+      }
+    ],
+    "pages": [],
+    "version": "1.2"
+  }
+}
diff --git a/tests/test-data/__recordings__/getting-releases-for-URLs_3409569510/returns-release-ID-for-URL_2258567196/recording.har b/tests/test-data/__recordings__/getting-releases-for-URLs_3409569510/returns-release-ID-for-URL_2258567196/recording.har
new file mode 100644
index 00000000..d9be48c8
--- /dev/null
+++ b/tests/test-data/__recordings__/getting-releases-for-URLs_3409569510/returns-release-ID-for-URL_2258567196/recording.har
@@ -0,0 +1,143 @@
+{
+  "log": {
+    "_recordingName": "getting releases for URLs/returns release ID for URL",
+    "creator": {
+      "comment": "persister:fs",
+      "name": "Polly.JS",
+      "version": "5.1.1"
+    },
+    "entries": [
+      {
+        "_id": "1e1af7b19fd74d3c614196e6da0c12da",
+        "_order": 0,
+        "cache": {},
+        "request": {
+          "bodySize": 0,
+          "cookies": [],
+          "headers": [
+            {
+              "name": "accept",
+              "value": "*/*"
+            },
+            {
+              "name": "accept-encoding",
+              "value": "gzip,deflate,br"
+            },
+            {
+              "name": "connection",
+              "value": "close"
+            },
+            {
+              "name": "user-agent",
+              "value": "node-fetch"
+            },
+            {
+              "name": "host",
+              "value": "musicbrainz.org"
+            }
+          ],
+          "headersSize": 248,
+          "httpVersion": "HTTP/1.1",
+          "method": "GET",
+          "queryString": [
+            {
+              "name": "resource",
+              "value": "https://www.discogs.com/release/12620800"
+            },
+            {
+              "name": "inc",
+              "value": "release-rels"
+            },
+            {
+              "name": "fmt",
+              "value": "json"
+            }
+          ],
+          "url": "https://musicbrainz.org/ws/2/url?resource=https%3A%2F%2Fwww.discogs.com%2Frelease%2F12620800&inc=release-rels&fmt=json"
+        },
+        "response": {
+          "bodySize": 1020,
+          "content": {
+            "_isBinary": true,
+            "mimeType": "application/json; charset=utf-8",
+            "size": 1020,
+            "text": "[\"1f8b08000000000000037d92cf6edb300cc65fa5e0396c6ccb929ddc7b18b0071850e440c98c2bd47f32596e560479f75189e315c3b093658afaf1e3475e207047d18fc304fbd70bf0d07003fb2375136f601ae7e0185de0c647d8036cc072eb07d80f73d76d80620cdece91f183ba990571b96e12e3912070a689250e3f67ea7cfc14ca30869e3a613514e50a8a2caf31cf30d312f3f21632434eb1daa12e8f164bc3166d9959dca952dbaa29a8500dac74e40f1ee25dffbf901498928281fa74f73287f1c490ba0b11ff0ec64ff92cead319fdda4ce327eaad6fe79b5f773b6e72eb1d994abb024573854a4b6d5b2b87655e5b6af48e6a4d29771a51e5c6608e6e6c925baff0e3050ed7eb41c4448af3f4a55ae45f11039f024fd2dd52f202930bfe9466f19de220d08e0611d4a61e786841ec3f917b271952fb3fe9f18d9fe62efa5efc7a4ad34de10db8711e6248431261a2c1c72e91bff50998ec59e10fb2a5909a91249315da54a6ca0b53c3a3a17bda352908ec96e25620670acd6a77d2e7c676822f9e4349555d176263eeb4122f2b83a48f0a759d15c655e65858971e506839e2027a2cdcd7ddf4cdb2986b28597f589ffe59ef3408f1fbb6f5f2ff16e369da6fb7e7f3f97951f8ecc67ebb14d94aa7455667d9630f345bb6659e2395aec472c70a4971818d72ea5895ba74750dd7df9a45110273030000\"]"
+          },
+          "cookies": [],
+          "headers": [
+            {
+              "name": "date",
+              "value": "Sat, 30 Oct 2021 16:35:11 GMT"
+            },
+            {
+              "name": "content-type",
+              "value": "application/json; charset=utf-8"
+            },
+            {
+              "name": "transfer-encoding",
+              "value": "chunked"
+            },
+            {
+              "name": "connection",
+              "value": "close"
+            },
+            {
+              "name": "vary",
+              "value": "Accept-Encoding"
+            },
+            {
+              "name": "x-ratelimit-limit",
+              "value": "1200"
+            },
+            {
+              "name": "x-ratelimit-remaining",
+              "value": "1171"
+            },
+            {
+              "name": "x-ratelimit-reset",
+              "value": "1635611713"
+            },
+            {
+              "name": "server",
+              "value": "Plack::Handler::Starlet"
+            },
+            {
+              "name": "etag",
+              "value": "W/\"a8ed854635248379cfedfe6e58cbd5ba\""
+            },
+            {
+              "name": "access-control-allow-origin",
+              "value": "*"
+            },
+            {
+              "name": "x-cache-status",
+              "value": "MISS"
+            },
+            {
+              "name": "content-encoding",
+              "value": "gzip"
+            }
+          ],
+          "headersSize": 396,
+          "httpVersion": "HTTP/1.1",
+          "redirectURL": "",
+          "status": 200,
+          "statusText": "OK"
+        },
+        "startedDateTime": "2021-10-30T16:35:11.072Z",
+        "time": 182,
+        "timings": {
+          "blocked": -1,
+          "connect": -1,
+          "dns": -1,
+          "receive": 0,
+          "send": 0,
+          "ssl": -1,
+          "wait": 182
+        }
+      }
+    ],
+    "pages": [],
+    "version": "1.2"
+  }
+}