Informations

Estimated reading time : 7 minutes

Tags: javascript reverse engineering

Table of contents:


Goal

I wanted to download a full album from Khinsider, but the website has a paywall that prevents you from downloading more than 1 song at a time.

(of course, I bought the album physically, but I don't have a CD player anymore, and I don't want to buy it again in a digital format)

So I decided to bypass the paywall, and download the full album in a single click, in the highest quality available.

How does the page works ?

When arriving on an album page, the website loads these things :

  1. A table with all the songs available, their size, format and a download button
  2. A player to listen directly to the songs on your browser
  3. A button to download all songs at once (for which you have to pay)

Unfortunately, clicking the download button will open a new tab, and two links to click again.

However, clicking the play button will instantly play the song, without opening a new tab.

And observing all the scripts, I noticed that they were obfuscated (and I don't want to spend hours deobfuscating them).

Abusing the play button

When clicking the play button for each lines in the table, it swaps the src attribute of the <audio> tag with the link to the song.

Unfortunately (again), the song played is always the mp3 version, and I want the flac version (when available).

But when trying to simulate a download, it seems like we just have to replace the mp3 extension with flac to get the flac version of the song :^).

Coding the script

The ripping script will have to do the following things :

  1. Check whether there is a flac version of the song
  2. Mute the player (to avoid playing the song)
  3. Get all rows (tr) in the table
  4. For each row, click the play button
  5. Get the src attribute of the <audio> tag
  6. Replace the mp3 extension with flac (if there is a flac version)
  7. Download the song to a ZipWriter object
  8. Repeat for each row
  9. Use blob to download the zip file
  10. Profit

I'll use Tampermonkey to inject the script on the page.

Checking the highest quality available

To do that, a simple method would be to check whether we have the string FLAC in the download button.

document.querySelector("div.albumMassDownload").innerText.includes("FLAC")

Muting the player

To mute the player, it seems like we can just click on the mute button

document.querySelector("div.audioplayerVolume").click()

Getting all rows

To get all rows, we can use the querySelectorAll method, and select all div.playTrack elements.

let songsPlayBtns = document.querySelectorAll('div.playTrack');
console.log("Found " + songsPlayBtns.length + " songs.");

Clicking the play button to get the song link

let urls = [];
for (let btn of songsPlayBtns) {
    btn.click();
    urls.push(document.querySelector("audio#audio1").src);
}

Replacing the mp3 extension with flac

If the song is available in flac, we can just replace the mp3 extension with flac.

if (hasFlac) {
    urls = urls.map(url => url.replace(".mp3", ".flac"));
}

Creating our download button

// Create our custom download button
    let div = document.querySelector("div.albumMassDownload").cloneNode(true);
    div.style.marginTop = "10px";
    div.style.marginBottom = "10px";

    // Set the href to # so the cursor stays as a pointer, but nothing happens when clicked
    div.querySelectorAll("a").forEach(a => a.setAttribute("href", "#"));

    // Change the text, and inject our a tag
    div.childNodes[2].textContent = "Rip all songs at once : ";
    div.childNodes[2].insertAdjacentHTML("afterend", "<a href='#'> click here (" + (hasFlac ? "FLAC" : "MP3") + " zip)</a><span style='margin-left: 4px;' id='progress'></span>");

    div.querySelectorAll("a").forEach(a => a.addEventListener("click", () => downloadToZip(urls)));

    // Add the button to the page above div#nowPlay
    document.querySelector("div#nowPlay").insertAdjacentElement("beforebegin", div);

Downloading the songs to a zip file

We will use the JSZip library to download the songs to a zip file, we have to inject it in the page.

// Inject the jszip library
let script = document.createElement("script");
script.src = "https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js";
document.head.appendChild(script);
// Wait for the script to load before continuing
script.onload = () => {

And then we can use our functions to download songs and create the zip file.

function urlToBlob(url) {
    return new Promise((resolve, reject) => {
        GM_xmlhttpRequest({ // Using GM_xmlhttpRequest instead of fetch because of CORS issues
            method: "GET",
            url: url,
            responseType: "blob",
            onload: function (response) {
                resolve(response.response);
            },
            onerror: function (error) {
                reject(error);
            }
        });
    });
}
function saveAs(blob, filename) {
    // Use GM_download if available
    if (typeof GM_download === "function") {
        GM_download({
            url: URL.createObjectURL(blob),
            name: filename,
            onload: function () {
                console.log("Downloaded " + filename);
            },
            onerror: function (error) {
                console.error(error);
            }
        });
    } else {
        // Create a link and click it
        let link = document.createElement("a");
        link.href = URL.createObjectURL(blob);
        link.download = filename;
        link.click();
    }
}

And this to download all the songs to our zip file.

let zip = new JSZip();
// Add all the files to the zip
let downloaded = 0;
for (let url of urls) {
    let filename = decodeURIComponent(url.split("/").pop());
    urlToBlob(url).then(blob => {
        downloaded++;
        progressElement.innerText = downloaded + "/" + urls.length + " downloaded so far.";
        zip.file(filename, blob);
        if (downloaded === urls.length) {
            progressElement.innerText = "Generating zip...";
            // Generate the zip
            let name = decodeURIComponent(document.querySelector("div#pageContent > h2").innerText);
            zip.generateAsync({ type: "blob" })
                .then(function (content) {
                    // Download the zip
                    saveAs(content, name + ".zip");
                    progressElement.innerText = "Done!";
                });
        }
    });
}

Which gives us this final script which despite being a bit messy, works perfectly (at least on what I tested).

Conclusion

This entire process took me ~1 hour and has a lot of room for improvement, but it works, and that's what matters :^)

Feel free to use this script if you want to download an album from Khinsider, and edit it to your needs !

(and of course, download only the albums you bought)