From 2d1461f1d06ef3590a423a7483e2d9ae5d2618b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Sun, 15 Mar 2026 17:21:19 +0000 Subject: [PATCH] Refactor distance and match display into properties Move disambig string and penalty formatting logic from display.py into Distance and Match classes as properties. Add Distance.color, Distance.string, Distance.penalties, Match.disambig_string, and Match.base_disambig_data to consolidate display logic closer to the data. Remove now-redundant standalone functions from display.py and session.py. --- beets/autotag/distance.py | 21 ++++ beets/autotag/hooks.py | 68 ++++++++++++- beets/ui/commands/import_/display.py | 142 +++++---------------------- beets/ui/commands/import_/session.py | 45 +++++---- beets/util/color.py | 18 +--- 5 files changed, 133 insertions(+), 161 deletions(-) diff --git a/beets/autotag/distance.py b/beets/autotag/distance.py index 5e3f630e3..b2c02ebd1 100644 --- a/beets/autotag/distance.py +++ b/beets/autotag/distance.py @@ -10,11 +10,13 @@ from unidecode import unidecode from beets import config, metadata_plugins from beets.util import as_string, cached_classproperty, get_most_common_tags +from beets.util.color import colorize if TYPE_CHECKING: from collections.abc import Iterator, Sequence from beets.library import Item + from beets.util.color import ColorName from .hooks import AlbumInfo, TrackInfo @@ -139,6 +141,13 @@ class Distance: weights[key] = weights_view[key].as_number() return weights + @property + def generic_penalty_keys(self) -> list[str]: + return [ + k.replace("album_", "").replace("track_", "").replace("_", " ") + for k in self._penalties + ] + # Access the components and their aggregates. @property @@ -167,6 +176,18 @@ class Distance: dist_raw += sum(penalty) * self._weights[key] return dist_raw + @property + def color(self) -> ColorName: + if self.distance <= config["match"]["strong_rec_thresh"].as_number(): + return "text_success" + if self.distance <= config["match"]["medium_rec_thresh"].as_number(): + return "text_warning" + return "text_error" + + @property + def string(self) -> str: + return colorize(self.color, f"{(1 - self.distance) * 100:.1f}%") + def items(self) -> list[tuple[str, float]]: """Return a list of (key, dist) pairs, with `dist` being the weighted distance, sorted from highest to lowest. Does not diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index d1f9e17bc..41b4560d0 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -19,21 +19,27 @@ from __future__ import annotations from copy import deepcopy from dataclasses import dataclass from functools import cached_property -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, ClassVar, TypeVar from typing_extensions import Self -from beets import plugins +from beets import config, logging, plugins from beets.util import cached_classproperty from beets.util.deprecation import deprecate_for_maintainers if TYPE_CHECKING: + from collections.abc import Sequence + from beets.library import Item from .distance import Distance V = TypeVar("V") +JSONDict = dict[str, Any] + +log = logging.getLogger("beets") + # Classes used to represent candidate options. class AttrDict(dict[str, V]): @@ -268,6 +274,8 @@ class TrackInfo(Info): # Structures that compose all the information for a candidate match. @dataclass class Match: + disambig_fields_key: ClassVar[str] + distance: Distance info: Info @@ -275,9 +283,38 @@ class Match: def type(cls) -> str: return cls.__name__.removesuffix("Match") # type: ignore[attr-defined] + @property + def disambig_fields(self) -> Sequence[str]: + chosen_fields = config["match"][self.disambig_fields_key].as_str_seq() + valid_fields = [f for f in chosen_fields if f in self.info] + if missing_fields := set(chosen_fields) - set(valid_fields): + log.warning( + "Disambiguation string keys {} do not exist.", missing_fields + ) + + return valid_fields + + @property + def base_disambig_data(self) -> JSONDict: + return {} + + @property + def disambig_string(self) -> str: + """Build a display string from the candidate's disambiguation fields. + + Merges base disambiguation data with instance-specific field values, + then formats them as a comma-separated string in field definition order. + """ + data = { + k: self.info[k] for k in self.disambig_fields + } | self.base_disambig_data + return ", ".join(str(data[k]) for k in self.disambig_fields) + @dataclass class AlbumMatch(Match): + disambig_fields_key = "album_disambig_fields" + info: AlbumInfo mapping: dict[Item, TrackInfo] extra_items: list[Item] @@ -295,7 +332,34 @@ class AlbumMatch(Match): def items(self) -> list[Item]: return [i for i, _ in self.item_info_pairs] + @property + def base_disambig_data(self) -> JSONDict: + return { + "media": ( + f"{mediums}x{self.info.media}" + if (mediums := self.info.mediums) and mediums > 1 + else self.info.media + ), + } + @dataclass class TrackMatch(Match): + disambig_fields_key = "singleton_disambig_fields" + info: TrackInfo + + @property + def base_disambig_data(self) -> JSONDict: + return { + "index": f"Index {self.info.index}", + "track_alt": f"Track {self.info.track_alt}", + "album": ( + f"[{self.info.album}]" + if ( + config["import"]["singleton_album_disambig"].get() + and self.info.album + ) + else "" + ), + } diff --git a/beets/ui/commands/import_/display.py b/beets/ui/commands/import_/display.py index b48a8affc..a89b8795f 100644 --- a/beets/ui/commands/import_/display.py +++ b/beets/ui/commands/import_/display.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import textwrap from dataclasses import dataclass from functools import cached_property from typing import TYPE_CHECKING @@ -8,18 +9,15 @@ from typing import TYPE_CHECKING from beets import config, ui from beets.autotag import hooks from beets.util import displayable_path -from beets.util.color import colorize, dist_colorize +from beets.util.color import colorize from beets.util.diff import colordiff from beets.util.layout import Side, get_layout_lines, indent from beets.util.units import human_seconds_short if TYPE_CHECKING: - from collections.abc import Sequence - import confuse from beets import autotag - from beets.autotag.distance import Distance from beets.library.models import Item from beets.util.color import ColorName @@ -46,9 +44,13 @@ class ChangeRepresentation: def _indentation_config(self) -> confuse.Subview: return config["ui"]["import"]["indentation"] + @cached_property + def indent(self) -> int: + return self._indentation_config["match_header"].get(int) + @cached_property def indent_header(self) -> str: - return indent(self._indentation_config["match_header"].get(int)) + return indent(self.indent) @cached_property def indent_detail(self) -> str: @@ -68,33 +70,29 @@ class ChangeRepresentation: the user accept the match. """ # Print newline at beginning of change block. - ui.print_("") + parts = [""] # 'Match' line and similarity. - ui.print_( - f"{self.indent_header}Match ({dist_string(self.match.distance)}):" + parts.append(f"Match ({self.match.distance.string}):") + parts.append( + ui.colorize( + self.match.distance.color, + f"{self.match.info.artist} - {self.match.info.name}", + ) ) - artist_name_str = f"{self.match.info.artist} - {self.match.info.name}" - ui.print_( - self.indent_header - + dist_colorize(artist_name_str, self.match.distance) - ) + if penalty_keys := self.match.distance.generic_penalty_keys: + parts.append( + ui.colorize("changed", f"\u2260 {', '.join(penalty_keys)}") + ) - # Penalties. - penalties = penalty_string(self.match.distance) - if penalties: - ui.print_(f"{self.indent_header}{penalties}") + if disambig := self.match.disambig_string: + parts.append(disambig) - # Disambiguation. - disambig = disambig_string(self.match.info) - if disambig: - ui.print_(f"{self.indent_header}{disambig}") + if data_url := self.match.info.data_url: + parts.append(ui.colorize("text_faint", f"{data_url}")) - # Data URL. - if self.match.info.data_url: - url = colorize("text_faint", f"{self.match.info.data_url}") - ui.print_(f"{self.indent_header}{url}") + ui.print_(textwrap.indent("\n".join(parts), self.indent_header)) def show_match_details(self) -> None: """Print out the details of the match, including changes in album name @@ -397,97 +395,3 @@ def show_item_change(item: Item, match: hooks.TrackMatch) -> None: change.show_match_header() # Print the match details. change.show_match_details() - - -def disambig_string(info: hooks.Info) -> str: - """Generate a string for an AlbumInfo or TrackInfo object that - provides context that helps disambiguate similar-looking albums and - tracks. - """ - if isinstance(info, hooks.AlbumInfo): - disambig = get_album_disambig_fields(info) - elif isinstance(info, hooks.TrackInfo): - disambig = get_singleton_disambig_fields(info) - else: - return "" - - 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": f"Index {info.index}", - "track_alt": f"Track {info.track_alt}", - "album": ( - f"[{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": ( - f"{info.mediums}x{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_string(dist: Distance) -> str: - """Formats a distance (a float) as a colorized similarity percentage - string. - """ - string = f"{(1 - dist) * 100:.1f}%" - return dist_colorize(string, dist) - - -def penalty_string(distance: Distance, limit: int | None = None) -> str: - """Returns a colorized string that indicates all the penalties - applied to a distance object. - """ - penalties = [] - for key in distance.keys(): - key = key.replace("album_", "") - key = key.replace("track_", "") - key = key.replace("_", " ") - penalties.append(key) - if penalties: - if limit and len(penalties) > limit: - penalties = [*penalties[:limit], "..."] - # Prefix penalty string with U+2260: Not Equal To - penalty_string = f"\u2260 {', '.join(penalties)}" - return colorize("changed", penalty_string) - - return "" diff --git a/beets/ui/commands/import_/session.py b/beets/ui/commands/import_/session.py index 9228ac4e2..8c3404bea 100644 --- a/beets/ui/commands/import_/session.py +++ b/beets/ui/commands/import_/session.py @@ -1,18 +1,15 @@ +from __future__ import annotations + from collections import Counter from itertools import chain from beets import autotag, config, importer, logging, plugins, ui from beets.autotag import Recommendation from beets.util import PromptChoice, displayable_path -from beets.util.color import colorize, dist_colorize +from beets.util.color import colorize from beets.util.units import human_bytes, human_seconds_short -from .display import ( - disambig_string, - penalty_string, - show_change, - show_item_change, -) +from .display import show_change, show_item_change # Global logger. log = logging.getLogger("beets") @@ -439,26 +436,28 @@ def choose_candidate( ui.print_(" Candidates:") for i, match in enumerate(candidates): # Index, metadata, and distance. - index0 = f"{i + 1}." - index = dist_colorize(index0, match.distance) - dist = f"({(1 - match.distance) * 100:.1f}%)" - distance = dist_colorize(dist, match.distance) - metadata = f"{match.info.artist} - {match.info.name}" - if i == 0: - metadata = dist_colorize(metadata, match.distance) - else: - metadata = colorize("text_highlight_minor", metadata) - line1 = [index, distance, metadata] - ui.print_(f" {' '.join(line1)}") + dist_color = match.distance.color + line_parts = [ + colorize(dist_color, f"{i + 1}."), + match.distance.string, + colorize( + dist_color if i == 0 else "text_highlight_minor", + f"{match.info.artist} - {match.info.name}", + ), + ] + ui.print_(f" {' '.join(line_parts)}") # Penalties. - penalties = penalty_string(match.distance, 3) - if penalties: - ui.print_(f"{' ' * 13}{penalties}") + if penalty_keys := match.distance.generic_penalty_keys: + if len(penalty_keys) > 3: + penalty_keys = [*penalty_keys[:3], "..."] + penalty_text = colorize( + "changed", f"\u2260 {', '.join(penalty_keys)}" + ) + ui.print_(f"{' ' * 13}{penalty_text}") # Disambiguation - disambig = disambig_string(match.info) - if disambig: + if disambig := match.disambig_string: ui.print_(f"{' ' * 13}{disambig}") # Ask the user for a choice. diff --git a/beets/util/color.py b/beets/util/color.py index 944f1cb96..8e83ba7cb 100644 --- a/beets/util/color.py +++ b/beets/util/color.py @@ -3,15 +3,12 @@ from __future__ import annotations import os import re from functools import cache -from typing import TYPE_CHECKING, Literal +from typing import Literal import confuse from beets import config -if TYPE_CHECKING: - from beets.autotag.distance import Distance - # ANSI terminal colorization code heavily inspired by pygments: # https://bitbucket.org/birkenfeld/pygments-main/src/default/pygments/console.py # (pygments is by Tim Hatch, Armin Ronacher, et al.) @@ -159,19 +156,6 @@ def colorize(color_name: ColorName, text: str) -> str: return text -def dist_colorize(string: str, dist: Distance) -> str: - """Formats a string as a colorized similarity string according to - a distance. - """ - if dist <= config["match"]["strong_rec_thresh"].as_number(): - string = colorize("text_success", string) - elif dist <= config["match"]["medium_rec_thresh"].as_number(): - string = colorize("text_warning", string) - else: - string = colorize("text_error", string) - return string - - def uncolorize(colored_text: str) -> str: """Remove colors from a string.""" # Define a regular expression to match ANSI codes.