This commit is contained in:
Šarūnas Nejus 2025-12-05 10:52:09 +10:00 committed by GitHub
commit d416c52155
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 200 additions and 174 deletions

View file

@ -29,7 +29,7 @@ from .hooks import AlbumInfo, AlbumMatch, TrackInfo, TrackMatch
from .match import Proposal, Recommendation, tag_album, tag_item from .match import Proposal, Recommendation, tag_album, tag_item
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Mapping, Sequence from collections.abc import Sequence
from beets.library import Album, Item, LibModel from beets.library import Album, Item, LibModel
@ -204,11 +204,11 @@ def apply_album_metadata(album_info: AlbumInfo, album: Album):
correct_list_fields(album) correct_list_fields(album)
def apply_metadata(album_info: AlbumInfo, mapping: Mapping[Item, TrackInfo]): def apply_metadata(
"""Set the items' metadata to match an AlbumInfo object using a album_info: AlbumInfo, item_info_pairs: list[tuple[Item, TrackInfo]]
mapping from Items to TrackInfo objects. ):
""" """Set items metadata to match corresponding tagged info."""
for item, track_info in mapping.items(): for item, track_info in item_info_pairs:
# Artist or artist credit. # Artist or artist credit.
if config["artist_credit"]: if config["artist_credit"]:
item.artist = ( item.artist = (

View file

@ -422,7 +422,7 @@ def track_distance(
def distance( def distance(
items: Sequence[Item], items: Sequence[Item],
album_info: AlbumInfo, album_info: AlbumInfo,
mapping: dict[Item, TrackInfo], item_info_pairs: list[tuple[Item, TrackInfo]],
) -> Distance: ) -> Distance:
"""Determines how "significant" an album metadata change would be. """Determines how "significant" an album metadata change would be.
Returns a Distance object. `album_info` is an AlbumInfo object Returns a Distance object. `album_info` is an AlbumInfo object
@ -518,16 +518,16 @@ def distance(
# Tracks. # Tracks.
dist.tracks = {} dist.tracks = {}
for item, track in mapping.items(): for item, track in item_info_pairs:
dist.tracks[track] = track_distance(item, track, album_info.va) dist.tracks[track] = track_distance(item, track, album_info.va)
dist.add("tracks", dist.tracks[track].distance) dist.add("tracks", dist.tracks[track].distance)
# Missing tracks. # Missing tracks.
for _ in range(len(album_info.tracks) - len(mapping)): for _ in range(len(album_info.tracks) - len(item_info_pairs)):
dist.add("missing_tracks", 1.0) dist.add("missing_tracks", 1.0)
# Unmatched tracks. # Unmatched tracks.
for _ in range(len(items) - len(mapping)): for _ in range(len(items) - len(item_info_pairs)):
dist.add("unmatched_tracks", 1.0) dist.add("unmatched_tracks", 1.0)
dist.add_data_source(likelies["data_source"], album_info.data_source) dist.add_data_source(likelies["data_source"], album_info.data_source)

View file

@ -17,10 +17,14 @@
from __future__ import annotations from __future__ import annotations
from copy import deepcopy from copy import deepcopy
from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar from dataclasses import dataclass
from functools import cached_property
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
@ -54,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,
@ -95,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],
@ -167,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,
*, *,
@ -214,16 +230,32 @@ class TrackInfo(Info):
# Structures that compose all the information for a candidate match. # Structures that compose all the information for a candidate match.
@dataclass
class Match:
class AlbumMatch(NamedTuple):
distance: Distance distance: Distance
info: Info
@cached_classproperty
def type(cls) -> str:
return cls.__name__.removesuffix("Match") # type: ignore[attr-defined]
@dataclass
class AlbumMatch(Match):
info: AlbumInfo info: AlbumInfo
mapping: dict[Item, TrackInfo] mapping: dict[Item, TrackInfo]
extra_items: list[Item] extra_items: list[Item]
extra_tracks: list[TrackInfo] extra_tracks: list[TrackInfo]
@property
def item_info_pairs(self) -> list[tuple[Item, TrackInfo]]:
return list(self.mapping.items())
class TrackMatch(NamedTuple): @property
distance: Distance def items(self) -> list[Item]:
return [i for i, _ in self.item_info_pairs]
@dataclass
class TrackMatch(Match):
info: TrackInfo info: TrackInfo

View file

@ -69,7 +69,7 @@ class Proposal(NamedTuple):
def assign_items( def assign_items(
items: Sequence[Item], items: Sequence[Item],
tracks: Sequence[TrackInfo], tracks: Sequence[TrackInfo],
) -> tuple[dict[Item, TrackInfo], list[Item], list[TrackInfo]]: ) -> tuple[list[tuple[Item, TrackInfo]], list[Item], list[TrackInfo]]:
"""Given a list of Items and a list of TrackInfo objects, find the """Given a list of Items and a list of TrackInfo objects, find the
best mapping between them. Returns a mapping from Items to TrackInfo best mapping between them. Returns a mapping from Items to TrackInfo
objects, a set of extra Items, and a set of extra TrackInfo objects, a set of extra Items, and a set of extra TrackInfo
@ -86,16 +86,17 @@ def assign_items(
# Each item in `assigned_item_idxs` list corresponds to a track in the # Each item in `assigned_item_idxs` list corresponds to a track in the
# `tracks` list. Each value is either an index into the assigned item in # `tracks` list. Each value is either an index into the assigned item in
# `items` list, or -1 if that track has no match. # `items` list, or -1 if that track has no match.
mapping = { item_info_pairs = [
items[iidx]: t (items[iidx], t)
for iidx, t in zip(assigned_item_idxs, tracks) for iidx, t in zip(assigned_item_idxs, tracks)
if iidx != -1 if iidx != -1
} ]
extra_items = list(set(items) - mapping.keys()) item_info_pairs.sort(key=lambda it: (it[0].disc, it[0].track, it[0].title))
extra_items = list(set(items) - {i for i, _ in item_info_pairs})
extra_items.sort(key=lambda i: (i.disc, i.track, i.title)) extra_items.sort(key=lambda i: (i.disc, i.track, i.title))
extra_tracks = list(set(tracks) - set(mapping.values())) extra_tracks = list(set(tracks) - {t for _, t in item_info_pairs})
extra_tracks.sort(key=lambda t: (t.index, t.title)) extra_tracks.sort(key=lambda t: (t.index, t.title))
return mapping, extra_items, extra_tracks return item_info_pairs, extra_items, extra_tracks
def match_by_id(items: Iterable[Item]) -> AlbumInfo | None: def match_by_id(items: Iterable[Item]) -> AlbumInfo | None:
@ -217,10 +218,12 @@ def _add_candidate(
return return
# Find mapping between the items and the track info. # Find mapping between the items and the track info.
mapping, extra_items, extra_tracks = assign_items(items, info.tracks) item_info_pairs, extra_items, extra_tracks = assign_items(
items, info.tracks
)
# Get the change distance. # Get the change distance.
dist = distance(items, info, mapping) dist = distance(items, info, item_info_pairs)
# Skip matches with ignored penalties. # Skip matches with ignored penalties.
penalties = [key for key, _ in dist] penalties = [key for key, _ in dist]
@ -232,14 +235,14 @@ def _add_candidate(
log.debug("Success. Distance: {}", dist) log.debug("Success. Distance: {}", dist)
results[info.album_id] = hooks.AlbumMatch( results[info.album_id] = hooks.AlbumMatch(
dist, info, mapping, extra_items, extra_tracks dist, info, dict(item_info_pairs), extra_items, extra_tracks
) )
def tag_album( def tag_album(
items, items,
search_artist: str | None = None, search_artist: str | None = None,
search_album: str | None = None, search_name: str | None = None,
search_ids: list[str] = [], search_ids: list[str] = [],
) -> tuple[str, str, Proposal]: ) -> tuple[str, str, Proposal]:
"""Return a tuple of the current artist name, the current album """Return a tuple of the current artist name, the current album
@ -300,10 +303,10 @@ def tag_album(
) )
# Search terms. # Search terms.
if not (search_artist and search_album): if not (search_artist and search_name):
# No explicit search terms -- use current metadata. # No explicit search terms -- use current metadata.
search_artist, search_album = cur_artist, cur_album search_artist, search_name = cur_artist, cur_album
log.debug("Search terms: {} - {}", search_artist, search_album) log.debug("Search terms: {} - {}", search_artist, search_name)
# Is this album likely to be a "various artist" release? # Is this album likely to be a "various artist" release?
va_likely = ( va_likely = (
@ -315,7 +318,7 @@ def tag_album(
# Get the results from the data sources. # Get the results from the data sources.
for matched_candidate in metadata_plugins.candidates( for matched_candidate in metadata_plugins.candidates(
items, search_artist, search_album, va_likely items, search_artist, search_name, va_likely
): ):
_add_candidate(items, candidates, matched_candidate) _add_candidate(items, candidates, matched_candidate)
if opt_candidate := candidates.get(matched_candidate.album_id): if opt_candidate := candidates.get(matched_candidate.album_id):
@ -331,7 +334,7 @@ def tag_album(
def tag_item( def tag_item(
item, item,
search_artist: str | None = None, search_artist: str | None = None,
search_title: str | None = None, search_name: str | None = None,
search_ids: list[str] | None = None, search_ids: list[str] | None = None,
) -> Proposal: ) -> Proposal:
"""Find metadata for a single track. Return a `Proposal` consisting """Find metadata for a single track. Return a `Proposal` consisting
@ -373,12 +376,12 @@ def tag_item(
# Search terms. # Search terms.
search_artist = search_artist or item.artist search_artist = search_artist or item.artist
search_title = search_title or item.title search_name = search_name or item.title
log.debug("Item search terms: {} - {}", search_artist, search_title) log.debug("Item search terms: {} - {}", search_artist, search_name)
# Get and evaluate candidate metadata. # Get and evaluate candidate metadata.
for track_info in metadata_plugins.item_candidates( for track_info in metadata_plugins.item_candidates(
item, search_artist, search_title item, search_artist, search_name
): ):
dist = track_distance(item, track_info, incl_artist=True) dist = track_distance(item, track_info, incl_artist=True)
candidates[track_info.track_id] = hooks.TrackMatch(dist, track_info) candidates[track_info.track_id] = hooks.TrackMatch(dist, track_info)

View file

@ -245,21 +245,21 @@ class ImportTask(BaseImportTask):
matched items. matched items.
""" """
if self.choice_flag in (Action.ASIS, Action.RETAG): if self.choice_flag in (Action.ASIS, Action.RETAG):
return list(self.items) return self.items
elif self.choice_flag == Action.APPLY and isinstance( elif self.choice_flag == Action.APPLY and isinstance(
self.match, autotag.AlbumMatch self.match, autotag.AlbumMatch
): ):
return list(self.match.mapping.keys()) return self.match.items
else: else:
assert False assert False
def apply_metadata(self): def apply_metadata(self):
"""Copy metadata from match info to the items.""" """Copy metadata from match info to the items."""
if config["import"]["from_scratch"]: if config["import"]["from_scratch"]:
for item in self.match.mapping: for item in self.match.items:
item.clear() item.clear()
autotag.apply_metadata(self.match.info, self.match.mapping) autotag.apply_metadata(self.match.info, self.match.item_info_pairs)
def duplicate_items(self, lib: library.Library): def duplicate_items(self, lib: library.Library):
duplicate_items = [] duplicate_items = []

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,26 +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. pairs = sorted(
# match is an AlbumMatch NamedTuple, mapping is a dict self.match.item_info_pairs, key=lambda pair: pair[1].index or 0
# 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 # 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
@ -426,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()
@ -452,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

@ -444,10 +444,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:

View file

@ -149,14 +149,14 @@ class BPSyncPlugin(BeetsPlugin):
library_trackid_to_item = { library_trackid_to_item = {
int(item.mb_trackid): item for item in items int(item.mb_trackid): item for item in items
} }
item_to_trackinfo = { item_info_pairs = [
item: beatport_trackid_to_trackinfo[track_id] (item, beatport_trackid_to_trackinfo[track_id])
for track_id, item in library_trackid_to_item.items() for track_id, item in library_trackid_to_item.items()
} ]
self._log.info("applying changes to {}", album) self._log.info("applying changes to {}", album)
with lib.transaction(): with lib.transaction():
autotag.apply_metadata(albuminfo, item_to_trackinfo) autotag.apply_metadata(albuminfo, item_info_pairs)
changed = False changed = False
# Find any changed item to apply Beatport changes to album. # Find any changed item to apply Beatport changes to album.
any_changed_item = items[0] any_changed_item = items[0]

View file

@ -278,11 +278,8 @@ class MusicBrainzPseudoReleasePlugin(MusicBrainzPlugin):
album_info.album_id, album_info.album_id,
) )
album_info.use_pseudo_as_ref() album_info.use_pseudo_as_ref()
mapping = match.mapping new_pairs, *_ = assign_items(match.items, album_info.tracks)
new_mappings, _, _ = assign_items( album_info.mapping = dict(new_pairs)
list(mapping.keys()), album_info.tracks
)
mapping.update(new_mappings)
if album_info.data_source == self.data_source: if album_info.data_source == self.data_source:
album_info.data_source = "MusicBrainz" album_info.data_source = "MusicBrainz"

View file

@ -121,18 +121,20 @@ class MBSyncPlugin(BeetsPlugin):
# Construct a track mapping according to MBIDs (release track MBIDs # Construct a track mapping according to MBIDs (release track MBIDs
# first, if available, and recording MBIDs otherwise). This should # first, if available, and recording MBIDs otherwise). This should
# work for albums that have missing or extra tracks. # work for albums that have missing or extra tracks.
mapping = {} item_info_pairs = []
items = list(album.items()) items = list(album.items())
for item in items: for item in items:
if ( if (
item.mb_releasetrackid item.mb_releasetrackid
and item.mb_releasetrackid in releasetrack_index and item.mb_releasetrackid in releasetrack_index
): ):
mapping[item] = releasetrack_index[item.mb_releasetrackid] item_info_pairs.append(
(item, releasetrack_index[item.mb_releasetrackid])
)
else: else:
candidates = track_index[item.mb_trackid] candidates = track_index[item.mb_trackid]
if len(candidates) == 1: if len(candidates) == 1:
mapping[item] = candidates[0] item_info_pairs.append((item, candidates[0]))
else: else:
# If there are multiple copies of a recording, they are # If there are multiple copies of a recording, they are
# disambiguated using their disc and track number. # disambiguated using their disc and track number.
@ -141,13 +143,13 @@ class MBSyncPlugin(BeetsPlugin):
c.medium_index == item.track c.medium_index == item.track
and c.medium == item.disc and c.medium == item.disc
): ):
mapping[item] = c item_info_pairs.append((item, c))
break break
# Apply. # Apply.
self._log.debug("applying changes to {}", album) self._log.debug("applying changes to {}", album)
with lib.transaction(): with lib.transaction():
autotag.apply_metadata(album_info, mapping) autotag.apply_metadata(album_info, item_info_pairs)
changed = False changed = False
# Find any changed item to apply changes to album. # Find any changed item to apply changes to album.
any_changed_item = items[0] any_changed_item = items[0]

View file

@ -182,7 +182,7 @@ class TestAlbumDistance:
@pytest.fixture @pytest.fixture
def get_dist(self, items): def get_dist(self, items):
def inner(info: AlbumInfo): def inner(info: AlbumInfo):
return distance(items, info, dict(zip(items, info.tracks))) return distance(items, info, list(zip(items, info.tracks)))
return inner return inner

View file

@ -55,10 +55,12 @@ class TestAssignment(ConfigMixin):
items = [Item(title=title) for title in item_titles] items = [Item(title=title) for title in item_titles]
tracks = [TrackInfo(title=title) for title in track_titles] tracks = [TrackInfo(title=title) for title in track_titles]
mapping, extra_items, extra_tracks = match.assign_items(items, tracks) item_info_pairs, extra_items, extra_tracks = match.assign_items(
items, tracks
)
assert ( assert (
{i.title: t.title for i, t in mapping.items()}, {i.title: t.title for i, t in item_info_pairs},
[i.title for i in extra_items], [i.title for i in extra_items],
[t.title for t in extra_tracks], [t.title for t in extra_tracks],
) == (expected_mapping, expected_extra_items, expected_extra_tracks) ) == (expected_mapping, expected_extra_items, expected_extra_tracks)
@ -105,7 +107,7 @@ class TestAssignment(ConfigMixin):
trackinfo.append(info(11, "Beloved One", 243.733)) trackinfo.append(info(11, "Beloved One", 243.733))
trackinfo.append(info(12, "In the Lord's Arms", 186.13300000000001)) trackinfo.append(info(12, "In the Lord's Arms", 186.13300000000001))
expected = dict(zip(items, trackinfo)), [], [] expected = list(zip(items, trackinfo)), [], []
assert match.assign_items(items, trackinfo) == expected assert match.assign_items(items, trackinfo) == expected
@ -113,12 +115,10 @@ class TestAssignment(ConfigMixin):
class ApplyTestUtil: class ApplyTestUtil:
def _apply(self, info=None, per_disc_numbering=False, artist_credit=False): def _apply(self, info=None, per_disc_numbering=False, artist_credit=False):
info = info or self.info info = info or self.info
mapping = {} item_info_pairs = list(zip(self.items, info.tracks))
for i, t in zip(self.items, info.tracks):
mapping[i] = t
config["per_disc_numbering"] = per_disc_numbering config["per_disc_numbering"] = per_disc_numbering
config["artist_credit"] = artist_credit config["artist_credit"] = artist_credit
autotag.apply_metadata(info, mapping) autotag.apply_metadata(info, item_info_pairs)
class ApplyTest(BeetsTestCase, ApplyTestUtil): class ApplyTest(BeetsTestCase, ApplyTestUtil):

View file

@ -87,15 +87,17 @@ class ShowChangeTest(IOMixin, unittest.TestCase):
"""Return an unicode string representing the changes""" """Return an unicode string representing the changes"""
items = items or self.items items = items or self.items
info = info or self.info info = info or self.info
mapping = dict(zip(items, info.tracks)) item_info_pairs = list(zip(items, info.tracks))
config["ui"]["color"] = color config["ui"]["color"] = color
config["import"]["detail"] = True config["import"]["detail"] = True
change_dist = distance(items, info, mapping) change_dist = distance(items, info, item_info_pairs)
change_dist._penalties = {"album": [dist], "artist": [dist]} change_dist._penalties = {"album": [dist], "artist": [dist]}
show_change( show_change(
cur_artist, cur_artist,
cur_album, cur_album,
autotag.AlbumMatch(change_dist, info, mapping, set(), set()), autotag.AlbumMatch(
change_dist, info, dict(item_info_pairs), set(), set()
),
) )
return self.io.getoutput().lower() return self.io.getoutput().lower()