hooks: introduce Info.name property

This commit is contained in:
Šarūnas Nejus 2025-10-26 03:16:21 +00:00
parent 77e25db1b5
commit 02f3cb7821
No known key found for this signature in database
3 changed files with 117 additions and 109 deletions

View file

@ -18,10 +18,13 @@ from __future__ import annotations
from copy import deepcopy from copy import deepcopy
from dataclasses import dataclass from dataclasses import dataclass
from functools import cached_property
from typing import TYPE_CHECKING, Any, TypeVar from typing import TYPE_CHECKING, Any, TypeVar
from typing_extensions import Self from typing_extensions import Self
from beets.util import cached_classproperty
if TYPE_CHECKING: if TYPE_CHECKING:
from beets.library import Item from beets.library import Item
@ -55,6 +58,10 @@ class AttrDict(dict[str, V]):
class Info(AttrDict[Any]): class Info(AttrDict[Any]):
"""Container for metadata about a musical entity.""" """Container for metadata about a musical entity."""
@cached_property
def name(self) -> str:
raise NotImplementedError
def __init__( def __init__(
self, self,
album: str | None = None, album: str | None = None,
@ -96,6 +103,10 @@ class AlbumInfo(Info):
user items, and later to drive tagging decisions once selected. user items, and later to drive tagging decisions once selected.
""" """
@cached_property
def name(self) -> str:
return self.album or ""
def __init__( def __init__(
self, self,
tracks: list[TrackInfo], tracks: list[TrackInfo],
@ -168,6 +179,10 @@ class TrackInfo(Info):
stand alone for singleton matching. stand alone for singleton matching.
""" """
@cached_property
def name(self) -> str:
return self.title or ""
def __init__( def __init__(
self, self,
*, *,
@ -220,6 +235,10 @@ class Match:
distance: Distance distance: Distance
info: Info info: Info
@cached_classproperty
def type(cls) -> str:
return cls.__name__.removesuffix("Match") # type: ignore[attr-defined]
@dataclass @dataclass
class AlbumMatch(Match): class AlbumMatch(Match):

View file

@ -1,15 +1,36 @@
from __future__ import annotations
import os import os
from collections.abc import Sequence from dataclasses import dataclass
from functools import cached_property from functools import cached_property
from typing import TYPE_CHECKING, TypedDict
from typing_extensions import NotRequired
from beets import autotag, config, ui from beets import autotag, config, ui
from beets.autotag import hooks from beets.autotag import hooks
from beets.util import displayable_path from beets.util import displayable_path
from beets.util.units import human_seconds_short from beets.util.units import human_seconds_short
if TYPE_CHECKING:
from collections.abc import Sequence
import confuse
from beets.library.models import Item
from beets.ui import ColorName
VARIOUS_ARTISTS = "Various Artists" VARIOUS_ARTISTS = "Various Artists"
class Line(TypedDict):
prefix: str
contents: str
suffix: str
width: NotRequired[int]
@dataclass
class ChangeRepresentation: class ChangeRepresentation:
"""Keeps track of all information needed to generate a (colored) text """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 representation of the changes that will be made if an album or singleton's
@ -17,46 +38,46 @@ class ChangeRepresentation:
TrackMatch object, accordingly. TrackMatch object, accordingly.
""" """
cur_artist: str
cur_name: str
match: autotag.hooks.Match
@cached_property @cached_property
def changed_prefix(self) -> str: def changed_prefix(self) -> str:
return ui.colorize("changed", "\u2260") return ui.colorize("changed", "\u2260")
cur_artist = None @cached_property
# cur_album set if album, cur_title set if singleton def _indentation_config(self) -> confuse.ConfigView:
cur_album = None return config["ui"]["import"]["indentation"]
cur_title = None
match = None
indent_header = ""
indent_detail = ""
def __init__(self): @cached_property
# Read match header indentation width from config. def indent_header(self) -> str:
match_header_indent_width = config["ui"]["import"]["indentation"][ return ui.indent(self._indentation_config["match_header"].as_number())
"match_header"
].as_number()
self.indent_header = ui.indent(match_header_indent_width)
# Read match detail indentation width from config. @cached_property
match_detail_indent_width = config["ui"]["import"]["indentation"][ def indent_detail(self) -> str:
"match_details" return ui.indent(self._indentation_config["match_details"].as_number())
].as_number()
self.indent_detail = ui.indent(match_detail_indent_width)
# Read match tracklist indentation width from config @cached_property
match_tracklist_indent_width = config["ui"]["import"]["indentation"][ def indent_tracklist(self) -> str:
"match_tracklist" return ui.indent(
].as_number() self._indentation_config["match_tracklist"].as_number()
self.indent_tracklist = ui.indent(match_tracklist_indent_width) )
self.layout = config["ui"]["import"]["layout"].as_choice(
{ @cached_property
"column": 0, def layout(self) -> int:
"newline": 1, return config["ui"]["import"]["layout"].as_choice(
} {"column": 0, "newline": 1}
) )
def print_layout( def print_layout(
self, indent, left, right, separator=" -> ", max_width=None self,
): indent: str,
left: Line,
right: Line,
separator: str = " -> ",
max_width: int | None = None,
) -> None:
if not max_width: if not max_width:
# If no max_width provided, use terminal width # If no max_width provided, use terminal width
max_width = ui.term_width() max_width = ui.term_width()
@ -65,7 +86,7 @@ class ChangeRepresentation:
else: else:
ui.print_newline_layout(indent, left, right, separator, max_width) ui.print_newline_layout(indent, left, right, separator, max_width)
def show_match_header(self): def show_match_header(self) -> None:
"""Print out a 'header' identifying the suggested match (album name, """Print out a 'header' identifying the suggested match (album name,
artist name,...) and summarizing the changes that would be made should artist name,...) and summarizing the changes that would be made should
the user accept the match. the user accept the match.
@ -78,19 +99,10 @@ class ChangeRepresentation:
f"{self.indent_header}Match ({dist_string(self.match.distance)}):" f"{self.indent_header}Match ({dist_string(self.match.distance)}):"
) )
if isinstance(self.match.info, autotag.hooks.AlbumInfo): artist_name_str = f"{self.match.info.artist} - {self.match.info.name}"
# Matching an album - print that
artist_album_str = (
f"{self.match.info.artist} - {self.match.info.album}"
)
else:
# Matching a single track
artist_album_str = (
f"{self.match.info.artist} - {self.match.info.title}"
)
ui.print_( ui.print_(
self.indent_header self.indent_header
+ dist_colorize(artist_album_str, self.match.distance) + dist_colorize(artist_name_str, self.match.distance)
) )
# Penalties. # Penalties.
@ -108,7 +120,7 @@ class ChangeRepresentation:
url = ui.colorize("text_faint", f"{self.match.info.data_url}") url = ui.colorize("text_faint", f"{self.match.info.data_url}")
ui.print_(f"{self.indent_header}{url}") ui.print_(f"{self.indent_header}{url}")
def show_match_details(self): def show_match_details(self) -> None:
"""Print out the details of the match, including changes in album name """Print out the details of the match, including changes in album name
and artist name. and artist name.
""" """
@ -117,6 +129,8 @@ class ChangeRepresentation:
if artist_r == VARIOUS_ARTISTS: if artist_r == VARIOUS_ARTISTS:
# Hide artists for VA releases. # Hide artists for VA releases.
artist_l, artist_r = "", "" artist_l, artist_r = "", ""
left: Line
right: Line
if artist_l != artist_r: if artist_l != artist_r:
artist_l, artist_r = ui.colordiff(artist_l, artist_r) artist_l, artist_r = ui.colordiff(artist_l, artist_r)
left = { left = {
@ -130,39 +144,22 @@ class ChangeRepresentation:
else: else:
ui.print_(f"{self.indent_detail}*", "Artist:", artist_r) ui.print_(f"{self.indent_detail}*", "Artist:", artist_r)
if self.cur_album: if self.cur_name:
# Album type_ = self.match.type
album_l, album_r = self.cur_album or "", self.match.info.album name_l, name_r = self.cur_name or "", self.match.info.name
if ( if self.cur_name != self.match.info.name != VARIOUS_ARTISTS:
self.cur_album != self.match.info.album name_l, name_r = ui.colordiff(name_l, name_r)
and self.match.info.album != VARIOUS_ARTISTS
):
album_l, album_r = ui.colordiff(album_l, album_r)
left = { left = {
"prefix": f"{self.changed_prefix} Album: ", "prefix": f"{self.changed_prefix} {type_}: ",
"contents": album_l, "contents": name_l,
"suffix": "", "suffix": "",
} }
right = {"prefix": "", "contents": album_r, "suffix": ""} right = {"prefix": "", "contents": name_r, "suffix": ""}
self.print_layout(self.indent_detail, left, right) self.print_layout(self.indent_detail, left, right)
else: else:
ui.print_(f"{self.indent_detail}*", "Album:", album_r) ui.print_(f"{self.indent_detail}*", f"{type_}:", name_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)
left = {
"prefix": f"{self.changed_prefix} Title: ",
"contents": title_l,
"suffix": "",
}
right = {"prefix": "", "contents": title_r, "suffix": ""}
self.print_layout(self.indent_detail, left, right)
else:
ui.print_(f"{self.indent_detail}*", "Title:", title_r)
def make_medium_info_line(self, track_info): def make_medium_info_line(self, track_info: hooks.TrackInfo) -> str:
"""Construct a line with the current medium's info.""" """Construct a line with the current medium's info."""
track_media = track_info.get("media", "Media") track_media = track_info.get("media", "Media")
# Build output string. # Build output string.
@ -177,7 +174,7 @@ class ChangeRepresentation:
else: else:
return "" return ""
def format_index(self, track_info): def format_index(self, track_info: hooks.TrackInfo | Item) -> str:
"""Return a string representing the track index of the given """Return a string representing the track index of the given
TrackInfo or Item object. TrackInfo or Item object.
""" """
@ -198,12 +195,15 @@ class ChangeRepresentation:
else: else:
return str(index) return str(index)
def make_track_numbers(self, item, track_info): def make_track_numbers(
self, item, track_info: hooks.TrackInfo
) -> tuple[str, str, bool]:
"""Format colored track indices.""" """Format colored track indices."""
cur_track = self.format_index(item) cur_track = self.format_index(item)
new_track = self.format_index(track_info) new_track = self.format_index(track_info)
changed = False changed = False
# Choose color based on change. # Choose color based on change.
highlight_color: ColorName
if cur_track != new_track: if cur_track != new_track:
changed = True changed = True
if item.track in (track_info.index, track_info.medium_index): if item.track in (track_info.index, track_info.medium_index):
@ -218,9 +218,11 @@ class ChangeRepresentation:
return lhs_track, rhs_track, changed return lhs_track, rhs_track, changed
@staticmethod @staticmethod
def make_track_titles(item, track_info): def make_track_titles(
item: Item, track_info: hooks.TrackInfo
) -> tuple[str, str, bool]:
"""Format colored track titles.""" """Format colored track titles."""
new_title = track_info.title new_title = track_info.name
if not item.title.strip(): if not item.title.strip():
# If there's no title, we use the filename. Don't colordiff. # If there's no title, we use the filename. Don't colordiff.
cur_title = displayable_path(os.path.basename(item.path)) cur_title = displayable_path(os.path.basename(item.path))
@ -232,9 +234,12 @@ class ChangeRepresentation:
return cur_col, new_col, cur_title != new_title return cur_col, new_col, cur_title != new_title
@staticmethod @staticmethod
def make_track_lengths(item, track_info): def make_track_lengths(
item: Item, track_info: hooks.TrackInfo
) -> tuple[str, str, bool]:
"""Format colored track lengths.""" """Format colored track lengths."""
changed = False changed = False
highlight_color: ColorName
if ( if (
item.length item.length
and track_info.length and track_info.length
@ -258,7 +263,9 @@ class ChangeRepresentation:
return lhs_length, rhs_length, changed return lhs_length, rhs_length, changed
def make_line(self, item, track_info): def make_line(
self, item: Item, track_info: hooks.TrackInfo
) -> tuple[Line, Line]:
"""Extract changes from item -> new TrackInfo object, and colorize """Extract changes from item -> new TrackInfo object, and colorize
appropriately. Returns (lhs, rhs) for column printing. appropriately. Returns (lhs, rhs) for column printing.
""" """
@ -282,12 +289,12 @@ class ChangeRepresentation:
# the case, thus the 'info' dictionary is unneeded. # the case, thus the 'info' dictionary is unneeded.
# penalties = penalty_string(self.match.distance.tracks[track_info]) # penalties = penalty_string(self.match.distance.tracks[track_info])
lhs = { lhs: Line = {
"prefix": f"{self.changed_prefix if changed else '*'} {lhs_track} ", "prefix": f"{self.changed_prefix if changed else '*'} {lhs_track} ",
"contents": lhs_title, "contents": lhs_title,
"suffix": f" {lhs_length}", "suffix": f" {lhs_length}",
} }
rhs = {"prefix": "", "contents": "", "suffix": ""} rhs: Line = {"prefix": "", "contents": "", "suffix": ""}
if not changed: if not changed:
# Only return the left side, as nothing changed. # Only return the left side, as nothing changed.
return (lhs, rhs) return (lhs, rhs)
@ -358,27 +365,18 @@ class ChangeRepresentation:
class AlbumChange(ChangeRepresentation): class AlbumChange(ChangeRepresentation):
"""Album change representation, setting cur_album""" match: autotag.hooks.AlbumMatch
def __init__(self, cur_artist, cur_album, match): def show_match_tracks(self) -> None:
super().__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 """Print out the tracks of the match, summarizing changes the match
suggests for them. suggests for them.
""" """
# Tracks.
# match is an AlbumMatch NamedTuple, mapping is a dict
# Sort the pairs by the track_info index (at index 1 of the NamedTuple)
pairs = sorted( pairs = sorted(
self.match.item_info_pairs, key=lambda pair: pair[1].index self.match.item_info_pairs, key=lambda pair: pair[1].index or 0
) )
# Build up LHS and RHS for track difference display. The `lines` list # Build up LHS and RHS for track difference display. The `lines` list
# contains `(left, right)` tuples. # contains `(left, right)` tuples.
lines = [] lines: list[tuple[Line, Line]] = []
medium = disctitle = None medium = disctitle = None
for item, track_info in pairs: for item, track_info in pairs:
# If the track is the first on a new medium, show medium # If the track is the first on a new medium, show medium
@ -427,21 +425,17 @@ class AlbumChange(ChangeRepresentation):
class TrackChange(ChangeRepresentation): class TrackChange(ChangeRepresentation):
"""Track change representation, comparing item with match.""" """Track change representation, comparing item with match."""
def __init__(self, cur_artist, cur_title, match): match: autotag.hooks.TrackMatch
super().__init__()
self.cur_artist = cur_artist
self.cur_title = cur_title
self.match = match
def show_change(cur_artist, cur_album, match): def show_change(
cur_artist: str, cur_album: str, match: hooks.AlbumMatch
) -> None:
"""Print out a representation of the changes that will be made if an """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 album's tags are changed according to `match`, which must be an AlbumMatch
object. object.
""" """
change = AlbumChange( change = AlbumChange(cur_artist, cur_album, match)
cur_artist=cur_artist, cur_album=cur_album, match=match
)
# Print the match header. # Print the match header.
change.show_match_header() change.show_match_header()
@ -453,13 +447,11 @@ def show_change(cur_artist, cur_album, match):
change.show_match_tracks() change.show_match_tracks()
def show_item_change(item, match): def show_item_change(item: Item, match: hooks.TrackMatch) -> None:
"""Print out the change that would occur by tagging `item` with the """Print out the change that would occur by tagging `item` with the
metadata from `match`, a TrackMatch object. metadata from `match`, a TrackMatch object.
""" """
change = TrackChange( change = TrackChange(item.artist, item.title, match)
cur_artist=item.artist, cur_title=item.title, match=match
)
# Print the match header. # Print the match header.
change.show_match_header() change.show_match_header()
# Print the match details. # Print the match details.

View file

@ -451,10 +451,7 @@ def choose_candidate(
index = dist_colorize(index0, match.distance) index = dist_colorize(index0, match.distance)
dist = f"({(1 - match.distance) * 100:.1f}%)" dist = f"({(1 - match.distance) * 100:.1f}%)"
distance = dist_colorize(dist, match.distance) distance = dist_colorize(dist, match.distance)
metadata = ( metadata = f"{match.info.artist} - {match.info.name}"
f"{match.info.artist} -"
f" {match.info.title if singleton else match.info.album}"
)
if i == 0: if i == 0:
metadata = dist_colorize(metadata, match.distance) metadata = dist_colorize(metadata, match.distance)
else: else: