Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
3b053e4
feat(player): SABR PO token via headless WebView
Priveetee Jun 5, 2026
cc554d2
feat(player): SABR session store and format selection
Priveetee Jun 5, 2026
2fe33cb
feat(player): SABR pump and datasource (reader-driven)
Priveetee Jun 5, 2026
e645821
feat(player): wire SABR into the player, resolver and load control
Priveetee Jun 5, 2026
8b55a70
fix(sabr): cap cached sessions to 2 to stop the cross-video black screen
Priveetee Jun 5, 2026
1c12209
feat(sabr): per-segment data source for the chunk-based source (tier2)
Priveetee Jun 5, 2026
f914f74
feat(sabr): seekable chunk-based MediaSource core (tier2)
Priveetee Jun 5, 2026
88af094
feat(sabr): wire chunk MediaSource into the resolver (tier2 wip)
Priveetee Jun 5, 2026
883661c
feat(sabr): self-contained webm/mp4 chunks, playback works (tier2)
Priveetee Jun 5, 2026
2815600
fix(sabr): track reader position so playback and seek keep feeding (t…
Priveetee Jun 5, 2026
1d6c535
chore(sabr): drop tier2 debug logging
Priveetee Jun 5, 2026
9d9de68
feat(sabr): respect the user-selected video quality and force AAC audio
Priveetee Jun 6, 2026
e92de45
perf(sabr): persist the PO token on disk and harden the mint timeout/…
Priveetee Jun 6, 2026
a65c5b3
fix(sabr): ignore ended tracks when reporting the buffered position
Priveetee Jun 6, 2026
d1e3216
fix(sabr): time segment stalls per-request so a throttled pump can't …
Priveetee Jun 6, 2026
dde6219
chore(sabr): remove the dead v1 byte-stream data source
Priveetee Jun 6, 2026
cf722e6
fix(sabr): adapt LoadControl and ChunkSampleStream to the media3 1.10…
Priveetee Jun 6, 2026
9b2f402
docs(sabr): clarify the AAC-over-Opus comment, separate the pump fals…
Priveetee Jun 6, 2026
f246f90
fix(sabr): rebuild the session when the user changes video quality/codec
Priveetee Jun 6, 2026
4a2d559
fix(sabr): keep a 30s back-buffer so short backward seeks land in cache
Priveetee Jun 6, 2026
8b72639
fix(sabr): re-fetch evicted segments on a backward seek past the back…
Priveetee Jun 6, 2026
440c7d8
fix(sabr): fall back to a decodable codec at the chosen resolution, n…
Priveetee Jun 6, 2026
672f621
fix(sabr): shrink the back-buffer when the cache is over budget so ev…
Priveetee Jun 6, 2026
6d0f998
fix(sabr): serve cold/forward seeks and rewind-after-end in the strea…
Priveetee Jun 11, 2026
96a6d90
fix(sabr): re-enable the video track on the live source when returnin…
Priveetee Jun 11, 2026
d7c1d0d
fix(player): recover from surface-released decoder failures instead o…
Priveetee Jun 11, 2026
224296d
fix(sabr): play the original audio track instead of the auto-dub
Priveetee Jun 12, 2026
fa151ea
fix(detail): stop reloading related videos on every resume
Priveetee Jun 12, 2026
a1435c3
feat(player): SABR multi-language audio track selector (show + switch)
Priveetee Jun 12, 2026
31f647e
fix(player): keep the playback position when switching the SABR audio…
Priveetee Jun 12, 2026
04570e0
fix(sabr): pre-load init metadata on cold-restore to avoid an audio d…
Priveetee Jun 12, 2026
2d26862
fix(player): guard NaN aspect ratio in ExpandableSurfaceView
Priveetee Jun 12, 2026
e9daa7a
fix(player): keep the SABR position on quality change and error recovery
Priveetee Jun 14, 2026
6cd5981
feat(sabr): stream the SABR POST response without buffering the whole…
Priveetee Jun 14, 2026
02c2e6e
fix(sabr): size the back-buffer by bytes so 4k keeps its read-ahead b…
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
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
android:banner="@mipmap/tv_banner"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:largeHeap="true"
android:logo="@mipmap/ic_launcher"
android:theme="@style/OpeningTheme"
android:resizeableActivity="true"
Expand Down
301 changes: 301 additions & 0 deletions app/src/main/assets/sabr_potoken_poc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,301 @@
/*
* SABR PO token POC — WebView pipeline (att mode).
*
* Ported from the local research mint (mint-po-token-browser.mjs), adapted to run inside an Android
* WebView instead of puppeteer. It must be injected AFTER https://www.youtube.com/ has finished
* loading (same-origin is required: att/get and GenerateIT are youtube.com endpoints, and the
* BotGuard interpreter is embedded in the challenge).
*
* Flow: read browser context -> att/get challenge -> run BotGuard VM -> snapshot -> GenerateIT
* integrity token -> mint a videoId-bound PO token -> hand the result back through the
* `SabrPocBridge` JavascriptInterface.
*
* INTERNAL / LOCAL POC ONLY. The minted PO token is session-bound; keep it out of any public log.
* API_KEY / REQUEST_KEY are the well-known public ecosystem constants, not secrets.
*/
(function () {
'use strict';

// YouTube ships a Trusted Types CSP (require-trusted-types-for 'script') that blocks
// new Function()/eval of the BotGuard interpreter. Installing an identity "default" policy makes
// Chromium route those sinks through it, restoring dynamic evaluation. If the CSP forbids
// creating the policy, loadBotGuard() will surface the eval error instead.
try {
if (window.trustedTypes && window.trustedTypes.createPolicy
&& !window.trustedTypes.defaultPolicy) {
window.trustedTypes.createPolicy('default', {
createHTML: function (value) { return value; },
createScript: function (value) { return value; },
createScriptURL: function (value) { return value; }
});
}
} catch (ttError) {
// ignore; surfaced later as an eval failure
}

var API_KEY = 'AIzaSyDyT5W0Jh49F30Pqqtyfdf7pDLFKLJoAnw';
var REQUEST_KEY = 'O43z0dpjhgX20SCx4KAo';

function report(result) {
try {
// eslint-disable-next-line no-undef
SabrPocBridge.onResult(JSON.stringify(result));
} catch (e) {
// Bridge not present (e.g. plain browser run): fall back to console.
try {
console.log('[sabr-poc] ' + JSON.stringify(result));
} catch (ignored) {
// nothing else we can do
}
}
}

function step(message) {
try { console.log('[sabr-poc] ' + message); } catch (e) { /* ignore */ }
}

function readVisitorData() {
var cfg = window.ytcfg;
var fromCfg = cfg && typeof cfg.get === 'function' ? cfg.get('VISITOR_DATA') : null;
if (fromCfg) {
return fromCfg;
}
var html = document.documentElement.innerHTML;
var marker = '"VISITOR_DATA":"';
var start = html.indexOf(marker);
if (start < 0) {
throw new Error('Could not find visitor data');
}
var from = start + marker.length;
var end = html.indexOf('"', from);
if (end < 0) {
throw new Error('Could not find visitor data end');
}
return html.slice(from, end);
}

function readClientVersion() {
var cfg = window.ytcfg;
var fromCfg = cfg && typeof cfg.get === 'function'
? cfg.get('INNERTUBE_CLIENT_VERSION') : null;
return fromCfg || '2.20260114.01.00';
}

function normalizeTrustedUrl(value) {
if (!value) {
throw new Error('Missing interpreter url');
}
return value.indexOf('//') === 0 ? 'https:' + value : value;
}

function fetchChallenge(ctx) {
var context = {
client: {
clientName: 'WEB',
clientVersion: ctx.clientVersion,
hl: 'en',
gl: 'US',
utcOffsetMinutes: 0,
visitorData: ctx.visitorData
}
};
return fetch('https://www.youtube.com/youtubei/v1/att/get?prettyPrint=false&alt=json', {
method: 'POST',
headers: {
'Accept': '*/*',
'Content-Type': 'application/json',
'X-Goog-Visitor-Id': ctx.visitorData,
'X-Youtube-Client-Version': ctx.clientVersion,
'X-Youtube-Client-Name': '1'
},
body: JSON.stringify({
engagementType: 'ENGAGEMENT_TYPE_UNBOUND',
context: context
})
}).then(function (response) {
return response.json().then(function (data) {
if (!response.ok || !data.bgChallenge) {
throw new Error('att/get failed status=' + response.status);
}
return data.bgChallenge;
});
});
}

function resolveInterpreter(challenge, userAgent) {
var embedded = challenge.interpreterJavascript
&& challenge.interpreterJavascript.privateDoNotAccessOrElseSafeScriptWrappedValue;
if (embedded) {
return Promise.resolve(embedded);
}
var url = normalizeTrustedUrl(
(challenge.interpreterJavascript
&& challenge.interpreterJavascript
.privateDoNotAccessOrElseTrustedResourceUrlWrappedValue)
|| (challenge.interpreterUrl
&& challenge.interpreterUrl
.privateDoNotAccessOrElseTrustedResourceUrlWrappedValue));
return fetch(url, { headers: { 'User-Agent': userAgent } }).then(function (response) {
return response.text().then(function (js) {
if (!response.ok || !js) {
throw new Error('interpreter fetch failed status=' + response.status);
}
return js;
});
});
}

function loadBotGuard(interpreterJavascript, program, globalName) {
return new Promise(function (resolve, reject) {
try {
new Function(interpreterJavascript)();
} catch (e) {
reject(new Error('interpreter eval failed: ' + e.message));
return;
}
var vm = window[globalName];
if (!vm || typeof vm.a !== 'function') {
reject(new Error('BotGuard VM missing init function'));
return;
}
var timeout = setTimeout(function () {
reject(new Error('BotGuard init timeout'));
}, 10000);
try {
vm.a(program, function (asyncSnapshotFunction) {
clearTimeout(timeout);
resolve({ asyncSnapshotFunction: asyncSnapshotFunction });
}, true, undefined, function () { }, [[], []]);
} catch (e) {
clearTimeout(timeout);
reject(new Error('BotGuard init threw: ' + e.message));
}
});
}

function snapshot(functions, webPoSignalOutput) {
return new Promise(function (resolve, reject) {
var timeout = setTimeout(function () {
reject(new Error('BotGuard snapshot timeout'));
}, 10000);
functions.asyncSnapshotFunction(function (response) {
clearTimeout(timeout);
resolve(response);
}, [undefined, undefined, webPoSignalOutput, undefined]);
});
}

function fetchIntegrityToken(botGuardResponse, userAgent) {
return fetch('https://www.youtube.com/api/jnn/v1/GenerateIT', {
method: 'POST',
headers: {
'content-type': 'application/json+protobuf',
'x-goog-api-key': API_KEY,
'x-user-agent': 'grpc-web-javascript/0.1',
'User-Agent': userAgent
},
body: JSON.stringify([REQUEST_KEY, botGuardResponse])
}).then(function (response) {
return response.json().then(function (data) {
var integrityToken = data[0];
if (typeof integrityToken !== 'string') {
throw new Error('GenerateIT failed status=' + response.status);
}
return integrityToken;
});
});
}

function base64ToU8(value) {
var normalized = value.replace(/-/g, '+').replace(/_/g, '/');
var padded = normalized + '==='.slice((normalized.length + 3) % 4);
var binary = atob(padded);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}

function u8ToBase64Url(value) {
var binary = '';
for (var i = 0; i < value.length; i++) {
binary += String.fromCharCode(value[i]);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}

function mint(webPoSignalOutput, integrityToken, identifier) {
var getMinter = webPoSignalOutput[0];
if (typeof getMinter !== 'function') {
return Promise.reject(new Error('Missing PO minter factory'));
}
return Promise.resolve(getMinter(base64ToU8(integrityToken))).then(function (mintCallback) {
if (typeof mintCallback !== 'function') {
throw new Error('Missing PO mint callback');
}
return Promise.resolve(mintCallback(new TextEncoder().encode(identifier)))
.then(u8ToBase64Url);
});
}

function run() {
step('run start, readyState=' + document.readyState + ' origin=' + location.origin);
var videoId = window.__SABR_POC_VIDEO_ID || 'aqz-KE-bpKQ';
var ctx = {
visitorData: readVisitorData(),
clientVersion: readClientVersion(),
userAgent: navigator.userAgent
};
step('context ok visitorLen=' + ctx.visitorData.length + ' clientVersion=' + ctx.clientVersion);
var webPoSignalOutput = [];
var integrityTokenLength = -1;
step('fetching att/get challenge...');
return fetchChallenge(ctx).then(function (challenge) {
step('challenge ok embedded='
+ !!(challenge.interpreterJavascript
&& challenge.interpreterJavascript.privateDoNotAccessOrElseSafeScriptWrappedValue));
return resolveInterpreter(challenge, ctx.userAgent).then(function (interpreterJs) {
step('interpreter resolved len=' + (interpreterJs ? interpreterJs.length : -1));
return loadBotGuard(interpreterJs, challenge.program, challenge.globalName);
});
}).then(function (functions) {
step('botguard loaded, taking snapshot...');
return snapshot(functions, webPoSignalOutput);
}).then(function (botGuardResponse) {
step('snapshot ok, calling GenerateIT...');
return fetchIntegrityToken(botGuardResponse, ctx.userAgent);
}).then(function (integrityToken) {
step('integrity token len=' + integrityToken.length + ', minting...');
integrityTokenLength = integrityToken.length;
return mint(webPoSignalOutput, integrityToken, videoId);
}).then(function (poToken) {
report({
ok: true,
videoId: videoId,
clientVersion: ctx.clientVersion,
visitorDataLength: ctx.visitorData.length,
integrityTokenLength: integrityTokenLength,
poTokenLength: poToken.length,
poToken: poToken,
userAgent: ctx.userAgent
});
});
}

function reportError(e) {
report({
ok: false,
error: (e && e.message) ? e.message : String(e),
errorName: e && e.name ? e.name : '',
stack: e && e.stack ? String(e.stack).slice(0, 400) : '',
userAgent: navigator.userAgent
});
}

try {
run().catch(reportError);
} catch (e) {
reportError(e);
}
})();
50 changes: 50 additions & 0 deletions app/src/main/java/org/schabi/newpipe/DownloaderImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
import org.schabi.newpipe.extractor.downloader.Downloader;
import org.schabi.newpipe.extractor.downloader.Request;
import org.schabi.newpipe.extractor.downloader.Response;
import org.schabi.newpipe.extractor.downloader.StreamingResponse;
import org.schabi.newpipe.extractor.localization.Localization;
import org.schabi.newpipe.extractor.exceptions.ParsingException;
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
import org.schabi.newpipe.extractor.services.bilibili.BilibiliService;
Expand All @@ -22,7 +24,9 @@
import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509TrustManager;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets;
import java.security.KeyManagementException;
Expand Down Expand Up @@ -305,6 +309,52 @@ public Response execute(@NonNull final Request request)
responseBodyToReturn, rawBodyBytes, latestUrl);
}

