Merge remote-tracking branch 'upstream/master' into spotify_timeout

This commit is contained in:
Alok Saboo 2023-10-15 09:36:54 -04:00
commit 66bf0023ea
12 changed files with 1502 additions and 365 deletions

View file

@ -97,13 +97,34 @@ ui:
length_diff_thresh: 10.0
color: yes
colors:
text_success: green
text_warning: yellow
text_error: red
text_highlight: red
text_highlight_minor: lightgray
action_default: turquoise
action: blue
text_success: ['bold', 'green']
text_warning: ['bold', 'yellow']
text_error: ['bold', 'red']
text_highlight: ['bold', 'red']
text_highlight_minor: ['white']
action_default: ['bold', 'cyan']
action: ['bold', 'cyan']
# New Colors
text: ['normal']
text_faint: ['faint']
import_path: ['bold', 'blue']
import_path_items: ['bold', 'blue']
added: ['green']
removed: ['red']
changed: ['yellow']
added_highlight: ['bold', 'green']
removed_highlight: ['bold', 'red']
changed_highlight: ['bold', 'yellow']
text_diff_added: ['bold', 'red']
text_diff_removed: ['bold', 'red']
text_diff_changed: ['bold', 'red']
action_description: ['white']
import:
indentation:
match_header: 2
match_details: 2
match_tracklist: 5
layout: column
format_item: $artist - $album - $title
format_album: $albumartist - $album
@ -176,3 +197,5 @@ match:
ignore_video_tracks: yes
track_length_grace: 10
track_length_max: 30
album_disambig_fields: data_source media year country label catalognum albumdisambig
singleton_disambig_fields: data_source index track_alt album

View file

