mirror of
https://code.eliotberriot.com/funkwhale/funkwhale.git
synced 2025-10-06 10:50:06 +02:00
Resolve "Per-user libraries" (use !368 instead)
This commit is contained in:
parent
b0ca181016
commit
2ea21994ee
144 changed files with 6749 additions and 5347 deletions
|
@ -143,7 +143,7 @@ export default {
|
|||
page_size: this.paginateBy,
|
||||
name__icontains: this.query,
|
||||
ordering: this.getOrderingAsString(),
|
||||
listenable: 'true'
|
||||
playable: 'true'
|
||||
}
|
||||
logger.default.debug('Fetching artists')
|
||||
axios.get(url, {params: params}).then((response) => {
|
||||
|
|
304
front/src/components/library/FileUpload.vue
Normal file
304
front/src/components/library/FileUpload.vue
Normal file
|
@ -0,0 +1,304 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="ui hidden clearing divider"></div>
|
||||
<!-- <div v-if="files.length > 0" class="ui indicating progress">
|
||||
<div class="bar"></div>
|
||||
<div class="label">
|
||||
{{ uploadedFilesCount }}/{{ files.length }} files uploaded,
|
||||
{{ processedFilesCount }}/{{ processableFiles }} files processed
|
||||
</div>
|
||||
</div> -->
|
||||
<div class="ui form">
|
||||
<div class="fields">
|
||||
<div class="ui four wide field">
|
||||
<label><translate>Import reference</translate></label>
|
||||
<input type="text" v-model="importReference" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<p><translate>This reference will be used to group imported files together.</translate></p>
|
||||
<div class="ui top attached tabular menu">
|
||||
<a :class="['item', {active: currentTab === 'uploads'}]" @click="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 yellow label">
|
||||
{{ uploadedFilesCount + erroredFilesCount }}/{{ files.length }}
|
||||
</div>
|
||||
<div v-else :class="['ui', {'green': erroredFilesCount === 0}, {'red': erroredFilesCount > 0}, 'label']">
|
||||
{{ uploadedFilesCount + erroredFilesCount }}/{{ files.length }}
|
||||
</div>
|
||||
</a>
|
||||
<a :class="['item', {active: currentTab === 'processing'}]" @click="currentTab = 'processing'">
|
||||
<translate>Processing</translate>
|
||||
<div v-if="processableFiles === 0" class="ui label">
|
||||
0
|
||||
</div>
|
||||
<div v-else-if="processableFiles > processedFilesCount" class="ui yellow label">
|
||||
{{ processedFilesCount }}/{{ processableFiles }}
|
||||
</div>
|
||||
<div v-else :class="['ui', {'green': trackFiles.errored === 0}, {'red': trackFiles.errored > 0}, 'label']">
|
||||
{{ processedFilesCount }}/{{ processableFiles }}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'uploads'}]">
|
||||
<div class="ui container">
|
||||
<file-upload-widget
|
||||
:class="['ui', 'icon', 'basic', 'button']"
|
||||
:post-action="uploadUrl"
|
||||
:multiple="true"
|
||||
:data="uploadData"
|
||||
:drop="true"
|
||||
accept="audio/*"
|
||||
v-model="files"
|
||||
name="audio_file"
|
||||
:thread="1"
|
||||
@input-filter="inputFilter"
|
||||
@input-file="inputFile"
|
||||
ref="upload">
|
||||
<i class="upload icon"></i>
|
||||
<translate>Click to select files to upload or drag and drop files or directories</translate>
|
||||
</file-upload-widget>
|
||||
</div>
|
||||
|
||||
<table v-if="files.length > 0" class="ui single line table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><translate>File name</translate></th>
|
||||
<th><translate>Size</translate></th>
|
||||
<th><translate>Status</translate></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(file, index) in sortedFiles" :key="file.id">
|
||||
<td :title="file.name">{{ file.name | truncate(60) }}</td>
|
||||
<td>{{ file.size | humanSize }}</td>
|
||||
<td>
|
||||
<span v-if="file.error" class="ui tooltip" :data-tooltip="labels.tooltips[file.error]">
|
||||
<span class="ui red icon label">
|
||||
<i class="question circle outline icon" /> {{ file.error }}
|
||||
</span>
|
||||
</span>
|
||||
<span v-else-if="file.success" class="ui green label">
|
||||
<translate key="1">Uploaded</translate>
|
||||
</span>
|
||||
<span v-else-if="file.active" class="ui yellow label">
|
||||
<translate key="2">Uploading...</translate>
|
||||
</span>
|
||||
<template v-else>
|
||||
<span class="ui label"><translate key="3">Pending</translate></span>
|
||||
<button class="ui tiny basic red icon button" @click.prevent="$refs.upload.remove(file)"><i class="delete icon"></i></button>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
<div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'processing'}]">
|
||||
<library-files-table
|
||||
:key="String(processTimestamp)"
|
||||
:filters="{import_reference: importReference}"
|
||||
:custom-objects="Object.values(trackFiles.objects)"></library-files-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import $ from 'jquery'
|
||||
import axios from 'axios'
|
||||
import logger from '@/logging'
|
||||
import FileUploadWidget from './FileUploadWidget'
|
||||
import LibraryFilesTable from '@/views/content/libraries/FilesTable'
|
||||
import moment from 'moment'
|
||||
import { WebSocketBridge } from 'django-channels'
|
||||
|
||||
export default {
|
||||
props: ['library', 'defaultImportReference'],
|
||||
components: {
|
||||
FileUploadWidget,
|
||||
LibraryFilesTable
|
||||
},
|
||||
data () {
|
||||
let importReference = this.defaultImportReference || moment().format()
|
||||
this.$router.replace({query: {import: importReference}})
|
||||
return {
|
||||
files: [],
|
||||
currentTab: 'uploads',
|
||||
uploadUrl: '/api/v1/track-files/',
|
||||
importReference,
|
||||
trackFiles: {
|
||||
pending: 0,
|
||||
finished: 0,
|
||||
skipped: 0,
|
||||
errored: 0,
|
||||
objects: {},
|
||||
},
|
||||
bridge: null,
|
||||
processTimestamp: new Date()
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.openWebsocket()
|
||||
this.fetchStatus()
|
||||
},
|
||||
destroyed () {
|
||||
this.disconnect()
|
||||
},
|
||||
|
||||
methods: {
|
||||
inputFilter (newFile, oldFile, prevent) {
|
||||
if (newFile && !oldFile) {
|
||||
let extension = newFile.name.split('.').pop()
|
||||
if (['ogg', 'mp3', 'flac'].indexOf(extension) < 0) {
|
||||
prevent()
|
||||
}
|
||||
}
|
||||
},
|
||||
inputFile (newFile, oldFile) {
|
||||
this.$refs.upload.active = true
|
||||
},
|
||||
fetchStatus () {
|
||||
let self = this
|
||||
let statuses = ['pending', 'errored', 'skipped', 'finished']
|
||||
statuses.forEach((status) => {
|
||||
axios.get('track-files/', {params: {import_reference: self.importReference, import_status: status, page_size: 1}}).then((response) => {
|
||||
self.trackFiles[status] = response.data.count
|
||||
})
|
||||
})
|
||||
},
|
||||
updateProgressBar () {
|
||||
$(this.$el).find('.progress').progress({
|
||||
total: this.files.length * 2,
|
||||
value: this.uploadedFilesCount + this.finishedJobs
|
||||
})
|
||||
},
|
||||
disconnect () {
|
||||
if (!this.bridge) {
|
||||
return
|
||||
}
|
||||
this.bridge.socket.close(1000, 'goodbye', {keepClosed: true})
|
||||
},
|
||||
openWebsocket () {
|
||||
this.disconnect()
|
||||
let self = this
|
||||
let token = this.$store.state.auth.token
|
||||
const bridge = new WebSocketBridge()
|
||||
this.bridge = bridge
|
||||
let url = this.$store.getters['instance/absoluteUrl'](`api/v1/activity?token=${token}`)
|
||||
url = url.replace('http://', 'ws://')
|
||||
url = url.replace('https://', 'wss://')
|
||||
bridge.connect(url)
|
||||
bridge.listen(function (event) {
|
||||
self.handleEvent(event)
|
||||
})
|
||||
bridge.socket.addEventListener('open', function () {
|
||||
console.log('Connected to WebSocket')
|
||||
})
|
||||
},
|
||||
handleEvent (event) {
|
||||
console.log('Received event', event.type, event)
|
||||
let self = this
|
||||
if (event.type === 'import.status_updated') {
|
||||
if (event.track_file.import_reference != self.importReference) {
|
||||
return
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
self.trackFiles[event.old_status] -= 1
|
||||
self.trackFiles[event.new_status] += 1
|
||||
self.trackFiles.objects[event.track_file.uuid] = event.track_file
|
||||
self.triggerReload()
|
||||
})
|
||||
}
|
||||
},
|
||||
triggerReload: _.throttle(function () {
|
||||
this.processTimestamp = new Date()
|
||||
}, 10000, {'leading': true})
|
||||
},
|
||||
computed: {
|
||||
labels () {
|
||||
let denied = this.$gettext('Upload refused, ensure the file is not too big and you have not reached your quota')
|
||||
let server = this.$gettext('Impossible to upload this file, ensure it is not too big')
|
||||
let network = this.$gettext('A network error occured while uploading this file')
|
||||
let timeout = this.$gettext('Upload timeout, please try again')
|
||||
return {
|
||||
tooltips: {
|
||||
denied,
|
||||
server,
|
||||
network,
|
||||
timeout
|
||||
}
|
||||
}
|
||||
},
|
||||
uploadedFilesCount () {
|
||||
return this.files.filter((f) => {
|
||||
return f.success
|
||||
}).length
|
||||
},
|
||||
uploadingFilesCount () {
|
||||
return this.files.filter((f) => {
|
||||
return !f.success && !f.error
|
||||
}).length
|
||||
},
|
||||
erroredFilesCount () {
|
||||
return this.files.filter((f) => {
|
||||
return f.error
|
||||
}).length
|
||||
},
|
||||
processableFiles () {
|
||||
return this.trackFiles.pending + this.trackFiles.skipped + this.trackFiles.errored + this.trackFiles.finished + this.uploadedFilesCount
|
||||
},
|
||||
processedFilesCount () {
|
||||
return this.trackFiles.skipped + this.trackFiles.errored + this.trackFiles.finished
|
||||
},
|
||||
uploadData: function () {
|
||||
return {
|
||||
'library': this.library.uuid,
|
||||
'import_reference': this.importReference,
|
||||
}
|
||||
},
|
||||
sortedFiles () {
|
||||
// return errored files on top
|
||||
return this.files.sort((f) => {
|
||||
if (f.errored) {
|
||||
return -5
|
||||
}
|
||||
if (f.success) {
|
||||
return 5
|
||||
}
|
||||
return 0
|
||||
})
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
uploadedFilesCount () {
|
||||
this.updateProgressBar()
|
||||
},
|
||||
finishedJobs () {
|
||||
this.updateProgressBar()
|
||||
},
|
||||
importReference: _.debounce(function () {
|
||||
this.$router.replace({query: {import: this.importReference}})
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
.file-uploads.ui.button {
|
||||
display: block;
|
||||
padding: 2em 1em;
|
||||
width: 100%;
|
||||
box-shadow: none;
|
||||
border-style: dashed !important;
|
||||
border: 3px solid rgba(50, 50, 50, 0.5);
|
||||
font-size: 1.5em;
|
||||
}
|
||||
.segment.hidden {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
|
@ -19,7 +19,9 @@ export default {
|
|||
form.append(key, value)
|
||||
}
|
||||
}
|
||||
form.append(this.name, file.file, file.file.filename || file.name)
|
||||
let filename = file.file.filename || file.name
|
||||
form.append('source', `upload://${filename}`)
|
||||
form.append(this.name, file.file, filename)
|
||||
let xhr = new XMLHttpRequest()
|
||||
xhr.open('POST', file.postAction)
|
||||
xhr.setRequestHeader('Authorization', this.$store.getters['auth/header'])
|
|
@ -13,7 +13,7 @@
|
|||
</track-widget>
|
||||
</div>
|
||||
<div class="column">
|
||||
<playlist-widget :url="'playlists/'" :filters="{scope: 'user', listenable: true, ordering: '-creation_date'}">
|
||||
<playlist-widget :url="'playlists/'" :filters="{scope: 'user', playable: true, ordering: '-creation_date'}">
|
||||
<template slot="title"><translate>Playlists</translate></template>
|
||||
</playlist-widget>
|
||||
</div>
|
||||
|
@ -21,7 +21,7 @@
|
|||
<div class="ui section hidden divider"></div>
|
||||
<div class="ui grid">
|
||||
<div class="ui row">
|
||||
<album-widget :filters="{ordering: '-creation_date'}">
|
||||
<album-widget :filters="{playable: true, ordering: '-creation_date'}">
|
||||
<template slot="title"><translate>Recently added</translate></template>
|
||||
</album-widget>
|
||||
</div>
|
||||
|
@ -72,7 +72,7 @@ export default {
|
|||
this.isLoadingArtists = true
|
||||
let params = {
|
||||
ordering: '-creation_date',
|
||||
listenable: true
|
||||
playable: true
|
||||
}
|
||||
let url = ARTISTS_URL
|
||||
logger.default.time('Loading latest artists')
|
||||
|
|
|
@ -1,170 +0,0 @@
|
|||
<template>
|
||||
<div>
|
||||
<h3 class="ui dividing block header">
|
||||
<a :href="getMusicbrainzUrl('artist', metadata.id)" target="_blank" :title="labels.viewOnMusicbrainz">{{ metadata.name }}</a>
|
||||
</h3>
|
||||
<form class="ui form" @submit.prevent="">
|
||||
<h6 class="ui header">
|
||||
<translate>Filter album types</translate>
|
||||
</h6>
|
||||
<div class="inline fields">
|
||||
<div class="field" v-for="t in availableReleaseTypes">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" :value="t" v-model="releaseTypes" />
|
||||
<label>{{ t }}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label><translate>Query template</translate></label>
|
||||
<input v-model="customQueryTemplate" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<template
|
||||
v-for="release in releases">
|
||||
<release-import
|
||||
:key="release.id"
|
||||
:metadata="release"
|
||||
:backends="backends"
|
||||
:defaultEnabled="false"
|
||||
:default-backend-id="defaultBackendId"
|
||||
:query-template="customQueryTemplate"
|
||||
@import-data-changed="recordReleaseData"
|
||||
@enabled="recordReleaseEnabled"
|
||||
></release-import>
|
||||
<div class="ui divider"></div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue'
|
||||
import axios from 'axios'
|
||||
import logger from '@/logging'
|
||||
|
||||
import ImportMixin from './ImportMixin'
|
||||
import ReleaseImport from './ReleaseImport'
|
||||
|
||||
export default Vue.extend({
|
||||
mixins: [ImportMixin],
|
||||
components: {
|
||||
ReleaseImport
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
releaseImportData: [],
|
||||
releaseGroupsData: {},
|
||||
releases: [],
|
||||
releaseTypes: ['Album'],
|
||||
availableReleaseTypes: [
|
||||
'Album',
|
||||
'Live',
|
||||
'Compilation',
|
||||
'EP',
|
||||
'Single',
|
||||
'Other']
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.fetchReleaseGroupsData()
|
||||
},
|
||||
methods: {
|
||||
recordReleaseData (release) {
|
||||
let existing = this.releaseImportData.filter(r => {
|
||||
return r.releaseId === release.releaseId
|
||||
})[0]
|
||||
if (existing) {
|
||||
existing.tracks = release.tracks
|
||||
} else {
|
||||
this.releaseImportData.push({
|
||||
releaseId: release.releaseId,
|
||||
enabled: true,
|
||||
tracks: release.tracks
|
||||
})
|
||||
}
|
||||
},
|
||||
recordReleaseEnabled (release, enabled) {
|
||||
let existing = this.releaseImportData.filter(r => {
|
||||
return r.releaseId === release.releaseId
|
||||
})[0]
|
||||
if (existing) {
|
||||
existing.enabled = enabled
|
||||
} else {
|
||||
this.releaseImportData.push({
|
||||
releaseId: release.releaseId,
|
||||
enabled: enabled,
|
||||
tracks: release.tracks
|
||||
})
|
||||
}
|
||||
},
|
||||
fetchReleaseGroupsData () {
|
||||
let self = this
|
||||
this.releaseGroups.forEach(group => {
|
||||
let url = 'providers/musicbrainz/releases/browse/' + group.id + '/'
|
||||
return axios.get(url).then((response) => {
|
||||
logger.default.info('successfully fetched release group', group.id)
|
||||
let release = response.data['release-list'].filter(r => {
|
||||
return r.status === 'Official'
|
||||
})[0]
|
||||
self.releaseGroupsData[group.id] = release
|
||||
self.releases = self.computeReleaseData()
|
||||
}, (response) => {
|
||||
logger.default.error('error while fetching release group', group.id)
|
||||
})
|
||||
})
|
||||
},
|
||||
computeReleaseData () {
|
||||
let self = this
|
||||
let releases = []
|
||||
this.releaseGroups.forEach(group => {
|
||||
let data = self.releaseGroupsData[group.id]
|
||||
if (data) {
|
||||
releases.push(data)
|
||||
}
|
||||
})
|
||||
return releases
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
labels () {
|
||||
return {
|
||||
viewOnMusicbrainz: this.$gettext('View on MusicBrainz')
|
||||
}
|
||||
},
|
||||
type () {
|
||||
return 'artist'
|
||||
},
|
||||
releaseGroups () {
|
||||
let self = this
|
||||
return this.metadata['release-group-list'].filter(r => {
|
||||
return self.releaseTypes.indexOf(r.type) !== -1
|
||||
}).sort(function (a, b) {
|
||||
if (a['first-release-date'] < b['first-release-date']) {
|
||||
return -1
|
||||
}
|
||||
if (a['first-release-date'] > b['first-release-date']) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
},
|
||||
importData () {
|
||||
let releases = this.releaseImportData.filter(r => {
|
||||
return r.enabled
|
||||
})
|
||||
return {
|
||||
artistId: this.metadata.id,
|
||||
count: releases.reduce(function (a, b) {
|
||||
return a + b.tracks.length
|
||||
}, 0),
|
||||
albums: releases
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
releaseTypes (newValue) {
|
||||
this.fetchReleaseGroupsData()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
|
@ -1,275 +0,0 @@
|
|||
<template>
|
||||
<div v-title="labels.title">
|
||||
<div v-if="isLoading && !batch" class="ui vertical segment">
|
||||
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||
</div>
|
||||
<div v-if="batch" class="ui vertical stripe segment">
|
||||
<table class="ui very basic table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<strong><translate>Import batch</translate></strong>
|
||||
</td>
|
||||
<td>
|
||||
#{{ batch.id }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong><translate>Launch date</translate></strong>
|
||||
</td>
|
||||
<td>
|
||||
<human-date :date="batch.creation_date"></human-date>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="batch.user">
|
||||
<td>
|
||||
<strong><translate>Submitted by</translate></strong>
|
||||
</td>
|
||||
<td>
|
||||
<username :username="batch.user.username" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="stats">
|
||||
<td><strong><translate>Pending</translate></strong></td>
|
||||
<td>{{ stats.pending }}</td>
|
||||
</tr>
|
||||
<tr v-if="stats">
|
||||
<td><strong><translate>Skipped</translate></strong></td>
|
||||
<td>{{ stats.skipped }}</td>
|
||||
</tr>
|
||||
<tr v-if="stats">
|
||||
<td><strong><translate>Errored</translate></strong></td>
|
||||
<td>
|
||||
{{ stats.errored }}
|
||||
<button
|
||||
@click="rerun({batches: [batch.id], jobs: []})"
|
||||
v-if="stats.errored > 0 || stats.pending > 0"
|
||||
class="ui tiny basic icon button">
|
||||
<i class="redo icon" />
|
||||
<translate>Rerun errored jobs</translate>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="stats">
|
||||
<td><strong><translate>Finished</translate></strong></td>
|
||||
<td>{{ stats.finished }}/{{ stats.count}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="ui inline form">
|
||||
<div class="fields">
|
||||
<div class="ui field">
|
||||
<label><translate>Search</translate></label>
|
||||
<input type="text" v-model="jobFilters.search" :placeholder="labels.searchPlaceholder" />
|
||||
</div>
|
||||
<div class="ui field">
|
||||
<label><translate>Status</translate></label>
|
||||
<select class="ui dropdown" v-model="jobFilters.status">
|
||||
<option :value="null"><translate>Any</translate></option>
|
||||
<option :value="'pending'"><translate>Pending</translate></option>
|
||||
<option :value="'errored'"><translate>Errored</translate></option>
|
||||
<option :value="'finished'"><translate>Success</translate></option>
|
||||
<option :value="'skipped'"><translate>Skipped</translate></option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table v-if="jobResult" class="ui unstackable table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><translate>Job ID</translate></th>
|
||||
<th><translate>Recording MusicBrainz ID</translate></th>
|
||||
<th><translate>Source</translate></th>
|
||||
<th><translate>Status</translate></th>
|
||||
<th><translate>Track</translate></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="job in jobResult.results">
|
||||
<td>{{ job.id }}</th>
|
||||
<td>
|
||||
<a :href="'https://www.musicbrainz.org/recording/' + job.mbid" target="_blank">{{ job.mbid }}</a>
|
||||
</td>
|
||||
<td>
|
||||
<a :title="job.source" :href="job.source" target="_blank">
|
||||
{{ job.source|truncate(50) }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
:class="['ui', {'yellow': job.status === 'pending'}, {'red': job.status === 'errored'}, {'green': job.status === 'finished'}, 'label']">
|
||||
{{ job.status }}</span>
|
||||
<button
|
||||
@click="rerun({batches: [], jobs: [job.id]})"
|
||||
v-if="['errored', 'pending'].indexOf(job.status) > -1"
|
||||
:title="labels.rerun"
|
||||
class="ui tiny basic icon button">
|
||||
<i class="redo icon" />
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<router-link v-if="job.track_file" :to="{name: 'library.tracks.detail', params: {id: job.track_file.track }}">{{ job.track_file.track }}</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot class="full-width">
|
||||
<tr>
|
||||
<th>
|
||||
<pagination
|
||||
v-if="jobResult && jobResult.count > jobFilters.paginateBy"
|
||||
@page-changed="selectPage"
|
||||
:compact="true"
|
||||
:current="jobFilters.page"
|
||||
:paginate-by="jobFilters.paginateBy"
|
||||
:total="jobResult.count"
|
||||
></pagination>
|
||||
</th>
|
||||
<th v-if="jobResult && jobResult.results.length > 0">
|
||||
<translate
|
||||
:translate-params="{start: ((jobFilters.page-1) * jobFilters.paginateBy) + 1, end: ((jobFilters.page-1) * jobFilters.paginateBy) + jobResult.results.length, total: jobResult.count}">
|
||||
Showing results %{ start }-%{ end } on %{ total }
|
||||
</translate>
|
||||
<th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import _ from 'lodash'
|
||||
import axios from 'axios'
|
||||
import logger from '@/logging'
|
||||
import Pagination from '@/components/Pagination'
|
||||
|
||||
export default {
|
||||
props: ['id'],
|
||||
components: {
|
||||
Pagination
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isLoading: true,
|
||||
batch: null,
|
||||
stats: null,
|
||||
jobResult: null,
|
||||
timeout: null,
|
||||
jobFilters: {
|
||||
status: null,
|
||||
source: null,
|
||||
search: '',
|
||||
paginateBy: 25,
|
||||
page: 1
|
||||
}
|
||||
}
|
||||
},
|
||||
created () {
|
||||
let self = this
|
||||
this.fetchData().then(() => {
|
||||
self.fetchJobs()
|
||||
self.fetchStats()
|
||||
})
|
||||
},
|
||||
destroyed () {
|
||||
if (this.timeout) {
|
||||
clearTimeout(this.timeout)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
labels () {
|
||||
let msg = this.$gettext('Import Batch #%{ id }')
|
||||
let title = this.$gettextInterpolate(msg, {id: this.id})
|
||||
let rerun = this.$gettext('Rerun job')
|
||||
let searchPlaceholder = this.$gettext('Search by source...')
|
||||
return {
|
||||
title,
|
||||
searchPlaceholder,
|
||||
rerun
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
fetchData () {
|
||||
var self = this
|
||||
this.isLoading = true
|
||||
let url = 'import-batches/' + this.id + '/'
|
||||
logger.default.debug('Fetching batch "' + this.id + '"')
|
||||
return axios.get(url).then((response) => {
|
||||
self.batch = response.data
|
||||
self.isLoading = false
|
||||
})
|
||||
},
|
||||
fetchStats () {
|
||||
var self = this
|
||||
let url = 'import-jobs/stats/'
|
||||
axios.get(url, {params: {batch: self.id}}).then((response) => {
|
||||
let old = self.stats
|
||||
self.stats = response.data
|
||||
self.isLoading = false
|
||||
if (!_.isEqual(old, self.stats)) {
|
||||
self.fetchJobs()
|
||||
self.fetchData()
|
||||
}
|
||||
if (self.stats.pending > 0) {
|
||||
self.timeout = setTimeout(
|
||||
self.fetchStats,
|
||||
5000
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
rerun ({jobs, batches}) {
|
||||
let payload = {
|
||||
jobs, batches
|
||||
}
|
||||
let self = this
|
||||
axios.post('import-jobs/run/', payload).then((response) => {
|
||||
self.fetchStats()
|
||||
})
|
||||
},
|
||||
fetchJobs () {
|
||||
let params = {
|
||||
batch: this.id,
|
||||
page_size: this.jobFilters.paginateBy,
|
||||
page: this.jobFilters.page,
|
||||
q: this.jobFilters.search
|
||||
}
|
||||
if (this.jobFilters.status) {
|
||||
params.status = this.jobFilters.status
|
||||
}
|
||||
if (this.jobFilters.source) {
|
||||
params.source = this.jobFilters.source
|
||||
}
|
||||
let self = this
|
||||
axios.get('import-jobs/', {params}).then((response) => {
|
||||
self.jobResult = response.data
|
||||
})
|
||||
},
|
||||
selectPage: function (page) {
|
||||
this.jobFilters.page = page
|
||||
}
|
||||
|
||||
},
|
||||
watch: {
|
||||
id () {
|
||||
this.fetchData()
|
||||
},
|
||||
jobFilters: {
|
||||
handler () {
|
||||
this.fetchJobs()
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
|
@ -1,163 +0,0 @@
|
|||
<template>
|
||||
<div v-title="labels.title">
|
||||
<div class="ui vertical stripe segment">
|
||||
<div v-if="isLoading" :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||
<div class="ui inline form">
|
||||
<div class="fields">
|
||||
<div class="ui field">
|
||||
<label><translate>Search</translate></label>
|
||||
<input type="text" v-model="filters.search" :placeholder="labels.searchPlaceholder" />
|
||||
</div>
|
||||
<div class="ui field">
|
||||
<label><translate>Status</translate></label>
|
||||
<select class="ui dropdown" v-model="filters.status">
|
||||
<option :value="null"><translate>Any</translate></option>
|
||||
<option :value="'pending'"><translate>Pending</translate></option>
|
||||
<option :value="'errored'"><translate>Errored</translate></option>
|
||||
<option :value="'finished'"><translate>Success</translate></option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ui field">
|
||||
<label><translate>Import source</translate></label>
|
||||
<select class="ui dropdown" v-model="filters.source">
|
||||
<option :value="null"><translate>Any</translate></option>
|
||||
<option :value="'shell'"><translate>CLI</translate></option>
|
||||
<option :value="'api'"><translate>API</translate></option>
|
||||
<option :value="'federation'"><translate>Federation</translate></option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui hidden clearing divider"></div>
|
||||
<table v-if="result && result.results.length > 0" class="ui unstackable table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><translate>ID</translate></th>
|
||||
<th><translate>Launch date</translate></th>
|
||||
<th><translate>Jobs</translate></th>
|
||||
<th><translate>Status</translate></th>
|
||||
<th><translate>Source</translate></th>
|
||||
<th><translate>Submitted by</translate></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="obj in result.results">
|
||||
<td>{{ obj.id }}</th>
|
||||
<td>
|
||||
<router-link :to="{name: 'library.import.batches.detail', params: {id: obj.id }}">
|
||||
<human-date :date="obj.creation_date"></human-date>
|
||||
</router-link>
|
||||
</td>
|
||||
<td>{{ obj.job_count }}</td>
|
||||
<td>
|
||||
<span
|
||||
:class="['ui', {'yellow': obj.status === 'pending'}, {'red': obj.status === 'errored'}, {'green': obj.status === 'finished'}, 'label']">{{ obj.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ obj.source }}</td>
|
||||
<td><template v-if="obj.submitted_by">{{ obj.submitted_by.username }}</template></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot class="full-width">
|
||||
<tr>
|
||||
<th>
|
||||
<pagination
|
||||
v-if="result && result.count > filters.paginateBy"
|
||||
@page-changed="selectPage"
|
||||
:compact="true"
|
||||
:current="filters.page"
|
||||
:paginate-by="filters.paginateBy"
|
||||
:total="result.count"
|
||||
></pagination>
|
||||
</th>
|
||||
<th v-if="result && result.results.length > 0">
|
||||
<translate
|
||||
:translate-params="{start: ((filters.page-1) * filters.paginateBy) + 1, end: ((filters.page-1) * filters.paginateBy) + result.results.length, total: result.count}">
|
||||
Showing results %{ start }-%{ end } on %{ total }
|
||||
</translate>
|
||||
<th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import logger from '@/logging'
|
||||
import Pagination from '@/components/Pagination'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Pagination
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
result: null,
|
||||
isLoading: false,
|
||||
filters: {
|
||||
status: null,
|
||||
source: null,
|
||||
search: '',
|
||||
paginateBy: 25,
|
||||
page: 1
|
||||
}
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.fetchData()
|
||||
},
|
||||
computed: {
|
||||
labels () {
|
||||
let searchPlaceholder = this.$gettext('Search by submitter, source...')
|
||||
let title = this.$gettext('Import Batches')
|
||||
return {
|
||||
searchPlaceholder,
|
||||
title
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
fetchData () {
|
||||
let params = {
|
||||
page_size: this.filters.paginateBy,
|
||||
page: this.filters.page,
|
||||
q: this.filters.search
|
||||
}
|
||||
if (this.filters.status) {
|
||||
params.status = this.filters.status
|
||||
}
|
||||
if (this.filters.source) {
|
||||
params.source = this.filters.source
|
||||
}
|
||||
var self = this
|
||||
this.isLoading = true
|
||||
logger.default.time('Loading import batches')
|
||||
axios.get('import-batches/', {params}).then((response) => {
|
||||
self.result = response.data
|
||||
logger.default.timeEnd('Loading import batches')
|
||||
self.isLoading = false
|
||||
})
|
||||
},
|
||||
selectPage: function (page) {
|
||||
this.filters.page = page
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
filters: {
|
||||
handler () {
|
||||
this.fetchData()
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
|
@ -1,144 +0,0 @@
|
|||
<template>
|
||||
<div>
|
||||
<div v-if="batch" class="ui container">
|
||||
<div class="ui message">
|
||||
<translate>Ensure your music files are properly tagged before uploading them.</translate>
|
||||
<a href="http://picard.musicbrainz.org/" target='_blank'><translate>We recommend using Picard for that purpose.</translate></a>
|
||||
</div>
|
||||
<file-upload-widget
|
||||
:class="['ui', 'icon', 'left', 'floated', 'button']"
|
||||
:post-action="uploadUrl"
|
||||
:multiple="true"
|
||||
:data="uploadData"
|
||||
:drop="true"
|
||||
extensions="ogg,mp3,flac"
|
||||
accept="audio/*"
|
||||
v-model="files"
|
||||
name="audio_file"
|
||||
:thread="1"
|
||||
@input-filter="inputFilter"
|
||||
@input-file="inputFile"
|
||||
ref="upload">
|
||||
<i class="upload icon"></i>
|
||||
<translate>Select files to upload...</translate>
|
||||
</file-upload-widget>
|
||||
<button
|
||||
:class="['ui', 'right', 'floated', 'icon', {disabled: files.length === 0}, 'button']"
|
||||
v-if="!$refs.upload || !$refs.upload.active" @click.prevent="startUpload()">
|
||||
<i class="play icon" aria-hidden="true"></i>
|
||||
<translate>Start Upload</translate>
|
||||
</button>
|
||||
<button type="button" class="ui right floated icon yellow button" v-else @click.prevent="$refs.upload.active = false">
|
||||
<i class="pause icon" aria-hidden="true"></i>
|
||||
<translate>Stop Upload</translate>
|
||||
</button>
|
||||
</div>
|
||||
<div class="ui hidden clearing divider"></div>
|
||||
<template v-if="batch"><translate>Once all your files are uploaded, simply click the following button to check the import status.</translate></template>
|
||||
<router-link class="ui basic button" v-if="batch" :to="{name: 'library.import.batches.detail', params: {id: batch.id }}">
|
||||
<translate>Import detail page</translate>
|
||||
</router-link>
|
||||
<table class="ui single line table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><translate>File name</translate></th>
|
||||
<th><translate>Size</translate></th>
|
||||
<th><translate>Status</translate></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(file, index) in files" :key="file.id">
|
||||
<td>{{ file.name }}</td>
|
||||
<td>{{ file.size | humanSize }}</td>
|
||||
<td>
|
||||
<span v-if="file.error" class="ui red label">
|
||||
{{ file.error }}
|
||||
</span>
|
||||
<span v-else-if="file.success" class="ui green label"><translate>Success</translate></span>
|
||||
<span v-else-if="file.active" class="ui yellow label"><translate>Uploading...</translate></span>
|
||||
<template v-else>
|
||||
<span class="ui label"><translate>Pending</translate></span>
|
||||
<button class="ui tiny basic red icon button" @click.prevent="$refs.upload.remove(file)"><i class="delete icon"></i></button>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import logger from '@/logging'
|
||||
import FileUploadWidget from './FileUploadWidget'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
FileUploadWidget
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
files: [],
|
||||
uploadUrl: '/api/v1/import-jobs/',
|
||||
batch: null
|
||||
}
|
||||
},
|
||||
mounted: function () {
|
||||
this.createBatch()
|
||||
},
|
||||
methods: {
|
||||
inputFilter (newFile, oldFile, prevent) {
|
||||
if (newFile && !oldFile) {
|
||||
let extension = newFile.name.split('.').pop()
|
||||
if (['ogg', 'mp3', 'flac'].indexOf(extension) < 0) {
|
||||
prevent()
|
||||
}
|
||||
}
|
||||
},
|
||||
inputFile (newFile, oldFile) {
|
||||
if (newFile && !oldFile) {
|
||||
// add
|
||||
if (!this.batch) {
|
||||
this.createBatch()
|
||||
}
|
||||
}
|
||||
if (newFile && oldFile) {
|
||||
// update
|
||||
}
|
||||
if (!newFile && oldFile) {
|
||||
// remove
|
||||
}
|
||||
},
|
||||
createBatch () {
|
||||
let self = this
|
||||
return axios.post('import-batches/', {}).then((response) => {
|
||||
self.batch = response.data
|
||||
}, (response) => {
|
||||
logger.default.error('error while launching creating batch')
|
||||
})
|
||||
},
|
||||
startUpload () {
|
||||
this.$emit('batch-created', this.batch)
|
||||
this.$refs.upload.active = true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
batchId: function () {
|
||||
if (this.batch) {
|
||||
return this.batch.id
|
||||
}
|
||||
return null
|
||||
},
|
||||
uploadData: function () {
|
||||
return {
|
||||
'batch': this.batchId,
|
||||
'source': 'file://'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
|
@ -1,96 +0,0 @@
|
|||
<template>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import logger from '@/logging'
|
||||
import router from '@/router'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
metadata: {type: Object, required: true},
|
||||
defaultEnabled: {type: Boolean, default: true},
|
||||
backends: {type: Array},
|
||||
defaultBackendId: {type: String},
|
||||
queryTemplate: {type: String, default: '$artist $title'},
|
||||
request: {type: Object, required: false}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
customQueryTemplate: this.queryTemplate,
|
||||
currentBackendId: this.defaultBackendId,
|
||||
isImporting: false,
|
||||
enabled: this.defaultEnabled
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getMusicbrainzUrl (type, id) {
|
||||
return 'https://musicbrainz.org/' + type + '/' + id
|
||||
},
|
||||
launchImport () {
|
||||
let self = this
|
||||
this.isImporting = true
|
||||
let url = 'submit/' + self.importType + '/'
|
||||
let payload = self.importData
|
||||
if (this.request) {
|
||||
payload.importRequest = this.request.id
|
||||
}
|
||||
axios.post(url, payload).then((response) => {
|
||||
logger.default.info('launched import for', self.type, self.metadata.id)
|
||||
self.isImporting = false
|
||||
router.push({
|
||||
name: 'library.import.batches.detail',
|
||||
params: {
|
||||
id: response.data.id
|
||||
}
|
||||
})
|
||||
}, (response) => {
|
||||
logger.default.error('error while launching import for', self.type, self.metadata.id)
|
||||
self.isImporting = false
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
importType () {
|
||||
return this.type
|
||||
},
|
||||
currentBackend () {
|
||||
let self = this
|
||||
return this.backends.filter(b => {
|
||||
return b.id === self.currentBackendId
|
||||
})[0]
|
||||
},
|
||||
realQueryTemplate () {
|
||||
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
isImporting (newValue) {
|
||||
this.$emit('import-state-changed', newValue)
|
||||
},
|
||||
importData: {
|
||||
handler (newValue) {
|
||||
this.$emit('import-data-changed', newValue)
|
||||
},
|
||||
deep: true
|
||||
},
|
||||
enabled (newValue) {
|
||||
this.$emit('enabled', this.importData, newValue)
|
||||
},
|
||||
queryTemplate (newValue, oldValue) {
|
||||
// we inherit from the prop template unless the component changed
|
||||
// the value
|
||||
if (oldValue === this.customQueryTemplate) {
|
||||
// no changed from prop, we keep the sync
|
||||
this.customQueryTemplate = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
|
@ -1,306 +0,0 @@
|
|||
<template>
|
||||
<div v-title="labels.title">
|
||||
<div class="ui vertical stripe segment">
|
||||
<div class="ui top three attached ordered steps">
|
||||
<a @click="currentStep = 0" :class="['step', {'active': currentStep === 0}, {'completed': currentStep > 0}]">
|
||||
<div class="content">
|
||||
<div class="title"><translate>Import source</translate></div>
|
||||
<div class="description"><translate>Uploaded files or external source</translate></div>
|
||||
</div>
|
||||
</a>
|
||||
<a @click="currentStep = 1" :class="['step', {'active': currentStep === 1}, {'completed': currentStep > 1}]">
|
||||
<div class="content">
|
||||
<div class="title"><translate>Metadata</translate></div>
|
||||
<div class="description"><translate>Grab corresponding metadata</translate></div>
|
||||
</div>
|
||||
</a>
|
||||
<a @click="currentStep = 2" :class="['step', {'active': currentStep === 2}, {'completed': currentStep > 2}]">
|
||||
<div class="content">
|
||||
<div class="title"><translate>Music</translate></div>
|
||||
<div class="description"><translate>Select relevant sources or files for import</translate></div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="ui hidden divider"></div>
|
||||
<div class="ui centered buttons">
|
||||
<button @click="currentStep -= 1" :disabled="currentStep === 0" class="ui icon button"><i class="left arrow icon"></i>
|
||||
<translate>Previous step</translate>
|
||||
</button>
|
||||
<button @click="nextStep()" v-if="currentStep < 2" class="ui icon button">
|
||||
<translate>Next step</translate>
|
||||
<i class="right arrow icon"></i>
|
||||
</button>
|
||||
<button
|
||||
@click="$refs.import.launchImport()"
|
||||
v-if="currentStep === 2 && currentSource != 'upload'"
|
||||
:class="['ui', 'positive', 'icon', {'loading': isImporting}, 'button']"
|
||||
:disabled="isImporting || importData.count === 0"
|
||||
>
|
||||
<translate
|
||||
:translate-params="{count: importData.count || 0}"
|
||||
:translate-n="importData.count || 0"
|
||||
translate-plural="Import %{ count } tracks">
|
||||
Import %{ count } track
|
||||
</translate>
|
||||
<i class="check icon"></i>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="currentStep === 2 && currentSource === 'upload'"
|
||||
@click="$router.push({name: 'library.import.batches.detail', params: {id: importBatch.id}})"
|
||||
:class="['ui', 'positive', 'icon', {'disabled': !importBatch}, 'button']"
|
||||
:disabled="!importBatch"
|
||||
>
|
||||
<translate>Finish import</translate>
|
||||
<i class="check icon"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="ui hidden divider"></div>
|
||||
<div class="ui attached segment">
|
||||
<template v-if="currentStep === 0">
|
||||
<p><translate>First, choose where you want to import the music from</translate></p>
|
||||
<form class="ui form">
|
||||
<div class="field">
|
||||
<div class="ui radio checkbox">
|
||||
<input type="radio" id="external" value="external" v-model="currentSource">
|
||||
<label for="external">
|
||||
<translate>External source. Supported backends</translate>
|
||||
<div v-for="backend in backends" class="ui basic label">
|
||||
<i v-if="backend.icon" :class="[backend.icon, 'icon']"></i>
|
||||
{{ backend.label }}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui radio checkbox">
|
||||
<input type="radio" id="upload" value="upload" v-model="currentSource">
|
||||
<label for="upload"><translate>File upload</translate></label>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
<div v-if="currentStep === 1" class="ui stackable two column grid">
|
||||
<div class="column">
|
||||
<form class="ui form" @submit.prevent="">
|
||||
<div class="field">
|
||||
<label><translate>Search an entity you want to import:</translate></label>
|
||||
<metadata-search
|
||||
:mb-type="mbType"
|
||||
:mb-id="mbId"
|
||||
@id-changed="updateId"
|
||||
@type-changed="updateType"></metadata-search>
|
||||
</div>
|
||||
</form>
|
||||
<div class="ui horizontal divider"><translate>Or</translate></div>
|
||||
<form class="ui form" @submit.prevent="">
|
||||
<div class="field">
|
||||
<label><translate>Input a MusicBrainz ID manually:</translate></label>
|
||||
<input type="text" v-model="currentId" />
|
||||
</div>
|
||||
</form>
|
||||
<div class="ui hidden divider"></div>
|
||||
<template v-if="currentType && currentId">
|
||||
<h4 class="ui header">
|
||||
<translate>You will import:</translate>
|
||||
</h4>
|
||||
<component
|
||||
:mbId="currentId"
|
||||
:is="metadataComponent"
|
||||
@metadata-changed="this.updateMetadata"
|
||||
></component>
|
||||
</template>
|
||||
<p><translate>You can also skip this step and enter metadata manually.</translate></p>
|
||||
</div>
|
||||
<div class="column">
|
||||
<h5 class="ui header"><translate>What is metadata?</translate></h5>
|
||||
<template v-translate>
|
||||
Metadata is the data related to the music you want to import. This includes all the information about the artists, albums and tracks. In order to have a high quality library, it is recommended to grab data from the
|
||||
<a href="https://musicbrainz.org" target="_blank">
|
||||
MusicBrainz
|
||||
</a>
|
||||
project, which you can think about as the Wikipedia of music.
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="currentStep === 2">
|
||||
<file-upload
|
||||
ref="import"
|
||||
@batch-created="updateBatch"
|
||||
v-if="currentSource == 'upload'"
|
||||
></file-upload>
|
||||
|
||||
<component
|
||||
ref="import"
|
||||
v-if="currentSource == 'external'"
|
||||
:request="currentRequest"
|
||||
:metadata="metadata"
|
||||
:is="importComponent"
|
||||
:backends="backends"
|
||||
:default-backend-id="backends[0].id"
|
||||
@import-data-changed="updateImportData"
|
||||
@import-state-changed="updateImportState"
|
||||
></component>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui vertical stripe segment" v-if="currentRequest">
|
||||
<h3 class="ui header">
|
||||
<translate>Music request</translate>
|
||||
</h3>
|
||||
<p><translate>This import will be associated with the music request below. After the import is finished, the request will be marked as fulfilled.</translate></p>
|
||||
<request-card :request="currentRequest" :import-action="false"></request-card>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import RequestCard from '@/components/requests/Card'
|
||||
import MetadataSearch from '@/components/metadata/Search'
|
||||
import ReleaseCard from '@/components/metadata/ReleaseCard'
|
||||
import ArtistCard from '@/components/metadata/ArtistCard'
|
||||
import ReleaseImport from './ReleaseImport'
|
||||
import FileUpload from './FileUpload'
|
||||
import ArtistImport from './ArtistImport'
|
||||
|
||||
import axios from 'axios'
|
||||
import router from '@/router'
|
||||
import $ from 'jquery'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
MetadataSearch,
|
||||
ArtistCard,
|
||||
ReleaseCard,
|
||||
ArtistImport,
|
||||
ReleaseImport,
|
||||
FileUpload,
|
||||
RequestCard
|
||||
},
|
||||
props: {
|
||||
mbType: {type: String, required: false},
|
||||
request: {type: String, required: false},
|
||||
source: {type: String, required: false},
|
||||
mbId: {type: String, required: false}
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
currentRequest: null,
|
||||
currentType: this.mbType || 'artist',
|
||||
currentId: this.mbId,
|
||||
currentStep: 0,
|
||||
currentSource: this.source,
|
||||
metadata: {},
|
||||
isImporting: false,
|
||||
importBatch: null,
|
||||
importData: {
|
||||
tracks: []
|
||||
},
|
||||
backends: [
|
||||
{
|
||||
id: 'youtube',
|
||||
label: 'YouTube',
|
||||
icon: 'youtube'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
created () {
|
||||
if (this.request) {
|
||||
this.fetchRequest(this.request)
|
||||
}
|
||||
if (this.currentSource) {
|
||||
this.currentStep = 1
|
||||
}
|
||||
},
|
||||
mounted: function () {
|
||||
$(this.$el).find('.ui.checkbox').checkbox()
|
||||
},
|
||||
methods: {
|
||||
updateRoute () {
|
||||
router.replace({
|
||||
query: {
|
||||
source: this.currentSource,
|
||||
type: this.currentType,
|
||||
id: this.currentId,
|
||||
request: this.request
|
||||
}
|
||||
})
|
||||
},
|
||||
updateImportData (newValue) {
|
||||
this.importData = newValue
|
||||
},
|
||||
updateImportState (newValue) {
|
||||
this.isImporting = newValue
|
||||
},
|
||||
updateMetadata (newValue) {
|
||||
this.metadata = newValue
|
||||
},
|
||||
updateType (newValue) {
|
||||
this.currentType = newValue
|
||||
},
|
||||
updateId (newValue) {
|
||||
this.currentId = newValue
|
||||
},
|
||||
updateBatch (batch) {
|
||||
this.importBatch = batch
|
||||
},
|
||||
fetchRequest (id) {
|
||||
let self = this
|
||||
axios.get(`requests/import-requests/${id}`).then((response) => {
|
||||
self.currentRequest = response.data
|
||||
})
|
||||
},
|
||||
nextStep () {
|
||||
if (this.currentStep === 0 && this.currentSource === 'upload') {
|
||||
// we skip metadata directly
|
||||
this.currentStep += 2
|
||||
} else {
|
||||
this.currentStep += 1
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
labels () {
|
||||
return {
|
||||
title: this.$gettext('Import Music')
|
||||
}
|
||||
},
|
||||
metadataComponent () {
|
||||
if (this.currentType === 'artist') {
|
||||
return 'ArtistCard'
|
||||
}
|
||||
if (this.currentType === 'release') {
|
||||
return 'ReleaseCard'
|
||||
}
|
||||
if (this.currentType === 'recording') {
|
||||
return 'RecordingCard'
|
||||
}
|
||||
},
|
||||
importComponent () {
|
||||
if (this.currentType === 'artist') {
|
||||
return 'ArtistImport'
|
||||
}
|
||||
if (this.currentType === 'release') {
|
||||
return 'ReleaseImport'
|
||||
}
|
||||
if (this.currentType === 'recording') {
|
||||
return 'RecordingImport'
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
currentType (newValue) {
|
||||
this.currentId = ''
|
||||
this.updateRoute()
|
||||
},
|
||||
currentId (newValue) {
|
||||
this.updateRoute()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
|
@ -1,119 +0,0 @@
|
|||
<template>
|
||||
<div>
|
||||
<h3 class="ui dividing block header">
|
||||
<translate
|
||||
tag="div"
|
||||
translate-plural="Album %{ title } (%{ count } tracks) by %{ artist }"
|
||||
:translate-n="tracks.length"
|
||||
:translate-params="{count: tracks.length, title: metadata.title, artist: metadata['artist-credit-phrase']}">
|
||||
Album %{ title } (%{ count } track) by %{ artist }
|
||||
</translate>
|
||||
<div class="ui divider"></div>
|
||||
<div class="sub header">
|
||||
<div class="ui toggle checkbox">
|
||||
<input type="checkbox" v-model="enabled" />
|
||||
<label><translate>Import this release</translate></label>
|
||||
</div>
|
||||
</div>
|
||||
</h3>
|
||||
<template
|
||||
v-if="enabled"
|
||||
v-for="track in tracks">
|
||||
<track-import
|
||||
:key="track.recording.id"
|
||||
:metadata="track"
|
||||
:release-metadata="metadata"
|
||||
:backends="backends"
|
||||
:default-backend-id="defaultBackendId"
|
||||
:query-template="customQueryTemplate"
|
||||
@import-data-changed="recordTrackData"
|
||||
@enabled="recordTrackEnabled"
|
||||
></track-import>
|
||||
<div class="ui divider"></div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue'
|
||||
import ImportMixin from './ImportMixin'
|
||||
import TrackImport from './TrackImport'
|
||||
|
||||
export default Vue.extend({
|
||||
mixins: [ImportMixin],
|
||||
components: {
|
||||
TrackImport
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
trackImportData: []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
recordTrackData (track) {
|
||||
let existing = this.trackImportData.filter(t => {
|
||||
return t.mbid === track.mbid
|
||||
})[0]
|
||||
if (existing) {
|
||||
existing.source = track.source
|
||||
} else {
|
||||
this.trackImportData.push({
|
||||
mbid: track.mbid,
|
||||
enabled: true,
|
||||
source: track.source
|
||||
})
|
||||
}
|
||||
},
|
||||
recordTrackEnabled (track, enabled) {
|
||||
let existing = this.trackImportData.filter(t => {
|
||||
return t.mbid === track.mbid
|
||||
})[0]
|
||||
if (existing) {
|
||||
existing.enabled = enabled
|
||||
} else {
|
||||
this.trackImportData.push({
|
||||
mbid: track.mbid,
|
||||
enabled: enabled,
|
||||
source: null
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
type () {
|
||||
return 'release'
|
||||
},
|
||||
importType () {
|
||||
return 'album'
|
||||
},
|
||||
tracks () {
|
||||
return this.metadata['medium-list'][0]['track-list']
|
||||
},
|
||||
importData () {
|
||||
let tracks = this.trackImportData.filter(t => {
|
||||
return t.enabled
|
||||
})
|
||||
return {
|
||||
releaseId: this.metadata.id,
|
||||
count: tracks.length,
|
||||
tracks: tracks
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
importData: {
|
||||
handler (newValue) {
|
||||
this.$emit('import-data-changed', newValue)
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped lang="scss">
|
||||
.ui.card {
|
||||
width: 100% !important;
|
||||
}
|
||||
</style>
|
|
@ -1,206 +0,0 @@
|
|||
<template>
|
||||
<div class="ui stackable grid">
|
||||
<div class="three wide column">
|
||||
<h5 class="ui header">
|
||||
{{ metadata.position }}. {{ metadata.recording.title }}
|
||||
<div class="sub header">
|
||||
{{ time.parse(parseInt(metadata.length) / 1000) }}
|
||||
</div>
|
||||
</h5>
|
||||
<div class="ui toggle checkbox">
|
||||
<input type="checkbox" v-model="enabled" />
|
||||
<label><translate>Import this track</translate></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="three wide column" v-if="enabled">
|
||||
<form class="ui mini form" @submit.prevent="">
|
||||
<div class="field">
|
||||
<label><translate>Source</translate></label>
|
||||
<select v-model="currentBackendId">
|
||||
<option v-for="backend in backends" :value="backend.id">
|
||||
{{ backend.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
<div class="ui hidden divider"></div>
|
||||
<template v-if="currentResult">
|
||||
<button @click="currentResultIndex -= 1" class="ui basic tiny icon button" :disabled="currentResultIndex === 0">
|
||||
<i class="left arrow icon"></i>
|
||||
</button>
|
||||
{{ results.total }}
|
||||
<translate :translate-params="{current: currentResultIndex + 1, total: results.length}">
|
||||
Result %{ current }/%{ total }
|
||||
</translate>
|
||||
<button @click="currentResultIndex += 1" class="ui basic tiny icon button" :disabled="currentResultIndex + 1 === results.length">
|
||||
<i class="right arrow icon"></i>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="four wide column" v-if="enabled">
|
||||
<form class="ui mini form" @submit.prevent="">
|
||||
<div class="field">
|
||||
<label><translate>Search query</translate></label>
|
||||
<input type="text" v-model="query" />
|
||||
<label><translate>Imported URL</translate></label>
|
||||
<input type="text" v-model="importedUrl" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="six wide column" v-if="enabled">
|
||||
<div v-if="isLoading" class="ui vertical segment">
|
||||
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||
</div>
|
||||
<div v-if="!isLoading && currentResult" class="ui items">
|
||||
<div class="item">
|
||||
<div class="ui small image">
|
||||
<img :src="currentResult.cover" />
|
||||
</div>
|
||||
<div class="content">
|
||||
<a
|
||||
:href="currentResult.url"
|
||||
target="_blank"
|
||||
class="description"
|
||||
v-html="$options.filters.highlight(currentResult.title, warnings)"></a>
|
||||
<div v-if="currentResult.channelTitle" class="meta">
|
||||
{{ currentResult.channelTitle}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import Vue from 'vue'
|
||||
import time from '@/utils/time'
|
||||
import logger from '@/logging'
|
||||
import ImportMixin from './ImportMixin'
|
||||
|
||||
import $ from 'jquery'
|
||||
|
||||
Vue.filter('highlight', function (words, query) {
|
||||
query.forEach(w => {
|
||||
let re = new RegExp('(' + w + ')', 'gi')
|
||||
words = words.replace(re, '<span class=\'highlight\'>$1</span>')
|
||||
})
|
||||
return words
|
||||
})
|
||||
|
||||
export default Vue.extend({
|
||||
mixins: [ImportMixin],
|
||||
props: {
|
||||
releaseMetadata: {type: Object, required: true}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isLoading: false,
|
||||
results: [],
|
||||
currentResultIndex: 0,
|
||||
importedUrl: '',
|
||||
warnings: [
|
||||
'live',
|
||||
'tv',
|
||||
'full',
|
||||
'cover',
|
||||
'mix'
|
||||
],
|
||||
customQuery: '',
|
||||
time
|
||||
}
|
||||
},
|
||||
created () {
|
||||
if (this.enabled) {
|
||||
this.search()
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
$('.ui.checkbox').checkbox()
|
||||
},
|
||||
methods: {
|
||||
search: function () {
|
||||
let self = this
|
||||
this.isLoading = true
|
||||
let url = 'providers/' + this.currentBackendId + '/search/'
|
||||
axios.get(url, {params: {query: this.query}}).then((response) => {
|
||||
logger.default.debug('searching', self.query, 'on', self.currentBackendId)
|
||||
self.results = response.data
|
||||
self.isLoading = false
|
||||
}, (response) => {
|
||||
logger.default.error('error while searching', self.query, 'on', self.currentBackendId)
|
||||
self.isLoading = false
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
type () {
|
||||
return 'track'
|
||||
},
|
||||
currentResult () {
|
||||
if (this.results) {
|
||||
return this.results[this.currentResultIndex]
|
||||
}
|
||||
},
|
||||
importData () {
|
||||
return {
|
||||
count: 1,
|
||||
mbid: this.metadata.recording.id,
|
||||
source: this.importedUrl
|
||||
}
|
||||
},
|
||||
query: {
|
||||
get: function () {
|
||||
if (this.customQuery.length > 0) {
|
||||
return this.customQuery
|
||||
}
|
||||
let queryMapping = [
|
||||
['artist', this.releaseMetadata['artist-credit'][0]['artist']['name']],
|
||||
['album', this.releaseMetadata['title']],
|
||||
['title', this.metadata['recording']['title']]
|
||||
]
|
||||
let query = this.customQueryTemplate
|
||||
queryMapping.forEach(e => {
|
||||
query = query.split('$' + e[0]).join(e[1])
|
||||
})
|
||||
return query
|
||||
},
|
||||
set: function (newValue) {
|
||||
this.customQuery = newValue
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
query () {
|
||||
this.search()
|
||||
},
|
||||
currentResult (newValue) {
|
||||
if (newValue) {
|
||||
this.importedUrl = newValue.url
|
||||
}
|
||||
},
|
||||
importedUrl (newValue) {
|
||||
this.$emit('url-changed', this.importData, this.importedUrl)
|
||||
},
|
||||
enabled (newValue) {
|
||||
if (newValue && this.results.length === 0) {
|
||||
this.search()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped lang="scss">
|
||||
.ui.card {
|
||||
width: 100% !important;
|
||||
}
|
||||
</style>
|
||||
<style lang="scss">
|
||||
.highlight {
|
||||
font-weight: bold !important;
|
||||
background-color: yellow !important;
|
||||
}
|
||||
</style>
|
Loading…
Add table
Add a link
Reference in a new issue