package it.vfsfitvnm.vimusic import android.content.ContentValues import android.content.Context import android.database.Cursor import android.database.sqlite.SQLiteDatabase.CONFLICT_IGNORE import android.os.Parcel import androidx.core.database.getFloatOrNull import androidx.media3.common.MediaItem import androidx.room.AutoMigration import androidx.room.Dao import androidx.room.Delete import androidx.room.DeleteTable import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.RenameColumn import androidx.room.RenameTable import androidx.room.RewriteQueriesToDropUnusedColumns import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.Transaction import androidx.room.TypeConverter import androidx.room.TypeConverters import androidx.room.Update import androidx.room.migration.AutoMigrationSpec import androidx.room.migration.Migration import androidx.sqlite.db.SimpleSQLiteQuery import androidx.sqlite.db.SupportSQLiteDatabase import it.vfsfitvnm.vimusic.enums.PlaylistSortBy import it.vfsfitvnm.vimusic.enums.SongSortBy import it.vfsfitvnm.vimusic.enums.SortOrder import it.vfsfitvnm.vimusic.models.Album import it.vfsfitvnm.vimusic.models.Artist import it.vfsfitvnm.vimusic.models.DetailedSong import it.vfsfitvnm.vimusic.models.DetailedSongWithContentLength import it.vfsfitvnm.vimusic.models.Format import it.vfsfitvnm.vimusic.models.Playlist import it.vfsfitvnm.vimusic.models.PlaylistPreview import it.vfsfitvnm.vimusic.models.PlaylistWithSongs import it.vfsfitvnm.vimusic.models.QueuedMediaItem import it.vfsfitvnm.vimusic.models.SearchQuery import it.vfsfitvnm.vimusic.models.Song import it.vfsfitvnm.vimusic.models.SongAlbumMap import it.vfsfitvnm.vimusic.models.SongArtistMap import it.vfsfitvnm.vimusic.models.SongPlaylistMap import it.vfsfitvnm.vimusic.models.SortedSongPlaylistMap import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @Dao interface Database { companion object : Database by DatabaseInitializer.Instance.database @Transaction @Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY ROWID ASC") fun songsByRowIdAsc(): Flow> @Transaction @Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY ROWID DESC") fun songsByRowIdDesc(): Flow> @Transaction @Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY title ASC") fun songsByTitleAsc(): Flow> @Transaction @Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY title DESC") fun songsByTitleDesc(): Flow> @Transaction @Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY totalPlayTimeMs ASC") fun songsByPlayTimeAsc(): Flow> @Transaction @Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY totalPlayTimeMs DESC") fun songsByPlayTimeDesc(): Flow> fun songs(sortBy: SongSortBy, sortOrder: SortOrder): Flow> { return when (sortBy) { SongSortBy.PlayTime -> when (sortOrder) { SortOrder.Ascending -> songsByPlayTimeAsc() SortOrder.Descending -> songsByPlayTimeDesc() } SongSortBy.Title -> when (sortOrder) { SortOrder.Ascending -> songsByTitleAsc() SortOrder.Descending -> songsByTitleDesc() } SongSortBy.DateAdded -> when (sortOrder) { SortOrder.Ascending -> songsByRowIdAsc() SortOrder.Descending -> songsByRowIdDesc() } } } @Transaction @Query("SELECT * FROM Song WHERE likedAt IS NOT NULL ORDER BY likedAt DESC") fun favorites(): Flow> @Query("SELECT * FROM QueuedMediaItem") fun queue(): List @Query("DELETE FROM QueuedMediaItem") fun clearQueue() @Query("SELECT * FROM SearchQuery WHERE query LIKE :query ORDER BY id DESC") fun queries(query: String): Flow> @Query("SELECT * FROM Song WHERE id = :id") fun song(id: String): Flow @Query("SELECT likedAt FROM Song WHERE id = :songId") fun likedAt(songId: String): Flow @Query("UPDATE Song SET likedAt = :likedAt WHERE id = :songId") fun like(songId: String, likedAt: Long?): Int @Query("SELECT lyrics FROM Song WHERE id = :songId") fun lyrics(songId: String): Flow @Query("SELECT synchronizedLyrics FROM Song WHERE id = :songId") fun synchronizedLyrics(songId: String): Flow @Query("UPDATE Song SET lyrics = :lyrics WHERE id = :songId") fun updateLyrics(songId: String, lyrics: String?): Int @Query("UPDATE Song SET synchronizedLyrics = :lyrics WHERE id = :songId") fun updateSynchronizedLyrics(songId: String, lyrics: String?): Int @Query("SELECT * FROM Artist WHERE id = :id") fun artist(id: String): Flow @Query("SELECT * FROM Album WHERE id = :id") fun album(id: String): Flow @Query("UPDATE Song SET totalPlayTimeMs = totalPlayTimeMs + :addition WHERE id = :id") fun incrementTotalPlayTimeMs(id: String, addition: Long) @Transaction @Query("SELECT * FROM Playlist WHERE id = :id") fun playlistWithSongs(id: Long): Flow @Transaction @Query("SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount FROM Playlist ORDER BY name ASC") fun playlistPreviewsByName(): Flow> @Transaction @Query("SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount FROM Playlist ORDER BY ROWID ASC") fun playlistPreviewsByDateAdded(): Flow> @Transaction @Query("SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount FROM Playlist ORDER BY songCount ASC") fun playlistPreviewsByDateSongCount(): Flow> fun playlistPreviews( sortBy: PlaylistSortBy, sortOrder: SortOrder ): Flow> { return when (sortBy) { PlaylistSortBy.Name -> playlistPreviewsByName() PlaylistSortBy.DateAdded -> playlistPreviewsByDateAdded() PlaylistSortBy.SongCount -> playlistPreviewsByDateSongCount() }.map { when (sortOrder) { SortOrder.Ascending -> it SortOrder.Descending -> it.reversed() } } } @Query("SELECT thumbnailUrl FROM Song JOIN SongPlaylistMap ON id = songId WHERE playlistId = :id ORDER BY position LIMIT 4") fun playlistThumbnailUrls(id: Long): Flow> @Transaction @Query("SELECT * FROM Song JOIN SongArtistMap ON Song.id = SongArtistMap.songId WHERE SongArtistMap.artistId = :artistId AND totalPlayTimeMs > 0 ORDER BY Song.ROWID DESC") @RewriteQueriesToDropUnusedColumns fun artistSongs(artistId: String): Flow> @Transaction @Query("SELECT * FROM Song JOIN SongAlbumMap ON Song.id = SongAlbumMap.songId WHERE SongAlbumMap.albumId = :albumId AND position IS NOT NULL ORDER BY position") @RewriteQueriesToDropUnusedColumns fun albumSongs(albumId: String): Flow> @Query("SELECT * FROM Format WHERE songId = :songId") fun format(songId: String): Flow @Transaction @Query("SELECT * FROM Song JOIN Format ON id = songId WHERE contentLength IS NOT NULL AND totalPlayTimeMs > 0 ORDER BY Song.ROWID DESC") @RewriteQueriesToDropUnusedColumns fun songsWithContentLength(): Flow> @Query("UPDATE SongPlaylistMap SET position = position - 1 WHERE playlistId = :playlistId AND position >= :fromPosition") fun decrementSongPositions(playlistId: Long, fromPosition: Int) @Query("UPDATE SongPlaylistMap SET position = position - 1 WHERE playlistId = :playlistId AND position >= :fromPosition AND position <= :toPosition") fun decrementSongPositions(playlistId: Long, fromPosition: Int, toPosition: Int) @Query("UPDATE SongPlaylistMap SET position = position + 1 WHERE playlistId = :playlistId AND position >= :fromPosition AND position <= :toPosition") fun incrementSongPositions(playlistId: Long, fromPosition: Int, toPosition: Int) @Query("SELECT loudnessDb FROM Format WHERE songId = :songId") fun loudnessDb(songId: String): Flow @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(format: Format) @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(searchQuery: SearchQuery) @Insert(onConflict = OnConflictStrategy.IGNORE) fun insert(info: Artist): Long @Insert(onConflict = OnConflictStrategy.IGNORE) fun insert(info: Album): Long @Insert(onConflict = OnConflictStrategy.IGNORE) fun insert(playlist: Playlist): Long @Insert(onConflict = OnConflictStrategy.IGNORE) fun insert(songPlaylistMap: SongPlaylistMap): Long @Insert(onConflict = OnConflictStrategy.IGNORE) fun insert(songAlbumMap: SongAlbumMap): Long @Insert(onConflict = OnConflictStrategy.ABORT) fun insert(songArtistMap: SongArtistMap): Long @Insert(onConflict = OnConflictStrategy.IGNORE) fun insert(song: Song): Long @Insert(onConflict = OnConflictStrategy.ABORT) fun insert(queuedMediaItems: List) @Transaction fun insert(mediaItem: MediaItem, block: (Song) -> Song = { it }) { val song = Song( id = mediaItem.mediaId, title = mediaItem.mediaMetadata.title!!.toString(), artistsText = mediaItem.mediaMetadata.artist?.toString(), durationText = mediaItem.mediaMetadata.extras?.getString("durationText")!!, thumbnailUrl = mediaItem.mediaMetadata.artworkUri?.toString() ).let(block).also { song -> if (insert(song) == -1L) return } mediaItem.mediaMetadata.extras?.getString("albumId")?.let { albumId -> Album( id = albumId, title = mediaItem.mediaMetadata.albumTitle?.toString(), year = null, authorsText = null, thumbnailUrl = null, shareUrl = null, timestamp = null, ).also(::insert) upsert( SongAlbumMap( songId = song.id, albumId = albumId, position = null ) ) } mediaItem.mediaMetadata.extras?.getStringArrayList("artistNames")?.let { artistNames -> mediaItem.mediaMetadata.extras?.getStringArrayList("artistIds")?.let { artistIds -> artistNames.mapIndexed { index, artistName -> Artist( id = artistIds[index], name = artistName, thumbnailUrl = null, info = null, timestamp = null, ).also(::insert) } } }?.forEach { artist -> insert( SongArtistMap( songId = song.id, artistId = artist.id ) ) } } @Update fun update(song: Song) @Update fun update(artist: Artist) @Update fun update(album: Album) @Update fun update(songAlbumMap: SongAlbumMap) @Update fun update(songPlaylistMap: SongPlaylistMap) @Update fun update(playlist: Playlist) @Delete fun delete(searchQuery: SearchQuery) @Delete fun delete(playlist: Playlist) @Delete fun delete(playlist: Album) @Delete fun delete(songPlaylistMap: SongPlaylistMap) fun upsert(songAlbumMap: SongAlbumMap) { if (insert(songAlbumMap) == -1L) { update(songAlbumMap) } } fun upsert(artist: Artist) { if (insert(artist) == -1L) { update(artist) } } fun upsert(album: Album) { if (insert(album) == -1L) { update(album) } } } @androidx.room.Database( entities = [ Song::class, SongPlaylistMap::class, Playlist::class, Artist::class, SongArtistMap::class, Album::class, SongAlbumMap::class, SearchQuery::class, QueuedMediaItem::class, Format::class, ], views = [ SortedSongPlaylistMap::class ], version = 16, exportSchema = true, autoMigrations = [ AutoMigration(from = 1, to = 2), AutoMigration(from = 2, to = 3), AutoMigration(from = 3, to = 4, spec = DatabaseInitializer.From3To4Migration::class), AutoMigration(from = 4, to = 5), AutoMigration(from = 5, to = 6), AutoMigration(from = 6, to = 7), AutoMigration(from = 7, to = 8, spec = DatabaseInitializer.From7To8Migration::class), AutoMigration(from = 9, to = 10), AutoMigration(from = 11, to = 12, spec = DatabaseInitializer.From11To12Migration::class), AutoMigration(from = 12, to = 13), AutoMigration(from = 13, to = 14), AutoMigration(from = 15, to = 16), ], ) @TypeConverters(Converters::class) abstract class DatabaseInitializer protected constructor() : RoomDatabase() { abstract val database: Database companion object { lateinit var Instance: DatabaseInitializer context(Context) operator fun invoke() { if (!::Instance.isInitialized) { Instance = Room .databaseBuilder(this@Context, DatabaseInitializer::class.java, "data.db") .addMigrations( From8To9Migration(), From10To11Migration(), From14To15Migration() ) .build() } } } @DeleteTable.Entries(DeleteTable(tableName = "QueuedMediaItem")) class From3To4Migration : AutoMigrationSpec @RenameColumn.Entries(RenameColumn("Song", "albumInfoId", "albumId")) class From7To8Migration : AutoMigrationSpec class From8To9Migration : Migration(8, 9) { override fun migrate(it: SupportSQLiteDatabase) { it.query(SimpleSQLiteQuery("SELECT DISTINCT browseId, text, Info.id FROM Info JOIN Song ON Info.id = Song.albumId;")) .use { cursor -> val albumValues = ContentValues(2) while (cursor.moveToNext()) { albumValues.put("id", cursor.getString(0)) albumValues.put("title", cursor.getString(1)) it.insert("Album", CONFLICT_IGNORE, albumValues) it.execSQL( "UPDATE Song SET albumId = '${cursor.getString(0)}' WHERE albumId = ${ cursor.getLong( 2 ) }" ) } } it.query(SimpleSQLiteQuery("SELECT GROUP_CONCAT(text, ''), SongWithAuthors.songId FROM Info JOIN SongWithAuthors ON Info.id = SongWithAuthors.authorInfoId GROUP BY songId;")) .use { cursor -> val songValues = ContentValues(1) while (cursor.moveToNext()) { songValues.put("artistsText", cursor.getString(0)) it.update( "Song", CONFLICT_IGNORE, songValues, "id = ?", arrayOf(cursor.getString(1)) ) } } it.query(SimpleSQLiteQuery("SELECT browseId, text, Info.id FROM Info JOIN SongWithAuthors ON Info.id = SongWithAuthors.authorInfoId WHERE browseId NOT NULL;")) .use { cursor -> val artistValues = ContentValues(2) while (cursor.moveToNext()) { artistValues.put("id", cursor.getString(0)) artistValues.put("name", cursor.getString(1)) it.insert("Artist", CONFLICT_IGNORE, artistValues) it.execSQL( "UPDATE SongWithAuthors SET authorInfoId = '${cursor.getString(0)}' WHERE authorInfoId = ${ cursor.getLong( 2 ) }" ) } } it.execSQL("INSERT INTO SongArtistMap(songId, artistId) SELECT songId, authorInfoId FROM SongWithAuthors") it.execSQL("DROP TABLE Info;") it.execSQL("DROP TABLE SongWithAuthors;") } } class From10To11Migration : Migration(10, 11) { override fun migrate(it: SupportSQLiteDatabase) { it.query(SimpleSQLiteQuery("SELECT id, albumId FROM Song;")).use { cursor -> val songAlbumMapValues = ContentValues(2) while (cursor.moveToNext()) { songAlbumMapValues.put("songId", cursor.getString(0)) songAlbumMapValues.put("albumId", cursor.getString(1)) it.insert("SongAlbumMap", CONFLICT_IGNORE, songAlbumMapValues) } } it.execSQL("CREATE TABLE IF NOT EXISTS `Song_new` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessDb` REAL, `contentLength` INTEGER, PRIMARY KEY(`id`))") it.execSQL("INSERT INTO Song_new(id, title, artistsText, durationText, thumbnailUrl, lyrics, likedAt, totalPlayTimeMs, loudnessDb, contentLength) SELECT id, title, artistsText, durationText, thumbnailUrl, lyrics, likedAt, totalPlayTimeMs, loudnessDb, contentLength FROM Song;") it.execSQL("DROP TABLE Song;") it.execSQL("ALTER TABLE Song_new RENAME TO Song;") } } @RenameTable("SongInPlaylist", "SongPlaylistMap") @RenameTable("SortedSongInPlaylist", "SortedSongPlaylistMap") class From11To12Migration : AutoMigrationSpec class From14To15Migration : Migration(14, 15) { override fun migrate(it: SupportSQLiteDatabase) { it.query(SimpleSQLiteQuery("SELECT id, loudnessDb, contentLength FROM Song;")) .use { cursor -> val formatValues = ContentValues(3) while (cursor.moveToNext()) { formatValues.put("songId", cursor.getString(0)) formatValues.put("loudnessDb", cursor.getFloatOrNull(1)) formatValues.put("contentLength", cursor.getFloatOrNull(2)) it.insert("Format", CONFLICT_IGNORE, formatValues) } } it.execSQL("CREATE TABLE IF NOT EXISTS `Song_new` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))") it.execSQL("INSERT INTO Song_new(id, title, artistsText, durationText, thumbnailUrl, lyrics, likedAt, totalPlayTimeMs) SELECT id, title, artistsText, durationText, thumbnailUrl, lyrics, likedAt, totalPlayTimeMs FROM Song;") it.execSQL("DROP TABLE Song;") it.execSQL("ALTER TABLE Song_new RENAME TO Song;") } } } @TypeConverters object Converters { @TypeConverter fun mediaItemFromByteArray(value: ByteArray?): MediaItem? { return value?.let { byteArray -> runCatching { val parcel = Parcel.obtain() parcel.unmarshall(byteArray, 0, byteArray.size) parcel.setDataPosition(0) val bundle = parcel.readBundle(MediaItem::class.java.classLoader) parcel.recycle() bundle?.let(MediaItem.CREATOR::fromBundle) }.getOrNull() } } @TypeConverter fun mediaItemToByteArray(mediaItem: MediaItem?): ByteArray? { return mediaItem?.toBundle()?.let { persistableBundle -> val parcel = Parcel.obtain() parcel.writeBundle(persistableBundle) val bytes = parcel.marshall() parcel.recycle() bytes } } } val Database.internal: RoomDatabase get() = DatabaseInitializer.Instance fun query(block: () -> Unit) = DatabaseInitializer.Instance.getQueryExecutor().execute(block) fun transaction(block: () -> Unit) = with(DatabaseInitializer.Instance) { getTransactionExecutor().execute { runInTransaction(block) } } val RoomDatabase.path: String get() = getOpenHelper().writableDatabase.path fun RoomDatabase.checkpoint() { getOpenHelper().writableDatabase.run { query("PRAGMA journal_mode").use { cursor -> if (cursor.moveToFirst()) { when (cursor.getString(0).lowercase()) { "wal" -> { query("PRAGMA wal_checkpoint").use(Cursor::moveToFirst) query("PRAGMA wal_checkpoint(TRUNCATE)").use(Cursor::moveToFirst) query("PRAGMA wal_checkpoint").use(Cursor::moveToFirst) } } } } } }