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:
Yep, it looks like the plugin doesn't get those fields yet. Seems like a reasonable addition if you're interested.

@sampsyo definitely interested! 😄 

I'd love to implement this, but can you tell me how I can pass this information to beets, @sampsyo?
I don't see a `bpm` or `key` field on the `TrackInfo` class.

As for the Genre tags, do you have a few IDs of releases that are missing genre tags so I can reproduce this?

Ah, good point @jbaiter -- I had forgotten that would be a roadblock.

For purposes like this, I've been meaning to replace the current implementations of `TrackInfo` and `AlbumInfo` with an "unstructured dict-like" alternative, where you could add arbitrary fields which will then be transferred into `Item` and `Album` objects when they're applied. Maybe this is the excuse we need to implement that style, which would then make this feature easy?

Sounds good to me, however I'm afraid my knowledge of beets internals is too limited to tackle that task.
If you provide the API, however, I'll gladly adapt the plugin :-)

It's a deal—I'll check back in here after I make progress on that refactoring.

@sampsyo just wondering if you have made any progress on this. Not a programmer myself, otherwise I'd take a stab at it. Apologize in advance if I sound snobby, not meaning to. 😄

No, sorry—I've been distracted with a few other things. I do hope to get to it soon.

@sampsyo Right on, no worries. :)

I've posted a bounty for this to get implemented if anyone is interested. I just really need this feature so figured I'd put up some cash to make it happen! 😄 

https://www.bountysource.com/issues/35434835-beatport-fetch-musical-key-and-bpm-data

Btw, I've noticed the Genre tag is missing as well now on every track I import.

Anybody up for tackling this? 
https://www.bountysource.com/issues/35434835-beatport-fetch-musical-key-and-bpm-data
I really need this, too! Is anybody up for it?
I too would like to see this. The BPM/Genre is quite important for me.
On Beatport, it's spot-on all the time.
Would also love to see this implemented and added some $ to the bounty :)

https://www.bountysource.com/issues/35434835-beatport-fetch-musical-key-bpm-and-genre-data

@brianroach you seem to have had the same issues as i do now (including the one with "original mix" & not finding some tracks), did you find any other solution after all?

I am currently tackling this issue.

I have already implemented BPM. Musical key should be equivalently easy. Beatport provides genre data for each track while beets has a genre per album. I am currently testing how this can be done best.

However, the API hooks need to be modified for this. Maybe I can modify it such that they take arbitrary values.

Resolving patch:
diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py
--- 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
--- a/beets/autotag/hooks.py
+++ b/beets/autotag/hooks.py
@@ -179,7 +179,8 @@ def __init__(self, title, track_id, release_track_id=None, artist=None,
                  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 @@ def __init__(self, title, track_id, release_track_id=None, artist=None,
         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
--- a/beetsplug/beatport.py
+++ b/beetsplug/beatport.py
@@ -255,6 +255,16 @@ def __init__(self, data):
             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 @@ def _get_track_info(self, track):
                          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


Test patch:
diff --git a/test/test_beatport.py b/test/test_beatport.py
new file mode 100644
--- /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')