diff --git a/docs/party-queue.md b/docs/party-queue.md index 805a8c5..c605dc4 100644 --- a/docs/party-queue.md +++ b/docs/party-queue.md @@ -5,14 +5,21 @@ Playlists/Artists views. Party requests append to the host's Spotify playback queue, never to a playlist or Spotify Jam. A successful command means **accepted, not played**. Spotify and other controllers can affect eventual playback order. -New parties automatically add exact/high-confidence matches. Guests paste a -song link and submit without entering a display name; the API stores a generic -`Guest` label, not a Telegram name. The room has no settings or approval panel. -Hosts share the invitation and can end the party (which deletes its credentials). -Uncertain recordings still require an explicit host version choice, which queues -the chosen version without a second approval. Recovery controls appear only -when playback needs attention. Existing approval-mode rooms retain their mode; -the API still accepts that mode for older clients. +Hosts and guests paste a complete song link to search after 500 ms without +submitting anything. Results use the same compact artwork/title/artist/album +cards as the room list. Clicking a result is the **only confirmation**: it creates +a durable queue job, without a dialog or routine host approval (including in +legacy approval-mode rooms). Multiple version-safe matches remain explicit choices. +The API stores a generic `Guest` label, not a Telegram name. The room has no +settings or approval panel. Hosts share the invitation and can end the party +(which deletes its credentials). Older submission/host-choice APIs retain their +existing behavior for legacy requests and recovery. + +Search alone never creates a request or outbox job. The Mini App shows +**Added to the queue** only after its own selected request is `added` following a +confirmed Spotify 204; 202/outbox creation shows progress, not success. A completed +current search with no eligible results shows **Not found** once. Provider +outages, authentication failures and throttling stay explicit errors. Start party authorizes Spotify when needed, then opens the room automatically. There is no device-selection screen or separate create step. Queue commands use @@ -198,10 +205,11 @@ 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 +version choice. The new flow lets the submitter make that choice directly by +clicking the result; legacy submitted requests still need the host. This 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. +is relaxed. New direct Spotify links also wait for a result click; only legacy +submissions 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 @@ -215,6 +223,45 @@ 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. +### Search and explicit selection API + +`POST /api/party/rooms/:id/search` accepts `{ url }`. Signed Telegram session, +exact Origin, CSRF, live room and membership are required; guests use only the +host's server-side credentials. Apple links use exact iTunes track ID/storefront +then Spotify search. Spotify links use direct Spotify lookup, not iTunes. +Both client and server share the strict link parser. Read-only resolution keeps +all existing version/playability checks and returns `{ candidates, expiresAt }`, +with a `selectionToken` on each candidate. It does not store search drafts or +publish search results in the room feed. + +Each domain-separated HMAC-signed capability binds the session, room, canonical +source, exact candidate metadata, common search nonce and expiry (at most five +minutes, bounded by session/room expiry). Tokens contain no host credentials and +are kept only in component memory. `POST /rooms/:id/selections` accepts only +`{ selectionToken }`, never trusts client track IDs/metadata, and rechecks expiry, +room and membership under the existing account lock. Request bodies are bounded +to 32 KiB, selection tokens to 30,000 characters; oversized provider metadata is +an explicit error rather than an unusable result. + +One common search nonce becomes the existing unique submission key. Concurrent +or repeated clicks on the same candidate return the same request (an overlapping +account operation may return retryable `host_busy`); choosing another candidate +from a consumed search conflicts. No duplicate job or provider write is created. +A fresh deliberate search permits adding the same song again. Selection creates +an `approved` request and one `deliver` job atomically, with no legacy resolver +job. Fresh pre-send recording validation, account serialization, 429 retry and +unknown-write safeguards remain unchanged. + +Database throttles limit search to six per participant/room and sixteen per room +per minute; selection has separate limits of eight and eighty respectively. +These limits are shared across instances but are **not a global iTunes quota** +or cache. They also do not replace provider Retry-After handling. The UI cancels +and invalidates old searches on input/room changes or unmount, never auto-retries +a failed search, and gates explicit rate-limit retries. It tracks only selections +made by this mounted form for outcome toasts: polling old/other users' receipts +or reloading does not replay success. Normal results need no second confirmation; +exceptional unknown-delivery recovery still requires host duplicate-risk consent. + ### Queue delivery Committed submissions and approvals create database outbox jobs. Scheduler @@ -308,3 +355,15 @@ 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. + +The search-and-select follow-up adds native PostgreSQL coverage for read-only +search, session/room/source-bound capabilities, tampering, expiry, revoked +membership, concurrent idempotent clicks, deliberate repeated additions and +recording drift. React DOM tests exercise real components with controlled timers +for debounce, stale responses, shared card markup, direct one-click selection, +confirmed-only one-time toasts, not-found versus errors and expiry. These DOM +tests do not establish browser layout or keyboard behavior. Browser regression +tests were updated, but local launch remains blocked: system Chrome timed out +with EPERM during cleanup, cached headless Chromium 1234 crashed with SIGSEGV, +and cached Chromium 1194 timed out. The HTTP-responsive preview is available +on port 5213; no rendered-browser verification is claimed for this follow-up. diff --git a/package-lock.json b/package-lock.json index 6431ab0..09cad93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,27 @@ "vite": "^7.0.4" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -326,6 +347,121 @@ "node": ">=12" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@electric-sql/pglite": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", @@ -3820,6 +3956,20 @@ "node": ">= 8" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -3836,6 +3986,57 @@ "node": ">= 12" } }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3862,6 +4063,13 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4085,6 +4293,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", @@ -5121,6 +5342,19 @@ "node": ">=16.9.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -5287,6 +5521,13 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -5337,6 +5578,83 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -6043,6 +6361,13 @@ "node": ">=0.10.0" } }, + "node_modules/nwsapi": { + "version": "2.2.28", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.28.tgz", + "integrity": "sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6194,6 +6519,19 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -7111,6 +7449,13 @@ "node": ">= 18" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -7137,6 +7482,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -7520,6 +7878,13 @@ "node": ">=4" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", @@ -7617,6 +7982,26 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -7649,6 +8034,19 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -8117,6 +8515,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -8132,6 +8543,43 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -8270,6 +8718,45 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -8476,6 +8963,7 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", + "jsdom": "^26.1.0", "typescript": "~6.0.2", "typescript-eslint": "^8.59.2", "vite": "^8.0.12" diff --git a/projects/api/src/party/catalog.ts b/projects/api/src/party/catalog.ts index 33b4e50..19b4efd 100644 --- a/projects/api/src/party/catalog.ts +++ b/projects/api/src/party/catalog.ts @@ -1,4 +1,5 @@ import type { PartyCandidate, PartyConfidence, PartyTrack } from '@brewtify/shared'; +import { InvalidSongLink, parsePartySongLink, type SongLink } from '@brewtify/shared'; import { providerRequest, ProviderTransportError, SPOTIFY_TRACK_ID, SpotifyError, trackEligibility, type SpotifyTrack, @@ -6,9 +7,7 @@ import { export { assertTrackEligible, trackEligibility } from '@brewtify/spotify'; -export type SongLink = - | { provider: 'spotify'; id: string; url: string } - | { provider: 'apple_music'; id: string; storefront: string; url: string }; +export type { SongLink } from '@brewtify/shared'; export class CatalogError extends Error { constructor( @@ -22,53 +21,17 @@ export class CatalogError extends Error { } } -const APPLE_ID = /^[1-9]\d{0,19}$/; const ISRC = /^[A-Z]{2}[A-Z0-9]{3}\d{7}$/; export const MATCH_DURATION_TOLERANCE_MS = 2_000; export const MAX_MATCH_CANDIDATES = 10; export function parseSongLink(input: string): SongLink { - const fail = (): never => { throw new CatalogError('invalid_song_link'); }; - if (typeof input !== 'string' || input.length > 2048) return fail(); - const text = input.trim(); - const uri = /^spotify:track:([A-Za-z0-9]{22})$/.exec(text); - if (uri) return { provider: 'spotify', id: uri[1], url: `https://open.spotify.com/track/${uri[1]}` }; - // URL parsers normalize backslashes, controls and dot paths; reject them before parsing. - if (!text || /[\u0000-\u0020\u007f\\]/.test(text) || !/^https:\/\//i.test(text)) return fail(); - let url: URL; - try { url = new URL(text); } catch { return fail(); } - if (url.protocol !== 'https:' || url.username || url.password || url.port || url.hash) return fail(); - const authority = text.slice(text.indexOf('://') + 3).split(/[/?#]/, 1)[0]; - if (!/^(open\.spotify\.com|music\.apple\.com)$/i.test(authority)) return fail(); - const rawPath = text.slice(text.indexOf(authority) + authority.length).split(/[?#]/, 1)[0]; - if (rawPath !== url.pathname || /(?:^|\/)\.{1,2}(?:\/|$)/.test(rawPath)) return fail(); - if (url.hostname === 'open.spotify.com') { - const match = /^\/(?:intl-[a-z]{2}(?:-[A-Z]{2})?\/)?track\/([A-Za-z0-9]{22})\/?$/.exec(url.pathname); - if (!match) return fail(); - return { provider: 'spotify', id: match[1], url: `https://open.spotify.com/track/${match[1]}` }; - } - if (url.hostname !== 'music.apple.com') return fail(); - const match = /^\/([a-z]{2})\/(song|album)\/(?:([^/]+)\/)?([1-9]\d{0,19})\/?$/.exec(url.pathname); - if (!match) return fail(); - if (match[3]) { - try { - if (/[/\\\u0000-\u001f\u007f]/.test(decodeURIComponent(match[3]))) return fail(); - } catch { return fail(); } - } - const ids = url.searchParams.getAll('i'); - let id = match[4]; - if (match[2] === 'album') { - if (ids.length !== 1 || !APPLE_ID.test(ids[0])) return fail(); - id = ids[0]; - } else if (ids.length > 0) { - // A song route with a second identifier is ambiguous, even if they happen to agree. - return fail(); + try { + return parsePartySongLink(input); + } catch (error) { + if (error instanceof InvalidSongLink) throw new CatalogError('invalid_song_link'); + throw error; } - if (!Number.isSafeInteger(Number(id))) return fail(); - return { - provider: 'apple_music', id, storefront: match[1], - url: `https://music.apple.com/${match[1]}/song/${id}`, - }; } function record(value: unknown): Record { diff --git a/projects/api/src/party/resolution.ts b/projects/api/src/party/resolution.ts new file mode 100644 index 0000000..59369d1 --- /dev/null +++ b/projects/api/src/party/resolution.ts @@ -0,0 +1,115 @@ +import { randomUUID } from 'node:crypto'; +import type { PartyCandidate, PartyConfidence, PartySearchResult, PartyTrack } from '@brewtify/shared'; +import { accessToken, hostFor, spotify } from './auth'; +import { parseSongLink, resolveSong } from './catalog'; +import { PartyError } from './config'; +import { createJob, getRoom, requireLive, requireMember, type SongRequest } from './rooms'; +import { constantEqual, identity, throttle, type MiniSession } from './security'; +import { hostLock, rows, transaction } from './store'; + +interface Selection { + session: string; + room: string; + nonce: string; + expires: number; + source: PartyTrack; + candidate: PartyCandidate; + confidence: PartyConfidence; +} + +export const SELECTION_TOKEN_LIMIT = 30000; + +function sign(selection: Selection): string { + const payload = Buffer.from(JSON.stringify(selection)).toString('base64url'); + const token = `${payload}.${identity(`party-selection-v1:${payload}`)}`; + if (token.length > SELECTION_TOKEN_LIMIT) + throw new PartyError(503, 'catalog_metadata_too_large', 'The catalog returned too much song metadata. Try another song link.'); + return token; +} + +function verify(token: string, who: MiniSession, roomId: string): Selection { + const [payload, signature, extra] = token.split('.'); + if (!payload || !signature || extra !== undefined || + !constantEqual(signature, identity(`party-selection-v1:${payload}`))) { + throw new PartyError(400, 'invalid_candidate', 'Search again and choose one of your results.'); + } + // Only server-issued, authenticated payloads reach JSON parsing. + const selection: Selection = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); + if (selection.session !== who.id || selection.room !== roomId) + throw new PartyError(403, 'invalid_candidate', 'This result belongs to another session or party.'); + if (selection.expires <= Date.now()) + throw new PartyError(410, 'search_expired', 'This search expired. Search for the song again.'); + return selection; +} + +export async function searchSongs(who: MiniSession, roomId: string, url: string): Promise { + const link = parseSongLink(url); + const room = await getRoom(roomId); + requireLive(room); + const participant = await requireMember(room, who); + await throttle(`search:${roomId}:${participant}`, 6, 60); + await throttle(`search-room:${roomId}`, 16, 60); + return hostLock(room.account_key, async (client) => { + const fresh = await getRoom(roomId, client); + requireLive(fresh); + await requireMember(fresh, who, client); + if (fresh.status !== 'open') + throw new PartyError(409, 'room_locked', 'The host has paused new requests.'); + const token = await accessToken(await hostFor(fresh.owner, client), client); + const match = await resolveSong(link.url, token, spotify()); + requireLive(await getRoom(roomId, client)); + const expires = Math.min(Date.now() + 5 * 60_000, fresh.expires_at.getTime(), who.expires_at.getTime()); + if (expires <= Date.now()) + throw new PartyError(401, 'session_expired', 'Your Party session expired. Reopen the Mini App in Telegram.'); + const nonce = randomUUID(); + return { + expiresAt: new Date(expires).toISOString(), + candidates: match.candidates.map(candidate => ({ + ...candidate, + selectionToken: sign({ + session: who.id, room: roomId, nonce, expires, + source: match.source, candidate, confidence: match.confidence, + }), + })), + }; + }); +} + +export async function selectSong(who: MiniSession, roomId: string, token: string): Promise<{ id: string }> { + const selection = verify(token, who, roomId); + const room = await getRoom(roomId); + const participant = await requireMember(room, who); + await throttle(`select:${roomId}:${participant}`, 8, 60); + await throttle(`select-room:${roomId}`, 80, 60); + return hostLock(room.account_key, client => transaction(async tx => { + const fresh = await getRoom(roomId, tx); + requireLive(fresh); + await requireMember(fresh, who, tx); + verify(token, who, roomId); + const [existing] = await rows( + 'SELECT * FROM party_requests WHERE room_id=$1 AND participant=$2 AND submission_key=$3', + [roomId, participant, selection.nonce], tx, + ); + if (existing) { + if (existing.source_url !== selection.source.url || existing.selected?.id !== selection.candidate.id) + throw new PartyError(409, 'submission_conflict', 'A different result from this search was already selected.'); + return { id: existing.id }; + } + if (fresh.status !== 'open') + throw new PartyError(409, 'room_locked', 'The host has paused new requests.'); + const id = randomUUID(); + // Explicit submitter confirmation bypasses legacy host approval, never delivery safeguards. + await tx.query( + `INSERT INTO party_requests + (id,room_id,participant,submission_key,display_name,source_url,source,selected,confidence,status,approved_order) + VALUES ($1,$2,$3,$4,'Guest',$5,$6,$7,$8,'approved',nextval('party_approval_order'))`, + [id, roomId, participant, selection.nonce, selection.source.url, selection.source, selection.candidate, selection.confidence], + ); + await tx.query( + 'INSERT INTO party_match_candidates (request_id,track_id,metadata) VALUES ($1,$2,$3)', + [id, selection.candidate.id, selection.candidate], + ); + await createJob(tx, roomId, id, 'deliver'); + return { id }; + }, client)); +} diff --git a/projects/api/src/party/routes.ts b/projects/api/src/party/routes.ts index 0503138..3869d5f 100644 --- a/projects/api/src/party/routes.ts +++ b/projects/api/src/party/routes.ts @@ -41,6 +41,7 @@ import { type MiniSession, } from './security'; import { rows } from './store'; +import { searchSongs, selectSong, SELECTION_TOKEN_LIMIT } from './resolution'; const log = createLogger('party'); function field(req: Request, key: string, max = 2048): string { @@ -162,7 +163,7 @@ partyRoutes.use((_req, _res, next) => { checkPrerequisites(); next(); }); -partyRoutes.use(express.json({ limit: '12kb' })); +partyRoutes.use(express.json({ limit: '32kb' })); partyRoutes.get('/auth/launch', launchAuthorization); partyRoutes.get('/auth/callback', finishAuthorization); partyRoutes.post('/session', async (req, res) => { @@ -238,6 +239,12 @@ partyRoutes.get('/rooms/:id/requests', async (req, res) => { ) ); }); +partyRoutes.post('/rooms/:id/search', async (req, res) => { + res.json(await searchSongs(who(res), param(req, 'id'), field(req, 'url'))); +}); +partyRoutes.post('/rooms/:id/selections', async (req, res) => { + res.status(202).json(await selectSong(who(res), param(req, 'id'), field(req, 'selectionToken', SELECTION_TOKEN_LIMIT))); +}); partyRoutes.post('/rooms/:id/requests', async (req, res) => { res .status(202) diff --git a/projects/api/tests/party.integration.test.mjs b/projects/api/tests/party.integration.test.mjs index cd1b27b..e2f7058 100644 --- a/projects/api/tests/party.integration.test.mjs +++ b/projects/api/tests/party.integration.test.mjs @@ -1025,3 +1025,183 @@ test('iTunes retry is durable; missing ISRC needs host choice in auto rooms; out assert.equal(queueCalls, before + 1); await call(`/rooms/${active.id}/action`, host, { action: 'close' }); }); + +test('read-only room search and explicit submitter selection preserve authorization, idempotency and delivery', async t => { + await store.rows('DELETE FROM party_throttles'); + await authorize(host); + const created = await makeRoom(host); + const active = created.room; + assert.equal(active.mode, 'host_approval', 'new selections must also bypass legacy routine approval'); + const secret = new URL(created.inviteUrl).searchParams.get('startapp').slice(2); + await call('/join', member, { secret }); + const path = `/rooms/${active.id}`; + const url = `https://open.spotify.com/track/${trackId}`; + let trackCalls = 0; + let spotifyFailure; + t.mock.method(SpotifyClient.prototype, 'track', async () => { + trackCalls++; + if (spotifyFailure) throw spotifyFailure; + return track; + }); + t.mock.method(SpotifyClient.prototype, 'enqueue', async () => { queueCalls++; }); + const count = async table => (await store.rows(`SELECT count(*)::int AS count FROM ${table}`))[0].count; + const requestsBefore = await count('party_requests'); + const jobsBefore = await count('party_jobs'); + const queuedBefore = queueCalls; + assert.equal((await call(`${path}/search`, null, { url })).response.status, 401); + assert.equal((await call(`${path}/search`, outsider, { url })).response.status, 403); + assert.equal((await call(`${path}/search`, member, { url }, { 'X-Party-CSRF': 'bad' })).response.status, 403); + assert.equal((await call(`${path}/search`, member, { url }, { Origin: 'https://evil.test' })).response.status, 403); + for (const value of ['', 'https://open.spotify.com/track/partial', 'https://music.apple.com/us/album/123', 'http://localhost/']) { + assert.equal((await call(`${path}/search`, member, { url: value })).response.status, 400); + } + assert.equal(trackCalls, 0); + const found = await call(`${path}/search`, member, { url }); + assert.equal(found.response.status, 200, JSON.stringify(found.data)); + assert.equal(found.data.candidates.length, 1); + const candidate = found.data.candidates[0]; + assert.equal(candidate.id, trackId); + assert.equal(await count('party_requests'), requestsBefore); + assert.equal(await count('party_jobs'), jobsBefore); + assert.equal(queueCalls, queuedBefore); + const selection = { selectionToken: candidate.selectionToken }; + const [payload, signature] = candidate.selectionToken.split('.'); + const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString()); + assert.equal(decoded.source.url, url); + assert.equal(JSON.stringify(decoded).includes('accessToken'), false); + const tamper = changed => `${Buffer.from(JSON.stringify({ ...decoded, ...changed })).toString('base64url')}.${signature}`; + for (const selectionToken of [ + tamper({ candidate: { ...decoded.candidate, id: 'ZZZZZZZZZZZZZZZZZZZZZZ' } }), + tamper({ source: { ...decoded.source, url: 'https://evil.test/' } }), + tamper({ expires: Date.now() + 86_400_000 }), + `${candidate.selectionToken}.extra`, + ]) { + assert.equal((await call(`${path}/selections`, member, { selectionToken })).data.error.code, 'invalid_candidate'); + } + assert.equal((await call(`${path}/selections`, host, selection)).response.status, 403); + assert.equal((await call(`${path}/selections`, outsider, selection)).response.status, 403); + assert.equal((await call('/rooms/another-room/selections', member, selection)).response.status, 403); + assert.equal((await call(`${path}/selections`, member, selection, { 'X-Party-CSRF': '' })).response.status, 403); + const anotherSession = await guest(200); + await call('/join', anotherSession, { secret }); + assert.equal((await call(`${path}/selections`, anotherSession, selection)).response.status, 403, 'even same-principal sessions cannot share offers'); + const expiredPayload = Buffer.from(JSON.stringify({ ...decoded, expires: Date.now() - 1000 })).toString('base64url'); + const expiredSignature = createHmac('sha256', Buffer.from(process.env.PARTY_IDENTITY_KEY, 'hex')) + .update(`party-selection-v1:${expiredPayload}`).digest('hex'); + assert.equal((await call(`${path}/selections`, member, { selectionToken: `${expiredPayload}.${expiredSignature}` })).data.error.code, 'search_expired'); + await store.rows('DELETE FROM party_memberships WHERE room_id=$1 AND session_id=$2', [active.id, decoded.session]); + assert.equal((await call(`${path}/selections`, member, selection)).response.status, 403); + await call('/join', member, { secret }); + const selected = await Promise.all([call(`${path}/selections`, member, selection), call(`${path}/selections`, member, selection)]); + assert.ok(selected.some(response => response.response.status === 202)); + assert.ok(selected.every(response => response.response.status === 202 || response.data.error.code === 'host_busy')); + const id = selected.find(response => response.response.status === 202).data.id; + assert.equal((await call(`${path}/selections`, member, selection)).data.id, id); + assert.ok(selected.filter(response => response.response.status === 202).every(response => response.data.id === id)); + assert.equal(await count('party_requests'), requestsBefore + 1); + assert.equal(await count('party_jobs'), jobsBefore + 1); + assert.equal(queueCalls, queuedBefore, 'outbox creation is not success'); + const [request] = await store.rows('SELECT * FROM party_requests WHERE id=$1', [id]); + assert.equal(request.status, 'approved'); + assert.equal(request.selected.id, trackId); + assert.equal((await store.rows('SELECT kind FROM party_jobs WHERE request_id=$1', [id]))[0].kind, 'deliver'); + await runPending(id, 'deliver'); + await runPending(id, 'deliver'); + assert.equal(queueCalls, queuedBefore + 1); + assert.equal((await call(`${path}/requests`, member)).data.requests.find(item => item.id === id).status, 'added'); + assert.equal((await call(`${path}/requests/${id}/action`, member, { action: 'select', candidateId: trackId })).response.status, 403, 'legacy host-only controls stay host-only'); + + // Missing recording identity still offers choices, but never silently chooses one. + const originalFetch = globalThis.fetch; + let itunesStatus = 200; + let empty = false; + t.mock.method(globalThis, 'fetch', async (input, options) => { + const lookup = new URL(String(input)); + if (lookup.origin === base) return originalFetch(input, options); + assert.equal(lookup.toString(), 'https://itunes.apple.com/lookup?id=123456789&country=il'); + return itunesStatus === 200 ? Response.json({ + resultCount: empty ? 0 : 1, + results: empty ? [] : [{ wrapperType: 'track', kind: 'song', trackId: 123456789, trackName: track.name, + artistName: 'Artist', collectionName: 'Album', trackTimeMillis: track.duration_ms, trackExplicitness: 'notExplicit' }], + }) : new Response('', { status: itunesStatus, headers: { 'Retry-After': '75' } }); + }); + const alternate = { ...track, id: 'ZZZZZZZZZZZZZZZZZZZZZZ' }; + let searchResults = [track, alternate, { ...track, id: 'YYYYYYYYYYYYYYYYYYYYYY', name: 'Test Song - Live' }]; + t.mock.method(SpotifyClient.prototype, 'search', async () => searchResults); + const apple = { url: 'https://music.apple.com/il/album/album/123456788?i=123456789' }; + const choices = await call(`${path}/search`, member, apple); + assert.equal(choices.response.status, 200, JSON.stringify(choices.data)); + assert.equal(choices.data.candidates.length, 2, 'contradictory live version must remain excluded'); + assert.equal(await count('party_requests'), requestsBefore + 1); + assert.equal(await count('party_jobs'), jobsBefore + 1); + const choice = choices.data.candidates[0]; + const chosen = await call(`${path}/selections`, member, { selectionToken: choice.selectionToken }); + assert.equal(chosen.response.status, 202, JSON.stringify(chosen.data)); + assert.equal((await call(`${path}/selections`, member, { selectionToken: choices.data.candidates[1].selectionToken })).data.error.code, 'submission_conflict'); + await runPending(chosen.data.id, 'deliver'); + assert.equal(queueCalls, queuedBefore + 2); + + await store.rows('DELETE FROM party_throttles'); + for (const status of [429, 503]) { + itunesStatus = status; + const failure = await call(`${path}/search`, member, apple); + assert.equal(failure.response.status, status); + assert.equal(failure.data.candidates, undefined, 'provider errors are never Not found'); + if (status === 429) assert.equal(failure.response.headers.get('retry-after'), '75'); + } + itunesStatus = 200; + empty = true; + assert.deepEqual((await call(`${path}/search`, member, apple)).data.candidates, []); + empty = false; + searchResults = []; + assert.deepEqual((await call(`${path}/search`, member, apple)).data.candidates, []); + spotifyFailure = new SpotifyError('rate_limited', 429, 30); + const limited = await call(`${path}/search`, member, { url }); + assert.equal(limited.response.status, 429, JSON.stringify(limited.data)); + spotifyFailure = undefined; + await call(`${path}/search`, member, { url }); + const beforeThrottled = trackCalls; + assert.equal((await call(`${path}/search`, member, { url })).response.status, 429); + assert.equal(trackCalls, beforeThrottled, 'search throttle must run before providers'); + + await store.rows('DELETE FROM party_throttles'); + spotifyFailure = new SpotifyError('unauthorized', 401); + const unauthorized = await call(`${path}/search`, member, { url }); + assert.equal(unauthorized.response.status, 401); + assert.equal(unauthorized.data.error.code, 'unauthorized'); + assert.equal(unauthorized.data.candidates, undefined); + spotifyFailure = new SpotifyError('network'); + assert.equal((await call(`${path}/search`, member, { url })).response.status, 503); + spotifyFailure = undefined; + const hostResult = await call(`${path}/search`, host, { url }); + assert.equal(hostResult.response.status, 200); + await call(`${path}/action`, host, { action: 'lock' }); + assert.equal((await call(`${path}/selections`, host, { selectionToken: hostResult.data.candidates[0].selectionToken })).data.error.code, 'room_locked'); + assert.equal((await call(`${path}/search`, host, { url })).data.error.code, 'room_locked'); + await call(`${path}/action`, host, { action: 'unlock' }); + const hostSelected = await call(`${path}/selections`, host, { selectionToken: hostResult.data.candidates[0].selectionToken }); + assert.equal(hostSelected.response.status, 202); + await runPending(hostSelected.data.id, 'deliver'); + assert.equal(queueCalls, queuedBefore + 3); + // A new intentional search permits the same song again; its saved metadata is still revalidated. + const repeat = await call(`${path}/search`, member, { url }); + const repeated = await call(`${path}/selections`, member, { selectionToken: repeat.data.candidates[0].selectionToken }); + assert.notEqual(repeated.data.id, id); + const changedTrack = { ...track, name: 'Test Song - Live' }; + SpotifyClient.prototype.track.mock.mockImplementation(async () => changedTrack); + await runPending(repeated.data.id, 'deliver'); + assert.equal(queueCalls, queuedBefore + 3, 'recording drift cannot send even an explicitly selected song'); + assert.equal((await store.rows('SELECT failure_code FROM party_requests WHERE id=$1', [repeated.data.id]))[0].failure_code, 'recording_changed'); + SpotifyClient.prototype.track.mock.mockImplementation(async () => track); + const unsent = await call(`${path}/search`, host, { url }); + const pending = await call(`${path}/selections`, host, { selectionToken: unsent.data.candidates[0].selectionToken }); + assert.equal(pending.response.status, 202); + await call(`${path}/action`, host, { action: 'close' }); + await runPending(pending.data.id, 'deliver'); + assert.equal((await call(`${path}/selections`, member, selection)).data.error.code, 'room_closed'); + assert.equal((await call(`${path}/search`, member, { url })).data.error.code, 'room_closed'); + assert.equal(queueCalls, queuedBefore + 3); + await store.rows("UPDATE party_rooms SET expires_at=now()-interval '1 second' WHERE id=$1", [active.id]); + assert.equal((await call(`${path}/search`, member, { url })).data.error.code, 'room_expired'); + assert.equal((await call(`${path}/selections`, member, selection)).data.error.code, 'room_expired'); +}); diff --git a/projects/mini-app/README.md b/projects/mini-app/README.md index dbf9d0c..0120cb3 100644 --- a/projects/mini-app/README.md +++ b/projects/mini-app/README.md @@ -35,6 +35,22 @@ closure/expiry or a terminal authorization/membership error. QR invitations are generated locally with `qrcode`, not through a third-party service. Guest receipt visibility and all host permissions are enforced by the API. +Host and guest song inputs search complete links after a 500 ms debounce. +Apple Music links resolve through exact-ID/storefront iTunes lookup and then +Spotify search; Spotify links resolve directly. Search never queues a song. +Clicking a result card is the only confirmation and creates a durable addition, +without another Add button, modal or routine host approval. `SongCard` supplies +identical artwork, title, artist and album markup for results and list rows. +Results expire after at most five minutes; edits and navigation invalidate old +responses. An empty completed current search shows **Not found** once, while +provider/auth/rate-limit failures remain errors with explicit retry. + +The existing toast provider shows **Added to the queue** only for a selected +request whose polled status confirms Spotify accepted it, never for search or +the initial 202 response. Pending requests show progress; unknown outcomes +retain their warning and host recovery safeguards. Old/other users' receipts +and reloads never replay success toasts. + Validation: ```sh @@ -49,6 +65,10 @@ Library boundary regressions, changed-feed merging, backoff, signed bootstrap, cookie/CSRF requests, and non-replayed writes. Real Telegram WebView cookies, external OAuth return, active-playback targeting, and actual Spotify delivery still require an authorized integration pilot. +`song-search.test.mjs` uses development-only JSDOM and Vite's TSX loader to test +real React input/click/effect behavior with controlled timers, including stale +responses and confirmed-only toasts. It requires the shared workspace build. +This is DOM-only coverage, not a replacement for browser layout/keyboard checks. On re-entry, Party first restores `GET /api/party/session`; only a 401 triggers signed-launch bootstrap. Sessions last one hour, while new signed launches @@ -152,8 +172,13 @@ With `VITE_PARTY_PREVIEW=true` on the Vite development server, open components with sample songs. The preview toolbar switches to Guest view and Start screen (`demo=start`; old `demo=setup` links also open this screen). The sample Start party button opens the host room directly, simulating consent -without a device picker. Guests only paste a song link and choose Add song; the demo shows -it added immediately with no approval or display-name form. There is no room +without a device picker. Both hosts and guests paste a complete song link and +click the resulting song card; no song appears in the list merely from typing. +Queue acceptance is simulated after a short delay, rather than claiming success +on click. The **Search / delivery sample** control exposes matching, multiple +versions, not found, rate limited, failed, unknown and pending scenarios in both +views. For example, paste `https://music.apple.com/us/song/123456789`. +There is no approval or display-name form and no room settings panel. Start party and End party update local sample state. Reset samples restores the example songs. diff --git a/projects/mini-app/dev/PartyPreview.tsx b/projects/mini-app/dev/PartyPreview.tsx index d9d6d67..4100067 100644 --- a/projects/mini-app/dev/PartyPreview.tsx +++ b/projects/mini-app/dev/PartyPreview.tsx @@ -1,11 +1,13 @@ import { useState } from 'react'; import { Room } from '../src/features/party/Room'; -import { PartyDemoClient } from './party-demo'; +import { PartyDemoClient, type DemoScenario } from './party-demo'; type View = 'host' | 'guest' | 'start'; -function DemoRoom({ isHost, onReconnect }: { isHost: boolean; onReconnect: () => void }) { - const [client] = useState(() => new PartyDemoClient(isHost)); +const scenarios: DemoScenario[] = ['match', 'multiple', 'not_found', 'rate_limited', 'failed', 'unknown', 'pending']; + +function DemoRoom({ isHost, scenario, onReconnect }: { isHost: boolean; scenario: DemoScenario; onReconnect: () => void }) { + const [client] = useState(() => new PartyDemoClient(isHost, scenario)); const [room, setRoom] = useState(client.room); return ; @@ -16,6 +18,7 @@ export default function PartyPreview() { const [view, setView] = useState(initial === 'guest' ? 'guest' : initial === 'start' || initial === 'setup' ? 'start' : 'host'); const [reset, setReset] = useState(0); const [premium, setPremium] = useState(false); + const [scenario, setScenario] = useState('match'); function show(next: View) { setView(next); @@ -37,6 +40,16 @@ export default function PartyPreview() { + {view !== 'start' && <> + + +

