update Beets

This commit is contained in:
AdeHub
2024-08-24 16:44:41 +12:00
parent a63098a919
commit 046d4d82b4
116 changed files with 17353 additions and 9964 deletions
+186 -135
View File
@@ -15,122 +15,150 @@
"""List duplicate tracks or albums.
"""
import os
import shlex
from beets.library import Album, Item
from beets.plugins import BeetsPlugin
from beets.ui import decargs, print_, Subcommand, UserError
from beets.util import command_output, displayable_path, subprocess, \
bytestring_path, MoveOperation, decode_commandline_path
from beets.library import Item, Album
from beets.ui import Subcommand, UserError, decargs, print_
from beets.util import (
MoveOperation,
bytestring_path,
command_output,
displayable_path,
subprocess,
)
PLUGIN = 'duplicates'
PLUGIN = "duplicates"
class DuplicatesPlugin(BeetsPlugin):
"""List duplicate tracks or albums
"""
"""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': '',
})
self.config.add(
{
"album": False,
"checksum": "",
"copy": "",
"count": False,
"delete": False,
"format": "",
"full": False,
"keys": [],
"merge": False,
"move": "",
"path": False,
"tiebreak": {},
"strict": False,
"tag": "",
}
)
self._command = Subcommand('duplicates',
help=__doc__,
aliases=['dup'])
self._command = Subcommand("duplicates", help=__doc__, aliases=["dup"])
self._command.parser.add_option(
'-c', '--count', dest='count',
action='store_true',
help='show duplicate counts',
"-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',
"-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',
"-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',
"-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',
"-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)',
"-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',
"-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',
"-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',
"-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',
"-t",
"--tag",
dest="tag",
action="store",
help="tag matched items with 'k=v' attribute",
)
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)
fmt = 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)
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)
fmt = 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']
keys = ["mb_albumid"]
items = lib.albums(decargs(args))
else:
if not keys:
keys = ['mb_trackid', 'mb_albumid']
keys = ["mb_trackid", "mb_albumid"]
items = lib.items(decargs(args))
# If there's nothing to do, return early. The code below assumes
@@ -139,43 +167,47 @@ class DuplicatesPlugin(BeetsPlugin):
return
if path:
fmt = '$path'
fmt = "$path"
# Default format string for count mode.
if count and not fmt:
if album:
fmt = '$albumartist - $album'
fmt = "$albumartist - $album"
else:
fmt = '$albumartist - $album - $title'
fmt += ': {0}'
fmt = "$albumartist - $album - $title"
fmt += ": {0}"
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):
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,
tag=tag,
fmt=fmt.format(obj_count))
self._process_item(
o,
copy=copy,
move=move,
delete=delete,
tag=tag,
fmt=fmt.format(obj_count),
)
self._command.func = _dup
return [self._command]
def _process_item(self, item, copy=False, move=False, delete=False,
tag=False, fmt=''):
"""Process Item `item`.
"""
def _process_item(
self, item, copy=False, move=False, delete=False, tag=False, fmt=""
):
"""Process Item `item`."""
print_(format(item, fmt))
if copy:
item.move(basedir=copy, operation=MoveOperation.COPY)
@@ -187,11 +219,9 @@ class DuplicatesPlugin(BeetsPlugin):
item.remove(delete=True)
if tag:
try:
k, v = tag.split('=')
k, v = tag.split("=")
except Exception:
raise UserError(
f"{PLUGIN}: can't parse k=v tag: {tag}"
)
raise UserError(f"{PLUGIN}: can't parse k=v tag: {tag}")
setattr(item, k, v)
item.store()
@@ -200,27 +230,36 @@ class DuplicatesPlugin(BeetsPlugin):
output as flexattr on a key that is the name of the program, and
return the key, checksum tuple.
"""
args = [p.format(file=decode_commandline_path(item.path))
for p in shlex.split(prog)]
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 {0} on item {1} not cached:'
'computing checksum',
key, displayable_path(item.path))
self._log.debug(
"key {0} on item {1} not cached:" "computing checksum",
key,
displayable_path(item.path),
)
try:
checksum = command_output(args).stdout
setattr(item, key, checksum)
item.store()
self._log.debug('computed checksum for {0} using {1}',
item.title, key)
self._log.debug(
"computed checksum for {0} using {1}", item.title, key
)
except subprocess.CalledProcessError as e:
self._log.debug('failed to checksum {0}: {1}',
displayable_path(item.path), e)
self._log.debug(
"failed to checksum {0}: {1}",
displayable_path(item.path),
e,
)
else:
self._log.debug('key {0} on item {1} cached:'
'not computing checksum',
key, displayable_path(item.path))
self._log.debug(
"key {0} on item {1} cached:" "not computing checksum",
key,
displayable_path(item.path),
)
return key, checksum
def _group_by(self, objs, keys, strict):
@@ -230,18 +269,23 @@ class DuplicatesPlugin(BeetsPlugin):
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, '')]
values = [v for v in values if v not in (None, "")]
if strict and len(values) < len(keys):
self._log.debug('some keys {0} on item {1} are null or empty:'
' skipping',
keys, displayable_path(obj.path))
elif (not strict and not len(values)):
self._log.debug('all keys {0} on item {1} are null or empty:'
' skipping',
keys, displayable_path(obj.path))
self._log.debug(
"some keys {0} on item {1} are null or empty:" " skipping",
keys,
displayable_path(obj.path),
)
elif not strict and not len(values):
self._log.debug(
"all keys {0} on item {1} are null or empty:" " skipping",
keys,
displayable_path(obj.path),
)
else:
key = tuple(values)
counts[key].append(obj)
@@ -257,18 +301,21 @@ class DuplicatesPlugin(BeetsPlugin):
"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'
kind = "items" if all(isinstance(o, Item) for o in objs) else "albums"
if tiebreak and kind in tiebreak.keys():
key = lambda x: tuple(getattr(x, k) for k in tiebreak[kind])
else:
if kind == 'items':
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)
return v is not None and (
v != "" if isinstance(v, str) else True
)
fields = Item.all_keys()
key = lambda x: sum(1 for f in fields if truthy(getattr(x, f)))
else:
@@ -285,13 +332,16 @@ class DuplicatesPlugin(BeetsPlugin):
fields = Item.all_keys()
for f in fields:
for o in objs[1:]:
if getattr(objs[0], f, None) in (None, ''):
if getattr(objs[0], f, None) in (None, ""):
value = getattr(o, f, None)
if value:
self._log.debug('key {0} on item {1} is null '
'or empty: setting from item {2}',
f, displayable_path(objs[0].path),
displayable_path(o.path))
self._log.debug(
"key {0} on item {1} is null "
"or empty: setting from item {2}",
f,
displayable_path(objs[0].path),
displayable_path(o.path),
)
setattr(objs[0], f, value)
objs[0].store()
break
@@ -309,12 +359,14 @@ class DuplicatesPlugin(BeetsPlugin):
missing = Item.from_path(i.path)
missing.album_id = objs[0].id
missing.add(i._db)
self._log.debug('item {0} missing from album {1}:'
' merging from {2} into {3}',
missing,
objs[0],
displayable_path(o.path),
displayable_path(missing.destination()))
self._log.debug(
"item {0} missing from album {1}:"
" merging from {2} into {3}",
missing,
objs[0],
displayable_path(o.path),
displayable_path(missing.destination()),
)
missing.move(operation=MoveOperation.COPY)
return objs
@@ -330,8 +382,7 @@ class DuplicatesPlugin(BeetsPlugin):
return objs
def _duplicates(self, objs, keys, full, strict, tiebreak, merge):
"""Generate triples of keys, duplicate counts, and constituent objects.
"""
"""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: