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
5 changes: 4 additions & 1 deletion openapi/components/media.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
VideoItem:
type: object
required: [id, title, url, thumbnailUrl, uploaderName, uploaderUrl, duration, viewCount, uploadDate, streamType, isShortFormContent, uploaderVerified]
required: [id, title, url, thumbnailUrl, uploaderName, uploaderUrl, duration, viewCount, uploadDate, streamType, isLive, isPostLive, isLiveContent, isShortFormContent, uploaderVerified]
properties:
id: { type: string }
title: { type: string }
Expand All @@ -14,6 +14,9 @@ VideoItem:
uploadDate: { type: string }
uploaded: { type: integer, format: int64, default: -1 }
streamType: { type: string }
isLive: { type: boolean }
isPostLive: { type: boolean }
isLiveContent: { type: boolean }
isShortFormContent: { type: boolean }
uploaderVerified: { type: boolean }
shortDescription: { type: string, nullable: true }
Expand Down
6 changes: 5 additions & 1 deletion openapi/components/streams.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ AudioStreamItem:
isOriginal: { type: boolean }
StreamResponse:
type: object
required: [id, title, uploaderName, uploaderUrl, thumbnailUrl, description, duration, viewCount, uploadDate, uploaded, streamType, videoStreams, audioStreams, videoOnlyStreams, subtitles, relatedStreams]
required: [id, title, uploaderName, uploaderUrl, thumbnailUrl, description, duration, viewCount, uploadDate, uploaded, streamType, isLive, isPostLive, isLiveContent, hasLiveManifest, videoStreams, audioStreams, videoOnlyStreams, subtitles, relatedStreams]
properties:
id: { type: string }
title: { type: string }
Expand All @@ -57,6 +57,10 @@ StreamResponse:
uploaded: { type: integer, format: int64 }
publishedAt: { type: integer, format: int64, nullable: true }
streamType: { type: string }
isLive: { type: boolean }
isPostLive: { type: boolean }
isLiveContent: { type: boolean }
hasLiveManifest: { type: boolean }
isShortFormContent: { type: boolean }
originalAudioTrackId: { type: string, nullable: true }
preferredDefaultAudioTrackId: { type: string, nullable: true }
Expand Down
4 changes: 4 additions & 0 deletions src/main/kotlin/dev/typetype/server/models/StreamResponse.kt
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,8 @@ data class StreamResponse(
val sponsorBlockSegments: List<SponsorBlockSegmentItem>,
val relatedStreams: List<VideoItem>,
val publishedAt: Long? = null,
val isLive: Boolean = false,
val isPostLive: Boolean = false,
val isLiveContent: Boolean = false,
val hasLiveManifest: Boolean = false,
)
3 changes: 3 additions & 0 deletions src/main/kotlin/dev/typetype/server/models/VideoItem.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,7 @@ data class VideoItem(
val uploaderVerified: Boolean,
val shortDescription: String?,
val publishedAt: Long? = null,
val isLive: Boolean = false,
val isPostLive: Boolean = false,
val isLiveContent: Boolean = false,
)
17 changes: 17 additions & 0 deletions src/main/kotlin/dev/typetype/server/services/ChannelTabResolver.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package dev.typetype.server.services

import org.schabi.newpipe.extractor.linkhandler.ChannelTabs

internal fun String.toChannelTab(sort: String?): String? {
if (contains("/shorts", ignoreCase = true)) return ChannelTabs.SHORTS
if (contains("/streams", ignoreCase = true)) return ChannelTabs.LIVESTREAMS
if (contains("/livestreams", ignoreCase = true)) return ChannelTabs.LIVESTREAMS
if (contains("/search", ignoreCase = true)) return ChannelTabs.SEARCH
return if (sort != null) ChannelTabs.VIDEOS else null
}

