Resolve "Funkwhale unable to import Albums with multiple Artists"

This commit is contained in:
Eliot Berriot 2018-07-09 20:47:55 +00:00
parent ce5502cab7
commit 1458c084a7
14 changed files with 180 additions and 47 deletions

View File

@ -6,19 +6,9 @@ from funkwhale_api.common import fields
from . import models from . import models
class ListenableMixin(filters.FilterSet): class ArtistFilter(filters.FilterSet):
listenable = filters.BooleanFilter(name="_", method="filter_listenable")
def filter_listenable(self, queryset, name, value):
queryset = queryset.annotate(files_count=Count("tracks__files"))
if value:
return queryset.filter(files_count__gt=0)
else:
return queryset.filter(files_count=0)
class ArtistFilter(ListenableMixin):
q = fields.SearchFilter(search_fields=["name"]) q = fields.SearchFilter(search_fields=["name"])
listenable = filters.BooleanFilter(name="_", method="filter_listenable")
class Meta: class Meta:
model = models.Artist model = models.Artist
@ -27,6 +17,13 @@ class ArtistFilter(ListenableMixin):
"listenable": "exact", "listenable": "exact",
} }
def filter_listenable(self, queryset, name, value):
queryset = queryset.annotate(files_count=Count("albums__tracks__files"))
if value:
return queryset.filter(files_count__gt=0)
else:
return queryset.filter(files_count=0)
class TrackFilter(filters.FilterSet): class TrackFilter(filters.FilterSet):
q = fields.SearchFilter(search_fields=["title", "album__title", "artist__name"]) q = fields.SearchFilter(search_fields=["title", "album__title", "artist__name"])
@ -72,10 +69,17 @@ class ImportJobFilter(filters.FilterSet):
} }
class AlbumFilter(ListenableMixin): class AlbumFilter(filters.FilterSet):
listenable = filters.BooleanFilter(name="_", method="filter_listenable") listenable = filters.BooleanFilter(name="_", method="filter_listenable")
q = fields.SearchFilter(search_fields=["title", "artist__name" "source"]) q = fields.SearchFilter(search_fields=["title", "artist__name" "source"])
class Meta: class Meta:
model = models.Album model = models.Album
fields = ["listenable", "q", "artist"] fields = ["listenable", "q", "artist"]
def filter_listenable(self, queryset, name, value):
queryset = queryset.annotate(files_count=Count("tracks__files"))
if value:
return queryset.filter(files_count__gt=0)
else:
return queryset.filter(files_count=0)

View File

@ -319,9 +319,10 @@ class Track(APIModelMixin):
"mbid": {"musicbrainz_field_name": "id"}, "mbid": {"musicbrainz_field_name": "id"},
"title": {"musicbrainz_field_name": "title"}, "title": {"musicbrainz_field_name": "title"},
"artist": { "artist": {
# we use the artist from the release to avoid #237 "musicbrainz_field_name": "artist-credit",
"musicbrainz_field_name": "release-list", "converter": lambda v: Artist.get_or_create_from_api(
"converter": get_artist, mbid=v[0]["artist"]["id"]
)[0],
}, },
"album": {"musicbrainz_field_name": "release-list", "converter": import_album}, "album": {"musicbrainz_field_name": "release-list", "converter": import_album},
} }
@ -389,19 +390,37 @@ class Track(APIModelMixin):
tracks = [t for m in data["release"]["medium-list"] for t in m["track-list"]] tracks = [t for m in data["release"]["medium-list"] for t in m["track-list"]]
track_data = None track_data = None
for track in tracks: for track in tracks:
if track["recording"]["id"] == mbid: if track["recording"]["id"] == str(mbid):
track_data = track track_data = track
break break
if not track_data: if not track_data:
raise ValueError("No track found matching this ID") raise ValueError("No track found matching this ID")
track_artist_mbid = None
for ac in track_data["recording"]["artist-credit"]:
try:
ac_mbid = ac["artist"]["id"]
except TypeError:
# it's probably a string, like "feat."
continue
if ac_mbid == str(album.artist.mbid):
continue
track_artist_mbid = ac_mbid
break
track_artist_mbid = track_artist_mbid or album.artist.mbid
if track_artist_mbid == str(album.artist.mbid):
track_artist = album.artist
else:
track_artist = Artist.get_or_create_from_api(track_artist_mbid)[0]
return cls.objects.update_or_create( return cls.objects.update_or_create(
mbid=mbid, mbid=mbid,
defaults={ defaults={
"position": int(track["position"]), "position": int(track["position"]),
"title": track["recording"]["title"], "title": track["recording"]["title"],
"album": album, "album": album,
"artist": album.artist, "artist": track_artist,
}, },
) )

