music: back playlists with database

Finally persist playlists with a backing database.
This commit is contained in:
Alexander Capehart 2023-05-20 15:01:46 -06:00
parent 0597fa876c
commit c86970470f
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
7 changed files with 186 additions and 48 deletions

View file

@ -299,8 +299,13 @@ constructor(
*/ */
fun savePlaylistEdit() { fun savePlaylistEdit() {
val playlist = _currentPlaylist.value ?: return val playlist = _currentPlaylist.value ?: return
val editedPlaylist = (_editedPlaylist.value ?: return).also { _editedPlaylist.value = null } val editedPlaylist = _editedPlaylist.value ?: return
musicRepository.rewritePlaylist(playlist, editedPlaylist) viewModelScope.launch {
musicRepository.rewritePlaylist(playlist, editedPlaylist)
// TODO: The user could probably press some kind of button if they were fast enough.
// Think of a better way to handle this state.
_editedPlaylist.value = null
}
} }
/** /**

View file

@ -116,7 +116,7 @@ interface MusicRepository {
* @param name The name of the new [Playlist]. * @param name The name of the new [Playlist].
* @param songs The songs to populate the new [Playlist] with. * @param songs The songs to populate the new [Playlist] with.
*/ */
fun createPlaylist(name: String, songs: List<Song>) suspend fun createPlaylist(name: String, songs: List<Song>)
/** /**
* Rename a [Playlist]. * Rename a [Playlist].
@ -124,14 +124,14 @@ interface MusicRepository {
* @param playlist The [Playlist] to rename. * @param playlist The [Playlist] to rename.
* @param name The name of the new [Playlist]. * @param name The name of the new [Playlist].
*/ */
fun renamePlaylist(playlist: Playlist, name: String) suspend fun renamePlaylist(playlist: Playlist, name: String)
/** /**
* Delete a [Playlist]. * Delete a [Playlist].
* *
* @param playlist The playlist to delete. * @param playlist The playlist to delete.
*/ */
fun deletePlaylist(playlist: Playlist) suspend fun deletePlaylist(playlist: Playlist)
/** /**
* Add the given [Song]s to a [Playlist]. * Add the given [Song]s to a [Playlist].
@ -139,7 +139,7 @@ interface MusicRepository {
* @param songs The [Song]s to add to the [Playlist]. * @param songs The [Song]s to add to the [Playlist].
* @param playlist The [Playlist] to add to. * @param playlist The [Playlist] to add to.
*/ */
fun addToPlaylist(songs: List<Song>, playlist: Playlist) suspend fun addToPlaylist(songs: List<Song>, playlist: Playlist)
/** /**
* Update the [Song]s of a [Playlist]. * Update the [Song]s of a [Playlist].
@ -147,7 +147,7 @@ interface MusicRepository {
* @param playlist The [Playlist] to update. * @param playlist The [Playlist] to update.
* @param songs The new [Song]s to be contained in the [Playlist]. * @param songs The new [Song]s to be contained in the [Playlist].
*/ */
fun rewritePlaylist(playlist: Playlist, songs: List<Song>) suspend fun rewritePlaylist(playlist: Playlist, songs: List<Song>)
/** /**
* Request that a music loading operation is started by the current [IndexingWorker]. Does * Request that a music loading operation is started by the current [IndexingWorker]. Does
@ -276,7 +276,7 @@ constructor(
(deviceLibrary?.run { findSong(uid) ?: findAlbum(uid) ?: findArtist(uid) ?: findGenre(uid) } (deviceLibrary?.run { findSong(uid) ?: findAlbum(uid) ?: findArtist(uid) ?: findGenre(uid) }
?: userLibrary?.findPlaylist(uid)) ?: userLibrary?.findPlaylist(uid))
override fun createPlaylist(name: String, songs: List<Song>) { override suspend fun createPlaylist(name: String, songs: List<Song>) {
val userLibrary = userLibrary ?: return val userLibrary = userLibrary ?: return
userLibrary.createPlaylist(name, songs) userLibrary.createPlaylist(name, songs)
for (listener in updateListeners) { for (listener in updateListeners) {
@ -285,7 +285,7 @@ constructor(
} }
} }
override fun renamePlaylist(playlist: Playlist, name: String) { override suspend fun renamePlaylist(playlist: Playlist, name: String) {
val userLibrary = userLibrary ?: return val userLibrary = userLibrary ?: return
userLibrary.renamePlaylist(playlist, name) userLibrary.renamePlaylist(playlist, name)
for (listener in updateListeners) { for (listener in updateListeners) {
@ -294,7 +294,7 @@ constructor(
} }
} }
override fun deletePlaylist(playlist: Playlist) { override suspend fun deletePlaylist(playlist: Playlist) {
val userLibrary = userLibrary ?: return val userLibrary = userLibrary ?: return
userLibrary.deletePlaylist(playlist) userLibrary.deletePlaylist(playlist)
for (listener in updateListeners) { for (listener in updateListeners) {
@ -303,7 +303,7 @@ constructor(
} }
} }
override fun addToPlaylist(songs: List<Song>, playlist: Playlist) { override suspend fun addToPlaylist(songs: List<Song>, playlist: Playlist) {
val userLibrary = userLibrary ?: return val userLibrary = userLibrary ?: return
userLibrary.addToPlaylist(playlist, songs) userLibrary.addToPlaylist(playlist, songs)
for (listener in updateListeners) { for (listener in updateListeners) {
@ -312,7 +312,7 @@ constructor(
} }
} }
override fun rewritePlaylist(playlist: Playlist, songs: List<Song>) { override suspend fun rewritePlaylist(playlist: Playlist, songs: List<Song>) {
val userLibrary = userLibrary ?: return val userLibrary = userLibrary ?: return
userLibrary.rewritePlaylist(playlist, songs) userLibrary.rewritePlaylist(playlist, songs)
for (listener in updateListeners) { for (listener in updateListeners) {

View file

@ -19,10 +19,13 @@
package org.oxycblt.auxio.music package org.oxycblt.auxio.music
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent import org.oxycblt.auxio.util.MutableEvent
@ -110,7 +113,7 @@ constructor(
*/ */
fun createPlaylist(name: String? = null, songs: List<Song> = listOf()) { fun createPlaylist(name: String? = null, songs: List<Song> = listOf()) {
if (name != null) { if (name != null) {
musicRepository.createPlaylist(name, songs) viewModelScope.launch(Dispatchers.IO) { musicRepository.createPlaylist(name, songs) }
} else { } else {
_newPlaylistSongs.put(songs) _newPlaylistSongs.put(songs)
} }
@ -124,7 +127,7 @@ constructor(
*/ */
fun renamePlaylist(playlist: Playlist, name: String? = null) { fun renamePlaylist(playlist: Playlist, name: String? = null) {
if (name != null) { if (name != null) {
musicRepository.renamePlaylist(playlist, name) viewModelScope.launch(Dispatchers.IO) { musicRepository.renamePlaylist(playlist, name) }
} else { } else {
_playlistToRename.put(playlist) _playlistToRename.put(playlist)
} }
@ -139,7 +142,7 @@ constructor(
*/ */
fun deletePlaylist(playlist: Playlist, rude: Boolean = false) { fun deletePlaylist(playlist: Playlist, rude: Boolean = false) {
if (rude) { if (rude) {
musicRepository.deletePlaylist(playlist) viewModelScope.launch(Dispatchers.IO) { musicRepository.deletePlaylist(playlist) }
} else { } else {
_playlistToDelete.put(playlist) _playlistToDelete.put(playlist)
} }
@ -193,7 +196,7 @@ constructor(
*/ */
fun addToPlaylist(songs: List<Song>, playlist: Playlist? = null) { fun addToPlaylist(songs: List<Song>, playlist: Playlist? = null) {
if (playlist != null) { if (playlist != null) {
musicRepository.addToPlaylist(songs, playlist) viewModelScope.launch(Dispatchers.IO) { musicRepository.addToPlaylist(songs, playlist) }
} else { } else {
_songsToAdd.put(songs) _songsToAdd.put(songs)
} }

View file

@ -21,6 +21,10 @@ package org.oxycblt.auxio.music.user
import androidx.room.* import androidx.room.*
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
/**
* Raw playlist information persisted to [UserMusicDatabase].
* @author Alexander Capehart (OxygenCobalt)
*/
data class RawPlaylist( data class RawPlaylist(
@Embedded val playlistInfo: PlaylistInfo, @Embedded val playlistInfo: PlaylistInfo,
@Relation( @Relation(
@ -30,12 +34,26 @@ data class RawPlaylist(
val songs: List<PlaylistSong> val songs: List<PlaylistSong>
) )
/**
* UID and name information corresponding to a [RawPlaylist] entry.
* @author Alexander Capehart (OxygenCobalt)
*/
@Entity data class PlaylistInfo(@PrimaryKey val playlistUid: Music.UID, val name: String) @Entity data class PlaylistInfo(@PrimaryKey val playlistUid: Music.UID, val name: String)
/**
* Song information corresponding to a [RawPlaylist] entry.
* @author Alexander Capehart (OxygenCobalt)
*/
@Entity data class PlaylistSong(@PrimaryKey val songUid: Music.UID) @Entity data class PlaylistSong(@PrimaryKey val songUid: Music.UID)
@Entity(primaryKeys = ["playlistUid", "songUid"])
/**
* Links individual songs to a playlist entry.
* @author Alexander Capehart (OxygenCobalt)
*/
@Entity
data class PlaylistSongCrossRef( data class PlaylistSongCrossRef(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val playlistUid: Music.UID, val playlistUid: Music.UID,
@ColumnInfo(index = true) val songUid: Music.UID val songUid: Music.UID
) )

View file

@ -57,11 +57,12 @@ interface UserLibrary {
/** /**
* Create a new [UserLibrary]. * Create a new [UserLibrary].
* *
* @param deviceLibrary Asynchronously populated [DeviceLibrary] that can be obtained later. * @param deviceLibraryChannel Asynchronously populated [DeviceLibrary] that can be obtained
* This allows database information to be read before the actual instance is constructed. * later. This allows database information to be read before the actual instance is
* constructed.
* @return A new [MutableUserLibrary] with the required implementation. * @return A new [MutableUserLibrary] with the required implementation.
*/ */
suspend fun read(deviceLibrary: Channel<DeviceLibrary>): MutableUserLibrary suspend fun read(deviceLibraryChannel: Channel<DeviceLibrary>): MutableUserLibrary
} }
} }
@ -78,7 +79,7 @@ interface MutableUserLibrary : UserLibrary {
* @param name The name of the [Playlist]. * @param name The name of the [Playlist].
* @param songs The songs to place in the [Playlist]. * @param songs The songs to place in the [Playlist].
*/ */
fun createPlaylist(name: String, songs: List<Song>) suspend fun createPlaylist(name: String, songs: List<Song>)
/** /**
* Rename a [Playlist]. * Rename a [Playlist].
@ -86,21 +87,21 @@ interface MutableUserLibrary : UserLibrary {
* @param playlist The [Playlist] to rename. * @param playlist The [Playlist] to rename.
* @param name The name of the new [Playlist]. * @param name The name of the new [Playlist].
*/ */
fun renamePlaylist(playlist: Playlist, name: String) suspend fun renamePlaylist(playlist: Playlist, name: String)
/** /**
* Delete a [Playlist]. * Delete a [Playlist].
* *
* @param playlist The playlist to delete. * @param playlist The playlist to delete.
*/ */
fun deletePlaylist(playlist: Playlist) suspend fun deletePlaylist(playlist: Playlist)
/** /**
* Add [Song]s to a [Playlist]. * Add [Song]s to a [Playlist].
* *
* @param playlist The [Playlist] to add to. Must currently exist. * @param playlist The [Playlist] to add to. Must currently exist.
*/ */
fun addToPlaylist(playlist: Playlist, songs: List<Song>) suspend fun addToPlaylist(playlist: Playlist, songs: List<Song>)
/** /**
* Update the [Song]s of a [Playlist]. * Update the [Song]s of a [Playlist].
@ -108,23 +109,32 @@ interface MutableUserLibrary : UserLibrary {
* @param playlist The [Playlist] to update. * @param playlist The [Playlist] to update.
* @param songs The new [Song]s to be contained in the [Playlist]. * @param songs The new [Song]s to be contained in the [Playlist].
*/ */
fun rewritePlaylist(playlist: Playlist, songs: List<Song>) suspend fun rewritePlaylist(playlist: Playlist, songs: List<Song>)
} }
class UserLibraryFactoryImpl class UserLibraryFactoryImpl
@Inject @Inject
constructor(private val playlistDao: PlaylistDao, private val musicSettings: MusicSettings) : constructor(private val playlistDao: PlaylistDao, private val musicSettings: MusicSettings) :
UserLibrary.Factory { UserLibrary.Factory {
override suspend fun read(deviceLibrary: Channel<DeviceLibrary>): MutableUserLibrary = override suspend fun read(deviceLibraryChannel: Channel<DeviceLibrary>): MutableUserLibrary {
UserLibraryImpl(playlistDao, deviceLibrary.receive(), musicSettings) // While were waiting for the library, read our playlists out.
val rawPlaylists = playlistDao.readRawPlaylists()
val deviceLibrary = deviceLibraryChannel.receive()
// Convert the database playlist information to actual usable playlists.
val playlistMap = mutableMapOf<Music.UID, PlaylistImpl>()
for (rawPlaylist in rawPlaylists) {
val playlistImpl = PlaylistImpl.fromRaw(rawPlaylist, deviceLibrary, musicSettings)
playlistMap[playlistImpl.uid] = playlistImpl
}
return UserLibraryImpl(playlistDao, playlistMap, musicSettings)
}
} }
private class UserLibraryImpl( private class UserLibraryImpl(
private val playlistDao: PlaylistDao, private val playlistDao: PlaylistDao,
private val deviceLibrary: DeviceLibrary, private val playlistMap: MutableMap<Music.UID, PlaylistImpl>,
private val musicSettings: MusicSettings private val musicSettings: MusicSettings
) : MutableUserLibrary { ) : MutableUserLibrary {
private val playlistMap = mutableMapOf<Music.UID, PlaylistImpl>()
override val playlists: List<Playlist> override val playlists: List<Playlist>
get() = playlistMap.values.toList() get() = playlistMap.values.toList()
@ -132,35 +142,41 @@ private class UserLibraryImpl(
override fun findPlaylist(name: String) = playlistMap.values.find { it.name.raw == name } override fun findPlaylist(name: String) = playlistMap.values.find { it.name.raw == name }
@Synchronized override suspend fun createPlaylist(name: String, songs: List<Song>) {
override fun createPlaylist(name: String, songs: List<Song>) {
val playlistImpl = PlaylistImpl.from(name, songs, musicSettings) val playlistImpl = PlaylistImpl.from(name, songs, musicSettings)
playlistMap[playlistImpl.uid] = playlistImpl synchronized(this) { playlistMap[playlistImpl.uid] = playlistImpl }
val rawPlaylist =
RawPlaylist(
PlaylistInfo(playlistImpl.uid, playlistImpl.name.raw),
playlistImpl.songs.map { PlaylistSong(it.uid) })
playlistDao.insertPlaylist(rawPlaylist)
} }
@Synchronized override suspend fun renamePlaylist(playlist: Playlist, name: String) {
override fun renamePlaylist(playlist: Playlist, name: String) {
val playlistImpl = val playlistImpl =
requireNotNull(playlistMap[playlist.uid]) { "Cannot rename invalid playlist" } requireNotNull(playlistMap[playlist.uid]) { "Cannot rename invalid playlist" }
playlistMap[playlist.uid] = playlistImpl.edit(name, musicSettings) synchronized(this) { playlistMap[playlist.uid] = playlistImpl.edit(name, musicSettings) }
playlistDao.replacePlaylistInfo(PlaylistInfo(playlist.uid, name))
} }
@Synchronized override suspend fun deletePlaylist(playlist: Playlist) {
override fun deletePlaylist(playlist: Playlist) { synchronized(this) {
requireNotNull(playlistMap.remove(playlist.uid)) { "Cannot remove invalid playlist" } requireNotNull(playlistMap.remove(playlist.uid)) { "Cannot remove invalid playlist" }
}
playlistDao.deletePlaylist(playlist.uid)
} }
@Synchronized override suspend fun addToPlaylist(playlist: Playlist, songs: List<Song>) {
override fun addToPlaylist(playlist: Playlist, songs: List<Song>) {
val playlistImpl = val playlistImpl =
requireNotNull(playlistMap[playlist.uid]) { "Cannot add to invalid playlist" } requireNotNull(playlistMap[playlist.uid]) { "Cannot add to invalid playlist" }
playlistMap[playlist.uid] = playlistImpl.edit { addAll(songs) } synchronized(this) { playlistMap[playlist.uid] = playlistImpl.edit { addAll(songs) } }
playlistDao.insertPlaylistSongs(playlist.uid, songs.map { PlaylistSong(it.uid) })
} }
@Synchronized override suspend fun rewritePlaylist(playlist: Playlist, songs: List<Song>) {
override fun rewritePlaylist(playlist: Playlist, songs: List<Song>) {
val playlistImpl = val playlistImpl =
requireNotNull(playlistMap[playlist.uid]) { "Cannot rewrite invalid playlist" } requireNotNull(playlistMap[playlist.uid]) { "Cannot rewrite invalid playlist" }
playlistMap[playlist.uid] = playlistImpl.edit(songs) synchronized(this) { playlistMap[playlist.uid] = playlistImpl.edit(songs) }
playlistDao.replacePlaylistSongs(playlist.uid, songs.map { PlaylistSong(it.uid) })
} }
} }

View file

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

View file

@ -21,16 +21,112 @@ package org.oxycblt.auxio.music.user
import androidx.room.* import androidx.room.*
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
/**
* Allows persistence of all user-created music information.
* @author Alexander Capehart (OxygenCobalt)
*/
@Database( @Database(
entities = [PlaylistInfo::class, PlaylistSong::class, PlaylistSongCrossRef::class], entities = [PlaylistInfo::class, PlaylistSong::class, PlaylistSongCrossRef::class],
version = 28, version = 30,
exportSchema = false) exportSchema = false)
@TypeConverters(Music.UID.TypeConverters::class) @TypeConverters(Music.UID.TypeConverters::class)
abstract class UserMusicDatabase : RoomDatabase() { abstract class UserMusicDatabase : RoomDatabase() {
abstract fun playlistDao(): PlaylistDao abstract fun playlistDao(): PlaylistDao
} }
// TODO: Handle playlist defragmentation? I really don't want dead songs to accumulate in this
// database.
/**
* The DAO for persisted playlist information.
* @author Alexander Capehart (OxygenCobalt)
*/
@Dao @Dao
interface PlaylistDao { interface PlaylistDao {
@Transaction @Query("SELECT * FROM PlaylistInfo") fun readRawPlaylists(): List<RawPlaylist> /**
* Read out all playlists stored in the database.
* @return A list of [RawPlaylist] representing each playlist stored.
*/
@Transaction
@Query("SELECT * FROM PlaylistInfo")
suspend fun readRawPlaylists(): List<RawPlaylist>
/**
* Create a new playlist.
* @param rawPlaylist The [RawPlaylist] to create.
*/
@Transaction
suspend fun insertPlaylist(rawPlaylist: RawPlaylist) {
insertInfo(rawPlaylist.playlistInfo)
insertSongs(rawPlaylist.songs)
insertRefs(
rawPlaylist.songs.map {
PlaylistSongCrossRef(
playlistUid = rawPlaylist.playlistInfo.playlistUid, songUid = it.songUid)
})
}
/**
* Replace the currently-stored [PlaylistInfo] for a playlist entry.
* @param playlistInfo The new [PlaylistInfo] to store.
*/
@Transaction
suspend fun replacePlaylistInfo(playlistInfo: PlaylistInfo) {
deleteInfo(playlistInfo.playlistUid)
insertInfo(playlistInfo)
}
/**
* Delete a playlist entry's [PlaylistInfo] and [PlaylistSong].
* @param playlistUid The [Music.UID] of the playlist to delete.
*/
@Transaction
suspend fun deletePlaylist(playlistUid: Music.UID) {
deleteInfo(playlistUid)
deleteRefs(playlistUid)
}
/**
* Insert new song entries into a playlist.
* @param playlistUid The [Music.UID] of the playlist to insert into.
* @param songs The [PlaylistSong] representing each song to put into the playlist.
*/
@Transaction
suspend fun insertPlaylistSongs(playlistUid: Music.UID, songs: List<PlaylistSong>) {
insertSongs(songs)
insertRefs(
songs.map { PlaylistSongCrossRef(playlistUid = playlistUid, songUid = it.songUid) })
}
/**
* Replace the currently-stored [Song]s of the current playlist entry.
* @param playlistUid The [Music.UID] of the playlist to update.
* @param songs The [PlaylistSong] representing the new list of songs to be placed in the playlist.
*/
@Transaction
suspend fun replacePlaylistSongs(playlistUid: Music.UID, songs: List<PlaylistSong>) {
deleteRefs(playlistUid)
insertSongs(songs)
insertRefs(
songs.map { PlaylistSongCrossRef(playlistUid = playlistUid, songUid = it.songUid) })
}
/** Internal, do not use. */
@Insert(onConflict = OnConflictStrategy.ABORT) suspend fun insertInfo(info: PlaylistInfo)
/** Internal, do not use. */
@Query("DELETE FROM PlaylistInfo where playlistUid = :playlistUid")
suspend fun deleteInfo(playlistUid: Music.UID)
/** Internal, do not use. */
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertSongs(songs: List<PlaylistSong>)
/** Internal, do not use. */
@Insert(onConflict = OnConflictStrategy.ABORT)
suspend fun insertRefs(refs: List<PlaylistSongCrossRef>)
/** Internal, do not use. */
@Query("DELETE FROM PlaylistSongCrossRef where playlistUid = :playlistUid")
suspend fun deleteRefs(playlistUid: Music.UID)
} }