funkwhale/front/src/components/library/FileUpload.vue

629 lines
16 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import type { BackendError, FileSystem, Library, PrivacyLevel } from '~/types'
import type { VueUploadItem } from 'vue-upload-component'
import type { paths } from '~/generated/types'
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'
import useSharedLabels from '~/composables/locale/useSharedLabels'
import Alert from '~/components/ui/Alert.vue'
import Button from '~/components/ui/Button.vue'
import Slider from '~/components/ui/Slider.vue'
import Section from '~/components/ui/Section.vue'
import Pill from '~/components/ui/Pill.vue'
import Layout from '~/components/ui/Layout.vue'
import Table from '~/components/ui/Table.vue'
interface Events {
(e: 'uploads-finished', delta: number):void
}
interface Props {
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('components.library.FileUpload.tooltip.denied'),
server: t('components.library.FileUpload.tooltip.size'),
network: t('components.library.FileUpload.tooltip.network'),
timeout: t('components.library.FileUpload.tooltip.timeout'),
retry: t('components.library.FileUpload.tooltip.retry'),
extension: t(
'components.library.FileUpload.tooltip.extension',
{ extensions: supportedExtensions.value.join(', ') }
)
} as Record<string, string>
}))
const uploads = reactive({
pending: 0,
finished: 0,
skipped: 0,
errored: 0,
objects: {} as Record<string, any>
})
// Select corresponding user-library when slider changes
const sharedLabels = useSharedLabels()
const options = {
me: sharedLabels.fields.privacy_level.choices.me,
instance: sharedLabels.fields.privacy_level.choices.instance,
everyone: sharedLabels.fields.privacy_level.choices.everyone
} as const satisfies Record<PrivacyLevel, string>
const privacyLevel = defineModel<keyof typeof options>({ required: true })
const library = ref<Library>()
// New implementation with `useClient`:
// watch(privacyLevel, (newValue) =>
// get({
// privacy_level: newValue,
// scope: 'me'
// })
// .then((data) =>
// library.value = data?.results.find(({name}) => name === privacyLevel.value)
// ),
// { immediate: true }
// )
// Old implementation:
watch(privacyLevel, async (newValue) => {
try {
const response = await axios.get<paths['/api/v2/libraries/']['get']['responses']['200']['content']['application/json']>('libraries/', {
params: {
privacy_level: privacyLevel.value,
scope: 'me'
}
})
library.value = response.data.results.find(({ name }) => name === privacyLevel.value)
} catch (error) {
useErrorHandler(error as Error)
}
}, { immediate: true })
//
// 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: library.value?.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,
library: library.value?.uuid
}
})
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()
if (event.new_status === 'errored') {
for (const file of files.value) {
if (file.response?.uuid === event.upload.uuid) {
file.error = event.new_status
break
}
}
}
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 = ref([])
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.value.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.value.join('/'),
library: library.value?.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 {
newFile.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('components.library.FileUpload.message.listener'))
})
// collapse section
const section = ref(false)
</script>
<template>
<div :class="{loading: isLoadingQuota}">
<div :class="['ui', {red: remainingSpace === 0}, {warning: remainingSpace > 0 && remainingSpace <= 50}, 'small', 'statistic']">
<div class="label">
{{ t('components.library.FileUpload.label.remainingSpace') }}
</div>
<div class="value">
{{ humanSize(remainingSpace * 1000 * 1000) }}
</div>
</div>
</div>
<Slider
v-model="privacyLevel"
:options="options"
:label="t('components.manage.library.UploadsTable.label.visibility')"
/>
<Alert blue>
<p>
{{ t('components.library.FileUpload.message.local.message') }}
</p>
<ul>
<li v-if="library?.privacy_level != 'me'">
{{ t('components.library.FileUpload.message.local.copyright') }}
</li>
<li>
{{ t('components.library.FileUpload.message.local.tag') }}&nbsp;
<a
href="http://picard.musicbrainz.org/"
target="_blank"
>{{ t('components.library.FileUpload.link.picard') }}</a>
</li>
<li>
{{ t('components.library.FileUpload.message.local.format') }}
</li>
</ul>
</Alert>
<file-upload-widget
ref="upload"
v-model="files"
:data="uploadData"
@input-file="inputFile"
>
<Button
primary
icon="bi bi-upload"
>
{{ t('components.library.FileUpload.label.uploadWidget') }}
</Button>
<p>
{{ t('components.library.FileUpload.label.extensions', {extensions: supportedExtensions.join(', ')}) }}
</p>
</file-upload-widget>
<!-- Show how many files are uploading and processing -->
<Layout
v-if="files.length > 0"
flex
>
<Layout
flex
gap-8
>
<label>{{ t('components.library.FileUpload.link.uploading') }}</label>
<Pill
v-bind="{
'green': erroredFilesCount === 0,
'red': erroredFilesCount > 0,
'yellow': files.length > uploadedFilesCount + erroredFilesCount
}"
>
{{ uploadedFilesCount + erroredFilesCount }} / {{ files.length }}
</Pill>
</Layout>
<Layout
flex
gap-8
>
<label>{{ t('components.library.FileUpload.link.processing') }}</label>
<Pill>
{{ processedFilesCount }} / {{ processableFiles }}
</Pill>
</Layout>
</Layout>
<Alert
v-if="fsErrors.length > 0"
red
>
<h3 class="header">
{{ t('components.library.FileUpload.header.failure') }}
</h3>
<ul class="list">
<li
v-for="(error, key) in fsErrors"
:key="key"
>
{{ error }}
</li>
</ul>
</Alert>
<!-- Show list of processed files -->
<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"
/>
<!-- Edit the metadata of uploaded files -->
<Table
:class="$style.table"
v-if="files.length > 0"
:grid-template-columns="['1fr', 'auto', 'auto', 'auto']"
>
<template #header>
<b class="ten wide">
{{ t('components.library.FileUpload.table.upload.header.filename') }}
</b>
<b>
{{ t('components.library.FileUpload.table.upload.header.size') }}
</b>
<b>
{{ t('components.library.FileUpload.table.upload.header.status') }}
</b>
<b>
{{ t('components.library.FileUpload.table.upload.header.actions') }}
</b>
</template>
<!-- Retry row -->
<template v-if="retryableFiles.length > 1">
<b> </b>
<b />
<b />
<b>
<Button
auto
primary
@click.prevent="retry(retryableFiles)"
>
{{ t('components.library.FileUpload.button.retry') }}
</Button>
</b>
</template>
<!-- Rows for each file -->
<template
v-for="file in sortedFiles"
:key="file.id"
>
<b :title="file.name">
{{ truncate(file.name ?? '', 60) }}
</b>
<b>{{ humanSize(file.size ?? 0) }}</b>
<b>
<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="bi bi-question-circle-fill" /> {{ file.error }}
</span>
</span>
<span
v-else-if="file.success"
class="ui success label"
>
<span key="1">
{{ t('components.library.FileUpload.table.upload.status.uploaded') }}
</span>
</span>
<span
v-else-if="file.active"
class="ui warning label"
>
<span key="2">
{{ t('components.library.FileUpload.table.upload.status.uploading') }}
</span>
{{ t('components.library.FileUpload.table.upload.progress', {percent: parseFloat(file.progress ?? '0.00')}) }}
</span>
<span
v-else
class="ui label"
>
<span key="3">
{{ t('components.library.FileUpload.table.upload.status.pending') }}
</span>
</span>
</b>
<b>
<template v-if="file.error">
<Button
v-if="retryableFiles.includes(file)"
square
secondary
:title="labels.tooltips.retry"
icon="bi-arrow-clockwise"
@click.prevent="retry([file])"
/>
</template>
<template v-else-if="!file.success">
<Button
square-small
destructive
icon="bi-trash-fill"
@click.prevent="upload.remove(file)"
/>
</template>
</b>
</template>
</Table>
<!-- Progressive disclosure: Import from server -->
<Section
:h2="t('components.library.FileUpload.header.server')"
align-left
no-items
v-bind="
section
? { collapse: () => { section = false } }
: { expand: () => { section = true } }
"
>
<div style="grid-column: 1 / -1">
<fs-browser
v-model="fsPath"
:loading="isLoadingFs"
:data="fsStatus"
@import="importFs"
/>
<template v-if="fsStatus && fsStatus.import">
<h3 class="ui header">
{{ t('components.library.FileUpload.header.status') }}
</h3>
<p v-if="fsStatus.import.reference !== importReference">
{{ t('components.library.FileUpload.description.previousImport') }}
</p>
<p v-else>
{{ t('components.library.FileUpload.description.import') }}
</p>
<Button
v-if="fsStatus.import.status === 'started' || fsStatus.import.status === 'pending'"
secondary
@click="cancelFsScan"
>
{{ t('components.library.FileUpload.button.cancel') }}
</Button>
<fs-logs :data="fsStatus.import" />
</template>
</div>
</Section>
</template>
<style module lang="scss">
.file-uploads {
padding: 32px;
border-radius: var(--fw-border-radius);
border: 2px dashed var(--border-color);
}
.table {
b:not(:first-child) { margin-left: 12px; }
}
</style>