mirror of
https://github.com/beetbox/beets.git
synced 2026-02-12 02:12:10 +01:00
Merge pull request #2725 from udiboy1209/merge-albums
Implement album merging for duplicates
This commit is contained in:
commit
c5ed992638
6 changed files with 111 additions and 33 deletions
|
|
@ -188,6 +188,8 @@ class ImportSession(object):
|
|||
self.paths = paths
|
||||
self.query = query
|
||||
self._is_resuming = dict()
|
||||
self._merged_items = set()
|
||||
self._merged_dirs = set()
|
||||
|
||||
# Normalize the paths.
|
||||
if self.paths:
|
||||
|
|
@ -350,6 +352,24 @@ class ImportSession(object):
|
|||
self._history_dirs = history_get()
|
||||
return self._history_dirs
|
||||
|
||||
def already_merged(self, paths):
|
||||
"""Returns true if all the paths being imported were part of a merge
|
||||
during previous tasks.
|
||||
"""
|
||||
for path in paths:
|
||||
if path not in self._merged_items \
|
||||
and path not in self._merged_dirs:
|
||||
return False
|
||||
return True
|
||||
|
||||
def mark_merged(self, paths):
|
||||
"""Mark paths and directories as merged for future reimport tasks.
|
||||
"""
|
||||
self._merged_items.update(paths)
|
||||
dirs = set([os.path.dirname(path) if os.path.isfile(path) else path
|
||||
for path in paths])
|
||||
self._merged_dirs.update(dirs)
|
||||
|
||||
def is_resuming(self, toppath):
|
||||
"""Return `True` if user wants to resume import of this path.
|
||||
|
||||
|
|
@ -443,6 +463,7 @@ class ImportTask(BaseImportTask):
|
|||
self.candidates = []
|
||||
self.rec = None
|
||||
self.should_remove_duplicates = False
|
||||
self.should_merge_duplicates = False
|
||||
self.is_album = True
|
||||
self.search_ids = [] # user-supplied candidate IDs.
|
||||
|
||||
|
|
@ -632,10 +653,11 @@ class ImportTask(BaseImportTask):
|
|||
))
|
||||
|
||||
for album in lib.albums(duplicate_query):
|
||||
# Check whether the album is identical in contents, in which
|
||||
# case it is not a duplicate (will be replaced).
|
||||
# Check whether the album paths are all present in the task
|
||||
# i.e. album is being completely re-imported by the task,
|
||||
# in which case it is not a duplicate (will be replaced).
|
||||
album_paths = set(i.path for i in album.items())
|
||||
if album_paths != task_paths:
|
||||
if not (album_paths <= task_paths):
|
||||
duplicates.append(album)
|
||||
return duplicates
|
||||
|
||||
|
|
@ -1225,6 +1247,27 @@ class ImportTaskFactory(object):
|
|||
displayable_path(path), exc)
|
||||
|
||||
|
||||
# Pipeline utilities
|
||||
|
||||
def _freshen_items(items):
|
||||
# Clear IDs from re-tagged items so they appear "fresh" when
|
||||
# we add them back to the library.
|
||||
for item in items:
|
||||
item.id = None
|
||||
item.album_id = None
|
||||
|
||||
|
||||
def _extend_pipeline(tasks, *stages):
|
||||
# Return pipeline extension for stages with list of tasks
|
||||
if type(tasks) == list:
|
||||
task_iter = iter(tasks)
|
||||
else:
|
||||
task_iter = tasks
|
||||
|
||||
ipl = pipeline.Pipeline([task_iter] + list(stages))
|
||||
return pipeline.multiple(ipl.pull())
|
||||
|
||||
|
||||
# Full-album pipeline stages.
|
||||
|
||||
def read_tasks(session):
|
||||
|
|
@ -1270,12 +1313,7 @@ def query_tasks(session):
|
|||
log.debug(u'yielding album {0}: {1} - {2}',
|
||||
album.id, album.albumartist, album.album)
|
||||
items = list(album.items())
|
||||
|
||||
# Clear IDs from re-tagged items so they appear "fresh" when
|
||||
# we add them back to the library.
|
||||
for item in items:
|
||||
item.id = None
|
||||
item.album_id = None
|
||||
_freshen_items(items)
|
||||
|
||||
task = ImportTask(None, [album.item_dir()], items)
|
||||
for task in task.handle_created(session):
|
||||
|
|
@ -1321,6 +1359,9 @@ def user_query(session, task):
|
|||
if task.skip:
|
||||
return task
|
||||
|
||||
if session.already_merged(task.paths):
|
||||
return pipeline.BUBBLE
|
||||
|
||||
# Ask the user for a choice.
|
||||
task.choose_match(session)
|
||||
plugins.send('import_task_choice', session=session, task=task)
|
||||
|
|
@ -1335,24 +1376,38 @@ def user_query(session, task):
|
|||
yield new_task
|
||||
yield SentinelImportTask(task.toppath, task.paths)
|
||||
|
||||
ipl = pipeline.Pipeline([
|
||||
emitter(task),
|
||||
lookup_candidates(session),
|
||||
user_query(session),
|
||||
])
|
||||
return pipeline.multiple(ipl.pull())
|
||||
return _extend_pipeline(emitter(task),
|
||||
lookup_candidates(session),
|
||||
user_query(session))
|
||||
|
||||
# As albums: group items by albums and create task for each album
|
||||
if task.choice_flag is action.ALBUMS:
|
||||
ipl = pipeline.Pipeline([
|
||||
iter([task]),
|
||||
group_albums(session),
|
||||
lookup_candidates(session),
|
||||
user_query(session)
|
||||
])
|
||||
return pipeline.multiple(ipl.pull())
|
||||
return _extend_pipeline([task],
|
||||
group_albums(session),
|
||||
lookup_candidates(session),
|
||||
user_query(session))
|
||||
|
||||
resolve_duplicates(session, task)
|
||||
|
||||
if task.should_merge_duplicates:
|
||||
# Create a new task for tagging the current items
|
||||
# and duplicates together
|
||||
duplicate_items = task.duplicate_items(session.lib)
|
||||
|
||||
# Duplicates would be reimported so make them look "fresh"
|
||||
_freshen_items(duplicate_items)
|
||||
duplicate_paths = [item.path for item in duplicate_items]
|
||||
|
||||
# Record merged paths in the session so they are not reimported
|
||||
session.mark_merged(duplicate_paths)
|
||||
|
||||
merged_task = ImportTask(None, task.paths + duplicate_paths,
|
||||
task.items + duplicate_items)
|
||||
|
||||
return _extend_pipeline([merged_task],
|
||||
lookup_candidates(session),
|
||||
user_query(session))
|
||||
|
||||
apply_choice(session, task)
|
||||
return task
|
||||
|
||||
|
|
@ -1373,6 +1428,7 @@ def resolve_duplicates(session, task):
|
|||
u'skip': u's',
|
||||
u'keep': u'k',
|
||||
u'remove': u'r',
|
||||
u'merge': u'm',
|
||||
u'ask': u'a',
|
||||
})
|
||||
log.debug(u'default action for duplicates: {0}', duplicate_action)
|
||||
|
|
@ -1386,6 +1442,9 @@ def resolve_duplicates(session, task):
|
|||
elif duplicate_action == u'r':
|
||||
# Remove old.
|
||||
task.should_remove_duplicates = True
|
||||
elif duplicate_action == u'm':
|
||||
# Merge duplicates together
|
||||
task.should_merge_duplicates = True
|
||||
else:
|
||||
# No default action set; ask the session.
|
||||
session.resolve_duplicate(task, found_duplicates)
|
||||
|
|
|
|||
|
|
@ -791,7 +791,7 @@ class TerminalImportSession(importer.ImportSession):
|
|||
))
|
||||
|
||||
sel = ui.input_options(
|
||||
(u'Skip new', u'Keep both', u'Remove old')
|
||||
(u'Skip new', u'Keep both', u'Remove old', u'Merge all')
|
||||
)
|
||||
|
||||
if sel == u's':
|
||||
|
|
@ -803,6 +803,8 @@ class TerminalImportSession(importer.ImportSession):
|
|||
elif sel == u'r':
|
||||
# Remove old.
|
||||
task.should_remove_duplicates = True
|
||||
elif sel == u'm':
|
||||
task.should_merge_duplicates = True
|
||||
else:
|
||||
assert False
|
||||
|
||||
|
|
|
|||
|
|
@ -234,17 +234,25 @@ If beets finds an album or item in your library that seems to be the same as the
|
|||
one you're importing, you may see a prompt like this::
|
||||
|
||||
This album is already in the library!
|
||||
[S]kip new, Keep both, Remove old?
|
||||
[S]kip new, Keep both, Remove old, Merge all?
|
||||
|
||||
Beets wants to keep you safe from duplicates, which can be a real pain, so you
|
||||
have three choices in this situation. You can skip importing the new music,
|
||||
have four choices in this situation. You can skip importing the new music,
|
||||
choosing to keep the stuff you already have in your library; you can keep both
|
||||
the old and the new music; or you can remove the existing music and choose the
|
||||
new stuff. If you choose that last "trump" option, any duplicates will be
|
||||
the old and the new music; you can remove the existing music and choose the
|
||||
new stuff; or you can merge the newly imported album and existing duplicate
|
||||
into one single album.
|
||||
If you choose that "remove" option, any duplicates will be
|
||||
removed from your library database---and, if the corresponding files are located
|
||||
inside of your beets library directory, the files themselves will be deleted as
|
||||
well.
|
||||
|
||||
If you choose "merge", beets will try re-importing the existing and new tracks
|
||||
as one bundle so they will get tagged together appropriately.
|
||||
This is particularly helpful when you are importing extra tracks
|
||||
of an album in your library with missing tracks, so beets will ask you the same
|
||||
questions as it would if you were importing all tracks at once.
|
||||
|
||||
If you choose to keep two identically-named albums, beets can avoid storing both
|
||||
in the same directory. See :ref:`aunique` for details.
|
||||
|
||||
|
|
|
|||
|
|
@ -571,11 +571,12 @@ Default: ``yes``.
|
|||
duplicate_action
|
||||
~~~~~~~~~~~~~~~~
|
||||
|
||||
Either ``skip``, ``keep``, ``remove``, or ``ask``. Controls how duplicates
|
||||
are treated in import task. "skip" means that new item(album or track) will be
|
||||
skipped; "keep" means keep both old and new items; "remove" means remove old
|
||||
item; "ask" means the user should be prompted for the action each time.
|
||||
The default is ``ask``.
|
||||
Either ``skip``, ``keep``, ``remove``, ``merge`` or ``ask``.
|
||||
Controls how duplicates are treated in import task.
|
||||
"skip" means that new item(album or track) will be skipped;
|
||||
"keep" means keep both old and new items; "remove" means remove old
|
||||
item; "merge" means merge into one album; "ask" means the user
|
||||
should be prompted for the action each time. The default is ``ask``.
|
||||
|
||||
.. _bell:
|
||||
|
||||
|
|
|
|||
|
|
@ -535,7 +535,7 @@ class TestImportSession(importer.ImportSession):
|
|||
|
||||
choose_item = choose_match
|
||||
|
||||
Resolution = Enum('Resolution', 'REMOVE SKIP KEEPBOTH')
|
||||
Resolution = Enum('Resolution', 'REMOVE SKIP KEEPBOTH MERGE')
|
||||
|
||||
default_resolution = 'REMOVE'
|
||||
|
||||
|
|
@ -553,6 +553,8 @@ class TestImportSession(importer.ImportSession):
|
|||
task.set_choice(importer.action.SKIP)
|
||||
elif res == self.Resolution.REMOVE:
|
||||
task.should_remove_duplicates = True
|
||||
elif res == self.Resolution.MERGE:
|
||||
task.should_merge_duplicates = True
|
||||
|
||||
|
||||
def generate_album_info(album_id, track_ids):
|
||||
|
|
|
|||
|
|
@ -1248,6 +1248,12 @@ class ImportDuplicateAlbumTest(unittest.TestCase, TestHelper,
|
|||
item = self.lib.items().get()
|
||||
self.assertEqual(item.title, u't\xeftle 0')
|
||||
|
||||
def test_merge_duplicate_album(self):
|
||||
self.importer.default_resolution = self.importer.Resolution.MERGE
|
||||
self.importer.run()
|
||||
|
||||
self.assertEqual(len(self.lib.albums()), 1)
|
||||
|
||||
def test_twice_in_import_dir(self):
|
||||
self.skipTest('write me')
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue