Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<ResultRow>): List<HistoryItem> {
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<String>): Map<String, Long> {
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],
)
}
18 changes: 9 additions & 9 deletions src/main/kotlin/dev/typetype/server/services/HistoryService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand All @@ -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
Expand All @@ -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
}
}
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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])
}
87 changes: 87 additions & 0 deletions src/test/kotlin/dev/typetype/server/HistoryProgressRoutesTest.kt
Original file line number Diff line number Diff line change
@@ -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<List<HistoryItem>>(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<HistoryItem>(response.bodyAsText())
val listed = Json.decodeFromString<List<HistoryItem>>(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}"""
}