From 8334137d078ff2e060e017e77f56a4c8fd542957 Mon Sep 17 00:00:00 2001 From: Tommy Schnabel-Jones Date: Mon, 8 Jun 2026 13:53:09 -0400 Subject: [PATCH 1/4] Add track-level duplicate resolution to the importer Move the import-time duplicate-track handling out of the `duplicates` plugin and into importer core, next to the existing duplicate machinery. Add the `import.duplicate_track_resolution` option (off by default). When enabled, album imports check each track against the library using `import.duplicate_keys.item` and resolve matches via the existing `import.duplicate_action`: - `skip` drops the already-imported tracks and imports the rest of the album (a fully-duplicate album is skipped); - `remove` removes the matching old library items; - `keep`/`merge` import everything unchanged; - `ask` prompts the session. The check runs before the autotag lookup so dropped tracks are excluded from the match. The `duplicates` plugin is left untouched. Co-Authored-By: Claude Opus 4.8 --- beets/config_default.yaml | 1 + beets/importer/session.py | 11 +++ beets/importer/stages.py | 77 +++++++++++++++++++ beets/importer/tasks.py | 51 ++++++++----- beets/test/helper.py | 12 +++ beets/ui/commands/import_/session.py | 22 ++++++ docs/changelog.rst | 5 ++ docs/reference/config.rst | 30 ++++++++ test/test_importer.py | 110 +++++++++++++++++++++++++++ 9 files changed, 301 insertions(+), 18 deletions(-) diff --git a/beets/config_default.yaml b/beets/config_default.yaml index 8df7f61e61..dd4e25f84d 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -52,6 +52,7 @@ import: item: artist title duplicate_action: ask duplicate_verbose_prompt: no + duplicate_track_resolution: no bell: no set_fields: {} ignored_alias_types: [] diff --git a/beets/importer/session.py b/beets/importer/session.py index 328de6ea1d..8813c3123b 100644 --- a/beets/importer/session.py +++ b/beets/importer/session.py @@ -183,6 +183,13 @@ def choose_match(self, task: ImportTask): def resolve_duplicate(self, task: ImportTask, found_duplicates): raise NotImplementedError + def resolve_track_duplicates(self, task: ImportTask, duplicates) -> str: + """Decide what to do with album tracks that already exist in the + library. Return ``"s"`` (skip the duplicate tracks), ``"k"`` (keep + all) or ``"r"`` (remove the old items). + """ + raise NotImplementedError + def choose_item(self, task: ImportTask): raise NotImplementedError @@ -205,6 +212,10 @@ def run(self): # Split directory tasks into one task for each album. stages += [stagefuncs.group_albums(self)] + # Optionally drop or replace album tracks that already exist in + # the library before the autotag lookup runs. + stages += [stagefuncs.resolve_track_duplicates(self)] + # These stages either talk to the user to get a decision or, # in the case of a non-autotagged import, just choose to # import everything as-is. In *both* cases, these stages diff --git a/beets/importer/stages.py b/beets/importer/stages.py index 820fb312e4..c856641238 100644 --- a/beets/importer/stages.py +++ b/beets/importer/stages.py @@ -127,6 +127,70 @@ def group(item): task = pipeline.multiple(tasks) +@pipeline.mutator_stage +def resolve_track_duplicates(session: ImportSession, task: ImportTask): + """Resolve tracks of an album that already exist in the library. + + When ``import.duplicate_track_resolution`` is enabled, each item of an + album import is checked against the library using + ``import.duplicate_keys.item``. Matched tracks are resolved according to + ``import.duplicate_action``: + + * ``skip`` drops the duplicate tracks and imports the rest of the album + (if every track is a duplicate, the whole album is skipped); + * ``remove`` removes the matching old library items; + * ``keep`` (and ``merge``) import everything as-is; + * ``ask`` prompts the session for one of the above. + + This runs before :func:`lookup_candidates` so that dropped tracks are + excluded from the autotag match. Singleton imports are handled by the + regular duplicate resolution and are ignored here. + """ + if ( + task.skip + or not task.is_album + or not task.items + or not config["import"]["duplicate_track_resolution"].get(bool) + ): + return + + keys = config["import"]["duplicate_keys"]["item"].as_str_seq() + if not keys: + return + + # Map each incoming item to the existing library items it duplicates. + duplicates: dict[library.Item, list[library.Item]] = {} + for item in task.items: + if not any(item.get(k) for k in keys): + continue + matches = _find_track_duplicates(session.lib, item, keys) + if matches: + duplicates[item] = matches + + if not duplicates: + return + + action = config["import"]["duplicate_action"].as_choice( + {"skip": "s", "keep": "k", "remove": "r", "merge": "m", "ask": "a"} + ) + if action == "a": + action = session.resolve_track_duplicates(task, duplicates) + + if action == "s": + for item in duplicates: + log.info( + "skipping duplicate track: {}", displayable_path(item.path) + ) + task.items.remove(item) + if not task.items: + task.set_choice(Action.SKIP) + elif action == "r": + for matches in duplicates.values(): + task.duplicate_track_items_to_remove.extend(matches) + # "k" (keep) and "m" (merge) leave the incoming tracks untouched; whole + # album duplicates are still handled by the regular resolution stage. + + @pipeline.mutator_stage def lookup_candidates(session: ImportSession, task: ImportTask): """A coroutine for performing the initial MusicBrainz lookup for an @@ -283,6 +347,9 @@ def manipulate_files(session: ImportSession, task: ImportTask): if task.should_remove_duplicates: task.remove_duplicates(session.lib) + if task.duplicate_track_items_to_remove: + task.remove_duplicate_track_items(session.lib) + if session.config["move"]: operation = MoveOperation.MOVE elif session.config["copy"]: @@ -333,6 +400,16 @@ def _apply_choice(session: ImportSession, task: ImportTask): task.set_fields(session.lib) +def _find_track_duplicates( + lib: library.Library, item: library.Item, keys: list[str] +) -> list[library.Item]: + """Return library items matching `item` on all `keys`, excluding the + item itself (so re-imports do not match their own files). + """ + query = item.duplicates_query(keys) + return [other for other in lib.items(query) if other.path != item.path] + + def _resolve_duplicates(session: ImportSession, task: ImportTask): """Check if a task conflicts with items or albums already imported and ask the session to resolve this. diff --git a/beets/importer/tasks.py b/beets/importer/tasks.py index 976331aaf7..50cc6ef7f4 100644 --- a/beets/importer/tasks.py +++ b/beets/importer/tasks.py @@ -72,6 +72,23 @@ log = logging.getLogger("beets") +def _remove_duplicate_item( + lib: library.Library, item: library.Item, with_album: bool = True +): + """Remove ``item`` from ``lib`` and delete its file when it lives inside + the library directory, pruning any newly-empty parent directories. + """ + item.remove(with_album=with_album) + if lib.directory in util.ancestry(item.path): + log.debug("deleting duplicate {.filepath}", item) + util.remove(item.path) + util.prune_dirs( + os.path.dirname(item.path), + lib.directory, + clutter=config["clutter"].as_str_seq(), + ) + + class ImportAbortError(Exception): """Raised when the user aborts the tagging operation.""" @@ -177,6 +194,9 @@ def __init__( super().__init__(toppath, paths, items) self.should_remove_duplicates = False self.should_merge_duplicates = False + # Existing library items to remove because individual tracks of this + # album duplicate them (see ``duplicate_track_resolution``). + self.duplicate_track_items_to_remove: list[library.Item] = [] self.is_album = True def set_choice(self, choice: Action | AlbumMatch | TrackMatch): @@ -272,15 +292,7 @@ def remove_duplicates(self, lib: library.Library): artpath = album.artpath for item in album.items(): - item.remove(with_album=False) - if lib.directory in util.ancestry(item.path): - log.debug("deleting duplicate {.filepath}", item) - util.remove(item.path) - util.prune_dirs( - os.path.dirname(item.path), - lib.directory, - clutter=config["clutter"].as_str_seq(), - ) + _remove_duplicate_item(lib, item, with_album=False) album.remove(with_items=False) @@ -293,6 +305,17 @@ def remove_duplicates(self, lib: library.Library): clutter=config["clutter"].as_str_seq(), ) + def remove_duplicate_track_items(self, lib: library.Library): + """Remove the old library items that individual tracks of this album + duplicate, as recorded in ``duplicate_track_items_to_remove``. + """ + seen: set[int] = set() + for item in self.duplicate_track_items_to_remove: + if item.id in seen: + continue + seen.add(item.id) + _remove_duplicate_item(lib, item) + def set_fields(self, lib: library.Library): """Sets the fields given at CLI or configuration to the specified values, for both the album and all its items. @@ -731,15 +754,7 @@ def remove_duplicates(self, lib: library.Library): duplicate_items = self.find_duplicates(lib) log.debug("removing {} old duplicated items", len(duplicate_items)) for item in duplicate_items: - item.remove() - if lib.directory in util.ancestry(item.path): - log.debug("deleting duplicate {.filepath}", item) - util.remove(item.path) - util.prune_dirs( - os.path.dirname(item.path), - lib.directory, - clutter=config["clutter"].as_str_seq(), - ) + _remove_duplicate_item(lib, item) def add(self, lib): with lib.transaction(): diff --git a/beets/test/helper.py b/beets/test/helper.py index b676ac450b..9478d76e96 100644 --- a/beets/test/helper.py +++ b/beets/test/helper.py @@ -669,6 +669,18 @@ def resolve_duplicate(self, task, found_duplicates): elif res == self.Resolution.MERGE: task.should_merge_duplicates = True + def resolve_track_duplicates(self, task, duplicates): + try: + res = self._resolutions.pop(0) + except IndexError: + res = self.default_resolution + + return { + self.Resolution.SKIP: "s", + self.Resolution.KEEPBOTH: "k", + self.Resolution.REMOVE: "r", + }.get(res, "k") + class TerminalImportSessionFixture(TerminalImportSession): def __init__(self, *args, **kwargs): diff --git a/beets/ui/commands/import_/session.py b/beets/ui/commands/import_/session.py index 007715b2f3..79ccbeeb43 100644 --- a/beets/ui/commands/import_/session.py +++ b/beets/ui/commands/import_/session.py @@ -204,6 +204,28 @@ def resolve_duplicate(self, task, found_duplicates): else: assert False + def resolve_track_duplicates(self, task, duplicates) -> str: + """Decide what to do with album tracks already in the library.""" + log.warning("Some tracks are already in the library!") + + if config["import"]["quiet"]: + # In quiet mode, don't prompt -- just skip the duplicate tracks. + log.info("Skipping duplicate tracks.") + return "s" + + existing = [item for matches in duplicates.values() for item in matches] + ui.print_("Old: " + summarize_items(existing, True)) + if config["import"]["duplicate_verbose_prompt"]: + for item in existing: + print(f" {item}") + + ui.print_("New: " + summarize_items(list(duplicates), True)) + if config["import"]["duplicate_verbose_prompt"]: + for item in duplicates: + print(f" {item}") + + return ui.input_options(("Skip dupes", "Keep all", "Remove old")) + def should_resume(self, path): return ui.input_yn( f"Import of the directory:\n{displayable_path(path)}\n" diff --git a/docs/changelog.rst b/docs/changelog.rst index 23812a7b7d..c269f98892 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -27,6 +27,11 @@ New features :conf:`plugins.musicbrainz:aliases_as_credits` to make aliases-as-artist-credit optional. - :doc:`plugins/badfiles`: Added settings for auto error and warning actions. +- Add the :ref:`duplicate_track_resolution` import option, which checks each + track of an album import against the library's singleton items (using the + :ref:`duplicate_keys` ``item`` fields) and resolves matches via + :ref:`duplicate_action`. With ``skip`` this drops already-imported tracks and + imports the rest of the album. Disabled by default. Bug fixes ~~~~~~~~~ diff --git a/docs/reference/config.rst b/docs/reference/config.rst index bc0a0b706a..6f3d5cb596 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -842,6 +842,36 @@ is applied, which would, considering the default, look like this: Default: ``no``. +.. _duplicate_track_resolution: + +duplicate_track_resolution +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When enabled, album imports also check each *individual track* against the +library, using the same fields as :ref:`duplicate_keys` ``item`` (by default +``artist`` and ``title``; set it to e.g. ``mb_trackid`` to match on the +MusicBrainz track ID). Tracks already in the library are resolved according to +:ref:`duplicate_action`: + +- ``skip`` drops the duplicate tracks and imports the rest of the album. If + every track is already present, the whole album is skipped. +- ``remove`` removes the matching old items from the library before importing. +- ``keep`` (and ``merge``) import the album unchanged. +- ``ask`` prompts you to choose one of the above. + +This complements the album-level duplicate check (which matches whole albums on +:ref:`duplicate_keys` ``album``): it catches the case where some loose tracks of +an album are already in your library as singletons. + +.. note:: + + Only **singleton** library items (tracks not attached to an album) are + considered. Tracks that already belong to an album in your library are not + matched, so importing a fuller version of an album you already have will not + deduplicate against that existing album's tracks. + +Default: ``no``. + .. _bell: bell diff --git a/test/test_importer.py b/test/test_importer.py index 51f44e4602..b6e574faca 100644 --- a/test/test_importer.py +++ b/test/test_importer.py @@ -48,6 +48,7 @@ AutotagStub, BeetsTestCase, ImportHelper, + ImportSessionFixture, PluginMixin, TestHelper, has_program, @@ -1304,6 +1305,115 @@ def add_item_fixture(self, **kwargs): return item +class ImportTrackDuplicateResolutionTest(ImportHelper, BeetsTestCase): + """``import.duplicate_track_resolution``: per-track dedup on album import. + + The imported album has two tracks (``Tag Track 1`` and ``Tag Track 2``); + tests seed the library with singletons matching one or both of them. + """ + + def setUp(self): + super().setUp() + self.prepare_album_for_import(2) + + def add_item_fixture(self, **kwargs): + item = self.add_item_fixtures()[0] + item.update(kwargs) + item.store() + return item + + def _import(self, action="skip", enabled=True, resolution=None): + self.setup_importer( + autotag=False, + duplicate_track_resolution=enabled, + duplicate_action=action, + ) + if resolution is not None: + self.importer.default_resolution = resolution + self.importer.run() + + def test_skip_drops_duplicate_track(self): + self.add_item_fixture(artist="Tag Artist", title="Tag Track 1") + + self._import(action="skip") + + # The duplicate "Tag Track 1" is dropped; "Tag Track 2" is imported. + assert len(self.lib.albums()) == 1 + assert {i.title for i in self.lib.items()} == { + "Tag Track 1", + "Tag Track 2", + } + assert len(self.lib.items()) == 2 + + def test_skip_all_duplicates_skips_album(self): + self.add_item_fixture(artist="Tag Artist", title="Tag Track 1") + self.add_item_fixture(artist="Tag Artist", title="Tag Track 2") + + self._import(action="skip") + + # Every track is a duplicate, so the whole album is skipped. + assert len(self.lib.albums()) == 0 + assert len(self.lib.items()) == 2 + + def test_remove_replaces_old_item(self): + old = self.add_item_fixture(artist="Tag Artist", title="Tag Track 1") + assert old.filepath.exists() + + self._import(action="remove") + + # The old matching item (and its file) is removed; both album tracks + # are imported. + assert not old.filepath.exists() + assert sorted(i.title for i in self.lib.items()) == [ + "Tag Track 1", + "Tag Track 2", + ] + assert len(self.lib.albums()) == 1 + + def test_keep_imports_all(self): + self.add_item_fixture(artist="Tag Artist", title="Tag Track 1") + + self._import(action="keep") + + # Nothing is dropped or removed. + assert len(self.lib.items()) == 3 + assert len(self.lib.albums()) == 1 + + def test_disabled_by_default(self): + self.add_item_fixture(artist="Tag Artist", title="Tag Track 1") + + self._import(action="skip", enabled=False) + + # With the option off, no track-level resolution happens. + assert len(self.lib.items()) == 3 + + def test_ask_skip_drops_duplicate_track(self): + self.add_item_fixture(artist="Tag Artist", title="Tag Track 1") + + # With "ask", the session is prompted; answer SKIP. + self._import( + action="ask", resolution=ImportSessionFixture.Resolution.SKIP + ) + + assert len(self.lib.albums()) == 1 + assert len(self.lib.items()) == 2 + + def test_ask_remove_replaces_old_item(self): + old = self.add_item_fixture(artist="Tag Artist", title="Tag Track 1") + + # With "ask", the session is prompted; answer REMOVE. + self._import( + action="ask", resolution=ImportSessionFixture.Resolution.REMOVE + ) + + assert not old.filepath.exists() + assert len(self.lib.albums()) == 1 + assert sorted(i.title for i in self.lib.items()) == [ + "Tag Track 1", + "Tag Track 2", + ] + + class TagLogTest(unittest.TestCase): def test_tag_log_line(self): sio = StringIO() From 478bc3f23c8b20353cd185cbb3b43ba1ae5dca9b Mon Sep 17 00:00:00 2001 From: Tommy Schnabel-Jones Date: Mon, 8 Jun 2026 16:51:00 -0400 Subject: [PATCH 2/4] Add fold action and fix track duplicate resolution Match track duplicates against all library items (including album members, not just singletons), so re-importing catches tracks already present in an existing album. When per-track resolution prunes some tracks of an album, suppress the album-level duplicate check so the remaining new tracks are still imported instead of the whole (partial) album being skipped. Add a dedicated `duplicate_track_action` option (inheriting `duplicate_action` when unset) with a new `fold` action that adds the remaining new tracks to the existing album they belong to, completing a partially-imported album. Co-Authored-By: Claude Opus 4.8 --- beets/config_default.yaml | 1 + beets/importer/session.py | 3 +- beets/importer/stages.py | 72 +++++++++++++-- beets/importer/tasks.py | 23 +++++ beets/test/helper.py | 3 +- beets/ui/commands/import_/session.py | 4 +- docs/changelog.rst | 10 +- docs/reference/config.rst | 51 +++++++++-- test/test_importer.py | 132 ++++++++++++++++++++++++++- 9 files changed, 273 insertions(+), 26 deletions(-) diff --git a/beets/config_default.yaml b/beets/config_default.yaml index dd4e25f84d..62c8982414 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -53,6 +53,7 @@ import: duplicate_action: ask duplicate_verbose_prompt: no duplicate_track_resolution: no + duplicate_track_action: '' bell: no set_fields: {} ignored_alias_types: [] diff --git a/beets/importer/session.py b/beets/importer/session.py index 8813c3123b..4bbd9d4cf1 100644 --- a/beets/importer/session.py +++ b/beets/importer/session.py @@ -186,7 +186,8 @@ def resolve_duplicate(self, task: ImportTask, found_duplicates): def resolve_track_duplicates(self, task: ImportTask, duplicates) -> str: """Decide what to do with album tracks that already exist in the library. Return ``"s"`` (skip the duplicate tracks), ``"k"`` (keep - all) or ``"r"`` (remove the old items). + all), ``"r"`` (remove the old items) or ``"f"`` (fold the remaining + new tracks into the existing album). """ raise NotImplementedError diff --git a/beets/importer/stages.py b/beets/importer/stages.py index c856641238..2e240bdf51 100644 --- a/beets/importer/stages.py +++ b/beets/importer/stages.py @@ -19,7 +19,7 @@ import logging from typing import TYPE_CHECKING -from beets import config, plugins +from beets import config, dbcore, plugins from beets.util import MoveOperation, displayable_path, pipeline from .tasks import ( @@ -134,11 +134,14 @@ def resolve_track_duplicates(session: ImportSession, task: ImportTask): When ``import.duplicate_track_resolution`` is enabled, each item of an album import is checked against the library using ``import.duplicate_keys.item``. Matched tracks are resolved according to - ``import.duplicate_action``: + ``import.duplicate_track_action`` (which falls back to + ``import.duplicate_action`` when unset): * ``skip`` drops the duplicate tracks and imports the rest of the album (if every track is a duplicate, the whole album is skipped); * ``remove`` removes the matching old library items; + * ``fold`` drops the duplicate tracks and adds the remaining new tracks to + the existing album they belong to; * ``keep`` (and ``merge``) import everything as-is; * ``ask`` prompts the session for one of the above. @@ -170,23 +173,43 @@ def resolve_track_duplicates(session: ImportSession, task: ImportTask): if not duplicates: return - action = config["import"]["duplicate_action"].as_choice( - {"skip": "s", "keep": "k", "remove": "r", "merge": "m", "ask": "a"} - ) + action = _track_duplicate_action() if action == "a": action = session.resolve_track_duplicates(task, duplicates) - if action == "s": + if action in ("s", "f"): for item in duplicates: log.info( "skipping duplicate track: {}", displayable_path(item.path) ) task.items.remove(item) if not task.items: + # Every track was a duplicate: skip the whole album. task.set_choice(Action.SKIP) + return + # Only some tracks were duplicates; we have already dropped them, so + # don't let the album-level check skip the rest. + task.duplicate_tracks_resolved = True + if action == "f": + # Fold the remaining new tracks into the existing album, if the + # matched duplicates all belong to a single one. + album_ids = { + match.album_id + for matches in duplicates.values() + for match in matches + if match.album_id is not None + } + if len(album_ids) == 1: + task.fold_into_album_id = album_ids.pop() + else: + log.warning( + "cannot fold tracks into a single existing album; " + "importing them as a new album" + ) elif action == "r": for matches in duplicates.values(): task.duplicate_track_items_to_remove.extend(matches) + task.duplicate_tracks_resolved = True # "k" (keep) and "m" (merge) leave the incoming tracks untouched; whole # album duplicates are still handled by the regular resolution stage. @@ -400,13 +423,42 @@ def _apply_choice(session: ImportSession, task: ImportTask): task.set_fields(session.lib) +def _track_duplicate_action() -> str: + """Return the single-letter action for per-track duplicate resolution. + + Uses ``import.duplicate_track_action`` when set, otherwise falls back to + ``import.duplicate_action``. + """ + choices = { + "skip": "s", + "keep": "k", + "remove": "r", + "merge": "m", + "fold": "f", + "ask": "a", + } + cfg = config["import"] + view = ( + cfg["duplicate_track_action"] + if cfg["duplicate_track_action"].get() + else cfg["duplicate_action"] + ) + return view.as_choice(choices) + + def _find_track_duplicates( lib: library.Library, item: library.Item, keys: list[str] ) -> list[library.Item]: """Return library items matching `item` on all `keys`, excluding the item itself (so re-imports do not match their own files). + + Unlike :meth:`Item.duplicates_query`, this matches *every* library item, + including tracks that belong to an album -- not just singletons -- so a + track is caught regardless of how it was originally imported. """ - query = item.duplicates_query(keys) + query = dbcore.AndQuery( + [item.field_query(k, item.get(k), dbcore.MatchQuery) for k in keys] + ) return [other for other in lib.items(query) if other.path != item.path] @@ -414,6 +466,12 @@ def _resolve_duplicates(session: ImportSession, task: ImportTask): """Check if a task conflicts with items or albums already imported and ask the session to resolve this. """ + if task.duplicate_tracks_resolved: + # Per-track duplicate resolution already pruned (or recorded for + # removal) the tracks of this album that exist in the library; the + # rest are new and should be imported without a whole-album skip. + return + if task.choice_flag in (Action.ASIS, Action.APPLY, Action.RETAG): found_duplicates = task.find_duplicates(session.lib) if found_duplicates: diff --git a/beets/importer/tasks.py b/beets/importer/tasks.py index 50cc6ef7f4..8c497d2290 100644 --- a/beets/importer/tasks.py +++ b/beets/importer/tasks.py @@ -197,6 +197,12 @@ def __init__( # Existing library items to remove because individual tracks of this # album duplicate them (see ``duplicate_track_resolution``). self.duplicate_track_items_to_remove: list[library.Item] = [] + # Set once per-track duplicate resolution has handled this task, so the + # album-level duplicate check does not then skip the remaining tracks. + self.duplicate_tracks_resolved = False + # Id of an existing album to fold the imported items into (instead of + # creating a new album), set by the ``fold`` track duplicate action. + self.fold_into_album_id: int | None = None self.is_album = True def set_choice(self, choice: Action | AlbumMatch | TrackMatch): @@ -538,6 +544,23 @@ def add(self, lib: library.Library): self.record_replaced(lib) self.remove_replaced(lib) + fold_album = ( + lib.get_album(self.fold_into_album_id) + if self.fold_into_album_id is not None + else None + ) + if fold_album is not None: + # Fold the imported items into an existing album rather than + # creating a new one. + self.album = fold_album + for item in self.imported_items(): + item.album_id = self.album.id + if item.id is None: + item.add(lib) + else: + item.store() + return + self.album = lib.add_album(self.imported_items()) if self.choice_flag == Action.APPLY and isinstance( self.match, AlbumMatch diff --git a/beets/test/helper.py b/beets/test/helper.py index 9478d76e96..dc7feced2a 100644 --- a/beets/test/helper.py +++ b/beets/test/helper.py @@ -652,7 +652,7 @@ def choose_match(self, task): choose_item = choose_match - Resolution = Enum("Resolution", "REMOVE SKIP KEEPBOTH MERGE") + Resolution = Enum("Resolution", "REMOVE SKIP KEEPBOTH MERGE FOLD") default_resolution = "REMOVE" @@ -679,6 +679,7 @@ def resolve_track_duplicates(self, task, duplicates): self.Resolution.SKIP: "s", self.Resolution.KEEPBOTH: "k", self.Resolution.REMOVE: "r", + self.Resolution.FOLD: "f", }.get(res, "k") diff --git a/beets/ui/commands/import_/session.py b/beets/ui/commands/import_/session.py index 79ccbeeb43..c232cac1c6 100644 --- a/beets/ui/commands/import_/session.py +++ b/beets/ui/commands/import_/session.py @@ -224,7 +224,9 @@ def resolve_track_duplicates(self, task, duplicates) -> str: for item in duplicates: print(f" {item}") - return ui.input_options(("Skip dupes", "Keep all", "Remove old")) + return ui.input_options( + ("Skip dupes", "Keep all", "Remove old", "Fold into album") + ) def should_resume(self, path): return ui.input_yn( diff --git a/docs/changelog.rst b/docs/changelog.rst index c269f98892..2bf1ba0009 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -28,10 +28,12 @@ New features aliases-as-artist-credit optional. - :doc:`plugins/badfiles`: Added settings for auto error and warning actions. - Add the :ref:`duplicate_track_resolution` import option, which checks each - track of an album import against the library's singleton items (using the - :ref:`duplicate_keys` ``item`` fields) and resolves matches via - :ref:`duplicate_action`. With ``skip`` this drops already-imported tracks and - imports the rest of the album. Disabled by default. + track of an album import against the library (using the + :ref:`duplicate_keys` ``item`` fields) and resolves matches via the new + :ref:`duplicate_track_action` option (falling back to :ref:`duplicate_action` + when unset). ``skip`` drops already-imported tracks and imports the rest of + the album; ``fold`` instead adds the remaining new tracks to the existing + album, completing a partially-imported album. Disabled by default. Bug fixes ~~~~~~~~~ diff --git a/docs/reference/config.rst b/docs/reference/config.rst index 6f3d5cb596..d56d507afc 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -851,26 +851,57 @@ When enabled, album imports also check each *individual track* against the library, using the same fields as :ref:`duplicate_keys` ``item`` (by default ``artist`` and ``title``; set it to e.g. ``mb_trackid`` to match on the MusicBrainz track ID). Tracks already in the library are resolved according to -:ref:`duplicate_action`: +:ref:`duplicate_track_action`. + +This complements the album-level duplicate check (which matches whole albums on +:ref:`duplicate_keys` ``album``): it catches the case where some tracks of an +album are already in your library even though the album itself is not. Matching +considers *all* library items, whether they were imported as singletons or as +part of another album. + +.. note:: + + The check runs *before* the autotagger lookup, so it matches on the + incoming files' existing tags rather than the metadata beets would apply. + When importing with autotagging on, match on a stable identifier such as + ``mb_trackid`` (via :ref:`duplicate_keys` ``item``); ``artist`` and + ``title`` may not yet agree with what is in your library. + +Default: ``no``. + +.. _duplicate_track_action: + +duplicate_track_action +~~~~~~~~~~~~~~~~~~~~~~~~ + +How to resolve individual album tracks that already exist in the library when +:ref:`duplicate_track_resolution` is enabled. The available actions are: - ``skip`` drops the duplicate tracks and imports the rest of the album. If every track is already present, the whole album is skipped. - ``remove`` removes the matching old items from the library before importing. +- ``fold`` drops the duplicate tracks and adds the remaining *new* tracks to the + existing album they belong to, instead of importing them as a separate album. + Use this to complete a partially-imported album. (If the matching tracks do + not all belong to a single album, the new tracks are imported as their own + album.) - ``keep`` (and ``merge``) import the album unchanged. - ``ask`` prompts you to choose one of the above. -This complements the album-level duplicate check (which matches whole albums on -:ref:`duplicate_keys` ``album``): it catches the case where some loose tracks of -an album are already in your library as singletons. +When left empty, this falls back to :ref:`duplicate_action` (so ``fold`` is only +available by setting this option explicitly). -.. note:: +A typical configuration for completing partially-imported albums while +autotagging looks like this:: - Only **singleton** library items (tracks not attached to an album) are - considered. Tracks that already belong to an album in your library are not - matched, so importing a fuller version of an album you already have will not - deduplicate against that existing album's tracks. + import: + duplicate_track_resolution: yes + duplicate_action: ask # whole-album duplicates + duplicate_track_action: fold # per-track duplicates: fold into the existing album + duplicate_keys: + item: mb_trackid # match on a stable id (recommended when autotagging) -Default: ``no``. +Default: empty (inherit :ref:`duplicate_action`). .. _bell: diff --git a/test/test_importer.py b/test/test_importer.py index b6e574faca..cb2b504010 100644 --- a/test/test_importer.py +++ b/test/test_importer.py @@ -1309,7 +1309,8 @@ class ImportTrackDuplicateResolutionTest(ImportHelper, BeetsTestCase): """``import.duplicate_track_resolution``: per-track dedup on album import. The imported album has two tracks (``Tag Track 1`` and ``Tag Track 2``); - tests seed the library with singletons matching one or both of them. + tests seed the library with items matching one or both of them (as + singletons unless noted otherwise). """ def setUp(self): @@ -1322,11 +1323,23 @@ def add_item_fixture(self, **kwargs): item.store() return item - def _import(self, action="skip", enabled=True, resolution=None): + def add_album_member_fixture(self, **kwargs): + """Seed the library with a track that belongs to an album (i.e. not a + singleton), so its ``album_id`` is set. + """ + item = self.add_item_fixture(**kwargs) + self.lib.add_album([item]) + item.store() + return item + + def _import( + self, action="skip", enabled=True, resolution=None, track_action=None + ): self.setup_importer( autotag=False, duplicate_track_resolution=enabled, duplicate_action=action, + duplicate_track_action=track_action or "", ) if resolution is not None: self.importer.default_resolution = resolution @@ -1345,6 +1358,46 @@ def test_skip_drops_duplicate_track(self): } assert len(self.lib.items()) == 2 + def test_partial_album_reimports_missing_tracks(self): + # Import the album fully, then lose one track from the library and + # re-import the same folder. The present track is skipped as a + # duplicate and the missing one is re-added, even though the album + # itself still matches the album-level duplicate check. + self._import(action="skip") + assert {i.title for i in self.lib.items()} == { + "Tag Track 1", + "Tag Track 2", + } + + missing = self.lib.items("title:'Tag Track 2'").get() + missing.remove(delete=True) + assert {i.title for i in self.lib.items()} == {"Tag Track 1"} + + self._import(action="skip") + + assert {i.title for i in self.lib.items()} == { + "Tag Track 1", + "Tag Track 2", + } + + def test_skip_matches_existing_album_member(self): + # A matching track that already belongs to an album in the library + # (not a singleton) must still be caught. + item = self.add_album_member_fixture( + artist="Tag Artist", title="Tag Track 1" + ) + assert item.album_id is not None + + self._import(action="skip") + + # The duplicate "Tag Track 1" is dropped; only "Tag Track 2" is added, + # alongside the pre-existing album member. + assert sorted(i.title for i in self.lib.items()) == [ + "Tag Track 1", + "Tag Track 2", + ] + assert len(self.lib.items()) == 2 + def test_skip_all_duplicates_skips_album(self): self.add_item_fixture(artist="Tag Artist", title="Tag Track 1") self.add_item_fixture(artist="Tag Artist", title="Tag Track 2") @@ -1413,6 +1466,81 @@ def test_ask_remove_replaces_old_item(self): "Tag Track 2", ] + def test_inherits_duplicate_action_when_unset(self): + self.add_item_fixture(artist="Tag Artist", title="Tag Track 1") + + # No duplicate_track_action: should inherit duplicate_action=skip. + self._import(action="skip", track_action=None) + + assert len(self.lib.albums()) == 1 + assert {i.title for i in self.lib.items()} == { + "Tag Track 1", + "Tag Track 2", + } + + def test_fold_into_existing_album(self): + # Import the album fully, lose one track, then re-import with "fold": + # the present track is skipped and the missing one is added back to + # the *same* album (no second album is created). + self._import(track_action="fold") + album = self.lib.albums().get() + assert {i.title for i in album.items()} == { + "Tag Track 1", + "Tag Track 2", + } + + missing = self.lib.items("title:'Tag Track 2'").get() + missing.remove(delete=True) + + self._import(track_action="fold") + + assert len(self.lib.albums()) == 1 + album = self.lib.albums().get() + folded = self.lib.items("title:'Tag Track 2'").get() + assert folded.album_id == album.id + assert folded.filepath.exists() + assert {i.title for i in album.items()} == { + "Tag Track 1", + "Tag Track 2", + } + + def test_fold_all_duplicates_skips_album(self): + self.add_album_member_fixture(artist="Tag Artist", title="Tag Track 1") + self.add_album_member_fixture(artist="Tag Artist", title="Tag Track 2") + + # Every track is a duplicate: nothing new to fold, album is skipped. + self._import(track_action="fold") + + assert len(self.lib.items()) == 2 + + def test_fold_falls_back_when_no_single_album(self): + # Matching tracks are singletons (no album), so there is no single + # album to fold into: the new track is imported as its own album. + self.add_item_fixture(artist="Tag Artist", title="Tag Track 1") + + self._import(track_action="fold") + + assert {i.title for i in self.lib.items()} == { + "Tag Track 1", + "Tag Track 2", + } + + def test_ask_fold(self): + self._import(track_action="fold") + missing = self.lib.items("title:'Tag Track 2'").get() + missing.remove(delete=True) + + # With "ask", the session is prompted; answer FOLD. + self._import( + action="ask", resolution=ImportSessionFixture.Resolution.FOLD + ) + + assert len(self.lib.albums()) == 1 + assert {i.title for i in self.lib.albums().get().items()} == { + "Tag Track 1", + "Tag Track 2", + } + class TagLogTest(unittest.TestCase): def test_tag_log_line(self): From 17c87ef94aa935367e1fb39a3e2e059afebd6d52 Mon Sep 17 00:00:00 2001 From: Tommy Schnabel-Jones Date: Mon, 8 Jun 2026 17:09:31 -0400 Subject: [PATCH 3/4] Merge fold into skip and colorize duplicate-skip messages Collapse the separate `fold` track duplicate action into `skip`: skipping per-track duplicates now folds the remaining new tracks into the existing album they belong to (falling back to a new album when the matches do not resolve to a single album). Removes the `fold` action, prompt option, and `FOLD` resolution. Show the per-track "Skipping duplicate track" message and the whole-album "Skipping album, all tracks are duplicates" message in the warning color. Co-Authored-By: Claude Opus 4.8 --- beets/importer/session.py | 6 +- beets/importer/stages.py | 52 +++++++++------- beets/importer/tasks.py | 3 +- beets/test/helper.py | 3 +- beets/ui/commands/import_/session.py | 4 +- docs/changelog.rst | 6 +- docs/reference/config.rst | 17 +++-- test/test_importer.py | 93 ++++++++++------------------ 8 files changed, 79 insertions(+), 105 deletions(-) diff --git a/beets/importer/session.py b/beets/importer/session.py index 4bbd9d4cf1..46910bd89f 100644 --- a/beets/importer/session.py +++ b/beets/importer/session.py @@ -185,9 +185,9 @@ def resolve_duplicate(self, task: ImportTask, found_duplicates): def resolve_track_duplicates(self, task: ImportTask, duplicates) -> str: """Decide what to do with album tracks that already exist in the - library. Return ``"s"`` (skip the duplicate tracks), ``"k"`` (keep - all), ``"r"`` (remove the old items) or ``"f"`` (fold the remaining - new tracks into the existing album). + library. Return ``"s"`` (skip the duplicate tracks and fold the + remaining new tracks into the existing album), ``"k"`` (keep all) or + ``"r"`` (remove the old items). """ raise NotImplementedError diff --git a/beets/importer/stages.py b/beets/importer/stages.py index 2e240bdf51..879f376dab 100644 --- a/beets/importer/stages.py +++ b/beets/importer/stages.py @@ -21,6 +21,7 @@ from beets import config, dbcore, plugins from beets.util import MoveOperation, displayable_path, pipeline +from beets.util.color import colorize from .tasks import ( Action, @@ -137,11 +138,10 @@ def resolve_track_duplicates(session: ImportSession, task: ImportTask): ``import.duplicate_track_action`` (which falls back to ``import.duplicate_action`` when unset): - * ``skip`` drops the duplicate tracks and imports the rest of the album - (if every track is a duplicate, the whole album is skipped); + * ``skip`` drops the duplicate tracks and adds the remaining new tracks to + the existing album they belong to (if every track is a duplicate, the + whole album is skipped); * ``remove`` removes the matching old library items; - * ``fold`` drops the duplicate tracks and adds the remaining new tracks to - the existing album they belong to; * ``keep`` (and ``merge``) import everything as-is; * ``ask`` prompts the session for one of the above. @@ -177,35 +177,42 @@ def resolve_track_duplicates(session: ImportSession, task: ImportTask): if action == "a": action = session.resolve_track_duplicates(task, duplicates) - if action in ("s", "f"): + if action == "s": for item in duplicates: log.info( - "skipping duplicate track: {}", displayable_path(item.path) + colorize("text_warning", "Skipping duplicate track: {}"), + displayable_path(item.path), ) task.items.remove(item) if not task.items: # Every track was a duplicate: skip the whole album. + log.info( + colorize( + "text_warning", + "Skipping album, all tracks are duplicates: {}", + ), + next(iter(duplicates)).album, + ) task.set_choice(Action.SKIP) return # Only some tracks were duplicates; we have already dropped them, so # don't let the album-level check skip the rest. task.duplicate_tracks_resolved = True - if action == "f": - # Fold the remaining new tracks into the existing album, if the - # matched duplicates all belong to a single one. - album_ids = { - match.album_id - for matches in duplicates.values() - for match in matches - if match.album_id is not None - } - if len(album_ids) == 1: - task.fold_into_album_id = album_ids.pop() - else: - log.warning( - "cannot fold tracks into a single existing album; " - "importing them as a new album" - ) + # Fold the remaining new tracks into the existing album, if the + # matched duplicates all belong to a single one. + album_ids = { + match.album_id + for matches in duplicates.values() + for match in matches + if match.album_id is not None + } + if len(album_ids) == 1: + task.fold_into_album_id = album_ids.pop() + else: + log.warning( + "cannot fold tracks into a single existing album; " + "importing them as a new album" + ) elif action == "r": for matches in duplicates.values(): task.duplicate_track_items_to_remove.extend(matches) @@ -434,7 +441,6 @@ def _track_duplicate_action() -> str: "keep": "k", "remove": "r", "merge": "m", - "fold": "f", "ask": "a", } cfg = config["import"] diff --git a/beets/importer/tasks.py b/beets/importer/tasks.py index 8c497d2290..3e3687fbcd 100644 --- a/beets/importer/tasks.py +++ b/beets/importer/tasks.py @@ -201,7 +201,8 @@ def __init__( # album-level duplicate check does not then skip the remaining tracks. self.duplicate_tracks_resolved = False # Id of an existing album to fold the imported items into (instead of - # creating a new album), set by the ``fold`` track duplicate action. + # creating a new album), set when skipping per-track duplicates leaves + # new tracks belonging to an existing album. self.fold_into_album_id: int | None = None self.is_album = True diff --git a/beets/test/helper.py b/beets/test/helper.py index dc7feced2a..9478d76e96 100644 --- a/beets/test/helper.py +++ b/beets/test/helper.py @@ -652,7 +652,7 @@ def choose_match(self, task): choose_item = choose_match - Resolution = Enum("Resolution", "REMOVE SKIP KEEPBOTH MERGE FOLD") + Resolution = Enum("Resolution", "REMOVE SKIP KEEPBOTH MERGE") default_resolution = "REMOVE" @@ -679,7 +679,6 @@ def resolve_track_duplicates(self, task, duplicates): self.Resolution.SKIP: "s", self.Resolution.KEEPBOTH: "k", self.Resolution.REMOVE: "r", - self.Resolution.FOLD: "f", }.get(res, "k") diff --git a/beets/ui/commands/import_/session.py b/beets/ui/commands/import_/session.py index c232cac1c6..79ccbeeb43 100644 --- a/beets/ui/commands/import_/session.py +++ b/beets/ui/commands/import_/session.py @@ -224,9 +224,7 @@ def resolve_track_duplicates(self, task, duplicates) -> str: for item in duplicates: print(f" {item}") - return ui.input_options( - ("Skip dupes", "Keep all", "Remove old", "Fold into album") - ) + return ui.input_options(("Skip dupes", "Keep all", "Remove old")) def should_resume(self, path): return ui.input_yn( diff --git a/docs/changelog.rst b/docs/changelog.rst index 2bf1ba0009..33b972e425 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -31,9 +31,9 @@ New features track of an album import against the library (using the :ref:`duplicate_keys` ``item`` fields) and resolves matches via the new :ref:`duplicate_track_action` option (falling back to :ref:`duplicate_action` - when unset). ``skip`` drops already-imported tracks and imports the rest of - the album; ``fold`` instead adds the remaining new tracks to the existing - album, completing a partially-imported album. Disabled by default. + when unset). ``skip`` drops already-imported tracks and adds the remaining new + tracks to the existing album, completing a partially-imported album. Disabled + by default. Bug fixes ~~~~~~~~~ diff --git a/docs/reference/config.rst b/docs/reference/config.rst index d56d507afc..24f979d074 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -877,19 +877,16 @@ duplicate_track_action How to resolve individual album tracks that already exist in the library when :ref:`duplicate_track_resolution` is enabled. The available actions are: -- ``skip`` drops the duplicate tracks and imports the rest of the album. If - every track is already present, the whole album is skipped. -- ``remove`` removes the matching old items from the library before importing. -- ``fold`` drops the duplicate tracks and adds the remaining *new* tracks to the +- ``skip`` drops the duplicate tracks and adds the remaining *new* tracks to the existing album they belong to, instead of importing them as a separate album. - Use this to complete a partially-imported album. (If the matching tracks do - not all belong to a single album, the new tracks are imported as their own - album.) + Use this to complete a partially-imported album. If every track is already + present, the whole album is skipped. (If the matching tracks do not all belong + to a single album, the new tracks are imported as their own album.) +- ``remove`` removes the matching old items from the library before importing. - ``keep`` (and ``merge``) import the album unchanged. - ``ask`` prompts you to choose one of the above. -When left empty, this falls back to :ref:`duplicate_action` (so ``fold`` is only -available by setting this option explicitly). +When left empty, this falls back to :ref:`duplicate_action`. A typical configuration for completing partially-imported albums while autotagging looks like this:: @@ -897,7 +894,7 @@ autotagging looks like this:: import: duplicate_track_resolution: yes duplicate_action: ask # whole-album duplicates - duplicate_track_action: fold # per-track duplicates: fold into the existing album + duplicate_track_action: skip # per-track duplicates: fold new tracks into the existing album duplicate_keys: item: mb_trackid # match on a stable id (recommended when autotagging) diff --git a/test/test_importer.py b/test/test_importer.py index cb2b504010..be1620c764 100644 --- a/test/test_importer.py +++ b/test/test_importer.py @@ -1345,12 +1345,14 @@ def _import( self.importer.default_resolution = resolution self.importer.run() - def test_skip_drops_duplicate_track(self): + def test_skip_singleton_dup_imports_remainder_as_new_album(self): + # The matching track is a singleton, so there is no single album to + # fold into: the duplicate is dropped and the remaining track is + # imported as its own album. self.add_item_fixture(artist="Tag Artist", title="Tag Track 1") self._import(action="skip") - # The duplicate "Tag Track 1" is dropped; "Tag Track 2" is imported. assert len(self.lib.albums()) == 1 assert {i.title for i in self.lib.items()} == { "Tag Track 1", @@ -1358,13 +1360,14 @@ def test_skip_drops_duplicate_track(self): } assert len(self.lib.items()) == 2 - def test_partial_album_reimports_missing_tracks(self): + def test_skip_folds_missing_tracks_into_existing_album(self): # Import the album fully, then lose one track from the library and # re-import the same folder. The present track is skipped as a - # duplicate and the missing one is re-added, even though the album - # itself still matches the album-level duplicate check. + # duplicate and the missing one is folded back into the *same* album + # (no second album is created). self._import(action="skip") - assert {i.title for i in self.lib.items()} == { + album = self.lib.albums().get() + assert {i.title for i in album.items()} == { "Tag Track 1", "Tag Track 2", } @@ -1375,14 +1378,20 @@ def test_partial_album_reimports_missing_tracks(self): self._import(action="skip") - assert {i.title for i in self.lib.items()} == { + assert len(self.lib.albums()) == 1 + album = self.lib.albums().get() + folded = self.lib.items("title:'Tag Track 2'").get() + assert folded.album_id == album.id + assert folded.filepath.exists() + assert {i.title for i in album.items()} == { "Tag Track 1", "Tag Track 2", } def test_skip_matches_existing_album_member(self): # A matching track that already belongs to an album in the library - # (not a singleton) must still be caught. + # (not a singleton) must still be caught, and the remaining new track + # folded into that album. item = self.add_album_member_fixture( artist="Tag Artist", title="Tag Track 1" ) @@ -1390,13 +1399,14 @@ def test_skip_matches_existing_album_member(self): self._import(action="skip") - # The duplicate "Tag Track 1" is dropped; only "Tag Track 2" is added, - # alongside the pre-existing album member. - assert sorted(i.title for i in self.lib.items()) == [ + # The duplicate "Tag Track 1" is dropped; "Tag Track 2" is folded into + # the existing album. + assert len(self.lib.albums()) == 1 + album = self.lib.albums().get() + assert {i.title for i in album.items()} == { "Tag Track 1", "Tag Track 2", - ] - assert len(self.lib.items()) == 2 + } def test_skip_all_duplicates_skips_album(self): self.add_item_fixture(artist="Tag Artist", title="Tag Track 1") @@ -1478,61 +1488,24 @@ def test_inherits_duplicate_action_when_unset(self): "Tag Track 2", } - def test_fold_into_existing_album(self): - # Import the album fully, lose one track, then re-import with "fold": - # the present track is skipped and the missing one is added back to - # the *same* album (no second album is created). - self._import(track_action="fold") - album = self.lib.albums().get() - assert {i.title for i in album.items()} == { - "Tag Track 1", - "Tag Track 2", - } - - missing = self.lib.items("title:'Tag Track 2'").get() - missing.remove(delete=True) - - self._import(track_action="fold") - - assert len(self.lib.albums()) == 1 - album = self.lib.albums().get() - folded = self.lib.items("title:'Tag Track 2'").get() - assert folded.album_id == album.id - assert folded.filepath.exists() - assert {i.title for i in album.items()} == { - "Tag Track 1", - "Tag Track 2", - } - - def test_fold_all_duplicates_skips_album(self): - self.add_album_member_fixture(artist="Tag Artist", title="Tag Track 1") - self.add_album_member_fixture(artist="Tag Artist", title="Tag Track 2") - - # Every track is a duplicate: nothing new to fold, album is skipped. - self._import(track_action="fold") - - assert len(self.lib.items()) == 2 - - def test_fold_falls_back_when_no_single_album(self): - # Matching tracks are singletons (no album), so there is no single - # album to fold into: the new track is imported as its own album. + def test_track_action_overrides_duplicate_action(self): self.add_item_fixture(artist="Tag Artist", title="Tag Track 1") - self._import(track_action="fold") + # duplicate_action says keep, but the track-specific action skips. + self._import(action="keep", track_action="skip") - assert {i.title for i in self.lib.items()} == { - "Tag Track 1", - "Tag Track 2", - } + # The duplicate was dropped (skip), not kept, so only two items exist. + assert len(self.lib.items()) == 2 - def test_ask_fold(self): - self._import(track_action="fold") + def test_ask_skip_folds_into_existing_album(self): + self._import(action="skip") missing = self.lib.items("title:'Tag Track 2'").get() missing.remove(delete=True) - # With "ask", the session is prompted; answer FOLD. + # With "ask", the session is prompted; answer SKIP, which folds the + # remaining track into the existing album. self._import( - action="ask", resolution=ImportSessionFixture.Resolution.FOLD + action="ask", resolution=ImportSessionFixture.Resolution.SKIP ) assert len(self.lib.albums()) == 1 From 41c25aa3516fac552b9b3c5e3694cd64ffaf10a4 Mon Sep 17 00:00:00 2001 From: Tommy Schnabel-Jones Date: Mon, 8 Jun 2026 17:35:58 -0400 Subject: [PATCH 4/4] Format docs --- docs/changelog.rst | 4 ++-- docs/reference/config.rst | 14 ++++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 33b972e425..91bb27db4e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -28,8 +28,8 @@ New features aliases-as-artist-credit optional. - :doc:`plugins/badfiles`: Added settings for auto error and warning actions. - Add the :ref:`duplicate_track_resolution` import option, which checks each - track of an album import against the library (using the - :ref:`duplicate_keys` ``item`` fields) and resolves matches via the new + track of an album import against the library (using the :ref:`duplicate_keys` + ``item`` fields) and resolves matches via the new :ref:`duplicate_track_action` option (falling back to :ref:`duplicate_action` when unset). ``skip`` drops already-imported tracks and adds the remaining new tracks to the existing album, completing a partially-imported album. Disabled diff --git a/docs/reference/config.rst b/docs/reference/config.rst index 24f979d074..75ed79f83e 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -845,7 +845,7 @@ Default: ``no``. .. _duplicate_track_resolution: duplicate_track_resolution -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~ When enabled, album imports also check each *individual track* against the library, using the same fields as :ref:`duplicate_keys` ``item`` (by default @@ -861,9 +861,9 @@ part of another album. .. note:: - The check runs *before* the autotagger lookup, so it matches on the - incoming files' existing tags rather than the metadata beets would apply. - When importing with autotagging on, match on a stable identifier such as + The check runs *before* the autotagger lookup, so it matches on the incoming + files' existing tags rather than the metadata beets would apply. When + importing with autotagging on, match on a stable identifier such as ``mb_trackid`` (via :ref:`duplicate_keys` ``item``); ``artist`` and ``title`` may not yet agree with what is in your library. @@ -872,7 +872,7 @@ Default: ``no``. .. _duplicate_track_action: duplicate_track_action -~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~ How to resolve individual album tracks that already exist in the library when :ref:`duplicate_track_resolution` is enabled. The available actions are: @@ -889,7 +889,9 @@ How to resolve individual album tracks that already exist in the library when When left empty, this falls back to :ref:`duplicate_action`. A typical configuration for completing partially-imported albums while -autotagging looks like this:: +autotagging looks like this: + +:: import: duplicate_track_resolution: yes