diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt index 8d890b218..f1da906b7 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -372,6 +372,7 @@ constructor( // Do the initial query of the cache and media databases in parallel. logD("Starting MediaStore query") val mediaStoreQueryJob = worker.scope.tryAsync { mediaStoreExtractor.query() } + val userLibraryQueryJob = worker.scope.tryAsync { userLibraryFactory.query() } val cache = if (withCache) { logD("Reading cache") @@ -388,6 +389,7 @@ constructor( logD("Starting song discovery") val completeSongs = Channel(Channel.UNLIMITED) val incompleteSongs = Channel(Channel.UNLIMITED) + val processedSongs = Channel(Channel.UNLIMITED) logD("Started MediaStore discovery") val mediaStoreJob = worker.scope.tryAsync { @@ -400,10 +402,17 @@ constructor( tagExtractor.consume(incompleteSongs, completeSongs) completeSongs.close() } + logD("Starting DeviceLibrary creation") + val deviceLibraryJob = + worker.scope.tryAsync(Dispatchers.Default) { + deviceLibraryFactory.create(completeSongs, processedSongs).also { + processedSongs.close() + } + } // Await completed raw songs as they are processed. val rawSongs = LinkedList() - for (rawSong in completeSongs) { + for (rawSong in processedSongs) { rawSongs.add(rawSong) emitLoading(IndexingProgress.Songs(rawSongs.size, query.projectedTotal)) } @@ -417,29 +426,22 @@ constructor( throw NoMusicException() } - // Successfully loaded the library, now save the cache, create the library, and - // read playlist information in parallel. + // Successfully loaded the library, now save the cache and read playlist information + // in parallel. logD("Discovered ${rawSongs.size} songs, starting finalization") // TODO: Indicate playlist state in loading process? emitLoading(IndexingProgress.Indeterminate) - val deviceLibraryChannel = Channel() - logD("Starting DeviceLibrary creation") - val deviceLibraryJob = - worker.scope.tryAsync(Dispatchers.Default) { - deviceLibraryFactory.create(rawSongs).also { deviceLibraryChannel.send(it) } - } - logD("Starting UserLibrary creation") - val userLibraryJob = - worker.scope.tryAsync { - userLibraryFactory.read(deviceLibraryChannel).also { deviceLibraryChannel.close() } - } + logD("Starting UserLibrary query") if (cache == null || cache.invalidated) { logD("Writing cache [why=${cache?.invalidated}]") cacheRepository.writeCache(rawSongs) } - logD("Awaiting library creation") + logD("Awaiting UserLibrary query") + val rawPlaylists = userLibraryQueryJob.await().getOrThrow() + logD("Awaiting DeviceLibrary creation") val deviceLibrary = deviceLibraryJob.await().getOrThrow() - val userLibrary = userLibraryJob.await().getOrThrow() + logD("Starting UserLibrary creation") + val userLibrary = userLibraryFactory.create(rawPlaylists, deviceLibrary) logD("Successfully indexed music library [device=$deviceLibrary user=$userLibrary]") emitComplete(null) diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt index 6d2c66899..f53a87ea2 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt @@ -21,8 +21,8 @@ package org.oxycblt.auxio.music.device import android.content.Context import android.net.Uri import android.provider.OpenableColumns -import java.util.LinkedList import javax.inject.Inject +import kotlinx.coroutines.channels.Channel import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre @@ -32,8 +32,8 @@ import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.fs.contentResolverSafe import org.oxycblt.auxio.music.fs.useQuery -import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW +import org.oxycblt.auxio.util.unlikelyToBeNull /** * Organized music library information obtained from device storage. @@ -46,13 +46,13 @@ import org.oxycblt.auxio.util.logW */ interface DeviceLibrary { /** All [Song]s in this [DeviceLibrary]. */ - val songs: List + val songs: Collection /** All [Album]s in this [DeviceLibrary]. */ - val albums: List + val albums: Collection /** All [Artist]s in this [DeviceLibrary]. */ - val artists: List + val artists: Collection /** All [Genre]s in this [DeviceLibrary]. */ - val genres: List + val genres: Collection /** * Find a [Song] instance corresponding to the given [Music.UID]. @@ -97,38 +97,157 @@ interface DeviceLibrary { /** Constructs a [DeviceLibrary] implementation in an asynchronous manner. */ interface Factory { - /** - * Create a new [DeviceLibrary]. - * - * @param rawSongs [RawSong] instances to create a [DeviceLibrary] from. - */ - suspend fun create(rawSongs: List): DeviceLibrary - } - - companion object { - /** - * Create an instance of [DeviceLibrary]. - * - * @param rawSongs [RawSong]s to create the library out of. - * @param settings [MusicSettings] required. - */ - fun from(rawSongs: List, settings: MusicSettings): DeviceLibrary = - DeviceLibraryImpl(rawSongs, settings) + suspend fun create( + rawSongs: Channel, + processedSongs: Channel + ): DeviceLibraryImpl } } class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: MusicSettings) : DeviceLibrary.Factory { - override suspend fun create(rawSongs: List): DeviceLibrary = - DeviceLibraryImpl(rawSongs, musicSettings) + override suspend fun create( + rawSongs: Channel, + processedSongs: Channel + ): DeviceLibraryImpl { + val songGrouping = mutableMapOf() + val albumGrouping = mutableMapOf>() + val artistGrouping = mutableMapOf>() + val genreGrouping = mutableMapOf>() + + // All music information is grouped as it is indexed by other components. + for (rawSong in rawSongs) { + val song = SongImpl(rawSong, musicSettings) + // At times the indexer produces duplicate songs, try to filter these. Comparing by + // UID is sufficient for something like this, and also prevents collisions from + // causing severe issues elsewhere. + if (songGrouping.containsKey(song.uid)) { + logW( + "Duplicate song found: ${song.path} in " + + "collides with ${unlikelyToBeNull(songGrouping[song.uid]).path}") + processedSongs.send(rawSong) + continue + } + songGrouping[song.uid] = song + + // Group the new song into an album. + val albumKey = song.rawAlbum.key + val albumBody = albumGrouping[albumKey] + if (albumBody != null) { + albumBody.music.add(song) + val prioritized = albumBody.raw.src + // Since albums are grouped fuzzily, we pick the song with the earliest track to + // use for album information to ensure consistent metadata and UIDs. Fall back to + // the name otherwise. + val trackLower = + song.track != null && (prioritized.track == null || song.track < prioritized.track) + val nameLower = + song.name < prioritized.name + if (trackLower || nameLower) { + albumBody.raw = PrioritizedRaw(song.rawAlbum, song) + } + } else { + // Need to initialize this grouping. + albumGrouping[albumKey] = + Grouping(PrioritizedRaw(song.rawAlbum, song), mutableListOf(song)) + } + + // Group the song into each of it's artists. + for (rawArtist in song.rawArtists) { + val artistKey = rawArtist.key + val artistBody = artistGrouping[artistKey] + if (artistBody != null) { + // Since artists are not guaranteed to have songs, song artist information is + // de-prioritized compared to album artist information. + artistBody.music.add(song) + } else { + // Need to initialize this grouping. + artistGrouping[artistKey] = + Grouping(PrioritizedRaw(rawArtist, song), mutableListOf(song)) + } + } + + // Group the song into each of it's genres. + for (rawGenre in song.rawGenres) { + val genreKey = rawGenre.key + val genreBody = genreGrouping[genreKey] + if (genreBody != null) { + genreBody.music.add(song) + // Genre information from higher songs in ascending alphabetical order are + // prioritized. + val prioritized = genreBody.raw.src + val nameLower = song.name < prioritized.name + if (nameLower) { + genreBody.raw = PrioritizedRaw(rawGenre, song) + } + } else { + // Need to initialize this grouping. + genreGrouping[genreKey] = + Grouping(PrioritizedRaw(rawGenre, song), mutableListOf(song)) + } + } + + processedSongs.send(rawSong) + } + + // Now that all songs are processed, also process albums and group them into their + // respective artists. + val albums = + albumGrouping.values.map { AlbumImpl(it.raw.inner, musicSettings, it.music) } + for (album in albums) { + for (rawArtist in album.rawArtists) { + val key = RawArtist.Key(rawArtist) + val body = artistGrouping[key] + if (body != null) { + body.music.add(album) + when (val prioritized = body.raw.src) { + // Immediately replace any songs that initially held the priority position. + is SongImpl -> body.raw = PrioritizedRaw(rawArtist, album) + is AlbumImpl -> { + // Album information from later dates is prioritized, as it is more likely to + // contain the "modern" name of the artist if the information really is + // in-consistent. Fall back to the name otherwise. + val dateEarlier = + album.dates != null && (prioritized.dates == null || album.dates < prioritized.dates) + val nameLower = + album.name < prioritized.name + if (dateEarlier || nameLower) { + body.raw = PrioritizedRaw(rawArtist, album) + } + } + else -> throw IllegalStateException() + } + } else { + // Need to initialize this grouping. + artistGrouping[key] = + Grouping(PrioritizedRaw(rawArtist, album), mutableListOf(album)) + } + } + } + + // Artists and genres do not need to be grouped and can be processed immediately. + val artists = + artistGrouping.values.map { ArtistImpl(it.raw.inner, musicSettings, it.music) } + val genres = + genreGrouping.values.map { GenreImpl(it.raw.inner, musicSettings, it.music) } + + return DeviceLibraryImpl(songGrouping.values, albums, artists, genres) + } + + private data class Grouping( + var raw: PrioritizedRaw, + val music: MutableList + ) + + private data class PrioritizedRaw(val inner: R, val src: M) } -private class DeviceLibraryImpl(rawSongs: List, settings: MusicSettings) : DeviceLibrary { - override val songs = buildSongs(rawSongs, settings) - override val albums = buildAlbums(songs, settings) - override val artists = buildArtists(songs, albums, settings) - override val genres = buildGenres(songs, settings) - +class DeviceLibraryImpl( + override val songs: Collection, + override val albums: Collection, + override val artists: Collection, + override val genres: Collection +) : DeviceLibrary { // Use a mapping to make finding information based on it's UID much faster. private val songUidMap = buildMap { songs.forEach { put(it.uid, it.finalize()) } } private val albumUidMap = buildMap { albums.forEach { put(it.uid, it.finalize()) } } @@ -139,12 +258,13 @@ private class DeviceLibraryImpl(rawSongs: List, settings: MusicSettings override fun equals(other: Any?) = other is DeviceLibrary && other.songs == songs override fun hashCode() = songs.hashCode() override fun toString() = - "DeviceLibrary(songs=${songs.size}, albums=${albums.size}, artists=${artists.size}, genres=${genres.size})" + "DeviceLibrary(songs=${songs.size}, albums=${albums.size}, " + + "artists=${artists.size}, genres=${genres.size})" - override fun findSong(uid: Music.UID) = songUidMap[uid] - override fun findAlbum(uid: Music.UID) = albumUidMap[uid] - override fun findArtist(uid: Music.UID) = artistUidMap[uid] - override fun findGenre(uid: Music.UID) = genreUidMap[uid] + override fun findSong(uid: Music.UID): Song? = songUidMap[uid] + override fun findAlbum(uid: Music.UID): Album? = albumUidMap[uid] + override fun findArtist(uid: Music.UID): Artist? = artistUidMap[uid] + override fun findGenre(uid: Music.UID): Genre? = genreUidMap[uid] override fun findSongForUri(context: Context, uri: Uri) = context.contentResolverSafe.useQuery( @@ -157,135 +277,4 @@ private class DeviceLibraryImpl(rawSongs: List, settings: MusicSettings val size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)) songs.find { it.path.name == displayName && it.size == size } } - - private fun buildSongs(rawSongs: List, settings: MusicSettings): List { - val start = System.currentTimeMillis() - val uidSet = LinkedHashSet(rawSongs.size) - val songs = LinkedList() - for (rawSong in rawSongs) { - val song = SongImpl(rawSong, settings) - if (uidSet.add(song.uid)) { - songs.add(song) - } else { - logW("Duplicate song found: $song") - } - } - logD("Successfully built ${songs.size} songs in ${System.currentTimeMillis() - start}ms") - return songs - } - - private fun buildAlbums(songs: List, settings: MusicSettings): List { - val start = System.currentTimeMillis() - val albumGrouping = mutableMapOf>() - for (song in songs) { - val key = RawAlbum.Key(song.rawAlbum) - val body = albumGrouping[key] - if (body != null) { - body.music.add(song) - val dominantSong = body.dominantRaw.derived - val dominates = - song.track != null && - (dominantSong.track == null || song.track < dominantSong.track) - if (dominates) { - body.dominantRaw = DominantRaw(song.rawAlbum, song) - } - } else { - albumGrouping[key] = Grouping(DominantRaw(song.rawAlbum, song), mutableListOf(song)) - } - } - - // Group songs by their singular raw album, then map the raw instances and their - // grouped songs to Album values. Album.Raw will handle the actual grouping rules. - val albums = - albumGrouping.values.map { AlbumImpl(it.dominantRaw.inner, settings, it.music) } - logD("Successfully built ${albums.size} albums in ${System.currentTimeMillis() - start}ms") - return albums - } - - private fun buildArtists( - songs: List, - albums: List, - settings: MusicSettings - ): List { - val start = System.currentTimeMillis() - // Add every raw artist credited to each Song/Album to the grouping. This way, - // different multi-artist combinations are not treated as different artists. - // Songs and albums are grouped by artist and album artist respectively. - val artistGrouping = mutableMapOf>() - - for (song in songs) { - for (rawArtist in song.rawArtists) { - val key = RawArtist.Key(rawArtist) - val body = artistGrouping[key] - if (body != null) { - body.music.add(song) - } else { - artistGrouping[key] = - Grouping(DominantRaw(rawArtist, albums.first()), mutableListOf(song)) - } - } - } - - for (album in albums) { - for (rawArtist in album.rawArtists) { - val key = RawArtist.Key(rawArtist) - val body = artistGrouping[key] - if (body != null) { - body.music.add(album) - val dominantAlbum = body.dominantRaw.derived - val dominates = - album.dates != null && - (dominantAlbum.dates == null || album.dates < dominantAlbum.dates) - if (dominates) { - body.dominantRaw = DominantRaw(rawArtist, album) - } - } else { - artistGrouping[key] = - Grouping(DominantRaw(rawArtist, album), mutableListOf(album)) - } - } - } - - // Convert the combined mapping into artist instances. - val artists = - artistGrouping.values.map { ArtistImpl(it.dominantRaw.inner, settings, it.music) } - logD( - "Successfully built ${artists.size} artists in ${System.currentTimeMillis() - start}ms") - return artists - } - - private fun buildGenres(songs: List, settings: MusicSettings): List { - val start = System.currentTimeMillis() - // Add every raw genre credited to each Song to the grouping. This way, - // different multi-genre combinations are not treated as different genres. - val songsByGenre = mutableMapOf>() - for (song in songs) { - for (rawGenre in song.rawGenres) { - val key = RawGenre.Key(rawGenre) - val body = songsByGenre[key] - if (body != null) { - body.music.add(song) - val dominantSong = body.dominantRaw.derived - if (song.date != null && song.name < dominantSong.name) { - body.dominantRaw = DominantRaw(rawGenre, song) - } - } else { - songsByGenre[key] = Grouping(DominantRaw(rawGenre, song), mutableListOf(song)) - } - } - } - - // Convert the mapping into genre instances. - val genres = - songsByGenre.map { GenreImpl(it.value.dominantRaw.inner, settings, it.value.music) } - logD("Successfully built ${genres.size} genres in ${System.currentTimeMillis() - start}ms") - return genres - } - - data class DominantRaw(val inner: R, val derived: M) - - data class Grouping( - var dominantRaw: DominantRaw, - val music: MutableList - ) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt index 6f6cebda0..e1501bede 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt @@ -156,8 +156,8 @@ private class TagWorkerImpl( (textFrames["TXXX:albumartists"] ?: textFrames["TPE2"])?.let { rawSong.albumArtistNames = it } - (textFrames["TXXX:albumartistssort"] ?: textFrames["TXXX:albumartists_sort"] - ?: textFrames["TSO2"]) + (textFrames["TXXX:albumartistssort"] + ?: textFrames["TXXX:albumartists_sort"] ?: textFrames["TSO2"]) ?.let { rawSong.albumArtistSortNames = it } // Genre @@ -248,14 +248,15 @@ private class TagWorkerImpl( // Artist comments["musicbrainz_artistid"]?.let { rawSong.artistMusicBrainzIds = it } (comments["artists"] ?: comments["artist"])?.let { rawSong.artistNames = it } - (comments["artistssort"] ?: comments["artists_sort"] ?: comments["artistsort"] )?.let { + (comments["artistssort"] ?: comments["artists_sort"] ?: comments["artistsort"])?.let { rawSong.artistSortNames = it } // Album artist comments["musicbrainz_albumartistid"]?.let { rawSong.albumArtistMusicBrainzIds = it } (comments["albumartists"] ?: comments["albumartist"])?.let { rawSong.albumArtistNames = it } - (comments["albumartistssort"] ?: comments["albumartists_sort"] ?: comments["albumartistsort"]) + (comments["albumartistssort"] + ?: comments["albumartists_sort"] ?: comments["albumartistsort"]) ?.let { rawSong.albumArtistSortNames = it } // Genre diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt index 412b14fa4..d6265bd00 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt @@ -20,7 +20,6 @@ package org.oxycblt.auxio.music.user import java.lang.Exception import javax.inject.Inject -import kotlinx.coroutines.channels.Channel import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicSettings @@ -37,6 +36,8 @@ import org.oxycblt.auxio.util.logE * is also not backed by library information, rather an app database with in-memory caching. It is * generally not expected to create this yourself, and instead rely on MusicRepository. * + * TODO: Communicate errors + * * @author Alexander Capehart */ interface UserLibrary { @@ -61,15 +62,12 @@ interface UserLibrary { /** Constructs a [UserLibrary] implementation in an asynchronous manner. */ interface Factory { - /** - * Create a new [UserLibrary]. - * - * @param deviceLibraryChannel Asynchronously populated [DeviceLibrary] that can be obtained - * later. This allows database information to be read before the actual instance is - * constructed. - * @return A new [MutableUserLibrary] with the required implementation. - */ - suspend fun read(deviceLibraryChannel: Channel): MutableUserLibrary + suspend fun query(): List + + suspend fun create( + rawPlaylists: List, + deviceLibrary: DeviceLibrary + ): MutableUserLibrary } } @@ -123,17 +121,19 @@ class UserLibraryFactoryImpl @Inject constructor(private val playlistDao: PlaylistDao, private val musicSettings: MusicSettings) : UserLibrary.Factory { - override suspend fun read(deviceLibraryChannel: Channel): MutableUserLibrary { - // While were waiting for the library, read our playlists out. - val rawPlaylists = - try { - playlistDao.readRawPlaylists() - } catch (e: Exception) { - logE("Unable to read playlists: $e") - return UserLibraryImpl(playlistDao, mutableMapOf(), musicSettings) - } + override suspend fun query() = + try { + playlistDao.readRawPlaylists() + } catch (e: Exception) { + logE("Unable to read playlists: $e") + listOf() + } + + override suspend fun create( + rawPlaylists: List, + deviceLibrary: DeviceLibrary + ): MutableUserLibrary { logD("Successfully read ${rawPlaylists.size} playlists") - val deviceLibrary = deviceLibraryChannel.receive() // Convert the database playlist information to actual usable playlists. val playlistMap = mutableMapOf() for (rawPlaylist in rawPlaylists) { diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt index a4471ae5e..24c5389fd 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt @@ -58,11 +58,11 @@ interface SearchEngine { * @param playlists A list of [Playlist], null if empty. */ data class Items( - val songs: List?, - val albums: List?, - val artists: List?, - val genres: List?, - val playlists: List? + val songs: Collection?, + val albums: Collection?, + val artists: Collection?, + val genres: Collection?, + val playlists: Collection? ) } @@ -90,7 +90,7 @@ class SearchEngineImpl @Inject constructor(@ApplicationContext private val conte * initially. This can be used to compare against additional attributes to improve search * result quality. */ - private inline fun List.searchListImpl( + private inline fun Collection.searchListImpl( query: String, fallback: (String, T) -> Boolean = { _, _ -> false } ) =