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
2 changes: 1 addition & 1 deletion src/main/kotlin/dev/typetype/server/Application.kt
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ fun Application.module() {
storyboardProxyRoutes(svc.proxyService)
}
downloaderGatewayRoutes(downloaderGatewayService)
authRoutes(authService, passwordResetService, profileService, adminSettingsService)
authRoutes(authService, passwordResetService, profileService, adminSettingsService, svc.homeRecommendationWarmupService)
adminRoutes(authService, userAdminService, passwordResetService, adminSettingsService)
adminSessionRoutes(authService, activeSessionService)
sessionActivityRoutes(authService, activeSessionService)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import dev.typetype.server.cache.DragonflyService
import dev.typetype.server.services.HomeRecommendationPoolResolver
import dev.typetype.server.services.HomeRecommendationPoolResolverDependencies
import dev.typetype.server.services.HomeRecommendationService
import dev.typetype.server.services.HomeRecommendationWarmupService

data class HomeRecommendationServices(
val recommendationService: HomeRecommendationService,
val warmupService: HomeRecommendationWarmupService,
)

fun createHomeRecommendationServices(
Expand All @@ -17,5 +19,6 @@ fun createHomeRecommendationServices(
val recommendationService = HomeRecommendationService(
poolResolver = HomeRecommendationPoolResolver(resolverDeps),
)
return HomeRecommendationServices(recommendationService)
val warmupService = HomeRecommendationWarmupService(recommendationService, cache)
return HomeRecommendationServices(recommendationService, warmupService)
}
4 changes: 1 addition & 3 deletions src/main/kotlin/dev/typetype/server/ServiceRegistry.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ 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.HomeRecommendationSignalContextService
import dev.typetype.server.services.SearchHistoryService
import dev.typetype.server.services.SettingsService
import dev.typetype.server.services.HomeRecommendationPoolResolverDependencies
Expand Down Expand Up @@ -83,7 +82,6 @@ internal class ServiceRegistry(cache: DragonflyService, subtitleServiceUrl: Stri
val searchHistoryService = SearchHistoryService()
val blockedService = BlockedService()
val bugReportService = BugReportService()
val recommendationSignalContextService = HomeRecommendationSignalContextService(subscriptionsService, historyService, favoritesService)
val youtubeTakeoutImportService = YoutubeTakeoutFactory.create(subscriptionsService, playlistService, historyService, favoritesService, watchLaterService)
val recommendationPoolResolverDependencies = HomeRecommendationPoolResolverDependencies(
subscriptionsService = subscriptionsService,
Expand All @@ -93,10 +91,10 @@ internal class ServiceRegistry(cache: DragonflyService, subtitleServiceUrl: Stri
favoritesService = favoritesService,
watchLaterService = watchLaterService,
blockedService = blockedService,
signalContextService = recommendationSignalContextService,
streamService = streamService,
cache = cache,
)
private val homeRecommendationServices = createHomeRecommendationServices(cache, recommendationPoolResolverDependencies)
val homeRecommendationService = homeRecommendationServices.recommendationService
val homeRecommendationWarmupService = homeRecommendationServices.warmupService
}
17 changes: 12 additions & 5 deletions src/main/kotlin/dev/typetype/server/routes/AuthRoutes.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import dev.typetype.server.models.UserProfileItem
import dev.typetype.server.services.AdminSettingsService
import dev.typetype.server.services.AuthCookieHelpers
import dev.typetype.server.services.AuthService
import dev.typetype.server.services.HomeRecommendationWarmup
import dev.typetype.server.services.NoopHomeRecommendationWarmup
import dev.typetype.server.services.PasswordResetService
import dev.typetype.server.services.ProfileService
import io.ktor.http.HttpStatusCode
Expand All @@ -15,7 +17,7 @@ import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.post

fun Route.authRoutes(authService: AuthService, passwordResetService: PasswordResetService, profileService: ProfileService, adminSettingsService: AdminSettingsService) {
fun Route.authRoutes(authService: AuthService, passwordResetService: PasswordResetService, profileService: ProfileService, adminSettingsService: AdminSettingsService, warmupService: HomeRecommendationWarmup = NoopHomeRecommendationWarmup) {
post("/auth/register") {
val req = call.receive<RegisterRequest>()
if (req.email.isBlank() || req.password.isBlank() || req.name.isBlank()) {
Expand All @@ -29,13 +31,13 @@ fun Route.authRoutes(authService: AuthService, passwordResetService: PasswordRes
}
try {
val token = authService.register(req.email, req.password, req.name)
token.accessToken.warm(authService, warmupService)
AuthCookieHelpers.setRefreshCookie(call.response, token.refreshToken)
call.respond(SessionResponse(token.accessToken))
} catch (e: Exception) {
call.respond(HttpStatusCode.BadRequest, ErrorResponse("Registration failed"))
}
}

post("/auth/login") {
val req = call.receive<LoginRequest>()
val identifier = req.identifier?.trim().orEmpty().ifBlank { req.email?.trim().orEmpty() }
Expand All @@ -45,9 +47,9 @@ fun Route.authRoutes(authService: AuthService, passwordResetService: PasswordRes
return@post
}
AuthCookieHelpers.setRefreshCookie(call.response, token.refreshToken)
token.accessToken.warm(authService, warmupService)
call.respond(SessionResponse(token.accessToken))
}

post("/auth/refresh") {
val req = runCatching { call.receive<RefreshRequest>() }.getOrNull()
val refreshToken = AuthCookieHelpers.extractRefreshToken(call) ?: req?.token
Expand All @@ -57,9 +59,9 @@ fun Route.authRoutes(authService: AuthService, passwordResetService: PasswordRes
return@post
}
AuthCookieHelpers.setRefreshCookie(call.response, newToken.refreshToken)
newToken.accessToken.warm(authService, warmupService)
call.respond(SessionResponse(newToken.accessToken))
}

post("/auth/logout") {
val refreshToken = AuthCookieHelpers.extractRefreshToken(call)
authService.logout(refreshToken)
Expand Down Expand Up @@ -95,7 +97,9 @@ fun Route.authRoutes(authService: AuthService, passwordResetService: PasswordRes
}

post("/auth/guest") {
call.respond(AuthResponse(authService.guestLogin()))
val token = authService.guestLogin()
token.warm(authService, warmupService)
call.respond(AuthResponse(token))
}

post("/auth/reset-password") {
Expand All @@ -111,3 +115,6 @@ fun Route.authRoutes(authService: AuthService, passwordResetService: PasswordRes
if (ok) call.respond(HttpStatusCode.NoContent) else call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid or expired reset token"))
}
}

private fun String.warm(authService: AuthService, warmupService: HomeRecommendationWarmup): Unit =
authService.verify(this)?.let(warmupService::markActive) ?: Unit
14 changes: 10 additions & 4 deletions src/main/kotlin/dev/typetype/server/routes/SubscriptionsRoutes.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package dev.typetype.server.routes
import dev.typetype.server.models.ErrorResponse
import dev.typetype.server.models.SubscriptionItem
import dev.typetype.server.services.AuthService
import dev.typetype.server.services.HomeRecommendationWarmup
import dev.typetype.server.services.NoopHomeRecommendationWarmup
import dev.typetype.server.services.SubscriptionsService
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
Expand All @@ -16,7 +18,7 @@ import io.ktor.server.routing.post
import java.net.URLDecoder
import java.nio.charset.StandardCharsets

fun Route.subscriptionsRoutes(subscriptionsService: SubscriptionsService, authService: AuthService) {
fun Route.subscriptionsRoutes(subscriptionsService: SubscriptionsService, authService: AuthService, warmupService: HomeRecommendationWarmup = NoopHomeRecommendationWarmup) {
get("/subscriptions") {
call.withJwtAuth(authService) { userId -> call.respond(subscriptionsService.getAll(userId)) }
}
Expand All @@ -25,31 +27,35 @@ fun Route.subscriptionsRoutes(subscriptionsService: SubscriptionsService, authSe
val item = runCatching { call.receive<SubscriptionItem>() }.getOrElse {
return@withJwtAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid request body"))
}
call.respond(HttpStatusCode.Created, subscriptionsService.add(userId, item))
val subscription = subscriptionsService.add(userId, item)
warmupService.invalidateAndWarm(userId)
call.respond(HttpStatusCode.Created, subscription)
}
}
delete("/subscriptions") {
call.withJwtAuth(authService) { userId ->
val channelUrl = call.request.queryParameters["url"]?.takeIf { it.isNotBlank() }
?: return@withJwtAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing channelUrl"))
call.respondDeleteResult(subscriptionsService, userId, channelUrl)
call.respondDeleteResult(subscriptionsService, warmupService, userId, channelUrl)
}
}
delete("/subscriptions/{channelUrl...}") {
call.withJwtAuth(authService) { userId ->
val channelUrl = call.extractDeleteChannelUrl()
?: return@withJwtAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing channelUrl"))
call.respondDeleteResult(subscriptionsService, userId, channelUrl)
call.respondDeleteResult(subscriptionsService, warmupService, userId, channelUrl)
}
}
}

private suspend fun ApplicationCall.respondDeleteResult(
subscriptionsService: SubscriptionsService,
warmupService: HomeRecommendationWarmup,
userId: String,
channelUrl: String,
) {
val deleted = subscriptionsService.delete(userId, channelUrl)
if (deleted) warmupService.invalidateAndWarm(userId)
if (deleted) respond(HttpStatusCode.NoContent) else respond(HttpStatusCode.NotFound, ErrorResponse("Not found"))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ internal fun Route.userDataRoutes(
restoreService: PipePipeBackupImporterService,
) {
historyRoutes(svc.historyService, authService)
subscriptionsRoutes(svc.subscriptionsService, authService)
subscriptionsRoutes(svc.subscriptionsService, authService, svc.homeRecommendationWarmupService)
subscriptionFeedRoutes(svc.subscriptionFeedService, authService)
subscriptionShortsFeedRoutes(svc.subscriptionShortsFeedService, authService)
playlistRoutes(svc.playlistService, authService)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ class HomeRecommendationBuilder(
private val favoritesService: FavoritesService,
private val watchLaterService: WatchLaterService,
private val blockedService: BlockedService,
private val signalContextService: HomeRecommendationSignalContextService,
private val streamService: StreamService,
) {
suspend fun build(
Expand All @@ -26,13 +25,12 @@ class HomeRecommendationBuilder(
watchLaterService = watchLaterService,
blockedService = blockedService,
)
val profile = signalService.loadProfile(userId = userId)
val (profile, signalContext) = signalService.load(userId = userId)
val candidates = HomeRecommendationCandidateService(
subscriptionFeedService = subscriptionFeedService,
subscriptionShortsFeedService = subscriptionShortsFeedService,
streamService = streamService,
)
val signalContext = signalContextService.load(userId)
val candidatePool = candidates.fetchCandidates(
userId = userId,
serviceId = serviceId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ package dev.typetype.server.services

object HomeRecommendationCandidateLimits {
const val FAST_SUBSCRIPTION_PAGE_SIZE = 60
const val SUBSCRIPTION_SEED_LIMIT = 20
const val FAVORITE_SEED_LIMIT = 20
const val RELATED_PER_SEED_LIMIT = 18
const val RELATED_DISCOVERY_CAP = 80
const val SUBSCRIPTION_SEED_LIMIT = 8
const val FAVORITE_SEED_LIMIT = 6
const val RELATED_PER_SEED_LIMIT = 10
const val RELATED_DISCOVERY_CAP = 48
const val FAST_THEME_QUERY_LIMIT = 2
const val FULL_THEME_QUERY_LIMIT = 6
const val SIGNAL_QUERY_LIMIT = 4
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ class HomeRecommendationCandidateService(
mode: HomeRecommendationPoolMode,
signalContext: HomeRecommendationSignalContext = HomeRecommendationSignalContext(),
): HomeRecommendationCandidatePool {
if (mode == HomeRecommendationPoolMode.FAST_SHORTS) {
val subscriptions = subscriptionShortsFeedService.getCachedFeed(
userId = userId,
page = 0,
limit = HomeRecommendationCandidateLimits.FAST_SUBSCRIPTION_PAGE_SIZE,
)
?.videos
.orEmpty()
.map { HomeRecommendationTaggedVideo(it, HomeRecommendationSourceTag.SUBSCRIPTION) }
return HomeRecommendationCandidatePool(subscriptions = subscriptions, discovery = emptyList())
}
if (mode == HomeRecommendationPoolMode.SHORTS) {
if (serviceId != YOUTUBE_SERVICE_ID) {
return HomeRecommendationCandidatePool(subscriptions = emptyList(), discovery = emptyList())
Expand All @@ -26,6 +37,9 @@ class HomeRecommendationCandidateService(
}
val subscriptions = fetchSubscriptionCandidates(userId, mode)
.map { HomeRecommendationTaggedVideo(it, HomeRecommendationSourceTag.SUBSCRIPTION) }
if (mode == HomeRecommendationPoolMode.FAST) {
return HomeRecommendationCandidatePool(subscriptions = subscriptions, discovery = emptyList())
}
val subscriptionSeeds = subscriptions.map { it.video.url }
val relatedFromSubscriptions = relatedCandidateService.fetch(
seedUrls = subscriptionSeeds,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class HomeRecommendationPoolBuilder {
context: HomeRecommendationSessionContext,
mode: HomeRecommendationPoolMode = HomeRecommendationPoolMode.FULL,
): HomeRecommendationPool {
val shortsMode = mode == HomeRecommendationPoolMode.SHORTS
val shortsMode = mode == HomeRecommendationPoolMode.SHORTS || mode == HomeRecommendationPoolMode.FAST_SHORTS
val subscriptionsScored = scoreAndFilter(
candidates = subscriptionCandidates,
profile = profile,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,21 @@ class HomeRecommendationPoolCache(private val cache: dev.typetype.server.cache.C
return runCatching { CacheJson.decodeFromString<HomeRecommendationPool>(raw) }.getOrNull()
}

suspend fun readStale(key: String): HomeRecommendationPool? {
val raw = runCatching { cache.get(staleKey(key)) }.getOrNull() ?: return null
return runCatching { CacheJson.decodeFromString<HomeRecommendationPool>(raw) }.getOrNull()
}

suspend fun write(key: String, pool: HomeRecommendationPool) {
runCatching { cache.set(key, CacheJson.encodeToString(pool), CACHE_TTL_SECONDS) }
val raw = CacheJson.encodeToString(pool)
runCatching { cache.set(key, raw, CACHE_TTL_SECONDS) }
runCatching { cache.set(staleKey(key), raw, STALE_TTL_SECONDS) }
}

suspend fun delete(userId: String, serviceId: Int, mode: HomeRecommendationPoolMode) {
val key = key(userId, serviceId, mode, personalizationEnabled = false)
runCatching { cache.delete(key) }
runCatching { cache.delete(staleKey(key)) }
}

fun key(userId: String, serviceId: Int, mode: HomeRecommendationPoolMode, personalizationEnabled: Boolean): String {
Expand All @@ -23,8 +36,11 @@ class HomeRecommendationPoolCache(private val cache: dev.typetype.server.cache.C
return "recommendations:home:$hex"
}

private fun staleKey(key: String): String = "$key:stale"

companion object {
private const val CACHE_TTL_SECONDS = 300L
private const val CACHE_TTL_SECONDS = 3_600L
private const val STALE_TTL_SECONDS = 86_400L
private const val CACHE_VERSION = 8
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ package dev.typetype.server.services
enum class HomeRecommendationPoolMode {
FULL,
FAST,
FAST_SHORTS,
SHORTS,
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull

class HomeRecommendationPoolResolver(
private val dependencies: HomeRecommendationPoolResolverDependencies,
Expand All @@ -31,13 +30,14 @@ class HomeRecommendationPoolResolver(
val cached = poolCache.read(key)
if (cached != null) return cached
val fullBuild = fullBuild(key, userId, serviceId, mode, context)
val quickFull = withTimeoutOrNull(FULL_BUILD_BUDGET_MS) { fullBuild.await() }
if (quickFull != null) {
poolCache.write(key, quickFull)
return quickFull
}
schedulePersistence(key, fullBuild)
val fastMode = if (mode == HomeRecommendationPoolMode.SHORTS) HomeRecommendationPoolMode.SHORTS else HomeRecommendationPoolMode.FAST
val stale = poolCache.readStale(key)
if (stale != null) return stale
val fastMode = if (mode == HomeRecommendationPoolMode.SHORTS) {
HomeRecommendationPoolMode.FAST_SHORTS
} else {
HomeRecommendationPoolMode.FAST
}
return buildPool(userId, serviceId, fastMode, context)
}

Expand Down Expand Up @@ -76,7 +76,6 @@ class HomeRecommendationPoolResolver(
watchLaterService = dependencies.watchLaterService,
blockedService = dependencies.blockedService,
streamService = dependencies.streamService,
signalContextService = dependencies.signalContextService,
).build(
userId = userId,
serviceId = serviceId,
Expand All @@ -92,7 +91,4 @@ class HomeRecommendationPoolResolver(
}
}

companion object {
private const val FULL_BUILD_BUDGET_MS = 1_500L
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ data class HomeRecommendationPoolResolverDependencies(
val favoritesService: FavoritesService,
val watchLaterService: WatchLaterService,
val blockedService: BlockedService,
val signalContextService: HomeRecommendationSignalContextService,
val streamService: StreamService = HomeRecommendationNoopStreamService,
val cache: CacheService,
)

This file was deleted.

Loading