View File

@ -60,8 +60,15 @@ class TrackFileSerializer(serializers.ModelSerializer):
return url return url
class ArtistSimpleSerializer(serializers.ModelSerializer):
class Meta:
model = models.Artist
fields = ("id", "mbid", "name", "creation_date")
class AlbumTrackSerializer(serializers.ModelSerializer): class AlbumTrackSerializer(serializers.ModelSerializer):
files = TrackFileSerializer(many=True, read_only=True) files = TrackFileSerializer(many=True, read_only=True)
artist = ArtistSimpleSerializer(read_only=True)
class Meta: class Meta:
model = models.Track model = models.Track
@ -77,12 +84,6 @@ class AlbumTrackSerializer(serializers.ModelSerializer):
) )
class ArtistSimpleSerializer(serializers.ModelSerializer):
class Meta:
model = models.Artist
fields = ("id", "mbid", "name", "creation_date")
class AlbumSerializer(serializers.ModelSerializer): class AlbumSerializer(serializers.ModelSerializer):
tracks = serializers.SerializerMethodField() tracks = serializers.SerializerMethodField()
artist = ArtistSimpleSerializer(read_only=True) artist = ArtistSimpleSerializer(read_only=True)

View File

@ -44,6 +44,7 @@ def test_import_album_stores_release_group(factories):
def test_import_track_from_release(factories, mocker): def test_import_track_from_release(factories, mocker):
album = factories["music.Album"](mbid="430347cb-0879-3113-9fde-c75b658c298e") album = factories["music.Album"](mbid="430347cb-0879-3113-9fde-c75b658c298e")
artist = factories["music.Artist"](mbid="a5211c65-2465-406b-93ec-213588869dc1")
album_data = { album_data = {
"release": { "release": {
"id": album.mbid, "id": album.mbid,
@ -64,6 +65,9 @@ def test_import_track_from_release(factories, mocker):
"id": "2109e376-132b-40ad-b993-2bb6812e19d4", "id": "2109e376-132b-40ad-b993-2bb6812e19d4",
"title": "Teen Age Riot", "title": "Teen Age Riot",
"length": "417973", "length": "417973",
"artist-credit": [
{"artist": {"id": artist.mbid, "name": artist.name}}
],
}, },
"track_or_recording_length": "417973", "track_or_recording_length": "417973",
} }
@ -84,10 +88,66 @@ def test_import_track_from_release(factories, mocker):
assert track.title == track_data["recording"]["title"] assert track.title == track_data["recording"]["title"]
assert track.mbid == track_data["recording"]["id"] assert track.mbid == track_data["recording"]["id"]
assert track.album == album assert track.album == album
assert track.artist == album.artist assert track.artist == artist
assert track.position == int(track_data["position"]) assert track.position == int(track_data["position"])
def test_import_track_with_different_artist_than_release(factories, mocker):
album = factories["music.Album"](mbid="430347cb-0879-3113-9fde-c75b658c298e")
recording_data = {
"recording": {
"id": "94ab07eb-bdf3-4155-b471-ba1dc85108bf",
"title": "Flaming Red Hair",
"length": "159000",
"artist-credit": [
{
"artist": {
"id": "a5211c65-2465-406b-93ec-213588869dc1",
"name": "Plan 9",
"sort-name": "Plan 9",
"disambiguation": "New Zealand group",
}
}
],
"release-list": [
{
"id": album.mbid,
"title": "The Lord of the Rings: The Fellowship of the Ring - The Complete Recordings",
"status": "Official",
"quality": "normal",
"text-representation": {"language": "eng", "script": "Latn"},
"artist-credit": [
{
"artist": {
"id": "9b58672a-e68e-4972-956e-a8985a165a1f",
"name": "Howard Shore",
"sort-name": "Shore, Howard",
}
}
],
"date": "2005-12-13",
"country": "US",
"release-event-count": 1,
"barcode": "093624945420",
"artist-credit-phrase": "Howard Shore",
}
],
"release-count": 3,
"artist-credit-phrase": "Plan 9",
}
}
artist = factories["music.Artist"](mbid="a5211c65-2465-406b-93ec-213588869dc1")
mocker.patch(
"funkwhale_api.musicbrainz.api.recordings.get", return_value=recording_data
)
track = models.Track.get_or_create_from_api(recording_data["recording"]["id"])[0]
assert track.title == recording_data["recording"]["title"]
assert track.mbid == recording_data["recording"]["id"]
assert track.album == album
assert track.artist == artist
def test_import_job_is_bound_to_track_file(factories, mocker): def test_import_job_is_bound_to_track_file(factories, mocker):
track = factories["music.Track"]() track = factories["music.Track"]()
job = factories["music.ImportJob"](mbid=track.mbid) job = factories["music.ImportJob"](mbid=track.mbid)

