diff --git a/beets/config_default.yaml b/beets/config_default.yaml index db9f985ec..b2d345aec 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -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 diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index cd9f4989e..815565d5a 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -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[^\x1b]*) + (?P(?:\x1b\[[;\d]*[A-Za-z])+) + (?P[^\x1b]+)(?P\x1b\[39;49;00m) + (?P[^\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 diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 3117e64b4..7d39c7b9b 100755 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -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. diff --git a/beetsplug/advancedrewrite.py b/beetsplug/advancedrewrite.py new file mode 100644 index 000000000..7844b8364 --- /dev/null +++ b/beetsplug/advancedrewrite.py @@ -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 diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index 732031227..efa7077b2 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -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) diff --git a/docs/changelog.rst b/docs/changelog.rst index 31274d8e9..0e1d8611e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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: diff --git a/docs/plugins/advancedrewrite.rst b/docs/plugins/advancedrewrite.rst new file mode 100644 index 000000000..8ac0e277e --- /dev/null +++ b/docs/plugins/advancedrewrite.rst @@ -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 `. +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. diff --git a/docs/plugins/fetchart.rst b/docs/plugins/fetchart.rst index 28bc5672e..6828b93fe 100644 --- a/docs/plugins/fetchart.rst +++ b/docs/plugins/fetchart.rst @@ -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. 65–75 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 ---------------------------- diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index b56c50225..b38a298a4 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -61,6 +61,7 @@ following to your configuration:: absubmit acousticbrainz + advancedrewrite albumtypes aura autobpm @@ -246,6 +247,9 @@ Path Formats :doc:`rewrite ` Substitute values in path formats. +:doc:`advancedrewrite ` + Substitute field values for items matching a query. + :doc:`substitute ` As an alternative to :doc:`rewrite `, use this plugin. The main difference between them is that this plugin never modifies the files diff --git a/docs/reference/config.rst b/docs/reference/config.rst index 56ed835a1..3f1b4be65 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -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 ---------------- diff --git a/test/test_art.py b/test/test_art.py index b14ec0f59..62b7393a4 100644 --- a/test/test_art.py +++ b/test/test_art.py @@ -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 = """{ diff --git a/test/test_ui.py b/test/test_ui.py index 27d36c493..f8ecd5d49 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -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))