mirror of
https://github.com/beetbox/beets.git
synced 2026-03-25 23:03:48 +01:00
Refactor dist display (#6444)
## Refactor: Move display logic into `Distance` and `Match` as properties Display-related logic previously scattered across `display.py` and `session.py` is consolidated into the data classes themselves. ### What changed **`Distance` gains three properties:** - `penalties` — list of cleaned-up penalty key strings - `color` — threshold-based `ColorName` derived from the distance value - `string` — colorized similarity percentage **`Match` gains two properties:** - `disambig_string` — formatted comma-separated disambiguation string - `base_disambig_data` — override point for subclass-specific field pre-processing (e.g. `media` for `AlbumMatch`, `index`/`track_alt`/`album` for `TrackMatch`) **`display.py` / `session.py`:** Standalone functions `dist_string`, `dist_colorize`, `penalty_string`, `disambig_string`, `get_album_disambig_fields`, `get_singleton_disambig_fields` are removed. Call sites now use the properties directly. A minor fix in `show_match_header` collects output into a list and uses `textwrap.indent` for a single `ui.print_` call, replacing the previous per-line prints. ### Impact - Display logic lives next to the data it describes — easier to find, easier to test - `display.py` and `session.py` become thinner; no shared utility functions to keep in sync
This commit is contained in:
commit
03b1ab012c
5 changed files with 133 additions and 161 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue