From 89d890c594708b44809507a72d49cd0bfe00f127 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Tue, 13 Oct 2026 10:43:32 +0300 Subject: [PATCH 01/14] feat(wiring): Spotify list requests, play, transfer and their parsers Signed-off-by: NovusEdge --- hooks/music/parse.ts | 5 ++++ hooks/music/webapi.ts | 52 +++++++++++++++++++++++++++++++-- test/music-webapi.test.ts | 60 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 114 insertions(+), 3 deletions(-) diff --git a/hooks/music/parse.ts b/hooks/music/parse.ts index 9cf30ce..b8bb00f 100644 --- a/hooks/music/parse.ts +++ b/hooks/music/parse.ts @@ -20,6 +20,11 @@ export type MusicCmd = | { cmd: 'repeat'; mode: Repeat } | { cmd: 'volume'; level: number } | { cmd: 'like'; on: boolean } + | { cmd: 'skipTo'; uri: string } + | { cmd: 'play'; uri: string } + // offset starts the context at that track, so what follows it is what followed it when it last played + | { cmd: 'playContext'; uri: string; offset?: string } + | { cmd: 'transfer'; deviceId: string } export type Extras = Pick const isAd = (id: string) => id.startsWith('/com/spotify/ad/') || id.startsWith('spotify:ad:') diff --git a/hooks/music/webapi.ts b/hooks/music/webapi.ts index d8073d0..2a0d05f 100644 --- a/hooks/music/webapi.ts +++ b/hooks/music/webapi.ts @@ -2,7 +2,8 @@ import type { Device, MusicCmd, OpenMusic, QueueItem, Repeat, Track } from './parse.ts' export const API = 'https://api.spotify.com/v1' -export type ApiRequest = { method: 'GET' | 'PUT' | 'POST' | 'DELETE'; url: string } +// body is JSON; SpotifyClient sends it with its content type +export type ApiRequest = { method: 'GET' | 'PUT' | 'POST' | 'DELETE'; url: string; body?: string } // Without additional_types=episode, a podcast episode comes back as item: null. export const playerRequest = (): ApiRequest => ({ method: 'GET', url: `${API}/me/player?additional_types=episode` }) @@ -11,10 +12,14 @@ export const meRequest = (): ApiRequest => ({ method: 'GET', url: `${API}/me` }) // /me/tracks was removed in February 2026; /me/library takes spotify: URIs export const likedRequest = (uri: string): ApiRequest => ({ method: 'GET', url: `${API}/me/library/contains?uris=${encodeURIComponent(uri)}` }) export const likeRequest = (uri: string, on: boolean): ApiRequest => ({ method: on ? 'PUT' : 'DELETE', url: `${API}/me/library?uris=${encodeURIComponent(uri)}` }) +export const recentRequest = (): ApiRequest => ({ method: 'GET', url: `${API}/me/player/recently-played?limit=20` }) +// next is the URL a page names for the one after it, already checked by parsePlaylists +export const playlistsRequest = (next?: string): ApiRequest => ({ method: 'GET', url: next ?? `${API}/me/playlists?limit=50` }) +export const devicesRequest = (): ApiRequest => ({ method: 'GET', url: `${API}/me/player/devices` }) const unit = (v: number) => Math.min(1, Math.max(0, v)) -// The write a transport command makes; playing picks pause or play. like goes through likeRequest, and raise is local only. +// The write a transport command makes; playing picks pause or play. like goes through likeRequest, skipTo through spotify.ts's skipTo, and raise is local only. export function commandRequest(c: MusicCmd, playing: boolean): ApiRequest | undefined { switch (c.cmd) { case 'playpause': return { method: 'PUT', url: `${API}/me/player/${playing ? 'pause' : 'play'}` } @@ -24,6 +29,10 @@ export function commandRequest(c: MusicCmd, playing: boolean): ApiRequest | unde case 'shuffle': return { method: 'PUT', url: `${API}/me/player/shuffle?state=${c.on}` } case 'repeat': return { method: 'PUT', url: `${API}/me/player/repeat?state=${c.mode}` } case 'volume': return { method: 'PUT', url: `${API}/me/player/volume?volume_percent=${Math.round(unit(c.level) * 100)}` } + case 'play': return { method: 'PUT', url: `${API}/me/player/play`, body: JSON.stringify({ uris: [c.uri] }) } + case 'playContext': return { method: 'PUT', url: `${API}/me/player/play`, body: JSON.stringify({ context_uri: c.uri, ...(c.offset && { offset: { uri: c.offset } }) }) } + // play: true starts it there; without it Spotify moves playback paused + case 'transfer': return { method: 'PUT', url: `${API}/me/player`, body: JSON.stringify({ device_ids: [c.deviceId], play: true }) } default: return undefined } } @@ -91,6 +100,45 @@ export function parseMe(text: string): string | undefined { return str(o?.display_name) || str(o?.id) || undefined } +export type RecentItem = QueueItem & { contextUri?: string } +export function parseRecent(text: string): RecentItem[] | undefined { + const items = obj(json(text))?.items + if (!Array.isArray(items)) return undefined + return items.flatMap((v): RecentItem[] => { + const o = obj(v), t = trackOf(o?.track) + if (!t) return [] + const ctx = str(obj(o?.context)?.uri) + return [{ uri: t.uri!, title: t.title, artist: t.artist, durationMs: t.durationMs, ...(ctx && { contextUri: ctx }) }] + }) +} + +export type PlaylistItem = { uri: string; name: string; owner: string; tracks: number } +// next is kept only on Spotify's own API host over https: the bearer token goes wherever it points. +export function parsePlaylists(text: string): { items: PlaylistItem[]; next?: string } | undefined { + const o = obj(json(text)) + if (!o || !Array.isArray(o.items)) return undefined + const items = o.items.flatMap((v): PlaylistItem[] => { + const p = obj(v), uri = str(p?.uri) + if (!p || !uri) return [] + const owner = obj(p.owner) + // Spotify renamed the playlist's tracks field to items in 2026; either may come back + return [{ uri, name: str(p.name), owner: str(owner?.display_name) || str(owner?.id), tracks: num(obj(p.items)?.total ?? obj(p.tracks)?.total) }] + }) + const next = str(o.next) + return { items, ...(next.startsWith(`${API}/`) && { next }) } +} + +export type DeviceItem = { id: string; name: string; type: string; active: boolean } +// A restricted device has no id and cannot take playback, so it is left out. +export function parseDevices(text: string): DeviceItem[] | undefined { + const d = obj(json(text))?.devices + if (!Array.isArray(d)) return undefined + return d.flatMap((v): DeviceItem[] => { + const o = obj(v), id = str(o?.id) + return o && id ? [{ id, name: str(o.name), type: str(o.type), active: o.is_active === true }] : [] + }) +} + export type ApiError = // a network error, or no answer within the 5 s timeout | { kind: 'offline' } diff --git a/test/music-webapi.test.ts b/test/music-webapi.test.ts index 9819664..ffa1709 100644 --- a/test/music-webapi.test.ts +++ b/test/music-webapi.test.ts @@ -1,5 +1,5 @@ import { test, expect } from 'claude-code/testing' -import { apiError, commandRequest, likedRequest, likeRequest, meRequest, needsRefresh, parseLiked, parseMe, parsePlayer, parseQueue, playerRequest, queueRequest } from '../hooks/music/webapi.ts' +import { apiError, commandRequest, devicesRequest, likedRequest, likeRequest, meRequest, needsRefresh, parseDevices, parseLiked, parseMe, parsePlayer, parsePlaylists, parseQueue, parseRecent, playerRequest, playlistsRequest, queueRequest, recentRequest } from '../hooks/music/webapi.ts' // Recorded shapes of the Web API's answers, trimmed to the fields glowup reads. const ITEM = { @@ -86,3 +86,61 @@ test('a token is refreshed in its last minute', () => { expect(needsRefresh(100_000, 40_001)).toBe(true) expect(needsRefresh(100_000, 40_000)).toBe(false) }) + +test('the list requests', () => { + expect(recentRequest()).toEqual({ method: 'GET', url: 'https://api.spotify.com/v1/me/player/recently-played?limit=20' }) + expect(playlistsRequest()).toEqual({ method: 'GET', url: 'https://api.spotify.com/v1/me/playlists?limit=50' }) + expect(playlistsRequest('https://api.spotify.com/v1/me/playlists?offset=50&limit=50').url).toBe('https://api.spotify.com/v1/me/playlists?offset=50&limit=50') + expect(devicesRequest()).toEqual({ method: 'GET', url: 'https://api.spotify.com/v1/me/player/devices' }) +}) + +test('play, play in a context, and transfer carry JSON bodies', () => { + expect(commandRequest({ cmd: 'play', uri: 'spotify:track:a' }, false)).toEqual({ method: 'PUT', url: 'https://api.spotify.com/v1/me/player/play', body: '{"uris":["spotify:track:a"]}' }) + expect(commandRequest({ cmd: 'playContext', uri: 'spotify:playlist:p', offset: 'spotify:track:a' }, false)!.body).toBe('{"context_uri":"spotify:playlist:p","offset":{"uri":"spotify:track:a"}}') + expect(commandRequest({ cmd: 'playContext', uri: 'spotify:playlist:p' }, false)!.body).toBe('{"context_uri":"spotify:playlist:p"}') + expect(commandRequest({ cmd: 'transfer', deviceId: 'dk' }, false)).toEqual({ method: 'PUT', url: 'https://api.spotify.com/v1/me/player', body: '{"device_ids":["dk"],"play":true}' }) + expect(commandRequest({ cmd: 'skipTo', uri: 'spotify:track:a' }, true)).toBeUndefined() +}) + +test('recently played: the track and the context it played in', () => { + const RECENT = JSON.stringify({ items: [ + { track: ITEM, played_at: '2026-10-12T10:00:00Z', context: { type: 'playlist', uri: 'spotify:playlist:pl1' } }, + { track: { ...ITEM, uri: 'spotify:track:b', name: 'B' }, played_at: '2026-10-12T09:00:00Z', context: null }, + { track: null }, + ] }) + expect(parseRecent(RECENT)).toEqual([ + { uri: 'spotify:track:tid', title: 'BACK 2 THE BASICS', artist: 'Double A-Ron, Guest', durationMs: 174000, contextUri: 'spotify:playlist:pl1' }, + { uri: 'spotify:track:b', title: 'B', artist: 'Double A-Ron, Guest', durationMs: 174000 }, + ]) + expect(parseRecent('{}')).toBeUndefined() +}) + +test('playlists: the page, its track count under either name, and the next page', () => { + const page = JSON.stringify({ items: [ + { uri: 'spotify:playlist:p1', name: 'Focus', owner: { display_name: 'Ada', id: 'ada' }, items: { total: 12 } }, + { uri: 'spotify:playlist:p2', name: 'Old', owner: { display_name: null, id: 'bob' }, tracks: { total: 1 } }, + ], next: 'https://api.spotify.com/v1/me/playlists?offset=50&limit=50' }) + expect(parsePlaylists(page)).toEqual({ items: [ + { uri: 'spotify:playlist:p1', name: 'Focus', owner: 'Ada', tracks: 12 }, + { uri: 'spotify:playlist:p2', name: 'Old', owner: 'bob', tracks: 1 }, + ], next: 'https://api.spotify.com/v1/me/playlists?offset=50&limit=50' }) + expect(parsePlaylists(JSON.stringify({ items: [], next: null }))).toEqual({ items: [] }) + expect(parsePlaylists('[]')).toBeUndefined() +}) + +test('a next page off the API host is dropped', () => { + expect(parsePlaylists(JSON.stringify({ items: [], next: 'https://evil.example/v1/me/playlists?offset=50' }))).toEqual({ items: [] }) + expect(parsePlaylists(JSON.stringify({ items: [], next: 'http://api.spotify.com/v1/me/playlists?offset=50' }))).toEqual({ items: [] }) +}) + +test('devices: the active one, and none without an id', () => { + expect(parseDevices(JSON.stringify({ devices: [ + { id: 'ph', is_active: true, name: 'Pixel 9', type: 'Smartphone' }, + { id: 'dk', is_active: false, name: 'Desktop', type: 'Computer' }, + { id: null, is_active: false, name: 'Restricted', type: 'Speaker' }, + ] }))).toEqual([ + { id: 'ph', name: 'Pixel 9', type: 'Smartphone', active: true }, + { id: 'dk', name: 'Desktop', type: 'Computer', active: false }, + ]) + expect(parseDevices('{}')).toBeUndefined() +}) From 6601567329f516e109f787355f583942a0051eb7 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Tue, 13 Oct 2026 10:49:17 +0300 Subject: [PATCH 02/14] fix(wiring): recent skips local files; next-page host tests Signed-off-by: NovusEdge --- hooks/music/webapi.ts | 3 ++- test/music-webapi.test.ts | 28 ++++++++++++++++++++++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/hooks/music/webapi.ts b/hooks/music/webapi.ts index 2a0d05f..8496c83 100644 --- a/hooks/music/webapi.ts +++ b/hooks/music/webapi.ts @@ -106,7 +106,8 @@ export function parseRecent(text: string): RecentItem[] | undefined { if (!Array.isArray(items)) return undefined return items.flatMap((v): RecentItem[] => { const o = obj(v), t = trackOf(o?.track) - if (!t) return [] + // a local file has no Web API representation: play and the queue cannot address it + if (!t || t.uri!.startsWith('spotify:local:')) return [] const ctx = str(obj(o?.context)?.uri) return [{ uri: t.uri!, title: t.title, artist: t.artist, durationMs: t.durationMs, ...(ctx && { contextUri: ctx }) }] }) diff --git a/test/music-webapi.test.ts b/test/music-webapi.test.ts index ffa1709..5660ed6 100644 --- a/test/music-webapi.test.ts +++ b/test/music-webapi.test.ts @@ -98,6 +98,7 @@ test('play, play in a context, and transfer carry JSON bodies', () => { expect(commandRequest({ cmd: 'play', uri: 'spotify:track:a' }, false)).toEqual({ method: 'PUT', url: 'https://api.spotify.com/v1/me/player/play', body: '{"uris":["spotify:track:a"]}' }) expect(commandRequest({ cmd: 'playContext', uri: 'spotify:playlist:p', offset: 'spotify:track:a' }, false)!.body).toBe('{"context_uri":"spotify:playlist:p","offset":{"uri":"spotify:track:a"}}') expect(commandRequest({ cmd: 'playContext', uri: 'spotify:playlist:p' }, false)!.body).toBe('{"context_uri":"spotify:playlist:p"}') + expect(commandRequest({ cmd: 'playContext', uri: 'spotify:playlist:p', offset: '' }, false)!.body).toBe('{"context_uri":"spotify:playlist:p"}') expect(commandRequest({ cmd: 'transfer', deviceId: 'dk' }, false)).toEqual({ method: 'PUT', url: 'https://api.spotify.com/v1/me/player', body: '{"device_ids":["dk"],"play":true}' }) expect(commandRequest({ cmd: 'skipTo', uri: 'spotify:track:a' }, true)).toBeUndefined() }) @@ -115,6 +116,11 @@ test('recently played: the track and the context it played in', () => { expect(parseRecent('{}')).toBeUndefined() }) +test('recently played skips local files', () => { + const local = { track: { ...ITEM, uri: 'spotify:local:artist:album:title:174' }, context: null } + expect(parseRecent(JSON.stringify({ items: [local, { track: ITEM }] }))!.map(r => r.uri)).toEqual(['spotify:track:tid']) +}) + test('playlists: the page, its track count under either name, and the next page', () => { const page = JSON.stringify({ items: [ { uri: 'spotify:playlist:p1', name: 'Focus', owner: { display_name: 'Ada', id: 'ada' }, items: { total: 12 } }, @@ -128,9 +134,26 @@ test('playlists: the page, its track count under either name, and the next page' expect(parsePlaylists('[]')).toBeUndefined() }) +test('playlists: odd entries', () => { + const got = parsePlaylists(JSON.stringify({ items: [ + { name: 'No uri', owner: { id: 'a' } }, + null, + { uri: 'spotify:playlist:p1', name: 'Nobody' }, + { uri: 'spotify:playlist:p2', name: 'Null items', owner: { id: 'a' }, items: { total: null }, tracks: { total: 7 } }, + ] })) + expect(got).toEqual({ items: [ + { uri: 'spotify:playlist:p1', name: 'Nobody', owner: '', tracks: 0 }, + { uri: 'spotify:playlist:p2', name: 'Null items', owner: 'a', tracks: 7 }, + ] }) +}) + test('a next page off the API host is dropped', () => { - expect(parsePlaylists(JSON.stringify({ items: [], next: 'https://evil.example/v1/me/playlists?offset=50' }))).toEqual({ items: [] }) - expect(parsePlaylists(JSON.stringify({ items: [], next: 'http://api.spotify.com/v1/me/playlists?offset=50' }))).toEqual({ items: [] }) + const next = (n: string) => parsePlaylists(JSON.stringify({ items: [], next: n })) + expect(next('https://evil.example/v1/me/playlists?offset=50')).toEqual({ items: [] }) + expect(next('http://api.spotify.com/v1/me/playlists?offset=50')).toEqual({ items: [] }) + expect(next('https://api.spotify.com.evil.example/v1/me/playlists?offset=50')).toEqual({ items: [] }) + expect(next('https://api.spotify.com/v1/')).toEqual({ items: [], next: 'https://api.spotify.com/v1/' }) + expect(next('https://api.spotify.com/v1')).toEqual({ items: [] }) }) test('devices: the active one, and none without an id', () => { @@ -143,4 +166,5 @@ test('devices: the active one, and none without an id', () => { { id: 'dk', name: 'Desktop', type: 'Computer', active: false }, ]) expect(parseDevices('{}')).toBeUndefined() + expect(parseDevices('{"devices":{}}')).toBeUndefined() }) From 865c02126b0592dad0dd13241e584cadb0e9241b Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Tue, 13 Oct 2026 11:20:56 +0300 Subject: [PATCH 03/14] feat(wiring): skipTo by URI, every playlist page, JSON bodies Signed-off-by: NovusEdge --- hooks/music/player.ts | 3 ++ hooks/music/spotify.ts | 42 +++++++++++++++++++++++-- test/music-player.test.ts | 10 ++++++ test/music-spotify.test.ts | 63 ++++++++++++++++++++++++++++++++++++-- 4 files changed, 114 insertions(+), 4 deletions(-) diff --git a/hooks/music/player.ts b/hooks/music/player.ts index 7c3d9ea..96c4465 100644 --- a/hooks/music/player.ts +++ b/hooks/music/player.ts @@ -58,6 +58,9 @@ function keepProgress(m: Music, prev: Music | undefined, now: number): Music { export function routeOf(cmd: MusicCmd, m: Music | undefined, premium: boolean): Route { const o = m?.status === 'open' && m.source === 'api' && m.device ? m : undefined if (cmd.cmd === 'like') return o?.track?.uri ? 'api' : 'none' + // The lists' commands are API-only. They run with nothing active too: Spotify's NO_ACTIVE_DEVICE answer is what + // sends the person to Devices, and transfer is the way out of that state. + if (cmd.cmd === 'skipTo' || cmd.cmd === 'play' || cmd.cmd === 'playContext' || cmd.cmd === 'transfer') return m?.status === 'open' && m.source === 'api' ? 'api' : 'none' if (cmd.cmd === 'raise' || !o || !premium) return 'local' // the API can fail where the app on this computer still answers return o.device!.isThisComputer ? 'apiThenLocal' : 'api' diff --git a/hooks/music/spotify.ts b/hooks/music/spotify.ts index c63b6f6..d4b5b64 100644 --- a/hooks/music/spotify.ts +++ b/hooks/music/spotify.ts @@ -2,7 +2,7 @@ import type { Host } from '../host.ts' import type { OpenMusic } from './parse.ts' import type { TokenKeeper } from './tokens.ts' -import { apiError, parsePlayer, playerRequest, type ApiError, type ApiRequest } from './webapi.ts' +import { apiError, commandRequest, parsePlayer, parsePlaylists, parseQueue, playerRequest, playlistsRequest, queueRequest, type ApiError, type ApiRequest, type PlaylistItem } from './webapi.ts' export const API_TIMEOUT_MS = 5000 export type ApiAnswer = { ok: true; status: number; text: string; at: number } | { ok: false; error: ApiError } @@ -45,7 +45,8 @@ export class SpotifyClient { } private fetch(r: ApiRequest, token: string, onSettled: () => void) { - return this.host.fetch(r.url, { method: r.method, headers: { Authorization: `Bearer ${token}` }, timeoutMs: API_TIMEOUT_MS, onSettled }).catch(() => undefined) + const headers: Record = { Authorization: `Bearer ${token}`, ...(r.body !== undefined && { 'Content-Type': 'application/json' }) } + return this.host.fetch(r.url, { method: r.method, headers, ...(r.body !== undefined && { body: r.body }), timeoutMs: API_TIMEOUT_MS, onSettled }).catch(() => undefined) } } @@ -58,3 +59,40 @@ export async function readPlayer(c: SpotifyClient): Promise { const music = parsePlayer(a.text, a.at) return music ? { ok: true, at: a.at, music } : { ok: false, error: { kind: 'server', status: a.status } } } + +export const SKIP_MAX = 20 +// 20 pages of 50: past a thousand playlists the list stops rather than polling on +export const PLAYLIST_PAGES = 20 + +export async function allPlaylists(c: SpotifyClient): Promise<{ ok: true; items: PlaylistItem[] } | { ok: false; error: ApiError }> { + const items: PlaylistItem[] = [] + let next: string | undefined + for (let page = 0; page < PLAYLIST_PAGES; page++) { + const a = await c.send(playlistsRequest(next)) + if (!a.ok) return a + const p = parsePlaylists(a.text) + if (!p) return { ok: false, error: { kind: 'server', status: a.status } } + items.push(...p.items) + if (!p.next) break + next = p.next + } + return { ok: true, items } +} + +// busy: another queue read was still out, so nothing was skipped +export type SkipResult = { kind: 'done' } | { kind: 'gone' } | { kind: 'busy' } | { kind: 'error'; error: ApiError } +// The Web API cannot jump within the queue, only skip: so skipTo carries the target's URI, not an index, +// re-reads the queue (it may have moved since the list was drawn) and sends next until the target plays. +export async function skipTo(c: SpotifyClient, uri: string): Promise { + const q = await c.send(queueRequest()) + if (!q.ok) return q.error.kind === 'busy' ? { kind: 'busy' } : { kind: 'error', error: q.error } + const at = (parseQueue(q.text) ?? []).findIndex(t => t.uri === uri) + if (at < 0) return { kind: 'gone' } + const next = commandRequest({ cmd: 'next' }, true)! + for (let i = 0; i < Math.min(at + 1, SKIP_MAX); i++) { + // awaited one by one: Spotify applies them in order, and a refusal (an ad, a restriction) stops the rest + const r = await c.send(next) + if (!r.ok) return { kind: 'error', error: r.error } + } + return { kind: 'done' } +} diff --git a/test/music-player.test.ts b/test/music-player.test.ts index ecc3d6d..e1cb71c 100644 --- a/test/music-player.test.ts +++ b/test/music-player.test.ts @@ -98,6 +98,16 @@ test('the routing table', () => { expect([routeOf(next, there, false), routeOf({ cmd: 'seek', ms: 1 }, there, false), routeOf(like, there, false)]).toEqual(['local', 'local', 'api']) }) +test('the list commands go to the API whenever it answers, active device or not', () => { + const there = mergeMusic(local(), { last: { at: 1000, music: api({ device: phone }) } }, undefined, 1000, I) + const none = mergeMusic({ status: 'closed' }, { last: { at: 1000 } }, undefined, 1000, I) + const failing = mergeMusic(local(), { last: { at: 1000, music: api() } }, undefined, 99_000, I) + const cmds = [{ cmd: 'skipTo', uri: 'spotify:track:x' }, { cmd: 'play', uri: 'spotify:track:x' }, { cmd: 'playContext', uri: 'spotify:playlist:p' }, { cmd: 'transfer', deviceId: 'dk' }] as const + for (const c of cmds) { + expect([routeOf(c, there, true), routeOf(c, none, true), routeOf(c, failing, true), routeOf(c, local(), true), routeOf(c, there, false)]).toEqual(['api', 'api', 'none', 'none', 'api']) + } +}) + test('the API widens the controls while it runs the player', () => { const linux: MusicCaps = { repeat: [], shuffle: false, raise: true, volume: false } const there = mergeMusic(local(), { last: { at: 1000, music: api({ device: phone }) } }, undefined, 1000, I) diff --git a/test/music-spotify.test.ts b/test/music-spotify.test.ts index 8e54f6c..092e12b 100644 --- a/test/music-spotify.test.ts +++ b/test/music-spotify.test.ts @@ -1,8 +1,8 @@ import { expect } from 'claude-code/testing' import { fakeHost, test, type FakeRequest } from './kit.ts' -import { SpotifyClient, readPlayer } from '../hooks/music/spotify.ts' +import { SpotifyClient, allPlaylists, readPlayer, skipTo } from '../hooks/music/spotify.ts' import { TokenKeeper, tokenFile, type Refresher } from '../hooks/music/tokens.ts' -import { commandRequest } from '../hooks/music/webapi.ts' +import { commandRequest, queueRequest } from '../hooks/music/webapi.ts' const PATH = tokenFile('/home/u/.claude') const file = (over: Record = {}) => JSON.stringify({ format: 1, clientId: 'c'.repeat(32), accessToken: 'at-1', refreshToken: 'rt-1', expiresAt: 3_600_000, scope: '', connectedAt: 0, gen: 1, ...over }) @@ -94,3 +94,62 @@ test('PREMIUM_REQUIRED turns premium off; another 403 does not', async () => { expect(await r.client.send(commandRequest({ cmd: 'next' }, true)!)).toEqual({ ok: false, error: { kind: 'premium' } }) expect(r.client.premium).toBe(false) }) + +const QUEUE = (...ids: string[]) => JSON.stringify({ queue: ids.map(id => ({ type: 'track', uri: `spotify:track:${id}`, name: id, duration_ms: 1000, artists: [], album: { name: '' } })) }) +const nexts = (reqs: FakeRequest[]) => reqs.filter(r => r.method === 'POST' && r.url.endsWith('/me/player/next')).length + +test('a write with a body sends it as JSON', async () => { + const r = await rig(() => ({ status: 204 })) + await r.client.send(commandRequest({ cmd: 'transfer', deviceId: 'dk' }, false)!) + expect(r.requests[0]).toMatchObject({ method: 'PUT', body: '{"device_ids":["dk"],"play":true}', headers: { Authorization: 'Bearer at-1', 'Content-Type': 'application/json' } }) + await readPlayer(r.client) + expect(r.requests[1]!.headers['Content-Type']).toBeUndefined() +}) + +test('every playlist page, and no more than 20', async () => { + const page = (n: number) => JSON.stringify({ items: [{ uri: `spotify:playlist:p${n}`, name: `P${n}`, owner: { id: 'a' }, items: { total: n } }], next: `https://api.spotify.com/v1/me/playlists?offset=${(n + 1) * 50}&limit=50` }) + const r = await rig(req => ({ status: 200, text: page(Number(/offset=(\d+)/.exec(req.url)?.[1] ?? 0) / 50) })) + const got = await allPlaylists(r.client) + expect(got.ok && got.items.map(p => p.uri)).toEqual(Array.from({ length: 20 }, (_, i) => `spotify:playlist:p${i}`)) + expect(r.requests).toHaveLength(20) + expect(r.requests[1]!.url).toBe('https://api.spotify.com/v1/me/playlists?offset=50&limit=50') +}) + +test('a failed playlist page fails the list', async () => { + const r = await rig(req => (req.url.includes('offset') ? { status: 503 } : { status: 200, text: JSON.stringify({ items: [], next: 'https://api.spotify.com/v1/me/playlists?offset=50&limit=50' }) })) + expect(await allPlaylists(r.client)).toEqual({ ok: false, error: { kind: 'server', status: 503 } }) +}) + +test('skipTo re-reads the queue and sends next once per track up to the target', async () => { + const r = await rig(req => (req.url.includes('/queue') ? { status: 200, text: QUEUE('a', 'b', 'c') } : { status: 204 })) + expect(await skipTo(r.client, 'spotify:track:c')).toEqual({ kind: 'done' }) + expect(nexts(r.requests)).toBe(3) +}) + +test('a track that left the queue skips nothing', async () => { + const r = await rig(req => (req.url.includes('/queue') ? { status: 200, text: QUEUE('a') } : { status: 204 })) + expect(await skipTo(r.client, 'spotify:track:c')).toEqual({ kind: 'gone' }) + expect(nexts(r.requests)).toBe(0) +}) + +test('skipTo stops at the first refusal', async () => { + let n = 0 + const r = await rig(req => (req.url.includes('/queue') ? { status: 200, text: QUEUE('a', 'b', 'c') } : ++n === 2 ? { status: 403, text: '{"error":{"status":403,"reason":"RESTRICTION_VIOLATED"}}' } : { status: 204 })) + expect(await skipTo(r.client, 'spotify:track:c')).toEqual({ kind: 'error', error: { kind: 'refused', reason: 'RESTRICTION_VIOLATED' } }) + expect(nexts(r.requests)).toBe(2) +}) + +test('a queue read still out: skipTo says busy and skips nothing', async () => { + const r = await rig(req => (req.url.includes('/queue') ? new Promise(() => {}) : { status: 204 })) + void r.client.send(queueRequest()) + await tick() + expect(await skipTo(r.client, 'spotify:track:a')).toEqual({ kind: 'busy' }) + expect(nexts(r.requests)).toBe(0) +}) + +test('skipTo sends at most 20 nexts', async () => { + const ids = Array.from({ length: 30 }, (_, i) => 't' + i) + const r = await rig(req => (req.url.includes('/queue') ? { status: 200, text: QUEUE(...ids) } : { status: 204 })) + await skipTo(r.client, 'spotify:track:t29') + expect(nexts(r.requests)).toBe(20) +}) From d593f8edaf1fff37aa644dbe02bc408119c72ee8 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Tue, 13 Oct 2026 11:32:13 +0300 Subject: [PATCH 04/14] fix(wiring): one skipTo at a time Signed-off-by: NovusEdge --- hooks/music/spotify.ts | 36 +++++++++++++++++++++++------------- test/music-spotify.test.ts | 27 +++++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 15 deletions(-) diff --git a/hooks/music/spotify.ts b/hooks/music/spotify.ts index d4b5b64..ffb3ace 100644 --- a/hooks/music/spotify.ts +++ b/hooks/music/spotify.ts @@ -12,6 +12,8 @@ export class SpotifyClient { private until = 0 // false once a write answered PREMIUM_REQUIRED: transport then goes through the local app for the session premium = true + // set for the whole of a skipTo: a second one would read a queue the first is still consuming + skipping = false constructor(private host: Host, private keeper: TokenKeeper, private now: () => number) {} async send(r: ApiRequest): Promise { @@ -79,20 +81,28 @@ export async function allPlaylists(c: SpotifyClient): Promise<{ ok: true; items: return { ok: true, items } } -// busy: another queue read was still out, so nothing was skipped -export type SkipResult = { kind: 'done' } | { kind: 'gone' } | { kind: 'busy' } | { kind: 'error'; error: ApiError } +// busy: another skipTo or queue read was still out, so nothing was skipped; far: the target is SKIP_MAX or more down +export type SkipResult = { kind: 'done' } | { kind: 'gone' } | { kind: 'far' } | { kind: 'busy' } | { kind: 'error'; error: ApiError } // The Web API cannot jump within the queue, only skip: so skipTo carries the target's URI, not an index, // re-reads the queue (it may have moved since the list was drawn) and sends next until the target plays. export async function skipTo(c: SpotifyClient, uri: string): Promise { - const q = await c.send(queueRequest()) - if (!q.ok) return q.error.kind === 'busy' ? { kind: 'busy' } : { kind: 'error', error: q.error } - const at = (parseQueue(q.text) ?? []).findIndex(t => t.uri === uri) - if (at < 0) return { kind: 'gone' } - const next = commandRequest({ cmd: 'next' }, true)! - for (let i = 0; i < Math.min(at + 1, SKIP_MAX); i++) { - // awaited one by one: Spotify applies them in order, and a refusal (an ad, a restriction) stops the rest - const r = await c.send(next) - if (!r.ok) return { kind: 'error', error: r.error } - } - return { kind: 'done' } + if (c.skipping) return { kind: 'busy' } + c.skipping = true + try { + const q = await c.send(queueRequest()) + if (!q.ok) return q.error.kind === 'busy' ? { kind: 'busy' } : { kind: 'error', error: q.error } + const queue = parseQueue(q.text) + if (!queue) return { kind: 'error', error: { kind: 'server', status: q.status } } + const at = queue.findIndex(t => t.uri === uri) + if (at < 0) return { kind: 'gone' } + // a capped run would stop on some other track + if (at >= SKIP_MAX) return { kind: 'far' } + const next = commandRequest({ cmd: 'next' }, true)! + for (let i = 0; i <= at; i++) { + // awaited one by one: Spotify applies them in order, and a refusal (an ad, a restriction) stops the rest + const r = await c.send(next) + if (!r.ok) return { kind: 'error', error: r.error } + } + return { kind: 'done' } + } finally { c.skipping = false } } diff --git a/test/music-spotify.test.ts b/test/music-spotify.test.ts index 092e12b..4abb5d4 100644 --- a/test/music-spotify.test.ts +++ b/test/music-spotify.test.ts @@ -147,9 +147,32 @@ test('a queue read still out: skipTo says busy and skips nothing', async () => { expect(nexts(r.requests)).toBe(0) }) -test('skipTo sends at most 20 nexts', async () => { +test('skipTo reaches the 20th track and sends nothing for the 21st', async () => { const ids = Array.from({ length: 30 }, (_, i) => 't' + i) const r = await rig(req => (req.url.includes('/queue') ? { status: 200, text: QUEUE(...ids) } : { status: 204 })) - await skipTo(r.client, 'spotify:track:t29') + expect(await skipTo(r.client, 'spotify:track:t19')).toEqual({ kind: 'done' }) expect(nexts(r.requests)).toBe(20) + expect(await skipTo(r.client, 'spotify:track:t20')).toEqual({ kind: 'far' }) + expect(nexts(r.requests)).toBe(20) +}) + +test('an unparseable queue is a server error, not a skip', async () => { + const r = await rig(() => ({ status: 200, text: '[]' })) + expect(await skipTo(r.client, 'spotify:track:a')).toEqual({ kind: 'error', error: { kind: 'server', status: 200 } }) + expect(nexts(r.requests)).toBe(0) +}) + +test('a second skipTo during the first is busy and sends nothing', async () => { + let open = () => {} + const gate = new Promise(res => { open = res }) + const r = await rig(async req => (req.url.includes('/queue') ? { status: 200, text: QUEUE('a', 'b') } : (await gate, { status: 204 }))) + const first = skipTo(r.client, 'spotify:track:b') + await tick() + expect(nexts(r.requests)).toBe(1) + expect(await skipTo(r.client, 'spotify:track:a')).toEqual({ kind: 'busy' }) + expect(nexts(r.requests)).toBe(1) + open() + expect(await first).toEqual({ kind: 'done' }) + expect(nexts(r.requests)).toBe(2) + expect(await skipTo(r.client, 'spotify:track:a')).toEqual({ kind: 'done' }) }) From 50cdd4fc6ca3107018f0f5ec0759a3735e1289f3 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Tue, 13 Oct 2026 11:42:46 +0300 Subject: [PATCH 05/14] feat(wiring): the music dialog's lists and when each reloads Signed-off-by: NovusEdge --- hooks/music/lists.ts | 91 ++++++++++++++++++++++++++++++++++++++ test/music-lists.test.ts | 95 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 hooks/music/lists.ts create mode 100644 test/music-lists.test.ts diff --git a/hooks/music/lists.ts b/hooks/music/lists.ts new file mode 100644 index 0000000..1017b50 --- /dev/null +++ b/hooks/music/lists.ts @@ -0,0 +1,91 @@ +// The Music dialog's four lists: what each holds, when it reloads, and what Enter on a row sends. +// JSX-free and engine-free, so the rules test on plain data. +import type { Music, MusicCmd, QueueItem } from './parse.ts' +import type { ApiError, DeviceItem, PlaylistItem, RecentItem } from './webapi.ts' +import type { SkipResult } from './spotify.ts' +import { mmss } from './seek.ts' + +export type ListId = 'queue' | 'recent' | 'playlists' | 'devices' +export const LISTS: readonly (readonly [ListId, string])[] = [['queue', 'Up next'], ['recent', 'Recent'], ['playlists', 'Playlists'], ['devices', 'Devices']] +// value is the Select option's value, unique within its list; cmd is what Enter on the row sends +export type ListRow = { value: string; title: string; detail: string; right: string; cmd: MusicCmd; active?: boolean } +// Rows survive a reload and a failed one, so the 15 s refresh never blanks a list. +// forUri is the track playing when the list loaded: Up next and Recent reload when it changes. +export type ListState = + | { status: 'idle' } + | { status: 'loading'; rows: ListRow[] } + | { status: 'ready'; rows: ListRow[]; at: number; forUri?: string } + | { status: 'error'; rows: ListRow[]; reason: string; at: number; forUri?: string } +export type Lists = { shown: ListId; byId: Record } + +export const LIST_MAX = 20 +export const RELOAD_MS = 15_000 +export const EMPTY: Record = { queue: 'Nothing queued.', recent: 'Nothing played yet.', playlists: 'No playlists yet.', devices: 'No devices. Open Spotify on one.' } +const IDLE: ListState = { status: 'idle' } + +// With nothing active anywhere (a 204) the dialog opens on Devices, the one list that can start playback somewhere. +export function initialLists(m?: Music): Lists { + const none = m?.status === 'open' && m.source === 'api' && !m.device + return { shown: none ? 'devices' : 'queue', byId: { queue: IDLE, recent: IDLE, playlists: IDLE, devices: IDLE } } +} + +// Asked only for the list on screen. Closing the dialog resets every list, so "once per open" is "once". +export function needsLoad(id: ListId, s: ListState, now: number, trackUri: string | undefined): boolean { + if (s.status === 'idle') return true + if (s.status === 'loading') return false + const aged = now - s.at >= RELOAD_MS + if (s.status === 'error') return aged + switch (id) { + case 'queue': return s.forUri !== trackUri || aged + case 'recent': return s.forUri !== trackUri + case 'playlists': return false + case 'devices': return aged + } +} + +export const rowsOf = (s: ListState): ListRow[] => (s.status === 'idle' ? [] : s.rows) +export const loadingFrom = (s: ListState): ListState => ({ status: 'loading', rows: rowsOf(s) }) +// Due at the next check: Devices each time they are shown and after a transfer, Up next after a skip. +// -Infinity, not 0: the engine's clock in tests starts at 0, where at: 0 would not be 15 s old. +export function expire(l: Lists, id: ListId): Lists { + const s = l.byId[id] + return s.status === 'ready' || s.status === 'error' ? { ...l, byId: { ...l.byId, [id]: { ...s, at: -Infinity } } } : l +} + +const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}` +// Spotify takes an offset into albums and playlists only; an artist, show or Liked Songs context plays the track alone. +const offsetable = (uri: string) => /^spotify:(album|playlist):/.test(uri) + +// The same track can be queued or played twice, so the index keeps each value unique; commands still go by URI. +export const queueRows = (q: QueueItem[]): ListRow[] => q.slice(0, LIST_MAX).map((t, i) => ({ value: `${i}:${t.uri}`, title: t.title, detail: t.artist, right: mmss(t.durationMs), cmd: { cmd: 'skipTo', uri: t.uri } })) + +export const recentRows = (r: RecentItem[]): ListRow[] => r.slice(0, LIST_MAX).map((t, i) => ({ + value: `${i}:${t.uri}`, title: t.title, detail: t.artist, right: mmss(t.durationMs), + cmd: t.contextUri && offsetable(t.contextUri) ? { cmd: 'playContext', uri: t.contextUri, offset: t.uri } : { cmd: 'play', uri: t.uri }, +})) + +export const playlistRows = (p: PlaylistItem[]): ListRow[] => p + .filter((x, i) => p.findIndex(y => y.uri === x.uri) === i) + .map(x => ({ value: x.uri, title: x.name, detail: x.owner, right: plural(x.tracks, 'track'), cmd: { cmd: 'playContext', uri: x.uri } })) + +export const deviceRows = (d: DeviceItem[]): ListRow[] => d.map(x => ({ value: x.id, title: x.name, detail: x.type, right: x.active ? 'active' : '', cmd: { cmd: 'transfer', deviceId: x.id }, ...(x.active && { active: true }) })) + +export function listError(e: ApiError): string { + switch (e.kind) { + case 'server': return `Spotify had a problem (${e.status}). glowup tries again shortly.` + case 'refused': return `Spotify refused the list (${e.reason}).` + case 'rate': return 'Spotify asked glowup to slow down. glowup tries again shortly.' + case 'premium': return 'Spotify needs Premium for this list.' + default: return 'Spotify did not answer. glowup tries again shortly.' + } +} + +export function skipText(r: SkipResult): string { + switch (r.kind) { + case 'done': return '' + case 'gone': return 'That track is no longer in the queue.' + case 'far': return 'That track is too far down the queue.' + case 'busy': return 'Still reading the queue, try again.' + case 'error': return listError(r.error) + } +} diff --git a/test/music-lists.test.ts b/test/music-lists.test.ts new file mode 100644 index 0000000..8e61bb7 --- /dev/null +++ b/test/music-lists.test.ts @@ -0,0 +1,95 @@ +import { expect } from 'claude-code/testing' +import { test } from './kit.ts' +import { EMPTY, deviceRows, expire, initialLists, listError, loadingFrom, needsLoad, playlistRows, queueRows, recentRows, rowsOf, skipText, type ListRow, type ListState } from '../hooks/music/lists.ts' + +const row = (v: string): ListRow => ({ value: v, title: v, detail: '', right: '', cmd: { cmd: 'play', uri: v } }) +const ready = (at: number, forUri?: string): ListState => ({ status: 'ready', rows: [row('a')], at, ...(forUri && { forUri }) }) + +test('the dialog opens on Up next, or on Devices when nothing is active', () => { + expect(initialLists().shown).toBe('queue') + expect(initialLists({ status: 'open', playing: false, ad: false, progressMs: 0, readAt: 0, source: 'api' }).shown).toBe('devices') + expect(initialLists({ status: 'open', playing: false, ad: false, progressMs: 0, readAt: 0, source: 'api', device: { id: 'd', name: 'D', type: 'Computer', isThisComputer: true } }).shown).toBe('queue') + expect(Object.values(initialLists().byId).every(s => s.status === 'idle')).toBe(true) +}) + +test('the reload rules', () => { + const T = 'spotify:track:t', U = 'spotify:track:u' + for (const id of ['queue', 'recent', 'playlists', 'devices'] as const) { + expect(needsLoad(id, { status: 'idle' }, 0, T)).toBe(true) + expect(needsLoad(id, { status: 'loading', rows: [] }, 99_999, U)).toBe(false) + } + // Up next: on a track change, and every 15 s + expect([needsLoad('queue', ready(0, T), 14_999, T), needsLoad('queue', ready(0, T), 15_000, T), needsLoad('queue', ready(0, T), 1, U)]).toEqual([false, true, true]) + // Recent: on a track change only + expect([needsLoad('recent', ready(0, T), 99_999, T), needsLoad('recent', ready(0, T), 1, U)]).toEqual([false, true]) + // Playlists: once per dialog open + expect([needsLoad('playlists', ready(0, T), 99_999, U)]).toEqual([false]) + // Devices: every 15 s, and whenever expire marks them + expect([needsLoad('devices', ready(0, T), 14_999, U), needsLoad('devices', ready(0, T), 15_000, T)]).toEqual([false, true]) + const l = { ...initialLists(), byId: { ...initialLists().byId, devices: ready(10_000) } } + expect(needsLoad('devices', expire(l, 'devices').byId.devices, 10_001, T)).toBe(true) + // a failed list is tried again after 15 s, whichever it is + const failed: ListState = { status: 'error', rows: [], reason: 'x', at: 0, forUri: T } + expect([needsLoad('playlists', failed, 14_999, T), needsLoad('playlists', failed, 15_000, T)]).toEqual([false, true]) +}) + +test('a reload keeps the rows; an error keeps them too', () => { + expect(loadingFrom(ready(0))).toEqual({ status: 'loading', rows: [row('a')] }) + expect(loadingFrom({ status: 'idle' })).toEqual({ status: 'loading', rows: [] }) + expect(rowsOf({ status: 'error', rows: [row('b')], reason: 'x', at: 0 })).toEqual([row('b')]) + expect(rowsOf({ status: 'idle' })).toEqual([]) +}) + +test('Up next rows skip to their track, at most 20, each value unique', () => { + const q = Array.from({ length: 25 }, (_, i) => ({ uri: i < 2 ? 'spotify:track:same' : `spotify:track:t${i}`, title: `T${i}`, artist: 'A', durationMs: 61_000 })) + const rows = queueRows(q) + expect(rows).toHaveLength(20) + expect(rows[0]).toEqual({ value: '0:spotify:track:same', title: 'T0', detail: 'A', right: '1:01', cmd: { cmd: 'skipTo', uri: 'spotify:track:same' } }) + expect(new Set(rows.map(r => r.value)).size).toBe(20) +}) + +test('Recent plays in an album or playlist context, else the track alone', () => { + const t = { title: 'T', artist: 'A', durationMs: 1000 } + const rows = recentRows([ + { ...t, uri: 'spotify:track:a', contextUri: 'spotify:playlist:p' }, + { ...t, uri: 'spotify:track:b', contextUri: 'spotify:album:x' }, + { ...t, uri: 'spotify:track:c', contextUri: 'spotify:artist:y' }, + { ...t, uri: 'spotify:track:d' }, + { ...t, uri: 'spotify:track:d' }, + ]) + expect(rows.map(r => r.cmd)).toEqual([ + { cmd: 'playContext', uri: 'spotify:playlist:p', offset: 'spotify:track:a' }, + { cmd: 'playContext', uri: 'spotify:album:x', offset: 'spotify:track:b' }, + { cmd: 'play', uri: 'spotify:track:c' }, + { cmd: 'play', uri: 'spotify:track:d' }, + { cmd: 'play', uri: 'spotify:track:d' }, + ]) + expect(rows.map(r => r.value)).toEqual(['0:spotify:track:a', '1:spotify:track:b', '2:spotify:track:c', '3:spotify:track:d', '4:spotify:track:d']) +}) + +test('playlists start their context; devices take playback and mark the active one', () => { + expect(playlistRows([{ uri: 'spotify:playlist:p', name: 'Focus', owner: 'Ada', tracks: 1 }, { uri: 'spotify:playlist:p', name: 'Dup', owner: 'Ada', tracks: 2 }, { uri: 'spotify:playlist:q', name: 'Q', owner: 'Bo', tracks: 12 }])).toEqual([ + { value: 'spotify:playlist:p', title: 'Focus', detail: 'Ada', right: '1 track', cmd: { cmd: 'playContext', uri: 'spotify:playlist:p' } }, + { value: 'spotify:playlist:q', title: 'Q', detail: 'Bo', right: '12 tracks', cmd: { cmd: 'playContext', uri: 'spotify:playlist:q' } }, + ]) + expect(deviceRows([{ id: 'ph', name: 'Pixel 9', type: 'Smartphone', active: true }, { id: 'dk', name: 'Desktop', type: 'Computer', active: false }])).toEqual([ + { value: 'ph', title: 'Pixel 9', detail: 'Smartphone', right: 'active', cmd: { cmd: 'transfer', deviceId: 'ph' }, active: true }, + { value: 'dk', title: 'Desktop', detail: 'Computer', right: '', cmd: { cmd: 'transfer', deviceId: 'dk' } }, + ]) +}) + +test('what an empty or failed list says', () => { + expect(EMPTY).toEqual({ queue: 'Nothing queued.', recent: 'Nothing played yet.', playlists: 'No playlists yet.', devices: 'No devices. Open Spotify on one.' }) + expect(listError({ kind: 'offline' })).toBe('Spotify did not answer. glowup tries again shortly.') + expect(listError({ kind: 'server', status: 503 })).toBe('Spotify had a problem (503). glowup tries again shortly.') + expect(listError({ kind: 'refused', reason: 'UNKNOWN' })).toBe('Spotify refused the list (UNKNOWN).') + expect(listError({ kind: 'rate', untilMs: 1 })).toBe('Spotify asked glowup to slow down. glowup tries again shortly.') +}) + +test('what a skip that did not land says', () => { + expect(skipText({ kind: 'far' })).toBe('That track is too far down the queue.') + expect(skipText({ kind: 'busy' })).toBe('Still reading the queue, try again.') + expect(skipText({ kind: 'gone' })).toBe('That track is no longer in the queue.') + expect(skipText({ kind: 'error', error: { kind: 'server', status: 502 } })).toBe(listError({ kind: 'server', status: 502 })) + expect(skipText({ kind: 'done' })).toBe('') +}) From cff042ed9195d19f2514e10fa0588756d142de57 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Tue, 13 Oct 2026 12:18:08 +0300 Subject: [PATCH 06/14] feat(pane): Up next, Recent, Playlists and Devices in the music dialog Signed-off-by: NovusEdge --- hooks/music/lists.ts | 2 + hooks/musicplayer.tsx | 56 +++++++++++++++++++++++++--- test/music-lists.test.ts | 3 ++ test/musicplayer.test.ts | 79 +++++++++++++++++++++++++++++++++++++++- 4 files changed, 133 insertions(+), 7 deletions(-) diff --git a/hooks/music/lists.ts b/hooks/music/lists.ts index 1017b50..df78de2 100644 --- a/hooks/music/lists.ts +++ b/hooks/music/lists.ts @@ -75,6 +75,8 @@ export function listError(e: ApiError): string { case 'server': return `Spotify had a problem (${e.status}). glowup tries again shortly.` case 'refused': return `Spotify refused the list (${e.reason}).` case 'rate': return 'Spotify asked glowup to slow down. glowup tries again shortly.' + case 'reconnect': return 'Spotify needs you to connect again. Run /glowup spotify connect.' + case 'noDevice': return 'No device is playing. Pick one in the Devices list.' case 'premium': return 'Spotify needs Premium for this list.' default: return 'Spotify did not answer. glowup tries again shortly.' } diff --git a/hooks/musicplayer.tsx b/hooks/musicplayer.tsx index 31d8861..e5e750a 100644 --- a/hooks/musicplayer.tsx +++ b/hooks/musicplayer.tsx @@ -1,8 +1,9 @@ // The Music dialog's body. Engine-free: register.tsx resolves the elements and builds the Clients. import type { Model } from './model.ts' import type { Theme } from './themes.ts' -import { fit, renderSegs, type Seg } from './layout.tsx' +import { fit, renderSegs, visibleLength, type Seg } from './layout.tsx' import { progressAt, type Music, type MusicCmd } from './music/parse.ts' +import { EMPTY, LISTS, rowsOf, type ListId, type ListRow, type ListState } from './music/lists.ts' import { barRow, type SeekBarProps } from './music/seek.ts' import { coverPlaceholder } from './music/art.ts' import type { MusicCaps } from './music/reader.ts' @@ -26,16 +27,17 @@ export type AccountView = | { step: 'pitch' } | { step: 'steps'; error?: string } | { step: 'waiting'; authUrl?: string; notice?: string } - | { step: 'connected'; name?: string } + | { step: 'connected'; name?: string; lists?: { shown: ListId; state: ListState } } | { step: 'reconnect' } | { step: 'refused'; text: string } -type Els = { Box: any; Text: any; Button: any; Input?: any } +type Els = { Box: any; Text: any; Button: any; Input?: any; Select?: any } export type PlayerNote = { text: string; tone: 'ok' | 'error' } export type PlayerView = { width: number; bodyRows: number; cover?: Seg[][]; caps?: MusicCaps; note?: PlayerNote; account?: AccountView } export type PlayerExtra = { onMusic: (cmd: MusicCmd) => void; onCopy: (url: string) => void; seek?: (props: SeekBarProps) => unknown; volume?: (props: SeekBarProps) => unknown onConnect?: () => void; onClientId?: (id: string) => void; onPaste?: (text: string) => void; onCancel?: () => void + onList?: (id: ListId) => void; onPick?: (value: string) => void } // State, note and account lines wrap, since a 30-column left column truncates "Open Spotify to see what's playing." @@ -79,7 +81,7 @@ export function renderPlayer(els: Els, m: Model, t: Theme, v: PlayerView, now: n const rw = side ? Math.max(1, v.width - LEFT_COLS - GAP) : v.width const right = ( - {accountRows(els, t, v.account ?? { step: 'pitch' }, s, extra) as any} + {accountRows(els, t, v.account ?? { step: 'pitch' }, s, extra, rw) as any} ) return ( @@ -90,7 +92,7 @@ export function renderPlayer(els: Els, m: Model, t: Theme, v: PlayerView, now: n ) } -function accountRows(els: Els, t: Theme, a: AccountView, s: Music, extra: PlayerExtra): unknown[] { +function accountRows(els: Els, t: Theme, a: AccountView, s: Music, extra: PlayerExtra, w: number): unknown[] { const { Box, Text, Button, Input } = els, c = t.colors // keyed wrap-* so the row-width tests leave it out, as they do the pitch const line = (text: string, key: string, color: string = c.dim) => wrapped(els, text, color, 'wrap-' + key) @@ -127,10 +129,12 @@ function accountRows(els: Els, t: Theme, a: AccountView, s: Music, extra: Player } case 'connected': { const o = s.status === 'open' ? s : undefined + const device = o?.device ? [line(`on: ${o.device.name}${o.device.isThisComputer ? ' (this computer)' : ''}`, 'device')] : [] + if (a.lists) return [...listRows(els, t, a.lists, w, extra), ...device] return [ line(a.name ? `Connected as ${a.name}.` : 'Connected.', 'name', c.text), ...(o?.upNext ? [line(upNextLine(o.upNext), 'next')] : []), - ...(o?.device ? [line(`on: ${o.device.name}${o.device.isThisComputer ? ' (this computer)' : ''}`, 'device')] : []), + ...device, line('/glowup spotify disconnect stops it.', 'disconnect'), ] } @@ -140,3 +144,43 @@ function accountRows(els: Els, t: Theme, a: AccountView, s: Music, extra: Player return [line(a.text, 'refused', c.fail)] } } + +// One row as a fixed-width label, since a Select option is one string: the title gives way first, +// the detail takes at most 40%, and the right column (a length, a count, "active") is dropped only when nothing else fits. +export function listLabel(r: ListRow, w: number): string { + const len = (s: string) => visibleLength([{ text: s, color: '' }]) + const pad = (s: string, n: number) => { + const f = fit([{ text: s, color: '' }], n) + return f.map(x => x.text).join('') + ' '.repeat(Math.max(0, n - visibleLength(f))) + } + const right = r.right && w >= len(r.right) + 8 ? ' ' + r.right : '' + const room = w - len(right) + const detailW = r.detail ? Math.min(len(r.detail), Math.floor(room * 0.4)) : 0 + const titleW = room - (detailW > 0 ? detailW + 2 : 0) + return pad(r.title, titleW) + (detailW > 0 ? ' ' + pad(r.detail, detailW) : '') + right +} + +function listRows(els: Els, t: Theme, v: { shown: ListId; state: ListState }, w: number, extra: PlayerExtra): unknown[] { + const { Box, Button, Select } = els, c = t.colors + // Loading…, empty and error lines wrap like the state and note lines, keyed wrap-* so the row-width tests skip them; + // only the Select-less rows are fixed-width labels + const say = (text: string, key: string) => wrapped(els, text, c.dim, 'wrap-' + key) + const tabs = ( + + {LISTS.map(([id, label], i) =>