@lucaperret/tidal-cli 1.2.3 → 1.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/history.js CHANGED
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.getRecentlyAddedData = getRecentlyAddedData;
4
4
  exports.getRecentlyAdded = getRecentlyAdded;
5
5
  const auth_1 = require("./auth");
6
+ const pagination_1 = require("./pagination");
6
7
  const endpointMap = {
7
8
  tracks: '/userCollectionTracks/{id}/relationships/items',
8
9
  albums: '/userCollectionAlbums/{id}/relationships/items',
@@ -14,20 +15,15 @@ const includeTypeMap = {
14
15
  artists: 'artists',
15
16
  };
16
17
  async function getRecentlyAddedData(type, client, countryCode) {
17
- const { data, error } = await client.GET(endpointMap[type], {
18
- params: {
19
- path: { id: 'me' },
20
- query: {
21
- countryCode,
22
- include: ['items'],
23
- sort: ['-addedAt'],
24
- },
18
+ // Paginated: returns the full collection (most-recent-first), not just the first ~20.
19
+ const { data: relData, included } = await (0, pagination_1.fetchAllPages)(client, endpointMap[type], {
20
+ path: { id: 'me' },
21
+ query: {
22
+ countryCode,
23
+ include: ['items'],
24
+ sort: ['-addedAt'],
25
25
  },
26
26
  });
27
- if (error || !data) {
28
- throw new Error(`Failed to get recently added ${type} — ${JSON.stringify(error)}`);
29
- }
30
- const included = data.included ?? [];
31
27
  const items = included
32
28
  .filter((item) => item.type === includeTypeMap[type])
33
29
  .map((item) => {
@@ -39,7 +35,6 @@ async function getRecentlyAddedData(type, client, countryCode) {
39
35
  };
40
36
  });
41
37
  // Enrich with addedAt from the relationship data if available
42
- const relData = data.data ?? [];
43
38
  for (const rel of relData) {
44
39
  const addedAt = rel.meta?.addedAt;
45
40
  if (addedAt) {
package/dist/library.js CHANGED
@@ -11,6 +11,7 @@ exports.addPlaylistToFavorites = addPlaylistToFavorites;
11
11
  exports.removePlaylistFromFavoritesData = removePlaylistFromFavoritesData;
12
12
  exports.removePlaylistFromFavorites = removePlaylistFromFavorites;
13
13
  const auth_1 = require("./auth");
14
+ const pagination_1 = require("./pagination");
14
15
  const collectionEndpoints = {
15
16
  artist: { path: '/userCollectionArtists/{id}/relationships/items', type: 'artists' },
16
17
  album: { path: '/userCollectionAlbums/{id}/relationships/items', type: 'albums' },
@@ -74,18 +75,10 @@ async function removeFromLibrary(resourceType, resourceId, json) {
74
75
  }
75
76
  }
76
77
  async function listFavoritedPlaylistsData(client) {
77
- const { data, error } = await client.GET('/userCollectionPlaylists/{id}/relationships/items', {
78
- params: {
79
- path: { id: 'me' },
80
- query: {
81
- include: ['items'],
82
- },
83
- },
78
+ const { included } = await (0, pagination_1.fetchAllPages)(client, '/userCollectionPlaylists/{id}/relationships/items', {
79
+ path: { id: 'me' },
80
+ query: { include: ['items'] },
84
81
  });
85
- if (error || !data) {
86
- throw new Error(`Failed to list favorited playlists — ${JSON.stringify(error)}`);
87
- }
88
- const included = data.included ?? [];
89
82
  return included
90
83
  .filter((item) => item.type === 'playlists')
91
84
  .map((item) => {
@@ -0,0 +1,19 @@
1
+ /** Pull the `page[cursor]` token out of a JSON:API `links.next` value. */
2
+ export declare function extractCursor(next: unknown): string | undefined;
3
+ interface PageParams {
4
+ path?: Record<string, unknown>;
5
+ query?: Record<string, unknown>;
6
+ }
7
+ interface PaginationOptions {
8
+ /** Delay between per-page retries. Overridable (set to 0) to keep tests fast. */
9
+ retryDelayMs?: number;
10
+ }
11
+ /**
12
+ * Repeatedly GET `path`, following `links.next`, until no further page.
13
+ * Returns the concatenated `data` and `included` arrays from every page.
14
+ */
15
+ export declare function fetchAllPages(client: any, path: string, params: PageParams, options?: PaginationOptions): Promise<{
16
+ data: any[];
17
+ included: any[];
18
+ }>;
19
+ export {};
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ // Cursor-based pagination for the Tidal v2 JSON:API.
3
+ //
4
+ // List responses return one page (~20 items) plus a `links.next` value carrying a
5
+ // `page[cursor]` token for the following page. The API also intermittently returns
6
+ // an HTTP 200 with an empty `data` array — a transient glitch that must be retried,
7
+ // otherwise a single bad response silently truncates the result.
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.extractCursor = extractCursor;
10
+ exports.fetchAllPages = fetchAllPages;
11
+ const MAX_PAGES = 100; // safety cap against a server that never stops returning `next`
12
+ const MAX_ATTEMPTS = 3; // per-page attempts for transient empty/error responses
13
+ const RETRY_DELAY_MS = 250;
14
+ /** Pull the `page[cursor]` token out of a JSON:API `links.next` value. */
15
+ function extractCursor(next) {
16
+ if (typeof next !== 'string' || next.length === 0)
17
+ return undefined;
18
+ const queryStart = next.indexOf('?');
19
+ const queryString = queryStart >= 0 ? next.slice(queryStart + 1) : next;
20
+ const cursor = new URLSearchParams(queryString).get('page[cursor]');
21
+ return cursor ?? undefined;
22
+ }
23
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
24
+ /**
25
+ * GET a single page, retrying transient failures (an `{ error }` response or an
26
+ * HTTP 200 with an empty `data` array). Throws once every attempt has errored;
27
+ * returns the (possibly empty) page once retries are exhausted without an error —
28
+ * an empty page is then treated as genuine end-of-data.
29
+ */
30
+ async function fetchPage(client, path, requestParams, retryDelayMs) {
31
+ let lastError;
32
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
33
+ const { data, error } = await client.GET(path, { params: requestParams });
34
+ if (!error && data) {
35
+ const pageData = data.data ?? [];
36
+ // A non-empty page is always real. An empty page may be a transient glitch,
37
+ // so accept it only once retries are spent.
38
+ if (pageData.length > 0 || attempt === MAX_ATTEMPTS) {
39
+ const links = data.links;
40
+ return {
41
+ data: pageData,
42
+ included: data.included ?? [],
43
+ cursor: links?.meta?.nextCursor ?? extractCursor(links?.next),
44
+ };
45
+ }
46
+ lastError = undefined;
47
+ }
48
+ else {
49
+ lastError = error;
50
+ }
51
+ if (attempt < MAX_ATTEMPTS)
52
+ await sleep(retryDelayMs);
53
+ }
54
+ throw new Error(`Failed to fetch ${path} — ${JSON.stringify(lastError)}`);
55
+ }
56
+ /**
57
+ * Repeatedly GET `path`, following `links.next`, until no further page.
58
+ * Returns the concatenated `data` and `included` arrays from every page.
59
+ */
60
+ async function fetchAllPages(client, path, params, options = {}) {
61
+ const retryDelayMs = options.retryDelayMs ?? RETRY_DELAY_MS;
62
+ const allData = [];
63
+ const allIncluded = [];
64
+ const seenCursors = new Set();
65
+ let cursor;
66
+ for (let page = 0; page < MAX_PAGES; page++) {
67
+ const query = { ...(params.query ?? {}) };
68
+ if (cursor)
69
+ query['page[cursor]'] = cursor;
70
+ const requestParams = {};
71
+ if (params.path)
72
+ requestParams.path = params.path;
73
+ if (Object.keys(query).length > 0)
74
+ requestParams.query = query;
75
+ const result = await fetchPage(client, path, requestParams, retryDelayMs);
76
+ allData.push(...result.data);
77
+ allIncluded.push(...result.included);
78
+ cursor = result.cursor;
79
+ // Stop on no next page, or if the server hands back a cursor already used.
80
+ if (!cursor || seenCursors.has(cursor))
81
+ break;
82
+ seenCursors.add(cursor);
83
+ }
84
+ return { data: allData, included: allIncluded };
85
+ }
86
+ //# sourceMappingURL=pagination.js.map
package/dist/playlist.js CHANGED
@@ -19,19 +19,15 @@ exports.moveTrackInPlaylist = moveTrackInPlaylist;
19
19
  exports.updatePlaylistDescriptionData = updatePlaylistDescriptionData;
20
20
  exports.updatePlaylistDescription = updatePlaylistDescription;
21
21
  const auth_1 = require("./auth");
22
+ const pagination_1 = require("./pagination");
22
23
  async function listPlaylistsData(client, countryCode) {
23
- const { data, error } = await client.GET('/playlists', {
24
- params: {
25
- query: {
26
- 'filter[owners.id]': ['me'],
27
- countryCode,
28
- },
24
+ const { data } = await (0, pagination_1.fetchAllPages)(client, '/playlists', {
25
+ query: {
26
+ 'filter[owners.id]': ['me'],
27
+ countryCode,
29
28
  },
30
29
  });
31
- if (error || !data) {
32
- throw new Error(`Failed to list playlists — ${JSON.stringify(error)}`);
33
- }
34
- return (data.data ?? []).map((p) => ({
30
+ return data.map((p) => ({
35
31
  id: p.id,
36
32
  name: p.attributes?.name ?? 'Untitled',
37
33
  description: p.attributes?.description,
@@ -187,13 +183,9 @@ async function addTrackToPlaylist(playlistId, trackId, json) {
187
183
  }
188
184
  }
189
185
  async function removeTrackFromPlaylistData(playlistId, trackId, client) {
190
- const { data: itemsData, error: itemsError } = await client.GET('/playlists/{id}/relationships/items', {
191
- params: { path: { id: playlistId } },
186
+ const { data: items } = await (0, pagination_1.fetchAllPages)(client, '/playlists/{id}/relationships/items', {
187
+ path: { id: playlistId },
192
188
  });
193
- if (itemsError || !itemsData) {
194
- throw new Error(`Failed to get playlist items — ${JSON.stringify(itemsError)}`);
195
- }
196
- const items = itemsData.data ?? [];
197
189
  const item = items.find((i) => i.id === trackId);
198
190
  if (!item) {
199
191
  throw new Error(`Track ${trackId} not found in playlist ${playlistId}.`);
@@ -267,13 +259,9 @@ async function addAlbumToPlaylist(playlistId, albumId, json) {
267
259
  }
268
260
  }
269
261
  async function moveTrackInPlaylistData(playlistId, trackId, positionBefore, client) {
270
- const { data: itemsData, error: itemsError } = await client.GET('/playlists/{id}/relationships/items', {
271
- params: { path: { id: playlistId } },
262
+ const { data: items } = await (0, pagination_1.fetchAllPages)(client, '/playlists/{id}/relationships/items', {
263
+ path: { id: playlistId },
272
264
  });
273
- if (itemsError || !itemsData) {
274
- throw new Error(`Failed to get playlist items — ${JSON.stringify(itemsError)}`);
275
- }
276
- const items = itemsData.data ?? [];
277
265
  const item = items.find((i) => i.id === trackId);
278
266
  if (!item) {
279
267
  throw new Error(`Track ${trackId} not found in playlist ${playlistId}.`);
package/dist/saved.js CHANGED
@@ -7,17 +7,13 @@ exports.addSavedItem = addSavedItem;
7
7
  exports.removeSavedItemData = removeSavedItemData;
8
8
  exports.removeSavedItem = removeSavedItem;
9
9
  const auth_1 = require("./auth");
10
+ const pagination_1 = require("./pagination");
10
11
  async function listSavedItemsData(client) {
11
- const { data, error } = await client.GET('/userCollectionSaveForLaters/{id}/relationships/items', {
12
- params: {
13
- path: { id: 'me' },
14
- query: { include: ['items'] },
15
- },
12
+ // Paginated: returns the full save-for-later collection, not just the first ~20.
13
+ const { included } = await (0, pagination_1.fetchAllPages)(client, '/userCollectionSaveForLaters/{id}/relationships/items', {
14
+ path: { id: 'me' },
15
+ query: { include: ['items'] },
16
16
  });
17
- if (error || !data) {
18
- throw new Error(`Failed to list saved items — ${JSON.stringify(error)}`);
19
- }
20
- const included = data.included ?? [];
21
17
  return included.map((item) => {
22
18
  const attrs = item.attributes ?? {};
23
19
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lucaperret/tidal-cli",
3
- "version": "1.2.3",
3
+ "version": "1.2.5",
4
4
  "description": "CLI for Tidal music streaming service, designed for LLM agent automation",
5
5
  "main": "dist/index.js",
6
6
  "bin": {