Skip to content

feat(coverart): search online catalogs for album art, with automatic fill-in for albums that have none - #2741

Open
sebastinto wants to merge 8 commits into
PixelPlayerHQ:masterfrom
sebastinto:feat/online-cover-art-search
Open

feat(coverart): search online catalogs for album art, with automatic fill-in for albums that have none#2741
sebastinto wants to merge 8 commits into
PixelPlayerHQ:masterfrom
sebastinto:feat/online-cover-art-search

Conversation

@sebastinto

@sebastinto sebastinto commented Aug 15, 2026

Copy link
Copy Markdown

What this adds

Cover art for albums that have none, or whose art you want to replace.

  • Search online catalogs — Deezer, iTunes and the Cover Art Archive, queried together, results ranked by how well they match the album and artist. Each result shows its resolution and file size so you can pick the best one, and results stream in as each catalog answers rather than waiting for the slowest.
  • Pick from your gallery — for releases no catalog carries.
  • Remove a cover — takes back one kept in PixelPlayer, or deletes the artwork embedded in the album's audio files. The two are separate menu entries because only the second one is irreversible, and it asks for confirmation first.
  • Feedback while it applies — applying a cover to an album rewrites a tag per track when covers go into the audio files, so the album screen shows a progress indicator until the write finishes rather than sitting unchanged for several seconds.
  • Choose where an applied cover is kept — Settings → Library → "Where covers you apply manually are kept". The default keeps it inside PixelPlayer and never touches your audio files; the other option embeds it into the files as before. The picker states which of the two a pick will do, so the destination is visible where the choice is made rather than only in the setting.
  • Fill in missing covers automatically — off by default. After a library scan it looks up albums that have no artwork at all and applies a cover only when the match is confident. Restricted to unmetered networks unless you turn that off. Albums nothing matched are remembered so the next pass doesn't re-ask.
  • Optional web image search — for anything the catalogs don't have, using your own Serper API key. Never used by the automatic pass, since those requests come out of your own allowance.

Why it touches code outside cover art

Two commits change shared code, plus one change inside the storage commit, so that's worth explaining up front rather than leaving it to be discovered in review. None of it is drive-by cleanup — each is a prerequisite, because this feature routes new callers into old code that had latent bugs.

fix(metadata): keep a batch edit to the fields it is editing — applying a cover to an album goes through the existing batch metadata save, which fills in every field the caller left null from Song. Song is the library's rendering of the file, not the file: writing it back turned a multi-artist tag of A; B into A, B, put MediaStore's filename-derived title into files that had their own, and embedded lyrics this app had fetched into its own database. Composer had no fallback at all, so "" was sent and the tag was deleted. All of that already happened on master when you changed one field across several tracks — this feature just made it fire on a single tap, across a whole album.

fix(artwork): refresh album rows when a song's cover changes — song rows carry a ?t= cache-busting token on their artwork URI while album and artist rows point at the query-less form. Invalidation only dropped whichever form the caller held, so album grids and headers kept drawing the old cover. Also pre-existing; unavoidable here because applying a cover has to make the album header update.

The post-sync orphan sweep in AlbumArtCacheManager also changed. Applied covers are stored there with filenames the existing sweep already understood, and the two need different retention: extracted artwork can be deleted the moment its song leaves the library because it re-extracts from the audio file, but an applied cover is the only copy. Absence from a single sync isn't evidence a song is gone — an unmounted card, a moved library or a MediaStore re-index all empty rows that come back — so applied covers now have to look orphaned for 30 days before they're dropped. Deletions in the extracted cache are unchanged.

