From 4519349cbf554f0ca125a885599746169142fe0d Mon Sep 17 00:00:00 2001
From: Dennis <39654202+dwei30@users.noreply.github.com>
Date: Fri, 14 Aug 2026 17:12:16 +1000
Subject: [PATCH 1/6] feat(iTunes): Fall back to Apple Music catalog API for
streaming-only releases
---
providers/iTunes/amp.test.ts | 65 ++++++++++++
providers/iTunes/amp.ts | 76 ++++++++++++++
providers/iTunes/amp_types.ts | 58 +++++++++++
providers/iTunes/mod.ts | 183 ++++++++++++++++++++++++++++++++--
4 files changed, 374 insertions(+), 8 deletions(-)
create mode 100644 providers/iTunes/amp.test.ts
create mode 100644 providers/iTunes/amp.ts
create mode 100644 providers/iTunes/amp_types.ts
diff --git a/providers/iTunes/amp.test.ts b/providers/iTunes/amp.test.ts
new file mode 100644
index 00000000..11725bda
--- /dev/null
+++ b/providers/iTunes/amp.test.ts
@@ -0,0 +1,65 @@
+import { assertEquals } from 'std/assert/assert_equals.ts';
+import { describe, it } from '@std/testing/bdd';
+import { collectAmpTracks, extractAppleMusicJwt, extractScriptUrls, parseJwtExpiry, resolveAmpUrl } from './amp.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 AMP 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 = collectAmpTracks({
+ 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 AMP pagination URLs', () => {
+ const next = resolveAmpUrl(
+ '/v1/catalog/au/albums/1/tracks?offset=10',
+ 'https://amp-api.music.apple.com',
+ );
+ assertEquals(next.href, 'https://amp-api.music.apple.com/v1/catalog/au/albums/1/tracks?offset=10');
+ });
+});
diff --git a/providers/iTunes/amp.ts b/providers/iTunes/amp.ts
new file mode 100644
index 00000000..9bebd5f2
--- /dev/null
+++ b/providers/iTunes/amp.ts
@@ -0,0 +1,76 @@
+import { decodeBase64 } from 'std/encoding/base64.ts';
+import type { AmpAlbum, AmpArtist, AmpDocument, AmpTrack } from './amp_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;
+
+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;
+ }
+}
+
+export function extractScriptUrls(html: string): string[] {
+ const urls: string[] = [];
+ const seen = new Set();
+ const patterns = [
+ /