-
-
-
-
-
-
- {{ tab.description }}
-
-
+
+
+
+ {{ tab.label }}
+
+
-
-
Open library
-
-
-
- Before uploading, please ensure your files are tagged properly.
- We recommend using Picard for that purpose.
+
-
- Got it
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{{ track.tags.title }}
- {{ track.tags.artist }} / {{ track.tags.album }}
-
-
- {{ track.file.name }}
-
-
-
-
- {{
- track.failReason
- ? 'failed'
- : track.importedAt
- ? 'imported'
- : track.progress === 100
- ? 'processing'
- : 'uploading'
- }}
-
-
- {{ timeAgo }}
-
-
- {{ bytesToHumanSize(track.file.size / 100 * track.progress) }}
- / {{ bytesToHumanSize(track.file.size) }}
- ⋅ {{ track.progress }}%
-
-
-
-
-
-
-
-
-
-
- Import from server directory
-
-
-
- Import
-
-
-
-
-
- Cancel
-
- {{ uploads.queue.length ? 'Continue in background' : 'Save and close' }}
-
-
-
-
+
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 @@
+
+ history
+
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 @@
+
+
+
+
+
Select a destination for your audio files:
+
+
+
+
+
+
+
+
+ {{ tab.description }}
+
+
+
+
+
Open library
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+ {{ timeAgo }}
+
+
+
+
+
+ {{ group.failedCount }}
+
+ failed
+
+
+
+
+ {{ group.importedCount }}
+
+ imported
+
+
+
+
+ {{ group.processingCount }}
+
+ processing
+
+
+
+
+
+
+
+
+
+
+
+
Retry
+
Interrupt
+
+
+
+
+ {{ group.importedCount }} / {{ group.queue.length }} files imported
+
+
+
+
+
+
+
+
+
+
+
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)
})