import { defineStore, acceptHMRUpdate } from 'pinia' 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' import type { MetadataParsingResult } from '~/ui/workers/file-metadata-parser' import type { Tags } from '~/ui/composables/metadata' export type UploadGroupType = 'music-library' | 'music-channel' | 'podcast-channel' export type FailReason = 'missing-tags' | 'upload-failed' | 'upload-cancelled' export class UploadGroupEntry { id = nanoid() abortController = new AbortController() progress = 0 error?: Error failReason?: FailReason importedAt?: Date metadata?: { tags: Tags, coverUrl?: string } constructor (public file: File, public uploadGroup: UploadGroup) { UploadGroup.entries[this.id] = this } async upload () { const body = new FormData() body.append('file', this.file) 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 this.progress = Math.floor(e.loaded / (e.total ?? this.file.size) * 100) if (this.progress === 100) { console.log(`[${this.id}] upload complete!`) } } }) console.log(`[${this.id}] import complete!`) this.importedAt = new Date() } 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) => entry.upload().catch((error) => { // The tags were missing, so we have cancelled the upload if (error.code === 'ERR_CANCELED') { return } entry.fail('upload-failed', error) console.error(error) }).finally(() => { // Move to the next upload despite failing currentIndex.value += 1 })) // Prevent the user from leaving the page while uploading window.addEventListener('beforeunload', (event) => { if (isUploading.value) { event.preventDefault() return (event.returnValue = 'The upload is still in progress. Are you sure you want to leave?') } }) const progress = computed(() => { return uploadGroups.reduce((acc, group) => acc + group.progress, 0) / uploadGroups.length }) // Return public API return { isUploading, currentIndex: readonly(currentIndex), currentUpload, queue: readonly(uploadQueue), uploadGroups, createUploadGroup, currentUploadGroup, progress } }) if (import.meta.hot) { import.meta.hot.accept(acceptHMRUpdate(useUploadsStore, import.meta.hot)) }