610 lines
17 KiB
Vue
610 lines
17 KiB
Vue
<script setup lang="ts">
|
|
import type { BackendError, Library, FileSystem } from '~/types'
|
|
import type { VueUploadItem } from 'vue-upload-component'
|
|
|
|
import { computed, ref, reactive, watch, nextTick } from 'vue'
|
|
import { useEventListener, useIntervalFn } from '@vueuse/core'
|
|
import { humanSize, truncate } from '~/utils/filters'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { sortBy } from 'lodash-es'
|
|
import { useStore } from '~/store'
|
|
|
|
import axios from 'axios'
|
|
|
|
import LibraryFilesTable from '~/views/content/libraries/FilesTable.vue'
|
|
import FileUploadWidget from './FileUploadWidget.vue'
|
|
import FsBrowser from './FsBrowser.vue'
|
|
import FsLogs from './FsLogs.vue'
|
|
|
|
import useWebSocketHandler from '~/composables/useWebSocketHandler'
|
|
import updateQueryString from '~/composables/updateQueryString'
|
|
import useErrorHandler from '~/composables/useErrorHandler'
|
|
|
|
interface Events {
|
|
(e: 'uploads-finished', delta: number):void
|
|
}
|
|
|
|
interface Props {
|
|
library: Library
|
|
defaultImportReference?: string
|
|
}
|
|
|
|
const emit = defineEmits<Events>()
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
defaultImportReference: ''
|
|
})
|
|
|
|
const { t } = useI18n()
|
|
const store = useStore()
|
|
|
|
const upload = ref()
|
|
const currentTab = ref('uploads')
|
|
const supportedExtensions = computed(() => store.state.ui.supportedExtensions)
|
|
|
|
const labels = computed(() => ({
|
|
tooltips: {
|
|
denied: t('Upload denied, ensure the file is not too big and that you have not reached your quota'),
|
|
server: t('Cannot upload this file, ensure it is not too big'),
|
|
network: t('A network error occurred while uploading this file'),
|
|
timeout: t('Upload timeout, please try again'),
|
|
retry: t('Retry'),
|
|
extension: t(
|
|
'Invalid file type, ensure you are uploading an audio file. Supported file extensions are %{ extensions }',
|
|
{ extensions: supportedExtensions.value.join(', ') }
|
|
)
|
|
} as Record<string, string>
|
|
}))
|
|
|
|
const uploads = reactive({
|
|
pending: 0,
|
|
finished: 0,
|
|
skipped: 0,
|
|
errored: 0,
|
|
objects: {} as Record<string, any>
|
|
})
|
|
|
|
//
|
|
// File counts
|
|
//
|
|
const files = ref([] as VueUploadItem[])
|
|
const processedFilesCount = computed(() => uploads.skipped + uploads.errored + uploads.finished)
|
|
const uploadedFilesCount = computed(() => files.value.filter(file => file.success).length)
|
|
const retryableFiles = computed(() => files.value.filter(file => file.error))
|
|
const erroredFilesCount = computed(() => retryableFiles.value.length)
|
|
const processableFiles = computed(() => uploads.pending
|
|
+ uploads.skipped
|
|
+ uploads.errored
|
|
+ uploads.finished
|
|
+ uploadedFilesCount.value
|
|
)
|
|
|
|
//
|
|
// Uploading
|
|
//
|
|
const importReference = ref(props.defaultImportReference || new Date().toISOString())
|
|
history.replaceState(history.state, '', updateQueryString(location.href, 'import', importReference.value))
|
|
const uploadData = computed(() => ({
|
|
library: props.library.uuid,
|
|
import_reference: importReference
|
|
}))
|
|
|
|
watch(() => uploads.finished, (newValue, oldValue) => {
|
|
if (newValue > oldValue) {
|
|
emit('uploads-finished', newValue - oldValue)
|
|
}
|
|
})
|
|
|
|
//
|
|
// Upload status
|
|
//
|
|
const fetchStatus = async () => {
|
|
for (const status of Object.keys(uploads)) {
|
|
if (status === 'objects') continue
|
|
|
|
try {
|
|
const response = await axios.get('uploads/', {
|
|
params: {
|
|
import_reference: importReference.value,
|
|
import_status: status,
|
|
page_size: 1
|
|
}
|
|
})
|
|
|
|
uploads[status as keyof typeof uploads] = response.data.count
|
|
} catch (error) {
|
|
useErrorHandler(error as Error)
|
|
}
|
|
}
|
|
}
|
|
|
|
fetchStatus()
|
|
|
|
const needsRefresh = ref(false)
|
|
useWebSocketHandler('import.status_updated', async (event) => {
|
|
if (event.upload.import_reference !== importReference.value) {
|
|
return
|
|
}
|
|
|
|
// TODO (wvffle): Why?
|
|
await nextTick()
|
|
|
|
uploads[event.old_status] -= 1
|
|
uploads[event.new_status] += 1
|
|
uploads.objects[event.upload.uuid] = event.upload
|
|
needsRefresh.value = true
|
|
})
|
|
|
|
//
|
|
// Files
|
|
//
|
|
const sortedFiles = computed(() => {
|
|
const filesToSort = files.value
|
|
|
|
return [
|
|
...sortBy(filesToSort.filter(file => file.errored), ['name']),
|
|
...sortBy(filesToSort.filter(file => !file.errored && !file.success), ['name']),
|
|
...sortBy(filesToSort.filter(file => file.success), ['name'])
|
|
]
|
|
})
|
|
|
|
const hasActiveUploads = computed(() => files.value.some(file => file.active))
|
|
|
|
//
|
|
// Quota status
|
|
//
|
|
const quotaStatus = ref()
|
|
|
|
const uploadedSize = computed(() => {
|
|
let uploaded = 0
|
|
|
|
for (const file of files.value) {
|
|
if (!file.error) {
|
|
uploaded += (file.size ?? 0) * +(file.progress ?? 0) / 100
|
|
}
|
|
}
|
|
|
|
return uploaded
|
|
})
|
|
|
|
const remainingSpace = computed(() => Math.max(
|
|
(quotaStatus.value?.remaining ?? 0) - uploadedSize.value / 1e6,
|
|
0
|
|
))
|
|
|
|
watch(remainingSpace, space => {
|
|
if (space <= 0) {
|
|
upload.value.active = false
|
|
}
|
|
})
|
|
|
|
const isLoadingQuota = ref(false)
|
|
const fetchQuota = async () => {
|
|
isLoadingQuota.value = true
|
|
|
|
try {
|
|
const response = await axios.get('users/me/')
|
|
quotaStatus.value = response.data.quota_status
|
|
} catch (error) {
|
|
useErrorHandler(error as Error)
|
|
}
|
|
|
|
isLoadingQuota.value = false
|
|
}
|
|
|
|
fetchQuota()
|
|
|
|
//
|
|
// Filesystem
|
|
//
|
|
const fsPath = reactive([])
|
|
const fsStatus = ref({
|
|
import: {
|
|
status: 'pending'
|
|
}
|
|
} as FileSystem)
|
|
watch(fsPath, () => fetchFilesystem(true))
|
|
|
|
const { pause, resume } = useIntervalFn(() => {
|
|
fetchFilesystem(false)
|
|
}, 5000, { immediate: false })
|
|
|
|
const isLoadingFs = ref(false)
|
|
const fetchFilesystem = async (updateLoading: boolean) => {
|
|
if (updateLoading) isLoadingFs.value = true
|
|
pause()
|
|
|
|
try {
|
|
const response = await axios.get('libraries/fs-import', { params: { path: fsPath.join('/') } })
|
|
fsStatus.value = response.data
|
|
} catch (error) {
|
|
useErrorHandler(error as Error)
|
|
}
|
|
|
|
if (updateLoading) isLoadingFs.value = false
|
|
if (store.state.auth.availablePermissions.library) resume()
|
|
}
|
|
|
|
if (store.state.auth.availablePermissions.library) {
|
|
fetchFilesystem(true)
|
|
}
|
|
|
|
const fsErrors = ref([] as string[])
|
|
const importFs = async () => {
|
|
isLoadingFs.value = true
|
|
|
|
try {
|
|
const response = await axios.post('libraries/fs-import', {
|
|
path: fsPath.join('/'),
|
|
library: props.library.uuid,
|
|
import_reference: importReference.value
|
|
})
|
|
|
|
fsStatus.value = response.data
|
|
} catch (error) {
|
|
fsErrors.value = (error as BackendError).backendErrors
|
|
}
|
|
|
|
isLoadingFs.value = false
|
|
}
|
|
|
|
// TODO (wvffle): Maybe use AbortController?
|
|
const cancelFsScan = async () => {
|
|
try {
|
|
await axios.delete('libraries/fs-import')
|
|
fetchFilesystem(false)
|
|
} catch (error) {
|
|
useErrorHandler(error as Error)
|
|
}
|
|
}
|
|
|
|
const inputFile = (newFile: VueUploadItem) => {
|
|
if (!newFile) return
|
|
|
|
if (remainingSpace.value < (newFile.size ?? Infinity) / 1e6) {
|
|
newFile.error = 'denied'
|
|
} else {
|
|
upload.value.active = true
|
|
}
|
|
}
|
|
|
|
// NOTE: For some weird reason typescript thinks that xhr field is not compatible with the same type
|
|
const retry = (files: Omit<VueUploadItem, 'xhr'>[]) => {
|
|
for (const file of files) {
|
|
upload.value.update(file, { error: '', progress: '0.00' })
|
|
}
|
|
|
|
upload.value.active = true
|
|
}
|
|
|
|
//
|
|
// Before unload
|
|
//
|
|
useEventListener(window, 'beforeunload', (event) => {
|
|
if (!hasActiveUploads.value) return null
|
|
event.preventDefault()
|
|
return (event.returnValue = t('This page is asking you to confirm that you want to leave - data you have entered may not be saved.'))
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="component-file-upload">
|
|
<div class="ui top attached tabular menu">
|
|
<a
|
|
href=""
|
|
:class="['item', {active: currentTab === 'uploads'}]"
|
|
@click.prevent="currentTab = 'uploads'"
|
|
>
|
|
<translate >Uploading</translate>
|
|
<div
|
|
v-if="files.length === 0"
|
|
class="ui label"
|
|
>
|
|
0
|
|
</div>
|
|
<div
|
|
v-else-if="files.length > uploadedFilesCount + erroredFilesCount"
|
|
class="ui warning label"
|
|
>
|
|
{{ uploadedFilesCount + erroredFilesCount }}/{{ files.length }}
|
|
</div>
|
|
<div
|
|
v-else
|
|
:class="['ui', {'success': erroredFilesCount === 0}, {'danger': erroredFilesCount > 0}, 'label']"
|
|
>
|
|
{{ uploadedFilesCount + erroredFilesCount }}/{{ files.length }}
|
|
</div>
|
|
</a>
|
|
<a
|
|
href=""
|
|
:class="['item', {active: currentTab === 'processing'}]"
|
|
@click.prevent="currentTab = 'processing'"
|
|
>
|
|
<translate >Processing</translate>
|
|
<div
|
|
v-if="processableFiles === 0"
|
|
class="ui label"
|
|
>
|
|
0
|
|
</div>
|
|
<div
|
|
v-else-if="processableFiles > processedFilesCount"
|
|
class="ui warning label"
|
|
>
|
|
{{ processedFilesCount }}/{{ processableFiles }}
|
|
</div>
|
|
<div
|
|
v-else
|
|
:class="['ui', {'success': uploads.errored === 0}, {'danger': uploads.errored > 0}, 'label']"
|
|
>
|
|
{{ processedFilesCount }}/{{ processableFiles }}
|
|
</div>
|
|
</a>
|
|
</div>
|
|
<div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'uploads'}]">
|
|
<div :class="['ui', {loading: isLoadingQuota}, 'container']">
|
|
<div :class="['ui', {red: remainingSpace === 0}, {warning: remainingSpace > 0 && remainingSpace <= 50}, 'small', 'statistic']">
|
|
<div class="label">
|
|
<translate >
|
|
Remaining storage space
|
|
</translate>
|
|
</div>
|
|
<div class="value">
|
|
{{ humanSize(remainingSpace * 1000 * 1000) }}
|
|
</div>
|
|
</div>
|
|
<div class="ui divider" />
|
|
<h2 class="ui header">
|
|
<translate >
|
|
Upload music from '~/your local storage
|
|
</translate>
|
|
</h2>
|
|
<div class="ui message">
|
|
<p>
|
|
<translate >
|
|
You are about to upload music to your library. Before proceeding, please ensure that:
|
|
</translate>
|
|
</p>
|
|
<ul>
|
|
<li v-if="library.privacy_level != 'me'">
|
|
<translate >
|
|
You are not uploading copyrighted content in a public library, otherwise you may be infringing the law
|
|
</translate>
|
|
</li>
|
|
<li>
|
|
<translate >
|
|
The music files you are uploading are tagged properly.
|
|
</translate>
|
|
<a
|
|
href="http://picard.musicbrainz.org/"
|
|
target="_blank"
|
|
><translate >We recommend using Picard for that purpose.</translate></a>
|
|
</li>
|
|
<li>
|
|
<translate >
|
|
The music files you are uploading are in OGG, Flac, MP3 or AIFF format
|
|
</translate>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<file-upload-widget
|
|
ref="upload"
|
|
v-model="files"
|
|
:class="['ui', 'icon', 'basic', 'button']"
|
|
:post-action="$store.getters['instance/absoluteUrl']('/api/v1/uploads/')"
|
|
:multiple="true"
|
|
:data="uploadData"
|
|
:drop="true"
|
|
:extensions="supportedExtensions"
|
|
name="audio_file"
|
|
:thread="1"
|
|
@input-file="inputFile"
|
|
>
|
|
<i class="upload icon" />
|
|
<translate >
|
|
Click to select files to upload or drag and drop files or directories
|
|
</translate>
|
|
<br>
|
|
<br>
|
|
<i>
|
|
<translate
|
|
|
|
:translate-params="{extensions: supportedExtensions.join(', ')}"
|
|
>
|
|
Supported extensions: %{ extensions }
|
|
</translate>
|
|
</i>
|
|
</file-upload-widget>
|
|
</div>
|
|
<div
|
|
v-if="files.length > 0"
|
|
class="table-wrapper"
|
|
>
|
|
<div class="ui hidden divider" />
|
|
<table class="ui unstackable table">
|
|
<thead>
|
|
<tr>
|
|
<th class="ten wide">
|
|
<translate >
|
|
Filename
|
|
</translate>
|
|
</th>
|
|
<th>
|
|
<translate >
|
|
Size
|
|
</translate>
|
|
</th>
|
|
<th>
|
|
<translate >
|
|
Status
|
|
</translate>
|
|
</th>
|
|
<th>
|
|
<translate >
|
|
Actions
|
|
</translate>
|
|
</th>
|
|
</tr>
|
|
<tr v-if="retryableFiles.length > 1">
|
|
<th class="ten wide" />
|
|
<th />
|
|
<th />
|
|
<th>
|
|
<button
|
|
class="ui right floated small basic button"
|
|
@click.prevent="retry(retryableFiles)"
|
|
>
|
|
<translate >
|
|
Retry failed uploads
|
|
</translate>
|
|
</button>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr
|
|
v-for="file in sortedFiles"
|
|
:key="file.id"
|
|
>
|
|
<td :title="file.name">
|
|
{{ truncate(file.name ?? '', 60) }}
|
|
</td>
|
|
<td>{{ humanSize(file.size ?? 0) }}</td>
|
|
<td>
|
|
<span
|
|
v-if="typeof file.error === 'string' && file.error"
|
|
class="ui tooltip"
|
|
:data-tooltip="labels.tooltips[file.error]"
|
|
>
|
|
<span class="ui danger icon label">
|
|
<i class="question circle outline icon" /> {{ file.error }}
|
|
</span>
|
|
</span>
|
|
<span
|
|
v-else-if="file.success"
|
|
class="ui success label"
|
|
>
|
|
<translate
|
|
key="1"
|
|
|
|
>Uploaded</translate>
|
|
</span>
|
|
<span
|
|
v-else-if="file.active"
|
|
class="ui warning label"
|
|
>
|
|
<translate
|
|
key="2"
|
|
|
|
>
|
|
Uploading…
|
|
</translate>
|
|
({{ parseFloat(file.progress ?? '0.00') }}%)
|
|
</span>
|
|
<span
|
|
v-else
|
|
class="ui label"
|
|
>
|
|
<translate
|
|
key="3"
|
|
|
|
>
|
|
Pending
|
|
</translate>
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<template v-if="file.error">
|
|
<button
|
|
v-if="retryableFiles.includes(file)"
|
|
class="ui tiny basic icon right floated button"
|
|
:title="labels.tooltips.retry"
|
|
@click.prevent="retry([file])"
|
|
>
|
|
<i class="redo icon" />
|
|
</button>
|
|
</template>
|
|
<template v-else-if="!file.success">
|
|
<button
|
|
class="ui tiny basic danger icon right floated button"
|
|
@click.prevent="upload.remove(file)"
|
|
>
|
|
<i class="delete icon" />
|
|
</button>
|
|
</template>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="ui divider" />
|
|
<h2 class="ui header">
|
|
<translate >
|
|
Import music from your server
|
|
</translate>
|
|
</h2>
|
|
<div
|
|
v-if="fsErrors.length > 0"
|
|
role="alert"
|
|
class="ui negative message"
|
|
>
|
|
<h3 class="header">
|
|
<translate >
|
|
Error while launching import
|
|
</translate>
|
|
</h3>
|
|
<ul class="list">
|
|
<li
|
|
v-for="(error, key) in fsErrors"
|
|
:key="key"
|
|
>
|
|
{{ error }}
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<fs-browser
|
|
v-model="fsPath"
|
|
:loading="isLoadingFs"
|
|
:data="fsStatus"
|
|
@import="importFs"
|
|
/>
|
|
<template v-if="fsStatus && fsStatus.import">
|
|
<h3 class="ui header">
|
|
<translate >
|
|
Import status
|
|
</translate>
|
|
</h3>
|
|
<p v-if="fsStatus.import.reference !== importReference">
|
|
<translate >
|
|
Results of your previous import:
|
|
</translate>
|
|
</p>
|
|
<p v-else>
|
|
<translate >
|
|
Results of your import:
|
|
</translate>
|
|
</p>
|
|
|
|
<button
|
|
v-if="fsStatus.import.status === 'started' || fsStatus.import.status === 'pending'"
|
|
class="ui button"
|
|
@click="cancelFsScan"
|
|
>
|
|
<translate >
|
|
Cancel
|
|
</translate>
|
|
</button>
|
|
<fs-logs :data="fsStatus.import" />
|
|
</template>
|
|
</div>
|
|
<div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'processing'}]">
|
|
<library-files-table
|
|
:needs-refresh="needsRefresh"
|
|
ordering-config-name="library.detail.upload"
|
|
:filters="{import_reference: importReference}"
|
|
:custom-objects="Object.values(uploads.objects)"
|
|
@fetch-start="needsRefresh = false"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|