diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/13.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/13.json new file mode 100644 index 0000000..d4644bf --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/13.json @@ -0,0 +1,530 @@ +{ + "formatVersion": 1, + "database": { + "version": 13, + "identityHash": "61cd3db93beeafd3ca398be54544c752", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`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`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongPlaylistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongPlaylistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongPlaylistMap_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `info` TEXT, `shuffleVideoId` TEXT, `shufflePlaylistId` TEXT, `radioVideoId` TEXT, `radioPlaylistId` TEXT, `timestamp` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "info", + "columnName": "info", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shuffleVideoId", + "columnName": "shuffleVideoId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shufflePlaylistId", + "columnName": "shufflePlaylistId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "radioVideoId", + "columnName": "radioVideoId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "radioPlaylistId", + "columnName": "radioPlaylistId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "SortedSongPlaylistMap", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '61cd3db93beeafd3ca398be54544c752')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt index a2061dc..ed77a87 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt @@ -156,6 +156,7 @@ interface Database { authorsText = null, thumbnailUrl = null, shareUrl = null, + timestamp = null, ).also(::insert) upsert( @@ -174,7 +175,8 @@ interface Database { id = artistIds[index], name = artistName, thumbnailUrl = null, - info = null + info = null, + timestamp = null, ).also(::insert) } } @@ -212,6 +214,9 @@ interface Database { @Delete fun delete(playlist: Playlist) + @Delete + fun delete(playlist: Album) + @Delete fun delete(songPlaylistMap: SongPlaylistMap) @@ -249,7 +254,7 @@ interface Database { views = [ SortedSongPlaylistMap::class ], - version = 12, + version = 13, exportSchema = true, autoMigrations = [ AutoMigration(from = 1, to = 2), @@ -261,6 +266,7 @@ interface Database { 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), ], ) @TypeConverters(Converters::class) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Album.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Album.kt index 405a167..1e2bc74 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Album.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Album.kt @@ -10,5 +10,6 @@ data class Album( val thumbnailUrl: String? = null, val year: String? = null, val authorsText: String? = null, - val shareUrl: String? = null + val shareUrl: String? = null, + val timestamp: Long? ) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Artist.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Artist.kt index 55a3fea..f011958 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Artist.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Artist.kt @@ -13,4 +13,5 @@ data class Artist( val shufflePlaylistId: String? = null, val radioVideoId: String? = null, val radioPlaylistId: String? = null, + val timestamp: Long? ) \ No newline at end of file diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/AlbumScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/AlbumScreen.kt index bc11f9b..d0c5e54 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/AlbumScreen.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/AlbumScreen.kt @@ -45,6 +45,9 @@ import it.vfsfitvnm.youtubemusic.YouTube import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking +import java.text.DateFormat +import java.util.* @ExperimentalAnimationApi @@ -56,32 +59,10 @@ fun AlbumScreen( val albumResult by remember(browseId) { Database.album(browseId).map { album -> - album?.takeIf { - album.thumbnailUrl != null - }?.let(Result.Companion::success) ?: YouTube.playlistOrAlbum(browseId) - ?.map { youtubeAlbum -> - Album( - id = browseId, - title = youtubeAlbum.title, - thumbnailUrl = youtubeAlbum.thumbnail?.url, - year = youtubeAlbum.year, - authorsText = youtubeAlbum.authors?.joinToString("") { it.name }, - shareUrl = youtubeAlbum.url - ).also(Database::upsert).also { - youtubeAlbum.withAudioSources().items?.forEachIndexed { position, albumItem -> - albumItem.toMediaItem(browseId, youtubeAlbum)?.let { mediaItem -> - Database.insert(mediaItem) - Database.upsert( - SongAlbumMap( - songId = mediaItem.mediaId, - albumId = browseId, - position = position - ) - ) - } - } - } - } + album + ?.takeIf { album.timestamp != null } + ?.let(Result.Companion::success) + ?: fetchAlbum(browseId) }.distinctUntilChanged() }.collectAsState(initial = null, context = Dispatchers.IO) @@ -210,6 +191,25 @@ fun AlbumScreen( } } ) + + MenuEntry( + icon = R.drawable.download, + text = "Refetch", + secondaryText = albumResult?.getOrNull()?.timestamp?.let { timestamp -> + "Last updated on ${DateFormat.getDateTimeInstance().format(Date(timestamp))}" + }, + isEnabled = albumResult?.getOrNull() != null, + onClick = { + menuState.hide() + + query { + albumResult?.getOrNull()?.let(Database::delete) + runBlocking(Dispatchers.IO) { + fetchAlbum(browseId) + } + } + } + ) } } } @@ -392,3 +392,31 @@ private fun LoadingOrError( } } } + +private suspend fun fetchAlbum(browseId: String): Result? { + return YouTube.playlistOrAlbum(browseId) + ?.map { youtubeAlbum -> + Album( + id = browseId, + title = youtubeAlbum.title, + thumbnailUrl = youtubeAlbum.thumbnail?.url, + year = youtubeAlbum.year, + authorsText = youtubeAlbum.authors?.joinToString("") { it.name }, + shareUrl = youtubeAlbum.url, + timestamp = System.currentTimeMillis() + ).also(Database::upsert).also { + youtubeAlbum.withAudioSources().items?.forEachIndexed { position, albumItem -> + albumItem.toMediaItem(browseId, youtubeAlbum)?.let { mediaItem -> + Database.insert(mediaItem) + Database.upsert( + SongAlbumMap( + songId = mediaItem.mediaId, + albumId = browseId, + position = position + ) + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/ArtistScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/ArtistScreen.kt index 1cb2884..113cb26 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/ArtistScreen.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/ArtistScreen.kt @@ -82,21 +82,10 @@ fun ArtistScreen( val artistResult by remember(browseId) { Database.artist(browseId).map { artist -> - artist?.takeIf { - artist.shufflePlaylistId != null - }?.let(Result.Companion::success) ?: YouTube.artist(browseId) - ?.map { youtubeArtist -> - Artist( - id = browseId, - name = youtubeArtist.name, - thumbnailUrl = youtubeArtist.thumbnail?.url, - info = youtubeArtist.description, - shuffleVideoId = youtubeArtist.shuffleEndpoint?.videoId, - shufflePlaylistId = youtubeArtist.shuffleEndpoint?.playlistId, - radioVideoId = youtubeArtist.radioEndpoint?.videoId, - radioPlaylistId = youtubeArtist.radioEndpoint?.playlistId, - ).also(Database::upsert) - } + artist + ?.takeIf { artist.timestamp != null } + ?.let(Result.Companion::success) + ?: fetchArtist(browseId) }.distinctUntilChanged() }.collectAsState(initial = null, context = Dispatchers.IO) @@ -139,17 +128,6 @@ fun ArtistScreen( contentDescription = null, modifier = Modifier .clip(CircleShape) - .clickable { - query { - runBlocking { - Database - .artist(browseId) - .first() - ?.copy(shufflePlaylistId = null) - ?.let(Database::update) - } - } - } .size(Dimensions.thumbnails.artist) ) @@ -177,6 +155,12 @@ fun ArtistScreen( playlistId = artist.shufflePlaylistId ) ) + + query { + runBlocking { + fetchArtist(browseId) + } + } } .shadow(elevation = 2.dp, shape = CircleShape) .background( @@ -200,6 +184,12 @@ fun ArtistScreen( playlistId = artist.radioPlaylistId ) ) + + query { + runBlocking { + fetchArtist(browseId) + } + } } .shadow(elevation = 2.dp, shape = CircleShape) .background( @@ -285,7 +275,10 @@ fun ArtistScreen( artistResult?.getOrNull()?.info?.let { description -> item { - TextCard { + TextCard( + modifier = Modifier + .fillMaxWidth() + ) { Title(text = "Information") Text(text = description) } @@ -329,3 +322,20 @@ private fun LoadingOrError( } } } + +private suspend fun fetchArtist(browseId: String): Result? { + return YouTube.artist(browseId) + ?.map { youtubeArtist -> + Artist( + id = browseId, + name = youtubeArtist.name, + thumbnailUrl = youtubeArtist.thumbnail?.url, + info = youtubeArtist.description, + shuffleVideoId = youtubeArtist.shuffleEndpoint?.videoId, + shufflePlaylistId = youtubeArtist.shuffleEndpoint?.playlistId, + radioVideoId = youtubeArtist.radioEndpoint?.videoId, + radioPlaylistId = youtubeArtist.radioEndpoint?.playlistId, + timestamp = System.currentTimeMillis() + ).also(Database::upsert) + } +} \ No newline at end of file