Terbau / fortnitepy

Async python library for interacting with Fortnite's API and XMPP services.
MIT License
148 stars 52 forks source link

Add Download function for GameFiles #25

Closed Luc1412 closed 4 years ago

Luc1412 commented 5 years ago

It would be nice to have a function to download the gamefiles. Waddlesworth has already published how it works in JS:

__chunk_hash.js__

function toHex(d)
{
    return ("0"+(Number(d).toString(16))).slice(-2).toUpperCase();
}

function FString_ToBlobUInt64(str)
{
    //if (8 < (str.length / 3) || str.length % 3 != 0)
    if (str.length % 3 != 0)
    {
        return null;
    }

    let hexString = "";
    for (let i = 0; i < str.length; i += 3)
    {
        const numStr = str[i] + str[i+1] + str[i+2];
        const val = parseInt(numStr);
        hexString = toHex(val) + hexString;
    }

    return hexString;
}

function FString_ToBlobUInt32(str) {
    if (4 < (str.length / 3) || str.length % 3 != 0)
    {
        return null;
    }

    var numbers = []
    for (let i = 0; i < str.length; i += 3)
    {
        const numStr = str[i] + str[i+1] + str[i+2];
        const val = parseInt(numStr);
        numbers.push(val);
    }

    var buffer = new Uint8Array(numbers);
    var uint32 = new Uint32Array(buffer.buffer);
    return uint32[0];
}

//console.log(FString_ToBlobUInt32('000000016000'));

exports.FString_ToBlobUInt64 = FString_ToBlobUInt64;
exports.FString_ToBlobUInt32 = FString_ToBlobUInt32;

//console.log(FString_ToBlobUInt64("235224080183233192061046"));

download.js

const fs = require('fs');
const fetch = require('node-fetch');
const zlib = require('zlib');
const crypto = require('crypto');
const {FString_ToBlobUInt64, FString_ToBlobUInt32} = require('./chunk_hash');

const distribution = 'https://epicgames-download1.akamaized.net/';
const fortniteDL = 'Builds/Fortnite/CloudDir/ChunksV3/';

function GetManifest(token) {
    return fetch("https://launcher-public-service-prod06.ol.epicgames.com/launcher/api/public/assets/Windows/4fe75bbc5a674f4f9b356b5c90567da5/Fortnite?label=Live", {
        method: 'GET',
        headers: {
            'Authorization': 'bearer ' + token,
        },
    }).then(r => r.json()).then(v => {
        fs.writeFileSync("./downloader/manifest.json", JSON.stringify(v));
        return v;
    });
}

exports.GetManifest = GetManifest;

function GetChunkManifest(manifest) {
    var url = distribution + manifest.items.MANIFEST.path;
    return fetch(url, {
        method: 'GET',
    }).then(r => r.json()).then(v => {
        fs.writeFileSync("./downloader/chunk_manifest.json", JSON.stringify(v));
        return v;
    });
}

exports.GetChunkManifest = GetChunkManifest;

function GetFileURLs(manifest, file) {
    return file.FileChunkParts.map(v => {
        var chunkHash = FString_ToBlobUInt64(manifest.ChunkHashList[v.Guid]);
        var dataGroup = manifest.DataGroupList[v.Guid].slice(-2);
        return {
            guid: v.Guid,
            url: distribution + fortniteDL + dataGroup + '/' + chunkHash + '_' + v.Guid + '.chunk',
            offset: FString_ToBlobUInt32(v.Offset),
            size: FString_ToBlobUInt32(v.Size),
            hash: chunkHash,
        };
    });
}

function ParseChunkHeader(data, fileData) {
    var reader = new DataReader(data, fileData);
    var magic = reader.readUInt32LE();
    var chunkVersion = reader.readUInt32LE();
    var headerSize = reader.readUInt32LE();
    var dataSize = reader.readUInt32LE();
    var guid = new FGuid(reader);
    var rollingHash = reader.readInt64LE();
    var storedAs = reader.readUInt8();
    var shaHash = reader.readData(20);
    var hashType = reader.readUInt8();

    var result = Buffer.allocUnsafe(dataSize);
    data.copy(result, 0, headerSize, headerSize + dataSize);

    if (storedAs & 0x01) { // Compressed
        var decomData = zlib.inflateSync(result);
        var selectedFile = Buffer.allocUnsafe(fileData.size);
        decomData.copy(selectedFile, 0, fileData.offset, fileData.offset + fileData.size);
        result = selectedFile;
    }

    if (hashType & 0x02) { // Sha1
        const hash = crypto.createHash('sha1');
        hash.update(result);
        // console.log([hash.digest('hex'), fileData.hash, shaHash]);
    }

    if (hashType & 0x01) { // RollingPoly64
        //console.log('rolling poly');
    }

    return result;
}

