Skip to content

Commit b0d59b9

Browse files
committed
feat: add support for syncing watch history
1 parent 7dde8ff commit b0d59b9

21 files changed

Lines changed: 446 additions & 247 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.github.libretube.api.obj
2+
3+
data class WatchHistoryEntryMetadata(
4+
val videoId: String,
5+
val addedDate: Long,
6+
val finished: Boolean,
7+
val positionMillis: Long? = null,
8+
)
9+
10+
data class WatchHistoryEntry(
11+
val metadata: WatchHistoryEntryMetadata,
12+
val video: StreamItem
13+
)

app/src/main/java/com/github/libretube/db/DatabaseHelper.kt

Lines changed: 26 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
package com.github.libretube.db
22

33
import com.github.libretube.api.obj.StreamItem
4+
import com.github.libretube.api.obj.WatchHistoryEntry
5+
import com.github.libretube.api.obj.WatchHistoryEntryMetadata
46
import com.github.libretube.constants.PreferenceKeys
57
import com.github.libretube.db.DatabaseHolder.Database
68
import com.github.libretube.db.obj.SearchHistoryItem
7-
import com.github.libretube.db.obj.WatchHistoryItem
89
import com.github.libretube.enums.ContentFilter
910
import com.github.libretube.extensions.toID
1011
import com.github.libretube.helpers.PreferenceHelper
12+
import com.github.libretube.repo.UserDataRepositoryHelper
1113
import kotlinx.coroutines.Dispatchers
12-
import kotlinx.coroutines.runBlocking
1314
import kotlinx.coroutines.withContext
15+
import kotlin.time.Clock
1416

