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 }