Repository: beetbox/beets

Issue:
discogs: Save album information on singleton imports
When the discogs plugin is used for singleton imports consider additionally saving album information to the beets library.

### Proposed solution

On singleton imports the user specifically selects a Discogs release (Discogs release URL) where the track information should be taken from. This could be saved to the library field `album` of the track item (`album` row in the `items` table in the database) and to the corresponding metadata tag of the file.

Let's also compare this feature idea to a singleton import against the MusicBrainz database: Here we have a distinction between an album and a recording. A recording / a track is its own entity and does have a 1:n connection to an album, thus by selecting a recording on a singleton import, we don't know which album we are referring to! A feature like that would not make sense with MusicBrainz. With Discogs on the other hand, we have a 1:1 connection from a track to an album.

#### Goals

Save to the track item's `album` field (`items` table, `album` column). Provide an easy way for subsequent re-tagging of such singletons to albums, by having the required album info already.

#### Non-goals

Adding `album` and `album_id` to the `album` table in the database.



Discussion:
@ghbrown I'm not sure if this is easily possible. I just had a quick look into the code and see that item_candidates() returns a list of `TrackInfo` objects. The definition of a TrackInfo object here https://github.com/beetbox/beets/blob/master/beets/autotag/hooks.py#L171, does not intend to store the title of an album.
@arsaboo please help me out: IIRC I came across the fact that the Spotify plugin saves album information when it does singleton imports. Is that correct or did I dream that?
> @ghbrown I'm not sure if this is easily possible. I just had a quick look into the code and see that item_candidates() returns a list of `TrackInfo` objects. The definition of a TrackInfo object here https://github.com/beetbox/beets/blob/master/beets/autotag/hooks.py#L171, does not intend to store the title of an album.

Whoops, right an hour after I wrote this it came to mind that actually the solution to this problem is provided in the closed PR you based your recent item_candidates PR on: https://github.com/beetbox/beets/pull/3682

The candidates we return somehow need to be augmented with album information, in that PR by returning a tuple containing both, TrackInfo and AlbumInfo objects, if I understand correctly.
@JOJ0 Indeed...spotify saves album information. Here's the relevant PR - https://github.com/beetbox/beets/pull/4405
@ghbrown I couldn't wait and resurrected the code I found in #3682, heavily work in progress state. Not sure if getting those old methods and string_dist is a good idea. Will play around with this... Could also imagine a team effort or handing the thing over to you, if you are motivated. Currently I'm not so sure what I'm doing or whether @arsaboo's way is the easier one....  ;-)

Just wanted to post the state and inform that I started working on it to prevent double efforts most of all!

Resolving patch:
diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py
--- a/beets/autotag/hooks.py
+++ b/beets/autotag/hooks.py
@@ -212,6 +212,7 @@ def __init__(
             bpm: Optional[str] = None,
             initial_key: Optional[str] = None,
             genre: Optional[str] = None,
+            album: Optional[str] = None,
             **kwargs,
     ):
         self.title = title
