mirror of
https://codeberg.org/lucida/lucida.git
synced 2026-11-13 15:17:25 -05:00
Initial commit
This commit is contained in:
2
src/streamers/tidal/constants.ts
Normal file
2
src/streamers/tidal/constants.ts
Normal 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
363
src/streamers/tidal/main.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
191
src/streamers/tidal/parse.ts
Normal file
191
src/streamers/tidal/parse.ts
Normal 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]
|
||||
}
|
||||
Reference in New Issue
Block a user