Skip to content
Open
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
8 changes: 8 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ paths:
/playlist: { $ref: ./openapi/paths/playlists.yaml#/Playlist }
/saved-playlists: { $ref: ./openapi/paths/saved-playlists.yaml#/SavedPlaylists }
/saved-playlists/{id}: { $ref: ./openapi/paths/saved-playlists.yaml#/SavedPlaylist }
/subscriptions: { $ref: ./openapi/paths/subscriptions.yaml#/Subscriptions }
/subscriptions/groups: { $ref: ./openapi/paths/subscriptions.yaml#/SubscriptionGroups }
/subscriptions/groups/{groupId}: { $ref: ./openapi/paths/subscriptions.yaml#/SubscriptionGroup }
/subscriptions/groups/{groupId}/channels: { $ref: ./openapi/paths/subscriptions.yaml#/SubscriptionGroupChannels }
/subscriptions/feed: { $ref: ./openapi/paths/subscriptions.yaml#/SubscriptionFeed }
/settings: { $ref: ./openapi/paths/access-control.yaml#/Settings }
/backup/typetype: { $ref: ./openapi/paths/user-backup.yaml#/TypeTypeBackup }
Expand Down Expand Up @@ -136,6 +140,10 @@ components:
$ref: ./openapi/components/media.yaml#/PublicPlaylistItem
SavedPlaylistItem: { $ref: ./openapi/components/media.yaml#/SavedPlaylistItem }
SavedPlaylistRequest: { $ref: ./openapi/components/media.yaml#/SavedPlaylistRequest }
SubscriptionItem: { $ref: ./openapi/components/subscriptions.yaml#/SubscriptionItem }
SubscriptionGroupItem: { $ref: ./openapi/components/subscriptions.yaml#/SubscriptionGroupItem }
SubscriptionGroupRequest: { $ref: ./openapi/components/subscriptions.yaml#/SubscriptionGroupRequest }
SubscriptionGroupMembershipRequest: { $ref: ./openapi/components/subscriptions.yaml#/SubscriptionGroupMembershipRequest }
SubscriptionFeedResponse: { $ref: ./openapi/components/subscriptions.yaml#/SubscriptionFeedResponse }
SubscriptionFeedPreparingResponse: { $ref: ./openapi/components/subscriptions.yaml#/SubscriptionFeedPreparingResponse }
SettingsItem: { $ref: ./openapi/components/access-control.yaml#/SettingsItem }
Expand Down
27 changes: 27 additions & 0 deletions openapi/components/subscriptions.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,30 @@
SubscriptionItem:
type: object
required: [channelUrl, name, avatarUrl, subscribedAt]
properties:
channelUrl: { type: string, minLength: 1 }
name: { type: string }
avatarUrl: { type: string }
subscribedAt: { type: integer, format: int64 }
SubscriptionGroupItem:
type: object
required: [id, name, channelCount, createdAt, updatedAt]
properties:
id: { type: string, format: uuid }
name: { type: string, minLength: 1, maxLength: 100 }
channelCount: { type: integer, minimum: 0 }
createdAt: { type: integer, format: int64 }
updatedAt: { type: integer, format: int64 }
SubscriptionGroupRequest:
type: object
required: [name]
properties:
name: { type: string, minLength: 1, maxLength: 100 }
SubscriptionGroupMembershipRequest:
type: object
required: [channelUrl]
properties:
channelUrl: { type: string, minLength: 1 }
SubscriptionFeedResponse:
type: object
required: [videos, nextpage, generation, generatedAt, refreshing]
Expand Down
157 changes: 156 additions & 1 deletion openapi/paths/subscriptions.yaml
Original file line number Diff line number Diff line change
@@ -1,8 +1,161 @@
Subscriptions:
get:
tags: [user-data]
summary: List the current user's subscriptions
description: Omit both filters for the global list. Use groupId for one named group or ungrouped=true for subscriptions in no groups.
parameters:
- name: groupId
in: query
required: false
schema: { type: string, format: uuid }
- name: ungrouped
in: query
required: false
schema: { type: boolean, default: false }
responses:
'200':
description: The selected subscription projection.
content:
application/json:
schema:
type: array
items: { $ref: ../components/subscriptions.yaml#/SubscriptionItem }
'400': { $ref: ../components/common.yaml#/JsonError }
'401': { $ref: ../components/common.yaml#/JsonError }
'404': { $ref: ../components/common.yaml#/JsonError }
post:
tags: [user-data]
summary: Subscribe to a channel
requestBody:
required: true
content:
application/json:
schema: { $ref: ../components/subscriptions.yaml#/SubscriptionItem }
responses:
'201':
description: Subscription created.
content:
application/json:
schema: { $ref: ../components/subscriptions.yaml#/SubscriptionItem }
'400': { $ref: ../components/common.yaml#/JsonError }
'401': { $ref: ../components/common.yaml#/JsonError }
delete:
tags: [user-data]
summary: Unsubscribe from a channel
parameters:
- name: url
in: query
required: true
schema: { type: string, minLength: 1 }
responses:
'204': { description: Subscription deleted. }
'400': { $ref: ../components/common.yaml#/JsonError }
'401': { $ref: ../components/common.yaml#/JsonError }
'404': { $ref: ../components/common.yaml#/JsonError }
SubscriptionGroups:
get:
tags: [user-data]
summary: List the current user's subscription groups
responses:
'200':
description: Account-scoped named groups.
content:
application/json:
schema:
type: array
items: { $ref: ../components/subscriptions.yaml#/SubscriptionGroupItem }
'401': { $ref: ../components/common.yaml#/JsonError }
post:
tags: [user-data]
summary: Create a subscription group
requestBody:
required: true
content:
application/json:
schema: { $ref: ../components/subscriptions.yaml#/SubscriptionGroupRequest }
responses:
'201':
description: Group created.
content:
application/json:
schema: { $ref: ../components/subscriptions.yaml#/SubscriptionGroupItem }
'400': { $ref: ../components/common.yaml#/JsonError }
'401': { $ref: ../components/common.yaml#/JsonError }
'409': { $ref: ../components/common.yaml#/JsonError }
SubscriptionGroup:
parameters:
- name: groupId
in: path
required: true
schema: { type: string, format: uuid }
put:
tags: [user-data]
summary: Rename a subscription group
requestBody:
required: true
content:
application/json:
schema: { $ref: ../components/subscriptions.yaml#/SubscriptionGroupRequest }
responses:
'204': { description: Group renamed. }
'400': { $ref: ../components/common.yaml#/JsonError }
'401': { $ref: ../components/common.yaml#/JsonError }
'404': { $ref: ../components/common.yaml#/JsonError }
'409': { $ref: ../components/common.yaml#/JsonError }
delete:
tags: [user-data]
summary: Delete a subscription group
responses:
'204': { description: Group and its memberships deleted. }
'401': { $ref: ../components/common.yaml#/JsonError }
'404': { $ref: ../components/common.yaml#/JsonError }
SubscriptionGroupChannels:
parameters:
- name: groupId
in: path
required: true
schema: { type: string, format: uuid }
put:
tags: [user-data]
summary: Add a subscribed channel to a group
requestBody:
required: true
content:
application/json:
schema: { $ref: ../components/subscriptions.yaml#/SubscriptionGroupMembershipRequest }
responses:
'204': { description: Membership exists. }
'400': { $ref: ../components/common.yaml#/JsonError }
'401': { $ref: ../components/common.yaml#/JsonError }
'404': { $ref: ../components/common.yaml#/JsonError }
delete:
tags: [user-data]
summary: Remove a subscribed channel from a group
parameters:
- name: url
in: query
required: true
schema: { type: string, minLength: 1 }
responses:
'204': { description: Membership deleted. }
'400': { $ref: ../components/common.yaml#/JsonError }
'401': { $ref: ../components/common.yaml#/JsonError }
'404': { $ref: ../components/common.yaml#/JsonError }
SubscriptionFeed:
get:
tags: [user-data]
summary: Read a stable page from the current user's subscription feed snapshot
parameters:
- name: groupId
in: query
required: false
description: Restrict the snapshot projection to subscriptions in one account-owned group.
schema: { type: string, format: uuid }
- name: ungrouped
in: query
required: false
description: Restrict the snapshot projection to subscriptions in no groups.
schema: { type: boolean, default: false }
- name: page
in: query
required: false
Expand All @@ -16,7 +169,7 @@ SubscriptionFeed:
- name: cursor
in: query
required: false
description: Opaque continuation returned in nextpage.
description: Opaque continuation returned in nextpage and bound to the selected subscription filter.
schema: { type: string }
responses:
'200':
Expand Down Expand Up @@ -45,6 +198,8 @@ SubscriptionFeed:
$ref: ../components/common.yaml#/JsonError
'401':
$ref: ../components/common.yaml#/JsonError
'404':
$ref: ../components/common.yaml#/JsonError
'409':
description: The cursor references a snapshot generation that is no longer retained.
headers:
Expand Down
2 changes: 2 additions & 0 deletions src/main/kotlin/dev/typetype/server/ServiceRegistry.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import dev.typetype.server.services.SubscriptionFeedService
import dev.typetype.server.services.SubscriptionShortsBlendService
import dev.typetype.server.services.SubscriptionShortsFeedService
import dev.typetype.server.services.SubscriptionsService
import dev.typetype.server.services.SubscriptionGroupsService
import dev.typetype.server.services.SubscriptionFeedCacheInvalidation
import dev.typetype.server.services.SubscriptionFeedCacheInvalidator
import dev.typetype.server.services.TypeTypeBackupService
Expand Down Expand Up @@ -80,6 +81,7 @@ internal class ServiceRegistry(
val sabrSessionStore = extraction.sabrSessionStore
val historyService = HistoryService()
val subscriptionsService = SubscriptionsService()
val subscriptionGroupsService = SubscriptionGroupsService()
val subscriptionFeedService = SubscriptionFeedService(subscriptionsService, channelService, cache)
val subscriptionShortsFeedService = SubscriptionShortsFeedService(
subscriptionsService,
Expand Down
4 changes: 4 additions & 0 deletions src/main/kotlin/dev/typetype/server/db/DatabaseFactory.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import dev.typetype.server.db.tables.SearchHistoryTable
import dev.typetype.server.db.tables.SettingsTable
import dev.typetype.server.db.tables.SessionsTable
import dev.typetype.server.db.tables.SubscriptionsTable
import dev.typetype.server.db.tables.SubscriptionGroupMembershipsTable
import dev.typetype.server.db.tables.SubscriptionGroupsTable
import dev.typetype.server.db.tables.UsersTable
import dev.typetype.server.db.tables.UserAvatarsTable
import dev.typetype.server.db.tables.WatchLaterTable
Expand Down Expand Up @@ -53,6 +55,8 @@ object DatabaseFactory {
AdminSettingsTable,
HistoryTable,
SubscriptionsTable,
SubscriptionGroupsTable,
SubscriptionGroupMembershipsTable,
PlaylistsTable,
PlaylistVideosTable,
WatchLaterTable,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package dev.typetype.server.db.tables

import org.jetbrains.exposed.v1.core.ReferenceOption
import org.jetbrains.exposed.v1.core.Table

object SubscriptionGroupMembershipsTable : Table("subscription_group_memberships") {
val groupId = text("group_id").references(SubscriptionGroupsTable.id, onDelete = ReferenceOption.CASCADE)
val userId = text("user_id")
val channelUrl = text("channel_url")
val addedAt = long("added_at")

init {
index(false, userId, channelUrl)
}

override val primaryKey = PrimaryKey(groupId, channelUrl)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package dev.typetype.server.db.tables

import org.jetbrains.exposed.v1.core.Table

object SubscriptionGroupsTable : Table("subscription_groups") {
val id = text("id")
val userId = text("user_id")
val name = text("name")
val normalizedName = text("normalized_name")
val createdAt = long("created_at")
val updatedAt = long("updated_at")

init {
uniqueIndex(userId, normalizedName)
}

override val primaryKey = PrimaryKey(id)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package dev.typetype.server.models

import kotlinx.serialization.Serializable

@Serializable
data class SubscriptionGroupItem(
val id: String,
val name: String,
val channelCount: Int,
val createdAt: Long,
val updatedAt: Long,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package dev.typetype.server.models

import kotlinx.serialization.Serializable

@Serializable
data class SubscriptionGroupMembershipRequest(val channelUrl: String)
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package dev.typetype.server.models

import kotlinx.serialization.Serializable

@Serializable
data class SubscriptionGroupRequest(val name: String)
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import dev.typetype.server.models.SubscriptionFeedPreparingResponse
import dev.typetype.server.services.AuthService
import dev.typetype.server.services.SubscriptionFeedPageResult
import dev.typetype.server.services.SubscriptionFeedService
import dev.typetype.server.services.SubscriptionGroupsService
import dev.typetype.server.services.SubscriptionSelection
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.server.response.respond
Expand All @@ -13,14 +15,29 @@ import io.ktor.server.routing.get

private const val MAX_FEED_PAGE = 10_000

fun Route.subscriptionFeedRoutes(feedService: SubscriptionFeedService, authService: AuthService) {
fun Route.subscriptionFeedRoutes(
feedService: SubscriptionFeedService,
authService: AuthService,
groupsService: SubscriptionGroupsService = SubscriptionGroupsService(),
) {
get("/subscriptions/feed") {
call.withJwtAuth(authService) { userId ->
val parsed = call.parseSubscriptionSelection()
if (parsed !is SubscriptionSelectionParseResult.Valid) {
return@withJwtAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid subscription filter"))
}
val selection = parsed.selection
if (selection is SubscriptionSelection.Group && !groupsService.exists(userId, selection.id)) {
return@withJwtAuth call.respond(
HttpStatusCode.NotFound,
ErrorResponse("Subscription group not found", "subscription_group_not_found"),
)
}
val page = call.request.queryParameters["page"]?.toIntOrNull()?.coerceIn(0, MAX_FEED_PAGE) ?: 0
val limit = call.request.queryParameters["limit"]?.toIntOrNull()?.coerceIn(1, 100) ?: 30
val cursor = call.request.queryParameters["cursor"]
call.response.headers.append(HttpHeaders.CacheControl, "no-store")
when (val result = feedService.getPage(userId, page, limit, cursor)) {
when (val result = feedService.getPage(userId, page, limit, cursor, selection = selection)) {
is SubscriptionFeedPageResult.Ready -> call.respond(result.response)
is SubscriptionFeedPageResult.Preparing -> {
call.response.headers.append(HttpHeaders.RetryAfter, "1")
Expand Down
Loading