192 lines
No EOL
5.4 KiB
JavaScript
192 lines
No EOL
5.4 KiB
JavaScript
import { error, json as jsonResp, html, Router, createResponse } from 'itty-router'
|
|
|
|
const router = Router()
|
|
|
|
// android automotive new2 atmos client
|
|
const CLIENT_ID = '4N3n6Q1x95LL5K7p'
|
|
const CLIENT_SECRET = 'oKOXfJW371cX6xaZ0PyhgGNBdNLlBZd4AKKYougMjik='
|
|
|
|
const AUDIO_QUALITIES = ['LOW', 'HIGH', 'LOSSLESS', 'HI_RES']
|
|
const VIDEO_QUALITIES = ['AUDIO_ONLY', 'LOW', 'MEDIUM', 'HIGH']
|
|
|
|
const TYPE_QUALITY_INFO = {
|
|
tracks: { default: 'LOSSLESS', list: AUDIO_QUALITIES, param: 'audioquality' },
|
|
videos: { default: 'HIGH', list: VIDEO_QUALITIES, param: 'videoquality' },
|
|
}
|
|
|
|
function to_form_data(obj) {
|
|
return Object.entries(obj).map(([k, v]) => `${k}=${v}`).join('&')
|
|
}
|
|
|
|
async function renew_token(env) {
|
|
const res = await fetch('https://auth.tidal.com/v1/oauth2/token', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded'
|
|
},
|
|
body: to_form_data({
|
|
client_id: CLIENT_ID,
|
|
client_secret: CLIENT_SECRET,
|
|
grant_type: 'refresh_token',
|
|
refresh_token: env.REFRESH_TOKEN,
|
|
scope: 'r_usr'
|
|
})
|
|
})
|
|
|
|
const json = await res.json()
|
|
|
|
const access_token = json.access_token
|
|
console.log('access_token', access_token)
|
|
await env.KV.put('access_token', access_token, {expirationTtl: json.expires_in})
|
|
return access_token
|
|
}
|
|
|
|
async function api_call(env, method, path, params={}, body=null) {
|
|
let url = `https://api.tidal.com/v1/${path}`
|
|
if (params) {
|
|
url += '?' + to_form_data(params)
|
|
}
|
|
|
|
let access_token = await env.KV.get('access_token')
|
|
if (access_token === null) {
|
|
access_token = await renew_token(env)
|
|
}
|
|
|
|
if (body) {
|
|
body = to_form_data(body)
|
|
}
|
|
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${access_token}`
|
|
},
|
|
body
|
|
})
|
|
|
|
const status = res.status
|
|
const json = await res.json()
|
|
return { status, json }
|
|
}
|
|
|
|
async function playbackinfopostpaywall(env, type, id, q) {
|
|
id = parseInt(id)
|
|
if (isNaN(id)) {
|
|
return error(400, 'Invalid id')
|
|
}
|
|
|
|
const quality_info = TYPE_QUALITY_INFO[type]
|
|
let quality = q || quality_info.default
|
|
quality = quality.toUpperCase()
|
|
if (!quality_info.list.includes(quality)) {
|
|
return error(400, 'Invalid quality')
|
|
}
|
|
|
|
let params = {
|
|
assetpresentation: 'FULL',
|
|
playbackmode: 'STREAM'
|
|
}
|
|
params[quality_info.param] = quality
|
|
|
|
const res = await api_call(env, 'GET', `${type}/${id}/playbackinfopostpaywall/v4`, params)
|
|
|
|
console.log('playbackinfopostpaywall', res.status, res.json)
|
|
|
|
if (res.status !== 200) {
|
|
return error(403, 'Couldn\'t get manifest')
|
|
}
|
|
|
|
if (!['application/vnd.tidal.bts', 'application/vnd.tidal.emu'].includes(res.json.manifestMimeType)) {
|
|
return error(500, 'Invalid manifest mime type')
|
|
}
|
|
|
|
const manifest = JSON.parse(atob(res.json.manifest))
|
|
return Response.redirect(manifest.urls[0], 302)
|
|
}
|
|
|
|
router.get('/track/:id', async ({ params, query }, env) => {
|
|
return await playbackinfopostpaywall(env, 'tracks', params.id, query.q)
|
|
})
|
|
|
|
router.get('/video/:id', async ({ params, query }, env) => {
|
|
return await playbackinfopostpaywall(env, 'videos', params.id, query.q)
|
|
})
|
|
|
|
function m3u8(items, audio_quality, video_quality, host) {
|
|
let list = '#EXTM3U\n'
|
|
|
|
for (const item_info of items) {
|
|
const item = item_info.item
|
|
const type = item_info.type
|
|
|
|
let quality
|
|
if (type === 'track') {
|
|
quality = audio_quality
|
|
} else {
|
|
quality = video_quality
|
|
}
|
|
|
|
const url = `https://${host}/${type}/${item.id}?q=${quality}`
|
|
list += `#EXTINF:${item.duration},${item.title}\n${url}\n`
|
|
}
|
|
|
|
return list
|
|
}
|
|
|
|
async function get_items(type, id, aq, vq, host, env) {
|
|
let audio_quality = aq || 'LOSSLESS'
|
|
audio_quality = audio_quality.toUpperCase()
|
|
if (!AUDIO_QUALITIES.includes(audio_quality)) {
|
|
return error(400, 'Invalid audio quality')
|
|
}
|
|
|
|
let video_quality = vq || 'HIGH'
|
|
video_quality = video_quality.toUpperCase()
|
|
if (!VIDEO_QUALITIES.includes(video_quality)) {
|
|
return error(400, 'Invalid video quality')
|
|
}
|
|
|
|
const res = await api_call(env, 'GET', `${type}/${id}/items`, {
|
|
offset: 0,
|
|
limit: 4294967295,
|
|
countryCode: 'US',
|
|
})
|
|
|
|
console.log(res.status, res.json)
|
|
|
|
if (res.status !== 200) {
|
|
return error(403, 'Couldn\'t get items')
|
|
}
|
|
|
|
const m3u = m3u8(res.json.items, audio_quality, video_quality, host)
|
|
|
|
return createResponse('application/x-mpegurl')(m3u)
|
|
}
|
|
|
|
router.get('/album/:id', async ({ params, query, headers }, env) => {
|
|
let id = parseInt(params.id)
|
|
if (isNaN(id)) {
|
|
return error(400, 'Invalid album id')
|
|
}
|
|
|
|
return await get_items('albums', id, query.aq, query.vq, headers.get('host'), env)
|
|
})
|
|
|
|
router.get('/playlist/:id', async ({ params, query, headers }, env) => {
|
|
let id = params.id
|
|
|
|
const uuid_regex = /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/gi;
|
|
if (!uuid_regex.test(id)) {
|
|
return error(400, 'Invalid playlist id')
|
|
}
|
|
|
|
return await get_items('playlists', id, query.aq, query.vq, headers.get('host'), env)
|
|
})
|
|
|
|
export default {
|
|
fetch: (req, env, ctx) =>
|
|
router
|
|
.handle(req, env, ctx)
|
|
.catch(error), // catch errors
|
|
} |