diff --git a/site/lib/cli/library.js b/site/lib/cli/library.js index a37f1b5..6cf1f8e 100644 --- a/site/lib/cli/library.js +++ b/site/lib/cli/library.js @@ -11,6 +11,7 @@ exports.addPlaylistToFavorites = addPlaylistToFavorites; exports.removePlaylistFromFavoritesData = removePlaylistFromFavoritesData; exports.removePlaylistFromFavorites = removePlaylistFromFavorites; const auth_1 = require("./auth"); +const pagination_1 = require("./pagination"); const collectionEndpoints = { artist: { path: '/userCollectionArtists/{id}/relationships/items', type: 'artists' }, album: { path: '/userCollectionAlbums/{id}/relationships/items', type: 'albums' }, @@ -74,18 +75,10 @@ async function removeFromLibrary(resourceType, resourceId, json) { } } async function listFavoritedPlaylistsData(client) { - const { data, error } = await client.GET('/userCollectionPlaylists/{id}/relationships/items', { - params: { - path: { id: 'me' }, - query: { - include: ['items'], - }, - }, + const { included } = await (0, pagination_1.fetchAllPages)(client, '/userCollectionPlaylists/{id}/relationships/items', { + path: { id: 'me' }, + query: { include: ['items'] }, }); - if (error || !data) { - throw new Error(`Failed to list favorited playlists — ${JSON.stringify(error)}`); - } - const included = data.included ?? []; return included .filter((item) => item.type === 'playlists') .map((item) => { diff --git a/site/lib/cli/pagination.d.ts b/site/lib/cli/pagination.d.ts new file mode 100644 index 0000000..ebbaf63 --- /dev/null +++ b/site/lib/cli/pagination.d.ts @@ -0,0 +1,19 @@ +/** Pull the `page[cursor]` token out of a JSON:API `links.next` value. */ +export declare function extractCursor(next: unknown): string | undefined; +interface PageParams { + path?: Record; + query?: Record; +} +interface PaginationOptions { + /** Delay between per-page retries. Overridable (set to 0) to keep tests fast. */ + retryDelayMs?: number; +} +/** + * Repeatedly GET `path`, following `links.next`, until no further page. + * Returns the concatenated `data` and `included` arrays from every page. + */ +export declare function fetchAllPages(client: any, path: string, params: PageParams, options?: PaginationOptions): Promise<{ + data: any[]; + included: any[]; +}>; +export {}; diff --git a/site/lib/cli/pagination.js b/site/lib/cli/pagination.js new file mode 100644 index 0000000..e96af90 --- /dev/null +++ b/site/lib/cli/pagination.js @@ -0,0 +1,86 @@ +"use strict"; +// Cursor-based pagination for the Tidal v2 JSON:API. +// +// List responses return one page (~20 items) plus a `links.next` value carrying a +// `page[cursor]` token for the following page. The API also intermittently returns +// an HTTP 200 with an empty `data` array — a transient glitch that must be retried, +// otherwise a single bad response silently truncates the result. +Object.defineProperty(exports, "__esModule", { value: true }); +exports.extractCursor = extractCursor; +exports.fetchAllPages = fetchAllPages; +const MAX_PAGES = 100; // safety cap against a server that never stops returning `next` +const MAX_ATTEMPTS = 3; // per-page attempts for transient empty/error responses +const RETRY_DELAY_MS = 250; +/** Pull the `page[cursor]` token out of a JSON:API `links.next` value. */ +function extractCursor(next) { + if (typeof next !== 'string' || next.length === 0) + return undefined; + const queryStart = next.indexOf('?'); + const queryString = queryStart >= 0 ? next.slice(queryStart + 1) : next; + const cursor = new URLSearchParams(queryString).get('page[cursor]'); + return cursor ?? undefined; +} +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +/** + * GET a single page, retrying transient failures (an `{ error }` response or an + * HTTP 200 with an empty `data` array). Throws once every attempt has errored; + * returns the (possibly empty) page once retries are exhausted without an error — + * an empty page is then treated as genuine end-of-data. + */ +async function fetchPage(client, path, requestParams, retryDelayMs) { + let lastError; + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + const { data, error } = await client.GET(path, { params: requestParams }); + if (!error && data) { + const pageData = data.data ?? []; + // A non-empty page is always real. An empty page may be a transient glitch, + // so accept it only once retries are spent. + if (pageData.length > 0 || attempt === MAX_ATTEMPTS) { + const links = data.links; + return { + data: pageData, + included: data.included ?? [], + cursor: links?.meta?.nextCursor ?? extractCursor(links?.next), + }; + } + lastError = undefined; + } + else { + lastError = error; + } + if (attempt < MAX_ATTEMPTS) + await sleep(retryDelayMs); + } + throw new Error(`Failed to fetch ${path} — ${JSON.stringify(lastError)}`); +} +/** + * Repeatedly GET `path`, following `links.next`, until no further page. + * Returns the concatenated `data` and `included` arrays from every page. + */ +async function fetchAllPages(client, path, params, options = {}) { + const retryDelayMs = options.retryDelayMs ?? RETRY_DELAY_MS; + const allData = []; + const allIncluded = []; + const seenCursors = new Set(); + let cursor; + for (let page = 0; page < MAX_PAGES; page++) { + const query = { ...(params.query ?? {}) }; + if (cursor) + query['page[cursor]'] = cursor; + const requestParams = {}; + if (params.path) + requestParams.path = params.path; + if (Object.keys(query).length > 0) + requestParams.query = query; + const result = await fetchPage(client, path, requestParams, retryDelayMs); + allData.push(...result.data); + allIncluded.push(...result.included); + cursor = result.cursor; + // Stop on no next page, or if the server hands back a cursor already used. + if (!cursor || seenCursors.has(cursor)) + break; + seenCursors.add(cursor); + } + return { data: allData, included: allIncluded }; +} +//# sourceMappingURL=pagination.js.map \ No newline at end of file diff --git a/site/lib/cli/playlist.js b/site/lib/cli/playlist.js index 9842f14..dbdbc3f 100644 --- a/site/lib/cli/playlist.js +++ b/site/lib/cli/playlist.js @@ -19,19 +19,15 @@ exports.moveTrackInPlaylist = moveTrackInPlaylist; exports.updatePlaylistDescriptionData = updatePlaylistDescriptionData; exports.updatePlaylistDescription = updatePlaylistDescription; const auth_1 = require("./auth"); +const pagination_1 = require("./pagination"); async function listPlaylistsData(client, countryCode) { - const { data, error } = await client.GET('/playlists', { - params: { - query: { - 'filter[owners.id]': ['me'], - countryCode, - }, + const { data } = await (0, pagination_1.fetchAllPages)(client, '/playlists', { + query: { + 'filter[owners.id]': ['me'], + countryCode, }, }); - if (error || !data) { - throw new Error(`Failed to list playlists — ${JSON.stringify(error)}`); - } - return (data.data ?? []).map((p) => ({ + return data.map((p) => ({ id: p.id, name: p.attributes?.name ?? 'Untitled', description: p.attributes?.description, @@ -187,13 +183,9 @@ async function addTrackToPlaylist(playlistId, trackId, json) { } } async function removeTrackFromPlaylistData(playlistId, trackId, client) { - const { data: itemsData, error: itemsError } = await client.GET('/playlists/{id}/relationships/items', { - params: { path: { id: playlistId } }, + const { data: items } = await (0, pagination_1.fetchAllPages)(client, '/playlists/{id}/relationships/items', { + path: { id: playlistId }, }); - if (itemsError || !itemsData) { - throw new Error(`Failed to get playlist items — ${JSON.stringify(itemsError)}`); - } - const items = itemsData.data ?? []; const item = items.find((i) => i.id === trackId); if (!item) { throw new Error(`Track ${trackId} not found in playlist ${playlistId}.`); @@ -267,13 +259,9 @@ async function addAlbumToPlaylist(playlistId, albumId, json) { } } async function moveTrackInPlaylistData(playlistId, trackId, positionBefore, client) { - const { data: itemsData, error: itemsError } = await client.GET('/playlists/{id}/relationships/items', { - params: { path: { id: playlistId } }, + const { data: items } = await (0, pagination_1.fetchAllPages)(client, '/playlists/{id}/relationships/items', { + path: { id: playlistId }, }); - if (itemsError || !itemsData) { - throw new Error(`Failed to get playlist items — ${JSON.stringify(itemsError)}`); - } - const items = itemsData.data ?? []; const item = items.find((i) => i.id === trackId); if (!item) { throw new Error(`Track ${trackId} not found in playlist ${playlistId}.`); diff --git a/site/scripts/copy-cli-dist.js b/site/scripts/copy-cli-dist.js index 26d5a3c..e1b668e 100644 --- a/site/scripts/copy-cli-dist.js +++ b/site/scripts/copy-cli-dist.js @@ -15,6 +15,7 @@ const files = [ 'album.js', 'album.d.ts', 'playlist.js', 'playlist.d.ts', 'library.js', 'library.d.ts', + 'pagination.js', 'pagination.d.ts', 'playback.js', 'playback.d.ts', 'recommend.js', 'recommend.d.ts', 'mix.js', 'mix.d.ts', diff --git a/src/__tests__/library.test.ts b/src/__tests__/library.test.ts index 6f83ada..bf4be89 100644 --- a/src/__tests__/library.test.ts +++ b/src/__tests__/library.test.ts @@ -119,6 +119,10 @@ describe('listFavoritedPlaylists', () => { it('lists favorited playlists', async () => { mockClient.GET.mockResolvedValue({ data: { + data: [ + { type: 'playlists', id: 'pl-fav-1' }, + { type: 'playlists', id: 'pl-fav-2' }, + ], included: [ { id: 'pl-fav-1', @@ -143,6 +147,7 @@ describe('listFavoritedPlaylists', () => { it('outputs JSON', async () => { mockClient.GET.mockResolvedValue({ data: { + data: [{ type: 'playlists', id: 'pl-1' }], included: [ { id: 'pl-1', type: 'playlists', attributes: { name: 'Fav PL', numberOfItems: 10 } }, ], @@ -169,6 +174,37 @@ describe('listFavoritedPlaylists', () => { await expect(listFavoritedPlaylists(false)).rejects.toThrow('process.exit(1)'); }); + + it('follows cursor pagination across multiple pages', async () => { + mockClient.GET + .mockResolvedValueOnce({ + data: { + data: [{ type: 'playlists', id: 'pl-1' }], + included: [ + { id: 'pl-1', type: 'playlists', attributes: { name: 'Page1', numberOfItems: 10 } }, + ], + links: { + next: '/userCollectionPlaylists/me/relationships/items?page%5Bcursor%5D=CURSOR2', + }, + }, + }) + .mockResolvedValueOnce({ + data: { + data: [{ type: 'playlists', id: 'pl-2' }], + included: [ + { id: 'pl-2', type: 'playlists', attributes: { name: 'Page2', numberOfItems: 20 } }, + ], + links: {}, // no `next` -> stop + }, + }); + + await listFavoritedPlaylists(false); + + expect(mockClient.GET).toHaveBeenCalledTimes(2); + expect(mockClient.GET.mock.calls[1][1].params.query['page[cursor]']).toBe('CURSOR2'); + expect(output.some((l) => l.includes('[pl-1] Page1'))).toBe(true); + expect(output.some((l) => l.includes('[pl-2] Page2'))).toBe(true); + }); }); describe('addPlaylistToFavorites', () => { diff --git a/src/__tests__/pagination.test.ts b/src/__tests__/pagination.test.ts new file mode 100644 index 0000000..ff5a5be --- /dev/null +++ b/src/__tests__/pagination.test.ts @@ -0,0 +1,193 @@ +import { vi, describe, it, expect } from 'vitest'; + +import { extractCursor, fetchAllPages } from '../pagination'; + +describe('extractCursor', () => { + it('extracts page[cursor] from a full URL', () => { + expect( + extractCursor('https://openapi.tidal.com/v2/playlists?page%5Bcursor%5D=ABC123'), + ).toBe('ABC123'); + }); + + it('extracts page[cursor] from a path + query string', () => { + expect( + extractCursor('/playlists?filter%5Bowners.id%5D=me&page%5Bcursor%5D=ABC123'), + ).toBe('ABC123'); + }); + + it('extracts page[cursor] from a bare query string', () => { + expect(extractCursor('page%5Bcursor%5D=ABC123')).toBe('ABC123'); + }); + + it('returns undefined for undefined, null, and empty string', () => { + expect(extractCursor(undefined)).toBeUndefined(); + expect(extractCursor(null)).toBeUndefined(); + expect(extractCursor('')).toBeUndefined(); + }); + + it('returns undefined when next carries no page[cursor] param', () => { + expect(extractCursor('/playlists?filter%5Bowners.id%5D=me')).toBeUndefined(); + }); +}); + +describe('fetchAllPages', () => { + const noDelay = { retryDelayMs: 0 }; + + it('concatenates data across two pages and stops when links.next is absent', async () => { + const client = { GET: vi.fn() }; + client.GET + .mockResolvedValueOnce({ + data: { + data: [{ id: 'd1' }], + links: { next: '/playlists?page%5Bcursor%5D=C2' }, + }, + }) + .mockResolvedValueOnce({ + data: { data: [{ id: 'd2' }], links: {} }, + }); + + const { data } = await fetchAllPages(client, '/playlists', {}, noDelay); + + expect(client.GET).toHaveBeenCalledTimes(2); + expect(data.map((d: any) => d.id)).toEqual(['d1', 'd2']); + }); + + it('concatenates included across pages', async () => { + const client = { GET: vi.fn() }; + client.GET + .mockResolvedValueOnce({ + data: { + data: [{ id: 'd1' }], + included: [{ id: 'i1' }], + links: { next: '/playlists?page%5Bcursor%5D=C2' }, + }, + }) + .mockResolvedValueOnce({ + data: { + data: [{ id: 'd2' }], + included: [{ id: 'i2' }], + links: {}, + }, + }); + + const { included } = await fetchAllPages(client, '/playlists', {}, noDelay); + + expect(included.map((i: any) => i.id)).toEqual(['i1', 'i2']); + }); + + it('passes page[cursor] on the second request only', async () => { + const client = { GET: vi.fn() }; + client.GET + .mockResolvedValueOnce({ + data: { + data: [{ id: 'd1' }], + links: { next: '/playlists?page%5Bcursor%5D=NEXT123' }, + }, + }) + .mockResolvedValueOnce({ + data: { data: [{ id: 'd2' }], links: {} }, + }); + + await fetchAllPages(client, '/playlists', {}, noDelay); + + expect(client.GET.mock.calls[0][1].params.query?.['page[cursor]']).toBeUndefined(); + expect(client.GET.mock.calls[1][1].params.query['page[cursor]']).toBe('NEXT123'); + }); + + it('prefers links.meta.nextCursor over links.next', async () => { + const client = { GET: vi.fn() }; + client.GET + .mockResolvedValueOnce({ + data: { + data: [{ id: 'd1' }], + links: { + next: '/playlists?page%5Bcursor%5D=FROM_URL', + meta: { nextCursor: 'FROM_META' }, + }, + }, + }) + .mockResolvedValueOnce({ + data: { data: [{ id: 'd2' }], links: {} }, + }); + + await fetchAllPages(client, '/playlists', {}, noDelay); + + expect(client.GET.mock.calls[1][1].params.query['page[cursor]']).toBe('FROM_META'); + }); + + it('retries a transient empty page and recovers the real items', async () => { + const client = { GET: vi.fn() }; + client.GET + .mockResolvedValueOnce({ data: { data: [], links: {} } }) + .mockResolvedValueOnce({ data: { data: [{ id: 'real' }], links: {} } }); + + const { data } = await fetchAllPages(client, '/playlists', {}, noDelay); + + expect(client.GET).toHaveBeenCalledTimes(2); + expect(data.map((d: any) => d.id)).toEqual(['real']); + }); + + it('accepts a genuinely empty page as end-of-data after retries are exhausted', async () => { + const client = { GET: vi.fn() }; + client.GET.mockResolvedValue({ data: { data: [], links: {} } }); + + const { data } = await fetchAllPages(client, '/playlists', {}, noDelay); + + expect(data).toEqual([]); + // retried up to the cap, then gave up — did not loop forever + expect(client.GET).toHaveBeenCalledTimes(3); + }); + + it('throws when a page still returns an error after its retries', async () => { + const client = { GET: vi.fn() }; + client.GET.mockResolvedValue({ data: null, error: { status: 503 } }); + + await expect(fetchAllPages(client, '/playlists', {}, noDelay)).rejects.toThrow( + /Failed to fetch \/playlists/, + ); + expect(client.GET).toHaveBeenCalledTimes(3); + }); + + it('recovers when an error page succeeds on retry', async () => { + const client = { GET: vi.fn() }; + client.GET + .mockResolvedValueOnce({ data: null, error: { status: 500 } }) + .mockResolvedValueOnce({ data: { data: [{ id: 'ok' }], links: {} } }); + + const { data } = await fetchAllPages(client, '/playlists', {}, noDelay); + + expect(data.map((d: any) => d.id)).toEqual(['ok']); + }); + + it('stops at the safety cap instead of looping forever', async () => { + const client = { GET: vi.fn() }; + let n = 0; + client.GET.mockImplementation(async () => ({ + data: { + data: [{ id: `d${n}` }], + links: { next: `/playlists?page%5Bcursor%5D=C${n++}` }, + }, + })); + + const { data } = await fetchAllPages(client, '/playlists', {}, noDelay); + + expect(client.GET).toHaveBeenCalledTimes(100); + expect(data).toHaveLength(100); + }); + + it('stops when the server repeats a cursor it already returned', async () => { + const client = { GET: vi.fn() }; + client.GET.mockResolvedValue({ + data: { + data: [{ id: 'd' }], + links: { next: '/playlists?page%5Bcursor%5D=SAME' }, + }, + }); + + const { data } = await fetchAllPages(client, '/playlists', {}, noDelay); + + // page 0 (no cursor) + page 1 (cursor SAME), then SAME repeats -> stop + expect(client.GET).toHaveBeenCalledTimes(2); + expect(data).toHaveLength(2); + }); +}); diff --git a/src/__tests__/playlist.test.ts b/src/__tests__/playlist.test.ts index 6861cc4..4de7a91 100644 --- a/src/__tests__/playlist.test.ts +++ b/src/__tests__/playlist.test.ts @@ -107,6 +107,30 @@ describe('listPlaylists', () => { await expect(listPlaylists(false)).rejects.toThrow('process.exit(1)'); }); + + it('follows cursor pagination across multiple pages', async () => { + mockClient.GET + .mockResolvedValueOnce({ + data: { + data: [{ id: 'pl-1', attributes: { name: 'Page1', numberOfItems: 1 } }], + links: { next: '/playlists?filter%5Bowners.id%5D=me&page%5Bcursor%5D=CURSOR2' }, + }, + }) + .mockResolvedValueOnce({ + data: { + data: [{ id: 'pl-2', attributes: { name: 'Page2', numberOfItems: 2 } }], + links: {}, // no `next` -> stop + }, + }); + + await listPlaylists(false); + + expect(mockClient.GET).toHaveBeenCalledTimes(2); + // second call carries the cursor + expect(mockClient.GET.mock.calls[1][1].params.query['page[cursor]']).toBe('CURSOR2'); + expect(output.some((l) => l.includes('[pl-1] Page1'))).toBe(true); + expect(output.some((l) => l.includes('[pl-2] Page2'))).toBe(true); + }); }); describe('createPlaylist', () => { @@ -321,6 +345,34 @@ describe('removeTrackFromPlaylist', () => { const parsed = JSON.parse(output.join('')); expect(parsed).toEqual({ playlistId: 'pl-1', trackId: 't-100', removed: true }); }); + + it('finds and removes a track that is on page 2', async () => { + mockClient.GET + .mockResolvedValueOnce({ + data: { + data: [{ id: 't-1', meta: { itemId: 'item-1' } }], + links: { next: '/playlists/pl-1/relationships/items?page%5Bcursor%5D=C2' }, + }, + }) + .mockResolvedValueOnce({ + data: { + data: [{ id: 't-2', meta: { itemId: 'item-2' } }], + links: {}, + }, + }); + mockClient.DELETE.mockResolvedValue({ error: undefined }); + + await removeTrackFromPlaylist('pl-1', 't-2', false); + + expect(mockClient.GET).toHaveBeenCalledTimes(2); + expect(mockClient.DELETE).toHaveBeenCalledWith('/playlists/{id}/relationships/items', { + params: { path: { id: 'pl-1' } }, + body: { + data: [{ id: 't-2', type: 'tracks', meta: { itemId: 'item-2' } }], + }, + }); + expect(output.some((l) => l.includes('t-2 removed from playlist pl-1'))).toBe(true); + }); }); describe('updatePlaylistDescription', () => { @@ -420,4 +472,32 @@ describe('moveTrackInPlaylist', () => { const parsed = JSON.parse(output.join('')); expect(parsed).toEqual({ playlistId: 'pl-1', trackId: 't-1', positionBefore: 'item-x', moved: true }); }); + + it('finds and moves a track that is on page 2', async () => { + mockClient.GET + .mockResolvedValueOnce({ + data: { + data: [{ id: 't-1', meta: { itemId: 'item-1' } }], + links: { next: '/playlists/pl-1/relationships/items?page%5Bcursor%5D=C2' }, + }, + }) + .mockResolvedValueOnce({ + data: { + data: [{ id: 't-2', meta: { itemId: 'item-2' } }], + links: {}, + }, + }); + mockClient.PATCH.mockResolvedValue({ error: undefined }); + + await moveTrackInPlaylist('pl-1', 't-2', 'end', false); + + expect(mockClient.GET).toHaveBeenCalledTimes(2); + expect(mockClient.PATCH).toHaveBeenCalledWith('/playlists/{id}/relationships/items', { + params: { path: { id: 'pl-1' } }, + body: { + data: [{ id: 't-2', type: 'tracks', meta: { itemId: 'item-2' } }], + meta: {}, + }, + }); + }); }); diff --git a/src/library.ts b/src/library.ts index 38f6bdd..2db5893 100644 --- a/src/library.ts +++ b/src/library.ts @@ -1,4 +1,5 @@ import { getApiClient } from './auth'; +import { fetchAllPages } from './pagination'; import type { LibraryResourceType } from './types'; export type { LibraryResourceType }; @@ -96,20 +97,15 @@ export async function removeFromLibrary( } export async function listFavoritedPlaylistsData(client: any): Promise> { - const { data, error } = await (client as any).GET('/userCollectionPlaylists/{id}/relationships/items', { - params: { + const { included } = await fetchAllPages( + client, + '/userCollectionPlaylists/{id}/relationships/items', + { path: { id: 'me' }, - query: { - include: ['items'] as any, - } as any, + query: { include: ['items'] }, }, - }); - - if (error || !data) { - throw new Error(`Failed to list favorited playlists — ${JSON.stringify(error)}`); - } + ); - const included = (data as any).included ?? []; return included .filter((item: any) => item.type === 'playlists') .map((item: any) => { diff --git a/src/pagination.ts b/src/pagination.ts new file mode 100644 index 0000000..be1bfc3 --- /dev/null +++ b/src/pagination.ts @@ -0,0 +1,115 @@ +// Cursor-based pagination for the Tidal v2 JSON:API. +// +// List responses return one page (~20 items) plus a `links.next` value carrying a +// `page[cursor]` token for the following page. The API also intermittently returns +// an HTTP 200 with an empty `data` array — a transient glitch that must be retried, +// otherwise a single bad response silently truncates the result. + +const MAX_PAGES = 100; // safety cap against a server that never stops returning `next` +const MAX_ATTEMPTS = 3; // per-page attempts for transient empty/error responses +const RETRY_DELAY_MS = 250; + +/** Pull the `page[cursor]` token out of a JSON:API `links.next` value. */ +export function extractCursor(next: unknown): string | undefined { + if (typeof next !== 'string' || next.length === 0) return undefined; + const queryStart = next.indexOf('?'); + const queryString = queryStart >= 0 ? next.slice(queryStart + 1) : next; + const cursor = new URLSearchParams(queryString).get('page[cursor]'); + return cursor ?? undefined; +} + +interface PageParams { + path?: Record; + query?: Record; +} + +interface PaginationOptions { + /** Delay between per-page retries. Overridable (set to 0) to keep tests fast. */ + retryDelayMs?: number; +} + +const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +interface PageResult { + data: any[]; + included: any[]; + cursor: string | undefined; +} + +/** + * GET a single page, retrying transient failures (an `{ error }` response or an + * HTTP 200 with an empty `data` array). Throws once every attempt has errored; + * returns the (possibly empty) page once retries are exhausted without an error — + * an empty page is then treated as genuine end-of-data. + */ +async function fetchPage( + client: any, + path: string, + requestParams: Record, + retryDelayMs: number, +): Promise { + let lastError: unknown; + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + const { data, error } = await client.GET(path, { params: requestParams }); + + if (!error && data) { + const pageData = (data as any).data ?? []; + // A non-empty page is always real. An empty page may be a transient glitch, + // so accept it only once retries are spent. + if (pageData.length > 0 || attempt === MAX_ATTEMPTS) { + const links = (data as any).links; + return { + data: pageData, + included: (data as any).included ?? [], + cursor: links?.meta?.nextCursor ?? extractCursor(links?.next), + }; + } + lastError = undefined; + } else { + lastError = error; + } + + if (attempt < MAX_ATTEMPTS) await sleep(retryDelayMs); + } + + throw new Error(`Failed to fetch ${path} — ${JSON.stringify(lastError)}`); +} + +/** + * Repeatedly GET `path`, following `links.next`, until no further page. + * Returns the concatenated `data` and `included` arrays from every page. + */ +export async function fetchAllPages( + client: any, + path: string, + params: PageParams, + options: PaginationOptions = {}, +): Promise<{ data: any[]; included: any[] }> { + const retryDelayMs = options.retryDelayMs ?? RETRY_DELAY_MS; + const allData: any[] = []; + const allIncluded: any[] = []; + const seenCursors = new Set(); + let cursor: string | undefined; + + for (let page = 0; page < MAX_PAGES; page++) { + const query: Record = { ...(params.query ?? {}) }; + if (cursor) query['page[cursor]'] = cursor; + + const requestParams: Record = {}; + if (params.path) requestParams.path = params.path; + if (Object.keys(query).length > 0) requestParams.query = query; + + const result = await fetchPage(client, path, requestParams, retryDelayMs); + allData.push(...result.data); + allIncluded.push(...result.included); + + cursor = result.cursor; + // Stop on no next page, or if the server hands back a cursor already used. + if (!cursor || seenCursors.has(cursor)) break; + seenCursors.add(cursor); + } + + return { data: allData, included: allIncluded }; +} diff --git a/src/playlist.ts b/src/playlist.ts index 7633360..17693b8 100644 --- a/src/playlist.ts +++ b/src/playlist.ts @@ -1,22 +1,17 @@ import { getApiClient, getCountryCode } from './auth'; +import { fetchAllPages } from './pagination'; import type { PlaylistInfo } from './types'; export type { PlaylistInfo }; export async function listPlaylistsData(client: any, countryCode: string): Promise { - const { data, error } = await client.GET('/playlists', { - params: { - query: { - 'filter[owners.id]': ['me'] as any, - countryCode, - }, + const { data } = await fetchAllPages(client, '/playlists', { + query: { + 'filter[owners.id]': ['me'], + countryCode, }, }); - if (error || !data) { - throw new Error(`Failed to list playlists — ${JSON.stringify(error)}`); - } - - return ((data as any).data ?? []).map((p: any) => ({ + return data.map((p: any) => ({ id: p.id, name: p.attributes?.name ?? 'Untitled', description: p.attributes?.description, @@ -200,15 +195,10 @@ export async function addTrackToPlaylist(playlistId: string, trackId: string, js } export async function removeTrackFromPlaylistData(playlistId: string, trackId: string, client: any): Promise<{ playlistId: string; trackId: string; removed: boolean }> { - const { data: itemsData, error: itemsError } = await client.GET('/playlists/{id}/relationships/items' as any, { - params: { path: { id: playlistId } }, + const { data: items } = await fetchAllPages(client, '/playlists/{id}/relationships/items', { + path: { id: playlistId }, }); - if (itemsError || !itemsData) { - throw new Error(`Failed to get playlist items — ${JSON.stringify(itemsError)}`); - } - - const items = (itemsData as any).data ?? []; const item = items.find((i: any) => i.id === trackId); if (!item) { throw new Error(`Track ${trackId} not found in playlist ${playlistId}.`); @@ -304,15 +294,10 @@ export async function moveTrackInPlaylistData( positionBefore: string, client: any, ): Promise<{ playlistId: string; trackId: string; positionBefore: string; moved: boolean }> { - const { data: itemsData, error: itemsError } = await client.GET('/playlists/{id}/relationships/items' as any, { - params: { path: { id: playlistId } }, + const { data: items } = await fetchAllPages(client, '/playlists/{id}/relationships/items', { + path: { id: playlistId }, }); - if (itemsError || !itemsData) { - throw new Error(`Failed to get playlist items — ${JSON.stringify(itemsError)}`); - } - - const items = (itemsData as any).data ?? []; const item = items.find((i: any) => i.id === trackId); if (!item) { throw new Error(`Track ${trackId} not found in playlist ${playlistId}.`);