diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.kt index 914bbdb6b..a272b4c04 100644 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.kt +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.kt @@ -13,6 +13,7 @@ import androidx.room3.Transaction import androidx.room3.rxjava3.RxDaoReturnTypeConverters import io.reactivex.rxjava3.core.Flowable import org.schabi.newpipe.database.BasicDAO +import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry import org.schabi.newpipe.database.playlist.model.PlaylistEntity @Dao @@ -48,4 +49,10 @@ interface PlaylistDAO : BasicDAO { return playlist.uid } } + + @Transaction + fun updatePlaylists(updateItems: List, deletedItems: List) { + deletedItems.forEach { uid -> deletePlaylist(uid) } + updateItems.forEach { item -> upsertPlaylist(PlaylistEntity(item)) } + } } diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.kt index ac2ec21d9..27f317e92 100644 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.kt +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.kt @@ -136,4 +136,10 @@ interface PlaylistStreamDAO : BasicDAO { """ ) fun getPlaylistDuplicatesMetadata(streamUrl: String): Flowable> + + @Transaction + fun updatePlaylistStreams(playlistId: Long, joinEntities: List) { + deleteBatch(playlistId) + insertAll(joinEntities) + } } diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java index 32fa8bf60..73f66b5c9 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java @@ -65,7 +65,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder { if (DependentPreferenceHelper .getPositionsInListsEnabled(itemProgressView.getContext())) { state2 = historyRecordManager.loadStreamState(infoItem) - .blockingGet()[0]; + .blockingGet(); } if (state2 != null) { itemProgressView.setVisibility(View.VISIBLE); @@ -120,7 +120,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder { if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext())) { state = historyRecordManager .loadStreamState(infoItem) - .blockingGet()[0]; + .blockingGet(); } if (state != null && item.getDuration() > 0 && !StreamTypeUtil.isLiveStream(item.getStreamType())) { diff --git a/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java b/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java deleted file mode 100644 index ed3cf548f..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java +++ /dev/null @@ -1,318 +0,0 @@ -package org.schabi.newpipe.local.history; - -/* - * Copyright (C) Mauricio Colli 2018 - * HistoryRecordManager.java is part of NewPipe. - * - * NewPipe is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * NewPipe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with NewPipe. If not, see . - */ - -import android.content.Context; -import android.content.SharedPreferences; - -import androidx.annotation.NonNull; -import androidx.preference.PreferenceManager; - -import org.schabi.newpipe.NewPipeDatabase; -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.AppDatabase; -import org.schabi.newpipe.database.LocalItem; -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.history.model.StreamHistoryEntry; -import org.schabi.newpipe.database.playlist.PlaylistStreamEntry; -import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity; -import org.schabi.newpipe.database.stream.StreamStatisticsEntry; -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.extractor.InfoItem; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.local.feed.FeedViewModel; -import org.schabi.newpipe.player.playqueue.PlayQueueItem; -import org.schabi.newpipe.util.ExtractorHelper; - -import java.time.OffsetDateTime; -import java.time.ZoneOffset; -import java.util.ArrayList; -import java.util.List; - -import io.reactivex.rxjava3.core.Completable; -import io.reactivex.rxjava3.core.Flowable; -import io.reactivex.rxjava3.core.Maybe; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public class HistoryRecordManager { - private final AppDatabase database; - private final StreamDAO streamTable; - private final StreamHistoryDAO streamHistoryTable; - private final SearchHistoryDAO searchHistoryTable; - private final StreamStateDAO streamStateTable; - private final SharedPreferences sharedPreferences; - private final String searchHistoryKey; - private final String streamHistoryKey; - - public HistoryRecordManager(final Context context) { - database = NewPipeDatabase.getInstance(context); - streamTable = database.streamDAO(); - streamHistoryTable = database.streamHistoryDAO(); - searchHistoryTable = database.searchHistoryDAO(); - streamStateTable = database.streamStateDAO(); - sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); - searchHistoryKey = context.getString(R.string.enable_search_history_key); - streamHistoryKey = context.getString(R.string.enable_watch_history_key); - } - - /////////////////////////////////////////////////////// - // Watch History - /////////////////////////////////////////////////////// - - /** - * Marks a stream item as watched such that it is hidden from the feed if watched videos are - * hidden. Adds a history entry and updates the stream progress to 100%. - * - * @see FeedViewModel#setSaveShowPlayedItems - * @param info the item to mark as watched - * @return a Maybe containing the ID of the item if successful - */ - public Maybe markAsWatched(final StreamInfoItem info) { - if (!isStreamHistoryEnabled()) { - return Maybe.empty(); - } - - final OffsetDateTime currentTime = OffsetDateTime.now(ZoneOffset.UTC); - return Maybe.fromCallable(() -> database.runInTransaction(() -> { - final long streamId; - final long duration; - // Duration will not exist if the item was loaded with fast mode, so fetch it if empty - if (info.getDuration() < 0) { - final StreamInfo completeInfo = ExtractorHelper.getStreamInfo( - info.getServiceId(), - info.getUrl(), - false - ) - .subscribeOn(Schedulers.io()) - .blockingGet(); - duration = completeInfo.getDuration(); - streamId = streamTable.upsert(new StreamEntity(completeInfo)); - } else { - duration = info.getDuration(); - streamId = streamTable.upsert(new StreamEntity(info)); - } - - // Update the stream progress to the full duration of the video - final StreamStateEntity entity = new StreamStateEntity( - streamId, - duration * 1000 - ); - streamStateTable.upsert(entity); - - // Add a history entry - final StreamHistoryEntity latestEntry = streamHistoryTable.getLatestEntry(streamId); - if (latestEntry == null) { - // never actually viewed: add history entry but with 0 views - return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime, 0)); - } else { - return 0L; - } - })).subscribeOn(Schedulers.io()); - } - - public Maybe onViewed(final StreamInfo info) { - if (!isStreamHistoryEnabled()) { - return Maybe.empty(); - } - - final OffsetDateTime currentTime = OffsetDateTime.now(ZoneOffset.UTC); - return Maybe.fromCallable(() -> database.runInTransaction(() -> { - final long streamId = streamTable.upsert(new StreamEntity(info)); - final StreamHistoryEntity latestEntry = streamHistoryTable.getLatestEntry(streamId); - - if (latestEntry != null) { - streamHistoryTable.delete(latestEntry); - latestEntry.setAccessDate(currentTime); - latestEntry.setRepeatCount(latestEntry.getRepeatCount() + 1); - return streamHistoryTable.insert(latestEntry); - } else { - // just viewed for the first time: set 1 view - return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime, 1)); - } - })).subscribeOn(Schedulers.io()); - } - - public Completable deleteStreamHistoryAndState(final long streamId) { - return Completable.fromAction(() -> { - streamStateTable.deleteState(streamId); - streamHistoryTable.deleteStreamHistory(streamId); - }).subscribeOn(Schedulers.io()); - } - - public Single deleteWholeStreamHistory() { - return Single.fromCallable(streamHistoryTable::deleteAll) - .subscribeOn(Schedulers.io()); - } - - public Single deleteCompleteStreamStateHistory() { - return Single.fromCallable(streamStateTable::deleteAll) - .subscribeOn(Schedulers.io()); - } - - public Flowable> getStreamHistorySortedById() { - return streamHistoryTable.getHistorySortedById().subscribeOn(Schedulers.io()); - } - - public Flowable> getStreamStatistics() { - return streamHistoryTable.getStatistics().subscribeOn(Schedulers.io()); - } - - private boolean isStreamHistoryEnabled() { - return sharedPreferences.getBoolean(streamHistoryKey, false); - } - - /////////////////////////////////////////////////////// - // Search History - /////////////////////////////////////////////////////// - - public Maybe onSearched(final int serviceId, final String search) { - if (!isSearchHistoryEnabled()) { - return Maybe.empty(); - } - - final OffsetDateTime currentTime = OffsetDateTime.now(ZoneOffset.UTC); - final SearchHistoryEntry newEntry = new SearchHistoryEntry(currentTime, serviceId, search); - - return Maybe.fromCallable(() -> database.runInTransaction(() -> { - final SearchHistoryEntry latestEntry = searchHistoryTable.getLatestEntry(); - if (latestEntry != null && latestEntry.hasEqualValues(newEntry)) { - latestEntry.setCreationDate(currentTime); - return (long) searchHistoryTable.update(latestEntry); - } else { - return searchHistoryTable.insert(newEntry); - } - })).subscribeOn(Schedulers.io()); - } - - public Single deleteSearchHistory(final String search) { - return Single.fromCallable(() -> searchHistoryTable.deleteAllWhereQuery(search)) - .subscribeOn(Schedulers.io()); - } - - public Single deleteCompleteSearchHistory() { - return Single.fromCallable(searchHistoryTable::deleteAll) - .subscribeOn(Schedulers.io()); - } - - public Flowable> getRelatedSearches(final String query, - final int similarQueryLimit, - final int uniqueQueryLimit) { - return query.length() > 0 - ? searchHistoryTable.getSimilarEntries(query, similarQueryLimit) - : searchHistoryTable.getUniqueEntries(uniqueQueryLimit); - } - - private boolean isSearchHistoryEnabled() { - return sharedPreferences.getBoolean(searchHistoryKey, false); - } - - /////////////////////////////////////////////////////// - // Stream State History - /////////////////////////////////////////////////////// - - public Maybe loadStreamState(final PlayQueueItem queueItem) { - return queueItem.getStream() - .map(info -> streamTable.upsert(new StreamEntity(info))) - .flatMapPublisher(streamStateTable::getState) - .firstElement() - .flatMap(list -> list.isEmpty() ? Maybe.empty() : Maybe.just(list.get(0))) - .filter(state -> state.isValid(queueItem.getDuration())) - .subscribeOn(Schedulers.io()); - } - - public Maybe loadStreamState(final StreamInfo info) { - return Single.fromCallable(() -> streamTable.upsert(new StreamEntity(info))) - .flatMapPublisher(streamStateTable::getState) - .firstElement() - .flatMap(list -> list.isEmpty() ? Maybe.empty() : Maybe.just(list.get(0))) - .filter(state -> state.isValid(info.getDuration())) - .subscribeOn(Schedulers.io()); - } - - public Completable saveStreamState(@NonNull final StreamInfo info, final long progressMillis) { - return Completable.fromAction(() -> database.runInTransaction(() -> { - final long streamId = streamTable.upsert(new StreamEntity(info)); - final StreamStateEntity state = new StreamStateEntity(streamId, progressMillis); - if (state.isValid(info.getDuration())) { - streamStateTable.upsert(state); - } - })).subscribeOn(Schedulers.io()); - } - - public Single loadStreamState(final InfoItem info) { - return Single.fromCallable(() -> { - final List entities = streamTable - .getStream(info.getServiceId(), info.getUrl()).blockingFirst(); - if (entities.isEmpty()) { - return new StreamStateEntity[]{null}; - } - final List states = streamStateTable - .getState(entities.get(0).getUid()).blockingFirst(); - if (states.isEmpty()) { - return new StreamStateEntity[]{null}; - } - return new StreamStateEntity[]{states.get(0)}; - }).subscribeOn(Schedulers.io()); - } - - public Single> loadLocalStreamStateBatch( - final List items) { - return Single.fromCallable(() -> { - final List result = new ArrayList<>(items.size()); - for (final LocalItem item : items) { - final long streamId; - if (item instanceof StreamStatisticsEntry) { - streamId = ((StreamStatisticsEntry) item).getStreamId(); - } else if (item instanceof PlaylistStreamEntity) { - streamId = ((PlaylistStreamEntity) item).getStreamUid(); - } else if (item instanceof PlaylistStreamEntry) { - streamId = ((PlaylistStreamEntry) item).getStreamId(); - } else { - result.add(null); - continue; - } - final List states = streamStateTable.getState(streamId) - .blockingFirst(); - if (states.isEmpty()) { - result.add(null); - } else { - result.add(states.get(0)); - } - } - return result; - }).subscribeOn(Schedulers.io()); - } - - /////////////////////////////////////////////////////// - // Utility - /////////////////////////////////////////////////////// - - public Single removeOrphanedRecords() { - return Single.fromCallable(streamTable::deleteOrphans).subscribeOn(Schedulers.io()); - } - -} diff --git a/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.kt b/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.kt new file mode 100644 index 000000000..5976ec148 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.kt @@ -0,0 +1,292 @@ +/* + * SPDX-FileCopyrightText: 2018-2023 NewPipe contributors + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package org.schabi.newpipe.local.history + +import android.content.Context +import androidx.preference.PreferenceManager +import androidx.room3.withWriteTransaction +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.core.Maybe +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import java.time.OffsetDateTime +import java.time.ZoneOffset +import kotlinx.coroutines.runBlocking +import org.schabi.newpipe.NewPipeDatabase +import org.schabi.newpipe.R +import org.schabi.newpipe.database.LocalItem +import org.schabi.newpipe.database.history.model.SearchHistoryEntry +import org.schabi.newpipe.database.history.model.StreamHistoryEntity +import org.schabi.newpipe.database.history.model.StreamHistoryEntry +import org.schabi.newpipe.database.playlist.PlaylistStreamEntry +import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity +import org.schabi.newpipe.database.stream.StreamStatisticsEntry +import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.database.stream.model.StreamStateEntity +import org.schabi.newpipe.extractor.InfoItem +import org.schabi.newpipe.extractor.stream.StreamInfo +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.local.feed.FeedViewModel +import org.schabi.newpipe.player.playqueue.PlayQueueItem +import org.schabi.newpipe.util.ExtractorHelper + +class HistoryRecordManager(context: Context) { + + private val database = NewPipeDatabase.getInstance(context) + private val streamTable = database.streamDAO() + private val streamHistoryTable = database.streamHistoryDAO() + private val searchHistoryTable = database.searchHistoryDAO() + private val streamStateTable = database.streamStateDAO() + + private val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) + private val searchHistoryKey = context.getString(R.string.enable_search_history_key) + private val streamHistoryKey = context.getString(R.string.enable_watch_history_key) + + private val isSearchHistoryEnabled: Boolean + get() = sharedPreferences.getBoolean(searchHistoryKey, false) + + private val isStreamHistoryEnabled: Boolean + get() = sharedPreferences.getBoolean(streamHistoryKey, false) + + ///////////////////////////////////////////////////////// + // Watch History + ///////////////////////////////////////////////////////// + + /** + * Marks a stream item as watched such that it is hidden from the feed if watched videos are + * hidden. Adds a history entry and updates the stream progress to 100%. + * + * @see FeedViewModel.setSaveShowPlayedItems + * @param info the item to mark as watched + * @return a Maybe containing the ID of the item if successful + */ + fun markAsWatched(info: StreamInfoItem): Maybe { + if (!isStreamHistoryEnabled) { + return Maybe.empty() + } + + val currentTime = OffsetDateTime.now(ZoneOffset.UTC) + + return Maybe.fromCallable { + runBlocking { + database.withWriteTransaction { + val streamId: Long + val duration: Long + + // Duration will not exist if the item was loaded with fast mode, so fetch it + // if empty + if (info.duration < 0) { + val completeInfo = ExtractorHelper.getStreamInfo( + info.serviceId, + info.url, + false + ).subscribeOn(Schedulers.io()).blockingGet() + + duration = completeInfo.duration + streamId = streamTable.upsert(StreamEntity(completeInfo)) + } else { + duration = info.duration + streamId = streamTable.upsert(StreamEntity(info)) + } + + // Update the stream progress to the full duration of the video + val entity = StreamStateEntity( + streamId, + duration * 1000 + ) + streamStateTable.upsert(entity) + + // Add a history entry + val latestEntry = streamHistoryTable.getLatestEntry(streamId) + if (latestEntry == null) { + // never actually viewed: add history entry but with 0 views + streamHistoryTable.insert(StreamHistoryEntity(streamId, currentTime, 0)) + } else { + 0L + } + } + } + }.subscribeOn(Schedulers.io()) + } + + fun onViewed(info: StreamInfo): Maybe { + if (!isStreamHistoryEnabled) { + return Maybe.empty() + } + + val currentTime = OffsetDateTime.now(ZoneOffset.UTC) + return Maybe.fromCallable { + runBlocking { + database.withWriteTransaction { + val streamId = streamTable.upsert(StreamEntity(info)) + val latestEntry = streamHistoryTable.getLatestEntry(streamId) + + if (latestEntry != null) { + streamHistoryTable.delete(latestEntry) + latestEntry.accessDate = currentTime + latestEntry.repeatCount = latestEntry.repeatCount + 1 + streamHistoryTable.insert(latestEntry) + } else { + // just viewed for the first time: set 1 view + streamHistoryTable.insert(StreamHistoryEntity(streamId, currentTime, 1)) + } + } + } + }.subscribeOn(Schedulers.io()) + } + + fun deleteStreamHistoryAndState(streamId: Long): Completable { + return Completable.fromAction { + streamStateTable.deleteState(streamId) + streamHistoryTable.deleteStreamHistory(streamId) + }.subscribeOn(Schedulers.io()) + } + + fun deleteWholeStreamHistory(): Single { + return Single.fromCallable { streamHistoryTable.deleteAll() } + .subscribeOn(Schedulers.io()) + } + + fun deleteCompleteStreamStateHistory(): Single { + return Single.fromCallable { streamStateTable.deleteAll() } + .subscribeOn(Schedulers.io()) + } + + fun getStreamHistorySortedById(): Flowable> { + return streamHistoryTable.historySortedById.subscribeOn(Schedulers.io()) + } + + fun getStreamStatistics(): Flowable> { + return streamHistoryTable.getStatistics().subscribeOn(Schedulers.io()) + } + + ///////////////////////////////////////////////////////// + // Search History + ///////////////////////////////////////////////////////// + + fun onSearched(serviceId: Int, search: String): Maybe { + if (!isSearchHistoryEnabled) { + return Maybe.empty() + } + + val currentTime = OffsetDateTime.now(ZoneOffset.UTC) + val newEntry = SearchHistoryEntry(currentTime, serviceId, search) + + return Maybe.fromCallable { + runBlocking { + database.withWriteTransaction { + val latestEntry = searchHistoryTable.latestEntry + if (latestEntry != null && latestEntry.hasEqualValues(newEntry)) { + latestEntry.creationDate = currentTime + searchHistoryTable.update(latestEntry).toLong() + } else { + searchHistoryTable.insert(newEntry) + } + } + } + }.subscribeOn(Schedulers.io()) + } + + fun deleteSearchHistory(search: String): Single { + return Single.fromCallable { searchHistoryTable.deleteAllWhereQuery(search) } + .subscribeOn(Schedulers.io()) + } + + fun deleteCompleteSearchHistory(): Single { + return Single.fromCallable { searchHistoryTable.deleteAll() } + .subscribeOn(Schedulers.io()) + } + + fun getRelatedSearches( + query: String, + similarQueryLimit: Int, + uniqueQueryLimit: Int + ): Flowable> { + return if (query.isNotEmpty()) { + searchHistoryTable.getSimilarEntries(query, similarQueryLimit) + } else { + searchHistoryTable.getUniqueEntries(uniqueQueryLimit) + } + } + + ///////////////////////////////////////////////////////// + // Stream State History + ///////////////////////////////////////////////////////// + + fun loadStreamState(queueItem: PlayQueueItem): Maybe { + return queueItem.stream + .map { info -> streamTable.upsert(StreamEntity(info)) } + .flatMapPublisher { streamId -> streamStateTable.getState(streamId) } + .firstElement() + .flatMap { list -> if (list.isEmpty()) Maybe.empty() else Maybe.just(list[0]) } + .filter { state -> state.isValid(queueItem.duration) } + .subscribeOn(Schedulers.io()) + } + + fun loadStreamState(info: StreamInfo): Maybe { + return Single.fromCallable { streamTable.upsert(StreamEntity(info)) } + .flatMapPublisher { streamId -> streamStateTable.getState(streamId) } + .firstElement() + .flatMap { list -> if (list.isEmpty()) Maybe.empty() else Maybe.just(list[0]) } + .filter { state -> state.isValid(info.duration) } + .subscribeOn(Schedulers.io()) + } + + fun saveStreamState(info: StreamInfo, progressMillis: Long): Completable { + return Completable.fromAction { + runBlocking { + database.withWriteTransaction { + val streamId = streamTable.upsert(StreamEntity(info)) + val state = StreamStateEntity(streamId, progressMillis) + if (state.isValid(info.duration)) { + streamStateTable.upsert(state) + } + } + } + }.subscribeOn(Schedulers.io()) + } + + fun loadStreamState(info: InfoItem): Maybe { + return Maybe.fromCallable { + streamTable.getStream(info.serviceId.toLong(), info.url) + .blockingFirst() + .firstOrNull() + ?.let { entity -> + streamStateTable.getState(entity.uid) + .blockingFirst() + .firstOrNull() + } + }.subscribeOn(Schedulers.io()) + } + + fun loadLocalStreamStateBatch(items: List): Single> { + return Single.fromCallable { + items.map { item -> + val streamId = when (item) { + is StreamStatisticsEntry -> item.streamId + is PlaylistStreamEntity -> item.streamUid + is PlaylistStreamEntry -> item.streamId + else -> return@map null + } + + streamStateTable.getState(streamId) + .blockingFirst() + .firstOrNull() + } + }.subscribeOn(Schedulers.io()) + } + + ///////////////////////////////////////////////////////// + // Utility + ///////////////////////////////////////////////////////// + + fun removeOrphanedRecords(): Single { + return Single.fromCallable { streamTable.deleteOrphans() } + .subscribeOn(Schedulers.io()) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java deleted file mode 100644 index 1480735fb..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java +++ /dev/null @@ -1,189 +0,0 @@ -package org.schabi.newpipe.local.playlist; - -import androidx.annotation.Nullable; - -import org.schabi.newpipe.database.AppDatabase; -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.dao.PlaylistDAO; -import org.schabi.newpipe.database.playlist.dao.PlaylistStreamDAO; -import org.schabi.newpipe.database.playlist.model.PlaylistEntity; -import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity; -import org.schabi.newpipe.database.stream.dao.StreamDAO; -import org.schabi.newpipe.database.stream.model.StreamEntity; - -import java.util.ArrayList; -import java.util.List; - -import io.reactivex.rxjava3.core.Completable; -import io.reactivex.rxjava3.core.Flowable; -import io.reactivex.rxjava3.core.Maybe; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public class LocalPlaylistManager { - private static final long THUMBNAIL_ID_LEAVE_UNCHANGED = -2; - - private final AppDatabase database; - private final StreamDAO streamTable; - private final PlaylistDAO playlistTable; - private final PlaylistStreamDAO playlistStreamTable; - - public LocalPlaylistManager(final AppDatabase db) { - database = db; - streamTable = db.streamDAO(); - playlistTable = db.playlistDAO(); - playlistStreamTable = db.playlistStreamDAO(); - } - - public Maybe> createPlaylist(final String name, final List streams) { - // Disallow creation of empty playlists - if (streams.isEmpty()) { - return Maybe.empty(); - } - - // Save to the database directly. - // Make sure the new playlist is always on the top of bookmark. - // The index will be reassigned to non-negative number in BookmarkFragment. - return Maybe.fromCallable(() -> database.runInTransaction(() -> { - final List streamIds = streamTable.upsertAll(streams); - final PlaylistEntity newPlaylist = new PlaylistEntity(name, false, - streamIds.get(0), -1); - - return insertJoinEntities(playlistTable.insert(newPlaylist), - streamIds, 0); - } - )).subscribeOn(Schedulers.io()); - } - - public Maybe> appendToPlaylist(final long playlistId, - final List streams) { - return playlistStreamTable.getMaximumIndexOf(playlistId) - .firstElement() - .map(maxJoinIndex -> database.runInTransaction(() -> { - final List streamIds = streamTable.upsertAll(streams); - return insertJoinEntities(playlistId, streamIds, maxJoinIndex + 1); - } - )).subscribeOn(Schedulers.io()); - } - - private List insertJoinEntities(final long playlistId, final List streamIds, - final int indexOffset) { - - final List joinEntities = new ArrayList<>(streamIds.size()); - - for (int index = 0; index < streamIds.size(); index++) { - joinEntities.add(new PlaylistStreamEntity(playlistId, streamIds.get(index), - index + indexOffset)); - } - return playlistStreamTable.insertAll(joinEntities); - } - - public Completable updateJoin(final long playlistId, final List streamIds) { - final List joinEntities = new ArrayList<>(streamIds.size()); - for (int i = 0; i < streamIds.size(); i++) { - joinEntities.add(new PlaylistStreamEntity(playlistId, streamIds.get(i), i)); - } - - return Completable.fromRunnable(() -> database.runInTransaction(() -> { - playlistStreamTable.deleteBatch(playlistId); - playlistStreamTable.insertAll(joinEntities); - })).subscribeOn(Schedulers.io()); - } - - public Completable updatePlaylists(final List updateItems, - final List deletedItems) { - final List items = new ArrayList<>(updateItems.size()); - for (final PlaylistMetadataEntry item : updateItems) { - items.add(new PlaylistEntity(item)); - } - return Completable.fromRunnable(() -> database.runInTransaction(() -> { - for (final Long uid : deletedItems) { - playlistTable.deletePlaylist(uid); - } - for (final PlaylistEntity item : items) { - playlistTable.upsertPlaylist(item); - } - })).subscribeOn(Schedulers.io()); - } - - public Flowable> getDistinctPlaylistStreams(final long playlistId) { - return playlistStreamTable - .getStreamsWithoutDuplicates(playlistId).subscribeOn(Schedulers.io()); - } - - /** - * Get playlists with attached information about how many times the provided stream is already - * contained in each playlist. - * - * @param streamUrl the stream url for which to check for duplicates - * @return a list of {@link PlaylistDuplicatesEntry} - */ - public Flowable> getPlaylistDuplicates(final String streamUrl) { - return playlistStreamTable.getPlaylistDuplicatesMetadata(streamUrl) - .subscribeOn(Schedulers.io()); - } - - public Flowable> getPlaylists() { - return playlistStreamTable.getPlaylistMetadata().subscribeOn(Schedulers.io()); - } - - public Flowable> getPlaylistStreams(final long playlistId) { - return playlistStreamTable.getOrderedStreamsOf(playlistId).subscribeOn(Schedulers.io()); - } - - public Maybe renamePlaylist(final long playlistId, final String name) { - return modifyPlaylist(playlistId, name, THUMBNAIL_ID_LEAVE_UNCHANGED, false); - } - - public Maybe changePlaylistThumbnail(final long playlistId, - final long thumbnailStreamId, - final boolean isPermanent) { - return modifyPlaylist(playlistId, null, thumbnailStreamId, isPermanent); - } - - public long getPlaylistThumbnailStreamId(final long playlistId) { - return playlistTable.getPlaylist(playlistId).blockingFirst().get(0).getThumbnailStreamId(); - } - - public boolean getIsPlaylistThumbnailPermanent(final long playlistId) { - return playlistTable.getPlaylist(playlistId).blockingFirst().get(0) - .isThumbnailPermanent(); - } - - public long getAutomaticPlaylistThumbnailStreamId(final long playlistId) { - final long streamId = playlistStreamTable.getAutomaticThumbnailStreamId(playlistId) - .blockingFirst(); - if (streamId < 0) { - return PlaylistEntity.DEFAULT_THUMBNAIL_ID; - } - return streamId; - } - - private Maybe modifyPlaylist(final long playlistId, - @Nullable final String name, - final long thumbnailStreamId, - final boolean isPermanent) { - return playlistTable.getPlaylist(playlistId) - .firstElement() - .filter(playlistEntities -> !playlistEntities.isEmpty()) - .map(playlistEntities -> { - final PlaylistEntity playlist = playlistEntities.get(0); - if (name != null) { - playlist.setName(name); - } - if (thumbnailStreamId != THUMBNAIL_ID_LEAVE_UNCHANGED) { - playlist.setThumbnailStreamId(thumbnailStreamId); - playlist.setThumbnailPermanent(isPermanent); - } - return playlistTable.update(playlist); - }).subscribeOn(Schedulers.io()); - } - - public Maybe hasPlaylists() { - return playlistTable.getCount() - .firstElement() - .map(count -> count > 0) - .subscribeOn(Schedulers.io()); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.kt b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.kt new file mode 100644 index 000000000..d914ca72c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.kt @@ -0,0 +1,181 @@ +/* + * SPDX-FileCopyrightText: 2018-2024 NewPipe contributors + * SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package org.schabi.newpipe.local.playlist + +import androidx.room3.withWriteTransaction +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.core.Maybe +import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.runBlocking +import org.schabi.newpipe.database.AppDatabase +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 org.schabi.newpipe.database.stream.model.StreamEntity + +class LocalPlaylistManager(private val database: AppDatabase) { + + private val streamTable = database.streamDAO() + private val playlistTable = database.playlistDAO() + private val playlistStreamTable = database.playlistStreamDAO() + + fun createPlaylist(name: String, streams: List): Maybe> { + // Disallow creation of empty playlists + if (streams.isEmpty()) { + return Maybe.empty() + } + + // Save to the database directly. + // Make sure the new playlist is always on the top of bookmark. + // The index will be reassigned to non-negative number in BookmarkFragment. + return Maybe.fromCallable { + runBlocking { + database.withWriteTransaction { + val streamIds = streamTable.upsertAll(streams) + val newPlaylist = PlaylistEntity( + name = name, + isThumbnailPermanent = false, + thumbnailStreamId = streamIds[0], + displayIndex = -1 + ) + insertJoinEntities(playlistTable.insert(newPlaylist), streamIds, 0) + } + } + }.subscribeOn(Schedulers.io()) + } + + fun appendToPlaylist(playlistId: Long, streams: List): Maybe> { + return playlistStreamTable.getMaximumIndexOf(playlistId) + .firstElement() + .map { maxJoinIndex -> + runBlocking { + database.withWriteTransaction> { + val streamIds = streamTable.upsertAll(streams) + insertJoinEntities(playlistId, streamIds, maxJoinIndex + 1) + } + } + }.subscribeOn(Schedulers.io()) + } + + private fun insertJoinEntities( + playlistId: Long, + streamIds: List, + indexOffset: Int + ): List { + val entities = streamIds.mapIndexed { index, streamId -> + PlaylistStreamEntity(playlistId, streamId, index + indexOffset) + } + return playlistStreamTable.insertAll(entities) + } + + fun updateJoin(playlistId: Long, streamIds: List): Completable { + val joinEntities = streamIds.mapIndexed { index, streamId -> + PlaylistStreamEntity(playlistId, streamId, index) + } + + return Completable.fromAction { + playlistStreamTable.updatePlaylistStreams(playlistId, joinEntities) + }.subscribeOn(Schedulers.io()) + } + + fun updatePlaylists( + updateItems: List, + deletedItems: List + ): Completable { + return Completable.fromRunnable { + playlistTable.updatePlaylists(updateItems = updateItems, deletedItems = deletedItems) + }.subscribeOn(Schedulers.io()) + } + + fun getDistinctPlaylistStreams(playlistId: Long): Flowable> { + return playlistStreamTable + .getStreamsWithoutDuplicates(playlistId) + .subscribeOn(Schedulers.io()) + } + + /** + * Get playlists with attached information about how many times the provided stream is already + * contained in each playlist. + * + * @param streamUrl the stream url for which to check for duplicates + * @return a list of [PlaylistDuplicatesEntry] + */ + fun getPlaylistDuplicates(streamUrl: String): Flowable> { + return playlistStreamTable.getPlaylistDuplicatesMetadata(streamUrl) + .subscribeOn(Schedulers.io()) + } + + fun getPlaylists(): Flowable> { + return playlistStreamTable.getPlaylistMetadata().subscribeOn(Schedulers.io()) + } + + fun getPlaylistStreams(playlistId: Long): Flowable> { + return playlistStreamTable.getOrderedStreamsOf(playlistId).subscribeOn(Schedulers.io()) + } + + fun renamePlaylist(playlistId: Long, name: String): Maybe { + return modifyPlaylist(playlistId, name, THUMBNAIL_ID_LEAVE_UNCHANGED, false) + } + + fun changePlaylistThumbnail( + playlistId: Long, + thumbnailStreamId: Long, + isPermanent: Boolean + ): Maybe { + return modifyPlaylist(playlistId, null, thumbnailStreamId, isPermanent) + } + + fun getPlaylistThumbnailStreamId(playlistId: Long): Long { + return playlistTable.getPlaylist(playlistId).blockingFirst()[0].thumbnailStreamId + } + + fun getIsPlaylistThumbnailPermanent(playlistId: Long): Boolean { + return playlistTable.getPlaylist(playlistId).blockingFirst()[0].isThumbnailPermanent + } + + fun getAutomaticPlaylistThumbnailStreamId(playlistId: Long): Long { + val streamId = playlistStreamTable.getAutomaticThumbnailStreamId(playlistId) + .blockingFirst() + return if (streamId < 0) PlaylistEntity.DEFAULT_THUMBNAIL_ID else streamId + } + + private fun modifyPlaylist( + playlistId: Long, + name: String?, + thumbnailStreamId: Long, + isPermanent: Boolean + ): Maybe { + return playlistTable.getPlaylist(playlistId) + .firstElement() + .filter { playlistEntities -> playlistEntities.isNotEmpty() } + .map { playlistEntities -> + val playlist = playlistEntities[0] + if (name != null) { + playlist.name = name + } + if (thumbnailStreamId != THUMBNAIL_ID_LEAVE_UNCHANGED) { + playlist.thumbnailStreamId = thumbnailStreamId + playlist.isThumbnailPermanent = isPermanent + } + playlistTable.update(playlist) + }.subscribeOn(Schedulers.io()) + } + + fun hasPlaylists(): Maybe { + return playlistTable.count + .firstElement() + .map { count -> count > 0 } + .subscribeOn(Schedulers.io()) + } + + companion object { + private const val THUMBNAIL_ID_LEAVE_UNCHANGED = -2L + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistViewModel.kt b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistViewModel.kt index 954449bb4..67024df5a 100644 --- a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistViewModel.kt +++ b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistViewModel.kt @@ -43,7 +43,7 @@ class LocalPlaylistViewModel(application: Application) : AndroidViewModel(applic fun removeWatchedStreams(playlistId: Long, removePartiallyWatched: Boolean) { _workState.value = WorkState.Loading - val historyIdsMaybe = recordManager.streamHistorySortedById + val historyIdsMaybe = recordManager.getStreamHistorySortedById() .firstElement() .map { historyList -> historyList.map { it.streamId }