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,