Skip to content

Instantly share code, notes, and snippets.

@szkrd
Last active July 30, 2019 11:09
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save szkrd/e8b3f5307e02f0ac5b928d35b7dd3a5a to your computer and use it in GitHub Desktop.
Save szkrd/e8b3f5307e02f0ac5b928d35b7dd3a5a to your computer and use it in GitHub Desktop.
save bandcamp album tracks to a pls playlist
// save bandcamp album tracks to a pls playlist;
// mp3 file urls are protected by a token, so these playlist
// will "expire", but at least one can use a native player;
// still, please do read http://bandcamp.com/help/audio_basics#steal
// and http://bandcamp.com/terms_of_use
//
// ```
// mkdir bandcamp-pls && \
// cd bandcamp-pls && \
// npm init --yes && \
// npm i -SE node-fetch safe-eval filenamify object-get open && \
// mkdir playlists
// ```
const fetch = require('node-fetch')
const safeEval = require('safe-eval')
const filenamify = require('filenamify')
const objectGet = require('object-get')
const open = require('open')
const fs = require('fs')
const path = require('path')
const { promisify } = require('util')
const writeFileAsync = promisify(fs.writeFile)
const targetUrl = process.argv.slice(2).filter(param => param.startsWith('http'))[0] || ''
const openPlaylist = process.argv.includes('--open')
async function main() {
if (!targetUrl || !targetUrl.includes('bandcamp.com')) {
throw new Error('usage: node bandcamp-pls (--open) [url]')
}
// fetch will use a custom user-agent, which I'm not going to override
const page = await fetch(targetUrl)
const text = await page.text()
const lines = text.split(/\n/)
let jsonString = []
let inRange = false
let successful = false
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const trimmed = lines[i].trim()
// search for track and album data global variable
if (trimmed.startsWith('var TralbumData = {')) {
jsonString.push('{')
inRange = true
}
if (inRange && /^ {4}[a-z_]*?:/.test(line) && !trimmed.startsWith('//')) {
jsonString.push(trimmed.replace(/\s+\/\/\s+.*$/gi, ''))
}
if (inRange && trimmed === '};') {
jsonString.push('}')
jsonString = jsonString.join('')
successful = true
break
}
}
if (!successful || !jsonString) {
throw new Error('!album data not found')
}
const albumData = safeEval(jsonString) || {}
const title = (objectGet(albumData, 'current.title') || 'unknown').trim()
const description = objectGet(albumData, 'current.credits') || ''
const defaultPrice = objectGet(albumData, 'current.defaultPrice')
const setPrice = objectGet(albumData, 'current.set_price')
const price = defaultPrice || setPrice || 0
console.info('\n# ' + title)
if (description) {
console.info('\n' + description)
}
if (price) {
console.info(`\n_If you like this album, you can have it for $${price}_`)
}
const trackInfo = objectGet(albumData, 'trackinfo') || []
if (!trackInfo.length) {
throw new Error('!no trackinfo found')
}
const output = trackInfo.reduce(
(playlist, track, i) => {
const n = i + 1
const len = Math.round(track.duration * 60)
const file = track.file['mp3-128']
const title = track.title
playlist.push(`File${n}=${file}`)
playlist.push(`Title${n}=${title}`)
playlist.push(`Length${n}=${len}`)
return playlist
},
['[playlist]']
)
output.push(`NumberOfEntries=${trackInfo.length}`)
output.push('Version=2')
const fileName = filenamify(title.toLocaleLowerCase()) + '.pls'
const fileNameWithPath = path.resolve(__dirname, './playlists/' + fileName)
await writeFileAsync(fileNameWithPath, output.join('\n'), 'utf-8')
console.info(`\n"${fileName}" saved`)
if (openPlaylist) {
await open(fileNameWithPath)
}
}
main().catch((error) => {
if (error.message.startsWith('!')) {
console.error(error.message.replace(/^!/, ''))
} else {
console.error(error)
}
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment