music: add framework for playlist mutation

Add the boilerplate for basic playlist creation/addition.

No integration in UI yet.
This commit is contained in:
Alexander Capehart 2023-03-25 14:36:22 -06:00
parent 70e824c4e7
commit 9988a1b76b
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
14 changed files with 142 additions and 107 deletions

View file

@ -24,9 +24,6 @@ deletion
#### What's Changed
- "Ignore articles when sorting" is now "Intelligent sorting"
#### What's Changed
- "Ignore articles when sorting" is now "Intelligent sorting"
## 3.0.3
#### What's New

View file

@ -53,7 +53,7 @@ class DetailViewModel
@Inject
constructor(
private val musicRepository: MusicRepository,
private val audioInfoProvider: AudioInfo.Provider,
private val audioInfoFactory: AudioInfo.Factory,
private val musicSettings: MusicSettings,
private val playbackSettings: PlaybackSettings
) : ViewModel(), MusicRepository.UpdateListener {
@ -308,7 +308,7 @@ constructor(
_songAudioInfo.value = null
currentSongJob =
viewModelScope.launch(Dispatchers.IO) {
val info = audioInfoProvider.extract(song)
val info = audioInfoFactory.extract(song)
yield()
_songAudioInfo.value = info
}

View file

@ -172,8 +172,8 @@ constructor(
private val cacheRepository: CacheRepository,
private val mediaStoreExtractor: MediaStoreExtractor,
private val tagExtractor: TagExtractor,
private val deviceLibraryProvider: DeviceLibrary.Provider,
private val userLibraryProvider: UserLibrary.Provider
private val deviceLibraryFactory: DeviceLibrary.Factory,
private val userLibraryFactory: UserLibrary.Factory
) : MusicRepository {
private val updateListeners = mutableListOf<MusicRepository.UpdateListener>()
private val indexingListeners = mutableListOf<MusicRepository.IndexingListener>()
@ -314,9 +314,9 @@ constructor(
val deviceLibraryChannel = Channel<DeviceLibrary>()
val deviceLibraryJob =
worker.scope.async(Dispatchers.Main) {
deviceLibraryProvider.create(rawSongs).also { deviceLibraryChannel.send(it) }
deviceLibraryFactory.create(rawSongs).also { deviceLibraryChannel.send(it) }
}
val userLibraryJob = worker.scope.async { userLibraryProvider.read(deviceLibraryChannel) }
val userLibraryJob = worker.scope.async { userLibraryFactory.read(deviceLibraryChannel) }
if (cache == null || cache.invalidated) {
cacheRepository.writeCache(rawSongs)
}

View file

@ -89,7 +89,7 @@ interface DeviceLibrary {
fun findGenre(uid: Music.UID): Genre?
/** Constructs a [DeviceLibrary] implementation in an asynchronous manner. */
interface Provider {
interface Factory {
/**
* Create a new [DeviceLibrary].
*
@ -110,8 +110,8 @@ interface DeviceLibrary {
}
}
class DeviceLibraryProviderImpl @Inject constructor(private val musicSettings: MusicSettings) :
DeviceLibrary.Provider {
class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: MusicSettings) :
DeviceLibrary.Factory {
override suspend fun create(rawSongs: List<RawSong>): DeviceLibrary =
DeviceLibraryImpl(rawSongs, musicSettings)
}

View file

@ -26,6 +26,5 @@ import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
interface DeviceModule {
@Binds
fun deviceLibraryProvider(providerImpl: DeviceLibraryProviderImpl): DeviceLibrary.Provider
@Binds fun deviceLibraryProvider(factory: DeviceLibraryFactoryImpl): DeviceLibrary.Factory
}

View file

@ -342,8 +342,8 @@ class ArtistImpl(
override val durationMs: Long?
override val isCollaborator: Boolean
// Note: Append song contents to MusicParent equality so that Groups with
// the same UID but different contents are not equal.
// Note: Append song contents to MusicParent equality so that artists with
// the same UID but different songs are not equal.
override fun hashCode() = 31 * uid.hashCode() + songs.hashCode()
override fun equals(other: Any?) =
other is ArtistImpl && uid == other.uid && songs == other.songs

View file

@ -186,13 +186,13 @@ class RawGenre(
/** @see Music.rawName */
val name: String? = null
) {
// Only group by the lowercase genre name. This allows Genre grouping to be
// case-insensitive, which may be helpful in some libraries with different ways of
// formatting genres.
// Cache the hashCode for HashMap efficiency.
private val hashCode = name?.lowercase().hashCode()
// Only group by the lowercase genre name. This allows Genre grouping to be
// case-insensitive, which may be helpful in some libraries with different ways of
// formatting genres.
override fun hashCode() = hashCode
override fun equals(other: Any?) =

View file

@ -178,8 +178,8 @@ private abstract class BaseMediaStoreExtractor(
while (cursor.moveToNext()) {
// Assume that a song can't inhabit multiple genre entries, as I
// doubt
// MediaStore is actually aware that songs can have multiple genres.
// doubt MediaStore is actually aware that songs can have multiple
// genres.
genreNamesMap[cursor.getLong(songIdIndex)] = name
}
}
@ -311,9 +311,8 @@ private abstract class BaseMediaStoreExtractor(
rawSong.date = cursor.getStringOrNull(yearIndex)?.let(Date::from)
// A non-existent album name should theoretically be the name of the folder it contained
// in, but in practice it is more often "0" (as in /storage/emulated/0), even when it
// the
// file is not actually in the root internal storage directory. We can't do anything to
// fix this, really.
// the file is not actually in the root internal storage directory. We can't do
// anything to fix this, really.
rawSong.albumName = cursor.getString(albumIndex)
// Android does not make a non-existent artist tag null, it instead fills it in
// as <unknown>, which makes absolutely no sense given how other columns default
@ -356,9 +355,6 @@ private abstract class BaseMediaStoreExtractor(
// Note: The separation between version-specific backends may not be the cleanest. To preserve
// speed, we only want to add redundancy on known issues, not with possible issues.
// Note: The separation between version-specific backends may not be the cleanest. To preserve
// speed, we only want to add redundancy on known issues, not with possible issues.
private class Api21MediaStoreExtractor(context: Context, musicSettings: MusicSettings) :
BaseMediaStoreExtractor(context, musicSettings) {
override val projection: Array<String>

View file

@ -43,7 +43,7 @@ data class AudioInfo(
val resolvedMimeType: MimeType
) {
/** Implements the process of extracting [AudioInfo] from a given [Song]. */
interface Provider {
interface Factory {
/**
* Extract the [AudioInfo] of a given [Song].
*
@ -55,12 +55,12 @@ data class AudioInfo(
}
/**
* A framework-backed implementation of [AudioInfo.Provider].
* A framework-backed implementation of [AudioInfo.Factory].
*
* @param context [Context] required to read audio files.
*/
class AudioInfoProviderImpl @Inject constructor(@ApplicationContext private val context: Context) :
AudioInfo.Provider {
class AudioInfoFactoryImpl @Inject constructor(@ApplicationContext private val context: Context) :
AudioInfo.Factory {
override suspend fun extract(song: Song): AudioInfo {
// While we would use ExoPlayer to extract this information, it doesn't support

View file

@ -26,7 +26,7 @@ import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
interface MetadataModule {
@Binds fun tagExtractor(tagExtractor: TagExtractorImpl): TagExtractor
@Binds fun tagWorkerFactory(taskFactory: TagWorkerImpl.Factory): TagWorker.Factory
@Binds fun audioInfoProvider(audioInfoProvider: AudioInfoProviderImpl): AudioInfo.Provider
@Binds fun tagExtractor(extractor: TagExtractorImpl): TagExtractor
@Binds fun tagWorkerFactory(factory: TagWorkerFactoryImpl): TagWorker.Factory
@Binds fun audioInfoProvider(factory: AudioInfoFactoryImpl): AudioInfo.Factory
}

View file

@ -56,14 +56,26 @@ interface TagWorker {
}
}
class TagWorkerImpl
private constructor(private val rawSong: RawSong, private val future: Future<TrackGroupArray>) :
TagWorker {
/**
* Try to get a completed song from this [TagWorker], if it has finished processing.
*
* @return A [RawSong] instance if processing has completed, null otherwise.
*/
class TagWorkerFactoryImpl
@Inject
constructor(private val mediaSourceFactory: MediaSource.Factory) : TagWorker.Factory {
override fun create(rawSong: RawSong): TagWorker =
// 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
// listener is used, instead crashing the app entirely.
TagWorkerImpl(
rawSong,
MetadataRetriever.retrieveMetadata(
mediaSourceFactory,
MediaItem.fromUri(
requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No id" }.toAudioUri())))
}
private class TagWorkerImpl(
private val rawSong: RawSong,
private val future: Future<TrackGroupArray>
) : TagWorker {
override fun poll(): RawSong? {
if (!future.isDone) {
// Not done yet, nothing to do.
@ -95,12 +107,6 @@ private constructor(private val rawSong: RawSong, private val future: Future<Tra
return rawSong
}
/**
* Complete this instance's [RawSong] with ID3v2 Text Identification Frames.
*
* @param textFrames A mapping between ID3v2 Text Identification Frame IDs and one or more
* values.
*/
private fun populateWithId3v2(textFrames: Map<String, List<String>>) {
// Song
textFrames["TXXX:musicbrainz release track id"]?.let { rawSong.musicBrainzId = it.first() }
@ -169,16 +175,6 @@ private constructor(private val rawSong: RawSong, private val future: Future<Tra
}
}
/**
* Parses the ID3v2.3 timestamp specification into a [Date] from the given Text Identification
* Frames.
*
* @param textFrames A mapping between ID3v2 Text Identification Frame IDs and one or more
* values.
* @return A [Date] of a year value from TORY/TYER, a month and day value from TDAT, and a
* hour/minute value from TIME. No second value is included. The latter two fields may not be
* included in they cannot be parsed. Will be null if a year value could not be parsed.
*/
private fun parseId3v23Date(textFrames: Map<String, List<String>>): Date? {
// Assume that TDAT/TIME can refer to TYER or TORY depending on if TORY
// is present.
@ -212,11 +208,6 @@ private constructor(private val rawSong: RawSong, private val future: Future<Tra
}
}
/**
* Complete this instance's [RawSong] with Vorbis comments.
*
* @param comments A mapping between vorbis comment names and one or more vorbis comment values.
*/
private fun populateWithVorbis(comments: Map<String, List<String>>) {
// Song
comments["musicbrainz_releasetrackid"]?.let { rawSong.musicBrainzId = it.first() }
@ -277,21 +268,6 @@ private constructor(private val rawSong: RawSong, private val future: Future<Tra
}
}
class Factory @Inject constructor(private val mediaSourceFactory: MediaSource.Factory) :
TagWorker.Factory {
override fun create(rawSong: RawSong) =
// 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
// listener is used, instead crashing the app entirely.
TagWorkerImpl(
rawSong,
MetadataRetriever.retrieveMetadata(
mediaSourceFactory,
MediaItem.fromUri(
requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No id" }
.toAudioUri())))
}
private companion object {
val COMPILATION_ALBUM_ARTISTS = listOf("Various Artists")
val COMPILATION_RELEASE_TYPES = listOf("compilation")

View file

@ -27,29 +27,60 @@ class PlaylistImpl
private constructor(
override val uid: Music.UID,
override val rawName: String,
override val songs: List<Song>,
musicSettings: MusicSettings
override val sortName: SortName,
override val songs: List<Song>
) : Playlist {
constructor(
name: String,
songs: List<Song>,
musicSettings: MusicSettings
) : this(Music.UID.auxio(MusicMode.PLAYLISTS, UUID.randomUUID()), name, songs, musicSettings)
constructor(
rawPlaylist: RawPlaylist,
deviceLibrary: DeviceLibrary,
musicSettings: MusicSettings
) : this(
rawPlaylist.playlistInfo.playlistUid,
rawPlaylist.playlistInfo.name,
rawPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.songUid) },
musicSettings)
override fun resolveName(context: Context) = rawName
override val rawSortName = null
override val sortName = SortName(rawName, musicSettings)
override val durationMs = songs.sumOf { it.durationMs }
override val albums =
songs.groupBy { it.album }.entries.sortedByDescending { it.value.size }.map { it.key }
/**
* Clone the data in this instance to a new [PlaylistImpl] with the given [Song]s.
*
* @param songs The new [Song]s to use.
*/
fun edit(songs: List<Song>) = PlaylistImpl(uid, rawName, sortName, songs)
/**
* Clone the data in this instance to a new [PlaylistImpl] with the given [edits].
*
* @param edits The edits to make to the [Song]s of the playlist.
*/
inline fun edit(edits: MutableList<Song>.() -> Unit) = edit(songs.toMutableList().apply(edits))
companion object {
/**
* Create a new instance with a novel UID.
*
* @param name The name of the playlist.
* @param songs The songs to initially populate the playlist with.
* @param musicSettings [MusicSettings] required for name configuration.
*/
fun new(name: String, songs: List<Song>, musicSettings: MusicSettings) =
PlaylistImpl(
Music.UID.auxio(MusicMode.PLAYLISTS, UUID.randomUUID()),
name,
SortName(name, musicSettings),
songs)
/**
* Populate a new instance from a read [RawPlaylist].
*
* @param rawPlaylist The [RawPlaylist] to read from.
* @param deviceLibrary The [DeviceLibrary] to initialize from.
* @param musicSettings [MusicSettings] required for name configuration.
*/
fun fromRaw(
rawPlaylist: RawPlaylist,
deviceLibrary: DeviceLibrary,
musicSettings: MusicSettings
) =
PlaylistImpl(
rawPlaylist.playlistInfo.playlistUid,
rawPlaylist.playlistInfo.name,
SortName(rawPlaylist.playlistInfo.name, musicSettings),
rawPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.songUid) })
}
}

View file

@ -45,23 +45,46 @@ interface UserLibrary {
fun findPlaylist(uid: Music.UID): Playlist?
/** Constructs a [UserLibrary] implementation in an asynchronous manner. */
interface Provider {
interface Factory {
/**
* Create a new [UserLibrary].
*
* @param deviceLibrary Asynchronously populated [DeviceLibrary] that can be obtained later.
* This allows database information to be read before the actual instance is constructed.
* @return A new [UserLibrary] with the required implementation.
* @return A new [MutableUserLibrary] with the required implementation.
*/
suspend fun read(deviceLibrary: Channel<DeviceLibrary>): UserLibrary
suspend fun read(deviceLibrary: Channel<DeviceLibrary>): MutableUserLibrary
}
}
class UserLibraryProviderImpl
/**
* A mutable instance of [UserLibrary]. Not meant for use outside of the music module. Use
* [MusicRepository] instead.
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface MutableUserLibrary : UserLibrary {
/**
* Make a new [Playlist].
*
* @param name The name of the [Playlist].
* @param songs The songs to place in the [Playlist].
*/
fun createPlaylist(name: String, songs: List<Song>)
/**
* Add [Song]s to a [Playlist].
*
* @param playlist The [Playlist] to add to. Must currently exist.
*/
fun addToPlaylist(playlist: Playlist, songs: List<Song>)
}
class UserLibraryFactoryImpl
@Inject
constructor(private val playlistDao: PlaylistDao, private val musicSettings: MusicSettings) :
UserLibrary.Provider {
override suspend fun read(deviceLibrary: Channel<DeviceLibrary>): UserLibrary =
UserLibrary.Factory {
override suspend fun read(deviceLibrary: Channel<DeviceLibrary>): MutableUserLibrary =
UserLibraryImpl(playlistDao, deviceLibrary.receive(), musicSettings)
}
@ -69,15 +92,28 @@ private class UserLibraryImpl(
private val playlistDao: PlaylistDao,
private val deviceLibrary: DeviceLibrary,
private val musicSettings: MusicSettings
) : UserLibrary {
) : MutableUserLibrary {
private val playlistMap = mutableMapOf<Music.UID, PlaylistImpl>()
override val playlists: List<Playlist>
get() = playlistMap.values.toList()
init {
val playlist = PlaylistImpl("Playlist 1", deviceLibrary.songs.slice(58..100), musicSettings)
playlistMap[playlist.uid] = playlist
// TODO: Actually read playlists
createPlaylist("Playlist 1", deviceLibrary.songs.slice(58..100))
}
override fun findPlaylist(uid: Music.UID) = playlistMap[uid]
@Synchronized
override fun createPlaylist(name: String, songs: List<Song>) {
val playlistImpl = PlaylistImpl.new(name, songs, musicSettings)
playlistMap[playlistImpl.uid] = playlistImpl
}
@Synchronized
override fun addToPlaylist(playlist: Playlist, songs: List<Song>) {
val playlistImpl =
requireNotNull(playlistMap[playlist.uid]) { "Cannot add to invalid playlist" }
playlistMap[playlist.uid] = playlistImpl.edit { addAll(songs) }
}
}

View file

@ -30,7 +30,7 @@ import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
interface UserModule {
@Binds fun userLibaryProvider(provider: UserLibraryProviderImpl): UserLibrary.Provider
@Binds fun userLibaryFactory(factory: UserLibraryFactoryImpl): UserLibrary.Factory
}
@Module