diff --git a/beets/config_default.yaml b/beets/config_default.yaml index 34c1c42e5b..86a9fec4e9 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -53,6 +53,8 @@ import: item: artist title 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 86e901f448..ecb719af45 100644 --- a/beets/importer/session.py +++ b/beets/importer/session.py @@ -2,7 +2,7 @@ import os import time -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from beets import config, logging, plugins, util from beets.util import displayable_path, normpath, pipeline, syspath @@ -46,6 +46,7 @@ class ImportSession: _is_resuming: dict[bytes, bool] _merged_items: set[PathBytes] _merged_dirs: set[PathBytes] + _seen_track_keys: set[tuple[Any, ...]] def __init__( self, @@ -74,6 +75,7 @@ def __init__( self._is_resuming = {} self._merged_items = set() self._merged_dirs = set() + self._seen_track_keys = set() # Normalize the paths. self.paths = list(map(normpath, paths or [])) @@ -185,6 +187,22 @@ def get_duplicate_action( def choose_item(self, task: SingletonImportTask) -> TrackMatch | Action: raise NotImplementedError + def resolve_track_duplicates( + self, + task: ImportTask, + duplicates: dict[library.Item, list[library.Item]], + ) -> DuplicateAction: + """Decide what to do with album tracks that already exist in the + library: :attr:`~DuplicateAction.SKIP` (drop the duplicate tracks and + fold the remaining new tracks into the existing album), + :attr:`~DuplicateAction.KEEP` (import everything) or + :attr:`~DuplicateAction.REMOVE` (remove the old items). + + ``duplicates`` maps each incoming :class:`~beets.library.Item` to the + existing library items it duplicates. + """ + raise NotImplementedError + def run(self) -> None: """Run the import task.""" self.logger.info("import started {}", time.asctime()) @@ -205,6 +223,10 @@ def run(self) -> None: # 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 @@ -285,6 +307,25 @@ def mark_merged(self, paths: Sequence[PathBytes]) -> None: } self._merged_dirs.update(dirs) + def track_key_seen(self, key: tuple[Any, ...]) -> bool: + """Return whether a per-track duplicate ``key`` was already claimed by + an earlier task in this import run (see :ref:`duplicate_track_resolution`). + + This lets the track-level duplicate check catch tracks imported earlier + in the *same* run before they reach the database, which matters under + the default threaded import where a later task may be checked before an + earlier one has been added. + """ + return key in self._seen_track_keys + + def remember_track_key(self, key: tuple[Any, ...]) -> None: + """Record a per-track duplicate ``key`` claimed by the current task. + + Only ever called from the single-threaded ``resolve_track_duplicates`` + pipeline stage, so the shared set needs no locking. + """ + self._seen_track_keys.add(key) + def is_resuming(self, toppath: PathBytes) -> bool: """Return `True` if user wants to resume import of this path. diff --git a/beets/importer/stages.py b/beets/importer/stages.py index c66a54cea0..008b48896e 100644 --- a/beets/importer/stages.py +++ b/beets/importer/stages.py @@ -3,10 +3,11 @@ import contextvars import itertools import logging -from typing import TYPE_CHECKING, TypeAlias +from typing import TYPE_CHECKING, Any, TypeAlias -from beets import config, plugins +from beets import config, dbcore, plugins from beets.util import MoveOperation, displayable_path, pipeline +from beets.util.color import colorize from .actions import Action, DuplicateAction from .tasks import ( @@ -119,6 +120,125 @@ def group(item: library.Item) -> tuple[str | None, str | None]: out = pipeline.multiple(tasks) +@pipeline.mutator_stage +def resolve_track_duplicates(session: ImportSession, task: ImportTask) -> None: + """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_track_action`` (which falls back to + ``import.duplicate_action`` when unset): + + * ``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; + * ``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 + + def item_key(item: library.Item) -> tuple[Any, ...]: + return tuple(item.get(k) for k in keys) + + def has_key(item: library.Item) -> bool: + return any(item.get(k) for k in keys) + + def remember(items: Iterable[library.Item]) -> None: + # Claim the keys of the tracks this task will import so that later + # tasks in the same run recognise them as duplicates before they reach + # the database (see ``ImportSession.track_key_seen``). + for item in items: + if has_key(item): + session.remember_track_key(item_key(item)) + + # Map each incoming item to the existing library items it duplicates. A + # track also counts as a duplicate when its key was already claimed by an + # earlier task in this same import run, even if that task has not been + # added to the library yet. + duplicates: dict[library.Item, list[library.Item]] = {} + for item in task.items: + if not has_key(item): + continue + matches = _find_track_duplicates(session.lib, item, keys) + if matches or session.track_key_seen(item_key(item)): + duplicates[item] = matches + + if not duplicates: + remember(task.items) + return + + action = _track_duplicate_action() + if action is DuplicateAction.ASK: + action = session.resolve_track_duplicates(task, duplicates) + + if action is DuplicateAction.SKIP: + for item in duplicates: + log.info( + 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 + # Fold the remaining new tracks into the existing album the matched + # duplicates belong to. Tracks matching a *singleton* are skipped + # individually but do not affect the fold target (their ``album_id`` + # is ``None``), so a mix of album-member and singleton matches still + # completes the album. Only when the matched album members span more + # than one album -- or none of the matches belong to an album at all + # -- are the new tracks imported as their own album. + 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 is DuplicateAction.REMOVE: + for matches in duplicates.values(): + task.duplicate_track_items_to_remove.extend(matches) + task.duplicate_tracks_resolved = True + # KEEP and MERGE leave the incoming tracks untouched; whole-album + # duplicates are still handled by the regular resolution stage. + + # Claim the keys of the tracks that remain to be imported. + remember(task.items) + + @pipeline.mutator_stage def lookup_candidates(session: ImportSession, task: ImportTask) -> None: """A coroutine for performing the initial MusicBrainz lookup for an @@ -275,6 +395,9 @@ def manipulate_files(session: ImportSession, task: ImportTask) -> None: if task.duplicate_action is DuplicateAction.REMOVE: 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"]: @@ -327,10 +450,48 @@ def _apply_choice(session: ImportSession, task: ImportTask) -> None: task.set_fields(session.lib) +def _track_duplicate_action() -> DuplicateAction: + """Return the configured :class:`DuplicateAction` for per-track resolution. + + Uses ``import.duplicate_track_action`` when set, otherwise falls back to + ``import.duplicate_action``. + """ + cfg = config["import"] + view = ( + cfg["duplicate_track_action"] + if cfg["duplicate_track_action"].get() + else cfg["duplicate_action"] + ) + choice = view.as_choice(DuplicateAction.choices()) + return DuplicateAction(choice) # type: ignore[call-arg] + + +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 = 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] + + def _resolve_duplicates(session: ImportSession, task: ImportTask) -> None: """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 143d90e2b6..b75978ac8d 100644 --- a/beets/importer/tasks.py +++ b/beets/importer/tasks.py @@ -63,6 +63,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.""" @@ -153,6 +170,16 @@ def __init__( items: Iterable[library.Item] | None, ) -> None: super().__init__(toppath, paths, items) + # 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 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 def set_choice(self, choice: Action | AlbumMatch | TrackMatch) -> None: @@ -251,15 +278,7 @@ def remove_duplicates(self, lib: library.Library) -> None: 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) @@ -272,6 +291,17 @@ def remove_duplicates(self, lib: library.Library) -> None: clutter=config["clutter"].as_str_seq(), ) + def remove_duplicate_track_items(self, lib: library.Library) -> None: + """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) -> None: """Sets the fields given at CLI or configuration to the specified values, for both the album and all its items. @@ -497,6 +527,23 @@ def add(self, lib: library.Library) -> None: 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 @@ -719,15 +766,7 @@ def remove_duplicates(self, lib: library.Library) -> None: 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: library.Library) -> None: with lib.transaction(): diff --git a/beets/ui/commands/import_/session.py b/beets/ui/commands/import_/session.py index 4e2fe48ada..d4d5a7daca 100644 --- a/beets/ui/commands/import_/session.py +++ b/beets/ui/commands/import_/session.py @@ -201,6 +201,31 @@ def get_duplicate_action( return action + def resolve_track_duplicates( + self, task: importer.ImportTask, duplicates: dict[Item, list[Item]] + ) -> DuplicateAction: + """Decide what to do with album tracks already in the library.""" + log.warning("Some tracks are already in the library!") + + if config["import"]["quiet"].get(bool): + # In quiet mode, don't prompt -- just skip the duplicate tracks. + log.info("Skipping duplicate tracks.") + return DuplicateAction.SKIP + + existing = [item for matches in duplicates.values() for item in matches] + ui.print_("Old: " + summarize_items(existing, True)) + if config["import"]["duplicate_verbose_prompt"].get(bool): + for item in existing: + print(f" {item}") + + ui.print_("New: " + summarize_items(list(duplicates), True)) + if config["import"]["duplicate_verbose_prompt"].get(bool): + for item in duplicates: + print(f" {item}") + + selection = ui.input_options(("Skip dupes", "Keep all", "Remove old")) + return DuplicateAction(selection) # type: ignore[call-arg] + 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 aed216b1c0..6312086c03 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -18,6 +18,13 @@ New features - :doc:`plugins/lyrics`: Added a ``rest_directory`` configuration option for specifying a reStructuredText output directory, semantically equivalent to ``-r, --write-rest``. :bug:`2806` +- 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 + :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 + by default. - A database backup is now automatically created before running schema migrations. Control with the ``create_backup_before_migrations`` option (default: yes). diff --git a/docs/reference/config.rst b/docs/reference/config.rst index f434ba8028..1b96388971 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -850,6 +850,88 @@ 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_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. + + Matching on ``mb_trackid`` only works for files that actually carry that + tag: a track missing it is not compared at all and will import as a + duplicate. If your files are reliably tagged with ``artist`` and ``title`` + but not always with MusicBrainz IDs, the default ``item: artist title`` + catches more duplicates. + +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 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 every track is already + present, the whole album is skipped. A track matching an existing *singleton* + is skipped individually but does not affect where the new tracks go, so a mix + of album-member and singleton matches still completes the album. The new + tracks are imported as their own album only when the matched album members + span more than one album, or when none of the matches belong to an 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`. + +A typical configuration for completing partially-imported albums while +autotagging looks like this (values other than the defaults are commented): + +.. code-block:: yaml + + import: + duplicate_track_resolution: yes # default: no + duplicate_action: ask # default: ask -- whole-album duplicates + duplicate_track_action: skip # default: '' -- per-track duplicates; fold new tracks into the existing album + duplicate_keys: + item: mb_trackid # default: artist title -- match on a stable id (recommended when autotagging) + +If some of your files are missing ``mb_trackid`` but are otherwise correctly +tagged, keep the default ``item`` keys instead, so tracks are matched on their +names rather than an ID that may be absent: + +.. code-block:: yaml + + import: + duplicate_track_resolution: yes + duplicate_track_action: skip + duplicate_keys: + album: albumartist album # default + item: artist title # default + +Default: empty (inherit :ref:`duplicate_action`). + .. _bell: bell diff --git a/test/test_importer.py b/test/test_importer.py index 872af765ca..cab807a4c7 100644 --- a/test/test_importer.py +++ b/test/test_importer.py @@ -1289,6 +1289,258 @@ 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 items matching one or both of them (as + singletons unless noted otherwise). + """ + + 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 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, track_action=None): + self.setup_importer( + autotag=False, + duplicate_track_resolution=enabled, + duplicate_action=action, + duplicate_track_action=track_action or "", + ) + self.importer.run() + + 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") + + 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_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 folded back into the *same* album + # (no second album is created). + self._import(action="skip") + 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) + assert {i.title for i in self.lib.items()} == {"Tag Track 1"} + + self._import(action="skip") + + 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, and the remaining new track + # folded into that album. + 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; "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", + } + + 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_singleton_match_still_folds_into_album(self): + # snejus's scenario: incoming album has tracks 1, 2 and 3. Track 1 + # matches an album member (album X); track 2 matches a singleton; + # track 3 is new. The singleton match is skipped individually but does + # not prevent folding: track 3 (the intended outcome for "C") is folded + # into album X, the album the matched album member belongs to. + self.prepare_album_for_import(3) + member = self.add_album_member_fixture( + artist="Tag Artist", title="Tag Track 1" + ) + album_x = member.get_album() + self.add_item_fixture(artist="Tag Artist", title="Tag Track 2") + + self._import(action="skip") + + # No new album is created; track 3 joins album X. + assert len(self.lib.albums()) == 1 + new_track = self.lib.items("title:'Tag Track 3'").get() + assert new_track is not None + assert new_track.album_id == album_x.id + assert {i.title for i in album_x.items()} == { + "Tag Track 1", + "Tag Track 3", + } + + def test_within_run_dedup_sequential(self): + # Two identical albums imported in one (sequential) run: the second + # album's tracks are caught against the first, so only two items land + # overall. The session's seen-key cache makes this hold regardless of + # ordering (see the threaded variant below). + self.prepare_album_for_import(2, album_id=2) # a second album dir + self.setup_importer( + autotag=False, + duplicate_track_resolution=True, + duplicate_action="skip", + threaded=False, + ) + self.importer.run() + + titles = sorted(i.title for i in self.lib.items()) + assert titles == ["Tag Track 1", "Tag Track 2"] + + def test_seen_key_cache_catches_within_run_duplicate(self): + # Deterministically isolate the seen-key cache: drive the stage over + # two tasks with *no* DB add() in between (as can happen under a + # threaded import, where a later task is checked before an earlier one + # is added). The second task's matching track must be caught purely via + # the cache, against an empty library. + from beets.importer.stages import resolve_track_duplicates + from beets.importer.tasks import ImportTask + from beets.library import Item + + session = self.setup_importer( + autotag=False, + duplicate_track_resolution=True, + duplicate_action="skip", + ) + + def make_task(path): + item = Item(artist="Tag Artist", title="Tag Track 1") + item.path = path + return ImportTask(None, [path], [item]) + + task1 = make_task(b"/import/a/track.mp3") + task2 = make_task(b"/import/b/track.mp3") + + coro = resolve_track_duplicates(session) + next(coro) # prime the coroutine + coro.send(task1) # first occurrence: nothing dropped, key remembered + coro.send(task2) # second occurrence: caught via the cache alone + + assert not task1.skip + assert [i.title for i in task1.items] == ["Tag Track 1"] + assert task2.skip + assert task2.items == [] + + 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_track_action_overrides_duplicate_action(self): + self.add_item_fixture(artist="Tag Artist", title="Tag Track 1") + + # duplicate_action says keep, but the track-specific action skips. + self._import(action="keep", track_action="skip") + + # The duplicate was dropped (skip), not kept, so only two items exist. + assert len(self.lib.items()) == 2 + + def test_track_action_ask_falls_back_to_duplicate_action(self): + # duplicate_track_action="ask" with a non-interactive session would + # need a prompt, so exercise the ask dispatch through the terminal + # session instead (see test/ui/test_ui_importer.py). Here we simply + # confirm the config plumbing: an explicit track action of "keep" + # overrides duplicate_action=skip. + self.add_item_fixture(artist="Tag Artist", title="Tag Track 1") + + self._import(action="skip", track_action="keep") + + # "keep" wins, so nothing is dropped. + assert len(self.lib.items()) == 3 + + class TagLogTest(unittest.TestCase): def test_tag_log_line(self): sio = StringIO() diff --git a/test/ui/test_ui_importer.py b/test/ui/test_ui_importer.py index da3b20a75d..ad6e46e1a5 100644 --- a/test/ui/test_ui_importer.py +++ b/test/ui/test_ui_importer.py @@ -44,6 +44,51 @@ class ChooseCandidateTest( pass +class ImportTrackDuplicateResolutionTest( + TerminalImportMixin, test_importer.ImportTrackDuplicateResolutionTest +): + """Run the per-track duplicate tests through ``TerminalImportSession``. + + Also covers the interactive ``ask`` prompt, which the non-terminal + fixture cannot exercise. + """ + + def test_ask_prompt_skip(self): + self.add_item_fixture(artist="Tag Artist", title="Tag Track 1") + + self.io.addinput("s") + self._import(action="ask") + + # Answered "skip": the duplicate track is dropped, the other imported. + assert len(self.lib.albums()) == 1 + assert {i.title for i in self.lib.items()} == { + "Tag Track 1", + "Tag Track 2", + } + + def test_ask_prompt_remove(self): + old = self.add_item_fixture(artist="Tag Artist", title="Tag Track 1") + + self.io.addinput("r") + self._import(action="ask") + + # Answered "remove": the old library item (and file) is removed. + assert not old.filepath.exists() + assert sorted(i.title for i in self.lib.items()) == [ + "Tag Track 1", + "Tag Track 2", + ] + + def test_ask_prompt_keep(self): + self.add_item_fixture(artist="Tag Artist", title="Tag Track 1") + + self.io.addinput("k") + self._import(action="ask") + + # Answered "keep": nothing dropped or removed. + assert len(self.lib.items()) == 3 + + class GroupAlbumsImportTest( TerminalImportMixin, test_importer.GroupAlbumsImportTest ):