Skip to content
Open
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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,11 @@ HARMONY_QOBUZ_APP_ID=

# Bugs! app config.
HARMONY_BUGS_CLIENT_SECRET=

# Apple Music catalog API (optional). Official developer token is preferred.
# See https://developer.apple.com/documentation/applemusicapi/generating-developer-tokens
HARMONY_APPLE_MUSIC_TOKEN=
# Unofficial AMP API token for self-hosted Harmony (used if HARMONY_APPLE_MUSIC_TOKEN is unset).
HARMONY_APPLE_MUSIC_AMP_TOKEN=
# Scrape a MusicKit JWT from music.apple.com when no token is set (self-hosted only). Set true or false.
HARMONY_APPLE_MUSIC_SCRAPE=
79 changes: 79 additions & 0 deletions providers/AppleMusic/catalog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { assertEquals } from 'std/assert/assert_equals.ts';
import { describe, it } from '@std/testing/bdd';
import {
catalogArtworkUrl,
collectCatalogTracks,
extractAppleMusicJwt,
extractScriptUrls,
parseJwtExpiry,
resolveCatalogUrl,
} from './catalog.ts';

function makeJwt(exp: number): string {
const encode = (value: object) =>
btoa(JSON.stringify(value)).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
return `${encode({ alg: 'ES256', typ: 'JWT' })}.${encode({ exp })}.sig`;
}

describe('Apple Music catalog helpers', () => {
it('extracts the JWT with the latest expiry', () => {
const older = makeJwt(1_700_000_000);
const newer = makeJwt(2_000_000_000);
const source = `const a="${older}"; const b='${newer}';`;
assertEquals(extractAppleMusicJwt(source), newer);
assertEquals(parseJwtExpiry(newer), 2_000_000_000 * 1000);
});

it('extracts crossorigin and musickit script URLs', () => {
const html = `
<script crossorigin src="https://js-cdn.music.apple.com/musickit/v3/musickit.js"></script>
<script src="https://music.apple.com/assets/index.js" crossorigin></script>
<script src="https://example.com/ignore.js"></script>
`;
assertEquals(extractScriptUrls(html), [
'https://js-cdn.music.apple.com/musickit/v3/musickit.js',
'https://music.apple.com/assets/index.js',
]);
});

it('hydrates track stubs from included resources', () => {
const tracks = collectCatalogTracks({
data: [{
id: '1',
type: 'albums',
attributes: { name: 'Mix', artistName: 'DJ' },
relationships: {
tracks: { data: [{ id: 't1', type: 'songs' }], next: '/v1/next' },
},
}],
included: [{
id: 't1',
type: 'songs',
attributes: { name: 'Track One', artistName: 'Artist', trackNumber: 1, discNumber: 1 },
}],
}, {
id: '1',
type: 'albums',
attributes: { name: 'Mix', artistName: 'DJ' },
relationships: {
tracks: { data: [{ id: 't1', type: 'songs' }] },
},
});
assertEquals(tracks[0].attributes?.name, 'Track One');
});

it('resolves relative catalog pagination URLs', () => {
const next = resolveCatalogUrl(
'/v1/catalog/au/albums/1/tracks?offset=10',
'https://api.music.apple.com',
);
assertEquals(next.href, 'https://api.music.apple.com/v1/catalog/au/albums/1/tracks?offset=10');
});

it('fills artwork template dimensions', () => {
assertEquals(
catalogArtworkUrl({ url: 'https://example.com/{w}x{h}bb.jpg', width: 3000, height: 3000 }, 250),
'https://example.com/250x250bb.jpg',
);
});
});
96 changes: 96 additions & 0 deletions providers/AppleMusic/catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// Helpers for the official Apple Music API and unofficial AMP catalog.
// music.apple.com is only fetched when scraping a MusicKit JWT; album metadata comes from the catalog API.
import { decodeBase64 } from 'std/encoding/base64.ts';
import type { CatalogAlbum, CatalogArtist, CatalogDocument, CatalogTrack } from './catalog_types.ts';

