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
Open
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this adds
Cover art for albums that have none, or whose art you want to replace.
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 fromSong.Songis the library's rendering of the file, not the file: writing it back turned a multi-artist tag ofA; BintoA, 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 onmasterwhen 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
AlbumArtCacheManageralso 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
AlbumArtUtils.ensureAlbumArtCachedFilepath 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.Testing
:app:assembleDebug,:app:assembleRelease -Ppixelplay.enableAbiSplits=trueand:wear:assembleDebugall pass locally — the three commands CI runs.Checked on device:
Pre-existing test failures
./gradlew :app:testDebugUnitTestreports 5 failures:BackupSectionTest,LoadControlBufferProfileTest,LyricsStateHolderTest,AudioMetaUtilsTest,LocalArtworkUriTest. All 5 fail identically onmasterat cf56a2c, and this branch touches none of the files involved.Screenshots