diff --git a/build.gradle.kts b/build.gradle.kts index 3ab906f..868448c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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.32") - implementation("com.github.InfinityLoop1308.PipePipeExtractor:extractor:a69bcc15d146d391a695210a52cbde7b3fff1137") + implementation("com.github.InfinityLoop1308.PipePipeExtractor:extractor:3e06f36ec130ca452dcf107f223eedbc30a07dc2") implementation("com.squareup.okhttp3:okhttp:5.3.2") implementation("io.lettuce:lettuce-core:7.5.2.RELEASE") implementation("org.jetbrains.exposed:exposed-core:1.3.0") @@ -74,6 +74,7 @@ tasks.test { useJUnitPlatform { excludeTags("network") } + jvmArgs("-XX:+EnableDynamicAgentLoading", "-Xshare:off") finalizedBy(tasks.jacocoTestReport) } diff --git a/openapi.yaml b/openapi.yaml index b37f97d..9e1b49a 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -21,6 +21,10 @@ paths: $ref: ./openapi/paths/search.yaml#/Search /channel: $ref: ./openapi/paths/channel.yaml#/Channel + /podcasts: + $ref: ./openapi/paths/podcasts.yaml#/Podcasts + /podcasts/episodes: + $ref: ./openapi/paths/podcasts.yaml#/PodcastEpisodes /comments: $ref: ./openapi/paths/comments.yaml#/Comments /comments/replies: @@ -51,6 +55,10 @@ components: $ref: ./openapi/components/media.yaml#/SearchPageResponse ChannelResponse: $ref: ./openapi/components/media.yaml#/ChannelResponse + PodcastPageResponse: + $ref: ./openapi/components/media.yaml#/PodcastPageResponse + PodcastEpisodesResponse: + $ref: ./openapi/components/media.yaml#/PodcastEpisodesResponse CommentsPageResponse: $ref: ./openapi/components/media.yaml#/CommentsPageResponse DownloadJob: diff --git a/openapi/components/media.yaml b/openapi/components/media.yaml index 6360e38..1c5010c 100644 --- a/openapi/components/media.yaml +++ b/openapi/components/media.yaml @@ -38,6 +38,33 @@ ChannelResponse: isVerified: { type: boolean } videos: { type: array, items: { $ref: '#/VideoItem' } } nextpage: { type: string, nullable: true } +PodcastItem: + 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 } +PodcastPageResponse: + type: object + required: [channelName, channelUrl, podcasts, episodes, nextpage] + properties: + channelName: { type: string } + channelUrl: { type: string } + podcasts: { type: array, items: { $ref: '#/PodcastItem' } } + episodes: { type: array, items: { $ref: '#/VideoItem' } } + nextpage: { type: string, nullable: true } +PodcastEpisodesResponse: + type: object + required: [podcast, episodes, nextpage] + properties: + podcast: { $ref: '#/PodcastItem' } + episodes: { 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] diff --git a/openapi/paths/channel.yaml b/openapi/paths/channel.yaml index 39a24de..51f542b 100644 --- a/openapi/paths/channel.yaml +++ b/openapi/paths/channel.yaml @@ -11,6 +11,12 @@ Channel: in: query required: false schema: { type: string } + - name: sort + in: query + required: false + schema: + type: string + enum: [latest, popular, oldest] responses: '200': description: Channel metadata and video page. diff --git a/openapi/paths/podcasts.yaml b/openapi/paths/podcasts.yaml new file mode 100644 index 0000000..faa265d --- /dev/null +++ b/openapi/paths/podcasts.yaml @@ -0,0 +1,54 @@ +Podcasts: + get: + tags: [extraction] + summary: Get YouTube channel podcasts and episodes + parameters: + - name: url + in: query + required: true + schema: { type: string } + - name: nextpage + in: query + required: false + schema: { type: string } + responses: + '200': + description: YouTube podcasts tab page. + headers: + X-Request-ID: + $ref: ../components/common.yaml#/RequestIdHeader + content: + application/json: + schema: + $ref: ../components/media.yaml#/PodcastPageResponse + '400': + $ref: ../components/common.yaml#/JsonError + '422': + $ref: ../components/common.yaml#/JsonError +PodcastEpisodes: + get: + tags: [extraction] + summary: Get YouTube podcast playlist episodes + parameters: + - name: url + in: query + required: true + schema: { type: string } + - name: nextpage + in: query + required: false + schema: { type: string } + responses: + '200': + description: YouTube podcast playlist episodes. + headers: + X-Request-ID: + $ref: ../components/common.yaml#/RequestIdHeader + content: + application/json: + schema: + $ref: ../components/media.yaml#/PodcastEpisodesResponse + '400': + $ref: ../components/common.yaml#/JsonError + '422': + $ref: ../components/common.yaml#/JsonError diff --git a/src/main/kotlin/dev/typetype/server/Application.kt b/src/main/kotlin/dev/typetype/server/Application.kt index 488a34b..1b10789 100644 --- a/src/main/kotlin/dev/typetype/server/Application.kt +++ b/src/main/kotlin/dev/typetype/server/Application.kt @@ -11,6 +11,7 @@ import dev.typetype.server.routes.downloaderGatewayRoutes import dev.typetype.server.routes.internalObservabilityRoutes 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.storyboardProxyRoutes import dev.typetype.server.routes.searchRoutes @@ -94,6 +95,7 @@ fun Application.module() { } rateLimit(CHANNEL_ZONE) { channelRoutes(svc.channelService) + podcastRoutes(svc.podcastService) } rateLimit(PROXY_ZONE) { proxyRoutes(svc.proxyService) diff --git a/src/main/kotlin/dev/typetype/server/ExtractorLifecycle.kt b/src/main/kotlin/dev/typetype/server/ExtractorLifecycle.kt index 5a46140..2d5475d 100644 --- a/src/main/kotlin/dev/typetype/server/ExtractorLifecycle.kt +++ b/src/main/kotlin/dev/typetype/server/ExtractorLifecycle.kt @@ -16,14 +16,14 @@ private val log = LoggerFactory.getLogger("ExtractorLifecycle") internal fun CoroutineScope.launchExtractorLifecycle() { launch(Dispatchers.IO) { runCatching { StreamInfo.getInfo(WARMUP_URL) } - .onFailure { log.warn("Warmup extraction failed: ${it.message}") } + .onFailure { log.info("Warmup extraction failed: ${it.message}") } } launch(Dispatchers.IO) { while (true) { delay(THROTTLE_CLEANUP_INTERVAL_MS) runCatching { YoutubeJavaScriptPlayerManager.clearThrottlingParametersCache() } - .onFailure { log.warn("Throttling cache cleanup failed: ${it.message}") } + .onFailure { log.info("Throttling cache cleanup failed: ${it.message}") } } } } diff --git a/src/main/kotlin/dev/typetype/server/ServiceRegistry.kt b/src/main/kotlin/dev/typetype/server/ServiceRegistry.kt index 6100a64..0822c8e 100644 --- a/src/main/kotlin/dev/typetype/server/ServiceRegistry.kt +++ b/src/main/kotlin/dev/typetype/server/ServiceRegistry.kt @@ -8,6 +8,7 @@ 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.CachedSearchService import dev.typetype.server.services.CachedStreamService import dev.typetype.server.services.CachedSuggestionService @@ -25,6 +26,7 @@ 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.PipePipeSearchService import dev.typetype.server.services.PipePipeStreamService import dev.typetype.server.services.PipePipeSuggestionService @@ -58,6 +60,7 @@ internal class ServiceRegistry(cache: DragonflyService, subtitleServiceUrl: Stri val commentService = CachedCommentService(PipePipeCommentService(), cache) val bulletCommentService = PipePipeBulletCommentService() val channelService = CachedChannelService(PipePipeChannelService(), cache) + val podcastService = CachedPodcastService(PipePipePodcastService(), cache) val proxyService = OkHttpProxyService(proxyHttpClient) val nicoVideoProxyService = NicoVideoProxyService() val manifestService = CachedManifestService(ManifestService(streamService), cache) diff --git a/src/main/kotlin/dev/typetype/server/models/PodcastEpisodesResponse.kt b/src/main/kotlin/dev/typetype/server/models/PodcastEpisodesResponse.kt new file mode 100644 index 0000000..1ab1e5d --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/models/PodcastEpisodesResponse.kt @@ -0,0 +1,10 @@ +package dev.typetype.server.models + +import kotlinx.serialization.Serializable + +@Serializable +data class PodcastEpisodesResponse( + val podcast: PodcastItem, + val episodes: List, + val nextpage: String?, +) diff --git a/src/main/kotlin/dev/typetype/server/models/PodcastItem.kt b/src/main/kotlin/dev/typetype/server/models/PodcastItem.kt new file mode 100644 index 0000000..4cf46b9 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/models/PodcastItem.kt @@ -0,0 +1,14 @@ +package dev.typetype.server.models + +import kotlinx.serialization.Serializable + +@Serializable +data class PodcastItem( + val id: String, + val title: String, + val url: String, + val thumbnailUrl: String, + val uploaderName: String, + val streamCount: Long, + val playlistType: String, +) diff --git a/src/main/kotlin/dev/typetype/server/models/PodcastPageResponse.kt b/src/main/kotlin/dev/typetype/server/models/PodcastPageResponse.kt new file mode 100644 index 0000000..7fa1e8b --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/models/PodcastPageResponse.kt @@ -0,0 +1,12 @@ +package dev.typetype.server.models + +import kotlinx.serialization.Serializable + +@Serializable +data class PodcastPageResponse( + val channelName: String, + val channelUrl: String, + val podcasts: List, + val episodes: List, + val nextpage: String?, +) diff --git a/src/main/kotlin/dev/typetype/server/routes/AdminRoutes.kt b/src/main/kotlin/dev/typetype/server/routes/AdminRoutes.kt index f35e40c..0bbf93d 100644 --- a/src/main/kotlin/dev/typetype/server/routes/AdminRoutes.kt +++ b/src/main/kotlin/dev/typetype/server/routes/AdminRoutes.kt @@ -59,7 +59,7 @@ fun Route.adminRoutes( call.withAdminAuth(authService) { adminId -> val id = call.parameters["id"] ?: return@withAdminAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing id")) if (id == adminId) { - adminRouteLog.warn("Admin self-suspend blocked for userId={}", adminId) + adminRouteLog.info("Admin self-suspend blocked for userId={}", adminId) return@withAdminAuth call.respond(HttpStatusCode.Forbidden, ErrorResponse("Cannot suspend your own account")) } val ok = userAdminService.suspendUser(id) @@ -71,7 +71,7 @@ fun Route.adminRoutes( call.withAdminAuth(authService) { adminId -> val id = call.parameters["id"] ?: return@withAdminAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing id")) if (id == adminId) { - adminRouteLog.warn("Admin self-unsuspend blocked for userId={}", adminId) + adminRouteLog.info("Admin self-unsuspend blocked for userId={}", adminId) return@withAdminAuth call.respond(HttpStatusCode.Forbidden, ErrorResponse("Cannot suspend your own account")) } val ok = userAdminService.unsuspendUser(id) @@ -83,7 +83,7 @@ fun Route.adminRoutes( call.withAdminAuth(authService) { adminId -> val id = call.parameters["id"] ?: return@withAdminAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing id")) if (id == adminId) { - adminRouteLog.warn("Admin role self-change blocked for userId={}", adminId) + adminRouteLog.info("Admin role self-change blocked for userId={}", adminId) return@withAdminAuth call.respond(HttpStatusCode.Forbidden, ErrorResponse("Cannot modify your own role")) } val body = runCatching { call.receive() }.getOrElse { diff --git a/src/main/kotlin/dev/typetype/server/routes/ChannelRoutes.kt b/src/main/kotlin/dev/typetype/server/routes/ChannelRoutes.kt index 568b2f8..cad9340 100644 --- a/src/main/kotlin/dev/typetype/server/routes/ChannelRoutes.kt +++ b/src/main/kotlin/dev/typetype/server/routes/ChannelRoutes.kt @@ -13,8 +13,9 @@ fun Route.channelRoutes(channelService: ChannelService) { val url = call.request.queryParameters["url"] ?: return@get call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing 'url' parameter")) val nextpage = call.request.queryParameters["nextpage"] + val sort = call.request.queryParameters["sort"]?.takeIf { it.isNotBlank() } - when (val result = channelService.getChannel(url = url, nextpage = nextpage)) { + when (val result = channelService.getChannel(url = url, nextpage = nextpage, sort = sort)) { 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)) diff --git a/src/main/kotlin/dev/typetype/server/routes/PodcastRoutes.kt b/src/main/kotlin/dev/typetype/server/routes/PodcastRoutes.kt new file mode 100644 index 0000000..2f370c8 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/routes/PodcastRoutes.kt @@ -0,0 +1,34 @@ +package dev.typetype.server.routes + +import dev.typetype.server.models.ErrorResponse +import dev.typetype.server.models.ExtractionResult +import dev.typetype.server.services.PodcastService +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.podcastRoutes(podcastService: PodcastService) { + get("/podcasts") { + 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 = podcastService.getPodcasts(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)) + } + } + get("/podcasts/episodes") { + 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 = podcastService.getPodcastEpisodes(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)) + } + } +} diff --git a/src/main/kotlin/dev/typetype/server/routes/RestoreRoutes.kt b/src/main/kotlin/dev/typetype/server/routes/RestoreRoutes.kt index 86033ae..645550c 100644 --- a/src/main/kotlin/dev/typetype/server/routes/RestoreRoutes.kt +++ b/src/main/kotlin/dev/typetype/server/routes/RestoreRoutes.kt @@ -49,7 +49,7 @@ fun Route.restoreRoutes(restoreService: PipePipeBackupImporterService, authServi val result = restoreService.restore(userId, tmp, timeMode) call.respond(result) } catch (e: Exception) { - call.application.environment.log.warn("Restore backup failed", e) + call.application.environment.log.info("Restore backup failed", e) return@withJwtAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid backup archive")) } finally { Files.deleteIfExists(tmp) diff --git a/src/main/kotlin/dev/typetype/server/services/CachedChannelService.kt b/src/main/kotlin/dev/typetype/server/services/CachedChannelService.kt index 8b2ab23..c31f87a 100644 --- a/src/main/kotlin/dev/typetype/server/services/CachedChannelService.kt +++ b/src/main/kotlin/dev/typetype/server/services/CachedChannelService.kt @@ -14,14 +14,14 @@ class CachedChannelService( private const val CHANNEL_CACHE_TTL_SECONDS = 1800L } - override suspend fun getChannel(url: String, nextpage: String?): ExtractionResult { - val key = "channel:$url:${nextpage ?: "null"}" + override suspend fun getChannel(url: String, nextpage: String?, sort: String?): ExtractionResult { + val key = "channel:$url:${nextpage ?: "null"}:${sort ?: "default"}" runCatching { cache.get(key) }.getOrNull()?.let { cached -> return runCatching { ExtractionResult.Success(CacheJson.decodeFromString(cached)) }.getOrElse { - delegate.getChannel(url, nextpage) + delegate.getChannel(url, nextpage, sort) } } - val result = delegate.getChannel(url, nextpage) + val result = delegate.getChannel(url, nextpage, sort) if (result is ExtractionResult.Success) { runCatching { cache.set(key, CacheJson.encodeToString(ChannelResponse.serializer(), result.data), CHANNEL_CACHE_TTL_SECONDS) } } diff --git a/src/main/kotlin/dev/typetype/server/services/CachedPodcastService.kt b/src/main/kotlin/dev/typetype/server/services/CachedPodcastService.kt new file mode 100644 index 0000000..53eaa19 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/CachedPodcastService.kt @@ -0,0 +1,45 @@ +package dev.typetype.server.services + +import dev.typetype.server.cache.CacheJson +import dev.typetype.server.cache.CacheService +import dev.typetype.server.models.ExtractionResult +import dev.typetype.server.models.PodcastEpisodesResponse +import dev.typetype.server.models.PodcastPageResponse + +class CachedPodcastService( + private val delegate: PodcastService, + private val cache: CacheService, +) : PodcastService { + + companion object { + private const val PODCAST_CACHE_TTL_SECONDS = 1800L + } + + override suspend fun getPodcasts(url: String, nextpage: String?): ExtractionResult { + val key = "podcasts:$url:${nextpage ?: "null"}" + runCatching { cache.get(key) }.getOrNull()?.let { cached -> + return runCatching { ExtractionResult.Success(CacheJson.decodeFromString(cached)) }.getOrElse { + delegate.getPodcasts(url, nextpage) + } + } + val result = delegate.getPodcasts(url, nextpage) + if (result is ExtractionResult.Success) { + runCatching { cache.set(key, CacheJson.encodeToString(PodcastPageResponse.serializer(), result.data), PODCAST_CACHE_TTL_SECONDS) } + } + return result + } + + override suspend fun getPodcastEpisodes(url: String, nextpage: String?): ExtractionResult { + val key = "podcast-episodes:$url:${nextpage ?: "null"}" + runCatching { cache.get(key) }.getOrNull()?.let { cached -> + return runCatching { ExtractionResult.Success(CacheJson.decodeFromString(cached)) }.getOrElse { + delegate.getPodcastEpisodes(url, nextpage) + } + } + val result = delegate.getPodcastEpisodes(url, nextpage) + if (result is ExtractionResult.Success) { + runCatching { cache.set(key, CacheJson.encodeToString(PodcastEpisodesResponse.serializer(), result.data), PODCAST_CACHE_TTL_SECONDS) } + } + return result + } +} diff --git a/src/main/kotlin/dev/typetype/server/services/ChannelResponseMappers.kt b/src/main/kotlin/dev/typetype/server/services/ChannelResponseMappers.kt new file mode 100644 index 0000000..0883613 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/ChannelResponseMappers.kt @@ -0,0 +1,52 @@ +package dev.typetype.server.services + +import dev.typetype.server.models.ChannelResponse +import org.schabi.newpipe.extractor.InfoItem +import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage +import org.schabi.newpipe.extractor.channel.ChannelInfo +import org.schabi.newpipe.extractor.channel.ChannelTabInfo +import org.schabi.newpipe.extractor.stream.StreamInfoItem + +internal fun ChannelInfo.toChannelResponse(): ChannelResponse = ChannelResponse( + name = name ?: "", + description = description ?: "", + avatarUrl = avatarUrl ?: "", + bannerUrl = bannerUrl ?: "", + subscriberCount = subscriberCount, + isVerified = isVerified, + videos = relatedItems.map { it.toVideoItem(fallbackAvatarUrl = avatarUrl ?: "") }, + nextpage = nextPage?.toCursor(), +) + +internal fun InfoItemsPage.toChannelResponse(): ChannelResponse = ChannelResponse( + name = "", + description = "", + avatarUrl = "", + bannerUrl = "", + subscriberCount = -1L, + isVerified = false, + videos = items.map { it.toVideoItem() }, + nextpage = nextPage?.toCursor(), +) + +internal fun ChannelTabInfo.toChannelTabResponse(metadata: ChannelInfo? = null): ChannelResponse = ChannelResponse( + name = metadata?.name ?: name ?: "", + description = metadata?.description ?: "", + avatarUrl = metadata?.avatarUrl ?: "", + bannerUrl = metadata?.bannerUrl ?: "", + subscriberCount = metadata?.subscriberCount ?: -1L, + isVerified = metadata?.isVerified ?: false, + videos = relatedItems.filterIsInstance().map { it.toVideoItem(fallbackAvatarUrl = metadata?.avatarUrl ?: "") }, + nextpage = nextPage?.toCursor(), +) + +internal fun InfoItemsPage.toChannelTabResponse(): ChannelResponse = ChannelResponse( + name = "", + description = "", + avatarUrl = "", + bannerUrl = "", + subscriberCount = -1L, + isVerified = false, + videos = items.filterIsInstance().map { it.toVideoItem() }, + nextpage = nextPage?.toCursor(), +) diff --git a/src/main/kotlin/dev/typetype/server/services/ChannelService.kt b/src/main/kotlin/dev/typetype/server/services/ChannelService.kt index 5da36bc..c5dba4d 100644 --- a/src/main/kotlin/dev/typetype/server/services/ChannelService.kt +++ b/src/main/kotlin/dev/typetype/server/services/ChannelService.kt @@ -4,5 +4,5 @@ import dev.typetype.server.models.ChannelResponse import dev.typetype.server.models.ExtractionResult interface ChannelService { - suspend fun getChannel(url: String, nextpage: String?): ExtractionResult + suspend fun getChannel(url: String, nextpage: String?, sort: String? = null): ExtractionResult } diff --git a/src/main/kotlin/dev/typetype/server/services/ChannelTabExtraction.kt b/src/main/kotlin/dev/typetype/server/services/ChannelTabExtraction.kt new file mode 100644 index 0000000..f762a12 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/ChannelTabExtraction.kt @@ -0,0 +1,16 @@ +package dev.typetype.server.services + +import org.schabi.newpipe.extractor.StreamingService +import org.schabi.newpipe.extractor.channel.ChannelTabExtractor +import org.schabi.newpipe.extractor.search.filter.Filter +import org.schabi.newpipe.extractor.search.filter.FilterItem + +internal fun StreamingService.channelTabExtractor( + channelId: String, + tab: String, + sort: String?, +): ChannelTabExtractor { + val contentFilter = listOf(FilterItem(Filter.ITEM_IDENTIFIER_UNKNOWN, tab)) + val linkHandler = channelTabLHFactory.fromQuery(channelId, contentFilter, sort.toYouTubeChannelTabSortFilter()) + return getChannelTabExtractor(linkHandler) +} diff --git a/src/main/kotlin/dev/typetype/server/services/PipePipeChannelService.kt b/src/main/kotlin/dev/typetype/server/services/PipePipeChannelService.kt index 7689351..f53f4b1 100644 --- a/src/main/kotlin/dev/typetype/server/services/PipePipeChannelService.kt +++ b/src/main/kotlin/dev/typetype/server/services/PipePipeChannelService.kt @@ -5,30 +5,32 @@ import dev.typetype.server.models.ExtractionResult import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout -import org.schabi.newpipe.extractor.InfoItem -import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.extractor.Page +import org.schabi.newpipe.extractor.StreamingService import org.schabi.newpipe.extractor.channel.ChannelInfo import org.schabi.newpipe.extractor.channel.ChannelTabInfo import org.schabi.newpipe.extractor.linkhandler.ChannelTabs -import org.schabi.newpipe.extractor.stream.StreamInfoItem class PipePipeChannelService : ChannelService { - override suspend fun getChannel(url: String, nextpage: String?): ExtractionResult = + override suspend fun getChannel(url: String, nextpage: String?, sort: String?): ExtractionResult = withContext(Dispatchers.IO) { + val normalizedSort = sort?.takeIf { it.isNotBlank() } val page = if (nextpage != null) { runCatching { nextpage.toPage() } .getOrElse { return@withContext ExtractionResult.BadRequest("Invalid nextpage cursor") } } else null + runCatching { normalizedSort.toYouTubeChannelTabSortFilter() } + .getOrElse { return@withContext ExtractionResult.BadRequest(it.message ?: "Invalid 'sort' parameter") } runCatching { withExtractionRetry { withTimeout(30_000L) { if (page == null) { - extractFirstPage(url) + extractFirstPage(url, normalizedSort) } else { - extractMorePage(url, page) + extractMorePage(url, page, normalizedSort) } } } @@ -38,75 +40,35 @@ class PipePipeChannelService : ChannelService { ) } - private fun extractFirstPage(url: String): ChannelResponse { - if (isShortsTab(url)) { - val service = NewPipe.getServiceByUrl(url) - val channelId = shortsChannelId(url, service) - val extractor = service.getChannelTabExtractorFromId(channelId, ChannelTabs.SHORTS) + private fun extractFirstPage(url: String, sort: String?): ChannelResponse { + val service = NewPipe.getServiceByUrl(url) + val tab = url.toChannelTab(sort) + if (tab != null) { + val channelUrl = url.toBaseChannelUrl(tab) + val metadata = runCatching { ChannelInfo.getInfo(channelUrl) }.getOrNull() + val extractor = service.channelTabExtractor(channelId(channelUrl, service), tab, sort) extractor.fetchPage() - return ChannelTabInfo.getInfo(extractor).toChannelTabResponse() + return ChannelTabInfo.getInfo(extractor).toChannelTabResponse(metadata) } return ChannelInfo.getInfo(url).toChannelResponse() } - private fun extractMorePage(url: String, page: org.schabi.newpipe.extractor.Page): ChannelResponse { + private fun extractMorePage(url: String, page: Page, sort: String?): ChannelResponse { val service = NewPipe.getServiceByUrl(url) - if (isShortsTab(url)) { - val channelId = shortsChannelId(url, service) - val extractor = service.getChannelTabExtractorFromId(channelId, ChannelTabs.SHORTS) + val tab = url.toChannelTab(sort) + if (tab != null) { + val extractor = service.channelTabExtractor(channelId(url.toBaseChannelUrl(tab), service), tab, sort) return extractor.getPage(page).toChannelTabResponse() } return ChannelInfo.getMoreItems(service, url, page).toChannelResponse() } - private fun isShortsTab(url: String): Boolean = url.contains("/shorts", ignoreCase = true) - - private fun shortsChannelId(url: String, service: org.schabi.newpipe.extractor.StreamingService): String { - val baseUrl = url.substringBefore("/shorts").trimEnd('/') - return service.channelLHFactory.fromUrl(baseUrl).id + private fun String.toChannelTab(sort: String?): String? { + if (contains("/shorts", ignoreCase = true)) return ChannelTabs.SHORTS + return if (sort != null) ChannelTabs.VIDEOS else null } - private fun ChannelInfo.toChannelResponse(): ChannelResponse = ChannelResponse( - name = name ?: "", - description = description ?: "", - avatarUrl = avatarUrl ?: "", - bannerUrl = bannerUrl ?: "", - subscriberCount = subscriberCount, - isVerified = isVerified, - videos = relatedItems.map { it.toVideoItem(fallbackAvatarUrl = avatarUrl ?: "") }, - nextpage = nextPage?.toCursor(), - ) - - private fun InfoItemsPage.toChannelResponse(): ChannelResponse = ChannelResponse( - name = "", - description = "", - avatarUrl = "", - bannerUrl = "", - subscriberCount = -1L, - isVerified = false, - videos = items.map { it.toVideoItem() }, - nextpage = nextPage?.toCursor(), - ) - - private fun ChannelTabInfo.toChannelTabResponse(): ChannelResponse = ChannelResponse( - name = name ?: "", - description = "", - avatarUrl = "", - bannerUrl = "", - subscriberCount = -1L, - isVerified = false, - videos = relatedItems.filterIsInstance().map { it.toVideoItem() }, - nextpage = nextPage?.toCursor(), - ) + private fun String.toBaseChannelUrl(tab: String): String = substringBefore("/$tab").substringBefore('?').substringBefore('#').trimEnd('/') - private fun InfoItemsPage.toChannelTabResponse(): ChannelResponse = ChannelResponse( - name = "", - description = "", - avatarUrl = "", - bannerUrl = "", - subscriberCount = -1L, - isVerified = false, - videos = items.filterIsInstance().map { it.toVideoItem() }, - nextpage = nextPage?.toCursor(), - ) + private fun channelId(url: String, service: StreamingService): String = service.channelLHFactory.fromUrl(url).id } diff --git a/src/main/kotlin/dev/typetype/server/services/PipePipePodcastEpisodesService.kt b/src/main/kotlin/dev/typetype/server/services/PipePipePodcastEpisodesService.kt new file mode 100644 index 0000000..0d2579f --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/PipePipePodcastEpisodesService.kt @@ -0,0 +1,52 @@ +package dev.typetype.server.services + +import dev.typetype.server.models.ExtractionResult +import dev.typetype.server.models.PodcastEpisodesResponse +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 + +internal class PipePipePodcastEpisodesService { + + suspend fun getPodcastEpisodes(url: String, nextpage: String?): ExtractionResult = + 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 podcast URL") } + if (service.serviceId != YOUTUBE_SERVICE_ID) { + return@withContext ExtractionResult.BadRequest("Podcasts are only supported for YouTube playlists") + } + + runCatching { + withExtractionRetry { + withTimeout(30_000L) { + if (page == null) PlaylistInfo.getInfo(service, url).toPodcastEpisodesResponse() + else PlaylistInfo.getMoreItems(service, url, page).toPodcastEpisodesResponse(url) + } + } + }.fold( + onSuccess = { ExtractionResult.Success(it) }, + onFailure = { ExtractionResult.Failure(it.message ?: "Podcast episodes extraction failed") } + ) + } + + private fun PlaylistInfo.toPodcastEpisodesResponse(): PodcastEpisodesResponse = PodcastEpisodesResponse( + podcast = toPodcastItem(), + episodes = relatedItems.map { it.toVideoItem() }, + nextpage = nextPage?.toCursor(), + ) + + private fun InfoItemsPage.toPodcastEpisodesResponse(url: String): PodcastEpisodesResponse = + PodcastEpisodesResponse( + podcast = emptyPodcastItem(url), + episodes = items.map { it.toVideoItem() }, + nextpage = nextPage?.toCursor(), + ) +} diff --git a/src/main/kotlin/dev/typetype/server/services/PipePipePodcastService.kt b/src/main/kotlin/dev/typetype/server/services/PipePipePodcastService.kt new file mode 100644 index 0000000..21be002 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/PipePipePodcastService.kt @@ -0,0 +1,96 @@ +package dev.typetype.server.services + +import dev.typetype.server.models.ExtractionResult +import dev.typetype.server.models.PodcastEpisodesResponse +import dev.typetype.server.models.PodcastPageResponse +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.schabi.newpipe.extractor.InfoItem +import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage +import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.extractor.Page +import org.schabi.newpipe.extractor.StreamingService +import org.schabi.newpipe.extractor.channel.ChannelTabInfo +import org.schabi.newpipe.extractor.linkhandler.ChannelTabs +import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem +import org.schabi.newpipe.extractor.stream.StreamInfoItem + +class PipePipePodcastService : PodcastService { + + private val episodesService = PipePipePodcastEpisodesService() + + override suspend fun getPodcasts(url: String, nextpage: String?): ExtractionResult = + withContext(Dispatchers.IO) { + val page = if (nextpage != null) { + runCatching { nextpage.toPage() } + .getOrElse { return@withContext ExtractionResult.BadRequest("Invalid nextpage cursor") } + } else null + val channelUrl = url.toPodcastChannelUrl() + val service = runCatching { NewPipe.getServiceByUrl(channelUrl) } + .getOrElse { return@withContext ExtractionResult.BadRequest("Unsupported podcast URL") } + if (service.serviceId != YOUTUBE_SERVICE_ID) { + return@withContext ExtractionResult.BadRequest("Podcasts are only supported for YouTube channels") + } + val channelId = runCatching { service.channelLHFactory.fromUrl(channelUrl).id } + .getOrElse { return@withContext ExtractionResult.BadRequest("Unsupported podcast URL") } + + runCatching { + withExtractionRetry { + withTimeout(30_000L) { extractPodcastPage(service, channelId, channelUrl, page) } + } + }.fold( + onSuccess = { ExtractionResult.Success(it) }, + onFailure = { ExtractionResult.Failure(it.message ?: "Podcast extraction failed") } + ) + } + + override suspend fun getPodcastEpisodes(url: String, nextpage: String?): ExtractionResult = + episodesService.getPodcastEpisodes(url, nextpage) + + private fun extractPodcastPage( + service: StreamingService, + channelId: String, + channelUrl: String, + page: Page?, + ): PodcastPageResponse { + val extractor = service.channelTabExtractor(channelId, ChannelTabs.PODCASTS, null) + if (page == null) { + extractor.fetchPage() + return ChannelTabInfo.getInfo(extractor).toPodcastPageResponse(channelUrl) + } + return extractor.getPage(page).toPodcastPageResponse(channelUrl) + } + + private fun ChannelTabInfo.toPodcastPageResponse(channelUrl: String): PodcastPageResponse = + relatedItems.toPodcastPageResponse(channelUrl = channelUrl, channelName = name ?: "", nextpage = nextPage?.toCursor()) + + private fun InfoItemsPage.toPodcastPageResponse(channelUrl: String): PodcastPageResponse = + items.toPodcastPageResponse(channelUrl = channelUrl, channelName = "", nextpage = nextPage?.toCursor()) + + private fun List.toPodcastPageResponse( + channelUrl: String, + channelName: String, + nextpage: String?, + ): PodcastPageResponse { + val podcasts = filterIsInstance().map { it.toPodcastItem() } + val episodes = filterIsInstance().map { it.toVideoItem() } + val resolvedChannelName = podcasts.firstOrNull { it.uploaderName.isNotBlank() }?.uploaderName + ?: episodes.firstOrNull { it.uploaderName.isNotBlank() }?.uploaderName + ?: channelName + return PodcastPageResponse( + channelName = resolvedChannelName, + channelUrl = channelUrl, + podcasts = podcasts, + episodes = episodes, + nextpage = nextpage, + ) + } +} + +private fun String.toPodcastChannelUrl(): String { + val normalized = trim().substringBefore('?').substringBefore('#').trimEnd('/') + val marker = "/podcasts" + val index = normalized.lowercase().indexOf(marker) + return if (index == -1) normalized else normalized.substring(0, index).trimEnd('/') +} diff --git a/src/main/kotlin/dev/typetype/server/services/PodcastItemMappers.kt b/src/main/kotlin/dev/typetype/server/services/PodcastItemMappers.kt new file mode 100644 index 0000000..0ca1ba2 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/PodcastItemMappers.kt @@ -0,0 +1,35 @@ +package dev.typetype.server.services + +import dev.typetype.server.models.PodcastItem +import org.schabi.newpipe.extractor.playlist.PlaylistInfo +import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem + +internal fun PlaylistInfoItem.toPodcastItem(): PodcastItem = PodcastItem( + id = url ?: "", + title = name ?: "", + url = url ?: "", + thumbnailUrl = thumbnailUrl.toAbsoluteUrl(), + uploaderName = uploaderName ?: "", + streamCount = streamCount, + playlistType = playlistType?.name?.lowercase() ?: "", +) + +internal fun PlaylistInfo.toPodcastItem(): PodcastItem = PodcastItem( + id = url ?: "", + title = name ?: "", + url = url ?: "", + thumbnailUrl = thumbnailUrl.toAbsoluteUrl(), + uploaderName = uploaderName ?: "", + streamCount = streamCount, + playlistType = playlistType?.name?.lowercase() ?: "", +) + +internal fun emptyPodcastItem(url: String): PodcastItem = PodcastItem( + id = url, + title = "", + url = url, + thumbnailUrl = "", + uploaderName = "", + streamCount = -1L, + playlistType = "", +) diff --git a/src/main/kotlin/dev/typetype/server/services/PodcastService.kt b/src/main/kotlin/dev/typetype/server/services/PodcastService.kt new file mode 100644 index 0000000..d215f26 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/PodcastService.kt @@ -0,0 +1,10 @@ +package dev.typetype.server.services + +import dev.typetype.server.models.ExtractionResult +import dev.typetype.server.models.PodcastEpisodesResponse +import dev.typetype.server.models.PodcastPageResponse + +interface PodcastService { + suspend fun getPodcasts(url: String, nextpage: String?): ExtractionResult + suspend fun getPodcastEpisodes(url: String, nextpage: String?): ExtractionResult +} diff --git a/src/main/kotlin/dev/typetype/server/services/YouTubeChannelTabSort.kt b/src/main/kotlin/dev/typetype/server/services/YouTubeChannelTabSort.kt new file mode 100644 index 0000000..525c7e5 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YouTubeChannelTabSort.kt @@ -0,0 +1,12 @@ +package dev.typetype.server.services + +import org.schabi.newpipe.extractor.search.filter.Filter +import org.schabi.newpipe.extractor.search.filter.FilterItem + +private val VALID_YOUTUBE_CHANNEL_TAB_SORTS = setOf("latest", "popular", "oldest") + +internal fun String?.toYouTubeChannelTabSortFilter(): List? { + val normalized = this?.lowercase()?.takeIf { it.isNotBlank() } ?: return null + if (normalized !in VALID_YOUTUBE_CHANNEL_TAB_SORTS) throw IllegalArgumentException("Invalid 'sort' parameter") + return listOf(FilterItem(Filter.ITEM_IDENTIFIER_UNKNOWN, normalized)) +} diff --git a/src/main/resources/psw4j.properties b/src/main/resources/psw4j.properties new file mode 100644 index 0000000..6317c96 --- /dev/null +++ b/src/main/resources/psw4j.properties @@ -0,0 +1,7 @@ +hash.argon2.memory=15360 +hash.argon2.iterations=2 +hash.argon2.length=32 +hash.argon2.parallelism=1 +hash.argon2.type=id +hash.argon2.version=19 +global.salt.length=64 diff --git a/src/test/kotlin/dev/typetype/server/ChannelRoutesTest.kt b/src/test/kotlin/dev/typetype/server/ChannelRoutesTest.kt index 00c1d88..d6f9cb5 100644 --- a/src/test/kotlin/dev/typetype/server/ChannelRoutesTest.kt +++ b/src/test/kotlin/dev/typetype/server/ChannelRoutesTest.kt @@ -48,7 +48,7 @@ class ChannelRoutesTest { @Test fun `GET channel returns 200 on Success`() = withApp { - coEvery { channelService.getChannel(any(), any()) } returns + coEvery { channelService.getChannel(any(), any(), any()) } returns ExtractionResult.Success(testChannelResponse()) val response = client.get("/channel?url=https://youtube.com/channel/test") assertEquals(HttpStatusCode.OK, response.status) @@ -56,7 +56,7 @@ class ChannelRoutesTest { @Test fun `GET channel returns 422 on Failure`() = withApp { - coEvery { channelService.getChannel(any(), any()) } returns + coEvery { channelService.getChannel(any(), any(), any()) } returns ExtractionResult.Failure("error") val response = client.get("/channel?url=https://youtube.com/channel/test") assertEquals(HttpStatusCode.UnprocessableEntity, response.status) @@ -64,7 +64,7 @@ class ChannelRoutesTest { @Test fun `GET channel returns 400 on BadRequest`() = withApp { - coEvery { channelService.getChannel(any(), any()) } returns + coEvery { channelService.getChannel(any(), any(), any()) } returns ExtractionResult.BadRequest("bad") val response = client.get("/channel?url=https://youtube.com/channel/test") assertEquals(HttpStatusCode.BadRequest, response.status) diff --git a/src/test/kotlin/dev/typetype/server/FakeChannelService.kt b/src/test/kotlin/dev/typetype/server/FakeChannelService.kt index 1bf58a9..7a330c0 100644 --- a/src/test/kotlin/dev/typetype/server/FakeChannelService.kt +++ b/src/test/kotlin/dev/typetype/server/FakeChannelService.kt @@ -6,7 +6,7 @@ import dev.typetype.server.models.VideoItem import dev.typetype.server.services.ChannelService class FakeChannelService : ChannelService { - override suspend fun getChannel(url: String, nextpage: String?): ExtractionResult { + override suspend fun getChannel(url: String, nextpage: String?, sort: String?): ExtractionResult { val video = VideoItem( id = "id-${url.hashCode()}", title = "video", diff --git a/src/test/kotlin/dev/typetype/server/PodcastRoutesTest.kt b/src/test/kotlin/dev/typetype/server/PodcastRoutesTest.kt new file mode 100644 index 0000000..c57a149 --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/PodcastRoutesTest.kt @@ -0,0 +1,99 @@ +package dev.typetype.server + +import dev.typetype.server.models.ExtractionResult +import dev.typetype.server.models.PodcastEpisodesResponse +import dev.typetype.server.models.PodcastItem +import dev.typetype.server.models.PodcastPageResponse +import dev.typetype.server.routes.podcastRoutes +import dev.typetype.server.services.PodcastService +import io.ktor.client.request.get +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.mockk +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class PodcastRoutesTest { + + private val podcastService: PodcastService = mockk() + + private fun withApp(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication { + application { + install(ContentNegotiation) { json() } + routing { podcastRoutes(podcastService) } + } + block() + } + + private fun testPodcastResponse() = PodcastPageResponse( + channelName = "Test Channel", + channelUrl = "https://youtube.com/@test", + podcasts = emptyList(), + episodes = emptyList(), + nextpage = null, + ) + + private fun testPodcastEpisodesResponse() = PodcastEpisodesResponse( + podcast = PodcastItem( + id = "https://youtube.com/playlist?list=test", + title = "Test Podcast", + url = "https://youtube.com/playlist?list=test", + thumbnailUrl = "", + uploaderName = "Test Channel", + streamCount = 1L, + playlistType = "normal", + ), + episodes = emptyList(), + nextpage = null, + ) + + @Test + fun `GET podcasts without url returns 400`() = withApp { + val response = client.get("/podcasts") + assertEquals(HttpStatusCode.BadRequest, response.status) + } + + @Test + fun `GET podcasts returns 200 on Success`() = withApp { + coEvery { podcastService.getPodcasts(any(), any()) } returns + ExtractionResult.Success(testPodcastResponse()) + val response = client.get("/podcasts?url=https://youtube.com/@test") + assertEquals(HttpStatusCode.OK, response.status) + } + + @Test + fun `GET podcasts returns 422 on Failure`() = withApp { + coEvery { podcastService.getPodcasts(any(), any()) } returns + ExtractionResult.Failure("error") + val response = client.get("/podcasts?url=https://youtube.com/@test") + assertEquals(HttpStatusCode.UnprocessableEntity, response.status) + } + + @Test + fun `GET podcasts returns 400 on BadRequest`() = withApp { + coEvery { podcastService.getPodcasts(any(), any()) } returns + ExtractionResult.BadRequest("bad") + val response = client.get("/podcasts?url=https://youtube.com/@test") + assertEquals(HttpStatusCode.BadRequest, response.status) + } + + @Test + fun `GET podcast episodes without url returns 400`() = withApp { + val response = client.get("/podcasts/episodes") + assertEquals(HttpStatusCode.BadRequest, response.status) + } + + @Test + fun `GET podcast episodes returns 200 on Success`() = withApp { + coEvery { podcastService.getPodcastEpisodes(any(), any()) } returns + ExtractionResult.Success(testPodcastEpisodesResponse()) + val response = client.get("/podcasts/episodes?url=https://youtube.com/playlist?list=test") + assertEquals(HttpStatusCode.OK, response.status) + } +} diff --git a/src/test/kotlin/dev/typetype/server/SubscriptionShortsFeedBlendTest.kt b/src/test/kotlin/dev/typetype/server/SubscriptionShortsFeedBlendTest.kt index 459bedb..ab62835 100644 --- a/src/test/kotlin/dev/typetype/server/SubscriptionShortsFeedBlendTest.kt +++ b/src/test/kotlin/dev/typetype/server/SubscriptionShortsFeedBlendTest.kt @@ -30,8 +30,6 @@ class SubscriptionShortsFeedBlendTest { cacheService, ) - companion object { @BeforeAll @JvmStatic fun initDb() = TestDatabase.setup() } - @BeforeEach fun clean() { TestDatabase.truncateAll() @@ -80,10 +78,18 @@ class SubscriptionShortsFeedBlendTest { duration = 20, viewCount = 0, uploadDate = "", - uploaded = System.currentTimeMillis(), + uploaded = TEST_UPLOAD_TIME, streamType = "video_stream", isShortFormContent = true, uploaderVerified = false, shortDescription = null, ) + + companion object { + private const val TEST_UPLOAD_TIME = 1_700_000_000_000L + + @BeforeAll + @JvmStatic + fun initDb() = TestDatabase.setup() + } } diff --git a/src/test/kotlin/dev/typetype/server/YoutubeTakeoutIssueServiceTest.kt b/src/test/kotlin/dev/typetype/server/YoutubeTakeoutIssueServiceTest.kt index 969d4c0..be39de1 100644 --- a/src/test/kotlin/dev/typetype/server/YoutubeTakeoutIssueServiceTest.kt +++ b/src/test/kotlin/dev/typetype/server/YoutubeTakeoutIssueServiceTest.kt @@ -7,7 +7,7 @@ import org.junit.jupiter.api.Test class YoutubeTakeoutIssueServiceTest { @Test - fun `build aggregates duplicate warnings and errors`() { + fun `build aggregates duplicate issues and errors`() { val warnings = listOf("Unsupported CSV schema: Takeout/a.csv", "No subscription rows detected", "No subscription rows detected") val errors = listOf("Invalid playlist row", "Invalid playlist row", "Invalid subscription row") val (issues, summary) = YoutubeTakeoutIssueService.build(warnings, errors, stage = "preview")