1
0
mirror of https://github.com/TeamNewPipe/NewPipe synced 2025-11-08 03:02:59 +00:00

Merge pull request #12746 from TeamNewPipe/kspMigration

Migrate from KAPT to KSP
This commit is contained in:
Tobi
2025-11-07 07:41:56 -08:00
committed by GitHub
72 changed files with 1834 additions and 2120 deletions

View File

@@ -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)

View File

@@ -458,7 +458,7 @@
"notNull": true
},
{
"fieldPath": "name",
"fieldPath": "orderingName",
"columnName": "name",
"affinity": "TEXT",
"notNull": false

View File

@@ -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)

View File

@@ -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<SearchHistoryEntry>) {
@@ -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"),
)
}
}

View File

@@ -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))
}
}

View File

@@ -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;
}
}
}
}
}

View File

@@ -0,0 +1,80 @@
/*
* SPDX-FileCopyrightText: 2017-2024 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
* 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
}
}
}
}
}

View File

@@ -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();
}

View File

@@ -0,0 +1,68 @@
/*
* SPDX-FileCopyrightText: 2017-2024 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
* 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"
}
}

View File

@@ -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<Entity> {
/* Inserts */
@Insert
long insert(Entity entity);
@Insert
List<Long> insertAll(Collection<Entity> entities);
/* Searches */
Flowable<List<Entity>> getAll();
Flowable<List<Entity>> listByService(int serviceId);
/* Deletes */
@Delete
void delete(Entity entity);
int deleteAll();
/* Updates */
@Update
int update(Entity entity);
@Update
void update(Collection<Entity> entities);
}

View File

@@ -0,0 +1,42 @@
/*
* SPDX-FileCopyrightText: 2017-2022 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
* 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<Entity> {
/* Inserts */
@Insert
fun insert(entity: Entity): Long
@Insert
fun insertAll(entities: Collection<Entity>): List<Long>
/* Searches */
fun getAll(): Flowable<List<Entity>>
fun listByService(serviceId: Int): Flowable<List<Entity>>
/* Deletes */
@Delete
fun delete(entity: Entity)
fun deleteAll(): Int
/* Updates */
@Update
fun update(entity: Entity): Int
@Update
fun update(entities: Collection<Entity>)
}

View File

@@ -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,
}
}

View File

@@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: 2018-2020 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
* 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,
}
}

View File

@@ -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() {
}
}

View File

