From ccd232173ecb61c8e682848584b48207c64ef0cd Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 17 Jun 2026 22:31:08 +0200 Subject: [PATCH 1/6] feat: add YouTube session storage primitives --- .env.example | 1 + .../dev/typetype/server/db/DatabaseFactory.kt | 4 + .../db/tables/YoutubeSessionPairingsTable.kt | 11 +++ .../server/db/tables/YoutubeSessionsTable.kt | 14 ++++ .../models/YoutubeSessionCompleteRequest.kt | 10 +++ .../models/YoutubeSessionPairingResponse.kt | 9 +++ .../models/YoutubeSessionStatusResponse.kt | 10 +++ .../services/YoutubeSessionCompleteResult.kt | 8 ++ .../server/services/YoutubeSessionCookie.kt | 7 ++ .../YoutubeSessionCookieNormalizer.kt | 78 +++++++++++++++++++ .../YoutubeSessionCredentialValidator.kt | 20 +++++ .../services/YoutubeSessionCredentials.kt | 8 ++ .../server/services/YoutubeSessionCrypto.kt | 52 +++++++++++++ .../services/YoutubeSessionPairingCode.kt | 13 ++++ .../server/services/YoutubeSessionStatus.kt | 12 +++ 15 files changed, 257 insertions(+) create mode 100644 src/main/kotlin/dev/typetype/server/db/tables/YoutubeSessionPairingsTable.kt create mode 100644 src/main/kotlin/dev/typetype/server/db/tables/YoutubeSessionsTable.kt create mode 100644 src/main/kotlin/dev/typetype/server/models/YoutubeSessionCompleteRequest.kt create mode 100644 src/main/kotlin/dev/typetype/server/models/YoutubeSessionPairingResponse.kt create mode 100644 src/main/kotlin/dev/typetype/server/models/YoutubeSessionStatusResponse.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/YoutubeSessionCompleteResult.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/YoutubeSessionCookie.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/YoutubeSessionCookieNormalizer.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/YoutubeSessionCredentialValidator.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/YoutubeSessionCredentials.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/YoutubeSessionCrypto.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/YoutubeSessionPairingCode.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/YoutubeSessionStatus.kt diff --git a/.env.example b/.env.example index 35042508..0335dbee 100644 --- a/.env.example +++ b/.env.example @@ -4,5 +4,6 @@ DATABASE_PASSWORD=typetype DRAGONFLY_URL=redis://dragonfly:6379 DOWNLOADER_SERVICE_URL=http://typetype-downloader:18093 +YOUTUBE_SESSION_ENCRYPTION_KEY=replace-with-at-least-32-random-characters ALLOWED_ORIGINS=http://localhost:5173 diff --git a/src/main/kotlin/dev/typetype/server/db/DatabaseFactory.kt b/src/main/kotlin/dev/typetype/server/db/DatabaseFactory.kt index 05fc86e7..d3d7971f 100644 --- a/src/main/kotlin/dev/typetype/server/db/DatabaseFactory.kt +++ b/src/main/kotlin/dev/typetype/server/db/DatabaseFactory.kt @@ -21,6 +21,8 @@ import dev.typetype.server.db.tables.PasswordResetTable import dev.typetype.server.db.tables.NotificationStatesTable import dev.typetype.server.db.tables.YoutubeTakeoutImportJobsTable import dev.typetype.server.db.tables.YoutubeTakeoutPlaylistKeysTable +import dev.typetype.server.db.tables.YoutubeSessionPairingsTable +import dev.typetype.server.db.tables.YoutubeSessionsTable import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.jetbrains.exposed.v1.jdbc.Database @@ -59,6 +61,8 @@ object DatabaseFactory { PasswordResetTable, YoutubeTakeoutImportJobsTable, YoutubeTakeoutPlaylistKeysTable, + YoutubeSessionsTable, + YoutubeSessionPairingsTable, BugReportsTable, NotificationStatesTable, ) diff --git a/src/main/kotlin/dev/typetype/server/db/tables/YoutubeSessionPairingsTable.kt b/src/main/kotlin/dev/typetype/server/db/tables/YoutubeSessionPairingsTable.kt new file mode 100644 index 00000000..340529a3 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/db/tables/YoutubeSessionPairingsTable.kt @@ -0,0 +1,11 @@ +package dev.typetype.server.db.tables + +import org.jetbrains.exposed.v1.core.Table + +object YoutubeSessionPairingsTable : Table("youtube_session_pairings") { + val code = text("code") + val userId = text("user_id") + val createdAt = long("created_at") + val expiresAt = long("expires_at") + override val primaryKey = PrimaryKey(code) +} diff --git a/src/main/kotlin/dev/typetype/server/db/tables/YoutubeSessionsTable.kt b/src/main/kotlin/dev/typetype/server/db/tables/YoutubeSessionsTable.kt new file mode 100644 index 00000000..a904aadb --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/db/tables/YoutubeSessionsTable.kt @@ -0,0 +1,14 @@ +package dev.typetype.server.db.tables + +import org.jetbrains.exposed.v1.core.Table + +object YoutubeSessionsTable : Table("youtube_sessions") { + val userId = text("user_id") + val encryptedCookies = text("encrypted_cookies") + val encryptedPoToken = text("encrypted_po_token") + val status = text("status") + val createdAt = long("created_at") + val updatedAt = long("updated_at") + val lastUsedAt = long("last_used_at").default(0) + override val primaryKey = PrimaryKey(userId) +} diff --git a/src/main/kotlin/dev/typetype/server/models/YoutubeSessionCompleteRequest.kt b/src/main/kotlin/dev/typetype/server/models/YoutubeSessionCompleteRequest.kt new file mode 100644 index 00000000..261033fc --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/models/YoutubeSessionCompleteRequest.kt @@ -0,0 +1,10 @@ +package dev.typetype.server.models + +import kotlinx.serialization.Serializable + +@Serializable +data class YoutubeSessionCompleteRequest( + val code: String, + val cookies: String, + val poToken: String, +) diff --git a/src/main/kotlin/dev/typetype/server/models/YoutubeSessionPairingResponse.kt b/src/main/kotlin/dev/typetype/server/models/YoutubeSessionPairingResponse.kt new file mode 100644 index 00000000..13eb026a --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/models/YoutubeSessionPairingResponse.kt @@ -0,0 +1,9 @@ +package dev.typetype.server.models + +import kotlinx.serialization.Serializable + +@Serializable +data class YoutubeSessionPairingResponse( + val code: String, + val expiresAt: Long, +) diff --git a/src/main/kotlin/dev/typetype/server/models/YoutubeSessionStatusResponse.kt b/src/main/kotlin/dev/typetype/server/models/YoutubeSessionStatusResponse.kt new file mode 100644 index 00000000..25f94afd --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/models/YoutubeSessionStatusResponse.kt @@ -0,0 +1,10 @@ +package dev.typetype.server.models + +import kotlinx.serialization.Serializable + +@Serializable +data class YoutubeSessionStatusResponse( + val status: String, + val updatedAt: Long, + val lastUsedAt: Long, +) diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeSessionCompleteResult.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeSessionCompleteResult.kt new file mode 100644 index 00000000..0372fe93 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeSessionCompleteResult.kt @@ -0,0 +1,8 @@ +package dev.typetype.server.services + +sealed interface YoutubeSessionCompleteResult { + data object Completed : YoutubeSessionCompleteResult + data object InvalidCode : YoutubeSessionCompleteResult + data object ExpiredCode : YoutubeSessionCompleteResult + data object InvalidCredentials : YoutubeSessionCompleteResult +} diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeSessionCookie.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeSessionCookie.kt new file mode 100644 index 00000000..dc0a2635 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeSessionCookie.kt @@ -0,0 +1,7 @@ +package dev.typetype.server.services + +data class YoutubeSessionCookie( + val name: String, + val value: String, + val priority: Int, +) diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeSessionCookieNormalizer.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeSessionCookieNormalizer.kt new file mode 100644 index 00000000..155fae78 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeSessionCookieNormalizer.kt @@ -0,0 +1,78 @@ +package dev.typetype.server.services + +object YoutubeSessionCookieNormalizer { + private const val MAX_RAW_COOKIE_LENGTH = 1024 * 1024 + private val allowedNames = setOf( + "SID", + "HSID", + "SSID", + "APISID", + "SAPISID", + "LOGIN_INFO", + "SIDCC", + "__Secure-1PSID", + "__Secure-3PSID", + "__Secure-1PAPISID", + "__Secure-3PAPISID", + "__Secure-1PSIDCC", + "__Secure-3PSIDCC", + "__Secure-1PSIDTS", + "__Secure-3PSIDTS", + "__Host-1PLSID", + "__Host-3PLSID", + "AEC", + "NID", + "PREF", + "SOCS", + "VISITOR_INFO1_LIVE", + "VISITOR_PRIVACY_METADATA", + "YSC", + ) + + fun normalize(raw: String): String? { + if (raw.length > MAX_RAW_COOKIE_LENGTH) return null + val cookies = if ('\t' in raw) parseNetscape(raw) else parseHeader(raw) + val header = cookies.values + .filter { it.name in allowedNames && it.value.isNotBlank() } + .joinToString("; ") { "${it.name}=${it.value}" } + return header.takeIf { it.isNotBlank() } + } + + private fun parseHeader(raw: String): Map = + raw.trim().removePrefix("Cookie:").trim().split(';').mapNotNull { part -> + val index = part.indexOf('=') + if (index <= 0) return@mapNotNull null + val name = part.take(index).trim() + val value = part.drop(index + 1).trim() + YoutubeSessionCookie(name = name, value = value, priority = 10) + }.toCookieMap() + + private fun parseNetscape(raw: String): Map = + raw.lineSequence().mapNotNull { line -> + if (line.isBlank() || line.startsWith("#")) return@mapNotNull null + val fields = line.split('\t') + if (fields.size < 7) return@mapNotNull null + val priority = fields[0].domainPriority() ?: return@mapNotNull null + YoutubeSessionCookie(name = fields[5].trim(), value = fields[6].trim(), priority = priority) + }.toCookieMap() + + private fun Sequence.toCookieMap(): Map = + fold(linkedMapOf()) { acc, cookie -> + val current = acc[cookie.name] + if (current == null || cookie.priority >= current.priority) acc[cookie.name] = cookie + acc + } + + private fun List.toCookieMap(): Map = + asSequence().toCookieMap() + + private fun String.domainPriority(): Int? { + val domain = trim().removePrefix(".").lowercase() + return when { + domain == "youtube.com" || domain.endsWith(".youtube.com") -> 4 + domain == "google.com" || domain.endsWith(".google.com") -> 3 + domain == "youtube-nocookie.com" || domain.endsWith(".youtube-nocookie.com") -> 2 + else -> null + } + } +} diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeSessionCredentialValidator.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeSessionCredentialValidator.kt new file mode 100644 index 00000000..b12f32ec --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeSessionCredentialValidator.kt @@ -0,0 +1,20 @@ +package dev.typetype.server.services + +object YoutubeSessionCredentialValidator { + private const val MAX_COOKIES_LENGTH = 32 * 1024 + private const val MAX_PO_TOKEN_LENGTH = 16 * 1024 + private val sessionCookieNames = listOf("SID", "__Secure-1PSID", "__Secure-3PSID") + + fun isValid(cookies: String, poToken: String): Boolean { + if (cookies.isBlank() || poToken.isBlank()) return false + if (cookies.length > MAX_COOKIES_LENGTH || poToken.length > MAX_PO_TOKEN_LENGTH) return false + if (poToken.length < 8) return false + return sessionCookieNames.any { cookies.hasCookie(it) } + } + + private fun String.hasCookie(name: String): Boolean = + split(';').any { part -> + val trimmed = part.trim() + trimmed.startsWith("$name=") && trimmed.length > name.length + 1 + } +} diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeSessionCredentials.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeSessionCredentials.kt new file mode 100644 index 00000000..510200d5 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeSessionCredentials.kt @@ -0,0 +1,8 @@ +package dev.typetype.server.services + +data class YoutubeSessionCredentials( + val userId: String, + val fingerprint: String, + val cookies: String, + val poToken: String, +) diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeSessionCrypto.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeSessionCrypto.kt new file mode 100644 index 00000000..a1012394 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeSessionCrypto.kt @@ -0,0 +1,52 @@ +package dev.typetype.server.services + +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.Base64 +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +class YoutubeSessionCrypto private constructor(private val key: SecretKeySpec) { + private val random = SecureRandom() + + fun encrypt(value: String): String { + val nonce = ByteArray(NONCE_BYTES) + random.nextBytes(nonce) + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(TAG_BITS, nonce)) + cipher.updateAAD(AAD) + val encrypted = cipher.doFinal(value.toByteArray(Charsets.UTF_8)) + return PREFIX + encoder.encodeToString(nonce + encrypted) + } + + fun decrypt(value: String): String { + require(value.startsWith(PREFIX)) { "Invalid encrypted payload format" } + val bytes = decoder.decode(value.removePrefix(PREFIX)) + require(bytes.size > NONCE_BYTES) { "Invalid encrypted payload" } + val nonce = bytes.copyOfRange(0, NONCE_BYTES) + val encrypted = bytes.copyOfRange(NONCE_BYTES, bytes.size) + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_BITS, nonce)) + cipher.updateAAD(AAD) + return cipher.doFinal(encrypted).toString(Charsets.UTF_8) + } + + companion object { + private const val PREFIX = "gcm256." + private const val NONCE_BYTES = 12 + private const val TAG_BITS = 128 + private const val MIN_SECRET_LENGTH = 32 + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private val AAD = "typetype.youtube-session".toByteArray(Charsets.UTF_8) + private val encoder = Base64.getUrlEncoder().withoutPadding() + private val decoder = Base64.getUrlDecoder() + + fun fromSecret(secret: String): YoutubeSessionCrypto { + require(secret.length >= MIN_SECRET_LENGTH) { "YouTube session encryption key is too short" } + val digest = MessageDigest.getInstance("SHA-256") + .digest(secret.toByteArray(Charsets.UTF_8)) + return YoutubeSessionCrypto(SecretKeySpec(digest, "AES")) + } + } +} diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeSessionPairingCode.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeSessionPairingCode.kt new file mode 100644 index 00000000..0154628e --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeSessionPairingCode.kt @@ -0,0 +1,13 @@ +package dev.typetype.server.services + +import java.security.SecureRandom + +private const val YOUTUBE_SESSION_CODE_LENGTH = 8 +private const val YOUTUBE_SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" +private val youtubeSessionCodeRandom = SecureRandom() + +fun newYoutubeSessionCode(): String = buildString(YOUTUBE_SESSION_CODE_LENGTH) { + repeat(YOUTUBE_SESSION_CODE_LENGTH) { + append(YOUTUBE_SESSION_CODE_ALPHABET[youtubeSessionCodeRandom.nextInt(YOUTUBE_SESSION_CODE_ALPHABET.length)]) + } +} diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeSessionStatus.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeSessionStatus.kt new file mode 100644 index 00000000..49c00ed8 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeSessionStatus.kt @@ -0,0 +1,12 @@ +package dev.typetype.server.services + +enum class YoutubeSessionStatus(val value: String) { + Connected("connected"), + NeedsReconnect("needs_reconnect"), + Disconnected("disconnected"); + + companion object { + fun from(value: String): YoutubeSessionStatus = + entries.firstOrNull { it.value == value } ?: Disconnected + } +} From 4f3305ac19bae22faf6bef9fc9c9bfa7a90c5200 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 17 Jun 2026 22:31:20 +0200 Subject: [PATCH 2/6] feat: add YouTube session routes --- .../server/routes/YoutubeSessionRoutes.kt | 50 ++++++++++ .../services/YoutubeSessionPairingStore.kt | 44 +++++++++ .../server/services/YoutubeSessionService.kt | 51 +++++++++++ .../server/services/YoutubeSessionStore.kt | 91 +++++++++++++++++++ 4 files changed, 236 insertions(+) create mode 100644 src/main/kotlin/dev/typetype/server/routes/YoutubeSessionRoutes.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/YoutubeSessionPairingStore.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/YoutubeSessionService.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/YoutubeSessionStore.kt diff --git a/src/main/kotlin/dev/typetype/server/routes/YoutubeSessionRoutes.kt b/src/main/kotlin/dev/typetype/server/routes/YoutubeSessionRoutes.kt new file mode 100644 index 00000000..74995595 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/routes/YoutubeSessionRoutes.kt @@ -0,0 +1,50 @@ +package dev.typetype.server.routes + +import dev.typetype.server.models.ErrorResponse +import dev.typetype.server.models.YoutubeSessionCompleteRequest +import dev.typetype.server.services.AuthService +import dev.typetype.server.services.YoutubeSessionCompleteResult +import dev.typetype.server.services.YoutubeSessionService +import io.ktor.http.HttpStatusCode +import io.ktor.server.request.receive +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.delete +import io.ktor.server.routing.get +import io.ktor.server.routing.post + +fun Route.youtubeSessionRoutes(youtubeSessionService: YoutubeSessionService, authService: AuthService): Unit { + post("/youtube-session/pairing") { + call.withJwtAuth(authService) { userId -> + call.respond(HttpStatusCode.Created, youtubeSessionService.createPairing(userId)) + } + } + post("/youtube-session/complete") { + val request = runCatching { call.receive() }.getOrElse { + return@post call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid request body")) + } + when (youtubeSessionService.complete(request)) { + YoutubeSessionCompleteResult.Completed -> call.respond(HttpStatusCode.NoContent) + YoutubeSessionCompleteResult.InvalidCode -> { + call.respond(HttpStatusCode.NotFound, ErrorResponse("Invalid pairing code", "youtube_pairing_invalid")) + } + YoutubeSessionCompleteResult.ExpiredCode -> { + call.respond(HttpStatusCode.Gone, ErrorResponse("Pairing code expired", "youtube_pairing_expired")) + } + YoutubeSessionCompleteResult.InvalidCredentials -> { + call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid YouTube credentials", "youtube_credentials_invalid")) + } + } + } + get("/youtube-session/status") { + call.withJwtAuth(authService) { userId -> + call.respond(youtubeSessionService.status(userId)) + } + } + delete("/youtube-session") { + call.withJwtAuth(authService) { userId -> + youtubeSessionService.delete(userId) + call.respond(HttpStatusCode.NoContent) + } + } +} diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeSessionPairingStore.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeSessionPairingStore.kt new file mode 100644 index 00000000..86b6b7af --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeSessionPairingStore.kt @@ -0,0 +1,44 @@ +package dev.typetype.server.services + +import dev.typetype.server.db.DatabaseFactory +import dev.typetype.server.db.tables.YoutubeSessionPairingsTable +import dev.typetype.server.models.YoutubeSessionPairingResponse +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.lessEq +import org.jetbrains.exposed.v1.jdbc.deleteWhere +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.selectAll + +private const val YOUTUBE_SESSION_PAIRING_TTL_MS = 5 * 60 * 1000L + +class YoutubeSessionPairingStore( + private val nowMillis: () -> Long = System::currentTimeMillis, + private val codeGenerator: () -> String = ::newYoutubeSessionCode, +) { + suspend fun create(userId: String): YoutubeSessionPairingResponse { + val now = nowMillis() + val expiresAt = now + YOUTUBE_SESSION_PAIRING_TTL_MS + val code = DatabaseFactory.query { + YoutubeSessionPairingsTable.deleteWhere { YoutubeSessionPairingsTable.expiresAt lessEq now } + val pairingCode = uniqueCode() + YoutubeSessionPairingsTable.insert { + it[YoutubeSessionPairingsTable.code] = pairingCode + it[YoutubeSessionPairingsTable.userId] = userId + it[createdAt] = now + it[YoutubeSessionPairingsTable.expiresAt] = expiresAt + } + pairingCode + } + return YoutubeSessionPairingResponse(code = code, expiresAt = expiresAt) + } + + private fun uniqueCode(): String { + repeat(10) { + val code = codeGenerator().trim().uppercase() + if (YoutubeSessionPairingsTable.selectAll().where { YoutubeSessionPairingsTable.code eq code }.empty()) { + return code + } + } + error("Unable to allocate YouTube session pairing code") + } +} diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeSessionService.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeSessionService.kt new file mode 100644 index 00000000..eec3fd10 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeSessionService.kt @@ -0,0 +1,51 @@ +package dev.typetype.server.services + +import dev.typetype.server.models.YoutubeSessionCompleteRequest +import dev.typetype.server.models.YoutubeSessionPairingResponse +import dev.typetype.server.models.YoutubeSessionStatusResponse + +class YoutubeSessionService( + private val crypto: YoutubeSessionCrypto, + private val pairingStore: YoutubeSessionPairingStore = YoutubeSessionPairingStore(), + private val store: YoutubeSessionStore = YoutubeSessionStore(), +) { + suspend fun createPairing(userId: String): YoutubeSessionPairingResponse = + pairingStore.create(userId) + + suspend fun complete(request: YoutubeSessionCompleteRequest): YoutubeSessionCompleteResult { + val code = request.code.trim().uppercase() + val cookies = YoutubeSessionCookieNormalizer.normalize(request.cookies) + ?: return YoutubeSessionCompleteResult.InvalidCredentials + val poToken = request.poToken.trim() + if (code.isBlank() || !YoutubeSessionCredentialValidator.isValid(cookies, poToken)) { + return YoutubeSessionCompleteResult.InvalidCredentials + } + return store.complete( + code = code, + encryptedCookies = crypto.encrypt(cookies), + encryptedPoToken = crypto.encrypt(poToken), + ) + } + + suspend fun status(userId: String): YoutubeSessionStatusResponse = store.status(userId) + + suspend fun delete(userId: String): Boolean = store.delete(userId) + + suspend fun connectedCredentials(userId: String): YoutubeSessionCredentials? { + val encrypted = store.connectedEncrypted(userId) ?: return null + val credentials = runCatching { + YoutubeSessionCredentials( + userId = userId, + fingerprint = PublicCacheKey.of("youtube-session", encrypted.first, encrypted.second), + cookies = crypto.decrypt(encrypted.first), + poToken = crypto.decrypt(encrypted.second), + ) + }.getOrNull() + if (credentials == null) store.markNeedsReconnect(userId) + return credentials + } + + suspend fun markUsed(userId: String): Unit = store.markUsed(userId) + + suspend fun markNeedsReconnect(userId: String): Unit = store.markNeedsReconnect(userId) +} diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeSessionStore.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeSessionStore.kt new file mode 100644 index 00000000..f88529b6 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeSessionStore.kt @@ -0,0 +1,91 @@ +package dev.typetype.server.services + +import dev.typetype.server.db.DatabaseFactory +import dev.typetype.server.db.tables.YoutubeSessionPairingsTable +import dev.typetype.server.db.tables.YoutubeSessionsTable +import dev.typetype.server.models.YoutubeSessionStatusResponse +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.jdbc.deleteWhere +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.jetbrains.exposed.v1.jdbc.update + +class YoutubeSessionStore( + private val nowMillis: () -> Long = System::currentTimeMillis, +) { + suspend fun complete(code: String, encryptedCookies: String, encryptedPoToken: String): YoutubeSessionCompleteResult { + val now = nowMillis() + return DatabaseFactory.query { + val pairing = YoutubeSessionPairingsTable.selectAll() + .where { YoutubeSessionPairingsTable.code eq code } + .singleOrNull() ?: return@query YoutubeSessionCompleteResult.InvalidCode + if (pairing[YoutubeSessionPairingsTable.expiresAt] <= now) { + YoutubeSessionPairingsTable.deleteWhere { YoutubeSessionPairingsTable.code eq code } + return@query YoutubeSessionCompleteResult.ExpiredCode + } + upsertSession(pairing[YoutubeSessionPairingsTable.userId], encryptedCookies, encryptedPoToken, now) + YoutubeSessionPairingsTable.deleteWhere { YoutubeSessionPairingsTable.code eq code } + YoutubeSessionCompleteResult.Completed + } + } + suspend fun status(userId: String): YoutubeSessionStatusResponse = DatabaseFactory.query { + YoutubeSessionsTable.selectAll() + .where { YoutubeSessionsTable.userId eq userId } + .singleOrNull() + ?.let { + YoutubeSessionStatusResponse( + status = YoutubeSessionStatus.from(it[YoutubeSessionsTable.status]).value, + updatedAt = it[YoutubeSessionsTable.updatedAt], + lastUsedAt = it[YoutubeSessionsTable.lastUsedAt], + ) + } + ?: YoutubeSessionStatusResponse(YoutubeSessionStatus.Disconnected.value, 0, 0) + } + suspend fun delete(userId: String): Boolean = DatabaseFactory.query { + YoutubeSessionsTable.deleteWhere { YoutubeSessionsTable.userId eq userId } > 0 + } + + suspend fun connectedEncrypted(userId: String): Pair? = DatabaseFactory.query { + YoutubeSessionsTable.selectAll() + .where { YoutubeSessionsTable.userId eq userId } + .singleOrNull() + ?.takeIf { YoutubeSessionStatus.from(it[YoutubeSessionsTable.status]) == YoutubeSessionStatus.Connected } + ?.let { it[YoutubeSessionsTable.encryptedCookies] to it[YoutubeSessionsTable.encryptedPoToken] } + } + + suspend fun markUsed(userId: String): Unit = DatabaseFactory.query { + YoutubeSessionsTable.update({ YoutubeSessionsTable.userId eq userId }) { it[lastUsedAt] = nowMillis() } + } + + suspend fun markNeedsReconnect(userId: String): Unit = DatabaseFactory.query { + val now = nowMillis() + YoutubeSessionsTable.update({ YoutubeSessionsTable.userId eq userId }) { + it[status] = YoutubeSessionStatus.NeedsReconnect.value + it[updatedAt] = now + it[lastUsedAt] = now + } + } + + private fun upsertSession(userId: String, encryptedCookies: String, encryptedPoToken: String, now: Long) { + val updated = YoutubeSessionsTable.update({ YoutubeSessionsTable.userId eq userId }) { + it[YoutubeSessionsTable.encryptedCookies] = encryptedCookies + it[YoutubeSessionsTable.encryptedPoToken] = encryptedPoToken + it[status] = YoutubeSessionStatus.Connected.value + it[updatedAt] = now + it[lastUsedAt] = 0 + } + if (updated == 0) insertSession(userId, encryptedCookies, encryptedPoToken, now) + } + + private fun insertSession(userId: String, encryptedCookies: String, encryptedPoToken: String, now: Long) { + YoutubeSessionsTable.insert { + it[YoutubeSessionsTable.userId] = userId + it[YoutubeSessionsTable.encryptedCookies] = encryptedCookies + it[YoutubeSessionsTable.encryptedPoToken] = encryptedPoToken + it[status] = YoutubeSessionStatus.Connected.value + it[createdAt] = now + it[updatedAt] = now + it[lastUsedAt] = 0 + } + } +} From ff2fbd1897b5709c4077497894ee14c19128e52d Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 17 Jun 2026 22:31:37 +0200 Subject: [PATCH 3/6] feat: guard public YouTube extraction tokens --- .../server/services/NativeManifestService.kt | 7 ++++ .../services/YoutubeScopedChannelService.kt | 21 ++++++++++ .../services/YoutubeScopedCommentService.kt | 13 ++++++ .../YoutubeScopedPublicPlaylistService.kt | 13 ++++++ .../services/YoutubeScopedSearchService.kt | 25 +++++++++++ .../services/YoutubeScopedStreamService.kt | 13 ++++++ .../YoutubeScopedSuggestionService.kt | 12 ++++++ .../services/YoutubeScopedTrendingService.kt | 13 ++++++ .../services/YoutubeSessionTokenScope.kt | 41 +++++++++++++++++++ .../server/services/YoutubeUrlDetector.kt | 8 ++++ 10 files changed, 166 insertions(+) create mode 100644 src/main/kotlin/dev/typetype/server/services/YoutubeScopedChannelService.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/YoutubeScopedCommentService.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/YoutubeScopedPublicPlaylistService.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/YoutubeScopedSearchService.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/YoutubeScopedStreamService.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/YoutubeScopedSuggestionService.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/YoutubeScopedTrendingService.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/YoutubeSessionTokenScope.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/YoutubeUrlDetector.kt diff --git a/src/main/kotlin/dev/typetype/server/services/NativeManifestService.kt b/src/main/kotlin/dev/typetype/server/services/NativeManifestService.kt index 0008a363..0d216102 100644 --- a/src/main/kotlin/dev/typetype/server/services/NativeManifestService.kt +++ b/src/main/kotlin/dev/typetype/server/services/NativeManifestService.kt @@ -13,6 +13,13 @@ import org.schabi.newpipe.extractor.stream.VideoStream class NativeManifestService { suspend fun nativeManifest(videoUrl: String): ExtractionResult = + if (isYoutubeUrl(videoUrl)) { + YoutubeSessionTokenScope.withoutCredentials { nativeManifestWithoutCredentials(videoUrl) } + } else { + nativeManifestWithoutCredentials(videoUrl) + } + + private suspend fun nativeManifestWithoutCredentials(videoUrl: String): ExtractionResult = withContext(Dispatchers.IO) { runCatching { withExtractionRetry { diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeScopedChannelService.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeScopedChannelService.kt new file mode 100644 index 00000000..4baf01d9 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeScopedChannelService.kt @@ -0,0 +1,21 @@ +package dev.typetype.server.services + +import dev.typetype.server.models.ChannelPlaylistsResponse +import dev.typetype.server.models.ChannelResponse +import dev.typetype.server.models.ExtractionResult + +class YoutubeScopedChannelService(private val delegate: ChannelService) : ChannelService { + override suspend fun getChannel(url: String, nextpage: String?, sort: String?): ExtractionResult = + if (isYoutubeUrl(url)) { + YoutubeSessionTokenScope.withoutCredentials { delegate.getChannel(url, nextpage, sort) } + } else { + delegate.getChannel(url, nextpage, sort) + } + + override suspend fun getPlaylists(url: String, nextpage: String?): ExtractionResult = + if (isYoutubeUrl(url)) { + YoutubeSessionTokenScope.withoutCredentials { delegate.getPlaylists(url, nextpage) } + } else { + delegate.getPlaylists(url, nextpage) + } +} diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeScopedCommentService.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeScopedCommentService.kt new file mode 100644 index 00000000..dabcc968 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeScopedCommentService.kt @@ -0,0 +1,13 @@ +package dev.typetype.server.services + +import dev.typetype.server.models.CommentsPageResponse +import dev.typetype.server.models.ExtractionResult + +class YoutubeScopedCommentService(private val delegate: CommentService) : CommentService { + override suspend fun getComments(url: String, nextpage: String?): ExtractionResult = + if (isYoutubeUrl(url)) { + YoutubeSessionTokenScope.withoutCredentials { delegate.getComments(url, nextpage) } + } else { + delegate.getComments(url, nextpage) + } +} diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeScopedPublicPlaylistService.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeScopedPublicPlaylistService.kt new file mode 100644 index 00000000..41359450 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeScopedPublicPlaylistService.kt @@ -0,0 +1,13 @@ +package dev.typetype.server.services + +import dev.typetype.server.models.ExtractionResult +import dev.typetype.server.models.PublicPlaylistResponse + +class YoutubeScopedPublicPlaylistService(private val delegate: PublicPlaylistService) : PublicPlaylistService { + override suspend fun getPlaylist(url: String, nextpage: String?): ExtractionResult = + if (isYoutubeUrl(url)) { + YoutubeSessionTokenScope.withoutCredentials { delegate.getPlaylist(url, nextpage) } + } else { + delegate.getPlaylist(url, nextpage) + } +} diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeScopedSearchService.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeScopedSearchService.kt new file mode 100644 index 00000000..92f001a1 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeScopedSearchService.kt @@ -0,0 +1,25 @@ +package dev.typetype.server.services + +import dev.typetype.server.models.ExtractionResult +import dev.typetype.server.models.SearchFiltersResponse +import dev.typetype.server.models.SearchPageResponse + +class YoutubeScopedSearchService(private val delegate: SearchService) : SearchService { + override suspend fun search( + query: String, + serviceId: Int, + nextpage: String?, + contentFilter: String?, + sortFilter: String?, + ): ExtractionResult = + if (serviceId == YOUTUBE_SERVICE_ID) { + YoutubeSessionTokenScope.withoutCredentials { + delegate.search(query, serviceId, nextpage, contentFilter, sortFilter) + } + } else { + delegate.search(query, serviceId, nextpage, contentFilter, sortFilter) + } + + override suspend fun filters(serviceId: Int): ExtractionResult = + delegate.filters(serviceId) +} diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeScopedStreamService.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeScopedStreamService.kt new file mode 100644 index 00000000..a9c09b7f --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeScopedStreamService.kt @@ -0,0 +1,13 @@ +package dev.typetype.server.services + +import dev.typetype.server.models.ExtractionResult +import dev.typetype.server.models.StreamResponse + +class YoutubeScopedStreamService(private val delegate: StreamService) : StreamService { + override suspend fun getStreamInfo(url: String): ExtractionResult = + if (isYoutubeUrl(url)) { + YoutubeSessionTokenScope.withoutCredentials { delegate.getStreamInfo(url) } + } else { + delegate.getStreamInfo(url) + } +} diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeScopedSuggestionService.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeScopedSuggestionService.kt new file mode 100644 index 00000000..0f10782d --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeScopedSuggestionService.kt @@ -0,0 +1,12 @@ +package dev.typetype.server.services + +import dev.typetype.server.models.ExtractionResult + +class YoutubeScopedSuggestionService(private val delegate: SuggestionService) : SuggestionService { + override suspend fun getSuggestions(query: String, serviceId: Int): ExtractionResult> = + if (serviceId == YOUTUBE_SERVICE_ID) { + YoutubeSessionTokenScope.withoutCredentials { delegate.getSuggestions(query, serviceId) } + } else { + delegate.getSuggestions(query, serviceId) + } +} diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeScopedTrendingService.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeScopedTrendingService.kt new file mode 100644 index 00000000..ef24fe67 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeScopedTrendingService.kt @@ -0,0 +1,13 @@ +package dev.typetype.server.services + +import dev.typetype.server.models.ExtractionResult +import dev.typetype.server.models.VideoItem + +class YoutubeScopedTrendingService(private val delegate: TrendingService) : TrendingService { + override suspend fun getTrending(serviceId: Int): ExtractionResult> = + if (serviceId == YOUTUBE_SERVICE_ID) { + YoutubeSessionTokenScope.withoutCredentials { delegate.getTrending(serviceId) } + } else { + delegate.getTrending(serviceId) + } +} diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeSessionTokenScope.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeSessionTokenScope.kt new file mode 100644 index 00000000..b6f45dcf --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeSessionTokenScope.kt @@ -0,0 +1,41 @@ +package dev.typetype.server.services + +import org.schabi.newpipe.extractor.ServiceList +import java.util.concurrent.Semaphore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +object YoutubeSessionTokenScope { + private const val PUBLIC_PERMITS = 64 + private val permits = Semaphore(PUBLIC_PERMITS, true) + + suspend fun withCredentials(credentials: YoutubeSessionCredentials, block: suspend () -> T): T = + withPermits(PUBLIC_PERMITS) { + val youtube = ServiceList.YouTube + try { + youtube.setTokens(credentials.cookies) + youtube.setAdditionalTokens(credentials.poToken) + block() + } finally { + youtube.setTokens("") + youtube.setAdditionalTokens("") + } + } + + suspend fun withoutCredentials(block: suspend () -> T): T = + withPermits(1) { + val youtube = ServiceList.YouTube + youtube.setTokens("") + youtube.setAdditionalTokens("") + block() + } + + private suspend fun withPermits(count: Int, block: suspend () -> T): T { + withContext(Dispatchers.IO) { permits.acquire(count) } + return try { + block() + } finally { + permits.release(count) + } + } +} diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeUrlDetector.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeUrlDetector.kt new file mode 100644 index 00000000..f086ba35 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeUrlDetector.kt @@ -0,0 +1,8 @@ +package dev.typetype.server.services + +import java.net.URI + +internal fun isYoutubeUrl(url: String): Boolean = runCatching { + val host = URI(url).host?.lowercase() ?: return false + host == "youtu.be" || host == "youtube.com" || host.endsWith(".youtube.com") +}.getOrDefault(false) From 618bce1e23dcd6e31473032a3214e4938ebc2360 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 17 Jun 2026 22:31:51 +0200 Subject: [PATCH 4/6] feat: use YouTube sessions in streams --- .../kotlin/dev/typetype/server/Application.kt | 6 +- .../server/ExtractionServiceRegistry.kt | 83 +++++++++++++++++++ .../dev/typetype/server/ServiceRegistry.kt | 69 +++++---------- .../typetype/server/routes/StreamRoutes.kt | 66 ++++++++++++++- .../dev/typetype/server/routes/UserAuth.kt | 9 +- .../typetype/server/routes/UserDataRoutes.kt | 1 + .../services/YoutubeSessionStreamService.kt | 72 ++++++++++++++++ 7 files changed, 252 insertions(+), 54 deletions(-) create mode 100644 src/main/kotlin/dev/typetype/server/ExtractionServiceRegistry.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/YoutubeSessionStreamService.kt diff --git a/src/main/kotlin/dev/typetype/server/Application.kt b/src/main/kotlin/dev/typetype/server/Application.kt index ff06012a..33fe79b7 100644 --- a/src/main/kotlin/dev/typetype/server/Application.kt +++ b/src/main/kotlin/dev/typetype/server/Application.kt @@ -72,8 +72,10 @@ fun Application.module() { val cacheUrl = System.getenv("DRAGONFLY_URL") ?: "redis://localhost:6379" val subtitleServiceUrl = System.getenv("SUBTITLE_SERVICE_URL") ?: "http://typetype-token:8081" val downloaderServiceUrl = System.getenv("DOWNLOADER_SERVICE_URL") ?: "http://typetype-downloader:18093" + val youtubeSessionEncryptionKey = System.getenv("YOUTUBE_SESSION_ENCRYPTION_KEY") + ?: error("YOUTUBE_SESSION_ENCRYPTION_KEY is required") val cache = DragonflyService(cacheUrl) - val svc = ServiceRegistry(cache, subtitleServiceUrl) + val svc = ServiceRegistry(cache, subtitleServiceUrl, youtubeSessionEncryptionKey) val downloaderGatewayService = DownloaderGatewayService(downloaderServiceUrl) val openMojiProxyService = OpenMojiProxyService(cache) val internalHealthService = InternalHealthService(cache, downloaderGatewayService, subtitleServiceUrl) @@ -82,7 +84,7 @@ fun Application.module() { internalObservabilityRoutes(internalHealthService::check) publicMetadataRoutes(instanceService::getInstance) rateLimit(STREAMS_ZONE) { - streamRoutes(svc.streamService) + streamRoutes(svc.streamService, authService, svc.youtubeSessionStreamService::getStreamInfo) manifestRoutes(svc.manifestService, svc.nativeManifestService, svc.hlsManifestService) } rateLimit(EXTRACTION_ZONE) { diff --git a/src/main/kotlin/dev/typetype/server/ExtractionServiceRegistry.kt b/src/main/kotlin/dev/typetype/server/ExtractionServiceRegistry.kt new file mode 100644 index 00000000..f38128fb --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/ExtractionServiceRegistry.kt @@ -0,0 +1,83 @@ +package dev.typetype.server + +import dev.typetype.server.cache.DragonflyService +import dev.typetype.server.services.BilibiliRelatedService +import dev.typetype.server.services.BilibiliTrendingService +import dev.typetype.server.services.CachedChannelService +import dev.typetype.server.services.CachedCommentService +import dev.typetype.server.services.CachedManifestService +import dev.typetype.server.services.CachedNativeManifestService +import dev.typetype.server.services.CachedPodcastService +import dev.typetype.server.services.CachedPublicPlaylistService +import dev.typetype.server.services.CachedSearchService +import dev.typetype.server.services.CachedStreamService +import dev.typetype.server.services.CachedSuggestionService +import dev.typetype.server.services.CachedTrendingService +import dev.typetype.server.services.HlsManifestService +import dev.typetype.server.services.ManifestService +import dev.typetype.server.services.NativeManifestService +import dev.typetype.server.services.NicoNicoTrendingService +import dev.typetype.server.services.NicoVideoProxyService +import dev.typetype.server.services.OkHttpProxyService +import dev.typetype.server.services.PipePipeBulletCommentService +import dev.typetype.server.services.PipePipeChannelService +import dev.typetype.server.services.PipePipeCommentService +import dev.typetype.server.services.PipePipePodcastService +import dev.typetype.server.services.PipePipePublicPlaylistService +import dev.typetype.server.services.PipePipeSearchService +import dev.typetype.server.services.PipePipeStreamService +import dev.typetype.server.services.PipePipeSuggestionService +import dev.typetype.server.services.PipePipeTrendingService +import dev.typetype.server.services.YouTubeSubtitleService +import dev.typetype.server.services.YoutubeScopedChannelService +import dev.typetype.server.services.YoutubeScopedCommentService +import dev.typetype.server.services.YoutubeScopedPublicPlaylistService +import dev.typetype.server.services.YoutubeScopedSearchService +import dev.typetype.server.services.YoutubeScopedStreamService +import dev.typetype.server.services.YoutubeScopedSuggestionService +import dev.typetype.server.services.YoutubeScopedTrendingService +import dev.typetype.server.services.YoutubeSessionCrypto +import dev.typetype.server.services.YoutubeSessionService +import dev.typetype.server.services.YoutubeSessionStreamService +import okhttp3.OkHttpClient +import java.util.concurrent.TimeUnit + +internal class ExtractionServiceRegistry( + cache: DragonflyService, + subtitleServiceUrl: String, + youtubeSessionEncryptionKey: String, +) { + val httpClient = OkHttpClient() + val proxyHttpClient: OkHttpClient = httpClient.newBuilder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .followRedirects(true) + .build() + private val pipePipeStreamService = PipePipeStreamService( + cache, + YouTubeSubtitleService(httpClient, subtitleServiceUrl), + BilibiliRelatedService(), + ) + val youtubeSessionService = YoutubeSessionService(YoutubeSessionCrypto.fromSecret(youtubeSessionEncryptionKey)) + val youtubeSessionStreamService = YoutubeSessionStreamService(pipePipeStreamService, youtubeSessionService, cache) + val streamService = CachedStreamService(YoutubeScopedStreamService(pipePipeStreamService), cache) + val searchService = CachedSearchService(YoutubeScopedSearchService(PipePipeSearchService()), cache) + val trendingService = CachedTrendingService( + YoutubeScopedTrendingService(PipePipeTrendingService(BilibiliTrendingService(), NicoNicoTrendingService(httpClient))), + cache, + ) + val commentService = CachedCommentService(YoutubeScopedCommentService(PipePipeCommentService()), cache) + val bulletCommentService = PipePipeBulletCommentService() + val channelService = CachedChannelService(YoutubeScopedChannelService(PipePipeChannelService()), cache) + val podcastService = CachedPodcastService(PipePipePodcastService(), cache) + val publicPlaylistService = CachedPublicPlaylistService( + YoutubeScopedPublicPlaylistService(PipePipePublicPlaylistService()), + cache, + ) + val proxyService = OkHttpProxyService(proxyHttpClient) + val nicoVideoProxyService = NicoVideoProxyService() + val manifestService = CachedManifestService(ManifestService(streamService), cache) + val nativeManifestService = CachedNativeManifestService(NativeManifestService(), cache) + val hlsManifestService = HlsManifestService(streamService, proxyHttpClient) + val suggestionService = CachedSuggestionService(YoutubeScopedSuggestionService(PipePipeSuggestionService()), cache) +} diff --git a/src/main/kotlin/dev/typetype/server/ServiceRegistry.kt b/src/main/kotlin/dev/typetype/server/ServiceRegistry.kt index d1064bc1..1b97e153 100644 --- a/src/main/kotlin/dev/typetype/server/ServiceRegistry.kt +++ b/src/main/kotlin/dev/typetype/server/ServiceRegistry.kt @@ -1,38 +1,11 @@ package dev.typetype.server import dev.typetype.server.cache.DragonflyService -import dev.typetype.server.services.BilibiliRelatedService -import dev.typetype.server.services.BilibiliTrendingService import dev.typetype.server.services.BlockedService import dev.typetype.server.services.BugReportService -import dev.typetype.server.services.CachedChannelService -import dev.typetype.server.services.CachedCommentService -import dev.typetype.server.services.CachedManifestService -import dev.typetype.server.services.CachedNativeManifestService -import dev.typetype.server.services.CachedPodcastService -import dev.typetype.server.services.CachedPublicPlaylistService -import dev.typetype.server.services.CachedSearchService -import dev.typetype.server.services.CachedStreamService -import dev.typetype.server.services.CachedSuggestionService -import dev.typetype.server.services.CachedTrendingService import dev.typetype.server.services.FavoritesService import dev.typetype.server.services.HistoryService -import dev.typetype.server.services.HlsManifestService import dev.typetype.server.services.HomeRecommendationService -import dev.typetype.server.services.ManifestService -import dev.typetype.server.services.NativeManifestService -import dev.typetype.server.services.NicoNicoTrendingService -import dev.typetype.server.services.NicoVideoProxyService import dev.typetype.server.services.NotificationsService -import dev.typetype.server.services.OkHttpProxyService -import dev.typetype.server.services.PipePipeBulletCommentService -import dev.typetype.server.services.PipePipeChannelService -import dev.typetype.server.services.PipePipeCommentService -import dev.typetype.server.services.PipePipePodcastService -import dev.typetype.server.services.PipePipePublicPlaylistService -import dev.typetype.server.services.PipePipeSearchService -import dev.typetype.server.services.PipePipeStreamService -import dev.typetype.server.services.PipePipeSuggestionService -import dev.typetype.server.services.PipePipeTrendingService import dev.typetype.server.services.PlaylistService import dev.typetype.server.services.ProgressService import dev.typetype.server.services.SearchHistoryService @@ -45,31 +18,33 @@ import dev.typetype.server.services.SubscriptionsService import dev.typetype.server.services.SubscriptionFeedCacheInvalidation import dev.typetype.server.services.SubscriptionFeedCacheInvalidator import dev.typetype.server.services.WatchLaterService -import dev.typetype.server.services.YouTubeSubtitleService import dev.typetype.server.services.YoutubeTakeoutFactory -import okhttp3.OkHttpClient -import java.util.concurrent.TimeUnit -internal class ServiceRegistry(cache: DragonflyService, subtitleServiceUrl: String) { +internal class ServiceRegistry( + cache: DragonflyService, + subtitleServiceUrl: String, + youtubeSessionEncryptionKey: String, +) { init { SubscriptionFeedCacheInvalidation.configure(SubscriptionFeedCacheInvalidator(cache)) } - private val httpClient = OkHttpClient() - private val proxyHttpClient = httpClient.newBuilder().connectTimeout(10, TimeUnit.SECONDS).readTimeout(30, TimeUnit.SECONDS).followRedirects(true).build() - val streamService = CachedStreamService(PipePipeStreamService(cache, YouTubeSubtitleService(httpClient, subtitleServiceUrl), BilibiliRelatedService()), cache) - val searchService = CachedSearchService(PipePipeSearchService(), cache) - val trendingService = CachedTrendingService(PipePipeTrendingService(BilibiliTrendingService(), NicoNicoTrendingService(httpClient)), cache) - val commentService = CachedCommentService(PipePipeCommentService(), cache) - val bulletCommentService = PipePipeBulletCommentService() - val channelService = CachedChannelService(PipePipeChannelService(), cache) - val podcastService = CachedPodcastService(PipePipePodcastService(), cache) - val publicPlaylistService = CachedPublicPlaylistService(PipePipePublicPlaylistService(), cache) - val proxyService = OkHttpProxyService(proxyHttpClient) - val nicoVideoProxyService = NicoVideoProxyService() - val manifestService = CachedManifestService(ManifestService(streamService), cache) - val nativeManifestService = CachedNativeManifestService(NativeManifestService(), cache) - val hlsManifestService = HlsManifestService(streamService, proxyHttpClient) - val suggestionService = CachedSuggestionService(PipePipeSuggestionService(), cache) + private val extraction = ExtractionServiceRegistry(cache, subtitleServiceUrl, youtubeSessionEncryptionKey) + val youtubeSessionService = extraction.youtubeSessionService + val youtubeSessionStreamService = extraction.youtubeSessionStreamService + val streamService = extraction.streamService + val searchService = extraction.searchService + val trendingService = extraction.trendingService + val commentService = extraction.commentService + val bulletCommentService = extraction.bulletCommentService + val channelService = extraction.channelService + val podcastService = extraction.podcastService + val publicPlaylistService = extraction.publicPlaylistService + val proxyService = extraction.proxyService + val nicoVideoProxyService = extraction.nicoVideoProxyService + val manifestService = extraction.manifestService + val nativeManifestService = extraction.nativeManifestService + val hlsManifestService = extraction.hlsManifestService + val suggestionService = extraction.suggestionService val historyService = HistoryService() val subscriptionsService = SubscriptionsService() val subscriptionFeedService = SubscriptionFeedService(subscriptionsService, channelService, cache) diff --git a/src/main/kotlin/dev/typetype/server/routes/StreamRoutes.kt b/src/main/kotlin/dev/typetype/server/routes/StreamRoutes.kt index de28e344..71d3c908 100644 --- a/src/main/kotlin/dev/typetype/server/routes/StreamRoutes.kt +++ b/src/main/kotlin/dev/typetype/server/routes/StreamRoutes.kt @@ -2,7 +2,10 @@ package dev.typetype.server.routes import dev.typetype.server.models.ErrorResponse import dev.typetype.server.models.ExtractionResult +import dev.typetype.server.models.StreamResponse +import dev.typetype.server.services.AuthService import dev.typetype.server.services.StreamService +import dev.typetype.server.services.YOUTUBE_SESSION_RECONNECT_ERROR import io.ktor.http.HttpStatusCode import io.ktor.http.HttpHeaders import io.ktor.server.response.respond @@ -10,19 +13,74 @@ import io.ktor.server.routing.Route import io.ktor.server.routing.get private const val STREAMS_CACHE_CONTROL = "public, max-age=21600, stale-while-revalidate=3600" +private const val AUTHENTICATED_STREAMS_CACHE_CONTROL = "no-store" -fun Route.streamRoutes(streamService: StreamService) { +fun Route.streamRoutes( + streamService: StreamService, + authService: AuthService? = null, + youtubeSessionStreamInfo: (suspend (String, String) -> ExtractionResult?)? = null, +): Unit { get("/streams") { val url = call.request.queryParameters["url"] ?: return@get call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing 'url' parameter")) - when (val result = streamService.getStreamInfo(url)) { + val publicResult = streamService.getStreamInfo(url) + val userId = authService?.let { call.optionalJwtUserId(it) } + val sessionResult = if ( + userId != null && + youtubeSessionStreamInfo != null && + publicResult.shouldTryYoutubeSession() + ) { + youtubeSessionStreamInfo(userId, url) + } else { + null + } + val usedYoutubeSession = sessionResult != null + when (val result = publicResult.resolveWith(sessionResult)) { is ExtractionResult.Success -> { - call.response.headers.append(HttpHeaders.CacheControl, STREAMS_CACHE_CONTROL) + call.response.headers.append( + HttpHeaders.CacheControl, + if (usedYoutubeSession) AUTHENTICATED_STREAMS_CACHE_CONTROL else STREAMS_CACHE_CONTROL, + ) call.respond(result.data) } - is ExtractionResult.BadRequest -> call.respond(HttpStatusCode.BadRequest, ErrorResponse(result.message)) + is ExtractionResult.BadRequest -> call.respond(HttpStatusCode.BadRequest, result.toErrorResponse()) is ExtractionResult.Failure -> call.respond(HttpStatusCode.UnprocessableEntity, ErrorResponse(result.message)) } } } + +private fun ExtractionResult.shouldTryYoutubeSession(): Boolean = when (this) { + is ExtractionResult.Success -> data.hlsUrl.isBlank() + is ExtractionResult.BadRequest -> true + is ExtractionResult.Failure -> true +} + +private fun ExtractionResult.resolveWith( + sessionResult: ExtractionResult?, +): ExtractionResult { + if (sessionResult == null) return this + if (this is ExtractionResult.Success && sessionResult is ExtractionResult.Success) { + return ExtractionResult.Success(data.mergeWithSession(sessionResult.data)) + } + if (this is ExtractionResult.Success) return this + return sessionResult +} + +private fun StreamResponse.mergeWithSession(session: StreamResponse): StreamResponse { + val base = if (session.playableStreamCount() > playableStreamCount()) session else this + return base.copy( + hlsUrl = base.hlsUrl.ifBlank { session.hlsUrl.ifBlank { hlsUrl } }, + dashMpdUrl = base.dashMpdUrl.ifBlank { session.dashMpdUrl.ifBlank { dashMpdUrl } }, + ) +} + +private fun StreamResponse.playableStreamCount(): Int = + videoStreams.size + videoOnlyStreams.size + audioStreams.size + +private fun ExtractionResult.BadRequest.toErrorResponse(): ErrorResponse = + if (message == YOUTUBE_SESSION_RECONNECT_ERROR) { + ErrorResponse(message, "youtube_session_needs_reconnect") + } else { + ErrorResponse(message) + } diff --git a/src/main/kotlin/dev/typetype/server/routes/UserAuth.kt b/src/main/kotlin/dev/typetype/server/routes/UserAuth.kt index 00f4514e..5970649f 100644 --- a/src/main/kotlin/dev/typetype/server/routes/UserAuth.kt +++ b/src/main/kotlin/dev/typetype/server/routes/UserAuth.kt @@ -19,4 +19,11 @@ suspend fun ApplicationCall.withJwtAuth(authService: AuthService, block: suspend return } block(userId) -} \ No newline at end of file +} + +fun ApplicationCall.optionalJwtUserId(authService: AuthService): String? { + val authHeader = request.headers["Authorization"] + if (authHeader == null || !authHeader.startsWith("Bearer ")) return null + val token = authHeader.substringAfter("Bearer ") + return authService.verify(token) +} diff --git a/src/main/kotlin/dev/typetype/server/routes/UserDataRoutes.kt b/src/main/kotlin/dev/typetype/server/routes/UserDataRoutes.kt index 88943c44..634f4604 100644 --- a/src/main/kotlin/dev/typetype/server/routes/UserDataRoutes.kt +++ b/src/main/kotlin/dev/typetype/server/routes/UserDataRoutes.kt @@ -28,6 +28,7 @@ internal fun Route.userDataRoutes( searchHistoryRoutes(svc.searchHistoryService, authService) blockedRoutes(svc.blockedService, authService) notificationsRoutes(svc.notificationsService, authService) + youtubeSessionRoutes(svc.youtubeSessionService, authService) youtubeTakeoutImportRoutes(svc.youtubeTakeoutImportService, authService) profileRoutes(profileService, avatarService, authService) bugReportRoutes(bugReportService, authService) diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeSessionStreamService.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeSessionStreamService.kt new file mode 100644 index 00000000..360a5c23 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeSessionStreamService.kt @@ -0,0 +1,72 @@ +package dev.typetype.server.services + +import dev.typetype.server.cache.CacheService +import dev.typetype.server.models.ExtractionResult +import dev.typetype.server.models.StreamResponse + +const val YOUTUBE_SESSION_RECONNECT_ERROR = "YouTube session needs reconnect" + +class YoutubeSessionStreamService( + private val streamService: StreamService, + private val youtubeSessionService: YoutubeSessionService, + private val cache: CacheService, +) { + suspend fun getStreamInfo(userId: String, url: String): ExtractionResult? { + if (!isYoutubeUrl(url)) return null + val credentials = youtubeSessionService.connectedCredentials(userId) ?: return null + val result = authenticatedCache(credentials, url) + if (result is ExtractionResult.Success) { + youtubeSessionService.markUsed(userId) + return result + } + if (requiresReconnect(result)) { + youtubeSessionService.markNeedsReconnect(userId) + return ExtractionResult.BadRequest(YOUTUBE_SESSION_RECONNECT_ERROR) + } + youtubeSessionService.markUsed(userId) + return result + } + + private suspend fun authenticatedCache( + credentials: YoutubeSessionCredentials, + url: String, + ): ExtractionResult = PublicExtractionCache.getOrLoad( + cache = cache, + area = "stream-auth", + key = PublicCacheKey.of("stream-auth", credentials.userId, credentials.fingerprint, url), + serializer = StreamResponse.serializer(), + ttlSeconds = { minOf(it.streamCacheTtlSeconds(), AUTHENTICATED_STREAM_MAX_TTL_SECONDS) }, + ) { + YoutubeSessionTokenScope.withCredentials(credentials) { + streamService.getStreamInfo(url) + } + } + + private fun requiresReconnect(result: ExtractionResult): Boolean = when (result) { + is ExtractionResult.Success -> false + is ExtractionResult.BadRequest -> result.message.isSessionRejected() + is ExtractionResult.Failure -> result.message.isSessionRejected() + } + + private fun String.isSessionRejected(): Boolean { + val message = lowercase() + return rejectionSignals.any { it in message } + } + + private companion object { + const val AUTHENTICATED_STREAM_MAX_TTL_SECONDS = 900L + val rejectionSignals = listOf( + "sign in", + "not a bot", + "login", + "cookie", + "sapisid", + "po token", + "pot", + "unauthorized", + "forbidden", + "no suitable stream", + "failed to load stream", + ) + } +} From 0f8e4b46a312a592f25f8ab5aa389d3eaab7e7aa Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 17 Jun 2026 22:32:05 +0200 Subject: [PATCH 5/6] test: cover YouTube session API storage --- .../dev/typetype/server/TestDatabase.kt | 4 + .../YoutubeSessionCookieNormalizerTest.kt | 40 ++++++ .../YoutubeSessionCredentialValidatorTest.kt | 16 +++ .../server/YoutubeSessionCryptoTest.kt | 26 ++++ .../server/YoutubeSessionRoutesTest.kt | 119 ++++++++++++++++++ 5 files changed, 205 insertions(+) create mode 100644 src/test/kotlin/dev/typetype/server/YoutubeSessionCookieNormalizerTest.kt create mode 100644 src/test/kotlin/dev/typetype/server/YoutubeSessionCredentialValidatorTest.kt create mode 100644 src/test/kotlin/dev/typetype/server/YoutubeSessionCryptoTest.kt create mode 100644 src/test/kotlin/dev/typetype/server/YoutubeSessionRoutesTest.kt diff --git a/src/test/kotlin/dev/typetype/server/TestDatabase.kt b/src/test/kotlin/dev/typetype/server/TestDatabase.kt index 0baf0999..e9b8351b 100644 --- a/src/test/kotlin/dev/typetype/server/TestDatabase.kt +++ b/src/test/kotlin/dev/typetype/server/TestDatabase.kt @@ -18,6 +18,8 @@ import dev.typetype.server.db.tables.SessionsTable import dev.typetype.server.db.tables.SubscriptionsTable import dev.typetype.server.db.tables.YoutubeTakeoutImportJobsTable import dev.typetype.server.db.tables.YoutubeTakeoutPlaylistKeysTable +import dev.typetype.server.db.tables.YoutubeSessionPairingsTable +import dev.typetype.server.db.tables.YoutubeSessionsTable import dev.typetype.server.db.tables.UsersTable import dev.typetype.server.db.tables.WatchLaterTable import dev.typetype.server.services.AdminSettingsService @@ -98,6 +100,8 @@ object TestDatabase { BlockedVideosTable.deleteAll() YoutubeTakeoutImportJobsTable.deleteAll() YoutubeTakeoutPlaylistKeysTable.deleteAll() + YoutubeSessionPairingsTable.deleteAll() + YoutubeSessionsTable.deleteAll() BugReportsTable.deleteAll() NotificationStatesTable.deleteAll() AdminSettingsService.clearCache() diff --git a/src/test/kotlin/dev/typetype/server/YoutubeSessionCookieNormalizerTest.kt b/src/test/kotlin/dev/typetype/server/YoutubeSessionCookieNormalizerTest.kt new file mode 100644 index 00000000..d11301ae --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/YoutubeSessionCookieNormalizerTest.kt @@ -0,0 +1,40 @@ +package dev.typetype.server + +import dev.typetype.server.services.YoutubeSessionCookieNormalizer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class YoutubeSessionCookieNormalizerTest { + @Test + fun `normalizes cookie header to allowed YouTube cookies only`() { + val normalized = YoutubeSessionCookieNormalizer.normalize( + "Cookie: SID=abc; SAPISID=def; unrelated=value; __Secure-3PSID=ghi" + ) + assertEquals("SID=abc; SAPISID=def; __Secure-3PSID=ghi", normalized) + } + + @Test + fun `normalizes Netscape export without unrelated domains`() { + val normalized = YoutubeSessionCookieNormalizer.normalize( + """ + # Netscape HTTP Cookie File + .example.com TRUE / FALSE 0 SID leak + .youtube.com TRUE / TRUE 0 SID yt + .google.com TRUE / TRUE 0 __Secure-3PSID google + .youtube.com TRUE / TRUE 0 unrelated skip + """.trimIndent() + ) + assertTrue(normalized?.contains("SID=yt") == true) + assertTrue(normalized?.contains("__Secure-3PSID=google") == true) + assertFalse(normalized?.contains("leak") == true) + assertFalse(normalized?.contains("unrelated") == true) + } + + @Test + fun `rejects oversized raw cookie export`() { + assertNull(YoutubeSessionCookieNormalizer.normalize("SID=${"a".repeat(1024 * 1024)}")) + } +} diff --git a/src/test/kotlin/dev/typetype/server/YoutubeSessionCredentialValidatorTest.kt b/src/test/kotlin/dev/typetype/server/YoutubeSessionCredentialValidatorTest.kt new file mode 100644 index 00000000..08ab4ed3 --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/YoutubeSessionCredentialValidatorTest.kt @@ -0,0 +1,16 @@ +package dev.typetype.server + +import dev.typetype.server.services.YoutubeSessionCredentialValidator +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class YoutubeSessionCredentialValidatorTest { + @Test + fun `validates YouTube session cookie and po token shape`() { + assertTrue(YoutubeSessionCredentialValidator.isValid("SID=abc; SAPISID=def", "po-token-value")) + assertTrue(YoutubeSessionCredentialValidator.isValid("__Secure-3PSID=abc", "po-token-value")) + assertFalse(YoutubeSessionCredentialValidator.isValid("SAPISID=def", "po-token-value")) + assertFalse(YoutubeSessionCredentialValidator.isValid("SID=abc", "short")) + } +} diff --git a/src/test/kotlin/dev/typetype/server/YoutubeSessionCryptoTest.kt b/src/test/kotlin/dev/typetype/server/YoutubeSessionCryptoTest.kt new file mode 100644 index 00000000..42be0530 --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/YoutubeSessionCryptoTest.kt @@ -0,0 +1,26 @@ +package dev.typetype.server + +import dev.typetype.server.services.YoutubeSessionCrypto +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class YoutubeSessionCryptoTest { + @Test + fun `encrypts and decrypts without storing plaintext`() { + val crypto = YoutubeSessionCrypto.fromSecret("test-youtube-session-key-32-bytes") + val encrypted = crypto.encrypt("SID=secret-cookie") + assertTrue(encrypted.startsWith("gcm256.")) + assertFalse(encrypted.contains("secret-cookie")) + assertEquals("SID=secret-cookie", crypto.decrypt(encrypted)) + } + + @Test + fun `rejects weak encryption key`() { + assertThrows(IllegalArgumentException::class.java) { + YoutubeSessionCrypto.fromSecret("short") + } + } +} diff --git a/src/test/kotlin/dev/typetype/server/YoutubeSessionRoutesTest.kt b/src/test/kotlin/dev/typetype/server/YoutubeSessionRoutesTest.kt new file mode 100644 index 00000000..30ce45aa --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/YoutubeSessionRoutesTest.kt @@ -0,0 +1,119 @@ +package dev.typetype.server + +import dev.typetype.server.db.DatabaseFactory +import dev.typetype.server.db.tables.YoutubeSessionsTable +import dev.typetype.server.models.YoutubeSessionPairingResponse +import dev.typetype.server.routes.youtubeSessionRoutes +import dev.typetype.server.services.AuthService +import dev.typetype.server.services.YoutubeSessionCrypto +import dev.typetype.server.services.YoutubeSessionPairingStore +import dev.typetype.server.services.YoutubeSessionService +import dev.typetype.server.services.YoutubeSessionStore +import io.ktor.client.request.delete +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.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +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 YoutubeSessionRoutesTest { + private var now = 1_000L + private val auth = AuthService.fixed(TEST_USER_ID) + private val service = YoutubeSessionService( + YoutubeSessionCrypto.fromSecret("test-youtube-session-key-32-bytes"), + YoutubeSessionPairingStore(nowMillis = { now }, codeGenerator = { "ABC12345" }), + YoutubeSessionStore(nowMillis = { now }), + ) + private val json = Json { ignoreUnknownKeys = true } + + companion object { + @BeforeAll + @JvmStatic + fun initDb() { TestDatabase.setup() } + } + @BeforeEach + fun clean() { + now = 1_000L + TestDatabase.truncateAll() + } + private fun withApp(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication { + application { + install(ContentNegotiation) { json(json) } + routing { youtubeSessionRoutes(service, auth) } + } + block() + } + + private suspend fun ApplicationTestBuilder.pairingCode(): String = + json.decodeFromString(client.post("/youtube-session/pairing") { + headers.append(HttpHeaders.Authorization, "Bearer test-jwt") + }.bodyAsText()).code + + private suspend fun ApplicationTestBuilder.completeSession(code: String) = client.post("/youtube-session/complete") { + headers.append(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody("""{"code":"$code","cookies":"SID=secret-cookie","poToken":"secret-pot-value"}""") + } + + @Test + fun `pairing requires auth`() = withApp { + assertEquals(HttpStatusCode.Unauthorized, client.post("/youtube-session/pairing").status) + } + + @Test + fun `complete stores encrypted session and status hides secrets`() = withApp { + assertEquals(HttpStatusCode.NoContent, completeSession(pairingCode()).status) + val status = client.get("/youtube-session/status") { + headers.append(HttpHeaders.Authorization, "Bearer test-jwt") + }.bodyAsText() + assertTrue(status.contains("\"status\":\"connected\"")) + assertFalse(status.contains("secret-cookie")) + assertFalse(status.contains("secret-pot")) + assertCredentialsAreEncrypted() + } + + @Test + fun `delete disconnects session`() = withApp { + completeSession(pairingCode()) + assertEquals(HttpStatusCode.NoContent, client.delete("/youtube-session") { + headers.append(HttpHeaders.Authorization, "Bearer test-jwt") + }.status) + val body = client.get("/youtube-session/status") { + headers.append(HttpHeaders.Authorization, "Bearer test-jwt") + }.bodyAsText() + assertTrue(body.contains("\"status\":\"disconnected\"")) + } + + @Test + fun `complete rejects expired pairing code`() = withApp { + val code = pairingCode() + now += 6 * 60 * 1000L + assertEquals(HttpStatusCode.Gone, completeSession(code).status) + } + + private suspend fun assertCredentialsAreEncrypted() { + val encrypted = DatabaseFactory.query { + val row = YoutubeSessionsTable.selectAll().where { YoutubeSessionsTable.userId eq TEST_USER_ID }.single() + row[YoutubeSessionsTable.encryptedCookies] to row[YoutubeSessionsTable.encryptedPoToken] + } + assertFalse(encrypted.first.contains("secret-cookie")) + assertFalse(encrypted.second.contains("secret-pot")) + } +} From e5de5eba2bd077699f0229437b28cddff2e6573d Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 17 Jun 2026 22:32:17 +0200 Subject: [PATCH 6/6] test: cover YouTube session stream extraction --- .../server/StreamRoutesYoutubeSessionTest.kt | 87 +++++++++++++++++++ .../server/YoutubeSessionStreamServiceTest.kt | 84 ++++++++++++++++++ .../server/YoutubeSessionTokenScopeTest.kt | 43 +++++++++ 3 files changed, 214 insertions(+) create mode 100644 src/test/kotlin/dev/typetype/server/StreamRoutesYoutubeSessionTest.kt create mode 100644 src/test/kotlin/dev/typetype/server/YoutubeSessionStreamServiceTest.kt create mode 100644 src/test/kotlin/dev/typetype/server/YoutubeSessionTokenScopeTest.kt diff --git a/src/test/kotlin/dev/typetype/server/StreamRoutesYoutubeSessionTest.kt b/src/test/kotlin/dev/typetype/server/StreamRoutesYoutubeSessionTest.kt new file mode 100644 index 00000000..664a5ac9 --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/StreamRoutesYoutubeSessionTest.kt @@ -0,0 +1,87 @@ +package dev.typetype.server + +import dev.typetype.server.models.ExtractionResult +import dev.typetype.server.routes.streamRoutes +import dev.typetype.server.services.AuthService +import dev.typetype.server.services.StreamService +import dev.typetype.server.services.YOUTUBE_SESSION_RECONNECT_ERROR +import io.ktor.client.request.get +import io.ktor.client.request.headers +import io.ktor.client.statement.bodyAsText +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.testApplication +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class StreamRoutesYoutubeSessionTest { + private val streamService: StreamService = mockk() + + @Test + fun `GET streams uses authenticated YouTube session when bearer is valid`() = testApplication { + coEvery { streamService.getStreamInfo(any()) } returns ExtractionResult.Failure("public path") + application { testRoutes { userId, _ -> if (userId == TEST_USER_ID) ExtractionResult.Success(testStreamResponse()) else null } } + val response = client.get("/streams?url=https://youtube.com/watch?v=test") { + headers.append(HttpHeaders.Authorization, "Bearer test-jwt") + } + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("no-store", response.headers[HttpHeaders.CacheControl]) + coVerify(exactly = 1) { streamService.getStreamInfo(any()) } + } + + @Test + fun `GET streams merges authenticated HLS with public direct streams`() = testApplication { + coEvery { streamService.getStreamInfo(any()) } returns ExtractionResult.Success( + testStreamResponse(videoOnlyStreams = listOf(testVideoStream()), audioStreams = listOf(testAudioStream())) + ) + application { + testRoutes { _, _ -> + ExtractionResult.Success(testStreamResponse(videoOnlyStreams = emptyList(), audioStreams = emptyList()).copy(hlsUrl = "https://manifest.googlevideo.com/hls.m3u8")) + } + } + val response = client.get("/streams?url=https://youtube.com/watch?v=test") { + headers.append(HttpHeaders.Authorization, "Bearer test-jwt") + } + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("no-store", response.headers[HttpHeaders.CacheControl]) + val body = response.bodyAsText() + assertTrue(body.contains("\"hlsUrl\":\"https://manifest.googlevideo.com/hls.m3u8\"")) + assertTrue(body.contains("\"videoOnlyStreams\":[")) + assertTrue(body.contains("\"audioStreams\":[")) + } + + @Test + fun `GET streams ignores authenticated path without valid bearer`() = testApplication { + coEvery { streamService.getStreamInfo(any()) } returns ExtractionResult.Success(testStreamResponse()) + application { testRoutes { _, _ -> ExtractionResult.Failure("unexpected session path") } } + val response = client.get("/streams?url=https://youtube.com/watch?v=test") + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("public, max-age=21600, stale-while-revalidate=3600", response.headers[HttpHeaders.CacheControl]) + } + + @Test + fun `GET streams returns stable code when YouTube session needs reconnect`() = testApplication { + coEvery { streamService.getStreamInfo(any()) } returns ExtractionResult.Failure("public path") + application { testRoutes { _, _ -> ExtractionResult.BadRequest(YOUTUBE_SESSION_RECONNECT_ERROR) } } + val response = client.get("/streams?url=https://youtube.com/watch?v=test") { + headers.append(HttpHeaders.Authorization, "Bearer test-jwt") + } + assertEquals(HttpStatusCode.BadRequest, response.status) + assertTrue(response.bodyAsText().contains("\"code\":\"youtube_session_needs_reconnect\"")) + } + + private fun io.ktor.server.application.Application.testRoutes( + block: suspend (String, String) -> ExtractionResult?, + ): Unit { + install(ContentNegotiation) { json() } + routing { streamRoutes(streamService, AuthService.fixed(TEST_USER_ID), block) } + } +} diff --git a/src/test/kotlin/dev/typetype/server/YoutubeSessionStreamServiceTest.kt b/src/test/kotlin/dev/typetype/server/YoutubeSessionStreamServiceTest.kt new file mode 100644 index 00000000..daa5688b --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/YoutubeSessionStreamServiceTest.kt @@ -0,0 +1,84 @@ +package dev.typetype.server + +import dev.typetype.server.models.ExtractionResult +import dev.typetype.server.models.StreamResponse +import dev.typetype.server.models.YoutubeSessionCompleteRequest +import dev.typetype.server.services.StreamService +import dev.typetype.server.services.YoutubeSessionCompleteResult +import dev.typetype.server.services.YoutubeSessionCrypto +import dev.typetype.server.services.YoutubeSessionService +import dev.typetype.server.services.YoutubeSessionStreamService +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +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 YoutubeSessionStreamServiceTest { + private val youtubeSessionService = YoutubeSessionService( + YoutubeSessionCrypto.fromSecret("test-youtube-session-key-32-bytes") + ) + + companion object { + @BeforeAll + @JvmStatic + fun initDb() { TestDatabase.setup() } + } + + @BeforeEach + fun clean() { TestDatabase.truncateAll() } + + @Test + fun `non YouTube url does not use authenticated extraction`() = runBlocking { + val service = YoutubeSessionStreamService(failingStreamService(), youtubeSessionService, FakeCacheService()) + assertNull(service.getStreamInfo(TEST_USER_ID, "https://example.com/watch?v=test")) + } + + @Test + fun `rejected authenticated extraction marks session needs reconnect`() = runBlocking { + connectYoutubeSession() + val service = YoutubeSessionStreamService( + failingStreamService("No suitable stream"), + youtubeSessionService, + FakeCacheService(), + ) + val result = service.getStreamInfo(TEST_USER_ID, "https://youtube.com/watch?v=test") + assertTrue(result is ExtractionResult.BadRequest) + assertEquals("needs_reconnect", youtubeSessionService.status(TEST_USER_ID).status) + } + + @Test + fun `successful authenticated extraction is cached by session`() = runBlocking { + connectYoutubeSession() + var calls = 0 + val stream = object : StreamService { + override suspend fun getStreamInfo(url: String): ExtractionResult { + calls += 1 + return ExtractionResult.Success(testStreamResponse()) + } + } + val service = YoutubeSessionStreamService(stream, youtubeSessionService, FakeCacheService()) + assertTrue(service.getStreamInfo(TEST_USER_ID, "https://youtube.com/watch?v=test") is ExtractionResult.Success) + assertTrue(service.getStreamInfo(TEST_USER_ID, "https://youtube.com/watch?v=test") is ExtractionResult.Success) + assertEquals(1, calls) + } + + private suspend fun connectYoutubeSession() { + val pairing = youtubeSessionService.createPairing(TEST_USER_ID) + val result = youtubeSessionService.complete( + YoutubeSessionCompleteRequest( + code = pairing.code, + cookies = "SID=secret-cookie", + poToken = "secret-pot-value", + ) + ) + assertEquals(YoutubeSessionCompleteResult.Completed, result) + } + + private fun failingStreamService(message: String = "unexpected call"): StreamService = object : StreamService { + override suspend fun getStreamInfo(url: String): ExtractionResult = + ExtractionResult.Failure(message) + } +} diff --git a/src/test/kotlin/dev/typetype/server/YoutubeSessionTokenScopeTest.kt b/src/test/kotlin/dev/typetype/server/YoutubeSessionTokenScopeTest.kt new file mode 100644 index 00000000..10da62ed --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/YoutubeSessionTokenScopeTest.kt @@ -0,0 +1,43 @@ +package dev.typetype.server + +import dev.typetype.server.services.YoutubeSessionCredentials +import dev.typetype.server.services.YoutubeSessionTokenScope +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.schabi.newpipe.extractor.ServiceList + +class YoutubeSessionTokenScopeTest { + @Test + fun `withCredentials injects and clears YouTube tokens`() = runBlocking { + val youtube = ServiceList.YouTube + youtube.setTokens("previous-cookies") + youtube.setAdditionalTokens("previous-pot") + val observed = YoutubeSessionTokenScope.withCredentials( + YoutubeSessionCredentials( + userId = TEST_USER_ID, + fingerprint = "session-fingerprint", + cookies = "SID=session-cookie", + poToken = "session-pot-value", + ) + ) { + youtube.tokens to youtube.additionalTokens + } + assertEquals("SID=session-cookie", observed.first) + assertEquals("session-pot-value", observed.second) + assertEquals("", youtube.tokens.orEmpty()) + assertEquals("", youtube.additionalTokens.orEmpty()) + } + + @Test + fun `withoutCredentials clears YouTube tokens for public extraction`() = runBlocking { + val youtube = ServiceList.YouTube + youtube.setTokens("previous-cookies") + youtube.setAdditionalTokens("previous-pot") + val observed = YoutubeSessionTokenScope.withoutCredentials { + youtube.tokens.orEmpty() to youtube.additionalTokens.orEmpty() + } + assertEquals("", observed.first) + assertEquals("", observed.second) + } +}