UNPKG

@masvio/downloader

Version:

A simple, lightweight library to easily download files from MASV links

1,488 lines (1,487 loc) 47.3 kB
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 };