Initial commit

This commit is contained in:
hazycora
2023-09-16 22:05:49 -04:00
commit fc4c8ceb40
22 changed files with 3476 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
export const TIDAL_AUTH_BASE = 'https://auth.tidal.com/v1/'
export const TIDAL_API_BASE = 'https://listen.tidal.com/v1/'

363
src/streamers/tidal/main.ts Normal file
View File

@@ -0,0 +1,363 @@
import fetch from 'node-fetch'
import { spawn } from 'child_process'
import { GetByUrlResponse, GetStreamResponse, SearchResults, Streamer, Track } from '../../types.js'
import { TIDAL_AUTH_BASE, TIDAL_API_BASE } from './constants.js'
import {
Contributor,
RawAlbum,
RawArtist,
RawTrack,
addCredits,
parseAlbum,
parseArtist,
parseMpd,
parseTrack
} from './parse.js'
import Stream from 'stream'
interface TidalOptions {
tvToken: string
tvSecret: string
accessToken: string
refreshToken: string
expires: number
}
interface LoginData {
error: string
access_token: string
refresh_token: string
expires_in: number
}
export default class Tidal implements Streamer {
tvToken: string
tvSecret: string
accessToken: string
refreshToken: string
expires: number
hostnames = ['tidal.com', 'www.tidal.com', 'listen.tidal.com']
failedAuth = false
constructor(options: TidalOptions) {
this.tvToken = options.tvToken
this.tvSecret = options.tvSecret
// TODO: ideally this should be stored in a .config file
this.accessToken = options.accessToken
this.refreshToken = options.refreshToken
this.expires = options.expires
const getReady = async () => {
if (!this.refreshToken) return
if (await this.sessionValid()) return
const success = await this.refresh()
if (success) console.log(`Refreshed tokens successfully`)
else console.log(`Failed to refresh tokens, this could be a bad sign`)
}
getReady()
}
headers() {
return {
'X-Tidal-Token': this.tvToken,
Authorization: `Bearer ${this.accessToken}`,
'Accept-Encoding': 'gzip',
'User-Agent': 'TIDAL_ANDROID/1039 okhttp/3.14.9'
}
}
async #get(url: string, params: { [key: string]: string | number } = {}): Promise<unknown> {
if (Date.now() > this.expires) await this.refresh()
if (this.failedAuth) throw new Error(`Last request failed to authorize, get new tokens`)
params.countryCode = params.countryCode ?? 'US'
params.locale = params.locale ?? 'en_US'
params.deviceType = params.deviceType ?? 'TV'
for (const key in params) {
if (typeof params[key] == 'number') params[key] = params[key].toString()
}
const response = await fetch(
`${TIDAL_API_BASE}${url}?${new URLSearchParams(<{ [key: string]: string }>params)}`,
{
headers: this.headers()
}
)
if (!response.ok) {
const errMsg = await response.text()
try {
const json = JSON.parse(errMsg)
const sessionValid = await this.sessionValid()
if (json.status == 401 && !sessionValid) {
this.failedAuth = !(await this.refresh())
console.log('Refreshed tokens')
if (this.failedAuth) {
throw new Error('Auth failed. Try getting new tokens.')
}
return this.#get(url, params)
}
console.error('Tidal error response:', json)
} catch (error) {
console.error('Tidal error response:', errMsg)
}
throw new Error(`Fetching ${url} from Tidal failed with status code ${response.status}.`)
}
return await response.json()
}
async sessionValid() {
const resp = await fetch('https://api.tidal.com/v1/sessions', {
headers: this.headers()
})
return resp.ok
}
async getTokens() {
const deviceAuthResponse = await fetch(`${TIDAL_AUTH_BASE}oauth2/device_authorization`, {
method: 'post',
body: new URLSearchParams({
client_id: this.tvToken,
scope: 'r_usr w_usr'
})
})
if (deviceAuthResponse.status != 200) throw new Error(`Couldn't authorize Tidal`)
interface DeviceAuth {
deviceCode: string
userCode: string
}
const deviceAuth = <DeviceAuth>await deviceAuthResponse.json()
const linkUrl = `https://link.tidal.com/${deviceAuth.userCode}`
const checkToken = async () => {
const params = {
client_id: this.tvToken,
client_secret: this.tvSecret,
device_code: deviceAuth.deviceCode,
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
scope: 'r_usr w_usr'
}
let loginData: LoginData | null = null
let statusCode = 400
while (statusCode == 400) {
await new Promise((r) => setTimeout(r, 1000))
const loginResponse = await fetch(`${TIDAL_AUTH_BASE}oauth2/token`, {
method: 'post',
body: new URLSearchParams(params)
})
statusCode = loginResponse.status
loginData = <LoginData>await loginResponse.json()
}
if (statusCode != 200 || !loginData) throw new Error(`Failed to log in. ${loginData?.error}`)
this.accessToken = loginData.access_token
this.refreshToken = loginData.refresh_token
this.expires = Date.now() + loginData.expires_in * 1000
console.log('Using the following new config:', this.getCurrentConfig())
}
console.log(`Log in at ${linkUrl}`)
checkToken()
return linkUrl
}
getCurrentConfig() {
return {
tvToken: this.tvToken,
tvSecret: this.tvSecret,
accessToken: this.accessToken,
refreshToken: this.refreshToken,
expires: this.expires
}
}
async refresh() {
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,
grant_type: 'refresh_token'
})
})
if (refreshResponse.status == 200) {
const refreshData: LoginData = <LoginData>await refreshResponse.json()
this.expires = Date.now() + refreshData.expires_in * 1000
this.accessToken = refreshData.access_token
if (refreshData.refresh_token) this.refreshToken = refreshData.refresh_token
return true
}
return false
}
async search(query: string, limit = 20): Promise<SearchResults> {
interface RawSearchResults {
albums: {
items: RawAlbum[]
}
artists: {
items: RawArtist[]
}
tracks: {
items: RawTrack[]
}
}
const results = <RawSearchResults>await this.#get('search/top-hits', {
query: query,
limit: limit,
offset: 0,
types: 'ARTISTS,ALBUMS,TRACKS',
includeContributors: 'true',
includeUserPlaylists: 'true',
supportsUserData: 'true'
})
return {
query,
albums: results.albums.items.map((raw) => parseAlbum(raw)),
artists: results.artists.items.map(parseArtist),
tracks: results.tracks.items.map(parseTrack)
}
}
async #getTrack(trackId: number | string) {
const trackResponse = <RawTrack>await this.#get(`tracks/${trackId}`)
const contributorResponse = (<{ items: Contributor[] }>(
await this.#get(`tracks/${trackId}/contributors`)
)).items
trackResponse.album = <RawAlbum>await this.#get(`albums/${trackResponse.album.id}`)
return parseTrack(addCredits(trackResponse, contributorResponse))
}
async #getAlbum(albumId: number | string) {
const albumResponse = await this.#get(`albums/${albumId}`)
return parseAlbum(<RawAlbum>albumResponse)
}
async #getAlbumTracks(albumId: number | string): Promise<Track[]> {
interface TracksContributors {
items: {
item: RawTrack
credits: Contributor[]
}[]
}
const contributorResponse = <TracksContributors>await this.#get(
`albums/${albumId}/items/credits`,
{
replace: 'true',
offset: 0,
includeContributors: 'true'
}
)
return contributorResponse.items.map((item) => parseTrack(addCredits(item.item, item.credits)))
}
async #getArtist(artistId: number | string) {
const [artistResponse, albumsResponse, tracksResponse] = await Promise.all([
<Promise<RawArtist>>this.#get(`artists/${artistId}`),
<Promise<{ items: RawAlbum[] }>>this.#get(`artists/${artistId}/albums`, {
limit: 20
}),
<Promise<{ items: RawTrack[] }>>this.#get(`artists/${artistId}/toptracks`, {
limit: 20
})
])
return {
...parseArtist(artistResponse),
albums: albumsResponse.items.map(parseAlbum),
tracks: tracksResponse.items.map(parseTrack)
}
}
async #getFileUrl(trackId: number | string, quality = 'HI_RES'): Promise<GetStreamResponse> {
interface PlaybackInfo {
manifest: string
manifestMimeType: string
}
const playbackInfoResponse = <PlaybackInfo>await this.#get(
`tracks/${trackId}/playbackinfopostpaywall`,
{
playbackmode: 'STREAM',
assetpresentation: 'FULL',
audioquality: quality,
prefetch: 'false'
}
)
const manifestStr = Buffer.from(playbackInfoResponse.manifest, 'base64').toString('utf-8')
if (playbackInfoResponse.manifestMimeType != 'application/dash+xml') {
interface Manifest {
mimeType: string
urls: string[]
}
const manifest = <Manifest>JSON.parse(manifestStr)
const streamResponse = await fetch(manifest.urls[0])
return {
mimeType: manifest.mimeType,
sizeBytes: parseInt(<string>streamResponse.headers.get('Content-Length')),
stream: <NodeJS.ReadableStream>streamResponse.body
}
}
const trackUrls = parseMpd(manifestStr)
const ffmpegProc = spawn('ffmpeg', [
'-hide_banner',
'-loglevel',
'error',
'-i',
'-',
'-c:a',
'copy',
'-f',
'flac',
'-'
])
const stream = new Stream.Readable({
// eslint-disable-next-line @typescript-eslint/no-empty-function
read() {}
})
async function load() {
for (const url of trackUrls) {
const resp = await fetch(url)
if (!resp.body) throw new Error('Response has no body')
for await (const chunk of resp.body) {
stream.push(chunk)
}
}
stream.push(null)
}
stream.pipe(ffmpegProc.stdin)
ffmpegProc.stderr.pipe(process.stderr)
load()
return {
mimeType: playbackInfoResponse.manifestMimeType,
sizeBytes: manifestStr.length,
stream: ffmpegProc.stdout
}
}
#getUrlParts(url: string): ['artist' | 'album' | 'track', string] {
const urlParts = url
.match(/^https?:\/\/(?:www\.|listen\.)?tidal\.com\/(?:browse\/)?(.*?)\/(.*?)\/?$/)
?.slice(1, 3)
if (!urlParts) throw new Error('URL not supported')
urlParts[1] = urlParts[1].replace(/\?.*?$/, '')
if (urlParts[0] != 'artist' && urlParts[0] != 'album' && urlParts[0] != 'track') {
throw new Error('URL unrecognised')
}
return [urlParts[0], urlParts[1]]
}
getTypeFromUrl(url: string): 'artist' | 'album' | 'track' {
return this.#getUrlParts(url)[0]
}
async getByUrl(url: string): Promise<GetByUrlResponse> {
const [type, id] = this.#getUrlParts(url)
switch (type) {
case 'track':
return {
type,
getStream: () => {
return this.#getFileUrl(id)
},
metadata: await this.#getTrack(id)
}
case 'album':
return {
type,
tracks: await this.#getAlbumTracks(id),
metadata: await this.#getAlbum(id)
}
case 'artist':
return {
type,
metadata: await this.#getArtist(id)
}
}
}
}