@@ -0,0 +1,368 @@
/*
* SPDX-FileCopyrightText: 2018-2024 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
* 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()
}
}
}
}

View File

@@ -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<List<OffsetDateTime>>
abstract fun oldestSubscriptionUpdate(groupId: Long): Flowable<List<OffsetDateTime?>>
@Query("SELECT MIN(last_updated) FROM feed_last_updated")
abstract fun oldestSubscriptionUpdateFromAll(): Flowable<List<OffsetDateTime>>
abstract fun oldestSubscriptionUpdateFromAll(): Flowable<List<OffsetDateTime?>>
@Query("SELECT COUNT(*) FROM feed_last_updated WHERE last_updated IS NULL")
abstract fun notLoadedCount(): Flowable<Long>

View File

@@ -1,7 +0,0 @@
package org.schabi.newpipe.database.history.dao;
import org.schabi.newpipe.database.BasicDAO;
public interface HistoryDAO<T> extends BasicDAO<T> {
T getLatestEntry();
}

View File

@@ -0,0 +1,13 @@
/*
* SPDX-FileCopyrightText: 2017 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.database.history.dao
import org.schabi.newpipe.database.BasicDAO
interface HistoryDAO<T> : BasicDAO<T> {
val latestEntry: T
}

View File

@@ -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<SearchHistoryEntry> {
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<List<SearchHistoryEntry>> getAll();
@Query("SELECT " + SEARCH + " FROM " + TABLE_NAME + " GROUP BY " + SEARCH
+ ORDER_BY_MAX_CREATION_DATE + " LIMIT :limit")
Flowable<List<String>> getUniqueEntries(int limit);
@Query("SELECT * FROM " + TABLE_NAME
+ " WHERE " + SERVICE_ID + " = :serviceId" + ORDER_BY_CREATION_DATE)
@Override
Flowable<List<SearchHistoryEntry>> listByService(int serviceId);
@Query("SELECT " + SEARCH + " FROM " + TABLE_NAME + " WHERE " + SEARCH + " LIKE :query || '%'"
+ " GROUP BY " + SEARCH + ORDER_BY_MAX_CREATION_DATE + " LIMIT :limit")
Flowable<List<String>> getSimilarEntries(String query, int limit);
}

View File

@@ -0,0 +1,42 @@
/*
* SPDX-FileCopyrightText: 2017-2021 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
* 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<SearchHistoryEntry> {
@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<List<SearchHistoryEntry>>
@Query("SELECT search FROM search_history GROUP BY search ORDER BY MAX(creation_date) DESC LIMIT :limit")
fun getUniqueEntries(limit: Int): Flowable<MutableList<String>>
@Query("SELECT * FROM search_history WHERE service_id = :serviceId ORDER BY creation_date DESC")
override fun listByService(serviceId: Int): Flowable<List<SearchHistoryEntry>>
@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<MutableList<String>>
}

View File

@@ -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<StreamHistoryEntity> {
@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<List<StreamHistoryEntity>> getAll();
@Override
@Query("DELETE FROM " + STREAM_HISTORY_TABLE)
public abstract int deleteAll();
@Override
public Flowable<List<StreamHistoryEntity>> 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<List<StreamHistoryEntry>> 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<List<StreamHistoryEntry>> 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<List<StreamStatisticsEntry>> getStatistics();
}

View File

@@ -0,0 +1,63 @@
/*
* SPDX-FileCopyrightText: 2018-2022 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
* 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<StreamHistoryEntity> {
@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<List<StreamHistoryEntity>>
@Query("DELETE FROM stream_history")
abstract override fun deleteAll(): Int
override fun listByService(serviceId: Int): Flowable<List<StreamHistoryEntity>> {
throw UnsupportedOperationException()
}
@get:Query("SELECT * FROM streams INNER JOIN stream_history ON uid = stream_id ORDER BY access_date DESC")
abstract val history: Flowable<MutableList<StreamHistoryEntry>>
@get:Query("SELECT * FROM streams INNER JOIN stream_history ON uid = stream_id ORDER BY uid ASC")
abstract val historySortedById: Flowable<MutableList<StreamHistoryEntry>>
@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<MutableList<StreamStatisticsEntry>>
}

View File

@@ -1,3 +1,9 @@
/*
* SPDX-FileCopyrightText: 2022 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
* 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 {

View File

@@ -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;
}
}

View File

@@ -0,0 +1,56 @@
/*
* SPDX-FileCopyrightText: 2018-2022 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
* 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"
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,54 @@
/*
* SPDX-FileCopyrightText: 2023-2024 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
* 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"
}
}

View File

@@ -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();
}

View File

@@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: 2018-2025 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
* 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?
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,42 @@
/*
* SPDX-FileCopyrightText: 2018-2025 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
* 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"
}
}

View File

@@ -1,3 +1,9 @@
/*
* SPDX-FileCopyrightText: 2020-2023 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
* 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)
}
}
}

View File

@@ -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<PlaylistEntity> {
@Override
@Query("SELECT * FROM " + PLAYLIST_TABLE)
Flowable<List<PlaylistEntity>> getAll();
@Override
@Query("DELETE FROM " + PLAYLIST_TABLE)
int deleteAll();
@Override
default Flowable<List<PlaylistEntity>> listByService(final int serviceId) {
throw new UnsupportedOperationException();
}
@Query("SELECT * FROM " + PLAYLIST_TABLE + " WHERE " + PLAYLIST_ID + " = :playlistId")
Flowable<List<PlaylistEntity>> getPlaylist(long playlistId);
@Query("DELETE FROM " + PLAYLIST_TABLE + " WHERE " + PLAYLIST_ID + " = :playlistId")
int deletePlaylist(long playlistId);
@Query("SELECT COUNT(*) FROM " + PLAYLIST_TABLE)
Flowable<Long> 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;
}
}
}

View File

@@ -0,0 +1,48 @@
/*
* SPDX-FileCopyrightText: 2018-2022 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
* 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<PlaylistEntity> {
@Query("SELECT * FROM playlists")
override fun getAll(): Flowable<List<PlaylistEntity>>
@Query("DELETE FROM playlists")
override fun deleteAll(): Int
override fun listByService(serviceId: Int): Flowable<List<PlaylistEntity>> {
throw UnsupportedOperationException()
}
@Query("SELECT * FROM playlists WHERE uid = :playlistId")
fun getPlaylist(playlistId: Long): Flowable<MutableList<PlaylistEntity>>
@Query("DELETE FROM playlists WHERE uid = :playlistId")
fun deletePlaylist(playlistId: Long): Int
@get:Query("SELECT COUNT(*) FROM playlists")
val count: Flowable<Long>
@Transaction
fun upsertPlaylist(playlist: PlaylistEntity): Long {
if (playlist.uid == -1L) {
// This situation is probably impossible.
return insert(playlist)
} else {
update(playlist)
return playlist.uid
}
}
}

View File

@@ -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<PlaylistRemoteEntity> {
@Override
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE)
Flowable<List<PlaylistRemoteEntity>> getAll();
@Override
@Query("DELETE FROM " + REMOTE_PLAYLIST_TABLE)
int deleteAll();
@Override
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE
+ " WHERE " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")
Flowable<List<PlaylistRemoteEntity>> listByService(int serviceId);
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE "
+ REMOTE_PLAYLIST_ID + " = :playlistId")
Flowable<PlaylistRemoteEntity> getPlaylist(long playlistId);
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE "
+ REMOTE_PLAYLIST_URL + " = :url AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")
Flowable<List<PlaylistRemoteEntity>> getPlaylist(long serviceId, String url);
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE
+ " ORDER BY " + REMOTE_PLAYLIST_DISPLAY_INDEX)
Flowable<List<PlaylistRemoteEntity>> 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);
}

View File

@@ -0,0 +1,55 @@
/*
* SPDX-FileCopyrightText: 2018-2025 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
* 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<PlaylistRemoteEntity> {
@Query("SELECT * FROM remote_playlists")
override fun getAll(): Flowable<List<PlaylistRemoteEntity>>
@Query("DELETE FROM remote_playlists")
override fun deleteAll(): Int
@Query("SELECT * FROM remote_playlists WHERE service_id = :serviceId")
override fun listByService(serviceId: Int): Flowable<List<PlaylistRemoteEntity>>
@Query("SELECT * FROM remote_playlists WHERE uid = :playlistId")
fun getPlaylist(playlistId: Long): Flowable<PlaylistRemoteEntity>
@Query("SELECT * FROM remote_playlists WHERE url = :url AND uid = :serviceId")
fun getPlaylist(serviceId: Long, url: String?): Flowable<MutableList<PlaylistRemoteEntity>>
@get:Query("SELECT * FROM remote_playlists ORDER BY display_index")
val playlists: Flowable<MutableList<PlaylistRemoteEntity>>
@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
}

View File

@@ -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<PlaylistStreamEntity> {
@Override
@Query("SELECT * FROM " + PLAYLIST_STREAM_JOIN_TABLE)
Flowable<List<PlaylistStreamEntity>> getAll();
@Override
@Query("DELETE FROM " + PLAYLIST_STREAM_JOIN_TABLE)
int deleteAll();
@Override
default Flowable<List<PlaylistStreamEntity>> 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<Integer> 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<Long> 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<List<PlaylistStreamEntry>> 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<List<PlaylistMetadataEntry>> 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<List<PlaylistStreamEntry>> 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<List<PlaylistDuplicatesEntry>> getPlaylistDuplicatesMetadata(String streamUrl);
}

View File

@@ -0,0 +1,126 @@
/*
* SPDX-FileCopyrightText: 2018-2024 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
* 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<PlaylistStreamEntity> {
@Query("SELECT * FROM playlist_stream_join")
override fun getAll(): Flowable<List<PlaylistStreamEntity>>
@Query("DELETE FROM playlist_stream_join")
override fun deleteAll(): Int
override fun listByService(serviceId: Int): Flowable<List<PlaylistStreamEntity>> {
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<Int>
@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<Long>
// 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<MutableList<PlaylistStreamEntry>>
@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<MutableList<PlaylistMetadataEntry>>
@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<MutableList<PlaylistStreamEntry>>
@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<MutableList<PlaylistDuplicatesEntry>>
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,54 @@
/*
* SPDX-FileCopyrightText: 2018-2024 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
* 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"
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,104 @@
/*
* SPDX-FileCopyrightText: 2018-2025 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
* 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"
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,68 @@
/*
* SPDX-FileCopyrightText: 2018-2020 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
* 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"
}
}

View File

@@ -1,16 +1,23 @@
/*
* SPDX-FileCopyrightText: 2020-2023 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
* 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 {
override val localItemType: LocalItem.LocalItemType
get() = LocalItem.LocalItemType.STATISTIC_STREAM_ITEM
@Ignore
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.STATISTIC_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)
}
}
companion object {

View File

@@ -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<StreamStateEntity> {
@Override
@Query("SELECT * FROM " + STREAM_STATE_TABLE)
Flowable<List<StreamStateEntity>> getAll();
@Override
@Query("DELETE FROM " + STREAM_STATE_TABLE)
int deleteAll();
@Override
default Flowable<List<StreamStateEntity>> listByService(final int serviceId) {
throw new UnsupportedOperationException();
}
@Query("SELECT * FROM " + STREAM_STATE_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId")
Flowable<List<StreamStateEntity>> 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);
}
}

View File

@@ -0,0 +1,45 @@
/*
* SPDX-FileCopyrightText: 2018-2021 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
* 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<StreamStateEntity> {
@Query("SELECT * FROM " + StreamStateEntity.STREAM_STATE_TABLE)
override fun getAll(): Flowable<List<StreamStateEntity>>
@Query("DELETE FROM " + StreamStateEntity.STREAM_STATE_TABLE)
override fun deleteAll(): Int
override fun listByService(serviceId: Int): Flowable<List<StreamStateEntity>> {
throw UnsupportedOperationException()
}
@Query("SELECT * FROM " + StreamStateEntity.STREAM_STATE_TABLE + " WHERE " + StreamStateEntity.JOIN_STREAM_ID + " = :streamId")
fun getState(streamId: Long): Flowable<MutableList<StreamStateEntity>>
@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()
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,85 @@
/*
* SPDX-FileCopyrightText: 2018-2023 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
* 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
}
}

View File

@@ -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
}

View File

@@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: 2021 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
* 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
}
}

View File

@@ -99,7 +99,7 @@ abstract class SubscriptionDAO : BasicDAO<SubscriptionEntity> {
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

View File

@@ -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;
}
}

View File

@@ -0,0 +1,87 @@
/*
* SPDX-FileCopyrightText: 2017-2024 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
* 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
)
}
}
}

View File

@@ -361,10 +361,10 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
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));

View File

@@ -140,7 +140,7 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
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 final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
fragmentManager,
entry.getServiceId(),
entry.getUrl(),
entry.getName());
entry.getOrderingName());
}
}
@@ -378,11 +378,11 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
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 final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
///////////////////////////////////////////////////////////////////////////
private void showRemoteDeleteDialog(final PlaylistRemoteEntity item) {
showDeleteDialog(item.getName(), item);
showDeleteDialog(item.getOrderingName(), item);
}
private void showLocalDialog(final PlaylistMetadataEntry selectedItem) {
@@ -508,7 +508,7 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
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 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
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())

View File

@@ -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 androidx.recyclerview.widget.RecyclerView;
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 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
private boolean anyPlaylistContainsDuplicates(final List<PlaylistDuplicatesEntry> 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 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
@NonNull final List<StreamEntity> 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 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
.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)

View File

@@ -177,7 +177,7 @@ class FeedDatabaseManager(context: Context) {
.observeOn(AndroidSchedulers.mainThread())
}
fun oldestSubscriptionUpdate(groupId: Long): Flowable<List<OffsetDateTime>> {
fun oldestSubscriptionUpdate(groupId: Long): Flowable<List<OffsetDateTime?>> {
return when (groupId) {
FeedGroupEntity.GROUP_ALL_ID -> feedTable.oldestSubscriptionUpdateFromAll()
else -> feedTable.oldestSubscriptionUpdate(groupId)

View File

@@ -507,7 +507,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
.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)
}

View File

@@ -65,7 +65,7 @@ class FeedViewModel(
feedDatabaseManager.oldestSubscriptionUpdate(groupId),
Function6 { t1: FeedEventManager.Event, t2: Boolean, t3: Boolean, t4: Boolean,
t5: Long, t6: List<OffsetDateTime> ->
t5: Long, t6: List<OffsetDateTime?> ->
return@Function6 CombineResultEventHolder(t1, t2, t3, t4, t5, t6.firstOrNull())
}
)

View File

@@ -35,15 +35,15 @@ public class LocalPlaylistItemHolder extends PlaylistItemHolder {
}
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);

View File

@@ -34,7 +34,7 @@ public class RemotePlaylistItemHolder extends PlaylistItemHolder {
}
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

View File

@@ -148,7 +148,7 @@ public class LocalPlaylistManager {
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 @@ public class LocalPlaylistManager {
}
if (thumbnailStreamId != THUMBNAIL_ID_LEAVE_UNCHANGED) {
playlist.setThumbnailStreamId(thumbnailStreamId);
playlist.setIsThumbnailPermanent(isPermanent);
playlist.setThumbnailPermanent(isPermanent);
}
return playlistTable.update(playlist);
}).subscribeOn(Schedulers.io());

View File

@@ -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)
}
}

View File

@@ -315,7 +315,7 @@ class MediaBrowserImpl(
}
private fun populateHistory(): Single<List<MediaBrowserCompat.MediaItem>> {
val history = database.streamHistoryDAO().getHistory().firstOrError()
val history = database.streamHistoryDAO().history.firstOrError()
return history.map { items ->
items.map { this.createHistoryMediaItem(it) }
}

View File

@@ -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

View File

@@ -118,12 +118,12 @@ public class SelectPlaylistFragment extends DialogFragment {
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 class SelectPlaylistFragment extends DialogFragment {
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);

View File

@@ -31,7 +31,7 @@ class NotificationModeConfigAdapter(
fun update(newData: List<SubscriptionEntity>) {
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)
}

View File

@@ -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<PlaylistMetadataEntry> localPlaylists = new ArrayList<>();
final List<PlaylistRemoteEntity> remotePlaylists = new ArrayList<>();
final List<PlaylistLocalItem> mergedPlaylists =
MergedPlaylistManager.merge(localPlaylists, remotePlaylists);
assertEquals(0, mergedPlaylists.size());
}
@Test
public void onlyLocalPlaylists() {
final List<PlaylistMetadataEntry> localPlaylists = new ArrayList<>();
final List<PlaylistRemoteEntity> 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<PlaylistLocalItem> 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<PlaylistMetadataEntry> localPlaylists = new ArrayList<>();
final List<PlaylistRemoteEntity> 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<PlaylistLocalItem> 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<PlaylistMetadataEntry> localPlaylists = new ArrayList<>();
final List<PlaylistRemoteEntity> 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<PlaylistLocalItem> 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<PlaylistMetadataEntry> localPlaylists = new ArrayList<>();
final List<PlaylistRemoteEntity> 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<PlaylistLocalItem> 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);
}
}

View File

@@ -0,0 +1,102 @@
/*
* SPDX-FileCopyrightText: 2022-2024 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
* 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<PlaylistMetadataEntry?>()
val remotePlaylists = listOf<PlaylistRemoteEntity?>()
val mergedPlaylists = MergedPlaylistManager.merge(localPlaylists, remotePlaylists)
assertEquals(0, mergedPlaylists.size)
}
@Test
fun onlyLocalPlaylists() {
val localPlaylists = listOf<PlaylistMetadataEntry?>(
PlaylistMetadataEntry(1, "name1", "", 0, false, -1, 1),
PlaylistMetadataEntry(2, "name2", "", 1, false, -1, 1),
PlaylistMetadataEntry(3, "name3", "", 3, false, -1, 1)
)
val remotePlaylists = listOf<PlaylistRemoteEntity?>()
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<PlaylistMetadataEntry?>()
val remotePlaylists = listOf<PlaylistRemoteEntity?>(
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?>(
PlaylistMetadataEntry(1, "name1", "", 0, false, -1, 1),
PlaylistMetadataEntry(2, "name2", "", 1, false, -1, 1)
)
val remotePlaylists = listOf<PlaylistRemoteEntity?>(
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?>(
PlaylistMetadataEntry(1, "name1", "", 1, false, -1, 1),
PlaylistMetadataEntry(2, "name2", "", 3, false, -1, 1)
)
val remotePlaylists = listOf<PlaylistRemoteEntity?>(
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)
}
}

View File

@@ -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
}

View File

@@ -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" }