View File

@ -43,7 +43,7 @@ def test_album_track_serializer(factories, to_api_date):
expected = { expected = {
"id": track.id, "id": track.id,
"artist": track.artist.id, "artist": serializers.ArtistSimpleSerializer(track.artist).data,
"album": track.album.id, "album": track.album.id,
"mbid": str(track.mbid), "mbid": str(track.mbid),
"title": track.title, "title": track.title,

View File

@ -37,6 +37,7 @@ def test_can_create_track_from_file_metadata_no_mbid(db, mocker):
def test_can_create_track_from_file_metadata_mbid(factories, mocker): def test_can_create_track_from_file_metadata_mbid(factories, mocker):
album = factories["music.Album"]() album = factories["music.Album"]()
artist = factories["music.Artist"]()
mocker.patch( mocker.patch(
"funkwhale_api.music.models.Album.get_or_create_from_api", "funkwhale_api.music.models.Album.get_or_create_from_api",
return_value=(album, True), return_value=(album, True),
@ -55,6 +56,9 @@ def test_can_create_track_from_file_metadata_mbid(factories, mocker):
"recording": { "recording": {
"id": "2109e376-132b-40ad-b993-2bb6812e19d4", "id": "2109e376-132b-40ad-b993-2bb6812e19d4",
"title": "Teen Age Riot", "title": "Teen Age Riot",
"artist-credit": [
{"artist": {"id": artist.mbid, "name": artist.name}}
],
}, },
} }
], ],
@ -79,7 +83,7 @@ def test_can_create_track_from_file_metadata_mbid(factories, mocker):
assert track.mbid == track_data["recording"]["id"] assert track.mbid == track_data["recording"]["id"]
assert track.position == 4 assert track.position == 4
assert track.album == album assert track.album == album
assert track.artist == album.artist assert track.artist == artist
def test_management_command_requires_a_valid_username(factories, mocker): def test_management_command_requires_a_valid_username(factories, mocker):

View File

