Resolve "Per-user libraries" (use !368 instead)

This commit is contained in:
Eliot Berriot 2018-09-06 18:35:02 +00:00
parent b0ca181016
commit 2ea21994ee
144 changed files with 6749 additions and 5347 deletions

View file

@ -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) => {

View 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>

View file

@ -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'])

View file

@ -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')

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>