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() {
val playlist = _currentPlaylist.value ?: return
val editedPlaylist = (_editedPlaylist.value ?: return).also { _editedPlaylist.value = null }
val editedPlaylist = _editedPlaylist.value ?: return
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 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].
@ -124,14 +124,14 @@ interface MusicRepository {
* @param playlist The [Playlist] to rename.
* @param name The name of the new [Playlist].
*/
fun renamePlaylist(playlist: Playlist, name: String)
suspend fun renamePlaylist(playlist: Playlist, name: String)
/**
* Delete a [Playlist].
*
* @param playlist The playlist to delete.
*/
fun deletePlaylist(playlist: Playlist)
suspend fun deletePlaylist(playlist: 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 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].
@ -147,7 +147,7 @@ interface MusicRepository {
* @param playlist The [Playlist] to update.
* @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
@ -276,7 +276,7 @@ constructor(
(deviceLibrary?.run { findSong(uid) ?: findAlbum(uid) ?: findArtist(uid) ?: findGenre(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
userLibrary.createPlaylist(name, songs)
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
userLibrary.renamePlaylist(playlist, name)
for (listener in updateListeners) {
@ -294,7 +294,7 @@ constructor(
}
}
override fun deletePlaylist(playlist: Playlist) {
override suspend fun deletePlaylist(playlist: Playlist) {
val userLibrary = userLibrary ?: return
userLibrary.deletePlaylist(playlist)
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
userLibrary.addToPlaylist(playlist, songs)
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
userLibrary.rewritePlaylist(playlist, songs)
for (listener in updateListeners) {

View file

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

View file

@ -21,6 +21,10 @@ package org.oxycblt.auxio.music.user
import androidx.room.*
import org.oxycblt.auxio.music.Music
/**
* Raw playlist information persisted to [UserMusicDatabase].
* @author Alexander Capehart (OxygenCobalt)
*/
data class RawPlaylist(
@Embedded val playlistInfo: PlaylistInfo,
@Relation(
@ -30,12 +34,26 @@ data class RawPlaylist(
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)
/**
* Song information corresponding to a [RawPlaylist] entry.
* @author Alexander Capehart (OxygenCobalt)
*/
@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(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
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].
*
* @param deviceLibrary Asynchronously populated [DeviceLibrary] that can be obtained later.
* This allows database information to be read before the actual instance is constructed.
* @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(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 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].
@ -86,21 +87,21 @@ interface MutableUserLibrary : UserLibrary {
* @param playlist The [Playlist] to rename.
* @param name The name of the new [Playlist].
*/
fun renamePlaylist(playlist: Playlist, name: String)
suspend fun renamePlaylist(playlist: Playlist, name: String)
/**
* Delete a [Playlist].
*
* @param playlist The playlist to delete.
*/
fun deletePlaylist(playlist: Playlist)
suspend fun deletePlaylist(playlist: Playlist)
/**
* Add [Song]s to a [Playlist].
*
* @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].
@ -108,23 +109,32 @@ interface MutableUserLibrary : UserLibrary {
* @param playlist The [Playlist] to update.
* @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
@Inject
constructor(private val playlistDao: PlaylistDao, private val musicSettings: MusicSettings) :
UserLibrary.Factory {
override suspend fun read(deviceLibrary: Channel<DeviceLibrary>): MutableUserLibrary =
UserLibraryImpl(playlistDao, deviceLibrary.receive(), musicSettings)
override suspend fun read(deviceLibraryChannel: Channel<DeviceLibrary>): MutableUserLibrary {
// 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 val playlistDao: PlaylistDao,
private val deviceLibrary: DeviceLibrary,
private val playlistMap: MutableMap<Music.UID, PlaylistImpl>,
private val musicSettings: MusicSettings
) : MutableUserLibrary {
private val playlistMap = mutableMapOf<Music.UID, PlaylistImpl>()
override val playlists: List<Playlist>
get() = playlistMap.values.toList()
@ -132,35 +142,41 @@ private class UserLibraryImpl(
override fun findPlaylist(name: String) = playlistMap.values.find { it.name.raw == name }
@Synchronized
override fun createPlaylist(name: String, songs: List<Song>) {
override suspend fun createPlaylist(name: String, songs: List<Song>) {
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 fun renamePlaylist(playlist: Playlist, name: String) {
override suspend fun renamePlaylist(playlist: Playlist, name: String) {
val playlistImpl =
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 fun deletePlaylist(playlist: Playlist) {
override suspend fun deletePlaylist(playlist: Playlist) {
synchronized(this) {
requireNotNull(playlistMap.remove(playlist.uid)) { "Cannot remove invalid playlist" }
}
playlistDao.deletePlaylist(playlist.uid)
}
@Synchronized
override fun addToPlaylist(playlist: Playlist, songs: List<Song>) {
override suspend 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) }
synchronized(this) { playlistMap[playlist.uid] = playlistImpl.edit { addAll(songs) } }
playlistDao.insertPlaylistSongs(playlist.uid, songs.map { PlaylistSong(it.uid) })
}
@Synchronized
override fun rewritePlaylist(playlist: Playlist, songs: List<Song>) {
override suspend fun rewritePlaylist(playlist: Playlist, songs: List<Song>) {
val playlistImpl =
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
@InstallIn(SingletonComponent::class)
interface UserModule {
@Binds fun userLibaryFactory(factory: UserLibraryFactoryImpl): UserLibrary.Factory
@Binds fun userLibraryFactory(factory: UserLibraryFactoryImpl): UserLibrary.Factory
}
@Module

View file

@ -21,16 +21,112 @@ package org.oxycblt.auxio.music.user
import androidx.room.*
import org.oxycblt.auxio.music.Music
/**
* Allows persistence of all user-created music information.
* @author Alexander Capehart (OxygenCobalt)
*/
@Database(
entities = [PlaylistInfo::class, PlaylistSong::class, PlaylistSongCrossRef::class],
version = 28,
version = 30,
exportSchema = false)
@TypeConverters(Music.UID.TypeConverters::class)
abstract class UserMusicDatabase : RoomDatabase() {
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
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)
}