From 87ed0f12eaf02288b47ba9bfc8d73abbaf5f6389 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Mon, 5 Dec 2022 07:39:06 +0100 Subject: [PATCH 01/70] Add --pretend option to splupdate command --- beetsplug/smartplaylist.py | 62 ++++++++++++++++++++++++++------------ 1 file changed, 42 insertions(+), 20 deletions(-) diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index 4c921eccc..5693353c0 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -19,7 +19,7 @@ from beets.plugins import BeetsPlugin from beets import ui from beets.util import (mkdirall, normpath, sanitize_path, syspath, - bytestring_path, path_as_posix) + bytestring_path, path_as_posix, displayable_path) from beets.library import Item, Album, parse_query_string from beets.dbcore import OrQuery from beets.dbcore.query import MultipleSort, ParsingError @@ -44,6 +44,7 @@ class SmartPlaylistPlugin(BeetsPlugin): 'forward_slash': False, 'prefix': '', 'urlencode': False, + 'pretend_paths': False, }) self.config['prefix'].redact = True # May contain username/password. @@ -59,6 +60,10 @@ class SmartPlaylistPlugin(BeetsPlugin): help='update the smart playlists. Playlist names may be ' 'passed as arguments.' ) + spl_update.parser.add_option( + '-p', '--pretend', action='store_true', + help="display query results but don't write playlist files." + ) spl_update.func = self.update_cmd return [spl_update] @@ -84,7 +89,7 @@ class SmartPlaylistPlugin(BeetsPlugin): else: self._matched_playlists = self._unmatched_playlists - self.update_playlists(lib) + self.update_playlists(lib, opts.pretend) def build_queries(self): """ @@ -170,9 +175,13 @@ class SmartPlaylistPlugin(BeetsPlugin): self._unmatched_playlists -= self._matched_playlists - def update_playlists(self, lib): - self._log.info("Updating {0} smart playlists...", - len(self._matched_playlists)) + def update_playlists(self, lib, pretend=False): + if pretend: + self._log.info("Showing query results for {0} smart playlists...", + len(self._matched_playlists)) + else: + self._log.info("Updating {0} smart playlists...", + len(self._matched_playlists)) playlist_dir = self.config['playlist_dir'].as_filename() playlist_dir = bytestring_path(playlist_dir) @@ -185,7 +194,10 @@ class SmartPlaylistPlugin(BeetsPlugin): for playlist in self._matched_playlists: name, (query, q_sort), (album_query, a_q_sort) = playlist - self._log.debug("Creating playlist {0}", name) + if pretend: + self._log.info('Results for playlist {}:', name) + else: + self._log.debug("Creating playlist {0}", name) items = [] if query: @@ -206,19 +218,29 @@ class SmartPlaylistPlugin(BeetsPlugin): item_path = os.path.relpath(item.path, relative_to) if item_path not in m3us[m3u_name]: m3us[m3u_name].append(item_path) + if pretend and self.config['pretend_paths']: + print(displayable_path(item_path)) + elif pretend: + print(item) - prefix = bytestring_path(self.config['prefix'].as_str()) - # Write all of the accumulated track lists to files. - for m3u in m3us: - m3u_path = normpath(os.path.join(playlist_dir, - bytestring_path(m3u))) - mkdirall(m3u_path) - with open(syspath(m3u_path), 'wb') as f: - for path in m3us[m3u]: - if self.config['forward_slash'].get(): - path = path_as_posix(path) - if self.config['urlencode']: - path = bytestring_path(pathname2url(path)) - f.write(prefix + path + b'\n') + if not pretend: + prefix = bytestring_path(self.config['prefix'].as_str()) + # Write all of the accumulated track lists to files. + for m3u in m3us: + m3u_path = normpath(os.path.join(playlist_dir, + bytestring_path(m3u))) + mkdirall(m3u_path) + with open(syspath(m3u_path), 'wb') as f: + for path in m3us[m3u]: + if self.config['forward_slash'].get(): + path = path_as_posix(path) + if self.config['urlencode']: + path = bytestring_path(pathname2url(path)) + f.write(prefix + path + b'\n') - self._log.info("{0} playlists updated", len(self._matched_playlists)) + if pretend: + self._log.info("Displayed results for {0} playlists", + len(self._matched_playlists)) + else: + self._log.info("{0} playlists updated", + len(self._matched_playlists)) From 1a4cff2a498af2b1d163766f06b6d6ba889e2bf6 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Tue, 6 Dec 2022 07:19:07 +0100 Subject: [PATCH 02/70] Add changelog for #4573 --- docs/changelog.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index c7f3eb614..2d9fc6247 100755 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -49,6 +49,10 @@ New features: :bug:`4438` * Add a new ``import.ignored_alias_types`` config option to allow for specific alias types to be skipped over when importing items/albums. +* :doc:`/plugins/smartplaylist`: A new ``--pretend`` option lets the user see + what a new or changed smart playlist saved in the config is actually + returning. + :bug:`4573` Bug fixes: From bcc9c93f9788de8eff15d13c9cd216604756001c Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Tue, 6 Dec 2022 07:49:36 +0100 Subject: [PATCH 03/70] Add docs for #4573 --- docs/plugins/smartplaylist.rst | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/plugins/smartplaylist.rst b/docs/plugins/smartplaylist.rst index 553ee48af..e687a68a4 100644 --- a/docs/plugins/smartplaylist.rst +++ b/docs/plugins/smartplaylist.rst @@ -82,6 +82,17 @@ automatically notify MPD of the playlist change, by adding ``mpdupdate`` to the ``plugins`` line in your config file *after* the ``smartplaylist`` plugin. +While changing existing playlists in the beets configuration it can help to use +the ``--pretend`` option to find out if the edits work as expected. The results +of the queries will be printed out instead of being written to the playlist +file. + + $ beet splupdate --pretend BeatlesUniverse.m3u + +The ``pretend_paths`` configuration option sets whether the items should be +displayed as per the user's ``format_item`` setting or what the file +paths as they would be written to the m3u file look like. + Configuration ------------- @@ -105,3 +116,5 @@ other configuration options are: example, you could use the URL for a server where the music is stored. Default: empty string. - **urlencoded**: URL-encode all paths. Default: ``no``. +- **pretend_paths**: When running with ``--pretend``, show the actual file + paths that will be written to the m3u file. Default: ``false``. From 00c0626e8ba73d70a0926ec597f4c91e17b06d3a Mon Sep 17 00:00:00 2001 From: Serene-Arc Date: Thu, 15 Dec 2022 19:54:29 +1000 Subject: [PATCH 04/70] Add typing for module --- beets/autotag/match.py | 51 +++++++++++++++++++++++++++++++----------- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/beets/autotag/match.py b/beets/autotag/match.py index 814738cd1..48a09e5ce 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -19,14 +19,18 @@ releases and tracks. import datetime import re +from typing import List, Dict, Tuple, Iterable, Union, Iterator, Optional + from munkres import Munkres from collections import namedtuple from beets import logging from beets import plugins from beets import config +from beets.library import Item from beets.util import plurality -from beets.autotag import hooks +from beets.autotag import hooks, TrackInfo, Distance, AlbumInfo, TrackMatch, \ + AlbumMatch from beets.util.enumeration import OrderedEnum # Artist signals that indicate "various artists". These are used at the @@ -85,7 +89,10 @@ def current_metadata(items): return likelies, consensus -def assign_items(items, tracks): +def assign_items( + items: List[Item], + tracks: List[TrackInfo], +) -> Tuple[Dict, List[Item], List[TrackInfo]]: """Given a list of Items and a list of TrackInfo objects, find the best mapping between them. Returns a mapping from Items to TrackInfo objects, a set of extra Items, and a set of extra TrackInfo @@ -114,14 +121,18 @@ def assign_items(items, tracks): return mapping, extra_items, extra_tracks -def track_index_changed(item, track_info): +def track_index_changed(item: Item, track_info: TrackInfo) -> bool: """Returns True if the item and track info index is different. Tolerates per disc and per release numbering. """ return item.track not in (track_info.medium_index, track_info.index) -def track_distance(item, track_info, incl_artist=False): +def track_distance( + item: Item, + track_info: TrackInfo, + incl_artist: bool = False, +) -> Distance: """Determines the significance of a track metadata change. Returns a Distance object. `incl_artist` indicates that a distance component should be included for the track artist (i.e., for various-artist releases). @@ -157,7 +168,11 @@ def track_distance(item, track_info, incl_artist=False): return dist -def distance(items, album_info, mapping): +def distance( + items: Iterable[Item], + album_info: AlbumInfo, + mapping: Dict[Item, TrackInfo], +) -> Distance: """Determines how "significant" an album metadata change would be. Returns a Distance object. `album_info` is an AlbumInfo object reflecting the album to be compared. `items` is a sequence of all @@ -263,7 +278,7 @@ def distance(items, album_info, mapping): return dist -def match_by_id(items): +def match_by_id(items: Iterable[Item]): """If the items are tagged with a MusicBrainz album ID, returns an AlbumInfo object for the corresponding album. Otherwise, returns None. @@ -287,7 +302,9 @@ def match_by_id(items): return hooks.album_for_mbid(first) -def _recommendation(results): +def _recommendation( + results: List[Union[AlbumMatch, TrackMatch]], +) -> Recommendation: """Given a sorted list of AlbumMatch or TrackMatch objects, return a recommendation based on the results' distances. @@ -338,12 +355,12 @@ def _recommendation(results): return rec -def _sort_candidates(candidates): +def _sort_candidates(candidates) -> Iterable: """Sort candidates by distance.""" return sorted(candidates, key=lambda match: match.distance) -def _add_candidate(items, results, info): +def _add_candidate(items: Iterable[Item], results: Dict, info: AlbumInfo): """Given a candidate AlbumInfo object, attempt to add the candidate to the output dictionary of AlbumMatch objects. This involves checking the track count, ordering the items, checking for @@ -386,8 +403,12 @@ def _add_candidate(items, results, info): extra_items, extra_tracks) -def tag_album(items, search_artist=None, search_album=None, - search_ids=[]): +def tag_album( + items, + search_artist: Optional[str] = None, + search_album: Optional[str] = None, + search_ids: List = [], +) -> Tuple[str, str, Proposal]: """Return a tuple of the current artist name, the current album name, and a `Proposal` containing `AlbumMatch` candidates. @@ -472,8 +493,12 @@ def tag_album(items, search_artist=None, search_album=None, return cur_artist, cur_album, Proposal(candidates, rec) -def tag_item(item, search_artist=None, search_title=None, - search_ids=[]): +def tag_item( + item, + search_artist: Optional[str] = None, + search_title: Optional[str] = None, + search_ids: List = [], +) -> Proposal: """Find metadata for a single track. Return a `Proposal` consisting of `TrackMatch` objects. From 4606ff20ce907595bd685ab4881d44243af1566e Mon Sep 17 00:00:00 2001 From: Serene-Arc Date: Thu, 15 Dec 2022 20:05:21 +1000 Subject: [PATCH 05/70] Add missing typing --- beets/autotag/match.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beets/autotag/match.py b/beets/autotag/match.py index 48a09e5ce..bfe11f5e8 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -19,7 +19,7 @@ releases and tracks. import datetime import re -from typing import List, Dict, Tuple, Iterable, Union, Iterator, Optional +from typing import List, Dict, Tuple, Iterable, Union, Optional from munkres import Munkres from collections import namedtuple @@ -64,7 +64,7 @@ Proposal = namedtuple('Proposal', ('candidates', 'recommendation')) # Primary matching functionality. -def current_metadata(items): +def current_metadata(items: List[Item]) -> Tuple[Dict, Dict]: """Extract the likely current metadata for an album given a list of its items. Return two dictionaries: - The most common value for each field. From 5044d13d8debd0513b781272a3f423fe70a36f2d Mon Sep 17 00:00:00 2001 From: Serene-Arc Date: Thu, 15 Dec 2022 20:30:24 +1000 Subject: [PATCH 06/70] Add typing for module --- beets/autotag/hooks.py | 186 ++++++++++++++++++++++++++++------------- 1 file changed, 129 insertions(+), 57 deletions(-) diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index 30904ff29..0dcaa43ed 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -17,10 +17,13 @@ from collections import namedtuple from functools import total_ordering import re +from typing import Dict, List, Tuple, Iterator, Union, NewType, Any, Optional, \ + Iterable, Callable from beets import logging from beets import plugins from beets import config +from beets.library import Item from beets.util import as_string from beets.autotag import mb from jellyfish import levenshtein_distance @@ -31,8 +34,10 @@ log = logging.getLogger('beets') # The name of the type for patterns in re changed in Python 3.7. try: Pattern = re._pattern_type + Patterntype = NewType('Patterntype', re._pattern_type) except AttributeError: Pattern = re.Pattern + Patterntype = NewType('Patterntype', re.Pattern) # Classes used to represent candidate options. @@ -68,17 +73,45 @@ class AlbumInfo(AttrDict): The others are optional and may be None. """ - def __init__(self, tracks, album=None, album_id=None, artist=None, - artist_id=None, asin=None, albumtype=None, va=False, - year=None, month=None, day=None, label=None, mediums=None, - artist_sort=None, releasegroup_id=None, catalognum=None, - script=None, language=None, country=None, style=None, - genre=None, albumstatus=None, media=None, albumdisambig=None, - releasegroupdisambig=None, artist_credit=None, - original_year=None, original_month=None, - original_day=None, data_source=None, data_url=None, - discogs_albumid=None, discogs_labelid=None, - discogs_artistid=None, **kwargs): + # TYPING: are all of these correct? I've assumed optional strings + def __init__( + self, + tracks: List['TrackInfo'], + album: Optional[str] = None, + album_id: Optional[str] = None, + artist: Optional[str] = None, + artist_id: Optional[str] = None, + asin: Optional[str] = None, + albumtype: Optional[str] = None, + va: bool = False, + year: Optional[str] = None, + month: Optional[str] = None, + day: Optional[str] = None, + label: Optional[str] = None, + mediums: Optional[str] = None, + artist_sort: Optional[str] = None, + releasegroup_id: Optional[str] = None, + catalognum: Optional[str] = None, + script: Optional[str] = None, + language: Optional[str] = None, + country: Optional[str] = None, + style: Optional[str] = None, + genre: Optional[str] = None, + albumstatus: Optional[str] = None, + media: Optional[str] = None, + albumdisambig: Optional[str] = None, + releasegroupdisambig: Optional[str] = None, + artist_credit: Optional[str] = None, + original_year: Optional[str] = None, + original_month: Optional[str] = None, + original_day: Optional[str] = None, + data_source: Optional[str] = None, + data_url: Optional[str] = None, + discogs_albumid: Optional[str] = None, + discogs_labelid: Optional[str] = None, + discogs_artistid: Optional[str] = None, + **kwargs, + ): self.album = album self.album_id = album_id self.artist = artist @@ -118,7 +151,7 @@ class AlbumInfo(AttrDict): # Work around a bug in python-musicbrainz-ngs that causes some # strings to be bytes rather than Unicode. # https://github.com/alastair/python-musicbrainz-ngs/issues/85 - def decode(self, codec='utf-8'): + def decode(self, codec: str = 'utf-8'): """Ensure that all string attributes on this object, and the constituent `TrackInfo` objects, are decoded to Unicode. """ @@ -135,7 +168,7 @@ class AlbumInfo(AttrDict): for track in self.tracks: track.decode(codec) - def copy(self): + def copy(self) -> 'AlbumInfo': dupe = AlbumInfo([]) dupe.update(self) dupe.tracks = [track.copy() for track in self.tracks] @@ -154,15 +187,38 @@ class TrackInfo(AttrDict): are all 1-based. """ - def __init__(self, title=None, track_id=None, release_track_id=None, - artist=None, artist_id=None, length=None, index=None, - medium=None, medium_index=None, medium_total=None, - artist_sort=None, disctitle=None, artist_credit=None, - data_source=None, data_url=None, media=None, lyricist=None, - composer=None, composer_sort=None, arranger=None, - track_alt=None, work=None, mb_workid=None, - work_disambig=None, bpm=None, initial_key=None, genre=None, - **kwargs): + # TYPING: are all of these correct? I've assumed optional strings + def __init__( + self, + title: Optional[str] = None, + track_id: Optional[str] = None, + release_track_id: Optional[str] = None, + artist: Optional[str] = None, + artist_id: Optional[str] = None, + length: Optional[str] = None, + index: Optional[str] = None, + medium: Optional[str] = None, + medium_index: Optional[str] = None, + medium_total: Optional[str] = None, + artist_sort: Optional[str] = None, + disctitle: Optional[str] = None, + artist_credit: Optional[str] = None, + data_source: Optional[str] = None, + data_url: Optional[str] = None, + media: Optional[str] = None, + lyricist: Optional[str] = None, + composer: Optional[str] = None, + composer_sort: Optional[str] = None, + arranger: Optional[str] = None, + track_alt: Optional[str] = None, + work: Optional[str] = None, + mb_workid: Optional[str] = None, + work_disambig: Optional[str] = None, + bpm: Optional[str] = None, + initial_key: Optional[str] = None, + genre: Optional[str] = None, + **kwargs, + ): self.title = title self.track_id = track_id self.release_track_id = release_track_id @@ -203,7 +259,7 @@ class TrackInfo(AttrDict): if isinstance(value, bytes): setattr(self, fld, value.decode(codec, 'ignore')) - def copy(self): + def copy(self) -> 'TrackInfo': dupe = TrackInfo() dupe.update(self) return dupe @@ -229,7 +285,7 @@ SD_REPLACE = [ ] -def _string_dist_basic(str1, str2): +def _string_dist_basic(str1: str, str2: str) -> float: """Basic edit distance between two strings, ignoring non-alphanumeric characters and case. Comparisons are based on a transliteration/lowering to ASCII characters. Normalized by string @@ -246,7 +302,7 @@ def _string_dist_basic(str1, str2): return levenshtein_distance(str1, str2) / float(max(len(str1), len(str2))) -def string_dist(str1, str2): +def string_dist(str1: str, str2: str) -> float: """Gives an "intuitive" edit distance between two strings. This is an edit distance, normalized by the string length, with a number of tweaks that reflect intuition about text. @@ -332,7 +388,7 @@ class Distance: self._penalties = {} @LazyClassProperty - def _weights(cls): # noqa: N805 + def _weights(cls) -> Dict: # noqa: N805 """A dictionary from keys to floating-point weights. """ weights_view = config['match']['distance_weights'] @@ -344,7 +400,7 @@ class Distance: # Access the components and their aggregates. @property - def distance(self): + def distance(self) -> float: """Return a weighted and normalized distance across all penalties. """ @@ -354,7 +410,7 @@ class Distance: return 0.0 @property - def max_distance(self): + def max_distance(self) -> float: """Return the maximum distance penalty (normalization factor). """ dist_max = 0.0 @@ -363,7 +419,7 @@ class Distance: return dist_max @property - def raw_distance(self): + def raw_distance(self) -> float: """Return the raw (denormalized) distance. """ dist_raw = 0.0 @@ -371,7 +427,7 @@ class Distance: dist_raw += sum(penalty) * self._weights[key] return dist_raw - def items(self): + def items(self) -> List[Tuple[str, float]]: """Return a list of (key, dist) pairs, with `dist` being the weighted distance, sorted from highest to lowest. Does not include penalties with a zero value. @@ -389,32 +445,32 @@ class Distance: key=lambda key_and_dist: (-key_and_dist[1], key_and_dist[0]) ) - def __hash__(self): + def __hash__(self) -> int: return id(self) - def __eq__(self, other): + def __eq__(self, other) -> bool: return self.distance == other # Behave like a float. - def __lt__(self, other): + def __lt__(self, other) -> bool: return self.distance < other - def __float__(self): + def __float__(self) -> float: return self.distance - def __sub__(self, other): + def __sub__(self, other) -> float: return self.distance - other - def __rsub__(self, other): + def __rsub__(self, other) -> float: return other - self.distance - def __str__(self): + def __str__(self) -> str: return f"{self.distance:.2f}" # Behave like a dict. - def __getitem__(self, key): + def __getitem__(self, key) -> float: """Returns the weighted distance for a named penalty. """ dist = sum(self._penalties[key]) * self._weights[key] @@ -423,16 +479,16 @@ class Distance: return dist / dist_max return 0.0 - def __iter__(self): + def __iter__(self) -> Iterator[Tuple[str, float]]: return iter(self.items()) - def __len__(self): + def __len__(self) -> int: return len(self.items()) - def keys(self): + def keys(self) -> List[str]: return [key for key, _ in self.items()] - def update(self, dist): + def update(self, dist: 'Distance'): """Adds all the distance penalties from `dist`. """ if not isinstance(dist, Distance): @@ -444,7 +500,7 @@ class Distance: # Adding components. - def _eq(self, value1, value2): + def _eq(self, value1: Union['Distance', Patterntype], value2) -> bool: """Returns True if `value1` is equal to `value2`. `value1` may be a compiled regular expression, in which case it will be matched against `value2`. @@ -453,7 +509,7 @@ class Distance: return bool(value1.match(value2)) return value1 == value2 - def add(self, key, dist): + def add(self, key: str, dist: float): """Adds a distance penalty. `key` must correspond with a configured weight setting. `dist` must be a float between 0.0 and 1.0, and will be added to any existing distance penalties @@ -465,7 +521,12 @@ class Distance: ) self._penalties.setdefault(key, []).append(dist) - def add_equality(self, key, value, options): + def add_equality( + self, + key: str, + value: Any, + options: Union[List, Tuple, Patterntype], + ): """Adds a distance penalty of 1.0 if `value` doesn't match any of the values in `options`. If an option is a compiled regular expression, it will be considered equal if it matches against @@ -481,7 +542,7 @@ class Distance: dist = 1.0 self.add(key, dist) - def add_expr(self, key, expr): + def add_expr(self, key: str, expr: bool): """Adds a distance penalty of 1.0 if `expr` evaluates to True, or 0.0. """ @@ -490,7 +551,7 @@ class Distance: else: self.add(key, 0.0) - def add_number(self, key, number1, number2): + def add_number(self, key: str, number1: float, number2: float): """Adds a distance penalty of 1.0 for each number of difference between `number1` and `number2`, or 0.0 when there is no difference. Use this when there is no upper limit on the @@ -503,7 +564,12 @@ class Distance: else: self.add(key, 0.0) - def add_priority(self, key, value, options): + def add_priority( + self, + key: str, + value: Any, + options: Union[List, Tuple, Patterntype], + ): """Adds a distance penalty that corresponds to the position at which `value` appears in `options`. A distance penalty of 0.0 for the first option, or 1.0 if there is no matching option. If @@ -521,7 +587,7 @@ class Distance: dist = 1.0 self.add(key, dist) - def add_ratio(self, key, number1, number2): + def add_ratio(self, key: str, number1: float, number2: float): """Adds a distance penalty for `number1` as a ratio of `number2`. `number1` is bound at 0 and `number2`. """ @@ -532,7 +598,7 @@ class Distance: dist = 0.0 self.add(key, dist) - def add_string(self, key, str1, str2): + def add_string(self, key: str, str1: str, str2: str): """Adds a distance penalty based on the edit distance between `str1` and `str2`. """ @@ -550,7 +616,7 @@ TrackMatch = namedtuple('TrackMatch', ['distance', 'info']) # Aggregation of sources. -def album_for_mbid(release_id): +def album_for_mbid(release_id: str) -> Optional[AlbumInfo]: """Get an AlbumInfo object for a MusicBrainz release ID. Return None if the ID is not found. """ @@ -563,7 +629,7 @@ def album_for_mbid(release_id): exc.log(log) -def track_for_mbid(recording_id): +def track_for_mbid(recording_id: str) -> Optional[TrackInfo]: """Get a TrackInfo object for a MusicBrainz recording ID. Return None if the ID is not found. """ @@ -576,7 +642,7 @@ def track_for_mbid(recording_id): exc.log(log) -def albums_for_id(album_id): +def albums_for_id(album_id: str) -> Iterable[Union[None, AlbumInfo]]: """Get a list of albums for an ID.""" a = album_for_mbid(album_id) if a: @@ -587,7 +653,7 @@ def albums_for_id(album_id): yield a -def tracks_for_id(track_id): +def tracks_for_id(track_id: str) -> Iterable[Union[None, TrackInfo]]: """Get a list of tracks for an ID.""" t = track_for_mbid(track_id) if t: @@ -598,7 +664,7 @@ def tracks_for_id(track_id): yield t -def invoke_mb(call_func, *args): +def invoke_mb(call_func: Callable, *args): try: return call_func(*args) except mb.MusicBrainzAPIError as exc: @@ -607,7 +673,13 @@ def invoke_mb(call_func, *args): @plugins.notify_info_yielded('albuminfo_received') -def album_candidates(items, artist, album, va_likely, extra_tags): +def album_candidates( + items: List[Item], + artist: str, + album: str, + va_likely: bool, + extra_tags: Dict, +) -> Iterable[Tuple]: """Search for album matches. ``items`` is a list of Item objects that make up the album. ``artist`` and ``album`` are the respective names (strings), which may be derived from the item list or may be @@ -633,7 +705,7 @@ def album_candidates(items, artist, album, va_likely, extra_tags): @plugins.notify_info_yielded('trackinfo_received') -def item_candidates(item, artist, title): +def item_candidates(item: Item, artist: str, title: str) -> Iterable[Tuple]: """Search for item matches. ``item`` is the Item to be matched. ``artist`` and ``title`` are strings and either reflect the item or are specified by the user. From 1950a98da8ece92b26239fe92592967b8adfb739 Mon Sep 17 00:00:00 2001 From: Serene-Arc Date: Thu, 15 Dec 2022 20:43:15 +1000 Subject: [PATCH 07/70] Add typing for module --- beets/autotag/hooks.py | 10 ++++----- beets/autotag/mb.py | 47 +++++++++++++++++++++++++++++------------- 2 files changed, 38 insertions(+), 19 deletions(-) diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index 0dcaa43ed..c09e4d321 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -88,7 +88,7 @@ class AlbumInfo(AttrDict): month: Optional[str] = None, day: Optional[str] = None, label: Optional[str] = None, - mediums: Optional[str] = None, + mediums: Optional[int] = None, artist_sort: Optional[str] = None, releasegroup_id: Optional[str] = None, catalognum: Optional[str] = None, @@ -196,10 +196,10 @@ class TrackInfo(AttrDict): artist: Optional[str] = None, artist_id: Optional[str] = None, length: Optional[str] = None, - index: Optional[str] = None, - medium: Optional[str] = None, - medium_index: Optional[str] = None, - medium_total: Optional[str] = None, + index: Optional[int] = None, + medium: Optional[int] = None, + medium_index: Optional[int] = None, + medium_total: Optional[int] = None, artist_sort: Optional[str] = None, disctitle: Optional[str] = None, artist_credit: Optional[str] = None, diff --git a/beets/autotag/mb.py b/beets/autotag/mb.py index 5b8d45138..edee7972c 100644 --- a/beets/autotag/mb.py +++ b/beets/autotag/mb.py @@ -14,6 +14,8 @@ """Searches for albums in the MusicBrainz database. """ +from typing import List, Tuple, Dict, Optional, Iterator, Iterable, AnyStr, \ + Union import musicbrainzngs import re @@ -82,11 +84,11 @@ if 'genres' in musicbrainzngs.VALID_INCLUDES['recording']: RELEASE_INCLUDES += ['genres'] -def track_url(trackid): +def track_url(trackid: str) -> str: return urljoin(BASE_URL, 'recording/' + trackid) -def album_url(albumid): +def album_url(albumid: str) -> str: return urljoin(BASE_URL, 'release/' + albumid) @@ -106,7 +108,7 @@ def configure(): ) -def _preferred_alias(aliases): +def _preferred_alias(aliases: List): """Given an list of alias structures for an artist credit, select and return the user's preferred alias alias or None if no matching alias is found. @@ -138,7 +140,7 @@ def _preferred_alias(aliases): return matches[0] -def _preferred_release_event(release): +def _preferred_release_event(release: Dict) -> Tuple[str, str]: """Given a release, select and return the user's preferred release event as a tuple of (country, release_date). Fall back to the default release event if a preferred event is not found. @@ -156,7 +158,7 @@ def _preferred_release_event(release): return release.get('country'), release.get('date') -def _flatten_artist_credit(credit): +def _flatten_artist_credit(credit: List[Dict]) -> Tuple[str, str, str]: """Given a list representing an ``artist-credit`` block, flatten the data into a triple of joined artist name strings: canonical, sort, and credit. @@ -215,8 +217,13 @@ def _get_related_artist_names(relations, relation_type): return ', '.join(related_artists) -def track_info(recording, index=None, medium=None, medium_index=None, - medium_total=None): +def track_info( + recording: Dict, + index: Optional[int] = None, + medium: Optional[int] = None, + medium_index: Optional[int] = None, + medium_total: Optional[int] = None, +) -> beets.autotag.hooks.TrackInfo: """Translates a MusicBrainz recording result dictionary into a beets ``TrackInfo`` object. Three parameters are optional and are used only for tracks that appear on releases (non-singletons): ``index``, @@ -303,7 +310,11 @@ def track_info(recording, index=None, medium=None, medium_index=None, return info -def _set_date_str(info, date_str, original=False): +def _set_date_str( + info: beets.autotag.hooks.AlbumInfo, + date_str: str, + original: bool = False, +): """Given a (possibly partial) YYYY-MM-DD string and an AlbumInfo object, set the object's release date fields appropriately. If `original`, then set the original_year, etc., fields. @@ -323,7 +334,7 @@ def _set_date_str(info, date_str, original=False): setattr(info, key, date_num) -def album_info(release): +def album_info(release: Dict) -> beets.autotag.hooks.AlbumInfo: """Takes a MusicBrainz release result dictionary and returns a beets AlbumInfo object containing the interesting data about that release. """ @@ -502,7 +513,12 @@ def album_info(release): return info -def match_album(artist, album, tracks=None, extra_tags=None): +def match_album( + artist: str, + album: str, + tracks: Optional[int] = None, + extra_tags: Dict = None, +) -> Iterator[beets.autotag.hooks.AlbumInfo]: """Searches for a single album ("release" in MusicBrainz parlance) and returns an iterator over AlbumInfo objects. May raise a MusicBrainzAPIError. @@ -549,7 +565,10 @@ def match_album(artist, album, tracks=None, extra_tags=None): yield albuminfo -def match_track(artist, title): +def match_track( + artist: str, + title: str, +) -> Iterator[beets.autotag.hooks.TrackInfo]: """Searches for a single track and returns an iterable of TrackInfo objects. May raise a MusicBrainzAPIError. """ @@ -571,7 +590,7 @@ def match_track(artist, title): yield track_info(recording) -def _parse_id(s): +def _parse_id(s: str) -> Optional[Union[str, bytes]]: """Search for a MusicBrainz ID in the given string and return it. If no ID can be found, return None. """ @@ -581,7 +600,7 @@ def _parse_id(s): return match.group() -def album_for_id(releaseid): +def album_for_id(releaseid: str) -> Optional[beets.autotag.hooks.AlbumInfo]: """Fetches an album by its MusicBrainz ID and returns an AlbumInfo object or None if the album is not found. May raise a MusicBrainzAPIError. @@ -603,7 +622,7 @@ def album_for_id(releaseid): return album_info(res['release']) -def track_for_id(releaseid): +def track_for_id(releaseid: str) -> Optional[beets.autotag.hooks.TrackInfo]: """Fetches a track by its MusicBrainz ID. Returns a TrackInfo object or None if no track is found. May raise a MusicBrainzAPIError. """ From f419543f07cb73d4217cabd07eaa39b6a3414299 Mon Sep 17 00:00:00 2001 From: wisp3rwind <17089248+wisp3rwind@users.noreply.github.com> Date: Tue, 14 Jun 2022 22:13:54 +0200 Subject: [PATCH 08/70] docs: remove unused link target, update links to python docs --- CONTRIBUTING.rst | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 18ca9b9e4..daf377d94 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -347,7 +347,6 @@ others. See `unittest.mock`_ for more info. ``mock.patch``, as they require manual cleanup. Use the annotation or context manager forms instead. -.. _Python unittest: https://docs.python.org/2/library/unittest.html .. _Codecov: https://codecov.io/github/beetbox/beets .. _pytest-random: https://github.com/klrmn/pytest-random .. _tox: https://tox.readthedocs.io/en/latest/ @@ -358,10 +357,9 @@ others. See `unittest.mock`_ for more info. .. _`https://github.com/beetbox/beets/blob/master/setup.py#L99`: https://github.com/beetbox/beets/blob/master/setup.py#L99 .. _test: https://github.com/beetbox/beets/tree/master/test .. _`https://github.com/beetbox/beets/blob/master/test/test_template.py#L224`: https://github.com/beetbox/beets/blob/master/test/test_template.py#L224 -.. _unittest: https://docs.python.org/3.8/library/unittest.html +.. _unittest: https://docs.python.org/3/library/unittest.html .. _integration test: https://github.com/beetbox/beets/actions?query=workflow%3A%22integration+tests%22 .. _unittest.mock: https://docs.python.org/3/library/unittest.mock.html -.. _Python unittest: https://docs.python.org/2/library/unittest.html .. _documentation: https://beets.readthedocs.io/en/stable/ .. _pip: https://pip.pypa.io/en/stable/ .. _vim: https://www.vim.org/ From bd09cc90b601c3bab01215c85165dbb219bb0467 Mon Sep 17 00:00:00 2001 From: wisp3rwind <17089248+wisp3rwind@users.noreply.github.com> Date: Tue, 14 Jun 2022 22:15:53 +0200 Subject: [PATCH 09/70] drop Python 3.6: docs, a few safe simplifications --- beets/autotag/hooks.py | 8 +------- beets/util/__init__.py | 8 ++------ beets/util/functemplate.py | 13 +------------ beetsplug/smartplaylist.py | 7 +------ docs/changelog.rst | 4 ++++ docs/guides/main.rst | 6 +++--- setup.py | 2 +- 7 files changed, 13 insertions(+), 35 deletions(-) diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index 30904ff29..8bd87d84a 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -28,12 +28,6 @@ from unidecode import unidecode log = logging.getLogger('beets') -# The name of the type for patterns in re changed in Python 3.7. -try: - Pattern = re._pattern_type -except AttributeError: - Pattern = re.Pattern - # Classes used to represent candidate options. class AttrDict(dict): @@ -449,7 +443,7 @@ class Distance: be a compiled regular expression, in which case it will be matched against `value2`. """ - if isinstance(value1, Pattern): + if isinstance(value1, re.Pattern): return bool(value1.match(value2)) return value1 == value2 diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 06e02ee08..fa3b17537 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -868,10 +868,7 @@ def command_output(cmd, shell=False): """ cmd = convert_command_args(cmd) - try: # python >= 3.3 - devnull = subprocess.DEVNULL - except AttributeError: - devnull = open(os.devnull, 'r+b') + devnull = subprocess.DEVNULL proc = subprocess.Popen( cmd, @@ -1054,8 +1051,7 @@ def asciify_path(path, sep_replace): def par_map(transform, items): """Apply the function `transform` to all the elements in the iterable `items`, like `map(transform, items)` but with no return - value. The map *might* happen in parallel: it's parallel on Python 3 - and sequential on Python 2. + value. The parallelism uses threads (not processes), so this is only useful for IO-bound `transform`s. diff --git a/beets/util/functemplate.py b/beets/util/functemplate.py index 289a436de..809207b9a 100644 --- a/beets/util/functemplate.py +++ b/beets/util/functemplate.py @@ -530,18 +530,7 @@ def _parse(template): return Expression(parts) -def cached(func): - """Like the `functools.lru_cache` decorator, but works (as a no-op) - on Python < 3.2. - """ - if hasattr(functools, 'lru_cache'): - return functools.lru_cache(maxsize=128)(func) - else: - # Do nothing when lru_cache is not available. - return func - - -@cached +@functools.lru_cache(maxsize=128) def template(fmt): return Template(fmt) diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index 4c921eccc..22fce6a63 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -24,12 +24,7 @@ from beets.library import Item, Album, parse_query_string from beets.dbcore import OrQuery from beets.dbcore.query import MultipleSort, ParsingError import os - -try: - from urllib.request import pathname2url -except ImportError: - # python2 is a bit different - from urllib import pathname2url +from urllib.request import pathname2url class SmartPlaylistPlugin(BeetsPlugin): diff --git a/docs/changelog.rst b/docs/changelog.rst index c7f3eb614..f3adcddb4 100755 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -6,6 +6,9 @@ Changelog Changelog goes here! +With this release, beets now requires Python 3.7 or later (it removes support +for Python 3.6). + New features: * We now import the remixer field from Musicbrainz into the library. @@ -125,6 +128,7 @@ Bug fixes: For packagers: +* As noted above, the minimum Python version is now 3.7. * We fixed a version for the dependency on the `Confuse`_ library. :bug:`4167` * The minimum required version of :pypi:`mediafile` is now 0.9.0. diff --git a/docs/guides/main.rst b/docs/guides/main.rst index 2b573ac32..6169ded8c 100644 --- a/docs/guides/main.rst +++ b/docs/guides/main.rst @@ -10,7 +10,7 @@ Installing ---------- You will need Python. -Beets works on Python 3.6 or later. +Beets works on Python 3.7 or later. * **macOS** 11 (Big Sur) includes Python 3.8 out of the box. You can opt for a more recent Python installing it via `Homebrew`_ @@ -94,7 +94,7 @@ Installing on Windows Installing beets on Windows can be tricky. Following these steps might help you get it right: -1. If you don't have it, `install Python`_ (you want at least Python 3.6). The +1. If you don't have it, `install Python`_ (you want at least Python 3.7). The installer should give you the option to "add Python to PATH." Check this box. If you do that, you can skip the next step. @@ -105,7 +105,7 @@ get it right: should open the "System Properties" screen, then select the "Advanced" tab, then hit the "Environmental Variables..." button, and then look for the PATH variable in the table. Add the following to the end of the variable's value: - ``;C:\Python36;C:\Python36\Scripts``. You may need to adjust these paths to + ``;C:\Python37;C:\Python37\Scripts``. You may need to adjust these paths to point to your Python installation. 3. Now install beets by running: ``pip install beets`` diff --git a/setup.py b/setup.py index d49ed65b2..0e8162e4d 100755 --- a/setup.py +++ b/setup.py @@ -166,10 +166,10 @@ setup( 'Environment :: Web Environment', 'Programming Language :: Python', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: Implementation :: CPython', ], ) From 632698680f5cc822f616f99e92d058aa6633d2de Mon Sep 17 00:00:00 2001 From: wisp3rwind <17089248+wisp3rwind@users.noreply.github.com> Date: Tue, 14 Jun 2022 22:16:43 +0200 Subject: [PATCH 10/70] drop old Python: simplify datetime usage --- beets/dbcore/query.py | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py index b0c769790..a0d79da70 100644 --- a/beets/dbcore/query.py +++ b/beets/dbcore/query.py @@ -526,19 +526,6 @@ class FalseQuery(Query): # Time/date queries. -def _to_epoch_time(date): - """Convert a `datetime` object to an integer number of seconds since - the (local) Unix epoch. - """ - if hasattr(date, 'timestamp'): - # The `timestamp` method exists on Python 3.3+. - return int(date.timestamp()) - else: - epoch = datetime.fromtimestamp(0) - delta = date - epoch - return int(delta.total_seconds()) - - def _parse_periods(pattern): """Parse a string containing two dates separated by two dots (..). Return a pair of `Period` objects. @@ -724,13 +711,15 @@ class DateQuery(FieldQuery): clause_parts = [] subvals = [] + # Convert the `datetime` objects to an integer number of seconds since + # the (local) Unix epoch using `datetime.timestamp()`. if self.interval.start: clause_parts.append(self._clause_tmpl.format(self.field, ">=")) - subvals.append(_to_epoch_time(self.interval.start)) + subvals.append(int(self.interval.start.timestamp())) if self.interval.end: clause_parts.append(self._clause_tmpl.format(self.field, "<")) - subvals.append(_to_epoch_time(self.interval.end)) + subvals.append(int(self.interval.end.timestamp())) if clause_parts: # One- or two-sided interval. From 5cdb0c5c5cc9eb23be1d1a7a540b808b0a11ad16 Mon Sep 17 00:00:00 2001 From: wisp3rwind <17089248+wisp3rwind@users.noreply.github.com> Date: Tue, 14 Jun 2022 22:17:41 +0200 Subject: [PATCH 11/70] drop old Python: rm python 2 leftovers in library.FileOperationError.__str__ --- beets/library.py | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/beets/library.py b/beets/library.py index 981563974..030cf630e 100644 --- a/beets/library.py +++ b/beets/library.py @@ -300,34 +300,26 @@ class FileOperationError(Exception): self.path = path self.reason = reason - def text(self): + def __str__(self): """Get a string representing the error. - Describe both the underlying reason and the file path - in question. + Describe both the underlying reason and the file path in question. """ - return '{}: {}'.format( - util.displayable_path(self.path), - str(self.reason) - ) - - # define __str__ as text to avoid infinite loop on super() calls - # with @six.python_2_unicode_compatible - __str__ = text + return f"{util.displayable_path(self.path)}: {self.reason}" class ReadError(FileOperationError): """An error while reading a file (i.e. in `Item.read`).""" def __str__(self): - return 'error reading ' + super().text() + return 'error reading ' + str(super()) class WriteError(FileOperationError): """An error while writing a file (i.e. in `Item.write`).""" def __str__(self): - return 'error writing ' + super().text() + return 'error writing ' + str(super()) # Item and Album model classes. From 67f0c73eecbe5b2645e6c7fb8e6ad52e140d2db0 Mon Sep 17 00:00:00 2001 From: wisp3rwind <17089248+wisp3rwind@users.noreply.github.com> Date: Tue, 14 Jun 2022 22:20:38 +0200 Subject: [PATCH 12/70] drop old Python: don't handle obsolte exception in keyfinder plugin --- beetsplug/keyfinder.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/beetsplug/keyfinder.py b/beetsplug/keyfinder.py index b695ab54e..051af9e1f 100644 --- a/beetsplug/keyfinder.py +++ b/beetsplug/keyfinder.py @@ -67,12 +67,6 @@ class KeyFinderPlugin(BeetsPlugin): except (subprocess.CalledProcessError, OSError) as exc: self._log.error('execution failed: {0}', exc) continue - except UnicodeEncodeError: - # Workaround for Python 2 Windows bug. - # https://bugs.python.org/issue1759845 - self._log.error('execution failed for Unicode path: {0!r}', - item.path) - continue try: key_raw = output.rsplit(None, 1)[-1] From 22826c6d36ef73f9d688a154fe8c3f3993c47ffb Mon Sep 17 00:00:00 2001 From: wisp3rwind <17089248+wisp3rwind@users.noreply.github.com> Date: Tue, 14 Jun 2022 22:21:14 +0200 Subject: [PATCH 13/70] drop old Python: remove obsolete custom string formatter in hooks plugin --- beetsplug/hook.py | 38 +++++++++----------------------------- 1 file changed, 9 insertions(+), 29 deletions(-) diff --git a/beetsplug/hook.py b/beetsplug/hook.py index 0fe3bffc6..8c7211450 100644 --- a/beetsplug/hook.py +++ b/beetsplug/hook.py @@ -26,39 +26,20 @@ class CodingFormatter(string.Formatter): """A variant of `string.Formatter` that converts everything to `unicode` strings. - This is necessary on Python 2, where formatting otherwise occurs on - bytestrings. It intercepts two points in the formatting process to decode - the format string and all fields using the specified encoding. If decoding - fails, the values are used as-is. + This was necessary on Python 2, in needs to be kept for backwards + compatibility. """ def __init__(self, coding): """Creates a new coding formatter with the provided coding.""" self._coding = coding - def format(self, format_string, *args, **kwargs): - """Formats the provided string using the provided arguments and keyword - arguments. - - This method decodes the format string using the formatter's coding. - - See str.format and string.Formatter.format. - """ - if isinstance(format_string, bytes): - format_string = format_string.decode(self._coding) - - return super().format(format_string, *args, - **kwargs) - def convert_field(self, value, conversion): """Converts the provided value given a conversion type. This method decodes the converted value using the formatter's coding. - - See string.Formatter.convert_field. """ - converted = super().convert_field(value, - conversion) + converted = super().convert_field(value, conversion) if isinstance(converted, bytes): return converted.decode(self._coding) @@ -92,14 +73,13 @@ class HookPlugin(BeetsPlugin): self._log.error('invalid command "{0}"', command) return - # Use a string formatter that works on Unicode strings. + # For backwards compatibility, use a string formatter that decodes + # bytes (in particular, paths) to unicode strings. formatter = CodingFormatter(arg_encoding()) - - command_pieces = shlex.split(command) - - for i, piece in enumerate(command_pieces): - command_pieces[i] = formatter.format(piece, event=event, - **kwargs) + command_pieces = [ + formatter.format(piece, event=event, **kwargs) + for piece in shlex.split(command) + ] self._log.debug('running command "{0}" for event {1}', ' '.join(command_pieces), event) From c816b2953d176a728d1ac91b1ca7cd86cb9291ce Mon Sep 17 00:00:00 2001 From: wisp3rwind <17089248+wisp3rwind@users.noreply.github.com> Date: Tue, 14 Jun 2022 22:21:58 +0200 Subject: [PATCH 14/70] drop old Python: remove obsolete exception handling for Windows symlinks when symlinks aren't supported, recent python will only raise NotImplementedError (or might even support symlinks), ending up in the other except arm --- beets/util/__init__.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/beets/util/__init__.py b/beets/util/__init__.py index fa3b17537..1f2b57607 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -532,10 +532,6 @@ def link(path, dest, replace=False): raise FilesystemError('OS does not support symbolic links.' 'link', (path, dest), traceback.format_exc()) except OSError as exc: - # TODO: Windows version checks can be removed for python 3 - if hasattr('sys', 'getwindowsversion'): - if sys.getwindowsversion()[0] < 6: # is before Vista - exc = 'OS does not support symbolic links.' raise FilesystemError(exc, 'link', (path, dest), traceback.format_exc()) From 9f5867343342d1178cdad0440882a7348e2a6d7f Mon Sep 17 00:00:00 2001 From: wisp3rwind <17089248+wisp3rwind@users.noreply.github.com> Date: Tue, 14 Jun 2022 22:24:49 +0200 Subject: [PATCH 15/70] drop old Python: update a bunch of comments, removing Python 2 details These didn't match the code anymore (which had already been changes manually or automatically as part of the 2->3 transition). --- beets/logging.py | 3 +++ beets/util/__init__.py | 3 +-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/beets/logging.py b/beets/logging.py index 516528c05..9e88fd7c1 100644 --- a/beets/logging.py +++ b/beets/logging.py @@ -21,6 +21,9 @@ that when getLogger(name) instantiates a logger that logger uses """ +# FIXME: Remove Python 2 leftovers. + + from copy import copy import subprocess import threading diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 1f2b57607..07ef021ea 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -737,8 +737,7 @@ def legalize_path(path, replacements, length, extension, fragment): def py3_path(path): - """Convert a bytestring path to Unicode on Python 3 only. On Python - 2, return the bytestring path unchanged. + """Convert a bytestring path to Unicode. This helps deal with APIs on Python 3 that *only* accept Unicode (i.e., `str` objects). I philosophically disagree with this From 7c2aa02de2dfc26765e27825c0ee48634d070ec6 Mon Sep 17 00:00:00 2001 From: wisp3rwind <17089248+wisp3rwind@users.noreply.github.com> Date: Wed, 15 Jun 2022 11:09:21 +0200 Subject: [PATCH 16/70] remove old Python: in logging, rm workarounds for broken string conversions --- beets/logging.py | 36 ++++++++---------------------------- 1 file changed, 8 insertions(+), 28 deletions(-) diff --git a/beets/logging.py b/beets/logging.py index 9e88fd7c1..63ecf1c3f 100644 --- a/beets/logging.py +++ b/beets/logging.py @@ -25,48 +25,28 @@ that when getLogger(name) instantiates a logger that logger uses from copy import copy -import subprocess import threading import logging def logsafe(val): - """Coerce a potentially "problematic" value so it can be formatted - in a Unicode log string. + """Coerce `bytes` to `str` to avoid crashes solely due to logging. - This works around a number of pitfalls when logging objects in - Python 2: - - Logging path names, which must be byte strings, requires - conversion for output. - - Some objects, including some exceptions, will crash when you call - `unicode(v)` while `str(v)` works fine. CalledProcessError is an - example. + This is particularly relevant for bytestring paths. Much of our code + explicitly uses `displayable_path` for them, but better be safe and prevent + any crashes that are solely due to log formatting. """ - # Already Unicode. - if isinstance(val, str): - return val - - # Bytestring: needs decoding. - elif isinstance(val, bytes): + # Bytestring: Needs decoding to be safe for substitution in format strings. + if isinstance(val, bytes): # Blindly convert with UTF-8. Eventually, it would be nice to # (a) only do this for paths, if they can be given a distinct # type, and (b) warn the developer if they do this for other # bytestrings. return val.decode('utf-8', 'replace') - # A "problem" object: needs a workaround. - elif isinstance(val, subprocess.CalledProcessError): - try: - return str(val) - except UnicodeDecodeError: - # An object with a broken __unicode__ formatter. Use __str__ - # instead. - return str(val).decode('utf-8', 'replace') - # Other objects are used as-is so field access, etc., still works in - # the format string. - else: - return val + # the format string. Relies on a working __str__ implementation. + return val class StrFormatLogger(logging.Logger): From 35e167f75ee6acae9d9f0ea217b26855d7e4413f Mon Sep 17 00:00:00 2001 From: wisp3rwind <17089248+wisp3rwind@users.noreply.github.com> Date: Wed, 15 Jun 2022 11:33:38 +0200 Subject: [PATCH 17/70] remove old Python: in logging, update comments and support new kwargs We still need these custom Logger modifications, but the precise reasoning as given in the comments didn't quite apply anymore. Also, `stack_info` and `stacklevel` arguments have been added in recent Python, which we now support. --- beets/logging.py | 45 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/beets/logging.py b/beets/logging.py index 63ecf1c3f..05c22bd1c 100644 --- a/beets/logging.py +++ b/beets/logging.py @@ -12,19 +12,17 @@ # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. -"""A drop-in replacement for the standard-library `logging` module that -allows {}-style log formatting on Python 2 and 3. +"""A drop-in replacement for the standard-library `logging` module. -Provides everything the "logging" module does. The only difference is -that when getLogger(name) instantiates a logger that logger uses -{}-style formatting. +Provides everything the "logging" module does. In addition, beets' logger +(as obtained by `getLogger(name)`) supports thread-local levels, and messages +use {}-style formatting and can interpolate keywords arguments to the logging +calls (`debug`, `info`, etc). """ -# FIXME: Remove Python 2 leftovers. - - from copy import copy +import sys import threading import logging @@ -51,7 +49,14 @@ def logsafe(val): class StrFormatLogger(logging.Logger): """A version of `Logger` that uses `str.format`-style formatting - instead of %-style formatting. + instead of %-style formatting and supports keyword arguments. + + We cannot easily get rid of this even in the Python 3 era: This custom + formatting supports substitution from `kwargs` into the message, which the + default `logging.Logger._log()` implementation does not. + + Remark by @sampsyo: https://stackoverflow.com/a/24683360 might be a way to + achieve this with less code. """ class _LogMessage: @@ -65,10 +70,28 @@ class StrFormatLogger(logging.Logger): kwargs = {k: logsafe(v) for (k, v) in self.kwargs.items()} return self.msg.format(*args, **kwargs) - def _log(self, level, msg, args, exc_info=None, extra=None, **kwargs): + def _log(self, level, msg, args, exc_info=None, extra=None, + stack_info=False, **kwargs): """Log msg.format(*args, **kwargs)""" m = self._LogMessage(msg, args, kwargs) - return super()._log(level, m, (), exc_info, extra) + + stacklevel = kwargs.pop("stacklevel", 1) + if sys.version_info >= (3, 8): + stacklevel = {"stacklevel": stacklevel} + else: + # Simply ignore this when not supported by current Python version. + # Can be dropped when we remove support for Python 3.7. + stacklevel = {} + + return super()._log( + level, + m, + (), + exc_info=exc_info, + extra=extra, + stack_info=stack_info, + **stacklevel, + ) class ThreadLocalLevelLogger(logging.Logger): From 3510e6311db479471102b5f0380d27fe2be8786b Mon Sep 17 00:00:00 2001 From: wisp3rwind <17089248+wisp3rwind@users.noreply.github.com> Date: Sat, 24 Sep 2022 12:08:04 +0200 Subject: [PATCH 18/70] web: slight refactor to make path handling more readable (at least, more readable to me) --- beetsplug/web/__init__.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/beetsplug/web/__init__.py b/beetsplug/web/__init__.py index b7baa93c1..6a70a962d 100644 --- a/beetsplug/web/__init__.py +++ b/beetsplug/web/__init__.py @@ -307,19 +307,24 @@ def item_file(item_id): else: item_path = util.py3_path(item.path) - try: - unicode_item_path = util.text_string(item.path) - except (UnicodeDecodeError, UnicodeEncodeError): - unicode_item_path = util.displayable_path(item.path) + base_filename = os.path.basename(item_path) + # FIXME: Arguably, this should just use `displayable_path`: The latter + # tries `_fsencoding()` first, but then falls back to `utf-8`, too. + if isinstance(base_filename, bytes): + try: + unicode_base_filename = base_filename.decode("utf-8") + except UnicodeError: + unicode_base_filename = util.displayable_path(base_filename) + else: + unicode_base_filename = base_filename - base_filename = os.path.basename(unicode_item_path) try: # Imitate http.server behaviour - base_filename.encode("latin-1", "strict") - except UnicodeEncodeError: - safe_filename = unidecode(base_filename) + unicode_base_filename.encode("latin-1", "strict") + except UnicodeError: + safe_filename = unidecode(unicode_base_filename) else: - safe_filename = base_filename + safe_filename = unicode_base_filename response = flask.send_file( item_path, From d24cf692693045114fd822eebd1841b98dc90143 Mon Sep 17 00:00:00 2001 From: wisp3rwind <17089248+wisp3rwind@users.noreply.github.com> Date: Wed, 15 Jun 2022 12:15:24 +0200 Subject: [PATCH 19/70] remove old Python: remove util.text_string This was a helper for situations when Python 2 and 3 APIs returned bytes and unicode, respectively. In these situation, we should nowadays know which of the two we receive, so there's no need to wrap & hide the `bytes.decode()` anymore (when it is still required). Detailed justification: beets/ui/__init__.py: - command line options are always parsed to str beets/ui/commands.py: - confuse's config.dump always returns str - open(...) defaults to text mode, read()ing str beetsplug/keyfinder.py: - ... beetsplug/web/__init__.py: - internally, paths are always bytestrings - additionally, I took the liberty to slighlty re-arrange the code: it makes sense to split off the basename first, since we're only interested in the unicode conversion of that part. test/helper.py: - capture_stdout() gives a StringIO, which yields str test/test_ui.py: - self.io, from _common.TestCase, ultimately contains a _common.DummyOut, which appears to be dealing with str (cf. DummyOut.get) --- beets/ui/__init__.py | 3 --- beets/ui/commands.py | 4 ++-- beets/util/__init__.py | 13 ------------- beetsplug/keyfinder.py | 2 +- beetsplug/web/__init__.py | 4 ++-- test/helper.py | 2 +- test/test_ui.py | 3 +-- 7 files changed, 7 insertions(+), 24 deletions(-) diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index ba058148d..cd9f4989e 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -790,9 +790,6 @@ def _store_dict(option, opt_str, value, parser): setattr(parser.values, dest, {}) option_values = getattr(parser.values, dest) - # Decode the argument using the platform's argument encoding. - value = util.text_string(value, util.arg_encoding()) - try: key, value = value.split('=', 1) if not (key and value): diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 91cee4516..6c8e25b85 100755 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -1778,7 +1778,7 @@ def config_func(lib, opts, args): else: config_out = config.dump(full=opts.defaults, redact=opts.redact) if config_out.strip() != '{}': - print_(util.text_string(config_out)) + print_(config_out) else: print("Empty configuration") @@ -1852,7 +1852,7 @@ def completion_script(commands): """ base_script = os.path.join(os.path.dirname(__file__), 'completion_base.sh') with open(base_script) as base_script: - yield util.text_string(base_script.read()) + yield base_script.read() options = {} aliases = {} diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 07ef021ea..fbff07660 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -770,19 +770,6 @@ def as_string(value): return str(value) -def text_string(value, encoding='utf-8'): - """Convert a string, which can either be bytes or unicode, to - unicode. - - Text (unicode) is left untouched; bytes are decoded. This is useful - to convert from a "native string" (bytes on Python 2, str on Python - 3) to a consistently unicode value. - """ - if isinstance(value, bytes): - return value.decode(encoding) - return value - - def plurality(objs): """Given a sequence of hashble objects, returns the object that is most common in the set and the its number of appearance. The diff --git a/beetsplug/keyfinder.py b/beetsplug/keyfinder.py index 051af9e1f..412b7ddaa 100644 --- a/beetsplug/keyfinder.py +++ b/beetsplug/keyfinder.py @@ -77,7 +77,7 @@ class KeyFinderPlugin(BeetsPlugin): continue try: - key = util.text_string(key_raw) + key = key_raw.decode("utf-8") except UnicodeDecodeError: self._log.error('output is invalid UTF-8') continue diff --git a/beetsplug/web/__init__.py b/beetsplug/web/__init__.py index 6a70a962d..7b04f3714 100644 --- a/beetsplug/web/__init__.py +++ b/beetsplug/web/__init__.py @@ -320,9 +320,9 @@ def item_file(item_id): try: # Imitate http.server behaviour - unicode_base_filename.encode("latin-1", "strict") + base_filename.encode("latin-1", "strict") except UnicodeError: - safe_filename = unidecode(unicode_base_filename) + safe_filename = unidecode(base_filename) else: safe_filename = unicode_base_filename diff --git a/test/helper.py b/test/helper.py index f7d37b654..b6e425c62 100644 --- a/test/helper.py +++ b/test/helper.py @@ -453,7 +453,7 @@ class TestHelper: def run_with_output(self, *args): with capture_stdout() as out: self.run_command(*args) - return util.text_string(out.getvalue()) + return out.getvalue() # Safe file operations diff --git a/test/test_ui.py b/test/test_ui.py index ad4387013..2034fb41f 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -1163,8 +1163,7 @@ class ShowChangeTest(_common.TestCase): cur_album, autotag.AlbumMatch(album_dist, info, mapping, set(), set()), ) - # FIXME decoding shouldn't be done here - return util.text_string(self.io.getoutput().lower()) + return self.io.getoutput().lower() def test_null_change(self): msg = self._show_change() From a6d74686d8435ee3215c8c11af856b4cb7b814c1 Mon Sep 17 00:00:00 2001 From: wisp3rwind <17089248+wisp3rwind@users.noreply.github.com> Date: Sat, 24 Dec 2022 13:36:53 +0100 Subject: [PATCH 20/70] test: separate case_sensitive unit tests from PathQueryTest - move tests for case_sensitive to test_util.py, since this is not really the concern of PathQueryTest - removes part of the tests, since the tests that patch os.path.samefile and os.path.exists are super brittle since they test the implementation rather than the functionality of case_sensitive(). This is a prepartory step for actually changing the implementation, which would otherwise break the tests in a confusing way... --- test/test_query.py | 33 --------------------------------- test/test_util.py | 27 +++++++++++++++++++++++++-- 2 files changed, 25 insertions(+), 35 deletions(-) diff --git a/test/test_query.py b/test/test_query.py index 3c6d6f70a..13f40482b 100644 --- a/test/test_query.py +++ b/test/test_query.py @@ -603,40 +603,7 @@ class PathQueryTest(_common.LibTestCase, TestHelper, AssertsMixin): results = self.lib.items(makeq(case_sensitive=False)) self.assert_items_matched(results, ['path item', 'caps path']) - # Check for correct case sensitivity selection (this check - # only works on non-Windows OSes). - with _common.system_mock('Darwin'): - # exists = True and samefile = True => Case insensitive - q = makeq() - self.assertEqual(q.case_sensitive, False) - # exists = True and samefile = False => Case sensitive - self.patcher_samefile.stop() - self.patcher_samefile.start().return_value = False - try: - q = makeq() - self.assertEqual(q.case_sensitive, True) - finally: - self.patcher_samefile.stop() - self.patcher_samefile.start().return_value = True - - # Test platform-aware default sensitivity when the library path - # does not exist. For the duration of this check, we change the - # `os.path.exists` mock to return False. - self.patcher_exists.stop() - self.patcher_exists.start().return_value = False - try: - with _common.system_mock('Darwin'): - q = makeq() - self.assertEqual(q.case_sensitive, True) - - with _common.system_mock('Windows'): - q = makeq() - self.assertEqual(q.case_sensitive, False) - finally: - # Restore the `os.path.exists` mock to its original state. - self.patcher_exists.stop() - self.patcher_exists.start().return_value = True @patch('beets.library.os') def test_path_sep_detection(self, mock_os): diff --git a/test/test_util.py b/test/test_util.py index 14ac7f2b2..8c16243a5 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -14,10 +14,11 @@ """Tests for base utils from the beets.util package. """ -import sys -import re import os +import platform +import re import subprocess +import sys import unittest from unittest.mock import patch, Mock @@ -122,6 +123,28 @@ class UtilTest(unittest.TestCase): self.assertEqual(exc_context.exception.returncode, 1) self.assertEqual(exc_context.exception.cmd, 'taga \xc3\xa9') + def test_case_sensitive_default(self): + path = util.bytestring_path(util.normpath( + "/this/path/does/not/exist", + )) + + self.assertEqual( + util.case_sensitive(path), + platform.system() != 'Windows', + ) + + @unittest.skipIf(sys.platform == 'win32', 'fs is not case sensitive') + def test_case_sensitive_detects_sensitive(self): + # FIXME: Add tests for more code paths of case_sensitive() + # when the filesystem on the test runner is not case sensitive + pass + + @unittest.skipIf(sys.platform != 'win32', 'fs is case sensitive') + def test_case_sensitive_detects_insensitive(self): + # FIXME: Add tests for more code paths of case_sensitive() + # when the filesystem on the test runner is case sensitive + pass + class PathConversionTest(_common.TestCase): def test_syspath_windows_format(self): From 2db0796fadb248f6786e923d5dbdf0daf67387b2 Mon Sep 17 00:00:00 2001 From: ghbrown Date: Wed, 11 Jan 2023 13:20:54 -0600 Subject: [PATCH 21/70] Implement item_candidates for Discogs --- beetsplug/discogs.py | 49 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index a474871ac..de6cf45f2 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -180,6 +180,55 @@ class DiscogsPlugin(BeetsPlugin): self._log.debug('Connection error in album search', exc_info=True) return [] + def item_candidates(self, item, artist, title): + """Returns a list of TrackInfo objects for Search API results + matching ``title`` and ``artist``. + :param item: Singleton item to be matched. + :type item: beets.library.Item + :param artist: The artist of the track to be matched. + :type artist: str + :param title: The title of the track to be matched. + :type title: str + :return: Candidate TrackInfo objects. + :rtype: list[beets.autotag.hooks.TrackInfo] + """ + if not self.discogs_client: + return + + query = f'{artist} {title}' + try: + albums = self.get_albums(query) + except DiscogsAPIError as e: + self._log.debug('API Error: {0} (query: {1})', e, query) + if e.status_code == 401: + self.reset_auth() + return self.item_candidates(item, artist, title) + else: + return [] + except CONNECTION_ERRORS: + self._log.debug('Connection error in track search', exc_info=True) + candidates = [] + for album_cur in albums: + self._log.debug(u'searching within album {0}', album_cur.album) + track_list = self.get_tracks_from_album(album_cur) + candidates += track_list + return candidates + + def get_tracks_from_album(self, album_info): + """Return a list of tracks in the release + """ + if not album_info: + return [] + + result = [] + for track_info in album_info.tracks: + # attach artist info if not provided + if not track_info['artist']: + track_info['artist'] = album_info.artist + track_info['artist_id'] = album_info.artist_id + result.append(track_info) + return result + @staticmethod def extract_release_id_regex(album_id): """Returns the Discogs_id or None.""" From 2e916404f93e739750a1d6c5cfadfa8e49c9bcd0 Mon Sep 17 00:00:00 2001 From: ghbrown Date: Thu, 12 Jan 2023 19:41:04 -0600 Subject: [PATCH 22/70] early exit; add data_source --- beetsplug/discogs.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index de6cf45f2..435d67b40 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -195,6 +195,10 @@ class DiscogsPlugin(BeetsPlugin): if not self.discogs_client: return + if not artist and not title: + self._log.debug('Skipping Discogs query. File missing artist and ' + 'title tags.') + query = f'{artist} {title}' try: albums = self.get_albums(query) @@ -212,12 +216,14 @@ class DiscogsPlugin(BeetsPlugin): self._log.debug(u'searching within album {0}', album_cur.album) track_list = self.get_tracks_from_album(album_cur) candidates += track_list + for candidate in candidates: + candidate.data_source = 'Discogs' return candidates def get_tracks_from_album(self, album_info): """Return a list of tracks in the release """ - if not album_info: + if not album_info: return [] result = [] From 2df41b9e166bd3c5e449c9c55d026519f7d6258e Mon Sep 17 00:00:00 2001 From: ghbrown Date: Mon, 16 Jan 2023 18:43:26 -0600 Subject: [PATCH 23/70] Limit number of returned track candidates --- beetsplug/discogs.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 435d67b40..990c1d786 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -218,7 +218,8 @@ class DiscogsPlugin(BeetsPlugin): candidates += track_list for candidate in candidates: candidate.data_source = 'Discogs' - return candidates + # first 10 results, don't overwhelm with options + return candidates[:10] def get_tracks_from_album(self, album_info): """Return a list of tracks in the release From 7f5a28348c78dc9ee28fd9755bdb126a6da0bda5 Mon Sep 17 00:00:00 2001 From: Serene-Arc Date: Fri, 20 Jan 2023 13:20:15 +1000 Subject: [PATCH 24/70] Add some typings --- beets/autotag/__init__.py | 6 ++++-- beets/autotag/hooks.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index 916906029..339e3826c 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -14,7 +14,9 @@ """Facilities for automatically determining files' correct metadata. """ +from typing import Mapping +from beets.library import Item from beets import logging from beets import config @@ -71,7 +73,7 @@ SPECIAL_FIELDS = { # Additional utilities for the main interface. -def apply_item_metadata(item, track_info): +def apply_item_metadata(item: Item, track_info: TrackInfo): """Set an item's metadata from its matched TrackInfo object. """ item.artist = track_info.artist @@ -95,7 +97,7 @@ def apply_item_metadata(item, track_info): # and track number). Perhaps these should be emptied? -def apply_metadata(album_info, mapping): +def apply_metadata(album_info: AlbumInfo, mapping: Mapping[Item, TrackInfo]): """Set the items' metadata to match an AlbumInfo object using a mapping from Items to TrackInfo objects. """ diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index c09e4d321..a1cd49d19 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -55,7 +55,7 @@ class AttrDict(dict): def __setattr__(self, key, value): self.__setitem__(key, value) - def __hash__(self): + def __hash__(self) -> int: return id(self) From 21bf37befab32e4a24900a0516a3807e7b899afa Mon Sep 17 00:00:00 2001 From: Stefano Pigozzi Date: Fri, 20 Jan 2023 12:36:44 +0100 Subject: [PATCH 25/70] Use definition lists with the plugins' names So that they are easier to find when knowing the name of an enabled plugin --- docs/plugins/index.rst | 390 ++++++++++++++++++++++++++++------------- 1 file changed, 271 insertions(+), 119 deletions(-) diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 4de531099..39925cf56 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -132,13 +132,21 @@ following to your configuration:: Autotagger Extensions --------------------- -* :doc:`chroma`: Use acoustic fingerprinting to identify audio files with - missing or incorrect metadata. -* :doc:`discogs`: Search for releases in the `Discogs`_ database. -* :doc:`spotify`: Search for releases in the `Spotify`_ database. -* :doc:`deezer`: Search for releases in the `Deezer`_ database. -* :doc:`fromfilename`: Guess metadata for untagged tracks from their - filenames. +:doc:`chroma ` + Use acoustic fingerprinting to identify audio files with + missing or incorrect metadata. + +:doc:`discogs ` + Search for releases in the `Discogs`_ database. + +:doc:`spotify ` + Search for releases in the `Spotify`_ database. + +:doc:`deezer ` + Search for releases in the `Deezer`_ database. + +:doc:`fromfilename ` + Guess metadata for untagged tracks from their filenames. .. _Discogs: https://www.discogs.com/ .. _Spotify: https://www.spotify.com @@ -147,30 +155,69 @@ Autotagger Extensions Metadata -------- -* :doc:`absubmit`: Analyse audio with the `streaming_extractor_music`_ program and submit the metadata to the AcousticBrainz server -* :doc:`acousticbrainz`: Fetch various AcousticBrainz metadata -* :doc:`bpm`: Measure tempo using keystrokes. -* :doc:`bpsync`: Fetch updated metadata from Beatport. -* :doc:`edit`: Edit metadata from a text editor. -* :doc:`embedart`: Embed album art images into files' metadata. -* :doc:`fetchart`: Fetch album cover art from various sources. -* :doc:`ftintitle`: Move "featured" artists from the artist field to the title - field. -* :doc:`keyfinder`: Use the `KeyFinder`_ program to detect the musical +:doc:`absubmit ` + Analyse audio with the `streaming_extractor_music`_ program and submit the metadata to the AcousticBrainz server + +:doc:`acousticbrainz ` + Fetch various AcousticBrainz metadata + +:doc:`bpm ` + Measure tempo using keystrokes. + +:doc:`bpsync ` + Fetch updated metadata from Beatport. + +:doc:`edit ` + Edit metadata from a text editor. + +:doc:`embedart ` + Embed album art images into files' metadata. + +:doc:`fetchart ` + Fetch album cover art from various sources. + +:doc:`ftintitle ` + Move "featured" artists from the artist field to the title + field. + +:doc:`keyfinder ` + Use the `KeyFinder`_ program to detect the musical key from the audio. -* :doc:`importadded`: Use file modification times for guessing the value for - the `added` field in the database. -* :doc:`lastgenre`: Fetch genres based on Last.fm tags. -* :doc:`lastimport`: Collect play counts from Last.fm. -* :doc:`lyrics`: Automatically fetch song lyrics. -* :doc:`mbsync`: Fetch updated metadata from MusicBrainz. -* :doc:`metasync`: Fetch metadata from local or remote sources -* :doc:`mpdstats`: Connect to `MPD`_ and update the beets library with play - statistics (last_played, play_count, skip_count, rating). -* :doc:`parentwork`: Fetch work titles and works they are part of. -* :doc:`replaygain`: Calculate volume normalization for players that support it. -* :doc:`scrub`: Clean extraneous metadata from music files. -* :doc:`zero`: Nullify fields by pattern or unconditionally. + +:doc:`importadded ` + Use file modification times for guessing the value for + the `added` field in the database. + +:doc:`lastgenre ` + Fetch genres based on Last.fm tags. + +:doc:`lastimport ` + Collect play counts from Last.fm. + +:doc:`lyrics ` + Automatically fetch song lyrics. + +:doc:`mbsync ` + Fetch updated metadata from MusicBrainz. + +:doc:`metasync ` + Fetch metadata from local or remote sources + +:doc:`mpdstats ` + Connect to `MPD`_ and update the beets library with play + statistics (last_played, play_count, skip_count, rating). + +:doc:`parentwork ` + Fetch work titles and works they are part of. + +:doc:`replaygain ` + Calculate volume normalization for players that support it. + +:doc:`scrub ` + Clean extraneous metadata from music files. + +:doc:`zero ` + Nullify fields by pattern or unconditionally. .. _KeyFinder: http://www.ibrahimshaath.co.uk/keyfinder/ .. _streaming_extractor_music: https://acousticbrainz.org/download @@ -178,37 +225,75 @@ Metadata Path Formats ------------ -* :doc:`albumtypes`: Format album type in path formats. -* :doc:`bucket`: Group your files into bucket directories that cover different - field values ranges. -* :doc:`inline`: Use Python snippets to customize path format strings. -* :doc:`rewrite`: Substitute values in path formats. -* :doc:`the`: Move patterns in path formats (i.e., move "a" and "the" to the - end). +:doc:`albumtypes ` + Format album type in path formats. + +:doc:`bucket ` + Group your files into bucket directories that cover different + field values ranges. + +:doc:`inline ` + Use Python snippets to customize path format strings. + +:doc:`rewrite ` + Substitute values in path formats. + +:doc:`the ` + Move patterns in path formats (i.e., move "a" and "the" to the + end). Interoperability ---------------- -* :doc:`aura`: A server implementation of the `AURA`_ specification. -* :doc:`badfiles`: Check audio file integrity. -* :doc:`embyupdate`: Automatically notifies `Emby`_ whenever the beets library changes. -* :doc:`fish`: Adds `Fish shell`_ tab autocompletion to ``beet`` commands. -* :doc:`importfeeds`: Keep track of imported files via ``.m3u`` playlist file(s) or symlinks. -* :doc:`ipfs`: Import libraries from friends and get albums from them via ipfs. -* :doc:`kodiupdate`: Automatically notifies `Kodi`_ whenever the beets library - changes. -* :doc:`mpdupdate`: Automatically notifies `MPD`_ whenever the beets library - changes. -* :doc:`play`: Play beets queries in your music player. -* :doc:`playlist`: Use M3U playlists to query the beets library. -* :doc:`plexupdate`: Automatically notifies `Plex`_ whenever the beets library - changes. -* :doc:`smartplaylist`: Generate smart playlists based on beets queries. -* :doc:`sonosupdate`: Automatically notifies `Sonos`_ whenever the beets library - changes. -* :doc:`thumbnails`: Get thumbnails with the cover art on your album folders. -* :doc:`subsonicupdate`: Automatically notifies `Subsonic`_ whenever the beets - library changes. +:doc:`aura ` + A server implementation of the `AURA`_ specification. + +:doc:`badfiles ` + Check audio file integrity. + +:doc:`embyupdate ` + Automatically notifies `Emby`_ whenever the beets library changes. + +:doc:`fish ` + Adds `Fish shell`_ tab autocompletion to ``beet`` commands. + +:doc:`importfeeds ` + Keep track of imported files via ``.m3u`` playlist file(s) or symlinks. + +:doc:`ipfs ` + Import libraries from friends and get albums from them via ipfs. + +:doc:`kodiupdate ` + Automatically notifies `Kodi`_ whenever the beets library + changes. + +:doc:`mpdupdate ` + Automatically notifies `MPD`_ whenever the beets library + changes. + +:doc:`play ` + Play beets queries in your music player. + +:doc:`playlist ` + Use M3U playlists to query the beets library. + +:doc:`plexupdate ` + Automatically notifies `Plex`_ whenever the beets library + changes. + +:doc:`smartplaylist ` + Generate smart playlists based on beets queries. + +:doc:`sonosupdate ` + Automatically notifies `Sonos`_ whenever the beets library + changes. + +:doc:`thumbnails ` + Get thumbnails with the cover art on your album folders. + +:doc:`subsonicupdate ` + Automatically notifies `Subsonic`_ whenever the beets + library changes. .. _AURA: https://auraspec.readthedocs.io @@ -222,28 +307,65 @@ Interoperability Miscellaneous ------------- -* :doc:`bareasc`: Search albums and tracks with bare ASCII string matching. -* :doc:`bpd`: A music player for your beets library that emulates `MPD`_ and is - compatible with `MPD clients`_. -* :doc:`convert`: Transcode music and embed album art while exporting to - a different directory. -* :doc:`duplicates`: List duplicate tracks or albums. -* :doc:`export`: Export data from queries to a format. -* :doc:`filefilter`: Automatically skip files during the import process based - on regular expressions. -* :doc:`fuzzy`: Search albums and tracks with fuzzy string matching. -* :doc:`hook`: Run a command when an event is emitted by beets. -* :doc:`ihate`: Automatically skip albums and tracks during the import process. -* :doc:`info`: Print music files' tags to the console. -* :doc:`loadext`: Load SQLite extensions. -* :doc:`mbcollection`: Maintain your MusicBrainz collection list. -* :doc:`mbsubmit`: Print an album's tracks in a MusicBrainz-friendly format. -* :doc:`missing`: List missing tracks. -* `mstream`_: A music streaming server + webapp that can be used alongside beets. -* :doc:`random`: Randomly choose albums and tracks from your library. -* :doc:`spotify`: Create Spotify playlists from the Beets library. -* :doc:`types`: Declare types for flexible attributes. -* :doc:`web`: An experimental Web-based GUI for beets. +:doc:`bareasc ` + Search albums and tracks with bare ASCII string matching. + +:doc:`bpd ` + A music player for your beets library that emulates `MPD`_ and is + compatible with `MPD clients`_. + +:doc:`convert ` + Transcode music and embed album art while exporting to + a different directory. + +:doc:`duplicates ` + List duplicate tracks or albums. + +:doc:`export ` + Export data from queries to a format. + +:doc:`filefilter ` + Automatically skip files during the import process based + on regular expressions. + +:doc:`fuzzy ` + Search albums and tracks with fuzzy string matching. + +:doc:`hook ` + Run a command when an event is emitted by beets. + +:doc:`ihate ` + Automatically skip albums and tracks during the import process. + +:doc:`info ` + Print music files' tags to the console. + +:doc:`loadext ` + Load SQLite extensions. + +:doc:`mbcollection ` + Maintain your MusicBrainz collection list. + +:doc:`mbsubmit ` + Print an album's tracks in a MusicBrainz-friendly format. + +:doc:`missing ` + List missing tracks. + +`mstream`_ + A music streaming server + webapp that can be used alongside beets. + +:doc:`random ` + Randomly choose albums and tracks from your library. + +:doc:`spotify ` + Create Spotify playlists from the Beets library. + +:doc:`types ` + Declare types for flexible attributes. + +:doc:`web ` + An experimental Web-based GUI for beets. .. _MPD: https://www.musicpd.org/ .. _MPD clients: https://mpd.wikia.com/wiki/Clients @@ -270,76 +392,106 @@ line in your config file. Here are a few of the plugins written by the beets community: -* `beets-alternatives`_ manages external files. +`beets-alternatives`_ + Manages external files. -* `beet-amazon`_ adds Amazon.com as a tagger data source. +`beet-amazon`_ + Adds Amazon.com as a tagger data source. -* `beets-artistcountry`_ fetches the artist's country of origin from - MusicBrainz. +`beets-artistcountry`_ + Fetches the artist's country of origin from MusicBrainz. -* `beets-autofix`_ automates repetitive tasks to keep your library in order. +`beets-autofix`_ + Automates repetitive tasks to keep your library in order. -* `beets-audible`_ adds Audible as a tagger data source and provides - other features for managing audiobook collections. +`beets-audible`_ + Adds Audible as a tagger data source and provides + other features for managing audiobook collections. -* `beets-barcode`_ lets you scan or enter barcodes for physical media to - search for their metadata. +`beets-barcode`_ + Lets you scan or enter barcodes for physical media to + search for their metadata. -* `beetcamp`_ enables **bandcamp.com** autotagger with a fairly extensive amount of metadata. +`beetcamp`_ + Enables **bandcamp.com** autotagger with a fairly extensive amount of metadata. -* `beetstream`_ is server implementation of the `SubSonic API`_ specification, allowing you to stream your music on a multitude of clients. +`beetstream`_ + Is server implementation of the `SubSonic API`_ specification, allowing you to stream your music on a multitude of clients. -* `beets-bpmanalyser`_ analyses songs and calculates their tempo (BPM). +`beets-bpmanalyser`_ + Analyses songs and calculates their tempo (BPM). -* `beets-check`_ automatically checksums your files to detect corruption. +`beets-check`_ + Automatically checksums your files to detect corruption. -* `A cmus plugin`_ integrates with the `cmus`_ console music player. +`A cmus plugin`_ + Integrates with the `cmus`_ console music player. -* `beets-copyartifacts`_ helps bring non-music files along during import. +`beets-copyartifacts`_ + Helps bring non-music files along during import. -* `beets-describe`_ gives you the full picture of a single attribute of your library items. +`beets-describe`_ + Gives you the full picture of a single attribute of your library items. -* `drop2beets`_ automatically imports singles as soon as they are dropped in a - folder (using Linux's ``inotify``). You can also set a sub-folders - hierarchy to set flexible attributes by the way. +`drop2beets`_ + Automatically imports singles as soon as they are dropped in a + folder (using Linux's ``inotify``). You can also set a sub-folders + hierarchy to set flexible attributes by the way. -* `dsedivec`_ has two plugins: ``edit`` and ``moveall``. +`dsedivec`_ + Has two plugins: ``edit`` and ``moveall``. -* `beets-follow`_ lets you check for new albums from artists you like. +`beets-follow`_ + Lets you check for new albums from artists you like. -* `beetFs`_ is a FUSE filesystem for browsing the music in your beets library. - (Might be out of date.) +`beetFs`_ + Is a FUSE filesystem for browsing the music in your beets library. + (Might be out of date.) -* `beets-goingrunning`_ generates playlists to go with your running sessions. +`beets-goingrunning`_ + Generates playlists to go with your running sessions. -* `beets-ibroadcast`_ uploads tracks to the `iBroadcast`_ cloud service. +`beets-ibroadcast`_ + Uploads tracks to the `iBroadcast`_ cloud service. -* `beets-importreplace`_ lets you perform regex replacements on incoming - metadata. +`beets-importreplace`_ + Lets you perform regex replacements on incoming + metadata. -* `beets-mosaic`_ generates a montage of a mosaic from cover art. +`beets-mosaic`_ + Generates a montage of a mosaic from cover art. -* `beets-noimport`_ adds and removes directories from the incremental import skip list. +`beets-noimport`_ + Adds and removes directories from the incremental import skip list. -* `beets-originquery`_ augments MusicBrainz queries with locally-sourced data - to improve autotagger results. +`beets-originquery`_ + Augments MusicBrainz queries with locally-sourced data + to improve autotagger results. -* `beets-popularity`_ fetches popularity values from Deezer. +`beets-popularity`_ + Fetches popularity values from Deezer. -* `beets-setlister`_ generate playlists from the setlists of a given artist. +`beets-setlister`_ + Generate playlists from the setlists of a given artist. -* `beet-summarize`_ can compute lots of counts and statistics about your music - library. +`beet-summarize`_ + Can compute lots of counts and statistics about your music + library. -* `beets-usertag`_ lets you use keywords to tag and organize your music. +`beets-usertag`_ + Lets you use keywords to tag and organize your music. -* `whatlastgenre`_ fetches genres from various music sites. +`whatlastgenre`_ + Fetches genres from various music sites. -* `beets-xtractor`_ extracts low- and high-level musical information from your songs. +`beets-xtractor`_ + Extracts low- and high-level musical information from your songs. -* `beets-ydl`_ downloads audio from youtube-dl sources and import into beets. +`beets-ydl`_ + Downloads audio from youtube-dl sources and import into beets. -* `beets-yearfixer`_ attempts to fix all missing ``original_year`` and ``year`` fields. +`beets-yearfixer`_ + Attempts to fix all missing ``original_year`` and ``year`` fields. .. _beets-barcode: https://github.com/8h2a/beets-barcode .. _beetcamp: https://github.com/snejus/beetcamp From bc379850b21388d284523fc7ea298e9365080c84 Mon Sep 17 00:00:00 2001 From: Stefano Pigozzi Date: Fri, 20 Jan 2023 12:47:43 +0100 Subject: [PATCH 26/70] Improve formatting in the `plugins/discogs` page --- docs/plugins/discogs.rst | 49 ++++++++++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/docs/plugins/discogs.rst b/docs/plugins/discogs.rst index a9125e737..e7346c960 100644 --- a/docs/plugins/discogs.rst +++ b/docs/plugins/discogs.rst @@ -39,11 +39,19 @@ Authentication via Personal Access Token As an alternative to OAuth, you can get a token from Discogs and add it to your configuration. To get a personal access token (called a "user token" in the `python3-discogs-client`_ -documentation), login to `Discogs`_, and visit the -`Developer settings page -`_. Press the ``Generate new -token`` button, and place the generated token in your configuration, as the -``user_token`` config option in the ``discogs`` section. +documentation): + +#. login to `Discogs`_; +#. visit the `Developer settings page `_; +#. press the *Generate new token* button; +#. copy the generated token; +#. place it in your configuration in the ``discogs`` section as the ``user_token`` option: + + .. code-block:: yaml + + discogs: + user_token: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + Configuration ------------- @@ -54,22 +62,30 @@ There is one additional option in the ``discogs:`` section, ``index_tracks``. Index tracks (see the `Discogs guidelines `_), along with headers, mark divisions between distinct works on the same release -or within works. When ``index_tracks`` is enabled:: +or within works. When ``index_tracks`` is enabled: + +.. code-block:: yaml discogs: index_tracks: yes beets will incorporate the names of the divisions containing each track into -the imported track's title. For example, importing +the imported track's title. + +For example, importing `this album `_ -would result in track names like:: +would result in track names like: + +.. code-block:: text Messiah, Part I: No.1: Sinfony Messiah, Part II: No.22: Chorus- Behold The Lamb Of God Athalia, Act I, Scene I: Sinfonia -whereas with ``index_tracks`` disabled you'd get:: +whereas with ``index_tracks`` disabled you'd get: + +.. code-block:: text No.1: Sinfony No.22: Chorus- Behold The Lamb Of God @@ -79,11 +95,16 @@ This option is useful when importing classical music. Other configurations available under ``discogs:`` are: -- **append_style_genre**: Appends the Discogs style (if found) to the genre tag. This can be useful if you want more granular genres to categorize your music. - For example, a release in Discogs might have a genre of "Electronic" and a style of "Techno": enabling this setting would set the genre to be "Electronic, Techno" (assuming default separator of ``", "``) instead of just "Electronic". - Default: ``false`` -- **separator**: How to join multiple genre and style values from Discogs into a string. - Default: ``", "`` +``append_style_genre`` + Appends the Discogs style (if found) to the genre tag. This can be useful if you want more granular genres to categorize your music. + For example, a release in Discogs might have a genre of "Electronic" and a style of "Techno": enabling this setting would set the genre to be "Electronic, Techno" (assuming default separator of ``", "``) instead of just "Electronic". + + Default: ``false`` + +``separator`` + How to join multiple genre and style values from Discogs into a string. + + Default: ``", "`` Troubleshooting From 54aa0958822b932c8a508548e50a4ec231f3f001 Mon Sep 17 00:00:00 2001 From: Stefano Pigozzi Date: Fri, 20 Jan 2023 13:01:23 +0100 Subject: [PATCH 27/70] Specify the language of a code-block --- docs/plugins/discogs.rst | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/plugins/discogs.rst b/docs/plugins/discogs.rst index e7346c960..febdf4ab0 100644 --- a/docs/plugins/discogs.rst +++ b/docs/plugins/discogs.rst @@ -11,9 +11,11 @@ Installation ------------ To use the ``discogs`` plugin, first enable it in your configuration (see -:ref:`using-plugins`). Then, install the `python3-discogs-client`_ library by typing:: +:ref:`using-plugins`). Then, install the `python3-discogs-client`_ library by typing: - pip install python3-discogs-client +.. code-block:: console + + $ pip install python3-discogs-client You will also need to register for a `Discogs`_ account, and provide authentication credentials via a personal access token or an OAuth2 From ca320527c8903e7740515f52eb09a5c6ad69897b Mon Sep 17 00:00:00 2001 From: Stefano Pigozzi Date: Fri, 20 Jan 2023 13:10:15 +0100 Subject: [PATCH 28/70] Improve formatting in the `plugins/plexupdate` page --- docs/plugins/plexupdate.rst | 59 ++++++++++++++++++++++++++----------- 1 file changed, 41 insertions(+), 18 deletions(-) diff --git a/docs/plugins/plexupdate.rst b/docs/plugins/plexupdate.rst index b6a2bf920..9fbb8e7df 100644 --- a/docs/plugins/plexupdate.rst +++ b/docs/plugins/plexupdate.rst @@ -8,18 +8,22 @@ To use ``plexupdate`` plugin, enable it in your configuration (see :ref:`using-plugins`). Then, you'll probably want to configure the specifics of your Plex server. You can do that using an ``plex:`` section in your ``config.yaml``, -which looks like this:: +which looks like this: - plex: - host: localhost - port: 32400 - token: token +.. code-block:: yaml + + plex: + host: "localhost" + port: 32400 + token: "TOKEN" The ``token`` key is optional: you'll need to use it when in a Plex Home (see Plex's own `documentation about tokens`_). To use the ``plexupdate`` plugin you need to install the `requests`_ library with: - pip install requests +.. code-block:: console + + $ pip install beets[plexupdate] With that all in place, you'll see beets send the "update" command to your Plex server every time you change your beets library. @@ -33,15 +37,34 @@ Configuration The available options under the ``plex:`` section are: -- **host**: The Plex server name. - Default: ``localhost``. -- **port**: The Plex server port. - Default: 32400. -- **token**: The Plex Home token. - Default: Empty. -- **library_name**: The name of the Plex library to update. - Default: ``Music`` -- **secure**: Use secure connections to the Plex server. - Default: ``False`` -- **ignore_cert_errors**: Ignore TLS certificate errors when using secure connections. - Default: ``False`` +``host`` + The Plex server name. + + Default: ``"localhost"``. + +``port`` + + The Plex server port. + + Default: ``32400``. + +``token`` + The Plex Home token. + + Default: ``""``. + +``library_name`` + The name of the Plex library to update. + + Default: ``"Music"`` + +``secure`` + Use secure connections to the Plex server. + + Default: ``False`` + +``ignore_cert_errors`` + + Ignore TLS certificate errors when using secure connections. + + Default: ``False`` From cbb1b214089fed6ac4c1e3a25336ef5c229cace9 Mon Sep 17 00:00:00 2001 From: ghbrown Date: Fri, 20 Jan 2023 21:07:15 -0600 Subject: [PATCH 29/70] Use tracks field in item_candidates; add more info to tracks of AlbumInfo --- beetsplug/discogs.py | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 990c1d786..44601e7e9 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -214,27 +214,11 @@ class DiscogsPlugin(BeetsPlugin): candidates = [] for album_cur in albums: self._log.debug(u'searching within album {0}', album_cur.album) - track_list = self.get_tracks_from_album(album_cur) - candidates += track_list + candidates += album_cur.tracks for candidate in candidates: candidate.data_source = 'Discogs' # first 10 results, don't overwhelm with options - return candidates[:10] - - def get_tracks_from_album(self, album_info): - """Return a list of tracks in the release - """ - if not album_info: - return [] - - result = [] - for track_info in album_info.tracks: - # attach artist info if not provided - if not track_info['artist']: - track_info['artist'] = album_info.artist - track_info['artist_id'] = album_info.artist_id - result.append(track_info) - return result + return candidates[:10] @staticmethod def extract_release_id_regex(album_id): @@ -407,9 +391,13 @@ class DiscogsPlugin(BeetsPlugin): for track in tracks: track.media = media track.medium_total = mediums.count(track.medium) + # artist info will be identical for all tracks until #3353 fixed + track.artist = artist + track.artist_id = artist_id # Discogs does not have track IDs. Invent our own IDs as proposed # in #2336. track.track_id = str(album_id) + "-" + track.track_alt + track.data_url = data_url # Retrieve master release id (returns None if there isn't one). master_id = result.data.get('master_id') From a99eb773373dd6816e566c03fe409012d1cb436a Mon Sep 17 00:00:00 2001 From: ghbrown Date: Fri, 20 Jan 2023 22:15:50 -0600 Subject: [PATCH 30/70] Improve where an how data added to tracks of album --- beetsplug/discogs.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 44601e7e9..103aa1107 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -215,8 +215,6 @@ class DiscogsPlugin(BeetsPlugin): for album_cur in albums: self._log.debug(u'searching within album {0}', album_cur.album) candidates += album_cur.tracks - for candidate in candidates: - candidate.data_source = 'Discogs' # first 10 results, don't overwhelm with options return candidates[:10] @@ -391,13 +389,15 @@ class DiscogsPlugin(BeetsPlugin): for track in tracks: track.media = media track.medium_total = mediums.count(track.medium) - # artist info will be identical for all tracks until #3353 fixed - track.artist = artist - track.artist_id = artist_id + if not track.artist: # get_track_info often fails to find artist + track.artist = artist + if not track.artist_id: + track.artist_id = artist_id # Discogs does not have track IDs. Invent our own IDs as proposed # in #2336. track.track_id = str(album_id) + "-" + track.track_alt track.data_url = data_url + track.data_source = 'Discogs' # Retrieve master release id (returns None if there isn't one). master_id = result.data.get('master_id') From 47fe387de1163a78dbb21e88acd83649f1af9402 Mon Sep 17 00:00:00 2001 From: ghbrown Date: Sat, 21 Jan 2023 20:56:44 -0600 Subject: [PATCH 31/70] Docs and changelog --- docs/changelog.rst | 1 + docs/plugins/discogs.rst | 8 +++----- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 66802d203..8311bffa9 100755 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -37,6 +37,7 @@ New features: ``=~``. :bug:`4251` * :doc:`/plugins/discogs`: Permit appending style to genre +* :doc:`plugins/discogs`: Implement item_candidates for matching singletons * :doc:`/plugins/convert`: Add a new `auto_keep` option that automatically converts files but keeps the *originals* in the library. :bug:`1840` :bug:`4302` diff --git a/docs/plugins/discogs.rst b/docs/plugins/discogs.rst index a9125e737..e424a4b04 100644 --- a/docs/plugins/discogs.rst +++ b/docs/plugins/discogs.rst @@ -2,8 +2,7 @@ Discogs Plugin ============== The ``discogs`` plugin extends the autotagger's search capabilities to -include matches from the `Discogs`_ database when importing albums. -(The plugin does not yet support matching singleton tracks.) +include matches from the `Discogs`_ database. .. _Discogs: https://discogs.com @@ -100,8 +99,7 @@ Here are two things you can try: * Make sure that your system clock is accurate. The Discogs servers can reject your request if your clock is too out of sync. -The plugin can only match albums, so no Discogs matches will be -reported when importing singletons using ``-s``. One possible -workaround is to use the ``--group-albums`` option. +Support for matching singleton tracks using ``-s`` is in progress. +If this is not working well, try the ``--group-albums`` option in album import mode. .. _python3-discogs-client: https://github.com/joalla/discogs_client From 7f3f522973364c176000155ec8621efa34a0ba68 Mon Sep 17 00:00:00 2001 From: Jordyn Date: Sat, 21 Jan 2023 22:08:03 -0600 Subject: [PATCH 32/70] config.rst: Remove extraneous for --- docs/reference/config.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/config.rst b/docs/reference/config.rst index e59937dc3..b6fa8fea6 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -135,7 +135,7 @@ unexpected behavior on all popular platforms:: These substitutions remove forward and back slashes, leading dots, and control characters—all of which is a good idea on any OS. The fourth line -removes the Windows "reserved characters" (useful even on Unix for for +removes the Windows "reserved characters" (useful even on Unix for compatibility with Windows-influenced network filesystems like Samba). Trailing dots and trailing whitespace, which can cause problems on Windows clients, are also removed. From 566579b5f8e6b75aace72b1fe1af02a81e410f96 Mon Sep 17 00:00:00 2001 From: Stefano Pigozzi Date: Sun, 22 Jan 2023 14:37:39 +0100 Subject: [PATCH 33/70] Revert the configuration section back to how it was before --- docs/plugins/plexupdate.rst | 43 +++++++++++-------------------------- 1 file changed, 12 insertions(+), 31 deletions(-) diff --git a/docs/plugins/plexupdate.rst b/docs/plugins/plexupdate.rst index 9fbb8e7df..aaeb28e5b 100644 --- a/docs/plugins/plexupdate.rst +++ b/docs/plugins/plexupdate.rst @@ -37,34 +37,15 @@ Configuration The available options under the ``plex:`` section are: -``host`` - The Plex server name. - - Default: ``"localhost"``. - -``port`` - - The Plex server port. - - Default: ``32400``. - -``token`` - The Plex Home token. - - Default: ``""``. - -``library_name`` - The name of the Plex library to update. - - Default: ``"Music"`` - -``secure`` - Use secure connections to the Plex server. - - Default: ``False`` - -``ignore_cert_errors`` - - Ignore TLS certificate errors when using secure connections. - - Default: ``False`` +- **host**: The Plex server name. + Default: ``localhost``. +- **port**: The Plex server port. + Default: 32400. +- **token**: The Plex Home token. + Default: Empty. +- **library_name**: The name of the Plex library to update. + Default: ``Music`` +- **secure**: Use secure connections to the Plex server. + Default: ``False`` +- **ignore_cert_errors**: Ignore TLS certificate errors when using secure connections. + Default: ``False`` \ No newline at end of file From 2b600fa15186e1ac934389b23cdcbe62f22d6fa2 Mon Sep 17 00:00:00 2001 From: Stefano Pigozzi Date: Sun, 22 Jan 2023 14:56:23 +0100 Subject: [PATCH 34/70] Revert the configuration section back to how it was before --- docs/plugins/discogs.rst | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/docs/plugins/discogs.rst b/docs/plugins/discogs.rst index febdf4ab0..17a1645bb 100644 --- a/docs/plugins/discogs.rst +++ b/docs/plugins/discogs.rst @@ -97,16 +97,11 @@ This option is useful when importing classical music. Other configurations available under ``discogs:`` are: -``append_style_genre`` - Appends the Discogs style (if found) to the genre tag. This can be useful if you want more granular genres to categorize your music. - For example, a release in Discogs might have a genre of "Electronic" and a style of "Techno": enabling this setting would set the genre to be "Electronic, Techno" (assuming default separator of ``", "``) instead of just "Electronic". - - Default: ``false`` - -``separator`` - How to join multiple genre and style values from Discogs into a string. - - Default: ``", "`` +- **append_style_genre**: Appends the Discogs style (if found) to the genre tag. This can be useful if you want more granular genres to categorize your music. + For example, a release in Discogs might have a genre of "Electronic" and a style of "Techno": enabling this setting would set the genre to be "Electronic, Techno" (assuming default separator of ``", "``) instead of just "Electronic". + Default: ``False`` +- **separator**: How to join multiple genre and style values from Discogs into a string. + Default: ``", "`` Troubleshooting From e76fe012128e9ab389aad5cdfd70c3e7240b88ff Mon Sep 17 00:00:00 2001 From: Stefano Pigozzi Date: Sun, 22 Jan 2023 15:02:18 +0100 Subject: [PATCH 35/70] Add missing space --- docs/plugins/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 39925cf56..8404ce716 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -182,7 +182,7 @@ Metadata :doc:`keyfinder ` Use the `KeyFinder`_ program to detect the musical - key from the audio. + key from the audio. :doc:`importadded ` Use file modification times for guessing the value for From 429dfb3e7ad2793663e6a6a56dacc401698cc833 Mon Sep 17 00:00:00 2001 From: ghbrown Date: Sat, 28 Jan 2023 18:11:22 -0600 Subject: [PATCH 36/70] Fix docs phrasing; fix changelog formatting --- docs/changelog.rst | 4 ++-- docs/plugins/discogs.rst | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 8311bffa9..9587788fa 100755 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -36,8 +36,8 @@ New features: * Add :ref:`exact match ` queries, using the prefixes ``=`` and ``=~``. :bug:`4251` -* :doc:`/plugins/discogs`: Permit appending style to genre -* :doc:`plugins/discogs`: Implement item_candidates for matching singletons +* :doc:`/plugins/discogs`: Permit appending style to genre. +* :doc:`plugins/discogs`: Implement item_candidates for matching singletons. * :doc:`/plugins/convert`: Add a new `auto_keep` option that automatically converts files but keeps the *originals* in the library. :bug:`1840` :bug:`4302` diff --git a/docs/plugins/discogs.rst b/docs/plugins/discogs.rst index e424a4b04..1f0628072 100644 --- a/docs/plugins/discogs.rst +++ b/docs/plugins/discogs.rst @@ -99,7 +99,7 @@ Here are two things you can try: * Make sure that your system clock is accurate. The Discogs servers can reject your request if your clock is too out of sync. -Support for matching singleton tracks using ``-s`` is in progress. -If this is not working well, try the ``--group-albums`` option in album import mode. +Matching tracks by Discogs ID is not yet supported. The ``--group-albums`` +option in album import mode provides an alternative to singleton mode for autotagging tracks that are not in album-related folders. .. _python3-discogs-client: https://github.com/joalla/discogs_client From 2c43adf463edb29ef6b1aadb90d75cd5560d2f6c Mon Sep 17 00:00:00 2001 From: Katelyn Dickey Date: Tue, 31 Jan 2023 01:31:53 -0500 Subject: [PATCH 37/70] Fix album store method to cascade flex field deletions to items --- beets/library.py | 13 +++++++++++-- docs/changelog.rst | 2 ++ test/test_library.py | 13 +++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/beets/library.py b/beets/library.py index 981563974..b4a8680d8 100644 --- a/beets/library.py +++ b/beets/library.py @@ -1355,9 +1355,12 @@ class Album(LibModel): """ # Get modified track fields. track_updates = {} - for key in self.item_keys: - if key in self._dirty: + track_deletes = set() + for key in self._dirty: + if key in self.item_keys: track_updates[key] = self[key] + elif key not in self: + track_deletes.add(key); with self._db.transaction(): super().store(fields) @@ -1366,6 +1369,12 @@ class Album(LibModel): for key, value in track_updates.items(): item[key] = value item.store() + if track_deletes: + for item in self.items(): + for key in track_deletes: + if key in item: + del item[key] + item.store() def try_sync(self, write, move): """Synchronize the album and its items with the database. diff --git a/docs/changelog.rst b/docs/changelog.rst index 66802d203..f39282633 100755 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -133,6 +133,8 @@ Bug fixes: * :doc:`/plugins/fromfilename`: Fix failed detection of filename patterns. :bug:`4561` :bug:`4600` +* Fix issue where deletion of flexible fields on an album doesn't cascade to items + :bug:`4662` For packagers: diff --git a/test/test_library.py b/test/test_library.py index 5e57afb17..e94bffa6a 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -77,6 +77,19 @@ class StoreTest(_common.LibTestCase): self.i.store() self.assertTrue('composer' not in self.i._dirty) + def test_store_album_cascades_flex_deletes(self): + album = _common.album() + album.flex1="Flex-1" + self.lib.add(album) + item = _common.item() + item.album_id = album.id + item.flex1="Flex-1" + self.lib.add(item) + del album.flex1 + album.store() + self.assertNotIn('flex1', album) + self.assertNotIn('flex1', album.items()[0]) + class AddTest(_common.TestCase): def setUp(self): From f0359007a59c2fb77de71c65c2605b828ec7442d Mon Sep 17 00:00:00 2001 From: Katelyn Dickey <katedickey94@gmail.com> Date: Tue, 31 Jan 2023 11:26:22 -0500 Subject: [PATCH 38/70] Code style fixes --- beets/library.py | 2 +- test/test_library.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/beets/library.py b/beets/library.py index b4a8680d8..d62403cbf 100644 --- a/beets/library.py +++ b/beets/library.py @@ -1360,7 +1360,7 @@ class Album(LibModel): if key in self.item_keys: track_updates[key] = self[key] elif key not in self: - track_deletes.add(key); + track_deletes.add(key) with self._db.transaction(): super().store(fields) diff --git a/test/test_library.py b/test/test_library.py index e94bffa6a..389f3fa5e 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -79,11 +79,11 @@ class StoreTest(_common.LibTestCase): def test_store_album_cascades_flex_deletes(self): album = _common.album() - album.flex1="Flex-1" + album.flex1 = "Flex-1" self.lib.add(album) item = _common.item() item.album_id = album.id - item.flex1="Flex-1" + item.flex1 = "Flex-1" self.lib.add(item) del album.flex1 album.store() From 23598df1558b1b458adf8c69538a04de2fb5ab97 Mon Sep 17 00:00:00 2001 From: night199uk <night199uk@hermitcrabslab.com> Date: Fri, 9 Dec 2022 19:04:19 -0800 Subject: [PATCH 39/70] Avoid calling chmod in some scenarios. This guards the os.chmod calls so it's only called IF the permissions need changing. This guards against an exception in certain complex library setups. --- beetsplug/permissions.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/beetsplug/permissions.py b/beetsplug/permissions.py index f5aab056c..a55feb6ca 100644 --- a/beetsplug/permissions.py +++ b/beetsplug/permissions.py @@ -6,6 +6,7 @@ like the following in your config.yaml to configure: dir: 755 """ import os +import stat from beets import config, util from beets.plugins import BeetsPlugin from beets.util import ancestry @@ -25,7 +26,7 @@ def check_permissions(path, permission): """Check whether the file's permissions equal the given vector. Return a boolean. """ - return oct(os.stat(path).st_mode & 0o777) == oct(permission) + return oct(stat.S_IMODE(os.stat(path).st_mode)) == oct(permission) def assert_permissions(path, permission, log): @@ -103,7 +104,8 @@ class Permissions(BeetsPlugin): 'setting file permissions on {}', util.displayable_path(path), ) - os.chmod(util.syspath(path), file_perm) + if not check_permissions(util.syspath(path), file_perm): + os.chmod(util.syspath(path), file_perm) # Checks if the destination path has the permissions configured. assert_permissions(path, file_perm, self._log) @@ -115,7 +117,8 @@ class Permissions(BeetsPlugin): 'setting directory permissions on {}', util.displayable_path(path), ) - os.chmod(util.syspath(path), dir_perm) + if not check_permissions(util.syspath(path), dir_perm): + os.chmod(util.syspath(path), dir_perm) # Checks if the destination path has the permissions configured. assert_permissions(path, dir_perm, self._log) From 12173d30a8077d588821952ca670c4457bf01a97 Mon Sep 17 00:00:00 2001 From: wisp3rwind <17089248+wisp3rwind@users.noreply.github.com> Date: Sun, 12 Feb 2023 14:04:24 +0100 Subject: [PATCH 40/70] permissions: Move syspath conversion to the actual API boundary --- beetsplug/permissions.py | 27 ++++++++++++--------------- test/rsrc/unicode’d.mp3 | Bin 25287 -> 75297 bytes 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/beetsplug/permissions.py b/beetsplug/permissions.py index a55feb6ca..6fe3aabe5 100644 --- a/beetsplug/permissions.py +++ b/beetsplug/permissions.py @@ -7,9 +7,9 @@ like the following in your config.yaml to configure: """ import os import stat -from beets import config, util +from beets import config from beets.plugins import BeetsPlugin -from beets.util import ancestry +from beets.util import ancestry, displayable_path, syspath def convert_perm(perm): @@ -26,7 +26,7 @@ def check_permissions(path, permission): """Check whether the file's permissions equal the given vector. Return a boolean. """ - return oct(stat.S_IMODE(os.stat(path).st_mode)) == oct(permission) + return oct(stat.S_IMODE(os.stat(syspath(path)).st_mode)) == oct(permission) def assert_permissions(path, permission, log): @@ -34,15 +34,12 @@ def assert_permissions(path, permission, log): log a warning message. Return a boolean indicating the match, like `check_permissions`. """ - if not check_permissions(util.syspath(path), permission): - log.warning( - 'could not set permissions on {}', - util.displayable_path(path), - ) + if not check_permissions(path, permission): + log.warning('could not set permissions on {}', displayable_path(path)) log.debug( 'set permissions to {}, but permissions are now {}', permission, - os.stat(util.syspath(path)).st_mode & 0o777, + os.stat(syspath(path)).st_mode & 0o777, ) @@ -102,10 +99,10 @@ class Permissions(BeetsPlugin): # Changing permissions on the destination file. self._log.debug( 'setting file permissions on {}', - util.displayable_path(path), + displayable_path(path), ) - if not check_permissions(util.syspath(path), file_perm): - os.chmod(util.syspath(path), file_perm) + if not check_permissions(path, file_perm): + os.chmod(syspath(path), file_perm) # Checks if the destination path has the permissions configured. assert_permissions(path, file_perm, self._log) @@ -115,10 +112,10 @@ class Permissions(BeetsPlugin): # Changing permissions on the destination directory. self._log.debug( 'setting directory permissions on {}', - util.displayable_path(path), + displayable_path(path), ) - if not check_permissions(util.syspath(path), dir_perm): - os.chmod(util.syspath(path), dir_perm) + if not check_permissions(path, dir_perm): + os.chmod(syspath(path), dir_perm) # Checks if the destination path has the permissions configured. assert_permissions(path, dir_perm, self._log) diff --git a/test/rsrc/unicode’d.mp3 b/test/rsrc/unicode’d.mp3 index 7a145f01e4b3d17c26abd4ad25101d50af8c9229..f7e8b6285ac6eb3d606a6f7fcaee76a4f8f9e735 100644 GIT binary patch delta 344 zcmX?plyTu17A{X0V-^Mm2IdOKja<)}nVB<eHh*EBC$~93_Y33Z4YC%Dn-^$ZVBdT} z3CLZbwGARRd4bj*m?T)}57*{8wj-Pv;*VHJHVD~_3+%`|s7;d%gyEKBQ9!bl2ShL| TA=wN^>B%2VxHf;ZxTXmJ{|u3< delta 60 zcmZ2@hUNHCMlMemV-^M=C`{kT^_-cRAu(t37v_0#n-921v28x^^$X+X1zOt}VcbTx K&E9{mX#xP5#u%pn From e6fd038b0edd760b84163f696324ccc3069c5b8c Mon Sep 17 00:00:00 2001 From: wisp3rwind <17089248+wisp3rwind@users.noreply.github.com> Date: Sat, 24 Dec 2022 14:19:41 +0100 Subject: [PATCH 41/70] tests: robustify path query / case_sensitive tests - samefile exists on all platforms for recent python - don't rely on monkey-patching os/os.path and on specifics on the implementation: as a result of doing so, the tests start failing in obscure ways as soon as the implementation (and its usage of os.path.exists and os.path.samefile) is changed --- beets/library.py | 14 +++-- test/test_query.py | 152 ++++++++++++++++++++++----------------------- 2 files changed, 82 insertions(+), 84 deletions(-) diff --git a/beets/library.py b/beets/library.py index 030cf630e..b9344fe89 100644 --- a/beets/library.py +++ b/beets/library.py @@ -51,6 +51,8 @@ class PathQuery(dbcore.FieldQuery): default, the behavior depends on the OS: case-insensitive on Windows and case-sensitive otherwise. """ + # For tests + force_implicit_query_detection = False def __init__(self, field, pattern, fast=True, case_sensitive=None): """Create a path query. @@ -90,11 +92,13 @@ class PathQuery(dbcore.FieldQuery): # Test both `sep` and `altsep` (i.e., both slash and backslash on # Windows). - return ( - (os.sep in query_part or - (os.altsep and os.altsep in query_part)) and - os.path.exists(syspath(normpath(query_part))) - ) + if not (os.sep in query_part + or (os.altsep and os.altsep in query_part)): + return False + + if cls.force_implicit_query_detection: + return True + return os.path.exists(syspath(normpath(query_part))) def match(self, item): path = item.path if self.case_sensitive else item.path.lower() diff --git a/test/test_query.py b/test/test_query.py index 13f40482b..3d7b56781 100644 --- a/test/test_query.py +++ b/test/test_query.py @@ -15,8 +15,8 @@ """Various tests for querying the library database. """ +from contextlib import contextmanager from functools import partial -from unittest.mock import patch import os import sys import unittest @@ -454,23 +454,14 @@ class PathQueryTest(_common.LibTestCase, TestHelper, AssertsMixin): self.lib.add(i2) self.lib.add_album([i2]) + @contextmanager + def force_implicit_query_detection(self): # Unadorned path queries with path separators in them are considered # path queries only when the path in question actually exists. So we # mock the existence check to return true. - self.patcher_exists = patch('beets.library.os.path.exists') - self.patcher_exists.start().return_value = True - - # We have to create function samefile as it does not exist on - # Windows and python 2.7 - self.patcher_samefile = patch('beets.library.os.path.samefile', - create=True) - self.patcher_samefile.start().return_value = True - - def tearDown(self): - super().tearDown() - - self.patcher_samefile.stop() - self.patcher_exists.stop() + beets.library.PathQuery.force_implicit_query_detection = True + yield + beets.library.PathQuery.force_implicit_query_detection = False def test_path_exact_match(self): q = 'path:/a/b/c.mp3' @@ -526,31 +517,35 @@ class PathQueryTest(_common.LibTestCase, TestHelper, AssertsMixin): @unittest.skipIf(sys.platform == 'win32', WIN32_NO_IMPLICIT_PATHS) def test_slashed_query_matches_path(self): - q = '/a/b' - results = self.lib.items(q) - self.assert_items_matched(results, ['path item']) + with self.force_implicit_query_detection(): + q = '/a/b' + results = self.lib.items(q) + self.assert_items_matched(results, ['path item']) - results = self.lib.albums(q) - self.assert_albums_matched(results, ['path album']) + results = self.lib.albums(q) + self.assert_albums_matched(results, ['path album']) @unittest.skipIf(sys.platform == 'win32', WIN32_NO_IMPLICIT_PATHS) def test_path_query_in_or_query(self): - q = '/a/b , /a/b' - results = self.lib.items(q) - self.assert_items_matched(results, ['path item']) + with self.force_implicit_query_detection(): + q = '/a/b , /a/b' + results = self.lib.items(q) + self.assert_items_matched(results, ['path item']) def test_non_slashed_does_not_match_path(self): - q = 'c.mp3' - results = self.lib.items(q) - self.assert_items_matched(results, []) + with self.force_implicit_query_detection(): + q = 'c.mp3' + results = self.lib.items(q) + self.assert_items_matched(results, []) - results = self.lib.albums(q) - self.assert_albums_matched(results, []) + results = self.lib.albums(q) + self.assert_albums_matched(results, []) def test_slashes_in_explicit_field_does_not_match_path(self): - q = 'title:/a/b' - results = self.lib.items(q) - self.assert_items_matched(results, []) + with self.force_implicit_query_detection(): + q = 'title:/a/b' + results = self.lib.items(q) + self.assert_items_matched(results, []) def test_path_item_regex(self): q = 'path::c\\.mp3$' @@ -603,68 +598,67 @@ class PathQueryTest(_common.LibTestCase, TestHelper, AssertsMixin): results = self.lib.items(makeq(case_sensitive=False)) self.assert_items_matched(results, ['path item', 'caps path']) + # FIXME: Also create a variant of this test for windows, which tests + # both os.sep and os.altsep + @unittest.skipIf(sys.platform == 'win32', 'win32') + def test_path_sep_detection(self): + is_path_query = beets.library.PathQuery.is_path_query + with self.force_implicit_query_detection(): + self.assertTrue(is_path_query('/foo/bar')) + self.assertTrue(is_path_query('foo/bar')) + self.assertTrue(is_path_query('foo/')) + self.assertFalse(is_path_query('foo')) + self.assertTrue(is_path_query('foo/:bar')) + self.assertFalse(is_path_query('foo:bar/')) + self.assertFalse(is_path_query('foo:/bar')) - @patch('beets.library.os') - def test_path_sep_detection(self, mock_os): - mock_os.sep = '/' - mock_os.altsep = None - mock_os.path.exists = lambda p: True - is_path = beets.library.PathQuery.is_path_query - - self.assertTrue(is_path('/foo/bar')) - self.assertTrue(is_path('foo/bar')) - self.assertTrue(is_path('foo/')) - self.assertFalse(is_path('foo')) - self.assertTrue(is_path('foo/:bar')) - self.assertFalse(is_path('foo:bar/')) - self.assertFalse(is_path('foo:/bar')) - + # FIXME: shouldn't this also work on windows? @unittest.skipIf(sys.platform == 'win32', WIN32_NO_IMPLICIT_PATHS) def test_detect_absolute_path(self): - # Don't patch `os.path.exists`; we'll actually create a file when - # it exists. - self.patcher_exists.stop() - is_path = beets.library.PathQuery.is_path_query + """Test detection of implicit path queries based on whether or + not the path actually exists, when using an absolute path query. - try: - path = self.touch(os.path.join(b'foo', b'bar')) - path = path.decode('utf-8') + Thus, don't use the `force_implicit_query_detection()` + contextmanager which would disable the existence check. + """ + is_path_query = beets.library.PathQuery.is_path_query - # The file itself. - self.assertTrue(is_path(path)) + path = self.touch(os.path.join(b'foo', b'bar')) + self.assertTrue(os.path.isabs(util.syspath(path))) + path_str = path.decode('utf-8') - # The parent directory. - parent = os.path.dirname(path) - self.assertTrue(is_path(parent)) + # The file itself. + self.assertTrue(is_path_query(path_str)) - # Some non-existent path. - self.assertFalse(is_path(path + 'baz')) + # The parent directory. + parent = os.path.dirname(path_str) + self.assertTrue(is_path_query(parent)) - finally: - # Restart the `os.path.exists` patch. - self.patcher_exists.start() + # Some non-existent path. + self.assertFalse(is_path_query(path_str + 'baz')) def test_detect_relative_path(self): - self.patcher_exists.stop() - is_path = beets.library.PathQuery.is_path_query + """Test detection of implicit path queries based on whether or + not the path actually exists, when using a relative path query. + Thus, don't use the `force_implicit_query_detection()` + contextmanager which would disable the existence check. + """ + is_path_query = beets.library.PathQuery.is_path_query + + self.touch(os.path.join(b'foo', b'bar')) + + # Temporarily change directory so relative paths work. + cur_dir = os.getcwd() try: - self.touch(os.path.join(b'foo', b'bar')) - - # Temporarily change directory so relative paths work. - cur_dir = os.getcwd() - try: - os.chdir(self.temp_dir) - self.assertTrue(is_path('foo/')) - self.assertTrue(is_path('foo/bar')) - self.assertTrue(is_path('foo/bar:tagada')) - self.assertFalse(is_path('bar')) - finally: - os.chdir(cur_dir) - + os.chdir(self.temp_dir) + self.assertTrue(is_path_query('foo/')) + self.assertTrue(is_path_query('foo/bar')) + self.assertTrue(is_path_query('foo/bar:tagada')) + self.assertFalse(is_path_query('bar')) finally: - self.patcher_exists.start() + os.chdir(cur_dir) class IntQueryTest(unittest.TestCase, TestHelper): From 9052854e5024f85e2bfe0ae6d972e2a5a4b39b59 Mon Sep 17 00:00:00 2001 From: wisp3rwind <17089248+wisp3rwind@users.noreply.github.com> Date: Sat, 24 Dec 2022 18:19:06 +0100 Subject: [PATCH 42/70] library/PathQuery: remove useless bytestring_path() normpath already applies bytestring_path() to its output --- beets/library.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/beets/library.py b/beets/library.py index b9344fe89..c3b11113c 100644 --- a/beets/library.py +++ b/beets/library.py @@ -67,7 +67,7 @@ class PathQuery(dbcore.FieldQuery): # By default, the case sensitivity depends on the filesystem # that the query path is located on. if case_sensitive is None: - path = util.bytestring_path(util.normpath(pattern)) + path = util.normpath(pattern) case_sensitive = beets.util.case_sensitive(path) self.case_sensitive = case_sensitive @@ -76,9 +76,9 @@ class PathQuery(dbcore.FieldQuery): pattern = pattern.lower() # Match the path as a single file. - self.file_path = util.bytestring_path(util.normpath(pattern)) + self.file_path = util.normpath(pattern) # As a directory (prefix). - self.dir_path = util.bytestring_path(os.path.join(self.file_path, b'')) + self.dir_path = os.path.join(self.file_path, b'') @classmethod def is_path_query(cls, query_part): From 427bfe8cbf0b7d06bbc6051b256898b69acebdb4 Mon Sep 17 00:00:00 2001 From: wisp3rwind <17089248+wisp3rwind@users.noreply.github.com> Date: Sat, 24 Dec 2022 18:20:14 +0100 Subject: [PATCH 43/70] library/PathQuery: fix lower-casing --- beets/library.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/beets/library.py b/beets/library.py index c3b11113c..be6d9817a 100644 --- a/beets/library.py +++ b/beets/library.py @@ -64,21 +64,27 @@ class PathQuery(dbcore.FieldQuery): """ super().__init__(field, pattern, fast) + path = util.normpath(pattern) + # By default, the case sensitivity depends on the filesystem # that the query path is located on. if case_sensitive is None: - path = util.normpath(pattern) - case_sensitive = beets.util.case_sensitive(path) + case_sensitive = util.case_sensitive(path) self.case_sensitive = case_sensitive # Use a normalized-case pattern for case-insensitive matches. if not case_sensitive: - pattern = pattern.lower() + # We need to lowercase the entire path, not just the pattern. + # In particular, on Windows, the drive letter is otherwise not + # lowercased. + # This also ensures that the `match()` method below and the SQL + # from `col_clause()` do the same thing. + path = path.lower() # Match the path as a single file. - self.file_path = util.normpath(pattern) + self.file_path = path # As a directory (prefix). - self.dir_path = os.path.join(self.file_path, b'') + self.dir_path = os.path.join(path, b'') @classmethod def is_path_query(cls, query_part): From a666057fdf2fa2b102a5badf4743489b3811d818 Mon Sep 17 00:00:00 2001 From: wisp3rwind <17089248+wisp3rwind@users.noreply.github.com> Date: Tue, 14 Jun 2022 22:19:07 +0200 Subject: [PATCH 44/70] drop old Python: simplify and improve case_sensitive() - some cleanup for recent Python which has samefile() on Windows - also, fix this function by only upper-/lower-casing one path component at a time. It is unclear to me how this could have ever worked. --- beets/util/__init__.py | 81 +++++++++++++++++++----------------------- 1 file changed, 36 insertions(+), 45 deletions(-) diff --git a/beets/util/__init__.py b/beets/util/__init__.py index fbff07660..2319890a3 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -936,61 +936,52 @@ def interactive_open(targets, command): return os.execlp(*args) -def _windows_long_path_name(short_path): - """Use Windows' `GetLongPathNameW` via ctypes to get the canonical, - long path given a short filename. - """ - if not isinstance(short_path, str): - short_path = short_path.decode(_fsencoding()) - - import ctypes - buf = ctypes.create_unicode_buffer(260) - get_long_path_name_w = ctypes.windll.kernel32.GetLongPathNameW - return_value = get_long_path_name_w(short_path, buf, 260) - - if return_value == 0 or return_value > 260: - # An error occurred - return short_path - else: - long_path = buf.value - # GetLongPathNameW does not change the case of the drive - # letter. - if len(long_path) > 1 and long_path[1] == ':': - long_path = long_path[0].upper() + long_path[1:] - return long_path - - def case_sensitive(path): """Check whether the filesystem at the given path is case sensitive. To work best, the path should point to a file or a directory. If the path does not exist, assume a case sensitive file system on every platform except Windows. + + Currently only used for absolute paths by beets; may have a trailing + path separator. """ - # A fallback in case the path does not exist. - if not os.path.exists(syspath(path)): - # By default, the case sensitivity depends on the platform. - return platform.system() != 'Windows' + # Look at parent paths until we find a path that actually exists, or + # reach the root. + while True: + head, tail = os.path.split(path) + if head == path: + # We have reached the root of the file system. + # By default, the case sensitivity depends on the platform. + return platform.system() != 'Windows' - # If an upper-case version of the path exists but a lower-case - # version does not, then the filesystem must be case-sensitive. - # (Otherwise, we have more work to do.) - if not (os.path.exists(syspath(path.lower())) and - os.path.exists(syspath(path.upper()))): - return True + # Trailing path separator, or path does not exist. + if not tail or not os.path.exists(path): + path = head + continue - # Both versions of the path exist on the file system. Check whether - # they refer to different files by their inodes. Alas, - # `os.path.samefile` is only available on Unix systems on Python 2. - if platform.system() != 'Windows': - return not os.path.samefile(syspath(path.lower()), - syspath(path.upper())) + upper_tail = tail.upper() + lower_tail = tail.lower() - # On Windows, we check whether the canonical, long filenames for the - # files are the same. - lower = _windows_long_path_name(path.lower()) - upper = _windows_long_path_name(path.upper()) - return lower != upper + # In case we can't tell from the given path name, look at the + # parent directory. + if upper_tail == lower_tail: + path = head + continue + + upper_sys = syspath(os.path.join(head, upper_tail)) + lower_sys = syspath(os.path.join(head, lower_tail)) + + # If either the upper-cased or lower-cased path does not exist, the + # filesystem must be case-sensitive. + # (Otherwise, we have more work to do.) + if not os.path.exists(upper_sys) or not os.path.exists(lower_sys): + return True + + # Original and both upper- and lower-cased versions of the path + # exist on the file system. Check whether they refer to different + # files by their inodes (or an alternative method on Windows). + return not os.path.samefile(lower_sys, upper_sys) def raw_seconds_short(string): From 24c0665142c9626ddbdc5aecdeb55cf8b8c9d6d6 Mon Sep 17 00:00:00 2001 From: Serene-Arc <serenical@gmail.com> Date: Mon, 13 Feb 2023 10:00:06 +1000 Subject: [PATCH 45/70] Fix some typings --- beets/autotag/hooks.py | 15 ++++++++++----- beets/autotag/mb.py | 2 +- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index a1cd49d19..f32a26e20 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -500,7 +500,7 @@ class Distance: # Adding components. - def _eq(self, value1: Union['Distance', Patterntype], value2) -> bool: + def _eq(self, value1: Any, value2: Any) -> bool: """Returns True if `value1` is equal to `value2`. `value1` may be a compiled regular expression, in which case it will be matched against `value2`. @@ -551,7 +551,7 @@ class Distance: else: self.add(key, 0.0) - def add_number(self, key: str, number1: float, number2: float): + def add_number(self, key: str, number1: int, number2: int): """Adds a distance penalty of 1.0 for each number of difference between `number1` and `number2`, or 0.0 when there is no difference. Use this when there is no upper limit on the @@ -587,7 +587,12 @@ class Distance: dist = 1.0 self.add(key, dist) - def add_ratio(self, key: str, number1: float, number2: float): + def add_ratio( + self, + key: str, + number1: Union[int, float], + number2: Union[int, float], + ): """Adds a distance penalty for `number1` as a ratio of `number2`. `number1` is bound at 0 and `number2`. """ @@ -642,7 +647,7 @@ def track_for_mbid(recording_id: str) -> Optional[TrackInfo]: exc.log(log) -def albums_for_id(album_id: str) -> Iterable[Union[None, AlbumInfo]]: +def albums_for_id(album_id: str) -> Iterable[AlbumInfo]: """Get a list of albums for an ID.""" a = album_for_mbid(album_id) if a: @@ -653,7 +658,7 @@ def albums_for_id(album_id: str) -> Iterable[Union[None, AlbumInfo]]: yield a -def tracks_for_id(track_id: str) -> Iterable[Union[None, TrackInfo]]: +def tracks_for_id(track_id: str) -> Iterable[TrackInfo]: """Get a list of tracks for an ID.""" t = track_for_mbid(track_id) if t: diff --git a/beets/autotag/mb.py b/beets/autotag/mb.py index edee7972c..77e585c2b 100644 --- a/beets/autotag/mb.py +++ b/beets/autotag/mb.py @@ -590,7 +590,7 @@ def match_track( yield track_info(recording) -def _parse_id(s: str) -> Optional[Union[str, bytes]]: +def _parse_id(s: str) -> Optional[str]: """Search for a MusicBrainz ID in the given string and return it. If no ID can be found, return None. """ From b33c3ce9579b394e2f475b77dd011920d9b5e06d Mon Sep 17 00:00:00 2001 From: Serene-Arc <serenical@gmail.com> Date: Mon, 13 Feb 2023 13:17:12 +1000 Subject: [PATCH 46/70] Fix some typings --- beets/autotag/hooks.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index f32a26e20..95f5eee7b 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -18,7 +18,7 @@ from collections import namedtuple from functools import total_ordering import re from typing import Dict, List, Tuple, Iterator, Union, NewType, Any, Optional, \ - Iterable, Callable + Iterable, Callable, TypeVar from beets import logging from beets import plugins @@ -39,6 +39,7 @@ except AttributeError: Pattern = re.Pattern Patterntype = NewType('Patterntype', re.Pattern) +T = TypeVar('T') # Classes used to represent candidate options. class AttrDict(dict): @@ -500,7 +501,7 @@ class Distance: # Adding components. - def _eq(self, value1: Any, value2: Any) -> bool: + def _eq(self, value1: T, value2: T) -> bool: """Returns True if `value1` is equal to `value2`. `value1` may be a compiled regular expression, in which case it will be matched against `value2`. @@ -525,7 +526,7 @@ class Distance: self, key: str, value: Any, - options: Union[List, Tuple, Patterntype], + options: Union[List[T, ...], Tuple[T, ...], T], ): """Adds a distance penalty of 1.0 if `value` doesn't match any of the values in `options`. If an option is a compiled regular @@ -568,7 +569,7 @@ class Distance: self, key: str, value: Any, - options: Union[List, Tuple, Patterntype], + options: Union[List[T, ...], Tuple[T, ...], T], ): """Adds a distance penalty that corresponds to the position at which `value` appears in `options`. A distance penalty of 0.0 From ef760feada7845eb641cdbb784c7cb97ba9a67ee Mon Sep 17 00:00:00 2001 From: Serene-Arc <serenical@gmail.com> Date: Mon, 13 Feb 2023 13:51:40 +1000 Subject: [PATCH 47/70] Fix style errors --- beets/autotag/hooks.py | 3 ++- beets/autotag/mb.py | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index 95f5eee7b..e122bf660 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -17,7 +17,7 @@ from collections import namedtuple from functools import total_ordering import re -from typing import Dict, List, Tuple, Iterator, Union, NewType, Any, Optional, \ +from typing import Dict, List, Tuple, Iterator, Union, NewType, Any, Optional,\ Iterable, Callable, TypeVar from beets import logging @@ -41,6 +41,7 @@ except AttributeError: T = TypeVar('T') + # Classes used to represent candidate options. class AttrDict(dict): """A dictionary that supports attribute ("dot") access, so `d.field` diff --git a/beets/autotag/mb.py b/beets/autotag/mb.py index 77e585c2b..328805d0a 100644 --- a/beets/autotag/mb.py +++ b/beets/autotag/mb.py @@ -14,8 +14,7 @@ """Searches for albums in the MusicBrainz database. """ -from typing import List, Tuple, Dict, Optional, Iterator, Iterable, AnyStr, \ - Union +from typing import List, Tuple, Dict, Optional, Iterator import musicbrainzngs import re From c0587cef3113315e7478d4398e5023a1190b5148 Mon Sep 17 00:00:00 2001 From: Serene-Arc <serenical@gmail.com> Date: Thu, 16 Feb 2023 16:59:01 +1000 Subject: [PATCH 48/70] Implement PEP 56v3 to avoid circular import --- beets/autotag/hooks.py | 1 + beets/autotag/mb.py | 1 + 2 files changed, 2 insertions(+) diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index e122bf660..910d75010 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -14,6 +14,7 @@ """Glue between metadata sources and the matching logic.""" +from __future__ import annotations from collections import namedtuple from functools import total_ordering import re diff --git a/beets/autotag/mb.py b/beets/autotag/mb.py index 328805d0a..0893cbe0a 100644 --- a/beets/autotag/mb.py +++ b/beets/autotag/mb.py @@ -14,6 +14,7 @@ """Searches for albums in the MusicBrainz database. """ +from __future__ import annotations from typing import List, Tuple, Dict, Optional, Iterator import musicbrainzngs From 13ce920fd185dfca9c130b36eb3eb7a481bf744a Mon Sep 17 00:00:00 2001 From: Mark Trolley <marktrolley@gmail.com> Date: Fri, 17 Feb 2023 18:39:03 -0500 Subject: [PATCH 49/70] Fix cover art archive fetching PR #3748 changed the way cover art is fetched from the cover art archive, but the manual addition of a `-` to the width suffix that was needed when the image URI was being constructed manually was not removed. Because of this the plugin would try to look up the property under `thumbnails` that didn't exist (for example `-1200` instead of `1200`), which would fail. --- beetsplug/fetchart.py | 12 ++++++------ docs/changelog.rst | 2 ++ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index c99c7081f..4a9693e64 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -367,7 +367,7 @@ class CoverArtArchive(RemoteArtSource): ID. """ - def get_image_urls(url, size_suffix=None): + def get_image_urls(url, preferred_width=None): try: response = self.request(url) except requests.RequestException: @@ -387,8 +387,8 @@ class CoverArtArchive(RemoteArtSource): if 'Front' not in item['types']: continue - if size_suffix: - yield item['thumbnails'][size_suffix] + if preferred_width: + yield item['thumbnails'][preferred_width] else: yield item['image'] except KeyError: @@ -401,12 +401,12 @@ class CoverArtArchive(RemoteArtSource): # If the maxwidth config matches one of the already available sizes # fetch it directly intead of fetching the full sized image and # resizing it. - size_suffix = None + preferred_width = None if plugin.maxwidth in self.VALID_THUMBNAIL_SIZES: - size_suffix = "-" + str(plugin.maxwidth) + preferred_width = str(plugin.maxwidth) if 'release' in self.match_by and album.mb_albumid: - for url in get_image_urls(release_url, size_suffix): + for url in get_image_urls(release_url, preferred_width): yield self._candidate(url=url, match=Candidate.MATCH_EXACT) if 'releasegroup' in self.match_by and album.mb_releasegroupid: diff --git a/docs/changelog.rst b/docs/changelog.rst index f572fb26c..918508f7c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -60,6 +60,8 @@ New features: Bug fixes: +* :doc:`/plugins/fetchart`: Fix fetching from Cover Art Archive when the + `maxwidth` option is set to one of the supported Cover Art Archive widths. * :doc:`/plugins/discogs`: Fix "Discogs plugin replacing Feat. or Ft. with a comma" by fixing an oversight that removed a functionality from the code base when the MetadataSourcePlugin abstract class was introduced in PR's From 36b6fb5498e67db2e67acc5f14dbbfc2e17a1216 Mon Sep 17 00:00:00 2001 From: Adrian Sampson <adrian@radbox.org> Date: Sat, 18 Feb 2023 20:33:16 -0800 Subject: [PATCH 50/70] Change some "read more" links --- docs/guides/main.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/guides/main.rst b/docs/guides/main.rst index 2b573ac32..497d237e9 100644 --- a/docs/guides/main.rst +++ b/docs/guides/main.rst @@ -298,9 +298,9 @@ You can always get help using the ``beet help`` command. The plain ``beet help`` command lists all the available commands; then, for example, ``beet help import`` gives more specific help about the ``import`` command. -Please let me know what you think of beets via `the discussion board`_ or -`Twitter`_. +Please let us know what you think of beets via `the discussion board`_ or +`Mastodon`_. .. _the mailing list: https://groups.google.com/group/beets-users -.. _the discussion board: https://discourse.beets.io -.. _twitter: https://twitter.com/b33ts +.. _the discussion board: https://github.com/beetbox/beets/discussions +.. _mastodon: https://fosstodon.org/@beets From 0f013f53ef0061a4459e064ffbf20d8f961cc1ed Mon Sep 17 00:00:00 2001 From: Adrian Sampson <adrian@radbox.org> Date: Sat, 18 Feb 2023 20:37:48 -0800 Subject: [PATCH 51/70] Link to walkthrough on blog Closes #4382. --- docs/guides/main.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/guides/main.rst b/docs/guides/main.rst index 497d237e9..0edbdcbda 100644 --- a/docs/guides/main.rst +++ b/docs/guides/main.rst @@ -298,6 +298,9 @@ You can always get help using the ``beet help`` command. The plain ``beet help`` command lists all the available commands; then, for example, ``beet help import`` gives more specific help about the ``import`` command. +If you need more of a walkthrough, you can read an illustrated one `on the +beets blog <https://beets.io/blog/walkthrough.html>`_. + Please let us know what you think of beets via `the discussion board`_ or `Mastodon`_. From 11a797fb945358aee570e0ab7b58b3681f309f6e Mon Sep 17 00:00:00 2001 From: Serene-Arc <serenical@gmail.com> Date: Sun, 19 Feb 2023 17:51:12 +1000 Subject: [PATCH 52/70] Fix typing --- beets/autotag/hooks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index 910d75010..e5a18e464 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -391,7 +391,7 @@ class Distance: self._penalties = {} @LazyClassProperty - def _weights(cls) -> Dict: # noqa: N805 + def _weights(cls) -> Dict[str, float]: # noqa: N805 """A dictionary from keys to floating-point weights. """ weights_view = config['match']['distance_weights'] From 0b4091166a31643621d7141c6cc9c00bd5d0b96e Mon Sep 17 00:00:00 2001 From: wisp3rwind <17089248+wisp3rwind@users.noreply.github.com> Date: Sun, 19 Feb 2023 13:08:14 +0100 Subject: [PATCH 53/70] typing: fixes according to mypy for autotag.* tighten/loosen/fix some types as required and to the extent that we're actually able to name the relevant types. Used a bunch of `cast(T, value)` to help the type checker, in particular around confuse (that should probably be fixed by typing confuse) and around getters for Item fields (not sure whether we can do anything about that. We could add runtime checks (but what the error handling do?)) --- beets/autotag/hooks.py | 35 +++++++++++----------- beets/autotag/match.py | 67 +++++++++++++++++++++++------------------- beets/autotag/mb.py | 35 +++++++++++++--------- 3 files changed, 75 insertions(+), 62 deletions(-) diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index 8d2680e95..e42a56047 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -19,7 +19,7 @@ from collections import namedtuple from functools import total_ordering import re from typing import Dict, List, Tuple, Iterator, Union, NewType, Any, Optional,\ - Iterable, Callable, TypeVar + Iterable, Callable, TypeVar, cast from beets import logging from beets import plugins @@ -33,9 +33,6 @@ from unidecode import unidecode log = logging.getLogger('beets') -T = TypeVar('T') - - # Classes used to represent candidate options. class AttrDict(dict): """A dictionary that supports attribute ("dot") access, so `d.field` @@ -51,7 +48,7 @@ class AttrDict(dict): def __setattr__(self, key, value): self.__setitem__(key, value) - def __hash__(self) -> int: + def __hash__(self): return id(self) @@ -80,9 +77,9 @@ class AlbumInfo(AttrDict): asin: Optional[str] = None, albumtype: Optional[str] = None, va: bool = False, - year: Optional[str] = None, - month: Optional[str] = None, - day: Optional[str] = None, + year: Optional[int] = None, + month: Optional[int] = None, + day: Optional[int] = None, label: Optional[str] = None, mediums: Optional[int] = None, artist_sort: Optional[str] = None, @@ -98,9 +95,9 @@ class AlbumInfo(AttrDict): albumdisambig: Optional[str] = None, releasegroupdisambig: Optional[str] = None, artist_credit: Optional[str] = None, - original_year: Optional[str] = None, - original_month: Optional[str] = None, - original_day: Optional[str] = None, + original_year: Optional[int] = None, + original_month: Optional[int] = None, + original_day: Optional[int] = None, data_source: Optional[str] = None, data_url: Optional[str] = None, discogs_albumid: Optional[str] = None, @@ -191,7 +188,7 @@ class TrackInfo(AttrDict): release_track_id: Optional[str] = None, artist: Optional[str] = None, artist_id: Optional[str] = None, - length: Optional[str] = None, + length: Optional[float] = None, index: Optional[int] = None, medium: Optional[int] = None, medium_index: Optional[int] = None, @@ -298,7 +295,7 @@ def _string_dist_basic(str1: str, str2: str) -> float: return levenshtein_distance(str1, str2) / float(max(len(str1), len(str2))) -def string_dist(str1: str, str2: str) -> float: +def string_dist(str1: Optional[str], str2: Optional[str]) -> float: """Gives an "intuitive" edit distance between two strings. This is an edit distance, normalized by the string length, with a number of tweaks that reflect intuition about text. @@ -382,6 +379,7 @@ class Distance: def __init__(self): self._penalties = {} + self.tracks: Dict[TrackInfo, Distance] = {} @LazyClassProperty def _weights(cls) -> Dict[str, float]: # noqa: N805 @@ -496,12 +494,13 @@ class Distance: # Adding components. - def _eq(self, value1: T, value2: T) -> bool: + def _eq(self, value1: Union[re.Pattern, Any], value2: Any) -> bool: """Returns True if `value1` is equal to `value2`. `value1` may be a compiled regular expression, in which case it will be matched against `value2`. """ if isinstance(value1, re.Pattern): + value2 = cast(str, value2) return bool(value1.match(value2)) return value1 == value2 @@ -521,7 +520,7 @@ class Distance: self, key: str, value: Any, - options: Union[List[T, ...], Tuple[T, ...], T], + options: Union[List[Any], Tuple[Any, ...], Any], ): """Adds a distance penalty of 1.0 if `value` doesn't match any of the values in `options`. If an option is a compiled regular @@ -564,7 +563,7 @@ class Distance: self, key: str, value: Any, - options: Union[List[T, ...], Tuple[T, ...], T], + options: Union[List[Any], Tuple[Any, ...], Any], ): """Adds a distance penalty that corresponds to the position at which `value` appears in `options`. A distance penalty of 0.0 @@ -599,7 +598,7 @@ class Distance: dist = 0.0 self.add(key, dist) - def add_string(self, key: str, str1: str, str2: str): + def add_string(self, key: str, str1: Optional[str], str2: Optional[str]): """Adds a distance penalty based on the edit distance between `str1` and `str2`. """ @@ -628,6 +627,7 @@ def album_for_mbid(release_id: str) -> Optional[AlbumInfo]: return album except mb.MusicBrainzAPIError as exc: exc.log(log) + return None def track_for_mbid(recording_id: str) -> Optional[TrackInfo]: @@ -641,6 +641,7 @@ def track_for_mbid(recording_id: str) -> Optional[TrackInfo]: return track except mb.MusicBrainzAPIError as exc: exc.log(log) + return None def albums_for_id(album_id: str) -> Iterable[AlbumInfo]: diff --git a/beets/autotag/match.py b/beets/autotag/match.py index bfe11f5e8..d0b31d50a 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -19,7 +19,7 @@ releases and tracks. import datetime import re -from typing import List, Dict, Tuple, Iterable, Union, Optional +from typing import Any, List, Dict, Sequence, Tuple, Iterable, TypeVar, Union, Optional, cast from munkres import Munkres from collections import namedtuple @@ -64,7 +64,7 @@ Proposal = namedtuple('Proposal', ('candidates', 'recommendation')) # Primary matching functionality. -def current_metadata(items: List[Item]) -> Tuple[Dict, Dict]: +def current_metadata(items: Iterable[Item]) -> Tuple[Dict[str, Any], Dict[str, Any]]: """Extract the likely current metadata for an album given a list of its items. Return two dictionaries: - The most common value for each field. @@ -90,9 +90,9 @@ def current_metadata(items: List[Item]) -> Tuple[Dict, Dict]: def assign_items( - items: List[Item], - tracks: List[TrackInfo], -) -> Tuple[Dict, List[Item], List[TrackInfo]]: + items: Sequence[Item], + tracks: Sequence[TrackInfo], +) -> Tuple[Dict[Item, TrackInfo], List[Item], List[TrackInfo]]: """Given a list of Items and a list of TrackInfo objects, find the best mapping between them. Returns a mapping from Items to TrackInfo objects, a set of extra Items, and a set of extra TrackInfo @@ -100,10 +100,10 @@ def assign_items( of objects of the two types. """ # Construct the cost matrix. - costs = [] + costs: List[List[Distance]] = [] for item in items: row = [] - for i, track in enumerate(tracks): + for track in tracks: row.append(track_distance(item, track)) costs.append(row) @@ -141,10 +141,10 @@ def track_distance( # Length. if track_info.length: - diff = abs(item.length - track_info.length) - \ - config['match']['track_length_grace'].as_number() + diff = abs(cast(float, item.length) - track_info.length) - \ + cast(Union[float, int], config['match']['track_length_grace'].as_number()) dist.add_ratio('track_length', diff, - config['match']['track_length_max'].as_number()) + cast(Union[float, int], config['match']['track_length_max'].as_number())) # Title. dist.add_string('track_title', item.title, track_info.title) @@ -169,7 +169,7 @@ def track_distance( def distance( - items: Iterable[Item], + items: Sequence[Item], album_info: AlbumInfo, mapping: Dict[Item, TrackInfo], ) -> Distance: @@ -196,6 +196,7 @@ def distance( if album_info.media: # Preferred media options. patterns = config['match']['preferred']['media'].as_str_seq() + patterns = cast(Sequence, patterns) options = [re.compile(r'(\d+x)?(%s)' % pat, re.I) for pat in patterns] if options: dist.add_priority('media', album_info.media, options) @@ -232,6 +233,7 @@ def distance( # Preferred countries. patterns = config['match']['preferred']['countries'].as_str_seq() + patterns = cast(Sequence, patterns) options = [re.compile(pat, re.I) for pat in patterns] if album_info.country and options: dist.add_priority('country', album_info.country, options) @@ -265,11 +267,11 @@ def distance( dist.add('tracks', dist.tracks[track].distance) # Missing tracks. - for i in range(len(album_info.tracks) - len(mapping)): + for _ in range(len(album_info.tracks) - len(mapping)): dist.add('missing_tracks', 1.0) # Unmatched tracks. - for i in range(len(items) - len(mapping)): + for _ in range(len(items) - len(mapping)): dist.add('unmatched_tracks', 1.0) # Plugins. @@ -303,7 +305,7 @@ def match_by_id(items: Iterable[Item]): def _recommendation( - results: List[Union[AlbumMatch, TrackMatch]], + results: Sequence[Union[AlbumMatch, TrackMatch]], ) -> Recommendation: """Given a sorted list of AlbumMatch or TrackMatch objects, return a recommendation based on the results' distances. @@ -355,12 +357,14 @@ def _recommendation( return rec -def _sort_candidates(candidates) -> Iterable: +AnyMatch = TypeVar("AnyMatch", TrackMatch, AlbumMatch) + +def _sort_candidates(candidates: Iterable[AnyMatch]) -> Sequence[AnyMatch]: """Sort candidates by distance.""" return sorted(candidates, key=lambda match: match.distance) -def _add_candidate(items: Iterable[Item], results: Dict, info: AlbumInfo): +def _add_candidate(items: Sequence[Item], results: Dict[Any, AlbumMatch], info: AlbumInfo): """Given a candidate AlbumInfo object, attempt to add the candidate to the output dictionary of AlbumMatch objects. This involves checking the track count, ordering the items, checking for @@ -380,7 +384,7 @@ def _add_candidate(items: Iterable[Item], results: Dict, info: AlbumInfo): return # Discard matches without required tags. - for req_tag in config['match']['required'].as_str_seq(): + for req_tag in cast(Sequence, config['match']['required'].as_str_seq()): if getattr(info, req_tag) is None: log.debug('Ignored. Missing required tag: {0}', req_tag) return @@ -393,7 +397,7 @@ def _add_candidate(items: Iterable[Item], results: Dict, info: AlbumInfo): # Skip matches with ignored penalties. penalties = [key for key, _ in dist] - for penalty in config['match']['ignored'].as_str_seq(): + for penalty in cast(Sequence[str], config['match']['ignored'].as_str_seq()): if penalty in penalties: log.debug('Ignored. Penalty: {0}', penalty) return @@ -428,20 +432,19 @@ def tag_album( """ # Get current metadata. likelies, consensus = current_metadata(items) - cur_artist = likelies['artist'] - cur_album = likelies['album'] + cur_artist = cast(str, likelies['artist']) + cur_album = cast(str, likelies['album']) log.debug('Tagging {0} - {1}', cur_artist, cur_album) - # The output result (distance, AlbumInfo) tuples (keyed by MB album - # ID). - candidates = {} + # The output result, keys are the MB album ID. + candidates: Dict[Any, AlbumMatch] = {} # Search by explicit ID. if search_ids: for search_id in search_ids: log.debug('Searching for album ID: {0}', search_id) - for id_candidate in hooks.albums_for_id(search_id): - _add_candidate(items, candidates, id_candidate) + for album_info_for_id in hooks.albums_for_id(search_id): + _add_candidate(items, candidates, album_info_for_id) # Use existing metadata or text search. else: @@ -488,9 +491,9 @@ def tag_album( log.debug('Evaluating {0} candidates.', len(candidates)) # Sort and get the recommendation. - candidates = _sort_candidates(candidates.values()) - rec = _recommendation(candidates) - return cur_artist, cur_album, Proposal(candidates, rec) + candidates_sorted = _sort_candidates(candidates.values()) + rec = _recommendation(candidates_sorted) + return cur_artist, cur_album, Proposal(candidates_sorted, rec) def tag_item( @@ -510,6 +513,7 @@ def tag_item( # Holds candidates found so far: keys are MBIDs; values are # (distance, TrackInfo) pairs. candidates = {} + rec: Optional[Recommendation] = None # First, try matching by MusicBrainz ID. trackids = search_ids or [t for t in [item.mb_trackid] if t] @@ -530,6 +534,7 @@ def tag_item( # If we're searching by ID, don't proceed. if search_ids: if candidates: + assert rec is not None return Proposal(_sort_candidates(candidates.values()), rec) else: return Proposal([], Recommendation.none) @@ -546,6 +551,6 @@ def tag_item( # Sort by distance and return with recommendation. log.debug('Found {0} candidates.', len(candidates)) - candidates = _sort_candidates(candidates.values()) - rec = _recommendation(candidates) - return Proposal(candidates, rec) + candidates_sorted = _sort_candidates(candidates.values()) + rec = _recommendation(candidates_sorted) + return Proposal(candidates_sorted, rec) diff --git a/beets/autotag/mb.py b/beets/autotag/mb.py index 0893cbe0a..7d7932faf 100644 --- a/beets/autotag/mb.py +++ b/beets/autotag/mb.py @@ -15,7 +15,7 @@ """Searches for albums in the MusicBrainz database. """ from __future__ import annotations -from typing import List, Tuple, Dict, Optional, Iterator +from typing import Any, List, Sequence, Tuple, Dict, Optional, Iterator, cast import musicbrainzngs import re @@ -140,12 +140,13 @@ def _preferred_alias(aliases: List): return matches[0] -def _preferred_release_event(release: Dict) -> Tuple[str, str]: +def _preferred_release_event(release: Dict[str, Any]) -> Tuple[str, str]: """Given a release, select and return the user's preferred release event as a tuple of (country, release_date). Fall back to the default release event if a preferred event is not found. """ countries = config['match']['preferred']['countries'].as_str_seq() + countries = cast(Sequence, countries) for country in countries: for event in release.get('release-event-list', {}): @@ -155,7 +156,10 @@ def _preferred_release_event(release: Dict) -> Tuple[str, str]: except KeyError: pass - return release.get('country'), release.get('date') + return ( + cast(str, release.get('country')), + cast(str, release.get('date')) + ) def _flatten_artist_credit(credit: List[Dict]) -> Tuple[str, str, str]: @@ -258,7 +262,7 @@ def track_info( ) if recording.get('length'): - info.length = int(recording['length']) / (1000.0) + info.length = int(recording['length']) / 1000.0 info.trackdisambig = recording.get('disambiguation') @@ -498,12 +502,14 @@ def album_info(release: Dict) -> beets.autotag.hooks.AlbumInfo: release['release-group'].get('genre-list', []), release.get('genre-list', []), ] - genres = Counter() + genres: Counter[str] = Counter() for source in sources: for genreitem in source: genres[genreitem['name']] += int(genreitem['count']) - info.genre = '; '.join(g[0] for g in sorted(genres.items(), - key=lambda g: -g[1])) + info.genre = '; '.join( + genre for genre, _count + in sorted(genres.items(), key=lambda g: -g[1]) + ) extra_albumdatas = plugins.send('mb_album_extract', data=release) for extra_albumdata in extra_albumdatas: @@ -517,7 +523,7 @@ def match_album( artist: str, album: str, tracks: Optional[int] = None, - extra_tags: Dict = None, + extra_tags: Optional[Dict[str, Any]] = None, ) -> Iterator[beets.autotag.hooks.AlbumInfo]: """Searches for a single album ("release" in MusicBrainz parlance) and returns an iterator over AlbumInfo objects. May raise a @@ -538,9 +544,9 @@ def match_album( # Additional search cues from existing metadata. if extra_tags: - for tag in extra_tags: + for tag, value in extra_tags.items(): key = FIELDS_TO_MB_KEYS[tag] - value = str(extra_tags.get(tag, '')).lower().strip() + value = str(value).lower().strip() if key == 'catno': value = value.replace(' ', '') if value: @@ -596,8 +602,9 @@ def _parse_id(s: str) -> Optional[str]: """ # Find the first thing that looks like a UUID/MBID. match = re.search('[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}', s) - if match: - return match.group() + if match is not None: + return match.group() if match else None + return None def album_for_id(releaseid: str) -> Optional[beets.autotag.hooks.AlbumInfo]: @@ -609,7 +616,7 @@ def album_for_id(releaseid: str) -> Optional[beets.autotag.hooks.AlbumInfo]: albumid = _parse_id(releaseid) if not albumid: log.debug('Invalid MBID ({0}).', releaseid) - return + return None try: res = musicbrainzngs.get_release_by_id(albumid, RELEASE_INCLUDES) @@ -629,7 +636,7 @@ def track_for_id(releaseid: str) -> Optional[beets.autotag.hooks.TrackInfo]: trackid = _parse_id(releaseid) if not trackid: log.debug('Invalid MBID ({0}).', releaseid) - return + return None try: res = musicbrainzngs.get_recording_by_id(trackid, TRACK_INCLUDES) except musicbrainzngs.ResponseError: From c389087319ede5bb033afd714dce53b1fe0ad648 Mon Sep 17 00:00:00 2001 From: wisp3rwind <17089248+wisp3rwind@users.noreply.github.com> Date: Sun, 19 Feb 2023 13:32:52 +0100 Subject: [PATCH 54/70] autotag: style fixes --- beets/autotag/hooks.py | 4 ++-- beets/autotag/match.py | 43 ++++++++++++++++++++++++++++++++++-------- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index e42a56047..af5ddc42d 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -18,8 +18,8 @@ from __future__ import annotations from collections import namedtuple from functools import total_ordering import re -from typing import Dict, List, Tuple, Iterator, Union, NewType, Any, Optional,\ - Iterable, Callable, TypeVar, cast +from typing import Dict, List, Tuple, Iterator, Union, Any, Optional,\ + Iterable, Callable, cast from beets import logging from beets import plugins diff --git a/beets/autotag/match.py b/beets/autotag/match.py index d0b31d50a..10fe2b24c 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -19,7 +19,18 @@ releases and tracks. import datetime import re -from typing import Any, List, Dict, Sequence, Tuple, Iterable, TypeVar, Union, Optional, cast +from typing import ( + Any, + Dict, + Iterable, + List, + Optional, + Sequence, + Tuple, + TypeVar, + Union, + cast, +) from munkres import Munkres from collections import namedtuple @@ -64,7 +75,9 @@ Proposal = namedtuple('Proposal', ('candidates', 'recommendation')) # Primary matching functionality. -def current_metadata(items: Iterable[Item]) -> Tuple[Dict[str, Any], Dict[str, Any]]: +def current_metadata( + items: Iterable[Item], +) -> Tuple[Dict[str, Any], Dict[str, Any]]: """Extract the likely current metadata for an album given a list of its items. Return two dictionaries: - The most common value for each field. @@ -141,10 +154,18 @@ def track_distance( # Length. if track_info.length: - diff = abs(cast(float, item.length) - track_info.length) - \ - cast(Union[float, int], config['match']['track_length_grace'].as_number()) - dist.add_ratio('track_length', diff, - cast(Union[float, int], config['match']['track_length_max'].as_number())) + item_length = cast(float, item.length) + track_length_grace = cast( + Union[float, int], + config['match']['track_length_grace'].as_number(), + ) + track_length_max = cast( + Union[float, int], + config['match']['track_length_max'].as_number(), + ) + + diff = abs(item_length - track_info.length) - track_length_grace + dist.add_ratio('track_length', diff, track_length_max) # Title. dist.add_string('track_title', item.title, track_info.title) @@ -359,12 +380,17 @@ def _recommendation( AnyMatch = TypeVar("AnyMatch", TrackMatch, AlbumMatch) + def _sort_candidates(candidates: Iterable[AnyMatch]) -> Sequence[AnyMatch]: """Sort candidates by distance.""" return sorted(candidates, key=lambda match: match.distance) -def _add_candidate(items: Sequence[Item], results: Dict[Any, AlbumMatch], info: AlbumInfo): +def _add_candidate( + items: Sequence[Item], + results: Dict[Any, AlbumMatch], + info: AlbumInfo, +): """Given a candidate AlbumInfo object, attempt to add the candidate to the output dictionary of AlbumMatch objects. This involves checking the track count, ordering the items, checking for @@ -397,7 +423,8 @@ def _add_candidate(items: Sequence[Item], results: Dict[Any, AlbumMatch], info: # Skip matches with ignored penalties. penalties = [key for key, _ in dist] - for penalty in cast(Sequence[str], config['match']['ignored'].as_str_seq()): + ignored = cast(Sequence[str], config['match']['ignored'].as_str_seq()) + for penalty in ignored: if penalty in penalties: log.debug('Ignored. Penalty: {0}', penalty) return From 55255b00872052244395a011394de31119631bb1 Mon Sep 17 00:00:00 2001 From: Mark Trolley <marktrolley@gmail.com> Date: Sun, 19 Feb 2023 16:13:52 -0500 Subject: [PATCH 55/70] Deprecate absubmit and update acousticbrainz plugins Fixes #4627. AcousticBrainz is shutting down as of early 2023. Deprecate the absubmit plugin and update the acousticbrainz plugin to require configuration of an AcousticBrainz server instance. --- beets/autotag/hooks.py | 2 +- beetsplug/absubmit.py | 34 +++++++++++++++++++++++++-------- beetsplug/acousticbrainz.py | 25 +++++++++++++++++++----- docs/changelog.rst | 6 ++++++ docs/plugins/absubmit.rst | 19 +++++++++++++++--- docs/plugins/acousticbrainz.rst | 19 +++++++++++++++--- docs/plugins/index.rst | 2 +- 7 files changed, 86 insertions(+), 21 deletions(-) diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index 8d2680e95..5f33cef28 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -18,7 +18,7 @@ from __future__ import annotations from collections import namedtuple from functools import total_ordering import re -from typing import Dict, List, Tuple, Iterator, Union, NewType, Any, Optional,\ +from typing import Dict, List, Tuple, Iterator, Union, Any, Optional,\ Iterable, Callable, TypeVar from beets import logging diff --git a/beetsplug/absubmit.py b/beetsplug/absubmit.py index d1ea692f8..a32889440 100644 --- a/beetsplug/absubmit.py +++ b/beetsplug/absubmit.py @@ -56,10 +56,13 @@ class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin): def __init__(self): super().__init__() + self._log.warning("This plugin is deprecated.") + self.config.add({ 'extractor': '', 'force': False, - 'pretend': False + 'pretend': False, + 'base_url': '' }) self.extractor = self.config['extractor'].as_str() @@ -79,7 +82,7 @@ class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin): except OSError: raise ui.UserError( 'No extractor command found: please install the extractor' - ' binary from https://acousticbrainz.org/download' + ' binary from https://essentia.upf.edu/' ) except ABSubmitError: # Extractor found, will exit with an error if not called with @@ -96,7 +99,15 @@ class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin): self.extractor_sha.update(extractor.read()) self.extractor_sha = self.extractor_sha.hexdigest() - base_url = 'https://acousticbrainz.org/api/v1/{mbid}/low-level' + self.url = '' + base_url = self.config['base_url'].as_str() + if base_url: + if not base_url.startswith('http'): + raise ui.UserError('AcousticBrainz server base URL must start ' + 'with an HTTP scheme') + elif base_url[-1] != '/': + base_url = base_url + '/' + self.url = base_url + '{mbid}/low-level' def commands(self): cmd = ui.Subcommand( @@ -118,10 +129,17 @@ only files which would be processed' return [cmd] def command(self, lib, opts, args): - # Get items from arguments - items = lib.items(ui.decargs(args)) - self.opts = opts - util.par_map(self.analyze_submit, items) + if not self.url: + raise ui.UserError( + 'This plugin is deprecated since AcousticBrainz no longer ' + 'accepts new submissions. See the base_url configuration ' + 'option.' + ) + else: + # Get items from arguments + items = lib.items(ui.decargs(args)) + self.opts = opts + util.par_map(self.analyze_submit, items) def analyze_submit(self, item): analysis = self._get_analysis(item) @@ -179,7 +197,7 @@ only files which would be processed' def _submit_data(self, item, data): mbid = item['mb_trackid'] headers = {'Content-Type': 'application/json'} - response = requests.post(self.base_url.format(mbid=mbid), + response = requests.post(self.url.format(mbid=mbid), json=data, headers=headers) # Test that request was successful and raise an error on failure. if response.status_code != 200: diff --git a/beetsplug/acousticbrainz.py b/beetsplug/acousticbrainz.py index 0cfd6e318..cda0012cf 100644 --- a/beetsplug/acousticbrainz.py +++ b/beetsplug/acousticbrainz.py @@ -22,7 +22,6 @@ import requests from beets import plugins, ui from beets.dbcore import types -ACOUSTIC_BASE = "https://acousticbrainz.org/" LEVELS = ["/low-level", "/high-level"] ABSCHEME = { 'highlevel': { @@ -138,12 +137,23 @@ class AcousticPlugin(plugins.BeetsPlugin): def __init__(self): super().__init__() + self._log.warning("This plugin is deprecated.") + self.config.add({ 'auto': True, 'force': False, - 'tags': [] + 'tags': [], + 'base_url': '' }) + self.base_url = self.config['base_url'].as_str() + if self.base_url: + if not self.base_url.startswith('http'): + raise ui.UserError('AcousticBrainz server base URL must start ' + 'with an HTTP scheme') + elif self.base_url[-1] != '/': + self.base_url = self.base_url + '/' + if self.config['auto']: self.register_listener('import_task_files', self.import_task_files) @@ -171,8 +181,13 @@ class AcousticPlugin(plugins.BeetsPlugin): self._fetch_info(task.imported_items(), False, True) def _get_data(self, mbid): + if not self.base_url: + raise ui.UserError( + 'This plugin is deprecated since AcousticBrainz has shut ' + 'down. See the base_url configuration option.' + ) data = {} - for url in _generate_urls(mbid): + for url in _generate_urls(self.base_url, mbid): self._log.debug('fetching URL: {}', url) try: @@ -328,8 +343,8 @@ class AcousticPlugin(plugins.BeetsPlugin): 'because key {} was not found', subdata, v, k) -def _generate_urls(mbid): +def _generate_urls(base_url, mbid): """Generates AcousticBrainz end point urls for given `mbid`. """ for level in LEVELS: - yield ACOUSTIC_BASE + mbid + level + yield base_url + mbid + level diff --git a/docs/changelog.rst b/docs/changelog.rst index 3fe976ee6..921ae7b30 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -155,6 +155,12 @@ For packagers: Other changes: +* :doc:`/plugins/absubmit`: Deprecate the ``absubmit`` plugin since + AcousticBrainz has stopped accepting new submissions. + :bug:`4627` +* :doc:`/plugins/acousticbrainz`: Deprecate the ``acousticbrainz`` plugin + since the AcousticBrainz project has shut down. + :bug:`4627` * :doc:`/plugins/limit`: Limit query results to head or tail (``lslimit`` command only) * :doc:`/plugins/fish`: Add ``--output`` option. diff --git a/docs/plugins/absubmit.rst b/docs/plugins/absubmit.rst index e26032edb..884eac524 100644 --- a/docs/plugins/absubmit.rst +++ b/docs/plugins/absubmit.rst @@ -1,8 +1,17 @@ AcousticBrainz Submit Plugin ============================ -The ``absubmit`` plugin lets you submit acoustic analysis results to the -`AcousticBrainz`_ server. +The ``absubmit`` plugin lets you submit acoustic analysis results to an +`AcousticBrainz`_ server. This plugin is now deprecated since the +AcousicBrainz project has been shut down. + +As an alternative the `beets-xtractor`_ plugin can be used. + +Warning +------- + +The AcousticBrainz project has shut down. To use this plugin you must set the +``base_url`` configuration option to a server offering the AcousticBrainz API. Installation ------------ @@ -57,10 +66,14 @@ file. The available options are: - **pretend**: Do not analyze and submit of AcousticBrainz data but print out the items which would be processed. Default: ``no``. +- **base_url**: The base URL of the AcousticBrainz server. The plugin has no + function if this option is not set. + Default: None -.. _streaming_extractor_music: https://acousticbrainz.org/download +.. _streaming_extractor_music: https://essentia.upf.edu/ .. _FAQ: https://acousticbrainz.org/faq .. _pip: https://pip.pypa.io .. _requests: https://requests.readthedocs.io/en/master/ .. _github: https://github.com/MTG/essentia .. _AcousticBrainz: https://acousticbrainz.org +.. _beets-xtractor: https://github.com/adamjakab/BeetsPluginXtractor diff --git a/docs/plugins/acousticbrainz.rst b/docs/plugins/acousticbrainz.rst index 7d7aed237..3a053e123 100644 --- a/docs/plugins/acousticbrainz.rst +++ b/docs/plugins/acousticbrainz.rst @@ -2,9 +2,13 @@ AcousticBrainz Plugin ===================== The ``acousticbrainz`` plugin gets acoustic-analysis information from the -`AcousticBrainz`_ project. +`AcousticBrainz`_ project. This plugin is now deprecated since the +AcousicBrainz project has been shut down. + +As an alternative the `beets-xtractor`_ plugin can be used. .. _AcousticBrainz: https://acousticbrainz.org/ +.. _beets-xtractor: https://github.com/adamjakab/BeetsPluginXtractor Enable the ``acousticbrainz`` plugin in your configuration (see :ref:`using-plugins`) and run it by typing:: @@ -44,6 +48,12 @@ these fields: * ``tonal`` * ``voice_instrumental`` +Warning +------- + +The AcousticBrainz project has shut down. To use this plugin you must set the +``base_url`` configuration option to a server offering the AcousticBrainz API. + Automatic Tagging ----------------- @@ -56,7 +66,7 @@ Configuration ------------- To configure the plugin, make a ``acousticbrainz:`` section in your -configuration file. There are three options: +configuration file. The available options are: - **auto**: Enable AcousticBrainz during ``beet import``. Default: ``yes``. @@ -64,4 +74,7 @@ configuration file. There are three options: it. Default: ``no``. - **tags**: Which tags from the list above to set on your files. - Default: [] (all) + Default: [] (all). +- **base_url**: The base URL of the AcousticBrainz server. The plugin has no + function if this option is not set. + Default: None diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 8404ce716..1c8aaf760 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -156,7 +156,7 @@ Metadata -------- :doc:`absubmit <absubmit>` - Analyse audio with the `streaming_extractor_music`_ program and submit the metadata to the AcousticBrainz server + Analyse audio with the `streaming_extractor_music`_ program and submit the metadata to an AcousticBrainz server :doc:`acousticbrainz <acousticbrainz>` Fetch various AcousticBrainz metadata From 41f9ecc73b66ab1a775e5609a0cc7b38b8300b8c Mon Sep 17 00:00:00 2001 From: Jonathan Matthews <github@hello.jonathanmatthews.com> Date: Fri, 11 Nov 2022 23:44:44 +0200 Subject: [PATCH 56/70] Introduce new DB type: DelimeteredString --- beets/dbcore/types.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/beets/dbcore/types.py b/beets/dbcore/types.py index 40f6a0805..8ab3bcfa2 100644 --- a/beets/dbcore/types.py +++ b/beets/dbcore/types.py @@ -207,6 +207,29 @@ class String(Type): else: return self.model_type(value) +class DelimeteredString(String): + """A list of Unicode strings, represented in-database by a single string + containing delimiter-separated values. + """ + model_type = list + + def __init__(self, delim=','): + self.delim = delim + + def to_sql(self, model_value): + return self.delim.join([str(elem) for elem in model_value]) + + def from_sql(self, sql_value): + if sql_value is None: + return self.null() + else: + return self.parse(sql_value) + + def parse(self, string): + try: + return string.split(self.delim) + except: + return self.null class Boolean(Type): """A boolean type. @@ -231,3 +254,4 @@ FLOAT = Float() NULL_FLOAT = NullFloat() STRING = String() BOOLEAN = Boolean() +SEMICOLON_DSV = DelimeteredString(delim=';') From af65c6d70715e5b828f8a380bd8f52c416a9b087 Mon Sep 17 00:00:00 2001 From: Jonathan Matthews <github@hello.jonathanmatthews.com> Date: Fri, 11 Nov 2022 23:45:59 +0200 Subject: [PATCH 57/70] Serialise albumtypes field as a semicolon-based DelimeteredString --- beets/autotag/mb.py | 2 +- beets/library.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/beets/autotag/mb.py b/beets/autotag/mb.py index 7d7932faf..f380cd033 100644 --- a/beets/autotag/mb.py +++ b/beets/autotag/mb.py @@ -466,7 +466,7 @@ def album_info(release: Dict) -> beets.autotag.hooks.AlbumInfo: if release['release-group']['secondary-type-list']: for sec_type in release['release-group']['secondary-type-list']: albumtypes.append(sec_type.lower()) - info.albumtypes = '; '.join(albumtypes) + info.albumtypes = albumtypes # Release events. info.country, release_date = _preferred_release_event(release) diff --git a/beets/library.py b/beets/library.py index c05dcda18..69cf8d349 100644 --- a/beets/library.py +++ b/beets/library.py @@ -504,7 +504,7 @@ class Item(LibModel): 'mb_releasetrackid': types.STRING, 'trackdisambig': types.STRING, 'albumtype': types.STRING, - 'albumtypes': types.STRING, + 'albumtypes': types.SEMICOLON_DSV, 'label': types.STRING, 'acoustid_fingerprint': types.STRING, 'acoustid_id': types.STRING, @@ -1064,7 +1064,7 @@ class Album(LibModel): 'mb_albumid': types.STRING, 'mb_albumartistid': types.STRING, 'albumtype': types.STRING, - 'albumtypes': types.STRING, + 'albumtypes': types.SEMICOLON_DSV, 'label': types.STRING, 'mb_releasegroupid': types.STRING, 'asin': types.STRING, From 7cfc39ea27c0c9c1bc357bf32a446920957bc402 Mon Sep 17 00:00:00 2001 From: Jonathan Matthews <github@hello.jonathanmatthews.com> Date: Mon, 12 Dec 2022 10:34:40 +0000 Subject: [PATCH 58/70] Realign with known-working code after review by @mkhl @mkhl was kind enough to do a drive-by review of my proposed changes, which I'll include here as the GitHub URI may bit-rot over time (it's technically [here](https://github.com/beetbox/beets/commit/bc21caa0d5665c091683f885ee5b0c59110fca74), but that commit isn't part of the `beets` repo, so may get GC'd). I've encorporated all their proposed changes, as their code is being run against an existing Beets library, whereas my changes were made as I tried to set up Beets for the first time - thus I'm inclined to trust their known-working code more than my own! This is a review starting at https://github.com/beetbox/beets/commit/bc21caa0d5665c091683f885ee5b0c59110fca74#diff-d53f73df7f26990645e7bdac865ef86a52b67bafc6fe6ad69890b510a57e2955R210 (`class DelimeteredString(String):`) > for context this is the version i'm using now: > > ```python > class DelimitedString(String): > model_type = list > > def __init__(self, delimiter): > self.delimiter = delimiter > > def format(self, value): > return self.delimiter.join(value) > > def parse(self, string): > if not string: > return [] > return string.split(self.delimiter) > > def to_sql(self, model_value): > return self.delimiter.join(model_value) > ``` > > i think 'delimited string' is the correct term here > > the rest of the code doesn't seem to use many abbreviations, so calling the property `delimiter` seems appropriate > > i don't think a default value for the delimiter makes a lot of sense? > > the list comprehension and string conversions in `to_sql` don't seem necessary to me, see above. did you run into trouble without them? > > the `from_sql` seems to just be missing functionality from the `Type` parent and seems completely unnecessary > > `parse` shouldn't be able to fail because at that point, we've ensured that its argument is actually a string. i also added a `if not string` condition because otherwise the empty list of album types would turn into the list containing the empty string (because that's what split returns) > > if we don't define a `format` method here we print the internal python representation of the values (i.e. `['album', 'live']` or somesuch) in the `beet write` output. joining on the delimiter nicely formats the output :) > > just so i don't ping you twice unnecessarily, i think it's better to instantiate this type with `'; '` (semicolon space) as the delimiter, because that's what was used before to join the albumtypes and what we'll find in the database All these changes have been made, including the switch from `;` to `;<space>` as the in-DB separator. --- beets/dbcore/types.py | 30 ++++++++++++++---------------- beets/library.py | 4 ++-- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/beets/dbcore/types.py b/beets/dbcore/types.py index 8ab3bcfa2..8c8bfa3f6 100644 --- a/beets/dbcore/types.py +++ b/beets/dbcore/types.py @@ -207,29 +207,27 @@ class String(Type): else: return self.model_type(value) -class DelimeteredString(String): + +class DelimitedString(String): """A list of Unicode strings, represented in-database by a single string containing delimiter-separated values. """ model_type = list - def __init__(self, delim=','): - self.delim = delim + def __init__(self, delimiter): + self.delimiter = delimiter - def to_sql(self, model_value): - return self.delim.join([str(elem) for elem in model_value]) - - def from_sql(self, sql_value): - if sql_value is None: - return self.null() - else: - return self.parse(sql_value) + def format(self, value): + return self.delimiter.join(value) def parse(self, string): - try: - return string.split(self.delim) - except: - return self.null + if not string: + return [] + return string.split(self.delimiter) + + def to_sql(self, model_value): + return self.delimiter.join(model_value) + class Boolean(Type): """A boolean type. @@ -254,4 +252,4 @@ FLOAT = Float() NULL_FLOAT = NullFloat() STRING = String() BOOLEAN = Boolean() -SEMICOLON_DSV = DelimeteredString(delim=';') +SEMICOLON_SPACE_DSV = DelimitedString(delimiter='; ') diff --git a/beets/library.py b/beets/library.py index 69cf8d349..9d5219b18 100644 --- a/beets/library.py +++ b/beets/library.py @@ -504,7 +504,7 @@ class Item(LibModel): 'mb_releasetrackid': types.STRING, 'trackdisambig': types.STRING, 'albumtype': types.STRING, - 'albumtypes': types.SEMICOLON_DSV, + 'albumtypes': types.SEMICOLON_SPACE_DSV, 'label': types.STRING, 'acoustid_fingerprint': types.STRING, 'acoustid_id': types.STRING, @@ -1064,7 +1064,7 @@ class Album(LibModel): 'mb_albumid': types.STRING, 'mb_albumartistid': types.STRING, 'albumtype': types.STRING, - 'albumtypes': types.SEMICOLON_DSV, + 'albumtypes': types.SEMICOLON_SPACE_DSV, 'label': types.STRING, 'mb_releasegroupid': types.STRING, 'asin': types.STRING, From cd52a05d3affc61bf33050a81c8f8fd919b6e761 Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Mon, 27 Feb 2023 13:29:30 +0100 Subject: [PATCH 59/70] Add fix for #4528 to changelog --- docs/changelog.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 921ae7b30..f00288d20 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -145,6 +145,9 @@ Bug fixes: :bug:`4561` :bug:`4600` * Fix issue where deletion of flexible fields on an album doesn't cascade to items :bug:`4662` +* Store ``albumtypes`` multi-value field correctly in the DB and in files' + tags, stopping useless re-tagging of files on every ``beets write``. + :bug:`4528` For packagers: From 27218a94909f0244ea7c7650e7dd3a10d5c0acb1 Mon Sep 17 00:00:00 2001 From: Jonathan Matthews <github@hello.jonathanmatthews.com> Date: Sun, 18 Dec 2022 12:39:20 +0000 Subject: [PATCH 60/70] Mark albumtype/s expected test failure as fixed --- test/test_ui.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/test/test_ui.py b/test/test_ui.py index 86c40d204..a1e02aaae 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -701,27 +701,30 @@ class UpdateTest(_common.TestCase): item = self.lib.items().get() self.assertEqual(item.title, 'full') - @unittest.expectedFailure def test_multivalued_albumtype_roundtrip(self): # https://github.com/beetbox/beets/issues/4528 # albumtypes is empty for our test fixtures, so populate it first album = self.album - # setting albumtypes does not set albumtype currently... - # FIXME: When actually fixing the issue 4528, consider whether this - # should be set to "album" or ["album"] - album.albumtype = "album" - album.albumtypes = "album" + correct_albumtypes = ["album", "live"] + + # Setting albumtypes does not set albumtype, currently. + # Using x[0] mirrors https://github.com/beetbox/mediafile/blob/057432ad53b3b84385e5582f69f44dc00d0a725d/mediafile.py#L1928 + correct_albumtype = correct_albumtypes[0] + + album.albumtype = correct_albumtype + album.albumtypes = correct_albumtypes album.try_sync(write=True, move=False) album.load() - albumtype_before = album.albumtype - self.assertEqual(albumtype_before, "album") + self.assertEqual(album.albumtype, correct_albumtype) + self.assertEqual(album.albumtypes, correct_albumtypes) self._update() album.load() - self.assertEqual(albumtype_before, album.albumtype) + self.assertEqual(album.albumtype, correct_albumtype) + self.assertEqual(album.albumtypes, correct_albumtypes) class PrintTest(_common.TestCase): From 93fa19f493fb0e2354c55d3429d66e23862d1465 Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Sat, 25 Feb 2023 07:46:33 +0100 Subject: [PATCH 61/70] Fix albumtypes plugin and its tests The new database type DelimitedString does list to string and vice versa conversions itself. --- beetsplug/albumtypes.py | 2 +- test/test_albumtypes.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/beetsplug/albumtypes.py b/beetsplug/albumtypes.py index 47f8dc64e..b54e802e6 100644 --- a/beetsplug/albumtypes.py +++ b/beetsplug/albumtypes.py @@ -55,7 +55,7 @@ class AlbumTypesPlugin(BeetsPlugin): bracket_r = '' res = '' - albumtypes = item.albumtypes.split('; ') + albumtypes = item.albumtypes is_va = item.mb_albumartistid == VARIOUS_ARTISTS_ID for type in types: if type[0] in albumtypes and type[1]: diff --git a/test/test_albumtypes.py b/test/test_albumtypes.py index 91808553d..3d329dd7b 100644 --- a/test/test_albumtypes.py +++ b/test/test_albumtypes.py @@ -106,6 +106,6 @@ class AlbumTypesPluginTest(unittest.TestCase, TestHelper): def _create_album(self, album_types: [str], artist_id: str = 0): return self.add_album( - albumtypes='; '.join(album_types), + albumtypes=album_types, mb_albumartistid=artist_id ) From 7be1eec762ec1e315dec734c322211eafd3ed892 Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Mon, 27 Feb 2023 13:56:35 +0100 Subject: [PATCH 62/70] Rewrite changelog entry for #4583 and include linking to manual fixing tutorial. --- docs/changelog.rst | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index f00288d20..b71bc1104 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -145,9 +145,13 @@ Bug fixes: :bug:`4561` :bug:`4600` * Fix issue where deletion of flexible fields on an album doesn't cascade to items :bug:`4662` -* Store ``albumtypes`` multi-value field correctly in the DB and in files' - tags, stopping useless re-tagging of files on every ``beets write``. - :bug:`4528` +* Fix issue where ``beet write`` continuosly retags the ``albumtypes`` metadata + field in files. Additionally broken data could have been added to the library + when the tag was read from file back into the library using ``beet update``. + It is required for all users to **check if such broken data is present in the + library**. Following the instructions `described here + <https://github.com/beetbox/beets/pull/4582#issuecomment-1445023493>`_, a + sanity check and potential fix is easily possible. :bug:`4528` For packagers: From 78853cc9c2aa562ee0e7bffc3ce7b07025d1e417 Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Tue, 28 Feb 2023 08:31:30 +0100 Subject: [PATCH 63/70] Add note to albumtypes plugin docs about #4528 Add a note to the docs of the albumtypes plugin warning about issue #4528 and linking to the manual fixing description. --- docs/plugins/albumtypes.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/plugins/albumtypes.rst b/docs/plugins/albumtypes.rst index 7a1a08f95..bf736abca 100644 --- a/docs/plugins/albumtypes.rst +++ b/docs/plugins/albumtypes.rst @@ -11,6 +11,11 @@ you can use in your path formats or elsewhere. .. _MusicBrainz documentation: https://musicbrainz.org/doc/Release_Group/Type +A bug introduced in beets 1.6.0 could have possibly imported broken data into +the ``albumtypes`` library field. Please follow the instructions `described +here <https://github.com/beetbox/beets/pull/4582#issuecomment-1445023493>`_ for +a sanity check and potential fix. :bug:`4528` + Configuration ------------- From 4908e1ae096155c547995d1b719936f63ba805be Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Tue, 28 Feb 2023 09:24:28 +0100 Subject: [PATCH 64/70] Fix flake8 issues in test_ui.py that were introduced in 27218a94. --- test/test_ui.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/test_ui.py b/test/test_ui.py index a1e02aaae..d3ce4a560 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -709,10 +709,10 @@ class UpdateTest(_common.TestCase): correct_albumtypes = ["album", "live"] # Setting albumtypes does not set albumtype, currently. - # Using x[0] mirrors https://github.com/beetbox/mediafile/blob/057432ad53b3b84385e5582f69f44dc00d0a725d/mediafile.py#L1928 - correct_albumtype = correct_albumtypes[0] + # Using x[0] mirrors https://github.com/beetbox/mediafile/blob/057432ad53b3b84385e5582f69f44dc00d0a725d/mediafile.py#L1928 # noqa: E501 + correct_albumtype = correct_albumtypes[0] - album.albumtype = correct_albumtype + album.albumtype = correct_albumtype album.albumtypes = correct_albumtypes album.try_sync(write=True, move=False) From c73ecb89d36087f84f6b05c5752d553aed6f9ff4 Mon Sep 17 00:00:00 2001 From: Alok Saboo <arsaboo@gmx.com> Date: Tue, 28 Feb 2023 09:36:16 -0500 Subject: [PATCH 65/70] Update spotify.py --- beetsplug/spotify.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 66b2b1084..af0d450e1 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -295,10 +295,11 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin): """ artist, artist_id = self.get_artist(track_data['artists']) + self._log.debug("Track Data is {}", track_data) # Get album information for spotify tracks try: album = track_data['album']['name'] - except KeyError: + except (KeyError, TypeError): album = None return TrackInfo( title=track_data['name'], From abf6b1e1f3736d4d74366b7f75b51351c6002668 Mon Sep 17 00:00:00 2001 From: Alok Saboo <arsaboo@gmx.com> Date: Tue, 28 Feb 2023 09:38:23 -0500 Subject: [PATCH 66/70] Update spotify.py --- beetsplug/spotify.py | 1 - 1 file changed, 1 deletion(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index af0d450e1..393e9c50a 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -295,7 +295,6 @@ class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin): """ artist, artist_id = self.get_artist(track_data['artists']) - self._log.debug("Track Data is {}", track_data) # Get album information for spotify tracks try: album = track_data['album']['name'] From 823599f2b43744ecee482e083b2717eea537c7cc Mon Sep 17 00:00:00 2001 From: Alok Saboo <arsaboo@gmx.com> Date: Tue, 28 Feb 2023 18:18:42 -0500 Subject: [PATCH 67/70] Update changelog --- docs/changelog.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 921ae7b30..8a44d1e84 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -11,6 +11,8 @@ for Python 3.6). New features: +* Added additional error handling for `spotify` plugin. + :bug:`4686` * We now import the remixer field from Musicbrainz into the library. :bug:`4428` * :doc:`/plugins/mbsubmit`: Added a new `mbsubmit` command to print track information to be submitted to MusicBrainz after initial import. @@ -70,7 +72,7 @@ Bug fixes: base when the MetadataSourcePlugin abstract class was introduced in PR's #3335 and #3371. :bug:`4401` -* :doc:`/plugins/convert`: Set default ``max_bitrate`` value to ``None`` to +* :doc:`/plugins/convert`: Set default ``max_bitrate`` value to ``None`` to avoid transcoding when this parameter is not set. :bug:`4472` * :doc:`/plugins/replaygain`: Avoid a crash when errors occur in the analysis backend. From 6336fef1e8aadc2c7d80e0f15ad8059f786bf4d6 Mon Sep 17 00:00:00 2001 From: J0J0 Todos <jojo@peek-a-boo.at> Date: Sat, 4 Mar 2023 19:51:58 +0100 Subject: [PATCH 68/70] Improve "Changelog goes here note" in changelog.rst --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 8a44d1e84..fa24b3bc4 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,7 +4,7 @@ Changelog 1.6.1 (in development) ---------------------- -Changelog goes here! +Changelog goes here! Please add your entry to the bottom of one of the lists below! With this release, beets now requires Python 3.7 or later (it removes support for Python 3.6). From 4c62673e412d685172ca528d544c2df9667ca3c6 Mon Sep 17 00:00:00 2001 From: Alok Saboo <arsaboo@gmx.com> Date: Tue, 7 Mar 2023 17:12:11 -0500 Subject: [PATCH 69/70] Update index.rst --- docs/plugins/index.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 1c8aaf760..53f73d732 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -468,6 +468,9 @@ Here are a few of the plugins written by the beets community: Augments MusicBrainz queries with locally-sourced data to improve autotagger results. +`beets-plexsync`_ + Allows you to sync your Plex library with your beets library, create smart playlists in Plex, and import online playlists (from services like Spotify) into Plex. + `beets-popularity`_ Fetches popularity values from Deezer. From 033f63ad4717c5bd72d42a069201656a5b963aac Mon Sep 17 00:00:00 2001 From: Alok Saboo <arsaboo@gmx.com> Date: Tue, 7 Mar 2023 17:18:15 -0500 Subject: [PATCH 70/70] Add Github link --- docs/plugins/index.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 53f73d732..ea13d2feb 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -521,6 +521,7 @@ Here are a few of the plugins written by the beets community: .. _whatlastgenre: https://github.com/YetAnotherNerd/whatlastgenre/tree/master/plugin/beets .. _beets-usertag: https://github.com/igordertigor/beets-usertag .. _beets-popularity: https://github.com/abba23/beets-popularity +.. _beets-plexsync: https://github.com/arsaboo/beets-plexsync .. _beets-ydl: https://github.com/vmassuchetto/beets-ydl .. _beet-summarize: https://github.com/steven-murray/beet-summarize .. _beets-mosaic: https://github.com/SusannaMaria/beets-mosaic