Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
004d638
feat(youtube): add DeliveryMethod.SABR
Priveetee Jun 2, 2026
b46cbfd
feat(sabr): UMP wire reader and proto codec
Priveetee Jun 2, 2026
a18ed6e
feat(sabr): UMP response parts - media and policy
Priveetee Jun 2, 2026
cbed5d0
feat(sabr): UMP response parts - context, onesie and misc
Priveetee Jun 2, 2026
a912bd8
feat(sabr): response decoder
Priveetee Jun 2, 2026
68e9dbc
feat(sabr): media segments and index parsers
Priveetee Jun 2, 2026
ecd4c7a
feat(sabr): request builder and client profile
Priveetee Jun 2, 2026
efa8911
feat(sabr): session, state and format selection
Priveetee Jun 2, 2026
993e87a
feat(sabr): info, formats, probe bootstrap and PO token provider
Priveetee Jun 2, 2026
82946f4
feat(youtube): wire SABR into YoutubeStreamExtractor
Priveetee Jun 2, 2026
1ac9bf6
fix(sabr): bound cached media memory (drop byte[] clones)
Priveetee Jun 3, 2026
8c15403
fix(sabr): survive transient backoff interrupt, add per-round diagnos…
Priveetee Jun 3, 2026
88c3d60
feat(sabr): slower-track buffered edge for pump pacing
Priveetee Jun 3, 2026
d63ce5e
fix(youtube): use the live web client version for the Safari player r…
Priveetee Jun 5, 2026
db268b0
feat(sabr): contiguous buffered ranges, play-head-aware eviction, rel…
Priveetee Jun 5, 2026
26e2e99
feat(sabr): support backward-seek re-requests (rewindBufferedTo + pre…
Priveetee Jun 6, 2026
feebae4
feat(sabr): reposition the buffered head on forward jumps (prepareFor…
Priveetee Jun 11, 2026
191f170
feat(sabr): expose audio track info and prefer the original language
Priveetee Jun 12, 2026
78325a4
feat(sabr): expose the audio track on built SABR streams
Priveetee Jun 12, 2026
f8ee718
fix(sabr): derive segment duration from the format when init metadata…
Priveetee Jun 12, 2026
09491c6
fix(sabr): clamp oversized webm elements so the vp9 segment index parses
Priveetee Jun 14, 2026
546c5c6
feat(sabr): add streaming UMP primitives for incremental response par…
Priveetee Jun 14, 2026
641482f
feat(sabr): stream the SABR response end to end instead of buffering …
Priveetee Jun 14, 2026
406dcfd
fix(sabr): collapse the segment cache to a window on seek to bound 4k…
Priveetee Jun 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -165,6 +166,24 @@ public Response post(final String url,
.build());
}

/**
* Like {@link #post(String, Map, byte[], Localization)} but returns the body as a stream instead
* of a buffered {@code byte[]}, so a large response (e.g. a SABR media batch, 50-150MB at 4K) is
* not held whole in memory. The default falls back to the buffered {@code post()} and wraps its
* body; an implementation that can stream (e.g. over okhttp) should override this. Caller closes.
*/
public StreamingResponse postStreaming(final String url,
@Nullable final Map<String, List<String>> headers,
@Nullable final byte[] dataToSend,
@Nullable final Localization localization)
throws IOException, ReCaptchaException {
final Response response = post(url, headers, dataToSend, localization);
final byte[] raw = response.rawResponseBody() == null
? new byte[0] : response.rawResponseBody();
return new StreamingResponse(response.responseCode(), response.responseHeaders(),
new ByteArrayInputStream(raw));
}

public CancellableCall postAsync(final String url,
@Nullable final Map<String, List<String>> headers,
@Nullable final byte[] dataToSend,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package org.schabi.newpipe.extractor.downloader;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.io.Closeable;
import java.io.IOException;
import java.io.InputStream;
import java.util.Collections;
import java.util.List;
import java.util.Map;

/**
* A streaming HTTP response: the body is exposed as an {@link InputStream} instead of a buffered
* {@code byte[]}, so a large response (e.g. a SABR media batch, which can be 50-150MB at 4K) is not
* held whole in memory. The caller MUST {@link #close()} it to release the underlying connection.
*/
public class StreamingResponse implements Closeable {
private final int responseCode;
@Nonnull
private final Map<String, List<String>> responseHeaders;
@Nonnull
private final InputStream body;

public StreamingResponse(final int responseCode,
@Nullable final Map<String, List<String>> responseHeaders,
@Nonnull final InputStream body) {
this.responseCode = responseCode;
this.responseHeaders = responseHeaders == null ? Collections.emptyMap() : responseHeaders;
this.body = body;
}

public int responseCode() {
return responseCode;
}

@Nonnull
public InputStream body() {
return body;
}

/** First value for a header name (case-insensitive), or {@code null}. */
@Nullable
public String getHeader(final String name) {
for (final Map.Entry<String, List<String>> entry : responseHeaders.entrySet()) {
final String key = entry.getKey();
if (key != null && key.equalsIgnoreCase(name) && !entry.getValue().isEmpty()) {
return entry.getValue().get(0);
}
}
return null;
}

@Override
public void close() throws IOException {
body.close();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1514,7 +1514,8 @@ public static JsonBuilder<JsonObject> prepareTvHtml5EmbedJsonBuilder(
@Nonnull
public static JsonBuilder<JsonObject> prepareSafariJsonBuilder(
@Nonnull final Localization localization,
@Nonnull final ContentCountry contentCountry) {
@Nonnull final ContentCountry contentCountry)
throws IOException, ExtractionException {
return JsonObject.builder()
.object("context")
.object("client")
Expand All @@ -1524,7 +1525,7 @@ public static JsonBuilder<JsonObject> prepareSafariJsonBuilder(
.value("gl", contentCountry.getCountryCode())
.value("userAgent", SAFARI_USER_AGENT)
.value("clientName", "WEB")
.value("clientVersion", SAFARI_CLIENT_VERSION)
.value("clientVersion", getClientVersion())
.end()
.end();
}
Expand All @@ -1535,7 +1536,8 @@ public static byte[] createSafariPlayerBody(
@Nonnull final ContentCountry contentCountry,
@Nonnull final String videoId,
@Nonnull final Integer sts,
@Nonnull final String contentPlaybackNonce) {
@Nonnull final String contentPlaybackNonce)
throws IOException, ExtractionException {
return JsonWriter.string(
prepareSafariJsonBuilder(localization, contentCountry)
.object("playbackContext")
Expand All @@ -1561,7 +1563,7 @@ public static CancellableCall getSafariPostResponseAsync(final String endpoint,
headers.put("Content-Type", singletonList("application/json"));
headers.put("User-Agent", singletonList(SAFARI_USER_AGENT));
headers.put("X-YouTube-Client-Name", singletonList("1"));
headers.put("X-Youtube-Client-Version", singletonList(SAFARI_CLIENT_VERSION));
headers.put("X-Youtube-Client-Version", singletonList(getClientVersion()));

addLoggedInHeaders(headers);

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

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

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

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

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

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

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

if (itagItem.itagType == ItagItem.ItagType.AUDIO) {
final AudioStream.Builder builder = new AudioStream.Builder()
.setContent(serverAbrStreamingUrl, false)
.setMediaFormat(itagItem.getMediaFormat())
.setAverageBitrate(itagItem.getAverageBitrate())
.setItagItem(itagItem)
.setDeliveryMethod(DeliveryMethod.SABR);
// Multi-track audio: the same itag is served once per language. Carry the track
// info so the player can show a language selector, and key the id on (itag,
// track) so the languages aren't collapsed into one by the dedup below.
String streamId = id;
if (formatData.has("audioTrack")) {
final JsonObject audioTrack = formatData.getObject("audioTrack");
if (audioTrack.has("id")) {
final String trackId = audioTrack.getString("id");
final String displayName = audioTrack.getString("displayName");
final String langPart = trackId.split("\\.")[0];
final boolean isOriginal = displayName != null
&& (displayName.contains("original")
|| displayName.contains("yokuqala"));
builder.setAudioTrackId(trackId)
.setAudioTrackName(displayName != null ? displayName
: (isOriginal ? langPart + " (original)" : langPart))
.setAudioLocale(langPart.split("-")[0]);
streamId = id + "-" + trackId;
}
}
final String audioStreamId = streamId;
final AudioStream stream = builder.setId(audioStreamId).build();
// Dedup by id (itag, or itag+track when multi-track), 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 -> audioStreamId.equals(s.getId()))) {
cachedAudioStreams.add(stream);
}
} else if (itagItem.itagType == ItagItem.ItagType.VIDEO_ONLY) {
final String resolution = itagItem.getResolutionString();
final VideoStream stream = new VideoStream.Builder()
.setId(id)
.setContent(serverAbrStreamingUrl, false)
.setMediaFormat(itagItem.getMediaFormat())
.setIsVideoOnly(true)
.setItagItem(itagItem)
.setResolution(resolution != null ? resolution : EMPTY_STRING)
.setDeliveryMethod(DeliveryMethod.SABR)
.build();
if (cachedVideoOnlyStreams.stream().noneMatch(s -> id.equals(s.getId()))) {
cachedVideoOnlyStreams.add(stream);
}
}
} catch (final Exception e) {
// Skip unknown itags or malformed formats; do not fail the whole extraction.
}
}

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

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

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

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

private void tryExtractHlsStreams(final String videoId) throws ExtractionException {
final String hlsManifestUrl = getHlsManifestUrlFromStreamingData();
if (hlsManifestUrl.isEmpty()) {
Expand Down Expand Up @@ -1401,12 +1556,8 @@ public void onSuccess(Response response) throws ExtractionException {
checkPlayabilityStatus(playerResponse.getObject("playabilityStatus"), videoId);
setStreamType();

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

private boolean isSabrOnlyResponse() {
Expand Down
Loading