Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,11 @@ private static String getManifestUrl(@Nonnull final String manifestType,
* This method collects all audio, video, and video-only streams,
* then performs batch deobfuscation in one request.
*/
// TEMP (deep SABR testing): route every video through the real SABR pipeline (via
// serverAbrStreamingUrl). Set false for production. With it false, SABR only fills the
// SABR-only/no-HLS gap that upstream otherwise throws ContentNotSupportedException on.
private static final boolean FORCE_SABR_FOR_TESTING = true;

private void ensureStreamsAreCached() throws ExtractionException {
if (streamsCached) {
return;
Expand All @@ -793,6 +798,16 @@ private void ensureStreamsAreCached() throws ExtractionException {
assertPageFetched();
final String videoId = getId();

// SABR-only responses carry no per-format URLs: build session-based SABR streams instead
// of the classic URL/DASH/HLS path. The client drives a YoutubeSabrSession from these.
if (streamType != StreamType.LIVE_STREAM
&& (FORCE_SABR_FOR_TESTING
|| (isSabrOnlyResponse() && getHlsManifestUrlFromStreamingData().isEmpty()))) {
buildSabrStreams();
streamsCached = true;
return;
}

// Collect all ItagInfo objects from all stream types
final List<ItagInfo> allItagInfos = new ArrayList<>();
final int audioStartIndex = 0;
Expand Down Expand Up @@ -874,6 +889,125 @@ private void ensureStreamsAreCached() throws ExtractionException {
}
}

/**
* Build session-based SABR streams from a SABR-only response.
*
* <p>SABR adaptiveFormats carry no per-format URL: each stream is marked with
* {@link DeliveryMethod#SABR}, its {@code content} is the serverAbrStreamingUrl (for reference),
* and {@code isUrl} is false. The client drives a {@code YoutubeSabrSession} from the videoId and
* the selected itag to fetch media.</p>
*/
private void buildSabrStreams() {
cachedAudioStreams = new ArrayList<>();
cachedVideoStreams = new ArrayList<>();
cachedVideoOnlyStreams = new ArrayList<>();

final JsonObject streamingData = getSabrStreamingData();
if (streamingData == null) {
return;
}
final String serverAbrStreamingUrl =
streamingData.getString("serverAbrStreamingUrl", EMPTY_STRING);
final JsonArray adaptiveFormats = streamingData.getArray(ADAPTIVE_FORMATS);
if (adaptiveFormats == null) {
return;
}

for (int i = 0; i < adaptiveFormats.size(); i++) {
final JsonObject formatData = adaptiveFormats.getObject(i);
try {
final ItagItem itagItem = ItagItem.getItag(formatData.getInt("itag"));
fillSabrItagItem(itagItem, formatData);
final String id = String.valueOf(itagItem.id);

if (itagItem.itagType == ItagItem.ItagType.AUDIO) {
final AudioStream stream = new AudioStream.Builder()
.setId(id)
.setContent(serverAbrStreamingUrl, false)
.setMediaFormat(itagItem.getMediaFormat())
.setAverageBitrate(itagItem.getAverageBitrate())
.setItagItem(itagItem)
.setDeliveryMethod(DeliveryMethod.SABR)
.build();
// Dedup by itag, not Stream.equalStats: all SABR formats share the same
// MediaFormat/delivery, so equalStats would collapse every bitrate/codec to one.
if (cachedAudioStreams.stream().noneMatch(s -> id.equals(s.getId()))) {
cachedAudioStreams.add(stream);
}
} else if (itagItem.itagType == ItagItem.ItagType.VIDEO_ONLY) {
final String resolution = itagItem.getResolutionString();
final VideoStream stream = new VideoStream.Builder()
.setId(id)
.setContent(serverAbrStreamingUrl, false)
.setMediaFormat(itagItem.getMediaFormat())
.setIsVideoOnly(true)
.setItagItem(itagItem)
.setResolution(resolution != null ? resolution : EMPTY_STRING)
.setDeliveryMethod(DeliveryMethod.SABR)
.build();
if (cachedVideoOnlyStreams.stream().noneMatch(s -> id.equals(s.getId()))) {
cachedVideoOnlyStreams.add(stream);
}
}
} catch (final Exception e) {
// Skip unknown itags or malformed formats; do not fail the whole extraction.
}
}

Collections.sort(cachedAudioStreams,
Comparator.comparingInt(AudioStream::getBitrate).reversed());
}

@Nullable
private JsonObject getSabrStreamingData() {
for (final JsonObject streamingData : Arrays.asList(
webStreamingData, safariStreamingData, androidStreamingData,
tvHtml5SimplyEmbedStreamingData)) {
if (streamingData != null
&& streamingData.getArray(ADAPTIVE_FORMATS) != null
&& !streamingData.getArray(ADAPTIVE_FORMATS).isEmpty()) {
return streamingData;
}
}
return null;
}

private static void fillSabrItagItem(@Nonnull final ItagItem itagItem,
@Nonnull final JsonObject formatData) {
final String mimeType = formatData.getString("mimeType", EMPTY_STRING);
final String codec = mimeType.contains("codecs") ? mimeType.split("\"")[1] : EMPTY_STRING;

itagItem.setBitrate(formatData.getInt("bitrate"));
itagItem.setWidth(formatData.getInt("width"));
itagItem.setHeight(formatData.getInt("height"));
if (formatData.has("initRange")) {
final JsonObject initRange = formatData.getObject("initRange");
itagItem.setInitStart(Integer.parseInt(initRange.getString("start", "-1")));
itagItem.setInitEnd(Integer.parseInt(initRange.getString("end", "-1")));
}
if (formatData.has("indexRange")) {
final JsonObject indexRange = formatData.getObject("indexRange");
itagItem.setIndexStart(Integer.parseInt(indexRange.getString("start", "-1")));
itagItem.setIndexEnd(Integer.parseInt(indexRange.getString("end", "-1")));
}
itagItem.setQuality(formatData.getString("quality"));
itagItem.setCodec(codec);
final int fps = formatData.getInt("fps", -1);
if (fps != -1) {
itagItem.setFps(fps);
}
if (itagItem.itagType == ItagItem.ItagType.AUDIO) {
if (formatData.has("audioSampleRate")) {
itagItem.setSampleRate(Integer.parseInt(formatData.getString("audioSampleRate")));
}
itagItem.setAudioChannels(formatData.getInt("audioChannels", 2));
}
itagItem.setContentLength(Long.parseLong(formatData.getString("contentLength",
String.valueOf(CONTENT_LENGTH_UNKNOWN))));
itagItem.setApproxDurationMs(Long.parseLong(formatData.getString("approxDurationMs",
String.valueOf(APPROX_DURATION_MS_UNKNOWN))));
}

private void tryExtractHlsStreams(final String videoId) throws ExtractionException {
final String hlsManifestUrl = getHlsManifestUrlFromStreamingData();
if (hlsManifestUrl.isEmpty()) {
Expand Down Expand Up @@ -1388,12 +1522,8 @@ public void onSuccess(Response response) throws ExtractionException {
setStreamType();
}

if (streamType != StreamType.LIVE_STREAM && isSabrOnlyResponse()
&& getHlsManifestUrlFromStreamingData().isEmpty()) {
throw new ContentNotSupportedException(
"YouTube returned SABR-only streaming data without usable stream URLs. "
+ "Try logging in to get HLS fallback streams.");
}
// SABR-only responses are no longer a hard failure: ensureStreamsAreCached() builds
// session-based SABR streams (DeliveryMethod.SABR) from the adaptiveFormats instead.
}

private boolean isSabrOnlyResponse() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package org.schabi.newpipe.extractor.services.youtube.sabr;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;

public final class SabrBufferedRange {
private static final int MAX_INT32_VALUE = Integer.MAX_VALUE;

private final int itag;
private final long lastModified;
@Nullable
private final String xtags;
private final long startTimeMs;
private final long durationMs;
private final int startSegmentIndex;
private final int endSegmentIndex;
private final int timescale;

public SabrBufferedRange(final int itag,
final long lastModified,
@Nullable final String xtags,
final long startTimeMs,
final long durationMs,
final int startSegmentIndex,
final int endSegmentIndex,
final int timescale) {
this.itag = itag;
this.lastModified = lastModified;
this.xtags = xtags;
this.startTimeMs = startTimeMs;
this.durationMs = durationMs;
this.startSegmentIndex = startSegmentIndex;
this.endSegmentIndex = endSegmentIndex;
this.timescale = timescale;
}

@Nonnull
static SabrBufferedRange full(@Nonnull final YoutubeSabrFormat format) {
return new SabrBufferedRange(format.getItag(), format.getLastModified(), format.getXtags(),
0, MAX_INT32_VALUE, MAX_INT32_VALUE, MAX_INT32_VALUE, 1000);
}

@Nonnull
byte[] toProto() {
return toProto(true);
}

@Nonnull
byte[] toProto(final boolean includeTimeRange) {
final SabrProto.Writer range = new SabrProto.Writer();
range.writeMessage(1, formatIdProto());
range.writeUInt64(2, startTimeMs);
range.writeUInt64(3, durationMs);
range.writeInt32(4, startSegmentIndex);
range.writeInt32(5, endSegmentIndex);
if (includeTimeRange) {
range.writeMessage(6, timeRangeProto());
}
return range.toByteArray();
}

@Nonnull
public String summarize() {
return "itag=" + itag
+ ":seq=" + startSegmentIndex + "-" + endSegmentIndex
+ ":time=" + startTimeMs + "+" + durationMs
+ ":timescale=" + timescale;
}

@Nonnull
private byte[] formatIdProto() {
final SabrProto.Writer format = new SabrProto.Writer();
format.writeInt32(1, itag);
if (lastModified > 0) {
format.writeUInt64(2, lastModified);
}
format.writeStringIfNotEmpty(3, xtags);
return format.toByteArray();
}

@Nonnull
private byte[] timeRangeProto() {
final SabrProto.Writer timeRange = new SabrProto.Writer();
timeRange.writeUInt64(1, startTimeMs);
timeRange.writeUInt64(2, durationMs);
timeRange.writeInt32(3, timescale);
return timeRange.toByteArray();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package org.schabi.newpipe.extractor.services.youtube.sabr;

import javax.annotation.Nonnull;
import java.security.SecureRandom;
import java.nio.charset.StandardCharsets;

final class SabrColdStartPoToken {
private static final int MAX_IDENTIFIER_BYTES = 118;
private static final SecureRandom RANDOM = new SecureRandom();

private SabrColdStartPoToken() {
}

@Nonnull
static byte[] generate(@Nonnull final String identifier, final int clientState)
throws SabrProtocolException {
final byte[] identifierBytes = identifier.getBytes(StandardCharsets.UTF_8);
if (identifierBytes.length > MAX_IDENTIFIER_BYTES) {
throw new SabrProtocolException("PO token identifier is too long");
}

final int timestamp = (int) (System.currentTimeMillis() / 1000L);
final byte[] key = new byte[] {(byte) RANDOM.nextInt(256), (byte) RANDOM.nextInt(256)};
final byte[] header = new byte[] {
key[0],
key[1],
0,
(byte) clientState,
(byte) ((timestamp >> 24) & 0xff),
(byte) ((timestamp >> 16) & 0xff),
(byte) ((timestamp >> 8) & 0xff),
(byte) (timestamp & 0xff)
};

final byte[] packet = new byte[2 + header.length + identifierBytes.length];
packet[0] = 34;
packet[1] = (byte) (header.length + identifierBytes.length);
System.arraycopy(header, 0, packet, 2, header.length);
System.arraycopy(identifierBytes, 0, packet, 2 + header.length, identifierBytes.length);

for (int i = key.length; i < packet.length - 2; i++) {
packet[2 + i] ^= packet[2 + (i % key.length)];
}
return packet;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package org.schabi.newpipe.extractor.services.youtube.sabr;

import javax.annotation.Nonnull;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public final class SabrContextSendingPolicy {
private final List<Integer> startPolicy = new ArrayList<>();
private final List<Integer> stopPolicy = new ArrayList<>();
private final List<Integer> discardPolicy = new ArrayList<>();

private SabrContextSendingPolicy() {
}

@Nonnull
static SabrContextSendingPolicy decode(@Nonnull final byte[] data)
throws SabrProtocolException {
final SabrContextSendingPolicy policy = new SabrContextSendingPolicy();
for (final SabrProto.Field field : SabrProto.readFields(data)) {
switch (field.getNumber()) {
case 1:
policy.readPolicyValues(field, policy.startPolicy);
break;
case 2:
policy.readPolicyValues(field, policy.stopPolicy);
break;
case 3:
policy.readPolicyValues(field, policy.discardPolicy);
break;
default:
break;
}
}
return policy;
}

private void readPolicyValues(@Nonnull final SabrProto.Field field,
@Nonnull final List<Integer> output)
throws SabrProtocolException {
if (field.getWireType() == SabrProto.WIRE_VARINT) {
output.add((int) field.getVarint());
} else if (field.getWireType() == SabrProto.WIRE_LENGTH_DELIMITED) {
for (final Long value : SabrProto.readPackedVarints(field.getBytes())) {
output.add(value.intValue());
}
}
}

@Nonnull
public List<Integer> getStartPolicy() {
return Collections.unmodifiableList(startPolicy);
}

@Nonnull
public List<Integer> getStopPolicy() {
return Collections.unmodifiableList(stopPolicy);
}

@Nonnull
public List<Integer> getDiscardPolicy() {
return Collections.unmodifiableList(discardPolicy);
}

@Nonnull
public String summarize() {
return "start=" + startPolicy + ", stop=" + stopPolicy + ", discard=" + discardPolicy;
}
}
Loading