Design notes

  • Nothing is written to your audio files unless you ask for it. The default storage mode keeps applied covers in app storage, resolved through the same AlbumArtUtils.ensureAlbumArtCachedFile path every screen already used, so there's still one way to ask for a song's artwork. Embedding into files requires per-file consent on Android 11+, which is also why the automatic pass never writes to files — there's nobody to ask during a background pass.
  • One image per album, not per track. Covers are content-addressed by digest and referenced by a small pointer file per song, so a 20-track album costs one image. Covers nothing points at are deleted.
  • The automatic pass is conservative by design. Confident matches only; albums whose title or artist is missing are never searched, because scoring would fall back to the title alone and every "Greatest Hits" in every catalog would come back an exact match. A search where a catalog failed is never recorded as "no cover exists" — the catalogs barely overlap, and the one that timed out may be the only one carrying that release.
  • Cloud tracks are left alone throughout. Their artwork lives on their server behind a scheme the local resolver can't see, so they look art-less from here; applying would replace a working remote cover with a guess.

Testing

  • 541 of 546 unit tests pass; the 5 failures are pre-existing and unrelated (see below). New coverage for the search repository and its ranking, the four providers, the image-header probe, the applied-artwork store and its orphan sweep, the automatic pass, and the storage routing between "keep in app" and "write to files".
  • :app:assembleDebug, :app:assembleRelease -Ppixelplay.enableAbiSplits=true and :wear:assembleDebug all pass locally — the three commands CI runs.
  • Checked on device:

    • picking a cover from search and from the gallery
    • both storage modes
    • removing an applied cover from the album menu
    • deleting an embedded cover from the album menu, including the confirmation dialog and cancelling it
    • the automatic pass over albums with no artwork
    • a multi-select tag edit
    • the progress indicator during an album-wide apply

Pre-existing test failures

./gradlew :app:testDebugUnitTest reports 5 failures: BackupSectionTest, LoadControlBufferProfileTest, LyricsStateHolderTest, AudioMetaUtilsTest, LocalArtworkUriTest. All 5 fail identically on master at cf56a2c, and this branch touches none of the files involved.

Screenshots

5c32ee5b-46f2-4fff-9e88-a27f2399e5bd 5d4292e3-7e8c-4078-9b1a-ca74b0708d9d 2a3e9eeb-0889-4584-a8b5-0305f6e36248 a8012d28-a8ac-4c20-a07c-84e28b360d3e a7cb6a49-c5a8-4a90-83c3-17d8d3edf8e2

A batch edit sends null for every field it is not changing, and the tag
write replaces the whole set -- so the null fields were filled in from
Song on the way past. Song is the library's rendering of the file rather
than the file itself: a multi-artist tag of "A; B" came back as the ", "
join displayArtist shows, a title MediaStore derived from the filename
landed in a file that had its own, and lyrics this app fetched into its
own database were embedded into the audio.

Composer had nothing to fall back on at all, so every batch edit sent ""
for it -- and the editor reads a blank as "delete this tag". Changing one
field across an album stripped the composer from every track of it.

The file now answers for its own tags, and Song only fills in what the
file has nothing to say about. Lyrics need one more turn: read from the
file, they would overwrite the library's copy for a song whose file
carries none, so saveMetadata takes syncLibraryLyrics to tell "this is
the new value" apart from "this is what was already there".
Song rows carry a cache busting "?t=" token on their artwork URI; album
and artist rows point at the query-less form of the same URI. Cache
invalidation was given whichever form the caller happened to hold, so
editing a song's artwork dropped the entry behind the song row and left
the one behind the album grid and the album header untouched -- both kept
drawing the previous cover until the next library load.

Both forms are now invalidated for every URI passed in.
Adds the search layer behind the cover art picker: Deezer, iTunes and
the Cover Art Archive by way of MusicBrainz, queried concurrently so
the search costs the slowest catalog rather than the sum of them, and
merged into one list ranked against the album's own tags.

The catalogs need no key. The Cover Art Archive costs a release query
plus a lookup per release against a service that asks callers to stay
near a request a second, so a caller that will discard anything below a
given score can ask for the direct catalogs first and skip it when
their answer is already good enough.

A fourth source, an image search engine on Google's index, is for
releases no catalog carries. It is metered against a key the user
supplies, so it has its own entry point and never runs as part of a
catalog search.

