diff --git a/frontend/src/api/search.ts b/frontend/src/api/search.ts index 871f0aed..6fa02d06 100644 --- a/frontend/src/api/search.ts +++ b/frontend/src/api/search.ts @@ -13,7 +13,7 @@ export default async function search(base: string, query: string) { let data = await res.json(); - data = data.map((item: UploadItem) => { + data = data.map((item: ResourceItem & { dir: boolean }) => { item.url = `/files${base}` + url.encodePath(item.path); if (item.dir) { diff --git a/frontend/src/api/tus.ts b/frontend/src/api/tus.ts index 8bffa6a5..d6601166 100644 --- a/frontend/src/api/tus.ts +++ b/frontend/src/api/tus.ts @@ -1,17 +1,11 @@ import * as tus from "tus-js-client"; import { baseURL, tusEndpoint, tusSettings, origin } from "@/utils/constants"; import { useAuthStore } from "@/stores/auth"; -import { useUploadStore } from "@/stores/upload"; import { removePrefix } from "@/api/utils"; const RETRY_BASE_DELAY = 1000; const RETRY_MAX_DELAY = 20000; -const SPEED_UPDATE_INTERVAL = 1000; -const ALPHA = 0.2; -const ONE_MINUS_ALPHA = 1 - ALPHA; -const RECENT_SPEEDS_LIMIT = 5; -const MB_DIVISOR = 1024 * 1024; -const CURRENT_UPLOAD_LIST: CurrentUploadList = {}; +const CURRENT_UPLOAD_LIST: { [key: string]: tus.Upload } = {}; export async function upload( filePath: string, @@ -56,11 +50,12 @@ export async function upload( return true; }, onError: function (error: Error | tus.DetailedError) { - if (CURRENT_UPLOAD_LIST[filePath].interval) { - clearInterval(CURRENT_UPLOAD_LIST[filePath].interval); - } delete CURRENT_UPLOAD_LIST[filePath]; + if (error.message === "Upload aborted") { + return reject(error); + } + const message = error instanceof tus.DetailedError ? error.originalResponse === null @@ -73,40 +68,16 @@ export async function upload( reject(new Error(message)); }, onProgress: function (bytesUploaded) { - const fileData = CURRENT_UPLOAD_LIST[filePath]; - fileData.currentBytesUploaded = bytesUploaded; - - if (!fileData.hasStarted) { - fileData.hasStarted = true; - fileData.lastProgressTimestamp = Date.now(); - - fileData.interval = window.setInterval(() => { - calcProgress(filePath); - }, SPEED_UPDATE_INTERVAL); - } if (typeof onupload === "function") { onupload({ loaded: bytesUploaded }); } }, onSuccess: function () { - if (CURRENT_UPLOAD_LIST[filePath].interval) { - clearInterval(CURRENT_UPLOAD_LIST[filePath].interval); - } delete CURRENT_UPLOAD_LIST[filePath]; resolve(); }, }); - CURRENT_UPLOAD_LIST[filePath] = { - upload: upload, - recentSpeeds: [], - initialBytesUploaded: 0, - currentBytesUploaded: 0, - currentAverageSpeed: 0, - lastProgressTimestamp: null, - sumOfRecentSpeeds: 0, - hasStarted: false, - interval: undefined, - }; + CURRENT_UPLOAD_LIST[filePath] = upload; upload.start(); }); } @@ -138,76 +109,11 @@ function isTusSupported() { return tus.isSupported === true; } -function computeETA(speed?: number) { - const state = useUploadStore(); - if (state.speedMbyte === 0) { - return Infinity; - } - const totalSize = state.sizes.reduce( - (acc: number, size: number) => acc + size, - 0 - ); - const uploadedSize = state.progress.reduce((a, b) => a + b, 0); - const remainingSize = totalSize - uploadedSize; - const speedBytesPerSecond = (speed ?? state.speedMbyte) * 1024 * 1024; - return remainingSize / speedBytesPerSecond; -} - -function computeGlobalSpeedAndETA() { - let totalSpeed = 0; - let totalCount = 0; - - for (const filePath in CURRENT_UPLOAD_LIST) { - totalSpeed += CURRENT_UPLOAD_LIST[filePath].currentAverageSpeed; - totalCount++; - } - - if (totalCount === 0) return { speed: 0, eta: Infinity }; - - const averageSpeed = totalSpeed / totalCount; - const averageETA = computeETA(averageSpeed); - - return { speed: averageSpeed, eta: averageETA }; -} - -function calcProgress(filePath: string) { - const uploadStore = useUploadStore(); - const fileData = CURRENT_UPLOAD_LIST[filePath]; - - const elapsedTime = - (Date.now() - (fileData.lastProgressTimestamp ?? 0)) / 1000; - const bytesSinceLastUpdate = - fileData.currentBytesUploaded - fileData.initialBytesUploaded; - const currentSpeed = bytesSinceLastUpdate / MB_DIVISOR / elapsedTime; - - if (fileData.recentSpeeds.length >= RECENT_SPEEDS_LIMIT) { - fileData.sumOfRecentSpeeds -= fileData.recentSpeeds.shift() ?? 0; - } - - fileData.recentSpeeds.push(currentSpeed); - fileData.sumOfRecentSpeeds += currentSpeed; - - const avgRecentSpeed = - fileData.sumOfRecentSpeeds / fileData.recentSpeeds.length; - fileData.currentAverageSpeed = - ALPHA * avgRecentSpeed + ONE_MINUS_ALPHA * fileData.currentAverageSpeed; - - const { speed, eta } = computeGlobalSpeedAndETA(); - uploadStore.setUploadSpeed(speed); - uploadStore.setETA(eta); - - fileData.initialBytesUploaded = fileData.currentBytesUploaded; - fileData.lastProgressTimestamp = Date.now(); -} - export function abortAllUploads() { for (const filePath in CURRENT_UPLOAD_LIST) { - if (CURRENT_UPLOAD_LIST[filePath].interval) { - clearInterval(CURRENT_UPLOAD_LIST[filePath].interval); - } - if (CURRENT_UPLOAD_LIST[filePath].upload) { - CURRENT_UPLOAD_LIST[filePath].upload.abort(true); - CURRENT_UPLOAD_LIST[filePath].upload.options!.onError!( + if (CURRENT_UPLOAD_LIST[filePath]) { + CURRENT_UPLOAD_LIST[filePath].abort(true); + CURRENT_UPLOAD_LIST[filePath].options!.onError!( new Error("Upload aborted") ); } diff --git a/frontend/src/components/prompts/UploadFiles.vue b/frontend/src/components/prompts/UploadFiles.vue index eb599aa8..883fffe3 100644 --- a/frontend/src/components/prompts/UploadFiles.vue +++ b/frontend/src/components/prompts/UploadFiles.vue @@ -1,20 +1,25 @@ - diff --git a/frontend/src/stores/upload.ts b/frontend/src/stores/upload.ts index 8429146e..53d96ea9 100644 --- a/frontend/src/stores/upload.ts +++ b/frontend/src/stores/upload.ts @@ -1,8 +1,9 @@ import { defineStore } from "pinia"; import { useFileStore } from "./file"; import { files as api } from "@/api"; -import { throttle } from "lodash-es"; import buttons from "@/utils/buttons"; +import { computed, inject, markRaw, ref } from "vue"; +import * as tus from "@/api/tus"; // TODO: make this into a user setting const UPLOADS_LIMIT = 5; @@ -13,208 +14,167 @@ const beforeUnload = (event: Event) => { // event.returnValue = ""; }; -// Utility function to format bytes into a readable string -function formatSize(bytes: number): string { - if (bytes === 0) return "0.00 Bytes"; +export const useUploadStore = defineStore("upload", () => { + const $showError = inject("$showError")!; - const k = 1024; - const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; - const i = Math.floor(Math.log(bytes) / Math.log(k)); + let progressInterval: number | null = null; - // Return the rounded size with two decimal places - return (bytes / k ** i).toFixed(2) + " " + sizes[i]; -} + // + // STATE + // -export const useUploadStore = defineStore("upload", { - // convert to a function - state: (): { - id: number; - sizes: number[]; - progress: number[]; - queue: UploadItem[]; - uploads: Uploads; - speedMbyte: number; - eta: number; - error: Error | null; - } => ({ - id: 0, - sizes: [], - progress: [], - queue: [], - uploads: {}, - speedMbyte: 0, - eta: 0, - error: null, - }), - getters: { - // user and jwt getter removed, no longer needed - getProgress: (state) => { - if (state.progress.length === 0) { - return 0; + const allUploads = ref([]); + const activeUploads = ref>(new Set()); + const lastUpload = ref(-1); + const totalBytes = ref(0); + const sentBytes = ref(0); + + // + // ACTIONS + // + + const upload = ( + path: string, + name: string, + file: File | null, + overwrite: boolean, + type: ResourceType + ) => { + if (!hasActiveUploads() && !hasPendingUploads()) { + window.addEventListener("beforeunload", beforeUnload); + buttons.loading("upload"); + } + + const upload: Upload = { + path, + name, + file, + overwrite, + type, + totalBytes: file?.size || 1, + sentBytes: 0, + // Stores rapidly changing sent bytes value without causing component re-renders + rawProgress: markRaw({ + sentBytes: 0, + }), + }; + + totalBytes.value += upload.totalBytes; + allUploads.value.push(upload); + + processUploads(); + }; + + const abort = () => { + // Resets the state by preventing the processing of the remaning uploads + lastUpload.value = Infinity; + tus.abortAllUploads(); + }; + + // + // GETTERS + // + + const pendingUploadCount = computed( + () => + allUploads.value.length - + (lastUpload.value + 1) + + activeUploads.value.size + ); + + // + // PRIVATE FUNCTIONS + // + + const hasActiveUploads = () => activeUploads.value.size > 0; + + const hasPendingUploads = () => + allUploads.value.length > lastUpload.value + 1; + + const isActiveUploadsOnLimit = () => activeUploads.value.size < UPLOADS_LIMIT; + + const processUploads = async () => { + if (!hasActiveUploads() && !hasPendingUploads()) { + const fileStore = useFileStore(); + window.removeEventListener("beforeunload", beforeUnload); + buttons.success("upload"); + reset(); + fileStore.reload = true; + } + + if (isActiveUploadsOnLimit() && hasPendingUploads()) { + if (!hasActiveUploads()) { + // Update the state in a fixed time interval + progressInterval = window.setInterval(syncState, 1000); } - const totalSize = state.sizes.reduce((a, b) => a + b, 0); - const sum = state.progress.reduce((a, b) => a + b, 0); - return Math.ceil((sum / totalSize) * 100); - }, - getProgressDecimal: (state) => { - if (state.progress.length === 0) { - return 0; + const upload = nextUpload(); + + if (upload.type === "dir") { + await api.post(upload.path).catch($showError); + } else { + const onUpload = (event: ProgressEvent) => { + upload.rawProgress.sentBytes = event.loaded; + }; + + await api + .post(upload.path, upload.file!, upload.overwrite, onUpload) + .catch((err) => err.message !== "Upload aborted" && $showError(err)); } - const totalSize = state.sizes.reduce((a, b) => a + b, 0); - const sum = state.progress.reduce((a, b) => a + b, 0); - return ((sum / totalSize) * 100).toFixed(2); - }, - getTotalProgressBytes: (state) => { - if (state.progress.length === 0 || state.sizes.length === 0) { - return "0 Bytes"; - } - const sum = state.progress.reduce((a, b) => a + b, 0); - return formatSize(sum); - }, - getTotalSize: (state) => { - if (state.sizes.length === 0) { - return "0 Bytes"; - } - const totalSize = state.sizes.reduce((a, b) => a + b, 0); - return formatSize(totalSize); - }, - filesInUploadCount: (state) => { - return Object.keys(state.uploads).length + state.queue.length; - }, - filesInUpload: (state) => { - const files = []; + finishUpload(upload); + } + }; - for (const index in state.uploads) { - const upload = state.uploads[index]; - const id = upload.id; - const type = upload.type; - const name = upload.file.name; - const size = state.sizes[id]; - const isDir = upload.file.isDir; - const progress = isDir - ? 100 - : Math.ceil((state.progress[id] / size) * 100); + const nextUpload = (): Upload => { + lastUpload.value++; - files.push({ - id, - name, - progress, - type, - isDir, - }); - } + const upload = allUploads.value[lastUpload.value]; + activeUploads.value.add(upload); - return files.sort((a, b) => a.progress - b.progress); - }, - uploadSpeed: (state) => { - return state.speedMbyte; - }, - getETA: (state) => state.eta, - }, - actions: { - // no context as first argument, use `this` instead - setProgress({ id, loaded }: { id: number; loaded: number }) { - this.progress[id] = loaded; - }, - setError(error: Error) { - this.error = error; - }, - reset() { - this.id = 0; - this.sizes = []; - this.progress = []; - this.queue = []; - this.uploads = {}; - this.speedMbyte = 0; - this.eta = 0; - this.error = null; - }, - addJob(item: UploadItem) { - this.queue.push(item); - this.sizes[this.id] = item.file.size; - this.id++; - }, - moveJob() { - const item = this.queue[0]; - this.queue.shift(); - this.uploads[item.id] = item; - }, - removeJob(id: number) { - delete this.uploads[id]; - }, - upload(item: UploadItem) { - const uploadsCount = Object.keys(this.uploads).length; + return upload; + }; - const isQueueEmpty = this.queue.length == 0; - const isUploadsEmpty = uploadsCount == 0; + const finishUpload = (upload: Upload) => { + sentBytes.value += upload.totalBytes - upload.sentBytes; + upload.sentBytes = upload.totalBytes; + upload.file = null; - if (isQueueEmpty && isUploadsEmpty) { - window.addEventListener("beforeunload", beforeUnload); - buttons.loading("upload"); - } + activeUploads.value.delete(upload); + processUploads(); + }; - this.addJob(item); - this.processUploads(); - }, - finishUpload(item: UploadItem) { - this.setProgress({ id: item.id, loaded: item.file.size }); - this.removeJob(item.id); - this.processUploads(); - }, - async processUploads() { - const uploadsCount = Object.keys(this.uploads).length; + const syncState = () => { + for (const upload of activeUploads.value) { + sentBytes.value += upload.rawProgress.sentBytes - upload.sentBytes; + upload.sentBytes = upload.rawProgress.sentBytes; + } + }; - const isBelowLimit = uploadsCount < UPLOADS_LIMIT; - const isQueueEmpty = this.queue.length == 0; - const isUploadsEmpty = uploadsCount == 0; + const reset = () => { + if (progressInterval !== null) { + clearInterval(progressInterval); + progressInterval = null; + } - const isFinished = isQueueEmpty && isUploadsEmpty; - const canProcess = isBelowLimit && !isQueueEmpty; + allUploads.value = []; + activeUploads.value = new Set(); + lastUpload.value = -1; + totalBytes.value = 0; + sentBytes.value = 0; + }; - if (isFinished) { - const fileStore = useFileStore(); - window.removeEventListener("beforeunload", beforeUnload); - buttons.success("upload"); - this.reset(); - fileStore.reload = true; - } + return { + // STATE + activeUploads, + totalBytes, + sentBytes, - if (canProcess) { - const item = this.queue[0]; - this.moveJob(); + // ACTIONS + upload, + abort, - if (item.file.isDir) { - await api.post(item.path).catch(this.setError); - } else { - const onUpload = throttle( - (event: ProgressEvent) => - this.setProgress({ - id: item.id, - loaded: event.loaded, - }), - 100, - { leading: true, trailing: false } - ); - - await api - .post(item.path, item.file.file as File, item.overwrite, onUpload) - .catch(this.setError); - } - - this.finishUpload(item); - } - }, - setUploadSpeed(value: number) { - this.speedMbyte = value; - }, - setETA(value: number) { - this.eta = value; - }, - // easily reset state using `$reset` - clearUpload() { - this.$reset(); - }, - }, + // GETTERS + pendingUploadCount, + }; }); diff --git a/frontend/src/types/file.d.ts b/frontend/src/types/file.d.ts index db2aa5fe..5664c16b 100644 --- a/frontend/src/types/file.d.ts +++ b/frontend/src/types/file.d.ts @@ -29,6 +29,7 @@ interface ResourceItem extends ResourceBase { } type ResourceType = + | "dir" | "video" | "audio" | "image" diff --git a/frontend/src/types/upload.d.ts b/frontend/src/types/upload.d.ts index 131f4b2c..4bad9e06 100644 --- a/frontend/src/types/upload.d.ts +++ b/frontend/src/types/upload.d.ts @@ -1,22 +1,15 @@ -interface Uploads { - [key: number]: Upload; -} - -interface Upload { - id: number; - file: UploadEntry; - type?: ResourceType; -} - -interface UploadItem { - id: number; - url?: string; +type Upload = { path: string; - file: UploadEntry; - dir?: boolean; - overwrite?: boolean; - type?: ResourceType; -} + name: string; + file: File | null; + type: ResourceType; + overwrite: boolean; + totalBytes: number; + sentBytes: number; + rawProgress: { + sentBytes: number; + }; +}; interface UploadEntry { name: string; @@ -27,17 +20,3 @@ interface UploadEntry { } type UploadList = UploadEntry[]; - -type CurrentUploadList = { - [key: string]: { - upload: import("tus-js-client").Upload; - recentSpeeds: number[]; - initialBytesUploaded: number; - currentBytesUploaded: number; - currentAverageSpeed: number; - lastProgressTimestamp: number | null; - sumOfRecentSpeeds: number; - hasStarted: boolean; - interval: number | undefined; - }; -}; diff --git a/frontend/src/utils/upload.ts b/frontend/src/utils/upload.ts index cdd974e8..e951cb43 100644 --- a/frontend/src/utils/upload.ts +++ b/frontend/src/utils/upload.ts @@ -132,7 +132,6 @@ export function handleFiles( layoutStore.closeHovers(); for (const file of files) { - const id = uploadStore.id; let path = base; if (file.fullPath !== undefined) { @@ -145,14 +144,8 @@ export function handleFiles( path += "/"; } - const item: UploadItem = { - id, - path, - file, - overwrite, - ...(!file.isDir && { type: detectType((file.file as File).type) }), - }; + const type = file.isDir ? "dir" : detectType((file.file as File).type); - uploadStore.upload(item); + uploadStore.upload(path, file.name, file.file ?? null, overwrite, type); } } diff --git a/frontend/src/views/Files.vue b/frontend/src/views/Files.vue index 950cb86d..5001659f 100644 --- a/frontend/src/views/Files.vue +++ b/frontend/src/views/Files.vue @@ -26,7 +26,6 @@ import { computed, defineAsyncComponent, - inject, onBeforeUnmount, onMounted, onUnmounted, @@ -37,7 +36,6 @@ import { files as api } from "@/api"; import { storeToRefs } from "pinia"; import { useFileStore } from "@/stores/file"; import { useLayoutStore } from "@/stores/layout"; -import { useUploadStore } from "@/stores/upload"; import HeaderBar from "@/components/header/HeaderBar.vue"; import Breadcrumbs from "@/components/Breadcrumbs.vue"; @@ -51,14 +49,10 @@ import { name } from "../utils/constants"; const Editor = defineAsyncComponent(() => import("@/views/files/Editor.vue")); const Preview = defineAsyncComponent(() => import("@/views/files/Preview.vue")); -const $showError = inject("$showError")!; - const layoutStore = useLayoutStore(); const fileStore = useFileStore(); -const uploadStore = useUploadStore(); const { reload } = storeToRefs(fileStore); -const { error: uploadError } = storeToRefs(uploadStore); const route = useRoute(); @@ -111,9 +105,6 @@ watch(route, () => { watch(reload, (newValue) => { newValue && fetchData(); }); -watch(uploadError, (newValue) => { - newValue && $showError(newValue); -}); // Define functions diff --git a/frontend/src/views/Layout.vue b/frontend/src/views/Layout.vue index 597a680e..5a91bee5 100644 --- a/frontend/src/views/Layout.vue +++ b/frontend/src/views/Layout.vue @@ -1,7 +1,11 @@