diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 72065a77e9..59bf10d232 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,5 @@ +import com.google.protobuf.gradle.id + plugins { id("com.android.application") id("kotlin-android") @@ -6,6 +8,7 @@ plugins { id("androidx.navigation.safeargs.kotlin") alias(libs.plugins.baselineprofile) alias(libs.plugins.ksp) + alias(libs.plugins.google.protobuf) } android { @@ -136,6 +139,8 @@ dependencies { implementation(libs.kotlinx.serialization) implementation(libs.kotlinx.datetime) implementation(libs.converter.kotlinx.serialization) + implementation(libs.google.protobuf.javalite) + implementation(libs.google.protobuf.kotlin.lite) /* NewPipe Extractor */ implementation(libs.newpipeextractor) @@ -160,3 +165,23 @@ dependencies { /* Testing */ testImplementation(libs.junit) } + +//TODO: exclude from release protobuf +protobuf { + protoc { + artifact = libs.protobuf.protoc.get().toString() + } + generateProtoTasks { + all().forEach { task -> + task.plugins { + //TODO: only generate kotlin code + id("java") { + option("lite") + } +// id("kotlin") { +// option("lite") +// } + } + } + } +} diff --git a/app/src/main/java/com/github/libretube/api/NewPipeMediaServiceRepository.kt b/app/src/main/java/com/github/libretube/api/NewPipeMediaServiceRepository.kt index 070bc981bd..cf81b0acf2 100644 --- a/app/src/main/java/com/github/libretube/api/NewPipeMediaServiceRepository.kt +++ b/app/src/main/java/com/github/libretube/api/NewPipeMediaServiceRepository.kt @@ -70,7 +70,10 @@ private fun VideoStream.toPipedStream() = PipedStream( indexStart = indexStart, indexEnd = indexEnd, fps = fps, - contentLength = itagItem?.contentLength ?: 0L + contentLength = itagItem?.contentLength ?: 0L, + itag = itagItem?.id, + lastModified = itagItem?.lastModified, + xtags = itagItem?.xtags, ) private fun AudioStream.toPipedStream() = PipedStream( @@ -89,7 +92,11 @@ private fun AudioStream.toPipedStream() = PipedStream( audioTrackName = audioTrackName, audioTrackLocale = audioLocale?.toLanguageTag(), audioTrackType = audioTrackType?.name, - videoOnly = false + videoOnly = false, + itag = itagItem?.id, + lastModified = itagItem?.lastModified, + isDrc = itagItem?.isDrc, + xtags = itagItem?.xtags, ) fun StreamInfoItem.toStreamItem( @@ -328,8 +335,8 @@ class NewPipeMediaServiceRepository : MediaServiceRepository { ) }, audioStreams = resp.audioStreams.map { it.toPipedStream() }, - videoStreams = resp.videoOnlyStreams.map { it.toPipedStream().copy(videoOnly = true) } + - resp.videoStreams.map { it.toPipedStream().copy(videoOnly = false) }, + videoStreams = resp.videoOnlyStreams.map { it.toPipedStream().copy(videoOnly = true,) }, + //+ resp.videoStreams.map { it.toPipedStream().copy(videoOnly = false) }, previewFrames = resp.previewFrames.map { PreviewFrames( it.urls, @@ -349,7 +356,9 @@ class NewPipeMediaServiceRepository : MediaServiceRepository { it.languageTag, it.isAutoGenerated ) - } + }, + serverAbrStreamingUrl = resp.serverAbrStreamingUrl, + videoPlaybackUstreamerConfig = resp.ustreamerConfig, ) } diff --git a/app/src/main/java/com/github/libretube/api/obj/PipedStream.kt b/app/src/main/java/com/github/libretube/api/obj/PipedStream.kt index 90f4db875e..8107860141 100644 --- a/app/src/main/java/com/github/libretube/api/obj/PipedStream.kt +++ b/app/src/main/java/com/github/libretube/api/obj/PipedStream.kt @@ -29,7 +29,11 @@ data class PipedStream( val audioTrackId: String? = null, val contentLength: Long = -1, val audioTrackType: String? = null, - val audioTrackLocale: String? = null + val audioTrackLocale: String? = null, + val itag: Int? = null, + val lastModified: Long? = null, + val isDrc: Boolean? = null, + val xtags: String? = null, ): Parcelable { private fun getQualityString(videoId: String): String { return "${videoId}_${quality?.replace(" ", "_")}_$format." + diff --git a/app/src/main/java/com/github/libretube/api/obj/Streams.kt b/app/src/main/java/com/github/libretube/api/obj/Streams.kt index 359316ed40..9fe048197f 100644 --- a/app/src/main/java/com/github/libretube/api/obj/Streams.kt +++ b/app/src/main/java/com/github/libretube/api/obj/Streams.kt @@ -48,7 +48,9 @@ data class Streams( val proxyUrl: String? = null, val chapters: List = emptyList(), val uploaderSubscriberCount: Long = 0, - val previewFrames: List = emptyList() + val previewFrames: List = emptyList(), + val serverAbrStreamingUrl: String? = null, + val videoPlaybackUstreamerConfig: String? = null ): Parcelable { @IgnoredOnParcel val isLive = livestream || duration <= 0 diff --git a/app/src/main/java/com/github/libretube/api/poToken/PoTokenGenerator.kt b/app/src/main/java/com/github/libretube/api/poToken/PoTokenGenerator.kt index 38f1b1c338..52bea055bc 100644 --- a/app/src/main/java/com/github/libretube/api/poToken/PoTokenGenerator.kt +++ b/app/src/main/java/com/github/libretube/api/poToken/PoTokenGenerator.kt @@ -19,7 +19,6 @@ class PoTokenGenerator : PoTokenProvider { private object WebPoTokenGenLock private var webPoTokenVisitorData: String? = null - private var webPoTokenStreamingPot: String? = null private var webPoTokenGenerator: PoTokenWebView? = null @@ -37,10 +36,7 @@ class PoTokenGenerator : PoTokenProvider { * [PoTokenGenerator.getWebClientPoToken] was called */ private fun getWebClientPoToken(videoId: String, forceRecreate: Boolean): PoTokenResult { - // just a helper class since Kotlin does not have builtin support for 4-tuples - data class Quadruple(val t1: T1, val t2: T2, val t3: T3, val t4: T4) - - val (poTokenGenerator, visitorData, streamingPot, hasBeenRecreated) = + val (poTokenGenerator, visitorData, hasBeenRecreated) = synchronized(WebPoTokenGenLock) { val shouldRecreate = webPoTokenGenerator == null || forceRecreate || webPoTokenGenerator!!.isExpired() @@ -66,22 +62,17 @@ class PoTokenGenerator : PoTokenProvider { // create a new webPoTokenGenerator webPoTokenGenerator = PoTokenWebView .newPoTokenGenerator(LibreTubeApp.instance) - - // The streaming poToken needs to be generated exactly once before generating - // any other (player) tokens. - webPoTokenStreamingPot = webPoTokenGenerator!!.generatePoToken(webPoTokenVisitorData!!) } } - return@synchronized Quadruple( + return@synchronized Triple( webPoTokenGenerator!!, webPoTokenVisitorData!!, - webPoTokenStreamingPot!!, shouldRecreate ) } - val playerPot = try { + val poToken = try { // Not using synchronized here, since poTokenGenerator would be able to generate // multiple poTokens in parallel if needed. The only important thing is for exactly one // visitorData/streaming poToken to be generated before anything else. @@ -105,13 +96,11 @@ class PoTokenGenerator : PoTokenProvider { if (BuildConfig.DEBUG) { Log.d( - TAG, - "poToken for $videoId: playerPot=$playerPot, " + - "streamingPot=$streamingPot, visitor_data=$visitorData" + TAG, "poToken for $videoId: $poToken, visitor_data=$visitorData" ) } - return PoTokenResult(visitorData, playerPot, streamingPot) + return PoTokenResult(visitorData, poToken, poToken) } override fun getWebEmbedClientPoToken(videoId: String?): PoTokenResult? = null diff --git a/app/src/main/java/com/github/libretube/player/DefaultSabrChunkSource.kt b/app/src/main/java/com/github/libretube/player/DefaultSabrChunkSource.kt new file mode 100644 index 0000000000..af38efd887 --- /dev/null +++ b/app/src/main/java/com/github/libretube/player/DefaultSabrChunkSource.kt @@ -0,0 +1,461 @@ +package com.github.libretube.player + +import android.os.SystemClock +import androidx.media3.common.C +import androidx.media3.common.C.TrackType +import androidx.media3.common.Format +import androidx.media3.common.util.UnstableApi +import androidx.media3.common.util.Util +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.HttpDataSource.InvalidResponseCodeException +import androidx.media3.datasource.TransferListener +import androidx.media3.exoplayer.LoadingInfo +import androidx.media3.exoplayer.SeekParameters +import androidx.media3.exoplayer.analytics.PlayerId +import androidx.media3.exoplayer.source.chunk.BaseMediaChunkIterator +import androidx.media3.exoplayer.source.chunk.BundledChunkExtractor +import androidx.media3.exoplayer.source.chunk.Chunk +import androidx.media3.exoplayer.source.chunk.ChunkExtractor +import androidx.media3.exoplayer.source.chunk.ChunkHolder +import androidx.media3.exoplayer.source.chunk.ContainerMediaChunk +import androidx.media3.exoplayer.source.chunk.InitializationChunk +import androidx.media3.exoplayer.source.chunk.MediaChunk +import androidx.media3.exoplayer.source.chunk.MediaChunkIterator +import androidx.media3.exoplayer.trackselection.ExoTrackSelection +import androidx.media3.exoplayer.upstream.CmcdConfiguration +import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy +import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy.FallbackOptions +import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy.LoadErrorInfo +import androidx.media3.extractor.ChunkIndex +import com.github.libretube.player.manifest.Representation +import com.github.libretube.player.manifest.SabrManifest +import com.github.libretube.player.parser.PlaybackRequest +import com.github.libretube.player.parser.SabrClient +import java.time.Instant + +/** A default [SabrChunkSource] implementation. */ +@UnstableApi +class DefaultSabrChunkSource( + chunkExtractorFactory: ChunkExtractor.Factory, + private val manifest: SabrManifest, + private val sabrClient: SabrClient, + private val adaptationSetIndices: IntArray, + private var trackSelection: ExoTrackSelection, + private val trackType: @TrackType Int, + private val dataSource: DataSource, + private val playerId: PlayerId, +) : SabrChunkSource { + + /** [SabrChunkSource.Factory] for [DefaultSabrChunkSource] instances. */ + class Factory( + private val dataSourceFactory: DataSource.Factory, + ) : SabrChunkSource.Factory { + private val chunkExtractorFactory = BundledChunkExtractor.Factory() + + override fun createSabrChunkSource( + manifest: SabrManifest, + sabrClient: SabrClient, + adaptationSetIndices: IntArray, + trackSelection: ExoTrackSelection, + trackType: @TrackType Int, + elapsedRealtimeOffsetMs: Long, + transferListener: TransferListener?, + playerId: PlayerId, + cmcdConfiguration: CmcdConfiguration?, + ): SabrChunkSource { + val dataSource = dataSourceFactory.createDataSource() + transferListener?.let { dataSource.addTransferListener(it) } + return DefaultSabrChunkSource( + chunkExtractorFactory, + manifest, + sabrClient, + adaptationSetIndices, + trackSelection, + trackType, + dataSource, + playerId, + ) + } + + /** + * {@inheritDoc} + * + * + * This implementation delegates determining of the output format to the [ ] passed to the constructor of this class. + */ + override fun getOutputTextFormat(sourceFormat: Format): Format { + return chunkExtractorFactory.getOutputTextFormat(sourceFormat) + } + } + + private val representationHolders: MutableList + + private var fatalError: Exception? = null + private var missingLastSegment = false + + /** + * @param chunkExtractorFactory Creates [ChunkExtractor] instances to use for extracting + * chunks. + * @param manifest The initial manifest. + * @param adaptationSetIndices The indices of the adaptation sets in the period. + * @param trackSelection The track selection. + * @param trackType The [type][C.TrackType] of the tracks in the selection. + * @param dataSource A [DataSource] suitable for loading the media data. + * @param playerId The [PlayerId] of the player using this chunk source. + */ + init { + val representations = + adaptationSetIndices.flatMap { manifest.adaptationSets[it].representations } + .filterNotNull().toList() + representationHolders = (0.., + ): Int { + if (fatalError != null || trackSelection.length() < 2) { + return queue.size + } + return trackSelection.evaluateQueueSize(playbackPositionUs, queue) + } + + override fun shouldCancelLoad( + playbackPositionUs: Long, loadingChunk: Chunk, queue: MutableList, + ): Boolean { + if (fatalError != null) { + return false + } + return trackSelection.shouldCancelChunkLoad(playbackPositionUs, loadingChunk, queue) + } + + override fun getNextChunk( + loadingInfo: LoadingInfo, + loadPositionUs: Long, + queue: List, + out: ChunkHolder, + ) { + if (fatalError != null) { + return + } + + val playbackPositionUs = loadingInfo.playbackPositionUs + val bufferedDurationUs = loadPositionUs - playbackPositionUs + + val previousChunk = queue.lastOrNull() + + val chunkIterators = representationHolders.map { + if (it.chunkIndex == null) MediaChunkIterator.EMPTY + else { + val lastAvailableSegmentNum= it.getLastAvailableSegmentNum() + + val segmentNum = previousChunk?.nextChunkIndex ?: Util.constrainValue( + it.getSegmentNum(loadPositionUs), + 0, + lastAvailableSegmentNum + ) + + RepresentationSegmentIterator( + it, segmentNum, lastAvailableSegmentNum + ) + } + }.toTypedArray() + + trackSelection.updateSelectedTrack( + playbackPositionUs, + bufferedDurationUs, + C.TIME_UNSET, + queue, + chunkIterators, + ) + + val representationHolder = representationHolders[trackSelection.selectedIndex] + + if (representationHolder.chunkExtractor != null) { + if (representationHolder.chunkIndex == null) { + // when we request a new format, it should start with a initialization chunk + val dataSpec = DataSpec.Builder() + // must be non-null, but is unused + .setUri(manifest.serverAbrStreamingUri) + .setCustomData( + PlaybackRequest.initRequest( + representationHolder.representation.formatId(), + Util.usToMs(playbackPositionUs), + loadingInfo.playbackSpeed, + ) + ) + .build() + + out.chunk = InitializationChunk( + dataSource, + dataSpec, + trackSelection.selectedFormat, + trackSelection.selectionReason, + trackSelection.selectionData, + representationHolder.chunkExtractor + ) + return + } + } + + if (representationHolder.segmentCount == 0L) { + // The index doesn't define any segments. + out.endOfStream = true; + return; + } + + val lastAvailableSegmentNum = representationHolder.getLastAvailableSegmentNum() + val segmentNum = previousChunk?.nextChunkIndex ?: Util.constrainValue( + representationHolder.getSegmentNum(loadPositionUs), + 0, + lastAvailableSegmentNum + ) + + //TODO: is this check needed? + if (segmentNum > lastAvailableSegmentNum + || (missingLastSegment && segmentNum >= lastAvailableSegmentNum)) { + // The segment is beyond the end of the period. + out.endOfStream = true; + return; + } + + //TODO: is this check needed? + if (representationHolder.getSegmentStartTimeUs(segmentNum) >= Util.msToUs(manifest.durationMs)) { + // The period duration clips the period to a position before the segment. + out.endOfStream = true; + return; + } + + val seekTimeUs = if (queue.isEmpty()) loadPositionUs else C.TIME_UNSET + val startTimeUs = representationHolder.getSegmentStartTimeUs(segmentNum) + + + // use the queue to build the buffered segments + // each queue media chunk corresponds to 1 segment + val bufferedSegments = queue.mapNotNull { (it.dataSpec.customData as PlaybackRequest?)?.segment } + val dataSpec = DataSpec.Builder() + // must be non-null, but is unused + .setUri(manifest.serverAbrStreamingUri) + .setCustomData(PlaybackRequest( + representationHolder.representation.formatId(), + Util.usToMs(playbackPositionUs), + loadingInfo.playbackSpeed, + segmentNum, + Util.usToMs(startTimeUs), + bufferedSegments, + )) + .build() + + out.chunk = ContainerMediaChunk( + dataSource, + dataSpec, + trackSelection.selectedFormat, + trackSelection.selectionReason, + trackSelection.selectionData, + startTimeUs, + representationHolder.getSegmentEndTimeUs(segmentNum), + seekTimeUs, + representationHolder.periodDurationUs, + segmentNum, + 1, + 0, + representationHolder.chunkExtractor!! + ) + } + + override fun onChunkLoadCompleted(chunk: Chunk) { + if (chunk is InitializationChunk) { + val trackIndex = trackSelection.indexOf(chunk.trackFormat) + val representationHolder = representationHolders[trackIndex] + // The null check avoids overwriting an index obtained from the manifest with one obtained + // from the stream. If the manifest defines an index then the stream shouldn't, but in cases + // where it does we should ignore it. + if (representationHolder.chunkIndex == null) { + representationHolder.chunkExtractor?.chunkIndex?.let { + representationHolders[trackIndex].chunkIndex = it + } + } + } + } + + override fun onChunkLoadError( + chunk: Chunk, + cancelable: Boolean, + loadErrorInfo: LoadErrorInfo, + loadErrorHandlingPolicy: LoadErrorHandlingPolicy, + ): Boolean { + if (!cancelable) { + return false + } + // Workaround for missing segment at the end of the period + if (chunk is MediaChunk + && loadErrorInfo.exception is InvalidResponseCodeException + && (loadErrorInfo.exception as InvalidResponseCodeException).responseCode == 404 + ) { + val representationHolder = + representationHolders[trackSelection.indexOf(chunk.trackFormat)] + val segmentCount = representationHolder.segmentCount + if (segmentCount != RepresentationHolder.INDEX_UNBOUNDED && segmentCount != 0L) { + val lastAvailableSegmentNum = segmentCount - 1 + if (chunk.nextChunkIndex > lastAvailableSegmentNum) { + missingLastSegment = true + return true + } + } + } + + val fallbackOptions = createFallbackOptions(trackSelection) + if (!fallbackOptions.isFallbackAvailable(LoadErrorHandlingPolicy.FALLBACK_TYPE_TRACK) + && !fallbackOptions.isFallbackAvailable(LoadErrorHandlingPolicy.FALLBACK_TYPE_LOCATION) + ) { + return false + } + val fallbackSelection = + loadErrorHandlingPolicy.getFallbackSelectionFor(fallbackOptions, loadErrorInfo) + if (fallbackSelection == null || !fallbackOptions.isFallbackAvailable(fallbackSelection.type)) { + // Policy indicated to not use any fallback or a fallback type that is not available. + return false + } + + var cancelLoad = false + if (fallbackSelection.type == LoadErrorHandlingPolicy.FALLBACK_TYPE_TRACK) { + cancelLoad = + trackSelection.excludeTrack( + trackSelection.indexOf(chunk.trackFormat), fallbackSelection.exclusionDurationMs + ) + } + return cancelLoad + } + + override fun release() { + for (representationHolder in representationHolders) { + representationHolder.chunkExtractor?.release() + } + } + + private fun createFallbackOptions(trackSelection: ExoTrackSelection): FallbackOptions { + val nowMs = SystemClock.elapsedRealtime() + val numberOfTracks = trackSelection.length() + var numberOfExcludedTracks = 0 + for (i in 0..> { + private val trackGroups: TrackGroupArray + private val trackGroupInfos: Array + + private var callback: MediaPeriod.Callback? = null + private var sampleStreams: Array> = emptyArray() + private var compositeSequenceableLoader: SequenceableLoader = compositeSequenceableLoaderFactory.empty() + private var canReportInitialDiscontinuity = true + private var initialStartTimeUs: Long = 0 + + init { + val result = buildTrackGroups( + drmSessionManager, + chunkSourceFactory, + manifest.adaptationSets + ) + trackGroups = result.first + trackGroupInfos = result.second as Array + } + + fun release() { + sampleStreams.forEach { it.release(null) } + callback = null + } + + override fun prepare(callback: MediaPeriod.Callback, positionUs: Long) { + this.callback = callback + callback.onPrepared(this) + } + + override fun maybeThrowPrepareError() {} + + override fun getTrackGroups(): TrackGroupArray = trackGroups + + override fun getStreamKeys(trackSelections: MutableList): List { + val manifestAdaptationSets = manifest.adaptationSets + val streamKeys = mutableListOf() + for (trackSelection in trackSelections) { + val trackGroupIndex = trackGroups.indexOf(trackSelection.trackGroup) + val trackGroupInfo = trackGroupInfos[trackGroupIndex] + val adaptationSetIndices = trackGroupInfo.adaptationSetIndices + val trackIndices = IntArray(trackSelection.length()) + for (i in 0..= totalTracksInPreviousAdaptationSets + tracksInCurrentAdaptationSet) { + currentAdaptationSetIndex++ + totalTracksInPreviousAdaptationSets += tracksInCurrentAdaptationSet + tracksInCurrentAdaptationSet = + manifestAdaptationSets[adaptationSetIndices[currentAdaptationSetIndex]].representations + .size + } + streamKeys.add( + StreamKey( + periodIndex, + adaptationSetIndices[currentAdaptationSetIndex], + trackIndex - totalTracksInPreviousAdaptationSets + ) + ) + } + } + return streamKeys + } + + override fun selectTracks( + selections: Array, + mayRetainStreamFlags: BooleanArray, + streams: Array, + streamResetFlags: BooleanArray, + positionUs: Long + ): Long { + val streamIndexToTrackGroupIndex = getStreamIndexToTrackGroupIndex(selections) + releaseDisabledStreams(selections, mayRetainStreamFlags, streams) + selectNewStreams( + selections, streams, streamResetFlags, positionUs, streamIndexToTrackGroupIndex + ) + + // inform the server about when we changed the format + val now = Instant.now().toEpochMilli() + sabrClient.lastManualFormatSelectionMs = now + sabrClient.lastActionMs = now + + val sampleStreamList: MutableList> = mutableListOf(); + for (sampleStream in streams) { + if (sampleStream is ChunkSampleStream<*>) { + val stream = + sampleStream as ChunkSampleStream + sampleStreamList.add(stream) + } + } + sampleStreams = sampleStreamList.toTypedArray() + + compositeSequenceableLoader = + compositeSequenceableLoaderFactory.create( + sampleStreams.toList(), + sampleStreamList.map { immutableListOf(it.primaryTrackType) }) + + if (canReportInitialDiscontinuity) { + canReportInitialDiscontinuity = false + initialStartTimeUs = positionUs + } + return positionUs + } + + override fun discardBuffer(positionUs: Long, toKeyframe: Boolean) { + sampleStreams.map { it.discardBuffer(positionUs, toKeyframe) } + } + + override fun reevaluateBuffer(positionUs: Long) { + sampleStreams.filter { !it.isLoading }.forEach { + val manifestDurationUs = Util.msToUs(manifest.durationMs) + it.discardUpstreamSamplesForClippedDuration(manifestDurationUs) + } + compositeSequenceableLoader.reevaluateBuffer(positionUs) + } + + override fun continueLoading(loadingInfo: LoadingInfo): Boolean = + compositeSequenceableLoader.continueLoading(loadingInfo) + + override fun isLoading(): Boolean = compositeSequenceableLoader.isLoading + + override fun getNextLoadPositionUs(): Long = compositeSequenceableLoader.nextLoadPositionUs + + override fun readDiscontinuity(): Long { + for (sampleStream in sampleStreams) { + if (sampleStream.consumeInitialDiscontinuity()) { + return initialStartTimeUs + } + } + return C.TIME_UNSET + } + + override fun getBufferedPositionUs(): Long = compositeSequenceableLoader.bufferedPositionUs + + override fun seekToUs(positionUs: Long): Long { + sampleStreams.forEach { it.seekToUs(positionUs) } + return positionUs + } + + override fun getAdjustedSeekPositionUs(positionUs: Long, seekParameters: SeekParameters): Long { + for (sampleStream in sampleStreams) { + if (sampleStream.primaryTrackType == C.TRACK_TYPE_VIDEO) { + return sampleStream.getAdjustedSeekPositionUs(positionUs, seekParameters) + } + } + return positionUs + } + + override fun onContinueLoadingRequested(sampleStream: ChunkSampleStream) { + callback!!.onContinueLoadingRequested(this) + } + + private fun getStreamIndexToTrackGroupIndex(selections: Array): IntArray { + val streamIndexToTrackGroupIndex = IntArray(selections.size) + for (i in selections.indices) { + if (selections[i] != null) { + streamIndexToTrackGroupIndex[i] = + trackGroups.indexOf(selections[i]!!.trackGroup) + } else { + streamIndexToTrackGroupIndex[i] = C.INDEX_UNSET + } + } + return streamIndexToTrackGroupIndex + } + + private fun releaseDisabledStreams( + selections: Array, + mayRetainStreamFlags: BooleanArray, + streams: Array + ) { + for (i in selections.indices) { + if (selections[i] == null || !mayRetainStreamFlags[i]) { + if (streams[i] is ChunkSampleStream<*>) { + val stream = streams[i] as ChunkSampleStream + stream.release(null) + } + streams[i] = null + } + } + } + + private fun selectNewStreams( + selections: Array, + streams: Array, + streamResetFlags: BooleanArray, + positionUs: Long, + streamIndexToTrackGroupIndex: IntArray + ) { + // Create newly selected primary and event streams. + for (i in selections.indices) { + val selection = selections[i] ?: continue + val trackGroupIndex = streamIndexToTrackGroupIndex[i] + val trackGroupInfo = trackGroupInfos[trackGroupIndex] + val representation = + manifest.adaptationSets[trackGroupInfo.adaptationSetIndices[0]].representations[selection.getIndexInTrackGroup(0)] + sabrClient.selectFormat(representation!!) + + if (streams[i] == null) { + // Create new stream for selection. + streamResetFlags[i] = true + streams[i] = buildSampleStream(trackGroupInfo, selection, positionUs) + } else if (streams[i] is ChunkSampleStream<*>) { + // Update selection in existing stream. + val stream = streams[i] as ChunkSampleStream + stream.getChunkSource().updateTrackSelection(selection) + } + } + } + + private fun buildSampleStream( + trackGroupInfo: TrackGroupInfo, selection: ExoTrackSelection, positionUs: Long + ): ChunkSampleStream { + val chunkSource = + chunkSourceFactory.createSabrChunkSource( + manifest, + sabrClient, + trackGroupInfo.adaptationSetIndices, + selection, + trackGroupInfo.trackType, + elapsedRealtimeOffsetMs, + transferListener, + playerId, + cmcdConfiguration + ) + val stream = + ChunkSampleStream( + trackGroupInfo.trackType, + null, + null, + chunkSource!!, + this, + allocator, + positionUs, + drmSessionManager, + drmEventDispatcher, + loadErrorHandlingPolicy, + mediaSourceEventDispatcher, + canReportInitialDiscontinuity, + null + ) + return stream + } + + private data class TrackGroupInfo( + val trackType: @TrackType Int, + val adaptationSetIndices: IntArray, + val primaryTrackGroupIndex: Int + ) + + companion object { + private fun buildTrackGroups( + drmSessionManager: DrmSessionManager, + chunkSourceFactory: SabrChunkSource.Factory, + adaptationSets: List + ): Pair> { + val groupedAdaptationSetIndices = getGroupedAdaptationSetIndices(adaptationSets) + + val primaryGroupCount = groupedAdaptationSetIndices.size + val trackGroups = arrayOfNulls(primaryGroupCount) + val trackGroupInfos = arrayOfNulls(primaryGroupCount) + + buildPrimaryTrackGroupInfos( + drmSessionManager, + chunkSourceFactory, + adaptationSets, + groupedAdaptationSetIndices, + primaryGroupCount, + trackGroups, + trackGroupInfos + ) + + return Pair.create( + TrackGroupArray(*trackGroups as Array), + trackGroupInfos + ) + } + + /** + * Groups adaptation sets. Two adaptations sets belong to the same group if either: + * + * + * * One is a trick-play adaptation set and uses a `http://Sabrif.org/guidelines/trickmode` essential or supplemental property to indicate + * that the other is the main adaptation set to which it corresponds. + * * The two adaptation sets are marked as safe for switching using `urn:mpeg:Sabr:adaptation-set-switching:2016` supplemental properties. + * + * + * @param adaptationSets The adaptation sets to merge. + * @return An array of groups, where each group is an array of adaptation set indices. + */ + private fun getGroupedAdaptationSetIndices(adaptationSets: List): Array = + MutableList(adaptationSets.size) { mutableListOf(it) }.map { group -> + group.sorted().toIntArray() + }.toTypedArray() + + private fun buildPrimaryTrackGroupInfos( + drmSessionManager: DrmSessionManager, + chunkSourceFactory: SabrChunkSource.Factory, + adaptationSets: List, + groupedAdaptationSetIndices: Array, + primaryGroupCount: Int, + trackGroups: Array, + trackGroupInfos: Array + ): Int { + var trackGroupCount = 0 + for (i in 0.. = mutableListOf() + for (adaptationSetIndex in adaptationSetIndices) { + representations.addAll(adaptationSets[adaptationSetIndex].representations) + } + val formats = arrayOfNulls(representations.size) + for (j in formats.indices) { + val originalFormat = representations[j]!!.format + val updatedFormat = + originalFormat + .buildUpon() + .setCryptoType(drmSessionManager.getCryptoType(originalFormat)) + formats[j] = updatedFormat.build() + } + + val firstAdaptationSet = adaptationSets[adaptationSetIndices[0]] + val trackGroupId = "unset:$i" + val primaryTrackGroupIndex = trackGroupCount++ + + maybeUpdateFormatsForParsedText(chunkSourceFactory, formats) + trackGroups[primaryTrackGroupIndex] = TrackGroup(trackGroupId, *(formats.filterNotNull().toTypedArray())) + trackGroupInfos[primaryTrackGroupIndex] = + TrackGroupInfo( + firstAdaptationSet.type, + adaptationSetIndices, + primaryTrackGroupIndex + ) + } + return trackGroupCount + } + + /** + * Modifies the provided [Format] array if subtitle/caption parsing is configured to happen + * during extraction. + */ + private fun maybeUpdateFormatsForParsedText( + chunkSourceFactory: SabrChunkSource.Factory, formats: Array + ) { + for (i in formats.indices) { + formats[i] = chunkSourceFactory.getOutputTextFormat(formats[i]!!) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/player/SabrMediaSource.kt b/app/src/main/java/com/github/libretube/player/SabrMediaSource.kt new file mode 100644 index 0000000000..c5604d01f8 --- /dev/null +++ b/app/src/main/java/com/github/libretube/player/SabrMediaSource.kt @@ -0,0 +1,242 @@ +package com.github.libretube.player + +import android.os.Looper +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaItem.LocalConfiguration +import androidx.media3.common.MediaLibraryInfo +import androidx.media3.common.Timeline +import androidx.media3.common.util.Assertions +import androidx.media3.common.util.UnstableApi +import androidx.media3.common.util.Util +import androidx.media3.datasource.TransferListener +import androidx.media3.exoplayer.drm.DefaultDrmSessionManagerProvider +import androidx.media3.exoplayer.drm.DrmSessionManager +import androidx.media3.exoplayer.drm.DrmSessionManagerProvider +import androidx.media3.exoplayer.source.BaseMediaSource +import androidx.media3.exoplayer.source.CompositeSequenceableLoaderFactory +import androidx.media3.exoplayer.source.DefaultCompositeSequenceableLoaderFactory +import androidx.media3.exoplayer.source.MediaPeriod +import androidx.media3.exoplayer.source.MediaSource +import androidx.media3.exoplayer.source.MediaSource.MediaPeriodId +import androidx.media3.exoplayer.upstream.Allocator +import androidx.media3.exoplayer.upstream.CmcdConfiguration +import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy +import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy +import com.github.libretube.player.manifest.SabrManifest +import com.github.libretube.player.parser.SabrClient + +/** A Sabr [MediaSource]. */ +@UnstableApi +class SabrMediaSource( + private var mediaItem: MediaItem, + private val manifest: SabrManifest, + private val sabrClient: SabrClient, + private val chunkSourceFactory: SabrChunkSource.Factory, + private val compositeSequenceableLoaderFactory: CompositeSequenceableLoaderFactory, + private val cmcdConfiguration: CmcdConfiguration?, + private val drmSessionManager: DrmSessionManager, + private val loadErrorHandlingPolicy: LoadErrorHandlingPolicy, +) : BaseMediaSource() { + + init { + MediaLibraryInfo.registerModule("media3.exoplayer.sabr") + } + + /** Factory for [SabrMediaSource]s. */ + class Factory(private val manifest: SabrManifest) : MediaSource.Factory { + private var cmcdConfigurationFactory: CmcdConfiguration.Factory? = null + private var drmSessionManagerProvider: DrmSessionManagerProvider = DefaultDrmSessionManagerProvider() + private val compositeSequenceableLoaderFactory= DefaultCompositeSequenceableLoaderFactory() + private var loadErrorHandlingPolicy: LoadErrorHandlingPolicy = DefaultLoadErrorHandlingPolicy() + + override fun setCmcdConfigurationFactory(cmcdConfigurationFactory: CmcdConfiguration.Factory): Factory = + this.apply { + this.cmcdConfigurationFactory = + Assertions.checkNotNull(cmcdConfigurationFactory) + } + + override fun setDrmSessionManagerProvider( + drmSessionManagerProvider: DrmSessionManagerProvider, + ): Factory = this.apply { this.drmSessionManagerProvider = drmSessionManagerProvider } + + override fun setLoadErrorHandlingPolicy(loadErrorHandlingPolicy: LoadErrorHandlingPolicy): Factory = + this.apply { this.loadErrorHandlingPolicy = loadErrorHandlingPolicy } + + /** + * Returns a new [SabrMediaSource] using the current parameters. + * + * @param mediaItem The media item of the stream. + * @return The new [SabrMediaSource]. + * @throws NullPointerException if [MediaItem.localConfiguration] is `null`. + */ + override fun createMediaSource(mediaItem: MediaItem): SabrMediaSource { + Assertions.checkNotNull(mediaItem.localConfiguration) + val cmcdConfiguration = cmcdConfigurationFactory?.createCmcdConfiguration(mediaItem) + val sabrClient = SabrClient(manifest) + + return SabrMediaSource( + mediaItem, + manifest, + sabrClient, + DefaultSabrChunkSource.Factory(SabrDataSource.Factory(sabrClient)), + compositeSequenceableLoaderFactory, + cmcdConfiguration, + drmSessionManagerProvider.get(mediaItem), + loadErrorHandlingPolicy + ) + } + + override fun getSupportedTypes(): IntArray = intArrayOf(C.CONTENT_TYPE_OTHER) + } + + private var mediaTransferListener: TransferListener? = null + + private var elapsedRealtimeOffsetMs: Long = C.TIME_UNSET + + @Synchronized + override fun getMediaItem(): MediaItem { + return mediaItem + } + + override fun canUpdateMediaItem(mediaItem: MediaItem): Boolean { + val existingMediaItem = getMediaItem() + val existingConfiguration = + Assertions.checkNotNull(existingMediaItem.localConfiguration) + val newConfiguration = mediaItem.localConfiguration + return newConfiguration != null && newConfiguration.uri == existingConfiguration.uri + && newConfiguration.streamKeys == existingConfiguration.streamKeys + && newConfiguration.drmConfiguration == existingConfiguration.drmConfiguration + && existingMediaItem.liveConfiguration == mediaItem.liveConfiguration + } + + @Synchronized + override fun updateMediaItem(mediaItem: MediaItem) { + this.mediaItem = mediaItem + } + + override fun prepareSourceInternal(mediaTransferListener: TransferListener?) { + this.mediaTransferListener = mediaTransferListener + drmSessionManager.setPlayer(Looper.myLooper()!!, playerId) + drmSessionManager.prepare() + processManifest() + } + + override fun maybeThrowSourceInfoRefreshError() { } + + override fun createPeriod( + id: MediaPeriodId, + allocator: Allocator, + startPositionUs: Long, + ): MediaPeriod { + val periodIndex = id.periodUid as Int + val periodEventDispatcher = createEventDispatcher(id) + val drmEventDispatcher = createDrmEventDispatcher(id) + val mediaPeriod = + SabrMediaPeriod( + manifest, + sabrClient, + periodIndex, + chunkSourceFactory, + mediaTransferListener, + cmcdConfiguration, + drmSessionManager, + drmEventDispatcher, + loadErrorHandlingPolicy, + periodEventDispatcher, + elapsedRealtimeOffsetMs, + allocator, + compositeSequenceableLoaderFactory, + playerId + ) + return mediaPeriod + } + + override fun releasePeriod(mediaPeriod: MediaPeriod) { + val sabrMediaPeriod = mediaPeriod as SabrMediaPeriod + sabrMediaPeriod.release() + } + + override fun releaseSourceInternal() { + elapsedRealtimeOffsetMs = C.TIME_UNSET + drmSessionManager.release() + } + + private fun processManifest() { + val timeline = + SabrTimeline( + C.TIME_UNSET, + C.TIME_UNSET, + elapsedRealtimeOffsetMs, + 0, + Util.msToUs(manifest.durationMs), + 0, + manifest, + mediaItem, + ) + refreshSourceInfo(timeline) + } + + private class SabrTimeline( + private val presentationStartTimeMs: Long, + private val windowStartTimeMs: Long, + private val elapsedRealtimeEpochOffsetMs: Long, + private val offsetInFirstPeriodUs: Long, + private val windowDurationUs: Long, + private val windowDefaultStartPositionUs: Long, + private val manifest: SabrManifest, + private val mediaItem: MediaItem?, + ) : Timeline() { + override fun getPeriodCount(): Int = 1 + + override fun getPeriod(periodIndex: Int, period: Period, setIds: Boolean): Period { + Assertions.checkIndex(periodIndex, 0, periodCount) + val uid: Any? = if (setIds) (0 + periodIndex) else null + return period.set( + null, + uid, + 0, + Util.msToUs(manifest.durationMs), + Util.msToUs(0) - offsetInFirstPeriodUs + ) + } + + override fun getWindowCount(): Int = 1 + + override fun getWindow( + windowIndex: Int, + window: Window, + defaultPositionProjectionUs: Long, + ): Window { + Assertions.checkIndex(windowIndex, 0, 1) + val windowDefaultStartPositionUs = getAdjustedWindowDefaultStartPositionUs() + return window.set( + Window.SINGLE_WINDOW_UID, + mediaItem, + manifest, + presentationStartTimeMs, + windowStartTimeMs, + elapsedRealtimeEpochOffsetMs, + true, + false, + null, + windowDefaultStartPositionUs, + windowDurationUs, + 0, + periodCount - 1, + offsetInFirstPeriodUs + ) + } + + override fun getIndexOfPeriod(uid: Any): Int = + if (uid !is Int || uid < 0 || uid >= periodCount) C.INDEX_UNSET else uid + + fun getAdjustedWindowDefaultStartPositionUs(): Long = + this.windowDefaultStartPositionUs + + override fun getUidOfPeriod(periodIndex: Int): Any { + Assertions.checkIndex(periodIndex, 0, periodCount) + return 0 + periodIndex + } + } +} diff --git a/app/src/main/java/com/github/libretube/player/manifest/AdaptationSet.kt b/app/src/main/java/com/github/libretube/player/manifest/AdaptationSet.kt new file mode 100644 index 0000000000..eec70b55ca --- /dev/null +++ b/app/src/main/java/com/github/libretube/player/manifest/AdaptationSet.kt @@ -0,0 +1,13 @@ +package com.github.libretube.player.manifest + +import androidx.media3.common.C.TrackType +import androidx.media3.common.util.UnstableApi + +/** Represents a set of interchangeable encoded versions of a media content component. */ +@UnstableApi +data class AdaptationSet( + /** The [track type][androidx.media3.common.C.TrackType] of the adaptation set. */ + val type: @TrackType Int, + /** [Representation]s in the adaptation set. */ + val representations: List +) \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/player/manifest/Representation.kt b/app/src/main/java/com/github/libretube/player/manifest/Representation.kt new file mode 100644 index 0000000000..6fdbdc46a3 --- /dev/null +++ b/app/src/main/java/com/github/libretube/player/manifest/Representation.kt @@ -0,0 +1,21 @@ +package com.github.libretube.player.manifest + +import androidx.media3.common.Format +import androidx.media3.common.util.UnstableApi +import com.github.libretube.api.obj.PipedStream +import misc.Common.FormatId + +/** A Sabr representation. */ +@UnstableApi +data class Representation( + /** The format of the representation. */ + val format: Format, + /** Metadata about the stream. */ + val stream: PipedStream, +) { + fun formatId(): FormatId = FormatId.newBuilder() + .setItag(stream.itag!!) + .setLastModified(stream.lastModified!!) + .setXtags(stream.xtags ?: "") + .build() +} diff --git a/app/src/main/java/com/github/libretube/player/manifest/SabrManifest.kt b/app/src/main/java/com/github/libretube/player/manifest/SabrManifest.kt new file mode 100644 index 0000000000..00f2c531bd --- /dev/null +++ b/app/src/main/java/com/github/libretube/player/manifest/SabrManifest.kt @@ -0,0 +1,107 @@ +package com.github.libretube.player.manifest + +import android.net.Uri +import android.util.Base64 +import androidx.core.net.toUri +import androidx.media3.common.C +import androidx.media3.common.C.ROLE_FLAG_DESCRIBES_VIDEO +import androidx.media3.common.C.ROLE_FLAG_DUB +import androidx.media3.common.C.ROLE_FLAG_MAIN +import androidx.media3.common.C.ROLE_FLAG_SUPPLEMENTARY +import androidx.media3.common.Format +import androidx.media3.common.MimeTypes +import androidx.media3.common.StreamKey +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.offline.FilterableManifest +import com.github.libretube.api.obj.PipedStream +import com.github.libretube.api.obj.Streams + +/** + * Represents server adaptive-bitrate streaming media metadata. + */ +@UnstableApi +class SabrManifest( + /** + * Identifier of the video being streamed. + */ + val videoId: String, + /** + * URL of the streaming server. + */ + val serverAbrStreamingUri: Uri, + /** + * Required config for media playback. + */ + val videoPlaybackUstreamerConfig: ByteArray, + /** + * The duration of the presentation in milliseconds, or [C.TIME_UNSET] if not applicable. + */ + val durationMs: Long, +) : FilterableManifest { + var adaptationSets: List = emptyList() + + internal constructor( + videoId: String, + streams: Streams + ) : this( + videoId, + streams.serverAbrStreamingUrl!!.toUri(), + Base64.decode(streams.videoPlaybackUstreamerConfig!!, Base64.URL_SAFE), + streams.duration * 1000, + ) { + val videoAdaptionSets = streams.videoStreams.groupBy { it.mimeType } + .map { (_, streams) -> + AdaptationSet(C.TRACK_TYPE_VIDEO, streams.map { + buildRepresentation( + it, + Format.Builder() + .setCodecs(it.codec) + .setContainerMimeType(it.mimeType) + .setSampleMimeType(MimeTypes.getVideoMediaMimeType(it.codec)) + .setAverageBitrate(it.bitrate ?: -1) + .setFrameRate(it.fps?.toFloat() ?: -1f) + .setWidth(it.width ?: -1) + .setHeight(it.height ?: -1).build(), + ) + }) + }; + + val audioAdaptationSets = streams.audioStreams.groupBy { it.mimeType + it.audioTrackId } + .map { (_, streams) -> + AdaptationSet(C.TRACK_TYPE_AUDIO, streams.map { + buildRepresentation( + it, + Format.Builder() + .setCodecs(it.codec) + .setContainerMimeType(it.mimeType) + .setSampleMimeType(MimeTypes.getAudioMediaMimeType(it.codec)) + .setAverageBitrate(it.bitrate ?: -1) + .setChannelCount(2) + .setLanguage(it.audioTrackId?.substring(0, 2)?: it.audioTrackLocale) + .setRoleFlags( + when (it.audioTrackType?.lowercase()) { + "descriptive" -> ROLE_FLAG_DESCRIBES_VIDEO + "original" -> ROLE_FLAG_MAIN + "dubbed", "auto-dubbed" -> ROLE_FLAG_DUB + "secondary" -> ROLE_FLAG_SUPPLEMENTARY + else -> 0 + } + ) + .build() + ) + }) + }; + adaptationSets = videoAdaptionSets + audioAdaptationSets + } + + override fun copy(streamKeys: List): SabrManifest { + return this + } + + companion object { + private fun buildRepresentation(stream: PipedStream, format: Format) = Representation( + format, + stream, + ) + } +} diff --git a/app/src/main/java/com/github/libretube/player/parser/SabrClient.kt b/app/src/main/java/com/github/libretube/player/parser/SabrClient.kt new file mode 100644 index 0000000000..e7170aaf30 --- /dev/null +++ b/app/src/main/java/com/github/libretube/player/parser/SabrClient.kt @@ -0,0 +1,606 @@ +package com.github.libretube.player.parser + +import android.util.Log +import androidx.annotation.OptIn +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter +import com.github.libretube.LibreTubeApp +import com.github.libretube.api.poToken.PoTokenGenerator +import com.github.libretube.player.manifest.Representation +import com.github.libretube.player.manifest.SabrManifest +import com.github.libretube.ui.dialogs.ShareDialog +import com.google.protobuf.ByteString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import misc.Common.FormatId +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import video_streaming.BufferedRangeOuterClass.BufferedRange +import video_streaming.ClientAbrStateOuterClass.ClientAbrState +import video_streaming.FormatInitializationMetadataOuterClass.FormatInitializationMetadata +import video_streaming.MediaHeaderOuterClass.MediaHeader +import video_streaming.NextRequestPolicyOuterClass.NextRequestPolicy +import video_streaming.PlaybackCookieOuterClass.PlaybackCookie +import video_streaming.SabrContextSendingPolicyOuterClass.SabrContextSendingPolicy +import video_streaming.SabrContextUpdateOuterClass.SabrContextUpdate +import video_streaming.SabrContextUpdateOuterClass.SabrContextUpdate.SabrContextWritePolicy +import video_streaming.SabrErrorOuterClass.SabrError +import video_streaming.SabrRedirectOuterClass.SabrRedirect +import video_streaming.StreamProtectionStatusOuterClass.StreamProtectionStatus +import video_streaming.StreamerContextOuterClass.StreamerContext +import video_streaming.StreamerContextOuterClass.StreamerContext.SabrContext +import video_streaming.UmpPartId.UMPPartId +import video_streaming.VideoPlaybackAbrRequestOuterClass.VideoPlaybackAbrRequest +import java.time.Instant + +class PlaybackRequest( + /* Format for which new media data is being requested */ + val format: FormatId, + /* Position of the player in milliseconds */ + val playerPosition: Long, + /* Multiplier applied to the speed at which content is played. */ + val playbackSpeed: Float, + /* Sequence number of which segment is loaded */ + val segment: Long, + /* List of segments which are buffered for the format */ + val segmentStartTimeMs: Long, + /* List of segments which are buffered for the format */ + val bufferedSegments: List, +) { + companion object { + fun initRequest( + format: FormatId, playerPosition: Long, playbackSpeed: Float, + ): PlaybackRequest = PlaybackRequest( + format, playerPosition, playbackSpeed, 0, 0, emptyList() + ) + } +} + +/** + * A segment of a media stream. + * + * Contains metadata, such as the position in the stream, its own duration, as well the raw + * media data. + */ +data class Segment( + /** Header of the media segment containing metadata. */ + val header: MediaHeader, + /** Sequence number indicating the position of the segment in the media stream. */ + val sequenceNumber: Long, + /** Raw media data for the segment. */ + val data: MutableList, + /** Duration of the segment in milliseconds. */ + val duration: Long, +) { + /** + * Length of the media data. + */ + fun length(): Int = data.sumOf { it.size } + + /** + * Media data as a single array. + */ + fun data(): ByteArray { + val result = ByteArray(length()) + var offset = 0 + for (chunk in data) { + System.arraycopy(chunk, 0, result, offset, chunk.size) + offset += chunk.size + } + return result + } +} + +/** + * An initialized format within a video stream. + */ +private data class InitializedFormat( + /** Identifier of the format. */ + val id: FormatId, + /** Segments that have been downloaded for this format. */ + val downloadedSegments: MutableMap = mutableMapOf(), + /** Segments that have been downloaded for this format. */ + val bufferedSegments: MutableMap = mutableMapOf(), + /** Sequence number of the last segment in the format. */ + val endSegmentNumber: Long, + /** Initial segment containing metadata about the stream, + * such as the position of the other segments. + **/ + var initSegment: Segment? = null, + /** Duration of the format in milliseconds. */ + val duration: Long, +) { + /** Returns a list of all downloaded segments for the format. */ + fun getSegment(sequenceNumber: Long): Segment? { + val segment = downloadedSegments.remove(sequenceNumber) + ?: initSegment?.takeIf { it.sequenceNumber == sequenceNumber } + ?: return null + // mark retrieved segment as buffered + bufferedSegments[sequenceNumber] = segment + return segment + } + + /** Returns a list of all downloaded segments for the format. */ + fun buildBufferedRanges(): List = + bufferedSegments.entries.union(downloadedSegments.entries).sortedBy { it.key } + .fold(mutableListOf>>()) { acc, (id, segment) -> + val previousId = acc.lastOrNull()?.lastOrNull()?.first + if (previousId?.plus(1) != id) { + //we found a discontinuity, create a new partition + acc.add(mutableListOf()) + } + acc.lastOrNull()!!.add(Pair(id, segment)) + acc + }.map { partition -> + val duration = partition.sumOf { it.second.duration } + val (firstId, firstSegment) = partition.first() + BufferedRange.newBuilder().setFormatId(id).setStartTimeMs(firstSegment.header.startMs) + .setDurationMs(duration).setStartSegmentIndex(firstId.toInt()) + .setEndSegmentIndex(partition.last().first.toInt()).build() + } + + /** + * Whether the format has non-retrieved data. + */ + fun hasSegment(segmentNumber: Long): Boolean = + downloadedSegments.containsKey(segmentNumber) +} + +/** + * A SABR/UMP streaming client. + * + * Handles the fetching and processing of streaming media data using the UMP protocol. + */ +@OptIn(UnstableApi::class) +class SabrClient private constructor( + /** Unique identifier for the SABR stream resource. */ + private val videoId: String, + /** The URL pointing to the SABR/UMP stream. */ + var url: String, + /** UStreamer configuration data. */ + private val ustreamerConfig: ByteString, +) { + + /** Generator to create PoTokens. */ + private var poTokenGenerator = PoTokenGenerator() + /** Po (Proof of Origin) Token. */ + private var poToken: ByteString? = null + + private var fatalError: SabrError? = null + private val dispatcher = Dispatchers.IO.limitedParallelism(1) + + /** Audio format video format */ + private lateinit var audioFormat: Representation + /** Optional video format */ + private var videoFormat: Representation? = null + + constructor(manifest: SabrManifest) : this( + manifest.videoId, + manifest.serverAbrStreamingUri.toString(), + ByteString.copyFrom(manifest.videoPlaybackUstreamerConfig) + ) + + /** + * Initialized formats. + * + * A format is initialized when the stream sends a [UMPPartId.FORMAT_INITIALIZATION_METADATA] part, + * containing the metadata of the format. + * + * Each format is identified by its `itag`. + */ + private val initializedFormats = mutableMapOf() + + /** + * Partial segments that are being processed. + * + * Segments are stored here temporarily until they are fully processed. + */ + private val partialSegments = mutableMapOf() + + /** HTTP Client for requesting UMP data. */ + private val client: OkHttpClient = OkHttpClient.Builder() + .addInterceptor { chain -> + val request = chain.request().newBuilder() + .addHeader("Content-Type", CONTENT_TYPE) + .addHeader("Accept-Encoding", ENCODING) + .addHeader("Accept", ACCEPT) + .addHeader("Origin", ShareDialog.YOUTUBE_FRONTEND_URL) + .addHeader("Referer", "${ShareDialog.YOUTUBE_FRONTEND_URL}/") + .addHeader("User-Agent", USER_AGENT) + .build() + chain.proceed(request) + } + .build() + + /** + * PlaybackCookie + * + * This cookie needs to be passed to subsequent requests. + */ + private var playbackCookie: PlaybackCookie? = null + + /** + * Back off time until the server accepts the next request in milliseconds. + * + * When set, the client should wait the specified amount of time before making another + * request. The server will not send any further data during this period. + */ + private var backoffTime: Int? = null + + /** SABR contexts for the stream. */ + private val sabrContexts = mutableMapOf() + + /** Active SABR contexts that should be sent with requests. */ + private val activeSabrContexts = mutableSetOf() + + /** Timestamp of the last seek */ + var lastSeekMs: Long? = null + + /** Timestamp when the last request was made */ + private var lastRequestMs: Long? = null + + /** + * Timestamp when the user/player last selected a format. + * + * For us, all format selections are manual, as we do not let the server decide the format. + **/ + var lastManualFormatSelectionMs: Long? = null + + /** + * Timestamp when the user last made an action. + * + * This is likely the same as [lastManualFormatSelectionMs] for us, + * as we handle no other actions. + **/ + var lastActionMs: Long? = null + + + private val bandwidthEstimator = DefaultBandwidthMeter.getSingletonInstance(LibreTubeApp.instance) + + @OptIn(UnstableApi::class) + fun selectFormat(representation: Representation) { + if (MimeTypes.isAudio(representation.format.containerMimeType)) { + audioFormat = representation + } else if (MimeTypes.isVideo(representation.format.containerMimeType)) { + videoFormat = representation + } + } + + fun getNextSegment(playbackRequest: PlaybackRequest): Segment? { + if (fatalError != null) { + throw Exception("SABR error: ${fatalError!!.type}") + } + val itag = playbackRequest.format.itag + + // the player should never request data past the end of the stream + assert( + playbackRequest.playerPosition < (initializedFormats[itag]?.duration ?: Long.MAX_VALUE) + ) { "Requested segment for finished format" } + + Log.d( + TAG, + "getNextSegment: loading media data for $itag at position ${playbackRequest.playerPosition}" + ) + + // synchronize buffered segments with the actually buffered segments from the player + initializedFormats[itag]?.bufferedSegments?.keys?.retainAll(playbackRequest.bufferedSegments) + + return runBlocking { + // ensure that the data is only ever accessed by a single thread + withContext(dispatcher) { + var format = initializedFormats[itag] + if (format == null || !format.hasSegment(playbackRequest.segment)) { + // fetch new data + media(playbackRequest) + } + format = format ?: initializedFormats[itag] + return@withContext format?.getSegment(playbackRequest.segment) + } + } + } + + + /** + * Extracts the raw media data from the stream. + * + * The data is returned as a pair of lists: (audio segments, video segments). + */ + private suspend fun media(playbackRequest: PlaybackRequest) { + // update currently held UMP data + val data = fetchStreamData(playbackRequest, audioFormat, videoFormat) + + val parser = UmpParser(data) + while (true) { + val part = parser.readPart() ?: break + processPart(part) + } + assert(parser.data().isEmpty()) { "Parser has left-over data" } + } + + /** + * Fetches streaming data from the URL. + */ + private suspend fun fetchStreamData( + playbackRequest: PlaybackRequest, + audioFormat: Representation, + videoFormat: Representation?, + ): ByteArray { + backoffTime?.let { backoff -> + Log.i(TAG, "fetchStreamData: Waiting for ${backoff}ms before making a request") + delay(backoff.toLong()) + backoffTime = null + } + + if (poToken == null) { + poToken = generatePoToken() + } + + val now = Instant.now().toEpochMilli() + val xtags = Xtags(audioFormat.formatId().xtags) + + val clientState = ClientAbrState.newBuilder() + // we pretend we're slightly in the previous (n-1) segment, so we get n-th segment, instead of the (n+1)-th one + .setPlayerTimeMs(playbackRequest.segmentStartTimeMs.minus(500).coerceAtLeast(0)) + .setEnabledTrackTypesBitfield(if (videoFormat == null) 1 else 0) + .setPlaybackRate(playbackRequest.playbackSpeed) + .setElapsedWallTimeMs(lastRequestMs?.let { now - it } ?: 0 ) + .setTimeSinceLastSeek(lastSeekMs?.let { now - it } ?: 0) + .setTimeSinceLastManualFormatSelectionMs(lastManualFormatSelectionMs?.let { now - it } ?: 0) + .setTimeSinceLastActionMs(lastActionMs?.let { now - it } ?: 0) + .setAudioTrackId(audioFormat.stream.audioTrackId ?: "") + .setDrcEnabled(audioFormat.stream.isDrc ?: false || xtags.isDrcAudio()) + .setEnableVoiceBoost(xtags.isVoiceBoosted()) + .setClientViewportIsFlexible(false) + .setBandwidthEstimate(bandwidthEstimator.bitrateEstimate) + .setVisibility(1) + .build() + + val playbackRequest = VideoPlaybackAbrRequest.newBuilder().setClientAbrState(clientState) + .addAllSelectedFormatIds(initializedFormats.values.map { it.id }.toList()) + .setVideoPlaybackUstreamerConfig(ustreamerConfig) + .addAllPreferredAudioFormatIds(listOf(audioFormat.formatId())) + .addAllPreferredVideoFormatIds(listOfNotNull(videoFormat?.formatId())) + .addAllSelectedFormatIds(initializedFormats.map { it.value.id }.toList()) + .addAllBufferedRanges(initializedFormats.values.flatMap { it.buildBufferedRanges() }) + .setStreamerContext( + StreamerContext.newBuilder() + .setPoToken(poToken ?: ByteString.empty()) + .setClientInfo( + StreamerContext.ClientInfo.newBuilder() + .setClientName(1) + .setClientVersion("2.20250122.04.00") + .setOsName("Windows") + .setOsVersion("10") + .build() + ) + .addAllSabrContexts(activeSabrContexts.mapNotNull { sabrContexts[it] }) + .addAllUnsentSabrContexts( + sabrContexts.keys.filter { it !in activeSabrContexts }) + .setPlaybackCookie(playbackCookie?.toByteString() ?: ByteString.empty()) + .build() + ) + .build() + + // ideally we would use HTTP3 here, like the official, however okhttp does not support it + val request = Request.Builder() + .url(url) + .post( + playbackRequest.toByteArray() + .toRequestBody(CONTENT_TYPE.toMediaType()) + ) + .build() + + lastRequestMs = Instant.now().toEpochMilli() + val response = client.newCall(request).execute() + if (!response.isSuccessful) { + throw Exception("HTTP request failed: ${response.code}") + } + + return response.body?.bytes() ?: throw Exception("Empty response body") + } + + /** + * Parse a UMP Part, handling its contents as appropriate. + * + * @throws Exception if parsing fails or the part is invalid + */ + private fun processPart(part: Part) { + when (part.type) { + UMPPartId.MEDIA_HEADER -> { + val header = MediaHeader.parseFrom(part.data) + val videoId = header.videoId + val headerId = header.headerId + val sequenceNumber = header.sequenceNumber + val duration = if (header.hasDurationMs()) header.durationMs else { + ((header.timeRange.durationTicks.toDouble() / header.timeRange.timescale.toDouble()) * 1000).toLong() + } + + if (videoId != this.videoId) { + Log.e(TAG, "processPart: Received unexpected media header for $videoId") + throw Exception("Header mismatch") + } + + val format = initializedFormats[header.formatId.itag]!! + + if (format.downloadedSegments.containsKey(sequenceNumber)) { + Log.w(TAG, "processPart: Segment $sequenceNumber is already downloaded. Ignoring.") + return + } + + Log.v(TAG, "processPart: Enqueuing partial segment $headerId") + partialSegments[headerId] = Segment( + header = header, + sequenceNumber = sequenceNumber, + data = mutableListOf(), + duration = duration + ) + } + + UMPPartId.MEDIA -> { + val parser = UmpParser(part.data) + val headerId = parser.readVarint()?.toInt()!! + + // repeated segment are skipped, when their header is found and their not added + // to the partial segment queue + val segment = partialSegments[headerId] ?: return + segment.data.add(parser.data()) + } + + UMPPartId.MEDIA_END -> { + val parser = UmpParser(part.data) + val headerId = parser.readVarint()?.toInt()!! + val segment = partialSegments.remove(headerId) ?: return + Log.v(TAG, "processPart: Dequeuing partial segment $headerId") + + val segmentLength = segment.length() + if (segmentLength != segment.header.contentLength.toInt()) { + Log.w( + TAG, + "processPart: Content length mismatch for segment $headerId: expected ${segment.header.contentLength}, got $segmentLength" + ) + throw Exception("Content length mismatch") + } + + val format = initializedFormats[segment.header.itag]!! + format.downloadedSegments[segment.sequenceNumber] = segment + + if (segment.header.isInitSeg) { + format.initSegment = segment + } + } + + UMPPartId.NEXT_REQUEST_POLICY -> { + val policy = NextRequestPolicy.parseFrom(part.data) + backoffTime = policy.backoffTimeMs + playbackCookie = policy.playbackCookie + } + + UMPPartId.FORMAT_INITIALIZATION_METADATA -> { + val metadata = FormatInitializationMetadata.parseFrom(part.data) + + val duration = metadata.endTimeMs + val endSegmentNumber = metadata.endSegmentNumber + val formatId = metadata.formatId + val itag = formatId.itag + + if (initializedFormats.containsKey(itag)) { + Log.w(TAG, "processPart: Skipping already initialized format `$itag`") + return + } + + val format = InitializedFormat( + id = formatId, + endSegmentNumber = endSegmentNumber, + duration = duration + ) + initializedFormats[itag] = format + } + + UMPPartId.SABR_REDIRECT -> { + val redirect = SabrRedirect.parseFrom(part.data) + url = redirect.url + } + + UMPPartId.SABR_CONTEXT_UPDATE -> { + val contextUpdate = SabrContextUpdate.parseFrom(part.data) + + if (contextUpdate.writePolicy == SabrContextWritePolicy.KEEP_EXISTING && + sabrContexts.containsKey(contextUpdate.type)) { + return + } + + if (contextUpdate.sendByDefault) { + activeSabrContexts.add(contextUpdate.type) + } + + sabrContexts[contextUpdate.type] = + SabrContext.newBuilder().setType(contextUpdate.type) + .setValue(contextUpdate.value).build() + } + + UMPPartId.SABR_CONTEXT_SENDING_POLICY -> { + val policy = SabrContextSendingPolicy.parseFrom(part.data) + + policy.startPolicyList.forEach { startPolicy -> + if (!activeSabrContexts.contains(startPolicy)) { + Log.v(TAG, "processPart: Server requested to enable SABR Context Update ($startPolicy)") + activeSabrContexts.add(startPolicy) + } + } + + policy.stopPolicyList.forEach { stopPolicy -> + if (activeSabrContexts.contains(stopPolicy)) { + Log.v(TAG, "processPart: Server requested to disable SABR Context Update ($stopPolicy)") + activeSabrContexts.remove(stopPolicy) + } + } + + policy.discardPolicyList.forEach { discardPolicy -> + if (activeSabrContexts.contains(discardPolicy)) { + Log.v(TAG, "processPart: Server requested to discard SABR Context Update ($discardPolicy)") + sabrContexts.remove(discardPolicy) + } + } + } + + UMPPartId.RELOAD_PLAYER_RESPONSE -> { + // this is called if the streams are expired or a new configuration feature needs to be set + // in either case, we purposefully crash the player here, as the first one is a rare edge-case + // and the second one cannot be handled + throw Exception("Server requested player reload") + } + + UMPPartId.STREAM_PROTECTION_STATUS -> { + val status = StreamProtectionStatus.parseFrom(part.data) + // https://github.com/coletdjnz/yt-dlp-dev/blob/5c0c2963396009a92101bc6e038b61844368409d/yt_dlp/extractor/youtube/_streaming/sabr/part.py + when (status.status) { + 1 -> Log.i(TAG, "processPart: [StreamProtectionStatus] OK") + 2 -> { + Log.i(TAG, "processPart: [StreamProtectionStatus] Attestation pending.") + // try to regenerate the poToken for the next request + poToken = generatePoToken() + } + // we assume that we got a attestation pending warning before and already tried to regenerate the token, + // but it's not accepted, so we bail + 3 -> throw Exception("Attestation required") + else -> Log.e(TAG, "processPart: Unknown StreamProtectionStatus (${status.status})") + } + } + + UMPPartId.SABR_ERROR -> { + val error = SabrError.parseFrom(part.data) + Log.e(TAG, "processPart: Received SABR error: ${error.type} (${error.code})") + fatalError = error + throw Exception("SABR error: ${error.type}") + } + + else -> { + Log.w(TAG, "processPart: Unhandled UMP part ${part.type}") + } + } + } + + /** + * Generates new poToken using the set generator. + * + * NOTE: This should use the same client used for the requests made for the server + */ + fun generatePoToken() : ByteString? { + val poTokenResult = poTokenGenerator.getWebClientPoToken(videoId) ?: return null + return ByteString.copyFrom(poTokenResult.streamingDataPoToken!!.toByteArray()) + } + + companion object { + private const val TAG = "SabrStream" + private const val CONTENT_TYPE = "application/x-protobuf" + private const val ENCODING = "identity" + private const val ACCEPT = "application/vnd.yt-ump" + private const val USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/player/parser/UmpParser.kt b/app/src/main/java/com/github/libretube/player/parser/UmpParser.kt new file mode 100644 index 0000000000..495d746ad2 --- /dev/null +++ b/app/src/main/java/com/github/libretube/player/parser/UmpParser.kt @@ -0,0 +1,153 @@ +package com.github.libretube.player.parser + +import video_streaming.UmpPartId.UMPPartId + +/** + * A parser to read UMP data. + * + * Example: + * ``` + * val bytes = byteArrayOf(20, 1, 42) + * val parser = Parser(bytes) + * val part = parser.readPart() + * assert(part.type == UMPPartId.MEDIA_HEADER) + * assert(part.data == byteArrayOf(42)) + * ``` + */ +class UmpParser(private var buf: ByteArray) { + private var position = 0 + + /** + * Reads a single byte from the buffer. + * + * Example: + * ``` + * val bytes = byteArrayOf(20, 1, 42) + * val parser = Parser(bytes) + * val byte = parser.readByte() // returns 20 + * ``` + */ + private fun readByte(): UByte? { + if (position >= buf.size) return null + return buf[position++].toUByte() + } + + /** + * Reads `n` bytes from the buffer. + * + * Example: + * ``` + * val bytes = byteArrayOf(20, 1, 42) + * val parser = Parser(bytes) + * val data = parser.readBytes(2) // returns byteArrayOf(20, 1) + * ``` + */ + private fun readBytes(n: Int): ByteArray? { + if (position + n > buf.size) return null + val result = buf.copyOfRange(position, position + n) + position += n + return result + } + + /** + * Read a variable sized integer from the buffer. + * + * The implementation follows https://github.com/gsuberland/UMP_Format/blob/main/UMP_Format.md#variable-sized-integers + * + * Example: + * ``` + * val bytes = byteArrayOf(0x80.toByte(), 0x01) + * val parser = Parser(bytes) + * val value = parser.readVarint() // returns 64 + * ``` + */ + fun readVarint(): UInt? { + val prefix = readByte() ?: return null + + // decode the size from the first 5 bits + // [0...4] bits corresponds to a size of 1...5 bytes + //val varintSize = minOf(prefix.countLeadingZeroBits(), 4) + 1 + val varintSize = minOf(prefix.inv().countLeadingZeroBits(), 4) + 1 + + + var shift = 0 + var result = 0u + + if (varintSize != 5) { + shift = 8 - varintSize + // compute mask of prefix + val mask = (1u shl shift) - 1u + result = result or (prefix.toUInt() and mask) + } + + for (i in 1 until varintSize) { + val byte = readByte()?.toUInt() ?: return null + result = result or (byte shl shift) + shift += 8 + } + + return result + } + + /** + * Returns the remaining data of the buffer. + * + * Example: + * ``` + * val bytes = byteArrayOf(0x80.toByte(), 0x01) + * val parser = Parser(bytes) + * parser.readBytes(1) + * val remaining = parser.data() // returns byteArrayOf(0x01) + * ``` + */ + fun data(): ByteArray { + return buf.copyOfRange(position, buf.size) + } + + /** + * Read a single [Part]. + * + * Each part consist of a type (identified via a [UMPPartId]) and some data. + * + * https://github.com/davidzeng0/innertube/blob/main/googlevideo/ump.md#high-level-structure + * + * Example: + * ``` + * val bytes = byteArrayOf(20, 1, 42) + * val parser = Parser(bytes) + * val part = parser.readPart() + * // part.type == UMPPartId.MEDIA_HEADER + * // part.data == byteArrayOf(42) + * ``` + */ + fun readPart(): Part? { + val ty = readVarint() ?: return null + val umpType = UMPPartId.forNumber(ty.toInt()) ?: UMPPartId.UNKNOWN + + val size = readVarint() ?: return null + val data = readBytes(size.toInt()) ?: return null + + return Part(umpType, data) + } +} + +/** + * A single segment (part) of a UMP stream. + * + * Each part has an identifying type and may have associated data. + */ +data class Part( + /** + * Type of the part. + * + * Set to [UMPPartId.UNKNOWN] if the type could not be identified. + */ + val type: UMPPartId, + + /** + * Associated data of the part. + * + * May be empty. + */ + val data: ByteArray +) \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/player/parser/Xtags.kt b/app/src/main/java/com/github/libretube/player/parser/Xtags.kt new file mode 100644 index 0000000000..da89ecd79b --- /dev/null +++ b/app/src/main/java/com/github/libretube/player/parser/Xtags.kt @@ -0,0 +1,28 @@ +package com.github.libretube.player.parser + +import android.util.Base64 +import misc.Common.XTags + +/** + * Extra tags about a format. + */ +class Xtags { + private var enabledFeatures: List = emptyList() + + constructor(xtags: String) { + val xtags = XTags.parseFrom(Base64.decode(xtags, Base64.URL_SAFE)) + xtags?.xtagsList?.filter { it.value == "1" }?.map { it.key }?.let { + enabledFeatures = it + } + } + + /** + * Whether the format uses [dynamic range compression](https://en.wikipedia.org/wiki/Dynamic_range_compression). + */ + fun isDrcAudio() = enabledFeatures.contains("drc") + + /** + * Whether the audio/voices are artificially boosted. + */ + fun isVoiceBoosted() = enabledFeatures.contains("vb") +} \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/services/OnlinePlayerService.kt b/app/src/main/java/com/github/libretube/services/OnlinePlayerService.kt index c7472971b5..4b11ca57ba 100644 --- a/app/src/main/java/com/github/libretube/services/OnlinePlayerService.kt +++ b/app/src/main/java/com/github/libretube/services/OnlinePlayerService.kt @@ -12,6 +12,7 @@ import androidx.media3.common.MimeTypes import androidx.media3.common.Player import androidx.media3.datasource.DefaultDataSource import androidx.media3.exoplayer.hls.HlsMediaSource +import com.github.libretube.BuildConfig import com.github.libretube.R import com.github.libretube.api.JsonHelper import com.github.libretube.api.MediaServiceRepository @@ -34,6 +35,8 @@ import com.github.libretube.helpers.PlayerHelper.getSubtitleRoleFlags import com.github.libretube.helpers.ProxyHelper import com.github.libretube.parcelable.PlayerData import com.github.libretube.util.DeArrowUtil +import com.github.libretube.player.SabrMediaSource +import com.github.libretube.player.manifest.SabrManifest import com.github.libretube.util.PlayingQueue import com.github.libretube.util.YoutubeHlsPlaylistParser import kotlinx.coroutines.CoroutineScope @@ -279,6 +282,23 @@ open class OnlinePlayerService : AbstractPlayerService() { val streams = streams ?: return when { + // SABR + // only enable when in DEBUG, as the implementation is still experimental + // skip SABR for livestreams, as the player impl has no support for it + BuildConfig.DEBUG && !streams.isLive && streams.serverAbrStreamingUrl != null && streams.videoPlaybackUstreamerConfig != null -> { + val sabrMediaSourceFactory = SabrMediaSource.Factory( + SabrManifest(videoId, streams) + ) + val mediaItem = createMediaItem( + streams.serverAbrStreamingUrl.toUri(), + "application/vnd.yt-ump", + streams + ) + val mediaSource = sabrMediaSourceFactory.createMediaSource(mediaItem) + + exoPlayer?.setMediaSource(mediaSource) + return + } // DASH streams.videoStreams.isNotEmpty() -> { // only use the dash manifest generated by YT if either it's a livestream or no other source is available diff --git a/app/src/main/proto/misc/common.proto b/app/src/main/proto/misc/common.proto new file mode 100644 index 0000000000..874cc3ac50 --- /dev/null +++ b/app/src/main/proto/misc/common.proto @@ -0,0 +1,206 @@ +syntax = "proto2"; +package misc; + +message HttpHeader { + optional string name = 1; + optional string value = 2; +} + +message FormatId { + optional int32 itag = 1; + optional uint64 last_modified = 2; + optional string xtags = 3; +} + +message Range { + optional int32 legacy_start = 1; + optional int32 legacy_end = 2; + optional int32 start = 3; + optional int32 end = 4; +} + +enum CompressionType { + UNKNOWN = 0; + GZIP = 1; + BROTLI = 2; +} + +message IdentifierToken { + optional int32 request_number = 1; + optional int32 field5 = 5; +} + +message KeyValuePair { + optional string key = 1; + optional string value = 2; +} + +enum AudioQuality { + AUDIO_QUALITY_UNKNOWN = 0; + AUDIO_QUALITY_ULTRALOW = 5; + AUDIO_QUALITY_LOW = 10; + AUDIO_QUALITY_MEDIUM = 20; + AUDIO_QUALITY_HIGH = 30; +} + +enum VideoQualitySetting { + VIDEO_QUALITY_SETTING_UNKNOWN = 0; + VIDEO_QUALITY_SETTING_HIGHER_QUALITY = 1; + VIDEO_QUALITY_SETTING_DATA_SAVER = 2; + VIDEO_QUALITY_SETTING_ADVANCED_MENU = 3; +} + +enum PlaybackAudioRouteOutputType { + PLAYBACK_AUDIO_ROUTE_OUTPUT_TYPE_UNKNOWN = 0; + PLAYBACK_AUDIO_ROUTE_OUTPUT_TYPE_LINE_OUT = 1; + PLAYBACK_AUDIO_ROUTE_OUTPUT_TYPE_HEADPHONES = 2; + PLAYBACK_AUDIO_ROUTE_OUTPUT_TYPE_BLUETOOTH_A2DP = 3; + PLAYBACK_AUDIO_ROUTE_OUTPUT_TYPE_BUILT_IN_RECEIVER = 4; + PLAYBACK_AUDIO_ROUTE_OUTPUT_TYPE_BUILT_IN_SPEAKER = 5; + PLAYBACK_AUDIO_ROUTE_OUTPUT_TYPE_HDMI = 6; + PLAYBACK_AUDIO_ROUTE_OUTPUT_TYPE_AIR_PLAY = 7; + PLAYBACK_AUDIO_ROUTE_OUTPUT_TYPE_BLUETOOTH_LE = 8; + PLAYBACK_AUDIO_ROUTE_OUTPUT_TYPE_BLUETOOTH_HFP = 9; + PLAYBACK_AUDIO_ROUTE_OUTPUT_TYPE_USB_AUDIO = 10; + PLAYBACK_AUDIO_ROUTE_OUTPUT_TYPE_CAR_PLAY = 11; + PLAYBACK_AUDIO_ROUTE_OUTPUT_TYPE_ANDROID_AUDIO = 12; +} + +enum NetworkMeteredState { + NETWORK_METERED_STATE_UNKNOWN = 0; + NETWORK_METERED_STATE_UNMETERED = 1; + NETWORK_METERED_STATE_METERED = 2; +} + +enum SeekSource { + SEEK_SOURCE_UNKNOWN = 0; + SEEK_SOURCE_TIMESTAMP_IN_COMMENTS = 1; + SEEK_SOURCE_TIMESTAMP_IN_DESCRIPTION = 2; + SEEK_SOURCE_MACRO_MARKER_LIST_ITEM = 3; + SEEK_SOURCE_DOUBLE_TAP_TO_SEEK = 4; + SEEK_SOURCE_DOUBLE_TAP_TO_SKIP_CHAPTER = 5; + SEEK_SOURCE_PICK_UP_PLAY_HEAD = 6; + SEEK_SOURCE_SLIDE_ON_SCRUBBER_BAR = 7; + SEEK_SOURCE_SLIDE_ON_PLAYER = 8; + SEEK_SOURCE_SABR_PARTIAL_CHUNK = 9; + SEEK_SOURCE_SABR_SEEK_TO_HEAD = 10; + SEEK_SOURCE_SABR_LIVE_DVR_USER_SEEK = 11; + SEEK_SOURCE_SABR_SEEK_TO_DVR_LOWER_BOUND = 12; + SEEK_SOURCE_SABR_SEEK_TO_DVR_UPPER_BOUND = 13; + SEEK_SOURCE_SSDAI_INTERNAL = 14; + SEEK_SOURCE_START_PLAYBACK = 15; + SEEK_SOURCE_SABR_ACCURATE_SEEK = 17; + SEEK_SOURCE_START_PLAYBACK_SEEK_TO_END = 18; + SEEK_SOURCE_IOS_PLAYER_REMOVED_SEGMENTS = 19; + SEEK_SOURCE_IOS_PLAYER_SEGMENT_LIST = 20; + SEEK_SOURCE_IOS_PLAYER_ITEM_SEEK = 21; + SEEK_SOURCE_IOS_PLAYER_ITEM_SEEK_TO_END = 22; + SEEK_SOURCE_IOS_PLAYER_SEEK_TO_END_TO_RESYNC = 23; + SEEK_SOURCE_IOS_SEEK_ACCESSIBILITY_BUTTON = 24; + SEEK_SOURCE_FINE_SCRUBBER_SLIDE_ON_FILMSTRIP = 25; + SEEK_SOURCE_FINE_SCRUBBER_TAP_ON_FILMSTRIP = 26; + SEEK_SOURCE_FINE_SCRUBBER_SLIDE_ON_SCRUBBER_BAR = 27; + SEEK_SOURCE_SEEK_BUTTON_ON_PLAYER_CONTROL = 28; + SEEK_SOURCE_SABR_INGESTION_WALL_TIME_SEEK = 29; + SEEK_SOURCE_PLAYER_VIEW_REPARENT_INTERNAL = 30; + SEEK_SOURCE_PRESS_REWIND_PLAY_BACK_CONTROL = 31; + SEEK_SOURCE_PRESS_FAST_FORWARD_PLAY_BACK_CONTROL = 32; + SEEK_SOURCE_PRESS_LIVE_SYNC_ICON = 33; + SEEK_SOURCE_PEG_TO_LIVE = 34; + SEEK_SOURCE_ANDROID_MEDIA_SESSION = 35; + SEEK_SOURCE_TAP_ON_REPLAY_ACTION = 36; + SEEK_SOURCE_AUTOMATIC_REPLAY_ACTION = 37; + SEEK_SOURCE_NON_USER_SEEK_TO_PREVIOUS = 38; + SEEK_SOURCE_NON_USER_SEEK_TO_NEXT = 39; + SEEK_SOURCE_HIGHLIGHTS_TAP_PREVIOUS_PLAY = 66; + SEEK_SOURCE_HIGHLIGHTS_TAP_NEXT_PLAY = 40; + SEEK_SOURCE_HIGHLIGHTS_TAP_HIDDEN_NEXT_PLAY = 41; + SEEK_SOURCE_HIGHLIGHTS_TAP_LIST_ITEM = 42; + SEEK_SOURCE_HIGHLIGHTS_AUTOMATIC_NEXT_PLAY = 43; + SEEK_SOURCE_HIGHLIGHTS_SEEK_TO_FIRST_PLAY = 44; + SEEK_SOURCE_HIGHLIGHTS_SEEK_TO_END = 45; + SEEK_SOURCE_SEGMENTS_TAP_LIST_ITEM = 46; + SEEK_SOURCE_PIP_FAST_FORWARD_BUTTON = 47; + SEEK_SOURCE_PIP_REWIND_BUTTON = 48; + SEEK_SOURCE_PIP_RESUME_ON_HEAD = 49; + SEEK_SOURCE_MOVING_CLIP_FRAME = 50; + SEEK_SOURCE_RESUME_CLIP_PREVIOUS_POSITION = 51; + SEEK_SOURCE_SEEK_TO_NEXT_CHAPTER = 52; + SEEK_SOURCE_SEEK_TO_PREVIOUS_CHAPTER = 53; + SEEK_SOURCE_IOS_SHAREPLAY_PAUSE = 54; + SEEK_SOURCE_IOS_SHAREPLAY_SEEK = 55; + SEEK_SOURCE_IOS_SHAREPLAY_SYNC_RESPONSE = 56; + SEEK_SOURCE_SEEK_TO_HEAD_IMMERSIVE_LIVE_VIDEO = 57; + SEEK_SOURCE_SEEK_TO_START_OF_LOOPING_RANGE_OF_SHORTS = 58; + SEEK_SOURCE_SABR_SEEK_TO_CLOSEST_KEYFRAME = 59; + SEEK_SOURCE_SEEK_TO_END_OF_LOOPING_RANGE_OF_SHORTS = 60; + SEEK_SOURCE_CLIP_SLIDE_ON_FLIMSTRIP = 61; + SEEK_SOURCE_PICK_UP_CLIP_SLIDER = 62; + SEEK_SOURCE_FINE_SCRUBBER_CANCELLED = 63; + SEEK_SOURCE_INLINE_PLAYER_SEEK_CHAPTER = 64; + SEEK_SOURCE_INLINE_PLAYER_SEEK_SECONDS = 65; + SEEK_SOURCE_HIGHLIGHTS_PLAYER_EXIT_FULLSCREEN = 67; + SEEK_SOURCE_LARGE_CONTROLS_FORWARD_BUTTON = 68; + SEEK_SOURCE_LARGE_CONTROLS_REWIND_BUTTON = 69; + SEEK_SOURCE_LARGE_CONTROLS_SCRUBBER_BAR = 70; + SEEK_SOURCE_SEEK_BACKWARD_5S = 71; + SEEK_SOURCE_SEEK_FORWARD_5S = 72; + SEEK_SOURCE_SEEK_BACKWARD_10S = 73; + SEEK_SOURCE_SEEK_FORWARD_10S = 74; + SEEK_SOURCE_SEEK_FORWARD_60S = 75; + SEEK_SOURCE_SEEK_BACKWARD_60S = 76; + SEEK_SOURCE_SEEK_TO_NEXT_FRAME = 77; + SEEK_SOURCE_SEEK_TO_PREV_FRAME = 78; + SEEK_SOURCE_KEYBOARD_SEEK_TO_BEGINNING = 79; + SEEK_SOURCE_KEYBOARD_SEEK_TO_END = 80; + SEEK_SOURCE_SEEK_PERCENT_OF_VIDEO = 81; + SEEK_SOURCE_HIDDEN_FAST_FORWARD_BUTTON = 82; + SEEK_SOURCE_HIDDEN_REWIND_BUTTON = 83; + SEEK_SOURCE_TIMESTAMP = 84; + SEEK_SOURCE_LR_MEDIA_SESSION_SEEK = 87; + SEEK_SOURCE_MIDROLLS_WITH_TIME_RANGE = 88; + SEEK_SOURCE_SKIP_AD = 89; + SEEK_SOURCE_SEEK_TO_PREVIOUS = 90; + SEEK_SOURCE_SEEK_TO_NEXT = 91; + SEEK_SOURCE_LR_QUICK_SEEK = 92; + SEEK_SOURCE_ONESIE_LIVE = 93; + SEEK_SOURCE_LR_PLAYER_CONTROL_ACTION = 94; + SEEK_SOURCE_UNPLUGGED_LENS_START_CLIP = 95; + SEEK_SOURCE_LR_KEY_PLAYS = 96; + SEEK_SOURCE_SSAP_AD_FMT_FATAL = 97; + SEEK_SOURCE_TVHTML5_INPUT_SOURCE_KEY_EVENT = 98; + SEEK_SOURCE_TVHTML5_INPUT_SOURCE_CONTROLS = 99; + SEEK_SOURCE_TVHTML5_INPUT_SOURCE_TOUCH = 100; + SEEK_SOURCE_TVHTML5_INPUT_SOURCE_TOUCHPAD = 101; + SEEK_SOURCE_SEEK_TO_HEAD = 102; + SEEK_SOURCE_AUTOMATIC_PREVIEW_REPLAY_ACTION = 103; + SEEK_SOURCE_H5_MEDIA_ELEMENT_EVENT = 104; + SEEK_SOURCE_H5_WORKAROUND_SEEK = 105; + SEEK_SOURCE_MINIPLAYER_REWIND_BUTTON = 106; + SEEK_SOURCE_MINIPLAYER_FAST_FORWARD_BUTTON = 107; + SEEK_SOURCE_SABR_RELOAD_PLAYER_RESPONSE_TOKEN_SEEK = 108; + SEEK_SOURCE_SLIDE_ON_SCRUBBER_BAR_CHAPTER = 109; + SEEK_SOURCE_ANDROID_CLEAR_BUFFER = 110; +} + +enum OnesieRequestTarget { + ONESIE_REQUEST_TARGET_UNKNOWN = 0; + ONESIE_REQUEST_TARGET_ENCRYPTED_PLAYER_SERVICE = 1; + ONESIE_REQUEST_TARGET_ENCRYPTED_WATCH_SERVICE_DEPRECATED = 2; + ONESIE_REQUEST_TARGET_ENCRYPTED_WATCH_SERVICE = 3; + ONESIE_REQUEST_TARGET_INNERTUBE_ENCRYPTED_SERVICE = 4; +} + +message AuthorizedFormat { + optional int32 track_type = 1; + optional bool is_hdr = 2; +} + +message PlaybackAuthorization { + repeated AuthorizedFormat authorized_formats = 1; + optional bytes sabr_license_constraint = 2; +} + +message XTags { + repeated KeyValuePair xtags = 1; +} diff --git a/app/src/main/proto/video_streaming/buffered_range.proto b/app/src/main/proto/video_streaming/buffered_range.proto new file mode 100644 index 0000000000..6303da360f --- /dev/null +++ b/app/src/main/proto/video_streaming/buffered_range.proto @@ -0,0 +1,31 @@ +syntax = "proto2"; +package video_streaming; + +import "misc/common.proto"; +import "video_streaming/time_range.proto"; + +message BufferedRange { + message UnknownMessage1 { + message UnknownInnerMessage { + optional string video_id = 1; + optional uint64 lmt = 2; + } + repeated UnknownInnerMessage field1 = 1; + } + + message UnknownMessage2 { + optional int32 field1 = 1; + optional int32 field2 = 2; + optional int32 field3 = 3; + } + + required .misc.FormatId format_id = 1; + required int64 start_time_ms = 2; + required int64 duration_ms = 3; + required int32 start_segment_index = 4; + required int32 end_segment_index = 5; + optional TimeRange time_range = 6; + optional UnknownMessage1 field9 = 9; + optional UnknownMessage2 field11 = 11; + optional UnknownMessage2 field12 = 12; +} \ No newline at end of file diff --git a/app/src/main/proto/video_streaming/client_abr_state.proto b/app/src/main/proto/video_streaming/client_abr_state.proto new file mode 100644 index 0000000000..bd22dcd290 --- /dev/null +++ b/app/src/main/proto/video_streaming/client_abr_state.proto @@ -0,0 +1,54 @@ +syntax = "proto2"; +package video_streaming; + +import "misc/common.proto"; +import "video_streaming/media_capabilities.proto"; + +message ClientAbrState { + optional int64 time_since_last_manual_format_selection_ms = 13; + optional sint32 last_manual_direction = 14; + optional int32 last_manual_selected_resolution = 16; + optional int32 detailed_network_type = 17; + optional int32 client_viewport_width = 18; + optional int32 client_viewport_height = 19; + optional int64 client_bitrate_cap_bytes_per_sec = 20; + optional int32 sticky_resolution = 21; + optional bool client_viewport_is_flexible = 22; + optional int64 bandwidth_estimate = 23; + optional .misc.AudioQuality min_audio_quality = 24; + optional .misc.AudioQuality max_audio_quality = 25; + optional .misc.VideoQualitySetting video_quality_setting = 26; + optional .misc.PlaybackAudioRouteOutputType audio_route = 27; + optional int64 player_time_ms = 28; + optional int64 time_since_last_seek = 29; + optional bool data_saver_mode = 30; + optional .misc.NetworkMeteredState network_metered_state = 32; + optional int32 visibility = 34; + optional float playback_rate = 35; + optional int64 elapsed_wall_time_ms = 36; + optional MediaCapabilities media_capabilities = 38; + optional int64 time_since_last_action_ms = 39; + optional int32 enabled_track_types_bitfield = 40; + optional int32 max_pacing_rate = 43; + optional int64 player_state = 44; + optional bool drc_enabled = 46; + optional int32 field48 = 48; + optional int32 field50 = 50; + optional int32 field51 = 51; + optional int32 sabr_report_request_cancellation_info = 54; + optional bool disable_streaming_xhr = 56; + optional int64 field57 = 57; + optional bool prefer_vp9 = 58; + optional int32 av1_quality_threshold = 59; // 2160 + optional int32 field60 = 60; + optional bool is_prefetch = 61; + optional bool sabr_support_quality_constraints = 62; + optional bytes sabr_license_constraint = 63; + optional int32 allow_proxima_live_latency = 64; + optional int32 sabr_force_proxima = 66; + optional int32 field67 = 67; + optional int64 sabr_force_max_network_interruption_duration_ms = 68; + optional string audio_track_id = 69; + optional bool enable_voice_boost = 76; + optional .misc.PlaybackAuthorization playback_authorization = 79; +} diff --git a/app/src/main/proto/video_streaming/crypto_params.proto b/app/src/main/proto/video_streaming/crypto_params.proto new file mode 100644 index 0000000000..0851b1099c --- /dev/null +++ b/app/src/main/proto/video_streaming/crypto_params.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +package video_streaming; + +import "misc/common.proto"; + +message CryptoParams { + optional bytes hmac = 4; + optional bytes iv = 5; + optional .misc.CompressionType compression_type = 6; +} diff --git a/app/src/main/proto/video_streaming/format_initialization_metadata.proto b/app/src/main/proto/video_streaming/format_initialization_metadata.proto new file mode 100644 index 0000000000..ff5d4876f3 --- /dev/null +++ b/app/src/main/proto/video_streaming/format_initialization_metadata.proto @@ -0,0 +1,17 @@ +syntax = "proto2"; +package video_streaming; + +import "misc/common.proto"; + +message FormatInitializationMetadata { + optional string video_id = 1; + optional .misc.FormatId format_id = 2; + optional int64 end_time_ms = 3; + optional int64 end_segment_number = 4; + optional string mime_type = 5; + optional .misc.Range init_range = 6; + optional .misc.Range index_range = 7; + optional int64 field8 = 8; + optional int64 duration_units = 9; + optional int64 duration_timescale = 10; +} \ No newline at end of file diff --git a/app/src/main/proto/video_streaming/format_selection_config.proto b/app/src/main/proto/video_streaming/format_selection_config.proto new file mode 100644 index 0000000000..9f14eefc42 --- /dev/null +++ b/app/src/main/proto/video_streaming/format_selection_config.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; + +package video_streaming; + +message FormatSelectionConfig { + repeated int32 itags = 2; + optional string video_id = 3; + optional int32 resolution = 4; +} \ No newline at end of file diff --git a/app/src/main/proto/video_streaming/innertube_request.proto b/app/src/main/proto/video_streaming/innertube_request.proto new file mode 100644 index 0000000000..a1528459b0 --- /dev/null +++ b/app/src/main/proto/video_streaming/innertube_request.proto @@ -0,0 +1,21 @@ +syntax = "proto2"; +package video_streaming; + +message InnertubeRequest { + optional bytes context = 1; + optional bytes encrypted_onesie_innertube_request = 2; + optional bytes encrypted_client_key = 5; + optional bytes iv = 6; + optional bytes hmac = 7; + optional string reverse_proxy_config = 9; + optional bool serialize_response_as_json = 10; + optional bool enable_ad_placements_preroll = 13; + optional bool enable_compression = 14; + optional UstreamerFlags ustreamer_flags = 15; + optional bytes unencrypted_onesie_innertube_request = 16; + optional bool use_jsonformatter_to_parse_player_response = 17; +} + +message UstreamerFlags { + optional bool send_video_playback_config = 2; +} \ No newline at end of file diff --git a/app/src/main/proto/video_streaming/live_metadata.proto b/app/src/main/proto/video_streaming/live_metadata.proto new file mode 100644 index 0000000000..4360dee0de --- /dev/null +++ b/app/src/main/proto/video_streaming/live_metadata.proto @@ -0,0 +1,16 @@ +syntax = "proto2"; +package video_streaming; + +message LiveMetadata { + optional string broadcast_id = 1; + optional int64 head_sequence_number = 3; + optional int64 head_time_ms = 4; + optional int64 wall_time_ms = 5; + optional string video_id = 6; + optional bool post_live_dvr = 8; + optional int64 headm = 10; + optional int64 min_seekable_time_ticks = 12; + optional int32 min_seekable_timescale = 13; + optional int64 max_seekable_time_ticks = 14; + optional int32 max_seekable_timescale = 15; +} diff --git a/app/src/main/proto/video_streaming/media_capabilities.proto b/app/src/main/proto/video_streaming/media_capabilities.proto new file mode 100644 index 0000000000..80dad8404f --- /dev/null +++ b/app/src/main/proto/video_streaming/media_capabilities.proto @@ -0,0 +1,24 @@ +syntax = "proto2"; +package video_streaming; + +message MediaCapabilities { + repeated VideoFormatCapability video_format_capabilities = 1; + repeated AudioFormatCapability audio_format_capabilities = 2; + optional int32 hdr_mode_bitmask = 5; + + message VideoFormatCapability { + optional int32 video_codec = 1; + optional int32 max_height = 3; + optional int32 max_width = 4; + optional int32 max_framerate = 11; + optional int32 max_bitrate_bps = 12; + optional bool is_10_bit_supported = 15; + } + + message AudioFormatCapability { + optional int32 audio_codec = 1; + optional int32 num_channels = 2; + optional int32 max_bitrate_bps = 3; + optional int32 spatial_capability_bitmask = 6; + } +} diff --git a/app/src/main/proto/video_streaming/media_header.proto b/app/src/main/proto/video_streaming/media_header.proto new file mode 100644 index 0000000000..9f7e77e1d3 --- /dev/null +++ b/app/src/main/proto/video_streaming/media_header.proto @@ -0,0 +1,24 @@ +syntax = "proto2"; +package video_streaming; + +import "misc/common.proto"; +import "video_streaming/time_range.proto"; + +message MediaHeader { + optional uint32 header_id = 1; + optional string video_id = 2; + optional int32 itag = 3; + optional uint64 last_modified = 4; + optional string xtags = 5; + optional int64 start_range = 6; + optional .misc.CompressionType compression_algorithm = 7; + optional bool is_init_seg = 8; + optional int64 sequence_number = 9; + optional int64 bitrate_bps = 10; + optional int64 start_ms = 11; + optional int64 duration_ms = 12; + optional .misc.FormatId format_id = 13; + optional int64 content_length = 14; + optional TimeRange time_range = 15; + optional uint64 sequence_lmt = 16; +} diff --git a/app/src/main/proto/video_streaming/next_request_policy.proto b/app/src/main/proto/video_streaming/next_request_policy.proto new file mode 100644 index 0000000000..adbe79258a --- /dev/null +++ b/app/src/main/proto/video_streaming/next_request_policy.proto @@ -0,0 +1,15 @@ +syntax = "proto2"; +package video_streaming; + +import "video_streaming/playback_cookie.proto"; + +message NextRequestPolicy { + optional int32 target_audio_readahead_ms = 1; + optional int32 target_video_readahead_ms = 2; + optional int32 max_time_since_last_request_ms = 3; + optional int32 backoff_time_ms = 4; + optional int32 min_audio_readahead_ms = 5; + optional int32 min_video_readahead_ms = 6; + optional .video_streaming.PlaybackCookie playback_cookie = 7; + optional string video_id = 8; +} diff --git a/app/src/main/proto/video_streaming/playback_cookie.proto b/app/src/main/proto/video_streaming/playback_cookie.proto new file mode 100644 index 0000000000..e900775b34 --- /dev/null +++ b/app/src/main/proto/video_streaming/playback_cookie.proto @@ -0,0 +1,11 @@ +syntax = "proto2"; +package video_streaming; + +import "misc/common.proto"; + +message PlaybackCookie { + optional int32 resolution = 1; // Always 999999 when resolution is set manually, or if the auto selected one is the max available resolution. + optional int32 field2 = 2; + optional .misc.FormatId video_fmt = 7; + optional .misc.FormatId audio_fmt = 8; +} diff --git a/app/src/main/proto/video_streaming/playback_start_policy.proto b/app/src/main/proto/video_streaming/playback_start_policy.proto new file mode 100644 index 0000000000..2b43367751 --- /dev/null +++ b/app/src/main/proto/video_streaming/playback_start_policy.proto @@ -0,0 +1,12 @@ +syntax = "proto2"; +package video_streaming; + +message PlaybackStartPolicy { + message ReadaheadPolicy { + optional int32 min_readahead_ms = 2; + optional int32 min_bandwidth_bytes_per_sec = 1; + } + + optional ReadaheadPolicy start_min_readahead_policy = 1; + optional ReadaheadPolicy resume_min_readahead_policy = 2; +} diff --git a/app/src/main/proto/video_streaming/reload_player_response.proto b/app/src/main/proto/video_streaming/reload_player_response.proto new file mode 100644 index 0000000000..54473e79ca --- /dev/null +++ b/app/src/main/proto/video_streaming/reload_player_response.proto @@ -0,0 +1,11 @@ +syntax = "proto2"; + +package video_streaming; + +message ReloadPlaybackParams { + optional string token = 1; +} + +message ReloadPlaybackContext { + optional ReloadPlaybackParams reload_playback_params = 1; +} \ No newline at end of file diff --git a/app/src/main/proto/video_streaming/request_cancellation_policy.proto b/app/src/main/proto/video_streaming/request_cancellation_policy.proto new file mode 100644 index 0000000000..110c259c25 --- /dev/null +++ b/app/src/main/proto/video_streaming/request_cancellation_policy.proto @@ -0,0 +1,14 @@ +syntax = "proto2"; +package video_streaming; + +message RequestCancellationPolicy { + message Item { + optional int32 fR = 1; + optional int32 NK = 2; + optional int32 minReadaheadMs = 3; + } + + optional int32 N0 = 1; + repeated Item items = 2; + optional int32 jq = 3; +} diff --git a/app/src/main/proto/video_streaming/request_identifier.proto b/app/src/main/proto/video_streaming/request_identifier.proto new file mode 100644 index 0000000000..c5e76fb5f1 --- /dev/null +++ b/app/src/main/proto/video_streaming/request_identifier.proto @@ -0,0 +1,7 @@ +syntax = "proto2"; + +package video_streaming; + +message RequestIdentifier { + optional string token = 1; +} \ No newline at end of file diff --git a/app/src/main/proto/video_streaming/sabr_context_sending_policy.proto b/app/src/main/proto/video_streaming/sabr_context_sending_policy.proto new file mode 100644 index 0000000000..efdac056f6 --- /dev/null +++ b/app/src/main/proto/video_streaming/sabr_context_sending_policy.proto @@ -0,0 +1,8 @@ +syntax = "proto2"; +package video_streaming; + +message SabrContextSendingPolicy { + repeated int32 start_policy = 1; + repeated int32 stop_policy = 2; + repeated int32 discard_policy = 3; +} \ No newline at end of file diff --git a/app/src/main/proto/video_streaming/sabr_context_update.proto b/app/src/main/proto/video_streaming/sabr_context_update.proto new file mode 100644 index 0000000000..d82bcdf39b --- /dev/null +++ b/app/src/main/proto/video_streaming/sabr_context_update.proto @@ -0,0 +1,43 @@ +syntax = "proto2"; +package video_streaming; + +message SabrContextUpdate { + enum SabrContextScope { + UNKNOWN = 0; + PLAYBACK = 1; + REQUEST = 2; + WATCH_ENDPOINT = 3; + CONTENT_ADS = 4; + } + + enum SabrContextWritePolicy { + UNSPECIFIED = 0; + OVERWRITE = 1; + KEEP_EXISTING = 2; + } + + optional int32 type = 1; + optional SabrContextScope scope = 2; + + optional bytes value = 3; + optional bool send_by_default = 4; + optional SabrContextWritePolicy write_policy = 5; +} + +// For debugging +message SabrContextValue { + message ContentInfo { + optional string content_id = 1; // Looks like a content identifier of some sort "mQxOaLekHJ2f-LAPtq3hwQ4" + optional int32 content_type = 2; // Value of 1 observed (unsure what it truly means/is) + } + + message TimingInfo { + optional int64 timestamp_ms = 1; + optional int32 duration_ms = 2; + optional ContentInfo content = 3; + } + + optional TimingInfo timing = 1; + optional bytes signature = 2; + optional int32 field5 = 5; +} diff --git a/app/src/main/proto/video_streaming/sabr_error.proto b/app/src/main/proto/video_streaming/sabr_error.proto new file mode 100644 index 0000000000..f05311bf9d --- /dev/null +++ b/app/src/main/proto/video_streaming/sabr_error.proto @@ -0,0 +1,7 @@ +syntax = "proto2"; +package video_streaming; + +message SabrError { + optional string type = 1; + optional int32 code = 2; +} diff --git a/app/src/main/proto/video_streaming/sabr_redirect.proto b/app/src/main/proto/video_streaming/sabr_redirect.proto new file mode 100644 index 0000000000..e660ad3241 --- /dev/null +++ b/app/src/main/proto/video_streaming/sabr_redirect.proto @@ -0,0 +1,6 @@ +syntax = "proto2"; +package video_streaming; + +message SabrRedirect { + optional string url = 1; +} diff --git a/app/src/main/proto/video_streaming/sabr_seek.proto b/app/src/main/proto/video_streaming/sabr_seek.proto new file mode 100644 index 0000000000..0b7e233a6b --- /dev/null +++ b/app/src/main/proto/video_streaming/sabr_seek.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +package video_streaming; + +import "misc/common.proto"; + +message SabrSeek { + optional int64 seek_media_time = 1; + optional int32 seek_media_timescale = 2; + optional .misc.SeekSource seek_source = 3; +} diff --git a/app/src/main/proto/video_streaming/snackbar_message.proto b/app/src/main/proto/video_streaming/snackbar_message.proto new file mode 100644 index 0000000000..2ba4e020a6 --- /dev/null +++ b/app/src/main/proto/video_streaming/snackbar_message.proto @@ -0,0 +1,6 @@ +syntax = "proto2"; +package video_streaming; + +message SnackbarMessage { + optional int32 id = 1; +} \ No newline at end of file diff --git a/app/src/main/proto/video_streaming/stream_protection_status.proto b/app/src/main/proto/video_streaming/stream_protection_status.proto new file mode 100644 index 0000000000..772806a676 --- /dev/null +++ b/app/src/main/proto/video_streaming/stream_protection_status.proto @@ -0,0 +1,7 @@ +syntax = "proto2"; +package video_streaming; + +message StreamProtectionStatus { + optional int32 status = 1; + optional int32 max_retries = 2; +} diff --git a/app/src/main/proto/video_streaming/streamer_context.proto b/app/src/main/proto/video_streaming/streamer_context.proto new file mode 100644 index 0000000000..08f8460f53 --- /dev/null +++ b/app/src/main/proto/video_streaming/streamer_context.proto @@ -0,0 +1,66 @@ +syntax = "proto2"; +package video_streaming; + +message StreamerContext { + message ClientInfo { + optional string device_make = 12; + optional string device_model = 13; + optional int32 client_name = 16; + optional string client_version = 17; + optional string os_name = 18; + optional string os_version = 19; + optional string accept_language = 21; + optional string accept_region = 22; + optional int32 screen_width_points = 37; + optional int32 screen_height_points = 38; + optional float screen_width_inches = 39; + optional float screen_height_inches = 40; + optional int32 screen_pixel_density = 41; + optional ClientFormFactor client_form_factor = 46; + optional int32 gmscore_version_code = 50; // e.g. 243731017 + optional int32 window_width_points = 55; + optional int32 window_height_points = 56; + optional int32 android_sdk_version = 64; + optional float screen_density_float = 65; + optional int64 utc_offset_minutes = 67; + optional string time_zone = 80; + optional string chipset = 92; // e.g. "qcom;taro" + optional GLDeviceInfo gl_device_info = 102; + } + + enum ClientFormFactor { + UNKNOWN_FORM_FACTOR = 0; + FORM_FACTOR_VAL1 = 1; + FORM_FACTOR_VAL2 = 2; + } + + message GLDeviceInfo { + optional string gl_renderer = 1; + optional int32 gl_es_version_major = 2; + optional int32 gl_es_version_minor = 3; + } + + message SabrContext { + optional int32 type = 1; + optional bytes value = 2; + } + + message UnknownMessage1 { + message UnknownInnerMessage1 { + optional int32 code = 1; + optional string message = 2; + } + + optional bytes field1 = 1; + optional UnknownInnerMessage1 field2 = 2; + } + + optional ClientInfo client_info = 1; + optional bytes po_token = 2; + optional bytes playback_cookie = 3; + optional bytes field4 = 4; + repeated SabrContext sabr_contexts = 5; + repeated int32 unsent_sabr_contexts = 6; + optional string field7 = 7; + optional UnknownMessage1 field8 = 8; +} \ No newline at end of file diff --git a/app/src/main/proto/video_streaming/time_range.proto b/app/src/main/proto/video_streaming/time_range.proto new file mode 100644 index 0000000000..62f100d92b --- /dev/null +++ b/app/src/main/proto/video_streaming/time_range.proto @@ -0,0 +1,8 @@ +syntax = "proto2"; +package video_streaming; + +message TimeRange { + optional int64 start_ticks = 1; + optional int64 duration_ticks = 2; + optional int32 timescale = 3; +} diff --git a/app/src/main/proto/video_streaming/ump_part_id.proto b/app/src/main/proto/video_streaming/ump_part_id.proto new file mode 100644 index 0000000000..6fd28527dc --- /dev/null +++ b/app/src/main/proto/video_streaming/ump_part_id.proto @@ -0,0 +1,60 @@ +syntax = "proto2"; + +package video_streaming; + +enum UMPPartId { + UNKNOWN = 0; + ONESIE_HEADER = 10; + ONESIE_DATA = 11; + ONESIE_ENCRYPTED_MEDIA = 12; + // Header for a media segment; includes sequence and timing information. + MEDIA_HEADER = 20; + // Chunk of media segment data. + MEDIA = 21; + // Indicates end of media segment; finalizes segment processing. + MEDIA_END = 22; + CONFIG = 30 [deprecated = true]; + LIVE_METADATA = 31; + HOSTNAME_CHANGE_HINT_DEPRECATED = 32; + LIVE_METADATA_PROMISE = 33; + LIVE_METADATA_PROMISE_CANCELLATION = 34; + // Server's policy for the next request; includes backoff time and playback cookie. + NEXT_REQUEST_POLICY = 35; + USTREAMER_VIDEO_AND_FORMAT_METADATA = 36; + FORMAT_SELECTION_CONFIG = 37; + USTREAMER_SELECTED_MEDIA_STREAM = 38; + // Metadata for format initialization; contains total number of segments, duration, etc. + FORMAT_INITIALIZATION_METADATA = 42; + // Indicates a redirect to a different streaming URL. + SABR_REDIRECT = 43; + // Indicates a SABR error; happens when the payload is invalid or the server cannot process the request. + SABR_ERROR = 44; + SABR_SEEK = 45; + // Directive to reload the player with new parameters. + RELOAD_PLAYER_RESPONSE = 46; + PLAYBACK_START_POLICY = 47; + ALLOWED_CACHED_FORMATS = 48; + START_BW_SAMPLING_HINT = 49; + PAUSE_BW_SAMPLING_HINT = 50; + SELECTABLE_FORMATS = 51; + REQUEST_IDENTIFIER = 52; + REQUEST_CANCELLATION_POLICY = 53; + ONESIE_PREFETCH_REJECTION = 54; + TIMELINE_CONTEXT = 55; + REQUEST_PIPELINING = 56; + // Updates SABR context data; usually used for ads. + SABR_CONTEXT_UPDATE = 57; + // Status of stream protection; indicates whether attestation is required. + STREAM_PROTECTION_STATUS = 58; + // Policy indicating which SABR contexts to send or discard in future requests. + SABR_CONTEXT_SENDING_POLICY = 59; + LAWNMOWER_POLICY = 60; + SABR_ACK = 61; + END_OF_TRACK = 62; + CACHE_LOAD_POLICY = 63; + LAWNMOWER_MESSAGING_POLICY = 64; + PREWARM_CONNECTION = 65; + PLAYBACK_DEBUG_INFO = 66; + // Directive to show the user a notification message. + SNACKBAR_MESSAGE = 67; +} diff --git a/app/src/main/proto/video_streaming/video_playback_abr_request.proto b/app/src/main/proto/video_streaming/video_playback_abr_request.proto new file mode 100644 index 0000000000..77ed2f372a --- /dev/null +++ b/app/src/main/proto/video_streaming/video_playback_abr_request.proto @@ -0,0 +1,48 @@ +syntax = "proto2"; +package video_streaming; + +import "misc/common.proto"; +import "video_streaming/buffered_range.proto"; +import "video_streaming/client_abr_state.proto"; +import "video_streaming/streamer_context.proto"; +import "video_streaming/time_range.proto"; + +message VideoPlaybackAbrRequest { + optional ClientAbrState client_abr_state = 1; + repeated .misc.FormatId selected_format_ids = 2; + repeated BufferedRange buffered_ranges = 3; + optional int64 player_time_ms = 4; // `osts` (Onesie Start Time Seconds) param on Onesie requests. + optional bytes video_playback_ustreamer_config = 5; + optional UnknownMessage1 field6 = 6; + repeated .misc.FormatId preferred_audio_format_ids = 16; // `pai` (Preferred Audio Itags) param on Onesie requests. + repeated .misc.FormatId preferred_video_format_ids = 17; // `pvi` (Preferred Video Itags) param on Onesie requests. + repeated .misc.FormatId preferred_subtitle_format_ids = 18; + optional StreamerContext streamer_context = 19; + optional UnknownMessage2 field21 = 21; + optional int32 field22 = 22; + optional int32 field23 = 23; + repeated UnknownMessage3 field1000 = 1000; +} + +message UnknownMessage1 { + optional .misc.FormatId format_id = 1; + optional sint64 lmt = 2; + optional int32 sequence_number = 3; + optional TimeRange time_range = 4; + optional int32 field5 = 5; +} + +message UnknownMessage2 { + repeated string field1 = 1; + optional bytes field2 = 2; + optional string field3 = 3; + optional int32 field4 = 4; + optional int32 field5 = 5; + optional string field6 = 6; +} + +message UnknownMessage3 { + repeated .misc.FormatId format_ids = 1; + repeated BufferedRange ud = 2; + optional string clip_id = 3; +} \ No newline at end of file diff --git a/app/src/test/java/com/github/libretube/player/parser/UmpParserTest.kt b/app/src/test/java/com/github/libretube/player/parser/UmpParserTest.kt new file mode 100644 index 0000000000..64cdc5fc56 --- /dev/null +++ b/app/src/test/java/com/github/libretube/player/parser/UmpParserTest.kt @@ -0,0 +1,48 @@ +package com.github.libretube.player.parser + +import androidx.annotation.VisibleForTesting +import org.junit.Test +import org.junit.Assert.* +import video_streaming.UmpPartId + +class ParserTest { + @Test + fun testReadVarint() { + val testCases = listOf( + // 1 byte long varint + Pair(byteArrayOf(0x01), 1u), + Pair(byteArrayOf(0x4F), 79u), + // 2 byte long varint + Pair(byteArrayOf(0x96.toByte(), 0), 22u), + Pair(byteArrayOf(0x80.toByte(), 0x01), 64u), + Pair(byteArrayOf(0x8A.toByte(), 0x7F), 8138u), + Pair(byteArrayOf(0xBF.toByte(), 0x7F), 8191u), + // 3 byte long varint + Pair(byteArrayOf(0xC0.toByte(), 0x80.toByte(), 0x01), 12288u), + Pair(byteArrayOf(0xDF.toByte(), 0x7F, 0xFF.toByte()), 2093055u), + // 4 byte long varint + Pair(byteArrayOf(0xE0.toByte(), 0x80.toByte(), 0x80.toByte(), 0x01), 1574912u), + Pair(byteArrayOf(0xEF.toByte(), 0x7F, 0xFF.toByte(), 0xFF.toByte()), 268433407u), + // 5 byte long varint + Pair(byteArrayOf(0xF0.toByte(), 0x80.toByte(), 0x80.toByte(), 0x80.toByte(), 0x01), 25198720u), + Pair(byteArrayOf(0xFF.toByte(), 0x7F, 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte()), 4294967167u) + ) + + for ((data, expected) in testCases) { + val parser = UmpParser(data) + val result = parser.readVarint() + assertEquals("Failed for input: ${data.joinToString { it.toString() }}", expected, result) + } + } + + @Test + fun testReadPart() { + val parser = UmpParser(byteArrayOf(20, 1, 42)) + val part = parser.readPart() + + assertNotNull(part) + assertEquals(UmpPartId.UMPPartId.MEDIA_HEADER, part?.type) + assertArrayEquals(byteArrayOf(42), part?.data) + assertTrue(parser.data().isEmpty()) + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 87e98c1721..e4a9b534ef 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,7 +11,7 @@ constraintlayout = "2.2.1" loggingInterceptor = "4.12.0" material = "1.14.0-alpha06" navigation = "2.8.9" -newpipeextractor = "fba182c" +newpipeextractor = "v0.24.8+23" preference = "1.2.1" extJunit = "1.3.0" espresso = "3.7.0" @@ -34,6 +34,8 @@ paging = "3.3.6" collection = "1.5.0" swiperefreshlayout = "1.1.0" splashscreen = "1.2.0" +protobuf = "4.32.1" +protobufPlugin = "0.9.5" [libraries] androidx-activity = { group = "androidx.activity", name = "activity-ktx", version.ref = "activity" } @@ -62,7 +64,7 @@ androidx-media3-exoplayer-hls = { group = "androidx.media3", name="media3-exopla androidx-media3-exoplayer-dash = { group = "androidx.media3", name="media3-exoplayer-dash", version.ref="media3" } androidx-media3-session = { group="androidx.media3", name="media3-session", version.ref="media3" } androidx-media3-ui = { group="androidx.media3", name="media3-ui", version.ref="media3" } -newpipeextractor = { module = "com.github.libre-tube:NewPipeExtractor", version.ref = "newpipeextractor" } +newpipeextractor = { module = "com.github.TeamNewPipe:NewPipeExtractor", version.ref = "newpipeextractor" } square-retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } converter-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" } desugaring = { group = "com.android.tools", name = "desugar_jdk_libs_nio", version.ref = "desugaring" } @@ -82,6 +84,9 @@ androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profi androidx-paging = { group = "androidx.paging", name = "paging-runtime-ktx", version.ref = "paging" } androidx-collection = { group = "androidx.collection", name = "collection", version.ref = "collection" } androidx-swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version.ref = "swiperefreshlayout" } +protobuf-protoc = { module = "com.google.protobuf:protoc", version.ref = "protobuf" } +google-protobuf-javalite = { module = 'com.google.protobuf:protobuf-javalite', version.ref = 'protobuf' } +google-protobuf-kotlin-lite = { module = 'com.google.protobuf:protobuf-kotlin-lite', version.ref = 'protobuf' } [plugins] androidTest = { id = "com.android.test", version.ref = "gradle" } @@ -89,3 +94,4 @@ jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "k baselineprofile = { id = "androidx.baselineprofile", version.ref = "baselineprofile" } androidApplication = { id = "com.android.application", version.ref = "gradle" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +google-protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" }