Skip to content

Commit 16b1efc

Browse files
authored
Merge pull request #54 from Priveetee/dev
feat: expose channel search results
2 parents bdca98a + 5241d92 commit 16b1efc

11 files changed

Lines changed: 193 additions & 24 deletions

File tree

openapi.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ components:
6565
$ref: ./openapi/components/media.yaml#/SearchFilterOption
6666
SearchFiltersResponse:
6767
$ref: ./openapi/components/media.yaml#/SearchFiltersResponse
68+
ChannelResultItem:
69+
$ref: ./openapi/components/media.yaml#/ChannelResultItem
6870
ChannelResponse:
6971
$ref: ./openapi/components/media.yaml#/ChannelResponse
7072
ChannelPageRequest:

openapi/components/media.yaml

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,26 @@ VideoItem:
2323
publishedAt: { type: integer, format: int64, nullable: true }
2424
SearchPageResponse:
2525
type: object
26-
required: [items, nextpage, searchSuggestion, isCorrectedSearch, playlists]
26+
required: [items, nextpage, searchSuggestion, isCorrectedSearch, playlists, channels]
2727
properties:
2828
items: { type: array, items: { $ref: '#/VideoItem' } }
2929
nextpage: { type: string, nullable: true }
3030
searchSuggestion: { type: string, nullable: true }
3131
isCorrectedSearch: { type: boolean }
3232
playlists: { type: array, items: { $ref: '#/PlaylistResultItem' }, default: [] }
33+
channels: { type: array, items: { $ref: '#/ChannelResultItem' }, default: [] }
34+
ChannelResultItem:
35+
type: object
36+
required: [id, name, url, thumbnailUrl, description, subscriberCount, streamCount, isVerified]
37+
properties:
38+
id: { type: string }
39+
name: { type: string }
40+
url: { type: string }
41+
thumbnailUrl: { type: string }
42+
description: { type: string }
43+
subscriberCount: { type: integer, format: int64 }
44+
streamCount: { type: integer, format: int64 }
45+
isVerified: { type: boolean }
3346
SearchFilterOption:
3447
type: object
3548
required: [value, label]
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package dev.typetype.server.models
2+
3+
import kotlinx.serialization.Serializable
4+
5+
@Serializable
6+
data class ChannelResultItem(
7+
val id: String,
8+
val name: String,
9+
val url: String,
10+
val thumbnailUrl: String,
11+
val description: String,
12+
val subscriberCount: Long,
13+
val streamCount: Long,
14+
val isVerified: Boolean,
15+
)

src/main/kotlin/dev/typetype/server/models/SearchPageResponse.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ data class SearchPageResponse(
99
val searchSuggestion: String?,
1010
val isCorrectedSearch: Boolean,
1111
val playlists: List<PlaylistResultItem> = emptyList(),
12+
val channels: List<ChannelResultItem> = emptyList(),
1213
)

src/main/kotlin/dev/typetype/server/services/CachedSearchService.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ class CachedSearchService(
1919
): ExtractionResult<SearchPageResponse> = PublicExtractionCache.getOrLoad(
2020
cache = cache,
2121
area = "search",
22-
key = PublicCacheKey.of("search", serviceId.toString(), query, nextpage, contentFilter, sortFilter),
22+
key = PublicCacheKey.of("search-v2", serviceId.toString(), query, nextpage, contentFilter, sortFilter),
2323
serializer = SearchPageResponse.serializer(),
2424
ttlSeconds = { PublicCachePolicy.searchTtl(serviceId, nextpage) },
2525
) { delegate.search(query, serviceId, nextpage, contentFilter, sortFilter) }
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package dev.typetype.server.services
2+
3+
import dev.typetype.server.models.ChannelResultItem
4+
import org.schabi.newpipe.extractor.channel.ChannelInfoItem
5+
6+
internal fun ChannelInfoItem.toChannelResultItem(): ChannelResultItem = ChannelResultItem(
7+
id = url,
8+
name = name,
9+
url = url,
10+
thumbnailUrl = thumbnailUrl.orEmpty(),
11+
description = description.orEmpty(),
12+
subscriberCount = subscriberCount,
13+
streamCount = streamCount,
14+
isVerified = isVerified,
15+
)