internal fun String.toBaseChannelUrl(tab: String): String = substringBefore("/$tab")
.substringBefore("/streams")
.substringBefore('?')
.substringBefore('#')
.trimEnd('/')
36 changes: 36 additions & 0 deletions src/main/kotlin/dev/typetype/server/services/LiveStreamTypes.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package dev.typetype.server.services

import org.schabi.newpipe.extractor.stream.StreamType

internal data class StreamLiveMetadata(
val streamType: String,
val hlsUrl: String,
val dashMpdUrl: String,
val isLive: Boolean,
val isPostLive: Boolean,
val isLiveContent: Boolean,
val hasLiveManifest: Boolean,
)

internal fun StreamType?.toApiStreamType(fallback: String = ""): String = this?.name?.lowercase() ?: fallback

internal fun streamLiveMetadata(streamType: StreamType?, hlsUrl: String?, dashMpdUrl: String?): StreamLiveMetadata {
val apiStreamType = streamType.toApiStreamType()
val apiHlsUrl = hlsUrl?.takeIf { it.startsWith("http") } ?: ""
val apiDashMpdUrl = dashMpdUrl?.takeIf { it.startsWith("http") } ?: ""
return StreamLiveMetadata(
streamType = apiStreamType,
hlsUrl = apiHlsUrl,
dashMpdUrl = apiDashMpdUrl,
isLive = apiStreamType.isLiveStreamType(),
isPostLive = apiStreamType.isPostLiveStreamType(),
isLiveContent = apiStreamType.isLiveContentType(),
hasLiveManifest = apiStreamType.isLiveStreamType() && (apiHlsUrl.isNotBlank() || apiDashMpdUrl.isNotBlank()),
)
}

internal fun String.isLiveStreamType(): Boolean = this == "live_stream" || this == "audio_live_stream"

internal fun String.isPostLiveStreamType(): Boolean = this == "post_live_stream" || this == "post_live_audio_stream"

internal fun String.isLiveContentType(): Boolean = isLiveStreamType() || isPostLiveStreamType()
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ 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

