mirror of
https://github.com/beetbox/beets.git
synced 2025-12-07 17:16:07 +01:00
Merge 02f3cb7821 into 2bd77b9895
This commit is contained in:
commit
d416c52155
13 changed files with 200 additions and 174 deletions
|
|
@ -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 = (
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 = []
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue