From 82d8eacc00d8b803aea8a167adb65d9cbcf0fe31 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 12 Jun 2026 13:36:17 +0200 Subject: [PATCH] feat: add playback privacy settings --- .../dev/typetype/server/db/DatabaseFactory.kt | 5 + .../server/db/tables/SettingsTable.kt | 5 + .../typetype/server/models/SettingsItem.kt | 5 + .../server/models/SponsorBlockMode.kt | 19 +++ .../server/services/SettingsService.kt | 16 +++ .../SettingsPrivacyControlsRoutesTest.kt | 111 ++++++++++++++++++ 6 files changed, 161 insertions(+) create mode 100644 src/main/kotlin/dev/typetype/server/models/SponsorBlockMode.kt create mode 100644 src/test/kotlin/dev/typetype/server/SettingsPrivacyControlsRoutesTest.kt diff --git a/src/main/kotlin/dev/typetype/server/db/DatabaseFactory.kt b/src/main/kotlin/dev/typetype/server/db/DatabaseFactory.kt index a4d7a7ac..8de8bc8f 100644 --- a/src/main/kotlin/dev/typetype/server/db/DatabaseFactory.kt +++ b/src/main/kotlin/dev/typetype/server/db/DatabaseFactory.kt @@ -69,6 +69,11 @@ object DatabaseFactory { exec("ALTER TABLE settings ADD COLUMN IF NOT EXISTS default_audio_language TEXT NOT NULL DEFAULT ''") exec("ALTER TABLE settings ADD COLUMN IF NOT EXISTS prefer_original_language BOOLEAN NOT NULL DEFAULT false") exec("ALTER TABLE settings ADD COLUMN IF NOT EXISTS enable_high_quality_playback BOOLEAN NOT NULL DEFAULT false") + exec("ALTER TABLE settings ADD COLUMN IF NOT EXISTS sponsor_block_mode TEXT NOT NULL DEFAULT 'auto_skip'") + exec("ALTER TABLE settings ADD COLUMN IF NOT EXISTS hide_home_recommendations BOOLEAN NOT NULL DEFAULT false") + exec("ALTER TABLE settings ADD COLUMN IF NOT EXISTS hide_related_videos BOOLEAN NOT NULL DEFAULT false") + exec("ALTER TABLE settings ADD COLUMN IF NOT EXISTS hide_comments BOOLEAN NOT NULL DEFAULT false") + exec("ALTER TABLE settings ADD COLUMN IF NOT EXISTS hide_shorts BOOLEAN NOT NULL DEFAULT false") exec("ALTER TABLE settings ADD COLUMN IF NOT EXISTS subscription_sync_interval INTEGER NOT NULL DEFAULT 0") exec("ALTER TABLE history ADD COLUMN IF NOT EXISTS channel_avatar TEXT NOT NULL DEFAULT ''") exec("ALTER TABLE history ADD COLUMN IF NOT EXISTS user_id TEXT NOT NULL DEFAULT ''") diff --git a/src/main/kotlin/dev/typetype/server/db/tables/SettingsTable.kt b/src/main/kotlin/dev/typetype/server/db/tables/SettingsTable.kt index 5a8140ac..cbe7b36c 100644 --- a/src/main/kotlin/dev/typetype/server/db/tables/SettingsTable.kt +++ b/src/main/kotlin/dev/typetype/server/db/tables/SettingsTable.kt @@ -14,5 +14,10 @@ object SettingsTable : Table("settings") { val defaultAudioLanguage = text("default_audio_language").default("") val preferOriginalLanguage = bool("prefer_original_language").default(false) val enableHighQualityPlayback = bool("enable_high_quality_playback").default(false) + val sponsorBlockMode = text("sponsor_block_mode").default("auto_skip") + val hideHomeRecommendations = bool("hide_home_recommendations").default(false) + val hideRelatedVideos = bool("hide_related_videos").default(false) + val hideComments = bool("hide_comments").default(false) + val hideShorts = bool("hide_shorts").default(false) override val primaryKey = PrimaryKey(userId) } diff --git a/src/main/kotlin/dev/typetype/server/models/SettingsItem.kt b/src/main/kotlin/dev/typetype/server/models/SettingsItem.kt index 503e7c34..2f5aec40 100644 --- a/src/main/kotlin/dev/typetype/server/models/SettingsItem.kt +++ b/src/main/kotlin/dev/typetype/server/models/SettingsItem.kt @@ -14,4 +14,9 @@ data class SettingsItem( val defaultAudioLanguage: String = "", val preferOriginalLanguage: Boolean = false, val enableHighQualityPlayback: Boolean = false, + val sponsorBlockMode: SponsorBlockMode = SponsorBlockMode.AUTO_SKIP, + val hideHomeRecommendations: Boolean = false, + val hideRelatedVideos: Boolean = false, + val hideComments: Boolean = false, + val hideShorts: Boolean = false, ) diff --git a/src/main/kotlin/dev/typetype/server/models/SponsorBlockMode.kt b/src/main/kotlin/dev/typetype/server/models/SponsorBlockMode.kt new file mode 100644 index 00000000..259ac8bb --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/models/SponsorBlockMode.kt @@ -0,0 +1,19 @@ +package dev.typetype.server.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class SponsorBlockMode(val storageValue: String) { + @SerialName("auto_skip") + AUTO_SKIP("auto_skip"), + + @SerialName("mark_only") + MARK_ONLY("mark_only"), + + @SerialName("disabled") + DISABLED("disabled"), +} + +fun String.toSponsorBlockMode(): SponsorBlockMode = + SponsorBlockMode.entries.firstOrNull { it.storageValue == this } ?: SponsorBlockMode.AUTO_SKIP diff --git a/src/main/kotlin/dev/typetype/server/services/SettingsService.kt b/src/main/kotlin/dev/typetype/server/services/SettingsService.kt index bb725bdf..665f3548 100644 --- a/src/main/kotlin/dev/typetype/server/services/SettingsService.kt +++ b/src/main/kotlin/dev/typetype/server/services/SettingsService.kt @@ -3,6 +3,7 @@ package dev.typetype.server.services import dev.typetype.server.db.DatabaseFactory import dev.typetype.server.db.tables.SettingsTable import dev.typetype.server.models.SettingsItem +import dev.typetype.server.models.toSponsorBlockMode import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.jdbc.insert import org.jetbrains.exposed.v1.jdbc.selectAll @@ -23,6 +24,11 @@ class SettingsService { defaultAudioLanguage = it[SettingsTable.defaultAudioLanguage], preferOriginalLanguage = it[SettingsTable.preferOriginalLanguage], enableHighQualityPlayback = it[SettingsTable.enableHighQualityPlayback], + sponsorBlockMode = it[SettingsTable.sponsorBlockMode].toSponsorBlockMode(), + hideHomeRecommendations = it[SettingsTable.hideHomeRecommendations], + hideRelatedVideos = it[SettingsTable.hideRelatedVideos], + hideComments = it[SettingsTable.hideComments], + hideShorts = it[SettingsTable.hideShorts], ) } ?: SettingsItem() } @@ -40,6 +46,11 @@ class SettingsService { it[defaultAudioLanguage] = settings.defaultAudioLanguage it[preferOriginalLanguage] = settings.preferOriginalLanguage it[enableHighQualityPlayback] = settings.enableHighQualityPlayback + it[sponsorBlockMode] = settings.sponsorBlockMode.storageValue + it[hideHomeRecommendations] = settings.hideHomeRecommendations + it[hideRelatedVideos] = settings.hideRelatedVideos + it[hideComments] = settings.hideComments + it[hideShorts] = settings.hideShorts } if (updated == 0) { SettingsTable.insert { @@ -54,6 +65,11 @@ class SettingsService { it[defaultAudioLanguage] = settings.defaultAudioLanguage it[preferOriginalLanguage] = settings.preferOriginalLanguage it[enableHighQualityPlayback] = settings.enableHighQualityPlayback + it[sponsorBlockMode] = settings.sponsorBlockMode.storageValue + it[hideHomeRecommendations] = settings.hideHomeRecommendations + it[hideRelatedVideos] = settings.hideRelatedVideos + it[hideComments] = settings.hideComments + it[hideShorts] = settings.hideShorts } } } diff --git a/src/test/kotlin/dev/typetype/server/SettingsPrivacyControlsRoutesTest.kt b/src/test/kotlin/dev/typetype/server/SettingsPrivacyControlsRoutesTest.kt new file mode 100644 index 00000000..04ca28e9 --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/SettingsPrivacyControlsRoutesTest.kt @@ -0,0 +1,111 @@ +package dev.typetype.server + +import dev.typetype.server.routes.settingsRoutes +import dev.typetype.server.services.AuthService +import dev.typetype.server.services.SettingsService +import io.ktor.client.request.get +import io.ktor.client.request.headers +import io.ktor.client.request.put +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.Assertions.assertTrue +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class SettingsPrivacyControlsRoutesTest { + private val service = SettingsService() + private val auth = AuthService.fixed(TEST_USER_ID) + + companion object { + @BeforeAll + @JvmStatic + fun initDb() { + TestDatabase.setup() + } + } + + @BeforeEach + fun clean() { + TestDatabase.truncateAll() + } + + private fun withApp(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication { + application { + install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true; encodeDefaults = true }) } + routing { settingsRoutes(service, auth) } + } + block() + } + + @Test + fun `GET settings returns SponsorBlock and privacy defaults`() = withApp { + val body = client.get("/settings") { + headers.append(HttpHeaders.Authorization, "Bearer test-jwt") + }.bodyAsText() + + assertContainsAll( + body, + listOf( + "\"sponsorBlockMode\":\"auto_skip\"", + "\"hideHomeRecommendations\":false", + "\"hideRelatedVideos\":false", + "\"hideComments\":false", + "\"hideShorts\":false", + ), + ) + } + + @Test + fun `PUT settings persists SponsorBlock and privacy controls`() = withApp { + val put = client.put("/settings") { + headers.append(HttpHeaders.Authorization, "Bearer test-jwt") + headers.append(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody(settingsBody()) + } + assertEquals(HttpStatusCode.OK, put.status) + + val body = client.get("/settings") { + headers.append(HttpHeaders.Authorization, "Bearer test-jwt") + }.bodyAsText() + assertContainsAll( + body, + listOf( + "\"sponsorBlockMode\":\"mark_only\"", + "\"hideHomeRecommendations\":true", + "\"hideRelatedVideos\":true", + "\"hideComments\":true", + "\"hideShorts\":true", + ), + ) + } + + @Test + fun `PUT settings rejects invalid SponsorBlock mode`() = withApp { + val response = client.put("/settings") { + headers.append(HttpHeaders.Authorization, "Bearer test-jwt") + headers.append(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody(settingsBody(sponsorBlockMode = "skip_everything")) + } + + assertEquals(HttpStatusCode.BadRequest, response.status) + } + + private fun assertContainsAll(body: String, values: List) = + values.forEach { assertTrue(body.contains(it)) } + + private fun settingsBody(sponsorBlockMode: String = "mark_only"): String = """ + {"defaultService":0,"defaultQuality":"1080p","autoplay":true,"volume":1.0,"muted":false,"sponsorBlockMode":"$sponsorBlockMode","hideHomeRecommendations":true,"hideRelatedVideos":true,"hideComments":true,"hideShorts":true} + """.trimIndent() +}