From 8eb573c0189a6047c0e41ac7e70ed6ddf3b8fb39 Mon Sep 17 00:00:00 2001 From: Amit Breuer Date: Wed, 23 Sep 2026 13:29:47 +0300 Subject: [PATCH] feat: resolve Party Apple Music links with free iTunes lookup --- docs/party-queue.md | 60 ++++- projects/api/.env.party.example | 4 +- projects/api/src/party/catalog.test.ts | 227 ++++++++++++------ projects/api/src/party/catalog.ts | 101 +++----- projects/api/src/party/config.test.ts | 41 ++++ projects/api/src/party/config.ts | 3 - projects/api/src/party/jobs.ts | 4 +- projects/api/src/party/routes.ts | 2 +- projects/api/tests/party.integration.test.mjs | 102 +++++++- projects/mini-app/src/features/party/api.ts | 2 +- .../mini-app/src/features/party/messages.ts | 10 +- projects/mini-app/tests/party-api.test.mjs | 14 +- .../mini-app/tests/party-preview.test.mjs | 5 +- projects/spotify/README.md | 28 ++- projects/spotify/src/index.test.ts | 4 +- projects/spotify/src/index.ts | 4 +- projects/spotify/src/network.test.ts | 4 +- projects/spotify/src/network.ts | 2 +- 18 files changed, 438 insertions(+), 179 deletions(-) create mode 100644 projects/api/src/party/config.test.ts diff --git a/docs/party-queue.md b/docs/party-queue.md index 7a58fc1..805a8c5 100644 --- a/docs/party-queue.md +++ b/docs/party-queue.md @@ -42,8 +42,10 @@ Required before real pilot traffic: scope remain compatible until revoked or reauthorized. The Premium checkbox is an explicit requirement, not proof from a removed profile property. Only an explicit provider Premium error is labeled Premium. -- Apple Music developer team ID, key ID and ES256 signing private key. No - Music User Token, Apple ID or guest provider authorization is used. +- Free iTunes Store exact-ID lookup reachable from the service for supported + Apple Music song links. No Apple Developer membership, keys, tokens, Apple ID + or guest provider authorization is required. Coverage is not all Apple Music; + verify the intended storefront and review the matching limitations below. - PostgreSQL migrations, direct/session-pinned connections supporting **session advisory locks** (do not use PgBouncer/Neon transaction pooling for Party). - Cloud Tasks queue, runtime enqueue/actAs grants, OIDC identity and audience, @@ -106,10 +108,11 @@ created first in a newly provisioned project. Take environment values from the Terraform `party_environment` output and `projects/api/.env.party.example`. Configure these on the isolated Cloud Run revision, with `PARTY_ENABLED=false`. Bind `PARTY_IDENTITY_KEY` (independent random -32-byte hex), `APPLE_MUSIC_PRIVATE_KEY`, and the host allowlist through Secret -Manager. The Apple team/key IDs may be environment values. Existing encryption +32-byte hex) and the host allowlist through Secret Manager. Existing encryption and Spotify client configuration are reused; Library token rows are not. -Keep the Apple PEM server-side; never use a `VITE_` variable for secrets. +Apple team/key IDs, signing PEMs and developer tokens are no longer used and +can be removed from Party configuration when retiring the old revision. +Never use a `VITE_` variable for secrets. Use explicit Cloud Run `--update-env-vars` / `--update-secrets` for these settings, not replacement of existing Library configuration. The existing deploy action's @@ -177,6 +180,43 @@ erase managed backups. Restore procedures must immediately run expiry cleanup. ## Delivery and failure handling +### Apple Music links via free iTunes Store lookup + +Apple Music remains an accepted **link format**, not the catalog API used. +`music.apple.com//song/.../` and album links with a single `?i=` +are resolved only through `https://itunes.apple.com/lookup?id=&country=`. +The original path storefront wins over unrelated query parameters. The exact +safe-integer track ID must be returned as a song; albums, different IDs and +malformed responses are errors. A valid empty result (or 404) is unavailable. +There is no alternate-country, full-album, URL-title guessing, or paid API fallback. +Use a Spotify song link if the recording is missing from that iTunes storefront. +The source link stays an Apple Music song link, not a provider-returned URL. + +iTunes supplies title, the combined artist string, album, duration and track +explicitness (`explicit`, `cleaned`, `notExplicit`); album explicitness is ignored. +Missing optional metadata stays unknown. Artwork is restricted to safe HTTPS +CDN URLs. iTunes does **not** supply ISRCs. Matching still checks full title/artist, +versions, duration and explicitness without fuzzy scoring. When Spotify supplies +an ISRC but iTunes does not, even a unique otherwise-identical candidate needs a +host version choice. This increases review frequency and can reduce match coverage +(including differently formatted multi-artist credits). No recording safety rule +is relaxed. Choosing a version in an auto room queues it without another approval; +direct Spotify links retain their existing automatic path. + +The [archived iTunes API guidance](https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/Searching.html) +quotes approximately 20 requests/minute, subject to change. Existing shared +database submission throttles and durable jobs remain, but **no global iTunes +quota or lookup cache is implemented**. Repeated lookups and different service +instances can collectively exceed that estimate; do not claim quota compliance +or widen the private pilot without measuring traffic and addressing that limit. +Each resolve attempt makes one lookup. Explicit 429s retain `Retry-After` in the +durable job (60 seconds if absent/invalid), with `itunes_rate_limited` receipts; +network/5xx/invalid-response errors follow bounded read retries. No in-process +retry, sleep, or silent empty result hides an outage. Transport remains fixed-host, +redirect-rejecting, DNS-vetted, limited to 1 MiB and an 8-second deadline. + +### Queue delivery + Committed submissions and approvals create database outbox jobs. Scheduler dispatches bounded named Cloud Tasks batches and repairs stale dispatch records. Named-task deduplication handles successful task creation followed by an uncertain @@ -258,3 +298,13 @@ The PostgreSQL suite runs all four migrations and verifies zero Prisma schema dr Docker image execution was not available because the local Docker daemon was not running. No real provider authorization, Telegram client session, Cloud Tasks dispatch, production migration, or Spotify queue write was performed. + +The free iTunes replacement (2026-09-23) passed all five workspace builds, +40 provider/transport tests, 20 catalog/config/Telegram tests, 17 native PostgreSQL +integration scenarios (including durable iTunes retries and host version choice), +18 frontend tests and focused frontend lint. Two public IL-storefront lookups +through the real adapter verified Ensalada and Shivers metadata without contacting +Spotify. The local preview responded over HTTP, but browser regression checks +were blocked before loading a page: system Chrome timed out on launch and cached +headless Chromium crashed with SIGSEGV. No browser pass is claimed for this change. +These checks do not establish live Spotify, signed Telegram or cloud readiness. diff --git a/projects/api/.env.party.example b/projects/api/.env.party.example index d227c95..793a229 100644 --- a/projects/api/.env.party.example +++ b/projects/api/.env.party.example @@ -7,9 +7,7 @@ PARTY_TELEGRAM_BOT_USERNAME=YOUR_TEST_BOT PARTY_HOST_ALLOWLIST= # Independent 32-byte hex HMAC key; store in Secret Manager. PARTY_IDENTITY_KEY= -APPLE_MUSIC_TEAM_ID= -APPLE_MUSIC_KEY_ID= -APPLE_MUSIC_PRIVATE_KEY= +# Apple Music song links use free iTunes Store lookup; no Apple credentials. PARTY_TASKS_PROJECT= PARTY_TASKS_LOCATION= PARTY_TASKS_QUEUE=brewtify-party diff --git a/projects/api/src/party/catalog.test.ts b/projects/api/src/party/catalog.test.ts index 1c10703..715a6f1 100644 --- a/projects/api/src/party/catalog.test.ts +++ b/projects/api/src/party/catalog.test.ts @@ -1,8 +1,7 @@ import assert from 'node:assert/strict'; -import { generateKeyPairSync, verify } from 'node:crypto'; import { test } from 'node:test'; import type { PartyTrack } from '@brewtify/shared'; -import { SpotifyError, type SpotifyTrack } from '@brewtify/spotify'; +import { PROVIDER_BODY_LIMIT, SpotifyError, type SpotifyTrack } from '@brewtify/spotify'; import { CatalogError, matchCandidates, parseSongLink, resolveSong, type CatalogSpotifyClient, } from './catalog'; @@ -52,6 +51,8 @@ test('parser rejects hostile, normalized, unsupported, ambiguous or malformed in 'https://music.apple.com/us/song/a%5cb/123', 'https://music.apple.com/us/song/%00/123', 'https://music.apple.com/us/song/%GG/123', 'https://music.apple.com/USA/song/123', 'https://music.apple.com/us/song/0', 'https://music.apple.com/us/song/123/extra', + 'https://music.apple.com/us/song/9007199254740992', + 'https://music.apple.com/us/album/a/456?i=99999999999999999999', 'https://music.apple.com/us/playlist/foo/123', 'https://music.apple.com/us/song/123#fragment', `http://open.spotify.com/track/${ID}`, `https://open.spotify.com.evil.example/track/${ID}`, `https://evil.example@open.spotify.com/track/${ID}`, `https://open.spotify.com@evil.example/track/${ID}`, @@ -159,91 +160,95 @@ test('Spotify outages and auth failures propagate, only explicit missing track b }))).confidence, 'no_match'); }); -const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' }); -const withAppleConfiguration = (t: { after: (fn: () => void) => void }) => { - const names = ['APPLE_MUSIC_TEAM_ID', 'APPLE_MUSIC_KEY_ID', 'APPLE_MUSIC_PRIVATE_KEY']; - const original = names.map(name => process.env[name]); - process.env.APPLE_MUSIC_TEAM_ID = 'ABCDEFGHIJ'; - process.env.APPLE_MUSIC_KEY_ID = '0123456789'; - process.env.APPLE_MUSIC_PRIVATE_KEY = privateKey.export({ format: 'pem', type: 'pkcs8' }).toString(); - t.after(() => names.forEach((name, index) => { - if (original[index] === undefined) delete process.env[name]; - else process.env[name] = original[index]; - })); -}; -const appleBody = (attributes: Record = {}) => ({ - data: [{ id: '123', type: 'songs', attributes: { - name: 'The Song', artistName: 'The Artist', albumName: 'The Album', - durationInMillis: 200_000, contentRating: 'clean', isrc: 'USABC1234567', - artwork: { url: 'https://is1-ssl.mzstatic.com/image/{w}x{h}bb.jpg' }, +const itunesBody = (attributes: Record = {}) => ({ + resultCount: 1, + results: [{ + wrapperType: 'track', kind: 'song', trackId: 123, + trackName: 'The Song', artistName: 'The Artist', collectionName: 'The Album', + trackTimeMillis: 200_000, trackExplicitness: 'notExplicit', + artworkUrl100: 'https://is1-ssl.mzstatic.com/image/100x100bb.jpg', ...attributes, - } }], + }], }); -test('Apple adapter signs official ES256 JWT, never sends user token, searches ISRC then constrained title', async t => { - withAppleConfiguration(t); +test('iTunes uses exact ID and original storefront without credentials; missing ISRC requires host choice', async t => { + for (const name of ['APPLE_MUSIC_TEAM_ID', 'APPLE_MUSIC_KEY_ID', 'APPLE_MUSIC_PRIVATE_KEY']) { + const original = process.env[name]; + delete process.env[name]; + t.after(() => { + if (original !== undefined) process.env[name] = original; + }); + } let calls = 0; t.mock.method(globalThis, 'fetch', async (input: Parameters[0], init?: RequestInit) => { const url = new URL(String(input)); calls++; - assert.equal(url.toString(), 'https://api.music.apple.com/v1/catalog/us/songs/123'); + assert.equal(url.toString(), 'https://itunes.apple.com/lookup?id=123&country=il'); assert.equal(init?.redirect, 'manual'); - const headers = init?.headers as Record; - assert.equal(headers['Music-User-Token'], undefined); - const jwt = headers.Authorization.slice(7).split('.'); - const head = JSON.parse(Buffer.from(jwt[0], 'base64url').toString()); - const claims = JSON.parse(Buffer.from(jwt[1], 'base64url').toString()); - assert.equal(head.alg, 'ES256'); - assert.equal(head.kid, '0123456789'); - assert.equal(claims.iss, 'ABCDEFGHIJ'); - assert.equal(claims.exp - claims.iat, 300); - assert.equal(verify('sha256', Buffer.from(jwt.slice(0, 2).join('.')), - { key: publicKey, dsaEncoding: 'ieee-p1363' }, Buffer.from(jwt[2], 'base64url')), true); - return new Response(JSON.stringify(appleBody())); + assert.deepEqual(init?.headers, { Accept: 'application/json' }); + return new Response(JSON.stringify(itunesBody())); }); const queries: string[] = []; - const result = await resolveSong('https://music.apple.com/us/album/album/456?i=123', 'host-token', spotify({ - search: async (token, query) => { assert.equal(token, 'host-token'); queries.push(query); return [track()]; }, - })); - assert.equal(result.confidence, 'exact'); - assert.deepEqual(queries, ['isrc:USABC1234567', 'track:"the song" artist:"the artist"']); - assert.equal(result.source.artwork, 'https://is1-ssl.mzstatic.com/image/300x300bb.jpg'); - assert.equal(calls, 1); -}); - -test('Apple missing/invalid credentials are configuration errors, never empty results', async t => { - withAppleConfiguration(t); - delete process.env.APPLE_MUSIC_TEAM_ID; - await assert.rejects(resolveSong('https://music.apple.com/us/song/123', 'host', spotify()), - (error: unknown) => error instanceof CatalogError && error.code === 'apple_configuration'); - process.env.APPLE_MUSIC_TEAM_ID = 'ABCDEFGHIJ'; - process.env.APPLE_MUSIC_PRIVATE_KEY = 'invalid'; - await assert.rejects(resolveSong('https://music.apple.com/us/song/123', 'host', spotify()), - (error: unknown) => error instanceof CatalogError && error.code === 'apple_configuration'); + for (const input of [ + 'https://music.apple.com/il/album/wrong-url-title/456?i=123&country=us', + 'https://music.apple.com/il/song/wrong-url-title/123', + ]) { + const result = await resolveSong(input, 'host-token', spotify({ + search: async (token, query) => { assert.equal(token, 'host-token'); queries.push(query); return [track()]; }, + })); + assert.equal(result.confidence, 'ambiguous'); + assert.equal(result.selected, undefined); + assert.equal(result.source.isrc, undefined); + assert.equal(result.source.url, 'https://music.apple.com/il/song/123'); + assert.equal(result.source.artwork, 'https://is1-ssl.mzstatic.com/image/100x100bb.jpg'); + assert.deepEqual(result.candidates[0].evidence, [ + 'same_title_and_artist', 'playable', 'duration_within_2000ms', 'same_explicitness', 'same_album', 'incomplete_evidence', + ]); + } + assert.deepEqual(queries, Array(2).fill('track:"the song" artist:"the artist"')); + assert.equal(calls, 2); }); -test('Apple omitted rating/duration are unknown, not fabricated clean/playtime evidence', async t => { - withAppleConfiguration(t); - t.mock.method(globalThis, 'fetch', async () => new Response(JSON.stringify(appleBody({ - contentRating: undefined, durationInMillis: undefined, +test('iTunes missing metadata remains unknown; collection rating and unsolicited ISRC are not track evidence', async t => { + t.mock.method(globalThis, 'fetch', async () => new Response(JSON.stringify(itunesBody({ + trackExplicitness: undefined, trackTimeMillis: undefined, collectionName: undefined, + collectionExplicitness: 'explicit', isrc: 'USABC1234567', })))); const result = await resolveSong('https://music.apple.com/us/song/123', 'host', spotify()); assert.equal(result.source.explicit, null); assert.equal(result.source.durationMs, 0); + assert.equal(result.source.album, ''); + assert.equal(result.source.isrc, undefined); assert.equal(result.confidence, 'ambiguous'); assert.equal(result.selected, undefined); }); -test('Apple status/error mapping and malformed payloads remain distinct from no match', async t => { - withAppleConfiguration(t); +test('iTunes preserves combined artists, version title, duration and track-specific explicitness', async t => { + for (const [rating, explicit] of [['explicit', true], ['cleaned', false], ['notExplicit', false]] as const) { + const mocked = t.mock.method(globalThis, 'fetch', async () => new Response(JSON.stringify(itunesBody({ + trackName: 'Song (Live)', artistName: 'Artist A, Artist B & Artist C', collectionName: 'Album (Remastered)', + trackTimeMillis: 226547, trackExplicitness: rating, collectionExplicitness: 'explicit', + })))); + const result = await resolveSong('https://music.apple.com/us/song/123', 'host', spotify()); + assert.equal(result.source.title, 'Song (Live)'); + assert.equal(result.source.artist, 'Artist A, Artist B & Artist C'); + assert.equal(result.source.album, 'Album (Remastered)'); + assert.equal(result.source.durationMs, 226547); + assert.equal(result.source.explicit, explicit); + assert.equal(result.confidence, 'no_match', 'different version cannot become a title-only match'); + mocked.mock.restore(); + } +}); + +test('iTunes status/error mapping and malformed payloads remain distinct from no match', async t => { const cases = [ - { status: 401, code: 'apple_unauthorized' }, { status: 403, code: 'apple_unauthorized' }, - { status: 429, code: 'apple_rate_limited' }, { status: 503, code: 'apple_unavailable' }, - { status: 302, code: 'apple_invalid_response' }, { status: 400, code: 'apple_rejected' }, - { status: 200, code: 'apple_invalid_response' }, + { status: 401, code: 'itunes_rejected' }, { status: 403, code: 'itunes_rejected' }, + { status: 429, code: 'itunes_rate_limited' }, { status: 503, code: 'itunes_unavailable' }, + { status: 302, code: 'itunes_invalid_response' }, { status: 400, code: 'itunes_rejected' }, + { status: 200, code: 'itunes_invalid_response' }, { status: 204, code: 'itunes_rejected' }, ]; for (const fixture of cases) { - const mocked = t.mock.method(globalThis, 'fetch', async () => new Response('{}', { + const mocked = t.mock.method(globalThis, 'fetch', async () => new Response(fixture.status === 204 ? null : '{}', { status: fixture.status, headers: { 'Retry-After': '17', Location: 'http://127.0.0.1/' }, })); await assert.rejects(resolveSong('https://music.apple.com/us/song/123', 'host', spotify()), (error: unknown) => { @@ -256,21 +261,91 @@ test('Apple status/error mapping and malformed payloads remain distinct from no } const network = t.mock.method(globalThis, 'fetch', async () => { throw new Error('offline'); }); await assert.rejects(resolveSong('https://music.apple.com/us/song/123', 'host', spotify()), - (error: unknown) => error instanceof CatalogError && error.code === 'apple_unavailable'); + (error: unknown) => error instanceof CatalogError && error.code === 'itunes_unavailable'); network.mock.restore(); - t.mock.method(globalThis, 'fetch', async () => new Response('{}', { status: 404 })); - assert.equal((await resolveSong('https://music.apple.com/us/song/123', 'host', spotify())).confidence, 'no_match'); }); -test('Apple returned ID mismatch and Spotify search outage are never no-match', async t => { - withAppleConfiguration(t); - const body = appleBody(); - body.data[0].id = '456'; - const response = t.mock.method(globalThis, 'fetch', async () => new Response(JSON.stringify(body))); - await assert.rejects(resolveSong('https://music.apple.com/us/song/123', 'host', spotify()), - (error: unknown) => error instanceof CatalogError && error.code === 'apple_invalid_response'); - response.mock.restore(); - t.mock.method(globalThis, 'fetch', async () => new Response(JSON.stringify(appleBody()))); +test('iTunes no-result never falls back to another country, album or guessed URL title', async t => { + for (const status of [200, 404]) { + let calls = 0; + const mocked = t.mock.method(globalThis, 'fetch', async (input: Parameters[0]) => { + calls++; + assert.equal(String(input), 'https://itunes.apple.com/lookup?id=123&country=il'); + return new Response(JSON.stringify({ resultCount: 0, results: [] }), { status }); + }); + const result = await resolveSong('https://music.apple.com/il/album/title/456?i=123', 'host', spotify({ + search: async () => assert.fail('No metadata, no Spotify search'), + })); + assert.equal(result.confidence, 'no_match'); + assert.equal(result.selected, undefined); + assert.equal(calls, 1); + mocked.mock.restore(); + } +}); + +test('iTunes malformed, mismatched and non-song results are rejected rather than cached or matched', async t => { + const bodies = [ + {}, null, [], { results: [] }, { resultCount: '0', results: [] }, + { resultCount: 1, results: [] }, { resultCount: 0, results: itunesBody().results }, + { resultCount: 2, results: [...itunesBody().results, ...itunesBody().results] }, + { resultCount: 1, results: [null] }, + ...[ + { trackId: 456 }, { trackId: '123' }, { trackId: 123.5 }, { trackId: 9007199254740992 }, + { kind: 'music-video' }, { wrapperType: 'collection' }, { trackName: '' }, + { trackName: ' ' }, { artistName: null }, { collectionName: null }, + { trackTimeMillis: -1 }, { trackTimeMillis: 0 }, { trackTimeMillis: 1.5 }, { trackTimeMillis: '200000' }, + { trackExplicitness: 'clean' }, { trackExplicitness: null }, { trackExplicitness: ['explicit'] }, + ].map(attributes => itunesBody(attributes)), + ]; + for (const body of bodies) { + const mocked = t.mock.method(globalThis, 'fetch', async () => new Response(JSON.stringify(body))); + await assert.rejects(resolveSong('https://music.apple.com/us/song/123', 'host', spotify({ + search: async () => assert.fail('Malformed data must not reach matching'), + })), (error: unknown) => error instanceof CatalogError && error.code === 'itunes_invalid_response'); + mocked.mock.restore(); + } + t.mock.method(globalThis, 'fetch', async () => new Response(JSON.stringify(itunesBody()))); + assert.equal((await resolveSong('https://music.apple.com/us/song/123', 'host', spotify())).source.title, 'The Song'); +}); + +test('iTunes discards unsafe artwork without trusting trackViewUrl or losing song metadata', async t => { + for (const artworkUrl100 of [ + 'http://is1-ssl.mzstatic.com/a.jpg', 'https://mzstatic.com.evil.test/a.jpg', + 'https://user:pass@is1-ssl.mzstatic.com/a.jpg', 'https://127.0.0.1/a.jpg', + 'https://is1-ssl.mzstatic.com:8443/a.jpg', 'javascript:alert(1)', 123, + ]) { + const mocked = t.mock.method(globalThis, 'fetch', async () => new Response(JSON.stringify(itunesBody({ + artworkUrl100, trackViewUrl: 'http://127.0.0.1/', + })))); + const result = await resolveSong('https://music.apple.com/us/song/123', 'host', spotify()); + assert.equal(result.source.artwork, undefined); + assert.equal(result.source.url, 'https://music.apple.com/us/song/123'); + mocked.mock.restore(); + } +}); + +test('iTunes response size limits apply and non-JSON 429 retains durable retry delay', async t => { + for (const declared of [true, false]) { + const mocked = t.mock.method(globalThis, 'fetch', async () => new Response('x'.repeat(PROVIDER_BODY_LIMIT + 1), { + headers: declared ? { 'content-length': String(PROVIDER_BODY_LIMIT + 1) } : {}, + })); + await assert.rejects(resolveSong('https://music.apple.com/us/song/123', 'host', spotify()), + (error: unknown) => error instanceof CatalogError && error.code === 'itunes_invalid_response'); + mocked.mock.restore(); + } + for (const retry of ['17', 'invalid']) { + const mocked = t.mock.method(globalThis, 'fetch', async () => new Response('Too many requests', { + status: 429, headers: { 'Retry-After': retry }, + })); + await assert.rejects(resolveSong('https://music.apple.com/us/song/123', 'host', spotify()), (error: unknown) => + error instanceof CatalogError && error.code === 'itunes_rate_limited' + && error.retryAfterSeconds === (retry === '17' ? 17 : 60)); + mocked.mock.restore(); + } +}); + +test('Spotify search outage after iTunes lookup is never no-match', async t => { + t.mock.method(globalThis, 'fetch', async () => new Response(JSON.stringify(itunesBody()))); await assert.rejects(resolveSong('https://music.apple.com/us/song/123', 'host', spotify({ search: async () => { throw new SpotifyError('provider_unavailable', 503); }, })), (error: unknown) => error instanceof SpotifyError && error.code === 'provider_unavailable'); diff --git a/projects/api/src/party/catalog.ts b/projects/api/src/party/catalog.ts index 2ea79ec..33b4e50 100644 --- a/projects/api/src/party/catalog.ts +++ b/projects/api/src/party/catalog.ts @@ -1,4 +1,3 @@ -import { createPrivateKey, sign } from 'node:crypto'; import type { PartyCandidate, PartyConfidence, PartyTrack } from '@brewtify/shared'; import { providerRequest, ProviderTransportError, SPOTIFY_TRACK_ID, SpotifyError, @@ -7,17 +6,14 @@ import { export { assertTrackEligible, trackEligibility } from '@brewtify/spotify'; -export interface SongLink { - provider: 'spotify' | 'apple_music'; - id: string; - storefront?: string; - url: string; -} +export type SongLink = + | { provider: 'spotify'; id: string; url: string } + | { provider: 'apple_music'; id: string; storefront: string; url: string }; export class CatalogError extends Error { constructor( - public readonly code: 'invalid_song_link' | 'apple_configuration' | 'apple_unauthorized' - | 'apple_rate_limited' | 'apple_unavailable' | 'apple_invalid_response' | 'apple_rejected', + public readonly code: 'invalid_song_link' | 'itunes_rate_limited' + | 'itunes_unavailable' | 'itunes_invalid_response' | 'itunes_rejected', public readonly status?: number, public readonly retryAfterSeconds?: number, ) { @@ -68,6 +64,7 @@ export function parseSongLink(input: string): SongLink { // A song route with a second identifier is ambiguous, even if they happen to agree. return fail(); } + if (!Number.isSafeInteger(Number(id))) return fail(); return { provider: 'apple_music', id, storefront: match[1], url: `https://music.apple.com/${match[1]}/song/${id}`, @@ -79,27 +76,6 @@ function record(value: unknown): Record { ? value as Record : {}; } -function appleDeveloperToken(): string { - const teamId = process.env.APPLE_MUSIC_TEAM_ID; - const keyId = process.env.APPLE_MUSIC_KEY_ID; - const privateKey = process.env.APPLE_MUSIC_PRIVATE_KEY; - if (!teamId || !keyId || !privateKey || !/^[A-Z0-9]{10}$/.test(teamId) || !/^[A-Z0-9]{10}$/.test(keyId)) { - throw new CatalogError('apple_configuration'); - } - try { - const key = createPrivateKey(privateKey.replace(/\\n/g, '\n')); - if (key.asymmetricKeyType !== 'ec' || key.asymmetricKeyDetails?.namedCurve !== 'prime256v1') { - throw new Error('Expected a P-256 signing key'); - } - const now = Math.floor(Date.now() / 1000); - const encode = (data: unknown) => Buffer.from(JSON.stringify(data)).toString('base64url'); - const unsigned = `${encode({ alg: 'ES256', kid: keyId })}.${encode({ iss: teamId, iat: now, exp: now + 300 })}`; - return `${unsigned}.${sign('sha256', Buffer.from(unsigned), { key, dsaEncoding: 'ieee-p1363' }).toString('base64url')}`; - } catch { - throw new CatalogError('apple_configuration'); - } -} - function safeArtwork(value: unknown): string | undefined { if (typeof value !== 'string' || value.length > 2048) return undefined; try { @@ -111,50 +87,50 @@ function safeArtwork(value: unknown): string | undefined { } catch { return undefined; } } -async function appleSong(link: SongLink): Promise { - const token = appleDeveloperToken(); +async function itunesSong(link: Extract): Promise { let response; try { - response = await providerRequest('apple', `catalog/${link.storefront}/songs/${link.id}`, { - headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, + const query = new URLSearchParams({ id: link.id, country: link.storefront }); + response = await providerRequest('itunes', `lookup?${query}`, { + headers: { Accept: 'application/json' }, }); } catch (error) { if (error instanceof ProviderTransportError) { - throw new CatalogError(error.code === 'network' ? 'apple_unavailable' : 'apple_invalid_response', error.status); + throw new CatalogError(error.code === 'network' ? 'itunes_unavailable' : 'itunes_invalid_response', error.status); } throw error; } if (response.status === 404) return undefined; - if (response.status === 401 || response.status === 403) throw new CatalogError('apple_unauthorized', response.status); - if (response.status === 429) throw new CatalogError('apple_rate_limited', 429, response.retryAfterSeconds ?? 60); - if (response.status >= 500) throw new CatalogError('apple_unavailable', response.status); - if (response.status !== 200) throw new CatalogError('apple_rejected', response.status); - const data = record(response.body).data; - if (!Array.isArray(data) || data.length > 1) throw new CatalogError('apple_invalid_response'); - if (!data.length) return undefined; - const song = record(data[0]); - const attributes = record(song.attributes); - if (song.id !== link.id || song.type !== 'songs' - || typeof attributes.name !== 'string' || !attributes.name || attributes.name.length > 1000 - || typeof attributes.artistName !== 'string' || !attributes.artistName || attributes.artistName.length > 1000 - || typeof attributes.albumName !== 'string' || attributes.albumName.length > 1000 - || (attributes.durationInMillis !== undefined && (typeof attributes.durationInMillis !== 'number' - || !Number.isFinite(attributes.durationInMillis) || attributes.durationInMillis <= 0)) - || (attributes.contentRating !== undefined && !['explicit', 'clean'].includes(String(attributes.contentRating)))) { - throw new CatalogError('apple_invalid_response'); + if (response.status === 429) throw new CatalogError('itunes_rate_limited', 429, response.retryAfterSeconds ?? 60); + if (response.status >= 500) throw new CatalogError('itunes_unavailable', response.status); + if (response.status !== 200) throw new CatalogError('itunes_rejected', response.status); + const body = record(response.body); + const results = body.results; + if (!Array.isArray(results) || results.length > 1 || body.resultCount !== results.length) { + throw new CatalogError('itunes_invalid_response'); } - if (attributes.isrc !== undefined && (typeof attributes.isrc !== 'string' || !ISRC.test(attributes.isrc.toUpperCase()))) { - throw new CatalogError('apple_invalid_response'); + if (!results.length) return undefined; + const song = record(results[0]); + if (!Number.isSafeInteger(song.trackId) || String(song.trackId) !== link.id || song.kind !== 'song' + || song.wrapperType !== 'track' + || typeof song.trackName !== 'string' || !song.trackName.trim() || song.trackName.length > 1000 + || typeof song.artistName !== 'string' || !song.artistName.trim() || song.artistName.length > 1000 + || (song.collectionName !== undefined && (typeof song.collectionName !== 'string' || song.collectionName.length > 1000)) + || (song.trackTimeMillis !== undefined && (typeof song.trackTimeMillis !== 'number' + || !Number.isSafeInteger(song.trackTimeMillis) || song.trackTimeMillis <= 0)) + || (song.trackExplicitness !== undefined && (typeof song.trackExplicitness !== 'string' + || !['explicit', 'cleaned', 'notExplicit'].includes(song.trackExplicitness)))) { + throw new CatalogError('itunes_invalid_response'); } - const rawArtwork = record(attributes.artwork).url; return { - id: link.id, title: attributes.name, artist: attributes.artistName, album: attributes.albumName, - durationMs: typeof attributes.durationInMillis === 'number' ? attributes.durationInMillis : 0, - // Apple omits contentRating on unrated tracks; omission is not evidence of "clean". - explicit: attributes.contentRating === 'explicit' ? true : attributes.contentRating === 'clean' ? false : null, - isrc: typeof attributes.isrc === 'string' && ISRC.test(attributes.isrc.toUpperCase()) ? attributes.isrc.toUpperCase() : undefined, + id: link.id, title: song.trackName, artist: song.artistName, + album: typeof song.collectionName === 'string' ? song.collectionName : '', + durationMs: typeof song.trackTimeMillis === 'number' ? song.trackTimeMillis : 0, + // Album ratings are not track evidence. iTunes does not supply recording ISRCs. + explicit: song.trackExplicitness === 'explicit' ? true + : song.trackExplicitness === 'cleaned' || song.trackExplicitness === 'notExplicit' ? false : null, url: link.url, - artwork: safeArtwork(typeof rawArtwork === 'string' ? rawArtwork.replace('{w}', '300').replace('{h}', '300') : undefined), + artwork: safeArtwork(song.artworkUrl100), }; } @@ -273,10 +249,9 @@ export async function resolveSong(input: string, token: string, spotifyClient: C const selected = { ...source, evidence: ['direct_spotify_id', 'playable', 'not_relinked'] }; return { source, candidates: [selected], selected, confidence: 'exact' }; } - const source = await appleSong(link); + const source = await itunesSong(link); if (!source) return { source: unavailableSource(link), candidates: [], confidence: 'no_match' }; const tracks: SpotifyTrack[] = []; - if (source.isrc) tracks.push(...await spotifyClient.search(token, `isrc:${source.isrc}`)); // Quote boundaries and field operators cannot come from provider metadata. const searchText = (text: string) => normalized(text).slice(0, 250); tracks.push(...await spotifyClient.search(token, `track:"${searchText(source.title)}" artist:"${searchText(source.artist)}"`)); diff --git a/projects/api/src/party/config.test.ts b/projects/api/src/party/config.test.ts new file mode 100644 index 0000000..101c7c1 --- /dev/null +++ b/projects/api/src/party/config.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { checkPrerequisites, PartyError } from './config'; + +test('Party prerequisites require no Apple credentials but retain Spotify and infrastructure gates', t => { + const values: Record = { + PARTY_PUBLIC_ORIGIN: 'https://party.test', + PARTY_TELEGRAM_BOT_USERNAME: 'test_party_bot', + DATABASE_URL: 'postgresql://localhost/unused', + ENCRYPTION_KEY: 'ab'.repeat(32), + TELEGRAM_BOT_TOKEN: 'test', + PARTY_IDENTITY_KEY: 'cd'.repeat(32), + SPOTIFY_CLIENT_ID: 'test-client', + PARTY_SPOTIFY_REDIRECT_URI: 'https://party.test/api/party/auth/callback', + PARTY_HOST_ALLOWLIST: 'host', + PARTY_TASKS_PROJECT: 'test', + PARTY_TASKS_LOCATION: 'test', + PARTY_TASKS_QUEUE: 'test', + PARTY_TASKS_SERVICE_ACCOUNT: 'tasks@test.invalid', + PARTY_TASKS_AUDIENCE: 'https://party.test', + APPLE_MUSIC_TEAM_ID: undefined, + APPLE_MUSIC_KEY_ID: undefined, + APPLE_MUSIC_PRIVATE_KEY: undefined, + }; + for (const [name, value] of Object.entries(values)) { + const original = process.env[name]; + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + t.after(() => { + if (original === undefined) delete process.env[name]; + else process.env[name] = original; + }); + } + assert.doesNotThrow(checkPrerequisites); + for (const name of ['SPOTIFY_CLIENT_ID', 'PARTY_HOST_ALLOWLIST', 'PARTY_TASKS_QUEUE', 'PARTY_IDENTITY_KEY']) { + delete process.env[name]; + assert.throws(checkPrerequisites, (error: unknown) => + error instanceof PartyError && error.code === 'configuration_required' && error.message.includes(name)); + process.env[name] = values[name]; + } +}); diff --git a/projects/api/src/party/config.ts b/projects/api/src/party/config.ts index df90c26..8ce2e83 100644 --- a/projects/api/src/party/config.ts +++ b/projects/api/src/party/config.ts @@ -69,9 +69,6 @@ export function checkPrerequisites(): void { 'SPOTIFY_CLIENT_ID', 'PARTY_SPOTIFY_REDIRECT_URI', 'PARTY_HOST_ALLOWLIST', - 'APPLE_MUSIC_TEAM_ID', - 'APPLE_MUSIC_KEY_ID', - 'APPLE_MUSIC_PRIVATE_KEY', 'PARTY_TASKS_PROJECT', 'PARTY_TASKS_LOCATION', 'PARTY_TASKS_QUEUE', diff --git a/projects/api/src/party/jobs.ts b/projects/api/src/party/jobs.ts index 8b88dcf..8230589 100644 --- a/projects/api/src/party/jobs.ts +++ b/projects/api/src/party/jobs.ts @@ -387,8 +387,8 @@ export async function runJob(id: string, generation: number): Promise { error.status === 429 ) { await tx.query( - "UPDATE party_requests SET failure_code='rate_limited' WHERE id=$1", - [request.id] + 'UPDATE party_requests SET failure_code=$2 WHERE id=$1', + [request.id, code] ); await reschedule(tx, job, error.retryAfterSeconds ?? 60); } else if (code === 'recording_changed') { diff --git a/projects/api/src/party/routes.ts b/projects/api/src/party/routes.ts index 64ada67..0503138 100644 --- a/projects/api/src/party/routes.ts +++ b/projects/api/src/party/routes.ts @@ -99,7 +99,7 @@ export const partyErrorHandler: ErrorRequestHandler = ( error instanceof CatalogError ? error.code === 'invalid_song_link' ? 400 - : error.code === 'apple_rate_limited' + : error.code === 'itunes_rate_limited' ? 429 : 503 : known && diff --git a/projects/api/tests/party.integration.test.mjs b/projects/api/tests/party.integration.test.mjs index 0af960d..cd1b27b 100644 --- a/projects/api/tests/party.integration.test.mjs +++ b/projects/api/tests/party.integration.test.mjs @@ -23,9 +23,6 @@ Object.assign(process.env, { SPOTIFY_CLIENT_ID: 'test-client', PARTY_SPOTIFY_REDIRECT_URI: `${origin}/api/party/auth/callback`, PARTY_HOST_ALLOWLIST: 'test-spotify', - APPLE_MUSIC_TEAM_ID: 'test', - APPLE_MUSIC_KEY_ID: 'test', - APPLE_MUSIC_PRIVATE_KEY: 'test-not-used', PARTY_TASKS_PROJECT: 'test', PARTY_TASKS_LOCATION: 'test', PARTY_TASKS_QUEUE: 'test', @@ -929,3 +926,102 @@ test('new parties auto-add unnamed guest submissions and selected versions witho assert.equal(queueCalls, before + 2, 'choosing a version does not require a second approval'); await call(`/rooms/${active.id}/action`, host, { action: 'close' }); }); + +test('iTunes retry is durable; missing ISRC needs host choice in auto rooms; outages are not no-match', async t => { + await authorize(host); + const created = await call('/rooms', host, {}); + assert.equal(created.response.status, 201, JSON.stringify(created.data)); + const active = created.data.room; + assert.equal(active.mode, 'auto'); + const secret = new URL(created.data.inviteUrl).searchParams.get('startapp').slice(2); + assert.equal((await call('/join', member, { secret })).response.status, 200); + const originalFetch = globalThis.fetch; + let status = 429; + let lookupCalls = 0; + let searches = 0; + t.mock.method(globalThis, 'fetch', async (input, options) => { + const url = new URL(String(input)); + if (url.origin === base) return originalFetch(input, options); + assert.equal(url.toString(), 'https://itunes.apple.com/lookup?id=1814958889&country=il'); + assert.deepEqual(options.headers, { Accept: 'application/json' }); + lookupCalls++; + return status === 200 ? Response.json({ + resultCount: 1, + results: [{ + wrapperType: 'track', kind: 'song', trackId: 1814958889, + trackName: track.name, artistName: 'Artist', collectionName: 'Album', + trackTimeMillis: track.duration_ms, trackExplicitness: 'notExplicit', + collectionExplicitness: 'explicit', + }], + }) : new Response('Provider unavailable', { status, headers: { 'Retry-After': '75' } }); + }); + t.mock.method(SpotifyClient.prototype, 'search', async (_token, query) => { + searches++; + assert.equal(query, 'track:"test song" artist:"artist"'); + return [track]; + }); + const submitApple = async () => { + const response = await call(`/rooms/${active.id}/requests`, member, { + url: 'https://music.apple.com/il/album/not-trusted/1814958890?i=1814958889', + submissionKey: randomUUID(), + }); + assert.equal(response.response.status, 202, JSON.stringify(response.data)); + return response.data.id; + }; + const id = await submitApple(); + const before = queueCalls; + const started = Date.now(); + await runPending(id, 'resolve'); + let [job] = await store.rows("SELECT * FROM party_jobs WHERE request_id=$1 AND kind='resolve'", [id]); + let [request] = await store.rows('SELECT * FROM party_requests WHERE id=$1', [id]); + assert.equal(job.status, 'pending'); + assert.equal(job.failures, 0); + assert.ok(job.due_at.getTime() >= started + 74_000); + assert.equal(request.status, 'pending'); + assert.equal(request.failure_code, 'itunes_rate_limited'); + assert.equal(lookupCalls, 1); + assert.equal(searches, 0); + assert.equal(queueCalls, before); + await assert.rejects(runPending(id, 'resolve'), error => error.code === 'task_not_due'); + assert.equal(lookupCalls, 1, 'a future job must not make an early provider retry'); + + status = 200; + await store.rows('UPDATE party_jobs SET due_at=now() WHERE id=$1', [job.id]); + await runPending(id, 'resolve'); + [request] = await store.rows('SELECT * FROM party_requests WHERE id=$1', [id]); + assert.equal(request.status, 'needs_review'); + assert.equal(request.confidence, 'ambiguous'); + assert.equal(request.failure_code, null); + assert.equal(request.source.isrc, undefined); + assert.equal(request.source.explicit, false); + assert.equal(lookupCalls, 2); + assert.equal(searches, 1); + assert.equal((await store.rows("SELECT id FROM party_jobs WHERE request_id=$1 AND kind='deliver'", [id])).length, 0); + assert.equal(queueCalls, before, 'otherwise-identical metadata cannot fill missing recording identity'); + assert.equal((await call(`/rooms/${active.id}/requests/${id}/action`, member, { + action: 'select', candidateId: trackId, + })).response.status, 403); + const selected = await call(`/rooms/${active.id}/requests/${id}/action`, host, { + action: 'select', candidateId: trackId, + }); + assert.equal(selected.response.status, 200, JSON.stringify(selected.data)); + await runPending(id, 'deliver'); + assert.equal(queueCalls, before + 1, 'host version choice queues via mocked active Spotify playback without extra approval'); + + status = 503; + const outage = await submitApple(); + for (let attempt = 0; attempt < 5; attempt++) { + await store.rows('UPDATE party_jobs SET due_at=now() WHERE request_id=$1', [outage]); + await runPending(outage, 'resolve'); + } + [job] = await store.rows("SELECT * FROM party_jobs WHERE request_id=$1 AND kind='resolve'", [outage]); + [request] = await store.rows('SELECT * FROM party_requests WHERE id=$1', [outage]); + assert.equal(job.status, 'done'); + assert.equal(job.failures, 4); + assert.equal(request.status, 'failed'); + assert.equal(request.failure_code, 'itunes_unavailable'); + assert.equal(lookupCalls, 7, 'one lookup per bounded read attempt, without a hidden retry or fallback'); + assert.equal(searches, 1); + assert.equal(queueCalls, before + 1); + await call(`/rooms/${active.id}/action`, host, { action: 'close' }); +}); diff --git a/projects/mini-app/src/features/party/api.ts b/projects/mini-app/src/features/party/api.ts index fba71d3..5874cb5 100644 --- a/projects/mini-app/src/features/party/api.ts +++ b/projects/mini-app/src/features/party/api.ts @@ -70,7 +70,7 @@ export class PartyClient { export function errorText(error: unknown): string { if (error instanceof PartyError && ( ['host_reconnect', 'unauthorized', 'premium_required', 'insufficient_scope', 'device_unavailable', 'delivery_settling'].includes(error.code) - || error.code.startsWith('apple_') + || error.code.startsWith('itunes_') )) { return partyFailureMessage(error.code); } diff --git a/projects/mini-app/src/features/party/messages.ts b/projects/mini-app/src/features/party/messages.ts index 79fb53d..7d0a47c 100644 --- a/projects/mini-app/src/features/party/messages.ts +++ b/projects/mini-app/src/features/party/messages.ts @@ -10,12 +10,10 @@ const messages: Record = { device_unavailable: 'Spotify has no available active playback. The host should open Spotify, start playing music, then tap Try again.', rate_limited: 'Spotify is rate-limiting requests. Approved requests are waiting for a safe retry.', forbidden: 'Spotify denied playback access. Check the pilot allowlist and device restrictions; this does not necessarily mean Premium is missing.', - apple_configuration: 'Apple Music catalog access is not configured. The Brewtify operator must configure it; guests do not need to sign in.', - apple_unauthorized: 'Apple Music catalog credentials were rejected. The Brewtify operator must restore catalog access; no guest login is needed.', - apple_rate_limited: 'Apple Music is limiting catalog requests. Matching will wait before retrying.', - apple_unavailable: 'Apple Music catalog is temporarily unavailable. Try this request again later; this is not a confirmed no-match.', - apple_invalid_response: 'Apple Music returned incomplete or unexpected song metadata. The host can retry later or use a Spotify song link.', - apple_rejected: 'Apple Music rejected this catalog lookup. Check the individual song link and storefront, or try a Spotify song link.', + itunes_rate_limited: 'iTunes Store is limiting song lookups. Matching will wait before retrying.', + itunes_unavailable: 'iTunes Store lookup is temporarily unavailable. Try again later; this is not a confirmed no-match.', + itunes_invalid_response: 'iTunes Store returned unexpected song metadata. The host can retry later or use a Spotify song link.', + itunes_rejected: 'iTunes Store rejected this lookup. Check the Apple Music song link and storefront, or use a Spotify song link. Not every Apple Music song is in iTunes Store.', room_closed: 'This party is closed. Tracks already queued remain in Spotify.', room_expired: 'This party has expired. Ask the host for a new invitation.', }; diff --git a/projects/mini-app/tests/party-api.test.mjs b/projects/mini-app/tests/party-api.test.mjs index d7904ab..24cd9bb 100644 --- a/projects/mini-app/tests/party-api.test.mjs +++ b/projects/mini-app/tests/party-api.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { PartyClient, PartyError } from '../src/features/party/api.ts'; +import { errorText, PartyClient, PartyError } from '../src/features/party/api.ts'; test('Party bootstrap uses only signed initData; mutations use same-origin cookies and CSRF', async (t) => { const calls = []; @@ -43,10 +43,22 @@ test('failed mutations are never automatically replayed', async (t) => { calls++; throw new TypeError('Network unavailable'); }); + await assert.rejects(new PartyClient().request('/rooms/room/requests/id/action', { action: 'retry', confirmDuplicateRisk: true }), PartyError); assert.equal(calls, 1); }); +test('iTunes errors display catalog-specific guidance rather than Spotify or Telegram authentication', () => { + for (const [code, status] of [ + ['itunes_rejected', 401], ['itunes_unavailable', 503], ['itunes_invalid_response', 503], + ['itunes_rate_limited', 429], + ]) { + const message = errorText(new PartyError('Raw catalog error', code, status, 75_000)); + assert.match(message, /iTunes Store/); + assert.doesNotMatch(message, /session expired|reconnect|credentials|Raw catalog error/i); + } +}); + test('reload restores its cookie session without replaying signed launch data', async (t) => { const calls = []; t.mock.method(globalThis, 'fetch', async (_url, options) => { diff --git a/projects/mini-app/tests/party-preview.test.mjs b/projects/mini-app/tests/party-preview.test.mjs index d5f48ac..f274887 100644 --- a/projects/mini-app/tests/party-preview.test.mjs +++ b/projects/mini-app/tests/party-preview.test.mjs @@ -40,9 +40,10 @@ test('provider failures have actionable labels without telling guests to log in' assert.match(partyFailureMessage('device_unavailable'), /start playing music, then tap Try again/); assert.match(partyFailureMessage('delivery_settling'), /two-minute safety window/); assert.equal(partyFailureMessage('recording_changed'), 'Spotify recording details changed. Resolve the request again and approve the version before adding.'); - for (const code of ['apple_configuration', 'apple_unauthorized', 'apple_rate_limited', 'apple_unavailable', 'apple_invalid_response', 'apple_rejected']) { - assert.match(partyFailureMessage(code), /Apple Music/); + for (const code of ['itunes_rate_limited', 'itunes_unavailable', 'itunes_invalid_response', 'itunes_rejected']) { + assert.match(partyFailureMessage(code), /iTunes Store/); assert.doesNotMatch(partyFailureMessage(code), /Party needs attention/); + assert.doesNotMatch(partyFailureMessage(code), /credentials|sign in|developer/i); } }); diff --git a/projects/spotify/README.md b/projects/spotify/README.md index 80cfb0a..f634540 100644 --- a/projects/spotify/README.md +++ b/projects/spotify/README.md @@ -25,8 +25,8 @@ any write. Unlike cross-catalog matching, this check has no duration tolerance. All requests use fixed official HTTPS origins, reject redirects, have an 8-second deadline (including body consumption), and a 1 MiB response limit. Search requests use at most 10 results without imposing an Apple storefront -as the Spotify market. The shared server transport also serves the official -Apple catalog API; it never fetches submitted links. +as the Spotify market. The shared server transport also serves free iTunes Store +lookup (`itunes`, `https://itunes.apple.com/`); it never fetches submitted links. The shared transport uses an Undici dispatcher with a socket-connect DNS lookup. It resolves only the three allowlisted provider hosts, validates the complete answer set, and returns those exact vetted addresses to the connecting socket @@ -46,19 +46,33 @@ unknown-delivery handling, never automatic resending. Explicit 429 rejections carry the provider delay (60 seconds when absent/invalid) for durable scheduling. Do not infer Premium requirements from generic 403 responses. -The API catalog adapter requires `APPLE_MUSIC_TEAM_ID`, `APPLE_MUSIC_KEY_ID`, and -`APPLE_MUSIC_PRIVATE_KEY` (P-256 PEM, with literal or escaped newlines). It signs -short-lived ES256 developer JWTs; no Apple user token is requested. +The API catalog adapter resolves Apple Music song links with an exact numeric ID +and the original storefront using public iTunes Store lookup. It requires no +Apple credentials, developer membership or signing key, and has no paid API fallback. +An empty result is unavailable, never a reason to try another country or recording. +iTunes Store coverage is not the complete Apple Music catalog and supplies no ISRC. +Only track explicitness is used: `explicit` is true; `cleaned` and `notExplicit` +are false; omission is unknown. Collection explicitness is never substituted. Matching uses full normalized title and artist equality, version-bearing album checks, known explicitness, and a maximum 2-second duration difference. ISRC contradictions always reject. Unique complete ISRC matches are exact; without ISRC on either side, complete title/artist/album evidence can be high confidence. -One-sided missing ISRC, unrated Apple tracks, other missing critical evidence, +One-sided missing ISRC, unrated iTunes tracks, other missing critical evidence, or multiple viable editions require host review. No fuzzy scoring resolves ties. Provider/configuration outages are errors, not empty catalog results. +Most identified Spotify candidates therefore need a host version choice for iTunes +sources; combined artist formatting can also reduce coverage. This does not change +direct Spotify matching or allow title-only auto-selection. + +The public service's archived guidance estimates 20 requests/minute, subject to +change. Party keeps its existing submission throttles and durable 429 scheduling, +not a global iTunes quota or lookup cache. Cross-instance aggregate quota compliance +is not guaranteed. See [the Party runbook](../../docs/party-queue.md) before widening +the pilot. Catalog failures/receipts use `itunes_*` codes rather than paid-API +credential errors. Run `npm test --workspace=@brewtify/spotify` after installing root dependencies and building `@brewtify/shared`. API catalog fixtures run with the API's Node test runner. Tests use mocked providers and do not prove live pilot access, -market evidence, Telegram behavior, Apple credentials, or actual enqueue access. +market evidence, Telegram behavior, live iTunes coverage, or actual enqueue access. diff --git a/projects/spotify/src/index.test.ts b/projects/spotify/src/index.test.ts index 6f52eef..731d783 100644 --- a/projects/spotify/src/index.test.ts +++ b/projects/spotify/src/index.test.ts @@ -220,7 +220,9 @@ test('fixed origins and redirect rejection prevent arbitrary destinations', asyn assert.equal(init?.redirect, 'manual'); return new Response(null, { status: 302, headers: { Location: 'http://169.254.169.254/' } }); }); - await assert.rejects(providerRequest('apple', 'catalog/us/songs/123', {}), /redirect_rejected/); + await assert.rejects(providerRequest('itunes', 'lookup?id=123&country=us', {}), /redirect_rejected/); + await assert.rejects(providerRequest('itunes', 'https://api.music.apple.com/v1/catalog/us/songs/123', {}), /invalid_response/); + await assert.rejects(providerRequest('itunes', '//127.0.0.1/lookup?id=123', {}), /invalid_response/); await assert.rejects(providerRequest('spotify', 'https://evil.example/', {}), /invalid_response/); await assert.rejects(providerRequest('spotify', '//127.0.0.1/', {}), /invalid_response/); await assert.rejects(providerRequest('spotify', '../../other', {}), /invalid_response/); diff --git a/projects/spotify/src/index.ts b/projects/spotify/src/index.ts index 76a117c..cc43013 100644 --- a/projects/spotify/src/index.ts +++ b/projects/spotify/src/index.ts @@ -81,7 +81,7 @@ export class SpotifyError extends Error { const BASES = { spotify: 'https://api.spotify.com/v1/', oauth: 'https://accounts.spotify.com/api/', - apple: 'https://api.music.apple.com/v1/', + itunes: 'https://itunes.apple.com/', } as const; export class ProviderTransportError extends Error { @@ -110,7 +110,7 @@ function retryAfter(value: string | null): number | undefined { } /** - * Server-only, fixed-origin transport shared with the official Apple catalog. + * Server-only, fixed-origin transport shared with the public iTunes lookup. * Exactly one fetch: durable callers decide read retries and 429 scheduling. */ export async function providerRequest( diff --git a/projects/spotify/src/network.test.ts b/projects/spotify/src/network.test.ts index 63071c6..ee9df1a 100644 --- a/projects/spotify/src/network.test.ts +++ b/projects/spotify/src/network.test.ts @@ -84,11 +84,11 @@ test('socket lookup rejects empty, malformed and mismatched-family DNS answers a test('socket lookup cannot resolve unapproved hostnames or literals', async () => { let calls = 0; - for (const hostname of ['evil.example', 'api.spotify.com.evil.example', '127.0.0.1', '35.186.224.25', 'api.music.apple.com.']) { + for (const hostname of ['evil.example', 'api.spotify.com.evil.example', '127.0.0.1', '35.186.224.25', 'itunes.apple.com.', 'itunes.apple.com.evil.example', 'api.music.apple.com']) { await assert.rejects(runLookup(async () => { calls++; return []; }, {}, hostname), { code: 'EACCES' }); } assert.equal(calls, 0); - for (const hostname of ['api.spotify.com', 'accounts.spotify.com', 'api.music.apple.com']) { + for (const hostname of ['api.spotify.com', 'accounts.spotify.com', 'itunes.apple.com']) { assert.equal((await runLookup(async () => [{ address: '35.186.224.25', family: 4 }], {}, hostname)).address, '35.186.224.25'); } }); diff --git a/projects/spotify/src/network.ts b/projects/spotify/src/network.ts index fdcea4d..d352363 100644 --- a/projects/spotify/src/network.ts +++ b/projects/spotify/src/network.ts @@ -2,7 +2,7 @@ import { lookup } from 'node:dns/promises'; import type { LookupAddress } from 'node:dns'; import { BlockList, isIP, type LookupFunction } from 'node:net'; -const PROVIDER_HOSTS = new Set(['api.spotify.com', 'accounts.spotify.com', 'api.music.apple.com']); +const PROVIDER_HOSTS = new Set(['api.spotify.com', 'accounts.spotify.com', 'itunes.apple.com']); const deniedV4 = new BlockList(); for (const [network, prefix] of [ ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],