@ -184,6 +184,13 @@ def should_move(move_opt=None):
# Input prompts.
def indent(count):
"""Returns a string with `count` many spaces.
"""
return " " * count
def input_(prompt=None):
"""Like `input`, but decodes the result to a Unicode string.
Raises a UserError if stdin is not available. The prompt is sent to
@ -267,8 +274,11 @@ def input_options(options, require=False, prompt=None, fallback_prompt=None,
show_letter)
# Insert the highlighted letter back into the word.
descr_color = "action_default" if is_default else "action_description"
capitalized.append(
option[:index] + show_letter + option[index + 1:]
colorize(descr_color, option[:index])
+ show_letter
+ colorize(descr_color, option[index + 1:])
)
display_letters.append(found_letter.upper())
@ -301,15 +311,16 @@ def input_options(options, require=False, prompt=None, fallback_prompt=None,
prompt_part_lengths += [len(s) for s in options]
# Wrap the query text.
prompt = ''
# Start prompt with U+279C: Heavy Round-Tipped Rightwards Arrow
prompt = colorize("action", "\u279C ")
line_length = 0
for i, (part, length) in enumerate(zip(prompt_parts,
prompt_part_lengths)):
# Add punctuation.
if i == len(prompt_parts) - 1:
part += '?'
part += colorize("action_description", "?")
else:
part += ','
part += colorize("action_description", ",")
length += 1
# Choose either the current line or the beginning of the next.
@ -368,10 +379,12 @@ def input_yn(prompt, require=False):
"""Prompts the user for a "yes" or "no" response. The default is
"yes" unless `require` is `True`, in which case there is no default.
"""
sel = input_options(
('y', 'n'), require, prompt, 'Enter Y or N:'
# Start prompt with U+279C: Heavy Round-Tipped Rightwards Arrow
yesno = colorize("action", "\u279C ") + colorize(
"action_description", "Enter Y or N:"
)
return sel == 'y'
sel = input_options(("y", "n"), require, prompt, yesno)
return sel == "y"
def input_select_objects(prompt, objs, rep, prompt_all=None):
@ -465,51 +478,102 @@ def human_seconds_short(interval):
# https://bitbucket.org/birkenfeld/pygments-main/src/default/pygments/console.py
# (pygments is by Tim Hatch, Armin Ronacher, et al.)
COLOR_ESCAPE = "\x1b["
DARK_COLORS = {
"black": 0,
"darkred": 1,
"darkgreen": 2,
"brown": 3,
"darkyellow": 3,
"darkblue": 4,
"purple": 5,
"darkmagenta": 5,
"teal": 6,
"darkcyan": 6,
"lightgray": 7
LEGACY_COLORS = {
"black": ["black"],
"darkred": ["red"],
"darkgreen": ["green"],
"brown": ["yellow"],
"darkyellow": ["yellow"],
"darkblue": ["blue"],
"purple": ["magenta"],
"darkmagenta": ["magenta"],
"teal": ["cyan"],
"darkcyan": ["cyan"],
"lightgray": ["white"],
"darkgray": ["bold", "black"],
"red": ["bold", "red"],
"green": ["bold", "green"],
"yellow": ["bold", "yellow"],
"blue": ["bold", "blue"],
"fuchsia": ["bold", "magenta"],
"magenta": ["bold", "magenta"],
"turquoise": ["bold", "cyan"],
"cyan": ["bold", "cyan"],
"white": ["bold", "white"],
}
LIGHT_COLORS = {
"darkgray": 0,
"red": 1,
"green": 2,
"yellow": 3,
"blue": 4,
"fuchsia": 5,
"magenta": 5,
"turquoise": 6,
"cyan": 6,
"white": 7
# All ANSI Colors.
ANSI_CODES = {
# Styles.
"normal": 0,
"bold": 1,
"faint": 2,
# "italic": 3,
"underline": 4,
# "blink_slow": 5,
# "blink_rapid": 6,
"inverse": 7,
# "conceal": 8,
# "crossed_out": 9
# Text colors.
"black": 30,
"red": 31,
"green": 32,
"yellow": 33,
"blue": 34,
"magenta": 35,
"cyan": 36,
"white": 37,
# Background colors.
"bg_black": 40,
"bg_red": 41,
"bg_green": 42,
"bg_yellow": 43,
"bg_blue": 44,
"bg_magenta": 45,
"bg_cyan": 46,
"bg_white": 47,
}
RESET_COLOR = COLOR_ESCAPE + "39;49;00m"
# These abstract COLOR_NAMES are lazily mapped on to the actual color in COLORS
# as they are defined in the configuration files, see function: colorize
COLOR_NAMES = ['text_success', 'text_warning', 'text_error', 'text_highlight',
'text_highlight_minor', 'action_default', 'action']
COLOR_NAMES = [
"text_success",
"text_warning",
"text_error",
"text_highlight",
"text_highlight_minor",
"action_default",
"action",
# New Colors
"text",
"text_faint",
"import_path",
"import_path_items",
"action_description",
"added",
"removed",
"changed",
"added_highlight",
"removed_highlight",
"changed_highlight",
"text_diff_added",
"text_diff_removed",
"text_diff_changed",
]
COLORS = None
def _colorize(color, text):
"""Returns a string that prints the given text in the given color
in a terminal that is ANSI color-aware. The color must be something
in DARK_COLORS or LIGHT_COLORS.
in a terminal that is ANSI color-aware. The color must be a list of strings
from ANSI_CODES.
"""
if color in DARK_COLORS:
escape = COLOR_ESCAPE + "%im" % (DARK_COLORS[color] + 30)
elif color in LIGHT_COLORS:
escape = COLOR_ESCAPE + "%i;01m" % (LIGHT_COLORS[color] + 30)
else:
raise ValueError('no such color %s', color)
# Construct escape sequence to be put before the text by iterating
# over all "ANSI codes" in `color`.
escape = ""
for code in color:
escape = escape + COLOR_ESCAPE + "%im" % ANSI_CODES[code]
return escape + text + RESET_COLOR
@ -517,45 +581,128 @@ def colorize(color_name, text):
"""Colorize text if colored output is enabled. (Like _colorize but
conditional.)
"""
if not config['ui']['color'] or 'NO_COLOR' in os.environ.keys():
if config["ui"]["color"]:
global COLORS
if not COLORS:
# Read all color configurations and set global variable COLORS.
COLORS = dict()
for name in COLOR_NAMES:
# Convert legacy color definitions (strings) into the new
# list-based color definitions. Do this by trying to read the
# color definition from the configuration as unicode - if this
# is successful, the color definition is a legacy definition
# and has to be converted.
try:
color_def = config["ui"]["colors"][name].get(str)
except (confuse.ConfigTypeError, NameError):
# Normal color definition (type: list of unicode).
color_def = config["ui"]["colors"][name].get(list)
else:
# Legacy color definition (type: unicode). Convert.
if color_def in LEGACY_COLORS:
color_def = LEGACY_COLORS[color_def]
else:
raise UserError("no such color %s", color_def)
for code in color_def:
if code not in ANSI_CODES.keys():
raise ValueError("no such ANSI code %s", code)
COLORS[name] = color_def
# In case a 3rd party plugin is still passing the actual color ('red')
# instead of the abstract color name ('text_error')
color = COLORS.get(color_name)
if not color:
log.debug("Invalid color_name: {0}", color_name)
color = color_name
return _colorize(color, text)
else:
return text
global COLORS
if not COLORS:
COLORS = {name:
config['ui']['colors'][name].as_str()
for name in COLOR_NAMES}
# In case a 3rd party plugin is still passing the actual color ('red')
# instead of the abstract color name ('text_error')
color = COLORS.get(color_name)
if not color:
log.debug('Invalid color_name: {0}', color_name)
color = color_name
return _colorize(color, text)
def uncolorize(colored_text):
"""Remove colors from a string."""
# Define a regular expression to match ANSI codes.
# See: http://stackoverflow.com/a/2187024/1382707
# Explanation of regular expression:
# \x1b - matches ESC character
# \[ - matches opening square bracket
# [;\d]* - matches a sequence consisting of one or more digits or
# semicola
# [A-Za-z] - matches a letter
ansi_code_regex = re.compile(r"\x1b\[[;\d]*[A-Za-z]", re.VERBOSE)
# Strip ANSI codes from `colored_text` using the regular expression.
text = ansi_code_regex.sub("", colored_text)
return text
def _colordiff(a, b, highlight='text_highlight',
minor_highlight='text_highlight_minor'):
def color_split(colored_text, index):
ansi_code_regex = re.compile(r"(\x1b\[[;\d]*[A-Za-z])", re.VERBOSE)
length = 0
pre_split = ""
post_split = ""
found_color_code = None
found_split = False
for part in ansi_code_regex.split(colored_text):
# Count how many real letters we have passed
length += color_len(part)
if found_split:
post_split += part
else:
if ansi_code_regex.match(part):
# This is a color code
if part == RESET_COLOR:
found_color_code = None
else:
found_color_code = part
pre_split += part
else:
if index < length:
# Found part with our split in.
split_index = index - (length - color_len(part))
found_split = True
if found_color_code:
pre_split += part[:split_index] + RESET_COLOR
post_split += found_color_code + part[split_index:]
else:
pre_split += part[:split_index]
post_split += part[split_index:]
else:
# Not found, add this part to the pre split
pre_split += part
return pre_split, post_split
def color_len(colored_text):
"""Measure the length of a string while excluding ANSI codes from the
measurement. The standard `len(my_string)` method also counts ANSI codes
to the string length, which is counterproductive when layouting a
Terminal interface.
"""
# Return the length of the uncolored string.
return len(uncolorize(colored_text))
def _colordiff(a, b):
"""Given two values, return the same pair of strings except with
their differences highlighted in the specified color. Strings are
highlighted intelligently to show differences; other values are
stringified and highlighted in their entirety.
"""
if not isinstance(a, str) \
or not isinstance(b, str):
# Non-strings: use ordinary equality.
a = str(a)
b = str(b)
if a == b:
return a, b
else:
return colorize(highlight, a), colorize(highlight, b)
# First, convert paths to readable format
if isinstance(a, bytes) or isinstance(b, bytes):
# A path field.
a = util.displayable_path(a)
b = util.displayable_path(b)
if not isinstance(a, str) or not isinstance(b, str):
# Non-strings: use ordinary equality.
if a == b:
return str(a), str(b)
else:
return (
colorize("text_diff_removed", str(a)),
colorize("text_diff_added", str(b))
)
a_out = []
b_out = []
@ -567,31 +714,36 @@ def _colordiff(a, b, highlight='text_highlight',
b_out.append(b[b_start:b_end])
elif op == 'insert':
# Right only.
b_out.append(colorize(highlight, b[b_start:b_end]))
b_out.append(colorize("text_diff_added",
b[b_start:b_end]))
elif op == 'delete':
# Left only.
a_out.append(colorize(highlight, a[a_start:a_end]))
a_out.append(colorize("text_diff_removed",
a[a_start:a_end]))
elif op == 'replace':
# Right and left differ. Colorise with second highlight if
# it's just a case change.
if a[a_start:a_end].lower() != b[b_start:b_end].lower():
color = highlight
a_color = "text_diff_removed"
b_color = "text_diff_added"
else:
color = minor_highlight
a_out.append(colorize(color, a[a_start:a_end]))
b_out.append(colorize(color, b[b_start:b_end]))
a_color = b_color = "text_highlight_minor"
a_out.append(colorize(a_color,
a[a_start:a_end]))
b_out.append(colorize(b_color,
b[b_start:b_end]))
else:
assert False
return ''.join(a_out), ''.join(b_out)
def colordiff(a, b, highlight='text_highlight'):
def colordiff(a, b):
"""Colorize differences between two values if color is enabled.
(Like _colordiff but conditional.)
"""
if config['ui']['color']:
return _colordiff(a, b, highlight)
return _colordiff(a, b)
else:
return str(a), str(b)
@ -648,6 +800,335 @@ def term_width():
return width
def split_into_lines(string, width_tuple):
"""Splits string into a list of substrings at whitespace.
`width_tuple` is a 3-tuple of `(first_width, last_width, middle_width)`.
The first substring has a length not longer than `first_width`, the last
substring has a length not longer than `last_width`, and all other
substrings have a length not longer than `middle_width`.
`string` may contain ANSI codes at word borders.
"""
first_width, middle_width, last_width = width_tuple
words = []
esc_text = re.compile(r"""(?P<pretext>[^\x1b]*)
(?P<esc>(?:\x1b\[[;\d]*[A-Za-z])+)
(?P<text>[^\x1b]+)(?P<reset>\x1b\[39;49;00m)
(?P<posttext>[^\x1b]*)""",
re.VERBOSE)
if uncolorize(string) == string:
# No colors in string
words = string.split()
else:
# Use a regex to find escapes and the text within them.
for m in esc_text.finditer(string):
# m contains four groups:
# pretext - any text before escape sequence
# esc - intitial escape sequence
# text - text, no escape sequence, may contain spaces
# reset - ASCII colour reset
space_before_text = False
if m.group("pretext") != "":
# Some pretext found, let's handle it
# Add any words in the pretext
words += m.group("pretext").split()
if m.group("pretext")[-1] == " ":
# Pretext ended on a space
space_before_text = True
else:
# Pretext ended mid-word, ensure next word
pass
else:
# pretext empty, treat as if there is a space before
space_before_text = True
if m.group("text")[0] == " ":
# First character of the text is a space
space_before_text = True
# Now, handle the words in the main text:
raw_words = m.group("text").split()
if space_before_text:
# Colorize each word with pre/post escapes
# Reconstruct colored words
words += [m.group("esc") + raw_word
+ RESET_COLOR for raw_word in raw_words]
else:
# Pretext stops mid-word
if m.group("esc") != RESET_COLOR:
# Add the rest of the current word, with a reset after it
words[-1] += m.group("esc") + raw_words[0] + RESET_COLOR
# Add the subsequent colored words:
words += [m.group("esc") + raw_word
+ RESET_COLOR for raw_word in raw_words[1:]]
else:
# Caught a mid-word escape sequence
words[-1] += raw_words[0]
words += raw_words[1:]
if (m.group("text")[-1] != " " and m.group("posttext") != ""
and m.group("posttext")[0] != " "):
# reset falls mid-word
post_text = m.group("posttext").split()
words[-1] += post_text[0]
words += post_text[1:]
else:
# Add any words after escape sequence
words += m.group("posttext").split()
result = []
next_substr = ""
# Iterate over all words.
previous_fit = False
for i in range(len(words)):
if i == 0:
pot_substr = words[i]
else:
# (optimistically) add the next word to check the fit
pot_substr = " ".join([next_substr, words[i]])
# Find out if the pot(ential)_substr fits into the next substring.
fits_first = (
len(result) == 0 and color_len(pot_substr) <= first_width
)
fits_middle = (
len(result) != 0 and color_len(pot_substr) <= middle_width
)
if fits_first or fits_middle:
# Fitted(!) let's try and add another word before appending
next_substr = pot_substr
previous_fit = True
elif not fits_first and not fits_middle and previous_fit:
# Extra word didn't fit, append what we have
result.append(next_substr)
next_substr = words[i]
previous_fit = color_len(next_substr) <= middle_width
else:
# Didn't fit anywhere
if uncolorize(pot_substr) == pot_substr:
# Simple uncolored string, append a cropped word
if len(result) == 0:
# Crop word by the first_width for the first line
result.append(pot_substr[:first_width])
# add rest of word to next line
next_substr = pot_substr[first_width:]
else:
result.append(pot_substr[:middle_width])
next_substr = pot_substr[middle_width:]
else:
# Colored strings
if len(result) == 0:
this_line, next_line = color_split(pot_substr, first_width)
result.append(this_line)
next_substr = next_line
else:
this_line, next_line = color_split(pot_substr,
middle_width)
result.append(this_line)
next_substr = next_line
previous_fit = color_len(next_substr) <= middle_width
# We finished constructing the substrings, but the last substring
# has not yet been added to the result.
result.append(next_substr)
# Also, the length of the last substring was only checked against
# `middle_width`. Append an empty substring as the new last substring if
# the last substring is too long.
if not color_len(next_substr) <= last_width:
result.append('')
return result
def print_column_layout(
indent_str, left, right, separator=" -> ", max_width=term_width()
):
"""Print left & right data, with separator inbetween
'left' and 'right' have a structure of:
{'prefix':u'','contents':u'','suffix':u'','width':0}
In a column layout the printing will be:
{indent_str}{lhs0}{separator}{rhs0}
{lhs1 / padding }{rhs1}
...
The first line of each column (i.e. {lhs0} or {rhs0}) is:
{prefix}{part of contents}{suffix}
With subsequent lines (i.e. {lhs1}, {rhs1} onwards) being the
rest of contents, wrapped if the width would be otherwise exceeded.
"""
if right["prefix"] + right["contents"] + right["suffix"] == '':
# No right hand information, so we don't need a separator.
separator = ""
first_line_no_wrap = (
indent_str
+ left["prefix"]
+ left["contents"]
+ left["suffix"]
+ separator
+ right["prefix"]
+ right["contents"]
+ right["suffix"]
)
if color_len(first_line_no_wrap) < max_width:
# Everything fits, print out line.
print_(first_line_no_wrap)
else:
# Wrap into columns
if "width" not in left or "width" not in right:
# If widths have not been defined, set to share space.
left["width"] = (max_width - len(indent_str)
- color_len(separator)) // 2
right["width"] = (max_width - len(indent_str)
- color_len(separator)) // 2
# On the first line, account for suffix as well as prefix
left_width_tuple = (
left["width"] - color_len(left["prefix"])
- color_len(left["suffix"]),
left["width"] - color_len(left["prefix"]),
left["width"] - color_len(left["prefix"]),
)
left_split = split_into_lines(left["contents"], left_width_tuple)
right_width_tuple = (
right["width"] - color_len(right["prefix"])
- color_len(right["suffix"]),
right["width"] - color_len(right["prefix"]),
right["width"] - color_len(right["prefix"]),
)
right_split = split_into_lines(right["contents"], right_width_tuple)
max_line_count = max(len(left_split), len(right_split))
out = ""
for i in range(max_line_count):
# indentation
out += indent_str
# Prefix or indent_str for line
if i == 0:
out += left["prefix"]
else:
out += indent(color_len(left["prefix"]))
# Line i of left hand side contents.
if i < len(left_split):
out += left_split[i]
left_part_len = color_len(left_split[i])
else:
left_part_len = 0
# Padding until end of column.
# Note: differs from original
# column calcs in not -1 afterwards for space
# in track number as that is included in 'prefix'
padding = left["width"] - color_len(left["prefix"]) - left_part_len
# Remove some padding on the first line to display
# length
if i == 0:
padding -= color_len(left["suffix"])
out += indent(padding)
if i == 0:
out += left["suffix"]
# Separator between columns.
if i == 0:
out += separator
else:
out += indent(color_len(separator))
# Right prefix, contents, padding, suffix
if i == 0:
out += right["prefix"]
else:
out += indent(color_len(right["prefix"]))
# Line i of right hand side.
if i < len(right_split):
out += right_split[i]
right_part_len = color_len(right_split[i])
else:
right_part_len = 0
# Padding until end of column
padding = right["width"] - color_len(right["prefix"]) \
- right_part_len
# Remove some padding on the first line to display
# length
if i == 0:
padding -= color_len(right["suffix"])
out += indent(padding)
# Length in first line
if i == 0:
out += right["suffix"]
# Linebreak, except in the last line.
if i < max_line_count - 1:
out += "\n"
# Constructed all of the columns, now print
print_(out)
def print_newline_layout(
indent_str, left, right, separator=" -> ", max_width=term_width()
):
"""Prints using a newline separator between left & right if
they go over their allocated widths. The datastructures are
shared with the column layout. In contrast to the column layout,
the prefix and suffix are printed at the beginning and end of
the contents. If no wrapping is required (i.e. everything fits) the
first line will look exactly the same as the column layout:
{indent}{lhs0}{separator}{rhs0}
However if this would go over the width given, the layout now becomes:
{indent}{lhs0}
{indent}{separator}{rhs0}
If {lhs0} would go over the maximum width, the subsequent lines are
indented a second time for ease of reading.
"""
if right["prefix"] + right["contents"] + right["suffix"] == '':
# No right hand information, so we don't need a separator.
separator = ""
first_line_no_wrap = (
indent_str
+ left["prefix"]
+ left["contents"]
+ left["suffix"]
+ separator
+ right["prefix"]
+ right["contents"]
+ right["suffix"]
)
if color_len(first_line_no_wrap) < max_width:
# Everything fits, print out line.
print_(first_line_no_wrap)
else:
# Newline separation, with wrapping
empty_space = max_width - len(indent_str)
# On lower lines we will double the indent for clarity
left_width_tuple = (
empty_space,
empty_space - len(indent_str),
empty_space - len(indent_str),
)
left_str = left["prefix"] + left["contents"] + left["suffix"]
left_split = split_into_lines(left_str, left_width_tuple)
# Repeat calculations for rhs, including separator on first line
right_width_tuple = (
empty_space - color_len(separator),
empty_space - len(indent_str),
empty_space - len(indent_str),
)
right_str = right["prefix"] + right["contents"] + right["suffix"]
right_split = split_into_lines(right_str, right_width_tuple)
for i, line in enumerate(left_split):
if i == 0:
print_(indent_str + line)
elif line != "":
# Ignore empty lines
print_(indent_str * 2 + line)
for i, line in enumerate(right_split):
if i == 0:
print_(indent_str + separator + line)
elif line != "":
print_(indent_str * 2 + line)
FLOAT_EPSILON = 0.01

View file

@ -22,10 +22,12 @@ import re
from platform import python_version
from collections import namedtuple, Counter
from itertools import chain
from typing import Sequence
import beets
from beets import ui
from beets.ui import print_, input_, decargs, show_path_changes
from beets.ui import print_, input_, decargs, show_path_changes, \
print_newline_layout, print_column_layout
from beets import autotag
from beets.autotag import Recommendation
from beets.autotag import hooks
@ -159,7 +161,6 @@ default_commands.append(fields_cmd)
# help: Print help text for commands
class HelpCommand(ui.Subcommand):
def __init__(self):
super().__init__(
'help', aliases=('?',),
@ -189,57 +190,78 @@ def disambig_string(info):
provides context that helps disambiguate similar-looking albums and
tracks.
"""
disambig = []
if info.data_source and info.data_source != 'MusicBrainz':
disambig.append(info.data_source)
if isinstance(info, hooks.AlbumInfo):
if info.media:
if info.mediums and info.mediums > 1:
disambig.append('{}x{}'.format(
info.mediums, info.media
))
else:
disambig.append(info.media)
if info.year:
disambig.append(str(info.year))
if info.country:
disambig.append(info.country)
if info.label:
disambig.append(info.label)
if info.catalognum:
disambig.append(info.catalognum)
if info.albumdisambig:
disambig.append(info.albumdisambig)
# Let the user differentiate between pseudo and actual releases.
if info.albumstatus == 'Pseudo-Release':
disambig.append(info.albumstatus)
disambig = get_album_disambig_fields(info)
elif isinstance(info, hooks.TrackInfo):
disambig = get_singleton_disambig_fields(info)
else:
return ''
if isinstance(info, hooks.TrackInfo):
if info.index:
disambig.append("Index {}".format(str(info.index)))
if info.track_alt:
disambig.append("Track {}".format(info.track_alt))
if (config['import']['singleton_album_disambig'].get()
and info.get('album')):
disambig.append("[{}]".format(info.album))
return ', '.join(disambig)
if disambig:
return ', '.join(disambig)
def get_singleton_disambig_fields(info: hooks.TrackInfo) -> Sequence[str]:
out = []
chosen_fields = config['match']['singleton_disambig_fields'].as_str_seq()
calculated_values = {
'index': "Index {}".format(str(info.index)),
'track_alt': "Track {}".format(info.track_alt),
'album': "[{}]".format(info.album) if
(config['import']['singleton_album_disambig'].get() and
info.get('album')) else '',
}
for field in chosen_fields:
if field in calculated_values:
out.append(str(calculated_values[field]))
else:
try:
out.append(str(info[field]))
except (AttributeError, KeyError):
print(f"Disambiguation string key {field} does not exist.")
return out
def get_album_disambig_fields(info: hooks.AlbumInfo) -> Sequence[str]:
out = []
chosen_fields = config['match']['album_disambig_fields'].as_str_seq()
calculated_values = {
'media': '{}x{}'.format(info.mediums, info.media) if
(info.mediums and info.mediums > 1) else info.media,
}
for field in chosen_fields:
if field in calculated_values:
out.append(str(calculated_values[field]))
else:
try:
out.append(str(info[field]))
except (AttributeError, KeyError):
print(f"Disambiguation string key {field} does not exist.")
return out
def dist_colorize(string, dist):
"""Formats a string as a colorized similarity string according to
a distance.
"""
if dist <= config["match"]["strong_rec_thresh"].as_number():
string = ui.colorize("text_success", string)
elif dist <= config["match"]["medium_rec_thresh"].as_number():
string = ui.colorize("text_warning", string)
else:
string = ui.colorize("text_error", string)
return string
def dist_string(dist):
"""Formats a distance (a float) as a colorized similarity percentage
string.
"""
out = '%.1f%%' % ((1 - dist) * 100)
if dist <= config['match']['strong_rec_thresh'].as_number():
out = ui.colorize('text_success', out)
elif dist <= config['match']['medium_rec_thresh'].as_number():
out = ui.colorize('text_warning', out)
else:
out = ui.colorize('text_error', out)
return out
string = "{:.1f}%".format(((1 - dist) * 100))
return dist_colorize(string, dist)
def penalty_string(distance, limit=None):
@ -255,24 +277,172 @@ def penalty_string(distance, limit=None):
if penalties:
if limit and len(penalties) > limit:
penalties = penalties[:limit] + ['...']
return ui.colorize('text_warning', '(%s)' % ', '.join(penalties))
# Prefix penalty string with U+2260: Not Equal To
penalty_string = "\u2260 {}".format(", ".join(penalties))
return ui.colorize("changed", penalty_string)
def show_change(cur_artist, cur_album, match):
"""Print out a representation of the changes that will be made if an
album's tags are changed according to `match`, which must be an AlbumMatch
object.
class ChangeRepresentation(object):
"""Keeps track of all information needed to generate a (colored) text
representation of the changes that will be made if an album or singleton's
tags are changed according to `match`, which must be an AlbumMatch or
TrackMatch object, accordingly.
"""
def show_album(artist, album):
if artist:
album_description = f' {artist} - {album}'
elif album:
album_description = ' %s' % album
else:
album_description = ' (unknown album)'
print_(album_description)
def format_index(track_info):
cur_artist = None
# cur_album set if album, cur_title set if singleton
cur_album = None
cur_title = None
match = None
indent_header = ""
indent_detail = ""
def __init__(self):
# Read match header indentation width from config.
match_header_indent_width = config["ui"]["import"]["indentation"][
"match_header"
].as_number()
self.indent_header = ui.indent(match_header_indent_width)
# Read match detail indentation width from config.
match_detail_indent_width = config["ui"]["import"]["indentation"][
"match_details"
].as_number()
self.indent_detail = ui.indent(match_detail_indent_width)
# Read match tracklist indentation width from config
match_tracklist_indent_width = config["ui"]["import"]["indentation"][
"match_tracklist"
].as_number()
self.indent_tracklist = ui.indent(match_tracklist_indent_width)
self.layout = config["ui"]["import"]["layout"].as_choice(
{
"column": 0,
"newline": 1,
}
)
def print_layout(self, indent, left, right, separator=" -> ",
max_width=None):
if not max_width:
# If no max_width provided, use terminal width
max_width = ui.term_width()
if self.layout == 0:
print_column_layout(indent, left, right, separator, max_width)
else:
print_newline_layout(indent, left, right, separator, max_width)
def show_match_header(self):
"""Print out a 'header' identifying the suggested match (album name,
artist name,...) and summarizing the changes that would be made should
the user accept the match.
"""
# Print newline at beginning of change block.
print_("")
# 'Match' line and similarity.
print_(self.indent_header +
f"Match ({dist_string(self.match.distance)}):")
if self.match.info.get("album"):
# Matching an album - print that
artist_album_str = f"{self.match.info.artist}" + \
f" - {self.match.info.album}"
else:
# Matching a single track
artist_album_str = f"{self.match.info.artist}" + \
f" - {self.match.info.title}"
print_(
self.indent_header +
dist_colorize(artist_album_str, self.match.distance)
)
# Penalties.
penalties = penalty_string(self.match.distance)
if penalties:
print_(self.indent_header + penalties)
# Disambiguation.
disambig = disambig_string(self.match.info)
if disambig:
print_(self.indent_header + disambig)
# Data URL.
if self.match.info.data_url:
url = ui.colorize("text_faint", f"{self.match.info.data_url}")
print_(self.indent_header + url)
def show_match_details(self):
"""Print out the details of the match, including changes in album name
and artist name.
"""
# Artist.
artist_l, artist_r = self.cur_artist or "", self.match.info.artist
if artist_r == VARIOUS_ARTISTS:
# Hide artists for VA releases.
artist_l, artist_r = "", ""
if artist_l != artist_r:
artist_l, artist_r = ui.colordiff(artist_l, artist_r)
# Prefix with U+2260: Not Equal To
left = {
"prefix": ui.colorize("changed", "\u2260") + " Artist: ",
"contents": artist_l,
"suffix": "",
}
right = {"prefix": "", "contents": artist_r, "suffix": ""}
self.print_layout(self.indent_detail, left, right)
else:
print_(self.indent_detail + "*", "Artist:", artist_r)
if self.cur_album:
# Album
album_l, album_r = self.cur_album or "", self.match.info.album
if (
self.cur_album != self.match.info.album
and self.match.info.album != VARIOUS_ARTISTS
):
album_l, album_r = ui.colordiff(album_l, album_r)
# Prefix with U+2260: Not Equal To
left = {
"prefix": ui.colorize("changed", "\u2260") + " Album: ",
"contents": album_l,
"suffix": "",
}
right = {"prefix": "", "contents": album_r, "suffix": ""}
self.print_layout(self.indent_detail, left, right)
else:
print_(self.indent_detail + "*", "Album:", album_r)
elif self.cur_title:
# Title - for singletons
title_l, title_r = self.cur_title or "", self.match.info.title
if self.cur_title != self.match.info.title:
title_l, title_r = ui.colordiff(title_l, title_r)
# Prefix with U+2260: Not Equal To
left = {
"prefix": ui.colorize("changed", "\u2260") + " Title: ",
"contents": title_l,
"suffix": "",
}
right = {"prefix": "", "contents": title_r, "suffix": ""}
self.print_layout(self.indent_detail, left, right)
else:
print_(self.indent_detail + "*", "Title:", title_r)
def make_medium_info_line(self, track_info):
"""Construct a line with the current medium's info."""
media = self.match.info.media or "Media"
# Build output string.
if self.match.info.mediums > 1 and track_info.disctitle:
return f"* {media} {track_info.medium}: {track_info.disctitle}"
elif self.match.info.mediums > 1:
return f"* {media} {track_info.medium}"
elif track_info.disctitle:
return f"* {media}: {track_info.disctitle}"
else:
return ""
def format_index(self, track_info):
"""Return a string representing the track index of the given
TrackInfo or Item object.
"""
@ -280,7 +450,7 @@ def show_change(cur_artist, cur_album, match):
index = track_info.index
medium_index = track_info.medium_index
medium = track_info.medium
mediums = match.info.mediums
mediums = self.match.info.mediums
else:
index = medium_index = track_info.track
medium = track_info.disc
@ -294,205 +464,271 @@ def show_change(cur_artist, cur_album, match):
else:
return str(index)
# Identify the album in question.
if cur_artist != match.info.artist or \
(cur_album != match.info.album and
match.info.album != VARIOUS_ARTISTS):
artist_l, artist_r = cur_artist or '', match.info.artist
album_l, album_r = cur_album or '', match.info.album
if artist_r == VARIOUS_ARTISTS:
# Hide artists for VA releases.
artist_l, artist_r = '', ''
if config['artist_credit']:
artist_r = match.info.artist_credit
artist_l, artist_r = ui.colordiff(artist_l, artist_r)
album_l, album_r = ui.colordiff(album_l, album_r)
print_("Correcting tags from:")
show_album(artist_l, album_l)
print_("To:")
show_album(artist_r, album_r)
else:
print_("Tagging:\n {0.artist} - {0.album}".format(match.info))
# Data URL.
if match.info.data_url:
print_('URL:\n %s' % match.info.data_url)
# Info line.
info = []
# Similarity.
info.append('(Similarity: %s)' % dist_string(match.distance))
# Penalties.
penalties = penalty_string(match.distance)
if penalties:
info.append(penalties)
# Disambiguation.
disambig = disambig_string(match.info)
if disambig:
info.append(ui.colorize('text_highlight_minor', '(%s)' % disambig))
print_(' '.join(info))
# Tracks.
pairs = list(match.mapping.items())
pairs.sort(key=lambda item_and_track_info: item_and_track_info[1].index)
# Build up LHS and RHS for track difference display. The `lines` list
# contains ``(lhs, rhs, width)`` tuples where `width` is the length (in
# characters) of the uncolorized LHS.
lines = []
medium = disctitle = None
for item, track_info in pairs:
# Medium number and title.
if medium != track_info.medium or disctitle != track_info.disctitle:
media = match.info.media or 'Media'
if match.info.mediums > 1 and track_info.disctitle:
lhs = '{} {}: {}'.format(media, track_info.medium,
track_info.disctitle)
elif match.info.mediums > 1:
lhs = f'{media} {track_info.medium}'
elif track_info.disctitle:
lhs = f'{media}: {track_info.disctitle}'
def make_track_numbers(self, item, track_info):
"""Format colored track indices."""
cur_track = self.format_index(item)
new_track = self.format_index(track_info)
templ = "(#{})"
changed = False
# Choose color based on change.
if cur_track != new_track:
changed = True
if item.track in (track_info.index, track_info.medium_index):
highlight_color = "text_highlight_minor"
else:
lhs = None
if lhs:
lines.append((lhs, '', 0))
medium, disctitle = track_info.medium, track_info.disctitle
highlight_color = "text_highlight"
else:
highlight_color = "text_faint"
# Titles.
cur_track = templ.format(cur_track)
new_track = templ.format(new_track)
lhs_track = ui.colorize(highlight_color, cur_track)
rhs_track = ui.colorize(highlight_color, new_track)
return lhs_track, rhs_track, changed
@staticmethod
def make_track_titles(item, track_info):
"""Format colored track titles."""
new_title = track_info.title
if not item.title.strip():
# If there's no title, we use the filename.
# If there's no title, we use the filename. Don't colordiff.
cur_title = displayable_path(os.path.basename(item.path))
lhs, rhs = cur_title, new_title
return cur_title, new_title, True
else:
# If there is a title, highlight differences.
cur_title = item.title.strip()
lhs, rhs = ui.colordiff(cur_title, new_title)
lhs_width = len(cur_title)
cur_col, new_col = ui.colordiff(cur_title, new_title)
return cur_col, new_col, cur_title != new_title
@staticmethod
def make_track_lengths(item, track_info):
"""Format colored track lengths."""
changed = False
if (
item.length
and track_info.length
and abs(item.length - track_info.length)
>= config["ui"]["length_diff_thresh"].as_number()
):
highlight_color = "text_highlight"
changed = True
else:
highlight_color = "text_highlight_minor"
# Handle nonetype lengths by setting to 0
cur_length0 = item.length if item.length else 0
new_length0 = track_info.length if track_info.length else 0
# format into string
cur_length = f"({ui.human_seconds_short(cur_length0)})"
new_length = f"({ui.human_seconds_short(new_length0)})"
# colorize
lhs_length = ui.colorize(highlight_color, cur_length)
rhs_length = ui.colorize(highlight_color, new_length)
return lhs_length, rhs_length, changed
def make_line(self, item, track_info):
"""Extract changes from item -> new TrackInfo object, and colorize
appropriately. Returns (lhs, rhs) for column printing.
"""
# Track titles.
lhs_title, rhs_title, diff_title = \
self.make_track_titles(item, track_info)
# Track number change.
cur_track, new_track = format_index(item), format_index(track_info)
if cur_track != new_track:
if item.track in (track_info.index, track_info.medium_index):
color = 'text_highlight_minor'
else:
color = 'text_highlight'
templ = ui.colorize(color, ' (#{0})')
lhs += templ.format(cur_track)
rhs += templ.format(new_track)
lhs_width += len(cur_track) + 4
lhs_track, rhs_track, diff_track = \
self.make_track_numbers(item, track_info)
# Length change.
if item.length and track_info.length and \
abs(item.length - track_info.length) > \
config['ui']['length_diff_thresh'].as_number():
cur_length = ui.human_seconds_short(item.length)
new_length = ui.human_seconds_short(track_info.length)
templ = ui.colorize('text_highlight', ' ({0})')
lhs += templ.format(cur_length)
rhs += templ.format(new_length)
lhs_width += len(cur_length) + 3
lhs_length, rhs_length, diff_length = \
self.make_track_lengths(item, track_info)
# Penalties.
penalties = penalty_string(match.distance.tracks[track_info])
if penalties:
rhs += ' %s' % penalties
changed = diff_title or diff_track or diff_length
if lhs != rhs:
lines.append((' * %s' % lhs, rhs, lhs_width))
elif config['import']['detail']:
lines.append((' * %s' % lhs, '', lhs_width))
# Construct lhs and rhs dicts.
# Previously, we printed the penalties, however this is no longer
# the case, thus the 'info' dictionary is unneeded.
# penalties = penalty_string(self.match.distance.tracks[track_info])
# Print each track in two columns, or across two lines.
col_width = (ui.term_width() - len(''.join([' * ', ' -> ']))) // 2
if lines:
max_width = max(w for _, _, w in lines)
for lhs, rhs, lhs_width in lines:
if not rhs:
print_(lhs)
elif max_width > col_width:
print_(f'{lhs} ->\n {rhs}')
else:
pad = max_width - lhs_width
print_('{}{} -> {}'.format(lhs, ' ' * pad, rhs))
prefix = ui.colorize("changed", "\u2260 ") if changed else "* "
lhs = {
"prefix": prefix + lhs_track + " ",
"contents": lhs_title,
"suffix": " " + lhs_length,
}
rhs = {"prefix": "", "contents": "", "suffix": ""}
if not changed:
# Only return the left side, as nothing changed.
return (lhs, rhs)
else:
# Construct a dictionary for the "changed to" side
rhs = {
"prefix": rhs_track + " ",
"contents": rhs_title,
"suffix": " " + rhs_length,
}
return (lhs, rhs)
# Missing and unmatched tracks.
if match.extra_tracks:
print_('Missing tracks ({}/{} - {:.1%}):'.format(
len(match.extra_tracks),
len(match.info.tracks),
len(match.extra_tracks) / len(match.info.tracks)
))
pad_width = max(len(track_info.title) for track_info in
match.extra_tracks)
for track_info in match.extra_tracks:
line = ' ! {0: <{width}} (#{1: >2})'.format(track_info.title,
format_index(track_info),
width=pad_width)
if track_info.length:
line += ' (%s)' % ui.human_seconds_short(track_info.length)
print_(ui.colorize('text_warning', line))
if match.extra_items:
print_('Unmatched tracks ({}):'.format(len(match.extra_items)))
pad_width = max(len(item.title) for item in match.extra_items)
for item in match.extra_items:
line = ' ! {0: <{width}} (#{1: >2})'.format(item.title,
format_index(item),
width=pad_width)
if item.length:
line += ' (%s)' % ui.human_seconds_short(item.length)
print_(ui.colorize('text_warning', line))
def print_tracklist(self, lines):
"""Calculates column widths for tracks stored as line tuples:
(left, right). Then prints each line of tracklist.
"""
if len(lines) == 0:
# If no lines provided, e.g. details not required, do nothing.
return
def get_width(side):
"""Return the width of left or right in uncolorized characters."""
try:
return len(
ui.uncolorize(
" ".join([side["prefix"],
side["contents"],
side["suffix"]])
)
)
except KeyError:
# An empty dictionary -> Nothing to report
return 0
# Check how to fit content into terminal window
indent_width = len(self.indent_tracklist)
terminal_width = ui.term_width()
joiner_width = len("".join(["* ", " -> "]))
col_width = (terminal_width - indent_width - joiner_width) // 2
max_width_l = max(get_width(line_tuple[0]) for line_tuple in lines)
max_width_r = max(get_width(line_tuple[1]) for line_tuple in lines)
if (
(max_width_l <= col_width)
and (max_width_r <= col_width)
or (
((max_width_l > col_width) or (max_width_r > col_width))
and ((max_width_l + max_width_r) <= col_width * 2)
)
):
# All content fits. Either both maximum widths are below column
# widths, or one of the columns is larger than allowed but the
# other is smaller than allowed.
# In this case we can afford to shrink the columns to fit their
# largest string
col_width_l = max_width_l
col_width_r = max_width_r
else:
# Not all content fits - stick with original half/half split
col_width_l = col_width
col_width_r = col_width
# Print out each line, using the calculated width from above.
for left, right in lines:
left["width"] = col_width_l
right["width"] = col_width_r
self.print_layout(self.indent_tracklist, left, right)
class AlbumChange(ChangeRepresentation):
"""Album change representation, setting cur_album"""
def __init__(self, cur_artist, cur_album, match):
super(AlbumChange, self).__init__()
self.cur_artist = cur_artist
self.cur_album = cur_album
self.match = match
def show_match_tracks(self):
"""Print out the tracks of the match, summarizing changes the match
suggests for them.
"""
# Tracks.
# match is an AlbumMatch named tuple, mapping is a dict
# Sort the pairs by the track_info index (at index 1 of the namedtuple)
pairs = list(self.match.mapping.items())
pairs.sort(
key=lambda item_and_track_info: item_and_track_info[1].index)
# Build up LHS and RHS for track difference display. The `lines` list
# contains `(left, right)` tuples.
lines = []
medium = disctitle = None
for item, track_info in pairs:
# If the track is the first on a new medium, show medium
# number and title.
if medium != track_info.medium or \
disctitle != track_info.disctitle:
# Create header for new medium
header = self.make_medium_info_line(track_info)
if header != "":
# Print tracks from previous medium
self.print_tracklist(lines)
lines = []
print_(self.indent_detail + header)
# Save new medium details for future comparison.
medium, disctitle = track_info.medium, track_info.disctitle
if config["import"]["detail"]:
# Construct the line tuple for the track.
left, right = self.make_line(item, track_info)
lines.append((left, right))
self.print_tracklist(lines)
# Missing and unmatched tracks.
if self.match.extra_tracks:
print_(
"Missing tracks ({0}/{1} - {2:.1%}):".format(
len(self.match.extra_tracks),
len(self.match.info.tracks),
len(self.match.extra_tracks) / len(self.match.info.tracks),
)
)
for track_info in self.match.extra_tracks:
line = f" ! {track_info.title} (#{self.format_index(track_info)})"
if track_info.length:
line += f" ({ui.human_seconds_short(track_info.length)})"
print_(ui.colorize("text_warning", line))
if self.match.extra_items:
print_(f"Unmatched tracks ({len(self.match.extra_items)}):")
for item in self.match.extra_items:
line = " ! {} (#{})".format(item.title, self.format_index(item))
if item.length:
line += " ({})".format(ui.human_seconds_short(item.length))
print_(ui.colorize("text_warning", line))
class TrackChange(ChangeRepresentation):
"""Track change representation, comparing item with match."""
def __init__(self, cur_artist, cur_title, match):
super(TrackChange, self).__init__()
self.cur_artist = cur_artist
self.cur_title = cur_title
self.match = match
def show_change(cur_artist, cur_album, match):
"""Print out a representation of the changes that will be made if an
album's tags are changed according to `match`, which must be an AlbumMatch
object.
"""
change = AlbumChange(cur_artist=cur_artist, cur_album=cur_album,
match=match)
# Print the match header.
change.show_match_header()
# Print the match details.
change.show_match_details()
# Print the match tracks.
change.show_match_tracks()
def show_item_change(item, match):
"""Print out the change that would occur by tagging `item` with the
metadata from `match`, a TrackMatch object.
"""
cur_artist, new_artist = item.artist, match.info.artist
cur_title, new_title = item.title, match.info.title
cur_album = item.album if item.album else ""
new_album = match.info.album if match.info.album else ""
if (cur_artist != new_artist or cur_title != new_title
or cur_album != new_album):
cur_artist, new_artist = ui.colordiff(cur_artist, new_artist)
cur_title, new_title = ui.colordiff(cur_title, new_title)
cur_album, new_album = ui.colordiff(cur_album, new_album)
print_("Correcting track tags from:")
print_(f" {cur_artist} - {cur_title}")
if cur_album:
print_(f" Album: {cur_album}")
print_("To:")
print_(f" {new_artist} - {new_title}")
if new_album:
print_(f" Album: {new_album}")
else:
print_(f"Tagging track: {cur_artist} - {cur_title}")
if cur_album:
print_(f" Album: {new_album}")
# Data URL.
if match.info.data_url:
print_('URL:\n %s' % match.info.data_url)
# Info line.
info = []
# Similarity.
info.append('(Similarity: %s)' % dist_string(match.distance))
# Penalties.
penalties = penalty_string(match.distance)
if penalties:
info.append(penalties)
# Disambiguation.
disambig = disambig_string(match.info)
if disambig:
info.append(ui.colorize('text_highlight_minor', '(%s)' % disambig))
print_(' '.join(info))
change = TrackChange(cur_artist=item.artist, cur_title=item.title,
match=match)
# Print the match header.
change.show_match_header()
# Print the match details.
change.show_match_details()
def summarize_items(items, singleton):
@ -625,36 +861,40 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None,
if not bypass_candidates:
# Display list of candidates.
print_("")
print_('Finding tags for {} "{} - {}".'.format(
'track' if singleton else 'album',
item.artist if singleton else cur_artist,
item.title if singleton else cur_album,
))
print_('Candidates:')
print_(ui.indent(2) + 'Candidates:')
for i, match in enumerate(candidates):
# Index, metadata, and distance.
line = [
'{}.'.format(i + 1),
'{} - {}'.format(
match.info.artist,
match.info.title if singleton else match.info.album,
),
'({})'.format(dist_string(match.distance)),
]
index0 = "{0}.".format(i + 1)
index = dist_colorize(index0, match.distance)
dist = "({:.1f}%)".format((1 - match.distance) * 100)
distance = dist_colorize(dist, match.distance)
metadata = "{0} - {1}".format(
match.info.artist,
match.info.title if singleton else match.info.album,
)
if i == 0:
metadata = dist_colorize(metadata, match.distance)
else:
metadata = ui.colorize("text_highlight_minor", metadata)
line1 = [index, distance, metadata]
print_(ui.indent(2) + " ".join(line1))
# Penalties.
penalties = penalty_string(match.distance, 3)
if penalties:
line.append(penalties)
print_(ui.indent(13) + penalties)
# Disambiguation
disambig = disambig_string(match.info)
if disambig:
line.append(ui.colorize('text_highlight_minor',
'(%s)' % disambig))
print_(' '.join(line))
print_(ui.indent(13) + disambig)
# Ask the user for a choice.
sel = ui.input_options(choice_opts,
@ -754,8 +994,12 @@ class TerminalImportSession(importer.ImportSession):
"""
# Show what we're tagging.
print_()
print_(displayable_path(task.paths, '\n') +
' ({} items)'.format(len(task.items)))
path_str0 = displayable_path(task.paths, '\n')
path_str = ui.colorize('import_path', path_str0)
items_str0 = '({} items)'.format(len(task.items))
items_str = ui.colorize('import_path_items', items_str0)
print_(' '.join([path_str, items_str]))
# Let plugins display info or prompt the user before we go through the
# process of selecting candidate.

View file

@ -0,0 +1,82 @@
# This file is part of beets.
# Copyright 2023, Max Rumpf.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""Plugin to rewrite fields based on a given query."""
from collections import defaultdict
import shlex
import confuse
from beets import ui
from beets.dbcore import AndQuery, query_from_strings
from beets.library import Item, Album
from beets.plugins import BeetsPlugin
def rewriter(field, rules):
"""Template field function factory.
Create a template field function that rewrites the given field
with the given rewriting rules.
``rules`` must be a list of (query, replacement) pairs.
"""
def fieldfunc(item):
value = item._values_fixed[field]
for query, replacement in rules:
if query.match(item):
# Rewrite activated.
return replacement
# Not activated; return original value.
return value
return fieldfunc
class AdvancedRewritePlugin(BeetsPlugin):
"""Plugin to rewrite fields based on a given query."""
def __init__(self):
"""Parse configuration and register template fields for rewriting."""
super().__init__()
template = confuse.Sequence({
'match': str,
'field': str,
'replacement': str,
})
# Gather all the rewrite rules for each field.
rules = defaultdict(list)
for rule in self.config.get(template):
query = query_from_strings(AndQuery, Item, prefixes={},
query_parts=shlex.split(rule['match']))
fieldname = rule['field']
replacement = rule['replacement']
if fieldname not in Item._fields:
raise ui.UserError(
"invalid field name (%s) in rewriter" % fieldname)
self._log.debug('adding template field {0}{1}',
fieldname, replacement)
rules[fieldname].append((query, replacement))
if fieldname == 'artist':
# Special case for the artist field: apply the same
# rewrite for "albumartist" as well.
rules['albumartist'].append((query, replacement))
# Replace each template field with the new rewriter function.
for fieldname, fieldrules in rules.items():
getter = rewriter(fieldname, fieldrules)
self.template_fields[fieldname] = getter
if fieldname in Album._fields:
self.album_template_fields[fieldname] = getter

View file

@ -399,10 +399,15 @@ class CoverArtArchive(RemoteArtSource):
if 'Front' not in item['types']:
continue
if preferred_width:
yield item['thumbnails'][preferred_width]
else:
yield item['image']
# If there is a pre-sized thumbnail of the desired size
# we select it. Otherwise, we return the raw image.
image_url: str = item["image"]
if preferred_width is not None:
if isinstance(item.get("thumbnails"), dict):
image_url = item["thumbnails"].get(
preferred_width, image_url
)
yield image_url
except KeyError:
pass
@ -422,7 +427,7 @@ class CoverArtArchive(RemoteArtSource):
yield self._candidate(url=url, match=Candidate.MATCH_EXACT)
if 'releasegroup' in self.match_by and album.mb_releasegroupid:
for url in get_image_urls(release_group_url):
for url in get_image_urls(release_group_url, preferred_width):
yield self._candidate(url=url, match=Candidate.MATCH_FALLBACK)

View file

@ -9,6 +9,12 @@ Changelog goes here! Please add your entry to the bottom of one of the lists bel
With this release, beets now requires Python 3.7 or later (it removes support
for Python 3.6).
Major new features:
* The beets importer UI received a major overhaul. Several new configuration
options are available for customizing layout and colors: :ref:`ui_options`.
:bug:`3721`
New features:
* :ref:`update-cmd`: added ```-e``` flag for excluding fields from being updated.
@ -125,6 +131,14 @@ New features:
* :doc:`/plugins/autobpm`: Add the `autobpm` plugin which uses Librosa to
calculate the BPM of the audio.
:bug:`3856`
* :doc:`/plugins/fetchart`: Fix the error with CoverArtArchive where the
`maxwidth` option would not be used to download a pre-sized thumbnail for
release groups, as is already done with releases.
* :doc:`/plugins/fetchart`: Fix the error with CoverArtArchive where no cover
would be found when the `maxwidth` option matches a pre-sized thumbnail size,
but no thumbnail is provided by CAA. We now fallback to the raw image.
* :doc:`/plugins/advancedrewrite`: Add an advanced version of the `rewrite`
plugin which allows to replace fields based on a given library query.
Bug fixes:

View file

@ -0,0 +1,39 @@
Advanced Rewrite Plugin
=======================
The ``advancedrewrite`` plugin lets you easily substitute values
in your templates and path formats, similarly to the :doc:`/plugins/rewrite`.
Please make sure to read the documentation of that plugin first.
The *advanced* rewrite plugin doesn't match the rewritten field itself,
but instead checks if the given item matches a :doc:`query </reference/query>`.
Only then, the field is replaced with the given value.
To use advanced field rewriting, first enable the ``advancedrewrite`` plugin
(see :ref:`using-plugins`).
Then, make a ``advancedrewrite:`` section in your config file to contain
your rewrite rules.
In contrast to the normal ``rewrite`` plugin, you need to provide a list
of replacement rule objects, each consisting of a query, a field name,
and the replacement value.
For example, to credit all songs of ODD EYE CIRCLE before 2023
to their original group name, you can use the following rule::
advancedrewrite:
- match: "mb_artistid:dec0f331-cb08-4c8e-9c9f-aeb1f0f6d88c year:..2022"
field: artist
replacement: "이달의 소녀 오드아이써클"
As a convenience, the plugin applies patterns for the ``artist`` field to the
``albumartist`` field as well. (Otherwise, you would probably want to duplicate
every rule for ``artist`` and ``albumartist``.)
A word of warning: This plugin theoretically only applies to templates and path
formats; it initially does not modify files' metadata tags or the values
tracked by beets' library database, but since it *rewrites all field lookups*,
it modifies the file's metadata anyway. See comments in issue :bug:`2786`.
As an alternative to this plugin the simpler :doc:`/plugins/rewrite` or
similar :doc:`/plugins/substitute` can be used.

View file

@ -41,7 +41,10 @@ file. The available options are:
considered as valid album art candidates. Default: 0.
- **maxwidth**: A maximum image width to downscale fetched images if they are
too big. The resize operation reduces image width to at most ``maxwidth``
pixels. The height is recomputed so that the aspect ratio is preserved.
pixels. The height is recomputed so that the aspect ratio is preserved. See
the section on :ref:`cover-art-archive-maxwidth` below for additional
information regarding the Cover Art Archive source.
Default: 0 (no maximum is enforced).
- **quality**: The JPEG quality level to use when compressing images (when
``maxwidth`` is set). This should be either a number from 1 to 100 or 0 to
use the default quality. 6575 is usually a good starting point. The default
@ -269,7 +272,21 @@ Spotify backend is enabled by default and will update album art if a valid Spoti
Cover Art URL
'''''''''''''
The `fetchart` plugin can also use a flexible attribute field ``cover_art_url`` where you can manually specify the image URL to be used as cover art. Any custom plugin can use this field to provide the cover art and ``fetchart`` will use it as a source.
The `fetchart` plugin can also use a flexible attribute field ``cover_art_url``
where you can manually specify the image URL to be used as cover art. Any custom
plugin can use this field to provide the cover art and ``fetchart`` will use it
as a source.
.. _cover-art-archive-maxwidth:
Cover Art Archive Pre-sized Thumbnails
--------------------------------------
The CAA provides pre-sized thumbnails of width 250, 500, and 1200 pixels. If you
set the `maxwidth` option to one of these values, the corresponding image will
be downloaded, saving `beets` the need to scale down the image. It can also
speed up the downloading process, as some cover arts can sometimes be very
large.
Storing the Artwork's Source
----------------------------

View file

@ -61,6 +61,7 @@ following to your configuration::
absubmit
acousticbrainz
advancedrewrite
albumtypes
aura
autobpm
@ -246,6 +247,9 @@ Path Formats
:doc:`rewrite <rewrite>`
Substitute values in path formats.
:doc:`advancedrewrite <advancedrewrite>`
Substitute field values for items matching a query.
:doc:`substitute <substitute>`
As an alternative to :doc:`rewrite <rewrite>`, use this plugin. The main
difference between them is that this plugin never modifies the files

View file

@ -398,6 +398,8 @@ Sets the albumartist for various-artist compilations. Defaults to ``'Various
Artists'`` (the MusicBrainz standard). Affects other sources, such as
:doc:`/plugins/discogs`, too.
.. _ui_options:
UI Options
----------
@ -419,6 +421,8 @@ support ANSI colors.
still respected, but a deprecation message will be shown until your
top-level `color` configuration has been nested under `ui`.
.. _colors:
colors
~~~~~~
@ -427,20 +431,77 @@ the ``color`` option is set to ``yes``. For example, you might have a section
in your configuration file that looks like this::
ui:
color: yes
colors:
text_success: green
text_warning: yellow
text_error: red
text_highlight: red
text_highlight_minor: lightgray
action_default: turquoise
action: blue
text_success: ['bold', 'green']
text_warning: ['bold', 'yellow']
text_error: ['bold', 'red']
text_highlight: ['bold', 'red']
text_highlight_minor: ['white']
action_default: ['bold', 'cyan']
action: ['bold', 'cyan']
# New colors after UI overhaul
text: ['normal']
text_faint: ['faint']
import_path: ['bold', 'blue']
import_path_items: ['bold', 'blue']
added: ['green']
removed: ['red']
changed: ['yellow']
added_highlight: ['bold', 'green']
removed_highlight: ['bold', 'red']
changed_highlight: ['bold', 'yellow']
text_diff_added: ['bold', 'red']
text_diff_removed: ['bold', 'red']
text_diff_changed: ['bold', 'red']
action_description: ['white']
Available colors: black, darkred, darkgreen, brown (darkyellow), darkblue,
purple (darkmagenta), teal (darkcyan), lightgray, darkgray, red, green,
yellow, blue, fuchsia (magenta), turquoise (cyan), white
Legacy UI colors config directive used strings. If any colors value is still a
string instead of a list, it will be translated to list automatically. For
example ``blue`` will become ``['blue']``.
terminal_width
~~~~~~~~~~~~~~
Controls line wrapping. Defaults to ``80`` characters::
ui:
terminal_width: 80
length_diff_thresh
~~~~~~~~~~~~~~~~~~
Beets compares the length of the imported track with the length the metadata
source provides. If any tracks differ by at least ``length_diff_thresh``
seconds, they will be colored with ``text_highlight``. Below this threshold,
different track lengths are colored with ``text_highlight_minor``.
``length_diff_thresh`` does not impact which releases are selected in
autotagger matching or distance score calculation (see :ref:`match-config`,
``distance_weights`` and :ref:`colors`)::
ui:
length_diff_thresh: 10.0
import
~~~~~~
When importing, beets will read several options to configure the visuals of the
import dialogue. There are two layouts controlling how horizontal space and
line wrapping is dealt with: ``column`` and ``newline``. The indentation of the
respective elements of the import UI can also be configured. For example
setting ``4`` for ``match_header`` will indent the very first block of a
proposed match by five characters in the terminal::
ui:
import:
indentation:
match_header: 4
match_details: 4
match_tracklist: 7
layout: newline
Importer Options
----------------

View file

@ -130,6 +130,39 @@ class CAAHelper():
}
],
"release": "https://musicbrainz.org/release/releaseid"
}"""
RESPONSE_RELEASE_WITHOUT_THUMBNAILS = """{
"images": [
{
"approved": false,
"back": false,
"comment": "GIF",
"edit": 12345,
"front": true,
"id": 12345,
"image": "http://coverartarchive.org/release/rid/12345.gif",
"types": [
"Front"
]
},
{
"approved": false,
"back": false,
"comment": "",
"edit": 12345,
"front": false,
"id": 12345,
"image": "http://coverartarchive.org/release/rid/12345.jpg",
"thumbnails": {
"large": "http://coverartarchive.org/release/rgid/12345-500.jpg",
"small": "http://coverartarchive.org/release/rgid/12345-250.jpg"
},
"types": [
"Front"
]
}
],
"release": "https://musicbrainz.org/release/releaseid"
}"""
RESPONSE_GROUP = """{
"images": [
@ -155,6 +188,23 @@ class CAAHelper():
],
"release": "https://musicbrainz.org/release/release-id"
}"""
RESPONSE_GROUP_WITHOUT_THUMBNAILS = """{
"images": [
{
"approved": false,
"back": false,
"comment": "",
"edit": 12345,
"front": true,
"id": 12345,
"image": "http://coverartarchive.org/release/releaseid/12345.jpg",
"types": [
"Front"
]
}
],
"release": "https://musicbrainz.org/release/release-id"
}"""
def mock_caa_response(self, url, json):
responses.add(responses.GET, url, body=json,
@ -521,6 +571,42 @@ class CoverArtArchiveTest(UseThePlugin, CAAHelper):
self.assertEqual(len(responses.calls), 2)
self.assertEqual(responses.calls[0].request.url, self.RELEASE_URL)
def test_fetchart_uses_caa_pre_sized_maxwidth_thumbs(self):
# CAA provides pre-sized thumbnails of width 250px, 500px, and 1200px
# We only test with one of them here
maxwidth = 1200
self.settings = Settings(maxwidth=maxwidth)
album = _common.Bag(
mb_albumid=self.MBID_RELASE, mb_releasegroupid=self.MBID_GROUP
)
self.mock_caa_response(self.RELEASE_URL, self.RESPONSE_RELEASE)
self.mock_caa_response(self.GROUP_URL, self.RESPONSE_GROUP)
candidates = list(self.source.get(album, self.settings, []))
self.assertEqual(len(candidates), 3)
for candidate in candidates:
self.assertTrue(f"-{maxwidth}.jpg" in candidate.url)
def test_caa_finds_image_if_maxwidth_is_set_and_thumbnails_is_empty(self):
# CAA provides pre-sized thumbnails of width 250px, 500px, and 1200px
# We only test with one of them here
maxwidth = 1200
self.settings = Settings(maxwidth=maxwidth)
album = _common.Bag(
mb_albumid=self.MBID_RELASE, mb_releasegroupid=self.MBID_GROUP
)
self.mock_caa_response(
self.RELEASE_URL, self.RESPONSE_RELEASE_WITHOUT_THUMBNAILS
)
self.mock_caa_response(
self.GROUP_URL, self.RESPONSE_GROUP_WITHOUT_THUMBNAILS,
)
candidates = list(self.source.get(album, self.settings, []))
self.assertEqual(len(candidates), 3)
for candidate in candidates:
self.assertFalse(f"-{maxwidth}.jpg" in candidate.url)
class FanartTVTest(UseThePlugin):
RESPONSE_MULTIPLE = """{

View file

@ -1184,59 +1184,140 @@ class ShowChangeTest(_common.TestCase):
]
)
def _show_change(self, items=None, info=None,
def _show_change(self, items=None, info=None, color=False,
cur_artist='the artist', cur_album='the album',
dist=0.1):
"""Return an unicode string representing the changes"""
items = items or self.items
info = info or self.info
mapping = dict(zip(items, info.tracks))
config['ui']['color'] = False
album_dist = distance(items, info, mapping)
album_dist._penalties = {'album': [dist]}
config['ui']['color'] = color
config['import']['detail'] = True
change_dist = distance(items, info, mapping)
change_dist._penalties = {'album': [dist], 'artist': [dist]}
commands.show_change(
cur_artist,
cur_album,
autotag.AlbumMatch(album_dist, info, mapping, set(), set()),
autotag.AlbumMatch(change_dist, info, mapping, set(), set()),
)
return self.io.getoutput().lower()
def test_null_change(self):
msg = self._show_change()
self.assertTrue('similarity: 90' in msg)
self.assertTrue('tagging:' in msg)
self.assertTrue('match (90.0%)' in msg)
self.assertTrue('album, artist' in msg)
def test_album_data_change(self):
msg = self._show_change(cur_artist='another artist',
cur_album='another album')
self.assertTrue('correcting tags from:' in msg)
self.assertTrue('another artist -> the artist' in msg)
self.assertTrue('another album -> the album' in msg)
def test_item_data_change(self):
self.items[0].title = 'different'
msg = self._show_change()
self.assertTrue('different -> the title' in msg)
self.assertTrue('different' in msg and 'the title' in msg)
def test_item_data_change_with_unicode(self):
self.items[0].title = 'caf\xe9'
msg = self._show_change()
self.assertTrue('caf\xe9 -> the title' in msg)
self.assertTrue(u'caf\xe9' in msg and 'the title' in msg)
def test_album_data_change_with_unicode(self):
msg = self._show_change(cur_artist='caf\xe9',
cur_album='another album')
self.assertTrue('correcting tags from:' in msg)
msg = self._show_change(cur_artist=u'caf\xe9',
cur_album=u'another album')
self.assertTrue(u'caf\xe9' in msg and 'the artist' in msg)
def test_item_data_change_title_missing(self):
self.items[0].title = ''
msg = re.sub(r' +', ' ', self._show_change())
self.assertTrue('file.mp3 -> the title' in msg)
self.assertTrue(u'file.mp3' in msg and 'the title' in msg)
def test_item_data_change_title_missing_with_unicode_filename(self):
self.items[0].title = ''
self.items[0].path = '/path/to/caf\xe9.mp3'.encode()
msg = re.sub(r' +', ' ', self._show_change())
self.assertTrue('caf\xe9.mp3 -> the title' in msg or
'caf.mp3 ->' in msg)
self.assertTrue(u'caf\xe9.mp3' in msg or
u'caf.mp3' in msg)
def test_colorize(self):
self.assertEqual("test", ui.uncolorize("test"))
txt = ui.uncolorize("\x1b[31mtest\x1b[39;49;00m")
self.assertEqual("test", txt)
txt = ui.uncolorize("\x1b[31mtest\x1b[39;49;00m test")
self.assertEqual("test test", txt)
txt = ui.uncolorize("\x1b[31mtest\x1b[39;49;00mtest")
self.assertEqual("testtest", txt)
txt = ui.uncolorize("test \x1b[31mtest\x1b[39;49;00m test")
self.assertEqual("test test test", txt)
def test_color_split(self):
exp = ("test", "")
res = ui.color_split("test", 5)
self.assertEqual(exp, res)
exp = ("\x1b[31mtes\x1b[39;49;00m", "\x1b[31mt\x1b[39;49;00m")
res = ui.color_split("\x1b[31mtest\x1b[39;49;00m", 3)
self.assertEqual(exp, res)
def test_split_into_lines(self):
# Test uncolored text
txt = ui.split_into_lines("test test test", [5, 5, 5])
self.assertEqual(txt, ["test", "test", "test"])
# Test multiple colored texts
colored_text = "\x1b[31mtest \x1b[39;49;00m" * 3
split_txt = ["\x1b[31mtest\x1b[39;49;00m",
"\x1b[31mtest\x1b[39;49;00m",
"\x1b[31mtest\x1b[39;49;00m"]
txt = ui.split_into_lines(colored_text, [5, 5, 5])
self.assertEqual(txt, split_txt)
# Test single color, multi space text
colored_text = "\x1b[31m test test test \x1b[39;49;00m"
txt = ui.split_into_lines(colored_text, [5, 5, 5])
self.assertEqual(txt, split_txt)
# Test single color, different spacing
colored_text = "\x1b[31mtest\x1b[39;49;00mtest test test"
# ToDo: fix color_len to handle mid-text color escapes, and thus
# split colored texts over newlines (potentially with dashes?)
split_txt = ["\x1b[31mtest\x1b[39;49;00mt", "est", "test", "test"]
txt = ui.split_into_lines(colored_text, [5, 5, 5])
self.assertEqual(txt, split_txt)
def test_album_data_change_wrap_newline(self):
# Patch ui.term_width to force wrapping
with patch('beets.ui.commands.ui.term_width', return_value=30):
# Test newline layout
config['ui']['import']['layout'] = u'newline'
long_name = u'another artist with a' + (u' very' * 10) + \
u' long name'
msg = self._show_change(cur_artist=long_name,
cur_album='another album')
# _common.log.info("Message:{}".format(msg))
self.assertTrue('artist: another artist' in msg)
self.assertTrue(' -> the artist' in msg)
self.assertFalse('another album -> the album' in msg)
def test_item_data_change_wrap_column(self):
# Patch ui.term_width to force wrapping
with patch('beets.ui.commands.ui.term_width', return_value=54):
# Test Column layout
config['ui']['import']['layout'] = u'column'
long_title = u'a track with a' + (u' very' * 10) + \
u' long name'
self.items[0].title = long_title
msg = self._show_change()
self.assertTrue('(#1) a track (1:00) -> (#1) the title (0:00)'
in msg)
def test_item_data_change_wrap_newline(self):
# Patch ui.term_width to force wrapping
with patch('beets.ui.commands.ui.term_width', return_value=30):
config['ui']['import']['layout'] = u'newline'
long_title = u'a track with a' + (u' very' * 10) + \
u' long name'
self.items[0].title = long_title
msg = self._show_change()
self.assertTrue('(#1) a track with' in msg)
self.assertTrue(' -> (#1) the title (0:00)' in msg)
@patch('beets.library.Item.try_filesize', Mock(return_value=987))