Merge branch '689-artist-album-mutations' into 'develop'

Edits for artists and albums

See merge request funkwhale/funkwhale!722
This commit is contained in:
Eliot Berriot 2019-04-17 16:11:24 +02:00
commit 63b1007596
15 changed files with 523 additions and 151 deletions

View File

@ -4,6 +4,7 @@ from django.contrib.postgres.fields import JSONField
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.conf import settings from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
from django.db import models, transaction from django.db import models, transaction
from django.db.models import Lookup from django.db.models import Lookup
from django.db.models.fields import Field 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) applied_date = models.DateTimeField(null=True, blank=True, db_index=True)
summary = models.TextField(max_length=2000, null=True, blank=True) summary = models.TextField(max_length=2000, null=True, blank=True)
payload = JSONField() payload = JSONField(encoder=DjangoJSONEncoder)
previous_state = JSONField(null=True, default=None) previous_state = JSONField(null=True, default=None, encoder=DjangoJSONEncoder)
target_id = models.IntegerField(null=True) target_id = models.IntegerField(null=True)
target_content_type = models.ForeignKey( target_content_type = models.ForeignKey(

View File

@ -346,3 +346,37 @@ def outbox_update_track(context):
to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}], 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"}],
),
}

View File

@ -28,3 +28,35 @@ class TrackMutationSerializer(mutations.UpdateMutationSerializer):
routes.outbox.dispatch( routes.outbox.dispatch(
{"type": "Update", "object": {"type": "Track"}}, context={"track": obj} {"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}
)

View File

@ -70,6 +70,8 @@ class ArtistViewSet(common_views.SkipFilterForGetObject, viewsets.ReadOnlyModelV
filterset_class = filters.ArtistFilter filterset_class = filters.ArtistFilter
ordering_fields = ("id", "name", "creation_date") ordering_fields = ("id", "name", "creation_date")
mutations = common_decorators.mutations_route(types=["update"])
def get_queryset(self): def get_queryset(self):
queryset = super().get_queryset() queryset = super().get_queryset()
albums = models.Album.objects.with_tracks_count() 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") ordering_fields = ("creation_date", "release_date", "title")
filterset_class = filters.AlbumFilter filterset_class = filters.AlbumFilter
mutations = common_decorators.mutations_route(types=["update"])
def get_queryset(self): def get_queryset(self):
queryset = super().get_queryset() queryset = super().get_queryset()
tracks = ( tracks = (

View File

@ -448,6 +448,19 @@ def test_inbox_update_artist(factories, mocker):
update_library_entity.assert_called_once_with(obj, {"name": "New name"}) 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): def test_inbox_update_album(factories, mocker):
update_library_entity = mocker.patch( update_library_entity = mocker.patch(
"funkwhale_api.music.tasks.update_library_entity" "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"}) 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): def test_inbox_update_track(factories, mocker):
update_library_entity = mocker.patch( update_library_entity = mocker.patch(
"funkwhale_api.music.tasks.update_library_entity" "funkwhale_api.music.tasks.update_library_entity"

View File

@ -1,6 +1,54 @@
import datetime
import pytest
from funkwhale_api.music import licenses 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): def test_track_license_mutation(factories, now):
track = factories["music.Track"](license=None) track = factories["music.Track"](license=None)
mutation = factories["common.Mutation"]( mutation = factories["common.Mutation"](

View File

@ -1,15 +1,15 @@
<template> <template>
<main> <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 :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
</div> </div>
<template v-if="album"> <template v-if="object">
<section :class="['ui', 'head', {'with-background': album.cover.original}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle" v-title="album.title"> <section :class="['ui', 'head', {'with-background': object.cover.original}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle" v-title="object.title">
<div class="segment-content"> <div class="segment-content">
<h2 class="ui center aligned icon header"> <h2 class="ui center aligned icon header">
<i class="circular inverted sound yellow icon"></i> <i class="circular inverted sound yellow icon"></i>
<div class="content"> <div class="content">
{{ album.title }} {{ object.title }}
<div v-html="subtitle"></div> <div v-html="subtitle"></div>
</div> </div>
</h2> </h2>
@ -17,7 +17,7 @@
<div class="header-buttons"> <div class="header-buttons">
<div class="ui 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> <translate translate-context="Content/Queue/Button.Label/Short, Verb">Play all</translate>
</play-button> </play-button>
</div> </div>
@ -28,7 +28,7 @@
</div> </div>
<div class="content"> <div class="content">
<div class="description"> <div class="description">
<embed-wizard type="album" :id="album.id" /> <embed-wizard type="album" :id="object.id" />
</div> </div>
</div> </div>
@ -61,15 +61,22 @@
<i class="external icon"></i> <i class="external icon"></i>
<translate translate-context="Content/*/*/Clickable, Verb">View on MusicBrainz</translate> <translate translate-context="Content/*/*/Clickable, Verb">View on MusicBrainz</translate>
</a> </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> <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> <i class="wrench icon"></i>
<translate translate-context="Content/Moderation/Link">Open in moderation interface</translate> <translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
</router-link> </router-link>
<a <a
v-if="$store.state.auth.profile.is_superuser" v-if="$store.state.auth.profile.is_superuser"
class="basic item" 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"> target="_blank" rel="noopener noreferrer">
<i class="wrench icon"></i> <i class="wrench icon"></i>
<translate translate-context="Content/Moderation/Link/Verb">View in Django's admin</translate>&nbsp; <translate translate-context="Content/Moderation/Link/Verb">View in Django's admin</translate>&nbsp;
@ -80,36 +87,7 @@
</div> </div>
</div> </div>
</section> </section>
<template v-if="discs && discs.length > 1"> <router-view v-if="object" :discs="discs" @libraries-loaded="libraries = $event" :object="object" object-type="album" :key="$route.fullPath"></router-view>
<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>
</template> </template>
</main> </main>
</template> </template>
@ -119,13 +97,12 @@ import axios from "axios"
import logger from "@/logging" 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 LibraryWidget from "@/components/federation/LibraryWidget"
import EmbedWizard from "@/components/audio/EmbedWizard" import EmbedWizard from "@/components/audio/EmbedWizard"
import Modal from '@/components/semantic/Modal' import Modal from '@/components/semantic/Modal'
const FETCH_URL = "albums/" const FETCH_URL = "albums/"
function groupByDisc(acc, track) { function groupByDisc(acc, track) {
var dn = track.disc_number - 1 var dn = track.disc_number - 1
if (dn < 0) dn = 0 if (dn < 0) dn = 0
@ -141,15 +118,13 @@ export default {
props: ["id"], props: ["id"],
components: { components: {
PlayButton, PlayButton,
TrackTable,
LibraryWidget,
EmbedWizard, EmbedWizard,
Modal Modal
}, },
data() { data() {
return { return {
isLoading: true, isLoading: true,
album: null, object: null,
discs: [], discs: [],
libraries: [], libraries: [],
showEmbedModal: false showEmbedModal: false
@ -165,8 +140,8 @@ export default {
let url = FETCH_URL + this.id + "/" let url = FETCH_URL + this.id + "/"
logger.default.debug('Fetching album "' + this.id + '"') logger.default.debug('Fetching album "' + this.id + '"')
axios.get(url).then(response => { axios.get(url).then(response => {
self.album = backend.Album.clean(response.data) self.object = backend.Album.clean(response.data)
self.discs = self.album.tracks.reduce(groupByDisc, []) self.discs = self.object.tracks.reduce(groupByDisc, [])
self.isLoading = false self.isLoading = false
}) })
} }
@ -185,28 +160,28 @@ export default {
wikipediaUrl() { wikipediaUrl() {
return ( return (
"https://en.wikipedia.org/w/index.php?search=" + "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() { musicbrainzUrl() {
if (this.album.mbid) { if (this.object.mbid) {
return "https://musicbrainz.org/release/" + this.album.mbid return "https://musicbrainz.org/release/" + this.object.mbid
} }
}, },
headerStyle() { headerStyle() {
if (!this.album.cover.original) { if (!this.object.cover.original) {
return "" return ""
} }
return ( return (
"background-image: url(" + "background-image: url(" +
this.$store.getters["instance/absoluteUrl"](this.album.cover.original) + this.$store.getters["instance/absoluteUrl"](this.object.cover.original) +
")" ")"
) )
}, },
subtitle () { subtitle () {
let route = this.$router.resolve({name: 'library.artists.detail', params: {id: this.album.artist.id }}) 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.album.tracks.length) 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.album.tracks.length, artist: this.album.artist.name, artistUrl: route.location.path}) return this.$gettextInterpolate(msg, {count: this.object.tracks.length, artist: this.object.artist.name, artistUrl: route.location.path})
} }
}, },
watch: { watch: {
@ -216,7 +191,3 @@ export default {
} }
} }
</script> </script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="scss">
</style>

View File

@ -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>

View File

@ -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>

View File

@ -3,13 +3,13 @@
<div v-if="isLoading" class="ui vertical segment"> <div v-if="isLoading" class="ui vertical segment">
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
</div> </div>
<template v-if="artist"> <template v-if="object">
<section :class="['ui', 'head', {'with-background': cover}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle" v-title="artist.name"> <section :class="['ui', 'head', {'with-background': cover}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle" v-title="object.name">
<div class="segment-content"> <div class="segment-content">
<h2 class="ui center aligned icon header"> <h2 class="ui center aligned icon header">
<i class="circular inverted users violet icon"></i> <i class="circular inverted users violet icon"></i>
<div class="content"> <div class="content">
{{ artist.name }} {{ object.name }}
<div class="sub header" v-if="albums"> <div class="sub header" v-if="albums">
<translate translate-context="Content/Artist/Paragraph" <translate translate-context="Content/Artist/Paragraph"
tag="div" tag="div"
@ -24,11 +24,11 @@
<div class="ui hidden divider"></div> <div class="ui hidden divider"></div>
<div class="header-buttons"> <div class="header-buttons">
<div class="ui 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>
<div class="ui buttons"> <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> <translate translate-context="Content/Artist/Button.Label/Verb">Play all albums</translate>
</play-button> </play-button>
</div> </div>
@ -39,7 +39,7 @@
</div> </div>
<div class="content"> <div class="content">
<div class="description"> <div class="description">
<embed-wizard type="artist" :id="artist.id" /> <embed-wizard type="artist" :id="object.id" />
</div> </div>
</div> </div>
@ -72,15 +72,22 @@
<i class="external icon"></i> <i class="external icon"></i>
<translate translate-context="Content/*/*/Clickable, Verb">View on MusicBrainz</translate> <translate translate-context="Content/*/*/Clickable, Verb">View on MusicBrainz</translate>
</a> </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> <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> <i class="wrench icon"></i>
<translate translate-context="Content/Moderation/Link">Open in moderation interface</translate> <translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
</router-link> </router-link>
<a <a
v-if="$store.state.auth.profile.is_superuser" v-if="$store.state.auth.profile.is_superuser"
class="basic item" 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"> target="_blank" rel="noopener noreferrer">
<i class="wrench icon"></i> <i class="wrench icon"></i>
<translate translate-context="Content/Moderation/Link/Verb">View in Django's admin</translate>&nbsp; <translate translate-context="Content/Moderation/Link/Verb">View in Django's admin</translate>&nbsp;
@ -91,84 +98,40 @@
</div> </div>
</div> </div>
</section> </section>
<div class="ui small text container" v-if="contentFilter"> <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>
<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>
</template> </template>
</main> </main>
</template> </template>
<script> <script>
import _ from "@/lodash"
import axios from "axios" import axios from "axios"
import logger from "@/logging" import logger from "@/logging"
import backend from "@/audio/backend" 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 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 EmbedWizard from "@/components/audio/EmbedWizard"
import Modal from '@/components/semantic/Modal' import Modal from '@/components/semantic/Modal'
import RadioButton from "@/components/radios/Button"
const FETCH_URL = "albums/"
export default { export default {
props: ["id"], props: ["id"],
components: { components: {
AlbumCard,
RadioButton,
PlayButton, PlayButton,
TrackTable,
LibraryWidget,
EmbedWizard, EmbedWizard,
Modal Modal,
RadioButton
}, },
data() { data() {
return { return {
isLoading: true, isLoading: true,
isLoadingAlbums: true, isLoadingAlbums: true,
artist: null, object: null,
albums: null, albums: null,
totalTracks: 0,
totalAlbums: 0,
tracks: [],
libraries: [], libraries: [],
showEmbedModal: false showEmbedModal: false,
tracks: [],
} }
}, },
created() { created() {
@ -184,7 +147,7 @@ export default {
self.totalTracks = response.data.count self.totalTracks = response.data.count
}) })
axios.get("artists/" + this.id + "/").then(response => { axios.get("artists/" + this.id + "/").then(response => {
self.artist = response.data self.object = response.data
self.isLoading = false self.isLoading = false
self.isLoadingAlbums = true self.isLoadingAlbums = true
axios axios
@ -204,40 +167,31 @@ export default {
} }
}, },
computed: { computed: {
labels() {
return {
title: this.$pgettext('*/*/*/Noun', "Artist")
}
},
isPlayable() { isPlayable() {
return ( return (
this.artist.albums.filter(a => { this.object.albums.filter(a => {
return a.is_playable return a.is_playable
}).length > 0 }).length > 0
) )
}, },
labels() {
return {
title: this.$pgettext('*/*/*', 'Album')
}
},
wikipediaUrl() { wikipediaUrl() {
return ( return (
"https://en.wikipedia.org/w/index.php?search=" + "https://en.wikipedia.org/w/index.php?search=" +
encodeURI(this.artist.name) encodeURI(this.object.name)
) )
}, },
musicbrainzUrl() { musicbrainzUrl() {
if (this.artist.mbid) { if (this.object.mbid) {
return "https://musicbrainz.org/artist/" + this.artist.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() { cover() {
return this.artist.albums return this.object.albums
.filter(album => { .filter(album => {
return album.cover return album.cover
}) })
@ -264,7 +218,7 @@ export default {
contentFilter () { contentFilter () {
let self = this let self = this
return this.$store.getters['moderation/artistFilters']().filter((e) => { return this.$store.getters['moderation/artistFilters']().filter((e) => {
return e.target.id === this.artist.id return e.target.id === this.object.id
})[0] })[0]
} }
}, },
@ -275,7 +229,3 @@ export default {
} }
} }
</script> </script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

View File

@ -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>

View File

@ -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>

View File

@ -149,6 +149,12 @@ export default {
if (this.objectType === 'track') { if (this.objectType === 'track') {
return `tracks/${this.object.id}/mutations/` 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 () { mutationPayload () {
let self = this let self = this

View File

@ -1,13 +1,42 @@
export default { export default {
getConfigs () { getConfigs () {
return { 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: { track: {
fields: [ fields: [
{ {
id: 'title', id: 'title',
type: 'text', type: 'text',
required: true, required: true,
label: this.$pgettext('Content/Track/*/Noun', 'Title'), label: this.$pgettext('*/*/*/Noun', 'Title'),
getValue: (obj) => { return obj.title } getValue: (obj) => { return obj.title }
}, },
{ {

View File

@ -16,10 +16,14 @@ import PasswordResetConfirm from '@/views/auth/PasswordResetConfirm'
import EmailConfirm from '@/views/auth/EmailConfirm' import EmailConfirm from '@/views/auth/EmailConfirm'
import Library from '@/components/library/Library' import Library from '@/components/library/Library'
import LibraryHome from '@/components/library/Home' import LibraryHome from '@/components/library/Home'
import LibraryArtist from '@/components/library/Artist'
import LibraryArtists from '@/components/library/Artists' 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 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 LibraryTrackDetail from '@/components/library/TrackDetail'
import LibraryTrackEdit from '@/components/library/TrackEdit' import LibraryTrackEdit from '@/components/library/TrackEdit'
import EditDetail from '@/components/library/EditDetail' import EditDetail from '@/components/library/EditDetail'
@ -411,8 +415,52 @@ export default new Router({
id: route.params.id, id: route.params.id,
defaultEdit: route.query.mode === 'edit' }) 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', path: 'tracks/:id',
component: LibraryTrackDetailBase, component: LibraryTrackDetailBase,