|
|
@ -3,15 +3,20 @@ package bandcampcollectiondownloader |
|
|
|
import com.google.gson.Gson |
|
|
|
import com.google.gson.JsonSyntaxException |
|
|
|
import com.google.gson.annotations.SerializedName |
|
|
|
import org.ini4j.Ini |
|
|
|
import org.jsoup.HttpStatusException |
|
|
|
import org.jsoup.Jsoup |
|
|
|
import org.zeroturnaround.zip.ZipUtil |
|
|
|
import java.io.File |
|
|
|
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.sql.Connection |
|
|
|
import java.sql.DriverManager |
|
|
|
import java.time.Instant |
|
|
|
import java.util.* |
|
|
|
import java.util.regex.Pattern |
|
|
|
import javax.mail.internet.ContentDisposition |
|
|
@ -25,7 +30,7 @@ data class ParsedCookie( |
|
|
|
) |
|
|
|
|
|
|
|
data class ParsedBandcampData( |
|
|
|
val digital_items: Array<DigitalItem> |
|
|
|
@Suppress("ArrayInDataClass") val digital_items: Array<DigitalItem> |
|
|
|
) |
|
|
|
|
|
|
|
data class DigitalItem( |
|
|
@ -50,7 +55,7 @@ fun parsedCookiesToMap(parsedCookies: Array<ParsedCookie>): Map<String, String> |
|
|
|
if (parsedCookie.nameRaw.isNullOrEmpty()) { |
|
|
|
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 |
|
|
|
} |
|
|
@ -64,24 +69,24 @@ fun downloadFile(fileURL: String, saveDir: Path, optionalFileName: String = ""): |
|
|
|
|
|
|
|
val url = URL(fileURL) |
|
|
|
val httpConn = url.openConnection() as HttpURLConnection |
|
|
|
val responseCode = httpConn.getResponseCode() |
|
|
|
val responseCode = httpConn.responseCode |
|
|
|
|
|
|
|
// 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() |
|
|
|
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 |
|
|
|
val inputStream = httpConn.getInputStream() |
|
|
|
val inputStream = httpConn.inputStream |
|
|
|
val saveFilePath = saveDir.resolve(fileName) |
|
|
|
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 |
|
|
|
*/ |
|
|
|
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() |
|
|
|
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 |
|
|
|
val doc = try { |
|
|
@ -152,13 +164,13 @@ fun downloadAll(cookiesFile: Path, bandcampUser: String, downloadFormat: String, |
|
|
|
val downloadPageJsonParsed = getDataBlobFromDownloadPage(downloadPageURL, cookies, gson) |
|
|
|
|
|
|
|
// Extract data from blob |
|
|
|
val digitalItem = downloadPageJsonParsed.digital_items.get(0) |
|
|
|
val digitalItem = downloadPageJsonParsed.digital_items[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 url = digitalItem.downloads[downloadFormat]?.get("url").orEmpty() |
|
|
|
val artid = digitalItem.art_id |
|
|
|
|
|
|
|
// 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) { |
|
|
|
// If the artist folder does not exist, we create it |
|
|
|
if (!Files.exists(artistFolderPath)) { |
|
|
@ -228,8 +305,7 @@ fun getDataBlobFromDownloadPage(downloadPageURL: String?, cookies: Map<String, S |
|
|
|
|
|
|
|
// Get 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 { |
|
|
@ -247,7 +323,7 @@ fun prepareDownload(albumtitle: String, url: String, cookies: Map<String, String |
|
|
|
val statedownloadUglyBody: String = Jsoup.connect(statdownloadURL) |
|
|
|
.cookies(cookies) |
|
|
|
.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 suffixPattern = Pattern.compile("""\s*\)\s*};""") |
|
|
@ -264,6 +340,5 @@ fun prepareDownload(albumtitle: String, url: String, cookies: Map<String, String |
|
|
|
println("Downloading $albumtitle ($realDownloadURL)") |
|
|
|
|
|
|
|
// Download content |
|
|
|
val outputFilePath: Path = downloadFile(realDownloadURL, albumFolderPath) |
|
|
|
return outputFilePath |
|
|
|
return downloadFile(realDownloadURL, albumFolderPath) |
|
|
|
} |