From a865fcdcf118540ac86d2b1042beb76598e07dea Mon Sep 17 00:00:00 2001
From: Eliot Berriot <contact@eliotberriot.com>
Date: Tue, 2 Oct 2018 19:30:13 +0200
Subject: [PATCH] Fix #551: Added a library widget to display libraries
 associated with a track, album and artist

---
 api/funkwhale_api/music/views.py              | 40 ++++++++-
 api/tests/music/test_views.py                 | 31 +++++++
 changes/changelog.d/551.enhancement           |  1 +
 front/src/components/common/CopyInput.vue     |  7 +-
 .../components/federation/LibraryWidget.vue   | 84 +++++++++++++++++++
 front/src/components/library/Album.vue        | 12 ++-
 front/src/components/library/Artist.vue       | 12 ++-
 front/src/components/library/Track.vue        | 12 ++-
 front/src/views/content/remote/Card.vue       | 21 ++++-
 9 files changed, 210 insertions(+), 10 deletions(-)
 create mode 100644 changes/changelog.d/551.enhancement
 create mode 100644 front/src/components/federation/LibraryWidget.vue

diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py
index 5c92ad2b..871dfc92 100644
--- a/api/funkwhale_api/music/views.py
+++ b/api/funkwhale_api/music/views.py
@@ -3,7 +3,7 @@ import urllib
 
 from django.conf import settings
 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.utils import timezone
 
@@ -26,6 +26,28 @@ from . import filters, models, serializers, tasks, utils
 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):
     def get_queryset(self):
         queryset = super().get_queryset()
