From 5d59c513f4d24990ec3e66560ee9a9ea6d8e7a4f Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Wed, 5 Aug 2026 23:27:16 -0700 Subject: [PATCH 1/2] browse the complete spotify library --- src/api/follow.ts | 8 +- src/api/library.ts | 39 ++- src/api/types.ts | 1 + src/store/actions.ts | 4 +- src/store/drill.ts | 32 ++ src/store/library-browser.ts | 429 +++++++++++++++++++++++++++ src/store/rows.ts | 30 +- src/store/search.ts | 33 +-- src/ui/App.tsx | 75 ++++- src/ui/CatalogRows.tsx | 133 +++++++++ src/ui/LibraryView.tsx | 147 +++++++++ src/ui/Palette.tsx | 140 +-------- src/ui/keys.ts | 9 +- src/ui/library-navigation.ts | 57 ++++ test/__snapshots__/hud.test.tsx.snap | 4 +- test/drill.test.ts | 53 ++++ test/follow.test.ts | 14 + test/hud.test.tsx | 2 + test/library-browser.test.ts | 185 ++++++++++++ test/library-navigation.test.ts | 47 +++ test/library-view.test.tsx | 152 ++++++++++ test/library.test.ts | 39 +++ test/palette.test.tsx | 5 +- 23 files changed, 1454 insertions(+), 184 deletions(-) create mode 100644 src/store/drill.ts create mode 100644 src/store/library-browser.ts create mode 100644 src/ui/CatalogRows.tsx create mode 100644 src/ui/LibraryView.tsx create mode 100644 src/ui/library-navigation.ts create mode 100644 test/library-browser.test.ts create mode 100644 test/library-navigation.test.ts create mode 100644 test/library-view.test.tsx diff --git a/src/api/follow.ts b/src/api/follow.ts index 23543c1..1af2076 100644 --- a/src/api/follow.ts +++ b/src/api/follow.ts @@ -25,7 +25,10 @@ const FOLLOW_PAGE_SIZE = 50; */ export async function followedArtists( client: SpotifyClient, - options: { signal?: AbortSignal } = {}, + options: { + signal?: AbortSignal; + priority?: "foreground" | "background"; + } = {}, ): Promise { const artists: FullArtist[] = []; let after: string | undefined; @@ -39,6 +42,7 @@ export async function followedArtists( }>("/me/following", { query: { type: "artist", limit: FOLLOW_PAGE_SIZE, after }, ...(options.signal ? { signal: options.signal } : {}), + ...(options.priority ? { priority: options.priority } : {}), }); const page = response?.artists; @@ -46,7 +50,7 @@ export async function followedArtists( if (item !== null && item !== undefined) artists.push(item); } const cursor = page?.cursors?.after; - if (typeof cursor !== "string" || cursor.length === 0) break; + if (typeof cursor !== "string" || cursor.length === 0 || cursor === after) break; after = cursor; } diff --git a/src/api/library.ts b/src/api/library.ts index 83b5531..81ad574 100644 --- a/src/api/library.ts +++ b/src/api/library.ts @@ -1,10 +1,13 @@ import { SpotifyLimitError, type SpotifyClient } from "./client.ts"; import { myPlaylists, type Playlist } from "./playlists.ts"; -import type { Page, Track } from "./types.ts"; +import type { Page, SimpleAlbum, Track } from "./types.ts"; /** Spotify accepts at most 40 URIs on each current library endpoint. */ const LIBRARY_BATCH_SIZE = 40; +/** Spotify returns at most 50 saved albums per page. */ +const SAVED_ALBUM_PAGE_SIZE = 50; + export interface HomeData { recent: Track[]; top: Track[]; @@ -21,6 +24,40 @@ function compact(items: (T | null | undefined)[] | undefined): T[] { return (items ?? []).filter((item): item is T => item !== null && item !== undefined); } +/** Every album saved in the signed-in user's library, in Spotify's library order. */ +export async function savedAlbums( + client: SpotifyClient, + options: { + market?: string; + signal?: AbortSignal; + priority?: "foreground" | "background"; + } = {}, +): Promise { + const albums: SimpleAlbum[] = []; + + for (let offset = 0; ; offset += SAVED_ALBUM_PAGE_SIZE) { + const page = await client.request<{ + items?: ({ album?: SimpleAlbum | null } | null)[] | null; + next?: string | null; + }>("/me/albums", { + query: { + limit: SAVED_ALBUM_PAGE_SIZE, + offset, + market: options.market, + }, + ...(options.signal ? { signal: options.signal } : {}), + ...(options.priority ? { priority: options.priority } : {}), + }); + + for (const saved of page?.items ?? []) { + if (saved?.album !== null && saved?.album !== undefined) albums.push(saved.album); + } + if (page?.next === null || page?.next === undefined) break; + } + + return albums; +} + /** Keep the first occurrence of each track; recently-played repeats the same track often. */ function dedupe(tracks: Track[]): Track[] { const seen = new Set(); diff --git a/src/api/types.ts b/src/api/types.ts index 0a90feb..3d101c3 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -17,6 +17,7 @@ export interface SimpleAlbum { name: string; uri: string; images: Image[]; + artists?: SimpleArtist[]; release_date?: string; total_tracks?: number; } diff --git a/src/store/actions.ts b/src/store/actions.ts index 6912b52..366ad46 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -14,7 +14,7 @@ import { isTrack, type PlayableItem } from "../api/types.ts"; import type { Drill } from "./rows.ts"; import { usePlaylistCatalog } from "./playlists.ts"; -export type ActionOrigin = "playback" | "palette"; +export type ActionOrigin = "playback" | "palette" | "library"; export type ActionMode = "actions" | "playlists"; export type ActionEntry = @@ -93,7 +93,7 @@ export interface ActionsSlice { current: () => ActionEntry | null; currentPlaylist: () => Playlist | null; setPlaylistQuery: (query: string) => void; - /** Perform the highlighted action. Navigation is returned for App to hand to search. */ + /** Perform the highlighted action. Navigation is returned to the originating browse surface. */ activate: () => Promise; /** One-key save/unsave for the currently playing item. */ toggleSaved: (item: PlayableItem) => Promise; diff --git a/src/store/drill.ts b/src/store/drill.ts new file mode 100644 index 0000000..713d37d --- /dev/null +++ b/src/store/drill.ts @@ -0,0 +1,32 @@ +import { albumTracks, artistAlbums } from "../api/catalog.ts"; +import type { SpotifyClient } from "../api/client.ts"; +import { playlistItems } from "../api/playlists.ts"; +import { + toAlbumRows, + toArtistRows, + toPlaylistRows, + type Drill, + type Row, +} from "./rows.ts"; + +/** Load the rows behind a catalog or library drill target. */ +export async function rowsForDrill( + client: SpotifyClient, + target: Drill, + options: { market?: string; signal: AbortSignal }, +): Promise { + switch (target.kind) { + case "artist": + return toArtistRows(await artistAlbums(client, target.id, options)); + case "album": + return toAlbumRows( + { id: target.id, name: target.name, uri: target.uri }, + await albumTracks(client, target.id, options), + ); + case "playlist": + return toPlaylistRows( + { name: target.name, uri: target.uri }, + await playlistItems(client, target.id, options), + ); + } +} diff --git a/src/store/library-browser.ts b/src/store/library-browser.ts new file mode 100644 index 0000000..90fce66 --- /dev/null +++ b/src/store/library-browser.ts @@ -0,0 +1,429 @@ +import { create } from "zustand"; +import type { SpotifyClient } from "../api/client.ts"; +import { followedArtists } from "../api/follow.ts"; +import { savedAlbums } from "../api/library.ts"; +import { rowsForDrill } from "./drill.ts"; +import { failureMessage } from "./error.ts"; +import { usePlaylistCatalog } from "./playlists.ts"; +import { + filterRows, + firstSelectable, + isSelectable, + moveSelection, + moveSelectionPage, + toLibraryAlbumRows, + toLibraryArtistRows, + toLibraryPlaylistRows, + type Drill, + type Row, +} from "./rows.ts"; + +export const LIBRARY_SECTIONS = ["playlists", "albums", "artists"] as const; +export type LibrarySection = (typeof LIBRARY_SECTIONS)[number]; + +export const LIBRARY_SECTION_LABEL: Record = { + playlists: "PLAYLISTS", + albums: "ALBUMS", + artists: "ARTISTS", +}; + +interface LibraryFrame { + id: number; + rows: Row[]; + selected: number; + filter: string; + loading: boolean; + loaded: boolean; + error: string | null; + title?: string; + target?: Drill; +} + +type LibraryRoots = Record; + +export interface LibraryBrowserSlice { + open: boolean; + section: LibrarySection; + roots: LibraryRoots; + drills: LibraryFrame[]; + + configure: (client: SpotifyClient, market: string | undefined, meId: string) => void; + openLibrary: () => void; + closeLibrary: () => void; + setSection: (section: LibrarySection) => void; + cycleSection: (delta: -1 | 1) => void; + setQuery: (query: string) => void; + move: (delta: number) => void; + movePage: (direction: -1 | 1, pageSize: number) => void; + moveTo: (edge: "first" | "last") => void; + drillInto: (target: Drill) => void; + retry: () => void; + back: () => boolean; + + rows: () => Row[]; + selected: () => number; + text: () => string; + loading: () => boolean; + loaded: () => boolean; + error: () => string | null; + depth: () => number; + breadcrumb: () => string | null; + total: () => number; + current: () => Extract | null; +} + +let client: SpotifyClient | null = null; +let market: string | undefined; +let meId = ""; +let generation = 0; +let nextFrameId = 1; +let drillLoad: AbortController | null = null; +const rootLoads = new Map>(); +const rootControllers = new Map(); + +function emptyFrame(id = 0): LibraryFrame { + return { + id, + rows: [], + selected: -1, + filter: "", + loading: false, + loaded: false, + error: null, + }; +} + +function emptyRoots(): LibraryRoots { + return { + playlists: emptyFrame(), + albums: emptyFrame(), + artists: emptyFrame(), + }; +} + +function activeFrame(state: LibraryBrowserSlice): LibraryFrame { + return state.drills.at(-1) ?? state.roots[state.section]; +} + +function resultCount(rows: readonly Row[]): number { + return rows.reduce((count, row) => count + (row.kind === "result" ? 1 : 0), 0); +} + +function selectedReference(frame: LibraryFrame): string | null { + const row = filterRows(frame.rows, frame.filter)[frame.selected]; + return row?.kind === "result" ? row.referenceUri : null; +} + +function frameWithRows(frame: LibraryFrame, rows: Row[]): LibraryFrame { + const reference = selectedReference(frame); + const visible = filterRows(rows, frame.filter); + const same = reference === null + ? -1 + : visible.findIndex((row) => row.kind === "result" && row.referenceUri === reference); + return { + ...frame, + rows, + selected: same === -1 ? firstSelectable(visible) : same, + loading: false, + loaded: true, + error: null, + }; +} + +function cancelAll(): void { + drillLoad?.abort(); + drillLoad = null; + for (const controller of rootControllers.values()) controller.abort(); + rootControllers.clear(); + rootLoads.clear(); +} + +export const useLibraryBrowser = create((set, get) => { + const patchRoot = (section: LibrarySection, patch: Partial) => { + set((state) => ({ + roots: { + ...state.roots, + [section]: { ...state.roots[section], ...patch }, + }, + })); + }; + + const loadRoot = (section: LibrarySection, force = false): Promise => { + const existing = rootLoads.get(section); + if (existing !== undefined) return existing; + if (get().roots[section].loaded && !force) return Promise.resolve(); + if (client === null || meId === "") return Promise.resolve(); + + const requestClient = client; + const requestMarket = market; + const requestGeneration = generation; + const controller = new AbortController(); + rootControllers.set(section, controller); + patchRoot(section, { loading: true, error: null }); + + let request: Promise; + request = (async () => { + try { + const rows = await (async (): Promise => { + switch (section) { + case "playlists": { + const playlists = await usePlaylistCatalog.getState().load("foreground", force); + return toLibraryPlaylistRows(playlists); + } + case "albums": { + const albums = await savedAlbums(requestClient, { + market: requestMarket, + signal: controller.signal, + priority: "foreground", + }); + return toLibraryAlbumRows(albums); + } + case "artists": { + const artists = await followedArtists(requestClient, { + signal: controller.signal, + priority: "foreground", + }); + return toLibraryArtistRows(artists); + } + } + })(); + if (controller.signal.aborted || generation !== requestGeneration) return; + set((state) => ({ + roots: { + ...state.roots, + [section]: frameWithRows(state.roots[section], rows), + }, + })); + } catch (error) { + if (controller.signal.aborted || generation !== requestGeneration) return; + patchRoot(section, { + loading: false, + error: failureMessage(`load ${section}`, error), + }); + } finally { + if (rootControllers.get(section) === controller) { + rootControllers.delete(section); + rootLoads.delete(section); + } + } + })(); + rootLoads.set(section, request); + return request; + }; + + const loadDrill = (target: Drill, frameId: number) => { + if (client === null) return; + drillLoad?.abort(); + const controller = new AbortController(); + drillLoad = controller; + const requestClient = client; + const requestMarket = market; + const requestGeneration = generation; + + void rowsForDrill(requestClient, target, { + market: requestMarket, + signal: controller.signal, + }) + .then((rows) => { + if (controller.signal.aborted || generation !== requestGeneration) return; + set((state) => { + const top = state.drills.at(-1); + if (top?.id !== frameId) return state; + return { + drills: [ + ...state.drills.slice(0, -1), + frameWithRows(top, rows), + ], + }; + }); + }) + .catch((error: unknown) => { + if (controller.signal.aborted || generation !== requestGeneration) return; + set((state) => { + const top = state.drills.at(-1); + if (top?.id !== frameId) return state; + return { + drills: [ + ...state.drills.slice(0, -1), + { + ...top, + loading: false, + error: failureMessage("open this item", error), + }, + ], + }; + }); + }) + .finally(() => { + if (drillLoad === controller) drillLoad = null; + }); + }; + + return { + open: false, + section: "playlists", + roots: emptyRoots(), + drills: [], + + configure(nextClient, nextMarket, nextMeId) { + usePlaylistCatalog.getState().configure(nextClient, nextMeId); + if (client !== nextClient || market !== nextMarket || meId !== nextMeId) { + generation++; + cancelAll(); + set({ + open: false, + section: "playlists", + roots: emptyRoots(), + drills: [], + }); + } + client = nextClient; + market = nextMarket; + meId = nextMeId; + }, + + openLibrary() { + const current = get().roots; + const reset = (frame: LibraryFrame): LibraryFrame => ({ + ...frame, + filter: "", + selected: firstSelectable(frame.rows), + error: null, + }); + const roots: LibraryRoots = { + playlists: reset(current.playlists), + albums: reset(current.albums), + artists: reset(current.artists), + }; + set({ open: true, section: "playlists", roots, drills: [] }); + void loadRoot("playlists"); + }, + + closeLibrary() { + drillLoad?.abort(); + drillLoad = null; + set({ open: false, drills: [] }); + }, + + setSection(section) { + const state = get(); + if (state.drills.length > 0 || state.section === section) return; + set({ section }); + void loadRoot(section); + }, + + cycleSection(delta) { + const state = get(); + if (state.drills.length > 0) return; + const current = LIBRARY_SECTIONS.indexOf(state.section); + const next = (current + delta + LIBRARY_SECTIONS.length) % LIBRARY_SECTIONS.length; + const section = LIBRARY_SECTIONS[next]; + if (section !== undefined) get().setSection(section); + }, + + setQuery(query) { + const state = get(); + const frame = activeFrame(state); + const selected = firstSelectable(filterRows(frame.rows, query)); + if (state.drills.length === 0) { + patchRoot(state.section, { filter: query, selected }); + return; + } + set({ + drills: [ + ...state.drills.slice(0, -1), + { ...frame, filter: query, selected }, + ], + }); + }, + + move(delta) { + const state = get(); + const frame = activeFrame(state); + const rows = filterRows(frame.rows, frame.filter); + if (rows.length === 0) return; + const selected = moveSelection(rows, frame.selected, delta); + if (state.drills.length === 0) patchRoot(state.section, { selected }); + else set({ drills: [...state.drills.slice(0, -1), { ...frame, selected }] }); + }, + + movePage(direction, pageSize) { + const state = get(); + const frame = activeFrame(state); + const rows = filterRows(frame.rows, frame.filter); + if (rows.length === 0) return; + const selected = moveSelectionPage(rows, frame.selected, direction, pageSize); + if (state.drills.length === 0) patchRoot(state.section, { selected }); + else set({ drills: [...state.drills.slice(0, -1), { ...frame, selected }] }); + }, + + moveTo(edge) { + const state = get(); + const frame = activeFrame(state); + const rows = filterRows(frame.rows, frame.filter); + const selected = edge === "first" + ? firstSelectable(rows) + : rows.findLastIndex(isSelectable); + if (selected < 0) return; + if (state.drills.length === 0) patchRoot(state.section, { selected }); + else set({ drills: [...state.drills.slice(0, -1), { ...frame, selected }] }); + }, + + drillInto(target) { + if (client === null) return; + const id = nextFrameId++; + const frame: LibraryFrame = { + ...emptyFrame(id), + title: target.name, + target, + loading: true, + }; + set((state) => ({ drills: [...state.drills, frame] })); + loadDrill(target, id); + }, + + retry() { + const state = get(); + const top = state.drills.at(-1); + if (top?.target !== undefined) { + set({ + drills: [ + ...state.drills.slice(0, -1), + { ...top, rows: [], selected: -1, loading: true, error: null }, + ], + }); + loadDrill(top.target, top.id); + return; + } + void loadRoot(state.section, true); + }, + + back() { + const state = get(); + if (state.drills.length === 0) return false; + drillLoad?.abort(); + drillLoad = null; + set({ drills: state.drills.slice(0, -1) }); + return true; + }, + + rows() { + const frame = activeFrame(get()); + return filterRows(frame.rows, frame.filter); + }, + selected: () => activeFrame(get()).selected, + text: () => activeFrame(get()).filter, + loading: () => activeFrame(get()).loading, + loaded: () => activeFrame(get()).loaded, + error: () => activeFrame(get()).error, + depth: () => get().drills.length + 1, + breadcrumb: () => activeFrame(get()).title ?? null, + total: () => resultCount(activeFrame(get()).rows), + current() { + const state = get(); + const frame = activeFrame(state); + const row = filterRows(frame.rows, frame.filter)[frame.selected]; + return row?.kind === "result" ? row : null; + }, + }; +}); diff --git a/src/store/rows.ts b/src/store/rows.ts index a52310f..2073b33 100644 --- a/src/store/rows.ts +++ b/src/store/rows.ts @@ -109,14 +109,17 @@ function artistRow(artist: SimpleArtist): ResultRow { }; } -function albumRow(album: SimpleAlbum): ResultRow { +function albumRow(album: SimpleAlbum, options: { showArtist?: boolean } = {}): ResultRow { const year = album.release_date?.slice(0, 4) ?? ""; const tracks = album.total_tracks === undefined ? "" : `${album.total_tracks} tracks`; + const artists = album.artists?.map((artist) => artist.name).join(", ") ?? ""; return { kind: "result", label: album.name, - detail: [year, tracks].filter((part) => part.length > 0).join(" · "), - trailing: "", + detail: options.showArtist === true && artists.length > 0 + ? artists + : [year, tracks].filter((part) => part.length > 0).join(" · "), + trailing: options.showArtist === true ? year : "", referenceUri: album.uri, play: { contextUri: album.uri }, drill: { kind: "album", id: album.id, name: album.name, uri: album.uri }, @@ -136,11 +139,11 @@ function playlistRow(playlist: { uri: string; ownerName: string; mine: boolean; -}): ResultRow { +}, options: { showOwner?: boolean } = {}): ResultRow { return { kind: "result", label: playlist.name, - detail: playlist.mine ? "" : playlist.ownerName, + detail: options.showOwner === true || !playlist.mine ? playlist.ownerName : "", trailing: "", referenceUri: playlist.uri, play: { contextUri: playlist.uri }, @@ -157,6 +160,21 @@ function playlistRow(playlist: { }; } +/** Complete playlist-library rows, including the owner column for every entry. */ +export function toLibraryPlaylistRows(playlists: Playlist[]): Row[] { + return playlists.map((playlist) => playlistRow(playlist, { showOwner: true })); +} + +/** Complete saved-album rows. */ +export function toLibraryAlbumRows(albums: SimpleAlbum[]): Row[] { + return albums.map((album) => albumRow(album, { showArtist: true })); +} + +/** Complete followed-artist rows. */ +export function toLibraryArtistRows(artists: SimpleArtist[]): Row[] { + return artists.map(artistRow); +} + /** A search hit, which reports its owner but not whether that owner is us. */ function searchPlaylistRow(playlist: SimplePlaylist, meId: string): ResultRow { const ownerId = playlist.owner?.id ?? ""; @@ -228,7 +246,7 @@ export function toAlbumRows( /** Rows for an artist's releases. */ export function toArtistRows(albums: SimpleAlbum[]): Row[] { - return albums.map(albumRow); + return albums.map((album) => albumRow(album)); } /** A resolved pasted URI/URL is shown as one deliberate, confirmable navigation result. */ diff --git a/src/store/search.ts b/src/store/search.ts index c6c3112..c522741 100644 --- a/src/store/search.ts +++ b/src/store/search.ts @@ -1,8 +1,6 @@ import { create } from "zustand"; -import { albumTracks, artistAlbums } from "../api/catalog.ts"; import type { SpotifyClient } from "../api/client.ts"; import { EMPTY_HOME, fetchHome, type HomeData } from "../api/library.ts"; -import { playlistItems } from "../api/playlists.ts"; import { resolveSpotifyReference, supportsSpotifyReference, @@ -19,6 +17,7 @@ import { looksLikeSpotifyReference, parseSpotifyReference, } from "../spotify/reference.ts"; +import { rowsForDrill } from "./drill.ts"; import { failureMessage } from "./error.ts"; import { usePlaylistCatalog } from "./playlists.ts"; import { @@ -28,10 +27,7 @@ import { matchPlaylists, moveSelection, moveSelectionPage, - toAlbumRows, - toArtistRows, toHomeRows, - toPlaylistRows, toReferenceRows, toRows, type Drill, @@ -202,28 +198,6 @@ function replaceRootRows( return selectedIndex === -1 ? next : { ...next, selected: selectedIndex }; } -/** The rows behind one drill target. */ -async function rowsFor( - client: SpotifyClient, - target: Drill, - options: { market?: string; signal: AbortSignal }, -): Promise { - switch (target.kind) { - case "artist": - return toArtistRows(await artistAlbums(client, target.id, options)); - case "album": - return toAlbumRows( - { id: target.id, name: target.name, uri: target.uri }, - await albumTracks(client, target.id, options), - ); - case "playlist": - return toPlaylistRows( - { name: target.name, uri: target.uri }, - await playlistItems(client, target.id, options), - ); - } -} - function cancelPending(): void { if (debounce !== null) clearTimeout(debounce); debounce = null; @@ -742,7 +716,10 @@ export const useSearch = create((set, get) => ({ void (async () => { try { - const rows = await rowsFor(client, target, { market, signal: controller.signal }); + const rows = await rowsForDrill(client, target, { + market, + signal: controller.signal, + }); if (controller.signal.aborted) return; set({ frames: withTop(get().frames, { rows, selected: firstSelectable(rows), loading: false }), diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 1338936..3dee9bc 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -18,6 +18,7 @@ import { useActions } from "../store/actions.ts"; import { useDevices } from "../store/devices.ts"; import { failureMessage } from "../store/error.ts"; import { useLyrics } from "../store/lyrics.ts"; +import { useLibraryBrowser } from "../store/library-browser.ts"; import { usePlayback } from "../store/playback.ts"; import { playbackContextDrill } from "../store/playback-context.ts"; import { useQueue } from "../store/queue.ts"; @@ -45,6 +46,12 @@ import { HUD_LEFT, hudTopForHeight } from "./Hud.tsx"; import { KeyHints, KEY_HINT_ROWS } from "./KeyHints.tsx"; import { isPlainShortcut } from "./keys.ts"; import { KeymapOverlay } from "./KeymapOverlay.tsx"; +import { + LibraryView, + LIBRARY_PROMPT_ROW, + libraryListHeight, +} from "./LibraryView.tsx"; +import { applyLibraryNavigation } from "./library-navigation.ts"; import { applyPaletteNavigation } from "./palette-navigation.ts"; import { OVERLAY_PADDING_X, overlayListHeight } from "./Overlay.tsx"; import { Palette, PROMPT_ROW } from "./Palette.tsx"; @@ -139,6 +146,7 @@ export function App({ version }: { version: string }) { // Must sit with the other hooks: below the `boot.phase` early returns the hook count would // differ between the loading and ready renders, which React rejects outright. const paletteOpen = useSearch((s) => s.open); + const libraryOpen = useLibraryBrowser((s) => s.open); const devicesOpen = useDevices((s) => s.open); const queueOpen = useQueue((s) => s.open); const actionsOpen = useActions((s) => s.open); @@ -147,7 +155,7 @@ export function App({ version }: { version: string }) { const lyricsOpen = useLyrics((s) => s.open); const [keysOpen, setKeysOpen] = useState(false); const overlayOpen = - paletteOpen || devicesOpen || queueOpen || actionsOpen || lyricsOpen || keysOpen; + paletteOpen || libraryOpen || devicesOpen || queueOpen || actionsOpen || lyricsOpen || keysOpen; const bootPlayer = boot.phase === "ready" ? boot.player : null; const bootClient = boot.phase === "ready" ? boot.client : null; const bootAuthorizationId = boot.phase === "ready" ? boot.authorizationId : null; @@ -219,6 +227,7 @@ export function App({ version }: { version: string }) { const player = new PlayerApi(client); if (me !== null) { useSearch.getState().configure(client, me.country, me.id); + useLibraryBrowser.getState().configure(client, me.country, me.id); useActions.getState().configure(client, me.id); } useDevices.getState().configure(player, undefined, me?.id ?? null); @@ -383,6 +392,7 @@ export function App({ version }: { version: string }) { setProfileRecoveryFailed(false); setProfileRecoveryRequest(0); useSearch.getState().configure(bootClient, profile.country, profile.id); + useLibraryBrowser.getState().configure(bootClient, profile.country, profile.id); useActions.getState().configure(bootClient, profile.id); usePlayback.setState({ error: null }); setBoot((current) => @@ -487,6 +497,7 @@ export function App({ version }: { version: string }) { const queue = useQueue.getState(); const actions = useActions.getState(); const lyrics = useLyrics.getState(); + const library = useLibraryBrowser.getState(); const copySpotify = (uri: string, label: string, asLink: boolean) => { const value = asLink ? spotifyOpenUrl(uri) : uri; @@ -568,9 +579,10 @@ export function App({ version }: { version: string }) { } else if (key.name === "return" || key.name === "enter") { void actions.activate().then((result) => { if (result?.kind !== "drill") return; - // Selected-item actions return to the existing palette stack; playing-item actions open - // a fresh stack. Both routes then inherit the palette's normal Back behavior. + // Selected-item actions return to the surface that launched them. Playing-item actions + // open a fresh search stack because they have no existing browse context. if (result.origin === "palette") palette.drillInto(result.drill); + else if (result.origin === "library") library.drillInto(result.drill); else palette.openAt(result.drill); }); } @@ -587,6 +599,54 @@ export function App({ version }: { version: string }) { return; } + // Library is one coherent filter-and-list mode. The input keeps printable characters while + // Tab changes the root section and non-printing keys navigate the visible rows. + if (library.open) { + const isEnter = key.name === "return" || key.name === "enter"; + const viewport = libraryListHeight(height); + const navigated = applyLibraryNavigation(key, library, { + canChangeSection: library.depth() === 1, + pageSize: viewport, + }); + if (navigated) return; + + if (key.ctrl && key.name === "space") { + const row = library.current(); + if (row?.actionItem !== undefined) actions.openActions(row.actionItem, "library"); + } else if (key.name === "escape") { + if (!library.back()) library.closeLibrary(); + } else if (isEnter) { + const row = library.current(); + if (row === null) { + if (library.error() !== null) library.retry(); + return; + } + if (boot.phase !== "ready") return; + + if (key.ctrl) { + const uris = "uris" in row.play ? row.play.uris : []; + const uri = uris[0]; + if (uri !== undefined) { + void useQueue.getState().enqueue(uri, row.label); + library.closeLibrary(); + } + return; + } + + if (row.drill !== undefined) { + library.drillInto(row.drill); + return; + } + + const preview = row.actionItem === undefined + ? { label: row.label } + : { label: row.label, item: row.actionItem }; + void usePlayback.getState().playSelection(row.play, preview); + library.closeLibrary(); + } + return; + } + // Search remains one coherent input mode: printable keys edit the query, arrows move the // selection, and Tab cycles the visible catalog scope directly. There is no hidden state in // which letters suddenly become navigation commands. @@ -671,6 +731,12 @@ export function App({ version }: { version: string }) { return; } + if (isPlainShortcut(key, "b")) { + if (key.repeated || boot.me === null) return; + library.openLibrary(); + return; + } + if (key.name === "d") { if (boot.me === null) return; picker.openPicker(); @@ -930,6 +996,8 @@ export function App({ version }: { version: string }) { solidRow={ paletteOpen && !actionsOpen ? PROMPT_ROW + : libraryOpen && !actionsOpen + ? LIBRARY_PROMPT_ROW : actionsOpen && actionMode === "playlists" ? PLAYLIST_PROMPT_ROW : null @@ -988,6 +1056,7 @@ export function App({ version }: { version: string }) { )} {paletteOpen && !actionsOpen ? : null} + {libraryOpen && !actionsOpen ? : null} {devicesOpen ? : null} {queueOpen ? : null} {actionsOpen && actionMode === "actions" ? ( diff --git a/src/ui/CatalogRows.tsx b/src/ui/CatalogRows.tsx new file mode 100644 index 0000000..44966a9 --- /dev/null +++ b/src/ui/CatalogRows.tsx @@ -0,0 +1,133 @@ +import { windowStart, type Row } from "../store/rows.ts"; +import { padColumns, truncate } from "./text.ts"; +import { theme } from "./theme.ts"; + +interface Columns { + label: number; + detail: number; + trailing: number; +} + +const LABEL_MAX = 40; +const DETAIL_MAX = 28; + +function widest(rows: Row[], field: "label" | "detail"): number { + return rows.reduce((width, row) => { + if (row.kind !== "result") return width; + return Math.max(width, Bun.stringWidth(row[field])); + }, 0); +} + +/** Stable column widths for every row in one catalog or library list. */ +function columnsFor(width: number, rows: Row[]): Columns { + const trailing = 6; + const gutter = 2; + const available = Math.max(10, width - gutter - trailing - 2); + const desiredLabel = Math.min(LABEL_MAX, Math.max(8, widest(rows, "label"))); + const desiredDetail = Math.min(DETAIL_MAX, widest(rows, "detail")); + const label = Math.min(desiredLabel, Math.max(8, Math.floor(available * 0.58))); + const detail = Math.min(desiredDetail, Math.max(0, available - label)); + return { label, detail, trailing }; +} + +function HeaderRow({ row }: { row: Extract }) { + return ( + + {row.label} + + ); +} + +function ResultRow({ + row, + selected, + columns, +}: { + row: Extract; + selected: boolean; + columns: Columns; +}) { + return ( + + {selected ? "▌" : " "} + + {padColumns(row.label, columns.label)} + + + {padColumns(row.detail, columns.detail)} + + {row.trailing.padStart(columns.trailing)} + + ); +} + +function MoreRow({ + row, + selected, + columns, +}: { + row: Extract; + selected: boolean; + columns: Columns; +}) { + return ( + + {selected ? "▌" : " "} + + {truncate(row.label, columns.label)} + + {row.detail === "" ? null : ( + + {truncate(row.detail, columns.detail + columns.trailing + 1)} + + )} + + ); +} + +/** A windowed, selectable rendering of the shared Row model. */ +export function CatalogRows({ + rows, + selected, + width, + height, +}: { + rows: Row[]; + selected: number; + width: number; + height: number; +}) { + const columns = columnsFor(width, rows); + const start = windowStart(rows, selected, height); + const visible = rows.slice(start, start + height); + + return visible.map((row, offset) => + row.kind === "header" ? ( + + ) : row.kind === "result" ? ( + + ) : ( + + ), + ); +} diff --git a/src/ui/LibraryView.tsx b/src/ui/LibraryView.tsx new file mode 100644 index 0000000..f648ee0 --- /dev/null +++ b/src/ui/LibraryView.tsx @@ -0,0 +1,147 @@ +import type { MouseEvent } from "@opentui/core"; +import { + LIBRARY_SECTIONS, + LIBRARY_SECTION_LABEL, + useLibraryBrowser, +} from "../store/library-browser.ts"; +import { filterRows } from "../store/rows.ts"; +import { CatalogRows } from "./CatalogRows.tsx"; +import { + Overlay, + OVERLAY_TOP, + OverlayTitle, + overlayInnerWidth, + overlayListHeight, + scrollSteps, +} from "./Overlay.tsx"; +import { truncate } from "./text.ts"; +import { theme } from "./theme.ts"; + +/** The second header row owns the focused library filter caret. */ +export const LIBRARY_PROMPT_ROW = OVERLAY_TOP + 1; + +/** Rows available to the library after its two-row header and shared overlay chrome. */ +export function libraryListHeight(height: number): number { + return overlayListHeight(height, 1); +} + +const ROOT_NOUN = { + playlists: "playlist", + albums: "album", + artists: "artist", +} as const; + +/** Complete, locally filterable Spotify library with in-surface catalog drilling. */ +export function LibraryView({ width, height }: { width: number; height: number }) { + const section = useLibraryBrowser((state) => state.section); + const roots = useLibraryBrowser((state) => state.roots); + const drills = useLibraryBrowser((state) => state.drills); + const frame = drills.at(-1) ?? roots[section]; + const rows = filterRows(frame.rows, frame.filter); + const selected = frame.selected; + const query = frame.filter; + const loading = frame.loading; + const loaded = frame.loaded; + const error = frame.error; + const depth = drills.length + 1; + const breadcrumb = frame.title ?? null; + const total = frame.rows.filter((row) => row.kind === "result").length; + const visibleCount = rows.filter((row) => row.kind === "result").length; + const inner = overlayInnerWidth(width); + const listHeight = libraryListHeight(height); + + const status = (() => { + if (error !== null) return error; + if (loading || !loaded) { + return depth > 1 + ? "loading…" + : `loading ${LIBRARY_SECTION_LABEL[section].toLowerCase()}…`; + } + if (depth > 1) { + if (total === 0) return "nothing here"; + return query.trim().length === 0 + ? `${total} ${total === 1 ? "item" : "items"}` + : `${visibleCount} of ${total} items`; + } + + const noun = ROOT_NOUN[section]; + if (total === 0) { + if (noun === "artist") return "no followed artists"; + if (noun === "album") return "no saved albums"; + return "no playlists"; + } + return query.trim().length === 0 + ? `${total} ${total === 1 ? noun : `${noun}s`}` + : `${visibleCount} of ${total} ${total === 1 ? noun : `${noun}s`}`; + })(); + + const hints = depth > 1 + ? inner < 72 + ? "↑↓ move · ↵ play · esc back" + : "type to filter · ↑↓ move · ↵ play · esc back" + : inner < 72 + ? "tab section · ↑↓ move · ↵ open/play" + : "tab section · ↑↓ move · ↵ open/play · esc close"; + + const handleMouseScroll = (event: MouseEvent) => { + const steps = scrollSteps(event); + if (steps === null) return; + useLibraryBrowser.getState().move(steps); + event.stopPropagation(); + }; + + return ( + + + + {depth === 1 ? ( + + {LIBRARY_SECTIONS.map((candidate) => ( + + {candidate === section ? ( + {LIBRARY_SECTION_LABEL[candidate]} + ) : ( + LIBRARY_SECTION_LABEL[candidate] + )} + + ))} + + ) : ( + + {truncate(breadcrumb ?? "", Math.max(0, inner - 14))} + + )} + + + + + + + + + } + > + + + ); +} diff --git a/src/ui/Palette.tsx b/src/ui/Palette.tsx index d8fd8d4..644f6a5 100644 --- a/src/ui/Palette.tsx +++ b/src/ui/Palette.tsx @@ -1,6 +1,5 @@ import type { MouseEvent } from "@opentui/core"; import { useSearch } from "../store/search.ts"; -import { windowStart, type Row } from "../store/rows.ts"; import { SEARCH_SCOPE_LABEL } from "../api/search.ts"; import { Overlay, @@ -9,7 +8,8 @@ import { overlayListHeight, scrollSteps, } from "./Overlay.tsx"; -import { padColumns, truncate } from "./text.ts"; +import { CatalogRows } from "./CatalogRows.tsx"; +import { truncate } from "./text.ts"; import { theme } from "./theme.ts"; /** @@ -20,112 +20,6 @@ import { theme } from "./theme.ts"; */ export const PROMPT_ROW = OVERLAY_TOP; -interface Columns { - label: number; - detail: number; - trailing: number; -} - -/** - * Column widths for the whole list. - * - * Computed once and applied to every row, including rows with no detail or trailing value. Sizing - * per row instead made the detail and duration columns start at different offsets depending on - * which fields that row happened to have. - */ -const LABEL_MAX = 40; -const DETAIL_MAX = 28; - -function widest(rows: Row[], field: "label" | "detail"): number { - return rows.reduce((width, row) => { - if (row.kind !== "result") return width; - return Math.max(width, Bun.stringWidth(row[field])); - }, 0); -} - -function columnsFor(width: number, rows: Row[]): Columns { - const trailing = 6; - const gutter = 2; - const available = Math.max(10, width - gutter - trailing - 2); - const desiredLabel = Math.min(LABEL_MAX, Math.max(8, widest(rows, "label"))); - const desiredDetail = Math.min(DETAIL_MAX, widest(rows, "detail")); - // Size to the content at roomy widths. Only fall back to a proportional split when the terminal - // cannot fit both desired columns; this keeps related values together instead of stretching them - // across every spare cell. - const label = Math.min(desiredLabel, Math.max(8, Math.floor(available * 0.58))); - const detail = Math.min(desiredDetail, Math.max(0, available - label)); - return { label, detail, trailing }; -} - -function HeaderRow({ - row, -}: { - row: Extract; -}) { - return ( - - {row.label} - - ); -} - -function ResultRow({ - row, - selected, - columns, -}: { - row: Extract; - selected: boolean; - columns: Columns; -}) { - return ( - - {selected ? "▌" : " "} - - {padColumns(row.label, columns.label)} - - - {padColumns(row.detail, columns.detail)} - - {row.trailing.padStart(columns.trailing)} - - ); -} - -function MoreRow({ - row, - selected, - columns, -}: { - row: Extract; - selected: boolean; - columns: Columns; -}) { - return ( - - {selected ? "▌" : " "} - - {truncate(row.label, columns.label)} - - {row.detail === "" ? null : ( - - {truncate(row.detail, columns.detail + columns.trailing + 1)} - - )} - - ); -} - /** * Search palette, overlaid on a dimmed cover. * @@ -154,10 +48,7 @@ export function Palette({ width, height }: { width: number; height: number }) { const scopeLabel = showingReference ? "DIRECT" : SEARCH_SCOPE_LABEL[scope]; const inner = overlayInnerWidth(width); - const columns = columnsFor(inner, rows); const listHeight = overlayListHeight(height); - const start = windowStart(rows, selected, listHeight); - const visible = rows.slice(start, start + listHeight); const resultCount = rows.filter((r) => r.kind === "result").length; const status = (() => { @@ -176,8 +67,8 @@ export function Palette({ width, height }: { width: number; height: number }) { return resultCount === 0 ? `type to search ${destination}` : scope === "all" - ? "your library — or type to search" - : `your library — search scope: ${destination}`; + ? "browse highlights — or type to search" + : `search scope: ${destination}`; } if (showingReference) { return resultCount === 0 @@ -249,28 +140,7 @@ export function Palette({ width, height }: { width: number; height: number }) { } > - {visible.map((row, offset) => - row.kind === "header" ? ( - - ) : row.kind === "result" ? ( - - ) : ( - - ), - )} + ); } diff --git a/src/ui/keys.ts b/src/ui/keys.ts index b093472..c4003e6 100644 --- a/src/ui/keys.ts +++ b/src/ui/keys.ts @@ -61,6 +61,7 @@ export const KEYMAP: KeyGroup[] = [ label: "BROWSE", bindings: [ { key: "/", action: "search" }, + { key: "b", action: "library" }, { key: "a", action: "actions" }, { key: "f", action: "save / unsave" }, { key: "l", action: "lyrics" }, @@ -86,10 +87,10 @@ export const KEYMAP: KeyGroup[] = [ ], }, { - label: "SEARCH", + label: "SEARCH / LIBRARY", bindings: [ // Spelled out: the ⇧ glyph has ambiguous terminal width and can misalign the column. - { key: "tab / shift+tab", action: "next / previous scope" }, + { key: "tab / shift+tab", action: "next / previous category" }, { key: "↵", action: "play / open" }, { key: "ctrl+↵", action: "queue it" }, { key: "ctrl+space", action: "actions" }, @@ -118,6 +119,7 @@ export function barFor(state: { if (!state.hasTrack) { return [ ...(state.canBrowse ? [{ key: "/", action: "search" }] : []), + ...(state.canBrowse ? [{ key: "b", action: "library" }] : []), ...(state.canBrowse ? [{ key: "d", action: "device" }] : []), ...(!state.canBrowse ? [{ key: "r", action: "retry account" }] : []), { key: "?", action: "keys" }, @@ -130,13 +132,14 @@ export function barFor(state: { ...(state.canBrowse ? [ { key: "/", action: "search" }, + { key: "b", action: "library" }, { key: "a", action: "actions" }, ] : []), + { key: "?", action: "keys" }, { key: "l", action: "lyrics" }, { key: "u", action: "queue" }, ...(state.canBrowse ? [{ key: "d", action: "device" }] : []), ...(!state.canBrowse ? [{ key: "r", action: "retry account" }] : []), - { key: "?", action: "keys" }, ]; } diff --git a/src/ui/library-navigation.ts b/src/ui/library-navigation.ts new file mode 100644 index 0000000..a77a62d --- /dev/null +++ b/src/ui/library-navigation.ts @@ -0,0 +1,57 @@ +export interface LibraryNavigationKey { + name: string; + ctrl: boolean; + shift: boolean; + option?: boolean; +} + +export type LibraryNavigationCommand = + | { kind: "section"; delta: -1 | 1 } + | { kind: "move"; distance: "line" | "page"; direction: -1 | 1 } + | { kind: "edge"; edge: "first" | "last" }; + +/** Translate the non-printing keys accepted while the library filter retains focus. */ +export function libraryNavigationCommand( + key: LibraryNavigationKey, + canChangeSection: boolean, +): LibraryNavigationCommand | null { + if (key.name === "tab" && canChangeSection) { + return { kind: "section", delta: key.shift ? -1 : 1 }; + } + if (key.option === true && key.name === "up") return { kind: "edge", edge: "first" }; + if (key.option === true && key.name === "down") return { kind: "edge", edge: "last" }; + if (key.name === "up" || (key.ctrl && key.name === "p")) { + return { kind: "move", distance: "line", direction: -1 }; + } + if (key.name === "down" || (key.ctrl && key.name === "n")) { + return { kind: "move", distance: "line", direction: 1 }; + } + if (key.name === "pageup") return { kind: "move", distance: "page", direction: -1 }; + if (key.name === "pagedown") return { kind: "move", distance: "page", direction: 1 }; + if (key.name === "home") return { kind: "edge", edge: "first" }; + if (key.name === "end") return { kind: "edge", edge: "last" }; + return null; +} + +interface LibraryNavigationTarget { + cycleSection: (delta: -1 | 1) => void; + move: (delta: number) => void; + movePage: (direction: -1 | 1, pageSize: number) => void; + moveTo: (edge: "first" | "last") => void; +} + +/** Apply the library navigation contract shared by App and interaction tests. */ +export function applyLibraryNavigation( + key: LibraryNavigationKey, + target: LibraryNavigationTarget, + options: { canChangeSection: boolean; pageSize: number }, +): boolean { + const command = libraryNavigationCommand(key, options.canChangeSection); + if (command === null) return false; + + if (command.kind === "section") target.cycleSection(command.delta); + else if (command.kind === "edge") target.moveTo(command.edge); + else if (command.distance === "line") target.move(command.direction); + else target.movePage(command.direction, options.pageSize); + return true; +} diff --git a/test/__snapshots__/hud.test.tsx.snap b/test/__snapshots__/hud.test.tsx.snap index 7dfaa48..b117b7f 100644 --- a/test/__snapshots__/hud.test.tsx.snap +++ b/test/__snapshots__/hud.test.tsx.snap @@ -32,7 +32,7 @@ exports[`hud overlay at 100x32 1`] = ` ▶ 1:35 █████████████████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 3:26 PLAYING · VOL 100% - space pause / search a actions l lyrics u queue d device ? keys + space pause / search b library a actions ? keys l lyrics u queue d device " `; @@ -60,6 +60,6 @@ exports[`hud overlay at 80x24 1`] = ` ▶ 1:35 ████████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 3:26 PLAYING · VOL 100% - space pause / search a actions l lyrics u queue d device ? keys + space pause / search b library a actions ? keys l lyrics u queue " `; diff --git a/test/drill.test.ts b/test/drill.test.ts index 8170b82..d1ce639 100644 --- a/test/drill.test.ts +++ b/test/drill.test.ts @@ -8,6 +8,9 @@ import { toAlbumRows, toArtistRows, toHomeRows, + toLibraryAlbumRows, + toLibraryArtistRows, + toLibraryPlaylistRows, toPlaylistRows, toRows, type Row, @@ -269,6 +272,56 @@ describe("playlist rows", () => { }); }); +describe("library root rows", () => { + test("shows every playlist owner while preserving ownership-aware drilling", () => { + const owned = { + id: "mine", + name: "Mine", + uri: "spotify:playlist:mine", + ownerId: "me", + ownerName: "Austin", + mine: true, + }; + const followed = { + ...owned, + id: "followed", + name: "Followed", + uri: "spotify:playlist:followed", + ownerId: "other", + ownerName: "Someone Else", + mine: false, + }; + const rows = results(toLibraryPlaylistRows([owned, followed])); + + expect(rows.map((row) => row.detail)).toEqual(["Austin", "Someone Else"]); + expect(rows[0]?.drill).toMatchObject({ kind: "playlist", id: "mine" }); + expect(rows[1]?.drill).toBeUndefined(); + }); + + test("saved albums and followed artists open through normal catalog drills", () => { + const savedAlbum = { + ...album(), + artists: [{ id: "artist", name: "The National", uri: "spotify:artist:artist" }], + }; + const albumResult = results(toLibraryAlbumRows([savedAlbum]))[0]; + expect(albumResult).toMatchObject({ + detail: "The National", + trailing: "2007", + }); + expect(albumResult?.drill).toMatchObject({ + kind: "album", + id: "b", + }); + expect( + results( + toLibraryArtistRows([ + { id: "a", name: "The National", uri: "spotify:artist:a" }, + ]), + )[0]?.drill, + ).toEqual({ kind: "artist", id: "a", name: "The National" }); + }); +}); + describe("toPlaylistRows", () => { const entry = (position: number, name: string) => ({ position, diff --git a/test/follow.test.ts b/test/follow.test.ts index 9abe763..198c226 100644 --- a/test/follow.test.ts +++ b/test/follow.test.ts @@ -52,4 +52,18 @@ describe("followedArtists", () => { const artists = await followedArtists(new SpotifyClient(tokens)); expect(artists.map((a) => a.id)).toEqual(["a1"]); }); + + test("stops on a repeated cursor instead of requesting the same page forever", async () => { + let calls = 0; + globalThis.fetch = (async () => { + calls++; + return Response.json({ + artists: { items: [artist(`a${calls}`)], cursors: { after: "same" } }, + }); + }) as unknown as typeof fetch; + + const artists = await followedArtists(new SpotifyClient(tokens)); + expect(artists.map((item) => item.id)).toEqual(["a1", "a2"]); + expect(calls).toBe(2); + }); }); diff --git a/test/hud.test.tsx b/test/hud.test.tsx index 5093d06..6e875d3 100644 --- a/test/hud.test.tsx +++ b/test/hud.test.tsx @@ -101,6 +101,7 @@ const SIZES: ReadonlyArray = [ test("profile-less quota mode does not advertise account-bound actions", () => { const hints = barFor({ playing: true, hasTrack: true, canBrowse: false }); expect(hints).not.toContainEqual({ key: "/", action: "search" }); + expect(hints).not.toContainEqual({ key: "b", action: "library" }); expect(hints).not.toContainEqual({ key: "a", action: "go to" }); expect(hints).not.toContainEqual({ key: "d", action: "device" }); expect(hints).toContainEqual({ key: "r", action: "retry account" }); @@ -124,6 +125,7 @@ describe("hud", () => { test.each(SIZES)("keybinds occupy the last row at %ix%i", async (w, h) => { const lines = await render(w, h); expect(lines[h - KEY_HINT_ROWS] ?? "").toContain("space"); + expect(lines[h - KEY_HINT_ROWS] ?? "").toContain("? keys"); }); test.each(SIZES)("transport and times render at %ix%i", async (w, h) => { diff --git a/test/library-browser.test.ts b/test/library-browser.test.ts new file mode 100644 index 0000000..24f58aa --- /dev/null +++ b/test/library-browser.test.ts @@ -0,0 +1,185 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { SpotifyClient } from "../src/api/client.ts"; +import type { TokenStore } from "../src/auth/tokens.ts"; +import { useLibraryBrowser } from "../src/store/library-browser.ts"; + +const realFetch = globalThis.fetch; +const tokens = { + accessToken: async () => "test-token", + refresh: async () => { + throw new Error("unexpected refresh"); + }, +} as unknown as TokenStore; + +const playlist = (id: string, owner = "me") => ({ + id, + name: `Playlist ${id}`, + uri: `spotify:playlist:${id}`, + owner: { id: owner, display_name: owner === "me" ? "Austin" : "Someone Else" }, +}); + +const album = (id: string) => ({ + id, + name: `Album ${id}`, + uri: `spotify:album:${id}`, + images: [], + release_date: "2020-01-01", + total_tracks: 10, +}); + +const artist = (id: string) => ({ + id, + name: `Artist ${id}`, + uri: `spotify:artist:${id}`, +}); + +const track = (id: string) => ({ + id, + name: `Track ${id}`, + uri: `spotify:track:${id}`, + duration_ms: 200_000, + type: "track", + artists: [artist("performer")], + album: album("parent"), +}); + +async function waitFor(predicate: () => boolean): Promise { + const deadline = Date.now() + 1_000; + while (!predicate()) { + if (Date.now() > deadline) throw new Error("timed out waiting for library state"); + await Bun.sleep(5); + } +} + +afterEach(() => { + globalThis.fetch = realFetch; + useLibraryBrowser.getState().closeLibrary(); +}); + +describe("library browser", () => { + test("shows the complete playlist catalog instead of a home-screen slice", async () => { + const playlists = Array.from({ length: 23 }, (_, index) => playlist(String(index))); + globalThis.fetch = (async () => Response.json({ items: playlists, next: null })) as unknown as typeof fetch; + + useLibraryBrowser.getState().configure(new SpotifyClient(tokens), "US", "me"); + useLibraryBrowser.getState().openLibrary(); + await waitFor(() => useLibraryBrowser.getState().loaded()); + + expect(useLibraryBrowser.getState().rows()).toHaveLength(23); + expect(useLibraryBrowser.getState().total()).toBe(23); + expect(useLibraryBrowser.getState().rows().at(-1)).toMatchObject({ + kind: "result", + label: "Playlist 22", + }); + }); + + test("loads sections on demand and retains each section's local filter", async () => { + const calls: string[] = []; + globalThis.fetch = (async (input: string | URL | Request) => { + const path = new URL(String(input)).pathname.replace("/v1", ""); + calls.push(path); + if (path === "/me/playlists") { + return Response.json({ items: [playlist("one")], next: null }); + } + if (path === "/me/albums") { + return Response.json({ items: [{ album: album("violet") }, { album: album("boxer") }], next: null }); + } + if (path === "/me/following") { + return Response.json({ artists: { items: [artist("national")], cursors: { after: null } } }); + } + return new Response("not found", { status: 404 }); + }) as unknown as typeof fetch; + + const library = useLibraryBrowser.getState(); + library.configure(new SpotifyClient(tokens), "US", "me"); + library.openLibrary(); + await waitFor(() => useLibraryBrowser.getState().loaded()); + expect(calls).toEqual(["/me/playlists"]); + + useLibraryBrowser.getState().setSection("albums"); + await waitFor(() => useLibraryBrowser.getState().loaded()); + useLibraryBrowser.getState().setQuery("violet"); + expect(useLibraryBrowser.getState().rows().map((row) => row.kind === "result" ? row.label : "")).toEqual([ + "Album violet", + ]); + + useLibraryBrowser.getState().setSection("artists"); + await waitFor(() => useLibraryBrowser.getState().loaded()); + expect(useLibraryBrowser.getState().current()?.label).toBe("Artist national"); + + useLibraryBrowser.getState().setSection("albums"); + expect(useLibraryBrowser.getState().text()).toBe("violet"); + expect(calls).toEqual(["/me/playlists", "/me/albums", "/me/following"]); + }); + + test("drills into an owned playlist and returns to the same library row", async () => { + globalThis.fetch = (async (input: string | URL | Request) => { + const path = new URL(String(input)).pathname.replace("/v1", ""); + if (path === "/me/playlists") { + return Response.json({ items: [playlist("one"), playlist("two")], next: null }); + } + if (path === "/playlists/one/items") { + return Response.json({ items: [{ item: track("inside") }], next: null }); + } + return new Response("not found", { status: 404 }); + }) as unknown as typeof fetch; + + useLibraryBrowser.getState().configure(new SpotifyClient(tokens), "US", "me"); + useLibraryBrowser.getState().openLibrary(); + await waitFor(() => useLibraryBrowser.getState().loaded()); + const target = useLibraryBrowser.getState().current()?.drill; + expect(target).toMatchObject({ kind: "playlist", id: "one" }); + if (target === undefined) throw new Error("expected owned playlist to expose a drill target"); + + useLibraryBrowser.getState().drillInto(target); + await waitFor(() => useLibraryBrowser.getState().loaded()); + expect(useLibraryBrowser.getState().depth()).toBe(2); + expect(useLibraryBrowser.getState().current()?.label).toBe("Track inside"); + + expect(useLibraryBrowser.getState().back()).toBeTrue(); + expect(useLibraryBrowser.getState().depth()).toBe(1); + expect(useLibraryBrowser.getState().current()?.label).toBe("Playlist one"); + }); + + test("keeps a followed playlist playable without offering an inaccessible drill", async () => { + globalThis.fetch = (async () => + Response.json({ items: [playlist("followed", "other")], next: null })) as unknown as typeof fetch; + + useLibraryBrowser.getState().configure(new SpotifyClient(tokens), "US", "me"); + useLibraryBrowser.getState().openLibrary(); + await waitFor(() => useLibraryBrowser.getState().loaded()); + + const current = useLibraryBrowser.getState().current(); + expect(current).toMatchObject({ + detail: "Someone Else", + play: { contextUri: "spotify:playlist:followed" }, + }); + expect(current?.drill).toBeUndefined(); + }); + + test("retries a failed section explicitly", async () => { + let albumRequests = 0; + globalThis.fetch = (async (input: string | URL | Request) => { + const path = new URL(String(input)).pathname.replace("/v1", ""); + if (path === "/me/playlists") return Response.json({ items: [], next: null }); + if (path === "/me/albums") { + albumRequests++; + return albumRequests === 1 + ? new Response("failed", { status: 500 }) + : Response.json({ items: [{ album: album("recovered") }], next: null }); + } + return new Response("not found", { status: 404 }); + }) as unknown as typeof fetch; + + useLibraryBrowser.getState().configure(new SpotifyClient(tokens), "US", "me"); + useLibraryBrowser.getState().openLibrary(); + await waitFor(() => useLibraryBrowser.getState().loaded()); + useLibraryBrowser.getState().setSection("albums"); + await waitFor(() => useLibraryBrowser.getState().error() !== null); + + useLibraryBrowser.getState().retry(); + await waitFor(() => useLibraryBrowser.getState().loaded()); + expect(useLibraryBrowser.getState().current()?.label).toBe("Album recovered"); + expect(albumRequests).toBe(2); + }); +}); diff --git a/test/library-navigation.test.ts b/test/library-navigation.test.ts new file mode 100644 index 0000000..0fb606f --- /dev/null +++ b/test/library-navigation.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test"; +import { + applyLibraryNavigation, + libraryNavigationCommand, + type LibraryNavigationKey, +} from "../src/ui/library-navigation.ts"; + +function key(name: string, options: Partial = {}): LibraryNavigationKey { + return { name, ctrl: false, shift: false, ...options }; +} + +describe("library navigation", () => { + test("cycles root sections in both directions", () => { + expect(libraryNavigationCommand(key("tab"), true)).toEqual({ + kind: "section", + delta: 1, + }); + expect(libraryNavigationCommand(key("tab", { shift: true }), true)).toEqual({ + kind: "section", + delta: -1, + }); + expect(libraryNavigationCommand(key("tab"), false)).toBeNull(); + }); + + test("leaves printable filter characters to the focused input", () => { + for (const name of ["b", "j", "k", "/", "r"]) { + expect(libraryNavigationCommand(key(name), true)).toBeNull(); + } + }); + + test("applies line, page, and edge movement without a focus mode", () => { + const calls: string[] = []; + const target = { + cycleSection: (delta: -1 | 1) => calls.push(`section:${delta}`), + move: (delta: number) => calls.push(`line:${delta}`), + movePage: (direction: -1 | 1, pageSize: number) => + calls.push(`page:${direction}:${pageSize}`), + moveTo: (edge: "first" | "last") => calls.push(`edge:${edge}`), + }; + + expect(applyLibraryNavigation(key("down"), target, { canChangeSection: true, pageSize: 12 })).toBeTrue(); + expect(applyLibraryNavigation(key("pagedown"), target, { canChangeSection: true, pageSize: 12 })).toBeTrue(); + expect(applyLibraryNavigation(key("up", { option: true }), target, { canChangeSection: true, pageSize: 12 })).toBeTrue(); + expect(applyLibraryNavigation(key("tab"), target, { canChangeSection: true, pageSize: 12 })).toBeTrue(); + expect(calls).toEqual(["line:1", "page:1:12", "edge:first", "section:1"]); + }); +}); diff --git a/test/library-view.test.tsx b/test/library-view.test.tsx new file mode 100644 index 0000000..945e989 --- /dev/null +++ b/test/library-view.test.tsx @@ -0,0 +1,152 @@ +import { createMockKeys, createTestRenderer } from "@opentui/core/testing"; +import { createRoot, useKeyboard } from "@opentui/react"; +import { afterEach, describe, expect, test } from "bun:test"; +import { SpotifyClient } from "../src/api/client.ts"; +import type { TokenStore } from "../src/auth/tokens.ts"; +import { useLibraryBrowser } from "../src/store/library-browser.ts"; +import { LibraryView, libraryListHeight } from "../src/ui/LibraryView.tsx"; +import { applyLibraryNavigation } from "../src/ui/library-navigation.ts"; + +const realFetch = globalThis.fetch; +const tokens = { + accessToken: async () => "test-token", + refresh: async () => { + throw new Error("unexpected refresh"); + }, +} as unknown as TokenStore; + +const playlist = (id: string) => ({ + id, + name: `Playlist ${id}`, + uri: `spotify:playlist:${id}`, + owner: { id: "me", display_name: "Austin" }, +}); + +const album = (id: string) => ({ + id, + name: `Album ${id}`, + uri: `spotify:album:${id}`, + images: [], + artists: [{ id: "national", name: "The National", uri: "spotify:artist:national" }], + release_date: "2024-01-01", + total_tracks: 9, +}); + +let setup: Awaited> | undefined; + +function activeSetup() { + if (setup === undefined) throw new Error("library test renderer is not initialized"); + return setup; +} + +function InteractiveLibrary({ width, height }: { width: number; height: number }) { + useKeyboard((key) => { + const library = useLibraryBrowser.getState(); + applyLibraryNavigation(key, library, { + canChangeSection: library.depth() === 1, + pageSize: libraryListHeight(height), + }); + }); + return ; +} + +async function waitFor(predicate: () => boolean): Promise { + const deadline = Date.now() + 1_000; + while (!predicate()) { + if (Date.now() > deadline) throw new Error("timed out waiting for library state"); + await Bun.sleep(5); + } +} + +async function render(width: number, height: number): Promise { + globalThis.fetch = (async (input: string | URL | Request) => { + const path = new URL(String(input)).pathname.replace("/v1", ""); + if (path === "/me/playlists") { + return Response.json({ + items: [playlist("Road Trip"), playlist("Late Night"), playlist("Morning")], + next: null, + }); + } + if (path === "/me/albums") { + return Response.json({ + items: [{ album: album("Boxer") }, { album: album("High Violet") }], + next: null, + }); + } + return Response.json({ artists: { items: [], cursors: { after: null } } }); + }) as unknown as typeof fetch; + + setup = await createTestRenderer({ width, height }); + createRoot(setup.renderer).render(); + useLibraryBrowser.getState().configure(new SpotifyClient(tokens), "US", "me"); + useLibraryBrowser.getState().openLibrary(); + await waitFor(() => useLibraryBrowser.getState().loaded()); + await setup.renderOnce(); + return setup.captureCharFrame().split("\n"); +} + +afterEach(() => { + setup?.renderer.destroy(); + setup = undefined; + globalThis.fetch = realFetch; + useLibraryBrowser.getState().closeLibrary(); +}); + +const SIZES: ReadonlyArray = [ + [120, 40], + [100, 32], + [80, 24], + [60, 20], +]; + +describe("library view", () => { + test.each(SIZES)("keeps its complete chrome inside %ix%i", async (width, height) => { + const lines = await render(width, height); + const screen = lines.join("\n"); + expect(screen).toContain("LIBRARY"); + expect(screen).toContain("PLAYLISTS"); + expect(screen).toContain("ALBUMS"); + expect(screen).toContain("ARTISTS"); + expect(screen).toContain("filter playlists"); + for (const line of lines.slice(0, height)) expect(line.length).toBeLessThanOrEqual(width); + }); + + test("real typing filters the complete playlist list locally", async () => { + await render(80, 24); + const current = activeSetup(); + const keys = createMockKeys(current.renderer); + await keys.typeText("late", 5); + await Bun.sleep(20); + await current.renderOnce(); + + expect(useLibraryBrowser.getState().text()).toBe("late"); + const screen = current.captureCharFrame(); + expect(screen).toContain("Playlist Late Night"); + expect(screen).not.toContain("Playlist Road Trip"); + }); + + test("Tab changes section while the filter remains ready for typing", async () => { + await render(80, 24); + const current = activeSetup(); + const keys = createMockKeys(current.renderer); + keys.pressTab(); + await waitFor(() => useLibraryBrowser.getState().loaded()); + await current.renderOnce(); + expect(useLibraryBrowser.getState().section).toBe("albums"); + + await keys.typeText("violet", 5); + await Bun.sleep(20); + await current.renderOnce(); + expect(useLibraryBrowser.getState().text()).toBe("violet"); + const screen = current.captureCharFrame(); + expect(screen).toContain("Album High Violet"); + expect(screen).toContain("The National"); + }); + + test("the selected row carries playlist owner context", async () => { + const screen = (await render(80, 24)).join("\n"); + expect(screen).toContain("Playlist Road Trip"); + expect(screen).toContain("Austin"); + expect(screen.split("\n").filter((line) => line.includes("▌"))).toHaveLength(1); + }); +}); diff --git a/test/library.test.ts b/test/library.test.ts index d55ede2..d2045c5 100644 --- a/test/library.test.ts +++ b/test/library.test.ts @@ -4,6 +4,7 @@ import { fetchHome, libraryContains, removeLibraryItems, + savedAlbums, saveLibraryItems, } from "../src/api/library.ts"; import type { TokenStore } from "../src/auth/tokens.ts"; @@ -177,6 +178,44 @@ describe("fetchHome", () => { }); }); +describe("savedAlbums", () => { + const album = (id: string) => ({ + id, + name: `Album ${id}`, + uri: `spotify:album:${id}`, + images: [], + }); + + test("loads every page in Spotify's library order", async () => { + const offsets: number[] = []; + globalThis.fetch = (async (input: string | URL | Request) => { + const url = new URL(String(input)); + const offset = Number(url.searchParams.get("offset")); + offsets.push(offset); + return Response.json( + offset === 0 + ? { items: [{ album: album("one") }], next: "next" } + : { items: [{ album: album("two") }], next: null }, + ); + }) as unknown as typeof fetch; + + const albums = await savedAlbums(new SpotifyClient(tokens), { market: "US" }); + expect(albums.map((item) => item.id)).toEqual(["one", "two"]); + expect(offsets).toEqual([0, 50]); + }); + + test("drops null wrappers and missing albums at the API boundary", async () => { + globalThis.fetch = (async () => + Response.json({ + items: [null, { album: null }, {}, { album: album("real") }], + next: null, + })) as unknown as typeof fetch; + + const albums = await savedAlbums(new SpotifyClient(tokens)); + expect(albums.map((item) => item.id)).toEqual(["real"]); + }); +}); + describe("current library writes", () => { test("checks URI membership on the current endpoint", async () => { const calls: Array<{ method: string; path: string; uris: string | null }> = []; diff --git a/test/palette.test.tsx b/test/palette.test.tsx index 267d4d9..5da25cf 100644 --- a/test/palette.test.tsx +++ b/test/palette.test.tsx @@ -216,11 +216,12 @@ describe("palette", () => { expect(screen).toContain("type to search"); }); - test("labels the pre-typing view as your library", async () => { + test("labels the pre-typing view as search highlights rather than the complete library", async () => { seedFrames("", toHomeRows({ recent: [RESULTS.tracks[0]!], top: [], playlists: [] }), { showingHome: true }); const screen = (await render(100, 32)).join("\n"); expect(screen).toContain("RECENTLY PLAYED"); - expect(screen).toContain("your library"); + expect(screen).toContain("browse highlights"); + expect(screen).not.toContain("your library"); }); test("reports an empty result set", async () => { From 15c94da28059a7c1771bcbd99fc3c10fd26658f5 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Fri, 7 Aug 2026 01:22:24 -0700 Subject: [PATCH 2/2] recover library retries from indefinite cooldowns --- src/api/catalog.ts | 28 ++++++++--- src/api/client.ts | 10 +++- src/api/follow.ts | 25 +++++++--- src/api/library.ts | 16 +++++-- src/api/playlists.ts | 30 +++++++++--- src/store/actions.ts | 5 +- src/store/drill.ts | 6 ++- src/store/library-browser.ts | 33 +++++++++++-- src/store/playlists.ts | 22 +++++++-- src/store/search.ts | 2 +- test/client.test.ts | 36 ++++++++++++++ test/library-browser.test.ts | 89 +++++++++++++++++++++++++++++++++++ test/playlist-catalog.test.ts | 20 ++++---- 13 files changed, 274 insertions(+), 48 deletions(-) diff --git a/src/api/catalog.ts b/src/api/catalog.ts index bc06ebd..5a3e1ce 100644 --- a/src/api/catalog.ts +++ b/src/api/catalog.ts @@ -44,16 +44,24 @@ function compact(page: Page | null): T[] { export async function artistAlbums( client: SpotifyClient, artistId: string, - options: { market?: string; signal?: AbortSignal } = {}, + options: { + market?: string; + signal?: AbortSignal; + probeIndefiniteCooldown?: boolean; + } = {}, ): Promise { - const page = await client.request>(`/artists/${artistId}/albums`, { + const path = `/artists/${artistId}/albums`; + const requestOptions = { query: { limit: ARTIST_ALBUMS_LIMIT, include_groups: "album,single", market: options.market, }, ...(options.signal ? { signal: options.signal } : {}), - }); + }; + const page = options.probeIndefiniteCooldown === true + ? await client.retryAfterIndefiniteCooldown>(path, requestOptions) + : await client.request>(path, requestOptions); return compact(page).sort((a, b) => (b.release_date ?? "").localeCompare(a.release_date ?? "")); } @@ -65,12 +73,20 @@ export async function artistAlbums( export async function albumTracks( client: SpotifyClient, albumId: string, - options: { market?: string; signal?: AbortSignal } = {}, + options: { + market?: string; + signal?: AbortSignal; + probeIndefiniteCooldown?: boolean; + } = {}, ): Promise { - const page = await client.request>(`/albums/${albumId}/tracks`, { + const path = `/albums/${albumId}/tracks`; + const requestOptions = { query: { limit: ALBUM_TRACKS_LIMIT, market: options.market }, ...(options.signal ? { signal: options.signal } : {}), - }); + }; + const page = options.probeIndefiniteCooldown === true + ? await client.retryAfterIndefiniteCooldown>(path, requestOptions) + : await client.request>(path, requestOptions); return compact(page); } diff --git a/src/api/client.ts b/src/api/client.ts index 65f3935..2972de7 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -74,6 +74,9 @@ export interface RequestOptions { priority?: "foreground" | "background"; } +/** Options an explicit cooldown probe may preserve while remaining a read-only GET. */ +export type SpotifyGetOptions = Pick; + export interface SpotifyCooldown { kind: "rate-limit" | "quota"; /** `null` means Spotify did not supply a valid Retry-After value. */ @@ -180,8 +183,11 @@ export class SpotifyClient { * Ordinary and finite cooldowns are still enforced. Other queued requests remain blocked while * this probe runs, and any new 429 immediately closes the circuit again. */ - async retryAfterIndefiniteCooldown(path: string): Promise { - const result = await this.requestWithPolicy(path, {}, true); + async retryAfterIndefiniteCooldown( + path: string, + options: SpotifyGetOptions = {}, + ): Promise { + const result = await this.requestWithPolicy(path, options, true); if (result === null) throw new SpotifyApiError(204, path, "Expected a response body."); return result; } diff --git a/src/api/follow.ts b/src/api/follow.ts index 1af2076..5b49174 100644 --- a/src/api/follow.ts +++ b/src/api/follow.ts @@ -17,6 +17,13 @@ import type { FullArtist } from "./types.ts"; /** `/me/following` returns at most 50 artists per page. */ const FOLLOW_PAGE_SIZE = 50; +interface FollowedArtistPage { + artists?: { + items?: (FullArtist | null)[] | null; + cursors?: { after?: string | null } | null; + } | null; +} + /** * Every artist the signed-in user follows. * @@ -28,22 +35,26 @@ export async function followedArtists( options: { signal?: AbortSignal; priority?: "foreground" | "background"; + probeIndefiniteCooldown?: boolean; } = {}, ): Promise { const artists: FullArtist[] = []; let after: string | undefined; + let firstPage = true; for (;;) { - const response = await client.request<{ - artists?: { - items?: (FullArtist | null)[] | null; - cursors?: { after?: string | null } | null; - } | null; - }>("/me/following", { + const requestOptions = { query: { type: "artist", limit: FOLLOW_PAGE_SIZE, after }, ...(options.signal ? { signal: options.signal } : {}), ...(options.priority ? { priority: options.priority } : {}), - }); + }; + const response = options.probeIndefiniteCooldown === true && firstPage + ? await client.retryAfterIndefiniteCooldown( + "/me/following", + requestOptions, + ) + : await client.request("/me/following", requestOptions); + firstPage = false; const page = response?.artists; for (const item of page?.items ?? []) { diff --git a/src/api/library.ts b/src/api/library.ts index 81ad574..ed8244d 100644 --- a/src/api/library.ts +++ b/src/api/library.ts @@ -8,6 +8,11 @@ const LIBRARY_BATCH_SIZE = 40; /** Spotify returns at most 50 saved albums per page. */ const SAVED_ALBUM_PAGE_SIZE = 50; +interface SavedAlbumPage { + items?: ({ album?: SimpleAlbum | null } | null)[] | null; + next?: string | null; +} + export interface HomeData { recent: Track[]; top: Track[]; @@ -31,15 +36,13 @@ export async function savedAlbums( market?: string; signal?: AbortSignal; priority?: "foreground" | "background"; + probeIndefiniteCooldown?: boolean; } = {}, ): Promise { const albums: SimpleAlbum[] = []; for (let offset = 0; ; offset += SAVED_ALBUM_PAGE_SIZE) { - const page = await client.request<{ - items?: ({ album?: SimpleAlbum | null } | null)[] | null; - next?: string | null; - }>("/me/albums", { + const requestOptions = { query: { limit: SAVED_ALBUM_PAGE_SIZE, offset, @@ -47,7 +50,10 @@ export async function savedAlbums( }, ...(options.signal ? { signal: options.signal } : {}), ...(options.priority ? { priority: options.priority } : {}), - }); + }; + const page = options.probeIndefiniteCooldown === true && offset === 0 + ? await client.retryAfterIndefiniteCooldown("/me/albums", requestOptions) + : await client.request("/me/albums", requestOptions); for (const saved of page?.items ?? []) { if (saved?.album !== null && saved?.album !== undefined) albums.push(saved.album); diff --git a/src/api/playlists.ts b/src/api/playlists.ts index 302d5e9..dfb9b36 100644 --- a/src/api/playlists.ts +++ b/src/api/playlists.ts @@ -123,7 +123,11 @@ function toPlaylist(raw: RawPlaylist, meId: string): Playlist { export async function myPlaylists( client: SpotifyClient, meId: string, - options: { signal?: AbortSignal; priority?: "foreground" | "background" } = {}, + options: { + signal?: AbortSignal; + priority?: "foreground" | "background"; + probeIndefiniteCooldown?: boolean; + } = {}, ): Promise { const opts = { ...(options.signal ? { signal: options.signal } : {}), @@ -132,10 +136,16 @@ export async function myPlaylists( const playlists: Playlist[] = []; for (let offset = 0; ; offset += PLAYLIST_PAGE) { - const page = await client.request>("/me/playlists", { + const requestOptions = { query: { limit: PLAYLIST_PAGE, offset, fields: PLAYLIST_FIELDS }, ...opts, - }); + }; + const page = options.probeIndefiniteCooldown === true && offset === 0 + ? await client.retryAfterIndefiniteCooldown>( + "/me/playlists", + requestOptions, + ) + : await client.request>("/me/playlists", requestOptions); for (const raw of page?.items ?? []) { if (raw !== null && raw !== undefined) playlists.push(toPlaylist(raw, meId)); @@ -156,17 +166,25 @@ export async function myPlaylists( export async function playlistItems( client: SpotifyClient, playlistId: string, - options: { market?: string; signal?: AbortSignal } = {}, + options: { + market?: string; + signal?: AbortSignal; + probeIndefiniteCooldown?: boolean; + } = {}, ): Promise { const opts = options.signal ? { signal: options.signal } : {}; const entries: PlaylistEntry[] = []; try { for (let offset = 0; ; offset += ITEM_PAGE) { - const page = await client.request>(`/playlists/${playlistId}/items`, { + const path = `/playlists/${playlistId}/items`; + const requestOptions = { query: { limit: ITEM_PAGE, offset, market: options.market, fields: ITEM_FIELDS }, ...opts, - }); + }; + const page = options.probeIndefiniteCooldown === true && offset === 0 + ? await client.retryAfterIndefiniteCooldown>(path, requestOptions) + : await client.request>(path, requestOptions); const items = page?.items ?? []; items.forEach((raw, index) => { diff --git a/src/store/actions.ts b/src/store/actions.ts index 366ad46..5426560 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -442,7 +442,10 @@ export const useActions = create((set, get) => { error: null, }); try { - const playlists = filterOwnedPlaylists(await catalog.load("foreground", true), ""); + const playlists = filterOwnedPlaylists( + await catalog.load({ priority: "foreground", force: true }), + "", + ); const state = get(); if ( revision !== openRevision || diff --git a/src/store/drill.ts b/src/store/drill.ts index 713d37d..48a2d43 100644 --- a/src/store/drill.ts +++ b/src/store/drill.ts @@ -13,7 +13,11 @@ import { export async function rowsForDrill( client: SpotifyClient, target: Drill, - options: { market?: string; signal: AbortSignal }, + options: { + market?: string; + signal: AbortSignal; + probeIndefiniteCooldown?: boolean; + }, ): Promise { switch (target.kind) { case "artist": diff --git a/src/store/library-browser.ts b/src/store/library-browser.ts index 90fce66..77118de 100644 --- a/src/store/library-browser.ts +++ b/src/store/library-browser.ts @@ -39,6 +39,11 @@ interface LibraryFrame { target?: Drill; } +interface LibraryLoadOptions { + force?: boolean; + probeIndefiniteCooldown?: boolean; +} + type LibraryRoots = Record; export interface LibraryBrowserSlice { @@ -148,7 +153,11 @@ export const useLibraryBrowser = create((set, get) => { })); }; - const loadRoot = (section: LibrarySection, force = false): Promise => { + const loadRoot = ( + section: LibrarySection, + options: LibraryLoadOptions = {}, + ): Promise => { + const force = options.force === true; const existing = rootLoads.get(section); if (existing !== undefined) return existing; if (get().roots[section].loaded && !force) return Promise.resolve(); @@ -167,7 +176,11 @@ export const useLibraryBrowser = create((set, get) => { const rows = await (async (): Promise => { switch (section) { case "playlists": { - const playlists = await usePlaylistCatalog.getState().load("foreground", force); + const playlists = await usePlaylistCatalog.getState().load({ + priority: "foreground", + force, + probeIndefiniteCooldown: options.probeIndefiniteCooldown, + }); return toLibraryPlaylistRows(playlists); } case "albums": { @@ -175,6 +188,7 @@ export const useLibraryBrowser = create((set, get) => { market: requestMarket, signal: controller.signal, priority: "foreground", + probeIndefiniteCooldown: options.probeIndefiniteCooldown, }); return toLibraryAlbumRows(albums); } @@ -182,6 +196,7 @@ export const useLibraryBrowser = create((set, get) => { const artists = await followedArtists(requestClient, { signal: controller.signal, priority: "foreground", + probeIndefiniteCooldown: options.probeIndefiniteCooldown, }); return toLibraryArtistRows(artists); } @@ -211,7 +226,11 @@ export const useLibraryBrowser = create((set, get) => { return request; }; - const loadDrill = (target: Drill, frameId: number) => { + const loadDrill = ( + target: Drill, + frameId: number, + probeIndefiniteCooldown = false, + ) => { if (client === null) return; drillLoad?.abort(); const controller = new AbortController(); @@ -223,6 +242,7 @@ export const useLibraryBrowser = create((set, get) => { void rowsForDrill(requestClient, target, { market: requestMarket, signal: controller.signal, + probeIndefiniteCooldown, }) .then((rows) => { if (controller.signal.aborted || generation !== requestGeneration) return; @@ -392,10 +412,13 @@ export const useLibraryBrowser = create((set, get) => { { ...top, rows: [], selected: -1, loading: true, error: null }, ], }); - loadDrill(top.target, top.id); + loadDrill(top.target, top.id, true); return; } - void loadRoot(state.section, true); + void loadRoot(state.section, { + force: true, + probeIndefiniteCooldown: true, + }); }, back() { diff --git a/src/store/playlists.ts b/src/store/playlists.ts index c515346..1b7b93d 100644 --- a/src/store/playlists.ts +++ b/src/store/playlists.ts @@ -17,9 +17,13 @@ export interface PlaylistCatalogSlice { * AbortSignal: closing search must not cancel a catalog load the playlist picker is also awaiting. */ load: ( - priority?: "foreground" | "background", - /** Refresh even when this account already has a completed snapshot. */ - force?: boolean, + options?: { + priority?: "foreground" | "background"; + /** Refresh even when this account already has a completed snapshot. */ + force?: boolean; + /** Admit the first request only when an explicit retry meets an indefinite cooldown. */ + probeIndefiniteCooldown?: boolean; + }, ) => Promise; } @@ -44,7 +48,12 @@ export const usePlaylistCatalog = create((set, get) => ({ meId = nextMeId; }, - async load(priority = "background", force = false) { + async load(options = {}) { + const { + priority = "background", + force = false, + probeIndefiniteCooldown = false, + } = options; if (pending !== null) return await pending; if (get().loaded && !force) return get().playlists; if (client === null || meId === "") return []; @@ -55,7 +64,10 @@ export const usePlaylistCatalog = create((set, get) => ({ set({ loading: true, error: null }); let request: Promise; - request = myPlaylists(requestClient, requestMeId, { priority }) + request = myPlaylists(requestClient, requestMeId, { + priority, + probeIndefiniteCooldown, + }) .then((playlists) => { if (generation === requestGeneration) { set({ playlists, loaded: true, loading: false, error: null }); diff --git a/src/store/search.ts b/src/store/search.ts index c522741..67501a9 100644 --- a/src/store/search.ts +++ b/src/store/search.ts @@ -341,7 +341,7 @@ export const useSearch = create((set, get) => ({ market, meId, signal: controller.signal, - loadPlaylists: () => usePlaylistCatalog.getState().load("background"), + loadPlaylists: () => usePlaylistCatalog.getState().load({ priority: "background" }), }); if (controller.signal.aborted) return; home = data; diff --git a/test/client.test.ts b/test/client.test.ts index 1b968b6..6273aed 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -422,6 +422,42 @@ describe("429 handling", () => { expect(spotify.getCooldown()).toBeNull(); }); + test("an explicit probe preserves the real GET request options", async () => { + let requests = 0; + let probeUrl: URL | undefined; + let probeSignal: AbortSignal | null | undefined; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + requests++; + if (requests === 1) { + return new Response(quotaBody, { + status: 429, + headers: { + "content-type": "application/json", + "retry-after": "not-a-time", + }, + }); + } + probeUrl = new URL(String(input)); + probeSignal = init?.signal; + return Response.json({ artists: { items: [], cursors: { after: null } } }); + }) as unknown as typeof fetch; + + const spotify = client(); + await expect(spotify.request("/seed")).rejects.toBeInstanceOf(SpotifyLimitError); + const controller = new AbortController(); + await spotify.retryAfterIndefiniteCooldown("/me/following", { + query: { type: "artist", limit: 50 }, + signal: controller.signal, + priority: "foreground", + }); + + expect(probeUrl?.pathname).toEndWith("/me/following"); + expect(probeUrl?.searchParams.get("type")).toBe("artist"); + expect(probeUrl?.searchParams.get("limit")).toBe("50"); + expect(probeSignal).toBe(controller.signal); + expect(requests).toBe(2); + }); + test("an explicit probe never bypasses a finite Spotify deadline", async () => { let requests = 0; globalThis.fetch = (async () => { diff --git a/test/library-browser.test.ts b/test/library-browser.test.ts index 24f58aa..29891a4 100644 --- a/test/library-browser.test.ts +++ b/test/library-browser.test.ts @@ -43,6 +43,19 @@ const track = (id: string) => ({ album: album("parent"), }); +function rateLimited(retryAfter: string): Response { + return new Response( + JSON.stringify({ error: { status: 429, message: "Too many requests" } }), + { + status: 429, + headers: { + "content-type": "application/json", + "retry-after": retryAfter, + }, + }, + ); +} + async function waitFor(predicate: () => boolean): Promise { const deadline = Date.now() + 1_000; while (!predicate()) { @@ -182,4 +195,80 @@ describe("library browser", () => { expect(useLibraryBrowser.getState().current()?.label).toBe("Album recovered"); expect(albumRequests).toBe(2); }); + + test("uses the retried root request to probe an indefinite cooldown", async () => { + let requests = 0; + const paths: string[] = []; + globalThis.fetch = (async (input: string | URL | Request) => { + requests++; + const url = new URL(String(input)); + paths.push(url.pathname.replace("/v1", "")); + return requests === 1 + ? rateLimited("not-a-time") + : Response.json({ items: [playlist("recovered")], next: null }); + }) as unknown as typeof fetch; + + useLibraryBrowser.getState().configure(new SpotifyClient(tokens), "US", "me"); + useLibraryBrowser.getState().openLibrary(); + await waitFor(() => useLibraryBrowser.getState().error() !== null); + + useLibraryBrowser.getState().retry(); + await waitFor(() => useLibraryBrowser.getState().loaded()); + + expect(useLibraryBrowser.getState().current()?.label).toBe("Playlist recovered"); + expect(paths).toEqual(["/me/playlists", "/me/playlists"]); + expect(requests).toBe(2); + }); + + test("uses the retried drill request to probe an indefinite cooldown", async () => { + let itemRequests = 0; + globalThis.fetch = (async (input: string | URL | Request) => { + const path = new URL(String(input)).pathname.replace("/v1", ""); + if (path === "/me/playlists") { + return Response.json({ items: [playlist("one")], next: null }); + } + if (path === "/playlists/one/items") { + itemRequests++; + return itemRequests === 1 + ? rateLimited("not-a-time") + : Response.json({ items: [{ item: track("recovered") }], next: null }); + } + return new Response("not found", { status: 404 }); + }) as unknown as typeof fetch; + + useLibraryBrowser.getState().configure(new SpotifyClient(tokens), "US", "me"); + useLibraryBrowser.getState().openLibrary(); + await waitFor(() => useLibraryBrowser.getState().loaded()); + const target = useLibraryBrowser.getState().current()?.drill; + if (target === undefined) throw new Error("expected owned playlist to expose a drill target"); + + useLibraryBrowser.getState().drillInto(target); + await waitFor(() => useLibraryBrowser.getState().error() !== null); + useLibraryBrowser.getState().retry(); + await waitFor(() => useLibraryBrowser.getState().loaded()); + + expect(useLibraryBrowser.getState().current()?.label).toBe("Track recovered"); + expect(itemRequests).toBe(2); + }); + + test("never lets a manual retry bypass a finite cooldown", async () => { + let requests = 0; + globalThis.fetch = (async () => { + requests++; + return rateLimited("60"); + }) as unknown as typeof fetch; + + const spotify = new SpotifyClient(tokens, { now: () => 10_000 }); + useLibraryBrowser.getState().configure(spotify, "US", "me"); + useLibraryBrowser.getState().openLibrary(); + await waitFor(() => useLibraryBrowser.getState().error() !== null); + + useLibraryBrowser.getState().retry(); + await waitFor( + () => !useLibraryBrowser.getState().loading() && useLibraryBrowser.getState().error() !== null, + ); + + expect(requests).toBe(1); + expect(spotify.getCooldown()?.retryAt).toBe(70_000); + }); }); diff --git a/test/playlist-catalog.test.ts b/test/playlist-catalog.test.ts index 0fc5f31..45b4fad 100644 --- a/test/playlist-catalog.test.ts +++ b/test/playlist-catalog.test.ts @@ -39,8 +39,8 @@ describe("shared playlist catalog", () => { const catalog = usePlaylistCatalog.getState(); catalog.configure(new SpotifyClient(tokens), "me"); - const first = await catalog.load("background"); - const second = await catalog.load("foreground"); + const first = await catalog.load({ priority: "background" }); + const second = await catalog.load({ priority: "foreground" }); expect(first.map((playlist) => playlist.id)).toEqual(["one"]); expect(second).toBe(first); @@ -62,7 +62,7 @@ describe("shared playlist catalog", () => { const catalog = usePlaylistCatalog.getState(); catalog.configure(new SpotifyClient(tokens), "me"); const first = catalog.load(); - const second = catalog.load("foreground"); + const second = catalog.load({ priority: "foreground" }); release?.(); expect(await first).toEqual(await second); @@ -88,12 +88,14 @@ describe("shared playlist catalog", () => { const catalog = usePlaylistCatalog.getState(); catalog.configure(new SpotifyClient(tokens), "me"); - expect((await catalog.load("background")).map((playlist) => playlist.id)).toEqual([ - "playlist-1", - ]); - expect((await catalog.load("foreground", true)).map((playlist) => playlist.id)).toEqual([ - "playlist-2", - ]); + expect( + (await catalog.load({ priority: "background" })).map((playlist) => playlist.id), + ).toEqual(["playlist-1"]); + expect( + (await catalog.load({ priority: "foreground", force: true })).map( + (playlist) => playlist.id, + ), + ).toEqual(["playlist-2"]); expect(request).toBe(2); });