diff --git a/src/main/kotlin/dev/typetype/server/models/AudioStreamItem.kt b/src/main/kotlin/dev/typetype/server/models/AudioStreamItem.kt index 7c5441e..29f386c 100644 --- a/src/main/kotlin/dev/typetype/server/models/AudioStreamItem.kt +++ b/src/main/kotlin/dev/typetype/server/models/AudioStreamItem.kt @@ -19,4 +19,5 @@ data class AudioStreamItem( val audioTrackId: String?, val audioTrackName: String?, val audioLocale: String?, + val isOriginal: Boolean, ) diff --git a/src/main/kotlin/dev/typetype/server/models/StreamResponse.kt b/src/main/kotlin/dev/typetype/server/models/StreamResponse.kt index 7fb0a0e..d55238d 100644 --- a/src/main/kotlin/dev/typetype/server/models/StreamResponse.kt +++ b/src/main/kotlin/dev/typetype/server/models/StreamResponse.kt @@ -32,6 +32,8 @@ data class StreamResponse( val dashMpdUrl: String, val videoStreams: List, val audioStreams: List, + val originalAudioTrackId: String?, + val preferredDefaultAudioTrackId: String?, val videoOnlyStreams: List, val subtitles: List, val previewFrames: List, diff --git a/src/main/kotlin/dev/typetype/server/services/ManifestService.kt b/src/main/kotlin/dev/typetype/server/services/ManifestService.kt index cf21470..0959bc1 100644 --- a/src/main/kotlin/dev/typetype/server/services/ManifestService.kt +++ b/src/main/kotlin/dev/typetype/server/services/ManifestService.kt @@ -7,13 +7,12 @@ import java.net.URLEncoder import java.nio.charset.StandardCharsets class ManifestService(private val streamService: StreamService) { - suspend fun dashManifest(videoUrl: String): ExtractionResult { val result = streamService.getStreamInfo(videoUrl) if (result !is ExtractionResult.Success) return result.recast() val info = result.data val videos = compatibleVideoStreams(info.videoOnlyStreams) - val audios = compatibleAudioStreams(info.audioStreams) + val audios = compatibleAudioStreams(info.audioStreams, info.preferredDefaultAudioTrackId) if (videos.isEmpty() && audios.isEmpty()) return ExtractionResult.Failure("No compatible streams found for DASH manifest") return ExtractionResult.Success(buildMpd(videos, audios, info.duration)) @@ -23,9 +22,10 @@ class ManifestService(private val streamService: StreamService) { streams.filter { it.codec?.startsWith("av01") != true && it.url.isNotBlank() && !it.codec.isNullOrBlank() } .sortedWith(compareBy({ codecPriority(it.codec ?: "") }, { -(it.bitrate ?: bwFromUrl(it.url) ?: 0) })) - private fun compatibleAudioStreams(streams: List): List = + private fun compatibleAudioStreams(streams: List, preferredTrackId: String?): List = streams.filter { it.url.isNotBlank() && !it.codec.isNullOrBlank() } - .sortedByDescending { it.bitrate ?: 0 } + .sortedWith(compareBy { preferredTrackId != null && it.audioTrackId != preferredTrackId } + .thenByDescending { it.bitrate ?: 0 }) private fun codecPriority(codec: String): Int = when { codec.startsWith("avc1") -> 0 @@ -117,4 +117,4 @@ class ManifestService(private val streamService: StreamService) { is ExtractionResult.BadRequest -> ExtractionResult.BadRequest(message) is ExtractionResult.Failure -> ExtractionResult.Failure(message) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/dev/typetype/server/services/NativeManifestBuilder.kt b/src/main/kotlin/dev/typetype/server/services/NativeManifestBuilder.kt index 147990d..8a28e7e 100644 --- a/src/main/kotlin/dev/typetype/server/services/NativeManifestBuilder.kt +++ b/src/main/kotlin/dev/typetype/server/services/NativeManifestBuilder.kt @@ -8,7 +8,7 @@ import org.schabi.newpipe.extractor.stream.VideoStream internal object NativeManifestBuilder { - fun build(videos: List, audios: List, duration: Long): String { + fun build(videos: List, audios: List, duration: Long, preferredAudioTrackId: String?): String { val sb = StringBuilder() sb.appendLine("") sb.appendLine("") sb.appendLine(" ") buildVideoAdaptationSets(sb, videos, duration) - buildAudioAdaptationSets(sb, audios, duration) + buildAudioAdaptationSets(sb, audios, duration, preferredAudioTrackId) sb.appendLine(" ") sb.append("") return sb.toString() @@ -34,10 +34,18 @@ internal object NativeManifestBuilder { } } - private fun buildAudioAdaptationSets(sb: StringBuilder, audios: List, duration: Long) { + private fun buildAudioAdaptationSets( + sb: StringBuilder, + audios: List, + duration: Long, + preferredAudioTrackId: String?, + ) { if (audios.isEmpty()) return var id = 0 - audios.groupBy { audioMimeType(it.getCodec() ?: "") } + val ordered = if (preferredAudioTrackId == null) audios else audios.sortedBy { + it.getAudioTrackId() != preferredAudioTrackId + } + ordered.groupBy { audioMimeType(it.getCodec() ?: "") } .forEach { (mime, group) -> sb.appendLine(" ") group.forEach { s -> appendAudioRepresentation(sb, s, id++, duration) } diff --git a/src/main/kotlin/dev/typetype/server/services/NativeManifestService.kt b/src/main/kotlin/dev/typetype/server/services/NativeManifestService.kt index 93639a3..0008a36 100644 --- a/src/main/kotlin/dev/typetype/server/services/NativeManifestService.kt +++ b/src/main/kotlin/dev/typetype/server/services/NativeManifestService.kt @@ -31,11 +31,12 @@ class NativeManifestService { private fun buildManifest(info: StreamInfo): ExtractionResult { val videos = compatibleVideoStreams(info.videoOnlyStreams) val audios = compatibleAudioStreams(info.audioStreams) + val preferredAudioTrackId = resolvePreferredAudioTrackId(audios) if (videos.isEmpty() && audios.isEmpty()) return ExtractionResult.Failure("No compatible streams found") return runCatching { ExtractionResult.Success( - NativeManifestBuilder.build(videos, audios, info.duration) + NativeManifestBuilder.build(videos, audios, info.duration, preferredAudioTrackId) ) }.getOrElse { ExtractionResult.Failure(it.message ?: "Manifest build failed") @@ -73,4 +74,19 @@ class NativeManifestService { else -> 2 } } + + private fun resolvePreferredAudioTrackId(audios: List): String? { + val original = audios.firstNotNullOfOrNull { stream -> + val name = stream.getAudioTrackName()?.lowercase() ?: return@firstNotNullOfOrNull null + stream.getAudioTrackId()?.takeIf { "original" in name || "default" in name || "yokuqala" in name } + } + if (original != null) return original + val english = audios.firstNotNullOfOrNull { stream -> + stream.getAudioTrackId()?.takeIf { + stream.getAudioLocale() == "en" || it.substringBefore('.').substringBefore('-') == "en" + } + } + if (english != null) return english + return audios.firstNotNullOfOrNull { it.getAudioTrackId() } + } } diff --git a/src/main/kotlin/dev/typetype/server/services/PipePipeStreamService.kt b/src/main/kotlin/dev/typetype/server/services/PipePipeStreamService.kt index 750f25d..8707798 100644 --- a/src/main/kotlin/dev/typetype/server/services/PipePipeStreamService.kt +++ b/src/main/kotlin/dev/typetype/server/services/PipePipeStreamService.kt @@ -48,7 +48,7 @@ internal class PipePipeStreamService( val segmentsDeferred = async { resolveSegments(extractor) } val streamInfo = streamInfoDeferred.await() streamInfo.setSponsorBlockSegments(segmentsDeferred.await()) - val response = streamInfo.toStreamResponse() + val response = StreamAudioContractResolver.apply(streamInfo.toStreamResponse()) val withSubtitles = if (response.subtitles.isEmpty() && service.serviceId == 0) { response.copy(subtitles = subtitleService.fetchSubtitles(streamInfo.id)) } else { diff --git a/src/main/kotlin/dev/typetype/server/services/StreamAudioContractResolver.kt b/src/main/kotlin/dev/typetype/server/services/StreamAudioContractResolver.kt new file mode 100644 index 0000000..15ef599 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/StreamAudioContractResolver.kt @@ -0,0 +1,61 @@ +package dev.typetype.server.services + +import dev.typetype.server.models.AudioStreamItem +import dev.typetype.server.models.StreamResponse + +object StreamAudioContractResolver { + fun apply( + response: StreamResponse, + fallbackLanguage: String = defaultFallbackLanguage(), + ): StreamResponse { + val normalized = response.audioStreams.map { stream -> + stream.copy(isOriginal = isOriginalTrack(stream)) + } + val originalTrackId = normalized.firstNotNullOfOrNull { stream -> + stream.audioTrackId?.takeIf { stream.isOriginal } + } + val preferredTrackId = resolvePreferredTrackId(normalized, fallbackLanguage, originalTrackId) + return response.copy( + audioStreams = normalized, + originalAudioTrackId = originalTrackId, + preferredDefaultAudioTrackId = preferredTrackId, + ) + } + + private fun resolvePreferredTrackId( + streams: List, + fallbackLanguage: String, + originalTrackId: String?, + ): String? { + if (originalTrackId != null) return originalTrackId + val fallbackTrackId = streams.firstNotNullOfOrNull { stream -> + stream.audioTrackId?.takeIf { matchesFallback(stream, fallbackLanguage) } + } + if (fallbackTrackId != null) return fallbackTrackId + return streams.firstNotNullOfOrNull { it.audioTrackId } + } + + private fun matchesFallback(stream: AudioStreamItem, fallbackLanguage: String): Boolean { + val wanted = normalizedLanguage(fallbackLanguage) + val locale = stream.audioLocale?.let(::normalizedLanguage) + if (locale != null && locale == wanted) return true + val trackLanguage = stream.audioTrackId?.substringBefore('.')?.let(::normalizedLanguage) + return trackLanguage != null && trackLanguage == wanted + } + + private fun isOriginalTrack(stream: AudioStreamItem): Boolean { + val lowered = stream.audioTrackName?.lowercase() ?: return false + if ("original" in lowered) return true + if ("default" in lowered) return true + if ("yokuqala" in lowered) return true + return false + } + + private fun normalizedLanguage(language: String): String = language.lowercase().substringBefore('-') + + private fun defaultFallbackLanguage(): String = + System.getenv(AUDIO_FALLBACK_ENV)?.trim().orEmpty().ifBlank { DEFAULT_FALLBACK_LANGUAGE } + + private const val AUDIO_FALLBACK_ENV = "DEFAULT_AUDIO_FALLBACK_LANGUAGE" + private const val DEFAULT_FALLBACK_LANGUAGE = "en" +} diff --git a/src/main/kotlin/dev/typetype/server/services/StreamInfoMappers.kt b/src/main/kotlin/dev/typetype/server/services/StreamInfoMappers.kt index d634398..91d7efd 100644 --- a/src/main/kotlin/dev/typetype/server/services/StreamInfoMappers.kt +++ b/src/main/kotlin/dev/typetype/server/services/StreamInfoMappers.kt @@ -1,5 +1,4 @@ package dev.typetype.server.services - import dev.typetype.server.models.AudioStreamItem import dev.typetype.server.models.PreviewFrameItem import dev.typetype.server.models.StreamResponse @@ -46,6 +45,8 @@ internal fun StreamInfo.toStreamResponse(): StreamResponse { dashMpdUrl = dashMpdUrl?.takeIf { it.startsWith("http") } ?: "", videoStreams = videoStreams.map { it.toVideoStreamItem(false) }, audioStreams = audioStreams.mapNotNull { runCatching { it.toAudioStreamItem() }.getOrNull() }, + originalAudioTrackId = null, + preferredDefaultAudioTrackId = null, videoOnlyStreams = videoOnlyStreams.map { it.toVideoStreamItem(true) }, subtitles = subtitles.mapNotNull { runCatching { it.toSubtitleItem() }.getOrNull() }, previewFrames = previewFrames.mapNotNull { runCatching { it.toPreviewFrameItem() }.getOrNull() }, @@ -53,7 +54,6 @@ internal fun StreamInfo.toStreamResponse(): StreamResponse { relatedStreams = relatedItems.filterIsInstance().mapNotNull { runCatching { it.toVideoItem() }.getOrNull() }, ) } - internal fun VideoStream.toVideoStreamItem(isVideoOnly: Boolean): VideoStreamItem = VideoStreamItem( url = getContent() ?: "", @@ -90,6 +90,7 @@ internal fun AudioStream.toAudioStreamItem(): AudioStreamItem = AudioStreamItem( audioTrackId = getAudioTrackId(), audioTrackName = getAudioTrackName(), audioLocale = getAudioLocale(), + isOriginal = false, ) internal fun SubtitlesStream.toSubtitleItem(): SubtitleItem = SubtitleItem( diff --git a/src/test/kotlin/dev/typetype/server/HomeRecommendationCandidateServiceTest.kt b/src/test/kotlin/dev/typetype/server/HomeRecommendationCandidateServiceTest.kt index ca07361..c0fb2fb 100644 --- a/src/test/kotlin/dev/typetype/server/HomeRecommendationCandidateServiceTest.kt +++ b/src/test/kotlin/dev/typetype/server/HomeRecommendationCandidateServiceTest.kt @@ -78,7 +78,8 @@ class HomeRecommendationCandidateServiceTest { uploaderSubscriberCount = 0, uploaderVerified = false, category = "", license = "", visibility = "", tags = emptyList(), streamType = "video_stream", isShortFormContent = false, requiresMembership = false, startPosition = 0, streamSegments = emptyList(), hlsUrl = "", dashMpdUrl = "", videoStreams = emptyList(), - audioStreams = emptyList(), videoOnlyStreams = emptyList(), subtitles = emptyList(), previewFrames = emptyList(), + audioStreams = emptyList(), originalAudioTrackId = null, preferredDefaultAudioTrackId = null, + videoOnlyStreams = emptyList(), subtitles = emptyList(), previewFrames = emptyList(), sponsorBlockSegments = emptyList(), relatedStreams = related, publishedAt = 0, ) diff --git a/src/test/kotlin/dev/typetype/server/NativeManifestBuilderCoreTest.kt b/src/test/kotlin/dev/typetype/server/NativeManifestBuilderCoreTest.kt index 69eb306..a3c85c5 100644 --- a/src/test/kotlin/dev/typetype/server/NativeManifestBuilderCoreTest.kt +++ b/src/test/kotlin/dev/typetype/server/NativeManifestBuilderCoreTest.kt @@ -25,7 +25,7 @@ class NativeManifestBuilderCoreTest { every { video.getInitStart() } returns 0 every { video.getInitEnd() } returns 220 - val manifest = NativeManifestBuilder.build(videos = listOf(video), audios = emptyList(), duration = 300) + val manifest = NativeManifestBuilder.build(videos = listOf(video), audios = emptyList(), duration = 300, preferredAudioTrackId = null) assertTrue(manifest.contains("mediaPresentationDuration=\"PT300S\"")) assertTrue(manifest.contains("