Skip to content
Snippets Groups Projects
YoutubeScript.js 465 KiB
Newer Older
Kelvin's avatar
Kelvin committed
		sourceObj.height);
	const urlPrefix = (isVideo) ?
		"https://grayjay.internal/video" :
		"https://grayjay.internal/audio";
	const dash = generateWEBMDash(webmHeader, 
		urlPrefix + "/internal/segment.webm?segIndex=$Number$", 
		urlPrefix + "/internal/init.webm");
	
	return [dash, umpResp, webmHeader];
}

function generateWEBMDash(webm, templateUrl, initUrl) {
	const duration = splitMS(webm.duration);
	const durationFormatted = `PT${duration.hours}H${duration.minutes}M${duration.seconds}.${((duration.miliseconds + "").padStart(3, '0'))}S`;

	let repCounter = 1;
	let mpd = `<?xml version="1.0" encoding="UTF-8"?>\n`;
	mpd += xmlTag("MPD", {
		"xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance",
		"xmlns": "urn:mpeg:dash:schema:mpd:2011",
		"xsi:schemaLocation": "urn:mpeg:dash:schema:mpd:2011 DASH-MPD.xsd",
		"profiles": "urn:mpeg:dash:profile:isoff-live:2011",
		"minBufferTime": "PT1.5S",
		"type": "static",
		"mediaPresentationDuration": durationFormatted
	}, (indent)=>
		xmlTag("Period", {id: "0", duration: durationFormatted}, (indent) =>
			xmlTag("AdaptationSet", {segmentAlignment: "true"}, (indent)=>
				xmlTag("Representation", 
					(webm.mimeType.startsWith("video/")) ? 
						{id: "1", mimeType: webm.mimeType, codecs: webm.codec, startWithSAP: "1", bandwidth: "800000", width: webm.width, height: webm.height}:
						{id: "2", mimeType: webm.mimeType, codecs: webm.codec, startWithSAP: "1", bandwidth: "800000", audioSamplingRate: webm.samplingFrequency},(indent)=>
						xmlTag("SegmentTemplate", {timescale: webm.timescale / 1000, startNumber: "1", 
								media: templateUrl, 
								duration: webm.duration,
Kelvin's avatar
Kelvin committed
								initialization: initUrl} , (indent)=>
Kelvin's avatar
Kelvin committed
							xmlTag("SegmentTimeline", {}, (indent)=>
								webm.cues.map((cue, i)=>
									xmlTag("S", {t: cue, d: (webm.cues.length > i + 1) ? webm.cues[i + 1] - cue : webm.durationCueTimescale - cue}, undefined, indent + " ")
								).join("")
							,indent + " ")
Kelvin's avatar
Kelvin committed
						,indent + " ")
Kelvin's avatar
Kelvin committed
				,indent + " ")
			, indent + " ")
		, indent + " ")
	, "");

	return mpd;
}
function splitMS(ms) {
	const hours = Math.floor(ms / (60 * 60 * 1000));
	ms -= hours * (60 * 60 * 1000);
	const minutes = Math.floor(ms / (60 * 1000));
	ms -= minutes * (60 * 1000);
	const seconds = Math.floor(ms / 1000);
	ms -= seconds * 1000;
	return {
		hours: hours,
		minutes: minutes,
		seconds: seconds,
		miliseconds: ms
	};
}
function xmlTag(tag, attributes, nested, indent) {
	indent = indent ?? "";
	let prefix = indent + "<" + tag;
	const attrKeys = (attributes) ? Object.keys(attributes) : [];
	if(attrKeys && attrKeys.length > 0) {
		prefix += " " + attrKeys.map(x=>x + "=\"" + attributes[x] + "\"").join(" ");
	}
	if(!!nested) {
		return prefix + ">\n" + 
			nested(indent + " ") + 
			indent + "</" + tag + ">\n";
	}
	else
		return prefix + "/>\n";
}

class TestYTABRVideoSource extends DashManifestRawSource {
    constructor(obj, url, sourceObj) {
		super(obj);
		this.url = url;
		this.url = "https://dash.akamaized.net/akamai/bbb_30fps/bbb_30fps.mpd";
		this.abrUrl = url;
		this.sourceObj = sourceObj;
    }

	generate() {
		const dash = http.GET("https://dash.akamaized.net/akamai/bbb_30fps/bbb_30fps.mpd", {});
		return dash.body;
	}
	getRequestExecutor() {
		return new YTABRExecutor(this.abrUrl, this.sourceObj);
	}

}

const KB_SIZE = 1000;
const MB_SIZE = 1000 * KB_SIZE;
const GB_SIZE = 1000 * MB_SIZE;
class ReusableBuffers {
	constructor(size, count) {
		this.maxSize = size;
		this.maxCount = count;
		this.buffers = [];
	}

	freeAll() {
		for(let buffer of this.buffers) {
			buffer.data = undefined;
			buffer.tag = undefined;
		}
	}
	freeTag(tag) {
		const buffers = this.buffers.filter(x=>x.tag == tag);
		for(let buffer of buffers) {
			buffer.data = undefined;
			buffer.tag = undefined;
		}
	}
	free(toFree) {
		const buffer = this.buffers.find(x=>x.data == toFree);
		if(buffer) {
			buffer.data = undefined;
			buffer.tag = undefined;
		}
	}

	getBuffer(size, tag) {
		log("Reusable Buffer [" + size + "]");
		if(size > this.maxSize)
			throw new ScriptException("Requested reusable buffer above the max buffer size (" + size + " > " + this.maxSize + ")");
		
		for(let buffer of this.buffers) {
			if(!buffer.data) {
				buffer.data = new Uint8Array(buffer.buffer, 0, size);
				buffer.tag = tag;
				return buffer.data;
			}
		}
		if(this.buffers.length < this.maxCount) {
			log("Allocated new resuable buffer (total: " + ((this.buffers.length + 1) * this.maxSize)/MB_SIZE + "MB)");
			const newBuffer = new ArrayBuffer(this.maxSize);
			const newData = new Uint8Array(newBuffer, 0, size);
			this.buffers.push({
				buffer: newBuffer,
				data: newData,
				tag: tag
			});
			return newData;
		}
		throw new ScriptException("Ran out of reusable memory (" + this.maxCount + ")");
	}
}

let _reusableBufferVideo = undefined;
let _reusableBufferAudio = undefined;
function getMediaReusableVideoBuffers() {
	if(!_reusableBufferVideo)
		_reusableBufferVideo = new ReusableBuffers(20 * MB_SIZE, 10);
	return _reusableBufferVideo;
}
function getMediaReusableAudioBuffers() {
	if(!_reusableBufferAudio)
		_reusableBufferAudio = new ReusableBuffers(2 * MB_SIZE, 10);
	return _reusableBufferAudio;
}
const useReusableBuffers = false;

let executorCounter = 0;
let _executorsVideo = [];
let _executorsAudio = [];
class YTABRExecutor {
	constructor(url, source, ustreamerConfig, header, initialUmp) {
		this.executorId = executorCounter++;
		this.source = source;
Kelvin's avatar
Kelvin committed
		this.header = header;
		this.initialUmp = initialUmp;
		this.abrUrl = url;
		this.ustreamerConfig = ustreamerConfig;
		this.lastRequest = 0;
		this.requestStarted = (new Date()).getTime();
		this.lastAction = (new Date()).getTime() - (Math.random() * 1000 * 5);
Kelvin's avatar
Kelvin committed
		this.segmentOffsets = undefined;
Kelvin's avatar
Kelvin committed
		log("UMP New executor: " + source.name + " - " + source.mimeType + " (segments: " + header?.cues?.length + ")");
		log("UMP Cues: " + header?.cues?.join(", "));
Kelvin's avatar
Kelvin committed
		this.isVideo = source.mimeType.startsWith("video/");
Kelvin's avatar
Kelvin committed
		if(source.mimeType.startsWith("video/")) {
			this.urlPrefix = "https://grayjay.internal/video";
			this.reusableBuffer = (useReusableBuffers) ? 
				getMediaReusableVideoBuffers() : undefined;
			this.type = "video";
			_executorsVideo.push(this);
			if(_executorsVideo.length > 2) {
				log("LEAKED EXECUTOR DETECTED?");
			}
		}
		else {
			this.urlPrefix = "https://grayjay.internal/audio";
			this.reusableBuffer = (useReusableBuffers) ? 
				getMediaReusableAudioBuffers() : undefined;
			this.type = "audio";
			_executorsAudio.push(this);
		}
		this.segments = {};
		if(initialUmp)
		{
			for(let segment of Object.keys(initialUmp.streams)) {
Kelvin's avatar
Kelvin committed
				const stream = initialUmp.streams[segment];
				if(stream.itag == this.itag) {
					log(`Caching initial Segment: itag:${stream.itag}, segmentIndex: ${stream.segmentIndex}, segmentLength: ${stream.segmentSize}, completed: ${stream.completed}`)
					this.cacheSegment(initialUmp.streams[segment]);
				}
Kelvin's avatar
Kelvin committed
	getOffset(index) {
		if(this.segmentOffset && this.segmentOffset.actual <= index)
			return this.segmentOffset.offset;
		return 0;
	}
	registerOffset(index, found) {
		this.segmentOffset = {index: index, actual: found, offset: found - index};
	}
Kelvin's avatar
Kelvin committed
	findSegmentTime(index) {
		if(this.header && this.header.cues) {
			if(this.header.cues.length > index) {
				const time = this.header.cues[index];
				if(index > 0 && time == 0) {
					log("UMP Cues: " + this.header.cues.join(", "));
					throw new ScriptException("Zero time for non-zero segment?");
				}
				return time;
			}
			else
				throw new ScriptException("UMP: Segment index out of bound? " + this.header.cues.length + " > " + index)
		}
		throw new ScriptException("Missing initialHeader?");
	}

	cacheSegment(segment) {
Kelvin's avatar
Kelvin committed
		this.segments[segment.segmentIndex - this.getOffset(segment.segmentIndex)] = segment;
Kelvin's avatar
Kelvin committed
	}
	getCachedSegmentCount() {
		return Object.keys(this.segments).length;
	}
	getCachedSegment(index) {
		return this.segments[index];
	}
	freeOldSegments(index) {
Kelvin's avatar
Kelvin committed
		index = parseInt(index);
Kelvin's avatar
Kelvin committed
		const reusable = this.reusableBuffer;
		for(let key of Object.keys(this.segments)) {
Kelvin's avatar
Kelvin committed
			key = parseInt(key);

Kelvin's avatar
Kelvin committed
			if(key < index || key > index + 7) {
Kelvin's avatar
Kelvin committed
				log("UMP [" + this.type + "]: disposing segment " + key + " (<" + index + " || >" + (index + 6) + ")");
Kelvin's avatar
Kelvin committed
				reusable?.free(this.segments[key].data);
				const segment = this.segments[key];
				if(segment) {
					delete segment.data;
				}
				delete this.segments[key];
			}
		}
	}
	freeAllSegments() {
		const reusable = this.reusableBuffer;
		for(let key of Object.keys(this.segments)) {
			reusable?.free(this.segments[key].data);
			delete this.segments[key];
		}
	}

	cleanup() {
		log("UMP: Cleaning up!");
		this.initialUmp = undefined;
		this.header = undefined;
		if(this.type == "video") {
			const index = _executorsVideo.indexOf(this);
			const removed = _executorsVideo.splice(index, 1);
			if(removed)
				log("Remaining video executors: " + _executorsVideo.length);
		}
		else {
			const index = _executorsAudio.indexOf(this);
			_executorsVideo.splice(index, 1);
			log("Remaining audio executors: " + _executorsAudio.length);
		}
		this.freeAllSegments();
	}

Kelvin's avatar
Kelvin committed
	executeRequest(url, headers, retryCount, overrideSegment) {
		if(!retryCount)
			retryCount = 0;
Kelvin's avatar
Kelvin committed
		log("UMP: " + url + "");
		const u = new URL(url);
		const isInternal = u.pathname.startsWith('/internal');
		const isInit = u.pathname.startsWith('/internal/init');
Kelvin's avatar
Kelvin committed
		let segment = u.searchParams.has("segIndex") ? u.searchParams.get("segIndex") : 0;
		let time = (segment > 0) ? this.findSegmentTime(segment - 1) : 0;
		if(overrideSegment && overrideSegment > 0) {
			const oldTime = time;
			time = this.findSegmentTime(overrideSegment - 1);
			log("UMP [" + this.type + "], overriding timestamp " + oldTime + " => " + time);
		}
Kelvin's avatar
Kelvin committed

		this.freeOldSegments(segment);
		const cached = this.getCachedSegment(segment);
		if(cached) {
			if(cached.data) {
				log("UMP [" + this.type + "] Cached segment " + segment + " (" + this.getCachedSegmentCount() + " remaining)");
				return cached.data;
			}
			else
				log("UMP [" + this.type + "] Cached segment " + segment + " was undefined, refetching");
		}

		log("UMP [" + this.type + "] requesting segment: " + segment + ", time: " + time + ", itag: " + this.itag);
Kelvin's avatar
Kelvin committed
		if(overrideSegment)
			log("UMP [" + this.type + "] requesting with overrided segment: " + overrideSegment)
Kelvin's avatar
Kelvin committed
		const now = (new Date()).getTime();
Kelvin's avatar
Kelvin committed
		const initialReq = getVideoPlaybackRequest(this.source, this.ustreamerConfig, time, (overrideSegment) ? overrideSegment : segment, this.lastRequest, this.lastAction, now, undefined, -6);
Kelvin's avatar
Kelvin committed
		const postData = initialReq.serializeBinary();
		const initialResp = http.POST(this.abrUrl, postData, {
			"Origin": "https://www.youtube.com",
			"Accept": "*/*",
			"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36"
		}, false, true);
		if(!initialResp.isOk)
			throw new ScriptException("Failed initial stream request [ " + initialResp.code + "]");

		const data = initialResp.body;
		let byteArray = undefined;
		if(data instanceof ArrayBuffer)
			byteArray = new Uint8Array(data);
		else if(data instanceof Int8Array)
			byteArray = new Uint8Array(data.buffer);
		else {
			byteArray = Uint8Array.from(data);
		}

		const umpResp = new UMPResponse(byteArray, this.reusableBuffer);
		
		let streamsArr = [];
		for(let key of Object.keys(umpResp.streams)) {
Kelvin's avatar
Kelvin committed
			const stream = umpResp.streams[key]
			if(stream.itag == this.itag && stream.segmentIndex >= segment)
				streamsArr.push(stream);
			else
				log(`IGNORING itag:${stream.itag}, segmentIndex: ${stream.segmentIndex}, segmentLength: ${stream.segmentSize}, completed: ${stream.completed}`)
		}
		log("UMP [" + this.type + "] stream resps: \n" + streamsArr
Kelvin's avatar
Kelvin committed
			.map(x=>`itag:${x.itag}, segmentIndex: ${x.segmentIndex}, segmentLength: ${x.segmentSize}, completed: ${x.completed}`)
Kelvin's avatar
Kelvin committed
			.join("\n"));

		this.lastRequest = (new Date()).getTime();

Kelvin's avatar
Kelvin committed

		const stream = streamsArr[0];
		if(!stream)
			throw new ScriptException("No streams for requesting segment " + segment + ((overrideSegment && overrideSegment > 0) ? (", override: " + overrideSegment) : ""));
		const expectedSegment = parseInt(segment) + parseInt(this.getOffset(stream.segmentIndex));
		log("Expected segment " + expectedSegment + " got " + stream.segmentIndex);
		if(stream && stream.segmentIndex != expectedSegment) {
			log("Retrieved wrong segment: " + stream.segmentIndex + " != " + segment + ", retrying (" + (retryCount + 1) + ")") 
			if(true) {
				let diff = stream.segmentIndex - segment;
				if(diff < 0)
					throw new ScriptException("Illegal negative offset");
				else {
					const doBackrequests = false;
					if(!doBackrequests) {
						log("Segment offset detected of " + diff + " (" + stream.segmentIndex + " - " + segment + ")");
						this.registerOffset(parseInt(segment), parseInt(stream.segmentIndex));
					}
					else {
						log("Requesting older data using offset (" + diff + ")");
						if(retryCount == 0) {
							for(let stream of streamsArr) {
								log("Caching future segment " + stream.segmentIndex);
								if(stream.completed)
									this.cacheSegment(stream);
							}
						}
						if(retryCount < 3) {
							return this.executeRequest(url, headers, retryCount + 1, (parseInt(segment) - diff));
						}
						else {
							throw new ScriptException("Too many back-requests");
						}
					}
				}
			}
			else {
				if(true || retryCount >= 2)
					throw new ScriptException("Retrieved wrong segment: " + stream.segmentIndex + " != " + segment + " (" + retryCount + " attempts)");
				else { //Disabled retry for now, doesnt make a diff.
					log("Retrieved wrong segment: " + stream.segmentIndex + " != " + segment + ", retrying (" + (retryCount + 1) + ")");
					return this.executeRequest(url, headers, retryCount + 1);
				}
			}
		}

		for(let stream of streamsArr) {
			if(stream.completed)
				this.cacheSegment(stream);
Kelvin's avatar
Kelvin committed
		}

		if(data instanceof ArrayBuffer) {
			log("Clearing POST ArrayBuffer?");
		}
		
		if(!stream || !stream.data)
			throw new ScriptException("NO STREAMDATA FOUND (" + Object.keys(umpResp.streams).join(", ") + "): " + !!umpResp.streams[0]?.data);
Kelvin's avatar
Kelvin committed
		
		log("UMP [" + this.type + "]: segment " + segment + " - " + stream.data?.length);
Kelvin's avatar
Kelvin committed
		return stream.data;
	}
}
function getVideoPlaybackRequest(source, ustreamerConfig, playerPosMs, segmentIndex, lastRequest, lastAction, requestStarted, playbackCookie) {
	const vidReq = new pb.VideoPlaybackRequest_pb.VideoPlaybackRequest();
	const ustreamerBytes = Uint8Array.from(atob(ustreamerConfig.replaceAll("_", "/").replaceAll("-", "+")), c => c.charCodeAt(0))
	vidReq.setVideoplaybackustreamerconfig(ustreamerBytes);
	
	const clientInfo = new pb.VideoPlaybackRequest_pb.ClientInfo();
	clientInfo.setClientname(1);
	clientInfo.setClientversion("2.20240808.00.00");
	clientInfo.setOsname("Windows");
	clientInfo.setOsversion("10.0");

	//Info
	const info = new pb.VideoPlaybackRequest_pb.VideoPlaybackRequestInfo();
	if(source.width) {
		info.setDesiredwidth(source.width);
		info.setDesiredheight(source.height);
		info.setVideoheightmaybe(source.height);
		info.setVideoheight2maybe(source.height);
		info.setSelectedqualityheight(source.height);
	}
Kelvin's avatar
Kelvin committed
	info.setG7(8613683);
Kelvin's avatar
Kelvin committed
	info.setCurrentvideopositionms(playerPosMs);
	if(lastRequest > 0)
		info.setTimesincelastrequestms((new Date().getTime() - lastRequest));
	info.setTimesincelastactionms(Math.floor((new Date()).getTime() - lastAction));
	info.setDynamicrangecompression(true);
	info.setLatencymsmaybe(Math.floor(Math.random() * 90 + 7));
	info.setLastmanualdirection(0);
	info.setTimesincelastmanualformatselectionms(requestStarted);
	info.setVisibility(0);
	info.setVp9(false);
	vidReq.setInfo(info);

	//SessionInfo
	const sessionInfo = new pb.VideoPlaybackRequest_pb.SessionInfo();
	sessionInfo.setClientinfo(clientInfo);
	//TODO: sessionInfo.setPot();
	if(playbackCookie)
		sessionInfo.setPlaybackcookie(playbackCookie);
	vidReq.setSessioninfo(sessionInfo);

	//Formats
	const format = new pb.VideoPlaybackRequest_pb.FormatId();
	format.setItag(source.itag);
	format.setLmt(source.lastModified);
	if(source.xtags)
		format.setXtags(source.xtags);

	if(segmentIndex > 0) {
		const bufferedStream = new pb.VideoPlaybackRequest_pb.BufferedStreamInfo()
		bufferedStream.setFormatid(format);
		//TODO: bufferedStream.setBuffereddurationms();
		bufferedStream.setBufferedsegmentstartindex(1);
		bufferedStream.setBufferedsegmentendindex(segmentIndex - 1);
		bufferedStream.setBufferedstarttimems(0);
Kelvin's avatar
Kelvin committed
		//bufferedStream.setBuffereddurationms(playerPosMs);
Kelvin's avatar
Kelvin committed
		vidReq.setBufferedstreamsList[bufferedStream];
		vidReq.setDesiredstreamsList([format]);
	}
	if(source.mimeType.startsWith("video/")) {
		vidReq.setSupportedvideostreamsList([format]);
		info.setMediatypeflags(pb.VideoPlaybackRequest_pb.MediaType.VIDEO);
	}
	else if(source.mimeType.startsWith("audio/")) {
		vidReq.setSupportedaudiostreamsList([format]);
		info.setMediatypeflags(pb.VideoPlaybackRequest_pb.MediaType.AUDIO);
	}
	else throw new ScriptException("Unknown source format?");

	return vidReq;
}

Koen's avatar
Koen committed
class YTAudioSource extends AudioUrlRangeSource {
Kelvin's avatar
Kelvin committed
    constructor(obj, originalUrl) {
Koen's avatar
Koen committed
		super(obj);
Kelvin's avatar
Kelvin committed
		this.originalUrl = originalUrl;
Koen's avatar
Koen committed
    }

    getRequestModifier() {
Kelvin's avatar
Kelvin committed
        return new YTRequestModifier(this.originalUrl);
Koen's avatar
Koen committed
    }
}

class YTRequestModifier extends RequestModifier {
Kelvin's avatar
Kelvin committed
	constructor(originalUrl) {
Koen's avatar
Koen committed
		super({ allowByteSkip: false });
        this.requestNumber = 0;
Kelvin's avatar
Kelvin committed
		this.originalUrl = originalUrl;
		this.newUrl = null;
		this.newUrlCount = 0;
Koen's avatar
Koen committed
    }

	/**
	 * Modifies the request
	 * @param {string} url The URL string used
	 * @param {{[key: string]: string}} headers The headers used
	 * @returns {Request}
	 */
	modifyRequest(url, headers) {
		const u = new URL(url);
Kelvin's avatar
Kelvin committed
		const actualUrl = (this.newUrl) ? new URL(this.newUrl) : u;
Koen's avatar
Koen committed
		const isVideoPlaybackUrl = u.pathname.startsWith('/videoplayback');

		if (isVideoPlaybackUrl && !u.searchParams.has("rn")) {
Kelvin's avatar
Kelvin committed
			actualUrl.searchParams.set("rn", this.requestNumber.toString());
Koen's avatar
Koen committed
		}
		this.requestNumber++;

Kelvin's avatar
Kelvin committed
		if(this.newUrl) {
			log("BYPASS: Using NewURL For sources");
			log("BYPASS: OldUrl: " + u.toString());
			log("BYPASS: NewUrl: " + actualUrl.toString());
			log("BYPASS: Headers: " + JSON.stringify(headers));
		}
		

		let removedRangeHeader = undefined;
Koen's avatar
Koen committed
		if (headers["Range"] && !u.searchParams.has("range")) {
			let range = headers["Range"];
			if (range.startsWith("bytes=")) {
				range = range.substring("bytes=".length);
			}
Kelvin's avatar
Kelvin committed
			removedRangeHeader = headers["Range"];
Koen's avatar
Koen committed
			delete headers["Range"];
Kelvin's avatar
Kelvin committed
			actualUrl.searchParams.set("range", range);
Koen's avatar
Koen committed
		}

		const c = u.searchParams.get("c");
		if (c === "WEB" || c === "TVHTML5_SIMPLY_EMBEDDED_PLAYER") {
			headers["Origin"] = URL_BASE;
			headers["Referer"] = URL_BASE;
			headers["Sec-Fetch-Dest"] = "empty";
			headers["Sec-Fetch-Mode"] = "cors";
			headers["Sec-Fetch-Site"] = "cross-site";
		}
	
		headers['TE'] = "trailers";
Kelvin's avatar
Kelvin committed
		
		
		//I hate this
		//Workaround for seemingly active blocking
		/*
		const isValid = refetchClient.request("HEAD", actualUrl.toString(), headers);
		if(isValid.code == 403 && this.newUrlCount < 3) {
			const itag = actualUrl.searchParams.get("itag");
			bridge.toast("Youtube block detected (" + (this.newUrlCount + 1) + "), bypassing..");
			log("Detected 403, attempting bypass");
			try {
				const newDetailsResp = source.getContentDetails(this.originalUrl, false, true);
				if(newDetailsResp) {
					let source = newDetailsResp.video.videoSources.find(x=>x.itagId == itag);
					if(!source)
						source = newDetailsResp.video.audioSources.find(x=>x.itagId == itag);
					if(source) {
						this.newUrl = source.url;
						this.newUrlCount++;
						this.requestNumber = 0;
						log("Injecting new source url[" + source.name + "]: " + source.url);
						bridge.toast("Injecting new source url");
						if(removedRangeHeader)
							headers["Range"] = removedRangeHeader;
						return this.modifyRequest(url, headers);
					}
				}
				else
					bridge.toast("Bypass failed, couldn't reload [" + newDetailsResp.code + "]");
			}
			catch(ex) {
				bridge.toast("Bypass failed\n" + ex);
			}
		}
		*/
Koen's avatar
Koen committed

		if (c) {
			switch (c) {
				case "ANDROID":
					headers["User-Agent"] = USER_AGENT_ANDROID;
					break;
				case "IOS":
					headers["User-Agent"] = USER_AGENT_IOS;
					break;
				default:
					headers["User-Agent"] = USER_AGENT_WINDOWS;
					break;
			}
		}

        return {
Kelvin's avatar
Kelvin committed
            url: actualUrl.toString(),
Koen's avatar
Koen committed
			headers: headers
		}
    }
}

class YTLiveEventPager extends LiveEventPager {
	constructor(key, continuation) {
		super([], continuation != null);
		this.key = key;
		this.continuation = continuation;
		this.hasMore = true;
		this.knownEmojis = {};
		this.nextPage();
	}
	nextPage() {
		const newResult = http.POST(URL_LIVE_CHAT + "?key=" + this.key + "&prettyPrint=false", 
		JSON.stringify({
			context: {
				client: {
					clientName: "WEB",
					clientVersion: "2.20220901.00.00",
					clientFormFactor: "UNKNOWN_FORM_FACTOR",
					utcOffsetMinutes: 0,
					memoryTotalKbytes: 100000,
					timeZone: "ETC/UTC"
				},
				user: {
					lockedSafetyMode: false
				}
			},
			continuation: this.continuation,
			webClientInfo: {
				isDocumentHidden: false
			}
		}), {
			"Content-Type": "application/json",
			"User-Agent": USER_AGENT_WINDOWS
		}, false);
		if(!newResult.isOk)
			throw new ScriptException("Failed chat: " + newResult.body);
		const json = JSON.parse(newResult.body);
		//if(IS_TESTING)
		//	console.log("Live Chat Json:", json);
	
		const continuationArr = json?.continuationContents?.liveChatContinuation?.continuations;
		if(!continuationArr || continuationArr.length == 0) {
			this.hasMore = false;
			throw new ScriptException("No chat continuation found");
		}
		const continuation = continuationArr[0]?.timedContinuationData?.continuation ?? continuationArr[0]?.invalidationContinuationData?.continuation
		if(!continuation) {
			this.hasMore = false;
			throw new ScriptException("No chat continuation found");
		}
		this.continuation = continuation;
	
		const actions = json.continuationContents?.liveChatContinuation?.actions;
		if(IS_TESTING)
			console.log("Live Chat Actions:", actions);
Koen's avatar
Koen committed
		let events = [];
Koen's avatar
Koen committed
		if(actions && actions.length > 0) {
Koen's avatar
Koen committed
			const actionResults = handleYoutubeLiveEvents(actions);
			const emojiMap = actionResults.emojis;
			events = actionResults.events;
Koen's avatar
Koen committed

			let newEmojiCount = 0;
			for(let kv in emojiMap) {
				if(this.knownEmojis[kv])
					delete emojiMap[kv];
				else {
					this.knownEmojis[kv] = emojiMap[kv];
					newEmojiCount++;
				}
			}
			if(newEmojiCount > 0) {
				console.log("New Emojis:", emojiMap);
				events.unshift(new LiveEventEmojis(emojiMap));
			}
		}
		this.results = events;

		//if(IS_TESTING)
		//	console.log("LiveEvents:", this.results);
	
		return this;
	}
}
Koen's avatar
Koen committed
function handleYoutubeLiveEvents(actions) {
	let emojiMap = {};
	let events = [];
	for(let action of actions) {
		try {
			if(action.addChatItemAction) {
				const obj = action.addChatItemAction;

				const isPaid = !!obj.item?.liveChatPaidMessageRenderer

				const renderer = (isPaid) ? obj.item?.liveChatPaidMessageRenderer : obj.item?.liveChatTextMessageRenderer;
				const msgObj = extractLiveMessage_Obj(renderer);

				if(!msgObj)
					continue;

				if(msgObj.emojis)
					for(let emojiKey in msgObj.emojis)
						emojiMap[emojiKey] = msgObj.emojis[emojiKey];

				if(msgObj && msgObj.name && (msgObj.message || isPaid)) {
					if(!isPaid)
						events.push(new LiveEventComment(msgObj.name, msgObj.message, msgObj.thumbnail, msgObj.colorName, msgObj.badges));
					else {
						const amount = extractText_String(renderer.amount ?? renderer.purchaseAmountText ?? paidMessageRenderer?.amount ?? paidMessageRenderer?.purchaseAmountText);
						events.push(new LiveEventDonation(amount, msgObj.name, msgObj.message ?? "", msgObj.thumbnail, 0, renderer.bodyBackgroundColor ? "#" + Number(renderer.bodyBackgroundColor).toString(16) : null));
					}
				}
			}
			else if(action.ReplaceChatItemAction) {}
			else if(action.RemoveChatItemAction) {}
			else if(action.addLiveChatTickerItemAction) {
				const obj = action.addLiveChatTickerItemAction;
				if(obj.item?.liveChatTickerSponsorItemRenderer) {
					const renderer = obj.item?.liveChatTickerSponsorItemRenderer;
					const membershipRenderer = renderer.showItemEndpoint?.showLiveChatItemEndpoint?.renderer?.liveChatMembershipItemRenderer;
					const msgObj = extractLiveMessage_Obj(membershipRenderer);
					if(msgObj && msgObj.name)
						events.push(new LiveEventDonation("Member", msgObj.name, msgObj.message, msgObj.thumbnail, (renderer.durationSec ?? 10) * 1000, membershipRenderer.bodyBackgroundColor ? "#" + Number(membershipRenderer.bodyBackgroundColor).toString(16) : null));
				}
				else if(obj.item?.liveChatTickerPaidMessageItemRenderer) {
					const renderer = obj.item?.liveChatTickerPaidMessageItemRenderer
					const paidMessageRenderer = renderer.showItemEndpoint?.showLiveChatItemEndpoint?.renderer?.liveChatPaidMessageRenderer;
					const msgObj = extractLiveMessage_Obj(paidMessageRenderer);
					const amount = extractText_String(renderer.amount ?? renderer.purchaseAmountText ?? paidMessageRenderer?.amount ?? paidMessageRenderer?.purchaseAmountText);
					if(msgObj && msgObj.name)
						events.push(new LiveEventDonation(amount, msgObj.name, msgObj.message, msgObj.thumbnail, (renderer.durationSec ?? 10) * 1000, paidMessageRenderer.bodyBackgroundColor ? "#" + Number(paidMessageRenderer.bodyBackgroundColor).toString(16) : null));
				}
			}
			else if(action.addBannerToLiveChatCommand) {
				const bannerRenderer = action.addBannerToLiveChatCommand?.bannerRenderer?.liveChatBannerRenderer;
				const redirectRenderer = bannerRenderer?.contents?.liveChatBannerRedirectRenderer;

				if(bannerRenderer && redirectRenderer && bannerRenderer.bannerType == "LIVE_CHAT_BANNER_TYPE_CROSS_CHANNEL_REDIRECT") {
					
					const url = redirectRenderer.inlineActionButton?.buttonRenderer?.command?.commandMetadata?.webCommandMetadata?.url;
					const name = redirectRenderer.bannerMessage?.runs?.find(x=>x.bold)?.text;
					const thumbnails = redirectRenderer.authorPhoto?.thumbnails;
					
					if(url && name && thumbnails && thumbnails.length && thumbnails.length > 0)
						events.push(new LiveEventRaid(URL_BASE + url, name, thumbnails[thumbnails.length - 1]?.url));
				}
			}
			else {
				const keys = Object.keys(action);
				log("Unknown Event: " + keys.join(",") + JSON.stringify(action, null, "   "));
			}
		}
		catch(ex) {
			log("Failed Youtube live action parse due to [" + ex + "]: " + JSON.stringify(action, null, "   "));
		}
	}
	return {
		events: events,
		emojis: emojiMap
	};
}
source.handleYoutubeLiveEvents = handleYoutubeLiveEvents;

Koen's avatar
Koen committed
function extractLiveMessage_Obj(obj) {
	if(!obj)
		return null;
	const name = extractText_String(obj.authorName);
	const thumbnails = obj?.authorPhoto?.thumbnails;
	let thumbnail = null;
	for(let thumb of thumbnails){
		if(thumb?.url) {
			thumbnail = thumb.url;
			break;
		}
	}
	let message = extractText_String(obj.message);
	const headerMessage = extractText_String(obj.headerPrimaryText);

	const emojiMap = {};

	let isMember = false;
	const badges = [];
    if(obj.authorBadges) {
        for(let badge of obj.authorBadges) {
            const badgeImages = badge.liveChatAuthorBadgeRenderer?.customThumbnail?.thumbnails;
            const badgeName = badge.liveChatAuthorBadgeRenderer?.tooltip;
            if(badgeImages && badgeImages.length > 0 && badgeName) {
                emojiMap[badgeName] = badgeImages[badgeImages.length - 1].url;
                badges.push(badgeName);

Koen's avatar
Koen committed
                if(badgeName.toLowerCase().indexOf("member") >= 0)
Koen's avatar
Koen committed
                    isMember = true;
            }
        }
    }

	if(obj?.message?.runs) {
		for(let part of obj?.message?.runs) {
			if(part.emoji?.image?.accessibility?.accessibilityData?.label && part.emoji?.image?.thumbnails) {
			    const label = part.emoji?.image?.accessibility?.accessibilityData?.label;
			    if(label && !emojiMap[label]) {
                    emojiMap[label] = part.emoji?.image?.thumbnails[0]?.url;
			    }
			}
		}
	}
	return {
		name: name,
		thumbnail: thumbnail,
		message: message,
		headerMessage: headerMessage,
		emojis: emojiMap,
		colorName: isMember ? "#2ba640" : null,
		badges: badges
	};
}

class YTCommentPager extends CommentPager {
	constructor(comments, continuation, contextUrl, useLogin, useMobile) {
Koen's avatar
Koen committed
		super(comments, continuation != null, contextUrl);
		this.useLogin = !!useLogin;
		this.useMobile = !!useMobile;
Koen's avatar
Koen committed
		this.continuation = continuation;
	}
	nextPage() {
		if(!this.continuation)
			return new CommentPager([], false);
		return requestCommentPager(this.context, this.continuation, this.useLogin, this.useMobile) ?? new CommentPager([], false);
Koen's avatar
Koen committed
	}
}
class YTComment extends Comment {
	constructor(obj) {
		super(obj);
	}
}

class RichGridPager extends VideoPager {
	constructor(tab, context, useMobile = false, useAuth = false) {
		super(tab.videos, tab.videos.length > 0 && !!tab.continuation, context);
		this.continuation = tab.continuation;
		this.useMobile = useMobile;
		this.useAuth = useAuth;
	}
	
	nextPage() {
		this.context.page = this.context.page + 1;
		if(this.continuation) {
			const newData = validateContinuation(()=>requestBrowse({
				continuation: this.continuation.token
			}, !!this.useMobile, !!this.useAuth));
			if(newData && newData.length > 0) {
Koen's avatar
Koen committed
				const fakeRichGrid = {
					contents: newData
				};
				const newItemSection = extractRichGridRenderer_Shelves(fakeRichGrid, this.context);
Kelvin's avatar
Kelvin committed

				if(newItemSection.videos && newItemSection.videos.length == 0 && newItemSection.shelves && newItemSection.shelves.length > 0) {
				    if(IS_TESTING)
				        console.log("No videos in root found, checking shelves", newItemSection);
				    let vids = [];
				    for(let i = 0; i < newItemSection.shelves.length; i++) {
				        const shelf = newItemSection.shelves[i];
                        vids = vids.concat(shelf.videos);
				    }
				    newItemSection.videos = vids;
				}

Koen's avatar
Koen committed
				if(newItemSection.videos)
					return new RichGridPager(newItemSection, this.context, this.useMobile, this.useAuth);
			}
			else
				log("Call [RichGridPager.nextPage] continuation gave no appended items, setting empty page with hasMore to false");
		}
		this.hasMore = false;
		this.results = [];
		return this;
	}
}
Kelvin's avatar
Kelvin committed
class RichGridPlaylistPager extends PlaylistPager {
	constructor(tab, context, useMobile = false, useAuth = false) {
		super(tab.playlists, tab.playlists.length > 0 && !!tab.continuation, context);
Kelvin's avatar
Kelvin committed
		this.continuation = tab.continuation;
		if(!this.continuation && tab.subContinuations && tab.subContinuations.length == 1) {
			this.continuation = tab.subContinuations[0];
			this.hasMore = true;
		}
Kelvin's avatar
Kelvin committed
		this.useMobile = useMobile;
		this.useAuth = useAuth;
	}
	
	nextPage() {
		this.context.page = this.context.page + 1;
		if(this.continuation) {
			const newData = validateContinuation(()=>requestBrowse({
				continuation: (this.continuation.token) ? this.continuation.token : this.continuation
Kelvin's avatar
Kelvin committed
			}, !!this.useMobile, !!this.useAuth));
			if(newData && newData.length > 0) {
				const fakeRichGrid = {
					contents: newData
				};
				const newItemSection = extractRichGridRenderer_Shelves(fakeRichGrid, this.context);

				if(newItemSection.playlists && newItemSection.playlists.length == 0 && newItemSection.shelves && newItemSection.shelves.length > 0) {
				    if(IS_TESTING)
				        console.log("No playlists in root found, checking shelves", newItemSection);
				    let vids = [];
				    for(let i = 0; i < newItemSection.shelves.length; i++) {
				        const shelf = newItemSection.shelves[i];
                        vids = vids.concat(shelf.playlists);
				    }
				    newItemSection.playlists = vids;
				}

				if(newItemSection.playlists)
					return new RichGridPlaylistPager(newItemSection, this.context, this.useMobile, this.useAuth);
				if(!newItemSection.playlists) {
					log("No results from RichGridRenderer extraction, trying single-shelf");
					const shelf = extractGridRenderer_Shelf({
						items: newData
					}, this.context);
					if(shelf.playlists && shelf.playlists.length > 0) {
						return new RichGridPlaylistPager(shelf, this.context, this.useMobile, this.useAuth);
					}
				}
Kelvin's avatar
Kelvin committed
			}
			else
				log("Call [RichGridPager.nextPage] continuation gave no appended items, setting empty page with hasMore to false");
		}
		this.hasMore = false;
		this.results = [];
		return this;
	}
}
Koen's avatar
Koen committed
class SearchItemSectionVideoPager extends VideoPager {
	constructor(itemSection) {
		super(itemSection.videos, itemSection.videos.length > 0 && !!itemSection.continuation);
		this.continuation = itemSection.continuation;
	}
	
	nextPage() {
		this.context.page = this.context.page + 1;
		if(this.continuation) {
			const continueItems = validateContinuation(()=>
				requestSearchContinuation(this.continuation.token));
			if(continueItems.length > 0) {
				const fakeSectionList = {
					contents: continueItems
				};
				const newItemSection = extractSectionListRenderer_Sections(fakeSectionList, this.context);
				if(newItemSection.videos)
					return new SearchItemSectionVideoPager(newItemSection);
			}
		}
		this.hasMore = false;
		this.results = [];
		return this;
	}
}
class SearchItemSectionChannelPager extends ChannelPager {
	constructor(itemSection) {
		super(itemSection.channels, itemSection.channels.length > 0 && !!itemSection.continuation);
		this.continuation = itemSection.continuation;
	}

	nextPage() {
		this.context.page = this.context.page + 1;
		if(this.continuation) {
			const continueItems = validateContinuation(()=>
				requestSearchContinuation(this.continuation.token));
			if(continueItems.length > 0) {
				const fakeSectionList = {
					contents: continueItems
				};
				const newItemSection = extractSectionListRenderer_Sections(fakeSectionList, this.context);
				if(newItemSection.channels)
					return new SearchItemSectionChannelPager(newItemSection);
			}
		}
		this.hasMore = false;
		this.results = [];
		return this;
	}
}
class SearchItemSectionPlaylistPager extends ChannelPager {
	constructor(itemSection) {
		super(itemSection.playlists, itemSection.playlists.length > 0 && !!itemSection.continuation);