diff --git a/app/schemas/org.schabi.newpipe.database.AppDatabase/9.json b/app/schemas/org.schabi.newpipe.database.AppDatabase/9.json
new file mode 100644
index 000000000..aced06c0a
--- /dev/null
+++ b/app/schemas/org.schabi.newpipe.database.AppDatabase/9.json
@@ -0,0 +1,730 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 9,
+ "identityHash": "7591e8039faa74d8c0517dc867af9d3e",
+ "entities": [
+ {
+ "tableName": "subscriptions",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT, `name` TEXT, `avatar_url` TEXT, `subscriber_count` INTEGER, `description` TEXT, `notification_mode` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "uid",
+ "columnName": "uid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serviceId",
+ "columnName": "service_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "avatarUrl",
+ "columnName": "avatar_url",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "subscriberCount",
+ "columnName": "subscriber_count",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "notificationMode",
+ "columnName": "notification_mode",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "uid"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_subscriptions_service_id_url",
+ "unique": true,
+ "columnNames": [
+ "service_id",
+ "url"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
+ }
+ ],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "search_history",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`creation_date` INTEGER, `service_id` INTEGER NOT NULL, `search` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "creationDate",
+ "columnName": "creation_date",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "serviceId",
+ "columnName": "service_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "search",
+ "columnName": "search",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_search_history_search",
+ "unique": false,
+ "columnNames": [
+ "search"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_search_history_search` ON `${TABLE_NAME}` (`search`)"
+ }
+ ],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "streams",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `stream_type` TEXT NOT NULL, `duration` INTEGER NOT NULL, `uploader` TEXT NOT NULL, `uploader_url` TEXT, `thumbnail_url` TEXT, `view_count` INTEGER, `textual_upload_date` TEXT, `upload_date` INTEGER, `is_upload_date_approximation` INTEGER)",
+ "fields": [
+ {
+ "fieldPath": "uid",
+ "columnName": "uid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serviceId",
+ "columnName": "service_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "streamType",
+ "columnName": "stream_type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "uploader",
+ "columnName": "uploader",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "uploaderUrl",
+ "columnName": "uploader_url",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "thumbnailUrl",
+ "columnName": "thumbnail_url",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "viewCount",
+ "columnName": "view_count",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "textualUploadDate",
+ "columnName": "textual_upload_date",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "uploadDate",
+ "columnName": "upload_date",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "isUploadDateApproximation",
+ "columnName": "is_upload_date_approximation",
+ "affinity": "INTEGER",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "uid"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_streams_service_id_url",
+ "unique": true,
+ "columnNames": [
+ "service_id",
+ "url"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_streams_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
+ }
+ ],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "stream_history",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, `repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "streamUid",
+ "columnName": "stream_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accessDate",
+ "columnName": "access_date",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "repeatCount",
+ "columnName": "repeat_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "stream_id",
+ "access_date"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_stream_history_stream_id",
+ "unique": false,
+ "columnNames": [
+ "stream_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_stream_history_stream_id` ON `${TABLE_NAME}` (`stream_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "streams",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "stream_id"
+ ],
+ "referencedColumns": [
+ "uid"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "stream_state",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "streamUid",
+ "columnName": "stream_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "progressMillis",
+ "columnName": "progress_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "stream_id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": [
+ {
+ "table": "streams",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "stream_id"
+ ],
+ "referencedColumns": [
+ "uid"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "playlists",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `is_thumbnail_permanent` INTEGER NOT NULL, `thumbnail_stream_id` INTEGER NOT NULL, `display_index` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "uid",
+ "columnName": "uid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "isThumbnailPermanent",
+ "columnName": "is_thumbnail_permanent",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "thumbnailStreamId",
+ "columnName": "thumbnail_stream_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "displayIndex",
+ "columnName": "display_index",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "uid"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "playlist_stream_join",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, `join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
+ "fields": [
+ {
+ "fieldPath": "playlistUid",
+ "columnName": "playlist_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "streamUid",
+ "columnName": "stream_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "index",
+ "columnName": "join_index",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "playlist_id",
+ "join_index"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_playlist_stream_join_playlist_id_join_index",
+ "unique": true,
+ "columnNames": [
+ "playlist_id",
+ "join_index"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlist_stream_join_playlist_id_join_index` ON `${TABLE_NAME}` (`playlist_id`, `join_index`)"
+ },
+ {
+ "name": "index_playlist_stream_join_stream_id",
+ "unique": false,
+ "columnNames": [
+ "stream_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_stream_join_stream_id` ON `${TABLE_NAME}` (`stream_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "playlists",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "playlist_id"
+ ],
+ "referencedColumns": [
+ "uid"
+ ]
+ },
+ {
+ "table": "streams",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "stream_id"
+ ],
+ "referencedColumns": [
+ "uid"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "remote_playlists",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, `thumbnail_url` TEXT, `uploader` TEXT, `display_index` INTEGER NOT NULL, `stream_count` INTEGER)",
+ "fields": [
+ {
+ "fieldPath": "uid",
+ "columnName": "uid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serviceId",
+ "columnName": "service_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "thumbnailUrl",
+ "columnName": "thumbnail_url",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "uploader",
+ "columnName": "uploader",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "displayIndex",
+ "columnName": "display_index",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "streamCount",
+ "columnName": "stream_count",
+ "affinity": "INTEGER",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "uid"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_remote_playlists_service_id_url",
+ "unique": true,
+ "columnNames": [
+ "service_id",
+ "url"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_remote_playlists_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
+ }
+ ],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "feed",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `subscription_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
+ "fields": [
+ {
+ "fieldPath": "streamId",
+ "columnName": "stream_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "subscriptionId",
+ "columnName": "subscription_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "stream_id",
+ "subscription_id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_feed_subscription_id",
+ "unique": false,
+ "columnNames": [
+ "subscription_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "streams",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "stream_id"
+ ],
+ "referencedColumns": [
+ "uid"
+ ]
+ },
+ {
+ "table": "subscriptions",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "subscription_id"
+ ],
+ "referencedColumns": [
+ "uid"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "feed_group",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon_id` INTEGER NOT NULL, `sort_order` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "uid",
+ "columnName": "uid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "icon",
+ "columnName": "icon_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sortOrder",
+ "columnName": "sort_order",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "uid"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_feed_group_sort_order",
+ "unique": false,
+ "columnNames": [
+ "sort_order"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_sort_order` ON `${TABLE_NAME}` (`sort_order`)"
+ }
+ ],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "feed_group_subscription_join",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`group_id`, `subscription_id`), FOREIGN KEY(`group_id`) REFERENCES `feed_group`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
+ "fields": [
+ {
+ "fieldPath": "feedGroupId",
+ "columnName": "group_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "subscriptionId",
+ "columnName": "subscription_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "group_id",
+ "subscription_id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_feed_group_subscription_join_subscription_id",
+ "unique": false,
+ "columnNames": [
+ "subscription_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_subscription_join_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "feed_group",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "group_id"
+ ],
+ "referencedColumns": [
+ "uid"
+ ]
+ },
+ {
+ "table": "subscriptions",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "subscription_id"
+ ],
+ "referencedColumns": [
+ "uid"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "feed_last_updated",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscription_id` INTEGER NOT NULL, `last_updated` INTEGER, PRIMARY KEY(`subscription_id`), FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
+ "fields": [
+ {
+ "fieldPath": "subscriptionId",
+ "columnName": "subscription_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastUpdated",
+ "columnName": "last_updated",
+ "affinity": "INTEGER",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "subscription_id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": [
+ {
+ "table": "subscriptions",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "subscription_id"
+ ],
+ "referencedColumns": [
+ "uid"
+ ]
+ }
+ ]
+ }
+ ],
+ "views": [],
+ "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, '7591e8039faa74d8c0517dc867af9d3e')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/org/schabi/newpipe/database/DatabaseMigrationTest.kt b/app/src/androidTest/java/org/schabi/newpipe/database/DatabaseMigrationTest.kt
index 65f41d8fa..a34cfece6 100644
--- a/app/src/androidTest/java/org/schabi/newpipe/database/DatabaseMigrationTest.kt
+++ b/app/src/androidTest/java/org/schabi/newpipe/database/DatabaseMigrationTest.kt
@@ -13,6 +13,8 @@ import org.junit.Assert.assertNull
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
+import org.schabi.newpipe.database.playlist.model.PlaylistEntity
+import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity
import org.schabi.newpipe.extractor.ServiceList
import org.schabi.newpipe.extractor.stream.StreamType
@@ -22,13 +24,17 @@ class DatabaseMigrationTest {
private const val DEFAULT_SERVICE_ID = 0
private const val DEFAULT_URL = "https://www.youtube.com/watch?v=cDphUib5iG4"
private const val DEFAULT_TITLE = "Test Title"
+ private const val DEFAULT_NAME = "Test Name"
private val DEFAULT_TYPE = StreamType.VIDEO_STREAM
private const val DEFAULT_DURATION = 480L
private const val DEFAULT_UPLOADER_NAME = "Uploader Test"
private const val DEFAULT_THUMBNAIL = "https://example.com/example.jpg"
- private const val DEFAULT_SECOND_SERVICE_ID = 0
+ private const val DEFAULT_SECOND_SERVICE_ID = 1
private const val DEFAULT_SECOND_URL = "https://www.youtube.com/watch?v=ncQU6iBn5Fc"
+
+ private const val DEFAULT_THIRD_SERVICE_ID = 2
+ private const val DEFAULT_THIRD_URL = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
}
@get:Rule
@@ -115,6 +121,13 @@ class DatabaseMigrationTest {
Migrations.MIGRATION_7_8
)
+ testHelper.runMigrationsAndValidate(
+ AppDatabase.DATABASE_NAME,
+ Migrations.DB_VER_9,
+ true,
+ Migrations.MIGRATION_8_9
+ )
+
val migratedDatabaseV3 = getMigratedDatabase()
val listFromDB = migratedDatabaseV3.streamDAO().all.blockingFirst()
@@ -198,6 +211,11 @@ class DatabaseMigrationTest {
true, Migrations.MIGRATION_7_8
)
+ testHelper.runMigrationsAndValidate(
+ AppDatabase.DATABASE_NAME, Migrations.DB_VER_9,
+ true, Migrations.MIGRATION_8_9
+ )
+
val migratedDatabaseV8 = getMigratedDatabase()
val listFromDB = migratedDatabaseV8.searchHistoryDAO().all.blockingFirst()
@@ -207,6 +225,94 @@ class DatabaseMigrationTest {
assertNotEquals(listFromDB[0].serviceId, listFromDB[1].serviceId)
}
+ @Test
+ fun migrateDatabaseFrom8to9() {
+ val databaseInV8 = testHelper.createDatabase(AppDatabase.DATABASE_NAME, Migrations.DB_VER_8)
+
+ val localUid1: Long
+ val localUid2: Long
+ val remoteUid1: Long
+ val remoteUid2: Long
+ databaseInV8.run {
+ localUid1 = insert(
+ "playlists", SQLiteDatabase.CONFLICT_FAIL,
+ ContentValues().apply {
+ put("name", DEFAULT_NAME + "1")
+ put("is_thumbnail_permanent", false)
+ put("thumbnail_stream_id", -1)
+ }
+ )
+ localUid2 = insert(
+ "playlists", SQLiteDatabase.CONFLICT_FAIL,
+ ContentValues().apply {
+ put("name", DEFAULT_NAME + "2")
+ put("is_thumbnail_permanent", false)
+ put("thumbnail_stream_id", -1)
+ }
+ )
+ delete(
+ "playlists", "uid = ?",
+ Array(1) { localUid1 }
+ )
+ remoteUid1 = insert(
+ "remote_playlists", SQLiteDatabase.CONFLICT_FAIL,
+ ContentValues().apply {
+ put("service_id", DEFAULT_SERVICE_ID)
+ put("url", DEFAULT_URL)
+ }
+ )
+ remoteUid2 = insert(
+ "remote_playlists", SQLiteDatabase.CONFLICT_FAIL,
+ ContentValues().apply {
+ put("service_id", DEFAULT_SECOND_SERVICE_ID)
+ put("url", DEFAULT_SECOND_URL)
+ }
+ )
+ delete(
+ "remote_playlists", "uid = ?",
+ Array(1) { remoteUid2 }
+ )
+ close()
+ }
+
+ testHelper.runMigrationsAndValidate(
+ AppDatabase.DATABASE_NAME,
+ Migrations.DB_VER_9,
+ true,
+ Migrations.MIGRATION_8_9
+ )
+
+ val migratedDatabaseV9 = getMigratedDatabase()
+ var localListFromDB = migratedDatabaseV9.playlistDAO().all.blockingFirst()
+ var remoteListFromDB = migratedDatabaseV9.playlistRemoteDAO().all.blockingFirst()
+
+ assertEquals(1, localListFromDB.size)
+ assertEquals(localUid2, localListFromDB[0].uid)
+ assertEquals(-1, localListFromDB[0].displayIndex)
+ assertEquals(1, remoteListFromDB.size)
+ assertEquals(remoteUid1, remoteListFromDB[0].uid)
+ assertEquals(-1, remoteListFromDB[0].displayIndex)
+
+ val localUid3 = migratedDatabaseV9.playlistDAO().insert(
+ PlaylistEntity(DEFAULT_NAME + "3", false, -1, -1)
+ )
+ val remoteUid3 = migratedDatabaseV9.playlistRemoteDAO().insert(
+ PlaylistRemoteEntity(
+ DEFAULT_THIRD_SERVICE_ID, DEFAULT_NAME, DEFAULT_THIRD_URL,
+ DEFAULT_THUMBNAIL, DEFAULT_UPLOADER_NAME, -1, 10
+ )
+ )
+
+ localListFromDB = migratedDatabaseV9.playlistDAO().all.blockingFirst()
+ remoteListFromDB = migratedDatabaseV9.playlistRemoteDAO().all.blockingFirst()
+ assertEquals(2, localListFromDB.size)
+ assertEquals(localUid3, localListFromDB[1].uid)
+ assertEquals(-1, localListFromDB[1].displayIndex)
+ assertEquals(2, remoteListFromDB.size)
+ assertEquals(remoteUid3, remoteListFromDB[1].uid)
+ assertEquals(-1, remoteListFromDB[1].displayIndex)
+ }
+
private fun getMigratedDatabase(): AppDatabase {
val database: AppDatabase = Room.databaseBuilder(
ApplicationProvider.getApplicationContext(),
diff --git a/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java b/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java
index c4f9feba7..21c5354f4 100644
--- a/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java
+++ b/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java
@@ -8,6 +8,7 @@ import static org.schabi.newpipe.database.Migrations.MIGRATION_4_5;
import static org.schabi.newpipe.database.Migrations.MIGRATION_5_6;
import static org.schabi.newpipe.database.Migrations.MIGRATION_6_7;
import static org.schabi.newpipe.database.Migrations.MIGRATION_7_8;
+import static org.schabi.newpipe.database.Migrations.MIGRATION_8_9;
import android.content.Context;
import android.database.Cursor;
@@ -28,7 +29,7 @@ public final class NewPipeDatabase {
return Room
.databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5,
- MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8)
+ MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9)
.build();
}
diff --git a/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java b/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java
index d03823e66..04d93a238 100644
--- a/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java
+++ b/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java
@@ -1,6 +1,6 @@
package org.schabi.newpipe.database;
-import static org.schabi.newpipe.database.Migrations.DB_VER_8;
+import static org.schabi.newpipe.database.Migrations.DB_VER_9;
import androidx.room.Database;
import androidx.room.RoomDatabase;
@@ -38,7 +38,7 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity;
FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.class,
FeedLastUpdatedEntity.class
},
- version = DB_VER_8
+ version = DB_VER_9
)
public abstract class AppDatabase extends RoomDatabase {
public static final String DATABASE_NAME = "newpipe.db";
diff --git a/app/src/main/java/org/schabi/newpipe/database/Migrations.java b/app/src/main/java/org/schabi/newpipe/database/Migrations.java
index 4b1a34dd6..c9f630869 100644
--- a/app/src/main/java/org/schabi/newpipe/database/Migrations.java
+++ b/app/src/main/java/org/schabi/newpipe/database/Migrations.java
@@ -26,6 +26,7 @@ public final class Migrations {
public static final int DB_VER_6 = 6;
public static final int DB_VER_7 = 7;
public static final int DB_VER_8 = 8;
+ public static final int DB_VER_9 = 9;
private static final String TAG = Migrations.class.getName();
public static final boolean DEBUG = MainActivity.DEBUG;
@@ -187,7 +188,7 @@ public final class Migrations {
@Override
public void migrate(@NonNull final SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `subscriptions` ADD COLUMN `notification_mode` "
- + "INTEGER NOT NULL DEFAULT 0");
+ + "INTEGER NOT NULL DEFAULT 0");
}
};
@@ -245,6 +246,62 @@ public final class Migrations {
}
};
+ public static final Migration MIGRATION_8_9 = new Migration(DB_VER_8, DB_VER_9) {
+ @Override
+ public void migrate(@NonNull final SupportSQLiteDatabase database) {
+ try {
+ database.beginTransaction();
+
+ // Update playlists.
+ // Create a temp table to initialize display_index.
+ database.execSQL("CREATE TABLE `playlists_tmp` "
+ + "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "
+ + "`name` TEXT, `is_thumbnail_permanent` INTEGER NOT NULL, "
+ + "`thumbnail_stream_id` INTEGER NOT NULL, "
+ + "`display_index` INTEGER NOT NULL)");
+ database.execSQL("INSERT INTO `playlists_tmp` "
+ + "(`uid`, `name`, `is_thumbnail_permanent`, `thumbnail_stream_id`, "
+ + "`display_index`) "
+ + "SELECT `uid`, `name`, `is_thumbnail_permanent`, `thumbnail_stream_id`, "
+ + "-1 "
+ + "FROM `playlists`");
+
+ // Replace the old table, note that this also removes the index on the name which
+ // we don't need anymore.
+ database.execSQL("DROP TABLE `playlists`");
+ database.execSQL("ALTER TABLE `playlists_tmp` RENAME TO `playlists`");
+
+
+ // Update remote_playlists.
+ // Create a temp table to initialize display_index.
+ database.execSQL("CREATE TABLE `remote_playlists_tmp` "
+ + "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "
+ + "`service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, "
+ + "`thumbnail_url` TEXT, `uploader` TEXT, "
+ + "`display_index` INTEGER NOT NULL,"
+ + "`stream_count` INTEGER)");
+ database.execSQL("INSERT INTO `remote_playlists_tmp` (`uid`, `service_id`, "
+ + "`name`, `url`, `thumbnail_url`, `uploader`, `display_index`, "
+ + "`stream_count`)"
+ + "SELECT `uid`, `service_id`, `name`, `url`, `thumbnail_url`, `uploader`, "
+ + "-1, `stream_count` FROM `remote_playlists`");
+
+ // Replace the old table, note that this also removes the index on the name which
+ // we don't need anymore.
+ database.execSQL("DROP TABLE `remote_playlists`");
+ database.execSQL("ALTER TABLE `remote_playlists_tmp` RENAME TO `remote_playlists`");
+
+ // Create index on the new table.
+ database.execSQL("CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` "
+ + "ON `remote_playlists` (`service_id`, `url`)");
+
+ database.setTransactionSuccessful();
+ } finally {
+ database.endTransaction();
+ }
+ }
+ };
+
private Migrations() {
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.java b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.java
index 0fcb4ced4..3be85e6e1 100644
--- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.java
+++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.java
@@ -13,12 +13,17 @@ public class PlaylistDuplicatesEntry extends PlaylistMetadataEntry {
@ColumnInfo(name = PLAYLIST_TIMES_STREAM_IS_CONTAINED)
public final long timesStreamIsContained;
+ @SuppressWarnings("checkstyle:ParameterNumber")
public PlaylistDuplicatesEntry(final long uid,
final String name,
final String thumbnailUrl,
+ final boolean isThumbnailPermanent,
+ final long thumbnailStreamId,
+ final long displayIndex,
final long streamCount,
final long timesStreamIsContained) {
- super(uid, name, thumbnailUrl, streamCount);
+ super(uid, name, thumbnailUrl, isThumbnailPermanent, thumbnailStreamId, displayIndex,
+ streamCount);
this.timesStreamIsContained = timesStreamIsContained;
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.java b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.java
index 695f9ec5a..072c49e2c 100644
--- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.java
+++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.java
@@ -1,22 +1,13 @@
package org.schabi.newpipe.database.playlist;
import org.schabi.newpipe.database.LocalItem;
-import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
-
-import java.util.Comparator;
-import java.util.List;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
public interface PlaylistLocalItem extends LocalItem {
String getOrderingName();
- static List> listByService(int serviceId);
+ @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE "
+ + REMOTE_PLAYLIST_ID + " = :playlistId")
+ Flowable
> getPlaylist(long playlistId);
+
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE "
+ REMOTE_PLAYLIST_URL + " = :url AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")
Flowable
> getPlaylist(long serviceId, String url);
+ @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE
+ + " ORDER BY " + REMOTE_PLAYLIST_DISPLAY_INDEX)
+ Flowable
> getPlaylists();
+
@Query("SELECT " + REMOTE_PLAYLIST_ID + " FROM " + REMOTE_PLAYLIST_TABLE
+ " WHERE " + REMOTE_PLAYLIST_URL + " = :url "
+ "AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")
diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.java b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.java
index 8aa1a0c8c..85b891770 100644
--- a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.java
+++ b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.java
@@ -18,10 +18,12 @@ import io.reactivex.rxjava3.core.Flowable;
import static org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry.PLAYLIST_TIMES_STREAM_IS_CONTAINED;
import static org.schabi.newpipe.database.playlist.PlaylistMetadataEntry.PLAYLIST_STREAM_COUNT;
+import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_DISPLAY_INDEX;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.DEFAULT_THUMBNAIL;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_ID;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_NAME;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_TABLE;
+import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_PERMANENT;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_URL;
import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_INDEX;
@@ -91,7 +93,9 @@ public interface PlaylistStreamDAO extends BasicDAO
> getOrderedStreamsOf(long playlistId);
@Transaction
- @Query("SELECT " + PLAYLIST_ID + ", " + PLAYLIST_NAME + ","
+ @Query("SELECT " + PLAYLIST_ID + ", " + PLAYLIST_NAME + ", "
+ + PLAYLIST_THUMBNAIL_PERMANENT + ", " + PLAYLIST_THUMBNAIL_STREAM_ID + ", "
+ + PLAYLIST_DISPLAY_INDEX + ", "
+ " CASE WHEN " + PLAYLIST_THUMBNAIL_STREAM_ID + " = "
+ PlaylistEntity.DEFAULT_THUMBNAIL_ID + " THEN " + "'" + DEFAULT_THUMBNAIL + "'"
@@ -105,7 +109,7 @@ public interface PlaylistStreamDAO extends BasicDAO
> getPlaylistMetadata();
@RewriteQueriesToDropUnusedColumns
@@ -126,8 +130,9 @@ public interface PlaylistStreamDAO extends BasicDAO
> getStreamsWithoutDuplicates(long playlistId);
@Transaction
- @Query("SELECT " + PLAYLIST_TABLE + "." + PLAYLIST_ID + ", "
- + PLAYLIST_NAME + ", "
+ @Query("SELECT " + PLAYLIST_TABLE + "." + PLAYLIST_ID + ", " + PLAYLIST_NAME + ", "
+ + PLAYLIST_THUMBNAIL_PERMANENT + ", " + PLAYLIST_THUMBNAIL_STREAM_ID + ", "
+ + PLAYLIST_DISPLAY_INDEX + ", "
+ " CASE WHEN " + PLAYLIST_THUMBNAIL_STREAM_ID + " = "
+ PlaylistEntity.DEFAULT_THUMBNAIL_ID + " THEN " + "'" + DEFAULT_THUMBNAIL + "'"
@@ -149,6 +154,6 @@ public interface PlaylistStreamDAO extends BasicDAO
> getPlaylistDuplicatesMetadata(String streamUrl);
}
diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.java b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.java
index efb7278fd..e0c1a06b7 100644
--- a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.java
+++ b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.java
@@ -2,16 +2,15 @@ package org.schabi.newpipe.database.playlist.model;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
-import androidx.room.Index;
+import androidx.room.Ignore;
import androidx.room.PrimaryKey;
-import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_NAME;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_TABLE;
import org.schabi.newpipe.R;
+import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
-@Entity(tableName = PLAYLIST_TABLE,
- indices = {@Index(value = {PLAYLIST_NAME})})
+@Entity(tableName = PLAYLIST_TABLE)
public class PlaylistEntity {
public static final String DEFAULT_THUMBNAIL = "drawable://"
@@ -22,6 +21,7 @@ public class PlaylistEntity {
public static final String PLAYLIST_ID = "uid";
public static final String PLAYLIST_NAME = "name";
public static final String PLAYLIST_THUMBNAIL_URL = "thumbnail_url";
+ public static final String PLAYLIST_DISPLAY_INDEX = "display_index";
public static final String PLAYLIST_THUMBNAIL_PERMANENT = "is_thumbnail_permanent";
public static final String PLAYLIST_THUMBNAIL_STREAM_ID = "thumbnail_stream_id";
@@ -38,11 +38,24 @@ public class PlaylistEntity {
@ColumnInfo(name = PLAYLIST_THUMBNAIL_STREAM_ID)
private long thumbnailStreamId;
+ @ColumnInfo(name = PLAYLIST_DISPLAY_INDEX)
+ private long displayIndex;
+
public PlaylistEntity(final String name, final boolean isThumbnailPermanent,
- final long thumbnailStreamId) {
+ final long thumbnailStreamId, final long displayIndex) {
this.name = name;
this.isThumbnailPermanent = isThumbnailPermanent;
this.thumbnailStreamId = thumbnailStreamId;
+ this.displayIndex = displayIndex;
+ }
+
+ @Ignore
+ public PlaylistEntity(final PlaylistMetadataEntry item) {
+ this.uid = item.getUid();
+ this.name = item.name;
+ this.isThumbnailPermanent = item.isThumbnailPermanent();
+ this.thumbnailStreamId = item.getThumbnailStreamId();
+ this.displayIndex = item.getDisplayIndex();
}
public long getUid() {
@@ -77,4 +90,11 @@ public class PlaylistEntity {
this.isThumbnailPermanent = isThumbnailSet;
}
+ public long getDisplayIndex() {
+ return displayIndex;
+ }
+
+ public void setDisplayIndex(final long displayIndex) {
+ this.displayIndex = displayIndex;
+ }
}
diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.java b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.java
index 7c6b4a8b0..60027a057 100644
--- a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.java
+++ b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.java
@@ -21,7 +21,6 @@ import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.RE
@Entity(tableName = REMOTE_PLAYLIST_TABLE,
indices = {
- @Index(value = {REMOTE_PLAYLIST_NAME}),
@Index(value = {REMOTE_PLAYLIST_SERVICE_ID, REMOTE_PLAYLIST_URL}, unique = true)
})
public class PlaylistRemoteEntity implements PlaylistLocalItem {
@@ -32,6 +31,7 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem {
public static final String REMOTE_PLAYLIST_URL = "url";
public static final String REMOTE_PLAYLIST_THUMBNAIL_URL = "thumbnail_url";
public static final String REMOTE_PLAYLIST_UPLOADER_NAME = "uploader";
+ public static final String REMOTE_PLAYLIST_DISPLAY_INDEX = "display_index";
public static final String REMOTE_PLAYLIST_STREAM_COUNT = "stream_count";
@PrimaryKey(autoGenerate = true)
@@ -53,6 +53,9 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem {
@ColumnInfo(name = REMOTE_PLAYLIST_UPLOADER_NAME)
private String uploader;
+ @ColumnInfo(name = REMOTE_PLAYLIST_DISPLAY_INDEX)
+ private long displayIndex = -1; // Make sure the new item is on the top
+
@ColumnInfo(name = REMOTE_PLAYLIST_STREAM_COUNT)
private Long streamCount;
@@ -67,6 +70,19 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem {
this.streamCount = streamCount;
}
+ @Ignore
+ public PlaylistRemoteEntity(final int serviceId, final String name, final String url,
+ final String thumbnailUrl, final String uploader,
+ final long displayIndex, final Long streamCount) {
+ this.serviceId = serviceId;
+ this.name = name;
+ this.url = url;
+ this.thumbnailUrl = thumbnailUrl;
+ this.uploader = uploader;
+ this.displayIndex = displayIndex;
+ this.streamCount = streamCount;
+ }
+
@Ignore
public PlaylistRemoteEntity(final PlaylistInfo info) {
this(info.getServiceId(), info.getName(), info.getUrl(),
@@ -93,6 +109,7 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem {
&& TextUtils.equals(getUploader(), info.getUploaderName());
}
+ @Override
public long getUid() {
return uid;
}
@@ -141,6 +158,16 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem {
this.uploader = uploader;
}
+ @Override
+ public long getDisplayIndex() {
+ return displayIndex;
+ }
+
+ @Override
+ public void setDisplayIndex(final long displayIndex) {
+ this.displayIndex = displayIndex;
+ }
+
public Long getStreamCount() {
return streamCount;
}
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java
index d50f0b0d8..52a41d38f 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java
@@ -220,7 +220,7 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
public void commitPlaylistTabs() {
pagerAdapter.getLocalPlaylistFragments()
.stream()
- .forEach(LocalPlaylistFragment::commitChanges);
+ .forEach(LocalPlaylistFragment::saveImmediate);
}
private void updateTabLayoutPosition() {
@@ -282,7 +282,7 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
* Keep reference to LocalPlaylistFragments, because their data can be modified by the user
* during runtime and changes are not committed immediately. However, in some cases,
* the changes need to be committed immediately by calling
- * {@link LocalPlaylistFragment#commitChanges()}.
+ * {@link LocalPlaylistFragment#saveImmediate()}.
* The fragments are removed when {@link LocalPlaylistFragment#onDestroy()} is called.
*/
private final List
, Void>
+ implements DebounceSavable {
+
+ private static final int MINIMUM_INITIAL_DRAG_VELOCITY = 12;
@State
- protected Parcelable itemsListState;
+ Parcelable itemsListState;
private Subscription databaseSubscription;
private CompositeDisposable disposables = new CompositeDisposable();
private LocalPlaylistManager localPlaylistManager;
private RemotePlaylistManager remotePlaylistManager;
+ private ItemTouchHelper itemTouchHelper;
+
+ /* Have the bookmarked playlists been fully loaded from db */
+ private AtomicBoolean isLoadingComplete;
+
+ /* Gives enough time to avoid interrupting user sorting operations */
+ @Nullable
+ private DebounceSaver debounceSaver;
+
+ private List
();
}
@Nullable
@@ -91,10 +117,20 @@ public final class BookmarkFragment extends BaseLocalListFragment
() {
@Override
public void selected(final LocalItem selectedItem) {
@@ -102,7 +138,7 @@ public final class BookmarkFragment extends BaseLocalListFragment
> getPlaylistsSubscriber() {
- return new Subscriber
>() {
+ return new Subscriber<>() {
@Override
public void onSubscribe(final Subscription s) {
showLoading();
+ isLoadingComplete.set(false);
+
if (databaseSubscription != null) {
databaseSubscription.cancel();
}
@@ -196,7 +258,10 @@ public final class BookmarkFragment extends BaseLocalListFragment
subscriptions) {
- handleResult(subscriptions);
+ if (debounceSaver == null || !debounceSaver.getIsModified()) {
+ handleResult(subscriptions);
+ isLoadingComplete.set(true);
+ }
if (databaseSubscription != null) {
databaseSubscription.request(1);
}
@@ -209,7 +274,8 @@ public final class BookmarkFragment extends BaseLocalListFragment
{ /*Do nothing on success*/ }, throwable -> showError(
+ new ErrorInfo(throwable,
+ UserAction.REQUESTED_BOOKMARK,
+ "Changing playlist name")));
+ disposables.add(disposable);
+ }
+
+ private void deleteItem(final PlaylistLocalItem item) {
+ if (itemListAdapter == null) {
+ return;
+ }
+ itemListAdapter.removeItem(item);
+
+ if (item instanceof PlaylistMetadataEntry) {
+ deletedItems.add(new Pair<>(item.getUid(),
+ LocalItem.LocalItemType.PLAYLIST_LOCAL_ITEM));
+ } else if (item instanceof PlaylistRemoteEntity) {
+ deletedItems.add(new Pair<>(item.getUid(),
+ LocalItem.LocalItemType.PLAYLIST_REMOTE_ITEM));
+ }
+
+ if (debounceSaver != null) {
+ debounceSaver.setHasChangesToSave();
+ saveImmediate();
+ }
+ }
+
+ @Override
+ public void saveImmediate() {
+ if (itemListAdapter == null) {
+ return;
+ }
+
+ // List must be loaded and modified in order to save
+ if (isLoadingComplete == null || debounceSaver == null
+ || !isLoadingComplete.get() || !debounceSaver.getIsModified()) {
+ return;
+ }
+
+ final List
items = new ArrayList<>();
items.add(rename);
@@ -270,13 +507,12 @@ public final class BookmarkFragment extends BaseLocalListFragment
changeLocalPlaylistName(
- selectedItem.uid,
+ selectedItem.getUid(),
dialogBinding.dialogEditText.getText().toString()))
.setNegativeButton(R.string.cancel, null)
.show();
}
- private void showDeleteDialog(final String name, final Single
- disposables.add(deleteReactor
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe(ignored -> { /*Do nothing on success*/ }, throwable ->
- showError(new ErrorInfo(throwable,
- UserAction.REQUESTED_BOOKMARK,
- "Deleting playlist")))))
+ .setPositiveButton(R.string.delete, (dialog, i) -> deleteItem(item))
.setNegativeButton(R.string.cancel, null)
.show();
}
-
- private void changeLocalPlaylistName(final long id, final String name) {
- if (localPlaylistManager == null) {
- return;
- }
-
- if (DEBUG) {
- Log.d(TAG, "Updating playlist id=[" + id + "] "
- + "with new name=[" + name + "] items");
- }
-
- localPlaylistManager.renamePlaylist(id, name);
- final Disposable disposable = localPlaylistManager.renamePlaylist(id, name)
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe(longs -> { /*Do nothing on success*/ }, throwable -> showError(
- new ErrorInfo(throwable,
- UserAction.REQUESTED_BOOKMARK,
- "Changing playlist name")));
- disposables.add(disposable);
- }
}
-
diff --git a/app/src/main/java/org/schabi/newpipe/local/bookmark/MergedPlaylistManager.java b/app/src/main/java/org/schabi/newpipe/local/bookmark/MergedPlaylistManager.java
new file mode 100644
index 000000000..25eb2f652
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/bookmark/MergedPlaylistManager.java
@@ -0,0 +1,95 @@
+package org.schabi.newpipe.local.bookmark;
+
+import org.schabi.newpipe.database.playlist.PlaylistLocalItem;
+import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
+import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
+import org.schabi.newpipe.local.playlist.LocalPlaylistManager;
+import org.schabi.newpipe.local.playlist.RemotePlaylistManager;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+import io.reactivex.rxjava3.core.Flowable;
+
+/**
+ * Takes care of remote and local playlists at once, hence "merged".
+ */
+public final class MergedPlaylistManager {
+
+ private MergedPlaylistManager() {
+ }
+
+ public static Flowable
> getMergedOrderedPlaylists(
+ final LocalPlaylistManager localPlaylistManager,
+ final RemotePlaylistManager remotePlaylistManager) {
+ return Flowable.combineLatest(
+ localPlaylistManager.getPlaylists(),
+ remotePlaylistManager.getPlaylists(),
+ MergedPlaylistManager::merge
+ );
+ }
+
+ /**
+ * Merge localPlaylists and remotePlaylists by the display index.
+ * If two items have the same display index, sort them in {@code CASE_INSENSITIVE_ORDER}.
+ *
+ * @param localPlaylists local playlists, already sorted by display index
+ * @param remotePlaylists remote playlists, already sorted by display index
+ * @return merged playlists
+ */
+ public static List
, Void>
- implements PlaylistControlViewHolder {
- /** Save the list 10 seconds after the last change occurred. */
- private static final long SAVE_DEBOUNCE_MILLIS = 10000;
+ implements PlaylistControlViewHolder, DebounceSavable {
+
private static final int MINIMUM_INITIAL_DRAG_VELOCITY = 12;
@State
protected Long playlistId;
@@ -90,13 +89,12 @@ public class LocalPlaylistFragment extends BaseLocalListFragment
debouncedSaveSignal;
private CompositeDisposable disposables;
/** Whether the playlist has been fully loaded from db. */
private AtomicBoolean isLoadingComplete;
- /** Whether the playlist has been modified (e.g. items reordered or deleted) */
- private AtomicBoolean isModified;
+ /** Used to debounce saving playlist edits to disk. */
+ private DebounceSaver debounceSaver;
/** Flag to prevent simultaneous rewrites of the playlist. */
private boolean isRewritingPlaylist = false;
@@ -121,12 +119,11 @@ public class LocalPlaylistFragment extends BaseLocalListFragment
Commit changes immediately if the playlist has been modified.
Commit changes immediately if the playlist has been modified.
+ * Delete operations and other modifications will be committed to ensure that the database + * is up to date, e.g. when the user adds the just deleted stream from another fragment. + */ + @Override + public void saveImmediate() { if (playlistManager == null || itemListAdapter == null) { return; } // List must be loaded and modified in order to save - if (isLoadingComplete == null || isModified == null - || !isLoadingComplete.get() || !isModified.get()) { - Log.w(TAG, "Attempting to save playlist when local playlist " - + "is not loaded or not modified: playlist id=[" + playlistId + "]"); + if (isLoadingComplete == null || debounceSaver == null + || !isLoadingComplete.get() || !debounceSaver.getIsModified()) { return; } @@ -740,8 +710,8 @@ public class LocalPlaylistFragment extends BaseLocalListFragment