Edits for artists and albums
This commit is contained in:
parent
2836b11190
commit
55d0e52c55
|
@ -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(
|
||||
|
|
|
@ -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"}],
|
||||
),
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
)
|
||||
|
|
|
@ -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 = (
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"](
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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
|
||||
|
|
|
@ -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 }
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue