Repository: tinytag/tinytag

Issue:
[BUG] ID3: Only first WOAR frame is kept
**Describe the bug**
According to the ID3 spec, the `WOAR` frame may appear multiple times

**To Reproduce**

1. tinytag open an MP3 with multiple `WOAR` frame
2. see that `extra["url"]` only has the first entry and not the second

**Expected behavior**
`extra["url"]` should be an (ordered) list wit all `WOAR` frame

**Sample File**
[example](https://github.com/whatsnowplaying/whats-now-playing/blob/tt/tests/audio/multiwebsite.mp3)

Hints from issue comments:
@aw-was-here: Looks like most of the website tags have the same problem.@mathiascode: This will require special handling similar to artists and genres. Which types of URLs are you interested in?@aw-was-here: I'm particularly interested in the artist websites (e.g., equivalent of WOAR under ID3).  Since 2.0 is going to be incompatible, maybe this would be a good time for TinyTag to automatically put things in lists if it detects more than on frame for a field, regardless of the frame type?

EDIT: I'm hitting the same problem with fields like 'musicbrainz_artistid' in flac's and that's an absolute killer for my app.  I won't be able to use TinyTag exclusively without getting all of the values for fields that are allowed to be repeated. ☹️ @mathiascode: > Since 2.0 is going to be incompatible, maybe this would be a good time for TinyTag to automatically put things in lists if it detects more than on frame for a field, regardless of the frame type?

I don't think doing this automatically this is a good idea. Returned types should be predictable and consistent, we can't just switch between a string/integer and list.

How about keeping non-extra fields as-is, but always using lists for extra fields? There will be a lot of single-item lists, but we can also guarantee that a list will always contain at least one item.@aw-was-here: > I don't think doing this automatically this is a good idea. Returned types should be predictable and consistent, we can't just switch between a string/integer and list.

You could always just make them always lists.

> How about keeping non-extra fields as-is, but always using lists for extra fields? There will be a lot of single-item lists, but we can also guarantee that a list will always contain at least one item.

That sounds reasonable, esp if the non-extra lists also appear in extra as lists.@mathiascode: Can you test https://github.com/devsnd/tinytag/pull/209 and verify that it works as expected?

New behavior:
- All `extra` attributes are always lists containing one or more values
- Non-extra attributes (e.g. `album`, `artist`, `genre`) store the first value. If any additional values are found for these attributes, they are stored in `extra` lists (e.g. `extra.album`, `extra.artist`, `extra.genre`).@aw-was-here: Sorry this completely slipped my mind (I was at the Cruel World festival).  I'll try to bang on it here over the weekend.@aw-was-here: > * Non-extra attributes (e.g. `album`, `artist`, `genre`) store the first value. If any additional values are found for these attributes, they are stored in `extra` lists (e.g. `extra.album`, `extra.artist`, `extra.genre`).

Including the one value that is stored in non-extra, correct?  Just want to verify what I'm seeing.@mathiascode: > Including the one value that is stored in non-extra, correct? Just want to verify what I'm seeing.

Excluding the first non-extra value (except for `composer`, but I decided to [undeprecate it](https://github.com/devsnd/tinytag/commit/f9bdcc6220b9ab51a9f0c396a08850ddc276246a), which will also fix the behavior there).

The idea is that you use the non-extra fields to get common info about a file as a single value (in part for backwards compatibility, to avoid breakage for every existing user out there, but also for consistency). For more "advanced" use cases, the extra dict provides any remaining, nonduplicate info in the form of lists.@aw-was-here: Ok then there is either a bug or I'm doing something wrong.  With a track with a single artist, I'm seeing the artist primary value also showing up in extra.artists.  FWIW, it'd make my life easier to just read that one field but in the end appending doesn't make much difference. :smile: 

```
{'album': 'Ghosts I-IV',
 'albumartist': 'Nine Inch Nails',
 'artist': 'Nine Inch Nails',
 'bitdepth': 24,
 'bitrate': 1058.4,
 'channels': 2,
 'comment': None,
 'disc': 1,
 'disc_total': 1,
 'duration': 113.148,
 'extra': {'ab:genre': ['Jazz'],
           'ab:mood': ['Not sad'],
           'acoustid id': ['02d23182-de8b-493e-a6e1-e011bfdacbcf'],
           'arranger': ['Atticus Ross'],
           'artists': ['Nine Inch Nails'],
           'asin': ['B00158SHD8'],
           'barcode': ['766929908628'],
           'catalognumber': ['halo twenty six LE'],
           'encoded_by': ['Lavf59.27.100'],
           'engineer': ['Alan Moulder'],
           'isrc': ['USTC40852243'],
           'label': ['The Null Corporation'],
           'license': ['https://creativecommons.org/licenses/by-nc-sa/3.0/us/'],
           'media': ['Digital Media'],
           'mixer': ['Alan Moulder'],
           'musicbrainz album artist id': ['b7ffd2af-418f-4be2-bdd1-22f8b48613da'],
           'musicbrainz album id': ['3af7ec8c-3bf4-4e6d-9bb3-1885d22b2b6a'],
           'musicbrainz album release country': ['XW'],
           'musicbrainz album status': ['official'],
           'musicbrainz album type': ['album'],
           'musicbrainz artist id': ['b7ffd2af-418f-4be2-bdd1-22f8b48613da'],
           'musicbrainz release group id': ['550bf4b9-92ca-30ba-9ea2-8b45f9d081f1'],
           'musicbrainz release track id': ['168cb2db-5626-30c5-b822-dbf2324c2f49'],
           'musicbrainz track id': ['2d7f08e1-be1c-4b86-b725-6e675b7b6de0'],
           'originaldate': ['2008-03-02'],
           'originalyear': ['2008'],
           'performer': ['Trent Reznor'],
           'producer': ['Atticus Ross'],
           'script': ['Latn'],
           'website': ['https://www.nin.com/']},
 'filename': '/Users/aw/Src/whats-now-playing/tests/audio/15_Ghosts_II_64kb_füllytâgged.m4a',
 'filesize': 11036760,
 'genre': 'Industrial',
 'images': {'back_cover': [],
            'extra': {},
            'front_cover': [{'data': b'\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x01\x00H\x00H\x00\x00\xff\xfe\x00\x0cAppleMark\n\xff\xdb\x00\x84\x00\x02\x02\x02\x02\x02\x02..',
                             'description': None,
                             'mime_type': 'image/jpeg',
                             'name': 'front_cover'},
                            {'data': b'\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x02\x01\x00H\x00H\x00\x00\xff\xe1\t\xf9Exif\x00\x00MM\x00*\x00\x00\x00\x08\x00\x0e\x01\x00\x00\x03\x00..',
                             'description': None,
                             'mime_type': 'image/jpeg',
                             'name': 'front_cover'}],
            'leaflet': [],
            'media': [],
            'other': []},
 'samplerate': 22050,
 'title': '15 Ghosts II',
 'track': 15,
 'track_total': 36,
 'year': '2008-03-02'}
```@mathiascode: `artists` must be some format-specific field. Maybe I have to map it to `extra.artist`.@mathiascode: Should be fixed in the latest revision of the PR.@mathiascode: > FWIW, it'd make my life easier to just read that one field but in the end appending doesn't make much difference. 😄

You should now be able to do this with `tag.as_dict(flatten=True)`. The `as_dict()` method returns the tag as a dictionary, and the `flatten` parameter merges the fields in the extra dict with the non-extra ones.

Edit: `flatten` is now true by default, so you can just use `tag.as_dict()`.@mathiascode: Have you been able to test the latest changes?@mathiascode: I'll assume the changes in the PR are fine, and merge it. Let me know if something is still missing.@mathiascode: Updated documentation for 2.0.0 is available here: https://github.com/mathiascode/tinytag/blob/2.0.0-docs/README.md

Let me know if something is unclear, missing or incorrect, and I'll make improvements before the final release.

Solution patch:
diff --git a/.appveyor.yml b/.appveyor.yml
deleted file mode 100644
index d6dba94..0000000
--- a/.appveyor.yml
+++ /dev/null
@@ -1,20 +0,0 @@
-image:
-- Visual Studio 2017
-
-stack: python 3
-
-environment:
-  PY_DIR: C:\Python37-x64
-
-clone_depth: 3
-
-build: off
-
-init:
-- cmd: set PATH=%PY_DIR%;%PY_DIR%\Scripts;%PATH%
-
-install:
-- pip install .[tests]
-
-test_script:
-- pytest --cov
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 5dcc691..87fe55b 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -46,20 +46,25 @@ jobs:
           python -m mypy -p tinytag
 
       - name: Unit tests
-        run: python -m pytest --cov
+        run: python -m pytest --cov --cov-report=lcov:coverage/lcov.info
         env:
           TINYTAG_DEBUG: true
 
       - name: Build package
         run: python -m build
 
-      - name: Coverage report
-        if: matrix.os == 'ubuntu-latest' && matrix.python == '3.12'
-        run: coverage lcov
-
       - name: Coveralls
         uses: coverallsapp/github-action@master
-        if: matrix.os == 'ubuntu-latest' && matrix.python == '3.12'
         with:
-          github-token: ${{ secrets.GITHUB_TOKEN }}
-          path-to-lcov: coverage.lcov
+          flag-name: run-${{ join(matrix.*, '-') }}
+          parallel: true
+
+  finish:
+    needs: tests
+    if: ${{ always() }}
+    runs-on: ubuntu-latest
+    steps:
+      - name: Coveralls finished
+        uses: coverallsapp/github-action@master
+        with:
+          parallel-finished: true
diff --git a/MANIFEST.in b/MANIFEST.in
index 51a0f07..4ed75df 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,4 +1,6 @@
 include *.md
+include *.png
+include *.svg
 include *.toml
 include *.txt
 include LICENSE
diff --git a/README.md b/README.md
index 0c18f7e..f5b3fc5 100644
--- a/README.md
+++ b/README.md
@@ -2,10 +2,10 @@
 
 tinytag is a Python library for reading audio file metadata
 
-[![Build Status](https://img.shields.io/github/actions/workflow/status/devsnd/tinytag/tests.yml
-)](https://github.com/devsnd/tinytag/actions?query=workflow:%22Tests%22)
-[![Coverage Status](https://img.shields.io/coverallsCoverage/github/devsnd/tinytag
-)](https://coveralls.io/r/devsnd/tinytag)
+[![Build Status](https://img.shields.io/github/actions/workflow/status/tinytag/tinytag/tests.yml
+)](https://github.com/tinytag/tinytag/actions?query=workflow:%22Tests%22)
+[![Coverage Status](https://img.shields.io/coverallsCoverage/github/tinytag/tinytag
+)](https://coveralls.io/r/tinytag/tinytag)
 [![PyPI Version](https://img.shields.io/pypi/v/tinytag
 )](https://pypi.org/project/tinytag/)
 [![PyPI Downloads](https://img.shields.io/pypi/dm/tinytag
diff --git a/setup.cfg b/setup.cfg
index 9c1bb77..15d2849 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -3,7 +3,7 @@ name = tinytag
 version = 2.0.0
 author = Tom Wallroth
 author_email = tomwallroth@gmail.com
-url = https://github.com/devsnd/tinytag
+url = https://github.com/tinytag/tinytag
 description = Read audio file metadata
 keywords =
   metadata
@@ -35,6 +35,7 @@ classifiers =
   Topic :: Multimedia
   Topic :: Multimedia :: Sound/Audio
   Topic :: Multimedia :: Sound/Audio :: Analysis
+  Typing :: Typed
 license = MIT
 license_files = LICENSE
 long_description = file: README.md
diff --git a/tinytag/__init__.py b/tinytag/__init__.py
index 0e62032..683b303 100644
--- a/tinytag/__init__.py
+++ b/tinytag/__init__.py
@@ -1,8 +1,10 @@
 """Audio file metadata reader"""
 
 from .tinytag import (
-    ParseError, TinyTag, TagImage, TagImages, TinyTagException, UnsupportedFormatError
+    ParseError, TinyTag, TagExtra, TagImage, TagImages, TagImagesExtra,
+    TinyTagException, UnsupportedFormatError
 )
 __all__ = (
-    "ParseError", "TinyTag", "TagImage", "TagImages", "TinyTagException", "UnsupportedFormatError"
+    "ParseError", "TinyTag", "TagExtra", "TagImage", "TagImages", "TagImagesExtra",
+    "TinyTagException", "UnsupportedFormatError"
 )
diff --git a/tinytag/__main__.py b/tinytag/__main__.py
index 6b280f0..e6d0867 100755
--- a/tinytag/__main__.py
+++ b/tinytag/__main__.py
@@ -1,7 +1,9 @@
 # pylint: disable=missing-module-docstring,protected-access
 
 from __future__ import annotations
+from io import StringIO
 from os.path import splitext
+import csv
 import json
 import os
 import sys
@@ -44,23 +46,29 @@ def _pop_switch(name: str) -> bool:
 
 
 def _print_tag(tag: TinyTag, formatting: str, header_printed: bool = False) -> bool:
-    data = tag._as_dict()
+    data = tag.as_dict(flatten=True)
     del data['images']
     if formatting == 'json':
-        print(json.dumps(data))
+        print(json.dumps(data, ensure_ascii=False, indent=2))
+        return header_printed
+    if formatting not in {'csv', 'tsv', 'tabularcsv'}:
         return header_printed
     for field, value in data.items():
         if isinstance(value, str):
             data[field] = value.replace('\x00', ';')  # use a more friendly separator for output
-    if formatting == 'csv':
-        print('\n'.join(f'{field},{value!r}' for field, value in data.items()))
-    elif formatting == 'tsv':
-        print('\n'.join(f'{field}\t{value!r}' for field, value in data.items()))
-    elif formatting == 'tabularcsv':
+    csv_file = StringIO()
+    delimiter = '\t' if formatting == 'tsv' else ','
+    writer = csv.writer(csv_file, delimiter=delimiter, lineterminator='\n')
+    if formatting == 'tabularcsv':
         if not header_printed:
-            print(','.join(field for field, value in data.items()))
+            writer.writerow(data.keys())
             header_printed = True
-        print(','.join(f'"{value!r}"' for field, value in data.items()))
+        writer.writerow(data.values())
+        value = csv_file.getvalue().strip()
+    else:
+        writer.writerows(data.items())
+        value = csv_file.getvalue()
+    print(value)
     return header_printed
 
 
diff --git a/tinytag/py.typed b/tinytag/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py
index 48a2939..ce48fa6 100644
--- a/tinytag/tinytag.py
+++ b/tinytag/tinytag.py
@@ -3,7 +3,7 @@
 # Copyright (c) 2021-2024 Mat (mathiascode)
 #
 # Sources on GitHub:
-# http://github.com/devsnd/tinytag/
+# http://github.com/tinytag/tinytag
 
 # MIT License
 
@@ -40,7 +40,7 @@ from collections.abc import Callable, Iterator
 from functools import reduce
 from os import PathLike
 from sys import stderr
-from typing import Any, BinaryIO
+from typing import Any, BinaryIO, Dict, List
 from warnings import warn
 
 import base64
@@ -99,7 +99,7 @@ class TinyTag:
         self.genre: str | None = None
         self.year: str | None = None
         self.comment: str | None = None
-        self.extra: dict[str, str | float | int] = {}
+        self.extra = TagExtra()
         self.images = TagImages()
         self._filehandler: BinaryIO | None = None
         self._default_encoding: str | None = None  # allow override for some file formats
@@ -107,6 +107,10 @@ class TinyTag:
         self._parse_tags = True
         self._load_image = False
         self._tags_parsed = False
+        self.__dict__: dict[str, str | int | float | TagExtra | TagImages]
+
+    def __repr__(self) -> str:
+        return str(self.as_dict(flatten=False))
 
     @classmethod
     def get(cls,
@@ -151,11 +155,38 @@ class TinyTag:
         """Check if a specific file is supported based on its file extension."""
         return cls._get_parser_for_filename(filename) is not None
 
-    def __repr__(self) -> str:
-        return str(self._as_dict())
-
-    def _as_dict(self) -> dict[str, Any]:
-        return {k: v for k, v in self.__dict__.items() if not k.startswith('_')}
+    def as_dict(self, flatten: bool = True) -> dict[
+        str,
+        str | int | float | TagExtra | list[str | TagImage]
+        | dict[str, list[TagImage] | TagImagesExtra]
+    ]:
+        """Return a dictionary representation of the tag."""
+        fields: dict[
+            str,
+            str | int | float | TagExtra | list[str | TagImage]
+            | dict[str, list[TagImage] | TagImagesExtra]
+        ] = {}
+        for key, value in self.__dict__.items():
+            if key.startswith('_'):
+                continue
+            if isinstance(value, TagImages):
+                fields[key] = value.as_dict(flatten)
+            elif not isinstance(value, TagExtra):
+                if value is None:
+                    continue
+                if flatten and key != 'filename' and isinstance(value, str):
+                    fields[key] = [value]
+                else:
+                    fields[key] = value
+            elif flatten:
+                for extra_key, extra_values in value.items():
+                    extra_fields = fields.get(extra_key)
+                    if not isinstance(extra_fields, list):
+                        extra_fields = fields[extra_key] = []
+                    extra_fields += extra_values
+            else:
+                fields[key] = value
+        return fields
 
     @classmethod
     def _get_parser_for_filename(
@@ -233,54 +264,38 @@ class TinyTag:
                 self._filehandler.seek(0)
             self._determine_duration(self._filehandler)
 
-    def _parse_string_field(self, fieldname: str, old_value: Any | None, value: str) -> str | None:
-        if fieldname in {'artist', 'genre'}:
-            # First artist/genre goes in tag.artist/genre, others in tag.extra.other_artists/genres
-            values = value.split('\x00')
-            value = values[0]
-            start_pos = 0 if old_value else 1
-            if len(values) > 1:
-                self._set_field(self._EXTRA_PREFIX + f'other_{fieldname}s', values[start_pos:])
-            elif old_value and value != old_value:
-                self._set_field(self._EXTRA_PREFIX + f'other_{fieldname}s', [value])
-                return None
-        if old_value or not value:
-            return None
-        return value
-
-    def _set_field(self, fieldname: str, value: str | int | float | list[str] | None) -> None:
-        write_dest = self.__dict__
-        original_fieldname = fieldname
+    def _set_field(self, fieldname: str, value: str | int | float,
+                   check_conflict: bool = True) -> None:
         if fieldname.startswith(self._EXTRA_PREFIX):
-            write_dest = self.extra
             fieldname = fieldname[len(self._EXTRA_PREFIX):]
-        old_value = write_dest.get(fieldname)
-        if isinstance(value, str):
-            value = self._parse_string_field(original_fieldname, old_value, value)
-            if not value:
+            if check_conflict and fieldname in self.__dict__:
+                fieldname = '_' + fieldname
+            extra_values = self.extra.get(fieldname, [])
+            if not isinstance(value, str) or value in extra_values:
                 return
-        elif isinstance(value, list):
-            if not isinstance(old_value, list):
-                old_value = []
-            value = old_value + [i for i in value if i and i not in old_value]
-        elif not value and old_value:
+            extra_values.append(value)
+            if DEBUG:
+                print(f'Setting extra field "{fieldname}" to "{extra_values!r}"')
+            self.extra[fieldname] = extra_values
+            return
+        old_value = self.__dict__.get(fieldname)
+        new_value = value
+        if isinstance(new_value, str):
+            # First value goes in tag, others in tag.extra
+            values = new_value.split('\x00')
+            for index, i_value in enumerate(values):
+                if index or old_value and i_value != old_value:
+                    self._set_field(self._EXTRA_PREFIX + fieldname, i_value, check_conflict=False)
+                    continue
+                new_value = i_value
+            if old_value:
+                return
+        elif not new_value and old_value:
+            # Prioritize non-zero integer values
             return
         if DEBUG:
-            print(f'Setting field "{original_fieldname}" to "{value!r}"')
-        write_dest[fieldname] = value
-
-    def _set_image_field(self, fieldname: str, value: TagImage) -> None:
-        write_dest = self.images.__dict__
-        if fieldname.startswith(self._EXTRA_PREFIX):
-            fieldname = fieldname[len(self._EXTRA_PREFIX):]
-            write_dest = self.images.extra
-        old_values = write_dest.get(fieldname)
-        values = [value]
-        if old_values is not None:
-            values = old_values + values
-        if DEBUG:
-            print(f'Setting image field "{fieldname}"')
-        write_dest[fieldname] = values
+            print(f'Setting field "{fieldname}" to "{new_value!r}"')
+        self.__dict__[fieldname] = new_value
 
     def _determine_duration(self, fh: BinaryIO) -> None:
         raise NotImplementedError
@@ -290,20 +305,18 @@ class TinyTag:
 
     def _update(self, other: TinyTag) -> None:
         # update the values of this tag with the values from another tag
-        excluded_attrs = {'filesize', 'extra', 'images'}
-        for standard_key, standard_value in other.__dict__.items():
-            if (not standard_key.startswith('_')
-                    and standard_key not in excluded_attrs
-                    and standard_value is not None):
-                self._set_field(standard_key, standard_value)
-        for extra_key, extra_value in other.extra.items():
-            self._set_field(self._EXTRA_PREFIX + extra_key, extra_value)
-        for image_key, images in other.images._as_dict().items():
-            for image in images:
-                self._set_image_field(image_key, image)
-        for image_extra_key, images_extra in other.images.extra.items():
-            for image_extra in images_extra:
-                self._set_image_field(self._EXTRA_PREFIX + image_extra_key, image_extra)
+        for key, value in other.__dict__.items():
+            if key.startswith('_'):
+                continue
+            if isinstance(value, TagExtra):
+                for extra_key, extra_values in other.extra.items():
+                    for extra_value in extra_values:
+                        self._set_field(
+                            self._EXTRA_PREFIX + extra_key, extra_value, check_conflict=False)
+            elif isinstance(value, TagImages):
+                self.images._update(value)
+            elif value is not None:
+                self._set_field(key, value)
 
     @staticmethod
     def _bytes_to_int_le(b: bytes) -> int:
@@ -334,37 +347,87 @@ class TinyTag:
              'removed in a future 2.x release', DeprecationWarning, stacklevel=2)
 
 
+class TagExtra(Dict[str, List[str]]):
+    """A dictionary containing additional fields of an audio file."""
+
+
 class TagImages:
     """A class containing images embedded in an audio file."""
+    _EXTRA_PREFIX = 'extra.'
+
     def __init__(self) -> None:
         self.front_cover: list[TagImage] = []
         self.back_cover: list[TagImage] = []
         self.leaflet: list[TagImage] = []
         self.media: list[TagImage] = []
         self.other: list[TagImage] = []
-        self.extra: dict[str, list[TagImage]] = {}
+        self.extra = TagImagesExtra()
+        self.__dict__: dict[str, list[TagImage] | TagImagesExtra]
+
+    def __repr__(self) -> str:
+        return str(self.as_dict(flatten=False))
 
     @property
     def any(self) -> TagImage | None:
         """Return a cover image.
         If not present, fall back to any other available image.
         """
-        for image_list in self._as_dict().values():
-            for image in image_list:
+        for value in self.__dict__.values():
+            if isinstance(value, TagImagesExtra):
+                for extra_images in value.values():
+                    for image in extra_images:
+                        return image
+                continue
+            for image in value:
                 return image
-        for extra_image_list in self.extra.values():
-            for extra_image in extra_image_list:
-                return extra_image
         return None
 
-    def __repr__(self) -> str:
-        return str(vars(self))
+    def as_dict(self, flatten: bool = True) -> dict[str, list[TagImage] | TagImagesExtra]:
+        """Return a dictionary representation of the tag images."""
+        images: dict[str, list[TagImage] | TagImagesExtra] = {}
+        for key, value in self.__dict__.items():
+            if not isinstance(value, TagImagesExtra):
+                if value:
+                    images[key] = value
+            elif flatten:
+                for extra_key, extra_values in value.items():
+                    extra_images = images.get(extra_key)
+                    if not isinstance(extra_images, list):
+                        extra_images = images[extra_key] = []
+                    extra_images += extra_values
+            else:
+                images[key] = value
+        return images
+
+    def _set_field(self, fieldname: str, value: TagImage) -> None:
+        if fieldname.startswith(self._EXTRA_PREFIX):
+            fieldname = fieldname[len(self._EXTRA_PREFIX):]
+            extra_values = self.extra.get(fieldname, [])
+            extra_values.append(value)
+            if DEBUG:
+                print(f'Setting extra image field "{fieldname}"')
+            self.extra[fieldname] = extra_values
+            return
+        values = self.__dict__.get(fieldname, [])
+        if isinstance(values, list):
+            values.append(value)
+            if DEBUG:
+                print(f'Setting image field "{fieldname}"')
+            self.__dict__[fieldname] = values
+
+    def _update(self, other: TagImages) -> None:
+        for key, value in other.__dict__.items():
+            if isinstance(value, TagImagesExtra):
+                for extra_key, extra_values in value.items():
+                    for image_extra in extra_values:
+                        self._set_field(self._EXTRA_PREFIX + extra_key, image_extra)
+                continue
+            for image in value:
+                self._set_field(key, image)
 
-    def _as_dict(self) -> dict[str, list[TagImage]]:
-        return {
-            k: v for k, v in self.__dict__.items()
-            if not k.startswith('_') and k != 'extra'
-        }
+
+class TagImagesExtra(Dict[str, List["TagImage"]]):
+    """A dictionary containing additional images embedded in an audio file."""
 
 
 class TagImage:
@@ -408,7 +471,7 @@ class _MP4(TinyTag):
         }
 
         @classmethod
-        def _unpack_integer(cls, value: bytes, signed: bool = True) -> int:
+        def _unpack_integer(cls, value: bytes, signed: bool = True) -> str:
             value_length = len(value)
             result = -1
             if value_length == 1:
@@ -419,10 +482,10 @@ class _MP4(TinyTag):
                 result = struct.unpack('>i' if signed else '>I', value)[0]
             elif value_length == 8:
                 result = struct.unpack('>q' if signed else '>Q', value)[0]
-            return result
+            return str(result)
 
         @classmethod
-        def _unpack_integer_unsigned(cls, value: bytes) -> int:
+        def _unpack_integer_unsigned(cls, value: bytes) -> str:
             return cls._unpack_integer(value, signed=False)
 
         @classmethod
@@ -658,7 +721,7 @@ class _MP4(TinyTag):
                         print(' ' * 4 * len(curr_path), 'FIELD: ', fieldname)
                     if fieldname.startswith('images.'):
                         if self._load_image:
-                            self._set_image_field(fieldname[len('images.'):], value)
+                            self.images._set_field(fieldname[len('images.'):], value)
                     elif fieldname:
                         self._set_field(fieldname, value)
             # if no action was specified using dict or callable, jump over atom
@@ -849,23 +912,24 @@ class _ID3(TinyTag):
         max_estimation_frames = (_ID3._MAX_ESTIMATION_SEC * 44100) // _ID3._SAMPLES_PER_FRAME
         frame_size_accu = 0
         audio_offset = 0
-        header_bytes = 4
         frames = 0  # count frames for determining mp3 duration
         bitrate_accu = 0    # add up bitrates to find average bitrate to detect
-        last_bitrates = []  # CBR mp3s (multiple frames with same bitrates)
+        last_bitrates = set()  # CBR mp3s (multiple frames with same bitrates)
         # seek to first position after id3 tag (speedup for large header)
+        first_mpeg_id = None
         fh.seek(self._bytepos_after_id3v2)
         file_offset = fh.tell()
         walker = io.BytesIO(fh.read())
         while True:
             # reading through garbage until 11 '1' sync-bits are found
-            b = walker.read()
-            walker.seek(-len(b), os.SEEK_CUR)
-            if len(b) < 4:
+            header = walker.read(4)
+            header_len = len(header)
+            walker.seek(-header_len, os.SEEK_CUR)
+            if header_len < 4:
                 if frames:
                     self.bitrate = bitrate_accu / frames
                 break  # EOF
-            _sync, conf, bitrate_freq, rest = struct.unpack('BBBB', b[0:4])
+            _sync, conf, bitrate_freq, rest = struct.unpack('BBBB', header)
             br_id = (bitrate_freq >> 4) & 0x0F  # biterate id
             sr_id = (bitrate_freq >> 2) & 0x03  # sample rate id
             padding = 1 if bitrate_freq & 0x02 > 0 else 0
@@ -873,46 +937,48 @@ class _ID3(TinyTag):
             layer_id = (conf >> 1) & 0x03
             channel_mode = (rest >> 6) & 0x03
             # check for eleven 1s, validate bitrate and sample rate
-            if (not b[:2] > b'\xFF\xE0' or br_id > 14 or br_id == 0 or sr_id == 3
-                    or layer_id == 0 or mpeg_id == 1):  # noqa
-                idx = b.find(b'\xFF', 1)  # invalid frame, find next sync header
+            if (not header[:2] > b'\xFF\xE0'
+                    or (first_mpeg_id is not None and first_mpeg_id != mpeg_id)
+                    or br_id > 14 or br_id == 0 or sr_id == 3 or layer_id == 0 or mpeg_id == 1):
+                idx = header.find(b'\xFF', 1)  # invalid frame, find next sync header
                 if idx == -1:
-                    idx = len(b)  # not found: jump over the current peek buffer
+                    idx = header_len  # not found: jump over the current peek buffer
                 walker.seek(max(idx, 1), os.SEEK_CUR)
                 continue
+            if first_mpeg_id is None:
+                first_mpeg_id = mpeg_id
             self.channels = self._CHANNELS_PER_CHANNEL_MODE[channel_mode]
             frame_bitrate = self._BITRATE_BY_VERSION_BY_LAYER[mpeg_id][layer_id][br_id]
             self.samplerate = samplerate = self._SAMPLE_RATES[mpeg_id][sr_id]
+            frame_length = (144000 * frame_bitrate) // samplerate + padding
             # There might be a xing header in the first frame that contains
             # all the info we need, otherwise parse multiple frames to find the
             # accurate average bitrate
             if frames == 0 and self._USE_XING_HEADER:
-                xing_header_offset = b.find(b'Xing')
+                walker_offset = walker.tell()
+                frame_content = walker.read(frame_length)
+                xing_header_offset = frame_content.find(b'Xing')
                 if xing_header_offset != -1:
-                    walker.seek(xing_header_offset, os.SEEK_CUR)
+                    walker.seek(walker_offset + xing_header_offset)
                     xframes, byte_count = self._parse_xing_header(walker)
                     if xframes > 0 and byte_count > 0:
                         # MPEG-2 Audio Layer III uses 576 samples per frame
                         samples_per_frame = 576 if mpeg_id <= 2 else self._SAMPLES_PER_FRAME
                         self.duration = duration = xframes * samples_per_frame / samplerate
-                        # self.duration = (xframes * self._SAMPLES_PER_FRAME / samplerate
-                        #                  / self.channels)  # noqa
                         self.bitrate = byte_count * 8 / duration / 1000
                         return
-                    continue
+                walker.seek(walker_offset)
 
             frames += 1  # it's most probably an mp3 frame
             bitrate_accu += frame_bitrate
             if frames == 1:
                 audio_offset = file_offset + walker.tell()
             if frames <= self._CBR_DETECTION_FRAME_COUNT:
-                last_bitrates.append(frame_bitrate)
-            walker.seek(4, os.SEEK_CUR)  # jump over peeked bytes
+                last_bitrates.add(frame_bitrate)
 
-            frame_length = (144000 * frame_bitrate) // samplerate + padding
             frame_size_accu += frame_length
             # if bitrate does not change over time its probably CBR
-            is_cbr = (frames == self._CBR_DETECTION_FRAME_COUNT and len(set(last_bitrates)) == 1)
+            is_cbr = (frames == self._CBR_DETECTION_FRAME_COUNT and len(last_bitrates) == 1)
             if frames == max_estimation_frames or is_cbr:
                 # try to estimate duration
                 fh.seek(-128, 2)  # jump to last byte (leaving out id3v1 tag)
@@ -924,7 +990,7 @@ class _ID3(TinyTag):
                 return
 
             if frame_length > 1:  # jump over current frame body
-                walker.seek(frame_length - header_bytes, os.SEEK_CUR)
+                walker.seek(frame_length, os.SEEK_CUR)
         if self.samplerate:
             self.duration = frames * self._SAMPLES_PER_FRAME / self.samplerate
 
@@ -979,20 +1045,30 @@ class _ID3(TinyTag):
         # tags are more likely to be outdated or have encoding issues
         fields = fh.read(30 + 30 + 30 + 4 + 30 + 1)
         if not self.title:
-            self._set_field('title', asciidecode(fields[:30]))
+            value = asciidecode(fields[:30])
+            if value:
+                self._set_field('title', value)
         if not self.artist:
-            self._set_field('artist', asciidecode(fields[30:60]))
+            value = asciidecode(fields[30:60])
+            if value:
+                self._set_field('artist', value)
         if not self.album:
-            self._set_field('album', asciidecode(fields[60:90]))
+            value = asciidecode(fields[60:90])
+            if value:
+                self._set_field('album', value)
         if not self.year:
-            self._set_field('year', asciidecode(fields[90:94]))
+            value = asciidecode(fields[90:94])
+            if value:
+                self._set_field('year', value)
         comment = fields[94:124]
         if b'\x00\x00' < comment[-2:] < b'\x01\x00':
             if self.track is None:
                 self._set_field('track', ord(comment[-1:]))
             comment = comment[:-2]
         if not self.comment:
-            self._set_field('comment', asciidecode(comment))
+            value = asciidecode(comment)
+            if value:
+                self._set_field('comment', value)
         if not self.genre:
             genre_id = ord(fields[124:125])
             if genre_id < len(self._ID3V1_GENRES):
@@ -1001,10 +1077,11 @@ class _ID3(TinyTag):
     def __parse_custom_field(self, content: str) -> bool:
         custom_field_name, separator, value = content.partition('\x00')
         custom_field_name_lower = custom_field_name.lower()
-        if custom_field_name_lower and separator:
+        value = value.lstrip('\ufeff')
+        if custom_field_name_lower and separator and value:
             field_name = self._ID3_MAPPING_CUSTOM.get(
                 custom_field_name_lower, self._EXTRA_PREFIX + custom_field_name_lower)
-            self._set_field(field_name, value.lstrip('\ufeff'))
+            self._set_field(field_name, value)
             return True
         return False
 
@@ -1053,6 +1130,8 @@ class _ID3(TinyTag):
                     return frame_size
                 language = fieldname in {'comment', 'extra.lyrics'}
                 value = self._decode_string(content, language)
+                if not value:
+                    return frame_size
                 if fieldname == "comment":
                     # check if comment is a key-value pair (used by iTunes)
                     should_set_field = not self.__parse_custom_field(value)
@@ -1082,7 +1161,9 @@ class _ID3(TinyTag):
             elif frame_id in self._CUSTOM_FRAME_IDS:
                 # custom fields
                 if self._parse_tags:
-                    self.__parse_custom_field(self._decode_string(content))
+                    value = self._decode_string(content)
+                    if value:
+                        self.__parse_custom_field(value)
             elif frame_id in self._IMAGE_FRAME_IDS:
                 if self._load_image:
                     # See section 4.14: http://id3.org/id3v2.4.0-frames
@@ -1105,12 +1186,13 @@ class _ID3(TinyTag):
                     description = self._decode_string(content[desc_start_pos:desc_end_pos])
                     field_name, image = self._create_tag_image(
                         content[desc_end_pos:], pic_type, mime_type, description)
-                    self._set_image_field(field_name, image)
+                    self.images._set_field(field_name, image)
             elif frame_id not in self._DISALLOWED_FRAME_IDS:
                 # unknown, try to add to extra dict
                 if self._parse_tags:
-                    self._set_field(
-                        self._EXTRA_PREFIX + frame_id.lower(), self._decode_string(content))
+                    value = self._decode_string(content)
+                    if value:
+                        self._set_field(self._EXTRA_PREFIX + frame_id.lower(), value)
             return frame_size
         return 0
 
@@ -1315,7 +1397,7 @@ class _Ogg(TinyTag):
                     if DEBUG:
                         print('Found Vorbis TagImage', key, value[:64])
                     fieldname, fieldvalue = _Flac._parse_image(io.BytesIO(base64.b64decode(value)))
-                    self._set_image_field(fieldname, fieldvalue)
+                    self.images._set_field(fieldname, fieldvalue)
                 else:
                     if DEBUG:
                         print('Found Vorbis Comment', key, value[:64])
@@ -1328,7 +1410,7 @@ class _Ogg(TinyTag):
                                 self._set_field(f'{fieldname}_total', int(total))
                         if value.isdecimal():
                             self._set_field(fieldname, int(value))
-                    else:
+                    elif value:
                         self._set_field(fieldname, value)
 
     def _parse_pages(self, fh: BinaryIO) -> Iterator[bytes]:
@@ -1524,7 +1606,7 @@ class _Flac(TinyTag):
                 self._update(oggtag)
             elif block_type == self.METADATA_PICTURE and self._load_image:
                 fieldname, value = self._parse_image(fh)
-                self._set_image_field(fieldname, value)
+                self.images._set_field(fieldname, value)
             elif block_type >= 127:
                 break  # invalid block type
             else:
@@ -1598,12 +1680,12 @@ class _Wma(TinyTag):
     def _decode_string(self, bytestring: bytes) -> str:
         return self._unpad(bytestring.decode('utf-16', 'replace'))
 
-    def _decode_ext_desc(self, value_type: int, value: bytes) -> int | str | None:
+    def _decode_ext_desc(self, value_type: int, value: bytes) -> str | None:
         """ decode _ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT values"""
         if value_type == 0:  # Unicode string
             return self._decode_string(value)
         if 1 < value_type < 6:  # DWORD / QWORD / WORD
-            return self._bytes_to_int_le(value)
+            return str(self._bytes_to_int_le(value))
         return None
 
     def _parse_tag(self, fh: BinaryIO) -> None:
@@ -1633,8 +1715,9 @@ class _Wma(TinyTag):
                 }
                 for i_field_name, length in data_blocks.items():
                     bytestring = fh.read(length)
-                    if not i_field_name.startswith('_'):
-                        self._set_field(i_field_name, self._decode_string(bytestring))
+                    value = self._decode_string(bytestring)
+                    if not i_field_name.startswith('_') and value:
+                        self._set_field(i_field_name, value)
             elif object_id == self._ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT and self._parse_tags:
                 # http://web.archive.org/web/20131203084402/http://msdn.microsoft.com/en-us/library/bb643323.aspx#_Toc509555195
                 descriptor_count = self._bytes_to_int_le(fh.read(2))
@@ -1656,7 +1739,7 @@ class _Wma(TinyTag):
                         if field_name in {'track', 'disc'}:
                             if isinstance(field_value, int) or field_value.isdecimal():
                                 self._set_field(field_name, int(field_value))
-                        else:
+                        elif field_value:
                             self._set_field(field_name, field_value)
             elif object_id == self._ASF_FILE_PROPERTY_OBJECT and self._parse_duration:
                 fh.seek(40, os.SEEK_CUR)


Test patch:
diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py
index e16cf3f..9981ccb 100644
--- a/tinytag/tests/test_all.py
+++ b/tinytag/tests/test_all.py
@@ -40,19 +40,19 @@ testfiles = dict([
         {'extra': {}, 'bitrate': 186.04383278145696, 'channels': 1, 'samplerate': 44100,
          'duration': 3.944489795918367, 'filesize': 91731}),
     ('samples/vbr_xing_header_2channel.mp3',
-        {'extra': {'encoder_settings': 'LAME 32bits version 3.99.5 (http://lame.sf.net)',
-                   'tlen': '249976'},
+        {'extra': {'encoder_settings': ['LAME 32bits version 3.99.5 (http://lame.sf.net)'],
+                   'tlen': ['249976']},
          'filesize': 2000, 'album': "The Harpers' Masque",
          'artist': 'Knodel and Valencia', 'bitrate': 46.276128290848305,
          'channels': 2, 'duration': 250.04408163265308, 'samplerate': 22050,
          'title': 'Lochaber No More', 'year': '1992'}),
     ('samples/id3v22-test.mp3',
-        {'extra': {'encoded_by': 'iTunes v4.6',
-                   'itunnorm': (' 0000044E 00000061 00009B67 000044C3 00022478 00022182 '
-                                '00007FCC 00007E5C 0002245E 0002214E'),
-                   'itunes_cddb_1': ('9D09130B+174405+11+150+14097+27391+43983+65786+84877+'
-                                     '99399+113226+132452+146426+163829'),
-                   'itunes_cddb_tracknumber': '3'},
+        {'extra': {'encoded_by': ['iTunes v4.6'],
+                   'itunnorm': [' 0000044E 00000061 00009B67 000044C3 00022478 00022182 '
+                                '00007FCC 00007E5C 0002245E 0002214E'],
+                   'itunes_cddb_1': ['9D09130B+174405+11+150+14097+27391+43983+65786+84877+'
+                                     '99399+113226+132452+146426+163829'],
+                   'itunes_cddb_tracknumber': ['3']},
          'channels': 2, 'samplerate': 44100, 'track_total': 11, 'duration': 0.13836297152858082,
          'album': 'Hymns for the Exiled', 'year': '2004', 'title': 'cosmic american',
          'artist': 'Anais Mitchell', 'track': 3, 'filesize': 5120,
@@ -67,18 +67,18 @@ testfiles = dict([
          'album': 'The Young Americans', 'title': 'Play Dead', 'filesize': 256, 'track': 12,
          'artist': 'Björk', 'year': '1993', 'comment': '                            '}),
     ('samples/UTF16.mp3',
-        {'extra': {'musicbrainz artist id': '664c3e0e-42d8-48c1-b209-1efca19c0325',
-                   'musicbrainz album id': '25322466-a29b-417b-b560-399687b91ddd',
-                   'musicbrainz album artist id': '664c3e0e-42d8-48c1-b209-1efca19c0325',
-                   'musicbrainz disc id': 'p.5xoyYRtCVFe2gt0mfTfsXrO9U-',
-                   'musicip puid': '6ff97581-1c73-fc05-b4e4-a4ccee12ec84', 'asin': 'B003KVNV4S',
-                   'musicbrainz album status': 'Official', 'musicbrainz album type': 'Album',
-                   'musicbrainz album release country': 'United States',
-                   'ufid': 'http://musicbrainz.org\x00cf639964-eabb-4c40-9673-c2117e456ea5',
-                   'publisher': '4AD', 'tdat': '1105',
-                   'wxxx': 'WIKIPEDIA_RELEASE\x00http://en.wikipedia.org/wiki/High_Violet',
-                   'media': 'Digital', 'tlen': '203733',
-                   'encoder_settings': 'LAME 32bits version 3.98.4 (http://www.mp3dev.org/)'},
+        {'extra': {'musicbrainz artist id': ['664c3e0e-42d8-48c1-b209-1efca19c0325'],
+                   'musicbrainz album id': ['25322466-a29b-417b-b560-399687b91ddd'],
+                   'musicbrainz album artist id': ['664c3e0e-42d8-48c1-b209-1efca19c0325'],
+                   'musicbrainz disc id': ['p.5xoyYRtCVFe2gt0mfTfsXrO9U-'],
+                   'musicip puid': ['6ff97581-1c73-fc05-b4e4-a4ccee12ec84'], 'asin': ['B003KVNV4S'],
+                   'musicbrainz album status': ['Official'], 'musicbrainz album type': ['Album'],
+                   'musicbrainz album release country': ['United States'],
+                   'ufid': ['http://musicbrainz.org\x00cf639964-eabb-4c40-9673-c2117e456ea5'],
+                   'publisher': ['4AD'], 'tdat': ['1105'],
+                   'wxxx': ['WIKIPEDIA_RELEASE\x00http://en.wikipedia.org/wiki/High_Violet'],
+                   'media': ['Digital'], 'tlen': ['203733'],
+                   'encoder_settings': ['LAME 32bits version 3.98.4 (http://www.mp3dev.org/)']},
          'track_total': 11, 'track': 7, 'artist': 'The National',
          'year': '2010', 'album': 'High Violet', 'title': 'Lemonworld', 'filesize': 20480,
          'genre': 'Indie', 'comment': 'Track 7'}),
@@ -96,7 +96,7 @@ testfiles = dict([
          'bitrate': 32.0, 'duration': 1.0438932496075353}),
     ('samples/id3v24-long-title.mp3',
         {'extra':
-         {'copyright': '2013 Marathon Artists under exclsuive license from Courtney Barnett'},
+         {'copyright': ['2013 Marathon Artists under exclsuive license from Courtney Barnett']},
          'track': 1, 'disc_total': 1, 'composer': 'Courtney Barnett',
          'album': 'The Double EP: A Sea of Split Peas', 'filesize': 10000,
          'track_total': 12, 'genre': 'AlternRock',
@@ -107,27 +107,28 @@ testfiles = dict([
         {'extra': {}, 'title': '52-girls', 'filesize': 2048, 'track': 6, 'album': 'party mix',
          'artist': 'The B52s', 'genre': 'Rock', 'year': '1981'}),
     ('samples/id3v22_image.mp3',
-        {'extra': {'rva': '\x10', 'bpm': '131'}, 'title': 'Kids (MGMT Cover) ', 'filesize': 35924,
+        {'extra': {'rva': ['\x10'], 'bpm': ['131']}, 'title': 'Kids (MGMT Cover) ',
+         'filesize': 35924,
          'album': 'winniecooper.net ', 'artist': 'The Kooks', 'year': '2008',
          'genre': '.'}),
     ('samples/id3v22.TCO.genre.mp3',
-        {'extra': {'encoded_by': 'iTunes 11.0.4',
-                   'itunnorm': (' 000019F0 00001E2A 00009F9A 0000C689 000312A1 00030C1A 0000902E '
-                                '00008D36 00020882 000321D6'),
-                   'itunsmpb': (' 00000000 00000210 000007B9 00000000008FB737 00000000 008242F1 '
-                                '00000000 00000000 00000000 00000000 00000000 00000000'),
-                   'itunpgap': '0'},
+        {'extra': {'encoded_by': ['iTunes 11.0.4'],
+                   'itunnorm': [' 000019F0 00001E2A 00009F9A 0000C689 000312A1 00030C1A 0000902E '
+                                '00008D36 00020882 000321D6'],
+                   'itunsmpb': [' 00000000 00000210 000007B9 00000000008FB737 00000000 008242F1 '
+                                '00000000 00000000 00000000 00000000 00000000 00000000'],
+                   'itunpgap': ['0']},
          'filesize': 500, 'album': 'ARTPOP', 'artist': 'Lady GaGa',
          'genre': 'Pop', 'title': 'Applause'}),
     ('samples/id3_comment_utf_16_with_bom.mp3',
-        {'extra': {'copyright': '(c) 2008 nin', 'isrc': 'USTC40852229', 'bpm': '60',
-                   'url': 'www.nin.com', 'encoded_by': 'LAME 3.97'},
+        {'extra': {'copyright': ['(c) 2008 nin'], 'isrc': ['USTC40852229'], 'bpm': ['60'],
+                   'url': ['www.nin.com'], 'encoded_by': ['LAME 3.97']},
          'filesize': 19980,
          'album': 'Ghosts I-IV', 'albumartist': 'Nine Inch Nails', 'artist': 'Nine Inch Nails',
          'disc': 1, 'disc_total': 2, 'title': '1 Ghosts I', 'track': 1, 'track_total': 36,
          'year': '2008', 'comment': '3/4 time'}),
     ('samples/id3_comment_utf_16_double_bom.mp3',
-        {'extra': {'label': 'Unclear'}, 'filesize': 512, 'album': 'The Embrace',
+        {'extra': {'label': ['Unclear']}, 'filesize': 512, 'album': 'The Embrace',
          'artist': 'Johannes Heil & D.Diggler', 'comment': 'Unclear',
          'title': 'The Embrace (Romano Alfieri Remix)',
          'year': '2012'}),
@@ -142,11 +143,11 @@ testfiles = dict([
     ('samples/id3v1_does_not_overwrite_id3v2.mp3',
         {'filesize': 1130, 'album': 'Somewhere Far Beyond', 'albumartist': 'Blind Guardian',
          'artist': 'Blind Guardian',
-         'extra': {'love rating': 'L', 'publisher': 'Century Media', 'popm': 'MusicBee\x00Ä'},
+         'extra': {'love rating': ['L'], 'publisher': ['Century Media'], 'popm': ['MusicBee\x00Ä']},
          'genre': 'Power Metal', 'title': 'Time What Is Time', 'track': 1,
          'year': '1992'}),
     ('samples/non_ascii_filename_äää.mp3',
-        {'extra': {'encoder_settings': 'Lavf58.20.100'}, 'filesize': 80919, 'channels': 2,
+        {'extra': {'encoder_settings': ['Lavf58.20.100']}, 'filesize': 80919, 'channels': 2,
          'duration': 5.067755102040817, 'samplerate': 44100, 'bitrate': 127.6701030927835}),
     ('samples/chinese_id3.mp3',
         {'extra': {}, 'filesize': 1000, 'album': '½ÇÂäÖ®¸è', 'albumartist': 'ËÕÔÆ',
@@ -154,31 +155,31 @@ testfiles = dict([
          'duration': 0.052244897959183675, 'genre': 'ÐÝÏÐÒôÀÖ', 'samplerate': 44100,
          'title': '½ÇÂäÖ®¸è', 'track': 1}),
     ('samples/cut_off_titles.mp3',
-        {'extra': {'encoder_settings': 'Lavf54.29.104'}, 'filesize': 1000, 'album': 'ERB',
+        {'extra': {'encoder_settings': ['Lavf54.29.104']}, 'filesize': 1000, 'album': 'ERB',
          'artist': 'Epic Rap Battles Of History',
          'bitrate': 192.0, 'channels': 2, 'duration': 0.052244897959183675,
          'samplerate': 44100, 'title': 'Tony Hawk VS Wayne Gretzky'}),
     ('samples/id3_xxx_lang.mp3',
-        {'extra': {'script': 'Latn',
-                   'acoustid id': '2dc0b571-a633-45b0-aa5e-f3d25e4e0020',
-                   'musicbrainz album type': 'album',
-                   'musicbrainz album artist id': '078a9376-3c04-4280-b7d7-b20e158f345d',
-                   'musicbrainz artist id': '078a9376-3c04-4280-b7d7-b20e158f345d',
-                   'barcode': '724386668721',
-                   'musicbrainz album id': '38b555fe-24c7-37b3-ad1b-f6dea9f1aafa',
-                   'musicbrainz release track id': '7f7c31a5-0905-39ba-ba72-68db91d3b9da',
-                   'catalog_number': '7243 8 66687 2 1',
-                   'musicbrainz release group id': '0f21095a-e629-389c-981a-d9569e9673c9',
-                   'musicbrainz album status': 'official',
-                   'asin': 'B000641ZIQ', 'musicbrainz album release country': 'US',
-                   'isrc': 'USVI20400513', 'lyrics': 'Don\'t fret, precious',
-                   'replaygain_track_gain': '-3.95 dB', 'replaygain_track_peak': '0.999969',
-                   'replaygain_album_gain': '-8.26 dB', 'publisher': 'Virgin Records America',
-                   'media': 'CD', 'tso2': 'Perfect Circle, A',
-                   'ufid': 'http://musicbrainz.org\x00d2b8f0e6-735a-42ee-adf0-7eca4e65cd72',
-                   'tsop': 'Perfect Circle, A', 'original_year': '2004', 'tdat': '0211',
-                   'ipls': ('producer\x00Billy Howerdel\x00producer\x00Maynard James Keenan'
-                            '\x00engineer\x00Billy Howerdel\x00engineer\x00Critter')},
+        {'extra': {'script': ['Latn'],
+                   'acoustid id': ['2dc0b571-a633-45b0-aa5e-f3d25e4e0020'],
+                   'musicbrainz album type': ['album'],
+                   'musicbrainz album artist id': ['078a9376-3c04-4280-b7d7-b20e158f345d'],
+                   'musicbrainz artist id': ['078a9376-3c04-4280-b7d7-b20e158f345d'],
+                   'barcode': ['724386668721'],
+                   'musicbrainz album id': ['38b555fe-24c7-37b3-ad1b-f6dea9f1aafa'],
+                   'musicbrainz release track id': ['7f7c31a5-0905-39ba-ba72-68db91d3b9da'],
+                   'catalog_number': ['7243 8 66687 2 1'],
+                   'musicbrainz release group id': ['0f21095a-e629-389c-981a-d9569e9673c9'],
+                   'musicbrainz album status': ['official'],
+                   'asin': ['B000641ZIQ'], 'musicbrainz album release country': ['US'],
+                   'isrc': ['USVI20400513'], 'lyrics': ['Don\'t fret, precious'],
+                   'replaygain_track_gain': ['-3.95 dB'], 'replaygain_track_peak': ['0.999969'],
+                   'replaygain_album_gain': ['-8.26 dB'], 'publisher': ['Virgin Records America'],
+                   'media': ['CD'], 'tso2': ['Perfect Circle, A'],
+                   'ufid': ['http://musicbrainz.org\x00d2b8f0e6-735a-42ee-adf0-7eca4e65cd72'],
+                   'tsop': ['Perfect Circle, A'], 'original_year': ['2004'], 'tdat': ['0211'],
+                   'ipls': ['producer\x00Billy Howerdel\x00producer\x00Maynard James Keenan'
+                            '\x00engineer\x00Billy Howerdel\x00engineer\x00Critter']},
          'filesize': 6943, 'album': 'eMOTIVe', 'albumartist': 'A Perfect Circle',
          'artist': 'A Perfect Circle', 'composer': 'Billy Howerdel/Maynard James Keenan',
          'bitrate': 192.0, 'channels': 2, 'duration': 0.13198711063372717, 'genre': 'Rock',
@@ -237,18 +238,21 @@ testfiles = dict([
     ('samples/id3_multiple_artists.mp3',
         {'filesize': 2007, 'bitrate': 57.39124999999999, 'channels': 1,
          'duration': 0.1306122448979592,
-         'extra': {'other_artists': ['artist2', 'artist3', 'artist4', 'artist5',
-                                     'artist6', 'artist7']},
+         'extra': {'artist': ['artist2', 'artist3', 'artist4', 'artist5',
+                              'artist6', 'artist7']},
          'samplerate': 44100, 'artist': 'artist1', 'genre': 'something 1'}),
+    ('samples/id3_frames.mp3',
+        {'filesize': 27576, 'bitrate': 50.03636363636364, 'channels': 1,
+         'duration': 3.96, 'samplerate': 16000, 'extra': {}}),
 
     # OGG
     ('samples/empty.ogg',
         {'extra': {}, 'duration': 3.684716553287982,
          'filesize': 4328, 'bitrate': 112.0, 'samplerate': 44100, 'channels': 2}),
     ('samples/multipage-setup.ogg',
-        {'extra': {'transcoded': 'mp3;241', 'replaygain_album_gain': '-10.29 dB',
-                   'replaygain_album_peak': '1.50579047', 'replaygain_track_peak': '1.17979193',
-                   'replaygain_track_gain': '-10.02 dB'},
+        {'extra': {'transcoded': ['mp3;241'], 'replaygain_album_gain': ['-10.29 dB'],
+                   'replaygain_album_peak': ['1.50579047'], 'replaygain_track_peak': ['1.17979193'],
+                   'replaygain_track_gain': ['-10.02 dB']},
          'genre': 'JRock', 'duration': 4.128798185941043,
          'album': 'Timeless', 'year': '2006', 'title': 'Burst', 'artist': 'UVERworld', 'track': 7,
          'filesize': 76983, 'bitrate': 160.0,
@@ -268,21 +272,22 @@ testfiles = dict([
          'genre': 'Some Genre', 'samplerate': 44100, 'title': 'A Title', 'track': 2,
          'year': '2007', 'comment': 'A Comment'}),
     ('samples/test.opus',
-        {'extra': {'encoder': 'Lavc57.24.102 libopus', 'arrange': '\u6771\u65b9',
-                   'catalogid': 'ARCD0024', 'discid': 'A212230D', 'event': '\u4f8b\u5927\u796d5',
-                   'lyricist': 'Haruka', 'mastering': 'Hedonist',
-                   'origin': '\u6771\u65b9\u5e7b\u60f3\u90f7', 'originaltitle': 'Bad Apple!!',
-                   'performer': 'Masayoshi Minoshima', 'vocal': 'nomico'},
+        {'extra': {'encoder': ['Lavc57.24.102 libopus'], 'arrange': ['\u6771\u65b9'],
+                   'catalogid': ['ARCD0024'], 'discid': ['A212230D'],
+                   'event': ['\u4f8b\u5927\u796d5'],
+                   'lyricist': ['Haruka'], 'mastering': ['Hedonist'],
+                   'origin': ['\u6771\u65b9\u5e7b\u60f3\u90f7'], 'originaltitle': ['Bad Apple!!'],
+                   'performer': ['Masayoshi Minoshima'], 'vocal': ['nomico']},
          'albumartist': 'Alstroemeria Records', 'samplerate': 48000, 'channels': 2,
          'track': 1, 'disc': 1, 'title': 'Bad Apple!!', 'duration': 2.0, 'year': '2008.05.25',
          'filesize': 10000, 'artist': 'nomico',
          'album': 'Exserens - A selection of Alstroemeria Records',
          'comment': 'ARCD0018 - Lovelight', 'disc_total': 1, 'track_total': 13}),
     ('samples/8khz_5s.opus',
-        {'extra': {'encoder': 'opusenc from opus-tools 0.2'}, 'filesize': 7251, 'channels': 1,
+        {'extra': {'encoder': ['opusenc from opus-tools 0.2']}, 'filesize': 7251, 'channels': 1,
          'samplerate': 48000, 'duration': 5.0065}),
     ('samples/test_flac.oga',
-        {'extra': {'copyright': 'test3', 'isrc': 'test4', 'lyrics': 'test7'},
+        {'extra': {'copyright': ['test3'], 'isrc': ['test4'], 'lyrics': ['test7']},
          'filesize': 9273, 'album': 'test2', 'artist': 'test6', 'comment': 'test5',
          'bitrate': 20.022488249118684, 'duration': 3.705034013605442, 'channels': 2,
          'genre': 'Acoustic', 'samplerate': 44100, 'bitdepth': 16, 'title': 'test1', 'track': 5,
@@ -313,8 +318,8 @@ testfiles = dict([
         {'extra': {}, 'channels': 1, 'duration': 0.9991836734693877, 'filesize': 48160,
          'bitrate': 352.8, 'samplerate': 22050, 'bitdepth': 16}),
     ('samples/id3_header_with_a_zero_byte.wav',
-        {'extra': {}, 'channels': 1, 'duration': 1.0, 'filesize': 44280, 'bitrate': 352.8,
-         'samplerate': 22050, 'bitdepth': 16, 'artist': 'Purpley',
+        {'extra': {'title': ['Stacked']}, 'channels': 1, 'duration': 1.0, 'filesize': 44280,
+         'bitrate': 352.8, 'samplerate': 22050, 'bitdepth': 16, 'artist': 'Purpley',
          'title': 'Test000', 'track': 17,
          'album': 'prototypes'}),
     ('samples/adpcm.wav',
@@ -357,13 +362,13 @@ testfiles = dict([
          'filesize': 59868, 'bitrate': 319.39739599872973, 'genre': 'Avantgarde',
          'samplerate': 44100, 'bitdepth': 16, 'comment': 'hello'}),
     ('samples/flac_application.flac',
-        {'extra': {'replaygain_track_peak': '0.9976',
-                   'musicbrainz_albumartistid': 'e5c7b94f-e264-473c-bb0f-37c85d4d5c70',
-                   'musicbrainz_trackid': 'e65fb332-0c1e-4172-85e0-59cd37e5669e',
-                   'replaygain_album_gain': '-8.14 dB', 'labelid': 'RTRADLP480',
-                   'musicbrainz_albumid': '359a91e9-3bb3-4b60-a823-8aaa4bad1e36',
-                   'artistsort': 'Belle and Sebastian', 'replaygain_track_gain': '-8.08 dB',
-                   'replaygain_album_peak': '1.0000'},
+        {'extra': {'replaygain_track_peak': ['0.9976'],
+                   'musicbrainz_albumartistid': ['e5c7b94f-e264-473c-bb0f-37c85d4d5c70'],
+                   'musicbrainz_trackid': ['e65fb332-0c1e-4172-85e0-59cd37e5669e'],
+                   'replaygain_album_gain': ['-8.14 dB'], 'labelid': ['RTRADLP480'],
+                   'musicbrainz_albumid': ['359a91e9-3bb3-4b60-a823-8aaa4bad1e36'],
+                   'artistsort': ['Belle and Sebastian'], 'replaygain_track_gain': ['-8.08 dB'],
+                   'replaygain_album_peak': ['1.0000']},
          'channels': 2, 'track_total': 11,
          'album': 'Belle and Sebastian Write About Love', 'year': '2010-10-11', 'duration': 273.64,
          'title': 'I Want the World to Stop', 'track': 4, 'artist': 'Belle and Sebastian',
@@ -372,14 +377,14 @@ testfiles = dict([
         {'extra': {}, 'channels': 2, 'duration': 3.684716553287982, 'filesize': 4692,
          'bitrate': 10.186943678613627, 'samplerate': 44100, 'bitdepth': 16}),
     ('samples/variable-block.flac',
-        {'extra': {'discid': 'AA0B360B',
-                   'japanese title': ('\u30a2\u30c3\u30d7\u30eb\u30b7\u30fc\u30c9 '
+        {'extra': {'discid': ['AA0B360B'],
+                   'japanese title': ['\u30a2\u30c3\u30d7\u30eb\u30b7\u30fc\u30c9 '
                                       '\u30aa\u30ea\u30b8\u30ca\u30eb\u30fb\u30b5\u30a6'
-                                      '\u30f3\u30c9\u30c8\u30e9\u30c3\u30af'),
-                   'organization': 'Sony Music Records (SRCP-371)',
-                   'ripper': 'Exact Audio Copy 0.99pb5',
-                   'replaygain_album_gain': '-8.68 dB', 'replaygain_album_peak': '1.000000',
-                   'replaygain_track_gain': '-9.61 dB', 'replaygain_track_peak': '1.000000'},
+                                      '\u30f3\u30c9\u30c8\u30e9\u30c3\u30af'],
+                   'organization': ['Sony Music Records (SRCP-371)'],
+                   'ripper': ['Exact Audio Copy 0.99pb5'],
+                   'replaygain_album_gain': ['-8.68 dB'], 'replaygain_album_peak': ['1.000000'],
+                   'replaygain_track_gain': ['-9.61 dB'], 'replaygain_track_peak': ['1.000000']},
          'channels': 2, 'album': 'Appleseed Original Soundtrack', 'year': '2004',
          'duration': 261.68, 'title': 'DIVE FOR YOU', 'track': 1, 'track_total': 11,
          'artist': 'Boom Boom Satellites', 'filesize': 10240, 'bitrate': 0.31305411189238763,
@@ -392,7 +397,8 @@ testfiles = dict([
         {'extra': {}, 'filesize': 4692, 'bitrate': 10.186943678613627, 'channels': 2,
          'duration': 3.684716553287982, 'samplerate': 44100, 'bitdepth': 16}),
     ('samples/with_id3_header.flac',
-        {'extra': {'id': '8591671910', 'other_artists': ['群星']}, 'filesize': 64837,
+        {'extra': {'id': ['8591671910'], 'artist': ['群星'], 'album': ['   '],
+                   'title': ['A 梦 哆啦 机器猫 短信铃声']}, 'filesize': 64837,
          'album': 'album', 'artist': 'artist',
          'title': 'title', 'track': 1, 'bitrate': 1143.72468, 'channels': 1,
          'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, 'bitdepth': 16,
@@ -403,14 +409,15 @@ testfiles = dict([
          'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, 'bitdepth': 16,
          'title': 'title', 'track': 1, 'year': '2018', 'comment': 'comment'}),
     ('samples/with_padded_id3_header2.flac',
-        {'extra': {'mcdi': ('2\x01\x05\x00\x10\x01\x00\x00\x00\x00\x00\x00\x10\x02\x00\x00\x00W5'
+        {'extra': {'mcdi': ['2\x01\x05\x00\x10\x01\x00\x00\x00\x00\x00\x00\x10\x02\x00\x00\x00W5'
                             '\x00\x10\x03\x00\x00\x00\x90\x0c\x00\x10\x04\x00\x00\x00ä7\x00\x10'
-                            '\x05\x00\x00\x013«\x00\x10ª\x00\x00\x01\x8c\xa0'),
-                   'tlen': '297666', 'encoded_by': 'Exact Audio Copy   (Sicherer Modus)',
-                   'encoder_settings': ('flac.exe -T "artist=Unbekannter Künstler" -T '
+                            '\x05\x00\x00\x013«\x00\x10ª\x00\x00\x01\x8c\xa0'],
+                   'tlen': ['297666'], 'encoded_by': ['Exact Audio Copy   (Sicherer Modus)'],
+                   'encoder_settings': ['flac.exe -T "artist=Unbekannter Künstler" -T '
                                         '"title=Track01" -T "album=Unbekannter Titel" -T '
-                                        '"date=" -T "tracknumber=01" -T "genre=" -5'),
-                   'other_artists': ['Unbekannter Künstler']},
+                                        '"date=" -T "tracknumber=01" -T "genre=" -5'],
+                   'artist': ['Unbekannter Künstler'], 'album': ['Unbekannter Titel'],
+                   'title': ['Track01']},
          'filesize': 19522, 'album': 'album',
          'artist': 'artist', 'bitrate': 344.36807999999996,
          'channels': 1, 'disc': 1, 'disc_total': 1,
@@ -418,8 +425,8 @@ testfiles = dict([
          'title': 'title', 'track': 1, 'track_total': 5, 'year': '2018',
          'comment': 'comment'}),
     ('samples/flac_with_image.flac',
-        {'extra': {}, 'filesize': 80000, 'album': 'smilin´ in circles',
-         'artist': 'Andreas Kümmert',
+        {'extra': {}, 'filesize': 80000,
+         'album': 'smilin´ in circles', 'artist': 'Andreas Kümmert',
          'bitrate': 7.6591670655816175, 'channels': 2, 'disc': 1, 'disc_total': 1,
          'duration': 83.56, 'genre': 'Blues', 'samplerate': 44100, 'bitdepth': 16, 'title': 'intro',
          'track': 1, 'track_total': 8}),
@@ -427,53 +434,56 @@ testfiles = dict([
         {'extra': {}, 'filesize': 235, 'bitrate': 18.8, 'channels': 1,
          'duration': 0.1, 'samplerate': 44100, 'bitdepth': 16}),
     ('samples/flac_multiple_fields.flac',
-        {'extra': {'other_artists': ['artist 2', 'artist 3'], 'other_genres': ['genre 2']},
-         'filesize': 235, 'album': 'album 1', 'artist': 'artist 1',
-         'bitrate': 18.8, 'channels': 1, 'duration': 0.1, 'genre': 'genre 1',
+        {'extra': {'artist': ['artist 2', 'artist 3'], 'genre': ['genre 2'],
+                   'album': ['album 2'], 'url': ['https://example.com']},
+         'filesize': 266, 'album': 'album 1', 'artist': 'artist 1',
+         'bitrate': 21.28, 'channels': 1, 'duration': 0.1, 'genre': 'genre 1',
          'samplerate': 44100, 'bitdepth': 16}),
 
     # WMA
     ('samples/test2.wma',
-        {'extra': {'track': 0,
-                   'mediaprimaryclassid': '{D1607DBC-E323-4BE2-86A1-48A42A28441E}',
-                   'encodingtime': 128861118183900000, 'wmfsdkversion': '11.0.5721.5145',
-                   'wmfsdkneeded': '0.0.0.0000', 'isvbr': 1, 'peakvalue': 30369,
-                   'averagelevel': 7291},
+        {'extra': {'_track': ['0'],
+                   'mediaprimaryclassid': ['{D1607DBC-E323-4BE2-86A1-48A42A28441E}'],
+                   'encodingtime': ['128861118183900000'], 'wmfsdkversion': ['11.0.5721.5145'],
+                   'wmfsdkneeded': ['0.0.0.0000'], 'isvbr': ['1'], 'peakvalue': ['30369'],
+                   'averagelevel': ['7291']},
          'samplerate': 44100, 'album': 'The Colour and the Shape', 'title': 'Doll',
          'bitrate': 64.04, 'filesize': 5800, 'track': 1, 'albumartist': 'Foo Fighters',
          'artist': 'Foo Fighters', 'duration': 83.406, 'year': '1997',
          'genre': 'Alternative', 'composer': 'Foo Fighters', 'channels': 2}),
     ('samples/lossless.wma',
-        {'extra': {}, 'samplerate': 44100, 'bitrate': 667.296, 'filesize': 2500, 'bitdepth': 16,
+        {'extra': {},
+         'samplerate': 44100, 'bitrate': 667.296, 'filesize': 2500, 'bitdepth': 16,
          'duration': 43.133, 'channels': 2}),
     ('samples/wma_invalid_track_number.wma',
-        {'extra': {'encoder_settings': 'Lavf60.16.100'}, 'filesize': 3940, 'bitrate': 128.0,
+        {'extra': {'encoder_settings': ['Lavf60.16.100']},
+         'filesize': 3940, 'bitrate': 128.0,
          'duration': 2.1409999999999996, 'samplerate': 44100, 'channels': 1}),
 
     # ALAC/M4A/MP4
     ('samples/test.m4a',
-        {'extra': {'itunsmpb': (' 00000000 00000840 000001DC 0000000000D3E9E4 00000000 00000000 '
-                                '00000000 00000000 00000000 00000000 00000000 00000000'),
-                   'itunnorm': (' 00000358 0000032E 000020AE 000020D9 0003A228 00032A28 00007E20 '
-                                '00007E90 00007BFD 00009293'),
-                   'itunes_cddb_ids': '11++', 'ufidhttp://www.cddb.com/id3/taginfo1.html':
-                   '3CD3N48Q241232290U3387DD249F72E6B082B283425ADB9B0F324P1', 'bpm': 0,
-                   'encoded_by': 'iTunes 10.5'},
+        {'extra': {'itunsmpb': [' 00000000 00000840 000001DC 0000000000D3E9E4 00000000 00000000 '
+                                '00000000 00000000 00000000 00000000 00000000 00000000'],
+                   'itunnorm': [' 00000358 0000032E 000020AE 000020D9 0003A228 00032A28 00007E20 '
+                                '00007E90 00007BFD 00009293'],
+                   'itunes_cddb_ids': ['11++'], 'ufidhttp://www.cddb.com/id3/taginfo1.html':
+                   ['3CD3N48Q241232290U3387DD249F72E6B082B283425ADB9B0F324P1'], 'bpm': ['0'],
+                   'encoded_by': ['iTunes 10.5']},
          'samplerate': 44100, 'duration': 314.97868480725623, 'bitrate': 256.0, 'channels': 2,
          'genre': 'Pop', 'year': '2011', 'title': 'Nothing', 'album': 'Only Our Hearts To Lose',
          'track_total': 11, 'track': 11, 'artist': 'Marian', 'filesize': 61432}),
     ('samples/test2.m4a',
-        {'extra': {'copyright': '℗ 1992 Ace Records',
-                   'itunnorm': (' 00000371 00000481 00002E90 00002EA6 00000099 00000058 000073F3 '
-                                '0000768E 00000092 00000092'),
-                   'itunsmpb': (' 00000000 00000840 00000110 000000000070DEB0 00000000 00000000 '
-                                '00000000 00000000 00000000 00000000 00000000 00000000'),
-                   'itunmovi': ('<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC '
+        {'extra': {'copyright': ['℗ 1992 Ace Records'],
+                   'itunnorm': [' 00000371 00000481 00002E90 00002EA6 00000099 00000058 000073F3 '
+                                '0000768E 00000092 00000092'],
+                   'itunsmpb': [' 00000000 00000840 00000110 000000000070DEB0 00000000 00000000 '
+                                '00000000 00000000 00000000 00000000 00000000 00000000'],
+                   'itunmovi': ['<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC '
                                 '"-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs'
                                 '/PropertyList-1.0.dtd">\n<plist version="1.0">\n<dict>\n\t<key>'
                                 'asset-info</key>\n\t<dict>\n\t\t<key>flavor</key>\n\t\t<string>'
-                                '2:256</string>\n\t</dict>\n</dict>\n</plist>\n'),
-                   'tool': 144255989988720642},
+                                '2:256</string>\n\t</dict>\n</dict>\n</plist>\n'],
+                   'tool': ['144255989988720642']},
          'bitrate': 256.0, 'track': 1,
          'albumartist': "Millie Jackson - Get It Out 'cha System - 1978",
          'duration': 167.78739229024944, 'filesize': 223365, 'channels': 2, 'year': '1978',
@@ -490,8 +500,8 @@ testfiles = dict([
          'albumartist': 'Major Lazer', 'channels': 2, 'bitrate': 125.584,
          'comment': '? 2016 Mad Decent'}),
     ('samples/alac_file.m4a',
-        {'extra': {'copyright': '© Hyperion Records Ltd, London', 'lyrics': 'Album notes:',
-         'upc': '0034571177380'},
+        {'extra': {'copyright': ['© Hyperion Records Ltd, London'], 'lyrics': ['Album notes:'],
+         'upc': ['0034571177380']},
          'artist': 'Howard Shelley', 'filesize': 20000,
          'composer': 'Clementi, Muzio (1752-1832)',
          'title': 'Clementi: Piano Sonata in D major, Op 25 No 6 - Movement 2: Un poco andante',
@@ -505,15 +515,16 @@ testfiles = dict([
         'channels': 2,
         'comment': 'test comment',
         'duration': 2.36,
-        'extra': {'description': 'test description', 'encoded_by': 'Lavf59.27.100'},
+        'extra': {'description': ['test description'], 'encoded_by': ['Lavf59.27.100']},
         'samplerate': 44100}),
     ('samples/mpeg4_xa9des.m4a', {
         'filesize': 2639,
         'comment': 'test comment',
         'duration': 727.1066666666667,
-        'extra': {'description': 'test description'}}),
+        'extra': {'description': ['test description']}}),
     ('samples/test3.m4a',
-        {'extra': {'publisher': 'test7', 'bpm': 99999, 'encoded_by': 'Lavf60.3.100'},
+        {'extra': {'publisher': ['test7'], 'bpm': ['99999'],
+                   'encoded_by': ['Lavf60.3.100']},
          'artist': 'test1', 'composer': 'test8',
          'filesize': 6260, 'samplerate': 8000, 'duration': 1.294, 'channels': 1,
          'bitrate': 27.887}),
@@ -525,7 +536,7 @@ testfiles = dict([
          'title': 'thetitle', 'album': 'thealbum', 'comment': 'hello',
          'year': '2014'}),
     ('samples/test.aiff',
-        {'extra': {'copyright': '℗ 1992 Ace Records'}, 'channels': 2, 'duration': 0.0,
+        {'extra': {'copyright': ['℗ 1992 Ace Records']}, 'channels': 2, 'duration': 0.0,
          'filesize': 164, 'bitrate': 1411.2, 'samplerate': 44100, 'bitdepth': 16,
          'title': 'Go Out and Get Some',
          'comment': 'Millie Jackson - Get It Out \'cha System - 1978'}),
@@ -535,14 +546,14 @@ testfiles = dict([
          'bitrate': 176.4, 'samplerate': 11025, 'bitdepth': 8,
          'comment': 'Audacity Pluck + Wahwah', 'year': '2013'}),
     ('samples/M1F1-mulawC-AFsp.afc',
-        {'extra': {}, 'channels': 2, 'duration': 2.936625, 'filesize': 47148,
+        {'extra': {'comment': ['user: kabal@CAPELLA', 'program: CopyAudio']},
+         'channels': 2, 'duration': 2.936625, 'filesize': 47148,
          'bitrate': 256.0, 'samplerate': 8000, 'bitdepth': 16,
-         'comment':
-         'AFspdate: 2003-01-30 03:28:34 UTC\x00user: kabal@CAPELLA\x00program: CopyAudio'}),
+         'comment': 'AFspdate: 2003-01-30 03:28:34 UTC'}),
     ('samples/invalid_sample_rate.aiff',
         {'extra': {}, 'channels': 1, 'filesize': 4096, 'bitdepth': 16}),
     ('samples/aiff_extra_tags.aiff',
-        {'extra': {'copyright': 'test', 'isrc': 'CC-XXX-YY-NNNNN'}, 'channels': 1,
+        {'extra': {'copyright': ['test'], 'isrc': ['CC-XXX-YY-NNNNN']}, 'channels': 1,
          'duration': 2.176, 'filesize': 18532, 'bitrate': 64.0, 'samplerate': 8000, 'bitdepth': 8,
          'title': 'song title', 'artist': 'artist 1;artist 2'}),
 
@@ -551,19 +562,21 @@ testfiles = dict([
 testfolder = os.path.join(os.path.dirname(__file__))
 
 
-def compare_tag(results: dict[str, dict[str, Any]], expected: dict[str, dict[str, Any]],
+def compare_tag(results: dict[str, Any],
+                expected: dict[str, Any],
                 file: str, prev_path: str | None = None) -> None:
-    def compare_values(path: str, result_val: int | float | str | dict[str, Any],
-                       expected_val: int | float | str | dict[str, Any]) -> bool:
+    def compare_values(path: str,
+                       result_val: str | int | float,
+                       expected_val: str | int | float) -> bool:
         # lets not copy *all* the lyrics inside the fixture
         if (path == 'extra.lyrics'
-                and isinstance(expected_val, str) and isinstance(result_val, str)):
-            return result_val.startswith(expected_val)
+                and isinstance(expected_val, list) and isinstance(result_val, list)):
+            return result_val[0].startswith(expected_val[0])
         if isinstance(expected_val, float):
             return result_val == pytest.approx(expected_val)
         return result_val == expected_val
 
-    def error_fmt(value: int | float | str | dict[str, Any]) -> str:
+    def error_fmt(value: str | int | float) -> str:
         return f'{repr(value)} ({type(value)})'
 
     assert isinstance(results, dict)
@@ -586,10 +599,9 @@ def compare_tag(results: dict[str, dict[str, Any]], expected: dict[str, dict[str
 def test_file_reading_tags_duration(testfile: str, expected: dict[str, dict[str, Any]]) -> None:
     filename = os.path.join(testfolder, testfile)
     tag = TinyTag.get(filename, tags=True, duration=True)
-    results = {
-        key: val for key, val in tag._as_dict().items()
-        if val is not None and key not in ('filename', 'images')
-    }
+    results = tag.as_dict(flatten=False)
+    for attr_name in ('filename', 'images'):
+        del results[attr_name]
     compare_tag(results, expected, filename)
     assert tag.images.any is None
 
@@ -599,10 +611,9 @@ def test_file_reading_tags(testfile: str, expected: dict[str, dict[str, Any]]) -
     filename = os.path.join(testfolder, testfile)
     excluded_attrs = {"bitdepth", "bitrate", "channels", "duration", "samplerate"}
     tag = TinyTag.get(filename, tags=True, duration=False)
-    results = {
-        key: val for key, val in tag._as_dict().items()
-        if val is not None and key not in ('filename', 'images')
-    }
+    results = tag.as_dict(flatten=False)
+    for attr_name in ('filename', 'images'):
+        del results[attr_name]
     expected = {
         key: val for key, val in expected.items() if key not in excluded_attrs
     }
@@ -615,14 +626,12 @@ def test_file_reading_duration(testfile: str, expected: dict[str, dict[str, Any]
     filename = os.path.join(testfolder, testfile)
     allowed_attrs = {"bitdepth", "bitrate", "channels", "duration", "filesize", "samplerate"}
     tag = TinyTag.get(filename, tags=False, duration=True)
-    results = {
-        key: val for key, val in tag._as_dict().items()
-        if val is not None and key not in ('filename', 'images')
-    }
+    results = tag.as_dict(flatten=False)
+    for attr_name in ('filename', 'extra', 'images'):
+        del results[attr_name]
     expected = {
         key: val for key, val in expected.items() if key in allowed_attrs
     }
-    expected["extra"] = {}
     compare_tag(results, expected, filename)
     assert tag.images.any is None
 
@@ -739,11 +748,8 @@ def test_image_loading(path: str, expected_size: int) -> None:
     assert image.mime_type == 'image/jpeg'
 
 
-@pytest.mark.parametrize('path', [
-    'samples/ogg_with_image.ogg',
-])
-def test_image_loading_extra(path: str) -> None:
-    tag = TinyTag.get(os.path.join(testfolder, path), image=True)
+def test_image_loading_extra() -> None:
+    tag = TinyTag.get(os.path.join(testfolder, 'samples/ogg_with_image.ogg'), image=True)
     image = tag.images.extra['bright_colored_fish'][0]
     assert image.data is not None
     assert tag.images.any is not None
@@ -807,13 +813,42 @@ def test_to_str() -> None:
     tag = TinyTag.get(os.path.join(testfolder, 'samples/id3v22-test.mp3'))
     assert (
         "'filesize': 5120, 'duration': 0.13836297152858082, 'channels': 2, 'bitrate': 160.0, "
-        "'bitdepth': None, 'samplerate': 44100, 'artist': 'Anais Mitchell', 'albumartist': None, "
-        "'composer': None, 'album': 'Hymns for the Exiled', 'disc': None, 'disc_total': None, "
-        "'title': 'cosmic american', 'track': 3, 'track_total': 11, 'genre': None, "
+        "'samplerate': 44100, 'artist': 'Anais Mitchell', "
+        "'album': 'Hymns for the Exiled', "
+        "'title': 'cosmic american', 'track': 3, 'track_total': 11, "
         "'year': '2004', 'comment': 'Waterbug Records, www.anaismitchell.com', "
-        "'extra': {'encoded_by': 'iTunes v4.6', 'itunnorm': ' 0000044E 00000061 00009B67 000044C3 "
-        "00022478 00022182 00007FCC 00007E5C 0002245E 0002214E', 'itunes_cddb_1': '9D09130B+"
-        "174405+11+150+14097+27391+43983+65786+84877+99399+113226+132452+146426+163829', "
-        "'itunes_cddb_tracknumber': '3'}, 'images': {'front_cover': [], 'back_cover': [], "
-        "'leaflet': [], 'media': [], 'other': [], 'extra': {}}"
+        "'extra': {'encoded_by': ['iTunes v4.6'], 'itunnorm': [' 0000044E 00000061 00009B67 "
+        "000044C3 00022478 00022182 00007FCC 00007E5C 0002245E 0002214E'], 'itunes_cddb_1': "
+        "['9D09130B+174405+11+150+14097+27391+43983+65786+84877+99399+113226+132452+146426+"
+        "163829'], 'itunes_cddb_tracknumber': ['3']}, 'images': {'extra': {}}"
     ) in str(tag)
+
+
+def test_to_str_flatten() -> None:
+    tag = TinyTag.get(os.path.join(testfolder, 'samples/flac_multiple_fields.flac'))
+    assert (
+        "'filesize': 266, 'duration': 0.1, 'channels': 1, 'bitrate': 21.28, 'bitdepth': 16, "
+        "'samplerate': 44100, 'artist': ['artist 1', 'artist 2', 'artist 3'], "
+        "'album': ['album 1', 'album 2'], 'genre': ['genre 1', 'genre 2'], "
+        "'url': ['https://example.com'], 'images': {}"
+    ) in str(tag.as_dict(flatten=True))
+
+
+def test_to_str_images() -> None:
+    tag = TinyTag.get(os.path.join(testfolder, 'samples/ogg_with_image.ogg'), image=True)
+    assert str(tag.images) == (
+        "{'extra': {'bright_colored_fish': [{'name': 'extra.bright_colored_fish', "
+        "'data': b'\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H"
+        "\\x00\\x00\\xff\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00\\x02\\xa0"
+        "lcm..', 'mime_type': 'image/jpeg', 'description': None}]}}"
+    )
+
+
+def test_to_str_images_flatten() -> None:
+    tag = TinyTag.get(os.path.join(testfolder, 'samples/ogg_with_image.ogg'), image=True)
+    assert str(tag.images.as_dict(flatten=True)) == (
+        "{'bright_colored_fish': [{'name': 'extra.bright_colored_fish', "
+        "'data': b'\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H"
+        "\\x00\\x00\\xff\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00\\x02\\xa0"
+        "lcm..', 'mime_type': 'image/jpeg', 'description': None}]}"
+    )
diff --git a/tinytag/tests/test_cli.py b/tinytag/tests/test_cli.py
index 1fca347..285811a 100644
--- a/tinytag/tests/test_cli.py
+++ b/tinytag/tests/test_cli.py
@@ -76,14 +76,14 @@ def test_meta_data_output_default_json() -> None:
     output = run_cli(mp3_with_image)
     data = json.loads(output)
     assert data
-    assert set(data.keys()) == tinytag_attributes
+    assert set(data.keys()).issubset(tinytag_attributes)
 
 
 def test_meta_data_output_format_json() -> None:
     output = run_cli('-f json ' + mp3_with_image)
     data = json.loads(output)
     assert data
-    assert set(data.keys()) == tinytag_attributes
+    assert set(data.keys()).issubset(tinytag_attributes)
 
 
 def test_meta_data_output_format_csv() -> None:
@@ -91,7 +91,7 @@ def test_meta_data_output_format_csv() -> None:
     lines = [line for line in output.split(os.linesep) if line]
     assert all(',' in line for line in lines)
     attributes = set(line.split(',')[0] for line in lines)
-    assert set(attributes) == tinytag_attributes
+    assert set(attributes).issubset(tinytag_attributes)
 
 
 def test_meta_data_output_format_tsv() -> None:
@@ -99,13 +99,18 @@ def test_meta_data_output_format_tsv() -> None:
     lines = [line for line in output.split(os.linesep) if line]
     assert all('\t' in line for line in lines)
     attributes = set(line.split('\t')[0] for line in lines)
-    assert set(attributes) == tinytag_attributes
+    assert set(attributes).issubset(tinytag_attributes)
 
 
 def test_meta_data_output_format_tabularcsv() -> None:
     output = run_cli('-f tabularcsv ' + mp3_with_image)
     header, _line, _rest = output.split(os.linesep)
-    assert set(header.split(',')) == tinytag_attributes
+    assert set(header.split(',')).issubset(tinytag_attributes)
+
+
+def test_meta_data_output_format_invalid() -> None:
+    output = run_cli('-f invalid ' + mp3_with_image)
+    assert not output
 
 
 def test_fail_on_unsupported_file() -> None: