From b528720e8b82b5723cdfcbd7e6f9bf3bde7512d1 Mon Sep 17 00:00:00 2001 From: Samidy Date: Sun, 19 Apr 2026 01:49:00 +0300 Subject: [PATCH] Revert "Fix v2 artists API: correct data access paths" --- js/HiFi.ts | 292 ++++++++++++++++------------------------------------- js/api.js | 7 +- 2 files changed, 89 insertions(+), 210 deletions(-) diff --git a/js/HiFi.ts b/js/HiFi.ts index 4f81a51..e96a4d6 100644 --- a/js/HiFi.ts +++ b/js/HiFi.ts @@ -100,48 +100,26 @@ export interface TidalArtistProfile { id: number; /** Artist display name. */ name: string; - /** - * Roles this artist holds on TIDAL, e.g. `["ARTIST", "CONTRIBUTOR"]`. - * Present in v1 responses; absent from v2. - */ - artistTypes?: string[]; + /** Roles this artist holds on TIDAL, e.g. `["ARTIST", "CONTRIBUTOR"]`. */ + artistTypes: string[]; /** Canonical TIDAL artist URL. */ url: string; /** Picture UUID, or `null` if no image is available. */ picture: string | null; - /** - * Fallback album cover UUID used when no artist picture exists, or `null`. - * Present in v1 responses; absent from v2. - */ - selectedAlbumCoverFallback?: string | null; - /** Popularity score (0-100; the raw v2 float (0-1) is multiplied by 100 and rounded). */ + /** Fallback album cover UUID used when no artist picture exists, or `null`. */ + selectedAlbumCoverFallback: string | null; + /** Popularity score (0-100). */ popularity: number; - /** - * List of credited roles for this artist. - * Present in v1 responses; absent from v2. - */ - artistRoles?: TidalArtistRole[]; - /** - * Map of mix type → mix ID, e.g. `{ "ARTIST_MIX": "000ff..." }`. - * Present in v1 responses; absent from v2. - */ - mixes?: Record; - /** - * TIDAL handle, or `null` if not set. - * Present in v1 responses; absent from v2. - */ - handle?: string | null; - /** - * Associated TIDAL user ID, or `null`. - * Present in v1 responses; absent from v2. - */ - userId?: number | null; + /** List of credited roles for this artist. */ + artistRoles: TidalArtistRole[]; + /** Map of mix type → mix ID, e.g. `{ "ARTIST_MIX": "000ff..." }`. */ + mixes: Record; + /** TIDAL handle, or `null` if not set. */ + handle: string | null; + /** Associated TIDAL user ID, or `null`. */ + userId: number | null; /** Whether the artist is currently spotlighted. */ - spotlighted?: boolean; - /** Whether artist contributions are enabled (v2 only). */ - contributionsEnabled?: boolean; - /** Owner type, e.g. `"LABEL"` (v2 only). */ - ownerType?: string; + spotlighted: boolean; } /** @@ -598,35 +576,13 @@ export interface ArtistCover { /** * Response returned by the `/artist` route when an `id` query parameter is supplied. - * Contains the artist's full profile, optional cover image URL, and (when using the - * v2 OpenAPI endpoint) the artist's albums and tracks inline. + * Contains the artist's full profile and optional cover image URL. */ export interface ArtistByIdResponse extends VersionedResponse { /** Full TIDAL artist profile data. */ artist: TidalArtistProfile; /** Cover image URL at 750 px, or `null` if no picture is available. */ cover: ArtistCover | null; - /** - * Albums associated with the artist (populated by the v2 endpoint). - * Items are partial because the v2 artist endpoint does not return every - * field present in a v1 full-album response. - * Absent in v1 responses. - */ - albums?: { items: Partial[] }; - /** - * Top tracks for the artist (populated by the v2 endpoint). - * Items are partial because the v2 artist endpoint does not return every - * field present in a v1 full-track response. - * Absent in v1 responses. - */ - tracks?: Partial[]; - /** - * Inline biography extracted from the v2 `included` array. - * Only `text` and `source` are available from this endpoint; - * for the full biography use the `/artist/bio` route. - * Absent in v1 responses and when no biography is available. - */ - biography?: { text: string; source?: string } | null; } /** @@ -1054,14 +1010,12 @@ interface JsonApiIncludeAttributes { externalLinks?: Array<{ href: string; meta: { type: string } }>; spotlighted?: boolean; contributionsEnabled?: boolean; - ownerType?: string; selectedAlbumCoverFallback?: string | null; files?: Array<{ href: string }>; title?: string; barcodeId?: string; numberOfVolumes?: number; numberOfItems?: number; - /** ISO 8601 duration string, e.g. `"PT3M45S"`. */ duration?: string; explicit?: boolean; releaseDate?: string; @@ -1072,8 +1026,6 @@ interface JsonApiIncludeAttributes { albumType?: string; createdAt?: string; type?: string; - text?: string; - source?: string; } /** An included resource node from a TIDAL OpenAPI JSON:API response. */ @@ -1081,26 +1033,12 @@ interface JsonApiInclude { id: string; type: string; attributes: JsonApiIncludeAttributes; - /** - * Relationships map. `data` may be a single ref (e.g. `biography`) or an array - * (e.g. `profileArt`, `coverArt`, `artists`). - */ - relationships?: Record }>; + relationships?: Record; } -/** A TIDAL OpenAPI JSON:API list response for relationship endpoints (e.g. similar-artists/albums). */ +/** A TIDAL OpenAPI JSON:API list response (similar-artists/albums). */ interface JsonApiListResponse { - /** Top-level data array returned by v2 list/relationship endpoints. */ data?: JsonApiRef[]; - /** Top-level included resources returned alongside the data array. */ - included?: JsonApiInclude[]; -} - -/** A TIDAL OpenAPI JSON:API artist detail response (v2 /artists/{id} endpoint). */ -interface JsonApiArtistResponse { - /** Single artist resource object. */ - data?: JsonApiInclude; - /** Included side-loaded resources (albums, tracks, artworks, biographies, etc.). */ included?: JsonApiInclude[]; } @@ -1147,7 +1085,7 @@ export enum HiFiClientEvents { } class HiFiClient { - static readonly API_VERSION = '2.9'; + static readonly API_VERSION = '2.7'; static readonly BROWSER_CLIENT_ID = 'txNoH4kkV41MfH25'; static readonly BROWSER_CLIENT_SECRET = 'dQjy0MinCEvxi1O4UmxvxWnDjt4cgHBPw8ll6nYBk98='; @@ -1494,17 +1432,6 @@ class HiFiClient { return parts.length >= 9 ? parts.slice(4, 9).join('-') : null; } - /** - * Parses an ISO 8601 duration string (e.g. `"PT3M45S"`) into whole seconds. - * Returns `0` for missing or unparseable values. - */ - static #parseDuration(iso?: string | null): number { - if (!iso) return 0; - const m = iso.match(/^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?$/); - if (!m) return 0; - return Number(m[1] ?? 0) * 3600 + Number(m[2] ?? 0) * 60 + Math.round(Number(m[3] ?? 0)); - } - async #withAlbumTrackSlot(fn: () => Promise) { if (this.#albumTracksActive >= this.#albumTracksMax) { await new Promise((res) => this.#albumTracksQueue.push(res)); @@ -1673,9 +1600,10 @@ class HiFiClient { } return { + ...attr, id: Number(aid), name: attr.name ?? '', - picture: pic_id ?? null, + picture: pic_id ?? attr.selectedAlbumCoverFallback ?? null, url: `http://www.tidal.com/artist/${aid}`, relationType: 'SIMILAR_ARTIST', popularity: attr.popularity ?? 0, @@ -1752,27 +1680,13 @@ class HiFiClient { } return { + ...attr, id: Number(aid), title: attr.title ?? '', - barcodeId: attr.barcodeId ?? '', - numberOfVolumes: attr.numberOfVolumes ?? 1, - numberOfItems: attr.numberOfItems ?? 0, - duration: attr.duration ?? '', - explicit: attr.explicit ?? false, - releaseDate: attr.releaseDate ?? '', - copyright: attr.copyright ?? { text: '' }, - popularity: attr.popularity ?? 0, - accessType: attr.accessType ?? '', - availability: attr.availability ?? [], - mediaTags: attr.mediaTags ?? [], - externalLinks: attr.externalLinks ?? [], - type: attr.type ?? '', - albumType: attr.albumType ?? '', - createdAt: attr.createdAt, cover: cover_id ?? '', artists: artist_list, url: `http://www.tidal.com/album/${aid}`, - }; + } as TidalSimilarAlbum; }; return HiFiClient.#jsonResponse({ @@ -1807,63 +1721,53 @@ class HiFiClient { if (!id && !f) throw new ResponseError(400, 'Provide id or f query param'); if (id) { - // Fetch the full v1 artist profile (has artistTypes, artistRoles, mixes, integer - // popularity, handle, userId, selectedAlbumCoverFallback, etc.) and the v2 artist - // resource (for albums, tracks, biography, and profile/cover art) in parallel. - const [v1Artist, v2Payload] = await Promise.all([ - this.#fetchJson( - `https://api.tidal.com/v1/artists/${id}`, - { countryCode: this.#countryCode }, - signal - ), - this.#fetchJson( - `https://openapi.tidal.com/v2/artists/${id}`, - { - countryCode: this.#countryCode, - include: 'albums,albums.coverArt,tracks,tracks.albums,biography,profileArt', - collapseBy: 'FINGERPRINT', - }, - signal - ).catch((): JsonApiArtistResponse | null => null), - ]); + const artist_url = `https://openapi.tidal.com/v2/artists/${id}`; + const payload = await this.#fetchJson( + artist_url, + { + countryCode: this.#countryCode, + include: 'albums,albums.coverArt,tracks,tracks.albums,biography,profileArt', + collapseBy: 'FINGERPRINT', + }, + signal + ); - const includedMap = new Map(); - if (Array.isArray(v2Payload?.included)) { - for (const item of v2Payload.included) { + const includedMap = new Map(); + if (Array.isArray(payload?.included)) { + for (const item of payload.included) { includedMap.set(`${item.type}:${item.id}`, item); } } - const getPic = (item: JsonApiInclude | undefined, relName: string): string | null => { - const relData = item?.relationships?.[relName]?.data; - const picRef = Array.isArray(relData) ? relData[0] : undefined; - if (!picRef) return null; - const pic = includedMap.get(`artworks:${picRef.id}`); - const href = pic?.attributes?.files?.[0]?.href; - return href ? HiFiClient.#extractUuidFromTidalUrl(href) : null; + const getPic = (item: any, relName: string) => { + if (item?.relationships?.[relName]?.data?.[0]) { + const picRef = item.relationships[relName].data[0]; + const pic = includedMap.get(`artworks:${picRef.id}`); + return pic?.attributes?.files?.[0]?.href + ? HiFiClient.#extractUuidFromTidalUrl(pic.attributes.files[0].href) + : null; + } + return null; }; - const v2Data = v2Payload?.data; - - // Biography: v2 returns a single-ref relationship, not an array - const bioRelData = v2Data?.relationships?.biography?.data; - const bioRef = Array.isArray(bioRelData) ? bioRelData[0] : bioRelData; - const bioItem = bioRef - ? (includedMap.get(`${bioRef.type}:${bioRef.id}`) ?? - includedMap.get(`biographies:${bioRef.id}`) ?? - includedMap.get(`biography:${bioRef.id}`)) - : undefined; - - // Use the full v1 artist profile as-is. It already carries all fields the UI - // needs: name, picture UUID, popularity (0-100 integer), artistTypes, artistRoles, - // mixes, handle, userId, selectedAlbumCoverFallback, url, spotlighted, etc. - const artist_data: TidalArtistProfile = v1Artist; - - // Fall back to the v2 profileArt UUID when v1 has no picture. - if (!artist_data.picture) { - artist_data.picture = getPic(v2Data, 'profileArt'); + const data = payload?.data; + let biography: any = null; + if (data?.relationships?.biography?.data) { + const bioRef = data.relationships.biography.data; + const bioItem = + includedMap.get(`biographies:${bioRef.id}`) || includedMap.get(`biography:${bioRef.id}`); + if (bioItem) { + biography = { text: bioItem.attributes?.text, source: bioItem.attributes?.source }; + } } + const artist_data: any = { + id: Number(data?.id || id), + name: data?.attributes?.name || '', + picture: getPic(data, 'profileArt') || data?.attributes?.selectedAlbumCoverFallback || null, + biography: biography, + }; + const picture = artist_data.picture; let cover: ArtistCover | null = null; if (picture) { @@ -1875,80 +1779,60 @@ class HiFiClient { }; } - const albums: Partial[] = []; - const tracks: Partial[] = []; + const albums: any[] = []; + const tracks: any[] = []; - const artistRef: TidalArtistRef = { - id: artist_data.id, - name: artist_data.name, - type: 'MAIN', - picture: artist_data.picture, - handle: artist_data.handle ?? null, - }; - - const albumsRelData = v2Data?.relationships?.albums?.data; - if (Array.isArray(albumsRelData)) { - for (const ref of albumsRelData) { + if (data?.relationships?.albums?.data) { + for (const ref of data.relationships.albums.data) { const al = includedMap.get(`albums:${ref.id}`); if (al) { albums.push({ id: Number(al.id), - title: al.attributes?.title ?? '', - duration: HiFiClient.#parseDuration(al.attributes?.duration), - numberOfTracks: al.attributes?.numberOfItems ?? 0, - releaseDate: al.attributes?.releaseDate ?? '', - type: al.attributes?.albumType ?? '', - cover: getPic(al, 'coverArt') ?? '', - artist: artistRef, - artists: [artistRef], + title: al.attributes?.title, + duration: al.attributes?.duration ? 100 : undefined, + numberOfTracks: al.attributes?.numberOfItems, + releaseDate: al.attributes?.releaseDate, + type: al.attributes?.albumType, + cover: getPic(al, 'coverArt'), + artist: { id: artist_data.id, name: artist_data.name }, }); } } } - const tracksRelData = v2Data?.relationships?.tracks?.data; - if (Array.isArray(tracksRelData)) { - for (const ref of tracksRelData) { + if (data?.relationships?.tracks?.data) { + for (const ref of data.relationships.tracks.data) { const tr = includedMap.get(`tracks:${ref.id}`); if (tr) { - const albumRelData = tr.relationships?.albums?.data; - const albumRef = Array.isArray(albumRelData) ? albumRelData[0] : undefined; - const aItem = albumRef ? includedMap.get(`albums:${albumRef.id}`) : undefined; - const albumInfo: TidalTrackAlbumRef | undefined = aItem - ? { - id: Number(aItem.id), - title: aItem.attributes?.title ?? '', - cover: getPic(aItem, 'coverArt') ?? '', - vibrantColor: '', - videoCover: null, - } - : undefined; + let albumInfo = undefined; + if (tr.relationships?.albums?.data?.[0]) { + const aRef = tr.relationships.albums.data[0]; + const aItem = includedMap.get(`albums:${aRef.id}`); + if (aItem) { + albumInfo = { + id: Number(aItem.id), + title: aItem.attributes?.title, + cover: getPic(aItem, 'coverArt'), + }; + } + } tracks.push({ id: Number(tr.id), - title: tr.attributes?.title ?? '', - duration: HiFiClient.#parseDuration(tr.attributes?.duration), - // v2 popularity is a 0-1 float; normalise to 0-100 so the consumer - // can sort tracks the same way it sorts v1 tracks. - popularity: Math.round((tr.attributes?.popularity ?? 0) * 100), + title: tr.attributes?.title, + duration: tr.attributes?.duration ? 100 : undefined, album: albumInfo, - artist: artistRef, - artists: [artistRef], + artist: { id: artist_data.id, name: artist_data.name }, }); } } } - const biography = bioItem - ? { text: bioItem.attributes?.text ?? '', source: bioItem.attributes?.source } - : null; - return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, artist: artist_data, cover, albums: { items: albums }, tracks, - biography, }); } diff --git a/js/api.js b/js/api.js index 544917a..75b0c8a 100644 --- a/js/api.js +++ b/js/api.js @@ -1167,12 +1167,7 @@ export class LosslessAPI { // Enrich tracks with album release dates const tracks = options.lightweight ? topTracks : await this.enrichTracksWithAlbumDates(topTracks); - // Biography is included inline when the id-path response carries it (from v2 included - // resources). When absent (v1-only profile with no v2 call, or no biography available), - // getArtistBiography is called lazily when the page renders and artist.biography is falsy. - const biography = primaryData.biography || null; - - const result = { ...artist, albums, eps, tracks, videos, biography }; + const result = { ...artist, albums, eps, tracks, videos }; if (!(primaryResponse instanceof TidalResponse)) { await this.cache.set('artist', cacheKey, result);