Fixed #4: can now import artists and releases with a clean interface :party:
This commit is contained in:
parent
3ccb70d0a8
commit
aa80bd15fa
43 changed files with 1614 additions and 120 deletions
|
|
@ -1,12 +1,5 @@
|
|||
import logger from '@/logging'
|
||||
|
||||
const pad = (val) => {
|
||||
val = Math.floor(val)
|
||||
if (val < 10) {
|
||||
return '0' + val
|
||||
}
|
||||
return val + ''
|
||||
}
|
||||
import time from '@/utils/time'
|
||||
|
||||
const Cov = {
|
||||
on (el, type, func) {
|
||||
|
|
@ -108,7 +101,7 @@ class Audio {
|
|||
})
|
||||
this.state.duration = Math.round(this.$Audio.duration * 100) / 100
|
||||
this.state.loaded = Math.round(10000 * this.$Audio.buffered.end(0) / this.$Audio.duration) / 100
|
||||
this.state.durationTimerFormat = this.timeParse(this.state.duration)
|
||||
this.state.durationTimerFormat = time.parse(this.state.duration)
|
||||
}
|
||||
|
||||
updatePlayState (e) {
|
||||
|
|
@ -116,9 +109,9 @@ class Audio {
|
|||
this.state.duration = Math.round(this.$Audio.duration * 100) / 100
|
||||
this.state.progress = Math.round(10000 * this.state.currentTime / this.state.duration) / 100
|
||||
|
||||
this.state.durationTimerFormat = this.timeParse(this.state.duration)
|
||||
this.state.currentTimeFormat = this.timeParse(this.state.currentTime)
|
||||
this.state.lastTimeFormat = this.timeParse(this.state.duration - this.state.currentTime)
|
||||
this.state.durationTimerFormat = time.parse(this.state.duration)
|
||||
this.state.currentTimeFormat = time.parse(this.state.currentTime)
|
||||
this.state.lastTimeFormat = time.parse(this.state.duration - this.state.currentTime)
|
||||
|
||||
this.hook.playState.forEach(func => {
|
||||
func(this.state)
|
||||
|
|
@ -181,14 +174,6 @@ class Audio {
|
|||
}
|
||||
this.$Audio.currentTime = time
|
||||
}
|
||||
|
||||
timeParse (sec) {
|
||||
let min = 0
|
||||
min = Math.floor(sec / 60)
|
||||
sec = sec - min * 60
|
||||
return pad(min) + ':' + pad(sec)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default Audio
|
||||
|
|
|
|||
|
|
@ -10,14 +10,13 @@ const LOGIN_URL = config.API_URL + 'token/'
|
|||
const USER_PROFILE_URL = config.API_URL + 'users/users/me/'
|
||||
// const SIGNUP_URL = API_URL + 'users/'
|
||||
|
||||
export default {
|
||||
|
||||
// User object will let us check authentication status
|
||||
user: {
|
||||
authenticated: false,
|
||||
username: '',
|
||||
profile: null
|
||||
},
|
||||
let userData = {
|
||||
authenticated: false,
|
||||
username: '',
|
||||
availablePermissions: {},
|
||||
profile: {}
|
||||
}
|
||||
let auth = {
|
||||
|
||||
// Send a request to the login URL and save the returned JWT
|
||||
login (context, creds, redirect, onError) {
|
||||
|
|
@ -87,7 +86,14 @@ export default {
|
|||
let self = this
|
||||
this.fetchProfile().then(data => {
|
||||
Vue.set(self.user, 'profile', data)
|
||||
Object.keys(data.permissions).forEach(function (key) {
|
||||
// this makes it easier to check for permissions in templates
|
||||
Vue.set(self.user.availablePermissions, key, data.permissions[String(key)].status)
|
||||
})
|
||||
})
|
||||
favoriteTracks.fetch()
|
||||
}
|
||||
}
|
||||
|
||||
Vue.set(auth, 'user', userData)
|
||||
export default auth
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
Welcome on funkwhale
|
||||
</h1>
|
||||
<p>We think listening music should be simple.</p>
|
||||
<router-link class="ui icon teal button" to="/browse">
|
||||
<router-link class="ui icon teal button" to="/library">
|
||||
Get me to the library
|
||||
<i class="right arrow icon"></i>
|
||||
</router-link>
|
||||
|
|
@ -90,9 +90,9 @@
|
|||
<p>Funkwhale is dead simple to use.</p>
|
||||
<div class="ui list">
|
||||
<div class="item">
|
||||
<i class="browser icon"></i>
|
||||
<i class="libraryr icon"></i>
|
||||
<div class="content">
|
||||
No add-ons, no plugins : you only need a web browser
|
||||
No add-ons, no plugins : you only need a web libraryr
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
|
||||
<div class="menu-area">
|
||||
<div class="ui compact fluid two item inverted menu">
|
||||
<a class="active item" data-tab="browse">Browse</a>
|
||||
<a class="active item" data-tab="library">Browse</a>
|
||||
<a class="item" data-tab="queue">
|
||||
Queue
|
||||
<template v-if="queue.tracks.length === 0">
|
||||
|
|
@ -26,12 +26,12 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="tabs">
|
||||
<div class="ui bottom attached active tab" data-tab="browse">
|
||||
<div class="ui bottom attached active tab" data-tab="library">
|
||||
<div class="ui inverted vertical fluid menu">
|
||||
<router-link class="item" v-if="auth.user.authenticated" :to="{name: 'profile', params: {username: auth.user.username}}"><i class="user icon"></i> Logged in as {{ auth.user.username }}</router-link>
|
||||
<router-link class="item" v-if="auth.user.authenticated" :to="{name: 'logout'}"><i class="sign out icon"></i> Logout</router-link>
|
||||
<router-link class="item" v-else :to="{name: 'login'}"><i class="sign in icon"></i> Login</router-link>
|
||||
<router-link class="item" :to="{path: '/browse'}"><i class="sound icon"> </i>Browse library</router-link>
|
||||
<router-link class="item" :to="{path: '/library'}"><i class="sound icon"> </i>Browse library</router-link>
|
||||
<router-link class="item" :to="{path: '/favorites'}"><i class="heart icon"></i> Favorites</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,14 +7,14 @@
|
|||
<img v-else src="../../assets/audio/default-cover.png">
|
||||
</div>
|
||||
<div class="middle aligned content">
|
||||
<router-link class="small header discrete link track" :to="{name: 'browse.track', params: {id: queue.currentTrack.id }}">
|
||||
<router-link class="small header discrete link track" :to="{name: 'library.track', params: {id: queue.currentTrack.id }}">
|
||||
{{ queue.currentTrack.title }}
|
||||
</router-link>
|
||||
<div class="meta">
|
||||
<router-link class="artist" :to="{name: 'browse.artist', params: {id: queue.currentTrack.artist.id }}">
|
||||
<router-link class="artist" :to="{name: 'library.artist', params: {id: queue.currentTrack.artist.id }}">
|
||||
{{ queue.currentTrack.artist.name }}
|
||||
</router-link> /
|
||||
<router-link class="album" :to="{name: 'browse.album', params: {id: queue.currentTrack.album.id }}">
|
||||
<router-link class="album" :to="{name: 'library.album', params: {id: queue.currentTrack.album.id }}">
|
||||
{{ queue.currentTrack.album.title }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ export default {
|
|||
let categories = [
|
||||
{
|
||||
code: 'artists',
|
||||
route: 'browse.artist',
|
||||
route: 'library.artist',
|
||||
name: 'Artist',
|
||||
getTitle (r) {
|
||||
return r.name
|
||||
|
|
@ -46,7 +46,7 @@ export default {
|
|||
},
|
||||
{
|
||||
code: 'albums',
|
||||
route: 'browse.album',
|
||||
route: 'library.album',
|
||||
name: 'Album',
|
||||
getTitle (r) {
|
||||
return r.title
|
||||
|
|
@ -57,7 +57,7 @@ export default {
|
|||
},
|
||||
{
|
||||
code: 'tracks',
|
||||
route: 'browse.track',
|
||||
route: 'library.track',
|
||||
name: 'Track',
|
||||
getTitle (r) {
|
||||
return r.title
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@
|
|||
<img v-else src="../../../assets/audio/default-cover.png">
|
||||
</div>
|
||||
<div class="header">
|
||||
<router-link class="discrete link" :to="{name: 'browse.album', params: {id: album.id }}">{{ album.title }}</router-link>
|
||||
<router-link class="discrete link" :to="{name: 'library.album', params: {id: album.id }}">{{ album.title }}</router-link>
|
||||
</div>
|
||||
<div class="meta">
|
||||
By <router-link :to="{name: 'browse.artist', params: {id: album.artist.id }}">
|
||||
By <router-link :to="{name: 'library.artist', params: {id: album.artist.id }}">
|
||||
{{ album.artist.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
<play-button class="basic icon" :track="track" :discrete="true"></play-button>
|
||||
</td>
|
||||
<td colspan="6">
|
||||
<router-link class="track discrete link" :to="{name: 'browse.track', params: {id: track.id }}">
|
||||
<router-link class="track discrete link" :to="{name: 'library.track', params: {id: track.id }}">
|
||||
{{ track.title }}
|
||||
</router-link>
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<div class="ui card">
|
||||
<div class="content">
|
||||
<div class="header">
|
||||
<router-link class="discrete link" :to="{name: 'browse.artist', params: {id: artist.id }}">
|
||||
<router-link class="discrete link" :to="{name: 'library.artist', params: {id: artist.id }}">
|
||||
{{ artist.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
<img class="ui mini image" v-else src="../../../assets/audio/default-cover.png">
|
||||
</td>
|
||||
<td colspan="4">
|
||||
<router-link class="discrete link":to="{name: 'browse.album', params: {id: album.id }}">
|
||||
<router-link class="discrete link":to="{name: 'library.album', params: {id: album.id }}">
|
||||
<strong>{{ album.title }}</strong>
|
||||
</router-link><br />
|
||||
{{ album.tracks.length }} tracks
|
||||
|
|
|
|||
|
|
@ -20,17 +20,17 @@
|
|||
<img class="ui mini image" v-else src="../../..//assets/audio/default-cover.png">
|
||||
</td>
|
||||
<td colspan="6">
|
||||
<router-link class="track" :to="{name: 'browse.track', params: {id: track.id }}">
|
||||
<router-link class="track" :to="{name: 'library.track', params: {id: track.id }}">
|
||||
{{ track.title }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td colspan="6">
|
||||
<router-link class="artist discrete link" :to="{name: 'browse.artist', params: {id: track.artist.id }}">
|
||||
<router-link class="artist discrete link" :to="{name: 'library.artist', params: {id: track.artist.id }}">
|
||||
{{ track.artist.name }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td colspan="6">
|
||||
<router-link class="album discrete link" :to="{name: 'browse.album', params: {id: track.album.id }}">
|
||||
<router-link class="album discrete link" :to="{name: 'library.album', params: {id: track.album.id }}">
|
||||
{{ track.album.title }}
|
||||
</router-link>
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export default {
|
|||
}
|
||||
// We need to pass the component's this context
|
||||
// to properly make use of http in the auth service
|
||||
auth.login(this, credentials, {path: '/browse'}, function (response) {
|
||||
auth.login(this, credentials, {path: '/library'}, function (response) {
|
||||
// error callback
|
||||
if (response.status === 400) {
|
||||
self.error = 'invalid_credentials'
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
{{ album.title }}
|
||||
<div class="sub header">
|
||||
Album containing {{ album.tracks.length }} tracks,
|
||||
by <router-link :to="{name: 'browse.artist', params: {id: album.artist.id }}">
|
||||
by <router-link :to="{name: 'library.artist', params: {id: album.artist.id }}">
|
||||
{{ album.artist.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
|
@ -34,7 +34,7 @@ import RadioCard from '@/components/radios/Card'
|
|||
const ARTISTS_URL = config.API_URL + 'artists/'
|
||||
|
||||
export default {
|
||||
name: 'browse',
|
||||
name: 'library',
|
||||
components: {
|
||||
Search,
|
||||
ArtistCard,
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
<template>
|
||||
<div class="main browse pusher">
|
||||
<div class="main library pusher">
|
||||
<div class="ui secondary pointing menu">
|
||||
<router-link class="ui item" to="/browse">Browse</router-link>
|
||||
<router-link class="ui item" to="/library" exact>Browse</router-link>
|
||||
<router-link v-if="auth.user.availablePermissions['import.launch']" class="ui item" to="/library/import/launch" exact>Import</router-link>
|
||||
<router-link v-if="auth.user.availablePermissions['import.launch']" class="ui item" to="/library/import/batches">Import batches</router-link>
|
||||
</div>
|
||||
<router-view></router-view>
|
||||
</div>
|
||||
|
|
@ -9,18 +11,25 @@
|
|||
|
||||
<script>
|
||||
|
||||
import auth from '@/auth'
|
||||
|
||||
export default {
|
||||
name: 'browse'
|
||||
name: 'library',
|
||||
data: function () {
|
||||
return {
|
||||
auth
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style lang="scss">
|
||||
.browse.pusher > .ui.secondary.menu {
|
||||
.library.pusher > .ui.secondary.menu {
|
||||
margin: 0 2.5rem;
|
||||
}
|
||||
|
||||
.browse {
|
||||
.library {
|
||||
.ui.segment.head {
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
|
|
@ -12,10 +12,10 @@
|
|||
{{ track.title }}
|
||||
<div class="sub header">
|
||||
From album
|
||||
<router-link :to="{name: 'browse.album', params: {id: track.album.id }}">
|
||||
<router-link :to="{name: 'library.album', params: {id: track.album.id }}">
|
||||
{{ track.album.title }}
|
||||
</router-link>
|
||||
by <router-link :to="{name: 'browse.artist', params: {id: track.artist.id }}">
|
||||
by <router-link :to="{name: 'library.artist', params: {id: track.artist.id }}">
|
||||
{{ track.artist.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
153
front/src/components/library/import/ArtistImport.vue
Normal file
153
front/src/components/library/import/ArtistImport.vue
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
<template>
|
||||
<div>
|
||||
<h3 class="ui dividing block header">
|
||||
<a :href="getMusicbrainzUrl('artist', metadata.id)" target="_blank" title="View on MusicBrainz">{{ metadata.name }}</a>
|
||||
</h3>
|
||||
<form class="ui form" @submit.prevent="">
|
||||
<h6 class="ui header">Filter album types</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>
|
||||
</form>
|
||||
<template
|
||||
v-for="release in releases">
|
||||
<release-import
|
||||
:key="release.id"
|
||||
:metadata="release"
|
||||
:backends="backends"
|
||||
:defaultEnabled="false"
|
||||
:default-backend-id="defaultBackendId"
|
||||
@import-data-changed="recordReleaseData"
|
||||
@enabled="recordReleaseEnabled"
|
||||
></release-import>
|
||||
<div class="ui divider"></div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue'
|
||||
import logger from '@/logging'
|
||||
import config from '@/config'
|
||||
|
||||
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 = config.API_URL + 'providers/musicbrainz/releases/browse/' + group.id + '/'
|
||||
let resource = Vue.resource(url)
|
||||
resource.get({}).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: {
|
||||
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>
|
||||
106
front/src/components/library/import/BatchDetail.vue
Normal file
106
front/src/components/library/import/BatchDetail.vue
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<template>
|
||||
<div>
|
||||
<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">
|
||||
<div :class="
|
||||
['ui',
|
||||
{'active': batch.status === 'pending'},
|
||||
{'warning': batch.status === 'pending'},
|
||||
{'success': batch.status === 'finished'},
|
||||
'progress']">
|
||||
<div class="bar" :style="progressBarStyle">
|
||||
<div class="progress"></div>
|
||||
</div>
|
||||
<div v-if="batch.status === 'pending'" class="label">Importing {{ batch.jobs.length }} tracks...</div>
|
||||
<div v-if="batch.status === 'finished'" class="label">Imported {{ batch.jobs.length }} tracks!</div>
|
||||
</div>
|
||||
<table class="ui table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Job ID</th>
|
||||
<th>Recording MusicBrainz ID</th>
|
||||
<th>Source</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="job in batch.jobs">
|
||||
<td>{{ job.id }}</th>
|
||||
<td>
|
||||
<a :href="'https://www.musicbrainz.org/recording/' + job.mbid" target="_blank">{{ job.mbid }}</a>
|
||||
</td>
|
||||
<td>
|
||||
<a :href="job.source" target="_blank">{{ job.source }}</a>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
:class="['ui', {'yellow': job.status === 'pending'}, {'green': job.status === 'finished'}, 'label']">{{ job.status }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import logger from '@/logging'
|
||||
import config from '@/config'
|
||||
|
||||
const FETCH_URL = config.API_URL + 'import-batches/'
|
||||
|
||||
export default {
|
||||
props: ['id'],
|
||||
data () {
|
||||
return {
|
||||
isLoading: true,
|
||||
batch: null
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.fetchData()
|
||||
},
|
||||
methods: {
|
||||
fetchData () {
|
||||
var self = this
|
||||
this.isLoading = true
|
||||
let url = FETCH_URL + this.id + '/'
|
||||
logger.default.debug('Fetching batch "' + this.id + '"')
|
||||
this.$http.get(url).then((response) => {
|
||||
self.batch = response.data
|
||||
self.isLoading = false
|
||||
if (self.batch.status === 'pending') {
|
||||
setTimeout(
|
||||
self.fetchData,
|
||||
5000
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
progress () {
|
||||
return this.batch.jobs.filter(j => {
|
||||
return j.status === 'finished'
|
||||
}).length * 100 / this.batch.jobs.length
|
||||
},
|
||||
progressBarStyle () {
|
||||
return 'width: ' + parseInt(this.progress) + '%'
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
id () {
|
||||
this.fetchData()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
80
front/src/components/library/import/BatchList.vue
Normal file
80
front/src/components/library/import/BatchList.vue
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="ui vertical stripe segment">
|
||||
<div v-if="isLoading" class="ui vertical segment">
|
||||
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui vertical stripe segment">
|
||||
<button class="ui left floated labeled icon button" @click="fetchData(previousLink)" :disabled="!previousLink"><i class="left arrow icon"></i> Previous</button>
|
||||
<button class="ui right floated right labeled icon button" @click="fetchData(nextLink)" :disabled="!nextLink">Next <i class="right arrow icon"></i></button>
|
||||
<div class="ui hidden clearing divider"></div>
|
||||
<div class="ui hidden clearing divider"></div>
|
||||
<table v-if="results.length > 0" class="ui table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Launch date</th>
|
||||
<th>Jobs</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="result in results">
|
||||
<td>{{ result.id }}</th>
|
||||
<td>
|
||||
<router-link :to="{name: 'library.import.batches.detail', params: {id: result.id }}">
|
||||
{{ result.creation_date }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td>{{ result.jobs.length }}</td>
|
||||
<td>
|
||||
<span
|
||||
:class="['ui', {'yellow': result.status === 'pending'}, {'green': result.status === 'finished'}, 'label']">{{ result.status }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import logger from '@/logging'
|
||||
import config from '@/config'
|
||||
|
||||
const BATCHES_URL = config.API_URL + 'import-batches/'
|
||||
|
||||
export default {
|
||||
components: {},
|
||||
data () {
|
||||
return {
|
||||
results: [],
|
||||
isLoading: false,
|
||||
nextLink: null,
|
||||
previousLink: null
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.fetchData(BATCHES_URL)
|
||||
},
|
||||
methods: {
|
||||
fetchData (url) {
|
||||
var self = this
|
||||
this.isLoading = true
|
||||
logger.default.time('Loading import batches')
|
||||
this.$http.get(url, {}).then((response) => {
|
||||
self.results = response.data.results
|
||||
self.nextLink = response.data.next
|
||||
self.previousLink = response.data.previous
|
||||
logger.default.timeEnd('Loading import batches')
|
||||
self.isLoading = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
||||
81
front/src/components/library/import/ImportMixin.vue
Normal file
81
front/src/components/library/import/ImportMixin.vue
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<template>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import logger from '@/logging'
|
||||
import config from '@/config'
|
||||
import Vue from 'vue'
|
||||
import router from '@/router'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
metadata: {type: Object, required: true},
|
||||
defaultEnabled: {type: Boolean, default: true},
|
||||
backends: {type: Array},
|
||||
defaultBackendId: {type: String}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
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 = config.API_URL + 'submit/' + self.importType + '/'
|
||||
let payload = self.importData
|
||||
let resource = Vue.resource(url)
|
||||
resource.save({}, 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]
|
||||
}
|
||||
},
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
231
front/src/components/library/import/Main.vue
Normal file
231
front/src/components/library/import/Main.vue
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
<template>
|
||||
<div>
|
||||
<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">Import source</div>
|
||||
<div class="description">
|
||||
Uploaded files or external source
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a @click="currentStep = 1" :class="['step', {'active': currentStep === 1}, {'completed': currentStep > 1}]">
|
||||
<div class="content">
|
||||
<div class="title">Metadata</div>
|
||||
<div class="description">Grab corresponding metadata</div>
|
||||
</div>
|
||||
</a>
|
||||
<a @click="currentStep = 2" :class="['step', {'active': currentStep === 2}, {'completed': currentStep > 2}]">
|
||||
<div class="content">
|
||||
<div class="title">Music</div>
|
||||
<div class="description">Select relevant sources or files for import</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="ui attached segment">
|
||||
<template v-if="currentStep === 0">
|
||||
<p>First, choose where you want to import the music from :</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">External source. Supported backends:
|
||||
<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 disabled radio checkbox">
|
||||
<input type="radio" id="upload" value="upload" v-model="currentSource" disabled>
|
||||
<label for="upload">File upload</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>Search an entity you want to import:</label>
|
||||
<metadata-search
|
||||
:mb-type="mbType"
|
||||
:mb-id="mbId"
|
||||
@id-changed="updateId"
|
||||
@type-changed="updateType"></metadata-search>
|
||||
</div>
|
||||
</form>
|
||||
<div class="ui horizontal divider">
|
||||
Or
|
||||
</div>
|
||||
<form class="ui form" @submit.prevent="">
|
||||
<div class="field">
|
||||
<label>Input a MusicBrainz ID manually:</label>
|
||||
<input type="text" v-model="currentId" />
|
||||
</div>
|
||||
</form>
|
||||
<div class="ui hidden divider"></div>
|
||||
<template v-if="currentType && currentId">
|
||||
<h4 class="ui header">You will import:</h4>
|
||||
<component
|
||||
:mbId="currentId"
|
||||
:is="metadataComponent"
|
||||
@metadata-changed="this.updateMetadata"
|
||||
></component>
|
||||
</template>
|
||||
<p>You can also skip this step and enter metadata manually.</p>
|
||||
</div>
|
||||
<div class="column">
|
||||
<h5 class="ui header">What is metadata?</h5>
|
||||
<p>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="http://musicbrainz.org/" target="_blank">MusicBrainz project</a>, which you can think about as the Wikipedia of music.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="currentStep === 2">
|
||||
<component
|
||||
ref="import"
|
||||
:metadata="metadata"
|
||||
:is="importComponent"
|
||||
:backends="backends"
|
||||
:default-backend-id="backends[0].id"
|
||||
@import-data-changed="updateImportData"
|
||||
@import-state-changed="updateImportState"
|
||||
></component>
|
||||
</div>
|
||||
<div class="ui hidden divider"></div>
|
||||
<div class="ui buttons">
|
||||
<button @click="currentStep -= 1" :disabled="currentStep === 0" class="ui icon button"><i class="left arrow icon"></i> Previous step</button>
|
||||
<button @click="currentStep += 1" v-if="currentStep < 2" class="ui icon button">Next step <i class="right arrow icon"></i></button>
|
||||
<button
|
||||
@click="$refs.import.launchImport()"
|
||||
v-if="currentStep === 2"
|
||||
:class="['ui', 'positive', 'icon', {'loading': isImporting}, 'button']"
|
||||
:disabled="isImporting || importData.count === 0"
|
||||
>Import {{ importData.count }} tracks <i class="check icon"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui vertical stripe segment">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import MetadataSearch from '@/components/metadata/Search'
|
||||
import ReleaseCard from '@/components/metadata/ReleaseCard'
|
||||
import ArtistCard from '@/components/metadata/ArtistCard'
|
||||
import ReleaseImport from './ReleaseImport'
|
||||
import ArtistImport from './ArtistImport'
|
||||
|
||||
import router from '@/router'
|
||||
import $ from 'jquery'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
MetadataSearch,
|
||||
ArtistCard,
|
||||
ReleaseCard,
|
||||
ArtistImport,
|
||||
ReleaseImport
|
||||
},
|
||||
props: {
|
||||
mbType: {type: String, required: false},
|
||||
source: {type: String, required: false},
|
||||
mbId: {type: String, required: false}
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
currentType: this.mbType || 'artist',
|
||||
currentId: this.mbId,
|
||||
currentStep: 0,
|
||||
currentSource: this.source || 'external',
|
||||
metadata: {},
|
||||
isImporting: false,
|
||||
importData: {
|
||||
tracks: []
|
||||
},
|
||||
backends: [
|
||||
{
|
||||
id: 'youtube',
|
||||
label: 'YouTube',
|
||||
icon: 'youtube'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
created () {
|
||||
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
|
||||
}
|
||||
})
|
||||
},
|
||||
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
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
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>
|
||||
113
front/src/components/library/import/ReleaseImport.vue
Normal file
113
front/src/components/library/import/ReleaseImport.vue
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
<template>
|
||||
<div>
|
||||
<h3 class="ui dividing block header">
|
||||
Album <a :href="getMusicbrainzUrl('release', metadata.id)" target="_blank" title="View on MusicBrainz">{{ metadata.title }}</a> ({{ tracks.length}} tracks) by
|
||||
<a :href="getMusicbrainzUrl('artist', metadata['artist-credit'][0]['artist']['id'])" target="_blank" title="View on MusicBrainz">{{ metadata['artist-credit-phrase'] }}</a>
|
||||
<div class="ui divider"></div>
|
||||
<div class="sub header">
|
||||
<div class="ui toggle checkbox">
|
||||
<input type="checkbox" v-model="enabled" />
|
||||
<label>Import this release</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"
|
||||
@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>
|
||||
188
front/src/components/library/import/TrackImport.vue
Normal file
188
front/src/components/library/import/TrackImport.vue
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
<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>Import this track</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="three wide column" v-if="enabled">
|
||||
<form class="ui mini form" @submit.prevent="">
|
||||
<div class="field">
|
||||
<label>Source</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>
|
||||
Result {{ currentResultIndex + 1 }}/{{ results.length }}
|
||||
<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>Search query</label>
|
||||
<input type="text" v-model="query" />
|
||||
<label>Imported URL</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 Vue from 'vue'
|
||||
import time from '@/utils/time'
|
||||
import config from '@/config'
|
||||
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 () {
|
||||
let queryParts = [
|
||||
this.releaseMetadata['artist-credit'][0]['artist']['name'],
|
||||
this.releaseMetadata['title'],
|
||||
this.metadata['recording']['title']
|
||||
]
|
||||
return {
|
||||
query: queryParts.join(' '),
|
||||
isLoading: false,
|
||||
results: [],
|
||||
currentResultIndex: 0,
|
||||
importedUrl: '',
|
||||
warnings: [
|
||||
'live',
|
||||
'full',
|
||||
'cover'
|
||||
],
|
||||
time
|
||||
}
|
||||
},
|
||||
created () {
|
||||
if (this.enabled) {
|
||||
this.search()
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
$('.ui.checkbox').checkbox()
|
||||
},
|
||||
methods: {
|
||||
search () {
|
||||
let self = this
|
||||
this.isLoading = true
|
||||
let url = config.API_URL + 'providers/' + this.currentBackendId + '/search/'
|
||||
let resource = Vue.resource(url)
|
||||
|
||||
resource.get({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
|
||||
}
|
||||
}
|
||||
},
|
||||
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>
|
||||
64
front/src/components/metadata/ArtistCard.vue
Normal file
64
front/src/components/metadata/ArtistCard.vue
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<template>
|
||||
<div class="ui card">
|
||||
<div class="content">
|
||||
<div v-if="isLoading" class="ui vertical segment">
|
||||
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||
</div>
|
||||
<template v-if="data.id">
|
||||
<div class="header">
|
||||
<a :href="getMusicbrainzUrl('artist', data.id)" target="_blank" title="View on MusicBrainz">{{ data.name }}</a>
|
||||
</div>
|
||||
<div class="description">
|
||||
<table class="ui very basic fixed single line compact table">
|
||||
<tbody>
|
||||
<tr v-for="group in releasesGroups">
|
||||
<td>
|
||||
{{ group['first-release-date'] }}
|
||||
</td>
|
||||
<td colspan="3">
|
||||
<a :href="getMusicbrainzUrl('release-group', group.id)" class="discrete link" target="_blank" title="View on MusicBrainz">
|
||||
{{ group.title }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue'
|
||||
import CardMixin from './CardMixin'
|
||||
import time from '@/utils/time'
|
||||
|
||||
export default Vue.extend({
|
||||
mixins: [CardMixin],
|
||||
data () {
|
||||
return {
|
||||
time
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
type () {
|
||||
return 'artist'
|
||||
},
|
||||
releasesGroups () {
|
||||
return this.data['release-group-list'].filter(r => {
|
||||
return r.type === 'Album'
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped lang="scss">
|
||||
.ui.card {
|
||||
width: 100% !important;
|
||||
}
|
||||
</style>
|
||||
50
front/src/components/metadata/CardMixin.vue
Normal file
50
front/src/components/metadata/CardMixin.vue
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<template>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import logger from '@/logging'
|
||||
|
||||
import config from '@/config'
|
||||
import Vue from 'vue'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
mbId: {type: String, required: true}
|
||||
},
|
||||
created: function () {
|
||||
this.fetchData()
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
isLoading: false,
|
||||
data: {}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
fetchData () {
|
||||
let self = this
|
||||
this.isLoading = true
|
||||
let url = config.API_URL + 'providers/musicbrainz/' + this.type + 's/' + this.mbId + '/'
|
||||
let resource = Vue.resource(url)
|
||||
resource.get({}).then((response) => {
|
||||
logger.default.info('successfully fetched', self.type, self.mbId)
|
||||
self.data = response.data[self.type]
|
||||
this.$emit('metadata-changed', self.data)
|
||||
self.isLoading = false
|
||||
}, (response) => {
|
||||
logger.default.error('error while fetching', self.type, self.mbId)
|
||||
self.isLoading = false
|
||||
})
|
||||
},
|
||||
getMusicbrainzUrl (type, id) {
|
||||
return 'https://musicbrainz.org/' + type + '/' + id
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
66
front/src/components/metadata/ReleaseCard.vue
Normal file
66
front/src/components/metadata/ReleaseCard.vue
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<template>
|
||||
<div class="ui card">
|
||||
<div class="content">
|
||||
<div v-if="isLoading" class="ui vertical segment">
|
||||
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||
</div>
|
||||
<template v-if="data.id">
|
||||
<div class="header">
|
||||
<a :href="getMusicbrainzUrl('release', data.id)" target="_blank" title="View on MusicBrainz">{{ data.title }}</a>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<a :href="getMusicbrainzUrl('artist', data['artist-credit'][0]['artist']['id'])" target="_blank" title="View on MusicBrainz">{{ data['artist-credit-phrase'] }}</a>
|
||||
</div>
|
||||
<div class="description">
|
||||
<table class="ui very basic fixed single line compact table">
|
||||
<tbody>
|
||||
<tr v-for="track in tracks">
|
||||
<td>
|
||||
{{ track.position }}
|
||||
</td>
|
||||
<td colspan="3">
|
||||
<a :href="getMusicbrainzUrl('recording', track.id)" class="discrete link" target="_blank" title="View on MusicBrainz">
|
||||
{{ track.recording.title }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
{{ time.parse(parseInt(track.length) / 1000) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue'
|
||||
import CardMixin from './CardMixin'
|
||||
import time from '@/utils/time'
|
||||
|
||||
export default Vue.extend({
|
||||
mixins: [CardMixin],
|
||||
data () {
|
||||
return {
|
||||
time
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
type () {
|
||||
return 'release'
|
||||
},
|
||||
tracks () {
|
||||
return this.data['medium-list'][0]['track-list']
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped lang="scss">
|
||||
.ui.card {
|
||||
width: 100% !important;
|
||||
}
|
||||
</style>
|
||||
153
front/src/components/metadata/Search.vue
Normal file
153
front/src/components/metadata/Search.vue
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="ui form">
|
||||
<div class="inline fields">
|
||||
<div v-for="type in types" class="field">
|
||||
<div class="ui radio checkbox">
|
||||
<input type="radio" :value="type.value" v-model="currentType">
|
||||
<label >{{ type.label }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui fluid search">
|
||||
<div class="ui icon input">
|
||||
<input class="prompt" placeholder="Enter your search query..." type="text">
|
||||
<i class="search icon"></i>
|
||||
</div>
|
||||
<div class="results"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import jQuery from 'jquery'
|
||||
import config from '@/config'
|
||||
import auth from '@/auth'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
mbType: {type: String, required: false},
|
||||
mbId: {type: String, required: false}
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
currentType: this.mbType || 'artist',
|
||||
currentId: this.mbId || '',
|
||||
types: [
|
||||
{
|
||||
value: 'artist',
|
||||
label: 'Artist'
|
||||
},
|
||||
{
|
||||
value: 'release',
|
||||
label: 'Album'
|
||||
},
|
||||
{
|
||||
value: 'recording',
|
||||
label: 'Track'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
mounted: function () {
|
||||
jQuery(this.$el).find('.ui.checkbox').checkbox()
|
||||
this.setUpSearch()
|
||||
},
|
||||
methods: {
|
||||
|
||||
setUpSearch () {
|
||||
var self = this
|
||||
jQuery(this.$el).search({
|
||||
minCharacters: 3,
|
||||
onSelect (result, response) {
|
||||
self.currentId = result.id
|
||||
},
|
||||
apiSettings: {
|
||||
beforeXHR: function (xhrObject, s) {
|
||||
xhrObject.setRequestHeader('Authorization', auth.getAuthHeader())
|
||||
return xhrObject
|
||||
},
|
||||
onResponse: function (initialResponse) {
|
||||
let category = self.currentTypeObject.value
|
||||
let results = initialResponse[category + '-list'].map(r => {
|
||||
let description = []
|
||||
if (category === 'artist') {
|
||||
if (r.type) {
|
||||
description.push(r.type)
|
||||
}
|
||||
if (r.area) {
|
||||
description.push(r.area.name)
|
||||
} else if (r['begin-area']) {
|
||||
description.push(r['begin-area'].name)
|
||||
}
|
||||
return {
|
||||
title: r.name,
|
||||
id: r.id,
|
||||
description: description.join(' - ')
|
||||
}
|
||||
}
|
||||
if (category === 'release') {
|
||||
if (r['medium-track-count']) {
|
||||
description.push(
|
||||
r['medium-track-count'] + ' tracks'
|
||||
)
|
||||
}
|
||||
if (r['artist-credit-phrase']) {
|
||||
description.push(r['artist-credit-phrase'])
|
||||
}
|
||||
if (r['date']) {
|
||||
description.push(r['date'])
|
||||
}
|
||||
return {
|
||||
title: r.title,
|
||||
id: r.id,
|
||||
description: description.join(' - ')
|
||||
}
|
||||
}
|
||||
if (category === 'recording') {
|
||||
if (r['artist-credit-phrase']) {
|
||||
description.push(r['artist-credit-phrase'])
|
||||
}
|
||||
return {
|
||||
title: r.title,
|
||||
id: r.id,
|
||||
description: description.join(' - ')
|
||||
}
|
||||
}
|
||||
})
|
||||
return {results: results}
|
||||
},
|
||||
url: this.searchUrl
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentTypeObject: function () {
|
||||
let self = this
|
||||
return this.types.filter(t => {
|
||||
return t.value === self.currentType
|
||||
})[0]
|
||||
},
|
||||
searchUrl: function () {
|
||||
return config.API_URL + 'providers/musicbrainz/search/' + this.currentTypeObject.value + 's/?query={query}'
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
currentType (newValue) {
|
||||
this.setUpSearch()
|
||||
this.$emit('type-changed', newValue)
|
||||
},
|
||||
currentId (newValue) {
|
||||
this.$emit('id-changed', newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
|
|
@ -4,11 +4,15 @@ import Home from '@/components/Home'
|
|||
import Login from '@/components/auth/Login'
|
||||
import Profile from '@/components/auth/Profile'
|
||||
import Logout from '@/components/auth/Logout'
|
||||
import Browse from '@/components/browse/Browse'
|
||||
import BrowseHome from '@/components/browse/Home'
|
||||
import BrowseArtist from '@/components/browse/Artist'
|
||||
import BrowseAlbum from '@/components/browse/Album'
|
||||
import BrowseTrack from '@/components/browse/Track'
|
||||
import Library from '@/components/library/Library'
|
||||
import LibraryHome from '@/components/library/Home'
|
||||
import LibraryArtist from '@/components/library/Artist'
|
||||
import LibraryAlbum from '@/components/library/Album'
|
||||
import LibraryTrack from '@/components/library/Track'
|
||||
import LibraryImport from '@/components/library/import/Main'
|
||||
import BatchList from '@/components/library/import/BatchList'
|
||||
import BatchDetail from '@/components/library/import/BatchDetail'
|
||||
|
||||
import Favorites from '@/components/favorites/List'
|
||||
|
||||
Vue.use(Router)
|
||||
|
|
@ -43,13 +47,27 @@ export default new Router({
|
|||
component: Favorites
|
||||
},
|
||||
{
|
||||
path: '/browse',
|
||||
component: Browse,
|
||||
path: '/library',
|
||||
component: Library,
|
||||
children: [
|
||||
{ path: '', component: BrowseHome },
|
||||
{ path: 'artist/:id', name: 'browse.artist', component: BrowseArtist, props: true },
|
||||
{ path: 'album/:id', name: 'browse.album', component: BrowseAlbum, props: true },
|
||||
{ path: 'track/:id', name: 'browse.track', component: BrowseTrack, props: true }
|
||||
{ path: '', component: LibraryHome },
|
||||
{ path: 'artist/:id', name: 'library.artist', component: LibraryArtist, props: true },
|
||||
{ path: 'album/:id', name: 'library.album', component: LibraryAlbum, props: true },
|
||||
{ path: 'track/:id', name: 'library.track', component: LibraryTrack, props: true },
|
||||
{
|
||||
path: 'import/launch',
|
||||
name: 'library.import.launch',
|
||||
component: LibraryImport,
|
||||
props: (route) => ({ mbType: route.query.type, mbId: route.query.id })
|
||||
},
|
||||
{
|
||||
path: 'import/batches',
|
||||
name: 'library.import.batches',
|
||||
component: BatchList,
|
||||
children: [
|
||||
]
|
||||
},
|
||||
{ path: 'import/batches/:id', name: 'library.import.batches.detail', component: BatchDetail, props: true }
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
16
front/src/utils/time.js
Normal file
16
front/src/utils/time.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
function pad (val) {
|
||||
val = Math.floor(val)
|
||||
if (val < 10) {
|
||||
return '0' + val
|
||||
}
|
||||
return val + ''
|
||||
}
|
||||
|
||||
export default {
|
||||
parse: function (sec) {
|
||||
let min = 0
|
||||
min = Math.floor(sec / 60)
|
||||
sec = sec - min * 60
|
||||
return pad(min) + ':' + pad(sec)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue