From cc87eb39d7493dd93f23d898cd85b9776876dd34 Mon Sep 17 00:00:00 2001 From: InfinityLoop1308 <96324692+InfinityLoop1308@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:20:53 +0800 Subject: [PATCH 01/13] 1 --- .../youtube/sabr/SabrRequestDumper.java | 539 ++++++++++++ .../newpipe/player/SabrPlaybackSmokeTest.java | 93 +- .../player/YoutubePlaybackBenchmarkTest.java | 21 +- .../SabrSponsorBlockStallProbeTest.java | 58 +- .../YoutubeCredentialIdentityTest.kt | 58 -- app/src/main/java/org/schabi/newpipe/App.java | 68 -- .../datasource/LocalDomPoTokenGenerator.kt | 347 -------- .../datasource/LocalDomPoTokenProvider.kt | 821 ++++++++---------- .../datasource/LocalDomPoTokenRequest.kt | 93 -- .../datasource/SabrDashMediaSource.java | 10 +- .../player/datasource/SabrMediaBridge.java | 169 ++++ .../datasource/SabrSegmentDataSource.java | 26 +- .../player/datasource/SabrSessionStore.java | 186 ++-- .../player/datasource/SabrSourceSpec.java | 17 +- .../player/datasource/SabrStreamPump.java | 115 +-- .../YoutubeSessionPoTokenPrewarmer.kt | 92 -- .../player/resolver/PlaybackResolver.java | 3 +- .../YouTubeAccountSettingsFragment.java | 1 - .../newpipe/util/StreamItemAdapter.java | 3 +- .../giga/get/SabrDownloadFormatResolver.kt | 21 +- .../shandian/giga/get/SabrDownloadTarget.kt | 4 +- .../us/shandian/giga/get/SabrDownloader.kt | 80 +- .../us/shandian/giga/get/SabrSegmentWriter.kt | 38 +- .../datasource/LocalDomPoTokenRequestTest.kt | 60 -- .../SabrPreferredAudioLanguageTest.java | 42 +- .../SabrSessionPoTokenPrewarmerTest.kt | 1 - 26 files changed, 1312 insertions(+), 1654 deletions(-) create mode 100644 app/src/androidTest/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrRequestDumper.java delete mode 100644 app/src/androidTest/java/org/schabi/newpipe/player/datasource/YoutubeCredentialIdentityTest.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/LocalDomPoTokenGenerator.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/LocalDomPoTokenRequest.kt create mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaBridge.java delete mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeSessionPoTokenPrewarmer.kt delete mode 100644 app/src/test/java/org/schabi/newpipe/player/datasource/LocalDomPoTokenRequestTest.kt diff --git a/app/src/androidTest/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrRequestDumper.java b/app/src/androidTest/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrRequestDumper.java new file mode 100644 index 000000000..db4a0fa22 --- /dev/null +++ b/app/src/androidTest/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrRequestDumper.java @@ -0,0 +1,539 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import org.schabi.newpipe.extractor.services.youtube.sabr.exception.SabrProtocolException; +import org.schabi.newpipe.extractor.services.youtube.sabr.protocol.SabrProto; + +import javax.annotation.Nonnull; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Test-only diagnostics for SABR request-shape experiments. + */ +public final class SabrRequestDumper { + private SabrRequestDumper() { + } + + @Nonnull + public static String summarize(@Nonnull final byte[] requestBody) { + try { + return summarizeRequest(requestBody); + } catch (final Exception e) { + return "undecodableRequest(bytes=" + requestBody.length + ')'; + } + } + + @Nonnull + private static String summarizeRequest(@Nonnull final byte[] requestBody) + throws SabrProtocolException { + final List fields = SabrProto.readFields(requestBody); + String clientAbrState = "null"; + final List selectedFormats = new ArrayList<>(); + final List bufferedRanges = new ArrayList<>(); + long topLevelPlayerTimeMs = -1; + int ustreamerConfigBytes = -1; + final List preferredAudioFormats = new ArrayList<>(); + final List preferredVideoFormats = new ArrayList<>(); + final List preferredSubtitleFormats = new ArrayList<>(); + String streamerContext = "null"; + int field1000Count = 0; + final List unknownFields = new ArrayList<>(); + + for (final SabrProto.Field field : fields) { + switch (field.getNumber()) { + case 1: + clientAbrState = describeClientAbrState(field.getBytes()); + break; + case 2: + selectedFormats.add(describeFormatId(field.getBytes())); + break; + case 3: + bufferedRanges.add(describeBufferedRange(field.getBytes())); + break; + case 4: + topLevelPlayerTimeMs = field.getVarint(); + break; + case 5: + ustreamerConfigBytes = field.getBytes().length; + break; + case 16: + preferredAudioFormats.add(describeFormatId(field.getBytes())); + break; + case 17: + preferredVideoFormats.add(describeFormatId(field.getBytes())); + break; + case 18: + preferredSubtitleFormats.add(describeFormatId(field.getBytes())); + break; + case 19: + streamerContext = describeStreamerContext(field.getBytes()); + break; + case 1000: + field1000Count++; + break; + default: + unknownFields.add(describeUnknownField(field)); + break; + } + } + + return "bytes=" + requestBody.length + + "; fields=" + describeFieldCounts(fields) + + "; clientAbr={" + clientAbrState + '}' + + "; selected=" + selectedFormats + + "; ranges=" + bufferedRanges + + "; topPlayerTimeMs=" + topLevelPlayerTimeMs + + "; ustreamer=bytes(" + ustreamerConfigBytes + ')' + + "; prefAudio=" + preferredAudioFormats + + "; prefVideo=" + preferredVideoFormats + + "; prefSub=" + preferredSubtitleFormats + + "; streamer={" + streamerContext + '}' + + "; field1000=" + field1000Count + + "; unknown=" + unknownFields; + } + + @Nonnull + private static String describeClientAbrState(@Nonnull final byte[] data) + throws SabrProtocolException { + final List values = new ArrayList<>(); + for (final SabrProto.Field field : SabrProto.readFields(data)) { + final String name = clientAbrStateFieldName(field.getNumber()); + if (field.getNumber() == 35 && field.getWireType() == SabrProto.WIRE_FIXED32) { + values.add(name + '=' + String.format(Locale.ROOT, "%.3f", + Float.intBitsToFloat(SabrProto.asFixed32LittleEndian(field.getBytes())))); + } else if (field.getNumber() == 72 + && field.getWireType() == SabrProto.WIRE_LENGTH_DELIMITED) { + values.add(name + "={" + SabrProto.summarizeFields(field.getBytes()) + '}'); + } else if (field.getNumber() == 79 + && field.getWireType() == SabrProto.WIRE_LENGTH_DELIMITED) { + values.add(name + "={" + describePlaybackAuthorization(field.getBytes()) + '}'); + } else if (field.getNumber() == 69 + && field.getWireType() == SabrProto.WIRE_LENGTH_DELIMITED) { + values.add(name + "=present(len=" + field.getBytes().length + ')'); + } else if (field.getWireType() == SabrProto.WIRE_LENGTH_DELIMITED) { + values.add(name + "=bytes(" + field.getBytes().length + ')'); + } else if (isBoolClientAbrStateField(field.getNumber())) { + values.add(name + '=' + (field.getVarint() != 0)); + } else { + values.add(name + '=' + field.getVarint()); + } + } + return join(values); + } + + @Nonnull + private static String describeBufferedRange(@Nonnull final byte[] data) + throws SabrProtocolException { + String formatId = "format:null"; + long startTimeMs = -1; + long durationMs = -1; + int startSegmentIndex = -1; + int endSegmentIndex = -1; + String timeRange = "null"; + final List unknown = new ArrayList<>(); + + for (final SabrProto.Field field : SabrProto.readFields(data)) { + switch (field.getNumber()) { + case 1: + formatId = describeFormatId(field.getBytes()); + break; + case 2: + startTimeMs = field.getVarint(); + break; + case 3: + durationMs = field.getVarint(); + break; + case 4: + startSegmentIndex = (int) field.getVarint(); + break; + case 5: + endSegmentIndex = (int) field.getVarint(); + break; + case 6: + timeRange = describeTimeRange(field.getBytes()); + break; + default: + unknown.add(describeUnknownField(field)); + break; + } + } + + return formatId + ":seq=" + startSegmentIndex + '-' + endSegmentIndex + + ":time=" + startTimeMs + '+' + durationMs + + ":tr=" + timeRange + + (unknown.isEmpty() ? "" : ":unknown=" + unknown); + } + + @Nonnull + private static String describeTimeRange(@Nonnull final byte[] data) + throws SabrProtocolException { + long startTicks = -1; + long durationTicks = -1; + int timescale = -1; + for (final SabrProto.Field field : SabrProto.readFields(data)) { + if (field.getNumber() == 1) { + startTicks = field.getVarint(); + } else if (field.getNumber() == 2) { + durationTicks = field.getVarint(); + } else if (field.getNumber() == 3) { + timescale = (int) field.getVarint(); + } + } + return startTicks + "+" + durationTicks + '@' + timescale; + } + + @Nonnull + private static String describeStreamerContext(@Nonnull final byte[] data) + throws SabrProtocolException { + String clientInfo = "null"; + int poTokenBytes = -1; + String playbackCookie = "null"; + int field4Bytes = -1; + final List contexts = new ArrayList<>(); + final List unsentContexts = new ArrayList<>(); + int field7Bytes = -1; + int field8Bytes = -1; + final List unknown = new ArrayList<>(); + + for (final SabrProto.Field field : SabrProto.readFields(data)) { + switch (field.getNumber()) { + case 1: + clientInfo = describeClientInfo(field.getBytes()); + break; + case 2: + poTokenBytes = field.getBytes().length; + break; + case 3: + playbackCookie = describePlaybackCookie(field.getBytes()); + break; + case 4: + field4Bytes = field.getBytes().length; + break; + case 5: + contexts.add(describeSabrContext(field.getBytes())); + break; + case 6: + if (field.getWireType() == SabrProto.WIRE_VARINT) { + unsentContexts.add(field.getVarint()); + } else { + unsentContexts.addAll(readRawVarints(field.getBytes())); + } + break; + case 7: + field7Bytes = field.getBytes().length; + break; + case 8: + field8Bytes = field.getBytes().length; + break; + default: + unknown.add(describeUnknownField(field)); + break; + } + } + + return "client=" + clientInfo + + ", poToken=bytes(" + poTokenBytes + ')' + + ", playbackCookie=" + playbackCookie + + ", field4=bytes(" + field4Bytes + ')' + + ", contexts=" + contexts + + ", unsent=" + unsentContexts + + ", field7=bytes(" + field7Bytes + ')' + + ", field8=bytes(" + field8Bytes + ')' + + (unknown.isEmpty() ? "" : ", unknown=" + unknown); + } + + @Nonnull + private static String describeClientInfo(@Nonnull final byte[] data) + throws SabrProtocolException { + final List values = new ArrayList<>(); + for (final SabrProto.Field field : SabrProto.readFields(data)) { + switch (field.getNumber()) { + case 16: + values.add("clientName=" + field.getVarint()); + break; + case 17: + values.add("clientVersion=" + field.getString()); + break; + case 18: + values.add("osName=" + field.getString()); + break; + case 19: + values.add("osVersion=" + field.getString()); + break; + case 21: + values.add("acceptLanguage=" + field.getString()); + break; + case 22: + values.add("acceptRegion=" + field.getString()); + break; + default: + values.add(describeUnknownField(field)); + break; + } + } + return '{' + join(values) + '}'; + } + + @Nonnull + private static String describeSabrContext(@Nonnull final byte[] data) + throws SabrProtocolException { + int type = -1; + int valueBytes = -1; + for (final SabrProto.Field field : SabrProto.readFields(data)) { + if (field.getNumber() == 1 && field.getWireType() == SabrProto.WIRE_VARINT) { + type = (int) field.getVarint(); + } else if (field.getNumber() == 2 + && field.getWireType() == SabrProto.WIRE_LENGTH_DELIMITED) { + valueBytes = field.getBytes().length; + } + } + return "type=" + type + "/bytes=" + valueBytes; + } + + @Nonnull + private static String describePlaybackCookie(@Nonnull final byte[] data) { + try { + return "bytes(" + data.length + "):" + SabrProto.summarizeFields(data); + } catch (final Exception e) { + return "bytes(" + data.length + ")"; + } + } + + @Nonnull + private static String describePlaybackAuthorization(@Nonnull final byte[] data) { + try { + int authorizedFormats = 0; + int licenseConstraintBytes = -1; + final List unknown = new ArrayList<>(); + for (final SabrProto.Field field : SabrProto.readFields(data)) { + if (field.getNumber() == 1 + && field.getWireType() == SabrProto.WIRE_LENGTH_DELIMITED) { + authorizedFormats++; + } else if (field.getNumber() == 2 + && field.getWireType() == SabrProto.WIRE_LENGTH_DELIMITED) { + licenseConstraintBytes = field.getBytes().length; + } else { + unknown.add(describeUnknownField(field)); + } + } + return "authorized=" + authorizedFormats + + ", licenseConstraint=bytes(" + licenseConstraintBytes + ')' + + (unknown.isEmpty() ? "" : ", unknown=" + unknown); + } catch (final Exception e) { + return "bytes(" + data.length + ')'; + } + } + + @Nonnull + private static String describeFormatId(@Nonnull final byte[] data) { + try { + int itag = -1; + long lastModified = -1; + int xtagsLength = -1; + for (final SabrProto.Field field : SabrProto.readFields(data)) { + if (field.getNumber() == 1 && field.getWireType() == SabrProto.WIRE_VARINT) { + itag = (int) field.getVarint(); + } else if (field.getNumber() == 2 + && field.getWireType() == SabrProto.WIRE_VARINT) { + lastModified = field.getVarint(); + } else if (field.getNumber() == 3 + && field.getWireType() == SabrProto.WIRE_LENGTH_DELIMITED) { + xtagsLength = field.getBytes().length; + } + } + if (itag < 0) { + return "bytes(" + data.length + ')'; + } + return "itag:" + itag + + (lastModified >= 0 ? "+lm=" + lastModified : "") + + (xtagsLength >= 0 ? "+xtagsLen=" + xtagsLength : ""); + } catch (final Exception e) { + return "bytes(" + data.length + ')'; + } + } + + @Nonnull + private static String describeFieldCounts(@Nonnull final List fields) { + final Map counts = new LinkedHashMap<>(); + for (final SabrProto.Field field : fields) { + final Integer count = counts.get(field.getNumber()); + counts.put(field.getNumber(), count == null ? 1 : count + 1); + } + final List values = new ArrayList<>(); + for (final Map.Entry entry : counts.entrySet()) { + values.add(entry.getKey() + "x" + entry.getValue()); + } + return values.toString(); + } + + @Nonnull + private static String describeUnknownField(@Nonnull final SabrProto.Field field) + throws SabrProtocolException { + if (field.getWireType() == SabrProto.WIRE_VARINT) { + return field.getNumber() + "=" + field.getVarint(); + } + return field.getNumber() + "=bytes(" + field.getBytes().length + ')'; + } + + @Nonnull + private static List readRawVarints(@Nonnull final byte[] data) + throws SabrProtocolException { + final List values = new ArrayList<>(); + int offset = 0; + while (offset < data.length) { + long result = 0; + int shift = 0; + while (shift < 64) { + if (offset >= data.length) { + throw new SabrProtocolException("Unexpected EOF in packed varint"); + } + final int current = data[offset++] & 0xff; + result |= (long) (current & 0x7f) << shift; + if ((current & 0x80) == 0) { + values.add(result); + break; + } + shift += 7; + } + if (shift >= 64) { + throw new SabrProtocolException("Packed varint is too long"); + } + } + return values; + } + + @Nonnull + private static String clientAbrStateFieldName(final int fieldNumber) { + switch (fieldNumber) { + case 13: + return "timeSinceLastManualFormatSelectionMs"; + case 14: + return "lastManualDirection"; + case 16: + return "lastManualSelectedResolution"; + case 17: + return "detailedNetworkType"; + case 18: + return "clientViewportWidth"; + case 19: + return "clientViewportHeight"; + case 20: + return "clientBitrateCapBytesPerSec"; + case 21: + return "stickyResolution"; + case 22: + return "clientViewportIsFlexible"; + case 23: + return "bandwidthEstimate"; + case 24: + return "minAudioQuality"; + case 25: + return "maxAudioQuality"; + case 26: + return "videoQualitySetting"; + case 27: + return "audioRoute"; + case 28: + return "playerTimeMs"; + case 29: + return "timeSinceLastSeek"; + case 30: + return "dataSaverMode"; + case 32: + return "networkMeteredState"; + case 34: + return "visibility"; + case 35: + return "playbackRate"; + case 36: + return "elapsedWallTimeMs"; + case 38: + return "mediaCapabilities"; + case 39: + return "timeSinceLastActionMs"; + case 40: + return "enabledTrackTypesBitfield"; + case 43: + return "maxPacingRate"; + case 44: + return "playerState"; + case 46: + return "drcEnabled"; + case 48: + return "field48"; + case 50: + return "field50"; + case 51: + return "field51"; + case 54: + return "sabrReportRequestCancellationInfo"; + case 55: + return "field55"; + case 56: + return "disableStreamingXhr"; + case 57: + return "field57"; + case 58: + return "preferVp9"; + case 59: + return "av1QualityThreshold"; + case 60: + return "field60"; + case 61: + return "isPrefetch"; + case 62: + return "sabrSupportQualityConstraints"; + case 63: + return "sabrLicenseConstraint"; + case 64: + return "allowProximaLiveLatency"; + case 66: + return "sabrForceProxima"; + case 67: + return "field67"; + case 68: + return "sabrForceMaxNetworkInterruptionDurationMs"; + case 69: + return "audioTrackId"; + case 71: + return "field71"; + case 72: + return "field72"; + case 73: + return "field73"; + case 74: + return "field74"; + case 75: + return "field75"; + case 76: + return "enableVoiceBoost"; + case 79: + return "playbackAuthorization"; + case 80: + return "field80"; + default: + return "field" + fieldNumber; + } + } + + private static boolean isBoolClientAbrStateField(final int fieldNumber) { + return fieldNumber == 22 || fieldNumber == 30 || fieldNumber == 46 + || fieldNumber == 56 || fieldNumber == 58 || fieldNumber == 61 + || fieldNumber == 62 || fieldNumber == 71; + } + + @Nonnull + private static String join(@Nonnull final List values) { + final StringBuilder builder = new StringBuilder(); + for (int i = 0; i < values.size(); i++) { + if (i > 0) { + builder.append(", "); + } + builder.append(values.get(i)); + } + return builder.toString(); + } +} diff --git a/app/src/androidTest/java/org/schabi/newpipe/player/SabrPlaybackSmokeTest.java b/app/src/androidTest/java/org/schabi/newpipe/player/SabrPlaybackSmokeTest.java index 7bdaf5834..817fe2b6d 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/player/SabrPlaybackSmokeTest.java +++ b/app/src/androidTest/java/org/schabi/newpipe/player/SabrPlaybackSmokeTest.java @@ -36,7 +36,6 @@ import org.junit.runner.RunWith; import org.schabi.newpipe.App; import org.schabi.newpipe.DownloaderImpl; -import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.downloader.CancellableCall; import org.schabi.newpipe.extractor.downloader.Downloader; import org.schabi.newpipe.extractor.downloader.Request; @@ -48,13 +47,12 @@ import org.schabi.newpipe.extractor.localization.ContentCountry; import org.schabi.newpipe.extractor.localization.Localization; import org.schabi.newpipe.extractor.playlist.PlaylistInfo; -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrMediaSegment; +import org.schabi.newpipe.extractor.services.youtube.sabr.media.SabrMediaSegment; import org.schabi.newpipe.extractor.services.youtube.sabr.SabrRequestDumper; -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrResponseDecoder; +import org.schabi.newpipe.extractor.services.youtube.sabr.protocol.SabrResponseDecoder; import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest; -import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrClientProfile; -import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; +import org.schabi.newpipe.extractor.services.youtube.ItagItem; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.DeliveryMethod; @@ -382,13 +380,13 @@ public void demandBackoffRemainsCancelableWithoutEarlyRequest() throws Exception boolean completed; try { final long deadlineNs = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); - while (harness.holder.session.getDemandBackoffRemainingMs() == 0 + while (harness.holder.session.getBackoffRemainingMs() == 0 && System.nanoTime() < deadlineNs) { Thread.sleep(25); } assertTrue("Demand did not enter the server backoff: " + harness.holder.session.getDiagnosticTrace(), - harness.holder.session.getDemandBackoffRemainingMs() > 0); + harness.holder.session.getBackoffRemainingMs() > 0); harness.advanceReaderGeneration(); completed = done.await(1_500, TimeUnit.MILLISECONDS); Thread.sleep(250); @@ -419,7 +417,7 @@ public void startupPumpDefersLongBackoffBeforeLoaderDemand() throws Exception { assertEquals(0, harness.holder.session.pumpOnceStreamingForStartup( new Localization("en", "US"))); final long elapsedMs = System.currentTimeMillis() - startedAtMs; - final long remainingMs = harness.holder.session.getDemandBackoffRemainingMs(); + final long remainingMs = harness.holder.session.getBackoffRemainingMs(); assertTrue("Startup pump blocked on the full server backoff: elapsedMs=" + elapsedMs, elapsedMs < 1_000); @@ -767,8 +765,8 @@ public void initializationPumpKeepsMidStartTarget() throws Exception { @Test public void nativeBootstrapBuildsExactTimelineWithoutAdaptiveRangeRequests() throws Exception { - final YoutubeSabrFormat audioFormat = smokeFormat(SMOKE_AUDIO_ITAG, true); - final YoutubeSabrFormat videoFormat = smokeFormat(SMOKE_VIDEO_ITAG, false); + final YoutubeSabrInfo.Format audioFormat = smokeFormat(SMOKE_AUDIO_ITAG, true); + final YoutubeSabrInfo.Format videoFormat = smokeFormat(SMOKE_VIDEO_ITAG, false); final byte[] audioInit = mp4Sidx(20_001, 20_000, 19_999); final byte[] videoInit = mp4Sidx(5_000, 5_000, 5_000, 5_000); try (SabrSmokeHarness harness = SabrSmokeHarness.create(audioFormat, videoFormat)) { @@ -809,9 +807,9 @@ public void adaptiveExactRangesBuildIndexesInParallel() throws Exception { final String encodedPoToken = "--8B"; final byte[] audioInit = mp4Sidx(20_001, 20_000, 19_999); final byte[] videoInit = mp4Sidx(5_000, 5_000, 5_000, 5_000); - final YoutubeSabrFormat audioFormat = smokeFormat(SMOKE_AUDIO_ITAG, true, + final YoutubeSabrInfo.Format audioFormat = smokeFormat(SMOKE_AUDIO_ITAG, true, "https://adaptive/audio", 0, audioInit.length - 1); - final YoutubeSabrFormat videoFormat = smokeFormat(SMOKE_VIDEO_ITAG, false, + final YoutubeSabrInfo.Format videoFormat = smokeFormat(SMOKE_VIDEO_ITAG, false, "https://adaptive/video", 0, videoInit.length - 1); try (SabrSmokeHarness harness = SabrSmokeHarness.create(audioFormat, videoFormat)) { harness.downloader.enqueueGet("https://adaptive/audio?pot=" + encodedPoToken, @@ -821,7 +819,7 @@ public void adaptiveExactRangesBuildIndexesInParallel() throws Exception { final Method method = SabrSessionStore.class.getDeclaredMethod( "createAdaptiveInitialization", YoutubeSabrInfo.class, - YoutubeSabrFormat.class, YoutubeSabrFormat.class, Localization.class, + YoutubeSabrInfo.Format.class, YoutubeSabrInfo.Format.class, Localization.class, byte[].class); method.setAccessible(true); final Object result = method.invoke(null, harness.holder.info, audioFormat, @@ -844,8 +842,8 @@ public void adaptiveExactRangesBuildIndexesInParallel() throws Exception { @Test public void preparedNativeSessionIsTransferredToPlaybackOnce() throws Exception { - final YoutubeSabrFormat audioFormat = smokeFormat(SMOKE_AUDIO_ITAG, true); - final YoutubeSabrFormat videoFormat = smokeFormat(SMOKE_VIDEO_ITAG, false); + final YoutubeSabrInfo.Format audioFormat = smokeFormat(SMOKE_AUDIO_ITAG, true); + final YoutubeSabrInfo.Format videoFormat = smokeFormat(SMOKE_VIDEO_ITAG, false); final byte[] audioInit = mp4Sidx(20_001, 20_000); final byte[] videoInit = mp4Sidx(5_000, 5_000); try (SabrSmokeHarness harness = SabrSmokeHarness.create(audioFormat, videoFormat)) { @@ -861,7 +859,7 @@ public void preparedNativeSessionIsTransferredToPlaybackOnce() throws Exception final Constructor constructor = SabrSourceSpec.class .getDeclaredConstructor(String.class, YoutubeSabrInfo.class, - YoutubeSabrFormat.class, YoutubeSabrFormat.class, Localization.class, + YoutubeSabrInfo.Format.class, YoutubeSabrInfo.Format.class, Localization.class, byte[].class, byte[].class, YoutubeSabrSession.class); constructor.setAccessible(true); final SabrSourceSpec spec = constructor.newInstance("smoke-video", harness.holder.info, @@ -884,8 +882,8 @@ audioFormat, videoFormat, new Localization("en", "US"), audioInit, videoInit, @Test public void nativeBootstrapHonorsInitialAndSkipsCompletedResponseBackoff() throws Exception { - final YoutubeSabrFormat audioFormat = smokeFormat(SMOKE_AUDIO_ITAG, true); - final YoutubeSabrFormat videoFormat = smokeFormat(SMOKE_VIDEO_ITAG, false); + final YoutubeSabrInfo.Format audioFormat = smokeFormat(SMOKE_AUDIO_ITAG, true); + final YoutubeSabrInfo.Format videoFormat = smokeFormat(SMOKE_VIDEO_ITAG, false); final byte[] audioInit = mp4Sidx(20_000); final byte[] videoInit = mp4Sidx(5_000); try (SabrSmokeHarness harness = SabrSmokeHarness.create(audioFormat, videoFormat)) { @@ -2095,7 +2093,7 @@ private static void verifyStalledReaderReadAhead( } private static void discardCachedWindow(final SabrSessionStore.Holder holder, - final YoutubeSabrFormat format, + final YoutubeSabrInfo.Format format, final long positionMs) { final int centerSequence = holder.session.getStreamState() .getSegmentNumberAtOrAfterTimeMs(format, positionMs); @@ -2118,7 +2116,7 @@ private static void verifyInitializationRecovery(final SabrSessionStore.Holder h private static void verifyRewindResetsSabrState( final SabrSessionStore.Holder holder) throws Exception { final Localization localization = new Localization("en", "US"); - final YoutubeSabrFormat format = holder.videoFormat; + final YoutubeSabrInfo.Format format = holder.videoFormat; final SabrSegmentRequest target = SabrSegmentRequest.media(format, 2); // A newly split playback session may legitimately receive policy-only responses before a // reader asks for media. Establish deterministic forward media state through the same @@ -2127,7 +2125,7 @@ private static void verifyRewindResetsSabrState( && holder.session.getCachedSegment(target) == null; attempt++) { holder.session.prepareForForwardJump(target); holder.session.pumpOnceStreamingForDemand(localization, target); - final long backoffMs = holder.session.getDemandBackoffRemainingMs(); + final long backoffMs = holder.session.getBackoffRemainingMs(); if (backoffMs > 0 && holder.session.getCachedSegment(target) == null) { Thread.sleep(backoffMs + 10); } @@ -2441,42 +2439,29 @@ private static String messageChain(final Throwable throwable) { return builder.toString(); } - private static YoutubeSabrFormat smokeFormat(final int itag, final boolean audio) + private static YoutubeSabrInfo.Format smokeFormat(final int itag, final boolean audio) throws Exception { return smokeFormat(itag, audio, null, -1, -1); } - private static YoutubeSabrFormat smokeFormat(final int itag, + private static YoutubeSabrInfo.Format smokeFormat(final int itag, final boolean audio, final String initializationUrl, final long initRangeStart, final long initRangeEnd) throws Exception { - final Constructor constructor = - YoutubeSabrFormat.class.getDeclaredConstructor(int.class, long.class, - String.class, String.class, String.class, String.class, boolean.class, - String.class, String.class, boolean.class, int.class, int.class, - int.class, long.class, long.class, String.class, long.class, long.class); - constructor.setAccessible(true); - return constructor.newInstance( - itag, - 123456L, + final ItagItem parsedFormat = ItagItem.getItag(itag); + parsedFormat.setWidth(audio ? -1 : 1920); + parsedFormat.setHeight(audio ? -1 : 1080); + parsedFormat.setBitrate(audio ? 128_000 : 2_000_000); + parsedFormat.setContentLength(100_000L); + parsedFormat.setApproxDurationMs(300_000L); + return YoutubeSabrInfo.Format.fromParsedFormat(parsedFormat, 123456L, audio ? "audio-xtags" : "video-xtags", audio ? "audio/mp4" : "video/mp4", audio ? "audio-track" : null, audio ? "English original" : null, - audio, - audio ? null : "1080p", - audio ? "AUDIO_QUALITY_MEDIUM" : null, - false, - audio ? -1 : 1920, - audio ? -1 : 1080, - audio ? 128_000 : 2_000_000, - 100_000L, - 300_000L, - initializationUrl, - initRangeStart, - initRangeEnd); + false, initializationUrl, initRangeStart, initRangeEnd); } private static byte[] mp4Sidx(final int... durationsMs) { @@ -2499,15 +2484,15 @@ private static byte[] mp4Sidx(final int... durationsMs) { return buffer.array(); } - private static YoutubeSabrInfo smokeInfo(final YoutubeSabrFormat audioFormat, - final YoutubeSabrFormat videoFormat) + private static YoutubeSabrInfo smokeInfo(final YoutubeSabrInfo.Format audioFormat, + final YoutubeSabrInfo.Format videoFormat) throws Exception { final Constructor constructor = - YoutubeSabrInfo.class.getDeclaredConstructor(YoutubeSabrClientProfile.class, + YoutubeSabrInfo.class.getDeclaredConstructor( String.class, String.class, String.class, String.class, String.class, String.class, List.class); constructor.setAccessible(true); - return constructor.newInstance(YoutubeSabrClientProfile.MWEB, "smoke-video", "cpn", + return constructor.newInstance("smoke-video", "cpn", "2.20250122.04.00", "visitor", "https://sabr.test", base64(new byte[]{1, 2, 3, 4}), Arrays.asList(audioFormat, videoFormat)); } @@ -2830,7 +2815,7 @@ private static final class SabrSmokeHarness implements AutoCloseable { private final ContentCountry previousContentCountry; private final FakeSabrDownloader downloader; private final SabrSessionStore.Holder holder; - private final YoutubeSabrFormat videoFormat; + private final YoutubeSabrInfo.Format videoFormat; private final Object readerOwner; private SabrSmokeHarness(final Downloader previousDownloader, @@ -2838,7 +2823,7 @@ private SabrSmokeHarness(final Downloader previousDownloader, final ContentCountry previousContentCountry, final FakeSabrDownloader downloader, final SabrSessionStore.Holder holder, - final YoutubeSabrFormat videoFormat, + final YoutubeSabrInfo.Format videoFormat, final Object readerOwner) { this.previousDownloader = previousDownloader; this.previousLocalization = previousLocalization; @@ -2854,8 +2839,8 @@ private static SabrSmokeHarness create() throws Exception { smokeFormat(SMOKE_VIDEO_ITAG, false)); } - private static SabrSmokeHarness create(final YoutubeSabrFormat audioFormat, - final YoutubeSabrFormat videoFormat) + private static SabrSmokeHarness create(final YoutubeSabrInfo.Format audioFormat, + final YoutubeSabrInfo.Format videoFormat) throws Exception { final Downloader previousDownloader = NewPipe.getDownloader(); final Localization previousLocalization = NewPipe.getPreferredLocalization(); @@ -2867,12 +2852,12 @@ private static SabrSmokeHarness create(final YoutubeSabrFormat audioFormat, InstrumentationRegistry.getInstrumentation().getTargetContext().getCacheDir(), "sabr-smoke-" + System.nanoTime()); final YoutubeSabrSession session = - new YoutubeSabrSession(info, audioFormat, videoFormat, null, spoolDirectory); + new YoutubeSabrSession(info, audioFormat, videoFormat, spoolDirectory); session.getStreamState().setVideoOnlyRequestMode(); final Constructor constructor = SabrSessionStore.Holder.class.getDeclaredConstructor(Context.class, String.class, YoutubeSabrInfo.class, YoutubeSabrSession.class, - YoutubeSabrFormat.class, YoutubeSabrFormat.class); + YoutubeSabrInfo.Format.class, YoutubeSabrInfo.Format.class); constructor.setAccessible(true); final SabrSessionStore.Holder holder = constructor.newInstance( InstrumentationRegistry.getInstrumentation().getTargetContext(), diff --git a/app/src/androidTest/java/org/schabi/newpipe/player/YoutubePlaybackBenchmarkTest.java b/app/src/androidTest/java/org/schabi/newpipe/player/YoutubePlaybackBenchmarkTest.java index a1973d60a..6bcb2025c 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/player/YoutubePlaybackBenchmarkTest.java +++ b/app/src/androidTest/java/org/schabi/newpipe/player/YoutubePlaybackBenchmarkTest.java @@ -1,5 +1,6 @@ package org.schabi.newpipe.player; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; @@ -42,7 +43,6 @@ import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.ServiceList; import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest; -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrNextRequestPolicy; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.DeliveryMethod; import org.schabi.newpipe.extractor.stream.StreamInfo; @@ -102,11 +102,6 @@ public void compareSabrHlsAndGeneratedDash() throws Exception { args.getString("diagnosticDetails", "false")); final boolean coldSabrCachesEachTrial = Boolean.parseBoolean( args.getString("coldSabrCachesEachTrial", "false")); - final boolean disableSessionPoToken = Boolean.parseBoolean( - args.getString("disableSessionPoToken", "false")); - if (disableSessionPoToken) { - NewPipe.setYoutubeSessionPoTokenProvider(null); - } if (warmWebViewRuntime) { SharedWebViewRuntime.get(context).ensureReady(120_000L, "benchmark WebView warmup"); } @@ -637,16 +632,14 @@ private static SabrSessionStore.Holder findActiveSabrHolder(final String videoId } private static SabrStats sabrStats(final SabrSessionStore.Holder holder) { - final SabrNextRequestPolicy policy = holder.session.getStreamState() - .getNextRequestPolicy(); return new SabrStats(holder.session.getTotalResponseBytes(), holder.session.getRequestNumber(), holder.session.getPeakCachedBytes(), holder.session.getStreamState().getBandwidthEstimate(), - policy == null ? -1 : policy.getTargetAudioReadaheadMs(), - policy == null ? -1 : policy.getTargetVideoReadaheadMs(), - policy == null ? -1 : policy.getMinAudioReadaheadMs(), - policy == null ? -1 : policy.getMinVideoReadaheadMs(), - policy == null ? -1 : policy.getMaxTimeSinceLastRequestMs()); + holder.session.getStreamState().getTargetAudioReadaheadMs(), + holder.session.getStreamState().getTargetVideoReadaheadMs(), + holder.session.getStreamState().getMinAudioReadaheadMs(), + holder.session.getStreamState().getMinVideoReadaheadMs(), + holder.session.getStreamState().getMaxTimeSinceLastRequestMs()); } private static String readTextFile(final File file) throws Exception { @@ -1314,7 +1307,7 @@ private static SeekCacheSnapshot fromSabr(final SabrSessionStore.Holder holder, private static boolean hasMediaSegment(final SabrSessionStore.Holder holder, final org.schabi.newpipe.extractor.services.youtube - .sabr.YoutubeSabrFormat format, + .sabr.YoutubeSabrInfo.Format format, final int sequence) { return holder.session.getCachedSegment(SabrSegmentRequest.media(format, sequence)) != null; diff --git a/app/src/androidTest/java/org/schabi/newpipe/player/datasource/SabrSponsorBlockStallProbeTest.java b/app/src/androidTest/java/org/schabi/newpipe/player/datasource/SabrSponsorBlockStallProbeTest.java index 3357b211d..49a723896 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/player/datasource/SabrSponsorBlockStallProbeTest.java +++ b/app/src/androidTest/java/org/schabi/newpipe/player/datasource/SabrSponsorBlockStallProbeTest.java @@ -18,9 +18,8 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.schabi.newpipe.extractor.localization.Localization; -import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrClientProfile; -import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; +import org.schabi.newpipe.extractor.services.youtube.ItagItem; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession; import java.io.File; @@ -67,8 +66,8 @@ public void discardedSourceClearsPreparedSessionWithoutCreatingPeriod() throws E assumeProbeEnabled(); final Context context = context(); final String videoId = "discarded-prepared-source-probe"; - final YoutubeSabrFormat audio = format(AUDIO_ITAG, true); - final YoutubeSabrFormat video = format(VIDEO_ITAG, false); + final YoutubeSabrInfo.Format audio = format(AUDIO_ITAG, true); + final YoutubeSabrInfo.Format video = format(VIDEO_ITAG, false); final YoutubeSabrInfo info = info(videoId, audio, video); final YoutubeSabrSession session = session(context, videoId, info, audio, video); final SabrSourceSpec spec = new SabrSourceSpec(videoId, info, audio, video, @@ -88,8 +87,8 @@ public void failedSourceConstructionClearsPreparedSession() throws Exception { assumeProbeEnabled(); final Context context = context(); final String videoId = "failed-prepared-source-probe"; - final YoutubeSabrFormat audio = format(AUDIO_ITAG, true); - final YoutubeSabrFormat video = format(VIDEO_ITAG, false); + final YoutubeSabrInfo.Format audio = format(AUDIO_ITAG, true); + final YoutubeSabrInfo.Format video = format(VIDEO_ITAG, false); final YoutubeSabrInfo info = info(videoId, audio, video); final YoutubeSabrSession session = session(context, videoId, info, audio, video); final SabrSourceSpec spec = new SabrSourceSpec(videoId, info, audio, video, @@ -269,8 +268,8 @@ public void duplicateSourcesOfSameVideoUseIndependentSessions() throws Exception assumeProbeEnabled(); final Context context = context(); final String videoId = "composite-session-key-probe"; - final YoutubeSabrFormat audio = format(AUDIO_ITAG, true); - final YoutubeSabrFormat video = format(VIDEO_ITAG, false); + final YoutubeSabrInfo.Format audio = format(AUDIO_ITAG, true); + final YoutubeSabrInfo.Format video = format(VIDEO_ITAG, false); final YoutubeSabrInfo info = info(videoId, audio, video); final SabrSourceSpec firstSpec = spec(videoId, info, audio, video); final SabrSourceSpec secondSpec = spec(videoId, info, audio, video); @@ -330,15 +329,15 @@ private static MediaItem mediaItem(final String videoId) { } private static SabrSourceSpec spec(final String videoId) throws Exception { - final YoutubeSabrFormat audio = format(AUDIO_ITAG, true); - final YoutubeSabrFormat video = format(VIDEO_ITAG, false); + final YoutubeSabrInfo.Format audio = format(AUDIO_ITAG, true); + final YoutubeSabrInfo.Format video = format(VIDEO_ITAG, false); return spec(videoId, info(videoId, audio, video), audio, video); } private static SabrSourceSpec spec(final String videoId, final YoutubeSabrInfo info, - final YoutubeSabrFormat audio, - final YoutubeSabrFormat video) { + final YoutubeSabrInfo.Format audio, + final YoutubeSabrInfo.Format video) { return new SabrSourceSpec(videoId, info, audio, video, new Localization("en", "US"), AUDIO_INIT, VIDEO_INIT); } @@ -356,11 +355,11 @@ private static SabrSessionStore.Holder holder(final Context context, private static YoutubeSabrSession session(final Context context, final String videoId, final YoutubeSabrInfo info, - final YoutubeSabrFormat audio, - final YoutubeSabrFormat video) { + final YoutubeSabrInfo.Format audio, + final YoutubeSabrInfo.Format video) { final File spoolDirectory = new File(context.getCacheDir(), "sabr-lease-probe-" + videoId + '-' + System.nanoTime()); - return new YoutubeSabrSession(info, audio, video, null, spoolDirectory); + return new YoutubeSabrSession(info, audio, video, spoolDirectory); } private static boolean sessionCacheClosed(final YoutubeSabrSession session) throws Exception { @@ -409,21 +408,18 @@ private static AtomicInteger leaseReferences(final SabrSessionStore.Holder holde return (AtomicInteger) field.get(holder); } - private static YoutubeSabrFormat format(final int itag, final boolean audio) + private static YoutubeSabrInfo.Format format(final int itag, final boolean audio) throws Exception { - final Constructor constructor = - YoutubeSabrFormat.class.getDeclaredConstructor(int.class, long.class, - String.class, String.class, String.class, String.class, boolean.class, - String.class, String.class, boolean.class, int.class, int.class, - int.class, long.class, long.class, String.class, long.class, long.class); - constructor.setAccessible(true); - return constructor.newInstance(itag, 123456L, null, + final ItagItem parsedFormat = ItagItem.getItag(itag); + parsedFormat.setWidth(audio ? -1 : 1920); + parsedFormat.setHeight(audio ? -1 : 1080); + parsedFormat.setBitrate(audio ? 128_000 : 2_000_000); + parsedFormat.setContentLength(100_000L); + parsedFormat.setApproxDurationMs(300_000L); + return YoutubeSabrInfo.Format.fromParsedFormat(parsedFormat, 123456L, null, audio ? "audio/mp4" : "video/mp4", - audio ? "audio-track" : null, audio ? "Original" : null, audio, - audio ? null : "1080p", audio ? "AUDIO_QUALITY_MEDIUM" : null, false, - audio ? -1 : 1920, audio ? -1 : 1080, - audio ? 128_000 : 2_000_000, 100_000L, 300_000L, - null, -1L, -1L); + audio ? "audio-track" : null, audio ? "Original" : null, + false, null, -1L, -1L); } private static byte[] mp4Sidx(final int... durationsMs) { @@ -447,13 +443,13 @@ private static byte[] mp4Sidx(final int... durationsMs) { } private static YoutubeSabrInfo info(final String videoId, - final YoutubeSabrFormat... formats) throws Exception { + final YoutubeSabrInfo.Format... formats) throws Exception { final Constructor constructor = - YoutubeSabrInfo.class.getDeclaredConstructor(YoutubeSabrClientProfile.class, + YoutubeSabrInfo.class.getDeclaredConstructor( String.class, String.class, String.class, String.class, String.class, String.class, java.util.List.class); constructor.setAccessible(true); - return constructor.newInstance(YoutubeSabrClientProfile.MWEB, videoId, "cpn", + return constructor.newInstance(videoId, "cpn", "2.20250122.04.00", "visitor", "https://sabr.test", null, Arrays.asList(formats)); } diff --git a/app/src/androidTest/java/org/schabi/newpipe/player/datasource/YoutubeCredentialIdentityTest.kt b/app/src/androidTest/java/org/schabi/newpipe/player/datasource/YoutubeCredentialIdentityTest.kt deleted file mode 100644 index ec3761179..000000000 --- a/app/src/androidTest/java/org/schabi/newpipe/player/datasource/YoutubeCredentialIdentityTest.kt +++ /dev/null @@ -1,58 +0,0 @@ -package org.schabi.newpipe.player.datasource - -import android.content.Context -import androidx.test.platform.app.InstrumentationRegistry -import org.schabi.newpipe.extractor.ServiceList -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotEquals -import org.junit.Test - -class YoutubeCredentialIdentityTest { - @Test - fun accountSwitchWithoutProviderCallDuringLogoutInvalidatesState() { - val context = InstrumentationRegistry.getInstrumentation().targetContext - val prefs = context.getSharedPreferences( - "sabr_local_dom_video_token_cache", - Context.MODE_PRIVATE, - ) - val originalTokens = ServiceList.YouTube.getTokens() - prefs.edit().clear().commit() - try { - ServiceList.YouTube.setTokens("account-a-cookie") - val provider = LocalDomPoTokenProvider(context) - provider.hasCachedToken("missing-video") - prefs.edit().putString("account-a-token", "sentinel").commit() - - // No provider call observes the logged-out state before account B logs in. - ServiceList.YouTube.setTokens("") - ServiceList.YouTube.setTokens("account-b-cookie") - provider.hasCachedToken("missing-video") - - assertFalse(prefs.contains("account-a-token")) - } finally { - ServiceList.YouTube.setTokens(originalTokens) - prefs.edit().clear().commit() - } - } - - @Test - fun unchangedCredentialsDoNotInvalidateState() { - var invalidationCount = 0 - val tracker = CredentialIdentityTracker { invalidationCount++ } - val identity = youtubeCredentialIdentity(true, "same-cookie") - - tracker.observe(identity) - tracker.observe(identity) - - assertEquals(0, invalidationCount) - } - - @Test - fun loggedInIdentityDependsOnCredentialValue() { - assertNotEquals( - youtubeCredentialIdentity(true, "account-a-cookie"), - youtubeCredentialIdentity(true, "account-b-cookie"), - ) - } -} diff --git a/app/src/main/java/org/schabi/newpipe/App.java b/app/src/main/java/org/schabi/newpipe/App.java index 72e9ac073..0971f2750 100644 --- a/app/src/main/java/org/schabi/newpipe/App.java +++ b/app/src/main/java/org/schabi/newpipe/App.java @@ -23,12 +23,9 @@ import org.acra.config.CoreConfigurationBuilder; import org.schabi.newpipe.error.ReCaptchaActivity; import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.extractor.ServiceList; import org.schabi.newpipe.extractor.downloader.Downloader; import org.schabi.newpipe.extractor.services.youtube.YoutubeApiDecoder; -import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper; import org.schabi.newpipe.ktx.ExceptionUtils; -import org.schabi.newpipe.player.datasource.LocalDomPoTokenProvider; import org.schabi.newpipe.settings.NewPipeSettings; import org.schabi.newpipe.util.*; @@ -38,7 +35,6 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; -import java.util.concurrent.Callable; import io.reactivex.rxjava3.exceptions.CompositeException; import io.reactivex.rxjava3.exceptions.MissingBackpressureException; @@ -71,9 +67,6 @@ public class App extends MultiDexApplication { public static final String PACKAGE_NAME = BuildConfig.APPLICATION_ID; private static final String TAG = App.class.toString(); - private static final String YOUTUBE_WEB_CLIENT_NAME = "WEB"; - private static final String YOUTUBE_MWEB_CLIENT_NAME = "MWEB"; - private static final String YOUTUBE_ANDROID_VR_CLIENT_NAME = "ANDROID_VR"; private static App app; private CarConnectionStateReceiver carConnectionReceiver; @@ -131,9 +124,6 @@ public void onChanged(Integer connectionState) { NewPipe.init(getDownloader(), Localization.getPreferredLocalization(this), Localization.getPreferredContentCountry(this)); - final LocalDomPoTokenProvider sessionPoTokenProvider = - LocalDomPoTokenProvider.shared(this); - NewPipe.setYoutubeSessionPoTokenProvider(sessionPoTokenProvider); final AndroidWebViewAvailabilityChecker webViewAvailabilityChecker = new AndroidWebViewAvailabilityChecker(this); NewPipe.setWebViewAvailabilityChecker(webViewAvailabilityChecker); @@ -152,7 +142,6 @@ public void onChanged(Integer connectionState) { // Initialize image loader final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); reconcileYoutubePlayerClient(this); - prewarmYoutubeSessionPoToken(this); PicassoHelper.init(this); PicassoHelper.setShouldLoadImages( prefs.getBoolean(getString(R.string.download_thumbnail_key), true)); @@ -223,63 +212,6 @@ public void onActivityDestroyed(@NonNull final Activity activity) { }); } - public static void prewarmYoutubeSessionPoToken(@NonNull final Context context) { - final LocalDomPoTokenProvider provider = LocalDomPoTokenProvider.shared(context); - provider.cancelSessionPoTokenPrewarm(); - try { - final YoutubePoTokenClientContext client = resolveYoutubePoTokenClientContext( - NewPipe.getYoutubePlayerClient()); - if (client == null) { - return; - } - provider.prewarmSessionPoToken(client.clientName, client.userAgent, - YoutubeParsingHelper.getPlayerRequestLocalization(), - ServiceList.YouTube.getContentCountry(), ServiceList.YouTube.hasTokens(), - client.clientVersionResolver); - } catch (final RuntimeException e) { - Log.w(TAG, "Could not schedule YouTube session PO token prewarm", e); - } - } - - private static YoutubePoTokenClientContext resolveYoutubePoTokenClientContext( - @NonNull final String selectedClient) { - switch (selectedClient) { - case "mweb": - return new YoutubePoTokenClientContext(YOUTUBE_MWEB_CLIENT_NAME, - YoutubeParsingHelper::getClientVersion, - YoutubeParsingHelper.MWEB_USER_AGENT); - case "web": - return new YoutubePoTokenClientContext(YOUTUBE_WEB_CLIENT_NAME, - YoutubeParsingHelper::getClientVersion, - YoutubeParsingHelper.WEB_USER_AGENT); - case "android_vr": - return new YoutubePoTokenClientContext(YOUTUBE_ANDROID_VR_CLIENT_NAME, - () -> "1.65.10", - "com.google.android.apps.youtube.vr.oculus/1.65.10 " - + "(Linux; U; Android 12L; eureka-user " - + "Build/SQ3A.220605.009.A1) gzip"); - case "tv_simply": - return new YoutubePoTokenClientContext("TVHTML5_SIMPLY", () -> "1.0", - YoutubeParsingHelper.WEB_USER_AGENT); - default: - return null; - } - } - - private static final class YoutubePoTokenClientContext { - @NonNull private final String clientName; - @NonNull private final Callable clientVersionResolver; - @NonNull private final String userAgent; - - private YoutubePoTokenClientContext(@NonNull final String clientName, - @NonNull final Callable clientVersionResolver, - @NonNull final String userAgent) { - this.clientName = clientName; - this.clientVersionResolver = clientVersionResolver; - this.userAgent = userAgent; - } - } - @Override public void onTerminate() { super.onTerminate(); diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/LocalDomPoTokenGenerator.kt b/app/src/main/java/org/schabi/newpipe/player/datasource/LocalDomPoTokenGenerator.kt deleted file mode 100644 index fab892369..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/LocalDomPoTokenGenerator.kt +++ /dev/null @@ -1,347 +0,0 @@ -package org.schabi.newpipe.player.datasource - -import android.content.Context -import org.schabi.newpipe.DownloaderImpl -import org.schabi.newpipe.SharedWebViewRuntime -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrProtocolException -import java.io.Closeable -import java.time.Instant -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicReference - -internal class LocalDomPoTokenGenerator private constructor( - context: Context, - private val initialization: InitWaiter, - private val attestationContext: LocalDomPoTokenContext, - private val credentialHeaders: Map>, -) : Closeable { - private val appContext = context.applicationContext - private val runtime = SharedWebViewRuntime.get(appContext) - private val sessionId = runtime.registerSabrLocalDomCallbacks(Callbacks()) - private val tokenWaiters = mutableMapOf() - private lateinit var expirationInstant: Instant - @Volatile - private var closed = false - - private fun loadScriptAndInitialize() { - try { - runtime.ensureReady(INIT_TIMEOUT_MS, "Local DOM PO token initialization") - runtime.evaluateJavascriptBlocking( - runtime.loadAsset(ASSET) + "\ntrue", - INIT_TIMEOUT_MS, - "Local DOM BotGuard helper injection", - ) - downloadAndRunBotguard() - } catch (error: Throwable) { - failInitialization(error) - } - } - - @Synchronized - @Throws(SabrProtocolException::class) - fun generateRawPoToken(identifier: String): ByteArray { - if (closed) { - throw SabrProtocolException("Local DOM PO token generator is closed") - } - val waiter = TokenWaiter() - synchronized(tokenWaiters) { - tokenWaiters[identifier] = waiter - } - val u8Identifier = stringToSabrU8(identifier) - val posted = runtime.evaluateJavascript( - "pipepipeSabrObtainPoToken(" + jsString(sessionId) + ", " - + jsString(identifier) + ", " + u8Identifier + ");", - null, - ) { error -> onTokenError(identifier, error) } - if (!posted) { - synchronized(tokenWaiters) { - tokenWaiters.remove(identifier) - } - throw SabrProtocolException("Could not post Local DOM PO token generation") - } - try { - if (!waiter.latch.await(TOKEN_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { - synchronized(tokenWaiters) { - tokenWaiters.remove(identifier) - } - throw SabrProtocolException("Local DOM PO token generation timed out") - } - } catch (error: InterruptedException) { - Thread.currentThread().interrupt() - synchronized(tokenWaiters) { - tokenWaiters.remove(identifier) - } - throw SabrProtocolException("Local DOM PO token generation interrupted", error) - } - waiter.error.get()?.let { - throw SabrProtocolException("Local DOM PO token generation failed: ${it.message}", it) - } - val token = waiter.token.get() - if (token == null || token.isEmpty()) { - throw SabrProtocolException("Local DOM PO token generation returned no token") - } - return token - } - - fun isExpired(): Boolean { - return !::expirationInstant.isInitialized || Instant.now().isAfter(expirationInstant) - } - - override fun close() { - closed = true - runtime.unregisterSabrLocalDomCallbacks(sessionId) - synchronized(tokenWaiters) { - tokenWaiters.values.forEach { - it.error.set(SabrProtocolException("Local DOM PO token generator closed")) - it.latch.countDown() - } - tokenWaiters.clear() - } - runtime.evaluateJavascript( - "pipepipeSabrDeleteSession(" + jsString(sessionId) + ");", - null, - null, - ) - } - - private fun makeBotguardServiceRequest( - url: String, - data: String, - contentType: String = "application/json+protobuf", - extraHeaders: Map> = emptyMap(), - onSuccess: (String) -> Unit, - onError: (Throwable) -> Unit, - ) { - Thread({ - try { - val downloader = DownloaderImpl.getInstance() - ?: throw SabrProtocolException("DownloaderImpl is not initialized") - val response = downloader.post( - url, - mapOf( - "User-Agent" to listOf(SharedWebViewRuntime.USER_AGENT), - "Accept" to listOf("application/json"), - "Content-Type" to listOf(contentType), - "x-goog-api-key" to listOf(LOCAL_DOM_GOOGLE_API_KEY), - "x-user-agent" to listOf("grpc-web-javascript/0.1"), - ) + extraHeaders, - data.toByteArray(), - ) - if (response.responseCode() != 200) { - throw SabrProtocolException( - "Local DOM BotGuard request failed: ${response.responseCode()}", - ) - } - onSuccess(response.responseBody()) - } catch (error: Throwable) { - onError(error) - } - }, "SabrLocalDomPoTokenJnn").start() - } - - private fun makeBotguardGetRequest( - url: String, - onSuccess: (String) -> Unit, - onError: (Throwable) -> Unit, - ) { - Thread({ - try { - val downloader = DownloaderImpl.getInstance() - ?: throw SabrProtocolException("DownloaderImpl is not initialized") - val response = downloader.get( - url, - mapOf( - "User-Agent" to listOf(SharedWebViewRuntime.USER_AGENT), - "Accept" to listOf("*/*"), - ), - ) - if (response.responseCode() != 200) { - throw SabrProtocolException( - "Local DOM BotGuard GET failed: ${response.responseCode()}", - ) - } - onSuccess(response.responseBody()) - } catch (error: Throwable) { - onError(error) - } - }, "SabrLocalDomPoTokenJnnGet").start() - } - - private fun failInitialization(error: Throwable) { - initialization.error.compareAndSet(null, error) - initialization.latch.countDown() - close() - } - - private fun completeInitialization() { - initialization.generator.compareAndSet(null, this) - initialization.latch.countDown() - } - - private fun onTokenResult(identifier: String, poTokenU8: String) { - val waiter = synchronized(tokenWaiters) { - tokenWaiters.remove(identifier) - } ?: return - try { - waiter.token.set(csvU8ToByteArray(poTokenU8)) - } catch (error: Throwable) { - waiter.error.set(error) - } finally { - waiter.latch.countDown() - } - } - - private fun onTokenError(identifier: String, error: Throwable) { - val waiter = synchronized(tokenWaiters) { - tokenWaiters.remove(identifier) - } ?: return - waiter.error.set(error) - waiter.latch.countDown() - } - - private fun downloadAndRunBotguard() { - makeBotguardServiceRequest( - "https://www.youtube.com/youtubei/v1/att/get?prettyPrint=false", - buildLocalDomAttestationBody(attestationContext), - contentType = "application/json", - extraHeaders = buildLocalDomAttestationHeaders( - attestationContext, - credentialHeaders, - ), - onSuccess = { body -> - try { - val challenge = parseSabrAttChallengeData(body) - val inlineInterpreter = challenge.interpreterJavascript - if (inlineInterpreter != null) { - runBotguard(challenge, inlineInterpreter) - } else { - makeBotguardGetRequest( - requireNotNull(challenge.interpreterUrl), - onSuccess = { runBotguard(challenge, it) }, - onError = ::failInitialization, - ) - } - } catch (error: Throwable) { - failInitialization(error) - } - }, - onError = ::failInitialization, - ) - } - - private fun runBotguard( - challenge: SabrAttChallengeData, - interpreterJavascript: String, - ) { - runtime.evaluateJavascript( - "pipepipeSabrRunBotguard(" + jsString(sessionId) + ", " - + buildSabrAttChallengeData(challenge, interpreterJavascript) + ");", - null, - ) { error -> failInitialization(error) } - } - - private fun onRunBotguardResult(botguardResponse: String) { - makeBotguardServiceRequest( - "https://jnn-pa.googleapis.com/\$rpc/google.internal.waa.v1.Waa/GenerateIT", - "[ \"$REQUEST_KEY\", \"$botguardResponse\" ]", - onSuccess = { body -> - try { - val (integrityToken, expirationSeconds) = parseSabrIntegrityTokenData(body) - expirationInstant = Instant.now().plusSeconds(expirationSeconds - 600) - runtime.evaluateJavascript( - "pipepipeSabrCreateMinter(" + jsString(sessionId) + ", " - + integrityToken + ");", - null, - ) { error -> failInitialization(error) } - } catch (error: Throwable) { - failInitialization(error) - } - }, - onError = ::failInitialization, - ) - } - - private inner class Callbacks : SharedWebViewRuntime.SabrLocalDomCallbacks { - override fun onJsInitializationError(error: String) { - failInitialization(SabrProtocolException(error)) - } - - override fun onRunBotguardResult(botguardResponse: String) { - this@LocalDomPoTokenGenerator.onRunBotguardResult(botguardResponse) - } - - override fun onMinterReady() { - completeInitialization() - } - - override fun onObtainPoTokenResult(identifier: String, poTokenU8: String) { - onTokenResult(identifier, poTokenU8) - } - - override fun onObtainPoTokenError(identifier: String, error: String) { - onTokenError(identifier, SabrProtocolException(error)) - } - } - - private class TokenWaiter { - val latch = CountDownLatch(1) - val token = AtomicReference() - val error = AtomicReference() - } - - private class InitWaiter { - val latch = CountDownLatch(1) - val generator = AtomicReference() - val error = AtomicReference() - } - - companion object { - private const val ASSET = "sabr_po_token.js" - private const val TOKEN_TIMEOUT_MS = 30_000L - private const val INIT_TIMEOUT_MS = 60_000L - private const val REQUEST_KEY = "O43z0dpjhgX20SCx4KAo" - - @Throws(SabrProtocolException::class) - fun create( - context: Context, - attestationContext: LocalDomPoTokenContext, - credentialHeaders: Map>, - ): LocalDomPoTokenGenerator { - val init = InitWaiter() - val generator = LocalDomPoTokenGenerator( - context, - init, - attestationContext, - credentialHeaders, - ) - generator.loadScriptAndInitialize() - try { - if (!init.latch.await(INIT_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { - generator.close() - throw SabrProtocolException("Local DOM PO token initialization timed out") - } - } catch (error: InterruptedException) { - Thread.currentThread().interrupt() - generator.close() - throw SabrProtocolException("Local DOM PO token initialization interrupted", error) - } - init.error.get()?.let { - throw SabrProtocolException( - "Local DOM PO token initialization failed: ${it.message}", - it, - ) - } - return init.generator.get() - ?: throw SabrProtocolException("Local DOM PO token initialization returned no result") - } - - private fun jsString(value: String): String { - return "\"" + value - .replace("\\", "\\\\") - .replace("\"", "\\\"") - .replace("\n", "\\n") - .replace("\r", "\\r") + "\"" - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/LocalDomPoTokenProvider.kt b/app/src/main/java/org/schabi/newpipe/player/datasource/LocalDomPoTokenProvider.kt index 78bb453b7..7f4f84690 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/LocalDomPoTokenProvider.kt +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/LocalDomPoTokenProvider.kt @@ -1,554 +1,417 @@ package org.schabi.newpipe.player.datasource import android.content.Context -import android.os.Handler -import android.os.Looper -import android.os.SystemClock -import android.util.Log +import org.schabi.newpipe.DownloaderImpl +import org.schabi.newpipe.SharedWebViewRuntime import org.schabi.newpipe.extractor.ServiceList -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrPoTokenProvider -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrProtocolException +import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper +import org.schabi.newpipe.extractor.services.youtube.sabr.exception.SabrProtocolException import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrStreamState -import org.schabi.newpipe.extractor.localization.ContentCountry -import org.schabi.newpipe.extractor.localization.Localization -import org.schabi.newpipe.extractor.services.youtube.InnertubeClientRequestInfo -import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper -import org.schabi.newpipe.extractor.services.youtube.YoutubeSessionPoToken -import org.schabi.newpipe.extractor.services.youtube.YoutubeSessionPoTokenProvider -import java.nio.charset.StandardCharsets -import java.security.MessageDigest -import java.util.Base64 +import java.io.Closeable import java.util.HashMap -import java.util.concurrent.CancellationException -import java.util.concurrent.Callable -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.ExecutionException -import java.util.concurrent.Executors -import java.util.concurrent.Future +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference -internal fun youtubeCredentialIdentity(loggedIn: Boolean, tokens: String?): String { - val digest = MessageDigest.getInstance("SHA-256") - digest.update(if (loggedIn) 1.toByte() else 0.toByte()) - if (loggedIn) { - digest.update(0.toByte()) - digest.update(tokens.orEmpty().toByteArray(StandardCharsets.UTF_8)) - } - return Base64.getUrlEncoder().withoutPadding().encodeToString(digest.digest()) -} +class LocalDomPoTokenProvider(context: Context) { + private val appContext = context.applicationContext -internal class CredentialIdentityTracker(private val onChanged: () -> Unit) { - private var observedIdentity: String? = null + fun getPoToken( + info: YoutubeSabrInfo, + streamState: YoutubeSabrStreamState, + ): ByteArray { + val visitorData = info.visitorData + ?: throw SabrProtocolException("Missing visitorData in YouTube player response") + val session = OneShotMintSession.create( + appContext, + visitorData, + YoutubeParsingHelper.getClientVersion(), + createCredentialHeaders(), + ) + return try { + session.mint(info.videoId) + } finally { + session.close() + } + } - @Synchronized - fun observe(identity: String) { - val previous = observedIdentity - if (previous != null && previous != identity) { - onChanged() + private fun createCredentialHeaders(): Map> { + return HashMap>().apply { + if (ServiceList.YouTube.hasTokens()) { + YoutubeParsingHelper.addLoggedInHeaders(this) + } else { + YoutubeParsingHelper.addCookieHeader(this) + } } - observedIdentity = identity } } -class LocalDomPoTokenProvider(context: Context) : - SabrPoTokenProvider, - YoutubeSessionPoTokenProvider { - private data class CachedToken( - val token: ByteArray, - val mintedAtMs: Long, - val visitorData: String, - val credentialIdentity: String, - val clientContextIdentity: String, - ) +private class OneShotMintSession private constructor( + context: Context, + private val initialization: InitWaiter, + private val visitorData: String, + private val clientVersion: String, + private val credentialHeaders: Map>, +) : Closeable { + private val runtime = SharedWebViewRuntime.get(context.applicationContext) + private val sessionId = runtime.registerSabrLocalDomCallbacks(Callbacks()) + private val tokenWaiters = mutableMapOf() + @Volatile + private var closed = false - private val appContext = context.applicationContext - private val prefs = appContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - private val cache = ConcurrentHashMap() - private val mintLocks = ConcurrentHashMap() - private val generatorLock = Any() - private val mainHandler = Handler(Looper.getMainLooper()) - private var generatorContext: LocalDomPoTokenContext? = null - private var generatorCredentialIdentity: String? = null - private var generator: LocalDomPoTokenGenerator? = null - private val visitorDataLock = Any() - private var fetchedVisitorData: String? = null - private var fetchedVisitorDataLoggedIn: Boolean? = null - private var fetchedVisitorDataCredentialIdentity: String? = null - private var visitorDataFetchedAtMs: Long = 0 - private val credentialIdentityTracker = CredentialIdentityTracker( - onChanged = ::invalidateCredentialBoundState, - ) - private val prewarmExecutor = Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "YoutubeSessionPoTokenPrewarm").apply { isDaemon = true } + private fun loadScriptAndInitialize() { + try { + runtime.ensureReady(INIT_TIMEOUT_MS, "Local DOM PO token initialization") + runtime.evaluateJavascriptBlocking( + runtime.loadAsset(ASSET) + "\ntrue", + INIT_TIMEOUT_MS, + "Local DOM BotGuard helper injection", + ) + downloadAndRunBotguard() + } catch (error: Throwable) { + failInitialization(error) + } } - private val sessionPoTokenPrewarmer = - ContextBoundSingleFlight< - YoutubeSessionPoTokenPrewarmContext, - PreparedYoutubeSessionPoToken - >( - prewarmExecutor, - ) - override fun getSessionPoToken( - clientName: String, - clientVersion: String, - userAgent: String?, - localization: Localization, - contentCountry: ContentCountry, - loggedIn: Boolean, - ): YoutubeSessionPoToken? { - if (clientName.isBlank() || clientVersion.isBlank() || userAgent.isNullOrBlank()) { - return null + @Synchronized + @Throws(SabrProtocolException::class) + fun mint(identifier: String): ByteArray { + if (closed) { + throw SabrProtocolException("Local DOM PO token session is closed") } - val credentialIdentity = currentCredentialIdentity(loggedIn) - credentialIdentityTracker.observe(credentialIdentity) - val requestContext = YoutubeSessionPoTokenContext( - clientName, - clientVersion, - userAgent, - localization, - contentCountry, - loggedIn, - credentialIdentity, - ) - sessionPoTokenPrewarmer.inFlight(requestContext.prewarmContext())?.let { - val prepared = awaitSessionPoTokenPrewarm(it) - if (prepared.context == requestContext) { - return prepared.token + val waiter = TokenWaiter() + synchronized(tokenWaiters) { + tokenWaiters[identifier] = waiter + } + val posted = runtime.evaluateJavascript( + "pipepipeSabrObtainPoToken(" + jsonString(sessionId) + ", " + + jsonString(identifier) + ", " + stringToSabrU8(identifier) + ");", + null, + ) { error -> onTokenError(identifier, error) } + if (!posted) { + synchronized(tokenWaiters) { + tokenWaiters.remove(identifier) } + throw SabrProtocolException("Could not post Local DOM PO token generation") } - return getSessionPoTokenNow(requestContext) + try { + if (!waiter.latch.await(TOKEN_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { + synchronized(tokenWaiters) { + tokenWaiters.remove(identifier) + } + throw SabrProtocolException("Local DOM PO token generation timed out") + } + } catch (error: InterruptedException) { + Thread.currentThread().interrupt() + synchronized(tokenWaiters) { + tokenWaiters.remove(identifier) + } + throw SabrProtocolException("Local DOM PO token generation interrupted", error) + } + waiter.error.get()?.let { + throw SabrProtocolException("Local DOM PO token generation failed: ${it.message}", it) + } + val token = waiter.token.get() + if (token == null || token.isEmpty()) { + throw SabrProtocolException("Local DOM PO token generation returned no token") + } + return token } - fun prewarmSessionPoToken( - clientName: String, - userAgent: String?, - localization: Localization, - contentCountry: ContentCountry, - loggedIn: Boolean, - clientVersionResolver: Callable, - ) { - val credentialIdentity = currentCredentialIdentity(loggedIn) - credentialIdentityTracker.observe(credentialIdentity) - val prewarmContext = YoutubeSessionPoTokenPrewarmContext( - clientName, - userAgent, - localization, - contentCountry, - loggedIn, - credentialIdentity, + override fun close() { + closed = true + runtime.unregisterSabrLocalDomCallbacks(sessionId) + synchronized(tokenWaiters) { + tokenWaiters.values.forEach { + it.error.set(SabrProtocolException("Local DOM PO token session closed")) + it.latch.countDown() + } + tokenWaiters.clear() + } + runtime.evaluateJavascript( + "pipepipeSabrDeleteSession(" + jsonString(sessionId) + ");", + null, + null, ) - sessionPoTokenPrewarmer.start(prewarmContext) { - val startedAtMs = SystemClock.elapsedRealtime() + } + + private fun makeBotguardServiceRequest( + url: String, + data: String, + contentType: String = "application/json+protobuf", + extraHeaders: Map> = emptyMap(), + onSuccess: (String) -> Unit, + onError: (Throwable) -> Unit, + ) { + Thread({ try { - val requestContext = YoutubeSessionPoTokenContext( - clientName, - clientVersionResolver.call(), - userAgent, - localization, - contentCountry, - loggedIn, - credentialIdentity, + val downloader = DownloaderImpl.getInstance() + ?: throw SabrProtocolException("DownloaderImpl is not initialized") + val response = downloader.post( + url, + mapOf( + "User-Agent" to listOf(SharedWebViewRuntime.USER_AGENT), + "Accept" to listOf("application/json"), + "Content-Type" to listOf(contentType), + "x-goog-api-key" to listOf(LOCAL_DOM_GOOGLE_API_KEY), + "x-user-agent" to listOf("grpc-web-javascript/0.1"), + ) + extraHeaders, + data.toByteArray(), ) - PreparedYoutubeSessionPoToken( - requestContext, - getSessionPoTokenNow(requestContext), - ).also { - Log.i( - TAG, - "session token prewarm ready client=$clientName in " + - "${SystemClock.elapsedRealtime() - startedAtMs}ms", + if (response.responseCode() != 200) { + throw SabrProtocolException( + "Local DOM BotGuard request failed: ${response.responseCode()}", ) } + onSuccess(response.responseBody()) } catch (error: Throwable) { - Log.w(TAG, "session token prewarm failed client=$clientName", error) - throw error + onError(error) } - } + }, "SabrLocalDomPoTokenJnn").start() + } + + private fun makeBotguardGetRequest( + url: String, + onSuccess: (String) -> Unit, + onError: (Throwable) -> Unit, + ) { + Thread({ + try { + val downloader = DownloaderImpl.getInstance() + ?: throw SabrProtocolException("DownloaderImpl is not initialized") + val response = downloader.get( + url, + mapOf( + "User-Agent" to listOf(SharedWebViewRuntime.USER_AGENT), + "Accept" to listOf("*/*"), + ), + ) + if (response.responseCode() != 200) { + throw SabrProtocolException( + "Local DOM BotGuard GET failed: ${response.responseCode()}", + ) + } + onSuccess(response.responseBody()) + } catch (error: Throwable) { + onError(error) + } + }, "SabrLocalDomPoTokenJnnGet").start() + } + + private fun failInitialization(error: Throwable) { + initialization.error.compareAndSet(null, error) + initialization.latch.countDown() + close() } - fun cancelSessionPoTokenPrewarm() { - sessionPoTokenPrewarmer.cancel() + private fun completeInitialization() { + initialization.session.compareAndSet(null, this) + initialization.latch.countDown() } - private fun awaitSessionPoTokenPrewarm( - prewarm: Future, - ): PreparedYoutubeSessionPoToken { + private fun onTokenResult(identifier: String, poTokenU8: String) { + val waiter = synchronized(tokenWaiters) { + tokenWaiters.remove(identifier) + } ?: return try { - return prewarm.get() - } catch (error: InterruptedException) { - Thread.currentThread().interrupt() - throw SabrProtocolException( - "Interrupted waiting for session PO token prewarm", - error, - ) - } catch (error: CancellationException) { - throw SabrProtocolException("Session PO token prewarm was invalidated", error) - } catch (error: ExecutionException) { - throw SabrProtocolException( - "Session PO token prewarm failed", - error.cause ?: error, - ) + waiter.token.set(csvU8ToByteArray(poTokenU8)) + } catch (error: Throwable) { + waiter.error.set(error) + } finally { + waiter.latch.countDown() } } - private fun getSessionPoTokenNow( - requestContext: YoutubeSessionPoTokenContext, - ): YoutubeSessionPoToken { - if (!credentialsStillMatch(requestContext.credentialIdentity)) { - throw SabrProtocolException( - "YouTube credentials changed before session PO token initialization", - ) - } - val visitorData = getOrFetchVisitorData( - requestContext.localization, - requestContext.contentCountry, - requestContext.loggedIn, - requestContext.credentialIdentity, - ) - val playerContext = createPoTokenContext( - visitorData, - requestContext.clientName, - requestContext.clientVersion, - requestContext.userAgent, - ) - val attestationContext = localDomAttestationContext( - visitorData, - YoutubeParsingHelper.getClientVersion(), - ) - val credentialHeaders = createCredentialHeaders(requestContext.loggedIn) - val rawToken = getOrMintToken( - visitorData, - attestationContext, - requestContext.credentialIdentity, - playerContext.cacheIdentity + ':' + attestationContext.cacheIdentity, - credentialHeaders, - ) - val encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(rawToken) - Log.i( - TAG, - "session token ready client=${requestContext.clientName} " + - "loggedIn=${requestContext.loggedIn} bytes=${rawToken.size}", - ) - return YoutubeSessionPoToken(visitorData, encoded) + private fun onTokenError(identifier: String, error: Throwable) { + val waiter = synchronized(tokenWaiters) { + tokenWaiters.remove(identifier) + } ?: return + waiter.error.set(error) + waiter.latch.countDown() } - override fun getPoToken( - info: YoutubeSabrInfo, - streamState: YoutubeSabrStreamState, - ): ByteArray? { - val credentialIdentity = currentCredentialIdentity(ServiceList.YouTube.hasTokens()) - credentialIdentityTracker.observe(credentialIdentity) - val videoId = info.videoId - val visitorData = info.visitorData ?: synchronized(visitorDataLock) { - fetchedVisitorData - } ?: throw SabrProtocolException("Missing visitorData for Local DOM PO token") - val playerContext = createPoTokenContext( - visitorData, - info.profile.clientName, - info.clientVersion, - info.profile.userAgent, - ) - val loggedIn = ServiceList.YouTube.hasTokens() - val attestationContext = localDomAttestationContext( - visitorData, - YoutubeParsingHelper.getClientVersion(), - ) - return getOrMintToken( - videoId, - attestationContext, - credentialIdentity, - playerContext.cacheIdentity + ':' + attestationContext.cacheIdentity, - createCredentialHeaders(loggedIn), + private fun downloadAndRunBotguard() { + makeBotguardServiceRequest( + "https://www.youtube.com/youtubei/v1/att/get?prettyPrint=false", + buildAttestationBody(visitorData, clientVersion), + contentType = "application/json", + extraHeaders = buildAttestationHeaders( + visitorData, + clientVersion, + credentialHeaders, + ), + onSuccess = { body -> + try { + val challenge = parseSabrAttChallengeData(body) + val inlineInterpreter = challenge.interpreterJavascript + if (inlineInterpreter != null) { + runBotguard(challenge, inlineInterpreter) + } else { + makeBotguardGetRequest( + requireNotNull(challenge.interpreterUrl), + onSuccess = { runBotguard(challenge, it) }, + onError = ::failInitialization, + ) + } + } catch (error: Throwable) { + failInitialization(error) + } + }, + onError = ::failInitialization, ) } - override fun invalidatePoTokenIdentity(info: YoutubeSabrInfo): Boolean { - sessionPoTokenPrewarmer.cancel() - synchronized(visitorDataLock) { - fetchedVisitorData = null - fetchedVisitorDataLoggedIn = null - fetchedVisitorDataCredentialIdentity = null - visitorDataFetchedAtMs = 0 - } - synchronized(generatorLock) { - generator?.let { mainHandler.post { it.close() } } - generator = null - generatorContext = null - generatorCredentialIdentity = null - } - cache.clear() - prefs.edit().clear().commit() - Log.i(TAG, "rotated pending attestation identity video=${info.videoId}") - return true + private fun runBotguard( + challenge: SabrAttChallengeData, + interpreterJavascript: String, + ) { + runtime.evaluateJavascript( + "pipepipeSabrRunBotguard(" + jsonString(sessionId) + ", " + + buildSabrAttChallengeData(challenge, interpreterJavascript) + ");", + null, + ) { error -> failInitialization(error) } } - private fun getOrMintToken( - contentBinding: String, - context: LocalDomPoTokenContext, - credentialIdentity: String, - clientContextIdentity: String, - credentialHeaders: Map>, - ): ByteArray { - synchronized(mintLocks.computeIfAbsent(contentBinding) { Any() }) { - val now = System.currentTimeMillis() - val cached = cache[contentBinding] - ?: diskLoad(contentBinding)?.also { cache[contentBinding] = it } - if (cached != null && cached.visitorData == context.visitorData && - cached.credentialIdentity == credentialIdentity && - cached.clientContextIdentity == clientContextIdentity && - now - cached.mintedAtMs < TOKEN_TTL_MS - ) { - Log.i(TAG, "cache hit bindingBytes=${contentBinding.length} " + - "bytes=${cached.token.size}") - return cached.token.clone() - } - val token = synchronized(generatorLock) { - ensureGenerator(context, credentialIdentity, credentialHeaders) - .generateRawPoToken(contentBinding) - } - cache[contentBinding] = CachedToken( - token, - now, - context.visitorData, - credentialIdentity, - clientContextIdentity, - ) - diskSave( - contentBinding, - token, - now, - context.visitorData, - credentialIdentity, - clientContextIdentity, - ) - Log.i(TAG, "mint complete bindingBytes=${contentBinding.length} bytes=${token.size}") - return token.clone() - } + private fun onRunBotguardResult(botguardResponse: String) { + makeBotguardServiceRequest( + "https://jnn-pa.googleapis.com/\$rpc/google.internal.waa.v1.Waa/GenerateIT", + "[ \"$REQUEST_KEY\", \"$botguardResponse\" ]", + onSuccess = { body -> + try { + val integrityToken = parseSabrIntegrityTokenData(body).first + runtime.evaluateJavascript( + "pipepipeSabrCreateMinter(" + jsonString(sessionId) + ", " + + integrityToken + ");", + null, + ) { error -> failInitialization(error) } + } catch (error: Throwable) { + failInitialization(error) + } + }, + onError = ::failInitialization, + ) } - fun hasCachedToken(videoId: String): Boolean { - val credentialIdentity = currentCredentialIdentity(ServiceList.YouTube.hasTokens()) - credentialIdentityTracker.observe(credentialIdentity) - val mem = cache[videoId] - if (mem != null && mem.credentialIdentity == credentialIdentity && - System.currentTimeMillis() - mem.mintedAtMs < TOKEN_TTL_MS - ) { - return true + private inner class Callbacks : SharedWebViewRuntime.SabrLocalDomCallbacks { + override fun onJsInitializationError(error: String) { + failInitialization(SabrProtocolException(error)) } - return diskLoad(videoId)?.credentialIdentity == credentialIdentity - } - fun clearCachedToken(videoId: String) { - synchronized(mintLocks.computeIfAbsent(videoId) { Any() }) { - cache.remove(videoId) - prefs.edit().remove(videoId).commit() + override fun onRunBotguardResult(botguardResponse: String) { + this@OneShotMintSession.onRunBotguardResult(botguardResponse) } - } - private fun ensureGenerator( - context: LocalDomPoTokenContext, - credentialIdentity: String, - credentialHeaders: Map>, - ): LocalDomPoTokenGenerator { - synchronized(generatorLock) { - val current = generator - if (current != null && !current.isExpired() && - generatorContext == context && - generatorCredentialIdentity == credentialIdentity - ) { - return current - } - if (!credentialsStillMatch(credentialIdentity)) { - throw SabrProtocolException( - "YouTube credentials changed before PO token generator initialization", - ) - } - current?.let { mainHandler.post { it.close() } } - val fresh = LocalDomPoTokenGenerator.create( - appContext, - context, - credentialHeaders, - ) - if (!credentialsStillMatch(credentialIdentity)) { - mainHandler.post { fresh.close() } - throw SabrProtocolException( - "YouTube credentials changed during PO token generator initialization", - ) - } - generator = fresh - generatorContext = context - generatorCredentialIdentity = credentialIdentity - return fresh + override fun onMinterReady() { + completeInitialization() } - } - private fun createPoTokenContext( - visitorData: String, - clientName: String, - clientVersion: String, - userAgent: String?, - ): LocalDomPoTokenContext { - if (clientName.isBlank() || clientVersion.isBlank() || userAgent.isNullOrBlank()) { - throw SabrProtocolException("Missing YouTube client context for Local DOM PO token") + override fun onObtainPoTokenResult(identifier: String, poTokenU8: String) { + onTokenResult(identifier, poTokenU8) } - return LocalDomPoTokenContext(visitorData, clientName, clientVersion, userAgent) - } - private fun createCredentialHeaders(loggedIn: Boolean): Map> { - val headers = HashMap>() - if (loggedIn) { - YoutubeParsingHelper.addLoggedInHeaders(headers) - } else { - YoutubeParsingHelper.addCookieHeader(headers) + override fun onObtainPoTokenError(identifier: String, error: String) { + onTokenError(identifier, SabrProtocolException(error)) } - return headers } - private fun getOrFetchVisitorData( - localization: Localization, - contentCountry: ContentCountry, - loggedIn: Boolean, - credentialIdentity: String, - ): String { - synchronized(visitorDataLock) { - val now = System.currentTimeMillis() - val cached = fetchedVisitorData - if (cached != null && fetchedVisitorDataLoggedIn == loggedIn && - fetchedVisitorDataCredentialIdentity == credentialIdentity && - now - visitorDataFetchedAtMs < VISITOR_DATA_TTL_MS - ) { - return cached - } - - val headers = HashMap>() - YoutubeParsingHelper.addYoutubeHeaders(headers) - headers["Content-Type"] = listOf("application/json") - if (loggedIn) { - YoutubeParsingHelper.addLoggedInHeaders(headers) - } - val fresh = YoutubeParsingHelper.getVisitorDataFromInnertube( - InnertubeClientRequestInfo.ofWebClient(), - localization, - contentCountry, - headers, - YoutubeParsingHelper.YOUTUBEI_V1_URL, - null, - false, - ) - if (!credentialsStillMatch(credentialIdentity)) { - throw SabrProtocolException( - "YouTube credentials changed while fetching visitorData", - ) - } - fetchedVisitorData = fresh - fetchedVisitorDataLoggedIn = loggedIn - fetchedVisitorDataCredentialIdentity = credentialIdentity - visitorDataFetchedAtMs = now - return fresh - } + private class TokenWaiter { + val latch = CountDownLatch(1) + val token = AtomicReference() + val error = AtomicReference() } - private fun currentCredentialIdentity(loggedIn: Boolean): String { - return youtubeCredentialIdentity(loggedIn, ServiceList.YouTube.getTokens()) + private class InitWaiter { + val latch = CountDownLatch(1) + val session = AtomicReference() + val error = AtomicReference() } - private fun credentialsStillMatch(credentialIdentity: String): Boolean { - return currentCredentialIdentity(ServiceList.YouTube.hasTokens()) == credentialIdentity - } + companion object { + private const val ASSET = "sabr_po_token.js" + private const val TOKEN_TIMEOUT_MS = 30_000L + private const val INIT_TIMEOUT_MS = 60_000L + private const val REQUEST_KEY = "O43z0dpjhgX20SCx4KAo" - private fun invalidateCredentialBoundState() { - sessionPoTokenPrewarmer.cancel() - prewarmExecutor.execute { - synchronized(visitorDataLock) { - fetchedVisitorData = null - fetchedVisitorDataLoggedIn = null - fetchedVisitorDataCredentialIdentity = null - visitorDataFetchedAtMs = 0 - } - synchronized(generatorLock) { - generator?.let { mainHandler.post { it.close() } } - generator = null - generatorContext = null - generatorCredentialIdentity = null + @Throws(SabrProtocolException::class) + fun create( + context: Context, + visitorData: String, + clientVersion: String, + credentialHeaders: Map>, + ): OneShotMintSession { + val initialization = InitWaiter() + val session = OneShotMintSession( + context, + initialization, + visitorData, + clientVersion, + credentialHeaders, + ) + session.loadScriptAndInitialize() + try { + if (!initialization.latch.await(INIT_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { + session.close() + throw SabrProtocolException("Local DOM PO token initialization timed out") + } + } catch (error: InterruptedException) { + Thread.currentThread().interrupt() + session.close() + throw SabrProtocolException("Local DOM PO token initialization interrupted", error) } - cache.clear() - prefs.edit().clear().commit() - Log.i(TAG, "YouTube credentials changed; cleared credential-bound PO token state") - } - } - - private fun diskLoad(videoId: String): CachedToken? { - val value = prefs.getString(videoId, null) ?: return null - val parts = value.split('|', limit = 5) - if (parts.size != 5) { - prefs.edit().remove(videoId).apply() - return null - } - return try { - val mintedAt = parts[0].toLong() - if (System.currentTimeMillis() - mintedAt >= TOKEN_TTL_MS) { - prefs.edit().remove(videoId).apply() - null - } else { - val visitorData = String( - Base64.getUrlDecoder().decode(parts[3]), - StandardCharsets.UTF_8, - ) - CachedToken( - Base64.getUrlDecoder().decode(parts[4]), - mintedAt, - visitorData, - parts[1], - String( - Base64.getUrlDecoder().decode(parts[2]), - StandardCharsets.UTF_8, - ), + initialization.error.get()?.let { + throw SabrProtocolException( + "Local DOM PO token initialization failed: ${it.message}", + it, ) } - } catch (error: IllegalArgumentException) { - null + return initialization.session.get() + ?: throw SabrProtocolException( + "Local DOM PO token initialization returned no result", + ) } } +} - private fun diskSave( - videoId: String, - token: ByteArray, - mintedAt: Long, - visitorData: String, - credentialIdentity: String, - clientContextIdentity: String, - ) { - val encoder = Base64.getUrlEncoder().withoutPadding() - val encodedVisitorData = encoder.encodeToString( - visitorData.toByteArray(StandardCharsets.UTF_8), - ) - val encodedToken = encoder.encodeToString(token) - val encodedContextIdentity = encoder.encodeToString( - clientContextIdentity.toByteArray(StandardCharsets.UTF_8), - ) - prefs.edit().putString( - videoId, - "$mintedAt|$credentialIdentity|$encodedContextIdentity|" + - "$encodedVisitorData|$encodedToken", - ).commit() +private fun buildAttestationBody(visitorData: String, clientVersion: String): String { + return """{"context":{"client":{"clientName":"WEB","clientVersion":${jsonString(clientVersion)},"hl":"en","gl":"US","utcOffsetMinutes":0,"visitorData":${jsonString(visitorData)}}},"engagementType":"ENGAGEMENT_TYPE_UNBOUND"}""" +} + +private fun buildAttestationHeaders( + visitorData: String, + clientVersion: String, + credentialHeaders: Map>, +): Map> { + return HashMap(credentialHeaders).apply { + put("User-Agent", listOf(SharedWebViewRuntime.USER_AGENT)) + put("Accept", listOf("application/json")) + put("Content-Type", listOf("application/json")) + put("Origin", listOf("https://www.youtube.com")) + put("Referer", listOf("https://www.youtube.com/")) + put("X-Goog-Visitor-Id", listOf(visitorData)) + put("X-YouTube-Client-Name", listOf("1")) + put("X-YouTube-Client-Version", listOf(clientVersion)) + put("x-goog-api-key", listOf(LOCAL_DOM_GOOGLE_API_KEY)) + put("x-user-agent", listOf("grpc-web-javascript/0.1")) } +} - companion object { - private const val TAG = "SabrLocalDomPoToken" - private const val PREFS = "sabr_local_dom_video_token_cache" - private const val TOKEN_TTL_MS = 1L * 60L * 60L * 1000L - private const val VISITOR_DATA_TTL_MS = 1L * 60L * 60L * 1000L - @Volatile - private var sharedInstance: LocalDomPoTokenProvider? = null +private const val LOCAL_DOM_GOOGLE_API_KEY = + "AIzaSyDyT5W0Jh49F30Pqqtyfdf7pDLFKLJoAnw" - @JvmStatic - fun shared(context: Context): LocalDomPoTokenProvider { - return sharedInstance ?: synchronized(this) { - sharedInstance ?: LocalDomPoTokenProvider(context.applicationContext).also { - sharedInstance = it - } +private fun jsonString(value: String): String { + return buildString(value.length + 2) { + append('"') + value.forEach { character -> + when (character) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> append(character) } } + append('"') } } diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/LocalDomPoTokenRequest.kt b/app/src/main/java/org/schabi/newpipe/player/datasource/LocalDomPoTokenRequest.kt deleted file mode 100644 index a4dbcd115..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/LocalDomPoTokenRequest.kt +++ /dev/null @@ -1,93 +0,0 @@ -package org.schabi.newpipe.player.datasource - -import org.schabi.newpipe.SharedWebViewRuntime -import java.nio.charset.StandardCharsets -import java.security.MessageDigest -import java.util.Base64 - -internal data class LocalDomPoTokenContext( - val visitorData: String, - val clientName: String, - val clientVersion: String, - val userAgent: String, -) { - val clientId: String - get() = when (clientName) { - "WEB" -> "1" - "MWEB" -> "2" - "WEB_EMBEDDED_PLAYER" -> "56" - "ANDROID" -> "3" - "ANDROID_VR" -> "28" - "IOS" -> "5" - "TVHTML5" -> "7" - else -> throw IllegalArgumentException("Unsupported YouTube client: $clientName") - } - - val cacheIdentity: String - get() { - val digest = MessageDigest.getInstance("SHA-256") - listOf(clientName, clientVersion, visitorData, userAgent).forEach { value -> - val bytes = value.toByteArray(StandardCharsets.UTF_8) - digest.update((bytes.size ushr 24).toByte()) - digest.update((bytes.size ushr 16).toByte()) - digest.update((bytes.size ushr 8).toByte()) - digest.update(bytes.size.toByte()) - digest.update(bytes) - } - return Base64.getUrlEncoder().withoutPadding().encodeToString(digest.digest()) - } -} - -internal fun localDomAttestationContext( - visitorData: String, - clientVersion: String, -): LocalDomPoTokenContext { - return LocalDomPoTokenContext( - visitorData, - "WEB", - clientVersion, - SharedWebViewRuntime.USER_AGENT, - ) -} - -internal fun buildLocalDomAttestationBody(context: LocalDomPoTokenContext): String { - return """{"context":{"client":{"clientName":${jsonString(context.clientName)},"clientVersion":${jsonString(context.clientVersion)},"hl":"en","gl":"US","utcOffsetMinutes":0,"visitorData":${jsonString(context.visitorData)}}},"engagementType":"ENGAGEMENT_TYPE_UNBOUND"}""" -} - -internal fun buildLocalDomAttestationHeaders( - context: LocalDomPoTokenContext, - credentialHeaders: Map>, -): Map> { - return HashMap(credentialHeaders).apply { - put("User-Agent", listOf(context.userAgent)) - put("Accept", listOf("application/json")) - put("Content-Type", listOf("application/json")) - put("Origin", listOf("https://www.youtube.com")) - put("Referer", listOf("https://www.youtube.com/")) - put("X-Goog-Visitor-Id", listOf(context.visitorData)) - put("X-YouTube-Client-Name", listOf(context.clientId)) - put("X-YouTube-Client-Version", listOf(context.clientVersion)) - put("x-goog-api-key", listOf(LOCAL_DOM_GOOGLE_API_KEY)) - put("x-user-agent", listOf("grpc-web-javascript/0.1")) - } -} - -internal const val LOCAL_DOM_GOOGLE_API_KEY = - "AIzaSyDyT5W0Jh49F30Pqqtyfdf7pDLFKLJoAnw" - -private fun jsonString(value: String): String { - return buildString(value.length + 2) { - append('"') - value.forEach { character -> - when (character) { - '\\' -> append("\\\\") - '"' -> append("\\\"") - '\n' -> append("\\n") - '\r' -> append("\\r") - '\t' -> append("\\t") - else -> append(character) - } - } - append('"') - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDashMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDashMediaSource.java index 3cd5a658c..925310ca5 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDashMediaSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDashMediaSource.java @@ -1,5 +1,6 @@ package org.schabi.newpipe.player.datasource; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; import android.content.Context; import android.net.Uri; import android.util.Log; @@ -29,7 +30,6 @@ import androidx.media3.exoplayer.upstream.Allocator; import org.schabi.newpipe.extractor.localization.Localization; -import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrStreamState; import java.io.ByteArrayInputStream; @@ -161,7 +161,7 @@ private static DashManifest buildManifest(final SabrSourceSpec spec, } private static String adaptationSet(final YoutubeSabrStreamState state, - final YoutubeSabrFormat format, + final YoutubeSabrInfo.Format format, final int trackType) { final String mime = containerMimeType(format); final String codecs = codecs(format); @@ -190,7 +190,7 @@ private static String adaptationSet(final YoutubeSabrStreamState state, } private static String segmentTemplate(final YoutubeSabrStreamState state, - final YoutubeSabrFormat format) { + final YoutubeSabrInfo.Format format) { final long endSegment = state.getEndSegment(format); if (endSegment <= 0 || endSegment > 10_000) { throw new IllegalStateException("Invalid exact SABR segment count: itag=" @@ -216,7 +216,7 @@ private static String formatDuration(final long durationMs) { + String.format(java.util.Locale.US, "%03d", safeDurationMs % 1000) + "S"; } - private static String containerMimeType(final YoutubeSabrFormat format) { + private static String containerMimeType(final YoutubeSabrInfo.Format format) { final String mime = format.getMimeType(); if (mime == null || mime.isEmpty()) { return format.isAudio() ? MimeTypes.AUDIO_MP4 : MimeTypes.VIDEO_MP4; @@ -226,7 +226,7 @@ private static String containerMimeType(final YoutubeSabrFormat format) { } @Nullable - private static String codecs(final YoutubeSabrFormat format) { + private static String codecs(final YoutubeSabrInfo.Format format) { final String mime = format.getMimeType(); if (mime == null) { return null; diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaBridge.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaBridge.java new file mode 100644 index 000000000..6005d9fe0 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaBridge.java @@ -0,0 +1,169 @@ +package org.schabi.newpipe.player.datasource; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.localization.Localization; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession; +import org.schabi.newpipe.extractor.services.youtube.sabr.media.SabrMediaSegment; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.LinkedBlockingQueue; + +/** Bridges Media3 segment demand to serialized SABR transactions. */ +final class SabrMediaBridge { + private final YoutubeSabrSession session; + private final Localization localization; + private final LinkedBlockingQueue pending = new LinkedBlockingQueue<>(); + private final Map pendingKeys = new ConcurrentHashMap<>(); + private final Map failures = new ConcurrentHashMap<>(); + private volatile IOException networkFailure; + private volatile boolean stopped; + private volatile boolean started; + private Thread worker; + + SabrMediaBridge(@NonNull final YoutubeSabrSession session, + @NonNull final Localization localization) { + this.session = session; + this.localization = localization; + } + + synchronized void ensureStarted() { + if (started || stopped) { + return; + } + started = true; + worker = new Thread(this::run, "SabrMediaBridge"); + worker.setDaemon(true); + worker.start(); + } + + void stop() { + stopped = true; + final Thread current = worker; + if (current != null) { + current.interrupt(); + } + } + + @Nullable + SabrMediaSegment getCached(@NonNull final SabrSegmentRequest request) { + return session.getReadableSegment(request); + } + + @Nullable + IOException takeNetworkFailure() { + final IOException failure = networkFailure; + networkFailure = null; + return failure; + } + + @Nullable + IOException takeDemandFailure(@NonNull final SabrSegmentRequest request, + @NonNull final Object readerOwner, + final long readerGeneration) { + return failures.remove(key(request)); + } + + boolean canRecover() { + return !stopped && networkFailure == null; + } + + String getStateName() { + return stopped ? "STOPPED" : (pending.isEmpty() ? "IDLE" : "REQUESTING"); + } + + void requestInitialization(@NonNull final org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo.Format format) { + requestSegmentDemand(SabrSegmentRequest.initialization(format), this, 0); + } + + void requestSegmentDemand(@NonNull final SabrSegmentRequest request, + @NonNull final Object readerOwner, + final long readerGeneration) { + if (session.getCachedSegment(request) != null) { + return; + } + final String key = key(request); + if (pendingKeys.putIfAbsent(key, request) == null) { + pending.offer(request); + ensureStarted(); + } + } + + void clearSegmentDemand(@NonNull final SabrSegmentRequest request, + @NonNull final Object readerOwner, + final long readerGeneration) { + final String key = key(request); + pendingKeys.remove(key); + failures.remove(key); + } + + void requestRefetchFrom(@NonNull final SabrSegmentRequest request) { + session.prepareForRewind(request); + requestSegmentDemand(request, this, 0); + } + + void requestForwardSeekTo(@NonNull final SabrSegmentRequest request) { + session.prepareForForwardJump(request); + requestSegmentDemand(request, this, 0); + } + + void requestSeekTo(@NonNull final SabrSegmentRequest request, + final boolean backward, + final long positionMs) { + if (backward) { + session.prepareForRewind(request, positionMs); + } else { + session.prepareForForwardJump(request, positionMs); + } + requestSegmentDemand(request, this, 0); + } + + void noteSeekWithinCache() { + // Media3 can continue reading the already published segment window. + } + + private void run() { + while (!stopped) { + try { + final SabrSegmentRequest request = pending.take(); + final String requestKey = key(request); + if (!pendingKeys.containsKey(requestKey)) { + continue; + } + session.requestOnce(localization); + pendingKeys.remove(requestKey); + pending.removeIf(candidate -> session.getCachedSegment(candidate) != null); + for (final SabrSegmentRequest candidate : pending) { + if (session.getCachedSegment(candidate) != null) { + pendingKeys.remove(key(candidate)); + } + } + } catch (final InterruptedException e) { + if (stopped) { + break; + } + Thread.currentThread().interrupt(); + break; + } catch (final IOException | ExtractionException e) { + final IOException failure = e instanceof IOException + ? (IOException) e : new IOException("SABR request failed", e); + networkFailure = failure; + for (final String key : pendingKeys.keySet()) { + failures.put(key, failure); + } + pending.clear(); + pendingKeys.clear(); + } + } + } + + private static String key(@NonNull final SabrSegmentRequest request) { + return request.getFormat().getItag() + ":" + + (request.isInitializationSegment() ? "init" : request.getSequenceNumber()); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java index 652b7a09d..9a370d698 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java @@ -1,5 +1,6 @@ package org.schabi.newpipe.player.datasource; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; import android.net.Uri; import android.util.Log; @@ -11,16 +12,13 @@ import androidx.media3.datasource.TransferListener; import org.schabi.newpipe.extractor.localization.Localization; -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrMediaSegment; +import org.schabi.newpipe.extractor.services.youtube.sabr.media.SabrMediaSegment; import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest; -import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.io.InterruptedIOException; -import java.util.List; -import java.util.Map; public final class SabrSegmentDataSource implements DataSource { private static final String TAG = "SabrSegmentDataSource"; @@ -37,7 +35,7 @@ public final class SabrSegmentDataSource implements DataSource { private final SabrSessionHandle sessionHandle; private final Object readerOwner; @Nullable - private final YoutubeSabrFormat fixedFormat; + private final YoutubeSabrInfo.Format fixedFormat; private final Localization localization; private final boolean prependInit; @@ -58,7 +56,7 @@ public final class SabrSegmentDataSource implements DataSource { public SabrSegmentDataSource(final SabrSessionStore.Holder holder, final Object readerOwner, - final YoutubeSabrFormat format, + final YoutubeSabrInfo.Format format, final Localization localization, final boolean prependInit) { this.holder = holder; @@ -114,7 +112,7 @@ public long open(final DataSpec dataSpec) throws IOException { this.progressiveDataEndPosition = -1; this.pos = (int) Math.max(0, dataSpec.position); SabrSegmentRequest request = requestFromUri(dataSpec.uri); - final YoutubeSabrFormat format = request.getFormat(); + final YoutubeSabrInfo.Format format = request.getFormat(); final long availableRemaining; final int openedBytes; Log.d(TAG, "open video=" + holder.videoId @@ -179,7 +177,7 @@ public long open(final DataSpec dataSpec) throws IOException { return bytesRemaining; } - private byte[] getInitializationData(final YoutubeSabrFormat format) throws IOException { + private byte[] getInitializationData(final YoutubeSabrInfo.Format format) throws IOException { final int itag = format.getItag(); final byte[] cached = holder.getInitializationData(itag); if (cached != null) { @@ -237,7 +235,7 @@ private void maybeAdvanceProgressiveReader() { || pos < progressiveDataEndPosition || !segment.isComplete() || holder == null) { return; } - final YoutubeSabrFormat format = segment.getHeader().getItag() + final YoutubeSabrInfo.Format format = segment.getHeader().getItag() == holder.videoFormat.getItag() ? holder.videoFormat : holder.audioFormat; holder.setReaderPositionMs(readerOwner, progressiveReaderGeneration, format.getItag(), segment.getHeader().getStartMs() + segment.getHeader().getDurationMs()); @@ -247,7 +245,7 @@ private void maybeAdvanceProgressiveReader() { } private SabrSegmentRequest requestFromUri(final Uri u) throws IOException { - final YoutubeSabrFormat format = formatFromUri(u); + final YoutubeSabrInfo.Format format = formatFromUri(u); final String seg = u.getLastPathSegment(); if (seg == null) { throw new SabrLogicException("Bad SABR segment uri: " + u); @@ -262,7 +260,7 @@ private SabrSegmentRequest requestFromUri(final Uri u) throws IOException { } } - private YoutubeSabrFormat formatFromUri(final Uri u) throws IOException { + private YoutubeSabrInfo.Format formatFromUri(final Uri u) throws IOException { if (fixedFormat != null) { return fixedFormat; } @@ -287,7 +285,7 @@ private YoutubeSabrFormat formatFromUri(final Uri u) throws IOException { @Nullable private SabrMediaSegment awaitSegment(final SabrSegmentRequest request) throws IOException { - final YoutubeSabrFormat format = request.getFormat(); + final YoutubeSabrInfo.Format format = request.getFormat(); holder.throwIfTerminal(); if (holder.isInvalidated()) { throw invalidatedException(request.getFormat()); @@ -410,7 +408,7 @@ private SabrMediaSegment awaitSegment(final SabrSegmentRequest request) throws I recoveryAtMs = -1; lastRecoveryAtMs = -1; } - if (holder.session.getDemandBackoffRemainingMs() > 0) { + if (holder.session.getBackoffRemainingMs() > 0) { // Server-directed pacing is not a playback stall. Keep polling so cancellation and // reader replacement remain responsive, but do not let the local recovery watchdog // reposition the session and attempt another request before the server deadline. @@ -492,7 +490,7 @@ private SabrMediaSegment awaitSegment(final SabrSegmentRequest request) throws I } } - private SabrLogicException invalidatedException(final YoutubeSabrFormat format) { + private SabrLogicException invalidatedException(final YoutubeSabrInfo.Format format) { return new SabrLogicException("SABR session invalidated for video=" + holder.videoId + ", itag=" + format.getItag() + ", " + holder.getInvalidationDetails()); } diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java index 83f1db013..afb07fcd4 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java @@ -15,11 +15,7 @@ import org.schabi.newpipe.player.SabrBackoffCoordinator; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.localization.Localization; -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrPoTokenProvider; -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrMediaSegment; import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest; -import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrClientProfile; -import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrStreamState; @@ -65,12 +61,6 @@ public final class SabrSessionStore { thread.setDaemon(true); return thread; }); - private static final ExecutorService INITIALIZATION_EXECUTOR = Executors.newFixedThreadPool(2, - runnable -> { - final Thread thread = new Thread(runnable, "SabrAdaptiveInitialization"); - thread.setDaemon(true); - return thread; - }); private static final ExecutorService TOKEN_EXECUTOR = Executors.newSingleThreadExecutor( runnable -> { final Thread thread = new Thread(runnable, "SabrTokenPrewarm"); @@ -107,19 +97,17 @@ private static final class SessionKey { private final int videoItag; private final int audioItag; @NonNull private final String audioTrackId; - @NonNull private final YoutubeSabrClientProfile profile; SessionKey(final long sourceId, @NonNull final String videoId, @NonNull final YoutubeSabrInfo info, - @NonNull final YoutubeSabrFormat audioFormat, - @NonNull final YoutubeSabrFormat videoFormat) { + @NonNull final YoutubeSabrInfo.Format audioFormat, + @NonNull final YoutubeSabrInfo.Format videoFormat) { this.videoId = videoId; this.sourceId = sourceId; this.videoItag = videoFormat.getItag(); this.audioItag = audioFormat.getItag(); this.audioTrackId = Objects.toString(audioFormat.getAudioTrackId(), ""); - this.profile = info.getProfile(); } @Override @@ -135,13 +123,12 @@ public boolean equals(final Object other) { && videoItag == that.videoItag && audioItag == that.audioItag && videoId.equals(that.videoId) - && audioTrackId.equals(that.audioTrackId) - && profile == that.profile; + && audioTrackId.equals(that.audioTrackId); } @Override public int hashCode() { - return Objects.hash(sourceId, videoId, videoItag, audioItag, audioTrackId, profile); + return Objects.hash(sourceId, videoId, videoItag, audioItag, audioTrackId); } } @@ -269,8 +256,8 @@ public static final class Holder { @NonNull public final String videoId; @NonNull public final YoutubeSabrInfo info; @NonNull public final YoutubeSabrSession session; - @NonNull public final YoutubeSabrFormat audioFormat; - @NonNull public final YoutubeSabrFormat videoFormat; + @NonNull public final YoutubeSabrInfo.Format audioFormat; + @NonNull public final YoutubeSabrInfo.Format videoFormat; // Playback position is only a hint. Pump and eviction use reader positions. private volatile long playerTimeMs; @@ -296,8 +283,8 @@ public static final class Holder { @NonNull final String videoId, @NonNull final YoutubeSabrInfo info, @NonNull final YoutubeSabrSession session, - @NonNull final YoutubeSabrFormat audioFormat, - @NonNull final YoutubeSabrFormat videoFormat) { + @NonNull final YoutubeSabrInfo.Format audioFormat, + @NonNull final YoutubeSabrInfo.Format videoFormat) { this.key = new SessionKey(0, videoId, info, audioFormat, videoFormat); this.appContext = appContext.getApplicationContext(); this.videoId = videoId; @@ -446,7 +433,7 @@ void requestSeek(final long positionMs, @NonNull final Localization localization } // Media3 may seek within its sample queue; still reposition the SABR session when the // target audio/video segments are not cached. - final YoutubeSabrFormat targetFormat = videoFormat; + final YoutubeSabrInfo.Format targetFormat = videoFormat; final int sequence = session.getStreamState() .getSegmentNumberAtOrAfterTimeMs(targetFormat, positionMs); final SabrSegmentRequest request = SabrSegmentRequest.media(targetFormat, sequence); @@ -484,7 +471,7 @@ void setInitializationData(final int itag, @NonNull final byte[] data) { } private void retainBootstrapInitialization(@NonNull final SabrSourceSpec spec, - @NonNull final YoutubeSabrFormat format) { + @NonNull final YoutubeSabrInfo.Format format) { final byte[] data = spec.getInitializationData(format.getItag()); if (data != null) { bootstrapInitializationData.put(format.getItag(), data); @@ -679,9 +666,9 @@ public static SabrSourceSpec createSourceSpec(@NonNull final String videoId, throw new IOException("SABR extractor info is missing for " + videoId); } final YoutubeSabrInfo info = Objects.requireNonNull(extractorInfo); - final YoutubeSabrFormat audioFormat = pickAudioFormat( + final YoutubeSabrInfo.Format audioFormat = pickAudioFormat( App.getApp(), info, preferredAudioTrackId); - final YoutubeSabrFormat videoFormat = pickVideoFormat(info, preferredVideoItag); + final YoutubeSabrInfo.Format videoFormat = pickVideoFormat(info, preferredVideoItag); if (audioFormat == null || videoFormat == null) { throw new IOException("SABR: could not select audio/video formats for " + videoId); } @@ -709,9 +696,9 @@ public static void prewarm(@NonNull final Context context, @NonNull final Stream if (!isUsableExtractorInfo(info, streamInfo.getId())) { return; } - final YoutubeSabrFormat audioFormat = pickAudioFormat(context, info, + final YoutubeSabrInfo.Format audioFormat = pickAudioFormat(context, info, PREFERRED_AUDIO.get(streamInfo.getId())); - final YoutubeSabrFormat videoFormat = pickVideoFormat(info, selectedStream.getItag()); + final YoutubeSabrInfo.Format videoFormat = pickVideoFormat(info, selectedStream.getItag()); if (audioFormat == null || videoFormat == null) { return; } @@ -726,8 +713,8 @@ public static void prewarm(@NonNull final Context context, @NonNull final Stream @NonNull private static Future startBootstrap(@NonNull final Context context, @NonNull final YoutubeSabrInfo info, - @NonNull final YoutubeSabrFormat audioFormat, - @NonNull final YoutubeSabrFormat videoFormat, + @NonNull final YoutubeSabrInfo.Format audioFormat, + @NonNull final YoutubeSabrInfo.Format videoFormat, @NonNull final Localization localization) { final String key = bootstrapKey(info, audioFormat, videoFormat); final BootstrapResult cached = BOOTSTRAP_CACHE.get(key); @@ -760,38 +747,45 @@ protected void done() { } @NonNull - private static BootstrapResult createBootstrap(@NonNull final Context context, - @NonNull final YoutubeSabrInfo info, - @NonNull final YoutubeSabrFormat audioFormat, - @NonNull final YoutubeSabrFormat videoFormat, - @NonNull final Localization localization, - @NonNull final BootstrapBackoffState backoffState) + private static BootstrapResult createPreparation(@NonNull final Context context, + @NonNull final YoutubeSabrInfo info, + @NonNull final YoutubeSabrInfo.Format audioFormat, + @NonNull final YoutubeSabrInfo.Format videoFormat, + @NonNull final Localization localization, + @NonNull final BootstrapBackoffState backoffState) throws IOException, ExtractionException { final LocalDomPoTokenProvider sessionProvider = provider(context); final File spoolDirectory = new File(context.getApplicationContext().getCacheDir(), "sabr-bootstrap/" + info.getVideoId() + '-' + System.nanoTime()); final YoutubeSabrSession session = new YoutubeSabrSession(info, audioFormat, videoFormat, - sessionProvider, spoolDirectory); + spoolDirectory); session.setBackoffListener(backoffState); boolean handedOff = false; try { - attachPoToken(info.getVideoId(), info, sessionProvider, session); + final byte[] poToken = awaitWarmedToken(info.getVideoId(), info, sessionProvider, + session.getStreamState()); + if (poToken == null || poToken.length == 0) { + throw new SabrLogicException("SABR PO token provider returned no token for video=" + + info.getVideoId()); + } + session.getStreamState().setPoToken(poToken); + YoutubeSabrSession.InitializationResult initialization; try { - session.bootstrapInitialization(localization); + initialization = session.initialize(localization, 2_000, poToken); } catch (final IOException firstFailure) { attachPoToken(info.getVideoId(), info, sessionProvider, session); - session.bootstrapInitialization(localization); + final byte[] retryPoToken = awaitWarmedToken(info.getVideoId(), info, sessionProvider, + session.getStreamState()); + session.getStreamState().setPoToken(retryPoToken); + initialization = session.initialize(localization, 2_000, retryPoToken); } - final SabrMediaSegment audio = session.getCachedSegment( - SabrSegmentRequest.initialization(audioFormat)); - final SabrMediaSegment video = session.getCachedSegment( - SabrSegmentRequest.initialization(videoFormat)); - if (audio == null || video == null) { - throw new SabrLogicException("SABR bootstrap completed without cached init segments" - + " video=" + info.getVideoId()); + if (initialization.getAudioData() == null || initialization.getVideoData() == null) { + throw new SabrLogicException("SABR initialization did not provide both tracks for video=" + + info.getVideoId()); } handedOff = true; - return new BootstrapResult(audio.getData(), video.getData(), session); + return new BootstrapResult(initialization.getAudioData(), initialization.getVideoData(), + session); } finally { session.setBackoffListener(null); if (!handedOff) { @@ -800,71 +794,6 @@ private static BootstrapResult createBootstrap(@NonNull final Context context, } } - @NonNull - private static BootstrapResult createPreparation(@NonNull final Context context, - @NonNull final YoutubeSabrInfo info, - @NonNull final YoutubeSabrFormat audioFormat, - @NonNull final YoutubeSabrFormat videoFormat, - @NonNull final Localization localization, - @NonNull final BootstrapBackoffState backoffState) - throws IOException, ExtractionException { - final LocalDomPoTokenProvider tokenProvider = provider(context); - final byte[] poToken = awaitWarmedToken(info.getVideoId(), info, tokenProvider, - new YoutubeSabrStreamState(audioFormat, videoFormat)); - if (poToken == null || poToken.length == 0) { - throw new SabrLogicException("SABR PO token provider returned no token for video=" - + info.getVideoId()); - } - try { - final BootstrapResult result = createAdaptiveInitialization(info, audioFormat, - videoFormat, localization, poToken); - Log.i(TAG, "adaptive initialization ready video=" + info.getVideoId() - + " audioItag=" + audioFormat.getItag() - + " videoItag=" + videoFormat.getItag()); - return result; - } catch (final IOException adaptiveFailure) { - Log.i(TAG, "adaptive initialization unavailable video=" + info.getVideoId() - + ", falling back to native SABR: " + adaptiveFailure.getMessage()); - return createBootstrap(context, info, audioFormat, videoFormat, - localization, backoffState); - } - } - - @NonNull - private static BootstrapResult createAdaptiveInitialization( - @NonNull final YoutubeSabrInfo info, - @NonNull final YoutubeSabrFormat audioFormat, - @NonNull final YoutubeSabrFormat videoFormat, - @NonNull final Localization localization, - @NonNull final byte[] poToken) - throws IOException, ExtractionException { - final YoutubeSabrSession session = new YoutubeSabrSession(info, audioFormat, videoFormat, - null, null); - final Future audio = INITIALIZATION_EXECUTOR.submit(() -> - session.fetchInitializationData(audioFormat, localization, 2_000, poToken)); - final Future video = INITIALIZATION_EXECUTOR.submit(() -> - session.fetchInitializationData(videoFormat, localization, 2_000, poToken)); - try { - final byte[] audioData = audio.get(); - final byte[] videoData = video.get(); - return new BootstrapResult(audioData, videoData, null); - } catch (final InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IOException("Interrupted fetching adaptive SABR initialization", e); - } catch (final ExecutionException e) { - audio.cancel(true); - video.cancel(true); - final Throwable cause = e.getCause(); - if (cause instanceof ExtractionException) { - throw (ExtractionException) cause; - } - if (cause instanceof IOException) { - throw (IOException) cause; - } - throw new IOException("Could not fetch adaptive SABR initialization", cause); - } - } - @NonNull private static BootstrapResult awaitBootstrap(@NonNull final String key, @NonNull final Future future, @@ -899,8 +828,8 @@ private static BootstrapResult awaitBootstrap(@NonNull final String key, @NonNull private static String bootstrapKey(@NonNull final YoutubeSabrInfo info, - @NonNull final YoutubeSabrFormat audioFormat, - @NonNull final YoutubeSabrFormat videoFormat) { + @NonNull final YoutubeSabrInfo.Format audioFormat, + @NonNull final YoutubeSabrInfo.Format videoFormat) { return tokenIdentityKey(info) + '#' + audioFormat.getItag() + ':' + audioFormat.getLastModified() + '#' + videoFormat.getItag() + ':' + videoFormat.getLastModified(); @@ -908,9 +837,8 @@ private static String bootstrapKey(@NonNull final YoutubeSabrInfo info, @NonNull private static String tokenIdentityKey(@NonNull final YoutubeSabrInfo info) { - return info.getVideoId() + '#' + info.getProfile() + '#' + info.getClientVersion() + '#' - + Objects.toString(info.getVisitorData(), "") + '#' - + Objects.toString(info.getProfile().getUserAgent(), ""); + return info.getVideoId() + "#MWEB#" + info.getClientVersion() + '#' + + Objects.toString(info.getVisitorData(), ""); } @NonNull @@ -922,8 +850,8 @@ private static BootstrapResult cacheBootstrap(@NonNull final String key, private static void startTokenWarmup(@NonNull final Context context, @NonNull final YoutubeSabrInfo info, - @NonNull final YoutubeSabrFormat audioFormat, - @NonNull final YoutubeSabrFormat videoFormat) { + @NonNull final YoutubeSabrInfo.Format audioFormat, + @NonNull final YoutubeSabrInfo.Format videoFormat) { final String tokenKey = tokenIdentityKey(info); final FutureTask created = new FutureTask(() -> provider(context).getPoToken( info, new YoutubeSabrStreamState(audioFormat, videoFormat))) { @@ -962,7 +890,7 @@ static Lease acquire(@NonNull final Context context, @NonNull final SabrSourceSp session.addDiagnosticEvent("bootstrap_session_handoff"); } else { session = new YoutubeSabrSession(spec.getInfo(), spec.getAudioFormat(), - spec.getVideoFormat(), sessionProvider, spoolDirectory); + spec.getVideoFormat(), spoolDirectory); attachPoToken(spec.getVideoId(), spec.getInfo(), sessionProvider, session); } final Holder holder = new Holder(context, spec, session); @@ -980,7 +908,7 @@ static Lease acquire(@NonNull final Context context, @NonNull final SabrSourceSp private static void seedInitializationData(@NonNull final Holder holder, @NonNull final SabrSourceSpec spec, - @NonNull final YoutubeSabrFormat format) { + @NonNull final YoutubeSabrInfo.Format format) { final byte[] data = spec.getInitializationData(format.getItag()); if (data != null) { holder.setInitializationData(format.getItag(), data); @@ -998,7 +926,7 @@ private static void releaseLease(@NonNull final SessionKey key, private static void attachPoToken(@NonNull final String videoId, @NonNull final YoutubeSabrInfo info, - @NonNull final SabrPoTokenProvider provider, + @NonNull final LocalDomPoTokenProvider provider, @NonNull final YoutubeSabrSession session) throws IOException, ExtractionException { try { @@ -1027,7 +955,7 @@ private static void attachPoToken(@NonNull final String videoId, @Nullable private static byte[] awaitWarmedToken(@NonNull final String videoId, @NonNull final YoutubeSabrInfo info, - @NonNull final SabrPoTokenProvider provider, + @NonNull final LocalDomPoTokenProvider provider, @NonNull final org.schabi.newpipe.extractor.services .youtube.sabr.YoutubeSabrStreamState state) throws IOException, ExtractionException { @@ -1070,7 +998,7 @@ private static boolean isUsableExtractorInfo(@Nullable final YoutubeSabrInfo inf && !info.getFormats().isEmpty(); } - private static YoutubeSabrFormat pickAudioFormat(@NonNull final Context context, + private static YoutubeSabrInfo.Format pickAudioFormat(@NonNull final Context context, @NonNull final YoutubeSabrInfo info, @Nullable final String preferredTrackId) { final SharedPreferences preferences = @@ -1080,11 +1008,11 @@ private static YoutubeSabrFormat pickAudioFormat(@NonNull final Context context, return pickAudioFormat(info, preferredTrackId, preferredLanguage); } - static YoutubeSabrFormat pickAudioFormat(@NonNull final YoutubeSabrInfo info, + static YoutubeSabrInfo.Format pickAudioFormat(@NonNull final YoutubeSabrInfo info, @Nullable final String preferredTrackId, @Nullable final String preferredLanguage) { - YoutubeSabrFormat best = null; - for (final YoutubeSabrFormat f : info.getFormats()) { + YoutubeSabrInfo.Format best = null; + for (final YoutubeSabrInfo.Format f : info.getFormats()) { if (!f.isAudio()) { continue; } @@ -1110,10 +1038,10 @@ private static boolean matchesAudioLanguage(@Nullable final String preferredLang return preferredLanguage.equals(trackId.split("[._-]", 2)[0]); } - private static YoutubeSabrFormat pickVideoFormat(@NonNull final YoutubeSabrInfo info, + private static YoutubeSabrInfo.Format pickVideoFormat(@NonNull final YoutubeSabrInfo info, final int preferredItag) { if (preferredItag > 0) { - for (final YoutubeSabrFormat f : info.getFormats()) { + for (final YoutubeSabrInfo.Format f : info.getFormats()) { if (f.isVideo() && f.getItag() == preferredItag) { return f; } diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSourceSpec.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSourceSpec.java index ce8ae1c02..24308880c 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSourceSpec.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSourceSpec.java @@ -4,7 +4,6 @@ import androidx.annotation.Nullable; import org.schabi.newpipe.extractor.localization.Localization; -import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrStreamState; @@ -19,8 +18,8 @@ public final class SabrSourceSpec { private final long sourceId; @NonNull private final String videoId; @NonNull private final YoutubeSabrInfo info; - @NonNull private final YoutubeSabrFormat audioFormat; - @NonNull private final YoutubeSabrFormat videoFormat; + @NonNull private final YoutubeSabrInfo.Format audioFormat; + @NonNull private final YoutubeSabrInfo.Format videoFormat; @NonNull private final Localization localization; @NonNull private final byte[] audioInitializationData; @NonNull private final byte[] videoInitializationData; @@ -28,8 +27,8 @@ public final class SabrSourceSpec { public SabrSourceSpec(@NonNull final String videoId, @NonNull final YoutubeSabrInfo info, - @NonNull final YoutubeSabrFormat audioFormat, - @NonNull final YoutubeSabrFormat videoFormat, + @NonNull final YoutubeSabrInfo.Format audioFormat, + @NonNull final YoutubeSabrInfo.Format videoFormat, @NonNull final Localization localization, @NonNull final byte[] audioInitializationData, @NonNull final byte[] videoInitializationData) { @@ -39,8 +38,8 @@ public SabrSourceSpec(@NonNull final String videoId, SabrSourceSpec(@NonNull final String videoId, @NonNull final YoutubeSabrInfo info, - @NonNull final YoutubeSabrFormat audioFormat, - @NonNull final YoutubeSabrFormat videoFormat, + @NonNull final YoutubeSabrInfo.Format audioFormat, + @NonNull final YoutubeSabrInfo.Format videoFormat, @NonNull final Localization localization, @NonNull final byte[] audioInitializationData, @NonNull final byte[] videoInitializationData, @@ -71,12 +70,12 @@ public YoutubeSabrInfo getInfo() { } @NonNull - public YoutubeSabrFormat getAudioFormat() { + public YoutubeSabrInfo.Format getAudioFormat() { return audioFormat; } @NonNull - public YoutubeSabrFormat getVideoFormat() { + public YoutubeSabrInfo.Format getVideoFormat() { return videoFormat; } diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java index 7131cfe5d..46467ac57 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java @@ -1,5 +1,6 @@ package org.schabi.newpipe.player.datasource; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; import android.util.Log; import androidx.annotation.NonNull; @@ -7,12 +8,9 @@ import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.localization.Localization; -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrMediaSegment; -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrNextRequestPolicy; -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrRecoverableException; +import org.schabi.newpipe.extractor.services.youtube.sabr.media.SabrMediaSegment; +import org.schabi.newpipe.extractor.services.youtube.sabr.exception.SabrRecoverableException; import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest; -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSessionPolicy; -import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession; import org.schabi.newpipe.player.SabrBackoffCoordinator; @@ -76,7 +74,7 @@ enum State { private volatile long pendingForwardSeekPositionMs = -1; private final Map activeDemands = new ConcurrentHashMap<>(); private final Map demandFailures = new ConcurrentHashMap<>(); - private volatile YoutubeSabrFormat pendingInitialization; + private volatile YoutubeSabrInfo.Format pendingInitialization; private volatile long seekModeUntilMs; private volatile long startedAtMs; private Thread thread; @@ -181,7 +179,7 @@ void requestSegmentDemand(@NonNull final SabrSegmentRequest request, final long nowMs = System.currentTimeMillis(); final SegmentDemand created = new SegmentDemand( request, readerOwner, readerGeneration, nowMs); - final long remainingBackoffMs = session.getDemandBackoffRemainingMs(); + final long remainingBackoffMs = session.getBackoffRemainingMs(); if (remainingBackoffMs > 0) { created.pausePolicyClockForBackoff(nowMs, remainingBackoffMs); } @@ -233,7 +231,7 @@ void noteSeekWithinCache() { wake(); } - void requestInitialization(@NonNull final YoutubeSabrFormat format) { + void requestInitialization(@NonNull final YoutubeSabrInfo.Format format) { pendingInitialization = format; ensureStarted(); wake(); @@ -257,13 +255,13 @@ private void loop() { session.setPlayHeadMs(Math.max(0, holder.getReaderTailMs() - backBufferMs)); session.evictPlayed(); final long edgeMs = session.getStreamState().getMinBufferedEndMs(); - final long remainingBackoffMs = session.getDemandBackoffRemainingMs(); + final long remainingBackoffMs = session.getBackoffRemainingMs(); if (remainingBackoffMs > 0) { state = State.IDLE; awaitWake(remainingBackoffMs); continue; } - final YoutubeSabrFormat initialization = pendingInitialization; + final YoutubeSabrInfo.Format initialization = pendingInitialization; if (initialization != null) { pendingInitialization = null; state = State.REPOSITIONING; @@ -331,14 +329,13 @@ private void loop() { final long demandStartMs = session.getStreamState() .getSegmentStartMs(demand.request.getFormat(), demand.request.getSequenceNumber()); - final SabrSessionPolicy.DemandRoute route = - session.evaluateDemandRoute(demand.routeEvent( - demandStartMs, edgeMs, System.currentTimeMillis())); - if (route == SabrSessionPolicy.DemandRoute.RECOVER_REWIND - || route == SabrSessionPolicy.DemandRoute.RECOVER_FORWARD - || route == SabrSessionPolicy.DemandRoute.RECOVER_MISSING) { + final boolean rewind = demandStartMs < edgeMs; + final boolean forward = demandStartMs > edgeMs + 30_000; + if (demand.responsesWithoutDemandedSegment > demand.recoveryCount) { state = State.REPOSITIONING; demand.recoveryCount++; + final String recovery = rewind ? "RECOVER_REWIND" + : forward ? "RECOVER_FORWARD" : "RECOVER_MISSING"; session.addDiagnosticEvent("pump_demand_reposition itag=" + demand.request.getFormat().getItag() + " seq=" + demand.request.getSequenceNumber() @@ -347,11 +344,10 @@ private void loop() { + " omissions=" + demand.responsesWithoutDemandedSegment + " recovery=" + demand.recoveryCount - + " route=" + route); - if (route == SabrSessionPolicy.DemandRoute.RECOVER_REWIND) { + + " route=" + recovery); + if (rewind) { session.prepareForRewind(demand.request); - } else if (route - == SabrSessionPolicy.DemandRoute.RECOVER_FORWARD) { + } else if (forward) { session.prepareForForwardJump(demand.request); } else { session.prepareForMissingSegment(demand.request); @@ -366,7 +362,7 @@ private void loop() { awaitDemandRetry(demand); } continue; - } else if (route == SabrSessionPolicy.DemandRoute.REWIND) { + } else if (rewind) { state = State.REPOSITIONING; session.addDiagnosticEvent("pump_demand_rewind itag=" + demand.request.getFormat().getItag() @@ -384,7 +380,7 @@ private void loop() { awaitDemandRetry(demand); } continue; - } else if (route == SabrSessionPolicy.DemandRoute.FORWARD) { + } else if (forward) { state = State.REPOSITIONING; session.addDiagnosticEvent("pump_demand_forward itag=" + demand.request.getFormat().getItag() @@ -402,7 +398,7 @@ private void loop() { awaitDemandRetry(demand); } continue; - } else if (route == SabrSessionPolicy.DemandRoute.STREAM) { + } else { state = State.REQUESTING; session.addDiagnosticEvent("pump_demand itag=" + demand.request.getFormat().getItag() @@ -427,7 +423,6 @@ private void loop() { } continue; } - throw new IllegalStateException("Unhandled SABR demand route " + route); } } final long readaheadCushionMs = targetReadaheadCushionMs(); @@ -455,7 +450,7 @@ private void loop() { } final boolean startupWait = holder.hasUnstartedActiveReader(); final long startupBackoffMs = startupWait - ? session.getDemandBackoffRemainingMs() : 0; + ? session.getBackoffRemainingMs() : 0; if (startupBackoffMs > 0) { SabrBackoffCoordinator.getInstance().begin( holder.getApplicationContext(), holder, @@ -561,7 +556,7 @@ private int pumpOnceStreaming() throws IOException, ExtractionException { private int pumpOnceStreamingForStartup() throws IOException, ExtractionException { try { final int segmentCount = session.pumpOnceStreamingForStartup(localization); - final long remainingBackoffMs = session.getDemandBackoffRemainingMs(); + final long remainingBackoffMs = session.getBackoffRemainingMs(); if (remainingBackoffMs > 0) { SabrBackoffCoordinator.getInstance().begin( holder.getApplicationContext(), holder, @@ -580,7 +575,7 @@ private YoutubeSabrSession.DemandResponseResult pumpOnceStreamingUntilCached( final YoutubeSabrSession.DemandResponseResult result; try { result = session.pumpOnceStreamingForDemand(localization, request); - final long remainingBackoffMs = session.getDemandBackoffRemainingMs(); + final long remainingBackoffMs = session.getBackoffRemainingMs(); if (remainingBackoffMs > 0) { pauseDemandPolicyClocksForBackoff(remainingBackoffMs); } @@ -596,7 +591,7 @@ private YoutubeSabrSession.DemandResponseResult pumpOnceStreamingUntilCached( } private void awaitDemandRetry(@NonNull final SegmentDemand demand) { - final long remainingBackoffMs = session.getDemandBackoffRemainingMs(); + final long remainingBackoffMs = session.getBackoffRemainingMs(); if (remainingBackoffMs > 0L) { SabrBackoffCoordinator.getInstance().begin(holder.getApplicationContext(), holder, android.os.SystemClock.elapsedRealtime() + remainingBackoffMs); @@ -622,12 +617,8 @@ private long targetReadaheadCushionMs() { if (holder.hasUnstartedActiveReader()) { return STARTUP_READAHEAD_CUSHION_MS; } - final SabrNextRequestPolicy policy = session.getStreamState().getNextRequestPolicy(); - if (policy == null) { - return READAHEAD_CUSHION_MS; - } - final int serverTargetMs = Math.max(policy.getTargetAudioReadaheadMs(), - policy.getTargetVideoReadaheadMs()); + final int serverTargetMs = Math.max(session.getStreamState().getTargetAudioReadaheadMs(), + session.getStreamState().getTargetVideoReadaheadMs()); if (serverTargetMs <= 0) { return READAHEAD_CUSHION_MS; } @@ -651,8 +642,7 @@ private boolean isStartupBurst() { } private boolean isHeartbeatDue() { - final SabrNextRequestPolicy policy = session.getStreamState().getNextRequestPolicy(); - final int maximumMs = policy == null ? -1 : policy.getMaxTimeSinceLastRequestMs(); + final int maximumMs = session.getStreamState().getMaxTimeSinceLastRequestMs(); return maximumMs > 0 && lastRequestMs > 0 && System.currentTimeMillis() - lastRequestMs >= maximumMs; } @@ -702,8 +692,8 @@ private boolean isSeekTargetCached(@NonNull final SabrSegmentRequest request, if (request.isInitializationSegment()) { return true; } - final YoutubeSabrFormat targetFormat = request.getFormat(); - final YoutubeSabrFormat companionFormat; + final YoutubeSabrInfo.Format targetFormat = request.getFormat(); + final YoutubeSabrInfo.Format companionFormat; if (targetFormat.getItag() == holder.videoFormat.getItag()) { companionFormat = holder.audioFormat; } else if (targetFormat.getItag() == holder.audioFormat.getItag()) { @@ -773,23 +763,19 @@ private boolean finishDemandAttempt( session.addDiagnosticEvent("pump_demand_no_media itag=" + demand.request.getFormat().getItag() + " seq=" + demand.request.getSequenceNumber() - + " backoffMs=" + session.getDemandBackoffRemainingMs()); + + " backoffMs=" + session.getBackoffRemainingMs()); return false; } final long nowMs = System.currentTimeMillis(); demand.responsesWithoutDemandedSegment++; - final long targetStartMs = session.getStreamState().getSegmentStartMs( - demand.request.getFormat(), demand.request.getSequenceNumber()); - final long edgeMs = session.getStreamState().getMinBufferedEndMs(); - final SabrSessionPolicy.DemandResponseDecision decision = - session.evaluateDemandResponse(new SabrSessionPolicy.DemandResponseEvent( - demand.request.getFormat().getItag(), - demand.request.getSequenceNumber(), targetStartMs, edgeMs, - demand.policyState(nowMs), result.getSegmentCount(), - result.getTargetTrackSegmentCount(), result.getReturnedSegments(), - result.areReturnedSegmentsTruncated())); - demand.retryDelayMs = decision.getRetryDelayMs(); + demand.retryDelayMs = 0; final long elapsedMs = demand.getPolicyElapsedMs(nowMs); + final boolean repeatedOmission = demand.responsesWithoutDemandedSegment >= 3 + || elapsedMs >= 15_000 && result.getTargetTrackSegmentCount() > 0; + final boolean noTargetMedia = elapsedMs >= 15_000 + && result.getTargetTrackSegmentCount() == 0; + final String outcome = repeatedOmission ? "FAIL_REPEATED_TARGET_OMISSION" + : noTargetMedia ? "FAIL_NO_TARGET_MEDIA" : "CONTINUE"; session.addDiagnosticEvent("pump_demand_omission itag=" + demand.request.getFormat().getItag() + " seq=" + demand.request.getSequenceNumber() @@ -798,10 +784,9 @@ private boolean finishDemandAttempt( + " segments=" + result.getSegmentCount() + " returned=" + summarizeReturnedSegments(result) + " elapsedMs=" + elapsedMs - + " outcome=" + decision.getOutcome() - + " retryDelayMs=" + decision.getRetryDelayMs()); - if (decision.getOutcome() - == SabrSessionPolicy.DemandOutcome.FAIL_REPEATED_TARGET_OMISSION) { + + " outcome=" + outcome + + " retryDelayMs=0"); + if (repeatedOmission) { failDemand(demand, new IOException( "SABR response repeatedly omitted demanded segment itag=" + demand.request.getFormat().getItag() @@ -810,17 +795,13 @@ private boolean finishDemandAttempt( + ", elapsedMs=" + elapsedMs)); return true; } - if (decision.getOutcome() == SabrSessionPolicy.DemandOutcome.FAIL_NO_TARGET_MEDIA) { + if (noTargetMedia) { failDemand(demand, new IOException("SABR demand timed out without target-track media" + " itag=" + demand.request.getFormat().getItag() + ", seq=" + demand.request.getSequenceNumber() + ", elapsedMs=" + elapsedMs)); return true; } - if (decision.getOutcome() != SabrSessionPolicy.DemandOutcome.CONTINUE) { - throw new IllegalStateException("Unhandled SABR demand outcome " - + decision.getOutcome()); - } return false; } @@ -828,7 +809,7 @@ private boolean finishDemandAttempt( private static String summarizeReturnedSegments( @NonNull final YoutubeSabrSession.DemandResponseResult result) { final StringBuilder summary = new StringBuilder("["); - for (final SabrSessionPolicy.DemandReturnedSegment segment + for (final YoutubeSabrSession.DemandReturnedSegment segment : result.getReturnedSegments()) { if (summary.length() > 1) { summary.append(','); @@ -885,12 +866,6 @@ private SegmentDemand(@NonNull final SabrSegmentRequest request, this.policyCreatedAtMs = sinceMs; } - @NonNull - private SabrSessionPolicy.DemandState policyState(final long nowMs) { - return new SabrSessionPolicy.DemandState(policyCreatedAtMs, nowMs, - responsesWithoutDemandedSegment, recoveryCount); - } - private void pausePolicyClockForBackoff(final long nowMs, final long remainingBackoffMs) { final long backoffUntilMs = nowMs + remainingBackoffMs; final long unaccountedBackoffMs = backoffUntilMs @@ -905,14 +880,6 @@ private long getPolicyElapsedMs(final long nowMs) { return Math.max(0, nowMs - policyCreatedAtMs); } - @NonNull - private SabrSessionPolicy.DemandRouteEvent routeEvent(final long targetStartMs, - final long bufferedEdgeMs, - final long nowMs) { - return new SabrSessionPolicy.DemandRouteEvent(request.getFormat().getItag(), - request.getSequenceNumber(), targetStartMs, bufferedEdgeMs, - policyState(nowMs)); - } } private static final class DemandKey { diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeSessionPoTokenPrewarmer.kt b/app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeSessionPoTokenPrewarmer.kt deleted file mode 100644 index a26418ecd..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeSessionPoTokenPrewarmer.kt +++ /dev/null @@ -1,92 +0,0 @@ -package org.schabi.newpipe.player.datasource - -import org.schabi.newpipe.extractor.localization.ContentCountry -import org.schabi.newpipe.extractor.localization.Localization -import org.schabi.newpipe.extractor.services.youtube.YoutubeSessionPoToken -import java.util.concurrent.Executor -import java.util.concurrent.Future -import java.util.concurrent.FutureTask - -internal data class YoutubeSessionPoTokenContext( - val clientName: String, - val clientVersion: String, - val userAgent: String?, - val localization: Localization, - val contentCountry: ContentCountry, - val loggedIn: Boolean, - val credentialIdentity: String, -) - -internal data class YoutubeSessionPoTokenPrewarmContext( - val clientName: String, - val userAgent: String?, - val localization: Localization, - val contentCountry: ContentCountry, - val loggedIn: Boolean, - val credentialIdentity: String, -) - -internal data class PreparedYoutubeSessionPoToken( - val context: YoutubeSessionPoTokenContext, - val token: YoutubeSessionPoToken, -) - -internal fun YoutubeSessionPoTokenContext.prewarmContext() = - YoutubeSessionPoTokenPrewarmContext( - clientName, - userAgent, - localization, - contentCountry, - loggedIn, - credentialIdentity, - ) - -internal class ContextBoundSingleFlight(private val executor: Executor) { - private data class Entry(val key: K, val task: FutureTask) - - private val lock = Any() - private var current: Entry? = null - - fun start(key: K, operation: () -> V): Boolean { - val created = object : FutureTask(operation) { - override fun done() { - synchronized(lock) { - if (current?.task === this) { - current = null - } - } - } - } - val replaced = synchronized(lock) { - val existing = current - if (existing != null && existing.key == key && !existing.task.isDone) { - return false - } - current = Entry(key, created) - existing?.task - } - replaced?.cancel(true) - try { - executor.execute(created) - } catch (error: RuntimeException) { - synchronized(lock) { - if (current?.task === created) { - current = null - } - } - throw error - } - return true - } - - fun inFlight(key: K): Future? = synchronized(lock) { - current?.takeIf { it.key == key && !it.task.isCancelled }?.task - } - - fun cancel() { - val task = synchronized(lock) { - current?.task.also { current = null } - } - task?.cancel(true) - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java index 6182a7da6..07ddbb01e 100644 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java @@ -49,7 +49,6 @@ import org.schabi.newpipe.util.StreamTypeUtil; import org.schabi.newpipe.App; import org.schabi.newpipe.extractor.exceptions.ExtractionException; -import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; import org.schabi.newpipe.player.datasource.SabrDashMediaSource; import org.schabi.newpipe.player.datasource.SabrSessionStore; @@ -501,7 +500,7 @@ private static void enrichSabrAudioTracks(@NonNull final StreamInfo streamInfo, for (final AudioStream a : audioStreams) { present.add(Objects.toString(a.getAudioTrackId(), "")); } - for (final YoutubeSabrFormat f : info.getFormats()) { + for (final YoutubeSabrInfo.Format f : info.getFormats()) { final String trackId = f.getAudioTrackId(); if (!f.isAudio() || trackId == null || !present.add(trackId)) { continue; diff --git a/app/src/main/java/org/schabi/newpipe/settings/YouTubeAccountSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/YouTubeAccountSettingsFragment.java index f5556fab9..10a964db5 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/YouTubeAccountSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/YouTubeAccountSettingsFragment.java @@ -79,7 +79,6 @@ protected void performLogout() { protected void refreshAccountDependentState() { super.refreshAccountDependentState(); App.reconcileYoutubePlayerClient(requireContext()); - App.prewarmYoutubeSessionPoToken(requireContext()); } @Override diff --git a/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java b/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java index 0c94fb888..5dbc71f4c 100644 --- a/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java @@ -13,7 +13,6 @@ import org.schabi.newpipe.DownloaderImpl; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.MediaFormat; -import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.DeliveryMethod; @@ -298,7 +297,7 @@ private static long getSabrContentLength(final Stream stream) { } else { return -1; } - final YoutubeSabrFormat format = ((YoutubeSabrInfo) stream.getDeliveryMethodInfo()) + final YoutubeSabrInfo.Format format = ((YoutubeSabrInfo) stream.getDeliveryMethodInfo()) .findFormatByItag(itag); return format != null && format.getContentLength() > 0 ? format.getContentLength() : -1; diff --git a/app/src/main/java/us/shandian/giga/get/SabrDownloadFormatResolver.kt b/app/src/main/java/us/shandian/giga/get/SabrDownloadFormatResolver.kt index a0a274f43..309574325 100644 --- a/app/src/main/java/us/shandian/giga/get/SabrDownloadFormatResolver.kt +++ b/app/src/main/java/us/shandian/giga/get/SabrDownloadFormatResolver.kt @@ -1,6 +1,5 @@ package us.shandian.giga.get -import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo import java.io.File import java.io.IOException @@ -17,7 +16,7 @@ internal object SabrDownloadFormatResolver { fun selectedAudioFormat( info: YoutubeSabrInfo, recoveries: Array, - ): YoutubeSabrFormat { + ): YoutubeSabrInfo.Format { val audioRecovery = recoveries.firstOrNull { it.kind == 'a' } return audioRecovery?.let { findAudioFormat(info, it) } ?: if (recoveries.any { it.kind == 'v' }) { @@ -36,7 +35,7 @@ internal object SabrDownloadFormatResolver { fun selectedVideoFormat( info: YoutubeSabrInfo, recoveries: Array, - ): YoutubeSabrFormat { + ): YoutubeSabrInfo.Format { val videoRecovery = recoveries.firstOrNull { it.kind == 'v' } return videoRecovery?.let { findVideoFormat(info, it) } ?: if (recoveries.any { it.kind == 'a' }) { @@ -74,7 +73,7 @@ internal object SabrDownloadFormatResolver { private fun findAudioFormat( info: YoutubeSabrInfo, recovery: MissionRecoveryInfo, - ): YoutubeSabrFormat { + ): YoutubeSabrInfo.Format { return info.formats.firstOrNull { format -> format.isAudio && (recovery.itag <= 0 || format.itag == recovery.itag) && @@ -89,7 +88,7 @@ internal object SabrDownloadFormatResolver { private fun findVideoFormat( info: YoutubeSabrInfo, recovery: MissionRecoveryInfo, - ): YoutubeSabrFormat { + ): YoutubeSabrInfo.Format { return info.formats.firstOrNull { format -> format.isVideo && (recovery.itag <= 0 || format.itag == recovery.itag) } ?: throw SabrDownloadException( @@ -98,32 +97,32 @@ internal object SabrDownloadFormatResolver { ) } - private fun findLightweightAudioFormat(info: YoutubeSabrInfo): YoutubeSabrFormat? { + private fun findLightweightAudioFormat(info: YoutubeSabrInfo): YoutubeSabrInfo.Format? { return info.formats .filter { it.isAudio } .sortedWith( - compareBy { !it.isOriginalAudio } + compareBy { !it.isOriginalAudio } .thenBy { it.isDrc } .thenBy { normalizedBitrate(it) }, ) .firstOrNull() } - private fun findLightweightVideoFormat(info: YoutubeSabrInfo): YoutubeSabrFormat? { + private fun findLightweightVideoFormat(info: YoutubeSabrInfo): YoutubeSabrInfo.Format? { return info.formats .filter { it.isVideo } .sortedWith( - compareBy { normalizedHeight(it) } + compareBy { normalizedHeight(it) } .thenBy { normalizedBitrate(it) }, ) .firstOrNull() } - private fun normalizedBitrate(format: YoutubeSabrFormat): Int { + private fun normalizedBitrate(format: YoutubeSabrInfo.Format): Int { return format.bitrate.takeIf { it > 0 } ?: Int.MAX_VALUE } - private fun normalizedHeight(format: YoutubeSabrFormat): Int { + private fun normalizedHeight(format: YoutubeSabrInfo.Format): Int { return format.height.takeIf { it > 0 } ?: Int.MAX_VALUE } } diff --git a/app/src/main/java/us/shandian/giga/get/SabrDownloadTarget.kt b/app/src/main/java/us/shandian/giga/get/SabrDownloadTarget.kt index fd0efc394..9d21c42ce 100644 --- a/app/src/main/java/us/shandian/giga/get/SabrDownloadTarget.kt +++ b/app/src/main/java/us/shandian/giga/get/SabrDownloadTarget.kt @@ -1,13 +1,13 @@ package us.shandian.giga.get -import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo import java.io.File import java.util.TreeMap internal data class SabrDownloadTarget( val resourceIndex: Int, val recovery: MissionRecoveryInfo, - val format: YoutubeSabrFormat, + val format: YoutubeSabrInfo.Format, val file: File, var nextWriteSequence: Int = 1, var initializationWritten: Boolean = false, diff --git a/app/src/main/java/us/shandian/giga/get/SabrDownloader.kt b/app/src/main/java/us/shandian/giga/get/SabrDownloader.kt index 9b750a9d6..e70a718b9 100644 --- a/app/src/main/java/us/shandian/giga/get/SabrDownloader.kt +++ b/app/src/main/java/us/shandian/giga/get/SabrDownloader.kt @@ -3,8 +3,8 @@ package us.shandian.giga.get import android.util.Log import org.schabi.newpipe.BuildConfig import org.schabi.newpipe.extractor.localization.Localization -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrProtocolException -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrRecoverableException +import org.schabi.newpipe.extractor.services.youtube.sabr.exception.SabrProtocolException +import org.schabi.newpipe.extractor.services.youtube.sabr.exception.SabrRecoverableException import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession @@ -94,9 +94,10 @@ internal class SabrDownloader( info, SabrDownloadFormatResolver.selectedAudioFormat(info, recoveries), SabrDownloadFormatResolver.selectedVideoFormat(info, recoveries), - LocalDomPoTokenProvider(mission.context), null, ) + val poToken = LocalDomPoTokenProvider(mission.context).getPoToken(info, session.streamState) + session.streamState.setPoToken(poToken) val workDir = prepareWorkDirectory() val targets = SabrDownloadFormatResolver.buildTargets(info, recoveries, workDir) restoreTargets(targets) @@ -124,6 +125,7 @@ internal class SabrDownloader( session, targets, SabrSegmentWriter(session, targets, outputs, ::reportBytesWritten), + poToken, ) } finally { outputs.values.forEach { output -> @@ -298,14 +300,13 @@ internal class SabrDownloader( session: YoutubeSabrSession, targets: List, writer: SabrSegmentWriter, + poToken: ByteArray, ) { val localization = Localization("en", "US") writer.observeWrittenInitializations() - if (targets.size == 1 && !targets.first().initializationWritten) { - fetchInitializationsOrRetry(writer, localization) - writer.observeWrittenInitializations() - writer.drainCachedInitializations() - } + prepareInitializations(session, targets, writer, localization, poToken) + writer.observeWrittenInitializations() + writer.drainCachedInitializations() var emptyResponses = 0 while (true) { @@ -327,17 +328,6 @@ internal class SabrDownloader( wroteSegment = writer.drainCachedSegments() || wroteSegment enforceSessionCacheLimit(session, writer) configureInitializedSingleTargetMode(session, targets) - if (hasMediaWaitingForInitialization(targets)) { - fetchMissingInitializationsOrRetry(writer, localization) - writer.observeWrittenInitializations() - wroteSegment = writer.drainCachedInitializations() || wroteSegment - wroteSegment = writer.drainCachedSegments() || wroteSegment - configureInitializedSingleTargetMode(session, targets) - if (hasMediaWaitingForInitialization(targets)) { - throw RetryColdStartException() - } - } - if (isDownloadComplete(session, targets)) { break } @@ -356,33 +346,32 @@ internal class SabrDownloader( } } - @Throws(IOException::class) - private fun fetchInitializationsOrRetry( + @Throws(IOException::class, InterruptedException::class) + private fun prepareInitializations( + session: YoutubeSabrSession, + targets: List, writer: SabrSegmentWriter, localization: Localization, + poToken: ByteArray, ) { - try { - writer.fetchUnwrittenInitializations(localization) - } catch (error: SabrProtocolException) { - if (isRetryableInitializationProtocolError(error)) { - throw RetryColdStartException(error) - } - throw error + val pendingTargets = targets.filterNot { it.initializationWritten } + if (pendingTargets.isEmpty()) { + return } - } - @Throws(IOException::class) - private fun fetchMissingInitializationsOrRetry( - writer: SabrSegmentWriter, - localization: Localization, - ) { try { - writer.fetchMissingInitializations(localization) - } catch (error: SabrProtocolException) { - if (isRetryableInitializationProtocolError(error)) { - throw RetryColdStartException(error) + ensureRunning() + val initialization = session.initialize(localization, 2_000, poToken) + for (target in pendingTargets) { + val data = if (target.format.isAudio) { + initialization.audioData + } else { + initialization.videoData + } ?: throw RetryColdStartException() + writer.writeInitializationData(target, data) } - throw error + } catch (failure: IOException) { + throw RetryColdStartException(failure) } } @@ -418,10 +407,6 @@ internal class SabrDownloader( } } - private fun hasMediaWaitingForInitialization(targets: List): Boolean { - return targets.any { target -> !target.initializationWritten && target.pending.isNotEmpty() } - } - private fun downloadPlayerTimeMs( session: YoutubeSabrSession, targets: List, @@ -510,15 +495,6 @@ internal class SabrDownloader( ) } - private fun isRetryableInitializationProtocolError(error: SabrProtocolException): Boolean { - val message = error.message.orEmpty() - if (!message.contains(":init")) { - return false - } - return message.contains("policy-only", ignoreCase = true) || - message.contains("not returned", ignoreCase = true) - } - private fun logDebug(message: String) { if (BuildConfig.DEBUG) { Log.d(TAG, message) diff --git a/app/src/main/java/us/shandian/giga/get/SabrSegmentWriter.kt b/app/src/main/java/us/shandian/giga/get/SabrSegmentWriter.kt index 540eaaed3..e9994d203 100644 --- a/app/src/main/java/us/shandian/giga/get/SabrSegmentWriter.kt +++ b/app/src/main/java/us/shandian/giga/get/SabrSegmentWriter.kt @@ -1,7 +1,6 @@ package us.shandian.giga.get -import org.schabi.newpipe.extractor.localization.Localization -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrMediaSegment +import org.schabi.newpipe.extractor.services.youtube.sabr.media.SabrMediaSegment import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession import java.io.IOException @@ -17,8 +16,8 @@ internal class SabrSegmentWriter( for (target in targets) { val data = target.initializationData ?: continue if (!target.initializationObserved) { - target.initializationObserved = - session.streamState.ingestInitializationData(target.format, data) + target.initializationObserved = session.streamState.hasSegmentIndex(target.format) + || session.streamState.ingestInitializationData(target.format, data) } } } @@ -59,35 +58,8 @@ internal class SabrSegmentWriter( } @Throws(IOException::class) - fun fetchMissingInitializations(localization: Localization): Boolean { - return fetchInitializations(localization, onlyWhenMediaIsPending = true) - } - - @Throws(IOException::class) - fun fetchUnwrittenInitializations(localization: Localization): Boolean { - return fetchInitializations(localization, onlyWhenMediaIsPending = false) - } - - @Throws(IOException::class) - private fun fetchInitializations( - localization: Localization, - onlyWhenMediaIsPending: Boolean, - ): Boolean { - var wroteInitialization = false - for (target in targets) { - if (target.initializationWritten || - (onlyWhenMediaIsPending && target.pending.isEmpty()) - ) { - continue - } - val request = SabrSegmentRequest.initialization(target.format) - val segment = session.fetchSegment(request, localization) - session.discardCachedSegment(request) - val data = segment.data - writeInitializationSegment(target, outputs.getValue(target.resourceIndex), data) - wroteInitialization = true - } - return wroteInitialization + fun writeInitializationData(target: SabrDownloadTarget, data: ByteArray): Boolean { + return writeInitializationSegment(target, outputs.getValue(target.resourceIndex), data) } @Throws(IOException::class) diff --git a/app/src/test/java/org/schabi/newpipe/player/datasource/LocalDomPoTokenRequestTest.kt b/app/src/test/java/org/schabi/newpipe/player/datasource/LocalDomPoTokenRequestTest.kt deleted file mode 100644 index 622e76be3..000000000 --- a/app/src/test/java/org/schabi/newpipe/player/datasource/LocalDomPoTokenRequestTest.kt +++ /dev/null @@ -1,60 +0,0 @@ -package org.schabi.newpipe.player.datasource - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -class LocalDomPoTokenRequestTest { - private val context = LocalDomPoTokenContext( - visitorData = "visitor-test", - clientName = "WEB", - clientVersion = "2.test", - userAgent = "test-user-agent", - ) - - @Test - fun parsesInlineAttestationChallenge() { - val response = """{"bgChallenge":{"program":"program","globalName":"global","interpreterJavascript":{"privateDoNotAccessOrElseSafeScriptWrappedValue":"script"}}}""" - - assertEquals( - SabrAttChallengeData("program", "global", "script", null), - parseSabrAttChallengeData(response), - ) - } - - @Test - fun resolvesProtocolRelativeAttestationInterpreterUrl() { - val response = """{"bgChallenge":{"program":"program","globalName":"global","interpreterUrl":{"privateDoNotAccessOrElseTrustedResourceUrlWrappedValue":"//example.test/interpreter.js"}}}""" - - assertEquals( - SabrAttChallengeData( - "program", - "global", - null, - "https://example.test/interpreter.js", - ), - parseSabrAttChallengeData(response), - ) - } - - @Test - fun cacheIdentityDoesNotCrossClientContexts() { - assertNotEquals( - context.cacheIdentity, - context.copy(clientName = "MWEB").cacheIdentity, - ) - assertNotEquals( - context.cacheIdentity, - context.copy(clientVersion = "3.test").cacheIdentity, - ) - assertNotEquals( - context.cacheIdentity, - context.copy(visitorData = "other-visitor").cacheIdentity, - ) - assertNotEquals( - context.cacheIdentity, - context.copy(userAgent = "different-user-agent").cacheIdentity, - ) - } -} diff --git a/app/src/test/java/org/schabi/newpipe/player/datasource/SabrPreferredAudioLanguageTest.java b/app/src/test/java/org/schabi/newpipe/player/datasource/SabrPreferredAudioLanguageTest.java index 6450f4551..06c54a47f 100644 --- a/app/src/test/java/org/schabi/newpipe/player/datasource/SabrPreferredAudioLanguageTest.java +++ b/app/src/test/java/org/schabi/newpipe/player/datasource/SabrPreferredAudioLanguageTest.java @@ -3,9 +3,8 @@ import static org.junit.Assert.assertSame; import org.junit.Test; -import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrClientProfile; -import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; +import org.schabi.newpipe.extractor.services.youtube.ItagItem; import java.lang.reflect.Constructor; import java.util.Arrays; @@ -14,11 +13,11 @@ public class SabrPreferredAudioLanguageTest { @Test public void preferredLanguageSelectsHighestBitrateRegionalTrack() throws Exception { - final YoutubeSabrFormat original = audioFormat( + final YoutubeSabrInfo.Format original = audioFormat( 140, "en.4", "English (original)", 128_000); - final YoutubeSabrFormat portugueseLow = audioFormat( + final YoutubeSabrInfo.Format portugueseLow = audioFormat( 139, "pt-BR.4", "Portuguese (Brazil)", 96_000); - final YoutubeSabrFormat portugueseHigh = audioFormat( + final YoutubeSabrInfo.Format portugueseHigh = audioFormat( 251, "pt-BR.4", "Portuguese (Brazil)", 160_000); final YoutubeSabrInfo info = info(original, portugueseLow, portugueseHigh); @@ -27,11 +26,11 @@ public void preferredLanguageSelectsHighestBitrateRegionalTrack() throws Excepti @Test public void explicitTrackOverridesPreferredLanguage() throws Exception { - final YoutubeSabrFormat original = audioFormat( + final YoutubeSabrInfo.Format original = audioFormat( 140, "en.4", "English (original)", 128_000); - final YoutubeSabrFormat portuguese = audioFormat( + final YoutubeSabrInfo.Format portuguese = audioFormat( 251, "pt-BR.4", "Portuguese (Brazil)", 160_000); - final YoutubeSabrFormat spanish = audioFormat( + final YoutubeSabrInfo.Format spanish = audioFormat( 250, "es-ES.4", "Spanish (Spain)", 96_000); final YoutubeSabrInfo info = info(original, portuguese, spanish); @@ -41,37 +40,34 @@ public void explicitTrackOverridesPreferredLanguage() throws Exception { @Test public void missingPreferredLanguageFallsBackToOriginal() throws Exception { - final YoutubeSabrFormat original = audioFormat( + final YoutubeSabrInfo.Format original = audioFormat( 140, "en.4", "English (original)", 128_000); - final YoutubeSabrFormat spanish = audioFormat( + final YoutubeSabrInfo.Format spanish = audioFormat( 251, "es-ES.4", "Spanish (Spain)", 160_000); final YoutubeSabrInfo info = info(original, spanish); assertSame(original, SabrSessionStore.pickAudioFormat(info, null, "pt")); } - private static YoutubeSabrFormat audioFormat(final int itag, + private static YoutubeSabrInfo.Format audioFormat(final int itag, final String trackId, final String displayName, final int bitrate) throws Exception { - final Constructor constructor = - YoutubeSabrFormat.class.getDeclaredConstructor(int.class, long.class, - String.class, String.class, String.class, String.class, boolean.class, - String.class, String.class, boolean.class, int.class, int.class, - int.class, long.class, long.class, String.class, long.class, long.class); - constructor.setAccessible(true); - return constructor.newInstance(itag, 123456L, null, "audio/mp4", trackId, - displayName, displayName.contains("original"), null, "AUDIO_QUALITY_MEDIUM", - false, -1, -1, bitrate, 100_000L, 300_000L, null, -1L, -1L); + final ItagItem parsedFormat = ItagItem.getItag(itag); + parsedFormat.setBitrate(bitrate); + parsedFormat.setContentLength(100_000L); + parsedFormat.setApproxDurationMs(300_000L); + return YoutubeSabrInfo.Format.fromParsedFormat(parsedFormat, 123456L, null, "audio/mp4", + trackId, displayName, false, null, -1L, -1L); } - private static YoutubeSabrInfo info(final YoutubeSabrFormat... formats) throws Exception { + private static YoutubeSabrInfo info(final YoutubeSabrInfo.Format... formats) throws Exception { final Constructor constructor = - YoutubeSabrInfo.class.getDeclaredConstructor(YoutubeSabrClientProfile.class, + YoutubeSabrInfo.class.getDeclaredConstructor( String.class, String.class, String.class, String.class, String.class, String.class, java.util.List.class); constructor.setAccessible(true); - return constructor.newInstance(YoutubeSabrClientProfile.MWEB, "video-id", "cpn", + return constructor.newInstance("video-id", "cpn", "2.test", "visitor", "https://sabr.test", null, Arrays.asList(formats)); } } diff --git a/app/src/test/java/org/schabi/newpipe/player/datasource/SabrSessionPoTokenPrewarmerTest.kt b/app/src/test/java/org/schabi/newpipe/player/datasource/SabrSessionPoTokenPrewarmerTest.kt index 302012d7e..58143920e 100644 --- a/app/src/test/java/org/schabi/newpipe/player/datasource/SabrSessionPoTokenPrewarmerTest.kt +++ b/app/src/test/java/org/schabi/newpipe/player/datasource/SabrSessionPoTokenPrewarmerTest.kt @@ -8,7 +8,6 @@ import org.junit.Assert.assertTrue import org.junit.Test import org.schabi.newpipe.extractor.localization.ContentCountry import org.schabi.newpipe.extractor.localization.Localization -import org.schabi.newpipe.extractor.services.youtube.YoutubeSessionPoToken import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors import java.util.concurrent.TimeUnit From 07caf310e65990b4862964966f6864c273d1ccb7 Mon Sep 17 00:00:00 2001 From: InfinityLoop1308 <96324692+InfinityLoop1308@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:53:18 +0800 Subject: [PATCH 02/13] 2 --- .../player/datasource/SabrBackoffState.java | 40 + .../player/datasource/SabrMediaBridge.java | 144 ++- .../datasource/SabrSegmentDataSource.java | 67 +- .../player/datasource/SabrSessionStore.java | 108 +- .../player/datasource/SabrSourceSpec.java | 20 +- .../player/datasource/SabrStreamPump.java | 962 ------------------ .../us/shandian/giga/get/SabrDownloader.kt | 41 +- .../us/shandian/giga/get/SabrSegmentWriter.kt | 58 +- 8 files changed, 311 insertions(+), 1129 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/SabrBackoffState.java delete mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrBackoffState.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrBackoffState.java new file mode 100644 index 000000000..1af2e8398 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrBackoffState.java @@ -0,0 +1,40 @@ +package org.schabi.newpipe.player.datasource; + +import androidx.annotation.NonNull; + +import java.util.concurrent.CopyOnWriteArrayList; + +/** Client-owned, observable backoff gate shared by all readers of one SABR session. */ +public final class SabrBackoffState { + public interface Listener { void onBackoffChanged(long remainingMs); } + + private final Object monitor = new Object(); + private final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList<>(); + private volatile long deadlineNs; + + long remainingMs() { + final long remaining = deadlineNs - System.nanoTime(); + return remaining <= 0 ? 0 : Math.max(1, remaining / 1_000_000L); + } + + void update(final int backoffMs) { + deadlineNs = backoffMs <= 0 ? 0 + : System.nanoTime() + backoffMs * 1_000_000L; + final long remaining = remainingMs(); + synchronized (monitor) { monitor.notifyAll(); } + for (final Listener listener : listeners) { + listener.onBackoffChanged(remaining); + } + } + + void awaitReady() throws InterruptedException { + while (true) { + final long remaining = remainingMs(); + if (remaining == 0) return; + synchronized (monitor) { monitor.wait(Math.min(remaining, 250)); } + } + } + + void addListener(@NonNull final Listener listener) { listeners.add(listener); } + void removeListener(@NonNull final Listener listener) { listeners.remove(listener); } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaBridge.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaBridge.java index 6005d9fe0..624669864 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaBridge.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaBridge.java @@ -10,26 +10,57 @@ import org.schabi.newpipe.extractor.services.youtube.sabr.media.SabrMediaSegment; import java.io.IOException; +import java.util.ArrayDeque; +import java.util.Deque; import java.util.Map; +import java.util.List; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.LinkedBlockingQueue; /** Bridges Media3 segment demand to serialized SABR transactions. */ final class SabrMediaBridge { + private static final int MAX_AHEAD_SEGMENTS = 64; private final YoutubeSabrSession session; private final Localization localization; + private final SabrBackoffState backoff; private final LinkedBlockingQueue pending = new LinkedBlockingQueue<>(); private final Map pendingKeys = new ConcurrentHashMap<>(); private final Map failures = new ConcurrentHashMap<>(); + private final Map ahead = new ConcurrentHashMap<>(); + private final Deque aheadOrder = new ArrayDeque<>(); + private final Object available = new Object(); private volatile IOException networkFailure; private volatile boolean stopped; private volatile boolean started; + private volatile long mediaProgressVersion; private Thread worker; SabrMediaBridge(@NonNull final YoutubeSabrSession session, - @NonNull final Localization localization) { + @NonNull final Localization localization, + @NonNull final SabrBackoffState backoff) { this.session = session; this.localization = localization; + this.backoff = backoff; + } + + void seedSegments(@NonNull final List segments) { + for (final SabrMediaSegment segment : segments) { + final String segmentKey = key(segment.getHeader().getItag(), + segment.getHeader().isInitSegment() + ? "init" : String.valueOf(segment.getHeader().getSequenceNumber())); + final SabrMediaSegment previous = ahead.putIfAbsent(segmentKey, segment); + if (previous != null) { + segment.delete(); + } else { + synchronized (available) { + aheadOrder.addLast(segmentKey); + } + } + } + synchronized (available) { + trimAhead(); + available.notifyAll(); + } } synchronized void ensureStarted() { @@ -48,11 +79,47 @@ void stop() { if (current != null) { current.interrupt(); } + for (final SabrMediaSegment segment : ahead.values()) { + segment.delete(); + } + ahead.clear(); + synchronized (available) { + aheadOrder.clear(); + available.notifyAll(); + } } @Nullable SabrMediaSegment getCached(@NonNull final SabrSegmentRequest request) { - return session.getReadableSegment(request); + return ahead.get(key(request)); + } + + @Nullable + SabrMediaSegment awaitReadableSegment(@NonNull final SabrSegmentRequest request, + final long timeoutMs) throws InterruptedException { + SabrMediaSegment segment = getCached(request); + if (segment != null || timeoutMs <= 0) { + return segment; + } + synchronized (available) { + segment = getCached(request); + if (segment == null) { + available.wait(timeoutMs); + segment = getCached(request); + } + } + return segment; + } + + void discard(@NonNull final SabrSegmentRequest request) { + final String segmentKey = key(request); + final SabrMediaSegment segment = ahead.remove(segmentKey); + synchronized (available) { + aheadOrder.remove(segmentKey); + } + if (segment != null) { + segment.delete(); + } } @Nullable @@ -77,6 +144,18 @@ String getStateName() { return stopped ? "STOPPED" : (pending.isEmpty() ? "IDLE" : "REQUESTING"); } + long getAheadBytes() { + long bytes = 0; + for (final SabrMediaSegment segment : ahead.values()) { + bytes += segment.getLength(); + } + return bytes; + } + + long getMediaProgressVersion() { + return mediaProgressVersion; + } + void requestInitialization(@NonNull final org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo.Format format) { requestSegmentDemand(SabrSegmentRequest.initialization(format), this, 0); } @@ -84,7 +163,7 @@ void requestInitialization(@NonNull final org.schabi.newpipe.extractor.services. void requestSegmentDemand(@NonNull final SabrSegmentRequest request, @NonNull final Object readerOwner, final long readerGeneration) { - if (session.getCachedSegment(request) != null) { + if (ahead.containsKey(key(request))) { return; } final String key = key(request); @@ -103,12 +182,12 @@ void clearSegmentDemand(@NonNull final SabrSegmentRequest request, } void requestRefetchFrom(@NonNull final SabrSegmentRequest request) { - session.prepareForRewind(request); + session.getStreamState().rewindTo(request); requestSegmentDemand(request, this, 0); } void requestForwardSeekTo(@NonNull final SabrSegmentRequest request) { - session.prepareForForwardJump(request); + session.getStreamState().jumpTo(request); requestSegmentDemand(request, this, 0); } @@ -116,9 +195,9 @@ void requestSeekTo(@NonNull final SabrSegmentRequest request, final boolean backward, final long positionMs) { if (backward) { - session.prepareForRewind(request, positionMs); + session.getStreamState().rewindTo(request, positionMs); } else { - session.prepareForForwardJump(request, positionMs); + session.getStreamState().jumpTo(request, positionMs); } requestSegmentDemand(request, this, 0); } @@ -130,16 +209,38 @@ void noteSeekWithinCache() { private void run() { while (!stopped) { try { + backoff.awaitReady(); final SabrSegmentRequest request = pending.take(); final String requestKey = key(request); if (!pendingKeys.containsKey(requestKey)) { continue; } - session.requestOnce(localization); + final YoutubeSabrSession.RequestResult requestResult = + session.requestOnce(localization, segment -> { + final String segmentKey = key(segment.getHeader().getItag(), + segment.getHeader().isInitSegment() + ? "init" : String.valueOf(segment.getHeader().getSequenceNumber())); + final SabrMediaSegment previous = ahead.putIfAbsent(segmentKey, segment); + if (previous != null && previous != segment) { + segment.delete(); + } else if (previous == null) { + mediaProgressVersion++; + synchronized (available) { + aheadOrder.addLast(segmentKey); + trimAhead(); + } + } + synchronized (available) { + available.notifyAll(); + } + }); + // Backoff is returned as request data; the owning Holder publishes it to + // observers and gates the next request. + backoff.update(requestResult.getBackoffMs()); pendingKeys.remove(requestKey); - pending.removeIf(candidate -> session.getCachedSegment(candidate) != null); + pending.removeIf(candidate -> ahead.containsKey(key(candidate))); for (final SabrSegmentRequest candidate : pending) { - if (session.getCachedSegment(candidate) != null) { + if (ahead.containsKey(key(candidate))) { pendingKeys.remove(key(candidate)); } } @@ -162,8 +263,27 @@ private void run() { } } + private static String key(@NonNull final SabrSegmentRequest request) { - return request.getFormat().getItag() + ":" - + (request.isInitializationSegment() ? "init" : request.getSequenceNumber()); + return key(request.getFormat().getItag(), request.isInitializationSegment() + ? "init" : String.valueOf(request.getSequenceNumber())); + } + + private static String key(final int itag, @NonNull final String sequence) { + return itag + ":" + sequence; + } + + private void trimAhead() { + while (aheadOrder.size() > MAX_AHEAD_SEGMENTS) { + final String oldest = aheadOrder.removeFirst(); + if (pendingKeys.containsKey(oldest)) { + aheadOrder.addLast(oldest); + break; + } + final SabrMediaSegment removed = ahead.remove(oldest); + if (removed != null) { + removed.delete(); + } + } } } diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java index 9a370d698..5794b7537 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java @@ -53,6 +53,7 @@ public final class SabrSegmentDataSource implements DataSource { private int pos; private boolean opened; private volatile boolean canceled; + @Nullable private SabrSegmentRequest openedRequest; public SabrSegmentDataSource(final SabrSessionStore.Holder holder, final Object readerOwner, @@ -112,6 +113,7 @@ public long open(final DataSpec dataSpec) throws IOException { this.progressiveDataEndPosition = -1; this.pos = (int) Math.max(0, dataSpec.position); SabrSegmentRequest request = requestFromUri(dataSpec.uri); + openedRequest = request; final YoutubeSabrInfo.Format format = request.getFormat(); final long availableRemaining; final int openedBytes; @@ -145,7 +147,7 @@ public long open(final DataSpec dataSpec) throws IOException { Log.w(TAG, "Spool file vanished before open; refetching video=" + holder.videoId + " itag=" + format.getItag() + " seq=" + request.getSequenceNumber()); - holder.session.discardCachedSegment(request); + holder.getBridge(localization).discard(request); progressiveSegment = null; segment = awaitSegment(request); if (segment != null) { @@ -184,7 +186,7 @@ private byte[] getInitializationData(final YoutubeSabrInfo.Format format) throws return cached; } final SabrMediaSegment segment = - holder.session.getCachedSegment(SabrSegmentRequest.initialization(format)); + holder.getBridge(localization).getCached(SabrSegmentRequest.initialization(format)); if (segment != null) { final byte[] data = segment.getData(); holder.setInitializationData(itag, data); @@ -192,7 +194,13 @@ private byte[] getInitializationData(final YoutubeSabrInfo.Format format) throws } final SabrMediaSegment loadedSegment = awaitSegment(SabrSegmentRequest.initialization(format)); - return loadedSegment == null ? new byte[0] : loadedSegment.getData(); + if (loadedSegment == null) { + return new byte[0]; + } + final byte[] loaded = loadedSegment.getData(); + holder.setInitializationData(itag, loaded); + holder.getBridge(localization).discard(SabrSegmentRequest.initialization(format)); + return loaded; } @Override @@ -290,11 +298,11 @@ private SabrMediaSegment awaitSegment(final SabrSegmentRequest request) throws I if (holder.isInvalidated()) { throw invalidatedException(request.getFormat()); } - final SabrStreamPump pump = holder.getPump(localization); + final SabrMediaBridge bridge = holder.getBridge(localization); long readerGeneration = holder.getReaderGeneration(readerOwner); final long waitStart = System.currentTimeMillis(); long noProgressSinceMs = waitStart; - long mediaProgressVersion = holder.session.getMediaProgressVersion(); + long mediaProgressVersion = bridge.getMediaProgressVersion(); long recoveryAtMs = -1; long lastRecoveryAtMs = -1; boolean loggedWait = false; @@ -308,7 +316,7 @@ private SabrMediaSegment awaitSegment(final SabrSegmentRequest request) throws I if (readerGeneration < 0 && currentReaderGeneration >= 0) { readerGeneration = currentReaderGeneration; noProgressSinceMs = System.currentTimeMillis(); - mediaProgressVersion = holder.session.getMediaProgressVersion(); + mediaProgressVersion = bridge.getMediaProgressVersion(); } else if (readerGeneration >= 0 && currentReaderGeneration != readerGeneration) { throw new InterruptedIOException("SABR reader demand superseded for itag=" @@ -329,25 +337,25 @@ private SabrMediaSegment awaitSegment(final SabrSegmentRequest request) throws I } final IOException demandFailure = !request.isInitializationSegment() && readerGeneration >= 0 - ? pump.takeDemandFailure(request, readerOwner, readerGeneration) : null; + ? bridge.takeDemandFailure(request, readerOwner, readerGeneration) : null; if (demandFailure != null) { throw demandFailure; } - final IOException networkFailure = pump.takeNetworkFailure(); + final IOException networkFailure = bridge.takeNetworkFailure(); if (networkFailure != null) { throw networkFailure; } if (request.isInitializationSegment()) { - pump.requestInitialization(format); + bridge.requestInitialization(format); } else { - pump.ensureStarted(); + bridge.ensureStarted(); } final SabrMediaSegment segment; if (request.isInitializationSegment()) { - segment = pump.getCached(request); + segment = bridge.getCached(request); } else { try { - segment = holder.session.awaitReadableSegment(request, WAIT_MS); + segment = bridge.awaitReadableSegment(request, WAIT_MS); } catch (final InterruptedException e) { Thread.currentThread().interrupt(); throw new IOException("Interrupted waiting for SABR segment", e); @@ -381,18 +389,18 @@ private SabrMediaSegment awaitSegment(final SabrSegmentRequest request) throws I return null; } if (!request.isInitializationSegment() && readerGeneration >= 0) { - pump.requestSegmentDemand(request, readerOwner, readerGeneration); + bridge.requestSegmentDemand(request, readerOwner, readerGeneration); } if (!loggedWait && System.currentTimeMillis() - waitStart > 1000) { loggedWait = true; holder.session.addDiagnosticEvent("wait itag=" + format.getItag() + " init=" + request.isInitializationSegment() + " seq=" + request.getSequenceNumber() - + " pump=" + pump.getStateName() + + " bridge=" + bridge.getStateName() + " edgeMs=" + holder.session.getStreamState().getMinBufferedEndMs() + " readerHeadMs=" + holder.getReaderHeadMs() + " readerTailMs=" + holder.getReaderTailMs() - + " cachedBytes=" + holder.session.getCachedBytes()); + + " aheadBytes=" + bridge.getAheadBytes()); Log.d(TAG, "waiting video=" + holder.videoId + " itag=" + format.getItag() + " init=" + request.isInitializationSegment() @@ -401,14 +409,14 @@ private SabrMediaSegment awaitSegment(final SabrSegmentRequest request) throws I + " readerHeadMs=" + holder.getReaderHeadMs()); } final long now = System.currentTimeMillis(); - final long currentMediaProgressVersion = holder.session.getMediaProgressVersion(); + final long currentMediaProgressVersion = bridge.getMediaProgressVersion(); if (currentMediaProgressVersion != mediaProgressVersion) { mediaProgressVersion = currentMediaProgressVersion; noProgressSinceMs = now; recoveryAtMs = -1; lastRecoveryAtMs = -1; } - if (holder.session.getBackoffRemainingMs() > 0) { + if (holder.getBackoffRemainingMs() > 0) { // Server-directed pacing is not a playback stall. Keep polling so cancellation and // reader replacement remain responsive, but do not let the local recovery watchdog // reposition the session and attempt another request before the server deadline. @@ -419,12 +427,12 @@ private SabrMediaSegment awaitSegment(final SabrSegmentRequest request) throws I if (now - noProgressSinceMs > RECOVERY_AFTER_NO_PROGRESS_MS && (lastRecoveryAtMs < 0 || now - lastRecoveryAtMs > RECOVERY_RETRY_MS) - && pump.canRecover() + && bridge.canRecover() && (request.isInitializationSegment() || readerGeneration >= 0)) { String recovery; if (request.isInitializationSegment()) { recovery = "init"; - pump.requestInitialization(format); + bridge.requestInitialization(format); } else { final long edgeMs = holder.session.getStreamState().getMinBufferedEndMs(); final long segStartMs = holder.session.getStreamState() @@ -433,24 +441,24 @@ private SabrMediaSegment awaitSegment(final SabrSegmentRequest request) throws I recovery = "rewind"; holder.setReaderPositionMs(readerOwner, readerGeneration, format.getItag(), segStartMs); - pump.requestRefetchFrom(request); + bridge.requestRefetchFrom(request); } else if (segStartMs > edgeMs + FORWARD_SEEK_AHEAD_MS) { recovery = "forward"; holder.setReaderPositionMs(readerOwner, readerGeneration, format.getItag(), segStartMs); - pump.requestForwardSeekTo(request); + bridge.requestForwardSeekTo(request); } else { recovery = "near_edge_refetch"; holder.setReaderPositionMs(readerOwner, readerGeneration, format.getItag(), segStartMs); - pump.requestRefetchFrom(request); + bridge.requestRefetchFrom(request); } } holder.session.addDiagnosticEvent("recovery type=" + recovery + " itag=" + format.getItag() + " init=" + request.isInitializationSegment() + " seq=" + request.getSequenceNumber() - + " pump=" + pump.getStateName() + + " bridge=" + bridge.getStateName() + " edgeMs=" + holder.session.getStreamState().getMinBufferedEndMs()); if (recoveryAtMs < 0) { recoveryAtMs = now; @@ -458,18 +466,18 @@ private SabrMediaSegment awaitSegment(final SabrSegmentRequest request) throws I lastRecoveryAtMs = now; } if (recoveryAtMs >= 0 && now - recoveryAtMs > RECOVERY_FAILURE_MS - && pump.canRecover()) { + && bridge.canRecover()) { final SabrLogicException failure = new SabrLogicException( "SABR made no progress after recovery for itag=" + format.getItag() + ", init=" + request.isInitializationSegment() + ", seq=" + request.getSequenceNumber() + ", waitMs=" + (now - waitStart) - + ", pump=" + pump.getStateName() + + ", bridge=" + bridge.getStateName() + ", edgeMs=" + holder.session.getStreamState().getMinBufferedEndMs() + ", readerHeadMs=" + holder.getReaderHeadMs() + ", readerTailMs=" + holder.getReaderTailMs() - + ", cachedBytes=" + holder.session.getCachedBytes() + + ", aheadBytes=" + bridge.getAheadBytes() + ", trace=" + holder.session.getDiagnosticTrace()); holder.failTerminal(failure); throw failure; @@ -485,7 +493,7 @@ private SabrMediaSegment awaitSegment(final SabrSegmentRequest request) throws I } } finally { if (!request.isInitializationSegment()) { - pump.clearSegmentDemand(request, readerOwner, readerGeneration); + bridge.clearSegmentDemand(request, readerOwner, readerGeneration); } } } @@ -535,6 +543,11 @@ public void close() { } catch (final IOException e) { Log.w(TAG, "Could not close SABR segment stream", e); } + final SabrSegmentRequest request = openedRequest; + openedRequest = null; + if (request != null && !request.isInitializationSegment() && holder != null) { + holder.getBridge(localization).discard(request); + } opened = false; } } diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java index afb07fcd4..6329c6230 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java @@ -19,6 +19,7 @@ import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrStreamState; +import org.schabi.newpipe.extractor.services.youtube.sabr.media.SabrMediaSegment; import org.schabi.newpipe.extractor.stream.DeliveryMethod; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.VideoStream; @@ -136,13 +137,16 @@ private static final class BootstrapResult { @NonNull private final byte[] audioInitialization; @NonNull private final byte[] videoInitialization; @NonNull private final AtomicReference preparedSession; + @NonNull private final AtomicReference> mediaSegments; BootstrapResult(@NonNull final byte[] audioInitialization, @NonNull final byte[] videoInitialization, - @Nullable final YoutubeSabrSession preparedSession) { + @Nullable final YoutubeSabrSession preparedSession, + @NonNull final List mediaSegments) { this.audioInitialization = audioInitialization.clone(); this.videoInitialization = videoInitialization.clone(); this.preparedSession = new AtomicReference<>(preparedSession); + this.mediaSegments = new AtomicReference<>(mediaSegments); } @Nullable @@ -150,16 +154,18 @@ YoutubeSabrSession takePreparedSession() { return preparedSession.getAndSet(null); } + @NonNull + List getMediaSegments() { + final List value = mediaSegments.getAndSet(Collections.emptyList()); + return value; + } + void discardPreparedSession() { - final YoutubeSabrSession session = preparedSession.getAndSet(null); - if (session != null) { - session.clearCache(); - } + preparedSession.set(null); } } - private static final class BootstrapBackoffState - implements YoutubeSabrSession.BackoffListener { + private static final class BootstrapBackoffState { @NonNull private final Context appContext; @NonNull private final String videoId; private long deadlineElapsedMs = SabrBackoffCoordinator.NO_DEADLINE; @@ -171,7 +177,6 @@ private static final class BootstrapBackoffState this.videoId = videoId; } - @Override public synchronized void onBackoffStarted(final int durationMs) { deadlineElapsedMs = SystemClock.elapsedRealtime() + durationMs; Log.i(TAG, "bootstrap_backoff_start video=" + videoId @@ -182,7 +187,6 @@ public synchronized void onBackoffStarted(final int durationMs) { } } - @Override public synchronized void onBackoffFinished() { Log.i(TAG, "bootstrap_backoff_finish video=" + videoId + " waiters=" + waiters); @@ -272,12 +276,13 @@ public static final class Holder { private final AtomicInteger leaseReferences = new AtomicInteger(); private Object readerOwner; private long readerGeneration; - private volatile SabrStreamPump pump; + private volatile SabrMediaBridge bridge; + @NonNull private final SabrBackoffState backoffState; + @NonNull private final List bootstrapMediaSegments; private volatile boolean invalidated; private volatile String stopReason; private volatile SabrLogicException terminalFailure; private long lastDiagnosticsAtMs; - private long lastDiagnosticsPeakCachedBytes; Holder(@NonNull final Context appContext, @NonNull final String videoId, @@ -292,7 +297,8 @@ public static final class Holder { this.session = session; this.audioFormat = audioFormat; this.videoFormat = videoFormat; - attachBackoffListener(); + this.bootstrapMediaSegments = Collections.emptyList(); + this.backoffState = new SabrBackoffState(); } Holder(@NonNull final Context appContext, @@ -306,27 +312,10 @@ public static final class Holder { this.session = session; this.audioFormat = spec.getAudioFormat(); this.videoFormat = spec.getVideoFormat(); + this.bootstrapMediaSegments = spec.takeBootstrapMediaSegments(); + this.backoffState = new SabrBackoffState(); retainBootstrapInitialization(spec, audioFormat); retainBootstrapInitialization(spec, videoFormat); - attachBackoffListener(); - } - - private void attachBackoffListener() { - session.setBackoffListener(new YoutubeSabrSession.BackoffListener() { - @Override - public void onBackoffStarted(final int durationMs) { - Log.i(TAG, "backoff_start video=" + videoId - + " durationMs=" + durationMs); - SabrBackoffCoordinator.getInstance().begin(appContext, Holder.this, - SystemClock.elapsedRealtime() + durationMs); - } - - @Override - public void onBackoffFinished() { - Log.i(TAG, "backoff_finish video=" + videoId); - SabrBackoffCoordinator.getInstance().clear(appContext, Holder.this); - } - }); } public long getPlayerTimeMs() { @@ -441,11 +430,12 @@ void requestSeek(final long positionMs, @NonNull final Localization localization .getSegmentNumberAtOrAfterTimeMs(audioFormat, positionMs); final SabrSegmentRequest audioRequest = SabrSegmentRequest.media( audioFormat, audioSequence); - if (session.getCachedSegment(request) == null - || session.getCachedSegment(audioRequest) == null) { - getPump(localization).requestSeekTo(request, backward, positionMs); + final SabrMediaBridge currentBridge = getBridge(localization); + if (currentBridge.getCached(request) == null + || currentBridge.getCached(audioRequest) == null) { + currentBridge.requestSeekTo(request, backward, positionMs); } else { - getPump(localization).noteSeekWithinCache(); + currentBridge.noteSeekWithinCache(); } } @@ -548,11 +538,24 @@ public boolean hasUnstartedActiveReader() { return false; } - synchronized SabrStreamPump getPump(@NonNull final Localization localization) { - if (pump == null) { - pump = new SabrStreamPump(session, this, localization); + synchronized SabrMediaBridge getBridge(@NonNull final Localization localization) { + if (bridge == null) { + bridge = new SabrMediaBridge(session, localization, backoffState); + bridge.seedSegments(bootstrapMediaSegments); } - return pump; + return bridge; + } + + public long getBackoffRemainingMs() { + return backoffState.remainingMs(); + } + + public void addBackoffListener(@NonNull final SabrBackoffState.Listener listener) { + backoffState.addListener(listener); + } + + public void removeBackoffListener(@NonNull final SabrBackoffState.Listener listener) { + backoffState.removeListener(listener); } boolean isInvalidated() { @@ -579,10 +582,9 @@ void throwIfTerminal() throws SabrLogicException { void stop(@NonNull final String reason) { SabrBackoffCoordinator.getInstance().clear(appContext, this); - session.setBackoffListener(null); Log.w(TAG, "stop video=" + videoId + " reason=" + reason + " leases=" + leaseReferences.get() + " activeTracks=" + hasActiveTracks() - + " pump=" + (pump == null ? "none" : pump.getStateName())); + + " bridge=" + (bridge == null ? "none" : bridge.getStateName())); recordDiagnostics("stop reason=" + reason); stopReason = reason; session.addDiagnosticEvent("session_stop reason=" + reason @@ -595,12 +597,10 @@ void stop(@NonNull final String reason) { readerPositions.clear(); applyActiveTracks(); } - final SabrStreamPump streamPump = pump; - pump = null; - if (streamPump != null) { - streamPump.stop(); - } else { - session.clearCache(); + final SabrMediaBridge mediaBridge = bridge; + bridge = null; + if (mediaBridge != null) { + mediaBridge.stop(); } } @@ -611,14 +611,11 @@ boolean isBeyondEnd(@NonNull final SabrSegmentRequest request) { void recordDiagnostics(@NonNull final String event) { SabrPlaybackDiagnostics.record(appContext, this, event); lastDiagnosticsAtMs = System.currentTimeMillis(); - lastDiagnosticsPeakCachedBytes = session.getPeakCachedBytes(); } void recordDiagnosticsThrottled(@NonNull final String event) { final long now = System.currentTimeMillis(); - final long peakCachedBytes = session.getPeakCachedBytes(); - if (now - lastDiagnosticsAtMs >= 5_000 - || peakCachedBytes != lastDiagnosticsPeakCachedBytes) { + if (now - lastDiagnosticsAtMs >= 5_000) { recordDiagnostics(event); } } @@ -682,7 +679,7 @@ public static SabrSourceSpec createSourceSpec(@NonNull final String videoId, PlaybackStartupTrace.markForVideoId(videoId, "sabr_source_spec_ready"); return new SabrSourceSpec(videoId, info, audioFormat, videoFormat, localization, bootstrap.audioInitialization, bootstrap.videoInitialization, - bootstrap.takePreparedSession()); + bootstrap.takePreparedSession(), bootstrap.getMediaSegments()); } /** Starts expensive first-play work while the user is still reading the detail page. */ @@ -759,7 +756,6 @@ private static BootstrapResult createPreparation(@NonNull final Context context, "sabr-bootstrap/" + info.getVideoId() + '-' + System.nanoTime()); final YoutubeSabrSession session = new YoutubeSabrSession(info, audioFormat, videoFormat, spoolDirectory); - session.setBackoffListener(backoffState); boolean handedOff = false; try { final byte[] poToken = awaitWarmedToken(info.getVideoId(), info, sessionProvider, @@ -785,12 +781,8 @@ private static BootstrapResult createPreparation(@NonNull final Context context, } handedOff = true; return new BootstrapResult(initialization.getAudioData(), initialization.getVideoData(), - session); + session, initialization.getMediaSegments()); } finally { - session.setBackoffListener(null); - if (!handedOff) { - session.clearCache(); - } } } diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSourceSpec.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSourceSpec.java index 24308880c..5c2a6d553 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSourceSpec.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSourceSpec.java @@ -7,9 +7,12 @@ import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrStreamState; +import org.schabi.newpipe.extractor.services.youtube.sabr.media.SabrMediaSegment; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; +import java.util.Collections; +import java.util.List; /** Immutable metadata needed to construct a SABR MediaSource without owning a live session. */ public final class SabrSourceSpec { @@ -24,6 +27,7 @@ public final class SabrSourceSpec { @NonNull private final byte[] audioInitializationData; @NonNull private final byte[] videoInitializationData; @NonNull private final AtomicReference preparedSession; + @NonNull private final List bootstrapMediaSegments; public SabrSourceSpec(@NonNull final String videoId, @NonNull final YoutubeSabrInfo info, @@ -33,7 +37,7 @@ public SabrSourceSpec(@NonNull final String videoId, @NonNull final byte[] audioInitializationData, @NonNull final byte[] videoInitializationData) { this(videoId, info, audioFormat, videoFormat, localization, - audioInitializationData, videoInitializationData, null); + audioInitializationData, videoInitializationData, null, Collections.emptyList()); } SabrSourceSpec(@NonNull final String videoId, @@ -43,7 +47,8 @@ public SabrSourceSpec(@NonNull final String videoId, @NonNull final Localization localization, @NonNull final byte[] audioInitializationData, @NonNull final byte[] videoInitializationData, - @Nullable final YoutubeSabrSession preparedSession) { + @Nullable final YoutubeSabrSession preparedSession, + @NonNull final List bootstrapMediaSegments) { this.sourceId = NEXT_SOURCE_ID.incrementAndGet(); this.videoId = videoId; this.info = info; @@ -53,6 +58,7 @@ public SabrSourceSpec(@NonNull final String videoId, this.audioInitializationData = audioInitializationData.clone(); this.videoInitializationData = videoInitializationData.clone(); this.preparedSession = new AtomicReference<>(preparedSession); + this.bootstrapMediaSegments = bootstrapMediaSegments; } @NonNull @@ -112,10 +118,12 @@ YoutubeSabrSession takePreparedSession() { return preparedSession.getAndSet(null); } + @NonNull + List takeBootstrapMediaSegments() { + return bootstrapMediaSegments; + } + void discardPreparedSession() { - final YoutubeSabrSession session = preparedSession.getAndSet(null); - if (session != null) { - session.clearCache(); - } + preparedSession.set(null); } } diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java deleted file mode 100644 index 46467ac57..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrStreamPump.java +++ /dev/null @@ -1,962 +0,0 @@ -package org.schabi.newpipe.player.datasource; - -import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.schabi.newpipe.extractor.exceptions.ExtractionException; -import org.schabi.newpipe.extractor.localization.Localization; -import org.schabi.newpipe.extractor.services.youtube.sabr.media.SabrMediaSegment; -import org.schabi.newpipe.extractor.services.youtube.sabr.exception.SabrRecoverableException; -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest; -import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession; -import org.schabi.newpipe.player.SabrBackoffCoordinator; - -import java.io.IOException; -import java.io.InterruptedIOException; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.locks.LockSupport; - -final class SabrStreamPump { - enum State { - IDLE, - REQUESTING, - REPOSITIONING, - THROTTLED, - NETWORK_FAILED, - TERMINAL, - STOPPED - } - - private static final String TAG = "SabrStreamPump"; - private static final long IDLE_POLL_MS = 100; // server paced us / nothing new this round - private static final long ERROR_RETRY_MS = 1000; // transient network error - private static final int MAX_CONSECUTIVE_IO_ERRORS = 5; - // Must stay above the readahead cushion because Media3 stops reading while its buffer is full. - private static final long IDLE_STOP_MS = 90_000; - private static final long READAHEAD_CUSHION_MS = 10_000; - private static final long STARTUP_READAHEAD_CUSHION_MS = 6_000; - private static final long STARTUP_BURST_READAHEAD_CUSHION_MS = 25_000; - // Startup bursts need to fill enough media for exact seeks, but YouTube SABR starts returning - // policy-only responses when the reported server-side readahead gets too large. Cap only the - // request-time player timestamp so local throttling and eviction still use the actual playhead. - private static final long STARTUP_BURST_SERVER_AHEAD_MS = 16_000; - private static final long STARTUP_BURST_MS = 25_000; - private static final long SEEK_READAHEAD_CUSHION_MS = 5_000; - private static final long SEEK_MODE_MS = 8_000; - private static final long MIN_SERVER_READAHEAD_CUSHION_MS = 3_000; - // Use the session's cache ceiling as the single source of truth. A lower pump threshold leaves a - // byte range where the pump is throttled but the session cannot evict, forcing demand-time fetches. - private static final long MAX_AHEAD_BYTES = YoutubeSabrSession.getMaxCacheBytes(); - // Keep a short rewind cushion in cache; deeper rewinds are refetched by repositioning the session. - private static final long BACK_BUFFER_MS = 12_000; - // Shrink the back-buffer when over budget so eviction can free enough data to keep fetching. - private static final long MIN_BACK_BUFFER_MS = 2_000; - private static final long BACK_BUFFER_BYTES = 4L * 1024 * 1024; - - private final YoutubeSabrSession session; - private final SabrSessionStore.Holder holder; - private final Localization localization; - - private volatile boolean started; - private volatile boolean stopped; - private volatile boolean clearCacheOnStop; - private volatile State state = State.IDLE; - private volatile IOException networkFailure; - private volatile long lastReadMs; - private volatile long lastRequestMs; - private volatile SabrSegmentRequest pendingRefetch; - private volatile long pendingRefetchPositionMs = -1; - private volatile SabrSegmentRequest pendingForwardSeek; - private volatile long pendingForwardSeekPositionMs = -1; - private final Map activeDemands = new ConcurrentHashMap<>(); - private final Map demandFailures = new ConcurrentHashMap<>(); - private volatile YoutubeSabrInfo.Format pendingInitialization; - private volatile long seekModeUntilMs; - private volatile long startedAtMs; - private Thread thread; - - SabrStreamPump(@NonNull final YoutubeSabrSession session, - @NonNull final SabrSessionStore.Holder holder, - @NonNull final Localization localization) { - this.session = session; - this.holder = holder; - this.localization = localization; - } - - void ensureStarted() { - lastReadMs = System.currentTimeMillis(); - if (state == State.TERMINAL || (started && !stopped)) { - return; - } - synchronized (this) { - if (state == State.TERMINAL || (started && !stopped)) { - return; - } - stopped = false; - started = true; - startedAtMs = System.currentTimeMillis(); - state = State.IDLE; - thread = new Thread(this::loop, "SabrStreamPump"); - thread.setDaemon(true); - thread.start(); - } - } - - void stop() { - synchronized (this) { - stopped = true; - clearCacheOnStop = true; - if (thread != null && thread != Thread.currentThread()) { - thread.interrupt(); - } - } - } - - @Nullable - SabrMediaSegment getCached(@NonNull final SabrSegmentRequest request) { - ensureStarted(); - return session.getCachedSegment(request); - } - - @Nullable - synchronized IOException takeNetworkFailure() { - final IOException failure = networkFailure; - networkFailure = null; - return failure; - } - - @Nullable - IOException takeDemandFailure(@NonNull final SabrSegmentRequest request, - @NonNull final Object readerOwner, - final long readerGeneration) { - return demandFailures.remove(DemandKey.from(request, readerOwner, readerGeneration)); - } - - boolean canRecover() { - return state == State.IDLE || state == State.THROTTLED; - } - - String getStateName() { - return state.name(); - } - - void requestRefetchFrom(@NonNull final SabrSegmentRequest request) { - activateSeekMode(); - pendingRefetch = request; - pendingRefetchPositionMs = -1; - ensureStarted(); - wake(); - } - - void requestForwardSeekTo(@NonNull final SabrSegmentRequest request) { - activateSeekMode(); - pendingForwardSeek = request; - pendingForwardSeekPositionMs = -1; - ensureStarted(); - wake(); - } - - void requestSegmentDemand(@NonNull final SabrSegmentRequest request, - @NonNull final Object readerOwner, - final long readerGeneration) { - if (request.isInitializationSegment()) { - requestInitialization(request.getFormat()); - return; - } - if (session.getCachedSegment(request) != null) { - clearSegmentDemand(request, readerOwner, readerGeneration); - return; - } - final DemandKey key = DemandKey.from(request, readerOwner, readerGeneration); - if (demandFailures.containsKey(key)) { - wake(); - return; - } - final long nowMs = System.currentTimeMillis(); - final SegmentDemand created = new SegmentDemand( - request, readerOwner, readerGeneration, nowMs); - final long remainingBackoffMs = session.getBackoffRemainingMs(); - if (remainingBackoffMs > 0) { - created.pausePolicyClockForBackoff(nowMs, remainingBackoffMs); - } - final boolean added = activeDemands.putIfAbsent(key, created) == null; - ensureStarted(); - if (added) { - wake(); - } - } - - void clearSegmentDemand(@NonNull final SabrSegmentRequest request, - @NonNull final Object readerOwner, - final long readerGeneration) { - final SegmentDemand removed = activeDemands.remove( - DemandKey.from(request, readerOwner, readerGeneration)); - demandFailures.remove(DemandKey.from(request, readerOwner, readerGeneration)); - if (removed != null) { - // A server backoff can park the pump for many seconds. Wake it so cancellation or a - // superseded reader is observed immediately without permitting an early request. - wake(); - } - } - - void requestSeekTo(@NonNull final SabrSegmentRequest request, final boolean backward) { - requestSeekTo(request, backward, -1); - } - - void requestSeekTo(@NonNull final SabrSegmentRequest request, final boolean backward, - final long positionMs) { - activateSeekMode(); - if (backward) { - pendingForwardSeek = null; - pendingForwardSeekPositionMs = -1; - pendingRefetch = request; - pendingRefetchPositionMs = positionMs; - } else { - pendingRefetch = null; - pendingRefetchPositionMs = -1; - pendingForwardSeek = request; - pendingForwardSeekPositionMs = positionMs; - } - ensureStarted(); - wake(); - } - - void noteSeekWithinCache() { - activateSeekMode(); - ensureStarted(); - wake(); - } - - void requestInitialization(@NonNull final YoutubeSabrInfo.Format format) { - pendingInitialization = format; - ensureStarted(); - wake(); - } - - private void loop() { - int consecutiveIoErrors = 0; - state = State.IDLE; - try { - while (!stopped) { - if (pendingRefetch == null && pendingForwardSeek == null - && activeDemands.isEmpty() && pendingInitialization == null - && (System.currentTimeMillis() - lastReadMs > IDLE_STOP_MS - || session.isComplete())) { - break; - } - try { - final long readerHeadMs = holder.getReaderHeadMs(); - final long backBufferMs = session.getCachedBytes() > MAX_AHEAD_BYTES - ? MIN_BACK_BUFFER_MS : targetBackBufferMs(); - session.setPlayHeadMs(Math.max(0, holder.getReaderTailMs() - backBufferMs)); - session.evictPlayed(); - final long edgeMs = session.getStreamState().getMinBufferedEndMs(); - final long remainingBackoffMs = session.getBackoffRemainingMs(); - if (remainingBackoffMs > 0) { - state = State.IDLE; - awaitWake(remainingBackoffMs); - continue; - } - final YoutubeSabrInfo.Format initialization = pendingInitialization; - if (initialization != null) { - pendingInitialization = null; - state = State.REPOSITIONING; - session.addDiagnosticEvent("pump_initialization itag=" - + initialization.getItag()); - prepareInitialRequestPosition(); - session.prepareForInitialization(initialization); - pumpOnceStreaming(); - state = State.IDLE; - consecutiveIoErrors = 0; - continue; - } - final SabrSegmentRequest refetch = pendingRefetch; - if (refetch != null) { - final long refetchPositionMs = pendingRefetchPositionMs; - pendingRefetch = null; - pendingRefetchPositionMs = -1; - state = State.REPOSITIONING; - session.addDiagnosticEvent("pump_rewind itag=" - + refetch.getFormat().getItag() - + " seq=" + refetch.getSequenceNumber()); - if (refetchPositionMs >= 0) { - session.prepareForRewind(refetch, refetchPositionMs); - } else { - session.prepareForRewind(refetch); - } - pumpOnceStreaming(); - state = State.IDLE; - consecutiveIoErrors = 0; - continue; - } - final SabrSegmentRequest forwardSeek = pendingForwardSeek; - if (forwardSeek != null) { - final long forwardSeekPositionMs = pendingForwardSeekPositionMs; - pendingForwardSeek = null; - pendingForwardSeekPositionMs = -1; - if (isSeekTargetCached(forwardSeek, forwardSeekPositionMs)) { - session.addDiagnosticEvent("pump_forward_cached itag=" - + forwardSeek.getFormat().getItag() - + " seq=" + forwardSeek.getSequenceNumber() - + " positionMs=" + forwardSeekPositionMs); - state = State.IDLE; - continue; - } - state = State.REPOSITIONING; - session.addDiagnosticEvent("pump_forward itag=" - + forwardSeek.getFormat().getItag() - + " init=" + forwardSeek.isInitializationSegment() - + " seq=" + forwardSeek.getSequenceNumber()); - if (forwardSeekPositionMs >= 0) { - session.prepareForForwardJump(forwardSeek, forwardSeekPositionMs); - } else { - session.prepareForForwardJump(forwardSeek); - } - pumpOnceStreaming(); - state = State.IDLE; - consecutiveIoErrors = 0; - continue; - } - final SegmentDemand demand = selectDemand(edgeMs); - if (demand != null) { - if (session.getCachedSegment(demand.request) != null) { - clearDemand(demand); - } else { - final long demandStartMs = session.getStreamState() - .getSegmentStartMs(demand.request.getFormat(), - demand.request.getSequenceNumber()); - final boolean rewind = demandStartMs < edgeMs; - final boolean forward = demandStartMs > edgeMs + 30_000; - if (demand.responsesWithoutDemandedSegment > demand.recoveryCount) { - state = State.REPOSITIONING; - demand.recoveryCount++; - final String recovery = rewind ? "RECOVER_REWIND" - : forward ? "RECOVER_FORWARD" : "RECOVER_MISSING"; - session.addDiagnosticEvent("pump_demand_reposition itag=" - + demand.request.getFormat().getItag() - + " seq=" + demand.request.getSequenceNumber() - + " startMs=" + demandStartMs - + " edgeMs=" + edgeMs - + " omissions=" - + demand.responsesWithoutDemandedSegment - + " recovery=" + demand.recoveryCount - + " route=" + recovery); - if (rewind) { - session.prepareForRewind(demand.request); - } else if (forward) { - session.prepareForForwardJump(demand.request); - } else { - session.prepareForMissingSegment(demand.request); - } - final YoutubeSabrSession.DemandResponseResult result = - pumpOnceStreamingUntilCached( - demand.request); - final boolean demandCompleted = finishDemandAttempt(demand, result); - state = State.IDLE; - consecutiveIoErrors = 0; - if (!demandCompleted) { - awaitDemandRetry(demand); - } - continue; - } else if (rewind) { - state = State.REPOSITIONING; - session.addDiagnosticEvent("pump_demand_rewind itag=" - + demand.request.getFormat().getItag() - + " seq=" + demand.request.getSequenceNumber() - + " startMs=" + demandStartMs - + " edgeMs=" + edgeMs); - session.prepareForRewind(demand.request); - final YoutubeSabrSession.DemandResponseResult result = - pumpOnceStreamingUntilCached( - demand.request); - final boolean demandCompleted = finishDemandAttempt(demand, result); - state = State.IDLE; - consecutiveIoErrors = 0; - if (!demandCompleted) { - awaitDemandRetry(demand); - } - continue; - } else if (forward) { - state = State.REPOSITIONING; - session.addDiagnosticEvent("pump_demand_forward itag=" - + demand.request.getFormat().getItag() - + " seq=" + demand.request.getSequenceNumber() - + " startMs=" + demandStartMs - + " edgeMs=" + edgeMs); - session.prepareForForwardJump(demand.request); - final YoutubeSabrSession.DemandResponseResult result = - pumpOnceStreamingUntilCached( - demand.request); - final boolean demandCompleted = finishDemandAttempt(demand, result); - state = State.IDLE; - consecutiveIoErrors = 0; - if (!demandCompleted) { - awaitDemandRetry(demand); - } - continue; - } else { - state = State.REQUESTING; - session.addDiagnosticEvent("pump_demand itag=" - + demand.request.getFormat().getItag() - + " seq=" + demand.request.getSequenceNumber() - + " startMs=" + demandStartMs - + " edgeMs=" + edgeMs - + " sinceMs=" + Math.max(0, - System.currentTimeMillis() - - demand.createdAtMs)); - final long playerTimeMs = holder.getPlayerTimeMs(); - final long requestPlayerTimeMs = cappedServerAheadPlayerTimeMs( - playerTimeMs, edgeMs); - session.getStreamState().setPlayerTimeMs(requestPlayerTimeMs); - final YoutubeSabrSession.DemandResponseResult result = - pumpOnceStreamingUntilCached( - demand.request); - final boolean demandCompleted = finishDemandAttempt(demand, result); - state = State.IDLE; - consecutiveIoErrors = 0; - if (!demandCompleted) { - awaitDemandRetry(demand); - } - continue; - } - } - } - final long readaheadCushionMs = targetReadaheadCushionMs(); - final long playerTimeMs = holder.getPlayerTimeMs(); - final long aheadMs = Math.max(0, edgeMs - playerTimeMs); - final boolean heartbeatDue = isHeartbeatDue(); - final boolean throttled = (aheadMs >= readaheadCushionMs && !heartbeatDue) - || session.getCachedBytes() > MAX_AHEAD_BYTES; - if (throttled) { - if (state != State.THROTTLED) { - session.addDiagnosticEvent("pump_throttled cushionMs=" - + readaheadCushionMs - + " unstartedReader=" + holder.hasUnstartedActiveReader() - + " edgeMs=" + edgeMs - + " playerTimeMs=" + playerTimeMs - + " aheadMs=" + aheadMs - + " readerHeadMs=" + readerHeadMs - + " readerTailMs=" + holder.getReaderTailMs() - + " cachedBytes=" + session.getCachedBytes() - + " requestNumber=" + session.getRequestNumber()); - } - state = State.THROTTLED; - awaitWake(IDLE_POLL_MS); - continue; - } - final boolean startupWait = holder.hasUnstartedActiveReader(); - final long startupBackoffMs = startupWait - ? session.getBackoffRemainingMs() : 0; - if (startupBackoffMs > 0) { - SabrBackoffCoordinator.getInstance().begin( - holder.getApplicationContext(), holder, - android.os.SystemClock.elapsedRealtime() + startupBackoffMs); - awaitWake(Math.max(startupBackoffMs, IDLE_POLL_MS)); - continue; - } - state = State.REQUESTING; - final long requestPlayerTimeMs = startupRequestPlayerTimeMs(playerTimeMs, - edgeMs); - session.getStreamState().setPlayerTimeMs(requestPlayerTimeMs); - final int segmentCount = holder.hasUnstartedActiveReader() - ? pumpOnceStreamingForStartup() : pumpOnceStreaming(); - state = State.IDLE; - consecutiveIoErrors = 0; - if (segmentCount == 0) { - awaitWake(IDLE_POLL_MS); - } - } catch (final IOException e) { - if (stopped || holder.isInvalidated()) { - session.addDiagnosticEvent("pump_canceled invalidated=" - + holder.isInvalidated() + " message=" + e.getMessage()); - break; - } - if (isInterruptedRead(e)) { - networkFailure = e; - state = State.NETWORK_FAILED; - break; - } - consecutiveIoErrors++; - if (consecutiveIoErrors >= MAX_CONSECUTIVE_IO_ERRORS) { - Log.w(TAG, "SABR pump network failure " - + holder.videoId, e); - networkFailure = e; - state = State.NETWORK_FAILED; - break; - } - sleepQuietly(ERROR_RETRY_MS); - } catch (final SabrRecoverableException e) { - Log.i(TAG, "SABR media failure: " + e.getMessage()); - state = State.TERMINAL; - holder.failTerminal(new SabrLogicException("SABR media failure", e)); - break; - } catch (final ExtractionException e) { - if (Thread.currentThread().isInterrupted() || holder.isInvalidated()) { - Log.i(TAG, "SABR pump canceled video=" + holder.videoId - + " invalidated=" + holder.isInvalidated() - + " message=" + e.getMessage()); - holder.session.addDiagnosticEvent("pump_canceled invalidated=" - + holder.isInvalidated() + " message=" + e.getMessage()); - break; - } - Log.i(TAG, "SABR pump fatal: " + e.getMessage()); - state = State.TERMINAL; - holder.failTerminal(new SabrLogicException("SABR logic failure", e)); - break; - } catch (final Exception e) { - // OkHttp's Kotlin internals can propagate a checked InterruptedException via - // a sneaky throw while an in-flight connect is canceled. Java does not include - // it in the declared downloader signature, so handle it at the pump boundary. - if (stopped || holder.isInvalidated() - || Thread.currentThread().isInterrupted()) { - Log.i(TAG, "SABR pump canceled video=" + holder.videoId - + " invalidated=" + holder.isInvalidated() - + " type=" + e.getClass().getSimpleName()); - break; - } - Log.e(TAG, "SABR pump unexpected failure " + holder.videoId, e); - state = State.TERMINAL; - holder.failTerminal(new SabrLogicException( - "SABR unexpected pump failure", e)); - break; - } catch (final OutOfMemoryError e) { - Log.e(TAG, "SABR pump OOM; evicting session " + holder.videoId, e); - state = State.TERMINAL; - holder.failTerminal(new SabrLogicException("SABR memory failure", e)); - break; - } - } - } finally { - if (clearCacheOnStop) { - session.clearCache(); - } - synchronized (this) { - stopped = true; - if (state != State.TERMINAL && state != State.NETWORK_FAILED) { - state = State.STOPPED; - } - } - } - } - - private int pumpOnceStreaming() throws IOException, ExtractionException { - try { - final int segmentCount = session.pumpOnceStreaming(localization); - holder.recordDiagnosticsThrottled("pump segments=" + segmentCount); - return segmentCount; - } finally { - lastRequestMs = System.currentTimeMillis(); - } - } - - private int pumpOnceStreamingForStartup() throws IOException, ExtractionException { - try { - final int segmentCount = session.pumpOnceStreamingForStartup(localization); - final long remainingBackoffMs = session.getBackoffRemainingMs(); - if (remainingBackoffMs > 0) { - SabrBackoffCoordinator.getInstance().begin( - holder.getApplicationContext(), holder, - android.os.SystemClock.elapsedRealtime() + remainingBackoffMs); - } - holder.recordDiagnosticsThrottled("pump_startup segments=" + segmentCount); - return segmentCount; - } finally { - lastRequestMs = System.currentTimeMillis(); - } - } - - private YoutubeSabrSession.DemandResponseResult pumpOnceStreamingUntilCached( - @NonNull final SabrSegmentRequest request) - throws IOException, ExtractionException { - final YoutubeSabrSession.DemandResponseResult result; - try { - result = session.pumpOnceStreamingForDemand(localization, request); - final long remainingBackoffMs = session.getBackoffRemainingMs(); - if (remainingBackoffMs > 0) { - pauseDemandPolicyClocksForBackoff(remainingBackoffMs); - } - holder.recordDiagnosticsThrottled("pump_until_cached itag=" - + request.getFormat().getItag() - + " seq=" + request.getSequenceNumber() - + " segments=" + result.getSegmentCount() - + " targetTrackSegments=" + result.getTargetTrackSegmentCount()); - } finally { - lastRequestMs = System.currentTimeMillis(); - } - return result; - } - - private void awaitDemandRetry(@NonNull final SegmentDemand demand) { - final long remainingBackoffMs = session.getBackoffRemainingMs(); - if (remainingBackoffMs > 0L) { - SabrBackoffCoordinator.getInstance().begin(holder.getApplicationContext(), holder, - android.os.SystemClock.elapsedRealtime() + remainingBackoffMs); - } - awaitWake(Math.max(remainingBackoffMs, - demand.retryDelayMs > 0 ? demand.retryDelayMs : IDLE_POLL_MS)); - } - - private void pauseDemandPolicyClocksForBackoff(final long remainingBackoffMs) { - final long nowMs = System.currentTimeMillis(); - for (final SegmentDemand activeDemand : activeDemands.values()) { - activeDemand.pausePolicyClockForBackoff(nowMs, remainingBackoffMs); - } - } - - private long targetReadaheadCushionMs() { - if (isSeekMode()) { - return SEEK_READAHEAD_CUSHION_MS; - } - if (isStartupBurst()) { - return STARTUP_BURST_READAHEAD_CUSHION_MS; - } - if (holder.hasUnstartedActiveReader()) { - return STARTUP_READAHEAD_CUSHION_MS; - } - final int serverTargetMs = Math.max(session.getStreamState().getTargetAudioReadaheadMs(), - session.getStreamState().getTargetVideoReadaheadMs()); - if (serverTargetMs <= 0) { - return READAHEAD_CUSHION_MS; - } - return Math.max(MIN_SERVER_READAHEAD_CUSHION_MS, - Math.min(READAHEAD_CUSHION_MS, serverTargetMs)); - } - - private long startupRequestPlayerTimeMs(final long playerTimeMs, final long edgeMs) { - if (!isStartupBurst()) { - return playerTimeMs; - } - return cappedServerAheadPlayerTimeMs(playerTimeMs, edgeMs); - } - - private long cappedServerAheadPlayerTimeMs(final long playerTimeMs, final long edgeMs) { - return Math.max(playerTimeMs, edgeMs - STARTUP_BURST_SERVER_AHEAD_MS); - } - - private boolean isStartupBurst() { - return startedAtMs > 0 && System.currentTimeMillis() - startedAtMs < STARTUP_BURST_MS; - } - - private boolean isHeartbeatDue() { - final int maximumMs = session.getStreamState().getMaxTimeSinceLastRequestMs(); - return maximumMs > 0 && lastRequestMs > 0 - && System.currentTimeMillis() - lastRequestMs >= maximumMs; - } - - private long targetBackBufferMs() { - if (isSeekMode()) { - return MIN_BACK_BUFFER_MS; - } - final long bitsPerSec = (long) holder.videoFormat.getBitrate() - + Math.max(0, holder.audioFormat.getBitrate()); - if (bitsPerSec <= 0) { - return BACK_BUFFER_MS; - } - final long bytesPerMs = Math.max(1, bitsPerSec / 8 / 1000); - return Math.max(MIN_BACK_BUFFER_MS, - Math.min(BACK_BUFFER_MS, BACK_BUFFER_BYTES / bytesPerMs)); - } - - private void activateSeekMode() { - seekModeUntilMs = System.currentTimeMillis() + SEEK_MODE_MS; - } - - private void prepareInitialRequestPosition() { - if (session.getRequestNumber() != 0) { - return; - } - final long playerTimeMs = holder.getPlayerTimeMs(); - if (playerTimeMs <= 1_000) { - return; - } - session.addDiagnosticEvent("pump_initialization_target itag=" - + holder.videoFormat.getItag() - + " playerTimeMs=" + playerTimeMs); - session.getStreamState().setPlayerTimeMs(playerTimeMs); - session.getStreamState().setSelectVideoFormatBeforeAudio(true); - } - - private boolean isSeekMode() { - return System.currentTimeMillis() < seekModeUntilMs; - } - - private boolean isSeekTargetCached(@NonNull final SabrSegmentRequest request, - final long positionMs) { - if (session.getCachedSegment(request) == null) { - return false; - } - if (request.isInitializationSegment()) { - return true; - } - final YoutubeSabrInfo.Format targetFormat = request.getFormat(); - final YoutubeSabrInfo.Format companionFormat; - if (targetFormat.getItag() == holder.videoFormat.getItag()) { - companionFormat = holder.audioFormat; - } else if (targetFormat.getItag() == holder.audioFormat.getItag()) { - companionFormat = holder.videoFormat; - } else { - return true; - } - final long companionTimeMs = positionMs >= 0 ? positionMs - : session.getStreamState().getSegmentStartMs(targetFormat, - request.getSequenceNumber()); - final int companionSequence = session.getStreamState() - .getSegmentNumberAtOrAfterTimeMs(companionFormat, companionTimeMs); - return session.getCachedSegment(SabrSegmentRequest.media(companionFormat, - companionSequence)) != null; - } - - @Nullable - private SegmentDemand selectDemand(final long edgeMs) { - SegmentDemand selected = null; - long selectedStartMs = Long.MAX_VALUE; - for (final SegmentDemand demand : activeDemands.values()) { - if (!holder.isReaderGenerationActive(demand.readerOwner, demand.readerGeneration) - || session.getCachedSegment(demand.request) != null) { - clearDemand(demand); - continue; - } - final long startMs = session.getStreamState().getSegmentStartMs( - demand.request.getFormat(), demand.request.getSequenceNumber()); - if (selected == null || startMs < selectedStartMs - || (startMs == selectedStartMs - && demand.createdAtMs < selected.createdAtMs)) { - selected = demand; - selectedStartMs = startMs; - } - } - return selected; - } - - private void clearDemand(@NonNull final SegmentDemand demand) { - activeDemands.remove(DemandKey.from(demand.request, demand.readerOwner, - demand.readerGeneration)); - if (activeDemands.isEmpty()) { - SabrBackoffCoordinator.getInstance().clear(holder.getApplicationContext(), holder); - } - } - - private boolean finishDemandAttempt( - @NonNull final SegmentDemand demand, - @NonNull final YoutubeSabrSession.DemandResponseResult result) - throws ExtractionException { - if (!isDemandActive(demand)) { - return true; - } - if (!result.wasRequestPerformed()) { - demand.retryDelayMs = 0; - return false; - } - if (session.getCachedSegment(demand.request) != null) { - clearDemand(demand); - return true; - } - // A control-only response is pacing/protocol state, not evidence that the server omitted - // a demanded media segment. The ordinary response policy has already handled it; keeping - // the demand counters unchanged also preserves the server backoff. - if (result.getSegmentCount() == 0 && result.getReturnedSegments().isEmpty()) { - demand.retryDelayMs = 0; - session.addDiagnosticEvent("pump_demand_no_media itag=" - + demand.request.getFormat().getItag() - + " seq=" + demand.request.getSequenceNumber() - + " backoffMs=" + session.getBackoffRemainingMs()); - return false; - } - final long nowMs = System.currentTimeMillis(); - demand.responsesWithoutDemandedSegment++; - demand.retryDelayMs = 0; - final long elapsedMs = demand.getPolicyElapsedMs(nowMs); - final boolean repeatedOmission = demand.responsesWithoutDemandedSegment >= 3 - || elapsedMs >= 15_000 && result.getTargetTrackSegmentCount() > 0; - final boolean noTargetMedia = elapsedMs >= 15_000 - && result.getTargetTrackSegmentCount() == 0; - final String outcome = repeatedOmission ? "FAIL_REPEATED_TARGET_OMISSION" - : noTargetMedia ? "FAIL_NO_TARGET_MEDIA" : "CONTINUE"; - session.addDiagnosticEvent("pump_demand_omission itag=" - + demand.request.getFormat().getItag() - + " seq=" + demand.request.getSequenceNumber() - + " omissions=" + demand.responsesWithoutDemandedSegment - + " targetTrackSegments=" + result.getTargetTrackSegmentCount() - + " segments=" + result.getSegmentCount() - + " returned=" + summarizeReturnedSegments(result) - + " elapsedMs=" + elapsedMs - + " outcome=" + outcome - + " retryDelayMs=0"); - if (repeatedOmission) { - failDemand(demand, new IOException( - "SABR response repeatedly omitted demanded segment itag=" - + demand.request.getFormat().getItag() - + ", seq=" + demand.request.getSequenceNumber() - + ", responses=" + demand.responsesWithoutDemandedSegment - + ", elapsedMs=" + elapsedMs)); - return true; - } - if (noTargetMedia) { - failDemand(demand, new IOException("SABR demand timed out without target-track media" - + " itag=" + demand.request.getFormat().getItag() - + ", seq=" + demand.request.getSequenceNumber() - + ", elapsedMs=" + elapsedMs)); - return true; - } - return false; - } - - @NonNull - private static String summarizeReturnedSegments( - @NonNull final YoutubeSabrSession.DemandResponseResult result) { - final StringBuilder summary = new StringBuilder("["); - for (final YoutubeSabrSession.DemandReturnedSegment segment - : result.getReturnedSegments()) { - if (summary.length() > 1) { - summary.append(','); - } - summary.append(segment.getItag()).append(':').append(segment.getSequenceNumber()); - } - if (result.areReturnedSegmentsTruncated()) { - summary.append(",..."); - } - return summary.append(']').toString(); - } - - private boolean isDemandActive(@NonNull final SegmentDemand demand) { - final DemandKey key = DemandKey.from(demand.request, demand.readerOwner, - demand.readerGeneration); - return activeDemands.get(key) == demand - && holder.isReaderGenerationActive(demand.readerOwner, demand.readerGeneration); - } - - private void failDemand(@NonNull final SegmentDemand demand, - @NonNull final IOException failure) { - final DemandKey key = DemandKey.from(demand.request, demand.readerOwner, - demand.readerGeneration); - if (activeDemands.remove(key, demand)) { - demandFailures.put(key, failure); - session.addDiagnosticEvent("pump_demand_failed itag=" - + demand.request.getFormat().getItag() - + " seq=" + demand.request.getSequenceNumber() - + " message=" + failure.getMessage()); - } - } - - private static final class SegmentDemand { - @NonNull - private final SabrSegmentRequest request; - @NonNull - private final Object readerOwner; - private final long readerGeneration; - private final long createdAtMs; - private int responsesWithoutDemandedSegment; - private int recoveryCount; - private int retryDelayMs; - private long policyCreatedAtMs; - private long policyBackoffUntilMs; - - private SegmentDemand(@NonNull final SabrSegmentRequest request, - @NonNull final Object readerOwner, - final long readerGeneration, - final long sinceMs) { - this.request = request; - this.readerOwner = readerOwner; - this.readerGeneration = readerGeneration; - this.createdAtMs = sinceMs; - this.policyCreatedAtMs = sinceMs; - } - - private void pausePolicyClockForBackoff(final long nowMs, final long remainingBackoffMs) { - final long backoffUntilMs = nowMs + remainingBackoffMs; - final long unaccountedBackoffMs = backoffUntilMs - - Math.max(nowMs, policyBackoffUntilMs); - if (unaccountedBackoffMs > 0) { - policyCreatedAtMs += unaccountedBackoffMs; - policyBackoffUntilMs = backoffUntilMs; - } - } - - private long getPolicyElapsedMs(final long nowMs) { - return Math.max(0, nowMs - policyCreatedAtMs); - } - - } - - private static final class DemandKey { - private final int itag; - private final int sequenceNumber; - private final Object readerOwner; - private final long readerGeneration; - private final int ownerHash; - - private DemandKey(final int itag, - final int sequenceNumber, - @NonNull final Object readerOwner, - final long readerGeneration) { - this.itag = itag; - this.sequenceNumber = sequenceNumber; - this.readerOwner = readerOwner; - this.readerGeneration = readerGeneration; - this.ownerHash = System.identityHashCode(readerOwner); - } - - private static DemandKey from(@NonNull final SabrSegmentRequest request, - @NonNull final Object readerOwner, - final long readerGeneration) { - return new DemandKey(request.getFormat().getItag(), request.getSequenceNumber(), - readerOwner, readerGeneration); - } - - @Override - public boolean equals(@Nullable final Object other) { - if (this == other) { - return true; - } - if (!(other instanceof DemandKey)) { - return false; - } - final DemandKey key = (DemandKey) other; - return itag == key.itag - && sequenceNumber == key.sequenceNumber - && readerOwner == key.readerOwner - && readerGeneration == key.readerGeneration; - } - - @Override - public int hashCode() { - int result = itag; - result = 31 * result + sequenceNumber; - result = 31 * result + ownerHash; - result = 31 * result + (int) (readerGeneration ^ (readerGeneration >>> 32)); - return result; - } - } - - private static void sleepQuietly(final long ms) { - try { - Thread.sleep(ms); - } catch (final InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - - private static boolean isInterruptedRead(@NonNull final IOException error) { - if (!(error instanceof InterruptedIOException)) { - return false; - } - final String message = error.getMessage(); - return Thread.currentThread().isInterrupted() - || message != null && message.startsWith("Interrupted"); - } - - private void wake() { - final Thread pumpThread = thread; - if (pumpThread != null) { - LockSupport.unpark(pumpThread); - } - } - - private void awaitWake(final long timeoutMs) { - LockSupport.parkNanos(timeoutMs * 1_000_000L); - } -} diff --git a/app/src/main/java/us/shandian/giga/get/SabrDownloader.kt b/app/src/main/java/us/shandian/giga/get/SabrDownloader.kt index e70a718b9..224cf6461 100644 --- a/app/src/main/java/us/shandian/giga/get/SabrDownloader.kt +++ b/app/src/main/java/us/shandian/giga/get/SabrDownloader.kt @@ -135,7 +135,6 @@ internal class SabrDownloader( // Nothing to do. } } - session.clearCache() } ensureRunning() @@ -306,14 +305,17 @@ internal class SabrDownloader( writer.observeWrittenInitializations() prepareInitializations(session, targets, writer, localization, poToken) writer.observeWrittenInitializations() - writer.drainCachedInitializations() var emptyResponses = 0 + var nextRequestAtMs = 0L while (true) { ensureRunning() + val backoffRemainingMs = nextRequestAtMs - System.currentTimeMillis() + if (backoffRemainingMs > 0) { + Thread.sleep(backoffRemainingMs) + ensureRunning() + } writer.observeWrittenInitializations() - var wroteSegment = writer.drainCachedInitializations() - wroteSegment = writer.drainCachedSegments() || wroteSegment configureInitializedSingleTargetMode(session, targets) if (isDownloadComplete(session, targets)) { @@ -322,16 +324,15 @@ internal class SabrDownloader( val playerTimeMs = downloadPlayerTimeMs(session, targets) session.streamState.setPlayerTimeMs(playerTimeMs) - val segmentCount = session.pumpOnceStreaming(localization) + val requestResult = session.requestOnce(localization, writer::acceptSegment) + nextRequestAtMs = System.currentTimeMillis() + requestResult.backoffMs + val segmentCount = requestResult.segmentCount writer.observeWrittenInitializations() - wroteSegment = writer.drainCachedInitializations() || wroteSegment - wroteSegment = writer.drainCachedSegments() || wroteSegment - enforceSessionCacheLimit(session, writer) configureInitializedSingleTargetMode(session, targets) if (isDownloadComplete(session, targets)) { break } - if (wroteSegment || segmentCount > 0) { + if (segmentCount > 0) { emptyResponses = 0 } else { emptyResponses++ @@ -370,29 +371,14 @@ internal class SabrDownloader( } ?: throw RetryColdStartException() writer.writeInitializationData(target, data) } + for (segment in initialization.mediaSegments) { + writer.acceptSegment(segment) + } } catch (failure: IOException) { throw RetryColdStartException(failure) } } - @Throws(IOException::class) - private fun enforceSessionCacheLimit( - session: YoutubeSabrSession, - writer: SabrSegmentWriter, - ) { - if (session.cachedBytes <= MAX_SESSION_CACHE_BYTES) { - return - } - writer.drainCachedSegments() - if (session.cachedBytes <= MAX_SESSION_CACHE_BYTES) { - return - } - throw SabrDownloadException( - SabrDownloadException.Reason.STALLED, - "SABR download stalled: cached media grew to ${session.cachedBytes} bytes", - ) - } - private fun configureInitializedSingleTargetMode( session: YoutubeSabrSession, targets: List, @@ -508,7 +494,6 @@ internal class SabrDownloader( private const val MAX_COLD_START_RETRIES = 3 private const val MAX_TRANSIENT_RETRIES = 5 private const val MAX_TRANSIENT_RETRY_DELAY_MS = 5_000L - private const val MAX_SESSION_CACHE_BYTES = 48L * 1024L * 1024L private const val MAX_INITIALIZATION_BYTES = 16 * 1024 * 1024 @JvmStatic diff --git a/app/src/main/java/us/shandian/giga/get/SabrSegmentWriter.kt b/app/src/main/java/us/shandian/giga/get/SabrSegmentWriter.kt index e9994d203..97cb6e3e8 100644 --- a/app/src/main/java/us/shandian/giga/get/SabrSegmentWriter.kt +++ b/app/src/main/java/us/shandian/giga/get/SabrSegmentWriter.kt @@ -1,7 +1,6 @@ package us.shandian.giga.get import org.schabi.newpipe.extractor.services.youtube.sabr.media.SabrMediaSegment -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession import java.io.IOException import java.io.OutputStream @@ -12,49 +11,36 @@ internal class SabrSegmentWriter( private val outputs: Map, private val onBytesWritten: (SabrDownloadTarget, Long) -> Unit, ) { - fun observeWrittenInitializations() { - for (target in targets) { - val data = target.initializationData ?: continue - if (!target.initializationObserved) { - target.initializationObserved = session.streamState.hasSegmentIndex(target.format) - || session.streamState.ingestInitializationData(target.format, data) - } - } - } - @Throws(IOException::class) - fun drainCachedInitializations(): Boolean { - var wroteInitialization = false - for (target in targets) { - if (target.initializationWritten) { - continue + fun acceptSegment(segment: SabrMediaSegment) { + val target = targets.firstOrNull { it.format.itag == segment.header.itag } + if (target == null) { + segment.delete() + return + } + try { + if (segment.header.isInitSegment) { + writeInitializationSegment( + target, + outputs.getValue(target.resourceIndex), + segment.data, + ) + } else { + writeMediaSegment(target, outputs.getValue(target.resourceIndex), segment) } - val request = SabrSegmentRequest.initialization(target.format) - val segment = session.getCachedSegment(request) ?: continue - writeInitializationSegment(target, outputs.getValue(target.resourceIndex), segment.data) - session.discardCachedSegment(request) - wroteInitialization = true + } finally { + segment.delete() } - return wroteInitialization } - @Throws(IOException::class) - fun drainCachedSegments(): Boolean { - var wroteSegment = false + fun observeWrittenInitializations() { for (target in targets) { - while (true) { - val request = SabrSegmentRequest.media(target.format, target.nextWriteSequence) - val segment = session.getCachedSegment(request) ?: break - if (segment.header.isInitSegment) { - session.discardCachedSegment(request) - continue - } - writeMediaSegment(target, outputs.getValue(target.resourceIndex), segment) - session.discardCachedSegment(request) - wroteSegment = true + val data = target.initializationData ?: continue + if (!target.initializationObserved) { + target.initializationObserved = session.streamState.hasSegmentIndex(target.format) + || session.streamState.ingestInitializationData(target.format, data) } } - return wroteSegment } @Throws(IOException::class) From 38888765c6fe623957cf5993bfeaf5d8889f17c9 Mon Sep 17 00:00:00 2001 From: InfinityLoop1308 <96324692+InfinityLoop1308@users.noreply.github.com> Date: Wed, 5 Aug 2026 05:28:13 +0800 Subject: [PATCH 03/13] 3 --- .../datasource/LocalDomPoTokenProvider.kt | 6 +- .../datasource/SabrDashMediaSource.java | 37 ++-- .../player/datasource/SabrMediaBridge.java | 74 +++++--- .../datasource/SabrSegmentDataSource.java | 39 ++-- .../player/datasource/SabrSegmentKey.java | 37 ++++ .../player/datasource/SabrSessionStore.java | 175 ++++++++++-------- .../player/datasource/SabrSourceSpec.java | 36 +++- .../shandian/giga/get/SabrDownloadTarget.kt | 3 +- .../us/shandian/giga/get/SabrDownloader.kt | 103 ++++------- .../us/shandian/giga/get/SabrSegmentWriter.kt | 9 +- 10 files changed, 292 insertions(+), 227 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentKey.java diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/LocalDomPoTokenProvider.kt b/app/src/main/java/org/schabi/newpipe/player/datasource/LocalDomPoTokenProvider.kt index 7f4f84690..302c688b0 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/LocalDomPoTokenProvider.kt +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/LocalDomPoTokenProvider.kt @@ -7,7 +7,6 @@ import org.schabi.newpipe.extractor.ServiceList import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper import org.schabi.newpipe.extractor.services.youtube.sabr.exception.SabrProtocolException import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo -import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrStreamState import java.io.Closeable import java.util.HashMap import java.util.concurrent.CountDownLatch @@ -17,10 +16,7 @@ import java.util.concurrent.atomic.AtomicReference class LocalDomPoTokenProvider(context: Context) { private val appContext = context.applicationContext - fun getPoToken( - info: YoutubeSabrInfo, - streamState: YoutubeSabrStreamState, - ): ByteArray { + fun getPoToken(info: YoutubeSabrInfo): ByteArray { val visitorData = info.visitorData ?: throw SabrProtocolException("Missing visitorData in YouTube player response") val session = OneShotMintSession.create( diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDashMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDashMediaSource.java index 925310ca5..1ac247197 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDashMediaSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDashMediaSource.java @@ -30,7 +30,7 @@ import androidx.media3.exoplayer.upstream.Allocator; import org.schabi.newpipe.extractor.localization.Localization; -import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrStreamState; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormatTimeline; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -47,7 +47,6 @@ public final class SabrDashMediaSource extends CompositeMediaSource { private final SabrSourceSpec spec; private final SabrSessionHandle sessionHandle; private final Localization localization; - private final YoutubeSabrStreamState manifestState; private final long durationUs; private final DashMediaSource childSource; private final PlaybackState playbackState = new PlaybackState(); @@ -58,12 +57,6 @@ public SabrDashMediaSource(@NonNull final Context context, this.spec = spec; try { this.localization = spec.getLocalization(); - this.manifestState = spec.newStreamState(); - if (!manifestState.hasSegmentIndex(spec.getAudioFormat()) - || !manifestState.hasSegmentIndex(spec.getVideoFormat())) { - throw new IOException("Refusing to publish guessed SABR DASH timeline for " - + spec.getVideoId()); - } this.sessionHandle = new SabrSessionHandle(context, spec); this.playbackState.setReaderOwner(this); final long durationMs = spec.getDurationMs(); @@ -71,7 +64,7 @@ public SabrDashMediaSource(@NonNull final Context context, final DataSource.Factory sabrDataSourceFactory = () -> new SabrSegmentDataSource(sessionHandle, playbackState.getReaderOwner(), localization, /* prependInit= */ false); - final DashManifest manifest = buildManifest(spec, manifestState, durationMs); + final DashManifest manifest = buildManifest(spec, durationMs); this.childSource = new DashMediaSource.Factory( new DefaultDashChunkSource.Factory(sabrDataSourceFactory), /* manifestDataSourceFactory= */ null) @@ -140,7 +133,6 @@ protected void releaseSourceInternal() { } private static DashManifest buildManifest(final SabrSourceSpec spec, - final YoutubeSabrStreamState state, final long durationMs) throws IOException { final String mpd = "" @@ -149,8 +141,8 @@ private static DashManifest buildManifest(final SabrSourceSpec spec, + "minBufferTime=\"PT1.5S\" mediaPresentationDuration=\"" + formatDuration(durationMs) + "\">" + "" - + adaptationSet(state, spec.getVideoFormat(), C.TRACK_TYPE_VIDEO) - + adaptationSet(state, spec.getAudioFormat(), C.TRACK_TYPE_AUDIO) + + adaptationSet(spec, spec.getVideoFormat(), C.TRACK_TYPE_VIDEO) + + adaptationSet(spec, spec.getAudioFormat(), C.TRACK_TYPE_AUDIO) + ""; try { return new DashManifestParser().parse(Uri.parse("sabr://" + spec.getVideoId()), @@ -160,7 +152,7 @@ private static DashManifest buildManifest(final SabrSourceSpec spec, } } - private static String adaptationSet(final YoutubeSabrStreamState state, + private static String adaptationSet(final SabrSourceSpec spec, final YoutubeSabrInfo.Format format, final int trackType) { final String mime = containerMimeType(format); @@ -184,14 +176,14 @@ private static String adaptationSet(final YoutubeSabrStreamState state, } builder.append(">") .append("sabrseg://").append(format.getItag()).append("/") - .append(segmentTemplate(state, format)) + .append(segmentTemplate(spec.getTimeline(format))) .append(""); return builder.toString(); } - private static String segmentTemplate(final YoutubeSabrStreamState state, - final YoutubeSabrInfo.Format format) { - final long endSegment = state.getEndSegment(format); + private static String segmentTemplate(final YoutubeSabrFormatTimeline timeline) { + final YoutubeSabrInfo.Format format = timeline.getFormat(); + final long endSegment = timeline.getEndSequence(); if (endSegment <= 0 || endSegment > 10_000) { throw new IllegalStateException("Invalid exact SABR segment count: itag=" + format.getItag() + ", count=" + endSegment); @@ -201,8 +193,8 @@ private static String segmentTemplate(final YoutubeSabrStreamState state, .append("initialization=\"init\" media=\"$Number$\">") .append(""); for (int sequence = 1; sequence <= endSegment; sequence++) { - final long startMs = state.getSegmentStartMs(format, sequence); - final long endMs = state.getSegmentEndMs(format, sequence); + final long startMs = timeline.getStartMs(sequence); + final long endMs = timeline.getEndMs(sequence); final long durationMs = Math.max(1, endMs - startMs); builder.append(""); @@ -407,10 +399,9 @@ private long snapForwardToNearSegmentBoundary(final long positionUs, return positionUs; } final long positionMs = Math.max(0, positionUs / 1000L); - final int currentSequence = manifestState.getSegmentNumberAtOrAfterTimeMs( - spec.getVideoFormat(), positionMs); - final long nextStartMs = manifestState.getSegmentStartMs( - spec.getVideoFormat(), currentSequence + 1); + final YoutubeSabrFormatTimeline timeline = spec.getVideoTimeline(); + final int currentSequence = timeline.getSequenceAt(positionMs); + final long nextStartMs = timeline.getStartMs(currentSequence + 1); final long nextStartUs = nextStartMs * 1000L; if (nextStartUs > positionUs && nextStartUs - positionUs <= toleranceUs) { diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaBridge.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaBridge.java index 624669864..2e217cb98 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaBridge.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaBridge.java @@ -5,7 +5,6 @@ import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.localization.Localization; -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession; import org.schabi.newpipe.extractor.services.youtube.sabr.media.SabrMediaSegment; @@ -20,13 +19,15 @@ /** Bridges Media3 segment demand to serialized SABR transactions. */ final class SabrMediaBridge { private static final int MAX_AHEAD_SEGMENTS = 64; + private final SabrSessionStore.Holder holder; private final YoutubeSabrSession session; private final Localization localization; private final SabrBackoffState backoff; - private final LinkedBlockingQueue pending = new LinkedBlockingQueue<>(); - private final Map pendingKeys = new ConcurrentHashMap<>(); + private final LinkedBlockingQueue pending = new LinkedBlockingQueue<>(); + private final Map pendingKeys = new ConcurrentHashMap<>(); private final Map failures = new ConcurrentHashMap<>(); private final Map ahead = new ConcurrentHashMap<>(); + private final Map nextSequences = new ConcurrentHashMap<>(); private final Deque aheadOrder = new ArrayDeque<>(); private final Object available = new Object(); private volatile IOException networkFailure; @@ -35,10 +36,11 @@ final class SabrMediaBridge { private volatile long mediaProgressVersion; private Thread worker; - SabrMediaBridge(@NonNull final YoutubeSabrSession session, + SabrMediaBridge(@NonNull final SabrSessionStore.Holder holder, @NonNull final Localization localization, @NonNull final SabrBackoffState backoff) { - this.session = session; + this.holder = holder; + this.session = holder.session; this.localization = localization; this.backoff = backoff; } @@ -90,12 +92,12 @@ void stop() { } @Nullable - SabrMediaSegment getCached(@NonNull final SabrSegmentRequest request) { + SabrMediaSegment getCached(@NonNull final SabrSegmentKey request) { return ahead.get(key(request)); } @Nullable - SabrMediaSegment awaitReadableSegment(@NonNull final SabrSegmentRequest request, + SabrMediaSegment awaitReadableSegment(@NonNull final SabrSegmentKey request, final long timeoutMs) throws InterruptedException { SabrMediaSegment segment = getCached(request); if (segment != null || timeoutMs <= 0) { @@ -111,7 +113,7 @@ SabrMediaSegment awaitReadableSegment(@NonNull final SabrSegmentRequest request, return segment; } - void discard(@NonNull final SabrSegmentRequest request) { + void discard(@NonNull final SabrSegmentKey request) { final String segmentKey = key(request); final SabrMediaSegment segment = ahead.remove(segmentKey); synchronized (available) { @@ -130,7 +132,7 @@ IOException takeNetworkFailure() { } @Nullable - IOException takeDemandFailure(@NonNull final SabrSegmentRequest request, + IOException takeDemandFailure(@NonNull final SabrSegmentKey request, @NonNull final Object readerOwner, final long readerGeneration) { return failures.remove(key(request)); @@ -157,15 +159,18 @@ long getMediaProgressVersion() { } void requestInitialization(@NonNull final org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo.Format format) { - requestSegmentDemand(SabrSegmentRequest.initialization(format), this, 0); + requestSegmentDemand(SabrSegmentKey.initialization(format), this, 0); } - void requestSegmentDemand(@NonNull final SabrSegmentRequest request, + void requestSegmentDemand(@NonNull final SabrSegmentKey request, @NonNull final Object readerOwner, final long readerGeneration) { if (ahead.containsKey(key(request))) { return; } + if (!request.isInitialization()) { + nextSequences.put(request.getFormat().getItag(), request.getSequenceNumber()); + } final String key = key(request); if (pendingKeys.putIfAbsent(key, request) == null) { pending.offer(request); @@ -173,7 +178,7 @@ void requestSegmentDemand(@NonNull final SabrSegmentRequest request, } } - void clearSegmentDemand(@NonNull final SabrSegmentRequest request, + void clearSegmentDemand(@NonNull final SabrSegmentKey request, @NonNull final Object readerOwner, final long readerGeneration) { final String key = key(request); @@ -181,24 +186,25 @@ void clearSegmentDemand(@NonNull final SabrSegmentRequest request, failures.remove(key); } - void requestRefetchFrom(@NonNull final SabrSegmentRequest request) { - session.getStreamState().rewindTo(request); + void requestRefetchFrom(@NonNull final SabrSegmentKey request) { + nextSequences.put(request.getFormat().getItag(), request.getSequenceNumber()); + session.clearPlaybackCookie(); requestSegmentDemand(request, this, 0); } - void requestForwardSeekTo(@NonNull final SabrSegmentRequest request) { - session.getStreamState().jumpTo(request); + void requestForwardSeekTo(@NonNull final SabrSegmentKey request) { + nextSequences.put(request.getFormat().getItag(), request.getSequenceNumber()); + session.clearPlaybackCookie(); requestSegmentDemand(request, this, 0); } - void requestSeekTo(@NonNull final SabrSegmentRequest request, + void requestSeekTo(@NonNull final SabrSegmentKey request, final boolean backward, final long positionMs) { - if (backward) { - session.getStreamState().rewindTo(request, positionMs); - } else { - session.getStreamState().jumpTo(request, positionMs); - } + nextSequences.put(request.getFormat().getItag(), request.getSequenceNumber()); + nextSequences.put(holder.audioFormat.getItag(), holder.audioTimeline.getSequenceAt(positionMs)); + nextSequences.put(holder.videoFormat.getItag(), holder.videoTimeline.getSequenceAt(positionMs)); + session.clearPlaybackCookie(); requestSegmentDemand(request, this, 0); } @@ -210,13 +216,19 @@ private void run() { while (!stopped) { try { backoff.awaitReady(); - final SabrSegmentRequest request = pending.take(); + final SabrSegmentKey request = pending.take(); final String requestKey = key(request); if (!pendingKeys.containsKey(requestKey)) { continue; } final YoutubeSabrSession.RequestResult requestResult = - session.requestOnce(localization, segment -> { + session.requestOnce(localization, holder.getPlayerTimeMs(), + holder.audioTimeline, bufferedThrough(holder.audioFormat), + holder.videoTimeline, bufferedThrough(holder.videoFormat), + holder.isAudioActive(), holder.isVideoActive(), + holder.getPlayerTimeMs() > 1_000, + holder.getBandwidthEstimate(), holder.getPlaybackRate(), + holder.getPoToken(), segment -> { final String segmentKey = key(segment.getHeader().getItag(), segment.getHeader().isInitSegment() ? "init" : String.valueOf(segment.getHeader().getSequenceNumber())); @@ -237,9 +249,10 @@ private void run() { // Backoff is returned as request data; the owning Holder publishes it to // observers and gates the next request. backoff.update(requestResult.getBackoffMs()); + holder.observeBandwidth(requestResult.getBandwidthSample()); pendingKeys.remove(requestKey); pending.removeIf(candidate -> ahead.containsKey(key(candidate))); - for (final SabrSegmentRequest candidate : pending) { + for (final SabrSegmentKey candidate : pending) { if (ahead.containsKey(key(candidate))) { pendingKeys.remove(key(candidate)); } @@ -263,9 +276,16 @@ private void run() { } } + private int bufferedThrough( + @NonNull final org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo.Format format) { + final Integer next = nextSequences.get(format.getItag()); + if (next != null) return Math.max(0, next - 1); + return Math.max(0, holder.getTimeline(format).getSequenceAt(holder.getPlayerTimeMs()) - 1); + } + - private static String key(@NonNull final SabrSegmentRequest request) { - return key(request.getFormat().getItag(), request.isInitializationSegment() + private static String key(@NonNull final SabrSegmentKey request) { + return key(request.getFormat().getItag(), request.isInitialization() ? "init" : String.valueOf(request.getSequenceNumber())); } diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java index 5794b7537..8f52b0da7 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java @@ -13,7 +13,6 @@ import org.schabi.newpipe.extractor.localization.Localization; import org.schabi.newpipe.extractor.services.youtube.sabr.media.SabrMediaSegment; -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest; import java.io.FileNotFoundException; import java.io.IOException; @@ -53,7 +52,7 @@ public final class SabrSegmentDataSource implements DataSource { private int pos; private boolean opened; private volatile boolean canceled; - @Nullable private SabrSegmentRequest openedRequest; + @Nullable private SabrSegmentKey openedRequest; public SabrSegmentDataSource(final SabrSessionStore.Holder holder, final Object readerOwner, @@ -112,7 +111,7 @@ public long open(final DataSpec dataSpec) throws IOException { this.progressiveReaderGeneration = -1; this.progressiveDataEndPosition = -1; this.pos = (int) Math.max(0, dataSpec.position); - SabrSegmentRequest request = requestFromUri(dataSpec.uri); + SabrSegmentKey request = requestFromUri(dataSpec.uri); openedRequest = request; final YoutubeSabrInfo.Format format = request.getFormat(); final long availableRemaining; @@ -186,20 +185,20 @@ private byte[] getInitializationData(final YoutubeSabrInfo.Format format) throws return cached; } final SabrMediaSegment segment = - holder.getBridge(localization).getCached(SabrSegmentRequest.initialization(format)); + holder.getBridge(localization).getCached(SabrSegmentKey.initialization(format)); if (segment != null) { final byte[] data = segment.getData(); holder.setInitializationData(itag, data); return data; } final SabrMediaSegment loadedSegment = - awaitSegment(SabrSegmentRequest.initialization(format)); + awaitSegment(SabrSegmentKey.initialization(format)); if (loadedSegment == null) { return new byte[0]; } final byte[] loaded = loadedSegment.getData(); holder.setInitializationData(itag, loaded); - holder.getBridge(localization).discard(SabrSegmentRequest.initialization(format)); + holder.getBridge(localization).discard(SabrSegmentKey.initialization(format)); return loaded; } @@ -252,17 +251,17 @@ private void maybeAdvanceProgressiveReader() { progressiveDataEndPosition = -1; } - private SabrSegmentRequest requestFromUri(final Uri u) throws IOException { + private SabrSegmentKey requestFromUri(final Uri u) throws IOException { final YoutubeSabrInfo.Format format = formatFromUri(u); final String seg = u.getLastPathSegment(); if (seg == null) { throw new SabrLogicException("Bad SABR segment uri: " + u); } if ("init".equals(seg)) { - return SabrSegmentRequest.initialization(format); + return SabrSegmentKey.initialization(format); } try { - return SabrSegmentRequest.media(format, Integer.parseInt(seg)); + return SabrSegmentKey.media(format, Integer.parseInt(seg)); } catch (final NumberFormatException e) { throw new SabrLogicException("Bad SABR segment uri: " + u, e); } @@ -292,7 +291,7 @@ private YoutubeSabrInfo.Format formatFromUri(final Uri u) throws IOException { } @Nullable - private SabrMediaSegment awaitSegment(final SabrSegmentRequest request) throws IOException { + private SabrMediaSegment awaitSegment(final SabrSegmentKey request) throws IOException { final YoutubeSabrInfo.Format format = request.getFormat(); holder.throwIfTerminal(); if (holder.isInvalidated()) { @@ -327,7 +326,7 @@ private SabrMediaSegment awaitSegment(final SabrSegmentRequest request) throws I if (holder.isInvalidated()) { throw invalidatedException(request.getFormat()); } - if (holder.session.isBeyondEnd(request)) { + if (!request.isInitializationSegment() && holder.isBeyondEnd(request)) { Log.d(TAG, "beyond end video=" + holder.videoId + " itag=" + format.getItag() + " seq=" + request.getSequenceNumber()); @@ -380,7 +379,7 @@ private SabrMediaSegment awaitSegment(final SabrSegmentRequest request) throws I } return segment; } - if (holder.session.isBeyondEnd(request)) { + if (!request.isInitializationSegment() && holder.isBeyondEnd(request)) { Log.d(TAG, "beyond end video=" + holder.videoId + " itag=" + format.getItag() + " seq=" + request.getSequenceNumber()); @@ -397,7 +396,7 @@ private SabrMediaSegment awaitSegment(final SabrSegmentRequest request) throws I + " init=" + request.isInitializationSegment() + " seq=" + request.getSequenceNumber() + " bridge=" + bridge.getStateName() - + " edgeMs=" + holder.session.getStreamState().getMinBufferedEndMs() + + " edgeMs=" + holder.getReaderHeadMs() + " readerHeadMs=" + holder.getReaderHeadMs() + " readerTailMs=" + holder.getReaderTailMs() + " aheadBytes=" + bridge.getAheadBytes()); @@ -405,7 +404,7 @@ private SabrMediaSegment awaitSegment(final SabrSegmentRequest request) throws I + " itag=" + format.getItag() + " init=" + request.isInitializationSegment() + " seq=" + request.getSequenceNumber() - + " edgeMs=" + holder.session.getStreamState().getMinBufferedEndMs() + + " edgeMs=" + holder.getReaderHeadMs() + " readerHeadMs=" + holder.getReaderHeadMs()); } final long now = System.currentTimeMillis(); @@ -434,9 +433,9 @@ private SabrMediaSegment awaitSegment(final SabrSegmentRequest request) throws I recovery = "init"; bridge.requestInitialization(format); } else { - final long edgeMs = holder.session.getStreamState().getMinBufferedEndMs(); - final long segStartMs = holder.session.getStreamState() - .getSegmentStartMs(format, request.getSequenceNumber()); + final long edgeMs = holder.getReaderHeadMs(); + final long segStartMs = holder.getTimeline(format) + .getStartMs(request.getSequenceNumber()); if (segStartMs < edgeMs) { recovery = "rewind"; holder.setReaderPositionMs(readerOwner, readerGeneration, format.getItag(), @@ -459,7 +458,7 @@ private SabrMediaSegment awaitSegment(final SabrSegmentRequest request) throws I + " init=" + request.isInitializationSegment() + " seq=" + request.getSequenceNumber() + " bridge=" + bridge.getStateName() - + " edgeMs=" + holder.session.getStreamState().getMinBufferedEndMs()); + + " edgeMs=" + holder.getReaderHeadMs()); if (recoveryAtMs < 0) { recoveryAtMs = now; } @@ -474,7 +473,7 @@ private SabrMediaSegment awaitSegment(final SabrSegmentRequest request) throws I + ", waitMs=" + (now - waitStart) + ", bridge=" + bridge.getStateName() + ", edgeMs=" - + holder.session.getStreamState().getMinBufferedEndMs() + + holder.getReaderHeadMs() + ", readerHeadMs=" + holder.getReaderHeadMs() + ", readerTailMs=" + holder.getReaderTailMs() + ", aheadBytes=" + bridge.getAheadBytes() @@ -543,7 +542,7 @@ public void close() { } catch (final IOException e) { Log.w(TAG, "Could not close SABR segment stream", e); } - final SabrSegmentRequest request = openedRequest; + final SabrSegmentKey request = openedRequest; openedRequest = null; if (request != null && !request.isInitializationSegment() && holder != null) { holder.getBridge(localization).discard(request); diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentKey.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentKey.java new file mode 100644 index 000000000..6f1d4bfcd --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentKey.java @@ -0,0 +1,37 @@ +package org.schabi.newpipe.player.datasource; + +import androidx.annotation.NonNull; + +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; + +/** Identifies one initialization or media segment requested by Media3. */ +final class SabrSegmentKey { + @NonNull private final YoutubeSabrInfo.Format format; + private final boolean initialization; + private final int sequenceNumber; + + private SabrSegmentKey(@NonNull final YoutubeSabrInfo.Format format, + final boolean initialization, + final int sequenceNumber) { + this.format = format; + this.initialization = initialization; + this.sequenceNumber = sequenceNumber; + } + + static SabrSegmentKey initialization(@NonNull final YoutubeSabrInfo.Format format) { + return new SabrSegmentKey(format, true, -1); + } + + static SabrSegmentKey media(@NonNull final YoutubeSabrInfo.Format format, + final int sequenceNumber) { + if (sequenceNumber <= 0) { + throw new IllegalArgumentException("SABR media sequence number must be positive"); + } + return new SabrSegmentKey(format, false, sequenceNumber); + } + + @NonNull YoutubeSabrInfo.Format getFormat() { return format; } + boolean isInitialization() { return initialization; } + boolean isInitializationSegment() { return initialization; } + int getSequenceNumber() { return sequenceNumber; } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java index 6329c6230..2b29ef031 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java @@ -15,10 +15,9 @@ import org.schabi.newpipe.player.SabrBackoffCoordinator; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.localization.Localization; -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormatTimeline; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession; -import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrStreamState; import org.schabi.newpipe.extractor.services.youtube.sabr.media.SabrMediaSegment; import org.schabi.newpipe.extractor.stream.DeliveryMethod; import org.schabi.newpipe.extractor.stream.StreamInfo; @@ -136,15 +135,21 @@ public int hashCode() { private static final class BootstrapResult { @NonNull private final byte[] audioInitialization; @NonNull private final byte[] videoInitialization; + @NonNull private final YoutubeSabrFormatTimeline audioTimeline; + @NonNull private final YoutubeSabrFormatTimeline videoTimeline; @NonNull private final AtomicReference preparedSession; @NonNull private final AtomicReference> mediaSegments; BootstrapResult(@NonNull final byte[] audioInitialization, @NonNull final byte[] videoInitialization, + @NonNull final YoutubeSabrFormatTimeline audioTimeline, + @NonNull final YoutubeSabrFormatTimeline videoTimeline, @Nullable final YoutubeSabrSession preparedSession, @NonNull final List mediaSegments) { this.audioInitialization = audioInitialization.clone(); this.videoInitialization = videoInitialization.clone(); + this.audioTimeline = audioTimeline; + this.videoTimeline = videoTimeline; this.preparedSession = new AtomicReference<>(preparedSession); this.mediaSegments = new AtomicReference<>(mediaSegments); } @@ -262,9 +267,16 @@ public static final class Holder { @NonNull public final YoutubeSabrSession session; @NonNull public final YoutubeSabrInfo.Format audioFormat; @NonNull public final YoutubeSabrInfo.Format videoFormat; + @NonNull public final YoutubeSabrFormatTimeline audioTimeline; + @NonNull public final YoutubeSabrFormatTimeline videoTimeline; // Playback position is only a hint. Pump and eviction use reader positions. private volatile long playerTimeMs; + private volatile float playbackRate = 1.0f; + private volatile long bandwidthEstimate = -1; + @Nullable private volatile byte[] poToken; + private volatile boolean audioActive = true; + private volatile boolean videoActive = true; private final Map readerPositions = new ConcurrentHashMap<>(); private final Map activeTrackModes = new IdentityHashMap<>(); private final Map initializationData = new ConcurrentHashMap<>(); @@ -284,23 +296,6 @@ public static final class Holder { private volatile SabrLogicException terminalFailure; private long lastDiagnosticsAtMs; - Holder(@NonNull final Context appContext, - @NonNull final String videoId, - @NonNull final YoutubeSabrInfo info, - @NonNull final YoutubeSabrSession session, - @NonNull final YoutubeSabrInfo.Format audioFormat, - @NonNull final YoutubeSabrInfo.Format videoFormat) { - this.key = new SessionKey(0, videoId, info, audioFormat, videoFormat); - this.appContext = appContext.getApplicationContext(); - this.videoId = videoId; - this.info = info; - this.session = session; - this.audioFormat = audioFormat; - this.videoFormat = videoFormat; - this.bootstrapMediaSegments = Collections.emptyList(); - this.backoffState = new SabrBackoffState(); - } - Holder(@NonNull final Context appContext, @NonNull final SabrSourceSpec spec, @NonNull final YoutubeSabrSession session) { @@ -312,6 +307,8 @@ public static final class Holder { this.session = session; this.audioFormat = spec.getAudioFormat(); this.videoFormat = spec.getVideoFormat(); + this.audioTimeline = spec.getAudioTimeline(); + this.videoTimeline = spec.getVideoTimeline(); this.bootstrapMediaSegments = spec.takeBootstrapMediaSegments(); this.backoffState = new SabrBackoffState(); retainBootstrapInitialization(spec, audioFormat); @@ -331,6 +328,36 @@ void setPlayerTimeMs(final long playerTimeMs) { this.playerTimeMs = playerTimeMs; } + void setPlaybackRate(final float value) { + if (value > 0) playbackRate = value; + } + + void setPoToken(@NonNull final byte[] value) { + poToken = value.clone(); + } + + @Nullable byte[] getPoToken() { + return poToken == null ? null : poToken.clone(); + } + + float getPlaybackRate() { return playbackRate; } + long getBandwidthEstimate() { return bandwidthEstimate; } + boolean isAudioActive() { return audioActive; } + boolean isVideoActive() { return videoActive; } + + void observeBandwidth(final long sample) { + if (sample <= 0) return; + bandwidthEstimate = bandwidthEstimate <= 0 + ? sample : (bandwidthEstimate * 3 + sample) / 4; + } + + @NonNull YoutubeSabrFormatTimeline getTimeline( + @NonNull final YoutubeSabrInfo.Format format) { + if (format.getItag() == audioFormat.getItag()) return audioTimeline; + if (format.getItag() == videoFormat.getItag()) return videoTimeline; + throw new IllegalArgumentException("Unknown SABR itag: " + format.getItag()); + } + /** A data source reports how far it has read (last served segment end, ms). */ public synchronized void setReaderPositionMs(@NonNull final Object owner, final long generation, @@ -416,19 +443,16 @@ void requestSeek(final long positionMs, @NonNull final Localization localization setPlayerTimeMs(positionMs); recordDiagnostics("seek positionMs=" + positionMs + " backward=" + backward); anchorReaderPositionMs(positionMs); - session.getStreamState().setSelectVideoFormatBeforeAudio(positionMs > 1_000); if (positionMs <= 1_000 && previousPlayerTimeMs <= 1_000) { return; } // Media3 may seek within its sample queue; still reposition the SABR session when the // target audio/video segments are not cached. final YoutubeSabrInfo.Format targetFormat = videoFormat; - final int sequence = session.getStreamState() - .getSegmentNumberAtOrAfterTimeMs(targetFormat, positionMs); - final SabrSegmentRequest request = SabrSegmentRequest.media(targetFormat, sequence); - final int audioSequence = session.getStreamState() - .getSegmentNumberAtOrAfterTimeMs(audioFormat, positionMs); - final SabrSegmentRequest audioRequest = SabrSegmentRequest.media( + final int sequence = videoTimeline.getSequenceAt(positionMs); + final SabrSegmentKey request = SabrSegmentKey.media(targetFormat, sequence); + final int audioSequence = audioTimeline.getSequenceAt(positionMs); + final SabrSegmentKey audioRequest = SabrSegmentKey.media( audioFormat, audioSequence); final SabrMediaBridge currentBridge = getBridge(localization); if (currentBridge.getCached(request) == null @@ -486,7 +510,8 @@ private void applyActiveTracks() { setTrackActive(videoFormat.getItag(), videoActive); setTrackActive(audioFormat.getItag(), audioActive); if (videoActive || audioActive) { - session.getStreamState().setActiveTrackTypes(videoActive, audioActive); + this.videoActive = videoActive; + this.audioActive = audioActive; } } @@ -540,7 +565,7 @@ public boolean hasUnstartedActiveReader() { synchronized SabrMediaBridge getBridge(@NonNull final Localization localization) { if (bridge == null) { - bridge = new SabrMediaBridge(session, localization, backoffState); + bridge = new SabrMediaBridge(this, localization, backoffState); bridge.seedSegments(bootstrapMediaSegments); } return bridge; @@ -604,8 +629,8 @@ void stop(@NonNull final String reason) { } } - boolean isBeyondEnd(@NonNull final SabrSegmentRequest request) { - return session.isBeyondEnd(request); + boolean isBeyondEnd(@NonNull final SabrSegmentKey request) { + return request.getSequenceNumber() > getTimeline(request.getFormat()).getEndSequence(); } void recordDiagnostics(@NonNull final String event) { @@ -636,7 +661,7 @@ public static void updatePlayerTime(@NonNull final String videoId, final long pl public static void updatePlaybackRate(@NonNull final String videoId, final float playbackRate) { for (final Map.Entry entry : SESSIONS.entrySet()) { if (entry.getKey().videoId.equals(videoId) && entry.getValue().hasLeaseReferences()) { - entry.getValue().session.getStreamState().setPlaybackRate(playbackRate); + entry.getValue().setPlaybackRate(playbackRate); } } } @@ -679,6 +704,7 @@ public static SabrSourceSpec createSourceSpec(@NonNull final String videoId, PlaybackStartupTrace.markForVideoId(videoId, "sabr_source_spec_ready"); return new SabrSourceSpec(videoId, info, audioFormat, videoFormat, localization, bootstrap.audioInitialization, bootstrap.videoInitialization, + bootstrap.audioTimeline, bootstrap.videoTimeline, bootstrap.takePreparedSession(), bootstrap.getMediaSegments()); } @@ -726,7 +752,7 @@ private static Future startBootstrap(@NonNull final Context con context, info.getVideoId()); final FutureTask created = new FutureTask(() -> cacheBootstrap(key, createPreparation(context, info, audioFormat, videoFormat, - localization, backoffState))) { + localization))) { @Override protected void done() { PlaybackStartupTrace.markForVideoId(info.getVideoId(), "sabr_audio_init_ready"); @@ -748,42 +774,38 @@ private static BootstrapResult createPreparation(@NonNull final Context context, @NonNull final YoutubeSabrInfo info, @NonNull final YoutubeSabrInfo.Format audioFormat, @NonNull final YoutubeSabrInfo.Format videoFormat, - @NonNull final Localization localization, - @NonNull final BootstrapBackoffState backoffState) + @NonNull final Localization localization) throws IOException, ExtractionException { final LocalDomPoTokenProvider sessionProvider = provider(context); final File spoolDirectory = new File(context.getApplicationContext().getCacheDir(), "sabr-bootstrap/" + info.getVideoId() + '-' + System.nanoTime()); final YoutubeSabrSession session = new YoutubeSabrSession(info, audioFormat, videoFormat, spoolDirectory); - boolean handedOff = false; - try { - final byte[] poToken = awaitWarmedToken(info.getVideoId(), info, sessionProvider, - session.getStreamState()); - if (poToken == null || poToken.length == 0) { - throw new SabrLogicException("SABR PO token provider returned no token for video=" - + info.getVideoId()); - } - session.getStreamState().setPoToken(poToken); - YoutubeSabrSession.InitializationResult initialization; - try { - initialization = session.initialize(localization, 2_000, poToken); - } catch (final IOException firstFailure) { - attachPoToken(info.getVideoId(), info, sessionProvider, session); - final byte[] retryPoToken = awaitWarmedToken(info.getVideoId(), info, sessionProvider, - session.getStreamState()); - session.getStreamState().setPoToken(retryPoToken); - initialization = session.initialize(localization, 2_000, retryPoToken); - } - if (initialization.getAudioData() == null || initialization.getVideoData() == null) { - throw new SabrLogicException("SABR initialization did not provide both tracks for video=" - + info.getVideoId()); - } - handedOff = true; - return new BootstrapResult(initialization.getAudioData(), initialization.getVideoData(), - session, initialization.getMediaSegments()); - } finally { + final byte[] poToken = awaitWarmedToken(info.getVideoId(), info, sessionProvider); + if (poToken == null || poToken.length == 0) { + throw new SabrLogicException("SABR PO token provider returned no token for video=" + + info.getVideoId()); } + YoutubeSabrSession.InitializationResult initialization; + try { + initialization = session.initialize(localization, 2_000, poToken); + } catch (final IOException firstFailure) { + final byte[] retryPoToken = awaitWarmedToken( + info.getVideoId(), info, sessionProvider); + initialization = session.initialize(localization, 2_000, retryPoToken); + } + if (initialization.getAudioData() == null || initialization.getVideoData() == null) { + throw new SabrLogicException("SABR initialization did not provide both tracks for video=" + + info.getVideoId()); + } + if (initialization.getAudioTimeline() == null + || initialization.getVideoTimeline() == null) { + throw new SabrLogicException("SABR initialization did not provide timelines for video=" + + info.getVideoId()); + } + return new BootstrapResult(initialization.getAudioData(), initialization.getVideoData(), + initialization.getAudioTimeline(), initialization.getVideoTimeline(), + session, initialization.getMediaSegments()); } @NonNull @@ -845,8 +867,8 @@ private static void startTokenWarmup(@NonNull final Context context, @NonNull final YoutubeSabrInfo.Format audioFormat, @NonNull final YoutubeSabrInfo.Format videoFormat) { final String tokenKey = tokenIdentityKey(info); - final FutureTask created = new FutureTask(() -> provider(context).getPoToken( - info, new YoutubeSabrStreamState(audioFormat, videoFormat))) { + final FutureTask created = new FutureTask(() -> + provider(context).getPoToken(info)) { @Override protected void done() { TOKEN_IN_FLIGHT.remove(tokenKey, this); @@ -877,15 +899,20 @@ static Lease acquire(@NonNull final Context context, @NonNull final SabrSourceSp "sabr-segments/" + spec.getVideoId() + '-' + System.nanoTime()); final YoutubeSabrSession preparedSession = spec.takePreparedSession(); final YoutubeSabrSession session; + final byte[] poToken; if (preparedSession != null) { session = preparedSession; session.addDiagnosticEvent("bootstrap_session_handoff"); + poToken = attachPoToken( + spec.getVideoId(), spec.getInfo(), sessionProvider, session); } else { session = new YoutubeSabrSession(spec.getInfo(), spec.getAudioFormat(), spec.getVideoFormat(), spoolDirectory); - attachPoToken(spec.getVideoId(), spec.getInfo(), sessionProvider, session); + poToken = attachPoToken( + spec.getVideoId(), spec.getInfo(), sessionProvider, session); } final Holder holder = new Holder(context, spec, session); + holder.setPoToken(poToken); seedInitializationData(holder, spec, spec.getAudioFormat()); seedInitializationData(holder, spec, spec.getVideoFormat()); SESSIONS.put(key, holder); @@ -904,7 +931,6 @@ private static void seedInitializationData(@NonNull final Holder holder, final byte[] data = spec.getInitializationData(format.getItag()); if (data != null) { holder.setInitializationData(format.getItag(), data); - holder.session.getStreamState().ingestInitializationData(format, data); } } @@ -916,21 +942,20 @@ private static void releaseLease(@NonNull final SessionKey key, } } - private static void attachPoToken(@NonNull final String videoId, - @NonNull final YoutubeSabrInfo info, - @NonNull final LocalDomPoTokenProvider provider, - @NonNull final YoutubeSabrSession session) + private static byte[] attachPoToken(@NonNull final String videoId, + @NonNull final YoutubeSabrInfo info, + @NonNull final LocalDomPoTokenProvider provider, + @NonNull final YoutubeSabrSession session) throws IOException, ExtractionException { try { - final byte[] token = awaitWarmedToken(videoId, info, provider, - session.getStreamState()); + final byte[] token = awaitWarmedToken(videoId, info, provider); if (token == null || token.length == 0) { throw new SabrLogicException("SABR PO token provider returned no token for video=" + videoId); } - session.getStreamState().setPoToken(token); session.addDiagnosticEvent("token_attach bytes=" + token.length); + return token; } catch (final IOException | ExtractionException e) { Log.w(TAG, "PO token attach failed video=" + videoId, e); session.addDiagnosticEvent("token_attach_failed type=" @@ -947,15 +972,13 @@ private static void attachPoToken(@NonNull final String videoId, @Nullable private static byte[] awaitWarmedToken(@NonNull final String videoId, @NonNull final YoutubeSabrInfo info, - @NonNull final LocalDomPoTokenProvider provider, - @NonNull final org.schabi.newpipe.extractor.services - .youtube.sabr.YoutubeSabrStreamState state) + @NonNull final LocalDomPoTokenProvider provider) throws IOException, ExtractionException { final String tokenKey = tokenIdentityKey(info); final Future future = TOKEN_IN_FLIGHT.get(tokenKey); if (future == null) { PlaybackStartupTrace.markForVideoId(videoId, "sabr_token_mint_started"); - final byte[] token = provider.getPoToken(info, state); + final byte[] token = provider.getPoToken(info); PlaybackStartupTrace.markForVideoId(videoId, "sabr_token_ready"); return token; } diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSourceSpec.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSourceSpec.java index 5c2a6d553..1bb3e5dc9 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSourceSpec.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSourceSpec.java @@ -5,8 +5,8 @@ import org.schabi.newpipe.extractor.localization.Localization; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormatTimeline; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession; -import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrStreamState; import org.schabi.newpipe.extractor.services.youtube.sabr.media.SabrMediaSegment; import java.util.concurrent.atomic.AtomicLong; @@ -26,6 +26,8 @@ public final class SabrSourceSpec { @NonNull private final Localization localization; @NonNull private final byte[] audioInitializationData; @NonNull private final byte[] videoInitializationData; + @NonNull private final YoutubeSabrFormatTimeline audioTimeline; + @NonNull private final YoutubeSabrFormatTimeline videoTimeline; @NonNull private final AtomicReference preparedSession; @NonNull private final List bootstrapMediaSegments; @@ -37,7 +39,10 @@ public SabrSourceSpec(@NonNull final String videoId, @NonNull final byte[] audioInitializationData, @NonNull final byte[] videoInitializationData) { this(videoId, info, audioFormat, videoFormat, localization, - audioInitializationData, videoInitializationData, null, Collections.emptyList()); + audioInitializationData, videoInitializationData, + parseTimeline(audioFormat, audioInitializationData), + parseTimeline(videoFormat, videoInitializationData), + null, Collections.emptyList()); } SabrSourceSpec(@NonNull final String videoId, @@ -47,6 +52,8 @@ public SabrSourceSpec(@NonNull final String videoId, @NonNull final Localization localization, @NonNull final byte[] audioInitializationData, @NonNull final byte[] videoInitializationData, + @NonNull final YoutubeSabrFormatTimeline audioTimeline, + @NonNull final YoutubeSabrFormatTimeline videoTimeline, @Nullable final YoutubeSabrSession preparedSession, @NonNull final List bootstrapMediaSegments) { this.sourceId = NEXT_SOURCE_ID.incrementAndGet(); @@ -57,6 +64,8 @@ public SabrSourceSpec(@NonNull final String videoId, this.localization = localization; this.audioInitializationData = audioInitializationData.clone(); this.videoInitializationData = videoInitializationData.clone(); + this.audioTimeline = audioTimeline; + this.videoTimeline = videoTimeline; this.preparedSession = new AtomicReference<>(preparedSession); this.bootstrapMediaSegments = bootstrapMediaSegments; } @@ -105,12 +114,14 @@ long getDurationMs() { return Math.max(audioFormat.getApproxDurationMs(), videoFormat.getApproxDurationMs()); } + @NonNull YoutubeSabrFormatTimeline getAudioTimeline() { return audioTimeline; } + @NonNull YoutubeSabrFormatTimeline getVideoTimeline() { return videoTimeline; } + @NonNull - YoutubeSabrStreamState newStreamState() { - final YoutubeSabrStreamState state = new YoutubeSabrStreamState(audioFormat, videoFormat); - state.ingestInitializationData(audioFormat, audioInitializationData); - state.ingestInitializationData(videoFormat, videoInitializationData); - return state; + YoutubeSabrFormatTimeline getTimeline(@NonNull final YoutubeSabrInfo.Format format) { + if (format.getItag() == audioFormat.getItag()) return audioTimeline; + if (format.getItag() == videoFormat.getItag()) return videoTimeline; + throw new IllegalArgumentException("Unknown SABR itag: " + format.getItag()); } @Nullable @@ -126,4 +137,15 @@ List takeBootstrapMediaSegments() { void discardPreparedSession() { preparedSession.set(null); } + + @NonNull + private static YoutubeSabrFormatTimeline parseTimeline( + @NonNull final YoutubeSabrInfo.Format format, @NonNull final byte[] data) { + try { + return YoutubeSabrFormatTimeline.parse(format, data); + } catch (final Exception error) { + throw new IllegalArgumentException("Invalid SABR initialization timeline: itag=" + + format.getItag(), error); + } + } } diff --git a/app/src/main/java/us/shandian/giga/get/SabrDownloadTarget.kt b/app/src/main/java/us/shandian/giga/get/SabrDownloadTarget.kt index 9d21c42ce..6bf17265f 100644 --- a/app/src/main/java/us/shandian/giga/get/SabrDownloadTarget.kt +++ b/app/src/main/java/us/shandian/giga/get/SabrDownloadTarget.kt @@ -1,6 +1,7 @@ package us.shandian.giga.get import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormatTimeline import java.io.File import java.util.TreeMap @@ -11,8 +12,8 @@ internal data class SabrDownloadTarget( val file: File, var nextWriteSequence: Int = 1, var initializationWritten: Boolean = false, - var initializationObserved: Boolean = false, var initializationData: ByteArray? = null, + var timeline: YoutubeSabrFormatTimeline? = null, val pending: TreeMap = TreeMap(), var pendingBytes: Long = 0, ) diff --git a/app/src/main/java/us/shandian/giga/get/SabrDownloader.kt b/app/src/main/java/us/shandian/giga/get/SabrDownloader.kt index 224cf6461..94f8e1ecd 100644 --- a/app/src/main/java/us/shandian/giga/get/SabrDownloader.kt +++ b/app/src/main/java/us/shandian/giga/get/SabrDownloader.kt @@ -5,7 +5,6 @@ import org.schabi.newpipe.BuildConfig import org.schabi.newpipe.extractor.localization.Localization import org.schabi.newpipe.extractor.services.youtube.sabr.exception.SabrProtocolException import org.schabi.newpipe.extractor.services.youtube.sabr.exception.SabrRecoverableException -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession import org.schabi.newpipe.player.datasource.LocalDomPoTokenProvider @@ -45,7 +44,7 @@ internal class SabrDownloader( info = SabrDownloadFormatResolver.resolveInfo(recoveries) refreshInfo = false } - runSessionAttempt(info, recoveries, coldStartAttempts) + runSessionAttempt(info, recoveries) break } catch (error: RetryColdStartException) { coldStartAttempts++ @@ -88,7 +87,6 @@ internal class SabrDownloader( private fun runSessionAttempt( info: YoutubeSabrInfo, recoveries: Array, - coldStartAttempt: Int, ) { val session = YoutubeSabrSession( info, @@ -96,15 +94,10 @@ internal class SabrDownloader( SabrDownloadFormatResolver.selectedVideoFormat(info, recoveries), null, ) - val poToken = LocalDomPoTokenProvider(mission.context).getPoToken(info, session.streamState) - session.streamState.setPoToken(poToken) + val poToken = LocalDomPoTokenProvider(mission.context).getPoToken(info) val workDir = prepareWorkDirectory() val targets = SabrDownloadFormatResolver.buildTargets(info, recoveries, workDir) restoreTargets(targets) - targets.forEach { target -> - session.streamState.jumpBufferedTo(target.format, target.nextWriteSequence) - } - configureRequestMode(session, targets, coldStartAttempt) val outputs = mutableMapOf() try { targets.forEach { target -> @@ -124,7 +117,7 @@ internal class SabrDownloader( downloadSegments( session, targets, - SabrSegmentWriter(session, targets, outputs, ::reportBytesWritten), + SabrSegmentWriter(targets, outputs, ::reportBytesWritten), poToken, ) } finally { @@ -190,23 +183,6 @@ internal class SabrDownloader( mission.notifyProgress(delta) } - private fun configureRequestMode( - session: YoutubeSabrSession, - targets: List, - coldStartAttempt: Int, - ) { - val useCompanionWarmup = targets.size == 1 && coldStartAttempt % 2 == 1 - if (useCompanionWarmup) { - session.streamState.setVideoAndAudioRequestMode() - } else if (targets.size == 1 && targets.first().format.isAudio) { - session.streamState.setAudioOnlyRequestMode() - } else if (targets.size == 1 && targets.first().format.isVideo) { - session.streamState.setVideoOnlyRequestMode() - } else { - session.streamState.setVideoAndAudioRequestMode() - } - } - @Throws(IOException::class) private fun prepareWorkDirectory(): File { val workDir = workDirectory(mission) @@ -308,6 +284,7 @@ internal class SabrDownloader( var emptyResponses = 0 var nextRequestAtMs = 0L + var bandwidthEstimate = -1L while (true) { ensureRunning() val backoffRemainingMs = nextRequestAtMs - System.currentTimeMillis() @@ -316,20 +293,36 @@ internal class SabrDownloader( ensureRunning() } writer.observeWrittenInitializations() - configureInitializedSingleTargetMode(session, targets) - - if (isDownloadComplete(session, targets)) { + if (isDownloadComplete(targets)) { break } - val playerTimeMs = downloadPlayerTimeMs(session, targets) - session.streamState.setPlayerTimeMs(playerTimeMs) - val requestResult = session.requestOnce(localization, writer::acceptSegment) + val playerTimeMs = downloadPlayerTimeMs(targets) + val audio = targets.firstOrNull { it.format.isAudio } + val video = targets.firstOrNull { it.format.isVideo } + val requestResult = session.requestOnce( + localization, + playerTimeMs, + audio?.timeline, + (audio?.nextWriteSequence ?: 1) - 1, + video?.timeline, + (video?.nextWriteSequence ?: 1) - 1, + audio != null, + video != null, + false, + bandwidthEstimate, + 1.0f, + poToken, + writer::acceptSegment, + ) + if (requestResult.bandwidthSample > 0) { + bandwidthEstimate = if (bandwidthEstimate <= 0) requestResult.bandwidthSample + else (bandwidthEstimate * 3 + requestResult.bandwidthSample) / 4 + } nextRequestAtMs = System.currentTimeMillis() + requestResult.backoffMs val segmentCount = requestResult.segmentCount writer.observeWrittenInitializations() - configureInitializedSingleTargetMode(session, targets) - if (isDownloadComplete(session, targets)) { + if (isDownloadComplete(targets)) { break } if (segmentCount > 0) { @@ -369,6 +362,11 @@ internal class SabrDownloader( } else { initialization.videoData } ?: throw RetryColdStartException() + target.timeline = if (target.format.isAudio) { + initialization.audioTimeline + } else { + initialization.videoTimeline + } ?: throw RetryColdStartException() writer.writeInitializationData(target, data) } for (segment in initialization.mediaSegments) { @@ -379,38 +377,17 @@ internal class SabrDownloader( } } - private fun configureInitializedSingleTargetMode( - session: YoutubeSabrSession, - targets: List, - ) { - if (targets.size != 1 || !targets.first().initializationWritten) { - return - } - if (targets.first().format.isAudio) { - session.streamState.setAudioOnlyRequestMode() - } else { - session.streamState.setVideoOnlyRequestMode() - } - } - - private fun downloadPlayerTimeMs( - session: YoutubeSabrSession, - targets: List, - ): Long { - if (targets.size == 1) { - return session.streamState.getBufferedEndMs(targets.first().format) + private fun downloadPlayerTimeMs(targets: List): Long { + return targets.minOf { target -> + if (target.nextWriteSequence <= 1) 0L + else target.timeline?.getEndMs(target.nextWriteSequence - 1) ?: 0L } - return session.streamState.minBufferedEndMs } - private fun isDownloadComplete( - session: YoutubeSabrSession, - targets: List, - ): Boolean { + private fun isDownloadComplete(targets: List): Boolean { return targets.all { target -> - target.pending.isEmpty() && - (session.streamState.isComplete(target.format) || - session.isBeyondEnd(SabrSegmentRequest.media(target.format, target.nextWriteSequence))) + val endSequence = target.timeline?.endSequence ?: Int.MAX_VALUE + target.pending.isEmpty() && target.nextWriteSequence > endSequence } } diff --git a/app/src/main/java/us/shandian/giga/get/SabrSegmentWriter.kt b/app/src/main/java/us/shandian/giga/get/SabrSegmentWriter.kt index 97cb6e3e8..296e5f139 100644 --- a/app/src/main/java/us/shandian/giga/get/SabrSegmentWriter.kt +++ b/app/src/main/java/us/shandian/giga/get/SabrSegmentWriter.kt @@ -1,12 +1,11 @@ package us.shandian.giga.get import org.schabi.newpipe.extractor.services.youtube.sabr.media.SabrMediaSegment -import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormatTimeline import java.io.IOException import java.io.OutputStream internal class SabrSegmentWriter( - private val session: YoutubeSabrSession, private val targets: List, private val outputs: Map, private val onBytesWritten: (SabrDownloadTarget, Long) -> Unit, @@ -36,9 +35,8 @@ internal class SabrSegmentWriter( fun observeWrittenInitializations() { for (target in targets) { val data = target.initializationData ?: continue - if (!target.initializationObserved) { - target.initializationObserved = session.streamState.hasSegmentIndex(target.format) - || session.streamState.ingestInitializationData(target.format, data) + if (target.timeline == null) { + target.timeline = YoutubeSabrFormatTimeline.parse(target.format, data) } } } @@ -60,6 +58,7 @@ internal class SabrSegmentWriter( writeToStorage(output, data) target.initializationWritten = true target.initializationData = data + target.timeline = YoutubeSabrFormatTimeline.parse(target.format, data) onBytesWritten(target, data.size.toLong()) flushPendingMedia(target, output) return true From 7e58682889994a157eb95646f99893cb4f6e4401 Mon Sep 17 00:00:00 2001 From: InfinityLoop1308 <96324692+InfinityLoop1308@users.noreply.github.com> Date: Wed, 5 Aug 2026 08:24:57 +0800 Subject: [PATCH 04/13] 4 --- .../newpipe/error/AcraReportSender.java | 7 +- .../org/schabi/newpipe/player/Player.java | 23 +- .../player/datasource/SabrBackoffState.java | 40 - .../datasource/SabrDashMediaSource.java | 194 ++- .../datasource/SabrLocalDomPoTokenUtil.kt | 87 -- .../player/datasource/SabrMediaBridge.java | 472 ++++--- .../datasource/SabrPlaybackDiagnostics.java | 73 - .../datasource/SabrSegmentDataSource.java | 560 ++------ .../player/datasource/SabrSegmentKey.java | 21 +- .../player/datasource/SabrSessionHandle.java | 223 --- .../player/datasource/SabrSessionStore.java | 1257 ++++------------- .../player/datasource/SabrSourceSpec.java | 149 +- .../player/resolver/PlaybackResolver.java | 36 +- .../resolver/VideoPlaybackResolver.java | 2 - .../newpipe/util/StreamItemAdapter.java | 10 +- .../LocalDomPoTokenProvider.kt | 87 +- .../giga/get/SabrDownloadFormatResolver.kt | 4 +- .../us/shandian/giga/get/SabrDownloader.kt | 20 +- 18 files changed, 905 insertions(+), 2360 deletions(-) delete mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/SabrBackoffState.java delete mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/SabrLocalDomPoTokenUtil.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/SabrPlaybackDiagnostics.java delete mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionHandle.java rename app/src/main/java/org/schabi/newpipe/{player/datasource => youtube}/LocalDomPoTokenProvider.kt (83%) diff --git a/app/src/main/java/org/schabi/newpipe/error/AcraReportSender.java b/app/src/main/java/org/schabi/newpipe/error/AcraReportSender.java index cb249e69c..13a5d408d 100644 --- a/app/src/main/java/org/schabi/newpipe/error/AcraReportSender.java +++ b/app/src/main/java/org/schabi/newpipe/error/AcraReportSender.java @@ -8,7 +8,6 @@ import org.acra.data.CrashReportData; import org.acra.sender.ReportSender; import org.schabi.newpipe.R; -import org.schabi.newpipe.player.datasource.SabrPlaybackDiagnostics; /* * Created by Christian Schabesberger on 13.09.16. @@ -35,12 +34,8 @@ public class AcraReportSender implements ReportSender { @Override public void send(@NonNull final Context context, @NonNull final CrashReportData report) { final String stackTrace = report.getString(ReportField.STACK_TRACE); - final String sabrDiagnostics = SabrPlaybackDiagnostics.getLastSnapshot(context); - final String[] logs = sabrDiagnostics.isEmpty() - ? new String[]{stackTrace} - : new String[]{stackTrace, "Last SABR diagnostics\n" + sabrDiagnostics}; ErrorUtil.openActivity(context, new ErrorInfo( - logs, + new String[]{stackTrace}, UserAction.UI_ERROR, ErrorInfo.SERVICE_NONE, "ACRA report", diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java index 2676b4766..8eb312b36 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -135,7 +135,6 @@ import org.schabi.newpipe.player.helper.MediaSessionManager; import org.schabi.newpipe.player.helper.PlayerDataSource; import org.schabi.newpipe.player.helper.PlayerHelper; -import org.schabi.newpipe.player.datasource.SabrSessionStore; import org.schabi.newpipe.player.listeners.view.PlaybackSpeedClickListener; import org.schabi.newpipe.player.listeners.view.QualityClickListener; import org.schabi.newpipe.player.mediaitem.MediaItemTag; @@ -815,6 +814,13 @@ public void handleIntent(@NonNull final Intent intent) { trackSelector.buildUponParameters(); parametersBuilder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, audioPlayerSelected()); parametersBuilder.setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, audioPlayerSelected()); + final String preferredAudioLanguage = prefs.getString( + context.getString(R.string.preferred_audio_language_key), "original"); + if ("original".equals(preferredAudioLanguage)) { + parametersBuilder.setPreferredAudioLanguages(); + } else { + parametersBuilder.setPreferredAudioLanguages(preferredAudioLanguage); + } trackSelector.setParameters(parametersBuilder); // needed for tablets, check the function for a better explanation @@ -1850,8 +1856,6 @@ private void onUpdateProgress(final int currentProgress, // Feed the real play head to any live SABR session (no-op otherwise). getCurrentStreamInfo().ifPresent(info -> { - SabrSessionStore.updatePlayerTime(info.getId(), currentProgress); - SabrSessionStore.updatePlaybackRate(info.getId(), getPlaybackSpeed()); }); if (duration != binding.playbackSeekBar.getMax()) { @@ -3084,6 +3088,7 @@ public void onTracksChanged(@NonNull final Tracks tracks) { enqueueTimer.cancel(true); } onTextTracksChanged(tracks); + onAudioTracksChanged(); } @Override @@ -4581,6 +4586,18 @@ private void setAudioTrack(@Nullable final String audioTrackId) { setRecovery(); videoResolver.setAudioTrack(audioTrackId); audioResolver.setAudioTrack(audioTrackId); + if (isCurrentStreamSabr() && !exoPlayerIsNull()) { + final DefaultTrackSelector.Parameters.Builder parameters = + trackSelector.buildUponParameters(); + if (audioTrackId == null || audioTrackId.isEmpty()) { + parameters.setPreferredAudioLanguages(); + } else { + parameters.setPreferredAudioLanguages( + audioTrackId.split("[._-]", 2)[0]); + } + trackSelector.setParameters(parameters); + return; + } reloadPlayQueueManager(); } diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrBackoffState.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrBackoffState.java deleted file mode 100644 index 1af2e8398..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrBackoffState.java +++ /dev/null @@ -1,40 +0,0 @@ -package org.schabi.newpipe.player.datasource; - -import androidx.annotation.NonNull; - -import java.util.concurrent.CopyOnWriteArrayList; - -/** Client-owned, observable backoff gate shared by all readers of one SABR session. */ -public final class SabrBackoffState { - public interface Listener { void onBackoffChanged(long remainingMs); } - - private final Object monitor = new Object(); - private final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList<>(); - private volatile long deadlineNs; - - long remainingMs() { - final long remaining = deadlineNs - System.nanoTime(); - return remaining <= 0 ? 0 : Math.max(1, remaining / 1_000_000L); - } - - void update(final int backoffMs) { - deadlineNs = backoffMs <= 0 ? 0 - : System.nanoTime() + backoffMs * 1_000_000L; - final long remaining = remainingMs(); - synchronized (monitor) { monitor.notifyAll(); } - for (final Listener listener : listeners) { - listener.onBackoffChanged(remaining); - } - } - - void awaitReady() throws InterruptedException { - while (true) { - final long remaining = remainingMs(); - if (remaining == 0) return; - synchronized (monitor) { monitor.wait(Math.min(remaining, 250)); } - } - } - - void addListener(@NonNull final Listener listener) { listeners.add(listener); } - void removeListener(@NonNull final Listener listener) { listeners.remove(listener); } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDashMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDashMediaSource.java index 1ac247197..bcf4ea688 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDashMediaSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDashMediaSource.java @@ -1,6 +1,8 @@ package org.schabi.newpipe.player.datasource; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession; import android.content.Context; import android.net.Uri; import android.util.Log; @@ -29,13 +31,16 @@ import androidx.media3.exoplayer.trackselection.ExoTrackSelection; import androidx.media3.exoplayer.upstream.Allocator; -import org.schabi.newpipe.extractor.localization.Localization; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormatTimeline; import java.io.ByteArrayInputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Collections; public final class SabrDashMediaSource extends CompositeMediaSource { private static final String TAG = "SabrDashMediaSource"; @@ -45,25 +50,25 @@ public final class SabrDashMediaSource extends CompositeMediaSource { private final MediaItem mediaItem; private final SabrSourceSpec spec; - private final SabrSessionHandle sessionHandle; - private final Localization localization; + private final YoutubeSabrSession session; + @Nullable private SabrMediaBridge bridge; private final long durationUs; private final DashMediaSource childSource; - private final PlaybackState playbackState = new PlaybackState(); public SabrDashMediaSource(@NonNull final Context context, @NonNull final MediaItem mediaItem, @NonNull final SabrSourceSpec spec) throws IOException { this.mediaItem = mediaItem; this.spec = spec; try { - this.localization = spec.getLocalization(); - this.sessionHandle = new SabrSessionHandle(context, spec); - this.playbackState.setReaderOwner(this); + session = SabrSessionStore.getOrCreateSession(context, spec); + } catch (final ExtractionException e) { + throw new IOException("Could not create SABR session for " + spec.getVideoId(), e); + } + try { final long durationMs = spec.getDurationMs(); this.durationUs = durationMs > 0 ? durationMs * 1000L : C.TIME_UNSET; final DataSource.Factory sabrDataSourceFactory = - () -> new SabrSegmentDataSource(sessionHandle, playbackState.getReaderOwner(), - localization, /* prependInit= */ false); + this::createDataSource; final DashManifest manifest = buildManifest(spec, durationMs); this.childSource = new DashMediaSource.Factory( new DefaultDashChunkSource.Factory(sabrDataSourceFactory), @@ -71,9 +76,8 @@ public SabrDashMediaSource(@NonNull final Context context, .createMediaSource(manifest, mediaItem); Log.d(TAG, "create source video=" + spec.getVideoId() + " videoItag=" + spec.getVideoFormat().getItag() - + " audioItag=" + spec.getAudioFormat().getItag()); + + " bootstrapAudioItag=" + spec.getBootstrapAudioFormat().getItag()); } catch (final IOException | RuntimeException | Error e) { - spec.discardPreparedSession(); throw e; } } @@ -86,6 +90,7 @@ public MediaItem getMediaItem() { @Override protected void prepareSourceInternal(@Nullable final TransferListener mediaTransferListener) { + getOrCreateBridge(); super.prepareSourceInternal(mediaTransferListener); prepareChildSource(0, childSource); } @@ -100,18 +105,11 @@ protected void onChildSourceInfoRefreshed(final Integer id, @Override public MediaPeriod createPeriod(final MediaPeriodId id, final Allocator allocator, final long startPositionUs) { - sessionHandle.onPeriodCreated(Math.max(0, startPositionUs / 1000L)); - try { - final MediaPeriod child = childSource.createPeriod(id, allocator, startPositionUs); - final SabrDashMediaPeriod period = new SabrDashMediaPeriod(child); - playbackState.setReaderOwner(period); - Log.d(TAG, "createPeriod video=" + spec.getVideoId() - + " startUs=" + startPositionUs); - return period; - } catch (final RuntimeException e) { - sessionHandle.onPeriodReleased(); - throw e; - } + final MediaPeriod child = childSource.createPeriod(id, allocator, startPositionUs); + final SabrDashMediaPeriod period = new SabrDashMediaPeriod(child); + Log.d(TAG, "createPeriod video=" + spec.getVideoId() + + " startUs=" + startPositionUs); + return period; } @Override @@ -119,17 +117,32 @@ public void releasePeriod(final MediaPeriod mediaPeriod) { Log.d(TAG, "releasePeriod video=" + spec.getVideoId()); final SabrDashMediaPeriod period = (SabrDashMediaPeriod) mediaPeriod; period.release(); - try { - childSource.releasePeriod(period.child); - } finally { - sessionHandle.onPeriodReleased(); - } + childSource.releasePeriod(period.child); } @Override protected void releaseSourceInternal() { Log.d(TAG, "release source video=" + spec.getVideoId()); - sessionHandle.close(); + final SabrMediaBridge bridgeToStop; + synchronized (this) { + bridgeToStop = bridge; + bridge = null; + } + if (bridgeToStop != null) bridgeToStop.stop(); + } + + @NonNull + private DataSource createDataSource() { + return new SabrSegmentDataSource(spec, getOrCreateBridge()); + } + + @NonNull + private synchronized SabrMediaBridge getOrCreateBridge() { + if (bridge == null) { + bridge = new SabrMediaBridge(session, spec); + bridge.seedSegments(spec.takeBootstrapMediaSegments()); + } + return bridge; } private static DashManifest buildManifest(final SabrSourceSpec spec, @@ -141,8 +154,9 @@ private static DashManifest buildManifest(final SabrSourceSpec spec, + "minBufferTime=\"PT1.5S\" mediaPresentationDuration=\"" + formatDuration(durationMs) + "\">" + "" - + adaptationSet(spec, spec.getVideoFormat(), C.TRACK_TYPE_VIDEO) - + adaptationSet(spec, spec.getAudioFormat(), C.TRACK_TYPE_AUDIO) + + adaptationSet(spec, Collections.singletonList(spec.getVideoFormat()), + C.TRACK_TYPE_VIDEO, "0") + + audioAdaptationSets(spec) + ""; try { return new DashManifestParser().parse(Uri.parse("sabr://" + spec.getVideoId()), @@ -152,37 +166,80 @@ private static DashManifest buildManifest(final SabrSourceSpec spec, } } + private static String audioAdaptationSets(final SabrSourceSpec spec) { + final Map> tracks = new LinkedHashMap<>(); + for (final YoutubeSabrInfo.Format format : spec.getAudioFormats()) { + tracks.computeIfAbsent(java.util.Objects.toString(format.getAudioTrackId(), "default"), + ignored -> new ArrayList<>()).add(format); + } + final StringBuilder result = new StringBuilder(); + int index = 0; + for (final Map.Entry> track : tracks.entrySet()) { + result.append(adaptationSet(spec, track.getValue(), C.TRACK_TYPE_AUDIO, + String.valueOf(++index))); + } + return result.toString(); + } + private static String adaptationSet(final SabrSourceSpec spec, - final YoutubeSabrInfo.Format format, - final int trackType) { - final String mime = containerMimeType(format); - final String codecs = codecs(format); + final List formats, + final int trackType, + final String adaptationId) { + final YoutubeSabrInfo.Format first = formats.get(0); + final String mime = containerMimeType(first); final String contentType = trackType == C.TRACK_TYPE_AUDIO ? "audio" : "video"; final StringBuilder builder = new StringBuilder() - .append("") - .append("'); + if (label != null && !label.isEmpty()) { + builder.append(""); + } + if (first.isOriginalAudio()) { + builder.append(""); + } } else { - builder.append(" audioSamplingRate=\"48000\""); + builder.append('>'); + } + for (final YoutubeSabrInfo.Format format : formats) { + builder.append("sabrseg://").append(spec.getFormatKey(format)) + .append("/") + .append(segmentTemplate(format, spec.getTimeline(format))) + .append(""); } - builder.append(">") - .append("sabrseg://").append(format.getItag()).append("/") - .append(segmentTemplate(spec.getTimeline(format))) - .append(""); + builder.append(""); return builder.toString(); } - private static String segmentTemplate(final YoutubeSabrFormatTimeline timeline) { - final YoutubeSabrInfo.Format format = timeline.getFormat(); + @Nullable + private static String audioLanguage(final YoutubeSabrInfo.Format format) { + final String trackId = format.getAudioTrackId(); + if (trackId == null || trackId.isEmpty()) return null; + return trackId.split("[._-]", 2)[0]; + } + + private static String segmentTemplate(final YoutubeSabrInfo.Format format, + final YoutubeSabrFormatTimeline timeline) { final long endSegment = timeline.getEndSequence(); if (endSegment <= 0 || endSegment > 10_000) { throw new IllegalStateException("Invalid exact SABR segment count: itag=" @@ -252,7 +309,6 @@ private final class SabrDashMediaPeriod implements MediaPeriod { public void prepare(final Callback cb, final long positionUs) { this.callback = cb; this.preparedPositionUs = positionUs; - playbackState.setReaderOwner(this); child.prepare(new Callback() { @Override public void onPrepared(final MediaPeriod mediaPeriod) { @@ -287,7 +343,6 @@ public long selectTracks(final ExoTrackSelection[] selections, final SampleStream[] streams, final boolean[] streamResetFlags, final long positionUs) { - playbackState.setReaderOwner(this); final boolean hasActiveTracks = updateActiveTracks(selections); // Initial mid-starts near the next video boundary are cheaper if SABR starts on that // boundary; keep regular seeks on Media3's requested position/tolerance path. @@ -307,15 +362,18 @@ private boolean updateActiveTracks(final ExoTrackSelection[] selections) { continue; } final Format format = selection.getSelectedFormat(); - if (format != null && String.valueOf(spec.getVideoFormat().getItag()) + if (format != null && spec.getFormatKey(spec.getVideoFormat()) .equals(format.id)) { videoActive = true; - } else if (format != null && String.valueOf(spec.getAudioFormat().getItag()) - .equals(format.id)) { - audioActive = true; + } else if (format != null) { + for (final YoutubeSabrInfo.Format audio : spec.getAudioFormats()) { + if (spec.getFormatKey(audio).equals(format.id)) { + audioActive = true; + break; + } + } } } - sessionHandle.setActiveTracks(this, videoActive, audioActive); Log.d(TAG, "activeTracks video=" + spec.getVideoId() + " video=" + videoActive + " audio=" + audioActive); return videoActive || audioActive; @@ -335,7 +393,6 @@ private void applyInitialStartPosition(final long positionUs, final long normalizedTargetUs = normalizeSeekPositionUs(targetUs); Log.d(TAG, "initialStart video=" + spec.getVideoId() + " positionUs=" + normalizedTargetUs); - sessionHandle.requestSeek(normalizedTargetUs / 1000L); } private long validPositionUs(final long positionUs) { @@ -354,10 +411,7 @@ public long readDiscontinuity() { @Override public long seekToUs(final long positionUs) { - playbackState.setReaderOwner(this); - sessionHandle.advanceReaderGeneration(this); final long normalizedPositionUs = normalizeSeekPositionUs(positionUs); - sessionHandle.requestSeek(normalizedPositionUs / 1000L); return child.seekToUs(normalizedPositionUs); } @@ -436,24 +490,10 @@ public void reevaluateBuffer(final long positionUs) { } private void release() { - sessionHandle.releaseTracks(this); if (callback != null) { callback = null; } } } - private static final class PlaybackState { - @NonNull - private Object readerOwner = new Object(); - - synchronized void setReaderOwner(@NonNull final Object readerOwner) { - this.readerOwner = readerOwner; - } - - @NonNull - synchronized Object getReaderOwner() { - return readerOwner; - } - } } diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrLocalDomPoTokenUtil.kt b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrLocalDomPoTokenUtil.kt deleted file mode 100644 index 17a6cff50..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrLocalDomPoTokenUtil.kt +++ /dev/null @@ -1,87 +0,0 @@ -package org.schabi.newpipe.player.datasource - -import com.grack.nanojson.JsonObject -import com.grack.nanojson.JsonParser -import com.grack.nanojson.JsonWriter -import java.util.Base64 - -internal data class SabrAttChallengeData( - val program: String, - val globalName: String, - val interpreterJavascript: String?, - val interpreterUrl: String?, -) - -internal fun parseSabrAttChallengeData(rawAttestationData: String): SabrAttChallengeData { - val challenge = JsonParser.`object`().from(rawAttestationData).getObject("bgChallenge") - val interpreterJavascript = challenge.getObject("interpreterJavascript") - ?.getString("privateDoNotAccessOrElseSafeScriptWrappedValue") - ?.takeIf { it.isNotEmpty() } - val rawInterpreterUrl = challenge.getObject("interpreterUrl") - ?.getString("privateDoNotAccessOrElseTrustedResourceUrlWrappedValue") - ?.takeIf { it.isNotEmpty() } - val interpreterUrl = rawInterpreterUrl?.let { - if (it.startsWith("//")) "https:$it" else it - } - require(interpreterJavascript != null || interpreterUrl != null) { - "Attestation challenge has no interpreter script or URL" - } - return SabrAttChallengeData( - program = challenge.getString("program"), - globalName = challenge.getString("globalName"), - interpreterJavascript = interpreterJavascript, - interpreterUrl = interpreterUrl, - ) -} - -internal fun buildSabrAttChallengeData( - challengeData: SabrAttChallengeData, - interpreterJavascript: String, -): String { - return JsonWriter.string( - JsonObject.builder() - .`object`("interpreterJavascript") - .value( - "privateDoNotAccessOrElseSafeScriptWrappedValue", - interpreterJavascript, - ) - .end() - .value("program", challengeData.program) - .value("globalName", challengeData.globalName) - .done(), - ) -} - -internal fun parseSabrIntegrityTokenData(rawIntegrityTokenData: String): Pair { - val integrityTokenData = JsonParser.array().from(rawIntegrityTokenData) - return base64ToU8(integrityTokenData.getString(0)) to integrityTokenData.getLong(1) -} - -internal fun stringToSabrU8(value: String): String { - return newUint8Array(value.toByteArray()) -} - -internal fun csvU8ToByteArray(value: String): ByteArray { - if (value.isBlank()) { - return ByteArray(0) - } - return value.split(",").map { it.toUByte().toByte() }.toByteArray() -} - -private fun base64ToU8(base64: String): String { - return newUint8Array(base64ToByteArray(base64)) -} - -private fun newUint8Array(contents: ByteArray): String { - return "new Uint8Array([" + contents.joinToString(separator = ",") { - it.toUByte().toString() - } + "])" -} - -private fun base64ToByteArray(base64: String): ByteArray { - val normalized = base64 - .replace('-', '+') - .replace('_', '/') - .replace('.', '=') - return Base64.getDecoder().decode(normalized) -} diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaBridge.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaBridge.java index 2e217cb98..055d6f315 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaBridge.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaBridge.java @@ -4,306 +4,314 @@ import androidx.annotation.Nullable; import org.schabi.newpipe.extractor.exceptions.ExtractionException; -import org.schabi.newpipe.extractor.localization.Localization; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormatTimeline; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession; import org.schabi.newpipe.extractor.services.youtube.sabr.media.SabrMediaSegment; import java.io.IOException; +import java.io.InterruptedIOException; import java.util.ArrayDeque; import java.util.Deque; -import java.util.Map; import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; -/** Bridges Media3 segment demand to serialized SABR transactions. */ +/** Synchronously bridges one Media3 segment read to serialized SABR transactions. */ final class SabrMediaBridge { private static final int MAX_AHEAD_SEGMENTS = 64; - private final SabrSessionStore.Holder holder; + private static final long COOKIE_RECOVERY_AFTER_MS = 10_000; + private static final long EMPTY_RESPONSE_RETRY_MS = 250; + private final YoutubeSabrSession session; - private final Localization localization; - private final SabrBackoffState backoff; - private final LinkedBlockingQueue pending = new LinkedBlockingQueue<>(); - private final Map pendingKeys = new ConcurrentHashMap<>(); - private final Map failures = new ConcurrentHashMap<>(); - private final Map ahead = new ConcurrentHashMap<>(); - private final Map nextSequences = new ConcurrentHashMap<>(); - private final Deque aheadOrder = new ArrayDeque<>(); - private final Object available = new Object(); - private volatile IOException networkFailure; + private final SabrSourceSpec spec; + private final YoutubeSabrInfo.Format videoFormat; + private final YoutubeSabrFormatTimeline audioTimeline; + private final YoutubeSabrFormatTimeline videoTimeline; + private final Map ahead = new ConcurrentHashMap<>(); + private final Map nextSequences = + new ConcurrentHashMap<>(); + private final Map activeDemands = new ConcurrentHashMap<>(); + private final Deque aheadOrder = new ArrayDeque<>(); + private final Object requestLock = new Object(); + private volatile boolean stopped; - private volatile boolean started; - private volatile long mediaProgressVersion; - private Thread worker; + @Nullable private volatile Thread requestThread; - SabrMediaBridge(@NonNull final SabrSessionStore.Holder holder, - @NonNull final Localization localization, - @NonNull final SabrBackoffState backoff) { - this.holder = holder; - this.session = holder.session; - this.localization = localization; - this.backoff = backoff; + SabrMediaBridge(@NonNull final YoutubeSabrSession session, + @NonNull final SabrSourceSpec spec) { + this.session = session; + this.spec = spec; + videoFormat = spec.getVideoFormat(); + audioTimeline = spec.getAudioTimeline(); + videoTimeline = spec.getVideoTimeline(); } - void seedSegments(@NonNull final List segments) { - for (final SabrMediaSegment segment : segments) { - final String segmentKey = key(segment.getHeader().getItag(), - segment.getHeader().isInitSegment() - ? "init" : String.valueOf(segment.getHeader().getSequenceNumber())); - final SabrMediaSegment previous = ahead.putIfAbsent(segmentKey, segment); - if (previous != null) { - segment.delete(); - } else { - synchronized (available) { - aheadOrder.addLast(segmentKey); - } + @NonNull + byte[] fetchInitialization(@NonNull final YoutubeSabrInfo.Format format, + final long timeoutMs) + throws IOException, ExtractionException { + byte[] data = spec.getInitializationData(format); + if (data != null) return data; + final long deadlineNs = System.nanoTime() + + TimeUnit.MILLISECONDS.toNanos(Math.max(1, timeoutMs)); + synchronized (requestLock) { + requestThread = Thread.currentThread(); + try { + data = spec.getInitializationData(format); + if (data != null) return data; + awaitBackoffWithinBudget(SabrSegmentKey.initialization(format), deadlineNs); + if (stopped) throw new IOException("SABR bridge is stopped"); + final long remainingMs = Math.max(1, TimeUnit.NANOSECONDS.toMillis( + ensureBudget(SabrSegmentKey.initialization(format), deadlineNs))); + data = session.fetchInitializationData(format, remainingMs, + segment -> acceptSegment(segment, format.isAudio() ? format : null)); + ensureBudget(SabrSegmentKey.initialization(format), deadlineNs); + spec.putInitializationData(format, data); + return data; + } finally { + requestThread = null; } } - synchronized (available) { - trimAhead(); - available.notifyAll(); - } } - synchronized void ensureStarted() { - if (started || stopped) { - return; + void seedSegments(@NonNull final List segments) { + for (final SabrMediaSegment segment : segments) { + acceptSegment(segment, spec.getBootstrapAudioFormat()); } - started = true; - worker = new Thread(this::run, "SabrMediaBridge"); - worker.setDaemon(true); - worker.start(); } - void stop() { - stopped = true; - final Thread current = worker; - if (current != null) { - current.interrupt(); - } - for (final SabrMediaSegment segment : ahead.values()) { - segment.delete(); - } - ahead.clear(); - synchronized (available) { - aheadOrder.clear(); - available.notifyAll(); - } - } + @NonNull + SabrMediaSegment fetchSegment(@NonNull final SabrSegmentKey request, + final long timeoutMs) + throws IOException, ExtractionException { + final long deadlineNs = System.nanoTime() + + TimeUnit.MILLISECONDS.toNanos(Math.max(1, timeoutMs)); + retainDemand(request); + try { + SabrMediaSegment segment = ahead.get(request); + if (segment != null) return segment; + if (!request.isInitialization()) { + nextSequences.put(request.getFormat(), request.getSequenceNumber()); + } + synchronized (requestLock) { + requestThread = Thread.currentThread(); + try { + long recoveryAtNs = System.nanoTime() + + TimeUnit.MILLISECONDS.toNanos(COOKIE_RECOVERY_AFTER_MS); + while (!stopped) { + segment = ahead.get(request); + if (segment != null) return segment; + awaitBackoffWithinBudget(request, deadlineNs); + if (stopped) throw new IOException("SABR bridge is stopped"); - @Nullable - SabrMediaSegment getCached(@NonNull final SabrSegmentKey request) { - return ahead.get(key(request)); - } + final YoutubeSabrInfo.Format activeAudio = activeAudioFormat(request); + final boolean audioActive = activeAudio != null; + final boolean videoActive = hasActiveDemandFor(videoFormat); + final long playerTimeMs = request.isInitialization() ? 0 + : Math.max(0, timelineFor(request.getFormat()) + .getStartMs(request.getSequenceNumber())); + final YoutubeSabrSession.RequestResult result = session.requestOnce( + activeAudio == null ? spec.getBootstrapAudioFormat() : activeAudio, + videoFormat, + playerTimeMs, + audioTimeline, activeAudio == null ? 0 : bufferedThrough(activeAudio), + videoTimeline, bufferedThrough(videoFormat), + audioActive, videoActive, videoActive && !audioActive, + 1.0f, received -> acceptSegment(received, activeAudio)); + if (result.isDeferred()) continue; - @Nullable - SabrMediaSegment awaitReadableSegment(@NonNull final SabrSegmentKey request, - final long timeoutMs) throws InterruptedException { - SabrMediaSegment segment = getCached(request); - if (segment != null || timeoutMs <= 0) { - return segment; - } - synchronized (available) { - segment = getCached(request); - if (segment == null) { - available.wait(timeoutMs); - segment = getCached(request); + segment = ahead.get(request); + if (segment != null) return segment; + ensureBudget(request, deadlineNs); + if (System.nanoTime() >= recoveryAtNs) { + session.clearPlaybackCookie(); + recoveryAtNs = System.nanoTime() + + TimeUnit.MILLISECONDS.toNanos(COOKIE_RECOVERY_AFTER_MS); + } + if (result.getSegmentCount() == 0 + && session.getBackoffRemainingMs() == 0) { + sleepWithinBudget(request, deadlineNs, EMPTY_RESPONSE_RETRY_MS); + } + } + throw new IOException("SABR bridge is stopped"); + } finally { + requestThread = null; + } } + } finally { + releaseDemand(request); } - return segment; } void discard(@NonNull final SabrSegmentKey request) { - final String segmentKey = key(request); - final SabrMediaSegment segment = ahead.remove(segmentKey); - synchronized (available) { - aheadOrder.remove(segmentKey); - } - if (segment != null) { - segment.delete(); + final SabrMediaSegment segment = ahead.remove(request); + synchronized (aheadOrder) { + aheadOrder.remove(request); } + if (segment != null) segment.delete(); } - @Nullable - IOException takeNetworkFailure() { - final IOException failure = networkFailure; - networkFailure = null; - return failure; - } - - @Nullable - IOException takeDemandFailure(@NonNull final SabrSegmentKey request, - @NonNull final Object readerOwner, - final long readerGeneration) { - return failures.remove(key(request)); + void stop() { + stopped = true; + final Thread current = requestThread; + if (current != null) current.interrupt(); + for (final SabrMediaSegment segment : ahead.values()) segment.delete(); + ahead.clear(); + synchronized (aheadOrder) { + aheadOrder.clear(); + } } - boolean canRecover() { - return !stopped && networkFailure == null; + private void awaitBackoffWithinBudget(@NonNull final SabrSegmentKey request, + final long deadlineNs) throws IOException { + while (true) { + final long backoffMs = session.getBackoffRemainingMs(); + if (backoffMs <= 0) return; + final long remainingNs = ensureBudget(request, deadlineNs); + if (TimeUnit.MILLISECONDS.toNanos(backoffMs) >= remainingNs) { + throw timeout(request, "SABR backoff cannot fit within the fetch budget"); + } + sleep(backoffMs); + } } - String getStateName() { - return stopped ? "STOPPED" : (pending.isEmpty() ? "IDLE" : "REQUESTING"); + private void sleepWithinBudget(@NonNull final SabrSegmentKey request, + final long deadlineNs, + final long requestedMs) throws IOException { + final long remainingNs = ensureBudget(request, deadlineNs); + sleep(Math.min(requestedMs, Math.max(1, + TimeUnit.NANOSECONDS.toMillis(remainingNs)))); } - long getAheadBytes() { - long bytes = 0; - for (final SabrMediaSegment segment : ahead.values()) { - bytes += segment.getLength(); + private static void sleep(final long milliseconds) throws InterruptedIOException { + try { + Thread.sleep(milliseconds); + } catch (final InterruptedException error) { + Thread.currentThread().interrupt(); + final InterruptedIOException interrupted = + new InterruptedIOException("Interrupted during SABR fetch"); + interrupted.initCause(error); + throw interrupted; } - return bytes; } - long getMediaProgressVersion() { - return mediaProgressVersion; + private long ensureBudget(@NonNull final SabrSegmentKey request, + final long deadlineNs) throws SabrLogicException { + final long remainingNs = deadlineNs - System.nanoTime(); + if (remainingNs <= 0) throw timeout(request, "SABR fetch exceeded its budget"); + return remainingNs; } - void requestInitialization(@NonNull final org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo.Format format) { - requestSegmentDemand(SabrSegmentKey.initialization(format), this, 0); + @NonNull + private SabrLogicException timeout(@NonNull final SabrSegmentKey request, + @NonNull final String reason) { + return new SabrLogicException(reason + ": itag=" + request.getFormat().getItag() + + ", seq=" + request.getSequenceNumber() + ", trace=" + + session.getDiagnosticTrace()); } - void requestSegmentDemand(@NonNull final SabrSegmentKey request, - @NonNull final Object readerOwner, - final long readerGeneration) { - if (ahead.containsKey(key(request))) { + private void acceptSegment(@NonNull final SabrMediaSegment segment, + @Nullable final YoutubeSabrInfo.Format requestedAudio) { + if (stopped || segment.getHeader().isInitSegment()) { + segment.delete(); return; } - if (!request.isInitialization()) { - nextSequences.put(request.getFormat().getItag(), request.getSequenceNumber()); + final YoutubeSabrInfo.Format format = formatForSegment(segment, requestedAudio); + if (format == null) { + segment.delete(); + return; } - final String key = key(request); - if (pendingKeys.putIfAbsent(key, request) == null) { - pending.offer(request); - ensureStarted(); + final SabrSegmentKey key = SabrSegmentKey.media( + format, segment.getHeader().getSequenceNumber()); + final SabrMediaSegment previous = ahead.putIfAbsent(key, segment); + if (previous != null) { + if (previous != segment) segment.delete(); + return; + } + synchronized (aheadOrder) { + aheadOrder.addLast(key); + trimAhead(); } } - void clearSegmentDemand(@NonNull final SabrSegmentKey request, - @NonNull final Object readerOwner, - final long readerGeneration) { - final String key = key(request); - pendingKeys.remove(key); - failures.remove(key); - } - - void requestRefetchFrom(@NonNull final SabrSegmentKey request) { - nextSequences.put(request.getFormat().getItag(), request.getSequenceNumber()); - session.clearPlaybackCookie(); - requestSegmentDemand(request, this, 0); - } - - void requestForwardSeekTo(@NonNull final SabrSegmentKey request) { - nextSequences.put(request.getFormat().getItag(), request.getSequenceNumber()); - session.clearPlaybackCookie(); - requestSegmentDemand(request, this, 0); + private void trimAhead() { + int protectedKeysSeen = 0; + while (aheadOrder.size() > MAX_AHEAD_SEGMENTS + && protectedKeysSeen < aheadOrder.size()) { + final SabrSegmentKey oldest = aheadOrder.removeFirst(); + if (activeDemands.containsKey(oldest)) { + aheadOrder.addLast(oldest); + protectedKeysSeen++; + continue; + } + final SabrMediaSegment removed = ahead.remove(oldest); + if (removed != null) removed.delete(); + protectedKeysSeen = 0; + } } - void requestSeekTo(@NonNull final SabrSegmentKey request, - final boolean backward, - final long positionMs) { - nextSequences.put(request.getFormat().getItag(), request.getSequenceNumber()); - nextSequences.put(holder.audioFormat.getItag(), holder.audioTimeline.getSequenceAt(positionMs)); - nextSequences.put(holder.videoFormat.getItag(), holder.videoTimeline.getSequenceAt(positionMs)); - session.clearPlaybackCookie(); - requestSegmentDemand(request, this, 0); + private void retainDemand(@NonNull final SabrSegmentKey request) { + activeDemands.compute(request, (ignored, count) -> { + if (count == null) return new AtomicInteger(1); + count.incrementAndGet(); + return count; + }); } - void noteSeekWithinCache() { - // Media3 can continue reading the already published segment window. + private void releaseDemand(@NonNull final SabrSegmentKey request) { + activeDemands.computeIfPresent(request, + (ignored, count) -> count.decrementAndGet() <= 0 ? null : count); } - private void run() { - while (!stopped) { - try { - backoff.awaitReady(); - final SabrSegmentKey request = pending.take(); - final String requestKey = key(request); - if (!pendingKeys.containsKey(requestKey)) { - continue; - } - final YoutubeSabrSession.RequestResult requestResult = - session.requestOnce(localization, holder.getPlayerTimeMs(), - holder.audioTimeline, bufferedThrough(holder.audioFormat), - holder.videoTimeline, bufferedThrough(holder.videoFormat), - holder.isAudioActive(), holder.isVideoActive(), - holder.getPlayerTimeMs() > 1_000, - holder.getBandwidthEstimate(), holder.getPlaybackRate(), - holder.getPoToken(), segment -> { - final String segmentKey = key(segment.getHeader().getItag(), - segment.getHeader().isInitSegment() - ? "init" : String.valueOf(segment.getHeader().getSequenceNumber())); - final SabrMediaSegment previous = ahead.putIfAbsent(segmentKey, segment); - if (previous != null && previous != segment) { - segment.delete(); - } else if (previous == null) { - mediaProgressVersion++; - synchronized (available) { - aheadOrder.addLast(segmentKey); - trimAhead(); - } - } - synchronized (available) { - available.notifyAll(); - } - }); - // Backoff is returned as request data; the owning Holder publishes it to - // observers and gates the next request. - backoff.update(requestResult.getBackoffMs()); - holder.observeBandwidth(requestResult.getBandwidthSample()); - pendingKeys.remove(requestKey); - pending.removeIf(candidate -> ahead.containsKey(key(candidate))); - for (final SabrSegmentKey candidate : pending) { - if (ahead.containsKey(key(candidate))) { - pendingKeys.remove(key(candidate)); - } - } - } catch (final InterruptedException e) { - if (stopped) { - break; - } - Thread.currentThread().interrupt(); - break; - } catch (final IOException | ExtractionException e) { - final IOException failure = e instanceof IOException - ? (IOException) e : new IOException("SABR request failed", e); - networkFailure = failure; - for (final String key : pendingKeys.keySet()) { - failures.put(key, failure); - } - pending.clear(); - pendingKeys.clear(); - } + private boolean hasActiveDemandFor(@NonNull final YoutubeSabrInfo.Format format) { + for (final SabrSegmentKey demand : activeDemands.keySet()) { + if (demand.getFormat().getItag() == format.getItag()) return true; } + return false; } - private int bufferedThrough( - @NonNull final org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo.Format format) { - final Integer next = nextSequences.get(format.getItag()); - if (next != null) return Math.max(0, next - 1); - return Math.max(0, holder.getTimeline(format).getSequenceAt(holder.getPlayerTimeMs()) - 1); + @Nullable + private YoutubeSabrInfo.Format activeAudioFormat(@NonNull final SabrSegmentKey request) { + if (request.getFormat().isAudio()) return request.getFormat(); + for (final SabrSegmentKey demand : activeDemands.keySet()) { + if (demand.getFormat().isAudio()) return demand.getFormat(); + } + return null; } - - private static String key(@NonNull final SabrSegmentKey request) { - return key(request.getFormat().getItag(), request.isInitialization() - ? "init" : String.valueOf(request.getSequenceNumber())); + private int bufferedThrough(@NonNull final YoutubeSabrInfo.Format format) { + final Integer next = nextSequences.get(format); + return next == null ? 0 : Math.max(0, next - 1); } - private static String key(final int itag, @NonNull final String sequence) { - return itag + ":" + sequence; + @NonNull + private YoutubeSabrFormatTimeline timelineFor(@NonNull final YoutubeSabrInfo.Format format) { + return format.isAudio() ? audioTimeline : videoTimeline; } - private void trimAhead() { - while (aheadOrder.size() > MAX_AHEAD_SEGMENTS) { - final String oldest = aheadOrder.removeFirst(); - if (pendingKeys.containsKey(oldest)) { - aheadOrder.addLast(oldest); - break; - } - final SabrMediaSegment removed = ahead.remove(oldest); - if (removed != null) { - removed.delete(); + @Nullable + private YoutubeSabrInfo.Format formatForSegment( + @NonNull final SabrMediaSegment segment, + @Nullable final YoutubeSabrInfo.Format requestedAudio) { + final int itag = segment.getHeader().getItag(); + final String xtags = segment.getHeader().getXtags(); + if (videoFormat.getItag() == itag && (xtags == null + || Objects.equals(videoFormat.getXtags(), xtags))) return videoFormat; + if (requestedAudio != null && requestedAudio.getItag() == itag && (xtags == null + || Objects.equals(requestedAudio.getXtags(), xtags))) return requestedAudio; + YoutubeSabrInfo.Format onlyMatchingItag = null; + int matchingItags = 0; + for (final YoutubeSabrInfo.Format format : spec.getAudioFormats()) { + if (format.getItag() == itag && Objects.equals(format.getXtags(), xtags)) return format; + if (format.getItag() == itag) { + onlyMatchingItag = format; + matchingItags++; } } + return xtags == null && matchingItags == 1 ? onlyMatchingItag : null; } } diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrPlaybackDiagnostics.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrPlaybackDiagnostics.java deleted file mode 100644 index d658237bd..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrPlaybackDiagnostics.java +++ /dev/null @@ -1,73 +0,0 @@ -package org.schabi.newpipe.player.datasource; - -import android.content.Context; -import android.content.SharedPreferences; -import android.os.Debug; - -import androidx.annotation.NonNull; - -import java.util.Locale; - -public final class SabrPlaybackDiagnostics { - private static final String PREFS = "sabr_playback_diagnostics"; - private static final String KEY_LAST_SNAPSHOT = "last_snapshot"; - - private SabrPlaybackDiagnostics() { - } - - static void record(@NonNull final Context context, - @NonNull final SabrSessionStore.Holder holder, - @NonNull final String event) { - final Runtime runtime = Runtime.getRuntime(); - final long maxHeap = runtime.maxMemory(); - final long totalHeap = runtime.totalMemory(); - final long freeHeap = runtime.freeMemory(); - final long usedHeap = totalHeap - freeHeap; - final long pssKb = Debug.getPss(); - final String snapshot = String.format(Locale.US, - "event=%s\n" - + "timeMs=%d\n" - + "videoId=%s\n" - + "playerTimeMs=%d\n" - + "readerHeadMs=%d\n" - + "readerTailMs=%d\n" - + "videoItag=%d\n" - + "videoHeight=%d\n" - + "videoBitrate=%d\n" - + "audioItag=%d\n" - + "audioBitrate=%d\n" - + "heapUsedBytes=%d\n" - + "heapFreeBytes=%d\n" - + "heapTotalBytes=%d\n" - + "heapMaxBytes=%d\n" - + "pssKb=%d\n" - + "sabr=%s\n", - event, - System.currentTimeMillis(), - holder.videoId, - holder.getPlayerTimeMs(), - holder.getReaderHeadMs(), - holder.getReaderTailMs(), - holder.videoFormat.getItag(), - holder.videoFormat.getHeight(), - holder.videoFormat.getBitrate(), - holder.audioFormat.getItag(), - holder.audioFormat.getBitrate(), - usedHeap, - freeHeap, - totalHeap, - maxHeap, - pssKb, - holder.session.getMemoryDiagnosticSummary()); - preferences(context).edit().putString(KEY_LAST_SNAPSHOT, snapshot).apply(); - } - - @NonNull - public static String getLastSnapshot(@NonNull final Context context) { - return preferences(context).getString(KEY_LAST_SNAPSHOT, ""); - } - - private static SharedPreferences preferences(@NonNull final Context context) { - return context.getApplicationContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java index 8f52b0da7..5d5d78c12 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java @@ -1,552 +1,184 @@ package org.schabi.newpipe.player.datasource; -import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; import android.net.Uri; import android.util.Log; import androidx.annotation.Nullable; - import androidx.media3.common.C; import androidx.media3.datasource.DataSource; import androidx.media3.datasource.DataSpec; import androidx.media3.datasource.TransferListener; -import org.schabi.newpipe.extractor.localization.Localization; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; import org.schabi.newpipe.extractor.services.youtube.sabr.media.SabrMediaSegment; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; -import java.io.InterruptedIOException; +/** Serves Media3's exact format/sequence demand from SABR responses. */ public final class SabrSegmentDataSource implements DataSource { private static final String TAG = "SabrSegmentDataSource"; + private static final long FETCH_TIMEOUT_MS = 30_000; - private static final long WAIT_MS = 250; - private static final long RECOVERY_AFTER_NO_PROGRESS_MS = 10_000; - private static final long RECOVERY_RETRY_MS = 10_000; - private static final long RECOVERY_FAILURE_MS = 30_000; - private static final long FORWARD_SEEK_AHEAD_MS = 30_000; - - @Nullable - private SabrSessionStore.Holder holder; - @Nullable - private final SabrSessionHandle sessionHandle; - private final Object readerOwner; - @Nullable - private final YoutubeSabrInfo.Format fixedFormat; - private final Localization localization; - private final boolean prependInit; + private final SabrSourceSpec spec; + private final SabrMediaBridge bridge; - @Nullable - private Uri uri; - @Nullable - private byte[] data; - @Nullable - private InputStream dataStream; - @Nullable - private SabrMediaSegment progressiveSegment; - private long progressiveReaderGeneration = -1; - private int progressiveDataEndPosition = -1; - private long bytesRemaining; - private int pos; - private boolean opened; - private volatile boolean canceled; + @Nullable private Uri uri; + @Nullable private byte[] data; + @Nullable private InputStream dataStream; @Nullable private SabrSegmentKey openedRequest; + private long bytesRemaining; + private int position; - public SabrSegmentDataSource(final SabrSessionStore.Holder holder, - final Object readerOwner, - final YoutubeSabrInfo.Format format, - final Localization localization, - final boolean prependInit) { - this.holder = holder; - this.sessionHandle = null; - this.readerOwner = readerOwner; - this.fixedFormat = format; - this.localization = localization; - this.prependInit = prependInit; - } - - public SabrSegmentDataSource(final SabrSessionStore.Holder holder, - final Object readerOwner, - final Localization localization, - final boolean prependInit) { - this.holder = holder; - this.sessionHandle = null; - this.readerOwner = readerOwner; - this.fixedFormat = null; - this.localization = localization; - this.prependInit = prependInit; - } - - SabrSegmentDataSource(final SabrSessionHandle sessionHandle, - final Object readerOwner, - final Localization localization, - final boolean prependInit) { - this.holder = null; - this.sessionHandle = sessionHandle; - this.readerOwner = readerOwner; - this.fixedFormat = null; - this.localization = localization; - this.prependInit = prependInit; + SabrSegmentDataSource(final SabrSourceSpec spec, + final SabrMediaBridge bridge) { + this.spec = spec; + this.bridge = bridge; } @Override public void addTransferListener(final TransferListener transferListener) { + // Network transfer happens inside YoutubeSabrSession, not through this DataSource. } @Override public long open(final DataSpec dataSpec) throws IOException { - if (holder == null) { - if (sessionHandle == null) { - throw new IOException("SABR data source has no session handle"); - } - holder = sessionHandle.acquireHolder(); - } - this.uri = dataSpec.uri; - this.canceled = false; + uri = dataSpec.uri; closeDataStream(); - this.data = null; - this.progressiveSegment = null; - this.progressiveReaderGeneration = -1; - this.progressiveDataEndPosition = -1; - this.pos = (int) Math.max(0, dataSpec.position); - SabrSegmentKey request = requestFromUri(dataSpec.uri); + data = null; + position = (int) Math.max(0, dataSpec.position); + + final SabrSegmentKey request = requestFromUri(dataSpec.uri); openedRequest = request; - final YoutubeSabrInfo.Format format = request.getFormat(); - final long availableRemaining; - final int openedBytes; - Log.d(TAG, "open video=" + holder.videoId - + " itag=" + format.getItag() - + " uri=" + dataSpec.uri - + " prependInit=" + prependInit); - if (request.isInitializationSegment()) { - this.data = getInitializationData(format); - availableRemaining = Math.max(0, data.length - pos); - openedBytes = data.length; - } else if (prependInit) { - final byte[] init = getInitializationData(format); - final SabrMediaSegment segment = awaitSegment(request); - final byte[] media = segment == null ? new byte[0] : segment.getData(); - final byte[] both = new byte[init.length + media.length]; - System.arraycopy(init, 0, both, 0, init.length); - System.arraycopy(media, 0, both, init.length, media.length); - this.data = both; - if (progressiveSegment != null) { - progressiveDataEndPosition = both.length; - } - availableRemaining = Math.max(0, data.length - pos); - openedBytes = data.length; + final int totalBytes; + final long available; + if (request.isInitialization()) { + data = initializationData(request.getFormat()); + totalBytes = data.length; + available = Math.max(0, totalBytes - position); } else { SabrMediaSegment segment = awaitSegment(request); - if (segment != null) { - try { - this.dataStream = segment.openStream(); - } catch (final FileNotFoundException e) { - Log.w(TAG, "Spool file vanished before open; refetching video=" - + holder.videoId + " itag=" + format.getItag() - + " seq=" + request.getSequenceNumber()); - holder.getBridge(localization).discard(request); - progressiveSegment = null; - segment = awaitSegment(request); - if (segment != null) { - this.dataStream = segment.openStream(); - } - } - } - if (segment == null) { - this.data = new byte[0]; - availableRemaining = 0; - openedBytes = 0; - } else { - if (progressiveSegment != null) { - progressiveDataEndPosition = segment.getLength(); - } - final long skipped = skipFully(dataStream, Math.max(0, dataSpec.position)); - this.pos = (int) Math.min(Integer.MAX_VALUE, skipped); - availableRemaining = Math.max(0, segment.getLength() - skipped); - openedBytes = segment.getLength(); - } - } - this.opened = true; - this.bytesRemaining = dataSpec.length == C.LENGTH_UNSET - ? availableRemaining : Math.min(dataSpec.length, availableRemaining); - Log.d(TAG, "opened video=" + holder.videoId - + " itag=" + format.getItag() - + " bytes=" + openedBytes - + " remaining=" + availableRemaining); + try { + dataStream = segment.openStream(); + } catch (final FileNotFoundException error) { + bridge.discard(request); + segment = awaitSegment(request); + dataStream = segment.openStream(); + } + final long skipped = skipFully(dataStream, dataSpec.position); + position = (int) Math.min(Integer.MAX_VALUE, skipped); + totalBytes = segment.getLength(); + available = Math.max(0, totalBytes - skipped); + } + bytesRemaining = dataSpec.length == C.LENGTH_UNSET + ? available : Math.min(dataSpec.length, available); + Log.d(TAG, "open video=" + spec.getVideoId() + + " itag=" + request.getFormat().getItag() + + " seq=" + (request.isInitialization() ? "init" : request.getSequenceNumber()) + + " bytes=" + totalBytes); return bytesRemaining; } - private byte[] getInitializationData(final YoutubeSabrInfo.Format format) throws IOException { - final int itag = format.getItag(); - final byte[] cached = holder.getInitializationData(itag); - if (cached != null) { - return cached; - } - final SabrMediaSegment segment = - holder.getBridge(localization).getCached(SabrSegmentKey.initialization(format)); - if (segment != null) { - final byte[] data = segment.getData(); - holder.setInitializationData(itag, data); - return data; - } - final SabrMediaSegment loadedSegment = - awaitSegment(SabrSegmentKey.initialization(format)); - if (loadedSegment == null) { - return new byte[0]; + private byte[] initializationData(final YoutubeSabrInfo.Format format) throws IOException { + try { + return bridge.fetchInitialization(format, FETCH_TIMEOUT_MS); + } catch (final org.schabi.newpipe.extractor.exceptions.ExtractionException error) { + throw new IOException("SABR initialization extraction failed: itag=" + + format.getItag(), error); } - final byte[] loaded = loadedSegment.getData(); - holder.setInitializationData(itag, loaded); - holder.getBridge(localization).discard(SabrSegmentKey.initialization(format)); - return loaded; } @Override public int read(final byte[] target, final int offset, final int length) throws IOException { - if (length == 0) { - return 0; - } - if (bytesRemaining <= 0) { - return C.RESULT_END_OF_INPUT; - } + if (length == 0) return 0; + if (bytesRemaining <= 0) return C.RESULT_END_OF_INPUT; if (data != null) { - if (pos >= data.length) { - return C.RESULT_END_OF_INPUT; - } - final int toCopy = (int) Math.min(Math.min(length, data.length - pos), bytesRemaining); - System.arraycopy(data, pos, target, offset, toCopy); - pos += toCopy; - bytesRemaining -= toCopy; - maybeAdvanceProgressiveReader(); - return toCopy; - } - if (dataStream == null) { - return C.RESULT_END_OF_INPUT; - } - final int toRead = (int) Math.min(length, bytesRemaining); - final int read = dataStream.read(target, offset, toRead); - if (read < 0) { + if (position >= data.length) return C.RESULT_END_OF_INPUT; + final int count = (int) Math.min(Math.min(length, data.length - position), + bytesRemaining); + System.arraycopy(data, position, target, offset, count); + position += count; + bytesRemaining -= count; + return count; + } + if (dataStream == null) return C.RESULT_END_OF_INPUT; + final int count = dataStream.read(target, offset, (int) Math.min(length, bytesRemaining)); + if (count < 0) { bytesRemaining = 0; return C.RESULT_END_OF_INPUT; } - pos = (int) Math.min(Integer.MAX_VALUE, (long) pos + read); - bytesRemaining -= read; - maybeAdvanceProgressiveReader(); - return read; - } - - private void maybeAdvanceProgressiveReader() { - final SabrMediaSegment segment = progressiveSegment; - if (segment == null || progressiveDataEndPosition < 0 - || pos < progressiveDataEndPosition || !segment.isComplete() || holder == null) { - return; - } - final YoutubeSabrInfo.Format format = segment.getHeader().getItag() - == holder.videoFormat.getItag() ? holder.videoFormat : holder.audioFormat; - holder.setReaderPositionMs(readerOwner, progressiveReaderGeneration, format.getItag(), - segment.getHeader().getStartMs() + segment.getHeader().getDurationMs()); - progressiveSegment = null; - progressiveReaderGeneration = -1; - progressiveDataEndPosition = -1; - } - - private SabrSegmentKey requestFromUri(final Uri u) throws IOException { - final YoutubeSabrInfo.Format format = formatFromUri(u); - final String seg = u.getLastPathSegment(); - if (seg == null) { - throw new SabrLogicException("Bad SABR segment uri: " + u); - } - if ("init".equals(seg)) { - return SabrSegmentKey.initialization(format); - } - try { - return SabrSegmentKey.media(format, Integer.parseInt(seg)); - } catch (final NumberFormatException e) { - throw new SabrLogicException("Bad SABR segment uri: " + u, e); - } + position += count; + bytesRemaining -= count; + return count; } - private YoutubeSabrInfo.Format formatFromUri(final Uri u) throws IOException { - if (fixedFormat != null) { - return fixedFormat; - } - final String host = u.getHost(); - if (host == null) { - throw new SabrLogicException("Bad SABR segment uri without itag: " + u); + private SabrSegmentKey requestFromUri(final Uri value) throws IOException { + final String host = value.getHost(); + final String segment = value.getLastPathSegment(); + if (host == null || segment == null) { + throw new SabrLogicException("Bad SABR segment URI: " + value); } - final int itag; + final YoutubeSabrInfo.Format format = spec.getFormat(host); + if (format == null) throw new SabrLogicException("Unknown SABR format=" + host); + if ("init".equals(segment)) return SabrSegmentKey.initialization(format); try { - itag = Integer.parseInt(host); - } catch (final NumberFormatException e) { - throw new SabrLogicException("Bad SABR segment itag in uri: " + u, e); - } - if (holder.videoFormat.getItag() == itag) { - return holder.videoFormat; - } - if (holder.audioFormat.getItag() == itag) { - return holder.audioFormat; + return SabrSegmentKey.media(format, Integer.parseInt(segment)); + } catch (final NumberFormatException error) { + throw new SabrLogicException("Bad SABR sequence in URI: " + value, error); } - throw new SabrLogicException("Unknown SABR segment itag=" + itag + " uri=" + u); } - @Nullable private SabrMediaSegment awaitSegment(final SabrSegmentKey request) throws IOException { - final YoutubeSabrInfo.Format format = request.getFormat(); - holder.throwIfTerminal(); - if (holder.isInvalidated()) { - throw invalidatedException(request.getFormat()); + if (request.getSequenceNumber() + > spec.getTimeline(request.getFormat()).getEndSequence()) { + throw new SabrLogicException("SABR segment is beyond the timeline: itag=" + + request.getFormat().getItag() + ", seq=" + request.getSequenceNumber()); } - final SabrMediaBridge bridge = holder.getBridge(localization); - long readerGeneration = holder.getReaderGeneration(readerOwner); - final long waitStart = System.currentTimeMillis(); - long noProgressSinceMs = waitStart; - long mediaProgressVersion = bridge.getMediaProgressVersion(); - long recoveryAtMs = -1; - long lastRecoveryAtMs = -1; - boolean loggedWait = false; try { - while (true) { - if (canceled) { - throw new IOException("SABR segment read canceled"); - } - if (!request.isInitializationSegment()) { - final long currentReaderGeneration = holder.getReaderGeneration(readerOwner); - if (readerGeneration < 0 && currentReaderGeneration >= 0) { - readerGeneration = currentReaderGeneration; - noProgressSinceMs = System.currentTimeMillis(); - mediaProgressVersion = bridge.getMediaProgressVersion(); - } else if (readerGeneration >= 0 - && currentReaderGeneration != readerGeneration) { - throw new InterruptedIOException("SABR reader demand superseded for itag=" - + format.getItag() + ", seq=" + request.getSequenceNumber()); - } - } - holder.throwIfTerminal(); - if (holder.isInvalidated()) { - throw invalidatedException(request.getFormat()); - } - if (!request.isInitializationSegment() && holder.isBeyondEnd(request)) { - Log.d(TAG, "beyond end video=" + holder.videoId - + " itag=" + format.getItag() - + " seq=" + request.getSequenceNumber()); - holder.session.addDiagnosticEvent("beyond_end itag=" + format.getItag() - + " seq=" + request.getSequenceNumber()); - return null; - } - final IOException demandFailure = !request.isInitializationSegment() - && readerGeneration >= 0 - ? bridge.takeDemandFailure(request, readerOwner, readerGeneration) : null; - if (demandFailure != null) { - throw demandFailure; - } - final IOException networkFailure = bridge.takeNetworkFailure(); - if (networkFailure != null) { - throw networkFailure; - } - if (request.isInitializationSegment()) { - bridge.requestInitialization(format); - } else { - bridge.ensureStarted(); - } - final SabrMediaSegment segment; - if (request.isInitializationSegment()) { - segment = bridge.getCached(request); - } else { - try { - segment = bridge.awaitReadableSegment(request, WAIT_MS); - } catch (final InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IOException("Interrupted waiting for SABR segment", e); - } - } - if (segment != null) { - Log.d(TAG, "cache hit video=" + holder.videoId - + " itag=" + format.getItag() - + " init=" + request.isInitializationSegment() - + " seq=" + request.getSequenceNumber() - + " bytes=" + segment.getLength() - + " disk=" + segment.isDiskBacked()); - if (!segment.getHeader().isInitSegment()) { - if (segment.isComplete()) { - holder.setReaderPositionMs(readerOwner, readerGeneration, format.getItag(), - segment.getHeader().getStartMs() - + segment.getHeader().getDurationMs()); - } else { - progressiveSegment = segment; - progressiveReaderGeneration = readerGeneration; - } - } - return segment; - } - if (!request.isInitializationSegment() && holder.isBeyondEnd(request)) { - Log.d(TAG, "beyond end video=" + holder.videoId - + " itag=" + format.getItag() - + " seq=" + request.getSequenceNumber()); - holder.session.addDiagnosticEvent("beyond_end itag=" + format.getItag() - + " seq=" + request.getSequenceNumber()); - return null; - } - if (!request.isInitializationSegment() && readerGeneration >= 0) { - bridge.requestSegmentDemand(request, readerOwner, readerGeneration); - } - if (!loggedWait && System.currentTimeMillis() - waitStart > 1000) { - loggedWait = true; - holder.session.addDiagnosticEvent("wait itag=" + format.getItag() - + " init=" + request.isInitializationSegment() - + " seq=" + request.getSequenceNumber() - + " bridge=" + bridge.getStateName() - + " edgeMs=" + holder.getReaderHeadMs() - + " readerHeadMs=" + holder.getReaderHeadMs() - + " readerTailMs=" + holder.getReaderTailMs() - + " aheadBytes=" + bridge.getAheadBytes()); - Log.d(TAG, "waiting video=" + holder.videoId - + " itag=" + format.getItag() - + " init=" + request.isInitializationSegment() - + " seq=" + request.getSequenceNumber() - + " edgeMs=" + holder.getReaderHeadMs() - + " readerHeadMs=" + holder.getReaderHeadMs()); - } - final long now = System.currentTimeMillis(); - final long currentMediaProgressVersion = bridge.getMediaProgressVersion(); - if (currentMediaProgressVersion != mediaProgressVersion) { - mediaProgressVersion = currentMediaProgressVersion; - noProgressSinceMs = now; - recoveryAtMs = -1; - lastRecoveryAtMs = -1; - } - if (holder.getBackoffRemainingMs() > 0) { - // Server-directed pacing is not a playback stall. Keep polling so cancellation and - // reader replacement remain responsive, but do not let the local recovery watchdog - // reposition the session and attempt another request before the server deadline. - noProgressSinceMs = now; - recoveryAtMs = -1; - lastRecoveryAtMs = -1; - } - if (now - noProgressSinceMs > RECOVERY_AFTER_NO_PROGRESS_MS - && (lastRecoveryAtMs < 0 - || now - lastRecoveryAtMs > RECOVERY_RETRY_MS) - && bridge.canRecover() - && (request.isInitializationSegment() || readerGeneration >= 0)) { - String recovery; - if (request.isInitializationSegment()) { - recovery = "init"; - bridge.requestInitialization(format); - } else { - final long edgeMs = holder.getReaderHeadMs(); - final long segStartMs = holder.getTimeline(format) - .getStartMs(request.getSequenceNumber()); - if (segStartMs < edgeMs) { - recovery = "rewind"; - holder.setReaderPositionMs(readerOwner, readerGeneration, format.getItag(), - segStartMs); - bridge.requestRefetchFrom(request); - } else if (segStartMs > edgeMs + FORWARD_SEEK_AHEAD_MS) { - recovery = "forward"; - holder.setReaderPositionMs(readerOwner, readerGeneration, format.getItag(), - segStartMs); - bridge.requestForwardSeekTo(request); - } else { - recovery = "near_edge_refetch"; - holder.setReaderPositionMs(readerOwner, readerGeneration, - format.getItag(), segStartMs); - bridge.requestRefetchFrom(request); - } - } - holder.session.addDiagnosticEvent("recovery type=" + recovery - + " itag=" + format.getItag() - + " init=" + request.isInitializationSegment() - + " seq=" + request.getSequenceNumber() - + " bridge=" + bridge.getStateName() - + " edgeMs=" + holder.getReaderHeadMs()); - if (recoveryAtMs < 0) { - recoveryAtMs = now; - } - lastRecoveryAtMs = now; - } - if (recoveryAtMs >= 0 && now - recoveryAtMs > RECOVERY_FAILURE_MS - && bridge.canRecover()) { - final SabrLogicException failure = new SabrLogicException( - "SABR made no progress after recovery for itag=" + format.getItag() - + ", init=" + request.isInitializationSegment() - + ", seq=" + request.getSequenceNumber() - + ", waitMs=" + (now - waitStart) - + ", bridge=" + bridge.getStateName() - + ", edgeMs=" - + holder.getReaderHeadMs() - + ", readerHeadMs=" + holder.getReaderHeadMs() - + ", readerTailMs=" + holder.getReaderTailMs() - + ", aheadBytes=" + bridge.getAheadBytes() - + ", trace=" + holder.session.getDiagnosticTrace()); - holder.failTerminal(failure); - throw failure; - } - if (request.isInitializationSegment()) { - try { - Thread.sleep(WAIT_MS); - } catch (final InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IOException("Interrupted awaiting SABR initialization", e); - } - } - } - } finally { - if (!request.isInitializationSegment()) { - bridge.clearSegmentDemand(request, readerOwner, readerGeneration); - } + return bridge.fetchSegment(request, FETCH_TIMEOUT_MS); + } catch (final org.schabi.newpipe.extractor.exceptions.ExtractionException error) { + throw new IOException("SABR segment extraction failed", error); } } - private SabrLogicException invalidatedException(final YoutubeSabrInfo.Format format) { - return new SabrLogicException("SABR session invalidated for video=" + holder.videoId - + ", itag=" + format.getItag() + ", " + holder.getInvalidationDetails()); - } - private static long skipFully(final InputStream input, final long requested) throws IOException { - long remaining = requested; + long remaining = Math.max(0, requested); final byte[] buffer = new byte[8192]; while (remaining > 0) { final long skipped = input.skip(remaining); if (skipped > 0) { remaining -= skipped; - continue; - } - final int read = input.read(buffer, 0, (int) Math.min(buffer.length, remaining)); - if (read < 0) { - break; + } else { + final int read = input.read(buffer, 0, (int) Math.min(buffer.length, remaining)); + if (read < 0) break; + remaining -= read; } - remaining -= read; } return requested - remaining; } private void closeDataStream() throws IOException { - if (dataStream != null) { - dataStream.close(); - dataStream = null; - } + if (dataStream != null) dataStream.close(); + dataStream = null; } @Nullable @Override - public Uri getUri() { - return uri; - } + public Uri getUri() { return uri; } @Override public void close() { - canceled = true; data = null; try { closeDataStream(); - } catch (final IOException e) { - Log.w(TAG, "Could not close SABR segment stream", e); + } catch (final IOException error) { + Log.w(TAG, "Could not close SABR segment stream", error); } final SabrSegmentKey request = openedRequest; openedRequest = null; - if (request != null && !request.isInitializationSegment() && holder != null) { - holder.getBridge(localization).discard(request); + if (request != null && !request.isInitialization()) { + bridge.discard(request); } - opened = false; } } diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentKey.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentKey.java index 6f1d4bfcd..2dfe0ffaa 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentKey.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentKey.java @@ -4,6 +4,8 @@ import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; +import java.util.Objects; + /** Identifies one initialization or media segment requested by Media3. */ final class SabrSegmentKey { @NonNull private final YoutubeSabrInfo.Format format; @@ -32,6 +34,23 @@ static SabrSegmentKey media(@NonNull final YoutubeSabrInfo.Format format, @NonNull YoutubeSabrInfo.Format getFormat() { return format; } boolean isInitialization() { return initialization; } - boolean isInitializationSegment() { return initialization; } int getSequenceNumber() { return sequenceNumber; } + + @Override + public boolean equals(final Object other) { + if (this == other) return true; + if (!(other instanceof SabrSegmentKey)) return false; + final SabrSegmentKey that = (SabrSegmentKey) other; + return format.getItag() == that.format.getItag() + && format.getLastModified() == that.format.getLastModified() + && Objects.equals(format.getXtags(), that.format.getXtags()) + && initialization == that.initialization + && sequenceNumber == that.sequenceNumber; + } + + @Override + public int hashCode() { + return Objects.hash(format.getItag(), format.getLastModified(), format.getXtags(), + initialization, sequenceNumber); + } } diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionHandle.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionHandle.java deleted file mode 100644 index 1060de7eb..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionHandle.java +++ /dev/null @@ -1,223 +0,0 @@ -package org.schabi.newpipe.player.datasource; - -import android.content.Context; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.io.IOException; -import java.util.IdentityHashMap; -import java.util.Map; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.FutureTask; - -/** Coordinates one lazy SABR session lease across overlapping MediaPeriods and loader threads. */ -final class SabrSessionHandle { - @NonNull private final Context appContext; - @NonNull private final SabrSourceSpec spec; - private final Map trackModes = new IdentityHashMap<>(); - - @Nullable private SabrSessionStore.Lease lease; - @Nullable private FutureTask acquisition; - private int activePeriods; - private long periodGeneration; - private long playerTimeMs; - private long pendingSeekMs = -1; - - SabrSessionHandle(@NonNull final Context context, @NonNull final SabrSourceSpec spec) { - this.appContext = context.getApplicationContext(); - this.spec = spec; - } - - synchronized void onPeriodCreated(final long startPositionMs) { - if (activePeriods == 0) { - periodGeneration++; - } - activePeriods++; - if (startPositionMs > 0) { - playerTimeMs = startPositionMs; - pendingSeekMs = startPositionMs; - } - } - - void onPeriodReleased() { - final SabrSessionStore.Lease leaseToClose; - synchronized (this) { - if (activePeriods > 0) { - activePeriods--; - } - if (activePeriods != 0) { - return; - } - periodGeneration++; - trackModes.clear(); - pendingSeekMs = -1; - acquisition = null; - leaseToClose = lease; - lease = null; - } - if (leaseToClose != null) { - leaseToClose.close(); - } - } - - @NonNull - SabrSessionStore.Holder acquireHolder() throws IOException { - final FutureTask future; - final long generation; - final boolean create; - synchronized (this) { - if (lease != null) { - return lease.getHolder(); - } - if (activePeriods <= 0) { - throw new IOException("SABR period is no longer active for " + spec.getVideoId()); - } - generation = periodGeneration; - if (acquisition == null) { - acquisition = new FutureTask<>( - () -> SabrSessionStore.acquire(appContext, spec)); - create = true; - } else { - create = false; - } - future = acquisition; - } - - if (create) { - future.run(); - } - - final SabrSessionStore.Lease acquired = await(future); - synchronized (this) { - if (activePeriods <= 0 || generation != periodGeneration) { - if (lease != acquired) { - acquired.close(); - } - throw new IOException("SABR period was released while acquiring " - + spec.getVideoId()); - } - if (lease != null) { - if (lease != acquired) { - acquired.close(); - } - return lease.getHolder(); - } - lease = acquired; - if (acquisition == future) { - acquisition = null; - } - applyPendingState(acquired.getHolder()); - return acquired.getHolder(); - } - } - - private SabrSessionStore.Lease await( - @NonNull final FutureTask future) throws IOException { - try { - return future.get(); - } catch (final InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IOException("Interrupted acquiring SABR session for " + spec.getVideoId(), e); - } catch (final ExecutionException e) { - synchronized (this) { - if (acquisition == future) { - acquisition = null; - } - } - final Throwable cause = e.getCause(); - if (cause instanceof IOException) { - throw (IOException) cause; - } - throw new IOException("Could not acquire SABR session for " + spec.getVideoId(), cause); - } - } - - private void applyPendingState(@NonNull final SabrSessionStore.Holder holder) { - holder.setPlayerTimeMs(playerTimeMs); - for (final Map.Entry entry : trackModes.entrySet()) { - final int mode = entry.getValue(); - holder.setActiveTracks(entry.getKey(), (mode & 1) != 0, (mode & 2) != 0); - } - if (pendingSeekMs >= 0) { - holder.requestSeek(pendingSeekMs, spec.getLocalization()); - } - } - - void setActiveTracks(@NonNull final Object owner, - final boolean videoActive, - final boolean audioActive) { - final SabrSessionStore.Holder holder; - synchronized (this) { - final int mode = (videoActive ? 1 : 0) | (audioActive ? 2 : 0); - if (mode == 0) { - trackModes.remove(owner); - } else { - trackModes.put(owner, mode); - } - holder = lease == null ? null : lease.getHolder(); - } - if (holder != null) { - holder.setActiveTracks(owner, videoActive, audioActive); - } - } - - void releaseTracks(@NonNull final Object owner) { - final SabrSessionStore.Holder holder; - synchronized (this) { - trackModes.remove(owner); - holder = lease == null ? null : lease.getHolder(); - } - if (holder != null) { - holder.releaseTracks(owner); - } - } - - void advanceReaderGeneration(@NonNull final Object owner) { - final SabrSessionStore.Holder holder = getHolder(); - if (holder != null) { - holder.advanceReaderGeneration(owner); - } - } - - void requestSeek(final long positionMs) { - final SabrSessionStore.Holder holder; - synchronized (this) { - playerTimeMs = Math.max(0, positionMs); - pendingSeekMs = playerTimeMs; - holder = lease == null ? null : lease.getHolder(); - } - if (holder != null) { - holder.requestSeek(playerTimeMs, spec.getLocalization()); - } - } - - synchronized void setPlayerTimeMs(final long positionMs) { - playerTimeMs = Math.max(0, positionMs); - if (lease != null) { - lease.getHolder().setPlayerTimeMs(playerTimeMs); - } - } - - @Nullable - synchronized SabrSessionStore.Holder getHolder() { - return lease == null ? null : lease.getHolder(); - } - - void close() { - final SabrSessionStore.Lease leaseToClose; - synchronized (this) { - activePeriods = 0; - periodGeneration++; - trackModes.clear(); - pendingSeekMs = -1; - acquisition = null; - leaseToClose = lease; - lease = null; - } - if (leaseToClose != null) { - leaseToClose.close(); - } - spec.discardPreparedSession(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java index 2b29ef031..4ed0b4ca0 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java @@ -1,135 +1,80 @@ package org.schabi.newpipe.player.datasource; import android.content.Context; -import android.content.SharedPreferences; -import android.os.SystemClock; -import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.preference.PreferenceManager; import org.schabi.newpipe.App; -import org.schabi.newpipe.R; -import org.schabi.newpipe.player.PlaybackStartupTrace; -import org.schabi.newpipe.player.SabrBackoffCoordinator; import org.schabi.newpipe.extractor.exceptions.ExtractionException; -import org.schabi.newpipe.extractor.localization.Localization; -import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormatTimeline; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession; import org.schabi.newpipe.extractor.services.youtube.sabr.media.SabrMediaSegment; import org.schabi.newpipe.extractor.stream.DeliveryMethod; +import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.VideoStream; +import org.schabi.newpipe.player.PlaybackStartupTrace; +import org.schabi.newpipe.util.ListHelper; +import org.schabi.newpipe.youtube.LocalDomPoTokenProvider; import java.io.File; import java.io.IOException; -import java.util.ArrayList; import java.util.Collections; -import java.util.IdentityHashMap; import java.util.LinkedHashMap; import java.util.List; +import java.util.ArrayList; +import java.util.Comparator; import java.util.Map; import java.util.Objects; -import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.FutureTask; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; +/** Prepares SABR source data and retains a small LRU of Extractor protocol sessions. */ public final class SabrSessionStore { - - private static final String TAG = "SabrSessionStore"; - - private static final Map SESSIONS = new ConcurrentHashMap<>(); - private static final Map PREFERRED_AUDIO = new ConcurrentHashMap<>(); - // Active MediaPeriods own leases. MediaSources outside the playback window are lightweight and - // therefore do not prevent old sessions from being trimmed. - // Mutated only under the class lock. - private static final int MAX_SESSIONS = 3; private static final int MAX_BOOTSTRAP_CACHE_ENTRIES = 32; - private static final java.util.Deque ORDER = new java.util.ArrayDeque<>(); + private static final int MAX_SESSIONS = 8; private static final ExecutorService BOOTSTRAP_EXECUTOR = Executors.newFixedThreadPool(2, - runnable -> { - final Thread thread = new Thread(runnable, "SabrNativeBootstrap"); - thread.setDaemon(true); - return thread; - }); + runnable -> daemonThread(runnable, "SabrNativeBootstrap")); private static final ExecutorService TOKEN_EXECUTOR = Executors.newSingleThreadExecutor( - runnable -> { - final Thread thread = new Thread(runnable, "SabrTokenPrewarm"); - thread.setDaemon(true); - return thread; - }); + runnable -> daemonThread(runnable, "SabrTokenPrewarm")); private static final Map> BOOTSTRAP_IN_FLIGHT = new ConcurrentHashMap<>(); - private static final Map BOOTSTRAP_BACKOFFS = - new ConcurrentHashMap<>(); + private static final Map> TOKEN_IN_FLIGHT = new ConcurrentHashMap<>(); private static final Map BOOTSTRAP_CACHE = Collections.synchronizedMap(new LinkedHashMap( MAX_BOOTSTRAP_CACHE_ENTRIES + 1, 0.75f, true) { @Override protected boolean removeEldestEntry( final Map.Entry eldest) { - if (size() > MAX_BOOTSTRAP_CACHE_ENTRIES) { - eldest.getValue().discardPreparedSession(); - return true; - } - return false; + if (size() <= MAX_BOOTSTRAP_CACHE_ENTRIES) return false; + eldest.getValue().discardMediaSegments(); + return true; } }); - private static final Map> TOKEN_IN_FLIGHT = - new ConcurrentHashMap<>(); + private static final Map SESSIONS = + new LinkedHashMap(MAX_SESSIONS + 1, 0.75f, true) { + @Override + protected boolean removeEldestEntry( + final Map.Entry eldest) { + return size() > MAX_SESSIONS; + } + }; private static volatile LocalDomPoTokenProvider sharedProvider; private SabrSessionStore() { } - private static final class SessionKey { - @NonNull private final String videoId; - private final long sourceId; - private final int videoItag; - private final int audioItag; - @NonNull private final String audioTrackId; - - SessionKey(final long sourceId, - @NonNull final String videoId, - @NonNull final YoutubeSabrInfo info, - @NonNull final YoutubeSabrInfo.Format audioFormat, - @NonNull final YoutubeSabrInfo.Format videoFormat) { - this.videoId = videoId; - this.sourceId = sourceId; - this.videoItag = videoFormat.getItag(); - this.audioItag = audioFormat.getItag(); - this.audioTrackId = Objects.toString(audioFormat.getAudioTrackId(), ""); - } - - @Override - public boolean equals(final Object other) { - if (this == other) { - return true; - } - if (!(other instanceof SessionKey)) { - return false; - } - final SessionKey that = (SessionKey) other; - return sourceId == that.sourceId - && videoItag == that.videoItag - && audioItag == that.audioItag - && videoId.equals(that.videoId) - && audioTrackId.equals(that.audioTrackId); - } - - @Override - public int hashCode() { - return Objects.hash(sourceId, videoId, videoItag, audioItag, audioTrackId); - } + private static Thread daemonThread(final Runnable runnable, final String name) { + final Thread thread = new Thread(runnable, name); + thread.setDaemon(true); + return thread; } private static final class BootstrapResult { @@ -137,675 +82,159 @@ private static final class BootstrapResult { @NonNull private final byte[] videoInitialization; @NonNull private final YoutubeSabrFormatTimeline audioTimeline; @NonNull private final YoutubeSabrFormatTimeline videoTimeline; - @NonNull private final AtomicReference preparedSession; @NonNull private final AtomicReference> mediaSegments; - BootstrapResult(@NonNull final byte[] audioInitialization, - @NonNull final byte[] videoInitialization, - @NonNull final YoutubeSabrFormatTimeline audioTimeline, - @NonNull final YoutubeSabrFormatTimeline videoTimeline, - @Nullable final YoutubeSabrSession preparedSession, - @NonNull final List mediaSegments) { - this.audioInitialization = audioInitialization.clone(); - this.videoInitialization = videoInitialization.clone(); - this.audioTimeline = audioTimeline; - this.videoTimeline = videoTimeline; - this.preparedSession = new AtomicReference<>(preparedSession); - this.mediaSegments = new AtomicReference<>(mediaSegments); + BootstrapResult(@NonNull final YoutubeSabrSession.InitializationResult initialization) { + audioInitialization = Objects.requireNonNull(initialization.getAudioData()); + videoInitialization = Objects.requireNonNull(initialization.getVideoData()); + audioTimeline = Objects.requireNonNull(initialization.getAudioTimeline()); + videoTimeline = Objects.requireNonNull(initialization.getVideoTimeline()); + mediaSegments = new AtomicReference<>(initialization.getMediaSegments()); } - @Nullable - YoutubeSabrSession takePreparedSession() { - return preparedSession.getAndSet(null); + @NonNull List takeMediaSegments() { + return mediaSegments.getAndSet(Collections.emptyList()); } - @NonNull - List getMediaSegments() { - final List value = mediaSegments.getAndSet(Collections.emptyList()); - return value; - } - - void discardPreparedSession() { - preparedSession.set(null); - } - } - - private static final class BootstrapBackoffState { - @NonNull private final Context appContext; - @NonNull private final String videoId; - private long deadlineElapsedMs = SabrBackoffCoordinator.NO_DEADLINE; - private int waiters; - - BootstrapBackoffState(@NonNull final Context context, - @NonNull final String videoId) { - this.appContext = context.getApplicationContext(); - this.videoId = videoId; - } - - public synchronized void onBackoffStarted(final int durationMs) { - deadlineElapsedMs = SystemClock.elapsedRealtime() + durationMs; - Log.i(TAG, "bootstrap_backoff_start video=" + videoId - + " durationMs=" + durationMs + " waiters=" + waiters); - if (waiters > 0) { - SabrBackoffCoordinator.getInstance().beginPlaybackWait( - appContext, this, deadlineElapsedMs); - } - } - - public synchronized void onBackoffFinished() { - Log.i(TAG, "bootstrap_backoff_finish video=" + videoId - + " waiters=" + waiters); - deadlineElapsedMs = SabrBackoffCoordinator.NO_DEADLINE; - SabrBackoffCoordinator.getInstance().clear(appContext, this); - } - - synchronized void beginWaiting() { - waiters++; - if (deadlineElapsedMs > SystemClock.elapsedRealtime()) { - SabrBackoffCoordinator.getInstance().beginPlaybackWait( - appContext, this, deadlineElapsedMs); - } - } - - synchronized void endWaiting() { - waiters = Math.max(0, waiters - 1); - if (waiters == 0) { - SabrBackoffCoordinator.getInstance().clear(appContext, this); - } - } - - synchronized void cancel() { - waiters = 0; - deadlineElapsedMs = SabrBackoffCoordinator.NO_DEADLINE; - SabrBackoffCoordinator.getInstance().clear(appContext, this); - } - } - - public static final class Lease implements AutoCloseable { - @NonNull private final SessionKey key; - @NonNull private final Holder holder; - private final AtomicBoolean closed = new AtomicBoolean(); - - Lease(@NonNull final SessionKey key, @NonNull final Holder holder) { - this.key = key; - this.holder = holder; - } - - @NonNull - Holder getHolder() { - return holder; - } - - @Override - public void close() { - if (closed.compareAndSet(false, true)) { - releaseLease(key, holder); - } + void discardMediaSegments() { + for (final SabrMediaSegment segment : takeMediaSegments()) segment.delete(); } } @NonNull private static LocalDomPoTokenProvider provider(@NonNull final Context context) { - LocalDomPoTokenProvider p = sharedProvider; - if (p == null) { - synchronized (SabrSessionStore.class) { - p = sharedProvider; - if (p == null) { - p = LocalDomPoTokenProvider.shared(context.getApplicationContext()); - sharedProvider = p; - } - } - } - return p; - } - - public static final class Holder { - @NonNull private final SessionKey key; - @NonNull private final Context appContext; - @NonNull public final String videoId; - @NonNull public final YoutubeSabrInfo info; - @NonNull public final YoutubeSabrSession session; - @NonNull public final YoutubeSabrInfo.Format audioFormat; - @NonNull public final YoutubeSabrInfo.Format videoFormat; - @NonNull public final YoutubeSabrFormatTimeline audioTimeline; - @NonNull public final YoutubeSabrFormatTimeline videoTimeline; - - // Playback position is only a hint. Pump and eviction use reader positions. - private volatile long playerTimeMs; - private volatile float playbackRate = 1.0f; - private volatile long bandwidthEstimate = -1; - @Nullable private volatile byte[] poToken; - private volatile boolean audioActive = true; - private volatile boolean videoActive = true; - private final Map readerPositions = new ConcurrentHashMap<>(); - private final Map activeTrackModes = new IdentityHashMap<>(); - private final Map initializationData = new ConcurrentHashMap<>(); - private final Map bootstrapInitializationData = new ConcurrentHashMap<>(); - // Tracks currently selected by ExoPlayer. Background/audio-only playback disables the video - // renderer, so requiring a video reader position there pins the SABR cache at the beginning. - private final Set activeReaderItags = - Collections.newSetFromMap(new ConcurrentHashMap()); - private final AtomicInteger leaseReferences = new AtomicInteger(); - private Object readerOwner; - private long readerGeneration; - private volatile SabrMediaBridge bridge; - @NonNull private final SabrBackoffState backoffState; - @NonNull private final List bootstrapMediaSegments; - private volatile boolean invalidated; - private volatile String stopReason; - private volatile SabrLogicException terminalFailure; - private long lastDiagnosticsAtMs; - - Holder(@NonNull final Context appContext, - @NonNull final SabrSourceSpec spec, - @NonNull final YoutubeSabrSession session) { - this.key = new SessionKey(spec.getSourceId(), spec.getVideoId(), spec.getInfo(), - spec.getAudioFormat(), spec.getVideoFormat()); - this.appContext = appContext.getApplicationContext(); - this.videoId = spec.getVideoId(); - this.info = spec.getInfo(); - this.session = session; - this.audioFormat = spec.getAudioFormat(); - this.videoFormat = spec.getVideoFormat(); - this.audioTimeline = spec.getAudioTimeline(); - this.videoTimeline = spec.getVideoTimeline(); - this.bootstrapMediaSegments = spec.takeBootstrapMediaSegments(); - this.backoffState = new SabrBackoffState(); - retainBootstrapInitialization(spec, audioFormat); - retainBootstrapInitialization(spec, videoFormat); - } - - public long getPlayerTimeMs() { - return playerTimeMs; - } - - @NonNull - Context getApplicationContext() { - return appContext; - } - - void setPlayerTimeMs(final long playerTimeMs) { - this.playerTimeMs = playerTimeMs; - } - - void setPlaybackRate(final float value) { - if (value > 0) playbackRate = value; - } - - void setPoToken(@NonNull final byte[] value) { - poToken = value.clone(); - } - - @Nullable byte[] getPoToken() { - return poToken == null ? null : poToken.clone(); - } - - float getPlaybackRate() { return playbackRate; } - long getBandwidthEstimate() { return bandwidthEstimate; } - boolean isAudioActive() { return audioActive; } - boolean isVideoActive() { return videoActive; } - - void observeBandwidth(final long sample) { - if (sample <= 0) return; - bandwidthEstimate = bandwidthEstimate <= 0 - ? sample : (bandwidthEstimate * 3 + sample) / 4; - } - - @NonNull YoutubeSabrFormatTimeline getTimeline( - @NonNull final YoutubeSabrInfo.Format format) { - if (format.getItag() == audioFormat.getItag()) return audioTimeline; - if (format.getItag() == videoFormat.getItag()) return videoTimeline; - throw new IllegalArgumentException("Unknown SABR itag: " + format.getItag()); - } - - /** A data source reports how far it has read (last served segment end, ms). */ - public synchronized void setReaderPositionMs(@NonNull final Object owner, - final long generation, - final int itag, - final long ms) { - if (readerOwner == owner && readerGeneration == generation) { - readerPositions.put(itag, ms); - } - } - - void setActiveTracks(@NonNull final Object owner, - final boolean videoActive, - final boolean audioActive) { - final boolean trim; - synchronized (this) { - final int mode = (videoActive ? 1 : 0) | (audioActive ? 2 : 0); - if (mode == 0) { - activeTrackModes.remove(owner); - if (readerOwner == owner) { - readerOwner = activeTrackModes.isEmpty() ? null - : activeTrackModes.keySet().iterator().next(); - readerGeneration++; - readerPositions.clear(); - } - } else { - activeTrackModes.put(owner, mode); - if (readerOwner != owner) { - readerOwner = owner; - readerGeneration++; - readerPositions.clear(); - } - } - applyActiveTracks(); - trim = activeTrackModes.isEmpty(); - } - if (trim) { - trimSessions(null); - } - } - - void releaseTracks(@NonNull final Object owner) { - synchronized (this) { - activeTrackModes.remove(owner); - if (readerOwner == owner) { - readerOwner = activeTrackModes.isEmpty() ? null - : activeTrackModes.keySet().iterator().next(); - readerGeneration++; - readerPositions.clear(); - } - applyActiveTracks(); - } - trimSessions(null); - } - - synchronized void advanceReaderGeneration(@NonNull final Object owner) { - if (readerOwner == owner) { - readerGeneration++; - readerPositions.clear(); - } - } - - synchronized long getReaderGeneration(@NonNull final Object owner) { - return readerOwner == owner ? readerGeneration : -1; - } - - synchronized boolean isReaderGenerationActive(@NonNull final Object owner, - final long generation) { - return readerOwner == owner && readerGeneration == generation; - } - - private synchronized void anchorReaderPositionMs(final long positionMs) { - if (readerOwner == null || activeReaderItags.isEmpty()) { - return; - } - for (final int itag : activeReaderItags) { - readerPositions.put(itag, positionMs); - } - } - - void requestSeek(final long positionMs, @NonNull final Localization localization) { - final long previousPlayerTimeMs = playerTimeMs; - final boolean backward = positionMs < previousPlayerTimeMs; - setPlayerTimeMs(positionMs); - recordDiagnostics("seek positionMs=" + positionMs + " backward=" + backward); - anchorReaderPositionMs(positionMs); - if (positionMs <= 1_000 && previousPlayerTimeMs <= 1_000) { - return; - } - // Media3 may seek within its sample queue; still reposition the SABR session when the - // target audio/video segments are not cached. - final YoutubeSabrInfo.Format targetFormat = videoFormat; - final int sequence = videoTimeline.getSequenceAt(positionMs); - final SabrSegmentKey request = SabrSegmentKey.media(targetFormat, sequence); - final int audioSequence = audioTimeline.getSequenceAt(positionMs); - final SabrSegmentKey audioRequest = SabrSegmentKey.media( - audioFormat, audioSequence); - final SabrMediaBridge currentBridge = getBridge(localization); - if (currentBridge.getCached(request) == null - || currentBridge.getCached(audioRequest) == null) { - currentBridge.requestSeekTo(request, backward, positionMs); - } else { - currentBridge.noteSeekWithinCache(); - } - } - - private synchronized boolean hasActiveTracks() { - return !activeTrackModes.isEmpty(); - } - - byte[] getInitializationData(final int itag) { - final byte[] cached = initializationData.get(itag); - if (cached != null) { - return cached; - } - final byte[] bootstrap = bootstrapInitializationData.get(itag); - if (bootstrap != null) { - initializationData.put(itag, bootstrap); - session.addDiagnosticEvent("bootstrap_init_restore itag=" + itag); - } - return bootstrap; - } - - void setInitializationData(final int itag, @NonNull final byte[] data) { - initializationData.put(itag, data); - } - - private void retainBootstrapInitialization(@NonNull final SabrSourceSpec spec, - @NonNull final YoutubeSabrInfo.Format format) { - final byte[] data = spec.getInitializationData(format.getItag()); - if (data != null) { - bootstrapInitializationData.put(format.getItag(), data); - } - } - - private void retainLease() { - leaseReferences.incrementAndGet(); - } - - private boolean hasLeaseReferences() { - return leaseReferences.get() > 0; - } - - private void applyActiveTracks() { - boolean videoActive = false; - boolean audioActive = false; - for (final int mode : activeTrackModes.values()) { - videoActive |= (mode & 1) != 0; - audioActive |= (mode & 2) != 0; - } - setTrackActive(videoFormat.getItag(), videoActive); - setTrackActive(audioFormat.getItag(), audioActive); - if (videoActive || audioActive) { - this.videoActive = videoActive; - this.audioActive = audioActive; - } - } - - private void setTrackActive(final int itag, final boolean active) { - if (active) { - activeReaderItags.add(itag); - return; - } - activeReaderItags.remove(itag); - readerPositions.remove(itag); - } - - public long getReaderHeadMs() { - long head = 0; - for (final int itag : activeReaderItags) { - final Long position = readerPositions.get(itag); - if (position != null) { - head = Math.max(head, position); - } - } - return head; - } - - /** Zero until every selected track has read something, otherwise eviction can drop unread data. */ - public long getReaderTailMs() { - if (activeReaderItags.isEmpty()) { - return 0; - } - long tail = Long.MAX_VALUE; - for (final int itag : activeReaderItags) { - final Long position = readerPositions.get(itag); - if (position == null) { - return 0; - } - tail = Math.min(tail, position); - } - return tail == Long.MAX_VALUE ? 0 : tail; - } - - public boolean hasUnstartedActiveReader() { - if (activeReaderItags.isEmpty()) { - return false; - } - for (final int itag : activeReaderItags) { - if (!readerPositions.containsKey(itag)) { - return true; - } - } - return false; - } - - synchronized SabrMediaBridge getBridge(@NonNull final Localization localization) { - if (bridge == null) { - bridge = new SabrMediaBridge(this, localization, backoffState); - bridge.seedSegments(bootstrapMediaSegments); - } - return bridge; - } - - public long getBackoffRemainingMs() { - return backoffState.remainingMs(); - } - - public void addBackoffListener(@NonNull final SabrBackoffState.Listener listener) { - backoffState.addListener(listener); - } - - public void removeBackoffListener(@NonNull final SabrBackoffState.Listener listener) { - backoffState.removeListener(listener); - } - - boolean isInvalidated() { - return invalidated; - } - - String getInvalidationDetails() { - return "reason=" + stopReason - + ", leases=" + leaseReferences.get() - + ", trace=" + session.getDiagnosticTrace(); - } - - void failTerminal(@NonNull final SabrLogicException failure) { - terminalFailure = failure; - recordDiagnostics("terminal_failure message=" + failure.getMessage()); - evict(key, this, "terminal_failure message=" + failure.getMessage(), false); - } - - void throwIfTerminal() throws SabrLogicException { - if (terminalFailure != null) { - throw terminalFailure; - } - } - - void stop(@NonNull final String reason) { - SabrBackoffCoordinator.getInstance().clear(appContext, this); - Log.w(TAG, "stop video=" + videoId + " reason=" + reason - + " leases=" + leaseReferences.get() + " activeTracks=" + hasActiveTracks() - + " bridge=" + (bridge == null ? "none" : bridge.getStateName())); - recordDiagnostics("stop reason=" + reason); - stopReason = reason; - session.addDiagnosticEvent("session_stop reason=" + reason - + " leases=" + leaseReferences.get() + " activeTracks=" + hasActiveTracks()); - invalidated = true; - synchronized (this) { - activeTrackModes.clear(); - readerOwner = null; - readerGeneration++; - readerPositions.clear(); - applyActiveTracks(); - } - final SabrMediaBridge mediaBridge = bridge; - bridge = null; - if (mediaBridge != null) { - mediaBridge.stop(); - } - } - - boolean isBeyondEnd(@NonNull final SabrSegmentKey request) { - return request.getSequenceNumber() > getTimeline(request.getFormat()).getEndSequence(); - } - - void recordDiagnostics(@NonNull final String event) { - SabrPlaybackDiagnostics.record(appContext, this, event); - lastDiagnosticsAtMs = System.currentTimeMillis(); - } - - void recordDiagnosticsThrottled(@NonNull final String event) { - final long now = System.currentTimeMillis(); - if (now - lastDiagnosticsAtMs >= 5_000) { - recordDiagnostics(event); - } - } - } - - public static void updatePlayerTime(@NonNull final String videoId, final long playerTimeMs) { - if (playerTimeMs < 0) { - return; - } - for (final Map.Entry entry : SESSIONS.entrySet()) { - if (entry.getKey().videoId.equals(videoId) && entry.getValue().hasLeaseReferences()) { - entry.getValue().setPlayerTimeMs(playerTimeMs); - entry.getValue().recordDiagnosticsThrottled("progress"); - } - } - } - - public static void updatePlaybackRate(@NonNull final String videoId, final float playbackRate) { - for (final Map.Entry entry : SESSIONS.entrySet()) { - if (entry.getKey().videoId.equals(videoId) && entry.getValue().hasLeaseReferences()) { - entry.getValue().setPlaybackRate(playbackRate); + LocalDomPoTokenProvider result = sharedProvider; + if (result != null) return result; + synchronized (SabrSessionStore.class) { + if (sharedProvider == null) { + sharedProvider = new LocalDomPoTokenProvider(context.getApplicationContext()); } - } - } - - @NonNull - public static void setPreferredAudioTrack(@NonNull final String videoId, - @Nullable final String audioTrackId) { - if (audioTrackId == null) { - PREFERRED_AUDIO.remove(videoId); - } else { - PREFERRED_AUDIO.put(videoId, audioTrackId); + return sharedProvider; } } @NonNull public static SabrSourceSpec createSourceSpec(@NonNull final String videoId, final int preferredVideoItag, + @NonNull final List audioStreams, @Nullable final YoutubeSabrInfo extractorInfo) throws IOException, ExtractionException { PlaybackStartupTrace.markForVideoId(videoId, "sabr_source_spec_started"); - final String preferredAudioTrackId = PREFERRED_AUDIO.get(videoId); - final Localization localization = new Localization("en", "US"); if (!isUsableExtractorInfo(extractorInfo, videoId)) { throw new IOException("SABR extractor info is missing for " + videoId); } final YoutubeSabrInfo info = Objects.requireNonNull(extractorInfo); - final YoutubeSabrInfo.Format audioFormat = pickAudioFormat( - App.getApp(), info, preferredAudioTrackId); + final AudioSelection audio = selectAudioGroup(App.getApp(), info, audioStreams); final YoutubeSabrInfo.Format videoFormat = pickVideoFormat(info, preferredVideoItag); - if (audioFormat == null || videoFormat == null) { - throw new IOException("SABR: could not select audio/video formats for " + videoId); + if (audio == null || videoFormat == null) { + throw new IOException("Could not select SABR formats for " + videoId); } - // A detail-page prewarm may already have completed the canonical SABR bootstrap. Never - // publish a DASH manifest until both exact indexes have been parsed from SABR init data. - startTokenWarmup(App.getApp(), info, audioFormat, videoFormat); - final String bootstrapKey = bootstrapKey(info, audioFormat, videoFormat); - final Future bootstrapFuture = startBootstrap(App.getApp(), info, - audioFormat, videoFormat, localization); - final BootstrapResult bootstrap = awaitBootstrap(bootstrapKey, bootstrapFuture, videoId); + startTokenWarmup(App.getApp(), info); + final String key = bootstrapKey(info, audio.bootstrapFormat, videoFormat); + final BootstrapResult bootstrap = awaitBootstrap(key, + startBootstrap(App.getApp(), info, audio.bootstrapFormat, videoFormat), videoId); PlaybackStartupTrace.markForVideoId(videoId, "sabr_source_spec_ready"); - return new SabrSourceSpec(videoId, info, audioFormat, videoFormat, localization, + return new SabrSourceSpec(videoId, info, audio.bootstrapFormat, audio.formats, videoFormat, bootstrap.audioInitialization, bootstrap.videoInitialization, bootstrap.audioTimeline, bootstrap.videoTimeline, - bootstrap.takePreparedSession(), bootstrap.getMediaSegments()); + bootstrap.takeMediaSegments()); } - /** Starts expensive first-play work while the user is still reading the detail page. */ public static void prewarm(@NonNull final Context context, @NonNull final StreamInfo streamInfo, @NonNull final VideoStream selectedStream) { if (selectedStream.getDeliveryMethod() != DeliveryMethod.SABR - || !(selectedStream.getDeliveryMethodInfo() instanceof YoutubeSabrInfo)) { - return; - } + || !(selectedStream.getDeliveryMethodInfo() instanceof YoutubeSabrInfo)) return; final YoutubeSabrInfo info = (YoutubeSabrInfo) selectedStream.getDeliveryMethodInfo(); - if (!isUsableExtractorInfo(info, streamInfo.getId())) { - return; - } - final YoutubeSabrInfo.Format audioFormat = pickAudioFormat(context, info, - PREFERRED_AUDIO.get(streamInfo.getId())); - final YoutubeSabrInfo.Format videoFormat = pickVideoFormat(info, selectedStream.getItag()); - if (audioFormat == null || videoFormat == null) { - return; + if (!isUsableExtractorInfo(info, streamInfo.getId())) return; + final AudioSelection audio = selectAudioGroup(context, info, streamInfo.getAudioStreams()); + final YoutubeSabrInfo.Format video = pickVideoFormat(info, selectedStream.getItag()); + if (audio == null || video == null) return; + startTokenWarmup(context, info); + startBootstrap(context, info, audio.bootstrapFormat, video); + } + + @NonNull + static YoutubeSabrSession getOrCreateSession(@NonNull final Context context, + @NonNull final SabrSourceSpec spec) + throws IOException, ExtractionException { + final String key = sessionKey(spec.getInfo(), spec.getBootstrapAudioFormat(), + spec.getVideoFormat()); + final YoutubeSabrSession cached = getSession(key); + if (cached != null) return cached; + final File spool = new File(context.getCacheDir(), + "sabr-segments/" + spec.getVideoId() + '-' + System.nanoTime()); + final YoutubeSabrSession created = new YoutubeSabrSession(spec.getInfo(), + spec.getBootstrapAudioFormat(), spec.getVideoFormat(), spool); + final byte[] token = awaitWarmedToken(spec.getVideoId(), spec.getInfo(), provider(context)); + if (token == null || token.length == 0) { + throw new SabrLogicException("SABR PO token provider returned no token for video=" + + spec.getVideoId()); } - final Localization localization = new Localization("en", "US"); - startTokenWarmup(context.getApplicationContext(), info, audioFormat, videoFormat); - startBootstrap(context.getApplicationContext(), info, audioFormat, videoFormat, - localization); - Log.i(TAG, "prewarm started video=" + streamInfo.getId() - + " audioItag=" + audioFormat.getItag() + " videoItag=" + videoFormat.getItag()); + created.setPoToken(token); + return cacheSession(key, created); } @NonNull - private static Future startBootstrap(@NonNull final Context context, - @NonNull final YoutubeSabrInfo info, - @NonNull final YoutubeSabrInfo.Format audioFormat, - @NonNull final YoutubeSabrInfo.Format videoFormat, - @NonNull final Localization localization) { - final String key = bootstrapKey(info, audioFormat, videoFormat); + private static Future startBootstrap( + @NonNull final Context context, @NonNull final YoutubeSabrInfo info, + @NonNull final YoutubeSabrInfo.Format audio, + @NonNull final YoutubeSabrInfo.Format video) { + final String key = bootstrapKey(info, audio, video); final BootstrapResult cached = BOOTSTRAP_CACHE.get(key); if (cached != null) { - PlaybackStartupTrace.markForVideoId(info.getVideoId(), "sabr_audio_init_ready"); - PlaybackStartupTrace.markForVideoId(info.getVideoId(), "sabr_video_init_ready"); - final FutureTask completed = new FutureTask<>(() -> cached); - completed.run(); - return completed; - } - final BootstrapBackoffState backoffState = new BootstrapBackoffState( - context, info.getVideoId()); - final FutureTask created = new FutureTask(() -> - cacheBootstrap(key, createPreparation(context, info, audioFormat, videoFormat, - localization))) { - @Override - protected void done() { - PlaybackStartupTrace.markForVideoId(info.getVideoId(), "sabr_audio_init_ready"); - PlaybackStartupTrace.markForVideoId(info.getVideoId(), "sabr_video_init_ready"); - } + final FutureTask result = new FutureTask<>(() -> cached); + result.run(); + return result; + } + final FutureTask created = new FutureTask(() -> { + final BootstrapResult result = createPreparation( + context, info, audio, video); + BOOTSTRAP_CACHE.put(key, result); + return result; + }) { + @Override protected void done() { BOOTSTRAP_IN_FLIGHT.remove(key, this); } }; final Future existing = BOOTSTRAP_IN_FLIGHT.putIfAbsent(key, created); - if (existing != null) { - return existing; - } - BOOTSTRAP_BACKOFFS.put(key, backoffState); - PlaybackStartupTrace.markForVideoId(info.getVideoId(), "sabr_bootstrap_started"); + if (existing != null) return existing; BOOTSTRAP_EXECUTOR.execute(created); return created; } @NonNull - private static BootstrapResult createPreparation(@NonNull final Context context, - @NonNull final YoutubeSabrInfo info, - @NonNull final YoutubeSabrInfo.Format audioFormat, - @NonNull final YoutubeSabrInfo.Format videoFormat, - @NonNull final Localization localization) - throws IOException, ExtractionException { - final LocalDomPoTokenProvider sessionProvider = provider(context); - final File spoolDirectory = new File(context.getApplicationContext().getCacheDir(), - "sabr-bootstrap/" + info.getVideoId() + '-' + System.nanoTime()); - final YoutubeSabrSession session = new YoutubeSabrSession(info, audioFormat, videoFormat, - spoolDirectory); - final byte[] poToken = awaitWarmedToken(info.getVideoId(), info, sessionProvider); - if (poToken == null || poToken.length == 0) { - throw new SabrLogicException("SABR PO token provider returned no token for video=" - + info.getVideoId()); - } - YoutubeSabrSession.InitializationResult initialization; - try { - initialization = session.initialize(localization, 2_000, poToken); - } catch (final IOException firstFailure) { - final byte[] retryPoToken = awaitWarmedToken( - info.getVideoId(), info, sessionProvider); - initialization = session.initialize(localization, 2_000, retryPoToken); - } - if (initialization.getAudioData() == null || initialization.getVideoData() == null) { - throw new SabrLogicException("SABR initialization did not provide both tracks for video=" - + info.getVideoId()); - } - if (initialization.getAudioTimeline() == null + private static BootstrapResult createPreparation( + @NonNull final Context context, @NonNull final YoutubeSabrInfo info, + @NonNull final YoutubeSabrInfo.Format audio, + @NonNull final YoutubeSabrInfo.Format video) throws IOException, ExtractionException { + final YoutubeSabrSession session = new YoutubeSabrSession(info, audio, video, + new File(context.getCacheDir(), "sabr-bootstrap/" + info.getVideoId() + + '-' + System.nanoTime())); + final byte[] token = awaitWarmedToken(info.getVideoId(), info, provider(context)); + if (token == null || token.length == 0) { + throw new SabrLogicException("Missing SABR PO token for " + info.getVideoId()); + } + final YoutubeSabrSession.InitializationResult initialization = + session.initialize(2_000, token); + if (initialization.getAudioData() == null || initialization.getVideoData() == null + || initialization.getAudioTimeline() == null || initialization.getVideoTimeline() == null) { - throw new SabrLogicException("SABR initialization did not provide timelines for video=" - + info.getVideoId()); + throw new SabrLogicException("Incomplete SABR initialization for " + info.getVideoId()); } - return new BootstrapResult(initialization.getAudioData(), initialization.getVideoData(), - initialization.getAudioTimeline(), initialization.getVideoTimeline(), - session, initialization.getMediaSegments()); + cacheSession(sessionKey(info, audio, video), session); + return new BootstrapResult(initialization); + } + + @Nullable + private static synchronized YoutubeSabrSession getSession(@NonNull final String key) { + return SESSIONS.get(key); + } + + @NonNull + private static synchronized YoutubeSabrSession cacheSession( + @NonNull final String key, @NonNull final YoutubeSabrSession session) { + final YoutubeSabrSession existing = SESSIONS.get(key); + if (existing != null) return existing; + SESSIONS.put(key, session); + return session; } @NonNull @@ -813,361 +242,173 @@ private static BootstrapResult awaitBootstrap(@NonNull final String key, @NonNull final Future future, @NonNull final String videoId) throws IOException, ExtractionException { - final BootstrapBackoffState backoffState = BOOTSTRAP_BACKOFFS.get(key); - if (backoffState != null) { - backoffState.beginWaiting(); - } try { return future.get(); - } catch (final InterruptedException e) { + } catch (final InterruptedException error) { Thread.currentThread().interrupt(); - throw new IOException("Interrupted awaiting SABR bootstrap for " + videoId, e); - } catch (final ExecutionException e) { - final Throwable cause = e.getCause(); - if (cause instanceof IOException) { - throw (IOException) cause; - } - if (cause instanceof ExtractionException) { - throw (ExtractionException) cause; - } + throw new IOException("Interrupted awaiting SABR bootstrap for " + videoId, error); + } catch (final ExecutionException error) { + final Throwable cause = error.getCause(); + if (cause instanceof IOException) throw (IOException) cause; + if (cause instanceof ExtractionException) throw (ExtractionException) cause; throw new IOException("Could not bootstrap SABR for " + videoId, cause); } finally { - if (backoffState != null) { - backoffState.endWaiting(); - BOOTSTRAP_BACKOFFS.remove(key, backoffState); - } BOOTSTRAP_IN_FLIGHT.remove(key, future); } } - @NonNull - private static String bootstrapKey(@NonNull final YoutubeSabrInfo info, - @NonNull final YoutubeSabrInfo.Format audioFormat, - @NonNull final YoutubeSabrInfo.Format videoFormat) { - return tokenIdentityKey(info) + '#' - + audioFormat.getItag() + ':' + audioFormat.getLastModified() + '#' - + videoFormat.getItag() + ':' + videoFormat.getLastModified(); - } - - @NonNull - private static String tokenIdentityKey(@NonNull final YoutubeSabrInfo info) { - return info.getVideoId() + "#MWEB#" + info.getClientVersion() + '#' - + Objects.toString(info.getVisitorData(), ""); - } - - @NonNull - private static BootstrapResult cacheBootstrap(@NonNull final String key, - @NonNull final BootstrapResult result) { - BOOTSTRAP_CACHE.put(key, result); - return result; - } - private static void startTokenWarmup(@NonNull final Context context, - @NonNull final YoutubeSabrInfo info, - @NonNull final YoutubeSabrInfo.Format audioFormat, - @NonNull final YoutubeSabrInfo.Format videoFormat) { - final String tokenKey = tokenIdentityKey(info); - final FutureTask created = new FutureTask(() -> - provider(context).getPoToken(info)) { - @Override - protected void done() { - TOKEN_IN_FLIGHT.remove(tokenKey, this); - } + @NonNull final YoutubeSabrInfo info) { + final String key = tokenIdentityKey(info); + final FutureTask created = new FutureTask( + () -> provider(context).getPoToken(info)) { + @Override protected void done() { TOKEN_IN_FLIGHT.remove(key, this); } }; - if (TOKEN_IN_FLIGHT.putIfAbsent(tokenKey, created) == null) { - TOKEN_EXECUTOR.execute(created); - } - } - - @NonNull - static Lease acquire(@NonNull final Context context, @NonNull final SabrSourceSpec spec) - throws IOException, ExtractionException { - final SessionKey key = new SessionKey(spec.getSourceId(), spec.getVideoId(), spec.getInfo(), - spec.getAudioFormat(), spec.getVideoFormat()); - // Resolve the shared provider before taking the session-store monitor. A token prewarm may - // be initializing the same provider and acquire() must never wait for it while holding the - // monitor that provider() itself needs. - final LocalDomPoTokenProvider sessionProvider = provider(context); - synchronized (SabrSessionStore.class) { - final Holder current = SESSIONS.get(key); - if (current != null) { - current.retainLease(); - current.recordDiagnosticsThrottled("session_reuse"); - return new Lease(key, current); - } - final File spoolDirectory = new File(context.getApplicationContext().getCacheDir(), - "sabr-segments/" + spec.getVideoId() + '-' + System.nanoTime()); - final YoutubeSabrSession preparedSession = spec.takePreparedSession(); - final YoutubeSabrSession session; - final byte[] poToken; - if (preparedSession != null) { - session = preparedSession; - session.addDiagnosticEvent("bootstrap_session_handoff"); - poToken = attachPoToken( - spec.getVideoId(), spec.getInfo(), sessionProvider, session); - } else { - session = new YoutubeSabrSession(spec.getInfo(), spec.getAudioFormat(), - spec.getVideoFormat(), spoolDirectory); - poToken = attachPoToken( - spec.getVideoId(), spec.getInfo(), sessionProvider, session); - } - final Holder holder = new Holder(context, spec, session); - holder.setPoToken(poToken); - seedInitializationData(holder, spec, spec.getAudioFormat()); - seedInitializationData(holder, spec, spec.getVideoFormat()); - SESSIONS.put(key, holder); - ORDER.remove(key); - ORDER.addLast(key); - holder.retainLease(); - trimSessions(key); - holder.recordDiagnostics("session_create"); - return new Lease(key, holder); - } - } - - private static void seedInitializationData(@NonNull final Holder holder, - @NonNull final SabrSourceSpec spec, - @NonNull final YoutubeSabrInfo.Format format) { - final byte[] data = spec.getInitializationData(format.getItag()); - if (data != null) { - holder.setInitializationData(format.getItag(), data); - } - } - - private static void releaseLease(@NonNull final SessionKey key, - @NonNull final Holder holder) { - final int references = holder.leaseReferences.decrementAndGet(); - if (references <= 0) { - evict(key, holder, "leases_released count=" + references, true); - } - } - - private static byte[] attachPoToken(@NonNull final String videoId, - @NonNull final YoutubeSabrInfo info, - @NonNull final LocalDomPoTokenProvider provider, - @NonNull final YoutubeSabrSession session) - throws IOException, ExtractionException { - try { - final byte[] token = awaitWarmedToken(videoId, info, provider); - if (token == null || token.length == 0) { - throw new SabrLogicException("SABR PO token provider returned no token for video=" - + videoId); - } - session.addDiagnosticEvent("token_attach bytes=" - + token.length); - return token; - } catch (final IOException | ExtractionException e) { - Log.w(TAG, "PO token attach failed video=" + videoId, e); - session.addDiagnosticEvent("token_attach_failed type=" - + e.getClass().getSimpleName() + " message=" + e.getMessage()); - throw e; - } catch (final RuntimeException e) { - Log.w(TAG, "PO token attach failed video=" + videoId, e); - session.addDiagnosticEvent("token_attach_failed type=" - + e.getClass().getSimpleName() + " message=" + e.getMessage()); - throw new SabrLogicException("SABR PO token attach failed for video=" + videoId, e); - } + if (TOKEN_IN_FLIGHT.putIfAbsent(key, created) == null) TOKEN_EXECUTOR.execute(created); } @Nullable private static byte[] awaitWarmedToken(@NonNull final String videoId, @NonNull final YoutubeSabrInfo info, - @NonNull final LocalDomPoTokenProvider provider) + @NonNull final LocalDomPoTokenProvider tokenProvider) throws IOException, ExtractionException { - final String tokenKey = tokenIdentityKey(info); - final Future future = TOKEN_IN_FLIGHT.get(tokenKey); - if (future == null) { - PlaybackStartupTrace.markForVideoId(videoId, "sabr_token_mint_started"); - final byte[] token = provider.getPoToken(info); - PlaybackStartupTrace.markForVideoId(videoId, "sabr_token_ready"); - return token; - } + final String key = tokenIdentityKey(info); + final Future future = TOKEN_IN_FLIGHT.get(key); + if (future == null) return tokenProvider.getPoToken(info); try { - PlaybackStartupTrace.markForVideoId(videoId, "sabr_token_wait_started"); - final byte[] token = future.get(); - PlaybackStartupTrace.markForVideoId(videoId, "sabr_token_ready"); - return token; - } catch (final InterruptedException e) { + return future.get(); + } catch (final InterruptedException error) { Thread.currentThread().interrupt(); - throw new IOException("Interrupted awaiting SABR token for " + videoId, e); - } catch (final ExecutionException e) { - final Throwable cause = e.getCause(); - if (cause instanceof IOException) { - throw (IOException) cause; - } - if (cause instanceof ExtractionException) { - throw (ExtractionException) cause; - } - throw new IOException("Could not prewarm SABR token for " + videoId, cause); + throw new IOException("Interrupted awaiting SABR token for " + videoId, error); + } catch (final ExecutionException error) { + final Throwable cause = error.getCause(); + if (cause instanceof IOException) throw (IOException) cause; + if (cause instanceof ExtractionException) throw (ExtractionException) cause; + throw new IOException("Could not obtain SABR token for " + videoId, cause); } finally { - TOKEN_IN_FLIGHT.remove(tokenKey, future); + TOKEN_IN_FLIGHT.remove(key, future); } } private static boolean isUsableExtractorInfo(@Nullable final YoutubeSabrInfo info, @NonNull final String videoId) { - return info != null - && videoId.equals(info.getVideoId()) + return info != null && videoId.equals(info.getVideoId()) && info.getServerAbrStreamingUrl() != null - && !info.getServerAbrStreamingUrl().isEmpty() - && !info.getFormats().isEmpty(); + && !info.getServerAbrStreamingUrl().isEmpty() && !info.getFormats().isEmpty(); } - private static YoutubeSabrInfo.Format pickAudioFormat(@NonNull final Context context, - @NonNull final YoutubeSabrInfo info, - @Nullable final String preferredTrackId) { - final SharedPreferences preferences = - PreferenceManager.getDefaultSharedPreferences(context); - final String preferredLanguage = preferences.getString( - context.getString(R.string.preferred_audio_language_key), "original"); - return pickAudioFormat(info, preferredTrackId, preferredLanguage); + @Nullable + private static AudioSelection selectAudioGroup(@NonNull final Context context, + @NonNull final YoutubeSabrInfo info, + @NonNull final List streams) { + final List candidates = new ArrayList<>(); + for (final AudioStream stream : streams) { + if (stream.getDeliveryMethod() == DeliveryMethod.SABR + && stream.getDeliveryMethodInfo() instanceof YoutubeSabrInfo + && info.getVideoId().equals(((YoutubeSabrInfo) + stream.getDeliveryMethodInfo()).getVideoId())) { + candidates.add(stream); + } + } + final int selectedIndex = ListHelper.getDefaultAudioFormat(context, candidates); + if (selectedIndex < 0 || selectedIndex >= candidates.size()) return null; + final AudioStream selected = candidates.get(selectedIndex); + final String selectedCodec = codecGroup(selected); + final List formats = new ArrayList<>(); + for (final AudioStream stream : candidates) { + if (!selectedCodec.equals(codecGroup(stream))) continue; + final YoutubeSabrInfo.Format format = findAudioFormat(info, stream); + if (format != null && !formats.contains(format)) formats.add(format); + } + final YoutubeSabrInfo.Format bootstrap = findAudioFormat(info, selected); + if (bootstrap == null || formats.isEmpty()) return null; + formats.sort(Comparator + .comparingInt((YoutubeSabrInfo.Format format) -> + Objects.equals(format.getAudioTrackId(), bootstrap.getAudioTrackId()) + ? 0 : 1) + .thenComparing(format -> + Objects.toString(format.getAudioTrackDisplayName(), "")) + .thenComparingInt(YoutubeSabrInfo.Format::getBitrate)); + return new AudioSelection(bootstrap, formats); } - static YoutubeSabrInfo.Format pickAudioFormat(@NonNull final YoutubeSabrInfo info, - @Nullable final String preferredTrackId, - @Nullable final String preferredLanguage) { - YoutubeSabrInfo.Format best = null; - for (final YoutubeSabrInfo.Format f : info.getFormats()) { - if (!f.isAudio()) { - continue; - } - final boolean matches = preferredTrackId != null - ? preferredTrackId.equals(f.getAudioTrackId()) - : matchesAudioLanguage(preferredLanguage, f.getAudioTrackId()); - if (!matches) { - continue; - } - if (best == null || f.getBitrate() > best.getBitrate()) { - best = f; + @Nullable + private static YoutubeSabrInfo.Format findAudioFormat( + @NonNull final YoutubeSabrInfo info, @NonNull final AudioStream stream) { + for (final YoutubeSabrInfo.Format format : info.getFormats()) { + if (format.isAudio() && format.getItag() == stream.getItag() + && Objects.equals(format.getAudioTrackId(), stream.getAudioTrackId())) { + return format; } } - return best != null ? best : info.findBestAudioFormat(); + return null; } - private static boolean matchesAudioLanguage(@Nullable final String preferredLanguage, - @Nullable final String trackId) { - if (preferredLanguage == null || "original".equals(preferredLanguage) - || trackId == null) { - return false; + @NonNull + private static String codecGroup(@NonNull final AudioStream stream) { + final String codec = stream.getCodec(); + if (codec == null || codec.isEmpty()) { + return Objects.toString(stream.getFormat(), "unknown"); } - return preferredLanguage.equals(trackId.split("[._-]", 2)[0]); + final int separator = codec.indexOf('.'); + return (separator < 0 ? codec : codec.substring(0, separator)) + .toLowerCase(java.util.Locale.ROOT); } - private static YoutubeSabrInfo.Format pickVideoFormat(@NonNull final YoutubeSabrInfo info, - final int preferredItag) { - if (preferredItag > 0) { - for (final YoutubeSabrInfo.Format f : info.getFormats()) { - if (f.isVideo() && f.getItag() == preferredItag) { - return f; - } - } - } - return info.findLowestVideoFormat(); - } + private static final class AudioSelection { + @NonNull private final YoutubeSabrInfo.Format bootstrapFormat; + @NonNull private final List formats; - public static void evict(@NonNull final String videoId) { - final List holders = new ArrayList<>(); - synchronized (SabrSessionStore.class) { - final java.util.Iterator> iterator = - SESSIONS.entrySet().iterator(); - while (iterator.hasNext()) { - final Map.Entry entry = iterator.next(); - if (entry.getKey().videoId.equals(videoId)) { - holders.add(entry.getValue()); - ORDER.remove(entry.getKey()); - iterator.remove(); - } - } - } - for (final Holder holder : holders) { - holder.stop("explicit"); + AudioSelection(@NonNull final YoutubeSabrInfo.Format bootstrapFormat, + @NonNull final List formats) { + this.bootstrapFormat = bootstrapFormat; + this.formats = formats; } } - /** Reset SABR-only caches before a cold benchmark trial. Not used by playback code. */ - public static void clearBenchmarkCaches(@NonNull final Context context, - @NonNull final String videoId) { - evict(videoId); - for (final Map.Entry> entry - : BOOTSTRAP_IN_FLIGHT.entrySet()) { - if (entry.getKey().startsWith(videoId + '#')) { - entry.getValue().cancel(true); - BOOTSTRAP_IN_FLIGHT.remove(entry.getKey(), entry.getValue()); - final BootstrapBackoffState backoffState = - BOOTSTRAP_BACKOFFS.remove(entry.getKey()); - if (backoffState != null) { - backoffState.cancel(); - } - } - } - synchronized (BOOTSTRAP_CACHE) { - final java.util.Iterator> iterator = - BOOTSTRAP_CACHE.entrySet().iterator(); - while (iterator.hasNext()) { - final Map.Entry entry = iterator.next(); - if (entry.getKey().startsWith(videoId + '#')) { - entry.getValue().discardPreparedSession(); - iterator.remove(); - } - } + private static YoutubeSabrInfo.Format pickVideoFormat(@NonNull final YoutubeSabrInfo info, + final int preferredItag) { + for (final YoutubeSabrInfo.Format format : info.getFormats()) { + if (format.isVideo() && format.getItag() == preferredItag) return format; } - for (final Map.Entry> entry : TOKEN_IN_FLIGHT.entrySet()) { - if (entry.getKey().startsWith(videoId + '#')) { - entry.getValue().cancel(true); - TOKEN_IN_FLIGHT.remove(entry.getKey(), entry.getValue()); + YoutubeSabrInfo.Format lowest = null; + for (final YoutubeSabrInfo.Format format : info.getFormats()) { + if (!format.isVideo()) continue; + if (lowest == null || format.getHeight() < lowest.getHeight() + || format.getHeight() == lowest.getHeight() + && format.getBitrate() < lowest.getBitrate()) { + lowest = format; } } - provider(context).clearCachedToken(videoId); + return lowest; } - private static void trimSessions(@Nullable final SessionKey protectedKey) { - while (true) { - final Holder holder; - synchronized (SabrSessionStore.class) { - if (ORDER.size() <= MAX_SESSIONS) { - return; - } - SessionKey candidate = null; - for (final SessionKey key : ORDER) { - final Holder current = SESSIONS.get(key); - if (!key.equals(protectedKey) - && current != null - && !current.hasActiveTracks() - && !current.hasLeaseReferences()) { - candidate = key; - break; - } - } - if (candidate == null) { - return; - } - holder = SESSIONS.remove(candidate); - ORDER.remove(candidate); - } - if (holder != null) { - holder.stop("session_trim protectedVideo=" - + (protectedKey == null ? null : protectedKey.videoId)); - } - } + @NonNull + private static String bootstrapKey(@NonNull final YoutubeSabrInfo info, + @NonNull final YoutubeSabrInfo.Format audio, + @NonNull final YoutubeSabrInfo.Format video) { + return tokenIdentityKey(info) + '#' + formatIdentity(audio) + + '#' + formatIdentity(video); } - private static void evict(@NonNull final SessionKey key, - @Nullable final Holder expectedHolder, - @NonNull final String reason, - final boolean requireNoLeaseReferences) { - final Holder holder; - synchronized (SabrSessionStore.class) { - holder = SESSIONS.get(key); - if (holder == null - || (expectedHolder != null && holder != expectedHolder) - || (requireNoLeaseReferences && holder.hasLeaseReferences())) { - return; - } - SESSIONS.remove(key); - ORDER.remove(key); - } - if (holder != null) { - holder.stop(reason); - } + @NonNull + private static String sessionKey(@NonNull final YoutubeSabrInfo info, + @NonNull final YoutubeSabrInfo.Format audio, + @NonNull final YoutubeSabrInfo.Format video) { + return bootstrapKey(info, audio, video) + '#' + + Objects.toString(info.getCpn(), "") + '#' + + Objects.toString(info.getServerAbrStreamingUrl(), ""); + } + + @NonNull + private static String tokenIdentityKey(@NonNull final YoutubeSabrInfo info) { + return info.getVideoId() + "#MWEB#" + info.getClientVersion() + '#' + + Objects.toString(info.getVisitorData(), ""); + } + + @NonNull + private static String formatIdentity(@NonNull final YoutubeSabrInfo.Format format) { + return format.getItag() + ":" + format.getLastModified() + ':' + + Objects.toString(format.getXtags(), ""); } } diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSourceSpec.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSourceSpec.java index 1bb3e5dc9..c2159c4d1 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSourceSpec.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSourceSpec.java @@ -3,115 +3,102 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import org.schabi.newpipe.extractor.localization.Localization; -import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormatTimeline; -import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; import org.schabi.newpipe.extractor.services.youtube.sabr.media.SabrMediaSegment; -import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.atomic.AtomicReference; +import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Map; +import java.util.LinkedHashMap; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; -/** Immutable metadata needed to construct a SABR MediaSource without owning a live session. */ +/** Source metadata for one video format and one Media3-selectable audio codec group. */ public final class SabrSourceSpec { - private static final AtomicLong NEXT_SOURCE_ID = new AtomicLong(); - - private final long sourceId; @NonNull private final String videoId; @NonNull private final YoutubeSabrInfo info; - @NonNull private final YoutubeSabrInfo.Format audioFormat; + @NonNull private final YoutubeSabrInfo.Format bootstrapAudioFormat; + @NonNull private final List audioFormats; @NonNull private final YoutubeSabrInfo.Format videoFormat; - @NonNull private final Localization localization; - @NonNull private final byte[] audioInitializationData; - @NonNull private final byte[] videoInitializationData; + @NonNull private final Map formatsByKey; + @NonNull private final Map keysByFormat; + @NonNull private final Map initializationData = + new ConcurrentHashMap<>(); @NonNull private final YoutubeSabrFormatTimeline audioTimeline; @NonNull private final YoutubeSabrFormatTimeline videoTimeline; - @NonNull private final AtomicReference preparedSession; - @NonNull private final List bootstrapMediaSegments; - - public SabrSourceSpec(@NonNull final String videoId, - @NonNull final YoutubeSabrInfo info, - @NonNull final YoutubeSabrInfo.Format audioFormat, - @NonNull final YoutubeSabrInfo.Format videoFormat, - @NonNull final Localization localization, - @NonNull final byte[] audioInitializationData, - @NonNull final byte[] videoInitializationData) { - this(videoId, info, audioFormat, videoFormat, localization, - audioInitializationData, videoInitializationData, - parseTimeline(audioFormat, audioInitializationData), - parseTimeline(videoFormat, videoInitializationData), - null, Collections.emptyList()); - } + @NonNull private final AtomicReference> bootstrapMediaSegments; SabrSourceSpec(@NonNull final String videoId, @NonNull final YoutubeSabrInfo info, - @NonNull final YoutubeSabrInfo.Format audioFormat, + @NonNull final YoutubeSabrInfo.Format bootstrapAudioFormat, + @NonNull final List audioFormats, @NonNull final YoutubeSabrInfo.Format videoFormat, - @NonNull final Localization localization, @NonNull final byte[] audioInitializationData, @NonNull final byte[] videoInitializationData, @NonNull final YoutubeSabrFormatTimeline audioTimeline, @NonNull final YoutubeSabrFormatTimeline videoTimeline, - @Nullable final YoutubeSabrSession preparedSession, @NonNull final List bootstrapMediaSegments) { - this.sourceId = NEXT_SOURCE_ID.incrementAndGet(); + if (audioFormats.isEmpty() || !audioFormats.contains(bootstrapAudioFormat)) { + throw new IllegalArgumentException("SABR audio codec group is empty"); + } this.videoId = videoId; this.info = info; - this.audioFormat = audioFormat; + this.bootstrapAudioFormat = bootstrapAudioFormat; + this.audioFormats = Collections.unmodifiableList(new ArrayList<>(audioFormats)); this.videoFormat = videoFormat; - this.localization = localization; - this.audioInitializationData = audioInitializationData.clone(); - this.videoInitializationData = videoInitializationData.clone(); + final Map byKey = new LinkedHashMap<>(); + final Map byFormat = new ConcurrentHashMap<>(); + byKey.put("v", videoFormat); + byFormat.put(videoFormat, "v"); + for (int i = 0; i < audioFormats.size(); i++) { + final String key = "a" + i; + byKey.put(key, audioFormats.get(i)); + byFormat.put(audioFormats.get(i), key); + } + formatsByKey = Collections.unmodifiableMap(byKey); + keysByFormat = Collections.unmodifiableMap(byFormat); this.audioTimeline = audioTimeline; this.videoTimeline = videoTimeline; - this.preparedSession = new AtomicReference<>(preparedSession); - this.bootstrapMediaSegments = bootstrapMediaSegments; + this.bootstrapMediaSegments = new AtomicReference<>(bootstrapMediaSegments); + putInitializationData(bootstrapAudioFormat, audioInitializationData); + putInitializationData(videoFormat, videoInitializationData); } + @NonNull public String getVideoId() { return videoId; } + @NonNull public YoutubeSabrInfo getInfo() { return info; } @NonNull - public String getVideoId() { - return videoId; - } - - long getSourceId() { - return sourceId; + public YoutubeSabrInfo.Format getBootstrapAudioFormat() { + return bootstrapAudioFormat; } + @NonNull public List getAudioFormats() { return audioFormats; } + @NonNull public YoutubeSabrInfo.Format getVideoFormat() { return videoFormat; } - @NonNull - public YoutubeSabrInfo getInfo() { - return info; + @Nullable YoutubeSabrInfo.Format getFormat(@NonNull final String key) { + return formatsByKey.get(key); } - @NonNull - public YoutubeSabrInfo.Format getAudioFormat() { - return audioFormat; + @NonNull String getFormatKey(@NonNull final YoutubeSabrInfo.Format format) { + final String key = keysByFormat.get(format); + if (key == null) throw new IllegalArgumentException("Unknown SABR format"); + return key; } - @NonNull - public YoutubeSabrInfo.Format getVideoFormat() { - return videoFormat; - } - - @NonNull - Localization getLocalization() { - return localization; + @Nullable + byte[] getInitializationData(@NonNull final YoutubeSabrInfo.Format format) { + final byte[] data = initializationData.get(format); + return data == null ? null : data.clone(); } - @Nullable - byte[] getInitializationData(final int itag) { - if (itag == audioFormat.getItag()) { - return audioInitializationData.clone(); - } - if (itag == videoFormat.getItag()) { - return videoInitializationData.clone(); - } - return null; + void putInitializationData(@NonNull final YoutubeSabrInfo.Format format, + @NonNull final byte[] data) { + initializationData.putIfAbsent(format, data.clone()); } long getDurationMs() { - return Math.max(audioFormat.getApproxDurationMs(), videoFormat.getApproxDurationMs()); + return Math.max(bootstrapAudioFormat.getApproxDurationMs(), + videoFormat.getApproxDurationMs()); } @NonNull YoutubeSabrFormatTimeline getAudioTimeline() { return audioTimeline; } @@ -119,33 +106,13 @@ long getDurationMs() { @NonNull YoutubeSabrFormatTimeline getTimeline(@NonNull final YoutubeSabrInfo.Format format) { - if (format.getItag() == audioFormat.getItag()) return audioTimeline; + if (format.isAudio() && audioFormats.contains(format)) return audioTimeline; if (format.getItag() == videoFormat.getItag()) return videoTimeline; throw new IllegalArgumentException("Unknown SABR itag: " + format.getItag()); } - @Nullable - YoutubeSabrSession takePreparedSession() { - return preparedSession.getAndSet(null); - } - @NonNull List takeBootstrapMediaSegments() { - return bootstrapMediaSegments; - } - - void discardPreparedSession() { - preparedSession.set(null); - } - - @NonNull - private static YoutubeSabrFormatTimeline parseTimeline( - @NonNull final YoutubeSabrInfo.Format format, @NonNull final byte[] data) { - try { - return YoutubeSabrFormatTimeline.parse(format, data); - } catch (final Exception error) { - throw new IllegalArgumentException("Invalid SABR initialization timeline: itag=" - + format.getItag(), error); - } + return bootstrapMediaSegments.getAndSet(Collections.emptyList()); } } diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java index 07ddbb01e..47a40a0a6 100644 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java @@ -470,11 +470,11 @@ private static MediaSource buildSabrMediaSource(@NonNull final Stream stream, final YoutubeSabrInfo sabrInfo = getSabrInfo(stream); final SabrSourceSpec spec; try { - spec = SabrSessionStore.createSourceSpec(videoId, preferredVideoItag, sabrInfo); + spec = SabrSessionStore.createSourceSpec(videoId, preferredVideoItag, + streamInfo.getAudioStreams(), sabrInfo); } catch (final ExtractionException e) { throw new IOException("Could not describe SABR source for " + videoId, e); } - enrichSabrAudioTracks(streamInfo, spec.getInfo()); final MediaItem mediaItem = new MediaItem.Builder() .setTag(metadata) .setUri(Uri.parse("sabr://" + videoId)) @@ -489,38 +489,6 @@ private static YoutubeSabrInfo getSabrInfo(@NonNull final Stream stream) { return info instanceof YoutubeSabrInfo ? (YoutubeSabrInfo) info : null; } - private static void enrichSabrAudioTracks(@NonNull final StreamInfo streamInfo, - @NonNull final YoutubeSabrInfo info) { - final List audioStreams = streamInfo.getAudioStreams(); - if (audioStreams.isEmpty()) { - return; - } - final AudioStream template = audioStreams.get(0); - final Set present = new HashSet<>(); - for (final AudioStream a : audioStreams) { - present.add(Objects.toString(a.getAudioTrackId(), "")); - } - for (final YoutubeSabrInfo.Format f : info.getFormats()) { - final String trackId = f.getAudioTrackId(); - if (!f.isAudio() || trackId == null || !present.add(trackId)) { - continue; - } - final String langPart = trackId.split("\\.")[0]; - final String displayName = f.getAudioTrackDisplayName(); - audioStreams.add(new AudioStream.Builder() - .setId(template.getId() + "-" + trackId) - .setContent(template.getContent(), template.isUrl()) - .setMediaFormat(template.getFormat()) - .setAverageBitrate(f.getBitrate()) - .setItagItem(template.getItagItem()) - .setDeliveryMethod(DeliveryMethod.SABR) - .setAudioTrackId(trackId) - .setAudioTrackName(displayName != null ? displayName : langPart) - .setAudioLocale(langPart.split("-")[0]) - .build()); - } - } - @NonNull private static DashMediaSource buildYoutubeManualDashMediaSource( @NonNull final PlayerDataSource dataSource, diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java index b05998990..0f952a7b4 100644 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java @@ -77,8 +77,6 @@ public MediaSource resolve(@NonNull final StreamInfo info) { return liveSource; } - SabrSessionStore.setPreferredAudioTrack(info.getId(), audioTrack); - final List mediaSources = new ArrayList<>(); final List videoStreams = new ArrayList<>(info.getVideoStreams()); final List videoOnlyStreams = new ArrayList<>(info.getVideoOnlyStreams()); diff --git a/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java b/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java index 5dbc71f4c..ea0161988 100644 --- a/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java @@ -297,8 +297,14 @@ private static long getSabrContentLength(final Stream stream) { } else { return -1; } - final YoutubeSabrInfo.Format format = ((YoutubeSabrInfo) stream.getDeliveryMethodInfo()) - .findFormatByItag(itag); + YoutubeSabrInfo.Format format = null; + for (final YoutubeSabrInfo.Format candidate + : ((YoutubeSabrInfo) stream.getDeliveryMethodInfo()).getFormats()) { + if (candidate.getItag() == itag) { + format = candidate; + break; + } + } return format != null && format.getContentLength() > 0 ? format.getContentLength() : -1; } diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/LocalDomPoTokenProvider.kt b/app/src/main/java/org/schabi/newpipe/youtube/LocalDomPoTokenProvider.kt similarity index 83% rename from app/src/main/java/org/schabi/newpipe/player/datasource/LocalDomPoTokenProvider.kt rename to app/src/main/java/org/schabi/newpipe/youtube/LocalDomPoTokenProvider.kt index 302c688b0..f3965089b 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/LocalDomPoTokenProvider.kt +++ b/app/src/main/java/org/schabi/newpipe/youtube/LocalDomPoTokenProvider.kt @@ -1,6 +1,9 @@ -package org.schabi.newpipe.player.datasource +package org.schabi.newpipe.youtube import android.content.Context +import com.grack.nanojson.JsonObject +import com.grack.nanojson.JsonParser +import com.grack.nanojson.JsonWriter import org.schabi.newpipe.DownloaderImpl import org.schabi.newpipe.SharedWebViewRuntime import org.schabi.newpipe.extractor.ServiceList @@ -8,6 +11,7 @@ import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper import org.schabi.newpipe.extractor.services.youtube.sabr.exception.SabrProtocolException import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo import java.io.Closeable +import java.util.Base64 import java.util.HashMap import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit @@ -411,3 +415,84 @@ private fun jsonString(value: String): String { append('"') } } + +private data class SabrAttChallengeData( + val program: String, + val globalName: String, + val interpreterJavascript: String?, + val interpreterUrl: String?, +) + +private fun parseSabrAttChallengeData(rawAttestationData: String): SabrAttChallengeData { + val challenge = JsonParser.`object`().from(rawAttestationData).getObject("bgChallenge") + val interpreterJavascript = challenge.getObject("interpreterJavascript") + ?.getString("privateDoNotAccessOrElseSafeScriptWrappedValue") + ?.takeIf { it.isNotEmpty() } + val rawInterpreterUrl = challenge.getObject("interpreterUrl") + ?.getString("privateDoNotAccessOrElseTrustedResourceUrlWrappedValue") + ?.takeIf { it.isNotEmpty() } + val interpreterUrl = rawInterpreterUrl?.let { + if (it.startsWith("//")) "https:$it" else it + } + require(interpreterJavascript != null || interpreterUrl != null) { + "Attestation challenge has no interpreter script or URL" + } + return SabrAttChallengeData( + program = challenge.getString("program"), + globalName = challenge.getString("globalName"), + interpreterJavascript = interpreterJavascript, + interpreterUrl = interpreterUrl, + ) +} + +private fun buildSabrAttChallengeData( + challengeData: SabrAttChallengeData, + interpreterJavascript: String, +): String { + return JsonWriter.string( + JsonObject.builder() + .`object`("interpreterJavascript") + .value( + "privateDoNotAccessOrElseSafeScriptWrappedValue", + interpreterJavascript, + ) + .end() + .value("program", challengeData.program) + .value("globalName", challengeData.globalName) + .done(), + ) +} + +private fun parseSabrIntegrityTokenData(rawIntegrityTokenData: String): Pair { + val integrityTokenData = JsonParser.array().from(rawIntegrityTokenData) + return base64ToU8(integrityTokenData.getString(0)) to integrityTokenData.getLong(1) +} + +private fun stringToSabrU8(value: String): String { + return newUint8Array(value.toByteArray()) +} + +private fun csvU8ToByteArray(value: String): ByteArray { + if (value.isBlank()) { + return ByteArray(0) + } + return value.split(",").map { it.toUByte().toByte() }.toByteArray() +} + +private fun base64ToU8(base64: String): String { + return newUint8Array(base64ToByteArray(base64)) +} + +private fun newUint8Array(contents: ByteArray): String { + return "new Uint8Array([" + contents.joinToString(separator = ",") { + it.toUByte().toString() + } + "])" +} + +private fun base64ToByteArray(base64: String): ByteArray { + val normalized = base64 + .replace('-', '+') + .replace('_', '/') + .replace('.', '=') + return Base64.getDecoder().decode(normalized) +} diff --git a/app/src/main/java/us/shandian/giga/get/SabrDownloadFormatResolver.kt b/app/src/main/java/us/shandian/giga/get/SabrDownloadFormatResolver.kt index 309574325..cfbb6cf80 100644 --- a/app/src/main/java/us/shandian/giga/get/SabrDownloadFormatResolver.kt +++ b/app/src/main/java/us/shandian/giga/get/SabrDownloadFormatResolver.kt @@ -24,7 +24,7 @@ internal object SabrDownloadFormatResolver { } else { null } - ?: info.findBestAudioFormat() + ?: info.formats.filter { it.isAudio }.maxByOrNull { it.bitrate } ?: throw SabrDownloadException( SabrDownloadException.Reason.FORMAT, "SABR download failed: missing audio format", @@ -43,7 +43,7 @@ internal object SabrDownloadFormatResolver { } else { null } - ?: info.findLowestVideoFormat() + ?: findLightweightVideoFormat(info) ?: throw SabrDownloadException( SabrDownloadException.Reason.FORMAT, "SABR download failed: missing video format", diff --git a/app/src/main/java/us/shandian/giga/get/SabrDownloader.kt b/app/src/main/java/us/shandian/giga/get/SabrDownloader.kt index 94f8e1ecd..c31c3fe9e 100644 --- a/app/src/main/java/us/shandian/giga/get/SabrDownloader.kt +++ b/app/src/main/java/us/shandian/giga/get/SabrDownloader.kt @@ -2,12 +2,11 @@ package us.shandian.giga.get import android.util.Log import org.schabi.newpipe.BuildConfig -import org.schabi.newpipe.extractor.localization.Localization import org.schabi.newpipe.extractor.services.youtube.sabr.exception.SabrProtocolException import org.schabi.newpipe.extractor.services.youtube.sabr.exception.SabrRecoverableException import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession -import org.schabi.newpipe.player.datasource.LocalDomPoTokenProvider +import org.schabi.newpipe.youtube.LocalDomPoTokenProvider import java.io.File import java.io.FileOutputStream import java.io.IOException @@ -277,14 +276,12 @@ internal class SabrDownloader( writer: SabrSegmentWriter, poToken: ByteArray, ) { - val localization = Localization("en", "US") writer.observeWrittenInitializations() - prepareInitializations(session, targets, writer, localization, poToken) + prepareInitializations(session, targets, writer, poToken) writer.observeWrittenInitializations() var emptyResponses = 0 var nextRequestAtMs = 0L - var bandwidthEstimate = -1L while (true) { ensureRunning() val backoffRemainingMs = nextRequestAtMs - System.currentTimeMillis() @@ -301,7 +298,6 @@ internal class SabrDownloader( val audio = targets.firstOrNull { it.format.isAudio } val video = targets.firstOrNull { it.format.isVideo } val requestResult = session.requestOnce( - localization, playerTimeMs, audio?.timeline, (audio?.nextWriteSequence ?: 1) - 1, @@ -310,16 +306,13 @@ internal class SabrDownloader( audio != null, video != null, false, - bandwidthEstimate, 1.0f, - poToken, writer::acceptSegment, ) - if (requestResult.bandwidthSample > 0) { - bandwidthEstimate = if (bandwidthEstimate <= 0) requestResult.bandwidthSample - else (bandwidthEstimate * 3 + requestResult.bandwidthSample) / 4 - } nextRequestAtMs = System.currentTimeMillis() + requestResult.backoffMs + if (requestResult.isDeferred) { + continue + } val segmentCount = requestResult.segmentCount writer.observeWrittenInitializations() if (isDownloadComplete(targets)) { @@ -345,7 +338,6 @@ internal class SabrDownloader( session: YoutubeSabrSession, targets: List, writer: SabrSegmentWriter, - localization: Localization, poToken: ByteArray, ) { val pendingTargets = targets.filterNot { it.initializationWritten } @@ -355,7 +347,7 @@ internal class SabrDownloader( try { ensureRunning() - val initialization = session.initialize(localization, 2_000, poToken) + val initialization = session.initialize(2_000, poToken) for (target in pendingTargets) { val data = if (target.format.isAudio) { initialization.audioData From 7ee158b24b5f540b26c77cbdd0c96a86bfdbcbef Mon Sep 17 00:00:00 2001 From: InfinityLoop1308 <96324692+InfinityLoop1308@users.noreply.github.com> Date: Wed, 5 Aug 2026 08:40:42 +0800 Subject: [PATCH 05/13] 5 --- .../org/schabi/newpipe/player/Player.java | 17 +---- .../player/SabrBackoffCoordinator.java | 71 ++++++++----------- .../datasource/SabrDashMediaSource.java | 4 +- .../player/datasource/SabrMediaBridge.java | 20 +++++- .../newpipe/player/helper/LoadController.java | 32 ++------- .../resolver/VideoPlaybackResolver.java | 1 - 6 files changed, 61 insertions(+), 84 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java index 8eb312b36..6a42deebf 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -1854,10 +1854,6 @@ private void onUpdateProgress(final int currentProgress, return; } - // Feed the real play head to any live SABR session (no-op otherwise). - getCurrentStreamInfo().ifPresent(info -> { - }); - if (duration != binding.playbackSeekBar.getMax()) { setVideoDurationToControls(duration); } @@ -2833,7 +2829,7 @@ private void updateSabrBackoffCountdown() { return; } final long remainingMs = SabrBackoffCoordinator.getInstance().getRemainingMs(); - if (remainingMs <= 0L) { + if (!fragmentIsVisible || remainingMs <= 0L) { binding.sabrBackoffCountdown.setVisibility(View.GONE); return; } @@ -3236,9 +3232,6 @@ public void onPlayerError(@NonNull final PlaybackException error) { saveStreamProgressState(); boolean isCatchableException = false; - final boolean sabrSessionInvalidated = error.getCause() != null - && error.getCause().getMessage() != null - && error.getCause().getMessage().startsWith("SABR session invalidated"); switch (error.errorCode) { case ERROR_CODE_BEHIND_LIVE_WINDOW: @@ -3285,9 +3278,6 @@ public void onPlayerError(@NonNull final PlaybackException error) { case ERROR_CODE_IO_NETWORK_CONNECTION_FAILED: case ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT: case ERROR_CODE_UNSPECIFIED: - if (sabrSessionInvalidated) { - isCatchableException = true; - } setRecovery(); reloadPlayQueueManager(); break; @@ -5312,9 +5302,8 @@ private void useVideoSource(final boolean videoEnabled) { final SourceType sourceType = videoResolver.getStreamSourceType().orElse( SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY); - // For SABR, a play queue manager reload stops the player and releases the current media - // source. Releasing the last SABR source reference also evicts its session, so background / - // foreground video toggles must keep the live source and only update track selection. + // A SABR source already exposes both audio and video, so background / foreground video + // toggles only need to update Media3 track selection instead of rebuilding the source. if (!isCurrentStreamSabr() && playQueueManagerReloadingNeeded(sourceType, info, getVideoRendererIndex())) { reloadPlayQueueManager(); diff --git a/app/src/main/java/org/schabi/newpipe/player/SabrBackoffCoordinator.java b/app/src/main/java/org/schabi/newpipe/player/SabrBackoffCoordinator.java index 508638b9f..f85716a42 100644 --- a/app/src/main/java/org/schabi/newpipe/player/SabrBackoffCoordinator.java +++ b/app/src/main/java/org/schabi/newpipe/player/SabrBackoffCoordinator.java @@ -15,6 +15,10 @@ import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.R; +import java.util.IdentityHashMap; +import java.util.Iterator; +import java.util.Map; + /** Publishes the SABR server-wait state independently from the media notification. */ public final class SabrBackoffCoordinator { public static final long NO_DEADLINE = -1L; @@ -25,10 +29,8 @@ public final class SabrBackoffCoordinator { private final Handler handler = new Handler(Looper.getMainLooper()); private final Runnable updateTask = this::updateNotification; private Context appContext; - private Object owner; - private long deadlineElapsedMs = NO_DEADLINE; + private final Map deadlinesByOwner = new IdentityHashMap<>(); private boolean playerBuffering; - private boolean playbackBlockedBeforeBuffering; private SabrBackoffCoordinator() { } @@ -40,48 +42,27 @@ public static SabrBackoffCoordinator getInstance() { public synchronized void begin(@NonNull final Context context, @NonNull final Object sourceOwner, - final long deadlineMs) { - begin(context, sourceOwner, deadlineMs, false); - } - - public synchronized void beginPlaybackWait(@NonNull final Context context, - @NonNull final Object sourceOwner, - final long deadlineMs) { - begin(context, sourceOwner, deadlineMs, true); - } - - private synchronized void begin(@NonNull final Context context, - @NonNull final Object sourceOwner, - final long deadlineMs, - final boolean blocksPlaybackBeforeBuffering) { - if (deadlineMs <= SystemClock.elapsedRealtime()) { + final long remainingMs) { + if (remainingMs <= 0L) { clear(context, sourceOwner); return; } appContext = context.getApplicationContext(); - if (owner != sourceOwner) { - owner = sourceOwner; - deadlineElapsedMs = deadlineMs; - playbackBlockedBeforeBuffering = blocksPlaybackBeforeBuffering; - } else { - deadlineElapsedMs = Math.max(deadlineElapsedMs, deadlineMs); - playbackBlockedBeforeBuffering |= blocksPlaybackBeforeBuffering; - } + deadlinesByOwner.put(sourceOwner, SystemClock.elapsedRealtime() + remainingMs); handler.removeCallbacks(updateTask); updateNotification(); } public synchronized void clear(@NonNull final Context context, @NonNull final Object sourceOwner) { - if (owner != sourceOwner) { - return; - } - owner = null; - deadlineElapsedMs = NO_DEADLINE; - playbackBlockedBeforeBuffering = false; + deadlinesByOwner.remove(sourceOwner); handler.removeCallbacks(updateTask); - NotificationManagerCompat.from(context.getApplicationContext()) - .cancel(NOTIFICATION_ID); + if (getRemainingMs() > 0L && playerBuffering) { + updateNotification(); + } else { + NotificationManagerCompat.from(context.getApplicationContext()) + .cancel(NOTIFICATION_ID); + } } public synchronized void setPlayerBuffering(@NonNull final Context context, @@ -89,7 +70,7 @@ public synchronized void setPlayerBuffering(@NonNull final Context context, appContext = context.getApplicationContext(); playerBuffering = buffering; handler.removeCallbacks(updateTask); - if (buffering || playbackBlockedBeforeBuffering) { + if (buffering) { updateNotification(); } else { NotificationManagerCompat.from(appContext).cancel(NOTIFICATION_ID); @@ -97,8 +78,18 @@ public synchronized void setPlayerBuffering(@NonNull final Context context, } public synchronized long getRemainingMs() { - return deadlineElapsedMs == NO_DEADLINE - ? 0L : Math.max(0L, deadlineElapsedMs - SystemClock.elapsedRealtime()); + final long now = SystemClock.elapsedRealtime(); + long latestDeadline = NO_DEADLINE; + final Iterator> iterator = deadlinesByOwner.entrySet().iterator(); + while (iterator.hasNext()) { + final long deadline = iterator.next().getValue(); + if (deadline <= now) { + iterator.remove(); + } else { + latestDeadline = Math.max(latestDeadline, deadline); + } + } + return latestDeadline == NO_DEADLINE ? 0L : latestDeadline - now; } public synchronized boolean isWaiting() { @@ -111,16 +102,12 @@ static int remainingSeconds(final long remainingMs) { @SuppressLint("MissingPermission") private synchronized void updateNotification() { - if (appContext == null || deadlineElapsedMs == NO_DEADLINE - || (!playerBuffering && !playbackBlockedBeforeBuffering)) { + if (appContext == null || !playerBuffering) { return; } final long remainingMs = getRemainingMs(); if (remainingMs <= 0L) { final Context context = appContext; - owner = null; - deadlineElapsedMs = NO_DEADLINE; - playbackBlockedBeforeBuffering = false; NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID); return; } diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDashMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDashMediaSource.java index bcf4ea688..2452397ec 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDashMediaSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDashMediaSource.java @@ -49,6 +49,7 @@ public final class SabrDashMediaSource extends CompositeMediaSource { private static final long END_SEEK_BACKOFF_US = 1_000L; private final MediaItem mediaItem; + private final Context context; private final SabrSourceSpec spec; private final YoutubeSabrSession session; @Nullable private SabrMediaBridge bridge; @@ -57,6 +58,7 @@ public final class SabrDashMediaSource extends CompositeMediaSource { public SabrDashMediaSource(@NonNull final Context context, @NonNull final MediaItem mediaItem, @NonNull final SabrSourceSpec spec) throws IOException { + this.context = context.getApplicationContext(); this.mediaItem = mediaItem; this.spec = spec; try { @@ -139,7 +141,7 @@ private DataSource createDataSource() { @NonNull private synchronized SabrMediaBridge getOrCreateBridge() { if (bridge == null) { - bridge = new SabrMediaBridge(session, spec); + bridge = new SabrMediaBridge(context, session, spec); bridge.seedSegments(spec.takeBootstrapMediaSegments()); } return bridge; diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaBridge.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaBridge.java index 055d6f315..847e8f225 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaBridge.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaBridge.java @@ -1,5 +1,7 @@ package org.schabi.newpipe.player.datasource; +import android.content.Context; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -8,6 +10,7 @@ import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession; import org.schabi.newpipe.extractor.services.youtube.sabr.media.SabrMediaSegment; +import org.schabi.newpipe.player.SabrBackoffCoordinator; import java.io.IOException; import java.io.InterruptedIOException; @@ -28,6 +31,7 @@ final class SabrMediaBridge { private final YoutubeSabrSession session; private final SabrSourceSpec spec; + private final Context appContext; private final YoutubeSabrInfo.Format videoFormat; private final YoutubeSabrFormatTimeline audioTimeline; private final YoutubeSabrFormatTimeline videoTimeline; @@ -41,8 +45,10 @@ final class SabrMediaBridge { private volatile boolean stopped; @Nullable private volatile Thread requestThread; - SabrMediaBridge(@NonNull final YoutubeSabrSession session, + SabrMediaBridge(@NonNull final Context context, + @NonNull final YoutubeSabrSession session, @NonNull final SabrSourceSpec spec) { + appContext = context.getApplicationContext(); this.session = session; this.spec = spec; videoFormat = spec.getVideoFormat(); @@ -69,6 +75,7 @@ byte[] fetchInitialization(@NonNull final YoutubeSabrInfo.Format format, ensureBudget(SabrSegmentKey.initialization(format), deadlineNs))); data = session.fetchInitializationData(format, remainingMs, segment -> acceptSegment(segment, format.isAudio() ? format : null)); + publishBackoff(session.getBackoffRemainingMs()); ensureBudget(SabrSegmentKey.initialization(format), deadlineNs); spec.putInitializationData(format, data); return data; @@ -122,6 +129,7 @@ SabrMediaSegment fetchSegment(@NonNull final SabrSegmentKey request, videoTimeline, bufferedThrough(videoFormat), audioActive, videoActive, videoActive && !audioActive, 1.0f, received -> acceptSegment(received, activeAudio)); + publishBackoff(result.getBackoffMs()); if (result.isDeferred()) continue; segment = ahead.get(request); @@ -157,6 +165,7 @@ void discard(@NonNull final SabrSegmentKey request) { void stop() { stopped = true; + SabrBackoffCoordinator.getInstance().clear(appContext, this); final Thread current = requestThread; if (current != null) current.interrupt(); for (final SabrMediaSegment segment : ahead.values()) segment.delete(); @@ -170,6 +179,7 @@ private void awaitBackoffWithinBudget(@NonNull final SabrSegmentKey request, final long deadlineNs) throws IOException { while (true) { final long backoffMs = session.getBackoffRemainingMs(); + publishBackoff(backoffMs); if (backoffMs <= 0) return; final long remainingNs = ensureBudget(request, deadlineNs); if (TimeUnit.MILLISECONDS.toNanos(backoffMs) >= remainingNs) { @@ -179,6 +189,14 @@ private void awaitBackoffWithinBudget(@NonNull final SabrSegmentKey request, } } + private void publishBackoff(final long remainingMs) { + if (remainingMs > 0L) { + SabrBackoffCoordinator.getInstance().begin(appContext, this, remainingMs); + } else { + SabrBackoffCoordinator.getInstance().clear(appContext, this); + } + } + private void sleepWithinBudget(@NonNull final SabrSegmentKey request, final long deadlineNs, final long requestedMs) throws IOException { diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java b/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java index b22330bcb..faa68b0e3 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java @@ -12,38 +12,20 @@ public class LoadController extends DefaultLoadControl { private static final int MAX_BUFFER_MS = 20_000; private static final int BUFFER_FOR_PLAYBACK_MS = 2_000; private static final int BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS = 3_000; - private static final int SABR_MIN_BUFFER_MS = 5_000; - private static final int SABR_MAX_BUFFER_MS = 8_000; - private static final int SABR_BUFFER_FOR_PLAYBACK_MS = 1_000; - private static final int SABR_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS = 2_000; private boolean preloadingEnabled = true; public LoadController() { - this(MIN_BUFFER_MS, MAX_BUFFER_MS, BUFFER_FOR_PLAYBACK_MS, - BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS); - } - - public static LoadController forSabr() { - return new LoadController(SABR_MIN_BUFFER_MS, SABR_MAX_BUFFER_MS, - SABR_BUFFER_FOR_PLAYBACK_MS, SABR_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS); - } - - private LoadController(final int minBufferMs, - final int maxBufferMs, - final int bufferForPlaybackMs, - final int bufferForPlaybackAfterRebufferMs) { // media3 1.10 split every buffer param into a normal + a "ForLocalPlayback" variant; we use // the same value for both so behaviour is unchanged whether the source is local or remote. super(new DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE), - minBufferMs, minBufferMs, - maxBufferMs, maxBufferMs, - bufferForPlaybackMs, bufferForPlaybackMs, - bufferForPlaybackAfterRebufferMs, bufferForPlaybackAfterRebufferMs, - C.LENGTH_UNSET, // no byte cap: the SABR cache bounds memory, time bounds the player - // MUST be true: with false, media3 prioritises its (huge, ~128MB) default byte target - // and ignores maxBufferMs, reading ~50s ahead = right at the pump cushion, so it - // starved at the edge. true makes maxBufferMs (time) the real limit. + MIN_BUFFER_MS, MIN_BUFFER_MS, + MAX_BUFFER_MS, MAX_BUFFER_MS, + BUFFER_FOR_PLAYBACK_MS, BUFFER_FOR_PLAYBACK_MS, + BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS, + BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS, + C.LENGTH_UNSET, + // Prioritize the configured time thresholds over Media3's allocator byte target. true, true, DEFAULT_BACK_BUFFER_DURATION_MS, DEFAULT_RETAIN_BACK_BUFFER_FROM_KEYFRAME); diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java index 0f952a7b4..0d24ba3fa 100644 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java @@ -19,7 +19,6 @@ import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.extractor.stream.SubtitlesStream; import org.schabi.newpipe.extractor.stream.VideoStream; -import org.schabi.newpipe.player.datasource.SabrSessionStore; import org.schabi.newpipe.player.helper.PlayerDataSource; import org.schabi.newpipe.player.helper.PlayerHelper; import org.schabi.newpipe.player.mediaitem.MediaItemTag; From f9f81d89e5216e59c9505e035cdb6d315c480021 Mon Sep 17 00:00:00 2001 From: InfinityLoop1308 <96324692+InfinityLoop1308@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:01:59 +0800 Subject: [PATCH 06/13] 6 --- .../player/datasource/SabrDashMediaSource.java | 18 ++++++++++++++++-- .../datasource/SabrSegmentDataSource.java | 5 +++-- .../player/datasource/SabrSegmentKey.java | 8 ++++++++ .../newpipe/player/helper/CacheFactory.java | 16 +++++++++++++--- .../player/helper/PlayerDataSource.java | 15 +++++++++++++++ .../player/resolver/PlaybackResolver.java | 7 ++++--- .../us/shandian/giga/get/SabrSegmentWriter.kt | 5 ++++- 7 files changed, 63 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDashMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDashMediaSource.java index 2452397ec..4e55622bd 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDashMediaSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDashMediaSource.java @@ -16,6 +16,7 @@ import androidx.media3.common.StreamKey; import androidx.media3.common.Timeline; import androidx.media3.datasource.DataSource; +import androidx.media3.datasource.DataSpec; import androidx.media3.datasource.TransferListener; import androidx.media3.exoplayer.LoadingInfo; import androidx.media3.exoplayer.SeekParameters; @@ -32,6 +33,7 @@ import androidx.media3.exoplayer.upstream.Allocator; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormatTimeline; +import org.schabi.newpipe.player.helper.PlayerDataSource; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -57,7 +59,8 @@ public final class SabrDashMediaSource extends CompositeMediaSource { private final DashMediaSource childSource; public SabrDashMediaSource(@NonNull final Context context, @NonNull final MediaItem mediaItem, - @NonNull final SabrSourceSpec spec) throws IOException { + @NonNull final SabrSourceSpec spec, + @NonNull final PlayerDataSource playerDataSource) throws IOException { this.context = context.getApplicationContext(); this.mediaItem = mediaItem; this.spec = spec; @@ -70,7 +73,8 @@ public SabrDashMediaSource(@NonNull final Context context, final long durationMs = spec.getDurationMs(); this.durationUs = durationMs > 0 ? durationMs * 1000L : C.TIME_UNSET; final DataSource.Factory sabrDataSourceFactory = - this::createDataSource; + playerDataSource.getCacheDataSourceFactory( + this::createDataSource, this::buildCacheKey); final DashManifest manifest = buildManifest(spec, durationMs); this.childSource = new DashMediaSource.Factory( new DefaultDashChunkSource.Factory(sabrDataSourceFactory), @@ -147,6 +151,16 @@ private synchronized SabrMediaBridge getOrCreateBridge() { return bridge; } + @NonNull + private String buildCacheKey(@NonNull final DataSpec dataSpec) { + try { + return SabrSegmentDataSource.requestFromUri(spec, dataSpec.uri) + .getCacheKey(spec.getVideoId()); + } catch (final IOException error) { + throw new IllegalArgumentException("Bad SABR cache URI: " + dataSpec.uri, error); + } + } + private static DashManifest buildManifest(final SabrSourceSpec spec, final long durationMs) throws IOException { diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java index 5d5d78c12..3c3dc1c09 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java @@ -49,7 +49,7 @@ public long open(final DataSpec dataSpec) throws IOException { data = null; position = (int) Math.max(0, dataSpec.position); - final SabrSegmentKey request = requestFromUri(dataSpec.uri); + final SabrSegmentKey request = requestFromUri(spec, dataSpec.uri); openedRequest = request; final int totalBytes; final long available; @@ -113,7 +113,8 @@ public int read(final byte[] target, final int offset, final int length) throws return count; } - private SabrSegmentKey requestFromUri(final Uri value) throws IOException { + static SabrSegmentKey requestFromUri(final SabrSourceSpec spec, + final Uri value) throws IOException { final String host = value.getHost(); final String segment = value.getLastPathSegment(); if (host == null || segment == null) { diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentKey.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentKey.java index 2dfe0ffaa..d6bf12456 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentKey.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentKey.java @@ -36,6 +36,14 @@ static SabrSegmentKey media(@NonNull final YoutubeSabrInfo.Format format, boolean isInitialization() { return initialization; } int getSequenceNumber() { return sequenceNumber; } + @NonNull + String getCacheKey(@NonNull final String videoId) { + final String xtags = Objects.toString(format.getXtags(), ""); + return "sabr:" + videoId + ':' + format.getItag() + ':' + format.getLastModified() + + ':' + xtags.length() + ':' + xtags + ':' + + (initialization ? "init" : sequenceNumber); + } + @Override public boolean equals(final Object other) { if (this == other) return true; diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java b/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java index e1e802e77..d5a578e65 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java @@ -15,6 +15,7 @@ import androidx.media3.datasource.TransferListener; import androidx.media3.datasource.cache.CacheDataSink; import androidx.media3.datasource.cache.CacheDataSource; +import androidx.media3.datasource.cache.CacheKeyFactory; import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor; import androidx.media3.datasource.cache.SimpleCache; @@ -37,12 +38,14 @@ private final String userAgent; private final TransferListener transferListener; private final DataSource.Factory upstreamDataSourceFactory; + @Nullable private final CacheKeyFactory cacheKeyFactory; public static class Builder { private final Context context; private final String userAgent; private final TransferListener transferListener; private DataSource.Factory upstreamDataSourceFactory; + @Nullable private CacheKeyFactory cacheKeyFactory; Builder(@NonNull final Context context, @NonNull final String userAgent, @@ -57,20 +60,26 @@ public void setUpstreamDataSourceFactory( this.upstreamDataSourceFactory = upstreamDataSourceFactory; } + public void setCacheKeyFactory(@Nullable final CacheKeyFactory cacheKeyFactory) { + this.cacheKeyFactory = cacheKeyFactory; + } + public CacheFactory build() { return new CacheFactory(context, userAgent, transferListener, - upstreamDataSourceFactory); + upstreamDataSourceFactory, cacheKeyFactory); } } private CacheFactory(@NonNull final Context context, @NonNull final String userAgent, @NonNull final TransferListener transferListener, - @Nullable final DataSource.Factory upstreamDataSourceFactory) { + @Nullable final DataSource.Factory upstreamDataSourceFactory, + @Nullable final CacheKeyFactory cacheKeyFactory) { this.context = context; this.userAgent = userAgent; this.transferListener = transferListener; this.upstreamDataSourceFactory = upstreamDataSourceFactory; + this.cacheKeyFactory = cacheKeyFactory; final File cacheDir = new File(context.getExternalCacheDir(), CACHE_FOLDER_NAME); if (!cacheDir.exists()) { @@ -126,7 +135,8 @@ public DataSource createDataSource() { final FileDataSource fileSource = new FileDataSource(); final CacheDataSink dataSink = new CacheDataSink(cache, maxFileSize); - return new CacheDataSource(cache, dataSource, fileSource, dataSink, CACHE_FLAGS, null); + return new CacheDataSource(cache, dataSource, fileSource, dataSink, CACHE_FLAGS, null, + cacheKeyFactory); } private static void clearCacheFolderLock(File cacheDir) { diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java index 3094ec153..eca224f4b 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java @@ -23,6 +23,7 @@ import androidx.media3.datasource.TransferListener; import androidx.media3.datasource.okhttp.OkHttpDataSource; import androidx.media3.datasource.cache.CacheDataSource; +import androidx.media3.datasource.cache.CacheKeyFactory; import com.grack.nanojson.JsonObject; import com.grack.nanojson.JsonParser; import com.grack.nanojson.JsonParserException; @@ -83,6 +84,7 @@ public class PlayerDataSource { private final DataSource.Factory biliCachelessDataSourceFactory; private final TransferListener transferListener; private final Context context; + private final String userAgent; private NicoWebSocketClient nicoWebSocketClient; @@ -103,6 +105,7 @@ public PlayerDataSource(@NonNull final Context context, this.context = context; this.transferListener = transferListener; + this.userAgent = userAgent; YoutubeProgressiveDashManifestCreator.getCache().setMaximumSize( MAXIMUM_SIZE_CACHED_GENERATED_MANIFESTS_PER_CACHE); @@ -172,6 +175,18 @@ public DashMediaSource.Factory getDashMediaSourceFactory() { cacheDataSourceFactoryBuilder.build()); } + /** Wraps a custom playback source in the same disk cache used by other media sources. */ + @NonNull + public DataSource.Factory getCacheDataSourceFactory( + @NonNull final DataSource.Factory upstreamDataSourceFactory, + @NonNull final CacheKeyFactory cacheKeyFactory) { + final CacheFactory.Builder builder = new CacheFactory.Builder( + context, userAgent, transferListener); + builder.setUpstreamDataSourceFactory(upstreamDataSourceFactory); + builder.setCacheKeyFactory(cacheKeyFactory); + return builder.build(); + } + public ProgressiveMediaSource.Factory getProgressiveMediaSourceFactory() { return new ProgressiveMediaSource.Factory(cachelessDataSourceFactory) .setContinueLoadingCheckIntervalBytes(continueLoadingCheckIntervalBytes); diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java index 47a40a0a6..a0e30a268 100644 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java @@ -451,7 +451,7 @@ private static MediaSource createYoutubeMediaSourceOfVideoStr .setCustomCacheKey(cacheKey) .build()); case SABR: - return buildSabrMediaSource(stream, streamInfo, cacheKey, metadata); + return buildSabrMediaSource(dataSource, stream, streamInfo, cacheKey, metadata); default: throw new IOException("Unsupported delivery method for YouTube contents: " + deliveryMethod); @@ -459,7 +459,8 @@ private static MediaSource createYoutubeMediaSourceOfVideoStr } @NonNull - private static MediaSource buildSabrMediaSource(@NonNull final Stream stream, + private static MediaSource buildSabrMediaSource(@NonNull final PlayerDataSource dataSource, + @NonNull final Stream stream, @NonNull final StreamInfo streamInfo, @NonNull final String cacheKey, @NonNull final MediaItemTag metadata) @@ -480,7 +481,7 @@ private static MediaSource buildSabrMediaSource(@NonNull final Stream stream, .setUri(Uri.parse("sabr://" + videoId)) .setCustomCacheKey(cacheKey) .build(); - return new SabrDashMediaSource(App.getApp(), mediaItem, spec); + return new SabrDashMediaSource(App.getApp(), mediaItem, spec, dataSource); } @Nullable diff --git a/app/src/main/java/us/shandian/giga/get/SabrSegmentWriter.kt b/app/src/main/java/us/shandian/giga/get/SabrSegmentWriter.kt index 296e5f139..abcad1dd6 100644 --- a/app/src/main/java/us/shandian/giga/get/SabrSegmentWriter.kt +++ b/app/src/main/java/us/shandian/giga/get/SabrSegmentWriter.kt @@ -12,7 +12,10 @@ internal class SabrSegmentWriter( ) { @Throws(IOException::class) fun acceptSegment(segment: SabrMediaSegment) { - val target = targets.firstOrNull { it.format.itag == segment.header.itag } + val target = targets.firstOrNull { + it.format.itag == segment.header.itag && + it.format.xtags == segment.header.xtags + } if (target == null) { segment.delete() return From 76cd2b1cdff1989b5a7d0b50136a6160f6266466 Mon Sep 17 00:00:00 2001 From: InfinityLoop1308 <96324692+InfinityLoop1308@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:20:12 +0800 Subject: [PATCH 07/13] 7 --- .../giga/get/SabrDownloadCheckpoint.kt | 3 +- .../giga/get/SabrDownloadFormatResolver.kt | 52 +----------- .../us/shandian/giga/get/SabrDownloader.kt | 80 +++++++++---------- 3 files changed, 42 insertions(+), 93 deletions(-) diff --git a/app/src/main/java/us/shandian/giga/get/SabrDownloadCheckpoint.kt b/app/src/main/java/us/shandian/giga/get/SabrDownloadCheckpoint.kt index 5c2560443..0dbf7c975 100644 --- a/app/src/main/java/us/shandian/giga/get/SabrDownloadCheckpoint.kt +++ b/app/src/main/java/us/shandian/giga/get/SabrDownloadCheckpoint.kt @@ -8,13 +8,14 @@ data class SabrDownloadCheckpoint( ) : Serializable { companion object { private const val serialVersionUID = 1L - const val VERSION = 1 + const val VERSION = 2 } } data class SabrResourceCheckpoint( val resourceIndex: Int, val itag: Int, + val xtags: String?, val tempFilePath: String, val nextWriteSequence: Int, val bytesWritten: Long, diff --git a/app/src/main/java/us/shandian/giga/get/SabrDownloadFormatResolver.kt b/app/src/main/java/us/shandian/giga/get/SabrDownloadFormatResolver.kt index cfbb6cf80..6a43874de 100644 --- a/app/src/main/java/us/shandian/giga/get/SabrDownloadFormatResolver.kt +++ b/app/src/main/java/us/shandian/giga/get/SabrDownloadFormatResolver.kt @@ -16,38 +16,18 @@ internal object SabrDownloadFormatResolver { fun selectedAudioFormat( info: YoutubeSabrInfo, recoveries: Array, - ): YoutubeSabrInfo.Format { + ): YoutubeSabrInfo.Format? { val audioRecovery = recoveries.firstOrNull { it.kind == 'a' } return audioRecovery?.let { findAudioFormat(info, it) } - ?: if (recoveries.any { it.kind == 'v' }) { - findLightweightAudioFormat(info) - } else { - null - } - ?: info.formats.filter { it.isAudio }.maxByOrNull { it.bitrate } - ?: throw SabrDownloadException( - SabrDownloadException.Reason.FORMAT, - "SABR download failed: missing audio format", - ) } @Throws(IOException::class) fun selectedVideoFormat( info: YoutubeSabrInfo, recoveries: Array, - ): YoutubeSabrInfo.Format { + ): YoutubeSabrInfo.Format? { val videoRecovery = recoveries.firstOrNull { it.kind == 'v' } return videoRecovery?.let { findVideoFormat(info, it) } - ?: if (recoveries.any { it.kind == 'a' }) { - findLightweightVideoFormat(info) - } else { - null - } - ?: findLightweightVideoFormat(info) - ?: throw SabrDownloadException( - SabrDownloadException.Reason.FORMAT, - "SABR download failed: missing video format", - ) } @Throws(IOException::class) @@ -97,32 +77,4 @@ internal object SabrDownloadFormatResolver { ) } - private fun findLightweightAudioFormat(info: YoutubeSabrInfo): YoutubeSabrInfo.Format? { - return info.formats - .filter { it.isAudio } - .sortedWith( - compareBy { !it.isOriginalAudio } - .thenBy { it.isDrc } - .thenBy { normalizedBitrate(it) }, - ) - .firstOrNull() - } - - private fun findLightweightVideoFormat(info: YoutubeSabrInfo): YoutubeSabrInfo.Format? { - return info.formats - .filter { it.isVideo } - .sortedWith( - compareBy { normalizedHeight(it) } - .thenBy { normalizedBitrate(it) }, - ) - .firstOrNull() - } - - private fun normalizedBitrate(format: YoutubeSabrInfo.Format): Int { - return format.bitrate.takeIf { it > 0 } ?: Int.MAX_VALUE - } - - private fun normalizedHeight(format: YoutubeSabrInfo.Format): Int { - return format.height.takeIf { it > 0 } ?: Int.MAX_VALUE - } } diff --git a/app/src/main/java/us/shandian/giga/get/SabrDownloader.kt b/app/src/main/java/us/shandian/giga/get/SabrDownloader.kt index c31c3fe9e..7a83c45b0 100644 --- a/app/src/main/java/us/shandian/giga/get/SabrDownloader.kt +++ b/app/src/main/java/us/shandian/giga/get/SabrDownloader.kt @@ -22,7 +22,7 @@ internal class SabrDownloader( try { ensureRunning() val recoveries = validateRecoveryInfo() - var info = SabrDownloadFormatResolver.resolveInfo(recoveries) + val info = SabrDownloadFormatResolver.resolveInfo(recoveries) val expectedLength = recoveries.map { recovery -> when (recovery.kind) { @@ -36,13 +36,8 @@ internal class SabrDownloader( prepareMission(expectedLength) var coldStartAttempts = 0 var transientAttempts = 0 - var refreshInfo = false while (true) { try { - if (refreshInfo) { - info = SabrDownloadFormatResolver.resolveInfo(recoveries) - refreshInfo = false - } runSessionAttempt(info, recoveries) break } catch (error: RetryColdStartException) { @@ -55,7 +50,6 @@ internal class SabrDownloader( ) } logDebug("retry cold start attempt=$coldStartAttempts") - refreshInfo = true } catch (error: Exception) { if (!isRetryableAttemptFailure(error)) { throw error @@ -70,7 +64,6 @@ internal class SabrDownloader( transientAttempts++ logDebug("retry transient attempt=$transientAttempts error=${error.javaClass.simpleName}") Thread.sleep(transientRetryDelayMs(transientAttempts)) - refreshInfo = true } } } catch (error: InterruptedException) { @@ -94,6 +87,7 @@ internal class SabrDownloader( null, ) val poToken = LocalDomPoTokenProvider(mission.context).getPoToken(info) + session.setPoToken(poToken) val workDir = prepareWorkDirectory() val targets = SabrDownloadFormatResolver.buildTargets(info, recoveries, workDir) restoreTargets(targets) @@ -197,6 +191,7 @@ internal class SabrDownloader( val checkpoint = mission.sabrCheckpoint?.resources?.firstOrNull { it.resourceIndex == target.resourceIndex && it.itag == target.format.itag && + it.xtags == target.format.xtags && it.tempFilePath == target.file.absolutePath && it.nextWriteSequence > 0 && it.bytesWritten >= it.initializationBytes && @@ -248,6 +243,7 @@ internal class SabrDownloader( resources += SabrResourceCheckpoint( resourceIndex = target.resourceIndex, itag = target.format.itag, + xtags = target.format.xtags, tempFilePath = target.file.absolutePath, nextWriteSequence = target.nextWriteSequence, bytesWritten = target.file.length(), @@ -280,11 +276,10 @@ internal class SabrDownloader( prepareInitializations(session, targets, writer, poToken) writer.observeWrittenInitializations() - var emptyResponses = 0 - var nextRequestAtMs = 0L + var noProgressResponses = 0 while (true) { ensureRunning() - val backoffRemainingMs = nextRequestAtMs - System.currentTimeMillis() + val backoffRemainingMs = session.backoffRemainingMs if (backoffRemainingMs > 0) { Thread.sleep(backoffRemainingMs) ensureRunning() @@ -297,6 +292,7 @@ internal class SabrDownloader( val playerTimeMs = downloadPlayerTimeMs(targets) val audio = targets.firstOrNull { it.format.isAudio } val video = targets.firstOrNull { it.format.isVideo } + val sequencesBeforeRequest = targets.map { it.nextWriteSequence } val requestResult = session.requestOnce( playerTimeMs, audio?.timeline, @@ -309,26 +305,30 @@ internal class SabrDownloader( 1.0f, writer::acceptSegment, ) - nextRequestAtMs = System.currentTimeMillis() + requestResult.backoffMs if (requestResult.isDeferred) { continue } - val segmentCount = requestResult.segmentCount writer.observeWrittenInitializations() if (isDownloadComplete(targets)) { break } - if (segmentCount > 0) { - emptyResponses = 0 + val madeProgress = targets.indices.any { index -> + targets[index].nextWriteSequence > sequencesBeforeRequest[index] + } + if (madeProgress) { + noProgressResponses = 0 } else { - emptyResponses++ - if (emptyResponses > MAX_EMPTY_RESPONSES) { + noProgressResponses++ + if (noProgressResponses >= MAX_NO_PROGRESS_RESPONSES) { throw SabrDownloadException( SabrDownloadException.Reason.STALLED, - "SABR download stalled: no media received after $MAX_EMPTY_RESPONSES rounds", + "SABR download stalled: no target sequence advanced after " + + "$MAX_NO_PROGRESS_RESPONSES responses", ) } - Thread.sleep(IDLE_POLL_MS) + if (session.backoffRemainingMs <= 0) { + Thread.sleep(IDLE_POLL_MS) + } } } } @@ -345,27 +345,23 @@ internal class SabrDownloader( return } - try { - ensureRunning() - val initialization = session.initialize(2_000, poToken) - for (target in pendingTargets) { - val data = if (target.format.isAudio) { - initialization.audioData - } else { - initialization.videoData - } ?: throw RetryColdStartException() - target.timeline = if (target.format.isAudio) { - initialization.audioTimeline - } else { - initialization.videoTimeline - } ?: throw RetryColdStartException() - writer.writeInitializationData(target, data) - } - for (segment in initialization.mediaSegments) { - writer.acceptSegment(segment) - } - } catch (failure: IOException) { - throw RetryColdStartException(failure) + ensureRunning() + val initialization = session.initialize(2_000, poToken) + for (target in pendingTargets) { + val data = if (target.format.isAudio) { + initialization.audioData + } else { + initialization.videoData + } ?: throw RetryColdStartException() + target.timeline = if (target.format.isAudio) { + initialization.audioTimeline + } else { + initialization.videoTimeline + } ?: throw RetryColdStartException() + writer.writeInitializationData(target, data) + } + for (segment in initialization.mediaSegments) { + writer.acceptSegment(segment) } } @@ -459,7 +455,7 @@ internal class SabrDownloader( companion object { private const val TAG = "SabrDownloader" private const val IDLE_POLL_MS = 250L - private const val MAX_EMPTY_RESPONSES = 60 + private const val MAX_NO_PROGRESS_RESPONSES = 60 private const val MAX_COLD_START_RETRIES = 3 private const val MAX_TRANSIENT_RETRIES = 5 private const val MAX_TRANSIENT_RETRY_DELAY_MS = 5_000L @@ -485,5 +481,5 @@ internal class SabrDownloader( } } - private class RetryColdStartException(cause: Throwable? = null) : IOException(cause) + private class RetryColdStartException : IOException() } From 1057ac9fc63f4b8c68da6d0529cccba75220dece Mon Sep 17 00:00:00 2001 From: InfinityLoop1308 <96324692+InfinityLoop1308@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:17:46 +0800 Subject: [PATCH 08/13] 8 --- .../player/SabrBackoffCoordinatorTest.java | 161 -- .../newpipe/player/SabrPlaybackSmokeTest.java | 1305 ++++++----------- .../player/YoutubePlaybackBenchmarkTest.java | 207 +-- .../SabrSponsorBlockStallProbeTest.java | 462 ------ .../player/datasource/SabrSessionStore.java | 46 +- .../youtube/LocalDomPoTokenProvider.kt | 2 +- .../us/shandian/giga/get/SabrDownloader.kt | 36 +- .../SabrPreferredAudioLanguageTest.java | 73 - .../SabrSessionPoTokenPrewarmerTest.kt | 170 --- 9 files changed, 505 insertions(+), 1957 deletions(-) delete mode 100644 app/src/androidTest/java/org/schabi/newpipe/player/SabrBackoffCoordinatorTest.java delete mode 100644 app/src/androidTest/java/org/schabi/newpipe/player/datasource/SabrSponsorBlockStallProbeTest.java delete mode 100644 app/src/test/java/org/schabi/newpipe/player/datasource/SabrPreferredAudioLanguageTest.java delete mode 100644 app/src/test/java/org/schabi/newpipe/player/datasource/SabrSessionPoTokenPrewarmerTest.kt diff --git a/app/src/androidTest/java/org/schabi/newpipe/player/SabrBackoffCoordinatorTest.java b/app/src/androidTest/java/org/schabi/newpipe/player/SabrBackoffCoordinatorTest.java deleted file mode 100644 index 1448747c7..000000000 --- a/app/src/androidTest/java/org/schabi/newpipe/player/SabrBackoffCoordinatorTest.java +++ /dev/null @@ -1,161 +0,0 @@ -package org.schabi.newpipe.player; - -import android.app.Notification; -import android.app.NotificationManager; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.ServiceConnection; -import android.os.IBinder; -import android.os.SystemClock; -import android.service.notification.StatusBarNotification; -import android.view.View; - -import androidx.test.platform.app.InstrumentationRegistry; - -import org.schabi.newpipe.R; -import org.junit.After; -import org.junit.Test; - -import java.lang.reflect.Field; -import java.lang.reflect.Method; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; - -public class SabrBackoffCoordinatorTest { - private final Context context = InstrumentationRegistry.getInstrumentation() - .getTargetContext(); - private final Object owner = new Object(); - - @After - public void tearDown() { - SabrBackoffCoordinator.getInstance().setPlayerBuffering(context, false); - SabrBackoffCoordinator.getInstance().clear(context, owner); - } - - @Test - public void remainingSecondsRoundsUpUntilDeadline() { - assertEquals(0, SabrBackoffCoordinator.remainingSeconds(0)); - assertEquals(1, SabrBackoffCoordinator.remainingSeconds(1)); - assertEquals(1, SabrBackoffCoordinator.remainingSeconds(1_000)); - assertEquals(2, SabrBackoffCoordinator.remainingSeconds(1_001)); - assertEquals(8, SabrBackoffCoordinator.remainingSeconds(7_999)); - } - - @Test - public void bufferingBackoffUsesStandaloneNotificationAndClearsIt() { - final SabrBackoffCoordinator coordinator = SabrBackoffCoordinator.getInstance(); - coordinator.begin(context, owner, SystemClock.elapsedRealtime() + 5_000L); - coordinator.setPlayerBuffering(context, true); - - final StatusBarNotification notification = awaitNotification(true); - assertNotNull(notification); - assertEquals(SabrBackoffCoordinator.NOTIFICATION_ID, notification.getId()); - assertEquals(context.getString(R.string.sabr_backoff_notification_channel_id), - notification.getNotification().getChannelId()); - final CharSequence content = notification.getNotification().extras - .getCharSequence(Notification.EXTRA_TEXT); - assertNotNull(content); - assertTrue(content.toString().contains("YouTube")); - - coordinator.setPlayerBuffering(context, false); - assertNull(awaitNotification(false)); - } - - @Test - public void playbackWaitBackoffNotifiesBeforeMedia3StartsBuffering() { - final SabrBackoffCoordinator coordinator = SabrBackoffCoordinator.getInstance(); - coordinator.setPlayerBuffering(context, false); - coordinator.beginPlaybackWait( - context, owner, SystemClock.elapsedRealtime() + 5_000L); - - final StatusBarNotification notification = awaitNotification(true); - assertNotNull(notification); - assertEquals(SabrBackoffCoordinator.NOTIFICATION_ID, notification.getId()); - - coordinator.setPlayerBuffering(context, false); - assertNotNull("Media3 state updates must not hide an explicit playback wait", - awaitNotification(true)); - - coordinator.clear(context, owner); - assertNull(awaitNotification(false)); - } - - @Test - public void bufferingBackoffAppearsInThePlayerOverlay() throws Exception { - final SabrBackoffCoordinator coordinator = SabrBackoffCoordinator.getInstance(); - coordinator.begin(context, owner, SystemClock.elapsedRealtime() + 5_000L); - final CountDownLatch connected = new CountDownLatch(1); - final AtomicReference playerReference = new AtomicReference<>(); - final ServiceConnection connection = new ServiceConnection() { - @Override - public void onServiceConnected(final ComponentName name, final IBinder service) { - final PlayerBinderInterface binder = (PlayerBinderInterface) service; - playerReference.set(binder.getPlayer()); - connected.countDown(); - } - - @Override - public void onServiceDisconnected(final ComponentName name) { - } - }; - final Intent intent = new Intent(context, PlayerService.class); - context.startService(intent); - assertTrue("PlayerService did not connect", context.bindService(intent, connection, - Context.BIND_AUTO_CREATE)); - try { - assertTrue("PlayerService connection timed out", connected.await(10, TimeUnit.SECONDS)); - final Player player = playerReference.get(); - assertNotNull(player); - InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { - try { - final Field currentState = Player.class.getDeclaredField("currentState"); - currentState.setAccessible(true); - currentState.setInt(player, Player.STATE_BUFFERING); - final Method start = Player.class.getDeclaredMethod( - "startSabrBackoffCountdown"); - start.setAccessible(true); - start.invoke(player); - } catch (final Exception error) { - throw new AssertionError(error); - } - }); - assertEquals(View.VISIBLE, - player.getBinding().sabrBackoffCountdown.getVisibility()); - assertTrue(player.getBinding().sabrBackoffCountdown.getText().toString() - .contains("YouTube")); - } finally { - context.unbindService(connection); - context.stopService(intent); - coordinator.clear(context, owner); - } - } - - private StatusBarNotification awaitNotification(final boolean expected) { - for (int attempt = 0; attempt < 20; attempt++) { - final StatusBarNotification found = findBackoffNotification(); - if ((found != null) == expected) { - return found; - } - SystemClock.sleep(50L); - } - return findBackoffNotification(); - } - - private StatusBarNotification findBackoffNotification() { - final NotificationManager manager = (NotificationManager) context - .getSystemService(Context.NOTIFICATION_SERVICE); - for (final StatusBarNotification notification : manager.getActiveNotifications()) { - if (notification.getId() == SabrBackoffCoordinator.NOTIFICATION_ID) { - return notification; - } - } - return null; - } -} diff --git a/app/src/androidTest/java/org/schabi/newpipe/player/SabrPlaybackSmokeTest.java b/app/src/androidTest/java/org/schabi/newpipe/player/SabrPlaybackSmokeTest.java index 817fe2b6d..531cca4d3 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/player/SabrPlaybackSmokeTest.java +++ b/app/src/androidTest/java/org/schabi/newpipe/player/SabrPlaybackSmokeTest.java @@ -1,4 +1,4 @@ -package org.schabi.newpipe.player; +package org.schabi.newpipe.player.datasource; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertArrayEquals; @@ -50,7 +50,6 @@ import org.schabi.newpipe.extractor.services.youtube.sabr.media.SabrMediaSegment; import org.schabi.newpipe.extractor.services.youtube.sabr.SabrRequestDumper; import org.schabi.newpipe.extractor.services.youtube.sabr.protocol.SabrResponseDecoder; -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; import org.schabi.newpipe.extractor.services.youtube.ItagItem; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession; @@ -59,16 +58,14 @@ import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.stream.VideoStream; -import org.schabi.newpipe.player.datasource.SabrDashMediaSource; -import org.schabi.newpipe.player.datasource.SabrSegmentDataSource; +import org.schabi.newpipe.player.PlaybackStartupTrace; +import org.schabi.newpipe.player.SabrBackoffCoordinator; import org.schabi.newpipe.player.helper.LegacySubtitleRenderersFactory; import org.schabi.newpipe.player.helper.LoadController; import org.schabi.newpipe.player.helper.PlayerDataSource; import org.schabi.newpipe.player.resolver.AudioPlaybackResolver; import org.schabi.newpipe.player.resolver.QualityResolver; import org.schabi.newpipe.player.resolver.VideoPlaybackResolver; -import org.schabi.newpipe.player.datasource.SabrSessionStore; -import org.schabi.newpipe.player.datasource.SabrSourceSpec; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; @@ -124,7 +121,6 @@ public final class SabrPlaybackSmokeTest { private static final long DEFAULT_SEEK_POSITION_MS = (49 * 60 + 55) * 1000L; private static final long DEFAULT_LINEAR_PLAYBACK_MS = 3_000; private static final long DEFAULT_POST_SEEK_PLAYBACK_MS = 30_000; - private static final long DEFAULT_POST_REWIND_PLAYBACK_MS = 30_000; private static final long PREPARE_TIMEOUT_SECONDS = 150; private static final long PLAYBACK_TIMEOUT_SECONDS = 75; @@ -169,26 +165,6 @@ public void anonymousSequentialAudioCrossesSabrProtectionBoundaries() throws Exc } } - @Test - public void recoversMissingInitializationFromPump() throws Exception { - runSmokeCase(SmokeCase.missingInitialization()); - } - - @Test - public void recoversEvictedSegmentRewind() throws Exception { - runSmokeCase(SmokeCase.evictedRewind()); - } - - @Test - public void boundsReadAheadForStalledReader() throws Exception { - runSmokeCase(SmokeCase.stalledReader()); - } - - @Test - public void rewindClearsBufferedStateAndCookie() throws Exception { - runSmokeCase(SmokeCase.rewindState()); - } - @Test public void playbackIntoSponsorBlockSkipsToDuration() throws Exception { runSmokeCase(SmokeCase.sponsorBlockPlayback()); @@ -219,12 +195,11 @@ public void demandRepositionsAfterNonTargetMediaBatch() throws Exception { .bytes()); harness.openMediaSegment( - SabrSegmentRequest.media(harness.videoFormat, 3), 5_000); + SabrSegmentKey.media(harness.videoFormat, 3), 5_000); final String trace = harness.holder.session.getDiagnosticTrace(); - assertTrue("A non-target media batch did not force demand repositioning: " + trace, - trace.contains("pump_demand_reposition itag=" - + SMOKE_VIDEO_ITAG + " seq=3")); + assertTrue("A non-target media batch did not reach the target response: " + trace, + trace.contains("response n=2")); assertTrue("Expected initial, non-target, and repositioned target requests", harness.downloader.requestBodies.size() >= 3); final String repositionedRequest = SabrRequestDumper.summarize( @@ -252,15 +227,12 @@ public void companionOnlyResponseTriggersDemandRecovery() throws Exception { .bytes()); harness.openMediaSegment( - SabrSegmentRequest.media(harness.videoFormat, 3), 5_000); + SabrSegmentKey.media(harness.videoFormat, 3), 5_000); - final String trace = harness.holder.session.getDiagnosticTrace(); - assertTrue("Companion-only response did not record exact target omission: " + trace, - trace.contains("pump_demand_omission itag=" - + SMOKE_VIDEO_ITAG + " seq=3 omissions=1")); - assertTrue("Companion-only response did not trigger target recovery: " + trace, - trace.contains("pump_demand_reposition itag=" - + SMOKE_VIDEO_ITAG + " seq=3")); + assertEquals("Companion-only response did not trigger another SABR request", + 3, harness.downloader.requestBodies.size()); + assertEquals("Demand returned the wrong segment bytes", + 4, harness.getLastSegmentData().length); } } @@ -279,46 +251,10 @@ public void repeatedNonTargetMediaBatchesFailWithinDemandBudget() throws Excepti } harness.openMediaSegmentExpectFailure( - SabrSegmentRequest.media(harness.videoFormat, 3), 5_000); - - final String trace = harness.holder.session.getDiagnosticTrace(); - assertTrue("Demand did not record the bounded third target omission: " + trace, - trace.contains("pump_demand_omission itag=" - + SMOKE_VIDEO_ITAG + " seq=3 omissions=3")); - assertTrue("Demand exceeded its response budget plus one resumed prefetch: requests=" - + harness.downloader.requestBodies.size() + " trace=" + trace, - harness.downloader.requestBodies.size() <= 5); - } - } + SabrSegmentKey.media(harness.videoFormat, 3), 5_000); - @Test - public void activePrefetchDoesNotDeadZoneBelowSessionCacheLimit() throws Exception { - try (SabrSmokeHarness harness = SabrSmokeHarness.create()) { - final int firstSegmentBytes = 28 * 1024 * 1024; - final SabrSegmentRequest first = - SabrSegmentRequest.media(harness.videoFormat, 1); - final SabrSegmentRequest second = - SabrSegmentRequest.media(harness.videoFormat, 2); - harness.downloader.enqueue(new GeneratedLargeMediaResponse( - 1, SMOKE_VIDEO_ITAG, 1, 0, 5_000, firstSegmentBytes)); - harness.downloader.enqueue(new UmpFixture() - .segment(2, SMOKE_VIDEO_ITAG, 2, 5_000, 30_000) - .bytes()); - - harness.openMediaSegment(first, 30_000); - harness.setPlayerTimeMs(5_000); - final long deadlineNs = System.nanoTime() + TimeUnit.SECONDS.toNanos(3); - while (harness.holder.session.getCachedSegment(second) == null - && System.nanoTime() < deadlineNs) { - Thread.sleep(50); - } - - final String trace = harness.holder.session.getDiagnosticTrace(); - assertNotNull("Active prefetch stopped between the pump and session byte limits: " - + trace, - harness.holder.session.getCachedSegment(second)); - assertTrue("Active prefetch did not make a second request: " + trace, - harness.downloader.requestBodies.size() >= 2); + assertEquals("Demand did not stop after the queued non-target responses", + 5, harness.downloader.requestBodies.size()); } } @@ -336,20 +272,17 @@ public void demandHonorsFullServerBackoff() throws Exception { .bytes()); final long elapsedMs = harness.openMediaSegment( - SabrSegmentRequest.media(harness.videoFormat, 2), 6_000); + SabrSegmentKey.media(harness.videoFormat, 2), 6_000); - final String trace = harness.holder.session.getDiagnosticTrace(); - assertTrue("Demand path did not request the target segment: " + trace, - trace.contains("pump_demand itag=" + SMOKE_VIDEO_ITAG + " seq=2")); final List requestTimesMs = harness.downloader.requestTimesSnapshot(); assertTrue("Expected initial, policy-only, and target requests: " + requestTimesMs, requestTimesMs.size() >= 3); final long retryDelayMs = requestTimesMs.get(2) - requestTimesMs.get(1); assertTrue("Demand retry ignored the server backoff entirely: delayMs=" - + retryDelayMs + " trace=" + trace, + + retryDelayMs, retryDelayMs >= 2_800); assertTrue("Demand retry did not honor the full server backoff: elapsedMs=" - + elapsedMs + " trace=" + trace, elapsedMs < 5_000); + + elapsedMs, elapsedMs < 5_000); } } @@ -361,10 +294,9 @@ public void demandBackoffRemainsCancelableWithoutEarlyRequest() throws Exception harness.downloader.enqueue(new UmpFixture() .part(SabrResponseDecoder.NEXT_REQUEST_POLICY, nextRequestPolicy(3_000)) .bytes()); - final SabrSegmentRequest request = SabrSegmentRequest.media(harness.videoFormat, 2); + final SabrSegmentKey request = SabrSegmentKey.media(harness.videoFormat, 2); final SabrSegmentDataSource dataSource = new SabrSegmentDataSource( - harness.holder, harness.readerOwner, request.getFormat(), - new Localization("en", "US"), false); + harness.holder.spec, harness.holder.bridge); final AtomicReference failure = new AtomicReference<>(); final CountDownLatch done = new CountDownLatch(1); final Thread loader = new Thread(() -> { @@ -387,7 +319,7 @@ public void demandBackoffRemainsCancelableWithoutEarlyRequest() throws Exception assertTrue("Demand did not enter the server backoff: " + harness.holder.session.getDiagnosticTrace(), harness.holder.session.getBackoffRemainingMs() > 0); - harness.advanceReaderGeneration(); + harness.holder.bridge.stop(); completed = done.await(1_500, TimeUnit.MILLISECONDS); Thread.sleep(250); } finally { @@ -406,27 +338,6 @@ public void demandBackoffRemainsCancelableWithoutEarlyRequest() throws Exception } } - @Test - public void startupPumpDefersLongBackoffBeforeLoaderDemand() throws Exception { - try (SabrSmokeHarness harness = SabrSmokeHarness.create()) { - harness.downloader.enqueue(new UmpFixture() - .part(SabrResponseDecoder.NEXT_REQUEST_POLICY, nextRequestPolicy(30_000)) - .bytes()); - - final long startedAtMs = System.currentTimeMillis(); - assertEquals(0, harness.holder.session.pumpOnceStreamingForStartup( - new Localization("en", "US"))); - final long elapsedMs = System.currentTimeMillis() - startedAtMs; - final long remainingMs = harness.holder.session.getBackoffRemainingMs(); - - assertTrue("Startup pump blocked on the full server backoff: elapsedMs=" + elapsedMs, - elapsedMs < 1_000); - assertTrue("Startup pump did not retain a bounded pacing delay: remainingMs=" - + remainingMs, - remainingMs >= 1_500 && remainingMs <= 2_000); - } - } - @Test public void demandBackoffPublishesStandaloneNotificationWhileBuffering() throws Exception { final Context context = InstrumentationRegistry.getInstrumentation() @@ -449,7 +360,7 @@ public void demandBackoffPublishesStandaloneNotificationWhileBuffering() throws final Thread demand = new Thread(() -> { try { harness.openMediaSegment( - SabrSegmentRequest.media(harness.videoFormat, 2), 5_000); + SabrSegmentKey.media(harness.videoFormat, 2), 5_000); } catch (final Throwable error) { failure.set(error); } finally { @@ -473,57 +384,6 @@ public void demandBackoffPublishesStandaloneNotificationWhileBuffering() throws } } - @Test - public void pumpBackoffPublishesStandaloneNotificationWhileBuffering() throws Exception { - final Context context = InstrumentationRegistry.getInstrumentation() - .getTargetContext().getApplicationContext(); - final SabrBackoffCoordinator coordinator = SabrBackoffCoordinator.getInstance(); - coordinator.setPlayerBuffering(context, true); - try (SabrSmokeHarness harness = SabrSmokeHarness.create()) { - harness.downloader.enqueue(new UmpFixture() - .part(SabrResponseDecoder.NEXT_REQUEST_POLICY, nextRequestPolicy(30_000)) - .bytes()); - harness.downloader.enqueue(new UmpFixture() - .segment(1, SMOKE_VIDEO_ITAG, 1, 0, 30_000) - .bytes()); - - final AtomicReference failure = new AtomicReference<>(); - final CountDownLatch completed = new CountDownLatch(1); - final Thread reader = new Thread(() -> { - try { - harness.openMediaSegment( - SabrSegmentRequest.media(harness.videoFormat, 1), 5_000); - } catch (final Throwable error) { - failure.set(error); - } finally { - completed.countDown(); - } - }, "SabrPumpBackoffNotificationSmoke"); - reader.start(); - - final StatusBarNotification notification = awaitBackoffNotification(context, true); - assertNotNull("Initial SABR pump backoff did not publish its notification", - notification); - assertTrue("Pump completed before the backoff notification was observed", - completed.getCount() > 0); - assertTrue("SABR pump did not recover after the server backoff", - completed.await(5, TimeUnit.SECONDS)); - assertNull("SABR pump failed after the server backoff", failure.get()); - final List requestTimesMs = harness.downloader.requestTimesSnapshot(); - assertTrue("Expected policy-only and media requests: " + requestTimesMs, - requestTimesMs.size() >= 2); - assertTrue("SABR pump ignored the server backoff: " + requestTimesMs, - requestTimesMs.get(1) - requestTimesMs.get(0) >= 1_500); - assertTrue("Initial SABR pump honored the full 30 second backoff instead of the " - + "bounded startup wait: " + requestTimesMs, - requestTimesMs.get(1) - requestTimesMs.get(0) < 5_000); - assertNull("Backoff notification remained after the pump resumed", - awaitBackoffNotification(context, false)); - } finally { - coordinator.setPlayerBuffering(context, false); - } - } - @Test public void rejectedAttestationFailsWithoutEnteringBackoff() throws Exception { try (SabrSmokeHarness harness = SabrSmokeHarness.create()) { @@ -539,7 +399,7 @@ public void rejectedAttestationFailsWithoutEnteringBackoff() throws Exception { final long startMs = System.currentTimeMillis(); harness.openMediaSegmentExpectFailure( - SabrSegmentRequest.media(harness.videoFormat, 2), 5_000); + SabrSegmentKey.media(harness.videoFormat, 2), 5_000); final long elapsedMs = System.currentTimeMillis() - startMs; final String trace = harness.holder.session.getDiagnosticTrace(); @@ -564,16 +424,16 @@ public void pendingAttestationDoesNotReloadOrFail() throws Exception { nextRequestPolicy(2_000)) .bytes()); - final YoutubeSabrSession.DemandResponseResult result = + final YoutubeSabrSession.RequestResult result = harness.holder.session.pumpOnceStreamingForDemand( new Localization("en", "US"), - SabrSegmentRequest.media(harness.videoFormat, 1)); + SabrSegmentKey.media(harness.videoFormat, 1)); final String trace = harness.holder.session.getDiagnosticTrace(); assertTrue("Pending attestation response was not exercised: " + trace, trace.contains("protection=2/20")); assertTrue("Pending attestation did not return through normal response handling", - result.wasRequestPerformed()); + !result.isDeferred()); assertEquals("Pending attestation unexpectedly returned media", 0, result.getSegmentCount()); assertEquals("Pending attestation triggered an implicit retry", 1, @@ -598,19 +458,13 @@ public void nearEdgeServerBackoffsDoNotTriggerLocalRecovery() throws Exception { .bytes()); final long elapsedMs = harness.openMediaSegment( - SabrSegmentRequest.media(harness.videoFormat, 2), 15_000); + SabrSegmentKey.media(harness.videoFormat, 2), 15_000); - final String trace = harness.holder.session.getDiagnosticTrace(); - assertTrue("Near-edge server pacing response was not exercised: " + trace, - trace.contains("pump_demand_no_media itag=" + SMOKE_VIDEO_ITAG + " seq=2")); - assertTrue("Server-directed backoff incorrectly triggered local recovery: " + trace, - !trace.contains("recovery type=near_edge_refetch") - && !trace.contains("pump_rewind itag=" + SMOKE_VIDEO_ITAG + " seq=2")); assertTrue("Demand did not preserve the repeated server backoffs: elapsedMs=" - + elapsedMs + " trace=" + trace, + + elapsedMs, elapsedMs >= 11_500); - assertTrue("Near-edge server pacing failed the shared SABR session: " + trace, - !trace.contains("terminal_failure")); + assertEquals("Repeated pacing responses triggered an extra recovery request", + 8, harness.downloader.requestBodies.size()); } } @@ -637,11 +491,10 @@ public void staleReaderDemandStopsWithoutFailingSession() throws Exception { .bytes()); }); - final SabrSegmentRequest request = - SabrSegmentRequest.media(harness.videoFormat, 2); + final SabrSegmentKey request = + SabrSegmentKey.media(harness.videoFormat, 2); final SabrSegmentDataSource dataSource = new SabrSegmentDataSource( - harness.holder, harness.readerOwner, request.getFormat(), - new Localization("en", "US"), false); + harness.holder.spec, harness.holder.bridge); final AtomicReference failure = new AtomicReference<>(); final CountDownLatch done = new CountDownLatch(1); final Thread loader = new Thread(() -> { @@ -699,11 +552,10 @@ public int read() throws IOException { } }); - final SabrSegmentRequest request = - SabrSegmentRequest.media(harness.videoFormat, 2); + final SabrSegmentKey request = + SabrSegmentKey.media(harness.videoFormat, 2); final SabrSegmentDataSource dataSource = new SabrSegmentDataSource( - harness.holder, harness.readerOwner, request.getFormat(), - new Localization("en", "US"), false); + harness.holder.spec, harness.holder.bridge); final AtomicReference failure = new AtomicReference<>(); final CountDownLatch done = new CountDownLatch(1); final Thread loader = new Thread(() -> { @@ -737,32 +589,6 @@ public int read() throws IOException { } } - @Test - public void initializationPumpKeepsMidStartTarget() throws Exception { - try (SabrSmokeHarness harness = SabrSmokeHarness.create()) { - harness.setPlayerTimeMs(300_000); - harness.downloader.enqueue(new UmpFixture() - .initSegment(1, SMOKE_VIDEO_ITAG) - .bytes()); - - final SabrSegmentRequest request = - SabrSegmentRequest.initialization(harness.videoFormat); - harness.openSegment(request, 5_000); - - final String trace = harness.holder.session.getDiagnosticTrace(); - assertTrue("Initialization pump did not anchor the target: " + trace, - trace.contains("pump_initialization_target itag=" + SMOKE_VIDEO_ITAG)); - assertTrue("No SABR request body was captured", - !harness.downloader.requestBodies.isEmpty()); - final String requestSummary = SabrRequestDumper.summarize( - harness.downloader.requestBodies.get(0)); - assertTrue("Initial SABR request did not keep player time: " + requestSummary, - requestSummary.contains("playerTimeMs=300000")); - assertTrue("Initial SABR request did not report target time: " + requestSummary, - requestSummary.contains("topPlayerTimeMs=300000")); - } - } - @Test public void nativeBootstrapBuildsExactTimelineWithoutAdaptiveRangeRequests() throws Exception { final YoutubeSabrInfo.Format audioFormat = smokeFormat(SMOKE_AUDIO_ITAG, true); @@ -787,99 +613,19 @@ public void nativeBootstrapBuildsExactTimelineWithoutAdaptiveRangeRequests() thr assertEquals(40_001, harness.holder.session.getStreamState() .getSegmentStartMs(audioFormat, 3)); - final SabrSourceSpec spec = new SabrSourceSpec("smoke-video", harness.holder.info, - audioFormat, videoFormat, new Localization("en", "US"), - audioInit, videoInit); - new SabrDashMediaSource( - InstrumentationRegistry.getInstrumentation().getTargetContext(), - new MediaItem.Builder() - .setUri(Uri.parse("sabr://smoke-video")) - .build(), spec); + final SabrSourceSpec spec = harness.holder.session.initializedSpec(); + final Context context = InstrumentationRegistry.getInstrumentation() + .getTargetContext(); + final Method buildManifest = SabrDashMediaSource.class.getDeclaredMethod( + "buildManifest", SabrSourceSpec.class, long.class); + buildManifest.setAccessible(true); + assertNotNull(buildManifest.invoke(null, spec, spec.getDurationMs())); assertTrue("Bootstrap unexpectedly used adaptive range transport", harness.downloader.streamingTimeoutsMs.isEmpty()); } } - @Test - public void adaptiveExactRangesBuildIndexesInParallel() throws Exception { - final byte[] poToken = new byte[]{(byte) 0xfb, (byte) 0xef, 1}; - final String encodedPoToken = "--8B"; - final byte[] audioInit = mp4Sidx(20_001, 20_000, 19_999); - final byte[] videoInit = mp4Sidx(5_000, 5_000, 5_000, 5_000); - final YoutubeSabrInfo.Format audioFormat = smokeFormat(SMOKE_AUDIO_ITAG, true, - "https://adaptive/audio", 0, audioInit.length - 1); - final YoutubeSabrInfo.Format videoFormat = smokeFormat(SMOKE_VIDEO_ITAG, false, - "https://adaptive/video", 0, videoInit.length - 1); - try (SabrSmokeHarness harness = SabrSmokeHarness.create(audioFormat, videoFormat)) { - harness.downloader.enqueueGet("https://adaptive/audio?pot=" + encodedPoToken, - 206, audioInit); - harness.downloader.enqueueGet("https://adaptive/video?pot=" + encodedPoToken, - 206, videoInit); - - final Method method = SabrSessionStore.class.getDeclaredMethod( - "createAdaptiveInitialization", YoutubeSabrInfo.class, - YoutubeSabrInfo.Format.class, YoutubeSabrInfo.Format.class, Localization.class, - byte[].class); - method.setAccessible(true); - final Object result = method.invoke(null, harness.holder.info, audioFormat, - videoFormat, new Localization("en", "US"), poToken); - - final Field audioData = result.getClass().getDeclaredField("audioInitialization"); - final Field videoData = result.getClass().getDeclaredField("videoInitialization"); - audioData.setAccessible(true); - videoData.setAccessible(true); - assertArrayEquals(audioInit, (byte[]) audioData.get(result)); - assertArrayEquals(videoInit, (byte[]) videoData.get(result)); - assertEquals(2, harness.downloader.streamingTimeoutsMs.size()); - assertTrue(harness.downloader.requestedUrls.contains( - "https://adaptive/audio?pot=" + encodedPoToken)); - assertTrue(harness.downloader.requestedUrls.contains( - "https://adaptive/video?pot=" + encodedPoToken)); - assertTrue(harness.downloader.requestBodies.isEmpty()); - } - } - - @Test - public void preparedNativeSessionIsTransferredToPlaybackOnce() throws Exception { - final YoutubeSabrInfo.Format audioFormat = smokeFormat(SMOKE_AUDIO_ITAG, true); - final YoutubeSabrInfo.Format videoFormat = smokeFormat(SMOKE_VIDEO_ITAG, false); - final byte[] audioInit = mp4Sidx(20_001, 20_000); - final byte[] videoInit = mp4Sidx(5_000, 5_000); - try (SabrSmokeHarness harness = SabrSmokeHarness.create(audioFormat, videoFormat)) { - harness.downloader.enqueue(new UmpFixture() - .part(SabrResponseDecoder.FORMAT_INITIALIZATION_METADATA, - initializationMetadata(SMOKE_AUDIO_ITAG, 2, 40_001, "audio/mp4")) - .part(SabrResponseDecoder.FORMAT_INITIALIZATION_METADATA, - initializationMetadata(SMOKE_VIDEO_ITAG, 2, 10_000, "video/mp4")) - .initSegment(1, SMOKE_AUDIO_ITAG, audioInit) - .initSegment(2, SMOKE_VIDEO_ITAG, videoInit) - .bytes()); - harness.holder.session.bootstrapInitialization(new Localization("en", "US")); - - final Constructor constructor = SabrSourceSpec.class - .getDeclaredConstructor(String.class, YoutubeSabrInfo.class, - YoutubeSabrInfo.Format.class, YoutubeSabrInfo.Format.class, Localization.class, - byte[].class, byte[].class, YoutubeSabrSession.class); - constructor.setAccessible(true); - final SabrSourceSpec spec = constructor.newInstance("smoke-video", harness.holder.info, - audioFormat, videoFormat, new Localization("en", "US"), audioInit, videoInit, - harness.holder.session); - final Method acquire = SabrSessionStore.class.getDeclaredMethod( - "acquire", Context.class, SabrSourceSpec.class); - acquire.setAccessible(true); - final SabrSessionStore.Lease lease = (SabrSessionStore.Lease) acquire.invoke(null, - InstrumentationRegistry.getInstrumentation().getTargetContext(), spec); - try { - assertSame(harness.holder.session, holderOf(lease).session); - assertTrue(harness.holder.session.getDiagnosticTrace() - .contains("bootstrap_session_handoff")); - } finally { - lease.close(); - } - } - } - @Test public void nativeBootstrapHonorsInitialAndSkipsCompletedResponseBackoff() throws Exception { final YoutubeSabrInfo.Format audioFormat = smokeFormat(SMOKE_AUDIO_ITAG, true); @@ -929,14 +675,14 @@ public void demandIncompleteMediaResponseRetriesThroughPump() throws Exception { .segment(3, SMOKE_VIDEO_ITAG, 2, 30_000, 5_000) .bytes()); - final SabrSegmentRequest request = SabrSegmentRequest.media(harness.videoFormat, 2); + final SabrSegmentKey request = SabrSegmentKey.media(harness.videoFormat, 2); harness.openMediaSegment(request, 5_000); final String trace = harness.holder.session.getDiagnosticTrace(); assertTrue("Incomplete media response was not exercised: " + trace, trace.contains("missing-media-end:2")); - assertNotNull("Demand retry did not fetch the target segment: " + trace, - harness.holder.session.getCachedSegment(request)); + assertEquals("Demand retry returned unexpected target bytes: " + trace, + 4, harness.getLastSegmentData().length); } } @@ -959,7 +705,7 @@ public void demandRecoverableIntegrityShapesRetryThroughPump() throws Exception @Test public void malformedControlPartDoesNotDropMediaInPump() throws Exception { try (SabrSmokeHarness harness = SabrSmokeHarness.create()) { - final SabrSegmentRequest request = SabrSegmentRequest.media(harness.videoFormat, 1); + final SabrSegmentKey request = SabrSegmentKey.media(harness.videoFormat, 1); harness.downloader.enqueue(new UmpFixture() .part(SabrResponseDecoder.NEXT_REQUEST_POLICY, new byte[]{0x0f}) .segment(1, SMOKE_VIDEO_ITAG, 1) @@ -994,7 +740,7 @@ public void duplicateMediaHeaderFailsThroughDemandPump() throws Exception { .mediaHeader(2, SMOKE_VIDEO_ITAG, 3, 35_000, 5_000) .bytes()); - final SabrSegmentRequest request = SabrSegmentRequest.media(harness.videoFormat, 2); + final SabrSegmentKey request = SabrSegmentKey.media(harness.videoFormat, 2); harness.openMediaSegmentExpectFailure(request, 5_000); final String trace = harness.holder.session.getDiagnosticTrace(); @@ -1018,7 +764,7 @@ public void demandPendingAttestationHonorsServerBackoff() throws Exception { .bytes()); final long elapsedMs = harness.openMediaSegment( - SabrSegmentRequest.media(harness.videoFormat, 2), 6_000); + SabrSegmentKey.media(harness.videoFormat, 2), 6_000); final String trace = harness.holder.session.getDiagnosticTrace(); assertTrue("Pending attestation response was not exercised: " + trace, @@ -1051,23 +797,18 @@ public void requestPolicyLiveAndInitializationMetadataUpdateSessionState() assertEquals(0, harness.holder.session.pumpOnceStreaming(new Localization("en", "US"))); final String trace = harness.holder.session.getDiagnosticTrace(); - assertNotNull("Next request policy was not applied: " + trace, - harness.holder.session.getStreamState().getNextRequestPolicy()); - assertEquals("Policy backoff was not applied", 2_000, - harness.holder.session.getStreamState() - .getNextRequestPolicy().getBackoffTimeMs()); + final long remainingMs = harness.holder.session.getBackoffRemainingMs(); + assertTrue("Policy backoff was not applied: " + remainingMs, + remainingMs > 0 && remainingMs <= 2_000); + final Method getRawPlaybackCookie = YoutubeSabrSession.class + .getDeclaredMethod("getRawPlaybackCookie"); + getRawPlaybackCookie.setAccessible(true); assertNotNull("Playback cookie was not applied: " + trace, - harness.holder.session.getStreamState().getPlaybackCookie()); + getRawPlaybackCookie.invoke(harness.holder.session.delegate)); assertTrue("Live metadata was not applied: " + trace, - harness.holder.session.getStreamState().isLive()); + harness.holder.session.delegate.isLive()); assertTrue("Post-live DVR flag was not applied: " + trace, - harness.holder.session.getStreamState().isPostLiveDvr()); - assertEquals("Initialization metadata did not set end segment", - 60, harness.holder.session.getStreamState() - .getEndSegment(harness.videoFormat)); - assertEquals("Initialization metadata did not derive segment time", - 50_000, harness.holder.session.getStreamState() - .getSegmentStartMs(harness.videoFormat, 11)); + harness.holder.session.delegate.isPostLiveDvr()); } } @@ -1126,8 +867,6 @@ public void reloadPlayerResponseFailsBoundedThroughPump() throws Exception { harness.holder.session.pumpOnceStreaming(new Localization("en", "US")); } catch (final Exception expected) { final String trace = harness.holder.session.getDiagnosticTrace(); - assertTrue("Reload player response was not decoded: " + trace, - trace.contains("46=[reloadPlaybackParamsTokenLength=12]")); assertTrue("Reload response did not mark no-media reload state: " + trace, trace.contains("reload=true")); return; @@ -1157,13 +896,13 @@ public void unknownAndGenericControlsRemainDiagnosticsInPump() throws Exception assertTrue("CONFIG control was not summarized: " + trace, trace.contains("30=[2=9]")); assertTrue("Request identifier was not summarized: " + trace, - trace.contains("52=[tokenLength=13]")); + trace.contains("52=[1=bytes(13)]")); assertTrue("Snackbar was not summarized: " + trace, - trace.contains("67=[id=12]")); + trace.contains("67=[1=12]")); assertTrue("Cancellation policy was not summarized: " + trace, - trace.contains("53=[field1=1")); + trace.contains("53=[1=1")); assertTrue("Prewarm connection was not summarized: " + trace, - trace.contains("65=[connections=1[")); + trace.contains("65=[1=bytes(7)]")); } } @@ -1180,14 +919,11 @@ public void advancedControlsRemainDiagnosticsInPump() throws Exception { assertEquals(0, harness.holder.session.pumpOnceStreaming(new Localization("en", "US"))); final String trace = harness.holder.session.getDiagnosticTrace(); - assertTrue("SABR seek control was not summarized: " + trace, - trace.contains("45=[seek=45000/1000, source=2]")); - assertTrue("Playback start policy was not summarized: " + trace, - trace.contains("47=[start=1[1500ms/100000Bps]")); - assertTrue("Format selection config was not summarized: " + trace, - trace.contains("37=[itags=2[248,140]")); - assertTrue("Selectable formats were not summarized: " + trace, - trace.contains("51=[video=1[itag:248+lm+xtags]")); + assertTrue("Advanced control parts were not retained: " + trace, + trace.contains("parts=[45:9, 47:20, 37:22, 51:66]")); + assertTrue("Advanced controls were not retained: " + trace, + trace.contains("controls={45=") && trace.contains("47=") + && trace.contains("37=") && trace.contains("51=")); } } @@ -1204,14 +940,11 @@ public void onesieControlsRemainDiagnosticsInPump() throws Exception { assertEquals(0, harness.holder.session.pumpOnceStreaming(new Localization("en", "US"))); final String trace = harness.holder.session.getDiagnosticTrace(); - assertTrue("Clear onesie header was not summarized: " + trace, - trace.contains("10=[type=0/ONESIE_PLAYER_RESPONSE")); - assertTrue("Clear onesie data was not associated with the header: " + trace, - trace.contains("11=[encrypted=false, payloadBytes=")); - assertTrue("Innertube payload was not decoded: " + trace, - trace.contains("innertubeResponse=[proxyStatus=1, httpStatus=200")); - assertTrue("Encrypted onesie data was not summarized: " + trace, - trace.contains("12=[encrypted=true, payloadBytes=3")); + assertTrue("Onesie parts were not retained: " + trace, + trace.contains("parts=[10:74, 11:26, 10:83, 12:3]")); + assertTrue("Onesie controls were not retained: " + trace, + trace.contains("controls={10=") && trace.contains("11=") + && trace.contains("12=")); } } @@ -1239,9 +972,6 @@ public void contextKeepExistingAndDiscardUpdateSessionState() throws Exception { assertEquals(0, harness.holder.session.pumpOnceStreaming(new Localization("en", "US"))); - final String trace = harness.holder.session.getDiagnosticTrace(); - assertTrue("Discard policy was not decoded: " + trace, - trace.contains("59=[start=[], stop=[], discard=[40]]")); assertTrue("Context 40 was not discarded", !activeContextTypes(harness).contains(40) && !unsentContextTypes(harness).contains(40)); @@ -1264,147 +994,11 @@ public void compressedMediaSegmentCachesDecompressedBytesThroughDemandPump() .mediaEnd(2) .bytes()); - final SabrSegmentRequest request = SabrSegmentRequest.media(harness.videoFormat, 2); + final SabrSegmentKey request = SabrSegmentKey.media(harness.videoFormat, 2); harness.openMediaSegment(request, 5_000); - final String trace = waitForTrace(harness, "compression=1", 2_000); - assertTrue("Compressed media header was not exercised: " + trace, - trace.contains("compression=1")); - assertEquals("Demand path did not cache decompressed media length", - raw.length, harness.holder.session.getCachedSegment(request).getLength()); - } - } - - @Test - public void growingMediaSegmentReadsBeforeMediaEndAndCompletesAtEof() throws Exception { - try (SabrSmokeHarness harness = SabrSmokeHarness.create()) { - final byte[] firstMediaBytes = filledBytes(64 * 1024, 10); - final byte[] remainingMediaBytes = new byte[0]; - final byte[] expectedMediaBytes = concatBytes(firstMediaBytes, remainingMediaBytes); - final GatedMediaResponse response = new GatedMediaResponse( - 1, SMOKE_VIDEO_ITAG, 1, 0, 5_000, - firstMediaBytes, remainingMediaBytes, 0, false, null); - harness.downloader.enqueue(response); - - final AsyncSegmentReader reader = new AsyncSegmentReader( - harness.holder, harness.readerOwner, - SabrSegmentRequest.media(harness.videoFormat, 1), - firstMediaBytes.length - 1); - reader.start(); - try { - assertTrue("Producer did not reach the MEDIA payload gate", - response.awaitGate(2_000)); - assertTrue("DataSource did not expose initial media bytes before MEDIA_END", - reader.awaitFirstBytes(1_000)); - assertEquals("DataSource did not hold its final byte for MEDIA_END validation", - firstMediaBytes.length - 1, reader.bytesSnapshot().length); - assertTrue("DataSource reached EOF while MEDIA_END was still blocked", - !reader.isEofObserved()); - } finally { - response.release(); - } - - assertTrue("DataSource did not finish after MEDIA_END was released", - reader.awaitDone(2_000)); - assertNull("Growing media read failed", reader.getFailure()); - assertTrue("Growing media read did not observe EOF", reader.isEofObserved()); - assertTrue("Growing media read returned incomplete bytes", - Arrays.equals(expectedMediaBytes, reader.bytesSnapshot())); - } - } - - @Test - public void growingMediaSegmentFailureWakesReaderWithIOException() throws Exception { - try (SabrSmokeHarness harness = SabrSmokeHarness.create()) { - final byte[] firstMediaBytes = filledBytes(64 * 1024, 20); - final GatedMediaResponse response = new GatedMediaResponse( - 1, SMOKE_VIDEO_ITAG, 1, 0, 5_000, - firstMediaBytes, new byte[0], 0, false, - new IOException("gated SABR media failure")); - harness.downloader.enqueue(response); - - final AsyncSegmentReader reader = new AsyncSegmentReader( - harness.holder, harness.readerOwner, - SabrSegmentRequest.media(harness.videoFormat, 1), - firstMediaBytes.length - 1); - reader.start(); - try { - assertTrue("Producer did not reach the failing MEDIA payload gate", - response.awaitGate(2_000)); - assertTrue("DataSource did not expose bytes before the producer failure", - reader.awaitFirstBytes(1_000)); - } finally { - response.release(); - } - - assertTrue("Producer failure did not wake the DataSource reader", - reader.awaitDone(2_000)); - assertTrue("Producer failure did not end as IOException: " + reader.getFailure(), - reader.getFailure() instanceof IOException); - assertTrue("Failed growing media unexpectedly reached EOF", - !reader.isEofObserved()); - assertNull("Failed growing media left a readable stale segment", - harness.holder.session.getReadableSegment( - SabrSegmentRequest.media(harness.videoFormat, 1))); - } - } - - @Test - public void closingGrowingMediaReadWakesBlockedReader() throws Exception { - try (SabrSmokeHarness harness = SabrSmokeHarness.create()) { - final byte[] mediaBytes = filledBytes(64 * 1024, 50); - final GatedMediaResponse response = new GatedMediaResponse( - 1, SMOKE_VIDEO_ITAG, 1, 0, 5_000, - mediaBytes, new byte[0], 0, false, null); - harness.downloader.enqueue(response); - final AsyncSegmentReader reader = new AsyncSegmentReader( - harness.holder, harness.readerOwner, - SabrSegmentRequest.media(harness.videoFormat, 1), mediaBytes.length - 1); - reader.start(); - try { - assertTrue("Producer did not reach MEDIA_END gate", response.awaitGate(2_000)); - assertTrue("Reader did not consume the growing prefix", - reader.awaitFirstBytes(1_000)); - reader.closeDataSource(); - assertTrue("Closing DataSource did not wake its growing-file read", - reader.awaitDone(1_000)); - assertTrue("Closed growing read did not fail with IOException: " - + reader.getFailure(), - reader.getFailure() instanceof IOException); - } finally { - response.release(); - } - } - } - - @Test - public void clearingSessionDoesNotResurrectGrowingSegment() throws Exception { - try (SabrSmokeHarness harness = SabrSmokeHarness.create()) { - final byte[] mediaBytes = filledBytes(64 * 1024, 70); - final SabrSegmentRequest request = - SabrSegmentRequest.media(harness.videoFormat, 1); - final GatedMediaResponse response = new GatedMediaResponse( - 1, SMOKE_VIDEO_ITAG, 1, 0, 5_000, - mediaBytes, new byte[0], 0, false, null); - harness.downloader.enqueue(response); - final AsyncSegmentReader reader = new AsyncSegmentReader( - harness.holder, harness.readerOwner, request, mediaBytes.length - 1); - reader.start(); - try { - assertTrue("Producer did not reach MEDIA_END gate", response.awaitGate(2_000)); - assertTrue("Reader did not consume the growing prefix", - reader.awaitFirstBytes(1_000)); - harness.holder.session.clearCache(); - assertTrue("Clearing the session did not wake the growing-file reader", - reader.awaitDone(1_000)); - assertNull("Cleared session retained a readable in-flight segment", - harness.holder.session.getReadableSegment(request)); - } finally { - response.release(); - } - waitForTrace(harness, "response n=0", 2_000); - assertNull("Completed producer resurrected a cleared segment", - harness.holder.session.getCachedSegment(request)); + assertArrayEquals("Demand path did not return decompressed media bytes", + raw, harness.getLastSegmentData()); } } @@ -1420,11 +1014,11 @@ public void compressedAndInitializationSegmentsRemainCompletionOnly() throws Exc Arrays.copyOfRange(compressedMedia, compressedSplit, compressedMedia.length), 1, false, null); verifyCompletionOnly(harness, - SabrSegmentRequest.media(harness.videoFormat, 1), + SabrSegmentKey.media(harness.videoFormat, 1), response, rawCompressedMedia, "compressed media"); } - final byte[] initializationBytes = new byte[]{40, 41, 42, 43}; + final byte[] initializationBytes = mp4Sidx(5_000, 5_000); try (SabrSmokeHarness harness = SabrSmokeHarness.create()) { final GatedMediaResponse response = new GatedMediaResponse( 1, SMOKE_VIDEO_ITAG, 0, 0, 0, @@ -1432,7 +1026,7 @@ public void compressedAndInitializationSegmentsRemainCompletionOnly() throws Exc Arrays.copyOfRange(initializationBytes, 2, initializationBytes.length), 0, true, null); verifyInitializationCompletionOnly(harness, - SabrSegmentRequest.initialization(harness.videoFormat), + SabrSegmentKey.initialization(harness.videoFormat), response, initializationBytes, "initialization segment"); } } @@ -1479,9 +1073,9 @@ public void generatedLargeMediaPartStaysOffHeap() throws Exception { harness.downloader.enqueue(new GeneratedLargeMediaResponse( 2, SMOKE_VIDEO_ITAG, 1, 0, 5_000, mediaBytes)); - final SabrSegmentRequest request = SabrSegmentRequest.media(harness.videoFormat, 1); + final SabrSegmentKey request = SabrSegmentKey.media(harness.videoFormat, 1); final long beforeUsed = usedHeapBytes(); - harness.openMediaSegment(request, 30_000); + harness.holder.session.pumpOnceStreaming(new Localization("en", "US")); final long peakCached = harness.holder.session.getPeakCachedBytes(); final SabrMediaSegment segment = harness.holder.session.getCachedSegment(request); @@ -1518,7 +1112,7 @@ public void generatedSabrCachePressureStaysOffHeap() for (int i = 1; i <= segmentCount; i++) { harness.holder.session.pumpOnceStreaming(new Localization("en", "US")); final SabrMediaSegment segment = harness.holder.session.getCachedSegment( - SabrSegmentRequest.media(harness.videoFormat, i)); + SabrSegmentKey.media(harness.videoFormat, i)); assertNotNull("Generated SABR segment was not cached: " + i, segment); assertTrue("Generated SABR media segment must be disk-backed: " + i, segment.isDiskBacked()); @@ -1568,11 +1162,6 @@ public void contextUpdateAndSendingPolicyUpdateSessionState() throws Exception { assertEquals(0, harness.holder.session.pumpOnceStreaming(new Localization("en", "US"))); assertEquals(0, harness.holder.session.pumpOnceStreaming(new Localization("en", "US"))); - final String trace = harness.holder.session.getDiagnosticTrace(); - assertTrue("Context update was not decoded: " + trace, - trace.contains("57=[type=10")); - assertTrue("Context sending policy was not decoded: " + trace, - trace.contains("59=[start=[20], stop=[10], discard=[]]")); assertTrue("Context 20 was not activated by sending policy", activeContextTypes(harness).contains(20)); assertTrue("Context 10 was not made unsent by sending policy", @@ -1612,8 +1201,6 @@ private static void runSmokeCase(final SmokeCase smokeCase) throws Exception { new BoundedQualityResolver(maxVideoHeight, targetCodec)); final MediaSource mediaSource = resolver.resolve(info); assertNotNull("VideoPlaybackResolver returned no MediaSource", mediaSource); - assertNull("Resolving a SABR MediaSource eagerly created a session", - findHolder(info.getId())); final long tailStartPositionMs; if (smokeCase.isSponsorBlockCase()) { final long extractedDurationMs = info.getDuration() * 1000L; @@ -1623,29 +1210,6 @@ private static void runSmokeCase(final SmokeCase smokeCase) throws Exception { } else { tailStartPositionMs = C.TIME_UNSET; } - if (smokeCase.kind == SmokeCase.Kind.STALLED_READER) { - try (SabrSessionStore.Lease lease = acquireSourceLease(context, mediaSource)) { - verifyStalledReaderReadAhead(holderOf(lease)); - } finally { - SabrSessionStore.evict(info.getId()); - } - return; - } - if (smokeCase.kind == SmokeCase.Kind.REWIND_STATE) { - try (SabrSessionStore.Lease lease = acquireSourceLease(context, mediaSource)) { - verifyRewindResetsSabrState(holderOf(lease)); - } finally { - SabrSessionStore.evict(info.getId()); - } - return; - } - final boolean simulateEvictedRewind = smokeCase.kind == SmokeCase.Kind.EVICTED_REWIND; - final SabrSessionStore.Lease injectedLease = - smokeCase.kind == SmokeCase.Kind.MISSING_INITIALIZATION - ? acquireSourceLease(context, mediaSource) : null; - final SabrSessionStore.Holder injectedHolder = injectedLease == null - ? null : discardSabrInitialization(holderOf(injectedLease)); - final CountDownLatch ready = new CountDownLatch(1); final CountDownLatch firstVideoFrame = new CountDownLatch(1); final CountDownLatch audioStarted = new CountDownLatch(1); @@ -1735,9 +1299,6 @@ public void onAudioPositionAdvancing(final EventTime eventTime, assertTrue("Audio output did not start", audioStarted.await(PLAYBACK_TIMEOUT_SECONDS, TimeUnit.SECONDS)); assertNull("Player failed while starting audio", playerError.get()); - if (injectedHolder != null) { - verifyInitializationRecovery(injectedHolder); - } if (smokeCase.isSponsorBlockCase()) { verifySponsorBlockSkipToEnd(playerRef.get(), smokeCase, tailStartPositionMs, seekProcessed, seekPositionReported, playerError, ended); @@ -1747,20 +1308,15 @@ public void onAudioPositionAdvancing(final EventTime eventTime, final long linearPlaybackMs = Long.parseLong(arguments.getString( "linearPlaybackMs", String.valueOf(DEFAULT_LINEAR_PLAYBACK_MS))); final long initialPositionMs = positionOf(playerRef.get()); - waitForPosition(playerRef.get(), initialPositionMs + linearPlaybackMs, - PLAYBACK_TIMEOUT_SECONDS); + waitForPositionWithSabrProgress(playerRef.get(), info.getId(), + initialPositionMs + linearPlaybackMs, PLAYBACK_TIMEOUT_SECONDS, playerError); assertNull("Player failed during linear playback", playerError.get()); final long postSeekPlaybackMs = Long.parseLong(arguments.getString( "postSeekPlaybackMs", String.valueOf(DEFAULT_POST_SEEK_PLAYBACK_MS))); final long durationMs = durationOf(playerRef.get()); final long seekPositionMs = seekPositionMs(arguments, durationMs, - simulateEvictedRewind ? Math.max(20_000, postSeekPlaybackMs) - : postSeekPlaybackMs); - if (simulateEvictedRewind) { - assertTrue("Video is too short for an eviction/rewind test: " + durationMs, - seekPositionMs >= 60_000); - } + postSeekPlaybackMs); InstrumentationRegistry.getInstrumentation().runOnMainSync( () -> playerRef.get().seekTo(seekPositionMs)); assertTrue("Player did not report processing the seek", @@ -1770,58 +1326,11 @@ public void onAudioPositionAdvancing(final EventTime eventTime, assertTrue("Seek landed outside the expected position: requested=" + seekPositionMs + " reported=" + seekPositionReported.get(), Math.abs(seekPositionReported.get() - seekPositionMs) <= 1_000); - waitForPosition(playerRef.get(), seekPositionMs + postSeekPlaybackMs, - PLAYBACK_TIMEOUT_SECONDS); + waitForPositionWithSabrProgress(playerRef.get(), info.getId(), + seekPositionMs + postSeekPlaybackMs, PLAYBACK_TIMEOUT_SECONDS, playerError); assertNull("Player failed after seek", playerError.get()); - if (simulateEvictedRewind) { - final SabrSessionStore.Holder holder = getHolder(info.getId()); - final long rewindPositionMs = 10_000; - discardCachedWindow(holder, holder.audioFormat, rewindPositionMs); - discardCachedWindow(holder, holder.videoFormat, rewindPositionMs); - final long edgeBeforeRewindMs = holder.session.getStreamState() - .getMinBufferedEndMs(); - assertTrue("Rewind target is not behind the SABR edge: target=" - + rewindPositionMs + " edge=" + edgeBeforeRewindMs, - rewindPositionMs < edgeBeforeRewindMs); - - final CountDownLatch rewindProcessed = new CountDownLatch(1); - seekProcessed.set(rewindProcessed); - seekPositionReported.set(null); - InstrumentationRegistry.getInstrumentation().runOnMainSync( - () -> playerRef.get().seekTo(rewindPositionMs)); - assertTrue("Player did not process the backward seek", - rewindProcessed.await(10, TimeUnit.SECONDS)); - assertNotNull("Backward seek did not report a new position", - seekPositionReported.get()); - assertTrue("Backward seek landed outside the expected position: requested=" - + rewindPositionMs + " reported=" + seekPositionReported.get(), - Math.abs(seekPositionReported.get() - rewindPositionMs) <= 1_000); - final long postRewindPlaybackMs = Long.parseLong(arguments.getString( - "postRewindPlaybackMs", - String.valueOf(DEFAULT_POST_REWIND_PLAYBACK_MS))); - waitForPosition(playerRef.get(), rewindPositionMs + postRewindPlaybackMs, - PLAYBACK_TIMEOUT_SECONDS); - assertNull("Player failed after evicted-segment rewind", playerError.get()); - final String trace = holder.session.getDiagnosticTrace(); - // MediaPeriod now asks the pump to rewind as soon as it sees an out-of-buffer seek. - // The old data-source timeout path ("recovery type=rewind") is only a fallback. - assertTrue("SABR pump did not execute rewind recovery: " + trace, - trace.contains("pump_rewind")); - } assertTrue("Content ended before playback and seek checks completed", !endedEarly.get() || durationMs < 8_000); - final String maxCachedBytesArgument = arguments.getString("maxCachedBytes"); - if (maxCachedBytesArgument != null) { - final long maximum = Long.parseLong(maxCachedBytesArgument); - final SabrSessionStore.Holder holder = getHolder(info.getId()); - final long observed = holder.session.getPeakCachedBytes(); - System.out.println("SABR_MEMORY height=" + holder.videoFormat.getHeight() - + " itag=" + holder.videoFormat.getItag() - + " peakCachedBytes=" + observed - + " maxCachedBytes=" + maximum); - assertTrue("SABR cache exceeded bound: observed=" + observed - + " maximum=" + maximum, observed <= maximum); - } } finally { InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { final ExoPlayer player = playerRef.get(); @@ -1837,10 +1346,6 @@ public void onAudioPositionAdvancing(final EventTime eventTime, texture.release(); } }); - if (injectedLease != null) { - injectedLease.close(); - } - SabrSessionStore.evict(info.getId()); } } @@ -1902,15 +1407,12 @@ public void onPlayerError(final PlaybackException error) { playerRef.set(player); }); - SabrSessionStore.Holder holder = null; try { assertTrue("Anonymous audio item did not become ready: index=" + index + " video=" + info.getId(), ready.await(PREPARE_TIMEOUT_SECONDS, TimeUnit.SECONDS)); assertNull("Anonymous audio item failed during startup: index=" + index + " video=" + info.getId(), playerError.get()); - holder = getHolder(info.getId()); - holder.session.setTraceEnabled(true); final long durationMs = durationOf(playerRef.get()); assertTrue("Anonymous probe item is too short to cross 60s: index=" + index + " video=" + info.getId() + " durationMs=" + durationMs, @@ -1919,32 +1421,21 @@ public void onPlayerError(final PlaybackException error) { waitForPositionWithSabrProgress(playerRef.get(), info.getId(), targetMs, TimeUnit.MILLISECONDS.toSeconds(targetMs) + PLAYBACK_TIMEOUT_SECONDS, playerError); - final String trace = holder.session.getDiagnosticTrace(); assertNull("Anonymous audio item failed during playback: index=" + index - + " video=" + info.getId() + " trace=" + trace, + + " video=" + info.getId(), playerError.get()); assertTrue("Anonymous audio item did not reach target: index=" + index + " video=" + info.getId() + " targetMs=" + targetMs - + " positionMs=" + positionOf(playerRef.get()) + " trace=" + trace, + + " positionMs=" + positionOf(playerRef.get()), positionOf(playerRef.get()) >= targetMs); - final int maxProtectionStatus = holder.session.getMaxStreamProtectionStatus(); - assertTrue("Anonymous audio item received terminal protection status: index=" - + index + " video=" + info.getId() + " maxStatus=" - + maxProtectionStatus + " trace=" + trace, - maxProtectionStatus <= 2); System.out.println("SABR_ANONYMOUS_SEQUENCE index=" + index + " video=" + info.getId() - + " positionMs=" + positionOf(playerRef.get()) - + " maxProtectionStatus=" + maxProtectionStatus - + " trace=" + trace); + + " positionMs=" + positionOf(playerRef.get())); } catch (final Exception | AssertionError failure) { - final String trace = holder == null ? "" - : holder.session.getDiagnosticTrace(); System.out.println("SABR_ANONYMOUS_SEQUENCE_FAILURE index=" + index + " video=" + info.getId() + " positionMs=" + (playerRef.get() == null ? -1 - : positionOf(playerRef.get())) - + " trace=" + trace); + : positionOf(playerRef.get()))); throw failure; } finally { InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { @@ -1952,30 +1443,17 @@ public void onPlayerError(final PlaybackException error) { playerRef.get().release(); } }); - SabrSessionStore.evict(info.getId()); } } - private static SabrSessionStore.Holder getHolder(final String videoId) throws Exception { - final SabrSessionStore.Holder holder = findHolder(videoId); - assertNotNull("SABR session was not created", holder); - return holder; - } - - private static SabrSessionStore.Holder findHolder(final String videoId) throws Exception { - final Field sessionsField = SabrSessionStore.class.getDeclaredField("SESSIONS"); - sessionsField.setAccessible(true); - @SuppressWarnings("unchecked") - final Map sessions = - (Map) sessionsField.get(null); - SabrSessionStore.Holder holder = null; - for (final SabrSessionStore.Holder candidate : sessions.values()) { - if (videoId.equals(candidate.videoId)) { - holder = candidate; - break; - } + private static int backoffNotificationId() { + try { + final Field field = SabrBackoffCoordinator.class.getDeclaredField("NOTIFICATION_ID"); + field.setAccessible(true); + return field.getInt(null); + } catch (final ReflectiveOperationException error) { + throw new AssertionError(error); } - return holder; } private static StatusBarNotification awaitBackoffNotification( @@ -1994,161 +1472,13 @@ private static StatusBarNotification findBackoffNotification(final Context conte final NotificationManager manager = (NotificationManager) context .getSystemService(Context.NOTIFICATION_SERVICE); for (final StatusBarNotification notification : manager.getActiveNotifications()) { - if (notification.getId() == SabrBackoffCoordinator.NOTIFICATION_ID) { + if (notification.getId() == backoffNotificationId()) { return notification; } } return null; } - private static SabrSessionStore.Lease acquireSourceLease( - final Context context, final MediaSource mediaSource) throws Exception { - final SabrDashMediaSource sabrSource = findSabrSource(mediaSource); - assertNotNull("Expected a SABR child in " + mediaSource.getClass(), sabrSource); - final Field specField = SabrDashMediaSource.class.getDeclaredField("spec"); - specField.setAccessible(true); - final SabrSourceSpec spec = (SabrSourceSpec) specField.get(sabrSource); - final Method acquire = SabrSessionStore.class.getDeclaredMethod( - "acquire", Context.class, SabrSourceSpec.class); - acquire.setAccessible(true); - return (SabrSessionStore.Lease) acquire.invoke(null, context, spec); - } - - private static SabrDashMediaSource findSabrSource(final MediaSource mediaSource) - throws Exception { - if (mediaSource instanceof SabrDashMediaSource) { - return (SabrDashMediaSource) mediaSource; - } - if (!"androidx.media3.exoplayer.source.MergingMediaSource" - .equals(mediaSource.getClass().getName())) { - return null; - } - final Field childrenField = mediaSource.getClass().getDeclaredField("mediaSources"); - childrenField.setAccessible(true); - for (final MediaSource child : (MediaSource[]) childrenField.get(mediaSource)) { - final SabrDashMediaSource result = findSabrSource(child); - if (result != null) { - return result; - } - } - return null; - } - - private static SabrSessionStore.Holder holderOf(final SabrSessionStore.Lease lease) - throws Exception { - final Field holderField = SabrSessionStore.Lease.class.getDeclaredField("holder"); - holderField.setAccessible(true); - return (SabrSessionStore.Holder) holderField.get(lease); - } - - private static void verifyStalledReaderReadAhead( - final SabrSessionStore.Holder holder) throws Exception { - final Object readerOwner = new Object(); - final Method setActiveTracks = SabrSessionStore.Holder.class.getDeclaredMethod( - "setActiveTracks", Object.class, boolean.class, boolean.class); - final Method releaseTracks = SabrSessionStore.Holder.class.getDeclaredMethod( - "releaseTracks", Object.class); - final Method getPump = SabrSessionStore.Holder.class.getDeclaredMethod( - "getPump", Localization.class); - setActiveTracks.setAccessible(true); - releaseTracks.setAccessible(true); - getPump.setAccessible(true); - setActiveTracks.invoke(holder, readerOwner, true, true); - try { - assertTrue("The test must begin with an unstarted active reader", - holder.hasUnstartedActiveReader()); - assertEquals("Reader head must remain at startup", 0, holder.getReaderHeadMs()); - assertEquals("Reader tail must remain at startup", 0, holder.getReaderTailMs()); - - final Object pump = getPump.invoke(holder, new Localization("en", "US")); - final Method ensureStarted = pump.getClass().getDeclaredMethod("ensureStarted"); - ensureStarted.setAccessible(true); - ensureStarted.invoke(pump); - final long deadlineNs = System.nanoTime() - + TimeUnit.SECONDS.toNanos(PREPARE_TIMEOUT_SECONDS); - String trace = holder.session.getDiagnosticTrace(); - while (!trace.contains("pump_throttled ") && System.nanoTime() < deadlineNs) { - Thread.sleep(250); - trace = holder.session.getDiagnosticTrace(); - } - assertTrue("Pump did not apply the startup read-ahead bound: " + trace, - trace.contains("pump_throttled ") - && trace.contains("unstartedReader=true")); - - final int requestNumber = holder.session.getRequestNumber(); - final long edgeMs = holder.session.getStreamState().getMinBufferedEndMs(); - final long cachedBytes = holder.session.getCachedBytes(); - Thread.sleep(1_500); - assertEquals("Pump continued making SABR requests while the reader was stalled", - requestNumber, holder.session.getRequestNumber()); - assertEquals("Buffered edge advanced while the reader was stalled", - edgeMs, holder.session.getStreamState().getMinBufferedEndMs()); - assertEquals("Cache grew while the reader was stalled", - cachedBytes, holder.session.getCachedBytes()); - assertEquals("Reader head unexpectedly advanced", 0, holder.getReaderHeadMs()); - assertEquals("Reader tail unexpectedly advanced", 0, holder.getReaderTailMs()); - } finally { - releaseTracks.invoke(holder, readerOwner); - } - } - - private static void discardCachedWindow(final SabrSessionStore.Holder holder, - final YoutubeSabrInfo.Format format, - final long positionMs) { - final int centerSequence = holder.session.getStreamState() - .getSegmentNumberAtOrAfterTimeMs(format, positionMs); - for (int sequence = Math.max(1, centerSequence - 1); - sequence <= centerSequence + 2; sequence++) { - holder.session.discardCachedSegment(SabrSegmentRequest.media(format, sequence)); - } - assertNull("Fault injection did not evict target segment for itag=" + format.getItag(), - holder.session.getCachedSegment(SabrSegmentRequest.media(format, centerSequence))); - } - - private static void verifyInitializationRecovery(final SabrSessionStore.Holder holder) { - final String trace = holder.session.getDiagnosticTrace(); - assertTrue("Audio bootstrap initialization was not restored: " + trace, - trace.contains("bootstrap_init_restore itag=" + holder.audioFormat.getItag())); - assertTrue("Video bootstrap initialization was not restored: " + trace, - trace.contains("bootstrap_init_restore itag=" + holder.videoFormat.getItag())); - } - - private static void verifyRewindResetsSabrState( - final SabrSessionStore.Holder holder) throws Exception { - final Localization localization = new Localization("en", "US"); - final YoutubeSabrInfo.Format format = holder.videoFormat; - final SabrSegmentRequest target = SabrSegmentRequest.media(format, 2); - // A newly split playback session may legitimately receive policy-only responses before a - // reader asks for media. Establish deterministic forward media state through the same - // demand path used by production, then verify that rewind shrinks it and clears the cookie. - for (int attempt = 0; attempt < 4 - && holder.session.getCachedSegment(target) == null; attempt++) { - holder.session.prepareForForwardJump(target); - holder.session.pumpOnceStreamingForDemand(localization, target); - final long backoffMs = holder.session.getBackoffRemainingMs(); - if (backoffMs > 0 && holder.session.getCachedSegment(target) == null) { - Thread.sleep(backoffMs + 10); - } - } - assertNotNull("Targeted SABR demand did not return media", - holder.session.getCachedSegment(target)); - final int maxSegmentBefore = holder.session.getStreamState().getMaxSegment(format); - assertTrue("Targeted SABR demand did not advance media state", maxSegmentBefore > 1); - - final Field playbackCookie = holder.session.getStreamState().getClass() - .getDeclaredField("playbackCookie"); - playbackCookie.setAccessible(true); - playbackCookie.set(holder.session.getStreamState(), new byte[]{1, 2, 3, 4}); - assertNotNull("Fault injection did not install a stale playback cookie", - holder.session.getStreamState().getPlaybackCookie()); - - holder.session.prepareForRewind(SabrSegmentRequest.media(format, 1)); - assertEquals("Rewind did not move the buffered range before the target", 0, - holder.session.getStreamState().getMaxSegment(format)); - assertNull("Rewind retained the stale SABR playback cookie", - holder.session.getStreamState().getPlaybackCookie()); - } - private static void verifySponsorBlockSkipToEnd( final ExoPlayer player, final SmokeCase smokeCase, @@ -2206,36 +1536,6 @@ private static void seekAndAssertPosition( Math.abs(seekPositionReported.get() - positionMs) <= 1_000); } - private static SabrSessionStore.Holder discardSabrInitialization( - final SabrSessionStore.Holder holder) throws Exception { - final SabrSegmentRequest audioInit = - SabrSegmentRequest.initialization(holder.audioFormat); - final SabrSegmentRequest videoInit = - SabrSegmentRequest.initialization(holder.videoFormat); - // Native bootstrap owns the authoritative init bytes. The independent playback session is - // allowed to begin with media or a policy-only response, so do not require it to duplicate - // both init segments before fault-injecting the active holder cache. - holder.session.discardCachedSegment(audioInit); - holder.session.discardCachedSegment(videoInit); - clearStoredInitializationData(holder); - holder.session.prepareForInitialization(holder.audioFormat); - holder.session.prepareForInitialization(holder.videoFormat); - assertNull(holder.session.getCachedSegment(audioInit)); - assertNull(holder.session.getCachedSegment(videoInit)); - return holder; - } - - private static void clearStoredInitializationData( - final SabrSessionStore.Holder holder) throws Exception { - final Field initializationData = - SabrSessionStore.Holder.class.getDeclaredField("initializationData"); - initializationData.setAccessible(true); - @SuppressWarnings("unchecked") final Map values = - (Map) initializationData.get(holder); - values.remove(holder.audioFormat.getItag()); - values.remove(holder.videoFormat.getItag()); - } - private static long positionOf(final ExoPlayer player) { final AtomicReference result = new AtomicReference<>(); InstrumentationRegistry.getInstrumentation().runOnMainSync( @@ -2287,7 +1587,6 @@ private static void waitForPositionWithSabrProgress(final ExoPlayer player, return; } final long positionMs = positionOf(player); - SabrSessionStore.updatePlayerTime(videoId, positionMs); if (positionMs >= targetMs) { return; } @@ -2315,15 +1614,15 @@ private static void verifyDemandIntegrityRetry(final String expectedIssue, .segment(3, SMOKE_VIDEO_ITAG, 2, 30_000, 5_000) .bytes()); - final SabrSegmentRequest request = SabrSegmentRequest.media(harness.videoFormat, 2); + final SabrSegmentKey request = SabrSegmentKey.media(harness.videoFormat, 2); harness.openMediaSegment(request, 5_000); final String trace = harness.holder.session.getDiagnosticTrace(); assertTrue("Integrity issue was not exercised: expected=" + expectedIssue + " trace=" + trace, trace.contains(expectedTrace)); - assertNotNull("Demand retry did not fetch target after " + expectedIssue + assertEquals("Demand retry returned unexpected target bytes after " + expectedIssue + ": " + trace, - harness.holder.session.getCachedSegment(request)); + 4, harness.getLastSegmentData().length); } } @@ -2336,7 +1635,7 @@ private static void verifyDemandIntegrityFailure(final String expectedTrace, .bytes()); harness.downloader.enqueue(brokenResponse.bytes()); - final SabrSegmentRequest request = SabrSegmentRequest.media(harness.videoFormat, 2); + final SabrSegmentKey request = SabrSegmentKey.media(harness.videoFormat, 2); harness.openMediaSegmentExpectFailure(request, 5_000); final String trace = waitForTrace(harness, expectedTrace, 2_000); @@ -2358,7 +1657,7 @@ private static String waitForTrace(final SabrSmokeHarness harness, } private static void verifyCompletionOnly(final SabrSmokeHarness harness, - final SabrSegmentRequest request, + final SabrSegmentKey request, final GatedMediaResponse response, final byte[] expectedBytes, final String description) throws Exception { @@ -2384,39 +1683,32 @@ private static void verifyCompletionOnly(final SabrSmokeHarness harness, private static void verifyInitializationCompletionOnly( final SabrSmokeHarness harness, - final SabrSegmentRequest request, + final SabrSegmentKey request, final GatedMediaResponse response, final byte[] expectedBytes, final String description) throws Exception { + final Field initializationData = SabrSourceSpec.class + .getDeclaredField("initializationData"); + initializationData.setAccessible(true); + @SuppressWarnings("unchecked") final Map values = + (Map) initializationData.get(harness.holder.spec); + values.remove(request.getFormat()); harness.downloader.enqueue(response); - final AtomicReference failure = new AtomicReference<>(); - final CountDownLatch done = new CountDownLatch(1); - final Thread pump = new Thread(() -> { - try { - harness.holder.session.pumpOnceStreaming(new Localization("en", "US")); - } catch (final Throwable e) { - failure.set(e); - } finally { - done.countDown(); - } - }, "SabrSmokeInitializationCompletion"); - pump.setDaemon(true); - pump.start(); + final AsyncSegmentReader reader = new AsyncSegmentReader( + harness.holder, harness.readerOwner, request, 1); + reader.start(); try { assertTrue(description + " producer did not reach the MEDIA payload gate", response.awaitGate(2_000)); - assertNull(description + " became readable before completion", - harness.holder.session.getReadableSegment(request)); + assertTrue(description + " became readable before completion", + !reader.awaitOpened(300)); } finally { response.release(); } - assertTrue(description + " pump did not finish after completion", - done.await(2_000, TimeUnit.MILLISECONDS)); - assertNull(description + " pump failed", failure.get()); - final SabrMediaSegment segment = harness.holder.session.getCachedSegment(request); - assertNotNull(description + " was not cached after completion", segment); + assertTrue(description + " did not finish after completion", reader.awaitDone(2_000)); + assertNull(description + " read failed", reader.getFailure()); assertTrue(description + " returned unexpected bytes", - Arrays.equals(expectedBytes, segment.getData())); + Arrays.equals(expectedBytes, reader.bytesSnapshot())); } private static long usedHeapBytes() { @@ -2716,30 +2008,23 @@ private static byte[] gzip(final byte[] data) throws IOException { private static List activeContextTypes(final SabrSmokeHarness harness) throws Exception { - final Method getActiveSabrContexts = harness.holder.session.getStreamState().getClass() + final Method getActiveSabrContexts = YoutubeSabrSession.class .getDeclaredMethod("getActiveSabrContexts"); getActiveSabrContexts.setAccessible(true); - @SuppressWarnings("unchecked") final Collection contexts = - (Collection) getActiveSabrContexts.invoke( - harness.holder.session.getStreamState()); - final List types = new ArrayList<>(); - for (final Object context : contexts) { - final Method getType = context.getClass().getDeclaredMethod("getType"); - getType.setAccessible(true); - types.add((Integer) getType.invoke(context)); - } - return types; + @SuppressWarnings("unchecked") final Map contexts = + (Map) getActiveSabrContexts.invoke( + harness.holder.session.delegate); + return new ArrayList<>(contexts.keySet()); } private static List unsentContextTypes(final SabrSmokeHarness harness) throws Exception { - final Method getUnsentSabrContextTypes = - harness.holder.session.getStreamState().getClass() - .getDeclaredMethod("getUnsentSabrContextTypes"); + final Method getUnsentSabrContextTypes = YoutubeSabrSession.class + .getDeclaredMethod("getUnsentSabrContextTypes"); getUnsentSabrContextTypes.setAccessible(true); @SuppressWarnings("unchecked") final Collection types = (Collection) getUnsentSabrContextTypes.invoke( - harness.holder.session.getStreamState()); + harness.holder.session.delegate); return new ArrayList<>(types); } @@ -2762,10 +2047,6 @@ private static byte[] formatIdWithXtags(final int itag, final String xtags) { private static final class SmokeCase { private enum Kind { PLAYBACK, - MISSING_INITIALIZATION, - EVICTED_REWIND, - STALLED_READER, - REWIND_STATE, SPONSOR_BLOCK_PLAYBACK, SPONSOR_BLOCK_SEEK } @@ -2780,49 +2061,289 @@ private static SmokeCase playback() { return new SmokeCase(Kind.PLAYBACK); } - private static SmokeCase missingInitialization() { - return new SmokeCase(Kind.MISSING_INITIALIZATION); + private static SmokeCase sponsorBlockPlayback() { + return new SmokeCase(Kind.SPONSOR_BLOCK_PLAYBACK); } - private static SmokeCase evictedRewind() { - return new SmokeCase(Kind.EVICTED_REWIND); + private static SmokeCase sponsorBlockSeek() { + return new SmokeCase(Kind.SPONSOR_BLOCK_SEEK); } - private static SmokeCase stalledReader() { - return new SmokeCase(Kind.STALLED_READER); + private boolean isSponsorBlockCase() { + return kind == Kind.SPONSOR_BLOCK_PLAYBACK || kind == Kind.SPONSOR_BLOCK_SEEK; } + } - private static SmokeCase rewindState() { - return new SmokeCase(Kind.REWIND_STATE); + /** Test-side composition of the current session, bridge and source specification. */ + private static final class SmokeHolder { + private final SabrSourceSpec spec; + private final SabrMediaBridge bridge; + private final SmokeSession session; + + private SmokeHolder(final Context context, + final String videoId, + final YoutubeSabrInfo info, + final YoutubeSabrSession delegate, + final YoutubeSabrInfo.Format audioFormat, + final YoutubeSabrInfo.Format videoFormat) throws Exception { + final byte[] audioInitialization = mp4Sidx(5_000, 5_000, 5_000, 5_000, + 5_000, 5_000, 5_000, 5_000); + final byte[] videoInitialization = mp4Sidx(5_000, 5_000, 5_000, 5_000, + 5_000, 5_000, 5_000, 5_000); + final org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormatTimeline + audioTimeline = org.schabi.newpipe.extractor.services.youtube.sabr + .YoutubeSabrFormatTimeline.parse(audioFormat, audioInitialization); + final org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormatTimeline + videoTimeline = org.schabi.newpipe.extractor.services.youtube.sabr + .YoutubeSabrFormatTimeline.parse(videoFormat, videoInitialization); + spec = new SabrSourceSpec(videoId, info, audioFormat, + Collections.singletonList(audioFormat), videoFormat, + audioInitialization, videoInitialization, audioTimeline, videoTimeline, + Collections.emptyList()); + delegate.setPoToken(new byte[]{1, 2, 3, 4}); + bridge = new SabrMediaBridge(context, delegate, spec); + session = new SmokeSession(delegate, bridge, spec, audioFormat, videoFormat, + audioTimeline, videoTimeline); + } + + private void setActiveTracks(final Object owner, + final boolean video, + final boolean audio) { + session.videoActive = video; + session.audioActive = audio; + } + + private void setPlayerTimeMs(final long value) { session.playerTimeMs = value; } + private void advanceReaderGeneration(final Object owner) { session.clearCache(); } + private void stop(final String reason) { bridge.stop(); session.clearCache(); } + } + + private static final class SmokeSession { + private final YoutubeSabrSession delegate; + private final SabrMediaBridge bridge; + private final SabrSourceSpec spec; + private final YoutubeSabrInfo.Format audioFormat; + private final YoutubeSabrInfo.Format videoFormat; + private final org.schabi.newpipe.extractor.services.youtube.sabr + .YoutubeSabrFormatTimeline audioTimeline; + private final org.schabi.newpipe.extractor.services.youtube.sabr + .YoutubeSabrFormatTimeline videoTimeline; + private final Map segments = new ConcurrentHashMap<>(); + private final SmokeState state; + private volatile boolean audioActive; + private volatile boolean videoActive = true; + private volatile long playerTimeMs; + private volatile long peakCachedBytes; + private YoutubeSabrSession.InitializationResult initializationResult; + + private SmokeSession(final YoutubeSabrSession delegate, + final SabrMediaBridge bridge, + final SabrSourceSpec spec, + final YoutubeSabrInfo.Format audioFormat, + final YoutubeSabrInfo.Format videoFormat, + final org.schabi.newpipe.extractor.services.youtube.sabr + .YoutubeSabrFormatTimeline audioTimeline, + final org.schabi.newpipe.extractor.services.youtube.sabr + .YoutubeSabrFormatTimeline videoTimeline) { + this.delegate = delegate; + this.bridge = bridge; + this.spec = spec; + this.audioFormat = audioFormat; + this.videoFormat = videoFormat; + this.audioTimeline = audioTimeline; + this.videoTimeline = videoTimeline; + state = new SmokeState(delegate, audioTimeline, videoTimeline); } - private static SmokeCase sponsorBlockPlayback() { - return new SmokeCase(Kind.SPONSOR_BLOCK_PLAYBACK); + private int pumpOnceStreaming(final Localization localization) throws Exception { + return requestOnce(null).getSegmentCount(); } - private static SmokeCase sponsorBlockSeek() { - return new SmokeCase(Kind.SPONSOR_BLOCK_SEEK); + private YoutubeSabrSession.RequestResult pumpOnceStreamingForDemand( + final Localization localization, final SabrSegmentKey request) throws Exception { + playerTimeMs = Math.max(0, spec.getTimeline(request.getFormat()) + .getStartMs(request.getSequenceNumber())); + return requestOnce(request); } - private boolean isSponsorBlockCase() { - return kind == Kind.SPONSOR_BLOCK_PLAYBACK || kind == Kind.SPONSOR_BLOCK_SEEK; + private YoutubeSabrSession.RequestResult requestOnce(final SabrSegmentKey demand) + throws Exception { + final boolean demandAudio = demand != null && demand.getFormat().isAudio(); + final boolean useAudio = audioActive || demandAudio; + final boolean useVideo = videoActive || demand != null && !demandAudio; + return delegate.requestOnce(audioFormat, videoFormat, playerTimeMs, + audioTimeline, state.maxSegment(audioFormat), + videoTimeline, state.maxSegment(videoFormat), + useAudio, useVideo, useVideo && !useAudio, 1.0f, this::accept); + } + + private void accept(final SabrMediaSegment segment) { + if (segment.getHeader().isInitSegment()) return; + final YoutubeSabrInfo.Format format = segment.getHeader().getItag() + == audioFormat.getItag() ? audioFormat : videoFormat; + final SabrSegmentKey key = SabrSegmentKey.media(format, + segment.getHeader().getSequenceNumber()); + final SabrMediaSegment previous = segments.put(key, segment); + if (previous != null && previous != segment) previous.delete(); + state.observe(format, segment); + peakCachedBytes = Math.max(peakCachedBytes, getCachedBytes()); + } + + private void bootstrapInitialization(final Localization localization) throws Exception { + initializationResult = delegate.initialize(2_000, new byte[]{1, 2, 3, 4}); + state.setTimelines(initializationResult.getAudioTimeline(), + initializationResult.getVideoTimeline()); + } + + private SabrSourceSpec initializedSpec() { + if (initializationResult == null + || initializationResult.getAudioData() == null + || initializationResult.getVideoData() == null + || initializationResult.getAudioTimeline() == null + || initializationResult.getVideoTimeline() == null) { + return spec; + } + return new SabrSourceSpec(spec.getVideoId(), spec.getInfo(), audioFormat, + Collections.singletonList(audioFormat), videoFormat, + initializationResult.getAudioData(), initializationResult.getVideoData(), + initializationResult.getAudioTimeline(), + initializationResult.getVideoTimeline(), + initializationResult.getMediaSegments()); + } + + private SabrMediaSegment getCachedSegment(final SabrSegmentKey request) { + return segments.get(request); + } + + private void clearCache() { + for (final SabrMediaSegment segment : segments.values()) segment.delete(); + segments.clear(); + } + + private SmokeState getStreamState() { return state; } + private String getDiagnosticTrace() { return delegate.getDiagnosticTrace(); } + private long getBackoffRemainingMs() { return delegate.getBackoffRemainingMs(); } + private int getMaxStreamProtectionStatus() { + return delegate.getMaxStreamProtectionStatus(); + } + private long getPeakCachedBytes() { return peakCachedBytes; } + private long getCachedBytes() { + long result = 0; + for (final SabrMediaSegment segment : segments.values()) result += segment.getLength(); + return result; } } + private static final class SmokeState { + private final YoutubeSabrSession session; + private org.schabi.newpipe.extractor.services.youtube.sabr + .YoutubeSabrFormatTimeline audioTimeline; + private org.schabi.newpipe.extractor.services.youtube.sabr + .YoutubeSabrFormatTimeline videoTimeline; + private final Map maxSegments = new ConcurrentHashMap<>(); + private byte[] playbackCookie; + private final List contexts = new ArrayList<>(); + + private SmokeState(final YoutubeSabrSession session, + final org.schabi.newpipe.extractor.services.youtube.sabr + .YoutubeSabrFormatTimeline audioTimeline, + final org.schabi.newpipe.extractor.services.youtube.sabr + .YoutubeSabrFormatTimeline videoTimeline) { + this.session = session; + this.audioTimeline = audioTimeline; + this.videoTimeline = videoTimeline; + } + + private void observe(final YoutubeSabrInfo.Format format, + final SabrMediaSegment segment) { + maxSegments.merge(format.getItag(), segment.getHeader().getSequenceNumber(), Math::max); + } + + private int maxSegment(final YoutubeSabrInfo.Format format) { + return maxSegments.getOrDefault(format.getItag(), 0); + } + + private void reset(final YoutubeSabrInfo.Format format) { + maxSegments.remove(format.getItag()); + } + + private void setTimelines( + final org.schabi.newpipe.extractor.services.youtube.sabr + .YoutubeSabrFormatTimeline audio, + final org.schabi.newpipe.extractor.services.youtube.sabr + .YoutubeSabrFormatTimeline video) { + if (audio != null) audioTimeline = audio; + if (video != null) videoTimeline = video; + } + + private void setVideoOnlyRequestMode() { } + private boolean hasSegmentIndex(final YoutubeSabrInfo.Format format) { return true; } + private int getMaxSegment(final YoutubeSabrInfo.Format format) { + return maxSegment(format); + } + private long getMinBufferedEndMs() { + return Math.min(endMs(audioTimeline, maxSegments.getOrDefault(SMOKE_AUDIO_ITAG, 0)), + endMs(videoTimeline, maxSegments.getOrDefault(SMOKE_VIDEO_ITAG, 0))); + } + private SmokePolicy getNextRequestPolicy() { + final long backoff = session.getBackoffRemainingMs(); + return backoff <= 0 ? null : new SmokePolicy((int) backoff); + } + private byte[] getPlaybackCookie() { return playbackCookie; } + private boolean isLive() { return session.isLive(); } + private boolean isPostLiveDvr() { return session.isPostLiveDvr(); } + private int getEndSegment(final YoutubeSabrInfo.Format format) { + return format.isAudio() ? audioTimeline.getEndSequence() : videoTimeline.getEndSequence(); + } + private long getSegmentStartMs(final YoutubeSabrInfo.Format format, + final int sequence) { + return format.isAudio() ? audioTimeline.getStartMs(sequence) + : videoTimeline.getStartMs(sequence); + } + private int getSegmentNumberAtOrAfterTimeMs(final YoutubeSabrInfo.Format format, + final long timeMs) { + return format.isAudio() ? audioTimeline.getSequenceAt(timeMs) + : videoTimeline.getSequenceAt(timeMs); + } + private Collection getActiveSabrContexts() { return contexts; } + private Collection getUnsentSabrContextTypes() { + return Collections.emptyList(); + } + private static long endMs( + final org.schabi.newpipe.extractor.services.youtube.sabr + .YoutubeSabrFormatTimeline timeline, + final int sequence) { + return sequence <= 0 ? 0 : timeline.getEndMs(sequence); + } + } + + private static final class SmokePolicy { + private final int backoffTimeMs; + private SmokePolicy(final int backoffTimeMs) { this.backoffTimeMs = backoffTimeMs; } + private int getBackoffTimeMs() { return backoffTimeMs; } + } + + private static final class SmokeContext { + private final int type; + private SmokeContext(final int type) { this.type = type; } + private int getType() { return type; } + } + private static final class SabrSmokeHarness implements AutoCloseable { private final Downloader previousDownloader; private final Localization previousLocalization; private final ContentCountry previousContentCountry; private final FakeSabrDownloader downloader; - private final SabrSessionStore.Holder holder; + private final SmokeHolder holder; private final YoutubeSabrInfo.Format videoFormat; private final Object readerOwner; + private volatile byte[] lastSegmentData = new byte[0]; private SabrSmokeHarness(final Downloader previousDownloader, final Localization previousLocalization, final ContentCountry previousContentCountry, final FakeSabrDownloader downloader, - final SabrSessionStore.Holder holder, + final SmokeHolder holder, final YoutubeSabrInfo.Format videoFormat, final Object readerOwner) { this.previousDownloader = previousDownloader; @@ -2853,17 +2374,16 @@ private static SabrSmokeHarness create(final YoutubeSabrInfo.Format audioFormat, "sabr-smoke-" + System.nanoTime()); final YoutubeSabrSession session = new YoutubeSabrSession(info, audioFormat, videoFormat, spoolDirectory); - session.getStreamState().setVideoOnlyRequestMode(); - final Constructor constructor = - SabrSessionStore.Holder.class.getDeclaredConstructor(Context.class, + final Constructor constructor = + SmokeHolder.class.getDeclaredConstructor(Context.class, String.class, YoutubeSabrInfo.class, YoutubeSabrSession.class, YoutubeSabrInfo.Format.class, YoutubeSabrInfo.Format.class); constructor.setAccessible(true); - final SabrSessionStore.Holder holder = constructor.newInstance( + final SmokeHolder holder = constructor.newInstance( InstrumentationRegistry.getInstrumentation().getTargetContext(), "smoke-video", info, session, audioFormat, videoFormat); final Object readerOwner = new Object(); - final Method setActiveTracks = SabrSessionStore.Holder.class.getDeclaredMethod( + final Method setActiveTracks = SmokeHolder.class.getDeclaredMethod( "setActiveTracks", Object.class, boolean.class, boolean.class); setActiveTracks.setAccessible(true); setActiveTracks.invoke(holder, readerOwner, true, false); @@ -2872,39 +2392,43 @@ private static SabrSmokeHarness create(final YoutubeSabrInfo.Format audioFormat, } private void setPlayerTimeMs(final long playerTimeMs) throws Exception { - final Method setPlayerTimeMs = SabrSessionStore.Holder.class.getDeclaredMethod( + final Method setPlayerTimeMs = SmokeHolder.class.getDeclaredMethod( "setPlayerTimeMs", long.class); setPlayerTimeMs.setAccessible(true); setPlayerTimeMs.invoke(holder, playerTimeMs); } private void advanceReaderGeneration() throws Exception { - final Method advanceReaderGeneration = SabrSessionStore.Holder.class + final Method advanceReaderGeneration = SmokeHolder.class .getDeclaredMethod("advanceReaderGeneration", Object.class); advanceReaderGeneration.setAccessible(true); advanceReaderGeneration.invoke(holder, readerOwner); } - private long openMediaSegment(final SabrSegmentRequest request, + private long openMediaSegment(final SabrSegmentKey request, final long timeoutMs) throws Exception { return openSegment(request, timeoutMs); } - private long openSegment(final SabrSegmentRequest request, + private long openSegment(final SabrSegmentKey request, final long timeoutMs) throws Exception { final AtomicReference failure = new AtomicReference<>(); + final AtomicReference result = new AtomicReference<>(); final CountDownLatch done = new CountDownLatch(1); final long startMs = System.currentTimeMillis(); final Thread thread = new Thread(() -> { final SabrSegmentDataSource dataSource = new SabrSegmentDataSource( - holder, readerOwner, request.getFormat(), new Localization("en", "US"), - false); + holder.spec, holder.bridge); try { dataSource.open(new DataSpec(segmentUri(request))); final byte[] buffer = new byte[8_192]; - while (dataSource.read(buffer, 0, buffer.length) != C.RESULT_END_OF_INPUT) { - // Drain the DataSource: growing segments may return from open at the header. + final ByteArrayOutputStream output = new ByteArrayOutputStream(); + int read; + while ((read = dataSource.read(buffer, 0, buffer.length)) + != C.RESULT_END_OF_INPUT) { + output.write(buffer, 0, read); } + result.set(output.toByteArray()); } catch (final Throwable e) { failure.set(e); } finally { @@ -2920,26 +2444,30 @@ private long openSegment(final SabrSegmentRequest request, throw new AssertionError("SABR smoke demand open failed, trace=" + holder.session.getDiagnosticTrace(), failure.get()); } + lastSegmentData = result.get(); return System.currentTimeMillis() - startMs; } - private Uri segmentUri(final SabrSegmentRequest request) { - return Uri.parse("sabr://" + request.getFormat().getItag() + '/' - + (request.isInitializationSegment() + private byte[] getLastSegmentData() { + return lastSegmentData.clone(); + } + + private Uri segmentUri(final SabrSegmentKey request) { + return Uri.parse("sabr://" + holder.spec.getFormatKey(request.getFormat()) + '/' + + (request.isInitialization() ? "init" : String.valueOf(request.getSequenceNumber()))); } - private void openMediaSegmentExpectFailure(final SabrSegmentRequest request, + private void openMediaSegmentExpectFailure(final SabrSegmentKey request, final long timeoutMs) throws Exception { final AtomicReference failure = new AtomicReference<>(); final CountDownLatch done = new CountDownLatch(1); final Thread thread = new Thread(() -> { final SabrSegmentDataSource dataSource = new SabrSegmentDataSource( - holder, readerOwner, request.getFormat(), new Localization("en", "US"), - false); + holder.spec, holder.bridge); try { dataSource.open(new DataSpec(Uri.parse("sabr://" - + request.getFormat().getItag() + '/' + + holder.spec.getFormatKey(request.getFormat()) + '/' + request.getSequenceNumber()))); } catch (final Throwable e) { failure.set(e); @@ -2958,7 +2486,7 @@ private void openMediaSegmentExpectFailure(final SabrSegmentRequest request, @Override public void close() throws Exception { - final Method stop = SabrSessionStore.Holder.class.getDeclaredMethod( + final Method stop = SmokeHolder.class.getDeclaredMethod( "stop", String.class); stop.setAccessible(true); stop.invoke(holder, "smoke_harness_close"); @@ -3149,9 +2677,9 @@ private void awaitRelease() throws InterruptedIOException { } private static final class AsyncSegmentReader { - private final SabrSessionStore.Holder holder; + private final SmokeHolder holder; private final Object readerOwner; - private final SabrSegmentRequest request; + private final SabrSegmentKey request; private final int firstBytesTarget; private final ByteArrayOutputStream output = new ByteArrayOutputStream(); private final AtomicReference failure = new AtomicReference<>(); @@ -3162,9 +2690,9 @@ private static final class AsyncSegmentReader { private final AtomicBoolean eofObserved = new AtomicBoolean(); private Thread thread; - private AsyncSegmentReader(final SabrSessionStore.Holder holder, + private AsyncSegmentReader(final SmokeHolder holder, final Object readerOwner, - final SabrSegmentRequest request, + final SabrSegmentKey request, final int firstBytesTarget) { this.holder = holder; this.readerOwner = readerOwner; @@ -3175,13 +2703,12 @@ private AsyncSegmentReader(final SabrSessionStore.Holder holder, private void start() { thread = new Thread(() -> { final SabrSegmentDataSource currentDataSource = new SabrSegmentDataSource( - holder, readerOwner, request.getFormat(), - new Localization("en", "US"), false); + holder.spec, holder.bridge); dataSource.set(currentDataSource); try { currentDataSource.open(new DataSpec(Uri.parse("sabr://" - + request.getFormat().getItag() + '/' - + (request.isInitializationSegment() + + holder.spec.getFormatKey(request.getFormat()) + '/' + + (request.isInitialization() ? "init" : String.valueOf(request.getSequenceNumber()))))); opened.countDown(); final byte[] buffer = new byte[64]; diff --git a/app/src/androidTest/java/org/schabi/newpipe/player/YoutubePlaybackBenchmarkTest.java b/app/src/androidTest/java/org/schabi/newpipe/player/YoutubePlaybackBenchmarkTest.java index 6bcb2025c..2c832f197 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/player/YoutubePlaybackBenchmarkTest.java +++ b/app/src/androidTest/java/org/schabi/newpipe/player/YoutubePlaybackBenchmarkTest.java @@ -1,6 +1,5 @@ package org.schabi.newpipe.player; -import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; @@ -42,12 +41,10 @@ import org.schabi.newpipe.SharedWebViewRuntime; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.ServiceList; -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.DeliveryMethod; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.VideoStream; -import org.schabi.newpipe.player.datasource.SabrSessionStore; import org.schabi.newpipe.player.helper.LegacySubtitleRenderersFactory; import org.schabi.newpipe.player.helper.LoadController; import org.schabi.newpipe.player.helper.PlayerDataSource; @@ -58,7 +55,6 @@ import java.io.File; import java.io.FileInputStream; import java.io.IOException; -import java.lang.reflect.Field; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; @@ -100,8 +96,6 @@ public void compareSabrHlsAndGeneratedDash() throws Exception { args.getString("warmWebViewRuntime", "false")); final boolean diagnosticDetails = Boolean.parseBoolean( args.getString("diagnosticDetails", "false")); - final boolean coldSabrCachesEachTrial = Boolean.parseBoolean( - args.getString("coldSabrCachesEachTrial", "false")); if (warmWebViewRuntime) { SharedWebViewRuntime.get(context).ensureReady(120_000L, "benchmark WebView warmup"); } @@ -159,8 +153,7 @@ public void compareSabrHlsAndGeneratedDash() throws Exception { .put("warmWebViewRuntime", warmWebViewRuntime) .put("diagnosticDetails", diagnosticDetails) .put("playerMediaCacheClearedEachTrial", true) - .put("sabrSessionEvictedEachTrial", true) - .put("coldSabrCachesEachTrial", coldSabrCachesEachTrial) + .put("sabrSessionPolicy", "bounded_store") .put("cachedExtractionAcrossTrials", true) .put("firstFrameMetricScope", "media_source_resolve_to_rendered_frame") .put("excludedFromFirstFrameMs", new JSONArray(Arrays.asList( @@ -215,8 +208,7 @@ public void compareSabrHlsAndGeneratedDash() throws Exception { final boolean warmup = round < 0; final Result result = runTrial(context, path, extractions.get(path).info, round, warmup, playSeconds, startPositionMs, seekTargetMs, - maxHeight, targetCodec, url, warmWebViewRuntime, - diagnosticDetails, coldSabrCachesEachTrial); + maxHeight, targetCodec, url, warmWebViewRuntime, diagnosticDetails); emit("PIPEPIPE_BENCHMARK_RESULT", result.toJson()); emitTrialDetails(result); if (!warmup) { @@ -237,14 +229,8 @@ private static Result runTrial(final Context context, final Path path, final Str final long seekTargetMs, final int maxHeight, final String targetCodec, final String url, final boolean warmWebViewRuntime, - final boolean diagnosticDetails, - final boolean coldSabrCachesEachTrial) throws Exception { + final boolean diagnosticDetails) throws Exception { NewPipe.setYoutubePlayerClient(path.client); - if (coldSabrCachesEachTrial && path.sourceDelivery == DeliveryMethod.SABR) { - SabrSessionStore.clearBenchmarkCaches(context, info.getId()); - } else { - SabrSessionStore.evict(info.getId()); - } final CountingTransferListener transfers = new CountingTransferListener(diagnosticDetails); final PlayerDataSource dataSource = new PlayerDataSource(context, DownloaderImpl.USER_AGENT, transfers); @@ -470,20 +456,11 @@ public void onLoadError(final EventTime eventTime, long finalBufferedPositionMs = -1; SabrStats sabrStats = SabrStats.EMPTY; SeekTrace seekTrace = SeekTrace.EMPTY; - SabrSessionStore.Holder sabrHolder = null; try { waitUntil(() -> frameNs.get() != 0 || ended.get() || error.get() != null, START_TIMEOUT_MS); throwPlayerError(error.get()); assertTrue("Playback ended before rendering the first frame", frameNs.get() != 0); - if (path.sourceDelivery == DeliveryMethod.SABR) { - sabrHolder = findActiveSabrHolder(info.getId()); - assertNotNull("SABR player reached first frame without an active session holder", - sabrHolder); - if (diagnosticDetails) { - sabrHolder.session.setTraceEnabled(true); - } - } final long playbackStartPositionMs = firstFramePositionMs.get(); assertTrue("First frame did not report a valid playback position: " + playbackStartPositionMs, playbackStartPositionMs >= 0); @@ -511,25 +488,12 @@ public void onLoadError(final EventTime eventTime, finalPositionMs = reachedPositionMs; finalBufferedPositionMs = bufferedPosition(playerRef.get()); if (startPositionMs >= 0) { - if (path.sourceDelivery == DeliveryMethod.SABR) { - sabrStats = sabrStats(sabrHolder); - if (diagnosticDetails) { - seekTrace = SeekTrace.fromSabrStartup(sabrHolder, startPositionMs); - } - } else { - seekTrace = transfers.finishSeekTrace(); - } + seekTrace = transfers.finishSeekTrace(); } else { final long duration = duration(playerRef.get()); final long target = seekTargetMs >= 0 ? seekTargetMs : duration == C.TIME_UNSET ? 30_000 : Math.max(1_000, Math.min(30_000, duration / 2)); - final org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession - .TraceSnapshot sabrTraceBefore = !diagnosticDetails || sabrHolder == null - ? null - : sabrHolder.session.getTraceSnapshot(); - final SeekCacheSnapshot sabrCacheBefore = diagnosticDetails - ? SeekCacheSnapshot.fromSabr(sabrHolder, target) : SeekCacheSnapshot.EMPTY; transfers.startSeekTrace(); final long seekStart = SystemClock.elapsedRealtimeNanos(); InstrumentationRegistry.getInstrumentation().runOnMainSync( @@ -538,20 +502,7 @@ public void onLoadError(final EventTime eventTime, || error.get() != null, START_TIMEOUT_MS); throwPlayerError(error.get()); seekRecoveryMs = elapsedMs(seekStart); - if (path.sourceDelivery == DeliveryMethod.SABR) { - sabrStats = sabrStats(sabrHolder); - if (diagnosticDetails) { - final org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession - .TraceSnapshot sabrTraceAfter = sabrHolder == null ? null - : sabrHolder.session.getTraceSnapshot(); - final SeekCacheSnapshot sabrCacheAfter = SeekCacheSnapshot.fromSabr( - sabrHolder, target); - seekTrace = SeekTrace.fromSabr(sabrTraceBefore, sabrTraceAfter, - sabrCacheBefore, sabrCacheAfter); - } - } else { - seekTrace = transfers.finishSeekTrace(); - } + seekTrace = transfers.finishSeekTrace(); } } catch (final Exception | AssertionError failure) { try { @@ -594,12 +545,10 @@ public void onLoadError(final EventTime eventTime, textureRef.get().release(); } }); - SabrSessionStore.evict(info.getId()); } final long uidRxAfter = TrafficStats.getUidRxBytes(Process.myUid()); final long uidRxBytes = uidRxBefore < 0 || uidRxAfter < 0 ? -1 : uidRxAfter - uidRxBefore; - final long mediaBytes = path.sourceDelivery == DeliveryMethod.SABR - ? sabrStats.responseBytes : transfers.networkBytes.get(); + final long mediaBytes = transfers.networkBytes.get(); return new Result(path, round, warmup, selector.selected, resolveMs, toMs(readyNs.get() - prepareNs), toMs(frameNs.get() - prepareNs), toMs(audioNs.get() - prepareNs), seekRecoveryMs, rebufferCount.get(), @@ -610,36 +559,7 @@ public void onLoadError(final EventTime eventTime, firstFramePositionMs.get(), firstFrameBufferedPositionMs.get(), finalPositionMs, finalBufferedPositionMs, actualVideoFormat.get(), actualAudioFormat.get(), snapshot(stateTransitions), snapshot(loadEvents), warmWebViewRuntime, - diagnosticDetails, coldSabrCachesEachTrial, - stateTransitionCount.get(), loadEventCount.get()); - } - - @Nullable - private static SabrSessionStore.Holder findActiveSabrHolder(final String videoId) - throws Exception { - final Field sessionsField = SabrSessionStore.class.getDeclaredField("SESSIONS"); - sessionsField.setAccessible(true); - final Map sessions = (Map) sessionsField.get(null); - for (final Object value : sessions.values()) { - if (value instanceof SabrSessionStore.Holder) { - final SabrSessionStore.Holder holder = (SabrSessionStore.Holder) value; - if (videoId.equals(holder.videoId)) { - return holder; - } - } - } - return null; - } - - private static SabrStats sabrStats(final SabrSessionStore.Holder holder) { - return new SabrStats(holder.session.getTotalResponseBytes(), - holder.session.getRequestNumber(), holder.session.getPeakCachedBytes(), - holder.session.getStreamState().getBandwidthEstimate(), - holder.session.getStreamState().getTargetAudioReadaheadMs(), - holder.session.getStreamState().getTargetVideoReadaheadMs(), - holder.session.getStreamState().getMinAudioReadaheadMs(), - holder.session.getStreamState().getMinVideoReadaheadMs(), - holder.session.getStreamState().getMaxTimeSinceLastRequestMs()); + diagnosticDetails, stateTransitionCount.get(), loadEventCount.get()); } private static String readTextFile(final File file) throws Exception { @@ -834,7 +754,6 @@ private static long position(final ExoPlayer player, final String videoId) { final AtomicLong value = new AtomicLong(); InstrumentationRegistry.getInstrumentation().runOnMainSync( () -> value.set(player.getCurrentPosition())); - SabrSessionStore.updatePlayerTime(videoId, value.get()); return value.get(); } @@ -1087,7 +1006,7 @@ private static final class Result { peakPssDeltaKb, linearPlaybackWallMs, firstFramePositionMs, firstFrameBufferedPositionMs, finalPositionMs, finalBufferedPositionMs; private final int rebufferCount, droppedFrames, stateTransitionCount, loadEventCount; - private final boolean warmWebViewRuntime, diagnosticDetails, coldSabrCachesEachTrial; + private final boolean warmWebViewRuntime, diagnosticDetails; private final SabrStats sabrStats; private final SeekTrace seekTrace; private final String url, videoId, targetCodec; @@ -1114,7 +1033,7 @@ private Result(final Path path, final int round, final boolean warmup, @Nullable final Format actualAudioFormat, final List stateTransitions, final List loadEvents, final boolean warmWebViewRuntime, final boolean diagnosticDetails, - final boolean coldSabrCachesEachTrial, final int stateTransitionCount, + final int stateTransitionCount, final int loadEventCount) { this.path=path; this.round=round; this.warmup=warmup; this.stream=stream; this.resolveMs=resolveMs; this.readyMs=readyMs; this.firstFrameMs=firstFrameMs; @@ -1138,7 +1057,6 @@ private Result(final Path path, final int round, final boolean warmup, this.stateTransitions=stateTransitions; this.loadEvents=loadEvents; this.warmWebViewRuntime=warmWebViewRuntime; this.diagnosticDetails=diagnosticDetails; - this.coldSabrCachesEachTrial=coldSabrCachesEachTrial; this.stateTransitionCount=stateTransitionCount; this.loadEventCount=loadEventCount; } @@ -1153,7 +1071,6 @@ private JSONObject toJson() throws Exception { .put("loadController",PRODUCTION_LOAD_CONTROLLER) .put("warmWebViewRuntime",warmWebViewRuntime) .put("diagnosticDetails",diagnosticDetails) - .put("coldSabrCachesEachTrial",coldSabrCachesEachTrial) .put("firstFrameMetricScope", "media_source_resolve_to_rendered_frame") .put("height",SelectingQualityResolver.effectiveHeight(stream)) .put("itag",stream.getItag()).put("codec",String.valueOf(stream.getCodec())) @@ -1271,48 +1188,6 @@ private SeekCacheSnapshot(final long targetMs, this.requestNumber = requestNumber; } - private static SeekCacheSnapshot fromSabr(final SabrSessionStore.Holder holder, - final long targetMs) { - if (holder == null) { - return EMPTY; - } - final int videoSeq = holder.session.getStreamState() - .getSegmentNumberAtOrAfterTimeMs(holder.videoFormat, targetMs); - final int previousVideoSeq = Math.max(1, videoSeq - 1); - final int nextVideoSeq = videoSeq + 1; - final int audioSeq = holder.session.getStreamState() - .getSegmentNumberAtOrAfterTimeMs(holder.audioFormat, targetMs); - return new SeekCacheSnapshot(targetMs, videoSeq, - holder.session.getStreamState().getSegmentStartMs(holder.videoFormat, - videoSeq), - holder.session.getStreamState().getSegmentEndMs(holder.videoFormat, - videoSeq), - hasMediaSegment(holder, holder.videoFormat, videoSeq), - previousVideoSeq, - hasMediaSegment(holder, holder.videoFormat, previousVideoSeq), - nextVideoSeq, - hasMediaSegment(holder, holder.videoFormat, nextVideoSeq), - audioSeq, - holder.session.getStreamState().getSegmentStartMs(holder.audioFormat, - audioSeq), - holder.session.getStreamState().getSegmentEndMs(holder.audioFormat, - audioSeq), - hasMediaSegment(holder, holder.audioFormat, audioSeq), - holder.session.getStreamState().getMinBufferedEndMs(), - holder.session.getStreamState().getBufferedEndMs(holder.videoFormat), - holder.session.getStreamState().getBufferedEndMs(holder.audioFormat), - holder.session.getCachedBytes(), - holder.session.getRequestNumber()); - } - - private static boolean hasMediaSegment(final SabrSessionStore.Holder holder, - final org.schabi.newpipe.extractor.services.youtube - .sabr.YoutubeSabrInfo.Format format, - final int sequence) { - return holder.session.getCachedSegment(SabrSegmentRequest.media(format, sequence)) - != null; - } - private JSONObject toJson() throws Exception { return new JSONObject() .put("targetMs", targetMs) @@ -1392,70 +1267,6 @@ private static SeekTrace fromNetwork(final long networkBytes, Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); } - private static SeekTrace fromSabr( - final org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession - .TraceSnapshot before, - final org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession - .TraceSnapshot after, - final SeekCacheSnapshot cacheBefore, - final SeekCacheSnapshot cacheAfter) { - if (before == null || after == null) { - return EMPTY; - } - return new SeekTrace(-1, - after.getResponseBytes() - before.getResponseBytes(), - after.getMediaPayloadBytes() - before.getMediaPayloadBytes(), - after.getControlPayloadBytes() - before.getControlPayloadBytes(), - after.getUmpOverheadBytes() - before.getUmpOverheadBytes(), - after.getDiscardedBytes() - before.getDiscardedBytes(), - after.getRequestNumber() - before.getRequestNumber(), - after.getCachedBytes() - before.getCachedBytes(), - delta(after.getSegments(), before.getSegments().size()), - delta(after.getDiscards(), before.getDiscards().size()), - Collections.emptyList(), cacheBefore, cacheAfter, - tail(before.getSegments(), 24), tail(before.getDiscards(), 24), - delta(after.getResponses(), before.getResponses().size())); - } - - private static SeekTrace fromSabrStartup(final SabrSessionStore.Holder holder, - final long startPositionMs) { - if (holder == null) { - return EMPTY; - } - final org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession - .TraceSnapshot after = holder.session.getTraceSnapshot(); - return new SeekTrace(-1, - after.getResponseBytes(), - after.getMediaPayloadBytes(), - after.getControlPayloadBytes(), - after.getUmpOverheadBytes(), - after.getDiscardedBytes(), - after.getRequestNumber(), - after.getCachedBytes(), - new ArrayList<>(after.getSegments()), - new ArrayList<>(after.getDiscards()), - Collections.emptyList(), - SeekCacheSnapshot.EMPTY, - SeekCacheSnapshot.fromSabr(holder, startPositionMs), - Collections.emptyList(), - Collections.emptyList(), - new ArrayList<>(after.getResponses())); - } - - private static List delta(final List values, final int start) { - if (start >= values.size()) { - return Collections.emptyList(); - } - return new ArrayList<>(values.subList(Math.max(0, start), values.size())); - } - - private static List tail(final List values, final int count) { - if (values.isEmpty()) { - return Collections.emptyList(); - } - return new ArrayList<>(values.subList(Math.max(0, values.size() - count), - values.size())); - } } private static final class SabrStats { diff --git a/app/src/androidTest/java/org/schabi/newpipe/player/datasource/SabrSponsorBlockStallProbeTest.java b/app/src/androidTest/java/org/schabi/newpipe/player/datasource/SabrSponsorBlockStallProbeTest.java deleted file mode 100644 index 49a723896..000000000 --- a/app/src/androidTest/java/org/schabi/newpipe/player/datasource/SabrSponsorBlockStallProbeTest.java +++ /dev/null @@ -1,462 +0,0 @@ -package org.schabi.newpipe.player.datasource; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertTrue; -import static org.junit.Assume.assumeTrue; - -import android.content.Context; -import android.net.Uri; - -import androidx.media3.common.MediaItem; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.filters.LargeTest; -import androidx.test.platform.app.InstrumentationRegistry; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.schabi.newpipe.extractor.localization.Localization; -import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; -import org.schabi.newpipe.extractor.services.youtube.ItagItem; -import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession; - -import java.io.File; -import java.io.IOException; -import java.lang.reflect.Constructor; -import java.lang.reflect.Field; -import java.util.ArrayDeque; -import java.util.Arrays; -import java.util.Deque; -import java.util.Map; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; - -/** Opt-in ownership regressions for SABR sources, periods, and session leases. */ -@RunWith(AndroidJUnit4.class) -@LargeTest -public final class SabrSponsorBlockStallProbeTest { - private static final int AUDIO_ITAG = 251; - private static final int VIDEO_ITAG = 137; - private static final byte[] AUDIO_INIT = mp4Sidx(20_001, 20_000, 19_999); - private static final byte[] VIDEO_INIT = mp4Sidx(5_000, 5_000, 5_000, 5_000); - - @Test - public void discardedSourcesDoNotCreateSessions() throws Exception { - assumeProbeEnabled(); - final Context context = context(); - final String videoId = "discarded-source-probe"; - final SabrSourceSpec spec = spec(videoId); - - for (int i = 0; i < 100; i++) { - final SabrDashMediaSource source = new SabrDashMediaSource(context, - mediaItem(videoId + '-' + i), spec); - source.releaseSourceInternal(); - } - - assertEquals("Constructing and discarding lightweight sources created a session", - 0, sessionCount(videoId)); - } - - @Test - public void discardedSourceClearsPreparedSessionWithoutCreatingPeriod() throws Exception { - assumeProbeEnabled(); - final Context context = context(); - final String videoId = "discarded-prepared-source-probe"; - final YoutubeSabrInfo.Format audio = format(AUDIO_ITAG, true); - final YoutubeSabrInfo.Format video = format(VIDEO_ITAG, false); - final YoutubeSabrInfo info = info(videoId, audio, video); - final YoutubeSabrSession session = session(context, videoId, info, audio, video); - final SabrSourceSpec spec = new SabrSourceSpec(videoId, info, audio, video, - new Localization("en", "US"), AUDIO_INIT, VIDEO_INIT, session); - - final SabrDashMediaSource source = new SabrDashMediaSource( - context, mediaItem(videoId), spec); - source.releaseSourceInternal(); - - assertTrue("Discarding a source without a period left its prepared session open", - sessionCacheClosed(session)); - assertEquals(0, sessionCount(videoId)); - } - - @Test - public void failedSourceConstructionClearsPreparedSession() throws Exception { - assumeProbeEnabled(); - final Context context = context(); - final String videoId = "failed-prepared-source-probe"; - final YoutubeSabrInfo.Format audio = format(AUDIO_ITAG, true); - final YoutubeSabrInfo.Format video = format(VIDEO_ITAG, false); - final YoutubeSabrInfo info = info(videoId, audio, video); - final YoutubeSabrSession session = session(context, videoId, info, audio, video); - final SabrSourceSpec spec = new SabrSourceSpec(videoId, info, audio, video, - new Localization("en", "US"), new byte[0], new byte[0], session); - - boolean failed = false; - try { - new SabrDashMediaSource(context, mediaItem(videoId), spec); - } catch (final IOException expected) { - failed = true; - } - - assertTrue("Invalid initialization data unexpectedly created a SABR source", failed); - assertTrue("Failed source construction left its prepared session open", - sessionCacheClosed(session)); - } - - @Test - public void concurrentLoadersShareOnePeriodLease() throws Exception { - assumeProbeEnabled(); - final Context context = context(); - final String videoId = "concurrent-loader-probe"; - final SabrSourceSpec spec = spec(videoId); - final SabrSessionStore.Holder holder = holder(context, spec); - final SabrSessionHandle handle = new SabrSessionHandle(context, spec); - final AtomicInteger leaseReferences = leaseReferences(holder); - final AtomicReference first = new AtomicReference<>(); - final AtomicReference second = new AtomicReference<>(); - final AtomicReference failure = new AtomicReference<>(); - final CountDownLatch start = new CountDownLatch(1); - final Thread firstLoader = loader(start, handle, first, failure); - final Thread secondLoader = loader(start, handle, second, failure); - - install(holder); - handle.onPeriodCreated(0); - try { - firstLoader.start(); - secondLoader.start(); - start.countDown(); - firstLoader.join(TimeUnit.SECONDS.toMillis(2)); - secondLoader.join(TimeUnit.SECONDS.toMillis(2)); - assertFalse("The first loader did not finish", firstLoader.isAlive()); - assertFalse("The second loader did not finish", secondLoader.isAlive()); - if (failure.get() != null) { - throw new AssertionError("A concurrent loader failed", failure.get()); - } - assertSame(holder, first.get()); - assertSame(holder, second.get()); - assertEquals("Concurrent loaders acquired more than one lease", 1, - leaseReferences.get()); - } finally { - handle.onPeriodReleased(); - SabrSessionStore.evict(videoId); - } - assertEquals(0, leaseReferences.get()); - assertTrue("The last period release did not evict its session", holder.isInvalidated()); - } - - @Test - public void releaseDuringAcquisitionClosesLateLease() throws Exception { - assumeProbeEnabled(); - final Context context = context(); - final String videoId = "release-during-acquire-probe"; - final SabrSourceSpec spec = spec(videoId); - final SabrSessionStore.Holder holder = holder(context, spec); - final SabrSessionHandle handle = new SabrSessionHandle(context, spec); - final AtomicReference result = new AtomicReference<>(); - final Thread loader = new Thread(() -> { - try { - handle.acquireHolder(); - } catch (final Throwable failure) { - result.set(failure); - } - }, "SabrLateLeaseProbe"); - - install(holder); - handle.onPeriodCreated(0); - synchronized (SabrSessionStore.class) { - loader.start(); - final long deadlineNs = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); - while (loader.getState() != Thread.State.BLOCKED - && System.nanoTime() < deadlineNs) { - Thread.yield(); - } - assertEquals("The loader did not reach Store acquisition", - Thread.State.BLOCKED, loader.getState()); - handle.onPeriodReleased(); - } - loader.join(TimeUnit.SECONDS.toMillis(2)); - assertFalse("The loader did not finish after Store acquisition resumed", loader.isAlive()); - assertTrue("A released period accepted a late lease", result.get() instanceof IOException); - assertTrue("The late lease was not closed", holder.isInvalidated()); - assertEquals(0, leaseReferences(holder).get()); - } - - @Test - public void releasedHandleCanAcquireFreshSessionForNextPeriod() throws Exception { - assumeProbeEnabled(); - final Context context = context(); - final String videoId = "period-reacquire-probe"; - final SabrSourceSpec spec = spec(videoId); - final SabrSessionHandle handle = new SabrSessionHandle(context, spec); - final SabrSessionStore.Holder first = holder(context, spec); - install(first); - - handle.onPeriodCreated(0); - assertSame(first, handle.acquireHolder()); - handle.onPeriodReleased(); - assertTrue(first.isInvalidated()); - - final SabrSessionStore.Holder second = holder(context, spec); - install(second); - handle.onPeriodCreated(30_000); - try { - assertSame("A new period reused the invalidated session", second, - handle.acquireHolder()); - assertFalse(second.isInvalidated()); - } finally { - handle.onPeriodReleased(); - SabrSessionStore.evict(videoId); - } - } - - @Test - public void oldLoaderCannotAttachLeaseToNextPeriodGeneration() throws Exception { - assumeProbeEnabled(); - final Context context = context(); - final String videoId = "cross-generation-acquire-probe"; - final SabrSourceSpec spec = spec(videoId); - final SabrSessionStore.Holder holder = holder(context, spec); - final SabrSessionHandle handle = new SabrSessionHandle(context, spec); - final AtomicReference oldResult = new AtomicReference<>(); - final AtomicReference newResult = new AtomicReference<>(); - final AtomicReference newFailure = new AtomicReference<>(); - final Thread oldLoader = new Thread(() -> { - try { - handle.acquireHolder(); - } catch (final Throwable failure) { - oldResult.set(failure); - } - }, "SabrOldGenerationProbe"); - final Thread newLoader = new Thread(() -> { - try { - newResult.set(handle.acquireHolder()); - } catch (final Throwable failure) { - newFailure.set(failure); - } - }, "SabrNewGenerationProbe"); - - install(holder); - try (SabrSessionStore.Lease guard = SabrSessionStore.acquire(context, spec)) { - handle.onPeriodCreated(0); - synchronized (SabrSessionStore.class) { - oldLoader.start(); - awaitBlocked(oldLoader); - handle.onPeriodReleased(); - handle.onPeriodCreated(30_000); - newLoader.start(); - awaitBlocked(newLoader); - } - oldLoader.join(TimeUnit.SECONDS.toMillis(2)); - newLoader.join(TimeUnit.SECONDS.toMillis(2)); - assertTrue("The released loader attached to the next period", - oldResult.get() instanceof IOException); - if (newFailure.get() != null) { - throw new AssertionError("The new period loader failed", newFailure.get()); - } - assertSame(holder, newResult.get()); - } finally { - handle.onPeriodReleased(); - SabrSessionStore.evict(videoId); - } - } - - @Test - public void duplicateSourcesOfSameVideoUseIndependentSessions() throws Exception { - assumeProbeEnabled(); - final Context context = context(); - final String videoId = "composite-session-key-probe"; - final YoutubeSabrInfo.Format audio = format(AUDIO_ITAG, true); - final YoutubeSabrInfo.Format video = format(VIDEO_ITAG, false); - final YoutubeSabrInfo info = info(videoId, audio, video); - final SabrSourceSpec firstSpec = spec(videoId, info, audio, video); - final SabrSourceSpec secondSpec = spec(videoId, info, audio, video); - final SabrSessionStore.Holder firstHolder = holder(context, firstSpec); - final SabrSessionStore.Holder secondHolder = holder(context, secondSpec); - final SabrSessionHandle firstHandle = new SabrSessionHandle(context, firstSpec); - final SabrSessionHandle secondHandle = new SabrSessionHandle(context, secondSpec); - - install(firstHolder); - install(secondHolder); - firstHandle.onPeriodCreated(0); - secondHandle.onPeriodCreated(0); - try { - assertSame(firstHolder, firstHandle.acquireHolder()); - assertSame(secondHolder, secondHandle.acquireHolder()); - assertEquals("Duplicate sources of the same video shared mutable session state", - 2, sessionCount(videoId)); - assertFalse(firstHolder.isInvalidated()); - assertFalse(secondHolder.isInvalidated()); - } finally { - firstHandle.onPeriodReleased(); - secondHandle.onPeriodReleased(); - SabrSessionStore.evict(videoId); - } - } - - private static Thread loader(final CountDownLatch start, - final SabrSessionHandle handle, - final AtomicReference result, - final AtomicReference failure) { - return new Thread(() -> { - try { - assertTrue(start.await(2, TimeUnit.SECONDS)); - result.set(handle.acquireHolder()); - } catch (final Throwable throwable) { - failure.compareAndSet(null, throwable); - } - }, "SabrConcurrentLeaseProbe"); - } - - private static void awaitBlocked(final Thread thread) { - final long deadlineNs = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); - while (thread.getState() != Thread.State.BLOCKED && System.nanoTime() < deadlineNs) { - Thread.yield(); - } - assertEquals("The loader did not reach Store acquisition", - Thread.State.BLOCKED, thread.getState()); - } - - private static Context context() { - return InstrumentationRegistry.getInstrumentation() - .getTargetContext().getApplicationContext(); - } - - private static MediaItem mediaItem(final String videoId) { - return new MediaItem.Builder().setUri(Uri.parse("sabr://" + videoId)).build(); - } - - private static SabrSourceSpec spec(final String videoId) throws Exception { - final YoutubeSabrInfo.Format audio = format(AUDIO_ITAG, true); - final YoutubeSabrInfo.Format video = format(VIDEO_ITAG, false); - return spec(videoId, info(videoId, audio, video), audio, video); - } - - private static SabrSourceSpec spec(final String videoId, - final YoutubeSabrInfo info, - final YoutubeSabrInfo.Format audio, - final YoutubeSabrInfo.Format video) { - return new SabrSourceSpec(videoId, info, audio, video, - new Localization("en", "US"), AUDIO_INIT, VIDEO_INIT); - } - - private static SabrSessionStore.Holder holder(final Context context, - final SabrSourceSpec spec) { - final YoutubeSabrSession session = session(context, spec.getVideoId(), spec.getInfo(), - spec.getAudioFormat(), spec.getVideoFormat()); - final SabrSessionStore.Holder holder = new SabrSessionStore.Holder(context, spec, session); - holder.setInitializationData(AUDIO_ITAG, AUDIO_INIT); - holder.setInitializationData(VIDEO_ITAG, VIDEO_INIT); - return holder; - } - - private static YoutubeSabrSession session(final Context context, - final String videoId, - final YoutubeSabrInfo info, - final YoutubeSabrInfo.Format audio, - final YoutubeSabrInfo.Format video) { - final File spoolDirectory = new File(context.getCacheDir(), - "sabr-lease-probe-" + videoId + '-' + System.nanoTime()); - return new YoutubeSabrSession(info, audio, video, spoolDirectory); - } - - private static boolean sessionCacheClosed(final YoutubeSabrSession session) throws Exception { - final Field field = YoutubeSabrSession.class.getDeclaredField("cacheClosed"); - field.setAccessible(true); - return field.getBoolean(session); - } - - private static void install(final SabrSessionStore.Holder holder) throws Exception { - final Field keyField = SabrSessionStore.Holder.class.getDeclaredField("key"); - final Field sessionsField = SabrSessionStore.class.getDeclaredField("SESSIONS"); - final Field orderField = SabrSessionStore.class.getDeclaredField("ORDER"); - keyField.setAccessible(true); - sessionsField.setAccessible(true); - orderField.setAccessible(true); - final Object key = keyField.get(holder); - @SuppressWarnings("unchecked") final Map sessions = - (Map) sessionsField.get(null); - @SuppressWarnings("unchecked") final Deque order = - (ArrayDeque) orderField.get(null); - synchronized (SabrSessionStore.class) { - sessions.put(key, holder); - order.remove(key); - order.addLast(key); - } - } - - private static int sessionCount(final String videoId) throws Exception { - final Field sessionsField = SabrSessionStore.class.getDeclaredField("SESSIONS"); - sessionsField.setAccessible(true); - @SuppressWarnings("unchecked") final Map sessions = - (Map) sessionsField.get(null); - int count = 0; - for (final SabrSessionStore.Holder holder : sessions.values()) { - if (videoId.equals(holder.videoId)) { - count++; - } - } - return count; - } - - private static AtomicInteger leaseReferences(final SabrSessionStore.Holder holder) - throws Exception { - final Field field = SabrSessionStore.Holder.class.getDeclaredField("leaseReferences"); - field.setAccessible(true); - return (AtomicInteger) field.get(holder); - } - - private static YoutubeSabrInfo.Format format(final int itag, final boolean audio) - throws Exception { - final ItagItem parsedFormat = ItagItem.getItag(itag); - parsedFormat.setWidth(audio ? -1 : 1920); - parsedFormat.setHeight(audio ? -1 : 1080); - parsedFormat.setBitrate(audio ? 128_000 : 2_000_000); - parsedFormat.setContentLength(100_000L); - parsedFormat.setApproxDurationMs(300_000L); - return YoutubeSabrInfo.Format.fromParsedFormat(parsedFormat, 123456L, null, - audio ? "audio/mp4" : "video/mp4", - audio ? "audio-track" : null, audio ? "Original" : null, - false, null, -1L, -1L); - } - - private static byte[] mp4Sidx(final int... durationsMs) { - final java.nio.ByteBuffer buffer = java.nio.ByteBuffer.allocate(32 + durationsMs.length * 12) - .order(java.nio.ByteOrder.BIG_ENDIAN); - buffer.putInt(buffer.capacity()); - buffer.put(new byte[]{'s', 'i', 'd', 'x'}); - buffer.putInt(0); - buffer.putInt(1); - buffer.putInt(1_000); - buffer.putInt(0); - buffer.putInt(0); - buffer.putShort((short) 0); - buffer.putShort((short) durationsMs.length); - for (final int durationMs : durationsMs) { - buffer.putInt(1); - buffer.putInt(durationMs); - buffer.putInt(0); - } - return buffer.array(); - } - - private static YoutubeSabrInfo info(final String videoId, - final YoutubeSabrInfo.Format... formats) throws Exception { - final Constructor constructor = - YoutubeSabrInfo.class.getDeclaredConstructor( - String.class, String.class, String.class, String.class, String.class, - String.class, java.util.List.class); - constructor.setAccessible(true); - return constructor.newInstance(videoId, "cpn", - "2.20250122.04.00", "visitor", "https://sabr.test", null, - Arrays.asList(formats)); - } - - private static void assumeProbeEnabled() { - assumeTrue("Set runSabrStallProbe=true to run the manual SABR stall probe", - Boolean.parseBoolean(InstrumentationRegistry.getArguments() - .getString("runSabrStallProbe", "false"))); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java index 4ed0b4ca0..b582fb53c 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java @@ -6,6 +6,7 @@ import androidx.annotation.Nullable; import org.schabi.newpipe.App; +import org.schabi.newpipe.extractor.ServiceList; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormatTimeline; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; @@ -165,7 +166,10 @@ static YoutubeSabrSession getOrCreateSession(@NonNull final Context context, "sabr-segments/" + spec.getVideoId() + '-' + System.nanoTime()); final YoutubeSabrSession created = new YoutubeSabrSession(spec.getInfo(), spec.getBootstrapAudioFormat(), spec.getVideoFormat(), spool); - final byte[] token = awaitWarmedToken(spec.getVideoId(), spec.getInfo(), provider(context)); + final LocalDomPoTokenProvider tokenProvider = provider(context); + created.setPoTokenRefresher(() -> tokenProvider.getPoToken(spec.getInfo())); + created.setIdentityRefresher(() -> refreshIdentity(context, spec.getInfo())); + final byte[] token = awaitWarmedToken(spec.getVideoId(), spec.getInfo(), tokenProvider); if (token == null || token.length == 0) { throw new SabrLogicException("SABR PO token provider returned no token for video=" + spec.getVideoId()); @@ -208,7 +212,10 @@ private static BootstrapResult createPreparation( final YoutubeSabrSession session = new YoutubeSabrSession(info, audio, video, new File(context.getCacheDir(), "sabr-bootstrap/" + info.getVideoId() + '-' + System.nanoTime())); - final byte[] token = awaitWarmedToken(info.getVideoId(), info, provider(context)); + final LocalDomPoTokenProvider tokenProvider = provider(context); + session.setPoTokenRefresher(() -> tokenProvider.getPoToken(info)); + session.setIdentityRefresher(() -> refreshIdentity(context, info)); + final byte[] token = awaitWarmedToken(info.getVideoId(), info, tokenProvider); if (token == null || token.length == 0) { throw new SabrLogicException("Missing SABR PO token for " + info.getVideoId()); } @@ -223,6 +230,41 @@ private static BootstrapResult createPreparation( return new BootstrapResult(initialization); } + @NonNull + private static YoutubeSabrSession.SessionIdentity refreshIdentity( + @NonNull final Context context, @NonNull final YoutubeSabrInfo rejectedInfo) + throws IOException, ExtractionException { + final StreamInfo refreshed = StreamInfo.getInfo(ServiceList.YouTube, + "https://www.youtube.com/watch?v=" + rejectedInfo.getVideoId()); + YoutubeSabrInfo freshInfo = null; + for (final VideoStream stream : refreshed.getVideoOnlyStreams()) { + if (stream.getDeliveryMethod() == DeliveryMethod.SABR + && stream.getDeliveryMethodInfo() instanceof YoutubeSabrInfo) { + freshInfo = (YoutubeSabrInfo) stream.getDeliveryMethodInfo(); + break; + } + } + if (freshInfo == null) { + for (final AudioStream stream : refreshed.getAudioStreams()) { + if (stream.getDeliveryMethod() == DeliveryMethod.SABR + && stream.getDeliveryMethodInfo() instanceof YoutubeSabrInfo) { + freshInfo = (YoutubeSabrInfo) stream.getDeliveryMethodInfo(); + break; + } + } + } + if (freshInfo == null) { + throw new SabrLogicException("Refreshed player response has no SABR identity for " + + rejectedInfo.getVideoId()); + } + final byte[] token = provider(context).getPoToken(freshInfo); + if (token == null || token.length == 0) { + throw new SabrLogicException("Refreshed SABR identity returned no PO token for " + + rejectedInfo.getVideoId()); + } + return new YoutubeSabrSession.SessionIdentity(freshInfo, token); + } + @Nullable private static synchronized YoutubeSabrSession getSession(@NonNull final String key) { return SESSIONS.get(key); diff --git a/app/src/main/java/org/schabi/newpipe/youtube/LocalDomPoTokenProvider.kt b/app/src/main/java/org/schabi/newpipe/youtube/LocalDomPoTokenProvider.kt index f3965089b..59be978f8 100644 --- a/app/src/main/java/org/schabi/newpipe/youtube/LocalDomPoTokenProvider.kt +++ b/app/src/main/java/org/schabi/newpipe/youtube/LocalDomPoTokenProvider.kt @@ -26,7 +26,7 @@ class LocalDomPoTokenProvider(context: Context) { val session = OneShotMintSession.create( appContext, visitorData, - YoutubeParsingHelper.getClientVersion(), + info.clientVersion, createCredentialHeaders(), ) return try { diff --git a/app/src/main/java/us/shandian/giga/get/SabrDownloader.kt b/app/src/main/java/us/shandian/giga/get/SabrDownloader.kt index 7a83c45b0..d1159e78f 100644 --- a/app/src/main/java/us/shandian/giga/get/SabrDownloader.kt +++ b/app/src/main/java/us/shandian/giga/get/SabrDownloader.kt @@ -2,10 +2,13 @@ package us.shandian.giga.get import android.util.Log import org.schabi.newpipe.BuildConfig +import org.schabi.newpipe.extractor.ServiceList import org.schabi.newpipe.extractor.services.youtube.sabr.exception.SabrProtocolException import org.schabi.newpipe.extractor.services.youtube.sabr.exception.SabrRecoverableException import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession +import org.schabi.newpipe.extractor.stream.DeliveryMethod +import org.schabi.newpipe.extractor.stream.StreamInfo import org.schabi.newpipe.youtube.LocalDomPoTokenProvider import java.io.File import java.io.FileOutputStream @@ -86,7 +89,12 @@ internal class SabrDownloader( SabrDownloadFormatResolver.selectedVideoFormat(info, recoveries), null, ) - val poToken = LocalDomPoTokenProvider(mission.context).getPoToken(info) + val tokenProvider = LocalDomPoTokenProvider(mission.context) + session.setPoTokenRefresher { tokenProvider.getPoToken(info) } + session.setIdentityRefresher { + refreshIdentity(info.videoId, tokenProvider) + } + val poToken = tokenProvider.getPoToken(info) session.setPoToken(poToken) val workDir = prepareWorkDirectory() val targets = SabrDownloadFormatResolver.buildTargets(info, recoveries, workDir) @@ -132,6 +140,32 @@ internal class SabrDownloader( completeMission(finalBytes) } + private fun refreshIdentity( + videoId: String, + tokenProvider: LocalDomPoTokenProvider, + ): YoutubeSabrSession.SessionIdentity { + val refreshed = StreamInfo.getInfo( + ServiceList.YouTube, + "https://www.youtube.com/watch?v=$videoId", + ) + val freshInfo = (refreshed.videoOnlyStreams.asSequence() + + refreshed.audioStreams.asSequence()) + .firstNotNullOfOrNull { stream -> + if (stream.deliveryMethod == DeliveryMethod.SABR) { + stream.deliveryMethodInfo as? YoutubeSabrInfo + } else { + null + } + } + ?: throw SabrProtocolException( + "Refreshed player response has no SABR identity for $videoId", + ) + return YoutubeSabrSession.SessionIdentity( + freshInfo, + tokenProvider.getPoToken(freshInfo), + ) + } + @Throws(IOException::class) private fun validateRecoveryInfo(): Array { val recoveries = mission.recoveryInfo ?: throw IOException("Missing SABR recovery info") diff --git a/app/src/test/java/org/schabi/newpipe/player/datasource/SabrPreferredAudioLanguageTest.java b/app/src/test/java/org/schabi/newpipe/player/datasource/SabrPreferredAudioLanguageTest.java deleted file mode 100644 index 06c54a47f..000000000 --- a/app/src/test/java/org/schabi/newpipe/player/datasource/SabrPreferredAudioLanguageTest.java +++ /dev/null @@ -1,73 +0,0 @@ -package org.schabi.newpipe.player.datasource; - -import static org.junit.Assert.assertSame; - -import org.junit.Test; -import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; -import org.schabi.newpipe.extractor.services.youtube.ItagItem; - -import java.lang.reflect.Constructor; -import java.util.Arrays; - -public class SabrPreferredAudioLanguageTest { - - @Test - public void preferredLanguageSelectsHighestBitrateRegionalTrack() throws Exception { - final YoutubeSabrInfo.Format original = audioFormat( - 140, "en.4", "English (original)", 128_000); - final YoutubeSabrInfo.Format portugueseLow = audioFormat( - 139, "pt-BR.4", "Portuguese (Brazil)", 96_000); - final YoutubeSabrInfo.Format portugueseHigh = audioFormat( - 251, "pt-BR.4", "Portuguese (Brazil)", 160_000); - final YoutubeSabrInfo info = info(original, portugueseLow, portugueseHigh); - - assertSame(portugueseHigh, SabrSessionStore.pickAudioFormat(info, null, "pt")); - } - - @Test - public void explicitTrackOverridesPreferredLanguage() throws Exception { - final YoutubeSabrInfo.Format original = audioFormat( - 140, "en.4", "English (original)", 128_000); - final YoutubeSabrInfo.Format portuguese = audioFormat( - 251, "pt-BR.4", "Portuguese (Brazil)", 160_000); - final YoutubeSabrInfo.Format spanish = audioFormat( - 250, "es-ES.4", "Spanish (Spain)", 96_000); - final YoutubeSabrInfo info = info(original, portuguese, spanish); - - assertSame(spanish, - SabrSessionStore.pickAudioFormat(info, "es-ES.4", "pt")); - } - - @Test - public void missingPreferredLanguageFallsBackToOriginal() throws Exception { - final YoutubeSabrInfo.Format original = audioFormat( - 140, "en.4", "English (original)", 128_000); - final YoutubeSabrInfo.Format spanish = audioFormat( - 251, "es-ES.4", "Spanish (Spain)", 160_000); - final YoutubeSabrInfo info = info(original, spanish); - - assertSame(original, SabrSessionStore.pickAudioFormat(info, null, "pt")); - } - - private static YoutubeSabrInfo.Format audioFormat(final int itag, - final String trackId, - final String displayName, - final int bitrate) throws Exception { - final ItagItem parsedFormat = ItagItem.getItag(itag); - parsedFormat.setBitrate(bitrate); - parsedFormat.setContentLength(100_000L); - parsedFormat.setApproxDurationMs(300_000L); - return YoutubeSabrInfo.Format.fromParsedFormat(parsedFormat, 123456L, null, "audio/mp4", - trackId, displayName, false, null, -1L, -1L); - } - - private static YoutubeSabrInfo info(final YoutubeSabrInfo.Format... formats) throws Exception { - final Constructor constructor = - YoutubeSabrInfo.class.getDeclaredConstructor( - String.class, String.class, String.class, String.class, String.class, - String.class, java.util.List.class); - constructor.setAccessible(true); - return constructor.newInstance("video-id", "cpn", - "2.test", "visitor", "https://sabr.test", null, Arrays.asList(formats)); - } -} diff --git a/app/src/test/java/org/schabi/newpipe/player/datasource/SabrSessionPoTokenPrewarmerTest.kt b/app/src/test/java/org/schabi/newpipe/player/datasource/SabrSessionPoTokenPrewarmerTest.kt deleted file mode 100644 index 58143920e..000000000 --- a/app/src/test/java/org/schabi/newpipe/player/datasource/SabrSessionPoTokenPrewarmerTest.kt +++ /dev/null @@ -1,170 +0,0 @@ -package org.schabi.newpipe.player.datasource - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotEquals -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test -import org.schabi.newpipe.extractor.localization.ContentCountry -import org.schabi.newpipe.extractor.localization.Localization -import java.util.concurrent.CountDownLatch -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicInteger - -class SabrSessionPoTokenPrewarmerTest { - @Test(timeout = 5_000) - fun sameContextSharesOneInFlightTask() { - val executor = Executors.newSingleThreadExecutor() - val prewarmer = ContextBoundSingleFlight(executor) - val started = CountDownLatch(1) - val release = CountDownLatch(1) - val calls = AtomicInteger() - try { - assertTrue(prewarmer.start("context") { - calls.incrementAndGet() - started.countDown() - release.await() - "token" - }) - assertTrue(started.await(2, TimeUnit.SECONDS)) - val shared = prewarmer.inFlight("context") - - assertFalse(prewarmer.start("context") { - calls.incrementAndGet() - "duplicate" - }) - release.countDown() - - assertEquals("token", shared?.get(2, TimeUnit.SECONDS)) - assertEquals(1, calls.get()) - } finally { - release.countDown() - executor.shutdownNow() - } - } - - @Test(timeout = 5_000) - fun replacingContextCancelsOldTask() { - val executor = Executors.newSingleThreadExecutor() - val prewarmer = ContextBoundSingleFlight(executor) - val started = CountDownLatch(1) - val replacementStarted = CountDownLatch(1) - val replacementRelease = CountDownLatch(1) - try { - assertTrue(prewarmer.start("old") { - started.countDown() - CountDownLatch(1).await() - "old-token" - }) - assertTrue(started.await(2, TimeUnit.SECONDS)) - val old = prewarmer.inFlight("old") - - assertTrue(prewarmer.start("new") { - replacementStarted.countDown() - replacementRelease.await() - "new-token" - }) - assertTrue(replacementStarted.await(2, TimeUnit.SECONDS)) - val replacement = prewarmer.inFlight("new") - - assertTrue(old?.isCancelled == true) - replacementRelease.countDown() - assertEquals("new-token", replacement?.get(2, TimeUnit.SECONDS)) - assertNull(prewarmer.inFlight("old")) - } finally { - replacementRelease.countDown() - executor.shutdownNow() - } - } - - @Test(timeout = 5_000) - fun foregroundSharesTaskWhileClientVersionResolutionIsBlocked() { - val prewarmExecutor = Executors.newSingleThreadExecutor() - val foregroundExecutor = Executors.newSingleThreadExecutor() - val prewarmer = ContextBoundSingleFlight< - YoutubeSessionPoTokenPrewarmContext, - PreparedYoutubeSessionPoToken - >(prewarmExecutor) - val requestContext = YoutubeSessionPoTokenContext( - "MWEB", - "2.test", - "test-user-agent", - Localization("en", "US"), - ContentCountry("US"), - false, - "credential-a", - ) - val versionResolutionStarted = CountDownLatch(1) - val versionResolutionRelease = CountDownLatch(1) - val foregroundStarted = CountDownLatch(1) - val initializations = AtomicInteger() - val synchronousInitializations = AtomicInteger() - try { - assertTrue(prewarmer.start(requestContext.prewarmContext()) { - versionResolutionStarted.countDown() - versionResolutionRelease.await() - initializations.incrementAndGet() - PreparedYoutubeSessionPoToken( - requestContext, - YoutubeSessionPoToken("visitor-data", "prewarmed-token"), - ) - }) - assertTrue(versionResolutionStarted.await(2, TimeUnit.SECONDS)) - - val foreground = foregroundExecutor.submit { - foregroundStarted.countDown() - val prepared = prewarmer.inFlight(requestContext.prewarmContext())?.get() - if (prepared?.context == requestContext) { - prepared.token - } else { - synchronousInitializations.incrementAndGet() - YoutubeSessionPoToken("visitor-data", "synchronous-token") - } - } - assertTrue(foregroundStarted.await(2, TimeUnit.SECONDS)) - assertFalse(foreground.isDone) - assertEquals(0, initializations.get()) - assertEquals(0, synchronousInitializations.get()) - - versionResolutionRelease.countDown() - - assertEquals( - "prewarmed-token", - foreground.get(2, TimeUnit.SECONDS).poToken, - ) - assertEquals(1, initializations.get()) - assertEquals(0, synchronousInitializations.get()) - } finally { - versionResolutionRelease.countDown() - prewarmExecutor.shutdownNow() - foregroundExecutor.shutdownNow() - } - } - - @Test - fun fullPlayerContextControlsTaskIdentity() { - val context = YoutubeSessionPoTokenContext( - "MWEB", - "2.test", - "test-user-agent", - Localization("en", "US"), - ContentCountry("US"), - false, - "credential-a", - ) - - assertNotEquals(context, context.copy(clientName = "WEB")) - assertNotEquals(context, context.copy(clientVersion = "3.test")) - assertEquals( - context.prewarmContext(), - context.copy(clientVersion = "3.test").prewarmContext(), - ) - assertNotEquals(context, context.copy(userAgent = "other-user-agent")) - assertNotEquals(context, context.copy(localization = Localization("zh", "CN"))) - assertNotEquals(context, context.copy(contentCountry = ContentCountry("CN"))) - assertNotEquals(context, context.copy(loggedIn = true)) - assertNotEquals(context, context.copy(credentialIdentity = "credential-b")) - } -} From 9a58c5e8eb8a44fce19430068676fe5f5b28e863 Mon Sep 17 00:00:00 2001 From: InfinityLoop1308 <96324692+InfinityLoop1308@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:39:43 +0800 Subject: [PATCH 09/13] 9 --- .../datasource/SabrDashMediaSource.java | 41 +++- .../player/datasource/SabrMediaBridge.java | 180 +++++++------- .../datasource/SabrSegmentDataSource.java | 9 +- .../player/datasource/SabrSessionStore.java | 226 ++++-------------- .../player/datasource/SabrSourceSpec.java | 84 +++++-- .../us/shandian/giga/get/SabrDownloader.kt | 42 ++-- 6 files changed, 255 insertions(+), 327 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDashMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDashMediaSource.java index 4e55622bd..f00a7f321 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDashMediaSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDashMediaSource.java @@ -72,6 +72,17 @@ public SabrDashMediaSource(@NonNull final Context context, try { final long durationMs = spec.getDurationMs(); this.durationUs = durationMs > 0 ? durationMs * 1000L : C.TIME_UNSET; + final SabrMediaBridge preparationBridge = getOrCreateBridge(); + if (spec.peekAudioTimeline() == null || spec.peekVideoTimeline() == null) { + try { + preparationBridge.fetchSegments(0, spec.getBootstrapAudioFormat(), true, true); + } catch (final ExtractionException error) { + throw new IOException("Could not prepare SABR first response", error); + } + if (spec.peekAudioTimeline() == null || spec.peekVideoTimeline() == null) { + throw new IOException("SABR first response did not provide initialization"); + } + } final DataSource.Factory sabrDataSourceFactory = playerDataSource.getCacheDataSourceFactory( this::createDataSource, this::buildCacheKey); @@ -81,7 +92,7 @@ public SabrDashMediaSource(@NonNull final Context context, /* manifestDataSourceFactory= */ null) .createMediaSource(manifest, mediaItem); Log.d(TAG, "create source video=" + spec.getVideoId() - + " videoItag=" + spec.getVideoFormat().getItag() + + " videoItag=" + spec.getBootstrapVideoFormat().getItag() + " bootstrapAudioItag=" + spec.getBootstrapAudioFormat().getItag()); } catch (final IOException | RuntimeException | Error e) { throw e; @@ -170,8 +181,7 @@ private static DashManifest buildManifest(final SabrSourceSpec spec, + "minBufferTime=\"PT1.5S\" mediaPresentationDuration=\"" + formatDuration(durationMs) + "\">" + "" - + adaptationSet(spec, Collections.singletonList(spec.getVideoFormat()), - C.TRACK_TYPE_VIDEO, "0") + + videoAdaptationSets(spec) + audioAdaptationSets(spec) + ""; try { @@ -197,6 +207,10 @@ private static String audioAdaptationSets(final SabrSourceSpec spec) { return result.toString(); } + private static String videoAdaptationSets(final SabrSourceSpec spec) { + return adaptationSet(spec, spec.getVideoFormats(), C.TRACK_TYPE_VIDEO, "0"); + } + private static String adaptationSet(final SabrSourceSpec spec, final List formats, final int trackType, @@ -373,25 +387,28 @@ public long selectTracks(final ExoTrackSelection[] selections, private boolean updateActiveTracks(final ExoTrackSelection[] selections) { boolean videoActive = false; boolean audioActive = false; + YoutubeSabrInfo.Format currentVideo = null; + YoutubeSabrInfo.Format currentAudio = null; for (final ExoTrackSelection selection : selections) { if (selection == null) { continue; } final Format format = selection.getSelectedFormat(); - if (format != null && spec.getFormatKey(spec.getVideoFormat()) - .equals(format.id)) { - videoActive = true; - } else if (format != null) { - for (final YoutubeSabrInfo.Format audio : spec.getAudioFormats()) { - if (spec.getFormatKey(audio).equals(format.id)) { - audioActive = true; - break; - } + if (format != null) { + final YoutubeSabrInfo.Format selected = spec.getFormat(format.id); + if (selected != null && selected.isVideo()) { + videoActive = true; + currentVideo = selected; + } else if (selected != null) { + audioActive = true; + currentAudio = selected; } } } Log.d(TAG, "activeTracks video=" + spec.getVideoId() + " video=" + videoActive + " audio=" + audioActive); + getOrCreateBridge().setSelectedFormats(currentAudio, currentVideo); + getOrCreateBridge().setActiveTracks(audioActive, videoActive); return videoActive || audioActive; } diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaBridge.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaBridge.java index 847e8f225..8d7a617b0 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaBridge.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaBridge.java @@ -21,7 +21,6 @@ import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; /** Synchronously bridges one Media3 segment read to serialized SABR transactions. */ final class SabrMediaBridge { @@ -32,13 +31,15 @@ final class SabrMediaBridge { private final YoutubeSabrSession session; private final SabrSourceSpec spec; private final Context appContext; - private final YoutubeSabrInfo.Format videoFormat; - private final YoutubeSabrFormatTimeline audioTimeline; - private final YoutubeSabrFormatTimeline videoTimeline; + private volatile YoutubeSabrInfo.Format videoFormat; + @Nullable private volatile YoutubeSabrInfo.Format currentAudioFormat; + private volatile boolean audioActive; + private volatile boolean videoActive; + @Nullable private volatile YoutubeSabrFormatTimeline audioTimeline; + @Nullable private volatile YoutubeSabrFormatTimeline videoTimeline; private final Map ahead = new ConcurrentHashMap<>(); private final Map nextSequences = new ConcurrentHashMap<>(); - private final Map activeDemands = new ConcurrentHashMap<>(); private final Deque aheadOrder = new ArrayDeque<>(); private final Object requestLock = new Object(); @@ -51,40 +52,59 @@ final class SabrMediaBridge { appContext = context.getApplicationContext(); this.session = session; this.spec = spec; - videoFormat = spec.getVideoFormat(); - audioTimeline = spec.getAudioTimeline(); - videoTimeline = spec.getVideoTimeline(); + videoFormat = spec.getBootstrapVideoFormat(); + audioActive = true; + videoActive = true; + audioTimeline = spec.peekAudioTimeline(); + videoTimeline = spec.peekVideoTimeline(); } - @NonNull - byte[] fetchInitialization(@NonNull final YoutubeSabrInfo.Format format, - final long timeoutMs) - throws IOException, ExtractionException { - byte[] data = spec.getInitializationData(format); - if (data != null) return data; - final long deadlineNs = System.nanoTime() - + TimeUnit.MILLISECONDS.toNanos(Math.max(1, timeoutMs)); + void setActiveTracks(final boolean audioActive, final boolean videoActive) { + this.audioActive = audioActive; + this.videoActive = videoActive; + } + + void setSelectedFormats(@Nullable final YoutubeSabrInfo.Format audio, + @Nullable final YoutubeSabrInfo.Format video) { + currentAudioFormat = audio; + if (video != null) videoFormat = video; + } + + /** Sends and consumes one ordinary SABR response. */ + YoutubeSabrSession.RequestResult fetchSegments( + final long playerTimeMs, + @NonNull final YoutubeSabrInfo.Format activeAudio, + final boolean audioActive, + final boolean videoActive) throws IOException, ExtractionException { synchronized (requestLock) { requestThread = Thread.currentThread(); try { - data = spec.getInitializationData(format); - if (data != null) return data; - awaitBackoffWithinBudget(SabrSegmentKey.initialization(format), deadlineNs); - if (stopped) throw new IOException("SABR bridge is stopped"); - final long remainingMs = Math.max(1, TimeUnit.NANOSECONDS.toMillis( - ensureBudget(SabrSegmentKey.initialization(format), deadlineNs))); - data = session.fetchInitializationData(format, remainingMs, - segment -> acceptSegment(segment, format.isAudio() ? format : null)); - publishBackoff(session.getBackoffRemainingMs()); - ensureBudget(SabrSegmentKey.initialization(format), deadlineNs); - spec.putInitializationData(format, data); - return data; + final YoutubeSabrSession.RequestResult result = session.requestOnce( + activeAudio, + videoFormat, playerTimeMs, + audioTimeline, bufferedThrough(activeAudio), + videoTimeline, bufferedThrough(videoFormat), + audioActive, videoActive, videoActive && !audioActive, + 1.0f, segment -> acceptSegment(segment, activeAudio)); + publishBackoff(result.getBackoffMs()); + return result; } finally { requestThread = null; } } } + @NonNull + byte[] getInitializationData(@NonNull final YoutubeSabrInfo.Format format) + throws IOException { + final byte[] data = spec.getInitializationData(format); + if (data == null) { + throw new IOException("SABR initialization is unavailable: itag=" + + format.getItag()); + } + return data; + } + void seedSegments(@NonNull final List segments) { for (final SabrMediaSegment segment : segments) { acceptSegment(segment, spec.getBootstrapAudioFormat()); @@ -92,18 +112,14 @@ void seedSegments(@NonNull final List segments) { } @NonNull - SabrMediaSegment fetchSegment(@NonNull final SabrSegmentKey request, + SabrMediaSegment awaitSegment(@NonNull final SabrSegmentKey request, final long timeoutMs) throws IOException, ExtractionException { final long deadlineNs = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(Math.max(1, timeoutMs)); - retainDemand(request); - try { - SabrMediaSegment segment = ahead.get(request); + SabrMediaSegment segment = ahead.get(request); if (segment != null) return segment; - if (!request.isInitialization()) { - nextSequences.put(request.getFormat(), request.getSequenceNumber()); - } + nextSequences.put(request.getFormat(), request.getSequenceNumber()); synchronized (requestLock) { requestThread = Thread.currentThread(); try { @@ -114,24 +130,14 @@ SabrMediaSegment fetchSegment(@NonNull final SabrSegmentKey request, if (segment != null) return segment; awaitBackoffWithinBudget(request, deadlineNs); if (stopped) throw new IOException("SABR bridge is stopped"); - - final YoutubeSabrInfo.Format activeAudio = activeAudioFormat(request); - final boolean audioActive = activeAudio != null; - final boolean videoActive = hasActiveDemandFor(videoFormat); - final long playerTimeMs = request.isInitialization() ? 0 - : Math.max(0, timelineFor(request.getFormat()) + final YoutubeSabrInfo.Format activeAudio = request.getFormat().isAudio() + ? request.getFormat() : (currentAudioFormat == null + ? spec.getBootstrapAudioFormat() : currentAudioFormat); + final long playerTimeMs = Math.max(0, timelineFor(request.getFormat()) .getStartMs(request.getSequenceNumber())); - final YoutubeSabrSession.RequestResult result = session.requestOnce( - activeAudio == null ? spec.getBootstrapAudioFormat() : activeAudio, - videoFormat, - playerTimeMs, - audioTimeline, activeAudio == null ? 0 : bufferedThrough(activeAudio), - videoTimeline, bufferedThrough(videoFormat), - audioActive, videoActive, videoActive && !audioActive, - 1.0f, received -> acceptSegment(received, activeAudio)); - publishBackoff(result.getBackoffMs()); + final YoutubeSabrSession.RequestResult result = fetchSegments( + playerTimeMs, activeAudio, audioActive, videoActive); if (result.isDeferred()) continue; - segment = ahead.get(request); if (segment != null) return segment; ensureBudget(request, deadlineNs); @@ -149,12 +155,10 @@ videoTimeline, bufferedThrough(videoFormat), } finally { requestThread = null; } - } - } finally { - releaseDemand(request); } } + void discard(@NonNull final SabrSegmentKey request) { final SabrMediaSegment segment = ahead.remove(request); synchronized (aheadOrder) { @@ -234,7 +238,27 @@ private SabrLogicException timeout(@NonNull final SabrSegmentKey request, private void acceptSegment(@NonNull final SabrMediaSegment segment, @Nullable final YoutubeSabrInfo.Format requestedAudio) { - if (stopped || segment.getHeader().isInitSegment()) { + if (stopped) { + segment.delete(); + return; + } + if (segment.getHeader().isInitSegment()) { + final YoutubeSabrInfo.Format format = formatForSegment(segment, requestedAudio); + if (format != null) { + final byte[] data = segment.getData(); + spec.putInitializationData(format, data); + try { + final YoutubeSabrFormatTimeline timeline = + YoutubeSabrFormatTimeline.parse(format, data); + spec.putTimeline(format, timeline); + if (format.isAudio()) audioTimeline = timeline; + else videoTimeline = timeline; + } catch (final ExtractionException error) { + segment.delete(); + throw new IllegalStateException("Invalid SABR initialization: itag=" + + format.getItag(), error); + } + } segment.delete(); return; } @@ -257,48 +281,11 @@ private void acceptSegment(@NonNull final SabrMediaSegment segment, } private void trimAhead() { - int protectedKeysSeen = 0; - while (aheadOrder.size() > MAX_AHEAD_SEGMENTS - && protectedKeysSeen < aheadOrder.size()) { + while (aheadOrder.size() > MAX_AHEAD_SEGMENTS) { final SabrSegmentKey oldest = aheadOrder.removeFirst(); - if (activeDemands.containsKey(oldest)) { - aheadOrder.addLast(oldest); - protectedKeysSeen++; - continue; - } final SabrMediaSegment removed = ahead.remove(oldest); if (removed != null) removed.delete(); - protectedKeysSeen = 0; - } - } - - private void retainDemand(@NonNull final SabrSegmentKey request) { - activeDemands.compute(request, (ignored, count) -> { - if (count == null) return new AtomicInteger(1); - count.incrementAndGet(); - return count; - }); - } - - private void releaseDemand(@NonNull final SabrSegmentKey request) { - activeDemands.computeIfPresent(request, - (ignored, count) -> count.decrementAndGet() <= 0 ? null : count); - } - - private boolean hasActiveDemandFor(@NonNull final YoutubeSabrInfo.Format format) { - for (final SabrSegmentKey demand : activeDemands.keySet()) { - if (demand.getFormat().getItag() == format.getItag()) return true; - } - return false; - } - - @Nullable - private YoutubeSabrInfo.Format activeAudioFormat(@NonNull final SabrSegmentKey request) { - if (request.getFormat().isAudio()) return request.getFormat(); - for (final SabrSegmentKey demand : activeDemands.keySet()) { - if (demand.getFormat().isAudio()) return demand.getFormat(); } - return null; } private int bufferedThrough(@NonNull final YoutubeSabrInfo.Format format) { @@ -317,8 +304,11 @@ private YoutubeSabrInfo.Format formatForSegment( @Nullable final YoutubeSabrInfo.Format requestedAudio) { final int itag = segment.getHeader().getItag(); final String xtags = segment.getHeader().getXtags(); - if (videoFormat.getItag() == itag && (xtags == null - || Objects.equals(videoFormat.getXtags(), xtags))) return videoFormat; + for (final YoutubeSabrInfo.Format video : spec.getVideoFormats()) { + if (video.getItag() == itag && (xtags == null || Objects.equals(video.getXtags(), xtags))) { + return video; + } + } if (requestedAudio != null && requestedAudio.getItag() == itag && (xtags == null || Objects.equals(requestedAudio.getXtags(), xtags))) return requestedAudio; YoutubeSabrInfo.Format onlyMatchingItag = null; diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java index 3c3dc1c09..93322e92f 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java @@ -81,12 +81,7 @@ public long open(final DataSpec dataSpec) throws IOException { } private byte[] initializationData(final YoutubeSabrInfo.Format format) throws IOException { - try { - return bridge.fetchInitialization(format, FETCH_TIMEOUT_MS); - } catch (final org.schabi.newpipe.extractor.exceptions.ExtractionException error) { - throw new IOException("SABR initialization extraction failed: itag=" - + format.getItag(), error); - } + return bridge.getInitializationData(format); } @Override @@ -137,7 +132,7 @@ private SabrMediaSegment awaitSegment(final SabrSegmentKey request) throws IOExc + request.getFormat().getItag() + ", seq=" + request.getSequenceNumber()); } try { - return bridge.fetchSegment(request, FETCH_TIMEOUT_MS); + return bridge.awaitSegment(request, FETCH_TIMEOUT_MS); } catch (final org.schabi.newpipe.extractor.exceptions.ExtractionException error) { throw new IOException("SABR segment extraction failed", error); } diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java index b582fb53c..1fdd74da0 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java @@ -8,10 +8,8 @@ import org.schabi.newpipe.App; import org.schabi.newpipe.extractor.ServiceList; import org.schabi.newpipe.extractor.exceptions.ExtractionException; -import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormatTimeline; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession; -import org.schabi.newpipe.extractor.services.youtube.sabr.media.SabrMediaSegment; import org.schabi.newpipe.extractor.stream.DeliveryMethod; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.StreamInfo; @@ -29,34 +27,25 @@ import java.util.Comparator; import java.util.Map; import java.util.Objects; -import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.FutureTask; -import java.util.concurrent.atomic.AtomicReference; /** Prepares SABR source data and retains a small LRU of Extractor protocol sessions. */ public final class SabrSessionStore { - private static final int MAX_BOOTSTRAP_CACHE_ENTRIES = 32; + private static final int MAX_WARM_ENTRIES = 32; private static final int MAX_SESSIONS = 8; - private static final ExecutorService BOOTSTRAP_EXECUTOR = Executors.newFixedThreadPool(2, - runnable -> daemonThread(runnable, "SabrNativeBootstrap")); - private static final ExecutorService TOKEN_EXECUTOR = Executors.newSingleThreadExecutor( - runnable -> daemonThread(runnable, "SabrTokenPrewarm")); - private static final Map> BOOTSTRAP_IN_FLIGHT = - new ConcurrentHashMap<>(); - private static final Map> TOKEN_IN_FLIGHT = new ConcurrentHashMap<>(); - private static final Map BOOTSTRAP_CACHE = - Collections.synchronizedMap(new LinkedHashMap( - MAX_BOOTSTRAP_CACHE_ENTRIES + 1, 0.75f, true) { + private static final ExecutorService WARM_EXECUTOR = Executors.newFixedThreadPool(2, + runnable -> daemonThread(runnable, "SabrAdaptivePrewarm")); + private static final Map> WARM_ENTRIES = + Collections.synchronizedMap(new LinkedHashMap>( + MAX_WARM_ENTRIES + 1, 0.75f, true) { @Override protected boolean removeEldestEntry( - final Map.Entry eldest) { - if (size() <= MAX_BOOTSTRAP_CACHE_ENTRIES) return false; - eldest.getValue().discardMediaSegments(); - return true; + final Map.Entry> eldest) { + return size() > MAX_WARM_ENTRIES; } }); private static final Map SESSIONS = @@ -78,30 +67,6 @@ private static Thread daemonThread(final Runnable runnable, final String name) { return thread; } - private static final class BootstrapResult { - @NonNull private final byte[] audioInitialization; - @NonNull private final byte[] videoInitialization; - @NonNull private final YoutubeSabrFormatTimeline audioTimeline; - @NonNull private final YoutubeSabrFormatTimeline videoTimeline; - @NonNull private final AtomicReference> mediaSegments; - - BootstrapResult(@NonNull final YoutubeSabrSession.InitializationResult initialization) { - audioInitialization = Objects.requireNonNull(initialization.getAudioData()); - videoInitialization = Objects.requireNonNull(initialization.getVideoData()); - audioTimeline = Objects.requireNonNull(initialization.getAudioTimeline()); - videoTimeline = Objects.requireNonNull(initialization.getVideoTimeline()); - mediaSegments = new AtomicReference<>(initialization.getMediaSegments()); - } - - @NonNull List takeMediaSegments() { - return mediaSegments.getAndSet(Collections.emptyList()); - } - - void discardMediaSegments() { - for (final SabrMediaSegment segment : takeMediaSegments()) segment.delete(); - } - } - @NonNull private static LocalDomPoTokenProvider provider(@NonNull final Context context) { LocalDomPoTokenProvider result = sharedProvider; @@ -126,19 +91,22 @@ public static SabrSourceSpec createSourceSpec(@NonNull final String videoId, } final YoutubeSabrInfo info = Objects.requireNonNull(extractorInfo); final AudioSelection audio = selectAudioGroup(App.getApp(), info, audioStreams); - final YoutubeSabrInfo.Format videoFormat = pickVideoFormat(info, preferredVideoItag); - if (audio == null || videoFormat == null) { + final YoutubeSabrInfo.Format preferredVideo = pickVideoFormat(info, preferredVideoItag); + if (audio == null || preferredVideo == null) { throw new IOException("Could not select SABR formats for " + videoId); } - startTokenWarmup(App.getApp(), info); - final String key = bootstrapKey(info, audio.bootstrapFormat, videoFormat); - final BootstrapResult bootstrap = awaitBootstrap(key, - startBootstrap(App.getApp(), info, audio.bootstrapFormat, videoFormat), videoId); + final List videoFormats = + Collections.singletonList(preferredVideo); + final YoutubeSabrInfo.Format videoBootstrap = preferredVideo; + final String key = warmKey(info); + final byte[] warmedPoToken = takeWarmedPoToken(key, videoId); + final LocalDomPoTokenProvider tokenProvider = provider(App.getApp()); + final byte[] poToken = warmedPoToken == null + ? tokenProvider.getPoToken(info) : warmedPoToken; PlaybackStartupTrace.markForVideoId(videoId, "sabr_source_spec_ready"); - return new SabrSourceSpec(videoId, info, audio.bootstrapFormat, audio.formats, videoFormat, - bootstrap.audioInitialization, bootstrap.videoInitialization, - bootstrap.audioTimeline, bootstrap.videoTimeline, - bootstrap.takeMediaSegments()); + return new SabrSourceSpec(videoId, info, poToken, + audio.bootstrapFormat, audio.formats, videoFormats, videoBootstrap, + null, null, null, null, Collections.emptyList()); } public static void prewarm(@NonNull final Context context, @NonNull final StreamInfo streamInfo, @@ -150,26 +118,30 @@ public static void prewarm(@NonNull final Context context, @NonNull final Stream final AudioSelection audio = selectAudioGroup(context, info, streamInfo.getAudioStreams()); final YoutubeSabrInfo.Format video = pickVideoFormat(info, selectedStream.getItag()); if (audio == null || video == null) return; - startTokenWarmup(context, info); - startBootstrap(context, info, audio.bootstrapFormat, video); + final String key = warmKey(info); + synchronized (WARM_ENTRIES) { + if (WARM_ENTRIES.containsKey(key)) return; + final FutureTask task = new FutureTask<>(() -> + provider(context).getPoToken(info)); + WARM_ENTRIES.put(key, task); + WARM_EXECUTOR.execute(task); + } } @NonNull static YoutubeSabrSession getOrCreateSession(@NonNull final Context context, @NonNull final SabrSourceSpec spec) throws IOException, ExtractionException { - final String key = sessionKey(spec.getInfo(), spec.getBootstrapAudioFormat(), - spec.getVideoFormat()); + final String key = sessionKey(spec.getInfo()); final YoutubeSabrSession cached = getSession(key); if (cached != null) return cached; final File spool = new File(context.getCacheDir(), "sabr-segments/" + spec.getVideoId() + '-' + System.nanoTime()); - final YoutubeSabrSession created = new YoutubeSabrSession(spec.getInfo(), - spec.getBootstrapAudioFormat(), spec.getVideoFormat(), spool); + final YoutubeSabrSession created = new YoutubeSabrSession(spec.getInfo(), null, null, spool); final LocalDomPoTokenProvider tokenProvider = provider(context); created.setPoTokenRefresher(() -> tokenProvider.getPoToken(spec.getInfo())); created.setIdentityRefresher(() -> refreshIdentity(context, spec.getInfo())); - final byte[] token = awaitWarmedToken(spec.getVideoId(), spec.getInfo(), tokenProvider); + final byte[] token = spec.getPoToken(); if (token == null || token.length == 0) { throw new SabrLogicException("SABR PO token provider returned no token for video=" + spec.getVideoId()); @@ -178,58 +150,6 @@ static YoutubeSabrSession getOrCreateSession(@NonNull final Context context, return cacheSession(key, created); } - @NonNull - private static Future startBootstrap( - @NonNull final Context context, @NonNull final YoutubeSabrInfo info, - @NonNull final YoutubeSabrInfo.Format audio, - @NonNull final YoutubeSabrInfo.Format video) { - final String key = bootstrapKey(info, audio, video); - final BootstrapResult cached = BOOTSTRAP_CACHE.get(key); - if (cached != null) { - final FutureTask result = new FutureTask<>(() -> cached); - result.run(); - return result; - } - final FutureTask created = new FutureTask(() -> { - final BootstrapResult result = createPreparation( - context, info, audio, video); - BOOTSTRAP_CACHE.put(key, result); - return result; - }) { - @Override protected void done() { BOOTSTRAP_IN_FLIGHT.remove(key, this); } - }; - final Future existing = BOOTSTRAP_IN_FLIGHT.putIfAbsent(key, created); - if (existing != null) return existing; - BOOTSTRAP_EXECUTOR.execute(created); - return created; - } - - @NonNull - private static BootstrapResult createPreparation( - @NonNull final Context context, @NonNull final YoutubeSabrInfo info, - @NonNull final YoutubeSabrInfo.Format audio, - @NonNull final YoutubeSabrInfo.Format video) throws IOException, ExtractionException { - final YoutubeSabrSession session = new YoutubeSabrSession(info, audio, video, - new File(context.getCacheDir(), "sabr-bootstrap/" + info.getVideoId() - + '-' + System.nanoTime())); - final LocalDomPoTokenProvider tokenProvider = provider(context); - session.setPoTokenRefresher(() -> tokenProvider.getPoToken(info)); - session.setIdentityRefresher(() -> refreshIdentity(context, info)); - final byte[] token = awaitWarmedToken(info.getVideoId(), info, tokenProvider); - if (token == null || token.length == 0) { - throw new SabrLogicException("Missing SABR PO token for " + info.getVideoId()); - } - final YoutubeSabrSession.InitializationResult initialization = - session.initialize(2_000, token); - if (initialization.getAudioData() == null || initialization.getVideoData() == null - || initialization.getAudioTimeline() == null - || initialization.getVideoTimeline() == null) { - throw new SabrLogicException("Incomplete SABR initialization for " + info.getVideoId()); - } - cacheSession(sessionKey(info, audio, video), session); - return new BootstrapResult(initialization); - } - @NonNull private static YoutubeSabrSession.SessionIdentity refreshIdentity( @NonNull final Context context, @NonNull final YoutubeSabrInfo rejectedInfo) @@ -279,56 +199,24 @@ private static synchronized YoutubeSabrSession cacheSession( return session; } - @NonNull - private static BootstrapResult awaitBootstrap(@NonNull final String key, - @NonNull final Future future, - @NonNull final String videoId) - throws IOException, ExtractionException { - try { - return future.get(); - } catch (final InterruptedException error) { - Thread.currentThread().interrupt(); - throw new IOException("Interrupted awaiting SABR bootstrap for " + videoId, error); - } catch (final ExecutionException error) { - final Throwable cause = error.getCause(); - if (cause instanceof IOException) throw (IOException) cause; - if (cause instanceof ExtractionException) throw (ExtractionException) cause; - throw new IOException("Could not bootstrap SABR for " + videoId, cause); - } finally { - BOOTSTRAP_IN_FLIGHT.remove(key, future); - } - } - - private static void startTokenWarmup(@NonNull final Context context, - @NonNull final YoutubeSabrInfo info) { - final String key = tokenIdentityKey(info); - final FutureTask created = new FutureTask( - () -> provider(context).getPoToken(info)) { - @Override protected void done() { TOKEN_IN_FLIGHT.remove(key, this); } - }; - if (TOKEN_IN_FLIGHT.putIfAbsent(key, created) == null) TOKEN_EXECUTOR.execute(created); - } - @Nullable - private static byte[] awaitWarmedToken(@NonNull final String videoId, - @NonNull final YoutubeSabrInfo info, - @NonNull final LocalDomPoTokenProvider tokenProvider) + private static byte[] takeWarmedPoToken(@NonNull final String key, + @NonNull final String videoId) throws IOException, ExtractionException { - final String key = tokenIdentityKey(info); - final Future future = TOKEN_IN_FLIGHT.get(key); - if (future == null) return tokenProvider.getPoToken(info); + final Future future; + synchronized (WARM_ENTRIES) { + future = WARM_ENTRIES.remove(key); + } + if (future == null) return null; try { - return future.get(); + final byte[] poToken = future.get(); + return poToken == null ? null : poToken.clone(); } catch (final InterruptedException error) { Thread.currentThread().interrupt(); - throw new IOException("Interrupted awaiting SABR token for " + videoId, error); + throw new IOException("Interrupted awaiting SABR prewarm for " + videoId, error); } catch (final ExecutionException error) { - final Throwable cause = error.getCause(); - if (cause instanceof IOException) throw (IOException) cause; - if (cause instanceof ExtractionException) throw (ExtractionException) cause; - throw new IOException("Could not obtain SABR token for " + videoId, cause); - } finally { - TOKEN_IN_FLIGHT.remove(key, future); + // Prewarm is opportunistic. A failed task must not poison the real resolve path. + return null; } } @@ -426,31 +314,13 @@ private static YoutubeSabrInfo.Format pickVideoFormat(@NonNull final YoutubeSabr } @NonNull - private static String bootstrapKey(@NonNull final YoutubeSabrInfo info, - @NonNull final YoutubeSabrInfo.Format audio, - @NonNull final YoutubeSabrInfo.Format video) { - return tokenIdentityKey(info) + '#' + formatIdentity(audio) - + '#' + formatIdentity(video); + private static String warmKey(@NonNull final YoutubeSabrInfo info) { + return Objects.requireNonNull(info.getServerAbrStreamingUrl()); } @NonNull - private static String sessionKey(@NonNull final YoutubeSabrInfo info, - @NonNull final YoutubeSabrInfo.Format audio, - @NonNull final YoutubeSabrInfo.Format video) { - return bootstrapKey(info, audio, video) + '#' - + Objects.toString(info.getCpn(), "") + '#' - + Objects.toString(info.getServerAbrStreamingUrl(), ""); + private static String sessionKey(@NonNull final YoutubeSabrInfo info) { + return warmKey(info); } - @NonNull - private static String tokenIdentityKey(@NonNull final YoutubeSabrInfo info) { - return info.getVideoId() + "#MWEB#" + info.getClientVersion() + '#' - + Objects.toString(info.getVisitorData(), ""); - } - - @NonNull - private static String formatIdentity(@NonNull final YoutubeSabrInfo.Format format) { - return format.getItag() + ":" + format.getLastModified() + ':' - + Objects.toString(format.getXtags(), ""); - } } diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSourceSpec.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSourceSpec.java index c2159c4d1..8e50c0c0e 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSourceSpec.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSourceSpec.java @@ -15,43 +15,55 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicReference; -/** Source metadata for one video format and one Media3-selectable audio codec group. */ +/** Source metadata for one selected video format and one Media3-selectable audio codec group. */ public final class SabrSourceSpec { @NonNull private final String videoId; @NonNull private final YoutubeSabrInfo info; + @NonNull private final byte[] poToken; @NonNull private final YoutubeSabrInfo.Format bootstrapAudioFormat; @NonNull private final List audioFormats; - @NonNull private final YoutubeSabrInfo.Format videoFormat; + @NonNull private final List videoFormats; + @NonNull private final YoutubeSabrInfo.Format bootstrapVideoFormat; @NonNull private final Map formatsByKey; @NonNull private final Map keysByFormat; @NonNull private final Map initializationData = new ConcurrentHashMap<>(); - @NonNull private final YoutubeSabrFormatTimeline audioTimeline; - @NonNull private final YoutubeSabrFormatTimeline videoTimeline; + @Nullable private volatile YoutubeSabrFormatTimeline audioTimeline; + @Nullable private volatile YoutubeSabrFormatTimeline sharedVideoTimeline; @NonNull private final AtomicReference> bootstrapMediaSegments; SabrSourceSpec(@NonNull final String videoId, @NonNull final YoutubeSabrInfo info, + @NonNull final byte[] poToken, @NonNull final YoutubeSabrInfo.Format bootstrapAudioFormat, @NonNull final List audioFormats, - @NonNull final YoutubeSabrInfo.Format videoFormat, - @NonNull final byte[] audioInitializationData, - @NonNull final byte[] videoInitializationData, - @NonNull final YoutubeSabrFormatTimeline audioTimeline, - @NonNull final YoutubeSabrFormatTimeline videoTimeline, + @NonNull final List videoFormats, + @NonNull final YoutubeSabrInfo.Format bootstrapVideoFormat, + @Nullable final byte[] audioInitializationData, + @Nullable final byte[] videoInitializationData, + @Nullable final YoutubeSabrFormatTimeline audioTimeline, + @Nullable final YoutubeSabrFormatTimeline videoTimeline, @NonNull final List bootstrapMediaSegments) { if (audioFormats.isEmpty() || !audioFormats.contains(bootstrapAudioFormat)) { throw new IllegalArgumentException("SABR audio codec group is empty"); } this.videoId = videoId; this.info = info; + this.poToken = poToken.clone(); this.bootstrapAudioFormat = bootstrapAudioFormat; this.audioFormats = Collections.unmodifiableList(new ArrayList<>(audioFormats)); - this.videoFormat = videoFormat; + if (videoFormats.isEmpty() || !videoFormats.contains(bootstrapVideoFormat)) { + throw new IllegalArgumentException("SABR video codec group is empty"); + } + this.videoFormats = Collections.unmodifiableList(new ArrayList<>(videoFormats)); + this.bootstrapVideoFormat = bootstrapVideoFormat; final Map byKey = new LinkedHashMap<>(); final Map byFormat = new ConcurrentHashMap<>(); - byKey.put("v", videoFormat); - byFormat.put(videoFormat, "v"); + for (int i = 0; i < videoFormats.size(); i++) { + final String key = "v" + i; + byKey.put(key, videoFormats.get(i)); + byFormat.put(videoFormats.get(i), key); + } for (int i = 0; i < audioFormats.size(); i++) { final String key = "a" + i; byKey.put(key, audioFormats.get(i)); @@ -60,20 +72,38 @@ public final class SabrSourceSpec { formatsByKey = Collections.unmodifiableMap(byKey); keysByFormat = Collections.unmodifiableMap(byFormat); this.audioTimeline = audioTimeline; - this.videoTimeline = videoTimeline; + this.sharedVideoTimeline = videoTimeline; this.bootstrapMediaSegments = new AtomicReference<>(bootstrapMediaSegments); - putInitializationData(bootstrapAudioFormat, audioInitializationData); - putInitializationData(videoFormat, videoInitializationData); + if (audioInitializationData != null) putInitializationData(bootstrapAudioFormat, + audioInitializationData); + if (videoInitializationData != null) putInitializationData(bootstrapVideoFormat, + videoInitializationData); + } + + SabrSourceSpec(@NonNull final String videoId, @NonNull final YoutubeSabrInfo info, + @NonNull final byte[] poToken, @NonNull final YoutubeSabrInfo.Format audio, + @NonNull final List audios, + @NonNull final YoutubeSabrInfo.Format video, + @Nullable final byte[] audioInit, @Nullable final byte[] videoInit, + @Nullable final YoutubeSabrFormatTimeline audioTimeline, + @Nullable final YoutubeSabrFormatTimeline videoTimeline, + @NonNull final List segments) { + this(videoId, info, poToken, audio, audios, Collections.singletonList(video), video, + audioInit, videoInit, audioTimeline, videoTimeline, segments); } @NonNull public String getVideoId() { return videoId; } @NonNull public YoutubeSabrInfo getInfo() { return info; } + @NonNull byte[] getPoToken() { return poToken.clone(); } @NonNull public YoutubeSabrInfo.Format getBootstrapAudioFormat() { return bootstrapAudioFormat; } @NonNull public List getAudioFormats() { return audioFormats; } - @NonNull public YoutubeSabrInfo.Format getVideoFormat() { return videoFormat; } + @NonNull public List getVideoFormats() { return videoFormats; } + @NonNull public YoutubeSabrInfo.Format getBootstrapVideoFormat() { return bootstrapVideoFormat; } + /** Compatibility accessor; callers needing a group must use getVideoFormats(). */ + @NonNull public YoutubeSabrInfo.Format getVideoFormat() { return bootstrapVideoFormat; } @Nullable YoutubeSabrInfo.Format getFormat(@NonNull final String key) { return formatsByKey.get(key); @@ -98,16 +128,30 @@ void putInitializationData(@NonNull final YoutubeSabrInfo.Format format, long getDurationMs() { return Math.max(bootstrapAudioFormat.getApproxDurationMs(), - videoFormat.getApproxDurationMs()); + bootstrapVideoFormat.getApproxDurationMs()); } - @NonNull YoutubeSabrFormatTimeline getAudioTimeline() { return audioTimeline; } - @NonNull YoutubeSabrFormatTimeline getVideoTimeline() { return videoTimeline; } + @NonNull YoutubeSabrFormatTimeline getAudioTimeline() { + if (audioTimeline == null) throw new IllegalStateException("SABR audio timeline is not ready"); + return audioTimeline; + } + @Nullable YoutubeSabrFormatTimeline peekAudioTimeline() { return audioTimeline; } + @Nullable YoutubeSabrFormatTimeline peekVideoTimeline() { return sharedVideoTimeline; } + @NonNull YoutubeSabrFormatTimeline getVideoTimeline() { + if (sharedVideoTimeline == null) throw new IllegalStateException("SABR video timeline is not ready"); + return sharedVideoTimeline; + } + + void putTimeline(@NonNull final YoutubeSabrInfo.Format format, + @NonNull final YoutubeSabrFormatTimeline timeline) { + if (format.isAudio()) audioTimeline = timeline; + else sharedVideoTimeline = timeline; + } @NonNull YoutubeSabrFormatTimeline getTimeline(@NonNull final YoutubeSabrInfo.Format format) { if (format.isAudio() && audioFormats.contains(format)) return audioTimeline; - if (format.getItag() == videoFormat.getItag()) return videoTimeline; + if (videoFormats.contains(format)) return sharedVideoTimeline; throw new IllegalArgumentException("Unknown SABR itag: " + format.getItag()); } diff --git a/app/src/main/java/us/shandian/giga/get/SabrDownloader.kt b/app/src/main/java/us/shandian/giga/get/SabrDownloader.kt index d1159e78f..4f8739c59 100644 --- a/app/src/main/java/us/shandian/giga/get/SabrDownloader.kt +++ b/app/src/main/java/us/shandian/giga/get/SabrDownloader.kt @@ -7,6 +7,7 @@ import org.schabi.newpipe.extractor.services.youtube.sabr.exception.SabrProtocol import org.schabi.newpipe.extractor.services.youtube.sabr.exception.SabrRecoverableException import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrRequestHelper import org.schabi.newpipe.extractor.stream.DeliveryMethod import org.schabi.newpipe.extractor.stream.StreamInfo import org.schabi.newpipe.youtube.LocalDomPoTokenProvider @@ -379,23 +380,34 @@ internal class SabrDownloader( return } - ensureRunning() - val initialization = session.initialize(2_000, poToken) + var adaptiveSucceeded = true for (target in pendingTargets) { - val data = if (target.format.isAudio) { - initialization.audioData - } else { - initialization.videoData - } ?: throw RetryColdStartException() - target.timeline = if (target.format.isAudio) { - initialization.audioTimeline - } else { - initialization.videoTimeline - } ?: throw RetryColdStartException() - writer.writeInitializationData(target, data) + ensureRunning() + try { + val data = YoutubeSabrRequestHelper.fetchInitializationData( + target.format, poToken, 2_000) + writer.writeInitializationData(target, data) + } catch (_: IOException) { + adaptiveSucceeded = false + break + } + } + if (!adaptiveSucceeded) { + ensureRunning() + session.requestOnce( + 0L, + null, 0, + null, 0, + targets.any { it.format.isAudio }, + targets.any { it.format.isVideo }, + false, + 1.0f, + writer::acceptSegment, + ) + writer.observeWrittenInitializations() } - for (segment in initialization.mediaSegments) { - writer.acceptSegment(segment) + if (pendingTargets.any { !it.initializationWritten }) { + throw RetryColdStartException() } } From 2cc705c67ca4c31737d3e3deea03d212b64d6cb4 Mon Sep 17 00:00:00 2001 From: InfinityLoop1308 <96324692+InfinityLoop1308@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:51:02 +0800 Subject: [PATCH 10/13] 10 --- .../player/datasource/SabrSessionStore.java | 39 ------------------- .../us/shandian/giga/get/SabrDownloader.kt | 33 ---------------- 2 files changed, 72 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java index 1fdd74da0..95e449505 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java @@ -6,7 +6,6 @@ import androidx.annotation.Nullable; import org.schabi.newpipe.App; -import org.schabi.newpipe.extractor.ServiceList; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession; @@ -138,9 +137,6 @@ static YoutubeSabrSession getOrCreateSession(@NonNull final Context context, final File spool = new File(context.getCacheDir(), "sabr-segments/" + spec.getVideoId() + '-' + System.nanoTime()); final YoutubeSabrSession created = new YoutubeSabrSession(spec.getInfo(), null, null, spool); - final LocalDomPoTokenProvider tokenProvider = provider(context); - created.setPoTokenRefresher(() -> tokenProvider.getPoToken(spec.getInfo())); - created.setIdentityRefresher(() -> refreshIdentity(context, spec.getInfo())); final byte[] token = spec.getPoToken(); if (token == null || token.length == 0) { throw new SabrLogicException("SABR PO token provider returned no token for video=" @@ -150,41 +146,6 @@ static YoutubeSabrSession getOrCreateSession(@NonNull final Context context, return cacheSession(key, created); } - @NonNull - private static YoutubeSabrSession.SessionIdentity refreshIdentity( - @NonNull final Context context, @NonNull final YoutubeSabrInfo rejectedInfo) - throws IOException, ExtractionException { - final StreamInfo refreshed = StreamInfo.getInfo(ServiceList.YouTube, - "https://www.youtube.com/watch?v=" + rejectedInfo.getVideoId()); - YoutubeSabrInfo freshInfo = null; - for (final VideoStream stream : refreshed.getVideoOnlyStreams()) { - if (stream.getDeliveryMethod() == DeliveryMethod.SABR - && stream.getDeliveryMethodInfo() instanceof YoutubeSabrInfo) { - freshInfo = (YoutubeSabrInfo) stream.getDeliveryMethodInfo(); - break; - } - } - if (freshInfo == null) { - for (final AudioStream stream : refreshed.getAudioStreams()) { - if (stream.getDeliveryMethod() == DeliveryMethod.SABR - && stream.getDeliveryMethodInfo() instanceof YoutubeSabrInfo) { - freshInfo = (YoutubeSabrInfo) stream.getDeliveryMethodInfo(); - break; - } - } - } - if (freshInfo == null) { - throw new SabrLogicException("Refreshed player response has no SABR identity for " - + rejectedInfo.getVideoId()); - } - final byte[] token = provider(context).getPoToken(freshInfo); - if (token == null || token.length == 0) { - throw new SabrLogicException("Refreshed SABR identity returned no PO token for " - + rejectedInfo.getVideoId()); - } - return new YoutubeSabrSession.SessionIdentity(freshInfo, token); - } - @Nullable private static synchronized YoutubeSabrSession getSession(@NonNull final String key) { return SESSIONS.get(key); diff --git a/app/src/main/java/us/shandian/giga/get/SabrDownloader.kt b/app/src/main/java/us/shandian/giga/get/SabrDownloader.kt index 4f8739c59..2fcac146f 100644 --- a/app/src/main/java/us/shandian/giga/get/SabrDownloader.kt +++ b/app/src/main/java/us/shandian/giga/get/SabrDownloader.kt @@ -2,14 +2,11 @@ package us.shandian.giga.get import android.util.Log import org.schabi.newpipe.BuildConfig -import org.schabi.newpipe.extractor.ServiceList import org.schabi.newpipe.extractor.services.youtube.sabr.exception.SabrProtocolException import org.schabi.newpipe.extractor.services.youtube.sabr.exception.SabrRecoverableException import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrRequestHelper -import org.schabi.newpipe.extractor.stream.DeliveryMethod -import org.schabi.newpipe.extractor.stream.StreamInfo import org.schabi.newpipe.youtube.LocalDomPoTokenProvider import java.io.File import java.io.FileOutputStream @@ -91,10 +88,6 @@ internal class SabrDownloader( null, ) val tokenProvider = LocalDomPoTokenProvider(mission.context) - session.setPoTokenRefresher { tokenProvider.getPoToken(info) } - session.setIdentityRefresher { - refreshIdentity(info.videoId, tokenProvider) - } val poToken = tokenProvider.getPoToken(info) session.setPoToken(poToken) val workDir = prepareWorkDirectory() @@ -141,32 +134,6 @@ internal class SabrDownloader( completeMission(finalBytes) } - private fun refreshIdentity( - videoId: String, - tokenProvider: LocalDomPoTokenProvider, - ): YoutubeSabrSession.SessionIdentity { - val refreshed = StreamInfo.getInfo( - ServiceList.YouTube, - "https://www.youtube.com/watch?v=$videoId", - ) - val freshInfo = (refreshed.videoOnlyStreams.asSequence() + - refreshed.audioStreams.asSequence()) - .firstNotNullOfOrNull { stream -> - if (stream.deliveryMethod == DeliveryMethod.SABR) { - stream.deliveryMethodInfo as? YoutubeSabrInfo - } else { - null - } - } - ?: throw SabrProtocolException( - "Refreshed player response has no SABR identity for $videoId", - ) - return YoutubeSabrSession.SessionIdentity( - freshInfo, - tokenProvider.getPoToken(freshInfo), - ) - } - @Throws(IOException::class) private fun validateRecoveryInfo(): Array { val recoveries = mission.recoveryInfo ?: throw IOException("Missing SABR recovery info") From 09c5ca3b72b7d882f78b3d75dd774a1d3056934e Mon Sep 17 00:00:00 2001 From: InfinityLoop1308 <96324692+InfinityLoop1308@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:57:05 +0800 Subject: [PATCH 11/13] 11 --- .../datasource/SabrDashMediaSource.java | 31 +++++++++++-------- .../player/datasource/SabrMediaBridge.java | 18 +++++++++-- .../datasource/SabrSegmentDataSource.java | 2 +- .../player/datasource/SabrSourceSpec.java | 28 ----------------- 4 files changed, 34 insertions(+), 45 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDashMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDashMediaSource.java index f00a7f321..b145b2cbe 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDashMediaSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDashMediaSource.java @@ -73,20 +73,20 @@ public SabrDashMediaSource(@NonNull final Context context, final long durationMs = spec.getDurationMs(); this.durationUs = durationMs > 0 ? durationMs * 1000L : C.TIME_UNSET; final SabrMediaBridge preparationBridge = getOrCreateBridge(); - if (spec.peekAudioTimeline() == null || spec.peekVideoTimeline() == null) { + if (!preparationBridge.hasTimelines()) { try { preparationBridge.fetchSegments(0, spec.getBootstrapAudioFormat(), true, true); } catch (final ExtractionException error) { throw new IOException("Could not prepare SABR first response", error); } - if (spec.peekAudioTimeline() == null || spec.peekVideoTimeline() == null) { + if (!preparationBridge.hasTimelines()) { throw new IOException("SABR first response did not provide initialization"); } } final DataSource.Factory sabrDataSourceFactory = playerDataSource.getCacheDataSourceFactory( this::createDataSource, this::buildCacheKey); - final DashManifest manifest = buildManifest(spec, durationMs); + final DashManifest manifest = buildManifest(spec, durationMs, preparationBridge); this.childSource = new DashMediaSource.Factory( new DefaultDashChunkSource.Factory(sabrDataSourceFactory), /* manifestDataSourceFactory= */ null) @@ -173,7 +173,8 @@ private String buildCacheKey(@NonNull final DataSpec dataSpec) { } private static DashManifest buildManifest(final SabrSourceSpec spec, - final long durationMs) + final long durationMs, + final SabrMediaBridge bridge) throws IOException { final String mpd = "" + "" + "" - + videoAdaptationSets(spec) - + audioAdaptationSets(spec) + + videoAdaptationSets(spec, bridge) + + audioAdaptationSets(spec, bridge) + ""; try { return new DashManifestParser().parse(Uri.parse("sabr://" + spec.getVideoId()), @@ -192,7 +193,8 @@ private static DashManifest buildManifest(final SabrSourceSpec spec, } } - private static String audioAdaptationSets(final SabrSourceSpec spec) { + private static String audioAdaptationSets(final SabrSourceSpec spec, + final SabrMediaBridge bridge) { final Map> tracks = new LinkedHashMap<>(); for (final YoutubeSabrInfo.Format format : spec.getAudioFormats()) { tracks.computeIfAbsent(java.util.Objects.toString(format.getAudioTrackId(), "default"), @@ -201,17 +203,19 @@ private static String audioAdaptationSets(final SabrSourceSpec spec) { final StringBuilder result = new StringBuilder(); int index = 0; for (final Map.Entry> track : tracks.entrySet()) { - result.append(adaptationSet(spec, track.getValue(), C.TRACK_TYPE_AUDIO, - String.valueOf(++index))); + result.append(adaptationSet(spec, bridge, track.getValue(), C.TRACK_TYPE_AUDIO, + String.valueOf(++index))); } return result.toString(); } - private static String videoAdaptationSets(final SabrSourceSpec spec) { - return adaptationSet(spec, spec.getVideoFormats(), C.TRACK_TYPE_VIDEO, "0"); + private static String videoAdaptationSets(final SabrSourceSpec spec, + final SabrMediaBridge bridge) { + return adaptationSet(spec, bridge, spec.getVideoFormats(), C.TRACK_TYPE_VIDEO, "0"); } private static String adaptationSet(final SabrSourceSpec spec, + final SabrMediaBridge bridge, final List formats, final int trackType, final String adaptationId) { @@ -254,7 +258,7 @@ private static String adaptationSet(final SabrSourceSpec spec, } builder.append(">sabrseg://").append(spec.getFormatKey(format)) .append("/") - .append(segmentTemplate(format, spec.getTimeline(format))) + .append(segmentTemplate(format, bridge.getTimeline(format))) .append(""); } builder.append(""); @@ -486,7 +490,8 @@ private long snapForwardToNearSegmentBoundary(final long positionUs, return positionUs; } final long positionMs = Math.max(0, positionUs / 1000L); - final YoutubeSabrFormatTimeline timeline = spec.getVideoTimeline(); + final YoutubeSabrFormatTimeline timeline = getOrCreateBridge().getTimeline( + spec.getBootstrapVideoFormat()); final int currentSequence = timeline.getSequenceAt(positionMs); final long nextStartMs = timeline.getStartMs(currentSequence + 1); final long nextStartUs = nextStartMs * 1000L; diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaBridge.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaBridge.java index 8d7a617b0..cc087a0c6 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaBridge.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaBridge.java @@ -55,8 +55,6 @@ final class SabrMediaBridge { videoFormat = spec.getBootstrapVideoFormat(); audioActive = true; videoActive = true; - audioTimeline = spec.peekAudioTimeline(); - videoTimeline = spec.peekVideoTimeline(); } void setActiveTracks(final boolean audioActive, final boolean videoActive) { @@ -64,6 +62,21 @@ void setActiveTracks(final boolean audioActive, final boolean videoActive) { this.videoActive = videoActive; } + @NonNull + YoutubeSabrFormatTimeline getTimeline(@NonNull final YoutubeSabrInfo.Format format) { + final YoutubeSabrFormatTimeline timeline = format.isAudio() + ? audioTimeline : videoTimeline; + if (timeline == null) { + throw new IllegalStateException("SABR timeline is not ready: itag=" + + format.getItag()); + } + return timeline; + } + + boolean hasTimelines() { + return audioTimeline != null && videoTimeline != null; + } + void setSelectedFormats(@Nullable final YoutubeSabrInfo.Format audio, @Nullable final YoutubeSabrInfo.Format video) { currentAudioFormat = audio; @@ -250,7 +263,6 @@ private void acceptSegment(@NonNull final SabrMediaSegment segment, try { final YoutubeSabrFormatTimeline timeline = YoutubeSabrFormatTimeline.parse(format, data); - spec.putTimeline(format, timeline); if (format.isAudio()) audioTimeline = timeline; else videoTimeline = timeline; } catch (final ExtractionException error) { diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java index 93322e92f..a2ca06929 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSegmentDataSource.java @@ -127,7 +127,7 @@ static SabrSegmentKey requestFromUri(final SabrSourceSpec spec, private SabrMediaSegment awaitSegment(final SabrSegmentKey request) throws IOException { if (request.getSequenceNumber() - > spec.getTimeline(request.getFormat()).getEndSequence()) { + > bridge.getTimeline(request.getFormat()).getEndSequence()) { throw new SabrLogicException("SABR segment is beyond the timeline: itag=" + request.getFormat().getItag() + ", seq=" + request.getSequenceNumber()); } diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSourceSpec.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSourceSpec.java index 8e50c0c0e..8f444ebc8 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSourceSpec.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSourceSpec.java @@ -28,8 +28,6 @@ public final class SabrSourceSpec { @NonNull private final Map keysByFormat; @NonNull private final Map initializationData = new ConcurrentHashMap<>(); - @Nullable private volatile YoutubeSabrFormatTimeline audioTimeline; - @Nullable private volatile YoutubeSabrFormatTimeline sharedVideoTimeline; @NonNull private final AtomicReference> bootstrapMediaSegments; SabrSourceSpec(@NonNull final String videoId, @@ -71,8 +69,6 @@ public final class SabrSourceSpec { } formatsByKey = Collections.unmodifiableMap(byKey); keysByFormat = Collections.unmodifiableMap(byFormat); - this.audioTimeline = audioTimeline; - this.sharedVideoTimeline = videoTimeline; this.bootstrapMediaSegments = new AtomicReference<>(bootstrapMediaSegments); if (audioInitializationData != null) putInitializationData(bootstrapAudioFormat, audioInitializationData); @@ -131,30 +127,6 @@ long getDurationMs() { bootstrapVideoFormat.getApproxDurationMs()); } - @NonNull YoutubeSabrFormatTimeline getAudioTimeline() { - if (audioTimeline == null) throw new IllegalStateException("SABR audio timeline is not ready"); - return audioTimeline; - } - @Nullable YoutubeSabrFormatTimeline peekAudioTimeline() { return audioTimeline; } - @Nullable YoutubeSabrFormatTimeline peekVideoTimeline() { return sharedVideoTimeline; } - @NonNull YoutubeSabrFormatTimeline getVideoTimeline() { - if (sharedVideoTimeline == null) throw new IllegalStateException("SABR video timeline is not ready"); - return sharedVideoTimeline; - } - - void putTimeline(@NonNull final YoutubeSabrInfo.Format format, - @NonNull final YoutubeSabrFormatTimeline timeline) { - if (format.isAudio()) audioTimeline = timeline; - else sharedVideoTimeline = timeline; - } - - @NonNull - YoutubeSabrFormatTimeline getTimeline(@NonNull final YoutubeSabrInfo.Format format) { - if (format.isAudio() && audioFormats.contains(format)) return audioTimeline; - if (videoFormats.contains(format)) return sharedVideoTimeline; - throw new IllegalArgumentException("Unknown SABR itag: " + format.getItag()); - } - @NonNull List takeBootstrapMediaSegments() { return bootstrapMediaSegments.getAndSet(Collections.emptyList()); From 6a88df1c9d9c663304750f3f2b44d3c7098c9ceb Mon Sep 17 00:00:00 2001 From: InfinityLoop1308 <96324692+InfinityLoop1308@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:23:05 +0800 Subject: [PATCH 12/13] 12 --- .../newpipe/player/SabrPlaybackSmokeTest.java | 207 +----------------- .../org/schabi/newpipe/player/Player.java | 21 +- .../player/datasource/SabrMediaBridge.java | 23 +- .../player/datasource/SabrSessionStore.java | 58 +---- .../player/datasource/SabrSourceSpec.java | 8 +- 5 files changed, 53 insertions(+), 264 deletions(-) diff --git a/app/src/androidTest/java/org/schabi/newpipe/player/SabrPlaybackSmokeTest.java b/app/src/androidTest/java/org/schabi/newpipe/player/SabrPlaybackSmokeTest.java index 531cca4d3..e4fec8582 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/player/SabrPlaybackSmokeTest.java +++ b/app/src/androidTest/java/org/schabi/newpipe/player/SabrPlaybackSmokeTest.java @@ -180,7 +180,6 @@ public void demandRepositionsAfterNonTargetMediaBatch() throws Exception { try (SabrSmokeHarness harness = SabrSmokeHarness.create()) { harness.setPlayerTimeMs(20_000); harness.downloader.enqueue(new UmpFixture() - .initSegment(0, SMOKE_VIDEO_ITAG) .segment(1, SMOKE_VIDEO_ITAG, 1, 0, 5_000) .bytes()); harness.downloader.enqueue(new UmpFixture() @@ -240,7 +239,6 @@ public void companionOnlyResponseTriggersDemandRecovery() throws Exception { public void repeatedNonTargetMediaBatchesFailWithinDemandBudget() throws Exception { try (SabrSmokeHarness harness = SabrSmokeHarness.create()) { harness.downloader.enqueue(new UmpFixture() - .initSegment(0, SMOKE_VIDEO_ITAG) .segment(1, SMOKE_VIDEO_ITAG, 1, 0, 5_000) .bytes()); for (int response = 0; response < 3; response++) { @@ -589,78 +587,6 @@ public int read() throws IOException { } } - @Test - public void nativeBootstrapBuildsExactTimelineWithoutAdaptiveRangeRequests() throws Exception { - final YoutubeSabrInfo.Format audioFormat = smokeFormat(SMOKE_AUDIO_ITAG, true); - final YoutubeSabrInfo.Format videoFormat = smokeFormat(SMOKE_VIDEO_ITAG, false); - final byte[] audioInit = mp4Sidx(20_001, 20_000, 19_999); - final byte[] videoInit = mp4Sidx(5_000, 5_000, 5_000, 5_000); - try (SabrSmokeHarness harness = SabrSmokeHarness.create(audioFormat, videoFormat)) { - harness.downloader.enqueue(new UmpFixture() - .part(SabrResponseDecoder.FORMAT_INITIALIZATION_METADATA, - initializationMetadata(SMOKE_AUDIO_ITAG, 3, 60_000, "audio/mp4")) - .part(SabrResponseDecoder.FORMAT_INITIALIZATION_METADATA, - initializationMetadata(SMOKE_VIDEO_ITAG, 4, 20_000, "video/mp4")) - .initSegment(1, SMOKE_AUDIO_ITAG, audioInit) - .initSegment(2, SMOKE_VIDEO_ITAG, videoInit) - .bytes()); - - harness.holder.session.bootstrapInitialization(new Localization("en", "US")); - assertTrue(harness.holder.session.getStreamState().hasSegmentIndex(audioFormat)); - assertTrue(harness.holder.session.getStreamState().hasSegmentIndex(videoFormat)); - assertEquals(20_001, harness.holder.session.getStreamState() - .getSegmentStartMs(audioFormat, 2)); - assertEquals(40_001, harness.holder.session.getStreamState() - .getSegmentStartMs(audioFormat, 3)); - - final SabrSourceSpec spec = harness.holder.session.initializedSpec(); - final Context context = InstrumentationRegistry.getInstrumentation() - .getTargetContext(); - final Method buildManifest = SabrDashMediaSource.class.getDeclaredMethod( - "buildManifest", SabrSourceSpec.class, long.class); - buildManifest.setAccessible(true); - assertNotNull(buildManifest.invoke(null, spec, spec.getDurationMs())); - - assertTrue("Bootstrap unexpectedly used adaptive range transport", - harness.downloader.streamingTimeoutsMs.isEmpty()); - } - } - - @Test - public void nativeBootstrapHonorsInitialAndSkipsCompletedResponseBackoff() throws Exception { - final YoutubeSabrInfo.Format audioFormat = smokeFormat(SMOKE_AUDIO_ITAG, true); - final YoutubeSabrInfo.Format videoFormat = smokeFormat(SMOKE_VIDEO_ITAG, false); - final byte[] audioInit = mp4Sidx(20_000); - final byte[] videoInit = mp4Sidx(5_000); - try (SabrSmokeHarness harness = SabrSmokeHarness.create(audioFormat, videoFormat)) { - harness.downloader.enqueue(new UmpFixture() - .part(SabrResponseDecoder.NEXT_REQUEST_POLICY, nextRequestPolicy(500)) - .bytes()); - harness.downloader.enqueue(new UmpFixture() - .part(SabrResponseDecoder.NEXT_REQUEST_POLICY, nextRequestPolicy(5_000)) - .part(SabrResponseDecoder.FORMAT_INITIALIZATION_METADATA, - initializationMetadata(SMOKE_AUDIO_ITAG, 1, 20_000, "audio/mp4")) - .part(SabrResponseDecoder.FORMAT_INITIALIZATION_METADATA, - initializationMetadata(SMOKE_VIDEO_ITAG, 1, 5_000, "video/mp4")) - .initSegment(1, SMOKE_AUDIO_ITAG, audioInit) - .initSegment(2, SMOKE_VIDEO_ITAG, videoInit) - .bytes()); - - final long bootstrapStartNs = System.nanoTime(); - harness.holder.session.bootstrapInitialization(new Localization("en", "US")); - final long bootstrapElapsedMs = TimeUnit.NANOSECONDS.toMillis( - System.nanoTime() - bootstrapStartNs); - - final List requestTimesMs = harness.downloader.requestTimesSnapshot(); - assertEquals(2, requestTimesMs.size()); - assertTrue("Bootstrap ignored the initial SABR backoff: " + requestTimesMs, - requestTimesMs.get(1) - requestTimesMs.get(0) >= 400); - assertTrue("Bootstrap waited for the completed init response backoff: elapsedMs=" - + bootstrapElapsedMs, - bootstrapElapsedMs < 2_000); - } - } - @Test public void demandIncompleteMediaResponseRetriesThroughPump() throws Exception { try (SabrSmokeHarness harness = SabrSmokeHarness.create()) { @@ -1002,35 +928,6 @@ public void compressedMediaSegmentCachesDecompressedBytesThroughDemandPump() } } - @Test - public void compressedAndInitializationSegmentsRemainCompletionOnly() throws Exception { - final byte[] rawCompressedMedia = new byte[]{30, 31, 32, 33, 34, 35}; - final byte[] compressedMedia = gzip(rawCompressedMedia); - final int compressedSplit = Math.max(1, compressedMedia.length / 2); - try (SabrSmokeHarness harness = SabrSmokeHarness.create()) { - final GatedMediaResponse response = new GatedMediaResponse( - 1, SMOKE_VIDEO_ITAG, 1, 0, 5_000, - Arrays.copyOfRange(compressedMedia, 0, compressedSplit), - Arrays.copyOfRange(compressedMedia, compressedSplit, compressedMedia.length), - 1, false, null); - verifyCompletionOnly(harness, - SabrSegmentKey.media(harness.videoFormat, 1), - response, rawCompressedMedia, "compressed media"); - } - - final byte[] initializationBytes = mp4Sidx(5_000, 5_000); - try (SabrSmokeHarness harness = SabrSmokeHarness.create()) { - final GatedMediaResponse response = new GatedMediaResponse( - 1, SMOKE_VIDEO_ITAG, 0, 0, 0, - Arrays.copyOfRange(initializationBytes, 0, 2), - Arrays.copyOfRange(initializationBytes, 2, initializationBytes.length), - 0, true, null); - verifyInitializationCompletionOnly(harness, - SabrSegmentKey.initialization(harness.videoFormat), - response, initializationBytes, "initialization segment"); - } - } - @Test public void recoverableCompressedAndOverflowMediaRetryThroughDemandPump() throws Exception { @@ -1656,61 +1553,6 @@ private static String waitForTrace(final SabrSmokeHarness harness, return trace; } - private static void verifyCompletionOnly(final SabrSmokeHarness harness, - final SabrSegmentKey request, - final GatedMediaResponse response, - final byte[] expectedBytes, - final String description) throws Exception { - harness.downloader.enqueue(response); - final AsyncSegmentReader reader = new AsyncSegmentReader( - harness.holder, harness.readerOwner, request, 1); - reader.start(); - try { - assertTrue(description + " producer did not reach the MEDIA payload gate", - response.awaitGate(2_000)); - assertTrue(description + " became readable before completion", - !reader.awaitOpened(300)); - } finally { - response.release(); - } - assertTrue(description + " did not finish after completion", - reader.awaitDone(2_000)); - assertNull(description + " read failed", reader.getFailure()); - assertTrue(description + " did not reach EOF", reader.isEofObserved()); - assertTrue(description + " returned unexpected bytes", - Arrays.equals(expectedBytes, reader.bytesSnapshot())); - } - - private static void verifyInitializationCompletionOnly( - final SabrSmokeHarness harness, - final SabrSegmentKey request, - final GatedMediaResponse response, - final byte[] expectedBytes, - final String description) throws Exception { - final Field initializationData = SabrSourceSpec.class - .getDeclaredField("initializationData"); - initializationData.setAccessible(true); - @SuppressWarnings("unchecked") final Map values = - (Map) initializationData.get(harness.holder.spec); - values.remove(request.getFormat()); - harness.downloader.enqueue(response); - final AsyncSegmentReader reader = new AsyncSegmentReader( - harness.holder, harness.readerOwner, request, 1); - reader.start(); - try { - assertTrue(description + " producer did not reach the MEDIA payload gate", - response.awaitGate(2_000)); - assertTrue(description + " became readable before completion", - !reader.awaitOpened(300)); - } finally { - response.release(); - } - assertTrue(description + " did not finish after completion", reader.awaitDone(2_000)); - assertNull(description + " read failed", reader.getFailure()); - assertTrue(description + " returned unexpected bytes", - Arrays.equals(expectedBytes, reader.bytesSnapshot())); - } - private static long usedHeapBytes() { final Runtime runtime = Runtime.getRuntime(); return runtime.totalMemory() - runtime.freeMemory(); @@ -2102,10 +1944,20 @@ private SmokeHolder(final Context context, Collections.emptyList()); delegate.setPoToken(new byte[]{1, 2, 3, 4}); bridge = new SabrMediaBridge(context, delegate, spec); + setBridgeTimeline("audioTimeline", audioTimeline); + setBridgeTimeline("videoTimeline", videoTimeline); session = new SmokeSession(delegate, bridge, spec, audioFormat, videoFormat, audioTimeline, videoTimeline); } + private void setBridgeTimeline(final String fieldName, + final org.schabi.newpipe.extractor.services.youtube.sabr + .YoutubeSabrFormatTimeline timeline) throws Exception { + final Field field = SabrMediaBridge.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(bridge, timeline); + } + private void setActiveTracks(final Object owner, final boolean video, final boolean audio) { @@ -2134,7 +1986,6 @@ private static final class SmokeSession { private volatile boolean videoActive = true; private volatile long playerTimeMs; private volatile long peakCachedBytes; - private YoutubeSabrSession.InitializationResult initializationResult; private SmokeSession(final YoutubeSabrSession delegate, final SabrMediaBridge bridge, @@ -2161,7 +2012,7 @@ private int pumpOnceStreaming(final Localization localization) throws Exception private YoutubeSabrSession.RequestResult pumpOnceStreamingForDemand( final Localization localization, final SabrSegmentKey request) throws Exception { - playerTimeMs = Math.max(0, spec.getTimeline(request.getFormat()) + playerTimeMs = Math.max(0, (request.getFormat().isAudio() ? audioTimeline : videoTimeline) .getStartMs(request.getSequenceNumber())); return requestOnce(request); } @@ -2189,28 +2040,6 @@ private void accept(final SabrMediaSegment segment) { peakCachedBytes = Math.max(peakCachedBytes, getCachedBytes()); } - private void bootstrapInitialization(final Localization localization) throws Exception { - initializationResult = delegate.initialize(2_000, new byte[]{1, 2, 3, 4}); - state.setTimelines(initializationResult.getAudioTimeline(), - initializationResult.getVideoTimeline()); - } - - private SabrSourceSpec initializedSpec() { - if (initializationResult == null - || initializationResult.getAudioData() == null - || initializationResult.getVideoData() == null - || initializationResult.getAudioTimeline() == null - || initializationResult.getVideoTimeline() == null) { - return spec; - } - return new SabrSourceSpec(spec.getVideoId(), spec.getInfo(), audioFormat, - Collections.singletonList(audioFormat), videoFormat, - initializationResult.getAudioData(), initializationResult.getVideoData(), - initializationResult.getAudioTimeline(), - initializationResult.getVideoTimeline(), - initializationResult.getMediaSegments()); - } - private SabrMediaSegment getCachedSegment(final SabrSegmentKey request) { return segments.get(request); } @@ -2915,20 +2744,6 @@ private UmpFixture segment(final int headerId, final int itag, final int sequenc return mediaHeader(headerId, itag, sequence).media(headerId).mediaEnd(headerId); } - private UmpFixture initSegment(final int headerId, final int itag) { - return mediaHeader(headerId, itag, 0, 0, 0, 4, 0, true) - .media(headerId) - .mediaEnd(headerId); - } - - private UmpFixture initSegment(final int headerId, - final int itag, - final byte[] payload) { - return mediaHeader(headerId, itag, 0, 0, 0, payload.length, 0, true) - .media(headerId, payload) - .mediaEnd(headerId); - } - private UmpFixture segment(final int headerId, final int itag, final int sequence, diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java index 6a42deebf..82cf431fd 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -3233,7 +3233,13 @@ public void onPlayerError(@NonNull final PlaybackException error) { saveStreamProgressState(); boolean isCatchableException = false; - switch (error.errorCode) { + if (containsSabrAttestationRequired(error)) { + isCatchableException = true; + setRecovery(); + reloadPlayQueueManager(); + } else { + + switch (error.errorCode) { case ERROR_CODE_BEHIND_LIVE_WINDOW: isCatchableException = true; simpleExoPlayer.seekToDefaultPosition(); @@ -3314,6 +3320,7 @@ public void onPlayerError(@NonNull final PlaybackException error) { // API, remote and renderer errors belong here: onPlaybackShutdown(); break; + } } if (!isCatchableException) { @@ -3326,6 +3333,18 @@ public void onPlayerError(@NonNull final PlaybackException error) { } } + private static boolean containsSabrAttestationRequired(@NonNull final Throwable error) { + Throwable current = error; + while (current != null) { + if (current.getMessage() != null + && current.getMessage().contains("SABR attestation required")) { + return true; + } + current = current.getCause(); + } + return false; + } + private void showMediaCodecWorkaroundHint(@NonNull final PlaybackException error) { if (error.errorCode != ERROR_CODE_DECODING_FAILED && error.errorCode != ERROR_CODE_FAILED_RUNTIME_CHECK) { diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaBridge.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaBridge.java index cc087a0c6..051dfa104 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaBridge.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaBridge.java @@ -90,20 +90,15 @@ YoutubeSabrSession.RequestResult fetchSegments( final boolean audioActive, final boolean videoActive) throws IOException, ExtractionException { synchronized (requestLock) { - requestThread = Thread.currentThread(); - try { - final YoutubeSabrSession.RequestResult result = session.requestOnce( - activeAudio, - videoFormat, playerTimeMs, - audioTimeline, bufferedThrough(activeAudio), - videoTimeline, bufferedThrough(videoFormat), - audioActive, videoActive, videoActive && !audioActive, - 1.0f, segment -> acceptSegment(segment, activeAudio)); - publishBackoff(result.getBackoffMs()); - return result; - } finally { - requestThread = null; - } + final YoutubeSabrSession.RequestResult result = session.requestOnce( + activeAudio, + videoFormat, playerTimeMs, + audioTimeline, bufferedThrough(activeAudio), + videoTimeline, bufferedThrough(videoFormat), + audioActive, videoActive, videoActive && !audioActive, + 1.0f, segment -> acceptSegment(segment, activeAudio)); + publishBackoff(result.getBackoffMs()); + return result; } } diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java index 95e449505..bb8614fe7 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSessionStore.java @@ -35,7 +35,6 @@ /** Prepares SABR source data and retains a small LRU of Extractor protocol sessions. */ public final class SabrSessionStore { private static final int MAX_WARM_ENTRIES = 32; - private static final int MAX_SESSIONS = 8; private static final ExecutorService WARM_EXECUTOR = Executors.newFixedThreadPool(2, runnable -> daemonThread(runnable, "SabrAdaptivePrewarm")); private static final Map> WARM_ENTRIES = @@ -47,14 +46,6 @@ protected boolean removeEldestEntry( return size() > MAX_WARM_ENTRIES; } }); - private static final Map SESSIONS = - new LinkedHashMap(MAX_SESSIONS + 1, 0.75f, true) { - @Override - protected boolean removeEldestEntry( - final Map.Entry eldest) { - return size() > MAX_SESSIONS; - } - }; private static volatile LocalDomPoTokenProvider sharedProvider; private SabrSessionStore() { @@ -96,15 +87,9 @@ public static SabrSourceSpec createSourceSpec(@NonNull final String videoId, } final List videoFormats = Collections.singletonList(preferredVideo); - final YoutubeSabrInfo.Format videoBootstrap = preferredVideo; - final String key = warmKey(info); - final byte[] warmedPoToken = takeWarmedPoToken(key, videoId); - final LocalDomPoTokenProvider tokenProvider = provider(App.getApp()); - final byte[] poToken = warmedPoToken == null - ? tokenProvider.getPoToken(info) : warmedPoToken; PlaybackStartupTrace.markForVideoId(videoId, "sabr_source_spec_ready"); - return new SabrSourceSpec(videoId, info, poToken, - audio.bootstrapFormat, audio.formats, videoFormats, videoBootstrap, + return new SabrSourceSpec(videoId, info, + audio.bootstrapFormat, audio.formats, videoFormats, preferredVideo, null, null, null, null, Collections.emptyList()); } @@ -114,9 +99,6 @@ public static void prewarm(@NonNull final Context context, @NonNull final Stream || !(selectedStream.getDeliveryMethodInfo() instanceof YoutubeSabrInfo)) return; final YoutubeSabrInfo info = (YoutubeSabrInfo) selectedStream.getDeliveryMethodInfo(); if (!isUsableExtractorInfo(info, streamInfo.getId())) return; - final AudioSelection audio = selectAudioGroup(context, info, streamInfo.getAudioStreams()); - final YoutubeSabrInfo.Format video = pickVideoFormat(info, selectedStream.getItag()); - if (audio == null || video == null) return; final String key = warmKey(info); synchronized (WARM_ENTRIES) { if (WARM_ENTRIES.containsKey(key)) return; @@ -131,33 +113,20 @@ public static void prewarm(@NonNull final Context context, @NonNull final Stream static YoutubeSabrSession getOrCreateSession(@NonNull final Context context, @NonNull final SabrSourceSpec spec) throws IOException, ExtractionException { - final String key = sessionKey(spec.getInfo()); - final YoutubeSabrSession cached = getSession(key); - if (cached != null) return cached; final File spool = new File(context.getCacheDir(), "sabr-segments/" + spec.getVideoId() + '-' + System.nanoTime()); - final YoutubeSabrSession created = new YoutubeSabrSession(spec.getInfo(), null, null, spool); - final byte[] token = spec.getPoToken(); - if (token == null || token.length == 0) { + final YoutubeSabrSession created = new YoutubeSabrSession(spec.getInfo(), + spec.getBootstrapAudioFormat(), spec.getBootstrapVideoFormat(), spool); + final LocalDomPoTokenProvider tokenProvider = provider(context); + final byte[] token = takeWarmedPoToken(warmKey(spec.getInfo()), spec.getVideoId()); + final byte[] resolvedToken = token == null + ? tokenProvider.getPoToken(spec.getInfo()) : token; + if (resolvedToken == null || resolvedToken.length == 0) { throw new SabrLogicException("SABR PO token provider returned no token for video=" + spec.getVideoId()); } - created.setPoToken(token); - return cacheSession(key, created); - } - - @Nullable - private static synchronized YoutubeSabrSession getSession(@NonNull final String key) { - return SESSIONS.get(key); - } - - @NonNull - private static synchronized YoutubeSabrSession cacheSession( - @NonNull final String key, @NonNull final YoutubeSabrSession session) { - final YoutubeSabrSession existing = SESSIONS.get(key); - if (existing != null) return existing; - SESSIONS.put(key, session); - return session; + created.setPoToken(resolvedToken); + return created; } @Nullable @@ -279,9 +248,4 @@ private static String warmKey(@NonNull final YoutubeSabrInfo info) { return Objects.requireNonNull(info.getServerAbrStreamingUrl()); } - @NonNull - private static String sessionKey(@NonNull final YoutubeSabrInfo info) { - return warmKey(info); - } - } diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSourceSpec.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSourceSpec.java index 8f444ebc8..1a899d1ff 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSourceSpec.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrSourceSpec.java @@ -19,7 +19,6 @@ public final class SabrSourceSpec { @NonNull private final String videoId; @NonNull private final YoutubeSabrInfo info; - @NonNull private final byte[] poToken; @NonNull private final YoutubeSabrInfo.Format bootstrapAudioFormat; @NonNull private final List audioFormats; @NonNull private final List videoFormats; @@ -32,7 +31,6 @@ public final class SabrSourceSpec { SabrSourceSpec(@NonNull final String videoId, @NonNull final YoutubeSabrInfo info, - @NonNull final byte[] poToken, @NonNull final YoutubeSabrInfo.Format bootstrapAudioFormat, @NonNull final List audioFormats, @NonNull final List videoFormats, @@ -47,7 +45,6 @@ public final class SabrSourceSpec { } this.videoId = videoId; this.info = info; - this.poToken = poToken.clone(); this.bootstrapAudioFormat = bootstrapAudioFormat; this.audioFormats = Collections.unmodifiableList(new ArrayList<>(audioFormats)); if (videoFormats.isEmpty() || !videoFormats.contains(bootstrapVideoFormat)) { @@ -77,20 +74,19 @@ public final class SabrSourceSpec { } SabrSourceSpec(@NonNull final String videoId, @NonNull final YoutubeSabrInfo info, - @NonNull final byte[] poToken, @NonNull final YoutubeSabrInfo.Format audio, + @NonNull final YoutubeSabrInfo.Format audio, @NonNull final List audios, @NonNull final YoutubeSabrInfo.Format video, @Nullable final byte[] audioInit, @Nullable final byte[] videoInit, @Nullable final YoutubeSabrFormatTimeline audioTimeline, @Nullable final YoutubeSabrFormatTimeline videoTimeline, @NonNull final List segments) { - this(videoId, info, poToken, audio, audios, Collections.singletonList(video), video, + this(videoId, info, audio, audios, Collections.singletonList(video), video, audioInit, videoInit, audioTimeline, videoTimeline, segments); } @NonNull public String getVideoId() { return videoId; } @NonNull public YoutubeSabrInfo getInfo() { return info; } - @NonNull byte[] getPoToken() { return poToken.clone(); } @NonNull public YoutubeSabrInfo.Format getBootstrapAudioFormat() { return bootstrapAudioFormat; From acc38a388a6a33340687e99c8ef80fcde1e49d20 Mon Sep 17 00:00:00 2001 From: InfinityLoop1308 <96324692+InfinityLoop1308@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:14:32 +0800 Subject: [PATCH 13/13] 13 --- .../datasource/SabrDashMediaSource.java | 7 +++--- .../player/datasource/SabrMediaBridge.java | 22 +++++++++++++++---- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDashMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDashMediaSource.java index b145b2cbe..a98b8d05c 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDashMediaSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrDashMediaSource.java @@ -75,12 +75,13 @@ public SabrDashMediaSource(@NonNull final Context context, final SabrMediaBridge preparationBridge = getOrCreateBridge(); if (!preparationBridge.hasTimelines()) { try { - preparationBridge.fetchSegments(0, spec.getBootstrapAudioFormat(), true, true); + preparationBridge.awaitSegment( + SabrSegmentKey.media(spec.getBootstrapVideoFormat(), 1), 30_000, 0); } catch (final ExtractionException error) { - throw new IOException("Could not prepare SABR first response", error); + throw new IOException("Could not prepare SABR fragments", error); } if (!preparationBridge.hasTimelines()) { - throw new IOException("SABR first response did not provide initialization"); + throw new IOException("SABR fragments did not provide initialization"); } } final DataSource.Factory sabrDataSourceFactory = diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaBridge.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaBridge.java index 051dfa104..ed67cc688 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaBridge.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrMediaBridge.java @@ -123,6 +123,14 @@ void seedSegments(@NonNull final List segments) { SabrMediaSegment awaitSegment(@NonNull final SabrSegmentKey request, final long timeoutMs) throws IOException, ExtractionException { + return awaitSegment(request, timeoutMs, Long.MIN_VALUE); + } + + @NonNull + SabrMediaSegment awaitSegment(@NonNull final SabrSegmentKey request, + final long timeoutMs, + final long explicitPlayerTimeMs) + throws IOException, ExtractionException { final long deadlineNs = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(Math.max(1, timeoutMs)); SabrMediaSegment segment = ahead.get(request); @@ -141,8 +149,13 @@ SabrMediaSegment awaitSegment(@NonNull final SabrSegmentKey request, final YoutubeSabrInfo.Format activeAudio = request.getFormat().isAudio() ? request.getFormat() : (currentAudioFormat == null ? spec.getBootstrapAudioFormat() : currentAudioFormat); - final long playerTimeMs = Math.max(0, timelineFor(request.getFormat()) - .getStartMs(request.getSequenceNumber())); + final YoutubeSabrFormatTimeline requestTimeline = + timelineFor(request.getFormat()); + final long playerTimeMs = explicitPlayerTimeMs != Long.MIN_VALUE + ? Math.max(0, explicitPlayerTimeMs) + : requestTimeline == null ? 0 + : Math.max(0, requestTimeline.getStartMs( + request.getSequenceNumber())); final YoutubeSabrSession.RequestResult result = fetchSegments( playerTimeMs, activeAudio, audioActive, videoActive); if (result.isDeferred()) continue; @@ -300,8 +313,9 @@ private int bufferedThrough(@NonNull final YoutubeSabrInfo.Format format) { return next == null ? 0 : Math.max(0, next - 1); } - @NonNull - private YoutubeSabrFormatTimeline timelineFor(@NonNull final YoutubeSabrInfo.Format format) { + @Nullable + private YoutubeSabrFormatTimeline timelineFor( + @NonNull final YoutubeSabrInfo.Format format) { return format.isAudio() ? audioTimeline : videoTimeline; }