Repository: ROpdebee/mb-userscripts
Pull request #811 (2025-03-09 23:30:11)

feat(ecau): Display image dimensions and seed release URLs from Harmony
With the latest changes to the Harmony website to make extraction of the necessary data more reliable, seeding of release URLs instead of image URLs became simpler to implement.
Image dimensions are now displayed there as well, making use of the already existing code for the a-tisket seeder.


Patch:
diff --git a/package-lock.json b/package-lock.json
index 9416635c..2b00e38f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6100,9 +6100,9 @@
       }
     },
     "node_modules/caniuse-lite": {
-      "version": "1.0.30001643",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001643.tgz",
-      "integrity": "sha512-ERgWGNleEilSrHM6iUz/zJNSQTP8Mr21wDWpdgvRwcTXGAq6jMtOUPP4dqFPTdKqZ2wKTdtB+uucZ3MRpAUSmg==",
+      "version": "1.0.30001703",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001703.tgz",
+      "integrity": "sha512-kRlAGTRWgPsOj7oARC9m1okJEXdL/8fekFVcxA8Hl7GH4r/sN4OJn/i6Flde373T50KS7Y37oFbMwlE8+F42kQ==",
       "dev": true,
       "funding": [
         {
@@ -6117,8 +6117,7 @@
           "type": "github",
           "url": "https://github.com/sponsors/ai"
         }
-      ],
-      "license": "CC-BY-4.0"
+      ]
     },
     "node_modules/chalk": {
       "version": "2.4.2",
diff --git a/src/mb_enhanced_cover_art_uploads/meta.ts b/src/mb_enhanced_cover_art_uploads/meta.ts
index 19bf60a3..4cde3b93 100644
--- a/src/mb_enhanced_cover_art_uploads/meta.ts
+++ b/src/mb_enhanced_cover_art_uploads/meta.ts
@@ -17,8 +17,8 @@ const metadata: UserscriptMetadata = {
         ...mbMatchedUrls,
         '*://atisket.pulsewidth.org.uk/*',
         '*://etc.marlonob.info/atisket/*',
+        '*://harmony.pulsewidth.org.uk/release/actions?*',
         '*://vgmdb.net/album/*',
-        '*://harmony.pulsewidth.org.uk/release/actions*',
     ],
     'exclude': ['*://atisket.pulsewidth.org.uk/'],
     'grant': [
diff --git a/src/mb_enhanced_cover_art_uploads/seeding/atisket/index.tsx b/src/mb_enhanced_cover_art_uploads/seeding/atisket/index.tsx
index bfa7eac9..670dc04e 100644
--- a/src/mb_enhanced_cover_art_uploads/seeding/atisket/index.tsx
+++ b/src/mb_enhanced_cover_art_uploads/seeding/atisket/index.tsx
@@ -1,13 +1,11 @@
-import type { ImageInfo } from '@src/mb_caa_dimensions/image-info';
 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 { getImageInfo } from '../dimensions';
 import { SeedParameters } from '../parameters';
-import { AtisketImage } from './dimensions';
 
 // For main page after search but before adding
 export const AtisketSeeder: Seeder = {
@@ -146,40 +144,6 @@ async function addDimensions(fig: HTMLElement): Promise<void> {
     }
 }
 
-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> = {
     itu: (id, country) => `https://music.apple.com/${country.toLowerCase()}/album/${id}`,
     deez: (id) => 'https://www.deezer.com/album/' + id,
diff --git a/src/mb_enhanced_cover_art_uploads/seeding/atisket/dimensions.ts b/src/mb_enhanced_cover_art_uploads/seeding/dimensions.ts
similarity index 74%
rename from src/mb_enhanced_cover_art_uploads/seeding/atisket/dimensions.ts
rename to src/mb_enhanced_cover_art_uploads/seeding/dimensions.ts
index dabac7e3..c05523bf 100644
--- a/src/mb_enhanced_cover_art_uploads/seeding/atisket/dimensions.ts
+++ b/src/mb_enhanced_cover_art_uploads/seeding/dimensions.ts
@@ -1,10 +1,11 @@
 import pRetry from 'p-retry';
 
-import type { Dimensions, FileInfo } from '@src/mb_caa_dimensions/image-info';
+import type { Dimensions, FileInfo, ImageInfo } from '@src/mb_caa_dimensions/image-info';
 import { LOGGER } from '@lib/logging/logger';
 import { safeParseJSON } from '@lib/util/json';
 import { HTTPResponseError, request } from '@lib/util/request';
 import { BaseImage } from '@src/mb_caa_dimensions/image';
+import { getMaximisedCandidates } from '@src/mb_enhanced_cover_art_uploads/maximise';
 
 // Use a multiple of 3, most a-tisket releases have 3 images.
 // Currently set to 30, should allow 10 releases open in parallel.
@@ -106,7 +107,7 @@ export /* for tests */ const localStorageCache = {
     },
 };
 
-export class AtisketImage extends BaseImage {
+export class SeederImage extends BaseImage {
     public constructor(imageUrl: string) {
         super(imageUrl, localStorageCache);
     }
@@ -133,3 +134,37 @@ export class AtisketImage extends BaseImage {
         };
     }
 }
+
+export 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 seederImage = new SeederImage(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 seederImage.getFileInfo();
+        const dimensions = fileInfo && await seederImage.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 SeederImage(imageUrl).getImageInfo();
+}
diff --git a/src/mb_enhanced_cover_art_uploads/seeding/harmony/index.tsx b/src/mb_enhanced_cover_art_uploads/seeding/harmony/index.tsx
index 7fc8236d..0c50423c 100644
--- a/src/mb_enhanced_cover_art_uploads/seeding/harmony/index.tsx
+++ b/src/mb_enhanced_cover_art_uploads/seeding/harmony/index.tsx
@@ -1,8 +1,11 @@
 import { LOGGER } from '@lib/logging/logger';
-import { ArtworkTypeIDs } from '@lib/MB/cover-art';
+import { logFailure } from '@lib/util/async';
 import { qs, qsa } from '@lib/util/dom';
+import { formatFileSize } from '@lib/util/format';
+import { getProvider } from '@src/mb_enhanced_cover_art_uploads/providers';
 
 import type { Seeder } from '../base';
+import { getImageInfo } from '../dimensions';
 import { SeedParameters } from '../parameters';
 
 export const HarmonySeeder: Seeder = {
@@ -19,6 +22,8 @@ export const HarmonySeeder: Seeder = {
             return;
         }
 
+        addDimensionsToCovers();
+
         // Use a cached link as the origin instead of the page URL itself,
         // so that we link to the state at the time the image was submitted.
         if (!originUrl.searchParams.has('ts')) {
@@ -30,6 +35,9 @@ export const HarmonySeeder: Seeder = {
     },
 };
 
+/** Mapping of Harmony provider names to provider release URLs. */
+let providerReleaseUrlMap: Record<string, string | undefined>;
+
 function addSeedLinksToCovers(mbid: string, origin: string): void {
     // Find cover image elements on the page
     const covers = qsa<HTMLElement>('figure.cover-image');
@@ -39,17 +47,38 @@ function addSeedLinksToCovers(mbid: string, origin: string): void {
         return;
     }
 
+    // Populate mapping with provider names from data attributes and release URLs.
+    providerReleaseUrlMap = Object.fromEntries(qsa<HTMLElement>('ul.provider-list > li').map((li) => [
+        li.dataset.provider!,
+        qs<HTMLAnchorElement>('a.provider-id', li).href,
+    ]));
+
     for (const coverElement of covers) {
         addSeedLinkToCover(coverElement, mbid, origin);
     }
 }
 
+function addDimensionsToCovers(): void {
+    const covers = qsa<HTMLElement>('figure.cover-image');
+    for (const fig of covers) {
+        addDimensions(fig).catch(logFailure('Failed to insert image information'));
+    }
+}
+
 function addSeedLinkToCover(coverElement: HTMLElement, mbid: string, origin: string): void {
-    const imageUrl = qs<HTMLImageElement>('img', coverElement).src;
+    let coverUrl = qs<HTMLImageElement>('img', coverElement).src;
+
+    // Prefer seeding the release URL over the image URL for supported providers.
+    const providerName = coverElement.dataset.provider;
+    if (providerName) {
+        const releaseUrl = providerReleaseUrlMap[providerName];
+        if (releaseUrl && getProvider(new URL(releaseUrl))) {
+            coverUrl = releaseUrl;
+        }
+    }
 
     const parameters = new SeedParameters([{
-        url: new URL(imageUrl),
-        types: [ArtworkTypeIDs.Front],
+        url: new URL(coverUrl),
     }], origin);
 
     const seedUrl = parameters.createSeedURL(mbid);
@@ -64,3 +93,28 @@ function addSeedLinkToCover(coverElement: HTMLElement, mbid: string, origin: str
     qs<HTMLElement>('figcaption', coverElement)
         .insertAdjacentElement('beforeend', seedLink);
 }
+
+async function addDimensions(fig: HTMLElement): Promise<void> {
+    const imageUrl = qs<HTMLImageElement>('img', fig).src;
+    const dimSpan = (
+        <span className="label">
+            loading…
+        </span>
+    );
+    qs('figcaption', fig).insertAdjacentElement('beforeend', dimSpan);
+
+    const imageInfo = await getImageInfo(imageUrl);
+
+    const infoStringParts = [
+        imageInfo.dimensions ? `${imageInfo.dimensions.width}x${imageInfo.dimensions.height}` : '',
+        imageInfo.size !== undefined ? formatFileSize(imageInfo.size) : '',
+        imageInfo.fileType,
+    ];
+    const infoString = infoStringParts.filter(Boolean).join(', ');
+
+    if (infoString) {
+        dimSpan.textContent = infoString;
+    } else {
+        dimSpan.remove();
+    }
+}


Test patch:
diff --git a/tests/unit/mb_enhanced_cover_art_uploads/seeding/atisket/dimensions.test.ts b/tests/unit/mb_enhanced_cover_art_uploads/seeding/dimensions.test.ts
similarity index 92%
rename from tests/unit/mb_enhanced_cover_art_uploads/seeding/atisket/dimensions.test.ts
rename to tests/unit/mb_enhanced_cover_art_uploads/seeding/dimensions.test.ts
index 5c4f95d3..78996d38 100644
--- a/tests/unit/mb_enhanced_cover_art_uploads/seeding/atisket/dimensions.test.ts
+++ b/tests/unit/mb_enhanced_cover_art_uploads/seeding/dimensions.test.ts
@@ -1,8 +1,8 @@
 import retry from 'retry';
 
-import type { CacheEntry } from '@src/mb_enhanced_cover_art_uploads/seeding/atisket/dimensions';
+import type { CacheEntry } from '@src/mb_enhanced_cover_art_uploads/seeding/dimensions';
 import { request } from '@lib/util/request';
-import { AtisketImage, CACHE_LOCALSTORAGE_KEY, localStorageCache, MAX_CACHED_IMAGES } from '@src/mb_enhanced_cover_art_uploads/seeding/atisket/dimensions';
+import { CACHE_LOCALSTORAGE_KEY, localStorageCache, MAX_CACHED_IMAGES, SeederImage } from '@src/mb_enhanced_cover_art_uploads/seeding/dimensions';
 import { setupPolly } from '@test-utils/pollyjs';
 
 describe('local storage cache', () => {
@@ -243,7 +243,7 @@ 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');
+        const image = new SeederImage('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,
@@ -252,7 +252,7 @@ describe('a-tisket images', () => {
     });
 
     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');
+        const image = new SeederImage('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!
@@ -261,7 +261,7 @@ describe('a-tisket images', () => {
     });
 
     it('loads file info for Spotify images', async () => {
-        const image = new AtisketImage('https://i.scdn.co/image/ab67616d0000b273843b6bc2dc1517b7f7f0f424');
+        const image = new SeederImage('https://i.scdn.co/image/ab67616d0000b273843b6bc2dc1517b7f7f0f424');
 
         await expect(image.getFileInfo()).resolves.toStrictEqual({
             fileType: 'JPEG',
@@ -270,7 +270,7 @@ describe('a-tisket images', () => {
     });
 
     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');
+        const image = new SeederImage('https://e-cdns-images.dzcdn.net/images/cover/2d8c720d7fee9506e40c5f16760c3640/1200x0-000000-100-0-0.jpg');
 
         await expect(image.getFileInfo()).resolves.toStrictEqual({
             fileType: 'JPEG',
@@ -297,7 +297,7 @@ describe('a-tisket images', () => {
                 .intercept((_request, response) => {
                     response.sendStatus(429);
                 });
-            const image = new AtisketImage('https://example.com/test');
+            const image = new SeederImage('https://example.com/test');
 
             await expect(image.getFileInfo()).resolves.toBeUndefined();
             expect(requestSpy).toHaveBeenCalledTimes(6); // First try + 5 retries
@@ -309,7 +309,7 @@ describe('a-tisket images', () => {
                 .intercept((_request, response) => {
                     response.sendStatus(404);
                 });
-            const image = new AtisketImage('https://example.com/test');
+            const image = new SeederImage('https://example.com/test');
 
             await expect(image.getFileInfo()).resolves.toBeUndefined();
             expect(requestSpy).toHaveBeenCalledTimes(1);
@@ -321,7 +321,7 @@ describe('a-tisket images', () => {
                 .intercept((_request, response) => {
                     response.sendStatus(503);
                 });
-            const image = new AtisketImage('https://example.com/test');
+            const image = new SeederImage('https://example.com/test');
 
             await expect(image.getFileInfo()).resolves.toBeUndefined();
             expect(requestSpy).toHaveBeenCalledTimes(6);