Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ecau): Display image dimensions and seed release URLs from Harmony #811

Merged
merged 3 commits into from
Mar 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/mb_enhanced_cover_art_uploads/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': [
Expand Down
38 changes: 1 addition & 37 deletions src/mb_enhanced_cover_art_uploads/seeding/atisket/index.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -106,7 +107,7 @@
},
};

export class AtisketImage extends BaseImage {
export class SeederImage extends BaseImage {
public constructor(imageUrl: string) {
super(imageUrl, localStorageCache);
}
Expand All @@ -133,3 +134,37 @@
};
}
}

export async function getImageInfo(imageUrl: string): Promise<ImageInfo> {
// Try maximising the image
for await (const maxCandidate of getMaximisedCandidates(new URL(imageUrl))) {

Check warning on line 140 in src/mb_enhanced_cover_art_uploads/seeding/dimensions.ts

View check run for this annotation

Codecov / codecov/patch

src/mb_enhanced_cover_art_uploads/seeding/dimensions.ts#L140

Added line #L140 was not covered by tests
// 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());

Check warning on line 146 in src/mb_enhanced_cover_art_uploads/seeding/dimensions.ts

View check run for this annotation

Codecov / codecov/patch

src/mb_enhanced_cover_art_uploads/seeding/dimensions.ts#L145-L146

Added lines #L145 - L146 were not covered by tests
// 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();

Check warning on line 155 in src/mb_enhanced_cover_art_uploads/seeding/dimensions.ts

View check run for this annotation

Codecov / codecov/patch

src/mb_enhanced_cover_art_uploads/seeding/dimensions.ts#L155

Added line #L155 was not covered by tests
const dimensions = fileInfo && await seederImage.getDimensions();
if (!dimensions) {
LOGGER.warn(`Failed to load dimensions for maximised candidate ${maxCandidate.url}`);
continue;

Check warning on line 159 in src/mb_enhanced_cover_art_uploads/seeding/dimensions.ts

View check run for this annotation

Codecov / codecov/patch

src/mb_enhanced_cover_art_uploads/seeding/dimensions.ts#L158-L159

Added lines #L158 - L159 were not covered by tests
}

return {

Check warning on line 162 in src/mb_enhanced_cover_art_uploads/seeding/dimensions.ts

View check run for this annotation

Codecov / codecov/patch

src/mb_enhanced_cover_art_uploads/seeding/dimensions.ts#L162

Added line #L162 was not covered by tests
dimensions,
...fileInfo,
};
}

// Fall back on original URL. Using `Image#getImageInfo` is fine here.
return new SeederImage(imageUrl).getImageInfo();

Check warning on line 169 in src/mb_enhanced_cover_art_uploads/seeding/dimensions.ts

View check run for this annotation

Codecov / codecov/patch

src/mb_enhanced_cover_art_uploads/seeding/dimensions.ts#L169

Added line #L169 was not covered by tests
}
62 changes: 58 additions & 4 deletions src/mb_enhanced_cover_art_uploads/seeding/harmony/index.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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')) {
Expand All @@ -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');
Expand All @@ -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);
Expand All @@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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,
Expand All @@ -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!
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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
Expand All @@ -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);
Expand All @@ -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);
Expand Down