// JWT as embedded in Apple Music / MusicKit assets.
const jwtPattern = /["'](eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)["']/g;

// Returns the JWT with the latest expiry when a page or script embeds more than one token.
export function extractAppleMusicJwt(source: string): string | undefined {
const matches = source.matchAll(jwtPattern);
let best: string | undefined;
let bestExpiry = 0;
for (const match of matches) {
const token = match[1];
const expiry = parseJwtExpiry(token) ?? 0;
if (expiry > bestExpiry) {
best = token;
bestExpiry = expiry;
}
}
return best;
}

export function parseJwtExpiry(token: string): number | undefined {
try {
const payloadPart = token.split('.')[1];
if (!payloadPart) return undefined;
const padded = payloadPart.replace(/-/g, '+').replace(/_/g, '/') +
'='.repeat((4 - (payloadPart.length % 4)) % 4);
const payload = JSON.parse(new TextDecoder().decode(decodeBase64(padded))) as { exp?: number };
return typeof payload.exp === 'number' ? payload.exp * 1000 : undefined;
} catch {
return undefined;
}
}

// Script URLs that typically contain the MusicKit developer token.
export function extractScriptUrls(html: string): string[] {
const urls: string[] = [];
const seen = new Set<string>();
const patterns = [
/<script[^>]*\bcrossorigin\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi,
/<script[^>]*\bsrc=["']([^"']+)["'][^>]*\bcrossorigin\b[^>]*>/gi,
/<script[^>]*\bsrc=["']([^"']*musickit[^"']*)["'][^>]*>/gi,
];
for (const pattern of patterns) {
for (const match of html.matchAll(pattern)) {
const src = match[1];
if (!seen.has(src)) {
seen.add(src);
urls.push(src);
}
}
}
return urls;
}

// Catalog pagination `next` is often a path (`/v1/catalog/...`), not an absolute URL.
export function resolveCatalogUrl(next: string, apiBaseUrl: string): URL {
if (next.startsWith('http://') || next.startsWith('https://')) {
return new URL(next);
}
return new URL(next, apiBaseUrl);
}

export function isCatalogTrack(
resource: CatalogAlbum | CatalogTrack | CatalogArtist,
): resource is CatalogTrack {
return resource.type === 'songs' || resource.type === 'music-videos';
}

// Collects tracks from a catalog page.
// Relationships may only list IDs; full objects (name, ISRC, duration) are often in `included`.
export function collectCatalogTracks(body: CatalogDocument, album?: CatalogAlbum): CatalogTrack[] {
const includedTracks = (body.included ?? []).filter(isCatalogTrack);
const byId = new Map(includedTracks.map((track) => [track.id, track] as const));
const hydrate = (partial: CatalogTrack[]) =>
partial.map((track) => track.attributes ? track : (byId.get(track.id) ?? track));

if (album) {
return hydrate(album.relationships?.tracks?.data ?? []);
}
return hydrate((body.data ?? []).filter(isCatalogTrack));
}

// Artwork URLs use `{w}` / `{h}` placeholders; fill them with the requested or native size.
export function catalogArtworkUrl(
artwork?: { url: string; width?: number; height?: number },
size?: number,
): string | undefined {
if (!artwork?.url) return undefined;
const width = size ?? artwork.width ?? 3000;
const height = size ?? artwork.height ?? 3000;
return artwork.url.replace('{w}', String(width)).replace('{h}', String(height));
}
60 changes: 60 additions & 0 deletions providers/AppleMusic/catalog_types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Types shared by the official Apple Music API and AMP
// (https://api.music.apple.com/v1/catalog/... and https://amp-api.music.apple.com/v1/catalog/...).

export type CatalogResourceType = 'albums' | 'songs' | 'music-videos' | 'artists';

export type CatalogArtwork = {
url: string;
width?: number;
height?: number;
};

export type CatalogAlbumAttributes = {
name: string;
artistName: string;
upc?: string;
releaseDate?: string;
copyright?: string;
recordLabel?: string;
trackCount?: number;
isComplete?: boolean;
url?: string;
artwork?: CatalogArtwork;
};

export type CatalogTrackAttributes = {
name: string;
artistName: string;
durationInMillis?: number;
trackNumber?: number;
discNumber?: number;
isrc?: string;
url?: string;
};

export type CatalogResource<Type extends CatalogResourceType, Attributes> = {
id: string;
type: Type;
attributes?: Attributes;
relationships?: {
tracks?: CatalogRelationship<CatalogTrack>;
artists?: CatalogRelationship<CatalogArtist>;
};
};

export type CatalogAlbum = CatalogResource<'albums', CatalogAlbumAttributes>;
export type CatalogTrack = CatalogResource<'songs' | 'music-videos', CatalogTrackAttributes>;
export type CatalogArtist = CatalogResource<'artists', { name: string; url?: string }>;

export type CatalogRelationship<T> = {
data?: T[];
next?: string;
href?: string;
};

export type CatalogDocument = {
data?: Array<CatalogAlbum | CatalogTrack | CatalogArtist>;
included?: Array<CatalogAlbum | CatalogTrack | CatalogArtist>;
next?: string;
errors?: Array<{ title?: string; detail?: string; status?: string }>;
};
56 changes: 56 additions & 0 deletions providers/AppleMusic/mod.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describeProvider, makeProviderOptions } from '@/providers/test_spec.ts';
import { stubProviderLookups, stubTokenRetrieval } from '@/providers/test_stubs.ts';
import { afterAll, describe } from '@std/testing/bdd';

import AppleMusicProvider from './mod.ts';

describe('Apple Music provider', () => {
const appleMusic = new AppleMusicProvider(makeProviderOptions());
const stubs = [stubProviderLookups(appleMusic), stubTokenRetrieval(appleMusic)];

describeProvider(appleMusic, {
urls: [{
description: 'Apple Music album URL',
url: new URL('https://music.apple.com/de/album/1705742568'),
id: { type: 'album', id: '1705742568', region: 'DE' },
isCanonical: true,
}, {
description: 'Apple Music album URL with implicit region',
url: new URL('https://music.apple.com/album/1705742568'),
id: { type: 'album', id: '1705742568', region: 'US' },
}, {
description: 'Apple Music album URL with slug',
url: new URL('https://music.apple.com/de/album/all-will-be-changed/1705742568'),
id: { type: 'album', id: '1705742568', region: 'DE', slug: 'all-will-be-changed' },
}, {
description: 'Apple Music artist URL',
url: new URL('https://music.apple.com/gb/artist/136975'),
id: { type: 'artist', id: '136975', region: 'GB' },
isCanonical: true,
}, {
description: 'Apple Music song URL',
url: new URL('https://music.apple.com/gb/song/1772318408'),
id: { type: 'song', id: '1772318408', region: 'GB' },
isCanonical: true,
}, {
description: 'Apple Music video URL',
url: new URL('https://music.apple.com/gb/music-video/1441458100'),
id: { type: 'music-video', id: '1441458100', region: 'GB' },
isCanonical: true,
}, {
description: 'Apple Music geo. album URL',
url: new URL('https://geo.music.apple.com/album/1135913516'),
id: { type: 'album', id: '1135913516', region: 'US' },
}, {
description: 'iTunes album URL (handled by iTunes provider)',
url: new URL('https://itunes.apple.com/gb/album/id1722294645'),
id: undefined,
}],
invalidIds: ['text'],
releaseLookup: [],
});

afterAll(() => {
stubs.forEach((stub) => stub.restore());
});
});
Loading