diff --git a/api/funkwhale_api/music/filters.py b/api/funkwhale_api/music/filters.py index 1f73fc9b0638df2d15520f5522fb13c013989de0..87537b675ed95dcfe0b3cfa48d792a5ddb7063ce 100644 --- a/api/funkwhale_api/music/filters.py +++ b/api/funkwhale_api/music/filters.py @@ -6,19 +6,9 @@ from funkwhale_api.common import fields from . import models -class ListenableMixin(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): +class ArtistFilter(filters.FilterSet): q = fields.SearchFilter(search_fields=["name"]) + listenable = filters.BooleanFilter(name="_", method="filter_listenable") class Meta: model = models.Artist @@ -27,6 +17,13 @@ class ArtistFilter(ListenableMixin): "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): 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") q = fields.SearchFilter(search_fields=["title", "artist__name" "source"]) class Meta: model = models.Album 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) diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index d533d852588ac401288c070ada67cb9fcea2b7eb..207b22dfb6745df5124e329736c5990e9ef2b941 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -319,9 +319,10 @@ class Track(APIModelMixin): "mbid": {"musicbrainz_field_name": "id"}, "title": {"musicbrainz_field_name": "title"}, "artist": { - # we use the artist from the release to avoid #237 - "musicbrainz_field_name": "release-list", - "converter": get_artist, + "musicbrainz_field_name": "artist-credit", + "converter": lambda v: Artist.get_or_create_from_api( + mbid=v[0]["artist"]["id"] + )[0], }, "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"]] track_data = None for track in tracks: - if track["recording"]["id"] == mbid: + if track["recording"]["id"] == str(mbid): track_data = track break if not track_data: 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( mbid=mbid, defaults={ "position": int(track["position"]), "title": track["recording"]["title"], "album": album, - "artist": album.artist, + "artist": track_artist, }, ) diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py index c34970d0b5cbcf29155c7b008c0ba4acd6246f1c..14ea54d51a22283e1c82f230da51ecab862b2c14 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -60,8 +60,15 @@ class TrackFileSerializer(serializers.ModelSerializer): return url +class ArtistSimpleSerializer(serializers.ModelSerializer): + class Meta: + model = models.Artist + fields = ("id", "mbid", "name", "creation_date") + + class AlbumTrackSerializer(serializers.ModelSerializer): files = TrackFileSerializer(many=True, read_only=True) + artist = ArtistSimpleSerializer(read_only=True) class Meta: 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): tracks = serializers.SerializerMethodField() artist = ArtistSimpleSerializer(read_only=True) diff --git a/api/tests/music/test_models.py b/api/tests/music/test_models.py index df18a09096fc147454f4fbf1905a84ff185213b2..1bd4282fe36b9bde03094a8e6304283ea97e9b59 100644 --- a/api/tests/music/test_models.py +++ b/api/tests/music/test_models.py @@ -44,6 +44,7 @@ def test_import_album_stores_release_group(factories): def test_import_track_from_release(factories, mocker): album = factories["music.Album"](mbid="430347cb-0879-3113-9fde-c75b658c298e") + artist = factories["music.Artist"](mbid="a5211c65-2465-406b-93ec-213588869dc1") album_data = { "release": { "id": album.mbid, @@ -64,6 +65,9 @@ def test_import_track_from_release(factories, mocker): "id": "2109e376-132b-40ad-b993-2bb6812e19d4", "title": "Teen Age Riot", "length": "417973", + "artist-credit": [ + {"artist": {"id": artist.mbid, "name": artist.name}} + ], }, "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.mbid == track_data["recording"]["id"] assert track.album == album - assert track.artist == album.artist + assert track.artist == artist 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): track = factories["music.Track"]() job = factories["music.ImportJob"](mbid=track.mbid) diff --git a/api/tests/music/test_serializers.py b/api/tests/music/test_serializers.py index 51ca96b5d713af1c1e39f2c1872a9bbea9809216..0d7400dfc7c70092a406574ea23754c531313c65 100644 --- a/api/tests/music/test_serializers.py +++ b/api/tests/music/test_serializers.py @@ -43,7 +43,7 @@ def test_album_track_serializer(factories, to_api_date): expected = { "id": track.id, - "artist": track.artist.id, + "artist": serializers.ArtistSimpleSerializer(track.artist).data, "album": track.album.id, "mbid": str(track.mbid), "title": track.title, diff --git a/api/tests/test_import_audio_file.py b/api/tests/test_import_audio_file.py index 43e596ff7c14a27b6b2db69f1ab7c7206632355c..f63e69b635cad1b4534a8f7c1a3aabc6b636fd15 100644 --- a/api/tests/test_import_audio_file.py +++ b/api/tests/test_import_audio_file.py @@ -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): album = factories["music.Album"]() + artist = factories["music.Artist"]() mocker.patch( "funkwhale_api.music.models.Album.get_or_create_from_api", return_value=(album, True), @@ -55,6 +56,9 @@ def test_can_create_track_from_file_metadata_mbid(factories, mocker): "recording": { "id": "2109e376-132b-40ad-b993-2bb6812e19d4", "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.position == 4 assert track.album == album - assert track.artist == album.artist + assert track.artist == artist def test_management_command_requires_a_valid_username(factories, mocker): diff --git a/changes/changelog.d/237.enhancement b/changes/changelog.d/237.enhancement new file mode 100644 index 0000000000000000000000000000000000000000..1b5eed8f2c83215b936e89d2a144e25fba8c54ed --- /dev/null +++ b/changes/changelog.d/237.enhancement @@ -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. diff --git a/front/src/audio/backend.js b/front/src/audio/backend.js index 5a82719a3a0d403ce6ee264eed47e2f5926068f0..c371566758631cf2a6f8c58547d01a47c318e3dd 100644 --- a/front/src/audio/backend.js +++ b/front/src/audio/backend.js @@ -2,7 +2,6 @@ var Album = { clean (album) { // we manually rebind the album and artist to each child track album.tracks = album.tracks.map((track) => { - track.artist = album.artist track.album = album return track }) diff --git a/front/src/components/audio/track/Row.vue b/front/src/components/audio/track/Row.vue index bd3ceb2aaa869350fa013ca36c709f16cc97cdbc..5870ac799cf2808baf03cf029a8ff166ed767ac5 100644 --- a/front/src/components/audio/track/Row.vue +++ b/front/src/components/audio/track/Row.vue @@ -16,9 +16,18 @@ </router-link> </td> <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 }} </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 colspan="6"> <router-link class="album discrete link" :to="{name: 'library.albums.detail', params: {id: track.album.id }}"> @@ -35,8 +44,6 @@ </template> <script> -import backend from '@/audio/backend' - import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon' import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon' import PlayButton from '@/components/audio/PlayButton' @@ -44,6 +51,7 @@ import PlayButton from '@/components/audio/PlayButton' export default { props: { track: {type: Object, required: true}, + artist: {type: Object, required: false}, displayPosition: {type: Boolean, default: false} }, components: { @@ -51,9 +59,13 @@ export default { TrackPlaylistIcon, PlayButton }, - data () { - return { - backend: backend + computed: { + albumArtist () { + if (this.artist) { + return this.artist + } else { + return this.track.album.artist + } } } } diff --git a/front/src/components/audio/track/Table.vue b/front/src/components/audio/track/Table.vue index 40d33df732b26adf9ba76181092ea57d6970b453..03e9398f8a6b9ff9518bd9874db331bc5fabee6c 100644 --- a/front/src/components/audio/track/Table.vue +++ b/front/src/components/audio/track/Table.vue @@ -14,6 +14,7 @@ <track-row :display-position="displayPosition" :track="track" + :artist="artist" :key="index + '-' + track.id" v-for="(track, index) in tracks"></track-row> </tbody> @@ -63,6 +64,7 @@ import Modal from '@/components/semantic/Modal' export default { props: { tracks: {type: Array, required: true}, + artist: {type: Object, required: false}, displayPosition: {type: Boolean, default: false} }, components: { diff --git a/front/src/components/library/Album.vue b/front/src/components/library/Album.vue index ced69edebd2f3abf69d79b8d5489db30fc33313c..698f07ae28abcf4a611327d41fedfa21e3295d91 100644 --- a/front/src/components/library/Album.vue +++ b/front/src/components/library/Album.vue @@ -43,7 +43,7 @@ <h2> <translate>Tracks</translate> </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> </template> </div> diff --git a/front/src/components/library/Artist.vue b/front/src/components/library/Artist.vue index 89eb7459c0f85fd95aeb32ea8c1ecbfa1e77c286..6e3ad638d01a0dcdb6685c04ac036ba09d79d61b 100644 --- a/front/src/components/library/Artist.vue +++ b/front/src/components/library/Artist.vue @@ -15,7 +15,7 @@ tag="div" translate-plural="%{ count } tracks in %{ albumsCount } albums" :translate-n="totalTracks" - :translate-params="{count: totalTracks, albumsCount: albums.length}"> + :translate-params="{count: totalTracks, albumsCount: totalAlbums}"> %{ count } track in %{ albumsCount } albums </translate> </div> @@ -40,7 +40,7 @@ <div v-if="isLoadingAlbums" class="ui vertical stripe segment"> <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></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> <translate>Albums by this artist</translate> </h2> @@ -50,33 +50,41 @@ </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> </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 RadioButton from '@/components/radios/Button' import PlayButton from '@/components/audio/PlayButton' - -const FETCH_URL = 'artists/' +import TrackTable from '@/components/audio/track/Table' export default { props: ['id'], components: { AlbumCard, RadioButton, - PlayButton + PlayButton, + TrackTable }, data () { return { isLoading: true, isLoadingAlbums: true, artist: null, - albums: null + albums: null, + tracks: [] } }, created () { @@ -86,14 +94,17 @@ export default { fetchData () { var self = this this.isLoading = true - let url = FETCH_URL + 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.isLoading = false self.isLoadingAlbums = true 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) }) @@ -108,12 +119,21 @@ export default { title: this.$gettext('Artist') } }, + totalAlbums () { + let trackAlbums = _.uniqBy(this.tracks, (t) => { + return t.album.id + }) + return this.albums.length + trackAlbums.length + }, totalTracks () { + if (this.albums.length === 0) { + return 0 + this.tracks.length + } return this.albums.map((album) => { return album.tracks.length }).reduce((a, b) => { return a + b - }) + }) + this.tracks.length }, wikipediaUrl () { return 'https://en.wikipedia.org/w/index.php?search=' + this.artist.name diff --git a/front/src/components/library/Home.vue b/front/src/components/library/Home.vue index 5610198a466478412be47e5d42bed4ebbf418f9d..ce6627e18bc66d50950a53b007c84d442c13d9ff 100644 --- a/front/src/components/library/Home.vue +++ b/front/src/components/library/Home.vue @@ -72,7 +72,8 @@ export default { var self = this this.isLoadingArtists = true let params = { - ordering: '-creation_date' + ordering: '-creation_date', + listenable: true } let url = ARTISTS_URL logger.default.time('Loading latest artists') diff --git a/front/src/components/requests/Form.vue b/front/src/components/requests/Form.vue index b6752a6353989acdc8bad0feeb4fbb888204d5b1..667bd5f5acb7b7c6e0c0b00a3e5905d6f127f53c 100644 --- a/front/src/components/requests/Form.vue +++ b/front/src/components/requests/Form.vue @@ -13,7 +13,7 @@ </div> <div class="field"> <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> <button class="ui submit button" type="submit"><translate>Submit</translate></button> </form>