Repository: beetbox/beets

Issue:
Integers are not normalized
## The Curse of the Floating BPM Value

### Problem

Beets currently allows the Model to set and the library to store any value for a field which is declared as `types.INTEGER` in the Item class. This behaviour however is not supported by MediaFile and for fields (such as bpm) which are [declared as integers](https://github.com/beetbox/mediafile/blob/master/mediafile.py#L1735)  these values are casted to integer before being written to the audio file tags. This results in the Model remaining always dirty and hence in the impossibility to have the library fully aligned with the media files.

This issue has been reported in #762 and discussed [here](https://discourse.beets.io/t/beet-write-bpm-quirk/738) on the forum. I am opening a new issue because even though this issue is most evident with the bpm field in reality it affects all fields declared `types.INTEGER` [in the Item class](https://github.com/beetbox/beets/blob/master/beets/library.py#L437).

In short, for any item in the library which has a bpm value in a non-integer format:
```text
     ...
     bpm: 119.434509277
     ...
```

Running this command:
```sh
$ beet write
```

will produce:

```
...
Madness - Full House: The Very Best of Madness - My Girl
  bpm: 119 -> 119.434509277
...
```

repeatedly. The audio file, if inspected through `beet info "My Girl"` will report:
```text
    ...
    bpm: 119
    ...
```
Therefore library and mediafile will remain forever out of sync.

### Setup

* OS: macOS 10.13.6
* Python version: 3.7.6
* beets version:  1.4.9 ([master](https://github.com/beetbox/beets/commit/8b8d223755662f499125b913ec9298faf3a2e3b2))
* Turning off plugins made problem go away (yes/no): no

My configuration (output of `beet config`) is:

```yaml
directory: /Volumes/Data/_TmpMusic_/
library: ~/.config/beets/test_library.db
asciify_paths: yes
id3v23: yes

ui:
    color: yes

plugins:
- the
- inline
- info
- fromfilename
- duplicates
- convert
- zero
- acousticbrainz

import:
    copy: no
    move: yes
    autotag: yes
    from_scratch: yes
    none_rec_action: asis
    duplicate_action: remove
    set_fields:
        albumstatus: new

paths:
    default: '%asciify{$initial_letter}/%the{%asciify{$albumartist}}/%asciify{$album}/$track. %asciify{$title}'
    singleton: V/Various Artists/%the{%asciify{$artist}}/$track. %asciify{$title}
    comp: V/Various Artists/%the{%asciify{$artist}}/$track. %asciify{$title}

musicbrainz:
    searchlimit: 3
item_fields:
    initial_letter: albumartist[0].upper() if albumartist else 'U'
zero:
    auto: yes
    fields:
    - month
    - day
    - encoder
    - lyrics
    - comments
    - artist_sort
    - original_month
    - original_day
    update_database: yes
    keep_fields: []
convert:
    command: ffmpeg -i $source -y -q:a 5 -f mp3 $dest
    extension: mp3
    max_bitrate: 182
    threads: 1
    auto: no
    dest: /Volumes/Data/_TmpMusic_/___converted___
    format: mp3
    formats:
        mp3: ffmpeg -i $source -y -q:a 5 -f mp3 $dest
        flac: ffmpeg -i $source -y -acodec flac -q:a 5 -f mp3 $dest
        aac:
            command: ffmpeg -i $source -y -vn -acodec aac -aq 1 $dest
            extension: m4a
        alac:
            command: ffmpeg -i $source -y -vn -acodec alac $dest
            extension: m4a
        opus: ffmpeg -i $source -y -vn -acodec libopus -ab 96k $dest
        ogg: ffmpeg -i $source -y -vn -acodec libvorbis -aq 3 $dest
        wma: ffmpeg -i $source -y -vn -acodec wmav2 -vn $dest
    pretend: no
    link: no
    hardlink: no
    id3v23: inherit
    tmpdir:
    quiet: no
    embed: yes

    paths: {}
    no_convert: ''
    never_convert_lossy_files: no
    copy_album_art: no
    album_art_maxwidth: 0
acousticbrainz:
    auto: no
    force: no
    tags: [bpm]
duplicates:
    album: no
    checksum: ''
    copy: ''
    count: no
    delete: no
    format: ''
    full: no
    keys: []
    merge: no
    move: ''
    path: no
    tiebreak: {}
    strict: no
    tag: ''
the:
    the: yes
    a: yes
    format: '{0}, {1}'
    strip: no
    patterns: []
pathfields: {}
album_fields: {}

```


Discussion:

Patch:
diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py
index 3195b52c9..b13f2638a 100755
--- a/beets/dbcore/db.py
+++ b/beets/dbcore/db.py
@@ -189,7 +189,7 @@ class LazyConvertDict(object):
 
 class Model(object):
     """An abstract object representing an object in the database. Model
-    objects act like dictionaries (i.e., the allow subscript access like
+    objects act like dictionaries (i.e., they allow subscript access like
     ``obj['field']``). The same field set is available via attribute
     access as a shortcut (i.e., ``obj.field``). Three kinds of attributes are
     available:
diff --git a/beets/dbcore/types.py b/beets/dbcore/types.py
index 521a5a1ee..5aa2b9812 100644
--- a/beets/dbcore/types.py
+++ b/beets/dbcore/types.py
@@ -131,6 +131,14 @@ class Integer(Type):
     query = query.NumericQuery
     model_type = int
 
+    def normalize(self, value):
+        try:
+            return self.model_type(round(float(value)))
+        except ValueError:
+            return self.null
+        except TypeError:
+            return self.null
+
 
 class PaddedInt(Integer):
     """An integer field that is formatted with a given number of digits,
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 7ef19871b..fca3e686f 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -166,6 +166,9 @@ Fixes:
 * Added a warning when configuration files defined in the `include` directive
   of the configuration file fail to be imported.
   :bug:`3498`
+* Added the normalize method to the dbcore.types.INTEGER class which now
+  properly returns integer values.
+  :bug:`762` and :bug:`3507`
 
 For plugin developers:
 
diff --git a/docs/plugins/chroma.rst b/docs/plugins/chroma.rst
index a6b60e6d8..1b86073b8 100644
--- a/docs/plugins/chroma.rst
+++ b/docs/plugins/chroma.rst
@@ -96,9 +96,7 @@ Usage
 
 Once you have all the dependencies sorted out, enable the ``chroma`` plugin in
 your configuration (see :ref:`using-plugins`) to benefit from fingerprinting
-the next time you run ``beet import``. (The plugin doesn't produce any obvious
-output by default. If you want to confirm that it's enabled, you can try
-running in verbose mode once with ``beet -v import``.)
+the next time you run ``beet import``.
 
 You can also use the ``beet fingerprint`` command to generate fingerprints for
 items already in your library. (Provide a query to fingerprint a subset of your


Test patch:
diff --git a/test/test_autotag.py b/test/test_autotag.py
index 28b7fd209..a11bc8fac 100644
--- a/test/test_autotag.py
+++ b/test/test_autotag.py
@@ -91,7 +91,10 @@ class PluralityTest(_common.TestCase):
                  for i in range(5)]
         likelies, _ = match.current_metadata(items)
         for f in fields:
-            self.assertEqual(likelies[f], '%s_1' % f)
+            if isinstance(likelies[f], int):
+                self.assertEqual(likelies[f], 0)
+            else:
+                self.assertEqual(likelies[f], '%s_1' % f)
 
 
 def _make_item(title, track, artist=u'some artist'):
diff --git a/test/test_dbcore.py b/test/test_dbcore.py
index 9bf78de67..0d40896da 100644
--- a/test/test_dbcore.py
+++ b/test/test_dbcore.py
@@ -53,6 +53,7 @@ class ModelFixture1(dbcore.Model):
     _fields = {
         'id': dbcore.types.PRIMARY_ID,
         'field_one': dbcore.types.INTEGER,
+        'field_two': dbcore.types.STRING,
     }
     _types = {
         'some_float_field': dbcore.types.FLOAT,
@@ -355,7 +356,7 @@ class ModelTest(unittest.TestCase):
     def test_items(self):
         model = ModelFixture1(self.db)
         model.id = 5
-        self.assertEqual({('id', 5), ('field_one', 0)},
+        self.assertEqual({('id', 5), ('field_one', 0), ('field_two', '')},
                          set(model.items()))
 
     def test_delete_internal_field(self):
@@ -370,10 +371,28 @@ class ModelTest(unittest.TestCase):
 
 
 class FormatTest(unittest.TestCase):
-    def test_format_fixed_field(self):
+    def test_format_fixed_field_integer(self):
         model = ModelFixture1()
-        model.field_one = u'caf\xe9'
+        model.field_one = 155
         value = model.formatted().get('field_one')
+        self.assertEqual(value, u'155')
+
+    def test_format_fixed_field_integer_normalized(self):
+        """The normalize method of the Integer class rounds floats
+        """
+        model = ModelFixture1()
+        model.field_one = 142.432
+        value = model.formatted().get('field_one')
+        self.assertEqual(value, u'142')
+
+        model.field_one = 142.863
+        value = model.formatted().get('field_one')
+        self.assertEqual(value, u'143')
+
+    def test_format_fixed_field_string(self):
+        model = ModelFixture1()
+        model.field_two = u'caf\xe9'
+        value = model.formatted().get('field_two')
         self.assertEqual(value, u'caf\xe9')
 
     def test_format_flex_field(self):