From b4edc1f832f6add99a912e271eb3e795d8fbad56 Mon Sep 17 00:00:00 2001 From: temrix Date: Thu, 19 Sep 2019 00:29:43 +0200 Subject: [PATCH 1/8] Add bpm, musical_key and genre to plugin. --- beetsplug/beatport.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py index 3462f118a..8ef356fe8 100644 --- a/beetsplug/beatport.py +++ b/beetsplug/beatport.py @@ -255,6 +255,16 @@ class BeatportTrack(BeatportObject): self.url = "https://beatport.com/track/{0}/{1}" \ .format(data['slug'], data['id']) self.track_number = data.get('trackNumber') + if 'bpm' in data: + self.bpm = data['bpm'] + if 'key' in data: + self.musical_key = six.text_type(data['key'].get('shortName')) + + # Use 'subgenre' and if not present, 'genre' as a fallback. + if 'subGenres' in data: + self.genre = six.text_type(data['subGenres'][0].get('name')) + if not self.genre and 'genres' in data: + self.genre = six.text_type(data['genres'][0].get('name')) class BeatportPlugin(BeetsPlugin): @@ -433,7 +443,8 @@ class BeatportPlugin(BeetsPlugin): artist=artist, artist_id=artist_id, length=length, index=track.track_number, medium_index=track.track_number, - data_source=u'Beatport', data_url=track.url) + data_source=u'Beatport', data_url=track.url, + bpm=track.bpm, musical_key=track.musical_key) def _get_artist(self, artists): """Returns an artist string (all artists) and an artist_id (the main From 067358711e554ca95dffac5770541ff21ba134af Mon Sep 17 00:00:00 2001 From: temrix Date: Thu, 19 Sep 2019 00:31:43 +0200 Subject: [PATCH 2/8] Add attributes to hooks. --- beets/autotag/__init__.py | 3 +++ beets/autotag/hooks.py | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index b8bdea479..38db0a07b 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -185,6 +185,9 @@ def apply_metadata(album_info, mapping): 'work', 'mb_workid', 'work_disambig', + 'bpm', + 'musical_key', + 'genre' ) } diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index 686423360..f59aaea42 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -179,7 +179,8 @@ class TrackInfo(object): disctitle=None, artist_credit=None, data_source=None, data_url=None, media=None, lyricist=None, composer=None, composer_sort=None, arranger=None, track_alt=None, - work=None, mb_workid=None, work_disambig=None): + work=None, mb_workid=None, work_disambig=None, bpm=None, + musical_key=None, genre=None): self.title = title self.track_id = track_id self.release_track_id = release_track_id @@ -204,6 +205,9 @@ class TrackInfo(object): self.work = work self.mb_workid = mb_workid self.work_disambig = work_disambig + self.bpm = bpm + self.musical_key = musical_key + self.genre = genre # As above, work around a bug in python-musicbrainz-ngs. def decode(self, codec='utf-8'): From 6c8535088a2561e6d637c33aa07e5dac4e6ea2d4 Mon Sep 17 00:00:00 2001 From: temrix Date: Thu, 19 Sep 2019 00:32:52 +0200 Subject: [PATCH 3/8] Add test file. --- test/test_beatport.py | 567 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 567 insertions(+) create mode 100644 test/test_beatport.py diff --git a/test/test_beatport.py b/test/test_beatport.py new file mode 100644 index 000000000..a1591d58c --- /dev/null +++ b/test/test_beatport.py @@ -0,0 +1,567 @@ +# -*- coding: utf-8 -*- +# This file is part of beets. +# Copyright 2016, Adrian Sampson. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +"""Tests for the 'beatport' plugin. +""" +from __future__ import division, absolute_import, print_function + +import unittest +from test import _common +from test.helper import TestHelper +import six +from datetime import timedelta + +from beetsplug import beatport +from beets import library + + +class BeatportTest(_common.TestCase, TestHelper): + def _make_release_response(self): + """Returns a dict that mimics a response from the beatport API. + + The results were retrived from: + https://oauth-api.beatport.com/catalog/3/releases?id=1742984 + The list of elements on the returned dict is incomplete, including just + those required for the tests on this class. + """ + results = { + "id": 1742984, + "type": "release", + "name": "Charade", + "slug": "charade", + "releaseDate": "2016-04-11", + "publishDate": "2016-04-11", + "audioFormat": "", + "category": "Release", + "currentStatus": "General Content", + "catalogNumber": "GR089", + "description": "", + "label": { + "id": 24539, + "name": "Gravitas Recordings", + "type": "label", + "slug": "gravitas-recordings" + }, + "artists": [{ + "id": 326158, + "name": "Supersillyus", + "slug": "supersillyus", + "type": "artist" + }], + "genres": [{ + "id": 9, + "name": "Breaks", + "slug": "breaks", + "type": "genre" + }], + } + return results + + def _make_tracks_response(self): + """Return a list that mimics a response from the beatport API. + + The results were retrived from: + https://oauth-api.beatport.com/catalog/3/tracks?releaseId=1742984 + The list of elements on the returned list is incomplete, including just + those required for the tests on this class. + """ + results = [{ + "id": 7817567, + "type": "track", + "sku": "track-7817567", + "name": "Mirage a Trois", + "trackNumber": 1, + "mixName": "Original Mix", + "title": "Mirage a Trois (Original Mix)", + "slug": "mirage-a-trois-original-mix", + "releaseDate": "2016-04-11", + "publishDate": "2016-04-11", + "currentStatus": "General Content", + "length": "7:05", + "lengthMs": 425421, + "bpm": 90, + "key": { + "standard": { + "letter": "G", + "sharp": False, + "flat": False, + "chord": "minor" + }, + "shortName": "Gmin" + }, + "artists": [{ + "id": 326158, + "name": "Supersillyus", + "slug": "supersillyus", + "type": "artist" + }], + "genres": [{ + "id": 9, + "name": "Breaks", + "slug": "breaks", + "type": "genre" + }], + "subGenres": [{ + "id": 209, + "name": "Glitch Hop", + "slug": "glitch-hop", + "type": "subgenre" + }], + "release": { + "id": 1742984, + "name": "Charade", + "type": "release", + "slug": "charade" + }, + "label": { + "id": 24539, + "name": "Gravitas Recordings", + "type": "label", + "slug": "gravitas-recordings", + "status": True + } + }, { + "id": 7817568, + "type": "track", + "sku": "track-7817568", + "name": "Aeon Bahamut", + "trackNumber": 2, + "mixName": "Original Mix", + "title": "Aeon Bahamut (Original Mix)", + "slug": "aeon-bahamut-original-mix", + "releaseDate": "2016-04-11", + "publishDate": "2016-04-11", + "currentStatus": "General Content", + "length": "7:38", + "lengthMs": 458000, + "bpm": 100, + "key": { + "standard": { + "letter": "G", + "sharp": False, + "flat": False, + "chord": "major" + }, + "shortName": "Gmaj" + }, + "artists": [{ + "id": 326158, + "name": "Supersillyus", + "slug": "supersillyus", + "type": "artist" + }], + "genres": [{ + "id": 9, + "name": "Breaks", + "slug": "breaks", + "type": "genre" + }], + "subGenres": [{ + "id": 209, + "name": "Glitch Hop", + "slug": "glitch-hop", + "type": "subgenre" + }], + "release": { + "id": 1742984, + "name": "Charade", + "type": "release", + "slug": "charade" + }, + "label": { + "id": 24539, + "name": "Gravitas Recordings", + "type": "label", + "slug": "gravitas-recordings", + "status": True + } + }, { + "id": 7817569, + "type": "track", + "sku": "track-7817569", + "name": "Trancendental Medication", + "trackNumber": 3, + "mixName": "Original Mix", + "title": "Trancendental Medication (Original Mix)", + "slug": "trancendental-medication-original-mix", + "releaseDate": "2016-04-11", + "publishDate": "2016-04-11", + "currentStatus": "General Content", + "length": "1:08", + "lengthMs": 68571, + "bpm": 141, + "key": { + "standard": { + "letter": "F", + "sharp": False, + "flat": False, + "chord": "major" + }, + "shortName": "Fmaj" + }, + "artists": [{ + "id": 326158, + "name": "Supersillyus", + "slug": "supersillyus", + "type": "artist" + }], + "genres": [{ + "id": 9, + "name": "Breaks", + "slug": "breaks", + "type": "genre" + }], + "subGenres": [{ + "id": 209, + "name": "Glitch Hop", + "slug": "glitch-hop", + "type": "subgenre" + }], + "release": { + "id": 1742984, + "name": "Charade", + "type": "release", + "slug": "charade" + }, + "label": { + "id": 24539, + "name": "Gravitas Recordings", + "type": "label", + "slug": "gravitas-recordings", + "status": True + } + }, { + "id": 7817570, + "type": "track", + "sku": "track-7817570", + "name": "A List of Instructions for When I'm Human", + "trackNumber": 4, + "mixName": "Original Mix", + "title": "A List of Instructions for When I'm Human (Original Mix)", + "slug": "a-list-of-instructions-for-when-im-human-original-mix", + "releaseDate": "2016-04-11", + "publishDate": "2016-04-11", + "currentStatus": "General Content", + "length": "6:57", + "lengthMs": 417913, + "bpm": 88, + "key": { + "standard": { + "letter": "A", + "sharp": False, + "flat": False, + "chord": "minor" + }, + "shortName": "Amin" + }, + "artists": [{ + "id": 326158, + "name": "Supersillyus", + "slug": "supersillyus", + "type": "artist" + }], + "genres": [{ + "id": 9, + "name": "Breaks", + "slug": "breaks", + "type": "genre" + }], + "subGenres": [{ + "id": 209, + "name": "Glitch Hop", + "slug": "glitch-hop", + "type": "subgenre" + }], + "release": { + "id": 1742984, + "name": "Charade", + "type": "release", + "slug": "charade" + }, + "label": { + "id": 24539, + "name": "Gravitas Recordings", + "type": "label", + "slug": "gravitas-recordings", + "status": True + } + }, { + "id": 7817571, + "type": "track", + "sku": "track-7817571", + "name": "The Great Shenanigan", + "trackNumber": 5, + "mixName": "Original Mix", + "title": "The Great Shenanigan (Original Mix)", + "slug": "the-great-shenanigan-original-mix", + "releaseDate": "2016-04-11", + "publishDate": "2016-04-11", + "currentStatus": "General Content", + "length": "9:49", + "lengthMs": 589875, + "bpm": 123, + "key": { + "standard": { + "letter": "E", + "sharp": False, + "flat": True, + "chord": "major" + }, + "shortName": "E♭maj" + }, + "artists": [{ + "id": 326158, + "name": "Supersillyus", + "slug": "supersillyus", + "type": "artist" + }], + "genres": [{ + "id": 9, + "name": "Breaks", + "slug": "breaks", + "type": "genre" + }], + "subGenres": [{ + "id": 209, + "name": "Glitch Hop", + "slug": "glitch-hop", + "type": "subgenre" + }], + "release": { + "id": 1742984, + "name": "Charade", + "type": "release", + "slug": "charade" + }, + "label": { + "id": 24539, + "name": "Gravitas Recordings", + "type": "label", + "slug": "gravitas-recordings", + "status": True + } + }, { + "id": 7817572, + "type": "track", + "sku": "track-7817572", + "name": "Charade", + "trackNumber": 6, + "mixName": "Original Mix", + "title": "Charade (Original Mix)", + "slug": "charade-original-mix", + "releaseDate": "2016-04-11", + "publishDate": "2016-04-11", + "currentStatus": "General Content", + "length": "7:05", + "lengthMs": 425423, + "bpm": 123, + "key": { + "standard": { + "letter": "A", + "sharp": False, + "flat": False, + "chord": "major" + }, + "shortName": "Amaj" + }, + "artists": [{ + "id": 326158, + "name": "Supersillyus", + "slug": "supersillyus", + "type": "artist" + }], + "genres": [{ + "id": 9, + "name": "Breaks", + "slug": "breaks", + "type": "genre" + }], + "subGenres": [{ + "id": 209, + "name": "Glitch Hop", + "slug": "glitch-hop", + "type": "subgenre" + }], + "release": { + "id": 1742984, + "name": "Charade", + "type": "release", + "slug": "charade" + }, + "label": { + "id": 24539, + "name": "Gravitas Recordings", + "type": "label", + "slug": "gravitas-recordings", + "status": True + } + }] + return results + + def setUp(self): + self.setup_beets() + self.load_plugins('beatport') + self.lib = library.Library(':memory:') + + # Set up 'album'. + response_release = self._make_release_response() + self.album = beatport.BeatportRelease(response_release) + + # Set up 'tracks'. + response_tracks = self._make_tracks_response() + self.tracks = [beatport.BeatportTrack(t) for t in response_tracks] + + # Set up 'test_album'. + self.test_album = self.mk_test_album() + # print(self.test_album.keys()) + + # Set up 'test_tracks' + self.test_tracks = self.test_album.items() + + def tearDown(self): + self.unload_plugins() + self.teardown_beets() + + def mk_test_album(self): + items = [_common.item() for _ in range(6)] + for item in items: + item.album = 'Charade' + item.catalognum = 'GR089' + item.label = 'Gravitas Recordings' + item.artist = 'Supersillyus' + item.year = 2016 + item.comp = False + item.label_name = 'Gravitas Recordings' + item.genre = 'Glitch Hop' + item.year = 2016 + item.month = 4 + item.day = 11 + item.mix_name = 'Original Mix' + + items[0].title = 'Mirage a Trois' + items[1].title = 'Aeon Bahamut' + items[2].title = 'Trancendental Medication' + items[3].title = 'A List of Instructions for When I\'m Human' + items[4].title = 'The Great Shenanigan' + items[5].title = 'Charade' + + items[0].length = timedelta(minutes=7, seconds=5).total_seconds() + items[1].length = timedelta(minutes=7, seconds=38).total_seconds() + items[2].length = timedelta(minutes=1, seconds=8).total_seconds() + items[3].length = timedelta(minutes=6, seconds=57).total_seconds() + items[4].length = timedelta(minutes=9, seconds=49).total_seconds() + items[5].length = timedelta(minutes=7, seconds=5).total_seconds() + + items[0].url = 'mirage-a-trois-original-mix' + items[1].url = 'aeon-bahamut-original-mix' + items[2].url = 'trancendental-medication-original-mix' + items[3].url = 'a-list-of-instructions-for-when-im-human-original-mix' + items[4].url = 'the-great-shenanigan-original-mix' + items[5].url = 'charade-original-mix' + + counter = 0 + for item in items: + counter += 1 + item.track_number = counter + + items[0].bpm = 90 + items[1].bpm = 100 + items[2].bpm = 141 + items[3].bpm = 88 + items[4].bpm = 123 + items[5].bpm = 123 + + items[0].musical_key = 'Gmin' + items[1].musical_key = 'Gmaj' + items[2].musical_key = 'Fmaj' + items[3].musical_key = 'Amin' + items[4].musical_key = 'E♭maj' + items[5].musical_key = 'Amaj' + + for item in items: + self.lib.add(item) + + album = self.lib.add_album(items) + album.store() + + return album + + # Test BeatportRelease. + def test_album_name_applied(self): + self.assertEqual(self.album.name, self.test_album['album']) + + def test_catalog_number_applied(self): + self.assertEqual(self.album.catalog_number, + self.test_album['catalognum']) + + def test_label_applied(self): + self.assertEqual(self.album.label_name, self.test_album['label']) + + def test_category_applied(self): + self.assertEqual(self.album.category, 'Release') + + def test_album_url_applied(self): + self.assertEqual(self.album.url, + 'https://beatport.com/release/charade/1742984') + + # Test BeatportTrack. + def test_title_applied(self): + for track, test_track in zip(self.tracks, self.test_tracks): + self.assertEqual(track.name, test_track.title) + + def test_mix_name_applied(self): + for track, test_track in zip(self.tracks, self.test_tracks): + self.assertEqual(track.mix_name, test_track.mix_name) + + def test_length_applied(self): + for track, test_track in zip(self.tracks, self.test_tracks): + self.assertEqual(int(track.length.total_seconds()), + int(test_track.length)) + + def test_track_url_applied(self): + # Specify beatport ids here because an 'item.id' is beets-internal. + ids = [ + 7817567, + 7817568, + 7817569, + 7817570, + 7817571, + 7817572, + ] + # Concatenate with 'id' to pass strict equality test. + for track, test_track, id in zip(self.tracks, self.test_tracks, ids): + self.assertEqual( + track.url, 'https://beatport.com/track/' + + test_track.url + '/' + six.text_type(id)) + + def test_bpm_applied(self): + for track, test_track in zip(self.tracks, self.test_tracks): + self.assertEqual(track.bpm, test_track.bpm) + + def test_musical_key_applied(self): + for track, test_track in zip(self.tracks, self.test_tracks): + self.assertEqual(track.musical_key, test_track.musical_key) + + def test_genre_applied(self): + for track, test_track in zip(self.tracks, self.test_tracks): + self.assertEqual(track.genre, test_track.genre) + + +def suite(): + return unittest.TestLoader().loadTestsFromName(__name__) + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') From 4038e36343da41f631b23970a96ed150fef2886c Mon Sep 17 00:00:00 2001 From: temrix Date: Thu, 19 Sep 2019 00:33:57 +0200 Subject: [PATCH 4/8] Update URL to use HTTPS and add documentation. --- docs/plugins/beatport.rst | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/plugins/beatport.rst b/docs/plugins/beatport.rst index 709dbb0a8..0fcd7676b 100644 --- a/docs/plugins/beatport.rst +++ b/docs/plugins/beatport.rst @@ -4,7 +4,9 @@ Beatport Plugin The ``beatport`` plugin adds support for querying the `Beatport`_ catalogue during the autotagging process. This can potentially be helpful for users whose collection includes a lot of diverse electronic music releases, for which -both MusicBrainz and (to a lesser degree) Discogs show no matches. +both MusicBrainz and (to a lesser degree) `Discogs`_ show no matches. + +.. _Discogs: https://discogs.com Installation ------------ @@ -21,15 +23,18 @@ run the :ref:`import-cmd` command after enabling the plugin, it will ask you to authorize with Beatport by visiting the site in a browser. On the site you will be asked to enter your username and password to authorize beets to query the Beatport API. You will then be displayed with a single line of -text that you should paste into your terminal. This will store the -authentication data for subsequent runs and you will not be required to -repeat the above steps. +text that you should paste as a whole into your terminal. This will store the +authentication data (under ``~/.config/beatport_token.json``) for subsequent +runs and you will not be required to repeat the above steps. Matches from Beatport should now show up alongside matches from MusicBrainz and other sources. If you have a Beatport ID or a URL for a release or track you want to tag, you -can just enter one of the two at the "enter Id" prompt in the importer. +can just enter one of the two at the "enter Id" prompt in the importer. You can +also search for an id like so: + + beet import path/to/music/library --search-id id .. _requests: https://docs.python-requests.org/en/latest/ .. _requests_oauthlib: https://github.com/requests/requests-oauthlib From c2b750d7e487dd5ff01e93c02d515e25afcab271 Mon Sep 17 00:00:00 2001 From: temrix Date: Thu, 19 Sep 2019 00:39:16 +0200 Subject: [PATCH 5/8] Add feature and add For plugin developers. --- docs/changelog.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index cf14ae974..aa87929c6 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -70,6 +70,9 @@ New features: you can now match tracks and albums using the `Deezer`_ database. Thanks to :user:`rhlahuja`. :bug:`3355` +* :doc:`/plugins/beatport`: The plugin now gets the musical key, BPM and the + genre for each track. + :bug:`2080` Fixes: @@ -136,6 +139,8 @@ For plugin developers: APIs to provide metadata matches for the importer. Refer to the Spotify and Deezer plugins for examples of using this template class. :bug:`3355` +* The autotag hooks have been modified such that they now take 'bpm', + 'musical_key' and a per-track based 'genre' as attributes. For packagers: From 045f5723e24e40f1ed78edd20ed2b54fc52f0b83 Mon Sep 17 00:00:00 2001 From: temrix Date: Thu, 19 Sep 2019 21:57:09 +0200 Subject: [PATCH 6/8] Remove hard-coded path. --- docs/plugins/beatport.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plugins/beatport.rst b/docs/plugins/beatport.rst index 0fcd7676b..980add451 100644 --- a/docs/plugins/beatport.rst +++ b/docs/plugins/beatport.rst @@ -24,8 +24,8 @@ to authorize with Beatport by visiting the site in a browser. On the site you will be asked to enter your username and password to authorize beets to query the Beatport API. You will then be displayed with a single line of text that you should paste as a whole into your terminal. This will store the -authentication data (under ``~/.config/beatport_token.json``) for subsequent -runs and you will not be required to repeat the above steps. +authentication data for subsequentruns and you will not be required to repeat +the above steps. Matches from Beatport should now show up alongside matches from MusicBrainz and other sources. From 15f08aba4fd03ee11eaa6d28f66580073b4dc804 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Thu, 19 Sep 2019 16:13:42 -0400 Subject: [PATCH 7/8] Restore missing space --- docs/plugins/beatport.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugins/beatport.rst b/docs/plugins/beatport.rst index 980add451..d645e2043 100644 --- a/docs/plugins/beatport.rst +++ b/docs/plugins/beatport.rst @@ -24,7 +24,7 @@ to authorize with Beatport by visiting the site in a browser. On the site you will be asked to enter your username and password to authorize beets to query the Beatport API. You will then be displayed with a single line of text that you should paste as a whole into your terminal. This will store the -authentication data for subsequentruns and you will not be required to repeat +authentication data for subsequent runs and you will not be required to repeat the above steps. Matches from Beatport should now show up alongside matches From 88aea76fcb7a064d265ca119be44f28c86773442 Mon Sep 17 00:00:00 2001 From: temrix Date: Thu, 19 Sep 2019 22:35:40 +0200 Subject: [PATCH 8/8] Add requests_oauthlib to test deps. --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index 8736f0f3c..9dac2d59a 100644 --- a/tox.ini +++ b/tox.ini @@ -24,6 +24,7 @@ deps = python-mpd2 coverage discogs-client + requests_oauthlib [_flake8] deps =