improve quality selector for all

This commit is contained in:
EUCLID 2026-04-09 16:24:25 -04:00 committed by synzr
parent 58ceea862f
commit 4d561bc2fd
6 changed files with 103 additions and 41 deletions

View file

@ -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 }
}
}

View file

@ -175,7 +175,7 @@ export default class Qobuz implements StreamerWithLogin {
}
}
async #getFileUrl(trackId: string, formatId: number = 27): Promise<GetStreamResponse> {
async #getFileUrl(trackId: string, formatId: string | number): Promise<GetStreamResponse> {
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

View file

@ -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']

View file

@ -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<unknown> {
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 = <PlaybackInfo>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<ItemType> {
@ -591,27 +617,39 @@ export default class Tidal implements Streamer {
if (type != 'track') throw new Error('URL unrecognized')
// get raw track info
const trackResponse = <RawTrack>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 }
}
}

View file

@ -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,

View file

@ -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 {