Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 22 additions & 6 deletions src/api/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,24 @@ function compact<T>(page: Page<T | null> | null): T[] {
export async function artistAlbums(
client: SpotifyClient,
artistId: string,
options: { market?: string; signal?: AbortSignal } = {},
options: {
market?: string;
signal?: AbortSignal;
probeIndefiniteCooldown?: boolean;
} = {},
): Promise<SimpleAlbum[]> {
const page = await client.request<Page<SimpleAlbum | null>>(`/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<Page<SimpleAlbum | null>>(path, requestOptions)
: await client.request<Page<SimpleAlbum | null>>(path, requestOptions);

return compact(page).sort((a, b) => (b.release_date ?? "").localeCompare(a.release_date ?? ""));
}
Expand All @@ -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<AlbumTrack[]> {
const page = await client.request<Page<AlbumTrack | null>>(`/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<Page<AlbumTrack | null>>(path, requestOptions)
: await client.request<Page<AlbumTrack | null>>(path, requestOptions);

return compact(page);
}
Expand Down
10 changes: 8 additions & 2 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RequestOptions, "query" | "signal" | "priority">;

export interface SpotifyCooldown {
kind: "rate-limit" | "quota";
/** `null` means Spotify did not supply a valid Retry-After value. */
Expand Down Expand Up @@ -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<T>(path: string): Promise<T> {
const result = await this.requestWithPolicy<T>(path, {}, true);
async retryAfterIndefiniteCooldown<T>(
path: string,
options: SpotifyGetOptions = {},
): Promise<T> {
const result = await this.requestWithPolicy<T>(path, options, true);
if (result === null) throw new SpotifyApiError(204, path, "Expected a response body.");
return result;
}
Expand Down
33 changes: 24 additions & 9 deletions src/api/follow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -25,28 +32,36 @@ const FOLLOW_PAGE_SIZE = 50;
*/
export async function followedArtists(
client: SpotifyClient,
options: { signal?: AbortSignal } = {},
options: {
signal?: AbortSignal;
priority?: "foreground" | "background";
probeIndefiniteCooldown?: boolean;
} = {},
): Promise<FullArtist[]> {
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<FollowedArtistPage>(
"/me/following",
requestOptions,
)
: await client.request<FollowedArtistPage>("/me/following", requestOptions);
firstPage = false;

const page = response?.artists;
for (const item of page?.items ?? []) {
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;
}

Expand Down
45 changes: 44 additions & 1 deletion src/api/library.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
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;

interface SavedAlbumPage {
items?: ({ album?: SimpleAlbum | null } | null)[] | null;
next?: string | null;
}

export interface HomeData {
recent: Track[];
top: Track[];
Expand All @@ -21,6 +29,41 @@ function compact<T>(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";
probeIndefiniteCooldown?: boolean;
} = {},
): Promise<SimpleAlbum[]> {
const albums: SimpleAlbum[] = [];

for (let offset = 0; ; offset += SAVED_ALBUM_PAGE_SIZE) {
const requestOptions = {
query: {
limit: SAVED_ALBUM_PAGE_SIZE,
offset,
market: options.market,
},
...(options.signal ? { signal: options.signal } : {}),
...(options.priority ? { priority: options.priority } : {}),
};
const page = options.probeIndefiniteCooldown === true && offset === 0
? await client.retryAfterIndefiniteCooldown<SavedAlbumPage>("/me/albums", requestOptions)
: await client.request<SavedAlbumPage>("/me/albums", requestOptions);

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<string>();
Expand Down
30 changes: 24 additions & 6 deletions src/api/playlists.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Playlist[]> {
const opts = {
...(options.signal ? { signal: options.signal } : {}),
Expand All @@ -132,10 +136,16 @@ export async function myPlaylists(
const playlists: Playlist[] = [];

for (let offset = 0; ; offset += PLAYLIST_PAGE) {
const page = await client.request<RawPage<RawPlaylist>>("/me/playlists", {
const requestOptions = {
query: { limit: PLAYLIST_PAGE, offset, fields: PLAYLIST_FIELDS },
...opts,
});
};
const page = options.probeIndefiniteCooldown === true && offset === 0
? await client.retryAfterIndefiniteCooldown<RawPage<RawPlaylist>>(
"/me/playlists",
requestOptions,
)
: await client.request<RawPage<RawPlaylist>>("/me/playlists", requestOptions);

for (const raw of page?.items ?? []) {
if (raw !== null && raw !== undefined) playlists.push(toPlaylist(raw, meId));
Expand All @@ -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<PlaylistEntry[]> {
const opts = options.signal ? { signal: options.signal } : {};
const entries: PlaylistEntry[] = [];

try {
for (let offset = 0; ; offset += ITEM_PAGE) {
const page = await client.request<RawPage<RawEntry>>(`/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<RawPage<RawEntry>>(path, requestOptions)
: await client.request<RawPage<RawEntry>>(path, requestOptions);

const items = page?.items ?? [];
items.forEach((raw, index) => {
Expand Down
1 change: 1 addition & 0 deletions src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface SimpleAlbum {
name: string;
uri: string;
images: Image[];
artists?: SimpleArtist[];
release_date?: string;
total_tracks?: number;
}
Expand Down
9 changes: 6 additions & 3 deletions src/store/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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<ActionResult | null>;
/** One-key save/unsave for the currently playing item. */
toggleSaved: (item: PlayableItem) => Promise<void>;
Expand Down Expand Up @@ -442,7 +442,10 @@ export const useActions = create<ActionsSlice>((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 ||
Expand Down
36 changes: 36 additions & 0 deletions src/store/drill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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;
probeIndefiniteCooldown?: boolean;
},
): Promise<Row[]> {
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),
);
}
}
Loading