@@ -50,6 +72,14 @@ class ArtistViewSet(viewsets.ReadOnlyModelViewSet):
         )
         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):
     queryset = (
@@ -76,6 +106,10 @@ class AlbumViewSet(viewsets.ReadOnlyModelViewSet):
         qs = queryset.prefetch_related(Prefetch("tracks", queryset=tracks))
         return qs.distinct()
 
+    libraries = detail_route(methods=["get"])(
+        get_libraries(filter_uploads=lambda o, uploads: uploads.filter(track__album=o))
+    )
+
 
 class LibraryViewSet(
     mixins.CreateModelMixin,
@@ -197,6 +231,10 @@ class TrackViewSet(TagViewSetMixin, viewsets.ReadOnlyModelViewSet):
         serializer = serializers.LyricsSerializer(lyrics)
         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):
     serve_path = settings.MUSIC_DIRECTORY_SERVE_PATH
diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py
index 691fa049..2f9d66e5 100644
--- a/api/tests/music/test_views.py
+++ b/api/tests/music/test_views.py
@@ -449,3 +449,34 @@ def test_user_can_list_own_library_follows(factories, logged_in_api_client):
         "previous": None,
         "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],
+    }
diff --git a/changes/changelog.d/551.enhancement b/changes/changelog.d/551.enhancement
new file mode 100644
index 00000000..267dba9f
--- /dev/null
+++ b/changes/changelog.d/551.enhancement
@@ -0,0 +1 @@
+Added a library widget to display libraries associated with a track, album and artist (#551)
diff --git a/front/src/components/common/CopyInput.vue b/front/src/components/common/CopyInput.vue
index c2db315b..af82f2c6 100644
--- a/front/src/components/common/CopyInput.vue
+++ b/front/src/components/common/CopyInput.vue
@@ -4,7 +4,7 @@
       <translate>Text copied to clipboard!</translate>
     </p>
     <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>
       <translate>Copy</translate>
     </button>
@@ -12,7 +12,10 @@
 </template>
 <script>
 export default {
-  props: ['value'],
+  props: {
+    value: {type: String},
+    buttonClasses: {type: String, default: 'teal'}
+  },
   data () {
     return {
       copied: false,
diff --git a/front/src/components/federation/LibraryWidget.vue b/front/src/components/federation/LibraryWidget.vue
new file mode 100644
index 00000000..ff73bb7a
--- /dev/null
+++ b/front/src/components/federation/LibraryWidget.vue
@@ -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>
diff --git a/front/src/components/library/Album.vue b/front/src/components/library/Album.vue
index 7ee5ae06..03d83e06 100644
--- a/front/src/components/library/Album.vue
+++ b/front/src/components/library/Album.vue
@@ -45,6 +45,14 @@
         </h2>
         <track-table v-if="album" :artist="album.artist" :display-position="true" :tracks="album.tracks"></track-table>
       </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>
   </div>
 </template>
@@ -55,6 +63,7 @@ import logger from '@/logging'
 import backend from '@/audio/backend'
 import PlayButton from '@/components/audio/PlayButton'
 import TrackTable from '@/components/audio/track/Table'
+import LibraryWidget from '@/components/federation/LibraryWidget'
 
 const FETCH_URL = 'albums/'
 
@@ -62,7 +71,8 @@ export default {
   props: ['id'],
   components: {
     PlayButton,
-    TrackTable
+    TrackTable,
+    LibraryWidget
   },
   data () {
     return {
diff --git a/front/src/components/library/Artist.vue b/front/src/components/library/Artist.vue
index ee9c625e..fe74f6f1 100644
--- a/front/src/components/library/Artist.vue
+++ b/front/src/components/library/Artist.vue
@@ -56,6 +56,14 @@
         </h2>
         <track-table :display-position="true" :tracks="tracks"></track-table>
       </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>
   </div>
 </template>
@@ -69,6 +77,7 @@ import AlbumCard from '@/components/audio/album/Card'
 import RadioButton from '@/components/radios/Button'
 import PlayButton from '@/components/audio/PlayButton'
 import TrackTable from '@/components/audio/track/Table'
+import LibraryWidget from '@/components/federation/LibraryWidget'
 
 export default {
   props: ['id'],
@@ -76,7 +85,8 @@ export default {
     AlbumCard,
     RadioButton,
     PlayButton,
-    TrackTable
+    TrackTable,
+    LibraryWidget
   },
   data () {
     return {
diff --git a/front/src/components/library/Track.vue b/front/src/components/library/Track.vue
index 1ede2218..75d5f650 100644
--- a/front/src/components/library/Track.vue
+++ b/front/src/components/library/Track.vue
@@ -118,6 +118,14 @@
           </a>
         </template>
       </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>
   </div>
 </template>
@@ -131,6 +139,7 @@ import logger from '@/logging'
 import PlayButton from '@/components/audio/PlayButton'
 import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
 import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon'
+import LibraryWidget from '@/components/federation/LibraryWidget'
 
 const FETCH_URL = 'tracks/'
 
@@ -139,7 +148,8 @@ export default {
   components: {
     PlayButton,
     TrackPlaylistIcon,
-    TrackFavoriteIcon
+    TrackFavoriteIcon,
+    LibraryWidget
   },
   data () {
     return {
diff --git a/front/src/views/content/remote/Card.vue b/front/src/views/content/remote/Card.vue
index 862a9a69..9cbaf857 100644
--- a/front/src/views/content/remote/Card.vue
+++ b/front/src/views/content/remote/Card.vue
@@ -26,7 +26,7 @@
         <i class="music icon"></i>
         <translate :translate-params="{count: library.uploads_count}" :translate-n="library.uploads_count" translate-plural="%{ count } tracks">%{ count } tracks</translate>
       </div>
-      <div v-if="latestScan" class="meta">
+      <div v-if="displayScan && latestScan" class="meta">
         <template v-if="latestScan.status === 'pending'">
           <i class="hourglass icon"></i>
           <translate>Scan pending</translate>
@@ -59,7 +59,7 @@
           <translate>Errored tracks:</translate> {{ latestScan.errored_files }}
         </div>
       </div>
-      <div v-if="canLaunchScan" class="clearfix">
+      <div v-if="displayScan && canLaunchScan" class="clearfix">
         <span class="right floated link" @click="launchScan">
           <translate>Launch scan</translate> <i class="paper plane icon" />
         </span>
@@ -68,7 +68,15 @@
     <div class="extra content">
       <actor-link :actor="library.actor" />
     </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
         v-if="!library.follow"
         @click="follow()"
@@ -104,7 +112,12 @@
 import axios from 'axios'
 
 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 () {
     return {
       isLoadingFollow: false,
-- 
GitLab