class PipePipeChannelService : ChannelService {

Expand Down Expand Up @@ -63,13 +62,5 @@ class PipePipeChannelService : ChannelService {
return ChannelInfo.getMoreItems(service, url, page).toChannelResponse()
}

private fun String.toChannelTab(sort: String?): String? {
if (contains("/shorts", ignoreCase = true)) return ChannelTabs.SHORTS
if (contains("/search", ignoreCase = true)) return ChannelTabs.SEARCH
return if (sort != null) ChannelTabs.VIDEOS else null
}

private fun String.toBaseChannelUrl(tab: String): String = substringBefore("/$tab").substringBefore('?').substringBefore('#').trimEnd('/')

private fun channelId(url: String, service: StreamingService): String = service.channelLHFactory.fromUrl(url).id
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ internal fun List<SponsorBlockSegmentItem>.toSponsorBlockSegments(): Array<Spons

internal fun StreamInfoItem.toVideoItem(fallbackAvatarUrl: String = ""): VideoItem {
val uploaded = uploadDate?.offsetDateTime()?.toInstant()?.toEpochMilli() ?: -1L
val apiStreamType = streamType.toApiStreamType("video")
return VideoItem(
id = url ?: "",
title = name ?: "",
Expand All @@ -48,7 +49,10 @@ internal fun StreamInfoItem.toVideoItem(fallbackAvatarUrl: String = ""): VideoIt
viewCount = viewCount,
uploadDate = textualUploadDate?.extractJapaneseDate() ?: "",
uploaded = uploaded,
streamType = streamType?.name?.lowercase() ?: "video",
streamType = apiStreamType,
isLive = apiStreamType.isLiveStreamType(),
isPostLive = apiStreamType.isPostLiveStreamType(),
isLiveContent = apiStreamType.isLiveContentType(),
isShortFormContent = isShortFormContent,
uploaderVerified = isUploaderVerified,
shortDescription = shortDescription?.takeIf { it.isNotBlank() },
Expand Down
33 changes: 8 additions & 25 deletions src/main/kotlin/dev/typetype/server/services/StreamInfoMappers.kt
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
package dev.typetype.server.services
import dev.typetype.server.models.AudioStreamItem
import dev.typetype.server.models.PreviewFrameItem
import dev.typetype.server.models.StreamResponse
import dev.typetype.server.models.StreamSegmentItem
import dev.typetype.server.models.SubtitleItem
import dev.typetype.server.models.VideoStreamItem
import org.schabi.newpipe.extractor.stream.AudioStream
import org.schabi.newpipe.extractor.stream.Frameset
import org.schabi.newpipe.extractor.stream.StreamInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.extractor.stream.StreamSegment
import org.schabi.newpipe.extractor.stream.SubtitlesStream
import org.schabi.newpipe.extractor.stream.VideoStream

internal fun StreamInfo.toStreamResponse(): StreamResponse {
val uploaded = uploadDate?.offsetDateTime()?.toInstant()?.toEpochMilli() ?: -1L
val liveMetadata = streamLiveMetadata(streamType, hlsUrl, dashMpdUrl)
return StreamResponse(
id = id,
title = name ?: "",
Expand All @@ -36,13 +33,17 @@ internal fun StreamInfo.toStreamResponse(): StreamResponse {
license = licence ?: "",
visibility = privacy?.name?.lowercase() ?: "",
tags = tags ?: emptyList(),
streamType = streamType?.name?.lowercase() ?: "",
streamType = liveMetadata.streamType,
isLive = liveMetadata.isLive,
isPostLive = liveMetadata.isPostLive,
isLiveContent = liveMetadata.isLiveContent,
hasLiveManifest = liveMetadata.hasLiveManifest,
isShortFormContent = isShortFormContent,
requiresMembership = requiresMembership(),
startPosition = startPosition,
streamSegments = runCatching { streamSegments.map { it.toStreamSegmentItem() } }.getOrElse { emptyList() },
hlsUrl = hlsUrl?.takeIf { it.startsWith("http") } ?: "",
dashMpdUrl = dashMpdUrl?.takeIf { it.startsWith("http") } ?: "",
hlsUrl = liveMetadata.hlsUrl,
dashMpdUrl = liveMetadata.dashMpdUrl,
videoStreams = videoStreams.map { it.toVideoStreamItem(false) },
audioStreams = audioStreams.mapNotNull { runCatching { it.toAudioStreamItem() }.getOrNull() },
originalAudioTrackId = null,
Expand Down Expand Up @@ -100,21 +101,3 @@ internal fun SubtitlesStream.toSubtitleItem(): SubtitleItem = SubtitleItem(
displayLanguageName = getDisplayLanguageName() ?: "",
isAutoGenerated = isAutoGenerated,
)

internal fun Frameset.toPreviewFrameItem(): PreviewFrameItem = PreviewFrameItem(
urls = getUrls() ?: emptyList(),
frameWidth = getFrameWidth(),
frameHeight = getFrameHeight(),
totalCount = getTotalCount(),
durationPerFrame = getDurationPerFrame(),
framesPerPageX = getFramesPerPageX(),
framesPerPageY = getFramesPerPageY(),
)

internal fun StreamSegment.toStreamSegmentItem(): StreamSegmentItem = StreamSegmentItem(
title = getTitle() ?: "",
startTimeSeconds = getStartTimeSeconds(),
channelName = getChannelName(),
url = getUrl(),
previewUrl = getPreviewUrl(),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package dev.typetype.server.services

import dev.typetype.server.models.PreviewFrameItem
import dev.typetype.server.models.StreamSegmentItem
import org.schabi.newpipe.extractor.stream.Frameset
import org.schabi.newpipe.extractor.stream.StreamSegment

internal fun Frameset.toPreviewFrameItem(): PreviewFrameItem = PreviewFrameItem(
urls = getUrls() ?: emptyList(),
frameWidth = getFrameWidth(),
frameHeight = getFrameHeight(),
totalCount = getTotalCount(),
durationPerFrame = getDurationPerFrame(),
framesPerPageX = getFramesPerPageX(),
framesPerPageY = getFramesPerPageY(),
)

internal fun StreamSegment.toStreamSegmentItem(): StreamSegmentItem = StreamSegmentItem(
title = getTitle() ?: "",
startTimeSeconds = getStartTimeSeconds(),
channelName = getChannelName(),
url = getUrl(),
previewUrl = getPreviewUrl(),
)
26 changes: 26 additions & 0 deletions src/test/kotlin/dev/typetype/server/ChannelTabResolverTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package dev.typetype.server

import dev.typetype.server.services.toBaseChannelUrl
import dev.typetype.server.services.toChannelTab
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.schabi.newpipe.extractor.linkhandler.ChannelTabs

class ChannelTabResolverTest {

@Test
fun `channel streams url maps to livestreams tab`() {
val url = "https://www.youtube.com/@test/streams?view=2"

assertEquals(ChannelTabs.LIVESTREAMS, url.toChannelTab(null))
assertEquals("https://www.youtube.com/@test", url.toBaseChannelUrl(ChannelTabs.LIVESTREAMS))
}

@Test
fun `channel sort maps base url to videos tab`() {
val url = "https://www.youtube.com/@test"

assertEquals(ChannelTabs.VIDEOS, url.toChannelTab("latest"))
assertEquals("https://www.youtube.com/@test", url.toBaseChannelUrl(ChannelTabs.VIDEOS))
}
}
30 changes: 30 additions & 0 deletions src/test/kotlin/dev/typetype/server/LiveStreamTypesTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package dev.typetype.server

import dev.typetype.server.services.streamLiveMetadata
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.schabi.newpipe.extractor.stream.StreamType

class LiveStreamTypesTest {

@Test
fun `live stream with manifest has live manifest`() {
val metadata = streamLiveMetadata(StreamType.LIVE_STREAM, "https://example.com/live.m3u8", "")

assertTrue(metadata.isLive)
assertFalse(metadata.isPostLive)
assertTrue(metadata.isLiveContent)
assertTrue(metadata.hasLiveManifest)
}

@Test
fun `post live stream is live content without live manifest flag`() {
val metadata = streamLiveMetadata(StreamType.POST_LIVE_STREAM, "https://example.com/replay.m3u8", "")

assertFalse(metadata.isLive)
assertTrue(metadata.isPostLive)
assertTrue(metadata.isLiveContent)
assertFalse(metadata.hasLiveManifest)
}
}
14 changes: 13 additions & 1 deletion src/test/kotlin/dev/typetype/server/StreamFieldsTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,21 @@ class StreamFieldsTest {
@Test
fun `GET streams serializes streamType`() = withApp {
coEvery { streamService.getStreamInfo(any()) } returns
ExtractionResult.Success(testStreamResponse().copy(streamType = "live_stream"))
ExtractionResult.Success(
testStreamResponse().copy(
streamType = "live_stream",
isLive = true,
isLiveContent = true,
hasLiveManifest = true,
hlsUrl = "https://example.com/live.m3u8",
)
)
val body = client.get("/streams?url=https://youtube.com/watch?v=test").bodyAsText()
assertTrue(body.contains("\"streamType\":\"live_stream\""))
assertTrue(body.contains("\"isLive\":true"))
assertTrue(body.contains("\"isPostLive\":false"))
assertTrue(body.contains("\"isLiveContent\":true"))
assertTrue(body.contains("\"hasLiveManifest\":true"))
}

@Test
Expand Down
4 changes: 4 additions & 0 deletions src/test/kotlin/dev/typetype/server/TestFixtures.kt
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ fun testStreamResponse(
visibility = "",
tags = emptyList(),
streamType = "video_stream",
isLive = false,
isPostLive = false,
isLiveContent = false,
hasLiveManifest = false,
isShortFormContent = false,
requiresMembership = false,
startPosition = 0L,
Expand Down
Loading