Files
beets/beetsplug/duplicates.py
2026-06-29 11:20:15 +02:00

400 lines
13 KiB
Python

"""List duplicate tracks or albums."""
import os
import shlex
from beets.library import Album, Item
from beets.plugins import BeetsPlugin
from beets.ui import Subcommand, UserError, print_
from beets.util import (
MoveOperation,
bytestring_path,
command_output,
displayable_path,
subprocess,
)
PLUGIN = "duplicates"
class DuplicatesPlugin(BeetsPlugin):
"""List duplicate tracks or albums"""
def __init__(self):
super().__init__()
self.config.add(
{
"album": False,
"checksum": "",
"copy": "",
"count": False,
"delete": False,
"format": "",
"full": False,
"keys": [],
"merge": False,
"move": "",
"path": False,
"tiebreak": {},
"strict": False,
"tag": "",
"remove": False,
}
)
self._command = Subcommand("duplicates", help=__doc__, aliases=["dup"])
self._command.parser.add_option(
"-c",
"--count",
dest="count",
action="store_true",
help="show duplicate counts",
)
self._command.parser.add_option(
"-C",
"--checksum",
dest="checksum",
action="store",
metavar="PROG",
help="report duplicates based on arbitrary command",
)
self._command.parser.add_option(
"-d",
"--delete",
dest="delete",
action="store_true",
help="delete items from library and disk",
)
self._command.parser.add_option(
"-F",
"--full",
dest="full",
action="store_true",
help="show all versions of duplicate tracks or albums",
)
self._command.parser.add_option(
"-s",
"--strict",
dest="strict",
action="store_true",
help="report duplicates only if all attributes are set",
)
self._command.parser.add_option(
"-k",
"--key",
dest="keys",
action="append",
metavar="KEY",
help="report duplicates based on keys (use multiple times)",
)
self._command.parser.add_option(
"-M",
"--merge",
dest="merge",
action="store_true",
help="merge duplicate items",
)
self._command.parser.add_option(
"-m",
"--move",
dest="move",
action="store",
metavar="DEST",
help="move items to dest",
)
self._command.parser.add_option(
"-o",
"--copy",
dest="copy",
action="store",
metavar="DEST",
help="copy items to dest",
)
self._command.parser.add_option(
"-t",
"--tag",
dest="tag",
action="store",
help="tag matched items with 'k=v' attribute",
)
self._command.parser.add_option(
"-r",
"--remove",
dest="remove",
action="store_true",
help="remove items from library",
)
self._command.parser.add_all_common_options()
def commands(self):
def _dup(lib, opts, args):
self.config.set_args(opts)
album = self.config["album"].get(bool)
checksum = self.config["checksum"].get(str)
copy = bytestring_path(self.config["copy"].as_str())
count = self.config["count"].get(bool)
delete = self.config["delete"].get(bool)
remove = self.config["remove"].get(bool)
fmt_tmpl = self.config["format"].get(str)
full = self.config["full"].get(bool)
keys = self.config["keys"].as_str_seq()
merge = self.config["merge"].get(bool)
move = bytestring_path(self.config["move"].as_str())
path = self.config["path"].get(bool)
tiebreak = self.config["tiebreak"].get(dict)
strict = self.config["strict"].get(bool)
tag = self.config["tag"].get(str)
if album:
if not keys:
keys = ["mb_albumid"]
items = lib.albums(args)
else:
if not keys:
keys = ["mb_trackid", "mb_albumid"]
items = lib.items(args)
# If there's nothing to do, return early. The code below assumes
# `items` to be non-empty.
if not items:
return
if path:
fmt_tmpl = "$path"
elif not fmt_tmpl:
if album:
fmt_tmpl = "$albumartist - $album"
else:
fmt_tmpl = "$albumartist - $album - $title"
if checksum:
for i in items:
k, _ = self._checksum(i, checksum)
keys = [k]
for obj_id, obj_count, objs in self._duplicates(
items,
keys=keys,
full=full,
strict=strict,
tiebreak=tiebreak,
merge=merge,
):
if obj_id: # Skip empty IDs.
for o in objs:
self._process_item(
o,
copy=copy,
move=move,
delete=delete,
remove=remove,
tag=tag,
fmt=(
fmt_tmpl
if not count
else f"{fmt_tmpl}: {obj_count}"
),
)
self._command.func = _dup
return [self._command]
def _process_item(
self,
item,
copy=False,
move=False,
delete=False,
tag=False,
fmt="",
remove=False,
):
"""Process Item `item`."""
print_(format(item, fmt))
if copy:
item.move(basedir=copy, operation=MoveOperation.COPY)
item.store()
if move:
item.move(basedir=move)
item.store()
if delete:
item.remove(delete=True)
elif remove:
item.remove(delete=False)
if tag:
try:
k, v = tag.split("=")
except Exception:
raise UserError(f"{PLUGIN}: can't parse k=v tag: {tag}")
setattr(item, k, v)
item.store()
def _checksum(self, item, prog):
"""Run external `prog` on file path associated with `item`, cache
output as flexattr on a key that is the name of the program, and
return the key, checksum tuple.
"""
args = [
p.format(file=os.fsdecode(item.path)) for p in shlex.split(prog)
]
key = args[0]
checksum = getattr(item, key, False)
if not checksum:
self._log.debug(
"key {} on item {.filepath} not cached:computing checksum",
key,
item,
)
try:
checksum = command_output(args).stdout
setattr(item, key, checksum)
item.store()
self._log.debug(
"computed checksum for {.title} using {}", item, key
)
except subprocess.CalledProcessError as e:
self._log.debug("failed to checksum {.filepath}: {}", item, e)
else:
self._log.debug(
"key {} on item {.filepath} cached:not computing checksum",
key,
item,
)
return key, checksum
def _group_by(self, objs, keys, strict):
"""Return a dictionary with keys arbitrary concatenations of attributes
and values lists of objects (Albums or Items) with those keys.
If strict, all attributes must be defined for a duplicate match.
"""
import collections
counts = collections.defaultdict(list)
for obj in objs:
values = [getattr(obj, k, None) for k in keys]
values = [v for v in values if v not in (None, "")]
if strict and len(values) < len(keys):
self._log.debug(
"some keys {} on item {.filepath} are null or empty: skipping",
keys,
obj,
)
elif not strict and not len(values):
self._log.debug(
"all keys {} on item {.filepath} are null or empty: skipping",
keys,
obj,
)
else:
key = tuple(values)
counts[key].append(obj)
return counts
def _order(self, objs, tiebreak=None):
"""Return the objects (Items or Albums) sorted by descending
order of priority.
If provided, the `tiebreak` dict indicates the field to use to
prioritize the objects. Otherwise, Items are placed in order of
"completeness" (objects with more non-null fields come first)
and Albums are ordered by their track count.
"""
kind = "items" if all(isinstance(o, Item) for o in objs) else "albums"
if tiebreak and kind in tiebreak.keys():
def key(x):
return tuple(getattr(x, k) for k in tiebreak[kind])
else:
if kind == "items":
def truthy(v):
# Avoid a Unicode warning by avoiding comparison
# between a bytes object and the empty Unicode
# string ''.
return v is not None and (
v != "" if isinstance(v, str) else True
)
fields = Item.all_keys()
def key(x):
return sum(1 for f in fields if truthy(getattr(x, f)))
else:
def key(x):
return len(x.items())
return sorted(objs, key=key, reverse=True)
def _merge_items(self, objs):
"""Merge Item objs by copying missing fields from items in the tail to
the head item.
Return same number of items, with the head item modified.
"""
fields = Item.all_keys()
for f in fields:
for o in objs[1:]:
if getattr(objs[0], f, None) in (None, ""):
value = getattr(o, f, None)
if value:
self._log.debug(
"key {} on item {} is null "
"or empty: setting from item {.filepath}",
f,
displayable_path(objs[0].path),
o,
)
setattr(objs[0], f, value)
objs[0].store()
break
return objs
def _merge_albums(self, objs):
"""Merge Album objs by copying missing items from albums in the tail
to the head album.
Return same number of albums, with the head album modified."""
ids = [i.mb_trackid for i in objs[0].items()]
for o in objs[1:]:
for i in o.items():
if i.mb_trackid not in ids:
missing = Item.from_path(i.path)
missing.album_id = objs[0].id
missing.add(i._db)
self._log.debug(
"item {} missing from album {}:"
" merging from {.filepath} into {}",
missing,
objs[0],
o,
displayable_path(missing.destination()),
)
missing.move(operation=MoveOperation.COPY)
return objs
def _merge(self, objs):
"""Merge duplicate items. See ``_merge_items`` and ``_merge_albums``
for the relevant strategies.
"""
kind = Item if all(isinstance(o, Item) for o in objs) else Album
if kind is Item:
objs = self._merge_items(objs)
else:
objs = self._merge_albums(objs)
return objs
def _duplicates(self, objs, keys, full, strict, tiebreak, merge):
"""Generate triples of keys, duplicate counts, and constituent objects."""
offset = 0 if full else 1
for k, objs in self._group_by(objs, keys, strict).items():
if len(objs) > 1:
objs = self._order(objs, tiebreak)
if merge:
objs = self._merge(objs)
yield (k, len(objs) - offset, objs[offset:])