View File

@@ -0,0 +1,191 @@
import { Album, Artist, Track } from '../../types.js'
import { DOMParser } from 'xmldom-qsa'
export interface RawArtist {
id: string
url?: string
name: string
picture?: string
}
export function parseArtist(raw: RawArtist): Artist {
const picturePath = raw.picture?.replace(/-/gm, '/')
const artist: Artist = {
id: raw.id,
url: raw.url ?? `https://www.tidal.com/artist/${raw.id}`,
name: raw.name
}
if (picturePath)
artist.pictures = [
`https://resources.tidal.com/images/${picturePath}/160x160.jpg`,
`https://resources.tidal.com/images/${picturePath}/320x320.jpg`,
`https://resources.tidal.com/images/${picturePath}/750x750.jpg`
]
return artist
}
export interface RawAlbum {
cover: string
id: number
url: string
numberOfTracks?: number
numberOfVolumes?: number
title: string
artists?: RawArtist[]
upc?: string
releaseDate?: string
}
export function parseAlbum(raw: RawAlbum): Album {
const coverPath = raw.cover.replace(/-/gm, '/')
const album: Album = {
id: raw.id,
url: raw.url ?? `https://tidal.com/browse/album/${raw.id}`,
title: raw.title,
coverArtwork: [
{
url: `https://resources.tidal.com/images/${coverPath}/160x160.jpg`,
width: 160,
height: 160
},
{
url: `https://resources.tidal.com/images/${coverPath}/320x320.jpg`,
width: 320,
height: 320
},
{
url: `https://resources.tidal.com/images/${coverPath}/1280x1280.jpg`,
width: 1280,
height: 1280
}
]
}
if (raw.upc) album.upc = raw.upc
if (raw.artists) album.artists = raw.artists.map(parseArtist)
if (raw.numberOfTracks) album.trackCount = raw.numberOfTracks
if (raw.numberOfVolumes) album.discCount = raw.numberOfVolumes
if (raw.releaseDate) album.releaseDate = new Date(raw.releaseDate)
return album
}
export interface RawTrack {
url: string
id: number
artists: RawArtist[]
duration: number
copyright: string
isrc?: string
producers?: string[]
composers?: string[]
lyricists?: string[]
explicit?: boolean
trackNumber?: number
volumeNumber?: number
title: string
album: RawAlbum
}
export function parseTrack(raw: RawTrack): Track {
const track: Track = {
url: raw.url,
id: raw.id,
title: raw.title,
durationMs: raw.duration * 1000,
artists: raw.artists.map(parseArtist),
album: parseAlbum(raw.album)
}
if (raw.producers) track.producers = raw.producers
if (raw.composers) track.composers = raw.composers
if (raw.lyricists) track.lyricists = raw.lyricists
if (raw.isrc) track.isrc = raw.isrc
if (raw.copyright) track.copyright = raw.copyright
if (raw.explicit) track.explicit = raw.explicit
if (raw.trackNumber) track.trackNumber = raw.trackNumber
if (raw.volumeNumber) track.discNumber = raw.volumeNumber
return track
}
export interface Contributor {
name: string
role: string
}
export interface ContributorsByType {
type: string
contributors: { name: string; id: number }[]
}
export function addCredits(raw: RawTrack, credits: Contributor[] | ContributorsByType[]): RawTrack {
if (credits.length > 0 && 'type' in credits[0]) {
credits = (<ContributorsByType[]>credits)
.map((group) => {
return group.contributors.map((contributor) => {
return {
name: contributor.name,
role: group.type
}
})
})
.flat()
}
for (const contributor of <Contributor[]>credits) {
switch (contributor.role) {
case 'Producer':
if (!raw.producers) raw.producers = []
raw.producers.push(contributor.name)
break
case 'Composer':
if (!raw.composers) raw.composers = []
raw.composers.push(contributor.name)
break
case 'Lyricist':
if (!raw.lyricists) raw.lyricists = []
raw.lyricists.push(contributor.name)
break
default:
break
}
}
return raw
}
export function parseMpd(mpdString: string): string[] {
const tracks: string[][] = []
const { documentElement: doc } = new DOMParser().parseFromString(mpdString, 'application/xml')
for (const adaptationSet of [...doc.querySelectorAll('AdaptationSet')]) {
const contentType = adaptationSet.getAttribute('contentType')
if (contentType != 'audio') throw new Error('Lucida only supports audio MPDs')
for (const rep of [...doc.querySelectorAll('Representation')]) {
let codec = rep.getAttribute('codecs')?.toLowerCase()
if (codec?.startsWith('mp4a')) codec = 'aac'
const segTemplate = rep.querySelector('SegmentTemplate')
if (!segTemplate) throw new Error('No SegmentTemplate found')
const initializationUrl = segTemplate.getAttribute('initialization')
if (!initializationUrl) throw new Error('No initialization url')
const mediaUrl = segTemplate.getAttribute('media')
if (!mediaUrl) throw new Error('No media url')
const trackUrls = []
const timeline = segTemplate.querySelector('SegmentTimeline')
if (timeline) {
const timeList = []
// let currentTime = 0
for (const s of [...timeline.querySelectorAll('S')]) {
if (s.getAttribute('t')) {
// currentTime = parseInt(<string>s.getAttribute('t'))
}
const r = parseInt(s.getAttribute('r') || '0') + 1
if (!s.getAttribute('d')) throw new Error('No d property on SegmentTimeline')
for (let i = 0; i < r; i++) {
timeList.push(0)
// currentTime += parseInt(<string>s.getAttribute('d'))
}
}
for (const i in timeList) {
trackUrls.push(mediaUrl.replace('$Number$', i))
}
}
tracks.push(trackUrls)
}
}
return tracks[0]
}