Browse Source

Look for firefox cookies when no cookies file is provided

master
Gwendal 7 years ago
parent
commit
c5e0b5dc33
4 changed files with 137 additions and 34 deletions
  1. +2
    -0
      build.gradle
  2. +107
    -32
      src/main/kotlin/bandcampcollectiondownloader/BandcampCollectionDownloader.kt
  3. +2
    -2
      src/main/kotlin/bandcampcollectiondownloader/Main.kt
  4. +26
    -0
      src/test/kotlin/bandcampcollectiodownloader/test/BandcampCollectionDownloaderTests.kt

+ 2
- 0
build.gradle View File

@ -12,6 +12,8 @@ dependencies {
compile 'com.sun.mail:javax.mail:1.6.1' compile 'com.sun.mail:javax.mail:1.6.1'
compile 'info.picocli:picocli:3.4.0' compile 'info.picocli:picocli:3.4.0'
compile 'com.google.code.gson:gson:2.8.5' compile 'com.google.code.gson:gson:2.8.5'
compile 'org.ini4j:ini4j:0.5.4'
compile 'org.xerial:sqlite-jdbc:3.25.2'
testCompile 'org.junit.jupiter:junit-jupiter-api:5.2.0' testCompile 'org.junit.jupiter:junit-jupiter-api:5.2.0'
testRuntime 'org.junit.jupiter:junit-jupiter-engine:5.2.0' testRuntime 'org.junit.jupiter:junit-jupiter-engine:5.2.0'
} }


+ 107
- 32
src/main/kotlin/bandcampcollectiondownloader/BandcampCollectionDownloader.kt View File

