Repository: beetbox/beets

Issue:
beatport: Fetch musical key, BPM, and genre data
### Problem

Everything seems to be getting pulled in except BPM & Key. Genre tag appears to be hit or miss. All tracks are from beatport, so the tags should be there. Looking at the API docs I see the facets listed. Are these actually accessible in Track Info?

```
Requires catalog API Permission 

Content can be filtered on facets. Ex:facets=genreName:House, subGenreName:Hard 

Use returnFacets to obtain specific information about the return set.

Allowed facets/returnFacets:

Note: Not all facets are relevant to all content types.

artistName
performerName
artist
artistId
performerId
label
labelName
labelId
subGenre
subGenreName
subGenreId
genre
genreName
genreId
bpm         <------------
key          <------------
tagId
```
### Setup
- OS: Mac OS X 10.11.5 (15F34)
- Python version: 2,7
- beets version: 1.3.18
- Turning off plugins made problem go away (yes/no): no

My configuration (output of `beet config`) is:

``` yaml
briansmac:beets anodigital$ beet config
lyrics:
    bing_lang_from: []
    bing_client_secret: REDACTED
    google_API_key: REDACTED
    force: no
    google_engine_ID: REDACTED
    sources:
    - google
    - lyricwiki
    - lyrics.com
    - musixmatch
    auto: yes
    fallback:
    genius_api_key: REDACTED
    bing_lang_to:
library: /Users/anodigital/Desktop/music.blb
original_date: yes
embedart:
    maxwidth: 1000
    compare_threshold: 0
    auto: yes
    ifempty: no
    remove_art_file: no
pluginpath: ~/github/beets/beetsplug

plugins: beatport fromfilename discogs embedart fetchart ftintitle fuzzy importadded lyrics mbsync spotify types
beatport:
    source_weight: 0.5
    tokenfile: beatport_token.json
    apikey: REDACTED
    apisecret: REDACTED
threaded: yes
art_filename: cover
duplicate_action: remove
none_rec_action: skip
ignore: .AppleDouble ._* *~ .DS_Store
directory: /Users/anodigital/Desktop/Music

import:
    copy: no
    write: yes
    resume: yes
    quiet_fallback: skip
    timid: no
    log: beetslog.txt
group_albums: no

match:
    strong_rec_thresh: 0.05
    medium_rec_thresh: 0.25
    rec_gap_thresh: 0.25
    max_rec:
        missing_tracks: medium
        unmatched_tracks: medium
    ignored: missing_tracks unmatched_tracks
discogs:
    source_weight: 0.5
    tokenfile: discogs_token.json
    apikey: REDACTED
    apisecret: REDACTED
fuzzy:
    threshold: 0.7
    prefix: '~'
spotify:
    regex: []
    artist_field: albumartist
    mode: list
    track_field: title
    album_field: album
    region_filter:
    show_failures: no
    tiebreak: popularity
importadded:
    preserve_write_mtimes: no
    preserve_mtimes: no
fetchart:
    auto: yes
    minwidth: 0
    sources:
    - filesystem
    - coverart
    - itunes
    - amazon
    - albumart
    google_engine: 001442825323518660753:hrh5ch1gjzm
    enforce_ratio: no
    cautious: no
    maxwidth: 0
    store_source: no
    google_key: REDACTED
    fanarttv_key: REDACTED
    cover_names:
    - cover
    - front
    - art
    - album
    - folder
ftintitle:
    auto: yes
    drop: no
    format: feat. {0}
```


Discussion:

Patch:
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'):
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
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:
 
diff --git a/docs/plugins/beatport.rst b/docs/plugins/beatport.rst
index 709dbb0a8..d645e2043 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 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
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 =


Test patch:
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&#9837;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&#9837;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')