async function DownloadChunkFile(manifest, file, progress) {
    var fileUrls = GetFileURLs(manifest, file);
    var filename = file.Filename.split('/').pop();
    var fd = fs.openSync('./paks/' + filename, 'a');
    for (var i=0; i < fileUrls.length; i++) {
        var buf = false;
        var chunkPath = './chunks/' + fileUrls[i].hash + '_' + fileUrls[i].guid + '.chunk';
        if (fs.existsSync(chunkPath)) {
            buf = fs.readFileSync(chunkPath);
        } else {
            var req = await fetch(fileUrls[i].url, {
                method: 'GET',
                headers: {
                    'Accept-Encoding': 'br, gzip, deflate',
                }
            });
            var buf = await req.buffer();
            fs.writeFileSync(chunkPath, buf);
        }
        progress(i, fileUrls.length);
        var resBuf = ParseChunkHeader(buf, fileUrls[i]);
        fs.writeSync(fd, resBuf);
    }
    fs.closeSync(fd);
    return filename;
}

exports.DownloadChunkFile = DownloadChunkFile;

class DataReader {
    constructor(data, context) {
        this.data = data;
        this.context = context;
        this.offset = 0;
    }
    readInt32LE() {
        const result = this.data.readInt32LE(this.offset);
        this.offset += 4;
        return result;
    }
    readUInt32LE() {
        const result = this.data.readUInt32LE(this.offset);
        this.offset += 4;
        return result;
    }
    readUInt16LE() {
        const result = this.data.readUInt16LE(this.offset);
        this.offset += 2;
        return result;
    }
    readInt64LE() {
        const result = this.data.readIntLE(this.offset, 6);
        this.offset += 8;
        return result;
    }
    readData(bytes) {
        const result = this.data.slice(this.offset, this.offset + bytes);
        this.offset += bytes;
        return result;
    }
    readString(length) {
        let result = this.data.toString('utf8', this.offset, this.offset + length);
        this.offset += length;
        if (length > 0) {
            result = result.slice(0, -1);
        }
        return result;
    }
    readWString(length) {
        let result = this.data.toString('utf16le', this.offset, this.offset + length * 2);
        this.offset += length * 2;
        if (length > 0) {
            result = result.slice(0, -1);
        }
        return result;
    }
    readBool() {
        const result = this.data.readInt8(this.offset);
        this.offset += 1;
        return result === 1 ? true : false;
    }
    readInt8() {
        const result = this.data.readInt8(this.offset);
        this.offset += 1;
        return result;
    }
    readUInt8() {
        const result = this.data.readUInt8(this.offset);
        this.offset += 1;
        return result;
    }
    readFloatLE() {
        const result = this.data.readFloatLE(this.offset);
        this.offset += 4;
        return result;
    }
    seek(pos) {
        this.offset = pos;
    }
    skip(length) {
        this.offset += length;
    }
    tell() {
        return this.offset;
    }
}

class FGuid {
    constructor(reader) {
        this.A = reader.readUInt32LE();
        this.B = reader.readUInt32LE();
        this.C = reader.readUInt32LE();
        this.D = reader.readUInt32LE();
    }

    toString() {
        return [this.A, this.B, this.C, this.D].map(v => v.toString(16).padStart(8, '0')).join('');
    }
}
PomegranateApps commented 5 years ago

Thanks for posting this! What token is used for GetManifest?

When I try using my access token I get this error. 'Sorry your login does not posses the permissions \'launcher:download:Live:Fortnite READ\' needed to perform the requested operation'

Luc1412 commented 5 years ago

I'm sorry but I can't help you. I never test or used this code. It only was published on the Fortnite Development Discord. The Endpoint could already be outdated.

Terbau commented 4 years ago

Closing this since I dont feel like it fits with fortnitepy out of the box.

QuentinBellus commented 4 years ago

I get that this issue was closed, but in case anyone finds it and still needs to make it work for their own dev purposes:

This is missing the first call to get the right catalog item ID, from: https://launcher-public-service-prod06.ol.epicgames.com/launcher/api/public/assets/Windows?label=Live

(which can in turn be used in GetManifest from the code above) All calls need either a regular fortnite/epic auth token, or no auth at all (target the catalog entries with no queryParams for that)

Feel free to @ me if you need anything, I've been using this process for over a year without problem.

PomegranateApps commented 4 years ago

@QuentinBellus What authentication token do you use? I get this error if I try to use my Epic access token.

Sorry your login does not posses the permissions 'launcher:download:Live:Fortnite READ' needed to perform the requested operation

QuentinBellus commented 4 years ago

@PomegranateApps I use this one, which is a different one from what I use for Fortnite -- It's probably for the launcher itself MzRhMDJjZjhmNDQxNGUyOWIxNTkyMTg3NmRhMzZmOWE6ZGFhZmJjY2M3Mzc3NDUwMzlkZmZlNTNkOTRmYzc2Y2Y=

PomegranateApps commented 4 years ago

@QuentinBellus Thanks for the reply! That one doesn't work for me either.

Sorry we couldn't validate your token MzRhMDJjZjhmNDQxNGUyOWIxNTkyMTg3NmRhMzZmOWE6ZGFhZmJjY2M3Mzc3NDUwMzlkZmZlNTNkOTRmYzc2Y2Y=. Please try with a new token.

QuentinBellus commented 4 years ago

@PomegranateApps I do the regular login process (same flow as used in this lib), except for the call to /account/api/oauth/token I use that token mentionned before. Then you can use the result token for the next calls. If this doesn't work, DM me on discord Quentin#5000