diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 037c3cb532b..e4f318f6548 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -6,7 +6,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.jetbrains.kotlin.android) - alias(libs.plugins.jetbrains.kotlin.kapt) + alias(libs.plugins.google.ksp) alias(libs.plugins.jetbrains.kotlin.parcelize) alias(libs.plugins.sonarqube) checkstyle @@ -40,12 +40,6 @@ android { System.getProperty("versionNameSuffix")?.let { versionNameSuffix = it } testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - - javaCompileOptions { - annotationProcessorOptions { - arguments["room.schemaLocation"] = "$projectDir/schemas" - } - } } buildTypes { @@ -124,6 +118,11 @@ android { } } +ksp { + arg("room.schemaLocation", "$projectDir/schemas") +} + + // Custom dependency configuration for ktlint val ktlint by configurations.creating @@ -218,7 +217,7 @@ dependencies { implementation(libs.androidx.recyclerview) implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.rxjava3) - kapt(libs.androidx.room.compiler) + ksp(libs.androidx.room.compiler) implementation(libs.androidx.swiperefreshlayout) implementation(libs.androidx.viewpager2) implementation(libs.androidx.work.runtime) @@ -229,7 +228,7 @@ dependencies { /** Third-party libraries **/ implementation(libs.livefront.bridge) implementation(libs.evernote.statesaver.core) - kapt(libs.evernote.statesaver.compiler) + ksp(libs.evernote.statesaver.compiler) // HTML parser implementation(libs.jsoup) @@ -249,7 +248,7 @@ dependencies { // Metadata generator for service descriptors compileOnly(libs.google.autoservice.annotations) - kapt(libs.google.autoservice.compiler) + ksp(libs.google.autoservice.compiler) // Manager for complex RecyclerView layouts implementation(libs.lisawray.groupie.core) diff --git a/app/schemas/org.schabi.newpipe.database.AppDatabase/9.json b/app/schemas/org.schabi.newpipe.database.AppDatabase/9.json index aced06c0ab0..b9a618638e7 100644 --- a/app/schemas/org.schabi.newpipe.database.AppDatabase/9.json +++ b/app/schemas/org.schabi.newpipe.database.AppDatabase/9.json @@ -458,7 +458,7 @@ "notNull": true }, { - "fieldPath": "name", + "fieldPath": "orderingName", "columnName": "name", "affinity": "TEXT", "notNull": false 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 a34cfece671..4327271f410 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/database/DatabaseMigrationTest.kt +++ b/app/src/androidTest/java/org/schabi/newpipe/database/DatabaseMigrationTest.kt @@ -129,7 +129,7 @@ class DatabaseMigrationTest { ) val migratedDatabaseV3 = getMigratedDatabase() - val listFromDB = migratedDatabaseV3.streamDAO().all.blockingFirst() + val listFromDB = migratedDatabaseV3.streamDAO().getAll().blockingFirst() // Only expect 2, the one with the null url will be ignored assertEquals(2, listFromDB.size) @@ -217,7 +217,7 @@ class DatabaseMigrationTest { ) val migratedDatabaseV8 = getMigratedDatabase() - val listFromDB = migratedDatabaseV8.searchHistoryDAO().all.blockingFirst() + val listFromDB = migratedDatabaseV8.searchHistoryDAO().getAll().blockingFirst() assertEquals(2, listFromDB.size) assertEquals("abc", listFromDB[0].search) @@ -283,8 +283,8 @@ class DatabaseMigrationTest { ) val migratedDatabaseV9 = getMigratedDatabase() - var localListFromDB = migratedDatabaseV9.playlistDAO().all.blockingFirst() - var remoteListFromDB = migratedDatabaseV9.playlistRemoteDAO().all.blockingFirst() + var localListFromDB = migratedDatabaseV9.playlistDAO().getAll().blockingFirst() + var remoteListFromDB = migratedDatabaseV9.playlistRemoteDAO().getAll().blockingFirst() assertEquals(1, localListFromDB.size) assertEquals(localUid2, localListFromDB[0].uid) @@ -294,17 +294,27 @@ class DatabaseMigrationTest { assertEquals(-1, remoteListFromDB[0].displayIndex) val localUid3 = migratedDatabaseV9.playlistDAO().insert( - PlaylistEntity(DEFAULT_NAME + "3", false, -1, -1) + PlaylistEntity( + name = "${DEFAULT_NAME}3", + isThumbnailPermanent = false, + thumbnailStreamId = -1, + displayIndex = -1 + ) ) val remoteUid3 = migratedDatabaseV9.playlistRemoteDAO().insert( PlaylistRemoteEntity( - DEFAULT_THIRD_SERVICE_ID, DEFAULT_NAME, DEFAULT_THIRD_URL, - DEFAULT_THUMBNAIL, DEFAULT_UPLOADER_NAME, -1, 10 + serviceId = DEFAULT_THIRD_SERVICE_ID, + orderingName = DEFAULT_NAME, + url = DEFAULT_THIRD_URL, + thumbnailUrl = DEFAULT_THUMBNAIL, + uploader = DEFAULT_UPLOADER_NAME, + displayIndex = -1, + streamCount = 10 ) ) - localListFromDB = migratedDatabaseV9.playlistDAO().all.blockingFirst() - remoteListFromDB = migratedDatabaseV9.playlistRemoteDAO().all.blockingFirst() + localListFromDB = migratedDatabaseV9.playlistDAO().getAll().blockingFirst() + remoteListFromDB = migratedDatabaseV9.playlistRemoteDAO().getAll().blockingFirst() assertEquals(2, localListFromDB.size) assertEquals(localUid3, localListFromDB[1].uid) assertEquals(-1, localListFromDB[1].displayIndex) diff --git a/app/src/androidTest/java/org/schabi/newpipe/local/history/HistoryRecordManagerTest.kt b/app/src/androidTest/java/org/schabi/newpipe/local/history/HistoryRecordManagerTest.kt index 24be0f868d1..0de9dd268f0 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/local/history/HistoryRecordManagerTest.kt +++ b/app/src/androidTest/java/org/schabi/newpipe/local/history/HistoryRecordManagerTest.kt @@ -41,7 +41,7 @@ class HistoryRecordManagerTest { // For some reason the Flowable returned by getAll() never completes, so we can't assert // that the number of Lists it returns is exactly 1, we can only check if the first List is // correct. Why on earth has a Flowable been used instead of a Single for getAll()?!? - val entities = database.searchHistoryDAO().all.blockingFirst() + val entities = database.searchHistoryDAO().getAll().blockingFirst() assertThat(entities).hasSize(1) assertThat(entities[0].id).isEqualTo(1) assertThat(entities[0].serviceId).isEqualTo(0) @@ -51,50 +51,50 @@ class HistoryRecordManagerTest { @Test fun deleteSearchHistory() { val entries = listOf( - SearchHistoryEntry(time.minusSeconds(1), 0, "A"), - SearchHistoryEntry(time.minusSeconds(2), 2, "A"), - SearchHistoryEntry(time.minusSeconds(3), 1, "B"), - SearchHistoryEntry(time.minusSeconds(4), 0, "B"), + SearchHistoryEntry(creationDate = time.minusSeconds(1), serviceId = 0, search = "A"), + SearchHistoryEntry(creationDate = time.minusSeconds(2), serviceId = 2, search = "A"), + SearchHistoryEntry(creationDate = time.minusSeconds(3), serviceId = 1, search = "B"), + SearchHistoryEntry(creationDate = time.minusSeconds(4), serviceId = 0, search = "B"), ) // make sure all 4 were inserted database.searchHistoryDAO().insertAll(entries) - assertThat(database.searchHistoryDAO().all.blockingFirst()).hasSameSizeAs(entries) + assertThat(database.searchHistoryDAO().getAll().blockingFirst()).hasSameSizeAs(entries) // try to delete only "A" entries, "B" entries should be untouched manager.deleteSearchHistory("A").test().await().assertValue(2) - val entities = database.searchHistoryDAO().all.blockingFirst() + val entities = database.searchHistoryDAO().getAll().blockingFirst() assertThat(entities).hasSize(2) assertThat(entities).usingElementComparator { o1, o2 -> if (o1.hasEqualValues(o2)) 0 else 1 } .containsExactly(*entries.subList(2, 4).toTypedArray()) // assert that nothing happens if we delete a search query that does exist in the db manager.deleteSearchHistory("A").test().await().assertValue(0) - val entities2 = database.searchHistoryDAO().all.blockingFirst() + val entities2 = database.searchHistoryDAO().getAll().blockingFirst() assertThat(entities2).hasSize(2) assertThat(entities2).usingElementComparator { o1, o2 -> if (o1.hasEqualValues(o2)) 0 else 1 } .containsExactly(*entries.subList(2, 4).toTypedArray()) // delete all remaining entries manager.deleteSearchHistory("B").test().await().assertValue(2) - assertThat(database.searchHistoryDAO().all.blockingFirst()).isEmpty() + assertThat(database.searchHistoryDAO().getAll().blockingFirst()).isEmpty() } @Test fun deleteCompleteSearchHistory() { val entries = listOf( - SearchHistoryEntry(time.minusSeconds(1), 1, "A"), - SearchHistoryEntry(time.minusSeconds(2), 2, "B"), - SearchHistoryEntry(time.minusSeconds(3), 0, "C"), + SearchHistoryEntry(creationDate = time.minusSeconds(1), serviceId = 1, search = "A"), + SearchHistoryEntry(creationDate = time.minusSeconds(2), serviceId = 2, search = "B"), + SearchHistoryEntry(creationDate = time.minusSeconds(3), serviceId = 0, search = "C"), ) // make sure all 3 were inserted database.searchHistoryDAO().insertAll(entries) - assertThat(database.searchHistoryDAO().all.blockingFirst()).hasSameSizeAs(entries) + assertThat(database.searchHistoryDAO().getAll().blockingFirst()).hasSameSizeAs(entries) // should remove everything manager.deleteCompleteSearchHistory().test().await().assertValue(entries.size) - assertThat(database.searchHistoryDAO().all.blockingFirst()).isEmpty() + assertThat(database.searchHistoryDAO().getAll().blockingFirst()).isEmpty() } private fun insertShuffledRelatedSearches(relatedSearches: Collection) { @@ -107,7 +107,7 @@ class HistoryRecordManagerTest { // make sure all entries were inserted assertEquals( relatedSearches.size, - database.searchHistoryDAO().all.blockingFirst().size + database.searchHistoryDAO().getAll().blockingFirst().size ) } @@ -127,19 +127,18 @@ class HistoryRecordManagerTest { @Test fun getRelatedSearches_emptyQuery_manyDuplicates() { - insertShuffledRelatedSearches( - listOf( - SearchHistoryEntry(time.minusSeconds(9), 3, "A"), - SearchHistoryEntry(time.minusSeconds(8), 3, "AB"), - SearchHistoryEntry(time.minusSeconds(7), 3, "A"), - SearchHistoryEntry(time.minusSeconds(6), 3, "A"), - SearchHistoryEntry(time.minusSeconds(5), 3, "BA"), - SearchHistoryEntry(time.minusSeconds(4), 3, "A"), - SearchHistoryEntry(time.minusSeconds(3), 3, "A"), - SearchHistoryEntry(time.minusSeconds(2), 0, "A"), - SearchHistoryEntry(time.minusSeconds(1), 2, "AA"), - ) + val relatedSearches = listOf( + SearchHistoryEntry(creationDate = time.minusSeconds(9), serviceId = 3, search = "A"), + SearchHistoryEntry(creationDate = time.minusSeconds(8), serviceId = 3, search = "AB"), + SearchHistoryEntry(creationDate = time.minusSeconds(7), serviceId = 3, search = "A"), + SearchHistoryEntry(creationDate = time.minusSeconds(6), serviceId = 3, search = "A"), + SearchHistoryEntry(creationDate = time.minusSeconds(5), serviceId = 3, search = "BA"), + SearchHistoryEntry(creationDate = time.minusSeconds(4), serviceId = 3, search = "A"), + SearchHistoryEntry(creationDate = time.minusSeconds(3), serviceId = 3, search = "A"), + SearchHistoryEntry(creationDate = time.minusSeconds(2), serviceId = 0, search = "A"), + SearchHistoryEntry(creationDate = time.minusSeconds(1), serviceId = 2, search = "AA"), ) + insertShuffledRelatedSearches(relatedSearches) val searches = manager.getRelatedSearches("", 9, 3).blockingFirst() assertThat(searches).containsExactly("AA", "A", "BA") @@ -166,13 +165,13 @@ class HistoryRecordManagerTest { private val time = OffsetDateTime.of(LocalDateTime.of(2000, 1, 1, 1, 1), ZoneOffset.UTC) private val RELATED_SEARCHES_ENTRIES = listOf( - SearchHistoryEntry(time.minusSeconds(7), 2, "AC"), - SearchHistoryEntry(time.minusSeconds(6), 0, "ABC"), - SearchHistoryEntry(time.minusSeconds(5), 1, "BA"), - SearchHistoryEntry(time.minusSeconds(4), 3, "A"), - SearchHistoryEntry(time.minusSeconds(2), 0, "B"), - SearchHistoryEntry(time.minusSeconds(3), 2, "AA"), - SearchHistoryEntry(time.minusSeconds(1), 1, "A"), + SearchHistoryEntry(creationDate = time.minusSeconds(7), serviceId = 2, search = "AC"), + SearchHistoryEntry(creationDate = time.minusSeconds(6), serviceId = 0, search = "ABC"), + SearchHistoryEntry(creationDate = time.minusSeconds(5), serviceId = 1, search = "BA"), + SearchHistoryEntry(creationDate = time.minusSeconds(4), serviceId = 3, search = "A"), + SearchHistoryEntry(creationDate = time.minusSeconds(2), serviceId = 0, search = "B"), + SearchHistoryEntry(creationDate = time.minusSeconds(3), serviceId = 2, search = "AA"), + SearchHistoryEntry(creationDate = time.minusSeconds(1), serviceId = 1, search = "A"), ) } } diff --git a/app/src/androidTest/java/org/schabi/newpipe/local/playlist/LocalPlaylistManagerTest.kt b/app/src/androidTest/java/org/schabi/newpipe/local/playlist/LocalPlaylistManagerTest.kt index c392d8d3d66..ce3aeb84ac0 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/local/playlist/LocalPlaylistManagerTest.kt +++ b/app/src/androidTest/java/org/schabi/newpipe/local/playlist/LocalPlaylistManagerTest.kt @@ -72,6 +72,6 @@ class LocalPlaylistManagerTest { val result = manager.createPlaylist("name", listOf(stream, upserted)) result.test().await().assertComplete() - database.streamDAO().all.test().awaitCount(1).assertValue(listOf(stream, upserted)) + database.streamDAO().getAll().test().awaitCount(1).assertValue(listOf(stream, upserted)) } } diff --git a/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java b/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java deleted file mode 100644 index 21c5354f44d..00000000000 --- a/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java +++ /dev/null @@ -1,72 +0,0 @@ -package org.schabi.newpipe; - -import static org.schabi.newpipe.database.AppDatabase.DATABASE_NAME; -import static org.schabi.newpipe.database.Migrations.MIGRATION_1_2; -import static org.schabi.newpipe.database.Migrations.MIGRATION_2_3; -import static org.schabi.newpipe.database.Migrations.MIGRATION_3_4; -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; - -import androidx.annotation.NonNull; -import androidx.room.Room; - -import org.schabi.newpipe.database.AppDatabase; - -public final class NewPipeDatabase { - private static volatile AppDatabase databaseInstance; - - private NewPipeDatabase() { - //no instance - } - - private static AppDatabase getDatabase(final Context context) { - 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_8_9) - .build(); - } - - @NonNull - public static AppDatabase getInstance(@NonNull final Context context) { - AppDatabase result = databaseInstance; - if (result == null) { - synchronized (NewPipeDatabase.class) { - result = databaseInstance; - if (result == null) { - databaseInstance = getDatabase(context); - result = databaseInstance; - } - } - } - - return result; - } - - public static void checkpoint() { - if (databaseInstance == null) { - throw new IllegalStateException("database is not initialized"); - } - final Cursor c = databaseInstance.query("pragma wal_checkpoint(full)", null); - if (c.moveToFirst() && c.getInt(0) == 1) { - throw new RuntimeException("Checkpoint was blocked from completing"); - } - } - - public static void close() { - if (databaseInstance != null) { - synchronized (NewPipeDatabase.class) { - if (databaseInstance != null) { - databaseInstance.close(); - databaseInstance = null; - } - } - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.kt b/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.kt new file mode 100644 index 00000000000..c3ce515240b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.kt @@ -0,0 +1,80 @@ +/* + * SPDX-FileCopyrightText: 2017-2024 NewPipe contributors + * SPDX-FileCopyrightText: 2025 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package org.schabi.newpipe + +import android.content.Context +import androidx.room.Room.databaseBuilder +import org.schabi.newpipe.database.AppDatabase +import org.schabi.newpipe.database.Migrations.MIGRATION_1_2 +import org.schabi.newpipe.database.Migrations.MIGRATION_2_3 +import org.schabi.newpipe.database.Migrations.MIGRATION_3_4 +import org.schabi.newpipe.database.Migrations.MIGRATION_4_5 +import org.schabi.newpipe.database.Migrations.MIGRATION_5_6 +import org.schabi.newpipe.database.Migrations.MIGRATION_6_7 +import org.schabi.newpipe.database.Migrations.MIGRATION_7_8 +import org.schabi.newpipe.database.Migrations.MIGRATION_8_9 +import kotlin.concurrent.Volatile + +object NewPipeDatabase { + + @Volatile + private var databaseInstance: AppDatabase? = null + + private fun getDatabase(context: Context): AppDatabase { + return databaseBuilder( + context.applicationContext, + AppDatabase::class.java, + AppDatabase.Companion.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_8_9 + ).build() + } + + @JvmStatic + fun getInstance(context: Context): AppDatabase { + var result = databaseInstance + if (result == null) { + synchronized(NewPipeDatabase::class.java) { + result = databaseInstance + if (result == null) { + databaseInstance = getDatabase(context) + result = databaseInstance + } + } + } + + return result!! + } + + @JvmStatic + fun checkpoint() { + checkNotNull(databaseInstance) { "database is not initialized" } + val c = databaseInstance!!.query("pragma wal_checkpoint(full)", null) + if (c.moveToFirst() && c.getInt(0) == 1) { + throw RuntimeException("Checkpoint was blocked from completing") + } + } + + @JvmStatic + fun close() { + if (databaseInstance != null) { + synchronized(NewPipeDatabase::class.java) { + if (databaseInstance != null) { + databaseInstance!!.close() + databaseInstance = null + } + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java b/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java deleted file mode 100644 index 04d93a238d5..00000000000 --- a/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java +++ /dev/null @@ -1,65 +0,0 @@ -package org.schabi.newpipe.database; - -import static org.schabi.newpipe.database.Migrations.DB_VER_9; - -import androidx.room.Database; -import androidx.room.RoomDatabase; -import androidx.room.TypeConverters; - -import org.schabi.newpipe.database.feed.dao.FeedDAO; -import org.schabi.newpipe.database.feed.dao.FeedGroupDAO; -import org.schabi.newpipe.database.feed.model.FeedEntity; -import org.schabi.newpipe.database.feed.model.FeedGroupEntity; -import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity; -import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity; -import org.schabi.newpipe.database.history.dao.SearchHistoryDAO; -import org.schabi.newpipe.database.history.dao.StreamHistoryDAO; -import org.schabi.newpipe.database.history.model.SearchHistoryEntry; -import org.schabi.newpipe.database.history.model.StreamHistoryEntity; -import org.schabi.newpipe.database.playlist.dao.PlaylistDAO; -import org.schabi.newpipe.database.playlist.dao.PlaylistRemoteDAO; -import org.schabi.newpipe.database.playlist.dao.PlaylistStreamDAO; -import org.schabi.newpipe.database.playlist.model.PlaylistEntity; -import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; -import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity; -import org.schabi.newpipe.database.stream.dao.StreamDAO; -import org.schabi.newpipe.database.stream.dao.StreamStateDAO; -import org.schabi.newpipe.database.stream.model.StreamEntity; -import org.schabi.newpipe.database.stream.model.StreamStateEntity; -import org.schabi.newpipe.database.subscription.SubscriptionDAO; -import org.schabi.newpipe.database.subscription.SubscriptionEntity; - -@TypeConverters({Converters.class}) -@Database( - entities = { - SubscriptionEntity.class, SearchHistoryEntry.class, - StreamEntity.class, StreamHistoryEntity.class, StreamStateEntity.class, - PlaylistEntity.class, PlaylistStreamEntity.class, PlaylistRemoteEntity.class, - FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.class, - FeedLastUpdatedEntity.class - }, - version = DB_VER_9 -) -public abstract class AppDatabase extends RoomDatabase { - public static final String DATABASE_NAME = "newpipe.db"; - - public abstract SearchHistoryDAO searchHistoryDAO(); - - public abstract StreamDAO streamDAO(); - - public abstract StreamHistoryDAO streamHistoryDAO(); - - public abstract StreamStateDAO streamStateDAO(); - - public abstract PlaylistDAO playlistDAO(); - - public abstract PlaylistStreamDAO playlistStreamDAO(); - - public abstract PlaylistRemoteDAO playlistRemoteDAO(); - - public abstract FeedDAO feedDAO(); - - public abstract FeedGroupDAO feedGroupDAO(); - - public abstract SubscriptionDAO subscriptionDAO(); -} diff --git a/app/src/main/java/org/schabi/newpipe/database/AppDatabase.kt b/app/src/main/java/org/schabi/newpipe/database/AppDatabase.kt new file mode 100644 index 00000000000..286eddf7b76 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/AppDatabase.kt @@ -0,0 +1,68 @@ +/* + * SPDX-FileCopyrightText: 2017-2024 NewPipe contributors + * SPDX-FileCopyrightText: 2025 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package org.schabi.newpipe.database + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import org.schabi.newpipe.database.feed.dao.FeedDAO +import org.schabi.newpipe.database.feed.dao.FeedGroupDAO +import org.schabi.newpipe.database.feed.model.FeedEntity +import org.schabi.newpipe.database.feed.model.FeedGroupEntity +import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity +import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity +import org.schabi.newpipe.database.history.dao.SearchHistoryDAO +import org.schabi.newpipe.database.history.dao.StreamHistoryDAO +import org.schabi.newpipe.database.history.model.SearchHistoryEntry +import org.schabi.newpipe.database.history.model.StreamHistoryEntity +import org.schabi.newpipe.database.playlist.dao.PlaylistDAO +import org.schabi.newpipe.database.playlist.dao.PlaylistRemoteDAO +import org.schabi.newpipe.database.playlist.dao.PlaylistStreamDAO +import org.schabi.newpipe.database.playlist.model.PlaylistEntity +import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity +import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity +import org.schabi.newpipe.database.stream.dao.StreamDAO +import org.schabi.newpipe.database.stream.dao.StreamStateDAO +import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.database.stream.model.StreamStateEntity +import org.schabi.newpipe.database.subscription.SubscriptionDAO +import org.schabi.newpipe.database.subscription.SubscriptionEntity + +@TypeConverters(Converters::class) +@Database( + version = Migrations.DB_VER_9, + entities = [ + SubscriptionEntity::class, + SearchHistoryEntry::class, + StreamEntity::class, + StreamHistoryEntity::class, + StreamStateEntity::class, + PlaylistEntity::class, + PlaylistStreamEntity::class, + PlaylistRemoteEntity::class, + FeedEntity::class, + FeedGroupEntity::class, + FeedGroupSubscriptionEntity::class, + FeedLastUpdatedEntity::class + ] +) +abstract class AppDatabase : RoomDatabase() { + abstract fun feedDAO(): FeedDAO + abstract fun feedGroupDAO(): FeedGroupDAO + abstract fun playlistDAO(): PlaylistDAO + abstract fun playlistRemoteDAO(): PlaylistRemoteDAO + abstract fun playlistStreamDAO(): PlaylistStreamDAO + abstract fun searchHistoryDAO(): SearchHistoryDAO + abstract fun streamDAO(): StreamDAO + abstract fun streamHistoryDAO(): StreamHistoryDAO + abstract fun streamStateDAO(): StreamStateDAO + abstract fun subscriptionDAO(): SubscriptionDAO + + companion object { + const val DATABASE_NAME: String = "newpipe.db" + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/BasicDAO.java b/app/src/main/java/org/schabi/newpipe/database/BasicDAO.java deleted file mode 100644 index 255f5ba8deb..00000000000 --- a/app/src/main/java/org/schabi/newpipe/database/BasicDAO.java +++ /dev/null @@ -1,39 +0,0 @@ -package org.schabi.newpipe.database; - -import androidx.room.Dao; -import androidx.room.Delete; -import androidx.room.Insert; -import androidx.room.Update; - -import java.util.Collection; -import java.util.List; - -import io.reactivex.rxjava3.core.Flowable; - -@Dao -public interface BasicDAO { - /* Inserts */ - @Insert - long insert(Entity entity); - - @Insert - List insertAll(Collection entities); - - /* Searches */ - Flowable> getAll(); - - Flowable> listByService(int serviceId); - - /* Deletes */ - @Delete - void delete(Entity entity); - - int deleteAll(); - - /* Updates */ - @Update - int update(Entity entity); - - @Update - void update(Collection entities); -} diff --git a/app/src/main/java/org/schabi/newpipe/database/BasicDAO.kt b/app/src/main/java/org/schabi/newpipe/database/BasicDAO.kt new file mode 100644 index 00000000000..74c7cc87c86 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/BasicDAO.kt @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: 2017-2022 NewPipe contributors + * SPDX-FileCopyrightText: 2025 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package org.schabi.newpipe.database + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Update +import io.reactivex.rxjava3.core.Flowable + +@Dao +interface BasicDAO { + + /* Inserts */ + @Insert + fun insert(entity: Entity): Long + + @Insert + fun insertAll(entities: Collection): List + + /* Searches */ + fun getAll(): Flowable> + + fun listByService(serviceId: Int): Flowable> + + /* Deletes */ + @Delete + fun delete(entity: Entity) + + fun deleteAll(): Int + + /* Updates */ + @Update + fun update(entity: Entity): Int + + @Update + fun update(entities: Collection) +} diff --git a/app/src/main/java/org/schabi/newpipe/database/LocalItem.java b/app/src/main/java/org/schabi/newpipe/database/LocalItem.java deleted file mode 100644 index 54b856b0653..00000000000 --- a/app/src/main/java/org/schabi/newpipe/database/LocalItem.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.schabi.newpipe.database; - -public interface LocalItem { - LocalItemType getLocalItemType(); - - enum LocalItemType { - PLAYLIST_LOCAL_ITEM, - PLAYLIST_REMOTE_ITEM, - - PLAYLIST_STREAM_ITEM, - STATISTIC_STREAM_ITEM, - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/LocalItem.kt b/app/src/main/java/org/schabi/newpipe/database/LocalItem.kt new file mode 100644 index 00000000000..50529610b88 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/LocalItem.kt @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2018-2020 NewPipe contributors + * SPDX-FileCopyrightText: 2025 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package org.schabi.newpipe.database + +interface LocalItem { + val localItemType: LocalItemType + + enum class LocalItemType { + PLAYLIST_LOCAL_ITEM, + PLAYLIST_REMOTE_ITEM, + + PLAYLIST_STREAM_ITEM, + STATISTIC_STREAM_ITEM, + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/Migrations.java b/app/src/main/java/org/schabi/newpipe/database/Migrations.java deleted file mode 100644 index c9f630869c9..00000000000 --- a/app/src/main/java/org/schabi/newpipe/database/Migrations.java +++ /dev/null @@ -1,307 +0,0 @@ -package org.schabi.newpipe.database; - -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.room.migration.Migration; -import androidx.sqlite.db.SupportSQLiteDatabase; - -import org.schabi.newpipe.MainActivity; - -public final class Migrations { - - ///////////////////////////////////////////////////////////////////////////// - // Test new migrations manually by importing a database from daily usage // - // and checking if the migration works (Use the Database Inspector // - // https://developer.android.com/studio/inspect/database). // - // If you add a migration point it out in the pull request, so that // - // others remember to test it themselves. // - ///////////////////////////////////////////////////////////////////////////// - - public static final int DB_VER_1 = 1; - public static final int DB_VER_2 = 2; - public static final int DB_VER_3 = 3; - public static final int DB_VER_4 = 4; - public static final int DB_VER_5 = 5; - 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; - - public static final Migration MIGRATION_1_2 = new Migration(DB_VER_1, DB_VER_2) { - @Override - public void migrate(@NonNull final SupportSQLiteDatabase database) { - if (DEBUG) { - Log.d(TAG, "Start migrating database"); - } - /* - * Unfortunately these queries must be hardcoded due to the possibility of - * schema and names changing at a later date, thus invalidating the older migration - * scripts if they are not hardcoded. - * */ - - // Not much we can do about this, since room doesn't create tables before migration. - // It's either this or blasting the entire database anew. - database.execSQL("CREATE INDEX `index_search_history_search` " - + "ON `search_history` (`search`)"); - database.execSQL("CREATE TABLE IF NOT EXISTS `streams` " - + "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " - + "`service_id` INTEGER NOT NULL, `url` TEXT, `title` TEXT, " - + "`stream_type` TEXT, `duration` INTEGER, `uploader` TEXT, " - + "`thumbnail_url` TEXT)"); - database.execSQL("CREATE UNIQUE INDEX `index_streams_service_id_url` " - + "ON `streams` (`service_id`, `url`)"); - database.execSQL("CREATE TABLE IF NOT EXISTS `stream_history` " - + "(`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 )"); - database.execSQL("CREATE INDEX `index_stream_history_stream_id` " - + "ON `stream_history` (`stream_id`)"); - database.execSQL("CREATE TABLE IF NOT EXISTS `stream_state` " - + "(`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 )"); - database.execSQL("CREATE TABLE IF NOT EXISTS `playlists` " - + "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " - + "`name` TEXT, `thumbnail_url` TEXT)"); - database.execSQL("CREATE INDEX `index_playlists_name` ON `playlists` (`name`)"); - database.execSQL("CREATE TABLE IF NOT EXISTS `playlist_stream_join` " - + "(`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)"); - database.execSQL("CREATE UNIQUE INDEX " - + "`index_playlist_stream_join_playlist_id_join_index` " - + "ON `playlist_stream_join` (`playlist_id`, `join_index`)"); - database.execSQL("CREATE INDEX `index_playlist_stream_join_stream_id` " - + "ON `playlist_stream_join` (`stream_id`)"); - database.execSQL("CREATE TABLE IF NOT EXISTS `remote_playlists` " - + "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " - + "`service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, " - + "`thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)"); - database.execSQL("CREATE INDEX `index_remote_playlists_name` " - + "ON `remote_playlists` (`name`)"); - database.execSQL("CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` " - + "ON `remote_playlists` (`service_id`, `url`)"); - - // Populate streams table with existing entries in watch history - // Latest data first, thus ignoring older entries with the same indices - database.execSQL("INSERT OR IGNORE INTO streams (service_id, url, title, " - + "stream_type, duration, uploader, thumbnail_url) " - - + "SELECT service_id, url, title, 'VIDEO_STREAM', duration, " - + "uploader, thumbnail_url " - - + "FROM watch_history " - + "ORDER BY creation_date DESC"); - - // Once the streams have PKs, join them with the normalized history table - // and populate it with the remaining data from watch history - database.execSQL("INSERT INTO stream_history (stream_id, access_date, repeat_count)" - + "SELECT uid, creation_date, 1 " - + "FROM watch_history INNER JOIN streams " - + "ON watch_history.service_id == streams.service_id " - + "AND watch_history.url == streams.url " - + "ORDER BY creation_date DESC"); - - database.execSQL("DROP TABLE IF EXISTS watch_history"); - - if (DEBUG) { - Log.d(TAG, "Stop migrating database"); - } - } - }; - - public static final Migration MIGRATION_2_3 = new Migration(DB_VER_2, DB_VER_3) { - @Override - public void migrate(@NonNull final SupportSQLiteDatabase database) { - // Add NOT NULLs and new fields - database.execSQL("CREATE TABLE IF NOT EXISTS streams_new " - + "(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, thumbnail_url TEXT, view_count INTEGER, " - + "textual_upload_date TEXT, upload_date INTEGER, " - + "is_upload_date_approximation INTEGER)"); - - database.execSQL("INSERT INTO streams_new (uid, service_id, url, title, stream_type, " - + "duration, uploader, thumbnail_url, view_count, textual_upload_date, " - + "upload_date, is_upload_date_approximation) " - - + "SELECT uid, service_id, url, ifnull(title, ''), " - + "ifnull(stream_type, 'VIDEO_STREAM'), ifnull(duration, 0), " - + "ifnull(uploader, ''), ifnull(thumbnail_url, ''), NULL, NULL, NULL, NULL " - - + "FROM streams WHERE url IS NOT NULL"); - - database.execSQL("DROP TABLE streams"); - database.execSQL("ALTER TABLE streams_new RENAME TO streams"); - database.execSQL("CREATE UNIQUE INDEX index_streams_service_id_url " - + "ON streams (service_id, url)"); - - // Tables for feed feature - database.execSQL("CREATE TABLE IF NOT EXISTS feed " - + "(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)"); - database.execSQL("CREATE INDEX index_feed_subscription_id ON feed (subscription_id)"); - database.execSQL("CREATE TABLE IF NOT EXISTS feed_group " - + "(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name TEXT NOT NULL, " - + "icon_id INTEGER NOT NULL, sort_order INTEGER NOT NULL)"); - database.execSQL("CREATE INDEX index_feed_group_sort_order ON feed_group (sort_order)"); - database.execSQL("CREATE TABLE IF NOT EXISTS feed_group_subscription_join " - + "(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)"); - database.execSQL("CREATE INDEX index_feed_group_subscription_join_subscription_id " - + "ON feed_group_subscription_join (subscription_id)"); - database.execSQL("CREATE TABLE IF NOT EXISTS feed_last_updated " - + "(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)"); - } - }; - - public static final Migration MIGRATION_3_4 = new Migration(DB_VER_3, DB_VER_4) { - @Override - public void migrate(@NonNull final SupportSQLiteDatabase database) { - database.execSQL( - "ALTER TABLE streams ADD COLUMN uploader_url TEXT" - ); - } - }; - - public static final Migration MIGRATION_4_5 = new Migration(DB_VER_4, DB_VER_5) { - @Override - public void migrate(@NonNull final SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `subscriptions` ADD COLUMN `notification_mode` " - + "INTEGER NOT NULL DEFAULT 0"); - } - }; - - public static final Migration MIGRATION_5_6 = new Migration(DB_VER_5, DB_VER_6) { - @Override - public void migrate(@NonNull final SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `playlists` ADD COLUMN `is_thumbnail_permanent` " - + "INTEGER NOT NULL DEFAULT 0"); - } - }; - - public static final Migration MIGRATION_6_7 = new Migration(DB_VER_6, DB_VER_7) { - @Override - public void migrate(@NonNull final SupportSQLiteDatabase database) { - // Create a new column thumbnail_stream_id - database.execSQL("ALTER TABLE `playlists` ADD COLUMN `thumbnail_stream_id` " - + "INTEGER NOT NULL DEFAULT -1"); - - // Migrate the thumbnail_url to the thumbnail_stream_id - database.execSQL("UPDATE playlists SET thumbnail_stream_id = (" - + " SELECT CASE WHEN COUNT(*) != 0 then stream_uid ELSE -1 END" - + " FROM (" - + " SELECT p.uid AS playlist_uid, s.uid AS stream_uid" - + " FROM playlists p" - + " LEFT JOIN playlist_stream_join ps ON p.uid = ps.playlist_id" - + " LEFT JOIN streams s ON s.uid = ps.stream_id" - + " WHERE s.thumbnail_url = p.thumbnail_url) AS temporary_table" - + " WHERE playlist_uid = playlists.uid)"); - - // Remove the thumbnail_url field in the playlist table - database.execSQL("CREATE TABLE IF NOT EXISTS `playlists_new`" - + "(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " - + "name TEXT, " - + "is_thumbnail_permanent INTEGER NOT NULL, " - + "thumbnail_stream_id INTEGER NOT NULL)"); - - database.execSQL("INSERT INTO playlists_new" - + " SELECT uid, name, is_thumbnail_permanent, thumbnail_stream_id " - + " FROM playlists"); - - - database.execSQL("DROP TABLE playlists"); - database.execSQL("ALTER TABLE playlists_new RENAME TO playlists"); - database.execSQL("CREATE INDEX IF NOT EXISTS " - + "`index_playlists_name` ON `playlists` (`name`)"); - } - }; - - public static final Migration MIGRATION_7_8 = new Migration(DB_VER_7, DB_VER_8) { - @Override - public void migrate(@NonNull final SupportSQLiteDatabase database) { - database.execSQL("DELETE FROM search_history WHERE id NOT IN (SELECT id FROM (SELECT " - + "MIN(id) as id FROM search_history GROUP BY trim(search), service_id ) tmp)"); - database.execSQL("UPDATE search_history SET search = trim(search)"); - } - }; - - 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/Migrations.kt b/app/src/main/java/org/schabi/newpipe/database/Migrations.kt new file mode 100644 index 00000000000..8988708e679 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/Migrations.kt @@ -0,0 +1,368 @@ +/* + * SPDX-FileCopyrightText: 2018-2024 NewPipe contributors + * SPDX-FileCopyrightText: 2025 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package org.schabi.newpipe.database + +import android.util.Log +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import org.schabi.newpipe.MainActivity + +object Migrations { + + // /////////////////////////////////////////////////////////////////////// // + // Test new migrations manually by importing a database from daily usage // + // and checking if the migration works (Use the Database Inspector // + // https://developer.android.com/studio/inspect/database). // + // If you add a migration point it out in the pull request, so that // + // others remember to test it themselves. // + // /////////////////////////////////////////////////////////////////////// // + + const val DB_VER_1 = 1 + const val DB_VER_2 = 2 + const val DB_VER_3 = 3 + const val DB_VER_4 = 4 + const val DB_VER_5 = 5 + const val DB_VER_6 = 6 + const val DB_VER_7 = 7 + const val DB_VER_8 = 8 + const val DB_VER_9 = 9 + + private val TAG = Migrations::class.java.getName() + private val isDebug = MainActivity.DEBUG + + val MIGRATION_1_2 = object : Migration(DB_VER_1, DB_VER_2) { + override fun migrate(db: SupportSQLiteDatabase) { + if (isDebug) { + Log.d(TAG, "Start migrating database") + } + + /* + * Unfortunately these queries must be hardcoded due to the possibility of + * schema and names changing at a later date, thus invalidating the older migration + * scripts if they are not hardcoded. + * */ + + // Not much we can do about this, since room doesn't create tables before migration. + // It's either this or blasting the entire database anew. + db.execSQL( + "CREATE INDEX `index_search_history_search` " + + "ON `search_history` (`search`)" + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS `streams` " + + "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "`service_id` INTEGER NOT NULL, `url` TEXT, `title` TEXT, " + + "`stream_type` TEXT, `duration` INTEGER, `uploader` TEXT, " + + "`thumbnail_url` TEXT)" + ) + db.execSQL( + "CREATE UNIQUE INDEX `index_streams_service_id_url` " + + "ON `streams` (`service_id`, `url`)" + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS `stream_history` " + + "(`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 )" + ) + db.execSQL( + "CREATE INDEX `index_stream_history_stream_id` " + + "ON `stream_history` (`stream_id`)" + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS `stream_state` " + + "(`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 )" + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS `playlists` " + + "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "`name` TEXT, `thumbnail_url` TEXT)" + ) + db.execSQL("CREATE INDEX `index_playlists_name` ON `playlists` (`name`)") + db.execSQL( + "CREATE TABLE IF NOT EXISTS `playlist_stream_join` " + + "(`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)" + ) + db.execSQL( + "CREATE UNIQUE INDEX " + + "`index_playlist_stream_join_playlist_id_join_index` " + + "ON `playlist_stream_join` (`playlist_id`, `join_index`)" + ) + db.execSQL( + "CREATE INDEX `index_playlist_stream_join_stream_id` " + + "ON `playlist_stream_join` (`stream_id`)" + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS `remote_playlists` " + + "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "`service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, " + + "`thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)" + ) + db.execSQL( + "CREATE INDEX `index_remote_playlists_name` " + + "ON `remote_playlists` (`name`)" + ) + db.execSQL( + "CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` " + + "ON `remote_playlists` (`service_id`, `url`)" + ) + + // Populate streams table with existing entries in watch history + // Latest data first, thus ignoring older entries with the same indices + db.execSQL( + "INSERT OR IGNORE INTO streams (service_id, url, title, " + + "stream_type, duration, uploader, thumbnail_url) " + + + "SELECT service_id, url, title, 'VIDEO_STREAM', duration, " + + "uploader, thumbnail_url " + + + "FROM watch_history " + + "ORDER BY creation_date DESC" + ) + + // Once the streams have PKs, join them with the normalized history table + // and populate it with the remaining data from watch history + db.execSQL( + "INSERT INTO stream_history (stream_id, access_date, repeat_count)" + + "SELECT uid, creation_date, 1 " + + "FROM watch_history INNER JOIN streams " + + "ON watch_history.service_id == streams.service_id " + + "AND watch_history.url == streams.url " + + "ORDER BY creation_date DESC" + ) + + db.execSQL("DROP TABLE IF EXISTS watch_history") + + if (isDebug) { + Log.d(TAG, "Stop migrating database") + } + } + } + + val MIGRATION_2_3 = object : Migration(DB_VER_2, DB_VER_3) { + override fun migrate(db: SupportSQLiteDatabase) { + // Add NOT NULLs and new fields + db.execSQL( + "CREATE TABLE IF NOT EXISTS streams_new " + + "(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, thumbnail_url TEXT, view_count INTEGER, " + + "textual_upload_date TEXT, upload_date INTEGER, " + + "is_upload_date_approximation INTEGER)" + ) + + db.execSQL( + "INSERT INTO streams_new (uid, service_id, url, title, stream_type, " + + "duration, uploader, thumbnail_url, view_count, textual_upload_date, " + + "upload_date, is_upload_date_approximation) " + + + "SELECT uid, service_id, url, ifnull(title, ''), " + + "ifnull(stream_type, 'VIDEO_STREAM'), ifnull(duration, 0), " + + "ifnull(uploader, ''), ifnull(thumbnail_url, ''), NULL, NULL, NULL, NULL " + + + "FROM streams WHERE url IS NOT NULL" + ) + + db.execSQL("DROP TABLE streams") + db.execSQL("ALTER TABLE streams_new RENAME TO streams") + db.execSQL( + "CREATE UNIQUE INDEX index_streams_service_id_url " + + "ON streams (service_id, url)" + ) + + // Tables for feed feature + db.execSQL( + "CREATE TABLE IF NOT EXISTS feed " + + "(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)" + ) + db.execSQL("CREATE INDEX index_feed_subscription_id ON feed (subscription_id)") + db.execSQL( + "CREATE TABLE IF NOT EXISTS feed_group " + + "(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name TEXT NOT NULL, " + + "icon_id INTEGER NOT NULL, sort_order INTEGER NOT NULL)" + ) + db.execSQL("CREATE INDEX index_feed_group_sort_order ON feed_group (sort_order)") + db.execSQL( + "CREATE TABLE IF NOT EXISTS feed_group_subscription_join " + + "(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)" + ) + db.execSQL( + "CREATE INDEX index_feed_group_subscription_join_subscription_id " + + "ON feed_group_subscription_join (subscription_id)" + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS feed_last_updated " + + "(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)" + ) + } + } + + val MIGRATION_3_4 = object : Migration(DB_VER_3, DB_VER_4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE streams ADD COLUMN uploader_url TEXT") + } + } + + val MIGRATION_4_5 = object : Migration(DB_VER_4, DB_VER_5) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE `subscriptions` ADD COLUMN `notification_mode` " + + "INTEGER NOT NULL DEFAULT 0" + ) + } + } + + val MIGRATION_5_6 = object : Migration(DB_VER_5, DB_VER_6) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE `playlists` ADD COLUMN `is_thumbnail_permanent` " + + "INTEGER NOT NULL DEFAULT 0" + ) + } + } + + val MIGRATION_6_7 = object : Migration(DB_VER_6, DB_VER_7) { + override fun migrate(db: SupportSQLiteDatabase) { + // Create a new column thumbnail_stream_id + db.execSQL( + "ALTER TABLE `playlists` ADD COLUMN `thumbnail_stream_id` " + + "INTEGER NOT NULL DEFAULT -1" + ) + + // Migrate the thumbnail_url to the thumbnail_stream_id + db.execSQL( + "UPDATE playlists SET thumbnail_stream_id = (" + + " SELECT CASE WHEN COUNT(*) != 0 then stream_uid ELSE -1 END" + + " FROM (" + + " SELECT p.uid AS playlist_uid, s.uid AS stream_uid" + + " FROM playlists p" + + " LEFT JOIN playlist_stream_join ps ON p.uid = ps.playlist_id" + + " LEFT JOIN streams s ON s.uid = ps.stream_id" + + " WHERE s.thumbnail_url = p.thumbnail_url) AS temporary_table" + + " WHERE playlist_uid = playlists.uid)" + ) + + // Remove the thumbnail_url field in the playlist table + db.execSQL( + "CREATE TABLE IF NOT EXISTS `playlists_new`" + + "(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "name TEXT, " + + "is_thumbnail_permanent INTEGER NOT NULL, " + + "thumbnail_stream_id INTEGER NOT NULL)" + ) + + db.execSQL( + "INSERT INTO playlists_new" + + " SELECT uid, name, is_thumbnail_permanent, thumbnail_stream_id " + + " FROM playlists" + ) + + db.execSQL("DROP TABLE playlists") + db.execSQL("ALTER TABLE playlists_new RENAME TO playlists") + db.execSQL( + "CREATE INDEX IF NOT EXISTS " + + "`index_playlists_name` ON `playlists` (`name`)" + ) + } + } + + val MIGRATION_7_8 = object : Migration(DB_VER_7, DB_VER_8) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "DELETE FROM search_history WHERE id NOT IN (SELECT id FROM (SELECT " + + "MIN(id) as id FROM search_history GROUP BY trim(search), service_id ) tmp)" + ) + db.execSQL("UPDATE search_history SET search = trim(search)") + } + } + + val MIGRATION_8_9 = object : Migration(DB_VER_8, DB_VER_9) { + override fun migrate(db: SupportSQLiteDatabase) { + try { + db.beginTransaction() + + // Update playlists. + // Create a temp table to initialize display_index. + db.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)" + ) + db.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. + db.execSQL("DROP TABLE `playlists`") + db.execSQL("ALTER TABLE `playlists_tmp` RENAME TO `playlists`") + + // Update remote_playlists. + // Create a temp table to initialize display_index. + db.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)" + ) + db.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. + db.execSQL("DROP TABLE `remote_playlists`") + db.execSQL("ALTER TABLE `remote_playlists_tmp` RENAME TO `remote_playlists`") + + // Create index on the new table. + db.execSQL( + "CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` " + + "ON `remote_playlists` (`service_id`, `url`)" + ) + + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt b/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt index e7ed934977a..d756df8b1f7 100644 --- a/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt +++ b/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt @@ -168,10 +168,10 @@ abstract class FeedDAO { ON fgs.subscription_id = lu.subscription_id AND fgs.group_id = :groupId """ ) - abstract fun oldestSubscriptionUpdate(groupId: Long): Flowable> + abstract fun oldestSubscriptionUpdate(groupId: Long): Flowable> @Query("SELECT MIN(last_updated) FROM feed_last_updated") - abstract fun oldestSubscriptionUpdateFromAll(): Flowable> + abstract fun oldestSubscriptionUpdateFromAll(): Flowable> @Query("SELECT COUNT(*) FROM feed_last_updated WHERE last_updated IS NULL") abstract fun notLoadedCount(): Flowable diff --git a/app/src/main/java/org/schabi/newpipe/database/history/dao/HistoryDAO.java b/app/src/main/java/org/schabi/newpipe/database/history/dao/HistoryDAO.java deleted file mode 100644 index 1ade08122c8..00000000000 --- a/app/src/main/java/org/schabi/newpipe/database/history/dao/HistoryDAO.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.schabi.newpipe.database.history.dao; - -import org.schabi.newpipe.database.BasicDAO; - -public interface HistoryDAO extends BasicDAO { - T getLatestEntry(); -} diff --git a/app/src/main/java/org/schabi/newpipe/database/history/dao/HistoryDAO.kt b/app/src/main/java/org/schabi/newpipe/database/history/dao/HistoryDAO.kt new file mode 100644 index 00000000000..d986d0c3b01 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/history/dao/HistoryDAO.kt @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2017 NewPipe contributors + * SPDX-FileCopyrightText: 2025 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package org.schabi.newpipe.database.history.dao + +import org.schabi.newpipe.database.BasicDAO + +interface HistoryDAO : BasicDAO { + val latestEntry: T +} diff --git a/app/src/main/java/org/schabi/newpipe/database/history/dao/SearchHistoryDAO.java b/app/src/main/java/org/schabi/newpipe/database/history/dao/SearchHistoryDAO.java deleted file mode 100644 index 8a281bdb48c..00000000000 --- a/app/src/main/java/org/schabi/newpipe/database/history/dao/SearchHistoryDAO.java +++ /dev/null @@ -1,52 +0,0 @@ -package org.schabi.newpipe.database.history.dao; - -import androidx.annotation.Nullable; -import androidx.room.Dao; -import androidx.room.Query; - -import org.schabi.newpipe.database.history.model.SearchHistoryEntry; - -import java.util.List; - -import io.reactivex.rxjava3.core.Flowable; - -import static org.schabi.newpipe.database.history.model.SearchHistoryEntry.CREATION_DATE; -import static org.schabi.newpipe.database.history.model.SearchHistoryEntry.ID; -import static org.schabi.newpipe.database.history.model.SearchHistoryEntry.SEARCH; -import static org.schabi.newpipe.database.history.model.SearchHistoryEntry.SERVICE_ID; -import static org.schabi.newpipe.database.history.model.SearchHistoryEntry.TABLE_NAME; - -@Dao -public interface SearchHistoryDAO extends HistoryDAO { - String ORDER_BY_CREATION_DATE = " ORDER BY " + CREATION_DATE + " DESC"; - String ORDER_BY_MAX_CREATION_DATE = " ORDER BY MAX(" + CREATION_DATE + ") DESC"; - - @Query("SELECT * FROM " + TABLE_NAME - + " WHERE " + ID + " = (SELECT MAX(" + ID + ") FROM " + TABLE_NAME + ")") - @Nullable - SearchHistoryEntry getLatestEntry(); - - @Query("DELETE FROM " + TABLE_NAME) - @Override - int deleteAll(); - - @Query("DELETE FROM " + TABLE_NAME + " WHERE " + SEARCH + " = :query") - int deleteAllWhereQuery(String query); - - @Query("SELECT * FROM " + TABLE_NAME + ORDER_BY_CREATION_DATE) - @Override - Flowable> getAll(); - - @Query("SELECT " + SEARCH + " FROM " + TABLE_NAME + " GROUP BY " + SEARCH - + ORDER_BY_MAX_CREATION_DATE + " LIMIT :limit") - Flowable> getUniqueEntries(int limit); - - @Query("SELECT * FROM " + TABLE_NAME - + " WHERE " + SERVICE_ID + " = :serviceId" + ORDER_BY_CREATION_DATE) - @Override - Flowable> listByService(int serviceId); - - @Query("SELECT " + SEARCH + " FROM " + TABLE_NAME + " WHERE " + SEARCH + " LIKE :query || '%'" - + " GROUP BY " + SEARCH + ORDER_BY_MAX_CREATION_DATE + " LIMIT :limit") - Flowable> getSimilarEntries(String query, int limit); -} diff --git a/app/src/main/java/org/schabi/newpipe/database/history/dao/SearchHistoryDAO.kt b/app/src/main/java/org/schabi/newpipe/database/history/dao/SearchHistoryDAO.kt new file mode 100644 index 00000000000..a249721fc13 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/history/dao/SearchHistoryDAO.kt @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: 2017-2021 NewPipe contributors + * SPDX-FileCopyrightText: 2025 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package org.schabi.newpipe.database.history.dao + +import androidx.room.Dao +import androidx.room.Query +import io.reactivex.rxjava3.core.Flowable +import org.schabi.newpipe.database.history.model.SearchHistoryEntry + +@Dao +interface SearchHistoryDAO : HistoryDAO { + + @get:Query("SELECT * FROM search_history WHERE id = (SELECT MAX(id) FROM search_history)") + override val latestEntry: SearchHistoryEntry + + @Query("DELETE FROM search_history") + override fun deleteAll(): Int + + @Query("DELETE FROM search_history WHERE search = :query") + fun deleteAllWhereQuery(query: String): Int + + @Query("SELECT * FROM search_history ORDER BY creation_date DESC") + override fun getAll(): Flowable> + + @Query("SELECT search FROM search_history GROUP BY search ORDER BY MAX(creation_date) DESC LIMIT :limit") + fun getUniqueEntries(limit: Int): Flowable> + + @Query("SELECT * FROM search_history WHERE service_id = :serviceId ORDER BY creation_date DESC") + override fun listByService(serviceId: Int): Flowable> + + @Query( + """ + SELECT search FROM search_history WHERE search LIKE :query || + '%' GROUP BY search ORDER BY MAX(creation_date) DESC LIMIT :limit + """ + ) + fun getSimilarEntries(query: String, limit: Int): Flowable> +} diff --git a/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.java b/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.java deleted file mode 100644 index 150d4a8e5b5..00000000000 --- a/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.java +++ /dev/null @@ -1,89 +0,0 @@ -package org.schabi.newpipe.database.history.dao; - -import androidx.annotation.Nullable; -import androidx.room.Dao; -import androidx.room.Query; -import androidx.room.RewriteQueriesToDropUnusedColumns; - -import org.schabi.newpipe.database.history.model.StreamHistoryEntity; -import org.schabi.newpipe.database.history.model.StreamHistoryEntry; -import org.schabi.newpipe.database.stream.StreamStatisticsEntry; - -import java.util.List; - -import io.reactivex.rxjava3.core.Flowable; - -import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.JOIN_STREAM_ID; -import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.STREAM_ACCESS_DATE; -import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.STREAM_HISTORY_TABLE; -import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.STREAM_REPEAT_COUNT; -import static org.schabi.newpipe.database.stream.StreamStatisticsEntry.STREAM_LATEST_DATE; -import static org.schabi.newpipe.database.stream.StreamStatisticsEntry.STREAM_WATCH_COUNT; -import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_ID; -import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_TABLE; -import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID_ALIAS; -import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS; -import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE; - -@Dao -public abstract class StreamHistoryDAO implements HistoryDAO { - @Query("SELECT * FROM " + STREAM_HISTORY_TABLE - + " WHERE " + STREAM_ACCESS_DATE + " = " - + "(SELECT MAX(" + STREAM_ACCESS_DATE + ") FROM " + STREAM_HISTORY_TABLE + ")") - @Override - @Nullable - public abstract StreamHistoryEntity getLatestEntry(); - - @Override - @Query("SELECT * FROM " + STREAM_HISTORY_TABLE) - public abstract Flowable> getAll(); - - @Override - @Query("DELETE FROM " + STREAM_HISTORY_TABLE) - public abstract int deleteAll(); - - @Override - public Flowable> listByService(final int serviceId) { - throw new UnsupportedOperationException(); - } - - @Query("SELECT * FROM " + STREAM_TABLE - + " INNER JOIN " + STREAM_HISTORY_TABLE - + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID - + " ORDER BY " + STREAM_ACCESS_DATE + " DESC") - public abstract Flowable> getHistory(); - - - @Query("SELECT * FROM " + STREAM_TABLE - + " INNER JOIN " + STREAM_HISTORY_TABLE - + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID - + " ORDER BY " + STREAM_ID + " ASC") - public abstract Flowable> getHistorySortedById(); - - @Query("SELECT * FROM " + STREAM_HISTORY_TABLE + " WHERE " + JOIN_STREAM_ID - + " = :streamId ORDER BY " + STREAM_ACCESS_DATE + " DESC LIMIT 1") - @Nullable - public abstract StreamHistoryEntity getLatestEntry(long streamId); - - @Query("DELETE FROM " + STREAM_HISTORY_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId") - public abstract int deleteStreamHistory(long streamId); - - @RewriteQueriesToDropUnusedColumns - @Query("SELECT * FROM " + STREAM_TABLE - - // Select the latest entry and watch count for each stream id on history table - + " INNER JOIN " - + "(SELECT " + JOIN_STREAM_ID + ", " - + " MAX(" + STREAM_ACCESS_DATE + ") AS " + STREAM_LATEST_DATE + ", " - + " SUM(" + STREAM_REPEAT_COUNT + ") AS " + STREAM_WATCH_COUNT - + " FROM " + STREAM_HISTORY_TABLE + " GROUP BY " + JOIN_STREAM_ID + ")" - - + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID - - + " LEFT JOIN " - + "(SELECT " + JOIN_STREAM_ID + " AS " + JOIN_STREAM_ID_ALIAS + ", " - + STREAM_PROGRESS_MILLIS - + " FROM " + STREAM_STATE_TABLE + " )" - + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID_ALIAS) - public abstract Flowable> getStatistics(); -} diff --git a/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.kt b/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.kt new file mode 100644 index 00000000000..dfea41e122a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.kt @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: 2018-2022 NewPipe contributors + * SPDX-FileCopyrightText: 2025 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package org.schabi.newpipe.database.history.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.RewriteQueriesToDropUnusedColumns +import io.reactivex.rxjava3.core.Flowable +import org.schabi.newpipe.database.history.model.StreamHistoryEntity +import org.schabi.newpipe.database.history.model.StreamHistoryEntry +import org.schabi.newpipe.database.stream.StreamStatisticsEntry + +@Dao +abstract class StreamHistoryDAO : HistoryDAO { + + @get:Query("SELECT * FROM stream_history WHERE access_date = (SELECT MAX(access_date) FROM stream_history)") + abstract override val latestEntry: StreamHistoryEntity + + @Query("SELECT * FROM stream_history") + abstract override fun getAll(): Flowable> + + @Query("DELETE FROM stream_history") + abstract override fun deleteAll(): Int + + override fun listByService(serviceId: Int): Flowable> { + throw UnsupportedOperationException() + } + + @get:Query("SELECT * FROM streams INNER JOIN stream_history ON uid = stream_id ORDER BY access_date DESC") + abstract val history: Flowable> + + @get:Query("SELECT * FROM streams INNER JOIN stream_history ON uid = stream_id ORDER BY uid ASC") + abstract val historySortedById: Flowable> + + @Query("SELECT * FROM stream_history WHERE stream_id = :streamId ORDER BY access_date DESC LIMIT 1") + abstract fun getLatestEntry(streamId: Long): StreamHistoryEntity + + @Query("DELETE FROM stream_history WHERE stream_id = :streamId") + abstract fun deleteStreamHistory(streamId: Long): Int + + // Select the latest entry and watch count for each stream id on history table + @RewriteQueriesToDropUnusedColumns + @Query( + """ + SELECT * FROM streams + + INNER JOIN ( + SELECT stream_id, MAX(access_date) AS latestAccess, SUM(repeat_count) AS watchCount + FROM stream_history + GROUP BY stream_id + ) + ON uid = stream_id + + LEFT JOIN (SELECT stream_id AS stream_id_alias, progress_time FROM stream_state ) + ON uid = stream_id_alias + """ + ) + abstract fun getStatistics(): Flowable> +} diff --git a/app/src/main/java/org/schabi/newpipe/database/history/model/SearchHistoryEntry.kt b/app/src/main/java/org/schabi/newpipe/database/history/model/SearchHistoryEntry.kt index 8cb9a25ca17..e6006a0694d 100644 --- a/app/src/main/java/org/schabi/newpipe/database/history/model/SearchHistoryEntry.kt +++ b/app/src/main/java/org/schabi/newpipe/database/history/model/SearchHistoryEntry.kt @@ -1,3 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2022 NewPipe contributors + * SPDX-FileCopyrightText: 2025 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + package org.schabi.newpipe.database.history.model import androidx.room.ColumnInfo @@ -11,23 +17,24 @@ import java.time.OffsetDateTime tableName = SearchHistoryEntry.TABLE_NAME, indices = [Index(value = [SearchHistoryEntry.SEARCH])] ) -data class SearchHistoryEntry( - @field:ColumnInfo(name = CREATION_DATE) var creationDate: OffsetDateTime?, - @field:ColumnInfo( - name = SERVICE_ID - ) var serviceId: Int, - @field:ColumnInfo(name = SEARCH) var search: String? -) { +data class SearchHistoryEntry @JvmOverloads constructor( + @ColumnInfo(name = CREATION_DATE) + var creationDate: OffsetDateTime?, + + @ColumnInfo(name = SERVICE_ID) + val serviceId: Int, + + @ColumnInfo(name = SEARCH) + val search: String?, + @ColumnInfo(name = ID) @PrimaryKey(autoGenerate = true) - var id: Long = 0 + val id: Long = 0, +) { @Ignore fun hasEqualValues(otherEntry: SearchHistoryEntry): Boolean { - return ( - serviceId == otherEntry.serviceId && - search == otherEntry.search - ) + return serviceId == otherEntry.serviceId && search == otherEntry.search } companion object { diff --git a/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntity.java b/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntity.java deleted file mode 100644 index a9d69afe855..00000000000 --- a/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntity.java +++ /dev/null @@ -1,81 +0,0 @@ -package org.schabi.newpipe.database.history.model; - -import androidx.annotation.NonNull; -import androidx.room.ColumnInfo; -import androidx.room.Entity; -import androidx.room.ForeignKey; -import androidx.room.Index; - -import org.schabi.newpipe.database.stream.model.StreamEntity; - -import java.time.OffsetDateTime; - -import static androidx.room.ForeignKey.CASCADE; -import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.JOIN_STREAM_ID; -import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.STREAM_ACCESS_DATE; -import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.STREAM_HISTORY_TABLE; - -@Entity(tableName = STREAM_HISTORY_TABLE, - primaryKeys = {JOIN_STREAM_ID, STREAM_ACCESS_DATE}, - // No need to index for timestamp as they will almost always be unique - indices = {@Index(value = {JOIN_STREAM_ID})}, - foreignKeys = { - @ForeignKey(entity = StreamEntity.class, - parentColumns = StreamEntity.STREAM_ID, - childColumns = JOIN_STREAM_ID, - onDelete = CASCADE, onUpdate = CASCADE) - }) -public class StreamHistoryEntity { - public static final String STREAM_HISTORY_TABLE = "stream_history"; - public static final String JOIN_STREAM_ID = "stream_id"; - public static final String STREAM_ACCESS_DATE = "access_date"; - public static final String STREAM_REPEAT_COUNT = "repeat_count"; - - @ColumnInfo(name = JOIN_STREAM_ID) - private long streamUid; - - @NonNull - @ColumnInfo(name = STREAM_ACCESS_DATE) - private OffsetDateTime accessDate; - - @ColumnInfo(name = STREAM_REPEAT_COUNT) - private long repeatCount; - - /** - * @param streamUid the stream id this history item will refer to - * @param accessDate the last time the stream was accessed - * @param repeatCount the total number of views this stream received - */ - public StreamHistoryEntity(final long streamUid, - @NonNull final OffsetDateTime accessDate, - final long repeatCount) { - this.streamUid = streamUid; - this.accessDate = accessDate; - this.repeatCount = repeatCount; - } - - public long getStreamUid() { - return streamUid; - } - - public void setStreamUid(final long streamUid) { - this.streamUid = streamUid; - } - - @NonNull - public OffsetDateTime getAccessDate() { - return accessDate; - } - - public void setAccessDate(@NonNull final OffsetDateTime accessDate) { - this.accessDate = accessDate; - } - - public long getRepeatCount() { - return repeatCount; - } - - public void setRepeatCount(final long repeatCount) { - this.repeatCount = repeatCount; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntity.kt b/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntity.kt new file mode 100644 index 00000000000..db41e141c5d --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntity.kt @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: 2018-2022 NewPipe contributors + * SPDX-FileCopyrightText: 2025 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package org.schabi.newpipe.database.history.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.ForeignKey.Companion.CASCADE +import androidx.room.Index +import org.schabi.newpipe.database.history.model.StreamHistoryEntity.Companion.JOIN_STREAM_ID +import org.schabi.newpipe.database.history.model.StreamHistoryEntity.Companion.STREAM_ACCESS_DATE +import org.schabi.newpipe.database.history.model.StreamHistoryEntity.Companion.STREAM_HISTORY_TABLE +import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID +import java.time.OffsetDateTime + +/** + * @param streamUid the stream id this history item will refer to + * @param accessDate the last time the stream was accessed + * @param repeatCount the total number of views this stream received + */ +@Entity( + tableName = STREAM_HISTORY_TABLE, + primaryKeys = [JOIN_STREAM_ID, STREAM_ACCESS_DATE], + indices = [Index(value = [JOIN_STREAM_ID])], + foreignKeys = [ + ForeignKey( + entity = StreamEntity::class, + parentColumns = arrayOf(STREAM_ID), + childColumns = arrayOf(JOIN_STREAM_ID), + onDelete = CASCADE, + onUpdate = CASCADE + ) + ] +) +data class StreamHistoryEntity( + @ColumnInfo(name = JOIN_STREAM_ID) + val streamUid: Long, + + @ColumnInfo(name = STREAM_ACCESS_DATE) + var accessDate: OffsetDateTime, + + @ColumnInfo(name = STREAM_REPEAT_COUNT) + var repeatCount: Long +) { + companion object { + const val STREAM_HISTORY_TABLE: String = "stream_history" + const val STREAM_ACCESS_DATE: String = "access_date" + const val JOIN_STREAM_ID: String = "stream_id" + const val STREAM_REPEAT_COUNT: String = "repeat_count" + } +} 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 deleted file mode 100644 index 3be85e6e1cb..00000000000 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.java +++ /dev/null @@ -1,29 +0,0 @@ -package org.schabi.newpipe.database.playlist; - -import androidx.room.ColumnInfo; - -/** - * This class adds a field to {@link PlaylistMetadataEntry} that contains an integer representing - * how many times a specific stream is already contained inside a local playlist. Used to be able - * to grey out playlists which already contain the current stream in the playlist append dialog. - * @see org.schabi.newpipe.local.playlist.LocalPlaylistManager#getPlaylistDuplicates(String) - */ -public class PlaylistDuplicatesEntry extends PlaylistMetadataEntry { - public static final String PLAYLIST_TIMES_STREAM_IS_CONTAINED = "timesStreamIsContained"; - @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, isThumbnailPermanent, thumbnailStreamId, displayIndex, - streamCount); - this.timesStreamIsContained = timesStreamIsContained; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.kt new file mode 100644 index 00000000000..84972a89e37 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.kt @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: 2023-2024 NewPipe contributors + * SPDX-FileCopyrightText: 2025 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package org.schabi.newpipe.database.playlist + +import androidx.room.ColumnInfo +import org.schabi.newpipe.database.playlist.model.PlaylistEntity + +/** + * This class adds a field to [PlaylistMetadataEntry] that contains an integer representing + * how many times a specific stream is already contained inside a local playlist. Used to be able + * to grey out playlists which already contain the current stream in the playlist append dialog. + * @see org.schabi.newpipe.local.playlist.LocalPlaylistManager.getPlaylistDuplicates + */ +data class PlaylistDuplicatesEntry( + @ColumnInfo(name = PlaylistEntity.PLAYLIST_ID) + override val uid: Long, + + @ColumnInfo(name = PlaylistEntity.PLAYLIST_THUMBNAIL_URL) + override val thumbnailUrl: String?, + + @ColumnInfo(name = PlaylistEntity.PLAYLIST_THUMBNAIL_PERMANENT) + override val isThumbnailPermanent: Boolean?, + + @ColumnInfo(name = PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID) + override val thumbnailStreamId: Long?, + + @ColumnInfo(name = PlaylistEntity.PLAYLIST_DISPLAY_INDEX) + override var displayIndex: Long?, + + @ColumnInfo(name = PLAYLIST_STREAM_COUNT) + override val streamCount: Long, + + @ColumnInfo(name = PlaylistEntity.PLAYLIST_NAME) + override val orderingName: String?, + + @ColumnInfo(name = PLAYLIST_TIMES_STREAM_IS_CONTAINED) + val timesStreamIsContained: Long +) : PlaylistMetadataEntry( + uid = uid, + orderingName = orderingName, + thumbnailUrl = thumbnailUrl, + isThumbnailPermanent = isThumbnailPermanent, + thumbnailStreamId = thumbnailStreamId, + displayIndex = displayIndex, + streamCount = streamCount +) { + companion object { + const val PLAYLIST_TIMES_STREAM_IS_CONTAINED: String = "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 deleted file mode 100644 index 91f4622e99a..00000000000 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.java +++ /dev/null @@ -1,18 +0,0 @@ -package org.schabi.newpipe.database.playlist; - -import androidx.annotation.Nullable; - -import org.schabi.newpipe.database.LocalItem; - -public interface PlaylistLocalItem extends LocalItem { - String getOrderingName(); - - long getDisplayIndex(); - - long getUid(); - - void setDisplayIndex(long displayIndex); - - @Nullable - String getThumbnailUrl(); -} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.kt new file mode 100644 index 00000000000..4f2f79aa05b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.kt @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: 2018-2025 NewPipe contributors + * SPDX-FileCopyrightText: 2025 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package org.schabi.newpipe.database.playlist + +import org.schabi.newpipe.database.LocalItem + +interface PlaylistLocalItem : LocalItem { + val orderingName: String? + val displayIndex: Long? + val uid: Long + val thumbnailUrl: String? +} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.java b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.java deleted file mode 100644 index 8fbadb02052..00000000000 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.java +++ /dev/null @@ -1,82 +0,0 @@ -package org.schabi.newpipe.database.playlist; - -import androidx.room.ColumnInfo; - -import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_DISPLAY_INDEX; -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_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 androidx.annotation.Nullable; - -public class PlaylistMetadataEntry implements PlaylistLocalItem { - public static final String PLAYLIST_STREAM_COUNT = "streamCount"; - - @ColumnInfo(name = PLAYLIST_ID) - private final long uid; - @ColumnInfo(name = PLAYLIST_NAME) - public final String name; - @ColumnInfo(name = PLAYLIST_THUMBNAIL_PERMANENT) - private final boolean isThumbnailPermanent; - @ColumnInfo(name = PLAYLIST_THUMBNAIL_STREAM_ID) - private final long thumbnailStreamId; - @ColumnInfo(name = PLAYLIST_THUMBNAIL_URL) - public final String thumbnailUrl; - @ColumnInfo(name = PLAYLIST_DISPLAY_INDEX) - private long displayIndex; - @ColumnInfo(name = PLAYLIST_STREAM_COUNT) - public final long streamCount; - - public PlaylistMetadataEntry(final long uid, final String name, final String thumbnailUrl, - final boolean isThumbnailPermanent, final long thumbnailStreamId, - final long displayIndex, final long streamCount) { - this.uid = uid; - this.name = name; - this.thumbnailUrl = thumbnailUrl; - this.isThumbnailPermanent = isThumbnailPermanent; - this.thumbnailStreamId = thumbnailStreamId; - this.displayIndex = displayIndex; - this.streamCount = streamCount; - } - - @Override - public LocalItemType getLocalItemType() { - return LocalItemType.PLAYLIST_LOCAL_ITEM; - } - - @Override - public String getOrderingName() { - return name; - } - - public boolean isThumbnailPermanent() { - return isThumbnailPermanent; - } - - public long getThumbnailStreamId() { - return thumbnailStreamId; - } - - @Override - public long getDisplayIndex() { - return displayIndex; - } - - @Override - public long getUid() { - return uid; - } - - @Override - public void setDisplayIndex(final long displayIndex) { - this.displayIndex = displayIndex; - } - - @Nullable - @Override - public String getThumbnailUrl() { - return thumbnailUrl; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.kt new file mode 100644 index 00000000000..9b62c1380f9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.kt @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: 2018-2025 NewPipe contributors + * SPDX-FileCopyrightText: 2025 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package org.schabi.newpipe.database.playlist + +import androidx.room.ColumnInfo +import org.schabi.newpipe.database.LocalItem.LocalItemType +import org.schabi.newpipe.database.playlist.model.PlaylistEntity + +open class PlaylistMetadataEntry( + @ColumnInfo(name = PlaylistEntity.PLAYLIST_ID) + override val uid: Long, + + @ColumnInfo(name = PlaylistEntity.PLAYLIST_NAME) + override val orderingName: String?, + + @ColumnInfo(name = PlaylistEntity.PLAYLIST_THUMBNAIL_URL) + override val thumbnailUrl: String?, + + @ColumnInfo(name = PlaylistEntity.PLAYLIST_DISPLAY_INDEX) + override var displayIndex: Long?, + + @ColumnInfo(name = PlaylistEntity.PLAYLIST_THUMBNAIL_PERMANENT) + open val isThumbnailPermanent: Boolean?, + + @ColumnInfo(name = PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID) + open val thumbnailStreamId: Long?, + + @ColumnInfo(name = PLAYLIST_STREAM_COUNT) + open val streamCount: Long +) : PlaylistLocalItem { + + override val localItemType: LocalItemType + get() = LocalItemType.PLAYLIST_LOCAL_ITEM + + companion object { + const val PLAYLIST_STREAM_COUNT: String = "streamCount" + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt index 1d74c6d31dc..90fdee2d339 100644 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt @@ -1,3 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2020-2023 NewPipe contributors + * SPDX-FileCopyrightText: 2025 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + package org.schabi.newpipe.database.playlist import androidx.room.ColumnInfo @@ -23,18 +29,21 @@ data class PlaylistStreamEntry( val joinIndex: Int ) : LocalItem { + override val localItemType: LocalItem.LocalItemType + get() = LocalItem.LocalItemType.PLAYLIST_STREAM_ITEM + @Throws(IllegalArgumentException::class) fun toStreamInfoItem(): StreamInfoItem { - val item = StreamInfoItem(streamEntity.serviceId, streamEntity.url, streamEntity.title, streamEntity.streamType) - item.duration = streamEntity.duration - item.uploaderName = streamEntity.uploader - item.uploaderUrl = streamEntity.uploaderUrl - item.thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl) - - return item - } - - override fun getLocalItemType(): LocalItem.LocalItemType { - return LocalItem.LocalItemType.PLAYLIST_STREAM_ITEM + return StreamInfoItem( + streamEntity.serviceId, + streamEntity.url, + streamEntity.title, + streamEntity.streamType + ).apply { + duration = streamEntity.duration + uploaderName = streamEntity.uploader + uploaderUrl = streamEntity.uploaderUrl + thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl) + } } } diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.java b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.java deleted file mode 100644 index d8071e0af3a..00000000000 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.java +++ /dev/null @@ -1,53 +0,0 @@ -package org.schabi.newpipe.database.playlist.dao; - -import androidx.room.Dao; -import androidx.room.Query; -import androidx.room.Transaction; - -import org.schabi.newpipe.database.BasicDAO; -import org.schabi.newpipe.database.playlist.model.PlaylistEntity; - -import java.util.List; - -import io.reactivex.rxjava3.core.Flowable; - -import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_ID; -import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_TABLE; - -@Dao -public interface PlaylistDAO extends BasicDAO { - @Override - @Query("SELECT * FROM " + PLAYLIST_TABLE) - Flowable> getAll(); - - @Override - @Query("DELETE FROM " + PLAYLIST_TABLE) - int deleteAll(); - - @Override - default Flowable> listByService(final int serviceId) { - throw new UnsupportedOperationException(); - } - - @Query("SELECT * FROM " + PLAYLIST_TABLE + " WHERE " + PLAYLIST_ID + " = :playlistId") - Flowable> getPlaylist(long playlistId); - - @Query("DELETE FROM " + PLAYLIST_TABLE + " WHERE " + PLAYLIST_ID + " = :playlistId") - int deletePlaylist(long playlistId); - - @Query("SELECT COUNT(*) FROM " + PLAYLIST_TABLE) - Flowable getCount(); - - @Transaction - default long upsertPlaylist(final PlaylistEntity playlist) { - final long playlistId = playlist.getUid(); - - if (playlistId == -1) { - // This situation is probably impossible. - return insert(playlist); - } else { - update(playlist); - return playlistId; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.kt new file mode 100644 index 00000000000..9c2dd89a8c9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.kt @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: 2018-2022 NewPipe contributors + * SPDX-FileCopyrightText: 2025 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package org.schabi.newpipe.database.playlist.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Transaction +import io.reactivex.rxjava3.core.Flowable +import org.schabi.newpipe.database.BasicDAO +import org.schabi.newpipe.database.playlist.model.PlaylistEntity + +@Dao +interface PlaylistDAO : BasicDAO { + + @Query("SELECT * FROM playlists") + override fun getAll(): Flowable> + + @Query("DELETE FROM playlists") + override fun deleteAll(): Int + + override fun listByService(serviceId: Int): Flowable> { + throw UnsupportedOperationException() + } + + @Query("SELECT * FROM playlists WHERE uid = :playlistId") + fun getPlaylist(playlistId: Long): Flowable> + + @Query("DELETE FROM playlists WHERE uid = :playlistId") + fun deletePlaylist(playlistId: Long): Int + + @get:Query("SELECT COUNT(*) FROM playlists") + val count: Flowable + + @Transaction + fun upsertPlaylist(playlist: PlaylistEntity): Long { + if (playlist.uid == -1L) { + // This situation is probably impossible. + return insert(playlist) + } else { + update(playlist) + return playlist.uid + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.java b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.java deleted file mode 100644 index ef77d5ade73..00000000000 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.java +++ /dev/null @@ -1,68 +0,0 @@ -package org.schabi.newpipe.database.playlist.dao; - -import androidx.room.Dao; -import androidx.room.Query; -import androidx.room.Transaction; - -import org.schabi.newpipe.database.BasicDAO; -import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; - -import java.util.List; - -import io.reactivex.rxjava3.core.Flowable; - -import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_DISPLAY_INDEX; -import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_ID; -import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_SERVICE_ID; -import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_TABLE; -import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_URL; - -@Dao -public interface PlaylistRemoteDAO extends BasicDAO { - @Override - @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE) - Flowable> getAll(); - - @Override - @Query("DELETE FROM " + REMOTE_PLAYLIST_TABLE) - int deleteAll(); - - @Override - @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE - + " WHERE " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId") - Flowable> 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") - Long getPlaylistIdInternal(long serviceId, String url); - - @Transaction - default long upsert(final PlaylistRemoteEntity playlist) { - final Long playlistId = getPlaylistIdInternal(playlist.getServiceId(), playlist.getUrl()); - - if (playlistId == null) { - return insert(playlist); - } else { - playlist.setUid(playlistId); - update(playlist); - return playlistId; - } - } - - @Query("DELETE FROM " + REMOTE_PLAYLIST_TABLE - + " WHERE " + REMOTE_PLAYLIST_ID + " = :playlistId") - int deletePlaylist(long playlistId); -} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.kt new file mode 100644 index 00000000000..8e0b80c3b69 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.kt @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: 2018-2025 NewPipe contributors + * SPDX-FileCopyrightText: 2025 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package org.schabi.newpipe.database.playlist.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Transaction +import io.reactivex.rxjava3.core.Flowable +import org.schabi.newpipe.database.BasicDAO +import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity + +@Dao +interface PlaylistRemoteDAO : BasicDAO { + + @Query("SELECT * FROM remote_playlists") + override fun getAll(): Flowable> + + @Query("DELETE FROM remote_playlists") + override fun deleteAll(): Int + + @Query("SELECT * FROM remote_playlists WHERE service_id = :serviceId") + override fun listByService(serviceId: Int): Flowable> + + @Query("SELECT * FROM remote_playlists WHERE uid = :playlistId") + fun getPlaylist(playlistId: Long): Flowable + + @Query("SELECT * FROM remote_playlists WHERE url = :url AND uid = :serviceId") + fun getPlaylist(serviceId: Long, url: String?): Flowable> + + @get:Query("SELECT * FROM remote_playlists ORDER BY display_index") + val playlists: Flowable> + + @Query("SELECT uid FROM remote_playlists WHERE url = :url AND service_id = :serviceId") + fun getPlaylistIdInternal(serviceId: Long, url: String?): Long? + + @Transaction + fun upsert(playlist: PlaylistRemoteEntity): Long { + val playlistId = getPlaylistIdInternal(playlist.serviceId.toLong(), playlist.url) + + if (playlistId == null) { + return insert(playlist) + } else { + playlist.uid = playlistId + update(playlist) + return playlistId + } + } + + @Query("DELETE FROM remote_playlists WHERE uid = :playlistId") + fun deletePlaylist(playlistId: Long): Int +} 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 deleted file mode 100644 index 6b77166eae9..00000000000 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.java +++ /dev/null @@ -1,159 +0,0 @@ -package org.schabi.newpipe.database.playlist.dao; - -import androidx.room.Dao; -import androidx.room.Query; -import androidx.room.RewriteQueriesToDropUnusedColumns; -import androidx.room.Transaction; - -import org.schabi.newpipe.database.BasicDAO; -import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry; -import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; -import org.schabi.newpipe.database.playlist.PlaylistStreamEntry; -import org.schabi.newpipe.database.playlist.model.PlaylistEntity; -import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity; - -import java.util.List; - -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; -import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_PLAYLIST_ID; -import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_STREAM_ID; -import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.PLAYLIST_STREAM_JOIN_TABLE; -import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_ID; -import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_TABLE; -import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_THUMBNAIL_URL; -import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_URL; -import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID_ALIAS; -import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS; -import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE; - -@Dao -public interface PlaylistStreamDAO extends BasicDAO { - @Override - @Query("SELECT * FROM " + PLAYLIST_STREAM_JOIN_TABLE) - Flowable> getAll(); - - @Override - @Query("DELETE FROM " + PLAYLIST_STREAM_JOIN_TABLE) - int deleteAll(); - - @Override - default Flowable> listByService(final int serviceId) { - throw new UnsupportedOperationException(); - } - - @Query("DELETE FROM " + PLAYLIST_STREAM_JOIN_TABLE - + " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId") - void deleteBatch(long playlistId); - - @Query("SELECT COALESCE(MAX(" + JOIN_INDEX + "), -1)" - + " FROM " + PLAYLIST_STREAM_JOIN_TABLE - + " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId") - Flowable getMaximumIndexOf(long playlistId); - - @Query("SELECT CASE WHEN COUNT(*) != 0 then " + STREAM_ID - + " ELSE " + PlaylistEntity.DEFAULT_THUMBNAIL_ID + " END" - + " FROM " + STREAM_TABLE - + " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE - + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID - + " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId " - + " LIMIT 1" - ) - Flowable getAutomaticThumbnailStreamId(long playlistId); - - @RewriteQueriesToDropUnusedColumns - @Transaction - @Query("SELECT * FROM " + STREAM_TABLE + " INNER JOIN " - // get ids of streams of the given playlist - + "(SELECT " + JOIN_STREAM_ID + "," + JOIN_INDEX - + " FROM " + PLAYLIST_STREAM_JOIN_TABLE - + " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId)" - - // then merge with the stream metadata - + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID - - + " LEFT JOIN " - + "(SELECT " + JOIN_STREAM_ID + " AS " + JOIN_STREAM_ID_ALIAS + ", " - + STREAM_PROGRESS_MILLIS - + " FROM " + STREAM_STATE_TABLE + " )" - + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID_ALIAS - - + " ORDER BY " + JOIN_INDEX + " ASC") - Flowable> getOrderedStreamsOf(long playlistId); - - @Transaction - @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 + "'" - + " ELSE (SELECT " + STREAM_THUMBNAIL_URL - + " FROM " + STREAM_TABLE - + " WHERE " + STREAM_TABLE + "." + STREAM_ID + " = " + PLAYLIST_THUMBNAIL_STREAM_ID - + " ) END AS " + PLAYLIST_THUMBNAIL_URL + ", " - - + "COALESCE(COUNT(" + JOIN_PLAYLIST_ID + "), 0) AS " + PLAYLIST_STREAM_COUNT - + " FROM " + PLAYLIST_TABLE - + " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE - + " ON " + PLAYLIST_TABLE + "." + PLAYLIST_ID + " = " + JOIN_PLAYLIST_ID - + " GROUP BY " + PLAYLIST_ID - + " ORDER BY " + PLAYLIST_DISPLAY_INDEX) - Flowable> getPlaylistMetadata(); - - @RewriteQueriesToDropUnusedColumns - @Transaction - @Query("SELECT *, MIN(" + JOIN_INDEX + ")" - + " FROM " + STREAM_TABLE + " INNER JOIN" - + " (SELECT " + JOIN_STREAM_ID + "," + JOIN_INDEX - + " FROM " + PLAYLIST_STREAM_JOIN_TABLE - + " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId)" - + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID - + " LEFT JOIN " - + "(SELECT " + JOIN_STREAM_ID + " AS " + JOIN_STREAM_ID_ALIAS + ", " - + STREAM_PROGRESS_MILLIS - + " FROM " + STREAM_STATE_TABLE + " )" - + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID_ALIAS - + " GROUP BY " + STREAM_ID - + " ORDER BY MIN(" + JOIN_INDEX + ") ASC") - Flowable> getStreamsWithoutDuplicates(long playlistId); - - @Transaction - @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 + "'" - + " ELSE (SELECT " + STREAM_THUMBNAIL_URL - + " FROM " + STREAM_TABLE - + " WHERE " + STREAM_TABLE + "." + STREAM_ID + " = " + PLAYLIST_THUMBNAIL_STREAM_ID - + " ) END AS " + PLAYLIST_THUMBNAIL_URL + ", " - - + "COALESCE(COUNT(" + JOIN_PLAYLIST_ID + "), 0) AS " + PLAYLIST_STREAM_COUNT + ", " - + "COALESCE(SUM(" + STREAM_URL + " = :streamUrl), 0) AS " - + PLAYLIST_TIMES_STREAM_IS_CONTAINED - - + " FROM " + PLAYLIST_TABLE - + " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE - + " ON " + PLAYLIST_TABLE + "." + PLAYLIST_ID + " = " + JOIN_PLAYLIST_ID - - + " LEFT JOIN " + STREAM_TABLE - + " ON " + STREAM_TABLE + "." + STREAM_ID + " = " + JOIN_STREAM_ID - + " AND :streamUrl = :streamUrl" - - + " GROUP BY " + JOIN_PLAYLIST_ID - + " ORDER BY " + PLAYLIST_DISPLAY_INDEX + ", " + PLAYLIST_NAME) - Flowable> getPlaylistDuplicatesMetadata(String streamUrl); -} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.kt new file mode 100644 index 00000000000..8bf26d754fa --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.kt @@ -0,0 +1,126 @@ +/* + * SPDX-FileCopyrightText: 2018-2024 NewPipe contributors + * SPDX-FileCopyrightText: 2025 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package org.schabi.newpipe.database.playlist.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.RewriteQueriesToDropUnusedColumns +import androidx.room.Transaction +import io.reactivex.rxjava3.core.Flowable +import org.schabi.newpipe.database.BasicDAO +import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry +import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry +import org.schabi.newpipe.database.playlist.PlaylistStreamEntry +import org.schabi.newpipe.database.playlist.model.PlaylistEntity.Companion.DEFAULT_THUMBNAIL_ID +import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity + +@Dao +interface PlaylistStreamDAO : BasicDAO { + + @Query("SELECT * FROM playlist_stream_join") + override fun getAll(): Flowable> + + @Query("DELETE FROM playlist_stream_join") + override fun deleteAll(): Int + + override fun listByService(serviceId: Int): Flowable> { + throw UnsupportedOperationException() + } + + @Query("DELETE FROM playlist_stream_join WHERE playlist_id = :playlistId") + fun deleteBatch(playlistId: Long) + + @Query("SELECT COALESCE(MAX(join_index), -1) FROM playlist_stream_join WHERE playlist_id = :playlistId") + fun getMaximumIndexOf(playlistId: Long): Flowable + + @Query( + """ + SELECT CASE WHEN COUNT(*) != 0 then stream_id ELSE $DEFAULT_THUMBNAIL_ID END + FROM streams + + LEFT JOIN playlist_stream_join + ON uid = stream_id + + WHERE playlist_id = :playlistId LIMIT 1 + """ + ) + fun getAutomaticThumbnailStreamId(playlistId: Long): Flowable + + // get ids of streams of the given playlist then merge with the stream metadata + @RewriteQueriesToDropUnusedColumns + @Transaction + @Query( + """ + SELECT * FROM streams + + INNER JOIN (SELECT stream_id, join_index FROM playlist_stream_join WHERE playlist_id = :playlistId) + ON uid = stream_id + + LEFT JOIN (SELECT stream_id AS stream_id_alias, progress_time FROM stream_state ) + ON uid = stream_id_alias + + ORDER BY join_index ASC + """ + ) + fun getOrderedStreamsOf(playlistId: Long): Flowable> + + @Transaction + @Query( + """ + SELECT uid, name, is_thumbnail_permanent, thumbnail_stream_id, display_index, + (SELECT thumbnail_url FROM streams WHERE streams.uid = thumbnail_stream_id) AS thumbnail_url, + + COALESCE(COUNT(playlist_id), 0) AS streamCount FROM playlists + + LEFT JOIN playlist_stream_join + ON playlists.uid = playlist_id + + GROUP BY uid + ORDER BY display_index + """ + ) + fun getPlaylistMetadata(): Flowable> + + @RewriteQueriesToDropUnusedColumns + @Transaction + @Query( + """ + SELECT *, MIN(join_index) FROM streams + + INNER JOIN (SELECT stream_id, join_index FROM playlist_stream_join WHERE playlist_id = :playlistId) + ON uid = stream_id + + LEFT JOIN (SELECT stream_id AS stream_id_alias, progress_time FROM stream_state ) + ON uid = stream_id_alias + + GROUP BY uid + ORDER BY MIN(join_index) ASC + """ + ) + fun getStreamsWithoutDuplicates(playlistId: Long): Flowable> + + @Transaction + @Query( + """ + SELECT playlists.uid, name, is_thumbnail_permanent, thumbnail_stream_id, display_index, + (SELECT thumbnail_url FROM streams WHERE streams.uid = thumbnail_stream_id) AS thumbnail_url, + + COALESCE(COUNT(playlist_id), 0) AS streamCount, + COALESCE(SUM(url = :streamUrl), 0) AS timesStreamIsContained FROM playlists + + LEFT JOIN playlist_stream_join + ON playlists.uid = playlist_id + + LEFT JOIN streams + ON streams.uid = stream_id AND :streamUrl = :streamUrl + + GROUP BY playlist_id + ORDER BY display_index, name + """ + ) + fun getPlaylistDuplicatesMetadata(streamUrl: String): Flowable> +} 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 deleted file mode 100644 index e0c1a06b79b..00000000000 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.java +++ /dev/null @@ -1,100 +0,0 @@ -package org.schabi.newpipe.database.playlist.model; - -import androidx.room.ColumnInfo; -import androidx.room.Entity; -import androidx.room.Ignore; -import androidx.room.PrimaryKey; - -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) -public class PlaylistEntity { - - public static final String DEFAULT_THUMBNAIL = "drawable://" - + R.drawable.placeholder_thumbnail_playlist; - public static final long DEFAULT_THUMBNAIL_ID = -1; - - public static final String PLAYLIST_TABLE = "playlists"; - 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"; - - @PrimaryKey(autoGenerate = true) - @ColumnInfo(name = PLAYLIST_ID) - private long uid = 0; - - @ColumnInfo(name = PLAYLIST_NAME) - private String name; - - @ColumnInfo(name = PLAYLIST_THUMBNAIL_PERMANENT) - private boolean isThumbnailPermanent; - - @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 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() { - return uid; - } - - public void setUid(final long uid) { - this.uid = uid; - } - - public String getName() { - return name; - } - - public void setName(final String name) { - this.name = name; - } - - public long getThumbnailStreamId() { - return thumbnailStreamId; - } - - public void setThumbnailStreamId(final long thumbnailStreamId) { - this.thumbnailStreamId = thumbnailStreamId; - } - - public boolean getIsThumbnailPermanent() { - return isThumbnailPermanent; - } - - public void setIsThumbnailPermanent(final boolean isThumbnailSet) { - 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/PlaylistEntity.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.kt new file mode 100644 index 00000000000..4ea4eb3a78b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.kt @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: 2018-2024 NewPipe contributors + * SPDX-FileCopyrightText: 2025 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package org.schabi.newpipe.database.playlist.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.PrimaryKey +import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry + +@Entity(tableName = PlaylistEntity.Companion.PLAYLIST_TABLE) +data class PlaylistEntity @JvmOverloads constructor( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = PLAYLIST_ID) + var uid: Long = 0, + + @ColumnInfo(name = PLAYLIST_NAME) + var name: String?, + + @ColumnInfo(name = PLAYLIST_THUMBNAIL_PERMANENT) + var isThumbnailPermanent: Boolean, + + @ColumnInfo(name = PLAYLIST_THUMBNAIL_STREAM_ID) + var thumbnailStreamId: Long, + + @ColumnInfo(name = PLAYLIST_DISPLAY_INDEX) + var displayIndex: Long +) { + + @Ignore + constructor(item: PlaylistMetadataEntry) : this( + uid = item.uid, + name = item.orderingName, + isThumbnailPermanent = item.isThumbnailPermanent!!, + thumbnailStreamId = item.thumbnailStreamId!!, + displayIndex = item.displayIndex!!, + ) + + companion object { + const val DEFAULT_THUMBNAIL_ID = -1L + + const val PLAYLIST_TABLE = "playlists" + const val PLAYLIST_ID = "uid" + const val PLAYLIST_NAME = "name" + const val PLAYLIST_THUMBNAIL_URL = "thumbnail_url" + const val PLAYLIST_DISPLAY_INDEX = "display_index" + const val PLAYLIST_THUMBNAIL_PERMANENT = "is_thumbnail_permanent" + const val PLAYLIST_THUMBNAIL_STREAM_ID = "thumbnail_stream_id" + } +} 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 deleted file mode 100644 index 0b0e3605ed3..00000000000 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.java +++ /dev/null @@ -1,191 +0,0 @@ -package org.schabi.newpipe.database.playlist.model; - -import android.text.TextUtils; - -import androidx.annotation.Nullable; -import androidx.room.ColumnInfo; -import androidx.room.Entity; -import androidx.room.Ignore; -import androidx.room.Index; -import androidx.room.PrimaryKey; - -import org.schabi.newpipe.database.playlist.PlaylistLocalItem; -import org.schabi.newpipe.extractor.playlist.PlaylistInfo; -import org.schabi.newpipe.util.Constants; -import org.schabi.newpipe.util.image.ImageStrategy; - -import static org.schabi.newpipe.database.LocalItem.LocalItemType.PLAYLIST_REMOTE_ITEM; -import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_NAME; -import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_SERVICE_ID; -import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_TABLE; -import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_URL; - -@Entity(tableName = REMOTE_PLAYLIST_TABLE, - indices = { - @Index(value = {REMOTE_PLAYLIST_SERVICE_ID, REMOTE_PLAYLIST_URL}, unique = true) - }) -public class PlaylistRemoteEntity implements PlaylistLocalItem { - public static final String REMOTE_PLAYLIST_TABLE = "remote_playlists"; - public static final String REMOTE_PLAYLIST_ID = "uid"; - public static final String REMOTE_PLAYLIST_SERVICE_ID = "service_id"; - public static final String REMOTE_PLAYLIST_NAME = "name"; - 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) - @ColumnInfo(name = REMOTE_PLAYLIST_ID) - private long uid = 0; - - @ColumnInfo(name = REMOTE_PLAYLIST_SERVICE_ID) - private int serviceId = Constants.NO_SERVICE_ID; - - @ColumnInfo(name = REMOTE_PLAYLIST_NAME) - private String name; - - @ColumnInfo(name = REMOTE_PLAYLIST_URL) - private String url; - - @ColumnInfo(name = REMOTE_PLAYLIST_THUMBNAIL_URL) - private String thumbnailUrl; - - @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; - - public PlaylistRemoteEntity(final int serviceId, final String name, final String url, - final String thumbnailUrl, final String uploader, - final Long streamCount) { - this.serviceId = serviceId; - this.name = name; - this.url = url; - this.thumbnailUrl = thumbnailUrl; - this.uploader = uploader; - 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(), - // use uploader avatar when no thumbnail is available - ImageStrategy.imageListToDbUrl(info.getThumbnails().isEmpty() - ? info.getUploaderAvatars() : info.getThumbnails()), - info.getUploaderName(), info.getStreamCount()); - } - - @Ignore - public boolean isIdenticalTo(final PlaylistInfo info) { - /* - * Returns boolean comparing the online playlist and the local copy. - * (False if info changed such as playlist name or track count) - */ - return getServiceId() == info.getServiceId() - && getStreamCount() == info.getStreamCount() - && TextUtils.equals(getName(), info.getName()) - && TextUtils.equals(getUrl(), info.getUrl()) - // we want to update the local playlist data even when either the remote thumbnail - // URL changes, or the preferred image quality setting is changed by the user - && TextUtils.equals(getThumbnailUrl(), - ImageStrategy.imageListToDbUrl(info.getThumbnails())) - && TextUtils.equals(getUploader(), info.getUploaderName()); - } - - @Override - public long getUid() { - return uid; - } - - public void setUid(final long uid) { - this.uid = uid; - } - - public int getServiceId() { - return serviceId; - } - - public void setServiceId(final int serviceId) { - this.serviceId = serviceId; - } - - public String getName() { - return name; - } - - public void setName(final String name) { - this.name = name; - } - - @Nullable - @Override - public String getThumbnailUrl() { - return thumbnailUrl; - } - - public void setThumbnailUrl(final String thumbnailUrl) { - this.thumbnailUrl = thumbnailUrl; - } - - public String getUrl() { - return url; - } - - public void setUrl(final String url) { - this.url = url; - } - - public String getUploader() { - return uploader; - } - - public void setUploader(final String uploader) { - this.uploader = uploader; - } - - @Override - public long getDisplayIndex() { - return displayIndex; - } - - @Override - public void setDisplayIndex(final long displayIndex) { - this.displayIndex = displayIndex; - } - - public Long getStreamCount() { - return streamCount; - } - - public void setStreamCount(final Long streamCount) { - this.streamCount = streamCount; - } - - @Override - public LocalItemType getLocalItemType() { - return PLAYLIST_REMOTE_ITEM; - } - - @Override - public String getOrderingName() { - return name; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.kt new file mode 100644 index 00000000000..82162e1e4ad --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.kt @@ -0,0 +1,104 @@ +/* + * SPDX-FileCopyrightText: 2018-2025 NewPipe contributors + * SPDX-FileCopyrightText: 2025 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package org.schabi.newpipe.database.playlist.model + +import android.text.TextUtils +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.Index +import androidx.room.PrimaryKey +import org.schabi.newpipe.database.LocalItem.LocalItemType +import org.schabi.newpipe.database.playlist.PlaylistLocalItem +import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_SERVICE_ID +import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_TABLE +import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_URL +import org.schabi.newpipe.extractor.playlist.PlaylistInfo +import org.schabi.newpipe.util.NO_SERVICE_ID +import org.schabi.newpipe.util.image.ImageStrategy + +@Entity( + tableName = REMOTE_PLAYLIST_TABLE, + indices = [ + Index( + value = [REMOTE_PLAYLIST_SERVICE_ID, REMOTE_PLAYLIST_URL], + unique = true + ) + ] +) +data class PlaylistRemoteEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = REMOTE_PLAYLIST_ID) + override var uid: Long = 0, + + @ColumnInfo(name = REMOTE_PLAYLIST_SERVICE_ID) + val serviceId: Int = NO_SERVICE_ID, + + @ColumnInfo(name = REMOTE_PLAYLIST_NAME) + override val orderingName: String?, + + @ColumnInfo(name = REMOTE_PLAYLIST_URL) + val url: String?, + + @ColumnInfo(name = REMOTE_PLAYLIST_THUMBNAIL_URL) + override val thumbnailUrl: String?, + + @ColumnInfo(name = REMOTE_PLAYLIST_UPLOADER_NAME) + val uploader: String?, + + @ColumnInfo(name = REMOTE_PLAYLIST_DISPLAY_INDEX) + override var displayIndex: Long = -1, // Make sure the new item is on the top + + @ColumnInfo(name = REMOTE_PLAYLIST_STREAM_COUNT) + val streamCount: Long? +) : PlaylistLocalItem { + + constructor(playlistInfo: PlaylistInfo) : this( + serviceId = playlistInfo.serviceId, + orderingName = playlistInfo.name, + url = playlistInfo.url, + thumbnailUrl = ImageStrategy.imageListToDbUrl( + if (playlistInfo.thumbnails.isEmpty()) { + playlistInfo.uploaderAvatars + } else { + playlistInfo.thumbnails + } + ), + uploader = playlistInfo.uploaderName, + streamCount = playlistInfo.streamCount + ) + + override val localItemType: LocalItemType + get() = LocalItemType.PLAYLIST_REMOTE_ITEM + + /** + * Returns boolean comparing the online playlist and the local copy. + * (False if info changed such as playlist name or track count) + */ + @Ignore + fun isIdenticalTo(info: PlaylistInfo): Boolean { + return this.serviceId == info.serviceId && this.streamCount == info.streamCount && + TextUtils.equals(this.orderingName, info.name) && + TextUtils.equals(this.url, info.url) && + // we want to update the local playlist data even when either the remote thumbnail + // URL changes, or the preferred image quality setting is changed by the user + TextUtils.equals(thumbnailUrl, ImageStrategy.imageListToDbUrl(info.thumbnails)) && + TextUtils.equals(this.uploader, info.uploaderName) + } + + companion object { + const val REMOTE_PLAYLIST_TABLE = "remote_playlists" + const val REMOTE_PLAYLIST_ID = "uid" + const val REMOTE_PLAYLIST_SERVICE_ID = "service_id" + const val REMOTE_PLAYLIST_NAME = "name" + const val REMOTE_PLAYLIST_URL = "url" + const val REMOTE_PLAYLIST_THUMBNAIL_URL = "thumbnail_url" + const val REMOTE_PLAYLIST_UPLOADER_NAME = "uploader" + const val REMOTE_PLAYLIST_DISPLAY_INDEX = "display_index" + const val REMOTE_PLAYLIST_STREAM_COUNT = "stream_count" + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistStreamEntity.java b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistStreamEntity.java deleted file mode 100644 index f3208b6d517..00000000000 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistStreamEntity.java +++ /dev/null @@ -1,76 +0,0 @@ -package org.schabi.newpipe.database.playlist.model; - -import androidx.room.ColumnInfo; -import androidx.room.Entity; -import androidx.room.ForeignKey; -import androidx.room.Index; - -import org.schabi.newpipe.database.stream.model.StreamEntity; - -import static androidx.room.ForeignKey.CASCADE; -import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_INDEX; -import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_PLAYLIST_ID; -import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_STREAM_ID; -import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.PLAYLIST_STREAM_JOIN_TABLE; - -@Entity(tableName = PLAYLIST_STREAM_JOIN_TABLE, - primaryKeys = {JOIN_PLAYLIST_ID, JOIN_INDEX}, - indices = { - @Index(value = {JOIN_PLAYLIST_ID, JOIN_INDEX}, unique = true), - @Index(value = {JOIN_STREAM_ID}) - }, - foreignKeys = { - @ForeignKey(entity = PlaylistEntity.class, - parentColumns = PlaylistEntity.PLAYLIST_ID, - childColumns = JOIN_PLAYLIST_ID, - onDelete = CASCADE, onUpdate = CASCADE, deferred = true), - @ForeignKey(entity = StreamEntity.class, - parentColumns = StreamEntity.STREAM_ID, - childColumns = JOIN_STREAM_ID, - onDelete = CASCADE, onUpdate = CASCADE, deferred = true) - }) -public class PlaylistStreamEntity { - public static final String PLAYLIST_STREAM_JOIN_TABLE = "playlist_stream_join"; - public static final String JOIN_PLAYLIST_ID = "playlist_id"; - public static final String JOIN_STREAM_ID = "stream_id"; - public static final String JOIN_INDEX = "join_index"; - - @ColumnInfo(name = JOIN_PLAYLIST_ID) - private long playlistUid; - - @ColumnInfo(name = JOIN_STREAM_ID) - private long streamUid; - - @ColumnInfo(name = JOIN_INDEX) - private int index; - - public PlaylistStreamEntity(final long playlistUid, final long streamUid, final int index) { - this.playlistUid = playlistUid; - this.streamUid = streamUid; - this.index = index; - } - - public long getPlaylistUid() { - return playlistUid; - } - - public void setPlaylistUid(final long playlistUid) { - this.playlistUid = playlistUid; - } - - public long getStreamUid() { - return streamUid; - } - - public void setStreamUid(final long streamUid) { - this.streamUid = streamUid; - } - - public int getIndex() { - return index; - } - - public void setIndex(final int index) { - this.index = index; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistStreamEntity.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistStreamEntity.kt new file mode 100644 index 00000000000..6ab1b6ac48f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistStreamEntity.kt @@ -0,0 +1,68 @@ +/* + * SPDX-FileCopyrightText: 2018-2020 NewPipe contributors + * SPDX-FileCopyrightText: 2025 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package org.schabi.newpipe.database.playlist.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.ForeignKey.Companion.CASCADE +import androidx.room.Index +import org.schabi.newpipe.database.LocalItem +import org.schabi.newpipe.database.playlist.model.PlaylistEntity.Companion.PLAYLIST_ID +import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.Companion.JOIN_INDEX +import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.Companion.JOIN_PLAYLIST_ID +import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.Companion.JOIN_STREAM_ID +import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.Companion.PLAYLIST_STREAM_JOIN_TABLE +import org.schabi.newpipe.database.stream.model.StreamEntity + +@Entity( + tableName = PLAYLIST_STREAM_JOIN_TABLE, + primaryKeys = [JOIN_PLAYLIST_ID, JOIN_INDEX], + indices = [ + Index(value = [JOIN_PLAYLIST_ID, JOIN_INDEX], unique = true), + Index(value = [JOIN_STREAM_ID]) + ], + foreignKeys = [ + ForeignKey( + entity = PlaylistEntity::class, + parentColumns = arrayOf(PLAYLIST_ID), + childColumns = arrayOf(JOIN_PLAYLIST_ID), + onDelete = CASCADE, + onUpdate = CASCADE, + deferred = true + ), + ForeignKey( + entity = StreamEntity::class, + parentColumns = arrayOf(StreamEntity.STREAM_ID), + childColumns = arrayOf(JOIN_STREAM_ID), + onDelete = CASCADE, + onUpdate = CASCADE, + deferred = true + ) + ] +) +data class PlaylistStreamEntity( + @ColumnInfo(name = JOIN_PLAYLIST_ID) + val playlistUid: Long, + + @ColumnInfo(name = JOIN_STREAM_ID) + val streamUid: Long, + + @ColumnInfo(name = JOIN_INDEX) + val index: Int +) : LocalItem { + + override val localItemType: LocalItem.LocalItemType + get() = LocalItem.LocalItemType.PLAYLIST_STREAM_ITEM + + companion object { + const val PLAYLIST_STREAM_JOIN_TABLE = "playlist_stream_join" + const val JOIN_PLAYLIST_ID = "playlist_id" + const val JOIN_STREAM_ID = "stream_id" + const val JOIN_INDEX = "join_index" + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt b/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt index 1f3654e7ae4..3fa281e457a 100644 --- a/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt +++ b/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt @@ -1,16 +1,23 @@ +/* + * SPDX-FileCopyrightText: 2020-2023 NewPipe contributors + * SPDX-FileCopyrightText: 2025 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + package org.schabi.newpipe.database.stream import androidx.room.ColumnInfo import androidx.room.Embedded +import androidx.room.Ignore import org.schabi.newpipe.database.LocalItem import org.schabi.newpipe.database.history.model.StreamHistoryEntity import org.schabi.newpipe.database.stream.model.StreamEntity -import org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS +import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.STREAM_PROGRESS_MILLIS import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.util.image.ImageStrategy import java.time.OffsetDateTime -class StreamStatisticsEntry( +data class StreamStatisticsEntry( @Embedded val streamEntity: StreamEntity, @@ -26,18 +33,23 @@ class StreamStatisticsEntry( @ColumnInfo(name = STREAM_WATCH_COUNT) val watchCount: Long ) : LocalItem { - fun toStreamInfoItem(): StreamInfoItem { - val item = StreamInfoItem(streamEntity.serviceId, streamEntity.url, streamEntity.title, streamEntity.streamType) - item.duration = streamEntity.duration - item.uploaderName = streamEntity.uploader - item.uploaderUrl = streamEntity.uploaderUrl - item.thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl) - return item - } + override val localItemType: LocalItem.LocalItemType + get() = LocalItem.LocalItemType.STATISTIC_STREAM_ITEM - override fun getLocalItemType(): LocalItem.LocalItemType { - return LocalItem.LocalItemType.STATISTIC_STREAM_ITEM + @Ignore + fun toStreamInfoItem(): StreamInfoItem { + return StreamInfoItem( + streamEntity.serviceId, + streamEntity.url, + streamEntity.title, + streamEntity.streamType + ).apply { + duration = streamEntity.duration + uploaderName = streamEntity.uploader + uploaderUrl = streamEntity.uploaderUrl + thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl) + } } companion object { diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.java b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.java deleted file mode 100644 index 06371248d62..00000000000 --- a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.java +++ /dev/null @@ -1,48 +0,0 @@ -package org.schabi.newpipe.database.stream.dao; - -import androidx.room.Dao; -import androidx.room.Insert; -import androidx.room.OnConflictStrategy; -import androidx.room.Query; -import androidx.room.Transaction; - -import org.schabi.newpipe.database.BasicDAO; -import org.schabi.newpipe.database.stream.model.StreamStateEntity; - -import java.util.List; - -import io.reactivex.rxjava3.core.Flowable; - -import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID; -import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE; - -@Dao -public interface StreamStateDAO extends BasicDAO { - @Override - @Query("SELECT * FROM " + STREAM_STATE_TABLE) - Flowable> getAll(); - - @Override - @Query("DELETE FROM " + STREAM_STATE_TABLE) - int deleteAll(); - - @Override - default Flowable> listByService(final int serviceId) { - throw new UnsupportedOperationException(); - } - - @Query("SELECT * FROM " + STREAM_STATE_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId") - Flowable> getState(long streamId); - - @Query("DELETE FROM " + STREAM_STATE_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId") - int deleteState(long streamId); - - @Insert(onConflict = OnConflictStrategy.IGNORE) - void silentInsertInternal(StreamStateEntity streamState); - - @Transaction - default long upsert(final StreamStateEntity stream) { - silentInsertInternal(stream); - return update(stream); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.kt b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.kt new file mode 100644 index 00000000000..f3c44f1f264 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.kt @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2018-2021 NewPipe contributors + * SPDX-FileCopyrightText: 2025 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package org.schabi.newpipe.database.stream.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import io.reactivex.rxjava3.core.Flowable +import org.schabi.newpipe.database.BasicDAO +import org.schabi.newpipe.database.stream.model.StreamStateEntity + +@Dao +interface StreamStateDAO : BasicDAO { + + @Query("SELECT * FROM " + StreamStateEntity.STREAM_STATE_TABLE) + override fun getAll(): Flowable> + + @Query("DELETE FROM " + StreamStateEntity.STREAM_STATE_TABLE) + override fun deleteAll(): Int + + override fun listByService(serviceId: Int): Flowable> { + throw UnsupportedOperationException() + } + + @Query("SELECT * FROM " + StreamStateEntity.STREAM_STATE_TABLE + " WHERE " + StreamStateEntity.JOIN_STREAM_ID + " = :streamId") + fun getState(streamId: Long): Flowable> + + @Query("DELETE FROM " + StreamStateEntity.STREAM_STATE_TABLE + " WHERE " + StreamStateEntity.JOIN_STREAM_ID + " = :streamId") + fun deleteState(streamId: Long): Int + + @Insert(onConflict = OnConflictStrategy.Companion.IGNORE) + fun silentInsertInternal(streamState: StreamStateEntity) + + @Transaction + fun upsert(stream: StreamStateEntity): Long { + silentInsertInternal(stream) + return update(stream).toLong() + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.java b/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.java deleted file mode 100644 index 627acea45a4..00000000000 --- a/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.java +++ /dev/null @@ -1,112 +0,0 @@ -package org.schabi.newpipe.database.stream.model; - -import androidx.annotation.Nullable; -import androidx.room.ColumnInfo; -import androidx.room.Entity; -import androidx.room.ForeignKey; - -import java.util.Objects; - -import static androidx.room.ForeignKey.CASCADE; -import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID; -import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE; - -@Entity(tableName = STREAM_STATE_TABLE, - primaryKeys = {JOIN_STREAM_ID}, - foreignKeys = { - @ForeignKey(entity = StreamEntity.class, - parentColumns = StreamEntity.STREAM_ID, - childColumns = JOIN_STREAM_ID, - onDelete = CASCADE, onUpdate = CASCADE) - }) -public class StreamStateEntity { - public static final String STREAM_STATE_TABLE = "stream_state"; - public static final String JOIN_STREAM_ID = "stream_id"; - // This additional field is required for the SQL query because 'stream_id' is used - // for some other joins already - public static final String JOIN_STREAM_ID_ALIAS = "stream_id_alias"; - public static final String STREAM_PROGRESS_MILLIS = "progress_time"; - - /** - * Playback state will not be saved, if playback time is less than this threshold (5000ms = 5s). - */ - public static final long PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS = 5000; - - /** - * Stream will be considered finished if the playback time left exceeds this threshold - * (60000ms = 60s). - * @see #isFinished(long) - * @see org.schabi.newpipe.database.feed.dao.FeedDAO#getLiveOrNotPlayedStreams() - * @see org.schabi.newpipe.database.feed.dao.FeedDAO#getLiveOrNotPlayedStreamsForGroup(long) - */ - public static final long PLAYBACK_FINISHED_END_MILLISECONDS = 60000; - - @ColumnInfo(name = JOIN_STREAM_ID) - private long streamUid; - - @ColumnInfo(name = STREAM_PROGRESS_MILLIS) - private long progressMillis; - - public StreamStateEntity(final long streamUid, final long progressMillis) { - this.streamUid = streamUid; - this.progressMillis = progressMillis; - } - - public long getStreamUid() { - return streamUid; - } - - public void setStreamUid(final long streamUid) { - this.streamUid = streamUid; - } - - public long getProgressMillis() { - return progressMillis; - } - - public void setProgressMillis(final long progressMillis) { - this.progressMillis = progressMillis; - } - - /** - * The state will be considered valid, and thus be saved, if the progress is more than {@link - * #PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS} or at least 1/4 of the video length. - * @param durationInSeconds the duration of the stream connected with this state, in seconds - * @return whether this stream state entity should be saved or not - */ - public boolean isValid(final long durationInSeconds) { - return progressMillis > PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS - || progressMillis > durationInSeconds * 1000 / 4; - } - - /** - * The video will be considered as finished, if the time left is less than {@link - * #PLAYBACK_FINISHED_END_MILLISECONDS} and the progress is at least 3/4 of the video length. - * The state will be saved anyway, so that it can be shown under stream info items, but the - * player will not resume if a state is considered as finished. Finished streams are also the - * ones that can be filtered out in the feed fragment. - * @see org.schabi.newpipe.database.feed.dao.FeedDAO#getLiveOrNotPlayedStreams() - * @see org.schabi.newpipe.database.feed.dao.FeedDAO#getLiveOrNotPlayedStreamsForGroup(long) - * @param durationInSeconds the duration of the stream connected with this state, in seconds - * @return whether the stream is finished or not - */ - public boolean isFinished(final long durationInSeconds) { - return progressMillis >= durationInSeconds * 1000 - PLAYBACK_FINISHED_END_MILLISECONDS - && progressMillis >= durationInSeconds * 1000 * 3 / 4; - } - - @Override - public boolean equals(@Nullable final Object obj) { - if (obj instanceof StreamStateEntity) { - return ((StreamStateEntity) obj).streamUid == streamUid - && ((StreamStateEntity) obj).progressMillis == progressMillis; - } else { - return false; - } - } - - @Override - public int hashCode() { - return Objects.hash(streamUid, progressMillis); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.kt b/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.kt new file mode 100644 index 00000000000..759a2dcec31 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.kt @@ -0,0 +1,85 @@ +/* + * SPDX-FileCopyrightText: 2018-2023 NewPipe contributors + * SPDX-FileCopyrightText: 2025 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package org.schabi.newpipe.database.stream.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.ForeignKey.Companion.CASCADE +import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID +import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.JOIN_STREAM_ID +import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.PLAYBACK_FINISHED_END_MILLISECONDS +import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.STREAM_STATE_TABLE + +@Entity( + tableName = STREAM_STATE_TABLE, + primaryKeys = [JOIN_STREAM_ID], + foreignKeys = [ + ForeignKey( + entity = StreamEntity::class, + parentColumns = arrayOf(STREAM_ID), + childColumns = arrayOf(JOIN_STREAM_ID), + onDelete = CASCADE, + onUpdate = CASCADE + ) + ] +) +data class StreamStateEntity( + @ColumnInfo(name = JOIN_STREAM_ID) + val streamUid: Long, + + @ColumnInfo(name = STREAM_PROGRESS_MILLIS) + val progressMillis: Long +) { + /** + * The state will be considered valid, and thus be saved, if the progress is more than + * [PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS] or at least 1/4 of the video length. + * @param durationInSeconds the duration of the stream connected with this state, in seconds + * @return whether this stream state entity should be saved or not + */ + fun isValid(durationInSeconds: Long): Boolean { + return progressMillis > PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS || + progressMillis > durationInSeconds * 1000 / 4 + } + + /** + * The video will be considered as finished, if the time left is less than + * [PLAYBACK_FINISHED_END_MILLISECONDS] and the progress is at least 3/4 of the video length. + * The state will be saved anyway, so that it can be shown under stream info items, but the + * player will not resume if a state is considered as finished. Finished streams are also the + * ones that can be filtered out in the feed fragment. + * @param durationInSeconds the duration of the stream connected with this state, in seconds + * @return whether the stream is finished or not + */ + fun isFinished(durationInSeconds: Long): Boolean { + return progressMillis >= durationInSeconds * 1000 - PLAYBACK_FINISHED_END_MILLISECONDS && + progressMillis >= durationInSeconds * 1000 * 3 / 4 + } + + companion object { + const val STREAM_STATE_TABLE = "stream_state" + const val JOIN_STREAM_ID = "stream_id" + + // This additional field is required for the SQL query because 'stream_id' is used + // for some other joins already + const val JOIN_STREAM_ID_ALIAS = "stream_id_alias" + const val STREAM_PROGRESS_MILLIS = "progress_time" + + /** + * Playback state will not be saved, if playback time is less than this threshold + * (5000ms = 5s). + */ + const val PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS = 5000L + + /** + * Stream will be considered finished if the playback time left exceeds this threshold + * (60000ms = 60s). + * @see org.schabi.newpipe.database.stream.model.StreamStateEntity.isFinished + */ + const val PLAYBACK_FINISHED_END_MILLISECONDS = 60000L + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.java b/app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.java deleted file mode 100644 index 07e0eb7d358..00000000000 --- a/app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.schabi.newpipe.database.subscription; - -import androidx.annotation.IntDef; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; - -@IntDef({NotificationMode.DISABLED, NotificationMode.ENABLED}) -@Retention(RetentionPolicy.SOURCE) -public @interface NotificationMode { - - int DISABLED = 0; - int ENABLED = 1; - //other values reserved for the future -} diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.kt b/app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.kt new file mode 100644 index 00000000000..f9bb18c0cf7 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.kt @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2021 NewPipe contributors + * SPDX-FileCopyrightText: 2025 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package org.schabi.newpipe.database.subscription + +import androidx.annotation.IntDef + +@IntDef(NotificationMode.Companion.DISABLED, NotificationMode.Companion.ENABLED) +@Retention(AnnotationRetention.SOURCE) +annotation class NotificationMode { + companion object { + const val DISABLED = 0 + const val ENABLED = 1 // other values reserved for the future + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt index 47b6f4dd9aa..e6fdcbf70ec 100644 --- a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt +++ b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt @@ -99,7 +99,7 @@ abstract class SubscriptionDAO : BasicDAO { if (uidFromInsert != -1L) { entity.uid = uidFromInsert } else { - val subscriptionIdFromDb = getSubscriptionIdInternal(entity.serviceId, entity.url) + val subscriptionIdFromDb = getSubscriptionIdInternal(entity.serviceId, entity.url!!) ?: throw IllegalStateException("Subscription cannot be null just after insertion.") entity.uid = subscriptionIdFromDb diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java deleted file mode 100644 index df5a3067af0..00000000000 --- a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java +++ /dev/null @@ -1,200 +0,0 @@ -package org.schabi.newpipe.database.subscription; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.room.ColumnInfo; -import androidx.room.Entity; -import androidx.room.Ignore; -import androidx.room.Index; -import androidx.room.PrimaryKey; - -import org.schabi.newpipe.extractor.channel.ChannelInfo; -import org.schabi.newpipe.extractor.channel.ChannelInfoItem; -import org.schabi.newpipe.util.Constants; -import org.schabi.newpipe.util.image.ImageStrategy; - -import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_SERVICE_ID; -import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_TABLE; -import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_URL; - -@Entity(tableName = SUBSCRIPTION_TABLE, - indices = {@Index(value = {SUBSCRIPTION_SERVICE_ID, SUBSCRIPTION_URL}, unique = true)}) -public class SubscriptionEntity { - public static final String SUBSCRIPTION_UID = "uid"; - public static final String SUBSCRIPTION_TABLE = "subscriptions"; - public static final String SUBSCRIPTION_SERVICE_ID = "service_id"; - public static final String SUBSCRIPTION_URL = "url"; - public static final String SUBSCRIPTION_NAME = "name"; - public static final String SUBSCRIPTION_AVATAR_URL = "avatar_url"; - public static final String SUBSCRIPTION_SUBSCRIBER_COUNT = "subscriber_count"; - public static final String SUBSCRIPTION_DESCRIPTION = "description"; - public static final String SUBSCRIPTION_NOTIFICATION_MODE = "notification_mode"; - - @PrimaryKey(autoGenerate = true) - private long uid = 0; - - @ColumnInfo(name = SUBSCRIPTION_SERVICE_ID) - private int serviceId = Constants.NO_SERVICE_ID; - - @ColumnInfo(name = SUBSCRIPTION_URL) - private String url; - - @ColumnInfo(name = SUBSCRIPTION_NAME) - private String name; - - @ColumnInfo(name = SUBSCRIPTION_AVATAR_URL) - private String avatarUrl; - - @ColumnInfo(name = SUBSCRIPTION_SUBSCRIBER_COUNT) - private Long subscriberCount; - - @ColumnInfo(name = SUBSCRIPTION_DESCRIPTION) - private String description; - - @ColumnInfo(name = SUBSCRIPTION_NOTIFICATION_MODE) - private int notificationMode; - - @Ignore - public static SubscriptionEntity from(@NonNull final ChannelInfo info) { - final SubscriptionEntity result = new SubscriptionEntity(); - result.setServiceId(info.getServiceId()); - result.setUrl(info.getUrl()); - result.setData(info.getName(), ImageStrategy.imageListToDbUrl(info.getAvatars()), - info.getDescription(), info.getSubscriberCount()); - return result; - } - - public long getUid() { - return uid; - } - - public void setUid(final long uid) { - this.uid = uid; - } - - public int getServiceId() { - return serviceId; - } - - public void setServiceId(final int serviceId) { - this.serviceId = serviceId; - } - - public String getUrl() { - return url; - } - - public void setUrl(final String url) { - this.url = url; - } - - public String getName() { - return name; - } - - public void setName(final String name) { - this.name = name; - } - - @Nullable - public String getAvatarUrl() { - return avatarUrl; - } - - public void setAvatarUrl(@Nullable final String avatarUrl) { - this.avatarUrl = avatarUrl; - } - - public Long getSubscriberCount() { - return subscriberCount; - } - - public void setSubscriberCount(final Long subscriberCount) { - this.subscriberCount = subscriberCount; - } - - public String getDescription() { - return description; - } - - public void setDescription(final String description) { - this.description = description; - } - - @NotificationMode - public int getNotificationMode() { - return notificationMode; - } - - public void setNotificationMode(@NotificationMode final int notificationMode) { - this.notificationMode = notificationMode; - } - - @Ignore - public void setData(final String n, final String au, final String d, final Long sc) { - this.setName(n); - this.setAvatarUrl(au); - this.setDescription(d); - this.setSubscriberCount(sc); - } - - @Ignore - public ChannelInfoItem toChannelInfoItem() { - final ChannelInfoItem item = new ChannelInfoItem(getServiceId(), getUrl(), getName()); - item.setThumbnails(ImageStrategy.dbUrlToImageList(getAvatarUrl())); - item.setSubscriberCount(getSubscriberCount()); - item.setDescription(getDescription()); - return item; - } - - - // TODO: Remove these generated methods by migrating this class to a data class from Kotlin. - @Override - @SuppressWarnings("EqualsReplaceableByObjectsCall") - public boolean equals(final Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - - final SubscriptionEntity that = (SubscriptionEntity) o; - - if (uid != that.uid) { - return false; - } - if (serviceId != that.serviceId) { - return false; - } - if (!url.equals(that.url)) { - return false; - } - if (name != null ? !name.equals(that.name) : that.name != null) { - return false; - } - if (avatarUrl != null ? !avatarUrl.equals(that.avatarUrl) : that.avatarUrl != null) { - return false; - } - if (subscriberCount != null - ? !subscriberCount.equals(that.subscriberCount) - : that.subscriberCount != null) { - return false; - } - return description != null - ? description.equals(that.description) - : that.description == null; - } - - @Override - public int hashCode() { - int result = (int) (uid ^ (uid >>> 32)); - result = 31 * result + serviceId; - result = 31 * result + url.hashCode(); - result = 31 * result + (name != null ? name.hashCode() : 0); - result = 31 * result + (avatarUrl != null ? avatarUrl.hashCode() : 0); - result = 31 * result + (subscriberCount != null ? subscriberCount.hashCode() : 0); - result = 31 * result + (description != null ? description.hashCode() : 0); - return result; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.kt b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.kt new file mode 100644 index 00000000000..104d0a14246 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.kt @@ -0,0 +1,87 @@ +/* + * SPDX-FileCopyrightText: 2017-2024 NewPipe contributors + * SPDX-FileCopyrightText: 2025 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package org.schabi.newpipe.database.subscription + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.Index +import androidx.room.PrimaryKey +import org.schabi.newpipe.extractor.channel.ChannelInfo +import org.schabi.newpipe.extractor.channel.ChannelInfoItem +import org.schabi.newpipe.util.NO_SERVICE_ID +import org.schabi.newpipe.util.image.ImageStrategy + +@Entity( + tableName = SubscriptionEntity.Companion.SUBSCRIPTION_TABLE, + indices = [ + Index( + value = [SubscriptionEntity.Companion.SUBSCRIPTION_SERVICE_ID, SubscriptionEntity.Companion.SUBSCRIPTION_URL], + unique = true + ) + ] +) +data class SubscriptionEntity( + @PrimaryKey(autoGenerate = true) + var uid: Long = 0, + + @ColumnInfo(name = SUBSCRIPTION_SERVICE_ID) + var serviceId: Int = NO_SERVICE_ID, + + @ColumnInfo(name = SUBSCRIPTION_URL) + var url: String? = null, + + @ColumnInfo(name = SUBSCRIPTION_NAME) + var name: String? = null, + + @ColumnInfo(name = SUBSCRIPTION_AVATAR_URL) + var avatarUrl: String? = null, + + @ColumnInfo(name = SUBSCRIPTION_SUBSCRIBER_COUNT) + var subscriberCount: Long? = null, + + @ColumnInfo(name = SUBSCRIPTION_DESCRIPTION) + var description: String? = null, + + @get:NotificationMode + @ColumnInfo(name = SUBSCRIPTION_NOTIFICATION_MODE) + var notificationMode: Int = 0 +) { + @Ignore + fun toChannelInfoItem(): ChannelInfoItem { + return ChannelInfoItem(this.serviceId, this.url, this.name).apply { + thumbnails = ImageStrategy.dbUrlToImageList(this@SubscriptionEntity.avatarUrl) + subscriberCount = this.subscriberCount + description = this.description + } + } + + companion object { + const val SUBSCRIPTION_UID: String = "uid" + const val SUBSCRIPTION_TABLE: String = "subscriptions" + const val SUBSCRIPTION_SERVICE_ID: String = "service_id" + const val SUBSCRIPTION_URL: String = "url" + const val SUBSCRIPTION_NAME: String = "name" + const val SUBSCRIPTION_AVATAR_URL: String = "avatar_url" + const val SUBSCRIPTION_SUBSCRIBER_COUNT: String = "subscriber_count" + const val SUBSCRIPTION_DESCRIPTION: String = "description" + const val SUBSCRIPTION_NOTIFICATION_MODE: String = "notification_mode" + + @JvmStatic + @Ignore + fun from(info: ChannelInfo): SubscriptionEntity { + return SubscriptionEntity( + serviceId = info.serviceId, + url = info.url, + name = info.name, + avatarUrl = ImageStrategy.imageListToDbUrl(info.avatars), + description = info.description, + subscriberCount = info.subscriberCount + ) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java index d36fc5fc960..d75d14b4ada 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java @@ -361,10 +361,10 @@ private Consumer> getSubscribeUpdateMonitor(final Chann final SubscriptionEntity channel = new SubscriptionEntity(); channel.setServiceId(info.getServiceId()); channel.setUrl(info.getUrl()); - channel.setData(info.getName(), - ImageStrategy.imageListToDbUrl(info.getAvatars()), - info.getDescription(), - info.getSubscriberCount()); + channel.setName(info.getName()); + channel.setAvatarUrl(ImageStrategy.imageListToDbUrl(info.getAvatars())); + channel.setDescription(info.getDescription()); + channel.setSubscriberCount(info.getSubscriberCount()); channelSubscription = null; updateNotifyButton(null); subscribeButtonMonitor = monitorSubscribeButton(mapOnSubscribe(channel)); diff --git a/app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.java b/app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.java index a5e1594d1b5..d15db84c7ef 100644 --- a/app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.java @@ -140,7 +140,7 @@ public void selected(final LocalItem selectedItem) { if (selectedItem instanceof PlaylistMetadataEntry) { final PlaylistMetadataEntry entry = ((PlaylistMetadataEntry) selectedItem); NavigationHelper.openLocalPlaylistFragment(fragmentManager, entry.getUid(), - entry.name); + entry.getOrderingName()); } else if (selectedItem instanceof PlaylistRemoteEntity) { final PlaylistRemoteEntity entry = ((PlaylistRemoteEntity) selectedItem); @@ -148,7 +148,7 @@ public void selected(final LocalItem selectedItem) { fragmentManager, entry.getServiceId(), entry.getUrl(), - entry.getName()); + entry.getOrderingName()); } } @@ -378,11 +378,11 @@ public void saveImmediate() { if (item instanceof PlaylistMetadataEntry && ((PlaylistMetadataEntry) item).getDisplayIndex() != i) { - ((PlaylistMetadataEntry) item).setDisplayIndex(i); + ((PlaylistMetadataEntry) item).setDisplayIndex((long) i); localItemsUpdate.add((PlaylistMetadataEntry) item); } else if (item instanceof PlaylistRemoteEntity && ((PlaylistRemoteEntity) item).getDisplayIndex() != i) { - ((PlaylistRemoteEntity) item).setDisplayIndex(i); + ((PlaylistRemoteEntity) item).setDisplayIndex((long) i); remoteItemsUpdate.add((PlaylistRemoteEntity) item); } } @@ -487,7 +487,7 @@ public void onSwiped(@NonNull final RecyclerView.ViewHolder viewHolder, /////////////////////////////////////////////////////////////////////////// private void showRemoteDeleteDialog(final PlaylistRemoteEntity item) { - showDeleteDialog(item.getName(), item); + showDeleteDialog(item.getOrderingName(), item); } private void showLocalDialog(final PlaylistMetadataEntry selectedItem) { @@ -508,7 +508,7 @@ private void showLocalDialog(final PlaylistMetadataEntry selectedItem) { if (items.get(index).equals(rename)) { showRenameDialog(selectedItem); } else if (items.get(index).equals(delete)) { - showDeleteDialog(selectedItem.name, selectedItem); + showDeleteDialog(selectedItem.getOrderingName(), selectedItem); } else if (isThumbnailPermanent && items.get(index).equals(unsetThumbnail)) { final long thumbnailStreamId = localPlaylistManager .getAutomaticPlaylistThumbnailStreamId(selectedItem.getUid()); @@ -529,7 +529,7 @@ private void showRenameDialog(final PlaylistMetadataEntry selectedItem) { DialogEditTextBinding.inflate(getLayoutInflater()); dialogBinding.dialogEditText.setHint(R.string.name); dialogBinding.dialogEditText.setInputType(InputType.TYPE_CLASS_TEXT); - dialogBinding.dialogEditText.setText(selectedItem.name); + dialogBinding.dialogEditText.setText(selectedItem.getOrderingName()); new AlertDialog.Builder(activity) .setView(dialogBinding.getRoot()) diff --git a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java index 478ef8039fe..48d17d1d8c2 100644 --- a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java +++ b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java @@ -1,5 +1,7 @@ package org.schabi.newpipe.local.dialog; +import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.DEFAULT_THUMBNAIL_ID; + import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; @@ -14,7 +16,6 @@ import org.schabi.newpipe.NewPipeDatabase; import org.schabi.newpipe.R; -import org.schabi.newpipe.database.playlist.model.PlaylistEntity; import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry; import org.schabi.newpipe.database.stream.model.StreamEntity; import org.schabi.newpipe.local.LocalItemListAdapter; @@ -138,7 +139,7 @@ private void onPlaylistsReceived(@NonNull final List pl private boolean anyPlaylistContainsDuplicates(final List playlists) { return playlists.stream() - .anyMatch(playlist -> playlist.timesStreamIsContained > 0); + .anyMatch(playlist -> playlist.getTimesStreamIsContained() > 0); } private void onPlaylistSelected(@NonNull final LocalPlaylistManager manager, @@ -146,9 +147,9 @@ private void onPlaylistSelected(@NonNull final LocalPlaylistManager manager, @NonNull final List streams) { final String toastText; - if (playlist.timesStreamIsContained > 0) { + if (playlist.getTimesStreamIsContained() > 0) { toastText = getString(R.string.playlist_add_stream_success_duplicate, - playlist.timesStreamIsContained); + playlist.getTimesStreamIsContained()); } else { toastText = getString(R.string.playlist_add_stream_success); } @@ -160,8 +161,9 @@ private void onPlaylistSelected(@NonNull final LocalPlaylistManager manager, .subscribe(ignored -> { successToast.show(); - if (playlist.thumbnailUrl != null - && playlist.thumbnailUrl.equals(PlaylistEntity.DEFAULT_THUMBNAIL)) { + if (playlist.getThumbnailStreamId() != null + && playlist.getThumbnailStreamId() == DEFAULT_THUMBNAIL_ID + ) { playlistDisposables.add(manager .changePlaylistThumbnail(playlist.getUid(), streams.get(0).getUid(), false) diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt index ed65d4048e8..aacc6757ec1 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt @@ -177,7 +177,7 @@ class FeedDatabaseManager(context: Context) { .observeOn(AndroidSchedulers.mainThread()) } - fun oldestSubscriptionUpdate(groupId: Long): Flowable> { + fun oldestSubscriptionUpdate(groupId: Long): Flowable> { return when (groupId) { FeedGroupEntity.GROUP_ALL_ID -> feedTable.oldestSubscriptionUpdateFromAll() else -> feedTable.oldestSubscriptionUpdate(groupId) diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt index 91f98f5d298..bbad7f689d1 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt @@ -507,7 +507,7 @@ class FeedFragment : BaseStateFragment() { .setTitle(R.string.feed_load_error) .setPositiveButton(R.string.unsubscribe) { _, _ -> SubscriptionManager(requireContext()) - .deleteSubscription(subscriptionEntity.serviceId, subscriptionEntity.url) + .deleteSubscription(subscriptionEntity.serviceId, subscriptionEntity.url!!) .subscribe() handleItemsErrors(nextItemsErrors) } diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt index 728570b17e0..f916db2b5b4 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt @@ -65,7 +65,7 @@ class FeedViewModel( feedDatabaseManager.oldestSubscriptionUpdate(groupId), Function6 { t1: FeedEventManager.Event, t2: Boolean, t3: Boolean, t4: Boolean, - t5: Long, t6: List -> + t5: Long, t6: List -> return@Function6 CombineResultEventHolder(t1, t2, t3, t4, t5, t6.firstOrNull()) } ) diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistItemHolder.java index 336f5cfe30b..528275d75d6 100644 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistItemHolder.java @@ -35,15 +35,15 @@ public void updateFromItem(final LocalItem localItem, } final PlaylistMetadataEntry item = (PlaylistMetadataEntry) localItem; - itemTitleView.setText(item.name); + itemTitleView.setText(item.getOrderingName()); itemStreamCountView.setText(Localization.localizeStreamCountMini( - itemStreamCountView.getContext(), item.streamCount)); + itemStreamCountView.getContext(), item.getStreamCount())); itemUploaderView.setVisibility(View.INVISIBLE); - PicassoHelper.loadPlaylistThumbnail(item.thumbnailUrl).into(itemThumbnailView); + PicassoHelper.loadPlaylistThumbnail(item.getThumbnailUrl()).into(itemThumbnailView); if (item instanceof PlaylistDuplicatesEntry - && ((PlaylistDuplicatesEntry) item).timesStreamIsContained > 0) { + && ((PlaylistDuplicatesEntry) item).getTimesStreamIsContained() > 0) { itemView.setAlpha(GRAYED_OUT_ALPHA); } else { itemView.setAlpha(1.0f); diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java index 7657320634c..3a339aec878 100644 --- a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java @@ -34,7 +34,7 @@ public void updateFromItem(final LocalItem localItem, } final PlaylistRemoteEntity item = (PlaylistRemoteEntity) localItem; - itemTitleView.setText(item.getName()); + itemTitleView.setText(item.getOrderingName()); itemStreamCountView.setText(Localization.localizeStreamCountMini( itemStreamCountView.getContext(), item.getStreamCount())); // Here is where the uploader name is set in the bookmarked playlists library diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java index dd9307675de..1480735fb9d 100644 --- a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java +++ b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java @@ -148,7 +148,7 @@ public long getPlaylistThumbnailStreamId(final long playlistId) { public boolean getIsPlaylistThumbnailPermanent(final long playlistId) { return playlistTable.getPlaylist(playlistId).blockingFirst().get(0) - .getIsThumbnailPermanent(); + .isThumbnailPermanent(); } public long getAutomaticPlaylistThumbnailStreamId(final long playlistId) { @@ -174,7 +174,7 @@ private Maybe modifyPlaylist(final long playlistId, } if (thumbnailStreamId != THUMBNAIL_ID_LEAVE_UNCHANGED) { playlist.setThumbnailStreamId(thumbnailStreamId); - playlist.setIsThumbnailPermanent(isPermanent); + playlist.setThumbnailPermanent(isPermanent); } return playlistTable.update(playlist); }).subscribeOn(Schedulers.io()); diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt index 474add4f41b..c0783e8123e 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt @@ -26,7 +26,7 @@ class SubscriptionManager(context: Context) { private val feedDatabaseManager = FeedDatabaseManager(context) fun subscriptionTable(): SubscriptionDAO = subscriptionTable - fun subscriptions() = subscriptionTable.all + fun subscriptions() = subscriptionTable.getAll() fun getSubscriptions( currentGroupId: Long = FeedGroupEntity.GROUP_ALL_ID, @@ -44,7 +44,7 @@ class SubscriptionManager(context: Context) { } } showOnlyUngrouped -> subscriptionTable.getSubscriptionsOnlyUngrouped(currentGroupId) - else -> subscriptionTable.all + else -> subscriptionTable.getAll() } } @@ -71,12 +71,12 @@ class SubscriptionManager(context: Context) { subscriptionTable.getSubscription(info.serviceId, info.url) .flatMapCompletable { Completable.fromRunnable { - it.setData( - info.name, - ImageStrategy.imageListToDbUrl(info.avatars), - info.description, - info.subscriberCount - ) + it.apply { + name = info.name + avatarUrl = ImageStrategy.imageListToDbUrl(info.avatars) + description = info.description + subscriberCount = info.subscriberCount + } subscriptionTable.update(it) } } diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt index b7d57657da6..d221d704bd8 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt +++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt @@ -315,7 +315,7 @@ class MediaBrowserImpl( } private fun populateHistory(): Single> { - val history = database.streamHistoryDAO().getHistory().firstOrError() + val history = database.streamHistoryDAO().history.firstOrError() return history.map { items -> items.map { this.createHistoryMediaItem(it) } } diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt index 4815965a368..072a8f3321d 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt +++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt @@ -215,7 +215,7 @@ class MediaBrowserPlaybackPreparer( } val streamId = path[0].toLong() - return database.streamHistoryDAO().getHistory() + return database.streamHistoryDAO().history .firstOrError() .map { items -> val infoItems = items diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java index 6a5f7c894b3..880cbb282ea 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java @@ -118,12 +118,12 @@ private void clickedItem(final int position) { if (selectedItem instanceof PlaylistMetadataEntry) { final PlaylistMetadataEntry entry = ((PlaylistMetadataEntry) selectedItem); - onSelectedListener.onLocalPlaylistSelected(entry.getUid(), entry.name); + onSelectedListener.onLocalPlaylistSelected(entry.getUid(), entry.getOrderingName()); } else if (selectedItem instanceof PlaylistRemoteEntity) { final PlaylistRemoteEntity entry = ((PlaylistRemoteEntity) selectedItem); onSelectedListener.onRemotePlaylistSelected( - entry.getServiceId(), entry.getUrl(), entry.getName()); + entry.getServiceId(), entry.getUrl(), entry.getOrderingName()); } } dismiss(); @@ -157,14 +157,15 @@ public void onBindViewHolder(@NonNull final SelectPlaylistItemHolder holder, if (selectedItem instanceof PlaylistMetadataEntry) { final PlaylistMetadataEntry entry = ((PlaylistMetadataEntry) selectedItem); - holder.titleView.setText(entry.name); + holder.titleView.setText(entry.getOrderingName()); holder.view.setOnClickListener(view -> clickedItem(position)); - PicassoHelper.loadPlaylistThumbnail(entry.thumbnailUrl).into(holder.thumbnailView); + PicassoHelper.loadPlaylistThumbnail(entry.getThumbnailUrl()) + .into(holder.thumbnailView); } else if (selectedItem instanceof PlaylistRemoteEntity) { final PlaylistRemoteEntity entry = ((PlaylistRemoteEntity) selectedItem); - holder.titleView.setText(entry.getName()); + holder.titleView.setText(entry.getOrderingName()); holder.view.setOnClickListener(view -> clickedItem(position)); PicassoHelper.loadPlaylistThumbnail(entry.getThumbnailUrl()) .into(holder.thumbnailView); diff --git a/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigAdapter.kt b/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigAdapter.kt index f61aa72ab18..fd8abfa1651 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigAdapter.kt +++ b/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigAdapter.kt @@ -31,7 +31,7 @@ class NotificationModeConfigAdapter( fun update(newData: List) { val items = newData.map { - SubscriptionItem(it.uid, it.name, it.notificationMode, it.serviceId, it.url) + SubscriptionItem(it.uid, it.name!!, it.notificationMode, it.serviceId, it.url!!) } submitList(items) } diff --git a/app/src/test/java/org/schabi/newpipe/database/playlist/PlaylistLocalItemTest.java b/app/src/test/java/org/schabi/newpipe/database/playlist/PlaylistLocalItemTest.java deleted file mode 100644 index 847c54aa849..00000000000 --- a/app/src/test/java/org/schabi/newpipe/database/playlist/PlaylistLocalItemTest.java +++ /dev/null @@ -1,106 +0,0 @@ -package org.schabi.newpipe.database.playlist; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; -import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; -import org.schabi.newpipe.local.bookmark.MergedPlaylistManager; - -import java.util.ArrayList; -import java.util.List; - -public class PlaylistLocalItemTest { - @Test - public void emptyPlaylists() { - final List localPlaylists = new ArrayList<>(); - final List remotePlaylists = new ArrayList<>(); - final List mergedPlaylists = - MergedPlaylistManager.merge(localPlaylists, remotePlaylists); - - assertEquals(0, mergedPlaylists.size()); - } - - @Test - public void onlyLocalPlaylists() { - final List localPlaylists = new ArrayList<>(); - final List remotePlaylists = new ArrayList<>(); - localPlaylists.add(new PlaylistMetadataEntry(1, "name1", "", false, -1, 0, 1)); - localPlaylists.add(new PlaylistMetadataEntry(2, "name2", "", false, -1, 1, 1)); - localPlaylists.add(new PlaylistMetadataEntry(3, "name3", "", false, -1, 3, 1)); - final List mergedPlaylists = - MergedPlaylistManager.merge(localPlaylists, remotePlaylists); - - assertEquals(3, mergedPlaylists.size()); - assertEquals(0, mergedPlaylists.get(0).getDisplayIndex()); - assertEquals(1, mergedPlaylists.get(1).getDisplayIndex()); - assertEquals(3, mergedPlaylists.get(2).getDisplayIndex()); - } - - @Test - public void onlyRemotePlaylists() { - final List localPlaylists = new ArrayList<>(); - final List remotePlaylists = new ArrayList<>(); - remotePlaylists.add(new PlaylistRemoteEntity( - 1, "name1", "url1", "", "", 1, 1L)); - remotePlaylists.add(new PlaylistRemoteEntity( - 2, "name2", "url2", "", "", 2, 1L)); - remotePlaylists.add(new PlaylistRemoteEntity( - 3, "name3", "url3", "", "", 4, 1L)); - final List mergedPlaylists = - MergedPlaylistManager.merge(localPlaylists, remotePlaylists); - - assertEquals(3, mergedPlaylists.size()); - assertEquals(1, mergedPlaylists.get(0).getDisplayIndex()); - assertEquals(2, mergedPlaylists.get(1).getDisplayIndex()); - assertEquals(4, mergedPlaylists.get(2).getDisplayIndex()); - } - - @Test - public void sameIndexWithDifferentName() { - final List localPlaylists = new ArrayList<>(); - final List remotePlaylists = new ArrayList<>(); - localPlaylists.add(new PlaylistMetadataEntry(1, "name1", "", false, -1, 0, 1)); - localPlaylists.add(new PlaylistMetadataEntry(2, "name2", "", false, -1, 1, 1)); - remotePlaylists.add(new PlaylistRemoteEntity( - 1, "name3", "url1", "", "", 0, 1L)); - remotePlaylists.add(new PlaylistRemoteEntity( - 2, "name4", "url2", "", "", 1, 1L)); - final List mergedPlaylists = - MergedPlaylistManager.merge(localPlaylists, remotePlaylists); - - assertEquals(4, mergedPlaylists.size()); - assertTrue(mergedPlaylists.get(0) instanceof PlaylistMetadataEntry); - assertEquals("name1", ((PlaylistMetadataEntry) mergedPlaylists.get(0)).name); - assertTrue(mergedPlaylists.get(1) instanceof PlaylistRemoteEntity); - assertEquals("name3", ((PlaylistRemoteEntity) mergedPlaylists.get(1)).getName()); - assertTrue(mergedPlaylists.get(2) instanceof PlaylistMetadataEntry); - assertEquals("name2", ((PlaylistMetadataEntry) mergedPlaylists.get(2)).name); - assertTrue(mergedPlaylists.get(3) instanceof PlaylistRemoteEntity); - assertEquals("name4", ((PlaylistRemoteEntity) mergedPlaylists.get(3)).getName()); - } - - @Test - public void sameNameWithDifferentIndex() { - final List localPlaylists = new ArrayList<>(); - final List remotePlaylists = new ArrayList<>(); - localPlaylists.add(new PlaylistMetadataEntry(1, "name1", "", false, -1, 1, 1)); - localPlaylists.add(new PlaylistMetadataEntry(2, "name2", "", false, -1, 3, 1)); - remotePlaylists.add(new PlaylistRemoteEntity( - 1, "name1", "url1", "", "", 0, 1L)); - remotePlaylists.add(new PlaylistRemoteEntity( - 2, "name2", "url2", "", "", 2, 1L)); - final List mergedPlaylists = - MergedPlaylistManager.merge(localPlaylists, remotePlaylists); - - assertEquals(4, mergedPlaylists.size()); - assertTrue(mergedPlaylists.get(0) instanceof PlaylistRemoteEntity); - assertEquals("name1", ((PlaylistRemoteEntity) mergedPlaylists.get(0)).getName()); - assertTrue(mergedPlaylists.get(1) instanceof PlaylistMetadataEntry); - assertEquals("name1", ((PlaylistMetadataEntry) mergedPlaylists.get(1)).name); - assertTrue(mergedPlaylists.get(2) instanceof PlaylistRemoteEntity); - assertEquals("name2", ((PlaylistRemoteEntity) mergedPlaylists.get(2)).getName()); - assertTrue(mergedPlaylists.get(3) instanceof PlaylistMetadataEntry); - assertEquals("name2", ((PlaylistMetadataEntry) mergedPlaylists.get(3)).name); - } -} diff --git a/app/src/test/java/org/schabi/newpipe/database/playlist/PlaylistLocalItemTest.kt b/app/src/test/java/org/schabi/newpipe/database/playlist/PlaylistLocalItemTest.kt new file mode 100644 index 00000000000..ace51e7e26d --- /dev/null +++ b/app/src/test/java/org/schabi/newpipe/database/playlist/PlaylistLocalItemTest.kt @@ -0,0 +1,102 @@ +/* + * SPDX-FileCopyrightText: 2022-2024 NewPipe contributors + * SPDX-FileCopyrightText: 2025 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package org.schabi.newpipe.database.playlist + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity +import org.schabi.newpipe.local.bookmark.MergedPlaylistManager + +class PlaylistLocalItemTest { + + @Test + fun emptyPlaylists() { + val localPlaylists = listOf() + val remotePlaylists = listOf() + val mergedPlaylists = MergedPlaylistManager.merge(localPlaylists, remotePlaylists) + assertEquals(0, mergedPlaylists.size) + } + + @Test + fun onlyLocalPlaylists() { + val localPlaylists = listOf( + PlaylistMetadataEntry(1, "name1", "", 0, false, -1, 1), + PlaylistMetadataEntry(2, "name2", "", 1, false, -1, 1), + PlaylistMetadataEntry(3, "name3", "", 3, false, -1, 1) + ) + val remotePlaylists = listOf() + val mergedPlaylists = MergedPlaylistManager.merge(localPlaylists, remotePlaylists) + + assertEquals(3, mergedPlaylists.size) + assertEquals(0L, mergedPlaylists[0]!!.displayIndex) + assertEquals(1L, mergedPlaylists[1]!!.displayIndex) + assertEquals(3L, mergedPlaylists[2]!!.displayIndex) + } + + @Test + fun onlyRemotePlaylists() { + val localPlaylists = listOf() + val remotePlaylists = listOf( + PlaylistRemoteEntity(1, 1, "name1", "url1", "", "", 1, 1), + PlaylistRemoteEntity(2, 2, "name2", "url2", "", "", 2, 1), + PlaylistRemoteEntity(3, 3, "name3", "url3", "", "", 4, 1) + ) + val mergedPlaylists = MergedPlaylistManager.merge(localPlaylists, remotePlaylists) + + assertEquals(3, mergedPlaylists.size) + assertEquals(1L, mergedPlaylists[0]!!.displayIndex) + assertEquals(2L, mergedPlaylists[1]!!.displayIndex) + assertEquals(4L, mergedPlaylists[2]!!.displayIndex) + } + + @Test + fun sameIndexWithDifferentName() { + val localPlaylists = listOf( + PlaylistMetadataEntry(1, "name1", "", 0, false, -1, 1), + PlaylistMetadataEntry(2, "name2", "", 1, false, -1, 1) + ) + val remotePlaylists = listOf( + PlaylistRemoteEntity(1, 1, "name3", "url1", "", "", 0, 1), + PlaylistRemoteEntity(2, 2, "name4", "url2", "", "", 1, 1) + ) + val mergedPlaylists = MergedPlaylistManager.merge(localPlaylists, remotePlaylists) + + assertEquals(4, mergedPlaylists.size) + assertTrue(mergedPlaylists[0] is PlaylistMetadataEntry) + assertEquals("name1", (mergedPlaylists[0] as PlaylistMetadataEntry).orderingName) + assertTrue(mergedPlaylists[1] is PlaylistRemoteEntity) + assertEquals("name3", (mergedPlaylists[1] as PlaylistRemoteEntity).orderingName) + assertTrue(mergedPlaylists[2] is PlaylistMetadataEntry) + assertEquals("name2", (mergedPlaylists[2] as PlaylistMetadataEntry).orderingName) + assertTrue(mergedPlaylists[3] is PlaylistRemoteEntity) + assertEquals("name4", (mergedPlaylists[3] as PlaylistRemoteEntity).orderingName) + } + + @Test + fun sameNameWithDifferentIndex() { + val localPlaylists = listOf( + PlaylistMetadataEntry(1, "name1", "", 1, false, -1, 1), + PlaylistMetadataEntry(2, "name2", "", 3, false, -1, 1) + ) + val remotePlaylists = listOf( + PlaylistRemoteEntity(1, 1, "name1", "url1", "", "", 0, 1), + PlaylistRemoteEntity(2, 2, "name2", "url2", "", "", 2, 1) + ) + val mergedPlaylists = MergedPlaylistManager.merge(localPlaylists, remotePlaylists) + + assertEquals(4, mergedPlaylists.size) + assertTrue(mergedPlaylists[0] is PlaylistRemoteEntity) + assertEquals("name1", (mergedPlaylists[0] as PlaylistRemoteEntity).orderingName) + assertTrue(mergedPlaylists[1] is PlaylistMetadataEntry) + assertEquals("name1", (mergedPlaylists[1] as PlaylistMetadataEntry).orderingName) + assertTrue(mergedPlaylists[2] is PlaylistRemoteEntity) + assertEquals("name2", (mergedPlaylists[2] as PlaylistRemoteEntity).orderingName) + assertTrue(mergedPlaylists[3] is PlaylistMetadataEntry) + assertEquals("name2", (mergedPlaylists[3] as PlaylistMetadataEntry).orderingName) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index e79ccc3e05a..d16afb3af19 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,7 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.jetbrains.kotlin.android) apply false - alias(libs.plugins.jetbrains.kotlin.kapt) apply false + alias(libs.plugins.google.ksp) apply false alias(libs.plugins.jetbrains.kotlin.parcelize) apply false alias(libs.plugins.sonarqube) apply false } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f4867886784..cd54e5ef402 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,6 +23,7 @@ jsoup = "1.21.2" junit = "4.13.2" junit-ext = "1.1.5" kotlin = "1.9.25" +ksp = "1.9.25-1.0.20" ktlint = "0.45.2" leakcanary = "2.12" lifecycle = "2.6.2" @@ -130,7 +131,7 @@ squareup-picasso = { module = "com.squareup.picasso:picasso", version.ref = "pic [plugins] android-application = { id = "com.android.application", version.ref = "agp" } +google-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } -jetbrains-kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } jetbrains-kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } sonarqube = { id = "org.sonarqube", version.ref = "sonarqube" }