@ -3,15 +3,20 @@ package bandcampcollectiondownloader
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.JsonSyntaxException import com.google.gson.JsonSyntaxException
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import org.ini4j.Ini
import org.jsoup.HttpStatusException import org.jsoup.HttpStatusException
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.zeroturnaround.zip.ZipUtil import org.zeroturnaround.zip.ZipUtil
import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import java.nio.file.Paths import java.nio.file.Paths
import java.sql.Connection
import java.sql.DriverManager
import java.time.Instant
import java.util.* import java.util.*
import java.util.regex.Pattern import java.util.regex.Pattern
import javax.mail.internet.ContentDisposition import javax.mail.internet.ContentDisposition
@ -25,7 +30,7 @@ data class ParsedCookie(
) )
data class ParsedBandcampData( data class ParsedBandcampData(
val digital_items: Array<DigitalItem>
@Suppress("ArrayInDataClass") val digital_items: Array<DigitalItem>
) )
data class DigitalItem( data class DigitalItem(
@ -50,7 +55,7 @@ fun parsedCookiesToMap(parsedCookies: Array<ParsedCookie>): Map<String, String>
if (parsedCookie.nameRaw.isNullOrEmpty()) { if (parsedCookie.nameRaw.isNullOrEmpty()) {
throw BandCampDownloaderError("Missing 'Name raw' field in cookie number ${parsedCookies.indexOf(parsedCookie) + 1}.") throw BandCampDownloaderError("Missing 'Name raw' field in cookie number ${parsedCookies.indexOf(parsedCookie) + 1}.")
} }
result.put(parsedCookie.nameRaw!!, parsedCookie.contentRaw!!)
result[parsedCookie.nameRaw!!] = parsedCookie.contentRaw!!
} }
return result return result
} }
@ -64,24 +69,24 @@ fun downloadFile(fileURL: String, saveDir: Path, optionalFileName: String = ""):
val url = URL(fileURL) val url = URL(fileURL)
val httpConn = url.openConnection() as HttpURLConnection val httpConn = url.openConnection() as HttpURLConnection
val responseCode = httpConn.getResponseCode()
val responseCode = httpConn.responseCode
// always check HTTP response code first // always check HTTP response code first
if (responseCode == HttpURLConnection.HTTP_OK) { if (responseCode == HttpURLConnection.HTTP_OK) {
val disposition = httpConn.getHeaderField("Content-Disposition") val disposition = httpConn.getHeaderField("Content-Disposition")
val fileName: String = val fileName: String =
if (optionalFileName != "") {
optionalFileName
} else if (disposition != null) {
val parsedDisposition = ContentDisposition(disposition)
parsedDisposition.getParameter("filename")
} else {
Paths.get(url.file).fileName.toString()
when {
optionalFileName != "" -> optionalFileName
disposition != null -> {
val parsedDisposition = ContentDisposition(disposition)
parsedDisposition.getParameter("filename")
}
else -> Paths.get(url.file).fileName.toString()
} }
// opens input stream from the HTTP connection // opens input stream from the HTTP connection
val inputStream = httpConn.getInputStream()
val inputStream = httpConn.inputStream
val saveFilePath = saveDir.resolve(fileName) val saveFilePath = saveDir.resolve(fileName)
val saveFilePathString = saveFilePath.toAbsolutePath().toString() val saveFilePathString = saveFilePath.toAbsolutePath().toString()
@ -106,24 +111,31 @@ fun downloadFile(fileURL: String, saveDir: Path, optionalFileName: String = ""):
} }
fun isUnix(): Boolean {
val os = System.getProperty("os.name").toLowerCase()
return os.indexOf("nix") >= 0 || os.indexOf("nux") >= 0 || os.indexOf("aix") > 0
}
/** /**
* Core function called from the main * Core function called from the main
*/ */
fun downloadAll(cookiesFile: Path, bandcampUser: String, downloadFormat: String, downloadFolder: Path) {
// Parse JSON cookies (obtained with "Cookie Quick Manager" Firefox addon)
fun downloadAll(cookiesFile: Path?, bandcampUser: String, downloadFormat: String, downloadFolder: Path) {
val gson = Gson() val gson = Gson()
if (!Files.exists(cookiesFile)) {
throw BandCampDownloaderError("Cookies file '$cookiesFile' cannot be found.")
}
val jsonData = String(Files.readAllBytes(cookiesFile))
val parsedCookies =
try {
gson.fromJson(jsonData, Array<ParsedCookie>::class.java)
} catch (e: JsonSyntaxException) {
throw BandCampDownloaderError("Cookies file '$cookiesFile' is not well formed: ${e.message}")
val cookies =
when {
cookiesFile != null -> {
// Parse JSON cookies (obtained with "Cookie Quick Manager" Firefox addon)
println("Loading provided cookies file: $cookiesFile")
retrieveCookiesFromFile(cookiesFile, gson)
}
isUnix() -> {
// Try to find cookies stored in default firefox profile
println("No provided cookies file, using Firefox cookies.")
retrieveFirefoxCookies()
}
else -> throw BandCampDownloaderError("No available cookies!")
} }
val cookies = parsedCookiesToMap(parsedCookies)
// Get collection page with cookies, hence with download links // Get collection page with cookies, hence with download links
val doc = try { val doc = try {
@ -152,13 +164,13 @@ fun downloadAll(cookiesFile: Path, bandcampUser: String, downloadFormat: String,
val downloadPageJsonParsed = getDataBlobFromDownloadPage(downloadPageURL, cookies, gson) val downloadPageJsonParsed = getDataBlobFromDownloadPage(downloadPageURL, cookies, gson)
// Extract data from blob // Extract data from blob
val digitalItem = downloadPageJsonParsed.digital_items.get(0)
val digitalItem = downloadPageJsonParsed.digital_items[0]
val albumtitle = digitalItem.title val albumtitle = digitalItem.title
val artist = digitalItem.artist val artist = digitalItem.artist
val releaseDate = digitalItem.package_release_date val releaseDate = digitalItem.package_release_date
val releaseYear = releaseDate.subSequence(7, 11) val releaseYear = releaseDate.subSequence(7, 11)
val isSingleTrack: Boolean = digitalItem.download_type == "t" val isSingleTrack: Boolean = digitalItem.download_type == "t"
val url = digitalItem.downloads.get(downloadFormat)?.get("url").orEmpty()
val url = digitalItem.downloads[downloadFormat]?.get("url").orEmpty()
val artid = digitalItem.art_id val artid = digitalItem.art_id
// Prepare artist and album folder // Prepare artist and album folder
@ -171,10 +183,75 @@ fun downloadAll(cookiesFile: Path, bandcampUser: String, downloadFormat: String,
} }
} }
class BandCampDownloaderError : Exception {
constructor(s: String) : super(s)
private fun retrieveCookiesFromFile(cookiesFile: Path?, gson: Gson): Map<String, String> {
if (!Files.exists(cookiesFile)) {
throw BandCampDownloaderError("Cookies file '$cookiesFile' cannot be found.")
}
val jsonData = String(Files.readAllBytes(cookiesFile))
val parsedCookies =
try {
gson.fromJson(jsonData, Array<ParsedCookie>::class.java)
} catch (e: JsonSyntaxException) {
throw BandCampDownloaderError("Cookies file '$cookiesFile' is not well formed: ${e.message}")
}
return parsedCookiesToMap(parsedCookies)
}
private fun retrieveFirefoxCookies(): HashMap<String, String> {
val result = HashMap<String, String>()
// Find cookies file path
val homeDir = System.getenv()["HOME"]
val firefoxConfDirPath = "$homeDir/.mozilla/firefox"
val profilesListPath = "$firefoxConfDirPath/profiles.ini"
val profilesListFile = File(profilesListPath)
if (!profilesListFile.exists()) {
throw BandCampDownloaderError("No Firefox profiles.ini file could be found!")
}
val ini = Ini(profilesListFile)
val default = "Default"
val defaultProfileSection = ini.keys.find {
ini[it] != null
&& ini[it]!!.containsKey(default)
&& ini[it]!![default] == "1"
}
val defaultProfilePath = firefoxConfDirPath + "/" + ini.get(defaultProfileSection, "Path")
val cookiesFilePath = "$defaultProfilePath/cookies.sqlite"
// Copy cookies file as tmp file
val tmpFolder = Files.createTempDirectory("bandcampCollectionDownloader")
val copiedCookiesPath = Files.copy(Paths.get(cookiesFilePath), tmpFolder.resolve("cookies.json"))
copiedCookiesPath.toFile().deleteOnExit()
// Start reading firefox's cookies.sqlite
var connection: Connection? = null
try {
// create a database connection
connection = DriverManager.getConnection("jdbc:sqlite:$copiedCookiesPath")
val statement = connection!!.createStatement()
statement.queryTimeout = 30 // set timeout to 30 sec.
val rs = statement.executeQuery("select * from moz_cookies where baseDomain = 'bandcamp.com'")
// For each resulting row
while (rs.next()) {
// Extract data from row
val name = rs.getString("name")
val value = rs.getString("value")
val expiry = rs.getString("expiry").toLong()
// We only keep cookies that have not expired yet
val now = Instant.now().epochSecond
val difference = expiry - now
if (difference > 0)
result[name] = value
}
} finally {
connection?.close()
}
return result
} }
class BandCampDownloaderError(s: String) : Exception(s)
fun downloadAlbum(artistFolderPath: Path?, albumFolderPath: Path, albumtitle: String, url: String, cookies: Map<String, String>, gson: Gson, isSingleTrack: Boolean, artid: String) { 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 the artist folder does not exist, we create it
if (!Files.exists(artistFolderPath)) { if (!Files.exists(artistFolderPath)) {
@ -228,8 +305,7 @@ fun getDataBlobFromDownloadPage(downloadPageURL: String?, cookies: Map<String, S
// Get data blob // Get data blob
val downloadPageJson = downloadPage.select("#pagedata").attr("data-blob") val downloadPageJson = downloadPage.select("#pagedata").attr("data-blob")
val downloadPageJsonParsed = gson.fromJson(downloadPageJson, ParsedBandcampData::class.java)
return downloadPageJsonParsed
return gson.fromJson(downloadPageJson, ParsedBandcampData::class.java)
} }
fun prepareDownload(albumtitle: String, url: String, cookies: Map<String, String>, gson: Gson, albumFolderPath: Path): Path { fun prepareDownload(albumtitle: String, url: String, cookies: Map<String, String>, gson: Gson, albumFolderPath: Path): Path {
@ -247,7 +323,7 @@ fun prepareDownload(albumtitle: String, url: String, cookies: Map<String, String
val statedownloadUglyBody: String = Jsoup.connect(statdownloadURL) val statedownloadUglyBody: String = Jsoup.connect(statdownloadURL)
.cookies(cookies) .cookies(cookies)
.timeout(100000) .timeout(100000)
.get().body().select("body").get(0).text().toString()
.get().body().select("body")[0].text().toString()
val prefixPattern = Pattern.compile("""if\s*\(\s*window\.Downloads\s*\)\s*\{\s*Downloads\.statResult\s*\(\s*""") val prefixPattern = Pattern.compile("""if\s*\(\s*window\.Downloads\s*\)\s*\{\s*Downloads\.statResult\s*\(\s*""")
val suffixPattern = Pattern.compile("""\s*\)\s*};""") val suffixPattern = Pattern.compile("""\s*\)\s*};""")
@ -264,6 +340,5 @@ fun prepareDownload(albumtitle: String, url: String, cookies: Map<String, String
println("Downloading $albumtitle ($realDownloadURL)") println("Downloading $albumtitle ($realDownloadURL)")
// Download content // Download content
val outputFilePath: Path = downloadFile(realDownloadURL, albumFolderPath)
return outputFilePath
return downloadFile(realDownloadURL, albumFolderPath)
} }

+ 2
- 2
src/main/kotlin/bandcampcollectiondownloader/Main.kt View File

@ -10,7 +10,7 @@ data class Args(
description = arrayOf("The bandcamp user account from which all albums must be downloaded.")) description = arrayOf("The bandcamp user account from which all albums must be downloaded."))
var bandcampUser: String = "", var bandcampUser: String = "",
@CommandLine.Option(names = arrayOf("--cookies-file", "-c"), required = true,
@CommandLine.Option(names = arrayOf("--cookies-file", "-c"), required = false,
description = arrayOf("A JSON file with valid bandcamp credential cookies.", description = arrayOf("A JSON file with valid bandcamp credential cookies.",
""""Cookie Quick Manager" can be used to obtain this file after logging into bandcamp.""", """"Cookie Quick Manager" can be used to obtain this file after logging into bandcamp.""",
"(visit https://addons.mozilla.org/en-US/firefox/addon/cookie-quick-manager/).")) "(visit https://addons.mozilla.org/en-US/firefox/addon/cookie-quick-manager/)."))
@ -57,7 +57,7 @@ fun main(args: Array<String>) {
// Else, parse arguments and run // Else, parse arguments and run
else { else {
val bandcampUser = parsedArgs.bandcampUser val bandcampUser = parsedArgs.bandcampUser
val cookiesFile = parsedArgs.pathToCookiesFile!!
val cookiesFile = parsedArgs.pathToCookiesFile
val downloadFormat = parsedArgs.audioFormat val downloadFormat = parsedArgs.audioFormat
val downloadFolder = parsedArgs.pathToDownloadFolder val downloadFolder = parsedArgs.pathToDownloadFolder
try { try {


+ 26
- 0
src/test/kotlin/bandcampcollectiodownloader/test/BandcampCollectionDownloaderTests.kt View File

@ -5,6 +5,7 @@ import bandcampcollectiondownloader.downloadAll
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.assertThrows
import java.nio.file.Paths import java.nio.file.Paths
import java.util.*
/** /**
* Note: bli is a valid bandcamp user (completely randomly chosen), * Note: bli is a valid bandcamp user (completely randomly chosen),
@ -54,4 +55,29 @@ class BandcampCollectionDownloaderTests {
} }
} }
@Test
fun testErrorNoCookiesAtAll() {
addToEnv("HOME", "NOPE")
assertThrows<BandCampDownloaderError> {
downloadAll(null, "bli", "bli", Paths.get("bli"))
}
}
@Throws(Exception::class)
fun addToEnv(key: String, value: String) {
val classes = Collections::class.java!!.declaredClasses
val env = System.getenv()
for (cl in classes) {
if ("java.util.Collections\$UnmodifiableMap" == cl.name) {
val field = cl.getDeclaredField("m")
field.isAccessible = true
val obj = field.get(env)
val map = obj as MutableMap<String, String>
map[key] = value
}
}
}
} }

Loading…
Cancel
Save