diff --git a/.env.example b/.env.example
index 7b02228..14ba3fb 100644
--- a/.env.example
+++ b/.env.example
@@ -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=
diff --git a/providers/AppleMusic/catalog.test.ts b/providers/AppleMusic/catalog.test.ts
new file mode 100644
index 0000000..c7aea1b
--- /dev/null
+++ b/providers/AppleMusic/catalog.test.ts
@@ -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 = `
+
+
+
+ `;
+ 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',
+ );
+ });
+});
diff --git a/providers/AppleMusic/catalog.ts b/providers/AppleMusic/catalog.ts
new file mode 100644
index 0000000..30a0a70
--- /dev/null
+++ b/providers/AppleMusic/catalog.ts
@@ -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();
+ const patterns = [
+ /