Paste a complete song link, for example https://music.apple.com/us/song/123456789. Results and queue acceptance are simulated; no provider is contacted.

+ } {view === 'start' ? (
@@ -44,7 +57,7 @@ export default function PartyPreview() {
- ) : show('start')} />} + ) : show('start')} />} ); diff --git a/projects/mini-app/dev/party-demo.ts b/projects/mini-app/dev/party-demo.ts index 950a0fb..2b2c6fd 100644 --- a/projects/mini-app/dev/party-demo.ts +++ b/projects/mini-app/dev/party-demo.ts @@ -1,4 +1,4 @@ -import type { PartyCandidate, PartyRequestDto, PartyRoomDto } from '@brewtify/shared'; +import { parsePartySongLink, type PartyCandidate, type PartyRequestDto, type PartyRoomDto } from '@brewtify/shared'; import { PartyClient, PartyError } from '../src/features/party/api.ts'; function artwork(color: string, accent: string): string { @@ -22,13 +22,19 @@ function sampleRequests(): PartyRequestDto[] { } // This transport never calls fetch or delegates to the authenticated Party client. +export type DemoScenario = 'match' | 'multiple' | 'not_found' | 'rate_limited' | 'failed' | 'unknown' | 'pending'; + export class PartyDemoClient extends PartyClient { room: PartyRoomDto; private requests = sampleRequests(); private revision = 1; + private offers = new Map(); + private delivery = new Map(); + private scenario: DemoScenario; - constructor(isHost: boolean) { + constructor(isHost: boolean, scenario: DemoScenario = 'match') { super(); + this.scenario = scenario; this.room = { id: 'demo-room', status: 'open', mode: 'auto', expiresAt: new Date(Date.now() + 12 * 3600_000).toISOString(), @@ -44,20 +50,46 @@ export class PartyDemoClient extends PartyClient { if (route === '/rooms/demo-room/invite' && body === undefined) { result = { inviteUrl: 'https://example.invalid/party-demo-not-a-real-invitation' }; } else if (route === '/rooms/demo-room/requests' && body === undefined) { + for (const request of this.requests) { + if (request.status !== 'approved' || Date.now() < (this.delivery.get(request.id) ?? Infinity) || this.scenario === 'pending') continue; + request.status = this.scenario === 'failed' || this.scenario === 'unknown' ? 'failed' : 'added'; + request.failureCode = this.scenario === 'unknown' ? 'delivery_unknown' : this.scenario === 'failed' ? 'device_unavailable' : null; + request.updatedAt = new Date().toISOString(); + this.revision++; + } result = { room: this.room, requests: this.room.isHost ? this.requests : this.requests.filter(request => request.displayName === 'You'), nextCursor: String(this.revision) }; - } else if (route === '/rooms/demo-room/requests' && body !== undefined) { + } else if (route === '/rooms/demo-room/search' && body !== undefined) { + if (this.room.status !== 'open') throw new PartyError('This demo party is not accepting songs.'); + if (typeof input.url !== 'string') throw new PartyError('Paste a song link.'); + const link = parsePartySongLink(input.url); + await new Promise(resolve => setTimeout(resolve, 250)); + signal?.throwIfAborted(); + if (this.scenario === 'rate_limited') throw new PartyError('Demo rate limit. Try again shortly.', 'rate_limited', 429, 2000); + const candidates = this.scenario === 'not_found' ? [] : this.scenario === 'multiple' + ? [tracks[0], { ...tracks[0], id: 'demo-afterglow-single', album: 'Afterglow - Single' }] + : [tracks[0]]; + const expires = Date.now() + 5 * 60_000; + result = { expiresAt: new Date(expires).toISOString(), candidates: candidates.map(candidate => { + const selectionToken = crypto.randomUUID(); + this.offers.set(selectionToken, { candidate, url: link.url, expires }); + return { ...candidate, selectionToken }; + }) }; + } else if (route === '/rooms/demo-room/selections' && body !== undefined) { if (this.room.status !== 'open') throw new PartyError('This demo party is not accepting songs.'); - if (typeof input.url !== 'string' || !/^https:\/\/(open\.spotify\.com|music\.apple\.com)\//.test(input.url)) { - throw new PartyError('Paste a Spotify or Apple Music song link. Demo mode never looks it up.'); + const offer = typeof input.selectionToken === 'string' && this.offers.get(input.selectionToken); + if (!offer || offer.expires <= Date.now()) throw new PartyError('Search again and choose a sample result.'); + if (offer.id) result = { id: offer.id }; + else { + const id = crypto.randomUUID(); + offer.id = id; + this.requests.push({ + id, displayName: 'You', sourceUrl: offer.url, selected: offer.candidate, + candidates: [], confidence: 'ambiguous', status: 'approved', failureCode: null, + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + }); + this.delivery.set(id, Date.now() + 1500); + result = { id }; } - const id = crypto.randomUUID(); - this.requests.push({ - id, displayName: 'You', - sourceUrl: input.url, selected: { ...tracks[0], title: 'Your sample song', artist: 'Demo catalog - not looked up' }, - candidates: [], confidence: 'exact', status: 'added', failureCode: null, - createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), - }); - result = { id }; } else if (route === '/rooms/demo-room/action' && body !== undefined && this.room.isHost) { if (input.action === 'lock') this.room.status = 'locked'; else if (input.action === 'unlock') this.room.status = 'open'; diff --git a/projects/mini-app/package.json b/projects/mini-app/package.json index 9b3a266..62717ba 100644 --- a/projects/mini-app/package.json +++ b/projects/mini-app/package.json @@ -31,6 +31,7 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", + "jsdom": "^26.1.0", "typescript": "~6.0.2", "typescript-eslint": "^8.59.2", "vite": "^8.0.12" diff --git a/projects/mini-app/src/features/party/RequestCard.tsx b/projects/mini-app/src/features/party/RequestCard.tsx index 1df0726..d5e7c6a 100644 --- a/projects/mini-app/src/features/party/RequestCard.tsx +++ b/projects/mini-app/src/features/party/RequestCard.tsx @@ -1,5 +1,6 @@ import type { PartyRequestDto } from '@brewtify/shared'; import { partyFailureMessage } from './messages'; +import { SongCard } from './SongCard'; function formatDuration(ms: number) { return `${Math.floor(ms / 60_000)}:${String(Math.floor(ms / 1000) % 60).padStart(2, '0')}`; @@ -29,15 +30,8 @@ export function RequestCard({ request, isHost, actionable, busy, onAction }: { const unknown = request.failureCode === 'delivery_unknown'; const reviewable = ['matched', 'needs_review'].includes(request.status); return ( -
-
- {track?.artwork ? :
+ + {reviewable && request.source && request.selected &&

Original: {request.source.title} — {request.source.artist}

} {(request.status !== 'added' || unknown) &&

{unknown ? 'Outcome unknown · this song may already be in the Spotify queue' : statusText[request.status]}

} {request.failureCode && !unknown &&

{partyFailureMessage(request.failureCode)}

} {isHost && actionable && (reviewable || request.status === 'failed') && ( @@ -64,6 +58,6 @@ export function RequestCard({ request, isHost, actionable, busy, onAction }: { )} -
+ ); } diff --git a/projects/mini-app/src/features/party/Room.tsx b/projects/mini-app/src/features/party/Room.tsx index e8739c6..0c7b265 100644 --- a/projects/mini-app/src/features/party/Room.tsx +++ b/projects/mini-app/src/features/party/Room.tsx @@ -7,6 +7,7 @@ import { RequestCard } from './RequestCard'; import type { RequestAction } from './RequestCard'; import { usePolling } from './usePolling'; import { partyFailureMessage } from './messages'; +import { SongSearch } from './SongSearch'; export function Room({ client, initialRoom, onRoomChange, onReconnect }: { client: PartyClient; @@ -21,13 +22,10 @@ export function Room({ client, initialRoom, onRoomChange, onReconnect }: { const [pollError, setPollError] = useState(''); const [terminalStatus, setTerminalStatus] = useState<'closed' | 'expired' | null>(null); const [busy, setBusy] = useState(false); - const [url, setUrl] = useState(''); - const [receipt, setReceipt] = useState(''); const [inviteUrl, setInviteUrl] = useState(''); const [qr, setQr] = useState(''); const [copied, setCopied] = useState(false); const cursor = useRef(null); - const submission = useRef<{ signature: string; key: string } | null>(null); const path = `/rooms/${encodeURIComponent(initialRoom.id)}`; const status = terminalStatus ?? room.status; const inactive = status === 'closed' || status === 'expired'; @@ -37,6 +35,7 @@ export function Room({ client, initialRoom, onRoomChange, onReconnect }: { const readFeed = useCallback(async (signal?: AbortSignal) => { const query = cursor.current ? `?cursor=${encodeURIComponent(cursor.current)}` : ''; const feed = await client.request(`${path}/requests${query}`, undefined, signal); + if (signal?.aborted) return { stop: true }; setRoom(feed.room); roomChangeRef.current(feed.room); setRequests((current) => mergeFeed(current, feed.requests)); @@ -94,20 +93,6 @@ export function Room({ client, initialRoom, onRoomChange, onReconnect }: { }); } - async function submit() { - const data = { url: url.trim() }; - const signature = JSON.stringify(data); - if (!submission.current || submission.current.signature !== signature) submission.current = { signature, key: crypto.randomUUID() }; - const submissionKey = submission.current.key; - await run(async () => { - await client.request(`${path}/requests`, { ...data, submissionKey }); - setUrl(''); - submission.current = null; - setReceipt('Song submitted.'); - await readFeed(); - }); - } - async function copyInvite() { try { await navigator.clipboard.writeText(inviteUrl); @@ -160,13 +145,7 @@ export function Room({ client, initialRoom, onRoomChange, onReconnect }: { )} {!inactive && ( -
{ event.preventDefault(); void submit(); }}> -

Add a song

- - { setUrl(event.target.value); setReceipt(''); }} required maxLength={2048} autoComplete="off" placeholder="Paste a Spotify or Apple Music link" disabled={room.status !== 'open' || busy} /> - - {receipt &&

{receipt}

} -
+ )}

{room.isHost ? 'Party songs' : 'Your songs'}

diff --git a/projects/mini-app/src/features/party/SongCard.tsx b/projects/mini-app/src/features/party/SongCard.tsx new file mode 100644 index 0000000..4c744bb --- /dev/null +++ b/projects/mini-app/src/features/party/SongCard.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from 'react'; +import type { PartyTrack } from '@brewtify/shared'; + +export function SongCard({ track, children, onSelect, disabled }: { + track?: PartyTrack; + children?: ReactNode; + onSelect?: () => void; + disabled?: boolean; +}) { + const content = ( + <> +
+ {track?.artwork ? :
+ {children} + + ); + return onSelect + ? + :
{content}
; +} diff --git a/projects/mini-app/src/features/party/SongSearch.tsx b/projects/mini-app/src/features/party/SongSearch.tsx new file mode 100644 index 0000000..3ffe6d7 --- /dev/null +++ b/projects/mini-app/src/features/party/SongSearch.tsx @@ -0,0 +1,152 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { InvalidSongLink, parsePartySongLink, type PartyRequestDto, type PartySearchResult } from '@brewtify/shared'; +import { useToast } from '../../hooks/useToast'; +import { errorText, PartyClient, PartyError } from './api'; +import { partyFailureMessage } from './messages'; +import { SongCard } from './SongCard'; + +function expired(expiresAt: string) { + return Date.parse(expiresAt) <= Date.now(); +} + +export function SongSearch({ client, path, enabled, requests }: { + client: PartyClient; + path: string; + enabled: boolean; + requests: PartyRequestDto[]; +}) { + const { showToast } = useToast(); + const [url, setUrl] = useState(''); + const [retry, setRetry] = useState(0); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + const [adding, setAdding] = useState(false); + const [error, setError] = useState(''); + const [retryAt, setRetryAt] = useState(0); + const [waiting, setWaiting] = useState(false); + const generation = useRef(0); + const controller = useRef(null); + const selecting = useRef(false); + const receipt = useRef(null); + + let link = ''; + try { link = parsePartySongLink(url).url; } catch (failure) { + if (!(failure instanceof InvalidSongLink)) throw failure; + } + + useEffect(() => { + const version = ++generation.current; + const abort = new AbortController(); + controller.current = abort; + let expiration: ReturnType | undefined; + const timer = setTimeout(async () => { + if (!enabled || !link || selecting.current) return; + setLoading(true); + setError(''); + try { + const found = await client.request(`${path}/search`, { url: link }, abort.signal); + if (abort.signal.aborted || generation.current !== version) return; + setResult(found); + if (!found.candidates.length) showToast('Not found', 'info'); + else expiration = setTimeout(() => { + if (generation.current !== version || selecting.current) return; + setResult(null); + setError('This search expired. Search for the song again.'); + }, Math.max(0, Date.parse(found.expiresAt) - Date.now())); + } catch (failure) { + if (abort.signal.aborted || generation.current !== version) return; + setError(errorText(failure)); + setRetryAt(Date.now() + (failure instanceof PartyError ? failure.retryAfterMs : 0)); + setWaiting(failure instanceof PartyError && failure.retryAfterMs > 0); + showToast(errorText(failure), 'error'); + } finally { + if (!abort.signal.aborted && generation.current === version) setLoading(false); + } + }, 500); + return () => { + abort.abort(); + clearTimeout(timer); + clearTimeout(expiration); + }; + }, [client, path, enabled, link, retry, showToast]); + + useEffect(() => { + if (!error || retryAt <= Date.now()) return; + const timer = setTimeout(() => setWaiting(false), retryAt - Date.now()); + return () => clearTimeout(timer); + }, [error, retryAt]); + + useEffect(() => { + const request = requests.find(item => item.id === receipt.current); + if (!request || !['added', 'failed', 'rejected', 'unavailable', 'needs_review', 'matched'].includes(request.status)) return; + receipt.current = null; + selecting.current = false; + setAdding(false); + if (request.status === 'added') showToast('Added to the queue', 'success'); + else showToast(request.failureCode ? partyFailureMessage(request.failureCode) + : request.status === 'unavailable' ? 'Not found' + : request.status === 'rejected' ? 'This song was not added.' + : 'This song needs host attention before it can be added.', 'error'); + }, [requests, showToast]); + + function change(value: string) { + // Invalidate immediately, before the next render/effect can run. + ++generation.current; + controller.current?.abort(); + receipt.current = null; + setUrl(value); + setResult(null); + setLoading(false); + setError(''); + setWaiting(false); + setRetryAt(0); + setRetry(value => value + 1); + } + + const select = useCallback(async (candidate: PartySearchResult['candidates'][number]) => { + if (selecting.current || waiting || !enabled || controller.current?.signal.aborted || !result || !result.candidates.includes(candidate)) return; + if (expired(result.expiresAt)) { + setResult(null); + setError('This search expired. Search for the song again.'); + return; + } + const version = generation.current; + const signal = controller.current?.signal; + selecting.current = true; + setAdding(true); + setError(''); + try { + const response = await client.request<{ id: string }>(`${path}/selections`, { selectionToken: candidate.selectionToken }, signal); + if (signal?.aborted || version !== generation.current) return; + receipt.current = response.id; + setResult(null); + setUrl(''); + } catch (failure) { + if (signal?.aborted || version !== generation.current) return; + selecting.current = false; + setAdding(false); + const message = failure instanceof PartyError && failure.status === 0 + ? `${errorText(failure)} The request may already exist. Tap the same result to check without adding it twice.` + : errorText(failure); + setError(message); + setRetryAt(Date.now() + (failure instanceof PartyError ? failure.retryAfterMs : 0)); + setWaiting(failure instanceof PartyError && failure.retryAfterMs > 0); + showToast(message, 'error'); + } + }, [client, path, enabled, result, waiting, showToast]); + + return ( +
event.preventDefault()}> +

Add a song

+ + change(event.target.value)} maxLength={2048} autoComplete="off" placeholder="Paste a Spotify or Apple Music link" disabled={!enabled || adding} /> + {loading &&

Searching…

} + {adding &&

Adding to Spotify…

} + {error &&
{error}
} + {error && !adding && link && } + {enabled && result &&
+ {result.candidates.map(candidate => void select(candidate)} />)} +
} +
+ ); +} diff --git a/projects/mini-app/src/features/party/api.ts b/projects/mini-app/src/features/party/api.ts index 5874cb5..b2c4d4c 100644 --- a/projects/mini-app/src/features/party/api.ts +++ b/projects/mini-app/src/features/party/api.ts @@ -68,6 +68,9 @@ export class PartyClient { } export function errorText(error: unknown): string { + if (error instanceof PartyError && error.code === 'itunes_rate_limited') { + return `iTunes Store is limiting song lookups. Wait ${Math.max(1, Math.ceil(error.retryAfterMs / 1000))} seconds, then search again.`; + } if (error instanceof PartyError && ( ['host_reconnect', 'unauthorized', 'premium_required', 'insufficient_scope', 'device_unavailable', 'delivery_settling'].includes(error.code) || error.code.startsWith('itunes_') @@ -79,7 +82,7 @@ export function errorText(error: unknown): string { } if (error instanceof PartyError && error.status === 429) { const seconds = Math.max(1, Math.ceil(error.retryAfterMs / 1000)); - return `Too many requests. Wait ${seconds} seconds before trying again. Updates will resume automatically.`; + return `Too many requests. Wait ${seconds} seconds before trying again.`; } return error instanceof Error ? error.message : 'Something went wrong. Please try again.'; } diff --git a/projects/mini-app/src/index.css b/projects/mini-app/src/index.css index 146d296..1c8e495 100644 --- a/projects/mini-app/src/index.css +++ b/projects/mini-app/src/index.css @@ -103,6 +103,8 @@ .party-track > div { min-width: 0; } .party-track img { height: 64px; width: 64px; flex: none; object-fit: cover; } .party-song-row { padding: 0.6rem; border-radius: 0.6rem; gap: 0.5rem; } +.party-page .party-song-choice { width: 100%; padding: 0.6rem; border-radius: 0.6rem; background: #1c1c1c; border-color: #383838; color: white; text-align: left; font-weight: inherit; } +.party-page .party-song-choice:not(:disabled):hover { background: #292929; } .party-song-row .party-track { align-items: center; gap: 0.65rem; } .party-song-row .party-track img, .party-artwork-placeholder { width: 48px; height: 48px; border-radius: 0.3rem; flex: none; } .party-artwork-placeholder { background: #303030; } diff --git a/projects/mini-app/tests/party-preview.test.mjs b/projects/mini-app/tests/party-preview.test.mjs index f274887..33999e3 100644 --- a/projects/mini-app/tests/party-preview.test.mjs +++ b/projects/mini-app/tests/party-preview.test.mjs @@ -63,11 +63,37 @@ test('interactive preview changes sample state without calling real APIs', async const guest = new PartyDemoClient(false); assert.equal((await guest.request('/rooms/demo-room/requests')).requests.length, 1); await assert.rejects(guest.request('/rooms/demo-room/action', { action: 'close' })); - await guest.request('/rooms/demo-room/requests', { url: 'https://open.spotify.com/track/demo' }); + const result = await guest.request('/rooms/demo-room/search', { url: 'https://music.apple.com/us/song/123456789' }); + assert.equal(result.candidates.length, 1); + assert.equal((await guest.request('/rooms/demo-room/requests')).requests.length, 1, 'search cannot enqueue'); + const selection = { selectionToken: result.candidates[0].selectionToken }; + const selected = await guest.request('/rooms/demo-room/selections', selection); + assert.deepEqual(await guest.request('/rooms/demo-room/selections', selection), selected); const after = await guest.request('/rooms/demo-room/requests'); assert.equal(after.requests.length, 2); - assert.equal(after.requests[1].status, 'added'); + assert.equal(after.requests[1].status, 'approved', 'click is not provider acceptance'); + await new Promise(resolve => setTimeout(resolve, 1510)); + assert.equal((await guest.request('/rooms/demo-room/requests')).requests[1].status, 'added'); } finally { globalThis.fetch = original; } }); + +test('demo exposes multiple choices, not found, rate errors and uncertain delivery without providers', async () => { + for (const scenario of ['multiple', 'not_found', 'rate_limited', 'failed', 'unknown', 'pending']) { + const client = new PartyDemoClient(false, scenario); + const search = client.request('/rooms/demo-room/search', { url: 'spotify:track:0123456789ABCDEFGHIJKL' }); + if (scenario === 'rate_limited') { + await assert.rejects(search, error => error.status === 429 && error.retryAfterMs > 0); + continue; + } + const result = await search; + assert.equal(result.candidates.length, scenario === 'multiple' ? 2 : scenario === 'not_found' ? 0 : 1); + if (!result.candidates.length) continue; + await client.request('/rooms/demo-room/selections', { selectionToken: result.candidates[0].selectionToken }); + await new Promise(resolve => setTimeout(resolve, 1510)); + const request = (await client.request('/rooms/demo-room/requests')).requests.at(-1); + assert.equal(request.status, scenario === 'multiple' ? 'added' : scenario === 'pending' ? 'approved' : 'failed'); + if (scenario === 'unknown') assert.equal(request.failureCode, 'delivery_unknown'); + } +}); diff --git a/projects/mini-app/tests/song-search.test.mjs b/projects/mini-app/tests/song-search.test.mjs new file mode 100644 index 0000000..7c579d0 --- /dev/null +++ b/projects/mini-app/tests/song-search.test.mjs @@ -0,0 +1,263 @@ +import { before, after, afterEach, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; +import { createServer } from 'vite'; +import react from '@vitejs/plugin-react'; +import { act, createElement as h } from 'react'; + +let vite, dom, createRoot, SongSearch, SongCard, ToastProvider, PartyError, root, container; +before(async () => { + dom = new JSDOM('', { url: 'http://localhost/app/' }); + globalThis.window = dom.window; + globalThis.document = dom.window.document; + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + ({ createRoot } = await import('react-dom/client')); + vite = await createServer({ + configFile: false, plugins: [react()], server: { middlewareMode: true }, appType: 'custom', + ssr: { external: ['@brewtify/shared'] }, optimizeDeps: { noDiscovery: true }, + cacheDir: 'node_modules/.vite-song-search-tests', + }); + ({ SongSearch } = await vite.ssrLoadModule('/src/features/party/SongSearch.tsx')); + ({ SongCard } = await vite.ssrLoadModule('/src/features/party/SongCard.tsx')); + ({ ToastProvider } = await vite.ssrLoadModule('/src/hooks/useToast.tsx')); + ({ PartyError } = await vite.ssrLoadModule('/src/features/party/api.ts')); +}); +afterEach(async () => { + if (root) await act(async () => root.unmount()); + container?.remove(); + root = undefined; +}); +after(async () => { + await vite?.close(); + dom?.window.close(); + delete globalThis.window; + delete globalThis.document; + delete globalThis.IS_REACT_ACT_ENVIRONMENT; +}); + +const link = 'https://open.spotify.com/track/0123456789ABCDEFGHIJKL'; +const other = 'https://music.apple.com/il/song/123456789'; +const candidate = { + id: '0123456789ABCDEFGHIJKL', title: 'Song', artist: 'Artist', album: 'Album', + durationMs: 180000, explicit: false, url: link, + artwork: 'https://i.scdn.co/image/test', evidence: ['direct_spotify_id'], selectionToken: 'signed-result', +}; +const result = (candidates = [candidate]) => ({ + candidates, expiresAt: new Date(Date.now() + 300000).toISOString(), +}); +const deferred = () => { + let resolve, reject; + const promise = new Promise((a, b) => { resolve = a; reject = b; }); + return { promise, resolve, reject }; +}; +async function mount(client) { + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + let props = { client, path: '/rooms/test', enabled: true, requests: [] }; + const render = async (changes = {}) => { + props = { ...props, ...changes }; + await act(async () => root.render(h(ToastProvider, null, h(SongSearch, { ...props, key: props.path })))); + }; + await render(); + return render; +} +async function input(value) { + await act(async () => { + const element = container.querySelector('input'); + Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, 'value').set.call(element, value); + element.dispatchEvent(new dom.window.Event('input', { bubbles: true })); + }); +} +async function tick(t, ms) { + await act(async () => t.mock.timers.tick(ms)); +} +const choice = () => container.querySelector('.party-song-choice'); +const toastCount = (text) => [...container.querySelectorAll('.animate-fade-in')].filter(element => element.textContent === text).length; +const receipt = (status, failureCode = null, id = 'own-request') => ({ + id, status, failureCode, selected: candidate, candidates: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), +}); + +test('typing debounces complete links, invalid input and Enter never submit, shared card is reused', async t => { + t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); + const calls = []; + await mount({ request: async (path, body) => { calls.push({ path, body }); return result(); } }); + for (const partial of ['', 'https:', 'https://open.spotify.com/track/partial', 'https://music.apple.com/us/album/123']) { + await input(partial); + await tick(t, 1000); + } + assert.equal(calls.length, 0); + await input(link); + await tick(t, 499); + assert.equal(calls.length, 0); + await tick(t, 1); + assert.deepEqual(calls, [{ path: '/rooms/test/search', body: { url: link } }]); + assert.equal(choice().textContent, 'SongArtist · Album'); + assert.equal(choice().type, 'button'); + assert.equal(choice().querySelector('img').getAttribute('src'), candidate.artwork); + await act(async () => container.querySelector('form').dispatchEvent(new dom.window.Event('submit', { bubbles: true, cancelable: true }))); + assert.equal(calls.length, 1, 'Enter is not a queue action'); + const trackMarkup = choice().querySelector('.party-track').outerHTML; + await act(async () => root.render(h(SongCard, { track: candidate }))); + assert.equal(container.querySelector('article .party-track').outerHTML, trackMarkup, 'list and result use identical track markup'); +}); + +test('out-of-order results, failures and no-match toasts are invalidated on typing, room change and unmount', async t => { + t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); + const calls = []; + const render = await mount({ request: (path, body, signal) => { + const response = deferred(); + calls.push({ path, body, signal, ...response }); + return response.promise; + } }); + await input(link); + await tick(t, 500); + assert.match(container.textContent, /Searching/); + await input(other); + assert.equal(calls[0].signal.aborted, true); + await tick(t, 500); + await act(async () => calls[1].resolve(result([{ ...candidate, title: 'Current song' }]))); + await act(async () => calls[0].resolve(result([]))); + assert.match(choice().textContent, /Current song/); + assert.equal(toastCount('Not found'), 0); + const staleButton = choice(); + await input('https:'); + await act(async () => staleButton.click()); + assert.equal(calls.length, 2, 'detached stale candidates cannot enqueue'); + assert.equal(choice(), null); + await input(link); + await tick(t, 500); + await render({ path: '/rooms/other' }); + assert.equal(calls[2].signal.aborted, true); + await act(async () => calls[2].reject(new Error('Stale outage'))); + assert.doesNotMatch(container.textContent, /Stale outage/); + await input(link); + await tick(t, 500); + await act(async () => root.unmount()); + root = undefined; + assert.equal(calls[3].signal.aborted, true); + await act(async () => calls[3].resolve(result([]))); + assert.equal(container.textContent, ''); +}); + +test('one click submits directly, disables duplicates and only own confirmed receipt toasts once', async t => { + t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); + const selected = deferred(); + const calls = []; + const render = await mount({ request: (path, body) => { + calls.push({ path, body }); + return path.endsWith('/search') ? Promise.resolve(result([candidate, { ...candidate, id: 'other', title: 'Other version' }])) : selected.promise; + } }); + await input(link); + await tick(t, 500); + assert.equal(container.querySelectorAll('.party-song-choice').length, 2); + assert.equal(calls.length, 1, 'multiple results never choose themselves'); + await act(async () => { choice().click(); choice().click(); }); + assert.equal(calls.length, 2); + assert.deepEqual(calls[1], { path: '/rooms/test/selections', body: { selectionToken: 'signed-result' } }); + assert.ok([...container.querySelectorAll('.party-song-choice')].every(button => button.disabled)); + assert.equal(toastCount('Added to the queue'), 0); + await act(async () => selected.resolve({ id: 'own-request' })); + assert.equal(container.querySelector('input').value, ''); + assert.equal(choice(), null); + await render({ requests: [receipt('approved'), receipt('added', null, 'someone-else')] }); + assert.equal(toastCount('Added to the queue'), 0); + assert.match(container.textContent, /Adding to Spotify/); + await render({ requests: [receipt('added')] }); + assert.equal(toastCount('Added to the queue'), 1); + assert.equal(container.querySelector('input').disabled, false); + await render({ requests: [receipt('added')] }); + assert.equal(toastCount('Added to the queue'), 1); + await tick(t, 4000); + await render({ requests: [receipt('added')] }); + assert.equal(toastCount('Added to the queue'), 0); + await render({ path: '/rooms/reloaded', requests: [receipt('added')] }); + assert.equal(toastCount('Added to the queue'), 0, 'history never replays success'); +}); + +test('no-match toasts once per completed search; rate/auth/network failures remain failures', async t => { + t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); + let failure; + let calls = 0; + const render = await mount({ request: async () => { + calls++; + if (failure) throw failure; + return result([]); + } }); + await input(link); + await tick(t, 500); + assert.equal(toastCount('Not found'), 1); + await render({ requests: [receipt('added', null, 'someone-else')] }); + await tick(t, 1000); + assert.equal(calls, 1); + assert.equal(toastCount('Not found'), 1); + await tick(t, 4000); + for (const error of [ + new PartyError('Busy', 'rate_limited', 429, 2000), + new PartyError('Sign in again', 'session_expired', 401), + new PartyError('Could not reach Party', 'network_error'), + ]) { + failure = error; + await input(other); + await tick(t, 500); + assert.equal(toastCount('Not found'), 0); + assert.ok(container.querySelector('[role=alert]')); + if (error.status === 429) { + assert.equal([...container.querySelectorAll('button')].find(button => button.textContent === 'Search again').disabled, true); + await tick(t, 2000); + assert.equal([...container.querySelectorAll('button')].find(button => button.textContent === 'Search again').disabled, false); + } + await input(''); + } +}); + +test('unknown and failed receipts never produce success, expired results cannot submit', async t => { + t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); + const render = await mount({ request: async path => path.endsWith('/search') ? result() : { id: 'own-request' } }); + await input(link); + await tick(t, 500); + await act(async () => choice().click()); + await render({ requests: [receipt('failed', 'delivery_unknown')] }); + assert.equal(toastCount('Added to the queue'), 0); + assert.match(container.textContent, /may already|unknown/i); + await render({ path: '/rooms/new', requests: [] }); + await input(link); + await tick(t, 500); + await act(async () => choice().click()); + await render({ requests: [receipt('failed', 'device_unavailable')] }); + assert.equal(toastCount('Added to the queue'), 0); + assert.match(container.textContent, /start playing music/); + await render({ path: '/rooms/expired-result', requests: [] }); + await input(link); + await tick(t, 500); + await tick(t, 300000); + assert.equal(choice(), null); + assert.match(container.textContent, /search expired/i); +}); + +test('uncertain submission retries reuse the same capability; navigation ignores old submission responses', async t => { + t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); + const replies = []; + const tokens = []; + const render = await mount({ request: (path, body, signal) => { + if (path.endsWith('/search')) return Promise.resolve(result()); + const response = deferred(); + replies.push({ ...response, signal }); + tokens.push(body.selectionToken); + return response.promise; + } }); + await input(link); + await tick(t, 500); + await act(async () => choice().click()); + await act(async () => replies[0].reject(new PartyError('Connection lost', 'network_error'))); + assert.equal(toastCount('Added to the queue'), 0); + assert.match(container.textContent, /may already exist/); + await act(async () => choice().click()); + assert.deepEqual(tokens, ['signed-result', 'signed-result']); + await render({ path: '/rooms/new' }); + assert.equal(replies[1].signal.aborted, true); + await act(async () => replies[1].resolve({ id: 'own-request' })); + await render({ requests: [receipt('added')] }); + assert.equal(toastCount('Added to the queue'), 0); + assert.equal(container.querySelector('input').disabled, false); +}); diff --git a/projects/shared/src/index.ts b/projects/shared/src/index.ts index fd57671..f872122 100644 --- a/projects/shared/src/index.ts +++ b/projects/shared/src/index.ts @@ -1,2 +1,3 @@ export * from './playlist-updater'; export * from './party'; +export * from './song-link'; diff --git a/projects/shared/src/party.ts b/projects/shared/src/party.ts index ee101b0..a4359ce 100644 --- a/projects/shared/src/party.ts +++ b/projects/shared/src/party.ts @@ -27,6 +27,11 @@ export interface PartyCandidate extends PartyTrack { evidence: string[]; } +export interface PartySearchResult { + candidates: (PartyCandidate & { selectionToken: string })[]; + expiresAt: string; +} + export interface PartyRequestDto { id: string; displayName: string; diff --git a/projects/shared/src/song-link.ts b/projects/shared/src/song-link.ts new file mode 100644 index 0000000..c72903a --- /dev/null +++ b/projects/shared/src/song-link.ts @@ -0,0 +1,45 @@ +export type SongLink = + | { provider: 'spotify'; id: string; url: string } + | { provider: 'apple_music'; id: string; storefront: string; url: string }; + +export class InvalidSongLink extends Error {} + +export function parsePartySongLink(input: string): SongLink { + const fail = (): never => { throw new InvalidSongLink('Invalid song link'); }; + if (typeof input !== 'string' || input.length > 2048) return fail(); + const text = input.trim(); + const uri = /^spotify:track:([A-Za-z0-9]{22})$/.exec(text); + if (uri) return { provider: 'spotify', id: uri[1], url: `https://open.spotify.com/track/${uri[1]}` }; + // Reject normalization of controls, backslashes and dot paths before parsing. + if (!text || /[\u0000-\u0020\u007f\\]/.test(text) || !/^https:\/\//i.test(text)) return fail(); + let url: URL; + try { url = new URL(text); } catch { return fail(); } + if (url.protocol !== 'https:' || url.username || url.password || url.port || url.hash) return fail(); + const authority = text.slice(text.indexOf('://') + 3).split(/[/?#]/, 1)[0]; + if (!/^(open\.spotify\.com|music\.apple\.com)$/i.test(authority)) return fail(); + const rawPath = text.slice(text.indexOf(authority) + authority.length).split(/[?#]/, 1)[0]; + if (rawPath !== url.pathname || /(?:^|\/)\.{1,2}(?:\/|$)/.test(rawPath)) return fail(); + if (url.hostname === 'open.spotify.com') { + const match = /^\/(?:intl-[a-z]{2}(?:-[A-Z]{2})?\/)?track\/([A-Za-z0-9]{22})\/?$/.exec(url.pathname); + if (!match) return fail(); + return { provider: 'spotify', id: match[1], url: `https://open.spotify.com/track/${match[1]}` }; + } + if (url.hostname !== 'music.apple.com') return fail(); + const match = /^\/([a-z]{2})\/(song|album)\/(?:([^/]+)\/)?([1-9]\d{0,19})\/?$/.exec(url.pathname); + if (!match) return fail(); + if (match[3]) { + try { + if (/[/\\\u0000-\u001f\u007f]/.test(decodeURIComponent(match[3]))) return fail(); + } catch { return fail(); } + } + const ids = url.searchParams.getAll('i'); + let id = match[4]; + if (match[2] === 'album') { + if (ids.length !== 1 || !/^[1-9]\d{0,19}$/.test(ids[0])) return fail(); + id = ids[0]; + } else if (ids.length > 0) { + 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}` }; +} diff --git a/tests/party.browser.mjs b/tests/party.browser.mjs index b8159db..9d78a2f 100644 --- a/tests/party.browser.mjs +++ b/tests/party.browser.mjs @@ -10,6 +10,7 @@ before(async () => { ? { executablePath: process.env.CHROME_PATH } : { channel: 'chrome' }), headless: true, + timeout: 20000, }); }); after(async () => { @@ -78,9 +79,15 @@ test('interactive demo renders host, guest and direct start with no API traffic' } await page.setViewportSize({ width: 390, height: 844 }); assert.equal(await page.getByText('Song links only. No music account needed.', { exact: true }).count(), 0); - await page.getByLabel('Spotify or Apple Music song link', { exact: true }).fill('https://open.spotify.com/track/demo'); - await page.getByRole('button', { name: 'Add song', exact: true }).click(); - await page.getByRole('heading', { name: 'Your sample song', exact: true }).waitFor(); + await page.getByLabel('Spotify or Apple Music song link', { exact: true }).fill('https://open.spotify.com/track/0123456789ABCDEFGHIJKL'); + const result = page.getByRole('button', { name: 'Afterglow Northern Lines · Blue Hour', exact: true }); + await result.waitFor(); + assert.equal(await page.getByRole('article').count(), 1, 'search does not add a song'); + assert.equal((await result.locator('img').boundingBox()).width, 48); + assert.ok((await result.boundingBox()).height <= 80); + await result.click(); + await page.getByText('Added to the queue', { exact: true }).waitFor(); + await page.getByRole('article').getByRole('heading', { name: 'Afterglow', exact: true }).waitFor(); assert.equal(await page.getByRole('article').count(), 2); assert.equal(await page.getByText('Added to host’s Spotify queue', { exact: true }).count(), 0); await page.getByRole('button', { name: 'Start screen', exact: true }).click(); @@ -139,6 +146,53 @@ test('mobile Party opens without Library requests or unsigned authentication', a await page.close(); }); +test('host and guest search cards match list dimensions, select with keyboard and distinguish no-match from errors', async () => { + for (const role of ['host', 'guest']) { + const page = await previewPage({ viewport: { width: 390, height: 844 } }); + try { + await page.goto(`${base}/app/?section=party&demo=${role}`); + const input = page.getByLabel('Spotify or Apple Music song link', { exact: true }); + await page.getByLabel('Search / delivery sample').selectOption('multiple'); + await input.fill('https://music.apple.com/us/song/123456789'); + const results = page.locator('.party-song-choice'); + await results.nth(1).waitFor(); + assert.equal(await results.count(), 2); + const before = await page.getByRole('article').count(); + for (const width of [390, 1280]) { + await page.setViewportSize({ width, height: 844 }); + assert.equal((await results.first().locator('img').boundingBox()).width, 48); + assert.equal((await results.first().locator('img').boundingBox()).height, 48); + assert.ok((await results.first().boundingBox()).height <= 80); + assert.equal(await results.first().locator('h3').evaluate(element => getComputedStyle(element).fontSize), + await page.getByRole('article').first().locator('h3').evaluate(element => getComputedStyle(element).fontSize)); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true); + } + assert.equal(await page.getByRole('article').count(), before); + assert.equal(await page.getByText('Added to the queue', { exact: true }).count(), 0); + await results.first().focus(); + await results.first().press('Enter'); + await page.getByText('Added to the queue', { exact: true }).waitFor(); + assert.equal(await page.getByRole('article').count(), before + 1); + for (const scenario of ['not_found', 'rate_limited', 'unknown']) { + await page.getByLabel('Search / delivery sample').selectOption(scenario); + await input.fill('https://music.apple.com/us/song/123456789'); + if (scenario === 'not_found') { + await page.getByText('Not found', { exact: true }).waitFor(); + assert.equal(await results.count(), 0); + await page.getByText('Not found', { exact: true }).waitFor({ state: 'hidden' }); + } else if (scenario === 'rate_limited') { + await page.getByRole('alert').getByText(/Too many requests/).waitFor(); + assert.equal(await page.getByText('Not found', { exact: true }).count(), 0); + } else { + await results.first().click(); + await page.getByText(/Outcome unknown/).first().waitFor(); + assert.equal(await page.getByText('Added to the queue', { exact: true }).count(), 0); + } + } + } finally { await page.close(); } + } +}); + test('Library loading and errors never hide Party navigation', async () => { const page = await previewPage({ viewport: { width: 1280, height: 800 }, @@ -236,6 +290,7 @@ test('recording confirmation and nameless submissions retain the Party CSRF cont }; let approval; let submission; + let search; await page.route('https://telegram.org/js/telegram-web-app.js', (route) => route.fulfill({ contentType: 'text/javascript', @@ -255,11 +310,13 @@ test('recording confirmation and nameless submissions retain the Party CSRF cont data = { inviteUrl: `https://t.me/test_party_bot?startapp=p_${'a'.repeat(43)}`, }; - else if (path.endsWith('/requests')) { - if (http.method() === 'POST') { - submission = { body: http.postDataJSON(), csrf: http.headers()['x-party-csrf'] }; - data = { id: 'new-song' }; - } else data = { room, requests: [request], nextCursor: '1' }; + else if (path.endsWith('/requests')) data = { room, requests: [request], nextCursor: '1' }; + else if (path.endsWith('/search')) { + search = { body: http.postDataJSON(), csrf: http.headers()['x-party-csrf'] }; + data = { candidates: [{ ...request.selected, selectionToken: 'signed-fixture' }], expiresAt: new Date(Date.now() + 300000).toISOString() }; + } else if (path.endsWith('/selections')) { + submission = { body: http.postDataJSON(), csrf: http.headers()['x-party-csrf'] }; + data = { id: 'new-song' }; } else if (path.endsWith('/action')) { approval = { @@ -287,13 +344,16 @@ test('recording confirmation and nameless submissions retain the Party CSRF cont }); const link = 'https://open.spotify.com/track/0123456789ABCDEFGHIJKL'; await page.getByLabel('Spotify or Apple Music song link', { exact: true }).fill(link); - const submitted = page.waitForResponse(response => response.request().method() === 'POST' && new URL(response.url()).pathname.endsWith('/requests')); - await page.getByRole('button', { name: 'Add song', exact: true }).click(); + const result = page.getByRole('button', { name: 'Song Artist · Album', exact: true }); + await result.waitFor(); + assert.deepEqual(search, { body: { url: link }, csrf: 'test-csrf' }); + assert.equal(submission, undefined); + const submitted = page.waitForResponse(response => response.request().method() === 'POST' && new URL(response.url()).pathname.endsWith('/selections')); + await result.click(); await submitted; assert.equal(submission.csrf, 'test-csrf'); - assert.equal(submission.body.url, link); - assert.match(submission.body.submissionKey, /^[a-f0-9-]{36}$/); - assert.deepEqual(Object.keys(submission.body).sort(), ['submissionKey', 'url']); + assert.deepEqual(submission.body, { selectionToken: 'signed-fixture' }); + assert.equal(await page.getByText('Added to the queue', { exact: true }).count(), 0); assert.equal(await page.getByRole('img', { name: /QR/i }).count(), 1); assert.equal( await page.evaluate(