diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py
index 383bb3da3..78f146a82 100644
--- a/beetsplug/replaygain.py
+++ b/beetsplug/replaygain.py
@@ -94,21 +94,138 @@ def lufs_to_db(db):
# gain: in LU to reference level
# peak: part of full scale (FS is 1.0)
Gain = collections.namedtuple("Gain", "gain peak")
-# album_gain: Gain object
-# track_gains: list of Gain objects
-AlbumGain = collections.namedtuple("AlbumGain", "album_gain track_gains")
-class Peak(enum.Enum):
- none = 0
+class PeakMethod(enum.Enum):
true = 1
sample = 2
+class RgTask():
+ """State and methods for a single replaygain calculation (rg version).
+
+ Bundles the state (parameters and results) of a single replaygain
+ calculation (either for one item, one disk, or one full album).
+
+ This class provides methods to store the resulting gains and peaks as plain
+ old rg tags.
+ """
+
+ def __init__(self, items, album, target_level, peak_method, backend_name,
+ log):
+ self.items = items
+ self.album = album
+ self.target_level = target_level
+ self.peak_method = peak_method
+ self.backend_name = backend_name
+ self._log = log
+ self.album_gain = None
+ self.track_gains = None
+
+ def _store_track_gain(self, item, track_gain):
+ """Store track gain for a single item in the database.
+ """
+ item.rg_track_gain = track_gain.gain
+ item.rg_track_peak = track_gain.peak
+ item.store()
+ self._log.debug('applied track gain {0} LU, peak {1} of FS',
+ item.rg_track_gain, item.rg_track_peak)
+
+ def _store_album_gain(self, item):
+ """Store album gain for a single item in the database.
+
+ The caller needs to ensure that `self.album_gain is not None`.
+ """
+ item.rg_album_gain = self.album_gain.gain
+ item.rg_album_peak = self.album_gain.peak
+ item.store()
+ self._log.debug('applied album gain {0} LU, peak {1} of FS',
+ item.rg_album_gain, item.rg_album_peak)
+
+ def _store_track(self, write):
+ """Store track gain for the first track of the task in the database.
+ """
+ item = self.items[0]
+ if self.track_gains is None or len(self.track_gains) != 1:
+ # In some cases, backends fail to produce a valid
+ # `track_gains` without throwing FatalReplayGainError
+ # => raise non-fatal exception & continue
+ raise ReplayGainError(
+ "ReplayGain backend `{}` failed for track {}"
+ .format(self.backend_name, item)
+ )
+
+ self._store_track_gain(item, self.track_gains[0])
+ if write:
+ item.try_write()
+ self._log.debug('done analyzing {0}', item)
+
+ def _store_album(self, write):
+ """Store track/album gains for all tracks of the task in the database.
+ """
+ if (self.album_gain is None or self.track_gains is None
+ or len(self.track_gains) != len(self.items)):
+ # In some cases, backends fail to produce a valid
+ # `album_gain` without throwing FatalReplayGainError
+ # => raise non-fatal exception & continue
+ raise ReplayGainError(
+ "ReplayGain backend `{}` failed "
+ "for some tracks in album {}"
+ .format(self.backend_name, self.album)
+ )
+ for item, track_gain in zip(self.items, self.track_gains):
+ self._store_track_gain(item, track_gain)
+ self._store_album_gain(item)
+ if write:
+ item.try_write()
+ self._log.debug('done analyzing {0}', item)
+
+ def store(self, write):
+ """Store computed gains for the items of this task in the database.
+ """
+ if self.album is not None:
+ self._store_album(write)
+ else:
+ self._store_track(write)
+
+
+class R128Task(RgTask):
+ """State and methods for a single replaygain calculation (r128 version).
+
+ Bundles the state (parameters and results) of a single replaygain
+ calculation (either for one item, one disk, or one full album).
+
+ This class provides methods to store the resulting gains and peaks as R128
+ tags.
+ """
+
+ def __init__(self, items, album, target_level, backend_name, log):
+ # R128_* tags do not store the track/album peak
+ super().__init__(items, album, target_level, None, backend_name,
+ log)
+
+ def _store_track_gain(self, item, track_gain):
+ item.r128_track_gain = track_gain.gain
+ item.store()
+ self._log.debug('applied r128 track gain {0} LU',
+ item.r128_track_gain)
+
+ def _store_album_gain(self, item):
+ """
+
+ The caller needs to ensure that `self.album_gain is not None`.
+ """
+ item.r128_album_gain = self.album_gain.gain
+ item.store()
+ self._log.debug('applied r128 album gain {0} LU',
+ item.r128_album_gain)
+
+
class Backend:
"""An abstract class representing engine for calculating RG values.
"""
+ NAME = ""
do_parallel = False
def __init__(self, config, log):
@@ -117,15 +234,15 @@ class Backend:
"""
self._log = log
- def compute_track_gain(self, items, target_level, peak):
- """Computes the track gain of the given tracks, returns a list
- of Gain objects.
+ def compute_track_gain(self, task):
+ """Computes the track gain for the tracks belonging to `task`, and sets
+ the `track_gains` attribute on the task. Returns `task`.
"""
raise NotImplementedError()
- def compute_album_gain(self, items, target_level, peak):
- """Computes the album gain of the given album, returns an
- AlbumGain object.
+ def compute_album_gain(self, task):
+ """Computes the album gain for the album belonging to `task`, and sets
+ the `album_gain` attribute on the task. Returns `task`.
"""
raise NotImplementedError()
@@ -135,6 +252,7 @@ class FfmpegBackend(Backend):
"""A replaygain backend using ffmpeg's ebur128 filter.
"""
+ NAME = "ffmpeg"
do_parallel = True
def __init__(self, config, log):
@@ -165,27 +283,28 @@ class FfmpegBackend(Backend):
"the --enable-libebur128 configuration option is required."
)
- def compute_track_gain(self, items, target_level, peak):
- """Computes the track gain of the given tracks, returns a list
- of Gain objects (the track gains).
+ def compute_track_gain(self, task):
+ """Computes the track gain for the tracks belonging to `task`, and sets
+ the `track_gains` attribute on the task. Returns `task`.
"""
gains = []
- for item in items:
+ for item in task.items:
gains.append(
self._analyse_item(
item,
- target_level,
- peak,
+ task.target_level,
+ task.peak_method,
count_blocks=False,
)[0] # take only the gain, discarding number of gating blocks
)
- return gains
+ task.track_gains = gains
+ return task
- def compute_album_gain(self, items, target_level, peak):
- """Computes the album gain of the given album, returns an
- AlbumGain object.
+ def compute_album_gain(self, task):
+ """Computes the album gain for the album belonging to `task`, and sets
+ the `album_gain` attribute on the task. Returns `task`.
"""
- target_level_lufs = db_to_lufs(target_level)
+ target_level_lufs = db_to_lufs(task.target_level)
# analyse tracks
# list of track Gain objects
@@ -197,9 +316,9 @@ class FfmpegBackend(Backend):
# total number of BS.1770 gating blocks
n_blocks = 0
- for item in items:
+ for item in task.items:
track_gain, track_n_blocks = self._analyse_item(
- item, target_level, peak
+ item, task.target_level, task.peak_method
)
track_gains.append(track_gain)
@@ -234,10 +353,12 @@ class FfmpegBackend(Backend):
self._log.debug(
"{}: gain {} LU, peak {}"
- .format(items, album_gain, album_peak)
+ .format(task.items, album_gain, album_peak)
)
- return AlbumGain(Gain(album_gain, album_peak), track_gains)
+ task.album_gain = Gain(album_gain, album_peak)
+ task.track_gains = track_gains
+ return task
def _construct_cmd(self, item, peak_method):
"""Construct the shell command to analyse items."""
@@ -250,13 +371,15 @@ class FfmpegBackend(Backend):
"-map",
"a:0",
"-filter",
- f"ebur128=peak={peak_method}",
+ "ebur128=peak={}".format(
+ "none" if peak_method is None else peak_method.name),
"-f",
"null",
"-",
]
- def _analyse_item(self, item, target_level, peak, count_blocks=True):
+ def _analyse_item(self, item, target_level, peak_method,
+ count_blocks=True):
"""Analyse item. Return a pair of a Gain object and the number
of gating blocks above the threshold.
@@ -264,7 +387,6 @@ class FfmpegBackend(Backend):
will be 0.
"""
target_level_lufs = db_to_lufs(target_level)
- peak_method = peak.name
# call ffmpeg
self._log.debug(f"analyzing {item}")
@@ -276,12 +398,13 @@ class FfmpegBackend(Backend):
# parse output
- if peak == Peak.none:
+ if peak_method is None:
peak = 0
else:
line_peak = self._find_line(
output,
- f" {peak_method.capitalize()} peak:".encode(),
+ # `peak_method` is non-`None` in this arm of the conditional
+ f" {peak_method.name.capitalize()} peak:".encode(),
start_line=len(output) - 1, step_size=-1,
)
peak = self._parse_float(
@@ -379,6 +502,7 @@ class FfmpegBackend(Backend):
# mpgain/aacgain CLI tool backend.
class CommandBackend(Backend):
+ NAME = "command"
do_parallel = True
def __init__(self, config, log):
@@ -412,28 +536,33 @@ class CommandBackend(Backend):
self.noclip = config['noclip'].get(bool)
- def compute_track_gain(self, items, target_level, peak):
- """Computes the track gain of the given tracks, returns a list
- of TrackGain objects.
+ def compute_track_gain(self, task):
+ """Computes the track gain for the tracks belonging to `task`, and sets
+ the `track_gains` attribute on the task. Returns `task`.
"""
- supported_items = list(filter(self.format_supported, items))
- output = self.compute_gain(supported_items, target_level, False)
- return output
+ supported_items = list(filter(self.format_supported, task.items))
+ output = self.compute_gain(supported_items, task.target_level, False)
+ task.track_gains = output
+ return task
- def compute_album_gain(self, items, target_level, peak):
- """Computes the album gain of the given album, returns an
- AlbumGain object.
+ def compute_album_gain(self, task):
+ """Computes the album gain for the album belonging to `task`, and sets
+ the `album_gain` attribute on the task. Returns `task`.
"""
# TODO: What should be done when not all tracks in the album are
# supported?
- supported_items = list(filter(self.format_supported, items))
- if len(supported_items) != len(items):
+ supported_items = list(filter(self.format_supported, task.items))
+ if len(supported_items) != len(task.items):
self._log.debug('tracks are of unsupported format')
- return AlbumGain(None, [])
+ task.album_gain = None
+ task.track_gains = None
+ return task
- output = self.compute_gain(supported_items, target_level, True)
- return AlbumGain(output[-1], output[:-1])
+ output = self.compute_gain(supported_items, task.target_level, True)
+ task.album_gain = output[-1]
+ task.track_gains = output[:-1]
+ return task
def format_supported(self, item):
"""Checks whether the given item is supported by the selected tool.
@@ -508,6 +637,8 @@ class CommandBackend(Backend):
# GStreamer-based backend.
class GStreamerBackend(Backend):
+ NAME = "gstreamer"
+
def __init__(self, config, log):
super().__init__(config, log)
self._import_gst()
@@ -612,21 +743,28 @@ class GStreamerBackend(Backend):
if self._error is not None:
raise self._error
- def compute_track_gain(self, items, target_level, peak):
- self.compute(items, target_level, False)
- if len(self._file_tags) != len(items):
+ def compute_track_gain(self, task):
+ """Computes the track gain for the tracks belonging to `task`, and sets
+ the `track_gains` attribute on the task. Returns `task`.
+ """
+ self.compute(task.items, task.target_level, False)
+ if len(self._file_tags) != len(task.items):
raise ReplayGainError("Some tracks did not receive tags")
ret = []
- for item in items:
+ for item in task.items:
ret.append(Gain(self._file_tags[item]["TRACK_GAIN"],
self._file_tags[item]["TRACK_PEAK"]))
- return ret
+ task.track_gains = ret
+ return task
- def compute_album_gain(self, items, target_level, peak):
- items = list(items)
- self.compute(items, target_level, True)
+ def compute_album_gain(self, task):
+ """Computes the album gain for the album belonging to `task`, and sets
+ the `album_gain` attribute on the task. Returns `task`.
+ """
+ items = list(task.items)
+ self.compute(items, task.target_level, True)
if len(self._file_tags) != len(items):
raise ReplayGainError("Some items in album did not receive tags")
@@ -648,7 +786,9 @@ class GStreamerBackend(Backend):
except KeyError:
raise ReplayGainError("results missing for album")
- return AlbumGain(Gain(gain, peak), track_gains)
+ task.album_gain = Gain(gain, peak)
+ task.track_gains = track_gains
+ return task
def close(self):
self._bus.remove_signal_watch()
@@ -779,6 +919,7 @@ class AudioToolsBackend(Backend):
`_ and its capabilities to read more
file formats and compute ReplayGain values using it replaygain module.
"""
+ NAME = "audiotools"
def __init__(self, config, log):
super().__init__(config, log)
@@ -840,12 +981,14 @@ class AudioToolsBackend(Backend):
return
return rg
- def compute_track_gain(self, items, target_level, peak):
- """Compute ReplayGain values for the requested items.
-
- :return list: list of :class:`Gain` objects
+ def compute_track_gain(self, task):
+ """Computes the track gain for the tracks belonging to `task`, and sets
+ the `track_gains` attribute on the task. Returns `task`.
"""
- return [self._compute_track_gain(item, target_level) for item in items]
+ gains = [self._compute_track_gain(i, task.target_level)
+ for i in task.items]
+ task.track_gains = gains
+ return task
def _with_target_level(self, gain, target_level):
"""Return `gain` relative to `target_level`.
@@ -890,23 +1033,22 @@ class AudioToolsBackend(Backend):
item.artist, item.title, rg_track_gain, rg_track_peak)
return Gain(gain=rg_track_gain, peak=rg_track_peak)
- def compute_album_gain(self, items, target_level, peak):
- """Compute ReplayGain values for the requested album and its items.
-
- :rtype: :class:`AlbumGain`
+ def compute_album_gain(self, task):
+ """Computes the album gain for the album belonging to `task`, and sets
+ the `album_gain` attribute on the task. Returns `task`.
"""
# The first item is taken and opened to get the sample rate to
# initialize the replaygain object. The object is used for all the
# tracks in the album to get the album values.
- item = list(items)[0]
+ item = list(task.items)[0]
audiofile = self.open_audio_file(item)
rg = self.init_replaygain(audiofile, item)
track_gains = []
- for item in items:
+ for item in task.items:
audiofile = self.open_audio_file(item)
rg_track_gain, rg_track_peak = self._title_gain(
- rg, audiofile, target_level
+ rg, audiofile, task.target_level
)
track_gains.append(
Gain(gain=rg_track_gain, peak=rg_track_peak)
@@ -917,14 +1059,14 @@ class AudioToolsBackend(Backend):
# After getting the values for all tracks, it's possible to get the
# album values.
rg_album_gain, rg_album_peak = rg.album_gain()
- rg_album_gain = self._with_target_level(rg_album_gain, target_level)
+ rg_album_gain = self._with_target_level(
+ rg_album_gain, task.target_level)
self._log.debug('ReplayGain for album {0}: {1:.2f}, {2:.2f}',
- items[0].album, rg_album_gain, rg_album_peak)
+ task.items[0].album, rg_album_gain, rg_album_peak)
- return AlbumGain(
- Gain(gain=rg_album_gain, peak=rg_album_peak),
- track_gains=track_gains
- )
+ task.album_gain = Gain(gain=rg_album_gain, peak=rg_album_peak)
+ task.track_gains = track_gains
+ return task
class ExceptionWatcher(Thread):
@@ -956,22 +1098,19 @@ class ExceptionWatcher(Thread):
# Main plugin logic.
+BACKEND_CLASSES = [
+ CommandBackend,
+ GStreamerBackend,
+ AudioToolsBackend,
+ FfmpegBackend,
+]
+BACKENDS = {b.NAME: b for b in BACKEND_CLASSES}
+
+
class ReplayGainPlugin(BeetsPlugin):
"""Provides ReplayGain analysis.
"""
- backends = {
- "command": CommandBackend,
- "gstreamer": GStreamerBackend,
- "audiotools": AudioToolsBackend,
- "ffmpeg": FfmpegBackend,
- }
-
- peak_methods = {
- "true": Peak.true,
- "sample": Peak.sample,
- }
-
def __init__(self):
super().__init__()
@@ -989,30 +1128,36 @@ class ReplayGainPlugin(BeetsPlugin):
'r128_targetlevel': lufs_to_db(-23),
})
- self.overwrite = self.config['overwrite'].get(bool)
- self.per_disc = self.config['per_disc'].get(bool)
+ # FIXME: Consider renaming the configuration option and deprecating the
+ # old name 'overwrite'.
+ self.force_on_import = self.config['overwrite'].get(bool)
# Remember which backend is used for CLI feedback
self.backend_name = self.config['backend'].as_str()
- if self.backend_name not in self.backends:
+ if self.backend_name not in BACKENDS:
raise ui.UserError(
"Selected ReplayGain backend {} is not supported. "
"Please select one of: {}".format(
self.backend_name,
- ', '.join(self.backends.keys())
+ ', '.join(BACKENDS.keys())
)
)
+
+ # FIXME: Consider renaming the configuration option to 'peak_method'
+ # and deprecating the old name 'peak'.
peak_method = self.config["peak"].as_str()
- if peak_method not in self.peak_methods:
+ if peak_method not in PeakMethod.__members__:
raise ui.UserError(
"Selected ReplayGain peak method {} is not supported. "
"Please select one of: {}".format(
peak_method,
- ', '.join(self.peak_methods.keys())
+ ', '.join(PeakMethod.__members__)
)
)
- self._peak_method = self.peak_methods[peak_method]
+ # This only applies to plain old rg tags, r128 doesn't store peak
+ # values.
+ self.peak_method = PeakMethod[peak_method]
# On-import analysis.
if self.config['auto']:
@@ -1024,7 +1169,7 @@ class ReplayGainPlugin(BeetsPlugin):
self.r128_whitelist = self.config['r128'].as_str_seq()
try:
- self.backend_instance = self.backends[self.backend_name](
+ self.backend_instance = BACKENDS[self.backend_name](
self.config, self._log
)
except (ReplayGainError, FatalReplayGainError) as e:
@@ -1037,70 +1182,66 @@ class ReplayGainPlugin(BeetsPlugin):
"""
return item.format in self.r128_whitelist
+ @staticmethod
+ def has_r128_track_data(item):
+ return item.r128_track_gain is not None
+
+ @staticmethod
+ def has_rg_track_data(item):
+ return (item.rg_track_gain is not None
+ and item.rg_track_peak is not None)
+
def track_requires_gain(self, item):
- return self.overwrite or \
- (self.should_use_r128(item) and not item.r128_track_gain) or \
- (not self.should_use_r128(item) and
- (not item.rg_track_gain or not item.rg_track_peak))
+ if self.should_use_r128(item):
+ if not self.has_r128_track_data(item):
+ return True
+ else:
+ if not self.has_rg_track_data(item):
+ return True
+
+ return False
+
+ @staticmethod
+ def has_r128_album_data(item):
+ return (item.r128_track_gain is not None
+ and item.r128_album_gain is not None)
+
+ @staticmethod
+ def has_rg_album_data(item):
+ return (item.rg_album_gain is not None
+ and item.rg_album_peak is not None)
def album_requires_gain(self, album):
# Skip calculating gain only when *all* files don't need
# recalculation. This way, if any file among an album's tracks
# needs recalculation, we still get an accurate album gain
# value.
- return self.overwrite or \
- any([self.should_use_r128(item) and
- (not item.r128_track_gain or not item.r128_album_gain)
- for item in album.items()]) or \
- any([not self.should_use_r128(item) and
- (not item.rg_album_gain or not item.rg_album_peak)
- for item in album.items()])
+ for item in album.items():
+ if self.should_use_r128(item):
+ if not self.has_r128_album_data(item):
+ return True
+ else:
+ if not self.has_rg_album_data(item):
+ return True
- def store_track_gain(self, item, track_gain):
- item.rg_track_gain = track_gain.gain
- item.rg_track_peak = track_gain.peak
- item.store()
- self._log.debug('applied track gain {0} LU, peak {1} of FS',
- item.rg_track_gain, item.rg_track_peak)
+ return False
- def store_album_gain(self, item, album_gain):
- item.rg_album_gain = album_gain.gain
- item.rg_album_peak = album_gain.peak
- item.store()
- self._log.debug('applied album gain {0} LU, peak {1} of FS',
- item.rg_album_gain, item.rg_album_peak)
-
- def store_track_r128_gain(self, item, track_gain):
- item.r128_track_gain = track_gain.gain
- item.store()
-
- self._log.debug('applied r128 track gain {0} LU',
- item.r128_track_gain)
-
- def store_album_r128_gain(self, item, album_gain):
- item.r128_album_gain = album_gain.gain
- item.store()
- self._log.debug('applied r128 album gain {0} LU',
- item.r128_album_gain)
-
- def tag_specific_values(self, items):
- """Return some tag specific values.
-
- Returns a tuple (store_track_gain, store_album_gain, target_level,
- peak_method).
- """
- if any([self.should_use_r128(item) for item in items]):
- store_track_gain = self.store_track_r128_gain
- store_album_gain = self.store_album_r128_gain
- target_level = self.config['r128_targetlevel'].as_number()
- peak = Peak.none # R128_* tags do not store the track/album peak
+ def create_task(self, items, use_r128, album=None):
+ if use_r128:
+ return R128Task(
+ items, album,
+ self.config["r128_targetlevel"].as_number(),
+ self.backend_instance.NAME,
+ self._log,
+ )
else:
- store_track_gain = self.store_track_gain
- store_album_gain = self.store_album_gain
- target_level = self.config['targetlevel'].as_number()
- peak = self._peak_method
-
- return store_track_gain, store_album_gain, target_level, peak
+ return RgTask(
+ items, album,
+ self.config["targetlevel"].as_number(),
+ self.peak_method,
+ self.backend_instance.NAME,
+ self._log,
+ )
def handle_album(self, album, write, force=False):
"""Compute album and track replay gain store it in all of the
@@ -1114,8 +1255,9 @@ class ReplayGainPlugin(BeetsPlugin):
self._log.info('Skipping album {0}', album)
return
- if (any([self.should_use_r128(item) for item in album.items()]) and not
- all([self.should_use_r128(item) for item in album.items()])):
+ items_iter = iter(album.items())
+ use_r128 = self.should_use_r128(next(items_iter))
+ if any(use_r128 != self.should_use_r128(i) for i in items_iter):
self._log.error(
"Cannot calculate gain for album {0} (incompatible formats)",
album)
@@ -1123,11 +1265,8 @@ class ReplayGainPlugin(BeetsPlugin):
self._log.info('analyzing {0}', album)
- tag_vals = self.tag_specific_values(album.items())
- store_track_gain, store_album_gain, target_level, peak = tag_vals
-
discs = {}
- if self.per_disc:
+ if self.config['per_disc'].get(bool):
for item in album.items():
if discs.get(item.disc) is None:
discs[item.disc] = []
@@ -1136,34 +1275,12 @@ class ReplayGainPlugin(BeetsPlugin):
discs[1] = album.items()
for discnumber, items in discs.items():
- def _store_album(album_gain):
- if not album_gain or not album_gain.album_gain \
- or len(album_gain.track_gains) != len(items):
- # In some cases, backends fail to produce a valid
- # `album_gain` without throwing FatalReplayGainError
- # => raise non-fatal exception & continue
- raise ReplayGainError(
- "ReplayGain backend `{}` failed "
- "for some tracks in album {}"
- .format(self.backend_name, album)
- )
- for item, track_gain in zip(items,
- album_gain.track_gains):
- store_track_gain(item, track_gain)
- store_album_gain(item, album_gain.album_gain)
- if write:
- item.try_write()
- self._log.debug('done analyzing {0}', item)
-
+ task = self.create_task(items, use_r128, album=album)
try:
self._apply(
- self.backend_instance.compute_album_gain, args=(),
- kwds={
- "items": list(items),
- "target_level": target_level,
- "peak": peak
- },
- callback=_store_album
+ self.backend_instance.compute_album_gain,
+ args=[task], kwds={},
+ callback=lambda task: task.store(write)
)
except ReplayGainError as e:
self._log.info("ReplayGain error: {0}", e)
@@ -1182,33 +1299,14 @@ class ReplayGainPlugin(BeetsPlugin):
self._log.info('Skipping track {0}', item)
return
- tag_vals = self.tag_specific_values([item])
- store_track_gain, store_album_gain, target_level, peak = tag_vals
-
- def _store_track(track_gains):
- if not track_gains or len(track_gains) != 1:
- # In some cases, backends fail to produce a valid
- # `track_gains` without throwing FatalReplayGainError
- # => raise non-fatal exception & continue
- raise ReplayGainError(
- "ReplayGain backend `{}` failed for track {}"
- .format(self.backend_name, item)
- )
-
- store_track_gain(item, track_gains[0])
- if write:
- item.try_write()
- self._log.debug('done analyzing {0}', item)
+ use_r128 = self.should_use_r128(item)
+ task = self.create_task([item], use_r128)
try:
self._apply(
- self.backend_instance.compute_track_gain, args=(),
- kwds={
- "items": [item],
- "target_level": target_level,
- "peak": peak,
- },
- callback=_store_track
+ self.backend_instance.compute_track_gain,
+ args=[task], kwds={},
+ callback=lambda task: task.store(write)
)
except ReplayGainError as e:
self._log.info("ReplayGain error: {0}", e)
@@ -1308,9 +1406,9 @@ class ReplayGainPlugin(BeetsPlugin):
"""
if self.config['auto']:
if task.is_album:
- self.handle_album(task.album, False)
+ self.handle_album(task.album, False, self.force_on_import)
else:
- self.handle_track(task.item, False)
+ self.handle_track(task.item, False, self.force_on_import)
def command_func(self, lib, opts, args):
try:
diff --git a/docs/changelog.rst b/docs/changelog.rst
index f4df82e5c..e803b5bfa 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -44,6 +44,9 @@ Bug fixes:
* :doc:`plugins/lyrics`: Fixed issues with the Tekstowo.pl and Genius
backends where some non-lyrics content got included in the lyrics
* :doc:`plugins/limit`: Better header formatting to improve index
+* :doc:`plugins/replaygain`: Correctly handle the ``overwrite`` config option,
+ which forces recomputing ReplayGain values on import even for tracks
+ that already have the tags.
For packagers:
diff --git a/docs/plugins/replaygain.rst b/docs/plugins/replaygain.rst
index fa0e10b75..4ba882686 100644
--- a/docs/plugins/replaygain.rst
+++ b/docs/plugins/replaygain.rst
@@ -112,7 +112,10 @@ configuration file. The available options are:
- **backend**: The analysis backend; either ``gstreamer``, ``command``, ``audiotools``
or ``ffmpeg``.
Default: ``command``.
-- **overwrite**: Re-analyze files that already have ReplayGain tags.
+- **overwrite**: On import, re-analyze files that already have ReplayGain tags.
+ Note that, for historical reasons, the name of this option is somewhat
+ unfortunate: It does not decide whether tags are written to the files (which
+ is controlled by the :ref:`import.write ` option).
Default: ``no``.
- **targetlevel**: A number of decibels for the target loudness level for files
using ``REPLAYGAIN_`` tags.
diff --git a/test/helper.py b/test/helper.py
index 988995f48..f7d37b654 100644
--- a/test/helper.py
+++ b/test/helper.py
@@ -373,21 +373,23 @@ class TestHelper:
items.append(item)
return items
- def add_album_fixture(self, track_count=1, ext='mp3'):
+ def add_album_fixture(self, track_count=1, ext='mp3', disc_count=1):
"""Add an album with files to the database.
"""
items = []
path = os.path.join(_common.RSRC, util.bytestring_path('full.' + ext))
- for i in range(track_count):
- item = Item.from_path(path)
- item.album = '\u00e4lbum' # Check unicode paths
- item.title = f't\u00eftle {i}'
- # mtime needs to be set last since other assignments reset it.
- item.mtime = 12345
- item.add(self.lib)
- item.move(operation=MoveOperation.COPY)
- item.store()
- items.append(item)
+ for discnumber in range(1, disc_count + 1):
+ for i in range(track_count):
+ item = Item.from_path(path)
+ item.album = '\u00e4lbum' # Check unicode paths
+ item.title = f't\u00eftle {i}'
+ item.disc = discnumber
+ # mtime needs to be set last since other assignments reset it.
+ item.mtime = 12345
+ item.add(self.lib)
+ item.move(operation=MoveOperation.COPY)
+ item.store()
+ items.append(item)
return self.lib.add_album(items)
def create_mediafile_fixture(self, ext='mp3', images=[]):
diff --git a/test/test_replaygain.py b/test/test_replaygain.py
index 58b487fad..499befee2 100644
--- a/test/test_replaygain.py
+++ b/test/test_replaygain.py
@@ -41,14 +41,54 @@ def reset_replaygain(item):
item['rg_track_gain'] = None
item['rg_album_gain'] = None
item['rg_album_gain'] = None
+ item['r128_track_gain'] = None
+ item['r128_album_gain'] = None
item.write()
item.store()
- item.store()
- item.store()
+
+
+class GstBackendMixin():
+ backend = 'gstreamer'
+ has_r128_support = True
+
+ def test_backend(self):
+ """Check whether the backend actually has all required functionality.
+ """
+ try:
+ # Check if required plugins can be loaded by instantiating a
+ # GStreamerBackend (via its .__init__).
+ config['replaygain']['targetlevel'] = 89
+ GStreamerBackend(config['replaygain'], None)
+ except FatalGstreamerPluginReplayGainError as e:
+ # Skip the test if plugins could not be loaded.
+ self.skipTest(str(e))
+
+
+class CmdBackendMixin():
+ backend = 'command'
+ has_r128_support = False
+
+ def test_backend(self):
+ """Check whether the backend actually has all required functionality.
+ """
+ pass
+
+
+class FfmpegBackendMixin():
+ backend = 'ffmpeg'
+ has_r128_support = True
+
+ def test_backend(self):
+ """Check whether the backend actually has all required functionality.
+ """
+ pass
class ReplayGainCliTestBase(TestHelper):
def setUp(self):
+ # Implemented by Mixins, see above. This may decide to skip the test.
+ self.test_backend()
+
self.setup_beets(disk=True)
self.config['replaygain']['backend'] = self.backend
@@ -58,25 +98,20 @@ class ReplayGainCliTestBase(TestHelper):
self.teardown_beets()
self.unload_plugins()
- album = self.add_album_fixture(2)
+ def _add_album(self, *args, **kwargs):
+ album = self.add_album_fixture(*args, **kwargs)
for item in album.items():
reset_replaygain(item)
+ return album
+
def tearDown(self):
self.teardown_beets()
self.unload_plugins()
- def _reset_replaygain(self, item):
- item['rg_track_peak'] = None
- item['rg_track_gain'] = None
- item['rg_album_peak'] = None
- item['rg_album_gain'] = None
- item['r128_track_gain'] = None
- item['r128_album_gain'] = None
- item.write()
- item.store()
-
def test_cli_saves_track_gain(self):
+ self._add_album(2)
+
for item in self.lib.items():
self.assertIsNone(item.rg_track_peak)
self.assertIsNone(item.rg_track_gain)
@@ -102,15 +137,85 @@ class ReplayGainCliTestBase(TestHelper):
mediafile.rg_track_gain, item.rg_track_gain, places=2)
def test_cli_skips_calculated_tracks(self):
+ album_rg = self._add_album(1)
+ item_rg = album_rg.items()[0]
+
+ if self.has_r128_support:
+ album_r128 = self._add_album(1, ext="opus")
+ item_r128 = album_r128.items()[0]
+
self.run_command('replaygain')
- item = self.lib.items()[0]
- peak = item.rg_track_peak
- item.rg_track_gain = 0.0
+
+ item_rg.load()
+ self.assertIsNotNone(item_rg.rg_track_gain)
+ self.assertIsNotNone(item_rg.rg_track_peak)
+ self.assertIsNone(item_rg.r128_track_gain)
+
+ item_rg.rg_track_gain += 1.0
+ item_rg.rg_track_peak += 1.0
+ item_rg.store()
+ rg_track_gain = item_rg.rg_track_gain
+ rg_track_peak = item_rg.rg_track_peak
+
+ if self.has_r128_support:
+ item_r128.load()
+ self.assertIsNotNone(item_r128.r128_track_gain)
+ self.assertIsNone(item_r128.rg_track_gain)
+ self.assertIsNone(item_r128.rg_track_peak)
+
+ item_r128.r128_track_gain += 1.0
+ item_r128.store()
+ r128_track_gain = item_r128.r128_track_gain
+
self.run_command('replaygain')
- self.assertEqual(item.rg_track_gain, 0.0)
- self.assertEqual(item.rg_track_peak, peak)
+
+ item_rg.load()
+ self.assertEqual(item_rg.rg_track_gain, rg_track_gain)
+ self.assertEqual(item_rg.rg_track_peak, rg_track_peak)
+
+ if self.has_r128_support:
+ item_r128.load()
+ self.assertEqual(item_r128.r128_track_gain, r128_track_gain)
+
+ def test_cli_does_not_skip_wrong_tag_type(self):
+ """Check that items that have tags of the wrong type won't be skipped.
+ """
+ if not self.has_r128_support:
+ # This test is a lot less interesting if the backend cannot write
+ # both tag types.
+ self.skipTest("r128 tags for opus not supported on backend {}"
+ .format(self.backend))
+
+ album_rg = self._add_album(1)
+ item_rg = album_rg.items()[0]
+
+ album_r128 = self._add_album(1, ext="opus")
+ item_r128 = album_r128.items()[0]
+
+ item_rg.r128_track_gain = 0.0
+ item_rg.store()
+
+ item_r128.rg_track_gain = 0.0
+ item_r128.rg_track_peak = 42.0
+ item_r128.store()
+
+ self.run_command('replaygain')
+ item_rg.load()
+ item_r128.load()
+
+ self.assertIsNotNone(item_rg.rg_track_gain)
+ self.assertIsNotNone(item_rg.rg_track_peak)
+ # FIXME: Should the plugin null this field?
+ # self.assertIsNone(item_rg.r128_track_gain)
+
+ self.assertIsNotNone(item_r128.r128_track_gain)
+ # FIXME: Should the plugin null these fields?
+ # self.assertIsNone(item_r128.rg_track_gain)
+ # self.assertIsNone(item_r128.rg_track_peak)
def test_cli_saves_album_gain_to_file(self):
+ self._add_album(2)
+
for item in self.lib.items():
mediafile = MediaFile(item.path)
self.assertIsNone(mediafile.rg_album_peak)
@@ -133,13 +238,11 @@ class ReplayGainCliTestBase(TestHelper):
self.assertNotEqual(max(peaks), 0.0)
def test_cli_writes_only_r128_tags(self):
- if self.backend == "command":
- # opus not supported by command backend
- return
+ if not self.has_r128_support:
+ self.skipTest("r128 tags for opus not supported on backend {}"
+ .format(self.backend))
- album = self.add_album_fixture(2, ext="opus")
- for item in album.items():
- self._reset_replaygain(item)
+ album = self._add_album(2, ext="opus")
self.run_command('replaygain', '-a')
@@ -152,51 +255,138 @@ class ReplayGainCliTestBase(TestHelper):
self.assertIsNotNone(mediafile.r128_track_gain)
self.assertIsNotNone(mediafile.r128_album_gain)
- def test_target_level_has_effect(self):
- item = self.lib.items()[0]
+ def test_targetlevel_has_effect(self):
+ album = self._add_album(1)
+ item = album.items()[0]
def analyse(target_level):
self.config['replaygain']['targetlevel'] = target_level
- self._reset_replaygain(item)
self.run_command('replaygain', '-f')
- mediafile = MediaFile(item.path)
- return mediafile.rg_track_gain
+ item.load()
+ return item.rg_track_gain
gain_relative_to_84 = analyse(84)
gain_relative_to_89 = analyse(89)
- # check that second calculation did work
- if gain_relative_to_84 is not None:
- self.assertIsNotNone(gain_relative_to_89)
+ self.assertNotEqual(gain_relative_to_84, gain_relative_to_89)
+
+ def test_r128_targetlevel_has_effect(self):
+ if not self.has_r128_support:
+ self.skipTest("r128 tags for opus not supported on backend {}"
+ .format(self.backend))
+
+ album = self._add_album(1, ext="opus")
+ item = album.items()[0]
+
+ def analyse(target_level):
+ self.config['replaygain']['r128_targetlevel'] = target_level
+ self.run_command('replaygain', '-f')
+ item.load()
+ return item.r128_track_gain
+
+ gain_relative_to_84 = analyse(84)
+ gain_relative_to_89 = analyse(89)
self.assertNotEqual(gain_relative_to_84, gain_relative_to_89)
+ def test_per_disc(self):
+ # Use the per_disc option and add a little more concurrency.
+ album = self._add_album(track_count=4, disc_count=3)
+ self.config['replaygain']['per_disc'] = True
+ self.run_command('replaygain', '-a')
+
+ # FIXME: Add fixtures with known track/album gain (within a suitable
+ # tolerance) so that we can actually check per-disc operation here.
+ for item in album.items():
+ self.assertIsNotNone(item.rg_track_gain)
+ self.assertIsNotNone(item.rg_album_gain)
+
@unittest.skipIf(not GST_AVAILABLE, 'gstreamer cannot be found')
-class ReplayGainGstCliTest(ReplayGainCliTestBase, unittest.TestCase):
- backend = 'gstreamer'
-
- def setUp(self):
- try:
- # Check if required plugins can be loaded by instantiating a
- # GStreamerBackend (via its .__init__).
- config['replaygain']['targetlevel'] = 89
- GStreamerBackend(config['replaygain'], None)
- except FatalGstreamerPluginReplayGainError as e:
- # Skip the test if plugins could not be loaded.
- self.skipTest(str(e))
-
- super().setUp()
+class ReplayGainGstCliTest(ReplayGainCliTestBase, unittest.TestCase,
+ GstBackendMixin):
+ pass
@unittest.skipIf(not GAIN_PROG_AVAILABLE, 'no *gain command found')
-class ReplayGainCmdCliTest(ReplayGainCliTestBase, unittest.TestCase):
- backend = 'command'
+class ReplayGainCmdCliTest(ReplayGainCliTestBase, unittest.TestCase,
+ CmdBackendMixin):
+ pass
@unittest.skipIf(not FFMPEG_AVAILABLE, 'ffmpeg cannot be found')
-class ReplayGainFfmpegTest(ReplayGainCliTestBase, unittest.TestCase):
- backend = 'ffmpeg'
+class ReplayGainFfmpegCliTest(ReplayGainCliTestBase, unittest.TestCase,
+ FfmpegBackendMixin):
+ pass
+
+
+class ImportTest(TestHelper):
+ threaded = False
+
+ def setUp(self):
+ # Implemented by Mixins, see above. This may decide to skip the test.
+ self.test_backend()
+
+ self.setup_beets(disk=True)
+ self.config['threaded'] = self.threaded
+ self.config['replaygain'] = {
+ 'backend': self.backend,
+ }
+
+ try:
+ self.load_plugins('replaygain')
+ except Exception:
+ import sys
+ # store exception info so an error in teardown does not swallow it
+ exc_info = sys.exc_info()
+ try:
+ self.teardown_beets()
+ self.unload_plugins()
+ except Exception:
+ # if load_plugins() failed then setup is incomplete and
+ # teardown operations may fail. In particular # {Item,Album}
+ # may not have the _original_types attribute in unload_plugins
+ pass
+ raise None.with_traceback(exc_info[2])
+
+ self.importer = self.create_importer()
+
+ def tearDown(self):
+ self.unload_plugins()
+ self.teardown_beets()
+
+ def test_import_converted(self):
+ self.importer.run()
+ for item in self.lib.items():
+ # FIXME: Add fixtures with known track/album gain (within a
+ # suitable tolerance) so that we can actually check correct
+ # operation here.
+ self.assertIsNotNone(item.rg_track_gain)
+ self.assertIsNotNone(item.rg_album_gain)
+
+
+@unittest.skipIf(not GST_AVAILABLE, 'gstreamer cannot be found')
+class ReplayGainGstImportTest(ImportTest, unittest.TestCase,
+ GstBackendMixin):
+ pass
+
+
+@unittest.skipIf(not GAIN_PROG_AVAILABLE, 'no *gain command found')
+class ReplayGainCmdImportTest(ImportTest, unittest.TestCase,
+ CmdBackendMixin):
+ pass
+
+
+@unittest.skipIf(not FFMPEG_AVAILABLE, 'ffmpeg cannot be found')
+class ReplayGainFfmpegImportTest(ImportTest, unittest.TestCase,
+ FfmpegBackendMixin):
+ pass
+
+
+@unittest.skipIf(not FFMPEG_AVAILABLE, 'ffmpeg cannot be found')
+class ReplayGainFfmpegThreadedImportTest(ImportTest, unittest.TestCase,
+ FfmpegBackendMixin):
+ threaded = True
def suite():