Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions beets/config_default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import:
ignored_alias_types: []
singleton_album_disambig: yes
fix_ext_inplace: no
remux_mp3_in_wav: yes
Comment thread
snejus marked this conversation as resolved.

# --------------- Paths ---------------

Expand Down
12 changes: 11 additions & 1 deletion beets/importer/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from beets.autotag.match import tag_album, tag_item
from beets.dbcore.query import PathQuery
from beets.util import extension
from beets.util.extension import remux_mpeglayer3_wav

from .state import ImportState

Expand Down Expand Up @@ -1082,7 +1083,16 @@ def read_item(self, path: util.PathBytes):
return library.Item.from_path(path)
except library.ReadError as exc:
if isinstance(exc.reason, mediafile.FileTypeError):
# Silently ignore non-music files.
mp3_path = None
if config["import"]["remux_mp3_in_wav"].get(bool):
mp3_path = remux_mpeglayer3_wav(path)
if mp3_path:
log.info(
"Remuxed MPEGLAYER3 WAV to MP3: {}",
util.displayable_path(mp3_path),
)
return library.Item.from_path(mp3_path)
# Silently ignore other non-music files
pass
elif isinstance(exc.reason, mediafile.UnreadableFileError):
log.warning("unreadable file: {}", util.displayable_path(path))
Expand Down
33 changes: 33 additions & 0 deletions beets/util/extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
from logging import Logger
from pathlib import Path

import mutagen.wave

import beets
from beets import util

Expand Down Expand Up @@ -147,3 +149,34 @@ def fix_extension(path_bytes: PathBytes, logger: Logger | None = None):
if logger:
logger.info("Import file with matching format to original target")
return new_path


def remux_mpeglayer3_wav(path: util.PathBytes) -> util.PathBytes | None:
"""If 'path' is a WAV file containing an MP3 stream
(WAVE_FORMAT_MPEGLAYER3, wFormatTag = 0x0055), extract the MP3 stream
to a new .mp3 file and return its path. Returns None if the file is not
MPEGLAYER3 or if extraction fails.
"""
try:
f = mutagen.wave.WAVE(util.syspath(path))
except mutagen.MutagenError:
return None
if getattr(f.info, "audio_format", 1) != 0x55:
return None

with open(util.syspath(path), "rb") as wav_file:
data = wav_file.read()

data_offset = data.find(b"data")
if data_offset == -1:
return None

# Skip 'data' marker (4 bytes) and chunk size (4 bytes).
mp3_data = data[data_offset + 8 :]

mp3_path = os.path.splitext(path)[0] + b".mp3"
with open(util.syspath(mp3_path), "wb") as mp3_file:
mp3_file.write(mp3_data)

util.remove(path)
return mp3_path
3 changes: 3 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ New features
Bug fixes
~~~~~~~~~

- :ref:`import-cmd` Automatically remux WAV files containing MP3 streams
(``WAVE_FORMAT_MPEGLAYER3``) to proper MP3 files during import, instead of
silently importing them with incorrect metadata. :bug:`6455`
- :doc:`plugins/listenbrainz`: Retry listenbrainz requests for temporary
failures.

Expand Down
13 changes: 13 additions & 0 deletions docs/reference/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -903,6 +903,19 @@ FFmpeg)

Default: ``no``.

.. _remux_mp3_in_wav:

remux_mp3_in_wav
~~~~~~~~~~~~~~~~

Some WAV files contain MP3 audio streams (``WAVE_FORMAT_MPEGLAYER3``) rather
than the standard PCM format. When this option is enabled, beets will
automatically extract the MP3 stream into a proper ``.mp3`` file during import,
removing the WAV container. The original WAV file is deleted after successful
extraction.

Default: ``yes``.

.. _match-config:

Autotagger Matching Options
Expand Down
368 changes: 184 additions & 184 deletions poetry.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ colorama = { version = "*", markers = "sys_platform == 'win32'" }
confuse = ">=2.2.0"
jellyfish = "*"
lap = ">=0.5.12"
mediafile = ">=0.16.0"
mediafile = ">=0.16.1"
numpy = [
{ python = "<3.13", version = ">=2.0.2" },
{ python = ">=3.13", version = ">=2.3.5" },
Expand Down
Binary file added test/rsrc/mpeglayer3.wav
Binary file not shown.
27 changes: 27 additions & 0 deletions test/test_importer.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
has_program,
)
from beets.util import bytestring_path, displayable_path, syspath
from beets.util.extension import remux_mpeglayer3_wav


class PathsMixin:
Expand Down Expand Up @@ -1747,3 +1748,29 @@ def test_candidates_singleton(self):
assert {"VALID_RECORDING_0", "VALID_RECORDING_1"} == {
c.info.title for c in task.candidates
}


class MpeglayerWavImportTest(AsIsImporterMixin, ImportTestCase):
"""Test remuxing of WAVE_FORMAT_MPEGLAYER3 WAV files."""

def test_remux_mpeglayer3_wav(self):
src = os.path.join(_common.RSRC, b"mpeglayer3.wav")
dest = os.path.join(self.temp_dir, b"mpeglayer3.wav")
shutil.copy(syspath(src), syspath(dest))

mp3_path = remux_mpeglayer3_wav(dest)

assert mp3_path is not None
assert mp3_path.endswith(b".mp3")
assert os.path.exists(mp3_path)
assert not os.path.exists(dest)

def test_remux_mpeglayer3_wav_disabled(self):
"""When remux_mp3_in_wav is disabled, WAV file should not be remuxed."""
self.config["import"]["remux_mp3_in_wav"] = False
src = os.path.join(_common.RSRC, b"mpeglayer3.wav")
dest = os.path.join(self.import_dir, b"mpeglayer3.wav")
shutil.copy(syspath(src), syspath(dest))

self.run_asis_importer()
assert os.path.exists(dest)
Loading