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 build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ dependencies {
implementation("io.ktor:ktor-server-call-logging-jvm")
implementation("io.ktor:ktor-server-rate-limit-jvm")
implementation("ch.qos.logback:logback-classic:1.5.34")
implementation("com.github.InfinityLoop1308.PipePipeExtractor:extractor:290faeb271b589740f13d24a22d775436d395b84")
implementation("com.github.InfinityLoop1308.PipePipeExtractor:extractor:3c8feae0548ba7e30bce92f881a1924121c3dc83")
implementation("org.json:json:20250517")
implementation("com.squareup.okhttp3:okhttp:5.3.2")
implementation("io.lettuce:lettuce-core:7.6.0.RELEASE")
Expand Down
6 changes: 6 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ paths:
$ref: ./openapi/paths/podcasts.yaml#/Podcasts
/podcasts/episodes:
$ref: ./openapi/paths/podcasts.yaml#/PodcastEpisodes
/playlist:
$ref: ./openapi/paths/playlists.yaml#/Playlist
/comments:
$ref: ./openapi/paths/comments.yaml#/Comments
/comments/replies:
Expand Down Expand Up @@ -63,6 +65,10 @@ components:
$ref: ./openapi/components/media.yaml#/PodcastPageResponse
PodcastEpisodesResponse:
$ref: ./openapi/components/media.yaml#/PodcastEpisodesResponse
PublicPlaylistResponse:
$ref: ./openapi/components/media.yaml#/PublicPlaylistResponse
PublicPlaylistItem:
$ref: ./openapi/components/media.yaml#/PublicPlaylistItem
CommentsPageResponse:
$ref: ./openapi/components/media.yaml#/CommentsPageResponse
DownloadJob:
Expand Down
18 changes: 18 additions & 0 deletions openapi/components/media.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,24 @@ PodcastEpisodesResponse:
podcast: { $ref: '#/PodcastItem' }
episodes: { type: array, items: { $ref: '#/VideoItem' } }
nextpage: { type: string, nullable: true }
PublicPlaylistItem:
type: object
required: [id, title, url, thumbnailUrl, uploaderName, streamCount, playlistType]
properties:
id: { type: string }
title: { type: string }
url: { type: string }
thumbnailUrl: { type: string }
uploaderName: { type: string }
streamCount: { type: integer, format: int64 }
playlistType: { type: string }
PublicPlaylistResponse:
type: object
required: [playlist, videos, nextpage]
properties:
playlist: { $ref: '#/PublicPlaylistItem' }
videos: { type: array, items: { $ref: '#/VideoItem' } }
nextpage: { type: string, nullable: true }
CommentItem:
type: object
required: [id, text, author, authorUrl, authorAvatarUrl, likeCount, textualLikeCount, publishedTime, isHeartedByUploader, isPinned, uploaderVerified, replyCount, repliesPage]
Expand Down
27 changes: 27 additions & 0 deletions openapi/paths/playlists.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
Playlist:
get:
tags: [extraction]
summary: Get public YouTube playlist videos
parameters:
- name: url
in: query
required: true
schema: { type: string }
- name: nextpage
in: query
required: false
schema: { type: string }
responses:
'200':
description: Public YouTube playlist page.
headers:
X-Request-ID:
$ref: ../components/common.yaml#/RequestIdHeader
content:
application/json:
schema:
$ref: ../components/media.yaml#/PublicPlaylistResponse
'400':
$ref: ../components/common.yaml#/JsonError
'422':
$ref: ../components/common.yaml#/JsonError
2 changes: 2 additions & 0 deletions src/main/kotlin/dev/typetype/server/Application.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import dev.typetype.server.routes.manifestRoutes
import dev.typetype.server.routes.nicoVideoProxyRoutes
import dev.typetype.server.routes.podcastRoutes
import dev.typetype.server.routes.proxyRoutes
import dev.typetype.server.routes.publicPlaylistRoutes
import dev.typetype.server.routes.storyboardProxyRoutes
import dev.typetype.server.routes.searchRoutes
import dev.typetype.server.routes.streamRoutes
Expand Down Expand Up @@ -90,6 +91,7 @@ fun Application.module() {
searchRoutes(svc.searchService)
suggestionRoutes(svc.suggestionService)
trendingRoutes(svc.trendingService)
publicPlaylistRoutes(svc.publicPlaylistService)
commentRoutes(svc.commentService)
bulletCommentRoutes(svc.bulletCommentService)
}
Expand Down
3 changes: 3 additions & 0 deletions src/main/kotlin/dev/typetype/server/ServiceRegistry.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ 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
Expand All @@ -27,6 +28,7 @@ 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
Expand Down Expand Up @@ -61,6 +63,7 @@ internal class ServiceRegistry(cache: DragonflyService, subtitleServiceUrl: Stri
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)
Expand Down
14 changes: 14 additions & 0 deletions src/main/kotlin/dev/typetype/server/models/PublicPlaylistItem.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package dev.typetype.server.models

import kotlinx.serialization.Serializable

@Serializable
data class PublicPlaylistItem(
val id: String,
val title: String,
val url: String,
val thumbnailUrl: String,
val uploaderName: String,
val streamCount: Long,
val playlistType: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package dev.typetype.server.models

import kotlinx.serialization.Serializable

@Serializable
data class PublicPlaylistResponse(
val playlist: PublicPlaylistItem,
val videos: List<VideoItem>,
val nextpage: String?,
)
23 changes: 23 additions & 0 deletions src/main/kotlin/dev/typetype/server/routes/PublicPlaylistRoutes.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package dev.typetype.server.routes

import dev.typetype.server.models.ErrorResponse
import dev.typetype.server.models.ExtractionResult
import dev.typetype.server.services.PublicPlaylistService
import io.ktor.http.HttpStatusCode
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get

fun Route.publicPlaylistRoutes(playlistService: PublicPlaylistService) {
get("/playlist") {
val url = call.request.queryParameters["url"]
?: return@get call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing 'url' parameter"))
val nextpage = call.request.queryParameters["nextpage"]

when (val result = playlistService.getPlaylist(url = url, nextpage = nextpage)) {
is ExtractionResult.Success -> call.respond(result.data)
is ExtractionResult.BadRequest -> call.respond(HttpStatusCode.BadRequest, ErrorResponse(result.message))
is ExtractionResult.Failure -> call.respond(HttpStatusCode.UnprocessableEntity, ErrorResponse(result.message))
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package dev.typetype.server.services

import dev.typetype.server.cache.CacheService
import dev.typetype.server.models.ExtractionResult
import dev.typetype.server.models.PublicPlaylistResponse

class CachedPublicPlaylistService(
private val delegate: PublicPlaylistService,
private val cache: CacheService,
) : PublicPlaylistService {
override suspend fun getPlaylist(url: String, nextpage: String?): ExtractionResult<PublicPlaylistResponse> =
PublicExtractionCache.getOrLoad(
cache = cache,
area = "playlist",
key = PublicCacheKey.of("playlist", url, nextpage),
serializer = PublicPlaylistResponse.serializer(),
ttlSeconds = { PublicCachePolicy.playlistTtl(nextpage) },
) { delegate.getPlaylist(url, nextpage) }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package dev.typetype.server.services

import dev.typetype.server.models.ExtractionResult
import dev.typetype.server.models.PublicPlaylistResponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.playlist.PlaylistInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem

class PipePipePublicPlaylistService : PublicPlaylistService {
override suspend fun getPlaylist(url: String, nextpage: String?): ExtractionResult<PublicPlaylistResponse> =
withContext(Dispatchers.IO) {
val page = if (nextpage != null) {
runCatching { nextpage.toPage() }
.getOrElse { return@withContext ExtractionResult.BadRequest("Invalid nextpage cursor") }
} else null
val service = runCatching { NewPipe.getServiceByUrl(url) }
.getOrElse { return@withContext ExtractionResult.BadRequest("Unsupported playlist URL") }
if (service.serviceId != YOUTUBE_SERVICE_ID) {
return@withContext ExtractionResult.BadRequest("Public playlists are only supported for YouTube")
}

runCatching {
withExtractionRetry {
withTimeout(30_000L) {
if (page == null) PlaylistInfo.getInfo(service, url).toPublicPlaylistResponse()
else PlaylistInfo.getMoreItems(service, url, page).toPublicPlaylistResponse(url)
}
}
}.fold(
onSuccess = { ExtractionResult.Success(it) },
onFailure = { ExtractionResult.Failure(it.message ?: "Playlist extraction failed") }
)
}

private fun PlaylistInfo.toPublicPlaylistResponse(): PublicPlaylistResponse = PublicPlaylistResponse(
playlist = toPublicPlaylistItem(),
videos = relatedItems.map { it.toVideoItem() },
nextpage = nextPage?.toCursor(),
)

private fun InfoItemsPage<StreamInfoItem>.toPublicPlaylistResponse(url: String): PublicPlaylistResponse =
PublicPlaylistResponse(
playlist = emptyPublicPlaylistItem(url),
videos = items.map { it.toVideoItem() },
nextpage = nextPage?.toCursor(),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ internal object PublicCachePolicy {
else -> 180L
}
}

fun playlistTtl(nextpage: String?): Long = if (nextpage == null) 3_600L else 1_800L
}

private fun String.serviceHint(): Int? = when {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package dev.typetype.server.services

import dev.typetype.server.models.PublicPlaylistItem
import org.schabi.newpipe.extractor.playlist.PlaylistInfo

internal fun PlaylistInfo.toPublicPlaylistItem(): PublicPlaylistItem = PublicPlaylistItem(
id = url ?: "",
title = name ?: "",
url = url ?: "",
thumbnailUrl = thumbnailUrl.toAbsoluteUrl(),
uploaderName = uploaderName ?: "",
streamCount = streamCount,
playlistType = playlistType?.name?.lowercase() ?: "",
)

internal fun emptyPublicPlaylistItem(url: String): PublicPlaylistItem = PublicPlaylistItem(
id = url,
title = "",
url = url,
thumbnailUrl = "",
uploaderName = "",
streamCount = -1L,
playlistType = "",
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package dev.typetype.server.services

import dev.typetype.server.models.ExtractionResult
import dev.typetype.server.models.PublicPlaylistResponse

interface PublicPlaylistService {
suspend fun getPlaylist(url: String, nextpage: String?): ExtractionResult<PublicPlaylistResponse>
}
74 changes: 74 additions & 0 deletions src/test/kotlin/dev/typetype/server/PublicPlaylistRoutesTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package dev.typetype.server

import dev.typetype.server.models.ExtractionResult
import dev.typetype.server.models.PublicPlaylistItem
import dev.typetype.server.models.PublicPlaylistResponse
import dev.typetype.server.routes.publicPlaylistRoutes
import dev.typetype.server.services.PublicPlaylistService
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
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 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 PublicPlaylistRoutesTest {
private val playlistService: PublicPlaylistService = mockk()

private fun withApp(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication {
application {
install(ContentNegotiation) { json() }
routing { publicPlaylistRoutes(playlistService) }
}
block()
}

@Test
fun `GET playlist without url returns 400`() = withApp {
val response = client.get("/playlist")
assertEquals(HttpStatusCode.BadRequest, response.status)
}

@Test
fun `GET playlist returns public playlist response`() = withApp {
coEvery { playlistService.getPlaylist(any(), any()) } returns ExtractionResult.Success(testPlaylistResponse())

val response = client.get("/playlist?url=https://youtube.com/playlist?list=test&nextpage=cursor")

assertEquals(HttpStatusCode.OK, response.status)
assertTrue(response.bodyAsText().contains("\"playlist\""))
coVerify { playlistService.getPlaylist("https://youtube.com/playlist?list=test", "cursor") }
}

@Test
fun `GET playlist returns 422 on extraction failure`() = withApp {
coEvery { playlistService.getPlaylist(any(), any()) } returns ExtractionResult.Failure("error")

val response = client.get("/playlist?url=https://youtube.com/playlist?list=test")

assertEquals(HttpStatusCode.UnprocessableEntity, response.status)
}

private fun testPlaylistResponse(): PublicPlaylistResponse = PublicPlaylistResponse(
playlist = PublicPlaylistItem(
id = "https://youtube.com/playlist?list=test",
title = "Test Playlist",
url = "https://youtube.com/playlist?list=test",
thumbnailUrl = "",
uploaderName = "Test Channel",
streamCount = 1L,
playlistType = "normal",
),
videos = emptyList(),
nextpage = null,
)
}
Loading