package bandcampcollectiondownloader
|
|
|
|
import com.google.gson.Gson
|
|
import com.google.gson.annotations.SerializedName
|
|
import org.jsoup.Jsoup
|
|
import org.zeroturnaround.zip.ZipUtil
|
|
import java.io.FileOutputStream
|
|
import java.net.HttpURLConnection
|
|
import java.net.URL
|
|
import java.nio.file.Files
|
|
import java.nio.file.Path
|
|
import java.nio.file.Paths
|
|
import java.util.*
|
|
import java.util.regex.Pattern
|
|
import javax.mail.internet.ContentDisposition
|
|
|
|
data class ParsedCookie(
|
|
@SerializedName("Name raw")
|
|
val nameRaw: String,
|
|
|
|
@SerializedName("Content raw")
|
|
val contentRaw: String
|
|
)
|
|
|
|
data class ParsedBandcampData(
|
|
val digital_items: Array<DigitalItem>
|
|
)
|
|
|
|
data class DigitalItem(
|
|
val downloads: Map<String, Map<String, String>>,
|
|
val package_release_date: String,
|
|
val title: String,
|
|
val artist: String,
|
|
val download_type: String,
|
|
val art_id: String
|
|
)
|
|
|
|
data class ParsedStatDownload(
|
|
val download_url: String
|
|
)
|
|
|
|
fun parsedCookiesToMap(parsedCookies: Array<ParsedCookie>): Map<String, String> {
|
|
val result = HashMap<String, String>()
|
|
for (parsedCookie in parsedCookies) {
|
|
result.put(parsedCookie.nameRaw, parsedCookie.contentRaw)
|
|
}
|
|
return result
|
|
}
|
|
|
|
const val BUFFER_SIZE = 4096
|
|
|
|
/**
|
|
* From http://www.codejava.net/java-se/networking/use-httpurlconnection-to-download-file-from-an-http-url
|
|
*/
|
|
fun downloadFile(fileURL: String, saveDir: Path, optionalFileName: String = ""): Path {
|
|
|
|
val url = URL(fileURL)
|
|
val httpConn = url.openConnection() as HttpURLConnection
|
|
val responseCode = httpConn.getResponseCode()
|
|
|
|
// always check HTTP response code first
|
|
if (responseCode == HttpURLConnection.HTTP_OK) {
|
|
val disposition = httpConn.getHeaderField("Content-Disposition")
|
|
|
|
val fileName: String =
|
|
if (optionalFileName != "") {
|
|
optionalFileName
|
|
} else if (disposition != null) {
|
|
val parsedDisposition = ContentDisposition(disposition)
|
|
parsedDisposition.getParameter("filename")
|
|
} else {
|
|
Paths.get(url.file).fileName.toString()
|
|
}
|
|
|
|
// opens input stream from the HTTP connection
|
|
val inputStream = httpConn.getInputStream()
|
|
val saveFilePath = saveDir.resolve(fileName)
|
|
val saveFilePathString = saveFilePath.toAbsolutePath().toString()
|
|
|
|
// opens an output stream to save into file
|
|
val outputStream = FileOutputStream(saveFilePathString)
|
|
|
|
val buffer = ByteArray(BUFFER_SIZE)
|
|
var bytesRead = inputStream.read(buffer)
|
|
while (bytesRead != -1) {
|
|
outputStream.write(buffer, 0, bytesRead)
|
|
bytesRead = inputStream.read(buffer)
|
|
}
|
|
|
|
outputStream.close()
|
|
inputStream.close()
|
|
httpConn.disconnect()
|
|
return saveFilePath
|
|
} else {
|
|
throw Exception("No file to download. Server replied HTTP code: $responseCode")
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
/**
|
|
* Core function called from the main
|
|
*/
|
|
fun downloadAll(cookiesFile: String, bandcampUser: String, downloadFormat: String, downloadFolder: Path) {
|
|
// Parse JSON cookies (obtained with "Cookie Quick Manager" Firefox addon)
|
|
val gson = Gson()
|
|
val jsonData = String(Files.readAllBytes(Paths.get(cookiesFile)))
|
|
val parsedCookies = gson.fromJson(jsonData, Array<ParsedCookie>::class.java)
|
|
val cookies = parsedCookiesToMap(parsedCookies)
|
|
|
|
// Get collection page with cookies, hence with download links
|
|
val doc = Jsoup.connect("https://bandcamp.com/$bandcampUser")
|
|
.cookies(cookies)
|
|
.get()
|
|
println("""Found collection page: "${doc.title()}"""")
|
|
if (!doc.toString().contains("buy-now")) {
|
|
println("Provided cookies appear to be working!")
|
|
}
|
|
|
|
// Get download pages
|
|
val collection = doc.select("span.redownload-item a")
|
|
|
|
// For each download page
|
|
for (item in collection) {
|
|
val downloadPageURL = item.attr("href")
|
|
val downloadPageJsonParsed = getDataBlobFromDownloadPage(downloadPageURL, cookies, gson)
|
|
|
|
// Extract data from blob
|
|
val digitalItem = downloadPageJsonParsed.digital_items.get(0)
|
|
val albumtitle = digitalItem.title
|
|
val artist = digitalItem.artist
|
|
val releaseDate = digitalItem.package_release_date
|
|
val releaseYear = releaseDate.subSequence(7, 11)
|
|
val isSingleTrack: Boolean = digitalItem.download_type == "t"
|
|
val url = digitalItem.downloads.get(downloadFormat)?.get("url").orEmpty()
|
|
val artid = digitalItem.art_id
|
|
|
|
// Prepare artist and album folder
|
|
val albumFolderName = "$releaseYear - $albumtitle"
|
|
val artistFolderPath = Paths.get("$downloadFolder").resolve(artist)
|
|
val albumFolderPath = artistFolderPath.resolve(albumFolderName)
|
|
|
|
downloadAlbum(artistFolderPath, albumFolderPath, albumtitle, url, cookies, gson, isSingleTrack, artid)
|
|
|
|
}
|
|
}
|
|
|
|
fun downloadAlbum(artistFolderPath: Path?, albumFolderPath: Path, albumtitle: String, url: String, cookies: Map<String, String>, gson: Gson, isSingleTrack: Boolean, artid: String) {
|
|
// If the artist folder does not exist, we create it
|
|
if (!Files.exists(artistFolderPath)) {
|
|
Files.createDirectory(artistFolderPath)
|
|
}
|
|
|
|
// If the album folder does not exist, we create it
|
|
if (!Files.exists(albumFolderPath)) {
|
|
Files.createDirectory(albumFolderPath)
|
|
}
|
|
|
|
// If the folder is empty, or if it only contains the zip.part file, we proceed
|
|
val amountFiles = albumFolderPath.toFile().listFiles().size
|
|
if (amountFiles < 2) {
|
|
|
|
val outputFilePath: Path = prepareDownload(albumtitle, url, cookies, gson, albumFolderPath)
|
|
|
|
// If this is a zip, we unzip
|
|
if (!isSingleTrack) {
|
|
|
|
// Unzip
|
|
try {
|
|
ZipUtil.unpack(outputFilePath.toFile(), albumFolderPath.toFile())
|
|
} finally {
|
|
// Delete zip
|
|
Files.delete(outputFilePath)
|
|
}
|
|
}
|
|
|
|
// Else if this is a single track, we just fetch the cover
|
|
else {
|
|
val coverURL = "https://f4.bcbits.com/img/a${artid}_10"
|
|
println("Downloading cover ($coverURL)...")
|
|
downloadFile(coverURL, albumFolderPath, "cover.jpg")
|
|
}
|
|
|
|
println("done.")
|
|
|
|
} else {
|
|
println("Album $albumtitle already done, skipping")
|
|
}
|
|
}
|
|
|
|
fun getDataBlobFromDownloadPage(downloadPageURL: String?, cookies: Map<String, String>, gson: Gson): ParsedBandcampData {
|
|
println("Analyzing download page $downloadPageURL")
|
|
|
|
// Get page content
|
|
val downloadPage = Jsoup.connect(downloadPageURL)
|
|
.cookies(cookies)
|
|
.timeout(100000).get()
|
|
|
|
// Get data blob
|
|
val downloadPageJson = downloadPage.select("#pagedata").attr("data-blob")
|
|
val downloadPageJsonParsed = gson.fromJson(downloadPageJson, ParsedBandcampData::class.java)
|
|
return downloadPageJsonParsed
|
|
}
|
|
|
|
fun prepareDownload(albumtitle: String, url: String, cookies: Map<String, String>, gson: Gson, albumFolderPath: Path): Path {
|
|
println("Preparing download of $albumtitle ($url)...")
|
|
|
|
val random = Random()
|
|
|
|
// Construct statdownload request URL
|
|
val statdownloadURL: String = url
|
|
.replace("/download/", "/statdownload/")
|
|
.replace("http", "https") + "&.vrs=1" + "&.rand=" + random.nextInt()
|
|
|
|
// Get statdownload JSON
|
|
println("Getting download link ($statdownloadURL)")
|
|
val statedownloadUglyBody: String = Jsoup.connect(statdownloadURL)
|
|
.cookies(cookies)
|
|
.timeout(100000)
|
|
.get().body().select("body").get(0).text().toString()
|
|
|
|
val prefixPattern = Pattern.compile("""if\s*\(\s*window\.Downloads\s*\)\s*\{\s*Downloads\.statResult\s*\(\s*""")
|
|
val suffixPattern = Pattern.compile("""\s*\)\s*};""")
|
|
val statdownloadJSON: String =
|
|
prefixPattern.matcher(
|
|
suffixPattern.matcher(statedownloadUglyBody)
|
|
.replaceAll("")
|
|
).replaceAll("")
|
|
|
|
// Parse statdownload JSON and get real download URL, and retrieve url
|
|
val statdownloadParsed: ParsedStatDownload = gson.fromJson(statdownloadJSON, ParsedStatDownload::class.java)
|
|
val realDownloadURL = statdownloadParsed.download_url
|
|
|
|
println("Downloading $albumtitle ($realDownloadURL)")
|
|
|
|
// Download content
|
|
val outputFilePath: Path = downloadFile(realDownloadURL, albumFolderPath)
|
|
return outputFilePath
|
|
}
|