@ -0,0 +1,11 @@
Store track artist and album artist separately (#237)
Better handling of tracks with a different artist than the album artist
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Some tracks involve a different artist than the album artist (e.g. a featuring)
and Funkwhale has been known to do weird things when importing such tracks, resulting
in albums that contained a single track, for instance.
The situation should be improved with this release, as Funkwhale is now able to
store separately the track and album artist, and display it properly in the interface.

View File

@ -2,7 +2,6 @@ var Album = {
clean (album) { clean (album) {
// we manually rebind the album and artist to each child track // we manually rebind the album and artist to each child track
album.tracks = album.tracks.map((track) => { album.tracks = album.tracks.map((track) => {
track.artist = album.artist
track.album = album track.album = album
return track return track
}) })

View File

@ -16,9 +16,18 @@
</router-link> </router-link>
</td> </td>
<td colspan="6"> <td colspan="6">
<router-link class="artist discrete link" :to="{name: 'library.artists.detail', params: {id: track.artist.id }}"> <router-link v-if="track.artist.id === albumArtist.id" class="artist discrete link" :to="{name: 'library.artists.detail', params: {id: track.artist.id }}">
{{ track.artist.name }} {{ track.artist.name }}
</router-link> </router-link>
<template v-else>
<router-link class="artist discrete link" :to="{name: 'library.artists.detail', params: {id: albumArtist.id }}">
{{ albumArtist.name }}
</router-link>
/
<router-link class="artist discrete link" :to="{name: 'library.artists.detail', params: {id: track.artist.id }}">
{{ track.artist.name }}
</router-link>
</template>
</td> </td>
<td colspan="6"> <td colspan="6">
<router-link class="album discrete link" :to="{name: 'library.albums.detail', params: {id: track.album.id }}"> <router-link class="album discrete link" :to="{name: 'library.albums.detail', params: {id: track.album.id }}">
@ -35,8 +44,6 @@
</template> </template>
<script> <script>
import backend from '@/audio/backend'
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon' import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon' import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon'
import PlayButton from '@/components/audio/PlayButton' import PlayButton from '@/components/audio/PlayButton'
@ -44,6 +51,7 @@ import PlayButton from '@/components/audio/PlayButton'
export default { export default {
props: { props: {
track: {type: Object, required: true}, track: {type: Object, required: true},
artist: {type: Object, required: false},
displayPosition: {type: Boolean, default: false} displayPosition: {type: Boolean, default: false}
}, },
components: { components: {
@ -51,9 +59,13 @@ export default {
TrackPlaylistIcon, TrackPlaylistIcon,
PlayButton PlayButton
}, },
data () { computed: {
return { albumArtist () {
backend: backend if (this.artist) {
return this.artist
} else {
return this.track.album.artist
}
} }
} }
} }

View File

@ -14,6 +14,7 @@
<track-row <track-row
:display-position="displayPosition" :display-position="displayPosition"
:track="track" :track="track"
:artist="artist"
:key="index + '-' + track.id" :key="index + '-' + track.id"
v-for="(track, index) in tracks"></track-row> v-for="(track, index) in tracks"></track-row>
</tbody> </tbody>
@ -63,6 +64,7 @@ import Modal from '@/components/semantic/Modal'
export default { export default {
props: { props: {
tracks: {type: Array, required: true}, tracks: {type: Array, required: true},
artist: {type: Object, required: false},
displayPosition: {type: Boolean, default: false} displayPosition: {type: Boolean, default: false}
}, },
components: { components: {

View File

@ -43,7 +43,7 @@
<h2> <h2>
<translate>Tracks</translate> <translate>Tracks</translate>
</h2> </h2>
<track-table v-if="album" :display-position="true" :tracks="album.tracks"></track-table> <track-table v-if="album" :artist="album.artist" :display-position="true" :tracks="album.tracks"></track-table>
</div> </div>
</template> </template>
</div> </div>

View File

@ -15,7 +15,7 @@
tag="div" tag="div"
translate-plural="%{ count } tracks in %{ albumsCount } albums" translate-plural="%{ count } tracks in %{ albumsCount } albums"
:translate-n="totalTracks" :translate-n="totalTracks"
:translate-params="{count: totalTracks, albumsCount: albums.length}"> :translate-params="{count: totalTracks, albumsCount: totalAlbums}">
%{ count } track in %{ albumsCount } albums %{ count } track in %{ albumsCount } albums
</translate> </translate>
</div> </div>
@ -40,7 +40,7 @@
<div v-if="isLoadingAlbums" class="ui vertical stripe segment"> <div v-if="isLoadingAlbums" class="ui vertical stripe segment">
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
</div> </div>
<div v-else-if="albums" class="ui vertical stripe segment"> <div v-else-if="albums && albums.length > 0" class="ui vertical stripe segment">
<h2> <h2>
<translate>Albums by this artist</translate> <translate>Albums by this artist</translate>
</h2> </h2>
@ -50,33 +50,41 @@
</div> </div>
</div> </div>
</div> </div>
<div v-if="tracks.length > 0" class="ui vertical stripe segment">
<h2>
<translate>Tracks by this artist</translate>
</h2>
<track-table :display-position="true" :tracks="tracks"></track-table>
</div>
</template> </template>
</div> </div>
</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 AlbumCard from '@/components/audio/album/Card'
import RadioButton from '@/components/radios/Button' 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'
const FETCH_URL = 'artists/'
export default { export default {
props: ['id'], props: ['id'],
components: { components: {
AlbumCard, AlbumCard,
RadioButton, RadioButton,
PlayButton PlayButton,
TrackTable
}, },
data () { data () {
return { return {
isLoading: true, isLoading: true,
isLoadingAlbums: true, isLoadingAlbums: true,
artist: null, artist: null,
albums: null albums: null,
tracks: []
} }
}, },
created () { created () {
@ -86,14 +94,17 @@ export default {
fetchData () { fetchData () {
var self = this var self = this
this.isLoading = true this.isLoading = true
let url = FETCH_URL + this.id + '/'
logger.default.debug('Fetching artist "' + this.id + '"') logger.default.debug('Fetching artist "' + this.id + '"')
axios.get(url).then((response) => { axios.get('tracks/', {params: {artist: this.id}}).then((response) => {
self.tracks = response.data.results
})
axios.get('artists/' + this.id + '/').then((response) => {
self.artist = response.data self.artist = response.data
self.isLoading = false self.isLoading = false
self.isLoadingAlbums = true self.isLoadingAlbums = true
axios.get('albums/', {params: {artist: this.id, ordering: '-release_date'}}).then((response) => { axios.get('albums/', {params: {artist: this.id, ordering: '-release_date'}}).then((response) => {
self.albums = JSON.parse(JSON.stringify(response.data.results)).map((album) => { let parsed = JSON.parse(JSON.stringify(response.data.results))
self.albums = parsed.map((album) => {
return backend.Album.clean(album) return backend.Album.clean(album)
}) })
@ -108,12 +119,21 @@ export default {
title: this.$gettext('Artist') title: this.$gettext('Artist')
} }
}, },
totalAlbums () {
let trackAlbums = _.uniqBy(this.tracks, (t) => {
return t.album.id
})
return this.albums.length + trackAlbums.length
},
totalTracks () { totalTracks () {
if (this.albums.length === 0) {
return 0 + this.tracks.length
}
return this.albums.map((album) => { return this.albums.map((album) => {
return album.tracks.length return album.tracks.length
}).reduce((a, b) => { }).reduce((a, b) => {
return a + b return a + b
}) }) + this.tracks.length
}, },
wikipediaUrl () { wikipediaUrl () {
return 'https://en.wikipedia.org/w/index.php?search=' + this.artist.name return 'https://en.wikipedia.org/w/index.php?search=' + this.artist.name

View File

@ -72,7 +72,8 @@ export default {
var self = this var self = this
this.isLoadingArtists = true this.isLoadingArtists = true
let params = { let params = {
ordering: '-creation_date' ordering: '-creation_date',
listenable: true
} }
let url = ARTISTS_URL let url = ARTISTS_URL
logger.default.time('Loading latest artists') logger.default.time('Loading latest artists')

View File

@ -13,7 +13,7 @@
</div> </div>
<div class="field"> <div class="field">
<label><translate>Comment</translate></label> <label><translate>Comment</translate></label>
<textarea v-model="currentComment" rows="3" :placeholder="comentPlaceholder" maxlength="2000"></textarea> <textarea v-model="currentComment" rows="3" :placeholder="labels.commentPlaceholder" maxlength="2000"></textarea>
</div> </div>
<button class="ui submit button" type="submit"><translate>Submit</translate></button> <button class="ui submit button" type="submit"><translate>Submit</translate></button>
</form> </form>