fetchart plugin - use best art from all sources, not from the first valid
- Dominant language
- Python
- Stars
- 15.7k
- Forks
- 2.1k
- Avg merge
- 4d 21h
- Merged PRs (30d)
- 31
Description
### Proposed solution
Implement intelligent album art selection logic in the `fetchart` plugin that compares quality across all available sources instead of using the current "first valid candidate wins" approach. The new system will:
1. **Collect all valid candidates** from all configured sources before making a selection
2. **Compare candidates based on quality metrics**: image resolution, match type (exact vs fallback), and source priority
3. **Prefer local files when quality is equivalent** to remote sources, maintaining the benefits of local storage
4. **Provide detailed logging** showing why a particular candidate was selected
The selection algorithm prioritizes candidates using the following criteria (in order):
- Match type: EXACT matches over FALLBACK matches
- Local vs Remote: Local files preferred when quality is similar
- Image resolution: Higher resolution images preferred
- Source order: As configured by the user
### Objective
Currently, the `fetchart` plugin uses a "first valid candidate wins" approach, which often results in suboptimal artwork selection. Users report cases where high-quality local artwork is replaced by lower-quality remote images, or where excellent remote sources are ignored because a mediocre local file was found first.
#### Goals
- **Improve artwork quality**: Always select the highest quality artwork available across all sources
- **Maintain local file preference**: When quality is equivalent, prefer local files to avoid unnecessary downloads
- **Preserve user control**: Respect the user's source ordering configuration as a tiebreaker
- **Provide transparency**: Clear logging showing which candidates were found and why one was selected
- **Backward compatibility**: No breaking changes to existing configuration or API
#### Non-goals
- **Advanced image analysis**: No content-based quality assessment (blur detection, compression artifacts, etc.)
- **User interaction**: No prompts asking users to choose between candidates
- **Performance optimization**: Not focused on reducing network requests (quality over speed)
- **New configuration options**: No new settings required, works with existing configuration
#### Anti-goals
- **Breaking existing workflows**: Users who rely on current behavior should not be negatively affected (need implement as option)
- **Increased complexity**: The change should be transparent to users who don't need quality comparison
- **Resource consumption**: Should not significantly increase memory usage or processing time
- **Network abuse**: Should not cause excessive downloads from remote sources (need testing)
### Implementation Details
The patch modifies the `art_for_album()` method in the `FetchArtPlugin` class to:
1. **Replace early termination logic** with candidate collection across all sources
2. **Add `_choose_best_candidate()` method** implementing the quality comparison algorithm
3. **Maintain proper cleanup** of unused temporary files from remote sources
4. **Enhance logging** to show the selection process
### Benefits
- **Better user experience**: Users get the best quality artwork automatically
- **Reduced manual intervention**: Less need to manually replace poor quality artwork
- **Intelligent local/remote balance**: Leverages both local files and remote sources optimally
- **Configurable behavior**: Existing source ordering still influences selection as intended
### Example Scenarios
**Before**: Local `cover.jpg` (500×500) found first → selected, iTunes (1200×1200) ignored
**After**: Both candidates evaluated → iTunes (1200×1200) selected for better quality
**Before**: Local `album.jpg` (1200×1200) found first → selected, remote (1200×1200) ignored
**After**: Both candidates evaluated → Local file selected (same quality, prefer local)
### Patch
```py
--- fetchart.py.orig 2024-01-01 00:00:00.000000000 +0000
+++ fetchart.py 2024-01-01 00:00:01.000000000 +0000
@@ -1407,33 +1407,75 @@
def art_for_album(self, album, paths, local_only=False):
"""Given an Album object, returns a path to downloaded art for the
album (or None if no art is found). If `maxwidth`, then images are
resized to this maximum pixel size. If `quality` then resized images
are saved at the specified quality level. If `local_only`, then only
local image files from the filesystem are returned; no network
requests are made.
"""
- out = None
+ candidates = []
for source in self.sources:
if source.IS_LOCAL or not local_only:
self._log.debug(
"trying source {0} for album {1.albumartist} - {1.album}",
SOURCE_NAMES[type(source)],
album,
)
# URLs might be invalid at this point, or the image may not
# fulfill the requirements
for candidate in source.get(album, self, paths):
source.fetch_image(candidate, self)
if candidate.validate(self):
- out = candidate
+ candidates.append(candidate)
self._log.debug(
- "using {0.LOC_STR} image {1}".format(
- source, util.displayable_path(out.path)
+ "found {0.LOC_STR} image {1}".format(
+ source, util.displayable_path(candidate.path)
)
)
- break
- # Remove temporary files for invalid candidates.
- source.cleanup(candidate)
- if out:
- break
+ else:
+ # Remove temporary files for invalid candidates.
+ source.cleanup(candidate)
+
+ # Choose the best candidate based on priority
+ out = self._choose_best_candidate(candidates)
+
+ if out:
+ self._log.debug(
+ "using {0.LOC_STR} image {1}".format(
+ out.source, util.displayable_path(out.path)
+ )
+ )
+ # Clean up unused candidates
+ for candidate in candidates:
+ if candidate != out:
+ candidate.source.cleanup(candidate)
+ out.resize(self)
+
+ return out
+
+ def _choose_best_candidate(self, candidates):
+ """Choose the best candidate from a list based on quality and source priority."""
+ if not candidates:
+ return None
+
+ if len(candidates) == 1:
+ return candidates[0]
+
+ # Sort candidates by priority:
+ # 1. Match type (EXACT > FALLBACK)
+ # 2. Local vs Remote (prefer local if same quality)
+ # 3. Image size (larger is better)
+ # 4. Source order (as configured)
+
+ def candidate_priority(candidate):
+ # Get image size for comparison
+ if candidate.size:
+ width, height = candidate.size
+ total_pixels = width * height
+ else:
+ # Try to get size if not already available
+ from beets.util.artresizer import ArtResizer
+ size = ArtResizer.shared.get_size(candidate.path)
+ if size:
+ width, height = size
+ total_pixels = width * height
+ else:
+ total_pixels = 0
+
+ # Priority tuple: (match_type, is_local, total_pixels, source_index)
+ match_priority = 0 if candidate.match == candidate.MATCH_EXACT else 1
+ is_local = 0 if candidate.source.IS_LOCAL else 1
+
+ # Find source index in configuration order
+ source_index = 0
+ for i, source in enumerate(self.sources):
+ if source == candidate.source:
+ source_index = i
+ break
+
+ return (match_priority, is_local, -total_pixels, source_index)
+
+ # Sort by priority (lower is better)
+ candidates.sort(key=candidate_priority)
+
+ best = candidates[0]
+ self._log.debug(
+ "selected best candidate: {0.LOC_STR} {1} (size: {2})".format(
+ best.source,
+ util.displayable_path(best.path),
+ best.size or "unknown"
+ )
+ )
+
+ return best
- if out:
- out.resize(self)
-
- return out
-
def batch_fetch_art(self, lib, albums, force, quiet):
```
### Example (log)
```
fetchart: trying source filesystem for album Eeriness - Paths
fetchart: trying source itunes for album Eeriness - Paths
fetchart: getting URL: https://itunes.apple.com/search?term=Eeriness+Paths&entity=album&media=music&limit=200
fetchart: iTunes search for 'Eeriness Paths' got no results
fetchart: trying source amazon for album Eeriness - Paths
fetchart: downloading image: https://images.amazon.com/images/P/B000NUOW4C.01.LZZZZZZZ.jpg
fetchart: downloaded art to: C:\Users\ALEXAN~1\AppData\Local\Temp\beets\beetsplug_fetchart\1pcebn_0.jpg
fetchart: image size: (500, 496)
fetchart: found remote image C:\Users\ALEXAN~1\AppData\Local\Temp\beets\beetsplug_fetchart\1pcebn_0.jpg
fetchart: downloading image: https://images.amazon.com/images/P/B000NUOW4C.02.LZZZZZZZ.jpg
fetchart: downloaded art to: C:\Users\ALEXAN~1\AppData\Local\Temp\beets\beetsplug_fetchart\zi6wphpt.jpg
fetchart: image size: (500, 496)
fetchart: found remote image C:\Users\ALEXAN~1\AppData\Local\Temp\beets\beetsplug_fetchart\zi6wphpt.jpg
fetchart: trying source lastfm for album Eeriness - Paths
fetchart: getting URL: https://ws.audioscrobbler.com/2.0?method=album.getinfo&api_key=REDACTED&mbid=REDACTED&format=json
fetchart: downloading image: https://lastfm.freetls.fastly.net/i/u/_/03a3f76d1f784549b25c294217430e1d.jpg
fetchart: downloaded art to: C:\Users\ALEXAN~1\AppData\Local\Temp\beets\beetsplug_fetchart\ymjy5bzm.jpg
fetchart: image size: (319, 320)
fetchart: found remote image C:\Users\ALEXAN~1\AppData\Local\Temp\beets\beetsplug_fetchart\ymjy5bzm.jpg
fetchart: downloading image: https://lastfm.freetls.fastly.net/i/u/300x300/03a3f76d1f784549b25c294217430e1d.jpg
fetchart: downloaded art to: C:\Users\ALEXAN~1\AppData\Local\Temp\beets\beetsplug_fetchart\mncdk0w_.jpg
fetchart: image size: (300, 300)
fetchart: found remote image C:\Users\ALEXAN~1\AppData\Local\Temp\beets\beetsplug_fetchart\mncdk0w_.jpg
fetchart: downloading image: https://lastfm.freetls.fastly.net/i/u/_/03a3f76d1f784549b25c294217430e1d.jpg
fetchart: downloaded art to: C:\Users\ALEXAN~1\AppData\Local\Temp\beets\beetsplug_fetchart\1yc8hrjd.jpg
fetchart: image size: (319, 320)
fetchart: found remote image C:\Users\ALEXAN~1\AppData\Local\Temp\beets\beetsplug_fetchart\1yc8hrjd.jpg
fetchart: downloading image: https://lastfm.freetls.fastly.net/i/u/300x300/03a3f76d1f784549b25c294217430e1d.jpg
fetchart: downloaded art to: C:\Users\ALEXAN~1\AppData\Local\Temp\beets\beetsplug_fetchart\hsb2j9nz.jpg
fetchart: image size: (300, 300)
fetchart: found remote image C:\Users\ALEXAN~1\AppData\Local\Temp\beets\beetsplug_fetchart\hsb2j9nz.jpg
fetchart: downloading image: https://lastfm.freetls.fastly.net/i/u/174s/03a3f76d1f784549b25c294217430e1d.jpg
fetchart: downloaded art to: C:\Users\ALEXAN~1\AppData\Local\Temp\beets\beetsplug_fetchart\miypor03.jpg
fetchart: image size: (174, 174)
fetchart: image too small (174 < 300)
fetchart: downloading image: https://lastfm.freetls.fastly.net/i/u/64s/03a3f76d1f784549b25c294217430e1d.jpg
fetchart: downloaded art to: C:\Users\ALEXAN~1\AppData\Local\Temp\beets\beetsplug_fetchart\0dimgmym.jpg
fetchart: image size: (64, 64)
fetchart: image too small (64 < 300)
fetchart: downloading image: https://lastfm.freetls.fastly.net/i/u/34s/03a3f76d1f784549b25c294217430e1d.jpg
fetchart: downloaded art to: C:\Users\ALEXAN~1\AppData\Local\Temp\beets\beetsplug_fetchart\wo7w5glr.jpg
fetchart: image size: (34, 34)
fetchart: image too small (34 < 300)
fetchart: trying source albumart for album Eeriness - Paths
fetchart: getting URL: https://www.albumart.org/index_detail.php?asin=B000NUOW4C
fetchart: scraped art URL: https://www.albumart.org/index_detail.php?asin=B000NUOW4C
fetchart: no image found on page
fetchart: trying source coverart for album Eeriness - Paths
fetchart: getting URL: https://coverartarchive.org/release/c8caf26f-e2cb-4cc4-ad9f-4a3fe392014f
fetchart: downloading image: http://coverartarchive.org/release/c8caf26f-e2cb-4cc4-ad9f-4a3fe392014f/1008367771-1200.jpg
fetchart: downloaded art to: C:\Users\ALEXAN~1\AppData\Local\Temp\beets\beetsplug_fetchart\knusf73z.jpg
fetchart: image size: (1200, 1183)
fetchart: found remote image C:\Users\ALEXAN~1\AppData\Local\Temp\beets\beetsplug_fetchart\knusf73z.jpg
fetchart: trying source coverart for album Eeriness - Paths
fetchart: getting URL: https://coverartarchive.org/release-group/eb7e272a-ad76-48b5-8bea-2ed459e7eb95
fetchart: downloading image: http://coverartarchive.org/release/c8caf26f-e2cb-4cc4-ad9f-4a3fe392014f/1008367771-1200.jpg
fetchart: downloaded art to: C:\Users\ALEXAN~1\AppData\Local\Temp\beets\beetsplug_fetchart\_6ct3mbb.jpg
fetchart: image size: (1200, 1183)
fetchart: found remote image C:\Users\ALEXAN~1\AppData\Local\Temp\beets\beetsplug_fetchart\_6ct3mbb.jpg
fetchart: selected best candidate: remote C:\Users\ALEXAN~1\AppData\Local\Temp\beets\beetsplug_fetchart\knusf73z.jpg (size: (1200, 1183))
fetchart: using remote image C:\Users\ALEXAN~1\AppData\Local\Temp\beets\beetsplug_fetchart\knusf73z.jpg
fetchart: image size: (1200, 1183)
Sending event: art_set
embedart: Resizing album art to 600 pixels wide and encoding at quality level 90
artresizer: PIL resizing \\?\C:\Users\alexander\Music\Auto\Eeriness\2006 - Paths\cover.jpg to C:\Users\ALEXAN~1\AppData\Local\Temp\beets\util_artresizer\resize_PIL_vcmntujm.jpg
embedart: Embedding album art into Eeriness - Paths
embedart: embedding C:\Users\ALEXAN~1\AppData\Local\Temp\beets\util_artresizer\resize_PIL_vcmntujm.jpg
Sending event: write
Sending event: after_write
...
fetchart: Eeriness - Paths: found album art
```
Contributor guide
Research direction
Start in fetchart.py at FetchArtPlugin.art_for_album() and compare the current source iteration with the proposed _choose_best_candidate() flow. Done means valid candidates from all configured sources are compared by match type, local preference, resolution, and source order, with unused temporary files cleaned up and selection logging preserved; the payload names no test file.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- cli
- Issue type
- Feature
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Stale
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100