src/main/kotlin/dev/typetype/server/services/PipePipeSearchService.kt

Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,8 @@ import dev.typetype.server.models.SearchPageResponse
66
import kotlinx.coroutines.Dispatchers
77
import kotlinx.coroutines.withContext
88
import kotlinx.coroutines.withTimeout
9-
import org.schabi.newpipe.extractor.InfoItem
10-
import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage
119
import org.schabi.newpipe.extractor.NewPipe
1210
import org.schabi.newpipe.extractor.search.SearchInfo
13-
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem
14-
import org.schabi.newpipe.extractor.stream.StreamInfoItem
1511

1612
class PipePipeSearchService : SearchService {
1713

@@ -48,10 +44,11 @@ class PipePipeSearchService : SearchService {
4844
selectedContentFilter.ifEmpty { null },
4945
selectedSortFilter.ifEmpty { null },
5046
)
47+
val contentKind = contentFilter.toSearchContentKind(selectedContentFilter)
5148
if (page == null) {
52-
SearchInfo.getInfo(service, queryHandler).toPageResponse()
49+
SearchInfo.getInfo(service, queryHandler).toSearchPageResponse().filteredBy(contentKind)
5350
} else {
54-
SearchInfo.getMoreItems(service, queryHandler, page).toPageResponse()
51+
SearchInfo.getMoreItems(service, queryHandler, page).toSearchPageResponse().filteredBy(contentKind)
5552
}
5653
}
5754
}
@@ -79,22 +76,6 @@ class PipePipeSearchService : SearchService {
7976
)
8077
}
8178

