diff --git a/.opencode/plans/tidal-web-backend.md b/.opencode/plans/tidal-web-backend.md
new file mode 100644
index 0000000..8713d03
--- /dev/null
+++ b/.opencode/plans/tidal-web-backend.md
@@ -0,0 +1,107 @@
+# Replace Tidal OAuth2 Backend with Public Web API + Self-Hosted Proxy
+
+## Goal
+Replace Monochrome's Tidal OAuth2 backend (which is blocked in your country) with the SpotiFLAC tidal-web approach that uses:
+1. **Tidal public web endpoints** (`tidal.com/v1/`) with a public token for metadata/search
+2. **Self-hosted proxy** (SpotiFLAC Go backend) for audio streaming/downloads
+
+## Changes
+
+### 1. `js/HiFi.ts` - Complete rewrite
+- Remove OAuth2 token flow (`auth.tidal.com/v1/oauth2/token`)
+- Use public web token (`x-tidal-token` header, default: `49YxDN9a2aFV6RTG`)
+- Change API base from `api.tidal.com/v1/` to `tidal.com/v1/`
+- Keep all TypeScript type interfaces (TidalTrack, TidalAlbum, etc.)
+- Keep `TidalResponse` class
+- Update `query()` to use `tidal.com/v1/` endpoints
+- Methods updated: `getInfo`, `getTrack`, `getAlbum`, `getPlaylist`, `getPlaylistItems`, `getArtist`, `search`, `getVideo`, `getLyrics`, `getSimilarArtists`, `getSimilarAlbums`
+- Constructor accepts: `publicToken`, `countryCode`, `locale`, `deviceType`
+- Remove: `#fetchAppToken`, `#fetchAuthenticated`, `#fetchJson` (OAuth2-based), `fetchToken`, `getTrackManifest`
+
+### 2. `js/api.js` - Rewrite LosslessAPI
+- `fetchWithRetry()` - Remove HiFiClient OAuth2 fallback, remove proxy instance iteration for metadata (use HiFiClient directly). Keep proxy logic only for streaming if needed.
+- `search()`, `searchTracks()`, `searchArtists()`, `searchAlbums()`, `searchPlaylists()`, `searchVideos()` - Update to use new HiFiClient.search()
+- `getAlbum()` - Update to handle pages API response structure (`pages/album` returns page modules with `ALBUM_HEADER` and `ALBUM_ITEMS`)
+- `getPlaylist()` - Update to use new HiFiClient methods
+- `getArtist()` - Update to handle pages API response (`pages/artist` returns `ARTIST_HEADER` module)
+- `getTrack()` - Route through self-hosted proxy instead of Tidal OpenAPI
+- `getStreamUrl()` - Route through self-hosted proxy
+- `getVideo()` - Update for new endpoints
+- `downloadTrack()` - Stream URLs come from self-hosted proxy
+- `enrichTrack()` - Update to use proxy for playback info
+- `normalizeTrackManifestResponse()` - Adapt to proxy response format
+- Keep: `getCoverUrl()`, `getCoverSrcset()`, `getArtistPictureUrl()`, `getArtistPictureSrcset()`, `getVideoCoverUrl()`, cache methods, prepare methods
+
+### 3. `js/storage.js` - Add tidalWebSettings
+```js
+export const tidalWebSettings = {
+ STORAGE_KEY: 'tidal-web-settings',
+ DEFAULT_PROXY_URL: '', // User must set their self-hosted proxy
+ DEFAULT_PUBLIC_TOKEN: '49YxDN9a2aFV6RTG',
+ DEFAULT_COUNTRY_CODE: 'US',
+ getProxyUrl() { ... },
+ setProxyUrl(url) { ... },
+ getPublicToken() { ... },
+ setPublicToken(token) { ... },
+ getCountryCode() { ... },
+ setCountryCode(code) { ... },
+};
+```
+
+### 4. `js/proxy-utils.js` - Simplify
+- Remove tidal CDN URL proxying (no longer needed - streaming goes through self-hosted proxy)
+- Keep CORS proxy list for any remaining direct stream URLs
+
+### 5. `js/music-api.js` - Minimal changes
+- `getTrack()`, `getStreamUrl()`, `downloadTrack()` already delegate to LosslessAPI
+- No structural changes needed
+
+### 6. `js/app.js` - Update initialization
+- Change `HiFiClient.initialize()` to pass `publicToken`, `countryCode` from tidalWebSettings
+- Remove OAuth2 token/tokenExpiry storage references
+
+## Self-Hosted Proxy API Contract
+
+The self-hosted SpotiFLAC Go backend must expose:
+
+```
+POST {proxyUrl}/v1/dl/tid2
+Body: { "id": "123456", "quality": "HI_RES_LOSSLESS" }
+
+Response:
+{
+ "data": {
+ "manifest": "base64-encoded-manifest",
+ "manifestMimeType": "application/dash+xml",
+ "audioQuality": "HI_RES_LOSSLESS",
+ "bitDepth": 24,
+ "sampleRate": 96000,
+ "trackPresentation": "FULL"
+ }
+}
+```
+
+Alternative response format (from mirror proxies):
+```json
+[{ "OriginalTrackUrl": "https://direct-stream-url.flac" }]
+```
+
+## Deployment Notes
+
+The SpotiFLAC Go backend is at `go_backend/` in the SpotiFLAC-Mobile repo:
+- Build: `cd go_backend && go build`
+- Run: `./go_backend` (listens on port)
+- Configure with Tidal credentials or public token
+
+## Files Modified
+1. `js/HiFi.ts` - Full rewrite
+2. `js/api.js` - Major rewrite (LosslessAPI class)
+3. `js/storage.js` - Add tidalWebSettings
+4. `js/proxy-utils.js` - Simplify
+5. `js/music-api.js` - Minimal changes
+6. `js/app.js` - Update initialization
+
+## Files NOT Modified
+- Server-side functions (Cloudflare Workers for SEO) - continue using OAuth2
+- Frontend UI components
+- Player, downloads, metadata embedding logic (unchanged interfaces)
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..988a86e
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,4 @@
+## Permissions
+- Allow edits to: js/*.js, js/*.ts, package.json
+- Allow edits to: .opencode/plans/*.md
+- Deny: git commit, git push, .git modifications
\ No newline at end of file
diff --git a/index.html b/index.html
index 9b0ad37..968e8b0 100644
--- a/index.html
+++ b/index.html
@@ -5629,6 +5629,71 @@
+
+
+
+ Tidal Web Proxy URL
+ URL of your self-hosted SpotiFLAC proxy for streaming and downloads. Leave empty to use default instances.
+
+
+
+
+
+ Public Token
+ Tidal public web token for API requests
+
+
+
+
+
+ Country Code
+ Country code for Tidal API requests (e.g., US, GB)
+
+
+
+
+
diff --git a/js/HiFi.ts b/js/HiFi.ts
index e7dd306..1ac7b2c 100644
--- a/js/HiFi.ts
+++ b/js/HiFi.ts
@@ -1,4 +1,3 @@
-import { EventEmitter } from 'events';
import type { PlaybackInfo } from './container-classes';
type Params = Record
;
@@ -11,29 +10,11 @@ class ResponseError extends Error {
}
}
-/**
- * A generic response interface that types the return value of `.json()` as `T`.
- *
- * Extends the standard `Response` interface with a typed `.json()` method so callers
- * can receive properly-typed data without manual casting.
- *
- * @typeParam T - The expected shape of the parsed JSON body.
- */
export interface TypedResponse extends Response {
- /** Returns a promise that resolves with the response body parsed as JSON, typed as `T`. */
json(): Promise;
}
-/**
- * A typed extension of the standard `Response` class returned by all TIDAL API methods.
- *
- * The generic type parameter `T` controls the type returned by `.json()`, enabling
- * full type safety on API responses without manual casts.
- *
- * @typeParam T - The expected JSON body type. Defaults to `unknown`.
- */
export class TidalResponse extends Response implements TypedResponse {
- /** Returns a promise that resolves with the response body parsed as JSON, typed as `T`. */
declare json: () => Promise;
constructor(response: Response);
@@ -51,959 +32,438 @@ export class TidalResponse extends Response implements TypedRespons
}
}
-// ─── Route response interfaces ───────────────────────────────────────────────
-
-/**
- * Base interface shared by all versioned TIDAL API responses.
- * Every response envelope includes a `version` string matching {@link HiFiClient.API_VERSION}.
- */
export interface VersionedResponse {
- /** The API version string, e.g. `"2.7"`. */
version: string;
}
-// ─── Shared sub-types (derived from live API samples) ────────────────────────
-
-/**
- * Minimal artist reference as it appears inside track and album objects.
- * For the full artist profile returned by the `/artist` route, see {@link TidalArtistProfile}.
- */
export interface TidalArtistRef {
- /** Numeric TIDAL artist ID. */
id: number;
- /** Artist display name. */
name: string;
- /** TIDAL handle, or `null` if not set. */
handle: string | null;
- /** Artist role in this context, e.g. `"MAIN"`. */
type: string;
- /** Picture UUID, or `null` if no image is available. */
picture: string | null;
}
-/**
- * A single artist-role entry as returned inside {@link TidalArtistProfile}.
- */
export interface TidalArtistRole {
- /** Internal category identifier (`-1` for the primary artist role). */
categoryId: number;
- /** Human-readable role label, e.g. `"Artist"`, `"Producer"`. */
category: string;
}
-/**
- * Full artist profile as returned by the `/artist/?id=` route.
- * Contains fields not present on the minimal {@link TidalArtistRef} seen inside tracks/albums.
- */
export interface TidalArtistProfile {
- /** Numeric TIDAL artist ID. */
id: number;
- /** Artist display name. */
name: 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`. */
selectedAlbumCoverFallback: string | null;
- /** Popularity score (0-100). */
popularity: number;
- /** 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;
}
-/**
- * Media metadata object attached to tracks and albums.
- */
export interface TidalMediaMetadata {
- /** Quality tags, e.g. `["LOSSLESS"]`, `["HIRES_LOSSLESS"]`. */
tags: string[];
}
-/**
- * Slim album reference embedded inside a track object.
- */
export interface TidalTrackAlbumRef {
- /** Numeric TIDAL album ID. */
id: number;
- /** Album title. */
title: string;
- /** Cover image UUID. */
cover: string;
- /** Vibrant accent colour hex string derived from the cover art. */
vibrantColor: string;
- /** Video cover UUID, or `null`. */
videoCover: string | null;
}
-/**
- * Full track object as returned by the `/info` route and embedded in albums, playlists, and mixes.
- *
- * @remarks
- * Fields `bpm`, `key`, and `keyScale` are nullable - they are absent for some tracks.
- * `version` is present in the payload but may be `null`.
- */
export interface TidalTrack {
- /** Numeric TIDAL track ID. */
id: number;
- /** Track title. */
title: string;
- /** Duration in seconds. */
duration: number;
- /** Track replay-gain value in dB. */
replayGain: number;
- /** Track peak amplitude (0-1). */
peak: number;
- /** Whether the track is available for streaming. */
allowStreaming: boolean;
- /** Whether the stream is ready. */
streamReady: boolean;
- /** Whether the track requires payment to stream. */
payToStream: boolean;
- /** Whether the track is available for ad-supported streaming. */
adSupportedStreamReady: boolean;
- /** Whether the track is available for DJ use. */
djReady: boolean;
- /** Whether stem files are available. */
stemReady: boolean;
- /** ISO-8601 timestamp from which the stream became available. */
streamStartDate: string;
- /** Whether a premium subscription is required. */
premiumStreamingOnly: boolean;
- /** Track number within its volume. */
trackNumber: number;
- /** Disc/volume number. */
volumeNumber: number;
- /** Version suffix (e.g. `"Remastered"`), or `null`. */
version: string | null;
- /** Popularity score (0-100). */
popularity: number;
- /** Copyright notice. */
copyright: string;
- /** Beats per minute, or `null` if unavailable. */
bpm: number | null;
- /** Musical key (e.g. `"Bb"`), or `null` if unavailable. */
key: string | null;
- /** Key scale (`"MAJOR"` / `"MINOR"`), or `null` if unavailable. */
keyScale: string | null;
- /** Canonical TIDAL track URL. */
url: string;
- /** International Standard Recording Code. */
isrc: string;
- /** Whether the track metadata can be edited. */
editable: boolean;
- /** Whether the track contains explicit content. */
explicit: boolean;
- /** Highest available audio quality, e.g. `"LOSSLESS"`, `"HI_RES_LOSSLESS"`. */
audioQuality: string;
- /** Available audio modes, e.g. `["STEREO"]`. */
audioModes: string[];
- /** Media metadata including quality tags. */
mediaMetadata: TidalMediaMetadata;
- /** Whether this is a user-uploaded track. */
upload: boolean;
- /** Access type, e.g. `"PUBLIC"`. */
accessType: string;
- /** Whether the track is currently spotlighted. */
spotlighted: boolean;
- /** Primary artist. */
artist: TidalArtistRef;
- /** All credited artists. */
artists: TidalArtistRef[];
- /** Album this track belongs to. */
album: TidalTrackAlbumRef;
- /** Map of mix type → mix ID, e.g. `{ "TRACK_MIX": "001e91..." }`. */
mixes: Record;
}
-/**
- * A track as it appears inside a playlist, extending {@link TidalTrack} with
- * playlist-specific fields added by the TIDAL API.
- */
export interface TidalPlaylistTrack extends TidalTrack {
- /** Track description text, or `null`. */
description: string | null;
- /** ISO-8601 timestamp when this track was added to the playlist. */
dateAdded: string;
- /** Position index within the playlist. */
index: number;
- /** Unique item UUID within the playlist. */
itemUuid: string;
}
-/**
- * Full album object as returned by the `/artist/?f=` discography route and
- * embedded in search results.
- *
- * @remarks
- * `artist` is present in discography responses but absent in some search results;
- * it is therefore typed as optional.
- * `version` and `videoCover` are present in the payload but may be `null`.
- */
export interface TidalAlbum {
- /** Numeric TIDAL album ID. */
id: number;
- /** Album title. */
title: string;
- /** Total duration in seconds. */
duration: number;
- /** Whether the stream is ready. */
streamReady: boolean;
- /** Whether the album requires payment to stream. */
payToStream: boolean;
- /** Whether the album is available for ad-supported streaming. */
adSupportedStreamReady: boolean;
- /** Whether the album is available for DJ use. */
djReady: boolean;
- /** Whether stem files are available. */
stemReady: boolean;
- /** ISO-8601 timestamp from which the stream became available. */
streamStartDate: string;
- /** Whether streaming is allowed. */
allowStreaming: boolean;
- /** Whether a premium subscription is required. */
premiumStreamingOnly: boolean;
- /** Number of tracks on the album. */
numberOfTracks: number;
- /** Number of videos on the album. */
numberOfVideos: number;
- /** Number of discs/volumes. */
numberOfVolumes: number;
- /** Release date string, e.g. `"2025-02-14"`. */
releaseDate: string;
- /** Copyright notice. */
copyright: string;
- /** Release type, e.g. `"ALBUM"`, `"EP"`, `"SINGLE"`. */
type: string;
- /** Version suffix, or `null`. */
version: string | null;
- /** Canonical TIDAL album URL. */
url: string;
- /** Cover image UUID. */
cover: string;
- /** Vibrant accent colour hex string. */
vibrantColor: string;
- /** Video cover UUID, or `null`. */
videoCover: string | null;
- /** Whether the album contains explicit content. */
explicit: boolean;
- /** UPC barcode. */
upc: string;
- /** Popularity score (0-100). */
popularity: number;
- /** Highest available audio quality. */
audioQuality: string;
- /** Available audio modes. */
audioModes: string[];
- /** Media metadata including quality tags. */
mediaMetadata: TidalMediaMetadata;
- /** Whether this is a user-uploaded album. */
upload: boolean;
- /** Primary artist (present in discography responses; absent in some search results). */
artist?: TidalArtistRef;
- /** All credited artists. */
artists: TidalArtistRef[];
}
-/**
- * A video item as returned inside search results and the topvideos page modules.
- */
export interface TidalVideoItem {
- /** Numeric TIDAL video ID. */
id: number;
- /** Video title. */
title: string;
- /** Duration in seconds. */
duration: number;
- /** Version suffix, or `null`. */
version: string | null;
- /** Canonical TIDAL video URL. */
url: string;
- /** All credited artists. */
artists: TidalArtistRef[];
- /** Associated album, or `null`. */
album: TidalTrackAlbumRef | null;
- /** Whether the video contains explicit content. */
explicit: boolean;
- /** Disc/volume number. */
volumeNumber: number;
- /** Track number on the disc. */
trackNumber: number;
- /** Popularity score (0-100). */
popularity: number;
- /** Double-precision popularity score (present in topvideos). */
doublePopularity?: number;
- /** Whether streaming is allowed. */
allowStreaming: boolean;
- /** Whether the stream is ready. */
streamReady: boolean;
- /** ISO-8601 timestamp from which streaming became available. */
streamStartDate: string;
- /** Whether the video is available for ad-supported streaming. */
adSupportedStreamReady: boolean;
- /** Whether the video is available for DJ use. */
djReady: boolean;
- /** Whether stem files are available. */
stemReady: boolean;
- /** Thumbnail image UUID. */
imageId: string;
- /** Image path (present in some search results), or `null`. */
imagePath?: string | null;
- /** Vibrant accent colour hex string. */
vibrantColor: string;
- /** Release date string. */
releaseDate: string;
- /** Content type, e.g. `"Music Video"`. */
type: string;
- /** Ad tag URL, or `null`. */
adsUrl: string | null;
- /** Whether ads are pre-paywall only. */
adsPrePaywallOnly: boolean;
- /** Playback quality label (present in some search results), e.g. `"MP4_1080P"`. */
quality?: string;
}
-/**
- * A page module object as returned inside {@link TopVideosResponse.videos}.
- *
- * @remarks
- * The `/topvideos` route processes a TIDAL pages API response. When the page
- * contains `VIDEO_LIST`-type modules (which do not match the `VIDEO_PLAYLIST` /
- * `VIDEO_ROW` / `PAGED_LIST` extraction path) the entire module object is pushed
- * into the output array. The actual video items are nested inside `pagedList.items`.
- */
export interface TidalVideoPageModule {
- /** Base-64 encoded module identifier. */
id: string;
- /** Module type, e.g. `"VIDEO_LIST"`. */
type: string;
- /** Column width percentage. */
width: number;
- /** Scroll direction, e.g. `"VERTICAL"`. */
scroll: string;
- /** Module title. */
title: string;
- /** Module description. */
description: string;
- /** "Show more" link data, or `null`. */
showMore: string | null;
- /** Paged list of video items. */
pagedList: {
- /** Internal API path for paged data. */
dataApiPath: string;
- /** Page size. */
limit: number;
- /** Current offset. */
offset: number;
- /** Total number of items available. */
totalNumberOfItems: number;
- /** Video items on this page. */
items: TidalVideoItem[];
};
- /** Whether the module supports paging. */
supportsPaging: boolean;
- /** Whether to show table headers. */
showTableHeaders: boolean;
- /** List display format. */
listFormat: string;
- /** Layout hint, or `null`. */
layout: string | null;
- /** Whether quick-play is enabled. */
quickPlay: boolean;
- /** Pre-title label, or `null`. */
preTitle: string | null;
}
-/**
- * A similar album entry as returned by the TIDAL OpenAPI `/album/similar` endpoint.
- *
- * @remarks
- * This shape differs substantially from a standard {@link TidalAlbum}: `duration` is an
- * ISO 8601 duration string (e.g. `"PT1H14M30S"`), `copyright` is an object, and several
- * fields (`barcodeId`, `mediaTags`, `availability`, `albumType`) have no equivalent in the
- * standard album object.
- */
export interface TidalSimilarAlbum {
- /** Numeric TIDAL album ID. */
id: number;
- /** Album title. */
title: string;
- /** UPC/EAN barcode identifier. */
barcodeId: string;
- /** Number of discs/volumes. */
numberOfVolumes: number;
- /** Total number of tracks (called `numberOfItems` in this endpoint). */
numberOfItems: number;
- /** ISO 8601 duration string, e.g. `"PT1H14M30S"`. */
duration: string;
- /** Whether the album contains explicit content. */
explicit: boolean;
- /** Release date string, e.g. `"2015-10-09"`. */
releaseDate: string;
- /** Copyright information. */
copyright: { text: string };
- /** Popularity score (0-1 float). */
popularity: number;
- /** Access type, e.g. `"PUBLIC"`. */
accessType: string;
- /** Availability modes, e.g. `["STREAM", "DJ"]`. */
availability: string[];
- /** Quality tags, e.g. `["LOSSLESS", "HIRES_LOSSLESS"]`. */
mediaTags: string[];
- /** External link entries (e.g. TIDAL sharing URL). */
externalLinks: Array<{ href: string; meta: { type: string } }>;
- /** Release type, e.g. `"ALBUM"`. */
type: string;
- /** Album type classification, e.g. `"ALBUM"`. */
albumType: string;
- /** ISO-8601 creation timestamp (present for some albums). */
createdAt?: string;
- /** Cover image UUID. */
cover: string;
- /** Abbreviated artist list for this album. */
artists: Array<{ id: number; name: string }>;
- /** Canonical TIDAL album URL. */
url: string;
}
-// ─── Response interfaces ──────────────────────────────────────────────────────
-
-/**
- * Response returned by the root `/` route.
- * Contains a link to the upstream HiFi API repository.
- */
export interface RootResponse extends VersionedResponse {
- /** URL of the upstream HiFi API repository. */
Repo: string;
}
-/**
- * Response returned by the `/info` route.
- * Contains full TIDAL track metadata.
- */
export interface InfoResponse extends VersionedResponse {
- /** Full metadata for the requested track. */
data: TidalTrack;
}
-/**
- * Response returned by the `/track` route.
- * Contains playback/stream information for a track.
- *
- * @remarks `data` is typed as {@link PlaybackInfo} from `container-classes`, whose
- * fields match the live API sample exactly.
- */
export interface TrackResponse extends VersionedResponse {
- /** Playback info including manifest, quality, and replay-gain data. */
data: PlaybackInfo;
}
-/**
- * Response returned by the `/recommendations` route.
- * Contains a paginated list of recommended tracks for a given track ID.
- *
- * @remarks No live API sample is available for this route.
- */
export interface RecommendationsResponse extends VersionedResponse {
- /** Raw TIDAL v1 recommendations payload. */
data: unknown;
}
-/**
- * A similar-artist entry as returned by the TIDAL OpenAPI `/artist/similar` endpoint.
- */
export interface SimilarArtist {
- /** Numeric TIDAL artist ID. */
id: number;
- /** Artist display name. */
name: string;
- /** Picture UUID, or `null` if no image is available. */
picture: string | null;
- /** Canonical TIDAL artist URL. */
url: string;
- /** Relation type, e.g. `"SIMILAR_ARTIST"`. */
relationType: string;
- /** Popularity score (0-1 float). */
popularity: number;
- /** External link entries (e.g. TIDAL sharing URL). */
externalLinks: Array<{ href: string; meta: { type: string } }>;
- /** Whether the artist is spotlighted. */
spotlighted: boolean;
- /** Whether artist contributions are enabled. */
contributionsEnabled: boolean;
}
-/**
- * Response returned by the `/artist/similar` route.
- * Contains a list of artists similar to the requested artist.
- */
export interface SimilarArtistsResponse extends VersionedResponse {
- /** List of similar artists. */
artists: SimilarArtist[];
}
-/**
- * Response returned by the `/album/similar` route.
- * Contains a list of albums similar to the requested album.
- */
export interface SimilarAlbumsResponse extends VersionedResponse {
- /** List of similar albums. */
albums: TidalSimilarAlbum[];
}
-/**
- * Artist cover image URL at 750 px resolution.
- * Returned inside {@link ArtistByIdResponse}.
- */
export interface ArtistCover {
- /** The TIDAL artist ID. */
id: number;
- /** The artist display name. */
name: string;
- /** 750×750 JPEG cover URL. */
'750': string;
}
-/**
- * Response returned by the `/artist` route when an `id` query parameter is supplied.
- * 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;
}
-/**
- * Response returned by the `/artist` route when an `f` query parameter is supplied.
- * Contains the artist's discography and, when `skip_tracks` is false, their top tracks.
- */
export interface ArtistDiscographyResponse extends VersionedResponse {
- /** Paginated album list for the artist. */
albums: { items: TidalAlbum[] };
- /**
- * Top tracks for the artist across all albums.
- * Absent when the request includes `skip_tracks=true`.
- */
tracks?: TidalTrack[];
}
-/**
- * Union of the two possible response shapes from the `/artist` route.
- * Use {@link ArtistByIdResponse} (has `artist`) when querying by `id`,
- * or {@link ArtistDiscographyResponse} (has `albums`) when querying by `f`.
- */
export type ArtistResponse = ArtistByIdResponse | ArtistDiscographyResponse;
-/**
- * Artist biography as returned by the TIDAL API.
- */
export interface ArtistBiography {
- /** Provider or publication source of the biography text, e.g. `"TiVo"`. */
source: string;
- /** ISO-8601 timestamp of the last biography update. */
lastUpdated: string;
- /** Full biography text. */
text: string;
- /** Short biography summary (may be an empty string). */
summary: string;
}
-/**
- * Response returned by the `/artist/bio` route.
- * Contains the biography text for the requested artist.
- */
export interface ArtistBioResponse extends VersionedResponse {
- /** Biography data for the requested artist. */
data: ArtistBiography;
}
-/**
- * A single cover-image entry with pre-built URLs at multiple resolutions.
- * Returned inside {@link CoverResponse}.
- */
export interface CoverEntry {
- /** The TIDAL track or album ID associated with this cover. */
id: number;
- /** The track or album title associated with this cover. */
name: string;
- /** 1280×1280 JPEG cover URL. */
'1280': string;
- /** 640×640 JPEG cover URL. */
'640': string;
- /** 80×80 JPEG cover URL. */
'80': string;
}
-/**
- * Response returned by the `/cover` route.
- * Contains one or more cover-image entries matching the query.
- */
export interface CoverResponse extends VersionedResponse {
- /** Resolved cover image entries. */
covers: CoverEntry[];
}
-/**
- * A paginated result bucket as returned inside {@link SearchResponse.data}.
- */
export interface TidalSearchBucket {
- /** Maximum number of items per page. */
limit: number;
- /** Current page offset. */
offset: number;
- /** Total number of matching items. */
totalNumberOfItems: number;
- /** Items on this page. */
items: T[];
}
-/**
- * Response returned by the `/search` route.
- *
- * @remarks
- * Two distinct query formats exist:
- * - `?q=` (general search): returns `topHit` and combined buckets for artists, albums, tracks, videos, playlists.
- * - `?v=` (video-focused search): returns `topHits` (plural), `genres`, and the same buckets.
- */
export interface SearchResponse extends VersionedResponse {
- /** Combined search result buckets. */
data: {
- /** Matching artist results. */
artists?: TidalSearchBucket;
- /** Matching album results. */
albums?: TidalSearchBucket;
- /** Matching track results. */
tracks?: TidalSearchBucket;
- /** Matching video results. */
videos?: TidalSearchBucket;
- /** Matching playlist results. */
playlists?: TidalSearchBucket;
- /** Genre results (present for `?v=` video searches). */
genres?: TidalSearchBucket;
- /** Single top-hit result (present for `?q=` general searches). */
topHit?: { value: TidalTrack | TidalArtistProfile | TidalAlbum | TidalVideoItem; type: string };
- /** Multiple top-hit results (present for `?v=` video searches). */
topHits?: Array;
};
}
-/**
- * An album with its full track listing, as returned by the `/album` route.
- *
- * @remarks
- * Each element of `items` wraps the track in an envelope `{ item, type }`.
- */
export interface TidalAlbumWithTracks extends TidalAlbum {
- /** Ordered list of track envelopes. */
items: Array<{ item: TidalTrack; type: string }>;
}
-/**
- * Response returned by the `/album` route.
- * Contains album metadata together with its full track listing.
- */
export interface AlbumResponse extends VersionedResponse {
- /** Album data including all tracks as `items`. */
data: TidalAlbumWithTracks;
}
-/**
- * A promoted artist entry as it appears inside {@link TidalPlaylist}.
- */
export interface TidalPromotedArtist {
- /** Numeric TIDAL artist ID. */
id: number;
- /** Artist display name. */
name: string;
- /** TIDAL handle, or `null`. */
handle: string | null;
- /** Artist role, e.g. `"MAIN"`. */
type: string;
- /** Picture UUID, or `null`. */
picture: string | null;
}
-/**
- * A TIDAL playlist as returned by the `/playlist` route.
- */
export interface TidalPlaylist {
- /** Unique playlist UUID. */
uuid: string;
- /** Playlist display title. */
title: string;
- /** Total number of tracks in the playlist. */
numberOfTracks: number;
- /** Total number of videos in the playlist. */
numberOfVideos: number;
- /** Playlist creator. */
creator: { id: number };
- /** Playlist description text. */
description: string;
- /** Total playlist duration in seconds. */
duration: number;
- /** ISO-8601 timestamp of the last update. */
lastUpdated: string;
- /** ISO-8601 creation timestamp. */
created: string;
- /** Playlist type, e.g. `"EDITORIAL"`. */
type: string;
- /** Whether the playlist is publicly accessible. */
publicPlaylist: boolean;
- /** Canonical TIDAL URL for this playlist. */
url: string;
- /** Rectangular cover image UUID. */
image: string;
- /** Playlist popularity score. */
popularity: number;
- /** Square cover image UUID, or `undefined` if not set. */
squareImage?: string;
- /** Custom image URL override, or `null`. */
customImageUrl: string | null;
- /** Artists featured/promoted in the playlist header. */
promotedArtists: TidalPromotedArtist[];
- /** ISO-8601 timestamp when the most recent item was added. */
lastItemAddedAt: string;
}
-/**
- * A single item in a TIDAL playlist.
- */
export interface PlaylistItem {
- /** The track object, augmented with playlist-specific fields. */
item: TidalPlaylistTrack;
- /** Item type string, e.g. `"track"`. */
type: string;
- /** Cut data associated with the item, or `null`. */
cut: string | null;
}
-/**
- * Response returned by the `/playlist` route.
- * Contains the playlist metadata and its item list.
- */
export interface PlaylistResponse extends VersionedResponse {
- /** Playlist metadata. */
playlist: TidalPlaylist;
- /** Ordered list of playlist items. */
items: PlaylistItem[];
}
-/**
- * A TIDAL mix header as parsed from a `/pages/mix` response.
- *
- * @remarks No live API sample is available for this route.
- */
export interface Mix {
- /** Mix identifier. */
id: string;
- /** Mix display title. */
title: string;
- /** Optional mix subtitle. */
subTitle?: string;
}
-/**
- * Response returned by the `/mix` route.
- * Contains the mix header and its constituent tracks.
- *
- * @remarks No live API sample is available for this route; the shape is inferred
- * from the TIDAL pages API structure.
- */
export interface MixResponse extends VersionedResponse {
- /** Mix metadata. */
mix: unknown;
- /** Ordered list of tracks in this mix. */
items: TidalTrack[];
}
-/**
- * Lyrics data as returned by the TIDAL API.
- *
- * @remarks No live API sample is available for this route.
- */
export interface Lyrics {
- /** The TIDAL track ID these lyrics belong to. */
trackId: number;
- /** Name of the lyrics provider. */
lyricsProvider: string;
- /** Provider's common-track identifier. */
providerCommontrackId: string;
- /** Provider's lyrics-specific identifier. */
providerLyricsId: string;
- /** Full unsynced lyrics text. */
lyrics: string;
- /** Time-synced subtitle text (LRC format). */
subtitles: string;
- /** Whether the lyrics text reads right-to-left. */
isRightToLeft: boolean;
}
-/**
- * Response returned by the `/lyrics` route.
- * Contains the lyrics data for the requested track.
- *
- * @remarks No live API sample is available for this route.
- */
export interface LyricsResponse extends VersionedResponse {
- /** Lyrics data for the requested track. */
lyrics: unknown;
}
-/**
- * Video playback info as returned by the TIDAL API for the `/video` route.
- */
export interface VideoPlaybackInfo {
- /** The TIDAL video ID. */
videoId: number;
- /** Stream type, e.g. `"ON_DEMAND"`. */
streamType: string;
- /** Asset presentation type, e.g. `"FULL"`. */
assetPresentation: string;
- /** Requested video quality, e.g. `"HIGH"`. */
videoQuality: string;
- /** MIME type of the manifest, e.g. `"application/vnd.tidal.emu"`. */
manifestMimeType: string;
- /** Hash of the manifest content. */
manifestHash: string;
- /** Base-64 encoded manifest. */
manifest: string;
}
-/**
- * Response returned by the `/video` route.
- * Contains playback information for the requested video.
- */
export interface VideoResponse extends VersionedResponse {
- /** Video playback info. */
video: VideoPlaybackInfo;
}
-/**
- * Response returned by the `/topvideos` route.
- *
- * @remarks
- * The `videos` array contains the video items extracted from the page modules'
- * `pagedList.items` arrays. Individual video objects matching {@link TidalVideoItem}
- * are pushed into this array at runtime.
- */
export interface TopVideosResponse extends VersionedResponse {
- /** Video items extracted from the page modules. */
videos: TidalVideoItem[];
- /** Total number of video items before pagination. */
total: number;
}
-/**
- * Audio normalisation data embedded in a track manifest attributes object.
- */
export interface TidalAudioNormData {
- /** Replay gain value in dB. */
replayGain: number;
- /** Peak amplitude (0-1). */
peakAmplitude: number;
}
-/**
- * DRM licence data embedded in a track manifest attributes object.
- */
export interface DrmData {
- /** DRM system identifier, e.g. `"WIDEVINE"`. */
drmSystem: string;
- /** Licence acquisition URL. */
licenseUrl: string;
- /** Certificate URL. */
certificateUrl: string;
- /** DRM initialisation data, or `null`. */
initData: string | null;
}
-/**
- * Attributes of a single track-manifest resource from the TIDAL OpenAPI.
- *
- * @remarks
- * The `uri` field contains the signed manifest URL (rather than an inline Base-64
- * `manifest` string). `previewReason` is only present for preview-only tracks.
- */
export interface TrackManifestAttributes {
- /** Presentation tier, e.g. `"PREVIEW"` or `"FULL"`. */
trackPresentation: string;
- /** Reason the track is restricted to a preview (only present when `trackPresentation` is `"PREVIEW"`). */
previewReason?: string;
- /** Signed manifest URI. */
uri: string;
- /** Hash of the manifest content. */
hash: string;
- /** Playback formats included in this manifest, e.g. `["HEAACV1", "AACLC", "FLAC"]`. */
formats: string[];
- /** Album-level audio normalisation data. */
albumAudioNormalizationData: TidalAudioNormData;
- /** Track-level audio normalisation data. */
trackAudioNormalizationData: TidalAudioNormData;
- /** DRM data (only present when the track is DRM-protected). */
drmData?: DrmData;
}
-/**
- * A single `trackManifests` resource object from the TIDAL OpenAPI (JSON:API format).
- */
export interface TrackManifestResource {
- /** Resource identifier (track ID as a string). */
id: string;
- /** JSON:API resource type - always `"trackManifests"`. */
type: string;
- /** Manifest attributes. */
attributes: TrackManifestAttributes;
}
-/**
- * The raw JSON:API response envelope returned by the TIDAL OpenAPI track-manifest endpoint.
- */
export interface TrackManifestApiResponse {
- /** The primary track-manifest resource. */
data: TrackManifestResource;
- /** JSON:API links object. */
links: { self: string };
}
-/**
- * Response returned by the `/trackManifests` route.
- * Wraps the raw JSON:API track-manifest response from the TIDAL OpenAPI.
- */
export interface TrackManifestResponse extends VersionedResponse {
- /** The full JSON:API response object from the TIDAL OpenAPI. */
data: TrackManifestApiResponse;
}
-// ─────────────────────────────────────────────────────────────────────────────
-
-/**
- * A genre entry as returned inside `?v=` video search results.
- */
export interface TidalGenre {
- /** Genre identifier. */
id: string;
- /** Genre display name. */
name: string;
}
-// ─── Private implementation types ────────────────────────────────────────────
-
-/** A JSON:API reference item (id + type only), used in similar-artist/album payloads. */
type JsonApiRef = { id: string; type: string };
-/** Attribute fields present in TIDAL OpenAPI included resources for similar-artist/album endpoints. */
interface JsonApiIncludeAttributes {
name?: string;
popularity?: number;
@@ -1030,7 +490,6 @@ interface JsonApiIncludeAttributes {
source?: string;
}
-/** An included resource node from a TIDAL OpenAPI JSON:API response. */
interface JsonApiInclude {
id: string;
type: string;
@@ -1038,19 +497,16 @@ interface JsonApiInclude {
relationships?: Record;
}
-/** A TIDAL OpenAPI JSON:API list response (similar-artists/albums). */
interface JsonApiListResponse {
data?: JsonApiRef[];
included?: JsonApiInclude[];
}
-/** A generic paginated list response from TIDAL v1 endpoints. */
interface TidalListResponse {
items?: T[];
totalNumberOfItems?: number;
}
-/** A module within a TIDAL pages API row. */
interface TidalPageModule {
type: string;
mix?: Mix;
@@ -1058,38 +514,28 @@ interface TidalPageModule {
pagedList?: { items: Array<{ item?: TidalTrack | TidalVideoItem }> };
}
-/** A row within a TIDAL pages API response. */
interface TidalPageRow {
modules?: TidalPageModule[];
}
-/** Response shape from TIDAL v1 pages endpoints (mix, top videos, album pages). */
interface TidalPagesApiResponse {
rows?: TidalPageRow[];
}
-/** Type guard: returns true if the given page-module item is a {@link TidalTrack}. */
function isTidalTrack(v: TidalTrack | TidalVideoItem | { item?: TidalTrack | TidalVideoItem }): v is TidalTrack {
return 'trackNumber' in v;
}
-/** Type guard: returns true if the given page-module item is a {@link TidalVideoItem}. */
function isTidalVideoItem(
v: TidalTrack | TidalVideoItem | { item?: TidalTrack | TidalVideoItem }
): v is TidalVideoItem {
return 'imageId' in v;
}
-export enum HiFiClientEvents {
- TokenUpdate,
- TokenExpiryUpdate,
- RefreshTokenUpdate,
-}
-
class HiFiClient {
static readonly API_VERSION = '2.7';
- static readonly BROWSER_CLIENT_ID = 'txNoH4kkV41MfH25';
- static readonly BROWSER_CLIENT_SECRET = 'dQjy0MinCEvxi1O4UmxvxWnDjt4cgHBPw8ll6nYBk98=';
+ static readonly DEFAULT_PUBLIC_TOKEN = '49YxDN9a2aFV6RTG';
+ static readonly TIDAL_BASE_URL = 'https://tidal.com/v1';
static #instance: HiFiClient | null = null;
static get instance() {
@@ -1099,85 +545,14 @@ class HiFiClient {
return HiFiClient.#instance;
}
- /**
- * The base URL to use for adjusting widevine license URLs.
- */
- #baseUrl: string | null = null;
- #token: string | null = null;
- #refreshToken: string | null = null;
- #appTokenExpiry = 0;
- #tokenPromise: Promise | null = null;
- #albumTracksActive = 0;
- readonly #albumTracksMax = 20;
- readonly #albumTracksQueue: Array<() => void> = [];
+ readonly #publicToken: string;
readonly #countryCode: string;
readonly #locale: string;
- readonly #clientId: string;
- readonly #clientSecret: string;
- readonly #emitter = new EventEmitter();
-
- on(event: HiFiClientEvents.TokenUpdate, listener: (token: string | null) => void): void;
- on(event: HiFiClientEvents.TokenExpiryUpdate, listener: (expiry: number) => void): void;
- on(event: HiFiClientEvents.RefreshTokenUpdate, listener: (refreshToken: string | null) => void): void;
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- on(event: HiFiClientEvents, listener: (...args: any[]) => void) {
- this.#emitter.addListener(HiFiClientEvents[event], listener);
- }
-
- off(event: HiFiClientEvents, listener: (...args: (string | number | null)[]) => void) {
- this.#emitter.removeListener(HiFiClientEvents[event], listener);
- }
-
- #emit(event: HiFiClientEvents.TokenUpdate, token: string | null): void;
- #emit(event: HiFiClientEvents.TokenExpiryUpdate, expiry: number): void;
- #emit(event: HiFiClientEvents.RefreshTokenUpdate, refreshToken: string | null): void;
- #emit(event: HiFiClientEvents, data: string | number | null) {
- this.#emitter.emit(HiFiClientEvents[event], data);
- }
-
- get token(): string | null {
- return this.#token;
- }
-
- private set token(value: string | null) {
- this.#emit(HiFiClientEvents.TokenUpdate, (this.#token = value || null));
- }
-
- get refreshToken(): string | null {
- return this.#refreshToken || null;
- }
-
- private set refreshToken(value: string | null) {
- this.#emit(HiFiClientEvents.RefreshTokenUpdate, (this.#refreshToken = value || null));
- }
-
- get appTokenExpiry() {
- return this.#appTokenExpiry;
- }
-
- private set appTokenExpiry(value: number) {
- this.#emit(HiFiClientEvents.TokenExpiryUpdate, (this.#appTokenExpiry = value));
-
- if (value >= 0 && value < Date.now()) {
- this.token = null;
- }
- }
+ readonly #deviceType: string;
+ readonly #baseUrl: string;
#useStorage(storage: Pick) {
- this.on(HiFiClientEvents.TokenUpdate, (token) => {
- if (token) {
- storage.setItem('hifi_token', token);
- } else {
- storage.removeItem('hifi_token');
- }
- });
- this.on(HiFiClientEvents.TokenExpiryUpdate, (expiry) => {
- if (expiry) {
- storage.setItem('hifi_token_expiry', String(expiry));
- } else {
- storage.removeItem('hifi_token_expiry');
- }
- });
+ this.#publicToken && storage.setItem('tidal_web_token', this.#publicToken);
}
static #jsonResponse(data: T): TidalResponse {
@@ -1203,229 +578,57 @@ class HiFiClient {
return u.toString();
}
- /**
- * Manually sets the access token, expiry, and optional refresh token on this client.
- *
- * Useful when tokens have been obtained externally (e.g. from a server-side OAuth flow)
- * and need to be injected into the client.
- *
- * @param options - Token values to apply.
- */
- setToken({ token, tokenExpiry, refreshToken }: HiFiClient.TokenOptions & HiFiClient.RefreshTokenOptions) {
- this.token = token || null;
- this.appTokenExpiry = tokenExpiry || 0;
- this.refreshToken = refreshToken || null;
- }
-
- static #basicAuth(username: string, password: string) {
- return 'Basic ' + btoa(`${username}:${password}`);
- }
-
- async #fetchAppToken({
- clientId = HiFiClient.BROWSER_CLIENT_ID,
- clientSecret = HiFiClient.BROWSER_CLIENT_SECRET,
- refreshToken,
- scope = 'r_usr+w_usr+w_sub',
- signal = new AbortController().signal,
- force = false,
- }: HiFiClient.ClientOptions &
- HiFiClient.RefreshTokenOptions & {
- scope?: string;
- signal?: AbortSignal;
- force?: boolean;
- }): Promise {
- if (!force && this.token && (this.appTokenExpiry < 0 || Date.now() < this.appTokenExpiry)) return this.token;
-
- return await (this.#tokenPromise ??= (async (): Promise => {
- try {
- const params = new URLSearchParams({
- client_id: clientId,
- client_secret: clientSecret,
- });
-
- if (refreshToken) {
- params.set('refresh_token', refreshToken);
- params.set('grant_type', 'refresh_token');
- params.set('scope', scope);
- } else {
- params.set('grant_type', 'client_credentials');
- }
-
- const res = await fetch('https://auth.tidal.com/v1/oauth2/token', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded',
- Authorization: HiFiClient.#basicAuth(clientId, clientSecret),
- },
- body: params,
- signal,
- });
-
- if (!res.ok) {
- const txt = await res.text().catch(() => '');
- throw new Error(`Failed to obtain app token: ${res.status} ${txt}`);
- }
-
- const json = (await res.json()) as { access_token?: string; expires_in?: number };
- const token = json.access_token;
- const expires_in = json.expires_in ?? 3600;
- this.token = token || null;
- this.appTokenExpiry = Date.now() + (expires_in - 60) * 1000;
-
- return token || null;
- } finally {
- this.#tokenPromise = null;
- }
- })());
- }
-
- static #getOptions({
- locale = 'en_US',
- countryCode = 'US',
- baseUrl = '',
- clientId = HiFiClient.BROWSER_CLIENT_ID,
- clientSecret = HiFiClient.BROWSER_CLIENT_SECRET,
- token = '',
- tokenExpiry = 0,
- refreshToken = '',
- storage = [],
- }: HiFiClient.ConstructorOptions = {}): WithRequiredKeys {
- return {
- locale,
- countryCode,
- baseUrl,
- clientId,
- clientSecret,
- token,
- tokenExpiry,
- refreshToken,
- storage,
- };
- }
-
- /**
- * Obtains (or refreshes) the TIDAL application access token.
- *
- * If a non-expired token is already held and `force` is `false`, the cached
- * token is returned immediately without a network request.
- *
- * @param force - When `true`, forces a token refresh even if the current token is still valid.
- * @param signal - Optional {@link AbortSignal} to cancel the token request.
- * @returns The access token string, or `null` if one could not be obtained.
- */
- async fetchToken(force: boolean = false, signal: AbortSignal | undefined = undefined) {
- return await this.#fetchAppToken({
- clientId: this.#clientId,
- clientSecret: this.#clientSecret,
- signal,
- refreshToken: this.refreshToken || undefined,
- force: !!force,
- });
- }
-
- async #fetchAuthenticated(
- url: string,
- params?: Params | URLSearchParams,
- signal: AbortSignal = new AbortController().signal
- ): Promise {
- const final = HiFiClient.#buildUrl(url, params);
- let res: Response | undefined;
-
- while (true) {
- const unauthorized = res?.status === 401;
- const previousResponse = res;
- const token = await this.#fetchAppToken({
- clientId: this.#clientId,
- clientSecret: this.#clientSecret,
- signal,
- refreshToken: this.refreshToken || undefined,
- force: unauthorized,
- });
-
- const headers: Record = {
- authorization: `Bearer ${token}`,
- };
- if (final.includes('openapi.tidal.com')) {
- // Prefer JSON:API for OpenAPI endpoints, but do not require it exclusively.
- // Some endpoints/proxies can still return compatible JSON.
- headers['Accept'] = 'application/vnd.api+json, application/json;q=0.9, */*;q=0.8';
- }
-
- try {
- res = await fetch(final, {
- headers,
- signal,
- });
- } catch (err: unknown) {
- throw new ResponseError(0, err instanceof Error ? err.message : String(err));
- }
-
- if (previousResponse && unauthorized && res.status === 401) {
- throw new ResponseError(401, 'Unauthorized: Invalid or expired token');
- }
-
- if (res.status !== 401) break;
- }
-
- if (!res.ok) {
- throw new ResponseError(res.status, res.statusText);
- }
-
- return res;
- }
-
async #fetchJson(
url: string,
params?: Params | URLSearchParams,
signal: AbortSignal = new AbortController().signal
): Promise {
- const res = await this.#fetchAuthenticated(url, params, signal);
+ const final = HiFiClient.#buildUrl(url, params);
+
+ const headers: Record = {
+ 'x-tidal-token': this.#publicToken,
+ 'Accept': 'application/json',
+ };
+
+ const res = await fetch(final, { headers, signal });
+
+ if (!res.ok) {
+ throw new ResponseError(res.status, res.statusText);
+ }
return res.json() as Promise;
}
constructor(options: HiFiClient.ConstructorOptions = {}) {
- const { locale, countryCode, baseUrl, clientId, clientSecret, token, tokenExpiry, refreshToken, storage } =
- HiFiClient.#getOptions(options);
+ const {
+ publicToken = HiFiClient.DEFAULT_PUBLIC_TOKEN,
+ locale = 'en_US',
+ countryCode = 'US',
+ deviceType = 'BROWSER',
+ baseUrl = '',
+ storage,
+ } = options;
+
+ this.#publicToken = publicToken;
this.#locale = locale;
this.#countryCode = countryCode;
- this.#baseUrl = baseUrl || null;
- this.#clientId = clientId;
- this.#clientSecret = clientSecret;
- this.token = token || null;
- this.appTokenExpiry = tokenExpiry || 0;
- this.refreshToken = refreshToken || null;
+ this.#deviceType = deviceType;
+ this.#baseUrl = baseUrl || '';
- for (const store of !Array.isArray(storage) ? [storage] : storage) {
- this.#useStorage(store);
+ if (storage) {
+ for (const store of !Array.isArray(storage) ? [storage] : storage) {
+ this.#useStorage(store);
+ }
}
}
- /**
- * Creates and initialises the singleton {@link HiFiClient} instance.
- *
- * Throws if {@link HiFiClient.initialize} has already been called. After
- * initialisation the instance can be retrieved via {@link HiFiClient.instance}.
- *
- * @param options - Constructor options including optional credentials and locale settings.
- * @returns The newly created {@link HiFiClient} instance.
- * @throws If a singleton instance already exists.
- */
static async initialize(options: HiFiClient.ConstructorOptions & { signal?: AbortSignal } = {}) {
if (HiFiClient.#instance) {
throw new Error('HiFiClient is already initialized');
}
- const instance = (HiFiClient.#instance = new HiFiClient(options));
-
- if (!options.token && !options.clientId && !options.clientSecret) {
- await instance.#fetchAppToken({
- ...options,
- signal: options.signal || new AbortController().signal,
- });
- }
-
- return (HiFiClient.#instance = instance);
+ HiFiClient.#instance = new HiFiClient(options);
+ return HiFiClient.#instance;
}
static #extractUuidFromTidalUrl(href?: string | null) {
@@ -1434,121 +637,31 @@ class HiFiClient {
return parts.length >= 9 ? parts.slice(4, 9).join('-') : null;
}
- async #withAlbumTrackSlot(fn: () => Promise) {
- if (this.#albumTracksActive >= this.#albumTracksMax) {
- await new Promise((res) => this.#albumTracksQueue.push(res));
- }
- this.#albumTracksActive++;
- try {
- return await fn();
- } finally {
- this.#albumTracksActive--;
- const next = this.#albumTracksQueue.shift();
- if (next) next();
- }
- }
-
- /**
- * Fetches full track metadata for the given track ID.
- *
- * @param id - TIDAL track ID.
- * @param signal - Optional {@link AbortSignal} to cancel the request.
- * @returns A {@link TidalResponse} whose `.json()` resolves to an {@link InfoResponse}.
- */
async getInfo(id: number, signal?: AbortSignal): Promise> {
- const url = `https://api.tidal.com/v1/tracks/${id}/`;
+ const url = `${HiFiClient.TIDAL_BASE_URL}/tracks/${id}`;
const data = await this.#fetchJson(url, { countryCode: this.#countryCode }, signal);
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data });
}
- /**
- * Fetches playback/stream info for the given track ID.
- *
- * @param id - TIDAL track ID.
- * @param quality - Audio quality string, e.g. `"HI_RES_LOSSLESS"` (default).
- * @param immersiveAudio - Whether to request immersive audio (Dolby Atmos / 360). Defaults to `false`.
- * @param signal - Optional {@link AbortSignal} to cancel the request.
- * @returns A {@link TidalResponse} whose `.json()` resolves to a {@link TrackResponse}.
- */
async getTrack(
id: number,
quality = 'HI_RES_LOSSLESS',
- immersiveAudio: boolean = false,
+ _immersiveAudio: boolean = false,
signal?: AbortSignal
): Promise> {
- const url = `https://api.tidal.com/v1/tracks/${id}/playbackinfo`;
+ const url = `${HiFiClient.TIDAL_BASE_URL}/tracks/${id}/playbackinfo`;
const params = {
audioquality: quality,
playbackmode: 'STREAM',
assetpresentation: 'FULL',
countryCode: this.#countryCode,
- immersiveAudio: String(immersiveAudio),
};
const data = await this.#fetchJson(url, params, signal);
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data });
}
- /**
- * Fetches the MPEG-DASH (or alternative) track manifest from the TIDAL OpenAPI.
- *
- * @param id - TIDAL track ID.
- * @param options - Optional manifest request options (formats, adaptive, manifestType, etc.).
- * @param signal - Optional {@link AbortSignal} to cancel the request.
- * @returns A {@link TidalResponse} whose `.json()` resolves to a {@link TrackManifestResponse}.
- */
- async getTrackManifest(
- id: number,
- {
- formats = ['HEAACV1', 'AACLC', 'FLAC', 'FLAC_HIRES', 'EAC3_JOC'],
- adaptive = true,
- manifestType = 'MPEG_DASH',
- uriScheme = 'HTTPS',
- usage = 'PLAYBACK',
- }: HiFiClient.GetTrackManifestOptions = {},
- signal?: AbortSignal
- ): Promise> {
- const url = `https://openapi.tidal.com/v2/trackManifests/${id}`;
- const params = new URLSearchParams({
- adaptive: String(adaptive),
- manifestType,
- uriScheme,
- usage,
- });
-
- for (const format of formats) {
- params.append('formats', format);
- }
-
- const res = await this.#fetchJson(url, params, signal);
- const drmData = res.data.attributes.drmData;
-
- if (drmData && this.#baseUrl) {
- const url = `${this.#baseUrl.replace(/\/+$/g, '')}/widevine`;
- drmData.licenseUrl = url;
- drmData.certificateUrl = url;
- }
-
- return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data: res });
- }
-
- /**
- * Fetches a raw Widevine licence response from the TIDAL API.
- *
- * @returns The raw {@link Response} from the Widevine endpoint.
- */
- async getWidevine(): Promise {
- return await this.#fetchAuthenticated('https://api.tidal.com/v2/widevine');
- }
-
- /**
- * Fetches track recommendations for the given track ID.
- *
- * @param id - TIDAL track ID.
- * @param signal - Optional {@link AbortSignal} to cancel the request.
- * @returns A {@link TidalResponse} whose `.json()` resolves to a {@link RecommendationsResponse}.
- */
async getRecommendations(id: number, signal?: AbortSignal): Promise> {
- const url = `https://api.tidal.com/v1/tracks/${id}/recommendations`;
+ const url = `${HiFiClient.TIDAL_BASE_URL}/tracks/${id}/recommendations`;
const data = await this.#fetchJson<{ items: TidalTrack[]; totalNumberOfItems: number }>(
url,
{ limit: '20', countryCode: this.#countryCode },
@@ -1557,165 +670,40 @@ class HiFiClient {
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data });
}
- /**
- * Fetches artists similar to the given artist ID.
- *
- * @param id - TIDAL artist ID.
- * @param cursor - Optional pagination cursor from a previous response.
- * @param signal - Optional {@link AbortSignal} to cancel the request.
- * @returns A {@link TidalResponse} whose `.json()` resolves to a {@link SimilarArtistsResponse}.
- */
async getSimilarArtists(
id: number,
- cursor?: string | number | null,
+ _cursor?: string | number | null,
signal?: AbortSignal
): Promise> {
- const url = `https://openapi.tidal.com/v2/artists/${id}/relationships/similarArtists`;
- const params: Params = {
- 'page[cursor]': cursor ?? undefined,
- countryCode: this.#countryCode,
- include: 'similarArtists,similarArtists.profileArt',
- };
-
- const payload = await this.#fetchJson(url, params, signal);
- const included: JsonApiInclude[] = Array.isArray(payload?.included) ? payload.included : [];
- const artists_map: Record = {};
- const artworks_map: Record = {};
- for (const i of included) {
- if (i.type === 'artists') artists_map[i.id] = i;
- if (i.type === 'artworks') artworks_map[i.id] = i;
- }
-
- const resolveArtist = (entry: JsonApiRef): SimilarArtist => {
- const aid = entry.id;
- const inc = artists_map[aid] ?? ({} as JsonApiInclude);
- const attr = inc.attributes ?? ({} as JsonApiIncludeAttributes);
-
- let pic_id: string | null = null;
- const art_refs_artist = (() => {
- const d = inc.relationships?.profileArt?.data;
- return Array.isArray(d) ? d : d ? [d as JsonApiRef] : [];
- })();
- if (art_refs_artist.length > 0) {
- const artwork = artworks_map[art_refs_artist[0].id];
- const files = artwork?.attributes?.files;
- if (Array.isArray(files) && files[0]?.href) {
- pic_id = HiFiClient.#extractUuidFromTidalUrl(files[0].href);
- }
- }
-
- return {
- ...attr,
- id: Number(aid),
- name: attr.name ?? '',
- picture: pic_id ?? attr.selectedAlbumCoverFallback ?? null,
- url: `http://www.tidal.com/artist/${aid}`,
- relationType: 'SIMILAR_ARTIST',
- popularity: attr.popularity ?? 0,
- externalLinks: attr.externalLinks ?? [],
- spotlighted: attr.spotlighted ?? false,
- contributionsEnabled: attr.contributionsEnabled ?? false,
- };
- };
-
+ const url = `${HiFiClient.TIDAL_BASE_URL}/artists/${id}/similar`;
+ const data = await this.#fetchJson<{ items: SimilarArtist[] }>(
+ url,
+ { countryCode: this.#countryCode },
+ signal
+ );
return HiFiClient.#jsonResponse({
version: HiFiClient.API_VERSION,
- artists: (payload?.data ?? []).map(resolveArtist),
+ artists: data.items || [],
});
}
- /**
- * Fetches albums similar to the given album ID.
- *
- * @param id - TIDAL album ID.
- * @param cursor - Optional pagination cursor from a previous response.
- * @param signal - Optional {@link AbortSignal} to cancel the request.
- * @returns A {@link TidalResponse} whose `.json()` resolves to a {@link SimilarAlbumsResponse}.
- */
async getSimilarAlbums(
id: number,
- cursor?: string | number | null,
+ _cursor?: string | number | null,
signal?: AbortSignal
): Promise> {
- const url = `https://openapi.tidal.com/v2/albums/${id}/relationships/similarAlbums`;
- const params: Params = {
- 'page[cursor]': cursor ?? undefined,
- countryCode: this.#countryCode,
- include: 'similarAlbums,similarAlbums.coverArt,similarAlbums.artists',
- };
-
- const payload = await this.#fetchJson(url, params, signal);
- const included: JsonApiInclude[] = Array.isArray(payload?.included) ? payload.included : [];
- const albums_map: Record = {};
- const artworks_map: Record = {};
- const artists_map: Record = {};
- for (const i of included) {
- if (i.type === 'albums') albums_map[i.id] = i;
- if (i.type === 'artworks') artworks_map[i.id] = i;
- if (i.type === 'artists') artists_map[i.id] = i;
- }
-
- const resolveAlbum = (entry: JsonApiRef): TidalSimilarAlbum => {
- const aid = entry.id;
- const inc = albums_map[aid] ?? ({} as JsonApiInclude);
- const attr = inc.attributes ?? ({} as JsonApiIncludeAttributes);
-
- let cover_id: string | null = null;
- const art_data = inc.relationships?.coverArt?.data;
- if (Array.isArray(art_data) && art_data.length > 0) {
- const artwork = artworks_map[art_data[0].id];
- const files = artwork?.attributes?.files;
- if (Array.isArray(files) && files[0]?.href) {
- cover_id = HiFiClient.#extractUuidFromTidalUrl(files[0].href);
- }
- }
-
- const artist_list: Array<{ id: number; name: string }> = [];
- const artists_data = inc.relationships?.artists?.data;
- if (Array.isArray(artists_data)) {
- for (const a_entry of artists_data) {
- const a_obj = artists_map[a_entry.id];
- if (a_obj) {
- artist_list.push({
- id: Number(a_obj.id),
- name: a_obj.attributes?.name ?? '',
- });
- }
- }
- }
-
- return {
- ...attr,
- id: Number(aid),
- title: attr.title ?? '',
- cover: cover_id ?? '',
- artists: artist_list,
- url: `http://www.tidal.com/album/${aid}`,
- } as TidalSimilarAlbum;
- };
-
+ const url = `${HiFiClient.TIDAL_BASE_URL}/albums/${id}/similar`;
+ const data = await this.#fetchJson<{ items: TidalSimilarAlbum[] }>(
+ url,
+ { countryCode: this.#countryCode },
+ signal
+ );
return HiFiClient.#jsonResponse({
version: HiFiClient.API_VERSION,
- albums: (payload?.data ?? []).map(resolveAlbum),
+ albums: data.items || [],
});
}
- /**
- * Fetches artist data including profile information, discography, and/or top tracks.
- *
- * When `id` is supplied, returns the artist's profile and a cover-image entry
- * ({@link ArtistByIdResponse}).
- *
- * When `f` is supplied, returns the artist's full discography and, if `skip_tracks`
- * is `false`, the tracks from all albums ({@link ArtistDiscographyResponse}).
- *
- * @param id - TIDAL artist ID for profile lookup.
- * @param f - TIDAL artist ID for discography/tracks lookup.
- * @param skip_tracks - When `true`, fetches only top tracks instead of all album tracks.
- * @param signal - Optional {@link AbortSignal} to cancel the request.
- * @param options - Optional pagination options (`offset`, `limit`) for track fetching.
- * @returns A {@link TidalResponse} whose `.json()` resolves to an {@link ArtistResponse}.
- */
async getArtist(
id?: number | null,
f?: number | null,
@@ -1726,57 +714,16 @@ class HiFiClient {
if (!id && !f) throw new ResponseError(400, 'Provide id or f query param');
if (id) {
- const artist_url = `https://openapi.tidal.com/v2/artists/${id}`;
- const payload = await this.#fetchJson(
+ const artist_url = `${HiFiClient.TIDAL_BASE_URL}/artists/${id}`;
+ const artist_data = await this.#fetchJson(
artist_url,
- {
- countryCode: this.#countryCode,
- include: 'albums,albums.coverArt,tracks,tracks.albums,biography,profileArt',
- collapseBy: 'FINGERPRINT',
- },
+ { countryCode: this.#countryCode },
signal
);
- 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: 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 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) {
- const slug = picture.replace(/-/g, '/');
+ if (artist_data.picture) {
+ const slug = artist_data.picture.replace(/-/g, '/');
cover = {
id: artist_data.id,
name: artist_data.name,
@@ -1784,65 +731,36 @@ class HiFiClient {
};
}
- const albums: any[] = [];
- const tracks: any[] = [];
+ const albums_url = `${HiFiClient.TIDAL_BASE_URL}/artists/${id}/albums`;
+ const albums_data = await this.#fetchJson>(
+ albums_url,
+ { countryCode: this.#countryCode, limit: 50 },
+ signal
+ );
- 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: 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 },
- });
- }
- }
- }
-
- if (data?.relationships?.tracks?.data) {
- for (const ref of data.relationships.tracks.data) {
- const tr = includedMap.get(`tracks:${ref.id}`);
- if (tr) {
- 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: tr.attributes?.duration ? 100 : undefined,
- album: albumInfo,
- artist: { id: artist_data.id, name: artist_data.name },
- });
- }
- }
+ let top_tracks: TidalTrack[] = [];
+ if (skip_tracks) {
+ const toptracks_url = `${HiFiClient.TIDAL_BASE_URL}/artists/${id}/toptracks`;
+ const params: Params = { countryCode: this.#countryCode, limit: options?.limit || 15 };
+ if (options?.offset !== undefined) params.offset = options.offset;
+ const toptracks_data = await this.#fetchJson>(
+ toptracks_url,
+ params,
+ signal
+ );
+ top_tracks = toptracks_data.items || [];
}
return HiFiClient.#jsonResponse({
version: HiFiClient.API_VERSION,
artist: artist_data,
cover,
- albums: { items: albums },
- tracks,
+ albums: { items: albums_data.items || [] },
+ tracks: top_tracks,
});
}
- // fallback to original f logic
- const albums_url = `https://api.tidal.com/v1/artists/${f}/albums`;
+ const albums_url = `${HiFiClient.TIDAL_BASE_URL}/artists/${f}/albums`;
const common_params: Params = { countryCode: this.#countryCode, limit: 50 };
const tasks: Array | TidalListResponse>> = [
@@ -1863,7 +781,7 @@ class HiFiClient {
}
tasks.push(
this.#fetchJson>(
- `https://api.tidal.com/v1/artists/${f}/toptracks`,
+ `${HiFiClient.TIDAL_BASE_URL}/artists/${f}/toptracks`,
toptracks_params,
signal
)
@@ -1888,7 +806,6 @@ class HiFiClient {
}
}
- const album_ids: number[] = unique_releases.map((i) => i.id).filter(Boolean);
const page_data = { items: unique_releases };
if (skip_tracks) {
@@ -1900,68 +817,19 @@ class HiFiClient {
top_tracks = data?.items ?? [];
}
}
-
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, albums: page_data, tracks: top_tracks });
}
- if (!album_ids.length)
- return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, albums: page_data, tracks: [] });
-
- const fetchAlbumTracks = async (album_id: number): Promise => {
- return await this.#withAlbumTrackSlot(async () => {
- const album_data = await this.#fetchJson(
- 'https://api.tidal.com/v1/pages/album',
- { albumId: album_id, countryCode: this.#countryCode, deviceType: 'BROWSER' },
- signal
- );
- const rows = Array.isArray(album_data?.rows) ? album_data.rows : [];
- if (rows.length < 2) return [];
- const modules = rows[1].modules ?? [];
- if (!modules || modules.length === 0) return [];
- const paged_list = modules[0].pagedList ?? { items: [] };
- const items = paged_list.items ?? [];
- return items.map((t) => t.item ?? t).filter((t): t is TidalTrack => isTidalTrack(t));
- });
- };
-
- const trackResults = await Promise.all(
- album_ids.map((aid) => fetchAlbumTracks(aid).catch((): TidalTrack[] => []))
- );
- const tracks: TidalTrack[] = [];
- for (const t of trackResults) {
- if (Array.isArray(t)) tracks.push(...t);
- }
-
- return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, albums: page_data, tracks });
+ return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, albums: page_data, tracks: [] });
}
+
async getArtistBiography(artistId: number, signal?: AbortSignal): Promise> {
- const url = `https://openapi.tidal.com/v2/artists/${artistId}`;
- const payload = await this.#fetchJson<{ data?: JsonApiInclude; included?: JsonApiInclude[] }>(
+ const url = `${HiFiClient.TIDAL_BASE_URL}/artists/${artistId}/bio`;
+ const data = await this.#fetchJson(
url,
- { countryCode: this.#countryCode, include: 'biography' },
+ { countryCode: this.#countryCode },
signal
);
-
- const includedMap = new Map();
- for (const item of payload?.included ?? []) {
- includedMap.set(`${item.type}:${item.id}`, item);
- }
-
- const bioRelData = payload?.data?.relationships?.biography?.data;
- const bioRef = (Array.isArray(bioRelData) ? bioRelData[0] : bioRelData) as JsonApiRef | undefined;
- const bioItem = bioRef
- ? (includedMap.get(`${bioRef.type}:${bioRef.id}`) ??
- includedMap.get(`biographies:${bioRef.id}`) ??
- includedMap.get(`biography:${bioRef.id}`))
- : undefined;
-
- const data: ArtistBiography = {
- text: bioItem?.attributes?.text ?? '',
- source: bioItem?.attributes?.source ?? 'Tidal',
- lastUpdated: '',
- summary: '',
- };
-
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data });
}
@@ -1976,22 +844,12 @@ class HiFiClient {
};
}
- /**
- * Fetches cover-image URLs for a track (by `id`) or a search query (`q`).
- *
- * @param id - TIDAL track ID; if provided, returns the cover for that track's album.
- * @param q - Free-text search query; returns covers for matching tracks.
- * @param signal - Optional {@link AbortSignal} to cancel the request.
- * @returns A {@link TidalResponse} whose `.json()` resolves to a {@link CoverResponse}.
- * @throws {@link ResponseError} with status 400 if neither `id` nor `q` is provided.
- * @throws {@link ResponseError} with status 404 if no cover could be found.
- */
async getCover(id?: number | null, q?: string | null, signal?: AbortSignal): Promise> {
if (!id && !q) throw new ResponseError(400, 'Provide id or q query param');
if (id) {
const track_data = await this.#fetchJson(
- `https://api.tidal.com/v1/tracks/${id}/`,
+ `${HiFiClient.TIDAL_BASE_URL}/tracks/${id}`,
{ countryCode: this.#countryCode },
signal
);
@@ -2003,7 +861,7 @@ class HiFiClient {
}
const search_data = await this.#fetchJson<{ items: TidalTrack[] }>(
- 'https://api.tidal.com/v1/search/tracks',
+ `${HiFiClient.TIDAL_BASE_URL}/search/tracks`,
{ countryCode: this.#countryCode, query: q, limit: 10 },
signal
);
@@ -2020,157 +878,26 @@ class HiFiClient {
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, covers });
}
- /**
- * Performs a TIDAL search. Exactly one search option must be provided.
- *
- * | Option | Description |
- * |--------|-------------|
- * | `q` | General search across artists, albums, tracks, videos, and playlists. |
- * | `s` | Track-specific text search. |
- * | `a` | Top-hits search scoped to artists and tracks. |
- * | `al` | Top-hits search scoped to albums. |
- * | `v` | Top-hits search scoped to videos. |
- * | `p` | Top-hits search scoped to playlists. |
- * | `i` | ISRC-based track lookup (falls back to text search). |
- *
- * @param options - Search parameters; at least one of `q`, `s`, `a`, `al`, `v`, `p`, or `i` is required.
- * @param signal - Optional {@link AbortSignal} to cancel the request.
- * @returns A {@link TidalResponse} whose `.json()` resolves to a {@link SearchResponse}.
- */
async search(
options: {
- /** General search query (artists, albums, tracks, videos, playlists). */
q?: string;
- /** Track text search query. */
s?: string;
- /** Artist/track top-hits query. */
a?: string;
- /** Album top-hits query. */
al?: string;
- /** Video top-hits query. */
v?: string;
- /** Playlist top-hits query. */
p?: string;
- /** ISRC code for exact track lookup. */
i?: string;
- /** Result offset for pagination. */
offset?: number;
- /** Maximum number of results to return. */
limit?: number;
},
signal?: AbortSignal
): Promise> {
const { q, s, a, al, v, p, i, offset = 0, limit = 25 } = options;
- const parseOpenApiSearch = (jsonApi: any): SearchResponse['data'] => {
- if (!jsonApi || !jsonApi.data) return {};
-
- const includedMap = new Map();
- if (Array.isArray(jsonApi.included)) {
- for (const item of jsonApi.included) {
- includedMap.set(`${item.type}:${item.id}`, item);
- }
- }
-
- const resolveArtworkId = (item: any, relName: string) => {
- const ref = item?.relationships?.[relName]?.data?.[0];
- if (!ref) return null;
- const artwork = includedMap.get(`artworks:${ref.id}`);
- const href = artwork?.attributes?.files?.[0]?.href;
- return href ? HiFiClient.#extractUuidFromTidalUrl(href) : null;
- };
-
- const resolveArtists = (item: any) => {
- const refs = item?.relationships?.artists?.data;
- if (!Array.isArray(refs)) return [];
- return refs.map((art: any) => {
- const aItem = includedMap.get(`artists:${art.id}`);
- return {
- id: Number(art.id),
- name: aItem?.attributes?.name ?? '',
- };
- });
- };
-
- const resolveItem = (ref: { id: string; type: string }) => {
- const item = includedMap.get(`${ref.type}:${ref.id}`);
- if (!item) return null;
-
- const attrs = item.attributes || {};
- const mapped: any = {
- id: Number(item.id) || item.id,
- ...attrs,
- };
-
- if (item.type === 'artists') {
- mapped.type = 'artist';
- mapped.name = attrs.name ?? '';
- mapped.picture = resolveArtworkId(item, 'profileArt');
- } else if (item.type === 'albums') {
- const artists = resolveArtists(item);
- mapped.type = 'album';
- mapped.title = attrs.title ?? '';
- mapped.cover = resolveArtworkId(item, 'coverArt');
- mapped.artists = artists;
- if (artists.length > 0) mapped.artist = artists[0];
- } else if (item.type === 'tracks') {
- const artists = resolveArtists(item);
- mapped.type = 'track';
- mapped.title = attrs.title ?? '';
- mapped.artists = artists;
- if (artists.length > 0) mapped.artist = artists[0];
- const albumRef = item.relationships?.albums?.data?.[0];
- if (albumRef) {
- const albumItem = includedMap.get(`albums:${albumRef.id}`);
- mapped.album = {
- id: Number(albumRef.id),
- title: albumItem?.attributes?.title ?? '',
- cover: albumItem ? resolveArtworkId(albumItem, 'coverArt') : null,
- };
- }
- } else if (item.type === 'videos') {
- const artists = resolveArtists(item);
- mapped.type = 'video';
- mapped.title = attrs.title ?? '';
- mapped.artists = artists;
- if (artists.length > 0) mapped.artist = artists[0];
- mapped.imageId = resolveArtworkId(item, 'image');
- } else if (item.type === 'playlists') {
- mapped.type = 'playlist';
- mapped.title = attrs.name ?? '';
- mapped.image = resolveArtworkId(item, 'coverArt');
- }
-
- return mapped;
- };
-
- const relationships = jsonApi.data.relationships || {};
- const mapBucket = (relName: string) => {
- const relData = relationships[relName]?.data;
- if (!Array.isArray(relData)) return undefined;
- const items = relData.map(resolveItem).filter(Boolean);
- return {
- items,
- totalNumberOfItems: items.length,
- limit,
- offset,
- };
- };
-
- return {
- artists: mapBucket('artists'),
- albums: mapBucket('albums'),
- tracks: mapBucket('tracks'),
- videos: mapBucket('videos'),
- playlists: mapBucket('playlists'),
- };
- };
-
if (i) {
- // try filtered track search first
try {
const res = await this.#fetchJson(
- 'https://api.tidal.com/v1/tracks',
+ `${HiFiClient.TIDAL_BASE_URL}/tracks`,
{
'filter[isrc]': i,
limit,
@@ -2182,131 +909,89 @@ class HiFiClient {
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data: res });
} catch (err: unknown) {
if (err instanceof ResponseError && ![400, 404].includes(err.status)) throw err;
- // fallback to text search
}
- const fallback = await this.#fetchJson(
- `https://openapi.tidal.com/v2/searchResults/${encodeURIComponent(i)}`,
- {
- limit,
- offset,
- include: 'tracks,tracks.artists,tracks.albums,tracks.albums.coverArt',
- countryCode: this.#countryCode,
- },
- signal
- );
- return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data: parseOpenApiSearch(fallback) });
}
- const includeQ =
- 'albums,albums.coverArt,albums.artists,tracks,tracks.artists,tracks.albums,tracks.albums.coverArt,artists,playlists,videos';
- const includeS = 'tracks,tracks.artists,tracks.albums,tracks.albums.coverArt';
- const includeA = 'artists,artists.profileArt,tracks,tracks.artists,tracks.albums,tracks.albums.coverArt';
- const includeAl = 'albums,albums.artists,albums.coverArt';
- const includeV = 'videos,videos.artists,videos.image';
- const includeP = 'playlists,playlists.coverArt';
-
- const mapping: Array<[string | undefined, string, Params]> = [
- [
- q,
- `https://openapi.tidal.com/v2/searchResults/${encodeURIComponent(q || '')}`,
- {
- limit,
- offset,
- include: includeQ,
- countryCode: this.#countryCode,
- },
- ],
- [
- s,
- `https://openapi.tidal.com/v2/searchResults/${encodeURIComponent(s || '')}`,
- { limit, offset, include: includeS, countryCode: this.#countryCode },
- ],
- [
- a,
- `https://openapi.tidal.com/v2/searchResults/${encodeURIComponent(a || '')}`,
- { limit, offset, include: includeA, countryCode: this.#countryCode },
- ],
- [
- al,
- `https://openapi.tidal.com/v2/searchResults/${encodeURIComponent(al || '')}`,
- { limit, offset, include: includeAl, countryCode: this.#countryCode },
- ],
- [
- v,
- `https://openapi.tidal.com/v2/searchResults/${encodeURIComponent(v || '')}`,
- { limit, offset, include: includeV, countryCode: this.#countryCode },
- ],
- [
- p,
- `https://openapi.tidal.com/v2/searchResults/${encodeURIComponent(p || '')}`,
- { limit, offset, include: includeP, countryCode: this.#countryCode },
- ],
+ const searchMap: Array<[string | undefined, string]> = [
+ [q, `${HiFiClient.TIDAL_BASE_URL}/search`],
+ [s, `${HiFiClient.TIDAL_BASE_URL}/search/tracks`],
+ [a, `${HiFiClient.TIDAL_BASE_URL}/search/artists`],
+ [al, `${HiFiClient.TIDAL_BASE_URL}/search/albums`],
+ [v, `${HiFiClient.TIDAL_BASE_URL}/search/videos`],
+ [p, `${HiFiClient.TIDAL_BASE_URL}/search/playlists`],
];
- for (const [val, url, params] of mapping) {
+ for (const [val, url] of searchMap) {
if (val) {
+ const params: Params = {
+ query: val,
+ limit,
+ offset,
+ countryCode: this.#countryCode,
+ };
const data = await this.#fetchJson(url, params, signal);
- return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data: parseOpenApiSearch(data) });
+ const normalized: SearchResponse['data'] = {};
+
+ if (data.items) {
+ const bucketKey = url.includes('/tracks') ? 'tracks'
+ : url.includes('/artists') ? 'artists'
+ : url.includes('/albums') ? 'albums'
+ : url.includes('/videos') ? 'videos'
+ : url.includes('/playlists') ? 'playlists'
+ : null;
+
+ if (bucketKey) {
+ normalized[bucketKey] = {
+ items: data.items,
+ totalNumberOfItems: data.totalNumberOfItems || data.items.length,
+ limit: data.limit || limit,
+ offset: data.offset || offset,
+ };
+ } else {
+ normalized.topHit = { value: data.items[0], type: data.items[0]?.type || '' };
+ }
+ }
+
+ return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data: normalized });
}
}
- throw new Error('Provide one of s, a, al, v, p, or i');
+ throw new Error('Provide one of q, s, a, al, v, p, or i');
}
- /**
- * Fetches album metadata together with its full track listing.
- *
- * @param id - TIDAL album ID.
- * @param limit - Maximum number of tracks to fetch. Defaults to `100`.
- * @param offset - Track list offset for pagination. Defaults to `0`.
- * @param signal - Optional {@link AbortSignal} to cancel the request.
- * @returns A {@link TidalResponse} whose `.json()` resolves to an {@link AlbumResponse}.
- */
async getAlbum(id: number, limit = 100, offset = 0, signal?: AbortSignal): Promise> {
- const albumUrl = `https://api.tidal.com/v1/albums/${id}`;
- const itemsUrl = `https://api.tidal.com/v1/albums/${id}/items`;
- type ItemsPage = { items?: Array<{ item: TidalTrack; type: string }> };
- const albumTask = this.#fetchJson(albumUrl, { countryCode: this.#countryCode }, signal);
- const itemsTasks: Array> = [];
+ const albumUrl = `${HiFiClient.TIDAL_BASE_URL}/albums/${id}`;
+ const itemsUrl = `${HiFiClient.TIDAL_BASE_URL}/albums/${id}/items`;
+ const albumRaw = await this.#fetchJson(albumUrl, { countryCode: this.#countryCode }, signal);
+
+ const allItems: Array<{ item: TidalTrack; type: string }> = [];
let remaining = limit;
let currentOffset = offset;
const maxChunk = 100;
+
while (remaining > 0) {
const chunk = Math.min(remaining, maxChunk);
- itemsTasks.push(
- this.#fetchJson(
- itemsUrl,
- { countryCode: this.#countryCode, limit: chunk, offset: currentOffset },
- signal
- )
+ const page = await this.#fetchJson<{ items?: Array<{ item: TidalTrack; type: string }> }>(
+ itemsUrl,
+ { countryCode: this.#countryCode, limit: chunk, offset: currentOffset },
+ signal
);
+ const pageItems = page?.items ?? [];
+ if (Array.isArray(pageItems)) allItems.push(...pageItems);
currentOffset += chunk;
remaining -= chunk;
}
- const [albumRaw, ...pages] = await Promise.all([albumTask, ...itemsTasks]);
- const allItems: Array<{ item: TidalTrack; type: string }> = [];
- for (const p of pages) {
- const pageItems = p?.items ?? [];
- if (Array.isArray(pageItems)) allItems.push(...pageItems);
- }
const albumData: TidalAlbumWithTracks = { ...albumRaw, items: allItems };
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data: albumData });
}
- /**
- * Fetches the header and track list for a TIDAL mix.
- *
- * @param id - TIDAL mix ID string.
- * @param signal - Optional {@link AbortSignal} to cancel the request.
- * @returns A {@link TidalResponse} whose `.json()` resolves to a {@link MixResponse}.
- */
async getMix(id: string, signal?: AbortSignal): Promise> {
- const url = 'https://api.tidal.com/v1/pages/mix';
+ const url = `${HiFiClient.TIDAL_BASE_URL}/pages/mix`;
const data = await this.#fetchJson(
url,
- { mixId: id, countryCode: this.#countryCode, deviceType: 'BROWSER' },
+ { mixId: id, countryCode: this.#countryCode, deviceType: this.#deviceType },
signal
);
let header: unknown = {};
@@ -2326,23 +1011,14 @@ class HiFiClient {
});
}
- /**
- * Fetches playlist metadata together with its item list.
- *
- * @param id - TIDAL playlist UUID string.
- * @param limit - Maximum number of items to fetch. Defaults to `100`.
- * @param offset - Item list offset for pagination. Defaults to `0`.
- * @param signal - Optional {@link AbortSignal} to cancel the request.
- * @returns A {@link TidalResponse} whose `.json()` resolves to a {@link PlaylistResponse}.
- */
async getPlaylist(
id: string,
limit = 100,
offset = 0,
signal?: AbortSignal
): Promise> {
- const playlistUrl = `https://api.tidal.com/v1/playlists/${id}`;
- const itemsUrl = `https://api.tidal.com/v1/playlists/${id}/items`;
+ const playlistUrl = `${HiFiClient.TIDAL_BASE_URL}/playlists/${id}`;
+ const itemsUrl = `${HiFiClient.TIDAL_BASE_URL}/playlists/${id}/items`;
const [playlistData, itemsData] = await Promise.all([
this.#fetchJson(playlistUrl, { countryCode: this.#countryCode }, signal),
this.#fetchJson<{ items: PlaylistItem[] }>(
@@ -2355,20 +1031,11 @@ class HiFiClient {
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, playlist: playlistData, items });
}
- // simplified artist/cover/lyrics/video/topvideos/similar methods (same pattern)
- /**
- * Fetches the lyrics for the given track ID.
- *
- * @param id - TIDAL track ID.
- * @param signal - Optional {@link AbortSignal} to cancel the request.
- * @returns A {@link TidalResponse} whose `.json()` resolves to a {@link LyricsResponse}.
- * @throws An error with `status = 404` if lyrics are unavailable for the track.
- */
async getLyrics(id: number, signal?: AbortSignal): Promise> {
- const url = `https://api.tidal.com/v1/tracks/${id}/lyrics`;
+ const url = `${HiFiClient.TIDAL_BASE_URL}/tracks/${id}/lyrics`;
const data = await this.#fetchJson(
url,
- { countryCode: this.#countryCode, locale: 'en_US', deviceType: 'BROWSER' },
+ { countryCode: this.#countryCode, locale: this.#locale, deviceType: this.#deviceType },
signal
);
if (!data) {
@@ -2378,16 +1045,6 @@ class HiFiClient {
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, lyrics: data });
}
- /**
- * Fetches video playback info for the given video ID.
- *
- * @param id - TIDAL video ID.
- * @param quality - Video quality string, e.g. `"HIGH"` (default).
- * @param mode - Playback mode, e.g. `"STREAM"` (default).
- * @param presentation - Asset presentation, e.g. `"FULL"` (default).
- * @param signal - Optional {@link AbortSignal} to cancel the request.
- * @returns A {@link TidalResponse} whose `.json()` resolves to a {@link VideoResponse}.
- */
async getVideo(
id: number,
quality = 'HIGH',
@@ -2395,7 +1052,7 @@ class HiFiClient {
presentation = 'FULL',
signal?: AbortSignal
): Promise> {
- const url = `https://api.tidal.com/v1/videos/${id}/playbackinfo`;
+ const url = `${HiFiClient.TIDAL_BASE_URL}/videos/${id}/playbackinfo`;
const data = await this.#fetchJson(
url,
{ videoquality: quality, playbackmode: mode, assetpresentation: presentation },
@@ -2404,18 +1061,11 @@ class HiFiClient {
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, video: data });
}
- /**
- * Fetches a paginated list of recommended videos from TIDAL.
- *
- * @param options - Optional locale, device type, and pagination parameters.
- * @param signal - Optional {@link AbortSignal} to cancel the request.
- * @returns A {@link TidalResponse} whose `.json()` resolves to a {@link TopVideosResponse}.
- */
async getTopVideos(
{ countryCode = 'US', locale = 'en_US', deviceType = 'BROWSER', limit = 25, offset = 0 } = {},
signal?: AbortSignal
): Promise> {
- const url = 'https://api.tidal.com/v1/pages/mymusic_recommended_videos';
+ const url = `${HiFiClient.TIDAL_BASE_URL}/pages/mymusic_recommended_videos`;
const data = await this.#fetchJson(url, { countryCode, locale, deviceType }, signal);
const rows = data.rows ?? [];
const videos: TidalVideoItem[] = [];
@@ -2429,9 +1079,7 @@ class HiFiClient {
videos.push(v as TidalVideoItem);
}
} else if (mt === 'VIDEO' || (mt && mt.toLowerCase().includes('video'))) {
- const it = module.item;
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
- videos.push(it as TidalVideoItem);
+ videos.push(module.item as TidalVideoItem);
}
}
}
@@ -2442,28 +1090,12 @@ class HiFiClient {
});
}
- /**
- * Dispatches a local route string (e.g. `"/info/?id=123"`) to the appropriate
- * {@link HiFiClient} method and returns a {@link TidalResponse}.
- *
- * This is a convenience method that mirrors an HTTP-router interface so external
- * code can call the API with path-style strings. Because the route is resolved
- * at runtime the response is untyped (`TidalResponse`); prefer calling
- * the individual typed methods directly when the route is known at compile time.
- *
- * @param pathOrUrl - A local route path such as `"/track/?id=123"`, or a full URL.
- * @param signal - Optional {@link AbortSignal} to cancel the request.
- * @returns A {@link TidalResponse} wrapping the route handler's response.
- * @throws An error if the pathname does not match any known route.
- */
async query(pathOrUrl: string, signal?: AbortSignal): Promise {
- // normalize: if starts with http use as-is, else treat as local route
try {
const u = new URL(pathOrUrl, 'http://localhost');
const pathname = u.pathname.replace(/\/+$/, '') || '/';
const qp: Record = {};
u.searchParams.forEach((v, k) => (qp[k] = v));
- const formats = u.searchParams.getAll('formats');
switch (pathname) {
case '/':
@@ -2559,61 +1191,26 @@ class HiFiClient {
offset: qp.offset ? Number(qp.offset) : undefined,
})
);
- case '/trackManifests':
- return new TidalResponse(
- await this.getTrackManifest(Number(qp.id), {
- ...qp,
- formats: formats.length > 0 ? formats : undefined,
- adaptive: Boolean(qp.adaptive?.toLowerCase()) || undefined,
- })
- );
- case '/widevine':
- return new TidalResponse(await this.getWidevine());
default:
throw new Error(`Unknown route: ${pathname}`);
}
} catch (err) {
const message = (err as { message?: string }).message ?? String(err);
console.error(message, err);
-
throw err;
}
}
}
namespace HiFiClient {
- export interface RefreshTokenOptions {
- refreshToken?: string;
- }
-
- export interface TokenOptions {
- token?: string;
- tokenExpiry?: number;
- }
-
- export interface ClientOptions {
- clientId?: string;
- clientSecret?: string;
- }
-
- export interface LocaleOptions {
+ export interface ConstructorOptions {
+ publicToken?: string;
locale?: string;
countryCode?: string;
- }
-
- export interface ConstructorOptions
- extends LocaleOptions, RefreshTokenOptions, ClientOptions, TokenOptions, RefreshTokenOptions {
+ deviceType?: string;
baseUrl?: string;
storage?: Pick[] | Pick;
}
-
- export interface GetTrackManifestOptions {
- formats?: string[];
- adaptive?: boolean;
- manifestType?: string;
- uriScheme?: 'HTTPS' | 'HTTP';
- usage?: string;
- }
}
export { HiFiClient };
diff --git a/js/api.js b/js/api.js
index 9524fdc..932628b 100644
--- a/js/api.js
+++ b/js/api.js
@@ -8,7 +8,7 @@ import {
getTrackDiscNumber,
normalizeQualityToken,
} from './utils.js';
-import { preferDolbyAtmosSettings, trackDateSettings, devModeSettings } from './storage.js';
+import { preferDolbyAtmosSettings, trackDateSettings, devModeSettings, tidalWebSettings } from './storage.js';
import { APICache } from './cache.js';
import { DashDownloader } from './dash-downloader.ts';
import { HlsDownloader } from './hls-downloader.js';
@@ -190,32 +190,17 @@ export class LosslessAPI {
return response;
}
- const shouldTryNative = type !== 'streaming';
-
- if (shouldTryNative) {
+ // Use HiFiClient directly for metadata requests (no worker fallback needed)
+ if (type !== 'streaming' && !options.userInstancesOnly) {
try {
if (import.meta.env.DEV) {
- console.log(relativePath);
+ console.log('[hifi]', relativePath);
}
-
- // HiFiClient.query fans out across the native TIDAL endpoints used by the route
- // implementation, including api.tidal.com and openapi.tidal.com where applicable.
- return await HiFiClient.instance.query(relativePath);
+ return await HiFiClient.instance.query(relativePath, options.signal);
} catch (err) {
- if (options.directOnly) {
- throw err;
- }
-
- if (import.meta.env.DEV && isSearchRequest) {
- console.warn(
- `[search] native TIDAL query failed for ${relativePath}, trying HiFi worker instances`,
- err
- );
- } else {
- console.warn(
- `Native TIDAL query failed for ${relativePath}. Falling back to configured HiFi API instances...`,
- err
- );
+ if (options.directOnly) throw err;
+ if (import.meta.env.DEV) {
+ console.warn(`[hifi] HiFiClient query failed for ${relativePath}, falling back to workers`, err);
}
}
}
@@ -1504,6 +1489,102 @@ export class LosslessAPI {
return null;
}
+ async #fetchProxy(id, quality, signal) {
+ let proxyUrl = tidalWebSettings.getProxyUrl();
+ if (!proxyUrl) {
+ throw new Error('Proxy URL not configured. Set tidalWebSettings.getProxyUrl() in settings.');
+ }
+
+ let baseUrl;
+ if (import.meta.env.DEV && proxyUrl.startsWith('http://localhost')) {
+ baseUrl = '/tidal-proxy';
+ } else {
+ baseUrl = proxyUrl.replace(/\/+$/g, '');
+ }
+
+ // api.zarz.moe only accepts lossless quality formats; upgrade HIGH/LOW to LOSSLESS
+ const proxyQuality = (quality === 'HIGH' || quality === 'LOW') ? 'LOSSLESS' : quality;
+
+ const response = await fetch(`${baseUrl}/v1/dl/tid2`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'User-Agent': 'SpotiFLAC-Mobile/4.5.5',
+ },
+ body: JSON.stringify({ id: String(id), quality: proxyQuality }),
+ signal,
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text().catch(() => '');
+ throw new Error(`Proxy request failed: ${response.status} ${response.statusText}${errorText ? ` - ${errorText}` : ''}`);
+ }
+
+ const data = await response.json();
+
+ // Validate response quality - reject if proxy downgraded to lossy
+ const inner = data.data ?? data;
+ const responseQuality = inner.audioQuality?.toUpperCase();
+ if (responseQuality === 'LOW' || responseQuality === 'HIGH') {
+ throw new Error(`Proxy returned ${responseQuality} quality; lossless not available for this track`);
+ }
+
+ return data;
+ }
+
+ #extractStreamUrlFromProxyResponse(data) {
+ if (!data) return null;
+
+ // Handle [{ OriginalTrackUrl: "..." }] format
+ if (Array.isArray(data)) {
+ for (const entry of data) {
+ if (entry?.OriginalTrackUrl) return entry.OriginalTrackUrl;
+ if (entry?.originalTrackUrl) return entry.originalTrackUrl;
+ }
+ return null;
+ }
+
+ // Handle { data: { manifest, manifestMimeType, ... } } format
+ const inner = data.data ?? data;
+
+ // Direct stream URL
+ if (inner.url) return inner.url;
+ if (inner.streamUrl) return inner.streamUrl;
+ if (inner.OriginalTrackUrl) return inner.OriginalTrackUrl;
+
+ // Manifest-based
+ if (inner.manifest) {
+ return this.extractStreamUrlFromManifest(inner.manifest);
+ }
+
+ return null;
+ }
+
+ #buildPlaybackInfoFromProxy(data, quality) {
+ const inner = data.data ?? data;
+ const normalizedQuality = inner.audioQuality || normalizeQualityToken(quality) || 'HIGH';
+
+ const info = {
+ trackId: inner.trackId ? Number(inner.trackId) : null,
+ assetPresentation: inner.trackPresentation || inner.assetPresentation || 'FULL',
+ audioQuality: normalizedQuality,
+ manifestMimeType: inner.manifestMimeType || 'application/dash+xml',
+ manifestHash: inner.manifestHash || '',
+ manifest: inner.manifest || '',
+ bitDepth: inner.bitDepth || (normalizedQuality === 'HI_RES_LOSSLESS' ? 24 : normalizedQuality === 'LOSSLESS' ? 16 : undefined),
+ sampleRate: inner.sampleRate || (normalizedQuality === 'HI_RES_LOSSLESS' ? 96000 : normalizedQuality === 'LOSSLESS' ? 44100 : undefined),
+ replayGain: inner.replayGain || inner.trackReplayGain,
+ trackReplayGain: inner.trackReplayGain,
+ trackPeakAmplitude: inner.trackPeakAmplitude,
+ albumReplayGain: inner.albumReplayGain,
+ albumPeakAmplitude: inner.albumPeakAmplitude,
+ drmData: inner.drmData || null,
+ formats: inner.formats || [],
+ };
+
+ return info;
+ }
+
async normalizeTrackManifestResponse(apiResponse, quality) {
if (!apiResponse || typeof apiResponse !== 'object') {
return apiResponse;
@@ -1613,23 +1694,41 @@ export class LosslessAPI {
if (cached) return cached;
const requestedQuality = normalizeQualityToken(quality) || quality || 'LOSSLESS';
- const params = new URLSearchParams({
- id: String(id),
- quality: requestedQuality,
- adaptive: String(adaptive),
- });
- const formats = adaptive ? this.getAdaptiveTrackManifestFormats() : this.getTrackManifestFormats(quality);
- for (const format of formats) {
- params.append('formats', format);
+
+ // Route through self-hosted proxy
+ const proxyData = await this.#fetchProxy(id, requestedQuality);
+
+ // Handle [{ OriginalTrackUrl: "..." }] format (direct stream)
+ if (Array.isArray(proxyData)) {
+ const entry = proxyData.find(e => e.OriginalTrackUrl || e.originalTrackUrl);
+ if (entry) {
+ const result = {
+ track: { id: Number(id), duration: 0 },
+ info: {
+ trackId: Number(id),
+ assetPresentation: 'FULL',
+ audioQuality: requestedQuality,
+ manifestMimeType: 'audio/flac',
+ manifestHash: '',
+ manifest: '',
+ },
+ originalTrackUrl: entry.OriginalTrackUrl || entry.originalTrackUrl,
+ };
+ await this.cache.set('track', cacheKey, result);
+ return result;
+ }
}
- const response = await this.fetchWithRetry(`/trackManifests/?${params.toString()}`, { type: 'streaming' });
- const jsonResponse = await response.json();
- const result = this.parseTrackLookup(await this.normalizeTrackManifestResponse(jsonResponse, quality));
+ // Handle { data: { manifest, manifestMimeType, ... } } format
+ const playbackInfo = this.#buildPlaybackInfoFromProxy(proxyData, requestedQuality);
- if (!(response instanceof TidalResponse)) {
- await this.cache.set('track', cacheKey, result);
- }
+ const result = {
+ track: { id: Number(id), duration: 0 },
+ info: playbackInfo,
+ originalTrackUrl: null,
+ };
+
+ await this.cache.set('track', cacheKey, result);
return result;
}
@@ -1640,30 +1739,34 @@ export class LosslessAPI {
return this.streamCache.get(cacheKey);
}
+ const requestedQuality = normalizeQualityToken(quality) || quality || 'LOSSLESS';
+
+ // Route through self-hosted proxy
+ const proxyData = await this.#fetchProxy(id, requestedQuality);
+
let streamUrl;
let manifestRgInfo = null;
- const lookup = await this.getTrack(id, quality, { adaptive: this.shouldUseAdaptiveTrackManifest(download) });
-
- if (lookup.originalTrackUrl) {
- streamUrl = lookup.originalTrackUrl;
+ // Handle direct stream URL format
+ if (Array.isArray(proxyData)) {
+ const entry = proxyData.find(e => e.OriginalTrackUrl || e.originalTrackUrl);
+ if (entry) {
+ streamUrl = entry.OriginalTrackUrl || entry.originalTrackUrl;
+ }
} else {
- const manifest = lookup.info?.manifest;
- if (manifest) {
- streamUrl = this.extractStreamUrlFromManifest(manifest);
- }
- if (!streamUrl) {
- throw new Error('Could not resolve stream URL');
- }
+ // Handle manifest-based response
+ streamUrl = this.#extractStreamUrlFromProxyResponse(proxyData);
+ manifestRgInfo = this.#buildPlaybackInfoFromProxy(proxyData, quality);
+ manifestRgInfo = {
+ trackReplayGain: manifestRgInfo.trackReplayGain || manifestRgInfo.replayGain,
+ trackPeakAmplitude: manifestRgInfo.trackPeakAmplitude || manifestRgInfo.peakAmplitude,
+ albumReplayGain: manifestRgInfo.albumReplayGain,
+ albumPeakAmplitude: manifestRgInfo.albumPeakAmplitude,
+ };
}
- if (lookup.info) {
- manifestRgInfo = {
- trackReplayGain: lookup.info.trackReplayGain || lookup.info.replayGain,
- trackPeakAmplitude: lookup.info.trackPeakAmplitude || lookup.info.peakAmplitude,
- albumReplayGain: lookup.info.albumReplayGain,
- albumPeakAmplitude: lookup.info.albumPeakAmplitude,
- };
+ if (!streamUrl) {
+ throw new Error('Could not resolve stream URL from proxy');
}
const result = { url: streamUrl, rgInfo: manifestRgInfo };
diff --git a/js/app.js b/js/app.js
index 05b71a3..9fa81f8 100644
--- a/js/app.js
+++ b/js/app.js
@@ -16,6 +16,7 @@ import {
pwaUpdateSettings,
modalSettings,
keyboardShortcuts,
+ tidalWebSettings,
} from './storage.js';
import { UIRenderer } from './ui.js';
import { Player } from './player.js';
@@ -455,6 +456,9 @@ document.addEventListener('DOMContentLoaded', async () => {
new ThemeStore();
await HiFiClient.initialize({
+ publicToken: tidalWebSettings.getPublicToken(),
+ countryCode: tidalWebSettings.getCountryCode(),
+ baseUrl: tidalWebSettings.getProxyUrl(),
storage: [
localStorage,
...(import.meta.env.DEV
@@ -466,8 +470,6 @@ document.addEventListener('DOMContentLoaded', async () => {
]
: []),
],
- token: localStorage.getItem('hifi_token') || undefined,
- tokenExpiry: parseInt(localStorage.getItem('hifi_token_expiry') || '0'),
});
await MusicAPI.initialize(apiSettings);
diff --git a/js/proxy-utils.js b/js/proxy-utils.js
index 4361d61..1e00385 100644
--- a/js/proxy-utils.js
+++ b/js/proxy-utils.js
@@ -1,3 +1,7 @@
+// CORS proxy utilities for streaming audio/video content.
+// The self-hosted proxy provides stream URLs, but actual segment downloads
+// may still require CORS proxying for blob://, DASH, and HLS streams.
+
const PROXIES = [
{ url: 'http://your-nas-ip:8081/', param: 'url=' }, // Local proxy - change to your NAS IP
{ url: 'https://audio-proxy.binimum.org/proxy-audio', param: 'url=' },
diff --git a/js/settings.js b/js/settings.js
index d6a8ab7..fb083f1 100644
--- a/js/settings.js
+++ b/js/settings.js
@@ -32,6 +32,7 @@ import {
pwaUpdateSettings,
contentBlockingSettings,
musicProviderSettings,
+ tidalWebSettings,
gaplessPlaybackSettings,
analyticsSettings,
modalSettings,
@@ -838,6 +839,43 @@ export async function initializeSettings(scrobbler, player, api, ui) {
});
}
+ // Tidal Web Proxy settings
+ const proxyUrlInput = document.getElementById('tidal-web-proxy-url');
+ if (proxyUrlInput) {
+ proxyUrlInput.value = tidalWebSettings.getProxyUrl();
+ let proxyUrlTimeout;
+ proxyUrlInput.addEventListener('input', (e) => {
+ clearTimeout(proxyUrlTimeout);
+ proxyUrlTimeout = setTimeout(() => {
+ tidalWebSettings.setProxyUrl(e.target.value.trim());
+ }, 500);
+ });
+ }
+
+ const publicTokenInput = document.getElementById('tidal-web-public-token');
+ if (publicTokenInput) {
+ publicTokenInput.value = tidalWebSettings.getPublicToken();
+ let tokenTimeout;
+ publicTokenInput.addEventListener('input', (e) => {
+ clearTimeout(tokenTimeout);
+ tokenTimeout = setTimeout(() => {
+ tidalWebSettings.setPublicToken(e.target.value.trim());
+ }, 500);
+ });
+ }
+
+ const countryCodeInput = document.getElementById('tidal-web-country-code');
+ if (countryCodeInput) {
+ countryCodeInput.value = tidalWebSettings.getCountryCode();
+ let countryTimeout;
+ countryCodeInput.addEventListener('input', (e) => {
+ clearTimeout(countryTimeout);
+ countryTimeout = setTimeout(() => {
+ tidalWebSettings.setCountryCode(e.target.value.trim().toUpperCase());
+ }, 500);
+ });
+ }
+
// Streaming Quality setting
const streamingQualitySetting = document.getElementById('streaming-quality-setting');
if (streamingQualitySetting) {
diff --git a/js/storage.js b/js/storage.js
index 3c216fe..6e72c20 100644
--- a/js/storage.js
+++ b/js/storage.js
@@ -7,6 +7,7 @@ export const apiSettings = {
INSTANCES_URLS: ['https://tidal-uptime.geeked.wtf'],
FALLBACK_INSTANCES: {
api: [
+ { url: 'https://api.zarz.moe', version: '2.10' },
{ url: 'https://eu-central.monochrome.tf', version: '2.10' },
{ url: 'https://us-west.monochrome.tf', version: '2.10' },
{ url: 'https://api.monochrome.tf', version: '2.5' },
@@ -22,6 +23,7 @@ export const apiSettings = {
{ url: 'https://hifi-two.spotisaver.net', version: '2.5' },
],
streaming: [
+ { url: 'https://api.zarz.moe', version: '2.10' },
{ url: 'https://eu-central.monochrome.tf', version: '2.10' },
{ url: 'https://us-west.monochrome.tf', version: '2.10' },
{ url: 'https://arran.monochrome.tf', version: '2.6' },
@@ -3034,6 +3036,45 @@ export const pwaUpdateSettings = {
},
};
+export const tidalWebSettings = {
+ PROXY_URL_KEY: 'tidal-web-proxy-url',
+ PUBLIC_TOKEN_KEY: 'tidal-web-public-token',
+ COUNTRY_CODE_KEY: 'tidal-web-country-code',
+ DEFAULT_PUBLIC_TOKEN: '49YxDN9a2aFV6RTG',
+ DEFAULT_COUNTRY_CODE: 'US',
+
+ getProxyUrl() {
+ try {
+ return localStorage.getItem(this.PROXY_URL_KEY) || 'https://api.zarz.moe';
+ } catch {
+ return '';
+ }
+ },
+ setProxyUrl(url) {
+ localStorage.setItem(this.PROXY_URL_KEY, url);
+ },
+ getPublicToken() {
+ try {
+ return localStorage.getItem(this.PUBLIC_TOKEN_KEY) || this.DEFAULT_PUBLIC_TOKEN;
+ } catch {
+ return this.DEFAULT_PUBLIC_TOKEN;
+ }
+ },
+ setPublicToken(token) {
+ localStorage.setItem(this.PUBLIC_TOKEN_KEY, token);
+ },
+ getCountryCode() {
+ try {
+ return localStorage.getItem(this.COUNTRY_CODE_KEY) || this.DEFAULT_COUNTRY_CODE;
+ } catch {
+ return this.DEFAULT_COUNTRY_CODE;
+ }
+ },
+ setCountryCode(code) {
+ localStorage.setItem(this.COUNTRY_CODE_KEY, code);
+ },
+};
+
export const musicProviderSettings = {
STORAGE_KEY: 'music-provider',
diff --git a/vite.config.ts b/vite.config.ts
index 1b68076..f87d79d 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -68,6 +68,13 @@ export default defineConfig((_options) => {
// host: true,
// allowedHosts: [''], // e.g. pi5.tailf5f622.ts.net
},
+ proxy: {
+ '/tidal-proxy': {
+ target: 'http://localhost:8080',
+ changeOrigin: true,
+ rewrite: (path) => path.replace(/^\/tidal-proxy/, ''),
+ },
+ },
},
// preview: {
// host: true,