diff --git a/api/funkwhale_api/common/models.py b/api/funkwhale_api/common/models.py index efb2cf4fe4dc4101d9e40ba3a63f9027b58e4bc3..87f7dc8e3a4031b23ee6111c2ebcfb449234857b 100644 --- a/api/funkwhale_api/common/models.py +++ b/api/funkwhale_api/common/models.py @@ -4,6 +4,7 @@ from django.contrib.postgres.fields import JSONField from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.conf import settings +from django.core.serializers.json import DjangoJSONEncoder from django.db import models, transaction from django.db.models import Lookup from django.db.models.fields import Field @@ -70,8 +71,8 @@ class Mutation(models.Model): applied_date = models.DateTimeField(null=True, blank=True, db_index=True) summary = models.TextField(max_length=2000, null=True, blank=True) - payload = JSONField() - previous_state = JSONField(null=True, default=None) + payload = JSONField(encoder=DjangoJSONEncoder) + previous_state = JSONField(null=True, default=None, encoder=DjangoJSONEncoder) target_id = models.IntegerField(null=True) target_content_type = models.ForeignKey( diff --git a/api/funkwhale_api/federation/routes.py b/api/funkwhale_api/federation/routes.py index 9f14fd110407c45be61bba819fc5eaa681c6adf0..bae2812cea0c678d1024e96a4b50233528cb8e2e 100644 --- a/api/funkwhale_api/federation/routes.py +++ b/api/funkwhale_api/federation/routes.py @@ -346,3 +346,37 @@ def outbox_update_track(context): to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}], ), } + + +@outbox.register({"type": "Update", "object.type": "Album"}) +def outbox_update_album(context): + album = context["album"] + serializer = serializers.ActivitySerializer( + {"type": "Update", "object": serializers.AlbumSerializer(album).data} + ) + + yield { + "type": "Update", + "actor": actors.get_service_actor(), + "payload": with_recipients( + serializer.data, + to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}], + ), + } + + +@outbox.register({"type": "Update", "object.type": "Artist"}) +def outbox_update_artist(context): + artist = context["artist"] + serializer = serializers.ActivitySerializer( + {"type": "Update", "object": serializers.ArtistSerializer(artist).data} + ) + + yield { + "type": "Update", + "actor": actors.get_service_actor(), + "payload": with_recipients( + serializer.data, + to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}], + ), + } diff --git a/api/funkwhale_api/music/mutations.py b/api/funkwhale_api/music/mutations.py index fdbb7c11cd3c35fb8f2e9c1d5ca146c99d680e92..9fd91fb506d245e0ea50f5ef9cab28c7e7ee0dc1 100644 --- a/api/funkwhale_api/music/mutations.py +++ b/api/funkwhale_api/music/mutations.py @@ -28,3 +28,35 @@ class TrackMutationSerializer(mutations.UpdateMutationSerializer): routes.outbox.dispatch( {"type": "Update", "object": {"type": "Track"}}, context={"track": obj} ) + + +@mutations.registry.connect( + "update", + models.Artist, + perm_checkers={"suggest": can_suggest, "approve": can_approve}, +) +class ArtistMutationSerializer(mutations.UpdateMutationSerializer): + class Meta: + model = models.Artist + fields = ["name"] + + def post_apply(self, obj, validated_data): + routes.outbox.dispatch( + {"type": "Update", "object": {"type": "Artist"}}, context={"artist": obj} + ) + + +@mutations.registry.connect( + "update", + models.Album, + perm_checkers={"suggest": can_suggest, "approve": can_approve}, +) +class AlbumMutationSerializer(mutations.UpdateMutationSerializer): + class Meta: + model = models.Album + fields = ["title", "release_date"] + + def post_apply(self, obj, validated_data): + routes.outbox.dispatch( + {"type": "Update", "object": {"type": "Album"}}, context={"album": obj} + ) diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index eeaa80124d057b382c9bd1acc2d5cc89ea3d3465..b6df2214351499858fec275da4f535d6827f18cd 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -70,6 +70,8 @@ class ArtistViewSet(common_views.SkipFilterForGetObject, viewsets.ReadOnlyModelV filterset_class = filters.ArtistFilter ordering_fields = ("id", "name", "creation_date") + mutations = common_decorators.mutations_route(types=["update"]) + def get_queryset(self): queryset = super().get_queryset() albums = models.Album.objects.with_tracks_count() @@ -98,6 +100,8 @@ class AlbumViewSet(common_views.SkipFilterForGetObject, viewsets.ReadOnlyModelVi ordering_fields = ("creation_date", "release_date", "title") filterset_class = filters.AlbumFilter + mutations = common_decorators.mutations_route(types=["update"]) + def get_queryset(self): queryset = super().get_queryset() tracks = ( diff --git a/api/tests/federation/test_routes.py b/api/tests/federation/test_routes.py index 10b58082941fce65fd49ff83c1c758f9d3da6887..5dfef61d31c270f1a71643d8d5a167b7689e7478 100644 --- a/api/tests/federation/test_routes.py +++ b/api/tests/federation/test_routes.py @@ -448,6 +448,19 @@ def test_inbox_update_artist(factories, mocker): update_library_entity.assert_called_once_with(obj, {"name": "New name"}) +def test_outbox_update_artist(factories): + artist = factories["music.Artist"]() + activity = list(routes.outbox_update_artist({"artist": artist}))[0] + expected = serializers.ActivitySerializer( + {"type": "Update", "object": serializers.ArtistSerializer(artist).data} + ).data + + expected["to"] = [contexts.AS.Public, {"type": "instances_with_followers"}] + + assert dict(activity["payload"]) == dict(expected) + assert activity["actor"] == actors.get_service_actor() + + def test_inbox_update_album(factories, mocker): update_library_entity = mocker.patch( "funkwhale_api.music.tasks.update_library_entity" @@ -466,6 +479,19 @@ def test_inbox_update_album(factories, mocker): update_library_entity.assert_called_once_with(obj, {"title": "New title"}) +def test_outbox_update_album(factories): + album = factories["music.Album"]() + activity = list(routes.outbox_update_album({"album": album}))[0] + expected = serializers.ActivitySerializer( + {"type": "Update", "object": serializers.AlbumSerializer(album).data} + ).data + + expected["to"] = [contexts.AS.Public, {"type": "instances_with_followers"}] + + assert dict(activity["payload"]) == dict(expected) + assert activity["actor"] == actors.get_service_actor() + + def test_inbox_update_track(factories, mocker): update_library_entity = mocker.patch( "funkwhale_api.music.tasks.update_library_entity" diff --git a/api/tests/music/test_mutations.py b/api/tests/music/test_mutations.py index a8a529798b23389e057eb0adfcb19c9326e12bca..be3fb0d76cf831d026e414131b82258e9b1a11db 100644 --- a/api/tests/music/test_mutations.py +++ b/api/tests/music/test_mutations.py @@ -1,6 +1,54 @@ +import datetime +import pytest + from funkwhale_api.music import licenses +@pytest.mark.parametrize( + "field, old_value, new_value, expected", [("name", "foo", "bar", "bar")] +) +def test_artist_mutation(field, old_value, new_value, expected, factories, now, mocker): + dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch") + artist = factories["music.Artist"](**{field: old_value}) + mutation = factories["common.Mutation"]( + type="update", target=artist, payload={field: new_value} + ) + mutation.apply() + artist.refresh_from_db() + + assert getattr(artist, field) == expected + dispatch.assert_called_once_with( + {"type": "Update", "object": {"type": "Artist"}}, context={"artist": artist} + ) + + +@pytest.mark.parametrize( + "field, old_value, new_value, expected", + [ + ("title", "foo", "bar", "bar"), + ( + "release_date", + datetime.date(2016, 1, 1), + "2018-02-01", + datetime.date(2018, 2, 1), + ), + ], +) +def test_album_mutation(field, old_value, new_value, expected, factories, now, mocker): + dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch") + album = factories["music.Album"](**{field: old_value}) + mutation = factories["common.Mutation"]( + type="update", target=album, payload={field: new_value} + ) + mutation.apply() + album.refresh_from_db() + + assert getattr(album, field) == expected + dispatch.assert_called_once_with( + {"type": "Update", "object": {"type": "Album"}}, context={"album": album} + ) + + def test_track_license_mutation(factories, now): track = factories["music.Track"](license=None) mutation = factories["common.Mutation"]( diff --git a/front/src/components/library/Album.vue b/front/src/components/library/AlbumBase.vue similarity index 65% rename from front/src/components/library/Album.vue rename to front/src/components/library/AlbumBase.vue index 1a5f6b50da9e05dc8e1d0b93ba5190909b492fb4..3ff07b10af8af5d8fdd254330f96f6aee500e0ec 100644 --- a/front/src/components/library/Album.vue +++ b/front/src/components/library/AlbumBase.vue @@ -1,15 +1,15 @@ <template> <main> - <div v-if="isLoading" class="ui vertical segment" v-title=""> + <div v-if="isLoading" class="ui vertical segment" v-title="labels.title"> <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> </div> - <template v-if="album"> - <section :class="['ui', 'head', {'with-background': album.cover.original}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle" v-title="album.title"> + <template v-if="object"> + <section :class="['ui', 'head', {'with-background': object.cover.original}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle" v-title="object.title"> <div class="segment-content"> <h2 class="ui center aligned icon header"> <i class="circular inverted sound yellow icon"></i> <div class="content"> - {{ album.title }} + {{ object.title }} <div v-html="subtitle"></div> </div> </h2> @@ -17,7 +17,7 @@ <div class="header-buttons"> <div class="ui buttons"> - <play-button class="orange" :tracks="album.tracks"> + <play-button class="orange" :tracks="object.tracks"> <translate translate-context="Content/Queue/Button.Label/Short, Verb">Play all</translate> </play-button> </div> @@ -28,7 +28,7 @@ </div> <div class="content"> <div class="description"> - <embed-wizard type="album" :id="album.id" /> + <embed-wizard type="album" :id="object.id" /> </div> </div> @@ -61,15 +61,22 @@ <i class="external icon"></i> <translate translate-context="Content/*/*/Clickable, Verb">View on MusicBrainz</translate> </a> + <router-link + v-if="object.is_local" + :to="{name: 'library.albums.edit', params: {id: object.id }}" + class="basic item"> + <i class="edit icon"></i> + <translate translate-context="Content/*/Button.Label/Verb">Edit</translate> + </router-link> <div class="divider"></div> - <router-link class="basic item" v-if="$store.state.auth.availablePermissions['library']" :to="{name: 'manage.library.albums.detail', params: {id: album.id}}"> + <router-link class="basic item" v-if="$store.state.auth.availablePermissions['library']" :to="{name: 'manage.library.albums.detail', params: {id: object.id}}"> <i class="wrench icon"></i> <translate translate-context="Content/Moderation/Link">Open in moderation interface</translate> </router-link> <a v-if="$store.state.auth.profile.is_superuser" class="basic item" - :href="$store.getters['instance/absoluteUrl'](`/api/admin/music/album/${album.id}`)" + :href="$store.getters['instance/absoluteUrl'](`/api/admin/music/album/${object.id}`)" target="_blank" rel="noopener noreferrer"> <i class="wrench icon"></i> <translate translate-context="Content/Moderation/Link/Verb">View in Django's admin</translate> @@ -80,36 +87,7 @@ </div> </div> </section> - <template v-if="discs && discs.length > 1"> - <section v-for="(tracks, disc_number) in discs" class="ui vertical stripe segment"> - <translate - tag="h2" - class="left floated" - :translate-params="{number: disc_number + 1}" - translate-context="Content/Album/" - >Volume %{ number }</translate> - <play-button class="right floated orange" :tracks="tracks"> - <translate translate-context="Content/Queue/Button.Label/Short, Verb">Play all</translate> - </play-button> - <track-table :artist="album.artist" :display-position="true" :tracks="tracks"></track-table> - </section> - </template> - <template v-else> - <section class="ui vertical stripe segment"> - <h2> - <translate translate-context="*/*/*/Noun">Tracks</translate> - </h2> - <track-table v-if="album" :artist="album.artist" :display-position="true" :tracks="album.tracks"></track-table> - </section> - </template> - <section class="ui vertical stripe segment"> - <h2> - <translate translate-context="Content/*/Title/Noun">User libraries</translate> - </h2> - <library-widget @loaded="libraries = $event" :url="'albums/' + id + '/libraries/'"> - <translate slot="subtitle" translate-context="Content/Album/Paragraph">This album is present in the following libraries:</translate> - </library-widget> - </section> + <router-view v-if="object" :discs="discs" @libraries-loaded="libraries = $event" :object="object" object-type="album" :key="$route.fullPath"></router-view> </template> </main> </template> @@ -119,13 +97,12 @@ import axios from "axios" 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" import EmbedWizard from "@/components/audio/EmbedWizard" import Modal from '@/components/semantic/Modal' const FETCH_URL = "albums/" + function groupByDisc(acc, track) { var dn = track.disc_number - 1 if (dn < 0) dn = 0 @@ -141,15 +118,13 @@ export default { props: ["id"], components: { PlayButton, - TrackTable, - LibraryWidget, EmbedWizard, Modal }, data() { return { isLoading: true, - album: null, + object: null, discs: [], libraries: [], showEmbedModal: false @@ -165,8 +140,8 @@ export default { let url = FETCH_URL + this.id + "/" logger.default.debug('Fetching album "' + this.id + '"') axios.get(url).then(response => { - self.album = backend.Album.clean(response.data) - self.discs = self.album.tracks.reduce(groupByDisc, []) + self.object = backend.Album.clean(response.data) + self.discs = self.object.tracks.reduce(groupByDisc, []) self.isLoading = false }) } @@ -185,28 +160,28 @@ export default { wikipediaUrl() { return ( "https://en.wikipedia.org/w/index.php?search=" + - encodeURI(this.album.title + " " + this.album.artist.name) + encodeURI(this.object.title + " " + this.object.artist.name) ) }, musicbrainzUrl() { - if (this.album.mbid) { - return "https://musicbrainz.org/release/" + this.album.mbid + if (this.object.mbid) { + return "https://musicbrainz.org/release/" + this.object.mbid } }, headerStyle() { - if (!this.album.cover.original) { + if (!this.object.cover.original) { return "" } return ( "background-image: url(" + - this.$store.getters["instance/absoluteUrl"](this.album.cover.original) + + this.$store.getters["instance/absoluteUrl"](this.object.cover.original) + ")" ) }, subtitle () { - let route = this.$router.resolve({name: 'library.artists.detail', params: {id: this.album.artist.id }}) - let msg = this.$npgettext('Content/Album/Header.Title', 'Album containing %{ count } track, by <a class="internal" href="%{ artistUrl }">%{ artist }</a>', 'Album containing %{ count } tracks, by <a class="internal" href="%{ artistUrl }">%{ artist }</a>', this.album.tracks.length) - return this.$gettextInterpolate(msg, {count: this.album.tracks.length, artist: this.album.artist.name, artistUrl: route.location.path}) + let route = this.$router.resolve({name: 'library.artists.detail', params: {id: this.object.artist.id }}) + let msg = this.$npgettext('Content/Album/Header.Title', 'Album containing %{ count } track, by <a class="internal" href="%{ artistUrl }">%{ artist }</a>', 'Album containing %{ count } tracks, by <a class="internal" href="%{ artistUrl }">%{ artist }</a>', this.object.tracks.length) + return this.$gettextInterpolate(msg, {count: this.object.tracks.length, artist: this.object.artist.name, artistUrl: route.location.path}) } }, watch: { @@ -216,7 +191,3 @@ export default { } } </script> - -<!-- Add "scoped" attribute to limit CSS to this component only --> -<style scoped lang="scss"> -</style> diff --git a/front/src/components/library/AlbumDetail.vue b/front/src/components/library/AlbumDetail.vue new file mode 100644 index 0000000000000000000000000000000000000000..d695b6e596b3d46de95774d8191f18e7309bb277 --- /dev/null +++ b/front/src/components/library/AlbumDetail.vue @@ -0,0 +1,62 @@ +<template> + <div v-if="object"> + <template v-if="discs && discs.length > 1"> + <section v-for="(tracks, disc_number) in discs" class="ui vertical stripe segment"> + <translate + tag="h2" + class="left floated" + :translate-params="{number: disc_number + 1}" + translate-context="Content/Album/" + >Volume %{ number }</translate> + <play-button class="right floated orange" :tracks="tracks"> + <translate translate-context="Content/Queue/Button.Label/Short, Verb">Play all</translate> + </play-button> + <track-table :artist="object.artist" :display-position="true" :tracks="tracks"></track-table> + </section> + </template> + <template v-else> + <section class="ui vertical stripe segment"> + <h2> + <translate translate-context="*/*/*/Noun">Tracks</translate> + </h2> + <track-table v-if="object" :artist="object.artist" :display-position="true" :tracks="object.tracks"></track-table> + </section> + </template> + <section class="ui vertical stripe segment"> + <h2> + <translate translate-context="Content/*/Title/Noun">User libraries</translate> + </h2> + <library-widget @loaded="$emit('libraries-loaded', $event)" :url="'albums/' + object.id + '/libraries/'"> + <translate slot="subtitle" translate-context="Content/Album/Paragraph">This album is present in the following libraries:</translate> + </library-widget> + </section> + </div> +</template> + +<script> + +import time from "@/utils/time" +import axios from "axios" +import url from "@/utils/url" +import logger from "@/logging" +import LibraryWidget from "@/components/federation/LibraryWidget" +import TrackTable from "@/components/audio/track/Table" + +export default { + props: ["object", "libraries", "discs"], + components: { + LibraryWidget, + TrackTable + }, + data() { + return { + time, + id: this.object.id, + } + }, +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped lang="scss"> +</style> diff --git a/front/src/components/library/AlbumEdit.vue b/front/src/components/library/AlbumEdit.vue new file mode 100644 index 0000000000000000000000000000000000000000..b7c24737c1dd5e07b737dd1a6e088f1edc179920 --- /dev/null +++ b/front/src/components/library/AlbumEdit.vue @@ -0,0 +1,41 @@ +<template> + + <section class="ui vertical stripe segment"> + <div class="ui text container"> + <h2> + <translate v-if="canEdit" key="1" translate-context="Content/*/Title">Edit this album</translate> + <translate v-else key="2" translate-context="Content/*/Title">Suggest an edit on this album</translate> + </h2> + <div class="ui message" v-if="!object.is_local"> + <translate translate-context="Content/*/Message">This object is managed by another server, you cannot edit it.</translate> + </div> + <edit-form + v-else + :object-type="objectType" + :object="object" + :can-edit="canEdit"></edit-form> + </div> + </section> +</template> + +<script> +import axios from "axios" + +import EditForm from '@/components/library/EditForm' +export default { + props: ["objectType", "object", "libraries"], + data() { + return { + id: this.object.id, + } + }, + components: { + EditForm + }, + computed: { + canEdit () { + return true + } + } +} +</script> diff --git a/front/src/components/library/Artist.vue b/front/src/components/library/ArtistBase.vue similarity index 67% rename from front/src/components/library/Artist.vue rename to front/src/components/library/ArtistBase.vue index 4d6e10e2d755a924b294a85f1bdb49c340d17e46..d4efcb82ec101214830506fbe1f23057dbd32dcd 100644 --- a/front/src/components/library/Artist.vue +++ b/front/src/components/library/ArtistBase.vue @@ -3,13 +3,13 @@ <div v-if="isLoading" class="ui vertical segment"> <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> </div> - <template v-if="artist"> - <section :class="['ui', 'head', {'with-background': cover}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle" v-title="artist.name"> + <template v-if="object"> + <section :class="['ui', 'head', {'with-background': cover}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle" v-title="object.name"> <div class="segment-content"> <h2 class="ui center aligned icon header"> <i class="circular inverted users violet icon"></i> <div class="content"> - {{ artist.name }} + {{ object.name }} <div class="sub header" v-if="albums"> <translate translate-context="Content/Artist/Paragraph" tag="div" @@ -24,11 +24,11 @@ <div class="ui hidden divider"></div> <div class="header-buttons"> <div class="ui buttons"> - <radio-button type="artist" :object-id="artist.id"></radio-button> + <radio-button type="artist" :object-id="object.id"></radio-button> </div> <div class="ui buttons"> - <play-button :is-playable="isPlayable" class="orange" :artist="artist"> + <play-button :is-playable="isPlayable" class="orange" :artist="object"> <translate translate-context="Content/Artist/Button.Label/Verb">Play all albums</translate> </play-button> </div> @@ -39,7 +39,7 @@ </div> <div class="content"> <div class="description"> - <embed-wizard type="artist" :id="artist.id" /> + <embed-wizard type="artist" :id="object.id" /> </div> </div> @@ -72,15 +72,22 @@ <i class="external icon"></i> <translate translate-context="Content/*/*/Clickable, Verb">View on MusicBrainz</translate> </a> + <router-link + v-if="object.is_local" + :to="{name: 'library.artists.edit', params: {id: object.id }}" + class="basic item"> + <i class="edit icon"></i> + <translate translate-context="Content/*/Button.Label/Verb">Edit</translate> + </router-link> <div class="divider"></div> - <router-link class="basic item" v-if="$store.state.auth.availablePermissions['library']" :to="{name: 'manage.library.artists.detail', params: {id: artist.id}}"> + <router-link class="basic item" v-if="$store.state.auth.availablePermissions['library']" :to="{name: 'manage.library.artists.detail', params: {id: object.id}}"> <i class="wrench icon"></i> <translate translate-context="Content/Moderation/Link">Open in moderation interface</translate> </router-link> <a v-if="$store.state.auth.profile.is_superuser" class="basic item" - :href="$store.getters['instance/absoluteUrl'](`/api/admin/music/artist/${artist.id}`)" + :href="$store.getters['instance/absoluteUrl'](`/api/admin/music/artist/${object.id}`)" target="_blank" rel="noopener noreferrer"> <i class="wrench icon"></i> <translate translate-context="Content/Moderation/Link/Verb">View in Django's admin</translate> @@ -91,84 +98,40 @@ </div> </div> </section> - <div class="ui small text container" v-if="contentFilter"> - <div class="ui hidden divider"></div> - <div class="ui message"> - <p> - <translate translate-context="Content/Artist/Paragraph">You are currently hiding content related to this artist.</translate> - </p> - <router-link class="right floated" :to="{name: 'settings'}"> - <translate translate-context="Content/Moderation/Link">Review my filters</translate> - </router-link> - <button @click="$store.dispatch('moderation/deleteContentFilter', contentFilter.uuid)" class="ui basic tiny button"> - <translate translate-context="Content/Moderation/Button.Label">Remove filter</translate> - </button> - </div> - </div> - <section v-if="isLoadingAlbums" class="ui vertical stripe segment"> - <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> - </section> - <section v-else-if="albums && albums.length > 0" class="ui vertical stripe segment"> - <h2> - <translate translate-context="Content/Artist/Title">Albums by this artist</translate> - </h2> - <div class="ui cards" > - <album-card :mode="'rich'" :album="album" :key="album.id" v-for="album in albums"></album-card> - </div> - </section> - <section v-if="tracks.length > 0" class="ui vertical stripe segment"> - <h2> - <translate translate-context="Content/Artist/Title">Tracks by this artist</translate> - </h2> - <track-table :display-position="true" :tracks="tracks"></track-table> - </section> - <section class="ui vertical stripe segment"> - <h2> - <translate translate-context="Content/*/Title/Noun">User libraries</translate> - </h2> - <library-widget @loaded="libraries = $event" :url="'artists/' + id + '/libraries/'"> - <translate translate-context="Content/Artist/Paragraph" slot="subtitle">This artist is present in the following libraries:</translate> - </library-widget> - </section> + <router-view v-if="object" :tracks="tracks" :albums="albums" :is-loading-albums="isLoadingAlbums" @libraries-loaded="libraries = $event" :object="object" object-type="artist" :key="$route.fullPath"></router-view> </template> </main> </template> <script> -import _ from "@/lodash" import axios from "axios" import logger from "@/logging" import backend from "@/audio/backend" -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" import EmbedWizard from "@/components/audio/EmbedWizard" import Modal from '@/components/semantic/Modal' +import RadioButton from "@/components/radios/Button" + +const FETCH_URL = "albums/" + export default { props: ["id"], components: { - AlbumCard, - RadioButton, PlayButton, - TrackTable, - LibraryWidget, EmbedWizard, - Modal + Modal, + RadioButton }, data() { return { isLoading: true, isLoadingAlbums: true, - artist: null, + object: null, albums: null, - totalTracks: 0, - totalAlbums: 0, - tracks: [], libraries: [], - showEmbedModal: false + showEmbedModal: false, + tracks: [], } }, created() { @@ -184,7 +147,7 @@ export default { self.totalTracks = response.data.count }) axios.get("artists/" + this.id + "/").then(response => { - self.artist = response.data + self.object = response.data self.isLoading = false self.isLoadingAlbums = true axios @@ -204,40 +167,31 @@ export default { } }, computed: { - labels() { - return { - title: this.$pgettext('*/*/*/Noun', "Artist") - } - }, isPlayable() { return ( - this.artist.albums.filter(a => { + this.object.albums.filter(a => { return a.is_playable }).length > 0 ) }, + labels() { + return { + title: this.$pgettext('*/*/*', 'Album') + } + }, wikipediaUrl() { return ( "https://en.wikipedia.org/w/index.php?search=" + - encodeURI(this.artist.name) + encodeURI(this.object.name) ) }, musicbrainzUrl() { - if (this.artist.mbid) { - return "https://musicbrainz.org/artist/" + this.artist.mbid + if (this.object.mbid) { + return "https://musicbrainz.org/artist/" + this.object.mbid } }, - allTracks() { - let tracks = [] - this.albums.forEach(album => { - album.tracks.forEach(track => { - tracks.push(track) - }) - }) - return tracks - }, cover() { - return this.artist.albums + return this.object.albums .filter(album => { return album.cover }) @@ -264,7 +218,7 @@ export default { contentFilter () { let self = this return this.$store.getters['moderation/artistFilters']().filter((e) => { - return e.target.id === this.artist.id + return e.target.id === this.object.id })[0] } }, @@ -275,7 +229,3 @@ export default { } } </script> - -<!-- Add "scoped" attribute to limit CSS to this component only --> -<style scoped> -</style> diff --git a/front/src/components/library/ArtistDetail.vue b/front/src/components/library/ArtistDetail.vue new file mode 100644 index 0000000000000000000000000000000000000000..50e1856e91cab720459674997c1b33581a316949 --- /dev/null +++ b/front/src/components/library/ArtistDetail.vue @@ -0,0 +1,79 @@ +<template> + <div v-if="object"> + <div class="ui small text container" v-if="contentFilter"> + <div class="ui hidden divider"></div> + <div class="ui message"> + <p> + <translate translate-context="Content/Artist/Paragraph">You are currently hiding content related to this artist.</translate> + </p> + <router-link class="right floated" :to="{name: 'settings'}"> + <translate translate-context="Content/Moderation/Link">Review my filters</translate> + </router-link> + <button @click="$store.dispatch('moderation/deleteContentFilter', contentFilter.uuid)" class="ui basic tiny button"> + <translate translate-context="Content/Moderation/Button.Label">Remove filter</translate> + </button> + </div> + </div> + <section v-if="isLoadingAlbums" class="ui vertical stripe segment"> + <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> + </section> + <section v-else-if="albums && albums.length > 0" class="ui vertical stripe segment"> + <h2> + <translate translate-context="Content/Artist/Title">Albums by this artist</translate> + </h2> + <div class="ui cards" > + <album-card :mode="'rich'" :album="album" :key="album.id" v-for="album in albums"></album-card> + </div> + </section> + <section v-if="tracks.length > 0" class="ui vertical stripe segment"> + <h2> + <translate translate-context="Content/Artist/Title">Tracks by this artist</translate> + </h2> + <track-table :display-position="true" :tracks="tracks"></track-table> + </section> + <section class="ui vertical stripe segment"> + <h2> + <translate translate-context="Content/*/Title/Noun">User libraries</translate> + </h2> + <library-widget @loaded="$emit('libraries-loaded', $event)" :url="'artists/' + object.id + '/libraries/'"> + <translate translate-context="Content/Artist/Paragraph" slot="subtitle">This artist is present in the following libraries:</translate> + </library-widget> + </section> + </div> +</template> + +<script> +import _ from "@/lodash" +import axios from "axios" +import logger from "@/logging" +import backend from "@/audio/backend" +import AlbumCard from "@/components/audio/album/Card" +import TrackTable from "@/components/audio/track/Table" +import LibraryWidget from "@/components/federation/LibraryWidget" + +export default { + props: ["object", "tracks", "albums", "isLoadingAlbums"], + components: { + AlbumCard, + TrackTable, + LibraryWidget, + }, + computed: { + contentFilter () { + let self = this + return this.$store.getters['moderation/artistFilters']().filter((e) => { + return e.target.id === this.object.id + })[0] + } + }, + watch: { + id() { + this.fetchData() + } + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +</style> diff --git a/front/src/components/library/ArtistEdit.vue b/front/src/components/library/ArtistEdit.vue new file mode 100644 index 0000000000000000000000000000000000000000..80a9ae0c3ce130720f62b389b6dac3a2c3c1baf9 --- /dev/null +++ b/front/src/components/library/ArtistEdit.vue @@ -0,0 +1,41 @@ +<template> + + <section class="ui vertical stripe segment"> + <div class="ui text container"> + <h2> + <translate v-if="canEdit" key="1" translate-context="Content/*/Title">Edit this artist</translate> + <translate v-else key="2" translate-context="Content/*/Title">Suggest an edit on this artist</translate> + </h2> + <div class="ui message" v-if="!object.is_local"> + <translate translate-context="Content/*/Message">This object is managed by another server, you cannot edit it.</translate> + </div> + <edit-form + v-else + :object-type="objectType" + :object="object" + :can-edit="canEdit"></edit-form> + </div> + </section> +</template> + +<script> +import axios from "axios" + +import EditForm from '@/components/library/EditForm' +export default { + props: ["objectType", "object", "libraries"], + data() { + return { + id: this.object.id, + } + }, + components: { + EditForm + }, + computed: { + canEdit () { + return true + } + } +} +</script> diff --git a/front/src/components/library/EditForm.vue b/front/src/components/library/EditForm.vue index a2df96c0018dcf0d2f9bf5dc461be7cf872632c1..617917c6812f03924934c48ec0e7d92079f95a72 100644 --- a/front/src/components/library/EditForm.vue +++ b/front/src/components/library/EditForm.vue @@ -149,6 +149,12 @@ export default { if (this.objectType === 'track') { return `tracks/${this.object.id}/mutations/` } + if (this.objectType === 'album') { + return `albums/${this.object.id}/mutations/` + } + if (this.objectType === 'artist') { + return `artists/${this.object.id}/mutations/` + } }, mutationPayload () { let self = this diff --git a/front/src/edits.js b/front/src/edits.js index c72cb4b09822bb716a9358e49b046f26141e901b..a53ab2fcc286d31815b81b99cb4a161a53416c4a 100644 --- a/front/src/edits.js +++ b/front/src/edits.js @@ -1,13 +1,42 @@ export default { getConfigs () { return { + artist: { + fields: [ + { + id: 'name', + type: 'text', + required: true, + label: this.$pgettext('*/*/*/Noun', 'Name'), + getValue: (obj) => { return obj.name } + }, + ] + }, + album: { + fields: [ + { + id: 'title', + type: 'text', + required: true, + label: this.$pgettext('*/*/*/Noun', 'Title'), + getValue: (obj) => { return obj.title } + }, + { + id: 'release_date', + type: 'text', + required: false, + label: this.$pgettext('Content/*/*/Noun', 'Release date'), + getValue: (obj) => { return obj.release_date } + }, + ] + }, track: { fields: [ { id: 'title', type: 'text', required: true, - label: this.$pgettext('Content/Track/*/Noun', 'Title'), + label: this.$pgettext('*/*/*/Noun', 'Title'), getValue: (obj) => { return obj.title } }, { diff --git a/front/src/router/index.js b/front/src/router/index.js index bee09f20281da8c94c6543c07ba63820bfa4f67b..f9332f5f556683067ae0fe03b187a2826d6e0e41 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -16,10 +16,14 @@ import PasswordResetConfirm from '@/views/auth/PasswordResetConfirm' import EmailConfirm from '@/views/auth/EmailConfirm' import Library from '@/components/library/Library' import LibraryHome from '@/components/library/Home' -import LibraryArtist from '@/components/library/Artist' import LibraryArtists from '@/components/library/Artists' +import LibraryArtistDetail from '@/components/library/ArtistDetail' +import LibraryArtistEdit from '@/components/library/ArtistEdit' +import LibraryArtistDetailBase from '@/components/library/ArtistBase' import LibraryAlbums from '@/components/library/Albums' -import LibraryAlbum from '@/components/library/Album' +import LibraryAlbumDetail from '@/components/library/AlbumDetail' +import LibraryAlbumEdit from '@/components/library/AlbumEdit' +import LibraryAlbumDetailBase from '@/components/library/AlbumBase' import LibraryTrackDetail from '@/components/library/TrackDetail' import LibraryTrackEdit from '@/components/library/TrackEdit' import EditDetail from '@/components/library/EditDetail' @@ -411,8 +415,52 @@ export default new Router({ id: route.params.id, defaultEdit: route.query.mode === 'edit' }) }, - { path: 'artists/:id', name: 'library.artists.detail', component: LibraryArtist, props: true }, - { path: 'albums/:id', name: 'library.albums.detail', component: LibraryAlbum, props: true }, + { + path: 'artists/:id', + component: LibraryArtistDetailBase, + props: true, + children: [ + { + path: '', + name: 'library.artists.detail', + component: LibraryArtistDetail + }, + { + path: 'edit', + name: 'library.artists.edit', + component: LibraryArtistEdit + }, + { + path: 'edit/:editId', + name: 'library.artists.edit.detail', + component: EditDetail, + props: true, + } + ] + }, + { + path: 'albums/:id', + component: LibraryAlbumDetailBase, + props: true, + children: [ + { + path: '', + name: 'library.albums.detail', + component: LibraryAlbumDetail + }, + { + path: 'edit', + name: 'library.albums.edit', + component: LibraryAlbumEdit + }, + { + path: 'edit/:editId', + name: 'library.albums.edit.detail', + component: EditDetail, + props: true, + } + ] + }, { path: 'tracks/:id', component: LibraryTrackDetailBase,