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 @@ -43,6 +43,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 }
/rss/feeds: { $ref: ./openapi/paths/rss.yaml#/RssFeeds }
/rss/feeds/{id}: { $ref: ./openapi/paths/rss.yaml#/RssFeed }
Expand Down Expand Up @@ -146,6 +150,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 }
RssFeedRequest: { $ref: ./openapi/components/rss.yaml#/RssFeedRequest }
Expand Down
34 changes: 34 additions & 0 deletions openapi/components/subscriptions.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,37 @@
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 }
SubscriptionCreateRequest:
type: object
required: [channelUrl, name, avatarUrl]
properties:
channelUrl: { type: string, minLength: 1 }
name: { type: string }
avatarUrl: { type: string }
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
12 changes: 12 additions & 0 deletions openapi/components/user-backup.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@ TypeTypeContentFiltersBackup:
allowedPlaylists:
type: array
items: { $ref: ./access-control.yaml#/AllowedPlaylistItem }
SubscriptionGroupBackupItem:
type: object
required: [name, channelUrls, createdAt, updatedAt]
properties:
name: { type: string, minLength: 1, maxLength: 100 }
channelUrls:
type: array
uniqueItems: true
items: { type: string, minLength: 1 }
createdAt: { type: integer, format: int64 }
updatedAt: { type: integer, format: int64 }
TypeTypeBackupItem:
type: object
required: [format, version, exportedAt, categories]
Expand All @@ -47,6 +58,7 @@ TypeTypeBackupItem:
- settings
- contentFilters
subscriptions: { type: array, nullable: true, items: { type: object, additionalProperties: true } }
subscriptionGroups: { type: array, nullable: true, items: { $ref: '#/SubscriptionGroupBackupItem' } }
history: { type: array, nullable: true, items: { type: object, additionalProperties: true } }
playlists: { type: array, nullable: true, items: { type: object, additionalProperties: true } }
watchLater: { type: array, nullable: true, items: { type: object, additionalProperties: true } }
Expand Down
166 changes: 165 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#/SubscriptionCreateRequest }
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 membership snapshot.
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 All @@ -54,3 +209,12 @@ SubscriptionFeed:
application/json:
schema:
$ref: ../components/common.yaml#/ErrorResponse
'429':
description: The account already has the maximum number of active filtered cursor sessions.
headers:
X-Request-ID:
$ref: ../components/common.yaml#/RequestIdHeader
content:
application/json:
schema:
$ref: ../components/common.yaml#/ErrorResponse
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 @@ -29,6 +29,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 @@ -84,6 +85,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/cache/CacheService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,9 @@ package dev.typetype.server.cache
interface CacheService {
suspend fun get(key: String): String?
suspend fun set(key: String, value: String, ttlSeconds: Long)
suspend fun setIfAbsent(key: String, value: String, ttlSeconds: Long): Boolean =
throw UnsupportedOperationException("Atomic set-if-absent is not supported")
suspend fun refreshIfValueMatches(key: String, value: String, ttlSeconds: Long): Boolean =
throw UnsupportedOperationException("Atomic compare-and-expire is not supported")
suspend fun delete(key: String)
}
19 changes: 19 additions & 0 deletions src/main/kotlin/dev/typetype/server/cache/DragonflyService.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package dev.typetype.server.cache

import io.lettuce.core.RedisClient
import io.lettuce.core.ScriptOutputType
import io.lettuce.core.SetArgs
import io.lettuce.core.api.StatefulRedisConnection
import io.lettuce.core.api.async.RedisAsyncCommands
import kotlinx.coroutines.future.await
Expand All @@ -17,8 +19,25 @@ class DragonflyService(url: String) : CacheService {
override suspend fun set(key: String, value: String, ttlSeconds: Long): Unit =
async.setex(key, ttlSeconds, value).await().let {}

override suspend fun setIfAbsent(key: String, value: String, ttlSeconds: Long): Boolean =
async.set(key, value, SetArgs.Builder.nx().ex(ttlSeconds)).await() == "OK"

override suspend fun refreshIfValueMatches(key: String, value: String, ttlSeconds: Long): Boolean =
async.eval<Long>(
REFRESH_IF_VALUE_MATCHES,
ScriptOutputType.INTEGER,
arrayOf(key),
value,
ttlSeconds.toString(),
).await() == 1L

override suspend fun delete(key: String): Unit =
async.del(key).await().let {}

suspend fun ping(): Boolean = async.ping().await() == "PONG"

private companion object {
const val REFRESH_IF_VALUE_MATCHES =
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('expire', KEYS[1], ARGV[2]) end return 0"
}
}
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 @@ -57,6 +59,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)
}
Loading