Skip to content

library: skip saving files with unchanged tags#6834

Open
eyupcanakman wants to merge 8 commits into
beetbox:masterfrom
eyupcanakman:fix/write-only-when-tags-change
Open

library: skip saving files with unchanged tags#6834
eyupcanakman wants to merge 8 commits into
beetbox:masterfrom
eyupcanakman:fix/write-only-when-tags-change

Conversation

@eyupcanakman

@eyupcanakman eyupcanakman commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Description

Fixes #6529.

Item.write() called mediafile.save() whether or not the tags it was about to write differed from the ones already in the file. beet modify --write on a field that only the database keeps, data_source for instance, gave the file a new modification time without changing a single tag, and tools that sync the library copied it again for nothing.

The tags the write is about to touch are now read back from the file, compared with the ones already there, and the save is skipped when they match.

Comparing what the file reads back, rather than what the database holds, keeps a file that cannot store a value exactly, such as one carrying a replaygain peak, from looking changed on every write. Images stay out of the comparison, since a write that carries one replaces the whole list of them.

beet write --force still rewrites. So does a file written with the id3v23 option, and so does one whose tags still need converting to the default ID3v2.4, since saving is what converts them either way.

To Do

  • Documentation. (The modify command, the id3v23 option, the mtime policy in the library docs, the write and after_write events, and the importadded plugin.)
  • Changelog
  • Tests

@eyupcanakman
eyupcanakman requested a review from a team as a code owner July 13, 2026 21:59
Copilot AI review requested due to automatic review settings July 13, 2026 21:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

grug see PR try stop useless file save when tags not really change. this help library sync tools not re-copy big files just because mtime bump, and make modify --write not touch disk when only DB-only field change.

Changes:

  • Item.write now read relevant tags from file and skip mediafile.save() when file already hold same values (unless force, id3v23, or images involved).
  • CLI beet write --force now plumb to Item.try_sync(..., force_write=True) so force still rewrite.
  • add tests and docs/changelog notes for new “skip save” behavior and event semantics.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
beets/library/models.py implement tag readback + compare in Item.write(), add force/force_write plumbing
beets/ui/commands/write.py pass CLI --force through to force writing even when unchanged
test/test_library.py add regression tests for “no write when same tags”, list-empty removal, unreadable image case
test/ui/commands/test_modify.py add regression test that DB-only field change does not change file mtime
docs/reference/config.rst document id3v23 implies always-saving behavior
docs/reference/cli.rst document modify does not touch file mtime for DB-only changes
docs/dev/plugins/events.rst update write event semantics (but one doc mismatch noted in comment)
docs/dev/library.rst clarify mtime policy when Item.write() skips saving
docs/changelog.rst add bugfix note + plugin-dev note about new behavior and force

Comment thread docs/dev/plugins/events.rst Outdated
Comment on lines +114 to +118
:Description: Called before beets decides whether to write a file's
metadata to disk. Handlers may modify ``tags`` or raise
``library.FileOperationError`` to abort. ``after_write`` is skipped
when the file being written is the item's own and already holds the
tags.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f0ff492192

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread beets/library/models.py
Comment on lines +983 to +989
compare = (
not force
# Saving is what converts the tags to ID3v2.3, and the conversion
# loses what the older version cannot hold, so a file written that
# way is saved every time. `MediaFile` sets this for MP3s only.
and not mediafile.id3v23
and path == self.path

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't skip MP3 ID3v2.4 conversions

When the config is left at the default id3v23: false, an MP3 that currently has ID3v2.3 tags is opened with its tags translated to v2.4 in memory, but save() is still the step that writes that v2.4 tag back to disk. With this condition, if the tag values already match the database, Item.write() returns before saving, so beet write no longer converts existing v2.3 files to beets' default v2.4 output unless the user happens to pass --force. The same conversion exception added for requested v2.3 writes needs to account for older MP3s being upgraded to v2.4 as well.

Useful? React with 👍 / 👎.

@snejus

snejus commented Jul 14, 2026

Copy link
Copy Markdown
Member

Check the CI failure and address existing comments, and I'll review after that!

Item.write() saved the file whether or not the tags it writes would change
anything, so `beet modify --write` gave a file a new modification time even
when the field that changed was one only the database keeps, such as
data_source.

Compare the tags read back from the file with the ones about to be written,
and skip the save when they match. The images are left out of the comparison,
since a write that carries one replaces the whole list of them.

A file written as ID3v2.3 is still saved every time, since saving is what
converts its tags.

Fixes beetbox#6529.
Reading an MP3 translates its tags to ID3v2.4 in memory, so a v2.3 file
already holding the values to be written read back as unchanged, and
the write skipped the save that would have converted it on disk. Check
the on-disk tag version too, and skip the save only when no conversion
is pending.

Also cover the `write -f` wiring, the `id3v23` branch, and the
`after_write` event with tests, list `path` among the `after_write`
event parameters, which the event has been sending all along, and
format the docs the way `docstrfmt` wants them.
A list tag whose values are all empty strings read back as no tag at
all, the same as an absent one, so removing the field compared as
unchanged and the file kept the tag. Only an empty list reads as no
tag now. Empty values keep counting as values.
The tag comparison only applies to the item's own file. A write to any
other path saves whether or not that file holds the tags, and no test
held that in place.
`preserve_write_mtimes` pins a file's mtime from the `after_write`
event, which a write that finds the tags already in the file no longer
sends.
The skipped save recorded the file's mtime as of after the comparison,
so a change landing in between looked already written and a later
update would not read it. Record the mtime from before the tags are
read instead, which leaves any later change newer than the database.
Also cover the race above with a test, register the event listener the
way the other plugin tests do, and fix a test comment that described
the image guard rather than the field selection the test proves.
@eyupcanakman
eyupcanakman force-pushed the fix/write-only-when-tags-change branch from f0ff492 to 1bfbb22 Compare July 16, 2026 16:44
@codecov

codecov Bot commented Jul 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 75.65%. Comparing base (311662b) to head (0635643).
⚠️ Report is 3 commits behind head on master.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #6834      +/-   ##
==========================================
+ Coverage   75.63%   75.65%   +0.02%     
==========================================
  Files         163      163              
  Lines       21312    21333      +21     
  Branches     3360     3364       +4     
==========================================
+ Hits        16119    16140      +21     
  Misses       4401     4401              
  Partials      792      792              
Files with missing lines Coverage Δ
beets/library/models.py 87.74% <100.00%> (+0.39%) ⬆️
beets/ui/commands/write.py 76.00% <100.00%> (ø)
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@eyupcanakman

Copy link
Copy Markdown
Contributor Author

Rebased onto master and the docs check is fixed. Both bot comments were right, fixed in the new commits along with two more cases they led me to.

The line ran a few characters short of the width the formatter uses,
which the docs check caught.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Leave files untouched when changes only affect the database

3 participants