diff --git a/README.md b/README.md index 6e4ec88..9f54a59 100644 --- a/README.md +++ b/README.md @@ -6,3 +6,34 @@ Playlists Brewery for Spotify The existing Telegram Mini App includes a feature-gated cross-service Party Queue. See [setup, privacy, deployment and release gates](docs/party-queue.md) before enabling `PARTY_ENABLED`. Library credentials and playback-only Party authorization are separate. + +## iTunes catalog evaluation CLI + +Standalone Node.js 22 script, with no dependencies, API key or music membership. +It only reads public iTunes metadata; it does not call Spotify or modify the app. + +```bash +node scripts/itunes-search.mjs 'Daft Punk Get Lucky' --country us --limit 5 +node scripts/itunes-search.mjs 617154366 --country us +node scripts/itunes-search.mjs 'https://music.apple.com/us/song/get-lucky/617154366' +node scripts/itunes-search.mjs 'https://music.apple.com/us/album/random-access-memories/617154241?i=617154366' --json +node scripts/itunes-search.mjs --help +node --test tests/itunes-search.test.mjs +``` + +Pass one quoted search, numeric track ID, or HTTPS Apple Music song link per run. +Country defaults to the link's storefront, otherwise `us`; `--country` overrides it. +Search limits are 1-200 (default 5); exact lookups ignore the limit. Bare album links +are rejected: select a song link with `?i=TRACK` instead. +Human output includes the request URL, country, count, track ID, title, artist, +album, duration, explicitness, artwork and song link. `--json` prints the complete +response object without a banner. No matches return exit code 0 (an empty results +array in JSON); invalid inputs or request errors print to stderr and exit 1. +Requests time out after 15 seconds and are not retried automatically. + +This is the **iTunes catalog, not the complete Apple Music catalog**. Availability +and IDs can vary by storefront. Metadata is not proof of an identical recording; +these results do not provide ISRCs. Apple's +[archived Search API documentation](https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/Searching.html) +describes an approximate rate limit of **20 calls/minute, subject to change**. +Keep evaluation requests small and space them out if rate-limited. diff --git a/scripts/itunes-search.mjs b/scripts/itunes-search.mjs new file mode 100644 index 0000000..6dec629 --- /dev/null +++ b/scripts/itunes-search.mjs @@ -0,0 +1,192 @@ +import { pathToFileURL } from 'node:url'; + +const help = `Usage: node scripts/itunes-search.mjs [options] + +Options: + --country Two-letter storefront code (default: URL country, otherwise us) + --limit <1..200> Search result limit (default: 5; not used for exact lookup) + --json Print the unmodified response object as JSON, without a banner + --help Show this help + +Examples: + node scripts/itunes-search.mjs 'Daft Punk Get Lucky' --country us --limit 5 + node scripts/itunes-search.mjs 617154366 --country us --json + node scripts/itunes-search.mjs 'https://music.apple.com/us/album/random-access-memories/617154241?i=617154366' + +Uses the public iTunes catalog, not the complete Apple Music catalog. +No API key or membership required. Metadata is not proof of an identical recording. +Requests time out after 15 seconds; there are no automatic retries. +`; + +function trackId(value) { + if (!/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(Number(value))) { + throw new Error('Track/album ID must be a positive safe integer.'); + } + return value; +} + +export function buildRequest(args) { + let input; + let country; + let limit = '5'; + let json = false; + let showHelp = false; + const seen = new Set(); + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (arg.startsWith('-')) { + if (!['--country', '--limit', '--json', '--help'].includes(arg)) { + throw new Error(`Unknown option: ${arg}`); + } + if (seen.has(arg)) throw new Error(`Duplicate option: ${arg}`); + seen.add(arg); + if (arg === '--json') json = true; + else if (arg === '--help') showHelp = true; + else { + const value = args[++index]; + if (!value || value.startsWith('-')) throw new Error(`Missing value for ${arg}`); + if (arg === '--country') { + if (!/^[a-z]{2}$/i.test(value)) throw new Error('Country must be a two-letter code.'); + country = value.toLowerCase(); + } else { + if (!/^\d+$/.test(value) || Number(value) < 1 || Number(value) > 200) { + throw new Error('Limit must be an integer from 1 to 200.'); + } + limit = String(Number(value)); + } + } + } else { + if (input !== undefined) throw new Error('Supply one input; quote multi-word search text.'); + input = arg.trim(); + } + } + if (showHelp) return { help: true }; + if (!input) throw new Error('Input is required. Use --help for examples.'); + + let id; + let linkCountry; + if (/^\d+$/.test(input)) { + id = trackId(input); + } else if (/^[a-z][a-z\d+.-]*:/i.test(input) || /^(?:\/\/|www\.|[^\s/]+\.[a-z]{2,}(?:[/?#]|$))/i.test(input)) { + if (!/^https:\/\/[^\\\s]+$/i.test(input)) { + throw new Error('Malformed or unsupported URL; use a full https://music.apple.com song URL.'); + } + let link; + try { + link = new URL(input); + } catch { + throw new Error('Malformed URL; use a full https://music.apple.com song URL.'); + } + if (link.protocol !== 'https:' || link.hostname !== 'music.apple.com' || + link.port || link.username || link.password || link.hash) { + throw new Error('Only https://music.apple.com song URLs without credentials, ports or fragments are supported.'); + } + const match = link.pathname.match(/^\/([a-z]{2})\/(song|album)\/(?:[^/]+\/)?([1-9]\d*)\/?$/i); + if (!match) throw new Error('Unsupported Apple Music URL; use /country/song/name/ID or /country/album/name/ID?i=TRACK.'); + linkCountry = match[1].toLowerCase(); + const pathId = trackId(match[3]); + const songIds = link.searchParams.getAll('i'); + if (songIds.length > 1) throw new Error('URL must not contain multiple song IDs (i).'); + if (match[2].toLowerCase() === 'album') { + if (!songIds.length) throw new Error('Album URLs require an i=TRACK song ID; an album is not a song.'); + id = trackId(songIds[0]); + } else { + id = pathId; + if (songIds.length && trackId(songIds[0]) !== id) { + throw new Error('Song URL contains conflicting track IDs.'); + } + } + } + country ??= linkCountry ?? 'us'; + const url = new URL(id ? 'https://itunes.apple.com/lookup' : 'https://itunes.apple.com/search'); + url.searchParams.set(id ? 'id' : 'term', id ?? input); + url.searchParams.set('media', 'music'); + url.searchParams.set('entity', 'song'); + url.searchParams.set('country', country); + if (!id) url.searchParams.set('limit', limit); + return { url, country, id, json }; +} + +function formatResults(data, request) { + const lines = [ + 'iTunes catalog (not the complete Apple Music catalog; not recording-proof).', + `Country: ${request.country}`, + `Request: ${request.url}`, + `Results: ${data.resultCount}`, + ]; + if (!data.results.length) lines.push('No matches in this storefront.'); + for (const [index, song] of data.results.entries()) { + const ms = song.trackTimeMillis; + const seconds = Math.round(ms / 1000); + const duration = Number.isFinite(ms) && ms >= 0 + ? `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')} (${ms} ms)` + : 'unavailable'; + lines.push( + '', + `${index + 1}. ${song.trackName ?? 'unavailable'}`, + ` Track ID: ${song.trackId ?? 'unavailable'}`, + ` Artist: ${song.artistName ?? 'unavailable'}`, + ` Album: ${song.collectionName ?? 'unavailable'}`, + ` Duration: ${duration}`, + ` Explicitness: ${song.trackExplicitness ?? 'unavailable'}`, + ` Artwork: ${song.artworkUrl100 ?? song.artworkUrl60 ?? 'unavailable'}`, + ` Link: ${song.trackViewUrl ?? 'unavailable'}`, + ); + } + return `${lines.join('\n')}\n`; +} + +export async function run(args, { + fetchImpl = globalThis.fetch, + stdout = process.stdout, + stderr = process.stderr, +} = {}) { + try { + const request = buildRequest(args); + if (request.help) { + stdout.write(help); + return 0; + } + const signal = AbortSignal.timeout(15_000); + let data; + try { + const response = await fetchImpl(request.url, { signal, redirect: 'error' }); + if (!response.ok) { + const retryAfter = response.headers.get('retry-after'); + throw new Error(response.status === 429 + ? `HTTP 429: iTunes rate limit exceeded.${retryAfter ? ` Retry-After: ${retryAfter}.` : ''} Try again later.` + : `HTTP ${response.status}: ${response.statusText || 'iTunes request failed'}.`); + } + try { + data = await response.json(); + } catch (error) { + if (error instanceof SyntaxError) throw new Error('iTunes returned invalid JSON.'); + throw error; + } + } catch (error) { + if (signal.aborted || error.name === 'TimeoutError' || error.name === 'AbortError') { + throw new Error('iTunes request timed out after 15 seconds.'); + } + if (error instanceof TypeError) throw new Error(`Network error contacting iTunes: ${error.message}`); + throw error; + } + if (!data || !Number.isInteger(data.resultCount) || data.resultCount < 0 || + !Array.isArray(data.results) || data.resultCount !== data.results.length || + data.results.some(result => !result || typeof result !== 'object' || Array.isArray(result))) { + throw new Error('iTunes returned an invalid response: expected resultCount and a results array.'); + } + if (request.id && data.results.some(result => + result.kind !== 'song' || String(result.trackId) !== request.id)) { + throw new Error('Lookup did not resolve to the requested song ID; albums and other media are not supported.'); + } + stdout.write(request.json ? `${JSON.stringify(data, null, 2)}\n` : formatResults(data, request)); + return 0; + } catch (error) { + stderr.write(`Error: ${error.message}\n`); + return 1; + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + process.exitCode = await run(process.argv.slice(2)); +} diff --git a/tests/itunes-search.test.mjs b/tests/itunes-search.test.mjs new file mode 100644 index 0000000..f47f2a7 --- /dev/null +++ b/tests/itunes-search.test.mjs @@ -0,0 +1,203 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { buildRequest, run } from '../scripts/itunes-search.mjs'; + +const song = { + kind: 'song', + trackId: 617154366, + trackName: 'Get Lucky', + artistName: 'Daft Punk', + collectionName: 'Random Access Memories', + trackTimeMillis: 369000, + trackExplicitness: 'notExplicit', + artworkUrl100: 'https://example.com/art.jpg', + trackViewUrl: 'https://music.apple.com/us/song/get-lucky/617154366', +}; +const response = { resultCount: 1, results: [song], extra: { preserved: true } }; + +async function invoke(args, fetchImpl = async () => Response.json(response)) { + let stdout = ''; + let stderr = ''; + const code = await run(args, { + fetchImpl, + stdout: { write: text => { stdout += text; } }, + stderr: { write: text => { stderr += text; } }, + }); + return { code, stdout, stderr }; +} + +test('search encodes text and sends fixed music/song parameters', () => { + const { url, country } = buildRequest(['Beyonce & Jay-Z', '--country', 'GB', '--limit', '200']); + assert.equal(url.origin, 'https://itunes.apple.com'); + assert.equal(url.pathname, '/search'); + assert.deepEqual(Object.fromEntries(url.searchParams), { + term: 'Beyonce & Jay-Z', media: 'music', entity: 'song', country: 'gb', limit: '200', + }); + assert.equal(country, 'gb'); + assert.equal(buildRequest(['hello']).url.searchParams.get('country'), 'us'); + assert.equal(buildRequest(['hello']).url.searchParams.get('limit'), '5'); + assert.equal(buildRequest(['hello', '--limit', '1']).url.searchParams.get('limit'), '1'); +}); + +test('song links infer country; album links use track i, not album ID', () => { + for (const input of [ + 'https://music.apple.com/gb/song/get-lucky/617154366', + 'https://music.apple.com/gb/song/617154366', + 'https://music.apple.com/gb/album/random-access-memories/617154241?i=617154366&uo=4', + ]) { + const { url } = buildRequest([input]); + assert.equal(url.origin, 'https://itunes.apple.com'); + assert.equal(url.pathname, '/lookup'); + assert.deepEqual(Object.fromEntries(url.searchParams), { + id: '617154366', media: 'music', entity: 'song', country: 'gb', + }); + assert.equal(buildRequest([input, '--country', 'US']).country, 'us'); + } +}); + +test('numeric track IDs use exact lookup with explicit country', () => { + const request = buildRequest(['617154366', '--country', 'il', '--limit', '10']); + assert.equal(request.id, '617154366'); + assert.equal(request.country, 'il'); + assert.equal(request.url.pathname, '/lookup'); + assert.equal(request.url.searchParams.has('limit'), false); +}); + +test('reject invalid inputs, URLs and options before any request', async () => { + const invalid = [ + [], [' '], ['Daft', 'Punk'], ['0'], ['9007199254740992'], + ['hello', '--bogus'], ['hello', '--country'], ['hello', '--country', 'USA'], + ['hello', '--limit'], ['hello', '--limit', '0'], ['hello', '--limit', '201'], + ['hello', '--limit', '1.5'], ['hello', '--limit', '1e2'], + ['hello', '--json', '--json'], ['hello', '--country', 'us', '--country', 'gb'], + ['https://example.com/us/song/name/123'], + ['http://music.apple.com/us/song/name/123'], + ['https://music.apple.com.evil.com/us/song/name/123'], + ['https://user:password@music.apple.com/us/song/name/123'], + ['https://music.apple.com:8080/us/song/name/123'], + ['https://music.apple.com/us/song/name/123#fragment'], + ['https://music.apple.com/us/album/name/123'], + ['https://music.apple.com/us/album/name/123?i='], + ['https://music.apple.com/us/album/name/123?i=456&i=789'], + ['https://music.apple.com/us/song/name/123?i=456'], + ['https://music.apple.com/us/song/name/abc'], + ['https://music.apple.com/us/playlist/name/123'], + ['https://music.apple.com/us/song/name/123/extra'], + ['https:music.apple.com/us/song/name/123'], + ['https://music.apple.com\\us\\song\\name\\123'], + ['https://'], ['music.apple.com/us/song/name/123'], ['//music.apple.com/us/song/name/123'], + ]; + for (const args of invalid) { + const result = await invoke(args, () => { assert.fail('Must not fetch invalid input'); }); + assert.equal(result.code, 1, JSON.stringify(args)); + assert.equal(result.stdout, ''); + assert.match(result.stderr, /^Error: /); + } +}); + +test('help is available without fetching', async () => { + const result = await invoke(['--help'], () => { assert.fail('Help must not fetch'); }); + assert.equal(result.code, 0); + assert.match(result.stdout, /Usage:/); + assert.match(result.stdout, /not the complete Apple Music catalog/); + assert.equal(result.stderr, ''); +}); + +test('CLI entry point prints help and sets process exit status on invalid input', () => { + const script = new URL('../scripts/itunes-search.mjs', import.meta.url); + for (const [args, status] of [[['--help'], 0], [['song', '--limit', '201'], 1]]) { + const result = spawnSync(process.execPath, [script.pathname, ...args], { encoding: 'utf8' }); + assert.equal(result.status, status); + if (status === 0) { + assert.match(result.stdout, /Usage:/); + assert.equal(result.stderr, ''); + } else { + assert.equal(result.stdout, ''); + assert.match(result.stderr, /Limit must be an integer/); + } + } +}); + +test('JSON output preserves all response fields with no banner', async (t) => { + const timeout = t.mock.method(AbortSignal, 'timeout'); + const result = await invoke(['Daft Punk', '--json'], async (url, options) => { + assert.equal(url.pathname, '/search'); + assert.ok(options.signal instanceof AbortSignal); + assert.equal(options.signal.aborted, false); + assert.equal(options.redirect, 'error'); + return Response.json(response); + }); + assert.equal(result.code, 0); + assert.deepEqual(timeout.mock.calls[0].arguments, [15_000]); + assert.deepEqual(JSON.parse(result.stdout), response); + assert.equal(result.stderr, ''); +}); + +test('human output shows comparison metadata and request context', async () => { + const result = await invoke(['617154366']); + assert.equal(result.code, 0); + for (const text of [ + 'Country: us', 'Request: https://itunes.apple.com/lookup?', 'Results: 1', + 'Track ID: 617154366', 'Get Lucky', 'Daft Punk', 'Random Access Memories', + '6:09 (369000 ms)', 'notExplicit', song.artworkUrl100, song.trackViewUrl, + 'not the complete Apple Music catalog', + ]) assert.ok(result.stdout.includes(text), text); + assert.equal(result.stderr, ''); +}); + +test('missing optional metadata is explicitly unavailable', async () => { + const result = await invoke(['song'], async () => Response.json({ + resultCount: 1, results: [{ trackName: 'Song' }], + })); + assert.equal(result.code, 0); + assert.match(result.stdout, /Duration: unavailable/); + assert.match(result.stdout, /Explicitness: unavailable/); + assert.match(result.stdout, /Artwork: unavailable/); +}); + +test('no matches are successful, explicit in human output and unchanged in JSON', async () => { + const empty = { resultCount: 0, results: [] }; + for (const input of ['missing song', '123']) { + for (const json of [false, true]) { + const result = await invoke([input, ...(json ? ['--json'] : [])], async () => Response.json(empty)); + assert.equal(result.code, 0); + assert.equal(result.stderr, ''); + if (json) assert.deepEqual(JSON.parse(result.stdout), empty); + else assert.match(result.stdout, /Results: 0\nNo matches in this storefront/); + } + } +}); + +test('HTTP, rate limit, network, timeout, JSON and response errors exit nonzero', async () => { + const cases = [ + [async () => new Response('', { status: 503 }), /HTTP 503/], + [async () => new Response('', { status: 429, headers: { 'Retry-After': '60' } }), /HTTP 429.*Retry-After: 60/], + [async () => { throw new TypeError('fetch failed'); }, /Network error.*fetch failed/], + [async () => { throw new DOMException('timeout', 'TimeoutError'); }, /timed out after 15 seconds/], + [async () => new Response('{broken'), /invalid JSON/], + [async () => Response.json({ error: 'bad' }), /invalid response/], + [async () => Response.json({ resultCount: 1, results: [] }), /invalid response/], + [async () => Response.json({ resultCount: 1, results: [null] }), /invalid response/], + ]; + for (const [fetchImpl, message] of cases) { + const result = await invoke(['song', '--json'], fetchImpl); + assert.equal(result.code, 1); + assert.equal(result.stdout, ''); + assert.match(result.stderr, message); + } +}); + +test('exact lookup cannot silently accept an album or a different track', async () => { + for (const results of [ + [{ wrapperType: 'collection', collectionId: 123 }, song], + [{ ...song, trackId: 456 }], + ]) { + const result = await invoke(['617154366', '--json'], async () => Response.json({ + resultCount: results.length, results, + })); + assert.equal(result.code, 1); + assert.equal(result.stdout, ''); + assert.match(result.stderr, /requested song ID/); + } +});