1517
object DatabaseHelper {
1618
private const val MAX_SEARCH_HISTORY_SIZE = 20
@@ -21,39 +23,6 @@ object DatabaseHelper {
2123
// can only mark as watched if at least 75% watched
2224
private const val RELATIVE_WATCHED_THRESHOLD = 0.75f
2325

24-
suspend fun addToWatchHistory(watchHistoryItem: WatchHistoryItem) =
25-
withContext(Dispatchers.IO) {
26-
Database.watchHistoryDao().insert(watchHistoryItem)
27-
val maxHistorySize = PreferenceHelper.getString(
28-
PreferenceKeys.WATCH_HISTORY_SIZE,
29-
"100"
30-
)
31-
if (maxHistorySize == "unlimited") {
32-
return@withContext
33-
}
34-
35-
// delete the first watch history entry if the limit is reached
36-
val historySize = Database.watchHistoryDao().getSize()
37-
if (historySize > maxHistorySize.toInt()) {
38-
Database.watchHistoryDao().delete(Database.watchHistoryDao().getOldest())
39-
}
40-
}
41-
42-
suspend fun getWatchHistoryPage(page: Int, pageSize: Int): List<WatchHistoryItem> {
43-
val watchHistoryDao = Database.watchHistoryDao()
44-
val historySize = watchHistoryDao.getSize()
45-
46-
if (historySize < pageSize * (page - 1)) return emptyList()
47-
48-
val offset = historySize - (pageSize * page)
49-
val limit = if (offset < 0) {
50-
offset + pageSize
51-
} else {
52-
pageSize
53-
}
54-
return watchHistoryDao.getN(limit, maxOf(offset, 0)).reversed()
55-
}
56-
5726
suspend fun addToSearchHistory(searchHistoryItem: SearchHistoryItem) {
5827
Database.searchHistoryDao().insert(searchHistoryItem)
5928

@@ -68,10 +37,23 @@ object DatabaseHelper {
6837
}
6938
}
7039

71-
suspend fun getWatchPosition(videoId: String) = Database.watchPositionDao().findById(videoId)?.position
72-
73-
fun getWatchPositionBlocking(videoId: String): Long? = runBlocking(Dispatchers.IO) {
74-
getWatchPosition(videoId)
40+
suspend fun getWatchPosition(videoId: String) = runCatching {
41+
UserDataRepositoryHelper.userDataRepository
42+
.getFromWatchHistory(videoId)
43+
}.getOrNull()?.metadata?.positionMillis
44+
45+
suspend fun addToWatchHistory(video: StreamItem) = runCatching {
46+
UserDataRepositoryHelper.userDataRepository.addToWatchHistory(
47+
WatchHistoryEntry(
48+
metadata = WatchHistoryEntryMetadata(
49+
videoId = video.url!!.toID(),
50+
addedDate = Clock.System.now().toEpochMilliseconds(),
51+
finished = false,
52+
positionMillis = null
53+
),
54+
video = video
55+
)
56+
)
7557
}
7658

7759
suspend fun isVideoWatched(videoId: String, duration: Long): Boolean =
@@ -99,10 +81,13 @@ object DatabaseHelper {
9981
* @param unfinished If true, only returns unfinished videos. If false, only returns finished videos.
10082
*/
10183
suspend fun filterByWatchStatus(
102-
watchHistoryItem: WatchHistoryItem,
84+
watchHistoryItem: WatchHistoryEntry,
10385
unfinished: Boolean = true
10486
): Boolean {
105-
return unfinished xor isVideoWatched(watchHistoryItem.videoId, watchHistoryItem.duration ?: 0)
87+
return unfinished xor isVideoWatched(
88+
watchHistoryItem.metadata.videoId,
89+
watchHistoryItem.video.duration ?: 0
90+
)
10691
}
10792

10893
suspend fun filterByStreamTypeAndWatchPosition(

app/src/main/java/com/github/libretube/db/obj/WatchHistoryItem.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import androidx.room.ColumnInfo
44
import androidx.room.Entity
55
import androidx.room.PrimaryKey
66
import com.github.libretube.api.obj.StreamItem
7+
import com.github.libretube.api.obj.WatchHistoryEntry
8+
import com.github.libretube.api.obj.WatchHistoryEntryMetadata
9+
import com.github.libretube.db.DatabaseHelper
10+
import com.github.libretube.db.DatabaseHolder
711
import com.github.libretube.extensions.toMillis
812
import kotlinx.datetime.LocalDate
913
import kotlinx.serialization.Serializable
@@ -20,6 +24,8 @@ data class WatchHistoryItem(
2024
@ColumnInfo var thumbnailUrl: String? = null,
2125
@ColumnInfo val duration: Long? = null,
2226
@ColumnInfo val isShort: Boolean = false
27+
28+
// TODO: store date when the video was added to the history
2329
) {
2430
val isLive get() = (duration == null) || (duration <= 0L)
2531

@@ -36,4 +42,21 @@ data class WatchHistoryItem(
3642
duration = duration,
3743
isShort = isShort
3844
)
45+
46+
suspend fun toWatchHistoryEntry(): WatchHistoryEntry {
47+
val watchPosition = DatabaseHolder.Database.watchPositionDao().findById(videoId)
48+
val isWatched = watchPosition?.position?.let {
49+
DatabaseHelper.isVideoWatched(it, duration)
50+
} ?: false
51+
52+
return WatchHistoryEntry(
53+
metadata = WatchHistoryEntryMetadata(
54+
videoId = videoId,
55+
finished = isWatched,
56+
addedDate = -1,
57+
positionMillis = watchPosition?.position,
58+
),
59+
video = toStreamItem()
60+
)
61+
}
3962
}

app/src/main/java/com/github/libretube/helpers/ImportHelper.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import com.github.libretube.api.JsonHelper
1010
import com.github.libretube.api.PlaylistsHelper
1111
import com.github.libretube.api.SubscriptionHelper
1212
import com.github.libretube.db.DatabaseHelper
13+
import com.github.libretube.db.DatabaseHolder
1314
import com.github.libretube.db.obj.WatchHistoryItem
1415
import com.github.libretube.enums.ImportFormat
1516
import com.github.libretube.extensions.TAG
@@ -356,7 +357,7 @@ object ImportHelper {
356357
}
357358

358359
for (video in videos) {
359-
DatabaseHelper.addToWatchHistory(video)
360+
DatabaseHolder.Database.watchHistoryDao().insert(video)
360361
}
361362

362363
if (videos.isEmpty()) {

app/src/main/java/com/github/libretube/helpers/PlayerHelper.kt

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,21 +39,25 @@ import com.github.libretube.api.obj.ChapterSegment
3939
import com.github.libretube.api.obj.Segment
4040
import com.github.libretube.api.obj.Streams
4141
import com.github.libretube.api.obj.Subtitle
42+
import com.github.libretube.api.obj.WatchHistoryEntryMetadata
4243
import com.github.libretube.constants.PreferenceKeys
44+
import com.github.libretube.db.DatabaseHelper
4345
import com.github.libretube.db.DatabaseHolder
4446
import com.github.libretube.db.obj.WatchPosition
4547
import com.github.libretube.enums.PlayerEvent
4648
import com.github.libretube.enums.SbSkipOptions
4749
import com.github.libretube.extensions.seekBy
4850
import com.github.libretube.extensions.togglePlayPauseState
4951
import com.github.libretube.obj.VideoStats
52+
import com.github.libretube.repo.UserDataRepositoryHelper
5053
import com.github.libretube.util.TextUtils
5154
import kotlinx.coroutines.CoroutineScope
5255
import kotlinx.coroutines.Dispatchers
5356
import kotlinx.coroutines.launch
5457
import java.util.Locale
5558
import kotlin.math.max
5659
import kotlin.math.roundToInt
60+
import kotlin.time.Clock
5761

5862
object PlayerHelper {
5963
private const val ACTION_MEDIA_CONTROL = "media_control"
@@ -868,9 +872,18 @@ object PlayerHelper {
868872
return
869873
}
870874

871-
val watchPosition = WatchPosition(videoId, player.currentPosition)
875+
val watchHistoryEntry = WatchHistoryEntryMetadata(
876+
videoId = videoId,
877+
finished = DatabaseHelper.isVideoWatched(
878+
player.currentPosition,
879+
player.duration.div(1000)
880+
),
881+
addedDate = Clock.System.now().toEpochMilliseconds(),
882+
positionMillis = player.currentPosition
883+
)
872884
CoroutineScope(Dispatchers.IO).launch {
873-
DatabaseHolder.Database.watchPositionDao().insert(watchPosition)
885+
UserDataRepositoryHelper.userDataRepository
886+
.updateWatchHistoryEntry(watchHistoryEntry)
874887
}
875888
}
876889

app/src/main/java/com/github/libretube/repo/LibreTubeSyncServerUserDataRepository.kt

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@ import com.github.libretube.api.ltsync.obj.DeleteUser
88
import com.github.libretube.api.ltsync.obj.ExtendedPlaylist
99
import com.github.libretube.api.ltsync.obj.ExtendedPublicPlaylist
1010
import com.github.libretube.api.ltsync.obj.ExtendedSubscriptionGroup
11+
import com.github.libretube.api.ltsync.obj.ExtendedWatchHistoryItem
1112
import com.github.libretube.api.ltsync.obj.LoginUser
1213
import com.github.libretube.api.ltsync.obj.RegisterUser
14+
import com.github.libretube.api.ltsync.obj.WatchHistoryItem
15+
import com.github.libretube.api.ltsync.obj.WatchedState
1316
import com.github.libretube.api.obj.Playlist
1417
import com.github.libretube.api.obj.Playlists
1518
import com.github.libretube.api.obj.StreamItem
@@ -268,6 +271,77 @@ class LibreTubeSyncServerUserDataRepository : UserDataRepository {
268271
}
269272
}
270273

274+
private fun WatchHistoryItem.toWatchHistoryEntryMetadata(videoId: String): WatchHistoryEntryMetadata {
275+
return WatchHistoryEntryMetadata(
276+
videoId = videoId,
277+
addedDate = addedDate,
278+
finished = watchedState == WatchedState.Completed,
279+
positionMillis = positionMillis?.toLong()
280+
)
281+
}
282+
283+
private fun ExtendedWatchHistoryItem.toWatchHistoryEntry(): WatchHistoryEntry {
284+
return WatchHistoryEntry(
285+
metadata = metadata.toWatchHistoryEntryMetadata(video.id),
286+
video = video.toStreamItem()
287+
)
288+
}
289+
290+
private fun WatchHistoryEntryMetadata.toWatchHistoryItem(): WatchHistoryItem {
291+
return WatchHistoryItem(
292+
addedDate = addedDate,
293+
watchedState = if (finished) WatchedState.Completed else WatchedState.Watching,
294+
positionMillis = positionMillis?.toInt()
295+
)
296+
}
297+
298+
override suspend fun getWatchHistory(page: Int): List<WatchHistoryEntry> {
299+
return tryHttpOrRaiseError {
300+
api.getWatchHistory(page).map { it.toWatchHistoryEntry() }
301+
}
302+
}
303+
304+
override suspend fun getFromWatchHistory(videoId: String): WatchHistoryEntry? {
305+
return tryHttpOrRaiseError {
306+
try {
307+
api.getFromWatchHistory(videoId).toWatchHistoryEntry()
308+
} catch (e: HttpException) {
309+
// if we get 404, the video is not in the watch history
310+
if (e.code() == 404) return@tryHttpOrRaiseError null
311+
else throw e
312+
}
313+
}
314+
}
315+
316+
override suspend fun clearWatchHistory() {
317+
tryHttpOrRaiseError {
318+
api.clearWatchHistory()
319+
}
320+
}
321+
322+
override suspend fun addToWatchHistory(watchHistoryEntry: WatchHistoryEntry) {
323+
val video = watchHistoryEntry.video.toCreateVideo()
324+
val metadata = watchHistoryEntry.metadata.toWatchHistoryItem()
325+
326+
tryHttpOrRaiseError {
327+
api.addToWatchHistory(
328+
ExtendedWatchHistoryItem(metadata, video)
329+
)
330+
}
331+
}
332+
333+
override suspend fun updateWatchHistoryEntry(metadata: WatchHistoryEntryMetadata) {
334+
tryHttpOrRaiseError {
335+
api.updateWatchHistoryEntry(metadata.videoId, metadata.toWatchHistoryItem())
336+
}
337+
}
338+
339+
override suspend fun removeFromWatchHistory(videoId: String) {
340+
tryHttpOrRaiseError {
341+
api.removeFromWatchHistory(videoId)
342+
}
343+
}
344+
271345
override suspend fun getPlaylistBookmarks(): List<PlaylistBookmark> {
272346
return tryHttpOrRaiseError {
273347
api.getPlaylistBookmarks().map {

0 commit comments

Comments
 (0)