You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

271 lines
9.9 KiB

7 years ago
  1. package bandcampdownloader
  2. import com.google.gson.Gson
  3. import com.google.gson.annotations.SerializedName
  4. import org.jsoup.Jsoup
  5. import org.zeroturnaround.zip.ZipUtil
  6. import picocli.CommandLine
  7. import java.io.FileOutputStream
  8. import java.net.HttpURLConnection
  9. import java.net.URL
  10. import java.nio.file.Files
  11. import java.nio.file.Path
  12. import java.nio.file.Paths
  13. import java.util.*
  14. import java.util.regex.Pattern
  15. import javax.mail.internet.ContentDisposition
  16. data class ParsedCookie(
  17. @SerializedName("Name raw")
  18. val nameRaw: String,
  19. @SerializedName("Content raw")
  20. val contentRaw: String
  21. )
  22. data class ParsedBandcampData(
  23. val digital_items: Array<DigitalItem>
  24. )
  25. data class DigitalItem(
  26. val downloads: Map<String, Map<String, String>>,
  27. val package_release_date: String,
  28. val title: String,
  29. val artist: String,
  30. val download_type: String,
  31. val art_id: String
  32. )
  33. data class ParsedStatDownload(
  34. val download_url: String
  35. )
  36. fun parsedCookiesToMap(parsedCookies: Array<ParsedCookie>): Map<String, String> {
  37. val result = HashMap<String, String>()
  38. for (parsedCookie in parsedCookies) {
  39. result.put(parsedCookie.nameRaw, parsedCookie.contentRaw)
  40. }
  41. return result
  42. }
  43. const val BUFFER_SIZE = 4096
  44. /**
  45. * From http://www.codejava.net/java-se/networking/use-httpurlconnection-to-download-file-from-an-http-url
  46. */
  47. fun downloadFile(fileURL: String, saveDir: Path, optionalFileName: String = ""): Path {
  48. val url = URL(fileURL)
  49. val httpConn = url.openConnection() as HttpURLConnection
  50. val responseCode = httpConn.getResponseCode()
  51. // always check HTTP response code first
  52. if (responseCode == HttpURLConnection.HTTP_OK) {
  53. val disposition = httpConn.getHeaderField("Content-Disposition")
  54. val fileName: String =
  55. if (optionalFileName != "") {
  56. optionalFileName
  57. } else if (disposition != null) {
  58. val parsedDisposition = ContentDisposition(disposition)
  59. parsedDisposition.getParameter("filename")
  60. } else {
  61. Paths.get(url.file).fileName.toString()
  62. }
  63. // opens input stream from the HTTP connection
  64. val inputStream = httpConn.getInputStream()
  65. val saveFilePath = saveDir.resolve(fileName)
  66. val saveFilePathString = saveFilePath.toAbsolutePath().toString()
  67. // opens an output stream to save into file
  68. val outputStream = FileOutputStream(saveFilePathString)
  69. val buffer = ByteArray(BUFFER_SIZE)
  70. var bytesRead = inputStream.read(buffer)
  71. while (bytesRead != -1) {
  72. outputStream.write(buffer, 0, bytesRead)
  73. bytesRead = inputStream.read(buffer)
  74. }
  75. outputStream.close()
  76. inputStream.close()
  77. httpConn.disconnect()
  78. return saveFilePath
  79. } else {
  80. throw Exception("No file to download. Server replied HTTP code: $responseCode")
  81. }
  82. }
  83. data class Args(
  84. @CommandLine.Parameters(arity = "1..1",
  85. description = arrayOf("The bandcamp user account from which all albums must be downloaded."))
  86. var bandcampUser: String = "",
  87. @CommandLine.Option(names = arrayOf("--cookies-file", "-c"), required = true,
  88. description = arrayOf("A JSON file with valid bandcamp credential cookies.",
  89. """"Cookie Quick Manager" can be used to obtain this file after logging into bandcamp.""",
  90. "(visit https://addons.mozilla.org/en-US/firefox/addon/cookie-quick-manager/)."))
  91. var pathToCookiesFile: String? = null,
  92. @CommandLine.Option(names = arrayOf("--audio-format", "-f"), required = false,
  93. description = arrayOf("The chosen audio format of the files to download (default: \${DEFAULT-VALUE}).",
  94. "Possible values: flac, wav, aac-hi, mp3-320, aiff-lossless, vorbis, mp3-v0, alac."))
  95. var audioFormat: String = "vorbis",
  96. @CommandLine.Option(names = arrayOf("--download-folder", "-d"), required = false,
  97. description = arrayOf("The folder in which downloaded albums must be extracted.",
  98. "The following structure is considered: <pathToDownloadFolder>/<artist>/<year> - <album>."))
  99. var pathToDownloadFolder: Path = Paths.get("."),
  100. @CommandLine.Option(names = arrayOf("-h", "--help"), usageHelp = true, description = arrayOf("Display this help message."))
  101. var help: Boolean = false
  102. )
  103. fun main(args: Array<String>) {
  104. // Parsing args
  105. System.setProperty("picocli.usage.width", "120")
  106. val parsedArgs: Args =
  107. try {
  108. CommandLine.populateCommand<Args>(Args(), *args)
  109. } catch (e: CommandLine.MissingParameterException) {
  110. CommandLine.usage(Args(), System.out)
  111. System.err.println(e.message)
  112. return
  113. }
  114. if (parsedArgs.help) {
  115. CommandLine.usage(Args(), System.out)
  116. }
  117. val bandcampUser = parsedArgs.bandcampUser
  118. val cookiesFile = parsedArgs.pathToCookiesFile
  119. val downloadFormat = parsedArgs.audioFormat
  120. val downloadFolder = parsedArgs.pathToDownloadFolder
  121. // Parse JSON cookies (obtained with some Firefox addon)
  122. val gson = Gson()
  123. val jsonData = String(Files.readAllBytes(Paths.get(cookiesFile)))
  124. val parsedCookies = gson.fromJson(jsonData, Array<ParsedCookie>::class.java)
  125. val cookies = parsedCookiesToMap(parsedCookies)
  126. // Get collection page with cookies, hence with download links
  127. val doc = Jsoup.connect("https://bandcamp.com/$bandcampUser")
  128. .cookies(cookies)
  129. .get()
  130. println(doc.title())
  131. if (!doc.toString().contains("buy-now")) {
  132. println("Cookies appear to be working!")
  133. }
  134. // Get download pages
  135. val collection = doc.select("span.redownload-item a")
  136. // For each download page
  137. for (item in collection) {
  138. val downloadPageURL = item.attr("href")
  139. println("Analyzing download page $downloadPageURL")
  140. // Get page content
  141. val downloadPage = Jsoup.connect(downloadPageURL)
  142. .cookies(cookies)
  143. .timeout(100000).get()
  144. // Get data blob
  145. val downloadPageJson = downloadPage.select("#pagedata").attr("data-blob")
  146. val downloadPageJsonParsed = gson.fromJson(downloadPageJson, ParsedBandcampData::class.java)
  147. // Extract data from blob
  148. val digitalItem = downloadPageJsonParsed.digital_items.get(0)
  149. val albumtitle = digitalItem.title
  150. val artist = digitalItem.artist
  151. val releaseDate = digitalItem.package_release_date
  152. val releaseYear = releaseDate.subSequence(7, 11)
  153. val isSingleTrack: Boolean = digitalItem.download_type == "t"
  154. val url = digitalItem.downloads.get(downloadFormat)?.get("url").orEmpty()
  155. val artid = digitalItem.art_id
  156. // Prepare artist and album folder
  157. val albumFolderName = "$releaseYear - $albumtitle"
  158. val artistFolderPath = Paths.get("$downloadFolder").resolve(artist)
  159. val albumFolderPath = artistFolderPath.resolve(albumFolderName)
  160. // If the artist folder does not exist, we create it
  161. if (!Files.exists(artistFolderPath)) {
  162. Files.createDirectory(artistFolderPath)
  163. }
  164. // If the album folder does not exist, we create it
  165. if (!Files.exists(albumFolderPath)) {
  166. Files.createDirectory(albumFolderPath)
  167. }
  168. // If the folder is empty, or if it only contains the zip.part file, we proceed
  169. val amountFiles = albumFolderPath.toFile().listFiles().size
  170. if (amountFiles < 2) {
  171. println("Preparing download of $albumtitle ($url)...")
  172. val random = Random()
  173. // Construct statdownload request URL
  174. val statdownloadURL: String = url
  175. .replace("/download/", "/statdownload/")
  176. .replace("http", "https") + "&.vrs=1" + "&.rand=" + random.nextInt()
  177. // Get statdownload JSON
  178. println("Getting download link ($statdownloadURL)")
  179. val statedownloadUglyBody: String = Jsoup.connect(statdownloadURL)
  180. .cookies(cookies)
  181. .timeout(100000)
  182. .get().body().select("body").get(0).text().toString()
  183. val prefixPattern = Pattern.compile("""if\s*\(\s*window\.Downloads\s*\)\s*\{\s*Downloads\.statResult\s*\(\s*""")
  184. val suffixPattern = Pattern.compile("""\s*\)\s*};""")
  185. val statdownloadJSON: String =
  186. prefixPattern.matcher(
  187. suffixPattern.matcher(statedownloadUglyBody)
  188. .replaceAll("")
  189. ).replaceAll("")
  190. // Parse statdownload JSON and get real download URL, and retrieve url
  191. val statdownloadParsed: ParsedStatDownload = gson.fromJson(statdownloadJSON, ParsedStatDownload::class.java)
  192. val realDownloadURL = statdownloadParsed.download_url
  193. println("Downloading $albumtitle ($realDownloadURL)")
  194. // Download content
  195. val outputFilePath: Path = downloadFile(realDownloadURL, albumFolderPath)
  196. // If this is a zip, we unzip
  197. if (!isSingleTrack) {
  198. // Unzip
  199. try {
  200. ZipUtil.unpack(outputFilePath.toFile(), albumFolderPath.toFile())
  201. } finally {
  202. // Delete zip
  203. Files.delete(outputFilePath)
  204. }
  205. }
  206. // Else if this is a single track, we just fetch the cover
  207. else {
  208. val coverURL = "https://f4.bcbits.com/img/a${artid}_10"
  209. println("Downloading cover ($coverURL)...")
  210. downloadFile(coverURL, albumFolderPath, "cover.jpg")
  211. }
  212. println("done.")
  213. } else {
  214. println("Album $albumtitle already done, skipping")
  215. }
  216. }
  217. }