/**
* Streaming POST: returns the body as a stream (okhttp {@code byteStream()}) instead of reading
* it whole, so a large SABR media batch (50-150MB at 4K) is not buffered into one byte[] (that
* was OOM-ing the 512MB heap). Mirrors execute()'s request building; caller closes the result.
*/
@Override
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 Map<String, List<String>> hdrs = headers == null ? Collections.emptyMap() : headers;
final RequestBody requestBody = RequestBody.create(null,
dataToSend == null ? new byte[0] : dataToSend);
final okhttp3.Request.Builder requestBuilder = new okhttp3.Request.Builder()
.method("POST", requestBody).url(url);
if (!hdrs.containsKey("User-Agent")) {
requestBuilder.header("User-Agent", USER_AGENT);
}
final String cookies = getCookies(url);
if (!hdrs.containsKey("Cookie") && !cookies.isEmpty()) {
requestBuilder.header("Cookie", cookies);
}
for (final Map.Entry<String, List<String>> pair : hdrs.entrySet()) {
final List<String> values = pair.getValue();
if (values.size() > 1) {
requestBuilder.removeHeader(pair.getKey());
for (final String value : values) {
requestBuilder.addHeader(pair.getKey(), value);
}
} else if (values.size() == 1) {
requestBuilder.header(pair.getKey(), values.get(0));
}
}
final okhttp3.Response response = client.newCall(requestBuilder.build()).execute();
if (response.code() == 429) {
response.close();
throw new ReCaptchaException("reCaptcha Challenge requested", url);
}
final ResponseBody body = response.body();
final InputStream stream = body == null
? new ByteArrayInputStream(new byte[0]) : body.byteStream();
// StreamingResponse.close() closes this stream -> closes the okhttp body + connection.
return new StreamingResponse(response.code(), response.headers().toMultimap(), stream);
}

public CancellableCall executeAsync(@NonNull final Request request, @NonNull final Downloader.AsyncCallback callback) {
final String httpMethod = request.httpMethod();
final String url = request.url();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -399,11 +399,12 @@ public void onResume() {

setupBrightness();

if (currentInfo != null) {
if (tabSettingsChanged) {
tabSettingsChanged = false;
initTabs();
}
// Only rebuild the tabs when the tab settings actually changed. Doing it on every resume
// recreated the related-items (and description) fragments each time, so returning from the
// share sheet / Home reloaded the related videos. A new video goes through handleResult().
if (currentInfo != null && tabSettingsChanged) {
tabSettingsChanged = false;
initTabs();
updateTabs(currentInfo);
}

Expand Down
Loading