From 56dd17cf84cf6bf56f922e04bb3d1e5678205269 Mon Sep 17 00:00:00 2001 From: Kasper Seweryn Date: Sat, 20 Jan 2024 22:54:07 +0100 Subject: [PATCH] feat: add upload groups --- front/src/router/routes/ui.ts | 34 ++ front/src/ui/components/CoverArt.vue | 60 +++ front/src/ui/components/Sidebar.vue | 2 +- front/src/ui/components/UploadList.vue | 144 +++++++ front/src/ui/components/UploadModal.vue | 156 +++++++ front/src/ui/components/VerticalCollapse.vue | 28 ++ front/src/ui/composables/bytes.ts | 8 + front/src/ui/layouts/constrained.vue | 2 +- front/src/ui/pages/index.vue | 402 +++++++++++++++++++ front/src/ui/pages/upload.vue | 225 ++--------- front/src/ui/pages/upload/all.vue | 43 ++ front/src/ui/pages/upload/files.vue | 0 front/src/ui/pages/upload/history.vue | 3 + front/src/ui/pages/upload/index.vue | 125 ++++++ front/src/ui/pages/upload/running.vue | 182 +++++++++ front/src/ui/stores/upload.ts | 256 +++++++----- front/src/ui/workers/file-metadata-parser.ts | 25 +- 17 files changed, 1388 insertions(+), 307 deletions(-) create mode 100644 front/src/ui/components/CoverArt.vue create mode 100644 front/src/ui/components/UploadList.vue create mode 100644 front/src/ui/components/UploadModal.vue create mode 100644 front/src/ui/components/VerticalCollapse.vue create mode 100644 front/src/ui/composables/bytes.ts create mode 100644 front/src/ui/pages/index.vue create mode 100644 front/src/ui/pages/upload/all.vue create mode 100644 front/src/ui/pages/upload/files.vue create mode 100644 front/src/ui/pages/upload/history.vue create mode 100644 front/src/ui/pages/upload/index.vue create mode 100644 front/src/ui/pages/upload/running.vue diff --git a/front/src/router/routes/ui.ts b/front/src/router/routes/ui.ts index 385cc69b1..9b5c44e02 100644 --- a/front/src/router/routes/ui.ts +++ b/front/src/router/routes/ui.ts @@ -1,4 +1,5 @@ import type { RouteRecordRaw } from 'vue-router' +import { useUploadsStore } from '~/ui/stores/upload' export default [ { @@ -10,6 +11,39 @@ export default [ path: 'upload', name: 'ui.upload', component: () => import('~/ui/pages/upload.vue'), + children: [ + { + path: '', + name: 'ui.upload.index', + component: () => import('~/ui/pages/upload/index.vue') + }, + + { + path: 'running', + name: 'ui.upload.running', + component: () => import('~/ui/pages/upload/running.vue'), + beforeEnter: (_to, _from, next) => { + const uploads = useUploadsStore() + if (uploads.uploadGroups.length === 0) { + next('/ui/upload') + } else { + next() + } + } + }, + + { + path: 'history', + name: 'ui.upload.history', + component: () => import('~/ui/pages/upload/history.vue') + }, + + { + path: 'all', + name: 'ui.upload.all', + component: () => import('~/ui/pages/upload/all.vue') + } + ] } ] } diff --git a/front/src/ui/components/CoverArt.vue b/front/src/ui/components/CoverArt.vue new file mode 100644 index 000000000..2b425130f --- /dev/null +++ b/front/src/ui/components/CoverArt.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/front/src/ui/components/Sidebar.vue b/front/src/ui/components/Sidebar.vue index 7371700a5..47f2e725c 100644 --- a/front/src/ui/components/Sidebar.vue +++ b/front/src/ui/components/Sidebar.vue @@ -28,7 +28,7 @@ const uploads = useUploadsStore()
-
+
diff --git a/front/src/ui/components/UploadList.vue b/front/src/ui/components/UploadList.vue new file mode 100644 index 000000000..90dfd75ce --- /dev/null +++ b/front/src/ui/components/UploadList.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/front/src/ui/components/UploadModal.vue b/front/src/ui/components/UploadModal.vue new file mode 100644 index 000000000..50f607616 --- /dev/null +++ b/front/src/ui/components/UploadModal.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/front/src/ui/components/VerticalCollapse.vue b/front/src/ui/components/VerticalCollapse.vue new file mode 100644 index 000000000..f274f9486 --- /dev/null +++ b/front/src/ui/components/VerticalCollapse.vue @@ -0,0 +1,28 @@ + + + + + diff --git a/front/src/ui/composables/bytes.ts b/front/src/ui/composables/bytes.ts new file mode 100644 index 000000000..bb08cf1b8 --- /dev/null +++ b/front/src/ui/composables/bytes.ts @@ -0,0 +1,8 @@ +export const bytesToHumanSize = (bytes: number) => { + const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] + if (bytes === 0) return '0 B' + const i = Math.floor(Math.log(bytes) / Math.log(1024)) + if (i === 0) return `${bytes} ${sizes[i]}` + return `${(bytes / 1024 ** i).toFixed(1)} ${sizes[i]}` +} + diff --git a/front/src/ui/layouts/constrained.vue b/front/src/ui/layouts/constrained.vue index 766ed0e1a..510af9b00 100644 --- a/front/src/ui/layouts/constrained.vue +++ b/front/src/ui/layouts/constrained.vue @@ -12,7 +12,7 @@ main { padding: 56px 48px; font-size: 16px; - max-width: 48rem; + max-width: 78rem; } diff --git a/front/src/ui/pages/index.vue b/front/src/ui/pages/index.vue new file mode 100644 index 000000000..4d250b070 --- /dev/null +++ b/front/src/ui/pages/index.vue @@ -0,0 +1,402 @@ + + + + + diff --git a/front/src/ui/pages/upload.vue b/front/src/ui/pages/upload.vue index 38cb90210..0c27834d4 100644 --- a/front/src/ui/pages/upload.vue +++ b/front/src/ui/pages/upload.vue @@ -1,8 +1,8 @@ diff --git a/front/src/ui/pages/upload/files.vue b/front/src/ui/pages/upload/files.vue new file mode 100644 index 000000000..e69de29bb diff --git a/front/src/ui/pages/upload/history.vue b/front/src/ui/pages/upload/history.vue new file mode 100644 index 000000000..6badad155 --- /dev/null +++ b/front/src/ui/pages/upload/history.vue @@ -0,0 +1,3 @@ + diff --git a/front/src/ui/pages/upload/index.vue b/front/src/ui/pages/upload/index.vue new file mode 100644 index 000000000..bda11d1ad --- /dev/null +++ b/front/src/ui/pages/upload/index.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/front/src/ui/pages/upload/running.vue b/front/src/ui/pages/upload/running.vue new file mode 100644 index 000000000..316a3208f --- /dev/null +++ b/front/src/ui/pages/upload/running.vue @@ -0,0 +1,182 @@ + + + + + diff --git a/front/src/ui/stores/upload.ts b/front/src/ui/stores/upload.ts index 58d968210..e11a9ae15 100644 --- a/front/src/ui/stores/upload.ts +++ b/front/src/ui/stores/upload.ts @@ -1,8 +1,8 @@ import { defineStore, acceptHMRUpdate } from 'pinia' -import { computed, reactive, readonly, ref, markRaw, toRaw, unref } from 'vue' -import { whenever, useWebWorker, type UseWebWorkerReturn } from '@vueuse/core' -import { not } from '@vueuse/math' +import { computed, reactive, readonly, ref, markRaw, toRaw, unref, watch } from 'vue' +import { whenever, useWebWorker } from '@vueuse/core' +import { nanoid } from 'nanoid' import axios from 'axios' import FileMetadataParserWorker from '~/ui/workers/file-metadata-parser.ts?worker' @@ -10,113 +10,184 @@ import type { MetadataParsingResult } from '~/ui/workers/file-metadata-parser' import type { Tags } from '~/ui/composables/metadata' -interface UploadQueueEntry { - id: number - file: File +export type UploadGroupType = 'music-library' | 'music-channel' | 'podcast-channel' +export type FailReason = 'missing-tags' | 'upload-failed' | 'upload-cancelled' - // Upload info - abortController: AbortController - progress: number +export class UploadGroupEntry { + id = nanoid() + abortController = new AbortController() + progress = 0 - // Import info + error?: Error + failReason?: FailReason importedAt?: Date - // Failure info - failReason?: 'missing-tags' | 'upload-failed' - error?: Error - - // Metadata - tags?: Tags - coverUrl?: string -} - -export const useUploadsStore = defineStore('uploads', () => { - const uploadQueue: UploadQueueEntry[] = reactive([]) - const currentIndex = ref(0) - const currentUpload = computed(() => uploadQueue[currentIndex.value]) - const isUploading = computed(() => !!currentUpload.value) - - // Tag extraction with a Web Worker - const worker = ref>() - const retrieveMetadata = (entry: Pick) => { - if (!worker.value) worker.value = useWebWorker(() => new FileMetadataParserWorker()) - worker.value.post(entry) + metadata?: { + tags: Tags, + coverUrl?: string } - whenever(not(isUploading), () => { - worker.value?.terminate() - worker.value = undefined - }) + constructor (public file: File, public uploadGroup: UploadGroup) { + UploadGroup.entries[this.id] = this + } - whenever(() => worker.value?.data, (reactiveData) => { - const data = toRaw(unref(reactiveData)) - if (data.status === 'success') { - const id = data.id - const tags = data.tags - const coverUrl = data.coverUrl - - uploadQueue[id].tags = markRaw(tags) - uploadQueue[id].coverUrl = coverUrl - } else { - const id = data.id - const entry = uploadQueue[id] - - entry.error = data.error - entry.failReason = 'missing-tags' - entry.importedAt = new Date() - entry.abortController.abort() - - console.warn(`Failed to parse metadata for file ${entry.file.name}:`, data.error) - } - }) - - const upload = async (entry: UploadQueueEntry) => { + async upload () { const body = new FormData() - body.append('file', entry.file) + body.append('file', this.file) - await axios.post('https://httpbin.org/post', body, { - headers: { - 'Content-Type': 'multipart/form-data' - }, - signal: entry.abortController.signal, + await axios.post(this.uploadGroup.uploadUrl, body, { + headers: { 'Content-Type': 'multipart/form-data' }, + signal: this.abortController.signal, onUploadProgress: (e) => { // NOTE: If e.total is absent, we use the file size instead. This is only an approximation, as e.total is the total size of the request, not just the file. // see: https://developer.mozilla.org/en-US/docs/Web/API/ProgressEvent/total - entry.progress = Math.floor(e.loaded / (e.total ?? entry.file.size) * 100) + this.progress = Math.floor(e.loaded / (e.total ?? this.file.size) * 100) - if (entry.progress === 100) { - console.log(`[${entry.id}] upload complete!`) + if (this.progress === 100) { + console.log(`[${this.id}] upload complete!`) } } }) - console.log(`[${entry.id}] import complete!`) - entry.importedAt = new Date() + console.log(`[${this.id}] import complete!`) + this.importedAt = new Date() } - const queueUpload = async (file: File) => { - let id = uploadQueue.length - uploadQueue.push({ - id, - file, - progress: 0, - abortController: new AbortController() - }) - - console.log('sending message to worker', id) - retrieveMetadata({ id, file }) + fail (reason: FailReason, error: Error) { + this.error = error + this.failReason = reason + this.importedAt = new Date() } + cancel (reason: FailReason = 'upload-cancelled', error: Error = new Error('Upload cancelled')) { + this.fail(reason, error) + this.abortController.abort() + } + + retry () { + this.error = undefined + this.failReason = undefined + this.importedAt = undefined + this.progress = 0 + this.abortController = new AbortController() + + if (!this.metadata) { + this.fail('missing-tags', new Error('Missing metadata')) + return + } + + uploadQueue.push(this) + } +} + +export class UploadGroup { + static entries = Object.create(null) + + queue: UploadGroupEntry[] = [] + createdAt = new Date() + + constructor ( + public guid: string, + public type: UploadGroupType, + public uploadUrl: string + ) {} + + get progress () { + return this.queue.reduce((total, entry) => total + entry.progress, 0) / this.queue.length + } + + get failedCount () { + return this.queue.filter((entry) => entry.failReason).length + } + + get importedCount () { + return this.queue.filter((entry) => entry.importedAt && !entry.failReason).length + } + + get processingCount () { + return this.queue.filter((entry) => !entry.importedAt && !entry.failReason).length + } + + queueUpload(file: File) { + const entry = new UploadGroupEntry(file, this) + this.queue.push(entry) + + const { id, metadata } = entry + if (!metadata) { + console.log('sending message to worker', id) + retrieveMetadata({ id, file }) + } + + uploadQueue.push(entry) + } + + cancel () { + for (const entry of this.queue) { + if (entry.importedAt) continue + entry.cancel() + } + } + + retry () { + for (const entry of this.queue) { + if (!entry.failReason) continue + entry.retry() + } + } +} + +const uploadQueue: UploadGroupEntry[] = reactive([]) +const uploadGroups: UploadGroup[] = reactive([]) +const currentUploadGroup = ref() +const currentIndex = ref(0) + +// Remove the upload group from the list if there are no uploads +watch(currentUploadGroup, (_, from) => { + if (from && from.queue.length === 0) { + const index = uploadGroups.indexOf(from) + if (index === -1) return + uploadGroups.splice(index, 1) + } +}) + +// Tag extraction with a Web Worker +const { post: retrieveMetadata, data: workerMetadata} = useWebWorker(() => new FileMetadataParserWorker()) +whenever(workerMetadata, (reactiveData) => { + const data = toRaw(unref(reactiveData)) + const entry = UploadGroup.entries[data.id] + console.log(data, entry) + if (!entry) return + + if (data.status === 'success') { + entry.metadata = { + tags: markRaw(data.tags), + coverUrl: data.coverUrl + } + } else { + entry.cancel('missing-tags', data.error) + console.warn(`Failed to parse metadata for file ${entry.file.name}:`, data.error) + } +}) + +export const useUploadsStore = defineStore('uploads', () => { + const createUploadGroup = async (type: UploadGroupType) => { + // TODO: API call + const uploadGroup = new UploadGroup('guid:' + nanoid(), type, 'https://httpbin.org/post') + uploadGroups.push(uploadGroup) + currentUploadGroup.value = uploadGroup + } + + const currentUpload = computed(() => uploadQueue[currentIndex.value]) + const isUploading = computed(() => !!currentUpload.value) + // Upload the file whenever it is available - whenever(currentUpload, (entry) => upload(entry).catch((error) => { + whenever(currentUpload, (entry) => entry.upload().catch((error) => { // The tags were missing, so we have cancelled the upload if (error.code === 'ERR_CANCELED') { return } - entry.error = error - entry.failReason = 'upload-failed' - entry.importedAt = new Date() + entry.fail('upload-failed', error) console.error(error) }).finally(() => { // Move to the next upload despite failing @@ -131,23 +202,20 @@ export const useUploadsStore = defineStore('uploads', () => { } }) - const cancelAll = () => { - for (const upload of uploadQueue) { - upload.abortController.abort() - } - - uploadQueue.length = 0 - currentIndex.value = 0 - } + const progress = computed(() => { + return uploadGroups.reduce((acc, group) => acc + group.progress, 0) / uploadGroups.length + }) // Return public API return { isUploading, - queueUpload, currentIndex: readonly(currentIndex), currentUpload, - cancelAll, - queue: readonly(uploadQueue) + queue: readonly(uploadQueue), + uploadGroups: uploadGroups, + createUploadGroup, + currentUploadGroup, + progress } }) diff --git a/front/src/ui/workers/file-metadata-parser.ts b/front/src/ui/workers/file-metadata-parser.ts index 589f76f2d..ca57665b1 100644 --- a/front/src/ui/workers/file-metadata-parser.ts +++ b/front/src/ui/workers/file-metadata-parser.ts @@ -3,14 +3,14 @@ import { getCoverUrl, getTags, type Tags } from '~/ui/composables/metadata' export interface MetadataParsingSuccess { - id: number + id: string status: 'success' tags: Tags coverUrl: string | undefined } export interface MetadataParsingFailure { - id: number + id: string status: 'failure' error: Error } @@ -18,32 +18,19 @@ export interface MetadataParsingFailure { export type MetadataParsingResult = MetadataParsingSuccess | MetadataParsingFailure -const parse = async (id: number, file: File) => { +const parse = async (id: string, file: File) => { try { console.log(`[${id}] parsing...`) const tags = await getTags(file) console.log(`[${id}] tags:`, tags) const coverUrl = await getCoverUrl(tags) - postMessage({ - id, - status: 'success', - tags, - coverUrl - }) + postMessage({ id, status: 'success', tags, coverUrl }) } catch (error) { - postMessage({ - id, - status: 'failure', - error - }) + postMessage({ id, status: 'failure', error }) } } -const queue = [] -let queuePromise = Promise.resolve() addEventListener('message', async (event) => { - const id = event.data.id as number - const file = event.data.file as File - parse(id, file) + parse(event.data.id, event.data.file) })