diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fe99c2b68..a2e4a30247 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Cover art:** Search Deezer, iTunes and the Cover Art Archive for an album's cover, compare results by resolution and size, and apply one. +- **Cover art:** Pick a cover for an album from your gallery. +- **Cover art:** Choose where applied covers are kept, in Settings β†’ Library β†’ Album art storage. +- **Cover art:** Optionally look up covers for albums that have none, after a library scan. Off by default, Wi-Fi only, confident matches only. +- **Cover art:** Optional web image search for releases no catalog carries, using your own Serper API key. + +### Changed +- **Cover art:** Changing only a cover now keeps it in PixelPlayer rather than writing it into the audio file; other tag edits are unchanged. Switch back under Settings β†’ Library β†’ Album art storage. +- **Cover art:** Covers applied to an album are stored once for the whole album, as WebP, instead of once per track. + +### Fixed +- **Metadata:** Editing one field across several tracks no longer rewrites the fields you left alone. Multi-artist tags kept their own spelling, titles were replaced with the library's version, and the composer was removed from every track edited this way. +- **Metadata:** Lyrics fetched inside PixelPlayer are no longer written into your audio files by an edit that was not about lyrics. +- **Album art:** Album grids and album headers now update when a track's cover changes, instead of showing the previous cover until the next library scan. + ## [0.7.5-beta] - 2026-06-13 ### Added diff --git a/README.md b/README.md index ca3d9e1ecb..476d733a5a 100644 --- a/README.md +++ b/README.md @@ -71,8 +71,10 @@ - **Lyrics Editing** - Modify or add lyrics to your tracks - **Scrolling Display** - Follow along as you listen -### πŸ–ΌοΈ Artist Artwork +### πŸ–ΌοΈ Artwork - **Deezer Integration** - Automatic artist images from Deezer API +- **Cover Art Search** - Find album covers on Deezer, iTunes and the Cover Art Archive +- **Automatic Covers** - Optionally fill in albums missing artwork after a scan - **Smart Caching** - Memory (LRU) + database caching for offline access - **Fallback Icons** - Beautiful placeholders when images unavailable diff --git a/app/src/main/java/com/theveloper/pixelplay/data/coverart/AlbumArtStorage.kt b/app/src/main/java/com/theveloper/pixelplay/data/coverart/AlbumArtStorage.kt new file mode 100644 index 0000000000..54d163b967 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/coverart/AlbumArtStorage.kt @@ -0,0 +1,29 @@ +package com.theveloper.pixelplay.data.coverart + +/** + * Where an applied cover is kept. + * + * The two options differ in what survives the app: art written into the audio + * files travels with them to any other player or machine, while art kept in the + * app leaves the user's files untouched and disappears with the app's data. + */ +enum class AlbumArtStorage { + /** + * Embedded into every track of the album, the way a tag editor would. + * + * Modifying files the app did not create needs the user's consent per file + * on Android 11 and up, which can only be asked for on screen. Covers found + * by the unattended pass are therefore still kept in the app, whatever this + * is set to. + */ + AUDIO_FILES, + + /** + * Kept in the app's own artwork store, leaving the audio files untouched. + * + * No write consent and no tag rewrite, at the cost of art no other player + * sees. The default: the unattended pass can only ever write here, and one + * store keeps "where is this album's cover" answerable. + */ + APP_ONLY +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/coverart/AppArtworkWriter.kt b/app/src/main/java/com/theveloper/pixelplay/data/coverart/AppArtworkWriter.kt new file mode 100644 index 0000000000..27d95ef16f --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/coverart/AppArtworkWriter.kt @@ -0,0 +1,162 @@ +package com.theveloper.pixelplay.data.coverart + +import android.content.Context +import com.theveloper.pixelplay.data.database.AlbumArtThemeDao +import com.theveloper.pixelplay.data.database.MusicDao +import com.theveloper.pixelplay.data.media.ImageCacheManager +import com.theveloper.pixelplay.utils.AlbumArtUtils +import com.theveloper.pixelplay.utils.LocalArtworkUri +import timber.log.Timber +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Applies a cover without touching the user's audio files. + * + * The image goes into the applied-artwork store, which nothing evicts; see + * [AlbumArtUtils.saveAppliedAlbumArt]. Shared by the unattended pass and by a + * manual apply under [AlbumArtStorage.APP_ONLY]. + */ +@Singleton +class AppArtworkWriter @Inject constructor( + @ApplicationContext private val context: Context, + private val musicDao: MusicDao, + private val albumArtThemeDao: AlbumArtThemeDao, + private val imageCacheManager: ImageCacheManager +) { + + private val _appliedArtworkRevision = MutableStateFlow(0L) + + /** + * Bumped every time a song's artwork changes, so a screen can re-read it. + * + * The rows are not a signal on their own: [apply] writes the canonical URI, + * which is often the string the row already held. Covers writes into the + * audio files too, via [noteExternalArtworkChange]. + */ + val appliedArtworkRevision: StateFlow = _appliedArtworkRevision.asStateFlow() + + /** + * Records artwork changed outside this writer -- a cover written into the + * audio files themselves. + * + * Those writes leave the album row pointing at the URI it already held, so + * a header drawing from it has nothing else to tell it to reload, and they + * can supersede an applied cover, which leaves the "remove cover" entry + * describing a store that no longer holds one. + */ + fun noteExternalArtworkChange() { + _appliedArtworkRevision.update { it + 1 } + } + + /** + * @param albumId the album these songs belong to, when they share one. Its + * row follows only if [songIds] covers the whole album -- one track of + * twenty is not the album getting a new cover. + * @return false when nothing was stored: an empty [songIds], or a failed + * write. A believed-but-absent apply would chain the automatic pass into + * re-fetching the same albums forever. + */ + suspend fun apply( + bytes: ByteArray, + songIds: List, + albumId: Long? = null + ): Boolean = withContext(Dispatchers.IO) { + // Cloud tracks have a negative id and no local store to write into, so + // pointing their rows here would replace a working remote URI with one + // resolving to nothing. Both callers filter; repeating it makes it the + // writer's invariant rather than each caller's to remember. + val songIds = songIds.filter { it > 0 } + if (songIds.isEmpty()) return@withContext false + + // One decode and re-encode for the album, not one per track: bounding + // is a full bitmap decode, scale and WebP encode. + val bounded = AlbumArtUtils.boundArtworkForStorage(bytes) + + // A full disk is worth a log and an unchanged cover, not an exception + // escaping into a ViewModel's scope. The caller is still told: reporting + // success chained the automatic pass into re-fetching forever. + val stored = runCatching { AlbumArtUtils.saveAppliedAlbumArt(context, bounded, songIds) } + .onFailure { error -> Timber.w(error, "Could not store the applied cover") } + .getOrNull() + if (stored == null) return@withContext false + + val artworkUris = songIds.map { songId -> + val artworkUri = LocalArtworkUri.buildSongUri(songId) + musicDao.updateSongAlbumArt(songId, artworkUri) + imageCacheManager.invalidateRenderedCoverArt(artworkUri) + artworkUri + } + + if (coversWholeAlbum(albumId, songIds)) { + musicDao.updateAlbumArt(requireNotNull(albumId), artworkUris.first()) + } + + // The palette is derived from the old cover and keyed by a URI that has + // not changed, so nothing else would ever recompute it. + albumArtThemeDao.deleteThemesByUris(artworkUris) + _appliedArtworkRevision.update { it + 1 } + true + } + + /** + * Takes back a cover applied to [songIds], leaving the audio files alone. + * + * Each song shows what it would have shown had the cover never been applied. + * Returns what each was left pointing at, since not every song keeps art. + */ + suspend fun removeApplied( + songIds: List, + albumId: Long? = null + ): Map = withContext(Dispatchers.IO) { + // Filtered for the same reason [apply] filters: a cloud track never had + // an applied cover to take back, and writing one's row here would + // re-point it at a local file that does not exist. + val songIds = songIds.filter { it > 0 } + if (songIds.isEmpty()) return@withContext emptyMap() + + val artworkUris = songIds.map { LocalArtworkUri.buildSongUri(it) } + var remainingForAlbum: String? = null + val remaining = mutableMapOf() + + songIds.forEach { songId -> + AlbumArtUtils.clearAppliedArtForSong(context, songId) + + // Asking for the artwork again is what re-extracts whatever the file + // still carries; a song with none is left pointing at nothing rather + // than at a URI that resolves to a blank. + val artworkUri = AlbumArtUtils.ensureAlbumArtCachedFile(context, songId) + ?.let { LocalArtworkUri.buildSongUri(songId) } + musicDao.updateSongAlbumArt(songId, artworkUri) + imageCacheManager.invalidateRenderedCoverArt(LocalArtworkUri.buildSongUri(songId)) + remaining[songId] = artworkUri + if (remainingForAlbum == null) remainingForAlbum = artworkUri + } + + if (coversWholeAlbum(albumId, songIds)) { + musicDao.updateAlbumArt(requireNotNull(albumId), remainingForAlbum) + } + + albumArtThemeDao.deleteThemesByUris(artworkUris) + _appliedArtworkRevision.update { it + 1 } + remaining + } + + /** + * Whether [songIds] accounts for every track of [albumId] this writer can + * give a cover to. Cloud tracks are left out of the count as they are left + * out of the write, or an album holding one is permanently short. + */ + private suspend fun coversWholeAlbum(albumId: Long?, songIds: List): Boolean { + val id = albumId ?: return false + val albumSongIds = musicDao.getSongsByAlbumIdOnce(id).map { it.id }.filter { it > 0 } + return albumSongIds.isNotEmpty() && songIds.containsAll(albumSongIds) + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/coverart/AutoCoverArtFetcher.kt b/app/src/main/java/com/theveloper/pixelplay/data/coverart/AutoCoverArtFetcher.kt new file mode 100644 index 0000000000..ce070cfa1c --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/coverart/AutoCoverArtFetcher.kt @@ -0,0 +1,304 @@ +package com.theveloper.pixelplay.data.coverart + +import android.content.Context +import com.theveloper.pixelplay.data.database.MusicDao +import com.theveloper.pixelplay.data.preferences.UserPreferencesRepository +import com.theveloper.pixelplay.data.repository.CoverArtSearchRepository +import com.theveloper.pixelplay.utils.DirectoryFilterUtils +import com.theveloper.pixelplay.utils.AlbumArtUtils +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.File +import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Outcome of one auto fetch pass, for the worker's log line. + */ +data class AutoCoverArtResult( + val albumsChecked: Int = 0, + val coversApplied: Int = 0, + val notFound: Int = 0, + /** True when the pass stopped at its cap, so albums are still waiting. */ + val reachedLimit: Boolean = false +) + +/** + * Fills in covers for albums that have none. + * + * Nothing reaches the user's audio files: the image goes into the applied + * store, one per album. Only confident matches are applied -- a cover chosen + * without anyone looking is worse than no cover -- and albums no catalog + * matched are remembered so the next pass skips them. + */ +@Singleton +class AutoCoverArtFetcher @Inject constructor( + @ApplicationContext private val context: Context, + private val musicDao: MusicDao, + private val coverArtSearchRepository: CoverArtSearchRepository, + private val appArtworkWriter: AppArtworkWriter, + private val userPreferencesRepository: UserPreferencesRepository +) { + + /** + * @param isStopped consulted between albums so a pass the system is taking + * back stops at an album boundary instead of part way through one. + */ + suspend fun fetchMissingCovers( + albumLimit: Int = DEFAULT_ALBUM_LIMIT, + isStopped: () -> Boolean = { false }, + /** Exposed so tests do not have to wait out the real pacing. */ + perAlbumDelayMs: Long = PER_ALBUM_DELAY_MS + ): AutoCoverArtResult = + withContext(Dispatchers.IO) { + val alreadyMissed = userPreferencesRepository.albumArtNotFoundIdsFlow.first() + + // Excluded folders stay out of it: their album names would + // otherwise be sent to third-party catalogs. Resolved through the + // shared helper because exclusion is the blocked set, not the + // allowed one, and the query matches parent directories rather + // than the roots the user configured. + val (allowedParentDirs, applyDirectoryFilter) = DirectoryFilterUtils.computeAllowedParentDirs( + allowedDirs = userPreferencesRepository.allowedDirectoriesFlow.first(), + blockedDirs = userPreferencesRepository.blockedDirectoriesFlow.first(), + getAllParentDirs = { musicDao.getDistinctParentDirectories() }, + normalizePath = { path -> java.io.File(path).absolutePath } + ) + val albums = musicDao.getAllAlbumsList( + allowedParentDirs = allowedParentDirs, + applyDirectoryFilter = applyDirectoryFilter, + minTracks = 1 + ) + + // Dead ends are MediaStore album ids, which a re-index does not + // preserve, so the set is pruned to the ones still present. Only + // against a listing that speaks for the whole library: a filtered + // or empty one is missing albums that are still there, and pruning + // against it would send the next pass back to the catalogs. + if (!applyDirectoryFilter && albums.isNotEmpty()) { + val stillPresent = alreadyMissed intersect albums.mapTo(mutableSetOf()) { it.id } + if (stillPresent.size != alreadyMissed.size) { + userPreferencesRepository.setAlbumArtNotFoundIds(stillPresent) + } + } + + var checked = 0 + var applied = 0 + var notFound = 0 + var failures = 0 + // MusicBrainz asks for roughly one request per second, and this runs + // unattended, so there is no reason to push it. + var searchedPreviousAlbum = false + var reachedLimit = false + val missed = mutableSetOf() + + // Recorded as the pass goes: one the system stops part way would + // otherwise persist nothing and be re-queried from the start. In + // batches, because each write rewrites the whole preferences file. + suspend fun checkpointMissed(force: Boolean = false) { + if (missed.isEmpty()) return + if (!force && missed.size < MISSED_CHECKPOINT_SIZE) return + userPreferencesRepository.addAlbumArtNotFoundIds(missed) + missed.clear() + } + + for (album in albums) { + if (applied + notFound >= albumLimit) { + reachedLimit = true + break + } + if (isStopped()) { + checkpointMissed(force = true) + break + } + if (album.id in alreadyMissed) continue + + // With no artist to compare against, scoring falls to the + // title alone and any release sharing a common one -- "Greatest + // Hits" -- comes back an exact match. Fine for the picker, where + // a person decides; here it would apply an unrelated cover with + // nobody watching. Not a dead end: nothing was asked. + if (isUnidentifiable(album.title) || isUnidentifiable(album.artistName)) continue + + // Paced before the album rather than after, so every path that + // spent a request pays the wait -- including the ones that give + // up early, which are what reach the slow catalog. + if (searchedPreviousAlbum) { + searchedPreviousAlbum = false + delay(perAlbumDelayMs) + } + + // Cloud tracks look art-less here whatever cover the user sees: + // theirs lives on the server, behind a scheme this pass cannot + // resolve. Applying would overwrite a working remote URI with a + // guess, and the remove action skips them, leaving no way back. + val songs = musicDao.getSongsByAlbumIdOnce(album.id).filter { it.id > 0 } + if (songs.isEmpty()) continue + // Every track, not just the first: an applied cover outranks + // extracted art and covers the whole album, so a compilation + // whose opener lacks embedded art would lose the real artwork + // on all the others. + if (songs.any { !isMissingArtwork(it.id, it.filePath) }) continue + + checked++ + searchedPreviousAlbum = true + val candidate = bestCandidateFor(album.title, album.artistName).getOrElse { error -> + // Left alone rather than remembered: a search that got no + // answer says nothing about whether a cover exists, and the + // not-found list is never revisited on its own. The counter + // resets only once an album goes through cleanly, below -- + // resetting on search alone hid a run of storage failures. + Timber.tag(TAG).w("Auto cover art search failed for ${album.title}: ${error.message}") + if (++failures >= MAX_CONSECUTIVE_FAILURES) break else continue + } + if (candidate == null) { + // No match is a legitimate outcome, not a failure: the + // catalogs answered, and the run is healthy. + failures = 0 + missed += album.id + notFound++ + checkpointMissed() + continue + } + + val application = applyCover(candidate, album.id, songs.map { it.id }) + if (application.isSuccess) { + failures = 0 + applied++ + } else { + // A cover was found; only fetching it failed. Remembering the + // album here would blacklist it over a download that a later + // pass would have completed. + Timber.tag(TAG).w( + "Could not apply cover for ${album.title}: " + + "${application.exceptionOrNull()?.message}" + ) + if (++failures >= MAX_CONSECUTIVE_FAILURES) break else continue + } + } + + checkpointMissed(force = true) + AutoCoverArtResult( + albumsChecked = checked, + coversApplied = applied, + notFound = notFound, + reachedLimit = reachedLimit + ) + } + + /** + * Whether [value] says nothing about which release this is. + * + * MediaStore fills unreadable fields in with a placeholder rather than + * leaving them empty, so both forms have to be recognised as the absence + * they are. + */ + private fun isUnidentifiable(value: String): Boolean { + val normalized = value.trim().lowercase(java.util.Locale.ROOT) + return normalized.isEmpty() || + normalized == "" || + normalized == "unknown" || + normalized == "unknown artist" || + normalized == "unknown album" + } + + /** + * Whether this one track yields no artwork, resolved the same way the UI + * resolves it when it draws the track. + */ + private fun isMissingArtwork(songId: Long, path: String?): Boolean = + AlbumArtUtils.ensureAlbumArtCachedFile( + appContext = context, + songId = songId, + filePath = path + ) == null + + /** + * The cover to apply, or null when the catalogs answered and none had one + * worth taking. + * + * A failure stays a failure rather than folding into "no match", which the + * caller remembers for good. That includes one catalog failing while + * another answered, unless what arrived is good enough to apply: the + * catalogs barely overlap, so the one that timed out may be the only one + * carrying the release. + */ + private suspend fun bestCandidateFor(album: String, artist: String): Result { + val outcome = coverArtSearchRepository + // Anything below the bar is discarded anyway, so once a direct + // catalog has answered this well there is nothing to gain by + // waiting on the slow one. + .search(album = album, artist = artist, confidentMatchScore = MIN_AUTO_SCORE) + val best = outcome.candidates.firstOrNull()?.takeIf { it.score >= MIN_AUTO_SCORE } + return when { + best != null -> Result.success(best) + outcome.failure != null -> Result.failure(outcome.failure) + else -> Result.success(null) + } + } + + /** + * Applies [candidate] to [songIds], or reports why it could not. + * + * Always through the app's own store, whatever the user chose for manual + * applies: embedding needs consent per file and there is nobody to ask + * here. A failure is a download or a disk, not an album without a cover. + */ + private suspend fun applyCover( + candidate: CoverArtCandidate, + albumId: Long, + songIds: List + ): Result { + val downloaded = coverArtSearchRepository.downloadCandidate(candidate) + .getOrElse { error -> return Result.failure(error) } + val bytes = try { + downloaded.path?.let { File(it).readBytes() } + ?: return Result.failure(IOException("Downloaded cover had no path")) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + return Result.failure(error) + } + + val stored = appArtworkWriter.apply(bytes = bytes, songIds = songIds, albumId = albumId) + // A cover the store failed to write is a cover this pass did not apply, + // whatever the download did: treating it as a download failure is what + // keeps a full disk from being remembered as forty found covers and + // chaining the pass into re-fetching them forever. + return if (stored) Result.success(Unit) else Result.failure(IOException("Could not store the cover")) + } + + companion object { + private const val TAG = "AutoCoverArtFetcher" + + /** + * Confidence required to apply a cover unattended, on the 0..1 scale + * [CoverArtQuery] produces. Comfortably above a coincidental match, + * below the near-exact score an identical title and artist would give. + */ + internal const val MIN_AUTO_SCORE = 0.7f + + /** + * Searches or applies that can fail in a row before the pass gives up + * on this run. + * + * A handful in a row is a network or storage problem rather than a + * library one, and there is nothing to learn by asking about every + * remaining album. + */ + private const val MAX_CONSECUTIVE_FAILURES = 5 + + /** Albums touched per pass, so an unattended run stays bounded. */ + private const val DEFAULT_ALBUM_LIMIT = 40 + + /** Dead ends held back before a preferences write, which rewrites the file. */ + private const val MISSED_CHECKPOINT_SIZE = 5 + private const val PER_ALBUM_DELAY_MS = 1_100L + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/coverart/CoverArtCandidate.kt b/app/src/main/java/com/theveloper/pixelplay/data/coverart/CoverArtCandidate.kt new file mode 100644 index 0000000000..9d195ce85d --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/coverart/CoverArtCandidate.kt @@ -0,0 +1,155 @@ +package com.theveloper.pixelplay.data.coverart + +import java.security.MessageDigest + +/** + * Catalog a cover art candidate was found in. + * + * [label] is a proper noun shown verbatim in the picker, so it is intentionally + * not a translatable resource. + */ +enum class CoverArtSource(val label: String) { + DEEZER("Deezer"), + ITUNES("iTunes"), + COVER_ART_ARCHIVE("Cover Art Archive"), + WEB_IMAGE_SEARCH("Web search"); + + /** + * Catalogs return structured releases that can be scored against the + * album's tags. A web search returns pictures with a page title, which + * cannot be scored the same way. + */ + val isCatalog: Boolean get() = this != WEB_IMAGE_SEARCH + + /** + * True for catalogs that answer a search in one request. + * + * The Cover Art Archive is reached through MusicBrainz -- a release query + * plus a lookup per release, rate-limited to about one a second -- so it + * costs seconds where Deezer and iTunes cost hundreds of milliseconds. + */ + val isDirectLookup: Boolean get() = this == DEEZER || this == ITUNES +} + +/** + * Pixel dimensions of a candidate, plus its weight in bytes once known. + * + * Providers publish a nominal size for the image they hand out (Deezer states + * one for its largest cover, iTunes resizes to whatever size is requested), + * which is shown immediately. [measured] marks the sizes that were read back from the + * image header instead of assumed. + */ +data class CoverArtSize( + val width: Int, + val height: Int, + val byteCount: Long? = null, + val measured: Boolean = false +) + +/** + * A single cover art result offered to the user. + * + * @property id Stable key for lazy lists, unique across sources. + * @property artistName As reported by the source, and blank for a web result. + * @property thumbnailUrl Small image used for the results grid. + * @property imageUrl Largest available image, downloaded once the user picks it. + * @property score Match confidence in `0f..1f`, assigned by [CoverArtQuery.rank]. + * @property size Nominal size from the provider, replaced by the measured size + * once the image header has been probed. Null when the provider cannot say. + */ +data class CoverArtCandidate( + val id: String, + val albumTitle: String, + val artistName: String, + val thumbnailUrl: String, + val imageUrl: String, + val source: CoverArtSource, + val score: Float = 0f, + val size: CoverArtSize? = null +) + +/** + * What the user is looking for. Values are already trimmed by the repository. + */ +data class CoverArtSearchRequest( + val album: String, + val artist: String, + val limit: Int +) + +/** + * A catalog that can be asked for cover art candidates. + * + * Implementations are expected to be stateless and to throw on transport + * failures; the repository decides how to run them, how to retry and how to + * merge what they return. + */ +interface CoverArtProvider { + val source: CoverArtSource + + /** + * False for a provider the user has not configured, so it is left out of a + * search entirely rather than reported as a catalog that found nothing. + */ + suspend fun isAvailable(): Boolean = true + + suspend fun search(request: CoverArtSearchRequest): List +} + +/** + * How one catalog is doing within a search. + * + * @property resultCount Results it contributed, meaningful once it has answered. + */ +data class CoverArtProviderStatus( + val source: CoverArtSource, + val isSearching: Boolean, + val resultCount: Int = 0, + val failed: Boolean = false +) + +/** + * One snapshot of a streaming search: everything found so far, ranked. + * + * @property statuses Per catalog progress, in provider order, so the UI can show + * which ones have answered while the rest are still running. + * @property isComplete True once every catalog has answered. + * @property failure Set only when the search finished without a single result + * and at least one catalog failed, so the UI can tell "nothing matched" apart + * from "nothing answered". + */ +data class CoverArtSearchUpdate( + val candidates: List, + val statuses: List, + val isComplete: Boolean, + val failure: Throwable? = null +) + +/** + * What a completed one-shot search turned up, ranked, and whether every catalog + * it asked actually answered. + * + * A caller acting without a person watching has to tell "no cover exists" from + * "the question never got asked". + * + * @property failure The first failure among the catalogs, set whenever any of + * them failed -- including when others answered. What arrived is still worth + * using; the failure says the absence of *more* means nothing. + */ +data class CoverArtSearchOutcome( + val candidates: List, + val failure: Throwable? = null +) + +/** + * A stable id for a candidate a catalog gave no id of its own. + * + * Derived from the image URL with a real digest rather than [String.hashCode]: + * ids key the results grid and the measured sizes folded into it, so two + * candidates colliding would show one cover's resolution under another. + */ +internal fun candidateIdFor(imageUrl: String): String = + MessageDigest.getInstance("SHA-256") + .digest(imageUrl.toByteArray()) + .take(8) + .joinToString("") { byte -> "%02x".format(byte) } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/coverart/CoverArtImageHeader.kt b/app/src/main/java/com/theveloper/pixelplay/data/coverart/CoverArtImageHeader.kt new file mode 100644 index 0000000000..0eb0242b5e --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/coverart/CoverArtImageHeader.kt @@ -0,0 +1,156 @@ +package com.theveloper.pixelplay.data.coverart + +/** + * Reads image dimensions out of the first bytes of a file. + * + * Catalogs do not report how big their images actually are, and downloading two + * dozen full covers just to show a resolution would be absurd, so the picker + * asks each host for a small prefix of the image and parses the header here. + * + * Pure and allocation-light on purpose: JPEG, PNG, WebP and GIF cover every + * format the supported catalogs serve. + */ +object CoverArtImageHeader { + + /** Bytes worth requesting: enough for a JPEG to reach its first SOF marker. */ + const val PROBE_BYTES: Int = 32 * 1024 + + /** + * Returns the pixel dimensions encoded in [bytes], or null when the prefix + * is too short, damaged, or in a format this parser does not handle. + */ + fun readDimensions(bytes: ByteArray): Pair? = when { + isJpeg(bytes) -> readJpeg(bytes) + isPng(bytes) -> readPng(bytes) + isWebp(bytes) -> readWebp(bytes) + isGif(bytes) -> readGif(bytes) + else -> null + } + + private fun isJpeg(bytes: ByteArray) = + bytes.size >= 2 && bytes.u8(0) == 0xFF && bytes.u8(1) == 0xD8 + + private fun isPng(bytes: ByteArray) = + bytes.size >= 8 && bytes.u8(0) == 0x89 && bytes.u8(1) == 0x50 && + bytes.u8(2) == 0x4E && bytes.u8(3) == 0x47 + + private fun isWebp(bytes: ByteArray) = + bytes.size >= 16 && bytes.ascii(0, 4) == "RIFF" && bytes.ascii(8, 4) == "WEBP" + + private fun isGif(bytes: ByteArray) = + bytes.size >= 10 && bytes.ascii(0, 3) == "GIF" + + /** + * Walks the JPEG marker chain to the first Start Of Frame, which is where + * the dimensions live. Skips over the metadata segments (EXIF, ICC, XMP) + * that publishers like to put in front of the image data. + */ + private fun readJpeg(bytes: ByteArray): Pair? { + var offset = 2 + while (offset + 9 < bytes.size) { + if (bytes.u8(offset) != 0xFF) { + offset++ + continue + } + + val marker = bytes.u8(offset + 1) + when { + // Any number of 0xFF bytes may pad the space before a marker. + // Read as a marker of its own, the two bytes after it are taken + // for a segment length and the walk lands somewhere arbitrary. + marker == 0xFF -> { + offset++ + } + // Standalone markers carry no payload. + marker == 0xD8 || marker == 0x01 || marker in 0xD0..0xD7 -> { + offset += 2 + } + // End of image without a frame header: nothing left to find. + marker == 0xD9 -> return null + // SOF0..SOF15, excluding the DHT/JPG/DAC markers interleaved in that range. + marker in 0xC0..0xCF && marker != 0xC4 && marker != 0xC8 && marker != 0xCC -> { + val height = bytes.u16(offset + 5) + val width = bytes.u16(offset + 7) + return if (width > 0 && height > 0) width to height else null + } + else -> { + val segmentLength = bytes.u16(offset + 2) + if (segmentLength < 2) return null + offset += 2 + segmentLength + } + } + } + return null + } + + private fun readPng(bytes: ByteArray): Pair? { + // 8 byte signature, 4 byte chunk length, "IHDR", then width and height. + if (bytes.size < 24 || bytes.ascii(12, 4) != "IHDR") return null + val width = bytes.u32(16) + val height = bytes.u32(20) + return if (width > 0 && height > 0) width to height else null + } + + private fun readWebp(bytes: ByteArray): Pair? = when (bytes.ascii(12, 4)) { + "VP8 " -> { + // Lossy: 3 byte frame tag, 3 byte start code, then 14 bit dimensions. + if (bytes.size < 30) null + else { + val width = bytes.u16le(26) and 0x3FFF + val height = bytes.u16le(28) and 0x3FFF + if (width > 0 && height > 0) width to height else null + } + } + + "VP8L" -> { + // Lossless: signature byte, then 14 bit width and height packed together. + if (bytes.size < 25 || bytes.u8(20) != 0x2F) null + else { + val packed = bytes.u32le(21) + val width = (packed and 0x3FFF) + 1 + val height = ((packed shr 14) and 0x3FFF) + 1 + width to height + } + } + + "VP8X" -> { + // Extended: 4 byte flags, then 24 bit canvas dimensions minus one. + if (bytes.size < 30) null + else { + val width = bytes.u24le(24) + 1 + val height = bytes.u24le(27) + 1 + width to height + } + } + + else -> null + } + + private fun readGif(bytes: ByteArray): Pair? { + val width = bytes.u16le(6) + val height = bytes.u16le(8) + return if (width > 0 && height > 0) width to height else null + } + + private fun ByteArray.u8(index: Int): Int = this[index].toInt() and 0xFF + + private fun ByteArray.u16(index: Int): Int = (u8(index) shl 8) or u8(index + 1) + + private fun ByteArray.u16le(index: Int): Int = (u8(index + 1) shl 8) or u8(index) + + private fun ByteArray.u24le(index: Int): Int = + (u8(index + 2) shl 16) or (u8(index + 1) shl 8) or u8(index) + + private fun ByteArray.u32(index: Int): Int = + (u8(index) shl 24) or (u8(index + 1) shl 16) or (u8(index + 2) shl 8) or u8(index + 3) + + private fun ByteArray.u32le(index: Int): Int = + (u8(index + 3) shl 24) or (u8(index + 2) shl 16) or (u8(index + 1) shl 8) or u8(index) + + private fun ByteArray.ascii(index: Int, length: Int): String? { + if (index + length > size) return null + return buildString(length) { + for (i in index until index + length) append(this@ascii.u8(i).toChar()) + } + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/coverart/CoverArtQuery.kt b/app/src/main/java/com/theveloper/pixelplay/data/coverart/CoverArtQuery.kt new file mode 100644 index 0000000000..c3c3cecd86 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/coverart/CoverArtQuery.kt @@ -0,0 +1,223 @@ +package com.theveloper.pixelplay.data.coverart + +import java.text.Normalizer +import java.util.Locale +import kotlin.math.max +import kotlin.math.min + +/** + * Text normalization and match scoring for cover art search. + * + * Everything here is pure and free of Android dependencies so the matching + * rules β€” the part that decides whether a user sees the right cover β€” can be + * unit tested on the JVM. + * + * Tags in real libraries carry edition noise ("Abbey Road (Remastered 2019)", + * "Nevermind - Deluxe Edition") that catalogs do not, so the album title is + * reduced to its core before being compared. + */ +object CoverArtQuery { + + /** Candidates scoring below this are dropped as unrelated. */ + private const val MIN_SCORE = 0.2f + + /** Cost of the edit distance grows with the product of both lengths. */ + private const val COMPARISON_LENGTH_CAP = 120 + + private const val ALBUM_WEIGHT = 0.65f + private const val ARTIST_WEIGHT = 0.35f + private const val EXACT_ALBUM_BONUS = 0.03f + private const val EXACT_ARTIST_BONUS = 0.02f + + private val EDITION_KEYWORDS = setOf( + "deluxe", "remaster", "remastered", "remasterized", "edition", "version", + "bonus", "explicit", "clean", "expanded", "anniversary", "reissue", + "mono", "stereo", "special", "extended", "limited", "collector", + "collectors", "disc", "disk", "cd", "volume", "vol", "soundtrack", + "ost", "single", "ep", "live" + ) + + private val BRACKETED = Regex("[(\\[{]([^(){}\\[\\]]*)[)\\]}]") + private val FEATURING = Regex("\\s+(feat|ft|featuring|con|with)\\b\\.?.*$") + private val DIACRITICS = Regex("\\p{Mn}+") + private val APOSTROPHES = Regex("['β€˜β€™ΚΌ`Β΄]") + private val NON_ALPHANUMERIC = Regex("[^\\p{L}\\p{Nd}\\s]") + private val WHITESPACE = Regex("\\s+") + + /** + * Reduces an album title to a comparable core: no diacritics, no edition + * suffixes, no punctuation. + */ + fun normalizeAlbum(raw: String): String { + val withoutDiacritics = stripDiacritics(raw) + val withoutEditionBrackets = dropEditionBrackets(withoutDiacritics) + val withoutEditionSuffix = dropEditionSuffix(withoutEditionBrackets) + return collapse(withoutEditionSuffix) + } + + /** + * Reduces an artist name the same way, additionally dropping "feat." style + * credits and spelling out ampersands so "Simon & Garfunkel" and + * "Simon and Garfunkel" compare as equal. + */ + fun normalizeArtist(raw: String): String { + val withoutDiacritics = stripDiacritics(raw).replace("&", " and ") + // Brackets first: "Artist (feat. Other)" only exposes the credit to the + // featuring pattern once the parenthesis is gone. + val withoutEditionBrackets = dropEditionBrackets(withoutDiacritics) + val withoutFeaturing = FEATURING.replace(withoutEditionBrackets, "") + return collapse(withoutFeaturing) + } + + /** + * Similarity of two already-normalized strings, in `0f..1f`. + * Returns `0f` when either side is empty. + */ + fun similarity(left: String, right: String): Float { + if (left.isEmpty() || right.isEmpty()) return 0f + if (left == right) return 1f + + val a = left.take(COMPARISON_LENGTH_CAP) + val b = right.take(COMPARISON_LENGTH_CAP) + val distance = levenshtein(a, b) + val longest = max(a.length, b.length) + return (1f - distance.toFloat() / longest).coerceIn(0f, 1f) + } + + /** + * Scores one candidate against the query. When the query carries no artist, + * the album similarity alone decides the score instead of penalizing every + * candidate for a comparison that cannot be made. + */ + fun score( + candidateAlbum: String, + candidateArtist: String, + queryAlbum: String, + queryArtist: String + ): Float { + val normalizedQueryAlbum = normalizeAlbum(queryAlbum) + val normalizedQueryArtist = normalizeArtist(queryArtist) + return scoreNormalized( + candidateAlbum = candidateAlbum, + candidateArtist = candidateArtist, + normalizedQueryAlbum = normalizedQueryAlbum, + normalizedQueryArtist = normalizedQueryArtist + ) + } + + /** + * Scores every candidate, drops the unrelated ones and returns the rest + * best first. Ties break on album title so results stay stable between + * identical searches. + */ + fun rank( + candidates: List, + queryAlbum: String, + queryArtist: String + ): List { + val normalizedQueryAlbum = normalizeAlbum(queryAlbum) + val normalizedQueryArtist = normalizeArtist(queryArtist) + + return candidates + .map { candidate -> + candidate.copy( + score = scoreNormalized( + candidateAlbum = candidate.albumTitle, + candidateArtist = candidate.artistName, + normalizedQueryAlbum = normalizedQueryAlbum, + normalizedQueryArtist = normalizedQueryArtist + ) + ) + } + .filter { it.score >= MIN_SCORE } + .sortedWith( + compareByDescending { it.score } + .thenBy { it.albumTitle.lowercase(Locale.ROOT) } + ) + } + + private fun scoreNormalized( + candidateAlbum: String, + candidateArtist: String, + normalizedQueryAlbum: String, + normalizedQueryArtist: String + ): Float { + val albumSimilarity = similarity(normalizeAlbum(candidateAlbum), normalizedQueryAlbum) + val artistSimilarity = similarity(normalizeArtist(candidateArtist), normalizedQueryArtist) + + val base = when { + normalizedQueryArtist.isEmpty() -> albumSimilarity + normalizedQueryAlbum.isEmpty() -> artistSimilarity + else -> ALBUM_WEIGHT * albumSimilarity + ARTIST_WEIGHT * artistSimilarity + } + + var score = base + if (albumSimilarity == 1f) score += EXACT_ALBUM_BONUS + if (artistSimilarity == 1f) score += EXACT_ARTIST_BONUS + return score.coerceIn(0f, 1f) + } + + private fun stripDiacritics(raw: String): String { + val lowercase = raw.lowercase(Locale.ROOT) + val decomposed = Normalizer.normalize(lowercase, Normalizer.Form.NFD) + return DIACRITICS.replace(decomposed, "") + } + + /** + * Removes bracketed groups that only carry edition noise, and unwraps the + * ones that carry real title content ("Blue Train (The Ultimate Blue Train)"). + */ + private fun dropEditionBrackets(value: String): String { + return BRACKETED.replace(value) { match -> + val inner = match.groupValues[1] + if (containsEditionKeyword(inner)) " " else " $inner " + } + } + + /** + * Drops trailing `- Remastered 2011` style suffixes, keeping at least the + * first segment so a title that is entirely made of keywords survives. + */ + private fun dropEditionSuffix(value: String): String { + val segments = value.split(" - ") + if (segments.size < 2) return value + + val kept = segments.toMutableList() + while (kept.size > 1 && containsEditionKeyword(kept.last())) { + kept.removeAt(kept.lastIndex) + } + return kept.joinToString(" - ") + } + + private fun containsEditionKeyword(value: String): Boolean { + val words = collapse(value).split(" ").filter { it.isNotEmpty() } + return words.isNotEmpty() && words.any { word -> + word in EDITION_KEYWORDS || word.toIntOrNull() != null + } + } + + private fun collapse(value: String): String { + // Apostrophes are dropped rather than turned into spaces so "Pepper's" + // stays one word and still matches a catalog spelling it "Peppers". + val withoutApostrophes = APOSTROPHES.replace(value, "") + val withoutPunctuation = NON_ALPHANUMERIC.replace(withoutApostrophes, " ") + return WHITESPACE.replace(withoutPunctuation, " ").trim() + } + + private fun levenshtein(left: String, right: String): Int { + var previous = IntArray(right.length + 1) { it } + var current = IntArray(right.length + 1) + + for (i in 1..left.length) { + current[0] = i + for (j in 1..right.length) { + val substitution = previous[j - 1] + if (left[i - 1] == right[j - 1]) 0 else 1 + current[j] = min(min(current[j - 1] + 1, previous[j] + 1), substitution) + } + val swap = previous + previous = current + current = swap + } + return previous[right.length] + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/coverart/DeezerCoverArtProvider.kt b/app/src/main/java/com/theveloper/pixelplay/data/coverart/DeezerCoverArtProvider.kt new file mode 100644 index 0000000000..8d787fdbd7 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/coverart/DeezerCoverArtProvider.kt @@ -0,0 +1,85 @@ +package com.theveloper.pixelplay.data.coverart + +import com.theveloper.pixelplay.data.network.deezer.DeezerAlbum +import com.theveloper.pixelplay.data.network.deezer.DeezerApiService +import javax.inject.Inject + +/** + * Cover art candidates from Deezer's public album search. + * + * Uses the same unauthenticated catalog API that already backs artist images. + */ +class DeezerCoverArtProvider @Inject constructor( + private val deezerApiService: DeezerApiService +) : CoverArtProvider { + + override val source: CoverArtSource = CoverArtSource.DEEZER + + override suspend fun search(request: CoverArtSearchRequest): List { + for (query in buildQueries(album = request.album, artist = request.artist)) { + val candidates = deezerApiService + .searchAlbum(query = query, limit = request.limit) + .data + .mapNotNull(::toCandidate) + + if (candidates.isNotEmpty()) return candidates + } + return emptyList() + } + + private fun toCandidate(album: DeezerAlbum): CoverArtCandidate? { + val fullSize = album.coverXl ?: album.coverBig ?: album.coverMedium ?: album.cover + if (fullSize.isNullOrBlank()) return null + + val thumbnail = album.coverMedium ?: album.coverBig ?: fullSize + // cover_xl is always served at 1000x1000; anything smaller means the XL + // rendition was missing and the size is then unknown. + val nominalSize = if (fullSize == album.coverXl) { + CoverArtSize(width = XL_COVER_SIZE_PX, height = XL_COVER_SIZE_PX) + } else { + null + } + + return CoverArtCandidate( + id = "${CoverArtSource.DEEZER.name}:${album.id}", + albumTitle = album.title, + artistName = album.artist?.name.orEmpty(), + thumbnailUrl = thumbnail, + imageUrl = fullSize, + source = CoverArtSource.DEEZER, + size = nominalSize + ) + } + + companion object { + private const val XL_COVER_SIZE_PX = 1000 + + /** + * Builds the queries to try in order. + * + * Deezer's advanced syntax is precise but unforgiving β€” a single stray + * edition suffix returns nothing β€” so a free-text query is kept as a + * fallback for when the strict one comes back empty. + */ + internal fun buildQueries(album: String, artist: String): List { + val cleanAlbum = album.trim() + val cleanArtist = artist.trim() + if (cleanAlbum.isEmpty() && cleanArtist.isEmpty()) return emptyList() + + val advanced = buildString { + if (cleanArtist.isNotEmpty()) append("artist:\"${escape(cleanArtist)}\"") + if (cleanAlbum.isNotEmpty()) { + if (isNotEmpty()) append(' ') + append("album:\"${escape(cleanAlbum)}\"") + } + } + val freeText = listOf(cleanArtist, cleanAlbum) + .filter { it.isNotEmpty() } + .joinToString(" ") + + return listOf(advanced, freeText).distinct() + } + + private fun escape(value: String): String = value.replace("\"", " ").trim() + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/coverart/ItunesCoverArtProvider.kt b/app/src/main/java/com/theveloper/pixelplay/data/coverart/ItunesCoverArtProvider.kt new file mode 100644 index 0000000000..7ff5c61339 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/coverart/ItunesCoverArtProvider.kt @@ -0,0 +1,88 @@ +package com.theveloper.pixelplay.data.coverart + +import com.theveloper.pixelplay.data.network.itunes.ItunesAlbum +import com.theveloper.pixelplay.data.network.itunes.ItunesApiService +import javax.inject.Inject + +/** + * Cover art candidates from the iTunes Search API. + * + * iTunes only ever reports a 100x100 artwork URL, but the size is encoded in + * the path, so a larger rendition is requested by rewriting it. Apple resizes + * from the master, which is why the size handed back is nominal until the + * picker measures the real image. + */ +class ItunesCoverArtProvider @Inject constructor( + private val itunesApiService: ItunesApiService +) : CoverArtProvider { + + override val source: CoverArtSource = CoverArtSource.ITUNES + + override suspend fun search(request: CoverArtSearchRequest): List { + val term = buildTerm(album = request.album, artist = request.artist) + ?: return emptyList() + + return itunesApiService + .searchAlbums(term = term, limit = request.limit) + .results + .mapNotNull(::toCandidate) + } + + private fun toCandidate(album: ItunesAlbum): CoverArtCandidate? { + val artwork = album.artworkUrl100?.takeIf { it.isNotBlank() } ?: return null + val title = album.collectionName?.takeIf { it.isNotBlank() } ?: return null + + // collectionId is occasionally absent, and two results sharing an id + // would collide as lazy list keys, so the artwork URL backs the id. + val id = album.collectionId.takeIf { it != 0L }?.toString() + ?: candidateIdFor(artwork) + + // Only a URL carrying a size segment can be asked for a larger + // rendition; the rest serve the 100x100 original. Claiming the requested + // size regardless put a resolution on the tile the image does not have. + val isResizable = ARTWORK_SIZE.containsMatchIn(artwork) + + return CoverArtCandidate( + id = "${CoverArtSource.ITUNES.name}:$id", + albumTitle = title, + artistName = album.artistName.orEmpty(), + thumbnailUrl = resizeArtwork(artwork, THUMBNAIL_SIZE_PX), + imageUrl = resizeArtwork(artwork, FULL_SIZE_PX), + source = CoverArtSource.ITUNES, + size = if (isResizable) { + CoverArtSize(width = FULL_SIZE_PX, height = FULL_SIZE_PX) + } else { + null + } + ) + } + + companion object { + private const val THUMBNAIL_SIZE_PX = 300 + private const val FULL_SIZE_PX = 1200 + private val ARTWORK_SIZE = Regex("/\\d+x\\d+(bb)?\\.(jpg|png)$") + + /** + * iTunes has no fielded query syntax, so artist and album are simply + * concatenated the way a person would type them into the store search. + */ + internal fun buildTerm(album: String, artist: String): String? { + val term = listOf(artist.trim(), album.trim()) + .filter { it.isNotEmpty() } + .joinToString(" ") + return term.ifEmpty { null } + } + + /** + * Rewrites the size segment of an artwork URL, e.g. + * `.../source/100x100bb.jpg` to `.../source/1200x1200bb.jpg`. + * URLs that do not carry a size segment are left untouched. + */ + internal fun resizeArtwork(url: String, sizePx: Int): String { + val match = ARTWORK_SIZE.find(url) ?: return url + val suffix = match.groupValues[1] + val extension = match.groupValues[2] + return url.replaceRange(match.range, "/${sizePx}x${sizePx}$suffix.$extension") + } + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/coverart/MusicBrainzCoverArtProvider.kt b/app/src/main/java/com/theveloper/pixelplay/data/coverart/MusicBrainzCoverArtProvider.kt new file mode 100644 index 0000000000..8a371734e2 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/coverart/MusicBrainzCoverArtProvider.kt @@ -0,0 +1,129 @@ +package com.theveloper.pixelplay.data.coverart + +import com.theveloper.pixelplay.data.network.coverartarchive.CoverArtArchiveApiService +import com.theveloper.pixelplay.data.network.coverartarchive.MusicBrainzApiService +import com.theveloper.pixelplay.data.network.coverartarchive.MusicBrainzRelease +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import timber.log.Timber +import javax.inject.Inject + +/** + * Cover art candidates from MusicBrainz releases backed by the Cover Art Archive. + * + * Two hops are unavoidable: MusicBrainz knows which releases match the query, + * the Archive knows which of them actually have artwork. The release lookups run + * concurrently against the Archive, which is a plain file host, while the single + * MusicBrainz query respects its one-request-per-second etiquette. + * + * Releases with no artwork answer 404, which is a normal outcome here and is + * dropped rather than surfaced as a failure. + */ +class MusicBrainzCoverArtProvider @Inject constructor( + private val musicBrainzApiService: MusicBrainzApiService, + private val coverArtArchiveApiService: CoverArtArchiveApiService +) : CoverArtProvider { + + override val source: CoverArtSource = CoverArtSource.COVER_ART_ARCHIVE + + override suspend fun search(request: CoverArtSearchRequest): List { + val query = buildQuery(album = request.album, artist = request.artist) + ?: return emptyList() + + val releases = musicBrainzApiService + .searchReleases(query = query, limit = RELEASE_LOOKUP_LIMIT) + .releases + .take(RELEASE_LOOKUP_LIMIT) + + if (releases.isEmpty()) return emptyList() + + val permits = Semaphore(ARCHIVE_CONCURRENCY) + return coroutineScope { + releases + .map { release -> async { permits.withPermit { toCandidate(release) } } } + .awaitAll() + .filterNotNull() + } + } + + private suspend fun toCandidate(release: MusicBrainzRelease): CoverArtCandidate? { + val response = try { + coverArtArchiveApiService.getReleaseCoverArt(release.id) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + // Usually a 404, which simply means this release has no artwork -- + // but a timeout or a 5xx lands here too and is not the same thing. + // The error is logged rather than swallowed so an outage does not + // read as a library-wide absence of covers. + Timber.tag(TAG).d(error, "No Cover Art Archive entry for release ${release.id}") + return null + } + + val front = response.images.firstOrNull { it.isFront } ?: response.images.firstOrNull() + val thumbnails = front?.thumbnails + // Prefer the 1200px rendition, falling back to the original upload. + val fullSize = thumbnails?.size1200 ?: front?.image ?: return null + // Anything but the original for the grid, so a multi-megabyte scan is + // never loaded just to draw a tile. + val thumbnail = thumbnails?.size250 + ?: thumbnails?.small + ?: thumbnails?.size500 + ?: thumbnails?.large + ?: fullSize + + return CoverArtCandidate( + id = "${CoverArtSource.COVER_ART_ARCHIVE.name}:${release.id}", + albumTitle = release.title.orEmpty(), + artistName = release.artistCredit.firstOrNull()?.name.orEmpty(), + thumbnailUrl = secure(thumbnail), + imageUrl = secure(fullSize), + source = CoverArtSource.COVER_ART_ARCHIVE + // Size is deliberately left unknown: the Archive stores whatever the + // contributor uploaded, so it is only known once measured. + ) + } + + companion object { + private const val TAG = "MusicBrainzCoverArt" + private const val RELEASE_LOOKUP_LIMIT = 8 + private const val ARCHIVE_CONCURRENCY = 4 + + /** + * The Archive embeds `http://` URLs in its JSON even though every one of + * them serves fine over TLS. Left as-is they are refused by the + * downloader, so the scheme is upgraded here. + */ + internal fun secure(url: String): String = + if (url.startsWith("http://")) "https://" + url.removePrefix("http://") else url + + /** + * Builds a Lucene query for the MusicBrainz release index. Quotes are + * stripped rather than escaped because a stray quote breaks the parse + * and returns a 400 for the whole search. + */ + internal fun buildQuery(album: String, artist: String): String? { + val cleanAlbum = sanitize(album) + val cleanArtist = sanitize(artist) + + return when { + cleanAlbum.isNotEmpty() && cleanArtist.isNotEmpty() -> + "release:\"$cleanAlbum\" AND artist:\"$cleanArtist\"" + + cleanAlbum.isNotEmpty() -> "release:\"$cleanAlbum\"" + cleanArtist.isNotEmpty() -> "artist:\"$cleanArtist\"" + else -> null + } + } + + private fun sanitize(value: String): String = + value.replace(LUCENE_SPECIALS, " ").trim().replace(WHITESPACE, " ") + + private val LUCENE_SPECIALS = Regex("[\"\\\\+\\-!(){}\\[\\]^~*?:/]") + private val WHITESPACE = Regex("\\s+") + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/coverart/WebImageCoverArtProvider.kt b/app/src/main/java/com/theveloper/pixelplay/data/coverart/WebImageCoverArtProvider.kt new file mode 100644 index 0000000000..c72e21ed40 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/coverart/WebImageCoverArtProvider.kt @@ -0,0 +1,98 @@ +package com.theveloper.pixelplay.data.coverart + +import com.theveloper.pixelplay.data.network.webimage.SerperImageSearchApi +import com.theveloper.pixelplay.data.network.webimage.SerperImageSearchRequest +import com.theveloper.pixelplay.data.network.webimage.WebImageSearchEngine +import com.theveloper.pixelplay.data.preferences.UserPreferencesRepository +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +/** + * Cover art candidates from a web image search. + * + * Music catalogs answer with structured releases, which is why they are the + * default and why their results can be scored against the album's tags. A web + * search has none of that structure -- it returns whatever pictures a page + * carried -- so it is disabled unless the user configures it, it runs only when + * asked for by hand, and the measured resolution shown on each tile is what + * makes the results judgeable. + * + * It earns its place on releases no catalog carries at all: Bandcamp-only + * records, private pressings, bootlegs. + * + * The engine requires an account, so the key is the user's own. None is + * shipped, and nothing is queried until one is entered. + */ +class WebImageCoverArtProvider @Inject constructor( + private val serperImageSearchApi: SerperImageSearchApi, + private val userPreferencesRepository: UserPreferencesRepository +) : CoverArtProvider { + + override val source: CoverArtSource = CoverArtSource.WEB_IMAGE_SEARCH + + override suspend fun isAvailable(): Boolean = apiKey() != null + + override suspend fun search(request: CoverArtSearchRequest): List { + val apiKey = apiKey() ?: return emptyList() + val query = buildQuery(album = request.album, artist = request.artist) ?: return emptyList() + + return serperImageSearchApi + .searchImages( + url = WebImageSearchEngine.IMAGES_URL, + apiKey = apiKey, + request = SerperImageSearchRequest(query = query, count = request.limit) + ) + .images + .mapNotNull { result -> + val imageUrl = result.imageUrl?.takeIf { it.startsWith("https://") } + ?: return@mapNotNull null + candidate( + imageUrl = imageUrl, + thumbnailUrl = result.thumbnailUrl ?: imageUrl, + title = result.title.orEmpty(), + width = result.imageWidth, + height = result.imageHeight + ) + } + } + + private suspend fun apiKey(): String? = + userPreferencesRepository.webImageSearchApiKeyFlow.first().takeIf { it.isNotBlank() } + + private fun candidate( + imageUrl: String, + thumbnailUrl: String, + title: String, + width: Int?, + height: Int? + ) = CoverArtCandidate( + id = "${CoverArtSource.WEB_IMAGE_SEARCH.name}:${candidateIdFor(imageUrl)}", + // A web result has no artist field; its page title is all there is. + albumTitle = title, + artistName = "", + thumbnailUrl = thumbnailUrl, + imageUrl = imageUrl, + source = CoverArtSource.WEB_IMAGE_SEARCH, + size = if (width != null && height != null && width > 0 && height > 0) { + CoverArtSize(width = width, height = height) + } else { + null + } + ) + + companion object { + /** + * The artist and album, plus the words that bias an engine towards + * artwork. + * + * Dropping the suffix sounds right -- an image search is already + * looking at pictures -- and measures worse: without it the engine + * ranks photographs of the artist and unrelated art above the cover. + */ + internal fun buildQuery(album: String, artist: String): String? { + val terms = listOf(artist.trim(), album.trim()).filter { it.isNotEmpty() } + if (terms.isEmpty()) return null + return (terms + "album cover").joinToString(" ") + } + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/database/MusicDao.kt b/app/src/main/java/com/theveloper/pixelplay/data/database/MusicDao.kt index 8a141e350e..23e8c53ff1 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/database/MusicDao.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/database/MusicDao.kt @@ -468,6 +468,15 @@ interface MusicDao { @Query("SELECT * FROM songs WHERE album_id = :albumId ORDER BY disc_number ASC, track_number ASC") fun getSongsByAlbumId(albumId: Long): Flow> + /** + * One-shot form, for callers reading an album's tracks once rather than + * watching them: collecting the Flow above registers and tears down an + * invalidation observer per call, which a loop over the library pays for + * on every album. + */ + @Query("SELECT * FROM songs WHERE album_id = :albumId ORDER BY disc_number ASC, track_number ASC") + suspend fun getSongsByAlbumIdOnce(albumId: Long): List + @Query("SELECT * FROM songs WHERE artist_id = :artistId ORDER BY title ASC") fun getSongsByArtistId(artistId: Long): Flow> @@ -1717,6 +1726,14 @@ interface MusicDao { @Query("UPDATE songs SET album_art_uri_string = :albumArtUri WHERE id = :songId") suspend fun updateSongAlbumArt(songId: Long, albumArtUri: String?) + /** + * Album rows carry their own copy of a representative song's artwork URI, + * assembled during a sync. A cover applied between syncs has to set it too, + * or the album grid keeps drawing a placeholder until the next full scan. + */ + @Query("UPDATE albums SET album_art_uri_string = :albumArtUri WHERE id = :albumId") + suspend fun updateAlbumArt(albumId: Long, albumArtUri: String?) + @Query("UPDATE songs SET lyrics = :lyrics WHERE id = :songId") suspend fun updateLyrics(songId: Long, lyrics: String) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/media/ImageCacheManager.kt b/app/src/main/java/com/theveloper/pixelplay/data/media/ImageCacheManager.kt index c1d10a4707..a5b25732a4 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/media/ImageCacheManager.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/media/ImageCacheManager.kt @@ -4,6 +4,8 @@ import android.content.Context import coil.annotation.ExperimentalCoilApi import coil.imageLoader import coil.memory.MemoryCache +import com.theveloper.pixelplay.utils.AlbumArtUtils +import com.theveloper.pixelplay.utils.LocalArtworkUri import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton @@ -12,8 +14,27 @@ import javax.inject.Singleton class ImageCacheManager @Inject constructor( @ApplicationContext private val context: Context ) { + /** + * Drops every cached form of the given artwork, including the extracted + * artwork file, so the next load re-reads the cover from the audio file. + * + * Callers that just wrote new art *into the file* want this. Callers whose + * new cover exists only as a file the app saved want + * [invalidateRenderedCoverArt] instead, or they delete the very image they + * just saved. + */ + fun invalidateCoverArtCaches(vararg uriStrings: String?) = + invalidate(uriStrings, dropExtractedArtwork = true) + + /** + * Drops the rendered bitmaps while keeping the extracted artwork files, for + * covers whose only copy is the cached file itself. + */ + fun invalidateRenderedCoverArt(vararg uriStrings: String?) = + invalidate(uriStrings, dropExtractedArtwork = false) + @OptIn(ExperimentalCoilApi::class) - fun invalidateCoverArtCaches(vararg uriStrings: String?) { + private fun invalidate(uriStrings: Array, dropExtractedArtwork: Boolean) { val imageLoader = context.imageLoader val memoryCache = imageLoader.memoryCache val diskCache = imageLoader.diskCache @@ -23,10 +44,22 @@ class ImageCacheManager @Inject constructor( // This is a best-effort invalidation for common sizes. val knownSizeSuffixes = listOf(null, "128x128", "150x150", "168x168", "256x256", "300x300", "512x512", "600x600", "800x800") - uriStrings.mapNotNull { it?.takeIf(String::isNotBlank) }.forEach { baseUri -> - if (com.theveloper.pixelplay.utils.LocalArtworkUri.isLocalArtworkUri(baseUri)) { - com.theveloper.pixelplay.utils.LocalArtworkUri.parseSongId(baseUri)?.let { songId -> - com.theveloper.pixelplay.utils.AlbumArtUtils.clearCacheForSong(context, songId) + // Album and artist rows point at the query-less form of a song artwork + // URI, while song rows carry a cache busting "?t=" token. Invalidating + // only what was passed in leaves the album grid and album header showing + // the previous cover, so the canonical form is always included. + val expandedUris = uriStrings + .mapNotNull { it?.takeIf(String::isNotBlank) } + .flatMap { uri -> + val canonical = LocalArtworkUri.parseSongId(uri)?.let(LocalArtworkUri::buildSongUri) + listOfNotNull(uri, canonical) + } + .distinct() + + expandedUris.forEach { baseUri -> + if (dropExtractedArtwork && LocalArtworkUri.isLocalArtworkUri(baseUri)) { + LocalArtworkUri.parseSongId(baseUri)?.let { songId -> + AlbumArtUtils.clearCacheForSong(context, songId) } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/media/SongMetadataEditor.kt b/app/src/main/java/com/theveloper/pixelplay/data/media/SongMetadataEditor.kt index e1f7c41eb6..7f2d96fe2f 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/media/SongMetadataEditor.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/media/SongMetadataEditor.kt @@ -1110,7 +1110,11 @@ class SongMetadataEditor( success } catch (e: Exception) { - Timber.e(e, "Failed to update MediaStore for songId: $songId") + // Best effort. MediaStore refuses the write for files the app does + // not own until the user consents per file, and it holds no artwork + // column at all -- the file itself was already written and the + // rescan below is what publishes the change either way. + Timber.tag(TAG).w(e, "MediaStore columns not updated for songId $songId") false } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/network/coverartarchive/CoverArtArchiveApiService.kt b/app/src/main/java/com/theveloper/pixelplay/data/network/coverartarchive/CoverArtArchiveApiService.kt new file mode 100644 index 0000000000..4de2aa560c --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/network/coverartarchive/CoverArtArchiveApiService.kt @@ -0,0 +1,43 @@ +package com.theveloper.pixelplay.data.network.coverartarchive + +import com.google.gson.annotations.SerializedName +import retrofit2.http.GET +import retrofit2.http.Path + +/** + * Retrofit interface for the Cover Art Archive. + * + * Keyless, and returns 404 for releases that have no artwork at all, so callers + * must treat a failed lookup as "no cover" rather than as an outage. + */ +interface CoverArtArchiveApiService { + + @GET("release/{mbid}") + suspend fun getReleaseCoverArt(@Path("mbid") releaseMbid: String): CoverArtArchiveResponse +} + +data class CoverArtArchiveResponse( + @SerializedName("images") val images: List = emptyList() +) + +data class CoverArtArchiveImage( + @SerializedName("front") val isFront: Boolean = false, + @SerializedName("image") val image: String? = null, + @SerializedName("thumbnails") val thumbnails: CoverArtArchiveThumbnails? = null +) + +/** + * Thumbnail URLs for one image. + * + * The Archive answers with two key styles depending on when the item was + * indexed: numeric keys (`250`, `500`, `1200`) on newer entries and named keys + * (`small`, `large`) on older ones, where `large` is the 500px rendition. Older + * entries carry only the named pair, so both have to be read. + */ +data class CoverArtArchiveThumbnails( + @SerializedName("250") val size250: String? = null, + @SerializedName("500") val size500: String? = null, + @SerializedName("1200") val size1200: String? = null, + @SerializedName("small") val small: String? = null, + @SerializedName("large") val large: String? = null +) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/network/coverartarchive/MusicBrainzApiService.kt b/app/src/main/java/com/theveloper/pixelplay/data/network/coverartarchive/MusicBrainzApiService.kt new file mode 100644 index 0000000000..b6f9f38499 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/network/coverartarchive/MusicBrainzApiService.kt @@ -0,0 +1,40 @@ +package com.theveloper.pixelplay.data.network.coverartarchive + +import com.google.gson.annotations.SerializedName +import retrofit2.http.GET +import retrofit2.http.Query + +/** + * Retrofit interface for the MusicBrainz web service. + * + * MusicBrainz is keyless but asks every client to identify itself and to stay + * under roughly one request per second, which is why searches here are a single + * query rather than a fan-out. + */ +interface MusicBrainzApiService { + + /** + * Search releases with Lucene syntax, e.g. `release:"Discovery" AND artist:"Daft Punk"`. + */ + @GET("ws/2/release") + suspend fun searchReleases( + @Query("query") query: String, + @Query("limit") limit: Int = 8, + @Query("fmt") format: String = "json" + ): MusicBrainzReleaseSearchResponse +} + +data class MusicBrainzReleaseSearchResponse( + @SerializedName("releases") val releases: List = emptyList() +) + +data class MusicBrainzRelease( + @SerializedName("id") val id: String, + @SerializedName("title") val title: String? = null, + @SerializedName("date") val date: String? = null, + @SerializedName("artist-credit") val artistCredit: List = emptyList() +) + +data class MusicBrainzArtistCredit( + @SerializedName("name") val name: String? = null +) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/network/deezer/DeezerApiService.kt b/app/src/main/java/com/theveloper/pixelplay/data/network/deezer/DeezerApiService.kt index 6d6b435f13..1a0ca48330 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/network/deezer/DeezerApiService.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/network/deezer/DeezerApiService.kt @@ -5,7 +5,10 @@ import retrofit2.http.Query /** * Retrofit interface for Deezer API. - * Used primarily for fetching artist artwork. + * Used primarily for fetching artist and album artwork. + * + * These are public catalog endpoints: they need no API key and no OAuth token, + * which is why the Retrofit instance in `AppModule` carries no auth interceptor. */ interface DeezerApiService { @@ -20,4 +23,16 @@ interface DeezerApiService { @Query("q") query: String, @Query("limit") limit: Int = 1 ): DeezerSearchResponse + + /** + * Search for albums, used to offer cover art candidates for a song. + * + * The query accepts Deezer's advanced syntax (`artist:"..." album:"..."`) + * as well as plain free text. + */ + @GET("search/album") + suspend fun searchAlbum( + @Query("q") query: String, + @Query("limit") limit: Int = 24 + ): DeezerAlbumSearchResponse } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/network/deezer/DeezerModels.kt b/app/src/main/java/com/theveloper/pixelplay/data/network/deezer/DeezerModels.kt index 9f5e427ca7..86741191ce 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/network/deezer/DeezerModels.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/network/deezer/DeezerModels.kt @@ -10,6 +10,39 @@ data class DeezerSearchResponse( @SerializedName("total") val total: Int = 0 ) +/** + * Response from Deezer album search API. + */ +data class DeezerAlbumSearchResponse( + @SerializedName("data") val data: List = emptyList(), + @SerializedName("total") val total: Int = 0 +) + +/** + * Album data from Deezer API. + * Cover URLs follow the same size ladder as artist pictures, with `cover_xl` + * served at 1000x1000. + */ +data class DeezerAlbum( + @SerializedName("id") val id: Long, + @SerializedName("title") val title: String, + @SerializedName("cover") val cover: String? = null, + @SerializedName("cover_small") val coverSmall: String? = null, + @SerializedName("cover_medium") val coverMedium: String? = null, + @SerializedName("cover_big") val coverBig: String? = null, + @SerializedName("cover_xl") val coverXl: String? = null, + @SerializedName("nb_tracks") val trackCount: Int = 0, + @SerializedName("artist") val artist: DeezerAlbumArtist? = null +) + +/** + * Minimal artist payload nested in album search results. + */ +data class DeezerAlbumArtist( + @SerializedName("id") val id: Long = 0L, + @SerializedName("name") val name: String = "" +) + /** * Artist data from Deezer API. * Contains multiple image sizes for different use cases. diff --git a/app/src/main/java/com/theveloper/pixelplay/data/network/itunes/ItunesApiService.kt b/app/src/main/java/com/theveloper/pixelplay/data/network/itunes/ItunesApiService.kt new file mode 100644 index 0000000000..6ba73a5643 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/network/itunes/ItunesApiService.kt @@ -0,0 +1,41 @@ +package com.theveloper.pixelplay.data.network.itunes + +import com.google.gson.annotations.SerializedName +import retrofit2.http.GET +import retrofit2.http.Query + +/** + * Retrofit interface for the iTunes Search API. + * + * Public and unauthenticated like the Deezer catalog endpoints, rate limited at + * roughly 20 requests per minute. + */ +interface ItunesApiService { + + /** + * Search the music catalog for albums. + * + * @param term Free text query, typically "artist album". + */ + @GET("search") + suspend fun searchAlbums( + @Query("term") term: String, + @Query("limit") limit: Int = 24, + @Query("entity") entity: String = "album", + @Query("media") media: String = "music" + ): ItunesSearchResponse +} + +data class ItunesSearchResponse( + @SerializedName("resultCount") val resultCount: Int = 0, + @SerializedName("results") val results: List = emptyList() +) + +data class ItunesAlbum( + @SerializedName("collectionId") val collectionId: Long = 0L, + @SerializedName("collectionName") val collectionName: String? = null, + @SerializedName("artistName") val artistName: String? = null, + /** Always a 100x100 URL; the size is part of the path and can be raised. */ + @SerializedName("artworkUrl100") val artworkUrl100: String? = null, + @SerializedName("trackCount") val trackCount: Int = 0 +) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/network/webimage/WebImageSearchApi.kt b/app/src/main/java/com/theveloper/pixelplay/data/network/webimage/WebImageSearchApi.kt new file mode 100644 index 0000000000..f977732eff --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/network/webimage/WebImageSearchApi.kt @@ -0,0 +1,56 @@ +package com.theveloper.pixelplay.data.network.webimage + +import com.google.gson.annotations.SerializedName +import retrofit2.http.Body +import retrofit2.http.Header +import retrofit2.http.POST +import retrofit2.http.Url + +/** + * The image search engine covers can be looked up in, when no catalog carries + * the release. + * + * It runs on Google's index, which is what makes it worth the request: the + * releases that reach this point are the ones living on a single label or + * Bandcamp page, and engines crawling their own index tend not to have them. + * + * An account is required, so the key is the user's own and nothing is shipped + * with the app. + */ +object WebImageSearchEngine { + const val LABEL = "Serper" + const val CONSOLE_URL = "https://serper.dev/api-key" + const val IMAGES_URL = "https://google.serper.dev/images" +} + +/** + * Serper's image endpoint, which reports image dimensions directly, so those + * results carry a size before anything is measured. + */ +interface SerperImageSearchApi { + + @POST + suspend fun searchImages( + @Url url: String, + @Header("X-API-KEY") apiKey: String, + @Body request: SerperImageSearchRequest + ): SerperImageSearchResponse +} + +data class SerperImageSearchRequest( + @SerializedName("q") val query: String, + @SerializedName("num") val count: Int = 20 +) + +data class SerperImageSearchResponse( + @SerializedName("images") val images: List = emptyList() +) + +data class SerperImageResult( + @SerializedName("title") val title: String? = null, + @SerializedName("imageUrl") val imageUrl: String? = null, + @SerializedName("imageWidth") val imageWidth: Int? = null, + @SerializedName("imageHeight") val imageHeight: Int? = null, + @SerializedName("thumbnailUrl") val thumbnailUrl: String? = null, + @SerializedName("source") val source: String? = null +) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt index 4598f1a86f..5e46e39293 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt @@ -13,6 +13,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.preferencesDataStore import androidx.media3.common.Player +import com.theveloper.pixelplay.data.coverart.AlbumArtStorage import com.theveloper.pixelplay.data.equalizer.EqualizerPreset import com.theveloper.pixelplay.data.diagnostics.AdvancedPerformanceDiagnostics import com.theveloper.pixelplay.data.model.FolderSource @@ -84,7 +85,15 @@ class UserPreferencesRepository @Inject constructor( ) { private val backupExcludedKeyNames = setOf( - PreferencesKeys.INITIAL_SETUP_DONE.name + PreferencesKeys.INITIAL_SETUP_DONE.name, + // MediaStore album ids, which mean nothing on another device or after a + // re-index: restoring them would suppress automatic covers for whatever + // albums happened to land on those ids. + PreferencesKeys.AUTO_ALBUM_ART_NOT_FOUND_IDS.name, + // Backups are plain JSON written where the user may share them, and + // every other credential store is excluded for that reason -- see + // backup_rules.xml. Listing it here also keeps a restore from wiping it. + PreferencesKeys.WEB_IMAGE_SEARCH_API_KEY.name ) // ─── Preference keys ──────────────────────────────────────────────────── @@ -116,6 +125,11 @@ class UserPreferencesRepository @Inject constructor( val LAST_LIBRARY_TAB_INDEX = intPreferencesKey("last_library_tab_index") val LAST_STORAGE_FILTER = stringPreferencesKey("last_storage_filter") val MOCK_GENRES_ENABLED = booleanPreferencesKey("mock_genres_enabled") + val AUTO_ALBUM_ART_ENABLED = booleanPreferencesKey("auto_album_art_enabled") + val ALBUM_ART_STORAGE = stringPreferencesKey("album_art_storage") + val AUTO_ALBUM_ART_UNMETERED_ONLY = booleanPreferencesKey("auto_album_art_unmetered_only") + val AUTO_ALBUM_ART_NOT_FOUND_IDS = stringSetPreferencesKey("auto_album_art_not_found_ids") + val WEB_IMAGE_SEARCH_API_KEY = stringPreferencesKey("web_image_search_api_key") val LAST_DAILY_MIX_UPDATE = longPreferencesKey("last_daily_mix_update") val DAILY_MIX_SONG_IDS = stringPreferencesKey("daily_mix_song_ids") val YOUR_MIX_SONG_IDS = stringPreferencesKey("your_mix_song_ids") @@ -873,6 +887,98 @@ suspend fun markDirectoryRulesVersionApplied(version: Int) { dataStore.edit { it[PreferencesKeys.LAST_STORAGE_FILTER] = filter.name } } + /** + * Where a cover the user applies is kept: inside the audio files, or only in + * the app. Applies wherever art is applied, by hand or automatically. + */ + val albumArtStorageFlow: Flow = + pref { preferences -> + preferences[PreferencesKeys.ALBUM_ART_STORAGE] + ?.let { stored -> AlbumArtStorage.entries.firstOrNull { it.name == stored } } + ?: AlbumArtStorage.APP_ONLY + } + + suspend fun setAlbumArtStorage(storage: AlbumArtStorage) { + dataStore.edit { it[PreferencesKeys.ALBUM_ART_STORAGE] = storage.name } + } + + /** Fetch covers for albums that have none, in the background, after a sync. */ + val autoAlbumArtEnabledFlow: Flow = + pref { it[PreferencesKeys.AUTO_ALBUM_ART_ENABLED] ?: false } + + suspend fun setAutoAlbumArtEnabled(enabled: Boolean) { + dataStore.edit { it[PreferencesKeys.AUTO_ALBUM_ART_ENABLED] = enabled } + } + + val autoAlbumArtUnmeteredOnlyFlow: Flow = + pref { it[PreferencesKeys.AUTO_ALBUM_ART_UNMETERED_ONLY] ?: true } + + suspend fun setAutoAlbumArtUnmeteredOnly(unmeteredOnly: Boolean) { + dataStore.edit { it[PreferencesKeys.AUTO_ALBUM_ART_UNMETERED_ONLY] = unmeteredOnly } + } + + /** + * Albums no catalog had a cover for. Remembered so every sync does not + * re-query the same dead ends, and clearable so a later upload can be found. + */ + val albumArtNotFoundIdsFlow: Flow> = + pref { preferences -> + preferences[PreferencesKeys.AUTO_ALBUM_ART_NOT_FOUND_IDS] + ?.mapNotNull(String::toLongOrNull) + ?.toSet() + .orEmpty() + } + + suspend fun addAlbumArtNotFoundIds(albumIds: Set) { + if (albumIds.isEmpty()) return + dataStore.edit { preferences -> + val existing = preferences[PreferencesKeys.AUTO_ALBUM_ART_NOT_FOUND_IDS].orEmpty() + preferences[PreferencesKeys.AUTO_ALBUM_ART_NOT_FOUND_IDS] = + existing + albumIds.map(Long::toString) + } + } + + /** + * Replaces the remembered dead ends outright. + * + * The adding form can only grow the set, and these are MediaStore album ids + * -- they do not survive a re-index, so entries for albums that no longer + * exist would otherwise accumulate for the life of the install, in a + * preferences file that is rewritten whole on every change. + */ + suspend fun setAlbumArtNotFoundIds(albumIds: Set) { + dataStore.edit { preferences -> + if (albumIds.isEmpty()) { + preferences.remove(PreferencesKeys.AUTO_ALBUM_ART_NOT_FOUND_IDS) + } else { + preferences[PreferencesKeys.AUTO_ALBUM_ART_NOT_FOUND_IDS] = + albumIds.map(Long::toString).toSet() + } + } + } + + suspend fun clearAlbumArtNotFoundIds() { + dataStore.edit { it.remove(PreferencesKeys.AUTO_ALBUM_ART_NOT_FOUND_IDS) } + } + + /** + * The user's own key for the image search engine. Its presence is the whole + * configuration: no key, no web search. + */ + val webImageSearchApiKeyFlow: Flow = + pref { it[PreferencesKeys.WEB_IMAGE_SEARCH_API_KEY].orEmpty() } + + suspend fun setWebImageSearchApiKey(apiKey: String) { + dataStore.edit { preferences -> + val trimmed = apiKey.trim() + if (trimmed.isEmpty()) { + preferences.remove(PreferencesKeys.WEB_IMAGE_SEARCH_API_KEY) + } else { + preferences[PreferencesKeys.WEB_IMAGE_SEARCH_API_KEY] = trimmed + } + } + } + val mockGenresEnabledFlow: Flow = pref { it[PreferencesKeys.MOCK_GENRES_ENABLED] ?: false } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/provider/SharedArtworkContentProvider.kt b/app/src/main/java/com/theveloper/pixelplay/data/provider/SharedArtworkContentProvider.kt index d9f0cc1e5b..cfcde47d0e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/provider/SharedArtworkContentProvider.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/provider/SharedArtworkContentProvider.kt @@ -25,10 +25,17 @@ class SharedArtworkContentProvider : ContentProvider() { override fun getType(uri: Uri): String? { val appContext = context?.applicationContext ?: return null - return if (parseSongId(uri, appContext.packageName) != null) { - DEFAULT_CONTENT_TYPE + val songId = parseSongId(uri, appContext.packageName) ?: return null + // Applied covers are WebP, extracted art is JPEG, and declaring the + // wrong one leaves anything trusting the declared type -- a share + // target, Android Auto -- with an image it cannot decode. Only the + // applied store is consulted: it wins when both exist and costs a + // pointer lookup, while resolving the cache could run a MediaStore + // query and a decode on the caller's main thread. + return if (AlbumArtUtils.getAppliedAlbumArtFile(appContext, songId) != null) { + WEBP_CONTENT_TYPE } else { - null + DEFAULT_CONTENT_TYPE } } @@ -72,6 +79,7 @@ class SharedArtworkContentProvider : ContentProvider() { private const val AUTHORITY_SUFFIX = ".artwork" private const val PATH_SONG = "song" private const val DEFAULT_CONTENT_TYPE = "image/jpeg" + private const val WEBP_CONTENT_TYPE = "image/webp" fun authority(packageName: String): String = packageName + AUTHORITY_SUFFIX diff --git a/app/src/main/java/com/theveloper/pixelplay/data/repository/CoverArtSearchRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/repository/CoverArtSearchRepository.kt new file mode 100644 index 0000000000..f6dadfe3a6 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/repository/CoverArtSearchRepository.kt @@ -0,0 +1,564 @@ +package com.theveloper.pixelplay.data.repository + +import android.content.Context +import android.net.Uri +import com.theveloper.pixelplay.data.coverart.CoverArtCandidate +import com.theveloper.pixelplay.data.coverart.CoverArtImageHeader +import com.theveloper.pixelplay.data.coverart.CoverArtProvider +import com.theveloper.pixelplay.data.coverart.CoverArtProviderStatus +import com.theveloper.pixelplay.data.coverart.CoverArtQuery +import com.theveloper.pixelplay.data.coverart.CoverArtSearchOutcome +import com.theveloper.pixelplay.data.coverart.CoverArtSearchRequest +import com.theveloper.pixelplay.data.coverart.CoverArtSearchUpdate +import com.theveloper.pixelplay.data.coverart.CoverArtSize +import com.theveloper.pixelplay.di.CoverArtImageClient +import com.theveloper.pixelplay.utils.NetworkRetryUtils +import com.theveloper.pixelplay.utils.isRetryableNetworkError +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import timber.log.Timber +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.security.MessageDigest +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Looks up cover art for an album across online catalogs and downloads the + * picked image into the cache so the existing cropper can consume it. + * + * Catalog searches run from the album screen and from the automatic pass over + * albums missing artwork. A web image search is metered against the user's own + * key, so it has its own entry point and runs only when asked for by hand. + */ +@Singleton +class CoverArtSearchRepository @Inject constructor( + @ApplicationContext private val context: Context, + private val providers: List<@JvmSuppressWildcards CoverArtProvider>, + @CoverArtImageClient private val okHttpClient: OkHttpClient +) { + + /** + * Queries the catalogs concurrently and returns the merged, ranked + * candidates along with whether any of them failed to answer. + * + * Run together, so the search costs the slowest catalog rather than the sum + * of all of them. A failing provider is skipped rather than failing the + * search, but is reported: an empty result and an incomplete one read the + * same to a caller that only looks at the candidates. + * + * @param confidentMatchScore when set, the slow catalogs are consulted only + * if the direct ones did not already answer this well. Null shows a person + * everything. + */ + suspend fun search( + album: String, + artist: String, + confidentMatchScore: Float? = null + ): CoverArtSearchOutcome = + withContext(Dispatchers.IO) { + val trimmedAlbum = album.trim() + val trimmedArtist = artist.trim() + if (trimmedAlbum.isEmpty() && trimmedArtist.isEmpty()) { + return@withContext CoverArtSearchOutcome(emptyList(), null) + } + + val request = CoverArtSearchRequest( + album = trimmedAlbum, + artist = trimmedArtist, + limit = PROVIDER_RESULT_LIMIT + ) + + val catalogs = catalogProviders() + val (direct, deferred) = when (confidentMatchScore) { + null -> catalogs to emptyList() + else -> catalogs.partition { it.source.isDirectLookup } + } + + val first = query(direct, request) + val firstRanked = rank(first.candidates, trimmedAlbum, trimmedArtist) + val settled = confidentMatchScore != null && + firstRanked.firstOrNull()?.let { it.score >= confidentMatchScore } == true + if (settled || deferred.isEmpty()) { + return@withContext CoverArtSearchOutcome(firstRanked, first.failure) + } + + val second = query(deferred, request) + val collected = first.candidates + second.candidates + + CoverArtSearchOutcome( + candidates = rank(collected, trimmedAlbum, trimmedArtist), + failure = first.failure ?: second.failure + ) + } + + /** What one wave of providers turned up, and the first failure among them. */ + private data class ProviderOutcomes( + val candidates: List, + val failure: Throwable? + ) + + private suspend fun query( + providers: List, + request: CoverArtSearchRequest + ): ProviderOutcomes { + if (providers.isEmpty()) return ProviderOutcomes(emptyList(), null) + + val outcomes = coroutineScope { + providers + .map { provider -> async { provider.searchOrFailure(request) } } + .awaitAll() + } + return ProviderOutcomes( + candidates = outcomes.flatMap { it.getOrDefault(emptyList()) }, + failure = outcomes.firstNotNullOfOrNull { it.exceptionOrNull() } + ) + } + + /** + * Same search, but emitting a merged snapshot every time a catalog answers. + * + * Latency between catalogs is wide -- iTunes usually answers in a few + * hundred milliseconds while MusicBrainz needs a second hop to the Cover Art + * Archive and can take seconds -- so waiting for all of them before drawing + * anything wastes the fast answers. Each emission is the full ranked list so + * far, which keeps the grid ordered by match quality rather than by arrival. + */ + fun searchStreaming(album: String, artist: String): Flow = channelFlow { + val trimmedAlbum = album.trim() + val trimmedArtist = artist.trim() + if (trimmedAlbum.isEmpty() && trimmedArtist.isEmpty()) { + send( + CoverArtSearchUpdate( + candidates = emptyList(), + statuses = emptyList(), + isComplete = true + ) + ) + return@channelFlow + } + + val request = CoverArtSearchRequest( + album = trimmedAlbum, + artist = trimmedArtist, + limit = PROVIDER_RESULT_LIMIT + ) + + val available = catalogProviders() + if (available.isEmpty()) { + // Nothing to wait for. Without this the flow ends on a snapshot that + // says "still searching" and the picker spins for good. + send( + CoverArtSearchUpdate( + candidates = emptyList(), + statuses = emptyList(), + isComplete = true + ) + ) + return@channelFlow + } + + val mutex = Mutex() + val collected = mutableListOf() + // Indexed by provider so the reported failure does not depend on which + // catalog happened to fail first. + val failures = arrayOfNulls(available.size) + val statuses = available.map { CoverArtProviderStatus(it.source, isSearching = true) } + .toMutableList() + var pending = available.size + + // Announce every catalog as pending before any of them answers, so the + // user can see what is being queried from the first frame. + send( + CoverArtSearchUpdate( + candidates = emptyList(), + statuses = statuses.toList(), + isComplete = false + ) + ) + + available.forEachIndexed { index, provider -> + launch { + val outcome = provider.searchOrFailure(request) + mutex.withLock { + outcome + .onSuccess { found -> + collected += found + statuses[index] = statuses[index].copy( + isSearching = false, + resultCount = found.size + ) + } + .onFailure { error -> + failures[index] = error + statuses[index] = statuses[index].copy( + isSearching = false, + failed = true + ) + } + pending-- + + val isComplete = pending == 0 + send( + CoverArtSearchUpdate( + candidates = rank(collected, trimmedAlbum, trimmedArtist), + statuses = statuses.toList(), + isComplete = isComplete, + failure = if (isComplete && collected.isEmpty()) { + failures.firstNotNullOfOrNull { it } + } else { + null + } + ) + ) + } + } + } + }.flowOn(Dispatchers.IO) + + suspend fun isWebImageSearchAvailable(): Boolean = + providers.any { !it.source.isCatalog && it.isAvailable() } + + /** + * Searches the user's configured image engine, and nothing else. + * + * Kept off every other path on purpose. The engines meter by request against + * a monthly allowance the user pays for or caps, and a web result cannot be + * scored against the album's tags, so spending a request on an album the + * catalogs already matched buys nothing. It runs when the user asks for it, + * on the album that needs it. + */ + suspend fun searchWebImages(album: String, artist: String): Result> = + withContext(Dispatchers.IO) { + val trimmedAlbum = album.trim() + val trimmedArtist = artist.trim() + if (trimmedAlbum.isEmpty() && trimmedArtist.isEmpty()) { + return@withContext Result.success(emptyList()) + } + + val engine = providers.firstOrNull { !it.source.isCatalog && it.isAvailable() } + ?: return@withContext Result.success(emptyList()) + + engine.searchOrFailure( + CoverArtSearchRequest( + album = trimmedAlbum, + artist = trimmedArtist, + limit = WEB_RESULT_LIMIT + ) + ).map { found -> found.distinctBy { it.imageUrl }.take(WEB_RESULT_LIMIT) } + } + + private suspend fun catalogProviders(): List = + providers.filter { it.source.isCatalog && it.isAvailable() } + + /** + * Orders catalog results by how well they match the album's tags. + * + * Only catalogs reach here. Web search results carry no artist to score + * against, so the scorer would discard them wholesale; they keep the + * engine's own relevance order and the caller appends them after these. + */ + private fun rank( + candidates: List, + album: String, + artist: String + ): List = + CoverArtQuery.rank( + candidates = candidates.distinctBy { it.imageUrl }, + queryAlbum = album, + queryArtist = artist + ).take(MAX_RESULTS) + + private suspend fun CoverArtProvider.searchOrFailure( + request: CoverArtSearchRequest + ): Result> = try { + Result.success( + NetworkRetryUtils.withNetworkRetry( + operationName = "cover_art_search:${source.name}", + maxAttempts = NETWORK_RETRY_ATTEMPTS, + initialDelayMs = NETWORK_RETRY_INITIAL_DELAY_MS, + shouldRetry = { throwable -> throwable.isRetryableNetworkError() } + ) { + search(request) + } + ) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + Timber.tag(TAG).w(error, "Cover art provider ${source.name} failed") + Result.failure(error) + } + + /** + * Reads the real dimensions and weight of a candidate without downloading it. + * + * Only the first [CoverArtImageHeader.PROBE_BYTES] are requested, via a + * range request where the host honors one, and the total size comes from the + * response headers. Returns null when the host refuses or the prefix cannot + * be parsed, which leaves the provider's nominal size in place. + */ + suspend fun probeSize(candidate: CoverArtCandidate): CoverArtSize? = + withContext(Dispatchers.IO) { + if (!candidate.imageUrl.startsWith("https://")) return@withContext null + + try { + val request = Request.Builder() + .url(candidate.imageUrl) + .header("Range", "bytes=0-${CoverArtImageHeader.PROBE_BYTES - 1}") + .get() + .build() + + okHttpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) return@withContext null + + // Not readBounded, which refuses a body past its limit: a + // host ignoring Range sends the whole file, and the header + // wanted here is in its first bytes. + val prefix = response.body.byteStream() + .readAtMost(CoverArtImageHeader.PROBE_BYTES) + val dimensions = CoverArtImageHeader.readDimensions(prefix) + ?: return@withContext null + + CoverArtSize( + width = dimensions.first, + height = dimensions.second, + byteCount = response.totalByteCount(), + measured = true + ) + } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + Timber.tag(TAG).d("Could not probe cover art ${candidate.imageUrl}: ${error.message}") + null + } + } + + /** + * Total size of the image, taken from `Content-Range` for a partial response + * and from `Content-Length` when the host ignored the range request. + */ + private fun Response.totalByteCount(): Long? { + header("Content-Range") + ?.substringAfter('/', "") + ?.toLongOrNull() + ?.let { return it } + + if (code == 200) { + body.contentLength().takeIf { it >= 0 }?.let { return it } + } + return null + } + + /** + * Downloads a candidate into the cache directory and returns a `file://` + * URI for it, ready to be handed to the cover art cropper. + */ + suspend fun downloadCandidate(candidate: CoverArtCandidate): Result = + withContext(Dispatchers.IO) { + if (!candidate.imageUrl.startsWith("https://")) { + return@withContext Result.failure( + IOException("Refusing to download cover art over a non-HTTPS URL") + ) + } + + try { + val bytes = NetworkRetryUtils.withNetworkRetry( + operationName = "cover_art_download:${candidate.id}", + maxAttempts = NETWORK_RETRY_ATTEMPTS, + initialDelayMs = NETWORK_RETRY_INITIAL_DELAY_MS, + // A rejected payload is rejected for good; retrying spends + // up to three downloads of up to 8 MB on a verdict that + // cannot change. + shouldRetry = { throwable -> + throwable !is UnusableCoverArtException && throwable.isRetryableNetworkError() + } + ) { + fetchImageBytes(candidate.imageUrl) + } + + val directory = cacheDirectory() + // Named after the image URL rather than the candidate id, so + // the same image picked twice reuses one file and two results + // that differ only by image never share one. + val file = File(directory, "${cacheFileName(candidate.imageUrl)}.img") + file.writeBytes(bytes) + prune(directory) + + Result.success(Uri.fromFile(file)) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + Timber.tag(TAG).e(error, "Failed to download cover art ${candidate.imageUrl}") + Result.failure(error) + } + } + + /** + * A response that will never become usable however many times it is asked + * for: too large, not an image, or refused with a status that says so. + * Kept apart from transport failures so the retry above does not spend + * three downloads reaching the same verdict. + */ + private class UnusableCoverArtException(message: String) : IOException(message) + + private fun fetchImageBytes(url: String): ByteArray { + val request = Request.Builder().url(url).get().build() + okHttpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + val message = "Cover art download failed with HTTP ${response.code}" + // A plain IOException is retried three times, which for a dead + // rendition URL spends a second and a half re-reading the same + // 404 -- and one of the unattended pass's five allowed failures. + // UnusableCoverArtException marks the codes worth asking once. + throw if (response.code in RETRYABLE_STATUS_CODES || response.code >= 500) { + IOException(message) + } else { + UnusableCoverArtException(message) + } + } + + val body = response.body + val contentType = body.contentType() + if (contentType != null && contentType.type != "image") { + throw UnusableCoverArtException("Cover art download returned $contentType") + } + if (body.contentLength() > MAX_IMAGE_BYTES) { + throw UnusableCoverArtException( + "Cover art is larger than the ${MAX_IMAGE_BYTES} byte limit" + ) + } + + val bytes = body.byteStream().readBounded(MAX_IMAGE_BYTES) + ?: throw UnusableCoverArtException( + "Cover art is larger than the ${MAX_IMAGE_BYTES} byte limit" + ) + if (!bytes.looksLikeImage()) { + throw UnusableCoverArtException("Cover art download did not return image data") + } + return bytes + } + } + + private fun cacheDirectory(): File = + File(context.cacheDir, CACHE_DIRECTORY_NAME).apply { mkdirs() } + + /** + * Keeps the cache bounded. These files only exist between picking a result + * and confirming the crop, but each one may be up to [MAX_IMAGE_BYTES], so + * the ceiling is a count rather than a size and the worst case is that many + * times that limit. + */ + private fun prune(directory: File) { + val files = directory.listFiles()?.sortedByDescending { it.lastModified() } ?: return + files.drop(MAX_CACHED_FILES).forEach { stale -> + if (!stale.delete()) { + Timber.tag(TAG).d("Could not delete stale cover art cache file ${stale.name}") + } + } + } + + /** Reads up to [limit] bytes, returning whatever arrived before that. */ + private fun InputStream.readAtMost(limit: Int): ByteArray { + val output = ByteArrayOutputStream() + val chunk = ByteArray(DOWNLOAD_CHUNK_BYTES) + while (output.size() < limit) { + val read = read(chunk, 0, minOf(chunk.size, limit - output.size())) + if (read == -1) break + output.write(chunk, 0, read) + } + return output.toByteArray() + } + + private fun InputStream.readBounded(limit: Long): ByteArray? { + val output = ByteArrayOutputStream() + val chunk = ByteArray(DOWNLOAD_CHUNK_BYTES) + var total = 0L + + while (true) { + val read = read(chunk) + if (read == -1) break + total += read + if (total > limit) return null + output.write(chunk, 0, read) + } + return output.toByteArray() + } + + /** + * Cheap magic-byte check so an error page served with an image content type + * never lands in the cache as a cover. + */ + private fun ByteArray.looksLikeImage(): Boolean { + if (size < 12) return false + val isJpeg = this[0] == 0xFF.toByte() && this[1] == 0xD8.toByte() + val isPng = this[0] == 0x89.toByte() && this[1] == 'P'.code.toByte() && + this[2] == 'N'.code.toByte() && this[3] == 'G'.code.toByte() + val isGif = this[0] == 'G'.code.toByte() && this[1] == 'I'.code.toByte() && + this[2] == 'F'.code.toByte() + val isWebp = this[0] == 'R'.code.toByte() && this[1] == 'I'.code.toByte() && + this[2] == 'F'.code.toByte() && this[3] == 'F'.code.toByte() && + this[8] == 'W'.code.toByte() && this[9] == 'E'.code.toByte() && + this[10] == 'B'.code.toByte() && this[11] == 'P'.code.toByte() + return isJpeg || isPng || isGif || isWebp + } + + companion object { + private const val TAG = "CoverArtSearchRepository" + private const val CACHE_DIRECTORY_NAME = "cover_art_search" + private const val PROVIDER_RESULT_LIMIT = 24 + private const val MAX_RESULTS = 24 + + /** + * Web results kept, well past the catalogs' cap. + * + * One request is billed whether it answers with twenty covers or + * eighty, and the right one for an obscure release sits far down the + * page -- truncating to a catalog's worth throws away what was already + * paid for. + */ + private const val WEB_RESULT_LIMIT = 60 + private const val MAX_CACHED_FILES = 20 + private const val MAX_IMAGE_BYTES = 8L * 1024L * 1024L + private const val DOWNLOAD_CHUNK_BYTES = 16 * 1024 + private const val NETWORK_RETRY_ATTEMPTS = 3 + private const val NETWORK_RETRY_INITIAL_DELAY_MS = 500L + + /** + * The 4xx answers that can come back differently next time: the request + * took too long to arrive (408), the server declined to risk replaying + * it (425), or it is asking for a slower pace (429). Every other 4xx is + * a verdict on the request itself and will not change however often it + * is asked. 5xx is handled alongside these, by range. + */ + private val RETRYABLE_STATUS_CODES = setOf(408, 425, 429) + + /** + * Content-addressed name so one cached file always means one image. + * + * A 32-bit String.hashCode is trivially collidable, and a collision + * here hands the cropper somebody else's cover. + */ + internal fun cacheFileName(imageUrl: String): String = + MessageDigest.getInstance("SHA-256") + .digest(imageUrl.toByteArray()) + .take(16) + .joinToString("") { byte -> "%02x".format(byte) } + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/worker/AutoCoverArtWorker.kt b/app/src/main/java/com/theveloper/pixelplay/data/worker/AutoCoverArtWorker.kt new file mode 100644 index 0000000000..1bbe579b7e --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/worker/AutoCoverArtWorker.kt @@ -0,0 +1,124 @@ +package com.theveloper.pixelplay.data.worker + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.theveloper.pixelplay.data.coverart.AutoCoverArtFetcher +import com.theveloper.pixelplay.data.preferences.UserPreferencesRepository +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.first +import timber.log.Timber + +/** + * Looks for covers for albums that have none, after a library sync. + * + * Nothing here touches audio files, so it needs no write consent and can run + * unattended; see [AutoCoverArtFetcher] for what it does write. + */ +@HiltWorker +class AutoCoverArtWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, + private val autoCoverArtFetcher: AutoCoverArtFetcher, + private val userPreferencesRepository: UserPreferencesRepository +) : CoroutineWorker(appContext, workerParams) { + + override suspend fun doWork(): Result { + if (!userPreferencesRepository.autoAlbumArtEnabledFlow.first()) { + return Result.success() + } + + return try { + val outcome = autoCoverArtFetcher.fetchMissingCovers(isStopped = { isStopped }) + Timber.tag(WORK_NAME).i( + "Checked ${outcome.albumsChecked} albums, applied ${outcome.coversApplied} covers, " + + "${outcome.notFound} without a match" + ) + + // A pass is capped so an unattended run stays bounded, but every album + // it touched is either covered now or on the not-found list, so + // chaining another pass makes progress and always terminates. + if (outcome.reachedLimit && !isStopped) { + enqueueContinuation( + context = applicationContext, + unmeteredOnly = userPreferencesRepository.autoAlbumArtUnmeteredOnlyFlow.first() + ) + } + Result.success() + } catch (cancellation: CancellationException) { + // WorkManager stopping the pass is not a failure to retry: it will + // be re-run on its own terms, and the albums already resolved were + // recorded as the pass went. + throw cancellation + } catch (error: Exception) { + Timber.tag(WORK_NAME).w(error, "Automatic cover art pass failed") + // Covers are a convenience, and a library the catalogs keep failing + // on would otherwise retry on WorkManager's backoff indefinitely. + // The next sync queues a fresh pass anyway. + // runAttemptCount is 0 on the first execution. + if (runAttemptCount + 1 >= MAX_ATTEMPTS) Result.failure() else Result.retry() + } + } + + companion object { + const val WORK_NAME = "auto_cover_art_worker" + + /** + * Attempts a failing pass gets before it is left to the next sync. + */ + private const val MAX_ATTEMPTS = 3 + + /** + * Queues a pass, keeping any run already under way. + * + * Automatic work keeps, the way the rest of the app's background work + * does; only the user's own "try again" replaces. + */ + fun enqueue(context: Context, unmeteredOnly: Boolean, replaceRunning: Boolean = false) { + WorkManager.getInstance(context).enqueueUniqueWork( + WORK_NAME, + if (replaceRunning) ExistingWorkPolicy.REPLACE else ExistingWorkPolicy.KEEP, + buildRequest(unmeteredOnly) + ) + } + + /** + * Queues the next pass of a run that hit its cap. + * + * Appended to the same name the pass itself runs under, so it queues + * behind the worker doing the asking rather than beside it. Queueing it + * under a name of its own would leave two passes able to run at once -- + * one under each name -- both querying the same catalogs and both + * writing to the same artwork store. REPLACE under the shared name + * would cancel the asker instead, and OR_REPLACE covers the case where + * the chain it is appending to has already been cancelled. + */ + private fun enqueueContinuation(context: Context, unmeteredOnly: Boolean) { + WorkManager.getInstance(context).enqueueUniqueWork( + WORK_NAME, + ExistingWorkPolicy.APPEND_OR_REPLACE, + buildRequest(unmeteredOnly) + ) + } + + internal fun buildRequest(unmeteredOnly: Boolean): OneTimeWorkRequest = + OneTimeWorkRequestBuilder() + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType( + if (unmeteredOnly) NetworkType.UNMETERED else NetworkType.CONNECTED + ) + .build() + ) + .build() + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt b/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt index 46ed856982..167b164e4a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt @@ -347,6 +347,7 @@ constructor( "runMaintenance" to "false" ) } + maybeEnqueueCoverArtPass() return@withContext Result.success( workDataOf(OUTPUT_TOTAL_SONGS to totalSongs.toLong()) ) @@ -478,6 +479,7 @@ constructor( "runMaintenance" to runMaintenance.toString() ) } + maybeEnqueueCoverArtPass() Result.success(workDataOf(OUTPUT_TOTAL_SONGS to finalTotalSongs.toLong())) } catch (e: Exception) { Log.e(TAG, "Error during MediaStore synchronization", e) @@ -1200,6 +1202,19 @@ constructor( return ids } + /** + * Queues a cover art pass when the setting is on. Called from both success + * paths: a local-only sync returns before the maintenance phases, and that + * is the common one. + */ + private suspend fun maybeEnqueueCoverArtPass() { + if (!userPreferencesRepository.autoAlbumArtEnabledFlow.first()) return + AutoCoverArtWorker.enqueue( + context = applicationContext, + unmeteredOnly = userPreferencesRepository.autoAlbumArtUnmeteredOnlyFlow.first() + ) + } + companion object { const val WORK_NAME = "com.theveloper.pixelplay.data.worker.SyncWorker" // Distinct unique name so background maintenance never feeds the WORK_NAME-bound diff --git a/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt b/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt index 7f1d935994..0b45961888 100644 --- a/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt +++ b/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt @@ -36,7 +36,17 @@ import com.theveloper.pixelplay.data.media.SongMetadataEditor import com.theveloper.pixelplay.data.network.deezer.DeezerApiService import com.theveloper.pixelplay.data.network.netease.NeteaseApiService import com.theveloper.pixelplay.data.network.lyrics.LrcLibApiService +import com.theveloper.pixelplay.data.coverart.CoverArtProvider +import com.theveloper.pixelplay.data.coverart.DeezerCoverArtProvider +import com.theveloper.pixelplay.data.coverart.ItunesCoverArtProvider +import com.theveloper.pixelplay.data.coverart.MusicBrainzCoverArtProvider +import com.theveloper.pixelplay.data.coverart.WebImageCoverArtProvider +import com.theveloper.pixelplay.data.network.webimage.SerperImageSearchApi +import com.theveloper.pixelplay.data.network.coverartarchive.CoverArtArchiveApiService +import com.theveloper.pixelplay.data.network.coverartarchive.MusicBrainzApiService +import com.theveloper.pixelplay.data.network.itunes.ItunesApiService import com.theveloper.pixelplay.data.repository.ArtistImageRepository +import com.theveloper.pixelplay.data.repository.CoverArtSearchRepository import com.theveloper.pixelplay.data.repository.LyricsRepository import com.theveloper.pixelplay.data.repository.LyricsRepositoryImpl import com.theveloper.pixelplay.data.repository.MediaStoreSongRepository @@ -68,6 +78,13 @@ import retrofit2.converter.gson.GsonConverterFactory @InstallIn(SingletonComponent::class) object AppModule { + /** + * MusicBrainz requires an application name, a version and a contact URL, and + * answers 403 to clients that do not identify themselves. + */ + private const val MUSICBRAINZ_USER_AGENT = + "PixelPlayer/${BuildConfig.VERSION_NAME} ( https://github.com/PixelPlayerHQ/PixelPlayer )" + @Singleton @Provides fun provideApplication(@ApplicationContext app: Context): PixelPlayApplication { @@ -443,6 +460,7 @@ object AppModule { redactHeader("Cookie") redactHeader("Set-Cookie") redactHeader("x-goog-api-key") + redactHeader("X-API-KEY") redactHeader("X-Emby-Token") redactHeader("X-Emby-Authorization") redactHeader("X-MediaBrowser-Token") @@ -586,4 +604,150 @@ object AppModule { ): ArtistImageRepository { return ArtistImageRepository(deezerApiService, musicDao) } + + /** + * Provee Retrofit para la API de bΓΊsqueda de iTunes. + */ + @Provides + @Singleton + @ItunesRetrofit + fun provideItunesRetrofit(okHttpClient: OkHttpClient): Retrofit { + return Retrofit.Builder() + .baseUrl("https://itunes.apple.com/") + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + } + + @Provides + @Singleton + fun provideItunesApiService(@ItunesRetrofit retrofit: Retrofit): ItunesApiService { + return retrofit.create(ItunesApiService::class.java) + } + + /** + * Client for fetching cover art images. + * + * The repository refuses a candidate whose URL is not HTTPS, but the check + * only sees the URL it is given: with redirects followed across schemes, a + * host could answer an HTTPS request with a 302 to cleartext and the image + * would be fetched in the open. The shared client cannot refuse those -- + * Navidrome and Jellyfin are reachable over HTTP on a local network -- so + * this one does. + */ + @Provides + @Singleton + @CoverArtImageClient + fun provideCoverArtImageClient(okHttpClient: OkHttpClient): OkHttpClient { + return okHttpClient.newBuilder() + .followSslRedirects(false) + .build() + } + + /** + * Cliente HTTP para MusicBrainz y Cover Art Archive. + * + * MusicBrainz exige que cada cliente se identifique con una User-Agent + * descriptiva que incluya un contacto; sin ella la API responde 403. + */ + @Provides + @Singleton + @MusicBrainzRetrofit + fun provideMusicBrainzOkHttpClient(okHttpClient: OkHttpClient): OkHttpClient { + return okHttpClient.newBuilder() + .addInterceptor { chain -> + val request = chain.request().newBuilder() + .header("User-Agent", MUSICBRAINZ_USER_AGENT) + .build() + chain.proceed(request) + } + .build() + } + + @Provides + @Singleton + @MusicBrainzRetrofit + fun provideMusicBrainzRetrofit( + @MusicBrainzRetrofit okHttpClient: OkHttpClient + ): Retrofit { + return Retrofit.Builder() + .baseUrl("https://musicbrainz.org/") + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + } + + @Provides + @Singleton + fun provideMusicBrainzApiService( + @MusicBrainzRetrofit retrofit: Retrofit + ): MusicBrainzApiService { + return retrofit.create(MusicBrainzApiService::class.java) + } + + @Provides + @Singleton + @CoverArtArchiveRetrofit + fun provideCoverArtArchiveRetrofit( + @MusicBrainzRetrofit okHttpClient: OkHttpClient + ): Retrofit { + return Retrofit.Builder() + .baseUrl("https://coverartarchive.org/") + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + } + + @Provides + @Singleton + fun provideCoverArtArchiveApiService( + @CoverArtArchiveRetrofit retrofit: Retrofit + ): CoverArtArchiveApiService { + return retrofit.create(CoverArtArchiveApiService::class.java) + } + + /** + * Retrofit para el buscador de imΓ‘genes web. La clave es del usuario y + * viaja como cabecera en cada llamada, asΓ­ que aquΓ­ no se guarda nada. + */ + @Provides + @Singleton + fun provideSerperImageSearchApi(okHttpClient: OkHttpClient): SerperImageSearchApi { + return Retrofit.Builder() + .baseUrl("https://google.serper.dev/") + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(SerperImageSearchApi::class.java) + } + + /** + * Provee los catΓ‘logos consultados al buscar carΓ‘tulas online. + * + * El orden importa: la bΓΊsqueda manual los consulta en paralelo y sΓ³lo lo + * usa para desempatar, pero la pasada automΓ‘tica recorre primero los + * catΓ‘logos de consulta directa y sΓ³lo sigue con el resto si ninguno da una + * coincidencia segura. + */ + @Provides + @Singleton + fun provideCoverArtProviders( + deezerApiService: DeezerApiService, + itunesApiService: ItunesApiService, + musicBrainzApiService: MusicBrainzApiService, + coverArtArchiveApiService: CoverArtArchiveApiService, + serperImageSearchApi: SerperImageSearchApi, + userPreferencesRepository: UserPreferencesRepository + ): List<@JvmSuppressWildcards CoverArtProvider> { + return listOf( + DeezerCoverArtProvider(deezerApiService), + ItunesCoverArtProvider(itunesApiService), + MusicBrainzCoverArtProvider(musicBrainzApiService, coverArtArchiveApiService), + // Sits out of every search until the user configures a key. + WebImageCoverArtProvider( + serperImageSearchApi = serperImageSearchApi, + userPreferencesRepository = userPreferencesRepository + ) + ) + } } diff --git a/app/src/main/java/com/theveloper/pixelplay/di/Qualifiers.kt b/app/src/main/java/com/theveloper/pixelplay/di/Qualifiers.kt index d0207b1997..c1bdc48371 100644 --- a/app/src/main/java/com/theveloper/pixelplay/di/Qualifiers.kt +++ b/app/src/main/java/com/theveloper/pixelplay/di/Qualifiers.kt @@ -29,3 +29,34 @@ annotation class BackupGson @Qualifier @Retention(AnnotationRetention.BINARY) annotation class AppScope + +/** + * Qualifier for the iTunes Search Retrofit instance. + */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class ItunesRetrofit + +/** + * Qualifier for the MusicBrainz Retrofit instance and the client behind it. + */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class MusicBrainzRetrofit + +/** + * Qualifier for the Cover Art Archive Retrofit instance. + */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class CoverArtArchiveRetrofit + +/** + * Qualifier for the client cover art images are fetched with. + * + * The URLs come from third-party search results and are checked for HTTPS + * before the request is made; this client refuses to be redirected off it. + */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class CoverArtImageClient diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/OnlineCoverArtPickerSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/OnlineCoverArtPickerSheet.kt new file mode 100644 index 0000000000..69848b029a --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/OnlineCoverArtPickerSheet.kt @@ -0,0 +1,776 @@ +package com.theveloper.pixelplay.presentation.components + +import android.net.Uri +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.derivedStateOf +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.theveloper.pixelplay.ui.theme.GoogleSansRounded +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.rounded.ErrorOutline +import androidx.compose.material.icons.rounded.Public +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.launch +import com.theveloper.pixelplay.R +import com.theveloper.pixelplay.data.coverart.AlbumArtStorage +import com.theveloper.pixelplay.data.coverart.CoverArtCandidate +import com.theveloper.pixelplay.data.coverart.CoverArtProviderStatus +import com.theveloper.pixelplay.data.coverart.CoverArtSource +import java.util.Locale +import com.theveloper.pixelplay.presentation.viewmodel.OnlineCoverArtUiState +import com.theveloper.pixelplay.presentation.viewmodel.OnlineCoverArtViewModel + +/** + * Lets the user search online catalogs for a cover and pick one. + * + * The picked image is downloaded to the cache and handed back as a local URI, + * so it goes through the same cropper as an image picked from the gallery. + * + * @param noteText Optional line stating what the pick will affect, e.g. how many + * tracks of the album the cover will be written to. Where the cover is kept is + * stated alongside it either way, since that decides whether the user's own + * files are about to be written to. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OnlineCoverArtPickerSheet( + visible: Boolean, + initialAlbum: String, + initialArtist: String, + onDismiss: () -> Unit, + onCoverDownloaded: (Uri) -> Unit, + noteText: String? = null, + viewModel: OnlineCoverArtViewModel = hiltViewModel() +) { + // A dialog window rather than a sheet: covers are judged by looking at them, + // so this wants the whole screen. The transition state, rather than `visible` + // alone, keeps it composed long enough to animate away. + val transitionState = remember { MutableTransitionState(false) } + transitionState.targetState = visible + + if (!transitionState.currentState && !transitionState.targetState) return + + val state by viewModel.uiState.collectAsStateWithLifecycle() + val albumArtStorage by viewModel.albumArtStorage.collectAsStateWithLifecycle() + + LaunchedEffect(initialAlbum, initialArtist) { + viewModel.start(album = initialAlbum, artist = initialArtist) + } + + LaunchedEffect(state.downloadedUri) { + state.downloadedUri?.let { uri -> + onCoverDownloaded(uri) + viewModel.onDownloadedUriHandled() + } + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false + ) + ) { + AnimatedVisibility( + visibleState = transitionState, + enter = slideInVertically(initialOffsetY = { it / 6 }) + fadeIn(animationSpec = tween(220)), + exit = slideOutVertically(targetOffsetY = { it / 6 }) + fadeOut(animationSpec = tween(200)) + ) { + CoverArtPickerContent( + state = state, + noteText = noteText, + albumArtStorage = albumArtStorage, + onAlbumChange = viewModel::onAlbumChange, + onArtistChange = viewModel::onArtistChange, + onSearch = viewModel::search, + onSearchWeb = viewModel::searchWeb, + onCandidateSelected = viewModel::onCandidateSelected, + onDismiss = onDismiss + ) + } + } +} + +/** + * Everything the picker shows, given a state rather than a view model. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CoverArtPickerContent( + state: OnlineCoverArtUiState, + noteText: String?, + albumArtStorage: AlbumArtStorage?, + onAlbumChange: (String) -> Unit, + onArtistChange: (String) -> Unit, + onSearch: () -> Unit, + onSearchWeb: () -> Unit, + onCandidateSelected: (CoverArtCandidate) -> Unit, + onDismiss: () -> Unit +) { + val gridState = rememberLazyGridState() + val scope = rememberCoroutineScope() + val keyboardController = LocalSoftwareKeyboardController.current + var isEditingQuery by remember { mutableStateOf(false) } + + // Said where the picking happens: the setting was made once, elsewhere, and + // decides whether the user's own files are about to be written to. Nothing + // is claimed until it has actually been read. + val destinationText = when (albumArtStorage) { + AlbumArtStorage.AUDIO_FILES -> stringResource(R.string.cover_art_search_destination_files) + AlbumArtStorage.APP_ONLY -> stringResource(R.string.cover_art_search_destination_app) + null -> null + } + val noteLine = listOfNotNull(noteText, destinationText).joinToString(" ") + + // The web engine joins the row only once it has been asked for, so an + // untouched search does not advertise a source it did not query. + val statuses = state.providerStatuses + listOfNotNull( + if (state.isSearchingWeb || state.webSearched) { + CoverArtProviderStatus( + source = CoverArtSource.WEB_IMAGE_SEARCH, + isSearching = state.isSearchingWeb, + resultCount = state.webCandidates.size + ) + } else { + null + } + ) + + // The catalogs register a moment after the sheet opens, and a lazy row holds + // its anchored item still by scrolling itself to the end. So the row stays + // empty until they are in it, and pinning keys off the catalogs alone -- a + // web search changing the row must not jump it back under the user's finger. + val chipsState = rememberLazyListState() + LaunchedEffect(state.album, state.artist, state.providerStatuses.size) { + chipsState.scrollToItem(0) + } + + // Results are grouped under their source so a chip has somewhere to jump to, + // and so a run of covers from one catalog reads as one catalog's opinion. + val groups = remember(state.candidates, state.webCandidates) { + (state.candidates + state.webCandidates) + .groupBy { it.source } + .toList() + } + // Where each group's header lands in the grid, counting the full-width + // header as one item, so a chip can scroll straight to it. + val groupStartIndex = remember(groups) { + var index = 0 + buildMap { + groups.forEach { (source, candidates) -> + put(source, index) + index += 1 + candidates.size + } + } + } + + // The covers share their grid with the rows above them, so an index into + // the groups only points at the right cover once those rows are counted. + val leadingItemCount = 1 + + // The note always carries the destination, so it is always drawn. + 1 + + (if (isEditingQuery) 2 else 0) + + 1 + + (if (state.errorRes != null && groups.isNotEmpty()) 1 else 0) + + // Searching again is the keyboard's action key: the sheet already searched + // on open, so a button of its own would sit there unused for every album + // the catalogs get right. + val searchAgain = { + keyboardController?.hide() + isEditingQuery = false + onSearch() + } + + val density = LocalDensity.current + val imeInsets = WindowInsets.ime + val isKeyboardVisible by remember { derivedStateOf { imeInsets.getBottom(density) > 0 } } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + modifier = Modifier.padding(start = 10.dp), + text = stringResource(R.string.cover_art_search_title), + fontFamily = GoogleSansRounded, + style = MaterialTheme.typography.displaySmall + ) + }, + navigationIcon = { + // Closing is the only way out: picking a cover is the commit, + // so there is no Save to pair a Cancel with down at the + // bottom the way the tag editor has. + FilledTonalIconButton( + modifier = Modifier.padding(start = 10.dp), + onClick = onDismiss, + shape = CircleShape + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(R.string.common_cancel) + ) + } + }, + actions = { + FilledTonalIconButton( + modifier = Modifier.padding(end = 10.dp), + onClick = { isEditingQuery = !isEditingQuery }, + shape = CircleShape + ) { + Icon( + imageVector = if (isEditingQuery) Icons.Rounded.Search else Icons.Rounded.Edit, + contentDescription = stringResource( + if (isEditingQuery) { + R.string.cover_art_search_edit_query_done + } else { + R.string.cover_art_search_edit_query + } + ) + ) + } + } + ) + }, + containerColor = MaterialTheme.colorScheme.surface, + modifier = Modifier.fillMaxSize(), + contentWindowInsets = WindowInsets.statusBars + ) { innerPadding -> + val navBarBottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + + // Everything above the covers rides in the grid as a full-width row, so + // the screen contains exactly one thing that scrolls. + LazyVerticalGrid( + state = gridState, + columns = GridCells.Adaptive(minSize = 104.dp), + modifier = Modifier + .fillMaxSize() + .imePadding(), + // The gutter is the grid's, so every row lines up without each one + // repeating it. The chip row opts back out below. + contentPadding = PaddingValues( + top = innerPadding.calculateTopPadding() + 8.dp, + bottom = if (isKeyboardVisible) 8.dp else navBarBottom + 24.dp, + start = 16.dp, + end = 16.dp + ), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + item(span = { GridItemSpan(maxLineSpan) }, key = "query") { + // Opened from an album, so the terms are already known and read + // as a line: two open text fields would cost about two rows of + // covers. The fields sit behind the app bar's action, for tags + // the catalogs do not agree with. + val queryLine = listOf(state.album, state.artist) + .filter { it.isNotBlank() } + .joinToString(" Β· ") + if (queryLine.isNotEmpty()) { + Text( + text = queryLine, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + item(span = { GridItemSpan(maxLineSpan) }, key = "note") { + Text( + text = noteLine, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + if (isEditingQuery) { + item(span = { GridItemSpan(maxLineSpan) }, key = "field-album") { + OutlinedTextField( + value = state.album, + onValueChange = onAlbumChange, + label = { Text(stringResource(R.string.cover_art_search_field_album)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { searchAgain() }), + modifier = Modifier.fillMaxWidth() + ) + } + item(span = { GridItemSpan(maxLineSpan) }, key = "field-artist") { + OutlinedTextField( + value = state.artist, + onValueChange = onArtistChange, + label = { Text(stringResource(R.string.cover_art_search_field_artist)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { searchAgain() }), + modifier = Modifier.fillMaxWidth() + ) + } + } + + item(span = { GridItemSpan(maxLineSpan) }, key = "chips") { + SourceChips( + modifier = Modifier.bleedHorizontally(20.dp), + listState = chipsState, + statuses = statuses, + // Never automatic: each request is metered against the + // user's own allowance, so it takes a deliberate tap. + showWebSearchAction = state.providerStatuses.isNotEmpty() && + state.webSearchConfigured && + !state.webSearched && !state.isSearchingWeb, + webSearchEnabled = !state.isSearching && + (state.album.isNotBlank() || state.artist.isNotBlank()), + onSearchWeb = onSearchWeb, + onJumpToSource = { source -> + groupStartIndex[source]?.let { index -> + scope.launch { + gridState.animateScrollToItem(leadingItemCount + index) + } + } + } + ) + } + + // The results own this message, so it only speaks when there are + // none -- a failed download happens with results still on screen. + state.errorRes?.takeIf { groups.isNotEmpty() }?.let { errorRes -> + item(span = { GridItemSpan(maxLineSpan) }, key = "error") { + Text( + text = stringResource(errorRes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.fillMaxWidth() + ) + } + } + + coverArtResults( + state = state, + groups = groups, + onCandidateSelected = onCandidateSelected + ) + } + } +} + +/** + * Lets one item ignore the horizontal padding its lazy container applies to + * every item. + * + * Content padding is the right place for a gutter every row should share, but a + * row that scrolls sideways wants the opposite: its contents inset, its track + * running to the edges, so chips leaving the screen leave from the edge rather + * than stopping at a margin. This measures [padding] wider than it was offered + * and draws itself back over the gutter on both sides. + */ +private fun Modifier.bleedHorizontally(padding: Dp) = layout { measurable, constraints -> + // Only meaningful against a bounded width; there is no gutter to escape + // from when the parent is not offering one. + if (constraints.maxWidth == Constraints.Infinity) { + val placeable = measurable.measure(constraints) + return@layout layout(placeable.width, placeable.height) { placeable.place(0, 0) } + } + + val inset = padding.roundToPx() + val bleed = inset * 2 + val placeable = measurable.measure( + constraints.copy( + minWidth = constraints.minWidth + bleed, + maxWidth = constraints.maxWidth + bleed + ) + ) + layout(placeable.width - bleed, placeable.height) { + placeable.place(-inset, 0) + } +} + +/** + * One chip per source: spinner while it is being queried, result count once it + * has answered, a warning when it failed. Catalogs answer seconds apart, so + * without this the grid looks finished as soon as the fastest one lands. + * + * Tapping a chip scrolls to that source's results, which is the quickest way + * past a catalog that answered with a dozen near misses. The row scrolls rather + * than wrapping: "Cover Art Archive" and "Web search" do not fit beside the + * others on a phone, and a second line of chrome costs a row of covers, which + * is what the sheet is for. + */ +@Composable +private fun SourceChips( + modifier: Modifier = Modifier, + listState: LazyListState, + statuses: List, + showWebSearchAction: Boolean, + webSearchEnabled: Boolean, + onSearchWeb: () -> Unit, + onJumpToSource: (CoverArtSource) -> Unit +) { + if (statuses.isEmpty() && !showWebSearchAction) return + + LazyRow( + state = listState, + modifier = modifier.fillMaxWidth(), + // The row runs edge to edge and insets its contents instead, so the + // first chip lines up with everything above it while the rest leave + // from the screen edge rather than from a padding line. + contentPadding = PaddingValues(horizontal = 20.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + statuses.forEach { status -> + item(key = status.source.name) { + val hasResults = !status.isSearching && !status.failed && status.resultCount > 0 + // A chip reads as one thing, not as a label beside a count: read + // out piecemeal, a failed catalog names itself and then a number. + val chipDescription = when { + status.isSearching -> stringResource( + R.string.cover_art_search_cd_catalog_searching, + status.source.label + ) + status.failed -> stringResource( + R.string.cover_art_search_cd_catalog_failed, + status.source.label + ) + else -> stringResource( + R.string.cover_art_search_cd_catalog_results, + status.source.label, + status.resultCount + ) + } + AssistChip( + onClick = { onJumpToSource(status.source) }, + enabled = hasResults, + label = { + Text( + text = if (hasResults) { + "${status.source.label} Β· ${status.resultCount}" + } else { + status.source.label + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + leadingIcon = when { + status.isSearching -> { + { + CircularProgressIndicator( + modifier = Modifier.size(AssistChipDefaults.IconSize), + strokeWidth = 2.dp + ) + } + } + // A shape as well as a colour, so the one state that + // means "nothing here" does not rest on colour alone. + status.failed -> { + { + Icon( + imageVector = Icons.Rounded.ErrorOutline, + contentDescription = null, + modifier = Modifier.size(AssistChipDefaults.IconSize) + ) + } + } + else -> null + }, + colors = AssistChipDefaults.assistChipColors( + disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledLeadingIconContentColor = if (status.failed) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ), + modifier = Modifier.semantics { contentDescription = chipDescription } + ) + } + } + + if (showWebSearchAction) { + item(key = "web-search-action") { + AssistChip( + onClick = onSearchWeb, + enabled = webSearchEnabled, + label = { + Text( + text = stringResource(R.string.cover_art_search_web_action), + maxLines = 1 + ) + }, + leadingIcon = { + Icon( + imageVector = Icons.Rounded.Public, + contentDescription = null, + modifier = Modifier.size(AssistChipDefaults.IconSize) + ) + }, + colors = AssistChipDefaults.assistChipColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + labelColor = MaterialTheme.colorScheme.onSecondaryContainer, + leadingIconContentColor = MaterialTheme.colorScheme.onSecondaryContainer + ), + border = null + ) + } + } + } +} + +/** + * The covers themselves, and whatever stands in for them: a spinner while the + * catalogs are still answering, or a line saying there was nothing. + * + * Emitted into the caller's grid rather than owning one, so the sheet has a + * single scrolling surface and the header above the covers scrolls away with + * them. + */ +private fun LazyGridScope.coverArtResults( + state: OnlineCoverArtUiState, + groups: List>>, + onCandidateSelected: (CoverArtCandidate) -> Unit +) { + // Results are drawn as soon as the first catalog answers, even while the + // slower ones are still running, so the grid comes before the spinner. + if (groups.isNotEmpty()) { + groups.forEach { (source, candidates) -> + item( + span = { GridItemSpan(maxLineSpan) }, + key = "header-${source.name}" + ) { + Text( + text = source.label, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp) + ) + } + + items(items = candidates, key = { it.id }) { candidate -> + CoverArtResultItem( + candidate = candidate, + isDownloading = state.downloadingCandidateId == candidate.id, + isMeasuring = candidate.id in state.measuringCandidateIds, + onClick = { onCandidateSelected(candidate) } + ) + } + } + return + } + + item(span = { GridItemSpan(maxLineSpan) }, key = "results-placeholder") { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 200.dp), + contentAlignment = Alignment.Center + ) { + when { + state.isSearching || state.isSearchingWeb -> CircularProgressIndicator() + state.errorRes != null -> ResultsMessage(text = stringResource(state.errorRes)) + state.hasSearched -> ResultsMessage(text = stringResource(R.string.cover_art_search_empty)) + else -> ResultsMessage(text = stringResource(R.string.cover_art_search_idle)) + } + } + } +} + +@Composable +private fun ResultsMessage(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 16.dp) + ) +} + +@Composable +private fun CoverArtResultItem( + candidate: CoverArtCandidate, + isDownloading: Boolean, + isMeasuring: Boolean, + onClick: () -> Unit +) { + Column( + modifier = Modifier.clickable( + enabled = !isDownloading, + role = Role.Button, + onClick = onClick + ), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHighest), + contentAlignment = Alignment.Center + ) { + SmartImage( + model = candidate.thumbnailUrl, + contentDescription = stringResource( + R.string.cover_art_search_cd_result, + candidate.albumTitle, + candidate.artistName + ), + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + placeholderResId = R.drawable.rounded_music_note_24, + errorResId = R.drawable.rounded_broken_image_24 + ) + + if (isDownloading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.45f)), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(modifier = Modifier.size(28.dp)) + } + } + } + + Text( + text = candidate.albumTitle, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (candidate.artistName.isNotBlank()) { + Text( + text = candidate.artistName, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Text( + text = candidateDetails(candidate, isMeasuring), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +/** + * Builds the "1000 Γ— 1000 Β· 148 KB Β· Deezer" line under a result. + * + * The resolution reads as approximate until the image itself has been measured, + * because up to that point it is only what the catalog promises to serve. + */ +@Composable +private fun candidateDetails(candidate: CoverArtCandidate, isMeasuring: Boolean): String { + val size = candidate.size + val resolution = when { + size == null && isMeasuring -> stringResource(R.string.cover_art_search_measuring) + size == null -> stringResource(R.string.cover_art_search_size_unknown) + size.measured -> stringResource( + R.string.cover_art_search_size_measured, + size.width, + size.height + ) + + else -> stringResource( + R.string.cover_art_search_size_nominal, + size.width, + size.height + ) + } + + return listOfNotNull( + resolution, + size?.byteCount?.let { formatByteCount(it) }, + candidate.source.label + ).joinToString(" Β· ") +} + +private fun formatByteCount(bytes: Long): String = when { + bytes >= 1024L * 1024L -> String.format(Locale.getDefault(), "%.1f MB", bytes / 1048576.0) + bytes >= 1024L -> "${bytes / 1024L} KB" + else -> "$bytes B" +} diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AlbumDetailScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AlbumDetailScreen.kt index ce578abd50..35a0dec452 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AlbumDetailScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AlbumDetailScreen.kt @@ -9,6 +9,10 @@ import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring import androidx.compose.animation.core.animateDpAsState +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -29,16 +33,33 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.rounded.Image +import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.Shuffle +import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ContainedLoadingIndicator +import androidx.compose.material3.TextButton import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LargeExtendedFloatingActionButton +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -46,8 +67,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -84,7 +107,15 @@ import androidx.media3.common.util.UnstableApi import androidx.navigation.NavController import coil.compose.AsyncImagePainter import coil.size.Size +import androidx.compose.ui.platform.LocalContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import com.theveloper.pixelplay.R +import com.theveloper.pixelplay.utils.AlbumArtUtils +import com.theveloper.pixelplay.utils.LocalArtworkUri +import com.theveloper.pixelplay.presentation.components.CoverArtCropperDialog +import com.theveloper.pixelplay.presentation.components.OnlineCoverArtPickerSheet +import com.theveloper.pixelplay.data.media.CoverArtUpdate import com.theveloper.pixelplay.data.model.Album import com.theveloper.pixelplay.presentation.components.CollapsibleCommonTopBar import com.theveloper.pixelplay.presentation.components.ExpressiveScrollBar @@ -123,6 +154,74 @@ fun AlbumDetailScreen( val navBarCompactMode by playerViewModel.navBarCompactMode.collectAsStateWithLifecycle() var showSongInfoBottomSheet by remember { mutableStateOf(false) } + var showCoverArtPicker by remember { mutableStateOf(false) } + var pendingCoverArtUri by rememberSaveable { mutableStateOf(null) } + // The writer's revision, which changes once a cover has actually been + // written. The songs are not that signal: a row keeps the canonical URI it + // already held, so the list re-emits equal. Nor is a token bumped where the + // action fires, which is before the write it stands for has run. + val context = LocalContext.current + val coverArtRevision by playerViewModel.appliedCoverArtRevision.collectAsStateWithLifecycle() + val batchEditInProgress by playerViewModel.batchEditInProgress.collectAsStateWithLifecycle() + + // Album rows keep the same artwork URI when a cover is replaced, so this + // token forces the header to reload in place -- but only once the new cover + // is on disk, or the reload re-caches the old image under the new URI. + val coverArtToken = coverArtRevision + + // Which removal this album needs, deciding what the menu offers and whether + // it asks first. "All of them" rather than "any": where only some tracks + // hold an applied cover, removal still has files to rewrite for the rest, + // so it is the destructive one. + var appliedCoverTrackCount by remember { mutableIntStateOf(0) } + LaunchedEffect(uiState.songs, coverArtRevision) { + appliedCoverTrackCount = withContext(Dispatchers.IO) { + uiState.songs.count { song -> + song.id.toLongOrNull() + ?.let { AlbumArtUtils.getAppliedAlbumArtFile(context, it) != null } == true + } + } + } + val songCount = uiState.songs.size + val everyTrackHoldsAppliedCover = songCount > 0 && appliedCoverTrackCount == songCount + // Nothing to take off an album that is not showing a cover in the first + // place. Read from the row rather than the files: the header draws from it, + // so it is exactly what the user is looking at. + val albumShowsCoverArt = uiState.album?.albumArtUriString != null + var showDeleteCoverFromFilesDialog by remember { mutableStateOf(false) } + + // Shared by the two top bar variants below, which differ only in which + // composable draws them. + val onRemoveCoverArt: () -> Unit = { + // Removal always clears the applied cover, so the menu can drop the + // entry now rather than waiting for the probe to confirm what is + // already decided. + appliedCoverTrackCount = 0 + playerViewModel.removeAppliedCoverArt(uiState.songs) + } + val onDeleteCoverFromFiles: () -> Unit = { + showDeleteCoverFromFilesDialog = false + // The tag editor's cover delete, for every track at once. The save + // sorts the album out track by track, and asks for write consent first. + playerViewModel.saveBatchMetadata( + songs = uiState.songs, + title = null, + artist = null, + album = null, + albumArtist = null, + composer = null, + genre = null, + lyrics = null, + trackNumber = null, + discNumber = null, + replayGainTrackGainDb = null, + replayGainAlbumGainDb = null, + coverArtUpdate = CoverArtUpdate(isDeletion = true) + ) + } + val pickCoverArtLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.PickVisualMedia() + ) { uri -> if (uri != null) pendingCoverArtUri = uri } val selectedSongForInfo by playerViewModel.selectedSongForInfo.collectAsStateWithLifecycle() val systemNavBarInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() val bottomBarHeightDp = resolveNavBarOccupiedHeight(systemNavBarInset, navBarCompactMode) @@ -374,7 +473,18 @@ fun AlbumDetailScreen( val randomSong = songs.random() playerViewModel.showAndPlaySong(randomSong, songs) } - } + }, + onSearchCoverArtOnline = { showCoverArtPicker = true }, + onPickCoverArtFromGallery = { + pickCoverArtLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + }, + canRemoveCoverArt = everyTrackHoldsAppliedCover, + onRemoveCoverArt = onRemoveCoverArt, + canDeleteCoverFromFiles = albumShowsCoverArt, + onDeleteCoverFromFiles = { showDeleteCoverFromFilesDialog = true }, + coverArtToken = coverArtToken ) } else { CollapsingAlbumTopBar( @@ -394,12 +504,146 @@ fun AlbumDetailScreen( val randomSong = songs.random() playerViewModel.showAndPlaySong(randomSong, songs) } - } + }, + onSearchCoverArtOnline = { showCoverArtPicker = true }, + onPickCoverArtFromGallery = { + pickCoverArtLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + }, + canRemoveCoverArt = everyTrackHoldsAppliedCover, + onRemoveCoverArt = onRemoveCoverArt, + canDeleteCoverFromFiles = albumShowsCoverArt, + onDeleteCoverFromFiles = { showDeleteCoverFromFilesDialog = true }, + coverArtToken = coverArtToken ) } + + // The cropper closes the moment the write starts, so + // without this the screen sits unchanged for seconds on the + // one path writing to the user's files. Clear of the mini + // player; a bar at the top edge the header art would swallow. + AnimatedVisibility( + visible = batchEditInProgress, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = bottomBarHeightDp + 16.dp) + ) { + Surface( + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + tonalElevation = 3.dp + ) { + Row( + modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = stringResource( + R.string.metadata_edit_updating_n_songs, + uiState.songs.size + ), + style = MaterialTheme.typography.bodyMedium + ) + } + } + } } } } + + // The one action in this menu that cannot be undone: the artwork lives + // in the audio files and nothing else holds a copy of it. + if (showDeleteCoverFromFilesDialog) { + AlertDialog( + icon = { Icon(Icons.Rounded.Delete, contentDescription = null) }, + title = { Text(stringResource(R.string.album_delete_cover_dialog_title)) }, + text = { + Text( + stringResource( + R.string.album_delete_cover_dialog_body, + uiState.songs.size + ) + ) + }, + onDismissRequest = { showDeleteCoverFromFilesDialog = false }, + confirmButton = { + TextButton(onClick = onDeleteCoverFromFiles) { + Text( + stringResource(R.string.common_delete), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteCoverFromFilesDialog = false }) { + Text( + stringResource(R.string.common_cancel), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + ) + } + + val albumForCoverArt = uiState.album + // Kept in composition while it animates away, so visibility is the + // picker's own business rather than a branch around it. + if (albumForCoverArt != null) { + OnlineCoverArtPickerSheet( + visible = showCoverArtPicker, + initialAlbum = albumForCoverArt.title, + initialArtist = albumForCoverArt.artist, + noteText = stringResource( + R.string.cover_art_search_album_scope, + uiState.songs.size + ), + onDismiss = { showCoverArtPicker = false }, + onCoverDownloaded = { uri -> + showCoverArtPicker = false + pendingCoverArtUri = uri + } + ) + } + + pendingCoverArtUri?.let { sourceUri -> + // Same cropper as every other cover art edit; the confirmed image is + // applied to every track of the album so the whole album moves together. + CoverArtCropperDialog( + sourceUri = sourceUri, + onDismiss = { pendingCoverArtUri = null }, + onConfirm = { result -> + pendingCoverArtUri = null + playerViewModel.saveBatchMetadata( + songs = uiState.songs, + title = null, + artist = null, + album = null, + albumArtist = null, + composer = null, + genre = null, + lyrics = null, + trackNumber = null, + discNumber = null, + replayGainTrackGainDb = null, + replayGainAlbumGainDb = null, + coverArtUpdate = result.update + ) + } + ) + } + if (showSongInfoBottomSheet && selectedSongForInfo != null) { val currentSong = selectedSongForInfo val isFavorite = remember(currentSong?.id, favoriteIds) { @@ -506,7 +750,14 @@ private fun SharedAlbumTopBarProbe( headerImageRequestSize: Size, onHeaderArtworkState: ((AsyncImagePainter.State) -> Unit)? = null, onBackPressed: () -> Unit, - onPlayClick: () -> Unit + onPlayClick: () -> Unit, + onSearchCoverArtOnline: () -> Unit, + onPickCoverArtFromGallery: () -> Unit, + canRemoveCoverArt: Boolean, + onRemoveCoverArt: () -> Unit, + canDeleteCoverFromFiles: Boolean, + onDeleteCoverFromFiles: () -> Unit, + coverArtToken: Long ) { val surfaceColor = MaterialTheme.colorScheme.surface val statusBarColor = @@ -546,7 +797,7 @@ private fun SharedAlbumTopBarProbe( ) { if (expandedContentAlpha > 0.01f) { SmartImage( - model = album.albumArtUriString, + model = album.albumArtUriString.withCoverArtToken(coverArtToken), contentDescription = stringResource(R.string.album_cover_for, album.title), contentScale = ContentScale.Crop, targetSize = headerImageRequestSize, @@ -596,7 +847,19 @@ private fun SharedAlbumTopBarProbe( contentColor = MaterialTheme.colorScheme.onSurface, subtitleColor = MaterialTheme.colorScheme.onSurfaceVariant, fadeSubtitleOnCollapse = false, - syncStatusBarWithContainer = false + syncStatusBarWithContainer = false, + actions = { + Box(modifier = Modifier.padding(end = 12.dp, top = 4.dp)) { + CoverArtMenuButton( + onSearchOnline = onSearchCoverArtOnline, + onPickFromGallery = onPickCoverArtFromGallery, + canRemoveCover = canRemoveCoverArt, + canDeleteCoverFromFiles = canDeleteCoverFromFiles, + onDeleteCoverFromFiles = onDeleteCoverFromFiles, + onRemoveCover = onRemoveCoverArt + ) + } + } ) LargeExtendedFloatingActionButton( @@ -617,6 +880,102 @@ private fun SharedAlbumTopBarProbe( } } +/** + * Adds a cache busting token to an artwork request without touching what is + * stored, so a replaced cover reloads in place. + * + * Uses `t`, the token [LocalArtworkUri] writes and reads, rather than inventing + * a second one: a token only this file knows about is invisible to everything + * that already understands these URIs. + */ +private fun String?.withCoverArtToken(token: Long): String? { + val uri = this ?: return null + if (token == 0L) return uri + + val separator = uri.indexOf('?') + if (separator < 0) return "$uri?t=$token" + + // Any token already on the uri is dropped rather than joined. The reader + // takes the first `t` it finds, so appending a second one leaves the old + // value winning and the cover never reloads. + val base = uri.substring(0, separator) + val params = uri.substring(separator + 1) + .split('&') + .filter { it.isNotEmpty() && it.substringBefore('=') != "t" } + + return (params + "t=$token").joinToString(separator = "&", prefix = "$base?") +} + +/** + * Cover art actions for an album. Online search covers most releases, and the + * gallery covers the ones no catalog carries -- Bandcamp-only releases, private + * pressings, anything self-released. + */ +@Composable +private fun CoverArtMenuButton( + onSearchOnline: () -> Unit, + onPickFromGallery: () -> Unit, + canRemoveCover: Boolean, + onRemoveCover: () -> Unit, + canDeleteCoverFromFiles: Boolean, + onDeleteCoverFromFiles: () -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + FilledIconButton( + onClick = { expanded = true }, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ) + ) { + Icon( + imageVector = Icons.Rounded.Edit, + contentDescription = stringResource(R.string.album_cd_edit_cover_art) + ) + } + + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.album_action_search_cover_online)) }, + leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) }, + onClick = { + expanded = false + onSearchOnline() + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.album_action_pick_cover_from_gallery)) }, + leadingIcon = { Icon(Icons.Rounded.Image, contentDescription = null) }, + onClick = { + expanded = false + onPickFromGallery() + } + ) + // At most one of these. Taking back an app-held cover is reversible; + // taking one out of the audio files destroys the image, so they are + // separate entries and only the second asks first. + if (canRemoveCover) { + DropdownMenuItem( + text = { Text(stringResource(R.string.album_action_remove_cover)) }, + leadingIcon = { Icon(Icons.Rounded.Delete, contentDescription = null) }, + onClick = { + expanded = false + onRemoveCover() + } + ) + } else if (canDeleteCoverFromFiles) { + DropdownMenuItem( + text = { Text(stringResource(R.string.album_action_delete_cover_from_files)) }, + leadingIcon = { Icon(Icons.Rounded.Delete, contentDescription = null) }, + onClick = { + expanded = false + onDeleteCoverFromFiles() + } + ) + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun CollapsingAlbumTopBar( @@ -627,7 +986,14 @@ private fun CollapsingAlbumTopBar( headerImageRequestSize: Size, onHeaderArtworkState: ((AsyncImagePainter.State) -> Unit)? = null, onBackPressed: () -> Unit, - onPlayClick: () -> Unit + onPlayClick: () -> Unit, + onSearchCoverArtOnline: () -> Unit, + onPickCoverArtFromGallery: () -> Unit, + canRemoveCoverArt: Boolean, + onRemoveCoverArt: () -> Unit, + canDeleteCoverFromFiles: Boolean, + onDeleteCoverFromFiles: () -> Unit, + coverArtToken: Long ) { val surfaceColor = MaterialTheme.colorScheme.surface val statusBarColor = @@ -692,7 +1058,7 @@ private fun CollapsingAlbumTopBar( ) { if (showExpandedArtwork) { SmartImage( - model = album.albumArtUriString, + model = album.albumArtUriString.withCoverArtToken(coverArtToken), contentDescription = stringResource(R.string.album_cover_for, album.title), contentScale = ContentScale.Crop, targetSize = headerImageRequestSize, @@ -732,6 +1098,21 @@ private fun CollapsingAlbumTopBar( Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.common_back)) } + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(end = 12.dp, top = 4.dp) + ) { + CoverArtMenuButton( + onSearchOnline = onSearchCoverArtOnline, + onPickFromGallery = onPickCoverArtFromGallery, + canRemoveCover = canRemoveCoverArt, + canDeleteCoverFromFiles = canDeleteCoverFromFiles, + onDeleteCoverFromFiles = onDeleteCoverFromFiles, + onRemoveCover = onRemoveCoverArt + ) + } + Box( modifier = Modifier .align(animatedTitleAlignment) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt index dd1672dbc6..9b7715b842 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt @@ -57,6 +57,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.background +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.clickable @@ -66,6 +67,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ClearAll import androidx.compose.material.icons.outlined.Folder +import androidx.compose.material.icons.outlined.Image +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material.icons.outlined.Wifi import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.LightMode @@ -135,6 +140,8 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextGeometricTransform import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -151,6 +158,8 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import com.theveloper.pixelplay.R +import com.theveloper.pixelplay.data.coverart.AlbumArtStorage +import com.theveloper.pixelplay.data.network.webimage.WebImageSearchEngine import com.theveloper.pixelplay.data.backup.model.BackupHistoryEntry import com.theveloper.pixelplay.data.backup.model.BackupOperationType import com.theveloper.pixelplay.data.backup.model.BackupSection @@ -506,6 +515,97 @@ fun SettingsCategoryScreen( onCheckedChange = { settingsViewModel.setAutoScanLrcFiles(it) }, leadingIcon = { Icon(Icons.Outlined.Folder, null, tint = MaterialTheme.colorScheme.secondary) } ) + + val albumArtStorage by settingsViewModel.albumArtStorage.collectAsStateWithLifecycle() + + ThemeSelectorItem( + label = stringResource(R.string.settings_album_art_storage_title), + description = stringResource(R.string.settings_album_art_storage_subtitle), + options = mapOf( + AlbumArtStorage.AUDIO_FILES.name to + stringResource(R.string.settings_album_art_storage_files), + AlbumArtStorage.APP_ONLY.name to + stringResource(R.string.settings_album_art_storage_app) + ), + selectedKey = albumArtStorage.name, + onSelectionChanged = { key -> + settingsViewModel.setAlbumArtStorage( + AlbumArtStorage.entries.first { it.name == key } + ) + }, + leadingIcon = { + Icon( + Icons.Outlined.Image, + null, + tint = MaterialTheme.colorScheme.secondary + ) + } + ) + + val autoAlbumArtEnabled by settingsViewModel.autoAlbumArtEnabled.collectAsStateWithLifecycle() + val autoAlbumArtUnmeteredOnly by settingsViewModel.autoAlbumArtUnmeteredOnly.collectAsStateWithLifecycle() + + SwitchSettingItem( + title = stringResource(R.string.settings_auto_album_art_title), + // Covers found automatically never reach the + // audio files, so a user who chose to apply + // covers into them is told here rather than + // left to find out from the storage setting + // they are no longer looking at. + subtitle = if (albumArtStorage == AlbumArtStorage.AUDIO_FILES) { + stringResource(R.string.settings_auto_album_art_subtitle_app_only) + } else { + stringResource(R.string.settings_auto_album_art_subtitle) + }, + checked = autoAlbumArtEnabled, + onCheckedChange = { settingsViewModel.setAutoAlbumArtEnabled(it) }, + leadingIcon = { Icon(Icons.Outlined.Image, null, tint = MaterialTheme.colorScheme.secondary) } + ) + + val webImageSearchKey by settingsViewModel.webImageSearchApiKey.collectAsStateWithLifecycle() + var showWebImageSearchDialog by remember { mutableStateOf(false) } + + SettingsItem( + title = stringResource(R.string.settings_web_image_search_title), + subtitle = if (webImageSearchKey.isNotBlank()) { + stringResource( + R.string.settings_web_image_search_configured, + WebImageSearchEngine.LABEL + ) + } else { + stringResource(R.string.settings_web_image_search_subtitle) + }, + leadingIcon = { Icon(Icons.Outlined.Search, null, tint = MaterialTheme.colorScheme.secondary) }, + onClick = { showWebImageSearchDialog = true } + ) + + if (showWebImageSearchDialog) { + WebImageSearchDialog( + currentApiKey = webImageSearchKey, + onDismiss = { showWebImageSearchDialog = false }, + onSave = { key -> + settingsViewModel.setWebImageSearchApiKey(key) + showWebImageSearchDialog = false + } + ) + } + + if (autoAlbumArtEnabled) { + SwitchSettingItem( + title = stringResource(R.string.settings_auto_album_art_unmetered_title), + subtitle = stringResource(R.string.settings_auto_album_art_unmetered_subtitle), + checked = autoAlbumArtUnmeteredOnly, + onCheckedChange = { settingsViewModel.setAutoAlbumArtUnmeteredOnly(it) }, + leadingIcon = { Icon(Icons.Outlined.Wifi, null, tint = MaterialTheme.colorScheme.secondary) } + ) + + SettingsItem( + title = stringResource(R.string.settings_auto_album_art_retry_title), + subtitle = stringResource(R.string.settings_auto_album_art_retry_subtitle), + leadingIcon = { Icon(Icons.Outlined.Refresh, null, tint = MaterialTheme.colorScheme.secondary) }, + onClick = { settingsViewModel.retryMissingAlbumArt() } + ) + } } SettingsSubsection( @@ -2927,3 +3027,73 @@ private fun SettingsSubsection( Spacer(modifier = Modifier.height(10.dp)) } } + + +/** + * Configures the optional web image search source. + * + * Every usable engine requires an account, so the key is the user's own: it is + * stored on the device and sent only to the engine they picked. + */ +@Composable +private fun WebImageSearchDialog( + currentApiKey: String, + onDismiss: () -> Unit, + onSave: (String) -> Unit +) { + var apiKey by remember { mutableStateOf(currentApiKey) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.settings_web_image_search_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = stringResource(R.string.settings_web_image_search_explainer), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + OutlinedTextField( + value = apiKey, + onValueChange = { apiKey = it }, + label = { Text(stringResource(R.string.settings_web_image_search_key_label)) }, + singleLine = true, + // Masked like the other credentials in Settings, and typed + // as a password so the soft keyboard does not learn it. + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password + ), + modifier = Modifier.fillMaxWidth() + ) + + Text( + text = stringResource( + R.string.settings_web_image_search_key_hint, + WebImageSearchEngine.CONSOLE_URL + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + confirmButton = { + TextButton(onClick = { onSave(apiKey) }) { + Text(stringResource(R.string.common_save)) + } + }, + dismissButton = { + Row { + if (currentApiKey.isNotBlank()) { + TextButton(onClick = { onSave("") }) { + Text(stringResource(R.string.settings_web_image_search_disable)) + } + } + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.common_cancel)) + } + } + } + ) +} diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/MetadataEditStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/MetadataEditStateHolder.kt index 318528acf8..e8c24b3238 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/MetadataEditStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/MetadataEditStateHolder.kt @@ -7,6 +7,8 @@ import android.util.Log import androidx.core.net.toUri import androidx.media3.common.C import com.theveloper.pixelplay.R +import com.theveloper.pixelplay.data.coverart.AlbumArtStorage +import com.theveloper.pixelplay.data.coverart.AppArtworkWriter import com.theveloper.pixelplay.data.database.AlbumArtThemeDao import com.theveloper.pixelplay.data.media.CoverArtUpdate import com.theveloper.pixelplay.data.media.ImageCacheManager @@ -14,18 +16,26 @@ import com.theveloper.pixelplay.data.media.MetadataEditError import com.theveloper.pixelplay.data.media.SongMetadataEditor import com.theveloper.pixelplay.data.model.Lyrics import com.theveloper.pixelplay.data.model.Song +import com.theveloper.pixelplay.data.preferences.UserPreferencesRepository import com.theveloper.pixelplay.data.repository.MusicRepository import com.theveloper.pixelplay.utils.FileDeletionUtils +import com.theveloper.pixelplay.utils.AlbumArtUtils +import com.theveloper.pixelplay.utils.LocalArtworkUri import com.theveloper.pixelplay.utils.LyricsUtils import com.theveloper.pixelplay.utils.MediaItemBuilder import com.theveloper.pixelplay.utils.MediaStorePermissionHelper import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope +import com.theveloper.pixelplay.data.media.AudioMetadataReader +import kotlin.math.abs import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -95,6 +105,8 @@ class MetadataEditStateHolder @Inject constructor( private val libraryStateHolder: LibraryStateHolder, private val multiSelectionStateHolder: MultiSelectionStateHolder, private val albumArtThemeDao: AlbumArtThemeDao, + private val appArtworkWriter: AppArtworkWriter, + private val userPreferencesRepository: UserPreferencesRepository, @ApplicationContext private val context: Context ) { @@ -106,6 +118,23 @@ class MetadataEditStateHolder @Inject constructor( ) val writePermissionRequest: SharedFlow = _writePermissionRequest.asSharedFlow() + /** Re-exposed for the UI; see [AppArtworkWriter.appliedArtworkRevision]. */ + val appliedArtworkRevision: StateFlow = appArtworkWriter.appliedArtworkRevision + + private val _batchEditInProgress = MutableStateFlow(false) + private val batchEditsRunning = java.util.concurrent.atomic.AtomicInteger(0) + + /** + * True while a batch of songs is being written, so a screen that started one + * can say it is happening. Under [AlbumArtStorage.AUDIO_FILES] that is a tag + * rewrite and a MediaStore rescan per track. + * + * Raised around the writing rather than the whole save: on Android 11+ the + * save parks for MediaStore consent and resumes, and an indicator held + * across that would sit stranded behind the system dialog. + */ + val batchEditInProgress: StateFlow = _batchEditInProgress.asStateFlow() + // Edits parked while waiting for the user's MediaStore write-permission decision. private var pendingMetadataEdit: PendingMetadataEdit? = null private var pendingBatchMetadataEdit: PendingBatchMetadataEdit? = null @@ -118,7 +147,15 @@ class MetadataEditStateHolder @Inject constructor( val updatedAlbumArtUri: String? = null, val parsedLyrics: Lyrics? = null, val error: MetadataEditError? = null, - val errorMessage: String? = null + val errorMessage: String? = null, + /** + * True when a cover was written to the app's artwork store during this + * save. Reported even on failure, because the store write does not + * depend on the tag write succeeding, and a caller that skips its + * refresh on failure would otherwise leave the queue and the + * notification drawing a cover the rows no longer name. + */ + val appliedCoverInApp: Boolean = false ) { /** * Returns a user-friendly error message based on the error type @@ -138,6 +175,15 @@ class MetadataEditStateHolder @Inject constructor( } } + /** + * @param appStoreCoverOutcome whether a cover was already written to the + * app's store for this song, or null when this call must write it. Writing + * it here sees one id at a time, so the writer could never recognise an + * apply as covering a whole album. + * @param syncLibraryLyrics whether [newLyrics] is also the library's copy. + * False when the caller filled the field in from the file merely to have + * something to write, which would otherwise drop lyrics this app fetched. + */ suspend fun saveMetadata( song: Song, newTitle: String, @@ -151,15 +197,26 @@ class MetadataEditStateHolder @Inject constructor( newDiscNumber: Int?, newReplayGainTrackGainDb: String? = null, newReplayGainAlbumGainDb: String? = null, - coverArtUpdate: CoverArtUpdate? + coverArtUpdate: CoverArtUpdate?, + cb: MetadataEditCallbacks, + appStoreCoverOutcome: Boolean? = null, + syncLibraryLyrics: Boolean = true ): MetadataEditResult = withContext(Dispatchers.IO) { - + Log.d("MetadataEditStateHolder", "Starting saveMetadata for: ${song.title}") + // The setting is about artwork, so it holds mid tag edit too: the tags + // still land in the file and the file's own artwork is left as it was. + val keepArtInApp = coverArtUpdate != null && + !coverArtUpdate.isDeletion && + coverArtUpdate.bytes != null && + userPreferencesRepository.albumArtStorageFlow.first() == AlbumArtStorage.APP_ONLY + val fileCoverArtUpdate = if (keepArtInApp) null else coverArtUpdate + // CRITICAL FIX: Preserve existing embedded artwork if the user didn't provide a new one. // Editing text metadata might strip the artwork if the underlying tagging library // overwrites the file structure. Explicitly re-saving the existing artwork prevents this. - val finalCoverArtUpdate = if (coverArtUpdate == null) { + val finalCoverArtUpdate = if (fileCoverArtUpdate == null) { val existingMetadata = try { com.theveloper.pixelplay.data.media.AudioMetadataReader.read(java.io.File(song.path)) } catch (e: Exception) { @@ -171,18 +228,25 @@ class MetadataEditStateHolder @Inject constructor( } else { null } - } else if (coverArtUpdate.isDeletion) { + } else if (fileCoverArtUpdate.isDeletion) { Log.d("MetadataEditStateHolder", "Artwork deletion requested, skipping preservation") - coverArtUpdate + fileCoverArtUpdate } else { - coverArtUpdate + fileCoverArtUpdate } val trimmedLyrics = newLyrics.trim() val normalizedLyrics = trimmedLyrics.takeIf { it.isNotBlank() } - // We parse lyrics here just to ensure they are valid or to have them ready, + // What the library should hold afterwards, which is not always what is + // being written to the file: see [syncLibraryLyrics]. + val libraryLyrics = if (syncLibraryLyrics) { + normalizedLyrics + } else { + song.lyrics?.takeIf { it.isNotBlank() } + } + // We parse lyrics here just to ensure they are valid or to have them ready, // essentially mirroring logic in ViewModel - val parsedLyrics = normalizedLyrics?.let { LyricsUtils.parseLyrics(it) } + val parsedLyrics = libraryLyrics?.let { LyricsUtils.parseLyrics(it) } val resolvedSongId = resolveSongIdForMetadataEdit(song) if (resolvedSongId == null) { @@ -212,18 +276,55 @@ class MetadataEditStateHolder @Inject constructor( Log.d("MetadataEditStateHolder", "Editor result: success=${result.success}, error=${result.error}") + // Storing the cover in the app needs nothing from the file, so a tag + // write that failed on a read-only file or a refused consent is no + // reason to throw away the cover the user picked. + val wantsAppStorage = keepArtInApp && coverArtUpdate?.bytes != null + val coverStored = when { + !wantsAppStorage -> false + // Already written for the whole batch this song is part of. + appStoreCoverOutcome != null -> appStoreCoverOutcome + else -> appArtworkWriter.apply( + bytes = requireNotNull(coverArtUpdate?.bytes), + songIds = listOf(resolvedSongId), + albumId = song.albumId + ) + } + // Only the cover's own outcome fails here; the rest of the save does not + // depend on it. A batch reports its single write once, from the caller. + if (wantsAppStorage && !coverStored && appStoreCoverOutcome == null) { + cb.sendToast(context.getString(R.string.cover_art_apply_failed)) + } + + // The applied store answers before the file does, so an applied cover + // from before the user switched to AUDIO_FILES would keep winning over + // the one just written. fileCoverArtUpdate rather than + // finalCoverArtUpdate: re-saving the file's existing artwork is not a + // new choice and should not disturb an applied cover. + if (result.success && + fileCoverArtUpdate != null && + !fileCoverArtUpdate.isDeletion && + fileCoverArtUpdate.bytes != null + ) { + AlbumArtUtils.clearAppliedArtForSong(context, resolvedSongId) + } + val artAppliedInApp = coverStored + + if (result.success) { - val refreshedAlbumArtUri = if (coverArtUpdate?.isDeletion == true) { - null - } else { - result.updatedAlbumArtUri ?: song.albumArtUriString + val refreshedAlbumArtUri = when { + coverArtUpdate?.isDeletion == true -> null + artAppliedInApp -> LocalArtworkUri.buildSongUriWithTimestamp(resolvedSongId) + else -> result.updatedAlbumArtUri ?: song.albumArtUriString } // Update Repository (Lyrics) - if (normalizedLyrics != null) { - musicRepository.updateLyrics(resolvedSongId, normalizedLyrics) - } else { - musicRepository.resetLyrics(resolvedSongId) + if (syncLibraryLyrics) { + if (normalizedLyrics != null) { + musicRepository.updateLyrics(resolvedSongId, normalizedLyrics) + } else { + musicRepository.resetLyrics(resolvedSongId) + } } val updatedSong = song.copy( @@ -232,7 +333,7 @@ class MetadataEditStateHolder @Inject constructor( album = newAlbum, albumArtist = newAlbumArtist.trim().takeIf { it.isNotBlank() }, genre = newGenre, - lyrics = normalizedLyrics, + lyrics = libraryLyrics, trackNumber = newTrackNumber, discNumber = newDiscNumber, albumArtUriString = refreshedAlbumArtUri, @@ -254,9 +355,18 @@ class MetadataEditStateHolder @Inject constructor( albumArtUriString = refreshedAlbumArtUri ) + // Removing a cover means removing the applied one too, or the file + // would lose its artwork and the app would keep showing the cover + // the user just asked to be rid of. + if (coverArtUpdate?.isDeletion == true) { + AlbumArtUtils.clearAppliedArtForSong(context, resolvedSongId) + } + // Force cache invalidation if album art might have changed val uriToInvalidate = if (coverArtUpdate?.isDeletion == true) song.albumArtUriString else refreshedAlbumArtUri - if (uriToInvalidate != null) { + // The writer has already dropped the rendered bitmaps; dropping them + // again here would only cost a reload. + if (uriToInvalidate != null && !artAppliedInApp) { // Invalidate Coil/Glide caches for the affected URI (old or new) imageCacheManager.invalidateCoverArtCaches(uriToInvalidate) } @@ -268,14 +378,16 @@ class MetadataEditStateHolder @Inject constructor( success = true, updatedSong = freshSong, updatedAlbumArtUri = freshSong.albumArtUriString, - parsedLyrics = parsedLyrics + parsedLyrics = parsedLyrics, + appliedCoverInApp = artAppliedInApp ) } else { Log.w("MetadataEditStateHolder", "Metadata edit failed: ${result.error} - ${result.errorMessage}") MetadataEditResult( success = false, error = result.error, - errorMessage = result.errorMessage + errorMessage = result.errorMessage, + appliedCoverInApp = artAppliedInApp ) } } @@ -350,8 +462,71 @@ class MetadataEditStateHolder @Inject constructor( cb.scope.launch { Log.e("PlayerViewModel", "METADATA_EDIT_VM: Starting editSongMetadata via Holder") - // On Android 11+, request MediaStore write permission for local songs + // The applied cover is the one being shown, so removal drops that + // rather than rewriting the file. Unconditional once there is one to + // take back: gating it on "nothing else changed" left the deletion + // to the tag write, which strips the file's own artwork and cannot + // run at all on a file that has gone missing. val songId = song.id.toLongOrNull() + val holdsAppliedCover = coverArtUpdate?.isDeletion == true && + songId != null && + withContext(Dispatchers.IO) { + AlbumArtUtils.getAppliedAlbumArtFile(context, songId) != null + } + if (holdsAppliedCover) { + // The editor sends every field on every save, so unless this is + // the cover on its own the rest still has to reach the file. + val coverIsTheOnlyChange = onlyCoverArtChanged( + song, newTitle, newArtist, newAlbum, newAlbumArtist, newComposer, + newGenre, newLyrics, newTrackNumber, newDiscNumber, + newReplayGainTrackGainDb, newReplayGainAlbumGainDb + ) + val refreshed = removeAppliedCoverArt( + songs = listOf(song), + cb = cb, + notify = coverIsTheOnlyChange + ) + if (coverIsTheOnlyChange) return@launch + + // Back through the front door with the cover taken out: passing + // the deletion on would take the file's artwork with it. The + // refreshed copy, so the write works from what the row holds now. + editSongMetadata( + song = refreshed.firstOrNull() ?: song, + newTitle = newTitle, + newArtist = newArtist, + newAlbum = newAlbum, + newAlbumArtist = newAlbumArtist, + newComposer = newComposer, + newGenre = newGenre, + newLyrics = newLyrics, + newTrackNumber = newTrackNumber, + newDiscNumber = newDiscNumber, + newReplayGainTrackGainDb = newReplayGainTrackGainDb, + newReplayGainAlbumGainDb = newReplayGainAlbumGainDb, + coverArtUpdate = null, + cb = cb + ) + return@launch + } + + // A cover kept in the app never touches their files, so it skips the + // tag rewrite and the consent it would ask for -- before asking. + if (coverArtUpdate != null && + !coverArtUpdate.isDeletion && + coverArtUpdate.bytes != null && + userPreferencesRepository.albumArtStorageFlow.first() == AlbumArtStorage.APP_ONLY && + onlyCoverArtChanged( + song, newTitle, newArtist, newAlbum, newAlbumArtist, newComposer, + newGenre, newLyrics, newTrackNumber, newDiscNumber, + newReplayGainTrackGainDb, newReplayGainAlbumGainDb + ) + ) { + applyCoverArtInApp(listOf(song), coverArtUpdate, cb) + return@launch + } + + // On Android 11+, request MediaStore write permission for local songs if (songId != null && songId > 0 && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) { val intentSender = MediaStorePermissionHelper.createWriteRequestForSong(context, songId) if (intentSender != null) { @@ -381,6 +556,299 @@ class MetadataEditStateHolder @Inject constructor( } } + /** + * True when the incoming values are the ones the editor was showing, so the + * only thing this save would change is the cover. + * + * The editor sends every field on every save, so unlike [isCoverArtOnly] + * there are no nulls to read the answer off. The comparison must run against + * whatever each field was *populated* from -- mostly [Song], where comparing + * to the file's raw tags would report changes the user never made. Only + * composer, replay gain and fallback lyrics are read from the file, because + * that is where the editor reads them from too. + * + * Anything unreadable answers false, which writes the file: a needless + * rewrite is recoverable, a silently dropped edit is not. + */ + private suspend fun onlyCoverArtChanged( + song: Song, + newTitle: String, + newArtist: String, + newAlbum: String, + newAlbumArtist: String, + newComposer: String, + newGenre: String, + newLyrics: String, + newTrackNumber: Int, + newDiscNumber: Int?, + newReplayGainTrackGainDb: String?, + newReplayGainAlbumGainDb: String? + ): Boolean { + fun sameText(incoming: String?, shown: String?): Boolean = + incoming.orEmpty().trim() == shown.orEmpty().trim() + + val matchesLibrary = sameText(newTitle, song.title) && + sameText(newArtist, song.displayArtist) && + sameText(newAlbum, song.album) && + sameText(newAlbumArtist, song.albumArtist) && + sameText(newGenre, song.genre) && + newTrackNumber == song.trackNumber && + newDiscNumber == song.discNumber + if (!matchesLibrary) return false + + if (song.path.isBlank()) return false + val embedded = withContext(Dispatchers.IO) { + runCatching { + AudioMetadataReader.read(java.io.File(song.path), readArtwork = false) + }.getOrNull() + } ?: return false + + fun sameGain(incoming: String?, shown: Float?): Boolean { + val parsed = incoming?.trim()?.removeSuffix("dB")?.trim()?.toFloatOrNull() + return when { + parsed == null && shown == null -> true + parsed == null || shown == null -> false + else -> abs(parsed - shown) < 0.01f + } + } + + // The editor shows the library's lyrics when it has any and the file's + // otherwise, so that is the order the comparison has to follow. + val shownLyrics = song.lyrics?.takeIf { it.isNotBlank() } ?: embedded.lyrics + + return sameText(newComposer, embedded.composer) && + sameText(newLyrics, shownLyrics) && + sameGain(newReplayGainTrackGainDb, embedded.replayGainTrackGainDb) && + sameGain(newReplayGainAlbumGainDb, embedded.replayGainAlbumGainDb) + } + + /** + * True when the only thing being changed is the cover, which is what an + * apply from the album screen or the cover art picker sends. + */ + private fun isCoverArtOnly( + title: String?, + artist: String?, + album: String?, + albumArtist: String?, + composer: String?, + genre: String?, + lyrics: String?, + trackNumber: Int?, + discNumber: Int?, + replayGainTrackGainDb: String?, + replayGainAlbumGainDb: String?, + coverArtUpdate: CoverArtUpdate? + ): Boolean = coverArtUpdate != null && + !coverArtUpdate.isDeletion && + coverArtUpdate.bytes != null && + listOf( + title, artist, album, albumArtist, composer, genre, lyrics, + trackNumber, discNumber, replayGainTrackGainDb, replayGainAlbumGainDb + ).all { it == null } + + /** + * True when the only thing being changed is that the cover goes away, which + * is what the batch editor's delete button sends on its own. + * + * The counterpart to [isCoverArtOnly] for a removal, and read for the same + * reason: a save that has nothing to write into the files should not open a + * tag rewrite, nor ask for the consent one needs. + */ + private fun isCoverArtDeletionOnly( + title: String?, + artist: String?, + album: String?, + albumArtist: String?, + composer: String?, + genre: String?, + lyrics: String?, + trackNumber: Int?, + discNumber: Int?, + replayGainTrackGainDb: String?, + replayGainAlbumGainDb: String?, + coverArtUpdate: CoverArtUpdate? + ): Boolean = coverArtUpdate?.isDeletion == true && + listOf( + title, artist, album, albumArtist, composer, genre, lyrics, + trackNumber, discNumber, replayGainTrackGainDb, replayGainAlbumGainDb + ).all { it == null } + + /** The songs of [songs] whose cover is one this app is holding. */ + private suspend fun songsHoldingAppliedCover(songs: List): List = + withContext(Dispatchers.IO) { + songs.filter { song -> + song.id.toLongOrNull() + ?.let { AlbumArtUtils.getAppliedAlbumArtFile(context, it) != null } == true + } + } + + /** + * Takes back a cover applied to [songs], leaving the audio files alone. + * + * The undo for an apply, and the reason it is worth having: the cover was + * put on a whole album in one action, so it has to come off the same way. + * Everything after the removal is the refresh an apply does, because the + * same rows, queue entries and notification are showing the old cover. + */ + fun removeAppliedCoverArt(songs: List, cb: MetadataEditCallbacks) { + cb.scope.launch { removeAppliedCoverArt(songs, cb, notify = true) } + } + + /** + * The removal itself, for callers already inside a coroutine. + * + * @param notify whether to announce the removal. False when the removal is + * one half of a save that still has tags to write: the selection and the + * toast belong to that save as a whole, which reports its own outcome once + * rather than twice. + * @return the refreshed copies of [songs], so a caller carrying on with the + * same songs works from what the rows hold now rather than from the applied + * cover it just took back. + */ + private suspend fun removeAppliedCoverArt( + songs: List, + cb: MetadataEditCallbacks, + notify: Boolean + ): List { + val localSongs = songs.filter { (it.id.toLongOrNull() ?: 0L) > 0L } + val songIds = localSongs.mapNotNull { it.id.toLongOrNull() } + if (songIds.isEmpty()) return songs + + val albumId = localSongs.map { it.albumId }.distinct().singleOrNull() + val remainingArt = appArtworkWriter.removeApplied(songIds = songIds, albumId = albumId) + + // What the writer left in each row, not a URI per song: a song whose + // file carries no art is null there, and a URI resolving to nothing + // would contradict the row until the next library load. + val updatedSongs = localSongs.mapNotNull { song -> + val songId = song.id.toLongOrNull() ?: return@mapNotNull null + val refreshed = remainingArt[songId] + ?.let { LocalArtworkUri.buildSongUriWithTimestamp(songId) } + song.copy(albumArtUriString = refreshed) + } + updatedSongs.forEach(libraryStateHolder::updateSong) + + cb.updateUiState { state -> + var queue = state.currentPlaybackQueue + updatedSongs.forEach { updated -> queue = queue.replaceSong(updated) } + if (queue === state.currentPlaybackQueue) state else state.copy(currentPlaybackQueue = queue) + } + + val playingSong = playbackStateHolder.stablePlayerState.value.currentSong + updatedSongs.firstOrNull { it.id == playingSong?.id }?.let { updated -> + playbackStateHolder.updateStablePlayerState { it.copy(currentSong = updated) } + refreshPlayerArtwork(updated) + } + + if (notify) { + multiSelectionStateHolder.clearSelection() + cb.sendToast(context.getString(R.string.cover_art_removed_in_app)) + } + return updatedSongs + } + + /** + * Applies a cover to the app's own artwork store, leaving the audio files + * alone, and refreshes the rows and in-memory songs the UI draws from. + */ + private suspend fun applyCoverArtInApp( + songs: List, + coverArtUpdate: CoverArtUpdate, + cb: MetadataEditCallbacks + ) { + val bytes = coverArtUpdate.bytes ?: return + val localSongs = songs.filter { (it.id.toLongOrNull() ?: 0L) > 0L } + if (localSongs.isEmpty()) { + // Cloud tracks have no local artwork store to write to; saying so + // beats a button that looks like it did nothing. + cb.sendToast(context.getString(R.string.cover_art_applied_unsupported)) + return + } + + // Grouped by album, as the batch save groups it: AppArtworkWriter can + // only tell whether an apply covers a whole album when handed one + // album's ids alone, and a call naming two albums lets neither row + // follow. One album stays a single write. + val idsByAlbum = localSongs + .mapNotNull { song -> + song.id.toLongOrNull()?.takeIf { it > 0 }?.let { song.albumId to it } + } + .groupBy({ it.first }, { it.second }) + + val storedAlbums = idsByAlbum + .filter { (albumId, songIds) -> + appArtworkWriter.apply(bytes = bytes, songIds = songIds, albumId = albumId) + } + .keys + + if (storedAlbums.isEmpty()) { + // Nothing was written, so nothing below it -- the rows, the queue, + // the notification -- would be describing a cover the store does + // not actually have. + cb.sendToast(context.getString(R.string.cover_art_apply_failed)) + return + } + + // Only what actually landed: a song whose album failed to store is + // still drawn from the cover it had. + refreshSongsAfterAppliedCover(localSongs.filter { it.albumId in storedAlbums }, cb) + multiSelectionStateHolder.clearSelection() + cb.sendToast( + context.getString( + if (storedAlbums.size == idsByAlbum.size) { + R.string.cover_art_applied_in_app + } else { + R.string.cover_art_apply_failed + } + ) + ) + } + + /** + * Points the in-memory copies of [songs] at the cover just written to the + * app's store. + * + * This is what the file-writing path does for itself after a cover changes. + * Without it the notification keeps the old art and the queue holds songs + * drawn from the previous cover, while the rows and the store hold the new + * one -- a disagreement nothing resolves until the next library load. + */ + private fun refreshSongsAfterAppliedCover(songs: List, cb: MetadataEditCallbacks) { + val updatedSongs = songs.mapNotNull { song -> + val songId = song.id.toLongOrNull() ?: return@mapNotNull null + song.copy(albumArtUriString = LocalArtworkUri.buildSongUriWithTimestamp(songId)) + } + updatedSongs.forEach(libraryStateHolder::updateSong) + + cb.updateUiState { state -> + var queue = state.currentPlaybackQueue + updatedSongs.forEach { updated -> queue = queue.replaceSong(updated) } + if (queue === state.currentPlaybackQueue) state else state.copy(currentPlaybackQueue = queue) + } + + val playingSong = playbackStateHolder.stablePlayerState.value.currentSong + updatedSongs.firstOrNull { it.id == playingSong?.id }?.let { updated -> + playbackStateHolder.updateStablePlayerState { it.copy(currentSong = updated) } + refreshPlayerArtwork(updated) + } + } + + /** + * Rebuilds the playing item so the notification picks up the new artwork. + * Media3 keeps the metadata it was handed, so nothing else refreshes it. + */ + private fun refreshPlayerArtwork(updatedSong: Song) { + val controller = playbackStateHolder.mediaController ?: return + val currentIndex = controller.currentMediaItemIndex + if (currentIndex < 0 || currentIndex >= controller.mediaItemCount) return + + val currentPosition = controller.currentPosition + controller.replaceMediaItem(currentIndex, MediaItemBuilder.build(updatedSong)) + // replaceMediaItem may reset the position. + controller.seekTo(currentIndex, currentPosition) + } + fun saveBatchMetadata( songs: List, title: String?, @@ -398,6 +866,34 @@ class MetadataEditStateHolder @Inject constructor( cb: MetadataEditCallbacks, ) { cb.scope.launch { + // A cover the user keeps in the app never touches their files, so it + // skips the tag rewrite and the write consent it would ask for. + if (isCoverArtOnly(title, artist, album, albumArtist, composer, genre, lyrics, + trackNumber, discNumber, replayGainTrackGainDb, replayGainAlbumGainDb, + coverArtUpdate) && + userPreferencesRepository.albumArtStorageFlow.first() == AlbumArtStorage.APP_ONLY + ) { + applyCoverArtInApp(songs, requireNotNull(coverArtUpdate), cb) + return@launch + } + + // Taking one back never touches the files either, so it skips the + // rewrite and the consent. Letting the tag write carry the deletion + // strips the file's own artwork, and cannot run on a missing file. + if (isCoverArtDeletionOnly(title, artist, album, albumArtist, composer, genre, + lyrics, trackNumber, discNumber, replayGainTrackGainDb, + replayGainAlbumGainDb, coverArtUpdate) + ) { + val holders = songsHoldingAppliedCover(songs) + // Only when every selected song's cover is one the app holds. A + // selection mixing the two still has files to rewrite, and + // performBatchMetadataEdit sorts those out track by track. + if (holders.isNotEmpty() && holders.size == songs.size) { + removeAppliedCoverArt(holders, cb, notify = true) + return@launch + } + } + // Check if we need MediaStore permission (Android 11+) val localSongsNeedingPermission = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) { songs.mapNotNull { song -> @@ -625,7 +1121,8 @@ class MetadataEditStateHolder @Inject constructor( newDiscNumber = newDiscNumber, newReplayGainTrackGainDb = newReplayGainTrackGainDb, newReplayGainAlbumGainDb = newReplayGainAlbumGainDb, - coverArtUpdate = coverArtUpdate + coverArtUpdate = coverArtUpdate, + cb = cb ) Log.e("PlayerViewModel", "METADATA_EDIT_VM: Result success=${result.success}") @@ -691,12 +1188,37 @@ class MetadataEditStateHolder @Inject constructor( // No need for full library sync - file, MediaStore, and local DB are already updated cb.sendToast(context.getString(R.string.metadata_edit_updated_successfully)) } else { + // The cover is kept even when the tags could not be written, so the + // rows and the store already hold it; without this the queue and the + // notification would keep the old art until the next library load. + if (result.appliedCoverInApp) { + refreshSongsAfterAppliedCover(listOf(song), cb) + } val errorMessage = result.getUserFriendlyErrorMessage() Log.e("PlayerViewModel", "METADATA_EDIT_VM: Failed - ${result.error}: $errorMessage") cb.sendToast(errorMessage) } } + /** + * Runs [block] with [batchEditInProgress] raised. + * + * The flag is a count in spirit rather than a boolean: two batches can + * overlap -- a permission-resumed save landing while another is running -- + * and the first to finish must not lower it while the second still writes. + */ + private suspend fun withBatchEditInProgress(block: suspend () -> Unit) { + batchEditsRunning.incrementAndGet() + _batchEditInProgress.value = true + try { + block() + } finally { + if (batchEditsRunning.decrementAndGet() == 0) { + _batchEditInProgress.value = false + } + } + } + private suspend fun performBatchMetadataEdit( songs: List, title: String?, @@ -712,28 +1234,115 @@ class MetadataEditStateHolder @Inject constructor( replayGainAlbumGainDb: String?, coverArtUpdate: CoverArtUpdate?, cb: MetadataEditCallbacks, - ) { + ) = withBatchEditInProgress { var successCount = 0 var failureCount = 0 val previousAlbumArts = mutableSetOf() + // Written here rather than by the per-song saves: only a whole album's + // ids at once let the writer recognise the apply as covering that album + // and point its row at the new cover, and the image is decoded, scaled + // and re-encoded once per album instead of once per track. Grouped by + // album so a selection spanning two lets both rows follow. + val appStoreCoverOutcomes: Map? = run { + val bytes = coverArtUpdate?.takeIf { !it.isDeletion }?.bytes ?: return@run null + if (userPreferencesRepository.albumArtStorageFlow.first() != AlbumArtStorage.APP_ONLY) { + return@run null + } + // The same ids the per-song saves resolve, so the set written here + // is exactly the set they each expect to have been written for. + val writable = songs.mapNotNull { song -> + resolveSongIdForMetadataEdit(song)?.takeIf { it > 0 }?.let { song to it } + } + if (writable.isEmpty()) return@run null + + val outcomeByAlbum = writable + .groupBy({ it.first.albumId }, { it.second }) + .mapValues { (albumId, songIds) -> + appArtworkWriter.apply(bytes = bytes, songIds = songIds, albumId = albumId) + } + // Reported once for the save rather than once per track, and only + // for what actually failed to store. + if (outcomeByAlbum.values.any { !it }) { + cb.sendToast(context.getString(R.string.cover_art_apply_failed)) + } + + // Per song rather than per album: the writer refuses a cloud + // track's negative id, and reading its album's success as its own + // would point the track at a local artwork URI with no file behind + // it. Songs nothing was written for are answered false rather than + // left out, so the per-song save does not try again. + val writableIds = writable.mapTo(mutableSetOf()) { it.first.id } + songs.associate { song -> + song.id to (song.id in writableIds && outcomeByAlbum[song.albumId] == true) + } + } + songs.forEach { song -> previousAlbumArts.add(song.albumArtUriString) + // Reaching here means the selection mixes covers this app holds + // with covers that really are in the file, so the distinction is + // drawn per track: handing an applied one to the tag writer would + // strip the file's own artwork instead of revealing it. + val appliedSongId = song.id.toLongOrNull() + val holdsAppliedCover = coverArtUpdate?.isDeletion == true && + appliedSongId != null && + withContext(Dispatchers.IO) { + AlbumArtUtils.getAppliedAlbumArtFile(context, appliedSongId) != null + } + val songCoverArtUpdate = if (holdsAppliedCover) null else coverArtUpdate + // The refreshed copy, so the save below works from what the row + // holds now rather than from the cover it no longer points at. + val editedSong = if (holdsAppliedCover) { + removeAppliedCoverArt(listOf(song), cb, notify = false).firstOrNull() ?: song + } else { + song + } + + // A null field means "not being edited", but the tag write replaces + // the whole set, so each one still needs a value -- and Song is the + // library's rendering of the file, not the file. Writing it back + // turns a multi-artist "A; B" into displayArtist's ", " join, lands + // a filename-derived title in a file that had its own, and embeds + // lyrics this app fetched. The file answers for its own tags; Song + // fills in only what it has nothing to say about. + val readsFromFile = title == null || artist == null || album == null || + albumArtist == null || composer == null || genre == null || lyrics == null || + trackNumber == null || discNumber == null + val embedded = if (readsFromFile) { + withContext(Dispatchers.IO) { + runCatching { + AudioMetadataReader.read(java.io.File(song.path), readArtwork = false) + }.getOrNull() + } + } else { + null + } + val result = saveMetadata( - song = song, - newTitle = title ?: song.title, - newArtist = artist ?: song.displayArtist, - newAlbum = album ?: song.album, - newAlbumArtist = albumArtist ?: (song.albumArtist ?: ""), - newComposer = composer ?: "", - newGenre = genre ?: (song.genre ?: ""), - newLyrics = lyrics ?: (song.lyrics ?: ""), - newTrackNumber = trackNumber ?: song.trackNumber, - newDiscNumber = discNumber ?: song.discNumber, + song = editedSong, + newTitle = title ?: embedded?.title ?: song.title, + newArtist = artist ?: embedded?.artist ?: song.displayArtist, + newAlbum = album ?: embedded?.album ?: song.album, + newAlbumArtist = albumArtist + ?: embedded?.albumArtist + ?: song.albumArtist?.takeIf { it.isNotBlank() } + ?: "", + newComposer = composer ?: embedded?.composer ?: "", + newGenre = genre ?: embedded?.genre ?: song.genre?.takeIf { it.isNotBlank() } ?: "", + newLyrics = lyrics ?: embedded?.lyrics ?: song.lyrics?.takeIf { it.isNotBlank() } ?: "", + newTrackNumber = trackNumber ?: embedded?.trackNumber ?: song.trackNumber, + newDiscNumber = discNumber ?: embedded?.discNumber ?: song.discNumber, newReplayGainTrackGainDb = replayGainTrackGainDb, newReplayGainAlbumGainDb = replayGainAlbumGainDb, - coverArtUpdate = coverArtUpdate + coverArtUpdate = songCoverArtUpdate, + cb = cb, + appStoreCoverOutcome = appStoreCoverOutcomes?.get(song.id), + // The lyrics above are the file's own when this save is not + // editing them, which is not what the library's copy should + // be overwritten with. + syncLibraryLyrics = lyrics != null ) if (result.success && result.updatedSong != null) { @@ -742,6 +1351,15 @@ class MetadataEditStateHolder @Inject constructor( val refreshedAlbumArtUri = result.updatedAlbumArtUri // Invalidate caches for this song + song.id.toLongOrNull()?.takeIf { coverArtUpdate?.isDeletion == true }?.let { songId -> + // clearAppliedArtForSong blocks on a file delete under a shared lock -- + // this whole branch otherwise runs on viewModelScope's Main dispatcher, + // so only this call is pushed off it rather than wrapping the block that + // follows, which touches the MediaController and must stay on Main. + withContext(Dispatchers.IO) { + AlbumArtUtils.clearAppliedArtForSong(context, songId) + } + } invalidateCoverArtCaches(song.albumArtUriString, refreshedAlbumArtUri) // Update queue if this song is in it @@ -785,11 +1403,24 @@ class MetadataEditStateHolder @Inject constructor( } } else { failureCount++ + // As in the single-song path: the cover is kept even when the + // tags could not be written, so the copies this song is drawn + // from have to follow the row rather than keep the old art. + if (result.appliedCoverInApp) { + refreshSongsAfterAppliedCover(listOf(song), cb) + } } } // Handle cover art theme updates if artwork was changed if (coverArtUpdate != null) { + // A cover written into the files leaves the album row pointing at + // the URI it already held, so nothing tells a header to reload. + // The app store's own writes announce themselves. + if (appStoreCoverOutcomes == null) { + appArtworkWriter.noteExternalArtworkChange() + } + previousAlbumArts.forEach { previousArt -> purgeAlbumArtThemes(previousArt, null) } @@ -824,7 +1455,11 @@ class MetadataEditStateHolder @Inject constructor( cb.sendToast(message) } - private suspend fun performBatchEditGenre(songs: List, newGenre: String, cb: MetadataEditCallbacks) { + private suspend fun performBatchEditGenre( + songs: List, + newGenre: String, + cb: MetadataEditCallbacks + ) = withBatchEditInProgress { Log.d("PlayerViewModel", "Starting batch genre update for ${songs.size} songs to '$newGenre'") cb.sendToast(context.getString(R.string.metadata_edit_updating_n_songs, songs.size)) @@ -851,7 +1486,8 @@ class MetadataEditStateHolder @Inject constructor( newLyrics = sourceSong.lyrics ?: "", newTrackNumber = sourceSong.trackNumber, newDiscNumber = sourceSong.discNumber, - coverArtUpdate = null + coverArtUpdate = null, + cb = cb ) if (result.success && result.updatedSong != null) { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/OnlineCoverArtViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/OnlineCoverArtViewModel.kt new file mode 100644 index 0000000000..46e7bb7715 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/OnlineCoverArtViewModel.kt @@ -0,0 +1,319 @@ +package com.theveloper.pixelplay.presentation.viewmodel + +import android.net.Uri +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.theveloper.pixelplay.R +import com.theveloper.pixelplay.data.coverart.AlbumArtStorage +import com.theveloper.pixelplay.data.coverart.CoverArtCandidate +import com.theveloper.pixelplay.data.coverart.CoverArtProviderStatus +import com.theveloper.pixelplay.data.coverart.CoverArtSize +import com.theveloper.pixelplay.data.preferences.UserPreferencesRepository +import com.theveloper.pixelplay.data.repository.CoverArtSearchRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class OnlineCoverArtUiState( + val album: String = "", + val artist: String = "", + val isSearching: Boolean = false, + val hasSearched: Boolean = false, + val candidates: List = emptyList(), + /** Per catalog progress, so the user sees which ones are still running. */ + val providerStatuses: List = emptyList(), + @StringRes val errorRes: Int? = null, + val downloadingCandidateId: String? = null, + /** Candidates a size is still being read for, so the rest can say so. */ + val measuringCandidateIds: Set = emptySet(), + /** Set once a picked cover is cached; the UI consumes it and clears it. */ + val downloadedUri: Uri? = null, + /** Album and artist the state was started for, so a new song starts clean. */ + val startedFor: Pair? = null, + val webSearchConfigured: Boolean = false, + val isSearchingWeb: Boolean = false, + /** Web results, kept apart so a re-run of the catalogs cannot drop them. */ + val webCandidates: List = emptyList(), + /** Set once the web has been searched for this query, match or not. */ + val webSearched: Boolean = false +) { + /** Catalog matches first, then anything the web turned up. */ + val allCandidates: List get() = candidates + webCandidates +} + +/** + * Drives the online cover art picker: searching catalogs and caching the image + * the user picks so the existing cropper can open it. + */ +@HiltViewModel +class OnlineCoverArtViewModel @Inject constructor( + private val coverArtSearchRepository: CoverArtSearchRepository, + userPreferencesRepository: UserPreferencesRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(OnlineCoverArtUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + /** + * Where a cover picked here will end up, so the screen doing the applying + * says so rather than leaving it to a setting the user is not looking at. + * + * Deliberately not part of [OnlineCoverArtUiState], which is replaced + * wholesale each time the picker opens for a different album: a setting + * folded into it was reset there and never corrected, since a preference + * only re-emits when it changes. + * + * Null until the store has answered -- a default would be a statement about + * whether the user's files are about to be written to. + */ + val albumArtStorage: StateFlow = userPreferencesRepository.albumArtStorageFlow + .stateIn(viewModelScope, SharingStarted.Eagerly, null) + + private var searchJob: Job? = null + private var webSearchJob: Job? = null + private var downloadJob: Job? = null + + /** Probes run once per candidate, across every snapshot of a search. */ + private val probedCandidateIds = mutableSetOf() + private val probePermits = Semaphore(PROBE_CONCURRENCY) + + /** + * Prefills the query from the song being edited and searches straight away. + * + * The view model outlives the sheet, so state is reset whenever the picker + * opens for a different song and kept when it reopens for the same one. + */ + fun start(album: String, artist: String) { + val key = album to artist + val isNewQuery = _uiState.value.startedFor != key + + if (isNewQuery) { + searchJob?.cancel() + webSearchJob?.cancel() + downloadJob?.cancel() + probedCandidateIds.clear() + _uiState.value = OnlineCoverArtUiState(album = album, artist = artist, startedFor = key) + } else { + // The view model outlives the sheet, so a download that finished + // after it was dismissed would still be sitting in state and would + // reopen the cropper on the previous image. Drop it. + downloadJob?.cancel() + _uiState.update { it.copy(downloadedUri = null, downloadingCandidateId = null) } + } + + // Re-read on every open: the key is entered in Settings and this view + // model outlives the sheet, so an answer taken once would keep the + // action hidden for a user who went and configured one. + viewModelScope.launch { + val configured = coverArtSearchRepository.isWebImageSearchAvailable() + _uiState.update { it.copy(webSearchConfigured = configured) } + } + + if (isNewQuery && (album.isNotBlank() || artist.isNotBlank())) { + search() + } + } + + fun onAlbumChange(album: String) { + _uiState.update { it.copy(album = album) } + } + + fun onArtistChange(artist: String) { + _uiState.update { it.copy(artist = artist) } + } + + fun search() { + val album = _uiState.value.album.trim() + val artist = _uiState.value.artist.trim() + if (album.isEmpty() && artist.isEmpty()) return + + searchJob?.cancel() + webSearchJob?.cancel() + probedCandidateIds.clear() + searchJob = viewModelScope.launch { + _uiState.update { + it.copy( + isSearching = true, + errorRes = null, + candidates = emptyList(), + providerStatuses = emptyList(), + // Probes in flight go down with the job being replaced, so + // a candidate mid-probe would otherwise read as measuring + // for as long as the picker stays open. + measuringCandidateIds = emptySet(), + // A new query invalidates the previous web results, and the + // user has to ask for the new ones: each request is metered. + webCandidates = emptyList(), + webSearched = false, + isSearchingWeb = false + ) + } + + coverArtSearchRepository.searchStreaming(album = album, artist = artist) + .collect { snapshot -> + _uiState.update { current -> + current.copy( + // A catalog answering later must not wipe the sizes + // already measured for results that are on screen. + candidates = snapshot.candidates.withKnownSizes(current.candidates), + providerStatuses = snapshot.statuses, + isSearching = !snapshot.isComplete, + hasSearched = snapshot.isComplete, + errorRes = when { + snapshot.failure != null -> R.string.cover_art_search_error + else -> null + } + ) + } + probeSizes(snapshot.candidates) + } + } + } + + /** + * Searches the web for this album, on the user's explicit request. + * + * Image engines meter by request against a monthly allowance, so this is + * never run for them: it is the last resort for an album no catalog carries, + * and its results land below the catalog matches they failed to provide. + */ + fun searchWeb() { + val state = _uiState.value + if (state.isSearchingWeb) return + val album = state.album.trim() + val artist = state.artist.trim() + if (album.isEmpty() && artist.isEmpty()) return + + webSearchJob?.cancel() + webSearchJob = viewModelScope.launch { + _uiState.update { it.copy(isSearchingWeb = true, errorRes = null) } + + val result = coverArtSearchRepository.searchWebImages(album = album, artist = artist) + _uiState.update { current -> + result.fold( + onSuccess = { found -> + current.copy( + isSearchingWeb = false, + webSearched = true, + webCandidates = found.withKnownSizes(current.webCandidates) + ) + }, + onFailure = { + // webSearched is what hides the action, so a failed + // search leaves it alone: otherwise a dropped connection + // spends the user's one offer of it. + current.copy( + isSearchingWeb = false, + errorRes = R.string.cover_art_search_error + ) + } + ) + } + probeSizes(result.getOrDefault(emptyList())) + } + } + + private fun List.withSize( + candidateId: String, + size: CoverArtSize + ): List = + map { existing -> if (existing.id == candidateId) existing.copy(size = size) else existing } + + private fun List.withKnownSizes( + previous: List + ): List { + if (previous.isEmpty()) return this + val measured = previous.mapNotNull { candidate -> + candidate.size?.takeIf { it.measured }?.let { candidate.id to it } + }.toMap() + if (measured.isEmpty()) return this + return map { candidate -> measured[candidate.id]?.let { candidate.copy(size = it) } ?: candidate } + } + + /** + * Measures the real resolution and weight of the first results. + * + * Catalogs report neither, and each probe reads only a small prefix of the + * image, folded into the grid as it arrives. + * + * Runs as a child of the search that produced the candidates, so a replaced + * search takes its probes with it -- and per snapshot, since one job would + * leave earlier batches measuring results nobody is looking at. + */ + private fun CoroutineScope.probeSizes(candidates: List) { + val unmeasured = candidates + .take(PROBE_LIMIT) + .filter { candidate -> probedCandidateIds.add(candidate.id) } + if (unmeasured.isEmpty()) return + + // Only the first PROBE_LIMIT are measured and some catalogs never state + // a size, so the rest can say "unknown" rather than promise a number. + val pending = unmeasured.mapTo(mutableSetOf()) { it.id } + _uiState.update { it.copy(measuringCandidateIds = it.measuringCandidateIds + pending) } + + launch { + unmeasured.map { candidate -> + async { + val size = probePermits.withPermit { + coverArtSearchRepository.probeSize(candidate) + } + + _uiState.update { current -> + current.copy( + candidates = size?.let { current.candidates.withSize(candidate.id, it) } + ?: current.candidates, + webCandidates = size?.let { current.webCandidates.withSize(candidate.id, it) } + ?: current.webCandidates, + measuringCandidateIds = current.measuringCandidateIds - candidate.id + ) + } + } + }.awaitAll() + } + } + + fun onCandidateSelected(candidate: CoverArtCandidate) { + if (_uiState.value.downloadingCandidateId != null) return + + downloadJob = viewModelScope.launch { + _uiState.update { it.copy(downloadingCandidateId = candidate.id, errorRes = null) } + val result = coverArtSearchRepository.downloadCandidate(candidate) + _uiState.update { current -> + result.fold( + onSuccess = { uri -> + current.copy(downloadingCandidateId = null, downloadedUri = uri) + }, + onFailure = { + current.copy( + downloadingCandidateId = null, + errorRes = R.string.cover_art_search_download_error + ) + } + ) + } + } + } + + fun onDownloadedUriHandled() { + _uiState.update { it.copy(downloadedUri = null) } + } + + private companion object { + /** Results measured per search; the rest keep their nominal size. */ + const val PROBE_LIMIT = 12 + const val PROBE_CONCURRENCY = 4 + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt index 4ddadbedea..f2c9056bec 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt @@ -2850,6 +2850,15 @@ class PlayerViewModel @Inject constructor( metadataEditCallbacks() ) + fun removeAppliedCoverArt(songs: List) = + metadataEditStateHolder.removeAppliedCoverArt(songs, metadataEditCallbacks()) + + /** See [com.theveloper.pixelplay.data.coverart.AppArtworkWriter.appliedArtworkRevision]. */ + val appliedCoverArtRevision = metadataEditStateHolder.appliedArtworkRevision + + /** See [MetadataEditStateHolder.batchEditInProgress]. */ + val batchEditInProgress = metadataEditStateHolder.batchEditInProgress + fun editSongMetadata( song: Song, newTitle: String, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt index abba7eaceb..9bd59410cb 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt @@ -4,6 +4,7 @@ import android.content.Context import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.theveloper.pixelplay.data.coverart.AlbumArtStorage import com.theveloper.pixelplay.data.backup.BackupManager import com.theveloper.pixelplay.data.backup.model.BackupSection import com.theveloper.pixelplay.data.backup.model.BackupOperationType @@ -30,6 +31,7 @@ import com.theveloper.pixelplay.data.preferences.ThemePreferencesRepository import com.theveloper.pixelplay.data.repository.LyricsRepository import com.theveloper.pixelplay.data.repository.MusicRepository import com.theveloper.pixelplay.data.model.LyricsSourcePreference +import com.theveloper.pixelplay.data.worker.AutoCoverArtWorker import com.theveloper.pixelplay.data.worker.SyncManager import com.theveloper.pixelplay.data.worker.SyncProgress import dagger.hilt.android.lifecycle.HiltViewModel @@ -1336,6 +1338,63 @@ class SettingsViewModel @Inject constructor( val useSmoothCorners: StateFlow = userPreferencesRepository.useSmoothCornersFlow .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true) + val albumArtStorage: StateFlow = + userPreferencesRepository.albumArtStorageFlow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AlbumArtStorage.APP_ONLY) + + fun setAlbumArtStorage(storage: AlbumArtStorage) { + viewModelScope.launch { + userPreferencesRepository.setAlbumArtStorage(storage) + } + } + + val autoAlbumArtEnabled: StateFlow = userPreferencesRepository.autoAlbumArtEnabledFlow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + + val autoAlbumArtUnmeteredOnly: StateFlow = + userPreferencesRepository.autoAlbumArtUnmeteredOnlyFlow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true) + + fun setAutoAlbumArtEnabled(enabled: Boolean) { + viewModelScope.launch { + userPreferencesRepository.setAutoAlbumArtEnabled(enabled) + // Turning it on runs a pass now rather than waiting for the next sync. + if (enabled) { + AutoCoverArtWorker.enqueue( + context = context, + unmeteredOnly = userPreferencesRepository.autoAlbumArtUnmeteredOnlyFlow.first() + ) + } + } + } + + fun setAutoAlbumArtUnmeteredOnly(unmeteredOnly: Boolean) { + viewModelScope.launch { + userPreferencesRepository.setAutoAlbumArtUnmeteredOnly(unmeteredOnly) + } + } + + val webImageSearchApiKey: StateFlow = userPreferencesRepository.webImageSearchApiKeyFlow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") + + fun setWebImageSearchApiKey(apiKey: String) { + viewModelScope.launch { + userPreferencesRepository.setWebImageSearchApiKey(apiKey) + } + } + + /** Forgets the albums no catalog matched, so they are tried again. */ + fun retryMissingAlbumArt() { + viewModelScope.launch { + userPreferencesRepository.clearAlbumArtNotFoundIds() + AutoCoverArtWorker.enqueue( + context = context, + unmeteredOnly = userPreferencesRepository.autoAlbumArtUnmeteredOnlyFlow.first(), + replaceRunning = true + ) + } + } + val tapBackgroundClosesPlayer: StateFlow = userPreferencesRepository.tapBackgroundClosesPlayerFlow .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true) diff --git a/app/src/main/java/com/theveloper/pixelplay/utils/AlbumArtCacheManager.kt b/app/src/main/java/com/theveloper/pixelplay/utils/AlbumArtCacheManager.kt index 7d6c8bb87e..6af2cc0320 100644 --- a/app/src/main/java/com/theveloper/pixelplay/utils/AlbumArtCacheManager.kt +++ b/app/src/main/java/com/theveloper/pixelplay/utils/AlbumArtCacheManager.kt @@ -30,6 +30,12 @@ object AlbumArtCacheManager { * Prefix for album art cache files */ private const val CACHE_PREFIX = "song_art_" + + // The applied store holds one cover per album plus a pointer per song. Both + // are artwork this app is keeping on the user's behalf, so both have to be + // visible here -- but only the covers carry any size worth reporting. + private const val APPLIED_COVER_PREFIX = "cover_" + private const val APPLIED_POINTER_EXTENSION = ".ref" /** * Suffix for "no art" marker files @@ -60,6 +66,26 @@ object AlbumArtCacheManager { */ private const val MIN_CLEANUP_INTERVAL_MS = 5 * 60 * 1000L + /** + * How long an applied cover's song has to stay out of the library before + * the cover is given up on. + * + * Long enough to outlast an unmounted card or a library being reorganised, + * because the other side of it is permanent loss of the only copy. Waiting + * costs a few kilobytes per genuinely deleted album. + */ + internal const val APPLIED_ORPHAN_GRACE_MS = 30L * 24 * 60 * 60 * 1000 + + /** + * Where the sweep records applied covers that looked orphaned, so a later + * one can tell a song that has been gone for weeks from a song that is + * missing from this sync alone. + * + * Named outside the artwork prefixes on purpose: it is not artwork, and + * nothing scanning this directory for covers or pointers should see it. + */ + private const val ORPHAN_CANDIDATES_FILE_NAME = "orphan_candidates" + private data class CacheEvictionCandidate( val file: File, val lastModifiedSnapshot: Long, @@ -137,41 +163,133 @@ object AlbumArtCacheManager { /** * Cleans orphaned cache files for songs that no longer exist. * Should be called after sync operations. - * + * + * Extracted artwork goes as soon as its song is out of the library; an + * applied cover has to look orphaned for [APPLIED_ORPHAN_GRACE_MS] of + * elapsed time before it does. See [sweepAppliedPointers]. + * * @param context Application context * @param validSongIds Set of song IDs that still exist in the library + * @param now Exposed so tests do not have to wait out the grace period. * @return Number of orphaned files deleted */ suspend fun cleanOrphanedCacheFiles( context: Context, - validSongIds: Set + validSongIds: Set, + now: Long = System.currentTimeMillis() ): Int = withContext(Dispatchers.IO) { + // An empty set makes every file here look orphaned, and now costs the + // applied covers rather than a rebuildable cache. A scan without media + // permission or before a card mounted produces one, so it is refused. + if (validSongIds.isEmpty()) { + Log.w(TAG, "Skipping orphan sweep: no valid song ids were supplied") + return@withContext 0 + } + cleanupMutex.withLock { - val cacheDir = AlbumArtUtils.getAlbumArtDir(context) - val allArtFiles = getAllAlbumArtRelatedFiles(cacheDir) - - if (allArtFiles.isEmpty()) { - return@withLock 0 - } - var deletedCount = 0 - - for (file in allArtFiles) { + + // Extracted artwork, which is re-read from the audio file the next + // time anything asks for it, so a song that turns out to still be + // there costs one extraction. + for (file in getAllAlbumArtRelatedFiles(AlbumArtUtils.getAlbumArtDir(context))) { val songId = extractSongIdFromFilename(file.name) - if (songId != null && songId !in validSongIds) { - if (file.delete()) { - deletedCount++ - } + if (songId != null && songId !in validSongIds && file.delete()) { + deletedCount++ } } - + + deletedCount += sweepAppliedPointers( + appliedDir = AlbumArtUtils.getAppliedArtDir(context), + validSongIds = validSongIds, + now = now + ) + + // Songs that left the library take their pointers with them above, + // which is what can stand a cover down to nothing pointing at it. + AlbumArtUtils.deleteUnreferencedAppliedCovers(context) + if (deletedCount > 0) { Log.d(TAG, "Cleaned $deletedCount orphaned album art files") } - + deletedCount } } + + /** + * Drops the pointers of applied covers whose songs have been gone from the + * library for [APPLIED_ORPHAN_GRACE_MS], and notes the rest as candidates. + * + * Absence from one sync is not evidence a song is gone: an unmounted card, + * a moved folder or a re-index all empty rows that come back. That costs a + * re-read for the extracted cache, but the cover itself here -- + * [AlbumArtUtils.deleteUnreferencedAppliedCovers] destroys the image once + * its last pointer goes, and there is no second copy. + * + * @return the number of pointers actually deleted. + */ + private fun sweepAppliedPointers( + appliedDir: File, + validSongIds: Set, + now: Long + ): Int { + val candidates = readOrphanCandidates(appliedDir) + val stillOrphaned = mutableMapOf() + var deletedCount = 0 + + for (file in getAllAlbumArtRelatedFiles(appliedDir)) { + val songId = extractSongIdFromFilename(file.name) ?: continue + if (songId in validSongIds) continue + + // A timestamp from the future is a clock that was wound back since + // it was written; read as it stands it would hold the pointer for + // however long that is. + val firstSeen = candidates[songId]?.coerceAtMost(now) + if (firstSeen != null && now - firstSeen >= APPLIED_ORPHAN_GRACE_MS) { + if (file.delete()) deletedCount++ + } else { + stillOrphaned[songId] = firstSeen ?: now + } + } + + writeOrphanCandidates(appliedDir, stillOrphaned) + return deletedCount + } + + /** When each still-present applied cover was first seen without its song. */ + private fun readOrphanCandidates(appliedDir: File): Map { + val file = File(appliedDir, ORPHAN_CANDIDATES_FILE_NAME) + if (!file.exists()) return emptyMap() + + return runCatching { + file.readLines().mapNotNull { line -> + val songId = line.substringBefore('=').toLongOrNull() ?: return@mapNotNull null + val firstSeen = line.substringAfter('=', "").toLongOrNull() ?: return@mapNotNull null + songId to firstSeen + }.toMap() + }.getOrElse { + // Unreadable means nothing has been observed yet, which starts every + // candidate's clock again rather than expiring anything early. + Log.w(TAG, "Could not read applied cover orphan candidates", it) + emptyMap() + } + } + + private fun writeOrphanCandidates(appliedDir: File, candidates: Map) { + val file = File(appliedDir, ORPHAN_CANDIDATES_FILE_NAME) + runCatching { + if (candidates.isEmpty()) { + file.delete() + } else { + file.writeText(candidates.entries.joinToString("\n") { "${it.key}=${it.value}" }) + } + }.onFailure { + // The sweep is still correct without this, only more cautious: every + // candidate looks new again on the next pass. + Log.w(TAG, "Could not record applied cover orphan candidates", it) + } + } /** * Gets the current cache size in bytes. @@ -180,7 +298,9 @@ object AlbumArtCacheManager { * @return Total size of album art cache in bytes */ suspend fun getCacheSizeBytes(context: Context): Long = withContext(Dispatchers.IO) { - getAlbumArtFiles(AlbumArtUtils.getAlbumArtDir(context)).sumOf { it.length() } + artworkDirectories(context) + .flatMap { getAlbumArtFiles(it) } + .sumOf { it.length() } } /** @@ -202,18 +322,35 @@ object AlbumArtCacheManager { * @return Number of cached files */ fun getCachedFileCount(context: Context): Int { - return getAlbumArtFiles(AlbumArtUtils.getAlbumArtDir(context)).size + return artworkDirectories(context).sumOf { getAlbumArtFiles(it).size } } - + /** - * Clears all album art cache files. - * + * Every directory holding artwork the app manages. + * + * Covers the user applied live apart from the extracted cache so the LRU + * cannot evict them, but they are still artwork the app is storing on the + * user's behalf: leaving them out here would under-report the size and + * leak them when their songs are gone. + */ + private fun artworkDirectories(context: Context): List = listOf( + AlbumArtUtils.getAlbumArtDir(context), + AlbumArtUtils.getAppliedArtDir(context) + ) + + /** + * Clears all album art cache files, including covers applied to songs. + * + * Unused as it stands. Note before wiring it to anything that the applied + * covers it deletes are the only copy there is: unlike the extracted cache, + * nothing can regenerate them from the audio files. + * * @param context Application context * @return Number of files deleted */ suspend fun clearAllCache(context: Context): Int = withContext(Dispatchers.IO) { cleanupMutex.withLock { - val files = getAllAlbumArtRelatedFiles(AlbumArtUtils.getAlbumArtDir(context)) + val files = artworkDirectories(context).flatMap { getAllAlbumArtRelatedFiles(it) } var deletedCount = 0 for (file in files) { @@ -233,8 +370,9 @@ object AlbumArtCacheManager { private fun getAlbumArtFiles(cacheDir: File): List { return cacheDir.listFiles { file -> file.isFile && - file.name.startsWith(CACHE_PREFIX) && - !file.name.contains(NO_ART_SUFFIX) + !file.name.endsWith(APPLIED_POINTER_EXTENSION) && + (file.name.startsWith(APPLIED_COVER_PREFIX) || + (file.name.startsWith(CACHE_PREFIX) && !file.name.contains(NO_ART_SUFFIX))) }?.toList() ?: emptyList() } @@ -268,7 +406,8 @@ object AlbumArtCacheManager { */ private fun getAllAlbumArtRelatedFiles(cacheDir: File): List { return cacheDir.listFiles { file -> - file.isFile && file.name.startsWith(CACHE_PREFIX) + file.isFile && + (file.name.startsWith(CACHE_PREFIX) || file.name.startsWith(APPLIED_COVER_PREFIX)) }?.toList() ?: emptyList() } @@ -279,7 +418,7 @@ object AlbumArtCacheManager { * @param filename The filename to parse * @return Song ID or null if parsing fails */ - private fun extractSongIdFromFilename(filename: String): Long? { + internal fun extractSongIdFromFilename(filename: String): Long? { return try { // Remove prefix "song_art_" val withoutPrefix = filename.removePrefix(CACHE_PREFIX) diff --git a/app/src/main/java/com/theveloper/pixelplay/utils/AlbumArtUtils.kt b/app/src/main/java/com/theveloper/pixelplay/utils/AlbumArtUtils.kt index b128d71a0e..c9537dec7a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/utils/AlbumArtUtils.kt +++ b/app/src/main/java/com/theveloper/pixelplay/utils/AlbumArtUtils.kt @@ -19,6 +19,7 @@ import java.io.ByteArrayOutputStream import java.io.File import java.io.FileInputStream import java.io.InputStream +import java.security.MessageDigest import java.util.concurrent.ConcurrentHashMap import kotlin.math.roundToInt @@ -37,6 +38,23 @@ object AlbumArtUtils { // background on first access (one-time migration for art cached before bounding existed). private const val OVERSIZED_CACHED_ART_BYTES = 900L * 1024 + // WebP at 80 runs roughly a third below JPEG at 90 for the same visible + // quality on cover art, and the encode is paid once per album rather than + // per track. The store is new in this release, so nothing needs converting. + private const val APPLIED_ART_WEBP_QUALITY = 80 + private const val APPLIED_COVER_PREFIX = "cover_" + private const val APPLIED_POINTER_EXTENSION = ".ref" + private const val APPLIED_STAGING_EXTENSION = ".tmp" + + // Versioned apart from the extracted cache: bumping that suffix discards a + // cache the audio files can rebuild, which is why it has been bumped three + // times. The same bump here would delete the only copy of every cover. + private const val APPLIED_ART_VERSION_SUFFIX = "_v1" + + // One writer at a time: a cover is written before the pointers that keep it + // alive, and a sweep landing in that window would delete it. + private val appliedArtLock = Any() + // P2-1: Dedicated app-level scope to replace GlobalScope. // SupervisorJob ensures child failures don't cancel sibling coroutines. // Appropriate for fire-and-forget tasks like cache cleanup that outlive any single component. @@ -112,7 +130,8 @@ object AlbumArtUtils { noArtFile.delete() } - val hasCachedArtwork = cachedFile.exists() && cachedFile.length() > 0 + val hasCachedArtwork = getAppliedAlbumArtFile(appContext, songId) != null || + (cachedFile.exists() && cachedFile.length() > 0) if (hasCachedArtwork) { cachedFile.setLastModified(System.currentTimeMillis()) } @@ -164,6 +183,10 @@ object AlbumArtUtils { filePath: String? = null, forceRefresh: Boolean = false ): File? { + // Applied art answers first and is never evicted, so a cover the user + // chose survives a cache sweep, a rescan and a re-extract. + getAppliedAlbumArtFile(appContext, songId)?.let { return it } + val cachedFile = getCachedAlbumArtFile(appContext, songId) val noArtFile = noArtMarkerFile(appContext, songId) @@ -229,6 +252,10 @@ object AlbumArtUtils { return false } + if (getAppliedAlbumArtFile(appContext, songId) != null) { + return true + } + val cachedFile = getCachedAlbumArtFile(appContext, songId) val noArtFile = noArtMarkerFile(appContext, songId) @@ -336,11 +363,35 @@ object AlbumArtUtils { ).forEach { it.delete() } } + /** + * Drops the cover the user applied to [songId], and the image behind it if + * no other song was pointing there. + * + * Deliberately not part of [clearCacheForSong], which every metadata save + * reaches and which only deletes what the audio file can rebuild. An + * applied cover is the only copy, so it goes only when meant to. + */ + fun clearAppliedArtForSong(appContext: Context, songId: Long) { + val removed = synchronized(appliedArtLock) { + appliedPointerFile(appContext, songId).delete() + } + // Reading every pointer in the store is too much to do on the caller's + // thread, and nothing waits on the image being gone. + if (removed) { + appScope.launch { deleteUnreferencedAppliedCovers(appContext) } + } + } + // Album art lives in filesDir (persistent) instead of cacheDir, because Android can // wipe cacheDir at any time under storage pressure β€” taking every cached cover with // it and leaving the UI blank. The size is bounded by AlbumArtCacheManager's LRU. private const val ALBUM_ART_DIR_NAME = "album_art" + // Apart from the extracted cache, which is safe to evict only because the + // audio files can rebuild it. An applied cover has no other copy, so this + // directory is never LRU-swept -- only swept for orphans. + private const val APPLIED_ART_DIR_NAME = "album_art_applied" + fun getAlbumArtDir(appContext: Context): File { val dir = File(appContext.filesDir, ALBUM_ART_DIR_NAME) if (!dir.exists()) { @@ -353,6 +404,166 @@ object AlbumArtUtils { return File(getAlbumArtDir(appContext), "song_art_${songId}${CACHE_VERSION_SUFFIX}.jpg") } + fun getAppliedArtDir(appContext: Context): File { + val dir = File(appContext.filesDir, APPLIED_ART_DIR_NAME) + if (!dir.exists()) { + dir.mkdirs() + } + return dir + } + + /** + * The cover [songId] was given, or null when it has none. + * + * Artwork is addressed by song id everywhere -- the library scan, the shared + * content provider, the widget and the Coil fetcher all resolve it that way + * -- so every track needs its own answer. The answer is a pointer rather + * than a copy: an album's tracks all name the same file, which is why + * covering a 20-track album costs one image instead of twenty. + */ + fun getAppliedAlbumArtFile(appContext: Context, songId: Long): File? { + val pointer = appliedPointerFile(appContext, songId) + if (!pointer.exists()) return null + + val coverKey = runCatching { pointer.readText().trim() } + .getOrNull() + ?.takeIf { it.isNotEmpty() } + ?: return null + + return appliedCoverFile(appContext, coverKey).takeIf { it.exists() && it.length() > 0 } + } + + /** + * Names the pointer after the song the way the extracted cache names its + * files, so the orphan sweep reads a song id out of it without knowing this + * directory holds anything unusual. + */ + private fun appliedPointerFile(appContext: Context, songId: Long): File { + return File( + getAppliedArtDir(appContext), + "song_art_${songId}${APPLIED_ART_VERSION_SUFFIX}$APPLIED_POINTER_EXTENSION" + ) + } + + internal fun appliedCoverFile(appContext: Context, coverKey: String): File { + return File( + getAppliedArtDir(appContext), + "$APPLIED_COVER_PREFIX$coverKey$APPLIED_ART_VERSION_SUFFIX.webp" + ) + } + + /** + * Stores a cover the user applied to [songIds], as the only copy there is. + * + * Kept out of the extracted cache so nothing sweeps it, and it wins over + * both the cache and the file's own tag as the most recent thing the user + * chose. + * + * The image is written once and each song pointed at it, named for a digest + * of its own bytes -- so the same cover applied by any path lands on one + * file, and a different cover for one track leaves its album-mates alone. + * Covers nothing points at are deleted here. + * + * @return the cover every song in [songIds] now resolves to, or null when + * [songIds] is empty. + */ + fun saveAppliedAlbumArt( + appContext: Context, + bytes: ByteArray, + songIds: List + ): File? { + val ids = songIds.distinct() + if (ids.isEmpty() || bytes.isEmpty()) return null + + val coverKey = digestOf(bytes) + + return synchronized(appliedArtLock) { + val cover = appliedCoverFile(appContext, coverKey) + + // Staged and moved into place, so a reader never opens a cover that + // is half written and an interrupted write leaves nothing behind + // that a later sweep has to reason about. + val staging = File(cover.parentFile, cover.name + APPLIED_STAGING_EXTENSION) + try { + staging.outputStream().use { it.write(bytes) } + if (!staging.renameTo(cover)) { + staging.copyTo(cover, overwrite = true) + } + } finally { + staging.delete() + } + + ids.forEach { songId -> + appliedPointerFile(appContext, songId).writeText(coverKey) + noArtMarkerFile(appContext, songId).delete() + } + + deleteUnreferencedAppliedCovers(appContext) + cover + } + } + + /** + * Deletes applied covers no song points at. + * + * Called after a write and after a song's art is dropped, which is every + * moment a cover can stop being referenced. + */ + internal fun deleteUnreferencedAppliedCovers(appContext: Context) { + synchronized(appliedArtLock) { + val files = getAppliedArtDir(appContext).listFiles() ?: return + + val covers = files + .filter { + it.isFile && + it.name.startsWith(APPLIED_COVER_PREFIX) && + !it.name.endsWith(APPLIED_STAGING_EXTENSION) + } + .associateBy { file -> + file.name + .removePrefix(APPLIED_COVER_PREFIX) + .removeSuffix("$APPLIED_ART_VERSION_SUFFIX.webp") + } + + val referenced = mutableSetOf() + files.asSequence() + .filter { it.isFile && it.name.endsWith(APPLIED_POINTER_EXTENSION) } + .forEach { pointer -> + val key = runCatching { pointer.readText().trim() }.getOrNull() + // A pointer at a cover that is gone keeps nothing alive, and + // would silently adopt the image if that key were ever + // written again. + if (key.isNullOrEmpty() || key !in covers) { + pointer.delete() + } else { + referenced += key + } + } + + covers.forEach { (key, file) -> if (key !in referenced) file.delete() } + + // Any staging file visible from in here is stale: writes hold this + // same lock, so an in-flight one cannot be observed. + files.asSequence() + .filter { it.isFile && it.name.endsWith(APPLIED_STAGING_EXTENSION) } + .forEach { it.delete() } + } + } + + /** + * Content digest, used to keep two different covers apart on disk. + * + * Long enough that a collision is not worth reasoning about, because the + * failure would be silent: two covers sharing a name means one album quietly + * showing another album's artwork, with nothing to notice it. + */ + private fun digestOf(bytes: ByteArray): String { + return MessageDigest.getInstance("SHA-256") + .digest(bytes) + .take(16) + .joinToString("") { "%02x".format(it) } + } + /** * Moves any legacy album-art files from cacheDir (old location, wipeable by the OS) * into filesDir/album_art/. Idempotent β€” safe to call on every startup. Runs quickly @@ -426,6 +637,41 @@ object AlbumArtUtils { } } + /** + * Bounds and re-encodes a cover for the applied store, once per album. + * + * Unlike [boundArtworkForCache] this always re-encodes rather than passing + * small sources through: the store keeps one image for a whole album and + * holds it until the user replaces it, so the smaller format is worth the + * decode every time. Falls back to the original bytes if anything fails, + * because a slightly larger cover beats no cover. + */ + fun boundArtworkForStorage(bytes: ByteArray): ByteArray { + return try { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + val srcWidth = bounds.outWidth + val srcHeight = bounds.outHeight + if (srcWidth <= 0 || srcHeight <= 0) return bytes + + val decodeOptions = BitmapFactory.Options().apply { + inSampleSize = calculateArtworkInSampleSize(srcWidth, srcHeight, MAX_CACHED_ART_DIMENSION_PX) + } + val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, decodeOptions) ?: return bytes + val scaled = scaleArtworkDownTo(decoded, MAX_CACHED_ART_DIMENSION_PX) + val encoded = ByteArrayOutputStream().use { stream -> + scaled.compress(Bitmap.CompressFormat.WEBP_LOSSY, APPLIED_ART_WEBP_QUALITY, stream) + stream.toByteArray() + } + if (scaled !== decoded) decoded.recycle() + scaled.recycle() + if (encoded.isNotEmpty()) encoded else bytes + } catch (e: Throwable) { + Timber.tag("AlbumArtUtils").w(e, "Failed to bound artwork for the applied store; keeping original bytes") + bytes + } + } + /** * Returns artwork bytes bounded for the display cache: longest edge at most * [MAX_CACHED_ART_DIMENSION_PX], re-encoded as JPEG when the source is oversized by diff --git a/app/src/main/res/values/strings_library.xml b/app/src/main/res/values/strings_library.xml index 8b7c6c2c60..2fc4cb4688 100644 --- a/app/src/main/res/values/strings_library.xml +++ b/app/src/main/res/values/strings_library.xml @@ -318,6 +318,45 @@ Unable to load the selected image Search lyrics on lrclib.net + + Change album cover art + Search online + Choose from gallery + + + Find cover art + Album + Artist + Cover removed + Remove cover + + Delete cover from files + Delete cover from files? + The artwork stored inside this album\'s %1$d tracks will be removed from the audio files. This can\'t be undone. + Edit search terms + Hide search terms + Enter an album or an artist to search for cover art. + No cover art found. Try a different album or artist. + Couldn\'t reach the cover art catalog. Check your connection and try again. + Couldn\'t download that cover. Try another one. + Cover art for %1$s by %2$s + %1$s, searching + %1$s, did not answer + %1$s, %2$d covers + %1$d Γ— %2$d + ~%1$d Γ— %2$d + Measuring… + Size unknown + The cover you pick is applied to all %1$d tracks in this album. + + It will be kept in PixelPlayer, and your audio files will not be changed. + It will be written into the audio files. + Cover art can only be applied to songs stored on this device. + Couldn\'t save that cover. Try again. + Cover saved in PixelPlayer. Your files were not changed. + Search the web + Edit %d Songs Only modified fields will be updated. Leave fields empty to keep existing values. diff --git a/app/src/main/res/values/strings_settings.xml b/app/src/main/res/values/strings_settings.xml index 1c6ae4e284..ef1d59f4cd 100644 --- a/app/src/main/res/values/strings_settings.xml +++ b/app/src/main/res/values/strings_settings.xml @@ -655,4 +655,27 @@ Volume Pause when volume reaches zero Automatically pause playback when the volume is set to 0 + + Find missing album art + After a library scan, look up covers online for albums that have none. Covers come from the Deezer, iTunes and Cover Art Archive catalogs, only the album and artist are sent, and your music files are never modified. + + After a library scan, look up covers online for albums that have none. Covers come from the Deezer, iTunes and Cover Art Archive catalogs, and only the album and artist are sent. These covers are kept in PixelPlayer even though you chose to apply covers into the audio files, because writing to your files needs your permission each time. + Wi-Fi only + Only look up covers on an unmetered connection + Try albums with no match again + Albums no catalog matched are skipped on later scans. This forgets that list and searches again. + + Where covers you apply manually are kept + Covers found automatically are always kept in PixelPlayer, because writing to your files needs your permission each time. + In the audio files + In PixelPlayer only + + Web image search + Off. Add a Serper key to search the web for covers no catalog carries. + Using %1$s + A web search has no album or artist data to rank against your tags, so it is never run on its own: it waits behind a button in the cover art picker, for the albums no catalog carries. Automatic cover art never uses it. Each search uses your own key and counts against its monthly allowance, so it is spent only on the album you ask it for. + API key + Create a key at %1$s. It is stored on this device and sent only to Serper. + Turn off diff --git a/app/src/test/java/com/theveloper/pixelplay/data/coverart/AppArtworkWriterTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/coverart/AppArtworkWriterTest.kt new file mode 100644 index 0000000000..44bb616ccf --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/coverart/AppArtworkWriterTest.kt @@ -0,0 +1,223 @@ +package com.theveloper.pixelplay.data.coverart + +import android.content.Context +import com.theveloper.pixelplay.data.database.AlbumArtThemeDao +import com.theveloper.pixelplay.data.database.MusicDao +import com.theveloper.pixelplay.data.database.SongEntity +import com.theveloper.pixelplay.data.media.ImageCacheManager +import com.theveloper.pixelplay.utils.AlbumArtUtils +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkObject +import io.mockk.verify +import java.io.File +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class AppArtworkWriterTest { + + private val context = mockk(relaxed = true) + private val musicDao = mockk(relaxed = true) + private val albumArtThemeDao = mockk(relaxed = true) + private val imageCacheManager = mockk(relaxed = true) + + @BeforeEach + fun setUp() { + mockkObject(AlbumArtUtils) + every { AlbumArtUtils.boundArtworkForStorage(any()) } answers { firstArg() } + every { AlbumArtUtils.saveAppliedAlbumArt(any(), any(), any()) } returns mockk(relaxed = true) + } + + @AfterEach + fun tearDown() = unmockkObject(AlbumArtUtils) + + @Test + fun `an applied cover goes to the store the eviction sweep never touches`() = runTest { + writer().apply(bytes = byteArrayOf(1, 2, 3), songIds = listOf(11L, 12L), albumId = 5L) + + // The extracted cache is re-derivable and gets swept once it grows past + // its limit. An applied cover has no second copy to re-derive from. + verify { AlbumArtUtils.saveAppliedAlbumArt(any(), any(), listOf(11L, 12L)) } + verify(exactly = 0) { AlbumArtUtils.saveAlbumArtToCache(any(), any(), any()) } + } + + @Test + fun `a cloud track is never pointed at an applied cover`() = runTest { + // Cloud tracks have a negative id and no local store, so writing their + // row would trade a working remote cover for one pointing at nothing. + // Both callers filter; the invariant belongs here all the same. + val stored = writer().apply(bytes = byteArrayOf(1, 2, 3), songIds = listOf(-9_000_000_000_001L)) + + assertEquals(false, stored) + verify(exactly = 0) { AlbumArtUtils.saveAppliedAlbumArt(any(), any(), any()) } + coVerify(exactly = 0) { musicDao.updateSongAlbumArt(any(), any()) } + } + + @Test + fun `a mixed album only claims the tracks it can actually write`() = runTest { + val stored = writer().apply( + bytes = byteArrayOf(1, 2, 3), + songIds = listOf(11L, -9_000_000_000_001L), + albumId = 5L + ) + + assertEquals(true, stored) + verify { AlbumArtUtils.saveAppliedAlbumArt(any(), any(), listOf(11L)) } + coVerify(exactly = 0) { musicDao.updateSongAlbumArt(-9_000_000_000_001L, any()) } + } + + @Test + fun `the album is stored in one pass rather than once per track`() = runTest { + writer().apply(bytes = byteArrayOf(1, 2, 3), songIds = (1L..10L).toList()) + + // The store keeps a single copy of the cover for the whole album, which + // it can only do when it is handed the album rather than a track. + verify(exactly = 1) { AlbumArtUtils.saveAppliedAlbumArt(any(), any(), any()) } + } + + @Test + fun `the image is decoded and re-encoded once for the whole album`() = runTest { + writer().apply(bytes = byteArrayOf(1, 2, 3), songIds = (1L..10L).toList()) + + // Bounding is a full bitmap decode, scale and WebP encode, and every + // track of an album is being given the same image. + verify(exactly = 1) { AlbumArtUtils.boundArtworkForStorage(any()) } + } + + @Test + fun `rows are pointed at the cover and stale palettes dropped`() = runTest { + val purged = slot>() + albumHolds(5L, 11L, 12L) + + writer().apply(bytes = byteArrayOf(1), songIds = listOf(11L, 12L), albumId = 5L) + + coVerify { musicDao.updateSongAlbumArt(11L, "pixelplay_local_art://song/11") } + coVerify { musicDao.updateSongAlbumArt(12L, "pixelplay_local_art://song/12") } + coVerify { musicDao.updateAlbumArt(5L, "pixelplay_local_art://song/11") } + // The artwork URI does not change when a cover is replaced, so a palette + // keyed by it would otherwise survive as the previous cover's colours. + coVerify { albumArtThemeDao.deleteThemesByUris(capture(purged)) } + assertEquals( + listOf("pixelplay_local_art://song/11", "pixelplay_local_art://song/12"), + purged.captured + ) + } + + @Test + fun `the rendered caches are dropped without deleting the cover itself`() = runTest { + writer().apply(bytes = byteArrayOf(1), songIds = listOf(11L)) + + verify { imageCacheManager.invalidateRenderedCoverArt("pixelplay_local_art://song/11") } + verify(exactly = 0) { imageCacheManager.invalidateCoverArtCaches(any()) } + } + + @Test + fun `the album row follows only when every one of its tracks is covered`() = runTest { + albumHolds(5L, 11L, 12L, 13L) + + writer().apply(bytes = byteArrayOf(1), songIds = listOf(11L, 12L), albumId = 5L) + + // Two tracks of three is not the album getting a new cover, and claiming + // the row would change the album everywhere it is shown. + coVerify(exactly = 0) { musicDao.updateAlbumArt(any(), any()) } + coVerify { musicDao.updateSongAlbumArt(11L, "pixelplay_local_art://song/11") } + } + + @Test + fun `a cloud track in the album does not hold the album row back`() = runTest { + albumHolds(5L, 11L, 12L, -9_000_000_000_001L) + + writer().apply(bytes = byteArrayOf(1), songIds = listOf(11L, 12L), albumId = 5L) + + // The cloud track is one this writer will never give a cover to, so + // counting it would leave the album permanently short of its own track + // list -- the row keeping its placeholder while every local track of + // the album draws the new cover. + coVerify { musicDao.updateAlbumArt(5L, "pixelplay_local_art://song/11") } + } + + @Test + fun `removing a cover leaves cloud tracks and their album row alone`() = runTest { + every { AlbumArtUtils.clearAppliedArtForSong(any(), any()) } returns Unit + every { AlbumArtUtils.ensureAlbumArtCachedFile(any(), any(), any(), any()) } returns null + albumHolds(5L, 11L, 12L) + + writer().removeApplied(songIds = listOf(-9_000_000_000_001L), albumId = 5L) + + // A cloud track never had an applied cover to take back, and letting it + // answer for the album would null out a row covering tracks this call + // never touched. + verify(exactly = 0) { AlbumArtUtils.clearAppliedArtForSong(any(), any()) } + coVerify(exactly = 0) { musicDao.updateSongAlbumArt(any(), any()) } + coVerify(exactly = 0) { musicDao.updateAlbumArt(any(), any()) } + } + + @Test + fun `nothing is written for an empty selection`() = runTest { + writer().apply(bytes = byteArrayOf(1), songIds = emptyList(), albumId = 5L) + + verify(exactly = 0) { AlbumArtUtils.saveAppliedAlbumArt(any(), any(), any()) } + coVerify(exactly = 0) { musicDao.updateAlbumArt(any(), any()) } + } + + @Test + fun `removing an applied cover leaves the audio files alone`() = runTest { + every { AlbumArtUtils.clearAppliedArtForSong(any(), any()) } returns Unit + every { AlbumArtUtils.ensureAlbumArtCachedFile(any(), any(), any(), any()) } returns null + albumHolds(5L, 11L, 12L) + + writer().removeApplied(songIds = listOf(11L, 12L), albumId = 5L) + + verify { AlbumArtUtils.clearAppliedArtForSong(any(), 11L) } + verify { AlbumArtUtils.clearAppliedArtForSong(any(), 12L) } + // Undoing an apply must not reach the tag editor: the cover never went + // into the file, so nothing there has to change to take it back. + verify(exactly = 0) { AlbumArtUtils.saveAppliedAlbumArt(any(), any(), any()) } + } + + @Test + fun `a song with nothing left underneath is pointed at no artwork`() = runTest { + every { AlbumArtUtils.clearAppliedArtForSong(any(), any()) } returns Unit + every { AlbumArtUtils.ensureAlbumArtCachedFile(any(), any(), any(), any()) } returns null + albumHolds(5L, 11L) + + writer().removeApplied(songIds = listOf(11L), albumId = 5L) + + // Left pointing at the local artwork scheme it would resolve to a blank + // rather than to the placeholder the rest of the app draws. + coVerify { musicDao.updateSongAlbumArt(11L, null) } + coVerify { musicDao.updateAlbumArt(5L, null) } + } + + @Test + fun `art still in the file comes back into view`() = runTest { + every { AlbumArtUtils.clearAppliedArtForSong(any(), any()) } returns Unit + every { AlbumArtUtils.ensureAlbumArtCachedFile(any(), any(), any(), any()) } returns + mockk(relaxed = true) + albumHolds(5L, 11L) + + writer().removeApplied(songIds = listOf(11L), albumId = 5L) + + coVerify { musicDao.updateSongAlbumArt(11L, "pixelplay_local_art://song/11") } + } + + private fun albumHolds(albumId: Long, vararg songIds: Long) { + coEvery { musicDao.getSongsByAlbumIdOnce(albumId) } returns songIds.map { songId -> + mockk(relaxed = true).also { every { it.id } returns songId } + } + } + + private fun writer() = AppArtworkWriter( + context = context, + musicDao = musicDao, + albumArtThemeDao = albumArtThemeDao, + imageCacheManager = imageCacheManager + ) +} diff --git a/app/src/test/java/com/theveloper/pixelplay/data/coverart/AutoCoverArtFetcherTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/coverart/AutoCoverArtFetcherTest.kt new file mode 100644 index 0000000000..96bb2c4708 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/coverart/AutoCoverArtFetcherTest.kt @@ -0,0 +1,568 @@ +package com.theveloper.pixelplay.data.coverart + +import android.content.Context +import android.net.Uri +import com.theveloper.pixelplay.data.database.AlbumEntity +import com.theveloper.pixelplay.data.database.MusicDao +import com.theveloper.pixelplay.data.database.SongEntity +import com.theveloper.pixelplay.data.preferences.UserPreferencesRepository +import com.theveloper.pixelplay.data.repository.CoverArtSearchRepository +import com.theveloper.pixelplay.utils.AlbumArtUtils +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import java.io.File +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class AutoCoverArtFetcherTest { + + private val context = mockk(relaxed = true) + private val musicDao = mockk(relaxed = true) + private val searchRepository = mockk(relaxed = true) + private val appArtworkWriter = mockk(relaxed = true) + private val preferences = mockk(relaxed = true) + + @BeforeEach + fun setUp() { + mockkObject(AlbumArtUtils) + every { preferences.albumArtNotFoundIdsFlow } returns flowOf(emptySet()) + every { preferences.allowedDirectoriesFlow } returns flowOf(emptySet()) + // An empty blocked set is what makes DirectoryFilterUtils skip the + // directory query entirely, which is the case every test but the + // exclusion ones below wants. + every { preferences.blockedDirectoriesFlow } returns flowOf(emptySet()) + } + + @AfterEach + fun tearDown() { + unmockkObject(AlbumArtUtils) + } + + @Test + fun `albums that already have artwork are left alone`() = runTest { + givenLibrary(album(1L, "Discovery", "Daft Punk")) + every { AlbumArtUtils.ensureAlbumArtCachedFile(any(), any(), any(), any()) } returns + mockk(relaxed = true) + + val result = fetcher().fetchMissingCovers(perAlbumDelayMs = 0L) + + assertEquals(0, result.albumsChecked) + coVerify(exactly = 0) { searchRepository.search(any(), any(), any()) } + } + + @Test + fun `a cloud album is left alone however art-less it looks from here`() = runTest { + // A cloud track's cover lives on the server, so givenNoArtwork is the + // truth as this pass can see it -- and the trap. Applying would replace + // a cover the user is looking at, and the remove action skips them. + val cloudAlbum = album(-1L, "Random Access Memories", "Daft Punk") + coEvery { musicDao.getAllAlbumsList(any(), any(), any()) } returns listOf(cloudAlbum) + coEvery { musicDao.getSongsByAlbumIdOnce(cloudAlbum.id) } returns + listOf(song(id = -9_000_000_000_001L, albumId = cloudAlbum.id)) + givenNoArtwork() + + val result = fetcher().fetchMissingCovers(perAlbumDelayMs = 0L) + + assertEquals(0, result.albumsChecked) + coVerify(exactly = 0) { searchRepository.search(any(), any(), any()) } + coVerify(exactly = 0) { appArtworkWriter.apply(any(), any(), any()) } + } + + @Test + fun `a weak match is skipped rather than applied unattended`() = runTest { + givenLibrary(album(1L, "Discovery", "Daft Punk")) + givenNoArtwork() + coEvery { searchRepository.search(any(), any(), any()) } returns CoverArtSearchOutcome( + listOf(candidate(score = AutoCoverArtFetcher.MIN_AUTO_SCORE - 0.05f)) + ) + + val result = fetcher().fetchMissingCovers(perAlbumDelayMs = 0L) + + assertEquals(1, result.albumsChecked) + assertEquals(0, result.coversApplied) + assertEquals(1, result.notFound) + coVerify(exactly = 0) { searchRepository.downloadCandidate(any()) } + } + + @Test + fun `a cover that downloads to nothing readable counts as a miss`() = runTest { + givenLibrary(album(1L, "Discovery", "Daft Punk"), songCount = 3) + givenNoArtwork() + coEvery { searchRepository.search(any(), any(), any()) } returns CoverArtSearchOutcome( + listOf(candidate(score = 0.95f)) + ) + val downloaded = mockk(relaxed = true) + every { downloaded.path } returns null + coEvery { searchRepository.downloadCandidate(any()) } returns Result.success(downloaded) + + val result = fetcher().fetchMissingCovers(perAlbumDelayMs = 0L) + + // Download happened, but nothing readable came back, so it counts as a miss. + assertEquals(1, result.albumsChecked) + assertEquals(0, result.coversApplied) + coVerify(exactly = 1) { searchRepository.downloadCandidate(any()) } + } + + @Test + fun `a confident match is applied to every track through the app's artwork store`() = runTest { + givenLibrary(album(1L, "Discovery", "Daft Punk"), songCount = 2) + givenNoArtwork() + coEvery { searchRepository.search(any(), any(), any()) } returns CoverArtSearchOutcome( + listOf(candidate(score = 0.95f)) + ) + givenDownloadedCover() + coEvery { appArtworkWriter.apply(any(), any(), any()) } returns true + + val result = fetcher().fetchMissingCovers(perAlbumDelayMs = 0L) + + assertEquals(1, result.coversApplied) + // Never into the audio files: embedding needs the user's consent per + // file, and a background pass has nobody to ask. + coVerify { appArtworkWriter.apply(any(), listOf(101L, 102L), 1L) } + } + + @Test + fun `the search is told the bar a cover has to clear to be applied`() = runTest { + givenLibrary(album(1L, "Discovery", "Daft Punk")) + givenNoArtwork() + coEvery { searchRepository.search(any(), any(), any()) } returns CoverArtSearchOutcome(emptyList()) + + fetcher().fetchMissingCovers(perAlbumDelayMs = 0L) + + // Candidates below the bar are discarded here anyway, so the slow + // catalog is worth skipping once a direct one has answered this well. + coVerify { + searchRepository.search( + album = "Discovery", + artist = "Daft Punk", + confidentMatchScore = AutoCoverArtFetcher.MIN_AUTO_SCORE + ) + } + } + + @Test + fun `albums already known to have no match are not queried again`() = runTest { + givenLibrary(album(7L, "Nothing", "Nobody")) + givenNoArtwork() + every { preferences.albumArtNotFoundIdsFlow } returns flowOf(setOf(7L)) + + val result = fetcher().fetchMissingCovers(perAlbumDelayMs = 0L) + + assertEquals(0, result.albumsChecked) + coVerify(exactly = 0) { searchRepository.search(any(), any(), any()) } + } + + @Test + fun `a pass that hits its cap reports there is more to do`() = runTest { + val albums = (1L..3L).map { id -> album(id, "Album $id", "Artist $id") } + coEvery { musicDao.getAllAlbumsList(any(), any(), any()) } returns albums + albums.forEach { album -> + coEvery { musicDao.getSongsByAlbumIdOnce(album.id) } returns + listOf(song(id = album.id * 100, albumId = album.id)) + } + givenNoArtwork() + coEvery { searchRepository.search(any(), any(), any()) } returns CoverArtSearchOutcome(emptyList()) + + val capped = fetcher().fetchMissingCovers(albumLimit = 2, perAlbumDelayMs = 0L) + assertTrue(capped.reachedLimit, "two of three albums processed, so more remain") + + val uncapped = fetcher().fetchMissingCovers(albumLimit = 10, perAlbumDelayMs = 0L) + assertFalse(uncapped.reachedLimit, "every album was processed") + } + + @Test + fun `an excluded folder is left out of the pass, not just the search`() = runTest { + // Exclusion is the blocked set, not the allowed one -- an empty allow + // list with something blocked still means "everything except that", + // and the query matches parent directories, not the roots the user + // configured, so those have to be expanded first. + every { preferences.blockedDirectoriesFlow } returns flowOf(setOf("/Music/Skip")) + coEvery { musicDao.getDistinctParentDirectories() } returns + listOf("/Music/Keep", "/Music/Skip") + coEvery { musicDao.getAllAlbumsList(any(), any(), any()) } returns emptyList() + + fetcher().fetchMissingCovers(perAlbumDelayMs = 0L) + + coVerify { + musicDao.getAllAlbumsList( + allowedParentDirs = listOf("/Music/Keep"), + applyDirectoryFilter = true, + minTracks = 1 + ) + } + } + + @Test + fun `nothing is excluded when nothing is blocked`() = runTest { + coEvery { musicDao.getAllAlbumsList(any(), any(), any()) } returns emptyList() + + fetcher().fetchMissingCovers(perAlbumDelayMs = 0L) + + // No blocked set means no filter at all -- an empty allowed set here is + // not "allow nothing", the way it would be misread if the two flows + // were conflated. + coVerify { + musicDao.getAllAlbumsList( + allowedParentDirs = emptyList(), + applyDirectoryFilter = false, + minTracks = 1 + ) + } + coVerify(exactly = 0) { musicDao.getDistinctParentDirectories() } + } + + @Test + fun `albums that found nothing are paced like the ones that did`() = runTest { + val albums = (1L..3L).map { id -> album(id, "Album $id", "Artist $id") } + coEvery { musicDao.getAllAlbumsList(any(), any(), any()) } returns albums + albums.forEach { album -> + coEvery { musicDao.getSongsByAlbumIdOnce(album.id) } returns + listOf(song(id = album.id * 100, albumId = album.id)) + } + givenNoArtwork() + coEvery { searchRepository.search(any(), any(), any()) } returns CoverArtSearchOutcome(emptyList()) + + // The pass runs on Dispatchers.IO, so its waits are real rather than the + // test scheduler's. Asserted as a floor, which no amount of slowness can + // break -- only pacing that did not happen at all. + val pacing = 40L + val startedNs = System.nanoTime() + fetcher().fetchMissingCovers(perAlbumDelayMs = pacing) + val elapsedMs = (System.nanoTime() - startedNs) / 1_000_000 + + // An album with no match is the album that fell through to the slow + // catalog to find that out, so it is the last one that should be allowed + // to skip the wait. Three albums, two gaps between them. + assertTrue( + elapsedMs >= pacing * 2, + "expected at least ${pacing * 2}ms of pacing, took ${elapsedMs}ms" + ) + } + + @Test + fun `a search that failed is not remembered as a dead end`() = runTest { + val albums = (1L..3L).map { id -> album(id, "Album $id", "Artist $id") } + coEvery { musicDao.getAllAlbumsList(any(), any(), any()) } returns albums + albums.forEach { album -> + coEvery { musicDao.getSongsByAlbumIdOnce(album.id) } returns + listOf(song(id = album.id * 100, albumId = album.id)) + } + givenNoArtwork() + coEvery { searchRepository.search(any(), any(), any()) } returns + CoverArtSearchOutcome(emptyList(), java.io.IOException("no route to host")) + + val outcome = fetcher().fetchMissingCovers(perAlbumDelayMs = 0L) + + // The not-found list is never revisited on its own, so an album put on + // it because the network was down would go uncovered for good. + coVerify(exactly = 0) { preferences.addAlbumArtNotFoundIds(any()) } + assertEquals(0, outcome.notFound) + } + + @Test + fun `a search one catalog never answered is not remembered as a dead end`() = runTest { + givenLibrary(album(1L, "Discovery", "Daft Punk")) + givenNoArtwork() + // The catalogs barely overlap: the one that timed out may be the only + // one carrying this release, while another offers something unrelated + // that scores nowhere near the bar. Read as "no cover exists", that + // puts the album beyond every future pass. + coEvery { searchRepository.search(any(), any(), any()) } returns CoverArtSearchOutcome( + candidates = listOf(candidate(score = 0.25f)), + failure = java.io.IOException("timeout") + ) + + val outcome = fetcher().fetchMissingCovers(perAlbumDelayMs = 0L) + + coVerify(exactly = 0) { preferences.addAlbumArtNotFoundIds(any()) } + assertEquals(0, outcome.notFound) + } + + @Test + fun `a catalog that failed does not hold back a match good enough to apply`() = runTest { + givenLibrary(album(1L, "Discovery", "Daft Punk")) + givenNoArtwork() + coEvery { searchRepository.search(any(), any(), any()) } returns CoverArtSearchOutcome( + candidates = listOf(candidate(score = 0.95f)), + failure = java.io.IOException("timeout") + ) + givenDownloadedCover() + coEvery { appArtworkWriter.apply(any(), any(), any()) } returns true + + val result = fetcher().fetchMissingCovers(perAlbumDelayMs = 0L) + + // What the missing catalog would have added is beside the point once a + // cover has cleared the bar. + assertEquals(1, result.coversApplied) + } + + @Test + fun `an album with nothing to identify it by is never searched for`() = runTest { + // Scoring compares what it is given, so with no artist to compare + // against the title alone decides -- and every "Greatest Hits" in every + // catalog is then an exact match, applied with nobody watching. + val unnamed = listOf( + album(1L, "Greatest Hits", ""), + album(2L, "Greatest Hits", ""), + album(3L, "", "Daft Punk") + ) + coEvery { musicDao.getAllAlbumsList(any(), any(), any()) } returns unnamed + unnamed.forEach { album -> + coEvery { musicDao.getSongsByAlbumIdOnce(album.id) } returns + listOf(song(id = album.id * 100, albumId = album.id)) + } + givenNoArtwork() + + val result = fetcher().fetchMissingCovers(perAlbumDelayMs = 0L) + + assertEquals(0, result.albumsChecked) + coVerify(exactly = 0) { searchRepository.search(any(), any(), any()) } + // Not a dead end either: tagging the album is all it takes to make it + // answerable, and the not-found list is never revisited on its own. + coVerify(exactly = 0) { preferences.addAlbumArtNotFoundIds(any()) } + } + + @Test + fun `a run of failed searches stops the pass`() = runTest { + val albums = (1L..20L).map { id -> album(id, "Album $id", "Artist $id") } + coEvery { musicDao.getAllAlbumsList(any(), any(), any()) } returns albums + albums.forEach { album -> + coEvery { musicDao.getSongsByAlbumIdOnce(album.id) } returns + listOf(song(id = album.id * 100, albumId = album.id)) + } + givenNoArtwork() + coEvery { searchRepository.search(any(), any(), any()) } returns + CoverArtSearchOutcome(emptyList(), java.io.IOException("no route to host")) + + fetcher().fetchMissingCovers(perAlbumDelayMs = 0L) + + // Nothing is answering, so there is nothing to learn by asking about + // every remaining album in the library. + coVerify(atMost = 6) { searchRepository.search(any(), any(), any()) } + } + + @Test + fun `a cover the store failed to write is not counted as applied`() = runTest { + givenLibrary(album(1L, "Discovery", "Daft Punk")) + givenNoArtwork() + coEvery { searchRepository.search(any(), any(), any()) } returns CoverArtSearchOutcome( + listOf(candidate(score = 0.95f)) + ) + givenDownloadedCover() + // A full disk or a failed write: the download and the search both + // succeeded, but nothing was actually stored. + coEvery { appArtworkWriter.apply(any(), any(), any()) } returns false + + val result = fetcher().fetchMissingCovers(perAlbumDelayMs = 0L) + + // Reported as applied here is exactly the failure that let a pass + // chain into re-fetching the same albums forever: each one believed + // it had made progress when nothing had changed. + assertEquals(0, result.coversApplied) + } + + @Test + fun `a run of failed writes stops the pass the same way a run of failed searches does`() = runTest { + val albums = (1L..20L).map { id -> album(id, "Album $id", "Artist $id") } + coEvery { musicDao.getAllAlbumsList(any(), any(), any()) } returns albums + albums.forEach { album -> + coEvery { musicDao.getSongsByAlbumIdOnce(album.id) } returns + listOf(song(id = album.id * 100, albumId = album.id)) + } + givenNoArtwork() + coEvery { searchRepository.search(any(), any(), any()) } returns CoverArtSearchOutcome( + listOf(candidate(score = 0.95f)) + ) + givenDownloadedCover() + coEvery { appArtworkWriter.apply(any(), any(), any()) } returns false + + fetcher().fetchMissingCovers(perAlbumDelayMs = 0L) + + // A search succeeding does not reset the count of a different kind of + // failure -- the store failing every time is still a run of failures, + // and there is nothing to learn by working through the rest of the + // library asking the same store to fail the same way. + coVerify(atMost = 6) { searchRepository.search(any(), any(), any()) } + } + + @Test + fun `dead ends are remembered as the pass goes, not only when it finishes`() = runTest { + val albums = (1L..12L).map { id -> album(id, "Album $id", "Artist $id") } + coEvery { musicDao.getAllAlbumsList(any(), any(), any()) } returns albums + albums.forEach { album -> + coEvery { musicDao.getSongsByAlbumIdOnce(album.id) } returns + listOf(song(id = album.id * 100, albumId = album.id)) + } + givenNoArtwork() + coEvery { searchRepository.search(any(), any(), any()) } returns CoverArtSearchOutcome(emptyList()) + + fetcher().fetchMissingCovers(perAlbumDelayMs = 0L) + + // A pass WorkManager stops would otherwise record nothing and be + // re-queried from the start. Batched because each write rewrites the + // whole preferences file: twelve dead ends is two batches plus a flush. + coVerify(exactly = 3) { preferences.addAlbumArtNotFoundIds(any()) } + } + + @Test + fun `a pass being taken back stops at the next album`() = runTest { + val albums = (1L..5L).map { id -> album(id, "Album $id", "Artist $id") } + coEvery { musicDao.getAllAlbumsList(any(), any(), any()) } returns albums + albums.forEach { album -> + coEvery { musicDao.getSongsByAlbumIdOnce(album.id) } returns + listOf(song(id = album.id * 100, albumId = album.id)) + } + givenNoArtwork() + coEvery { searchRepository.search(any(), any(), any()) } returns CoverArtSearchOutcome(emptyList()) + + // Flipped from under the pass once two albums have been searched, so a + // flag consulted only before the loop, or only after it, both fail here. + var searched = 0 + coEvery { searchRepository.search(any(), any(), any()) } answers { + searched++ + CoverArtSearchOutcome(emptyList()) + } + + val result = fetcher().fetchMissingCovers( + isStopped = { searched >= 2 }, + perAlbumDelayMs = 0L + ) + + assertEquals(2, result.albumsChecked) + + val secondResult = fetcher().fetchMissingCovers(isStopped = { true }, perAlbumDelayMs = 0L) + assertEquals(0, secondResult.albumsChecked) + assertFalse(secondResult.reachedLimit) + } + + @Test + fun `dead ends no album answers to any more are dropped`() = runTest { + every { preferences.albumArtNotFoundIdsFlow } returns flowOf(setOf(1L, 404L)) + givenLibrary(album(1L, "Discovery", "Daft Punk")) + every { AlbumArtUtils.ensureAlbumArtCachedFile(any(), any(), any(), any()) } returns + mockk(relaxed = true) + + fetcher().fetchMissingCovers(perAlbumDelayMs = 0L) + + // 404 is gone from the library, 1 is still there, so the set shrinks to + // the size of the problem rather than the install's history. + coVerify(exactly = 1) { preferences.setAlbumArtNotFoundIds(setOf(1L)) } + } + + @Test + fun `a filtered library leaves the remembered dead ends alone`() = runTest { + // The listing speaks only for the allowed folders, so albums outside + // them are absent while still in the library. Pruning against it would + // send the next pass back to the catalogs for all of them. + every { preferences.albumArtNotFoundIdsFlow } returns flowOf(setOf(1L, 404L)) + every { preferences.blockedDirectoriesFlow } returns flowOf(setOf("/Music/Skip")) + coEvery { musicDao.getDistinctParentDirectories() } returns + listOf("/Music/Keep", "/Music/Skip") + givenLibrary(album(1L, "Discovery", "Daft Punk")) + every { AlbumArtUtils.ensureAlbumArtCachedFile(any(), any(), any(), any()) } returns + mockk(relaxed = true) + + fetcher().fetchMissingCovers(perAlbumDelayMs = 0L) + + // 404 lives in the excluded folder, so its absence here says nothing. + coVerify(exactly = 0) { preferences.setAlbumArtNotFoundIds(any()) } + } + + @Test + fun `an empty library leaves the remembered dead ends alone`() = runTest { + // Enabling the setting queues a pass without waiting for a sync, and the + // directory filter can exclude everything, so an empty listing is not + // evidence that every remembered album is gone. Wiping the set here + // would send the next pass back to the catalogs for every one of them. + every { preferences.albumArtNotFoundIdsFlow } returns flowOf(setOf(1L, 2L, 3L)) + coEvery { musicDao.getAllAlbumsList(any(), any(), any()) } returns emptyList() + + fetcher().fetchMissingCovers(perAlbumDelayMs = 0L) + + coVerify(exactly = 0) { preferences.setAlbumArtNotFoundIds(any()) } + } + + @Test + fun `an album is left alone when any track still has its own artwork`() = runTest { + // The cover goes on the whole album and an applied cover outranks + // extracted art, so judging by track one would cost every other track + // the artwork it already carries. + val album = album(1L, "Greatest Hits", "Various") + givenLibrary(album, songCount = 3) + val firstTrackId = album.id * 100 + 1 + every { AlbumArtUtils.ensureAlbumArtCachedFile(any(), any(), any(), any()) } answers { + if (secondArg() == firstTrackId) null else mockk(relaxed = true) + } + + val result = fetcher().fetchMissingCovers(perAlbumDelayMs = 0L) + + assertEquals(0, result.albumsChecked) + coVerify(exactly = 0) { searchRepository.search(any(), any(), any()) } + coVerify(exactly = 0) { appArtworkWriter.apply(any(), any(), any()) } + } + + private fun fetcher() = AutoCoverArtFetcher( + context = context, + musicDao = musicDao, + coverArtSearchRepository = searchRepository, + appArtworkWriter = appArtworkWriter, + userPreferencesRepository = preferences + ) + + private fun givenLibrary(album: AlbumEntity, songCount: Int = 1) { + coEvery { musicDao.getAllAlbumsList(any(), any(), any()) } returns listOf(album) + coEvery { musicDao.getSongsByAlbumIdOnce(album.id) } returns + (1..songCount).map { index -> song(id = album.id * 100 + index, albumId = album.id) } + } + + private fun givenNoArtwork() { + every { AlbumArtUtils.ensureAlbumArtCachedFile(any(), any(), any(), any()) } returns null + } + + /** A cover that downloads to a readable file, as the happy path does. */ + private fun givenDownloadedCover() { + val file = File.createTempFile("cover", ".jpg").apply { + writeBytes(byteArrayOf(1, 2, 3)) + deleteOnExit() + } + val downloaded = mockk(relaxed = true) + every { downloaded.path } returns file.absolutePath + coEvery { searchRepository.downloadCandidate(any()) } returns Result.success(downloaded) + } + + private fun album(id: Long, title: String, artist: String) = AlbumEntity( + id = id, + title = title, + artistName = artist, + artistId = 1L, + albumArtUriString = null, + songCount = 1, + dateAdded = 0L, + year = 2001 + ) + + private fun song(id: Long, albumId: Long) = mockk(relaxed = true).also { + every { it.id } returns id + every { it.filePath } returns "/music/$albumId/$id.mp3" + } + + private fun candidate(score: Float) = CoverArtCandidate( + id = "DEEZER:1", + albumTitle = "Discovery", + artistName = "Daft Punk", + thumbnailUrl = "https://example.test/250.jpg", + imageUrl = "https://example.test/1000.jpg", + source = CoverArtSource.DEEZER, + score = score + ) +} diff --git a/app/src/test/java/com/theveloper/pixelplay/data/coverart/CoverArtImageHeaderTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/coverart/CoverArtImageHeaderTest.kt new file mode 100644 index 0000000000..a2e00fb97c --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/coverart/CoverArtImageHeaderTest.kt @@ -0,0 +1,115 @@ +package com.theveloper.pixelplay.data.coverart + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class CoverArtImageHeaderTest { + + @Test + fun `reads dimensions from a baseline jpeg`() { + val jpeg = jpegHeader(width = 1400, height = 1400) + + assertEquals(1400 to 1400, CoverArtImageHeader.readDimensions(jpeg)) + } + + @Test + fun `skips jpeg metadata segments before the frame header`() { + // A JFIF app segment and an oversized EXIF blob in front of the frame, + // which is what catalogs actually serve. + val jpeg = jpegHeader( + width = 600, + height = 900, + leadingSegments = listOf( + segment(marker = 0xE0, payloadSize = 14), + segment(marker = 0xE1, payloadSize = 4000) + ) + ) + + assertEquals(600 to 900, CoverArtImageHeader.readDimensions(jpeg)) + } + + @Test + fun `walks past the fill bytes a jpeg may pad its markers with`() { + // 0xFF repeated before a marker is legal padding. Taken for a marker of + // its own, the two bytes after it are read as a segment length and the + // walk jumps to an arbitrary offset, so the frame header is never found + // and the picker shows "size unknown" for an image it could measure. + val jpeg = jpegHeader( + width = 800, + height = 1200, + leadingSegments = listOf( + segment(marker = 0xE0, payloadSize = 14), + byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte()) + ) + ) + + assertEquals(800 to 1200, CoverArtImageHeader.readDimensions(jpeg)) + } + + @Test + fun `reads dimensions from a png`() { + val png = byteArrayOf( + 0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, + 0x49, 0x48, 0x44, 0x52 + ) + intBe(1000) + intBe(1000) + ByteArray(8) + + assertEquals(1000 to 1000, CoverArtImageHeader.readDimensions(png)) + } + + @Test + fun `reads dimensions from a lossy webp`() { + val webp = ByteArray(30) + "RIFF".toByteArray().copyInto(webp, 0) + "WEBP".toByteArray().copyInto(webp, 8) + "VP8 ".toByteArray().copyInto(webp, 12) + // 14 bit little endian dimensions at the end of the frame header. + webp[26] = (500 and 0xFF).toByte() + webp[27] = (500 shr 8).toByte() + webp[28] = (500 and 0xFF).toByte() + webp[29] = (500 shr 8).toByte() + + assertEquals(500 to 500, CoverArtImageHeader.readDimensions(webp)) + } + + @Test + fun `returns null for a truncated prefix`() { + assertNull(CoverArtImageHeader.readDimensions(byteArrayOf(0xFF.toByte(), 0xD8.toByte()))) + } + + @Test + fun `returns null for a non image payload`() { + assertNull(CoverArtImageHeader.readDimensions("404".toByteArray())) + } + + private fun jpegHeader( + width: Int, + height: Int, + leadingSegments: List = emptyList() + ): ByteArray { + val start = byteArrayOf(0xFF.toByte(), 0xD8.toByte()) + val frame = byteArrayOf( + 0xFF.toByte(), 0xC0.toByte(), // SOF0 + 0x00, 0x11, // segment length + 0x08 // sample precision + ) + shortBe(height) + shortBe(width) + ByteArray(6) + + return start + leadingSegments.fold(ByteArray(0)) { acc, seg -> acc + seg } + frame + } + + private fun segment(marker: Int, payloadSize: Int): ByteArray { + val length = payloadSize + 2 + return byteArrayOf(0xFF.toByte(), marker.toByte()) + shortBe(length) + ByteArray(payloadSize) + } + + private fun shortBe(value: Int) = + byteArrayOf((value shr 8).toByte(), (value and 0xFF).toByte()) + + private fun intBe(value: Int) = byteArrayOf( + (value shr 24).toByte(), + (value shr 16).toByte(), + (value shr 8).toByte(), + (value and 0xFF).toByte() + ) +} diff --git a/app/src/test/java/com/theveloper/pixelplay/data/coverart/CoverArtQueryTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/coverart/CoverArtQueryTest.kt new file mode 100644 index 0000000000..731d4f3f2c --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/coverart/CoverArtQueryTest.kt @@ -0,0 +1,134 @@ +package com.theveloper.pixelplay.data.coverart + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class CoverArtQueryTest { + + @Test + fun `normalizeAlbum drops bracketed edition noise`() { + assertEquals("abbey road", CoverArtQuery.normalizeAlbum("Abbey Road (Remastered 2019)")) + assertEquals( + "the dark side of the moon", + CoverArtQuery.normalizeAlbum("The Dark Side of the Moon [50th Anniversary]") + ) + } + + @Test + fun `normalizeAlbum drops trailing edition suffixes`() { + assertEquals("nevermind", CoverArtQuery.normalizeAlbum("Nevermind - Deluxe Edition")) + assertEquals("in rainbows", CoverArtQuery.normalizeAlbum("In Rainbows - 2016 Remaster")) + } + + @Test + fun `normalizeAlbum keeps bracketed content that is part of the title`() { + assertEquals( + "blue train the ultimate blue train", + CoverArtQuery.normalizeAlbum("Blue Train (The Ultimate Blue Train)") + ) + } + + @Test + fun `normalizeAlbum removes diacritics and punctuation`() { + assertEquals( + "sgt peppers lonely hearts club band", + CoverArtQuery.normalizeAlbum("Sgt. Pepper's Lonely Hearts Club Band") + ) + assertEquals("bjork", CoverArtQuery.normalizeAlbum("BjΓΆrk")) + assertEquals( + CoverArtQuery.normalizeAlbum("Sgt. Peppers Lonely Hearts Club Band"), + CoverArtQuery.normalizeAlbum("Sgt. Pepper’s Lonely Hearts Club Band") + ) + } + + @Test + fun `normalizeAlbum keeps a title made entirely of keywords`() { + assertEquals("live", CoverArtQuery.normalizeAlbum("Live")) + } + + @Test + fun `normalizeArtist drops featured credits`() { + assertEquals("daft punk", CoverArtQuery.normalizeArtist("Daft Punk feat. Pharrell Williams")) + assertEquals("gorillaz", CoverArtQuery.normalizeArtist("Gorillaz (feat. De La Soul)")) + } + + @Test + fun `normalizeArtist spells out ampersands`() { + assertEquals( + CoverArtQuery.normalizeArtist("Simon and Garfunkel"), + CoverArtQuery.normalizeArtist("Simon & Garfunkel") + ) + } + + @Test + fun `similarity is one for equal strings and zero for empty input`() { + assertEquals(1f, CoverArtQuery.similarity("discovery", "discovery")) + assertEquals(0f, CoverArtQuery.similarity("", "discovery")) + assertEquals(0f, CoverArtQuery.similarity("discovery", "")) + } + + @Test + fun `score ignores the artist when the query has none`() { + val score = CoverArtQuery.score( + candidateAlbum = "Random Access Memories", + candidateArtist = "Daft Punk", + queryAlbum = "Random Access Memories", + queryArtist = "" + ) + + assertEquals(1f, score) + } + + @Test + fun `score survives edition noise on either side`() { + val score = CoverArtQuery.score( + candidateAlbum = "Abbey Road", + candidateArtist = "The Beatles", + queryAlbum = "Abbey Road (Remastered 2019)", + queryArtist = "The Beatles" + ) + + assertEquals(1f, score) + } + + @Test + fun `rank puts the best match first and drops unrelated results`() { + val candidates = listOf( + candidate(id = "thriller", album = "Thriller", artist = "Michael Jackson"), + candidate(id = "discovery", album = "Discovery", artist = "Daft Punk"), + candidate(id = "ram", album = "Random Access Memories", artist = "Daft Punk") + ) + + val ranked = CoverArtQuery.rank( + candidates = candidates, + queryAlbum = "Random Access Memories", + queryArtist = "Daft Punk" + ) + + assertEquals("ram", ranked.first().id) + assertTrue(ranked.none { it.id == "thriller" }, "unrelated album should be dropped") + assertTrue(ranked.first().score > ranked.last().score) + } + + @Test + fun `rank is stable for candidates that score the same`() { + val candidates = listOf( + candidate(id = "b", album = "Homework", artist = "Daft Punk"), + candidate(id = "a", album = "Homework", artist = "Daft Punk") + ) + + val ranked = CoverArtQuery.rank(candidates, "Homework", "Daft Punk") + + assertEquals(listOf("b", "a"), ranked.map { it.id }) + } + + private fun candidate(id: String, album: String, artist: String) = CoverArtCandidate( + id = id, + albumTitle = album, + artistName = artist, + thumbnailUrl = "https://example.test/$id/250.jpg", + imageUrl = "https://example.test/$id/1000.jpg", + source = CoverArtSource.DEEZER + ) +} diff --git a/app/src/test/java/com/theveloper/pixelplay/data/coverart/DeezerCoverArtProviderTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/coverart/DeezerCoverArtProviderTest.kt new file mode 100644 index 0000000000..e517e81465 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/coverart/DeezerCoverArtProviderTest.kt @@ -0,0 +1,127 @@ +package com.theveloper.pixelplay.data.coverart + +import com.theveloper.pixelplay.data.network.deezer.DeezerAlbum +import com.theveloper.pixelplay.data.network.deezer.DeezerAlbumArtist +import com.theveloper.pixelplay.data.network.deezer.DeezerAlbumSearchResponse +import com.theveloper.pixelplay.data.network.deezer.DeezerApiService +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class DeezerCoverArtProviderTest { + + @Test + fun `buildQueries tries the advanced syntax before free text`() { + val queries = DeezerCoverArtProvider.buildQueries( + album = "Random Access Memories", + artist = "Daft Punk" + ) + + assertEquals( + listOf( + "artist:\"Daft Punk\" album:\"Random Access Memories\"", + "Daft Punk Random Access Memories" + ), + queries + ) + } + + @Test + fun `buildQueries handles a missing artist`() { + val queries = DeezerCoverArtProvider.buildQueries(album = "Homework", artist = " ") + + assertEquals(listOf("album:\"Homework\"", "Homework"), queries) + } + + @Test + fun `buildQueries returns nothing when there is nothing to search for`() { + assertTrue(DeezerCoverArtProvider.buildQueries(album = " ", artist = "").isEmpty()) + } + + @Test + fun `search falls back to free text when the advanced query finds nothing`() = runTest { + val api = mockk() + val provider = DeezerCoverArtProvider(api) + coEvery { + api.searchAlbum("artist:\"Daft Punk\" album:\"Discovery\"", 24) + } returns DeezerAlbumSearchResponse() + coEvery { + api.searchAlbum("Daft Punk Discovery", 24) + } returns DeezerAlbumSearchResponse(data = listOf(album())) + + val candidates = provider.search( + CoverArtSearchRequest(album = "Discovery", artist = "Daft Punk", limit = 24) + ) + + assertEquals(1, candidates.size) + coVerify(exactly = 1) { api.searchAlbum("Daft Punk Discovery", 24) } + } + + @Test + fun `search prefers the largest cover and keeps a smaller one for the grid`() = runTest { + val api = mockk() + val provider = DeezerCoverArtProvider(api) + coEvery { api.searchAlbum(any(), any()) } returns DeezerAlbumSearchResponse( + data = listOf(album()) + ) + + val candidate = provider.search( + CoverArtSearchRequest(album = "Discovery", artist = "Daft Punk", limit = 24) + ).single() + + assertEquals("DEEZER:302127", candidate.id) + assertEquals("Discovery", candidate.albumTitle) + assertEquals("Daft Punk", candidate.artistName) + assertEquals("https://example.test/1000x1000.jpg", candidate.imageUrl) + assertEquals("https://example.test/250x250.jpg", candidate.thumbnailUrl) + assertEquals(CoverArtSource.DEEZER, candidate.source) + assertEquals(CoverArtSize(width = 1000, height = 1000), candidate.size) + } + + @Test + fun `search reports no size when the xl rendition is missing`() = runTest { + val api = mockk() + coEvery { api.searchAlbum(any(), any()) } returns DeezerAlbumSearchResponse( + data = listOf(album().copy(coverXl = null)) + ) + + val candidate = DeezerCoverArtProvider(api) + .search(CoverArtSearchRequest(album = "Discovery", artist = "Daft Punk", limit = 24)) + .single() + + assertNull(candidate.size) + } + + @Test + fun `search skips albums that carry no cover at all`() = runTest { + val api = mockk() + val provider = DeezerCoverArtProvider(api) + coEvery { api.searchAlbum(any(), any()) } returns DeezerAlbumSearchResponse( + data = listOf( + DeezerAlbum(id = 1L, title = "Coverless"), + album() + ) + ) + + val candidates = provider.search( + CoverArtSearchRequest(album = "Discovery", artist = "Daft Punk", limit = 24) + ) + + assertEquals(listOf("DEEZER:302127"), candidates.map { it.id }) + } + + private fun album() = DeezerAlbum( + id = 302127L, + title = "Discovery", + cover = "https://example.test/cover.jpg", + coverMedium = "https://example.test/250x250.jpg", + coverBig = "https://example.test/500x500.jpg", + coverXl = "https://example.test/1000x1000.jpg", + artist = DeezerAlbumArtist(id = 27L, name = "Daft Punk") + ) +} diff --git a/app/src/test/java/com/theveloper/pixelplay/data/coverart/ItunesCoverArtProviderTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/coverart/ItunesCoverArtProviderTest.kt new file mode 100644 index 0000000000..7c1fba8af5 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/coverart/ItunesCoverArtProviderTest.kt @@ -0,0 +1,84 @@ +package com.theveloper.pixelplay.data.coverart + +import com.theveloper.pixelplay.data.network.itunes.ItunesAlbum +import com.theveloper.pixelplay.data.network.itunes.ItunesApiService +import com.theveloper.pixelplay.data.network.itunes.ItunesSearchResponse +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ItunesCoverArtProviderTest { + + @Test + fun `buildTerm joins artist and album the way a person would type it`() { + assertEquals( + "Daft Punk Discovery", + ItunesCoverArtProvider.buildTerm(album = "Discovery", artist = "Daft Punk") + ) + assertEquals("Discovery", ItunesCoverArtProvider.buildTerm(album = "Discovery", artist = " ")) + assertNull(ItunesCoverArtProvider.buildTerm(album = " ", artist = "")) + } + + @Test + fun `resizeArtwork rewrites the size segment`() { + val url = "https://is1-ssl.mzstatic.com/image/thumb/Music/abc/source/100x100bb.jpg" + + assertEquals( + "https://is1-ssl.mzstatic.com/image/thumb/Music/abc/source/1200x1200bb.jpg", + ItunesCoverArtProvider.resizeArtwork(url, 1200) + ) + } + + @Test + fun `resizeArtwork leaves urls without a size segment untouched`() { + val url = "https://example.test/cover.jpg" + + assertEquals(url, ItunesCoverArtProvider.resizeArtwork(url, 1200)) + } + + @Test + fun `search maps albums to candidates with a nominal size`() = runTest { + val api = mockk() + coEvery { api.searchAlbums(any(), any(), any(), any()) } returns ItunesSearchResponse( + resultCount = 1, + results = listOf( + ItunesAlbum( + collectionId = 697194953L, + collectionName = "Random Access Memories", + artistName = "Daft Punk", + artworkUrl100 = "https://example.test/source/100x100bb.jpg" + ) + ) + ) + + val candidate = ItunesCoverArtProvider(api) + .search(CoverArtSearchRequest(album = "Random Access Memories", artist = "Daft Punk", limit = 24)) + .single() + + assertEquals("ITUNES:697194953", candidate.id) + assertEquals("https://example.test/source/1200x1200bb.jpg", candidate.imageUrl) + assertEquals("https://example.test/source/300x300bb.jpg", candidate.thumbnailUrl) + assertEquals(CoverArtSize(width = 1200, height = 1200), candidate.size) + assertTrue(candidate.size?.measured == false) + } + + @Test + fun `search drops results without artwork or title`() = runTest { + val api = mockk() + coEvery { api.searchAlbums(any(), any(), any(), any()) } returns ItunesSearchResponse( + results = listOf( + ItunesAlbum(collectionId = 1L, collectionName = "No Artwork"), + ItunesAlbum(collectionId = 2L, artworkUrl100 = "https://example.test/source/100x100bb.jpg") + ) + ) + + val candidates = ItunesCoverArtProvider(api) + .search(CoverArtSearchRequest(album = "Whatever", artist = "Someone", limit = 24)) + + assertTrue(candidates.isEmpty()) + } +} diff --git a/app/src/test/java/com/theveloper/pixelplay/data/coverart/MusicBrainzCoverArtProviderTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/coverart/MusicBrainzCoverArtProviderTest.kt new file mode 100644 index 0000000000..412b9dba7f --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/coverart/MusicBrainzCoverArtProviderTest.kt @@ -0,0 +1,211 @@ +package com.theveloper.pixelplay.data.coverart + +import com.theveloper.pixelplay.data.network.coverartarchive.CoverArtArchiveApiService +import com.theveloper.pixelplay.data.network.coverartarchive.CoverArtArchiveImage +import com.theveloper.pixelplay.data.network.coverartarchive.CoverArtArchiveResponse +import com.theveloper.pixelplay.data.network.coverartarchive.CoverArtArchiveThumbnails +import com.theveloper.pixelplay.data.network.coverartarchive.MusicBrainzApiService +import com.theveloper.pixelplay.data.network.coverartarchive.MusicBrainzArtistCredit +import com.theveloper.pixelplay.data.network.coverartarchive.MusicBrainzRelease +import com.theveloper.pixelplay.data.network.coverartarchive.MusicBrainzReleaseSearchResponse +import io.mockk.coEvery +import io.mockk.mockk +import java.io.IOException +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class MusicBrainzCoverArtProviderTest { + + @Test + fun `buildQuery uses fielded lucene syntax`() { + assertEquals( + "release:\"Discovery\" AND artist:\"Daft Punk\"", + MusicBrainzCoverArtProvider.buildQuery(album = "Discovery", artist = "Daft Punk") + ) + assertEquals( + "release:\"Discovery\"", + MusicBrainzCoverArtProvider.buildQuery(album = "Discovery", artist = "") + ) + assertNull(MusicBrainzCoverArtProvider.buildQuery(album = " ", artist = " ")) + } + + @Test + fun `buildQuery strips lucene operators that would break the parse`() { + val query = MusicBrainzCoverArtProvider.buildQuery( + album = "Album: \"Deluxe\" (2011) +bonus", + artist = "AC/DC" + ) + + assertEquals("release:\"Album Deluxe 2011 bonus\" AND artist:\"AC DC\"", query) + } + + @Test + fun `search returns only releases that actually have artwork`() = runTest { + val musicBrainz = mockk() + val archive = mockk() + + coEvery { musicBrainz.searchReleases(any(), any(), any()) } returns + MusicBrainzReleaseSearchResponse( + releases = listOf( + release("mbid-with-art", "Discovery"), + release("mbid-without-art", "Discovery") + ) + ) + coEvery { archive.getReleaseCoverArt("mbid-with-art") } returns CoverArtArchiveResponse( + images = listOf( + CoverArtArchiveImage( + isFront = true, + image = "https://coverartarchive.test/full.jpg", + thumbnails = CoverArtArchiveThumbnails( + size250 = "https://coverartarchive.test/250.jpg", + size1200 = "https://coverartarchive.test/1200.jpg" + ) + ) + ) + ) + coEvery { archive.getReleaseCoverArt("mbid-without-art") } throws IOException("404") + + val candidates = MusicBrainzCoverArtProvider(musicBrainz, archive) + .search(CoverArtSearchRequest(album = "Discovery", artist = "Daft Punk", limit = 24)) + + val candidate = candidates.single() + assertEquals("COVER_ART_ARCHIVE:mbid-with-art", candidate.id) + assertEquals("https://coverartarchive.test/1200.jpg", candidate.imageUrl) + assertEquals("https://coverartarchive.test/250.jpg", candidate.thumbnailUrl) + assertEquals("Daft Punk", candidate.artistName) + } + + @Test + fun `search upgrades the archive http urls to https`() = runTest { + val musicBrainz = mockk() + val archive = mockk() + + coEvery { musicBrainz.searchReleases(any(), any(), any()) } returns + MusicBrainzReleaseSearchResponse(releases = listOf(release("mbid", "Discovery"))) + // The Archive embeds http:// links even though every one serves over TLS. + coEvery { archive.getReleaseCoverArt("mbid") } returns CoverArtArchiveResponse( + images = listOf( + CoverArtArchiveImage( + isFront = true, + image = "http://coverartarchive.org/release/mbid/123.jpg", + thumbnails = CoverArtArchiveThumbnails( + small = "http://coverartarchive.org/release/mbid/123-250.jpg", + large = "http://coverartarchive.org/release/mbid/123-500.jpg" + ) + ) + ) + ) + + val candidate = MusicBrainzCoverArtProvider(musicBrainz, archive) + .search(CoverArtSearchRequest(album = "Discovery", artist = "Daft Punk", limit = 24)) + .single() + + assertEquals("https://coverartarchive.org/release/mbid/123.jpg", candidate.imageUrl) + assertEquals("https://coverartarchive.org/release/mbid/123-250.jpg", candidate.thumbnailUrl) + } + + @Test + fun `search reads the named thumbnail keys used by older archive entries`() = runTest { + val musicBrainz = mockk() + val archive = mockk() + + coEvery { musicBrainz.searchReleases(any(), any(), any()) } returns + MusicBrainzReleaseSearchResponse(releases = listOf(release("mbid", "Discovery"))) + coEvery { archive.getReleaseCoverArt("mbid") } returns CoverArtArchiveResponse( + images = listOf( + CoverArtArchiveImage( + isFront = true, + image = "https://coverartarchive.org/release/mbid/123.jpg", + // Older entries carry only small/large, no numeric keys. + thumbnails = CoverArtArchiveThumbnails( + small = "https://coverartarchive.org/release/mbid/123-250.jpg", + large = "https://coverartarchive.org/release/mbid/123-500.jpg" + ) + ) + ) + ) + + val candidate = MusicBrainzCoverArtProvider(musicBrainz, archive) + .search(CoverArtSearchRequest(album = "Discovery", artist = "Daft Punk", limit = 24)) + .single() + + // The grid must not be handed the multi-megabyte original. + assertEquals("https://coverartarchive.org/release/mbid/123-250.jpg", candidate.thumbnailUrl) + assertEquals("https://coverartarchive.org/release/mbid/123.jpg", candidate.imageUrl) + } + + @Test + fun `search prefers the 1200px rendition when the entry has numeric keys`() = runTest { + val musicBrainz = mockk() + val archive = mockk() + + coEvery { musicBrainz.searchReleases(any(), any(), any()) } returns + MusicBrainzReleaseSearchResponse(releases = listOf(release("mbid", "Discovery"))) + coEvery { archive.getReleaseCoverArt("mbid") } returns CoverArtArchiveResponse( + images = listOf( + CoverArtArchiveImage( + isFront = true, + image = "https://coverartarchive.org/release/mbid/123.jpg", + thumbnails = CoverArtArchiveThumbnails( + size250 = "https://coverartarchive.org/release/mbid/123-250.jpg", + size500 = "https://coverartarchive.org/release/mbid/123-500.jpg", + size1200 = "https://coverartarchive.org/release/mbid/123-1200.jpg" + ) + ) + ) + ) + + val candidate = MusicBrainzCoverArtProvider(musicBrainz, archive) + .search(CoverArtSearchRequest(album = "Discovery", artist = "Daft Punk", limit = 24)) + .single() + + assertEquals("https://coverartarchive.org/release/mbid/123-1200.jpg", candidate.imageUrl) + assertEquals("https://coverartarchive.org/release/mbid/123-250.jpg", candidate.thumbnailUrl) + } + + @Test + fun `search reports no size because the archive serves whatever was uploaded`() = runTest { + val musicBrainz = mockk() + val archive = mockk() + + coEvery { musicBrainz.searchReleases(any(), any(), any()) } returns + MusicBrainzReleaseSearchResponse(releases = listOf(release("mbid", "Homework"))) + coEvery { archive.getReleaseCoverArt("mbid") } returns CoverArtArchiveResponse( + images = listOf( + CoverArtArchiveImage( + isFront = true, + image = "https://coverartarchive.test/full.jpg", + thumbnails = CoverArtArchiveThumbnails(size1200 = "https://coverartarchive.test/1200.jpg") + ) + ) + ) + + val candidate = MusicBrainzCoverArtProvider(musicBrainz, archive) + .search(CoverArtSearchRequest(album = "Homework", artist = "Daft Punk", limit = 24)) + .single() + + assertNull(candidate.size) + } + + @Test + fun `search returns nothing when musicbrainz has no releases`() = runTest { + val musicBrainz = mockk() + val archive = mockk() + coEvery { musicBrainz.searchReleases(any(), any(), any()) } returns + MusicBrainzReleaseSearchResponse() + + val candidates = MusicBrainzCoverArtProvider(musicBrainz, archive) + .search(CoverArtSearchRequest(album = "Nothing", artist = "Nobody", limit = 24)) + + assertTrue(candidates.isEmpty()) + } + + private fun release(id: String, title: String) = MusicBrainzRelease( + id = id, + title = title, + artistCredit = listOf(MusicBrainzArtistCredit(name = "Daft Punk")) + ) +} diff --git a/app/src/test/java/com/theveloper/pixelplay/data/coverart/WebImageCoverArtProviderTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/coverart/WebImageCoverArtProviderTest.kt new file mode 100644 index 0000000000..79855f3217 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/coverart/WebImageCoverArtProviderTest.kt @@ -0,0 +1,93 @@ +package com.theveloper.pixelplay.data.coverart + +import com.theveloper.pixelplay.data.network.webimage.SerperImageResult +import com.theveloper.pixelplay.data.network.webimage.SerperImageSearchApi +import com.theveloper.pixelplay.data.network.webimage.SerperImageSearchResponse +import com.theveloper.pixelplay.data.preferences.UserPreferencesRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class WebImageCoverArtProviderTest { + + private val serper = mockk(relaxed = true) + private val preferences = mockk(relaxed = true) + + @Test + fun `buildQuery biases the search towards artwork`() { + // Measured: without the suffix the engine ranks photographs of the + // artist above the cover. + assertEquals( + "Daft Punk Discovery album cover", + WebImageCoverArtProvider.buildQuery(album = "Discovery", artist = "Daft Punk") + ) + } + + @Test + fun `provider stays out of searches until a key is configured`() = runTest { + every { preferences.webImageSearchApiKeyFlow } returns flowOf("") + + val provider = provider() + + assertFalse(provider.isAvailable()) + assertTrue( + provider.search(CoverArtSearchRequest("Discovery", "Daft Punk", 24)).isEmpty() + ) + coVerify(exactly = 0) { serper.searchImages(any(), any(), any()) } + } + + @Test + fun `serper results keep the dimensions the engine reported`() = runTest { + givenSerperConfigured() + coEvery { serper.searchImages(any(), any(), any()) } returns SerperImageSearchResponse( + images = listOf( + SerperImageResult( + title = "CRZKNY - GW VIP", + imageUrl = "https://f4.bcbits.test/img/a123_10.jpg", + imageWidth = 1200, + imageHeight = 1200, + thumbnailUrl = "https://f4.bcbits.test/img/a123_16.jpg" + ) + ) + ) + + val candidate = provider() + .search(CoverArtSearchRequest("GW VIP", "CRZKNY", 24)) + .single() + + assertEquals(CoverArtSource.WEB_IMAGE_SEARCH, candidate.source) + assertEquals("CRZKNY - GW VIP", candidate.albumTitle) + assertEquals(CoverArtSize(width = 1200, height = 1200), candidate.size) + } + + @Test + fun `insecure image urls are dropped`() = runTest { + givenSerperConfigured() + coEvery { serper.searchImages(any(), any(), any()) } returns SerperImageSearchResponse( + images = listOf( + SerperImageResult(title = "cleartext", imageUrl = "http://example.test/a.jpg"), + SerperImageResult(title = "secure", imageUrl = "https://example.test/b.jpg") + ) + ) + + val candidates = provider().search(CoverArtSearchRequest("GW VIP", "CRZKNY", 24)) + + assertEquals(listOf("secure"), candidates.map { it.albumTitle }) + } + + private fun givenSerperConfigured() { + every { preferences.webImageSearchApiKeyFlow } returns flowOf("test-key") + } + + private fun provider() = WebImageCoverArtProvider( + serperImageSearchApi = serper, + userPreferencesRepository = preferences + ) +} diff --git a/app/src/test/java/com/theveloper/pixelplay/data/repository/CoverArtDownloadTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/repository/CoverArtDownloadTest.kt new file mode 100644 index 0000000000..1d508df956 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/repository/CoverArtDownloadTest.kt @@ -0,0 +1,308 @@ +package com.theveloper.pixelplay.data.repository + +import android.content.Context +import com.theveloper.pixelplay.data.coverart.CoverArtCandidate +import com.theveloper.pixelplay.data.coverart.CoverArtSource +import io.mockk.every +import io.mockk.mockk +import java.io.File +import kotlinx.coroutines.test.runTest +import okhttp3.Call +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody +import okhttp3.ResponseBody.Companion.toResponseBody +import okio.Buffer +import okio.BufferedSource +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir + +/** + * Covers what happens between a remote host and a file on disk: the candidate + * URL is chosen from third-party search results, so this is the one path in the + * feature where an outside party decides what the app writes. + * + * Responses are handed to the client directly rather than served over a socket, + * which keeps this to the dependencies the project already has. + */ +class CoverArtDownloadTest { + + @TempDir + lateinit var cacheDir: File + + private val context = mockk(relaxed = true) + + @BeforeEach + fun setUp() { + every { context.cacheDir } returns cacheDir + } + + @Test + fun `a cleartext url is refused without asking for it`() = runTest { + val http = FakeHttp { error("no request should be made") } + + val result = repositoryOf(http).downloadCandidate(candidate("http://example.test/a.jpg")) + + assertTrue(result.isFailure) + assertEquals(0, http.callCount, "the refusal must come before the request") + assertEquals(emptyList(), cachedFileNames()) + } + + @Test + fun `a probe refuses a cleartext url too`() = runTest { + val http = FakeHttp { error("no request should be made") } + + assertNull(repositoryOf(http).probeSize(candidate("http://example.test/a.jpg"))) + assertEquals(0, http.callCount) + } + + @Test + fun `an image the host declares as oversized is never read`() = runTest { + val http = FakeHttp { request -> + response(request, body = declaredSize(MAX_IMAGE_BYTES + 1)) + } + + val result = repositoryOf(http).downloadCandidate(candidate()) + + assertTrue(result.isFailure) + assertEquals(emptyList(), cachedFileNames()) + } + + @Test + fun `a body that keeps coming past the cap is cut off`() = runTest { + // No declared length, so the only defence is the bounded read. + val http = FakeHttp { request -> + response(request, body = undeclaredSize(MAX_IMAGE_BYTES + 1)) + } + + val result = repositoryOf(http).downloadCandidate(candidate()) + + assertTrue(result.isFailure) + assertEquals(emptyList(), cachedFileNames()) + } + + @Test + fun `an error page served as an image is not written to disk`() = runTest { + val http = FakeHttp { request -> + response( + request, + // The host says image, the bytes say otherwise. A captive portal + // or a rate-limit page reaches the cropper without this check. + body = "rate limited" + .toByteArray() + .toResponseBody("image/jpeg".toMediaType()) + ) + } + + val result = repositoryOf(http).downloadCandidate(candidate()) + + assertTrue(result.isFailure) + assertEquals(emptyList(), cachedFileNames()) + } + + @Test + fun `a non-image content type is refused`() = runTest { + val http = FakeHttp { request -> + response(request, body = jpegBytes().toResponseBody("text/html".toMediaType())) + } + + assertTrue(repositoryOf(http).downloadCandidate(candidate()).isFailure) + assertEquals(emptyList(), cachedFileNames()) + } + + @Test + fun `a rejected payload is not downloaded again`() = runTest { + val http = FakeHttp { request -> + response(request, body = "nope".toByteArray().toResponseBody("image/jpeg".toMediaType())) + } + + repositoryOf(http).downloadCandidate(candidate()) + + // The verdict cannot change on a second read, and the transport retry + // would spend up to three full downloads reaching it. + assertEquals(1, http.callCount) + } + + @Test + fun `each accepted format lands in the cache`() { + listOf( + "jpeg" to jpegBytes(), + "png" to pngBytes(width = 1000, height = 1000), + "gif" to "GIF89a".toByteArray() + ByteArray(16), + "webp" to webpBytes() + ).forEach { (label, bytes) -> + runTest { + cacheDir.listFiles()?.forEach { it.deleteRecursively() } + val http = FakeHttp { request -> + response(request, body = bytes.toResponseBody("image/jpeg".toMediaType())) + } + + val result = repositoryOf(http).downloadCandidate(candidate()) + + assertTrue(result.isSuccess, "$label should be accepted") + assertEquals(1, cachedFileNames().size, "$label should have been cached") + } + } + } + + @Test + fun `the cache does not grow without bound`() = runTest { + val directory = File(cacheDir, "cover_art_search").apply { mkdirs() } + repeat(25) { index -> + File(directory, "stale$index.img").apply { + writeBytes(ByteArray(4)) + setLastModified(1_000_000L + index * 1_000L) + } + } + val http = FakeHttp { request -> + response(request, body = jpegBytes().toResponseBody("image/jpeg".toMediaType())) + } + + repositoryOf(http).downloadCandidate(candidate()) + + // These files only live between picking a result and confirming the + // crop, so the ceiling is what stops a browsing session filling the + // cache directory. + assertEquals(20, cachedFileNames().size) + } + + @Test + fun `a probe reports the full size of a ranged response`() = runTest { + val http = FakeHttp { request -> + response( + request, + code = 206, + body = pngBytes(width = 1400, height = 1400) + .toResponseBody("image/png".toMediaType()), + headers = mapOf("Content-Range" to "bytes 0-1023/450560") + ) + } + + val size = repositoryOf(http).probeSize(candidate()) + + // Only the first bytes were asked for, so the body length is not the + // image's weight β€” the range footer is. + assertEquals(1400, size?.width) + assertEquals(450_560L, size?.byteCount) + assertTrue(size?.measured == true) + } + + @Test + fun `a probe falls back to the body length when the host ignores the range`() = runTest { + val png = pngBytes(width = 600, height = 900) + val http = FakeHttp { request -> + response(request, body = png.toResponseBody("image/png".toMediaType())) + } + + val size = repositoryOf(http).probeSize(candidate()) + + assertEquals(600 to 900, size?.width to size?.height) + assertEquals(png.size.toLong(), size?.byteCount) + } + + @Test + fun `a probe that cannot read the prefix leaves the reported size alone`() = runTest { + val http = FakeHttp { request -> + response(request, body = ByteArray(64).toResponseBody("image/png".toMediaType())) + } + + assertNull(repositoryOf(http).probeSize(candidate())) + } + + private fun repositoryOf(http: FakeHttp) = CoverArtSearchRepository( + context = context, + providers = emptyList(), + okHttpClient = http.client + ) + + private fun cachedFileNames(): List = + File(cacheDir, "cover_art_search").listFiles()?.map { it.name }?.sorted() ?: emptyList() + + private fun candidate(imageUrl: String = "https://example.test/cover.jpg") = CoverArtCandidate( + id = "candidate", + albumTitle = "Discovery", + artistName = "Daft Punk", + thumbnailUrl = "https://example.test/thumb.jpg", + imageUrl = imageUrl, + source = CoverArtSource.DEEZER + ) + + private fun response( + request: Request, + code: Int = 200, + body: ResponseBody, + headers: Map = emptyMap() + ): Response = Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(code) + .message("OK") + .body(body) + .apply { headers.forEach { (name, value) -> header(name, value) } } + .build() + + /** Claims a length without producing one, the way a host advertises a file. */ + private fun declaredSize(length: Long) = object : ResponseBody() { + override fun contentType() = "image/jpeg".toMediaType() + override fun contentLength() = length + override fun source(): BufferedSource = Buffer().write(jpegBytes()) + } + + /** Produces more than it admits to, which is what the bounded read is for. */ + private fun undeclaredSize(length: Long) = object : ResponseBody() { + override fun contentType() = "image/jpeg".toMediaType() + override fun contentLength() = -1L + override fun source(): BufferedSource = + Buffer().write(jpegBytes()).write(ByteArray(length.toInt())) + } + + private fun jpegBytes() = byteArrayOf(0xFF.toByte(), 0xD8.toByte()) + ByteArray(32) + + private fun webpBytes(): ByteArray { + val bytes = ByteArray(32) + "RIFF".toByteArray().copyInto(bytes, 0) + "WEBP".toByteArray().copyInto(bytes, 8) + return bytes + } + + private fun pngBytes(width: Int, height: Int): ByteArray = byteArrayOf( + 0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, + 0x49, 0x48, 0x44, 0x52 + ) + intBe(width) + intBe(height) + ByteArray(8) + + private fun intBe(value: Int) = byteArrayOf( + (value ushr 24).toByte(), + (value ushr 16).toByte(), + (value ushr 8).toByte(), + value.toByte() + ) + + /** + * Hands back canned responses and counts how often the client was asked, + * which is how the retry behaviour is observed. + */ + private class FakeHttp(private val handler: (Request) -> Response) { + var callCount: Int = 0 + private set + + val client: OkHttpClient = mockk { + every { newCall(any()) } answers { + callCount++ + val request = firstArg() + mockk { every { execute() } returns handler(request) } + } + } + } + + private companion object { + const val MAX_IMAGE_BYTES = 8L * 1024L * 1024L + } +} diff --git a/app/src/test/java/com/theveloper/pixelplay/data/repository/CoverArtSearchRepositoryTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/repository/CoverArtSearchRepositoryTest.kt new file mode 100644 index 0000000000..43afba382b --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/repository/CoverArtSearchRepositoryTest.kt @@ -0,0 +1,395 @@ +package com.theveloper.pixelplay.data.repository + +import android.content.Context +import com.theveloper.pixelplay.data.coverart.CoverArtCandidate +import com.theveloper.pixelplay.data.coverart.CoverArtProvider +import com.theveloper.pixelplay.data.coverart.CoverArtSearchRequest +import com.theveloper.pixelplay.data.coverart.CoverArtProviderStatus +import com.theveloper.pixelplay.data.coverart.CoverArtSearchUpdate +import com.theveloper.pixelplay.data.coverart.CoverArtSource +import app.cash.turbine.test +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import okhttp3.OkHttpClient +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class CoverArtSearchRepositoryTest { + + @Test + fun `search skips the network when there is nothing to search for`() = runTest { + val provider = FakeProvider(results = listOf(candidate("a", "Discovery", "Daft Punk"))) + val repository = repositoryOf(provider) + + val result = repository.search(album = " ", artist = "") + + assertEquals(emptyList(), result.candidates) + assertFalse(provider.wasQueried, "providers should not be queried for a blank search") + } + + @Test + fun `search merges providers, drops duplicate images and ranks the best match first`() = runTest { + val exact = candidate("exact", "Random Access Memories", "Daft Punk") + val duplicate = exact.copy(id = "duplicate") + val weaker = candidate("weaker", "Discovery", "Daft Punk") + val repository = repositoryOf( + FakeProvider(results = listOf(weaker, exact)), + FakeProvider(results = listOf(duplicate)) + ) + + val ranked = repository + .search(album = "Random Access Memories", artist = "Daft Punk") + .candidates + + assertEquals(listOf("exact", "weaker"), ranked.map { it.id }) + } + + @Test + fun `search queries providers concurrently rather than one after another`() = runTest { + // Each provider blocks until every provider has been entered, so this + // only completes if they really do run at the same time. + val entered = java.util.concurrent.atomic.AtomicInteger(0) + val allEntered = CompletableDeferred() + val providerCount = 3 + + val providers = (1..providerCount).map { index -> + object : CoverArtProvider { + override val source = CoverArtSource.DEEZER + override suspend fun search(request: CoverArtSearchRequest): List { + if (entered.incrementAndGet() == providerCount) allEntered.complete(Unit) + allEntered.await() + return listOf(candidate("c$index", "Homework", "Daft Punk")) + } + } + } + + val results = repositoryOf(*providers.toTypedArray()) + .search(album = "Homework", artist = "Daft Punk") + .candidates + + assertEquals(providerCount, entered.get()) + assertEquals(providerCount, results.size) + } + + @Test + fun `search still returns results when one provider fails`() = runTest { + val healthy = FakeProvider(results = listOf(candidate("ok", "Homework", "Daft Punk"))) + val repository = repositoryOf(FakeProvider(failure = IllegalStateException("boom")), healthy) + + val result = repository.search(album = "Homework", artist = "Daft Punk") + + assertEquals(listOf("ok"), result.candidates.map { it.id }) + } + + @Test + fun `search reports the catalog that failed even when another answered`() = runTest { + val healthy = FakeProvider(results = listOf(candidate("ok", "Homework", "Daft Punk"))) + val repository = repositoryOf(FakeProvider(failure = IllegalStateException("boom")), healthy) + + val result = repository.search(album = "Homework", artist = "Daft Punk") + + // What arrived is worth using; what the caller must not conclude is + // that the rest of the catalogs had nothing. An unattended pass reads + // that as "this album has no cover anywhere" and remembers it for good. + assertEquals("boom", result.failure?.message) + } + + @Test + fun `search reports the failure when every provider fails`() = runTest { + val repository = repositoryOf( + FakeProvider(failure = IllegalStateException("first")), + FakeProvider(failure = IllegalStateException("second")) + ) + + val result = repository.search(album = "Homework", artist = "Daft Punk") + + assertTrue(result.candidates.isEmpty()) + assertEquals("first", result.failure?.message) + } + + @Test + fun `searchStreaming emits as each catalog answers instead of waiting for all`() = runTest { + val slowGate = CompletableDeferred() + val fast = FakeProvider(results = listOf(candidate("fast", "Homework", "Daft Punk"))) + val slow = object : CoverArtProvider { + override val source = CoverArtSource.DEEZER + override suspend fun search(request: CoverArtSearchRequest): List { + slowGate.await() + return listOf(candidate("slow", "Homework", "Daft Punk")) + } + } + + repositoryOf(fast, slow) + .searchStreaming(album = "Homework", artist = "Daft Punk") + .test { + // First snapshot announces both catalogs as pending, before any + // answer, then the fast one lands on its own. + assertEquals(emptyList(), awaitItem().candidates.map { it.id }) + assertEquals(listOf("fast"), awaitItem().candidates.map { it.id }) + + slowGate.complete(Unit) + + assertEquals(setOf("fast", "slow"), awaitItem().candidates.map { it.id }.toSet()) + awaitComplete() + } + } + + @Test + fun `searchStreaming reports each catalog as it answers`() = runTest { + val statuses = mutableListOf>() + + repositoryOf( + FakeProvider(results = listOf(candidate("a", "Homework", "Daft Punk"))), + FakeProvider(failure = IllegalStateException("boom")) + ).searchStreaming(album = "Homework", artist = "Daft Punk").collect { statuses += it.statuses } + + // Everything pending up front, then resolved one by one. + assertTrue(statuses.first().all { it.isSearching }) + val settled = statuses.last() + assertTrue(settled.none { it.isSearching }) + assertEquals(1, settled.first().resultCount) + assertTrue(settled.last().failed) + } + + @Test + fun `searchStreaming reports the failure only when nothing was found at all`() = runTest { + val snapshots = mutableListOf() + + repositoryOf( + FakeProvider(failure = IllegalStateException("boom")), + FakeProvider(results = listOf(candidate("ok", "Homework", "Daft Punk"))) + ).searchStreaming(album = "Homework", artist = "Daft Punk").collect { snapshots += it } + + assertTrue(snapshots.last().isComplete) + assertNull(snapshots.last().failure, "a healthy catalog answered, so this is not a failure") + + val allFailed = mutableListOf() + repositoryOf( + FakeProvider(failure = IllegalStateException("first")), + FakeProvider(failure = IllegalStateException("second")) + ).searchStreaming(album = "Homework", artist = "Daft Punk").collect { allFailed += it } + + assertEquals("first", allFailed.last().failure?.message) + } + + @Test + fun `a confident direct match settles the search without the slow catalog`() = runTest { + val slow = FakeProvider( + results = listOf(candidate("slow", "Discovery", "Daft Punk")), + source = CoverArtSource.COVER_ART_ARCHIVE + ) + val repository = repositoryOf( + FakeProvider(results = listOf(candidate("fast", "Discovery", "Daft Punk"))), + slow + ) + + val ranked = repository + .search(album = "Discovery", artist = "Daft Punk", confidentMatchScore = 0.7f) + .candidates + + assertEquals(listOf("fast"), ranked.map { it.id }) + // Reaching the Cover Art Archive means a MusicBrainz query plus a lookup + // per release, and it is what makes an unattended pass take seconds an + // album. An exact match is already in hand. + assertFalse(slow.wasQueried, "the slow catalog should not have been consulted") + } + + @Test + fun `a weak direct match still falls back to the slow catalog`() = runTest { + val slow = FakeProvider( + results = listOf(candidate("slow", "Discovery", "Daft Punk")), + source = CoverArtSource.COVER_ART_ARCHIVE + ) + val repository = repositoryOf( + FakeProvider(results = listOf(candidate("unrelated", "Trans-Europe Express", "Kraftwerk"))), + slow + ) + + val ranked = repository + .search(album = "Discovery", artist = "Daft Punk", confidentMatchScore = 0.7f) + .candidates + + assertTrue(slow.wasQueried, "nothing good enough was found, so keep looking") + assertEquals("slow", ranked.first().id) + } + + @Test + fun `without a confidence bar every catalog is queried at once`() = runTest { + val slow = FakeProvider( + results = listOf(candidate("slow", "Discovery", "Daft Punk")), + source = CoverArtSource.COVER_ART_ARCHIVE + ) + val repository = repositoryOf( + FakeProvider(results = listOf(candidate("fast", "Discovery", "Daft Punk"))), + slow + ) + + // A person choosing a cover is shown everything, however good the first + // answer looked. + val ranked = repository.search(album = "Discovery", artist = "Daft Punk").candidates + + assertTrue(slow.wasQueried) + assertEquals(setOf("fast", "slow"), ranked.map { it.id }.toSet()) + } + + @Test + fun `a staged search survives a direct catalog failing`() = runTest { + val repository = repositoryOf( + FakeProvider(failure = IllegalStateException("boom")), + FakeProvider( + results = listOf(candidate("slow", "Discovery", "Daft Punk")), + source = CoverArtSource.COVER_ART_ARCHIVE + ) + ) + + val result = repository + .search(album = "Discovery", artist = "Daft Punk", confidentMatchScore = 0.7f) + + assertEquals(listOf("slow"), result.candidates.map { it.id }) + } + + @Test + fun `a staged search reports the failure of every catalog`() = runTest { + val repository = repositoryOf( + FakeProvider(failure = IllegalStateException("first")), + FakeProvider( + failure = IllegalStateException("second"), + source = CoverArtSource.COVER_ART_ARCHIVE + ) + ) + + val result = repository + .search(album = "Discovery", artist = "Daft Punk", confidentMatchScore = 0.7f) + + assertTrue(result.candidates.isEmpty()) + assertEquals("first", result.failure?.message) + } + + @Test + fun `a catalog search never spends a metered web request`() = runTest { + val web = FakeProvider( + results = listOf(webResult()), + source = CoverArtSource.WEB_IMAGE_SEARCH + ) + val repository = repositoryOf( + FakeProvider(results = listOf(candidate("catalog", "Discovery", "Daft Punk"))), + web + ) + + val ranked = repository.search(album = "Discovery", artist = "Daft Punk").candidates + + // Web requests come out of the user's own monthly allowance, so they are + // spent on the album they were asked for and nothing else. This is the + // path the unattended fetcher runs on. + assertEquals(listOf("catalog"), ranked.map { it.id }) + assertFalse(web.wasQueried, "the web engine must not be queried by a catalog search") + } + + @Test + fun `a catalog search does not announce the web engine as a pending source`() = runTest { + val repository = repositoryOf( + FakeProvider(results = listOf(candidate("catalog", "Discovery", "Daft Punk"))), + FakeProvider(results = listOf(webResult()), source = CoverArtSource.WEB_IMAGE_SEARCH) + ) + + val update = repository.searchStreaming(album = "Discovery", artist = "Daft Punk").first() + + assertEquals(listOf(CoverArtSource.DEEZER), update.statuses.map { it.source }) + } + + @Test + fun `an explicit web search returns the engine's own results and order`() = runTest { + val catalog = FakeProvider(results = listOf(candidate("catalog", "Discovery", "Daft Punk"))) + val repository = repositoryOf( + catalog, + FakeProvider( + // A web result has a page title and no artist, which the catalog + // scorer would rate close to zero and discard. + results = listOf(webResult("WEB:1"), webResult("WEB:2")), + source = CoverArtSource.WEB_IMAGE_SEARCH + ) + ) + + val found = repository.searchWebImages(album = "Discovery", artist = "Daft Punk").getOrThrow() + + assertEquals(listOf("WEB:1", "WEB:2"), found.map { it.id }) + assertFalse(catalog.wasQueried, "the catalogs have already had their turn") + } + + @Test + fun `web search reports itself unavailable until an engine is configured`() = runTest { + val catalogOnly = repositoryOf(FakeProvider(results = emptyList())) + assertFalse(catalogOnly.isWebImageSearchAvailable()) + + val unconfigured = repositoryOf( + FakeProvider(source = CoverArtSource.WEB_IMAGE_SEARCH, available = false) + ) + assertFalse(unconfigured.isWebImageSearchAvailable()) + + val configured = repositoryOf( + FakeProvider(source = CoverArtSource.WEB_IMAGE_SEARCH, available = true) + ) + assertTrue(configured.isWebImageSearchAvailable()) + } + + @Test + fun `providers the user has not configured are left out entirely`() = runTest { + val unavailable = FakeProvider(results = emptyList(), available = false) + val repository = repositoryOf( + FakeProvider(results = listOf(candidate("ok", "Homework", "Daft Punk"))), + unavailable + ) + + val update = repository.searchStreaming(album = "Homework", artist = "Daft Punk").first() + + assertEquals(1, update.statuses.size) + assertFalse(unavailable.wasQueried) + } + + private fun repositoryOf(vararg providers: CoverArtProvider) = CoverArtSearchRepository( + context = mockk(relaxed = true), + providers = providers.toList(), + okHttpClient = mockk(relaxed = true) + ) + + private fun webResult(id: String = "WEB:1") = CoverArtCandidate( + id = id, + albumTitle = "daft punk discovery vinyl reissue - record shop", + artistName = "", + thumbnailUrl = "https://example.test/$id/250.jpg", + imageUrl = "https://example.test/$id/1000.jpg", + source = CoverArtSource.WEB_IMAGE_SEARCH + ) + + private fun candidate(id: String, album: String, artist: String) = CoverArtCandidate( + id = id, + albumTitle = album, + artistName = artist, + thumbnailUrl = "https://example.test/$id/250.jpg", + imageUrl = "https://example.test/$id/1000.jpg", + source = CoverArtSource.DEEZER + ) + + private class FakeProvider( + private val results: List = emptyList(), + private val failure: Throwable? = null, + override val source: CoverArtSource = CoverArtSource.DEEZER, + private val available: Boolean = true + ) : CoverArtProvider { + var wasQueried: Boolean = false + private set + + override suspend fun isAvailable(): Boolean = available + + override suspend fun search(request: CoverArtSearchRequest): List { + wasQueried = true + failure?.let { throw it } + return results + } + } +} diff --git a/app/src/test/java/com/theveloper/pixelplay/data/worker/AutoCoverArtWorkerRequestTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/worker/AutoCoverArtWorkerRequestTest.kt new file mode 100644 index 0000000000..1cbf4dccbf --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/worker/AutoCoverArtWorkerRequestTest.kt @@ -0,0 +1,48 @@ +package com.theveloper.pixelplay.data.worker + +import androidx.work.NetworkType +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +/** + * Pins the constraints the automatic pass runs under. These are the difference + * between a background feature and a surprise on someone's mobile data bill, + * and they are set once, far from where the pass is written. + */ +class AutoCoverArtWorkerRequestTest { + + @Test + fun `the default pass waits for an unmetered network`() { + val request = AutoCoverArtWorker.buildRequest(unmeteredOnly = true) + + assertEquals( + NetworkType.UNMETERED, + request.workSpec.constraints.requiredNetworkType + ) + } + + @Test + fun `a user who allows mobile data only needs a connection`() { + val request = AutoCoverArtWorker.buildRequest(unmeteredOnly = false) + + assertEquals( + NetworkType.CONNECTED, + request.workSpec.constraints.requiredNetworkType + ) + } + + @Test + fun `a pass never runs without a network at all`() { + listOf(true, false).forEach { unmeteredOnly -> + val request = AutoCoverArtWorker.buildRequest(unmeteredOnly) + + // Every album this pass touches costs at least one catalog request, + // so running offline would burn through the library marking albums + // as having no match. + assertEquals( + false, + request.workSpec.constraints.requiredNetworkType == NetworkType.NOT_REQUIRED + ) + } + } +} diff --git a/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/CoverArtStorageRoutingTest.kt b/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/CoverArtStorageRoutingTest.kt new file mode 100644 index 0000000000..27212bc26d --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/CoverArtStorageRoutingTest.kt @@ -0,0 +1,590 @@ +package com.theveloper.pixelplay.presentation.viewmodel + +import android.content.Context +import com.google.common.truth.Truth.assertThat +import com.theveloper.pixelplay.data.coverart.AlbumArtStorage +import com.theveloper.pixelplay.data.coverart.AppArtworkWriter +import com.theveloper.pixelplay.data.database.AlbumArtThemeDao +import com.theveloper.pixelplay.data.media.CoverArtUpdate +import com.theveloper.pixelplay.data.media.ImageCacheManager +import com.theveloper.pixelplay.data.media.SongMetadataEditor +import com.theveloper.pixelplay.data.model.Song +import com.theveloper.pixelplay.data.preferences.UserPreferencesRepository +import com.theveloper.pixelplay.data.repository.MusicRepository +import io.mockk.coVerify +import io.mockk.verify +import io.mockk.unmockkObject +import io.mockk.mockkObject +import io.mockk.coEvery +import com.theveloper.pixelplay.utils.AlbumArtUtils +import com.theveloper.pixelplay.data.media.AudioMetadata +import com.theveloper.pixelplay.data.media.AudioMetadataReader +import com.theveloper.pixelplay.data.media.SongMetadataEditResult +import io.mockk.MockKMatcherScope +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test + +/** + * Where a cover ends up is a decision about the user's own files: one setting + * chooses between rewriting the audio file's tags and keeping the image inside + * the app. Getting it backwards either writes to files the user asked us not to + * touch, or silently stops writing to files they expect us to. + */ +class CoverArtStorageRoutingTest { + + private val songMetadataEditor = mockk(relaxed = true) + private val appArtworkWriter = mockk(relaxed = true) + private val preferences = mockk(relaxed = true) + private val musicRepository = mockk(relaxed = true) + private val libraryStateHolder = mockk(relaxed = true) + + @BeforeEach + fun setUp() { + mockkObject(AlbumArtUtils) + // mockkObject spies rather than stubs, so without this the applied-store + // lookups below would read the real filesystem through a mock Context. + // No applied cover is the default; the tests that care say otherwise. + every { AlbumArtUtils.getAppliedAlbumArtFile(any(), any()) } returns null + } + + @AfterEach + fun tearDown() = unmockkObject(AlbumArtUtils) + + @Test + fun `keeping covers in the app never reaches the file writer`() = runTest { + every { preferences.albumArtStorageFlow } returns flowOf(AlbumArtStorage.APP_ONLY) + + holder().saveBatchMetadata( + songs = listOf(song()), + coverArtUpdate = CoverArtUpdate(bytes = byteArrayOf(1, 2, 3)), + cb = callbacks() + ) + settleRealDispatch() + + coVerify { appArtworkWriter.apply(any(), listOf(11L), any()) } + coVerify(exactly = 0) { + songMetadataEditor.editSongMetadata( + songId = any(), + newTitle = any(), + newArtist = any(), + newAlbum = any(), + newGenre = any(), + newLyrics = any(), + newTrackNumber = any(), + newDiscNumber = any() + ) + } + } + + @Test + fun `choosing audio files does not divert the cover into the app`() = runTest { + every { preferences.albumArtStorageFlow } returns flowOf(AlbumArtStorage.AUDIO_FILES) + + holder().saveBatchMetadata( + songs = listOf(song()), + coverArtUpdate = CoverArtUpdate(bytes = byteArrayOf(1, 2, 3)), + cb = callbacks() + ) + settleRealDispatch() + + // The cover has to stay on the path that embeds it into the file; the + // app store is not a fallback for it. + coVerify(exactly = 0) { appArtworkWriter.apply(any(), any(), any()) } + } + + @Test + fun `deleting a cover is a file edit whatever the storage setting says`() = runTest { + every { preferences.albumArtStorageFlow } returns flowOf(AlbumArtStorage.APP_ONLY) + + holder().saveBatchMetadata( + songs = listOf(song()), + coverArtUpdate = CoverArtUpdate(isDeletion = true), + cb = callbacks() + ) + settleRealDispatch() + + // Removing embedded art means rewriting the file. Writing an app-side + // copy instead would leave the art the user deleted still in the file. + coVerify(exactly = 0) { appArtworkWriter.apply(any(), any(), any()) } + coVerify(exactly = 0) { appArtworkWriter.removeApplied(any(), any()) } + coVerify(exactly = 1) { editorSawDeletionFor(11L) } + } + + @Test + fun `deleting a cover the app is holding never rewrites the audio files`() = runTest { + // The applied cover is the one on screen, so it is the one to remove. + // Handing the deletion to the tag writer strips the file's own artwork + // instead -- the very thing that should come back into view. + every { preferences.albumArtStorageFlow } returns flowOf(AlbumArtStorage.APP_ONLY) + every { AlbumArtUtils.getAppliedAlbumArtFile(any(), any()) } returns mockk(relaxed = true) + + holder().saveBatchMetadata( + songs = listOf(song(id = "11"), song(id = "12")), + coverArtUpdate = CoverArtUpdate(isDeletion = true), + cb = callbacks() + ) + settleRealDispatch() + + coVerify(exactly = 1) { appArtworkWriter.removeApplied(listOf(11L, 12L), 5L) } + coVerify(exactly = 0) { + songMetadataEditor.editSongMetadata( + songId = any(), + newTitle = any(), + newArtist = any(), + newAlbum = any(), + newGenre = any(), + newLyrics = any(), + newTrackNumber = any(), + newDiscNumber = any() + ) + } + } + + @Test + fun `a selection mixing applied and embedded covers is sorted out track by track`() = runTest { + // Only the track whose cover lives in the file has anything for the tag + // writer to remove. Letting the deletion through for the other one + // would take artwork the user never asked to lose. + every { preferences.albumArtStorageFlow } returns flowOf(AlbumArtStorage.APP_ONLY) + every { AlbumArtUtils.getAppliedAlbumArtFile(any(), 11L) } returns mockk(relaxed = true) + every { AlbumArtUtils.getAppliedAlbumArtFile(any(), 12L) } returns null + + holder().saveBatchMetadata( + songs = listOf(song(id = "11"), song(id = "12")), + coverArtUpdate = CoverArtUpdate(isDeletion = true), + cb = callbacks() + ) + settleRealDispatch() + + coVerify(exactly = 1) { appArtworkWriter.removeApplied(listOf(11L), any()) } + coVerify(exactly = 0) { editorSawDeletionFor(11L) } + coVerify(exactly = 1) { editorSawDeletionFor(12L) } + } + + @Test + fun `a tag edit alongside a cover still keeps the cover out of the file`() = runTest { + // APP_ONLY means the cover never touches the file, not "unless + // something else is also being edited": a tag change alongside it falls + // through to the per-song save, which routes the cover there instead. + every { preferences.albumArtStorageFlow } returns flowOf(AlbumArtStorage.APP_ONLY) + + holder().saveBatchMetadata( + songs = listOf(song()), + title = "Renamed", + coverArtUpdate = CoverArtUpdate(bytes = byteArrayOf(1, 2, 3)), + cb = callbacks() + ) + settleRealDispatch() + + coVerify(exactly = 1) { appArtworkWriter.apply(any(), listOf(11L), any()) } + coVerify { + songMetadataEditor.editSongMetadata( + songId = 11L, + newTitle = "Renamed", + newArtist = any(), + newAlbum = any(), + newAlbumArtist = any(), + newComposer = any(), + newGenre = any(), + newLyrics = any(), + newTrackNumber = any(), + newDiscNumber = any(), + newReplayGainTrackGainDb = any(), + newReplayGainAlbumGainDb = any(), + // Not the cover: it went to the app store above, and the "preserve + // existing embedded artwork" fallback only fires when nothing was + // routed anywhere, which is not this case. + coverArtUpdate = null + ) + } + } + + @Test + fun `a tag edit alongside a cover applies it once for the whole album`() = runTest { + // The writer can only see an apply covering a whole album when handed + // every track's id together. Fanned out across the per-song saves it + // never did: tracks drew the new cover while the album kept its old + // art, and the same bytes were re-encoded once per track. + every { preferences.albumArtStorageFlow } returns flowOf(AlbumArtStorage.APP_ONLY) + + holder().saveBatchMetadata( + songs = listOf(song(id = "11"), song(id = "12"), song(id = "13")), + title = "Renamed", + coverArtUpdate = CoverArtUpdate(bytes = byteArrayOf(1, 2, 3)), + cb = callbacks() + ) + settleRealDispatch() + + coVerify(exactly = 1) { appArtworkWriter.apply(any(), listOf(11L, 12L, 13L), 5L) } + coVerify(exactly = 1) { appArtworkWriter.apply(any(), any(), any()) } + } + + @Test + fun `a cover spanning two albums is applied once per album`() = runTest { + // Grouping by album is what lets each row follow. Writing the batch as + // one call would hand the writer ids from two albums and an album id it + // could not name, leaving both rows behind. + every { preferences.albumArtStorageFlow } returns flowOf(AlbumArtStorage.APP_ONLY) + + holder().saveBatchMetadata( + songs = listOf( + song(id = "11", albumId = 5L), + song(id = "12", albumId = 5L), + song(id = "21", albumId = 9L) + ), + title = "Renamed", + coverArtUpdate = CoverArtUpdate(bytes = byteArrayOf(1, 2, 3)), + cb = callbacks() + ) + settleRealDispatch() + + coVerify(exactly = 1) { appArtworkWriter.apply(any(), listOf(11L, 12L), 5L) } + coVerify(exactly = 1) { appArtworkWriter.apply(any(), listOf(21L), 9L) } + coVerify(exactly = 2) { appArtworkWriter.apply(any(), any(), any()) } + } + + @Test + fun `applying a cover to the album does not strip the composer off its tracks`() = runTest { + // An album-wide apply sends nothing but the cover, so composer arrives + // null -- and Song carries none to fall back on. Turned into "" the + // editor reads it as "delete this tag", taking the composer off every + // track of a classical album for a purely visual action. + every { preferences.albumArtStorageFlow } returns flowOf(AlbumArtStorage.AUDIO_FILES) + mockkObject(AudioMetadataReader) + every { AudioMetadataReader.read(any(), readArtwork = false) } returns + audioMetadata(composer = "Gustav Mahler") + + try { + holder().saveBatchMetadata( + songs = listOf(song()), + coverArtUpdate = CoverArtUpdate(bytes = byteArrayOf(1, 2, 3)), + cb = callbacks() + ) + settleRealDispatch() + + coVerify { + songMetadataEditor.editSongMetadata( + songId = 11L, + newTitle = any(), newArtist = any(), newAlbum = any(), + newAlbumArtist = any(), + // The file's own composer, read back and handed straight + // through, rather than the blank that would delete it. + newComposer = "Gustav Mahler", + newGenre = any(), newLyrics = any(), + newTrackNumber = any(), newDiscNumber = any(), + newReplayGainTrackGainDb = any(), newReplayGainAlbumGainDb = any(), + coverArtUpdate = any() + ) + } + } finally { + unmockkObject(AudioMetadataReader) + } + } + + @Test + fun `applying a cover to the album writes each file's own tags back, not the library's`() = runTest { + // The tag write replaces the whole set, so every other field is filled + // in on the way past -- and from Song that means the library's rendering + // over the file's own tags: displayArtist's ", " join over "A; B", a + // filename-derived title over a real one. On every track of the album. + every { preferences.albumArtStorageFlow } returns flowOf(AlbumArtStorage.AUDIO_FILES) + mockkObject(AudioMetadataReader) + every { AudioMetadataReader.read(any(), readArtwork = false) } returns audioMetadata( + title = "Take Five", + artist = "Dave Brubeck; Paul Desmond", + album = "Time Out", + genre = "Jazz", + trackNumber = 3 + ) + val song = song().also { + every { it.title } returns "03 take five" + every { it.displayArtist } returns "Dave Brubeck, Paul Desmond" + every { it.album } returns "Time Out" + every { it.genre } returns "Jazz" + every { it.trackNumber } returns 3 + } + + try { + holder().saveBatchMetadata( + songs = listOf(song), + coverArtUpdate = CoverArtUpdate(bytes = byteArrayOf(1, 2, 3)), + cb = callbacks() + ) + settleRealDispatch() + + coVerify { + songMetadataEditor.editSongMetadata( + songId = 11L, + newTitle = "Take Five", + newArtist = "Dave Brubeck; Paul Desmond", + newAlbum = any(), newAlbumArtist = any(), newComposer = any(), + newGenre = any(), newLyrics = any(), + newTrackNumber = any(), newDiscNumber = any(), + newReplayGainTrackGainDb = any(), newReplayGainAlbumGainDb = any(), + coverArtUpdate = any() + ) + } + } finally { + unmockkObject(AudioMetadataReader) + } + } + + @Test + fun `a save that is not editing lyrics leaves the library's copy of them alone`() = runTest { + // The lyrics handed to the tag write are the file's own, precisely so + // that a cover apply does not embed lyrics this app fetched into its + // own database. Read back as the new value of the field, though, a file + // that carries none would clear the database of the ones it has. + every { preferences.albumArtStorageFlow } returns flowOf(AlbumArtStorage.AUDIO_FILES) + mockkObject(AudioMetadataReader) + every { AudioMetadataReader.read(any(), readArtwork = false) } returns audioMetadata() + coEvery { + songMetadataEditor.editSongMetadata( + songId = any(), newTitle = any(), newArtist = any(), newAlbum = any(), + newAlbumArtist = any(), newComposer = any(), newGenre = any(), newLyrics = any(), + newTrackNumber = any(), newDiscNumber = any(), + newReplayGainTrackGainDb = any(), newReplayGainAlbumGainDb = any(), + coverArtUpdate = any() + ) + } returns SongMetadataEditResult(success = true, updatedAlbumArtUri = null) + every { AlbumArtUtils.clearAppliedArtForSong(any(), any()) } returns Unit + val song = song().also { every { it.lyrics } returns "fetched into the app's database" } + + try { + holder().saveBatchMetadata( + songs = listOf(song), + coverArtUpdate = CoverArtUpdate(bytes = byteArrayOf(1, 2, 3)), + cb = callbacks() + ) + settleRealDispatch() + + coVerify(exactly = 0) { musicRepository.resetLyrics(any()) } + coVerify(exactly = 0) { musicRepository.updateLyrics(any(), any()) } + } finally { + unmockkObject(AudioMetadataReader) + } + } + + @Test + fun `a cloud track in an album of local ones is not told a cover was stored for it`() = runTest { + // The writer refuses a negative id. Answering per album hands that + // refusal back as a success, and the save re-points the track at a local + // artwork URI with no file behind it, replacing a working remote cover. + every { preferences.albumArtStorageFlow } returns flowOf(AlbumArtStorage.APP_ONLY) + val cloudId = "-9000000000001" + coEvery { appArtworkWriter.apply(any(), any(), any()) } returns true + val updated = mutableListOf() + every { libraryStateHolder.updateSong(capture(updated)) } returns Unit + + holder().saveBatchMetadata( + songs = listOf(realSong(id = "11"), realSong(id = cloudId)), + title = "Renamed", + coverArtUpdate = CoverArtUpdate(bytes = byteArrayOf(1, 2, 3)), + cb = callbacks() + ) + settleRealDispatch() + + coVerify(exactly = 1) { appArtworkWriter.apply(any(), listOf(11L), 5L) } + // The local track does follow the cover it was given, so an empty list + // here would be the assertion below passing for the wrong reason. + assertThat(updated.map { it.id }).contains("11") + assertThat(updated.filter { it.id == cloudId }).isEmpty() + } + + @Test + fun `a cover written into the file replaces one the app was holding`() = runTest { + // The applied store answers first, as the most recent thing the user + // chose. Applying under AUDIO_FILES makes the file that instead, so the + // old applied cover has to stand down or it keeps winning. + every { preferences.albumArtStorageFlow } returns flowOf(AlbumArtStorage.AUDIO_FILES) + coEvery { + songMetadataEditor.editSongMetadata( + songId = any(), newTitle = any(), newArtist = any(), newAlbum = any(), + newAlbumArtist = any(), newComposer = any(), newGenre = any(), newLyrics = any(), + newTrackNumber = any(), newDiscNumber = any(), + newReplayGainTrackGainDb = any(), newReplayGainAlbumGainDb = any(), + coverArtUpdate = any() + ) + } returns SongMetadataEditResult(success = true, updatedAlbumArtUri = "content://updated") + every { AlbumArtUtils.clearAppliedArtForSong(any(), any()) } returns Unit + + holder().saveBatchMetadata( + songs = listOf(song()), + coverArtUpdate = CoverArtUpdate(bytes = byteArrayOf(1, 2, 3)), + cb = callbacks() + ) + settleRealDispatch() + + verify { AlbumArtUtils.clearAppliedArtForSong(any(), 11L) } + } + + @Test + fun `re-saving the file's own existing artwork does not disturb an applied cover`() = runTest { + // No new cover was chosen here -- coverArtUpdate is null, so saveMetadata + // preserves whatever the file already has, which is not a decision to + // stop trusting the applied one. + every { preferences.albumArtStorageFlow } returns flowOf(AlbumArtStorage.AUDIO_FILES) + coEvery { + songMetadataEditor.editSongMetadata( + songId = any(), newTitle = any(), newArtist = any(), newAlbum = any(), + newAlbumArtist = any(), newComposer = any(), newGenre = any(), newLyrics = any(), + newTrackNumber = any(), newDiscNumber = any(), + newReplayGainTrackGainDb = any(), newReplayGainAlbumGainDb = any(), + coverArtUpdate = any() + ) + } returns SongMetadataEditResult(success = true, updatedAlbumArtUri = null) + + holder().saveBatchMetadata( + songs = listOf(song()), + title = "Renamed", + coverArtUpdate = null, + cb = callbacks() + ) + settleRealDispatch() + + verify(exactly = 0) { AlbumArtUtils.clearAppliedArtForSong(any(), any()) } + } + + private fun MetadataEditStateHolder.saveBatchMetadata( + songs: List, + coverArtUpdate: CoverArtUpdate?, + cb: MetadataEditCallbacks, + title: String? = null + ) = saveBatchMetadata( + songs = songs, + title = title, + artist = null, + album = null, + albumArtist = null, + composer = null, + genre = null, + lyrics = null, + trackNumber = null, + discNumber = null, + replayGainTrackGainDb = null, + replayGainAlbumGainDb = null, + coverArtUpdate = coverArtUpdate, + cb = cb + ) + + private fun holder() = MetadataEditStateHolder( + songMetadataEditor = songMetadataEditor, + musicRepository = musicRepository, + imageCacheManager = mockk(relaxed = true), + themeStateHolder = mockk(relaxed = true), + playbackStateHolder = mockk(relaxed = true).also { + every { it.stablePlayerState } returns + MutableStateFlow(mockk(relaxed = true)) + }, + libraryStateHolder = libraryStateHolder, + multiSelectionStateHolder = mockk(relaxed = true), + albumArtThemeDao = mockk(relaxed = true), + appArtworkWriter = appArtworkWriter, + userPreferencesRepository = preferences, + context = mockk(relaxed = true) + ) + + private fun kotlinx.coroutines.test.TestScope.callbacks() = MetadataEditCallbacks( + scope = this, + getUiState = { mockk(relaxed = true) }, + updateUiState = {}, + getSelectedSongForInfo = { null }, + setSelectedSongForInfo = {}, + sendToast = {}, + reloadLyricsForCurrentSong = {} + ) + + /** + * saveBatchMetadata's body runs a suspend function under + * withContext(Dispatchers.IO) -- a real dispatcher, outside the test + * scheduler's control. advanceUntilIdle() only drains work scheduled on + * the virtual clock, so a coroutine parked on the real dispatcher can + * resume and re-post its continuation *after* advanceUntilIdle() has + * already returned, landing an assertion before the work it is checking + * for has actually happened. A short bounded wait for the real dispatcher + * to hand the continuation back, redraining the virtual scheduler each + * time, catches that instead of racing it. + */ + private suspend fun TestScope.settleRealDispatch() { + advanceUntilIdle() + repeat(10) { + withContext(Dispatchers.Default) { Thread.sleep(15) } + advanceUntilIdle() + } + } + + private fun audioMetadata( + title: String? = null, + artist: String? = null, + album: String? = null, + genre: String? = null, + composer: String? = null, + lyrics: String? = null, + trackNumber: Int? = null + ) = AudioMetadata( + title = title, artist = artist, albumArtist = null, album = album, + genre = genre, composer = composer, lyrics = lyrics, durationMs = null, + trackNumber = trackNumber, discNumber = null, year = null, bitrate = null, + sampleRate = null, artwork = null + ) + + /** + * A tag write for [songId] carrying the cover deletion, which is the write + * that strips the file's own artwork. Spelled out in full because the + * editor's unnamed parameters would otherwise be matched against their + * declared defaults rather than against anything. + */ + private suspend fun MockKMatcherScope.editorSawDeletionFor(songId: Long): SongMetadataEditResult = + songMetadataEditor.editSongMetadata( + songId = songId, + newTitle = any(), + newArtist = any(), + newAlbum = any(), + newAlbumArtist = any(), + newComposer = any(), + newGenre = any(), + newLyrics = any(), + newTrackNumber = any(), + newDiscNumber = any(), + newReplayGainTrackGainDb = any(), + newReplayGainAlbumGainDb = any(), + coverArtUpdate = matchNullable { it?.isDeletion == true } + ) + + /** + * A real [Song] rather than a mock, for the tests that read what the holder + * wrote back into one: `copy` on a mock yields another mock, so the field + * under test would never carry the value being asserted on. + */ + private fun realSong(id: String, albumId: Long = 5L) = Song( + id = id, + title = "Track", + artist = "Artist", + artistId = 1L, + album = "Album", + albumId = albumId, + path = "/music/track.mp3", + contentUriString = "content://media/external/audio/media/$id", + albumArtUriString = "https://cdn.example/cover.jpg", + duration = 1_000L, + mimeType = "audio/mpeg", + bitrate = null, + sampleRate = null + ) + + private fun song(id: String = "11", albumId: Long = 5L) = mockk(relaxed = true).also { + every { it.id } returns id + every { it.albumId } returns albumId + every { it.path } returns "/music/track.mp3" + } +} diff --git a/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/OnlineCoverArtViewModelTest.kt b/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/OnlineCoverArtViewModelTest.kt new file mode 100644 index 0000000000..e2f3b4a056 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/OnlineCoverArtViewModelTest.kt @@ -0,0 +1,225 @@ +package com.theveloper.pixelplay.presentation.viewmodel + +import com.theveloper.pixelplay.MainCoroutineExtension +import com.theveloper.pixelplay.data.coverart.AlbumArtStorage +import com.theveloper.pixelplay.data.coverart.CoverArtCandidate +import com.theveloper.pixelplay.data.coverart.CoverArtSearchUpdate +import com.theveloper.pixelplay.data.coverart.CoverArtSize +import com.theveloper.pixelplay.data.coverart.CoverArtSource +import com.theveloper.pixelplay.data.preferences.UserPreferencesRepository +import com.theveloper.pixelplay.data.repository.CoverArtSearchRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +/** + * Covers the concurrency bookkeeping in [OnlineCoverArtViewModel] that a snapshot of its UI + * state cannot exercise on its own: cancelling stale work when the picker reopens, and the + * probe / size-merge logic that keeps a streaming search from clobbering results already on + * screen. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(MainCoroutineExtension::class) +class OnlineCoverArtViewModelTest { + + private val repository: CoverArtSearchRepository = mockk(relaxed = true) + private val preferences: UserPreferencesRepository = mockk(relaxed = true) { + every { albumArtStorageFlow } returns flowOf(AlbumArtStorage.APP_ONLY) + } + + private fun viewModel() = OnlineCoverArtViewModel(repository, preferences) + + private fun candidate(id: String, size: CoverArtSize? = null) = CoverArtCandidate( + id = id, + albumTitle = "Album", + artistName = "Artist", + thumbnailUrl = "https://example.com/$id-thumb.jpg", + imageUrl = "https://example.com/$id.jpg", + source = CoverArtSource.ITUNES, + score = 0.9f, + size = size + ) + + @Test + fun `where a cover will be kept survives the picker opening for another album`() = runTest { + every { preferences.albumArtStorageFlow } returns flowOf(AlbumArtStorage.AUDIO_FILES) + coEvery { repository.isWebImageSearchAvailable() } returns false + val viewModel = viewModel() + + viewModel.start(album = "Album", artist = "Artist") + advanceUntilIdle() + // Opening for a different album replaces the search state wholesale, so + // a setting held in it went back to its default and stayed there -- a + // preference only re-emits when it changes. + viewModel.start(album = "Other", artist = "Somebody") + advanceUntilIdle() + + assertEquals(AlbumArtStorage.AUDIO_FILES, viewModel.albumArtStorage.value) + } + + @Test + fun `where a cover will be kept is not claimed before the setting has been read`() = runTest { + every { preferences.albumArtStorageFlow } returns flow { awaitCancellation() } + + // A default would be a statement about whether the user's own files are + // about to be written to, made before anything was read. + assertNull(viewModel().albumArtStorage.value) + } + + @Test + fun `starting the same album and artist again drops a stale download without disturbing the results on screen`() = + runTest { + val found = candidate("c1") + every { repository.searchStreaming("Album", "Artist") } returns + flowOf(CoverArtSearchUpdate(candidates = listOf(found), statuses = emptyList(), isComplete = true)) + coEvery { repository.isWebImageSearchAvailable() } returns false + coEvery { repository.probeSize(any()) } returns null + coEvery { repository.downloadCandidate(found) } coAnswers { awaitCancellation() } + + val vm = viewModel() + vm.start("Album", "Artist") + advanceUntilIdle() + vm.onCandidateSelected(found) + advanceUntilIdle() + assertEquals("c1", vm.uiState.value.downloadingCandidateId) + + // Reopening the picker for the same song: the download that was still + // in flight belongs to a sheet the user already left, not to this one. + vm.start("Album", "Artist") + advanceUntilIdle() + + assertNull(vm.uiState.value.downloadingCandidateId) + assertNull(vm.uiState.value.downloadedUri) + // The search itself was not repeated -- reopening for the same song + // reuses what is already on screen rather than re-querying the catalogs. + assertEquals(listOf(found), vm.uiState.value.candidates) + coVerify(exactly = 1) { repository.searchStreaming("Album", "Artist") } + } + + @Test + fun `starting a different album cancels the search still running for the previous one`() = runTest { + var firstSearchCancelled = false + every { repository.searchStreaming("Album A", "Artist A") } returns flow { + try { + awaitCancellation() + } finally { + firstSearchCancelled = true + } + } + val foundB = candidate("b1") + every { repository.searchStreaming("Album B", "Artist B") } returns + flowOf(CoverArtSearchUpdate(candidates = listOf(foundB), statuses = emptyList(), isComplete = true)) + coEvery { repository.isWebImageSearchAvailable() } returns false + coEvery { repository.probeSize(any()) } returns null + + val vm = viewModel() + vm.start("Album A", "Artist A") + advanceUntilIdle() + + vm.start("Album B", "Artist B") + advanceUntilIdle() + + assertTrue(firstSearchCancelled) + assertEquals(listOf(foundB), vm.uiState.value.candidates) + } + + @Test + fun `a candidate is only probed once even when it reappears in a later snapshot`() = runTest { + val found = candidate("c1") + val updates = Channel(Channel.UNLIMITED) + every { repository.searchStreaming("Album", "Artist") } returns updates.consumeAsFlow() + coEvery { repository.isWebImageSearchAvailable() } returns false + coEvery { repository.probeSize(found) } returns CoverArtSize(500, 500, measured = true) + + val vm = viewModel() + vm.start("Album", "Artist") + updates.send(CoverArtSearchUpdate(candidates = listOf(found), statuses = emptyList(), isComplete = false)) + advanceUntilIdle() + updates.send(CoverArtSearchUpdate(candidates = listOf(found), statuses = emptyList(), isComplete = true)) + advanceUntilIdle() + + coVerify(exactly = 1) { repository.probeSize(found) } + } + + @Test + fun `a later snapshot does not erase a size already measured for a candidate on screen`() = runTest { + val nominal = candidate("c1") + val measured = CoverArtSize(500, 500, measured = true) + val updates = Channel(Channel.UNLIMITED) + every { repository.searchStreaming("Album", "Artist") } returns updates.consumeAsFlow() + coEvery { repository.isWebImageSearchAvailable() } returns false + coEvery { repository.probeSize(nominal) } returns measured + + val vm = viewModel() + vm.start("Album", "Artist") + updates.send(CoverArtSearchUpdate(candidates = listOf(nominal), statuses = emptyList(), isComplete = false)) + advanceUntilIdle() + assertEquals(measured, vm.uiState.value.candidates.single().size) + + // The catalog answers again with the same candidate, reporting no size of + // its own -- the merge must keep the size already measured for it. + updates.send(CoverArtSearchUpdate(candidates = listOf(nominal), statuses = emptyList(), isComplete = true)) + advanceUntilIdle() + + assertEquals(measured, vm.uiState.value.candidates.single().size) + } + + @Test + fun `a web search that failed can be asked for again`() = runTest { + // webSearched hides the action once used, so marking a failure as used + // spent the user's one offer of it on a dropped connection -- a request + // that was never billed against their allowance anyway. + every { repository.searchStreaming("Album", "Artist") } returns + flowOf(CoverArtSearchUpdate(candidates = emptyList(), statuses = emptyList(), isComplete = true)) + coEvery { repository.isWebImageSearchAvailable() } returns true + coEvery { repository.searchWebImages(album = "Album", artist = "Artist") } returns + Result.failure(java.io.IOException("no connectivity")) + + val vm = viewModel() + vm.start("Album", "Artist") + advanceUntilIdle() + + vm.searchWeb() + advanceUntilIdle() + + assertFalse(vm.uiState.value.webSearched) + assertFalse(vm.uiState.value.isSearchingWeb) + assertNotNull(vm.uiState.value.errorRes) + } + + @Test + fun `a web search that succeeded is not offered again`() = runTest { + every { repository.searchStreaming("Album", "Artist") } returns + flowOf(CoverArtSearchUpdate(candidates = emptyList(), statuses = emptyList(), isComplete = true)) + coEvery { repository.isWebImageSearchAvailable() } returns true + coEvery { repository.searchWebImages(album = "Album", artist = "Artist") } returns + Result.success(listOf(candidate("w1"))) + + val vm = viewModel() + vm.start("Album", "Artist") + advanceUntilIdle() + + vm.searchWeb() + advanceUntilIdle() + + assertTrue(vm.uiState.value.webSearched) + assertNull(vm.uiState.value.errorRes) + } +} diff --git a/app/src/test/java/com/theveloper/pixelplay/utils/AlbumArtOrphanSweepTest.kt b/app/src/test/java/com/theveloper/pixelplay/utils/AlbumArtOrphanSweepTest.kt new file mode 100644 index 0000000000..3e7179e06d --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/utils/AlbumArtOrphanSweepTest.kt @@ -0,0 +1,161 @@ +package com.theveloper.pixelplay.utils + +import android.content.Context +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import java.io.File +import kotlin.io.path.createTempDirectory +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +/** + * The sweep that runs after every library scan, against the applied-artwork + * store. + * + * Worth its own file because the two were written apart and only agree by + * convention: the sweep deletes any file it can read a departed song's id out + * of, and applied covers survive only because their names do not parse as one. + * Nothing else states that, so nothing else would notice it stopping being true + * -- and the cost of it stopping being true is every cover the user has chosen, + * on the next scan, with no way back. + */ +class AlbumArtOrphanSweepTest { + + @Test + fun sweep_keepsTheCoversOfSongsStillInTheLibrary() = runTest { + withStore { context -> + AlbumArtUtils.saveAppliedAlbumArt(context, ByteArray(64) { 1 }, listOf(11L, 12L)) + + AlbumArtCacheManager.cleanOrphanedCacheFiles(context, validSongIds = setOf(11L, 12L)) + + assertThat(AlbumArtUtils.getAppliedAlbumArtFile(context, 11L)).isNotNull() + assertThat(AlbumArtUtils.getAppliedAlbumArtFile(context, 12L)).isNotNull() + assertThat(coverFiles(context)).hasSize(1) + } + } + + @Test + fun sweep_keepsTheCoverOfASongMissingFromOneScan() = runTest { + withStore { context -> + val cover = ByteArray(64) { 2 } + AlbumArtUtils.saveAppliedAlbumArt(context, cover, listOf(11L)) + + // An unmounted card, a moved library, a re-index handing out new + // ids: the song is out of the database and back a scan later. The + // extracted cache pays a re-read for guessing wrong here; this pays + // the only copy of the cover. + val deleted = AlbumArtCacheManager.cleanOrphanedCacheFiles( + context, + validSongIds = setOf(99L), + now = NOW + ) + + assertThat(deleted).isEqualTo(0) + assertThat(AlbumArtUtils.getAppliedAlbumArtFile(context, 11L)?.readBytes()).isEqualTo(cover) + } + } + + @Test + fun sweep_dropsTheCoverOfASongGoneSinceLongBeforeTheLastScan() = runTest { + withStore { context -> + AlbumArtUtils.saveAppliedAlbumArt(context, ByteArray(64) { 2 }, listOf(11L)) + + AlbumArtCacheManager.cleanOrphanedCacheFiles(context, validSongIds = setOf(99L), now = NOW) + AlbumArtCacheManager.cleanOrphanedCacheFiles( + context, + validSongIds = setOf(99L), + now = NOW + AlbumArtCacheManager.APPLIED_ORPHAN_GRACE_MS + ) + + assertThat(AlbumArtUtils.getAppliedAlbumArtFile(context, 11L)).isNull() + assertThat(coverFiles(context)).isEmpty() + } + } + + @Test + fun sweep_startsTheClockAgainForASongThatCameBack() = runTest { + withStore { context -> + val cover = ByteArray(64) { 2 } + AlbumArtUtils.saveAppliedAlbumArt(context, cover, listOf(11L)) + + AlbumArtCacheManager.cleanOrphanedCacheFiles(context, validSongIds = setOf(99L), now = NOW) + // Back in the library, so what the first sweep noticed says nothing + // about it any more. + AlbumArtCacheManager.cleanOrphanedCacheFiles(context, validSongIds = setOf(11L), now = NOW + 1_000) + AlbumArtCacheManager.cleanOrphanedCacheFiles( + context, + validSongIds = setOf(99L), + now = NOW + AlbumArtCacheManager.APPLIED_ORPHAN_GRACE_MS + 2_000 + ) + + assertThat(AlbumArtUtils.getAppliedAlbumArtFile(context, 11L)?.readBytes()).isEqualTo(cover) + } + } + + @Test + fun sweep_keepsACoverStillHeldByOneOfItsAlbumMates() = runTest { + withStore { context -> + val cover = ByteArray(64) { 3 } + AlbumArtUtils.saveAppliedAlbumArt(context, cover, listOf(11L, 12L)) + + AlbumArtCacheManager.cleanOrphanedCacheFiles(context, validSongIds = setOf(12L), now = NOW) + AlbumArtCacheManager.cleanOrphanedCacheFiles( + context, + validSongIds = setOf(12L), + now = NOW + AlbumArtCacheManager.APPLIED_ORPHAN_GRACE_MS + ) + + assertThat(AlbumArtUtils.getAppliedAlbumArtFile(context, 11L)).isNull() + assertThat(AlbumArtUtils.getAppliedAlbumArtFile(context, 12L)?.readBytes()).isEqualTo(cover) + } + } + + @Test + fun sweep_refusesAnEmptySetRatherThanTreatingEverythingAsOrphaned() = runTest { + withStore { context -> + val cover = ByteArray(64) { 4 } + AlbumArtUtils.saveAppliedAlbumArt(context, cover, listOf(11L, 12L)) + + // A scan that ran without media permission, before a card mounted, + // or across a failed migration hands over nothing. Trusting it would + // delete the only copy of every cover in the library. + val deleted = AlbumArtCacheManager.cleanOrphanedCacheFiles(context, validSongIds = emptySet()) + + assertThat(deleted).isEqualTo(0) + assertThat(AlbumArtUtils.getAppliedAlbumArtFile(context, 11L)?.readBytes()).isEqualTo(cover) + assertThat(AlbumArtUtils.getAppliedAlbumArtFile(context, 12L)?.readBytes()).isEqualTo(cover) + } + } + + @Test + fun sweep_doesNotReadASongIdOutOfACoverFilename() = runTest { + withStore { context -> + AlbumArtUtils.saveAppliedAlbumArt(context, ByteArray(64) { 5 }, listOf(11L)) + val coverName = coverFiles(context).single().name + + // The sweep deletes by song id, and the cover survives only because + // its name yields none. Named so that one could be read out of it, + // every applied cover in the library goes on the next scan. + assertThat(AlbumArtCacheManager.extractSongIdFromFilename(coverName)).isNull() + } + } + + /** Any fixed point; the sweep only ever reads differences from it. */ + private val NOW = 1_700_000_000_000L + + private inline fun withStore(block: (Context) -> Unit) { + val root = createTempDirectory("orphan-sweep-test").toFile() + try { + block(mockk(relaxed = true).also { every { it.filesDir } returns root }) + } finally { + root.deleteRecursively() + } + } + + private fun coverFiles(context: Context): List = + AlbumArtUtils.getAppliedArtDir(context) + .listFiles { file: File -> file.isFile && file.name.startsWith("cover_") } + ?.toList() + .orEmpty() +} diff --git a/app/src/test/java/com/theveloper/pixelplay/utils/AppliedAlbumArtStoreTest.kt b/app/src/test/java/com/theveloper/pixelplay/utils/AppliedAlbumArtStoreTest.kt new file mode 100644 index 0000000000..3d9766da66 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/utils/AppliedAlbumArtStoreTest.kt @@ -0,0 +1,245 @@ +package com.theveloper.pixelplay.utils + +import android.content.Context +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import java.io.File +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import kotlin.io.path.createTempDirectory +import org.junit.jupiter.api.Test + +/** + * Exercises the applied-artwork store against a real filesystem, because what + * is worth pinning is what the files end up being: a cover every track of an + * album resolves to, stored once, with tracks that stay independent of each + * other and nothing left behind when one is replaced. + */ +class AppliedAlbumArtStoreTest { + + @Test + fun saveAppliedAlbumArt_givesEveryTrackOfTheAlbumTheCover() { + val root = createTempDirectory("applied-art-test").toFile() + val context = contextWith(root) + val cover = ByteArray(64) { it.toByte() } + + AlbumArtUtils.saveAppliedAlbumArt(context, cover, listOf(11L, 12L, 13L)) + + listOf(11L, 12L, 13L).forEach { songId -> + val file = AlbumArtUtils.getAppliedAlbumArtFile(context, songId) + assertThat(file).isNotNull() + assertThat(file!!.readBytes()).isEqualTo(cover) + } + root.deleteRecursively() + } + + @Test + fun saveAppliedAlbumArt_storesOneImageForTheWholeAlbum() { + val root = createTempDirectory("applied-art-test").toFile() + val context = contextWith(root) + + AlbumArtUtils.saveAppliedAlbumArt(context, ByteArray(64) { 3 }, (1L..20L).toList()) + + assertThat(coverFiles(context)).hasSize(1) + root.deleteRecursively() + } + + @Test + fun saveAppliedAlbumArt_replacingOneTrackLeavesTheRestOfTheAlbumAlone() { + val root = createTempDirectory("applied-art-test").toFile() + val context = contextWith(root) + val albumCover = ByteArray(64) { 1 } + val trackCover = ByteArray(64) { 2 } + AlbumArtUtils.saveAppliedAlbumArt(context, albumCover, listOf(11L, 12L, 13L)) + + AlbumArtUtils.saveAppliedAlbumArt(context, trackCover, listOf(12L)) + + assertThat(appliedBytes(context, 12L)).isEqualTo(trackCover) + assertThat(appliedBytes(context, 11L)).isEqualTo(albumCover) + assertThat(appliedBytes(context, 13L)).isEqualTo(albumCover) + root.deleteRecursively() + } + + @Test + fun saveAppliedAlbumArt_replacingTheAlbumsCoverReplacesItForEveryTrack() { + val root = createTempDirectory("applied-art-test").toFile() + val context = contextWith(root) + AlbumArtUtils.saveAppliedAlbumArt(context, ByteArray(64) { 1 }, listOf(11L, 12L)) + val replacement = ByteArray(64) { 9 } + + AlbumArtUtils.saveAppliedAlbumArt(context, replacement, listOf(11L, 12L)) + + listOf(11L, 12L).forEach { songId -> + assertThat(appliedBytes(context, songId)).isEqualTo(replacement) + } + root.deleteRecursively() + } + + @Test + fun saveAppliedAlbumArt_replacingTheAlbumsCoverLeavesTheOldImageBehindNowhere() { + val root = createTempDirectory("applied-art-test").toFile() + val context = contextWith(root) + AlbumArtUtils.saveAppliedAlbumArt(context, ByteArray(64) { 1 }, listOf(11L, 12L)) + + AlbumArtUtils.saveAppliedAlbumArt(context, ByteArray(64) { 9 }, listOf(11L, 12L)) + + assertThat(coverFiles(context)).hasSize(1) + root.deleteRecursively() + } + + @Test + fun saveAppliedAlbumArt_reapplyingTheSameCoverRewritesTheSameFile() { + val root = createTempDirectory("applied-art-test").toFile() + val context = contextWith(root) + val cover = ByteArray(64) { 4 } + AlbumArtUtils.saveAppliedAlbumArt(context, cover, listOf(11L, 12L)) + val firstNames = coverFiles(context).map { it.name } + + AlbumArtUtils.saveAppliedAlbumArt(context, cover, listOf(11L, 12L)) + + assertThat(coverFiles(context).map { it.name }).isEqualTo(firstNames) + root.deleteRecursively() + } + + @Test + fun clearCacheForSong_leavesAppliedArtWhereItIs() { + val root = createTempDirectory("applied-art-test").toFile() + val context = contextWith(root) + val cover = ByteArray(64) { 7 } + AlbumArtUtils.saveAppliedAlbumArt(context, cover, listOf(11L, 12L)) + + // Every metadata save invalidates artwork and lands here. Everything it + // drops can be read back out of the audio file -- an applied cover + // cannot, so it must survive an edit that had nothing to do with it. + AlbumArtUtils.clearCacheForSong(context, 11L) + + assertThat(appliedBytes(context, 11L)).isEqualTo(cover) + assertThat(appliedBytes(context, 12L)).isEqualTo(cover) + assertThat(coverFiles(context)).hasSize(1) + root.deleteRecursively() + } + + @Test + fun clearAppliedArtForSong_dropsOneTrackWithoutTouchingTheRestOfTheAlbum() { + val root = createTempDirectory("applied-art-test").toFile() + val context = contextWith(root) + val cover = ByteArray(64) { 7 } + AlbumArtUtils.saveAppliedAlbumArt(context, cover, listOf(11L, 12L)) + + AlbumArtUtils.clearAppliedArtForSong(context, 11L) + + assertThat(AlbumArtUtils.getAppliedAlbumArtFile(context, 11L)).isNull() + assertThat(appliedBytes(context, 12L)).isEqualTo(cover) + root.deleteRecursively() + } + + @Test + fun clearAppliedArtForSong_removesTheImageOnceTheLastTrackIsGone() { + val root = createTempDirectory("applied-art-test").toFile() + val context = contextWith(root) + AlbumArtUtils.saveAppliedAlbumArt(context, ByteArray(64) { 7 }, listOf(11L, 12L)) + + // The collection is handed off rather than run on the caller's thread, + // so the image goes when the sweep gets to it, not when the call returns. + AlbumArtUtils.clearAppliedArtForSong(context, 11L) + assertThat(AlbumArtUtils.getAppliedAlbumArtFile(context, 11L)).isNull() + assertThat(appliedBytes(context, 12L)).isNotNull() + + AlbumArtUtils.clearAppliedArtForSong(context, 12L) + assertThat(eventually { coverFiles(context).isEmpty() }).isTrue() + root.deleteRecursively() + } + + @Test + fun saveAppliedAlbumArt_writesNothingForEmptyBytes() { + val root = createTempDirectory("applied-art-test").toFile() + val context = contextWith(root) + + val written = AlbumArtUtils.saveAppliedAlbumArt(context, ByteArray(0), listOf(11L)) + + assertThat(written).isNull() + assertThat(AlbumArtUtils.getAppliedAlbumArtFile(context, 11L)).isNull() + root.deleteRecursively() + } + + @Test + fun deleteUnreferencedAppliedCovers_dropsAPointerWhoseImageIsGone() { + val root = createTempDirectory("applied-art-test").toFile() + val context = contextWith(root) + AlbumArtUtils.saveAppliedAlbumArt(context, ByteArray(64) { 3 }, listOf(11L)) + coverFiles(context).forEach { it.delete() } + + AlbumArtUtils.deleteUnreferencedAppliedCovers(context) + + // Left in place, the pointer would adopt whatever was written under that + // key next -- which is the same cover for anyone applying the same image. + val pointers = AlbumArtUtils.getAppliedArtDir(context) + .listFiles { file: File -> file.name.endsWith(".ref") } + .orEmpty() + assertThat(pointers).isEmpty() + root.deleteRecursively() + } + + @Test + fun saveAppliedAlbumArt_keepsTracksApartWhenTheyBelongToNoCommonAlbum() { + val root = createTempDirectory("applied-art-test").toFile() + val context = contextWith(root) + val first = ByteArray(64) { 1 } + val second = ByteArray(64) { 2 } + + AlbumArtUtils.saveAppliedAlbumArt(context, first, listOf(11L)) + AlbumArtUtils.saveAppliedAlbumArt(context, second, listOf(12L)) + + assertThat(appliedBytes(context, 11L)).isEqualTo(first) + assertThat(appliedBytes(context, 12L)).isEqualTo(second) + root.deleteRecursively() + } + + @Test + fun getAppliedAlbumArtFile_isNullWhenTheImageWentMissingUnderTheSong() { + val root = createTempDirectory("applied-art-test").toFile() + val context = contextWith(root) + AlbumArtUtils.saveAppliedAlbumArt(context, ByteArray(64) { 5 }, listOf(11L)) + + coverFiles(context).forEach { it.delete() } + + assertThat(AlbumArtUtils.getAppliedAlbumArtFile(context, 11L)).isNull() + root.deleteRecursively() + } + + @Test + fun saveAppliedAlbumArt_writesNothingForAnEmptySelection() { + val root = createTempDirectory("applied-art-test").toFile() + val context = contextWith(root) + + val written = AlbumArtUtils.saveAppliedAlbumArt(context, ByteArray(8), emptyList()) + + assertThat(written).isNull() + assertThat(AlbumArtUtils.getAppliedArtDir(context).listFiles()).isEmpty() + root.deleteRecursively() + } + + /** Polls [condition] briefly, for the collection that runs off-thread. */ + private fun eventually(timeoutMs: Long = 2_000, condition: () -> Boolean): Boolean { + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + if (condition()) return true + Thread.sleep(10) + } + return condition() + } + + private fun appliedBytes(context: Context, songId: Long): ByteArray? = + AlbumArtUtils.getAppliedAlbumArtFile(context, songId)?.readBytes() + + private fun coverFiles(context: Context): List = + AlbumArtUtils.getAppliedArtDir(context) + .listFiles { file: File -> file.isFile && file.name.startsWith("cover_") } + ?.toList() + .orEmpty() + + private fun contextWith(filesDir: File): Context = mockk(relaxed = true).also { + every { it.filesDir } returns filesDir + } +}