From c8f41b933419896d165a61e5f8888629031aa29a Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 3 Jun 2026 00:02:28 +0200 Subject: [PATCH 01/13] feat(youtube): add DeliveryMethod.SABR --- .../newpipe/extractor/stream/DeliveryMethod.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/stream/DeliveryMethod.java b/extractor/src/main/java/org/schabi/newpipe/extractor/stream/DeliveryMethod.java index ed9893572..993f4d42c 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/stream/DeliveryMethod.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/stream/DeliveryMethod.java @@ -49,5 +49,15 @@ public enum DeliveryMethod { * and Bitorrent's website for more information * about the BitTorrent protocol */ - TORRENT + TORRENT, + + /** + * Used for {@link Stream}s served using YouTube's SABR (Server Adaptive BitRate) protocol. + * + *

Unlike the other delivery methods, a SABR stream is not fetched from a single URL or a + * manifest. The client drives a stateful session, POSTing {@code VideoPlaybackAbrRequest} + * messages and receiving UMP responses that carry media segments and control policies until + * playback ends.

+ */ + SABR } From 3f36c1e7169c9a2964c1f9c0f857679901f2f543 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 3 Jun 2026 00:02:28 +0200 Subject: [PATCH 02/13] feat(sabr): UMP wire reader and proto codec --- .../services/youtube/sabr/SabrProto.java | 321 ++++++++++++++++++ .../youtube/sabr/SabrProtocolException.java | 13 + .../services/youtube/sabr/UmpPart.java | 34 ++ .../services/youtube/sabr/UmpReader.java | 80 +++++ 4 files changed, 448 insertions(+) create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrProto.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrProtocolException.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/UmpPart.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/UmpReader.java diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrProto.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrProto.java new file mode 100644 index 000000000..4ea41d23d --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrProto.java @@ -0,0 +1,321 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Minimal protobuf wire reader/writer used by the experimental YouTube SABR probe. + */ +final class SabrProto { + static final int WIRE_VARINT = 0; + static final int WIRE_FIXED64 = 1; + static final int WIRE_LENGTH_DELIMITED = 2; + static final int WIRE_FIXED32 = 5; + + private SabrProto() { + } + + @Nonnull + static List readFields(@Nonnull final byte[] data) throws SabrProtocolException { + final Cursor cursor = new Cursor(data); + final List fields = new ArrayList<>(); + while (!cursor.isDone()) { + final long tag = cursor.readVarint(); + final int number = (int) (tag >> 3); + final int wireType = (int) (tag & 0x07); + if (number <= 0) { + throw new SabrProtocolException("Invalid protobuf field number: " + number); + } + + switch (wireType) { + case WIRE_VARINT: + fields.add(Field.varint(number, cursor.readVarint())); + break; + case WIRE_FIXED64: + fields.add(Field.bytes(number, wireType, cursor.readBytes(8))); + break; + case WIRE_LENGTH_DELIMITED: + fields.add(Field.bytes(number, wireType, + cursor.readBytes((int) cursor.readVarint()))); + break; + case WIRE_FIXED32: + fields.add(Field.bytes(number, wireType, cursor.readBytes(4))); + break; + default: + throw new SabrProtocolException("Unsupported protobuf wire type: " + wireType); + } + } + return fields; + } + + @Nonnull + static byte[] formatId(@Nonnull final YoutubeSabrFormat format) { + final Writer writer = new Writer(); + writer.writeInt32(1, format.getItag()); + if (format.getLastModified() > 0) { + writer.writeUInt64(2, format.getLastModified()); + } + writer.writeStringIfNotEmpty(3, format.getXtags()); + return writer.toByteArray(); + } + + @Nonnull + static String asString(@Nonnull final byte[] data) { + return new String(data, StandardCharsets.UTF_8); + } + + @Nonnull + static List readPackedVarints(@Nonnull final byte[] data) throws SabrProtocolException { + final Cursor cursor = new Cursor(data); + final List values = new ArrayList<>(); + while (!cursor.isDone()) { + values.add(cursor.readVarint()); + } + return values; + } + + static int asFixed32LittleEndian(@Nonnull final byte[] data) throws SabrProtocolException { + if (data.length != 4) { + throw new SabrProtocolException("Expected fixed32 length 4, got " + data.length); + } + return (data[0] & 0xff) + | ((data[1] & 0xff) << 8) + | ((data[2] & 0xff) << 16) + | ((data[3] & 0xff) << 24); + } + + @Nonnull + static String summarizeFields(@Nonnull final byte[] data) throws SabrProtocolException { + return summarizeFields(data, new int[0]); + } + + @Nonnull + static String summarizeUnknownFields(@Nonnull final byte[] data, + final int... knownFieldNumbers) + throws SabrProtocolException { + return summarizeFields(data, knownFieldNumbers); + } + + @Nonnull + private static String summarizeFields(@Nonnull final byte[] data, + @Nonnull final int[] skippedFieldNumbers) + throws SabrProtocolException { + final Map fields = new LinkedHashMap<>(); + for (final Field field : readFields(data)) { + if (contains(skippedFieldNumbers, field.getNumber())) { + continue; + } + final String key = summarizeField(field); + final Integer count = fields.get(key); + fields.put(key, count == null ? 1 : count + 1); + } + + if (fields.isEmpty()) { + return "none"; + } + final StringBuilder builder = new StringBuilder(); + for (final Map.Entry entry : fields.entrySet()) { + if (builder.length() > 0) { + builder.append(", "); + } + builder.append(entry.getKey()); + if (entry.getValue() > 1) { + builder.append('x').append(entry.getValue()); + } + } + return builder.toString(); + } + + @Nonnull + private static String summarizeField(@Nonnull final Field field) throws SabrProtocolException { + final StringBuilder builder = new StringBuilder(); + builder.append(field.getNumber()).append('='); + if (field.getWireType() == WIRE_VARINT) { + builder.append(field.getVarint()); + } else { + builder.append("bytes(").append(field.getBytes().length).append(')'); + } + return builder.toString(); + } + + private static boolean contains(@Nonnull final int[] values, final int value) { + for (final int current : values) { + if (current == value) { + return true; + } + } + return false; + } + + static final class Field { + private final int number; + private final int wireType; + private final long varint; + @Nullable + private final byte[] bytes; + + private Field(final int number, + final int wireType, + final long varint, + @Nullable final byte[] bytes) { + this.number = number; + this.wireType = wireType; + this.varint = varint; + this.bytes = bytes; + } + + @Nonnull + static Field varint(final int number, final long value) { + return new Field(number, WIRE_VARINT, value, null); + } + + @Nonnull + static Field bytes(final int number, final int wireType, @Nonnull final byte[] value) { + return new Field(number, wireType, 0, value); + } + + int getNumber() { + return number; + } + + int getWireType() { + return wireType; + } + + long getVarint() { + return varint; + } + + @Nonnull + byte[] getBytes() throws SabrProtocolException { + if (bytes == null) { + throw new SabrProtocolException("Field " + number + " is not length-delimited"); + } + return bytes; + } + + @Nonnull + String getString() throws SabrProtocolException { + return asString(getBytes()); + } + } + + static final class Writer { + private final ByteArrayOutputStream output = new ByteArrayOutputStream(); + + void writeInt32(final int fieldNumber, final int value) { + writeUInt64(fieldNumber, value); + } + + void writeUInt64(final int fieldNumber, final long value) { + writeTag(fieldNumber, WIRE_VARINT); + writeVarint(value); + } + + void writeBool(final int fieldNumber, final boolean value) { + writeUInt64(fieldNumber, value ? 1 : 0); + } + + void writeFloat(final int fieldNumber, final float value) { + writeTag(fieldNumber, WIRE_FIXED32); + writeFixed32(Float.floatToIntBits(value)); + } + + void writeBytes(final int fieldNumber, @Nonnull final byte[] bytes) { + writeTag(fieldNumber, WIRE_LENGTH_DELIMITED); + writeVarint(bytes.length); + writeRaw(bytes); + } + + void writeStringIfNotEmpty(final int fieldNumber, @Nullable final String value) { + if (value != null && !value.isEmpty()) { + writeBytes(fieldNumber, value.getBytes(StandardCharsets.UTF_8)); + } + } + + void writeMessage(final int fieldNumber, @Nonnull final byte[] bytes) { + writeBytes(fieldNumber, bytes); + } + + @Nonnull + byte[] toByteArray() { + return output.toByteArray(); + } + + private void writeTag(final int fieldNumber, final int wireType) { + writeVarint(((long) fieldNumber << 3) | wireType); + } + + private void writeVarint(final long value) { + long remaining = value; + while ((remaining & ~0x7fL) != 0) { + output.write((int) ((remaining & 0x7f) | 0x80)); + remaining >>>= 7; + } + output.write((int) remaining); + } + + private void writeFixed32(final int value) { + output.write(value & 0xff); + output.write((value >> 8) & 0xff); + output.write((value >> 16) & 0xff); + output.write((value >> 24) & 0xff); + } + + private void writeRaw(@Nonnull final byte[] bytes) { + try { + output.write(bytes); + } catch (final IOException e) { + throw new IllegalStateException(e); + } + } + } + + private static final class Cursor { + private final byte[] data; + private int offset; + + private Cursor(@Nonnull final byte[] data) { + this.data = data; + } + + boolean isDone() { + return offset >= data.length; + } + + long readVarint() throws SabrProtocolException { + long result = 0; + int shift = 0; + while (shift < 64) { + if (offset >= data.length) { + throw new SabrProtocolException("Unexpected EOF in protobuf varint"); + } + final int current = data[offset++] & 0xff; + result |= (long) (current & 0x7f) << shift; + if ((current & 0x80) == 0) { + return result; + } + shift += 7; + } + throw new SabrProtocolException("Protobuf varint is too long"); + } + + @Nonnull + byte[] readBytes(final int length) throws SabrProtocolException { + if (length < 0 || offset + length > data.length) { + throw new SabrProtocolException("Unexpected EOF while reading " + length + " bytes"); + } + final byte[] result = new byte[length]; + System.arraycopy(data, offset, result, 0, length); + offset += length; + return result; + } + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrProtocolException.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrProtocolException.java new file mode 100644 index 000000000..f0437f446 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrProtocolException.java @@ -0,0 +1,13 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import org.schabi.newpipe.extractor.exceptions.ExtractionException; + +public class SabrProtocolException extends ExtractionException { + public SabrProtocolException(final String message) { + super(message); + } + + public SabrProtocolException(final String message, final Throwable cause) { + super(message, cause); + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/UmpPart.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/UmpPart.java new file mode 100644 index 000000000..f288f9b2e --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/UmpPart.java @@ -0,0 +1,34 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import javax.annotation.Nonnull; + +public final class UmpPart { + private final int type; + private final int size; + @Nonnull + private final byte[] data; + + UmpPart(final int type, final int size, @Nonnull final byte[] data) { + this.type = type; + this.size = size; + this.data = data; + } + + public int getType() { + return type; + } + + public int getSize() { + return size; + } + + @Nonnull + public byte[] getData() { + return data.clone(); + } + + @Nonnull + byte[] getRawData() { + return data; + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/UmpReader.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/UmpReader.java new file mode 100644 index 000000000..6a299f3da --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/UmpReader.java @@ -0,0 +1,80 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import javax.annotation.Nonnull; +import java.util.ArrayList; +import java.util.List; + +/** + * Reader for YouTube's UMP envelope. UMP uses its own compact integer format, not protobuf varints. + */ +public final class UmpReader { + private UmpReader() { + } + + @Nonnull + public static List readAll(@Nonnull final byte[] data) throws SabrProtocolException { + final Cursor cursor = new Cursor(data); + final List parts = new ArrayList<>(); + while (!cursor.isDone()) { + final int type = cursor.readUmpInt(); + final int size = cursor.readUmpInt(); + if (type < 0 || size < 0) { + throw new SabrProtocolException("Invalid UMP part header"); + } + parts.add(new UmpPart(type, size, cursor.readBytes(size))); + } + return parts; + } + + private static final class Cursor { + private final byte[] data; + private int offset; + + private Cursor(@Nonnull final byte[] data) { + this.data = data; + } + + boolean isDone() { + return offset >= data.length; + } + + int readUmpInt() throws SabrProtocolException { + final int first = readUnsignedByte(); + if (first < 128) { + return first; + } + if (first < 192) { + return (first & 0x3f) + 64 * readUnsignedByte(); + } + if (first < 224) { + return (first & 0x1f) + 32 * (readUnsignedByte() + + 256 * readUnsignedByte()); + } + if (first < 240) { + return (first & 0x0f) + 16 * (readUnsignedByte() + + 256 * (readUnsignedByte() + 256 * readUnsignedByte())); + } + return readUnsignedByte() + + 256 * (readUnsignedByte() + + 256 * (readUnsignedByte() + 256 * readUnsignedByte())); + } + + @Nonnull + byte[] readBytes(final int length) throws SabrProtocolException { + if (length < 0 || offset + length > data.length) { + throw new SabrProtocolException("Unexpected EOF while reading UMP part data"); + } + final byte[] result = new byte[length]; + System.arraycopy(data, offset, result, 0, length); + offset += length; + return result; + } + + private int readUnsignedByte() throws SabrProtocolException { + if (offset >= data.length) { + throw new SabrProtocolException("Unexpected EOF in UMP integer"); + } + return data[offset++] & 0xff; + } + } +} From 79952a901b5c7a780a72881d689e18a725265294 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 3 Jun 2026 00:02:28 +0200 Subject: [PATCH 03/13] feat(sabr): UMP response parts - media and policy --- .../SabrFormatInitializationMetadata.java | 219 ++++++++++++ .../youtube/sabr/SabrMediaHeader.java | 316 ++++++++++++++++++ .../youtube/sabr/SabrNextRequestPolicy.java | 159 +++++++++ .../youtube/sabr/SabrPlaybackCookie.java | 174 ++++++++++ .../youtube/sabr/SabrPlaybackStartPolicy.java | 118 +++++++ .../sabr/SabrRequestCancellationPolicy.java | 125 +++++++ .../youtube/sabr/SabrRequestIdentifier.java | 35 ++ .../youtube/sabr/SabrSelectableFormats.java | 182 ++++++++++ .../sabr/SabrStreamProtectionStatus.java | 53 +++ 9 files changed, 1381 insertions(+) create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrFormatInitializationMetadata.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrMediaHeader.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrNextRequestPolicy.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrPlaybackCookie.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrPlaybackStartPolicy.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrRequestCancellationPolicy.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrRequestIdentifier.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrSelectableFormats.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrStreamProtectionStatus.java diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrFormatInitializationMetadata.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrFormatInitializationMetadata.java new file mode 100644 index 000000000..ffb2a3162 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrFormatInitializationMetadata.java @@ -0,0 +1,219 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public final class SabrFormatInitializationMetadata { + @Nullable + private final String videoId; + private final int itag; + private final long lastModified; + @Nullable + private final String xtags; + private final long endTimeMs; + private final long endSegmentNumber; + @Nullable + private final String mimeType; + private final long initRangeStart; + private final long initRangeEnd; + private final long indexRangeStart; + private final long indexRangeEnd; + private final long field8; + private final long durationUnits; + private final long durationTimescale; + + private SabrFormatInitializationMetadata(@Nullable final String videoId, + final int itag, + final long lastModified, + @Nullable final String xtags, + final long endTimeMs, + final long endSegmentNumber, + @Nullable final String mimeType, + final long initRangeStart, + final long initRangeEnd, + final long indexRangeStart, + final long indexRangeEnd, + final long field8, + final long durationUnits, + final long durationTimescale) { + this.videoId = videoId; + this.itag = itag; + this.lastModified = lastModified; + this.xtags = xtags; + this.endTimeMs = endTimeMs; + this.endSegmentNumber = endSegmentNumber; + this.mimeType = mimeType; + this.initRangeStart = initRangeStart; + this.initRangeEnd = initRangeEnd; + this.indexRangeStart = indexRangeStart; + this.indexRangeEnd = indexRangeEnd; + this.field8 = field8; + this.durationUnits = durationUnits; + this.durationTimescale = durationTimescale; + } + + @Nonnull + static SabrFormatInitializationMetadata decode(@Nonnull final byte[] data) + throws SabrProtocolException { + String videoId = null; + int itag = -1; + long lastModified = -1; + String xtags = null; + long endTimeMs = -1; + long endSegmentNumber = -1; + String mimeType = null; + long initRangeStart = -1; + long initRangeEnd = -1; + long indexRangeStart = -1; + long indexRangeEnd = -1; + long field8 = -1; + long durationUnits = -1; + long durationTimescale = -1; + + for (final SabrProto.Field field : SabrProto.readFields(data)) { + switch (field.getNumber()) { + case 1: + videoId = field.getString(); + break; + case 2: + for (final SabrProto.Field formatField : SabrProto.readFields(field.getBytes())) { + if (formatField.getNumber() == 1) { + itag = (int) formatField.getVarint(); + } else if (formatField.getNumber() == 2) { + lastModified = formatField.getVarint(); + } else if (formatField.getNumber() == 3) { + xtags = formatField.getString(); + } + } + break; + case 3: + endTimeMs = field.getVarint(); + break; + case 4: + endSegmentNumber = field.getVarint(); + break; + case 5: + mimeType = field.getString(); + break; + case 6: + final Range initRange = decodeRange(field.getBytes()); + initRangeStart = initRange.start; + initRangeEnd = initRange.end; + break; + case 7: + final Range indexRange = decodeRange(field.getBytes()); + indexRangeStart = indexRange.start; + indexRangeEnd = indexRange.end; + break; + case 8: + field8 = field.getVarint(); + break; + case 9: + durationUnits = field.getVarint(); + break; + case 10: + durationTimescale = field.getVarint(); + break; + default: + break; + } + } + + return new SabrFormatInitializationMetadata(videoId, itag, lastModified, xtags, + endTimeMs, endSegmentNumber, mimeType, initRangeStart, initRangeEnd, + indexRangeStart, indexRangeEnd, field8, durationUnits, durationTimescale); + } + + @Nonnull + private static Range decodeRange(@Nonnull final byte[] data) throws SabrProtocolException { + long start = -1; + long end = -1; + for (final SabrProto.Field field : SabrProto.readFields(data)) { + if ((field.getNumber() == 1 || field.getNumber() == 3) + && field.getWireType() == SabrProto.WIRE_VARINT) { + start = field.getVarint(); + } else if ((field.getNumber() == 2 || field.getNumber() == 4) + && field.getWireType() == SabrProto.WIRE_VARINT) { + end = field.getVarint(); + } + } + return new Range(start, end); + } + + @Nullable + public String getVideoId() { + return videoId; + } + + public int getItag() { + return itag; + } + + public long getLastModified() { + return lastModified; + } + + @Nullable + public String getXtags() { + return xtags; + } + + public long getEndSegmentNumber() { + return endSegmentNumber; + } + + @Nullable + public String getMimeType() { + return mimeType; + } + + public long getDurationUnits() { + return durationUnits; + } + + public long getDurationTimescale() { + return durationTimescale; + } + + public long getInitRangeStart() { + return initRangeStart; + } + + public long getInitRangeEnd() { + return initRangeEnd; + } + + public long getIndexRangeStart() { + return indexRangeStart; + } + + public long getIndexRangeEnd() { + return indexRangeEnd; + } + + public long getField8() { + return field8; + } + + @Nonnull + public String summarize() { + return "itag=" + itag + + ", endSegment=" + endSegmentNumber + + ", endTimeMs=" + endTimeMs + + ", mime=" + (mimeType == null ? "null" : mimeType) + + ", init=" + initRangeStart + '-' + initRangeEnd + + ", index=" + indexRangeStart + '-' + indexRangeEnd + + ", field8=" + field8 + + ", duration=" + durationUnits + '/' + durationTimescale; + } + + private static final class Range { + private final long start; + private final long end; + + private Range(final long start, final long end) { + this.start = start; + this.end = end; + } + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrMediaHeader.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrMediaHeader.java new file mode 100644 index 000000000..86d1555f6 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrMediaHeader.java @@ -0,0 +1,316 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public final class SabrMediaHeader { + private final int headerId; + @Nullable + private final String videoId; + private final int itag; + private final long lastModified; + @Nullable + private final String xtags; + private final long startRange; + private final int compressionAlgorithm; + private final boolean initSegment; + private final int sequenceNumber; + private final long bitrateBps; + private final long startMs; + private final long durationMs; + private final long contentLength; + private final long timeRangeStartTicks; + private final long timeRangeDurationTicks; + private final int timeRangeTimescale; + private final long sequenceLastModified; + + private SabrMediaHeader(final int headerId, + @Nullable final String videoId, + final int itag, + final long lastModified, + @Nullable final String xtags, + final long startRange, + final int compressionAlgorithm, + final boolean initSegment, + final int sequenceNumber, + final long bitrateBps, + final long startMs, + final long durationMs, + final long contentLength, + final long timeRangeStartTicks, + final long timeRangeDurationTicks, + final int timeRangeTimescale, + final long sequenceLastModified) { + this.headerId = headerId; + this.videoId = videoId; + this.itag = itag; + this.lastModified = lastModified; + this.xtags = xtags; + this.startRange = startRange; + this.compressionAlgorithm = compressionAlgorithm; + this.initSegment = initSegment; + this.sequenceNumber = sequenceNumber; + this.bitrateBps = bitrateBps; + this.startMs = startMs; + this.durationMs = durationMs; + this.contentLength = contentLength; + this.timeRangeStartTicks = timeRangeStartTicks; + this.timeRangeDurationTicks = timeRangeDurationTicks; + this.timeRangeTimescale = timeRangeTimescale; + this.sequenceLastModified = sequenceLastModified; + } + + @Nonnull + static SabrMediaHeader decode(@Nonnull final byte[] data) throws SabrProtocolException { + int headerId = -1; + String videoId = null; + int itag = -1; + long lastModified = -1; + String xtags = null; + long startRange = -1; + int compressionAlgorithm = -1; + boolean initSegment = false; + int sequenceNumber = -1; + long bitrateBps = -1; + long startMs = -1; + long durationMs = -1; + long contentLength = -1; + long timeRangeStartTicks = -1; + long timeRangeDurationTicks = -1; + int timeRangeTimescale = -1; + long sequenceLastModified = -1; + + for (final SabrProto.Field field : SabrProto.readFields(data)) { + switch (field.getNumber()) { + case 1: + headerId = (int) field.getVarint(); + break; + case 2: + videoId = field.getString(); + break; + case 3: + itag = (int) field.getVarint(); + break; + case 4: + lastModified = field.getVarint(); + break; + case 5: + xtags = field.getString(); + break; + case 6: + startRange = field.getVarint(); + break; + case 7: + compressionAlgorithm = (int) field.getVarint(); + break; + case 8: + initSegment = field.getVarint() != 0; + break; + case 9: + sequenceNumber = (int) field.getVarint(); + break; + case 10: + bitrateBps = field.getVarint(); + break; + case 11: + startMs = field.getVarint(); + break; + case 12: + durationMs = field.getVarint(); + break; + case 13: + final FormatId formatId = decodeFormatId(field.getBytes()); + if (itag < 0) { + itag = formatId.itag; + } + if (lastModified < 0) { + lastModified = formatId.lastModified; + } + if (xtags == null) { + xtags = formatId.xtags; + } + break; + case 14: + contentLength = field.getVarint(); + break; + case 15: + final TimeRange timeRange = decodeTimeRange(field.getBytes()); + timeRangeStartTicks = timeRange.startTicks; + timeRangeDurationTicks = timeRange.durationTicks; + timeRangeTimescale = timeRange.timescale; + break; + case 16: + sequenceLastModified = field.getVarint(); + break; + default: + break; + } + } + + if (timeRangeTimescale > 0) { + if (startMs < 0 && timeRangeStartTicks >= 0) { + startMs = timeRangeStartTicks * 1000L / timeRangeTimescale; + } + if (durationMs < 0 && timeRangeDurationTicks >= 0) { + durationMs = timeRangeDurationTicks * 1000L / timeRangeTimescale; + } + } + + return new SabrMediaHeader(headerId, videoId, itag, lastModified, xtags, startRange, + compressionAlgorithm, initSegment, sequenceNumber, bitrateBps, startMs, durationMs, + contentLength, timeRangeStartTicks, timeRangeDurationTicks, timeRangeTimescale, + sequenceLastModified); + } + + @Nonnull + private static FormatId decodeFormatId(@Nonnull final byte[] data) throws SabrProtocolException { + int itag = -1; + long lastModified = -1; + String xtags = null; + 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) { + xtags = field.getString(); + } + } + return new FormatId(itag, lastModified, xtags); + } + + @Nonnull + private static TimeRange decodeTimeRange(@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 && field.getWireType() == SabrProto.WIRE_VARINT) { + startTicks = field.getVarint(); + } else if (field.getNumber() == 2 && field.getWireType() == SabrProto.WIRE_VARINT) { + durationTicks = field.getVarint(); + } else if (field.getNumber() == 3 && field.getWireType() == SabrProto.WIRE_VARINT) { + timescale = (int) field.getVarint(); + } + } + return new TimeRange(startTicks, durationTicks, timescale); + } + + public int getHeaderId() { + return headerId; + } + + @Nullable + public String getVideoId() { + return videoId; + } + + public int getItag() { + return itag; + } + + public long getLastModified() { + return lastModified; + } + + @Nullable + public String getXtags() { + return xtags; + } + + public long getStartRange() { + return startRange; + } + + public int getCompressionAlgorithm() { + return compressionAlgorithm; + } + + public boolean isInitSegment() { + return initSegment; + } + + public int getSequenceNumber() { + return sequenceNumber; + } + + public long getBitrateBps() { + return bitrateBps; + } + + public long getStartMs() { + return startMs; + } + + public long getDurationMs() { + return durationMs; + } + + public long getContentLength() { + return contentLength; + } + + public long getTimeRangeStartTicks() { + return timeRangeStartTicks; + } + + public long getTimeRangeDurationTicks() { + return timeRangeDurationTicks; + } + + public int getTimeRangeTimescale() { + return timeRangeTimescale; + } + + public long getSequenceLastModified() { + return sequenceLastModified; + } + + @Nonnull + public String summarize() { + return "id=" + headerId + + ", itag=" + itag + + ", init=" + initSegment + + ", seq=" + sequenceNumber + + ", startRange=" + startRange + + ", startMs=" + startMs + + ", durationMs=" + durationMs + + ", contentLength=" + contentLength + + ", compression=" + compressionAlgorithm + + ", bitrateBps=" + bitrateBps + + ", timeRange=" + timeRangeStartTicks + '+' + timeRangeDurationTicks + + '/' + timeRangeTimescale + + ", sequenceLmt=" + sequenceLastModified; + } + + private static final class FormatId { + private final int itag; + private final long lastModified; + @Nullable + private final String xtags; + + private FormatId(final int itag, + final long lastModified, + @Nullable final String xtags) { + this.itag = itag; + this.lastModified = lastModified; + this.xtags = xtags; + } + } + + private static final class TimeRange { + private final long startTicks; + private final long durationTicks; + private final int timescale; + + private TimeRange(final long startTicks, + final long durationTicks, + final int timescale) { + this.startTicks = startTicks; + this.durationTicks = durationTicks; + this.timescale = timescale; + } + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrNextRequestPolicy.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrNextRequestPolicy.java new file mode 100644 index 000000000..d5d0f9b7e --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrNextRequestPolicy.java @@ -0,0 +1,159 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public final class SabrNextRequestPolicy { + private final int targetAudioReadaheadMs; + private final int targetVideoReadaheadMs; + private final int maxTimeSinceLastRequestMs; + private final int backoffTimeMs; + private final int minAudioReadaheadMs; + private final int minVideoReadaheadMs; + @Nullable + private final byte[] playbackCookie; + @Nullable + private final SabrPlaybackCookie decodedPlaybackCookie; + @Nullable + private final String videoId; + @Nonnull + private final String unknownFields; + + private SabrNextRequestPolicy(final int targetAudioReadaheadMs, + final int targetVideoReadaheadMs, + final int maxTimeSinceLastRequestMs, + final int backoffTimeMs, + final int minAudioReadaheadMs, + final int minVideoReadaheadMs, + @Nullable final byte[] playbackCookie, + @Nullable final SabrPlaybackCookie decodedPlaybackCookie, + @Nullable final String videoId, + @Nonnull final String unknownFields) { + this.targetAudioReadaheadMs = targetAudioReadaheadMs; + this.targetVideoReadaheadMs = targetVideoReadaheadMs; + this.maxTimeSinceLastRequestMs = maxTimeSinceLastRequestMs; + this.backoffTimeMs = backoffTimeMs; + this.minAudioReadaheadMs = minAudioReadaheadMs; + this.minVideoReadaheadMs = minVideoReadaheadMs; + this.playbackCookie = playbackCookie == null ? null : playbackCookie.clone(); + this.decodedPlaybackCookie = decodedPlaybackCookie; + this.videoId = videoId; + this.unknownFields = unknownFields; + } + + @Nonnull + static SabrNextRequestPolicy decode(@Nonnull final byte[] data) throws SabrProtocolException { + int targetAudioReadaheadMs = -1; + int targetVideoReadaheadMs = -1; + int maxTimeSinceLastRequestMs = -1; + int backoffTimeMs = -1; + int minAudioReadaheadMs = -1; + int minVideoReadaheadMs = -1; + byte[] playbackCookie = null; + SabrPlaybackCookie decodedPlaybackCookie = null; + String videoId = null; + final String unknownFields = SabrProto.summarizeUnknownFields(data, + 1, 2, 3, 4, 5, 6, 7, 8); + + for (final SabrProto.Field field : SabrProto.readFields(data)) { + switch (field.getNumber()) { + case 1: + targetAudioReadaheadMs = (int) field.getVarint(); + break; + case 2: + targetVideoReadaheadMs = (int) field.getVarint(); + break; + case 3: + maxTimeSinceLastRequestMs = (int) field.getVarint(); + break; + case 4: + backoffTimeMs = (int) field.getVarint(); + break; + case 5: + minAudioReadaheadMs = (int) field.getVarint(); + break; + case 6: + minVideoReadaheadMs = (int) field.getVarint(); + break; + case 7: + playbackCookie = field.getBytes(); + decodedPlaybackCookie = SabrPlaybackCookie.decode(playbackCookie); + break; + case 8: + videoId = field.getString(); + break; + default: + break; + } + } + + return new SabrNextRequestPolicy(targetAudioReadaheadMs, targetVideoReadaheadMs, + maxTimeSinceLastRequestMs, backoffTimeMs, minAudioReadaheadMs, + minVideoReadaheadMs, playbackCookie, decodedPlaybackCookie, videoId, + unknownFields); + } + + public int getTargetAudioReadaheadMs() { + return targetAudioReadaheadMs; + } + + public int getTargetVideoReadaheadMs() { + return targetVideoReadaheadMs; + } + + public int getMaxTimeSinceLastRequestMs() { + return maxTimeSinceLastRequestMs; + } + + public int getBackoffTimeMs() { + return backoffTimeMs; + } + + public int getMinAudioReadaheadMs() { + return minAudioReadaheadMs; + } + + public int getMinVideoReadaheadMs() { + return minVideoReadaheadMs; + } + + @Nullable + public byte[] getPlaybackCookie() { + return playbackCookie == null ? null : playbackCookie.clone(); + } + + @Nullable + byte[] getRawPlaybackCookie() { + return playbackCookie; + } + + @Nullable + public SabrPlaybackCookie getDecodedPlaybackCookie() { + return decodedPlaybackCookie; + } + + @Nullable + public String getVideoId() { + return videoId; + } + + @Nonnull + public String getUnknownFields() { + return unknownFields; + } + + @Nonnull + public String summarize() { + return "targetAudio=" + targetAudioReadaheadMs + + ", targetVideo=" + targetVideoReadaheadMs + + ", maxSinceLast=" + maxTimeSinceLastRequestMs + + ", backoff=" + backoffTimeMs + + ", minAudio=" + minAudioReadaheadMs + + ", minVideo=" + minVideoReadaheadMs + + ", cookie=" + (decodedPlaybackCookie == null + ? "bytes(" + (playbackCookie == null ? 0 : playbackCookie.length) + ')' + : decodedPlaybackCookie.summarize()) + + ", videoIdLength=" + (videoId == null ? 0 : videoId.length()) + + ("none".equals(unknownFields) ? "" : ", unknown=" + unknownFields); + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrPlaybackCookie.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrPlaybackCookie.java new file mode 100644 index 000000000..eb3fcc306 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrPlaybackCookie.java @@ -0,0 +1,174 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import javax.annotation.Nonnull; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +public final class SabrPlaybackCookie { + private final int resolution; + private final int field2; + private final int videoItag; + private final long videoLastModified; + private final boolean videoXtagsPresent; + private final int audioItag; + private final long audioLastModified; + private final boolean audioXtagsPresent; + @Nonnull + private final Map extraVarints; + @Nonnull + private final String extraFields; + + private SabrPlaybackCookie(final int resolution, + final int field2, + final int videoItag, + final long videoLastModified, + final boolean videoXtagsPresent, + final int audioItag, + final long audioLastModified, + final boolean audioXtagsPresent, + @Nonnull final Map extraVarints, + @Nonnull final String extraFields) { + this.resolution = resolution; + this.field2 = field2; + this.videoItag = videoItag; + this.videoLastModified = videoLastModified; + this.videoXtagsPresent = videoXtagsPresent; + this.audioItag = audioItag; + this.audioLastModified = audioLastModified; + this.audioXtagsPresent = audioXtagsPresent; + this.extraVarints = Collections.unmodifiableMap(new LinkedHashMap<>(extraVarints)); + this.extraFields = extraFields; + } + + @Nonnull + static SabrPlaybackCookie decode(@Nonnull final byte[] data) throws SabrProtocolException { + int resolution = -1; + int field2 = -1; + int videoItag = -1; + long videoLastModified = -1; + boolean videoXtagsPresent = false; + int audioItag = -1; + long audioLastModified = -1; + boolean audioXtagsPresent = false; + final Map extraVarints = new LinkedHashMap<>(); + final String extraFields = SabrProto.summarizeUnknownFields(data, 1, 2, 7, 8); + + for (final SabrProto.Field field : SabrProto.readFields(data)) { + switch (field.getNumber()) { + case 1: + resolution = (int) field.getVarint(); + break; + case 2: + field2 = (int) field.getVarint(); + break; + case 7: + final FormatId video = decodeFormatId(field.getBytes()); + videoItag = video.itag; + videoLastModified = video.lastModified; + videoXtagsPresent = video.xtagsPresent; + break; + case 8: + final FormatId audio = decodeFormatId(field.getBytes()); + audioItag = audio.itag; + audioLastModified = audio.lastModified; + audioXtagsPresent = audio.xtagsPresent; + break; + default: + if (field.getWireType() == SabrProto.WIRE_VARINT) { + extraVarints.put(field.getNumber(), field.getVarint()); + } + break; + } + } + return new SabrPlaybackCookie(resolution, field2, videoItag, videoLastModified, + videoXtagsPresent, audioItag, audioLastModified, audioXtagsPresent, + extraVarints, extraFields); + } + + @Nonnull + private static FormatId decodeFormatId(@Nonnull final byte[] data) throws SabrProtocolException { + int itag = -1; + long lastModified = -1; + boolean xtagsPresent = false; + 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) { + xtagsPresent = field.getBytes().length > 0; + } + } + return new FormatId(itag, lastModified, xtagsPresent); + } + + public int getResolution() { + return resolution; + } + + public int getField2() { + return field2; + } + + public int getVideoItag() { + return videoItag; + } + + public long getVideoLastModified() { + return videoLastModified; + } + + public boolean isVideoXtagsPresent() { + return videoXtagsPresent; + } + + public int getAudioItag() { + return audioItag; + } + + public long getAudioLastModified() { + return audioLastModified; + } + + public boolean isAudioXtagsPresent() { + return audioXtagsPresent; + } + + @Nonnull + public Map getExtraVarints() { + return extraVarints; + } + + @Nonnull + public String getExtraFields() { + return extraFields; + } + + @Nonnull + public String summarize() { + return "resolution=" + resolution + + ", field2=" + field2 + + ", videoItag=" + videoItag + + (videoXtagsPresent ? "+xtags" : "") + + ", audioItag=" + audioItag + + (audioXtagsPresent ? "+xtags" : "") + + ", extraVarints=" + extraVarints + + ("none".equals(extraFields) ? "" : ", extraFields=" + extraFields); + } + + private static final class FormatId { + private final int itag; + private final long lastModified; + private final boolean xtagsPresent; + + private FormatId(final int itag, + final long lastModified, + final boolean xtagsPresent) { + this.itag = itag; + this.lastModified = lastModified; + this.xtagsPresent = xtagsPresent; + } + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrPlaybackStartPolicy.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrPlaybackStartPolicy.java new file mode 100644 index 000000000..6db06f071 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrPlaybackStartPolicy.java @@ -0,0 +1,118 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import javax.annotation.Nonnull; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class SabrPlaybackStartPolicy { + @Nonnull + private final List startMinReadaheadPolicies; + @Nonnull + private final List resumeMinReadaheadPolicies; + @Nonnull + private final Map extraVarints; + + private SabrPlaybackStartPolicy( + @Nonnull final List startMinReadaheadPolicies, + @Nonnull final List resumeMinReadaheadPolicies, + @Nonnull final Map extraVarints) { + this.startMinReadaheadPolicies = Collections.unmodifiableList( + new ArrayList<>(startMinReadaheadPolicies)); + this.resumeMinReadaheadPolicies = Collections.unmodifiableList( + new ArrayList<>(resumeMinReadaheadPolicies)); + this.extraVarints = Collections.unmodifiableMap(new LinkedHashMap<>(extraVarints)); + } + + @Nonnull + static SabrPlaybackStartPolicy decode(@Nonnull final byte[] data) + throws SabrProtocolException { + final List startMinReadaheadPolicies = new ArrayList<>(); + final List resumeMinReadaheadPolicies = new ArrayList<>(); + final Map extraVarints = new LinkedHashMap<>(); + + for (final SabrProto.Field field : SabrProto.readFields(data)) { + if (field.getNumber() == 1 + && field.getWireType() == SabrProto.WIRE_LENGTH_DELIMITED) { + startMinReadaheadPolicies.add(decodeReadaheadPolicy(field.getBytes())); + } else if (field.getNumber() == 2 + && field.getWireType() == SabrProto.WIRE_LENGTH_DELIMITED) { + resumeMinReadaheadPolicies.add(decodeReadaheadPolicy(field.getBytes())); + } else if (field.getWireType() == SabrProto.WIRE_VARINT) { + extraVarints.put(field.getNumber(), field.getVarint()); + } + } + + return new SabrPlaybackStartPolicy(startMinReadaheadPolicies, + resumeMinReadaheadPolicies, extraVarints); + } + + @Nonnull + private static ReadaheadPolicy decodeReadaheadPolicy(@Nonnull final byte[] data) + throws SabrProtocolException { + int minBandwidthBytesPerSecond = -1; + int minReadaheadMs = -1; + for (final SabrProto.Field field : SabrProto.readFields(data)) { + if (field.getNumber() == 1 && field.getWireType() == SabrProto.WIRE_VARINT) { + minBandwidthBytesPerSecond = (int) field.getVarint(); + } else if (field.getNumber() == 2 && field.getWireType() == SabrProto.WIRE_VARINT) { + minReadaheadMs = (int) field.getVarint(); + } + } + return new ReadaheadPolicy(minBandwidthBytesPerSecond, minReadaheadMs); + } + + @Nonnull + public String summarize() { + return "start=" + summarizePolicies(startMinReadaheadPolicies) + + ", resume=" + summarizePolicies(resumeMinReadaheadPolicies) + + ", extraVarints=" + extraVarints; + } + + @Nonnull + private static String summarizePolicies(@Nonnull final List policies) { + if (policies.isEmpty()) { + return "[]"; + } + final StringBuilder builder = new StringBuilder(); + builder.append(policies.size()).append('['); + final int sampleSize = Math.min(6, policies.size()); + for (int i = 0; i < sampleSize; i++) { + if (i > 0) { + builder.append(','); + } + builder.append(policies.get(i).summarize()); + } + if (policies.size() > sampleSize) { + builder.append(",..."); + } + builder.append(']'); + return builder.toString(); + } + + public static final class ReadaheadPolicy { + private final int minBandwidthBytesPerSecond; + private final int minReadaheadMs; + + private ReadaheadPolicy(final int minBandwidthBytesPerSecond, + final int minReadaheadMs) { + this.minBandwidthBytesPerSecond = minBandwidthBytesPerSecond; + this.minReadaheadMs = minReadaheadMs; + } + + public int getMinBandwidthBytesPerSecond() { + return minBandwidthBytesPerSecond; + } + + public int getMinReadaheadMs() { + return minReadaheadMs; + } + + @Nonnull + private String summarize() { + return minReadaheadMs + "ms/" + minBandwidthBytesPerSecond + "Bps"; + } + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrRequestCancellationPolicy.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrRequestCancellationPolicy.java new file mode 100644 index 000000000..f53543128 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrRequestCancellationPolicy.java @@ -0,0 +1,125 @@ +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 SabrRequestCancellationPolicy { + private final int field1; + private final int field3; + @Nonnull + private final List items; + + private SabrRequestCancellationPolicy(final int field1, + final int field3, + @Nonnull final List items) { + this.field1 = field1; + this.field3 = field3; + this.items = Collections.unmodifiableList(new ArrayList<>(items)); + } + + @Nonnull + static SabrRequestCancellationPolicy decode(@Nonnull final byte[] data) + throws SabrProtocolException { + int field1 = 0; + int field3 = 0; + final List items = new ArrayList<>(); + for (final SabrProto.Field field : SabrProto.readFields(data)) { + if (field.getNumber() == 1 && field.getWireType() == SabrProto.WIRE_VARINT) { + field1 = (int) field.getVarint(); + } else if (field.getNumber() == 2 + && field.getWireType() == SabrProto.WIRE_LENGTH_DELIMITED) { + items.add(Item.decode(field.getBytes())); + } else if (field.getNumber() == 3 + && field.getWireType() == SabrProto.WIRE_VARINT) { + field3 = (int) field.getVarint(); + } + } + return new SabrRequestCancellationPolicy(field1, field3, items); + } + + public int getField1() { + return field1; + } + + public int getField3() { + return field3; + } + + @Nonnull + public List getItems() { + return items; + } + + @Nonnull + public String summarize() { + final StringBuilder builder = new StringBuilder(); + builder.append("field1=").append(field1) + .append(", items=").append(items.size()).append('['); + final int sampleSize = Math.min(4, items.size()); + for (int i = 0; i < sampleSize; i++) { + if (i > 0) { + builder.append(','); + } + builder.append(items.get(i).summarize()); + } + if (items.size() > sampleSize) { + builder.append(",..."); + } + builder.append(']').append(", field3=").append(field3); + return builder.toString(); + } + + public static final class Item { + private final int field1; + private final int field2; + private final int minReadaheadMs; + + private Item(final int field1, + final int field2, + final int minReadaheadMs) { + this.field1 = field1; + this.field2 = field2; + this.minReadaheadMs = minReadaheadMs; + } + + @Nonnull + private static Item decode(@Nonnull final byte[] data) throws SabrProtocolException { + int field1 = 0; + int field2 = 0; + int minReadaheadMs = 0; + for (final SabrProto.Field field : SabrProto.readFields(data)) { + if (field.getWireType() != SabrProto.WIRE_VARINT) { + continue; + } + if (field.getNumber() == 1) { + field1 = (int) field.getVarint(); + } else if (field.getNumber() == 2) { + field2 = (int) field.getVarint(); + } else if (field.getNumber() == 3) { + minReadaheadMs = (int) field.getVarint(); + } + } + return new Item(field1, field2, minReadaheadMs); + } + + public int getField1() { + return field1; + } + + public int getField2() { + return field2; + } + + public int getMinReadaheadMs() { + return minReadaheadMs; + } + + @Nonnull + public String summarize() { + return "field1=" + field1 + "/field2=" + field2 + + "/minReadaheadMs=" + minReadaheadMs; + } + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrRequestIdentifier.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrRequestIdentifier.java new file mode 100644 index 000000000..1188ac563 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrRequestIdentifier.java @@ -0,0 +1,35 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public final class SabrRequestIdentifier { + @Nullable + private final String token; + + private SabrRequestIdentifier(@Nullable final String token) { + this.token = token; + } + + @Nonnull + static SabrRequestIdentifier decode(@Nonnull final byte[] data) throws SabrProtocolException { + String token = null; + for (final SabrProto.Field field : SabrProto.readFields(data)) { + if (field.getNumber() == 1 + && field.getWireType() == SabrProto.WIRE_LENGTH_DELIMITED) { + token = field.getString(); + } + } + return new SabrRequestIdentifier(token); + } + + @Nullable + public String getToken() { + return token; + } + + @Nonnull + public String summarize() { + return "tokenLength=" + (token == null ? 0 : token.length()); + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrSelectableFormats.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrSelectableFormats.java new file mode 100644 index 000000000..3122fc286 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrSelectableFormats.java @@ -0,0 +1,182 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class SabrSelectableFormats { + @Nonnull + private final List videoFormats; + @Nonnull + private final List audioFormats; + @Nonnull + private final List wrappedVideoFormats; + @Nonnull + private final List wrappedAudioFormats; + private final int otherFieldCount; + + private SabrSelectableFormats(@Nonnull final List videoFormats, + @Nonnull final List audioFormats, + @Nonnull final List wrappedVideoFormats, + @Nonnull final List wrappedAudioFormats, + final int otherFieldCount) { + this.videoFormats = Collections.unmodifiableList(new ArrayList<>(videoFormats)); + this.audioFormats = Collections.unmodifiableList(new ArrayList<>(audioFormats)); + this.wrappedVideoFormats = Collections.unmodifiableList(new ArrayList<>(wrappedVideoFormats)); + this.wrappedAudioFormats = Collections.unmodifiableList(new ArrayList<>(wrappedAudioFormats)); + this.otherFieldCount = otherFieldCount; + } + + @Nonnull + static SabrSelectableFormats decode(@Nonnull final byte[] data) throws SabrProtocolException { + final List videoFormats = new ArrayList<>(); + final List audioFormats = new ArrayList<>(); + final List wrappedVideoFormats = new ArrayList<>(); + final List wrappedAudioFormats = new ArrayList<>(); + int otherFieldCount = 0; + + for (final SabrProto.Field field : SabrProto.readFields(data)) { + if (field.getWireType() != SabrProto.WIRE_LENGTH_DELIMITED) { + otherFieldCount++; + continue; + } + if (field.getNumber() == 1) { + videoFormats.add(FormatId.decode(field.getBytes())); + } else if (field.getNumber() == 2) { + audioFormats.add(FormatId.decode(field.getBytes())); + } else if (field.getNumber() == 4) { + wrappedVideoFormats.add(decodeWrappedFormatId(field.getBytes())); + } else if (field.getNumber() == 5) { + wrappedAudioFormats.add(decodeWrappedFormatId(field.getBytes())); + } else { + otherFieldCount++; + } + } + + return new SabrSelectableFormats(videoFormats, audioFormats, wrappedVideoFormats, + wrappedAudioFormats, otherFieldCount); + } + + @Nonnull + private static FormatId decodeWrappedFormatId(@Nonnull final byte[] data) + throws SabrProtocolException { + for (final SabrProto.Field field : SabrProto.readFields(data)) { + if (field.getNumber() == 1 + && field.getWireType() == SabrProto.WIRE_LENGTH_DELIMITED) { + return FormatId.decode(field.getBytes()); + } + } + return FormatId.empty(); + } + + @Nonnull + public List getVideoFormats() { + return videoFormats; + } + + @Nonnull + public List getAudioFormats() { + return audioFormats; + } + + @Nonnull + public List getWrappedVideoFormats() { + return wrappedVideoFormats; + } + + @Nonnull + public List getWrappedAudioFormats() { + return wrappedAudioFormats; + } + + public int getOtherFieldCount() { + return otherFieldCount; + } + + @Nonnull + public String summarize() { + return "video=" + summarizeFormats(videoFormats) + + ", audio=" + summarizeFormats(audioFormats) + + ", wrappedVideo=" + summarizeFormats(wrappedVideoFormats) + + ", wrappedAudio=" + summarizeFormats(wrappedAudioFormats) + + ", otherFields=" + otherFieldCount; + } + + @Nonnull + private static String summarizeFormats(@Nonnull final List formats) { + final StringBuilder builder = new StringBuilder(); + builder.append(formats.size()).append('['); + final int sampleSize = Math.min(6, formats.size()); + for (int i = 0; i < sampleSize; i++) { + if (i > 0) { + builder.append(','); + } + builder.append(formats.get(i).summarize()); + } + if (formats.size() > sampleSize) { + builder.append(",..."); + } + return builder.append(']').toString(); + } + + public static final class FormatId { + private final int itag; + private final long lastModified; + @Nullable + private final String xtags; + + private FormatId(final int itag, + final long lastModified, + @Nullable final String xtags) { + this.itag = itag; + this.lastModified = lastModified; + this.xtags = xtags; + } + + @Nonnull + private static FormatId decode(@Nonnull final byte[] data) throws SabrProtocolException { + int itag = -1; + long lastModified = -1; + String xtags = null; + 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) { + xtags = field.getString(); + } + } + return new FormatId(itag, lastModified, xtags); + } + + @Nonnull + private static FormatId empty() { + return new FormatId(-1, -1, null); + } + + public int getItag() { + return itag; + } + + public long getLastModified() { + return lastModified; + } + + @Nullable + public String getXtags() { + return xtags; + } + + @Nonnull + private String summarize() { + return "itag:" + itag + + (lastModified >= 0 ? "+lm" : "") + + (xtags != null ? "+xtags" : ""); + } + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrStreamProtectionStatus.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrStreamProtectionStatus.java new file mode 100644 index 000000000..5e98454aa --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrStreamProtectionStatus.java @@ -0,0 +1,53 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import javax.annotation.Nonnull; + +public final class SabrStreamProtectionStatus { + private final int status; + private final int maxRetries; + @Nonnull + private final String unknownFields; + + private SabrStreamProtectionStatus(final int status, + final int maxRetries, + @Nonnull final String unknownFields) { + this.status = status; + this.maxRetries = maxRetries; + this.unknownFields = unknownFields; + } + + @Nonnull + static SabrStreamProtectionStatus decode(@Nonnull final byte[] data) + throws SabrProtocolException { + int status = -1; + int maxRetries = -1; + final String unknownFields = SabrProto.summarizeUnknownFields(data, 1, 2); + for (final SabrProto.Field field : SabrProto.readFields(data)) { + if (field.getNumber() == 1 && field.getWireType() == SabrProto.WIRE_VARINT) { + status = (int) field.getVarint(); + } else if (field.getNumber() == 2 && field.getWireType() == SabrProto.WIRE_VARINT) { + maxRetries = (int) field.getVarint(); + } + } + return new SabrStreamProtectionStatus(status, maxRetries, unknownFields); + } + + public int getStatus() { + return status; + } + + public int getMaxRetries() { + return maxRetries; + } + + @Nonnull + public String getUnknownFields() { + return unknownFields; + } + + @Nonnull + public String summarize() { + return "status=" + status + ", maxRetries=" + maxRetries + + ("none".equals(unknownFields) ? "" : ", unknown=" + unknownFields); + } +} From 171e8c84581c4f98be6cc306063a0cbbb66c9450 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 3 Jun 2026 00:02:28 +0200 Subject: [PATCH 04/13] feat(sabr): UMP response parts - context, onesie and misc --- .../sabr/SabrContextSendingPolicy.java | 69 +++++ .../youtube/sabr/SabrContextUpdate.java | 120 ++++++++ .../youtube/sabr/SabrContextValue.java | 156 +++++++++++ .../services/youtube/sabr/SabrError.java | 46 ++++ .../youtube/sabr/SabrLiveMetadata.java | 114 ++++++++ .../services/youtube/sabr/SabrOnesieData.java | 114 ++++++++ .../youtube/sabr/SabrOnesieHeader.java | 258 ++++++++++++++++++ .../sabr/SabrOnesieInnertubeResponse.java | 51 ++++ .../services/youtube/sabr/SabrRedirect.java | 35 +++ .../sabr/SabrReloadPlayerResponse.java | 61 +++++ .../services/youtube/sabr/SabrSeek.java | 52 ++++ .../youtube/sabr/SabrSnackbarMessage.java | 31 +++ 12 files changed, 1107 insertions(+) create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrContextSendingPolicy.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrContextUpdate.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrContextValue.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrError.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrLiveMetadata.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrOnesieData.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrOnesieHeader.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrOnesieInnertubeResponse.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrRedirect.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrReloadPlayerResponse.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrSeek.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrSnackbarMessage.java diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrContextSendingPolicy.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrContextSendingPolicy.java new file mode 100644 index 000000000..480c16ac1 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrContextSendingPolicy.java @@ -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 startPolicy = new ArrayList<>(); + private final List stopPolicy = new ArrayList<>(); + private final List 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 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 getStartPolicy() { + return Collections.unmodifiableList(startPolicy); + } + + @Nonnull + public List getStopPolicy() { + return Collections.unmodifiableList(stopPolicy); + } + + @Nonnull + public List getDiscardPolicy() { + return Collections.unmodifiableList(discardPolicy); + } + + @Nonnull + public String summarize() { + return "start=" + startPolicy + ", stop=" + stopPolicy + ", discard=" + discardPolicy; + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrContextUpdate.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrContextUpdate.java new file mode 100644 index 000000000..40e36af70 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrContextUpdate.java @@ -0,0 +1,120 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public final class SabrContextUpdate { + static final int WRITE_POLICY_OVERWRITE = 1; + static final int WRITE_POLICY_KEEP_EXISTING = 2; + + private final int type; + private final int scope; + @Nonnull + private final byte[] value; + private final boolean sendByDefault; + private final int writePolicy; + @Nullable + private final SabrContextValue decodedValue; + + private SabrContextUpdate(final int type, + final int scope, + @Nonnull final byte[] value, + final boolean sendByDefault, + final int writePolicy, + @Nullable final SabrContextValue decodedValue) { + this.type = type; + this.scope = scope; + this.value = value.clone(); + this.sendByDefault = sendByDefault; + this.writePolicy = writePolicy; + this.decodedValue = decodedValue; + } + + @Nonnull + static SabrContextUpdate decode(@Nonnull final byte[] data) throws SabrProtocolException { + int type = -1; + int scope = -1; + byte[] value = new byte[0]; + boolean sendByDefault = false; + int writePolicy = -1; + SabrContextValue decodedValue = null; + + for (final SabrProto.Field field : SabrProto.readFields(data)) { + switch (field.getNumber()) { + case 1: + type = (int) field.getVarint(); + break; + case 2: + scope = (int) field.getVarint(); + break; + case 3: + value = field.getBytes(); + try { + decodedValue = SabrContextValue.decode(value); + } catch (final SabrProtocolException ignored) { + decodedValue = null; + } + break; + case 4: + sendByDefault = field.getVarint() != 0; + break; + case 5: + writePolicy = (int) field.getVarint(); + break; + default: + break; + } + } + + return new SabrContextUpdate(type, scope, value, sendByDefault, writePolicy, + decodedValue); + } + + @Nonnull + byte[] toStreamerContextProto() { + final SabrProto.Writer context = new SabrProto.Writer(); + context.writeInt32(1, type); + context.writeBytes(2, value); + return context.toByteArray(); + } + + public int getType() { + return type; + } + + public int getScope() { + return scope; + } + + @Nonnull + public byte[] getValue() { + return value.clone(); + } + + int getValueLength() { + return value.length; + } + + public boolean isSendByDefault() { + return sendByDefault; + } + + public int getWritePolicy() { + return writePolicy; + } + + @Nullable + public SabrContextValue getDecodedValue() { + return decodedValue; + } + + @Nonnull + public String summarize() { + return "type=" + type + + ", scope=" + scope + + ", valueBytes=" + value.length + + ", sendByDefault=" + sendByDefault + + ", writePolicy=" + writePolicy + + ", value=" + (decodedValue == null ? "undecoded" : decodedValue.summarize()); + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrContextValue.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrContextValue.java new file mode 100644 index 000000000..bb7486048 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrContextValue.java @@ -0,0 +1,156 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public final class SabrContextValue { + @Nullable + private final TimingInfo timingInfo; + private final int signatureLength; + private final int field5; + + private SabrContextValue(@Nullable final TimingInfo timingInfo, + final int signatureLength, + final int field5) { + this.timingInfo = timingInfo; + this.signatureLength = signatureLength; + this.field5 = field5; + } + + @Nonnull + static SabrContextValue decode(@Nonnull final byte[] data) throws SabrProtocolException { + TimingInfo timingInfo = null; + int signatureLength = 0; + int field5 = -1; + for (final SabrProto.Field field : SabrProto.readFields(data)) { + if (field.getNumber() == 1 + && field.getWireType() == SabrProto.WIRE_LENGTH_DELIMITED) { + timingInfo = TimingInfo.decode(field.getBytes()); + } else if (field.getNumber() == 2 + && field.getWireType() == SabrProto.WIRE_LENGTH_DELIMITED) { + signatureLength = field.getBytes().length; + } else if (field.getNumber() == 5 + && field.getWireType() == SabrProto.WIRE_VARINT) { + field5 = (int) field.getVarint(); + } + } + return new SabrContextValue(timingInfo, signatureLength, field5); + } + + @Nullable + public TimingInfo getTimingInfo() { + return timingInfo; + } + + public int getSignatureLength() { + return signatureLength; + } + + public int getField5() { + return field5; + } + + @Nonnull + public String summarize() { + return "timing=" + (timingInfo == null ? "null" : timingInfo.summarize()) + + ", signatureBytes=" + signatureLength + + ", field5=" + field5; + } + + public static final class TimingInfo { + private final long timestampMs; + private final int durationMs; + @Nullable + private final ContentInfo contentInfo; + + private TimingInfo(final long timestampMs, + final int durationMs, + @Nullable final ContentInfo contentInfo) { + this.timestampMs = timestampMs; + this.durationMs = durationMs; + this.contentInfo = contentInfo; + } + + @Nonnull + private static TimingInfo decode(@Nonnull final byte[] data) throws SabrProtocolException { + long timestampMs = -1; + int durationMs = -1; + ContentInfo contentInfo = null; + for (final SabrProto.Field field : SabrProto.readFields(data)) { + if (field.getNumber() == 1 && field.getWireType() == SabrProto.WIRE_VARINT) { + timestampMs = field.getVarint(); + } else if (field.getNumber() == 2 + && field.getWireType() == SabrProto.WIRE_VARINT) { + durationMs = (int) field.getVarint(); + } else if (field.getNumber() == 3 + && field.getWireType() == SabrProto.WIRE_LENGTH_DELIMITED) { + contentInfo = ContentInfo.decode(field.getBytes()); + } + } + return new TimingInfo(timestampMs, durationMs, contentInfo); + } + + public long getTimestampMs() { + return timestampMs; + } + + public int getDurationMs() { + return durationMs; + } + + @Nullable + public ContentInfo getContentInfo() { + return contentInfo; + } + + @Nonnull + private String summarize() { + return "timestampMs=" + timestampMs + + "/durationMs=" + durationMs + + "/content=" + (contentInfo == null ? "null" : contentInfo.summarize()); + } + } + + public static final class ContentInfo { + @Nullable + private final String contentId; + private final int contentType; + + private ContentInfo(@Nullable final String contentId, + final int contentType) { + this.contentId = contentId; + this.contentType = contentType; + } + + @Nonnull + private static ContentInfo decode(@Nonnull final byte[] data) throws SabrProtocolException { + String contentId = null; + int contentType = -1; + for (final SabrProto.Field field : SabrProto.readFields(data)) { + if (field.getNumber() == 1 + && field.getWireType() == SabrProto.WIRE_LENGTH_DELIMITED) { + contentId = field.getString(); + } else if (field.getNumber() == 2 + && field.getWireType() == SabrProto.WIRE_VARINT) { + contentType = (int) field.getVarint(); + } + } + return new ContentInfo(contentId, contentType); + } + + @Nullable + public String getContentId() { + return contentId; + } + + public int getContentType() { + return contentType; + } + + @Nonnull + private String summarize() { + return "contentIdLength=" + (contentId == null ? 0 : contentId.length()) + + "/contentType=" + contentType; + } + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrError.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrError.java new file mode 100644 index 000000000..3cd4c6d6b --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrError.java @@ -0,0 +1,46 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public final class SabrError { + @Nullable + private final String type; + private final int code; + + private SabrError(@Nullable final String type, + final int code) { + this.type = type; + this.code = code; + } + + @Nonnull + static SabrError decode(@Nonnull final byte[] data) throws SabrProtocolException { + String type = null; + int code = 0; + for (final SabrProto.Field field : SabrProto.readFields(data)) { + if (field.getNumber() == 1 + && field.getWireType() == SabrProto.WIRE_LENGTH_DELIMITED) { + type = field.getString(); + } else if (field.getNumber() == 2 + && field.getWireType() == SabrProto.WIRE_VARINT) { + code = (int) field.getVarint(); + } + } + return new SabrError(type, code); + } + + @Nullable + public String getType() { + return type; + } + + public int getCode() { + return code; + } + + @Nonnull + public String summarize() { + return "type=" + (type == null ? "null" : type) + ", code=" + code; + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrLiveMetadata.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrLiveMetadata.java new file mode 100644 index 000000000..76d9b5179 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrLiveMetadata.java @@ -0,0 +1,114 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public final class SabrLiveMetadata { + @Nullable + private final String broadcastId; + private final long headSequenceNumber; + private final long headTimeMs; + private final long wallTimeMs; + @Nullable + private final String videoId; + private final boolean postLiveDvr; + private final long headm; + private final long minSeekableTimeTicks; + private final int minSeekableTimescale; + private final long maxSeekableTimeTicks; + private final int maxSeekableTimescale; + + private SabrLiveMetadata(@Nullable final String broadcastId, + final long headSequenceNumber, + final long headTimeMs, + final long wallTimeMs, + @Nullable final String videoId, + final boolean postLiveDvr, + final long headm, + final long minSeekableTimeTicks, + final int minSeekableTimescale, + final long maxSeekableTimeTicks, + final int maxSeekableTimescale) { + this.broadcastId = broadcastId; + this.headSequenceNumber = headSequenceNumber; + this.headTimeMs = headTimeMs; + this.wallTimeMs = wallTimeMs; + this.videoId = videoId; + this.postLiveDvr = postLiveDvr; + this.headm = headm; + this.minSeekableTimeTicks = minSeekableTimeTicks; + this.minSeekableTimescale = minSeekableTimescale; + this.maxSeekableTimeTicks = maxSeekableTimeTicks; + this.maxSeekableTimescale = maxSeekableTimescale; + } + + @Nonnull + static SabrLiveMetadata decode(@Nonnull final byte[] data) throws SabrProtocolException { + String broadcastId = null; + long headSequenceNumber = -1; + long headTimeMs = -1; + long wallTimeMs = -1; + String videoId = null; + boolean postLiveDvr = false; + long headm = -1; + long minSeekableTimeTicks = -1; + int minSeekableTimescale = -1; + long maxSeekableTimeTicks = -1; + int maxSeekableTimescale = -1; + for (final SabrProto.Field field : SabrProto.readFields(data)) { + switch (field.getNumber()) { + case 1: + broadcastId = field.getString(); + break; + case 3: + headSequenceNumber = field.getVarint(); + break; + case 4: + headTimeMs = field.getVarint(); + break; + case 5: + wallTimeMs = field.getVarint(); + break; + case 6: + videoId = field.getString(); + break; + case 8: + postLiveDvr = field.getVarint() != 0; + break; + case 10: + headm = field.getVarint(); + break; + case 12: + minSeekableTimeTicks = field.getVarint(); + break; + case 13: + minSeekableTimescale = (int) field.getVarint(); + break; + case 14: + maxSeekableTimeTicks = field.getVarint(); + break; + case 15: + maxSeekableTimescale = (int) field.getVarint(); + break; + default: + break; + } + } + return new SabrLiveMetadata(broadcastId, headSequenceNumber, headTimeMs, wallTimeMs, + videoId, postLiveDvr, headm, minSeekableTimeTicks, minSeekableTimescale, + maxSeekableTimeTicks, maxSeekableTimescale); + } + + @Nonnull + public String summarize() { + return "broadcastIdLength=" + (broadcastId == null ? 0 : broadcastId.length()) + + ", headSeq=" + headSequenceNumber + + ", headTimeMs=" + headTimeMs + + ", wallTimeMs=" + wallTimeMs + + ", videoIdLength=" + (videoId == null ? 0 : videoId.length()) + + ", postLiveDvr=" + postLiveDvr + + ", headm=" + headm + + ", minSeekable=" + minSeekableTimeTicks + '/' + minSeekableTimescale + + ", maxSeekable=" + maxSeekableTimeTicks + '/' + maxSeekableTimescale; + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrOnesieData.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrOnesieData.java new file mode 100644 index 000000000..b99a6fe90 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrOnesieData.java @@ -0,0 +1,114 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.zip.GZIPInputStream; + +public final class SabrOnesieData { + private final boolean encrypted; + private final int payloadBytes; + @Nullable + private final SabrOnesieHeader header; + @Nullable + private final SabrOnesieInnertubeResponse innertubeResponse; + + private SabrOnesieData(final boolean encrypted, + final int payloadBytes, + @Nullable final SabrOnesieHeader header, + @Nullable final SabrOnesieInnertubeResponse innertubeResponse) { + this.encrypted = encrypted; + this.payloadBytes = payloadBytes; + this.header = header; + this.innertubeResponse = innertubeResponse; + } + + @Nonnull + static SabrOnesieData fromPart(@Nonnull final byte[] data, + final boolean encrypted, + @Nullable final SabrOnesieHeader header) { + return new SabrOnesieData(encrypted, data.length, header, + tryDecodeInnertubeResponse(data, encrypted, header)); + } + + @Nullable + private static SabrOnesieInnertubeResponse tryDecodeInnertubeResponse( + @Nonnull final byte[] data, + final boolean encrypted, + @Nullable final SabrOnesieHeader header) { + if (encrypted || header == null || header.getType() != 0 + || header.hasEncryptionMaterial()) { + return null; + } + final byte[] decodedData = maybeDecompress(data, header); + if (decodedData == null) { + return null; + } + try { + return SabrOnesieInnertubeResponse.decode(decodedData); + } catch (final SabrProtocolException ignored) { + return null; + } + } + + @Nullable + private static byte[] maybeDecompress(@Nonnull final byte[] data, + @Nonnull final SabrOnesieHeader header) { + if (header.getCryptoCompressionType() < 0 || header.getCryptoCompressionType() == 0) { + return data; + } + if (header.getCryptoCompressionType() != 1) { + return null; + } + try (GZIPInputStream input = new GZIPInputStream(new ByteArrayInputStream(data)); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + final byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) != -1) { + output.write(buffer, 0, read); + } + return output.toByteArray(); + } catch (final IOException ignored) { + return null; + } + } + + public boolean isEncrypted() { + return encrypted; + } + + public int getPayloadBytes() { + return payloadBytes; + } + + @Nullable + public SabrOnesieHeader getHeader() { + return header; + } + + @Nullable + public SabrOnesieInnertubeResponse getInnertubeResponse() { + return innertubeResponse; + } + + @Nonnull + public String summarize() { + if (header == null) { + return "encrypted=" + encrypted + + ", payloadBytes=" + payloadBytes + + ", header=null" + + ", innertubeResponse=null"; + } + return "encrypted=" + encrypted + + ", payloadBytes=" + payloadBytes + + ", headerType=" + header.getTypeSummary() + + ", headerItag=" + (header.getItag() == null ? "null" : header.getItag()) + + ", headerSeq=" + header.getSequenceNumber() + + ", headerCrypto=" + header.hasCryptoParams() + + ", headerEncrypted=" + header.hasEncryptionMaterial() + + ", innertubeResponse=" + + (innertubeResponse == null ? "null" : '[' + innertubeResponse.summarize() + ']'); + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrOnesieHeader.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrOnesieHeader.java new file mode 100644 index 000000000..93aa0317e --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrOnesieHeader.java @@ -0,0 +1,258 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public final class SabrOnesieHeader { + private final int type; + @Nullable + private final String videoId; + @Nullable + private final String itag; + private final int cryptoParamsBytes; + private final int cryptoHmacBytes; + private final int cryptoIvBytes; + private final int cryptoCompressionType; + private final long lastModified; + private final long expectedMediaSizeBytes; + private final int restrictedFormatCount; + @Nullable + private final String xtags; + private final long sequenceNumber; + private final int field23VideoIdLength; + private final int field34ItagDenylistCount; + + private SabrOnesieHeader(final int type, + @Nullable final String videoId, + @Nullable final String itag, + final int cryptoParamsBytes, + final int cryptoHmacBytes, + final int cryptoIvBytes, + final int cryptoCompressionType, + final long lastModified, + final long expectedMediaSizeBytes, + final int restrictedFormatCount, + @Nullable final String xtags, + final long sequenceNumber, + final int field23VideoIdLength, + final int field34ItagDenylistCount) { + this.type = type; + this.videoId = videoId; + this.itag = itag; + this.cryptoParamsBytes = cryptoParamsBytes; + this.cryptoHmacBytes = cryptoHmacBytes; + this.cryptoIvBytes = cryptoIvBytes; + this.cryptoCompressionType = cryptoCompressionType; + this.lastModified = lastModified; + this.expectedMediaSizeBytes = expectedMediaSizeBytes; + this.restrictedFormatCount = restrictedFormatCount; + this.xtags = xtags; + this.sequenceNumber = sequenceNumber; + this.field23VideoIdLength = field23VideoIdLength; + this.field34ItagDenylistCount = field34ItagDenylistCount; + } + + @Nonnull + static SabrOnesieHeader decode(@Nonnull final byte[] data) throws SabrProtocolException { + int type = -1; + String videoId = null; + String itag = null; + int cryptoParamsBytes = 0; + int cryptoHmacBytes = 0; + int cryptoIvBytes = 0; + int cryptoCompressionType = -1; + long lastModified = -1; + long expectedMediaSizeBytes = -1; + int restrictedFormatCount = 0; + String xtags = null; + long sequenceNumber = -1; + int field23VideoIdLength = 0; + int field34ItagDenylistCount = 0; + for (final SabrProto.Field field : SabrProto.readFields(data)) { + switch (field.getNumber()) { + case 1: + type = (int) field.getVarint(); + break; + case 2: + videoId = field.getString(); + break; + case 3: + itag = field.getString(); + break; + case 4: + final byte[] cryptoParams = field.getBytes(); + cryptoParamsBytes = cryptoParams.length; + final CryptoParamsSummary cryptoParamsSummary = + decodeCryptoParams(cryptoParams); + cryptoHmacBytes = cryptoParamsSummary.hmacBytes; + cryptoIvBytes = cryptoParamsSummary.ivBytes; + cryptoCompressionType = cryptoParamsSummary.compressionType; + break; + case 5: + lastModified = field.getVarint(); + break; + case 7: + expectedMediaSizeBytes = field.getVarint(); + break; + case 11: + restrictedFormatCount++; + break; + case 15: + xtags = field.getString(); + break; + case 18: + sequenceNumber = field.getVarint(); + break; + case 23: + field23VideoIdLength = decodeField23VideoIdLength(field.getBytes()); + break; + case 34: + field34ItagDenylistCount = decodeField34ItagDenylistCount(field.getBytes()); + break; + default: + break; + } + } + return new SabrOnesieHeader(type, videoId, itag, cryptoParamsBytes, + cryptoHmacBytes, cryptoIvBytes, cryptoCompressionType, lastModified, + expectedMediaSizeBytes, restrictedFormatCount, xtags, sequenceNumber, + field23VideoIdLength, field34ItagDenylistCount); + } + + @Nonnull + private static CryptoParamsSummary decodeCryptoParams(@Nonnull final byte[] data) + throws SabrProtocolException { + int hmacBytes = 0; + int ivBytes = 0; + int compressionType = -1; + for (final SabrProto.Field field : SabrProto.readFields(data)) { + if (field.getNumber() == 4 + && field.getWireType() == SabrProto.WIRE_LENGTH_DELIMITED) { + hmacBytes = field.getBytes().length; + } else if (field.getNumber() == 5 + && field.getWireType() == SabrProto.WIRE_LENGTH_DELIMITED) { + ivBytes = field.getBytes().length; + } else if (field.getNumber() == 6 + && field.getWireType() == SabrProto.WIRE_VARINT) { + compressionType = (int) field.getVarint(); + } + } + return new CryptoParamsSummary(hmacBytes, ivBytes, compressionType); + } + + private static int decodeField23VideoIdLength(@Nonnull final byte[] data) + throws SabrProtocolException { + for (final SabrProto.Field field : SabrProto.readFields(data)) { + if (field.getNumber() == 2 + && field.getWireType() == SabrProto.WIRE_LENGTH_DELIMITED) { + return field.getString().length(); + } + } + return 0; + } + + private static int decodeField34ItagDenylistCount(@Nonnull final byte[] data) + throws SabrProtocolException { + int count = 0; + for (final SabrProto.Field field : SabrProto.readFields(data)) { + if (field.getNumber() == 1) { + count++; + } + } + return count; + } + + @Nonnull + public String summarize() { + return "type=" + getTypeSummary() + + ", videoIdLength=" + (videoId == null ? 0 : videoId.length()) + + ", itag=" + (itag == null ? "null" : itag) + + ", cryptoParamsBytes=" + cryptoParamsBytes + + ", cryptoHmacBytes=" + cryptoHmacBytes + + ", cryptoIvBytes=" + cryptoIvBytes + + ", cryptoCompression=" + cryptoCompressionType + + ", lastModified=" + lastModified + + ", expectedMediaSizeBytes=" + expectedMediaSizeBytes + + ", restrictedFormats=" + restrictedFormatCount + + ", xtags=" + (xtags != null) + + ", sequenceNumber=" + sequenceNumber + + ", field23VideoIdLength=" + field23VideoIdLength + + ", field34ItagDenylistCount=" + field34ItagDenylistCount; + } + + int getType() { + return type; + } + + @Nonnull + String getTypeSummary() { + return type + '/' + getTypeName(); + } + + @Nonnull + String getTypeName() { + switch (type) { + case 0: + return "ONESIE_PLAYER_RESPONSE"; + case 1: + return "MEDIA"; + case 2: + return "MEDIA_DECRYPTION_KEY"; + case 3: + return "CLEAR_MEDIA"; + case 4: + return "CLEAR_INIT_SEGMENT"; + case 5: + return "ACK"; + case 6: + return "MEDIA_STREAMER_HOSTNAME"; + case 7: + return "MEDIA_SIZE_HINT"; + case 8: + return "PLAYER_SERVICE_RESPONSE_PUSH_URL"; + case 9: + return "LAST_HIGH_PRIORITY_HINT"; + case 16: + return "STREAM_METADATA"; + case 25: + return "ENCRYPTED_INNERTUBE_RESPONSE_PART"; + default: + return "UNKNOWN"; + } + } + + @Nullable + String getItag() { + return itag; + } + + long getSequenceNumber() { + return sequenceNumber; + } + + boolean hasCryptoParams() { + return cryptoParamsBytes > 0; + } + + boolean hasEncryptionMaterial() { + return cryptoHmacBytes > 0 || cryptoIvBytes > 0; + } + + int getCryptoCompressionType() { + return cryptoCompressionType; + } + + private static final class CryptoParamsSummary { + private final int hmacBytes; + private final int ivBytes; + private final int compressionType; + + private CryptoParamsSummary(final int hmacBytes, + final int ivBytes, + final int compressionType) { + this.hmacBytes = hmacBytes; + this.ivBytes = ivBytes; + this.compressionType = compressionType; + } + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrOnesieInnertubeResponse.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrOnesieInnertubeResponse.java new file mode 100644 index 000000000..f95cf72a7 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrOnesieInnertubeResponse.java @@ -0,0 +1,51 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import javax.annotation.Nonnull; + +public final class SabrOnesieInnertubeResponse { + private final int proxyStatus; + private final int httpStatus; + private final int headerCount; + private final int bodyBytes; + + private SabrOnesieInnertubeResponse(final int proxyStatus, + final int httpStatus, + final int headerCount, + final int bodyBytes) { + this.proxyStatus = proxyStatus; + this.httpStatus = httpStatus; + this.headerCount = headerCount; + this.bodyBytes = bodyBytes; + } + + @Nonnull + static SabrOnesieInnertubeResponse decode(@Nonnull final byte[] data) + throws SabrProtocolException { + int proxyStatus = -1; + int httpStatus = -1; + int headerCount = 0; + int bodyBytes = 0; + for (final SabrProto.Field field : SabrProto.readFields(data)) { + if (field.getNumber() == 1 && field.getWireType() == SabrProto.WIRE_VARINT) { + proxyStatus = (int) field.getVarint(); + } else if (field.getNumber() == 2 && field.getWireType() == SabrProto.WIRE_VARINT) { + httpStatus = (int) field.getVarint(); + } else if (field.getNumber() == 3 + && field.getWireType() == SabrProto.WIRE_LENGTH_DELIMITED) { + headerCount++; + } else if (field.getNumber() == 4 + && field.getWireType() == SabrProto.WIRE_LENGTH_DELIMITED) { + bodyBytes = field.getBytes().length; + } + } + return new SabrOnesieInnertubeResponse(proxyStatus, httpStatus, headerCount, bodyBytes); + } + + @Nonnull + public String summarize() { + return "proxyStatus=" + proxyStatus + + ", httpStatus=" + httpStatus + + ", headers=" + headerCount + + ", bodyBytes=" + bodyBytes; + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrRedirect.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrRedirect.java new file mode 100644 index 000000000..dcad784d3 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrRedirect.java @@ -0,0 +1,35 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public final class SabrRedirect { + @Nullable + private final String url; + + private SabrRedirect(@Nullable final String url) { + this.url = url; + } + + @Nonnull + static SabrRedirect decode(@Nonnull final byte[] data) throws SabrProtocolException { + String url = null; + for (final SabrProto.Field field : SabrProto.readFields(data)) { + if (field.getNumber() == 1 + && field.getWireType() == SabrProto.WIRE_LENGTH_DELIMITED) { + url = field.getString(); + } + } + return new SabrRedirect(url); + } + + @Nullable + public String getUrl() { + return url; + } + + @Nonnull + public String summarize() { + return "urlLength=" + (url == null ? 0 : url.length()); + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrReloadPlayerResponse.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrReloadPlayerResponse.java new file mode 100644 index 000000000..3d9f49369 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrReloadPlayerResponse.java @@ -0,0 +1,61 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public final class SabrReloadPlayerResponse { + @Nullable + private final String reloadPlaybackParamsToken; + + private SabrReloadPlayerResponse(@Nullable final String reloadPlaybackParamsToken) { + this.reloadPlaybackParamsToken = reloadPlaybackParamsToken; + } + + @Nonnull + static SabrReloadPlayerResponse decode(@Nonnull final byte[] data) + throws SabrProtocolException { + String token = null; + for (final SabrProto.Field field : SabrProto.readFields(data)) { + if (field.getNumber() == 1 + && field.getWireType() == SabrProto.WIRE_LENGTH_DELIMITED) { + token = decodeReloadPlaybackContext(field.getBytes()); + } + } + return new SabrReloadPlayerResponse(token); + } + + @Nullable + private static String decodeReloadPlaybackContext(@Nonnull final byte[] data) + throws SabrProtocolException { + for (final SabrProto.Field field : SabrProto.readFields(data)) { + if (field.getNumber() == 1 + && field.getWireType() == SabrProto.WIRE_LENGTH_DELIMITED) { + return decodeReloadPlaybackParams(field.getBytes()); + } + } + return null; + } + + @Nullable + private static String decodeReloadPlaybackParams(@Nonnull final byte[] data) + throws SabrProtocolException { + for (final SabrProto.Field field : SabrProto.readFields(data)) { + if (field.getNumber() == 1 + && field.getWireType() == SabrProto.WIRE_LENGTH_DELIMITED) { + return field.getString(); + } + } + return null; + } + + @Nullable + public String getReloadPlaybackParamsToken() { + return reloadPlaybackParamsToken; + } + + @Nonnull + public String summarize() { + return "reloadPlaybackParamsTokenLength=" + + (reloadPlaybackParamsToken == null ? 0 : reloadPlaybackParamsToken.length()); + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrSeek.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrSeek.java new file mode 100644 index 000000000..69596540a --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrSeek.java @@ -0,0 +1,52 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import javax.annotation.Nonnull; + +public final class SabrSeek { + private final long seekMediaTime; + private final int seekMediaTimescale; + private final int seekSource; + + private SabrSeek(final long seekMediaTime, + final int seekMediaTimescale, + final int seekSource) { + this.seekMediaTime = seekMediaTime; + this.seekMediaTimescale = seekMediaTimescale; + this.seekSource = seekSource; + } + + @Nonnull + static SabrSeek decode(@Nonnull final byte[] data) throws SabrProtocolException { + long seekMediaTime = -1; + int seekMediaTimescale = -1; + int seekSource = -1; + for (final SabrProto.Field field : SabrProto.readFields(data)) { + if (field.getNumber() == 1 && field.getWireType() == SabrProto.WIRE_VARINT) { + seekMediaTime = field.getVarint(); + } else if (field.getNumber() == 2 && field.getWireType() == SabrProto.WIRE_VARINT) { + seekMediaTimescale = (int) field.getVarint(); + } else if (field.getNumber() == 3 && field.getWireType() == SabrProto.WIRE_VARINT) { + seekSource = (int) field.getVarint(); + } + } + return new SabrSeek(seekMediaTime, seekMediaTimescale, seekSource); + } + + public long getSeekMediaTime() { + return seekMediaTime; + } + + public int getSeekMediaTimescale() { + return seekMediaTimescale; + } + + public int getSeekSource() { + return seekSource; + } + + @Nonnull + public String summarize() { + return "seek=" + seekMediaTime + '/' + seekMediaTimescale + + ", source=" + seekSource; + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrSnackbarMessage.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrSnackbarMessage.java new file mode 100644 index 000000000..e902023c2 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrSnackbarMessage.java @@ -0,0 +1,31 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import javax.annotation.Nonnull; + +public final class SabrSnackbarMessage { + private final int id; + + private SabrSnackbarMessage(final int id) { + this.id = id; + } + + @Nonnull + static SabrSnackbarMessage decode(@Nonnull final byte[] data) throws SabrProtocolException { + int id = -1; + for (final SabrProto.Field field : SabrProto.readFields(data)) { + if (field.getNumber() == 1 && field.getWireType() == SabrProto.WIRE_VARINT) { + id = (int) field.getVarint(); + } + } + return new SabrSnackbarMessage(id); + } + + public int getId() { + return id; + } + + @Nonnull + public String summarize() { + return "id=" + id; + } +} From 112e1092d4d6cc797234c220d15b228a0a69fb7f Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 3 Jun 2026 00:02:28 +0200 Subject: [PATCH 05/13] feat(sabr): response decoder --- .../youtube/sabr/SabrDecodedResponse.java | 410 ++++++++++++++++++ .../youtube/sabr/SabrResponseDecoder.java | 256 +++++++++++ 2 files changed, 666 insertions(+) create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrDecodedResponse.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrResponseDecoder.java diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrDecodedResponse.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrDecodedResponse.java new file mode 100644 index 000000000..322fbd6ae --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrDecodedResponse.java @@ -0,0 +1,410 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class SabrDecodedResponse { + private final List parts = new ArrayList<>(); + private final List formatInitializationMetadata = + new ArrayList<>(); + private final List mediaHeaders = new ArrayList<>(); + private final List sabrContextUpdates = new ArrayList<>(); + private final List liveMetadata = new ArrayList<>(); + private final List onesieHeaders = new ArrayList<>(); + private final List onesieData = new ArrayList<>(); + private final Map mediaBytesByHeaderId = new LinkedHashMap<>(); + private final List mediaEndHeaderIds = new ArrayList<>(); + private final List unknownPartTypes = new ArrayList<>(); + private final Map> genericPartDescriptions = new LinkedHashMap<>(); + @Nullable + private String redirectUrl; + @Nullable + private SabrRedirect redirect; + @Nullable + private SabrSeek sabrSeek; + @Nullable + private String sabrError; + @Nullable + private SabrError sabrErrorDetails; + @Nullable + private SabrReloadPlayerResponse reloadPlayerResponse; + @Nullable + private SabrFormatSelectionConfig formatSelectionConfig; + @Nullable + private SabrSelectableFormats selectableFormats; + @Nullable + private SabrNextRequestPolicy nextRequestPolicy; + @Nullable + private SabrRequestIdentifier requestIdentifier; + @Nullable + private SabrPlaybackStartPolicy playbackStartPolicy; + @Nullable + private SabrContextSendingPolicy sabrContextSendingPolicy; + @Nullable + private SabrRequestCancellationPolicy requestCancellationPolicy; + @Nullable + private SabrStreamProtectionStatus streamProtection; + @Nullable + private SabrPrewarmConnection prewarmConnection; + @Nullable + private SabrSnackbarMessage snackbarMessage; + private int streamProtectionStatus = -1; + private int streamProtectionMaxRetries = -1; + private int backoffTimeMs = -1; + private boolean reloadRequested; + + void addPart(@Nonnull final UmpPart part) { + parts.add(part); + } + + void addUnknownPartType(final int type) { + unknownPartTypes.add(type); + } + + void addGenericPartDescription(final int type, @Nonnull final String description) { + List descriptions = genericPartDescriptions.get(type); + if (descriptions == null) { + descriptions = new ArrayList<>(); + genericPartDescriptions.put(type, descriptions); + } + descriptions.add(description); + } + + void addFormatInitializationMetadata( + @Nonnull final SabrFormatInitializationMetadata metadata) { + formatInitializationMetadata.add(metadata); + } + + void addMediaHeader(@Nonnull final SabrMediaHeader header) { + mediaHeaders.add(header); + } + + void addSabrContextUpdate(@Nonnull final SabrContextUpdate sabrContextUpdate) { + sabrContextUpdates.add(sabrContextUpdate); + } + + void addLiveMetadata(@Nonnull final SabrLiveMetadata metadata) { + liveMetadata.add(metadata); + } + + void addOnesieHeader(@Nonnull final SabrOnesieHeader onesieHeader) { + onesieHeaders.add(onesieHeader); + } + + void addOnesieData(@Nonnull final SabrOnesieData data) { + onesieData.add(data); + } + + void addMediaBytes(final int headerId, final long bytes) { + final Long current = mediaBytesByHeaderId.get(headerId); + mediaBytesByHeaderId.put(headerId, current == null ? bytes : current + bytes); + } + + void addMediaEndHeaderId(final int headerId) { + mediaEndHeaderIds.add(headerId); + } + + void setRedirectUrl(@Nullable final String redirectUrl) { + this.redirectUrl = redirectUrl; + } + + void setRedirect(@Nullable final SabrRedirect redirect) { + this.redirect = redirect; + } + + void setSabrSeek(@Nullable final SabrSeek sabrSeek) { + this.sabrSeek = sabrSeek; + } + + void setSabrError(@Nullable final String sabrError) { + this.sabrError = sabrError; + } + + void setSabrErrorDetails(@Nullable final SabrError sabrErrorDetails) { + this.sabrErrorDetails = sabrErrorDetails; + } + + void setReloadPlayerResponse(@Nullable final SabrReloadPlayerResponse reloadPlayerResponse) { + this.reloadPlayerResponse = reloadPlayerResponse; + } + + void setFormatSelectionConfig(@Nullable final SabrFormatSelectionConfig formatSelectionConfig) { + this.formatSelectionConfig = formatSelectionConfig; + } + + void setSelectableFormats(@Nullable final SabrSelectableFormats selectableFormats) { + this.selectableFormats = selectableFormats; + } + + void setNextRequestPolicy(@Nullable final SabrNextRequestPolicy nextRequestPolicy) { + this.nextRequestPolicy = nextRequestPolicy; + } + + void setRequestIdentifier(@Nullable final SabrRequestIdentifier requestIdentifier) { + this.requestIdentifier = requestIdentifier; + } + + void setPlaybackStartPolicy(@Nullable final SabrPlaybackStartPolicy playbackStartPolicy) { + this.playbackStartPolicy = playbackStartPolicy; + } + + void setSabrContextSendingPolicy( + @Nullable final SabrContextSendingPolicy sabrContextSendingPolicy) { + this.sabrContextSendingPolicy = sabrContextSendingPolicy; + } + + void setRequestCancellationPolicy( + @Nullable final SabrRequestCancellationPolicy requestCancellationPolicy) { + this.requestCancellationPolicy = requestCancellationPolicy; + } + + void setStreamProtection(@Nullable final SabrStreamProtectionStatus streamProtection) { + this.streamProtection = streamProtection; + } + + void setPrewarmConnection(@Nullable final SabrPrewarmConnection prewarmConnection) { + this.prewarmConnection = prewarmConnection; + } + + void setSnackbarMessage(@Nullable final SabrSnackbarMessage snackbarMessage) { + this.snackbarMessage = snackbarMessage; + } + + void setStreamProtectionStatus(final int streamProtectionStatus) { + this.streamProtectionStatus = streamProtectionStatus; + } + + void setStreamProtectionMaxRetries(final int streamProtectionMaxRetries) { + this.streamProtectionMaxRetries = streamProtectionMaxRetries; + } + + void setBackoffTimeMs(final int backoffTimeMs) { + this.backoffTimeMs = backoffTimeMs; + } + + void setReloadRequested(final boolean reloadRequested) { + this.reloadRequested = reloadRequested; + } + + @Nonnull + public List getParts() { + return Collections.unmodifiableList(parts); + } + + @Nonnull + public List getFormatInitializationMetadata() { + return Collections.unmodifiableList(formatInitializationMetadata); + } + + @Nonnull + public List getMediaHeaders() { + return Collections.unmodifiableList(mediaHeaders); + } + + @Nonnull + public List getSabrContextUpdates() { + return Collections.unmodifiableList(sabrContextUpdates); + } + + @Nonnull + public List getLiveMetadata() { + return Collections.unmodifiableList(liveMetadata); + } + + @Nonnull + public List getOnesieHeaders() { + return Collections.unmodifiableList(onesieHeaders); + } + + @Nonnull + public List getOnesieData() { + return Collections.unmodifiableList(onesieData); + } + + @Nonnull + public Map getMediaBytesByHeaderId() { + return Collections.unmodifiableMap(mediaBytesByHeaderId); + } + + @Nonnull + public List getMediaEndHeaderIds() { + return Collections.unmodifiableList(mediaEndHeaderIds); + } + + @Nonnull + public List getIntegrityIssues() { + final List issues = new ArrayList<>(); + final List mediaHeaderIds = new ArrayList<>(); + for (final SabrMediaHeader header : mediaHeaders) { + if (mediaHeaderIds.contains(header.getHeaderId())) { + issues.add("duplicate-media-header:" + header.getHeaderId()); + } + mediaHeaderIds.add(header.getHeaderId()); + final Long mediaBytes = mediaBytesByHeaderId.get(header.getHeaderId()); + if (mediaBytes == null) { + issues.add("missing-media:" + header.getHeaderId()); + } else if (header.getContentLength() >= 0 && mediaBytes != header.getContentLength()) { + issues.add("length-mismatch:" + header.getHeaderId() + + ":expected=" + header.getContentLength() + + ":actual=" + mediaBytes); + } + if (!mediaEndHeaderIds.contains(header.getHeaderId())) { + issues.add("missing-media-end:" + header.getHeaderId()); + } + } + for (final Integer headerId : mediaBytesByHeaderId.keySet()) { + if (!mediaHeaderIds.contains(headerId)) { + issues.add("media-without-header:" + headerId); + } + } + for (final Integer headerId : mediaEndHeaderIds) { + if (!mediaHeaderIds.contains(headerId)) { + issues.add("media-end-without-header:" + headerId); + } + } + return issues; + } + + @Nonnull + public List getUnknownPartTypes() { + return Collections.unmodifiableList(unknownPartTypes); + } + + @Nonnull + public Map> getGenericPartDescriptions() { + final Map> copy = new LinkedHashMap<>(); + for (final Map.Entry> entry : genericPartDescriptions.entrySet()) { + copy.put(entry.getKey(), Collections.unmodifiableList(new ArrayList<>(entry.getValue()))); + } + return Collections.unmodifiableMap(copy); + } + + @Nullable + public String getRedirectUrl() { + return redirectUrl; + } + + @Nullable + public SabrRedirect getRedirect() { + return redirect; + } + + @Nullable + public SabrSeek getSabrSeek() { + return sabrSeek; + } + + @Nullable + public String getSabrError() { + return sabrError; + } + + @Nullable + public SabrError getSabrErrorDetails() { + return sabrErrorDetails; + } + + @Nullable + public SabrReloadPlayerResponse getReloadPlayerResponse() { + return reloadPlayerResponse; + } + + @Nullable + public SabrFormatSelectionConfig getFormatSelectionConfig() { + return formatSelectionConfig; + } + + @Nullable + public SabrSelectableFormats getSelectableFormats() { + return selectableFormats; + } + + @Nullable + public SabrNextRequestPolicy getNextRequestPolicy() { + return nextRequestPolicy; + } + + @Nullable + public SabrRequestIdentifier getRequestIdentifier() { + return requestIdentifier; + } + + @Nullable + public SabrPlaybackStartPolicy getPlaybackStartPolicy() { + return playbackStartPolicy; + } + + @Nullable + public SabrContextSendingPolicy getSabrContextSendingPolicy() { + return sabrContextSendingPolicy; + } + + @Nullable + public SabrRequestCancellationPolicy getRequestCancellationPolicy() { + return requestCancellationPolicy; + } + + @Nullable + public SabrStreamProtectionStatus getStreamProtection() { + return streamProtection; + } + + @Nullable + public SabrPrewarmConnection getPrewarmConnection() { + return prewarmConnection; + } + + @Nullable + public SabrSnackbarMessage getSnackbarMessage() { + return snackbarMessage; + } + + public int getStreamProtectionStatus() { + return streamProtectionStatus; + } + + public int getStreamProtectionMaxRetries() { + return streamProtectionMaxRetries; + } + + public int getBackoffTimeMs() { + return backoffTimeMs; + } + + public boolean isReloadRequested() { + return reloadRequested; + } + + public boolean hasMedia() { + return !mediaHeaders.isEmpty() || !mediaBytesByHeaderId.isEmpty(); + } + + public boolean isNoMediaResponse() { + return !hasMedia(); + } + + public boolean isPolicyOnlyResponse() { + return isNoMediaResponse() && nextRequestPolicy != null; + } + + public boolean isProtectedNoMediaResponse() { + return isNoMediaResponse() && streamProtectionStatus >= 3; + } + + @Nonnull + public String summarizeNoMediaResponse() { + return "parts=" + parts.size() + + ", status=" + streamProtectionStatus + + ", maxRetries=" + streamProtectionMaxRetries + + ", backoffMs=" + backoffTimeMs + + ", policy=" + (nextRequestPolicy != null) + + ", reload=" + reloadRequested + + ", redirect=" + (redirectUrl != null && !redirectUrl.isEmpty()) + + ", error=" + (sabrError == null ? "null" : sabrError); + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrResponseDecoder.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrResponseDecoder.java new file mode 100644 index 000000000..06a320b42 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrResponseDecoder.java @@ -0,0 +1,256 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import javax.annotation.Nonnull; +import java.util.List; + +public final class SabrResponseDecoder { + public static final int ONESIE_HEADER = 10; + public static final int ONESIE_DATA = 11; + public static final int ONESIE_ENCRYPTED_MEDIA = 12; + public static final int MEDIA_HEADER = 20; + public static final int MEDIA = 21; + public static final int MEDIA_END = 22; + public static final int CONFIG = 30; + public static final int LIVE_METADATA = 31; + public static final int HOSTNAME_CHANGE_HINT_DEPRECATED = 32; + public static final int LIVE_METADATA_PROMISE = 33; + public static final int LIVE_METADATA_PROMISE_CANCELLATION = 34; + public static final int NEXT_REQUEST_POLICY = 35; + public static final int USTREAMER_VIDEO_AND_FORMAT_METADATA = 36; + public static final int FORMAT_SELECTION_CONFIG = 37; + public static final int USTREAMER_SELECTED_MEDIA_STREAM = 38; + public static final int FORMAT_INITIALIZATION_METADATA = 42; + public static final int SABR_REDIRECT = 43; + public static final int SABR_ERROR = 44; + public static final int SABR_SEEK = 45; + public static final int RELOAD_PLAYER_RESPONSE = 46; + public static final int PLAYBACK_START_POLICY = 47; + public static final int ALLOWED_CACHED_FORMATS = 48; + public static final int START_BW_SAMPLING_HINT = 49; + public static final int PAUSE_BW_SAMPLING_HINT = 50; + public static final int SELECTABLE_FORMATS = 51; + public static final int REQUEST_IDENTIFIER = 52; + public static final int REQUEST_CANCELLATION_POLICY = 53; + public static final int ONESIE_PREFETCH_REJECTION = 54; + public static final int TIMELINE_CONTEXT = 55; + public static final int REQUEST_PIPELINING = 56; + public static final int SABR_CONTEXT_UPDATE = 57; + public static final int STREAM_PROTECTION_STATUS = 58; + public static final int SABR_CONTEXT_SENDING_POLICY = 59; + public static final int LAWNMOWER_POLICY = 60; + public static final int SABR_ACK = 61; + public static final int END_OF_TRACK = 62; + public static final int CACHE_LOAD_POLICY = 63; + public static final int LAWNMOWER_MESSAGING_POLICY = 64; + public static final int PREWARM_CONNECTION = 65; + public static final int PLAYBACK_DEBUG_INFO = 66; + public static final int SNACKBAR_MESSAGE = 67; + + private SabrResponseDecoder() { + } + + @Nonnull + public static SabrDecodedResponse decode(@Nonnull final byte[] data) + throws SabrProtocolException { + final SabrDecodedResponse decoded = new SabrDecodedResponse(); + final List parts = UmpReader.readAll(data); + SabrOnesieHeader currentOnesieHeader = null; + for (final UmpPart part : parts) { + final byte[] partData = part.getRawData(); + decoded.addPart(part); + switch (part.getType()) { + case ONESIE_HEADER: + final SabrOnesieHeader onesieHeader = + SabrOnesieHeader.decode(partData); + currentOnesieHeader = onesieHeader; + decoded.addOnesieHeader(onesieHeader); + decoded.addGenericPartDescription(part.getType(), onesieHeader.summarize()); + break; + case ONESIE_DATA: + case ONESIE_ENCRYPTED_MEDIA: + final SabrOnesieData onesieData = SabrOnesieData.fromPart(partData, + part.getType() == ONESIE_ENCRYPTED_MEDIA, currentOnesieHeader); + decoded.addOnesieData(onesieData); + decoded.addGenericPartDescription(part.getType(), onesieData.summarize()); + break; + case FORMAT_INITIALIZATION_METADATA: + final SabrFormatInitializationMetadata metadata = + SabrFormatInitializationMetadata.decode(partData); + decoded.addFormatInitializationMetadata(metadata); + decoded.addGenericPartDescription(part.getType(), metadata.summarize()); + break; + case MEDIA_HEADER: + decoded.addMediaHeader(SabrMediaHeader.decode(partData)); + break; + case MEDIA: + if (partData.length > 0) { + decoded.addMediaBytes(partData[0] & 0xff, partData.length - 1L); + } + break; + case MEDIA_END: + if (partData.length > 0) { + decoded.addMediaEndHeaderId(partData[0] & 0xff); + } + break; + case LIVE_METADATA: + final SabrLiveMetadata liveMetadata = SabrLiveMetadata.decode(partData); + decoded.addLiveMetadata(liveMetadata); + decoded.addGenericPartDescription(part.getType(), liveMetadata.summarize()); + break; + case NEXT_REQUEST_POLICY: + final SabrNextRequestPolicy nextRequestPolicy = + decodeNextRequestPolicy(partData, decoded); + decoded.addGenericPartDescription(part.getType(), + nextRequestPolicy.summarize()); + break; + case SABR_REDIRECT: + final SabrRedirect redirect = SabrRedirect.decode(partData); + decoded.setRedirect(redirect); + decoded.setRedirectUrl(redirect.getUrl()); + decoded.addGenericPartDescription(part.getType(), redirect.summarize()); + break; + case SABR_SEEK: + final SabrSeek sabrSeek = SabrSeek.decode(partData); + decoded.setSabrSeek(sabrSeek); + decoded.addGenericPartDescription(part.getType(), sabrSeek.summarize()); + break; + case SABR_ERROR: + final SabrError sabrError = SabrError.decode(partData); + decoded.setSabrErrorDetails(sabrError); + decoded.setSabrError(sabrError.summarize()); + decoded.addGenericPartDescription(part.getType(), sabrError.summarize()); + break; + case RELOAD_PLAYER_RESPONSE: + final SabrReloadPlayerResponse reloadPlayerResponse = + SabrReloadPlayerResponse.decode(partData); + decoded.setReloadRequested(true); + decoded.setReloadPlayerResponse(reloadPlayerResponse); + decoded.addGenericPartDescription(part.getType(), + reloadPlayerResponse.summarize()); + break; + case STREAM_PROTECTION_STATUS: + final SabrStreamProtectionStatus streamProtection = + SabrStreamProtectionStatus.decode(partData); + decoded.setStreamProtection(streamProtection); + decoded.setStreamProtectionStatus(streamProtection.getStatus()); + decoded.setStreamProtectionMaxRetries(streamProtection.getMaxRetries()); + decoded.addGenericPartDescription(part.getType(), + streamProtection.summarize()); + break; + case PLAYBACK_START_POLICY: + final SabrPlaybackStartPolicy playbackStartPolicy = + SabrPlaybackStartPolicy.decode(partData); + decoded.setPlaybackStartPolicy(playbackStartPolicy); + decoded.addGenericPartDescription(part.getType(), + playbackStartPolicy.summarize()); + break; + case SABR_CONTEXT_UPDATE: + final SabrContextUpdate sabrContextUpdate = + SabrContextUpdate.decode(partData); + decoded.addSabrContextUpdate(sabrContextUpdate); + decoded.addGenericPartDescription(part.getType(), + sabrContextUpdate.summarize()); + break; + case SABR_CONTEXT_SENDING_POLICY: + final SabrContextSendingPolicy sabrContextSendingPolicy = + SabrContextSendingPolicy.decode(partData); + decoded.setSabrContextSendingPolicy(sabrContextSendingPolicy); + decoded.addGenericPartDescription(part.getType(), + sabrContextSendingPolicy.summarize()); + break; + case SNACKBAR_MESSAGE: + final SabrSnackbarMessage snackbarMessage = + SabrSnackbarMessage.decode(partData); + decoded.setSnackbarMessage(snackbarMessage); + decoded.addGenericPartDescription(part.getType(), snackbarMessage.summarize()); + break; + case FORMAT_SELECTION_CONFIG: + final SabrFormatSelectionConfig formatSelectionConfig = + SabrFormatSelectionConfig.decode(partData); + decoded.setFormatSelectionConfig(formatSelectionConfig); + decoded.addGenericPartDescription(part.getType(), + formatSelectionConfig.summarize()); + break; + case PREWARM_CONNECTION: + final SabrPrewarmConnection prewarmConnection = + SabrPrewarmConnection.decode(partData); + decoded.setPrewarmConnection(prewarmConnection); + decoded.addGenericPartDescription(part.getType(), + prewarmConnection.summarize()); + break; + case START_BW_SAMPLING_HINT: + case CONFIG: + case HOSTNAME_CHANGE_HINT_DEPRECATED: + case LIVE_METADATA_PROMISE: + case LIVE_METADATA_PROMISE_CANCELLATION: + case USTREAMER_VIDEO_AND_FORMAT_METADATA: + case USTREAMER_SELECTED_MEDIA_STREAM: + case ALLOWED_CACHED_FORMATS: + case PAUSE_BW_SAMPLING_HINT: + case ONESIE_PREFETCH_REJECTION: + case TIMELINE_CONTEXT: + case REQUEST_PIPELINING: + case LAWNMOWER_POLICY: + case SABR_ACK: + case END_OF_TRACK: + case CACHE_LOAD_POLICY: + case LAWNMOWER_MESSAGING_POLICY: + case PLAYBACK_DEBUG_INFO: + decoded.addGenericPartDescription(part.getType(), + describeGenericMessage(partData)); + break; + case REQUEST_IDENTIFIER: + final SabrRequestIdentifier requestIdentifier = + SabrRequestIdentifier.decode(partData); + decoded.setRequestIdentifier(requestIdentifier); + decoded.addGenericPartDescription(part.getType(), + requestIdentifier.summarize()); + break; + case REQUEST_CANCELLATION_POLICY: + final SabrRequestCancellationPolicy requestCancellationPolicy = + SabrRequestCancellationPolicy.decode(partData); + decoded.setRequestCancellationPolicy(requestCancellationPolicy); + decoded.addGenericPartDescription(part.getType(), + requestCancellationPolicy.summarize()); + break; + case SELECTABLE_FORMATS: + final SabrSelectableFormats selectableFormats = + SabrSelectableFormats.decode(partData); + decoded.setSelectableFormats(selectableFormats); + decoded.addGenericPartDescription(part.getType(), + selectableFormats.summarize()); + break; + default: + decoded.addUnknownPartType(part.getType()); + decoded.addGenericPartDescription(part.getType(), + describeGenericMessage(partData)); + break; + } + } + return decoded; + } + + @Nonnull + private static SabrNextRequestPolicy decodeNextRequestPolicy(@Nonnull final byte[] data, + @Nonnull final SabrDecodedResponse decoded) + throws SabrProtocolException { + final SabrNextRequestPolicy policy = SabrNextRequestPolicy.decode(data); + decoded.setNextRequestPolicy(policy); + decoded.setBackoffTimeMs(policy.getBackoffTimeMs()); + for (final SabrProto.Field field : SabrProto.readFields(data)) { + if (field.getNumber() == 4 && field.getWireType() == SabrProto.WIRE_VARINT) { + decoded.setBackoffTimeMs((int) field.getVarint()); + } + } + return policy; + } + + @Nonnull + private static String describeGenericMessage(@Nonnull final byte[] data) { + try { + return SabrProto.summarizeFields(data); + } catch (final Exception e) { + return "undecodable(" + data.length + " bytes)"; + } + } +} From 07fc8ecbfde467ecd7b1d74345771e1c52d65c81 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 3 Jun 2026 00:02:28 +0200 Subject: [PATCH 06/13] feat(sabr): media segments and index parsers --- .../youtube/sabr/SabrBufferedRange.java | 89 +++++++ .../youtube/sabr/SabrMediaSegment.java | 29 +++ .../sabr/SabrMediaSegmentCollector.java | 140 ++++++++++ .../sabr/SabrMp4SegmentIndexParser.java | 135 ++++++++++ .../youtube/sabr/SabrSegmentIndex.java | 58 +++++ .../youtube/sabr/SabrSegmentRequest.java | 55 ++++ .../sabr/SabrWebmSegmentIndexParser.java | 244 ++++++++++++++++++ 7 files changed, 750 insertions(+) create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrBufferedRange.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrMediaSegment.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrMediaSegmentCollector.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrMp4SegmentIndexParser.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrSegmentIndex.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrSegmentRequest.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrWebmSegmentIndexParser.java diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrBufferedRange.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrBufferedRange.java new file mode 100644 index 000000000..8404a73f8 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrBufferedRange.java @@ -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(); + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrMediaSegment.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrMediaSegment.java new file mode 100644 index 000000000..bca65b00f --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrMediaSegment.java @@ -0,0 +1,29 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import javax.annotation.Nonnull; + +public final class SabrMediaSegment { + @Nonnull + private final SabrMediaHeader header; + @Nonnull + private final byte[] data; + + SabrMediaSegment(@Nonnull final SabrMediaHeader header, @Nonnull final byte[] data) { + this.header = header; + this.data = data.clone(); + } + + @Nonnull + public SabrMediaHeader getHeader() { + return header; + } + + @Nonnull + public byte[] getData() { + return data.clone(); + } + + public int getLength() { + return data.length; + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrMediaSegmentCollector.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrMediaSegmentCollector.java new file mode 100644 index 000000000..0b3e4eb04 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrMediaSegmentCollector.java @@ -0,0 +1,140 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import org.brotli.dec.BrotliInputStream; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.GZIPInputStream; + +public final class SabrMediaSegmentCollector { + private SabrMediaSegmentCollector() { + } + + @Nonnull + public static List collect(@Nonnull final SabrDecodedResponse response) + throws SabrProtocolException { + final List segments = new ArrayList<>(); + final Map openSegments = new HashMap<>(); + for (final UmpPart part : response.getParts()) { + final byte[] partData = part.getRawData(); + switch (part.getType()) { + case SabrResponseDecoder.MEDIA_HEADER: + final SabrMediaHeader header = SabrMediaHeader.decode(partData); + openSegments.put(header.getHeaderId(), new OpenSegment(header)); + break; + case SabrResponseDecoder.MEDIA: + if (partData.length > 0) { + final OpenSegment openSegment = openSegments.get(partData[0] & 0xff); + if (openSegment != null) { + openSegment.write(partData, 1, partData.length - 1); + } + } + break; + case SabrResponseDecoder.MEDIA_END: + if (partData.length > 0) { + final OpenSegment openSegment = openSegments.remove(partData[0] & 0xff); + if (openSegment != null) { + segments.add(openSegment.toSegment()); + } + } + break; + default: + break; + } + } + return segments; + } + + @Nullable + public static SabrMediaSegment find(@Nonnull final SabrDecodedResponse response, + @Nonnull final SabrSegmentRequest request) + throws SabrProtocolException { + for (final SabrMediaSegment segment : collect(response)) { + if (request.matches(segment.getHeader())) { + return segment; + } + } + return null; + } + + private static final class OpenSegment { + @Nonnull + private final SabrMediaHeader header; + private final ByteArrayOutputStream data = new ByteArrayOutputStream(); + + private OpenSegment(@Nonnull final SabrMediaHeader header) { + this.header = header; + } + + private void write(@Nonnull final byte[] bytes, final int offset, final int length) { + data.write(bytes, offset, length); + } + + @Nonnull + private SabrMediaSegment toSegment() throws SabrProtocolException { + final byte[] rawBytes = data.toByteArray(); + if (header.getContentLength() >= 0 && rawBytes.length != header.getContentLength()) { + throw new SabrProtocolException("SABR media length mismatch: headerId=" + + header.getHeaderId() + + ", expected=" + header.getContentLength() + + ", actual=" + rawBytes.length); + } + return new SabrMediaSegment(header, maybeDecompress(header, rawBytes)); + } + + @Nonnull + private static byte[] maybeDecompress(@Nonnull final SabrMediaHeader header, + @Nonnull final byte[] bytes) + throws SabrProtocolException { + final int compressionAlgorithm = header.getCompressionAlgorithm(); + if (compressionAlgorithm <= 0) { + return bytes; + } + if (compressionAlgorithm == 1) { + return gunzip(bytes); + } + if (compressionAlgorithm == 2) { + return brotli(bytes); + } + throw new SabrProtocolException("Unsupported SABR media compression: " + + compressionAlgorithm); + } + + @Nonnull + private static byte[] gunzip(@Nonnull final byte[] bytes) throws SabrProtocolException { + try (GZIPInputStream input = new GZIPInputStream(new ByteArrayInputStream(bytes)); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + final byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) != -1) { + output.write(buffer, 0, read); + } + return output.toByteArray(); + } catch (final IOException e) { + throw new SabrProtocolException("Could not decompress gzip SABR media segment", e); + } + } + + @Nonnull + private static byte[] brotli(@Nonnull final byte[] bytes) throws SabrProtocolException { + try (BrotliInputStream input = new BrotliInputStream(new ByteArrayInputStream(bytes)); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + final byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) != -1) { + output.write(buffer, 0, read); + } + return output.toByteArray(); + } catch (final IOException e) { + throw new SabrProtocolException("Could not decompress brotli SABR media segment", e); + } + } + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrMp4SegmentIndexParser.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrMp4SegmentIndexParser.java new file mode 100644 index 000000000..449b090ca --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrMp4SegmentIndexParser.java @@ -0,0 +1,135 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import javax.annotation.Nonnull; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +final class SabrMp4SegmentIndexParser { + private static final String SIDX_BOX = "sidx"; + + private SabrMp4SegmentIndexParser() { + } + + @Nonnull + static SabrSegmentIndex parse(@Nonnull final byte[] initData, + @Nonnull final SabrFormatInitializationMetadata metadata) + throws SabrProtocolException { + final int indexStart = checkedRangeOffset(metadata.getIndexRangeStart(), initData.length); + final int indexEnd = checkedRangeOffset(metadata.getIndexRangeEnd(), initData.length); + if (indexEnd < indexStart) { + throw new SabrProtocolException("Invalid MP4 SIDX range"); + } + + final int sidxOffset = findSidxBox(initData, indexStart, indexEnd + 1); + final long boxSize = readUint32(initData, sidxOffset); + final int boxEnd = checkedBoxEnd(sidxOffset, boxSize, indexEnd + 1); + int cursor = sidxOffset + 8; + final int version = initData[cursor] & 0xff; + cursor += 4; + + cursor += 4; // reference_ID + final long timescale = readUint32(initData, cursor); + cursor += 4; + if (timescale <= 0) { + throw new SabrProtocolException("Invalid MP4 SIDX timescale"); + } + + long earliestPresentationTime; + if (version == 0) { + earliestPresentationTime = readUint32(initData, cursor); + cursor += 8; // earliest_presentation_time + first_offset + } else if (version == 1) { + earliestPresentationTime = readUint64(initData, cursor); + cursor += 16; // earliest_presentation_time + first_offset + } else { + throw new SabrProtocolException("Unsupported MP4 SIDX version: " + version); + } + + cursor += 2; // reserved + final int referenceCount = readUint16(initData, cursor); + cursor += 2; + final List entries = new ArrayList<>(referenceCount); + long unscaledStart = earliestPresentationTime; + for (int i = 0; i < referenceCount; i++) { + if (cursor + 12 > boxEnd) { + throw new SabrProtocolException("Truncated MP4 SIDX references"); + } + final long reference = readUint32(initData, cursor); + cursor += 4; + final boolean nestedSidx = (reference & 0x80000000L) != 0; + if (nestedSidx) { + throw new SabrProtocolException("Nested MP4 SIDX references are unsupported"); + } + final long duration = readUint32(initData, cursor); + cursor += 8; // subsegment_duration + SAP flags + entries.add(new SabrSegmentIndex.Entry(i + 1, + scaleToMs(unscaledStart, timescale), + scaleToMs(duration, timescale))); + unscaledStart += duration; + } + return new SabrSegmentIndex(entries); + } + + private static int findSidxBox(@Nonnull final byte[] data, + final int start, + final int end) throws SabrProtocolException { + for (int offset = start; offset + 8 <= end; offset++) { + if (SIDX_BOX.equals(new String(data, offset + 4, 4, StandardCharsets.US_ASCII))) { + return offset; + } + } + throw new SabrProtocolException("MP4 SIDX box not found"); + } + + private static int checkedRangeOffset(final long offset, + final int length) throws SabrProtocolException { + if (offset < 0 || offset >= length) { + throw new SabrProtocolException("MP4 SIDX range outside init segment"); + } + return (int) offset; + } + + private static int checkedBoxEnd(final int offset, + final long boxSize, + final int rangeEnd) throws SabrProtocolException { + if (boxSize < 8 || boxSize > Integer.MAX_VALUE || offset + boxSize > rangeEnd) { + throw new SabrProtocolException("Invalid MP4 SIDX box size"); + } + return offset + (int) boxSize; + } + + private static int readUint16(@Nonnull final byte[] data, + final int offset) throws SabrProtocolException { + checkAvailable(data, offset, 2); + return ((data[offset] & 0xff) << 8) | (data[offset + 1] & 0xff); + } + + private static long readUint32(@Nonnull final byte[] data, + final int offset) throws SabrProtocolException { + checkAvailable(data, offset, 4); + return ((long) (data[offset] & 0xff) << 24) + | ((long) (data[offset + 1] & 0xff) << 16) + | ((long) (data[offset + 2] & 0xff) << 8) + | (long) (data[offset + 3] & 0xff); + } + + private static long readUint64(@Nonnull final byte[] data, + final int offset) throws SabrProtocolException { + final long high = readUint32(data, offset); + final long low = readUint32(data, offset + 4); + return (high << 32) | low; + } + + private static void checkAvailable(@Nonnull final byte[] data, + final int offset, + final int length) throws SabrProtocolException { + if (offset < 0 || offset + length > data.length) { + throw new SabrProtocolException("Unexpected EOF while reading MP4 SIDX"); + } + } + + private static long scaleToMs(final long value, final long timescale) { + return (value * 1000L + timescale / 2L) / timescale; + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrSegmentIndex.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrSegmentIndex.java new file mode 100644 index 000000000..27cf2da19 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrSegmentIndex.java @@ -0,0 +1,58 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class SabrSegmentIndex { + @Nonnull + private final List entries; + + SabrSegmentIndex(@Nonnull final List entries) { + this.entries = Collections.unmodifiableList(new ArrayList<>(entries)); + } + + @Nullable + public Entry getEntry(final int sequenceNumber) { + if (sequenceNumber <= 0 || sequenceNumber > entries.size()) { + return null; + } + return entries.get(sequenceNumber - 1); + } + + public int size() { + return entries.size(); + } + + public static final class Entry { + private final int sequenceNumber; + private final long startMs; + private final long durationMs; + + Entry(final int sequenceNumber, + final long startMs, + final long durationMs) { + this.sequenceNumber = sequenceNumber; + this.startMs = startMs; + this.durationMs = durationMs; + } + + public int getSequenceNumber() { + return sequenceNumber; + } + + public long getStartMs() { + return startMs; + } + + public long getDurationMs() { + return durationMs; + } + + public long getEndMs() { + return startMs + durationMs; + } + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrSegmentRequest.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrSegmentRequest.java new file mode 100644 index 000000000..c5f2dac4d --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrSegmentRequest.java @@ -0,0 +1,55 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import javax.annotation.Nonnull; + +public final class SabrSegmentRequest { + @Nonnull + private final YoutubeSabrFormat format; + private final boolean initializationSegment; + private final int sequenceNumber; + + private SabrSegmentRequest(@Nonnull final YoutubeSabrFormat format, + final boolean initializationSegment, + final int sequenceNumber) { + this.format = format; + this.initializationSegment = initializationSegment; + this.sequenceNumber = sequenceNumber; + } + + @Nonnull + public static SabrSegmentRequest initialization(@Nonnull final YoutubeSabrFormat format) { + return new SabrSegmentRequest(format, true, -1); + } + + @Nonnull + public static SabrSegmentRequest media(@Nonnull final YoutubeSabrFormat format, + final int sequenceNumber) { + if (sequenceNumber <= 0) { + throw new IllegalArgumentException("SABR media sequence number must be positive"); + } + return new SabrSegmentRequest(format, false, sequenceNumber); + } + + boolean matches(@Nonnull final SabrMediaHeader header) { + if (header.getItag() != format.getItag()) { + return false; + } + if (initializationSegment) { + return header.isInitSegment(); + } + return !header.isInitSegment() && header.getSequenceNumber() == sequenceNumber; + } + + @Nonnull + public YoutubeSabrFormat getFormat() { + return format; + } + + public boolean isInitializationSegment() { + return initializationSegment; + } + + public int getSequenceNumber() { + return sequenceNumber; + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrWebmSegmentIndexParser.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrWebmSegmentIndexParser.java new file mode 100644 index 000000000..08f8a342b --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrWebmSegmentIndexParser.java @@ -0,0 +1,244 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import javax.annotation.Nonnull; +import java.util.ArrayList; +import java.util.List; + +final class SabrWebmSegmentIndexParser { + private static final long SEGMENT_ID = 0x18538067L; + private static final long INFO_ID = 0x1549a966L; + private static final long TIMECODE_SCALE_ID = 0x2ad7b1L; + private static final long CUES_ID = 0x1c53bb6bL; + private static final long CUE_POINT_ID = 0xbbL; + private static final long CUE_TIME_ID = 0xb3L; + private static final long DEFAULT_TIMECODE_SCALE_NANOS = 1000000L; + + private SabrWebmSegmentIndexParser() { + } + + @Nonnull + static SabrSegmentIndex parse(@Nonnull final byte[] initData, + @Nonnull final SabrFormatInitializationMetadata metadata) + throws SabrProtocolException { + final Element segment = findElement(initData, 0, initData.length, SEGMENT_ID); + final long timecodeScaleNanos = readTimecodeScale(initData, segment); + final Element cues = findElement(initData, + checkedRangeOffset(metadata.getIndexRangeStart(), initData.length), + checkedRangeEnd(metadata.getIndexRangeEnd(), initData.length), CUES_ID); + final List cueTimes = readCueTimes(initData, cues, timecodeScaleNanos); + if (cueTimes.isEmpty()) { + throw new SabrProtocolException("WebM cues contain no cue times"); + } + + final long totalDurationMs = metadata.getDurationUnits() > 0 + && metadata.getDurationTimescale() > 0 + ? scaleToMs(metadata.getDurationUnits(), metadata.getDurationTimescale()) + : -1; + final List entries = new ArrayList<>(cueTimes.size()); + for (int i = 0; i < cueTimes.size(); i++) { + final long startMs = cueTimes.get(i); + final long endMs; + if (i + 1 < cueTimes.size()) { + endMs = cueTimes.get(i + 1); + } else if (totalDurationMs > startMs) { + endMs = totalDurationMs; + } else if (i > 0) { + endMs = startMs + Math.max(1, startMs - cueTimes.get(i - 1)); + } else { + endMs = startMs + 1; + } + entries.add(new SabrSegmentIndex.Entry(i + 1, startMs, Math.max(1, endMs - startMs))); + } + return new SabrSegmentIndex(entries); + } + + private static long readTimecodeScale(@Nonnull final byte[] data, + @Nonnull final Element segment) + throws SabrProtocolException { + final Element info = findElement(data, segment.contentStart, segment.contentEnd, INFO_ID); + int offset = info.contentStart; + while (offset < info.contentEnd) { + final Element element = readElement(data, offset, info.contentEnd); + if (element.id == TIMECODE_SCALE_ID) { + return readUnsignedInteger(data, element.contentStart, + element.contentEnd - element.contentStart); + } + offset = element.contentEnd; + } + return DEFAULT_TIMECODE_SCALE_NANOS; + } + + @Nonnull + private static List readCueTimes(@Nonnull final byte[] data, + @Nonnull final Element cues, + final long timecodeScaleNanos) + throws SabrProtocolException { + final List cueTimes = new ArrayList<>(); + int offset = cues.contentStart; + while (offset < cues.contentEnd) { + final Element cuePoint = readElement(data, offset, cues.contentEnd); + if (cuePoint.id == CUE_POINT_ID) { + final long cueTime = readCueTime(data, cuePoint); + if (cueTime >= 0) { + cueTimes.add(scaleWebmTimeToMs(cueTime, timecodeScaleNanos)); + } + } + offset = cuePoint.contentEnd; + } + return cueTimes; + } + + private static long readCueTime(@Nonnull final byte[] data, + @Nonnull final Element cuePoint) + throws SabrProtocolException { + int offset = cuePoint.contentStart; + while (offset < cuePoint.contentEnd) { + final Element element = readElement(data, offset, cuePoint.contentEnd); + if (element.id == CUE_TIME_ID) { + return readUnsignedInteger(data, element.contentStart, + element.contentEnd - element.contentStart); + } + offset = element.contentEnd; + } + return -1; + } + + @Nonnull + private static Element findElement(@Nonnull final byte[] data, + final int start, + final int end, + final long id) throws SabrProtocolException { + int offset = start; + while (offset < end) { + final Element element = readElement(data, offset, end); + if (element.id == id) { + return element; + } + offset = element.contentEnd; + } + throw new SabrProtocolException("WebM element not found: " + Long.toHexString(id)); + } + + @Nonnull + private static Element readElement(@Nonnull final byte[] data, + final int offset, + final int containerEnd) throws SabrProtocolException { + final Varint id = readElementId(data, offset, containerEnd); + final Varint size = readElementSize(data, offset + id.length, containerEnd); + final int contentStart = offset + id.length + size.length; + final int contentEnd; + if (size.unknown) { + contentEnd = containerEnd; + } else if (size.value > Integer.MAX_VALUE || contentStart + size.value > containerEnd) { + throw new SabrProtocolException("Invalid WebM element size"); + } else { + contentEnd = contentStart + (int) size.value; + } + return new Element(id.value, contentStart, contentEnd); + } + + @Nonnull + private static Varint readElementId(@Nonnull final byte[] data, + final int offset, + final int end) throws SabrProtocolException { + final int length = readVintLength(data, offset, end); + long value = 0; + for (int i = 0; i < length; i++) { + value = (value << 8) | (data[offset + i] & 0xffL); + } + return new Varint(value, length, false); + } + + @Nonnull + private static Varint readElementSize(@Nonnull final byte[] data, + final int offset, + final int end) throws SabrProtocolException { + final int length = readVintLength(data, offset, end); + long value = data[offset] & (0xffL >> length); + for (int i = 1; i < length; i++) { + value = (value << 8) | (data[offset + i] & 0xffL); + } + final long unknownValue = (1L << (7 * length)) - 1L; + return new Varint(value, length, value == unknownValue); + } + + private static int readVintLength(@Nonnull final byte[] data, + final int offset, + final int end) throws SabrProtocolException { + if (offset >= end) { + throw new SabrProtocolException("Unexpected EOF while reading WebM vint"); + } + final int first = data[offset] & 0xff; + for (int length = 1; length <= 8; length++) { + if ((first & (0x80 >> (length - 1))) != 0) { + if (offset + length > end) { + throw new SabrProtocolException("Truncated WebM vint"); + } + return length; + } + } + throw new SabrProtocolException("Invalid WebM vint"); + } + + private static int checkedRangeOffset(final long offset, + final int length) throws SabrProtocolException { + if (offset < 0 || offset >= length) { + throw new SabrProtocolException("WebM index range outside init segment"); + } + return (int) offset; + } + + private static int checkedRangeEnd(final long inclusiveEnd, + final int length) throws SabrProtocolException { + if (inclusiveEnd < 0 || inclusiveEnd >= length) { + throw new SabrProtocolException("WebM index range outside init segment"); + } + return (int) inclusiveEnd + 1; + } + + private static long readUnsignedInteger(@Nonnull final byte[] data, + final int offset, + final int length) throws SabrProtocolException { + if (length <= 0 || length > 8 || offset < 0 || offset + length > data.length) { + throw new SabrProtocolException("Invalid WebM unsigned integer length"); + } + long value = 0; + for (int i = 0; i < length; i++) { + value = (value << 8) | (data[offset + i] & 0xffL); + } + return value; + } + + private static long scaleWebmTimeToMs(final long cueTime, + final long timecodeScaleNanos) { + return (cueTime * timecodeScaleNanos + 500000L) / 1000000L; + } + + private static long scaleToMs(final long value, final long timescale) { + return (value * 1000L + timescale / 2L) / timescale; + } + + private static final class Element { + private final long id; + private final int contentStart; + private final int contentEnd; + + private Element(final long id, final int contentStart, final int contentEnd) { + this.id = id; + this.contentStart = contentStart; + this.contentEnd = contentEnd; + } + } + + private static final class Varint { + private final long value; + private final int length; + private final boolean unknown; + + private Varint(final long value, final int length, final boolean unknown) { + this.value = value; + this.length = length; + this.unknown = unknown; + } + } +} From bb8806a700407ed9fe0cbf8e3ed849cc93e89b4d Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 3 Jun 2026 00:02:28 +0200 Subject: [PATCH 07/13] feat(sabr): request builder and client profile --- .../sabr/YoutubeSabrClientProfile.java | 86 ++++ .../sabr/YoutubeSabrRequestBuilder.java | 412 ++++++++++++++++++ 2 files changed, 498 insertions(+) create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrClientProfile.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrRequestBuilder.java diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrClientProfile.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrClientProfile.java new file mode 100644 index 000000000..1f8a03977 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrClientProfile.java @@ -0,0 +1,86 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public enum YoutubeSabrClientProfile { + WEB("WEB", "1", "2.20250122.04.00", null, null, false, null), + WEB_EMBEDDED("WEB_EMBEDDED_PLAYER", "56", "1.20250121.00.00", null, null, true, null), + ANDROID("ANDROID", "3", "21.03.36", "Android", "16", false, + "com.google.android.youtube/21.03.36 (Linux; U; Android 15; US) gzip"), + ANDROID_VR("ANDROID_VR", "28", "1.65.10", "Android", "12L", false, + "com.google.android.apps.youtube.vr.oculus/1.65.10 " + + "(Linux; U; Android 12L; eureka-user Build/SQ3A.220605.009.A1) gzip"), + IOS("IOS", "5", "19.45.4", "iOS", "18.1.0.22B83", false, + "com.google.ios.youtube/19.45.4(iPhone16,2; U; CPU iOS 18_1_0 like Mac OS X; US)"), + TVHTML5("TVHTML5", "7", "7.20250923.13.00", null, null, true, + "Mozilla/5.0 (PlayStation; PlayStation 4/12.00) AppleWebKit/605.1.15 " + + "(KHTML, like Gecko) Version/15.4 Safari/605.1.15"), + SAFARI_WEB("WEB", "1", "2.20260114.08.00", "Macintosh", "10_15_7", false, + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 " + + "(KHTML, like Gecko) Version/15.5 Safari/605.1.15,gzip(gfe)"); + + @Nonnull + private final String clientName; + @Nonnull + private final String clientId; + @Nonnull + private final String clientVersion; + @Nullable + private final String osName; + @Nullable + private final String osVersion; + private final boolean webLike; + @Nullable + private final String userAgent; + + YoutubeSabrClientProfile(@Nonnull final String clientName, + @Nonnull final String clientId, + @Nonnull final String clientVersion, + @Nullable final String osName, + @Nullable final String osVersion, + final boolean webLike, + @Nullable final String userAgent) { + this.clientName = clientName; + this.clientId = clientId; + this.clientVersion = clientVersion; + this.osName = osName; + this.osVersion = osVersion; + this.webLike = webLike; + this.userAgent = userAgent; + } + + @Nonnull + public String getClientName() { + return clientName; + } + + @Nonnull + public String getClientId() { + return clientId; + } + + @Nonnull + public String getClientVersion() { + return clientVersion; + } + + @Nullable + public String getOsName() { + return osName; + } + + @Nullable + public String getOsVersion() { + return osVersion; + } + + public boolean isWebLike() { + return webLike; + } + + @Nullable + public String getUserAgent() { + return userAgent; + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrRequestBuilder.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrRequestBuilder.java new file mode 100644 index 000000000..eb86c7739 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrRequestBuilder.java @@ -0,0 +1,412 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Base64; +import java.util.List; + +final class YoutubeSabrRequestBuilder { + static final int ENABLED_TRACK_TYPES_VIDEO_AND_AUDIO = 0; + static final int ENABLED_TRACK_TYPES_AUDIO_ONLY = 1; + static final int ENABLED_TRACK_TYPES_VIDEO_ONLY = 2; + + private YoutubeSabrRequestBuilder() { + } + + @Nonnull + static byte[] buildFirstMediaRequest(@Nonnull final YoutubeSabrInfo info, + @Nonnull final YoutubeSabrFormat audioFormat, + @Nonnull final YoutubeSabrFormat videoFormat) + throws SabrProtocolException { + return buildFirstMediaRequest(info, audioFormat, videoFormat, null); + } + + @Nonnull + static byte[] buildFirstMediaRequest(@Nonnull final YoutubeSabrInfo info, + @Nonnull final YoutubeSabrFormat audioFormat, + @Nonnull final YoutubeSabrFormat videoFormat, + @Nullable final YoutubeSabrStreamState streamState) + throws SabrProtocolException { + final String ustreamerConfig = info.getVideoPlaybackUstreamerConfig(); + if (ustreamerConfig == null || ustreamerConfig.isEmpty()) { + throw new SabrProtocolException("Missing video playback ustreamer config"); + } + + final SabrProto.Writer request = new SabrProto.Writer(); + request.writeMessage(1, buildClientAbrState(audioFormat, videoFormat, 0, false, + ENABLED_TRACK_TYPES_VIDEO_AND_AUDIO, streamState)); + request.writeBytes(5, decodeBase64(ustreamerConfig)); + writePreferredFormats(request, info, audioFormat, videoFormat, streamState); + request.writeMessage(19, streamState == null + ? buildStreamerContext(info) + : buildStreamerContext(info, streamState)); + return request.toByteArray(); + } + + @Nonnull + static byte[] buildFollowUpMediaRequest(@Nonnull final YoutubeSabrInfo info, + @Nonnull final YoutubeSabrFormat audioFormat, + @Nonnull final YoutubeSabrFormat videoFormat, + @Nonnull final YoutubeSabrStreamState streamState) + throws SabrProtocolException { + final String ustreamerConfig = info.getVideoPlaybackUstreamerConfig(); + if (ustreamerConfig == null || ustreamerConfig.isEmpty()) { + throw new SabrProtocolException("Missing video playback ustreamer config"); + } + + final long playerTimeMs = streamState.getPlayerTimeMs(); + final SabrProto.Writer request = new SabrProto.Writer(); + request.writeMessage(1, buildClientAbrState(audioFormat, videoFormat, playerTimeMs, + true, streamState.getEnabledTrackTypesBitfield(), streamState)); + if (streamState.shouldSelectVideoFormatBeforeAudio() && streamState.shouldSelectVideoFormat()) { + request.writeMessage(2, SabrProto.formatId(videoFormat)); + } + if (streamState.shouldSelectAudioFormat()) { + request.writeMessage(2, SabrProto.formatId(audioFormat)); + } + if (!streamState.shouldSelectVideoFormatBeforeAudio() && streamState.shouldSelectVideoFormat()) { + request.writeMessage(2, SabrProto.formatId(videoFormat)); + } + final List bufferedRanges = streamState.getBufferedRanges(); + for (final SabrBufferedRange range : bufferedRanges) { + request.writeMessage(3, range.toProto(streamState.shouldWriteBufferedRangeTimeRange())); + } + if (streamState.shouldWriteTopLevelPlayerTimeMs()) { + request.writeUInt64(4, playerTimeMs); + } + request.writeBytes(5, decodeBase64(ustreamerConfig)); + writePreferredFormats(request, info, audioFormat, videoFormat, streamState); + request.writeMessage(19, buildStreamerContext(info, streamState)); + return request.toByteArray(); + } + + @Nonnull + private static byte[] buildClientAbrState(@Nonnull final YoutubeSabrFormat audioFormat, + @Nonnull final YoutubeSabrFormat videoFormat) { + return buildClientAbrState(audioFormat, videoFormat, 0, false); + } + + @Nonnull + private static byte[] buildClientAbrState(@Nonnull final YoutubeSabrFormat audioFormat, + @Nonnull final YoutubeSabrFormat videoFormat, + final long playerTimeMs, + final boolean includeFollowUpState) { + return buildClientAbrState(audioFormat, videoFormat, playerTimeMs, includeFollowUpState, + ENABLED_TRACK_TYPES_VIDEO_AND_AUDIO); + } + + @Nonnull + private static byte[] buildClientAbrState(@Nonnull final YoutubeSabrFormat audioFormat, + @Nonnull final YoutubeSabrFormat videoFormat, + final long playerTimeMs, + final boolean includeFollowUpState, + final int enabledTrackTypesBitfield) { + return buildClientAbrState(audioFormat, videoFormat, playerTimeMs, includeFollowUpState, + enabledTrackTypesBitfield, null); + } + + @Nonnull + private static byte[] buildClientAbrState(@Nonnull final YoutubeSabrFormat audioFormat, + @Nonnull final YoutubeSabrFormat videoFormat, + final long playerTimeMs, + final boolean includeFollowUpState, + final int enabledTrackTypesBitfield, + @Nullable final YoutubeSabrStreamState streamState) { + final SabrProto.Writer state = new SabrProto.Writer(); + final boolean officialWebClientAbrFields = streamState != null + && streamState.shouldWriteOfficialWebClientAbrFields(); + if ((includeFollowUpState || officialWebClientAbrFields) && streamState != null + && streamState.shouldWriteLastManualSelectedResolution()) { + state.writeInt32(16, Math.max(videoFormat.getHeight(), 360)); + } + if (includeFollowUpState || officialWebClientAbrFields) { + state.writeInt32(18, streamState != null && streamState.getClientViewportWidth() > 0 + ? streamState.getClientViewportWidth() + : Math.max(videoFormat.getWidth(), 640)); + state.writeInt32(19, streamState != null && streamState.getClientViewportHeight() > 0 + ? streamState.getClientViewportHeight() + : Math.max(videoFormat.getHeight(), 360)); + } + final Integer stickyResolutionOverride = streamState == null + ? null + : streamState.getStickyResolutionOverride(); + state.writeInt32(21, stickyResolutionOverride == null + ? Math.max(videoFormat.getHeight(), 360) + : stickyResolutionOverride); + if (includeFollowUpState || officialWebClientAbrFields) { + final long bandwidthEstimate = streamState != null + && streamState.getBandwidthEstimate() > 0 + ? streamState.getBandwidthEstimate() + : audioFormat.getBitrate() > 0 && videoFormat.getBitrate() > 0 + ? (audioFormat.getBitrate() + videoFormat.getBitrate()) * 2L + : -1; + if (bandwidthEstimate > 0) { + state.writeUInt64(23, bandwidthEstimate); + } + } + final Integer visibility = streamState == null + ? Integer.valueOf(1) + : streamState.getClientAbrVisibility(); + if (visibility != null) { + state.writeInt32(34, visibility); + } + state.writeFloat(35, streamState == null ? 1.0f : streamState.getPlaybackRate()); + if (enabledTrackTypesBitfield != ENABLED_TRACK_TYPES_VIDEO_AND_AUDIO) { + state.writeInt32(40, enabledTrackTypesBitfield); + } + if (audioFormat.isDrc()) { + state.writeBool(46, true); + } + if (streamState != null + && streamState.getSabrReportRequestCancellationInfoOverride() != null) { + state.writeInt32(54, streamState.getSabrReportRequestCancellationInfoOverride()); + } + if (officialWebClientAbrFields) { + if (includeFollowUpState) { + state.writeUInt64(29, longOverride( + streamState.getOfficialTimeSinceLastSeekOverride(), 48)); + state.writeUInt64(36, longOverride( + streamState.getOfficialElapsedWallTimeOverride(), 1406)); + state.writeUInt64(39, longOverride( + streamState.getOfficialTimeSinceLastActionOverride(), 1446)); + state.writeUInt64(57, longOverride(streamState.getOfficialField57Override(), 59)); + } else { + state.writeUInt64(29, longOverride( + streamState.getOfficialTimeSinceLastSeekOverride(), 9)); + state.writeUInt64(36, longOverride( + streamState.getOfficialElapsedWallTimeOverride(), 41)); + state.writeUInt64(39, longOverride( + streamState.getOfficialTimeSinceLastActionOverride(), 80)); + final Long officialField57Override = streamState.getOfficialField57Override(); + if (officialField57Override != null) { + state.writeUInt64(57, officialField57Override); + } + } + state.writeBool(58, false); + state.writeInt32(59, Math.max(videoFormat.getHeight(), 1080)); + state.writeUInt64(68, longOverride(streamState.getOfficialField68Override(), 0)); + state.writeBool(71, true); + state.writeMessage(72, buildOfficialWebQualityConstraints( + Math.max(videoFormat.getHeight(), 1080))); + state.writeInt32(76, 0); + state.writeMessage(79, buildOfficialWebPlaybackAuthorization()); + if (!includeFollowUpState) { + state.writeInt32(80, 1); + } + } + state.writeUInt64(28, playerTimeMs); + state.writeStringIfNotEmpty(69, audioFormat.getAudioTrackId()); + return state.toByteArray(); + } + + private static void writePreferredFormats(@Nonnull final SabrProto.Writer request, + @Nonnull final YoutubeSabrInfo info, + @Nonnull final YoutubeSabrFormat audioFormat, + @Nonnull final YoutubeSabrFormat videoFormat, + @Nullable final YoutubeSabrStreamState streamState) { + if (streamState != null && streamState.shouldWriteAllPreferredFormats()) { + if (streamState.shouldWriteOfficialWebPreferredFormats()) { + writeOfficialWebPreferredFormats(request, info); + return; + } + for (final YoutubeSabrFormat format : info.getFormats()) { + if (format.isAudio()) { + request.writeMessage(16, SabrProto.formatId(format)); + } + } + for (final YoutubeSabrFormat format : info.getFormats()) { + if (format.isVideo()) { + request.writeMessage(17, SabrProto.formatId(format)); + } + } + return; + } + request.writeMessage(16, SabrProto.formatId(audioFormat)); + request.writeMessage(17, SabrProto.formatId(videoFormat)); + } + + private static void writeOfficialWebPreferredFormats(@Nonnull final SabrProto.Writer request, + @Nonnull final YoutubeSabrInfo info) { + writeAudioFormatByItagAndXtagsLength(request, info, 251, 12); + writeAudioFormatByItagAndXtagsLength(request, info, 251, 14); + writeAudioFormatByItagAndXtagsLength(request, info, 251, 0); + writeAudioFormatByItagAndXtagsLength(request, info, 250, 14); + writeAudioFormatByItagAndXtagsLength(request, info, 250, 0); + writeAudioFormatByItagAndXtagsLength(request, info, 250, 12); + writeVideoFormatByItag(request, info, 248); + writeVideoFormatByItag(request, info, 247); + writeVideoFormatByItag(request, info, 244); + writeVideoFormatByItag(request, info, 243); + writeVideoFormatByItag(request, info, 242); + writeVideoFormatByItag(request, info, 278); + } + + private static void writeAudioFormatByItagAndXtagsLength( + @Nonnull final SabrProto.Writer request, + @Nonnull final YoutubeSabrInfo info, + final int itag, + final int xtagsLength) { + for (final YoutubeSabrFormat format : info.getFormats()) { + final String xtags = format.getXtags(); + final int currentXtagsLength = xtags == null ? 0 : xtags.length(); + if (format.isAudio() && format.getItag() == itag + && currentXtagsLength == xtagsLength) { + request.writeMessage(16, SabrProto.formatId(format)); + } + } + } + + private static void writeVideoFormatByItag(@Nonnull final SabrProto.Writer request, + @Nonnull final YoutubeSabrInfo info, + final int itag) { + for (final YoutubeSabrFormat format : info.getFormats()) { + if (format.isVideo() && format.getItag() == itag) { + request.writeMessage(17, SabrProto.formatId(format)); + return; + } + } + } + + private static boolean isOfficialWebPreferredAudio(@Nonnull final YoutubeSabrFormat format) { + final String mimeType = format.getMimeType(); + return mimeType != null && mimeType.contains("webm") && format.getItag() != 249; + } + + private static boolean isOfficialWebPreferredVideo(@Nonnull final YoutubeSabrFormat format) { + final String mimeType = format.getMimeType(); + return mimeType != null && mimeType.contains("webm"); + } + + @Nonnull + private static byte[] buildOfficialWebQualityConstraints(final int height) { + final SabrProto.Writer constraints = new SabrProto.Writer(); + constraints.writeInt32(1, 0); + constraints.writeInt32(2, height); + constraints.writeInt32(3, 0); + constraints.writeInt32(4, 0); + constraints.writeInt32(5, height); + constraints.writeInt32(6, 0); + return constraints.toByteArray(); + } + + @Nonnull + private static byte[] buildOfficialWebPlaybackAuthorization() { + final SabrProto.Writer authorization = new SabrProto.Writer(); + authorization.writeMessage(1, buildAuthorizedTrack(1, false)); + authorization.writeMessage(1, buildAuthorizedTrack(2, false)); + authorization.writeMessage(1, buildAuthorizedTrack(2, true)); + return authorization.toByteArray(); + } + + @Nonnull + private static byte[] buildAuthorizedTrack(final int trackType, final boolean hdr) { + final SabrProto.Writer track = new SabrProto.Writer(); + track.writeInt32(1, trackType); + track.writeBool(2, hdr); + return track.toByteArray(); + } + + @Nonnull + private static byte[] buildStreamerContext(@Nonnull final YoutubeSabrInfo info) { + return buildStreamerContext(info, (byte[]) null); + } + + @Nonnull + private static byte[] buildStreamerContext(@Nonnull final YoutubeSabrInfo info, + @Nonnull final YoutubeSabrStreamState streamState) { + return buildStreamerContext(info, streamState.getRawPlaybackCookie(), streamState); + } + + @Nonnull + private static byte[] buildStreamerContext(@Nonnull final YoutubeSabrInfo info, + @Nullable final byte[] playbackCookie) { + return buildStreamerContext(info, playbackCookie, null); + } + + @Nonnull + private static byte[] buildStreamerContext(@Nonnull final YoutubeSabrInfo info, + @Nullable final byte[] playbackCookie, + @Nullable final YoutubeSabrStreamState streamState) { + final SabrProto.Writer context = new SabrProto.Writer(); + context.writeMessage(1, buildClientInfo(info, streamState)); + final byte[] poToken = streamState == null ? null : streamState.getRawPoToken(); + if (poToken != null && poToken.length > 0) { + context.writeBytes(2, poToken); + } + if (playbackCookie != null && playbackCookie.length > 0) { + context.writeBytes(3, playbackCookie); + } + if (streamState != null) { + for (final SabrContextUpdate contextUpdate : streamState.getActiveSabrContexts()) { + context.writeMessage(5, contextUpdate.toStreamerContextProto()); + } + for (final Integer type : streamState.getUnsentSabrContextTypes()) { + context.writeInt32(6, type); + } + } + return context.toByteArray(); + } + + @Nonnull + private static byte[] buildClientInfo(@Nonnull final YoutubeSabrInfo info) { + return buildClientInfo(info, null); + } + + @Nonnull + private static byte[] buildClientInfo(@Nonnull final YoutubeSabrInfo info, + @Nullable final YoutubeSabrStreamState streamState) { + final SabrProto.Writer client = new SabrProto.Writer(); + if (streamState != null && streamState.shouldWriteOfficialWebClientAbrFields()) { + client.writeStringIfNotEmpty(1, "en_US"); + client.writeInt32(16, parseInt(info.getProfile().getClientId(), -1)); + client.writeStringIfNotEmpty(17, info.getClientVersion()); + client.writeStringIfNotEmpty(18, "X11"); + return client.toByteArray(); + } + client.writeInt32(16, parseInt(info.getProfile().getClientId(), -1)); + client.writeStringIfNotEmpty(17, info.getClientVersion()); + client.writeStringIfNotEmpty(18, info.getProfile().getOsName()); + client.writeStringIfNotEmpty(19, info.getProfile().getOsVersion()); + client.writeStringIfNotEmpty(21, "en-US"); + client.writeStringIfNotEmpty(22, "US"); + return client.toByteArray(); + } + + @Nonnull + private static byte[] decodeBase64(@Nonnull final String value) throws SabrProtocolException { + try { + return Base64.getDecoder().decode(padBase64(value)); + } catch (final IllegalArgumentException first) { + try { + return Base64.getUrlDecoder().decode(padBase64(value)); + } catch (final IllegalArgumentException second) { + throw new SabrProtocolException("Could not decode base64 ustreamer config", second); + } + } + } + + @Nonnull + private static String padBase64(@Nonnull final String value) { + final int padding = (4 - value.length() % 4) % 4; + final StringBuilder builder = new StringBuilder(value); + for (int i = 0; i < padding; i++) { + builder.append('='); + } + return builder.toString(); + } + + private static int parseInt(@Nullable final String value, final int fallback) { + if (value == null) { + return fallback; + } + try { + return Integer.parseInt(value); + } catch (final NumberFormatException ignored) { + return fallback; + } + } + + private static long longOverride(@Nullable final Long override, final long fallback) { + return override == null ? fallback : override; + } +} From e5338d613b00c99a2d97792f38415a8330554847 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 3 Jun 2026 00:02:28 +0200 Subject: [PATCH 08/13] feat(sabr): session, state and format selection --- .../sabr/SabrFormatSelectionConfig.java | 83 +++ .../youtube/sabr/YoutubeSabrSession.java | 405 ++++++++++ .../youtube/sabr/YoutubeSabrStreamState.java | 702 ++++++++++++++++++ 3 files changed, 1190 insertions(+) create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrFormatSelectionConfig.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrSession.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrStreamState.java diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrFormatSelectionConfig.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrFormatSelectionConfig.java new file mode 100644 index 000000000..8129f8078 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrFormatSelectionConfig.java @@ -0,0 +1,83 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class SabrFormatSelectionConfig { + @Nonnull + private final List itags; + @Nullable + private final String videoId; + private final int resolution; + + private SabrFormatSelectionConfig(@Nonnull final List itags, + @Nullable final String videoId, + final int resolution) { + this.itags = Collections.unmodifiableList(new ArrayList<>(itags)); + this.videoId = videoId; + this.resolution = resolution; + } + + @Nonnull + static SabrFormatSelectionConfig decode(@Nonnull final byte[] data) + throws SabrProtocolException { + final List itags = new ArrayList<>(); + String videoId = null; + int resolution = 0; + for (final SabrProto.Field field : SabrProto.readFields(data)) { + if (field.getNumber() == 2) { + if (field.getWireType() == SabrProto.WIRE_VARINT) { + itags.add((int) field.getVarint()); + } else if (field.getWireType() == SabrProto.WIRE_LENGTH_DELIMITED) { + for (final Long itag : SabrProto.readPackedVarints(field.getBytes())) { + itags.add(itag.intValue()); + } + } + } else if (field.getNumber() == 3 + && field.getWireType() == SabrProto.WIRE_LENGTH_DELIMITED) { + videoId = field.getString(); + } else if (field.getNumber() == 4 + && field.getWireType() == SabrProto.WIRE_VARINT) { + resolution = (int) field.getVarint(); + } + } + return new SabrFormatSelectionConfig(itags, videoId, resolution); + } + + @Nonnull + public List getItags() { + return itags; + } + + @Nullable + public String getVideoId() { + return videoId; + } + + public int getResolution() { + return resolution; + } + + @Nonnull + public String summarize() { + final StringBuilder builder = new StringBuilder(); + builder.append("itags=").append(itags.size()).append('['); + final int sampleSize = Math.min(8, itags.size()); + for (int i = 0; i < sampleSize; i++) { + if (i > 0) { + builder.append(','); + } + builder.append(itags.get(i)); + } + if (itags.size() > sampleSize) { + builder.append(",..."); + } + builder.append(']') + .append(", videoIdLength=").append(videoId == null ? 0 : videoId.length()) + .append(", resolution=").append(resolution); + return builder.toString(); + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrSession.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrSession.java new file mode 100644 index 000000000..be45cc0cf --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrSession.java @@ -0,0 +1,405 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.localization.Localization; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.io.IOException; +import java.util.ArrayDeque; +import java.util.Arrays; +import java.util.Deque; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public final class YoutubeSabrSession { + private static final int MAX_REQUESTS_PER_SEGMENT = 16; + private static final int MAX_POLICY_ONLY_RESPONSES_PER_SEGMENT = 3; + private static final int MAX_REDIRECTS_PER_SESSION = 3; + // How many times a stale/rejected PO token may be force-re-minted before giving up (token + // expiry mid-playback). Bounded so a genuinely-rejected token can't loop forever. + private static final int MAX_PO_TOKEN_REFRESHES = 2; + private static final int MAX_BACKOFF_MS = 30_000; + // Cap the cached media bytes so a high-bitrate (4K VP9/AV1) stream can't fill the heap and OOM. + // 32 MiB ≈ ~50s of 4K video, far more than the read-lag, so forward playback never starves. + private static final long MAX_CACHE_BYTES = 32L * 1024 * 1024; + private static final int MIN_CACHED_SEGMENTS = 6; + + @Nonnull + private final YoutubeSabrInfo info; + @Nonnull + private final YoutubeSabrFormat audioFormat; + @Nonnull + private final YoutubeSabrFormat videoFormat; + @Nonnull + private final YoutubeSabrStreamState streamState; + @Nullable + private final SabrPoTokenProvider poTokenProvider; + private final Map segmentCache = new ConcurrentHashMap<>(); + private String serverAbrStreamingUrl; + private int requestNumber; + private int redirectCount; + private int poTokenRefreshes; + // Insertion order + total bytes of cached MEDIA segments (init segments are never evicted). + // Mutated only by the single pump thread in pumpOnce; readers only do concurrent-map gets. + private final Deque cacheOrder = new ArrayDeque<>(); + private long cachedBytes; + + public YoutubeSabrSession(@Nonnull final YoutubeSabrInfo info, + @Nonnull final YoutubeSabrFormat audioFormat, + @Nonnull final YoutubeSabrFormat videoFormat) { + this(info, audioFormat, videoFormat, null); + } + + public YoutubeSabrSession(@Nonnull final YoutubeSabrInfo info, + @Nonnull final YoutubeSabrFormat audioFormat, + @Nonnull final YoutubeSabrFormat videoFormat, + @Nullable final SabrPoTokenProvider poTokenProvider) { + if (!audioFormat.isAudio()) { + throw new IllegalArgumentException("SABR audio format must be audio: itag=" + + audioFormat.getItag()); + } + if (!videoFormat.isVideo()) { + throw new IllegalArgumentException("SABR video format must be video: itag=" + + videoFormat.getItag()); + } + if (audioFormat.getItag() == videoFormat.getItag()) { + throw new IllegalArgumentException("SABR audio/video formats must be distinct"); + } + if (info.getServerAbrStreamingUrl() == null || info.getServerAbrStreamingUrl().isEmpty()) { + throw new IllegalArgumentException("Missing SABR streaming URL"); + } + this.info = info; + this.audioFormat = audioFormat; + this.videoFormat = videoFormat; + this.streamState = new YoutubeSabrStreamState(audioFormat, videoFormat); + this.poTokenProvider = poTokenProvider; + this.serverAbrStreamingUrl = info.getServerAbrStreamingUrl(); + } + + @Nonnull + public SabrMediaSegment fetchSegment(@Nonnull final SabrSegmentRequest request, + @Nonnull final Localization localization) + throws IOException, ExtractionException { + final SabrMediaSegment cachedSegment = segmentCache.get(cacheKey(request)); + if (cachedSegment != null) { + return cachedSegment; + } + failIfKnownOutOfBounds(request); + + boolean targetPrepared = maybePrepareForDistantMediaSegment(request); + int policyOnlyResponses = 0; + for (int attempts = 0; attempts < MAX_REQUESTS_PER_SEGMENT; attempts++) { + final YoutubeSabrProbeResult result = fetchNextResponse(localization); + final SabrDecodedResponse decoded = result.getDecodedResponse(); + validateResponseIntegrity(decoded, request); + streamState.ingest(decoded); + final List segments = SabrMediaSegmentCollector.collect(decoded); + for (final SabrMediaSegment segment : segments) { + streamState.ingest(segment); + segmentCache.put(cacheKey(segment), segment); + } + final SabrMediaSegment segment = segmentCache.get(cacheKey(request)); + if (segment != null) { + return segment; + } + failIfKnownOutOfBounds(request); + if (!targetPrepared) { + targetPrepared = maybePrepareForDistantMediaSegment(request); + } + if (decoded.getSabrErrorDetails() != null) { + throw new SabrProtocolException("SABR error while fetching " + + describeRequest(request) + ": " + decoded.getSabrErrorDetails().summarize()); + } + if (decoded.isReloadRequested()) { + throw new SabrProtocolException("SABR requested player reload while fetching " + + describeRequest(request) + ": " + decoded.summarizeNoMediaResponse()); + } + if (decoded.isProtectedNoMediaResponse()) { + if (applyPoTokenForProtectedResponse()) { + if (decoded.getBackoffTimeMs() > 0) { + sleepBackoff(decoded.getBackoffTimeMs()); + } + continue; + } + throw new SabrProtocolException("SABR protected no-media response while fetching " + + describeRequest(request) + ": " + decoded.summarizeNoMediaResponse()); + } + if (decoded.isPolicyOnlyResponse()) { + policyOnlyResponses++; + if (policyOnlyResponses >= MAX_POLICY_ONLY_RESPONSES_PER_SEGMENT) { + throw new SabrProtocolException("SABR repeated policy-only responses while fetching " + + describeRequest(request) + ": " + decoded.summarizeNoMediaResponse()); + } + } else if (!segments.isEmpty()) { + policyOnlyResponses = 0; + } + if (decoded.getBackoffTimeMs() > 0) { + sleepBackoff(decoded.getBackoffTimeMs()); + } + if (streamState.isComplete()) { + break; + } + } + throw new SabrProtocolException("Requested SABR segment was not returned: itag=" + + request.getFormat().getItag() + + (request.isInitializationSegment() + ? ":init" + : ":seq=" + request.getSequenceNumber())); + } + + @Nonnull + public YoutubeSabrProbeResult fetchNextResponse(@Nonnull final Localization localization) + throws IOException, ExtractionException { + final YoutubeSabrProbeResult result; + if (requestNumber == 0) { + result = YoutubeSabrProbe.probeFirstMediaResponse(info, audioFormat, videoFormat, streamState, + serverAbrStreamingUrl, localization); + } else { + result = YoutubeSabrProbe.probeFollowUpMediaResponse(info, audioFormat, videoFormat, + streamState, requestNumber, serverAbrStreamingUrl, localization); + } + if (result.getDecodedResponse().getRedirectUrl() != null + && !result.getDecodedResponse().getRedirectUrl().isEmpty()) { + redirectCount++; + if (redirectCount > MAX_REDIRECTS_PER_SESSION) { + throw new SabrProtocolException("SABR redirect limit exceeded: redirects=" + + redirectCount); + } + serverAbrStreamingUrl = result.getDecodedResponse().getRedirectUrl(); + } + requestNumber++; + return result; + } + + /** + * Server-driven advance: issue one request with the current state and ingest whatever the + * server returns (segments for either or both formats), instead of demanding one specific + * per-track segment. A single consumer pumping this keeps audio and video coherent and lets the + * server feed the track that is behind the play head, so neither track starves the other. + */ + @Nonnull + public List pumpOnce(@Nonnull final Localization localization) + throws IOException, ExtractionException { + final YoutubeSabrProbeResult result = fetchNextResponse(localization); + final SabrDecodedResponse decoded = result.getDecodedResponse(); + final List integrityIssues = decoded.getIntegrityIssues(); + if (!integrityIssues.isEmpty()) { + throw new SabrProtocolException("SABR media integrity issue: " + integrityIssues); + } + streamState.ingest(decoded); + final List segments = SabrMediaSegmentCollector.collect(decoded); + for (final SabrMediaSegment segment : segments) { + streamState.ingest(segment); + final String key = cacheKey(segment); + final SabrMediaSegment prev = segmentCache.put(key, segment); + if (prev == null && !segment.getHeader().isInitSegment()) { + cacheOrder.addLast(key); + cachedBytes += segment.getData().length; + } + } + evictCacheIfNeeded(); + if (decoded.getSabrErrorDetails() != null) { + throw new SabrProtocolException("SABR error: " + + decoded.getSabrErrorDetails().summarize()); + } + if (decoded.isReloadRequested()) { + throw new SabrProtocolException("SABR requested player reload: " + + decoded.summarizeNoMediaResponse()); + } + if (decoded.isProtectedNoMediaResponse()) { + // Best-effort: mint / bounded re-mint the token. Do NOT throw on a single protected + // response — status=3 is a normal pacing/protection state the server clears on a later + // request; the pump keeps trying, and the reader stall watchdog is the final give-up. + applyPoTokenForProtectedResponse(); + } + if (!segments.isEmpty()) { + // A media-bearing response means the current token works and CDN hops are normal: clear + // the cumulative redirect and token-refresh budgets so a long session isn't capped. + redirectCount = 0; + poTokenRefreshes = 0; + } + if (decoded.getBackoffTimeMs() > 0) { + sleepBackoff(decoded.getBackoffTimeMs()); + } + return segments; + } + + /** Drop the oldest cached media segments (furthest behind the play head) to bound memory. */ + private void evictCacheIfNeeded() { + while (cachedBytes > MAX_CACHE_BYTES && cacheOrder.size() > MIN_CACHED_SEGMENTS) { + final String oldKey = cacheOrder.pollFirst(); + if (oldKey == null) { + break; + } + final SabrMediaSegment old = segmentCache.remove(oldKey); + if (old != null) { + cachedBytes -= old.getData().length; + } + } + } + + @Nullable + public SabrMediaSegment getCachedSegment(@Nonnull final SabrSegmentRequest request) { + return segmentCache.get(cacheKey(request)); + } + + /** True once the requested media segment is known to be past the last segment of the stream. */ + public boolean isBeyondEnd(@Nonnull final SabrSegmentRequest request) { + if (request.isInitializationSegment()) { + return false; + } + final long endSegment = streamState.getEndSegment(request.getFormat()); + return endSegment > 0 && request.getSequenceNumber() > endSegment; + } + + public boolean isComplete() { + return streamState.isComplete(); + } + + @Nonnull + public YoutubeSabrStreamState getStreamState() { + return streamState; + } + + public int getRequestNumber() { + return requestNumber; + } + + public void prepareForMediaSegment(@Nonnull final SabrSegmentRequest request) { + if (request.isInitializationSegment()) { + return; + } + final YoutubeSabrFormat targetFormat = request.getFormat(); + final YoutubeSabrFormat companionFormat = getCompanionFormat(targetFormat); + final long targetStartMs = streamState.getSegmentStartMs(targetFormat, + request.getSequenceNumber()); + streamState.assumeBufferedUntil(targetFormat, request.getSequenceNumber() - 1); + streamState.assumeBufferedUntil(companionFormat, + streamState.getSegmentNumberAtOrAfterTimeMs(companionFormat, targetStartMs)); + streamState.setPlayerTimeMs(targetStartMs); + } + + private void failIfKnownOutOfBounds(@Nonnull final SabrSegmentRequest request) + throws SabrProtocolException { + if (request.isInitializationSegment()) { + return; + } + final long endSegment = streamState.getEndSegment(request.getFormat()); + if (endSegment > 0 && request.getSequenceNumber() > endSegment) { + throw new SabrProtocolException("Requested SABR segment is beyond end: " + + describeRequest(request) + ", endSegment=" + endSegment); + } + } + + private void validateResponseIntegrity(@Nonnull final SabrDecodedResponse decoded, + @Nonnull final SabrSegmentRequest request) + throws SabrProtocolException { + final List integrityIssues = decoded.getIntegrityIssues(); + if (!integrityIssues.isEmpty()) { + throw new SabrProtocolException("SABR media integrity issue while fetching " + + describeRequest(request) + ": " + integrityIssues); + } + } + + private boolean maybePrepareForDistantMediaSegment( + @Nonnull final SabrSegmentRequest request) { + if (request.isInitializationSegment() || requestNumber == 0) { + return false; + } + final YoutubeSabrFormat format = request.getFormat(); + if (streamState.getEndSegment(format) <= 0) { + return false; + } + if (request.getSequenceNumber() <= streamState.getMaxSegment(format) + 1) { + return false; + } + prepareForMediaSegment(request); + return true; + } + + private static void sleepBackoff(final int backoffTimeMs) throws SabrProtocolException { + // Clamp to [0, MAX_BACKOFF_MS]: a negative (overflowed varint) must not skip the wait, and + // a huge server backoff must not be honoured verbatim (would stall playback for minutes). + final long ms = Math.min(Math.max(0, backoffTimeMs), MAX_BACKOFF_MS); + if (ms == 0) { + return; + } + try { + Thread.sleep(ms); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new SabrProtocolException("Interrupted while waiting for SABR backoff", e); + } + } + + private boolean maybeApplyPoToken(final boolean forceRefresh) + throws IOException, ExtractionException { + if (poTokenProvider == null) { + return false; + } + final byte[] current = streamState.getRawPoToken(); + if (current != null && current.length > 0 && !forceRefresh) { + return false; + } + final byte[] poToken = poTokenProvider.getPoToken(info, streamState, forceRefresh); + if (poToken != null && poToken.length > 0 && !Arrays.equals(poToken, current)) { + streamState.setPoToken(poToken); + return true; + } + return false; + } + + /** + * React to a protected/no-media (status=3) response: mint a token, or — if one is already set + * but the server still rejects it (token expired mid-playback) — force a bounded re-mint. + * Returns false when no usable token could be applied (caller treats it as fatal). + */ + private boolean applyPoTokenForProtectedResponse() throws IOException, ExtractionException { + if (maybeApplyPoToken(false)) { + return true; + } + if (poTokenRefreshes < MAX_PO_TOKEN_REFRESHES) { + // Count the attempt regardless of outcome: a force-refresh triggers a ~45s WebView + // mint, so we bound them even when the server keeps returning the same rejected token. + poTokenRefreshes++; + return maybeApplyPoToken(true); + } + return false; + } + + @Nonnull + private static String describeRequest(@Nonnull final SabrSegmentRequest request) { + return "itag=" + request.getFormat().getItag() + + (request.isInitializationSegment() + ? ":init" + : ":seq=" + request.getSequenceNumber()); + } + + @Nonnull + private static String cacheKey(@Nonnull final SabrSegmentRequest request) { + return request.getFormat().getItag() + ":" + + (request.isInitializationSegment() + ? "init" + : request.getSequenceNumber()); + } + + @Nonnull + private static String cacheKey(@Nonnull final SabrMediaSegment segment) { + final SabrMediaHeader header = segment.getHeader(); + return header.getItag() + ":" + + (header.isInitSegment() ? "init" : header.getSequenceNumber()); + } + + @Nonnull + private YoutubeSabrFormat getCompanionFormat(@Nonnull final YoutubeSabrFormat targetFormat) { + if (targetFormat.getItag() == audioFormat.getItag()) { + return videoFormat; + } + if (targetFormat.getItag() == videoFormat.getItag()) { + return audioFormat; + } + throw new IllegalArgumentException("Unknown SABR itag: " + targetFormat.getItag()); + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrStreamState.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrStreamState.java new file mode 100644 index 000000000..b5a9bcd69 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrStreamState.java @@ -0,0 +1,702 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public final class YoutubeSabrStreamState { + private final FormatProgress audio; + private final FormatProgress video; + private final Map sabrContexts = new LinkedHashMap<>(); + private final Set activeSabrContextTypes = new LinkedHashSet<>(); + @Nullable + private byte[] playbackCookie; + @Nullable + private byte[] poToken; + private long playerTimeMsOverride = -1; + private boolean audioFullyBuffered; + private boolean videoFullyBuffered; + private boolean audioLastOnlyRange; + private boolean videoLastOnlyRange; + private boolean lastOnlyRangesUseObservedTiming; + private int enabledTrackTypesBitfield = YoutubeSabrRequestBuilder.ENABLED_TRACK_TYPES_VIDEO_AND_AUDIO; + private boolean selectAudioFormat = true; + private boolean selectVideoFormat = true; + private boolean writeTopLevelPlayerTimeMs = true; + private int clientViewportWidth = -1; + private int clientViewportHeight = -1; + private long bandwidthEstimate = -1; + private float playbackRate = 1.0f; + // Experimental knobs used by local SABR probes. Defaults preserve the normal request shape. + private int bufferedRangeStartSegmentIndexOffset; + private int bufferedRangeEndSegmentIndexOffset; + @Nullable + private Integer clientAbrVisibility = 1; + private boolean writeLastManualSelectedResolution; + private boolean writeAllPreferredFormats; + private boolean writeOfficialWebPreferredFormats; + private boolean selectVideoFormatBeforeAudio; + private boolean writeBufferedRangeTimeRange = true; + @Nullable + private Integer stickyResolutionOverride; + @Nullable + private Long officialTimeSinceLastSeekOverride; + @Nullable + private Long officialElapsedWallTimeOverride; + @Nullable + private Long officialTimeSinceLastActionOverride; + @Nullable + private Long officialField57Override; + @Nullable + private Long officialField68Override; + @Nullable + private Integer sabrReportRequestCancellationInfoOverride; + private boolean writeOfficialWebClientAbrFields; + @Nullable + private List bufferedRangesOverride; + + public YoutubeSabrStreamState(@Nonnull final YoutubeSabrFormat audioFormat, + @Nonnull final YoutubeSabrFormat videoFormat) { + audio = new FormatProgress(audioFormat); + video = new FormatProgress(videoFormat); + } + + public boolean ingest(@Nonnull final SabrDecodedResponse response) { + boolean progressed = false; + final SabrNextRequestPolicy nextRequestPolicy = response.getNextRequestPolicy(); + if (nextRequestPolicy != null && nextRequestPolicy.getRawPlaybackCookie() != null) { + playbackCookie = nextRequestPolicy.getRawPlaybackCookie().clone(); + } + for (final SabrFormatInitializationMetadata metadata + : response.getFormatInitializationMetadata()) { + final FormatProgress progress = findProgressForItag(metadata.getItag()); + if (progress != null) { + progressed |= progress.observeMetadata(metadata); + } + } + for (final SabrMediaHeader header : response.getMediaHeaders()) { + final FormatProgress progress = findProgressForItag(header.getItag()); + if (progress != null) { + progressed |= progress.observeHeader(header); + } + } + for (final SabrContextUpdate contextUpdate : response.getSabrContextUpdates()) { + ingestContextUpdate(contextUpdate); + } + if (response.getSabrContextSendingPolicy() != null) { + ingestContextSendingPolicy(response.getSabrContextSendingPolicy()); + } + return progressed; + } + + public boolean ingest(@Nonnull final SabrMediaSegment segment) { + final FormatProgress progress = findProgressForItag(segment.getHeader().getItag()); + return progress != null && progress.observeSegment(segment); + } + + @Nonnull + public List getBufferedRanges() { + if (bufferedRangesOverride != null) { + return new ArrayList<>(bufferedRangesOverride); + } + final List ranges = new ArrayList<>(); + if (audioFullyBuffered) { + ranges.add(SabrBufferedRange.full(audio.format)); + } else { + audio.addBufferedRange(ranges, audioLastOnlyRange, + lastOnlyRangesUseObservedTiming, + bufferedRangeStartSegmentIndexOffset, bufferedRangeEndSegmentIndexOffset); + } + if (videoFullyBuffered) { + ranges.add(SabrBufferedRange.full(video.format)); + } else { + video.addBufferedRange(ranges, videoLastOnlyRange, + lastOnlyRangesUseObservedTiming, + bufferedRangeStartSegmentIndexOffset, bufferedRangeEndSegmentIndexOffset); + } + return ranges; + } + + public void setBufferedRangesOverride( + @Nullable final List bufferedRangesOverride) { + this.bufferedRangesOverride = bufferedRangesOverride == null + ? null + : new ArrayList<>(bufferedRangesOverride); + } + + public long getPlayerTimeMs() { + if (playerTimeMsOverride >= 0) { + return playerTimeMsOverride; + } + return Math.max(audio.getBufferedEndMs(), video.getBufferedEndMs()); + } + + public void setPlayerTimeMs(final long playerTimeMs) { + playerTimeMsOverride = Math.max(0, playerTimeMs); + } + + public void clearPlayerTimeMsOverride() { + playerTimeMsOverride = -1; + } + + @Nullable + public byte[] getPlaybackCookie() { + return playbackCookie == null ? null : playbackCookie.clone(); + } + + public void setPoToken(@Nullable final byte[] poToken) { + this.poToken = poToken == null ? null : poToken.clone(); + } + + @Nullable + public byte[] getPoToken() { + return poToken == null ? null : poToken.clone(); + } + + @Nullable + byte[] getRawPlaybackCookie() { + return playbackCookie; + } + + @Nullable + byte[] getRawPoToken() { + return poToken; + } + + @Nonnull + Collection getActiveSabrContexts() { + final List activeSabrContexts = new ArrayList<>(); + for (final Integer type : activeSabrContextTypes) { + final SabrContextUpdate contextUpdate = sabrContexts.get(type); + if (contextUpdate != null) { + activeSabrContexts.add(contextUpdate); + } + } + return activeSabrContexts; + } + + @Nonnull + Collection getUnsentSabrContextTypes() { + final List unsentSabrContextTypes = new ArrayList<>(); + for (final Integer type : sabrContexts.keySet()) { + if (!activeSabrContextTypes.contains(type)) { + unsentSabrContextTypes.add(type); + } + } + return unsentSabrContextTypes; + } + + public boolean isComplete() { + return audio.isComplete() && video.isComplete(); + } + + public int getMaxSegment(@Nonnull final YoutubeSabrFormat format) { + return progressForItag(format.getItag()).maxSegment; + } + + public long getEndSegment(@Nonnull final YoutubeSabrFormat format) { + return progressForItag(format.getItag()).endSegment; + } + + public boolean isComplete(@Nonnull final YoutubeSabrFormat format) { + return progressForItag(format.getItag()).isComplete(); + } + + public void assumeBufferedUntil(@Nonnull final YoutubeSabrFormat format, + final int endSegment) { + if (endSegment > 0) { + progressForItag(format.getItag()).assumeBufferedUntil(endSegment); + } + } + + public void setFullyBuffered(@Nonnull final YoutubeSabrFormat format, + final boolean fullyBuffered) { + if (audio.itag == format.getItag()) { + audioFullyBuffered = fullyBuffered; + } else if (video.itag == format.getItag()) { + videoFullyBuffered = fullyBuffered; + } else { + throw new IllegalArgumentException("Unknown SABR itag: " + format.getItag()); + } + } + + public void setLastOnlyRange(@Nonnull final YoutubeSabrFormat format, + final boolean lastOnlyRange) { + if (audio.itag == format.getItag()) { + audioLastOnlyRange = lastOnlyRange; + } else if (video.itag == format.getItag()) { + videoLastOnlyRange = lastOnlyRange; + } else { + throw new IllegalArgumentException("Unknown SABR itag: " + format.getItag()); + } + } + + public void setLastOnlyRangesUseObservedTiming(final boolean useObservedTiming) { + lastOnlyRangesUseObservedTiming = useObservedTiming; + } + + public void setBufferedRangeSegmentIndexOffset(final int bufferedRangeSegmentIndexOffset) { + bufferedRangeStartSegmentIndexOffset = bufferedRangeSegmentIndexOffset; + bufferedRangeEndSegmentIndexOffset = bufferedRangeSegmentIndexOffset; + } + + public void setBufferedRangeSegmentIndexOffsets(final int startSegmentIndexOffset, + final int endSegmentIndexOffset) { + bufferedRangeStartSegmentIndexOffset = startSegmentIndexOffset; + bufferedRangeEndSegmentIndexOffset = endSegmentIndexOffset; + } + + public void setRequestTrackMode(final int enabledTrackTypesBitfield, + final boolean selectAudioFormat, + final boolean selectVideoFormat) { + this.enabledTrackTypesBitfield = enabledTrackTypesBitfield; + this.selectAudioFormat = selectAudioFormat; + this.selectVideoFormat = selectVideoFormat; + } + + public void setClientViewport(final int clientViewportWidth, + final int clientViewportHeight) { + this.clientViewportWidth = clientViewportWidth; + this.clientViewportHeight = clientViewportHeight; + } + + int getClientViewportWidth() { + return clientViewportWidth; + } + + int getClientViewportHeight() { + return clientViewportHeight; + } + + public void setBandwidthEstimate(final long bandwidthEstimate) { + this.bandwidthEstimate = bandwidthEstimate; + } + + long getBandwidthEstimate() { + return bandwidthEstimate; + } + + public void setPlaybackRate(final float playbackRate) { + if (playbackRate > 0.0f) { + this.playbackRate = playbackRate; + } + } + + float getPlaybackRate() { + return playbackRate; + } + + int getEnabledTrackTypesBitfield() { + return enabledTrackTypesBitfield; + } + + boolean shouldSelectAudioFormat() { + return selectAudioFormat; + } + + boolean shouldSelectVideoFormat() { + return selectVideoFormat; + } + + public void setWriteTopLevelPlayerTimeMs(final boolean writeTopLevelPlayerTimeMs) { + this.writeTopLevelPlayerTimeMs = writeTopLevelPlayerTimeMs; + } + + boolean shouldWriteTopLevelPlayerTimeMs() { + return writeTopLevelPlayerTimeMs; + } + + public void setClientAbrVisibility(@Nullable final Integer clientAbrVisibility) { + this.clientAbrVisibility = clientAbrVisibility; + } + + @Nullable + Integer getClientAbrVisibility() { + return clientAbrVisibility; + } + + public void setWriteLastManualSelectedResolution( + final boolean writeLastManualSelectedResolution) { + this.writeLastManualSelectedResolution = writeLastManualSelectedResolution; + } + + boolean shouldWriteLastManualSelectedResolution() { + return writeLastManualSelectedResolution; + } + + public void setWriteAllPreferredFormats(final boolean writeAllPreferredFormats) { + this.writeAllPreferredFormats = writeAllPreferredFormats; + } + + boolean shouldWriteAllPreferredFormats() { + return writeAllPreferredFormats; + } + + public void setWriteOfficialWebPreferredFormats( + final boolean writeOfficialWebPreferredFormats) { + this.writeOfficialWebPreferredFormats = writeOfficialWebPreferredFormats; + } + + boolean shouldWriteOfficialWebPreferredFormats() { + return writeOfficialWebPreferredFormats; + } + + public void setSelectVideoFormatBeforeAudio(final boolean selectVideoFormatBeforeAudio) { + this.selectVideoFormatBeforeAudio = selectVideoFormatBeforeAudio; + } + + boolean shouldSelectVideoFormatBeforeAudio() { + return selectVideoFormatBeforeAudio; + } + + public void setWriteBufferedRangeTimeRange(final boolean writeBufferedRangeTimeRange) { + this.writeBufferedRangeTimeRange = writeBufferedRangeTimeRange; + } + + boolean shouldWriteBufferedRangeTimeRange() { + return writeBufferedRangeTimeRange; + } + + public void setStickyResolutionOverride(@Nullable final Integer stickyResolutionOverride) { + this.stickyResolutionOverride = stickyResolutionOverride; + } + + @Nullable + Integer getStickyResolutionOverride() { + return stickyResolutionOverride; + } + + public void setOfficialWebClientAbrTimingOverrides( + @Nullable final Long timeSinceLastSeek, + @Nullable final Long elapsedWallTime, + @Nullable final Long timeSinceLastAction, + @Nullable final Long field57) { + officialTimeSinceLastSeekOverride = timeSinceLastSeek; + officialElapsedWallTimeOverride = elapsedWallTime; + officialTimeSinceLastActionOverride = timeSinceLastAction; + officialField57Override = field57; + } + + public void setOfficialField68Override(@Nullable final Long field68) { + officialField68Override = field68; + } + + @Nullable + Long getOfficialTimeSinceLastSeekOverride() { + return officialTimeSinceLastSeekOverride; + } + + @Nullable + Long getOfficialElapsedWallTimeOverride() { + return officialElapsedWallTimeOverride; + } + + @Nullable + Long getOfficialTimeSinceLastActionOverride() { + return officialTimeSinceLastActionOverride; + } + + @Nullable + Long getOfficialField57Override() { + return officialField57Override; + } + + @Nullable + Long getOfficialField68Override() { + return officialField68Override; + } + + public void setSabrReportRequestCancellationInfoOverride( + @Nullable final Integer sabrReportRequestCancellationInfoOverride) { + this.sabrReportRequestCancellationInfoOverride = sabrReportRequestCancellationInfoOverride; + } + + @Nullable + Integer getSabrReportRequestCancellationInfoOverride() { + return sabrReportRequestCancellationInfoOverride; + } + + public void setWriteOfficialWebClientAbrFields( + final boolean writeOfficialWebClientAbrFields) { + this.writeOfficialWebClientAbrFields = writeOfficialWebClientAbrFields; + } + + boolean shouldWriteOfficialWebClientAbrFields() { + return writeOfficialWebClientAbrFields; + } + + @Nonnull + public String summarizeBufferedRanges() { + final List ranges = getBufferedRanges(); + final StringBuilder builder = new StringBuilder(); + for (int i = 0; i < ranges.size(); i++) { + if (i > 0) { + builder.append(','); + } + builder.append(ranges.get(i).summarize()); + } + return builder.toString(); + } + + public long getAverageSegmentDurationMs(@Nonnull final YoutubeSabrFormat format) { + return progressForItag(format.getItag()).averageDurationMs; + } + + public long getSegmentStartMs(@Nonnull final YoutubeSabrFormat format, + final int sequenceNumber) { + return progressForItag(format.getItag()).getSegmentStartMs(sequenceNumber); + } + + public long getSegmentEndMs(@Nonnull final YoutubeSabrFormat format, + final int sequenceNumber) { + return progressForItag(format.getItag()).getSegmentEndMs(sequenceNumber); + } + + public int getSegmentNumberAtOrAfterTimeMs(@Nonnull final YoutubeSabrFormat format, + final long timeMs) { + return progressForItag(format.getItag()).getSegmentNumberAtOrAfterTimeMs(timeMs); + } + + @Nonnull + private FormatProgress progressForItag(final int itag) { + final FormatProgress progress = findProgressForItag(itag); + if (progress == null) { + throw new IllegalArgumentException("Unknown SABR itag: " + itag); + } + return progress; + } + + @Nullable + private FormatProgress findProgressForItag(final int itag) { + if (audio.itag == itag) { + return audio; + } + if (video.itag == itag) { + return video; + } + return null; + } + + private void ingestContextUpdate(@Nonnull final SabrContextUpdate contextUpdate) { + if (contextUpdate.getType() < 0 || contextUpdate.getValueLength() == 0) { + return; + } + if (contextUpdate.getWritePolicy() == SabrContextUpdate.WRITE_POLICY_KEEP_EXISTING + && sabrContexts.containsKey(contextUpdate.getType())) { + return; + } + sabrContexts.put(contextUpdate.getType(), contextUpdate); + if (contextUpdate.isSendByDefault()) { + activeSabrContextTypes.add(contextUpdate.getType()); + } + } + + private void ingestContextSendingPolicy(@Nonnull final SabrContextSendingPolicy policy) { + activeSabrContextTypes.addAll(policy.getStartPolicy()); + activeSabrContextTypes.removeAll(policy.getStopPolicy()); + for (final Integer type : policy.getDiscardPolicy()) { + sabrContexts.remove(type); + activeSabrContextTypes.remove(type); + } + } + + private static final class FormatProgress { + @Nonnull + private final YoutubeSabrFormat format; + private final int itag; + private final long lastModified; + @Nullable + private final String xtags; + // Written by the pump thread (ingest), read by the ExoPlayer loader threads (isBeyondEnd / + // isComplete / getEndSegment) — volatile for cross-thread visibility. + private volatile boolean initReceived; + private volatile int maxSegment; + private int observedMaxSegment; + private volatile long endSegment = -1; + private long averageDurationMs = 5000; + private int firstObservedSegment = -1; + private int lastObservedSegment = -1; + private long observedStartMs = -1; + private long observedEndMs = -1; + private long lastObservedDurationMs = -1; + @Nullable + private SabrFormatInitializationMetadata metadata; + @Nullable + private SabrSegmentIndex segmentIndex; + + private FormatProgress(@Nonnull final YoutubeSabrFormat format) { + this.format = format; + itag = format.getItag(); + lastModified = format.getLastModified(); + xtags = format.getXtags(); + } + + private boolean observeMetadata(@Nonnull final SabrFormatInitializationMetadata metadata) { + this.metadata = metadata; + final long previousEndSegment = endSegment; + endSegment = metadata.getEndSegmentNumber(); + if (metadata.getDurationUnits() > 0 && metadata.getDurationTimescale() > 0 + && metadata.getEndSegmentNumber() > 0) { + final long totalMs = metadata.getDurationUnits() * 1000L + / metadata.getDurationTimescale(); + averageDurationMs = Math.max(1L, totalMs / metadata.getEndSegmentNumber()); + } + return previousEndSegment != endSegment; + } + + private boolean observeSegment(@Nonnull final SabrMediaSegment segment) { + if (!segment.getHeader().isInitSegment() || metadata == null || segmentIndex != null) { + return false; + } + final String mimeType = metadata.getMimeType(); + if (mimeType == null) { + return false; + } + try { + if (mimeType.contains("mp4")) { + segmentIndex = SabrMp4SegmentIndexParser.parse(segment.getData(), metadata); + } else if (mimeType.contains("webm")) { + segmentIndex = SabrWebmSegmentIndexParser.parse(segment.getData(), metadata); + } else { + return false; + } + return true; + } catch (final SabrProtocolException ignored) { + return false; + } + } + + private boolean observeHeader(@Nonnull final SabrMediaHeader header) { + if (header.isInitSegment()) { + final boolean changed = !initReceived; + initReceived = true; + return changed; + } + if (header.getSequenceNumber() > maxSegment) { + maxSegment = header.getSequenceNumber(); + } + if (header.getSequenceNumber() > observedMaxSegment) { + observedMaxSegment = header.getSequenceNumber(); + } + if (firstObservedSegment < 0 || header.getSequenceNumber() < firstObservedSegment) { + firstObservedSegment = header.getSequenceNumber(); + } + if (header.getSequenceNumber() >= lastObservedSegment) { + lastObservedSegment = header.getSequenceNumber(); + lastObservedDurationMs = header.getDurationMs(); + } + if (header.getStartMs() >= 0 && header.getDurationMs() > 0) { + if (observedStartMs < 0 || header.getStartMs() < observedStartMs) { + observedStartMs = header.getStartMs(); + } + observedEndMs = Math.max(observedEndMs, header.getStartMs() + header.getDurationMs()); + } + if (header.getSequenceNumber() == maxSegment) { + return true; + } + return false; + } + + private void addBufferedRange(@Nonnull final List ranges, + final boolean lastOnlyRange, + final boolean lastOnlyRangeUseObservedTiming, + final int startSegmentIndexOffset, + final int endSegmentIndexOffset) { + if (maxSegment <= 0) { + return; + } + if (lastOnlyRange && lastObservedSegment > 0) { + final long durationMs = lastObservedDurationMs > 0 + ? lastObservedDurationMs : averageDurationMs; + final long startTimeMs = lastOnlyRangeUseObservedTiming + ? getSegmentStartMs(lastObservedSegment) : 0; + ranges.add(new SabrBufferedRange(itag, lastModified, xtags, startTimeMs, + durationMs, + applySegmentIndexOffset(lastObservedSegment, startSegmentIndexOffset), + applySegmentIndexOffset(lastObservedSegment, endSegmentIndexOffset), 1000)); + return; + } + final boolean canUseObservedTiming = observedStartMs >= 0 && observedEndMs > observedStartMs + && observedMaxSegment >= maxSegment && firstObservedSegment > 0; + ranges.add(new SabrBufferedRange(itag, lastModified, xtags, + canUseObservedTiming ? observedStartMs : 0, + canUseObservedTiming ? observedEndMs - observedStartMs : getBufferedEndMs(), + applySegmentIndexOffset(canUseObservedTiming ? firstObservedSegment : 1, + startSegmentIndexOffset), + applySegmentIndexOffset(maxSegment, endSegmentIndexOffset), 1000)); + } + + private int applySegmentIndexOffset(final int segmentIndex, + final int segmentIndexOffset) { + return Math.max(0, segmentIndex + segmentIndexOffset); + } + + private long getBufferedEndMs() { + final long indexedEndMs = getSegmentEndMs(maxSegment); + if (indexedEndMs >= 0) { + return indexedEndMs; + } + return maxSegment * averageDurationMs; + } + + private long getSegmentStartMs(final int sequenceNumber) { + if (sequenceNumber <= 1) { + return 0; + } + if (segmentIndex != null) { + final SabrSegmentIndex.Entry entry = segmentIndex.getEntry(sequenceNumber); + if (entry != null) { + return entry.getStartMs(); + } + } + return Math.max(0, sequenceNumber - 1L) * averageDurationMs; + } + + private long getSegmentEndMs(final int sequenceNumber) { + if (segmentIndex != null) { + final SabrSegmentIndex.Entry entry = segmentIndex.getEntry(sequenceNumber); + if (entry != null) { + return entry.getEndMs(); + } + } + if (sequenceNumber <= 0) { + return -1; + } + return sequenceNumber * averageDurationMs; + } + + private int getSegmentNumberAtOrAfterTimeMs(final long timeMs) { + if (timeMs <= 0) { + return 1; + } + if (segmentIndex != null) { + for (int i = 1; i <= segmentIndex.size(); i++) { + final SabrSegmentIndex.Entry entry = segmentIndex.getEntry(i); + if (entry != null && entry.getEndMs() >= timeMs) { + return entry.getSequenceNumber(); + } + } + return Math.max(1, segmentIndex.size()); + } + final long durationMs = Math.max(1, averageDurationMs); + final long sequenceNumber = (timeMs + durationMs - 1) / durationMs; + return sequenceNumber > Integer.MAX_VALUE + ? Integer.MAX_VALUE + : Math.max(1, (int) sequenceNumber); + } + + private void assumeBufferedUntil(final int endSegment) { + maxSegment = Math.max(maxSegment, endSegment); + } + + private boolean isComplete() { + return initReceived && endSegment > 0 && maxSegment >= endSegment; + } + } +} From bca0e1117fcd7b87f25758c95b32ee76ba8eb844 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 3 Jun 2026 00:02:28 +0200 Subject: [PATCH 09/13] feat(sabr): info, formats, probe bootstrap and PO token provider --- .../youtube/sabr/SabrColdStartPoToken.java | 46 ++ .../youtube/sabr/SabrPoTokenProvider.java | 34 ++ .../youtube/sabr/SabrPrewarmConnection.java | 130 +++++ .../youtube/sabr/SabrRequestDumper.java | 536 ++++++++++++++++++ .../youtube/sabr/YoutubeSabrFormat.java | 171 ++++++ .../youtube/sabr/YoutubeSabrInfo.java | 115 ++++ .../youtube/sabr/YoutubeSabrProbe.java | 531 +++++++++++++++++ .../youtube/sabr/YoutubeSabrProbeResult.java | 49 ++ 8 files changed, 1612 insertions(+) create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrColdStartPoToken.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrPoTokenProvider.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrPrewarmConnection.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrRequestDumper.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrFormat.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrInfo.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrProbe.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrProbeResult.java diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrColdStartPoToken.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrColdStartPoToken.java new file mode 100644 index 000000000..53cc7b43c --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrColdStartPoToken.java @@ -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; + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrPoTokenProvider.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrPoTokenProvider.java new file mode 100644 index 000000000..53167c31a --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrPoTokenProvider.java @@ -0,0 +1,34 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import org.schabi.newpipe.extractor.exceptions.ExtractionException; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.io.IOException; + +/** + * Supplies raw WEB PO token bytes for experimental SABR requests. + */ +@FunctionalInterface +public interface SabrPoTokenProvider { + /** + * Returns raw PO token bytes for the current SABR session, or {@code null} if unavailable. + */ + @Nullable + byte[] getPoToken(@Nonnull YoutubeSabrInfo info, + @Nonnull YoutubeSabrStreamState streamState) + throws IOException, ExtractionException; + + /** + * Same as {@link #getPoToken}, but when {@code forceRefresh} is true any cached token is + * discarded and a fresh one is minted — used when the server rejects a token that expired + * mid-playback. Default ignores the flag (no cache to bypass). + */ + @Nullable + default byte[] getPoToken(@Nonnull final YoutubeSabrInfo info, + @Nonnull final YoutubeSabrStreamState streamState, + final boolean forceRefresh) + throws IOException, ExtractionException { + return getPoToken(info, streamState); + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrPrewarmConnection.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrPrewarmConnection.java new file mode 100644 index 000000000..127e09579 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrPrewarmConnection.java @@ -0,0 +1,130 @@ +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 SabrPrewarmConnection { + private static final int MAX_SUMMARY_ITEMS = 4; + private static final int MAX_NESTING_DEPTH = 2; + + @Nonnull + private final List connections; + @Nonnull + private final List extraFields; + + private SabrPrewarmConnection(@Nonnull final List connections, + @Nonnull final List extraFields) { + this.connections = Collections.unmodifiableList(new ArrayList<>(connections)); + this.extraFields = Collections.unmodifiableList(new ArrayList<>(extraFields)); + } + + @Nonnull + static SabrPrewarmConnection decode(@Nonnull final byte[] data) throws SabrProtocolException { + final List connections = new ArrayList<>(); + final List extraFields = new ArrayList<>(); + for (final SabrProto.Field field : SabrProto.readFields(data)) { + if (field.getNumber() == 1 && field.getWireType() == SabrProto.WIRE_LENGTH_DELIMITED) { + connections.add(describeNestedMessage(field.getBytes(), 0)); + } else { + extraFields.add(describeField(field, 0)); + } + } + return new SabrPrewarmConnection(connections, extraFields); + } + + @Nonnull + public List getConnections() { + return connections; + } + + @Nonnull + public List getExtraFields() { + return extraFields; + } + + @Nonnull + public String summarize() { + return "connections=" + summarizeList(connections) + + (extraFields.isEmpty() ? "" : ", extra=" + summarizeList(extraFields)); + } + + @Nonnull + private static String summarizeList(@Nonnull final List values) { + final StringBuilder builder = new StringBuilder(); + builder.append(values.size()).append('['); + final int sampleSize = Math.min(MAX_SUMMARY_ITEMS, values.size()); + for (int i = 0; i < sampleSize; i++) { + if (i > 0) { + builder.append(','); + } + builder.append(values.get(i)); + } + if (values.size() > sampleSize) { + builder.append(",..."); + } + return builder.append(']').toString(); + } + + @Nonnull + private static String describeNestedMessage(@Nonnull final byte[] data, + final int depth) { + if (depth >= MAX_NESTING_DEPTH) { + return describeOpaqueBytes(data); + } + try { + final List fields = new ArrayList<>(); + for (final SabrProto.Field field : SabrProto.readFields(data)) { + fields.add(describeField(field, depth + 1)); + } + return '{' + join(fields) + '}'; + } catch (final SabrProtocolException e) { + return describeOpaqueBytes(data); + } + } + + @Nonnull + private static String describeOpaqueBytes(@Nonnull final byte[] data) { + return "bytes(" + data.length + (isPrintableAscii(data) ? ",ascii" : "") + ')'; + } + + private static boolean isPrintableAscii(@Nonnull final byte[] data) { + if (data.length == 0) { + return false; + } + for (final byte value : data) { + final int unsigned = value & 0xff; + if (unsigned < 0x20 || unsigned > 0x7e) { + return false; + } + } + return true; + } + + @Nonnull + private static String describeField(@Nonnull final SabrProto.Field field, + final int depth) throws SabrProtocolException { + if (field.getWireType() == SabrProto.WIRE_VARINT) { + return field.getNumber() + "=" + field.getVarint(); + } + if (field.getWireType() == SabrProto.WIRE_LENGTH_DELIMITED) { + final byte[] bytes = field.getBytes(); + final String nested = describeNestedMessage(bytes, depth); + return field.getNumber() + "=" + nested; + } + return field.getNumber() + "=bytes(" + field.getBytes().length + ')'; + } + + @Nonnull + private static String join(@Nonnull final List values) { + final StringBuilder builder = new StringBuilder(); + for (final String value : values) { + if (builder.length() > 0) { + builder.append('/'); + } + builder.append(value); + } + return builder.toString(); + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrRequestDumper.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrRequestDumper.java new file mode 100644 index 000000000..1408127af --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrRequestDumper.java @@ -0,0 +1,536 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import javax.annotation.Nonnull; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Sanitized diagnostics for local 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 + "):" + SabrPlaybackCookie.decode(data).summarize(); + } 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/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrFormat.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrFormat.java new file mode 100644 index 000000000..c12f8adfb --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrFormat.java @@ -0,0 +1,171 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import com.grack.nanojson.JsonArray; +import com.grack.nanojson.JsonObject; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.List; + +public final class YoutubeSabrFormat { + private final int itag; + private final long lastModified; + @Nullable + private final String xtags; + @Nullable + private final String mimeType; + @Nullable + private final String audioTrackId; + @Nullable + private final String qualityLabel; + @Nullable + private final String audioQuality; + private final boolean drc; + private final int width; + private final int height; + private final int bitrate; + private final long contentLength; + private final long approxDurationMs; + + private YoutubeSabrFormat(final int itag, + final long lastModified, + @Nullable final String xtags, + @Nullable final String mimeType, + @Nullable final String audioTrackId, + @Nullable final String qualityLabel, + @Nullable final String audioQuality, + final boolean drc, + final int width, + final int height, + final int bitrate, + final long contentLength, + final long approxDurationMs) { + this.itag = itag; + this.lastModified = lastModified; + this.xtags = xtags; + this.mimeType = mimeType; + this.audioTrackId = audioTrackId; + this.qualityLabel = qualityLabel; + this.audioQuality = audioQuality; + this.drc = drc; + this.width = width; + this.height = height; + this.bitrate = bitrate; + this.contentLength = contentLength; + this.approxDurationMs = approxDurationMs; + } + + @Nonnull + static List fromAdaptiveFormats(@Nullable final JsonArray formats) { + final List result = new ArrayList<>(); + if (formats == null) { + return result; + } + for (int i = 0; i < formats.size(); i++) { + final JsonObject format = formats.getObject(i); + if (format != null && format.has("itag")) { + result.add(fromJson(format)); + } + } + return result; + } + + @Nonnull + private static YoutubeSabrFormat fromJson(@Nonnull final JsonObject format) { + final JsonObject audioTrack = format.getObject("audioTrack"); + return new YoutubeSabrFormat( + format.getInt("itag"), + parseLong(format.get("lastModified")), + format.getString("xtags"), + format.getString("mimeType"), + audioTrack == null ? null : audioTrack.getString("id"), + format.getString("qualityLabel"), + format.getString("audioQuality"), + format.getBoolean("isDrc", false), + format.getInt("width", -1), + format.getInt("height", -1), + format.getInt("bitrate", -1), + parseLong(format.get("contentLength")), + parseLong(format.get("approxDurationMs"))); + } + + private static long parseLong(@Nullable final Object value) { + if (value instanceof Number) { + return ((Number) value).longValue(); + } + if (value instanceof String) { + try { + return Long.parseLong((String) value); + } catch (final NumberFormatException ignored) { + return -1; + } + } + return -1; + } + + public boolean isAudio() { + return mimeType != null && mimeType.contains("audio"); + } + + public boolean isVideo() { + return mimeType != null && mimeType.contains("video"); + } + + public int getItag() { + return itag; + } + + public long getLastModified() { + return lastModified; + } + + @Nullable + public String getXtags() { + return xtags; + } + + @Nullable + public String getMimeType() { + return mimeType; + } + + @Nullable + public String getAudioTrackId() { + return audioTrackId; + } + + @Nullable + public String getQualityLabel() { + return qualityLabel; + } + + @Nullable + public String getAudioQuality() { + return audioQuality; + } + + public boolean isDrc() { + return drc; + } + + public int getWidth() { + return width; + } + + public int getHeight() { + return height; + } + + public int getBitrate() { + return bitrate; + } + + public long getContentLength() { + return contentLength; + } + + public long getApproxDurationMs() { + return approxDurationMs; + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrInfo.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrInfo.java new file mode 100644 index 000000000..b444dac3f --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrInfo.java @@ -0,0 +1,115 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Collections; +import java.util.List; + +public final class YoutubeSabrInfo { + @Nonnull + private final YoutubeSabrClientProfile profile; + @Nonnull + private final String videoId; + @Nonnull + private final String cpn; + @Nonnull + private final String clientVersion; + @Nullable + private final String visitorData; + @Nullable + private final String serverAbrStreamingUrl; + @Nullable + private final String videoPlaybackUstreamerConfig; + @Nonnull + private final List formats; + + YoutubeSabrInfo(@Nonnull final YoutubeSabrClientProfile profile, + @Nonnull final String videoId, + @Nonnull final String cpn, + @Nonnull final String clientVersion, + @Nullable final String visitorData, + @Nullable final String serverAbrStreamingUrl, + @Nullable final String videoPlaybackUstreamerConfig, + @Nonnull final List formats) { + this.profile = profile; + this.videoId = videoId; + this.cpn = cpn; + this.clientVersion = clientVersion; + this.visitorData = visitorData; + this.serverAbrStreamingUrl = serverAbrStreamingUrl; + this.videoPlaybackUstreamerConfig = videoPlaybackUstreamerConfig; + this.formats = formats; + } + + @Nonnull + public YoutubeSabrClientProfile getProfile() { + return profile; + } + + @Nonnull + public String getVideoId() { + return videoId; + } + + @Nonnull + public String getCpn() { + return cpn; + } + + @Nonnull + public String getClientVersion() { + return clientVersion; + } + + @Nullable + public String getVisitorData() { + return visitorData; + } + + @Nullable + public String getServerAbrStreamingUrl() { + return serverAbrStreamingUrl; + } + + @Nullable + public String getVideoPlaybackUstreamerConfig() { + return videoPlaybackUstreamerConfig; + } + + @Nonnull + public List getFormats() { + return Collections.unmodifiableList(formats); + } + + @Nullable + public YoutubeSabrFormat findBestAudioFormat() { + YoutubeSabrFormat best = null; + for (final YoutubeSabrFormat format : formats) { + if (format.isAudio() && (best == null || format.getBitrate() > best.getBitrate())) { + best = format; + } + } + return best; + } + + @Nullable + public YoutubeSabrFormat findBestVideoFormat() { + YoutubeSabrFormat best = null; + for (final YoutubeSabrFormat format : formats) { + if (format.isVideo() && (best == null || format.getHeight() > best.getHeight())) { + best = format; + } + } + return best; + } + + @Nullable + public YoutubeSabrFormat findFormatByItag(final int itag) { + for (final YoutubeSabrFormat format : formats) { + if (format.getItag() == itag) { + return format; + } + } + return null; + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrProbe.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrProbe.java new file mode 100644 index 000000000..5ee317790 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrProbe.java @@ -0,0 +1,531 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import com.grack.nanojson.JsonArray; +import com.grack.nanojson.JsonBuilder; +import com.grack.nanojson.JsonObject; +import com.grack.nanojson.JsonWriter; + +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.downloader.Response; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.exceptions.ParsingException; +import org.schabi.newpipe.extractor.localization.ContentCountry; +import org.schabi.newpipe.extractor.localization.Localization; +import org.schabi.newpipe.extractor.services.youtube.YoutubeJavaScriptPlayerManager; +import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper; +import org.schabi.newpipe.extractor.utils.JsonUtils; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public final class YoutubeSabrProbe { + private static final String PLAYER = "player"; + private static final String STREAMING_DATA = "streamingData"; + private static final String ADAPTIVE_FORMATS = "adaptiveFormats"; + + private YoutubeSabrProbe() { + } + + @Nonnull + public static YoutubeSabrInfo fetchSabrInfo(@Nonnull final String videoId, + @Nonnull final YoutubeSabrClientProfile profile, + @Nonnull final Localization localization, + @Nonnull final ContentCountry contentCountry) + throws IOException, ExtractionException { + return fetchSabrInfo(videoId, profile, localization, contentCountry, null, null); + } + + @Nonnull + public static YoutubeSabrInfo fetchSabrInfo(@Nonnull final String videoId, + @Nonnull final YoutubeSabrClientProfile profile, + @Nonnull final Localization localization, + @Nonnull final ContentCountry contentCountry, + @Nullable final String playerPoToken, + @Nullable final String visitorDataOverride) + throws IOException, ExtractionException { + final String cpn = YoutubeParsingHelper.generateContentPlaybackNonce(); + final JsonObject playerResponse = fetchPlayerResponse(videoId, profile, localization, + contentCountry, cpn, playerPoToken, visitorDataOverride); + final JsonObject streamingData = playerResponse.getObject(STREAMING_DATA); + if (streamingData == null) { + throw new SabrProtocolException("Player response has no streamingData for " + profile); + } + + final String serverAbrStreamingUrl = maybeDeobfuscateNParameter(videoId, + streamingData.getString("serverAbrStreamingUrl")); + final String ustreamerConfig = extractVideoPlaybackUstreamerConfig(playerResponse); + final String visitorData = visitorDataOverride == null || visitorDataOverride.isEmpty() + ? extractVisitorData(playerResponse) + : visitorDataOverride; + final JsonArray adaptiveFormats = streamingData.getArray(ADAPTIVE_FORMATS); + + return new YoutubeSabrInfo(profile, videoId, cpn, resolveClientVersion(profile), + visitorData, serverAbrStreamingUrl, ustreamerConfig, + YoutubeSabrFormat.fromAdaptiveFormats(adaptiveFormats)); + } + + @Nonnull + public static YoutubeSabrProbeResult probeFirstMediaResponse( + @Nonnull final String videoId, + @Nonnull final YoutubeSabrClientProfile profile, + @Nonnull final Localization localization, + @Nonnull final ContentCountry contentCountry) + throws IOException, ExtractionException { + final YoutubeSabrInfo info = fetchSabrInfo(videoId, profile, localization, contentCountry); + return probeFirstMediaResponse(info, localization); + } + + @Nonnull + public static YoutubeSabrProbeResult probeFirstMediaResponse( + @Nonnull final YoutubeSabrInfo info, + @Nonnull final Localization localization) + throws IOException, ExtractionException { + final YoutubeSabrFormat audioFormat = info.findBestAudioFormat(); + final YoutubeSabrFormat videoFormat = info.findBestVideoFormat(); + if (audioFormat == null || videoFormat == null) { + throw new SabrProtocolException("Could not select audio/video SABR formats"); + } + final String serverAbrStreamingUrl = info.getServerAbrStreamingUrl(); + if (serverAbrStreamingUrl == null || serverAbrStreamingUrl.isEmpty()) { + throw new SabrProtocolException("Missing serverAbrStreamingUrl"); + } + + final byte[] requestBody = YoutubeSabrRequestBuilder.buildFirstMediaRequest( + info, audioFormat, videoFormat); + return postMediaRequest(info, requestBody, 0, localization); + } + + @Nonnull + public static YoutubeSabrProbeResult probeFirstMediaResponse( + @Nonnull final YoutubeSabrInfo info, + @Nonnull final YoutubeSabrFormat audioFormat, + @Nonnull final YoutubeSabrFormat videoFormat, + @Nonnull final Localization localization) + throws IOException, ExtractionException { + return probeFirstMediaResponse(info, audioFormat, videoFormat, null, localization); + } + + @Nonnull + public static YoutubeSabrProbeResult probeFirstMediaResponse( + @Nonnull final YoutubeSabrInfo info, + @Nonnull final YoutubeSabrFormat audioFormat, + @Nonnull final YoutubeSabrFormat videoFormat, + @Nullable final YoutubeSabrStreamState streamState, + @Nonnull final Localization localization) + throws IOException, ExtractionException { + return probeFirstMediaResponse(info, audioFormat, videoFormat, streamState, null, + localization); + } + + @Nonnull + static YoutubeSabrProbeResult probeFirstMediaResponse( + @Nonnull final YoutubeSabrInfo info, + @Nonnull final YoutubeSabrFormat audioFormat, + @Nonnull final YoutubeSabrFormat videoFormat, + @Nullable final YoutubeSabrStreamState streamState, + @Nullable final String serverAbrStreamingUrlOverride, + @Nonnull final Localization localization) + throws IOException, ExtractionException { + final byte[] requestBody = YoutubeSabrRequestBuilder.buildFirstMediaRequest( + info, audioFormat, videoFormat, streamState); + return postMediaRequest(info, requestBody, 0, serverAbrStreamingUrlOverride, localization); + } + + @Nonnull + public static YoutubeSabrProbeResult probeFollowUpMediaResponse( + @Nonnull final YoutubeSabrInfo info, + @Nonnull final YoutubeSabrFormat audioFormat, + @Nonnull final YoutubeSabrFormat videoFormat, + @Nonnull final YoutubeSabrStreamState streamState, + final int requestNumber, + @Nonnull final Localization localization) + throws IOException, ExtractionException { + if (requestNumber <= 0) { + throw new SabrProtocolException("Follow-up request number must be positive"); + } + final byte[] requestBody = YoutubeSabrRequestBuilder.buildFollowUpMediaRequest( + info, audioFormat, videoFormat, streamState); + return postMediaRequest(info, requestBody, requestNumber, localization); + } + + @Nonnull + static YoutubeSabrProbeResult probeFollowUpMediaResponse( + @Nonnull final YoutubeSabrInfo info, + @Nonnull final YoutubeSabrFormat audioFormat, + @Nonnull final YoutubeSabrFormat videoFormat, + @Nonnull final YoutubeSabrStreamState streamState, + final int requestNumber, + @Nullable final String serverAbrStreamingUrlOverride, + @Nonnull final Localization localization) + throws IOException, ExtractionException { + if (requestNumber <= 0) { + throw new SabrProtocolException("Follow-up request number must be positive"); + } + final byte[] requestBody = YoutubeSabrRequestBuilder.buildFollowUpMediaRequest( + info, audioFormat, videoFormat, streamState); + return postMediaRequest(info, requestBody, requestNumber, serverAbrStreamingUrlOverride, + localization); + } + + @Nonnull + private static YoutubeSabrProbeResult postMediaRequest( + @Nonnull final YoutubeSabrInfo info, + @Nonnull final byte[] requestBody, + final int requestNumber, + @Nonnull final Localization localization) + throws IOException, ExtractionException { + return postMediaRequest(info, requestBody, requestNumber, null, localization); + } + + @Nonnull + private static YoutubeSabrProbeResult postMediaRequest( + @Nonnull final YoutubeSabrInfo info, + @Nonnull final byte[] requestBody, + final int requestNumber, + @Nullable final String serverAbrStreamingUrlOverride, + @Nonnull final Localization localization) + throws IOException, ExtractionException { + final String serverAbrStreamingUrl = serverAbrStreamingUrlOverride == null + || serverAbrStreamingUrlOverride.isEmpty() + ? info.getServerAbrStreamingUrl() + : serverAbrStreamingUrlOverride; + if (serverAbrStreamingUrl == null || serverAbrStreamingUrl.isEmpty()) { + throw new SabrProtocolException("Missing serverAbrStreamingUrl"); + } + + final Response response = NewPipe.getDownloader().post( + withSabrSessionParameters(serverAbrStreamingUrl, info.getCpn(), requestNumber), + buildSabrHeaders(info), requestBody, localization); + final String contentType = response.getHeader("Content-Type"); + if (contentType == null || !contentType.toLowerCase().contains("application/vnd.yt-ump")) { + throw new SabrProtocolException("Expected UMP response, got content type: " + + contentType + ", status=" + response.responseCode()); + } + + final byte[] rawResponseBody = response.rawResponseBody() == null + ? new byte[0] + : response.rawResponseBody(); + return new YoutubeSabrProbeResult(info, + SabrResponseDecoder.decode(rawResponseBody), response.responseCode(), + rawResponseBody.length, contentType); + } + + @Nonnull + private static JsonObject fetchPlayerResponse(@Nonnull final String videoId, + @Nonnull final YoutubeSabrClientProfile profile, + @Nonnull final Localization localization, + @Nonnull final ContentCountry contentCountry, + @Nonnull final String cpn, + @Nullable final String playerPoToken, + @Nullable final String visitorDataOverride) + throws IOException, ExtractionException { + final byte[] body = createPlayerBody(videoId, profile, localization, contentCountry, + cpn, playerPoToken, visitorDataOverride); + final String url = getInnertubeBaseUrl(profile) + PLAYER + "?" + + YoutubeParsingHelper.DISABLE_PRETTY_PRINT_PARAMETER; + final Response response = NewPipe.getDownloader().post(url, + buildPlayerHeaders(profile, visitorDataOverride), + body, localization); + return JsonUtils.toJsonObject(YoutubeParsingHelper.getValidJsonResponseBody(response)); + } + + @Nonnull + private static byte[] createPlayerBody(@Nonnull final String videoId, + @Nonnull final YoutubeSabrClientProfile profile, + @Nonnull final Localization localization, + @Nonnull final ContentCountry contentCountry, + @Nonnull final String cpn, + @Nullable final String playerPoToken, + @Nullable final String visitorDataOverride) + throws ParsingException { + final JsonBuilder builder = JsonObject.builder() + .object("context") + .object("client") + .value("clientName", profile.getClientName()) + .value("clientVersion", resolveClientVersion(profile)) + .value("hl", localization.getLocalizationCode()) + .value("gl", contentCountry.getCountryCode()) + .value("utcOffsetMinutes", 0); + + if (visitorDataOverride != null && !visitorDataOverride.isEmpty()) { + builder.value("visitorData", visitorDataOverride); + } + + if (profile == YoutubeSabrClientProfile.WEB || profile == YoutubeSabrClientProfile.SAFARI_WEB) { + builder.value("platform", "DESKTOP"); + } else if (profile == YoutubeSabrClientProfile.TVHTML5) { + builder.value("platform", "GAME_CONSOLE"); + } else { + builder.value("platform", "MOBILE"); + } + if (profile.getOsName() != null) { + builder.value("osName", profile.getOsName()); + } + if (profile.getOsVersion() != null) { + builder.value("osVersion", profile.getOsVersion()); + } + if (profile == YoutubeSabrClientProfile.ANDROID) { + builder.value("clientScreen", "WATCH") + .value("androidSdkVersion", 36); + } else if (profile == YoutubeSabrClientProfile.ANDROID_VR) { + builder.value("clientScreen", "WATCH") + .value("deviceMake", "Oculus") + .value("deviceModel", "Quest 3") + .value("androidSdkVersion", 32); + } else if (profile == YoutubeSabrClientProfile.IOS) { + builder.value("clientScreen", "WATCH") + .value("deviceMake", "Apple") + .value("deviceModel", "iPhone16,2"); + } else if (profile == YoutubeSabrClientProfile.WEB_EMBEDDED) { + builder.value("clientScreen", "EMBED"); + } + + builder.end() + .object("request") + .array("internalExperimentFlags") + .end() + .value("useSsl", true) + .end() + .object("user") + .value("lockedSafetyMode", false) + .end() + .end() + .object("playbackContext") + .object("contentPlaybackContext") + .value("referer", "https://www.youtube.com/watch?v=" + videoId) + .value("vis", 0) + .value("splay", false) + .value("lactMilliseconds", "-1") + .value("signatureTimestamp", + YoutubeJavaScriptPlayerManager.getSignatureTimestamp(videoId)) + .value("html5Preference", "HTML5_PREF_WANTS") + .end() + .end() + .value(YoutubeParsingHelper.CPN, cpn) + .value(YoutubeParsingHelper.VIDEO_ID, videoId) + .value(YoutubeParsingHelper.CONTENT_CHECK_OK, true) + .value(YoutubeParsingHelper.RACY_CHECK_OK, true); + + if (playerPoToken != null && !playerPoToken.isEmpty()) { + builder.object("serviceIntegrityDimensions") + .value("poToken", playerPoToken) + .end(); + } + + return JsonWriter.string(builder.done()).getBytes(StandardCharsets.UTF_8); + } + + @Nonnull + private static String getInnertubeBaseUrl(@Nonnull final YoutubeSabrClientProfile profile) { + if (profile == YoutubeSabrClientProfile.ANDROID + || profile == YoutubeSabrClientProfile.ANDROID_VR + || profile == YoutubeSabrClientProfile.IOS) { + return YoutubeParsingHelper.YOUTUBEI_V1_GAPIS_URL; + } + return YoutubeParsingHelper.YOUTUBEI_V1_URL; + } + + @Nonnull + private static Map> buildPlayerHeaders( + @Nonnull final YoutubeSabrClientProfile profile) throws IOException, ExtractionException { + return buildPlayerHeaders(profile, null); + } + + @Nonnull + private static Map> buildPlayerHeaders( + @Nonnull final YoutubeSabrClientProfile profile, + @Nullable final String visitorDataOverride) throws IOException, ExtractionException { + final Map> headers = new HashMap<>(); + headers.put("Content-Type", Collections.singletonList("application/json")); + if (visitorDataOverride != null && !visitorDataOverride.isEmpty()) { + headers.put("X-Goog-Visitor-Id", Collections.singletonList(visitorDataOverride)); + } + if (profile.getUserAgent() != null) { + headers.put("User-Agent", Collections.singletonList(profile.getUserAgent())); + } + if (profile == YoutubeSabrClientProfile.ANDROID + || profile == YoutubeSabrClientProfile.ANDROID_VR + || profile == YoutubeSabrClientProfile.IOS) { + headers.put("X-Goog-Api-Format-Version", Collections.singletonList("2")); + } else { + headers.put("Origin", Collections.singletonList("https://www.youtube.com")); + headers.put("Referer", Collections.singletonList("https://www.youtube.com")); + headers.put("X-YouTube-Client-Name", Collections.singletonList(profile.getClientId())); + headers.put("X-YouTube-Client-Version", + Collections.singletonList(resolveClientVersion(profile))); + YoutubeParsingHelper.addCookieHeader(headers); + } + return headers; + } + + @Nonnull + private static Map> buildSabrHeaders(@Nonnull final YoutubeSabrInfo info) { + final Map> headers = new HashMap<>(); + headers.put("Content-Type", Collections.singletonList("application/x-protobuf")); + headers.put("Accept", Collections.singletonList("application/vnd.yt-ump")); + headers.put("Accept-Encoding", Collections.singletonList("identity")); + if (info.getProfile().getUserAgent() != null) { + headers.put("User-Agent", Collections.singletonList(info.getProfile().getUserAgent())); + } + if (!isWebSabrProfile(info.getProfile()) + && info.getVisitorData() != null && !info.getVisitorData().isEmpty()) { + headers.put("X-Goog-Visitor-Id", Collections.singletonList(info.getVisitorData())); + } + if (isWebSabrProfile(info.getProfile())) { + headers.remove("Content-Type"); + headers.remove("Accept-Encoding"); + headers.put("Accept", Collections.singletonList("*/*")); + headers.put("Accept-Language", Collections.singletonList("en-US,en;q=0.9")); + headers.put("Origin", Collections.singletonList("https://www.youtube.com")); + headers.put("Referer", Collections.singletonList("https://www.youtube.com/")); + } + return headers; + } + + private static boolean isWebSabrProfile(@Nonnull final YoutubeSabrClientProfile profile) { + return profile.isWebLike() + || profile == YoutubeSabrClientProfile.WEB + || profile == YoutubeSabrClientProfile.SAFARI_WEB; + } + + @Nonnull + private static String withSabrSessionParameters(@Nonnull final String url, + @Nonnull final String cpn, + final int requestNumber) { + String result = appendQueryParameterIfMissing(url, "alr", "yes"); + result = appendQueryParameterIfMissing(result, "cpn", cpn); + return setQueryParameter(result, "rn", String.valueOf(requestNumber + 1)); + } + + @Nonnull + private static String appendQueryParameterIfMissing(@Nonnull final String url, + @Nonnull final String name, + @Nonnull final String value) { + if (url.contains("?" + name + "=") || url.contains("&" + name + "=")) { + return url; + } + return appendQueryParameter(url, name, value); + } + + @Nonnull + private static String appendQueryParameter(@Nonnull final String url, + @Nonnull final String name, + @Nonnull final String value) { + final String separator = url.contains("?") ? "&" : "?"; + return url + separator + name + "=" + value; + } + + @Nonnull + private static String setQueryParameter(@Nonnull final String url, + @Nonnull final String name, + @Nonnull final String value) { + final int fragmentIndex = url.indexOf('#'); + final String baseUrl = fragmentIndex < 0 ? url : url.substring(0, fragmentIndex); + final String fragment = fragmentIndex < 0 ? "" : url.substring(fragmentIndex); + final int queryIndex = baseUrl.indexOf('?'); + final String path = queryIndex < 0 ? baseUrl : baseUrl.substring(0, queryIndex); + final String query = queryIndex < 0 ? "" : baseUrl.substring(queryIndex + 1); + + final StringBuilder result = new StringBuilder(path).append('?'); + boolean wroteParameter = false; + for (final String parameter : query.split("&", -1)) { + if (parameter.isEmpty()) { + continue; + } + final int equalsIndex = parameter.indexOf('='); + final String parameterName = equalsIndex < 0 + ? parameter + : parameter.substring(0, equalsIndex); + if (parameterName.equals(name)) { + continue; + } + if (wroteParameter) { + result.append('&'); + } + result.append(parameter); + wroteParameter = true; + } + if (wroteParameter) { + result.append('&'); + } + return result.append(name).append('=').append(value).append(fragment).toString(); + } + + @Nonnull + private static String resolveClientVersion(@Nonnull final YoutubeSabrClientProfile profile) + throws ParsingException { + if (profile == YoutubeSabrClientProfile.WEB) { + try { + return YoutubeParsingHelper.getClientVersion(); + } catch (final Exception e) { + return profile.getClientVersion(); + } + } + return profile.getClientVersion(); + } + + @Nullable + private static String extractVisitorData(@Nonnull final JsonObject response) { + final JsonObject responseContext = response.getObject("responseContext"); + return responseContext == null ? null : responseContext.getString("visitorData"); + } + + @Nullable + private static String extractVideoPlaybackUstreamerConfig(@Nonnull final JsonObject response) { + JsonObject current = response.getObject("playerConfig"); + if (current == null) { + return null; + } + current = current.getObject("mediaCommonConfig"); + if (current == null) { + return null; + } + current = current.getObject("mediaUstreamerRequestConfig"); + if (current == null) { + return null; + } + return current.getString("videoPlaybackUstreamerConfig"); + } + + @Nullable + private static String maybeDeobfuscateNParameter(@Nonnull final String videoId, + @Nullable final String url) + throws ParsingException { + if (url == null || url.isEmpty()) { + return url; + } + final java.util.regex.Matcher queryMatcher = java.util.regex.Pattern.compile("([?&])n=([^&]+)") + .matcher(url); + if (queryMatcher.find()) { + final String encryptedN = java.net.URLDecoder.decode(queryMatcher.group(2), + StandardCharsets.UTF_8); + final org.schabi.newpipe.extractor.services.youtube.YoutubeApiDecoder.BatchDecodeResult result = + YoutubeJavaScriptPlayerManager.deobfuscateBatch(videoId, null, + Collections.singletonList(encryptedN)); + final String decryptedN = result.getNParameters().get(encryptedN); + if (decryptedN != null) { + return url.substring(0, queryMatcher.start(2)) + + java.net.URLEncoder.encode(decryptedN, StandardCharsets.UTF_8) + + url.substring(queryMatcher.end(2)); + } + } + + final java.util.regex.Matcher pathMatcher = java.util.regex.Pattern.compile("/n/([^/]+)") + .matcher(url); + if (!pathMatcher.find()) { + return url; + } + final String encryptedN = pathMatcher.group(1); + final org.schabi.newpipe.extractor.services.youtube.YoutubeApiDecoder.BatchDecodeResult result = + YoutubeJavaScriptPlayerManager.deobfuscateBatch(videoId, null, + Collections.singletonList(encryptedN)); + final String decryptedN = result.getNParameters().get(encryptedN); + return decryptedN == null ? url : url.replace("/n/" + encryptedN, "/n/" + decryptedN); + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrProbeResult.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrProbeResult.java new file mode 100644 index 000000000..9244bbb3b --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrProbeResult.java @@ -0,0 +1,49 @@ +package org.schabi.newpipe.extractor.services.youtube.sabr; + +import javax.annotation.Nonnull; + +public final class YoutubeSabrProbeResult { + @Nonnull + private final YoutubeSabrInfo info; + @Nonnull + private final SabrDecodedResponse decodedResponse; + private final int responseCode; + private final int responseBodyLength; + @Nonnull + private final String contentType; + + YoutubeSabrProbeResult(@Nonnull final YoutubeSabrInfo info, + @Nonnull final SabrDecodedResponse decodedResponse, + final int responseCode, + final int responseBodyLength, + @Nonnull final String contentType) { + this.info = info; + this.decodedResponse = decodedResponse; + this.responseCode = responseCode; + this.responseBodyLength = responseBodyLength; + this.contentType = contentType; + } + + @Nonnull + public YoutubeSabrInfo getInfo() { + return info; + } + + @Nonnull + public SabrDecodedResponse getDecodedResponse() { + return decodedResponse; + } + + public int getResponseCode() { + return responseCode; + } + + public int getResponseBodyLength() { + return responseBodyLength; + } + + @Nonnull + public String getContentType() { + return contentType; + } +} From fd8853eb84b1c9d681701530f7961cf766249c42 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 3 Jun 2026 00:02:28 +0200 Subject: [PATCH 10/13] feat(youtube): wire SABR into YoutubeStreamExtractor --- .../extractors/YoutubeStreamExtractor.java | 142 +++++++++++++++++- 1 file changed, 136 insertions(+), 6 deletions(-) diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeStreamExtractor.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeStreamExtractor.java index 72c9fd341..808e1bbb6 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeStreamExtractor.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeStreamExtractor.java @@ -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; @@ -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 allItagInfos = new ArrayList<>(); final int audioStartIndex = 0; @@ -874,6 +889,125 @@ private void ensureStreamsAreCached() throws ExtractionException { } } + /** + * Build session-based SABR streams from a SABR-only response. + * + *

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.

+ */ + 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()) { @@ -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() { From 02100af23dae2d8f1d0070b2b91ad3fd6a749578 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 3 Jun 2026 14:18:48 +0200 Subject: [PATCH 11/13] fix(sabr): bound cached media memory (drop byte[] clones) --- .../extractor/services/youtube/sabr/SabrMediaSegment.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrMediaSegment.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrMediaSegment.java index bca65b00f..892af0ce2 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrMediaSegment.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrMediaSegment.java @@ -10,7 +10,10 @@ public final class SabrMediaSegment { SabrMediaSegment(@Nonnull final SabrMediaHeader header, @Nonnull final byte[] data) { this.header = header; - this.data = data.clone(); + // No defensive copy: the collector hands over a freshly built array it does not retain. + // Media segments reach several MB (4K), so cloning here doubled peak memory and caused OOM + // under rapid switching. The array is treated as immutable from here on. + this.data = data; } @Nonnull @@ -18,9 +21,10 @@ public SabrMediaHeader getHeader() { return header; } + /** Read-only: callers must not mutate the returned array (no defensive copy, for memory). */ @Nonnull public byte[] getData() { - return data.clone(); + return data; } public int getLength() { From 41c8d4c05130eff85d0484a427587e2d518732b8 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 3 Jun 2026 14:23:36 +0200 Subject: [PATCH 12/13] fix(sabr): survive transient backoff interrupt, add per-round diagnostics --- .../youtube/sabr/SabrPoTokenProvider.java | 5 +- .../youtube/sabr/YoutubeSabrSession.java | 65 ++++++++++++++++--- .../youtube/sabr/YoutubeSabrStreamState.java | 3 +- 3 files changed, 58 insertions(+), 15 deletions(-) diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrPoTokenProvider.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrPoTokenProvider.java index 53167c31a..17beb6532 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrPoTokenProvider.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/SabrPoTokenProvider.java @@ -20,9 +20,8 @@ byte[] getPoToken(@Nonnull YoutubeSabrInfo info, throws IOException, ExtractionException; /** - * Same as {@link #getPoToken}, but when {@code forceRefresh} is true any cached token is - * discarded and a fresh one is minted — used when the server rejects a token that expired - * mid-playback. Default ignores the flag (no cache to bypass). + * Like {@link #getPoToken}, but {@code forceRefresh} drops the cached token and mints a fresh + * one. For when the server rejects a token that died mid-playback. Default impl ignores the flag. */ @Nullable default byte[] getPoToken(@Nonnull final YoutubeSabrInfo info, diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrSession.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrSession.java index be45cc0cf..82cb3ef9f 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrSession.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrSession.java @@ -25,6 +25,9 @@ public final class YoutubeSabrSession { // 32 MiB ≈ ~50s of 4K video, far more than the read-lag, so forward playback never starves. private static final long MAX_CACHE_BYTES = 32L * 1024 * 1024; private static final int MIN_CACHED_SEGMENTS = 6; + // SABR-DIAG: verbose per-round SABR diagnostics (per-itag media, status, backoff), gated by + // this flag. prints to stdout -> I/System.out in logcat. flip to false to silence. + private static final boolean DIAG = true; @Nonnull private final YoutubeSabrInfo info; @@ -182,6 +185,19 @@ public YoutubeSabrProbeResult fetchNextResponse(@Nonnull final Localization loca @Nonnull public List pumpOnce(@Nonnull final Localization localization) throws IOException, ExtractionException { + if (DIAG) { + final StringBuilder br = new StringBuilder(); + for (final SabrBufferedRange r : streamState.getBufferedRanges()) { + br.append('[').append(r.summarize()).append(']'); + } + System.out.println("SABR-DIAG >>send req=" + requestNumber + + " playerTms=" + streamState.getPlayerTimeMs() + + " aSeg=" + streamState.getMaxSegment(audioFormat) + + "/" + streamState.getEndSegment(audioFormat) + + " vSeg=" + streamState.getMaxSegment(videoFormat) + + "/" + streamState.getEndSegment(videoFormat) + + " buf=" + br); + } final YoutubeSabrProbeResult result = fetchNextResponse(localization); final SabrDecodedResponse decoded = result.getDecodedResponse(); final List integrityIssues = decoded.getIntegrityIssues(); @@ -196,10 +212,32 @@ public List pumpOnce(@Nonnull final Localization localization) final SabrMediaSegment prev = segmentCache.put(key, segment); if (prev == null && !segment.getHeader().isInitSegment()) { cacheOrder.addLast(key); - cachedBytes += segment.getData().length; + cachedBytes += segment.getLength(); } } evictCacheIfNeeded(); + if (DIAG) { + final Map segCount = new java.util.LinkedHashMap<>(); + final Map segBytes = new java.util.LinkedHashMap<>(); + for (final SabrMediaSegment s : segments) { + final int itag = s.getHeader().getItag(); + segCount.merge(itag, 1, Integer::sum); + segBytes.merge(itag, (long) s.getLength(), Long::sum); + } + final StringBuilder fmt = new StringBuilder(); + for (final Map.Entry e : segCount.entrySet()) { + fmt.append(" itag").append(e.getKey()).append('=').append(e.getValue()) + .append("seg/").append(segBytes.get(e.getKey()) / 1024).append("KB"); + } + System.out.println("SABR-DIAG req=" + requestNumber + + " aFmt=" + audioFormat.getItag() + " vFmt=" + videoFormat.getItag() + + " seg=" + segments.size() + fmt + + " status3=" + decoded.isProtectedNoMediaResponse() + + " backoffMs=" + decoded.getBackoffTimeMs() + + " reload=" + decoded.isReloadRequested() + + " err=" + (decoded.getSabrErrorDetails() != null) + + " cacheKB=" + (cachedBytes / 1024)); + } if (decoded.getSabrErrorDetails() != null) { throw new SabrProtocolException("SABR error: " + decoded.getSabrErrorDetails().summarize()); @@ -209,9 +247,9 @@ public List pumpOnce(@Nonnull final Localization localization) + decoded.summarizeNoMediaResponse()); } if (decoded.isProtectedNoMediaResponse()) { - // Best-effort: mint / bounded re-mint the token. Do NOT throw on a single protected - // response — status=3 is a normal pacing/protection state the server clears on a later - // request; the pump keeps trying, and the reader stall watchdog is the final give-up. + // mint / re-mint the token, best effort. don't throw on a single status=3: it's normal + // pacing, the server usually clears it next round. pump keeps trying; the stall watchdog + // is the real give-up. applyPoTokenForProtectedResponse(); } if (!segments.isEmpty()) { @@ -235,7 +273,7 @@ private void evictCacheIfNeeded() { } final SabrMediaSegment old = segmentCache.remove(oldKey); if (old != null) { - cachedBytes -= old.getData().length; + cachedBytes -= old.getLength(); } } } @@ -326,11 +364,18 @@ private static void sleepBackoff(final int backoffTimeMs) throws SabrProtocolExc if (ms == 0) { return; } + if (DIAG) { + System.out.println("SABR-DIAG backoff sleep " + ms + "ms (server=" + backoffTimeMs + ")"); + } try { Thread.sleep(ms); } catch (final InterruptedException e) { - Thread.currentThread().interrupt(); - throw new SabrProtocolException("Interrupted while waiting for SABR backoff", e); + // don't go fatal on a stray interrupt mid-backoff or we nuke the whole session and kill + // BOTH audio and video. just swallow it; the loop re-checks stop/idle next round and + // bails if playback's really gone. + if (DIAG) { + System.out.println("SABR-DIAG backoff interrupted (non-fatal, continuing)"); + } } } @@ -352,9 +397,9 @@ private boolean maybeApplyPoToken(final boolean forceRefresh) } /** - * React to a protected/no-media (status=3) response: mint a token, or — if one is already set - * but the server still rejects it (token expired mid-playback) — force a bounded re-mint. - * Returns false when no usable token could be applied (caller treats it as fatal). + * Handle a status=3 / no-media response: mint a token, or force a bounded re-mint if we already + * have one but the server still rejects it (expired mid-playback). Returns false if none could + * be applied (caller treats that as fatal). */ private boolean applyPoTokenForProtectedResponse() throws IOException, ExtractionException { if (maybeApplyPoToken(false)) { diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrStreamState.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrStreamState.java index b5a9bcd69..d87b45868 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrStreamState.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrStreamState.java @@ -513,8 +513,7 @@ private static final class FormatProgress { private final long lastModified; @Nullable private final String xtags; - // Written by the pump thread (ingest), read by the ExoPlayer loader threads (isBeyondEnd / - // isComplete / getEndSegment) — volatile for cross-thread visibility. + // pump thread writes it, ExoPlayer loader threads read it. volatile so they actually see it. private volatile boolean initReceived; private volatile int maxSegment; private int observedMaxSegment; From 2f842baaede96d9e6d2ac8c0a13c47617e95fef7 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 3 Jun 2026 21:58:21 +0200 Subject: [PATCH 13/13] feat(sabr): slower-track buffered edge for pump pacing --- .../services/youtube/sabr/YoutubeSabrSession.java | 13 +++++++------ .../youtube/sabr/YoutubeSabrStreamState.java | 5 +++++ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrSession.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrSession.java index 82cb3ef9f..e1dd1f6b1 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrSession.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrSession.java @@ -25,9 +25,9 @@ public final class YoutubeSabrSession { // 32 MiB ≈ ~50s of 4K video, far more than the read-lag, so forward playback never starves. private static final long MAX_CACHE_BYTES = 32L * 1024 * 1024; private static final int MIN_CACHED_SEGMENTS = 6; - // SABR-DIAG: verbose per-round SABR diagnostics (per-itag media, status, backoff), gated by - // this flag. prints to stdout -> I/System.out in logcat. flip to false to silence. - private static final boolean DIAG = true; + // SABR-DIAG: spammy per-round logging (media/status/backoff) for when SABR is being a diva. + // off by default; flip to true to watch it suffer in logcat. + private static final boolean DIAG = false; @Nonnull private final YoutubeSabrInfo info; @@ -283,6 +283,7 @@ public SabrMediaSegment getCachedSegment(@Nonnull final SabrSegmentRequest reque return segmentCache.get(cacheKey(request)); } + /** True once the requested media segment is known to be past the last segment of the stream. */ public boolean isBeyondEnd(@Nonnull final SabrSegmentRequest request) { if (request.isInitializationSegment()) { @@ -370,9 +371,9 @@ private static void sleepBackoff(final int backoffTimeMs) throws SabrProtocolExc try { Thread.sleep(ms); } catch (final InterruptedException e) { - // don't go fatal on a stray interrupt mid-backoff or we nuke the whole session and kill - // BOTH audio and video. just swallow it; the loop re-checks stop/idle next round and - // bails if playback's really gone. + // a random interrupt during a backoff must NOT kill the session, that drags audio AND + // video down with it in one shot. swallow it and carry on; the loop figures out by + // itself if playback is actually dead. if (DIAG) { System.out.println("SABR-DIAG backoff interrupted (non-fatal, continuing)"); } diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrStreamState.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrStreamState.java index d87b45868..06069149c 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrStreamState.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/YoutubeSabrStreamState.java @@ -137,6 +137,11 @@ public long getPlayerTimeMs() { return Math.max(audio.getBufferedEndMs(), video.getBufferedEndMs()); } + /** buffered end (ms) of the slower track = how far we can actually play. the weakest link wins. */ + public long getMinBufferedEndMs() { + return Math.min(audio.getBufferedEndMs(), video.getBufferedEndMs()); + } + public void setPlayerTimeMs(final long playerTimeMs) { playerTimeMsOverride = Math.max(0, playerTimeMs); }