From 4d561bc2fdfa3801de86bc6f5b89982f7617bc03 Mon Sep 17 00:00:00 2001 From: EUCLID Date: Thu, 9 Apr 2026 16:24:25 -0400 Subject: [PATCH] improve quality selector for all --- src/streamers/deezer/main.ts | 4 +- src/streamers/qobuz/main.ts | 9 +-- src/streamers/tidal/constants.ts | 11 +--- src/streamers/tidal/main.ts | 107 +++++++++++++++++++++++-------- src/streamers/tidal/parse.ts | 11 +++- src/types.ts | 2 +- 6 files changed, 103 insertions(+), 41 deletions(-) diff --git a/src/streamers/deezer/main.ts b/src/streamers/deezer/main.ts index 8fb9d6b..a09a6b4 100644 --- a/src/streamers/deezer/main.ts +++ b/src/streamers/deezer/main.ts @@ -580,7 +580,7 @@ export default class Deezer implements StreamerWithLogin { if (readableFormats[i].size != '0') { formats.push({ id: readableFormats[i].format, - rippable: true, + rippable: this.availableFormats.has(readableFormats[i].format), size: parseInt(readableFormats[i].size), ...formatHelper(readableFormats[i].format) } as Format) @@ -598,6 +598,6 @@ function formatHelper(format: DeezerFormat) { case DeezerFormat.MP3_320: return { extension: 'mp3', mimeType: 'audio/mp3', bitrate: 320 } case DeezerFormat.FLAC: - return { extension: 'flac', mimeType: 'audio/flac' } + return { extension: 'flac', mimeType: 'audio/flac', bitDepth: 16 } } } diff --git a/src/streamers/qobuz/main.ts b/src/streamers/qobuz/main.ts index 0938b44..aa17401 100644 --- a/src/streamers/qobuz/main.ts +++ b/src/streamers/qobuz/main.ts @@ -175,7 +175,7 @@ export default class Qobuz implements StreamerWithLogin { } } - async #getFileUrl(trackId: string, formatId: number = 27): Promise { + async #getFileUrl(trackId: string, formatId: string | number): Promise { if (!this.token) throw new Error('Not logged in.') const params: { [key: string]: string } = { @@ -298,8 +298,9 @@ export default class Qobuz implements StreamerWithLogin { case 'track': { return { type, - getStream: () => { - return this.#getFileUrl(id) + getStream: (formatId?: string | number) => { + if (!formatId) formatId = 27 + return this.#getFileUrl(id, formatId) }, metadata: await this.#getTrackMetadata(id) } @@ -384,7 +385,7 @@ export default class Qobuz implements StreamerWithLogin { // slice by maximum rate provided by qobuz let maxIndex: number | undefined if (rawTrack.maximum_bit_depth == 16) maxIndex = 2 - else if (rawTrack.maximum_bit_depth == 24) maxIndex = 3 + else if (rawTrack.maximum_bit_depth == 24 && rawTrack.maximum_sampling_rate == 96) maxIndex = 3 else if (rawTrack.maximum_sampling_rate == 192) maxIndex = 4 else maxIndex = 1 diff --git a/src/streamers/tidal/constants.ts b/src/streamers/tidal/constants.ts index f4cc9d2..c092922 100644 --- a/src/streamers/tidal/constants.ts +++ b/src/streamers/tidal/constants.ts @@ -1,13 +1,8 @@ import { TidalFormat } from './parse.js' export const TIDAL_AUTH_BASE = 'https://auth.tidal.com/v1/' -export const TIDAL_API_BASE = 'https://listen.tidal.com/v1/' +export const TIDAL_LISTEN_BASE = 'https://listen.tidal.com/v1/' export const TIDAL_SUBSCRIPTION_BASE = 'https://api.tidal.com/v1/' -export const TIDAL_FORMATS = [ - 'LOW', - 'HIGH', - 'LOSSLESS', - 'HI_RES', - 'HI_RES_LOSSLESS' -] as TidalFormat[] +export const TIDAL_NORMAL_FORMATS = ['LOW', 'HIGH', 'LOSSLESS', 'HI_RES_LOSSLESS'] as TidalFormat[] +export const TIDAL_SPATIAL_FORMATS = ['DOLBY_ATMOS', 'SONY_360RA'] diff --git a/src/streamers/tidal/main.ts b/src/streamers/tidal/main.ts index af3ecfb..928500d 100644 --- a/src/streamers/tidal/main.ts +++ b/src/streamers/tidal/main.ts @@ -1,7 +1,7 @@ -// todo: add atmos support for format selection -// might have to do it how orpheusdl does it? +// todo: possible refactor // https://github.com/Dniel97/orpheusdl-tidal/blob/master/interface.py#L41 +// https://github.com/Radexito/tidal-dl-ng-For-DJ/blob/87ec210dfeeef23441b7c99a16123a25ec63f207/tidal_dl_ng/config.py#L177 import { Dispatcher, fetch } from 'undici' import { spawn } from 'child_process' @@ -19,9 +19,9 @@ import { } from '../../types.js' import { TIDAL_AUTH_BASE, - TIDAL_API_BASE, + TIDAL_LISTEN_BASE, TIDAL_SUBSCRIPTION_BASE, - TIDAL_FORMATS + TIDAL_NORMAL_FORMATS } from './constants.js' import { Contributor, @@ -45,11 +45,20 @@ interface TidalOptions { accessToken: string refreshToken: string expires: number - countryCode: string + countryCode?: string + atmos?: Client sessionId?: string dispatcher?: Dispatcher } +interface Client { + tvToken: string + tvSecret: string + accessToken: string + refreshToken: string + expires: number +} + interface LoginData { error: string access_token: string @@ -73,7 +82,7 @@ interface SubscriptionData { type: string offlineGracePeriod: number } - highestSoundQuality: TidalFormat + highestSoundQuality: 'HI_RES' | 'LOSSLESS' premiumAccess: boolean canGetTrial: boolean paymentType: string @@ -99,16 +108,17 @@ interface RawIsrcLookup { } export default class Tidal implements Streamer { + mode: 'atmos' | 'normal' = 'normal' tvToken: string tvSecret: string accessToken: string refreshToken: string expires: number - countryCode: string + countryCode: string | undefined userId: number | undefined dispatcher: Dispatcher | undefined sessionId: string | undefined - highestQuality: TidalFormat | undefined + highestQuality: 'HI_RES' | 'LOSSLESS' | undefined hostnames = ['tidal.com', 'www.tidal.com', 'listen.tidal.com'] testData = { 'https://tidal.com/browse/artist/3908662': { @@ -129,12 +139,15 @@ export default class Tidal implements Streamer { this.tvToken = options.tvToken this.tvSecret = options.tvSecret + // this.atmosToken = options.atmosToken + // this.atmosSecret = options.atmosSecret + // TODO: ideally this should be stored in a .config file this.accessToken = options.accessToken this.refreshToken = options.refreshToken this.expires = options.expires - this.countryCode = options.countryCode - this.sessionId = options.sessionId + if (options.countryCode) this.countryCode = options.countryCode + if (options.sessionId) this.sessionId = options.sessionId if (options.dispatcher) this.dispatcher = options.dispatcher } headers() { @@ -153,7 +166,7 @@ export default class Tidal implements Streamer { async #get( url: string, params: { [key: string]: string | number } = {}, - base: string = TIDAL_API_BASE + base: string = TIDAL_LISTEN_BASE ): Promise { if (this.failedAuth) throw new Error(`Last request failed to authorize, get new tokens`) if (Date.now() > this.expires) await this.refresh() @@ -269,13 +282,17 @@ export default class Tidal implements Streamer { countryCode: this.countryCode } } + async refresh() { + // if (!clientId) clientId = this.tvToken + // if (!clientSecret) clientSecret = this.tvSecret + const refreshResponse = await fetch(`${TIDAL_AUTH_BASE}oauth2/token`, { method: 'post', body: new URLSearchParams({ refresh_token: this.refreshToken, client_id: this.tvToken, - client_secret: this.tvSecret, + client_secret: this.tvToken, grant_type: 'refresh_token' }), dispatcher: this.dispatcher @@ -378,6 +395,14 @@ export default class Tidal implements Streamer { audioQuality: TidalFormat } + if (format == 'DOLBY_ATMOS') { + // switch to atmos client + + throw new Error('Not implemented.') + format = 'LOW' + // await this.refresh(this.atmosToken, this.atmosSecret) + } + if (!this.sessionId) await this.getAccountInfo() const playbackInfoResponse = await this.#get( @@ -524,6 +549,7 @@ export default class Tidal implements Streamer { ) { throw new Error('URL unrecognised') } + if (urlParts[1].includes('/')) urlParts[1] = urlParts[1].split('/')[0] return [urlParts[0], urlParts[1]] } async getTypeFromUrl(url: string): Promise { @@ -591,27 +617,39 @@ export default class Tidal implements Streamer { if (type != 'track') throw new Error('URL unrecognized') // get raw track info - const trackResponse = await this.#get(`tracks/${id}`) - const highestTrackQuality = TIDAL_FORMATS.indexOf(trackResponse.audioQuality) + 1 + const trackResponse = (await this.#get(`tracks/${id}`)) as RawTrack + + // get the highest quality + let highestTrackQuality = 1 + if (trackResponse.mediaMetadata.tags.includes('HIRES_LOSSLESS')) highestTrackQuality = 4 + else if (trackResponse.mediaMetadata.tags.includes('LOSSLESS')) highestTrackQuality = 3 + + // create formats array + let formats: Format[] = [] + if (trackResponse.mediaMetadata.tags.includes('DOLBY_ATMOS')) { + // handle atmos + formats.push({ + id: 'DOLBY_ATMOS', // psudeo id, not actually handled by tidal + rippable: true, + ...formatHelper('DOLBY_ATMOS') + }) + return formats + } // if we don't have it already, get the account highest quality, to mark certain tracks as rippable if (!this.highestQuality) await this.getAccountInfo() - const highestAccountQuality = TIDAL_FORMATS.indexOf(this.highestQuality as TidalFormat) + 1 + let highestAccountQuality = 1 + if (this.highestQuality == 'HI_RES') highestAccountQuality = 5 + else if (this.highestQuality == 'LOSSLESS') highestAccountQuality = 4 - let formats: Format[] = [] - const acceptable = TIDAL_FORMATS.slice(0, highestTrackQuality) + // delete formats we dont have + const acceptable = TIDAL_NORMAL_FORMATS.slice(0, highestTrackQuality) for (const i in acceptable) { - let bitrate - if (acceptable[i] == 'LOW') bitrate = 96 - if (acceptable[i] == 'HIGH') bitrate = 320 - formats.push({ id: acceptable[i] as TidalFormat, - rippable: parseInt(i) < highestAccountQuality, - mimeType: acceptable[i] == 'LOW' || acceptable[i] == 'HIGH' ? 'audio/mp4' : 'audio/flac', - extension: acceptable[i] == 'LOW' || acceptable[i] == 'HIGH' ? 'm4a' : 'flac', - bitrate + rippable: parseInt(i) <= highestAccountQuality, + ...formatHelper(acceptable[i]) }) } @@ -620,6 +658,8 @@ export default class Tidal implements Streamer { } function getExtensionFromMime(mime: string) { + mime = mime.split(';')[0] + switch (mime) { case 'audio/mp4': return 'm4a' @@ -629,3 +669,20 @@ function getExtensionFromMime(mime: string) { throw new Error(`Unable to resolve extension from mime type: ${mime}`) } } + +function formatHelper(format: TidalFormat) { + switch (format) { + case 'LOW': + return { extension: 'm4a', mimeType: 'audio/m4a', bitrate: 96, spatial: false } + case 'HIGH': + return { extension: 'm4a', mimeType: 'audio/m4a', bitrate: 320, spatial: false } + case 'LOSSLESS': + return { extension: 'flac', mimeType: 'audio/flac', bitDepth: 16, spatial: false } + case 'HI_RES_LOSSLESS': + return { extension: 'flac', mimeType: 'audio/flac', bitDepth: 24, spatial: false } + case 'DOLBY_ATMOS': + return { extension: 'ac3', mimeType: 'audio/ac3', rippable: false, spatial: true } + case 'SONY_360RA': + return { extension: '360ra', spatial: true } + } +} diff --git a/src/streamers/tidal/parse.ts b/src/streamers/tidal/parse.ts index e4c438c..9475f9a 100644 --- a/src/streamers/tidal/parse.ts +++ b/src/streamers/tidal/parse.ts @@ -1,7 +1,13 @@ import { Album, Artist, Playlist, Track } from '../../types.js' import { DOMParser } from 'xmldom-qsa' -export type TidalFormat = 'LOW' | 'HIGH' | 'LOSSLESS' | 'HI_RES' | 'HI_RES_LOSSLESS' +export type TidalFormat = + | 'LOW' + | 'HIGH' + | 'LOSSLESS' + | 'HI_RES_LOSSLESS' + | 'DOLBY_ATMOS' + | 'SONY_360RA' export interface RawArtist { id: string @@ -140,8 +146,11 @@ export interface RawTrack { version?: string album: RawAlbum audioQuality: TidalFormat + mediaMetadata: { tags: MediaMetadataTags[] } } +type MediaMetadataTags = 'LOSSLESS' | 'HIRES_LOSSLESS' | 'DOLBY_ATMOS' | 'SONY_360RA' + export function parseTrack(raw: RawTrack): Track { const track: Track = { url: raw.url, diff --git a/src/types.ts b/src/types.ts index 8020f1a..9c7fb56 100644 --- a/src/types.ts +++ b/src/types.ts @@ -122,7 +122,7 @@ export interface Format { sampleRate?: number bitDepth?: number // {x}-bit FLAC bitrate?: number // {x}kbps MP3 - atmos?: boolean + spatial?: boolean // sony 360ra or dolby atmos } export interface GetStreamResponse {