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:

Resolving patch:
diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py
--- a/beets/dbcore/db.py
+++ b/beets/dbcore/db.py
@@ -189,7 +189,7 @@ def __iter__(self):
 
 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
--- 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,


Test patch:
diff --git a/test/test_autotag.py b/test/test_autotag.py
--- a/test/test_autotag.py
+++ b/test/test_autotag.py
@@ -91,7 +91,10 @@ def test_current_metadata_likelies(self):
                  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
--- 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 @@ def test_computed_field(self):
     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 @@ def test_parse_nonstring(self):
 
 
 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):