diff --git a/src/main/kotlin/dev/typetype/server/services/HistoryProgressMapper.kt b/src/main/kotlin/dev/typetype/server/services/HistoryProgressMapper.kt new file mode 100644 index 0000000..795ca1e --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/HistoryProgressMapper.kt @@ -0,0 +1,46 @@ +package dev.typetype.server.services + +import dev.typetype.server.db.tables.HistoryTable +import dev.typetype.server.db.tables.ProgressTable +import dev.typetype.server.models.HistoryItem +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.and +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.inList +import org.jetbrains.exposed.v1.jdbc.selectAll + +internal object HistoryProgressMapper { + fun toHistoryItems(userId: String, rows: List): List { + val items = rows.map { it.toHistoryItem() } + val savedProgress = savedProgressSeconds(userId, items.map { it.url }) + return items.map { it.withSavedProgress(savedProgress[it.url]) } + } + + fun savedProgressSeconds(userId: String, videoUrl: String): Long? = savedProgressSeconds(userId, listOf(videoUrl))[videoUrl] + + fun savedProgressSeconds(userId: String, videoUrls: List): Map { + val urls = videoUrls.distinct() + if (urls.isEmpty()) return emptyMap() + return ProgressTable.selectAll() + .where { (ProgressTable.userId eq userId) and (ProgressTable.videoUrl inList urls) } + .associate { it[ProgressTable.videoUrl] to it[ProgressTable.position].toSeconds() } + } + + private fun HistoryItem.withSavedProgress(savedProgress: Long?): HistoryItem = + copy(progress = maxOf(progress, savedProgress ?: 0L)) + + private fun Long.toSeconds(): Long = coerceAtLeast(0L) / 1_000L + + private fun ResultRow.toHistoryItem(): HistoryItem = HistoryItem( + id = this[HistoryTable.id], + url = this[HistoryTable.url], + title = this[HistoryTable.title], + thumbnail = this[HistoryTable.thumbnail], + channelName = this[HistoryTable.channelName], + channelUrl = this[HistoryTable.channelUrl], + channelAvatar = this[HistoryTable.channelAvatar], + duration = this[HistoryTable.duration], + progress = this[HistoryTable.progress], + watchedAt = this[HistoryTable.watchedAt], + ) +} diff --git a/src/main/kotlin/dev/typetype/server/services/HistoryService.kt b/src/main/kotlin/dev/typetype/server/services/HistoryService.kt index 53a53c3..1b9276c 100644 --- a/src/main/kotlin/dev/typetype/server/services/HistoryService.kt +++ b/src/main/kotlin/dev/typetype/server/services/HistoryService.kt @@ -4,7 +4,6 @@ import dev.typetype.server.db.DatabaseFactory import dev.typetype.server.db.tables.HistoryTable import dev.typetype.server.models.HistoryItem import org.jetbrains.exposed.v1.core.LowerCase -import org.jetbrains.exposed.v1.core.ResultRow import org.jetbrains.exposed.v1.core.SortOrder import org.jetbrains.exposed.v1.core.and import org.jetbrains.exposed.v1.core.eq @@ -32,7 +31,8 @@ class HistoryService( if (from != null) query.andWhere { HistoryTable.watchedAt greaterEq from } if (to != null) query.andWhere { HistoryTable.watchedAt less to } val total = query.count() - val items = query.orderBy(HistoryTable.watchedAt to SortOrder.DESC, HistoryTable.id to SortOrder.DESC).limit(limit).offset(offset.toLong()).map { it.toHistoryItem() } + val rows = query.orderBy(HistoryTable.watchedAt to SortOrder.DESC, HistoryTable.id to SortOrder.DESC).limit(limit).offset(offset.toLong()).toList() + val items = HistoryProgressMapper.toHistoryItems(userId, rows) items to total } @@ -48,6 +48,7 @@ class HistoryService( Triple(UUID.randomUUID().toString(), item, watchedAt) } DatabaseFactory.query { + val progressByUrl = HistoryProgressMapper.savedProgressSeconds(userId, rows.map { it.second.url }) HistoryTable.batchInsert(data = rows, shouldReturnGeneratedValues = false) { (id, item, watchedAt) -> this[HistoryTable.id] = id this[HistoryTable.userId] = userId @@ -58,7 +59,7 @@ class HistoryService( this[HistoryTable.channelUrl] = item.channelUrl this[HistoryTable.channelAvatar] = item.channelAvatar this[HistoryTable.duration] = item.duration - this[HistoryTable.progress] = item.progress + this[HistoryTable.progress] = maxOf(item.progress, progressByUrl[item.url] ?: 0L) this[HistoryTable.watchedAt] = watchedAt } } @@ -73,6 +74,7 @@ class HistoryService( private suspend fun insert(userId: String, item: HistoryItem, watchedAt: Long): HistoryItem { val id = UUID.randomUUID().toString() + val progress = DatabaseFactory.query { maxOf(item.progress, HistoryProgressMapper.savedProgressSeconds(userId, item.url) ?: 0L) } DatabaseFactory.query { HistoryTable.insert { it[HistoryTable.id] = id @@ -84,11 +86,11 @@ class HistoryService( it[channelUrl] = item.channelUrl it[channelAvatar] = item.channelAvatar it[duration] = item.duration - it[progress] = item.progress + it[HistoryTable.progress] = progress it[HistoryTable.watchedAt] = watchedAt } } - val ratio = if (item.duration > 0) item.progress.toDouble() / item.duration.toDouble() else 0.0 + val ratio = if (item.duration > 0) progress.toDouble() / item.duration.toDouble() else 0.0 if (privacyService.isPersonalizationEnabled(userId)) { eventService?.add( userId = userId, @@ -97,11 +99,9 @@ class HistoryService( uploaderUrl = item.channelUrl, title = item.title, watchRatio = ratio.coerceIn(0.0, 1.0), - watchDurationMs = item.progress * 1_000L, + watchDurationMs = progress * 1_000L, ) } - return item.copy(id = id, watchedAt = watchedAt) + return item.copy(id = id, progress = progress, watchedAt = watchedAt) } - - private fun ResultRow.toHistoryItem() = HistoryItem(id = this[HistoryTable.id], url = this[HistoryTable.url], title = this[HistoryTable.title], thumbnail = this[HistoryTable.thumbnail], channelName = this[HistoryTable.channelName], channelUrl = this[HistoryTable.channelUrl], channelAvatar = this[HistoryTable.channelAvatar], duration = this[HistoryTable.duration], progress = this[HistoryTable.progress], watchedAt = this[HistoryTable.watchedAt]) } diff --git a/src/test/kotlin/dev/typetype/server/HistoryProgressRoutesTest.kt b/src/test/kotlin/dev/typetype/server/HistoryProgressRoutesTest.kt new file mode 100644 index 0000000..36f463a --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/HistoryProgressRoutesTest.kt @@ -0,0 +1,87 @@ +package dev.typetype.server + +import dev.typetype.server.models.HistoryItem +import dev.typetype.server.routes.historyRoutes +import dev.typetype.server.routes.progressRoutes +import dev.typetype.server.services.AuthService +import dev.typetype.server.services.HistoryService +import dev.typetype.server.services.ProgressService +import io.ktor.client.request.get +import io.ktor.client.request.headers +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.application.install +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation +import io.ktor.server.routing.routing +import io.ktor.server.testing.ApplicationTestBuilder +import io.ktor.server.testing.testApplication +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class HistoryProgressRoutesTest { + private val historyService = HistoryService() + private val progressService = ProgressService() + private val auth = AuthService.fixed(TEST_USER_ID) + + companion object { + private const val VIDEO_URL = "https://yt.test/watch?v=history-progress" + private const val ENCODED_VIDEO_URL = "https%3A%2F%2Fyt.test%2Fwatch%3Fv%3Dhistory-progress" + + @BeforeAll + @JvmStatic + fun initDb(): Unit = TestDatabase.setup() + } + + @BeforeEach + fun clean(): Unit = TestDatabase.truncateAll() + + private fun withApp(block: suspend ApplicationTestBuilder.() -> Unit): Unit = testApplication { + application { + install(ContentNegotiation) { json() } + routing { + historyRoutes(historyService, auth) + progressRoutes(progressService, auth) + } + } + block() + } + + @Test + fun `GET history returns saved progress converted to seconds`(): Unit = withApp { + historyService.add(TEST_USER_ID, history(progress = 0L)) + progressService.upsert(TEST_USER_ID, VIDEO_URL, 12_345L) + val progress = client.get("/progress?url=$ENCODED_VIDEO_URL") { headers.append(HttpHeaders.Authorization, "Bearer test-jwt") } + val history = client.get("/history") { headers.append(HttpHeaders.Authorization, "Bearer test-jwt") } + val historyItems = Json.decodeFromString>(history.bodyAsText()) + assertEquals(HttpStatusCode.OK, progress.status) + assertEquals(true, progress.bodyAsText().contains("\"position\":12345")) + assertEquals(12L, historyItems.single().progress) + } + + @Test + fun `POST history keeps newer saved progress instead of zero`(): Unit = withApp { + progressService.upsert(TEST_USER_ID, VIDEO_URL, 45_000L) + val response = client.post("/history") { + headers.append(HttpHeaders.Authorization, "Bearer test-jwt") + headers.append(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody(historyBody(progress = 0L)) + } + val created = Json.decodeFromString(response.bodyAsText()) + val listed = Json.decodeFromString>(client.get("/history") { headers.append(HttpHeaders.Authorization, "Bearer test-jwt") }.bodyAsText()) + assertEquals(HttpStatusCode.Created, response.status) + assertEquals(45L, created.progress) + assertEquals(45L, listed.single().progress) + } + + private fun history(progress: Long): HistoryItem = HistoryItem(url = VIDEO_URL, title = "Video", thumbnail = "", channelName = "Channel", channelUrl = "", duration = 120L, progress = progress) + + private fun historyBody(progress: Long): String = """{"url":"$VIDEO_URL","title":"Video","thumbnail":"","channelName":"Channel","channelUrl":"","duration":120,"progress":$progress}""" +}