82-
private fun SearchInfo.toPageResponse(): SearchPageResponse = SearchPageResponse(
83-
items = relatedItems.filterIsInstance<StreamInfoItem>().map { it.toVideoItem() },
84-
nextpage = nextPage?.toCursor(),
85-
searchSuggestion = searchSuggestion?.takeIf { it.isNotBlank() },
86-
isCorrectedSearch = isCorrectedSearch,
87-
playlists = relatedItems.filterIsInstance<PlaylistInfoItem>().map { it.toPlaylistResultItem() },
88-
)
89-
90-
private fun InfoItemsPage<InfoItem>.toPageResponse(): SearchPageResponse = SearchPageResponse(
91-
items = items.filterIsInstance<StreamInfoItem>().map { it.toVideoItem() },
92-
nextpage = nextPage?.toCursor(),
93-
searchSuggestion = null,
94-
isCorrectedSearch = false,
95-
playlists = items.filterIsInstance<PlaylistInfoItem>().map { it.toPlaylistResultItem() },
96-
)
97-
9879
private companion object {
9980
const val YOUTUBE_SERVICE_ID = 0
10081
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package dev.typetype.server.services
2+
3+
import dev.typetype.server.models.SearchPageResponse
4+
import org.schabi.newpipe.extractor.search.filter.FilterItem
5+
6+
internal enum class SearchContentKind {
7+
All,
8+
Videos,
9+
Playlists,
10+
Channels,
11+
}
12+
13+
internal fun String?.toSearchContentKind(fallbackFilters: List<FilterItem>): SearchContentKind = this
14+
?.substringAfterLast('|')
15+
?.toSearchContentKind()
16+
?.takeUnless { it == SearchContentKind.All }
17+
?: fallbackFilters.toSearchContentKind()
18+
19+
internal fun List<FilterItem>.toSearchContentKind(): SearchContentKind = firstOrNull()
20+
?.name
21+
?.lowercase()
22+
?.toSearchContentKind()
23+
?: SearchContentKind.All
24+
25+
private fun String.toSearchContentKind(): SearchContentKind = when (lowercase()) {
26+
"channels", "music_artists" -> SearchContentKind.Channels
27+
"playlists", "music_playlists", "music_albums" -> SearchContentKind.Playlists
28+
"videos", "lives", "music_songs", "music_videos", "animes", "movies_and_tv" -> SearchContentKind.Videos
29+
else -> SearchContentKind.All
30+
}
31+
32+
internal fun SearchPageResponse.filteredBy(kind: SearchContentKind): SearchPageResponse = when (kind) {
33+
SearchContentKind.All -> this
34+
SearchContentKind.Videos -> copy(playlists = emptyList(), channels = emptyList())
35+
SearchContentKind.Playlists -> copy(items = emptyList(), channels = emptyList())
36+
SearchContentKind.Channels -> copy(items = emptyList(), playlists = emptyList())
37+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package dev.typetype.server.services
2+
3+
import dev.typetype.server.models.SearchPageResponse
4+
import org.schabi.newpipe.extractor.InfoItem
5+
import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage
6+
import org.schabi.newpipe.extractor.channel.ChannelInfoItem
7+
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem
8+
import org.schabi.newpipe.extractor.search.SearchInfo
9+
import org.schabi.newpipe.extractor.stream.StreamInfoItem
10+
11+
internal fun SearchInfo.toSearchPageResponse(): SearchPageResponse = SearchPageResponse(
12+
items = relatedItems.filterIsInstance<StreamInfoItem>().map { it.toVideoItem() },
13+
nextpage = nextPage?.toCursor(),
14+
searchSuggestion = searchSuggestion?.takeIf { it.isNotBlank() },
15+
isCorrectedSearch = isCorrectedSearch,
16+
playlists = relatedItems.filterIsInstance<PlaylistInfoItem>().map { it.toPlaylistResultItem() },
17+
channels = relatedItems.filterIsInstance<ChannelInfoItem>().map { it.toChannelResultItem() },
18+
)
19+
20+
internal fun InfoItemsPage<InfoItem>.toSearchPageResponse(): SearchPageResponse = SearchPageResponse(
21+
items = items.filterIsInstance<StreamInfoItem>().map { it.toVideoItem() },
22+
nextpage = nextPage?.toCursor(),
23+
searchSuggestion = null,
24+
isCorrectedSearch = false,
25+
playlists = items.filterIsInstance<PlaylistInfoItem>().map { it.toPlaylistResultItem() },
26+
channels = items.filterIsInstance<ChannelInfoItem>().map { it.toChannelResultItem() },
27+
)
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package dev.typetype.server
2+
3+
import dev.typetype.server.models.ChannelResultItem
4+
import dev.typetype.server.models.ExtractionResult
5+
import dev.typetype.server.models.SearchPageResponse
6+
import dev.typetype.server.routes.searchRoutes
7+
import dev.typetype.server.services.SearchService
8+
import io.ktor.client.request.get
9+
import io.ktor.client.statement.bodyAsText
10+
import io.ktor.http.HttpStatusCode
11+
import io.ktor.serialization.kotlinx.json.json
12+
import io.ktor.server.application.install
13+
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
14+
import io.ktor.server.routing.routing
15+
import io.ktor.server.testing.testApplication
16+
import io.mockk.coEvery
17+
import io.mockk.mockk
18+
import org.junit.jupiter.api.Assertions.assertEquals
19+
import org.junit.jupiter.api.Assertions.assertTrue
20+
import org.junit.jupiter.api.Test
21+
22+
class SearchChannelsRoutesTest {
23+
private val searchService: SearchService = mockk()
24+
25+
@Test
26+
fun `GET search returns channel results`() = withApp {
27+
coEvery { searchService.search(any(), any(), any(), any(), any()) } returns ExtractionResult.Success(
28+
SearchPageResponse(
29+
items = emptyList(),
30+
nextpage = null,
31+
searchSuggestion = null,
32+
isCorrectedSearch = false,
33+
channels = listOf(channel()),
34+
)
35+
)
36+
val response = client.get("/search?q=test&service=0")
37+
val body = response.bodyAsText()
38+
assertEquals(HttpStatusCode.OK, response.status)
39+
assertTrue(body.contains("\"channels\""))
40+
assertTrue(body.contains("\"subscriberCount\":42"))
41+
}
42+
43+
private fun withApp(block: suspend io.ktor.server.testing.ApplicationTestBuilder.() -> Unit) = testApplication {
44+
application {
45+
install(ContentNegotiation) { json() }
46+
routing { searchRoutes(searchService) }
47+
}
48+
block()
49+
}
50+
51+
private fun channel(): ChannelResultItem = ChannelResultItem(
52+
id = "channel-id",
53+
name = "Channel",
54+
url = "https://youtube.com/channel/channel-id",
55+
thumbnailUrl = "https://img.youtube.com/channel.jpg",
56+
description = "Description",
57+
subscriberCount = 42L,
58+
streamCount = 7L,
59+
isVerified = true,
60+
)
61+
}

0 commit comments

Comments
 (0)