Contribution Number: 1
Student: Samad Ballaj (@SamadBallaj1)
Issue: beetbox/beets #1203 - replaygain: metaflac backend
Fork: https://github.com/SamadBallaj1/beets
Status: Phase IV Complete
beets is a music library tool I can actually read, and it's Python, which is what I work in. The replaygain plugin already has a few backends (command, gstreamer, audiotools, ffmpeg), so adding a metaflac one means following a pattern that's already in the file instead of inventing something new. Good size for a first PR.
I also want to learn how a real plugin system is structured. The maintainer left a note on the issue about the right approach, so I have somewhere to start and someone to check my plan against.
The replaygain plugin computes ReplayGain values through a backend. Right now there's no metaflac backend, so people who only have metaflac available (for example on a NAS where the other tools won't install) can't use the plugin for their FLAC files.
You can set replaygain.backend: metaflac and beets uses metaflac for FLAC files. Concretely, done looks like:
- a metaflac backend that's selectable in config
- it reads or writes ReplayGain tags on FLAC through metaflac
- it fails with a clear message if metaflac or the needed flag isn't there
- a test covers it, matching the existing backend tests
There is no metaflac option. The plugin supports command, gstreamer, audiotools, and ffmpeg only.
beetsplug/replaygain.py- the plugin and its backend classes. A new backend would follow the existing backend structure there.- The plugin's tests.
The maintainer suggested updating the plugin now that the backends are extensible, and failing gracefully if the flag isn't there. There's also a related discussion on how the backends behave.
- Forked beets, added
beetbox/beetsas upstream, made a branchfix-issue-1203. - beets uses poetry (it's in
CONTRIBUTING.rst). Installed it withuv tool install poetry poethepoet. - My Python 3.14 was too new, so I used 3.12.9, then ran
poetry install. - I have ffmpeg but not metaflac yet. Enough to reproduce this.
-
In a beets config, turn on the replaygain plugin and set the backend to metaflac:
plugins: replaygain replaygain: backend: metaflac
-
Run
beet ls.
Expected: beets uses metaflac for FLAC files.
Actual: it errors out:
UserError: Selected ReplayGain backend metaflac is not supported. Please select one of: command, gstreamer, audiotools, ffmpeg
Got the same error every time.
- Commit showing reproduction: it's a missing feature, not a code bug, so there's nothing to commit yet. The config and error above are the proof. Working branch: fix-issue-1203.
- Screenshots/logs: the
UserErroroutput above is the log. It's a command-line error, so there's no UI to screenshot. - My findings: the plugin checks the backend name against a fixed list and rejects anything that isn't in it. metaflac just isn't there.
The backends live in a fixed list, BACKEND_CLASSES, in beetsplug/replaygain.py, and ReplayGainPlugin errors if your backend name isn't in it. There's just no MetaflacBackend, so metaflac isn't an option. It's not a bug, just a missing backend.
Add a MetaflacBackend modeled on the existing CommandBackend (both run an external tool), and register it. It'd call metaflac --scan-replay-gain to read the values without changing the files, then hand them to beets.
Using UMPIRE framework (adapted):
Understand: no metaflac backend exists, so backend: metaflac errors out. People who only have metaflac can't use the plugin on FLAC.
Match: copy the pattern from CommandBackend. Backend sets the two methods to fill in, compute_track_gain and compute_album_gain. git log shows the ffmpeg backend was added the same way back in 2018 (c3af5b3), so this is a well-worn path.
Plan:
- Add a
MetaflacBackendtobeetsplug/replaygain.pyand register it inBACKEND_CLASSES. - Fill in
compute_track_gainandcompute_album_gainusingmetaflac --scan-replay-gain. - Add a test in
test/plugins/test_replaygain.py(its mixin needstest_backendor it won't run), and updatedocs/plugins/replaygain.rstanddocs/changelog.rst.
Implement: not written yet, goes on the fix-issue-1203 branch.
Review: run poe lint, keep it small, follow CONTRIBUTING.rst.
Evaluate: the new test and the existing ones pass, and beet replaygain works on a real FLAC. One thing to watch: metaflac scanning wants all the files at the same sample rate and channels, and only mono or stereo.
-
test_metaflac_backend_parses_replaygain_tagsfeeds the backend a sample of metaflac'sNAME=VALUEoutput and checks it pulls out the gain (-11.55 dBbecomes-11.55) and the peak. It runs even when metaflac isn't installed, so the parsing is always covered.
-
TestReplayGainMetaflacClireuses the same CLI test class the other backends use, pointed at thewhitenoise.flacfixture. It runsbeet replaygainfor real through metaflac and checks the track and album gains get written and read back. 7 of these pass; the 4 that skip are the Opus (R128) cases, which metaflac doesn't handle.
Before I wrote the parser I ran metaflac --add-replay-gain and then --show-tag on a copy of the FLAC fixture to see the exact output (REPLAYGAIN_TRACK_GAIN=-11.55 dB). Once the code was in, poe test test/plugins/test_replaygain.py gave 7 passed, 16 skipped (the skips are gstreamer, ffmpeg, and mp3gain, none of which are installed on my machine). ruff check, ruff format, and mypy all come back clean.
I added a MetaflacBackend to beetsplug/replaygain.py and registered it in BACKEND_CLASSES, so you can now pick it with backend: metaflac. It shells out to metaflac --add-replay-gain to compute the gain, then reads the values back with metaflac --show-tag. I modeled it on the existing CommandBackend, since both wrap an external command-line tool.
The backend only handles FLAC and skips anything else. For a single track it scans the file on its own; for an album it hands the whole set to metaflac in one call, which is how metaflac works out the album gain.
Two things tripped me up. First, metaflac writes the ReplayGain tags into the FLAC itself, unlike the command and ffmpeg backends that just print numbers, so I had to run it and then read the tags back with --show-tag instead of parsing one command's output. Second, metaflac always targets an 89 dB reference, so my gains came out slightly off when I tried a non-default targetlevel. I fixed that by adding target_level - 89, the same adjustment the command backend makes, and test_targetlevel_has_effect passes now.
- Files modified:
beetsplug/replaygain.py(the newMetaflacBackend, registered inBACKEND_CLASSES)test/plugins/test_replaygain.py(aMetaflacBackendMixin, theTestReplayGainMetaflacCliclass, and a parsing unit test)docs/plugins/replaygain.rstanddocs/changelog.rst(docs and a changelog line)
- Key commits:
- Branch: https://github.com/SamadBallaj1/beets/tree/fix-issue-1203
- Approach decisions: I reused the project's existing backend test harness instead of writing a new one. metaflac's known limits (FLAC only, and every file in an album needs the same sample rate and channel layout) I left in place and documented in the plugin docs rather than working around them. I also made the tag reader turn any bad metaflac output into a skip-this-file error instead of letting it crash the whole run, the same way the other backends behave.
PR Link: beetbox/beets#6800
PR Description: Adds a metaflac backend to the replaygain plugin (issue #1203). It runs metaflac --add-replay-gain to compute the gain and reads it back with metaflac --show-tag, modeled on the existing command backend. FLAC only.
Maintainer Feedback:
- None yet. The PR is in review (auto-requested from the beets maintainers). I'll log feedback and my responses here as it comes in.
Status: Awaiting review
I learned how beets' replaygain backends are built: you subclass Backend, fill in compute_track_gain and compute_album_gain, and register the class. I also got more comfortable calling a CLI tool from Python and reading its output back, and writing tests against the project's own test harness.
The part that threw me was that metaflac writes the ReplayGain tags into the file instead of printing them, so I had to run it and then read the tags back. After I rebased, beets had just released 2.12.0, so my changelog line landed under the released section and CI caught it. I moved it under Unreleased.
Rebase onto upstream more often instead of once at the end. My only real conflict was the changelog, and it only happened because beets shipped 2.12.0 while my PR was open, so my line ended up in a section that had already been released. The beets PR template actually says to add the changelog entry only once review is nearly done, since that file conflicts more than any other, and I get why now. Next time I'll keep that entry under Unreleased and add it late, and treat any busy shared file as the last thing I touch, not the first. The bigger takeaway for me: on an active project the base branch keeps moving while you work, so small frequent rebases beat one big catch-up at the end.
- beets
CONTRIBUTING.rstand the existing backends inbeetsplug/replaygain.py - metaflac docs: https://xiph.org/flac/documentation_tools_metaflac.html
- The issue and discussion: beetbox/beets#1203