image: complete per-song album covers

- Implement perceptual hashing algorithm to efficiently cache images
- Efficiently pre-sort cover sources to make cover images load without
freezing and look more pleasing

Resolbes #342.
This commit is contained in:
Alexander Capehart 2024-04-20 14:30:10 -06:00
parent bd330f0c71
commit 51406deaa7
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
15 changed files with 205 additions and 112 deletions

View file

@ -94,7 +94,7 @@ constructor(
target target
.onConfigRequest( .onConfigRequest(
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(listOf(song)) .data(listOf(song.cover))
// Use ORIGINAL sizing, as we are not loading into any View-like component. // Use ORIGINAL sizing, as we are not loading into any View-like component.
.size(Size.ORIGINAL)) .size(Size.ORIGINAL))
.target( .target(

View file

@ -48,6 +48,7 @@ import com.google.android.material.shape.MaterialShapeDrawable
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.image.extractor.Cover
import org.oxycblt.auxio.image.extractor.RoundedRectTransformation import org.oxycblt.auxio.image.extractor.RoundedRectTransformation
import org.oxycblt.auxio.image.extractor.SquareCropTransformation import org.oxycblt.auxio.image.extractor.SquareCropTransformation
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
@ -101,14 +102,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
private val indicatorMatrixSrc = RectF() private val indicatorMatrixSrc = RectF()
private val indicatorMatrixDst = RectF() private val indicatorMatrixDst = RectF()
private data class Cover(
val songs: Collection<Song>,
val desc: String,
@DrawableRes val errorRes: Int
)
private var currentCover: Cover? = null
init { init {
// Obtain some StyledImageView attributes to use later when theming the custom view. // Obtain some StyledImageView attributes to use later when theming the custom view.
@SuppressLint("CustomViewStyleable") @SuppressLint("CustomViewStyleable")
@ -342,8 +335,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
* @param song The [Song] to bind to the view. * @param song The [Song] to bind to the view.
*/ */
fun bind(song: Song) = fun bind(song: Song) =
bind( bindImpl(
listOf(song), listOf(song.cover),
context.getString(R.string.desc_album_cover, song.album.name), context.getString(R.string.desc_album_cover, song.album.name),
R.drawable.ic_album_24) R.drawable.ic_album_24)
@ -353,8 +346,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
* @param album The [Album] to bind to the view. * @param album The [Album] to bind to the view.
*/ */
fun bind(album: Album) = fun bind(album: Album) =
bind( bindImpl(
album.songs, album.cover.all,
context.getString(R.string.desc_album_cover, album.name), context.getString(R.string.desc_album_cover, album.name),
R.drawable.ic_album_24) R.drawable.ic_album_24)
@ -364,8 +357,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
* @param artist The [Artist] to bind to the view. * @param artist The [Artist] to bind to the view.
*/ */
fun bind(artist: Artist) = fun bind(artist: Artist) =
bind( bindImpl(
artist.songs, artist.cover.all,
context.getString(R.string.desc_artist_image, artist.name), context.getString(R.string.desc_artist_image, artist.name),
R.drawable.ic_artist_24) R.drawable.ic_artist_24)
@ -375,8 +368,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
* @param genre The [Genre] to bind to the view. * @param genre The [Genre] to bind to the view.
*/ */
fun bind(genre: Genre) = fun bind(genre: Genre) =
bind( bindImpl(
genre.songs, genre.cover.all,
context.getString(R.string.desc_genre_image, genre.name), context.getString(R.string.desc_genre_image, genre.name),
R.drawable.ic_genre_24) R.drawable.ic_genre_24)
@ -386,8 +379,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
* @param playlist the [Playlist] to bind. * @param playlist the [Playlist] to bind.
*/ */
fun bind(playlist: Playlist) = fun bind(playlist: Playlist) =
bind( bindImpl(
playlist.songs, playlist.cover?.all ?: emptyList(),
context.getString(R.string.desc_playlist_image, playlist.name), context.getString(R.string.desc_playlist_image, playlist.name),
R.drawable.ic_playlist_24) R.drawable.ic_playlist_24)
@ -398,10 +391,13 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
* @param desc The content description to describe the bound data. * @param desc The content description to describe the bound data.
* @param errorRes The resource of the error drawable to use if the cover cannot be loaded. * @param errorRes The resource of the error drawable to use if the cover cannot be loaded.
*/ */
fun bind(songs: Collection<Song>, desc: String, @DrawableRes errorRes: Int) { fun bind(songs: List<Song>, desc: String, @DrawableRes errorRes: Int) =
bindImpl(Cover.order(songs), desc, errorRes)
private fun bindImpl(covers: List<Cover>, desc: String, @DrawableRes errorRes: Int) {
val request = val request =
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(songs) .data(covers)
.error(StyledDrawable(context, context.getDrawableCompat(errorRes), iconSizeRes)) .error(StyledDrawable(context, context.getDrawableCompat(errorRes), iconSizeRes))
.target(image) .target(image)
@ -417,7 +413,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
CoilUtils.dispose(image) CoilUtils.dispose(image)
imageLoader.enqueue(request.build()) imageLoader.enqueue(request.build())
contentDescription = desc contentDescription = desc
currentCover = Cover(songs, desc, errorRes)
} }
/** /**

View file

@ -24,25 +24,23 @@ import coil.key.Keyer
import coil.request.Options import coil.request.Options
import coil.size.Size import coil.size.Size
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.music.Song
class SongKeyer @Inject constructor(private val coverExtractor: CoverExtractor) : class CoverKeyer @Inject constructor() : Keyer<Collection<Cover>> {
Keyer<Collection<Song>> { override fun key(data: Collection<Cover>, options: Options) =
override fun key(data: Collection<Song>, options: Options) = "${data.map { it.perceptualHash }.hashCode()}"
"${coverExtractor.computeCoverOrdering(data).hashCode()}"
} }
class SongCoverFetcher class CoverFetcher
private constructor( private constructor(
private val songs: Collection<Song>, private val covers: Collection<Cover>,
private val size: Size, private val size: Size,
private val coverExtractor: CoverExtractor, private val coverExtractor: CoverExtractor,
) : Fetcher { ) : Fetcher {
override suspend fun fetch() = coverExtractor.extract(songs, size) override suspend fun fetch() = coverExtractor.extract(covers, size)
class Factory @Inject constructor(private val coverExtractor: CoverExtractor) : class Factory @Inject constructor(private val coverExtractor: CoverExtractor) :
Fetcher.Factory<Collection<Song>> { Fetcher.Factory<Collection<Cover>> {
override fun create(data: Collection<Song>, options: Options, imageLoader: ImageLoader) = override fun create(data: Collection<Cover>, options: Options, imageLoader: ImageLoader) =
SongCoverFetcher(data, options.size, coverExtractor) CoverFetcher(data, options.size, coverExtractor)
} }
} }

View file

@ -19,6 +19,8 @@
package org.oxycblt.auxio.image.extractor package org.oxycblt.auxio.image.extractor
import android.net.Uri import android.net.Uri
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Song
/** /**
* Bundle of [Uri] information used in [CoverExtractor] to ensure consistent [Uri] use when loading * Bundle of [Uri] information used in [CoverExtractor] to ensure consistent [Uri] use when loading
@ -29,4 +31,24 @@ import android.net.Uri
* an album cover. * an album cover.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
data class Cover(val perceptualHash: String?, val mediaStoreUri: Uri, val songUri: Uri) data class Cover(val perceptualHash: String?, val mediaStoreUri: Uri, val songUri: Uri) {
companion object {
private val FALLBACK_SORT = Sort(Sort.Mode.ByAlbum, Sort.Direction.ASCENDING)
fun order(songs: Collection<Song>) =
FALLBACK_SORT.songs(songs)
.map { it.cover }
.groupBy { it.perceptualHash }
.entries
.sortedByDescending { it.value.size }
.map { it.value.first() }
}
}
data class ParentCover(val single: Cover, val all: List<Cover>) {
companion object {
fun from(song: Song, songs: Collection<Song>) = from(song.cover, songs)
fun from(src: Cover, songs: Collection<Song>) = ParentCover(src, Cover.order(songs))
}
}

View file

@ -27,6 +27,7 @@ import android.util.Size as AndroidSize
import androidx.core.graphics.drawable.toDrawable import androidx.core.graphics.drawable.toDrawable
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
import androidx.media3.common.Metadata
import androidx.media3.exoplayer.MetadataRetriever import androidx.media3.exoplayer.MetadataRetriever
import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.extractor.metadata.flac.PictureFrame import androidx.media3.extractor.metadata.flac.PictureFrame
@ -50,8 +51,6 @@ import okio.buffer
import okio.source import okio.source
import org.oxycblt.auxio.image.CoverMode import org.oxycblt.auxio.image.CoverMode
import org.oxycblt.auxio.image.ImageSettings import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logE
@ -70,14 +69,13 @@ constructor(
/** /**
* Extract an image (in the form of [FetchResult]) to represent the given [Song]s. * Extract an image (in the form of [FetchResult]) to represent the given [Song]s.
* *
* @param songs The [Song]s to load. * @param covers The [Cover]s to load.
* @param size The [Size] of the image to load. * @param size The [Size] of the image to load.
* @return If four distinct album covers could be extracted from the [Song]s, a [DrawableResult] * @return If four distinct album covers could be extracted from the [Song]s, a [DrawableResult]
* will be returned of a mosaic composed of four album covers ordered by * will be returned of a mosaic composed of four album covers ordered by
* [computeCoverOrdering]. Otherwise, a [SourceResult] of one album cover will be returned. * [computeCoverOrdering]. Otherwise, a [SourceResult] of one album cover will be returned.
*/ */
suspend fun extract(songs: Collection<Song>, size: Size): FetchResult? { suspend fun extract(covers: Collection<Cover>, size: Size): FetchResult? {
val covers = computeCoverOrdering(songs)
val streams = mutableListOf<InputStream>() val streams = mutableListOf<InputStream>()
for (cover in covers) { for (cover in covers) {
openCoverInputStream(cover)?.let(streams::add) openCoverInputStream(cover)?.let(streams::add)
@ -108,19 +106,37 @@ constructor(
dataSource = DataSource.DISK) dataSource = DataSource.DISK)
} }
/** fun findCoverDataInMetadata(metadata: Metadata): InputStream? {
* Creates an [Album] list representing the order that album covers would be used in [extract]. var stream: ByteArrayInputStream? = null
*
* @param songs A hypothetical list of [Song]s that would be used in [extract]. for (i in 0 until metadata.length()) {
* @return A list of [Album]s first ordered by the "representation" within the [Song]s, and then // We can only extract pictures from two tags with this method, ID3v2's APIC or
* by their names. "Representation" is defined by how many [Song]s were found to be linked to // Vorbis picture comments.
* the given [Album] in the given [Song] list. val pic: ByteArray?
*/ val type: Int
fun computeCoverOrdering(songs: Collection<Song>) =
Sort(Sort.Mode.ByAlbum, Sort.Direction.ASCENDING) when (val entry = metadata.get(i)) {
.songs(songs) is ApicFrame -> {
.distinctBy { (it.cover.perceptualHash ?: it.uri).toString() } pic = entry.pictureData
.map { it.cover } type = entry.pictureType
}
is PictureFrame -> {
pic = entry.pictureData
type = entry.pictureType
}
else -> continue
}
if (type == MediaMetadata.PICTURE_TYPE_FRONT_COVER) {
stream = ByteArrayInputStream(pic)
break
} else if (stream == null) {
stream = ByteArrayInputStream(pic)
}
}
return stream
}
private suspend fun openCoverInputStream(cover: Cover) = private suspend fun openCoverInputStream(cover: Cover) =
try { try {
@ -165,35 +181,7 @@ constructor(
return null return null
} }
var stream: ByteArrayInputStream? = null return findCoverDataInMetadata(metadata)
for (i in 0 until metadata.length()) {
// We can only extract pictures from two tags with this method, ID3v2's APIC or
// Vorbis picture comments.
val pic: ByteArray?
val type: Int
when (val entry = metadata.get(i)) {
is ApicFrame -> {
pic = entry.pictureData
type = entry.pictureType
}
is PictureFrame -> {
pic = entry.pictureData
type = entry.pictureType
}
else -> continue
}
if (type == MediaMetadata.PICTURE_TYPE_FRONT_COVER) {
stream = ByteArrayInputStream(pic)
break
} else if (stream == null) {
stream = ByteArrayInputStream(pic)
}
}
return stream
} }
private suspend fun extractMediaStoreCover(cover: Cover) = private suspend fun extractMediaStoreCover(cover: Cover) =

View file

@ -0,0 +1,59 @@
/*
* Copyright (c) 2024 Auxio Project
* DHash.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.extractor
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorMatrix
import android.graphics.ColorMatrixColorFilter
import android.graphics.Paint
import java.math.BigInteger
fun Bitmap.dHash(hashSize: Int = 16): String {
// Step 1: Resize the bitmap to a fixed size
val resizedBitmap = Bitmap.createScaledBitmap(this, hashSize + 1, hashSize, true)
// Step 2: Convert the bitmap to grayscale
val grayBitmap =
Bitmap.createBitmap(resizedBitmap.width, resizedBitmap.height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(grayBitmap)
val paint = Paint()
val colorMatrix = ColorMatrix()
colorMatrix.setSaturation(0f)
val filter = ColorMatrixColorFilter(colorMatrix)
paint.colorFilter = filter
canvas.drawBitmap(resizedBitmap, 0f, 0f, paint)
// Step 3: Compute the difference between adjacent pixels
var hash = BigInteger.valueOf(0)
val one = BigInteger.valueOf(1)
for (y in 0 until hashSize) {
for (x in 0 until hashSize) {
val pixel1 = grayBitmap.getPixel(x, y)
val pixel2 = grayBitmap.getPixel(x + 1, y)
val diff = Color.red(pixel1) - Color.red(pixel2)
if (diff > 0) {
hash = hash.or(one.shl(y * hashSize + x))
}
}
}
return hash.toString(16)
}

View file

@ -35,14 +35,14 @@ class ExtractorModule {
@Provides @Provides
fun imageLoader( fun imageLoader(
@ApplicationContext context: Context, @ApplicationContext context: Context,
songKeyer: SongKeyer, keyer: CoverKeyer,
songFactory: SongCoverFetcher.Factory factory: CoverFetcher.Factory
) = ) =
ImageLoader.Builder(context) ImageLoader.Builder(context)
.components { .components {
// Add fetchers for Music components to make them usable with ImageRequest // Add fetchers for Music components to make them usable with ImageRequest
add(songKeyer) add(keyer)
add(songFactory) add(factory)
} }
// Use our own crossfade with error drawable support // Use our own crossfade with error drawable support
.transitionFactory(ErrorCrossfadeTransitionFactory()) .transitionFactory(ErrorCrossfadeTransitionFactory())

View file

@ -1,6 +1,6 @@
/* /*
* Copyright (c) 2024 Auxio Project * Copyright (c) 2024 Auxio Project
* CoilBitmapLoader.kt is part of Auxio. * MediaSessionBitmapLoader.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -31,7 +31,7 @@ import com.google.common.util.concurrent.SettableFuture
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.image.BitmapProvider import org.oxycblt.auxio.image.BitmapProvider
import org.oxycblt.auxio.image.extractor.SongKeyer import org.oxycblt.auxio.image.extractor.CoverKeyer
import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.service.MediaSessionUID import org.oxycblt.auxio.music.service.MediaSessionUID
@ -41,7 +41,7 @@ constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val musicRepository: MusicRepository, private val musicRepository: MusicRepository,
private val bitmapProvider: BitmapProvider, private val bitmapProvider: BitmapProvider,
private val songKeyer: SongKeyer, private val keyer: CoverKeyer,
private val imageLoader: ImageLoader, private val imageLoader: ImageLoader,
) : BitmapLoader { ) : BitmapLoader {
override fun decodeBitmap(data: ByteArray): ListenableFuture<Bitmap> { override fun decodeBitmap(data: ByteArray): ListenableFuture<Bitmap> {
@ -69,7 +69,7 @@ constructor(
?: return null ?: return null
// Even launching a coroutine to obtained cached covers is enough to make the notification // Even launching a coroutine to obtained cached covers is enough to make the notification
// go without covers. // go without covers.
val key = songKeyer.key(listOf(song), Options(context)) val key = keyer.key(listOf(song.cover), Options(context))
if (imageLoader.memoryCache?.get(MemoryCache.Key(key)) != null) { if (imageLoader.memoryCache?.get(MemoryCache.Key(key)) != null) {
future.set(imageLoader.memoryCache?.get(MemoryCache.Key(key))?.bitmap) future.set(imageLoader.memoryCache?.get(MemoryCache.Key(key))?.bitmap)
return future return future

View file

@ -28,6 +28,7 @@ import kotlin.math.max
import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.oxycblt.auxio.image.extractor.Cover import org.oxycblt.auxio.image.extractor.Cover
import org.oxycblt.auxio.image.extractor.ParentCover
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.fs.MimeType import org.oxycblt.auxio.music.fs.MimeType
import org.oxycblt.auxio.music.fs.Path import org.oxycblt.auxio.music.fs.Path
@ -296,7 +297,7 @@ interface Album : MusicParent {
*/ */
val releaseType: ReleaseType val releaseType: ReleaseType
/** Cover information from the template song used for the album. */ /** Cover information from the template song used for the album. */
val cover: Cover val cover: ParentCover
/** The duration of all songs in the album, in milliseconds. */ /** The duration of all songs in the album, in milliseconds. */
val durationMs: Long val durationMs: Long
/** The earliest date a song in this album was added, as a unix epoch timestamp. */ /** The earliest date a song in this album was added, as a unix epoch timestamp. */
@ -326,7 +327,7 @@ interface Artist : MusicParent {
*/ */
val durationMs: Long? val durationMs: Long?
/** Useful information to quickly obtain a (single) cover for a Genre. */ /** Useful information to quickly obtain a (single) cover for a Genre. */
val cover: Cover val cover: ParentCover
/** The [Genre]s of this artist. */ /** The [Genre]s of this artist. */
val genres: List<Genre> val genres: List<Genre>
} }
@ -342,7 +343,7 @@ interface Genre : MusicParent {
/** The total duration of the songs in this genre, in milliseconds. */ /** The total duration of the songs in this genre, in milliseconds. */
val durationMs: Long val durationMs: Long
/** Useful information to quickly obtain a (single) cover for a Genre. */ /** Useful information to quickly obtain a (single) cover for a Genre. */
val cover: Cover val cover: ParentCover
} }
/** /**
@ -356,7 +357,7 @@ interface Playlist : MusicParent {
/** The total duration of the songs in this genre, in milliseconds. */ /** The total duration of the songs in this genre, in milliseconds. */
val durationMs: Long val durationMs: Long
/** Useful information to quickly obtain a (single) cover for a Genre. */ /** Useful information to quickly obtain a (single) cover for a Genre. */
val cover: Cover? val cover: ParentCover?
} }
/** /**

View file

@ -32,7 +32,7 @@ import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.metadata.correctWhitespace import org.oxycblt.auxio.music.metadata.correctWhitespace
import org.oxycblt.auxio.music.metadata.splitEscaped import org.oxycblt.auxio.music.metadata.splitEscaped
@Database(entities = [CachedSong::class], version = 42, exportSchema = false) @Database(entities = [CachedSong::class], version = 45, exportSchema = false)
abstract class CacheDatabase : RoomDatabase() { abstract class CacheDatabase : RoomDatabase() {
abstract fun cachedSongsDao(): CachedSongsDao abstract fun cachedSongsDao(): CachedSongsDao
} }
@ -80,6 +80,8 @@ data class CachedSong(
var subtitle: String? = null, var subtitle: String? = null,
/** @see RawSong.date */ /** @see RawSong.date */
var date: Date? = null, var date: Date? = null,
/** @see RawSong.coverPerceptualHash */
var coverPerceptualHash: String? = null,
/** @see RawSong.albumMusicBrainzId */ /** @see RawSong.albumMusicBrainzId */
var albumMusicBrainzId: String? = null, var albumMusicBrainzId: String? = null,
/** @see RawSong.albumName */ /** @see RawSong.albumName */
@ -119,6 +121,8 @@ data class CachedSong(
rawSong.subtitle = subtitle rawSong.subtitle = subtitle
rawSong.date = date rawSong.date = date
rawSong.coverPerceptualHash = coverPerceptualHash
rawSong.albumMusicBrainzId = albumMusicBrainzId rawSong.albumMusicBrainzId = albumMusicBrainzId
rawSong.albumName = albumName rawSong.albumName = albumName
rawSong.albumSortName = albumSortName rawSong.albumSortName = albumSortName
@ -167,6 +171,7 @@ data class CachedSong(
disc = rawSong.disc, disc = rawSong.disc,
subtitle = rawSong.subtitle, subtitle = rawSong.subtitle,
date = rawSong.date, date = rawSong.date,
coverPerceptualHash = rawSong.coverPerceptualHash,
albumMusicBrainzId = rawSong.albumMusicBrainzId, albumMusicBrainzId = rawSong.albumMusicBrainzId,
albumName = requireNotNull(rawSong.albumName) { "Invalid raw: No album name" }, albumName = requireNotNull(rawSong.albumName) { "Invalid raw: No album name" },
albumSortName = rawSong.albumSortName, albumSortName = rawSong.albumSortName,

View file

@ -20,6 +20,7 @@ package org.oxycblt.auxio.music.device
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.image.extractor.Cover import org.oxycblt.auxio.image.extractor.Cover
import org.oxycblt.auxio.image.extractor.ParentCover
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
@ -112,7 +113,8 @@ class SongImpl(
override val genres: List<Genre> override val genres: List<Genre>
get() = _genres get() = _genres
override val cover = Cover("", requireNotNull(rawSong.mediaStoreId).toCoverUri(), uri) override val cover =
Cover(rawSong.coverPerceptualHash, requireNotNull(rawSong.mediaStoreId).toCoverUri(), uri)
/** /**
* The [RawAlbum] instances collated by the [Song]. This can be used to group [Song]s into an * The [RawAlbum] instances collated by the [Song]. This can be used to group [Song]s into an
@ -295,7 +297,7 @@ class AlbumImpl(
override val releaseType = rawAlbum.releaseType ?: ReleaseType.Album(null) override val releaseType = rawAlbum.releaseType ?: ReleaseType.Album(null)
override val durationMs: Long override val durationMs: Long
override val dateAdded: Long override val dateAdded: Long
override val cover = grouping.raw.src.cover override val cover: ParentCover
private val _artists = mutableListOf<ArtistImpl>() private val _artists = mutableListOf<ArtistImpl>()
override val artists: List<Artist> override val artists: List<Artist>
@ -339,6 +341,8 @@ class AlbumImpl(
durationMs = totalDuration durationMs = totalDuration
dateAdded = earliestDateAdded dateAdded = earliestDateAdded
cover = ParentCover.from(grouping.raw.src.cover, songs)
hashCode = 31 * hashCode + rawAlbum.hashCode() hashCode = 31 * hashCode + rawAlbum.hashCode()
hashCode = 31 * hashCode + nameFactory.hashCode() hashCode = 31 * hashCode + nameFactory.hashCode()
hashCode = 31 * hashCode + songs.hashCode() hashCode = 31 * hashCode + songs.hashCode()
@ -421,12 +425,7 @@ class ArtistImpl(
override val explicitAlbums: Set<Album> override val explicitAlbums: Set<Album>
override val implicitAlbums: Set<Album> override val implicitAlbums: Set<Album>
override val durationMs: Long? override val durationMs: Long?
override val cover = override val cover: ParentCover
when (val src = grouping.raw.src) {
is AlbumImpl -> src.cover
is SongImpl -> src.cover
else -> error("Unexpected input music $src in $name ${src::class.simpleName}")
}
override lateinit var genres: List<Genre> override lateinit var genres: List<Genre>
@ -459,6 +458,14 @@ class ArtistImpl(
implicitAlbums = albums.filterNotTo(mutableSetOf()) { albumMap[it] == true } implicitAlbums = albums.filterNotTo(mutableSetOf()) { albumMap[it] == true }
durationMs = songs.sumOf { it.durationMs }.positiveOrNull() durationMs = songs.sumOf { it.durationMs }.positiveOrNull()
val singleCover =
when (val src = grouping.raw.src) {
is SongImpl -> src.cover
is AlbumImpl -> src.cover.single
else -> error("Unexpected input source $src in $name ${src::class.simpleName}")
}
cover = ParentCover.from(singleCover, songs)
hashCode = 31 * hashCode + rawArtist.hashCode() hashCode = 31 * hashCode + rawArtist.hashCode()
hashCode = 31 * hashCode + nameFactory.hashCode() hashCode = 31 * hashCode + nameFactory.hashCode()
hashCode = 31 * hashCode + songs.hashCode() hashCode = 31 * hashCode + songs.hashCode()
@ -536,7 +543,7 @@ class GenreImpl(
override val songs: Set<Song> override val songs: Set<Song>
override val artists: Set<Artist> override val artists: Set<Artist>
override val durationMs: Long override val durationMs: Long
override val cover = grouping.raw.src.cover override val cover: ParentCover
private var hashCode = uid.hashCode() private var hashCode = uid.hashCode()
@ -554,6 +561,8 @@ class GenreImpl(
artists = distinctArtists artists = distinctArtists
durationMs = totalDuration durationMs = totalDuration
cover = ParentCover.from(grouping.raw.src.cover, songs)
hashCode = 31 * hashCode + rawGenre.hashCode() hashCode = 31 * hashCode + rawGenre.hashCode()
hashCode = 31 * hashCode + nameFactory.hashCode() hashCode = 31 * hashCode + nameFactory.hashCode()
hashCode = 31 * hashCode + songs.hashCode() hashCode = 31 * hashCode + songs.hashCode()

View file

@ -67,6 +67,8 @@ data class RawSong(
var subtitle: String? = null, var subtitle: String? = null,
/** @see Song.date */ /** @see Song.date */
var date: Date? = null, var date: Date? = null,
/** @see Song.cover */
var coverPerceptualHash: String? = null,
/** @see RawAlbum.mediaStoreId */ /** @see RawAlbum.mediaStoreId */
var albumMediaStoreId: Long? = null, var albumMediaStoreId: Long? = null,
/** @see RawAlbum.musicBrainzId */ /** @see RawAlbum.musicBrainzId */

View file

@ -18,6 +18,7 @@
package org.oxycblt.auxio.music.metadata package org.oxycblt.auxio.music.metadata
import android.graphics.BitmapFactory
import androidx.core.text.isDigitsOnly import androidx.core.text.isDigitsOnly
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.MetadataRetriever import androidx.media3.exoplayer.MetadataRetriever
@ -25,6 +26,8 @@ import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.TrackGroupArray import androidx.media3.exoplayer.source.TrackGroupArray
import java.util.concurrent.Future import java.util.concurrent.Future
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.image.extractor.CoverExtractor
import org.oxycblt.auxio.image.extractor.dHash
import org.oxycblt.auxio.music.device.RawSong import org.oxycblt.auxio.music.device.RawSong
import org.oxycblt.auxio.music.fs.toAudioUri import org.oxycblt.auxio.music.fs.toAudioUri
import org.oxycblt.auxio.music.info.Date import org.oxycblt.auxio.music.info.Date
@ -60,7 +63,10 @@ interface TagWorker {
class TagWorkerFactoryImpl class TagWorkerFactoryImpl
@Inject @Inject
constructor(private val mediaSourceFactory: MediaSource.Factory) : TagWorker.Factory { constructor(
private val mediaSourceFactory: MediaSource.Factory,
private val coverExtractor: CoverExtractor
) : TagWorker.Factory {
override fun create(rawSong: RawSong): TagWorker = override fun create(rawSong: RawSong): TagWorker =
// Note that we do not leverage future callbacks. This is because errors in the // Note that we do not leverage future callbacks. This is because errors in the
// (highly fallible) extraction process will not bubble up to Indexer when a // (highly fallible) extraction process will not bubble up to Indexer when a
@ -70,12 +76,14 @@ constructor(private val mediaSourceFactory: MediaSource.Factory) : TagWorker.Fac
MetadataRetriever.retrieveMetadata( MetadataRetriever.retrieveMetadata(
mediaSourceFactory, mediaSourceFactory,
MediaItem.fromUri( MediaItem.fromUri(
requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No id" }.toAudioUri()))) requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No id" }.toAudioUri())),
coverExtractor)
} }
private class TagWorkerImpl( private class TagWorkerImpl(
private val rawSong: RawSong, private val rawSong: RawSong,
private val future: Future<TrackGroupArray> private val future: Future<TrackGroupArray>,
private val coverExtractor: CoverExtractor
) : TagWorker { ) : TagWorker {
override fun poll(): RawSong? { override fun poll(): RawSong? {
if (!future.isDone) { if (!future.isDone) {
@ -98,6 +106,11 @@ private class TagWorkerImpl(
populateWithId3v2(textTags.id3v2) populateWithId3v2(textTags.id3v2)
populateWithVorbis(textTags.vorbis) populateWithVorbis(textTags.vorbis)
val coverInputStream = coverExtractor.findCoverDataInMetadata(metadata)
val bitmap = coverInputStream?.use { BitmapFactory.decodeStream(it) }
rawSong.coverPerceptualHash = bitmap?.dHash()
bitmap?.recycle()
// OPUS base gain interpretation code: This is likely not needed, as the media player // OPUS base gain interpretation code: This is likely not needed, as the media player
// should be using the base gain already. Uncomment if that's not the case. // should be using the base gain already. Uncomment if that's not the case.
// if (format.sampleMimeType == MimeTypes.AUDIO_OPUS // if (format.sampleMimeType == MimeTypes.AUDIO_OPUS

View file

@ -74,7 +74,7 @@ fun Song.toMediaItem(context: Context, parent: MusicParent?): MediaItem {
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC) .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
.setIsPlayable(true) .setIsPlayable(true)
.setIsBrowsable(false) .setIsBrowsable(false)
.setArtworkUri(album.cover.mediaStoreUri) .setArtworkUri(album.cover.single.mediaStoreUri)
.setExtras( .setExtras(
Bundle().apply { Bundle().apply {
putString("uid", mediaSessionUID.toString()) putString("uid", mediaSessionUID.toString())
@ -105,7 +105,7 @@ fun Album.toMediaItem(context: Context): MediaItem {
.setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM) .setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM)
.setIsPlayable(true) .setIsPlayable(true)
.setIsBrowsable(true) .setIsBrowsable(true)
.setArtworkUri(cover.mediaStoreUri) .setArtworkUri(cover.single.mediaStoreUri)
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
.build() .build()
return MediaItem.Builder() return MediaItem.Builder()
@ -136,7 +136,7 @@ fun Artist.toMediaItem(context: Context): MediaItem {
.setIsPlayable(true) .setIsPlayable(true)
.setIsBrowsable(true) .setIsBrowsable(true)
.setGenre(genres.resolveNames(context)) .setGenre(genres.resolveNames(context))
.setArtworkUri(cover.mediaStoreUri) .setArtworkUri(cover.single.mediaStoreUri)
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
.build() .build()
return MediaItem.Builder() return MediaItem.Builder()
@ -159,7 +159,7 @@ fun Genre.toMediaItem(context: Context): MediaItem {
.setMediaType(MediaMetadata.MEDIA_TYPE_GENRE) .setMediaType(MediaMetadata.MEDIA_TYPE_GENRE)
.setIsPlayable(true) .setIsPlayable(true)
.setIsBrowsable(true) .setIsBrowsable(true)
.setArtworkUri(cover.mediaStoreUri) .setArtworkUri(cover.single.mediaStoreUri)
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
.build() .build()
return MediaItem.Builder() return MediaItem.Builder()
@ -182,7 +182,7 @@ fun Playlist.toMediaItem(context: Context): MediaItem {
.setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST) .setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST)
.setIsPlayable(true) .setIsPlayable(true)
.setIsBrowsable(true) .setIsBrowsable(true)
.setArtworkUri(cover?.mediaStoreUri) .setArtworkUri(cover?.single?.mediaStoreUri)
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
.build() .build()
return MediaItem.Builder() return MediaItem.Builder()

View file

@ -18,6 +18,7 @@
package org.oxycblt.auxio.music.user package org.oxycblt.auxio.music.user
import org.oxycblt.auxio.image.extractor.ParentCover
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Playlist
@ -46,7 +47,7 @@ private constructor(
override fun toString() = "Playlist(uid=$uid, name=$name)" override fun toString() = "Playlist(uid=$uid, name=$name)"
override val cover = songs.firstOrNull()?.cover override val cover = songs.takeIf { it.isNotEmpty() }?.let { ParentCover.from(it.first(), it) }
/** /**
* Clone the data in this instance to a new [PlaylistImpl] with the given [name]. * Clone the data in this instance to a new [PlaylistImpl] with the given [name].