Candidate images are downloaded over HTTPS only, with a client that
will not follow a redirect off it, bounded at 8 MB, and checked for
image magic bytes so an error page served as an image cannot reach the
cropper.
A screen of its own, reached from the cover's menu on the album screen,
that searches the catalogs and hands the picked image to the cropper the
gallery picker already uses. Presented the way the tag editor is -- a
dialog window sliding in over what it came from -- because covers are
judged by looking at them, and a sheet spends its height negotiating
with its own contents rather than showing them.

Results are drawn as each catalog answers rather than when all of them
have, since they land seconds apart, and are grouped under their source
with a chip per source that scrolls to it. Each result shows its
resolution, weight and where it came from; catalogs publish neither
dimension nor size reliably, so the first results are measured by
reading a short prefix of the image itself, and read as approximate
until they have been. Results past that prefix, and the catalogs that
state no size at all, say so rather than promising a measurement that is
not coming.

The search terms come from the album and read as a line, with the fields
to correct them behind an action in the app bar: an album whose tags do
not match what the catalogs call it is the reason they exist, and it is
not the common case.
Album art storage, in Settings: embedded into the audio files the way a
tag editor would, or kept inside PixelPlayer leaving the files
untouched. It applies wherever a cover is applied, including in the
middle of a tag edit, where the tags still reach the file and only the
artwork is held back. A cover-only change under the second setting
never opens the file at all, so it does not ask for the write consent
Android 11 requires.

Covers kept in the app go to a store of their own rather than the
extracted artwork cache. Everything in that cache can be read back out
of the audio file, which is what makes it safe for the LRU to evict; an
applied cover has no other copy, so an eviction would either blank the
album or bring back the cover it replaced. The artwork cache's size and
its orphan sweep both account for the new store, so it is neither
hidden from the user nor unreclaimable.

An album's tracks share one copy of the image. Artwork is addressed by
song id throughout -- the library scan, the shared content provider,
the widget, Wear, the Coil fetcher -- so each track keeps its own path,
and that path is a pointer at the album's cover rather than a copy of
it. Covering a twenty-track album costs one image instead of twenty.
The image is named for the album and for a digest of its bytes, so
giving one track a different cover leaves its album-mates pointing at
the old one, and covers nothing points at any more are deleted.

The store is new, so it is written as WebP from the start: one encode
serves a whole album and the file outlives every cache around it, which
is worth the smaller format.

Applying a cover in the app takes the same follow-up as writing one to
the files: the playing item is rebuilt so the notification picks it up,
the queue and the album row are updated, and the palette derived from
the previous cover is dropped, since the artwork URI it is keyed by
does not change.
An optional pass after a library sync that looks up the albums with no
artwork. Off by default, Wi-Fi only by default, and it never touches
audio files: embedding needs the user's consent per file and there is
nobody to ask in the background, so what it finds is always kept in the
app.

Only confident matches are applied, because a cover chosen with nobody
looking is worse than no cover. Anything below the bar is left for the
user to pick by hand, and albums no catalog matched are remembered so
the next sync does not re-query the same dead ends -- recorded as the
pass goes, since the system can take the pass back at any point.

A run is capped so it stays bounded, and appends its continuation to
the same unique name it runs under. A name of its own would leave two
passes able to run at once, one under each, both querying the same
catalogs and both writing to the same artwork store; REPLACE under the
shared name would cancel the worker doing the asking. Appending queues
the next pass behind this one, and OR_REPLACE covers a chain that has
already been cancelled.

Failures are told apart from absences throughout: a search or a
download that did not complete leaves the album for the next pass,
while only an album the catalogs answered about is remembered. A run
of failures ends the pass rather than working through the library
asking questions nothing is answering, and every album that spent a
request is paced, including the ones that gave up early -- those are
the ones that reached the slow catalog to get their answer.

Albums in folders excluded from the library are skipped, so their names
are never sent to a third-party catalog.
Album art storage, the automatic pass and its Wi-Fi-only constraint,
the list of albums nothing matched with a way to try them again, and
the key for the image search engine.

The copy states what leaves the device and what does not: the automatic
pass sends album and artist to the three catalogs and never modifies
files, and the image engine is named along with the fact that its
requests are billed to the user's own account.
Including the change to what a cover-only edit does by default, since
it is a change to behaviour an installed app already has.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant