@masvio/downloader
Version:
A simple, lightweight library to easily download files from MASV links
1,488 lines (1,487 loc) • 47.3 kB
JavaScript
class T {
constructor(e) {
if (this.ma = 0, this.previousTime = 0, e <= 0)
throw new Error("must provide a timespan > 0 to the moving average constructor");
this.timespan = e;
}
alpha(e, t) {
return 1 - Math.exp(-(e - t) / this.timespan);
}
push(e, t) {
if (this.previousTime > 0) {
const n = this.alpha(e, this.previousTime);
this.ma = n * t + (1 - n) * this.ma;
} else
this.ma = t;
this.previousTime = e;
}
// Exponential Moving Average
movingAverage() {
return this.ma;
}
}
var g = /* @__PURE__ */ ((r) => (r.Start = "download_start", r.Stop = "download_stop", r.Pause = "download_pause", r.Finish = "download_finish", r.Continue = "download_continue", r))(g || {});
const y = 500;
class R {
constructor() {
this.total = 0, this.marks = {
download_start: [],
download_pause: [],
download_continue: [],
download_finish: [],
download_stop: []
}, this.moving = new T(y), this.lastSampleAmount = 0, this.lastSampleTime = Date.now(), this.progress = 0, this.chunkProgress = 0, this.fileProgress = 0, this.instantSpeed = 0, this.movingSpeed = 0, this.totalFiles = 0, this.finalizedFiles = 0, this.duration = 0, this.timeOffset = 0, this.stopped = !0;
}
reset() {
this.total = 0, this.marks = {
download_start: [],
download_pause: [],
download_continue: [],
download_finish: [],
download_stop: []
}, this.moving = new T(y), this.lastSampleAmount = 0, this.lastSampleTime = Date.now(), this.progress = 0, this.chunkProgress = 0, this.fileProgress = 0, this.instantSpeed = 0, this.movingSpeed = 0, this.duration = 0, this.timeOffset = 0, this.stopped = !0, this.totalFiles = 0, this.finalizedFiles = 0;
}
mark(e) {
this.marks[e] && (e === "download_start" || e === "download_continue" ? (this.stopped = !1, this.lastSampleTime = Date.now(), this.record(performance.now() - this.timeOffset)) : (this.stopped = !0, this.timeOffset = this.duration), this.marks[e].push(Date.now()));
}
record(e = performance.now()) {
requestAnimationFrame((t) => {
this.stopped || (this.duration = t - e, this.record(e));
});
}
setTotal(e) {
this.total = e;
}
setTotalFiles(e) {
this.totalFiles = e;
}
addProgress(e = 0) {
this.progress += e;
const t = Date.now();
if (t - this.lastSampleTime < y && this.progress > e) return;
const n = Math.max(t - this.lastSampleTime, y), i = this.progress - this.lastSampleAmount;
this.instantSpeed = 8 * i / (n / 1e3), this.lastSampleAmount = this.progress, this.lastSampleTime = t, this.moving.push(t, this.instantSpeed), this.movingSpeed = this.moving.movingAverage();
}
addFileProgress(e = 0) {
this.finalizedFiles++, this.fileProgress += e;
}
addChunkProgress(e = 0) {
this.chunkProgress += e;
}
subtractProgress(e = 0) {
this.progress -= e, this.lastSampleAmount = this.progress;
}
getStats() {
return {
duration: this.duration,
speed: this.averageSpeed,
instant: this.instantSpeed,
moving: this.movingSpeed,
total: this.total,
progress: this.progress,
chunkProgress: this.chunkProgress,
fileProgress: this.fileProgress,
totalFiles: this.totalFiles,
finalizedFiles: this.finalizedFiles
};
}
// Measured in bps (bits per second)
get averageSpeed() {
return 8 * this.progress / (this.duration / 1e3);
}
}
var o = /* @__PURE__ */ ((r) => (r.Start = "download:start", r.Progress = "download:progress", r.Chunk = "download:chunk", r.File = "download:file", r.Error = "download:error", r.Finished = "download:finish", r.FileQueued = "download:file-queued", r.FileErrored = "download:file-errored", r.FileDownloaded = "download:file-downloaded", r.Abort = "download:abort", r.Retry = "download:retry", r.PartialFinished = "download:partial-finish", r.ParentDirectoryCreated = "download:parent-directory-created", r))(o || {}), c = /* @__PURE__ */ ((r) => (r.Created = "worker:create", r.Cancelled = "worker:cancelled", r.Execute = "worker:execute", r.Finish = "worker:finish", r.FinishFile = "worker:finish-file", r.FinishChunk = "worker:finish-chunk", r.LinkExpired = "worker:link-expired", r.Error = "worker:error", r.Retry = "worker:retry", r.Progress = "worker:progress", r.Terminate = "worker:terminate", r.Abort = "worker:abort", r.ParentDirectoryCreated = "worker:parent-directory-created", r))(c || {});
class v {
constructor() {
this.handlers = {};
}
on(e, t) {
this.handlers[e] || (this.handlers[e] = []), this.handlers[e].push(t);
}
off(e, t) {
if (t == null) {
this.handlers[e] = [];
return;
}
this.handlers[e] && (this.handlers[e] = this.handlers[e].filter(
(n) => n !== t
));
}
clearHandlers() {
this.handlers = {};
}
emit(e, t) {
const n = {
time: Date.now(),
event: e,
target: this,
data: {},
...t
};
this.handlers[e] && this.handlers[e].forEach((i) => {
i(n);
}), this.handlers.emit != null && this.handlers.emit.forEach((i) => {
i(n);
});
}
bubbleEmit(e) {
this.emit(e.event, {
...e
});
}
}
var m = /* @__PURE__ */ ((r) => (r.File = "file", r.Directory = "directory", r.Metadata = "metadata", r.ZipMac = "zip_mac", r.ZipWindows = "zip_windows", r))(m || {});
class f extends Error {
constructor(e) {
super(e), this.name = "AbortError";
}
}
const p = class p {
constructor(e, t) {
this.apiURL = e, this.masvUserAgent = t, this.requestList = {};
}
async request({ url: e, method: t = "GET", headers: n, version: i = "v1", body: s }, a = 0) {
return new Promise((h, w) => {
const u = crypto.randomUUID(), l = new XMLHttpRequest();
if (l.open(t, [this.apiURL, i, e].join("/"), !0), l.setRequestHeader("Accept", "application/json"), l.setRequestHeader("Content-Type", "application/json"), l.setRequestHeader("Masv-User-Agent", this.masvUserAgent), n)
for (const d in n)
n[d] && l.setRequestHeader(d, n[d]);
const E = () => {
const d = Math.min(
p.MAX_TIMEOUT,
Math.pow(p.BACKOFF_FACTOR, a) * p.BASE_TIMEOUT
);
setTimeout(() => {
if (!(u in this.requestList)) {
w(new f("Request aborted"));
return;
}
this.request({ url: e, method: t, headers: n, version: i, body: s }, a + 1).then((b) => h(b)).catch((b) => w(b));
}, d);
};
l.onload = () => {
if (this.shouldRetry(l.status))
E();
else {
u in this.requestList && delete this.requestList[u];
try {
let d;
l.responseText && (d = JSON.parse(l.responseText)), h(d);
} catch (d) {
w(d);
}
}
}, l.onerror = () => {
this.shouldRetry(l.status) ? E() : (u in this.requestList && delete this.requestList[u], w(new Error(`Request failed with status ${l.status}`)));
}, l.onabort = () => {
u in this.requestList && delete this.requestList[u], w(new f("Request aborted"));
}, this.requestList[u] = l, l.send(s);
});
}
fetchLinkDetails(e, t, n, i) {
let s;
return n ? s = { "X-Link-Password": n } : i && (s = { "X-User-Token": i }), this.request({
url: `links/${e}?secret=${t}`,
headers: s
});
}
fetchPackageFiles(e, t) {
return this.request({
url: `packages/${e}/files`,
headers: { "X-Package-Token": t }
});
}
fetchDownloadLink(e, t, n) {
return this.request({
url: `packages/${e}/files/${n}/download`,
headers: { "X-Package-Token": t }
});
}
async fetchDownloadLinks(e, t, n) {
const i = new URLSearchParams({
file_ids: n.join(",")
}).toString();
return (await this.request({
url: `packages/${e}/files/batch/download?${i}`,
headers: { "X-Package-Token": t },
version: "v1.1"
})).records;
}
notifyLinkDownloaded(e, t, n, i) {
let s;
n ? s = { "X-Link-Password": n } : i && (s = { "X-User-Token": i });
const a = JSON.stringify({ secret: t });
return this.request({
url: `links/${e}/complete`,
method: "PUT",
headers: s,
body: a
});
}
notifyError(e, t, n) {
const i = { "X-Package-Token": t }, s = JSON.stringify(n);
return this.request({
url: `packages/${e}/error`,
method: "POST",
headers: i,
body: s
});
}
abortAll() {
for (const e in this.requestList)
this.requestList[e].abort(), delete this.requestList[e];
}
shouldRetry(e) {
return [-1, 0].includes(e) || e >= 500;
}
};
p.BASE_TIMEOUT = 1e3, p.MAX_TIMEOUT = 3e4, p.BACKOFF_FACTOR = 2;
let P = p;
var F = /* @__PURE__ */ ((r) => (r.QueueFile = "QueueFile", r.Abort = "Abort", r.CreateParentDirectory = "CreateParentDirectory", r))(F || {}), S = /* @__PURE__ */ ((r) => (r.Cancel = "Cancel", r.Pause = "Pause", r))(S || {});
const q = `class f extends Error {
constructor(e) {
super(e), this.name = "AbortError";
}
}
const m = class m {
constructor(e, t) {
this.apiURL = e, this.masvUserAgent = t, this.requestList = {};
}
async request({ url: e, method: t = "GET", headers: r, version: s = "v1", body: a }, d = 0) {
return new Promise((p, u) => {
const o = crypto.randomUUID(), l = new XMLHttpRequest();
if (l.open(t, [this.apiURL, s, e].join("/"), !0), l.setRequestHeader("Accept", "application/json"), l.setRequestHeader("Content-Type", "application/json"), l.setRequestHeader("Masv-User-Agent", this.masvUserAgent), r)
for (const n in r)
r[n] && l.setRequestHeader(n, r[n]);
const c = () => {
const n = Math.min(
m.MAX_TIMEOUT,
Math.pow(m.BACKOFF_FACTOR, d) * m.BASE_TIMEOUT
);
setTimeout(() => {
if (!(o in this.requestList)) {
u(new f("Request aborted"));
return;
}
this.request({ url: e, method: t, headers: r, version: s, body: a }, d + 1).then((y) => p(y)).catch((y) => u(y));
}, n);
};
l.onload = () => {
if (this.shouldRetry(l.status))
c();
else {
o in this.requestList && delete this.requestList[o];
try {
let n;
l.responseText && (n = JSON.parse(l.responseText)), p(n);
} catch (n) {
u(n);
}
}
}, l.onerror = () => {
this.shouldRetry(l.status) ? c() : (o in this.requestList && delete this.requestList[o], u(new Error(\`Request failed with status \${l.status}\`)));
}, l.onabort = () => {
o in this.requestList && delete this.requestList[o], u(new f("Request aborted"));
}, this.requestList[o] = l, l.send(a);
});
}
fetchLinkDetails(e, t, r, s) {
let a;
return r ? a = { "X-Link-Password": r } : s && (a = { "X-User-Token": s }), this.request({
url: \`links/\${e}?secret=\${t}\`,
headers: a
});
}
fetchPackageFiles(e, t) {
return this.request({
url: \`packages/\${e}/files\`,
headers: { "X-Package-Token": t }
});
}
fetchDownloadLink(e, t, r) {
return this.request({
url: \`packages/\${e}/files/\${r}/download\`,
headers: { "X-Package-Token": t }
});
}
async fetchDownloadLinks(e, t, r) {
const s = new URLSearchParams({
file_ids: r.join(",")
}).toString();
return (await this.request({
url: \`packages/\${e}/files/batch/download?\${s}\`,
headers: { "X-Package-Token": t },
version: "v1.1"
})).records;
}
notifyLinkDownloaded(e, t, r, s) {
let a;
r ? a = { "X-Link-Password": r } : s && (a = { "X-User-Token": s });
const d = JSON.stringify({ secret: t });
return this.request({
url: \`links/\${e}/complete\`,
method: "PUT",
headers: a,
body: d
});
}
notifyError(e, t, r) {
const s = { "X-Package-Token": t }, a = JSON.stringify(r);
return this.request({
url: \`packages/\${e}/error\`,
method: "POST",
headers: s,
body: a
});
}
abortAll() {
for (const e in this.requestList)
this.requestList[e].abort(), delete this.requestList[e];
}
shouldRetry(e) {
return [-1, 0].includes(e) || e >= 500;
}
};
m.BASE_TIMEOUT = 1e3, m.MAX_TIMEOUT = 3e4, m.BACKOFF_FACTOR = 2;
let E = m;
class T {
constructor(e, t) {
this.directoryHandle = e, this.packageName = t, this.fileWriteStreams = {}, this.filesWaitingForClose = {};
}
async createParentDirectory(e, t) {
let r = await this.getParentDirectoryName(e);
if (!r) {
const s = \`\${t} - \${crypto.randomUUID()}\`;
r = await this.getParentDirectoryName(s);
}
if (!r)
throw new Error("Error when creating parent directory.");
return this.packageName = r, await this.directoryHandle.getDirectoryHandle(r, {
create: !0
}), r;
}
async deleteParentDirectory() {
for (const s in this.fileWriteStreams)
await this.closeWritableFileHandle(s);
this.fileWriteStreams = {};
const e = async (s) => {
try {
return await this.directoryHandle.getDirectoryHandle(s), !0;
} catch {
return !1;
}
}, t = (s) => {
const a = Math.random() * 50;
return new Promise((d) => setTimeout(d, s + a));
}, r = 1e3;
for (let s = 0; s < r; s++)
try {
if (!await e(this.packageName))
break;
await this.directoryHandle.removeEntry(this.packageName, { recursive: !0 });
break;
} catch {
await t(1);
}
}
async createEmptyDirectory(e) {
await (await this.getDirectoryHandle(e)).getDirectoryHandle(e.name, { create: !0 });
}
async getFileHandle(e) {
try {
return await (await this.getDirectoryHandle(e)).getFileHandle(e.name, { create: !0 });
} catch (t) {
throw t;
}
}
async getWritableFileHandle(e, t) {
if (e.id in this.filesWaitingForClose)
throw new f("File is already closing");
if (e.id in this.fileWriteStreams)
return this.fileWriteStreams[e.id];
t || (t = await this.getFileHandle(e));
const r = await t.createWritable();
return this.fileWriteStreams[e.id] = r, r;
}
async closeWritableFileHandle(e) {
try {
if (!(e in this.fileWriteStreams))
return;
e in this.filesWaitingForClose || (this.filesWaitingForClose[e] = new Promise(async (t, r) => {
try {
const s = this.fileWriteStreams[e];
delete this.fileWriteStreams[e], await s.close(), t();
} catch {
t();
}
})), await this.filesWaitingForClose[e], delete this.filesWaitingForClose[e];
} catch (t) {
throw console.warn("Failed to close file:", e, t), delete this.filesWaitingForClose[e], t;
}
}
async deleteFile(e) {
await this.closeWritableFileHandle(e.id), await (await this.getFileHandle(e)).remove();
}
async write(e, t) {
await (await this.getWritableFileHandle(e)).write({
type: "write",
data: t.buffer,
position: t.position,
size: t.size
});
}
async getDirectoryHandle({ path: e }) {
try {
let t;
if (e !== void 0) {
const r = e.split("/");
this.packageName && r.unshift(this.packageName), t = await this.stepThrough(r, this.directoryHandle);
} else this.packageName ? t = await this.stepThrough([this.packageName], this.directoryHandle) : t = this.directoryHandle;
return t;
} catch (t) {
throw t;
}
}
async getParentDirectoryName(e) {
let r = e, s = 0;
for (; s <= 9; ) {
try {
await this.directoryHandle.getDirectoryHandle(r);
} catch (a) {
if (a.name === "NotFoundError") return r;
if (a.name !== "TypeMismatchError") throw a;
}
s++, r = \`\${this.packageName} (\${s})\`;
}
return "";
}
async stepThrough(e, t) {
const r = e.shift();
if (!r) throw new Error("No directory to step into");
const s = await t.getDirectoryHandle(
r,
{ create: !0 }
);
return e.length ? await this.stepThrough(e, s) : s;
}
}
var h = /* @__PURE__ */ ((i) => (i.Created = "worker:create", i.Cancelled = "worker:cancelled", i.Execute = "worker:execute", i.Finish = "worker:finish", i.FinishFile = "worker:finish-file", i.FinishChunk = "worker:finish-chunk", i.LinkExpired = "worker:link-expired", i.Error = "worker:error", i.Retry = "worker:retry", i.Progress = "worker:progress", i.Terminate = "worker:terminate", i.Abort = "worker:abort", i.ParentDirectoryCreated = "worker:parent-directory-created", i))(h || {});
const q = 6;
class P extends Error {
constructor(e) {
super(e), this.name = "ShortReadError";
}
}
const C = /* @__PURE__ */ new Set([
"QuotaExceededError",
// Disk is full
"NoModificationAllowedError",
// Read-only permissions error
"InvalidStateError"
// File(s) changed out of band
]);
async function L({
payload: i
}, e) {
try {
const { directoryHandle: t, packageName: r, packageId: s } = i;
e.fileHandleManager || (e.fileHandleManager = new T(t, r));
const a = await e.fileHandleManager.createParentDirectory(r, s);
e.postMessage({
event: h.ParentDirectoryCreated,
eventPayload: {
parentDirectoryName: a
}
});
} catch (t) {
console.error(
\`Error with download worker when creating parent directory: \${t}\`
), e.postMessage({
event: h.Error,
eventPayload: {
errorMsg: t.message
}
});
}
}
async function U({ payload: i }, e) {
const {
file: t,
directoryHandle: r,
packageName: s,
chunkSize: a,
retryAttempt: d,
finishedChunks: p,
chunkConcurrency: u
} = i;
e.fileHandleManager || (e.fileHandleManager = new T(r, s));
const o = crypto.randomUUID(), l = O(o, t.id, e), c = [];
try {
if (l.aborted) {
delete e.attempts[o];
return;
}
if (t.kind === "directory") {
await e.fileHandleManager.createEmptyDirectory(t), e.postMessage({
event: h.FinishFile,
eventPayload: {
fileId: t.id,
fileSize: 0
}
}), delete e.attempts[o];
return;
}
if (t.virus_detected) {
e.postMessage({
event: h.FinishFile,
eventPayload: {
fileId: t.id,
fileSize: t.size,
errorMsg: \`File with id \${t.id} contains a virus, download prevented.\`
}
}), delete e.attempts[o];
return;
}
const n = await e.fileHandleManager.getFileHandle(t);
if (l.aborted) {
delete e.attempts[o];
return;
}
if (!t.size) {
e.postMessage({
event: h.FinishFile,
eventPayload: {
fileId: t.id,
fileSize: 0
}
}), delete e.attempts[o];
return;
}
await e.fileHandleManager.getWritableFileHandle(t, n);
const y = Math.ceil(t.size / a), w = new Array(y).fill("").map((F, g) => async () => {
if (l.aborted)
return Promise.reject(new f("Download aborted"));
const M = g * a, v = Math.min(t.size - 1, M + a - 1);
return D(
{
file: t,
chunkIndex: g,
start: M,
end: v
},
void 0,
e,
o
).then(() => {
if (l.aborted)
return Promise.reject(new f("Download aborted"));
const H = w.shift();
return H ? H() : Promise.resolve();
});
}).filter((F, g) => !p.has(g) && !x(t.id, g, e));
if (!w.length) {
delete e.attempts[o];
return;
}
const R = u || q, A = Math.min(w.length, R);
for (let F = 0; F < A; F++) {
const g = w.shift();
g && c.push(g());
}
if (await Promise.all(c), l.aborted) {
delete e.attempts[o];
return;
}
await e.fileHandleManager.closeWritableFileHandle(t.id);
let b = "";
(await (await e.fileHandleManager.getFileHandle(t)).getFile()).size !== t.size ? b = "File size on disk does not match the file size on the package" : e.finishedChunks.delete(t.id), e.postMessage({
event: h.FinishFile,
eventPayload: {
fileId: t.id,
fileSize: t.size,
errorMsg: b
}
}), delete e.attempts[o];
} catch (n) {
if (c.length && await Promise.allSettled(c), n instanceof f) {
delete e.attempts[o];
return;
}
if (C.has(n.name)) {
e.postMessage({
event: h.Error,
eventPayload: {
errorMsg: n.message
}
}), delete e.attempts[o];
return;
}
if (n instanceof TypeError && n.message.includes("Failed to fetch")) {
e.postMessage({
event: h.LinkExpired,
eventPayload: {
fileId: t.id,
retryAttempt: d
}
}), delete e.attempts[o];
return;
}
try {
await e.fileHandleManager.deleteFile(t);
} catch (y) {
console.error("Error removing file handle after file error:", y);
}
e.postMessage({
event: h.FinishFile,
eventPayload: {
fileId: t.id,
fileSize: t.size,
errorMsg: n.message
}
}), e.finishedChunks.delete(t.id), delete e.attempts[o];
}
}
async function N({ payload: i }, e) {
const { requestType: t, deleteFiles: r } = i;
for (const s in e.attempts) {
const a = e.attempts[s];
a.aborted = !0, a.abortController.abort();
}
if (t === "Cancel") {
try {
if (e.finishedChunks.clear(), !e.fileHandleManager) return;
r && await e.fileHandleManager.deleteParentDirectory();
} catch {
return;
}
e.postMessage({
event: h.Cancelled,
eventPayload: {}
});
}
}
function O(i, e, t) {
const r = {
id: i,
fileId: e,
aborted: !1,
abortController: new AbortController()
};
for (const s in t.attempts) {
const a = t.attempts[s];
if (!a.aborted && a.fileId === e)
return r.aborted = !0, r.abortController.abort(), t.attempts[i] = r, r;
}
return t.attempts[i] = r, t.currentAttempt = i, r;
}
function $(i) {
return !(i >= 200 && i < 300);
}
function _(i) {
const e = i.message, t = i.status, r = i.name, s = [-1, 0];
return C.has(r) || i instanceof f ? !1 : i instanceof P ? !0 : i instanceof TypeError && e.includes("Failed to fetch") ? !navigator.onLine : i instanceof TypeError && e.includes("network error") ? !0 : t >= 500 || s.includes(t);
}
async function z(i, e, t = 0, r, s, a, d) {
if (!i.body)
throw new Error("Response body is missing");
const p = i.body.getReader({ mode: "byob" });
let u = new ArrayBuffer(r), o = 0;
const l = d.attempts[a];
if (!l || l.aborted)
throw new f("Download aborted");
if (d.fileHandleManager)
try {
for (; ; ) {
const { done: c, value: n } = await p.read(
new Uint8Array(u, o, u.byteLength - o)
);
if (l.aborted)
throw new f("Download aborted");
if (c)
throw new P("Short read");
if (d.postMessage({
event: h.Progress,
eventPayload: { fileId: e.id, chunkIndex: s, transferred: n.byteLength }
}), u = n.buffer, o += n.byteLength, o >= r) {
if (await d.fileHandleManager.write(e, {
buffer: u,
position: t,
size: r
}), l.aborted)
throw new f("Download aborted");
X(e.id, s, d), d.postMessage({
event: h.FinishChunk,
eventPayload: { fileId: e.id, chunkIndex: s, chunkSize: r }
});
break;
}
}
} catch (c) {
throw c.name !== "AbortError" && c.name !== "ShortReadError" && console.error("Error with read stream", c), d.postMessage({
event: h.Abort,
eventPayload: {
fileId: e.id,
chunkIndex: s,
lostProgress: o
}
}), c;
}
}
function X(i, e, t) {
let r = t.finishedChunks.get(i);
r || (r = /* @__PURE__ */ new Set()), r.add(e), t.finishedChunks.set(i, r);
}
function x(i, e, t) {
const r = t.finishedChunks.get(i);
return r ? r.has(e) : !1;
}
async function D({
file: i,
chunkIndex: e,
start: t,
end: r
}, s = 0, a, d) {
if (!a.fileHandleManager) return;
const l = a.attempts[d];
if (!l.aborted)
try {
const c = await fetch(i.url, {
keepalive: !0,
headers: {
range: \`bytes=\${t}-\${r}\`
},
signal: l.abortController.signal
});
if ($(c.status)) {
const n = new Error(
\`Request failed with status: \${c.status}\`
);
throw n.status = c.status, n;
} else if (c.status === 200 || c.status === 206) {
const n = r - t + 1;
await z(
c,
i,
t,
n,
e,
d,
a
);
return;
} else {
const n = new Error(
\`Unexpected response status code: \${c.status}\`
);
throw n.status = c.status, n;
}
} catch (c) {
if (l.aborted || c.name === "AbortError")
return;
if (!_(c))
throw console.error(
\`Error cannot be retried for chunk at range \${t}-\${r}: \${c.message}\`
), c;
let n = Math.pow(2, s) * 1e3;
return n > 3e4 && (n = 3e4), console.error(\`Error: \${c.message} for file \${i.id}. Retry Count: \${s}\`), a.postMessage({
event: h.Retry,
eventPayload: {
fileId: i.id,
chunkIndex: e,
errorMsg: c.message
}
}), new Promise((y) => {
setTimeout(() => {
y(
D(
{ file: i, chunkIndex: e, start: t, end: r },
s + 1,
a,
d
)
);
}, n);
});
}
}
const W = {
attempts: {},
currentAttempt: "",
fileHandleManager: void 0,
finishedChunks: /* @__PURE__ */ new Map(),
postMessage: self.postMessage.bind(self)
};
self.onmessage = async ({
data: i
}) => {
const { message: e, payload: t } = i, r = {
QueueFile: U,
Abort: N,
CreateParentDirectory: L
};
r[e] && await r[e]({ payload: t }, W);
};
`, M = typeof self < "u" && self.Blob && new Blob(["URL.revokeObjectURL(import.meta.url);", q], { type: "text/javascript;charset=utf-8" });
function H(r) {
let e;
try {
if (e = M && (self.URL || self.webkitURL).createObjectURL(M), !e) throw "";
const t = new Worker(e, {
type: "module",
name: r?.name
});
return t.addEventListener("error", () => {
(self.URL || self.webkitURL).revokeObjectURL(e);
}), t;
} catch {
return new Worker(
"data:text/javascript;charset=utf-8," + encodeURIComponent(q),
{
type: "module",
name: r?.name
}
);
}
}
class _ extends v {
constructor() {
super(), this.terminated = !1, this.onNativeWorkerMessage = ({ data: e }) => {
const { event: t, eventPayload: n } = e;
this.emit(t, { event: t, data: n });
}, this.emit(c.Created), this.startWorker();
}
startWorker() {
this.nativeWorker = new H(), this.nativeWorker.onmessage = this.onNativeWorkerMessage;
}
abort(e) {
const t = {
message: F.Abort,
payload: e
};
this.nativeWorker.postMessage(t);
}
createParentDirectory(e) {
const t = {
message: F.CreateParentDirectory,
payload: e
};
this.nativeWorker.postMessage(t);
}
terminate() {
this.terminated = !0, this.emit(c.Terminate), this.clearHandlers(), this.nativeWorker.terminate();
}
queueFile(e) {
if (this.terminated)
throw new Error("Can't use a terminated worker");
this.emit(c.Execute, {
event: c.Execute,
data: {
requestPayload: e
}
});
const t = {
message: F.QueueFile,
payload: e
};
this.nativeWorker.postMessage(t);
}
}
class z extends v {
constructor(e, t, n = 6, i = 6) {
super(), this.packageId = e, this.chunkSize = t, this.poolSize = n, this.chunkConcurrency = i, this.workers = new Array(), this.terminated = !1;
let s;
for (let a = 0; a < this.poolSize; a++)
s = new _(), s.on("emit", this.bubbleEmit.bind(this)), this.workers.push(s);
}
prepareDownload(e, t, n) {
for (const s of this.workers)
s.startWorker();
if (n) return;
const i = {
directoryHandle: e,
packageName: t,
packageId: this.packageId
};
this.workers[0].createParentDirectory(i);
}
queueFile(e, t, n, i, s = 0) {
if (this.terminated)
throw new D();
const a = this.workers[e.index % this.workers.length], h = {
file: e.file,
directoryHandle: n,
packageName: i,
chunkSize: this.chunkSize,
retryAttempt: s,
finishedChunks: t,
chunkConcurrency: this.chunkConcurrency
};
a.queueFile(h);
}
abort(e, t, n, i) {
const s = {
requestType: e,
directoryHandle: t,
packageName: n,
deleteFiles: i
};
this.workers.forEach((a) => {
a.abort(s);
});
}
terminate() {
this.terminated = !0, this.workers.forEach((e) => e.terminate()), this.clearHandlers();
}
get workerPoolSize() {
return this.poolSize;
}
}
class U {
constructor() {
this.queue = {};
}
getFile(e) {
return this.queue[e];
}
addFile(e) {
const t = new N(e);
return this.queue[e.fileId] = t, t;
}
removeFile(e) {
delete this.queue[e];
}
setProgress(e, t, n) {
const i = this.getFile(e);
i && i.addChunkProgress(t, n);
}
get length() {
return Object.keys(this.queue).length;
}
}
class N {
constructor({ fileId: e, index: t, file: n, chunkSize: i }) {
this.chunkProgress = {}, this.finishedChunks = /* @__PURE__ */ new Set(), this.id = e, this.file = n, this.index = t, n.size ? this.chunkSize = i > n.size ? n.size : i : this.chunkSize = i;
}
get totalProgress() {
return Object.values(this.chunkProgress).reduce((e, t) => e + t, 0);
}
getChunkProgress(e) {
return this.chunkProgress[e];
}
clearChunkProgress(e) {
this.chunkProgress[e] = 0;
}
writeChunk(e) {
this.finishedChunks.add(e);
}
addChunkProgress(e, t) {
e in this.chunkProgress || (this.chunkProgress[e] = 0), this.chunkProgress[e] += t;
}
}
const x = "masv-web", O = "masv-web-downloader", $ = "dev", W = 100 * 1024 * 1024, C = 6, Q = C, j = 1, X = 100, L = 300, B = 200, G = 100;
var A = /* @__PURE__ */ ((r) => (r.Init = "init", r.Idle = "idle", r.Downloading = "downloading", r.Ready = "ready", r.Paused = "paused", r.Finished = "finished", r.PartialFinished = "partial-finished", r.Errored = "errored", r.Terminated = "terminated", r.Cancelled = "cancelled", r))(A || {});
const k = class k extends v {
constructor(e, t = "", n = "", i = "https://api.massive.app", s = W, a = `${x}/${$}`) {
super(), this.linkPassword = t, this.userToken = n, this.chunkSize = s, this.version = "1.0.4", this.packageID = "", this.packageToken = "", this.packageName = "", this.packageSize = 0, this.directoryHandle = null, this.fileQueue = [], this.fileCount = 0, this.isPartialDownload = !1, this.progressTracker = new R(), this.filesFinished = 0, this.currentFileIndex = 0, this.currentFetchedFileIndex = 0, this.fileMap = {}, this.pendingLinkQueue = [], this.requestingLinks = !1, this.erroredFiles = [], this.fileConcurrency = this.getFileConcurrency(), this.chunkConcurrency = this.getChunkConcurrency(), this.status = "init", this.downloadState = new U(), this.masvUserAgent = `${a}+${O}/${this.version}`;
try {
this.apiClient = new P(i, this.masvUserAgent);
const h = new URL(e);
if (this.linkID = h.pathname.slice(1), this.linkSecret = h.searchParams.get("secret") ?? "", !this.linkSecret || !this.linkID)
throw new Error("Invalid download link");
} catch (h) {
throw this.status = "terminated", h;
}
}
async initialize() {
try {
await this.getPackageInfo(), await this.loadFiles();
const e = this.packageSize / this.fileCount;
this.chunkConcurrency = this.getChunkConcurrency(e), this.fileConcurrency = this.getFileConcurrency(e), this.fdownloader = new z(
this.packageID,
this.chunkSize,
this.fileConcurrency,
this.chunkConcurrency
), this.fdownloader.on(
c.FinishFile,
this.onFileFinish.bind(this)
), this.fdownloader.on(
c.FinishChunk,
this.onChunkFinish.bind(this)
), this.fdownloader.on(
c.Progress,
this.onFileProgress.bind(this)
), this.fdownloader.on(c.Error, this.onDownloadError.bind(this)), this.fdownloader.on(c.Retry, this.onDownloadRetry.bind(this)), this.fdownloader.on(c.Abort, this.onDownloadAbort.bind(this)), this.fdownloader.on(
c.ParentDirectoryCreated,
this.onCreateParentDirectory.bind(this)
), this.fdownloader.on(
c.Cancelled,
this.onDownloadCancelled.bind(this)
), this.fdownloader.on(
c.LinkExpired,
this.onDownloadLinkExpired.bind(this)
), this.fdownloader.on("emit", this.bubbleEmit.bind(this));
} catch (e) {
this.handleError(e.message), this.cancel(), this.terminate();
}
}
changeStatus(e) {
this.status = e, this.emit(this.status);
}
async onCreateParentDirectory(e) {
const { data: t } = e;
this.parentDirectoryName = t.parentDirectoryName, this.emit(o.ParentDirectoryCreated, {
event: o.ParentDirectoryCreated,
data: {
parentDirectoryName: this.parentDirectoryName
}
}), await this.startDownload();
}
onDownloadCancelled(e) {
this.changeStatus(
"cancelled"
/* Cancelled */
);
}
async onFileFinish(e) {
try {
const { data: t } = e, { fileId: n, fileSize: i, errorMsg: s } = t;
this.filesFinished++;
const a = this.downloadState.getFile(n);
if (s ? (console.error(`Error downloading file ${n}: ${s}`), a?.totalProgress && this.progressTracker.subtractProgress(a?.totalProgress), this.erroredFiles.push(this.fileMap[n]), this.emit(o.FileErrored, {
event: o.FileErrored,
data: {
fileId: n,
errorMsg: s
}
})) : (this.progressTracker.addFileProgress(i), this.emit(o.FileDownloaded, {
event: o.FileDownloaded,
data: {
fileId: n
}
})), a && this.downloadState.removeFile(a.id), this.filesFinished != this.fileCount) {
await this.processQueue();
return;
}
if (this.progressTracker.mark(g.Finish), this.erroredFiles.length) {
this.emit(o.PartialFinished, {
event: o.PartialFinished,
data: {
performanceStats: this.performanceStats,
failedFiles: this.erroredFiles
}
}), this.changeStatus(
"partial-finished"
/* PartialFinished */
);
const h = {
error_code: "partial_download",
error_message: "Some files failed to be downloaded",
event_time: (/* @__PURE__ */ new Date()).toISOString(),
extras: this.progressSummary
};
this.apiClient.notifyError(
this.packageID,
this.packageToken,
h
);
return;
}
this.emit(o.Finished, {
event: o.Finished,
data: {
performanceStats: this.performanceStats
}
}), this.isPartialDownload || await this.apiClient.notifyLinkDownloaded(
this.linkID,
this.linkSecret,
this.linkPassword,
this.userToken
).catch((h) => console.error(h)), this.terminate(), this.changeStatus(
"finished"
/* Finished */
);
} catch (t) {
if (t instanceof f)
return;
this.handleError(t.message), this.cancel(), this.terminate();
}
}
onChunkFinish(e) {
const { data: t } = e, { fileId: n, chunkIndex: i, chunkSize: s } = t, a = this.downloadState.getFile(n);
a && (this.downloadState.setProgress(n, i, s), a.writeChunk(i), this.progressTracker.addChunkProgress(s));
}
onFileProgress(e) {
const { data: t } = e, { transferred: n } = t;
this.progressTracker.addProgress(n), this.emit(o.Progress, {
event: o.Progress,
data: { performanceStats: this.performanceStats }
});
}
onDownloadError(e) {
const { data: t } = e, { errorMsg: n } = t;
this.handleError(n), this.cancel(), this.terminate();
}
onDownloadRetry(e) {
const { data: t } = e, { errorMsg: n } = t;
this.emit(o.Retry, {
event: o.Retry,
data: {
performanceStats: this.performanceStats,
errorMsg: n
}
});
}
onDownloadAbort(e) {
const { data: t } = e, { fileId: n, lostProgress: i } = t;
i > 0 && this.progressTracker.subtractProgress(i), this.emit(o.Abort, {
event: o.Abort,
data: {
fileId: n
}
});
}
async onDownloadLinkExpired(e) {
try {
const { data: t } = e, { fileId: n, retryAttempt: i } = t, s = await this.apiClient.fetchDownloadLink(
this.packageID,
this.packageToken,
n
), a = this.downloadState.getFile(n);
if (!a) return;
this.downloadState.removeFile(a.id), a.file.url = s.url, await this.processQueue(a, i + 1);
} catch (t) {
if (t instanceof f)
return;
this.handleError(t.message), this.cancel(), this.terminate();
}
}
get canDownloadFile() {
return this.status === "ready" || this.status === "downloading";
}
get allDownloadLinksFetched() {
return this.currentFetchedFileIndex >= Object.values(this.fileMap).length;
}
isLowFileDensity(e) {
return e <= 0.1 * this.chunkSize;
}
isHighFileDensity(e) {
return e >= 10 * this.chunkSize;
}
getFileConcurrency(e) {
if (e) {
if (this.isLowFileDensity(e))
return C;
if (this.isHighFileDensity(e))
return 1;
}
return Q;
}
getChunkConcurrency(e) {
if (e) {
if (this.isLowFileDensity(e))
return 1;
if (this.isHighFileDensity(e))
return C;
}
return j;
}
async fetchMetaDataLinks() {
const t = Object.values(this.fileMap).filter((n) => n.kind === m.Metadata).map((n) => n.id);
if (t.length)
for (const n of t) {
const i = await this.apiClient.fetchDownloadLink(
this.packageID,
this.packageToken,
n
);
this.pendingLinkQueue.push({ fileId: n, url: i.url });
}
}
chunkFile(e, t) {
const n = Math.min(t, e.size);
return {
fileId: e.id,
index: this.currentFileIndex,
file: e,
chunkSize: n
};
}
// If given file state, process the given file state
// Otherwise, process the next file in the queue
async processQueue(e, t = 0) {
if (!this.canDownloadFile) return;
if (!this.directoryHandle || !this.parentDirectoryName)
throw new Error("No directory provided.");
let n, i;
if (e)
i = e.file, n = {
fileId: i.id,
index: e.index,
file: i,
chunkSize: this.chunkSize
};
else {
const a = this.fileQueue.pop();
if (!a) return;
i = a, n = this.chunkFile(i, this.chunkSize);
}
this.emit(o.FileQueued, {
event: o.FileQueued,
data: {
fileId: i.id
}
});
const s = e ? e.finishedChunks : /* @__PURE__ */ new Set();
this.fdownloader.queueFile(
n,
s,
this.directoryHandle,
this.parentDirectoryName,
t
), this.downloadState.addFile(n), e || this.currentFileIndex++, await this.fillFileQueue();
}
queueEmptyFilesAndDirectories() {
const e = Object.values(this.fileMap).filter(
(t) => t.kind === m.Directory || t.kind === m.File && !t.size
);
this.fileQueue = this.fileQueue.concat(e);
}
async fetchDownloadLinks(e) {
if (this.requestingLinks || (this.requestingLinks = !0, this.pendingLinkQueue.length >= L)) return;
const t = Math.min(Object.values(this.fileMap).length, e), n = Object.values(this.fileMap);
let i = this.currentFetchedFileIndex + t;
i > n.length && (i = n.length);
const s = n.filter((h) => h.kind === m.File && h.size).slice(this.currentFetchedFileIndex, i).map((h) => h.id);
if (this.currentFetchedFileIndex += e, !s.length) return;
const a = await this.apiClient.fetchDownloadLinks(
this.packageID,
this.packageToken,
s
);
for (const h of a)
this.pendingLinkQueue.push({ fileId: h.id, url: h.url });
this.requestingLinks = !1;
}
async fillFileQueue() {
for (; this.fileQueue.length < G; ) {
const e = this.pendingLinkQueue.pop();
if (!e) return;
const t = {
...this.fileMap[e.fileId],
url: e.url
};
this.fileQueue.push(t);
}
!this.allDownloadLinksFetched && this.pendingLinkQueue.length < B && await this.fetchDownloadLinks(X);
}
async prepareDownload(e, t) {
this.filesFinished = 0, this.currentFileIndex = 0, this.currentFetchedFileIndex = 0, this.progressTracker.reset(), this.progressTracker.setTotal(this.totalSize), this.progressTracker.setTotalFiles(this.fileCount), this.erroredFiles = [], this.queueEmptyFilesAndDirectories(), await this.fetchMetaDataLinks(), await this.fetchDownloadLinks(L - this.pendingLinkQueue.length), await this.fillFileQueue(), this.directoryHandle = e, this.progressTracker.mark(g.Start), this.fdownloader.prepareDownload(
e,
this.parentDirectoryName,
t
);
}
async getPackageInfo() {
const e = await this.apiClient.fetchLinkDetails(
this.linkID,
this.linkSecret,
this.linkPassword,
this.userToken
);
return this.packageID = e.package_id, this.packageName = e.name, this.packageToken = e.package_token, this.packageSize = e.package_size, e;
}
handleError(e) {
console.error(e), this.changeStatus(
"errored"
/* Errored */
), this.emit(o.Error, {
event: o.Error,
data: {
performanceStats: this.performanceStats,
errorMsg: e
}
});
const t = {
error_code: "download_error",
error_message: e,
event_time: (/* @__PURE__ */ new Date()).toISOString(),
extras: this.progressSummary
};
this.apiClient.notifyError(
this.packageID,
this.packageToken,
t
);
}
pause() {
try {
if (this.status === "paused" || !this.directoryHandle || !this.parentDirectoryName) return;
this.changeStatus(
"paused"
/* Paused */
), this.progressTracker.mark(g.Pause), this.fdownloader.abort(
S.Pause,
this.directoryHandle,
this.parentDirectoryName,
!1
), this.apiClient.abortAll();
} catch (e) {
if (e instanceof f)
return;
this.handleError(e.message), this.cancel(), this.terminate();
}
}
cancel(e = !1) {
try {
if (this.status === "cancelled" || !this.directoryHandle || !this.parentDirectoryName) return;
this.changeStatus(
"cancelled"
/* Cancelled */
), this.progressTracker.mark(g.Pause), this.fdownloader.abort(
S.Cancel,
this.directoryHandle,
this.parentDirectoryName,
e
), this.apiClient.abortAll();
const t = {
error_code: "download_cancelled",
error_message: "The download was manually cancelled by the user",
event_time: (/* @__PURE__ */ new Date()).toISOString(),
extras: this.progressSummary
};
this.apiClient.notifyError(
this.packageID,
this.packageToken,
t
);
} catch (t) {
if (t instanceof f)
return;
this.handleError(t.message), this.terminate();
}
}
stop() {
this.progressTracker.mark(g.Stop), this.changeStatus(
"idle"
/* Idle */
);
}
terminate() {
try {
if (this.status === "terminated") return;
this.changeStatus(
"terminated"
/* Terminated */
), this.clearHandlers(), this.fdownloader.terminate();
} catch (e) {
this.handleError(e.message);
}
}
async loadFiles() {
try {
let e = await this.apiClient.fetchPackageFiles(
this.packageID,
this.packageToken
);
e || (e = []);
const t = {};
for (const n of e)
t[n.id] = n;
return this.fileMap = t, this.fileCount = Object.values(this.fileMap).filter(
(n) => !n.kind.includes("zip")
).length, e;
} catch (e) {
throw this.handleError(e.message), this.cancel(), this.terminate(), e;
}
}
async retry() {
try {
if (this.status != "partial-finished") return;
this.changeStatus(
"downloading"
/* Downloading */
);
const e = {};
for (const t of this.erroredFiles)
e[t.id] = t;
this.fileMap = e, this.fileCount = Object.values(this.fileMap).filter(
(t) => !t.kind.includes("zip")
).length, await this.prepareDownload(this.directoryHandle, !0), await this.startDownload();
} catch (e) {
if (e instanceof f)
return;
this.handleError(e.message), this.cancel(), this.terminate();
}
}
async start(e, t) {
const n = this.status === "init";
try {
if (this.status === "downloading") return;
if (this.changeStatus(
"downloading"
/* Downloading */
), this.parentDirectoryName || (this.parentDirectoryName = this.packageName), !n) {
this.progressTracker.mark(g.Continue);
const i = Object.values(this.downloadState.queue);
for (const a of i)
await this.processQueue(a);
const s = Math.min(
this.fileConcurrency,
Object.values(this.fileMap).length
);
for (let a = 0; a < s - i.length; a++)
await this.processQueue();
return;
}
if (t?.length) {
const i = this.fileCount, s = {};
for (const a of t)
s[a.id] = a;
this.fileMap = s, this.fileCount = Object.values(this.fileMap).filter(
(a) => !a.kind.includes("zip")
).length, this.fileCount < i && (this.isPartialDownload = !0);
}
await this.prepareDownload(e);
} catch (i) {
if (i instanceof f)
return;
this.handleError(i.message), this.cancel(), this.terminate();
}
}
async startDownload() {
const e = Math.min(
this.fileConcurrency,
Object.values(this.fileMap).length
);
for (let t = 0; t < e; t++)
await this.processQueue();
}
get totalSize() {
return Object.values(this.fileMap).reduce((t, n) => t + (n.size || 0), 0);
}
get performanceStats() {
return this.progressTracker.getStats();
}
get progressSummary() {
const e = this.performanceStats;
return {
finalizedBytes: String(e.fileProgress),
finalizedFiles: String(e.finalizedFiles),
totalBytes: String(e.total),
totalFiles: String(e.totalFiles),
erroredFiles: String(this.erroredFiles.length)
};
}
};
k.DownloaderEvents = o, k.Emit = "emit", k.States = A;
let I = k;
class D extends Error {
constructor() {
super("Terminated"), Object.setPrototypeOf(this, D.prototype);
}
}
export {
I as Downloader,
A as DownloaderStates,
D as TerminatedError
};