Snippets

profile://github.com/delan stan downloader

Created by Delan Azabani last modified
/// stan downloader
/// <https://bitbucket.org/delan/workspace/snippets/kxyjaj>
/// <https://bucket.daz.cat/stan.js>
///
/// bookmarklet version below:
/// javascript:(script=>{script.src="https://bucket.daz.cat/stan.js";document.body.appendChild(script)})(document.createElement("script"))

(() => {
    const links = [...document.querySelectorAll("a.episode__play")];

    if (links.length < 1) {
        alert("fatal: this doesn’t look like a season page (play.stan.com.au/programs/.../seasons/...).");
        return;
    }

    if (typeof EmeInterception == "undefined") {
        alert("fatal: this script depends on the Widevine L3 Decryptor (a Chrome extension). search for widevine-l3-decryptor, install it, then try again.");
        return;
    }

    if (!confirm("before we start, make sure your devtools console is open, so you can follow the instructions.")) {
        return;
    }

    const quality = promptOrThrow("quality? [low, medium, high, sd, hd(?), uhd(?)]\n\nnote: choosing too high a quality for the episodes results in low!", "sd");
    const saved = JSON.parse(promptOrThrow("saved episode list (or null)?", "null"));

    const commands = [];
    const result = [];
    next();

    function promptOrThrow(...rest) {
        const result = prompt(...rest);

        if (result == null) {
            throw new Error("cancelled by user");
        }

        return result;
    }

    async function next() {
        if (saved != null || links.length < 1) {
            const episodes = saved ?? result;
            console.log(`saved episode list: ${JSON.stringify(episodes)}`);

            for (const [id, key] of episodes) {
                const token = document.cookie.split("; ")
                    .filter(x => /^streamco_token=/.test(x))[0]
                    .split("=")[1];

                const client = JSON.parse(localStorage["player_device_id"]).val;

                const streams = await get(x => x.json(), `https://api.stan.com.au/concurrency/v1/streams?programId=${id}&jwToken=${token}&clientId=${client}&quality=${quality}&format=dash&capabilities.drm=widevine&player=html5`);
                const kidHex = streams.media.drm.keyId.replace("-", "");
                const keyHex = key.map(x => x.toString(16).padStart(2,0)).join("");

                await downloadText(`${id}.kid.txt`, kidHex);
                await downloadText(`${id}.key.txt`, keyHex);

                for (const x of streams.media.captions) {
                    const blob = await get(x => x.blob(), x.url);
                    await download(blob, `${id}.${x.language}.vtt`);
                }

                const mpd = await get(async x => (new DOMParser).parseFromString(await x.text(), "application/xml"), `https://api.stan.com.au/manifest/v1/dash/web.mpd?url=${streams.media.videoUrl}&audioType=all&version=87`);
                const base = mpd.querySelector("BaseURL").textContent;
                const inputs = { video: [], audio: [] };

                for (const type of ["video", "audio"]) {
                    for (const [index, a] of [...mpd.querySelectorAll(`AdaptationSet[mimeType^="${type}/"]`)].entries()) {
                        const b = a.querySelector("BaseURL")?.textContent ?? base;
                        const r = [...a.querySelectorAll(`Representation`)].sort((p,q) => q.getAttribute("bandwidth") - p.getAttribute("bandwidth"))[0].getAttribute("id");
                        const t = a.querySelector("SegmentTemplate");
                        const parts = [];

                        const init = b + t.getAttribute("initialization").replace("$RepresentationID$", r);
                        const blob = await get(x => x.blob(), init);
                        parts.push(blob);

                        for (let i = Number(t.getAttribute("startNumber")); ; i++) {
                            try {
                                const seg = b + t.getAttribute("media").replace("$RepresentationID$", r).replace("$Number$", i);
                                const blob = await get(x => x.blob(), seg);
                                parts.push(blob);
                            } catch (error) {
                                if (error._response) {
                                    console.debug("reached the end of the stream’s segments: %o", error);
                                    break;
                                }

                                throw error;
                            }
                        }

                        const whole = new Blob(parts, { type: a.getAttribute("mimeType") });
                        await download(whole, `${id}.${type}${index}e.mp4`);

                        enqueue(`mp4decrypt --key ${kidHex}:${keyHex} ${id}.${type}${index}e.mp4 ${id}.${type}${index}d.mp4`);
                        inputs[type].push(`${id}.${type}${index}d.mp4`);
                    }
                }

                const vinputs = inputs.video;
                const ainputs = inputs.audio;
                const sinputs = streams.media.captions;
                const ffvinputs = ffoption(inputs.video, x => `-i ${x}`);
                const ffainputs = ffoption(inputs.audio, x => `-i ${x}`);
                const ffsinputs = ffoption(streams.media.captions, x => `-i ${id}.${x.language}.vtt`);
                const ffmap = ffoption([...Array(vinputs.length + ainputs.length + sinputs.length)], (_,i) => `-map ${i}`);
                const ffsmeta = ffoption(streams.media.captions, (x,i) => `-metadata:s:s:${i} language=${x.language}`);
                enqueue(`ffmpeg -nostdin -y ${ffvinputs} ${ffainputs} ${ffsinputs} ${ffmap} ${ffsmeta} -c:v copy -c:a copy -c:s mov_text ${id}.mp4`);
            }

            console.log("done! now run the following to decrypt and mux:");
            console.log(commands.join("\n"));

            return;
        }

        const episode = links.shift();
        const id = eid(episode.href);

        const old = EmeInterception.onOperation;
        EmeInterception.onOperation = async (type, args) => {
            // console.log(type,args);
            switch (type){
            case "MessageEvent":
                lastReceivedLicenseRequest = args.message;
                break;
            case "UpdateCall":
                lastReceivedLicenseResponse = args[0];
                const key = await WidevineCrypto.decryptContentKey(lastReceivedLicenseRequest, lastReceivedLicenseResponse);
                if (!key) break;
                EmeInterception.onOperation = old;
                result.push([id, key]);
                // console.log(result);
                document.querySelector("button.vjs-back-player-control").click();
                next();
            }
        };

        const listener = event => {
            if (eid(event.target.href) != id) {
                console.log("wrong episode");
                event.preventDefault();
                event.stopPropagation();
            } else {
                removeEventListener("click", listener, true);
            }
        };

        addEventListener("click", listener, true);

        const title = episode.parentNode.parentNode.nextElementSibling.querySelector("h4").textContent;
        console.log("now click %o", title);
    }

    async function get(fun, url) {
        console.debug("get: %o", url);
        const response = await fetch(url);

        if (response.ok) {
            return fun(response);
        }

        const error = new Error(`fetch yielded failing HTTP status code: ${response.status}`);
        error._response = response;
        throw error;
    }

    async function download(blob, name) {
        const href = URL.createObjectURL(blob);
        const link = document.createElement("a");
        link.href = href;
        link.download = name;
        link.addEventListener("click", () => {
            setTimeout(() => {
                URL.revokeObjectURL(href);
            }, 1000);
        });
        link.click();

        // try to wait long enough for navigation to occur
        await sleep(250);
    }

    function downloadText(name, text) {
        const blob = new Blob([text], { type: "text/plain" });
        download(blob, name);
    }

    function eid(href) {
        return href.match(/(?<=[/]programs[/])[0-9]+/)[0];
    }

    async function sleep(ms) {
        await new Promise(resolve => setTimeout(resolve, ms));
    }

    function enqueue(command) {
        console.debug(`enqueueing command: ${command}`);
        commands.push(command);
    }

    function ffoption(xs, fun) {
        return xs.map(fun).join(" ");
    }
})();

Comments (0)

HTTPS SSH

You can clone a snippet to your computer for local editing. Learn more.