diff --git a/api/config/api_urls.py b/api/config/api_urls.py index e75781d14c3061892520290867d068f29991a016..98b863a93c3f108299ee67975e12df70ec9094c3 100644 --- a/api/config/api_urls.py +++ b/api/config/api_urls.py @@ -38,6 +38,10 @@ v1_patterns += [ include( ('funkwhale_api.instance.urls', 'instance'), namespace='instance')), + url(r'^manage/', + include( + ('funkwhale_api.manage.urls', 'manage'), + namespace='manage')), url(r'^federation/', include( ('funkwhale_api.federation.api_urls', 'federation'), diff --git a/api/config/settings/common.py b/api/config/settings/common.py index f376781b0498580db756062ab94204afbde77191..50c62e9d56ad60e281f0989c3d3e3c9062c4673d 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -97,6 +97,7 @@ THIRD_PARTY_APPS = ( 'dynamic_preferences', 'django_filters', 'cacheops', + 'django_cleanup', ) diff --git a/api/funkwhale_api/common/serializers.py b/api/funkwhale_api/common/serializers.py index 62d9c567e9693cb9a5542c5456b71e79315e6af5..a995cc360eca4792d02dacd8acffdb255690d5bc 100644 --- a/api/funkwhale_api/common/serializers.py +++ b/api/funkwhale_api/common/serializers.py @@ -12,6 +12,9 @@ class ActionSerializer(serializers.Serializer): filters = serializers.DictField(required=False) actions = None filterset_class = None + # those are actions identifier where we don't want to allow the "all" + # selector because it's to dangerous. Like object deletion. + dangerous_actions = [] def __init__(self, *args, **kwargs): self.queryset = kwargs.pop('queryset') @@ -49,6 +52,10 @@ class ActionSerializer(serializers.Serializer): 'list of identifiers or the string "all".'.format(value)) def validate(self, data): + dangerous = data['action'] in self.dangerous_actions + if dangerous and self.initial_data['objects'] == 'all': + raise serializers.ValidationError( + 'This action is to dangerous to be applied to all objects') if self.filterset_class and 'filters' in data: qs_filterset = self.filterset_class( data['filters'], queryset=data['objects']) diff --git a/api/funkwhale_api/manage/__init__.py b/api/funkwhale_api/manage/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..03e091e5c5ed1abf61b267f35a379206a3ab391c --- /dev/null +++ b/api/funkwhale_api/manage/__init__.py @@ -0,0 +1,3 @@ +""" +App that includes all views/serializers and stuff for management API +""" diff --git a/api/funkwhale_api/manage/filters.py b/api/funkwhale_api/manage/filters.py new file mode 100644 index 0000000000000000000000000000000000000000..9853b7a61fb3f0018f41ad2c497e635613523edd --- /dev/null +++ b/api/funkwhale_api/manage/filters.py @@ -0,0 +1,25 @@ +from django.db.models import Count + +from django_filters import rest_framework as filters + +from funkwhale_api.common import fields +from funkwhale_api.music import models as music_models + + +class ManageTrackFileFilterSet(filters.FilterSet): + q = fields.SearchFilter(search_fields=[ + 'track__title', + 'track__album__title', + 'track__artist__name', + 'source', + ]) + + class Meta: + model = music_models.TrackFile + fields = [ + 'q', + 'track__album', + 'track__artist', + 'track', + 'library_track' + ] diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py new file mode 100644 index 0000000000000000000000000000000000000000..02300ec0689c16f7250e5557a3c05d1eb2f07006 --- /dev/null +++ b/api/funkwhale_api/manage/serializers.py @@ -0,0 +1,82 @@ +from django.db import transaction +from rest_framework import serializers + +from funkwhale_api.common import serializers as common_serializers +from funkwhale_api.music import models as music_models + +from . import filters + + +class ManageTrackFileArtistSerializer(serializers.ModelSerializer): + class Meta: + model = music_models.Artist + fields = [ + 'id', + 'mbid', + 'creation_date', + 'name', + ] + + +class ManageTrackFileAlbumSerializer(serializers.ModelSerializer): + artist = ManageTrackFileArtistSerializer() + + class Meta: + model = music_models.Album + fields = ( + 'id', + 'mbid', + 'title', + 'artist', + 'release_date', + 'cover', + 'creation_date', + ) + + +class ManageTrackFileTrackSerializer(serializers.ModelSerializer): + artist = ManageTrackFileArtistSerializer() + album = ManageTrackFileAlbumSerializer() + + class Meta: + model = music_models.Track + fields = ( + 'id', + 'mbid', + 'title', + 'album', + 'artist', + 'creation_date', + 'position', + ) + + +class ManageTrackFileSerializer(serializers.ModelSerializer): + track = ManageTrackFileTrackSerializer() + + class Meta: + model = music_models.TrackFile + fields = ( + 'id', + 'path', + 'source', + 'filename', + 'mimetype', + 'track', + 'duration', + 'mimetype', + 'bitrate', + 'size', + 'path', + 'library_track', + ) + + +class ManageTrackFileActionSerializer(common_serializers.ActionSerializer): + actions = ['delete'] + dangerous_actions = ['delete'] + filterset_class = filters.ManageTrackFileFilterSet + + @transaction.atomic + def handle_delete(self, objects): + return objects.delete() diff --git a/api/funkwhale_api/manage/urls.py b/api/funkwhale_api/manage/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..c434581ecde46de48d4b87f36984923dfcff84fa --- /dev/null +++ b/api/funkwhale_api/manage/urls.py @@ -0,0 +1,11 @@ +from django.conf.urls import include, url +from . import views + +from rest_framework import routers +library_router = routers.SimpleRouter() +library_router.register(r'track-files', views.ManageTrackFileViewSet, 'track-files') + +urlpatterns = [ + url(r'^library/', + include((library_router.urls, 'instance'), namespace='library')), +] diff --git a/api/funkwhale_api/manage/views.py b/api/funkwhale_api/manage/views.py new file mode 100644 index 0000000000000000000000000000000000000000..74059caa1d97a1cef31fcf7408a49e7486447f93 --- /dev/null +++ b/api/funkwhale_api/manage/views.py @@ -0,0 +1,49 @@ +from rest_framework import mixins +from rest_framework import response +from rest_framework import viewsets +from rest_framework.decorators import list_route + +from funkwhale_api.music import models as music_models +from funkwhale_api.users.permissions import HasUserPermission + +from . import filters +from . import serializers + + +class ManageTrackFileViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet): + queryset = ( + music_models.TrackFile.objects.all() + .select_related( + 'track__artist', + 'track__album__artist', + 'library_track') + .order_by('-id') + ) + serializer_class = serializers.ManageTrackFileSerializer + filter_class = filters.ManageTrackFileFilterSet + permission_classes = (HasUserPermission,) + required_permissions = ['library'] + ordering_fields = [ + 'accessed_date', + 'modification_date', + 'creation_date', + 'track__artist__name', + 'bitrate', + 'size', + 'duration', + ] + + @list_route(methods=['post']) + def action(self, request, *args, **kwargs): + queryset = self.get_queryset() + serializer = serializers.ManageTrackFileActionSerializer( + request.data, + queryset=queryset, + ) + serializer.is_valid(raise_exception=True) + result = serializer.save() + return response.Response(result, status=200) diff --git a/api/funkwhale_api/music/factories.py b/api/funkwhale_api/music/factories.py index 412e2f798835579217f6fa84b35e926d59baaba9..11423f5b0134936b9efeed1b352e3e4dc6bdcd1c 100644 --- a/api/funkwhale_api/music/factories.py +++ b/api/funkwhale_api/music/factories.py @@ -117,6 +117,11 @@ class ImportJobFactory(factory.django.DjangoModelFactory): status='finished', audio_file=None, ) + with_audio_file = factory.Trait( + status='finished', + audio_file=factory.django.FileField( + from_path=os.path.join(SAMPLES_PATH, 'test.ogg')), + ) @registry.register(name='music.FileImportJob') diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index a3c5bd0bfe3ea18d5133190b7105bc2e477a9b25..fcf78d0473efa38a00e7d8e1f12d1e4a0533768e 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -27,7 +27,7 @@ PERMISSIONS_CONFIGURATION = { }, 'library': { 'label': 'Manage library', - 'help_text': 'Manage library', + 'help_text': 'Manage library, delete files, tracks, artists, albums...', }, 'settings': { 'label': 'Manage instance-level settings', diff --git a/api/requirements/base.txt b/api/requirements/base.txt index d88483de4f2d131eb419db22b74dd71246d6dcd9..13c0efdbc7df9547a6991fc589d37312de98909d 100644 --- a/api/requirements/base.txt +++ b/api/requirements/base.txt @@ -65,3 +65,4 @@ cryptography>=2,<3 # requests-http-signature==0.0.3 # clone until the branch is merged and released upstream git+https://github.com/EliotBerriot/requests-http-signature.git@signature-header-support +django-cleanup==2.1.0 diff --git a/api/tests/common/test_serializers.py b/api/tests/common/test_serializers.py index 5636765562d61c6262f7f17b77c85c3b5b98b128..f0f5fb7e61c8bd1d30fd14dec1b0edb28c5d31ab 100644 --- a/api/tests/common/test_serializers.py +++ b/api/tests/common/test_serializers.py @@ -18,6 +18,17 @@ class TestSerializer(serializers.ActionSerializer): return {'hello': 'world'} +class TestDangerousSerializer(serializers.ActionSerializer): + actions = ['test', 'test_dangerous'] + dangerous_actions = ['test_dangerous'] + + def handle_test(self, objects): + pass + + def handle_test_dangerous(self, objects): + pass + + def test_action_serializer_validates_action(): data = {'objects': 'all', 'action': 'nope'} serializer = TestSerializer(data, queryset=models.User.objects.none()) @@ -98,3 +109,28 @@ def test_action_serializers_validates_at_least_one_object(): assert serializer.is_valid() is False assert 'non_field_errors' in serializer.errors + + +def test_dangerous_actions_refuses_all(factories): + factories['users.User']() + data = { + 'objects': 'all', + 'action': 'test_dangerous', + } + serializer = TestDangerousSerializer( + data, queryset=models.User.objects.all()) + + assert serializer.is_valid() is False + assert 'non_field_errors' in serializer.errors + + +def test_dangerous_actions_refuses_not_listed(factories): + factories['users.User']() + data = { + 'objects': 'all', + 'action': 'test', + } + serializer = TestDangerousSerializer( + data, queryset=models.User.objects.all()) + + assert serializer.is_valid() is True diff --git a/api/tests/manage/__init__.py b/api/tests/manage/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/tests/manage/test_serializers.py b/api/tests/manage/test_serializers.py new file mode 100644 index 0000000000000000000000000000000000000000..45167722ca2f95ef23eb3c98b8b96491d41207e0 --- /dev/null +++ b/api/tests/manage/test_serializers.py @@ -0,0 +1,10 @@ +from funkwhale_api.manage import serializers + + +def test_manage_track_file_action_delete(factories): + tfs = factories['music.TrackFile'](size=5) + s = serializers.ManageTrackFileActionSerializer(queryset=None) + + s.handle_delete(tfs.__class__.objects.all()) + + assert tfs.__class__.objects.count() == 0 diff --git a/api/tests/manage/test_views.py b/api/tests/manage/test_views.py new file mode 100644 index 0000000000000000000000000000000000000000..db2e0980a8b512e5267dbfe5882fcb87a76a24f3 --- /dev/null +++ b/api/tests/manage/test_views.py @@ -0,0 +1,26 @@ +import pytest + +from django.urls import reverse + +from funkwhale_api.manage import serializers +from funkwhale_api.manage import views + + +@pytest.mark.parametrize('view,permissions,operator', [ + (views.ManageTrackFileViewSet, ['library'], 'and'), +]) +def test_permissions(assert_user_permission, view, permissions, operator): + assert_user_permission(view, permissions, operator) + + +def test_track_file_view(factories, superuser_api_client): + tfs = factories['music.TrackFile'].create_batch(size=5) + qs = tfs[0].__class__.objects.order_by('-creation_date') + url = reverse('api:v1:manage:library:track-files-list') + + response = superuser_api_client.get(url, {'sort': '-creation_date'}) + expected = serializers.ManageTrackFileSerializer( + qs, many=True, context={'request': response.wsgi_request}).data + + assert response.data['count'] == len(tfs) + assert response.data['results'] == expected diff --git a/changes/changelog.d/223.feature b/changes/changelog.d/223.feature new file mode 100644 index 0000000000000000000000000000000000000000..c2f104ba59381635755c8ea249f04f50c8873686 --- /dev/null +++ b/changes/changelog.d/223.feature @@ -0,0 +1,10 @@ +Files management interface for users with "library" permission (#223) + +Files management interface +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +This is the first bit of an ongoing work that will span several releases, to +bring more powerful library management features to Funkwhale. This iteration +includes a basic file management interface where users with the "library" +permission can list and search available files, order them using +various criterias (size, bitrate, duration...) and delete them. diff --git a/changes/changelog.d/241.enhancement b/changes/changelog.d/241.enhancement new file mode 100644 index 0000000000000000000000000000000000000000..00c84c4977a48eba574cc8cd0d5a6e070443130a --- /dev/null +++ b/changes/changelog.d/241.enhancement @@ -0,0 +1 @@ +Autoremove media files on model instance deletion (#241) diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index e8f330c38aa28b19f56d98efc507ec1b2d1c56b1..72c55847fa3ed09c1cd428cba0347495f3608903 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -68,6 +68,12 @@ :title="$t('Pending import requests')"> {{ notifications.importRequests }}</div> </router-link> + <router-link + class="item" + v-if="$store.state.auth.availablePermissions['library']" + :to="{name: 'manage.library.files'}"> + <i class="book icon"></i>{{ $t('Library') }} + </router-link> <router-link class="item" v-else-if="$store.state.auth.availablePermissions['upload']" diff --git a/front/src/components/common/ActionTable.vue b/front/src/components/common/ActionTable.vue index 718e57b19b61672913d0de6145ec7447c69ac0cf..5221c328292c317c199850d6c9a3de58fec188f9 100644 --- a/front/src/components/common/ActionTable.vue +++ b/front/src/components/common/ActionTable.vue @@ -21,7 +21,7 @@ :class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']"> {{ $t('Go') }}</div> <dangerous-button - v-else :class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']" + v-else-if="!currentAction.isDangerous" :class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']" confirm-color="green" color="" @confirm="launchAction"> @@ -36,7 +36,7 @@ <div class="count field"> <span v-if="selectAll">{{ $t('{% count %} on {% total %} selected', {count: objectsData.count, total: objectsData.count}) }}</span> <span v-else>{{ $t('{% count %} on {% total %} selected', {count: checked.length, total: objectsData.count}) }}</span> - <template v-if="checkable.length === checked.length"> + <template v-if="!currentAction.isDangerous && checkable.length === checked.length"> <a @click="selectAll = true" v-if="!selectAll"> {{ $t('Select all {% total %} elements', {total: objectsData.count}) }} </a> diff --git a/front/src/components/manage/library/FilesTable.vue b/front/src/components/manage/library/FilesTable.vue new file mode 100644 index 0000000000000000000000000000000000000000..2788006f4a4b1c63ca3e5ff8445cad38f2ef7880 --- /dev/null +++ b/front/src/components/manage/library/FilesTable.vue @@ -0,0 +1,206 @@ +<template> + <div> + <div class="ui inline form"> + <div class="fields"> + <div class="ui field"> + <label>{{ $t('Search') }}</label> + <input type="text" v-model="search" placeholder="Search by title, artist, domain..." /> + </div> + <div class="field"> + <i18next tag="label" path="Ordering"/> + <select class="ui dropdown" v-model="ordering"> + <option v-for="option in orderingOptions" :value="option[0]"> + {{ option[1] }} + </option> + </select> + </div> + <div class="field"> + <i18next tag="label" path="Ordering direction"/> + <select class="ui dropdown" v-model="orderingDirection"> + <option value="+">Ascending</option> + <option value="-">Descending</option> + </select> + </div> + </div> + </div> + <div class="dimmable"> + <div v-if="isLoading" class="ui active inverted dimmer"> + <div class="ui loader"></div> + </div> + <action-table + v-if="result" + @action-launched="fetchData" + :objects-data="result" + :actions="actions" + :action-url="'manage/library/track-files/action/'" + :filters="actionFilters"> + <template slot="header-cells"> + <th>{{ $t('Title') }}</th> + <th>{{ $t('Artist') }}</th> + <th>{{ $t('Album') }}</th> + <th>{{ $t('Import date') }}</th> + <th>{{ $t('Type') }}</th> + <th>{{ $t('Bitrate') }}</th> + <th>{{ $t('Duration') }}</th> + <th>{{ $t('Size') }}</th> + </template> + <template slot="row-cells" slot-scope="scope"> + <td> + <span :title="scope.obj.track.title">{{ scope.obj.track.title|truncate(30) }}</span> + </td> + <td> + <span :title="scope.obj.track.artist.name">{{ scope.obj.track.artist.name|truncate(30) }}</span> + </td> + <td> + <span :title="scope.obj.track.album.title">{{ scope.obj.track.album.title|truncate(20) }}</span> + </td> + <td> + <human-date :date="scope.obj.creation_date"></human-date> + </td> + <td v-if="scope.obj.audio_mimetype"> + {{ scope.obj.audio_mimetype }} + </td> + <td v-else> + {{ $t('N/A') }} + </td> + <td v-if="scope.obj.bitrate"> + {{ scope.obj.bitrate | humanSize }}/s + </td> + <td v-else> + {{ $t('N/A') }} + </td> + <td v-if="scope.obj.duration"> + {{ time.parse(scope.obj.duration) }} + </td> + <td v-else> + {{ $t('N/A') }} + </td> + <td v-if="scope.obj.size"> + {{ scope.obj.size | humanSize }} + </td> + <td v-else> + {{ $t('N/A') }} + </td> + </template> + </action-table> + </div> + <div> + <pagination + v-if="result && result.results.length > 0" + @page-changed="selectPage" + :compact="true" + :current="page" + :paginate-by="paginateBy" + :total="result.count" + ></pagination> + + <span v-if="result && result.results.length > 0"> + {{ $t('Showing results {%start%}-{%end%} on {%total%}', {start: ((page-1) * paginateBy) + 1 , end: ((page-1) * paginateBy) + result.results.length, total: result.count})}} + </span> + </div> + </div> +</template> + +<script> +import axios from 'axios' +import _ from 'lodash' +import time from '@/utils/time' +import Pagination from '@/components/Pagination' +import ActionTable from '@/components/common/ActionTable' +import OrderingMixin from '@/components/mixins/Ordering' + +export default { + mixins: [OrderingMixin], + props: { + filters: {type: Object, required: false} + }, + components: { + Pagination, + ActionTable + }, + data () { + let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date') + return { + time, + isLoading: false, + result: null, + page: 1, + paginateBy: 25, + search: '', + orderingDirection: defaultOrdering.direction || '+', + ordering: defaultOrdering.field, + orderingOptions: [ + ['creation_date', 'Creation date'], + ['accessed_date', 'Accessed date'], + ['modification_date', 'Modification date'], + ['size', 'Size'], + ['bitrate', 'Bitrate'], + ['duration', 'Duration'] + ] + + } + }, + created () { + this.fetchData() + }, + methods: { + fetchData () { + let params = _.merge({ + 'page': this.page, + 'page_size': this.paginateBy, + 'q': this.search, + 'ordering': this.getOrderingAsString() + }, this.filters) + let self = this + self.isLoading = true + self.checked = [] + axios.get('/manage/library/track-files/', {params: params}).then((response) => { + self.result = response.data + self.isLoading = false + }, error => { + self.isLoading = false + self.errors = error.backendErrors + }) + }, + selectPage: function (page) { + this.page = page + } + }, + computed: { + actionFilters () { + var currentFilters = { + q: this.search + } + if (this.filters) { + return _.merge(currentFilters, this.filters) + } else { + return currentFilters + } + }, + actions () { + return [ + { + name: 'delete', + label: this.$t('Delete'), + isDangerous: true + } + ] + } + }, + watch: { + search (newValue) { + this.page = 1 + this.fetchData() + }, + page () { + this.fetchData() + }, + ordering () { + this.fetchData() + }, + orderingDirection () { + this.fetchData() + } + } +} +</script> diff --git a/front/src/router/index.js b/front/src/router/index.js index f71dab7f92decf05f5df0ca2129207bf7830fb48..a52070e35912b42813db85f3c8ac195f6e39e4d2 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -29,6 +29,8 @@ import PlaylistDetail from '@/views/playlists/Detail' import PlaylistList from '@/views/playlists/List' import Favorites from '@/components/favorites/List' import AdminSettings from '@/views/admin/Settings' +import AdminLibraryBase from '@/views/admin/library/Base' +import AdminLibraryFilesList from '@/views/admin/library/FilesList' import FederationBase from '@/views/federation/Base' import FederationScan from '@/views/federation/Scan' import FederationLibraryDetail from '@/views/federation/LibraryDetail' @@ -167,6 +169,17 @@ export default new Router({ { path: 'libraries/:id', name: 'federation.libraries.detail', component: FederationLibraryDetail, props: true } ] }, + { + path: '/manage/library', + component: AdminLibraryBase, + children: [ + { + path: 'files', + name: 'manage.library.files', + component: AdminLibraryFilesList + } + ] + }, { path: '/library', component: Library, diff --git a/front/src/views/admin/library/Base.vue b/front/src/views/admin/library/Base.vue new file mode 100644 index 0000000000000000000000000000000000000000..834fca920f62f195cb83bbd37c4d65dd47ce946a --- /dev/null +++ b/front/src/views/admin/library/Base.vue @@ -0,0 +1,28 @@ +<template> + <div class="main pusher" v-title="'Manage library'"> + <div class="ui secondary pointing menu"> + <router-link + class="ui item" + :to="{name: 'manage.library.files'}">{{ $t('Files') }}</router-link> + </div> + <router-view :key="$route.fullPath"></router-view> + </div> +</template> + +<script> +export default {} +</script> + +<style lang="scss"> +@import '../../../style/vendor/media'; + +.main.pusher > .ui.secondary.menu { + @include media(">tablet") { + margin: 0 2.5rem; + } + .item { + padding-top: 1.5em; + padding-bottom: 1.5em; + } +} +</style> diff --git a/front/src/views/admin/library/FilesList.vue b/front/src/views/admin/library/FilesList.vue new file mode 100644 index 0000000000000000000000000000000000000000..9c52de5767042d9a2ddfe805965ef316d3dc5933 --- /dev/null +++ b/front/src/views/admin/library/FilesList.vue @@ -0,0 +1,23 @@ +<template> + <div v-title="'Files'"> + <div class="ui vertical stripe segment"> + <h2 class="ui header">{{ $t('Library files') }}</h2> + <div class="ui hidden divider"></div> + <library-files-table :show-library="true"></library-files-table> + </div> + </div> +</template> + +<script> +import LibraryFilesTable from '@/components/manage/library/FilesTable' + +export default { + components: { + LibraryFilesTable + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +</style>