@@ -241,6 +242,7 @@ def __init__(
         self.bpm = bpm
         self.initial_key = initial_key
         self.genre = genre
+        self.album = album
         self.update(kwargs)
 
     # As above, work around a bug in python-musicbrainz-ngs.
diff --git a/beets/ui/commands.py b/beets/ui/commands.py
--- a/beets/ui/commands.py
+++ b/beets/ui/commands.py
@@ -215,6 +215,15 @@ def disambig_string(info):
         if info.albumstatus == 'Pseudo-Release':
             disambig.append(info.albumstatus)
 
+    if isinstance(info, hooks.TrackInfo):
+        if info.index:
+            disambig.append("Index {}".format(str(info.index)))
+        if info.track_alt:
+            disambig.append("Track {}".format(info.track_alt))
+        if (config['import']['singleton_album_disambig'].get()
+                and info.get('album')):
+            disambig.append("[{}]".format(info.album))
+
     if disambig:
         return ', '.join(disambig)
 
@@ -444,18 +453,28 @@ def show_item_change(item, match):
     """
     cur_artist, new_artist = item.artist, match.info.artist
     cur_title, new_title = item.title, match.info.title
+    cur_album = item.album if item.album else ""
+    new_album = match.info.album if match.info.album else ""
 
-    if cur_artist != new_artist or cur_title != new_title:
+    if (cur_artist != new_artist or cur_title != new_title
+            or cur_album != new_album):
         cur_artist, new_artist = ui.colordiff(cur_artist, new_artist)
         cur_title, new_title = ui.colordiff(cur_title, new_title)
+        cur_album, new_album = ui.colordiff(cur_album, new_album)
 
         print_("Correcting track tags from:")
         print_(f"    {cur_artist} - {cur_title}")
+        if cur_album:
+            print_(f"    Album: {cur_album}")
         print_("To:")
         print_(f"    {new_artist} - {new_title}")
+        if new_album:
+            print_(f"    Album: {new_album}")
 
     else:
         print_(f"Tagging track: {cur_artist} - {cur_title}")
+        if cur_album:
+            print_(f"               Album: {new_album}")
 
     # Data URL.
     if match.info.data_url:
diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py
--- a/beetsplug/discogs.py
+++ b/beetsplug/discogs.py
@@ -19,7 +19,7 @@
 import beets.ui
 from beets import config
 from beets.util.id_extractors import extract_discogs_id_regex
-from beets.autotag.hooks import AlbumInfo, TrackInfo
+from beets.autotag.hooks import AlbumInfo, TrackInfo, string_dist
 from beets.plugins import MetadataSourcePlugin, BeetsPlugin, get_distance
 import confuse
 from discogs_client import __version__ as dc_string
@@ -196,6 +196,40 @@ def candidates(self, items, artist, album, va_likely, extra_tags=None):
             self._log.debug('Connection error in album search', exc_info=True)
             return []
 
+    def get_track_from_album_by_title(self, album_info, title,
+                                      dist_threshold=0.3):
+        def compare_func(track_info):
+            track_title = getattr(track_info, "title", None)
+            dist = string_dist(track_title, title)
+            return (track_title and dist < dist_threshold)
+        return self.get_track_from_album(album_info, compare_func)
+
+    def get_track_from_album(self, album_info, compare_func):
+        """Return the first track of the release where `compare_func` returns
+        true.
+
+        :return: TrackInfo object.
+        :rtype: beets.autotag.hooks.TrackInfo
+        """
+        if not album_info:
+            return None
+
+        for track_info in album_info.tracks:
+            # check for matching position
+            if not compare_func(track_info):
+                continue
+
+            # 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
+            # attach album info
+            track_info['album'] = album_info.album
+
+            return track_info
+
+        return None
+
     def item_candidates(self, item, artist, title):
         """Returns a list of TrackInfo objects for Search API results
         matching ``title`` and ``artist``.
@@ -214,6 +248,7 @@ def item_candidates(self, item, artist, title):
         if not artist and not title:
             self._log.debug('Skipping Discogs query. File missing artist and '
                             'title tags.')
+            return
 
         query = f'{artist} {title}'
         try:
@@ -230,7 +265,11 @@ def item_candidates(self, item, artist, title):
         candidates = []
         for album_cur in albums:
             self._log.debug(u'searching within album {0}', album_cur.album)
-            candidates += album_cur.tracks
+            track_result = self.get_track_from_album_by_title(
+                album_cur, item['title']
+            )
+            if track_result:
+                candidates.append(track_result)
         # first 10 results, don't overwhelm with options
         return candidates[:10]
 
@@ -276,7 +315,7 @@ def get_albums(self, query):
         query = re.sub(r'(?u)\W+', ' ', query)
         # Strip medium information from query, Things like "CD1" and "disk 1"
         # can also negate an otherwise positive result.
-        query = re.sub(r'(?i)\b(CD|disc)\s*\d+', '', query)
+        query = re.sub(r'(?i)\b(CD|disc|vinyl)\s*\d+', '', query)
 
         try:
             releases = self.discogs_client.search(query,


Test patch: