Fix #551: Added a library widget to display libraries associated with a track, album and artist
This commit is contained in:
parent
f2812c67ce
commit
a865fcdcf1
|
@ -3,7 +3,7 @@ import urllib
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Count, Prefetch, Sum, F
|
from django.db.models import Count, Prefetch, Sum, F, Q
|
||||||
from django.db.models.functions import Length
|
from django.db.models.functions import Length
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
@ -26,6 +26,28 @@ from . import filters, models, serializers, tasks, utils
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_libraries(filter_uploads):
|
||||||
|
def view(self, request, *args, **kwargs):
|
||||||
|
obj = self.get_object()
|
||||||
|
actor = utils.get_actor_from_request(request)
|
||||||
|
uploads = models.Upload.objects.all()
|
||||||
|
uploads = filter_uploads(obj, uploads)
|
||||||
|
uploads = uploads.playable_by(actor)
|
||||||
|
libraries = models.Library.objects.filter(
|
||||||
|
pk__in=uploads.values_list("library", flat=True)
|
||||||
|
)
|
||||||
|
libraries = libraries.select_related("actor")
|
||||||
|
page = self.paginate_queryset(libraries)
|
||||||
|
if page is not None:
|
||||||
|
serializer = federation_api_serializers.LibrarySerializer(page, many=True)
|
||||||
|
return self.get_paginated_response(serializer.data)
|
||||||
|
|
||||||
|
serializer = federation_api_serializers.LibrarySerializer(libraries, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
return view
|
||||||
|
|
||||||
|
|
||||||
class TagViewSetMixin(object):
|
class TagViewSetMixin(object):
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = super().get_queryset()
|
queryset = super().get_queryset()
|
||||||
|
@ -50,6 +72,14 @@ class ArtistViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
)
|
)
|
||||||
return queryset.prefetch_related(Prefetch("albums", queryset=albums)).distinct()
|
return queryset.prefetch_related(Prefetch("albums", queryset=albums)).distinct()
|
||||||
|
|
||||||
|
libraries = detail_route(methods=["get"])(
|
||||||
|
get_libraries(
|
||||||
|
filter_uploads=lambda o, uploads: uploads.filter(
|
||||||
|
Q(track__artist=o) | Q(track__album__artist=o)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AlbumViewSet(viewsets.ReadOnlyModelViewSet):
|
class AlbumViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
queryset = (
|
queryset = (
|
||||||
|
@ -76,6 +106,10 @@ class AlbumViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
qs = queryset.prefetch_related(Prefetch("tracks", queryset=tracks))
|
qs = queryset.prefetch_related(Prefetch("tracks", queryset=tracks))
|
||||||
return qs.distinct()
|
return qs.distinct()
|
||||||
|
|
||||||
|
libraries = detail_route(methods=["get"])(
|
||||||
|
get_libraries(filter_uploads=lambda o, uploads: uploads.filter(track__album=o))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class LibraryViewSet(
|
class LibraryViewSet(
|
||||||
mixins.CreateModelMixin,
|
mixins.CreateModelMixin,
|
||||||
|
@ -197,6 +231,10 @@ class TrackViewSet(TagViewSetMixin, viewsets.ReadOnlyModelViewSet):
|
||||||
serializer = serializers.LyricsSerializer(lyrics)
|
serializer = serializers.LyricsSerializer(lyrics)
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
libraries = detail_route(methods=["get"])(
|
||||||
|
get_libraries(filter_uploads=lambda o, uploads: uploads.filter(track=o))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_file_path(audio_file):
|
def get_file_path(audio_file):
|
||||||
serve_path = settings.MUSIC_DIRECTORY_SERVE_PATH
|
serve_path = settings.MUSIC_DIRECTORY_SERVE_PATH
|
||||||
|
|
|
@ -449,3 +449,34 @@ def test_user_can_list_own_library_follows(factories, logged_in_api_client):
|
||||||
"previous": None,
|
"previous": None,
|
||||||
"results": [federation_api_serializers.LibraryFollowSerializer(follow).data],
|
"results": [federation_api_serializers.LibraryFollowSerializer(follow).data],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("entity", ["artist", "album", "track"])
|
||||||
|
def test_can_get_libraries_for_music_entities(
|
||||||
|
factories, api_client, entity, preferences
|
||||||
|
):
|
||||||
|
preferences["common__api_authentication_required"] = False
|
||||||
|
upload = factories["music.Upload"](playable=True)
|
||||||
|
# another private library that should not appear
|
||||||
|
factories["music.Upload"](
|
||||||
|
import_status="finished", library__privacy_level="me", track=upload.track
|
||||||
|
).library
|
||||||
|
library = upload.library
|
||||||
|
data = {
|
||||||
|
"artist": upload.track.artist,
|
||||||
|
"album": upload.track.album,
|
||||||
|
"track": upload.track,
|
||||||
|
}
|
||||||
|
|
||||||
|
url = reverse("api:v1:{}s-libraries".format(entity), kwargs={"pk": data[entity].pk})
|
||||||
|
|
||||||
|
response = api_client.get(url)
|
||||||
|
expected = federation_api_serializers.LibrarySerializer(library).data
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data == {
|
||||||
|
"count": 1,
|
||||||
|
"next": None,
|
||||||
|
"previous": None,
|
||||||
|
"results": [expected],
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
Added a library widget to display libraries associated with a track, album and artist (#551)
|
|
@ -4,7 +4,7 @@
|
||||||
<translate>Text copied to clipboard!</translate>
|
<translate>Text copied to clipboard!</translate>
|
||||||
</p>
|
</p>
|
||||||
<input ref="input" :value="value" type="text">
|
<input ref="input" :value="value" type="text">
|
||||||
<button @click="copy" class="ui teal right labeled icon button">
|
<button @click="copy" :class="['ui', buttonClasses, 'right', 'labeled', 'icon', 'button']">
|
||||||
<i class="copy icon"></i>
|
<i class="copy icon"></i>
|
||||||
<translate>Copy</translate>
|
<translate>Copy</translate>
|
||||||
</button>
|
</button>
|
||||||
|
@ -12,7 +12,10 @@
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: ['value'],
|
props: {
|
||||||
|
value: {type: String},
|
||||||
|
buttonClasses: {type: String, default: 'teal'}
|
||||||
|
},
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
copied: false,
|
copied: false,
|
||||||
|
|
|
@ -0,0 +1,84 @@
|
||||||
|
<template>
|
||||||
|
<div class="wrapper">
|
||||||
|
<h3 class="ui header">
|
||||||
|
<slot name="title"></slot>
|
||||||
|
</h3>
|
||||||
|
<p v-if="!isLoading && libraries.length > 0" class="ui subtitle"><slot name="subtitle"></slot></p>
|
||||||
|
<p v-if="!isLoading && libraries.length === 0" class="ui subtitle"><translate>No matching library.</translate></p>
|
||||||
|
<i @click="fetchData(previousPage)" :disabled="!previousPage" :class="['ui', {disabled: !previousPage}, 'circular', 'medium', 'angle left', 'icon']">
|
||||||
|
</i>
|
||||||
|
<i @click="fetchData(nextPage)" :disabled="!nextPage" :class="['ui', {disabled: !nextPage}, 'circular', 'medium', 'angle right', 'icon']">
|
||||||
|
</i>
|
||||||
|
<div class="ui hidden divider"></div>
|
||||||
|
<div class="ui three cards">
|
||||||
|
<div v-if="isLoading" class="ui inverted active dimmer">
|
||||||
|
<div class="ui loader"></div>
|
||||||
|
</div>
|
||||||
|
<library-card
|
||||||
|
:display-scan="false"
|
||||||
|
:display-follow="$store.state.auth.authenticated"
|
||||||
|
:library="library"
|
||||||
|
:display-copy-fid="true"
|
||||||
|
v-for="library in libraries"
|
||||||
|
:key="library.uuid"></library-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import _ from 'lodash'
|
||||||
|
import axios from 'axios'
|
||||||
|
import LibraryCard from '@/views/content/remote/Card'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
url: {type: String, required: true}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
LibraryCard
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
libraries: [],
|
||||||
|
limit: 6,
|
||||||
|
isLoading: false,
|
||||||
|
errors: null,
|
||||||
|
previousPage: null,
|
||||||
|
nextPage: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created () {
|
||||||
|
this.fetchData()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
fetchData () {
|
||||||
|
this.isLoading = true
|
||||||
|
let self = this
|
||||||
|
let params = _.clone({})
|
||||||
|
params.page_size = this.limit
|
||||||
|
params.offset = this.offset
|
||||||
|
axios.get(this.url, {params: params}).then((response) => {
|
||||||
|
self.previousPage = response.data.previous
|
||||||
|
self.nextPage = response.data.next
|
||||||
|
self.isLoading = false
|
||||||
|
self.libraries = response.data.results
|
||||||
|
}, error => {
|
||||||
|
self.isLoading = false
|
||||||
|
self.errors = error.backendErrors
|
||||||
|
})
|
||||||
|
},
|
||||||
|
updateOffset (increment) {
|
||||||
|
if (increment) {
|
||||||
|
this.offset += this.limit
|
||||||
|
} else {
|
||||||
|
this.offset = Math.max(this.offset - this.limit, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
offset () {
|
||||||
|
this.fetchData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -45,6 +45,14 @@
|
||||||
</h2>
|
</h2>
|
||||||
<track-table v-if="album" :artist="album.artist" :display-position="true" :tracks="album.tracks"></track-table>
|
<track-table v-if="album" :artist="album.artist" :display-position="true" :tracks="album.tracks"></track-table>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ui vertical stripe segment">
|
||||||
|
<h2>
|
||||||
|
<translate>User libraries</translate>
|
||||||
|
</h2>
|
||||||
|
<library-widget :url="'albums/' + id + '/libraries/'">
|
||||||
|
<translate slot="subtitle">This album is present in the following libraries:</translate>
|
||||||
|
</library-widget>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -55,6 +63,7 @@ import logger from '@/logging'
|
||||||
import backend from '@/audio/backend'
|
import backend from '@/audio/backend'
|
||||||
import PlayButton from '@/components/audio/PlayButton'
|
import PlayButton from '@/components/audio/PlayButton'
|
||||||
import TrackTable from '@/components/audio/track/Table'
|
import TrackTable from '@/components/audio/track/Table'
|
||||||
|
import LibraryWidget from '@/components/federation/LibraryWidget'
|
||||||
|
|
||||||
const FETCH_URL = 'albums/'
|
const FETCH_URL = 'albums/'
|
||||||
|
|
||||||
|
@ -62,7 +71,8 @@ export default {
|
||||||
props: ['id'],
|
props: ['id'],
|
||||||
components: {
|
components: {
|
||||||
PlayButton,
|
PlayButton,
|
||||||
TrackTable
|
TrackTable,
|
||||||
|
LibraryWidget
|
||||||
},
|
},
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -56,6 +56,14 @@
|
||||||
</h2>
|
</h2>
|
||||||
<track-table :display-position="true" :tracks="tracks"></track-table>
|
<track-table :display-position="true" :tracks="tracks"></track-table>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ui vertical stripe segment">
|
||||||
|
<h2>
|
||||||
|
<translate>User libraries</translate>
|
||||||
|
</h2>
|
||||||
|
<library-widget :url="'artists/' + id + '/libraries/'">
|
||||||
|
<translate slot="subtitle">This artist is present in the following libraries:</translate>
|
||||||
|
</library-widget>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -69,6 +77,7 @@ import AlbumCard from '@/components/audio/album/Card'
|
||||||
import RadioButton from '@/components/radios/Button'
|
import RadioButton from '@/components/radios/Button'
|
||||||
import PlayButton from '@/components/audio/PlayButton'
|
import PlayButton from '@/components/audio/PlayButton'
|
||||||
import TrackTable from '@/components/audio/track/Table'
|
import TrackTable from '@/components/audio/track/Table'
|
||||||
|
import LibraryWidget from '@/components/federation/LibraryWidget'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: ['id'],
|
props: ['id'],
|
||||||
|
@ -76,7 +85,8 @@ export default {
|
||||||
AlbumCard,
|
AlbumCard,
|
||||||
RadioButton,
|
RadioButton,
|
||||||
PlayButton,
|
PlayButton,
|
||||||
TrackTable
|
TrackTable,
|
||||||
|
LibraryWidget
|
||||||
},
|
},
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -118,6 +118,14 @@
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ui vertical stripe segment">
|
||||||
|
<h2>
|
||||||
|
<translate>User libraries</translate>
|
||||||
|
</h2>
|
||||||
|
<library-widget :url="'tracks/' + id + '/libraries/'">
|
||||||
|
<translate slot="subtitle">This track is present in the following libraries:</translate>
|
||||||
|
</library-widget>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -131,6 +139,7 @@ import logger from '@/logging'
|
||||||
import PlayButton from '@/components/audio/PlayButton'
|
import PlayButton from '@/components/audio/PlayButton'
|
||||||
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
|
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
|
||||||
import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon'
|
import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon'
|
||||||
|
import LibraryWidget from '@/components/federation/LibraryWidget'
|
||||||
|
|
||||||
const FETCH_URL = 'tracks/'
|
const FETCH_URL = 'tracks/'
|
||||||
|
|
||||||
|
@ -139,7 +148,8 @@ export default {
|
||||||
components: {
|
components: {
|
||||||
PlayButton,
|
PlayButton,
|
||||||
TrackPlaylistIcon,
|
TrackPlaylistIcon,
|
||||||
TrackFavoriteIcon
|
TrackFavoriteIcon,
|
||||||
|
LibraryWidget
|
||||||
},
|
},
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -26,7 +26,7 @@
|
||||||
<i class="music icon"></i>
|
<i class="music icon"></i>
|
||||||
<translate :translate-params="{count: library.uploads_count}" :translate-n="library.uploads_count" translate-plural="%{ count } tracks">%{ count } tracks</translate>
|
<translate :translate-params="{count: library.uploads_count}" :translate-n="library.uploads_count" translate-plural="%{ count } tracks">%{ count } tracks</translate>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="latestScan" class="meta">
|
<div v-if="displayScan && latestScan" class="meta">
|
||||||
<template v-if="latestScan.status === 'pending'">
|
<template v-if="latestScan.status === 'pending'">
|
||||||
<i class="hourglass icon"></i>
|
<i class="hourglass icon"></i>
|
||||||
<translate>Scan pending</translate>
|
<translate>Scan pending</translate>
|
||||||
|
@ -59,7 +59,7 @@
|
||||||
<translate>Errored tracks:</translate> {{ latestScan.errored_files }}
|
<translate>Errored tracks:</translate> {{ latestScan.errored_files }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="canLaunchScan" class="clearfix">
|
<div v-if="displayScan && canLaunchScan" class="clearfix">
|
||||||
<span class="right floated link" @click="launchScan">
|
<span class="right floated link" @click="launchScan">
|
||||||
<translate>Launch scan</translate> <i class="paper plane icon" />
|
<translate>Launch scan</translate> <i class="paper plane icon" />
|
||||||
</span>
|
</span>
|
||||||
|
@ -68,7 +68,15 @@
|
||||||
<div class="extra content">
|
<div class="extra content">
|
||||||
<actor-link :actor="library.actor" />
|
<actor-link :actor="library.actor" />
|
||||||
</div>
|
</div>
|
||||||
<div class="ui bottom attached buttons">
|
<div v-if="displayCopyFid" class="extra content">
|
||||||
|
<div class="ui form">
|
||||||
|
<div class="field">
|
||||||
|
<label><translate>Sharing link</translate></label>
|
||||||
|
<copy-input :button-classes="'basic'" :value="library.fid" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="displayFollow" class="ui bottom attached buttons">
|
||||||
<button
|
<button
|
||||||
v-if="!library.follow"
|
v-if="!library.follow"
|
||||||
@click="follow()"
|
@click="follow()"
|
||||||
|
@ -104,7 +112,12 @@
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: ['library'],
|
props: {
|
||||||
|
library: {type: Object, required: true},
|
||||||
|
displayFollow: {type: Boolean, default: true},
|
||||||
|
displayScan: {type: Boolean, default: true},
|
||||||
|
displayCopyFid: {type: Boolean, default: false},
|
||||||
|
},
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
isLoadingFollow: false,
|
isLoadingFollow: false,
|
||||||
|
|
Loading…
Reference in New Issue